mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(exec-approvals): unify system.run binding and generate host env policy
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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_"
|
||||
]
|
||||
}
|
||||
45
scripts/generate-host-env-security-policy-swift.mjs
Normal file
45
scripts/generate-host-env-security-policy-swift.mjs
Normal 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)}`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
99
src/gateway/system-run-approval-binding.contract.test.ts
Normal file
99
src/gateway/system-run-approval-binding.contract.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
101
src/gateway/system-run-approval-binding.test.ts
Normal file
101
src/gateway/system-run-approval-binding.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
238
src/gateway/system-run-approval-binding.ts
Normal file
238
src/gateway/system-run-approval-binding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
116
test/fixtures/system-run-approval-binding-contract.json
vendored
Normal file
116
test/fixtures/system-run-approval-binding-contract.json
vendored
Normal 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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user