From f0b1c469f82e91012875b452544764e5fc51f5c4 Mon Sep 17 00:00:00 2001 From: anikitenko Date: Sun, 18 Oct 2026 12:40:13 +0300 Subject: [PATCH 1/2] fix(sdk): preserve safe SSH profile metadata from host Validate optional profile details while retaining older host compatibility. Exclude credentials and secret references from returned summaries. Align Git profile reuse documentation with current host behavior. --- docs/API_STABILITY.md | 2 +- docs/FDO_HOST_SECRETS_SSH_HANDOFF.md | 19 +++++---- docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md | 2 +- docs/SECRETS_AND_SSH.md | 49 ++++++++++++++++-------- src/types.ts | 12 +++++- src/utils/hostServices.ts | 22 ++++++++++- tests/utils/hostServices.test.ts | 47 +++++++++++++++++++++++ 7 files changed, 123 insertions(+), 30 deletions(-) diff --git a/docs/API_STABILITY.md b/docs/API_STABILITY.md index e87c00f..cb2c910 100644 --- a/docs/API_STABILITY.md +++ b/docs/API_STABILITY.md @@ -75,7 +75,7 @@ This document defines which SDK surfaces are stable contracts versus internal im - `HostServiceError` and exported service types - `createSecretsCapabilityBundle` and `createSSHCapabilityBundle` - base-plus-operation capability requirements and backend protocol v1 in [the service contract](./SECRETS_AND_SSH.md) - - host implementation is required; Git profile integration is a documented future extension, not a runtime API guarantee + - host implementation is required; profile-backed Git uses the existing operator API on compatible hosts; a dedicated Git client remains a future extension ## Provisional Surfaces diff --git a/docs/FDO_HOST_SECRETS_SSH_HANDOFF.md b/docs/FDO_HOST_SECRETS_SSH_HANDOFF.md index 5234366..9e4da95 100644 --- a/docs/FDO_HOST_SECRETS_SSH_HANDOFF.md +++ b/docs/FDO_HOST_SECRETS_SSH_HANDOFF.md @@ -43,19 +43,18 @@ Enforce separate handshake and command deadlines, host maximum ceilings, and a c ## Reusable profiles and Git over SSH -Implement the profile registry as a reusable host connection service, with authorization per plugin, consumer, target and operation. Follow the detailed [profile reuse and Git transport contract](./SECRETS_AND_SSH.md#reusing-ssh-profiles-for-git). It is part of this handoff and covers ownership, credentials, trust, transport options, endpoint matching, Git-only servers, repository policy and verification. +The current FDO host implements profile reuse through the existing `requestOperatorTool("git", payload)` operator path. The detailed [profile reuse and Git transport contract](./SECRETS_AND_SSH.md#reusing-ssh-profiles-for-git) documents this integration and its boundaries. The optional dedicated typed Git API proposed in the host repository is separate future work; do not add `this.git` or new `git.*` actions to use the existing path. -Keep the release boundary explicit: +Current host alignment: -- The current SDK implements secret storage and SSH execution facades. It has no profile-aware Git request, Git transport helper or new `git.*` permission family. -- Git consumers should reuse an authorized profile without receiving its secrets and without needing general `ssh.exec` permission. Require `ssh.use`, `secrets.use` + `secrets.consume`, and separate explicit Git operation/repository authorization. Existing CLI execution retains `system.process.exec` + `system.process.scope.git`; these grants alone do not authorize profile consumption. Define any future typed Git permissions and SDK request additions separately before exposing them. -- Profile access must not cross plugin namespaces. A profile ID conveys no grant. Host-owned flows manage profiles and trust. Edits, deletion, credential rotation and revocation must invalidate caches and prevent stale use. -- The Git CLI needs an explicit SSH transport integration. If choosing a shipped helper backed by host `ssh2`, use a binary-safe streaming channel and short-lived authenticated operation handles. Never pass private keys or passphrases through arguments, environment variables or temporary key files. The buffered `SSHClient.exec` API cannot carry Git transport data. -- Bundled SSH does not imply bundled Git. Select/package a Git implementation separately, or document a Git prerequisite. Verify Windows/macOS/Linux packaging for the actual choice. -- Match the effective remote hostname, port, username and repository to the chosen profile and policy. Control URL rewrites, transport overrides, custom helpers and repository settings. Reauthorize submodule and auxiliary destinations. Apply the same host-key verification to Git as to SSH commands. Support Git-only servers without requiring a general shell probe. -- Git HTTPS authentication is a separate universal-secret consumer; SSH profiles do not supply HTTPS authentication automatically. +- Git profile use requires `system.process.exec`, `system.process.scope.git`, `ssh.use`, `ssh.exec`, `secrets.use` and `secrets.consume`. The host currently requires the SSH execution child even for Git transport. Profile consumer, target and repository read/push policy remain authoritative; a Git-only profile still does not authorize shell commands. Plaintext `secrets.get` and `ssh.profiles.list` are not needed. +- Profiles remain within one plugin namespace. Different repositories on the same endpoint can select different profiles/keys via exact host-owned repository policy. One profile may serve multiple authorized repositories. Missing/ambiguous matches reject; profile IDs grant nothing. Cross-plugin sharing is not part of this contract. +- `ssh.profiles.list` filters to the `ssh.exec` consumer and therefore excludes Git-only profiles. It now returns optional safe `host`, `port`, `username`, `authenticationType` and `trustedHostKey` fields alongside id/label. The SDK validates each independently, preserves only these fields and remains compatible with older id/label-only hosts. Never return authentication objects, reference keys, credentials or command policies. No extra permission or profile CRUD action is added. +- The host ships a binary-safe SSH helper for Git; it must not use buffered `SSHClient.exec`. Credentials remain in the host, with authenticated short-lived operation handles and lifecycle cleanup. Preserve effective endpoint/repository matching and pinned host-key trust across URL resolution, submodules and auxiliary transfers. +- Plugin CLI operations use installed, allowlisted Git; FDO's separately bundled GitOps runtime is not a plugin profile transport. Packaged Windows/macOS/Linux behavior needs actual verification. Git over HTTPS credentials remain a separate integration. +- Enrolled profile mode persists across profile deletion. Missing setup must not silently fall back to machine SSH; explicit host Settings opt-out is required to restore legacy authentication. Keep host-authorized operation syntax, separate auxiliary-target authorization and transport overrides controlled by the host. -If Git integration is deferred, ship reusable profile storage and the documented extension boundary, and explicitly describe Git reuse as planned host work in release notes. Do not claim that existing `requestOperatorTool("git", ...)` requests automatically use these profiles. +Profile management and host-key enrollment remain host-owned. Profile edits/deletion, credential rotation and permission revocation invalidate affected work. Use the current FDO starter templates and host contract for supported CLI forms; do not claim every Git flag is authorized by the SDK's generic process payload type. ## Release compatibility diff --git a/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md b/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md index 8eb8221..15c5020 100644 --- a/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md +++ b/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md @@ -6,7 +6,7 @@ This document defines the SDK-side contract for host-mediated privileged operati Universal secrets and builtin SSH use a separate authenticated backend service bridge. Their canonical contract is [Universal secrets and builtin SSH](./SECRETS_AND_SSH.md); the implementation message is [FDO host secrets and SSH handoff](./FDO_HOST_SECRETS_SSH_HANDOFF.md). These services are not additions to the iframe privileged-action request union or validator below. -The contract includes base-plus-operation permissions (`secrets.use` and `ssh.use`), host-owned storage and profiles, credential references, trust verification, profile reuse for Git with separate consumer authorization, Git transport requirements, and minor release compatibility. The SDK facade requires host implementation. Git profile integration is documented future host work, not a shipped SDK Git transport. +The contract includes base-plus-operation permissions (`secrets.use` and `ssh.use`), host-owned storage and profiles, credential references, trust verification, profile reuse for Git with separate consumer authorization, Git transport requirements, and minor release compatibility. The SDK facade requires host implementation. Current FDO hosts provide profile-backed Git through the existing Git operator path. This SDK does not ship a Git transport or a dedicated Git client; see the canonical service contract for host grants and profile matching. ## Goal diff --git a/docs/SECRETS_AND_SSH.md b/docs/SECRETS_AND_SSH.md index 2eea2f0..2e3e67f 100644 --- a/docs/SECRETS_AND_SSH.md +++ b/docs/SECRETS_AND_SSH.md @@ -64,7 +64,7 @@ Raw secret entry should preferably use a host-owned credential UI. If backend pl ## SSH -`ssh.listProfiles()` returns authorized `{ id, label }` summaries. `ssh.exec({ profileId, command, timeoutMs?, maxOutputBytes? })` executes one noninteractive remote shell command. Defaults are a 30-second command deadline and 1 MiB combined stdout/stderr capture. The host may impose lower ceilings. Results contain `stdout`, `stderr`, `exitCode`, `signal`, `durationMs`, and `truncated`. A nonzero remote exit is a successful transport result; transport and service failures reject. +`ssh.listProfiles()` returns command-authorized profile summaries with `id` and `label`, plus optional `host`, `port`, `username`, `authenticationType` (`password` or `privateKey`) and `trustedHostKey`. Each optional field is validated independently and absent on older hosts. Credentials, secret reference keys, authentication objects and command policies are excluded by the SDK allowlist. Listing still requires only `ssh.use` + `ssh.profiles.list`, with no new permission or secret read grant. The fingerprint is public trust metadata, not a private key. These summaries are not an inventory of Git-only profiles: the current host filters this action to the `ssh.exec` consumer. `ssh.exec({ profileId, command, timeoutMs?, maxOutputBytes? })` executes one noninteractive remote shell command. Defaults are a 30-second command deadline and 1 MiB combined stdout/stderr capture. The host may impose lower ceilings. Results contain `stdout`, `stderr`, `exitCode`, `signal`, `durationMs`, and `truncated`. A nonzero remote exit is a successful transport result; transport and service failures reject. Profiles belong to the host. The exported `SSHConnectionProfile` describes an id, label, host, port, username, authentication references, and a trusted SHA256 host-key fingerprint. Password and private-key authentication are supported by the contract. Profiles must be authorized for the caller, with credential references resolved in its namespace. Profile creation, credential entry and host-key enrollment occur through host-owned flows in this initial API. @@ -74,18 +74,32 @@ This initial API supports buffered execution. Interactive terminals, SFTP, forwa ## Reusing SSH profiles for Git -SSH profiles describe connection identity and trust, so the host should reuse the same profile model for remote command execution and Git over SSH. Reuse means resolving an authorized profile through a common host service. It does not mean copying credentials into Git configuration or sharing secrets across plugins. +FDO now reuses plugin-owned SSH profiles for Git through the existing `requestOperatorTool("git", payload)` path. Configure the profile's Git consumer, exact repository paths and read/push policy in Settings → Credentials & SSH. Reuse is within the same plugin namespace; cross-plugin sharing is not supported. It never copies credentials into plugin code or repository settings. ### Authorization and profile ownership -| Consumer | Required permissions and policy | Status in this SDK | +| Consumer | Required permissions and policy | Current integration | | --- | --- | --- | -| List SSH profiles | `ssh.use` + `ssh.profiles.list`; filter to caller-authorized profiles | Implemented facade | -| Execute a remote shell command | `ssh.use` + `ssh.exec`, `secrets.use` + `secrets.consume`, profile/target/command policy | Implemented facade | -| Run the existing local Git operator preset | `system.process.exec` + `system.process.scope.git`, with host executable/argument/cwd/env policy | Existing API; no SSH profile binding | -| Use a profile for Git clone/fetch/push | `ssh.use`, `secrets.use` + `secrets.consume`, and separately authorized Git operation/repository/target policy | Future host integration; no profile-aware Git request in v1 | +| List SSH command profiles | `ssh.use` + `ssh.profiles.list`; caller-owned, command-authorized profiles only | `this.ssh.listProfiles()` with optional safe metadata | +| Execute a remote shell command | `ssh.use` + `ssh.exec`, `secrets.use` + `secrets.consume`, profile/target/command policy | `this.ssh.exec(...)` | +| Local Git operator execution | `system.process.exec` + `system.process.scope.git`, host executable/argument/cwd/env policy | Existing operator preset | +| Profile-backed Git | Both process grants above, `ssh.use` + `ssh.exec`, `secrets.use` + `secrets.consume`, Git-consumer/repository/operation policy | Existing operator preset with current FDO host wiring | -Git transport use must not require `ssh.exec` just to authenticate: that capability authorizes arbitrary policy-approved shell commands through the SSH helper. Likewise, `ssh.exec` must not grant Git repository access, fetch permission, push permission, or local Git execution. A future Git transport must have its own specific operation authorization paired with its base access permission. This release introduces no `git.*` capability names or Git service actions. When integrating through the existing local Git operator path, retain its two existing process permissions and add explicit host policy for which Git operations may consume which SSH profiles. Merely granting that process scope must not expose secret material or enable profile consumption. +The current host requires six grants for profile-backed Git, including `ssh.exec` as the child of `ssh.use`. This does not bypass consumer or command policy: a Git-only profile still cannot execute general shell commands. Neither `ssh.exec` nor the process scope alone grants repository access. `secrets.get` and `ssh.profiles.list` are not needed for Git authentication. No `this.git`, `git.*` capability family or Git-specific host service action ships in this SDK. A future typed Git API is a separate proposal, not a prerequisite for current Git reuse. + +Declare the current Git permissions without requesting unnecessary profile listing or plaintext reads: + +```typescript +import { createCapabilityBundle, createOperatorToolCapabilityPreset } from "@anikitenko/fdo-sdk"; + +// Return from declareCapabilities(). The host approves each grant independently. +const capabilities = createCapabilityBundle([ + ...createOperatorToolCapabilityPreset("git"), + "ssh.use", "ssh.exec", "secrets.use", "secrets.consume", +]); +``` + +The host selects a profile from the effective repository URL and exact repository policy, not a plugin-supplied profile override. Two repositories on the same server and username can use different keys; one profile may authorize multiple repositories. Missing and ambiguous matches reject. Configure those bindings in Settings; `this.ssh.listProfiles()` does not list Git-only bindings and must not be used as a Git repository picker. Keep profile IDs stable within their owner namespace. Resolve the ID against the authenticated caller and the intended consumer every time; an ID is not an access token. The host may keep authorization policy alongside a profile, but this SDK's `SSHConnectionProfile` type has no consumer-policy fields. Do not silently reinterpret a profile ID from a different plugin. Cross-plugin profile/secret sharing requires a future explicit ownership and grant contract; v1 supports reuse by authorized consumers within one plugin namespace only. @@ -93,13 +107,11 @@ Profile creation, edits, deletion, secret selection and trust enrollment remain ### Git transport integration -Git over SSH requires an explicit transport implementation. The ordinary Git CLI does not automatically discover an in-process `ssh2` connection. Git can invoke an SSH command through `GIT_SSH`/`GIT_SSH_COMMAND`; the latter is interpreted by a shell. A host-owned helper executable is one possible integration point. [Git transport environment documentation](https://git-scm.com/docs/git#Documentation/git.txt-GITSSH). - -For an installation without an external SSH executable, the host can ship a helper that forwards Git's binary stdin/stdout over an authenticated, operation-scoped host channel backed by `ssh2`. The helper must carry only an opaque short-lived operation handle and connection metadata, never plaintext credentials in command arguments or environment variables. The host owns the remote Git service command and enforces the approved repository and operation. Treat this as a design option requiring implementation and platform tests, not a transport already provided by this SDK. Another Git engine with explicit SSH transport support would need equivalent authorization and trust enforcement. +The current host supplies a bundled binary-safe streaming helper backed by `ssh2` for profile-backed Git. Git invokes the host-controlled helper through its transport integration. Operation-scoped authenticated handles identify the authorized work; SSH credentials stay in the host and never enter Git arguments, environment variables, configuration or temporary private-key files. Plugin code must not set `GIT_SSH_COMMAND` or implement its own helper. This SDK supplies the existing operator request API; it does not ship the helper or open Git transport streams itself. Do not implement Git transport using `this.ssh.exec()`: its UTF-8, buffered, output-limited result is intended for command output and cannot carry Git's bidirectional binary protocol. A Git transport needs binary-safe streaming, stdin EOF/half-close handling, backpressure, separate stderr, exit status, cancellation, deadlines and disconnect cleanup. Map read/fetch to the approved read-side Git service and push to the approved write-side service. Never let a transport caller turn that mapping into unrestricted remote command execution. -Bundling `ssh2` removes the external SSH executable requirement for the SSH helper only. It does not bundle Git. If the host chooses the Git CLI, ship and maintain it or explicitly document it as a prerequisite on each platform. A completely builtin Git experience needs a separately selected Git implementation. Do not advertise one based solely on this SDK release. +The current FDO plugin operator path uses an installed, allowlisted Git executable with the host's pinned SSH helper; an external SSH executable is not required for profile mode. Git itself is a separate prerequisite for plugin CLI operations. FDO GitOps has its own bundled Git runtime and does not automatically consume plugin profiles. Do not infer plugin Git availability from GitOps or SDK installation. ### Targets, trust and credential compatibility @@ -113,9 +125,14 @@ A server may allow Git service commands while denying a general shell. For examp Git over HTTPS uses API tokens or other HTTPS credentials from the universal secret store through a separately authorized consumer; an SSH profile does not describe HTTPS authentication. This release adds no HTTPS Git credential helper. -### Host verification for future Git reuse +### Current host behavior and verification + +Profile mode is persistent after enrolling a Git-authorized profile. Removing the last profile must not silently fall back to machine SSH credentials. SSH-capable plugins without enrollment fail closed; only an explicit host Settings opt-out enables legacy authentication. Legacy behavior is separate and must not expose saved profile credentials. + +The current host supports authorized URL/named-remote Git operations, including clone/fetch/push, pull, submodules and selected LFS commands under operation-specific policy. Use supported syntax and an allowlisted Git executable/cwd; arbitrary config, custom environment/stdin, force/deletion/mirror pushes and SSH overrides are rejected. Auxiliary repositories require separate profile matching. LFS HTTPS origins are restricted by host policy. The supported forms evolve in FDO, so use the installed host's contract and starter template rather than inferring unrestricted Git support from the SDK preset. + -Before advertising profile-aware Git, verify clone/fetch/push against a controlled SSH Git server on packaged Windows, macOS and Linux hosts. Cover restricted Git-only accounts, encrypted private keys, wrong/missing/changed host keys, mismatched URL/profile endpoints, repository read versus push permissions, malicious repository transport settings, unauthorized submodule destinations, concurrent operations and profile edits, revocation/deletion, binary integrity, backpressure, helper handle replay, timeouts and cleanup. Confirm that private keys and passphrases never appear in process arguments, environment, Git config, temporary files, logs or UI responses. These are host integration checks, not assertions made by the SDK unit tests. +For packaged-host acceptance, verify clone/fetch/push against a controlled SSH Git server on packaged Windows, macOS and Linux hosts. Cover restricted Git-only accounts, encrypted private keys, wrong/missing/changed host keys, mismatched URL/profile endpoints, repository read versus push permissions, malicious repository transport settings, unauthorized submodule destinations, concurrent operations and profile edits, revocation/deletion, binary integrity, backpressure, helper handle replay, timeouts and cleanup. Confirm that private keys and passphrases never appear in process arguments, environment, Git config, temporary files, logs or UI responses. These are host integration checks, not assertions made by the SDK unit tests. ## Minor release scope and migration @@ -125,9 +142,9 @@ Existing plugins need no changes unless adopting the new services. Adopting plug Older hosts can continue running existing plugins, but cannot execute the new service APIs until their backend transport is installed. New capabilities must be recognized by the host's capability parser and consent UI. The current SDK handshake does not advertise host readiness for these services: a new SDK version or successful SDK handshake alone is insufficient. Before enabling feature UI, the host must provide its own readiness/availability signal or handle unavailable service calls explicitly. Do not silently fall back to plaintext storage or unrestricted system SSH. -Release notes must distinguish the implemented SDK facade and protocol from the required FDO host encryption/network implementation. Profile reuse for Git is a documented host integration design; no profile-aware Git method, transport helper or new Git permission family ships in this release. Interactive SSH, SFTP, agent forwarding, streaming SSH events and caller cancellation also remain outside v1. End-to-end cross-platform claims require packaged host verification. +Release notes must distinguish the implemented SDK facade and protocol from the required FDO host encryption/network implementation. Current FDO hosts can reuse profiles through the existing Git operator API. No dedicated Git client, transport helper or new Git permission family ships inside this SDK package; these host features require a compatible FDO version. Interactive SSH, SFTP, agent forwarding, streaming SSH events and caller cancellation also remain outside v1. End-to-end cross-platform claims require packaged host verification. -Use the repository's Release Please workflow for the version and changelog update. A conventional `feat` commit describes the additive feature; do not mark it as a breaking change or manually promise Git integration in the release title. The current package is 1.7.1; the next minor is 1.8.0 if no intervening release changes that baseline. +Use the repository's Release Please workflow for the version and changelog update. A conventional `feat` commit describes the additive feature; do not mark it as a breaking change or manually promise Git integration in the release title. The safe profile metadata fields are an additive extension to the SDK 1.8 service contract; older hosts returning only id/label remain compatible. Let Release Please determine the actual next version from the release baseline and commit history. ## Backend transport protocol v1 diff --git a/src/types.ts b/src/types.ts index 4c27e59..6322d86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -777,7 +777,17 @@ export type HostServiceCapability = /** References are names in the authenticated plugin namespace, not bearer credentials. */ export type SecretReference = Readonly<{ kind: "secret"; key: string }>; export type SecretMetadata = { key: string; updatedAt: string }; -export type SSHProfileSummary = { id: string; label: string }; +/** Safe host metadata only. Optional fields are absent on older hosts. */ +export type SSHProfileSummary = { + id: string; + label: string; + host?: string; + port?: number; + username?: string; + authenticationType?: "password" | "privateKey"; + /** Enrolled public host-key fingerprint, never a credential or secret reference. */ + trustedHostKey?: string; +}; export type SSHAuthentication = | { type: "password"; passwordRef: SecretReference } | { type: "privateKey"; privateKeyRef: SecretReference; passphraseRef?: SecretReference }; diff --git a/src/utils/hostServices.ts b/src/utils/hostServices.ts index 201b3d7..cd624b9 100644 --- a/src/utils/hostServices.ts +++ b/src/utils/hostServices.ts @@ -120,7 +120,27 @@ export class SSHClient { if (!record(result) || !Array.isArray(result.profiles)) invalidResponse(); return (result.profiles as unknown[]).map(profile => { if (!record(profile) || typeof profile.id !== "string" || !profile.id || typeof profile.label !== "string") invalidResponse(); - return { id: profile.id as string, label: profile.label as string }; + const summary: SSHProfileSummary = { id: profile.id as string, label: profile.label as string }; + for (const field of ["host", "username"] as const) { + const value = profile[field]; + if (value !== undefined) { + if (typeof value !== "string" || !value.trim() || /[\0\r\n]/.test(value)) invalidResponse(); + summary[field] = value; + } + } + if (profile.port !== undefined) { + if (typeof profile.port !== "number" || !Number.isInteger(profile.port) || profile.port < 1 || profile.port > 65535) invalidResponse(); + summary.port = profile.port; + } + if (profile.authenticationType !== undefined) { + if (profile.authenticationType !== "password" && profile.authenticationType !== "privateKey") invalidResponse(); + summary.authenticationType = profile.authenticationType; + } + if (profile.trustedHostKey !== undefined) { + if (typeof profile.trustedHostKey !== "string" || profile.trustedHostKey.length !== 50 || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(profile.trustedHostKey)) invalidResponse(); + summary.trustedHostKey = profile.trustedHostKey; + } + return summary; }); } diff --git a/tests/utils/hostServices.test.ts b/tests/utils/hostServices.test.ts index 62fdca0..4a7da3f 100644 --- a/tests/utils/hostServices.test.ts +++ b/tests/utils/hostServices.test.ts @@ -201,3 +201,50 @@ test("SSH profile listing needs no secret access when both SSH permissions are g .toEqual(["ssh.profiles.list", "ssh.use"]); expect(describeCapability("ssh.use").category).toBe("ssh"); }); + +describe("SSH profile metadata", () => { + const summary = { + id: "production", label: "Production", host: "git.example.com", port: 2222, + username: "git", authenticationType: "privateKey" as const, + trustedHostKey: `SHA256:${"A".repeat(43)}`, + }; + + test("preserves safe host fields while excluding credentials, references and policies", async () => { + const request = configure(vi.fn().mockResolvedValue({ profiles: [{ + ...summary, + password: "SECRET", privateKey: "SECRET", passphrase: "SECRET", + authentication: { type: "privateKey", privateKeyRef: { kind: "secret", key: "private-key-name" } }, + privateKeyRef: { kind: "secret", key: "private-key-name" }, + policy: { consumers: ["git", "ssh.exec"], commands: ["private-command"] }, + }] })); + PluginRegistry.configureCapabilities({ granted: ["ssh.use", "ssh.profiles.list"] }); + await expect(ssh.listProfiles()).resolves.toEqual([summary]); + expect(request).toHaveBeenCalledWith({ action: "ssh.profiles.list", payload: {} }); + }); + + test.each(Object.entries(summary).filter(([field]) => !["id", "label"].includes(field)))( + "accepts independently supplied optional metadata %s", async (field, value) => { + const partial = { id: "old", label: "Older host", [field]: value }; + configure(vi.fn().mockResolvedValue({ profiles: [partial] })); + await expect(ssh.listProfiles()).resolves.toEqual([partial]); + }); + + test("preserves password authentication metadata and supports empty profile lists", async () => { + configure(vi.fn().mockResolvedValueOnce({ profiles: [{ ...summary, authenticationType: "password" }] }) + .mockResolvedValueOnce({ profiles: [] })); + await expect(ssh.listProfiles()).resolves.toEqual([{ ...summary, authenticationType: "password" }]); + await expect(ssh.listProfiles()).resolves.toEqual([]); + }); + + test.each([ + { host: null }, { host: "" }, { host: 123 }, { host: "server\nSECRET" }, + { username: null }, { username: " " }, { username: {} }, { username: "user\0SECRET" }, + { port: null }, { port: "22" }, { port: 0 }, { port: 65536 }, { port: 22.5 }, + { authenticationType: null }, { authenticationType: "agent" }, { authenticationType: {} }, + { trustedHostKey: null }, { trustedHostKey: "SECRET" }, { trustedHostKey: {} }, + { trustedHostKey: `SHA256:${"A".repeat(43)}\n` }, + ])("rejects malformed optional metadata without echoing host values: %j", async invalid => { + configure(vi.fn().mockResolvedValue({ profiles: [{ ...summary, ...invalid }] })); + await expect(ssh.listProfiles()).rejects.toThrow("Invalid host service response."); + }); +}); From edb27291b30abfb55bb74566f2e092e3016c43e8 Mon Sep 17 00:00:00 2001 From: anikitenko Date: Sun, 18 Oct 2026 12:57:34 +0300 Subject: [PATCH 2/2] fix(sdk): update documentation --- docs/SECRETS_AND_SSH.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SECRETS_AND_SSH.md b/docs/SECRETS_AND_SSH.md index 2e3e67f..c9ea021 100644 --- a/docs/SECRETS_AND_SSH.md +++ b/docs/SECRETS_AND_SSH.md @@ -18,7 +18,7 @@ Declare permissions through the existing `declareCapabilities()` hook. Declarati | `secrets.consume` | Allow an authorized host service to consume a referenced secret (requires `secrets.use`) | | `ssh.use` | Base permission to access SSH services; grants no operation by itself | | `ssh.exec` | Execute a remote command on an authorized host profile | -| `ssh.profiles.list` | List authorized profile IDs and labels | +| `ssh.profiles.list` | List authorized SSH command profile IDs and labels, plus optional host, port, username, authentication type and trusted host-key fingerprint; excludes credentials, secret references and command policies | Every secret operation requires **`secrets.use` plus the specific operation capability**: for example, reading requires `secrets.use` + `secrets.get`, and listing requires `secrets.use` + `secrets.list`. Neither the base permission alone nor an operation alone is sufficient. Permission to write never implicitly permits reading or deletion.