fix(security): harden exec approval boundaries

This commit is contained in:
Peter Steinberger
2026-03-22 09:35:16 -07:00
parent e99d44525a
commit a94ec3b79b
29 changed files with 835 additions and 67 deletions

View File

@@ -2,8 +2,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, type Mock, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { loadExecApprovals, saveExecApprovals } from "../infra/exec-approvals.js";
import type { ExecHostResponse } from "../infra/exec-host.js";
import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js";
@@ -1229,4 +1230,65 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
errorLabel: "runCommand should not be called for nested env depth overflow",
});
});
it("requires explicit approval for inline eval when strictInlineEval is enabled", async () => {
setRuntimeConfigSnapshot({
tools: {
exec: {
strictInlineEval: true,
},
},
});
try {
const { runCommand, sendInvokeResult, sendNodeEvent } = await runSystemInvoke({
preferMacAppExecHost: false,
command: ["python3", "-c", "print('hi')"],
security: "full",
ask: "off",
});
expect(runCommand).not.toHaveBeenCalled();
expect(sendNodeEvent).toHaveBeenCalledWith(
expect.anything(),
"exec.denied",
expect.objectContaining({ reason: "approval-required" }),
);
expectInvokeErrorMessage(sendInvokeResult, {
message: "python3 -c requires explicit approval in strictInlineEval mode",
});
} finally {
clearRuntimeConfigSnapshot();
}
});
it("does not persist allow-always interpreter approvals when strictInlineEval is enabled", async () => {
setRuntimeConfigSnapshot({
tools: {
exec: {
strictInlineEval: true,
},
},
});
try {
await withTempApprovalsHome({
approvals: createAllowlistOnMissApprovals(),
run: async () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: ["python3", "-c", "print('hi')"],
security: "allowlist",
ask: "on-miss",
approved: true,
runCommand: vi.fn(async () => createLocalRunResult("inline-eval-ok")),
});
expect(runCommand).toHaveBeenCalledTimes(1);
expectInvokeOk(sendInvokeResult, { payloadContains: "inline-eval-ok" });
expect(loadExecApprovals().agents?.main?.allowlist ?? []).toEqual([]);
},
});
} finally {
clearRuntimeConfigSnapshot();
}
});
});

View File

@@ -13,6 +13,10 @@ import {
type ExecSecurity,
} from "../infra/exec-approvals.js";
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
import {
describeInterpreterInlineEval,
detectInterpreterInlineEvalArgv,
} from "../infra/exec-inline-eval.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import {
inspectHostExecEnvOverrides,
@@ -91,6 +95,7 @@ type SystemRunPolicyPhase = SystemRunParsePhase & {
approvals: ResolvedExecApprovals;
security: ExecSecurity;
policy: ReturnType<typeof evaluateSystemRunPolicy>;
inlineEvalHit: ReturnType<typeof detectInterpreterInlineEvalArgv>;
allowlistMatches: ExecAllowlistEntry[];
analysisOk: boolean;
allowlistSatisfied: boolean;
@@ -338,6 +343,15 @@ async function evaluateSystemRunPolicyPhase(
skillBins: bins,
autoAllowSkills,
});
const strictInlineEval =
agentExec?.strictInlineEval === true || cfg.tools?.exec?.strictInlineEval === true;
const inlineEvalHit = strictInlineEval
? (segments
.map((segment) =>
detectInterpreterInlineEvalArgv(segment.resolution?.effectiveArgv ?? segment.argv),
)
.find((entry) => entry !== null) ?? null)
: null;
const isWindows = process.platform === "win32";
const cmdInvocation = parsed.shellPayload
? opts.isCmdExeInvocation(segments[0]?.argv ?? [])
@@ -363,6 +377,16 @@ async function evaluateSystemRunPolicyPhase(
return null;
}
if (inlineEvalHit && !policy.approvedByAsk) {
await sendSystemRunDenied(opts, parsed.execution, {
reason: "approval-required",
message:
`SYSTEM_RUN_DENIED: approval required (` +
`${describeInterpreterInlineEval(inlineEvalHit)} requires explicit approval in strictInlineEval mode)`,
});
return null;
}
// Fail closed if policy/runtime drift re-allows unapproved shell wrappers.
if (security === "allowlist" && parsed.shellPayload && !policy.approvedByAsk) {
await sendSystemRunDenied(opts, parsed.execution, {
@@ -414,6 +438,7 @@ async function evaluateSystemRunPolicyPhase(
approvals,
security,
policy,
inlineEvalHit,
allowlistMatches,
analysisOk,
allowlistSatisfied,
@@ -518,7 +543,11 @@ async function executeSystemRunPhase(
}
}
if (phase.policy.approvalDecision === "allow-always" && phase.security === "allowlist") {
if (
phase.policy.approvalDecision === "allow-always" &&
phase.security === "allowlist" &&
phase.inlineEvalHit === null
) {
if (phase.policy.analysisOk) {
const patterns = resolveAllowAlwaysPatterns({
segments: phase.segments,