Skip to content

Commit 96e54da

Browse files
committed
fix(exec): surface headless gate denials on stderr via onHeadlessDeny
1 parent bc972f5 commit 96e54da

4 files changed

Lines changed: 127 additions & 32 deletions

File tree

‎src/exec/runner.test.ts‎

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, spyOn, test } from "bun:test";
2+
import type { ToolCall } from "@intx/types/runtime";
23
import type { AgentTool } from "@intx/agent";
34
import { submitOutputDefinition } from "../agent/director.js";
45
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
@@ -19,7 +20,7 @@ import {
1920
} from "../auth/codex/session.js";
2021
import type { Config } from "../config/index.js";
2122
import { CREDENTIAL_FAILURE_USER_MESSAGE } from "../inference-error-message.js";
22-
import type { PermissionRequest } from "../permission/types.js";
23+
import { createPermissionGate } from "../permission/gate.js";
2324
import {
2425
clearActiveRun,
2526
getActiveRun,
@@ -45,7 +46,6 @@ import {
4546
createExecToolPromoter,
4647
execUserFailureMessage,
4748
isExecOverlayToolAllowed,
48-
promptPermission,
4949
refreshSelectedProviderCredential,
5050
resolveExecDirectorOverlay,
5151
resolveExecDirectorOverlayForPackage,
@@ -844,13 +844,22 @@ describe("exec permission prompt gating (CL-9002)", () => {
844844
).toBe(false);
845845
});
846846

847-
test("headless permission seam fails closed with an explicit stderr denial", async () => {
848-
const request: PermissionRequest = {
849-
tool: "run_shell",
850-
action: "Run shell",
851-
subject: "rm -rf /tmp/work",
852-
scopes: [],
853-
};
847+
// Exec-identical headless gate wiring: interactive:false with the prompt seam
848+
// armed to throw, so a test passes only when decide() denies on the real
849+
// production path (the old direct-seam test never went through decide()).
850+
const headlessGate = (onDeny: (reason: string) => void) =>
851+
createPermissionGate({
852+
approvals: [],
853+
requestApproval: async () => {
854+
throw new Error("headless run must never reach the prompt seam");
855+
},
856+
interactive: false,
857+
onHeadlessDeny: onDeny,
858+
skipPermissions: false,
859+
reactorGated: true,
860+
});
861+
862+
const captureStdio = () => {
854863
const errWrites: string[] = [];
855864
const outWrites: string[] = [];
856865
const stderrSpy = spyOn(process.stderr, "write").mockImplementation(((
@@ -865,16 +874,89 @@ describe("exec permission prompt gating (CL-9002)", () => {
865874
outWrites.push(String(chunk));
866875
return true;
867876
}) as typeof process.stdout.write);
877+
return {
878+
errWrites,
879+
outWrites,
880+
restore: () => {
881+
stderrSpy.mockRestore();
882+
stdoutSpy.mockRestore();
883+
},
884+
};
885+
};
886+
887+
test("headless gate deny names the action and remedy on stderr, stdout clean", async () => {
888+
const stdio = captureStdio();
868889
try {
869-
const outcome = await promptPermission(request, false);
870-
expect(outcome).toEqual({ allow: false });
890+
// Same shape as runner.ts: the denial surfaces on stderr, never stdout.
891+
const gate = headlessGate((reason) => {
892+
process.stderr.write(`Permission denied: ${reason}\n`);
893+
});
894+
const authorized = await gate.authorizeCall({
895+
id: "h1",
896+
name: "web_fetch",
897+
arguments: { url: "https://example.com/docs", format: "markdown" },
898+
} as ToolCall);
899+
expect(authorized.effect).toBe("deny");
900+
const evaluated = await gate.evaluate({
901+
id: "h2",
902+
name: "web_fetch",
903+
arguments: { url: "https://example.com/other", format: "markdown" },
904+
} as ToolCall);
905+
expect(evaluated.allowed).toBe(false);
871906
} finally {
872-
stderrSpy.mockRestore();
873-
stdoutSpy.mockRestore();
907+
stdio.restore();
874908
}
875-
const denial = errWrites.join("");
876-
expect(denial).toContain("Run shell");
909+
const denial = stdio.errWrites.join("");
910+
expect(denial).toContain("Permission denied:");
911+
expect(denial).toContain("requires operator approval");
877912
expect(denial).toContain("--dangerously-skip-permissions");
878-
expect(outWrites).toEqual([]);
913+
expect(stdio.outWrites).toEqual([]);
914+
});
915+
916+
test("secret-path headless deny names the action without bypass coaching", async () => {
917+
const stdio = captureStdio();
918+
try {
919+
const gate = headlessGate((reason) => {
920+
process.stderr.write(`Permission denied: ${reason}\n`);
921+
});
922+
const verdict = await gate.evaluate({
923+
id: "s1",
924+
name: "run_shell",
925+
arguments: { command: "cat .env" },
926+
} as ToolCall);
927+
expect(verdict.allowed).toBe(false);
928+
} finally {
929+
stdio.restore();
930+
}
931+
const denial = stdio.errWrites.join("");
932+
expect(denial).toContain("sensitive path");
933+
expect(denial).not.toContain("--dangerously-skip-permissions");
934+
expect(stdio.outWrites).toEqual([]);
935+
});
936+
937+
test("piped stdout with a stdin TTY keeps the ask advertised and round-trips", async () => {
938+
expect(resolveExecInteractive({ stdinTTY: true, stdoutTTY: false })).toBe(
939+
true,
940+
);
941+
const seen: string[] = [];
942+
const gate = createPermissionGate({
943+
approvals: [],
944+
requestApproval: async (request) => {
945+
seen.push(request.subject);
946+
return { allow: true };
947+
},
948+
interactive: true,
949+
skipPermissions: false,
950+
reactorGated: true,
951+
});
952+
const call = {
953+
id: "i1",
954+
name: "web_fetch",
955+
arguments: { url: "https://example.com/docs", format: "markdown" },
956+
} as ToolCall;
957+
const authorized = await gate.authorizeCall(call);
958+
expect(authorized.effect).toBe("ask");
959+
expect(await gate.evaluate(call)).toEqual({ allowed: true });
960+
expect(seen).toEqual(["https://example.com/docs"]);
879961
});
880962
});

‎src/exec/runner.ts‎

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
688688
model: config.model,
689689
telemetry: liveTelemetry,
690690
requestApproval: (request: PermissionRequest): Promise<ApprovalOutcome> =>
691-
promptPermission(request, interactive),
691+
promptPermission(request),
692692
getActiveProviderModel: () => `${config.providerName}:${config.model}`,
693693
onPersistNotice: (text) => {
694694
stderr.write(`${text}\n`);
@@ -697,6 +697,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
697697
stderr.write(`${text}\n`);
698698
},
699699
interactive,
700+
// Headless operator surface: the gate denies without an operator before
701+
// ever reaching requestApproval, so the explicit denial lives here —
702+
// naming the action and the remedy on stderr (never stdout: piped stdout
703+
// may feed JSON consumers). The reason already omits the bypass remedy
704+
// on sensitive-path denials; emit it verbatim.
705+
onHeadlessDeny: (reason) => {
706+
stderr.write(`Permission denied: ${reason}\n`);
707+
},
700708
skipPermissions: config.dangerouslySkipPermissions,
701709
auto: config.auto,
702710
// Main session: gating rides the reactor's approval-suspend seam. In
@@ -1415,7 +1423,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
14151423
* stdin and write stderr, so a piped stdout must not disable them: stdin TTY
14161424
* alone means an operator can answer. Fully headless (stdin not a TTY) fails
14171425
* closed — denials name the action and the remedy on stderr instead of
1418-
* prompting. stdoutTTY rides along so the call site stays explicit that a
1426+
* prompting (gate onHeadlessDeny wiring above, not the prompt seam: the gate
1427+
* denies headless asks before requestApproval is ever reached). stdoutTTY
1428+
* rides along so the call site stays explicit that a
14191429
* piped stdout is a supported prompting configuration, not an oversight.
14201430
*/
14211431
export function resolveExecInteractive(stdio: {
@@ -1450,22 +1460,9 @@ async function promptOperator(
14501460
}
14511461
}
14521462

1453-
export async function promptPermission(
1463+
async function promptPermission(
14541464
request: PermissionRequest,
1455-
interactive: boolean,
14561465
): Promise<ApprovalOutcome> {
1457-
if (!interactive) {
1458-
// Fail closed and say so on stderr (never stdout: piped stdout may feed
1459-
// JSON consumers — see printResumeHint). A headless ask has no operator
1460-
// to answer, so the denial names the action and the bypass instead of
1461-
// vanishing into a generic block. Same sentence as the gate's
1462-
// non-interactive deny reasons (src/permission/gate.ts) by intent; the
1463-
// gate stays canonical for model-facing text, this seam for the operator.
1464-
stderr.write(
1465-
`Permission denied: ${request.action} requires operator approval, which is unavailable in a non-interactive run. Re-run with --dangerously-skip-permissions to bypass, or narrow the action.\n`,
1466-
);
1467-
return { allow: false };
1468-
}
14691466
// Same freeze-while-deciding contract as the TUI gate: the tool wall-clock
14701467
// budget must not run down while the operator reads the prompt.
14711468
const budget = getToolApprovalBudget();

‎src/permission/gate.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,13 @@ export interface PermissionGateOptions {
395395
// No operator is attached (headless). An unresolved "ask" becomes a denial
396396
// unless skipPermissions is set.
397397
interactive: boolean;
398+
// Headless operator surface: fired with the deny reason when decide() denies
399+
// because no operator is attached (the !interactive exits above). Exec wires
400+
// it to stderr so the denial names the action and the remedy; gates without
401+
// an operator surface omit it and stay silent by construction. The reason
402+
// already carries the secret-path distinction (no bypass remedy on
403+
// sensitive-path denials), so callers emit it verbatim.
404+
onHeadlessDeny?: ((reason: string) => void) | undefined;
398405
// The --dangerously-skip-permissions escape hatch: auto-allow anything the
399406
// authorization layer did not already deny.
400407
skipPermissions: boolean;
@@ -1023,6 +1030,7 @@ export function createPermissionGate(
10231030
? `${request.action} references a sensitive path and requires operator approval, which is unavailable in a non-interactive run.`
10241031
: `${request.action} requires operator approval, which is unavailable in a non-interactive run. Re-run with --dangerously-skip-permissions to bypass, or narrow the action.`;
10251032
denialMemory.record(stableId, reason);
1033+
options.onHeadlessDeny?.(reason);
10261034
return { kind: "deny", reason };
10271035
}
10281036

@@ -1067,6 +1075,7 @@ export function createPermissionGate(
10671075
recordAutoDecision(request.tool, "non-interactive", "deny");
10681076
const reason = `${request.action} requires operator approval, which is unavailable in a non-interactive run. Re-run with --dangerously-skip-permissions to bypass, or narrow the action.`;
10691077
denialMemory.record(stableId, reason);
1078+
options.onHeadlessDeny?.(reason);
10701079
return { kind: "deny", reason };
10711080
}
10721081

‎src/session/assemble-runtime.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,12 @@ export interface SessionGateArgs {
265265
*/
266266
onPendingProjectGrants?: ((text: string) => void) | undefined;
267267
interactive: boolean;
268+
/**
269+
* Headless operator surface (see PermissionGateOptions.onHeadlessDeny):
270+
* fired with the deny reason when the gate denies for want of an operator.
271+
* Exec writes it to stderr; the TUI omits it.
272+
*/
273+
onHeadlessDeny?: ((reason: string) => void) | undefined;
268274
skipPermissions: boolean;
269275
auto?: boolean | undefined;
270276
/** Route this gate's decisions through the reactor authz seam (main session). */
@@ -308,6 +314,7 @@ export async function assembleSessionGate(
308314
),
309315
approvalLog: createApprovalLog(sessionDir(args.cwd, args.sessionId)),
310316
interactive: args.interactive,
317+
onHeadlessDeny: args.onHeadlessDeny,
311318
skipPermissions: args.skipPermissions,
312319
auto: args.auto,
313320
reactorGated: args.reactorGated,

0 commit comments

Comments
 (0)