Skip to content

Commit 20cff93

Browse files
committed
Fail closed stale ask accept as unavailable not reject
Overlay ask ids mint on the gate event at emit, not on PermissionRequest. Escape still denies. An empty or mismatched Enter cannot impersonate Reject.
1 parent 951d142 commit 20cff93

17 files changed

Lines changed: 329 additions & 188 deletions

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1616
### Fixed
1717

1818
- Sequential TUI ask and permission selectors paint the live question's option
19-
labels. Rows bind by ask and request id rather than render-order index, so a
20-
later question cannot keep the previous question's choices.
19+
labels. Overlay rows bind by an ask id minted at emit, not render-order
20+
index. A stale or empty accept fail-closes as unavailable rather than
21+
impersonating Reject; Escape still denies.
2122

2223
## [0.3.18] - 2026-09-08
2324

docs/TUI.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,13 @@ viewport kit: shared windowing, keep-active-visible, and page/jump behavior.
342342
There is exactly one scroll lease at a time; keyboard paging and the mouse
343343
wheel both follow whichever surface currently holds it, so a modal open on
344344
top of the transcript never lets the wheel move the transcript underneath it.
345-
Ask and permission rows bind by ask/request id, never render-order index;
346-
painted labels are the live payload, and stale rows whose ids are not in the
347-
new payload are dropped.
345+
Ask and permission rows are namespaced by the ask id minted when the overlay
346+
opens. Paint replaces the whole options array (labels and ids together);
347+
there is no drop-by-id merge. Enter binds by the painted id against the live
348+
bag — a painted value that is not in that bag, or Enter on an empty gate
349+
with no answer field, fail-closes the accept as unavailable rather than
350+
remapping by index or treating it as Reject. Escape still denies a
351+
permission and cancels an operator question through the dismiss path.
348352

349353
"Current" is never inferred. For the model picker, the row marked
350354
`(current)` is read live from the session's actual active provider/model on

src/permission/gate.test.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -236,39 +236,3 @@ describe("standing grant covers a later git worktree command (CL-5638)", () => {
236236
expect(prompts).toBe(1);
237237
});
238238
});
239-
240-
describe("ask decision mints request.id", () => {
241-
test("the copy handed to requestApproval has a non-empty id", async () => {
242-
let captured: PermissionRequest | undefined;
243-
const gate = createPermissionGate({
244-
approvals: [],
245-
interactive: true,
246-
skipPermissions: false,
247-
reactorGated: false,
248-
requestApproval: async (request) => {
249-
captured = request;
250-
return { allow: true };
251-
},
252-
});
253-
const verdict = await gate.evaluate(shellCall("npm test || true"));
254-
expect(verdict.allowed).toBe(true);
255-
expect(captured?.id).toEqual(expect.any(String));
256-
expect(captured?.id?.length).toBeGreaterThan(0);
257-
});
258-
259-
test("authorizeCall ask request carries a minted id", async () => {
260-
const gate = createPermissionGate({
261-
approvals: [],
262-
interactive: true,
263-
skipPermissions: false,
264-
reactorGated: false,
265-
requestApproval: async () => ({ allow: true }),
266-
});
267-
const result = await gate.authorizeCall(shellCall("npm test || true"));
268-
expect(result.effect).toBe("ask");
269-
if (result.effect !== "ask") throw new Error("expected ask");
270-
const id = result.request.id;
271-
expect(id).toEqual(expect.any(String));
272-
expect(id?.length).toBeGreaterThan(0);
273-
});
274-
});

src/permission/gate.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { ToolCall } from "@intx/types/runtime";
22
import { isAbsolute, resolve } from "node:path";
3-
import { randomUUID } from "node:crypto";
43
import type {
54
Approval,
65
ApprovalOutcome,
@@ -43,17 +42,25 @@ import { NOOP_APPROVAL_LOG, type ApprovalLog, type ApprovalOutcomeKind } from ".
4342
// Closes out an operator prompt: ends the wait span and records the outcome.
4443
// buildRequests yields at most one request per tool call, and the two prompt
4544
// sites below are mutually exclusive, so this runs once per prompt shown.
45+
function finishApprovalWait(
46+
telemetry: Telemetry,
47+
waitSpanId: string,
48+
tool: string,
49+
outcome: ApprovalOutcome | undefined,
50+
): void {
51+
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
52+
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
53+
telemetry.capture("permission_prompt", {
54+
decision,
55+
permission_kind: classifyPermissionKind(tool),
56+
});
57+
}
58+
4659
// Classifies a settled ApprovalOutcome into the approval-log taxonomy.
4760
// gate-wire.ts's timeout/abort auto-denies carry a fixed message text (see
4861
// autoDeny in gate-wire.ts and the timeout branch in tui/request-approval.ts's
4962
// finish() usage); anything else that denies is a plain operator/unavailable
5063
// decision.
51-
function withRequestId(request: PermissionRequest): PermissionRequest {
52-
return request.id !== undefined && request.id.length > 0
53-
? request
54-
: { ...request, id: randomUUID() };
55-
}
56-
5764
function classifyOutcome(outcome: ApprovalOutcome | undefined): ApprovalOutcomeKind {
5865
if (outcome === undefined) return "deny";
5966
if (!outcome.allow) {
@@ -65,20 +72,6 @@ function classifyOutcome(outcome: ApprovalOutcome | undefined): ApprovalOutcomeK
6572
return outcome.persist !== undefined ? "allow-with-scope" : "allow-once";
6673
}
6774

68-
function finishApprovalWait(
69-
telemetry: Telemetry,
70-
waitSpanId: string,
71-
tool: string,
72-
outcome: ApprovalOutcome | undefined,
73-
): void {
74-
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
75-
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
76-
telemetry.capture("permission_prompt", {
77-
decision,
78-
permission_kind: classifyPermissionKind(tool),
79-
});
80-
}
81-
8275
export type GateVerdict = { allowed: true } | { allowed: false; reason: string };
8376

8477
// One shell segment's forced-ask guard: a secret-path reference or a
@@ -653,7 +646,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
653646
const requestForOperator = anySecret ? { ...request, scopes: [] } : request;
654647
return {
655648
kind: "ask",
656-
request: withRequestId(requestForOperator),
649+
request: requestForOperator,
657650
anySecret,
658651
segmentCount: segments.length,
659652
};
@@ -681,7 +674,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
681674
};
682675
}
683676

684-
return { kind: "ask", request: withRequestId(request), anySecret: false, segmentCount: 0 };
677+
return { kind: "ask", request, anySecret: false, segmentCount: 0 };
685678
}
686679
return { kind: "allow" };
687680
};

src/permission/types.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,6 @@ export interface PermissionRequest {
5656
// withheld for a reason beyond the ordinary "no persistent option exists
5757
// yet" case. Plain literal text, never model-authored.
5858
notice?: string;
59-
// Set by the gate on the copy handed to requestApproval, never on
60-
// buildRequests matching/display copies. Overlay rows bind by this id
61-
// rather than render-order index (see gate-wire.ts).
62-
id?: string;
6359
// Set by the gate right before handing this request to requestApproval, so
6460
// whichever surface actually renders it (see gate-wire.ts's overlay host)
6561
// can report the moment it reached the operator's screen — distinct from

src/tui/decision-truncation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ const hintRequest: PermissionRequest = {
3333
hint: HINT,
3434
},
3535
],
36-
id: "req-1",
3736
};
3837

3938
function bodySelect(view: ReturnType<typeof createOverlayView>): SelectRenderable {
@@ -91,6 +90,7 @@ describe("decision choice rendering", () => {
9190
const emitter = new EventEmitter();
9291
const dispose = wireGates(emitter, shell);
9392
emitter.emit("permission.gate", {
93+
id: "req-1",
9494
request: hintRequest,
9595
resolve: () => {},
9696
});

src/tui/gate-events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { ApprovalOutcome, PermissionRequest } from "../permission/types.js";
22
import type { OperatorResult } from "../agent/tools.js";
33

4+
/** Fail-closed settle when no approval UI can bind the operator's accept. */
5+
export const APPROVAL_UNAVAILABLE_MESSAGE = "no approval UI available; request denied" as const;
6+
47
export interface OperatorGateEvent {
58
/** Minted by the session emitter, never by the TUI overlay. */
69
id: string;
@@ -23,6 +26,8 @@ export interface OperatorGateEvent {
2326
}
2427

2528
export interface PermissionGateEvent {
29+
/** Minted at overlay-open by the TUI emitter, never on PermissionRequest. */
30+
id: string;
2631
request: PermissionRequest;
2732
resolve: (outcome: ApprovalOutcome) => void;
2833
/**

0 commit comments

Comments
 (0)