@lucascouts/claude-agent-tui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +191 -0
  2. package/NOTICE +14 -0
  3. package/README.md +50 -0
  4. package/dist/acp-agent.d.ts +594 -0
  5. package/dist/acp-agent.d.ts.map +1 -0
  6. package/dist/acp-agent.js +2139 -0
  7. package/dist/ansi-mirror.d.ts +42 -0
  8. package/dist/ansi-mirror.d.ts.map +1 -0
  9. package/dist/ansi-mirror.js +61 -0
  10. package/dist/besteffort.d.ts +44 -0
  11. package/dist/besteffort.d.ts.map +1 -0
  12. package/dist/besteffort.js +100 -0
  13. package/dist/billing/entrypoint-guard.d.ts +97 -0
  14. package/dist/billing/entrypoint-guard.d.ts.map +1 -0
  15. package/dist/billing/entrypoint-guard.js +166 -0
  16. package/dist/claude-path.d.ts +12 -0
  17. package/dist/claude-path.d.ts.map +1 -0
  18. package/dist/claude-path.js +61 -0
  19. package/dist/diff-enriched-reader.d.ts +41 -0
  20. package/dist/diff-enriched-reader.d.ts.map +1 -0
  21. package/dist/diff-enriched-reader.js +106 -0
  22. package/dist/diff-source.d.ts +104 -0
  23. package/dist/diff-source.d.ts.map +1 -0
  24. package/dist/diff-source.js +164 -0
  25. package/dist/end-of-turn.d.ts +172 -0
  26. package/dist/end-of-turn.d.ts.map +1 -0
  27. package/dist/end-of-turn.js +415 -0
  28. package/dist/engine-lifecycle.d.ts +222 -0
  29. package/dist/engine-lifecycle.d.ts.map +1 -0
  30. package/dist/engine-lifecycle.js +236 -0
  31. package/dist/engine-pty.d.ts +143 -0
  32. package/dist/engine-pty.d.ts.map +1 -0
  33. package/dist/engine-pty.js +222 -0
  34. package/dist/engine-watcher.d.ts +83 -0
  35. package/dist/engine-watcher.d.ts.map +1 -0
  36. package/dist/engine-watcher.js +173 -0
  37. package/dist/engine.d.ts +30 -0
  38. package/dist/engine.d.ts.map +1 -0
  39. package/dist/engine.js +34 -0
  40. package/dist/event-switch.d.ts +164 -0
  41. package/dist/event-switch.d.ts.map +1 -0
  42. package/dist/event-switch.js +206 -0
  43. package/dist/gate/port.d.ts +38 -0
  44. package/dist/gate/port.d.ts.map +1 -0
  45. package/dist/gate/port.js +126 -0
  46. package/dist/gate/settings-writer.d.ts +130 -0
  47. package/dist/gate/settings-writer.d.ts.map +1 -0
  48. package/dist/gate/settings-writer.js +349 -0
  49. package/dist/index.d.ts +3 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +106 -0
  52. package/dist/jsonl.d.ts +267 -0
  53. package/dist/jsonl.d.ts.map +1 -0
  54. package/dist/jsonl.js +527 -0
  55. package/dist/lib.d.ts +6 -0
  56. package/dist/lib.d.ts.map +1 -0
  57. package/dist/lib.js +5 -0
  58. package/dist/linearize.d.ts +219 -0
  59. package/dist/linearize.d.ts.map +1 -0
  60. package/dist/linearize.js +444 -0
  61. package/dist/live-diff-env.d.ts +7 -0
  62. package/dist/live-diff-env.d.ts.map +1 -0
  63. package/dist/live-diff-env.js +18 -0
  64. package/dist/live-subagent-env.d.ts +7 -0
  65. package/dist/live-subagent-env.d.ts.map +1 -0
  66. package/dist/live-subagent-env.js +19 -0
  67. package/dist/permissions/allow-inject.d.ts +67 -0
  68. package/dist/permissions/allow-inject.d.ts.map +1 -0
  69. package/dist/permissions/allow-inject.js +85 -0
  70. package/dist/permissions/deny.d.ts +60 -0
  71. package/dist/permissions/deny.d.ts.map +1 -0
  72. package/dist/permissions/deny.js +81 -0
  73. package/dist/permissions/gate-wiring.d.ts +112 -0
  74. package/dist/permissions/gate-wiring.d.ts.map +1 -0
  75. package/dist/permissions/gate-wiring.js +350 -0
  76. package/dist/permissions/hook-server.d.ts +72 -0
  77. package/dist/permissions/hook-server.d.ts.map +1 -0
  78. package/dist/permissions/hook-server.js +179 -0
  79. package/dist/permissions/permission-mode.d.ts +67 -0
  80. package/dist/permissions/permission-mode.d.ts.map +1 -0
  81. package/dist/permissions/permission-mode.js +100 -0
  82. package/dist/permissions/request-permission.d.ts +102 -0
  83. package/dist/permissions/request-permission.d.ts.map +1 -0
  84. package/dist/permissions/request-permission.js +124 -0
  85. package/dist/settings.d.ts +68 -0
  86. package/dist/settings.d.ts.map +1 -0
  87. package/dist/settings.js +182 -0
  88. package/dist/stop-reason-map.d.ts +17 -0
  89. package/dist/stop-reason-map.d.ts.map +1 -0
  90. package/dist/stop-reason-map.js +33 -0
  91. package/dist/subagent-source.d.ts +63 -0
  92. package/dist/subagent-source.d.ts.map +1 -0
  93. package/dist/subagent-source.js +132 -0
  94. package/dist/subagent-watcher.d.ts +40 -0
  95. package/dist/subagent-watcher.d.ts.map +1 -0
  96. package/dist/subagent-watcher.js +108 -0
  97. package/dist/tools.d.ts +119 -0
  98. package/dist/tools.d.ts.map +1 -0
  99. package/dist/tools.js +729 -0
  100. package/dist/usage-env.d.ts +7 -0
  101. package/dist/usage-env.d.ts.map +1 -0
  102. package/dist/usage-env.js +16 -0
  103. package/dist/usage.d.ts +54 -0
  104. package/dist/usage.d.ts.map +1 -0
  105. package/dist/usage.js +53 -0
  106. package/dist/utils.d.ts +16 -0
  107. package/dist/utils.d.ts.map +1 -0
  108. package/dist/utils.js +83 -0
  109. package/dist/zed-register.d.ts +26 -0
  110. package/dist/zed-register.d.ts.map +1 -0
  111. package/dist/zed-register.js +106 -0
  112. package/package.json +79 -0
@@ -0,0 +1,350 @@
1
+ // Story 034 (§9 / R3.3) — runtime wiring of the HYBRID permission gate into the real session path.
2
+ //
3
+ // Stories 032/033 delivered the gate as STANDALONE, offline-tested seams: the verified-free loopback
4
+ // port (gate/port.ts), the non-destructive `--settings` hook writer (gate/settings-writer.ts), the
5
+ // fail-closed `PreToolUse` http hook server (hook-server.ts), the `tool_use.id` correlation + ACP
6
+ // `session/request_permission` bridge (request-permission.ts), and the #52822 allow keystroke
7
+ // mitigation (allow-inject.ts). THIS module is the missing glue: one {@link SessionGate} PER SESSION
8
+ // that composes those REAL units (no re-implementation) so `createSession` can:
9
+ //
10
+ // 1. allocate a free loopback port (`findFreePort`) and start the hook server on it;
11
+ // 2. write the per-session SCRATCH settings file carrying the hook (`injectHook`) — NEVER the
12
+ // user's `~/.claude/settings*.json` and NEVER the project `settings.local.json`; the scratch
13
+ // lives in `os.tmpdir()` and is handed to the spawn as `--settings "<file>"` (GATE_FINDINGS
14
+ // blocker c: settings MUST be on disk BEFORE the spawn — claude reads them only at startup);
15
+ // 3. decide each `PreToolUse` via the REAL story-033 decider chain: correlate by `tool_use.id`
16
+ // against the JSONL (fed by the pump through {@link SessionGate.correlator}) → raise ACP
17
+ // `session/request_permission` in Zed → enforce (`deny` body intercepts BEFORE the tool runs);
18
+ // 4. on `allow`, run the #52822 sweep: if the native TUI prompt still renders, inject the `'1\r'`
19
+ // keystroke RAW into the PTY (allow-inject — deliberately NOT `sendPrompt`, whose leading
20
+ // Ctrl+U clear byte must never touch a pending native dialog); on a stuck prompt WARN and hold;
21
+ // 5. tear everything down (close the server, `restore` the scratch) on `teardownSession` AND on
22
+ // PTY exit — idempotently, leaking no port, server, or scratch file.
23
+ //
24
+ // FAIL CLOSED (PERMISSIONS.md §6): every uncertain path in the chain below already resolves to deny
25
+ // (malformed payload / no decider / decider timeout → hook-server; missing/duplicate id, ACP
26
+ // transport error, cancelled outcome → request-permission). This module adds two more: a missing
27
+ // session binding (no ACP sessionId to prompt with) denies, and a correlation that never appears
28
+ // within the bounded wait lets request-permission deny. Nothing here ever approves silently.
29
+ //
30
+ // OFFLINE: this module spawns NO claude and bills nothing; the http server binds 127.0.0.1 only.
31
+ import * as os from "node:os";
32
+ import * as path from "node:path";
33
+ import { randomUUID } from "node:crypto";
34
+ import { findFreePort } from "../gate/port.js";
35
+ import { injectHook, restore } from "../gate/settings-writer.js";
36
+ import { startHookServer, } from "./hook-server.js";
37
+ import { requestPermission, ToolUseCorrelator, } from "./request-permission.js";
38
+ import { clearNativePrompt } from "./allow-inject.js";
39
+ /**
40
+ * Substrings that evidence the native TUI permission prompt (the bordered "Do you want to
41
+ * proceed?" box with numbered options), matched case-sensitively against the ANSI-stripped recent
42
+ * PTY output. COPIED VERBATIM from the Degrau-0 billed probe (`experiments/e-gate.ts`
43
+ * `NATIVE_PERMISSION_PROMPT_MARKERS`, claude 2.1.161) — the only empirical characterization of the
44
+ * prompt's rendering we have. ANY hit ⇒ the native prompt is showing (#52822 reproduced).
45
+ */
46
+ export const NATIVE_PERMISSION_PROMPT_MARKERS = [
47
+ "Do you want to proceed",
48
+ "Do you want to allow",
49
+ "Yes, and don't ask again",
50
+ "Yes, allow",
51
+ "1. Yes",
52
+ "No, and tell Claude",
53
+ ];
54
+ /** Strip CSI / common ANSI escape sequences so prompt markers match the plain text (e-gate probe). */
55
+ function stripAnsiText(s) {
56
+ // eslint-disable-next-line no-control-regex
57
+ return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
58
+ }
59
+ /** True iff any native-prompt marker appears in `text` (after ANSI stripping). */
60
+ export function textShowsNativePrompt(text) {
61
+ const stripped = stripAnsiText(text);
62
+ return NATIVE_PERMISSION_PROMPT_MARKERS.some((m) => stripped.includes(m));
63
+ }
64
+ /** Default bounded wait for the JSONL `tool_use` correlation to land after a hook fires (ms).
65
+ * The hook fires AFTER claude appended the assistant `tool_use` line, but the fs-watch → pump
66
+ * re-read is asynchronous — this window absorbs that lag. On expiry the decider proceeds and
67
+ * request-permission fails closed (deny) on the still-missing correlation. */
68
+ export const DEFAULT_CORRELATION_WAIT_MS = 5000;
69
+ /** Default poll interval for the correlation wait (ms). */
70
+ export const DEFAULT_CORRELATION_POLL_MS = 50;
71
+ /** Default window for the native prompt to APPEAR after an allow decision (#52822 sweep, ms).
72
+ * If no marker renders within it, allow-suppression held (the 2.1.161 case) — nothing to clear. */
73
+ export const DEFAULT_PROMPT_APPEAR_MS = 1500;
74
+ /** Default poll interval for the sweep's appear/clear phases (ms). */
75
+ export const DEFAULT_PROMPT_POLL_MS = 100;
76
+ /** Default budget for the keystroke to clear the prompt before the stuck warning (ms). */
77
+ export const DEFAULT_INJECT_TIMEOUT_MS = 2000;
78
+ /** Default bounded wait for the hook server to close on teardown (ms) — teardown never hangs on a
79
+ * socket held open by an in-flight decider; on expiry it warns and proceeds (the process-level
80
+ * close still completes in the background). */
81
+ export const DEFAULT_CLOSE_TIMEOUT_MS = 2000;
82
+ /** Cap on the retained rolling tail of recent PTY output (chars) for the prompt probe. */
83
+ const OUTPUT_TAIL_CAP = 16384;
84
+ /** Filename prefix of the per-session scratch settings file (diagnosable in `ls /tmp`). */
85
+ export const SCRATCH_SETTINGS_PREFIX = "fork-acp-gate-settings-";
86
+ const defaultSchedule = (fn, ms) => {
87
+ setTimeout(fn, ms);
88
+ };
89
+ /** Internal mutable state of one session's gate. */
90
+ class SessionGateImpl {
91
+ constructor(opts) {
92
+ this.opts = opts;
93
+ this.port = 0;
94
+ this.settingsPath = "";
95
+ this.correlator = new ToolUseCorrelator();
96
+ /** Rolling tail of recent PTY output + absolute count of chars ever appended (probe offsets). */
97
+ this.outputTail = "";
98
+ this.totalOutput = 0;
99
+ this.torndown = false;
100
+ }
101
+ get isTorndown() {
102
+ return this.torndown;
103
+ }
104
+ warn(message) {
105
+ this.opts.onWarn?.(message);
106
+ }
107
+ get schedule() {
108
+ return this.opts.schedule ?? defaultSchedule;
109
+ }
110
+ /** Start the hook server, then write the scratch settings (server first, so the URL the settings
111
+ * point at is live before claude can ever read them; settings BEFORE the spawn is the caller's
112
+ * ordering contract — blocker c). */
113
+ async start() {
114
+ const findPort = this.opts.findPort ?? findFreePort;
115
+ this.port = await findPort();
116
+ this.server = await startHookServer({
117
+ port: this.port,
118
+ deciderTimeoutMs: this.opts.deciderTimeoutMs,
119
+ onWarn: (m) => this.warn(m),
120
+ onToolCall: (call) => this.decide(call),
121
+ });
122
+ const dir = this.opts.settingsDir ?? os.tmpdir();
123
+ this.settingsPath = path.join(dir, `${SCRATCH_SETTINGS_PREFIX}${randomUUID()}.json`);
124
+ try {
125
+ this.backup = await injectHook({
126
+ settingsPath: this.settingsPath,
127
+ port: this.port,
128
+ timeout: this.opts.hookTimeoutSeconds,
129
+ });
130
+ }
131
+ catch (err) {
132
+ // The settings write failed AFTER the server bound — close it before surfacing, so a failed
133
+ // gate setup leaks nothing (the caller aborts createSession; FORK_GATE=off is the escape hatch).
134
+ await this.closeServerBounded();
135
+ throw err;
136
+ }
137
+ }
138
+ bindSession(sessionId, nudge) {
139
+ this.sessionId = sessionId;
140
+ this.nudge = nudge;
141
+ }
142
+ bindPty(pty) {
143
+ this.pty = pty;
144
+ if (typeof pty.onData === "function") {
145
+ this.outputTap = pty.onData((data) => {
146
+ this.totalOutput += data.length;
147
+ this.outputTail = (this.outputTail + data).slice(-OUTPUT_TAIL_CAP);
148
+ });
149
+ }
150
+ }
151
+ /** Bytes received after the absolute output offset `mark` (clipped to the retained tail). */
152
+ outputSince(mark) {
153
+ const available = Math.min(this.outputTail.length, Math.max(0, this.totalOutput - mark));
154
+ return available === 0 ? "" : this.outputTail.slice(-available);
155
+ }
156
+ /**
157
+ * The REAL decider chain (the hook→ACP connection): nudge the pump → bounded-wait for the JSONL
158
+ * `tool_use.id` correlation → raise ACP `session/request_permission` (story 033, fail-closed) →
159
+ * on `allow`, arm the #52822 native-prompt sweep. Every deny path is request-permission's own.
160
+ */
161
+ async decide(call) {
162
+ if (this.torndown)
163
+ return "deny"; // a hook racing teardown is never approved
164
+ // Kick the pump so the freshest JSONL (carrying this call's `tool_use` line) is re-read NOW.
165
+ try {
166
+ this.nudge?.();
167
+ }
168
+ catch {
169
+ // a nudge failure only widens the correlation wait below; never approves/denies by itself
170
+ }
171
+ const sessionId = this.sessionId ?? call.sessionId;
172
+ if (!sessionId) {
173
+ this.warn(`[gate §9] FAIL CLOSED: PreToolUse for tool_use ${call.toolUseId} arrived before the gate ` +
174
+ `was bound to an ACP session — denying (cannot raise session/request_permission).`);
175
+ return "deny";
176
+ }
177
+ await this.waitForCorrelation(call.toolUseId);
178
+ const decision = await requestPermission({
179
+ client: this.opts.client,
180
+ sessionId,
181
+ toolCall: {
182
+ toolUseId: call.toolUseId,
183
+ toolName: call.toolName,
184
+ toolInput: call.toolInput,
185
+ },
186
+ correlator: this.correlator,
187
+ onWarn: (m) => this.warn(m),
188
+ });
189
+ if (decision === "allow") {
190
+ // Return the allow body FIRST (claude is blocked on this response); sweep out of band.
191
+ this.armAllowSweep(call);
192
+ }
193
+ return decision;
194
+ }
195
+ /** Bounded poll until the pump has registered `toolUseId` as a clean single JSONL match. On
196
+ * expiry, resolve anyway — `requestPermission` then fails closed on the missing correlation. */
197
+ waitForCorrelation(toolUseId) {
198
+ const waitMs = this.opts.correlationWaitMs ?? DEFAULT_CORRELATION_WAIT_MS;
199
+ const pollMs = this.opts.correlationPollMs ?? DEFAULT_CORRELATION_POLL_MS;
200
+ if (this.correlator.isCleanMatch(toolUseId))
201
+ return Promise.resolve();
202
+ return new Promise((resolve) => {
203
+ let elapsed = 0;
204
+ const poll = () => {
205
+ if (this.torndown || this.correlator.isCleanMatch(toolUseId)) {
206
+ resolve();
207
+ return;
208
+ }
209
+ elapsed += pollMs;
210
+ if (elapsed >= waitMs) {
211
+ this.warn(`[gate §9] correlation wait expired (${waitMs}ms) for tool_use ${toolUseId} — the JSONL ` +
212
+ `tool_use line never reached the pump; the decision will fail closed (deny).`);
213
+ resolve();
214
+ return;
215
+ }
216
+ this.schedule(poll, pollMs);
217
+ };
218
+ this.schedule(poll, pollMs);
219
+ });
220
+ }
221
+ /**
222
+ * #52822 sweep (allow path, best-effort by design): wait a bounded window for the native TUI
223
+ * prompt to APPEAR in the post-decision PTY output; if it never renders, allow-suppression held
224
+ * (the 2.1.161 case) and nothing is typed. If it renders, clear it via the story-033
225
+ * `clearNativePrompt` — the `'1\r'` keystroke goes RAW through `pty.write` (never `sendPrompt`,
226
+ * whose leading Ctrl+U clear byte must not touch a pending dialog). On a stuck prompt it WARNS
227
+ * and holds — never a silent approve (R3.2).
228
+ *
229
+ * Post-keystroke probe semantics (honest limits): the prompt counts as CLEARED only when NEW
230
+ * output arrives after the keystroke and carries no prompt marker (the TUI re-rendered past the
231
+ * dialog). Zero new output ⇒ still shown ⇒ the stuck warning fires at the timeout. A re-rendered
232
+ * marker ⇒ still shown. This is a byte-stream heuristic over the Degrau-0 markers — the live
233
+ * fidelity check is story 034's acceptance run.
234
+ */
235
+ armAllowSweep(call) {
236
+ const pty = this.pty;
237
+ if (!pty) {
238
+ this.warn(`[gate §9] allow for tool_use ${call.toolUseId}: no PTY bound — cannot run the #52822 ` +
239
+ `keystroke sweep; if the native prompt appears it needs a manual keystroke.`);
240
+ return;
241
+ }
242
+ const appearMs = this.opts.promptAppearMs ?? DEFAULT_PROMPT_APPEAR_MS;
243
+ const pollMs = this.opts.promptPollMs ?? DEFAULT_PROMPT_POLL_MS;
244
+ const appearMark = this.totalOutput; // only output AFTER the allow decision is scanned
245
+ let elapsed = 0;
246
+ const pollAppear = () => {
247
+ if (this.torndown)
248
+ return;
249
+ if (textShowsNativePrompt(this.outputSince(appearMark))) {
250
+ void this.runClear(call, pty);
251
+ return;
252
+ }
253
+ elapsed += pollMs;
254
+ if (elapsed >= appearMs)
255
+ return; // suppressed (the 2.1.161 case) — nothing to clear
256
+ this.schedule(pollAppear, pollMs);
257
+ };
258
+ this.schedule(pollAppear, pollMs);
259
+ }
260
+ /** Drive the story-033 keystroke injection against the live output probe (see armAllowSweep). */
261
+ async runClear(call, pty) {
262
+ let keystrokeMark = null;
263
+ const isPromptShown = () => {
264
+ if (keystrokeMark === null) {
265
+ // First probe (pre-keystroke): the appear-poll already proved the prompt is up. Mark the
266
+ // output offset so the post-keystroke probes scan only the TUI's REACTION to the keystroke.
267
+ keystrokeMark = this.totalOutput;
268
+ return true;
269
+ }
270
+ if (this.totalOutput === keystrokeMark)
271
+ return true; // TUI has not reacted yet — still shown
272
+ return textShowsNativePrompt(this.outputSince(keystrokeMark));
273
+ };
274
+ const result = await clearNativePrompt({
275
+ pty,
276
+ decision: "allow",
277
+ isPromptShown,
278
+ timeoutMs: this.opts.injectTimeoutMs ?? DEFAULT_INJECT_TIMEOUT_MS,
279
+ pollMs: this.opts.promptPollMs ?? DEFAULT_PROMPT_POLL_MS,
280
+ schedule: this.schedule,
281
+ onWarn: (m) => this.warn(m),
282
+ });
283
+ if (result.status === "stuck") {
284
+ // clearNativePrompt already warned; name the tool here for the session log's correlation.
285
+ this.warn(`[gate §9] STUCK PROMPT for tool_use ${call.toolUseId} (tool "${call.toolName}") — the ` +
286
+ `allow keystroke did not clear the native prompt; holding (no silent approve).`);
287
+ }
288
+ }
289
+ /** Close the hook server with a bounded wait so teardown never hangs on an in-flight request. */
290
+ async closeServerBounded() {
291
+ const server = this.server;
292
+ this.server = undefined;
293
+ if (!server)
294
+ return;
295
+ const closeTimeoutMs = this.opts.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
296
+ let timedOut = false;
297
+ await Promise.race([
298
+ server.close(),
299
+ new Promise((resolve) => this.schedule(() => {
300
+ timedOut = true;
301
+ resolve();
302
+ }, closeTimeoutMs)),
303
+ ]);
304
+ if (timedOut) {
305
+ this.warn(`[gate §9] hook server on 127.0.0.1:${this.port} did not close within ${closeTimeoutMs}ms ` +
306
+ `(an in-flight decider may be holding a request open); teardown proceeds — the close ` +
307
+ `completes in the background.`);
308
+ }
309
+ }
310
+ async teardown() {
311
+ if (this.torndown)
312
+ return;
313
+ this.torndown = true;
314
+ this.outputTap?.dispose();
315
+ this.outputTap = undefined;
316
+ await this.closeServerBounded();
317
+ const backup = this.backup;
318
+ this.backup = undefined;
319
+ if (backup) {
320
+ try {
321
+ await restore(backup); // scratch was created by injectHook (existed:false) → deleted here
322
+ }
323
+ catch (err) {
324
+ this.warn(`[gate §9] scratch settings restore failed for ${backup.settingsPath} ` +
325
+ `(${err instanceof Error ? err.message : String(err)}) — the file may need manual removal.`);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ /**
331
+ * Set up the per-session HYBRID gate runtime (story 034 wiring): allocate a verified-free loopback
332
+ * port, start the fail-closed `PreToolUse` hook server with the REAL story-033 decider chain, and
333
+ * write the per-session scratch settings file the spawn consumes via `--settings "<file>"`.
334
+ *
335
+ * ORDERING CONTRACT (GATE_FINDINGS blocker c): the caller MUST `await setupSessionGate(...)` and
336
+ * pass {@link SessionGate.settingsPath} to the spawn BEFORE the claude PTY is spawned — claude reads
337
+ * settings only at startup, so a late write misses the first tool call. After the spawn the caller
338
+ * binds the authoritative session id ({@link SessionGate.bindSession}) and the live PTY
339
+ * ({@link SessionGate.bindPty}), and disposes via {@link SessionGate.teardown} on session teardown
340
+ * AND PTY exit (idempotent).
341
+ *
342
+ * On a setup failure (port exhaustion, bind error, settings write error) the promise REJECTS with
343
+ * everything already cleaned up — the caller fails the session creation LOUDLY rather than spawning
344
+ * an ungated claude that LOOKS gated (the blocker-b hazard). `FORK_GATE=off` is the escape hatch.
345
+ */
346
+ export async function setupSessionGate(opts) {
347
+ const gate = new SessionGateImpl(opts);
348
+ await gate.start();
349
+ return gate;
350
+ }
@@ -0,0 +1,72 @@
1
+ import { type Server } from "node:http";
2
+ /** The §9 `PreToolUse` http payload shape (GATE_FINDINGS keystone 1.3). All fields optional on parse
3
+ * so a malformed body fails closed rather than throwing — a missing tool_use_id is itself a deny. */
4
+ export interface PreToolUsePayload {
5
+ session_id?: string;
6
+ transcript_path?: string;
7
+ cwd?: string;
8
+ permission_mode?: string;
9
+ hook_event_name?: string;
10
+ tool_name?: string;
11
+ tool_input?: unknown;
12
+ tool_use_id?: string;
13
+ }
14
+ /** The normalized tool call forwarded to the decider (camelCase, the shape request-permission uses). */
15
+ export interface ForwardedToolCall {
16
+ toolName: string;
17
+ toolInput: unknown;
18
+ toolUseId: string;
19
+ sessionId?: string;
20
+ permissionMode?: string;
21
+ }
22
+ /** The decider the server forwards each tool call to; returns the enforced `'allow'`/`'deny'`. */
23
+ export type ToolCallDecider = (call: ForwardedToolCall) => Promise<"allow" | "deny"> | "allow" | "deny";
24
+ /** The running hook server handle. */
25
+ export interface HookServer {
26
+ /** The loopback port the server bound (feeds the story-032 hook URL). */
27
+ port: number;
28
+ /** Register/replace the decider invoked per PreToolUse call. */
29
+ onToolCall(decider: ToolCallDecider): void;
30
+ /** Idempotently close the server. */
31
+ close(): Promise<void>;
32
+ }
33
+ /** Options for {@link startHookServer}. */
34
+ export interface StartHookServerOptions {
35
+ /** The verified-free loopback port (story-032 `findFreePort`). */
36
+ port: number;
37
+ /** Initial decider (may also be set later via {@link HookServer.onToolCall}). */
38
+ onToolCall?: ToolCallDecider;
39
+ /**
40
+ * Max ms to await the decider before failing closed (deny). DISTINCT from the 5583 ms turn watchdog
41
+ * (story 024) and the claude hook timeout (story 032, 600 s) — this is the server's own
42
+ * never-hang-the-request budget. Defaults to a conservative value.
43
+ */
44
+ deciderTimeoutMs?: number;
45
+ /** Injectable http-server factory (defaults to `node:http`'s `createServer`); for tests. */
46
+ createHttpServer?: () => Server;
47
+ /** Optional diagnostics sink for fail-closed events (defaults to no-op). */
48
+ onWarn?: (message: string) => void;
49
+ }
50
+ /** Default decider timeout (ms) — long enough for an async Zed decision relay, short of a hang. */
51
+ export declare const DEFAULT_DECIDER_TIMEOUT_MS = 600000;
52
+ /**
53
+ * Parse a PreToolUse payload from raw JSON, normalizing to {@link ForwardedToolCall}. Returns null when
54
+ * the body is unparseable OR lacks a `tool_use_id`/`tool_name` — the caller fails closed on null (a
55
+ * call we cannot identify is denied, never approved).
56
+ */
57
+ export declare function parsePayload(raw: string): ForwardedToolCall | null;
58
+ /**
59
+ * Bind a loopback `PreToolUse` http hook server on `port` and forward each tool call to the decider,
60
+ * FAILING CLOSED on every error path (R2.1, R2.4).
61
+ *
62
+ * The server binds {@link LOOPBACK_HOST} (127.0.0.1) ONLY — never `0.0.0.0` — matching the blocker-b
63
+ * constraint that the transport is loopback TCP. Each request body is parsed by {@link parsePayload};
64
+ * a malformed/identity-less payload, a decider timeout, or a decider throw all write a
65
+ * `permissionDecision:'deny'` body so the tool never runs ungated. A clean `'allow'` writes the allow
66
+ * body (the keystroke mitigation for #52822 is a separate allow-inject.ts concern).
67
+ *
68
+ * @returns a {@link HookServer} once the socket is bound (rejects on a bind error — the spawn path then
69
+ * fails fast rather than spawning claude with an ungated hook URL).
70
+ */
71
+ export declare function startHookServer(opts: StartHookServerOptions): Promise<HookServer>;
72
+ //# sourceMappingURL=hook-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-server.d.ts","sourceRoot":"","sources":["../../src/permissions/hook-server.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAC;AAIjG;sGACsG;AACtG,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wGAAwG;AACxG,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,kGAAkG;AAClG,MAAM,MAAM,eAAe,GAAG,CAAC,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,GAAG,OAAO,GAAG,MAAM,CAAC;AAExG,sCAAsC;AACtC,MAAM,WAAW,UAAU;IACzB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,UAAU,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,CAAC;IAC3C,qCAAqC;IACrC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,2CAA2C;AAC3C,MAAM,WAAW,sBAAsB;IACrC,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,UAAU,CAAC,EAAE,eAAe,CAAC;IAC7B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4FAA4F;IAC5F,gBAAgB,CAAC,EAAE,MAAM,MAAM,CAAC;IAChC,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAED,mGAAmG;AACnG,eAAO,MAAM,0BAA0B,SAAU,CAAC;AAsBlD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAyBlE;AAiCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,UAAU,CAAC,CAkFjF"}
@@ -0,0 +1,179 @@
1
+ // Story 033 / Task 2.1 — loopback PreToolUse http hook server that forwards tool calls over IPC and
2
+ // FAILS CLOSED on any transport/timeout error (R2.1, R2.4).
3
+ //
4
+ // BINDING Degrau-0 constraint (experiments/GATE_FINDINGS.md blocker b): the `PreToolUse` type:http
5
+ // hook is TCP-LOOPBACK ONLY — a unix-socket URL form is silently ignored AND the tool runs ungated. So
6
+ // this server MUST bind `127.0.0.1` (loopback) on the story-032 `findFreePort` port, and MUST fail
7
+ // closed (respond `permissionDecision:'deny'`) on a malformed payload, a missing decision, or a decider
8
+ // timeout — never leave a tool ungated by returning nothing or throwing out of the request handler.
9
+ //
10
+ // WIRING (IMPLEMENTACAO-FORK-ACP.md §9): claude POSTs the `PreToolUse` payload to
11
+ // `http://127.0.0.1:<port>/__fork-acp-gate__` (the story-032 hook URL) BEFORE the tool runs; the server
12
+ // parses `{session_id, transcript_path, cwd, permission_mode, hook_event_name, tool_name, tool_input,
13
+ // tool_use_id}`, forwards `{toolName, toolInput, toolUseId, …}` to the injected `onToolCall` decider
14
+ // (which correlates by tool_use.id + raises ACP session/request_permission — request-permission.ts),
15
+ // and writes the decision body back. The decider is INJECTED so the server is unit-testable OFFLINE.
16
+ //
17
+ // The hook is injected into the TUI via `--settings <scratchFile>` (story 032 injectHook), never the
18
+ // user's real config — that wiring is the engine-integration concern; this module is the standalone
19
+ // server seam.
20
+ import { createServer } from "node:http";
21
+ import { LOOPBACK_HOST } from "../gate/port.js";
22
+ import { allowDecision, denyDecision } from "./deny.js";
23
+ /** Default decider timeout (ms) — long enough for an async Zed decision relay, short of a hang. */
24
+ export const DEFAULT_DECIDER_TIMEOUT_MS = 600_000;
25
+ /** Read a request body fully into a UTF-8 string (bounded by Node's own socket limits). */
26
+ function readBody(req) {
27
+ return new Promise((resolve, reject) => {
28
+ let body = "";
29
+ req.setEncoding("utf8");
30
+ req.on("data", (chunk) => {
31
+ body += chunk;
32
+ });
33
+ req.on("end", () => resolve(body));
34
+ req.on("error", reject);
35
+ });
36
+ }
37
+ /** Write a decision body as the http JSON response (always 200 — the body carries the decision). */
38
+ function writeDecision(res, decision) {
39
+ const json = JSON.stringify(decision);
40
+ res.writeHead(200, { "content-type": "application/json" });
41
+ res.end(json);
42
+ }
43
+ /**
44
+ * Parse a PreToolUse payload from raw JSON, normalizing to {@link ForwardedToolCall}. Returns null when
45
+ * the body is unparseable OR lacks a `tool_use_id`/`tool_name` — the caller fails closed on null (a
46
+ * call we cannot identify is denied, never approved).
47
+ */
48
+ export function parsePayload(raw) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ if (parsed === null || typeof parsed !== "object") {
57
+ return null;
58
+ }
59
+ const toolUseId = parsed.tool_use_id;
60
+ const toolName = parsed.tool_name;
61
+ if (typeof toolUseId !== "string" || toolUseId.length === 0) {
62
+ return null;
63
+ }
64
+ if (typeof toolName !== "string" || toolName.length === 0) {
65
+ return null;
66
+ }
67
+ return {
68
+ toolName,
69
+ toolInput: parsed.tool_input,
70
+ toolUseId,
71
+ sessionId: typeof parsed.session_id === "string" ? parsed.session_id : undefined,
72
+ permissionMode: typeof parsed.permission_mode === "string" ? parsed.permission_mode : undefined,
73
+ };
74
+ }
75
+ /** Run the decider with a timeout; on timeout or throw, resolve `'deny'` (fail closed). */
76
+ async function decideWithTimeout(decider, call, timeoutMs, onWarn) {
77
+ let timer;
78
+ const timeout = new Promise((resolve) => {
79
+ timer = setTimeout(() => {
80
+ onWarn?.(`[gate §9] FAIL CLOSED: decider timed out after ${timeoutMs}ms for tool_use ` +
81
+ `${call.toolUseId} (tool "${call.toolName}") — denying.`);
82
+ resolve("deny");
83
+ }, timeoutMs);
84
+ });
85
+ try {
86
+ const decided = await Promise.race([Promise.resolve(decider(call)), timeout]);
87
+ return decided;
88
+ }
89
+ catch (err) {
90
+ onWarn?.(`[gate §9] FAIL CLOSED: decider threw for tool_use ${call.toolUseId} ` +
91
+ `(${err instanceof Error ? err.message : String(err)}) — denying.`);
92
+ return "deny";
93
+ }
94
+ finally {
95
+ if (timer !== undefined)
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+ /**
100
+ * Bind a loopback `PreToolUse` http hook server on `port` and forward each tool call to the decider,
101
+ * FAILING CLOSED on every error path (R2.1, R2.4).
102
+ *
103
+ * The server binds {@link LOOPBACK_HOST} (127.0.0.1) ONLY — never `0.0.0.0` — matching the blocker-b
104
+ * constraint that the transport is loopback TCP. Each request body is parsed by {@link parsePayload};
105
+ * a malformed/identity-less payload, a decider timeout, or a decider throw all write a
106
+ * `permissionDecision:'deny'` body so the tool never runs ungated. A clean `'allow'` writes the allow
107
+ * body (the keystroke mitigation for #52822 is a separate allow-inject.ts concern).
108
+ *
109
+ * @returns a {@link HookServer} once the socket is bound (rejects on a bind error — the spawn path then
110
+ * fails fast rather than spawning claude with an ungated hook URL).
111
+ */
112
+ export function startHookServer(opts) {
113
+ const { port, deciderTimeoutMs = DEFAULT_DECIDER_TIMEOUT_MS, createHttpServer = createServer, onWarn, } = opts;
114
+ let decider = opts.onToolCall;
115
+ const server = createHttpServer();
116
+ server.on("request", (req, res) => {
117
+ void (async () => {
118
+ // The tool name is unknown until the body parses; default the fail-closed deny target.
119
+ let call = null;
120
+ try {
121
+ const raw = await readBody(req);
122
+ call = parsePayload(raw);
123
+ if (call === null) {
124
+ onWarn?.(`[gate §9] FAIL CLOSED: unparseable or identity-less PreToolUse payload — denying.`);
125
+ // No tool identity: still respond deny so claude intercepts rather than running ungated.
126
+ writeDecision(res, denyDecision({ toolName: "(unknown)", toolUseId: "(unknown)" }));
127
+ return;
128
+ }
129
+ if (decider === undefined) {
130
+ onWarn?.(`[gate §9] FAIL CLOSED: no decider registered for tool_use ${call.toolUseId} — denying.`);
131
+ writeDecision(res, denyDecision(call));
132
+ return;
133
+ }
134
+ const decision = await decideWithTimeout(decider, call, deciderTimeoutMs, onWarn);
135
+ writeDecision(res, decision === "allow" ? allowDecision(call) : denyDecision(call));
136
+ }
137
+ catch (err) {
138
+ // Any unexpected handler error (body read failure, write failure) → fail closed.
139
+ onWarn?.(`[gate §9] FAIL CLOSED: hook request handler error ` +
140
+ `(${err instanceof Error ? err.message : String(err)}) — denying.`);
141
+ try {
142
+ writeDecision(res, denyDecision(call ?? { toolName: "(unknown)", toolUseId: "(unknown)" }));
143
+ }
144
+ catch {
145
+ // The response may already be partially sent; nothing else to do but not crash the server.
146
+ }
147
+ }
148
+ })();
149
+ });
150
+ return new Promise((resolve, reject) => {
151
+ const onError = (err) => {
152
+ server.removeListener("listening", onListening);
153
+ reject(err);
154
+ };
155
+ const onListening = () => {
156
+ server.removeListener("error", onError);
157
+ resolve({
158
+ port,
159
+ onToolCall(next) {
160
+ decider = next;
161
+ },
162
+ close() {
163
+ return new Promise((res) => {
164
+ try {
165
+ server.close(() => res());
166
+ }
167
+ catch {
168
+ res();
169
+ }
170
+ });
171
+ },
172
+ });
173
+ };
174
+ server.once("error", onError);
175
+ server.once("listening", onListening);
176
+ // LOOPBACK ONLY (blocker b) — bind 127.0.0.1, never 0.0.0.0.
177
+ server.listen(port, LOOPBACK_HOST);
178
+ });
179
+ }
@@ -0,0 +1,67 @@
1
+ import { type GuardHooks, type WatchedMessage } from "../billing/entrypoint-guard.js";
2
+ /** The two auto-approve permission modes the alternative supports (§9). */
3
+ export type AltPermissionMode = "acceptEdits" | "bypassPermissions";
4
+ /**
5
+ * Build the `--permission-mode <mode>` spawn flag fragment for the alternative (R4.1). Returns the two
6
+ * argv tokens; the spawn path appends them to the interactive `claude` argv. NOTE: this flag changes
7
+ * only how the TUI PROMPTS — it does NOT add `-p`/`stream-json` and therefore does NOT change billing.
8
+ *
9
+ * @param mode `acceptEdits` (the safe default) or `bypassPermissions`.
10
+ * @returns the argv tokens `['--permission-mode', mode]`.
11
+ * @throws {Error} on an unrecognized mode (never silently fall through to a non-gating flag).
12
+ */
13
+ export declare function permissionModeFlag(mode: AltPermissionMode): [string, string];
14
+ /**
15
+ * The DENY-ONLY policy for the alternative: the set of dangerous tools (or matchers) the hook denies
16
+ * while everything else auto-approves in the TUI. Kept as a caller-supplied list so the dangerous set
17
+ * is an explicit, reviewable decision (not a hidden default). The matcher uses the same exact/`"*"`
18
+ * convention as the §9 settings matcher.
19
+ */
20
+ export interface DenyOnlyPolicy {
21
+ /** Tool names (or `"*"`) the hook DENIES; everything not listed auto-approves. */
22
+ denyMatchers: string[];
23
+ }
24
+ /**
25
+ * Decide whether the deny-only hook should DENY this tool (R4.1). Returns true iff the tool matches a
26
+ * deny matcher (exact name or `"*"`); otherwise the tool is allowed to auto-approve in the TUI.
27
+ *
28
+ * @param policy the deny-only policy (the dangerous-tool matchers).
29
+ * @param toolName the tool from the hook payload.
30
+ * @returns true → the hook denies this tool; false → auto-approve.
31
+ */
32
+ export declare function denyOnlyShouldDeny(policy: DenyOnlyPolicy, toolName: string): boolean;
33
+ /**
34
+ * Re-assert the story-022 billing guard-rail (§10) for one observed message under the alternative mode
35
+ * (R4.2). This is the SAME guard-rail the read-only pump runs — re-asserted here so enabling
36
+ * auto-approve does NOT weaken billing. Delegates to {@link guardEvent} (it never re-derives the class
37
+ * and never mutates/spoofs `entrypoint`): on a `sdk-*`/`print` (or unknown) label it alerts + stops the
38
+ * session via the injected hooks; on `cli` it is a silent no-op.
39
+ *
40
+ * @param event one watched message event (the story-015 watcher shape).
41
+ * @param hooks the alert/stop sink (wired to the engine-lifecycle stop in production).
42
+ * @returns the guard decision (`allow`/`abort`/`skip`) so the caller can branch.
43
+ */
44
+ export declare function reassertBillingGuard(event: WatchedMessage, hooks: GuardHooks): import("../billing/entrypoint-guard.js").GuardDecision;
45
+ /** Result of {@link assertEntrypointCli}: the session may proceed, or it must abort (credit-class). */
46
+ export type EntrypointAssertion = {
47
+ ok: true;
48
+ entrypoint: string;
49
+ } | {
50
+ ok: false;
51
+ entrypoint: string;
52
+ reason: string;
53
+ };
54
+ /**
55
+ * Convenience post-spawn assertion (R4.2): given the FIRST observed billing message's entrypoint
56
+ * self-label, decide whether the alternative-mode session may proceed. ABORTS (`ok:false`) on anything
57
+ * that is not the subscription `cli` label, naming the observed entrypoint in the reason — never
58
+ * proceeds on a `sdk-*`/`print` label and never spoofs the value toward `cli`.
59
+ *
60
+ * This is a thin, pure wrapper over {@link guardEvent} for the spawn path that wants a boolean rather
61
+ * than the hook side-effects; the side-effecting {@link reassertBillingGuard} is used by the live pump.
62
+ *
63
+ * @param event the first observed `assistant`/`user` event under the alternative mode.
64
+ * @returns `{ ok:true, entrypoint }` for `cli`; `{ ok:false, entrypoint, reason }` otherwise.
65
+ */
66
+ export declare function assertEntrypointCli(event: WatchedMessage): EntrypointAssertion;
67
+ //# sourceMappingURL=permission-mode.d.ts.map