diff --git a/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml b/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml index db48c398884e..42313a726370 100644 --- a/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml +++ b/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml @@ -14,6 +14,7 @@ paths: - src/infra/push-apns-http2.ts - src/infra/ssh-tunnel.ts - src/proxy-capture + - src/secrets/egress-proxy - extensions/codex/src/app-server/transport-websocket.ts - extensions/irc/src - extensions/qa-lab/src diff --git a/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql b/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql index 58d6f17a0f8b..8e1775641738 100644 --- a/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql +++ b/.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql @@ -73,6 +73,11 @@ predicate allowedRawSocketClientCall(Expr call) { or allowedOwnerScope(call, "src/proxy-capture/proxy-server.ts", "startDebugProxyServer") or + // Bypass hosts are blind TLS tunnels for pinned clients: the proxy relays bytes it + // deliberately cannot read, so no secret substitution happens on this route. Every + // substituting route uses the managed https client, not a raw socket. + allowedOwnerScope(call, "src/secrets/egress-proxy/proxy-server.ts", "startSecretEgressProxyServer") + or allowedOwnerScope(call, "extensions/codex/src/app-server/transport-websocket.ts", "connectCodexAppServerUnixSocket") or allowedOwnerScope(call, "extensions/irc/src/client.ts", "connectIrcClient") diff --git a/CHANGELOG.md b/CHANGELOG.md index 8caca88ebb2d..40714215a5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- **Secret egress host binding:** bind each shared-store secret to exact HTTPS destination hosts across CLI, Gateway RPC, and Control UI so unbound sentinel substitution fails closed before plaintext egress. - **Release validation:** defer beta candidate Parallels smoke to postpublish `release:beta-smoke` by default, keep stable/full prepublish coverage, and bound nested release workflow monitors with explicit job timeouts. - **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched. - **Developer workflow:** remove the obsolete scoped-commit helper and use standard Git commands in isolated worktrees. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 0778a970dd83..d82cb897e260 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4745,6 +4745,7 @@ public struct SecretStoreSecretEntry: Codable, Sendable { public let updatedatms: Int public let updatedby: String? public let kind: String + public let allowedhosts: [String]? public init( name: String, @@ -4753,7 +4754,8 @@ public struct SecretStoreSecretEntry: Codable, Sendable { createdatms: Int, updatedatms: Int, updatedby: String? = nil, - kind: String) + kind: String, + allowedhosts: [String]? = nil) { self.name = name self.scopekind = scopekind @@ -4762,6 +4764,7 @@ public struct SecretStoreSecretEntry: Codable, Sendable { self.updatedatms = updatedatms self.updatedby = updatedby self.kind = kind + self.allowedhosts = allowedhosts } private enum CodingKeys: String, CodingKey { @@ -4772,6 +4775,7 @@ public struct SecretStoreSecretEntry: Codable, Sendable { case updatedatms = "updatedAtMs" case updatedby = "updatedBy" case kind + case allowedhosts = "allowedHosts" } } @@ -4837,21 +4841,25 @@ public struct SecretsStoreSetParams: Codable, Sendable { public let name: String public let value: String public let kind: AnyCodable + public let allowedhosts: [String]? public init( name: String, value: String, - kind: AnyCodable) + kind: AnyCodable, + allowedhosts: [String]? = nil) { self.name = name self.value = value self.kind = kind + self.allowedhosts = allowedhosts } private enum CodingKeys: String, CodingKey { case name case value case kind + case allowedhosts = "allowedHosts" } } diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 42031111a6c1..f0c145647edd 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2308, + "core": 2312, "channel": 3575, "plugin": 3997 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 0566eecce612..d2d0131a34ac 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -ff3a4f23b20f40e493f2c5d4da1a8c6fba0206cf763a372e6437a3cf515d1099 config-baseline.json -d834d843e6f65a87490964c1e7a089ec8421b546a9274b84c5d2ef35ec14f5a1 config-baseline.core.json +617f7a9ff716a0d79aaaf779905c37e4470d647459f13ceb14791d9cbb4df747 config-baseline.json +3a5d877e6697388e8b61e5a9e539a1fad7113118453276fe24fb6772abaeae97 config-baseline.core.json 1144184911193a239dd0e6415335a2a95771af27c4b1f5974e64851d6b2ed65d config-baseline.channel.json 250f573a93619d8a2f554028288af84551e572ef8bfdc820a43024572b55175a config-baseline.plugin.json diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index ff00f610d89b..2b9add4a953a 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -82,6 +82,18 @@ openclaw secrets store set TLS_PRIVATE_KEY \ `set` is idempotent and updates an existing name. Add `--dry-run` to validate and preview the operation without writing. A successful write reminds you to run `openclaw secrets reload` before a config-referenced value can take effect. +Secret egress substitution fails closed until each secret has at least one exact allowed host. Bind or replace hosts with repeatable `--allow-host` flags; this policy-only form does not ask for or replace an existing secret value: + +```bash +openclaw secrets store set OPENAI_API_KEY --allow-host api.openai.com +openclaw secrets store set SERVICE_TOKEN \ + --allow-host api.example.com \ + --allow-host uploads.example.com +openclaw secrets store set SERVICE_TOKEN --clear-allowed-hosts +``` + +Hosts are normalized to lowercase ASCII/punycode. Schemes, paths, ports, and wildcards are rejected. `store list` shows allowed hosts because they are policy metadata, not secret material. + ### Read values ```bash @@ -92,7 +104,7 @@ openclaw secrets store get LOG_LEVEL Secret values never appear in human, `--json`, or `--plain` output. `store get` refuses a `secret` entry as write-only by design and exits `2`; it exits `3` when the name does not exist. Environment-kind values are readable. -Team-scoped `env` entries also reach commands run by OpenClaw's own exec tool, including Code Mode, sandboxed exec, and `node`-hosted exec. Explicit per-call env wins over store values, and host/sandbox security filters can reject protected or credential-shaped names with a warning. `secret` entries are never exposed as subprocess env; use them through `store` SecretRefs instead. +Team-scoped `env` entries also reach commands run by OpenClaw's own exec tool, including Code Mode, sandboxed exec, and `node`-hosted exec. Explicit per-call env wins over store values, and host/sandbox security filters can reject protected or credential-shaped names with a warning. `secret` entries stay out of subprocesses by default. With `secrets.egressProxy.enabled: true`, Gateway-hosted exec receives only authenticated sentinels and the Gateway replaces them at HTTPS egress; see [Secret egress proxy](/gateway/secrets#secret-egress-proxy). Store entries do not reach commands run inside an external agent harness. The Codex app-server and its sandbox exec-server, and ACP children such as Claude Code, build their own child environment and never pass through OpenClaw's exec preparation. If an agent run is delegated to one of those harnesses, set the variable in that harness's own configuration instead. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 94bd978a85d9..edca46cf4d49 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1260,6 +1260,26 @@ Reference env vars in any config string with `${VAR_NAME}`: Secret refs are additive: plaintext values still work. +### `secrets.egressProxy` + +Default-off Gateway-owned substitution for shared-store `secret` entries used by agent exec subprocesses: + +```json5 +{ + secrets: { + egressProxy: { + enabled: false, + bypassHosts: ["pinned-api.example.com"], + }, + }, +} +``` + +- `enabled`: starts the loopback proxy and ephemeral CA at Gateway startup. Default: `false`. Changing it requires a Gateway restart. +- `bypassHosts`: optional exact-hostname list for authenticated blind CONNECT tunnels used by certificate-pinned clients. Sentinels are not substituted on bypassed hosts and fail vendor authentication without exposing plaintext. + +See [Secret egress proxy](/gateway/secrets#secret-egress-proxy) for subprocess environment wiring, authentication, fail-closed behavior, and limitations. + ### `SecretRef` Use one object shape: diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 9854178b4fe4..cf0331d6de0a 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -461,6 +461,8 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker By default, local container sandboxes run with **no network**. Override with `agents.defaults.sandbox.docker.network`. +The default-off [secret egress proxy](/gateway/secrets#secret-egress-proxy) is Gateway-loopback only. Sandbox exec receives the proxy and CA environment variables when the feature is enabled, but container loopback does not reach the Gateway host, and the default `network: "none"` blocks egress entirely. Sandbox/container proxy reachability is not implemented; do not enable sandbox networking expecting secret substitution to work in this release. + Package installation and certificate-store changes are image provisioning, not normal sandbox-turn behavior. The defaults deliberately combine no network, diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 0ba637ac0f7d..680136291e16 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -34,7 +34,7 @@ Gateway ingress protection, structurally invalid config or resolved values, poli ## Egress-time injection (sentinels) -For model-provider credentials backed by SecretRefs, OpenClaw mints an opaque, process-local sentinel during model-auth resolution. Auth storage, stream options, SDK configuration, logs, error objects, and most runtime introspection therefore see a value such as `oc-sent-v1-...`, not the provider credential. The guarded model fetch and managed local-provider health probes replace known sentinels in URL and header values immediately before each request leaves the process. +For model-provider credentials backed by SecretRefs, OpenClaw mints an opaque, process-local sentinel during model-auth resolution. Auth storage, stream options, SDK configuration, logs, error objects, and most runtime introspection therefore see a value such as `oc-sent-v2..end`, not the provider credential. The guarded model fetch and managed local-provider health probes replace known sentinels in URL and header values immediately before each request leaves the process. Unknown sentinel-shaped values fail closed before network activity. OpenClaw refuses to send the request rather than forwarding an unresolved sentinel to a provider. Resolved secret values are also registered for exact-value log redaction as a defense in depth measure. @@ -282,7 +282,7 @@ Entries have a `secret` or `env` kind. The kind controls CLI disclosure, not Sec It does not cover commands executed inside a provider-native harness — the Codex app-server and its sandbox exec-server, or ACP children such as Claude Code. Those harnesses assemble their own child environment and never pass through OpenClaw's exec preparation, so store entries are absent there. The store snapshot is also read once per agent run, so entries added mid-run apply from the next run onward. -`secret` entries are never injected into subprocess environments. They remain available only through `store` SecretRefs because plaintext env injection would bypass the store disclosure boundary; safe secret injection requires a future egress-substitution mechanism. +By default, `secret` entries are never injected into subprocess environments. When the default-off [secret egress proxy](#secret-egress-proxy) is enabled, Gateway-hosted exec commands receive process-local sentinels instead of plaintext values. Names use the same uppercase grammar as env SecretRefs, and each UTF-8 value is limited to 64 KiB (65,536 bytes). A `secret` entry must carry a value; empty secrets are rejected because they would surface only as a confusing downstream auth failure. `env` entries may be empty. This supports PEM keys and service-account JSON without inheriting the smaller limits of ordinary environment variables. @@ -306,6 +306,68 @@ Control UI set/delete operations automatically refresh the active secrets runtim Store values are not encrypted at rest. They are stored unencrypted in the shared state SQLite database (`state/openclaw.sqlite`), protected by the same `0600` file and `0700` directory permissions as other credentials in that database. Operators who need stronger storage isolation should use an external exec provider such as the [1Password plugin](/plugins/onepassword) or [Vault SecretRefs](/plugins/vault). +## Secret egress proxy + +The secret egress proxy lets Gateway-hosted agent subprocesses use shared-store `secret` entries without receiving their plaintext. OpenClaw puts the existing authenticated sentinel in the subprocess environment, then a Gateway-owned loopback proxy replaces it in request URLs, headers, and streamed bodies immediately before egress. + +Each secret must also name the exact HTTPS hosts where substitution is allowed. Hostnames are stored lowercase in ASCII/punycode form and matched exactly; wildcards, suffix matching, and ports are not supported. A secret with no allowed hosts is never substituted. Bind a host without replacing the stored value: + +```bash +openclaw secrets store set OPENAI_API_KEY --allow-host api.openai.com +``` + +Repeat `--allow-host` to replace the binding with multiple hosts, or use `--clear-allowed-hosts` to remove every binding. A refused request names the secret and prints the exact `store set ... --allow-host ...` command needed for that destination. + +Enable it explicitly, then restart the Gateway: + +```bash +openclaw config set secrets.egressProxy.enabled true --strict-json +openclaw gateway restart +``` + +Equivalent config: + +```json5 +{ + secrets: { + egressProxy: { + enabled: true, + bypassHosts: ["pinned-api.example.com"], + }, + }, +} +``` + +When enabled, OpenClaw adds these values to Gateway and sandbox exec environments: + +- `HTTPS_PROXY` and `HTTP_PROXY`, with per-run credentials embedded in the loopback proxy URL +- `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, and `REQUESTS_CA_BUNDLE`, pointing at the ephemeral CA certificate +- each team-store `secret` entry as an `oc-sent-v2...end` sentinel; `env` entries keep their existing behavior and precedence + +Proxy authentication uses standard Basic proxy auth with username `openclaw` and a random per-run password. The token expires when the exact agent run closes, including cancellation and replacement. Base64 is not treated as encryption: the listener binds only to loopback, and a process that can read the proxy token from the agent environment can already read the sentinels in that environment. Missing, wrong, or expired credentials receive `407 Proxy Authentication Required` and are never forwarded. + +The run snapshot registers each sentinel together with its secret name and allowed hosts. After proxy authentication, the proxy looks up the matched sentinel in that run's registration and authorizes the normalized destination hostname before decrypting the sentinel. A sentinel that is unregistered, unresolved, unbound, or bound to another host is refused before its plaintext is forwarded. + + +Destination binding does not make an allowed host trustworthy. A bound service that reflects request credentials can still return the plaintext to the agent. DNS-level compromise can redirect a permitted hostname because policy is hostname-based, not an IP pin. Non-HTTPS requests are refused rather than protected, and HTTPS interception still has the protocol limits below. Use external network policy or process isolation when those threats are in scope. + + +The CA is generated once per Gateway start under the state directory. Its directory is mode `0700`, its private keys are mode `0600`, it is removed during Gateway shutdown, and OpenClaw never installs it in a system trust store. Requests fail closed when a sentinel cannot be authenticated or resolved; the proxy never forwards or silently strips an unresolved sentinel. Request bodies are scanned as a stream with a bounded carry window, so substitution also works when a sentinel crosses chunk boundaries or appears in a large upload. + +`bypassHosts` contains exact hostnames that must remain end-to-end TLS for certificate-pinned clients. Those hosts use an authenticated blind CONNECT tunnel. No substitution is possible inside the tunnel; a sentinel sent there is safe by construction because it is authenticated ciphertext rather than a credential, so the vendor sees an invalid credential and rejects it. + +Current limits: + +- HTTP/2 upstream connections are not supported; the proxy uses HTTP/1.1 upstream. +- WebSocket rewriting is not supported. +- Non-443 HTTPS substitution is not a supported compatibility target. +- Identity-scoped secrets are not supported; only the team store participates. +- Allowed-host policy is exact-hostname authorization only. It does not validate the resolved IP or prevent an allowed origin from reflecting credentials. +- Plain HTTP is refused; it is not upgraded or substituted. +- Sandbox/container reachability is not implemented. Local container sandboxes default to `network: "none"`, and their loopback address is not the Gateway host. The variables are present, but the proxy is normally unreachable. +- Remote `node` exec and provider-native harness subprocesses do not use this proxy. +- Background subprocesses lose proxy authorization when their owning agent run ends, even if the process itself is still alive. + ## File-backed API keys Do not put `file:...` strings in the config `env` block. That block is literal and non-overriding, so `file:...` is never resolved there. diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 05b82ce5811d..41c81d4e7f39 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -76,6 +76,7 @@ Notes: - On non-Windows gateway hosts, bash and zsh exec commands use a startup snapshot. OpenClaw captures sourceable aliases/functions and a small safe environment set from shell startup files into `$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command. Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set `OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path. - Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to prevent binary hijacking or injected code. - OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context. +- With the default-off [secret egress proxy](/gateway/secrets#secret-egress-proxy), Gateway-hosted exec receives shared-store `secret` entries only as process-local sentinels. The authenticated loopback proxy substitutes plaintext at outbound HTTPS request time; the exact run token expires when the agent run closes. - For channel-origin runs, OpenClaw also exposes a narrow sender/chat identity JSON payload in `OPENCLAW_CHANNEL_CONTEXT` when the channel provided those ids. - `exec` cannot run `openclaw channels login` or `/approve` shell commands: `openclaw channels login` is an interactive channel-auth flow, and `/approve` needs to go through the approval command handler, not a shell. Run channel login in a terminal on the gateway host, or use a channel-specific login agent tool when one exists (for example `whatsapp_login`). - Important: sandboxing is **off by default**. If sandboxing is off, implicit `host=auto` resolves to `gateway`. Explicit `host=sandbox` still fails closed instead of silently running on the gateway host. Enable sandboxing or use `host=gateway` with approvals. diff --git a/packages/gateway-protocol/src/schema/secrets.test.ts b/packages/gateway-protocol/src/schema/secrets.test.ts index 8c2119f1bb9d..fd6d8f22cf4e 100644 --- a/packages/gateway-protocol/src/schema/secrets.test.ts +++ b/packages/gateway-protocol/src/schema/secrets.test.ts @@ -20,7 +20,7 @@ describe("secret store protocol schemas", () => { expect( Value.Check(SecretsStoreListResultSchema, { entries: [ - { ...metadata, kind: "secret" }, + { ...metadata, kind: "secret", allowedHosts: ["api.example.com"] }, { ...metadata, name: "SERVICE_URL", kind: "env", value: "https://service.test" }, ], }), @@ -43,8 +43,17 @@ describe("secret store protocol schemas", () => { name: "SERVICE_API_KEY", value: "value", kind: "secret", + allowedHosts: ["api.example.com"], }), ).toBe(true); + expect( + Value.Check(SecretsStoreSetParamsSchema, { + name: "SERVICE_API_KEY", + value: "value", + kind: "secret", + allowedHosts: ["api.example.com", "api.example.com"], + }), + ).toBe(false); expect( Value.Check(SecretsStoreSetParamsSchema, { name: "lowercase", diff --git a/packages/gateway-protocol/src/schema/secrets.ts b/packages/gateway-protocol/src/schema/secrets.ts index bcd55b72677f..52a5462dc69a 100644 --- a/packages/gateway-protocol/src/schema/secrets.ts +++ b/packages/gateway-protocol/src/schema/secrets.ts @@ -2,6 +2,7 @@ import { Type, type Static } from "typebox"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; +import { withSince } from "./since.js"; /** * Secret-provider protocol schemas. @@ -27,10 +28,16 @@ const SecretStoreEntryMetadataProperties = { updatedBy: Type.Optional(Type.String()), } as const; +const SecretStoreAllowedHostsSchema = Type.Array(Type.String({ minLength: 1, maxLength: 253 }), { + maxItems: 128, + uniqueItems: true, +}); + /** Secret metadata never structurally carries the stored value. */ export const SecretStoreSecretEntrySchema = closedObject({ ...SecretStoreEntryMetadataProperties, kind: Type.Literal("secret"), + allowedHosts: Type.Optional(withSince("2026.8", SecretStoreAllowedHostsSchema)), }); /** Environment entries include their value because they are intentionally visible. */ @@ -59,6 +66,7 @@ export const SecretsStoreSetParamsSchema = closedObject({ name: SecretStoreNameSchema, value: Type.String({ maxLength: 64 * 1024 }), kind: Type.Union([Type.Literal("secret"), Type.Literal("env")]), + allowedHosts: Type.Optional(withSince("2026.8", SecretStoreAllowedHostsSchema)), }); /** Soft-delete one team secret-store entry. */ diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 0df14d585467..76ff53ad7203 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -31,6 +31,7 @@ import type { InputProvenance } from "../sessions/input-provenance.js"; import type { SkillSnapshot, SkillUsagePath } from "../skills/types.js"; import type { SkillWorkshopRunOptions } from "../skills/workshop/types.js"; import { resolveGatewayMessageChannel } from "../utils/message-channel.js"; +import type { OperationalRunInstanceRef } from "./admitted-run-context.js"; import type { ToolOutcomeObserver } from "./agent-tools.before-tool-call.js"; import { finalizeAgentTools } from "./agent-tools.finalize.js"; import { filterToolsByMessageProvider } from "./agent-tools.message-provider-policy.js"; @@ -197,6 +198,8 @@ type OpenClawCodingToolsOptions = { oneShotCliRun?: boolean; /** Stable run identifier for this agent invocation. */ runId?: string; + /** Exact admitted run instance for lifecycle-bound subprocess capabilities. */ + operationalRunInstance?: OperationalRunInstanceRef; /** Device-scoped operator session allowed to review approvals initiated by this run. */ approvalReviewerDeviceId?: string; /** Diagnostic trace context for hook/log correlation during this run. */ @@ -586,6 +589,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) scopeKey, sessionKey: options?.sessionKey, runId: options?.runId, + operationalRunInstance: options?.operationalRunInstance, // Detached completions return to the live session, not the sandbox policy scope. notifySessionKey: options?.runSessionKey ?? options?.sessionKey, sessionId: options?.sessionId, diff --git a/src/agents/bash-tools.exec-request-preparation.ts b/src/agents/bash-tools.exec-request-preparation.ts index ae490701ff91..4422bcd7bab6 100644 --- a/src/agents/bash-tools.exec-request-preparation.ts +++ b/src/agents/bash-tools.exec-request-preparation.ts @@ -356,9 +356,14 @@ export function resolvePreparedExecEnvironment(params: { defaultPathPrepend: string[]; pluginEnv?: Record; storeEnv?: Record; + storeSecretEnv?: Record; + secretEgressEnv?: Record; warnings: string[]; }): { env: Record; requestedEnv?: Record } { const inheritedBaseEnv = coerceEnv(process.env); + if (params.secretEgressEnv) { + Object.assign(inheritedBaseEnv, params.secretEgressEnv); + } const channelContextEnv = buildChannelContextEnv(params.channelContext); const explicitEnv: Record | undefined = params.execParams.env !== undefined || @@ -400,20 +405,23 @@ export function resolvePreparedExecEnvironment(params: { ); } const hasStoreEnv = storeEnv && Object.keys(storeEnv).length > 0; - const requestedEnv: Record | undefined = hasStoreEnv + const untrustedRequestedEnv: Record | undefined = hasStoreEnv ? { ...storeEnv, ...explicitEnv } : explicitEnv; + const requestedEnv: Record | undefined = params.storeSecretEnv + ? { ...storeEnv, ...params.storeSecretEnv, ...explicitEnv } + : untrustedRequestedEnv; const hostEnvResult = params.host === "sandbox" ? null : sanitizeHostExecEnvWithDiagnostics({ baseEnv: inheritedBaseEnv, - overrides: requestedEnv, + overrides: untrustedRequestedEnv, blockPathOverrides: true, }); if ( hostEnvResult && - requestedEnv && + untrustedRequestedEnv && (hostEnvResult.rejectedOverrideBlockedKeys.length > 0 || hostEnvResult.rejectedOverrideInvalidKeys.length > 0) ) { @@ -450,7 +458,7 @@ export function resolvePreparedExecEnvironment(params: { params.sandbox && params.host === "sandbox" ? buildSandboxEnv({ defaultPath: DEFAULT_PATH, - paramsEnv: requestedEnv, + paramsEnv: untrustedRequestedEnv, sandboxEnv: params.sandbox.env, containerWorkdir: params.containerWorkdir ?? params.sandbox.containerWorkdir, }) @@ -474,5 +482,18 @@ export function resolvePreparedExecEnvironment(params: { applyPathPrepend(env, params.defaultPathPrepend); } + if (params.storeSecretEnv) { + // Secret-kind entries are authenticated ciphertext, not active credentials. + // Inject them after ordinary env filtering so names such as GH_TOKEN remain usable. + for (const [key, value] of Object.entries(params.storeSecretEnv)) { + if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { + env[key] = value; + } + } + } + if (params.secretEgressEnv) { + Object.assign(env, params.secretEgressEnv); + } + return { env, requestedEnv }; } diff --git a/src/agents/bash-tools.exec-run.ts b/src/agents/bash-tools.exec-run.ts index 0edb3bbc5d39..1bb51104eb39 100644 --- a/src/agents/bash-tools.exec-run.ts +++ b/src/agents/bash-tools.exec-run.ts @@ -17,6 +17,10 @@ import { rejectUnsafeExecControlShellCommand } from "../infra/exec-control-comma import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js"; import { logInfo } from "../logger.js"; import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; +import { + isSecretEgressProxyActive, + registerSecretEgressProxyRun, +} from "../secrets/egress-proxy/registry.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; import { markBackgrounded } from "./bash-process-registry.js"; import { describeExecTool } from "./bash-tools.descriptions.js"; @@ -66,18 +70,17 @@ import type { AgentToolWithMeta } from "./tools/common.js"; export function createExecTool( defaults?: ExecToolDefaults, ): AgentToolWithMeta { + const secretEgressEnabled = isSecretEgressProxyActive(); // Agent runs own one tool instance, so the store is read on first exec and reused for that run. // A new run constructs a new instance and observes later store mutations. - let storeEnvPromise: Promise | undefined> | undefined; + let storeEnvPromise: + | Promise + | undefined; const resolveStoreEnv = () => { storeEnvPromise ??= import("../secrets/store/secret-store.js").then((store) => { - const env: Record = {}; - for (const entry of store.listSecretStoreEntries({ scope: { kind: "team" } })) { - if (entry.kind === "env" && entry.valuePreview !== undefined) { - env[entry.name] = entry.valuePreview; - } - } - return Object.keys(env).length > 0 ? env : undefined; + return store.readSecretStoreExecEnvironment({ + includeSecretSentinels: secretEgressEnabled, + }); }); return storeEnvPromise; }; @@ -401,6 +404,17 @@ export function createExecTool( const resolvedExecEnvState = requestPreparation.getResolvedExecEnvPreparedState(params); const storeEnv = await resolveStoreEnv(); + const canReachGatewayProxy = host === "gateway" || host === "sandbox"; + let secretEgressEnv: Record | undefined; + if (secretEgressEnabled && canReachGatewayProxy) { + if (!defaults?.operationalRunInstance) { + throw new Error("Secret egress proxy requires an admitted agent run instance"); + } + secretEgressEnv = registerSecretEgressProxyRun( + defaults.operationalRunInstance, + storeEnv.secretEgressBindings ?? [], + ); + } const { env, requestedEnv } = resolvePreparedExecEnvironment({ execParams: params, host, @@ -409,7 +423,9 @@ export function createExecTool( channelContext: defaults?.channelContext, defaultPathPrepend, pluginEnv: resolvedExecEnvState?.pluginEnv, - storeEnv, + storeEnv: storeEnv.env, + storeSecretEnv: secretEgressEnv ? storeEnv.secretSentinels : undefined, + secretEgressEnv, warnings, }); diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 46bcb337ce2c..c50aab770ca6 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -17,6 +17,7 @@ import type { ExecAutoReviewer } from "../infra/exec-auto-review.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { PluginHookChannelContext } from "../plugins/hook-types.js"; import type { TerminationReason } from "../process/supervisor/types.js"; +import type { OperationalRunInstanceRef } from "./admitted-run-context.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; import type { EmbeddedFullAccessBlockedReason } from "./embedded-agent-runner/types.js"; import type { ExecReviewerConfig } from "./exec-auto-reviewer.js"; @@ -56,6 +57,8 @@ export type ExecToolDefaults = { sessionKey?: string; /** Stable agent run that owns any approval created by this tool. */ runId?: string; + /** Exact admitted execution instance that owns secret-egress proxy access. */ + operationalRunInstance?: OperationalRunInstanceRef; /** Durable session that receives detached exec completion events and approval followups. */ notifySessionKey?: string; /** Ephemeral session UUID active when this exec tool was built. Regenerated diff --git a/src/agents/bash-tools.exec.store-env.test.ts b/src/agents/bash-tools.exec.store-env.test.ts index 1c3f24bb299f..894fafee981c 100644 --- a/src/agents/bash-tools.exec.store-env.test.ts +++ b/src/agents/bash-tools.exec.store-env.test.ts @@ -1,17 +1,21 @@ /** Store-backed exec environment tests cover run snapshots, precedence, and security filtering. */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { looksLikeSecretSentinel, resolveSecretSentinel } from "../secrets/sentinel.js"; import { writeSecretStoreEntry } from "../secrets/store/secret-store.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv } from "../test-utils/env.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; const mocks = vi.hoisted(() => ({ + egressActive: false, + proxyUrl: ["http://openclaw:", "fixture-password", "@127.0.0.1:19090"].join(""), gatewayParams: [] as Array<{ env: Record; requestedEnv?: Record; }>, spawnInputs: [] as Array<{ env?: Record }>, + proxyBindings: [] as Array, })); vi.mock("../plugins/hook-runner-global.js", () => ({ @@ -19,6 +23,21 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunnerRegistry: () => null, })); +vi.mock("../secrets/egress-proxy/registry.js", () => ({ + isSecretEgressProxyActive: () => mocks.egressActive, + registerSecretEgressProxyRun: (_run: unknown, bindings: unknown) => { + mocks.proxyBindings.push(bindings); + return { + HTTPS_PROXY: mocks.proxyUrl, + HTTP_PROXY: mocks.proxyUrl, + NODE_EXTRA_CA_CERTS: "/state/secret-egress/root-ca.pem", + SSL_CERT_FILE: "/state/secret-egress/root-ca.pem", + CURL_CA_BUNDLE: "/state/secret-egress/root-ca.pem", + REQUESTS_CA_BUNDLE: "/state/secret-egress/root-ca.pem", + }; + }, +})); + vi.mock("../infra/shell-env.js", () => ({ getShellEnvAppliedKeys: vi.fn(() => []), getShellPathFromLoginShell: vi.fn(() => null), @@ -70,7 +89,12 @@ vi.mock("../process/supervisor/index.js", () => ({ let createExecTool: typeof import("./bash-tools.exec-run.js").createExecTool; let createLazyExecTool: typeof import("./lazy-exec-tool.js").createLazyExecTool; -type StoreEntry = { name: string; value: string; kind: "env" | "secret" }; +type StoreEntry = { + name: string; + value: string; + kind: "env" | "secret"; + allowedHosts?: string[]; +}; async function withTeamStoreEntries( entries: StoreEntry[], @@ -99,8 +123,10 @@ describe("exec store environment", () => { }); beforeEach(() => { + mocks.egressActive = false; mocks.gatewayParams.length = 0; mocks.spawnInputs.length = 0; + mocks.proxyBindings.length = 0; }); it("adds only team env-kind entries to gateway exec subprocesses", async () => { @@ -268,4 +294,96 @@ describe("exec store environment", () => { ); }); }); + + it("keeps disabled secret egress byte-identical with a secret-kind store entry", async () => { + await withTeamStoreEntries( + [ + { + name: "SERVICE_API_KEY", + value: "disabled-secret", + kind: "secret", + allowedHosts: ["api.example.com"], + }, + ], + async () => { + const absentConfig = createExecTool({ host: "gateway", security: "full", ask: "off" }); + await absentConfig.execute("call-egress-absent", { + command: "echo ok", + yieldMs: 120_000, + }); + const baseline = JSON.stringify({ + gateway: mocks.gatewayParams[0], + spawn: mocks.spawnInputs[0], + }); + mocks.gatewayParams.length = 0; + mocks.spawnInputs.length = 0; + + const explicitFalse = createExecTool({ + host: "gateway", + security: "full", + ask: "off", + config: { secrets: { egressProxy: { enabled: false } } }, + }); + await explicitFalse.execute("call-egress-disabled", { + command: "echo ok", + yieldMs: 120_000, + }); + + expect( + JSON.stringify({ gateway: mocks.gatewayParams[0], spawn: mocks.spawnInputs[0] }), + ).toBe(baseline); + }, + ); + }); + + it("injects proxy trust and secret-kind sentinels without changing env-kind entries", async () => { + await withTeamStoreEntries( + [ + { name: "AWS_REGION", value: "us-west-2", kind: "env" }, + { + name: "SERVICE_API_KEY", + value: "enabled-secret", + kind: "secret", + allowedHosts: ["API.EXAMPLE.COM"], + }, + ], + async () => { + mocks.egressActive = true; + const tool = createExecTool({ + host: "gateway", + security: "full", + ask: "off", + operationalRunInstance: { instanceId: "instance-1", runId: "run-1" }, + config: { secrets: { egressProxy: { enabled: true } } }, + }); + await tool.execute("call-egress-enabled", { + command: "echo ok", + yieldMs: 120_000, + }); + + const env = mocks.gatewayParams[0]?.env ?? {}; + expect(env.AWS_REGION).toBe("us-west-2"); + expect(looksLikeSecretSentinel(env.SERVICE_API_KEY ?? "")).toBe(true); + expect(resolveSecretSentinel(env.SERVICE_API_KEY ?? "")).toBe("enabled-secret"); + expect(env).toMatchObject({ + HTTPS_PROXY: mocks.proxyUrl, + HTTP_PROXY: mocks.proxyUrl, + NODE_EXTRA_CA_CERTS: "/state/secret-egress/root-ca.pem", + SSL_CERT_FILE: "/state/secret-egress/root-ca.pem", + CURL_CA_BUNDLE: "/state/secret-egress/root-ca.pem", + REQUESTS_CA_BUNDLE: "/state/secret-egress/root-ca.pem", + }); + expect(JSON.stringify(env)).not.toContain("enabled-secret"); + expect(mocks.proxyBindings).toEqual([ + [ + expect.objectContaining({ + name: "SERVICE_API_KEY", + allowedHosts: ["api.example.com"], + sentinel: env.SERVICE_API_KEY, + }), + ], + ]); + }, + ); + }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts index 1b17aa418571..164022941f4a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-prepare.ts @@ -249,6 +249,7 @@ export function prepareEmbeddedAttemptToolBase(params: { : undefined, sessionId: attempt.sessionId, runId: attempt.runId, + operationalRunInstance: attempt.admittedRunContext.operationalRunInstance, conversationRecall: attempt.conversationRecall, approvalReviewerDeviceId: attempt.approvalReviewerDeviceId, oneShotCliRun: attempt.oneShotCliRun, diff --git a/src/cli/secrets-store-cli.test.ts b/src/cli/secrets-store-cli.test.ts index a5f84a3594fa..b9f4065ad7a7 100644 --- a/src/cli/secrets-store-cli.test.ts +++ b/src/cli/secrets-store-cli.test.ts @@ -12,6 +12,7 @@ const mocks = await vi.hoisted(async () => { list: vi.fn(), read: vi.fn(), write: vi.fn(), + updateHosts: vi.fn(), remove: vi.fn(), purge: vi.fn(), gatewayIdentity: vi.fn(), @@ -26,10 +27,14 @@ vi.mock("../secrets/store/secret-store.js", async (importOriginal) => ({ SecretStoreValidationError: ( await importOriginal() ).SecretStoreValidationError, + normalizeSecretAllowedHosts: ( + await importOriginal() + ).normalizeSecretAllowedHosts, SECRET_STORE_VALUE_MAX_BYTES: 64 * 1024, listSecretStoreEntries: (params: unknown) => mocks.list(params), readSecretStoreValue: (params: unknown) => mocks.read(params), writeSecretStoreEntry: (params: unknown) => mocks.write(params), + updateSecretStoreAllowedHosts: (params: unknown) => mocks.updateHosts(params), deleteSecretStoreEntry: (params: unknown) => mocks.remove(params), purgeExpiredSecretStoreEntries: () => mocks.purge(), })); @@ -55,9 +60,10 @@ function createProgram(): Command { beforeEach(() => { mocks.runtimeLogs.length = 0; mocks.runtimeErrors.length = 0; - mocks.list.mockReset(); + mocks.list.mockReset().mockReturnValue([]); mocks.read.mockReset(); mocks.write.mockReset(); + mocks.updateHosts.mockReset(); mocks.remove.mockReset(); mocks.purge.mockReset(); mocks.gatewayIdentity.mockReset().mockResolvedValue(undefined); @@ -70,6 +76,22 @@ beforeEach(() => { }); describe("secrets store CLI", () => { + it("shows non-secret allowed-host metadata in list output", async () => { + mocks.list.mockReturnValue([ + { + name: "SERVICE_API_KEY", + kind: "secret", + allowedHosts: ["api.example.com", "uploads.example.com"], + }, + ]); + + await createProgram().parseAsync(["secrets", "store", "list"], { from: "user" }); + + expect(mocks.runtimeLogs.join("\n")).toContain( + "allowed hosts: api.example.com, uploads.example.com", + ); + }); + it("refuses --value for secret entries with all safe alternatives and exit 2", async () => { await expect( createProgram().parseAsync( @@ -115,6 +137,55 @@ describe("secrets store CLI", () => { expect(mocks.read).not.toHaveBeenCalled(); }); + it("normalizes repeatable allowed hosts and can clear them without replacing the secret", async () => { + mocks.list.mockReturnValue([{ name: "MISC_VALUE", kind: "secret" }]); + + await createProgram().parseAsync( + [ + "secrets", + "store", + "set", + "MISC_VALUE", + "--allow-host", + "API.EXAMPLE.COM", + "--allow-host", + "bücher.example", + ], + { from: "user" }, + ); + await createProgram().parseAsync( + ["secrets", "store", "set", "MISC_VALUE", "--clear-allowed-hosts"], + { from: "user" }, + ); + + expect(mocks.updateHosts).toHaveBeenNthCalledWith(1, { + scope: { kind: "team" }, + name: "MISC_VALUE", + allowedHosts: ["api.example.com", "xn--bcher-kva.example"], + updatedBy: "cli", + }); + expect(mocks.updateHosts).toHaveBeenNthCalledWith(2, { + scope: { kind: "team" }, + name: "MISC_VALUE", + allowedHosts: [], + updatedBy: "cli", + }); + expect(mocks.write).not.toHaveBeenCalled(); + }); + + it("rejects wildcard allowed hosts before reading or writing a value", async () => { + await expect( + createProgram().parseAsync( + ["secrets", "store", "set", "SERVICE_API_KEY", "--allow-host", "*.example.com"], + { from: "user" }, + ), + ).rejects.toThrow("__exit__:2"); + + expect(mocks.runtimeErrors.join("\n")).toContain("cannot contain a wildcard"); + expect(mocks.write).not.toHaveBeenCalled(); + expect(mocks.updateHosts).not.toHaveBeenCalled(); + }); + it("returns exit 3 for a missing get and exit 1 for a database failure", async () => { mocks.list.mockReturnValueOnce([]); await expect( diff --git a/src/cli/secrets-store-cli.ts b/src/cli/secrets-store-cli.ts index a878406dfc4f..78901420b92c 100644 --- a/src/cli/secrets-store-cli.ts +++ b/src/cli/secrets-store-cli.ts @@ -18,6 +18,8 @@ type SetOptions = { kind?: string; scope?: string; dryRun?: boolean; + allowHost?: string[]; + clearAllowedHosts?: boolean; }; type RemoveOptions = { scope?: string; dryRun?: boolean; yes?: boolean }; type ImportOptions = RemoveOptions & { from?: string; kind?: string }; @@ -74,7 +76,8 @@ function mapStoreError(error: unknown): SecretStoreCliFailure { validation?.name === "SecretStoreValidationError" && (validation.code === "SECRET_STORE_INVALID_NAME" || validation.code === "SECRET_STORE_VALUE_TOO_LARGE" || - validation.code === "SECRET_STORE_VALUE_EMPTY") + validation.code === "SECRET_STORE_VALUE_EMPTY" || + validation.code === "SECRET_STORE_INVALID_ALLOWED_HOST") ) { return new SecretStoreCliFailure(2, validation.message ?? "Invalid secret store input."); } @@ -103,7 +106,12 @@ function renderList(entries: SecretStoreEntryMetadata[], options: OutputOptions) if (options.plain) { for (const entry of entries) { defaultRuntime.writeStdout( - [entry.name, entry.kind, entry.kind === "env" ? (entry.valuePreview ?? "") : ""].join("\t"), + [ + entry.name, + entry.kind, + entry.kind === "env" ? (entry.valuePreview ?? "") : "", + entry.kind === "secret" ? (entry.allowedHosts ?? []).join(",") : "", + ].join("\t"), ); } return; @@ -114,7 +122,11 @@ function renderList(entries: SecretStoreEntryMetadata[], options: OutputOptions) } for (const entry of entries) { const value = entry.kind === "env" ? ` = ${entry.valuePreview ?? ""}` : " (write-only)"; - defaultRuntime.log(`${entry.name} [${entry.kind}]${value}`); + const hosts = + entry.kind === "secret" + ? `; allowed hosts: ${(entry.allowedHosts ?? []).join(", ") || "none"}` + : ""; + defaultRuntime.log(`${entry.name} [${entry.kind}]${value}${hosts}`); } } @@ -176,13 +188,43 @@ export function registerSecretStoreCli(secrets: Command): void { .option("--value ", "Literal value (env kind only)") .option("--value-file ", "Read value from a file; use - for stdin") .option("--kind ", "Entry kind (defaults from NAME)") + .option( + "--allow-host ", + "Allow substitution only for this exact host (repeatable)", + (host: string, hosts: string[]) => [...hosts, host], + [], + ) + .option("--clear-allowed-hosts", "Remove all allowed hosts", false) .option("--scope ", "Store scope", "team") .option("--dry-run", "Validate without writing", false) .action((name: string, options: SetOptions) => runStoreAction(async () => { assertStoreName(name); const scope = teamScope(options.scope); - const kind = storeKind(options.kind, name); + const storeModule = await import("../secrets/store/secret-store.js"); + const requestedHosts = options.allowHost ?? []; + const hostPolicyRequested = requestedHosts.length > 0 || options.clearAllowedHosts === true; + const existingEntry = hostPolicyRequested + ? storeModule.listSecretStoreEntries({ scope }).find((entry) => entry.name === name) + : undefined; + const kind = options.kind + ? storeKind(options.kind, name) + : (existingEntry?.kind ?? storeKind(undefined, name)); + if (requestedHosts.length > 0 && options.clearAllowedHosts) { + throw new SecretStoreCliFailure( + 2, + "Use either --allow-host or --clear-allowed-hosts, not both.", + ); + } + if (kind === "env" && (requestedHosts.length > 0 || options.clearAllowedHosts)) { + throw new SecretStoreCliFailure(2, "Allowed hosts apply only to secret entries."); + } + const allowedHosts = + requestedHosts.length > 0 + ? storeModule.normalizeSecretAllowedHosts(requestedHosts) + : options.clearAllowedHosts + ? [] + : undefined; if (options.value !== undefined && options.valueFile !== undefined) { throw new SecretStoreCliFailure(2, "Use only one of --value or --value-file."); } @@ -193,6 +235,29 @@ export function registerSecretStoreCli(secrets: Command): void { "--value is refused for secret entries. Use a stdin pipe, --value-file, or the interactive no-echo prompt.", ); } + const policyOnly = + allowedHosts !== undefined && + options.value === undefined && + options.valueFile === undefined && + existingEntry?.kind === "secret"; + if (policyOnly) { + if (options.dryRun) { + defaultRuntime.log(`Would update allowed hosts for ${name}.`); + return; + } + storeModule.updateSecretStoreAllowedHosts({ + scope, + name, + allowedHosts, + updatedBy: "cli", + }); + defaultRuntime.log( + allowedHosts.length > 0 + ? `Allowed ${name} for ${allowedHosts.join(", ")}.` + : `Cleared allowed hosts for ${name}.`, + ); + return; + } const value = options.value !== undefined ? options.value @@ -201,7 +266,6 @@ export function registerSecretStoreCli(secrets: Command): void { ).readSecretStoreInput({ valueFile: options.valueFile, }); - const storeModule = await import("../secrets/store/secret-store.js"); if (Buffer.byteLength(value, "utf8") > storeModule.SECRET_STORE_VALUE_MAX_BYTES) { throw new SecretStoreCliFailure( 2, @@ -212,7 +276,14 @@ export function registerSecretStoreCli(secrets: Command): void { defaultRuntime.log(`Would ${kind === "secret" ? "write" : "set"} ${name} (${kind}).`); return; } - storeModule.writeSecretStoreEntry({ scope, name, value, kind, updatedBy: "cli" }); + storeModule.writeSecretStoreEntry({ + scope, + name, + value, + kind, + ...(allowedHosts !== undefined ? { allowedHosts } : {}), + updatedBy: "cli", + }); storeModule.purgeExpiredSecretStoreEntries(); defaultRuntime.log(`Stored ${name} (${kind}).`); await noteGatewayReload(); diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts index dadbcbbff962..723d8dc81e9a 100644 --- a/src/config/config.secrets-schema.test.ts +++ b/src/config/config.secrets-schema.test.ts @@ -24,6 +24,10 @@ describe("config secret refs schema", () => { it("accepts top-level secrets sources and model apiKey refs", () => { const result = validateConfigObjectRaw({ secrets: { + egressProxy: { + enabled: true, + bypassHosts: ["pinned.example.com"], + }, providers: { default: { source: "env" }, filemain: { @@ -58,6 +62,20 @@ describe("config secret refs schema", () => { }); expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.secrets?.egressProxy).toEqual({ + enabled: true, + bypassHosts: ["pinned.example.com"], + }); + } + }); + + it("rejects empty secret egress bypass hosts", () => { + const result = validateConfigObjectRaw({ + secrets: { egressProxy: { enabled: false, bypassHosts: [""] } }, + }); + + expect(result.ok).toBe(false); }); it("rejects store refs outside the env-name grammar", () => { diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 7b0694f28973..510e697a445c 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -24,6 +24,14 @@ export const CORE_FIELD_HELP: Record = { "Maximum time in milliseconds allowed for shell environment resolution before fallback behavior applies. Use tighter timeouts for faster startup, or increase when shell initialization is heavy.", "env.vars": "Explicit key/value environment variable overrides merged into runtime process environment for OpenClaw. Use this for deterministic env configuration instead of relying only on shell profile side effects.", + secrets: + "Secret reference providers, shared-store behavior, and optional subprocess egress protection.", + "secrets.egressProxy": + "Gateway-owned loopback proxy that replaces shared-store secret sentinels only at outbound request time. Restart the Gateway after changing this startup-scoped section.", + "secrets.egressProxy.enabled": + "Enables secret egress substitution for Gateway-hosted agent subprocesses. Default: false.", + "secrets.egressProxy.bypassHosts": + "Exact hostnames that use authenticated blind CONNECT tunnels for certificate-pinned clients. Sentinels remain ciphertext and will fail vendor authentication instead of exposing plaintext.", wizard: "User-owned setup preferences. Machine-owned wizard history and acknowledgement state live in the shared state database.", "wizard.accessMode": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 45eca7fbab3b..8ae3b8d9ef48 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -17,6 +17,10 @@ export const FIELD_LABELS: Record = { "env.shellEnv.enabled": "Shell Environment Import Enabled", "env.shellEnv.timeoutMs": "Shell Environment Import Timeout (ms)", "env.vars": "Environment Variable Overrides", + secrets: "Secrets", + "secrets.egressProxy": "Secret Egress Proxy", + "secrets.egressProxy.enabled": "Secret Egress Proxy Enabled", + "secrets.egressProxy.bypassHosts": "Secret Egress Proxy Bypass Hosts", wizard: "Setup Preferences", "wizard.accessMode": "Setup Discovery Access", "wizard.appRecommendations": "Setup App Recommendations", diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index bea6be202812..c73e3ad89462 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -381,6 +381,10 @@ export type SecretProviderConfig = | StoreSecretProviderConfig; export type SecretsConfig = { + egressProxy?: { + enabled?: boolean; + bypassHosts?: string[]; + }; providers?: Record; defaults?: { env?: string; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 962852c0df99..0ef00a3b44c2 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -204,6 +204,13 @@ export const SecretProviderSchema = z.union([ /** Schema for the top-level `secrets` config block. */ export const SecretsConfigSchema = z .object({ + egressProxy: z + .object({ + enabled: z.boolean().optional(), + bypassHosts: z.array(z.string().trim().min(1)).max(256).optional(), + }) + .strict() + .optional(), providers: z .object({ // Keep this as a record so users can define multiple named providers per source. diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 9467e542475c..18bf2d6c5260 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -127,6 +127,8 @@ const BASE_RELOAD_RULES: ReloadRule[] = [ // startup; disposing MCP runtimes cannot move or create that HTTP server. { prefix: "mcp.apps", kind: "restart" }, { prefix: "mcp", kind: "hot", actions: ["dispose-mcp-runtimes"] }, + // The proxy listener, per-start CA, and run-token registry are Gateway-owned. + { prefix: "secrets.egressProxy", kind: "restart" }, { prefix: "plugins.load", kind: "restart" }, { prefix: "plugins.installs", kind: "restart" }, ]; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 042b7e8b5ce5..c49f33d92ebc 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -288,6 +288,8 @@ describe("buildGatewayReloadPlan", () => { "plugins.installs.telegram.installPath", "plugins.load.paths.0", "gateway.auth.mode", + "secrets.egressProxy.enabled", + "secrets.egressProxy.bypassHosts", ])("keeps restart-owned path restart-backed: %s", (path) => { const plan = buildGatewayReloadPlan([path]); diff --git a/src/gateway/server-aux-handlers.test.ts b/src/gateway/server-aux-handlers.test.ts index d158cff88e0f..3718bd256b35 100644 --- a/src/gateway/server-aux-handlers.test.ts +++ b/src/gateway/server-aux-handlers.test.ts @@ -214,6 +214,7 @@ type SecretsReloadHarnessParams = { logChannelsInfo?: GatewayAuxHandlerParams["logChannels"]["info"]; respond?: ReturnType; onApprovalLifecycle?: GatewayAuxHandlerParams["onApprovalLifecycle"]; + onAgentRunAuthorityClosed?: GatewayAuxHandlerParams["onAgentRunAuthorityClosed"]; validateAgentRuntimeDelegatedAuthority?: GatewayAuxHandlerParams["validateAgentRuntimeDelegatedAuthority"]; registerWorkerTurnClaimClosedHandler?: GatewayAuxHandlerParams["registerWorkerTurnClaimClosedHandler"]; }; @@ -236,6 +237,7 @@ function createSecretsReloadHarness(params: SecretsReloadHarnessParams) { getChannelAutostartSuppression: params.getChannelAutostartSuppression, logChannels: { info: params.logChannelsInfo ?? vi.fn() }, onApprovalLifecycle: params.onApprovalLifecycle, + onAgentRunAuthorityClosed: params.onAgentRunAuthorityClosed, validateAgentRuntimeDelegatedAuthority: params.validateAgentRuntimeDelegatedAuthority, registerWorkerTurnClaimClosedHandler: params.registerWorkerTurnClaimClosedHandler, }); @@ -302,6 +304,27 @@ describe("gateway aux handlers", () => { ); }); + it("fans exact run closure out to Gateway-owned capability cleanup", () => { + const onAgentRunAuthorityClosed = vi.fn(); + const gatewayAux = createSecretsReloadHarness({ + activateRuntimeSecrets: mockResolvedSecrets(asConfig({})), + onAgentRunAuthorityClosed, + }); + const operationalRunInstance = Object.freeze({ + instanceId: "egress-proxy-instance", + runId: "egress-proxy-run", + }); + const authority = claimAgentRunDelegatedAuthority(operationalRunInstance); + + releaseAgentRunDelegatedAuthority(authority); + + expect(onAgentRunAuthorityClosed).toHaveBeenCalledOnce(); + expect(onAgentRunAuthorityClosed).toHaveBeenCalledWith( + expect.objectContaining({ operationalRunInstance }), + ); + gatewayAux.unregisterApprovalAuthorityObserver(); + }); + it("settles and publishes both approval kinds from the production worker-claim observer", async () => { const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "openclaw-aux-worker-")); closeOpenClawStateDatabaseForTest(); diff --git a/src/gateway/server-aux-handlers.ts b/src/gateway/server-aux-handlers.ts index 8de0c2ae8f02..7e19cbd113bd 100644 --- a/src/gateway/server-aux-handlers.ts +++ b/src/gateway/server-aux-handlers.ts @@ -2,7 +2,10 @@ // Wires reload, secrets, exec approval, and plugin approval RPC handlers. import { randomUUID } from "node:crypto"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { registerAgentRunDelegatedAuthorityClosedHandler } from "../infra/agent-run-registry.js"; +import { + type AgentRunDelegatedAuthority, + registerAgentRunDelegatedAuthorityClosedHandler, +} from "../infra/agent-run-registry.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js"; import { @@ -127,6 +130,7 @@ export function createGatewayAuxHandlers(params: { getChannelAutostartSuppression?: () => ChannelAutostartSuppression | null; logChannels: { info: (msg: string) => void }; onApprovalLifecycle?: (event: OperatorApprovalLifecycleEvent) => void; + onAgentRunAuthorityClosed?: (authority: AgentRunDelegatedAuthority) => void; validateAgentRuntimeDelegatedAuthority?: (authority: AgentRuntimeDelegatedAuthority) => boolean; chatAbortControllers?: Map; registerWorkerTurnClaimClosedHandler?: ( @@ -248,6 +252,7 @@ export function createGatewayAuxHandlers(params: { } catch (error) { params.log.error?.(`plugin approvals: authority-close settlement failed: ${String(error)}`); } + params.onAgentRunAuthorityClosed?.(authority); }, ); const unregisterWorkerTurnClaimClosedObserver = params.registerWorkerTurnClaimClosedHandler?.( diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 5bd00f731111..979d1dc1d9bc 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -155,6 +155,19 @@ export async function startGatewayCoreRuntime(input: { if (desktopSessionRegistry) { kernel.addGatewayLifetimeSidecar({ stop: () => desktopSessionRegistry.stopAll() }); } + const secretEgressProxy = + cfgAtStart.secrets?.egressProxy?.enabled === true + ? await import("../secrets/egress-proxy/runtime.js").then((egressRuntime) => + egressRuntime.startGatewaySecretEgressProxy( + cfgAtStart.secrets?.egressProxy?.bypassHosts + ? { bypassHosts: cfgAtStart.secrets.egressProxy.bypassHosts } + : {}, + ), + ) + : undefined; + if (secretEgressProxy) { + kernel.addGatewayLifetimeSidecar(secretEgressProxy); + } let earlyRuntimePromise: ReturnType< Awaited>["startGatewayEarlyRuntime"] > | null = null; @@ -339,6 +352,9 @@ export async function startGatewayCoreRuntime(input: { delegatedAuthority: authority, }), onApprovalLifecycle: approvalSessionEvents.publish, + onAgentRunAuthorityClosed: (authority) => { + secretEgressProxy?.revokeRun(authority.operationalRunInstance); + }, }), coreGatewayHandlers: coreGatewayHandlersLocal, }; diff --git a/src/gateway/server-methods/secrets.test.ts b/src/gateway/server-methods/secrets.test.ts index 0aa237fbfb70..ea791a4cbb76 100644 --- a/src/gateway/server-methods/secrets.test.ts +++ b/src/gateway/server-methods/secrets.test.ts @@ -366,6 +366,7 @@ describe("secrets handlers", () => { createdAtMs: 1, updatedAtMs: 2, updatedBy: "Operator", + allowedHosts: ["api.example.com"], valuePreview: "malicious-leak", }, { @@ -388,7 +389,7 @@ describe("secrets handlers", () => { }); expect(respond.mock.calls[0]?.[1]).toMatchObject({ entries: [ - { name: "SERVICE_API_KEY", kind: "secret" }, + { name: "SERVICE_API_KEY", kind: "secret", allowedHosts: ["api.example.com"] }, { name: "SERVICE_URL", kind: "env", value: "https://service.test" }, ], }); @@ -417,7 +418,12 @@ describe("secrets handlers", () => { await invokeStoreMethod({ handlers, method: "secrets.store.set", - requestParams: { name: "SERVICE_API_KEY", value: "new-value", kind: "secret" }, + requestParams: { + name: "SERVICE_API_KEY", + value: "new-value", + kind: "secret", + allowedHosts: ["api.example.com"], + }, respond: setRespond, }); expect(storeMocks.writeEntry).toHaveBeenCalledWith({ @@ -425,6 +431,7 @@ describe("secrets handlers", () => { name: "SERVICE_API_KEY", value: "new-value", kind: "secret", + allowedHosts: ["api.example.com"], updatedBy: "Control UI", }); expect(setRespond).toHaveBeenCalledWith(true, { diff --git a/src/gateway/server-methods/secrets.ts b/src/gateway/server-methods/secrets.ts index c3e7ef10b852..ad7844044315 100644 --- a/src/gateway/server-methods/secrets.ts +++ b/src/gateway/server-methods/secrets.ts @@ -48,7 +48,7 @@ function toProtocolStoreEntry( } return { ...metadata, kind: "env", value: entry.valuePreview }; } - return { ...metadata, kind: "secret" }; + return { ...metadata, kind: "secret", allowedHosts: entry.allowedHosts ?? [] }; } function storeUpdatedBy(client: GatewayClient | null): string { @@ -285,6 +285,9 @@ export function createSecretsHandlers(params: { name: requestParams.name, value: requestParams.value, kind: requestParams.kind, + ...(requestParams.allowedHosts !== undefined + ? { allowedHosts: requestParams.allowedHosts } + : {}), updatedBy: storeUpdatedBy(client), }); stored = true; diff --git a/src/proxy-capture/ca.ts b/src/proxy-capture/ca.ts index 2ad1293ff495..2aa6b6af375b 100644 --- a/src/proxy-capture/ca.ts +++ b/src/proxy-capture/ca.ts @@ -1,26 +1,33 @@ // Proxy capture CA helpers create and inspect local capture CA certificates. -import { createPrivateKey, X509Certificate } from "node:crypto"; +import { createHash, createPrivateKey, randomBytes, X509Certificate } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { parseCanonicalIpAddress } from "@openclaw/net-policy/ip"; import { type FileLockOptions, withFileLock } from "../infra/file-lock.js"; import { resolveSystemBin } from "../infra/resolve-system-bin.js"; import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js"; import { runExec } from "../process/exec.js"; const DEBUG_PROXY_CA_GENERATION_TIMEOUT_MS = 30_000; -const DEBUG_PROXY_CA_OPENSSL_CONFIG = [ - "[req]", - "distinguished_name = subject", - "prompt = no", - "", - "[subject]", - "CN = OpenClaw Debug Proxy", - "", - "[v3_ca]", - "basicConstraints = critical, CA:TRUE", - "keyUsage = critical, keyCertSign, cRLSign", - "", -].join("\n"); +const LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS = 30_000; +const LOCAL_PROXY_DIR_MODE = 0o700; +const LOCAL_PROXY_PRIVATE_KEY_MODE = 0o600; + +function buildLocalProxyCaOpenSslConfig(commonName: string): string { + return [ + "[req]", + "distinguished_name = subject", + "prompt = no", + "", + "[subject]", + `CN = ${commonName}`, + "", + "[v3_ca]", + "basicConstraints = critical, CA:TRUE", + "keyUsage = critical, keyCertSign, cRLSign", + "", + ].join("\n"); +} const DEBUG_PROXY_CA_LOCK_OPTIONS: FileLockOptions = { retries: { // About 36s of minimum backoff covers one full 30s OpenSSL deadline. @@ -58,13 +65,23 @@ function removeStagingDirBestEffort(stagingDir: string): void { } } -// Ensure a short-lived root CA for local MITM debug proxy runs. Existing certs -// are reused within the cert dir so repeated starts do not prompt regeneration. -export async function ensureDebugProxyCa(certDir: string): Promise<{ +type LocalProxyCaOptions = { + commonName: string; + purpose: string; + validityDays: number; +}; + +type LocalProxyCaPair = { certPath: string; keyPath: string; -}> { - fs.mkdirSync(certDir, { recursive: true }); +}; + +async function ensureLocalProxyCa( + certDir: string, + options: LocalProxyCaOptions, +): Promise { + fs.mkdirSync(certDir, { recursive: true, mode: LOCAL_PROXY_DIR_MODE }); + fs.chmodSync(certDir, LOCAL_PROXY_DIR_MODE); const certPath = path.join(certDir, "root-ca.pem"); const keyPath = path.join(certDir, "root-ca-key.pem"); const canonicalKeyPath = path.join(fs.realpathSync(certDir), "root-ca-key.pem"); @@ -75,14 +92,16 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{ } const openssl = resolveSystemBin("openssl"); if (!openssl) { - throw new Error("openssl is required to generate debug proxy certificates"); + throw new Error(`openssl is required to generate ${options.purpose} certificates`); } const stagingDir = fs.mkdtempSync(path.join(certDir, ".root-ca-")); const stagedConfigPath = path.join(stagingDir, "openssl.cnf"); const stagedCertPath = path.join(stagingDir, "root-ca.pem"); const stagedKeyPath = path.join(stagingDir, "root-ca-key.pem"); try { - fs.writeFileSync(stagedConfigPath, DEBUG_PROXY_CA_OPENSSL_CONFIG, { mode: 0o600 }); + fs.writeFileSync(stagedConfigPath, buildLocalProxyCaOpenSslConfig(options.commonName), { + mode: LOCAL_PROXY_PRIVATE_KEY_MODE, + }); await runExec( openssl, [ @@ -96,7 +115,7 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{ "rsa:2048", "-sha256", "-days", - "7", + String(options.validityDays), "-nodes", "-keyout", stagedKeyPath, @@ -106,9 +125,9 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{ { logOutput: false, timeoutMs: DEBUG_PROXY_CA_GENERATION_TIMEOUT_MS }, ); if (!isValidDebugProxyCaPair(stagedCertPath, stagedKeyPath)) { - throw new Error("openssl generated invalid debug proxy certificate material"); + throw new Error(`openssl generated invalid ${options.purpose} certificate material`); } - fs.chmodSync(stagedKeyPath, 0o600); + fs.chmodSync(stagedKeyPath, LOCAL_PROXY_PRIVATE_KEY_MODE); fs.chmodSync(stagedCertPath, 0o644); // All OpenClaw writers hold this lock. Same-directory renames replace each // file atomically; validation repairs a pair interrupted between renames. @@ -121,3 +140,119 @@ export async function ensureDebugProxyCa(certDir: string): Promise<{ }), ); } + +// Ensure a short-lived root CA for local MITM debug proxy runs. Existing certs +// are reused within the cert dir so repeated starts do not prompt regeneration. +export async function ensureDebugProxyCa(certDir: string): Promise<{ + certPath: string; + keyPath: string; +}> { + return await ensureLocalProxyCa(certDir, { + commonName: "OpenClaw Debug Proxy", + purpose: "debug proxy", + validityDays: 7, + }); +} + +/** Generates the root CA for one Gateway-lifetime secret egress proxy. */ +export async function ensureSecretEgressProxyCa(certDir: string): Promise { + return await ensureLocalProxyCa(certDir, { + commonName: "OpenClaw Secret Egress Proxy", + purpose: "secret egress proxy", + validityDays: 1, + }); +} + +function isValidLeafPair(params: { certPath: string; keyPath: string; hostname: string }): boolean { + try { + const cert = new X509Certificate(fs.readFileSync(params.certPath)); + const key = createPrivateKey(fs.readFileSync(params.keyPath)); + const hostMatches = parseCanonicalIpAddress(params.hostname) + ? cert.checkIP(params.hostname) === params.hostname + : cert.checkHost(params.hostname) === params.hostname; + return !cert.ca && cert.checkPrivateKey(key) && hostMatches; + } catch { + return false; + } +} + +async function generateLocalProxyLeafQueued(params: { + certDir: string; + ca: LocalProxyCaPair; + hostname: string; +}): Promise<{ cert: Buffer; key: Buffer }> { + const openssl = resolveSystemBin("openssl"); + if (!openssl) { + throw new Error("openssl is required to generate local proxy certificates"); + } + const leafKeyPath = path.join(params.certDir, "leaf-key.pem"); + if (!fs.existsSync(leafKeyPath)) { + await runExec(openssl, ["genrsa", "-out", leafKeyPath, "2048"], { + logOutput: false, + timeoutMs: LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS, + }); + fs.chmodSync(leafKeyPath, LOCAL_PROXY_PRIVATE_KEY_MODE); + } + const leafId = createHash("sha256").update(params.hostname).digest("hex"); + const stagingDir = fs.mkdtempSync(path.join(params.certDir, `.leaf-${leafId.slice(0, 12)}-`)); + const csrPath = path.join(stagingDir, "leaf.csr"); + const certPath = path.join(stagingDir, "leaf.pem"); + const extPath = path.join(stagingDir, "leaf.ext"); + try { + const sanKind = parseCanonicalIpAddress(params.hostname) ? "IP" : "DNS"; + fs.writeFileSync( + extPath, + `subjectAltName=${sanKind}:${params.hostname}\nextendedKeyUsage=serverAuth\n`, + { mode: LOCAL_PROXY_PRIVATE_KEY_MODE }, + ); + await runExec( + openssl, + ["req", "-new", "-key", leafKeyPath, "-subj", `/CN=${params.hostname}`, "-out", csrPath], + { logOutput: false, timeoutMs: LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS }, + ); + await runExec( + openssl, + [ + "x509", + "-req", + "-in", + csrPath, + "-CA", + params.ca.certPath, + "-CAkey", + params.ca.keyPath, + "-set_serial", + `0x${randomBytes(16).toString("hex")}`, + "-out", + certPath, + "-days", + "1", + "-sha256", + "-extfile", + extPath, + ], + { logOutput: false, timeoutMs: LOCAL_PROXY_CERT_GENERATION_TIMEOUT_MS }, + ); + if (!isValidLeafPair({ certPath, keyPath: leafKeyPath, hostname: params.hostname })) { + throw new Error("openssl generated invalid local proxy leaf certificate material"); + } + return { + cert: fs.readFileSync(certPath), + key: fs.readFileSync(leafKeyPath), + }; + } finally { + removeStagingDirBestEffort(stagingDir); + } +} + +/** Mints one on-demand TLS leaf signed by a local proxy CA. */ +export async function generateLocalProxyLeaf(params: { + certDir: string; + ca: LocalProxyCaPair; + hostname: string; +}): Promise<{ cert: Buffer; key: Buffer }> { + const queueKey = path.join(fs.realpathSync(params.certDir), "leaf-key.pem"); + return await debugProxyCaGenerationQueue.enqueue(queueKey, () => + generateLocalProxyLeafQueued(params), + ); +} diff --git a/src/secrets/egress-proxy/proxy-server.test.ts b/src/secrets/egress-proxy/proxy-server.test.ts new file mode 100644 index 000000000000..b08afb0c4685 --- /dev/null +++ b/src/secrets/egress-proxy/proxy-server.test.ts @@ -0,0 +1,423 @@ +import fs from "node:fs"; +import { request as httpRequest, type Server } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import net, { type Socket } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import tls from "node:tls"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { generateLocalProxyLeaf } from "../../proxy-capture/ca.js"; +import { + mintSecretSentinel, + SECRET_SENTINEL_MAX_LENGTH, + SECRET_SENTINEL_PREFIX, +} from "../sentinel.js"; +import { startSecretEgressProxyServer, type SecretEgressProxyHandle } from "./proxy-server.js"; + +type SecretEgressProxyAuditEvent = Parameters< + typeof startSecretEgressProxyServer +>[0]["onAudit"] extends (event: infer Event) => void + ? Event + : never; + +type OriginRequest = { + body: string; + headers: Record; + url: string; +}; + +const servers: Server[] = []; +const proxies: SecretEgressProxyHandle[] = []; +const sockets = new Set(); +const tempDirs: string[] = []; +let caDir: string; +let auditEvents: SecretEgressProxyAuditEvent[]; +let originRequests: OriginRequest[]; +let originPort: number; +let proxy: SecretEgressProxyHandle; +let run: Readonly<{ instanceId: string; runId: string }>; +let proxyEnv: Record; + +function registerSentinel(params: { + sentinel: string; + allowedHosts: readonly string[]; + name?: string; + targetProxy?: SecretEgressProxyHandle; +}): Record { + return (params.targetProxy ?? proxy).registerRun(run, [ + { + name: params.name ?? "SERVICE_API_KEY", + sentinel: params.sentinel, + allowedHosts: params.allowedHosts, + }, + ]); +} + +async function listen(server: Server): Promise { + servers.push(server); + return await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("test server did not bind a TCP port")); + return; + } + resolve(address.port); + }); + }); +} + +async function closeServer(server: Server): Promise { + server.closeAllConnections?.(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function basicProxyAuth(password: string): string { + return `Basic ${Buffer.from(`openclaw:${password}`).toString("base64")}`; +} + +function registeredPassword(env: Record): string { + const proxyUrl = env.HTTPS_PROXY; + if (!proxyUrl) { + throw new Error("test proxy environment is missing HTTPS_PROXY"); + } + return new URL(proxyUrl).password; +} + +async function rawConnect(params: { + auth?: string; + proxyOrigin?: string; +}): Promise<{ response: string; socket: Socket }> { + const proxyUrl = new URL(params.proxyOrigin ?? proxy.proxyOrigin); + const socket = net.connect(Number(proxyUrl.port), proxyUrl.hostname); + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const authLine = params.auth ? `Proxy-Authorization: ${params.auth}\r\n` : ""; + socket.write( + `CONNECT localhost:${originPort} HTTP/1.1\r\nHost: localhost:${originPort}\r\n${authLine}\r\n`, + ); + const response = await new Promise((resolve, reject) => { + let buffered = ""; + const onData = (chunk: Buffer) => { + buffered += chunk.toString("latin1"); + if (buffered.includes("\r\n\r\n")) { + socket.off("data", onData); + resolve(buffered); + } + }; + socket.on("data", onData); + socket.once("error", reject); + socket.once("close", () => resolve(buffered)); + }); + return { response, socket }; +} + +async function requestThroughTunnel(params: { + path?: string; + headers?: Record; + bodyChunks?: readonly string[]; + caPath?: string; + proxyEnv?: Record; +}): Promise<{ body: string; status: number }> { + const env = params.proxyEnv ?? proxyEnv; + const configuredProxy = env.HTTPS_PROXY; + if (!configuredProxy) { + throw new Error("test proxy environment is missing HTTPS_PROXY"); + } + const connected = await rawConnect({ + auth: basicProxyAuth(registeredPassword(env)), + proxyOrigin: new URL(configuredProxy).origin, + }); + expect(connected.response).toContain("200 Connection Established"); + const secureSocket = tls.connect({ + socket: connected.socket, + servername: "localhost", + ca: fs.readFileSync(params.caPath ?? proxy.caCertPath), + }); + await new Promise((resolve, reject) => { + secureSocket.once("secureConnect", resolve); + secureSocket.once("error", reject); + }); + const bodyChunks = params.bodyChunks ?? []; + const headers = { + Host: `localhost:${originPort}`, + Connection: "close", + ...(bodyChunks.length > 0 ? { "Transfer-Encoding": "chunked" } : {}), + ...params.headers, + }; + secureSocket.write(`POST ${params.path ?? "/"} HTTP/1.1\r\n`); + for (const [name, value] of Object.entries(headers)) { + secureSocket.write(`${name}: ${value}\r\n`); + } + secureSocket.write("\r\n"); + for (const chunk of bodyChunks) { + secureSocket.write(`${Buffer.byteLength(chunk).toString(16)}\r\n${chunk}\r\n`); + } + if (bodyChunks.length > 0) { + secureSocket.write("0\r\n\r\n"); + } + const raw = await new Promise((resolve, reject) => { + let output = ""; + secureSocket.setEncoding("utf8"); + secureSocket.on("data", (chunk) => { + output += chunk.toString(); + }); + secureSocket.once("end", () => resolve(output)); + secureSocket.once("error", reject); + }); + const [head = "", body = ""] = raw.split("\r\n\r\n", 2); + const status = Number(/^HTTP\/1\.1 (\d{3})/u.exec(head)?.[1]); + return { body, status }; +} + +async function forwardedRequest(auth?: string, protocol = "https"): Promise { + const proxyUrl = new URL(proxy.proxyOrigin); + return await new Promise((resolve, reject) => { + const request = httpRequest( + { + hostname: proxyUrl.hostname, + port: proxyUrl.port, + path: `${protocol}://localhost:${originPort}/forwarded-auth`, + method: "GET", + headers: auth ? { "Proxy-Authorization": auth } : undefined, + }, + (response) => { + response.resume(); + response.once("end", () => resolve(response.statusCode ?? 0)); + }, + ); + request.once("error", reject); + request.end(); + }); +} + +function tamperSentinel(sentinel: string): string { + const index = SECRET_SENTINEL_PREFIX.length; + const replacement = sentinel[index] === "A" ? "B" : "A"; + return `${sentinel.slice(0, index)}${replacement}${sentinel.slice(index + 1)}`; +} + +beforeEach(async () => { + auditEvents = []; + originRequests = []; + caDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-egress-proxy-test-")); + tempDirs.push(caDir); + proxy = await startSecretEgressProxyServer({ + caDir, + onAudit: (event) => auditEvents.push(event), + }); + proxies.push(proxy); + const leaf = await generateLocalProxyLeaf({ + certDir: caDir, + ca: { certPath: proxy.caCertPath, keyPath: path.join(caDir, "root-ca-key.pem") }, + hostname: "localhost", + }); + originPort = await listen( + createHttpsServer(leaf, (request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + originRequests.push({ + body: Buffer.concat(chunks).toString("utf8"), + headers: { ...request.headers }, + url: request.url ?? "", + }); + response.writeHead(200, { Connection: "close", "Content-Length": 2 }); + response.end("ok"); + }); + }), + ); + run = Object.freeze({ instanceId: "instance-1", runId: "run-1" }); + proxyEnv = proxy.registerRun(run); +}); + +afterEach(async () => { + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + for (const currentProxy of proxies.splice(0)) { + await currentProxy.stop(); + } + for (const server of servers.splice(0)) { + await closeServer(server); + } + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("secret egress proxy", () => { + it.each([ + { label: "missing", auth: undefined, expectedReason: "missing-proxy-auth" }, + { + label: "wrong", + auth: basicProxyAuth("wrong-token"), + expectedReason: "invalid-proxy-auth", + }, + ])("refuses $label authentication on CONNECT and forwarded requests", async (testCase) => { + const connect = await rawConnect({ auth: testCase.auth }); + expect(connect.response).toContain("407 Proxy Authentication Required"); + connect.socket.destroy(); + + await expect(forwardedRequest(testCase.auth)).resolves.toBe(407); + expect(originRequests).toEqual([]); + expect(auditEvents).toEqual([ + expect.objectContaining({ kind: "refused", reason: testCase.expectedReason }), + expect.objectContaining({ kind: "refused", reason: testCase.expectedReason }), + ]); + }); + + it("substitutes an authenticated header and strips proxy authorization upstream", async () => { + const secret = "header-secret-value"; + const sentinel = mintSecretSentinel(secret, { label: "egress-header" }); + proxyEnv = registerSentinel({ sentinel, allowedHosts: ["LOCALHOST"] }); + expect(fs.statSync(caDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(path.join(caDir, "root-ca-key.pem")).mode & 0o777).toBe(0o600); + + await expect( + requestThroughTunnel({ headers: { Authorization: `Bearer ${sentinel}` } }), + ).resolves.toMatchObject({ body: "ok", status: 200 }); + + expect(originRequests).toHaveLength(1); + expect(originRequests[0]?.headers.authorization).toBe(`Bearer ${secret}`); + expect(originRequests[0]?.headers).not.toHaveProperty("proxy-authorization"); + expect(auditEvents).toContainEqual( + expect.objectContaining({ kind: "forwarded", host: "localhost", substituted: true }), + ); + }); + + it.each([ + { label: "an unbound host", allowedHosts: ["api.example.com"] }, + { label: "no bound hosts", allowedHosts: [] }, + ])( + "refuses substitution for $label before the real value reaches the origin", + async (testCase) => { + const secret = `never-forward-${testCase.label}`; + const sentinel = mintSecretSentinel(secret, { label: `egress-${testCase.label}` }); + proxyEnv = registerSentinel({ + sentinel, + allowedHosts: testCase.allowedHosts, + name: "SERVICE_API_KEY", + }); + + const result = await requestThroughTunnel({ + headers: { Authorization: `Bearer ${sentinel}` }, + }); + + expect(result).toMatchObject({ status: 502 }); + expect(result.body).toContain( + "openclaw secrets store set SERVICE_API_KEY --allow-host localhost", + ); + expect(originRequests).toEqual([]); + expect(JSON.stringify(originRequests)).not.toContain(secret); + expect(auditEvents.at(-1)).toMatchObject({ + kind: "refused", + host: "localhost", + reason: "destination-not-allowed", + }); + }, + ); + + it.each(["url", "header", "body"] as const)( + "refuses an unresolved sentinel in the %s", + async (location) => { + const unknown = tamperSentinel( + mintSecretSentinel(`unknown-${location}`, { label: `egress-${location}` }), + ); + const before = originRequests.length; + const result = await requestThroughTunnel({ + path: location === "url" ? `/refuse?token=${unknown}` : "/refuse", + headers: location === "header" ? { "X-Token": unknown } : undefined, + bodyChunks: location === "body" ? [unknown] : undefined, + }); + + expect(result.status).toBe(502); + expect(originRequests).toHaveLength(before); + expect(auditEvents.at(-1)).toMatchObject({ + kind: "refused", + reason: "unresolved-sentinel", + }); + }, + ); + + it("substitutes a streamed body larger than the maximum carry window", async () => { + const secret = "stream-boundary-secret"; + const sentinel = mintSecretSentinel(secret, { label: "egress-stream" }); + proxyEnv = registerSentinel({ sentinel, allowedHosts: ["localhost"] }); + const split = SECRET_SENTINEL_PREFIX.length + 3; + const prefix = "x".repeat(SECRET_SENTINEL_MAX_LENGTH + 1024); + const suffix = "y".repeat(2048); + + await expect( + requestThroughTunnel({ + bodyChunks: [prefix, sentinel.slice(0, split), sentinel.slice(split), suffix], + }), + ).resolves.toMatchObject({ status: 200 }); + + expect(originRequests.at(-1)?.body).toBe(`${prefix}${secret}${suffix}`); + expect(originRequests.at(-1)?.body).not.toContain(sentinel); + }); + + it("blind-tunnels bypassed hosts without substituting sentinels", async () => { + const bypassEvents: SecretEgressProxyAuditEvent[] = []; + const bypassProxy = await startSecretEgressProxyServer({ + caDir: fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-egress-bypass-test-")), + bypassHosts: ["localhost"], + onAudit: (event) => bypassEvents.push(event), + }); + proxies.push(bypassProxy); + tempDirs.push(path.dirname(bypassProxy.caCertPath)); + const bypassEnv = bypassProxy.registerRun(run); + const sentinel = mintSecretSentinel("bypass-secret", { label: "egress-bypass" }); + + await expect( + requestThroughTunnel({ + caPath: proxy.caCertPath, + headers: { Authorization: `Bearer ${sentinel}` }, + proxyEnv: bypassEnv, + }), + ).resolves.toMatchObject({ status: 200 }); + + expect(originRequests.at(-1)?.headers.authorization).toBe(`Bearer ${sentinel}`); + expect(bypassEvents).toEqual([ + expect.objectContaining({ + kind: "forwarded", + reason: "bypass", + substituted: false, + }), + ]); + }); + + it("revokes Basic authorization with the exact owning run and keeps audits payload-free", async () => { + const secret = "audit-secret-value"; + const sentinel = mintSecretSentinel(secret, { label: "egress-audit" }); + proxyEnv = registerSentinel({ sentinel, allowedHosts: ["localhost"] }); + await requestThroughTunnel({ headers: { "X-Secret": sentinel } }); + await expect( + forwardedRequest(basicProxyAuth(registeredPassword(proxyEnv)), "http"), + ).resolves.toBe(502); + expect(auditEvents).toContainEqual( + expect.objectContaining({ kind: "refused", reason: "non-https-request" }), + ); + + proxy.revokeRun(run); + const refused = await rawConnect({ + auth: basicProxyAuth(registeredPassword(proxyEnv)), + }); + expect(refused.response).toContain("407 Proxy Authentication Required"); + refused.socket.destroy(); + + const auditText = JSON.stringify(auditEvents); + expect(auditText).not.toContain(secret); + expect(auditText).not.toContain(sentinel); + }); +}); diff --git a/src/secrets/egress-proxy/proxy-server.ts b/src/secrets/egress-proxy/proxy-server.ts new file mode 100644 index 000000000000..a142ab5f5168 --- /dev/null +++ b/src/secrets/egress-proxy/proxy-server.ts @@ -0,0 +1,621 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import fs from "node:fs"; +import { + createServer as createHttpServer, + type IncomingHttpHeaders, + type IncomingMessage, + type RequestOptions, + type ServerResponse, +} from "node:http"; +import { + Agent as HttpsAgent, + createServer as createHttpsServer, + request as httpsRequest, + type Server as HttpsServer, +} from "node:https"; +import net, { type Socket } from "node:net"; +import path from "node:path"; +import type { Duplex } from "node:stream"; +import { rootCertificates } from "node:tls"; +import { domainToASCII, URL } from "node:url"; +import { ensureSecretEgressProxyCa, generateLocalProxyLeaf } from "../../proxy-capture/ca.js"; +import { + containsSecretSentinel, + resolveSecretSentinel, + SECRET_SENTINEL_PATTERN, +} from "../sentinel.js"; +import { + createSecretEgressBodyTransform, + SecretEgressSubstitutionError, + type SecretEgressRefusalReason, +} from "./stream-substitution.js"; + +const PROXY_AUTH_USERNAME = "openclaw"; +const PROXY_AUTH_REALM = "OpenClaw secret egress"; +const REFUSAL_BODY = "Secret egress proxy refused the request.\n"; +const UPSTREAM_ERROR_BODY = "Secret egress proxy could not reach the upstream host.\n"; + +type SecretEgressProxyAuditEvent = { + kind: "forwarded" | "refused"; + host: string; + substituted: boolean; + reason?: SecretEgressRefusalReason | "bypass"; +}; + +export type SecretEgressSentinelBinding = Readonly<{ + name: string; + sentinel: string; + allowedHosts: readonly string[]; +}>; + +export type SecretEgressProxyHandle = { + caCertPath: string; + proxyOrigin: string; + registerRun: ( + run: Readonly<{ instanceId: string; runId: string }>, + bindings?: readonly SecretEgressSentinelBinding[], + ) => Record; + revokeRun: (run: Readonly<{ instanceId: string; runId: string }>) => void; + stop: () => Promise; +}; + +type ConnectTarget = { hostname: string; port: number }; +type RegisteredRun = { + digest: Buffer; + key: string; + sentinelBindings: Map; name: string }>; + token: string; +}; + +function normalizeHostname(raw: string): string { + const trimmed = raw.trim().toLowerCase().replace(/\.+$/, ""); + const unbracketed = + trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed; + if (net.isIP(unbracketed)) { + return unbracketed; + } + const ascii = domainToASCII(unbracketed); + if ( + !ascii || + ascii.length > 253 || + ascii + .split(".") + .some( + (label) => + !label || + label.length > 63 || + label.startsWith("-") || + label.endsWith("-") || + !/^[a-z0-9-]+$/u.test(label), + ) + ) { + throw new Error("Invalid proxy target hostname"); + } + return ascii; +} + +function parseConnectTarget(rawTarget: string | undefined): ConnectTarget { + const raw = rawTarget?.trim(); + if (!raw || /[\r\n]/u.test(raw)) { + throw new Error("Invalid CONNECT target"); + } + const target = new URL(`https://${raw}`); + if ( + target.pathname !== "/" || + target.search || + target.hash || + target.username || + target.password + ) { + throw new Error("Invalid CONNECT target"); + } + const port = target.port ? Number(target.port) : 443; + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("Invalid CONNECT target port"); + } + return { hostname: normalizeHostname(target.hostname), port }; +} + +function runKey(run: Readonly<{ instanceId: string; runId: string }>): string { + return `${run.runId}\0${run.instanceId}`; +} + +// Proxy tokens are 256-bit random bearer credentials, not user-chosen passwords, so a +// slow KDF would add per-request latency on the proxy hot path without making brute force +// any more infeasible. A process-keyed MAC is the right primitive: it normalizes attacker- +// controlled input to a fixed length for constant-time compare, and a leaked digest cannot +// be correlated back to a token without the in-memory key. +const tokenMacKey = randomBytes(32); + +function tokenDigest(token: string): Buffer { + return createHmac("sha256", tokenMacKey).update(token).digest(); +} + +function parseBasicProxyPassword(header: string | string[] | undefined): string | undefined { + if (typeof header !== "string") { + return undefined; + } + const match = /^Basic\s+([A-Za-z0-9+/]+={0,2})$/iu.exec(header.trim()); + if (!match?.[1]) { + return undefined; + } + let decoded: string; + try { + decoded = Buffer.from(match[1], "base64").toString("utf8"); + } catch { + return undefined; + } + const colon = decoded.indexOf(":"); + if (colon === -1 || decoded.slice(0, colon) !== PROXY_AUTH_USERNAME) { + return undefined; + } + return decoded.slice(colon + 1); +} + +function sendProxyAuthRequired(socket: Duplex): void { + socket.end( + `HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="${PROXY_AUTH_REALM}"\r\nConnection: close\r\nContent-Length: ${Buffer.byteLength(REFUSAL_BODY)}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${REFUSAL_BODY}`, + ); +} + +function sendHttpRefusal(res: ServerResponse, status = 502, body = REFUSAL_BODY): void { + if (res.destroyed || res.writableEnded) { + return; + } + if (res.headersSent) { + res.destroy(); + return; + } + res.writeHead(status, { + Connection: "close", + "Content-Length": Buffer.byteLength(body), + "Content-Type": "text/plain; charset=utf-8", + }); + res.end(body); +} + +function resolveRegisteredSentinel(params: { + sentinel: string; + host: string; + registered: RegisteredRun; +}): string | undefined { + const binding = params.registered.sentinelBindings.get(params.sentinel); + if (!binding) { + return undefined; + } + if (!binding.allowedHosts.has(params.host)) { + throw new SecretEgressSubstitutionError("destination-not-allowed", { + host: params.host, + secretName: binding.name, + }); + } + return resolveSecretSentinel(params.sentinel); +} + +function swapRequestText(params: { + value: string; + urlMode: boolean; + host: string; + registered: RegisteredRun; +}): { value: string; substituted: boolean } { + if (!containsSecretSentinel(params.value)) { + return { value: params.value, substituted: false }; + } + let substituted = false; + const swapped = params.value.replace( + new RegExp(SECRET_SENTINEL_PATTERN.source, "g"), + (sentinel) => { + const resolved = resolveRegisteredSentinel({ + sentinel, + host: params.host, + registered: params.registered, + }); + if (resolved === undefined) { + return sentinel; + } + substituted = true; + return params.urlMode ? encodeURIComponent(resolved) : resolved; + }, + ); + if (containsSecretSentinel(swapped)) { + throw new SecretEgressSubstitutionError("unresolved-sentinel"); + } + return { value: swapped, substituted }; +} + +function swapRequestHeaders(params: { + headers: IncomingHttpHeaders; + host: string; + registered: RegisteredRun; +}): { + headers: IncomingHttpHeaders; + substituted: boolean; +} { + const output: IncomingHttpHeaders = {}; + let substituted = false; + for (const [name, rawValue] of Object.entries(params.headers)) { + const lowerName = name.toLowerCase(); + if (lowerName === "proxy-authorization" || lowerName === "proxy-connection") { + continue; + } + if (Array.isArray(rawValue)) { + output[name] = rawValue.map((value) => { + const swapped = swapRequestText({ + value, + urlMode: false, + host: params.host, + registered: params.registered, + }); + substituted ||= swapped.substituted; + return swapped.value; + }); + continue; + } + if (rawValue !== undefined) { + const swapped = swapRequestText({ + value: rawValue, + urlMode: false, + host: params.host, + registered: params.registered, + }); + substituted ||= swapped.substituted; + output[name] = swapped.value; + } + } + delete output["content-length"]; + delete output["transfer-encoding"]; + return { headers: output, substituted }; +} + +function createUpstreamRequestOptions(params: { + target: URL; + request: IncomingMessage; + headers: IncomingHttpHeaders; +}): RequestOptions { + return { + hostname: params.target.hostname, + port: params.target.port || (params.target.protocol === "https:" ? 443 : 80), + path: `${params.target.pathname}${params.target.search}`, + method: params.request.method, + headers: params.headers, + }; +} + +/** Starts one authenticated, loopback-only substitution proxy. */ +export async function startSecretEgressProxyServer(params: { + caDir: string; + bypassHosts?: readonly string[]; + onAudit: (event: SecretEgressProxyAuditEvent) => void; +}): Promise { + const ca = await ensureSecretEgressProxyCa(params.caDir); + const caPem = fs.readFileSync(ca.certPath, "utf8"); + const trustBundlePath = path.join(params.caDir, "trust-bundle.pem"); + fs.writeFileSync(trustBundlePath, `${rootCertificates.join("\n")}\n${caPem}`, { mode: 0o644 }); + const upstreamTlsAgent = new HttpsAgent({ + ca: [...rootCertificates, caPem], + }); + const bypassHosts = new Set((params.bypassHosts ?? []).map(normalizeHostname)); + const tokens = new Map(); + const sockets = new Set(); + const tlsServers = new Map>(); + + const audit = (event: SecretEgressProxyAuditEvent) => params.onAudit(event); + const authorize = ( + headers: IncomingHttpHeaders, + ): RegisteredRun | Exclude => { + const rawHeader = headers["proxy-authorization"]; + if (rawHeader === undefined) { + return "missing-proxy-auth"; + } + const password = parseBasicProxyPassword(rawHeader); + if (!password) { + return "invalid-proxy-auth"; + } + const candidate = tokenDigest(password); + for (const registered of tokens.values()) { + if (timingSafeEqual(candidate, registered.digest)) { + return registered; + } + } + return "invalid-proxy-auth"; + }; + + const forwardRequest = (forward: { + request: IncomingMessage; + response: ServerResponse; + target: URL; + registered: RegisteredRun; + }) => { + const host = normalizeHostname(forward.target.hostname); + if (forward.target.protocol !== "https:") { + audit({ + kind: "refused", + host, + substituted: false, + reason: "non-https-request", + }); + sendHttpRefusal(forward.response); + forward.request.resume(); + return; + } + let substituted = false; + let target: URL; + let headers: IncomingHttpHeaders; + try { + const swappedUrl = swapRequestText({ + value: forward.target.toString(), + urlMode: true, + host, + registered: forward.registered, + }); + target = new URL(swappedUrl.value); + const swappedHeaders = swapRequestHeaders({ + headers: forward.request.headers, + host, + registered: forward.registered, + }); + headers = swappedHeaders.headers; + headers.host = target.host; + substituted = swappedUrl.substituted || swappedHeaders.substituted; + } catch (error) { + const reason = + error instanceof SecretEgressSubstitutionError ? error.reason : "unresolved-sentinel"; + audit({ kind: "refused", host, substituted, reason }); + sendHttpRefusal( + forward.response, + 502, + error instanceof SecretEgressSubstitutionError ? `${error.message}\n` : REFUSAL_BODY, + ); + forward.request.resume(); + return; + } + + const bodyTransform = createSecretEgressBodyTransform({ + onSubstitution: () => { + substituted = true; + }, + resolveSentinel: (sentinel) => + resolveRegisteredSentinel({ sentinel, host, registered: forward.registered }), + }); + let refused = false; + let forwardedLogged = false; + const requestOptions = createUpstreamRequestOptions({ + target, + request: forward.request, + headers, + }); + const upstream = httpsRequest( + { + ...requestOptions, + agent: upstreamTlsAgent, + }, + (upstreamResponse) => { + if (refused) { + upstreamResponse.destroy(); + return; + } + forward.response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(forward.response); + }, + ); + bodyTransform.once("finish", () => { + if (!refused && !forwardedLogged) { + forwardedLogged = true; + audit({ kind: "forwarded", host, substituted }); + } + }); + bodyTransform.once("error", (error) => { + if (refused) { + return; + } + refused = true; + forward.request.unpipe(bodyTransform); + forward.request.resume(); + upstream.destroy(); + const reason = + error instanceof SecretEgressSubstitutionError ? error.reason : "unresolved-sentinel"; + audit({ kind: "refused", host, substituted, reason }); + sendHttpRefusal( + forward.response, + 502, + error instanceof SecretEgressSubstitutionError ? `${error.message}\n` : REFUSAL_BODY, + ); + }); + upstream.once("error", () => { + if (refused) { + return; + } + refused = true; + audit({ kind: "refused", host, substituted, reason: "upstream-error" }); + sendHttpRefusal(forward.response, 502, UPSTREAM_ERROR_BODY); + }); + forward.request.pipe(bodyTransform).pipe(upstream); + }; + + const tlsServerFor = (target: ConnectTarget, registered: RegisteredRun): Promise => { + const key = `${registered.key}\0${target.hostname}:${target.port}`; + let server = tlsServers.get(key); + if (!server) { + server = generateLocalProxyLeaf({ + certDir: params.caDir, + ca, + hostname: target.hostname, + }).then((leaf) => + createHttpsServer(leaf, (request, response) => { + const targetUrl = new URL( + request.url ?? "/", + `https://${target.hostname}${target.port === 443 ? "" : `:${target.port}`}`, + ); + forwardRequest({ request, response, target: targetUrl, registered }); + }), + ); + tlsServers.set(key, server); + } + return server; + }; + + const proxy = createHttpServer((request, response) => { + let target: URL; + try { + target = new URL(request.url ?? ""); + } catch { + audit({ + kind: "refused", + host: request.headers.host ?? "unknown", + substituted: false, + reason: "upstream-error", + }); + sendHttpRefusal(response, 400); + return; + } + const host = normalizeHostname(target.hostname); + const authorization = authorize(request.headers); + if (typeof authorization === "string") { + audit({ kind: "refused", host, substituted: false, reason: authorization }); + response.writeHead(407, { + "Proxy-Authenticate": `Basic realm="${PROXY_AUTH_REALM}"`, + Connection: "close", + "Content-Length": Buffer.byteLength(REFUSAL_BODY), + "Content-Type": "text/plain; charset=utf-8", + }); + response.end(REFUSAL_BODY); + request.resume(); + return; + } + forwardRequest({ request, response, target, registered: authorization }); + }); + + proxy.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + proxy.on("connect", (request, clientSocket, head) => { + void (async () => { + let target: ConnectTarget; + try { + target = parseConnectTarget(request.url); + } catch { + audit({ kind: "refused", host: "unknown", substituted: false, reason: "upstream-error" }); + clientSocket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + return; + } + const authorization = authorize(request.headers); + if (typeof authorization === "string") { + audit({ + kind: "refused", + host: target.hostname, + substituted: false, + reason: authorization, + }); + sendProxyAuthRequired(clientSocket); + return; + } + if (bypassHosts.has(target.hostname)) { + const upstream = net.connect(target.port, target.hostname, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) { + upstream.write(head); + } + clientSocket.pipe(upstream).pipe(clientSocket); + audit({ + kind: "forwarded", + host: target.hostname, + substituted: false, + reason: "bypass", + }); + }); + sockets.add(upstream); + upstream.once("close", () => sockets.delete(upstream)); + upstream.once("error", () => clientSocket.destroy()); + return; + } + try { + const tlsServer = await tlsServerFor(target, authorization); + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) { + clientSocket.unshift(head); + } + tlsServer.emit("connection", clientSocket); + } catch { + audit({ + kind: "refused", + host: target.hostname, + substituted: false, + reason: "upstream-error", + }); + clientSocket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + } + })(); + }); + + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(0, "127.0.0.1", () => { + proxy.off("error", reject); + resolve(); + }); + }); + const address = proxy.address(); + if (!address || typeof address === "string") { + throw new Error("Secret egress proxy failed to bind loopback"); + } + const proxyOrigin = `http://127.0.0.1:${address.port}`; + let stopped = false; + return { + caCertPath: ca.certPath, + proxyOrigin, + registerRun: (run, bindings = []) => { + const key = runKey(run); + let registered = tokens.get(key); + if (!registered) { + const token = randomBytes(32).toString("base64url"); + registered = { + digest: tokenDigest(token), + key, + sentinelBindings: new Map(), + token, + }; + tokens.set(key, registered); + } + registered.sentinelBindings = new Map( + bindings.map((binding) => [ + binding.sentinel, + { + allowedHosts: new Set(binding.allowedHosts.map(normalizeHostname)), + name: binding.name, + }, + ]), + ); + // Basic is deliberately used because curl and Go net/http derive it from + // proxy-URL credentials. Base64 is acceptable here: loopback is the only + // listener, the token is run-scoped, and a process that can read it from + // this env can already read the sentinels that authorize substitution. + const proxyUrl = `http://${PROXY_AUTH_USERNAME}:${registered.token}@127.0.0.1:${address.port}`; + return { + HTTPS_PROXY: proxyUrl, + HTTP_PROXY: proxyUrl, + NODE_EXTRA_CA_CERTS: trustBundlePath, + SSL_CERT_FILE: trustBundlePath, + CURL_CA_BUNDLE: trustBundlePath, + REQUESTS_CA_BUNDLE: trustBundlePath, + }; + }, + revokeRun: (run) => { + tokens.delete(runKey(run)); + }, + stop: async () => { + if (stopped) { + return; + } + stopped = true; + tokens.clear(); + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + await new Promise((resolve) => { + proxy.close(() => resolve()); + }); + }, + }; +} diff --git a/src/secrets/egress-proxy/registry.ts b/src/secrets/egress-proxy/registry.ts new file mode 100644 index 000000000000..071e10ade2cd --- /dev/null +++ b/src/secrets/egress-proxy/registry.ts @@ -0,0 +1,43 @@ +import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; +import type { SecretEgressProxyHandle, SecretEgressSentinelBinding } from "./proxy-server.js"; + +type SecretEgressProxyRegistryState = { activeProxy?: SecretEgressProxyHandle }; +const SECRET_EGRESS_PROXY_REGISTRY_KEY = Symbol.for("openclaw.secretEgressProxy.registry"); + +function getSecretEgressProxyRegistry(): SecretEgressProxyRegistryState { + return resolveGlobalSingleton( + SECRET_EGRESS_PROXY_REGISTRY_KEY, + () => ({}), + ); +} + +export function publishSecretEgressProxy(proxy: SecretEgressProxyHandle): void { + const registry = getSecretEgressProxyRegistry(); + if (registry.activeProxy) { + throw new Error("Secret egress proxy is already active in this process"); + } + registry.activeProxy = proxy; +} + +export function clearSecretEgressProxy(proxy: SecretEgressProxyHandle): void { + const registry = getSecretEgressProxyRegistry(); + if (registry.activeProxy === proxy) { + registry.activeProxy = undefined; + } +} + +export function isSecretEgressProxyActive(): boolean { + return getSecretEgressProxyRegistry().activeProxy !== undefined; +} + +/** Returns the trusted subprocess environment for one exact admitted agent run. */ +export function registerSecretEgressProxyRun( + run: Readonly<{ instanceId: string; runId: string }>, + bindings: readonly SecretEgressSentinelBinding[], +): Record { + const proxy = getSecretEgressProxyRegistry().activeProxy; + if (!proxy) { + throw new Error("Secret egress proxy is not active in this Gateway process"); + } + return proxy.registerRun(run, bindings); +} diff --git a/src/secrets/egress-proxy/runtime.ts b/src/secrets/egress-proxy/runtime.ts new file mode 100644 index 000000000000..8152ea7e283b --- /dev/null +++ b/src/secrets/egress-proxy/runtime.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveStateDir } from "../../config/paths.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { startSecretEgressProxyServer, type SecretEgressProxyHandle } from "./proxy-server.js"; +import { clearSecretEgressProxy, publishSecretEgressProxy } from "./registry.js"; + +const log = createSubsystemLogger("secrets/egress-proxy"); +const SECRET_EGRESS_PROXY_DIR_MODE = 0o700; + +function removeProxyDirBestEffort(proxyDir: string): void { + try { + fs.rmSync(proxyDir, { recursive: true, force: true }); + fs.rmdirSync(path.dirname(proxyDir)); + } catch { + // The Gateway-owned CA is already unusable after token and socket teardown. + } +} + +function removeStaleProxyDirs(parentDir: string): void { + for (const entry of fs.readdirSync(parentDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith("gateway-")) { + fs.rmSync(path.join(parentDir, entry.name), { recursive: true, force: true }); + } + } +} + +/** Starts the process-local proxy and registers it as the current Gateway owner. */ +export async function startGatewaySecretEgressProxy(params: { + bypassHosts?: readonly string[]; +}): Promise { + const parentDir = path.join(resolveStateDir(), "secret-egress-proxy"); + fs.mkdirSync(parentDir, { recursive: true, mode: SECRET_EGRESS_PROXY_DIR_MODE }); + fs.chmodSync(parentDir, SECRET_EGRESS_PROXY_DIR_MODE); + removeStaleProxyDirs(parentDir); + const proxyDir = fs.mkdtempSync(path.join(parentDir, "gateway-")); + fs.chmodSync(proxyDir, SECRET_EGRESS_PROXY_DIR_MODE); + let proxy: SecretEgressProxyHandle | undefined; + try { + proxy = await startSecretEgressProxyServer({ + caDir: proxyDir, + ...(params.bypassHosts ? { bypassHosts: params.bypassHosts } : {}), + onAudit: (event) => log.info("secret egress request", event), + }); + const ownedProxy = proxy; + const cleanupOnProcessExit = () => removeProxyDirBestEffort(proxyDir); + process.once("exit", cleanupOnProcessExit); + const handle: SecretEgressProxyHandle = { + ...ownedProxy, + stop: async () => { + clearSecretEgressProxy(handle); + process.off("exit", cleanupOnProcessExit); + try { + await ownedProxy.stop(); + } finally { + removeProxyDirBestEffort(proxyDir); + } + }, + }; + publishSecretEgressProxy(handle); + return handle; + } catch (error) { + await proxy?.stop().catch(() => undefined); + removeProxyDirBestEffort(proxyDir); + throw error; + } +} diff --git a/src/secrets/egress-proxy/stream-substitution.ts b/src/secrets/egress-proxy/stream-substitution.ts new file mode 100644 index 000000000000..486fb0802bf5 --- /dev/null +++ b/src/secrets/egress-proxy/stream-substitution.ts @@ -0,0 +1,120 @@ +import { Transform, type TransformCallback } from "node:stream"; +import { + looksLikeSecretSentinel, + SECRET_SENTINEL_MAX_LENGTH, + SECRET_SENTINEL_PREFIX, + SECRET_SENTINEL_SUFFIX, +} from "../sentinel.js"; + +const SENTINEL_PREFIX_BYTES = Buffer.from(SECRET_SENTINEL_PREFIX); +const SENTINEL_SUFFIX_BYTES = Buffer.from(SECRET_SENTINEL_SUFFIX); + +export type SecretEgressRefusalReason = + | "invalid-proxy-auth" + | "missing-proxy-auth" + | "non-https-request" + | "non-https-port" + | "destination-not-allowed" + | "unresolved-sentinel" + | "upstream-error"; + +export class SecretEgressSubstitutionError extends Error { + constructor( + readonly reason: SecretEgressRefusalReason, + readonly details?: { host: string; secretName: string }, + ) { + super( + details + ? `Secret "${details.secretName}" is not allowed for host "${details.host}". Run: openclaw secrets store set ${details.secretName} --allow-host ${details.host}` + : "Secret egress proxy refused an unresolved secret sentinel", + ); + this.name = "SecretEgressSubstitutionError"; + } +} + +function processPendingBuffer(params: { + buffer: Buffer; + flush: boolean; + onSubstitution: () => void; + resolveSentinel: (sentinel: string) => string | undefined; + push: (chunk: Buffer) => void; +}): Buffer { + let pending = params.buffer; + for (;;) { + const prefixIndex = pending.indexOf(SENTINEL_PREFIX_BYTES); + if (prefixIndex === -1) { + const carryBytes = params.flush + ? 0 + : Math.min(pending.length, SENTINEL_PREFIX_BYTES.length - 1); + const emitBytes = pending.length - carryBytes; + if (emitBytes > 0) { + params.push(pending.subarray(0, emitBytes)); + } + return carryBytes > 0 ? pending.subarray(emitBytes) : Buffer.alloc(0); + } + if (prefixIndex > 0) { + params.push(pending.subarray(0, prefixIndex)); + pending = pending.subarray(prefixIndex); + } + const suffixIndex = pending.indexOf(SENTINEL_SUFFIX_BYTES, SENTINEL_PREFIX_BYTES.length); + if (suffixIndex === -1) { + if (params.flush || pending.length > SECRET_SENTINEL_MAX_LENGTH) { + throw new SecretEgressSubstitutionError("unresolved-sentinel"); + } + return pending; + } + const sentinelEnd = suffixIndex + SENTINEL_SUFFIX_BYTES.length; + if (sentinelEnd > SECRET_SENTINEL_MAX_LENGTH) { + throw new SecretEgressSubstitutionError("unresolved-sentinel"); + } + const sentinel = pending.subarray(0, sentinelEnd).toString("ascii"); + const resolved = looksLikeSecretSentinel(sentinel) + ? params.resolveSentinel(sentinel) + : undefined; + if (resolved === undefined) { + throw new SecretEgressSubstitutionError("unresolved-sentinel"); + } + params.push(Buffer.from(resolved, "utf8")); + params.onSubstitution(); + pending = pending.subarray(sentinelEnd); + } +} + +/** Rewrites process-local sentinels across arbitrary request-body chunk boundaries. */ +export function createSecretEgressBodyTransform(params: { + onSubstitution: () => void; + resolveSentinel: (sentinel: string) => string | undefined; +}): Transform { + let pending: Buffer = Buffer.alloc(0); + return new Transform({ + transform(chunk: Buffer | string, _encoding: BufferEncoding, callback: TransformCallback) { + try { + const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + pending = processPendingBuffer({ + buffer: pending.length > 0 ? Buffer.concat([pending, input]) : input, + flush: false, + onSubstitution: params.onSubstitution, + resolveSentinel: params.resolveSentinel, + push: (output) => this.push(output), + }); + callback(); + } catch (error) { + callback(error as Error); + } + }, + flush(callback: TransformCallback) { + try { + pending = processPendingBuffer({ + buffer: pending, + flush: true, + onSubstitution: params.onSubstitution, + resolveSentinel: params.resolveSentinel, + push: (output) => this.push(output), + }); + callback(); + } catch (error) { + callback(error as Error); + } + }, + }); +} diff --git a/src/secrets/sentinel.ts b/src/secrets/sentinel.ts index 504a2a58817a..8e40a8b1d367 100644 --- a/src/secrets/sentinel.ts +++ b/src/secrets/sentinel.ts @@ -1,8 +1,9 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto"; import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; -const SECRET_SENTINEL_PREFIX = "oc-sent-v2."; -const SECRET_SENTINEL_SUFFIX = ".end"; +export const SECRET_SENTINEL_PREFIX = "oc-sent-v2."; +export const SECRET_SENTINEL_SUFFIX = ".end"; const SECRET_SENTINEL_SOURCE = "oc-sent-v2\\.[A-Za-z0-9_-]+\\.end"; const SECRET_SENTINEL_CIPHER = "aes-256-gcm"; const SECRET_SENTINEL_NONCE_BYTES = 12; @@ -10,12 +11,23 @@ const SECRET_SENTINEL_SCOPE_BYTES = 8; const SECRET_SENTINEL_TAG_BYTES = 16; const SECRET_SENTINEL_HEADER_BYTES = SECRET_SENTINEL_SCOPE_BYTES + SECRET_SENTINEL_NONCE_BYTES + SECRET_SENTINEL_TAG_BYTES; +// Shared-store secrets are capped at 64 KiB. The egress proxy uses this bound +// to keep only one maximum sentinel in memory while scanning streamed bodies. +export const SECRET_SENTINEL_MAX_LENGTH = + SECRET_SENTINEL_PREFIX.length + + Math.ceil(((SECRET_SENTINEL_HEADER_BYTES + 64 * 1024) * 4) / 3) + + SECRET_SENTINEL_SUFFIX.length; export const SECRET_SENTINEL_PATTERN = new RegExp(SECRET_SENTINEL_SOURCE, "g"); -// One process key keeps sentinels resolvable for in-flight requests without a -// plaintext registry that retains every historical credential until exit. -const secretSentinelKeys = randomBytes(64); +type SecretSentinelKeyState = { keys: Buffer }; +const SECRET_SENTINEL_KEY_STATE = Symbol.for("openclaw.secretSentinel.keys"); +// Bundled runtime chunks can instantiate this module independently. A process-global +// key keeps their sentinels interoperable without retaining a plaintext registry. +const secretSentinelKeys = resolveGlobalSingleton( + SECRET_SENTINEL_KEY_STATE, + () => ({ keys: randomBytes(64) }), +).keys; const secretSentinelCipherKey = secretSentinelKeys.subarray(0, 32); const secretSentinelNonceKey = secretSentinelKeys.subarray(32); diff --git a/src/secrets/store/secret-store.test.ts b/src/secrets/store/secret-store.test.ts index b1f02fd5cb16..cf0601a9ffa6 100644 --- a/src/secrets/store/secret-store.test.ts +++ b/src/secrets/store/secret-store.test.ts @@ -13,6 +13,7 @@ import { deleteSecretStoreEntry, listSecretStoreEntries, purgeExpiredSecretStoreEntries, + readSecretStoreExecEnvironment, readSecretStoreValue, SECRET_STORE_VALUE_MAX_BYTES, writeSecretStoreEntry, @@ -51,12 +52,17 @@ describe("secret store", () => { name: "SERVICE_API_KEY", value: "stored-super-secret", kind: "secret", + allowedHosts: ["API.EXAMPLE.COM", "bücher.example"], updatedBy: "test", database, }); expect(listSecretStoreEntries({ scope: team, database })).toEqual([ - expect.objectContaining({ name: "SERVICE_API_KEY", kind: "secret" }), + expect.objectContaining({ + name: "SERVICE_API_KEY", + kind: "secret", + allowedHosts: ["api.example.com", "xn--bcher-kva.example"], + }), expect.objectContaining({ name: "SERVICE_URL", kind: "env", @@ -69,6 +75,15 @@ describe("secret store", () => { value: "stored-super-secret", }); expect(isSecretValueRegisteredForRedaction("stored-super-secret")).toBe(true); + expect( + readSecretStoreExecEnvironment({ includeSecretSentinels: true, database }) + .secretEgressBindings, + ).toEqual([ + expect.objectContaining({ + name: "SERVICE_API_KEY", + allowedHosts: ["api.example.com", "xn--bcher-kva.example"], + }), + ]); }); it("soft-deletes idempotently and purges after the 30-day retention", () => { @@ -138,6 +153,23 @@ describe("secret store", () => { ).toThrow(expect.objectContaining({ code: "SECRET_STORE_VALUE_TOO_LARGE" })); }); + it.each(["*.example.com", "https://api.example.com", "api.example.com:443", "bad host"])( + "rejects invalid allowed host %s at write time", + (allowedHost) => { + expect(() => + writeSecretStoreEntry({ + scope: team, + name: "HOST_BOUND_SECRET", + value: "value", + kind: "secret", + allowedHosts: [allowedHost], + updatedBy: null, + database: createDatabaseOptions(), + }), + ).toThrow(expect.objectContaining({ code: "SECRET_STORE_INVALID_ALLOWED_HOST" })); + }, + ); + it("rejects an empty secret value but keeps empty env values legal", () => { const database = createDatabaseOptions(); // A silently-empty secret (a failed `op read |` pipe) is undiagnosable later: diff --git a/src/secrets/store/secret-store.ts b/src/secrets/store/secret-store.ts index 4c4421fb8848..ca521b8e9ed3 100644 --- a/src/secrets/store/secret-store.ts +++ b/src/secrets/store/secret-store.ts @@ -1,3 +1,5 @@ +import net from "node:net"; +import { domainToASCII } from "node:url"; import { err, ok, type Result } from "@openclaw/normalization-core/result"; import type { Selectable } from "kysely"; import { ENV_SECRET_REF_ID_RE } from "../../config/types.secrets.js"; @@ -16,6 +18,7 @@ import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "../../state/openclaw-state-db.js"; +import { mintSecretSentinel } from "../sentinel.js"; type SecretStoreDatabase = Pick; type SecretStoreRow = Selectable; @@ -30,9 +33,22 @@ export type SecretStoreEntryMetadata = { updatedAtMs: number; createdAtMs: number; updatedBy: string | null; + allowedHosts?: string[]; valuePreview?: string; }; +export type SecretStoreEgressBinding = { + name: string; + sentinel: string; + allowedHosts: string[]; +}; + +export type SecretStoreExecEnvironment = { + env?: Record; + secretSentinels?: Record; + secretEgressBindings?: SecretStoreEgressBinding[]; +}; + type SecretStoreReadError = | { code: "SECRET_STORE_NOT_FOUND"; message: string } | { code: "SECRET_STORE_INVALID_NAME"; message: string } @@ -40,6 +56,7 @@ type SecretStoreReadError = type SecretStoreValidationCode = | "SECRET_STORE_INVALID_NAME" + | "SECRET_STORE_INVALID_ALLOWED_HOST" | "SECRET_STORE_VALUE_TOO_LARGE" | "SECRET_STORE_VALUE_EMPTY"; @@ -54,6 +71,7 @@ export class SecretStoreValidationError extends Error { } export const SECRET_STORE_VALUE_MAX_BYTES = 64 * 1024; +export const SECRET_STORE_ALLOWED_HOSTS_MAX = 128; const SECRET_STORE_RETENTION_MS = 30 * 24 * 60 * 60_000; function normalizeScope(_scope: SecretStoreScope): { scopeKind: "team"; scopeId: "" } { @@ -89,6 +107,73 @@ function assertSecretStoreValue(value: string, kind: SecretStoreKind): void { } } +function normalizeSecretAllowedHost(raw: string): string { + const trimmed = raw.trim().toLowerCase().replace(/\.+$/u, ""); + if (trimmed.includes("*")) { + throw new SecretStoreValidationError( + "SECRET_STORE_INVALID_ALLOWED_HOST", + `Allowed host "${raw}" cannot contain a wildcard; use one exact hostname.`, + ); + } + const unbracketed = + trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed; + if (net.isIP(unbracketed)) { + return unbracketed; + } + if (!unbracketed || unbracketed.includes(":") || /[\s/?#@]/u.test(unbracketed)) { + throw new SecretStoreValidationError( + "SECRET_STORE_INVALID_ALLOWED_HOST", + `Allowed host "${raw}" must be a hostname without a scheme, path, wildcard, or port.`, + ); + } + const ascii = domainToASCII(unbracketed); + if ( + !ascii || + ascii.length > 253 || + ascii + .split(".") + .some( + (label) => + !label || + label.length > 63 || + label.startsWith("-") || + label.endsWith("-") || + !/^[a-z0-9-]+$/u.test(label), + ) + ) { + throw new SecretStoreValidationError( + "SECRET_STORE_INVALID_ALLOWED_HOST", + `Allowed host "${raw}" is not a valid hostname.`, + ); + } + return ascii; +} + +export function normalizeSecretAllowedHosts(hosts: readonly string[]): string[] { + if (hosts.length > SECRET_STORE_ALLOWED_HOSTS_MAX) { + throw new SecretStoreValidationError( + "SECRET_STORE_INVALID_ALLOWED_HOST", + `A secret can allow at most ${SECRET_STORE_ALLOWED_HOSTS_MAX} hosts.`, + ); + } + return [...new Set(hosts.map(normalizeSecretAllowedHost))].toSorted(); +} + +function parseSecretAllowedHosts(raw: string | null | undefined): string[] { + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) && parsed.every((host) => typeof host === "string") + ? normalizeSecretAllowedHosts(parsed) + : []; + } catch { + // Corrupt policy is never interpreted permissively: an empty list fails closed. + return []; + } +} + function isMissingSecretStoreTableError(error: unknown): boolean { return ( error instanceof Error && @@ -109,6 +194,7 @@ function toMetadata(row: SecretStoreRow): SecretStoreEntryMetadata { updatedAtMs: normalizeSqliteNumber(row.updated_at_ms) ?? 0, createdAtMs: normalizeSqliteNumber(row.created_at_ms) ?? 0, updatedBy: row.updated_by, + ...(row.kind === "secret" ? { allowedHosts: parseSecretAllowedHosts(row.allowed_hosts) } : {}), ...(row.kind === "env" ? { valuePreview: row.value } : {}), }; } @@ -143,6 +229,63 @@ export function listSecretStoreEntries(params: { } } +/** Captures one coherent team-store snapshot for an agent run's exec environment. */ +export function readSecretStoreExecEnvironment(params: { + includeSecretSentinels: boolean; + database?: OpenClawStateDatabaseOptions; +}): SecretStoreExecEnvironment { + try { + return ( + withExistingOpenClawStateDatabaseReadOnly(({ db: sqlite }) => { + const db = getNodeSqliteKysely(sqlite); + const rows = executeSqliteQuerySync( + sqlite, + db + .selectFrom("secret_store_entries") + .selectAll() + .where("scope_kind", "=", "team") + .where("scope_id", "=", "") + .where("deleted_at_ms", "is", null) + .orderBy("name", "asc"), + ).rows; + const env: Record = {}; + const secretSentinels: Record = {}; + const secretEgressBindings: SecretStoreEgressBinding[] = []; + for (const row of rows) { + if (row.kind === "env") { + env[row.name] = row.value; + continue; + } + registerSecretValueForRedaction(row.value); + if (params.includeSecretSentinels) { + // Named placeholders disclose the credential name. The existing sentinel + // is authenticated ciphertext, so an escaped value only fails vendor auth. + const sentinel = mintSecretSentinel(row.value, { + label: `exec-store:${row.name}`, + }); + secretSentinels[row.name] = sentinel; + secretEgressBindings.push({ + name: row.name, + sentinel, + allowedHosts: parseSecretAllowedHosts(row.allowed_hosts), + }); + } + } + return { + ...(Object.keys(env).length > 0 ? { env } : {}), + ...(Object.keys(secretSentinels).length > 0 ? { secretSentinels } : {}), + ...(secretEgressBindings.length > 0 ? { secretEgressBindings } : {}), + }; + }, params.database ?? {}) ?? {} + ); + } catch (error) { + if (isMissingSecretStoreTableError(error)) { + return {}; + } + throw error; + } +} + export function readSecretStoreValue(params: { scope: SecretStoreScope; name: string; @@ -197,11 +340,23 @@ export function writeSecretStoreEntry(params: { name: string; value: string; kind: SecretStoreKind; + allowedHosts?: readonly string[]; updatedBy: string | null; database?: OpenClawStateDatabaseOptions; }): void { assertSecretStoreName(params.name); assertSecretStoreValue(params.value, params.kind); + if (params.kind === "env" && params.allowedHosts !== undefined) { + throw new SecretStoreValidationError( + "SECRET_STORE_INVALID_ALLOWED_HOST", + "Allowed hosts apply only to secret entries.", + ); + } + const allowedHosts = + params.kind === "secret" && params.allowedHosts !== undefined + ? normalizeSecretAllowedHosts(params.allowedHosts) + : undefined; + const allowedHostsJson = allowedHosts?.length ? JSON.stringify(allowedHosts) : null; const { scopeKind, scopeId } = normalizeScope(params.scope); const now = Date.now(); runOpenClawStateWriteTransaction( @@ -222,6 +377,7 @@ export function writeSecretStoreEntry(params: { updated_at_ms: now, updated_by: params.updatedBy, deleted_at_ms: null, + allowed_hosts: allowedHostsJson, }) .onConflict((conflict) => conflict.columns(["scope_kind", "scope_id", "name"]).doUpdateSet({ @@ -230,6 +386,11 @@ export function writeSecretStoreEntry(params: { updated_at_ms: now, updated_by: params.updatedBy, deleted_at_ms: null, + ...(params.kind === "env" + ? { allowed_hosts: null } + : allowedHosts !== undefined + ? { allowed_hosts: allowedHostsJson } + : {}), }), ), ); @@ -239,6 +400,48 @@ export function writeSecretStoreEntry(params: { ); } +export function updateSecretStoreAllowedHosts(params: { + scope: SecretStoreScope; + name: string; + allowedHosts: readonly string[]; + updatedBy: string | null; + database?: OpenClawStateDatabaseOptions; +}): void { + assertSecretStoreName(params.name); + const allowedHosts = normalizeSecretAllowedHosts(params.allowedHosts); + const { scopeKind, scopeId } = normalizeScope(params.scope); + const now = Date.now(); + runOpenClawStateWriteTransaction( + ({ db: sqlite }) => { + ensureSecretStoreSchema(sqlite); + const db = getNodeSqliteKysely(sqlite); + const updated = executeSqliteQuerySync( + sqlite, + db + .updateTable("secret_store_entries") + .set({ + allowed_hosts: allowedHosts.length ? JSON.stringify(allowedHosts) : null, + updated_at_ms: now, + updated_by: params.updatedBy, + }) + .where("scope_kind", "=", scopeKind) + .where("scope_id", "=", scopeId) + .where("name", "=", params.name) + .where("kind", "=", "secret") + .where("deleted_at_ms", "is", null), + ); + if (Number(updated.numAffectedRows ?? 0n) !== 1) { + throw new SecretStoreValidationError( + "SECRET_STORE_INVALID_ALLOWED_HOST", + `Secret store entry "${params.name}" is missing or is not a secret entry.`, + ); + } + }, + params.database, + { operationLabel: "secrets.store.allowed-hosts" }, + ); +} + export function deleteSecretStoreEntry(params: { scope: SecretStoreScope; name: string; diff --git a/src/state/openclaw-database-maintenance.test.ts b/src/state/openclaw-database-maintenance.test.ts index 88b6047af0ab..d95fbf1480b1 100644 --- a/src/state/openclaw-database-maintenance.test.ts +++ b/src/state/openclaw-database-maintenance.test.ts @@ -172,6 +172,7 @@ describe("OpenClaw database maintenance schema validation", () => { "worker_session_placements.terminal_at_ms INTEGER", "worktrees.run_end_cleanup_json TEXT", "installed_plugin_index.workspace_dir TEXT", + "secret_store_entries.allowed_hosts TEXT", ]); const database = createGlobalDatabase(); diff --git a/src/state/openclaw-state-db-additive-columns.ts b/src/state/openclaw-state-db-additive-columns.ts index cb15c6b315d0..48b1a6a93634 100644 --- a/src/state/openclaw-state-db-additive-columns.ts +++ b/src/state/openclaw-state-db-additive-columns.ts @@ -23,4 +23,5 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [ { columnName: "terminal_at_ms", dataType: "INTEGER", tableName: "worker_session_placements" }, { columnName: "run_end_cleanup_json", dataType: "TEXT", tableName: "worktrees" }, { columnName: "workspace_dir", dataType: "TEXT", tableName: "installed_plugin_index" }, + { columnName: "allowed_hosts", dataType: "TEXT", tableName: "secret_store_entries" }, ] as const satisfies readonly LazyAdditiveStateColumnDefinition[]; diff --git a/src/state/openclaw-state-db-schema-additive.test.ts b/src/state/openclaw-state-db-schema-additive.test.ts index 6aef0bd2d318..fa431b2d7bcb 100644 --- a/src/state/openclaw-state-db-schema-additive.test.ts +++ b/src/state/openclaw-state-db-schema-additive.test.ts @@ -30,3 +30,37 @@ it("keeps secret-store first use from installing later additive schema", () => { database.close(); } }); + +it("lazily adds allowed_hosts to a v6 secret store without changing user_version", () => { + const database = new DatabaseSync(":memory:"); + try { + database.exec(` + PRAGMA user_version = 6; + CREATE TABLE secret_store_entries ( + scope_kind TEXT NOT NULL, + scope_id TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + kind TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + updated_by TEXT, + deleted_at_ms INTEGER, + PRIMARY KEY (scope_kind, scope_id, name) + ) STRICT; + `); + + ensureSecretStoreSchema(database); + + expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 6 }); + expect( + database + .prepare( + 'SELECT name, type, "notnull", dflt_value FROM pragma_table_info(?) WHERE name = ?', + ) + .get("secret_store_entries", "allowed_hosts"), + ).toEqual({ name: "allowed_hosts", type: "TEXT", notnull: 0, dflt_value: null }); + } finally { + database.close(); + } +}); diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 77ad6bdbf18e..5132a195e734 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -41,6 +41,7 @@ function secretStoreSchemaSql(): string { /** Lazily install the additive secret store table and index on first write. */ export function ensureSecretStoreSchema(database: DatabaseSync): void { database.exec(secretStoreSchemaSql()); // sqlite-allow-raw -- Canonical additive DDL only. + ensureColumn(database, "secret_store_entries", "allowed_hosts TEXT"); } /** Lazily install durable MCP OAuth callback correlation on first feature use. */ diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index d506b8ba7153..1b2bd2628b6e 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -1119,6 +1119,7 @@ export interface SchemaMeta { } export interface SecretStoreEntries { + allowed_hosts: string | null; created_at_ms: number; deleted_at_ms: number | null; kind: string; diff --git a/src/state/openclaw-state-schema-compatibility.ts b/src/state/openclaw-state-schema-compatibility.ts index 3e8a58067e61..a3a943360f74 100644 --- a/src/state/openclaw-state-schema-compatibility.ts +++ b/src/state/openclaw-state-schema-compatibility.ts @@ -29,6 +29,7 @@ const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [ "worker_session_placements.terminal_at_ms", "worktrees.run_end_cleanup_json", "installed_plugin_index.workspace_dir", + "secret_store_entries.allowed_hosts", ] as const; const CLAW_LAZY_ADDITIVE_STATE_COLUMN_SET = new Set(CLAW_LAZY_ADDITIVE_STATE_COLUMNS); diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index ae7b34c1bc0d..9a797298098b 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -2443,6 +2443,7 @@ CREATE TABLE IF NOT EXISTS secret_store_entries ( updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0), updated_by TEXT, deleted_at_ms INTEGER, + allowed_hosts TEXT, CHECK ((scope_kind = 'team' AND scope_id = '') OR (scope_kind = 'identity' AND length(scope_id) > 0)), PRIMARY KEY (scope_kind, scope_id, name) ) STRICT; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2f794d3864a6..f3e4cf96a12f 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5658,6 +5658,11 @@ export const en: TranslationMap = { secretsStore: { name: "Name", value: "Value", + allowedHosts: "Allowed hosts", + allowedHostsPlaceholder: "api.example.com", + allowedHostsHint: + "Exact hostnames only, one per line or comma-separated. No wildcards or ports.", + noAllowedHosts: "None", updated: "Last updated", by: "{time} by {name}", actions: "Actions", diff --git a/ui/src/lib/secrets-store/index.test.ts b/ui/src/lib/secrets-store/index.test.ts index 29707c2ab317..784d613b9529 100644 --- a/ui/src/lib/secrets-store/index.test.ts +++ b/ui/src/lib/secrets-store/index.test.ts @@ -51,6 +51,7 @@ describe("secrets store state", () => { name: "SERVICE_URL", value: "https://service.test", kind: "env", + allowedHosts: "", }); expect(request.mock.calls.map(([method]) => method)).toEqual([ @@ -83,4 +84,26 @@ describe("secrets store state", () => { "secrets.store.list", ]); }); + + it("sends parsed allowed hosts through the store RPC", async () => { + const { client, request } = clientWithResponses([ + { ok: true, reloaded: false }, + { entries: [] }, + ]); + const state = createInitialSecretsStoreState({ client, connected: true }); + + await setSecretsStoreEntry(state, { + name: "SERVICE_API_KEY", + value: "secret", + kind: "secret", + allowedHosts: "api.example.com, uploads.example.com\napi2.example.com", + }); + + expect(request).toHaveBeenNthCalledWith(1, "secrets.store.set", { + name: "SERVICE_API_KEY", + value: "secret", + kind: "secret", + allowedHosts: ["api.example.com", "uploads.example.com", "api2.example.com"], + }); + }); }); diff --git a/ui/src/lib/secrets-store/index.ts b/ui/src/lib/secrets-store/index.ts index a76e7c8fd2e0..734c8ce483e8 100644 --- a/ui/src/lib/secrets-store/index.ts +++ b/ui/src/lib/secrets-store/index.ts @@ -14,9 +14,10 @@ export type SecretsStoreDraft = { name: string; value: string; kind: "secret" | "env"; + allowedHosts: string; }; -type SecretsStoreBulkEntry = SecretsStoreDraft; +type SecretsStoreBulkEntry = Omit; export type SecretsStoreState = { client: GatewayBrowserClient | null; @@ -113,7 +114,19 @@ export function setSecretsStoreEntry( draft: SecretsStoreDraft, ): Promise { return mutateAndReload(state, (client) => - client.request("secrets.store.set", draft), + client.request("secrets.store.set", { + name: draft.name, + value: draft.value, + kind: draft.kind, + ...(draft.kind === "secret" + ? { + allowedHosts: draft.allowedHosts + .split(/[\s,]+/u) + .map((host) => host.trim()) + .filter(Boolean), + } + : {}), + }), ); } diff --git a/ui/src/pages/secrets/secrets-page.ts b/ui/src/pages/secrets/secrets-page.ts index 9c66bdfb7335..70115873db82 100644 --- a/ui/src/pages/secrets/secrets-page.ts +++ b/ui/src/pages/secrets/secrets-page.ts @@ -31,7 +31,12 @@ class SecretsPage extends OpenClawLightDomElement { @state() private store = createInitialSecretsStoreState(); @state() private dialogMode: SecretsDialogMode = null; - @state() private draft: SecretsStoreDraft = { name: "", value: "", kind: "env" }; + @state() private draft: SecretsStoreDraft = { + name: "", + value: "", + kind: "env", + allowedHosts: "", + }; @state() private secretKindOverridden = false; @state() private bulkOpen = false; @state() private bulkRaw = ""; @@ -113,7 +118,7 @@ class SecretsPage extends OpenClawLightDomElement { this.notice = null; this.formError = null; this.secretKindOverridden = false; - this.draft = { name: "", value: "", kind: "env" }; + this.draft = { name: "", value: "", kind: "env", allowedHosts: "" }; this.dialogMode = "add"; } @@ -128,6 +133,7 @@ class SecretsPage extends OpenClawLightDomElement { name: entry.name, value: entry.kind === "env" ? entry.value : "", kind: entry.kind, + allowedHosts: entry.kind === "secret" ? (entry.allowedHosts ?? []).join("\n") : "", }; this.dialogMode = "edit"; } @@ -304,6 +310,7 @@ class SecretsPage extends OpenClawLightDomElement { onCloseDialog: () => this.closeDialog(), onDraftNameChange: (name) => this.changeDraftName(name), onDraftValueChange: (value) => this.patchDraft({ value }), + onDraftAllowedHostsChange: (allowedHosts) => this.patchDraft({ allowedHosts }), onDraftSecretChange: (secret) => { this.secretKindOverridden = true; this.patchDraft({ kind: secret ? "secret" : "env" }); diff --git a/ui/src/pages/secrets/secrets.e2e.test.ts b/ui/src/pages/secrets/secrets.e2e.test.ts index e4e87799bbf6..3f63bdd01c48 100644 --- a/ui/src/pages/secrets/secrets.e2e.test.ts +++ b/ui/src/pages/secrets/secrets.e2e.test.ts @@ -35,6 +35,7 @@ const secretEntry: SecretStoreEntry = { createdAtMs: 1_786_352_400_000, updatedAtMs: 1_786_352_400_000, updatedBy: "E2E Operator", + allowedHosts: ["api.example.com"], }; const bulkEnvEntry: SecretStoreEntry = { @@ -46,6 +47,7 @@ const bulkEnvEntry: SecretStoreEntry = { const bulkSecretEntry: SecretStoreEntry = { ...secretEntry, name: "BULK_PRIVATE_KEY", + allowedHosts: [], }; async function capture(page: Page, fileName: string) { @@ -84,12 +86,18 @@ async function tableBodyContrast(page: Page): Promise { suite.define(() => { it("adds env and secret values, bulk imports, and deletes without revealing secrets", async () => { + if (captureUiProofEnabled) { + await mkdir(proofDir, { recursive: true }); + } await suite.withPage( { colorScheme: "dark", locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1440 }, + ...(captureUiProofEnabled + ? { recordVideo: { dir: proofDir, size: { height: 900, width: 1440 } } } + : {}), }, async ({ page }) => { const gateway = await installMockGateway(page, { @@ -133,9 +141,14 @@ suite.define(() => { await secretDialog.getByLabel("Name", { exact: true }).fill("SERVICE_API_KEY"); expect(await secretDialog.locator('input[type="checkbox"]').isChecked()).toBe(true); await secretDialog.getByLabel("Value", { exact: true }).fill("super-secret-material"); + await secretDialog.locator('textarea[name="allowed-hosts"]').fill("api.example.com"); + await capture(page, "02-secret-allowed-hosts.png"); await secretDialog.getByRole("button", { name: "Save", exact: true }).click(); await page.getByRole("status").getByText("Saved SERVICE_API_KEY.").waitFor(); expect(await page.content()).not.toContain("super-secret-material"); + expect(await page.getByRole("row", { name: /SERVICE_API_KEY/u }).textContent()).toContain( + "api.example.com", + ); await page.getByRole("button", { name: "Bulk Add", exact: true }).click(); const bulkDialog = page.locator('openclaw-modal-dialog[label="Bulk Add"]'); @@ -156,6 +169,10 @@ suite.define(() => { expect(await page.getByRole("row", { name: /BULK_URL/u }).count()).toBe(0); expect(await gateway.getRequests("secrets.store.set")).toHaveLength(4); + expect((await gateway.getRequests("secrets.store.set"))[1]?.params).toMatchObject({ + name: "SERVICE_API_KEY", + allowedHosts: ["api.example.com"], + }); expect(await gateway.getRequests("secrets.store.delete")).toHaveLength(1); expect(await page.content()).not.toContain("super-secret-material"); expect(await tableBodyContrast(page)).toBeGreaterThanOrEqual(9.5); diff --git a/ui/src/pages/secrets/view.test.ts b/ui/src/pages/secrets/view.test.ts index 80969b293876..643482936848 100644 --- a/ui/src/pages/secrets/view.test.ts +++ b/ui/src/pages/secrets/view.test.ts @@ -32,7 +32,7 @@ function mount( canSet: true, canDelete: true, dialogMode: null, - draft: { name: "", value: "", kind: "env" }, + draft: { name: "", value: "", kind: "env", allowedHosts: "" }, formError: null, bulkOpen: false, bulkRaw: "", @@ -46,6 +46,7 @@ function mount( onCloseDialog: noop, onDraftNameChange: noop, onDraftValueChange: noop, + onDraftAllowedHostsChange: noop, onDraftSecretChange: noop, onSubmitDraft: noop, onOpenBulk: noop, @@ -70,6 +71,7 @@ describe("secrets store view", () => { createdAtMs: 1, updatedAtMs: 2, updatedBy: "Operator", + allowedHosts: ["api.example.com"], } as unknown as SecretStoreEntry; const env: SecretStoreEntry = { name: "SERVICE_URL", @@ -86,6 +88,23 @@ describe("secrets store view", () => { expect(container.innerHTML).not.toContain("must-never-render"); expect(container.textContent).toContain("••••••••"); expect(container.textContent).toContain("https://service.test"); + expect(container.textContent).toContain("api.example.com"); + }); + + it("shows the allowed-host field for secret add and edit dialogs", () => { + const container = mount([], { + dialogMode: "edit", + draft: { + name: "SERVICE_API_KEY", + value: "replacement", + kind: "secret", + allowedHosts: "api.example.com", + }, + }); + + const field = container.querySelector('textarea[name="allowed-hosts"]'); + expect(field?.value).toBe("api.example.com"); + expect(container.textContent).toContain("Exact hostnames only"); }); it("hides mutation controls when the gateway does not advertise them", () => { diff --git a/ui/src/pages/secrets/view.ts b/ui/src/pages/secrets/view.ts index a61c6c0cf708..09d80da38362 100644 --- a/ui/src/pages/secrets/view.ts +++ b/ui/src/pages/secrets/view.ts @@ -41,6 +41,7 @@ type SecretsStoreViewProps = { onCloseDialog: () => void; onDraftNameChange: (name: string) => void; onDraftValueChange: (value: string) => void; + onDraftAllowedHostsChange: (allowedHosts: string) => void; onDraftSecretChange: (secret: boolean) => void; onSubmitDraft: () => void; onOpenBulk: () => void; @@ -120,6 +121,7 @@ function renderTable(props: SecretsStoreViewProps): TemplateResult { ${t("secretsStore.name")} ${t("secretsStore.value")} + ${t("secretsStore.allowedHosts")} ${t("secretsStore.updated")} ${t("secretsStore.actions")} @@ -142,6 +144,13 @@ function renderTable(props: SecretsStoreViewProps): TemplateResult { >${entry.kind === "env" ? entry.value : SECRET_MASK} + + + ${entry.kind === "secret" && (entry.allowedHosts?.length ?? 0) > 0 + ? entry.allowedHosts?.join(", ") + : t("secretsStore.noAllowedHosts")} + +