From 4894d907faa7540ca8b6835f561a6dd3ea824b77 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 16:57:29 +0100 Subject: [PATCH] refactor(exec-approvals): unify system.run binding and generate host env policy --- .../Sources/OpenClaw/HostEnvSanitizer.swift | 34 +-- .../HostEnvSecurityPolicy.generated.swift | 38 +++ ...enerate-host-env-security-policy-swift.mjs | 45 ++++ .../bash-tools.exec-approval-request.ts | 67 +++-- ...e-invoke-system-run-approval-match.test.ts | 77 ++++-- .../node-invoke-system-run-approval-match.ts | 111 +++----- .../node-invoke-system-run-approval.test.ts | 20 +- .../node-invoke-system-run-approval.ts | 15 +- src/gateway/server-methods/exec-approval.ts | 17 +- .../server-methods/server-methods.test.ts | 17 +- ...stem-run-approval-binding.contract.test.ts | 99 ++++++++ .../system-run-approval-binding.test.ts | 101 ++++++++ src/gateway/system-run-approval-binding.ts | 238 ++++++++++++++++++ .../system-run-approval-env-binding.test.ts | 71 ------ .../system-run-approval-env-binding.ts | 88 ------- src/infra/exec-approvals.ts | 12 + .../host-env-security.policy-parity.test.ts | 34 ++- .../system-run-approval-binding-contract.json | 116 +++++++++ 18 files changed, 858 insertions(+), 342 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift create mode 100644 scripts/generate-host-env-security-policy-swift.mjs create mode 100644 src/gateway/system-run-approval-binding.contract.test.ts create mode 100644 src/gateway/system-run-approval-binding.test.ts create mode 100644 src/gateway/system-run-approval-binding.ts delete mode 100644 src/gateway/system-run-approval-env-binding.test.ts delete mode 100644 src/gateway/system-run-approval-env-binding.ts create mode 100644 test/fixtures/system-run-approval-binding-contract.json diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index 7f559576cfa..e1c4f5b8531 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -1,37 +1,11 @@ import Foundation enum HostEnvSanitizer { - /// Keep in sync with src/infra/host-env-security-policy.json. + /// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs. /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. - private static let blockedKeys: Set = [ - "NODE_OPTIONS", - "NODE_PATH", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYLIB", - "RUBYOPT", - "BASH_ENV", - "ENV", - "GIT_EXTERNAL_DIFF", - "SHELL", - "SHELLOPTS", - "PS4", - "GCONV_PATH", - "IFS", - "SSLKEYLOGFILE", - ] - - private static let blockedPrefixes: [String] = [ - "DYLD_", - "LD_", - "BASH_FUNC_", - ] - private static let blockedOverrideKeys: Set = [ - "HOME", - "ZDOTDIR", - ] + private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys + private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes + private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys private static let shellWrapperAllowedOverrideKeys: Set = [ "TERM", "LANG", diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift new file mode 100644 index 00000000000..0a94ce114f3 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -0,0 +1,38 @@ +// Generated file. Do not edit directly. +// Source: src/infra/host-env-security-policy.json +// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs + +import Foundation + +enum HostEnvSecurityPolicy { + static let blockedKeys: Set = [ + "NODE_OPTIONS", + "NODE_PATH", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYLIB", + "RUBYOPT", + "BASH_ENV", + "ENV", + "GIT_EXTERNAL_DIFF", + "SHELL", + "SHELLOPTS", + "PS4", + "GCONV_PATH", + "IFS", + "SSLKEYLOGFILE" + ] + + static let blockedOverrideKeys: Set = [ + "HOME", + "ZDOTDIR" + ] + + static let blockedPrefixes: [String] = [ + "DYLD_", + "LD_", + "BASH_FUNC_" + ] +} diff --git a/scripts/generate-host-env-security-policy-swift.mjs b/scripts/generate-host-env-security-policy-swift.mjs new file mode 100644 index 00000000000..b8d496db6b8 --- /dev/null +++ b/scripts/generate-host-env-security-policy-swift.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const policyPath = path.join(repoRoot, "src", "infra", "host-env-security-policy.json"); +const outputPath = path.join( + repoRoot, + "apps", + "macos", + "Sources", + "OpenClaw", + "HostEnvSecurityPolicy.generated.swift", +); + +/** @type {{blockedKeys: string[]; blockedOverrideKeys?: string[]; blockedPrefixes: string[]}} */ +const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")); + +const renderSwiftStringArray = (items) => items.map((item) => ` "${item}"`).join(",\n"); + +const swift = `// Generated file. Do not edit directly. +// Source: src/infra/host-env-security-policy.json +// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs + +import Foundation + +enum HostEnvSecurityPolicy { + static let blockedKeys: Set = [ +${renderSwiftStringArray(policy.blockedKeys)} + ] + + static let blockedOverrideKeys: Set = [ +${renderSwiftStringArray(policy.blockedOverrideKeys ?? [])} + ] + + static let blockedPrefixes: [String] = [ +${renderSwiftStringArray(policy.blockedPrefixes)} + ] +} +`; + +fs.writeFileSync(outputPath, swift); +console.log(`Wrote ${path.relative(repoRoot, outputPath)}`); diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index 664b96431fa..842fcc1dcf4 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -24,6 +24,52 @@ export type RequestExecApprovalDecisionParams = { turnSourceThreadId?: string | number; }; +type ExecApprovalRequestToolParams = { + id: string; + command: string; + commandArgv?: string[]; + env?: Record; + cwd: string; + nodeId?: string; + host: "gateway" | "node"; + security: ExecSecurity; + ask: ExecAsk; + agentId?: string; + resolvedPath?: string; + sessionKey?: string; + turnSourceChannel?: string; + turnSourceTo?: string; + turnSourceAccountId?: string; + turnSourceThreadId?: string | number; + timeoutMs: number; + twoPhase: true; +}; + +function buildExecApprovalRequestToolParams( + params: RequestExecApprovalDecisionParams, +): ExecApprovalRequestToolParams { + return { + id: params.id, + command: params.command, + commandArgv: params.commandArgv, + env: params.env, + cwd: params.cwd, + nodeId: params.nodeId, + host: params.host, + security: params.security, + ask: params.ask, + agentId: params.agentId, + resolvedPath: params.resolvedPath, + sessionKey: params.sessionKey, + turnSourceChannel: params.turnSourceChannel, + turnSourceTo: params.turnSourceTo, + turnSourceAccountId: params.turnSourceAccountId, + turnSourceThreadId: params.turnSourceThreadId, + timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, + twoPhase: true, + }; +} + type ParsedDecision = { present: boolean; value: string | null }; function parseDecision(value: unknown): ParsedDecision { @@ -65,26 +111,7 @@ export async function registerExecApprovalRequest( }>( "exec.approval.request", { timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS }, - { - id: params.id, - command: params.command, - commandArgv: params.commandArgv, - env: params.env, - cwd: params.cwd, - nodeId: params.nodeId, - host: params.host, - security: params.security, - ask: params.ask, - agentId: params.agentId, - resolvedPath: params.resolvedPath, - sessionKey: params.sessionKey, - turnSourceChannel: params.turnSourceChannel, - turnSourceTo: params.turnSourceTo, - turnSourceAccountId: params.turnSourceAccountId, - turnSourceThreadId: params.turnSourceThreadId, - timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, - twoPhase: true, - }, + buildExecApprovalRequestToolParams(params), { expectFinal: false }, ); const decision = parseDecision(registrationResult); diff --git a/src/gateway/node-invoke-system-run-approval-match.test.ts b/src/gateway/node-invoke-system-run-approval-match.test.ts index 1098499cd05..87e79e7c848 100644 --- a/src/gateway/node-invoke-system-run-approval-match.test.ts +++ b/src/gateway/node-invoke-system-run-approval-match.test.ts @@ -1,10 +1,13 @@ import { describe, expect, test } from "vitest"; -import { approvalMatchesSystemRunRequest } from "./node-invoke-system-run-approval-match.js"; -import { buildSystemRunApprovalEnvBinding } from "./system-run-approval-env-binding.js"; +import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; +import { + buildSystemRunApprovalBindingV1, + buildSystemRunApprovalEnvBinding, +} from "./system-run-approval-binding.js"; -describe("approvalMatchesSystemRunRequest", () => { +describe("evaluateSystemRunApprovalMatch", () => { test("matches legacy command text when binding fields match", () => { - const result = approvalMatchesSystemRunRequest({ + const result = evaluateSystemRunApprovalMatch({ cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { @@ -20,11 +23,11 @@ describe("approvalMatchesSystemRunRequest", () => { sessionKey: "session-1", }, }); - expect(result).toBe(true); + expect(result).toEqual({ ok: true }); }); test("rejects legacy command mismatch", () => { - const result = approvalMatchesSystemRunRequest({ + const result = evaluateSystemRunApprovalMatch({ cmdText: "echo PWNED", argv: ["echo", "PWNED"], request: { @@ -37,17 +40,26 @@ describe("approvalMatchesSystemRunRequest", () => { sessionKey: null, }, }); - expect(result).toBe(false); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.code).toBe("APPROVAL_REQUEST_MISMATCH"); }); - test("enforces exact argv binding when commandArgv is set", () => { - const result = approvalMatchesSystemRunRequest({ + test("enforces exact argv binding in v1 object", () => { + const result = evaluateSystemRunApprovalMatch({ cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { host: "node", command: "echo SAFE", - commandArgv: ["echo", "SAFE"], + systemRunBindingV1: buildSystemRunApprovalBindingV1({ + argv: ["echo", "SAFE"], + cwd: null, + agentId: null, + sessionKey: null, + }).binding, }, binding: { cwd: null, @@ -55,17 +67,22 @@ describe("approvalMatchesSystemRunRequest", () => { sessionKey: null, }, }); - expect(result).toBe(true); + expect(result).toEqual({ ok: true }); }); - test("rejects argv mismatch even when command text matches", () => { - const result = approvalMatchesSystemRunRequest({ + test("rejects argv mismatch in v1 object", () => { + const result = evaluateSystemRunApprovalMatch({ cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { host: "node", command: "echo SAFE", - commandArgv: ["echo SAFE"], + systemRunBindingV1: buildSystemRunApprovalBindingV1({ + argv: ["echo SAFE"], + cwd: null, + agentId: null, + sessionKey: null, + }).binding, }, binding: { cwd: null, @@ -73,11 +90,15 @@ describe("approvalMatchesSystemRunRequest", () => { sessionKey: null, }, }); - expect(result).toBe(false); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.code).toBe("APPROVAL_REQUEST_MISMATCH"); }); - test("rejects env overrides when approval record lacks env hash", () => { - const result = approvalMatchesSystemRunRequest({ + test("rejects env overrides when approval record lacks env binding", () => { + const result = evaluateSystemRunApprovalMatch({ cmdText: "git diff", argv: ["git", "diff"], request: { @@ -92,22 +113,26 @@ describe("approvalMatchesSystemRunRequest", () => { env: { GIT_EXTERNAL_DIFF: "/tmp/pwn.sh" }, }, }); - expect(result).toBe(false); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.code).toBe("APPROVAL_ENV_BINDING_MISSING"); }); test("accepts matching env hash with reordered keys", () => { - const binding = buildSystemRunApprovalEnvBinding({ + const envBinding = buildSystemRunApprovalEnvBinding({ SAFE_A: "1", SAFE_B: "2", }); - const result = approvalMatchesSystemRunRequest({ + const result = evaluateSystemRunApprovalMatch({ cmdText: "git diff", argv: ["git", "diff"], request: { host: "node", command: "git diff", commandArgv: ["git", "diff"], - envHash: binding.envHash, + envHash: envBinding.envHash, }, binding: { cwd: null, @@ -116,11 +141,11 @@ describe("approvalMatchesSystemRunRequest", () => { env: { SAFE_B: "2", SAFE_A: "1" }, }, }); - expect(result).toBe(true); + expect(result).toEqual({ ok: true }); }); test("rejects non-node host requests", () => { - const result = approvalMatchesSystemRunRequest({ + const result = evaluateSystemRunApprovalMatch({ cmdText: "echo SAFE", argv: ["echo", "SAFE"], request: { @@ -133,6 +158,10 @@ describe("approvalMatchesSystemRunRequest", () => { sessionKey: null, }, }); - expect(result).toBe(false); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.code).toBe("APPROVAL_REQUEST_MISMATCH"); }); }); diff --git a/src/gateway/node-invoke-system-run-approval-match.ts b/src/gateway/node-invoke-system-run-approval-match.ts index bf135e9ab45..567fd08e5b7 100644 --- a/src/gateway/node-invoke-system-run-approval-match.ts +++ b/src/gateway/node-invoke-system-run-approval-match.ts @@ -1,5 +1,10 @@ import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; -import { matchSystemRunApprovalEnvBinding } from "./system-run-approval-env-binding.js"; +import { + buildSystemRunApprovalBindingV1, + matchLegacySystemRunApprovalBinding, + matchSystemRunApprovalBindingV1, + type SystemRunApprovalMatchResult, +} from "./system-run-approval-binding.js"; export type SystemRunApprovalBinding = { cwd: string | null; @@ -8,35 +13,16 @@ export type SystemRunApprovalBinding = { env?: unknown; }; -function argvMatchesRequest(requestedArgv: string[], argv: string[]): boolean { - if (requestedArgv.length === 0 || requestedArgv.length !== argv.length) { - return false; - } - for (let i = 0; i < requestedArgv.length; i += 1) { - if (requestedArgv[i] !== argv[i]) { - return false; - } - } - return true; +function requestMismatch(): SystemRunApprovalMatchResult { + return { + ok: false, + code: "APPROVAL_REQUEST_MISMATCH", + message: "approval id does not match request", + }; } -export function approvalMatchesSystemRunRequest(params: { - cmdText: string; - argv: string[]; - request: ExecApprovalRequestPayload; - binding: SystemRunApprovalBinding; -}): boolean { - return evaluateSystemRunApprovalMatch(params).ok; -} - -export type SystemRunApprovalMatchResult = - | { ok: true } - | { - ok: false; - code: "APPROVAL_REQUEST_MISMATCH" | "APPROVAL_ENV_BINDING_MISSING" | "APPROVAL_ENV_MISMATCH"; - message: string; - details?: Record; - }; +export { toSystemRunApprovalMismatchError } from "./system-run-approval-binding.js"; +export type { SystemRunApprovalMatchResult } from "./system-run-approval-binding.js"; export function evaluateSystemRunApprovalMatch(params: { cmdText: string; @@ -45,59 +31,30 @@ export function evaluateSystemRunApprovalMatch(params: { binding: SystemRunApprovalBinding; }): SystemRunApprovalMatchResult { if (params.request.host !== "node") { - return { - ok: false, - code: "APPROVAL_REQUEST_MISMATCH", - message: "approval id does not match request", - }; + return requestMismatch(); } - const requestedArgv = params.request.commandArgv; - if (Array.isArray(requestedArgv)) { - if (!argvMatchesRequest(requestedArgv, params.argv)) { - return { - ok: false, - code: "APPROVAL_REQUEST_MISMATCH", - message: "approval id does not match request", - }; - } - } else if (!params.cmdText || params.request.command !== params.cmdText) { - return { - ok: false, - code: "APPROVAL_REQUEST_MISMATCH", - message: "approval id does not match request", - }; - } - - if ((params.request.cwd ?? null) !== params.binding.cwd) { - return { - ok: false, - code: "APPROVAL_REQUEST_MISMATCH", - message: "approval id does not match request", - }; - } - if ((params.request.agentId ?? null) !== params.binding.agentId) { - return { - ok: false, - code: "APPROVAL_REQUEST_MISMATCH", - message: "approval id does not match request", - }; - } - if ((params.request.sessionKey ?? null) !== params.binding.sessionKey) { - return { - ok: false, - code: "APPROVAL_REQUEST_MISMATCH", - message: "approval id does not match request", - }; - } - - const envMatch = matchSystemRunApprovalEnvBinding({ - request: params.request, + const actualBinding = buildSystemRunApprovalBindingV1({ + argv: params.argv, + cwd: params.binding.cwd, + agentId: params.binding.agentId, + sessionKey: params.binding.sessionKey, env: params.binding.env, }); - if (!envMatch.ok) { - return envMatch; + + const expectedBinding = params.request.systemRunBindingV1; + if (expectedBinding) { + return matchSystemRunApprovalBindingV1({ + expected: expectedBinding, + actual: actualBinding.binding, + actualEnvKeys: actualBinding.envKeys, + }); } - return { ok: true }; + return matchLegacySystemRunApprovalBinding({ + request: params.request, + cmdText: params.cmdText, + argv: params.argv, + binding: params.binding, + }); } diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index 1f70c36ceff..7437fb7ff58 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js"; import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js"; -import { buildSystemRunApprovalEnvBinding } from "./system-run-approval-env-binding.js"; +import { buildSystemRunApprovalEnvBinding } from "./system-run-approval-binding.js"; describe("sanitizeSystemRunParamsForForwarding", () => { const now = Date.now(); @@ -224,7 +224,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => { test("rejects env hash mismatch", () => { const record = makeRecord("git diff", ["git", "diff"]); - record.request.envHash = buildSystemRunApprovalEnvBinding({ SAFE: "1" }).envHash; + record.request.systemRunBindingV1 = { + version: 1, + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + envHash: buildSystemRunApprovalEnvBinding({ SAFE: "1" }).envHash, + }; const result = sanitizeSystemRunParamsForForwarding({ rawParams: { command: ["git", "diff"], @@ -249,7 +256,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => { test("accepts matching env hash with reordered keys", () => { const record = makeRecord("git diff", ["git", "diff"]); const binding = buildSystemRunApprovalEnvBinding({ SAFE_A: "1", SAFE_B: "2" }); - record.request.envHash = binding.envHash; + record.request.systemRunBindingV1 = { + version: 1, + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + envHash: binding.envHash, + }; const result = sanitizeSystemRunParamsForForwarding({ rawParams: { command: ["git", "diff"], diff --git a/src/gateway/node-invoke-system-run-approval.ts b/src/gateway/node-invoke-system-run-approval.ts index f75be30c650..6573025769e 100644 --- a/src/gateway/node-invoke-system-run-approval.ts +++ b/src/gateway/node-invoke-system-run-approval.ts @@ -1,6 +1,9 @@ import { resolveSystemRunCommand } from "../infra/system-run-command.js"; import type { ExecApprovalRecord } from "./exec-approval-manager.js"; -import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; +import { + evaluateSystemRunApprovalMatch, + toSystemRunApprovalMismatchError, +} from "./node-invoke-system-run-approval-match.js"; type SystemRunParamsLike = { command?: unknown; @@ -216,15 +219,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: { }, }); if (!approvalMatch.ok) { - return { - ok: false, - message: approvalMatch.message, - details: { - code: approvalMatch.code, - runId, - ...approvalMatch.details, - }, - }; + return toSystemRunApprovalMismatchError({ runId, match: approvalMatch }); } // Normal path: enforce the decision recorded by the gateway. diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index 45738b23cbd..3898a07eee3 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -11,7 +11,7 @@ import { validateExecApprovalRequestParams, validateExecApprovalResolveParams, } from "../protocol/index.js"; -import { buildSystemRunApprovalEnvBinding } from "../system-run-approval-env-binding.js"; +import { buildSystemRunApprovalBindingV1 } from "../system-run-approval-binding.js"; import type { GatewayRequestHandlers } from "./types.js"; export function createExecApprovalHandlers( @@ -70,7 +70,16 @@ export function createExecApprovalHandlers( const commandArgv = Array.isArray(p.commandArgv) ? p.commandArgv.map((entry) => String(entry)) : undefined; - const envBinding = buildSystemRunApprovalEnvBinding(p.env); + const systemRunBindingV1 = + host === "node" && Array.isArray(commandArgv) && commandArgv.length > 0 + ? buildSystemRunApprovalBindingV1({ + argv: commandArgv, + cwd: p.cwd, + agentId: p.agentId, + sessionKey: p.sessionKey, + env: p.env, + }) + : null; if (host === "node" && !nodeId) { respond( false, @@ -90,8 +99,8 @@ export function createExecApprovalHandlers( const request = { command: p.command, commandArgv, - envHash: envBinding.envHash, - envKeys: envBinding.envKeys.length > 0 ? envBinding.envKeys : undefined, + envKeys: systemRunBindingV1?.envKeys?.length ? systemRunBindingV1.envKeys : undefined, + systemRunBindingV1: systemRunBindingV1?.binding ?? null, cwd: p.cwd ?? null, nodeId: host === "node" ? nodeId : null, host: host || null, diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 9155825a5b2..3ed48c246c3 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -9,7 +9,7 @@ import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js import { resetLogger, setLoggerOverride } from "../../logging.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; import { validateExecApprovalRequestParams } from "../protocol/index.js"; -import { buildSystemRunApprovalEnvBinding } from "../system-run-approval-env-binding.js"; +import { buildSystemRunApprovalBindingV1 } from "../system-run-approval-binding.js"; import { waitForAgentJob } from "./agent-job.js"; import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; @@ -424,13 +424,14 @@ describe("exec approval handlers", () => { expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true); }); - it("stores env binding hash and sorted env keys on approval request", async () => { + it("stores versioned system.run binding and sorted env keys on approval request", async () => { const { handlers, broadcasts, respond, context } = createExecApprovalFixture(); await requestExecApproval({ handlers, respond, context, params: { + commandArgv: ["echo", "ok"], env: { Z_VAR: "z", A_VAR: "a", @@ -440,12 +441,14 @@ describe("exec approval handlers", () => { const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); expect(requested).toBeTruthy(); const request = (requested?.payload as { request?: Record })?.request ?? {}; - const expected = buildSystemRunApprovalEnvBinding({ - A_VAR: "a", - Z_VAR: "z", - }); - expect(request["envHash"]).toBe(expected.envHash); expect(request["envKeys"]).toEqual(["A_VAR", "Z_VAR"]); + expect(request["systemRunBindingV1"]).toEqual( + buildSystemRunApprovalBindingV1({ + argv: ["echo", "ok"], + cwd: "/tmp", + env: { A_VAR: "a", Z_VAR: "z" }, + }).binding, + ); }); it("accepts resolve during broadcast", async () => { diff --git a/src/gateway/system-run-approval-binding.contract.test.ts b/src/gateway/system-run-approval-binding.contract.test.ts new file mode 100644 index 00000000000..ae29c258ae4 --- /dev/null +++ b/src/gateway/system-run-approval-binding.contract.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; +import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js"; +import { + buildSystemRunApprovalBindingV1, + buildSystemRunApprovalEnvBinding, +} from "./system-run-approval-binding.js"; + +type FixtureCase = { + name: string; + request: { + host: string; + command: string; + commandArgv?: string[]; + cwd?: string | null; + agentId?: string | null; + sessionKey?: string | null; + bindingV1?: { + argv: string[]; + cwd?: string | null; + agentId?: string | null; + sessionKey?: string | null; + env?: Record; + }; + envHashFrom?: Record; + }; + invoke: { + cmdText: string; + argv: string[]; + binding: { + cwd: string | null; + agentId: string | null; + sessionKey: string | null; + env?: Record; + }; + }; + expected: { + ok: boolean; + code?: "APPROVAL_REQUEST_MISMATCH" | "APPROVAL_ENV_BINDING_MISSING" | "APPROVAL_ENV_MISMATCH"; + }; +}; + +type Fixture = { + cases: FixtureCase[]; +}; + +const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../test/fixtures/system-run-approval-binding-contract.json", +); +const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")) as Fixture; + +function buildRequestPayload(entry: FixtureCase): ExecApprovalRequestPayload { + const payload: ExecApprovalRequestPayload = { + host: entry.request.host, + command: entry.request.command, + commandArgv: entry.request.commandArgv, + cwd: entry.request.cwd ?? null, + agentId: entry.request.agentId ?? null, + sessionKey: entry.request.sessionKey ?? null, + }; + if (entry.request.bindingV1) { + payload.systemRunBindingV1 = buildSystemRunApprovalBindingV1({ + argv: entry.request.bindingV1.argv, + cwd: entry.request.bindingV1.cwd, + agentId: entry.request.bindingV1.agentId, + sessionKey: entry.request.bindingV1.sessionKey, + env: entry.request.bindingV1.env, + }).binding; + } + if (entry.request.envHashFrom) { + payload.envHash = buildSystemRunApprovalEnvBinding(entry.request.envHashFrom).envHash; + } + return payload; +} + +describe("system-run approval binding contract fixtures", () => { + for (const entry of fixture.cases) { + test(entry.name, () => { + const result = evaluateSystemRunApprovalMatch({ + cmdText: entry.invoke.cmdText, + argv: entry.invoke.argv, + request: buildRequestPayload(entry), + binding: entry.invoke.binding, + }); + + expect(result.ok).toBe(entry.expected.ok); + if (!entry.expected.ok) { + if (result.ok) { + throw new Error("expected approval mismatch"); + } + expect(result.code).toBe(entry.expected.code); + } + }); + } +}); diff --git a/src/gateway/system-run-approval-binding.test.ts b/src/gateway/system-run-approval-binding.test.ts new file mode 100644 index 00000000000..88299063711 --- /dev/null +++ b/src/gateway/system-run-approval-binding.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "vitest"; +import { + buildSystemRunApprovalBindingV1, + buildSystemRunApprovalEnvBinding, + matchSystemRunApprovalBindingV1, + matchSystemRunApprovalEnvHash, +} from "./system-run-approval-binding.js"; + +describe("buildSystemRunApprovalEnvBinding", () => { + test("normalizes keys and produces stable hash regardless of input order", () => { + const a = buildSystemRunApprovalEnvBinding({ + Z_VAR: "z", + A_VAR: "a", + " BAD KEY": "ignored", + }); + const b = buildSystemRunApprovalEnvBinding({ + A_VAR: "a", + Z_VAR: "z", + }); + expect(a.envKeys).toEqual(["A_VAR", "Z_VAR"]); + expect(a.envHash).toBe(b.envHash); + }); +}); + +describe("matchSystemRunApprovalEnvHash", () => { + test("accepts empty env hash on both sides", () => { + expect( + matchSystemRunApprovalEnvHash({ + expectedEnvHash: null, + actualEnvHash: null, + actualEnvKeys: [], + }), + ).toEqual({ ok: true }); + }); + + test("rejects non-empty actual env hash when expected is empty", () => { + const result = matchSystemRunApprovalEnvHash({ + expectedEnvHash: null, + actualEnvHash: "hash", + actualEnvKeys: ["GIT_EXTERNAL_DIFF"], + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.code).toBe("APPROVAL_ENV_BINDING_MISSING"); + }); +}); + +describe("matchSystemRunApprovalBindingV1", () => { + test("accepts matching binding with reordered env keys", () => { + const expected = buildSystemRunApprovalBindingV1({ + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + env: { SAFE_A: "1", SAFE_B: "2" }, + }); + const actual = buildSystemRunApprovalBindingV1({ + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + env: { SAFE_B: "2", SAFE_A: "1" }, + }); + expect( + matchSystemRunApprovalBindingV1({ + expected: expected.binding, + actual: actual.binding, + actualEnvKeys: actual.envKeys, + }), + ).toEqual({ ok: true }); + }); + + test("rejects env mismatch", () => { + const expected = buildSystemRunApprovalBindingV1({ + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + env: { SAFE: "1" }, + }); + const actual = buildSystemRunApprovalBindingV1({ + argv: ["git", "diff"], + cwd: null, + agentId: null, + sessionKey: null, + env: { SAFE: "2" }, + }); + const result = matchSystemRunApprovalBindingV1({ + expected: expected.binding, + actual: actual.binding, + actualEnvKeys: actual.envKeys, + }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("unreachable"); + } + expect(result.code).toBe("APPROVAL_ENV_MISMATCH"); + }); +}); diff --git a/src/gateway/system-run-approval-binding.ts b/src/gateway/system-run-approval-binding.ts new file mode 100644 index 00000000000..d0cb59c7cd6 --- /dev/null +++ b/src/gateway/system-run-approval-binding.ts @@ -0,0 +1,238 @@ +import crypto from "node:crypto"; +import type { + ExecApprovalRequestPayload, + SystemRunApprovalBindingV1, +} from "../infra/exec-approvals.js"; +import { normalizeEnvVarKey } from "../infra/host-env-security.js"; + +type NormalizedSystemRunEnvEntry = [key: string, value: string]; + +function normalizeString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function normalizeStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map((entry) => String(entry)) : []; +} + +function normalizeSystemRunEnvEntries(env: unknown): NormalizedSystemRunEnvEntry[] { + if (!env || typeof env !== "object" || Array.isArray(env)) { + return []; + } + const entries: NormalizedSystemRunEnvEntry[] = []; + for (const [rawKey, rawValue] of Object.entries(env as Record)) { + if (typeof rawValue !== "string") { + continue; + } + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (!key) { + continue; + } + entries.push([key, rawValue]); + } + entries.sort((a, b) => a[0].localeCompare(b[0])); + return entries; +} + +function hashSystemRunEnvEntries(entries: NormalizedSystemRunEnvEntry[]): string | null { + if (entries.length === 0) { + return null; + } + return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex"); +} + +export function buildSystemRunApprovalEnvBinding(env: unknown): { + envHash: string | null; + envKeys: string[]; +} { + const entries = normalizeSystemRunEnvEntries(env); + return { + envHash: hashSystemRunEnvEntries(entries), + envKeys: entries.map(([key]) => key), + }; +} + +export function buildSystemRunApprovalBindingV1(params: { + argv: unknown; + cwd?: unknown; + agentId?: unknown; + sessionKey?: unknown; + env?: unknown; +}): { binding: SystemRunApprovalBindingV1; envKeys: string[] } { + const envBinding = buildSystemRunApprovalEnvBinding(params.env); + return { + binding: { + version: 1, + argv: normalizeStringArray(params.argv), + cwd: normalizeString(params.cwd), + agentId: normalizeString(params.agentId), + sessionKey: normalizeString(params.sessionKey), + envHash: envBinding.envHash, + }, + envKeys: envBinding.envKeys, + }; +} + +function argvMatches(expectedArgv: string[], actualArgv: string[]): boolean { + if (expectedArgv.length === 0 || expectedArgv.length !== actualArgv.length) { + return false; + } + for (let i = 0; i < expectedArgv.length; i += 1) { + if (expectedArgv[i] !== actualArgv[i]) { + return false; + } + } + return true; +} + +function readExpectedEnvHash(request: Pick): string | null { + if (typeof request.envHash !== "string") { + return null; + } + const trimmed = request.envHash.trim(); + return trimmed ? trimmed : null; +} + +export type SystemRunApprovalMatchResult = + | { ok: true } + | { + ok: false; + code: "APPROVAL_REQUEST_MISMATCH" | "APPROVAL_ENV_BINDING_MISSING" | "APPROVAL_ENV_MISMATCH"; + message: string; + details?: Record; + }; + +type SystemRunApprovalMismatch = Extract; + +const APPROVAL_REQUEST_MISMATCH_MESSAGE = "approval id does not match request"; + +function requestMismatch(details?: Record): SystemRunApprovalMatchResult { + return { + ok: false, + code: "APPROVAL_REQUEST_MISMATCH", + message: APPROVAL_REQUEST_MISMATCH_MESSAGE, + details, + }; +} + +export function matchSystemRunApprovalEnvHash(params: { + expectedEnvHash: string | null; + actualEnvHash: string | null; + actualEnvKeys: string[]; +}): SystemRunApprovalMatchResult { + if (!params.expectedEnvHash && !params.actualEnvHash) { + return { ok: true }; + } + if (!params.expectedEnvHash && params.actualEnvHash) { + return { + ok: false, + code: "APPROVAL_ENV_BINDING_MISSING", + message: "approval id missing env binding for requested env overrides", + details: { envKeys: params.actualEnvKeys }, + }; + } + if (params.expectedEnvHash !== params.actualEnvHash) { + return { + ok: false, + code: "APPROVAL_ENV_MISMATCH", + message: "approval id env binding mismatch", + details: { + envKeys: params.actualEnvKeys, + expectedEnvHash: params.expectedEnvHash, + actualEnvHash: params.actualEnvHash, + }, + }; + } + return { ok: true }; +} + +export function matchSystemRunApprovalBindingV1(params: { + expected: SystemRunApprovalBindingV1; + actual: SystemRunApprovalBindingV1; + actualEnvKeys: string[]; +}): SystemRunApprovalMatchResult { + if (params.expected.version !== 1 || params.actual.version !== 1) { + return requestMismatch({ + expectedVersion: params.expected.version, + actualVersion: params.actual.version, + }); + } + if (!argvMatches(params.expected.argv, params.actual.argv)) { + return requestMismatch(); + } + if (params.expected.cwd !== params.actual.cwd) { + return requestMismatch(); + } + if (params.expected.agentId !== params.actual.agentId) { + return requestMismatch(); + } + if (params.expected.sessionKey !== params.actual.sessionKey) { + return requestMismatch(); + } + return matchSystemRunApprovalEnvHash({ + expectedEnvHash: params.expected.envHash, + actualEnvHash: params.actual.envHash, + actualEnvKeys: params.actualEnvKeys, + }); +} + +export function matchLegacySystemRunApprovalBinding(params: { + request: Pick< + ExecApprovalRequestPayload, + "command" | "commandArgv" | "cwd" | "agentId" | "sessionKey" | "envHash" + >; + cmdText: string; + argv: string[]; + binding: { + cwd: string | null; + agentId: string | null; + sessionKey: string | null; + env?: unknown; + }; +}): SystemRunApprovalMatchResult { + const requestedArgv = params.request.commandArgv; + if (Array.isArray(requestedArgv)) { + if (!argvMatches(requestedArgv, params.argv)) { + return requestMismatch(); + } + } else if (!params.cmdText || params.request.command !== params.cmdText) { + return requestMismatch(); + } + if ((params.request.cwd ?? null) !== params.binding.cwd) { + return requestMismatch(); + } + if ((params.request.agentId ?? null) !== params.binding.agentId) { + return requestMismatch(); + } + if ((params.request.sessionKey ?? null) !== params.binding.sessionKey) { + return requestMismatch(); + } + const actualEnvBinding = buildSystemRunApprovalEnvBinding(params.binding.env); + return matchSystemRunApprovalEnvHash({ + expectedEnvHash: readExpectedEnvHash(params.request), + actualEnvHash: actualEnvBinding.envHash, + actualEnvKeys: actualEnvBinding.envKeys, + }); +} + +export function toSystemRunApprovalMismatchError(params: { + runId: string; + match: SystemRunApprovalMismatch; +}): { ok: false; message: string; details: Record } { + const details: Record = { + code: params.match.code, + runId: params.runId, + }; + if (params.match.details) { + Object.assign(details, params.match.details); + } + return { + ok: false, + message: params.match.message, + details, + }; +} diff --git a/src/gateway/system-run-approval-env-binding.test.ts b/src/gateway/system-run-approval-env-binding.test.ts deleted file mode 100644 index 654b21ebafc..00000000000 --- a/src/gateway/system-run-approval-env-binding.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - buildSystemRunApprovalEnvBinding, - matchSystemRunApprovalEnvBinding, -} from "./system-run-approval-env-binding.js"; - -describe("buildSystemRunApprovalEnvBinding", () => { - test("normalizes keys and produces stable hash regardless of input order", () => { - const a = buildSystemRunApprovalEnvBinding({ - Z_VAR: "z", - A_VAR: "a", - " BAD KEY": "ignored", - }); - const b = buildSystemRunApprovalEnvBinding({ - A_VAR: "a", - Z_VAR: "z", - }); - expect(a.envKeys).toEqual(["A_VAR", "Z_VAR"]); - expect(a.envHash).toBe(b.envHash); - }); -}); - -describe("matchSystemRunApprovalEnvBinding", () => { - test("accepts missing env hash when request has no env overrides", () => { - const result = matchSystemRunApprovalEnvBinding({ - request: {}, - env: undefined, - }); - expect(result).toEqual({ ok: true }); - }); - - test("rejects non-empty env overrides when approval has no env hash", () => { - const result = matchSystemRunApprovalEnvBinding({ - request: {}, - env: { GIT_EXTERNAL_DIFF: "/tmp/pwn.sh" }, - }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("unreachable"); - } - expect(result.code).toBe("APPROVAL_ENV_BINDING_MISSING"); - }); - - test("rejects env hash mismatch", () => { - const approved = buildSystemRunApprovalEnvBinding({ SAFE: "1" }); - const result = matchSystemRunApprovalEnvBinding({ - request: { envHash: approved.envHash }, - env: { SAFE: "2" }, - }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("unreachable"); - } - expect(result.code).toBe("APPROVAL_ENV_MISMATCH"); - }); - - test("accepts matching env hash with key order differences", () => { - const approved = buildSystemRunApprovalEnvBinding({ - SAFE_A: "1", - SAFE_B: "2", - }); - const result = matchSystemRunApprovalEnvBinding({ - request: { envHash: approved.envHash }, - env: { - SAFE_B: "2", - SAFE_A: "1", - }, - }); - expect(result).toEqual({ ok: true }); - }); -}); diff --git a/src/gateway/system-run-approval-env-binding.ts b/src/gateway/system-run-approval-env-binding.ts deleted file mode 100644 index 7f129c2f552..00000000000 --- a/src/gateway/system-run-approval-env-binding.ts +++ /dev/null @@ -1,88 +0,0 @@ -import crypto from "node:crypto"; -import type { ExecApprovalRequestPayload } from "../infra/exec-approvals.js"; -import { normalizeEnvVarKey } from "../infra/host-env-security.js"; - -type NormalizedSystemRunEnvEntry = [key: string, value: string]; - -function normalizeSystemRunEnvEntries(env: unknown): NormalizedSystemRunEnvEntry[] { - if (!env || typeof env !== "object" || Array.isArray(env)) { - return []; - } - const entries: NormalizedSystemRunEnvEntry[] = []; - for (const [rawKey, rawValue] of Object.entries(env as Record)) { - if (typeof rawValue !== "string") { - continue; - } - const key = normalizeEnvVarKey(rawKey, { portable: true }); - if (!key) { - continue; - } - entries.push([key, rawValue]); - } - entries.sort((a, b) => a[0].localeCompare(b[0])); - return entries; -} - -function hashSystemRunEnvEntries(entries: NormalizedSystemRunEnvEntry[]): string | null { - if (entries.length === 0) { - return null; - } - return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex"); -} - -export function buildSystemRunApprovalEnvBinding(env: unknown): { - envHash: string | null; - envKeys: string[]; -} { - const entries = normalizeSystemRunEnvEntries(env); - return { - envHash: hashSystemRunEnvEntries(entries), - envKeys: entries.map(([key]) => key), - }; -} - -export type SystemRunEnvBindingMatchResult = - | { ok: true } - | { - ok: false; - code: "APPROVAL_ENV_BINDING_MISSING" | "APPROVAL_ENV_MISMATCH"; - message: string; - details?: Record; - }; - -export function matchSystemRunApprovalEnvBinding(params: { - request: Pick; - env: unknown; -}): SystemRunEnvBindingMatchResult { - const expectedEnvHash = - typeof params.request.envHash === "string" && params.request.envHash.trim().length > 0 - ? params.request.envHash.trim() - : null; - const actual = buildSystemRunApprovalEnvBinding(params.env); - const actualEnvHash = actual.envHash; - - if (!expectedEnvHash && !actualEnvHash) { - return { ok: true }; - } - if (!expectedEnvHash && actualEnvHash) { - return { - ok: false, - code: "APPROVAL_ENV_BINDING_MISSING", - message: "approval id missing env binding for requested env overrides", - details: { envKeys: actual.envKeys }, - }; - } - if (expectedEnvHash !== actualEnvHash) { - return { - ok: false, - code: "APPROVAL_ENV_MISMATCH", - message: "approval id env binding mismatch", - details: { - envKeys: actual.envKeys, - expectedEnvHash, - actualEnvHash, - }, - }; - } - return { ok: true }; -} diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 6435ea986d0..4bf6d62a4a8 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -11,11 +11,23 @@ export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecAsk = "off" | "on-miss" | "always"; +export type SystemRunApprovalBindingV1 = { + version: 1; + argv: string[]; + cwd: string | null; + agentId: string | null; + sessionKey: string | null; + envHash: string | null; +}; + export type ExecApprovalRequestPayload = { command: string; commandArgv?: string[]; + // Legacy env binding field (used for backward compatibility with old approvals). envHash?: string | null; + // Optional UI-safe env key preview for approval prompts. envKeys?: string[]; + systemRunBindingV1?: SystemRunApprovalBindingV1 | null; cwd?: string | null; nodeId?: string | null; host?: string | null; diff --git a/src/infra/host-env-security.policy-parity.test.ts b/src/infra/host-env-security.policy-parity.test.ts index 4ee46265447..49b631d25a4 100644 --- a/src/infra/host-env-security.policy-parity.test.ts +++ b/src/infra/host-env-security.policy-parity.test.ts @@ -19,26 +19,44 @@ function parseSwiftStringArray(source: string, marker: string): string[] { } describe("host env security policy parity", () => { - it("keeps macOS HostEnvSanitizer lists in sync with shared JSON policy", () => { + it("keeps generated macOS host env policy in sync with shared JSON policy", () => { const repoRoot = process.cwd(); const policyPath = path.join(repoRoot, "src/infra/host-env-security-policy.json"); - const swiftPath = path.join(repoRoot, "apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift"); + const generatedSwiftPath = path.join( + repoRoot, + "apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift", + ); + const sanitizerSwiftPath = path.join( + repoRoot, + "apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift", + ); const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")) as HostEnvSecurityPolicy; - const swiftSource = fs.readFileSync(swiftPath, "utf8"); + const generatedSource = fs.readFileSync(generatedSwiftPath, "utf8"); + const sanitizerSource = fs.readFileSync(sanitizerSwiftPath, "utf8"); - const swiftBlockedKeys = parseSwiftStringArray(swiftSource, "private static let blockedKeys"); + const swiftBlockedKeys = parseSwiftStringArray(generatedSource, "static let blockedKeys"); const swiftBlockedOverrideKeys = parseSwiftStringArray( - swiftSource, - "private static let blockedOverrideKeys", + generatedSource, + "static let blockedOverrideKeys", ); const swiftBlockedPrefixes = parseSwiftStringArray( - swiftSource, - "private static let blockedPrefixes", + generatedSource, + "static let blockedPrefixes", ); expect(swiftBlockedKeys).toEqual(policy.blockedKeys); expect(swiftBlockedOverrideKeys).toEqual(policy.blockedOverrideKeys ?? []); expect(swiftBlockedPrefixes).toEqual(policy.blockedPrefixes); + + expect(sanitizerSource).toContain( + "private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys", + ); + expect(sanitizerSource).toContain( + "private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys", + ); + expect(sanitizerSource).toContain( + "private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes", + ); }); }); diff --git a/test/fixtures/system-run-approval-binding-contract.json b/test/fixtures/system-run-approval-binding-contract.json new file mode 100644 index 00000000000..b296898d06f --- /dev/null +++ b/test/fixtures/system-run-approval-binding-contract.json @@ -0,0 +1,116 @@ +{ + "cases": [ + { + "name": "v1 matches when env key order changes", + "request": { + "host": "node", + "command": "git diff", + "bindingV1": { + "argv": ["git", "diff"], + "cwd": null, + "agentId": null, + "sessionKey": null, + "env": { "SAFE_A": "1", "SAFE_B": "2" } + } + }, + "invoke": { + "cmdText": "git diff", + "argv": ["git", "diff"], + "binding": { + "cwd": null, + "agentId": null, + "sessionKey": null, + "env": { "SAFE_B": "2", "SAFE_A": "1" } + } + }, + "expected": { "ok": true } + }, + { + "name": "v1 rejects env mismatch", + "request": { + "host": "node", + "command": "git diff", + "bindingV1": { + "argv": ["git", "diff"], + "cwd": null, + "agentId": null, + "sessionKey": null, + "env": { "SAFE": "1" } + } + }, + "invoke": { + "cmdText": "git diff", + "argv": ["git", "diff"], + "binding": { + "cwd": null, + "agentId": null, + "sessionKey": null, + "env": { "SAFE": "2" } + } + }, + "expected": { "ok": false, "code": "APPROVAL_ENV_MISMATCH" } + }, + { + "name": "v1 rejects unbound env overrides", + "request": { + "host": "node", + "command": "git diff", + "bindingV1": { + "argv": ["git", "diff"], + "cwd": null, + "agentId": null, + "sessionKey": null + } + }, + "invoke": { + "cmdText": "git diff", + "argv": ["git", "diff"], + "binding": { + "cwd": null, + "agentId": null, + "sessionKey": null, + "env": { "GIT_EXTERNAL_DIFF": "/tmp/pwn.sh" } + } + }, + "expected": { "ok": false, "code": "APPROVAL_ENV_BINDING_MISSING" } + }, + { + "name": "legacy rejects argv mismatch", + "request": { + "host": "node", + "command": "echo SAFE", + "commandArgv": ["echo SAFE"] + }, + "invoke": { + "cmdText": "echo SAFE", + "argv": ["echo", "SAFE"], + "binding": { + "cwd": null, + "agentId": null, + "sessionKey": null + } + }, + "expected": { "ok": false, "code": "APPROVAL_REQUEST_MISMATCH" } + }, + { + "name": "legacy accepts matching env hash", + "request": { + "host": "node", + "command": "git diff", + "commandArgv": ["git", "diff"], + "envHashFrom": { "SAFE_A": "1", "SAFE_B": "2" } + }, + "invoke": { + "cmdText": "git diff", + "argv": ["git", "diff"], + "binding": { + "cwd": null, + "agentId": null, + "sessionKey": null, + "env": { "SAFE_B": "2", "SAFE_A": "1" } + } + }, + "expected": { "ok": true } + } + ] +}