diff --git a/README.md b/README.md index 25daf14..cbcb342 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,12 @@ For operator UX and diagnostics, the SDK also provides structured capability rem For a dedicated authoring guide, see [docs/OPERATOR_PLUGIN_PATTERNS.md](./docs/OPERATOR_PLUGIN_PATTERNS.md). +## Universal Secrets and SSH + +Plugin backend code can use `this.secrets` for host-managed universal secret storage and `this.ssh` for builtin SSH execution against authorized connection profiles. Declare the required secret operations and SSH capabilities through `declareCapabilities()`. The FDO host must configure the backend service transport; calls fail closed until host support is installed. Secrets are separate from JSON storage. + +See [secrets and SSH usage and protocol](./docs/SECRETS_AND_SSH.md) and [FDO host implementation handoff](./docs/FDO_HOST_SECRETS_SSH_HANDOFF.md). + ## Storage Notes - `PluginRegistry.useStore("default")` returns an in-memory store scoped to the active plugin. diff --git a/docs/API_STABILITY.md b/docs/API_STABILITY.md index 4b6d31f..e87c00f 100644 --- a/docs/API_STABILITY.md +++ b/docs/API_STABILITY.md @@ -54,6 +54,7 @@ This document defines which SDK surfaces are stable contracts versus internal im - `registerHandler` - `useStore` - `registerStore` + - `configureHostServices` - `configureStorage` - `configureCapabilities` - Documented decorator behavior (`handleError`) @@ -68,6 +69,14 @@ This document defines which SDK surfaces are stable contracts versus internal im - `createScopedWorkflowRequest` - `requestScopedWorkflow` +- Backend secret and SSH service surfaces: + - `FDO_SDK.secrets` / `SecretStore` + - `FDO_SDK.ssh` / `SSHClient` + - `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 + ## Provisional Surfaces - Store lifecycle capability metadata shape diff --git a/docs/FDO_HOST_SECRETS_SSH_HANDOFF.md b/docs/FDO_HOST_SECRETS_SSH_HANDOFF.md new file mode 100644 index 0000000..5234366 --- /dev/null +++ b/docs/FDO_HOST_SECRETS_SSH_HANDOFF.md @@ -0,0 +1,70 @@ +# Message for the FDO host implementation agent + +Implement host support for the SDK's universal secret storage and builtin SSH helper. Read the SDK's `docs/SECRETS_AND_SSH.md`, `src/utils/hostServices.ts`, and the host-service types in `src/types.ts` first. These files define the exact initial API and protocol. The SDK side is a facade; actual secure persistence and SSH execution must be implemented in FDO. + +## Integration and permissions + +1. Add an authenticated backend worker-to-host request/reply service. Before plugin initialization, call `PluginRegistry.configureHostServices({ request })` inside that plugin's isolated worker. The adapter must be bound to the trusted worker/plugin identity. It accepts `HostServiceRequest` and returns the action result directly, without the legacy privileged-action envelope. Use unique request IDs, bounded pending requests, disconnect cleanup and sanitized errors. Never derive identity from plugin-provided payload or metadata. +2. Integrate `secrets.set`, `secrets.get`, `secrets.delete`, `secrets.list`, `secrets.use`, `secrets.consume`, `ssh.use`, `ssh.exec`, and `ssh.profiles.list` into capability discovery, plugin installation/consent, grants, preflight and revocation. `declareCapabilities()` requests grants; it does not authorize operations. Reflect granted capabilities through the existing worker capability configuration. Independently enforce host grants on every operation; workers can bypass SDK checks. Revocation must affect existing service access. +3. Treat `secrets.use` as the base capability declaring intent to access the secret store. Each operation ALSO requires its specific capability: `secrets.use` + `secrets.get` for reads, `secrets.use` + `secrets.list` for listing, `secrets.use` + `secrets.set` for writes, and `secrets.use` + `secrets.delete` for deletion. Neither the base capability alone nor the operation alone authorizes access. Apply the same two-level permission model to SSH: `ssh.use` is the base permission, and each operation requires its specific grant too. SSH execution requires `ssh.use` + `ssh.exec`, as well as `secrets.use` + `secrets.consume`, plus profile/target authorization and a command policy. `secrets.consume` is the specific operation for host-side consumption of a secret reference; it does not return plaintext to the plugin. Profile listing requires `ssh.use` + `ssh.profiles.list` and lists only caller-authorized entries. `secrets.get` is not required to use SSH credential references. +4. Keep this bridge out of the iframe and generic UI action path. Add host-owned credential/profile UI. A plugin may receive plaintext through backend `secrets.get` when granted; never expose it through diagnostics or automatically forward it to UI. + +SDK declaration example (the bundle helpers automatically include the base permission): + +```typescript +import { createCapabilityBundle, createSecretsCapabilityBundle, createSSHCapabilityBundle } from "@anikitenko/fdo-sdk"; + +// Return this from the plugin's declareCapabilities(): +const requestedCapabilities = createCapabilityBundle([ + ...createSecretsCapabilityBundle(["get", "list"]), + ...createSSHCapabilityBundle(), +]); +// Includes secrets.use, secrets.get, secrets.list, secrets.consume, +// ssh.use, ssh.exec and ssh.profiles.list. The host still decides which requests to grant. +``` + +## Universal secret service + +Implement per-plugin namespaced `set/get/delete/list`, matching the exact payload/result table in `docs/SECRETS_AND_SSH.md`. Support arbitrary UTF-8 strings, including API credentials, passwords, PEM keys, passphrases, and serialized JSON. Missing values return null; empty strings are valid. Locked/unavailable storage must reject, never masquerade as a missing secret. + +Use OS-protected encryption at rest on Windows/macOS/Linux, with host-owned key management. Evaluate [Electron safeStorage](https://www.electronjs.org/docs/latest/api/safe-storage) for the shipped Electron version. On Linux explicitly inspect the selected encryption backend: do not silently accept basic_text or any plaintext fallback. If a secure provider is unavailable or locked, fail closed with a useful host UI remediation. Store only ciphertext and nonsecret metadata, with restrictive permissions and atomic writes. Never place plaintext in SDK JSON storage, source files, environment variables, logs or backups. Protect key/value size limits, quotas, concurrency, replacement and crash consistency. + +Treat keys as case-sensitive logical names using the SDK's key grammar, not filesystem paths. Bind namespaces to stable host installation/plugin identity. Define uninstall cleanup and upgrade identity continuity explicitly. No cross-plugin access or shared secrets are part of v1. References are `{ kind: "secret", key }`; resolve them in the authenticated plugin's namespace. Validate stored reference ownership when a profile is created and again when used. Use after deletion must fail; do not retain stale decrypted credentials indefinitely. + +## Builtin SSH service + +Bundle [`ssh2`](https://github.com/mscdex/ssh2) as a host runtime dependency and package it for Windows, macOS and Linux. Do not require an external ssh executable. Implement `ssh.profiles.list` and `ssh.exec` exactly as specified. Profiles are configured through host-owned flows; the SDK exports `SSHConnectionProfile` as the profile contract. A profile contains id/label, host, port, username, password or private-key references, optional passphrase reference, and a trusted SHA256 host-key fingerprint. Restrict references to the caller's namespace in v1. + +Require explicit host-key verification. ssh2's default accepts host keys when no hostVerifier is supplied: always set a verifier and reject missing/mismatched trust. Enroll/change trust through a deliberate host UI flow; changed keys must not be silently accepted. Be precise about fingerprint encoding: OpenSSH SHA256 fingerprints use base64 digest without padding, while ssh2 hostHash provides hexadecimal; normalize before comparing or hash the raw key yourself. + +Validate profile ownership, host/port policy, command policy, numeric limits and all payload fields independently. Do not expose unrestricted SSH merely by adding ssh to a local executable allowlist. Remote commands are shell strings; do not imply argv-style injection safety or normalize arbitrary commands into something else. + +Enforce separate handshake and command deadlines, host maximum ceilings, and a combined stdout/stderr byte capture limit. Return stdout/stderr, nullable exitCode/signal, durationMs, and truncated. Consume/drain excess output without retaining it and mark truncation. Nonzero command exits return results; timeouts, authentication, connection or host-key failures reject. Never automatically retry mutating commands. Handle null exit status and premature disconnection explicitly. Decode UTF-8 across chunk boundaries correctly. Release channels, sockets, listeners and pending requests on completion, failure, timeout, revocation and plugin/host teardown. Document that channel closure cannot guarantee remote process termination. + +## 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. + +Keep the release boundary explicit: + +- 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. + +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. + +## Release compatibility + +Read [minor release scope and migration](./SECRETS_AND_SSH.md#minor-release-scope-and-migration). The SDK additions are additive, but the host must recognize all new capability names, enforce the base-plus-operation model and install the backend transport before enabling the feature. The SDK handshake alone does not establish host readiness. Existing plugins need no new grants unless adopting these services. Never infer secret/SSH grants from existing storage or process permissions, and never fall back to insecure storage or an unrestricted SSH process. + +## Errors, audit and verification + +Transport failures expose only a safe code: ACCESS_DENIED, UNAVAILABLE, LOCKED, TIMEOUT, HOST_KEY_REJECTED, CONNECTION_FAILED, or INVALID_REQUEST. Provider messages must not leak credentials. Audit identity, action, outcome and request ID without secret values or request/response bodies. Commands and stdout/stderr may also contain secrets, so do not log them by default. + +Add host tests covering cross-plugin access and forged identities, each missing capability, revocation, unavailable/locked secure backends, absence of plaintext persistence/logging, missing and empty values, overwrites/deletion, malformed requests, unauthorized profiles, correct/mismatched/missing host keys, password and encrypted private-key authentication, output caps, UTF-8 chunk boundaries, nonzero/null exits, timeouts, disconnects and cleanup. Test the packaged host on all three platforms without an installed SSH executable. The SDK test suite mocks the bridge and does not verify real encryption or networking. + +Do not claim complete integration until a real plugin can save a credential through host UI/backend storage and execute SSH with a reference on the packaged host. Retain synchronous SDK lifecycle hooks: register async backend handlers during init rather than making init/render async. diff --git a/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md b/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md index c8bd7d8..8eb8221 100644 --- a/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md +++ b/docs/HOST_PRIVILEGED_ACTIONS_CONTRACT.md @@ -2,6 +2,12 @@ This document defines the SDK-side contract for host-mediated privileged operations. +## Backend secrets, SSH and reusable connection profiles + +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. + ## Goal Allow narrowly scoped privileged operations without granting plugins broad filesystem escape from `PLUGIN_HOME`. diff --git a/docs/SECRETS_AND_SSH.md b/docs/SECRETS_AND_SSH.md new file mode 100644 index 0000000..2eea2f0 --- /dev/null +++ b/docs/SECRETS_AND_SSH.md @@ -0,0 +1,149 @@ +# Universal secrets and builtin SSH + +The SDK exposes `this.secrets` and `this.ssh` in plugin backend code. The FDO host must supply a backend transport using `PluginRegistry.configureHostServices(...)` before plugin initialization. Until then, calls fail closed. The SDK does not persist secrets, load encryption keys, or open SSH sockets itself. Host support is required for these APIs to work. + +These services are separate from `PluginRegistry.useStore(...)`. JSON storage is suitable for nonsecret preferences and connection profile IDs. Do not put secret values in it. + +## Capabilities + +Declare permissions through the existing `declareCapabilities()` hook. Declaration requests host grants; it does not grant them. The host authorizes them, configures the worker's granted capabilities, and independently checks each service operation. SDK checks are a convenience, not a security boundary. + +| Capability | Operation | +| --- | --- | +| `secrets.use` | Base permission to access the secret store; grants no operation by itself | +| `secrets.set` | Create or replace a secret | +| `secrets.get` | Read a plaintext secret into plugin backend memory | +| `secrets.delete` | Delete a secret | +| `secrets.list` | List names and update timestamps, never values | +| `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 | + +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. + +SSH follows the same two-level model: execution requires `ssh.use` + `ssh.exec`, and profile listing requires `ssh.use` + `ssh.profiles.list`. Neither the base permission alone nor an operation alone is sufficient. Execution additionally requires `secrets.use` + `secrets.consume` for credential references. It does not require `secrets.get`. `secrets.consume` authorizes host-side use of references without returning plaintext. Profile listing is independent of execution. + +`createSecretsCapabilityBundle(["get", "list"])` returns `secrets.use`, `secrets.get`, and `secrets.list` (sorted and deduplicated). `createSSHCapabilityBundle()` includes `ssh.use`, `ssh.exec`, `ssh.profiles.list`, `secrets.use`, and `secrets.consume`. + +```typescript +import { + FDO_SDK, createCapabilityBundle, + createSecretsCapabilityBundle, createSSHCapabilityBundle, +} from "@anikitenko/fdo-sdk"; + +class OperationsPlugin extends FDO_SDK { + declareCapabilities() { + return createCapabilityBundle([ + ...createSecretsCapabilityBundle(["set", "delete"]), + ...createSSHCapabilityBundle(), + ]); + } + + init(): void { + // Register backend handlers here. Lifecycle hooks remain synchronous. + } + + render(): string { return "
Remote operations
"; } + + async inspectServer() { + const profiles = await this.ssh.listProfiles(); + if (!profiles.length) throw new Error("Configure an SSH connection in FDO first."); + return this.ssh.exec({ profileId: profiles[0].id, command: "uptime" }); + } +} +``` + +## Secrets + +`set(key, value)` creates or replaces a UTF-8 string. Tokens, passwords, PEM private keys, passphrases, and JSON-serialized credential objects all fit the same API. Empty strings are valid. `get(key)` returns a string or `null` for an absent key; locked or unavailable storage rejects instead. `delete(key)` reports whether an entry existed. `list()` returns `{ key, updatedAt }[]`. + +Keys are case-sensitive opaque names: 1–128 ASCII letters, digits, dots, underscores or hyphens, beginning with a letter or digit. Keys must not contain confidential data, since names are visible as metadata. `ref(key)` returns `{ kind: "secret", key }` without contacting the host or granting access. References resolve only in the authenticated caller's namespace. They are not globally usable bearer tokens. + +Raw secret entry should preferably use a host-owned credential UI. If backend plugin code receives or reads a secret, that code can access its plaintext; secure storage does not sandbox it from that plugin. Never return secret values in UI handler responses or include them in logs or diagnostics. The SDK exposes no iframe secret transport. + +## 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. + +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. + +The host should bundle `ssh2`, so users need no installed SSH executable on Windows, macOS or Linux. Remote command syntax still depends on the remote shell. A command string is shell input: do not concatenate untrusted input. The host must apply its own command policy and reject unauthorized targets and commands. Do not automatically retry commands that might have already executed. + +This initial API supports buffered execution. Interactive terminals, SFTP, forwarding, agent integration, streaming events and explicit caller cancellation are future extensions. The host must enforce deadlines and dispose channels/connections on worker teardown. Closing a channel does not guarantee the remote process has stopped. + +## 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. + +### Authorization and profile ownership + +| Consumer | Required permissions and policy | Status in this SDK | +| --- | --- | --- | +| 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 | + +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. + +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. + +Profile creation, edits, deletion, secret selection and trust enrollment remain host-owned UI flows. No SDK profile CRUD API ships in v1. An edited host, port, username, credential reference or trust key must invalidate affected cached authentication/session state and undergo authorization again. Removing a profile or secret, revoking grants or unloading the plugin must prevent subsequent use; the host must define how it terminates active operations. Never pool authenticated connections across plugin identities or consumers with different grants. Serialize profile revisions or snapshot them per operation so a concurrent edit cannot mix old trust with new credentials. + +### 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. + +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. + +### Targets, trust and credential compatibility + +A selected profile must match the effective Git destination after URL resolution: hostname, port and SSH username. Account for `ssh://` URLs, scp-style remotes, aliases, nondefault ports and URL rewrites. Reject conflicting destinations instead of sending a profile's credentials to whatever a repository config specifies. Authorize repository paths and read/write intent separately. Treat submodules, recursive operations, Git LFS and other auxiliary transfers as new destinations needing their own authorization; they do not inherit blanket credential access. + +Protect the transport selection from repository configuration, environment overrides, custom remote helpers, hooks and caller-supplied SSH commands. Sanitize host-injected Git execution settings; isolate the approved transport from untrusted local configuration. Any future use of `GIT_SSH_COMMAND` must quote correctly on the actual platform and must not interpolate secrets or untrusted command fragments. Credentials must not be persisted into repository URLs, `.git/config`, temporary private-key files or trace logs by this integration. + +Use the same enrolled host-key trust policy for Git as for command execution. Trust is bound to the endpoint and enrolled key, not just a display label. A key change requires a deliberate host trust flow for every affected consumer; never disable verification to make Git work. Do not implicitly inherit user SSH config, jump hosts, proxy commands or agent forwarding. Such features need explicit future contracts and policy. Authentication support depends on the server and the chosen Git transport; a password or key accepted for a shell account is not necessarily accepted by a Git provider. + +A server may allow Git service commands while denying a general shell. For example, `git-shell` is designed for restricted Git access. Do not test profile validity by requiring `uptime`, an interactive shell, or a successful `ssh -T` exit. Validate the intended operation and interpret the server's actual result. [Git restricted-shell documentation](https://git-scm.com/docs/git-shell). + +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 + +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. + +## Minor release scope and migration + +This is an additive SDK feature suitable for a minor release when included with the associated implementation. Public additions are `this.secrets`, `this.ssh`, `SecretStore`, `SSHClient`, `HostServiceError`, `PluginRegistry.configureHostServices`, capability bundle helpers and their exported types. Existing storage and privileged-action envelopes retain their behavior. + +Existing plugins need no changes unless adopting the new services. Adopting plugins must declare base and operation permissions, obtain host grants, and call services from backend handlers while keeping lifecycle hooks synchronous. Never migrate ordinary JSON store contents wholesale into secret storage or derive new grants from old storage/process grants. Explicitly move only user-selected secrets through an authorized host flow and remove plaintext copies as appropriate. + +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. + +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. + +## Backend transport protocol v1 + +Configure one transport per isolated plugin worker/runtime. The host must bind it to a trusted plugin identity from worker bootstrap, never an identity supplied by the plugin or derived from mutable metadata. `PluginRegistry.clearPlugin()` clears the adapter; the host must also revoke its endpoint and close in-flight work. Reconfiguration invalidates in-flight SDK responses. A transport has a single asynchronous `request(HostServiceRequest)` method. It resolves with the result object below, without a privileged-action envelope. + +| Action | Payload | Result | +| --- | --- | --- | +| `secrets.set` | `{ key, value }` | `{ stored: true }` | +| `secrets.get` | `{ key }` | `{ value: string or null }` | +| `secrets.delete` | `{ key }` | `{ deleted: boolean }` | +| `secrets.list` | `{}` | `{ entries: SecretMetadata[] }` | +| `ssh.profiles.list` | `{}` | `{ profiles: SSHProfileSummary[] }` | +| `ssh.exec` | `{ profileId, command, timeoutMs, maxOutputBytes }` | `SSHExecResult` | + +Failures reject with a safe `code`: `ACCESS_DENIED`, `UNAVAILABLE`, `LOCKED`, `TIMEOUT`, `HOST_KEY_REJECTED`, `CONNECTION_FAILED`, or `INVALID_REQUEST`. The SDK wraps provider failures in `HostServiceError`, preserving only these codes and suppressing arbitrary provider messages. Unknown failures become `UNAVAILABLE`. Validation and local capability failures also reject before dispatch. SDK validation errors never interpolate secret values or host response data. + +No request accepts a plugin ID, filesystem path, encryption key or raw SSH authentication material. The host must validate all requests independently, reject unknown fields, limit secret size and namespace quotas, enforce capabilities using its own policy, and avoid logging secret request/response bodies. This interface is intentionally backend-only; never route it through `window.createBackendReq` or the generic UI privileged-action handler. + +See [the host implementation handoff](./FDO_HOST_SECRETS_SSH_HANDOFF.md) for the corresponding host work. diff --git a/src/PluginRegistry.ts b/src/PluginRegistry.ts index 2e99312..1dd28a4 100644 --- a/src/PluginRegistry.ts +++ b/src/PluginRegistry.ts @@ -1,4 +1,5 @@ import path from "path"; +import { configureHostServices } from "./utils/hostServices"; import { FDO_SDK, StoreType } from "./index"; import { PluginMetadata } from "./PluginMetadata"; import { @@ -11,6 +12,7 @@ import { SidePanelConfig, CapabilityConfiguration, StorageConfiguration, + HostServicesConfiguration, StoreFactory, StoreFactoryContext, StoreRegistration, @@ -60,6 +62,11 @@ export class PluginRegistry { }; private static readonly storeInstances: Record> = {}; + /** Host bootstrap only; bind this transport to the worker's authenticated plugin identity. */ + public static configureHostServices(configuration: HostServicesConfiguration | null): void { + configureHostServices(configuration); + } + public static configureStorage(configuration: StorageConfiguration): void { this.storageConfiguration = { ...this.storageConfiguration, @@ -303,6 +310,7 @@ export class PluginRegistry { } public static clearPlugin(): void { + configureHostServices(null); void this.disposeStoreScope(this.getPluginScopeForLogging(this.pluginInstance)); this.pluginInstance = null; this.pluginMetadata = null; diff --git a/src/index.ts b/src/index.ts index 9536f23..f76c4b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,12 @@ import {Logger} from "./Logger"; import "./ambient/fdo-host"; import {Communicator} from "./Communicator"; import {PluginRegistry} from "./PluginRegistry"; +import { SecretStore, SSHClient } from "./utils/hostServices"; import type { RenderOnLoadOutput } from "./types"; import { createNoopRenderOnLoadSource, resolveRenderOnLoadSource } from "./utils/renderOnLoad"; import { SDK_API_VERSION } from "./version"; +export { SecretStore, SSHClient, HostServiceError } from "./utils/hostServices"; export { atomicWriteFile, atomicWriteFileSync } from "./utils/atomic"; export { BLUEPRINT_V6_ICON_NAMES, isBlueprintV6IconName } from "./utils/blueprintIcons"; export { @@ -21,6 +23,8 @@ export { runWithSudo } from "./utils/runWithSudo"; export { emitDeprecationWarning, formatDeprecationMessage } from "./utils/deprecation"; export { createAICapabilityBundle, + createSecretsCapabilityBundle, + createSSHCapabilityBundle, createCapabilityBundle, createFilesystemCapabilityBundle, createNetworkCapabilityBundle, @@ -160,6 +164,10 @@ export class FDO_SDK { public static readonly API_VERSION: string = SDK_API_VERSION static readonly TYPE_TAG = Symbol("FDO_SDK") private _logger: Logger = new Logger({ context: { component: "FDO_SDK" } }) + /** Backend-only, host-managed universal secret storage. */ + public readonly secrets: SecretStore = new SecretStore(); + /** Backend-only SSH execution using host-managed profiles. */ + public readonly ssh: SSHClient = new SSHClient(); private loggerScope: string = "global"; private readonly communicator: Communicator = new Communicator() diff --git a/src/types.ts b/src/types.ts index b4a4474..4c27e59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,6 +144,7 @@ export type PluginCapability = | "storage.json" | StorageBackendCapability | AICapability + | HostServiceCapability | "system.network" | NetworkTransportCapability | NetworkScopeCapability @@ -414,6 +415,8 @@ export type AICapabilityPresetDefinition = { export type CapabilityCategory = | "ai" | "storage" + | "secrets" + | "ssh" | "network" | "network-scope" | "sudo" @@ -451,6 +454,7 @@ export type CapabilityPreflightReport = { action: string; declared: string[]; granted: string[]; + /** Declared capabilities whose own grant or any prerequisite grant is missing. */ missing: CapabilityPreflightMissingDiagnostic[]; undeclaredGranted: CapabilityDescriptor[]; remediations: string[]; @@ -764,3 +768,63 @@ export type DiagnosticFixTemplate = { steps: string[]; docs?: string[]; }; + +/** Secret operations require secrets.use plus an operation grant; SSH consumes references without secrets.get. */ +export type HostServiceCapability = + | "secrets.set" | "secrets.get" | "secrets.delete" | "secrets.list" | "secrets.use" | "secrets.consume" + | "ssh.use" | "ssh.exec" | "ssh.profiles.list"; + +/** 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 }; +export type SSHAuthentication = + | { type: "password"; passwordRef: SecretReference } + | { type: "privateKey"; privateKeyRef: SecretReference; passphraseRef?: SecretReference }; + +/** Host-owned profile shape. Plugins select an existing authorized profile by id. */ +export type SSHConnectionProfile = SSHProfileSummary & { + host: string; + port: number; + username: string; + authentication: SSHAuthentication; + /** SHA256 fingerprint enrolled through a trusted host flow. Missing/mismatched keys must fail. */ + trustedHostKey: string; +}; +export type SSHExecOptions = { + profileId: string; + /** Remote shell command. The host must enforce its command policy. */ + command: string; + /** Default 30 seconds; the host may impose a lower ceiling. */ + timeoutMs?: number; + /** Combined stdout/stderr byte limit; default 1 MiB. */ + maxOutputBytes?: number; +}; +export type SSHExecResult = { + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + durationMs: number; + truncated: boolean; +}; + +/** Backend-only protocol v1. No plugin id, storage path or raw SSH credentials are accepted. */ +export type HostServiceRequest = + | { action: "secrets.set"; payload: { key: string; value: string } } + | { action: "secrets.get" | "secrets.delete"; payload: { key: string } } + | { action: "secrets.list" | "ssh.profiles.list"; payload: Record } + | { action: "ssh.exec"; payload: Required }; + +/** + * Host bootstrap adapter, bound to a trusted plugin identity and lifecycle. + * request resolves with the action result (not the legacy privileged-action envelope) + * and rejects on transport, authorization or service failures. No logging of payloads. + */ +export type HostServicesConfiguration = { + request(request: HostServiceRequest): Promise; +}; + +export type HostServiceErrorCode = + | "ACCESS_DENIED" | "UNAVAILABLE" | "LOCKED" | "TIMEOUT" + | "HOST_KEY_REJECTED" | "CONNECTION_FAILED" | "INVALID_REQUEST"; diff --git a/src/utils/capabilities.ts b/src/utils/capabilities.ts index 191c3d2..3569d38 100644 --- a/src/utils/capabilities.ts +++ b/src/utils/capabilities.ts @@ -5,6 +5,7 @@ import { CapabilityDescriptor, MissingCapabilityDiagnostic, PluginCapability, + HostServiceCapability, } from "../types"; type CapabilityAudit = { @@ -71,6 +72,12 @@ function buildMissingCapabilityErrorMessage(capability: PluginCapability, action function getCapabilityPrerequisites(capability: string): string[] { const prerequisites = new Set(); + if (capability.startsWith("secrets.") && capability !== "secrets.use") prerequisites.add("secrets.use"); + if (capability.startsWith("ssh.") && capability !== "ssh.use") prerequisites.add("ssh.use"); + if (capability === "ssh.exec") { + prerequisites.add("secrets.use"); + prerequisites.add("secrets.consume"); + } if (capability.startsWith(STORAGE_BACKEND_CAPABILITY_PREFIX)) { prerequisites.add("storage"); @@ -304,6 +311,26 @@ export function createNetworkCapabilityBundle( } export function describeCapability(capability: PluginCapability | string): CapabilityDescriptor { + if (["secrets.set", "secrets.get", "secrets.delete", "secrets.list", "secrets.use", "secrets.consume"].includes(capability)) { + return { + capability, + label: `Secrets: ${capability.slice("secrets.".length)}`, + description: capability === "secrets.use" + ? "Base permission to access the host-managed secret store; pair with specific secret operation capabilities." + : "Allows this operation in the plugin's secret namespace when paired with secrets.use. The host enforces authorization.", + category: "secrets", + }; + } + if (capability === "ssh.use" || capability === "ssh.exec" || capability === "ssh.profiles.list") { + return { + capability, + label: capability === "ssh.use" ? "SSH Access" : capability === "ssh.exec" ? "SSH Execution" : "SSH Profiles List", + description: capability === "ssh.use" + ? "Base permission to access SSH services; pair with specific SSH operation capabilities." + : "Allows this operation on authorized profiles when paired with ssh.use. Execution also requires secrets.use and secrets.consume.", + category: "ssh", + }; + } if (capability === "system.ai") { return { capability, @@ -543,7 +570,6 @@ export function runCapabilityPreflight(options: { const grantedSet = new Set(granted); const missing: CapabilityPreflightMissingDiagnostic[] = declared - .filter((capability) => !grantedSet.has(capability)) .map((capability) => { const descriptor = describeCapability(capability); const prerequisites = getCapabilityPrerequisites(capability); @@ -562,7 +588,8 @@ export function runCapabilityPreflight(options: { missingPrerequisites, grantedPrerequisites, }; - }); + }) + .filter((entry) => !grantedSet.has(entry.capability) || entry.missingPrerequisites.length > 0); const undeclaredGranted = granted .filter((capability) => !declaredSet.has(capability)) @@ -573,6 +600,8 @@ export function runCapabilityPreflight(options: { ); const summary = missing.length === 0 ? "All declared capabilities are granted." + : missing.some((entry) => grantedSet.has(entry.capability)) + ? `Missing capabilities or prerequisites for ${missing.length} declared capability${missing.length === 1 ? "" : "ies"} (${missing.map((entry) => entry.capability).join(", ")}).` : `Missing ${missing.length} declared capability${missing.length === 1 ? "" : "ies"} (${missing.map((entry) => entry.capability).join(", ")}).`; return { @@ -608,3 +637,19 @@ export function resetCapabilityStateForTests(): void { delete audit.deniedCount[key]; } } + +/** Declare only the operations required by the plugin; there is no implicit full-access grant. */ +export function createSecretsCapabilityBundle( + operations: Array<"set" | "get" | "delete" | "list" | "consume" | "use"> = ["get"] +): PluginCapability[] { + if (!Array.isArray(operations) || !operations.length + || operations.some(operation => !["set", "get", "delete", "list", "consume", "use"].includes(operation))) { + throw new Error("At least one supported secret operation is required."); + } + return createCapabilityBundle(["secrets.use", ...operations.map(operation => `secrets.${operation}` as HostServiceCapability)]); +} + +/** SSH uses references inside the host, so plaintext secret reads are not required. */ +export function createSSHCapabilityBundle(): PluginCapability[] { + return createCapabilityBundle(["ssh.use", "ssh.exec", "ssh.profiles.list", "secrets.use", "secrets.consume"]); +} diff --git a/src/utils/contracts.ts b/src/utils/contracts.ts index 55f7468..6a456f7 100644 --- a/src/utils/contracts.ts +++ b/src/utils/contracts.ts @@ -25,6 +25,8 @@ export interface PluginInitPayload { } const KNOWN_PLUGIN_CAPABILITIES = new Set([ + "secrets.set", "secrets.get", "secrets.delete", "secrets.list", "secrets.use", "secrets.consume", + "ssh.use", "ssh.exec", "ssh.profiles.list", "system.ai", "system.ai.assistants.list", "system.ai.request", diff --git a/src/utils/hostServices.ts b/src/utils/hostServices.ts new file mode 100644 index 0000000..201b3d7 --- /dev/null +++ b/src/utils/hostServices.ts @@ -0,0 +1,156 @@ +import type { + HostServiceRequest, HostServicesConfiguration, SecretMetadata, SecretReference, + SSHExecOptions, SSHExecResult, SSHProfileSummary, HostServiceErrorCode, +} from "../types"; +import { requireCapability } from "./capabilities"; + +/** Safe, stable host failure code. Provider messages and payloads are deliberately excluded. */ +export class HostServiceError extends Error { + readonly code: HostServiceErrorCode; + constructor(code: HostServiceErrorCode) { + super(`Host service request failed (${code}).`); + this.name = "HostServiceError"; + this.code = code; + } +} + +let configuration: Readonly | undefined; + +/** Host bootstrap only. The transport must be bound to a trusted plugin identity. */ +export function configureHostServices(value: HostServicesConfiguration | null): void { + if (value !== null && (!value || typeof value.request !== "function")) { + throw new Error("Host services require a request transport."); + } + configuration = value === null ? undefined : Object.freeze({ request: value.request.bind(value) }); +} + +function validKey(key: unknown): asserts key is string { + // Keys are opaque names, never filesystem paths. Reject rather than normalize aliases. + if (typeof key !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(key)) { + throw new Error("Secret key must be 1–128 ASCII letters, digits, dots, underscores or hyphens, starting with a letter or digit."); + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function invalidResponse(): never { + // Never interpolate host data: a malformed response could contain a secret. + throw new Error("Invalid host service response."); +} + +async function request(request: HostServiceRequest): Promise { + if (typeof window !== "undefined") { + throw new Error("Host services are only available in the plugin backend runtime."); + } + if (request.action.startsWith("ssh.")) requireCapability("ssh.use", "access SSH services"); + if (request.action.startsWith("secrets.") || request.action === "ssh.exec") { + requireCapability("secrets.use", "access the secret store"); + } + requireCapability(request.action, "access host services"); + if (request.action === "ssh.exec") { + requireCapability("secrets.consume", "consume secret references for SSH execution"); + } + const transport = configuration; + if (!transport) { + throw new Error("Host services are unavailable. The FDO host must configure a backend transport before plugin initialization."); + } + let result: unknown; + try { + result = await transport.request(request); + } catch (error) { + const codes: HostServiceErrorCode[] = ["ACCESS_DENIED", "UNAVAILABLE", "LOCKED", "TIMEOUT", "HOST_KEY_REJECTED", "CONNECTION_FAILED", "INVALID_REQUEST"]; + const code = record(error) && codes.includes(error.code as HostServiceErrorCode) + ? error.code as HostServiceErrorCode : "UNAVAILABLE"; + // Provider errors must not accidentally leak credentials or request payloads. + throw new HostServiceError(code); + } + if (configuration !== transport) throw new HostServiceError("UNAVAILABLE"); + return result; +} + +/** Universal plugin-scoped secret storage. Persistence and isolation belong to the host. */ +export class SecretStore { + /** Create or replace a UTF-8 string (tokens, PEM keys, passwords or serialized credentials). */ + async set(key: string, value: string): Promise { + validKey(key); + if (typeof value !== "string") throw new Error("Secret value must be a string."); + const result = await request({ action: "secrets.set", payload: { key, value } }); + if (!record(result) || result.stored !== true) invalidResponse(); + } + + /** Returns null only for a missing key. Unavailable/locked storage must reject. */ + async get(key: string): Promise { + validKey(key); + const result = await request({ action: "secrets.get", payload: { key } }); + if (!record(result) || (result.value !== null && typeof result.value !== "string")) invalidResponse(); + return result.value as string | null; + } + + async delete(key: string): Promise { + validKey(key); + const result = await request({ action: "secrets.delete", payload: { key } }); + if (!record(result) || typeof result.deleted !== "boolean") invalidResponse(); + return result.deleted as boolean; + } + + /** Returns names and timestamps only. Secret values are never part of this result. */ + async list(): Promise { + const result = await request({ action: "secrets.list", payload: {} }); + if (!record(result) || !Array.isArray(result.entries)) invalidResponse(); + return (result.entries as unknown[]).map(entry => { + if (!record(entry) || typeof entry.updatedAt !== "string" || !Number.isFinite(Date.parse(entry.updatedAt))) invalidResponse(); + validKey(entry.key); + return { key: entry.key as string, updatedAt: entry.updatedAt as string }; + }); + } + + /** A reference grants no access; the host resolves it in the authenticated caller's namespace. */ + ref(key: string): SecretReference { + validKey(key); + return Object.freeze({ kind: "secret" as const, key }); + } +} + +/** Builtin SSH facade. The host ships the SSH implementation and owns connection profiles. */ +export class SSHClient { + async listProfiles(): Promise { + const result = await request({ action: "ssh.profiles.list", payload: {} }); + 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 }; + }); + } + + /** Buffered noninteractive execution; remote nonzero exits are returned, transport failures reject. */ + async exec(options: SSHExecOptions): Promise { + if (!record(options) || typeof options.profileId !== "string" || !options.profileId.trim() + || typeof options.command !== "string" || !options.command.trim() || options.command.includes("\0")) { + throw new Error("SSH execution requires a profileId and a nonempty command without NUL characters."); + } + for (const value of [options.timeoutMs, options.maxOutputBytes]) { + if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) { + throw new Error("SSH timeout and output limits must be positive safe integers."); + } + } + // Project allowed fields so arbitrary caller properties cannot cross the boundary. + const result = await request({ action: "ssh.exec", payload: { + profileId: options.profileId, + command: options.command, + timeoutMs: options.timeoutMs ?? 30_000, + maxOutputBytes: options.maxOutputBytes ?? 1_048_576, + } }); + if (!record(result) || typeof result.stdout !== "string" || typeof result.stderr !== "string" + || (result.exitCode !== null && !Number.isSafeInteger(result.exitCode)) + || (result.signal !== null && typeof result.signal !== "string") + || typeof result.durationMs !== "number" || !Number.isFinite(result.durationMs) || result.durationMs < 0 + || typeof result.truncated !== "boolean") invalidResponse(); + return { + stdout: result.stdout as string, stderr: result.stderr as string, + exitCode: result.exitCode as number | null, signal: result.signal as string | null, + durationMs: result.durationMs as number, truncated: result.truncated as boolean, + }; + } +} diff --git a/tests/utils/capabilities.test.ts b/tests/utils/capabilities.test.ts index 822cbd2..b1da532 100644 --- a/tests/utils/capabilities.test.ts +++ b/tests/utils/capabilities.test.ts @@ -280,3 +280,58 @@ describe("capabilities", () => { expect(parseMissingCapabilityError(new Error("something else"))).toBeNull(); }); }); + +describe("preflight prerequisites for granted operations", () => { + const sshPrerequisites = ["ssh.use", "secrets.use", "secrets.consume"]; + + test.each(Array.from({ length: 8 }, (_, mask) => ({ + grantedPrerequisites: sshPrerequisites.filter((_, index) => (mask & (1 << index)) !== 0), + missingPrerequisites: sshPrerequisites.filter((_, index) => (mask & (1 << index)) === 0).sort(), + })))("checks granted ssh.exec with prerequisites $grantedPrerequisites", ({ grantedPrerequisites, missingPrerequisites }) => { + const report = runCapabilityPreflight({ + declared: ["ssh.exec"], + granted: ["ssh.exec", ...grantedPrerequisites], + }); + + expect(report.ok).toBe(missingPrerequisites.length === 0); + if (missingPrerequisites.length === 0) { + expect(report.missing).toEqual([]); + expect(report.remediations).toEqual([]); + return; + } + expect(report.missing).toHaveLength(1); + expect(report.missing[0]).toMatchObject({ + capability: "ssh.exec", + requiredCapabilities: ["secrets.consume", "secrets.use", "ssh.exec", "ssh.use"], + missingPrerequisites, + grantedPrerequisites: [...grantedPrerequisites].sort(), + }); + expect(report.remediations).toHaveLength(1); + for (const prerequisite of missingPrerequisites) { + expect(report.remediations[0]).toContain(`"${prerequisite}"`); + } + expect(report.summary).toBe("Missing capabilities or prerequisites for 1 declared capability (ssh.exec)."); + }); + + test.each([ + { capability: "secrets.get", prerequisite: "secrets.use" }, + { capability: "ssh.profiles.list", prerequisite: "ssh.use" }, + { capability: "storage.json", prerequisite: "storage" }, + { capability: "system.process.scope.git", prerequisite: "system.process.exec" }, + ])("checks $prerequisite for granted $capability", ({ capability, prerequisite }) => { + const report = runCapabilityPreflight({ declared: [capability], granted: [capability] }); + expect(report.ok).toBe(false); + expect(report.missing[0]).toMatchObject({ capability, missingPrerequisites: [prerequisite] }); + expect(runCapabilityPreflight({ declared: [capability], granted: [capability, prerequisite] }).ok).toBe(true); + }); + + test("reports missing operations and granted operations with unmet prerequisites together", () => { + const report = runCapabilityPreflight({ + declared: ["ssh.exec", "secrets.list", "secrets.list"], + granted: ["ssh.exec", "ssh.use", "secrets.consume"], + }); + expect(report.ok).toBe(false); + expect(report.missing.map(entry => entry.capability)).toEqual(["secrets.list", "ssh.exec"]); + expect(report.missing.every(entry => entry.missingPrerequisites.includes("secrets.use"))).toBe(true); + }); +}); diff --git a/tests/utils/hostServices.test.ts b/tests/utils/hostServices.test.ts new file mode 100644 index 0000000..62fdca0 --- /dev/null +++ b/tests/utils/hostServices.test.ts @@ -0,0 +1,203 @@ +import { + PluginRegistry, SecretStore, SSHClient, HostServiceError, + createSecretsCapabilityBundle, createSSHCapabilityBundle, + describeCapability, runCapabilityPreflight, validatePluginInitPayload, +} from "../../src"; +import { resetCapabilityStateForTests } from "../../src/utils/capabilities"; + +const secrets = new SecretStore(); +const ssh = new SSHClient(); +const execResult = { stdout: "ok", stderr: "", exitCode: 0, signal: null, durationMs: 5, truncated: false }; + +beforeEach(() => { + resetCapabilityStateForTests(); + PluginRegistry.configureHostServices(null); +}); +afterEach(() => { + vi.unstubAllGlobals(); + PluginRegistry.configureHostServices(null); + resetCapabilityStateForTests(); +}); + +function configure(request = vi.fn().mockResolvedValue(execResult)) { + PluginRegistry.configureCapabilities({ granted: [ + ...createSecretsCapabilityBundle(["get", "set", "delete", "list", "use"]), + ...createSSHCapabilityBundle(), + ] }); + PluginRegistry.configureHostServices({ request }); + return request; +} + +test("fails closed without a host, even when capabilities were granted", async () => { + configure(); + PluginRegistry.configureHostServices(null); + await expect(secrets.get("token")).rejects.toThrow("unavailable"); +}); + +test.each(["get", "set", "delete", "list"] as const)("requires separate secrets.%s permission before calling host", async operation => { + const transport = configure(); + PluginRegistry.configureCapabilities({ granted: ["secrets.use"] }); + const call = operation === "set" ? secrets.set("key", "value") + : operation === "list" ? secrets.list() : secrets[operation]("key"); + await expect(call).rejects.toThrow(`secrets.${operation}`); + expect(transport).not.toHaveBeenCalled(); +}); + +test.each([{ granted: [] }, { granted: ["secrets.use"] }, { granted: ["ssh.exec"] }, { granted: ["ssh.exec", "secrets.use"] }, { granted: ["ssh.exec", "secrets.consume"] }, { granted: ["ssh.use", "secrets.use", "secrets.consume"] }, { granted: ["ssh.exec", "secrets.use", "secrets.consume"] }] as const)("SSH requires execution, base secret access and secret consumption grants: %j", async ({ granted }) => { + const transport = configure(); + PluginRegistry.configureCapabilities({ granted: [...granted] }); + await expect(ssh.exec({ profileId: "prod", command: "uptime" })).rejects.toThrow("Capability"); + expect(transport).not.toHaveBeenCalled(); +}); + +test("secret lifecycle uses backend transport; missing is distinct from an empty value", async () => { + const request = configure(vi.fn() + .mockResolvedValueOnce({ stored: true }) + .mockResolvedValueOnce({ value: "" }) + .mockResolvedValueOnce({ value: null }) + .mockResolvedValueOnce({ deleted: true }) + .mockResolvedValueOnce({ deleted: false })); + await secrets.set("api-token", ""); + await expect(secrets.get("api-token")).resolves.toBe(""); + await expect(secrets.get("missing")).resolves.toBeNull(); + await expect(secrets.delete("api-token")).resolves.toBe(true); + await expect(secrets.delete("api-token")).resolves.toBe(false); + expect(request).toHaveBeenNthCalledWith(1, { action: "secrets.set", payload: { key: "api-token", value: "" } }); + expect(secrets.ref("key")).toEqual({ kind: "secret", key: "key" }); + expect(Object.isFrozen(secrets.ref("key"))).toBe(true); +}); + +test("metadata and profiles drop extra properties including secret values", async () => { + configure(vi.fn().mockResolvedValueOnce({ entries: [{ key: "key", updatedAt: "2026-10-17T00:00:00Z", value: "SECRET" }] }) + .mockResolvedValueOnce({ profiles: [{ id: "prod", label: "Production", password: "SECRET" }] })); + await expect(secrets.list()).resolves.toEqual([{ key: "key", updatedAt: "2026-10-17T00:00:00Z" }]); + await expect(ssh.listProfiles()).resolves.toEqual([{ id: "prod", label: "Production" }]); +}); + +test.each(["", "../key", "with space", "key/child", "a".repeat(129)])("rejects invalid secret keys %j before transport", async key => { + const request = configure(); + await expect(secrets.set(key, "SECRET")).rejects.toThrow("Secret key"); + expect(() => secrets.ref(key)).toThrow("Secret key"); + expect(request).not.toHaveBeenCalled(); +}); + +test("SSH sends defaults and only approved fields; nonzero remote exit is a result", async () => { + const request = configure(vi.fn().mockResolvedValue({ ...execResult, exitCode: 1, stderr: "failed" })); + const result = await ssh.exec({ profileId: "prod", command: "false", password: "SECRET" } as never); + expect(result.exitCode).toBe(1); + expect(request).toHaveBeenCalledWith({ action: "ssh.exec", payload: { + profileId: "prod", command: "false", timeoutMs: 30_000, maxOutputBytes: 1_048_576, + } }); +}); + +test.each([{ profileId: "", command: "x" }, { profileId: "p", command: "\0" }, + { profileId: "p", command: "x", timeoutMs: -1 }, { profileId: "p", command: "x", maxOutputBytes: Infinity }])( + "rejects invalid SSH options before transport: %j", async options => { + const request = configure(); + await expect(ssh.exec(options)).rejects.toThrow(); + expect(request).not.toHaveBeenCalled(); + }); + +test("host errors expose stable codes without leaking messages or payloads", async () => { + configure(vi.fn().mockRejectedValue({ code: "LOCKED", message: "SECRET" })); + await expect(secrets.get("token")).rejects.toMatchObject({ code: "LOCKED", message: "Host service request failed (LOCKED)." }); + configure(vi.fn().mockRejectedValue(new Error("SECRET"))); + await expect(secrets.get("token")).rejects.toBeInstanceOf(HostServiceError); +}); + +test("malformed host responses cannot become missing secrets or successful commands", async () => { + configure(vi.fn().mockResolvedValue({ value: undefined, message: "SECRET" })); + await expect(secrets.get("token")).rejects.toThrow("Invalid host service response."); + await expect(ssh.exec({ profileId: "prod", command: "x" })).rejects.toThrow("Invalid host service response."); +}); + +test("iframe access and cleared transports fail before calling the host", async () => { + const request = configure(); + vi.stubGlobal("window", {}); + await expect(secrets.get("token")).rejects.toThrow("backend runtime"); + expect(request).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + PluginRegistry.clearPlugin(); + await expect(secrets.get("token")).rejects.toThrow("unavailable"); +}); + +test("new capabilities participate in initialization validation and preflight", () => { + expect(createSecretsCapabilityBundle(["get", "get"])).toEqual(["secrets.get", "secrets.use"]); + expect(() => createSecretsCapabilityBundle([])).toThrow(); + expect(describeCapability("ssh.exec").category).toBe("ssh"); + expect(describeCapability("secrets.get").category).toBe("secrets"); + expect(runCapabilityPreflight({ declared: ["ssh.exec"], granted: [] }).missing[0].requiredCapabilities) + .toEqual(["secrets.consume", "secrets.use", "ssh.exec", "ssh.use"]); + expect(() => validatePluginInitPayload({ capabilities: createSSHCapabilityBundle() })).not.toThrow(); +}); + +test("SSH can use host credential references without permission to read plaintext", async () => { + const request = configure(); + PluginRegistry.configureCapabilities({ granted: ["ssh.use", "ssh.exec", "secrets.use", "secrets.consume"] }); + await expect(ssh.exec({ profileId: "prod", command: "uptime", timeoutMs: 500, maxOutputBytes: 100 })) + .resolves.toEqual(execResult); + await expect(secrets.get("key")).rejects.toThrow("secrets.get"); + expect(request).toHaveBeenCalledTimes(1); +}); + +test("profile listing has its own permission", async () => { + const request = configure(); + PluginRegistry.configureCapabilities({ granted: ["ssh.use", "ssh.exec", "secrets.use", "secrets.consume"] }); + await expect(ssh.listProfiles()).rejects.toThrow("ssh.profiles.list"); + expect(request).not.toHaveBeenCalled(); +}); + +test("clearing a worker invalidates its in-flight secret response", async () => { + let resolve!: (value: unknown) => void; + configure(vi.fn().mockImplementation(() => new Promise(done => { resolve = done; }))); + const pending = secrets.get("key"); + PluginRegistry.configureHostServices(null); + resolve({ value: "SECRET" }); + await expect(pending).rejects.toMatchObject({ code: "UNAVAILABLE" }); +}); + +test("rejects malformed acknowledgements and metadata", async () => { + configure(vi.fn().mockResolvedValueOnce({ stored: false }) + .mockResolvedValueOnce({ deleted: "yes" }) + .mockResolvedValueOnce({ entries: [{ key: "key", updatedAt: "invalid" }] }) + .mockResolvedValueOnce({ profiles: [{ id: "p" }] })); + await expect(secrets.set("key", "value")).rejects.toThrow("Invalid host service response"); + await expect(secrets.delete("key")).rejects.toThrow("Invalid host service response"); + await expect(secrets.list()).rejects.toThrow("Invalid host service response"); + await expect(ssh.listProfiles()).rejects.toThrow("Invalid host service response"); +}); + +test.each(["get", "set", "delete", "list"] as const)("secrets.%s alone cannot bypass the base secrets.use permission", async operation => { + const transport = configure(); + PluginRegistry.configureCapabilities({ granted: [`secrets.${operation}`] }); + const call = operation === "set" ? secrets.set("key", "value") + : operation === "list" ? secrets.list() : secrets[operation]("key"); + await expect(call).rejects.toThrow("secrets.use"); + expect(transport).not.toHaveBeenCalled(); +}); + +test("secret operation preflight identifies the base permission", () => { + expect(createSecretsCapabilityBundle(["get", "list"])).toEqual(["secrets.get", "secrets.list", "secrets.use"]); + const report = runCapabilityPreflight({ declared: ["secrets.get"], granted: [] }); + expect(report.missing[0].requiredCapabilities).toEqual(["secrets.get", "secrets.use"]); + expect(report.missing[0].missingPrerequisites).toEqual(["secrets.use"]); +}); + +test.each([ + { granted: ["ssh.use"] }, + { granted: ["ssh.profiles.list"] }, +] as const)("SSH profile listing requires base and operation grants: %j", async ({ granted }) => { + const transport = configure(); + PluginRegistry.configureCapabilities({ granted: [...granted] }); + await expect(ssh.listProfiles()).rejects.toThrow("Capability"); + expect(transport).not.toHaveBeenCalled(); +}); + +test("SSH profile listing needs no secret access when both SSH permissions are granted", async () => { + configure(vi.fn().mockResolvedValue({ profiles: [{ id: "prod", label: "Production" }] })); + PluginRegistry.configureCapabilities({ granted: ["ssh.use", "ssh.profiles.list"] }); + await expect(ssh.listProfiles()).resolves.toEqual([{ id: "prod", label: "Production" }]); + expect(runCapabilityPreflight({ declared: ["ssh.profiles.list"], granted: [] }).missing[0].requiredCapabilities) + .toEqual(["ssh.profiles.list", "ssh.use"]); + expect(describeCapability("ssh.use").category).toBe("ssh"); +});