Skip to content

Commit 6fad4cc

Browse files
committed
Drop model-authored agentLabel from the approval log (CL-5666 review fix)
A sub-agent's task-dispatch description is free text the model composes itself, only ever .trim()ed, never constrained to a closed set. It had been flowing verbatim into approvals.jsonl as agentLabel, which is on-by-default and append-forever -- unlike rule (a closed set) and segments (a count), nothing stops it from carrying a path, a token, or content the model just read. Dropped the field entirely: volume, rule mix, mode split, and timing all still work without it, and no concrete question needed a per-agent breakdown badly enough to justify the risk. Added a hard size cap on the serialized record (512 bytes) as defense in depth -- every real field is a fixed enum, a count, or a timestamp, so a well-formed line should never come close to it; an oversized line is dropped rather than truncated, so no partial secret survives. Added the missing test: a fake secret embedded in a sub-agent's dispatch description, driven through the gate, asserted absent from the serialized record; and a test that a rule field stuffed with 10k chars (simulating a future regression) is dropped by the cap rather than logged. Also corrected the doc comment's claim about rule provenance: alongside the existing auto-shell-policy.ts/classify.ts rule names, this file defines its own small set of fixed literals (auto-allowed-tool, non-interactive, mega-chain) for decisions those modules don't otherwise name -- still a closed set, just not literally reused from elsewhere.
1 parent b06a563 commit 6fad4cc

4 files changed

Lines changed: 93 additions & 30 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ tool call
374374
- **queue** — Headless settle registry (`src/permission/queue.ts`). Surfaces enqueue outstanding requests; `wirePermissionGrantReconciliation` listens for `permission.grant` and drains every queued request the new grant covers, without a second prompt. Teardown calls `drain()` so no awaited resolve is left hanging.
375375
- **types**`Approval`, `ApprovalScope`, `PermissionRequest`, `ApprovalOutcome`.
376376

377-
**Approval log** (`src/permission/approval-log.ts`, CL-5666): every consequential decision the gate makes — auto-mode allow/deny or an interactive prompt's allow-once/allow-with-scope/deny/timeout/abort — is appended as one JSONL record to `approvals.jsonl` in the session dir, carrying the classifier/auto-shell rule name that fired (reusing the existing rule taxonomy, not a new one), whether the decision was `auto` or `interactive`, a shell chain's segment count, and queued/displayed/settled timestamps. `displayedAt` is set by `PermissionRequest.markDisplayed`, called from `gate-wire.ts`'s `open()` the moment a request actually reaches the overlay host — distinct from when it was raised, so the gap it exposes is the CL-5664 signal (a queued gate arming its timeout before the operator could see it). No command text, file content, path, or credential is ever recorded — only tool name, rule, mode, segment count, and timing. Writes are fire-and-forget and swallow their own errors; the log defaults to a no-op so nothing depends on it being wired. `scripts/approval-forensics.ts` aggregates across local sessions the same way `intervention-forensics.ts` does for stop/nudge events: per-tool counts by outcome and mode, duration/display-delay percentiles, mega-chain counts, and a duplicate-rate proxy (sessions that hit the same rule more than once).
377+
**Approval log** (`src/permission/approval-log.ts`, CL-5666): every consequential decision the gate makes — auto-mode allow/deny or an interactive prompt's allow-once/allow-with-scope/deny/timeout/abort — is appended as one JSONL record to `approvals.jsonl` in the session dir, carrying the classifier/auto-shell rule name that fired (the existing `auto-shell-policy.ts`/`classify.ts` rule names, plus a small closed set of additional fixed literals the log itself defines for decisions those modules don't otherwise name — `auto-allowed-tool`, `non-interactive`, `mega-chain` — never model- or user-authored text), whether the decision was `auto` or `interactive`, a shell chain's segment count, and queued/displayed/settled timestamps. `displayedAt` is set by `PermissionRequest.markDisplayed`, called from `gate-wire.ts`'s `open()` the moment a request actually reaches the overlay host — distinct from when it was raised, so the gap it exposes is the CL-5664 signal (a queued gate arming its timeout before the operator could see it). No command text, file content, path, credential, or other free text is ever recorded — only tool name, rule, mode, segment count, and timing; a sub-agent's free-text dispatch label is deliberately left out, even though it would enable a per-agent breakdown, because nothing constrains what a model puts in it. A hard size cap on the serialized line is defense in depth against a future field reintroducing free text. Writes are fire-and-forget and swallow their own errors; the log defaults to a no-op so nothing depends on it being wired. `scripts/approval-forensics.ts` aggregates across local sessions the same way `intervention-forensics.ts` does for stop/nudge events: per-tool counts by outcome and mode, duration/display-delay percentiles, mega-chain counts, and a duplicate-rate proxy (sessions that hit the same rule more than once).
378378

379379
**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`). The watchdog arms only when Settings set `tools.timeoutMs` / `tools.maxTimeoutMs`, or when `run_shell` passes a positive timeout (requested plus slack, so this layer cannot beat shell-guard). The `task` tool is always exempt, regardless of Settings — a sub-agent run is bounded by its own limits (maxTurns, no-progress, thrash, opt-in deadlineMs), so the generic per-tool budget never aborts a healthy long-running worker; parent cancel, maxTurns, and eval `--agent-timeout-ms` still bound the run. By default (`tools.waitForApproval`, Settings → Tools, **On**), an armed budget freezes while the operator is deciding on a permission prompt, so a late approve still runs the tool and the agent waits for the decision instead of timing out under the modal. When **Off**, the budget keeps ticking during the prompt; if it expires first the tool is skipped and the permission modal is dismissed via the budget AbortSignal (auto-deny with a timeout message). The TUI permission queue (`src/tui/gate-wire.ts`, backed by `src/permission/queue.ts`) attaches that signal so ghost prompts cannot outlive an already-aborted tool.
380380

src/permission/approval-log.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { createPermissionGate } from "./gate.js";
88
import type { ToolCall } from "@intx/types/runtime";
99

1010
function readRecords(dir: string): Record<string, unknown>[] {
11-
const raw = readFileSync(join(dir, APPROVAL_LOG_FILE), "utf8");
11+
let raw: string;
12+
try {
13+
raw = readFileSync(join(dir, APPROVAL_LOG_FILE), "utf8");
14+
} catch {
15+
return [];
16+
}
1217
return raw
1318
.split("\n")
1419
.filter((l) => l.trim().length > 0)
@@ -143,4 +148,60 @@ describe("approval-log wiring through the permission gate", () => {
143148
expect(record!.outcome).toBe("deny");
144149
expect(record!.rule).toBe("non-interactive");
145150
});
151+
152+
// A sub-agent's `task` dispatch `description` is model-authored free text
153+
// (see task-tool.ts) — it is only ever trimmed, never constrained to a
154+
// closed set. A prior version of this log carried it verbatim as
155+
// `agentLabel`. It must never reach the record: unlike `rule` (a fixed
156+
// taxonomy) and `segments` (a count), nothing stops a model from quoting a
157+
// path, a token, or secret content it just read into its own summary of the
158+
// sub-task.
159+
test("never logs a sub-agent's free-text dispatch description, even with a secret embedded", async () => {
160+
const { runWithSubAgentIdentity } = await import("../subagent/identity-context.js");
161+
const dir = mkdtempSync(join(tmpdir(), "approval-log-gate-"));
162+
const cwd = mkdtempSync(join(tmpdir(), "gate-cwd-"));
163+
const gate = createPermissionGate({
164+
approvals: [],
165+
interactive: true,
166+
skipPermissions: false,
167+
cwd,
168+
approvalLog: createApprovalLog(dir),
169+
requestApproval: async (request) => {
170+
request.markDisplayed?.();
171+
return { allow: true };
172+
},
173+
});
174+
const secret = "sk-live-9f2c7a1e4b6d8f0a";
175+
const verdict = await runWithSubAgentIdentity(
176+
{ description: `fetch the token ${secret} from the vault and cache it`, cwd },
177+
() => gate.evaluate(shellCall("curl https://example.com")),
178+
);
179+
expect(verdict.allowed).toBe(true);
180+
181+
await new Promise((r) => setTimeout(r, 10));
182+
const [record] = readRecords(dir);
183+
expect(record).toBeDefined();
184+
expect(Object.keys(record!)).not.toContain("agentLabel");
185+
const serialized = JSON.stringify(record);
186+
expect(serialized).not.toContain(secret);
187+
expect(serialized).not.toContain("vault");
188+
});
189+
});
190+
191+
describe("approval-log record size cap", () => {
192+
test("drops a record that would exceed the hard size cap rather than truncate it", async () => {
193+
const dir = mkdtempSync(join(tmpdir(), "approval-log-cap-"));
194+
const log = createApprovalLog(dir);
195+
// `rule` is a real, typed field — simulate a future regression where some
196+
// caller stuffs unbounded text into it instead of the closed taxonomy.
197+
// The cap must catch that even though the type system would not.
198+
const ask = log.ask({
199+
tool: "run_shell",
200+
mode: "interactive",
201+
rule: "x".repeat(10_000),
202+
});
203+
ask.settle("allow-once");
204+
await new Promise((r) => setTimeout(r, 10));
205+
expect(readRecords(dir)).toHaveLength(0);
206+
});
146207
});

src/permission/approval-log.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@
1010
* classifier rule triggers them, or whether a prompt was even auto-allowed by
1111
* policy rather than shown to the operator (CL-5666).
1212
*
13-
* No command text, file content, path, or credential ever appears here — only
14-
* the tool name, the classifier/auto-shell rule that fired (reusing the rule
15-
* names already defined in auto-shell-policy.ts and classify.ts, not a new
16-
* taxonomy), a shell chain's segment count, and timing. Writes are
13+
* No command text, file content, path, credential, or any other
14+
* model-authored or user-authored free text ever appears here — only the tool
15+
* name (a fixed identifier), the classifier/auto-shell rule that fired
16+
* (reusing the rule names already defined in auto-shell-policy.ts and
17+
* classify.ts, plus a small closed set of additional literals this file
18+
* defines for decisions those modules don't otherwise name — never a new
19+
* taxonomy), a shell chain's segment count, and timing. Every field is either
20+
* a fixed enum, a count, or a timestamp; a sub-agent's free-text dispatch
21+
* label was deliberately left out even though it would enable a per-agent
22+
* breakdown, because nothing constrains what a model puts in it. Writes are
1723
* fire-and-forget and never throw: a diagnostic must not be able to fail a
18-
* run.
24+
* run. A hard size cap on the serialized line (see MAX_RECORD_BYTES) is
25+
* belt-and-suspenders insurance against a future field reintroducing free
26+
* text.
1927
*/
2028

2129
import { appendFile } from "node:fs/promises";
@@ -51,8 +59,6 @@ export interface ApprovalRecord {
5159
*/
5260
rule?: string;
5361
mode: ApprovalMode;
54-
/** The requesting sub-agent's label, when this request came from one. */
55-
agentLabel?: string;
5662
/** Real (non-comment) shell chain segment count, for run_shell requests. */
5763
segments?: number;
5864
outcome: ApprovalOutcomeKind;
@@ -77,10 +83,17 @@ export interface AskEvent {
7783
tool: string;
7884
rule?: string;
7985
mode: ApprovalMode;
80-
agentLabel?: string;
8186
segments?: number;
8287
}
8388

89+
// Belt-and-suspenders cap on the serialized record. Every field here is
90+
// either a fixed enum, a count, or a timestamp, so a well-formed line should
91+
// never come close to this — it exists only so a future field that
92+
// reintroduces free text (an agent label, a subject, a message) cannot grow
93+
// this file into a content leak; oversized lines are dropped, not truncated,
94+
// so no partial secret survives half-written.
95+
const MAX_RECORD_BYTES = 512;
96+
8497
/** Handle for one in-flight ask, returned by ApprovalLog.ask(). */
8598
export interface ApprovalAsk {
8699
readonly id: string;
@@ -118,6 +131,10 @@ export function createApprovalLog(dir: string, now: () => Date = () => new Date(
118131

119132
const append = (record: ApprovalRecord): void => {
120133
const line = `${JSON.stringify(record)}\n`;
134+
if (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES) {
135+
log.debug?.("approval log record dropped: exceeds max size");
136+
return;
137+
}
121138
tail = tail.then(
122139
() =>
123140
appendFile(path, line, "utf8").catch((err: unknown) => {
@@ -148,7 +165,6 @@ export function createApprovalLog(dir: string, now: () => Date = () => new Date(
148165
tool: event.tool,
149166
...(event.rule !== undefined ? { rule: event.rule } : {}),
150167
mode: event.mode,
151-
...(event.agentLabel !== undefined ? { agentLabel: event.agentLabel } : {}),
152168
...(event.segments !== undefined ? { segments: event.segments } : {}),
153169
outcome,
154170
queuedAt: queuedAt.toISOString(),

src/permission/gate.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -413,14 +413,12 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
413413
tool: string,
414414
rule: string | undefined,
415415
outcome: ApprovalOutcomeKind,
416-
agentLabel: string | undefined,
417416
): void => {
418417
approvalLog
419418
.ask({
420419
tool,
421420
mode: "auto",
422421
...(rule !== undefined ? { rule } : {}),
423-
...(agentLabel !== undefined ? { agentLabel } : {}),
424422
})
425423
.settle(outcome);
426424
};
@@ -494,20 +492,15 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
494492
// a secret path is ask so an explicit one-time approval can pass it.
495493
const shellRule = autoShellRuleForCall(call, isRestrictedHere, effectiveCwd, rootsProvider);
496494
if (shellRule?.effect === "deny") {
497-
recordAutoDecision(call.name, shellRule.name, "auto-deny", subAgentIdentity?.description);
495+
recordAutoDecision(call.name, shellRule.name, "auto-deny");
498496
return { allowed: false, reason: shellRule.reason };
499497
}
500498
if (shellRule === undefined) {
501-
recordAutoDecision(call.name, undefined, "auto-allow", subAgentIdentity?.description);
499+
recordAutoDecision(call.name, undefined, "auto-allow");
502500
return { allowed: true };
503501
}
504502
} else if (!restricted && AUTO_ALLOWED_TOOLS.has(call.name)) {
505-
recordAutoDecision(
506-
call.name,
507-
"auto-allowed-tool",
508-
"auto-allow",
509-
subAgentIdentity?.description,
510-
);
503+
recordAutoDecision(call.name, "auto-allowed-tool", "auto-allow");
511504
return { allowed: true };
512505
}
513506
// Any other tool in auto mode (MCP or unknown built-in) is not
@@ -617,12 +610,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
617610
const askRule = anySecret ? "sensitive-path" : isMegaChain ? "mega-chain" : undefined;
618611

619612
if (!interactive || requestApproval === undefined) {
620-
recordAutoDecision(
621-
request.tool,
622-
askRule ?? "non-interactive",
623-
"deny",
624-
request.agentLabel,
625-
);
613+
recordAutoDecision(request.tool, askRule ?? "non-interactive", "deny");
626614
return {
627615
allowed: false,
628616
reason: anySecret
@@ -638,7 +626,6 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
638626
tool: request.tool,
639627
mode: "interactive",
640628
...(askRule !== undefined ? { rule: askRule } : {}),
641-
...(request.agentLabel !== undefined ? { agentLabel: request.agentLabel } : {}),
642629
segments: segments.length,
643630
});
644631
requestForOperator.markDisplayed = ask.markDisplayed;
@@ -685,7 +672,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
685672
}
686673

687674
if (!interactive || requestApproval === undefined) {
688-
recordAutoDecision(request.tool, "non-interactive", "deny", request.agentLabel);
675+
recordAutoDecision(request.tool, "non-interactive", "deny");
689676
return {
690677
allowed: false,
691678
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.`,
@@ -695,7 +682,6 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
695682
const ask = approvalLog.ask({
696683
tool: request.tool,
697684
mode: "interactive",
698-
...(request.agentLabel !== undefined ? { agentLabel: request.agentLabel } : {}),
699685
});
700686
request.markDisplayed = ask.markDisplayed;
701687
const turnId = currentTurnId();

0 commit comments

Comments
 (0)