refactor(exec-approvals): unify system.run binding and generate host env policy
This commit is contained in:
@@ -1,37 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum HostEnvSanitizer {
|
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.
|
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||||
private static let blockedKeys: Set<String> = [
|
private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys
|
||||||
"NODE_OPTIONS",
|
private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes
|
||||||
"NODE_PATH",
|
private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys
|
||||||
"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 shellWrapperAllowedOverrideKeys: Set<String> = [
|
private static let shellWrapperAllowedOverrideKeys: Set<String> = [
|
||||||
"TERM",
|
"TERM",
|
||||||
"LANG",
|
"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;
|
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 };
|
type ParsedDecision = { present: boolean; value: string | null };
|
||||||
|
|
||||||
function parseDecision(value: unknown): ParsedDecision {
|
function parseDecision(value: unknown): ParsedDecision {
|
||||||
@@ -65,26 +111,7 @@ export async function registerExecApprovalRequest(
|
|||||||
}>(
|
}>(
|
||||||
"exec.approval.request",
|
"exec.approval.request",
|
||||||
{ timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS },
|
{ timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS },
|
||||||
{
|
buildExecApprovalRequestToolParams(params),
|
||||||
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,
|
|
||||||
},
|
|
||||||
{ expectFinal: false },
|
{ expectFinal: false },
|
||||||
);
|
);
|
||||||
const decision = parseDecision(registrationResult);
|
const decision = parseDecision(registrationResult);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { approvalMatchesSystemRunRequest } from "./node-invoke-system-run-approval-match.js";
|
import { evaluateSystemRunApprovalMatch } from "./node-invoke-system-run-approval-match.js";
|
||||||
import { buildSystemRunApprovalEnvBinding } from "./system-run-approval-env-binding.js";
|
import {
|
||||||
|
buildSystemRunApprovalBindingV1,
|
||||||
|
buildSystemRunApprovalEnvBinding,
|
||||||
|
} from "./system-run-approval-binding.js";
|
||||||
|
|
||||||
describe("approvalMatchesSystemRunRequest", () => {
|
describe("evaluateSystemRunApprovalMatch", () => {
|
||||||
test("matches legacy command text when binding fields match", () => {
|
test("matches legacy command text when binding fields match", () => {
|
||||||
const result = approvalMatchesSystemRunRequest({
|
const result = evaluateSystemRunApprovalMatch({
|
||||||
cmdText: "echo SAFE",
|
cmdText: "echo SAFE",
|
||||||
argv: ["echo", "SAFE"],
|
argv: ["echo", "SAFE"],
|
||||||
request: {
|
request: {
|
||||||
@@ -20,11 +23,11 @@ describe("approvalMatchesSystemRunRequest", () => {
|
|||||||
sessionKey: "session-1",
|
sessionKey: "session-1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects legacy command mismatch", () => {
|
test("rejects legacy command mismatch", () => {
|
||||||
const result = approvalMatchesSystemRunRequest({
|
const result = evaluateSystemRunApprovalMatch({
|
||||||
cmdText: "echo PWNED",
|
cmdText: "echo PWNED",
|
||||||
argv: ["echo", "PWNED"],
|
argv: ["echo", "PWNED"],
|
||||||
request: {
|
request: {
|
||||||
@@ -37,17 +40,26 @@ describe("approvalMatchesSystemRunRequest", () => {
|
|||||||
sessionKey: null,
|
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", () => {
|
test("enforces exact argv binding in v1 object", () => {
|
||||||
const result = approvalMatchesSystemRunRequest({
|
const result = evaluateSystemRunApprovalMatch({
|
||||||
cmdText: "echo SAFE",
|
cmdText: "echo SAFE",
|
||||||
argv: ["echo", "SAFE"],
|
argv: ["echo", "SAFE"],
|
||||||
request: {
|
request: {
|
||||||
host: "node",
|
host: "node",
|
||||||
command: "echo SAFE",
|
command: "echo SAFE",
|
||||||
commandArgv: ["echo", "SAFE"],
|
systemRunBindingV1: buildSystemRunApprovalBindingV1({
|
||||||
|
argv: ["echo", "SAFE"],
|
||||||
|
cwd: null,
|
||||||
|
agentId: null,
|
||||||
|
sessionKey: null,
|
||||||
|
}).binding,
|
||||||
},
|
},
|
||||||
binding: {
|
binding: {
|
||||||
cwd: null,
|
cwd: null,
|
||||||
@@ -55,17 +67,22 @@ describe("approvalMatchesSystemRunRequest", () => {
|
|||||||
sessionKey: null,
|
sessionKey: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects argv mismatch even when command text matches", () => {
|
test("rejects argv mismatch in v1 object", () => {
|
||||||
const result = approvalMatchesSystemRunRequest({
|
const result = evaluateSystemRunApprovalMatch({
|
||||||
cmdText: "echo SAFE",
|
cmdText: "echo SAFE",
|
||||||
argv: ["echo", "SAFE"],
|
argv: ["echo", "SAFE"],
|
||||||
request: {
|
request: {
|
||||||
host: "node",
|
host: "node",
|
||||||
command: "echo SAFE",
|
command: "echo SAFE",
|
||||||
commandArgv: ["echo SAFE"],
|
systemRunBindingV1: buildSystemRunApprovalBindingV1({
|
||||||
|
argv: ["echo SAFE"],
|
||||||
|
cwd: null,
|
||||||
|
agentId: null,
|
||||||
|
sessionKey: null,
|
||||||
|
}).binding,
|
||||||
},
|
},
|
||||||
binding: {
|
binding: {
|
||||||
cwd: null,
|
cwd: null,
|
||||||
@@ -73,11 +90,15 @@ describe("approvalMatchesSystemRunRequest", () => {
|
|||||||
sessionKey: null,
|
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", () => {
|
test("rejects env overrides when approval record lacks env binding", () => {
|
||||||
const result = approvalMatchesSystemRunRequest({
|
const result = evaluateSystemRunApprovalMatch({
|
||||||
cmdText: "git diff",
|
cmdText: "git diff",
|
||||||
argv: ["git", "diff"],
|
argv: ["git", "diff"],
|
||||||
request: {
|
request: {
|
||||||
@@ -92,22 +113,26 @@ describe("approvalMatchesSystemRunRequest", () => {
|
|||||||
env: { GIT_EXTERNAL_DIFF: "/tmp/pwn.sh" },
|
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", () => {
|
test("accepts matching env hash with reordered keys", () => {
|
||||||
const binding = buildSystemRunApprovalEnvBinding({
|
const envBinding = buildSystemRunApprovalEnvBinding({
|
||||||
SAFE_A: "1",
|
SAFE_A: "1",
|
||||||
SAFE_B: "2",
|
SAFE_B: "2",
|
||||||
});
|
});
|
||||||
const result = approvalMatchesSystemRunRequest({
|
const result = evaluateSystemRunApprovalMatch({
|
||||||
cmdText: "git diff",
|
cmdText: "git diff",
|
||||||
argv: ["git", "diff"],
|
argv: ["git", "diff"],
|
||||||
request: {
|
request: {
|
||||||
host: "node",
|
host: "node",
|
||||||
command: "git diff",
|
command: "git diff",
|
||||||
commandArgv: ["git", "diff"],
|
commandArgv: ["git", "diff"],
|
||||||
envHash: binding.envHash,
|
envHash: envBinding.envHash,
|
||||||
},
|
},
|
||||||
binding: {
|
binding: {
|
||||||
cwd: null,
|
cwd: null,
|
||||||
@@ -116,11 +141,11 @@ describe("approvalMatchesSystemRunRequest", () => {
|
|||||||
env: { SAFE_B: "2", SAFE_A: "1" },
|
env: { SAFE_B: "2", SAFE_A: "1" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects non-node host requests", () => {
|
test("rejects non-node host requests", () => {
|
||||||
const result = approvalMatchesSystemRunRequest({
|
const result = evaluateSystemRunApprovalMatch({
|
||||||
cmdText: "echo SAFE",
|
cmdText: "echo SAFE",
|
||||||
argv: ["echo", "SAFE"],
|
argv: ["echo", "SAFE"],
|
||||||
request: {
|
request: {
|
||||||
@@ -133,6 +158,10 @@ describe("approvalMatchesSystemRunRequest", () => {
|
|||||||
sessionKey: null,
|
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 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 = {
|
export type SystemRunApprovalBinding = {
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
@@ -8,35 +13,16 @@ export type SystemRunApprovalBinding = {
|
|||||||
env?: unknown;
|
env?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function argvMatchesRequest(requestedArgv: string[], argv: string[]): boolean {
|
function requestMismatch(): SystemRunApprovalMatchResult {
|
||||||
if (requestedArgv.length === 0 || requestedArgv.length !== argv.length) {
|
return {
|
||||||
return false;
|
ok: false,
|
||||||
}
|
code: "APPROVAL_REQUEST_MISMATCH",
|
||||||
for (let i = 0; i < requestedArgv.length; i += 1) {
|
message: "approval id does not match request",
|
||||||
if (requestedArgv[i] !== argv[i]) {
|
};
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function approvalMatchesSystemRunRequest(params: {
|
export { toSystemRunApprovalMismatchError } from "./system-run-approval-binding.js";
|
||||||
cmdText: string;
|
export type { SystemRunApprovalMatchResult } from "./system-run-approval-binding.js";
|
||||||
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 function evaluateSystemRunApprovalMatch(params: {
|
export function evaluateSystemRunApprovalMatch(params: {
|
||||||
cmdText: string;
|
cmdText: string;
|
||||||
@@ -45,59 +31,30 @@ export function evaluateSystemRunApprovalMatch(params: {
|
|||||||
binding: SystemRunApprovalBinding;
|
binding: SystemRunApprovalBinding;
|
||||||
}): SystemRunApprovalMatchResult {
|
}): SystemRunApprovalMatchResult {
|
||||||
if (params.request.host !== "node") {
|
if (params.request.host !== "node") {
|
||||||
return {
|
return requestMismatch();
|
||||||
ok: false,
|
|
||||||
code: "APPROVAL_REQUEST_MISMATCH",
|
|
||||||
message: "approval id does not match request",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedArgv = params.request.commandArgv;
|
const actualBinding = buildSystemRunApprovalBindingV1({
|
||||||
if (Array.isArray(requestedArgv)) {
|
argv: params.argv,
|
||||||
if (!argvMatchesRequest(requestedArgv, params.argv)) {
|
cwd: params.binding.cwd,
|
||||||
return {
|
agentId: params.binding.agentId,
|
||||||
ok: false,
|
sessionKey: params.binding.sessionKey,
|
||||||
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,
|
|
||||||
env: params.binding.env,
|
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 { describe, expect, test } from "vitest";
|
||||||
import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js";
|
import { ExecApprovalManager, type ExecApprovalRecord } from "./exec-approval-manager.js";
|
||||||
import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.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", () => {
|
describe("sanitizeSystemRunParamsForForwarding", () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -224,7 +224,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
|||||||
|
|
||||||
test("rejects env hash mismatch", () => {
|
test("rejects env hash mismatch", () => {
|
||||||
const record = makeRecord("git diff", ["git", "diff"]);
|
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({
|
const result = sanitizeSystemRunParamsForForwarding({
|
||||||
rawParams: {
|
rawParams: {
|
||||||
command: ["git", "diff"],
|
command: ["git", "diff"],
|
||||||
@@ -249,7 +256,14 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
|
|||||||
test("accepts matching env hash with reordered keys", () => {
|
test("accepts matching env hash with reordered keys", () => {
|
||||||
const record = makeRecord("git diff", ["git", "diff"]);
|
const record = makeRecord("git diff", ["git", "diff"]);
|
||||||
const binding = buildSystemRunApprovalEnvBinding({ SAFE_A: "1", SAFE_B: "2" });
|
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({
|
const result = sanitizeSystemRunParamsForForwarding({
|
||||||
rawParams: {
|
rawParams: {
|
||||||
command: ["git", "diff"],
|
command: ["git", "diff"],
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
|
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
|
||||||
import type { ExecApprovalRecord } from "./exec-approval-manager.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 = {
|
type SystemRunParamsLike = {
|
||||||
command?: unknown;
|
command?: unknown;
|
||||||
@@ -216,15 +219,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!approvalMatch.ok) {
|
if (!approvalMatch.ok) {
|
||||||
return {
|
return toSystemRunApprovalMismatchError({ runId, match: approvalMatch });
|
||||||
ok: false,
|
|
||||||
message: approvalMatch.message,
|
|
||||||
details: {
|
|
||||||
code: approvalMatch.code,
|
|
||||||
runId,
|
|
||||||
...approvalMatch.details,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal path: enforce the decision recorded by the gateway.
|
// Normal path: enforce the decision recorded by the gateway.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
validateExecApprovalRequestParams,
|
validateExecApprovalRequestParams,
|
||||||
validateExecApprovalResolveParams,
|
validateExecApprovalResolveParams,
|
||||||
} from "../protocol/index.js";
|
} 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";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
export function createExecApprovalHandlers(
|
export function createExecApprovalHandlers(
|
||||||
@@ -70,7 +70,16 @@ export function createExecApprovalHandlers(
|
|||||||
const commandArgv = Array.isArray(p.commandArgv)
|
const commandArgv = Array.isArray(p.commandArgv)
|
||||||
? p.commandArgv.map((entry) => String(entry))
|
? p.commandArgv.map((entry) => String(entry))
|
||||||
: undefined;
|
: 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) {
|
if (host === "node" && !nodeId) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -90,8 +99,8 @@ export function createExecApprovalHandlers(
|
|||||||
const request = {
|
const request = {
|
||||||
command: p.command,
|
command: p.command,
|
||||||
commandArgv,
|
commandArgv,
|
||||||
envHash: envBinding.envHash,
|
envKeys: systemRunBindingV1?.envKeys?.length ? systemRunBindingV1.envKeys : undefined,
|
||||||
envKeys: envBinding.envKeys.length > 0 ? envBinding.envKeys : undefined,
|
systemRunBindingV1: systemRunBindingV1?.binding ?? null,
|
||||||
cwd: p.cwd ?? null,
|
cwd: p.cwd ?? null,
|
||||||
nodeId: host === "node" ? nodeId : null,
|
nodeId: host === "node" ? nodeId : null,
|
||||||
host: host || null,
|
host: host || null,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js
|
|||||||
import { resetLogger, setLoggerOverride } from "../../logging.js";
|
import { resetLogger, setLoggerOverride } from "../../logging.js";
|
||||||
import { ExecApprovalManager } from "../exec-approval-manager.js";
|
import { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||||
import { validateExecApprovalRequestParams } from "../protocol/index.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 { waitForAgentJob } from "./agent-job.js";
|
||||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||||
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.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);
|
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();
|
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
||||||
await requestExecApproval({
|
await requestExecApproval({
|
||||||
handlers,
|
handlers,
|
||||||
respond,
|
respond,
|
||||||
context,
|
context,
|
||||||
params: {
|
params: {
|
||||||
|
commandArgv: ["echo", "ok"],
|
||||||
env: {
|
env: {
|
||||||
Z_VAR: "z",
|
Z_VAR: "z",
|
||||||
A_VAR: "a",
|
A_VAR: "a",
|
||||||
@@ -440,12 +441,14 @@ describe("exec approval handlers", () => {
|
|||||||
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
||||||
expect(requested).toBeTruthy();
|
expect(requested).toBeTruthy();
|
||||||
const request = (requested?.payload as { request?: Record<string, unknown> })?.request ?? {};
|
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["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 () => {
|
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 ExecSecurity = "deny" | "allowlist" | "full";
|
||||||
export type ExecAsk = "off" | "on-miss" | "always";
|
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 = {
|
export type ExecApprovalRequestPayload = {
|
||||||
command: string;
|
command: string;
|
||||||
commandArgv?: string[];
|
commandArgv?: string[];
|
||||||
|
// Legacy env binding field (used for backward compatibility with old approvals).
|
||||||
envHash?: string | null;
|
envHash?: string | null;
|
||||||
|
// Optional UI-safe env key preview for approval prompts.
|
||||||
envKeys?: string[];
|
envKeys?: string[];
|
||||||
|
systemRunBindingV1?: SystemRunApprovalBindingV1 | null;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
nodeId?: string | null;
|
nodeId?: string | null;
|
||||||
host?: string | null;
|
host?: string | null;
|
||||||
|
|||||||
@@ -19,26 +19,44 @@ function parseSwiftStringArray(source: string, marker: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("host env security policy parity", () => {
|
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 repoRoot = process.cwd();
|
||||||
const policyPath = path.join(repoRoot, "src/infra/host-env-security-policy.json");
|
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 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(
|
const swiftBlockedOverrideKeys = parseSwiftStringArray(
|
||||||
swiftSource,
|
generatedSource,
|
||||||
"private static let blockedOverrideKeys",
|
"static let blockedOverrideKeys",
|
||||||
);
|
);
|
||||||
const swiftBlockedPrefixes = parseSwiftStringArray(
|
const swiftBlockedPrefixes = parseSwiftStringArray(
|
||||||
swiftSource,
|
generatedSource,
|
||||||
"private static let blockedPrefixes",
|
"static let blockedPrefixes",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(swiftBlockedKeys).toEqual(policy.blockedKeys);
|
expect(swiftBlockedKeys).toEqual(policy.blockedKeys);
|
||||||
expect(swiftBlockedOverrideKeys).toEqual(policy.blockedOverrideKeys ?? []);
|
expect(swiftBlockedOverrideKeys).toEqual(policy.blockedOverrideKeys ?? []);
|
||||||
expect(swiftBlockedPrefixes).toEqual(policy.blockedPrefixes);
|
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