@junghanacs/entwurf 0.12.10 → 0.13.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 (43) hide show
  1. package/AGENTS.md +2 -1
  2. package/BASELINE.md +45 -6
  3. package/CHANGELOG.md +16 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +1 -1
  6. package/README.md +20 -5
  7. package/VERIFY.md +7 -4
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  13. package/package.json +4 -2
  14. package/pi/settings.reference.json +1 -1
  15. package/pi-extensions/acp-provider.ts +20 -10
  16. package/pi-extensions/lib/acp/augment.ts +60 -2
  17. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  18. package/pi-extensions/lib/acp/backend.ts +5 -1
  19. package/pi-extensions/lib/acp/config.ts +19 -5
  20. package/pi-extensions/lib/acp/engraving.ts +3 -1
  21. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  22. package/pi-extensions/lib/acp/models.ts +69 -7
  23. package/pi-extensions/lib/acp/overlay.ts +234 -5
  24. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  25. package/run.sh +152 -21
  26. package/scripts/check-acp-cortex.ts +668 -0
  27. package/scripts/check-acp-provider-surface.ts +50 -6
  28. package/scripts/check-acp-session-reuse.ts +64 -1
  29. package/scripts/check-gate-qualification.ts +2 -0
  30. package/scripts/check-probe-cli-shim.ts +879 -0
  31. package/scripts/check-probe-ordering.ts +2450 -0
  32. package/scripts/check-shell-quote.ts +4 -4
  33. package/scripts/fixtures/probe-cli-shim +20 -0
  34. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  35. package/scripts/lib/probe-acp-turn.ts +207 -0
  36. package/scripts/lib/probe-cli-shim.ts +464 -0
  37. package/scripts/lib/probe-cli-target.ts +165 -0
  38. package/scripts/lib/probe-event-log.ts +383 -0
  39. package/scripts/lib/probe-verdict.ts +1213 -0
  40. package/scripts/mutants/acp-cortex.json +196 -0
  41. package/scripts/mutants/probe-ordering.json +1032 -0
  42. package/scripts/smoke-acp-cortex-live.ts +392 -0
  43. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
@@ -0,0 +1,464 @@
1
+ // §11-7-c B-name-snapshot seam — the PRODUCER half (docs/acp-backend-rail.md).
2
+ //
3
+ // The consumer half (probe-cli-target.ts preconditions, the event-log doors, the
4
+ // classifier ladder) was built and mutant-qualified first; this is the instrument
5
+ // it was specified against. The shim sits at CLAUDE_CODE_EXECUTABLE, spawns the
6
+ // REAL CLI the runner resolved, and tees the stream-json stdout so the CLI's own
7
+ // per-turn `system`/`init` tool-NAME set becomes an oracle that does not depend on
8
+ // model compliance (§11-7-b measured that stimulus tuning cannot reach delta-B).
9
+ //
10
+ // What it must NOT be is a second copy of upstream launch semantics. The runner
11
+ // already refused an ambient override, resolved the target through upstream
12
+ // `claudeCliPath()`, and asserted the native branch (absolute ∧ no script suffix ∧
13
+ // regular file ∧ X_OK). So the shim RESOLVES NOTHING: it execs exactly the path it
14
+ // was handed and reports that path plus its content hash, which is the fact the
15
+ // classifier verifies against the roster (§11-7-c condition 5 — a managed-policy
16
+ // env swap of the target can then never promote, it becomes a NAMED structural
17
+ // finding instead of an anonymous no-snapshot).
18
+ //
19
+ // Three properties are load-bearing and each one is a place this could silently
20
+ // lie, so each is stated here and proved by the fake-CLI matrix in
21
+ // check-probe-cli-shim:
22
+ //
23
+ // 1. BYTE TRANSPARENCY. Every byte that arrives is written downstream unchanged
24
+ // and in order. The NDJSON scanning is a SIDE observation over a bounded
25
+ // buffer — it never reframes, re-encodes, or re-chunks the stream. Line
26
+ // splitting is done on BYTES (0x0a), never on a decoded string, so a chunk
27
+ // boundary inside a multi-byte UTF-8 sequence cannot corrupt the passthrough
28
+ // or the framing.
29
+ //
30
+ // 2. THE SNAPSHOT APPEND HAPPENS INSIDE THE DOWNSTREAM WRITE CALLBACK. §11-7-c
31
+ // condition 6 makes the snapshot timestamp an INTERVAL — full line received ↔
32
+ // handed downstream — whose END has ONE SSOT: the event's own envelope
33
+ // `tsMs`. Appending inside the callback is what makes the single clock read
34
+ // that stamps the line BE the callback moment; the payload therefore carries
35
+ // only `receivedAtMs`, and the door holds `receivedAtMs ≤ tsMs`. Like the
36
+ // fixture's write-callback timing, this PLACEMENT is review-pinned rather
37
+ // than mutant-proven (the honesty carve-out recorded in §11-7-c).
38
+ //
39
+ // 3. THE SCRUB IS AN EXACT ALLOWLIST. `SHIM_SCRUB_ENV_VARS` (the single source
40
+ // in probe-cli-target.ts) leaves the child env otherwise byte-identical. A
41
+ // prefix scrub would delete operator env this probe has no claim on, and
42
+ // NOT scrubbing CLAUDE_CODE_EXECUTABLE would re-propagate the override to
43
+ // grandchildren — recursion, or a sub-agent measured through a second shim.
44
+ //
45
+ // It is ARGV-AGNOSTIC on purpose: `claudeCliPath()` has a second consumer
46
+ // (`claude auth logout`, acp-agent.js:841), so an invocation that is not a
47
+ // stream-json turn must be pure passthrough whose only log line is the boot
48
+ // marker. Nothing about argv, env, auth, or prompt bodies is ever logged — the
49
+ // only payload that leaves this process is the allowlisted init fields (tools,
50
+ // mcp_servers status, model) plus an ordinal and timings.
51
+
52
+ import { spawn } from "node:child_process";
53
+ import { appendFileSync, writeSync } from "node:fs";
54
+ import { constants as osConstants } from "node:os";
55
+ import { hashFileSha256, PROBE_SHIM_ENV, SHIM_SCRUB_ENV_VARS } from "./probe-cli-target.ts";
56
+ import { appendProbeEvent, PROBE_EVENTS } from "./probe-event-log.ts";
57
+
58
+ const NEWLINE = 0x0a;
59
+
60
+ /** Bound on the in-memory NDJSON framing buffer (§11-7-c condition 3: "a bounded
61
+ * in-memory line buffer"). A single stream-json line larger than this cannot be
62
+ * parsed without letting a hostile or pathological stream drive this process's
63
+ * memory, so the PARSE is skipped — the BYTES still pass through untouched,
64
+ * because passthrough never depends on the scanner. The contract consequence of
65
+ * a skipped parse is fail-closed by construction: a skipped init line yields
66
+ * zero bound candidates and a skipped prompt frame yields zero anchors, both of
67
+ * which are NAMED reading violations in the classifier, never a quiet promotion.
68
+ * 16 MiB is ~500x the largest init line this seam has measured (a ~30 KB name
69
+ * set), so reaching it means the stream stopped being stream-json. */
70
+ export const SHIM_MAX_LINE_BYTES = 16 * 1024 * 1024;
71
+
72
+ /** Signals forwarded to the child and then RE-RAISED on ourselves, so the parent's
73
+ * wait status carries the child's real (code, signal) instead of the shim's. */
74
+ const FORWARDED_SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
75
+
76
+ /** Zero-length flush barrier. `write()`'s callback fires only after every queued
77
+ * chunk has reached the OS, so this is how the shim proves it lost no forwarded
78
+ * byte to `process.exit()`, which truncates pending async writes on a pipe. */
79
+ const FLUSH_BARRIER = Buffer.alloc(0);
80
+
81
+ interface InitSnapshotPayload {
82
+ tools: string[];
83
+ receivedAtMs: number;
84
+ mcpServers?: Array<{ name: string; status: string }>;
85
+ model?: string;
86
+ }
87
+
88
+ /** Byte-level NDJSON line framing over a buffer bounded in BOTH dimensions.
89
+ *
90
+ * Splitting on bytes rather than on a decoded string is not a micro-optimisation:
91
+ * a chunk boundary can fall inside a multi-byte UTF-8 sequence, and decoding each
92
+ * chunk independently would produce replacement characters that corrupt the line
93
+ * the parser then judges. Only COMPLETE lines are decoded.
94
+ *
95
+ * The partial line lives in ONE buffer that grows geometrically to a hard cap —
96
+ * not in an array of per-chunk pieces (adversarial review 2026-07-29). A piece
97
+ * list is bounded in BYTES and unbounded in OBJECTS: a peer writing one byte at a
98
+ * time reaches the byte cap holding sixteen million Buffer headers plus the array
99
+ * indexing them, so "a bounded in-memory line buffer" would have been false
100
+ * exactly where it mattered — under a hostile stream. One buffer makes the bound
101
+ * O(cap) in both dimensions, and the copy it costs is paid only by lines that
102
+ * actually span reads: a line whole inside its chunk is handed to the parser as a
103
+ * VIEW, with no copy and nothing retained. */
104
+ const INITIAL_LINE_CAPACITY = 64 * 1024;
105
+ /** Above this a completed line's buffer is released instead of kept for reuse —
106
+ * one big line must not leave the instrument holding that much for a whole turn. */
107
+ const RETAINED_LINE_CAPACITY = 1024 * 1024;
108
+
109
+ export class NdjsonLineScanner {
110
+ private buffer: Buffer = Buffer.alloc(0);
111
+ private length = 0;
112
+ private overflowed = false;
113
+ /** Lines whose parse was skipped because they exceeded the buffer bound. */
114
+ overflowCount = 0;
115
+ /** Allocations the CURRENT partial line has cost — the object half of the
116
+ * framing bound, made observable. Geometric growth keeps it logarithmic in the
117
+ * line's length however many reads it arrived in; per-read retention (a piece
118
+ * list, or a buffer regrown to the exact size each time) makes it linear in the
119
+ * number of reads, which is the bound a one-byte-at-a-time peer walks through. */
120
+ retainedAllocations = 0;
121
+
122
+ feed(chunk: Buffer, onLine: (line: Buffer) => void): void {
123
+ let start = 0;
124
+ for (;;) {
125
+ const nl = chunk.indexOf(NEWLINE, start);
126
+ if (nl === -1) {
127
+ this.absorb(chunk.subarray(start));
128
+ return;
129
+ }
130
+ const piece = chunk.subarray(start, nl);
131
+ if (this.length === 0 && !this.overflowed) {
132
+ if (piece.length > SHIM_MAX_LINE_BYTES) this.overflowCount += 1;
133
+ else if (piece.length > 0) onLine(piece);
134
+ } else {
135
+ this.absorb(piece);
136
+ if (this.overflowed) this.overflowCount += 1;
137
+ else onLine(this.buffer.subarray(0, this.length));
138
+ }
139
+ this.reset();
140
+ start = nl + 1;
141
+ }
142
+ }
143
+
144
+ /** Stream end. A line still OVER the bound when the stream closed never met a
145
+ * newline, so `feed` never counted it; without this the skip diagnostic would
146
+ * under-report exactly the unterminated-oversized shape. */
147
+ finalize(): void {
148
+ if (this.overflowed) this.overflowCount += 1;
149
+ this.reset();
150
+ }
151
+
152
+ private absorb(slice: Buffer): void {
153
+ if (this.overflowed || slice.length === 0) return;
154
+ const needed = this.length + slice.length;
155
+ if (needed > SHIM_MAX_LINE_BYTES) {
156
+ this.overflowed = true;
157
+ this.buffer = Buffer.alloc(0);
158
+ this.length = 0;
159
+ return;
160
+ }
161
+ if (needed > this.buffer.length) {
162
+ let capacity = Math.max(this.buffer.length, INITIAL_LINE_CAPACITY);
163
+ while (capacity < needed) capacity *= 2;
164
+ const grown = Buffer.allocUnsafe(Math.min(capacity, SHIM_MAX_LINE_BYTES));
165
+ this.retainedAllocations += 1;
166
+ this.buffer.copy(grown, 0, 0, this.length);
167
+ this.buffer = grown;
168
+ }
169
+ slice.copy(this.buffer, this.length);
170
+ this.length = needed;
171
+ }
172
+
173
+ private reset(): void {
174
+ this.length = 0;
175
+ this.overflowed = false;
176
+ this.retainedAllocations = 0;
177
+ if (this.buffer.length > RETAINED_LINE_CAPACITY) this.buffer = Buffer.alloc(0);
178
+ }
179
+ }
180
+
181
+ function parseJsonObject(line: Buffer): Record<string, unknown> | undefined {
182
+ try {
183
+ const parsed: unknown = JSON.parse(line.toString("utf8"));
184
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
185
+ return parsed as Record<string, unknown>;
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ }
190
+
191
+ /** A stream-json INPUT frame carrying a turn's prompt.
192
+ *
193
+ * Measured, not assumed (claude-agent-sdk 0.3.219): the SDK writes NDJSON to the
194
+ * CLI's stdin from two places — control traffic (`{"type":"control_request"…}` /
195
+ * `control_response`) and `streamInput`, which serializes each user message as
196
+ * `{"type":"user",…}`. Counting every stdin line would therefore count the
197
+ * initialize handshake as prompts and blow the exactly-one binding on every run;
198
+ * the prompt axis is the `user` frames alone. */
199
+ function isPromptFrame(line: Buffer): boolean {
200
+ return parseJsonObject(line)?.type === "user";
201
+ }
202
+
203
+ /** The §11-7-c oracle line: `SDKSystemMessage` with `subtype: 'init'`, which
204
+ * sdk.d.ts:4412 defines as carrying `tools: string[]` and
205
+ * `mcp_servers: {name,status}[]`, re-emitted per turn (acp-agent.js:1573-1587).
206
+ *
207
+ * `tools` must be a real array of strings or this returns nothing. An init line
208
+ * whose name set is missing or mistyped would otherwise be reported as an EMPTY
209
+ * name set — and an empty set is exactly what the B-name-snapshot ladder reads as
210
+ * ABSENCE of the measured id. Fabricating an absence claim out of a malformed
211
+ * line is the one failure this instrument must never have, so the line simply
212
+ * does not become a candidate and the run fails closed on cardinality. */
213
+ function initSnapshotOf(line: Buffer, receivedAtMs: number): InitSnapshotPayload | undefined {
214
+ const obj = parseJsonObject(line);
215
+ if (obj === undefined || obj.type !== "system" || obj.subtype !== "init") return undefined;
216
+ if (!Array.isArray(obj.tools) || !obj.tools.every((t) => typeof t === "string")) return undefined;
217
+ const payload: InitSnapshotPayload = { tools: obj.tools as string[], receivedAtMs };
218
+ if (Array.isArray(obj.mcp_servers)) {
219
+ // Allowlist entries whose two fields are ALREADY strings rather than
220
+ // coercing: `String(someObject)` would put "[object Object]" into the
221
+ // evidence log as though the CLI had reported it. This fact never promotes
222
+ // anything, which is all the more reason not to manufacture it.
223
+ payload.mcpServers = obj.mcp_servers
224
+ .filter((entry): entry is { name: string; status: string } => {
225
+ if (typeof entry !== "object" || entry === null) return false;
226
+ const fields = entry as Record<string, unknown>;
227
+ return typeof fields.name === "string" && typeof fields.status === "string";
228
+ })
229
+ .map((entry) => ({ name: entry.name, status: entry.status }));
230
+ }
231
+ if (typeof obj.model === "string") payload.model = obj.model;
232
+ return payload;
233
+ }
234
+
235
+ function abort(message: string, code: number): never {
236
+ // writeSync, not process.stderr.write. Node documents stream writes to a pipe
237
+ // as synchronous on Linux, so the truncation this avoids is latent here rather
238
+ // than live — but the NAMED-failure contract should not rest on a
239
+ // platform-specific guarantee about the one path that reports why the
240
+ // instrument could not run (adversarial review 2026-07-29). Partial writes are
241
+ // looped because write(2) may accept fewer bytes than offered.
242
+ const bytes = Buffer.from(`[probe-cli-shim] ${message}\n`, "utf8");
243
+ let written = 0;
244
+ while (written < bytes.length) {
245
+ try {
246
+ written += writeSync(2, bytes, written, bytes.length - written);
247
+ } catch {
248
+ break; // stderr is gone; the exit status still carries the fact
249
+ }
250
+ }
251
+ process.exit(code);
252
+ }
253
+
254
+ /** One errno→status mapping for BOTH ways the target can be unusable — unreadable
255
+ * at hash time and unspawnable at exec time. Shell conventions (127 not-found,
256
+ * 126 not-executable) keep the status readable to whatever launched us, and using
257
+ * the same mapping on both paths means the operator reads one story about the
258
+ * target rather than two unrelated numbers. */
259
+ function exitStatusForErrno(code: string | undefined): number {
260
+ if (code === "ENOENT") return 127;
261
+ if (code === "EACCES") return 126;
262
+ return 70;
263
+ }
264
+
265
+ function requiredShimEnv(name: string): string {
266
+ const value = process.env[name];
267
+ // The shim is probe-only: the runner sets all three vars together, so a
268
+ // missing one means this binary was reached by something other than the probe
269
+ // (a stale CLAUDE_CODE_EXECUTABLE in an operator shell, say). Failing loud
270
+ // beats silently exec-ing a target we cannot name in the log.
271
+ if (typeof value !== "string" || value.length === 0) {
272
+ abort(`missing required env ${name} — this shim is probe-only and its runner sets ${name}`, 70);
273
+ }
274
+ return value;
275
+ }
276
+
277
+ export function runProbeCliShim(): void {
278
+ const targetPath = requiredShimEnv(PROBE_SHIM_ENV.target);
279
+ const eventLog = requiredShimEnv(PROBE_SHIM_ENV.eventLog);
280
+ const runId = requiredShimEnv(PROBE_SHIM_ENV.runId);
281
+
282
+ const emit = (event: (typeof PROBE_EVENTS)[keyof typeof PROBE_EVENTS], payload: Record<string, unknown>): void => {
283
+ appendProbeEvent(eventLog, runId, event, payload);
284
+ };
285
+
286
+ // Boot marker FIRST — before the spawn can fail. §11-7-c condition 7 makes a
287
+ // missing boot marker on an armed roster a NAMED structural finding
288
+ // (`snapshot-instrument-absent` / `snapshot-topology`), which is precisely how
289
+ // a managed-policy env application that replaced the shim inside the ACP child
290
+ // becomes visible instead of looking like "the CLI just did not re-emit init".
291
+ // The hash is taken HERE, by the process that actually execs it, because the
292
+ // classifier verifies this report against the roster's expected identity.
293
+ //
294
+ // A target that cannot be READ has no knowable identity, so there is no boot
295
+ // marker to write: emitting one with a placeholder hash would be fabricating
296
+ // exactly the fact condition 5 has the classifier verify. The absence is the
297
+ // honest report, and the consumer already names it (`snapshot-instrument-absent`
298
+ // → structural, P0), so this exits before the spawn rather than guessing.
299
+ let targetSha256: string;
300
+ try {
301
+ targetSha256 = hashFileSha256(targetPath);
302
+ } catch (err) {
303
+ const code = (err as NodeJS.ErrnoException).code;
304
+ abort(`cannot read exec target ${targetPath}: ${code ?? (err as Error).message}`, exitStatusForErrno(code));
305
+ }
306
+ emit(PROBE_EVENTS.shimBoot, { targetPath, targetSha256 });
307
+
308
+ // Exact-allowlist scrub (§11-7-c condition 4). Every other variable keeps its
309
+ // key AND its value byte-for-byte: under the runner's ambient-override refusal
310
+ // there is no prior operator value to restore, so deletion IS preservation.
311
+ const childEnv: NodeJS.ProcessEnv = { ...process.env };
312
+ for (const name of SHIM_SCRUB_ENV_VARS) delete childEnv[name];
313
+
314
+ // Same argv, same cwd (inherited — passing an explicit cwd would be a second
315
+ // copy of a fact we already hold), no shell. stderr is `inherit` rather than a
316
+ // copied pipe: handing the child our own fd 2 is exact passthrough with no
317
+ // buffering, no reordering, and nothing lost at exit, and the SDK reads a
318
+ // stderr tail for its own diagnostics.
319
+ const child = spawn(targetPath, process.argv.slice(2), {
320
+ env: childEnv,
321
+ stdio: ["pipe", "pipe", "inherit"],
322
+ windowsHide: true,
323
+ });
324
+ const childStdin = child.stdin;
325
+ const childStdout = child.stdout;
326
+ if (childStdin === null || childStdout === null) abort("child stdio pipes were not created", 70);
327
+
328
+ const stdinScanner = new NdjsonLineScanner();
329
+ const stdoutScanner = new NdjsonLineScanner();
330
+ let promptOrdinal = 0;
331
+ let finished = false;
332
+
333
+ // A closed downstream (the SDK gave up, the CLI died) is not this process's
334
+ // error to raise — it must not become an unhandled 'error' crash that changes
335
+ // the exit status the parent observes. But IGNORING it is not enough: the
336
+ // backpressure path parks the source until a 'drain' a dead stream will never
337
+ // emit, so a bare ignore turns a closed consumer into a HUNG instrument holding
338
+ // a live CLI open (adversarial review 2026-07-29). Losing stdout means the turn
339
+ // cannot be delivered at all, so unpark the source and tear the child down.
340
+ childStdin.on("error", (): void => {});
341
+ process.stdout.on("error", (): void => {
342
+ childStdout.resume();
343
+ if (!finished) child.kill("SIGTERM");
344
+ });
345
+
346
+ // --- stdin: SDK → shim → real CLI -------------------------------------
347
+ // The prompt anchor is stamped in the DOWNSTREAM write callback, i.e. when the
348
+ // bytes completing the frame reached the CLI's stdin — "fully passed to the
349
+ // CLI's stdin", not "we saw a newline". Only a successful callback stamps: an
350
+ // errored write must never claim a hand-off that did not happen (the same
351
+ // discipline the fixture applies to the wire marker).
352
+ process.stdin.on("data", (chunk: Buffer) => {
353
+ const ordinals: number[] = [];
354
+ stdinScanner.feed(chunk, (line) => {
355
+ if (isPromptFrame(line)) ordinals.push(++promptOrdinal);
356
+ });
357
+ const flushed = childStdin.write(chunk, (err) => {
358
+ if (err) return;
359
+ for (const ordinal of ordinals) emit(PROBE_EVENTS.shimPromptForwarded, { ordinal });
360
+ });
361
+ // Backpressure: stop READING upstream while the child's stdin is full,
362
+ // instead of letting Node's writable queue grow without bound.
363
+ if (!flushed) {
364
+ process.stdin.pause();
365
+ childStdin.once("drain", () => process.stdin.resume());
366
+ }
367
+ });
368
+ process.stdin.on("end", () => {
369
+ stdinScanner.finalize();
370
+ childStdin.end();
371
+ });
372
+
373
+ // --- stdout: real CLI → shim → SDK ------------------------------------
374
+ // ONE clock read per chunk is the receive stamp for every line that COMPLETES
375
+ // in that chunk: the line became whole when its newline arrived, which is this
376
+ // moment. The snapshot event is appended INSIDE the downstream write callback
377
+ // so the envelope `tsMs` the log door stamps IS the hand-off moment — the
378
+ // interval's single-SSOT end (§11-7-c condition 6).
379
+ childStdout.on("data", (chunk: Buffer) => {
380
+ const receivedAtMs = Date.now();
381
+ const snapshots: InitSnapshotPayload[] = [];
382
+ stdoutScanner.feed(chunk, (line) => {
383
+ const snapshot = initSnapshotOf(line, receivedAtMs);
384
+ if (snapshot !== undefined) snapshots.push(snapshot);
385
+ });
386
+ const flushed = process.stdout.write(chunk, (err) => {
387
+ if (err) return;
388
+ for (const snapshot of snapshots) emit(PROBE_EVENTS.shimInitSnapshot, { ...snapshot });
389
+ });
390
+ if (!flushed) {
391
+ childStdout.pause();
392
+ process.stdout.once("drain", () => childStdout.resume());
393
+ }
394
+ });
395
+
396
+ // --- lifecycle --------------------------------------------------------
397
+ const writeOverflowDiagnostic = (): void => {
398
+ const total = stdinScanner.overflowCount + stdoutScanner.overflowCount;
399
+ if (total === 0) return;
400
+ // Deliberately NOT an event in the shared log: the log's vocabulary is a
401
+ // closed contract the classifier judges on, and an unknown marker name is
402
+ // MALFORMED at the door. This sidecar is forensics only — the contract
403
+ // consequence of a skipped parse is already fail-closed (zero anchors or
404
+ // zero candidates, both NAMED readings).
405
+ try {
406
+ appendFileSync(
407
+ `${eventLog}.shim-diag`,
408
+ `${JSON.stringify({
409
+ runId,
410
+ pid: process.pid,
411
+ stdinLineParseSkipped: stdinScanner.overflowCount,
412
+ stdoutLineParseSkipped: stdoutScanner.overflowCount,
413
+ maxLineBytes: SHIM_MAX_LINE_BYTES,
414
+ })}\n`,
415
+ "utf8",
416
+ );
417
+ } catch {
418
+ // A diagnostic that cannot be written must not change the exit status.
419
+ }
420
+ };
421
+
422
+ const reRaise = (signal: NodeJS.Signals): void => {
423
+ // Restore the default disposition before signalling ourselves, or our own
424
+ // forwarding handler would swallow it and the parent would read a plain
425
+ // exit where the child actually died on a signal.
426
+ for (const forwarded of FORWARDED_SIGNALS) process.removeAllListeners(forwarded);
427
+ process.stdin.destroy();
428
+ process.kill(process.pid, signal);
429
+ // Only reached if this signal is ignored or blocked for us; do not hang.
430
+ const number = osConstants.signals[signal as keyof typeof osConstants.signals] ?? 0;
431
+ setTimeout(() => process.exit(number > 0 ? 128 + number : 1), 200);
432
+ };
433
+
434
+ const finish = (code: number | null, signal: NodeJS.Signals | null): void => {
435
+ if (finished) return;
436
+ finished = true;
437
+ writeOverflowDiagnostic();
438
+ process.stdout.write(FLUSH_BARRIER, () => {
439
+ if (signal !== null) {
440
+ reRaise(signal);
441
+ return;
442
+ }
443
+ process.exit(code ?? 0);
444
+ });
445
+ };
446
+
447
+ childStdout.on("end", () => stdoutScanner.finalize());
448
+ child.on("error", (err: NodeJS.ErrnoException) => {
449
+ // ENOENT/EACCES are NAMED here rather than swallowed: the runner already
450
+ // asserted the target is a present, executable regular file, so reaching
451
+ // this means the target moved under the pair — a fact the operator needs
452
+ // in plain words. Shell conventions for the two cases keep the exit status
453
+ // readable to whatever spawned us.
454
+ abort(`cannot execute ${targetPath}: ${err.code ?? err.message}`, exitStatusForErrno(err.code));
455
+ });
456
+ for (const signal of FORWARDED_SIGNALS) {
457
+ process.on(signal, () => {
458
+ if (!finished) child.kill(signal);
459
+ });
460
+ }
461
+ // 'close' rather than 'exit': it fires after the child's stdio streams are
462
+ // closed, so every byte the CLI wrote has already been scanned and forwarded.
463
+ child.on("close", (code, signal) => finish(code, signal));
464
+ }
@@ -0,0 +1,165 @@
1
+ // §11-7-c CLI-target precondition seam (docs/acp-backend-rail.md).
2
+ //
3
+ // The B-name-snapshot oracle rides a probe-only shim at CLAUDE_CODE_EXECUTABLE,
4
+ // and the ONE way that override seam stays closed without duplicating upstream
5
+ // launch semantics is to narrow the precondition honestly instead of proving
6
+ // "operator override meaning is preserved" in general. Measured facts the
7
+ // narrowing rests on (installed dists, pinned by check-probe-ordering):
8
+ //
9
+ // - `claudeCliPath()` returns an ambient CLAUDE_CODE_EXECUTABLE VERBATIM —
10
+ // no resolution, no validation (acp-agent.js:204-207). Only with the env
11
+ // unset does it resolve the platform native binary to an ABSOLUTE path.
12
+ // - The SDK picks its launch branch off a pure suffix test: a path ending in
13
+ // one of SDK_SCRIPT_SUFFIXES is run as `node|bun <path> <flags>`, anything
14
+ // else is spawned DIRECTLY (sdk.mjs, claude-agent-sdk 0.3.219). The list
15
+ // has live sharp edges (`.cjs` is absent), which is exactly why this repo
16
+ // asserts against it instead of re-implementing it: a second copy of
17
+ // upstream semantics is a drift channel, not a contract.
18
+ //
19
+ // So the probe REFUSES to run under an ambient override (a named precondition
20
+ // failure, not a fallback), resolves the target once in that ambient-clean
21
+ // state, and then only ASSERTS the result is the shape the native branch
22
+ // spawns directly. The asserts are a gate, never launch logic.
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { accessSync, constants as fsConstants, readFileSync, statSync } from "node:fs";
26
+ import { isAbsolute } from "node:path";
27
+
28
+ /** The upstream executable-override env var this seam refuses to run under. */
29
+ export const AMBIENT_OVERRIDE_ENV = "CLAUDE_CODE_EXECUTABLE";
30
+
31
+ /** The SDK's script-branch suffix list — a path ending in one of these is run
32
+ * as `node|bun <path>`, anything else is spawned directly. PINNED against the
33
+ * installed sdk.mjs by check-probe-ordering; asserted here, never implemented:
34
+ * the probe refuses a script-suffixed target rather than reproducing the
35
+ * interpreter choice. */
36
+ export const SDK_SCRIPT_SUFFIXES = [".js", ".mjs", ".tsx", ".ts", ".jsx"] as const;
37
+
38
+ /** Env names the probe runner sets for the shim on the ACP child (the SDK's
39
+ * `{...process.env}` spread carries them into the CLI child's env, which is
40
+ * where the shim reads them). Single source — the shim, the runner, and the
41
+ * scrub list below must agree exactly. */
42
+ export const PROBE_SHIM_ENV = {
43
+ /** Absolute native CLI path the shim must exec — resolved HERE, never by the shim. */
44
+ target: "PROBE_SHIM_TARGET",
45
+ /** The shared NDJSON event log path (same file every other writer appends to). */
46
+ eventLog: "PROBE_SHIM_EVENT_LOG",
47
+ /** The §11-7 runId the shim stamps on every event it writes. */
48
+ runId: "PROBE_SHIM_RUN_ID",
49
+ } as const;
50
+
51
+ /** The EXACT allowlist of env vars the shim removes from the real CLI child's
52
+ * env before exec — the override itself plus every probe-private var, each by
53
+ * its literal name. Deliberately NOT a prefix/wildcard scrub: a pattern like
54
+ * `PROBE_*` would also delete operator env this probe has no claim on (GPT
55
+ * review 2026-07-29). Under the ambient-override refusal above, deletion is
56
+ * exact preservation — there is no prior operator value to restore. */
57
+ export const SHIM_SCRUB_ENV_VARS: ReadonlyArray<string> = [
58
+ AMBIENT_OVERRIDE_ENV,
59
+ PROBE_SHIM_ENV.target,
60
+ PROBE_SHIM_ENV.eventLog,
61
+ PROBE_SHIM_ENV.runId,
62
+ ];
63
+
64
+ export type ProbeCliPreconditionReason =
65
+ | "ambient-override-present"
66
+ | "target-not-absolute"
67
+ | "target-script-suffix"
68
+ | "target-missing"
69
+ | "target-not-regular-file"
70
+ | "target-not-executable";
71
+
72
+ /** A named precondition failure — the P0-style refusal §11-7-c condition 1
73
+ * requires. Callers must surface `reason` on the artifact, never soften it
74
+ * into a fallback. */
75
+ export class ProbeCliPreconditionError extends Error {
76
+ readonly reason: ProbeCliPreconditionReason;
77
+ constructor(reason: ProbeCliPreconditionReason, message: string) {
78
+ super(message);
79
+ this.name = "ProbeCliPreconditionError";
80
+ this.reason = reason;
81
+ }
82
+ }
83
+
84
+ export interface ResolvedProbeCliTarget {
85
+ path: string;
86
+ sha256: string;
87
+ }
88
+
89
+ export function hashFileSha256(path: string): string {
90
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
91
+ }
92
+
93
+ /** Refuse if the given env carries the upstream override — KEY PRESENCE is the
94
+ * predicate, an empty string included. Upstream consumers disagree about
95
+ * empty (`??` at acp-agent.js:4083 treats "" as set and passes it on; a
96
+ * truthy check treats it as unset), and the probe refuses the ambiguity
97
+ * instead of picking a side. Used twice: on the runner's own process.env
98
+ * before resolving, and on the COMPOSED spawn env of every ACP child (adapter
99
+ * launch defaults / overlay overrides could inject what process.env did not
100
+ * carry). */
101
+ export function assertNoAmbientOverride(env: Record<string, string | undefined>, context: string): void {
102
+ if (env[AMBIENT_OVERRIDE_ENV] !== undefined) {
103
+ throw new ProbeCliPreconditionError(
104
+ "ambient-override-present",
105
+ `${AMBIENT_OVERRIDE_ENV}=${JSON.stringify(env[AMBIENT_OVERRIDE_ENV])} is present (${context}) — §11-7-c ` +
106
+ "refuses to run under an ambient executable override, empty included: claudeCliPath() would return it " +
107
+ "VERBATIM (relative paths, bare PATH commands and script overrides all change launch semantics), and " +
108
+ "preserving arbitrary operator override shapes is explicitly out of probe scope. Unset it and re-run.",
109
+ );
110
+ }
111
+ }
112
+
113
+ /** Resolve the CLI target for the pair, under the §11-7-c precondition gate:
114
+ * ambient-clean env → upstream resolution → assert absolute ∧ native-branch ∧
115
+ * present → content hash. The resolver is injected so the deterministic gate
116
+ * can drive every refusal without touching the installed dist; the LIVE runner
117
+ * passes upstream `claudeCliPath` (a version-pinned deep import whose
118
+ * disappearance breaks check-probe-ordering, not a LIVE run). */
119
+ export async function resolveProbeCliTarget(opts: {
120
+ env: Record<string, string | undefined>;
121
+ resolveNative: () => Promise<string>;
122
+ }): Promise<ResolvedProbeCliTarget> {
123
+ assertNoAmbientOverride(opts.env, "probe runner env");
124
+ const path = await opts.resolveNative();
125
+ if (!isAbsolute(path)) {
126
+ throw new ProbeCliPreconditionError(
127
+ "target-not-absolute",
128
+ `resolved CLI target ${JSON.stringify(path)} is not an absolute path — a bare PATH command or relative ` +
129
+ "path resolves against the SESSION cwd at spawn time (child_process.spawn semantics), which is " +
130
+ "stimulus drift, not the pinned native binary",
131
+ );
132
+ }
133
+ if (SDK_SCRIPT_SUFFIXES.some((s) => path.endsWith(s))) {
134
+ throw new ProbeCliPreconditionError(
135
+ "target-script-suffix",
136
+ `resolved CLI target ${path} ends in a script suffix — the SDK would run it as \`node|bun <path>\`, a ` +
137
+ "different launch branch than the direct spawn this seam is specified against; the probe asserts the " +
138
+ "native branch instead of reproducing the interpreter choice",
139
+ );
140
+ }
141
+ let stat: ReturnType<typeof statSync>;
142
+ try {
143
+ stat = statSync(path);
144
+ } catch {
145
+ throw new ProbeCliPreconditionError(
146
+ "target-missing",
147
+ `resolved CLI target ${path} does not exist — refusing before a LIVE turn spends money on a spawn error`,
148
+ );
149
+ }
150
+ if (!stat.isFile()) {
151
+ throw new ProbeCliPreconditionError(
152
+ "target-not-regular-file",
153
+ `resolved CLI target ${path} is not a regular file — a directory or special file cannot be the native binary`,
154
+ );
155
+ }
156
+ try {
157
+ accessSync(path, fsConstants.X_OK);
158
+ } catch {
159
+ throw new ProbeCliPreconditionError(
160
+ "target-not-executable",
161
+ `resolved CLI target ${path} is not executable (X_OK) — spawning it would fail after the pair started`,
162
+ );
163
+ }
164
+ return { path, sha256: hashFileSha256(path) };
165
+ }