Skip to content

Commit 49ab528

Browse files
joshavantvincentkocjesse-merhi
committed
fix(exec): honor node approval floors before auto-review
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
1 parent 765477d commit 49ab528

3 files changed

Lines changed: 146 additions & 4 deletions

File tree

src/agents/bash-tools.exec-host-node-phases.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ type NodeApprovalAnalysis = {
4848
analysisOk: boolean;
4949
allowlistSatisfied: boolean;
5050
durableApprovalSatisfied: boolean;
51+
nodeSecurity?: ExecSecurity;
52+
nodeAsk?: ExecAsk;
5153
inlineEvalHit: InterpreterInlineEvalHit | null;
5254
requiresSecurityAuditSuppressionApproval: boolean;
5355
autoReviewArgv?: string[];
@@ -314,6 +316,8 @@ export async function analyzeNodeApprovalRequirement(params: {
314316
let analysisOk = baseAllowlistEval.analysisOk;
315317
let allowlistSatisfied = false;
316318
let durableApprovalSatisfied = false;
319+
let nodeSecurity: ExecSecurity | undefined;
320+
let nodeAsk: ExecAsk | undefined;
317321
const inlineEvalHit =
318322
params.request.strictInlineEval === true
319323
? detectPolicyInlineEval(baseAllowlistEval.segments)
@@ -349,6 +353,8 @@ export async function analyzeNodeApprovalRequirement(params: {
349353
agentId: params.request.agentId,
350354
overrides: { security: "full" },
351355
});
356+
nodeSecurity = resolved.agent.security;
357+
nodeAsk = resolved.agent.ask;
352358
// Allowlist-only precheck; safe bins are node-local and may diverge.
353359
const allowlistEval = evaluateShellAllowlist({
354360
command: params.request.command,
@@ -376,6 +382,8 @@ export async function analyzeNodeApprovalRequirement(params: {
376382
analysisOk,
377383
allowlistSatisfied,
378384
durableApprovalSatisfied,
385+
nodeSecurity,
386+
nodeAsk,
379387
inlineEvalHit,
380388
requiresSecurityAuditSuppressionApproval,
381389
autoReviewArgv:

src/agents/bash-tools.exec-host-node.test.ts

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ const evaluateShellAllowlistMock = vi.hoisted(() =>
3838
segmentAllowlistEntries: [],
3939
})),
4040
);
41+
const resolveExecApprovalsFromFileMock = vi.hoisted(() =>
42+
vi.fn(() => ({
43+
allowlist: [],
44+
file: { version: 1, agents: {} },
45+
agent: {
46+
security: "full",
47+
ask: "off",
48+
askFallback: "deny",
49+
autoAllowSkills: false,
50+
},
51+
})),
52+
);
4153
const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
4254
const resolveExecHostApprovalContextMock = vi.hoisted(() =>
4355
vi.fn(() => ({
@@ -93,10 +105,7 @@ vi.mock("../infra/exec-approvals.js", () => ({
93105
hasDurableExecApproval: vi.fn(() => false),
94106
requiresExecApproval: requiresExecApprovalMock,
95107
resolveExecApprovalAllowedDecisions: vi.fn(() => ["allow-once", "allow-always", "deny"]),
96-
resolveExecApprovalsFromFile: vi.fn(() => ({
97-
allowlist: [],
98-
file: { version: 1, agents: {} },
99-
})),
108+
resolveExecApprovalsFromFile: resolveExecApprovalsFromFileMock,
100109
}));
101110

102111
vi.mock("../infra/command-analysis/inline-eval.js", () => ({
@@ -275,6 +284,17 @@ describe("executeNodeHostCommand", () => {
275284
segments: [{ resolution: null, argv: ["bun", "./script.ts"] }],
276285
segmentAllowlistEntries: [],
277286
});
287+
resolveExecApprovalsFromFileMock.mockReset();
288+
resolveExecApprovalsFromFileMock.mockReturnValue({
289+
allowlist: [],
290+
file: { version: 1, agents: {} },
291+
agent: {
292+
security: "full",
293+
ask: "off",
294+
askFallback: "deny",
295+
autoAllowSkills: false,
296+
},
297+
});
278298
requiresExecApprovalMock.mockReset();
279299
requiresExecApprovalMock.mockReturnValue(true);
280300
resolveExecHostApprovalContextMock.mockReset();
@@ -460,6 +480,94 @@ describe("executeNodeHostCommand", () => {
460480
expect(warnings.join("\n")).toContain("needs a person");
461481
});
462482

483+
it.each([
484+
{
485+
name: "ask always",
486+
nodeSecurity: "full",
487+
nodeAsk: "always",
488+
},
489+
{
490+
name: "deny security",
491+
nodeSecurity: "deny",
492+
nodeAsk: "off",
493+
},
494+
] as const)(
495+
"requests human approval when node policy has $name floor",
496+
async ({ nodeSecurity, nodeAsk }) => {
497+
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
498+
decision: "allow-once",
499+
risk: "low",
500+
rationale: "test reviewer would allow it",
501+
}));
502+
resolveExecHostApprovalContextMock.mockReturnValue({
503+
approvals: { allowlist: [], file: { version: 1, agents: {} } },
504+
hostSecurity: "allowlist",
505+
hostAsk: "on-miss",
506+
askFallback: "deny",
507+
});
508+
resolveExecApprovalsFromFileMock.mockReturnValue({
509+
allowlist: [],
510+
file: { version: 1, agents: {} },
511+
agent: {
512+
security: nodeSecurity,
513+
ask: nodeAsk,
514+
askFallback: "deny",
515+
autoAllowSkills: false,
516+
},
517+
});
518+
callGatewayToolMock.mockImplementation(
519+
async (method: string, _options: unknown, params: MockNodeInvokeParams | undefined) => {
520+
if (method === "exec.approvals.node.get") {
521+
return { file: { version: 1, agents: {} } };
522+
}
523+
if (method === "exec.approval.resolve") {
524+
return { payload: {} };
525+
}
526+
if (method !== "node.invoke") {
527+
throw new Error(`unexpected gateway method: ${method}`);
528+
}
529+
if (params?.command === "system.run.prepare") {
530+
return { payload: { plan: preparedPlan } };
531+
}
532+
if (params?.command === "system.run") {
533+
return {
534+
payload: {
535+
success: true,
536+
stdout: "ok",
537+
stderr: "",
538+
exitCode: 0,
539+
timedOut: false,
540+
},
541+
};
542+
}
543+
throw new Error(`unexpected node invoke command: ${String(params?.command)}`);
544+
},
545+
);
546+
547+
const result = await executeNodeHostCommand({
548+
command: "bun ./script.ts",
549+
workdir: "/tmp/work",
550+
env: {},
551+
security: "allowlist",
552+
ask: "on-miss",
553+
autoReview: true,
554+
autoReviewer,
555+
defaultTimeoutSec: 30,
556+
approvalRunningNoticeMs: 0,
557+
warnings: [],
558+
agentId: "requested-agent",
559+
sessionKey: "requested-session",
560+
});
561+
562+
expect(result.details?.status).toBe("approval-pending");
563+
expect(autoReviewer).not.toHaveBeenCalled();
564+
expect(createAndRegisterDefaultExecApprovalRequestMock).toHaveBeenCalledTimes(1);
565+
expect(
566+
callGatewayToolMock.mock.calls.some(([method]) => method === "exec.approval.resolve"),
567+
).toBe(false);
568+
},
569+
);
570+
463571
it("auto-reviews strict inline-eval commands before asking a human", async () => {
464572
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
465573
decision: "allow-once",

src/agents/bash-tools.exec-host-node.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ function resolveNodeAutoReviewReason(params: {
5656
return "approval-required";
5757
}
5858

59+
function execSecurityFloorRank(security: ExecSecurity): number {
60+
switch (security) {
61+
case "full":
62+
return 0;
63+
case "allowlist":
64+
return 1;
65+
case "deny":
66+
return 2;
67+
}
68+
}
69+
70+
function nodePolicyBlocksAutoReview(params: {
71+
hostSecurity: ExecSecurity;
72+
nodeSecurity?: ExecSecurity;
73+
nodeAsk?: "off" | "on-miss" | "always";
74+
}): boolean {
75+
return (
76+
params.nodeAsk === "always" ||
77+
(params.nodeSecurity !== undefined &&
78+
execSecurityFloorRank(params.nodeSecurity) > execSecurityFloorRank(params.hostSecurity))
79+
);
80+
}
81+
5982
export async function executeNodeHostCommand(
6083
params: ExecuteNodeHostCommandParams,
6184
): Promise<AgentToolResult<ExecToolDetails>> {
@@ -88,6 +111,8 @@ export async function executeNodeHostCommand(
88111
analysisOk,
89112
allowlistSatisfied,
90113
durableApprovalSatisfied,
114+
nodeSecurity,
115+
nodeAsk,
91116
inlineEvalHit,
92117
requiresSecurityAuditSuppressionApproval,
93118
autoReviewArgv,
@@ -142,6 +167,7 @@ export async function executeNodeHostCommand(
142167
if (
143168
params.autoReview === true &&
144169
hostAsk !== "always" &&
170+
!nodePolicyBlocksAutoReview({ hostSecurity, nodeSecurity, nodeAsk }) &&
145171
!requiresSecurityAuditSuppressionApproval
146172
) {
147173
const reviewer = params.autoReviewer ?? defaultExecAutoReviewer;

0 commit comments

Comments
 (0)