@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.
- package/LICENSE +191 -0
- package/NOTICE +14 -0
- package/README.md +50 -0
- package/dist/acp-agent.d.ts +594 -0
- package/dist/acp-agent.d.ts.map +1 -0
- package/dist/acp-agent.js +2139 -0
- package/dist/ansi-mirror.d.ts +42 -0
- package/dist/ansi-mirror.d.ts.map +1 -0
- package/dist/ansi-mirror.js +61 -0
- package/dist/besteffort.d.ts +44 -0
- package/dist/besteffort.d.ts.map +1 -0
- package/dist/besteffort.js +100 -0
- package/dist/billing/entrypoint-guard.d.ts +97 -0
- package/dist/billing/entrypoint-guard.d.ts.map +1 -0
- package/dist/billing/entrypoint-guard.js +166 -0
- package/dist/claude-path.d.ts +12 -0
- package/dist/claude-path.d.ts.map +1 -0
- package/dist/claude-path.js +61 -0
- package/dist/diff-enriched-reader.d.ts +41 -0
- package/dist/diff-enriched-reader.d.ts.map +1 -0
- package/dist/diff-enriched-reader.js +106 -0
- package/dist/diff-source.d.ts +104 -0
- package/dist/diff-source.d.ts.map +1 -0
- package/dist/diff-source.js +164 -0
- package/dist/end-of-turn.d.ts +172 -0
- package/dist/end-of-turn.d.ts.map +1 -0
- package/dist/end-of-turn.js +415 -0
- package/dist/engine-lifecycle.d.ts +222 -0
- package/dist/engine-lifecycle.d.ts.map +1 -0
- package/dist/engine-lifecycle.js +236 -0
- package/dist/engine-pty.d.ts +143 -0
- package/dist/engine-pty.d.ts.map +1 -0
- package/dist/engine-pty.js +222 -0
- package/dist/engine-watcher.d.ts +83 -0
- package/dist/engine-watcher.d.ts.map +1 -0
- package/dist/engine-watcher.js +173 -0
- package/dist/engine.d.ts +30 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +34 -0
- package/dist/event-switch.d.ts +164 -0
- package/dist/event-switch.d.ts.map +1 -0
- package/dist/event-switch.js +206 -0
- package/dist/gate/port.d.ts +38 -0
- package/dist/gate/port.d.ts.map +1 -0
- package/dist/gate/port.js +126 -0
- package/dist/gate/settings-writer.d.ts +130 -0
- package/dist/gate/settings-writer.d.ts.map +1 -0
- package/dist/gate/settings-writer.js +349 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +106 -0
- package/dist/jsonl.d.ts +267 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/jsonl.js +527 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +5 -0
- package/dist/linearize.d.ts +219 -0
- package/dist/linearize.d.ts.map +1 -0
- package/dist/linearize.js +444 -0
- package/dist/live-diff-env.d.ts +7 -0
- package/dist/live-diff-env.d.ts.map +1 -0
- package/dist/live-diff-env.js +18 -0
- package/dist/live-subagent-env.d.ts +7 -0
- package/dist/live-subagent-env.d.ts.map +1 -0
- package/dist/live-subagent-env.js +19 -0
- package/dist/permissions/allow-inject.d.ts +67 -0
- package/dist/permissions/allow-inject.d.ts.map +1 -0
- package/dist/permissions/allow-inject.js +85 -0
- package/dist/permissions/deny.d.ts +60 -0
- package/dist/permissions/deny.d.ts.map +1 -0
- package/dist/permissions/deny.js +81 -0
- package/dist/permissions/gate-wiring.d.ts +112 -0
- package/dist/permissions/gate-wiring.d.ts.map +1 -0
- package/dist/permissions/gate-wiring.js +350 -0
- package/dist/permissions/hook-server.d.ts +72 -0
- package/dist/permissions/hook-server.d.ts.map +1 -0
- package/dist/permissions/hook-server.js +179 -0
- package/dist/permissions/permission-mode.d.ts +67 -0
- package/dist/permissions/permission-mode.d.ts.map +1 -0
- package/dist/permissions/permission-mode.js +100 -0
- package/dist/permissions/request-permission.d.ts +102 -0
- package/dist/permissions/request-permission.d.ts.map +1 -0
- package/dist/permissions/request-permission.js +124 -0
- package/dist/settings.d.ts +68 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +182 -0
- package/dist/stop-reason-map.d.ts +17 -0
- package/dist/stop-reason-map.d.ts.map +1 -0
- package/dist/stop-reason-map.js +33 -0
- package/dist/subagent-source.d.ts +63 -0
- package/dist/subagent-source.d.ts.map +1 -0
- package/dist/subagent-source.js +132 -0
- package/dist/subagent-watcher.d.ts +40 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +108 -0
- package/dist/tools.d.ts +119 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +729 -0
- package/dist/usage-env.d.ts +7 -0
- package/dist/usage-env.d.ts.map +1 -0
- package/dist/usage-env.js +16 -0
- package/dist/usage.d.ts +54 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +53 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +83 -0
- package/dist/zed-register.d.ts +26 -0
- package/dist/zed-register.d.ts.map +1 -0
- package/dist/zed-register.js +106 -0
- 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
|