refactor(exec-approvals): unify system.run binding and generate host env policy

This commit is contained in:
Peter Steinberger
2026-02-26 16:57:29 +01:00
parent baf1c8ea13
commit 4894d907fa
18 changed files with 858 additions and 342 deletions

View File

@@ -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<String> = [
"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<String> = [
"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<String> = [
"TERM",
"LANG",

View File

@@ -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<String> = [
"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<String> = [
"HOME",
"ZDOTDIR"
]
static let blockedPrefixes: [String] = [
"DYLD_",
"LD_",
"BASH_FUNC_"
]
}

View File

@@ -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<String> = [
${renderSwiftStringArray(policy.blockedKeys)}
]
static let blockedOverrideKeys: Set<String> = [
${renderSwiftStringArray(policy.blockedOverrideKeys ?? [])}
]
static let blockedPrefixes: [String] = [
${renderSwiftStringArray(policy.blockedPrefixes)}
]
}
`;
fs.writeFileSync(outputPath, swift);
console.log(`Wrote ${path.relative(repoRoot, outputPath)}`);

View File

@@ -24,6 +24,52 @@ export type RequestExecApprovalDecisionParams = {
turnSourceThreadId?: string | number;
};
type ExecApprovalRequestToolParams = {
id: string;
command: string;
commandArgv?: string[];
env?: Record<string, string>;
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);

View File

@@ -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");
});
});

View File

@@ -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<string, unknown>;
};
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,
});
}

View File

@@ -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"],

View File

@@ -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.

View File

@@ -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,

View File

@@ -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<string, unknown> })?.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 () => {

View File

@@ -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<string, string>;
};
envHashFrom?: Record<string, string>;
};
invoke: {
cmdText: string;
argv: string[];
binding: {
cwd: string | null;
agentId: string | null;
sessionKey: string | null;
env?: Record<string, string>;
};
};
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);
}
});
}
});

View File

@@ -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");
});
});

View File

@@ -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<string, unknown>)) {
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<ExecApprovalRequestPayload, "envHash">): 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<string, unknown>;
};
type SystemRunApprovalMismatch = Extract<SystemRunApprovalMatchResult, { ok: false }>;
const APPROVAL_REQUEST_MISMATCH_MESSAGE = "approval id does not match request";
function requestMismatch(details?: Record<string, unknown>): 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<string, unknown> } {
const details: Record<string, unknown> = {
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,
};
}

View File

@@ -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 });
});
});

View File

@@ -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<string, unknown>)) {
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<string, unknown>;
};
export function matchSystemRunApprovalEnvBinding(params: {
request: Pick<ExecApprovalRequestPayload, "envHash">;
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 };
}

View File

@@ -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;

View File

@@ -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",
);
});
});

View File

@@ -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 }
}
]
}