@junghanacs/entwurf 0.12.9 → 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 (100) hide show
  1. package/AGENTS.md +135 -248
  2. package/BASELINE.md +46 -7
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +5 -4
  6. package/README.md +46 -18
  7. package/VERIFY.md +10 -6
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +10 -4
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
  28. package/mcp/entwurf-bridge/src/index.ts +190 -82
  29. package/package.json +7 -3
  30. package/pi/settings.reference.json +1 -1
  31. package/pi-extensions/acp-provider.ts +20 -10
  32. package/pi-extensions/entwurf-control.ts +69 -27
  33. package/pi-extensions/lib/acp/augment.ts +60 -2
  34. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  35. package/pi-extensions/lib/acp/backend.ts +5 -1
  36. package/pi-extensions/lib/acp/config.ts +19 -5
  37. package/pi-extensions/lib/acp/engraving.ts +3 -1
  38. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  39. package/pi-extensions/lib/acp/models.ts +69 -7
  40. package/pi-extensions/lib/acp/overlay.ts +234 -3
  41. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  42. package/pi-extensions/lib/entwurf-core.ts +26 -9
  43. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  44. package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
  45. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  46. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  47. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  48. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  49. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  50. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  51. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  52. package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
  53. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
  54. package/pi-extensions/lib/meta-session.ts +17 -20
  55. package/run.sh +200 -62
  56. package/scripts/agy-bridge-config.py +249 -49
  57. package/scripts/agy-bridge.sh +59 -14
  58. package/scripts/check-acp-carrier-augment.ts +34 -2
  59. package/scripts/check-acp-cortex.ts +668 -0
  60. package/scripts/check-acp-provider-surface.ts +50 -6
  61. package/scripts/check-acp-sdk-surface.ts +22 -11
  62. package/scripts/check-acp-session-reuse.ts +64 -1
  63. package/scripts/check-agy-permission-matrix.py +655 -0
  64. package/scripts/check-entwurf-deliverability.ts +9 -6
  65. package/scripts/check-entwurf-fact-provider.ts +7 -7
  66. package/scripts/check-entwurf-facts.ts +12 -12
  67. package/scripts/check-entwurf-resume-args.ts +22 -60
  68. package/scripts/check-entwurf-self-address.ts +187 -4
  69. package/scripts/check-entwurf-v2-contract.ts +2 -2
  70. package/scripts/check-entwurf-v2-production.ts +5 -5
  71. package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
  72. package/scripts/check-entwurf-v2-surface.ts +327 -14
  73. package/scripts/check-gate-qualification.ts +787 -0
  74. package/scripts/check-meta-identity-consumers.ts +1 -1
  75. package/scripts/check-probe-cli-shim.ts +879 -0
  76. package/scripts/check-probe-ordering.ts +2450 -0
  77. package/scripts/check-shell-quote.ts +5 -4
  78. package/scripts/fixtures/probe-cli-shim +20 -0
  79. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  80. package/scripts/lib/mutation-qualify.ts +794 -0
  81. package/scripts/lib/probe-acp-turn.ts +207 -0
  82. package/scripts/lib/probe-cli-shim.ts +464 -0
  83. package/scripts/lib/probe-cli-target.ts +165 -0
  84. package/scripts/lib/probe-event-log.ts +383 -0
  85. package/scripts/lib/probe-verdict.ts +1213 -0
  86. package/scripts/mutants/acp-augment.json +30 -0
  87. package/scripts/mutants/acp-cortex.json +196 -0
  88. package/scripts/mutants/agy-permission.json +144 -0
  89. package/scripts/mutants/meta-identity.json +17 -0
  90. package/scripts/mutants/probe-ordering.json +1032 -0
  91. package/scripts/mutants/self-address.json +59 -0
  92. package/scripts/mutants/v2-surface.json +87 -0
  93. package/scripts/smoke-acp-cortex-live.ts +392 -0
  94. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  95. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  96. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  97. package/scripts/smoke-agy-install-state.sh +205 -20
  98. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  99. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  100. package/scripts/check-entwurf-mailbox-guard.ts +0 -266
@@ -0,0 +1,207 @@
1
+ // §11-7 ordering probe — the ACP-side turn driver (probe-dedicated raw client).
2
+ //
3
+ // §11-7 names two client seams and requires picking one explicitly. This lane
4
+ // picks the RAW CLIENT (not the stdio wire proxy), so it carries §11-7's bound:
5
+ // it is NOT the production path and must be gated as issuing the SAME calls,
6
+ // arguments, and order as the backend's real sequence (backend.ts runNewTurn) —
7
+ // otherwise the probe measures a lookalike. check-probe-ordering is that gate;
8
+ // it drives this exact function over a recording fake connection.
9
+ //
10
+ // Production values arrive through the `adapter` / `enrichMcpServers` seams —
11
+ // the LIVE runner tsc-emits pi-extensions and injects the REAL claudeAdapter +
12
+ // enrichMcpServersWithEnvelope (backend-adapter.ts carries `.js` value imports,
13
+ // so it is not strip-types-loadable from here; the emit-then-import pattern is
14
+ // the house one, see check-acp-session-reuse). This module therefore holds ONLY
15
+ // the orchestration backend.ts owns: phase order, arg assembly, timeouts, and
16
+ // phase-attributed failure — each pinned against backend.ts source by the gate
17
+ // so production drift turns the gate red instead of silently unbinding the probe.
18
+ //
19
+ // Every phase start/end is stamped into the shared NDJSON log — including
20
+ // set-model, because §11-7 measured that dropping it misreads an enforceModel
21
+ // stall as C or D.
22
+
23
+ import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
24
+ import type { AcpConnectionLike } from "../../pi-extensions/lib/acp/acp-client.ts";
25
+ import type { ResolvedAcpConfig } from "../../pi-extensions/lib/acp/config.ts";
26
+ import { PROBE_EVENTS, type ProbeEventName } from "./probe-event-log.ts";
27
+
28
+ export interface ProbePhaseTimeouts {
29
+ initializeMs: number;
30
+ newSessionMs: number;
31
+ setModelMs: number;
32
+ promptMs: number;
33
+ }
34
+
35
+ // Phase timeouts — MUST equal backend.ts's INITIALIZE/NEW_SESSION/SET_MODEL/
36
+ // PROMPT_TIMEOUT_MS (check-probe-ordering pins them against the source). §11-7:
37
+ // a D verdict is only readable against these boundaries, so the probe may not
38
+ // invent its own.
39
+ export const PROBE_PHASE_TIMEOUTS: ProbePhaseTimeouts = {
40
+ initializeMs: 30_000,
41
+ newSessionMs: 30_000,
42
+ setModelMs: 30_000,
43
+ promptMs: 600_000,
44
+ };
45
+
46
+ /** The two adapter methods the turn drives — satisfied by the REAL claudeAdapter
47
+ * (emitted) in the LIVE runner and by recording stubs in the gate. */
48
+ export interface ProbeAdapterSeam {
49
+ buildSessionMeta(
50
+ params: { modelId: string; nativeModelId: string; config: ResolvedAcpConfig },
51
+ carrier: string | null,
52
+ ): Record<string, unknown> | undefined;
53
+ enforceModel(params: {
54
+ connection: AcpConnectionLike;
55
+ acpSessionId: string;
56
+ modelId: string;
57
+ nativeModelId: string;
58
+ }): Promise<void>;
59
+ }
60
+
61
+ /** Production's enrichMcpServersWithEnvelope shape (config.ts). */
62
+ export type ProbeMcpEnricher = (
63
+ servers: ResolvedAcpConfig["mcpServers"],
64
+ envelope: { modelId?: string; piSessionId?: string },
65
+ ) => ResolvedAcpConfig["mcpServers"];
66
+
67
+ /** The turn phases in production order. `initialize` failures are P0/I0 input
68
+ * (run-invalidating), the rest map to phase-qualified D readings. */
69
+ export type ProbeTurnPhase = "initialize" | "newSession" | "enforceModel" | "prompt";
70
+
71
+ /** A phase-attributed turn failure — the classifier needs WHICH wire step died,
72
+ * never just that the turn died. */
73
+ export class ProbePhaseError extends Error {
74
+ readonly phase: ProbeTurnPhase;
75
+ readonly timedOut: boolean;
76
+
77
+ constructor(phase: ProbeTurnPhase, cause: Error, timedOut: boolean) {
78
+ super(`${phase}: ${cause.message}`);
79
+ this.phase = phase;
80
+ this.timedOut = timedOut;
81
+ this.cause = cause;
82
+ }
83
+ }
84
+
85
+ class PhaseTimeoutError extends Error {}
86
+
87
+ // Same guard shape as backend.ts withTimeout / smoke-acp-raw-turn-live: always
88
+ // clear the timer so a passing turn cannot pin the event loop on a stale timer.
89
+ function withPhaseTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
90
+ let timer: ReturnType<typeof setTimeout> | undefined;
91
+ const timeout = new Promise<never>((_, reject) => {
92
+ timer = setTimeout(() => reject(new PhaseTimeoutError(`${label} timed out after ${ms}ms`)), ms);
93
+ });
94
+ return Promise.race([p, timeout]).finally(() => {
95
+ if (timer) clearTimeout(timer);
96
+ });
97
+ }
98
+
99
+ export interface ProbeTurnParams {
100
+ cwd: string;
101
+ modelId: string;
102
+ nativeModelId: string;
103
+ config: ResolvedAcpConfig;
104
+ /** Production carrier (adapter.loadCarrier result) — folded into `_meta` by
105
+ * buildSessionMeta so the probe's newSession matches production shape. */
106
+ carrier: string | null;
107
+ promptText: string;
108
+ adapter: ProbeAdapterSeam;
109
+ enrichMcpServers: ProbeMcpEnricher;
110
+ log: (event: ProbeEventName, payload?: Record<string, unknown>) => void;
111
+ timeouts?: ProbePhaseTimeouts;
112
+ }
113
+
114
+ export interface ProbeTurnResult {
115
+ acpSessionId: string;
116
+ stopReason: string | undefined;
117
+ }
118
+
119
+ async function runPhase<T>(
120
+ phase: ProbeTurnPhase,
121
+ startEvent: ProbeEventName,
122
+ endEvent: ProbeEventName,
123
+ log: ProbeTurnParams["log"],
124
+ ms: number,
125
+ body: () => Promise<T>,
126
+ ): Promise<T> {
127
+ log(startEvent, { timeoutMs: ms });
128
+ try {
129
+ const result = await withPhaseTimeout(phase, body(), ms);
130
+ log(endEvent, { ok: true });
131
+ return result;
132
+ } catch (err) {
133
+ const error = err instanceof Error ? err : new Error(String(err));
134
+ const timedOut = error instanceof PhaseTimeoutError;
135
+ log(endEvent, { ok: false, timedOut, error: error.message });
136
+ throw new ProbePhaseError(phase, error, timedOut);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Drive ONE production-shaped turn over an already-connected ACP connection:
142
+ *
143
+ * initialize → newSession → enforceModel(setSessionConfigOption) → prompt
144
+ *
145
+ * The connection seam is `AcpConnectionLike` — the same seam backend.ts drives —
146
+ * so the sameness gate runs this function over a recording fake with zero
147
+ * launch/IO coupling.
148
+ */
149
+ export async function driveProbeTurn(connection: AcpConnectionLike, params: ProbeTurnParams): Promise<ProbeTurnResult> {
150
+ const t = params.timeouts ?? PROBE_PHASE_TIMEOUTS;
151
+ const log = params.log;
152
+
153
+ await runPhase("initialize", PROBE_EVENTS.initializeStart, PROBE_EVENTS.initializeEnd, log, t.initializeMs, () =>
154
+ connection.initialize({
155
+ protocolVersion: PROTOCOL_VERSION,
156
+ clientCapabilities: {},
157
+ clientInfo: { name: "entwurf", version: "s2d" },
158
+ }),
159
+ );
160
+
161
+ // _meta + wire servers exactly as backend.ts assembles them (S2g/§9-4): the
162
+ // injected production buildSessionMeta (omitted key when undefined) + the
163
+ // injected production envelope enrichment over the resolved config.
164
+ const sessionMeta = params.adapter.buildSessionMeta(
165
+ { modelId: params.modelId, nativeModelId: params.nativeModelId, config: params.config },
166
+ params.carrier,
167
+ );
168
+ const wireMcpServers = params.enrichMcpServers(params.config.mcpServers, {
169
+ modelId: params.modelId,
170
+ piSessionId: process.env.PI_SESSION_ID?.trim() || undefined,
171
+ });
172
+ const newSessionArgs =
173
+ sessionMeta === undefined
174
+ ? { cwd: params.cwd, mcpServers: wireMcpServers }
175
+ : { cwd: params.cwd, mcpServers: wireMcpServers, _meta: sessionMeta };
176
+
177
+ const created = await runPhase(
178
+ "newSession",
179
+ PROBE_EVENTS.newSessionStart,
180
+ PROBE_EVENTS.newSessionEnd,
181
+ log,
182
+ t.newSessionMs,
183
+ () => connection.newSession(newSessionArgs),
184
+ );
185
+ const acpSessionId = created?.sessionId;
186
+ if (!acpSessionId) {
187
+ throw new ProbePhaseError("newSession", new Error("newSession returned no sessionId"), false);
188
+ }
189
+
190
+ await runPhase("enforceModel", PROBE_EVENTS.setModelStart, PROBE_EVENTS.setModelEnd, log, t.setModelMs, () =>
191
+ params.adapter.enforceModel({
192
+ connection,
193
+ acpSessionId,
194
+ modelId: params.modelId,
195
+ nativeModelId: params.nativeModelId,
196
+ }),
197
+ );
198
+
199
+ const promptResult = await runPhase("prompt", PROBE_EVENTS.promptStart, PROBE_EVENTS.promptEnd, log, t.promptMs, () =>
200
+ connection.prompt({
201
+ sessionId: acpSessionId,
202
+ prompt: [{ type: "text", text: params.promptText }],
203
+ }),
204
+ );
205
+
206
+ return { acpSessionId, stopReason: promptResult?.stopReason };
207
+ }
@@ -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
+ }