refactor(exec): dedupe durable approval checks

This commit is contained in:
Peter Steinberger
2026-04-04 07:12:14 +01:00
parent b32a2cadc2
commit dd16080af7
4 changed files with 108 additions and 64 deletions

View File

@@ -80,6 +80,19 @@ export type ProcessGatewayAllowlistResult = {
pendingResult?: AgentToolResult<ExecToolDetails>;
};
function hasGatewayAllowlistMiss(params: {
hostSecurity: ExecSecurity;
analysisOk: boolean;
allowlistSatisfied: boolean;
durableApprovalSatisfied: boolean;
}): boolean {
return (
params.hostSecurity === "allowlist" &&
(!params.analysisOk || !params.allowlistSatisfied) &&
!params.durableApprovalSatisfied
);
}
export async function processGatewayAllowlist(
params: ProcessGatewayAllowlistParams,
): Promise<ProcessGatewayAllowlistResult> {
@@ -330,10 +343,13 @@ export async function processGatewayAllowlist(
}
if (
hostSecurity === "allowlist" &&
(!analysisOk || !allowlistSatisfied) &&
!approvedByAsk &&
!durableApprovalSatisfied
hasGatewayAllowlistMiss({
hostSecurity,
analysisOk,
allowlistSatisfied,
durableApprovalSatisfied,
})
) {
deniedReason = deniedReason ?? "allowlist-miss";
}
@@ -406,9 +422,12 @@ export async function processGatewayAllowlist(
}
if (
hostSecurity === "allowlist" &&
(!analysisOk || !allowlistSatisfied) &&
!durableApprovalSatisfied
hasGatewayAllowlistMiss({
hostSecurity,
analysisOk,
allowlistSatisfied,
durableApprovalSatisfied,
})
) {
throw new Error("exec denied: allowlist miss");
}

View File

@@ -165,6 +165,32 @@ async function expectGatewayExecWithoutApproval(options: {
expect(calls).not.toContain("exec.approval.waitDecision");
}
async function expectGatewayAskAlwaysPrompt(options: {
turnId: string;
command?: string;
allowlist?: Array<{ pattern: string; source?: "allow-always" }>;
}) {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "full" },
agents: {
main: options.allowlist ? { allowlist: options.allowlist } : {},
},
});
mockPendingApprovalRegistration();
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
return await tool.execute(options.turnId, {
command: options.command ?? `${JSON.stringify(process.execPath)} --version`,
});
}
function mockAcceptedApprovalFlow(options: {
onAgent?: (params: Record<string, unknown>) => void;
onNodeInvoke?: (params: unknown) => unknown;
@@ -484,26 +510,9 @@ describe("exec approvals", () => {
});
it("keeps ask=always prompts even when durable allow-always trust matches", async () => {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "full" },
agents: {
main: {
allowlist: [{ pattern: process.execPath, source: "allow-always" }],
},
},
});
mockPendingApprovalRegistration();
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-gateway-durable-still-prompts", {
command: `${JSON.stringify(process.execPath)} --version`,
const result = await expectGatewayAskAlwaysPrompt({
turnId: "call-gateway-durable-still-prompts",
allowlist: [{ pattern: process.execPath, source: "allow-always" }],
});
expect(result.details.status).toBe("approval-pending");
@@ -517,26 +526,9 @@ describe("exec approvals", () => {
});
it("keeps ask=always prompts for static allowlist entries without allow-always trust", async () => {
await writeExecApprovalsConfig({
version: 1,
defaults: { security: "full", ask: "always", askFallback: "full" },
agents: {
main: {
allowlist: [{ pattern: process.execPath }],
},
},
});
mockPendingApprovalRegistration();
const tool = createExecTool({
host: "gateway",
ask: "always",
security: "full",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call-static-allowlist-still-prompts", {
command: `${JSON.stringify(process.execPath)} --version`,
const result = await expectGatewayAskAlwaysPrompt({
turnId: "call-static-allowlist-still-prompts",
allowlist: [{ pattern: process.execPath }],
});
expect(result.details.status).toBe("approval-pending");