@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.
- package/AGENTS.md +2 -1
- package/BASELINE.md +45 -6
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +4 -2
- package/DELIVERY.md +1 -1
- package/README.md +20 -5
- package/VERIFY.md +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
- package/package.json +4 -2
- package/pi/settings.reference.json +1 -1
- package/pi-extensions/acp-provider.ts +20 -10
- package/pi-extensions/lib/acp/augment.ts +60 -2
- package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
- package/pi-extensions/lib/acp/backend.ts +5 -1
- package/pi-extensions/lib/acp/config.ts +19 -5
- package/pi-extensions/lib/acp/engraving.ts +3 -1
- package/pi-extensions/lib/acp/event-mapper.ts +10 -3
- package/pi-extensions/lib/acp/models.ts +69 -7
- package/pi-extensions/lib/acp/overlay.ts +234 -5
- package/pi-extensions/lib/acp/tool-surface.ts +12 -4
- package/run.sh +152 -21
- package/scripts/check-acp-cortex.ts +668 -0
- package/scripts/check-acp-provider-surface.ts +50 -6
- package/scripts/check-acp-session-reuse.ts +64 -1
- package/scripts/check-gate-qualification.ts +2 -0
- package/scripts/check-probe-cli-shim.ts +879 -0
- package/scripts/check-probe-ordering.ts +2450 -0
- package/scripts/check-shell-quote.ts +4 -4
- package/scripts/fixtures/probe-cli-shim +20 -0
- package/scripts/fixtures/probe-mcp-server.ts +168 -12
- package/scripts/lib/probe-acp-turn.ts +207 -0
- package/scripts/lib/probe-cli-shim.ts +464 -0
- package/scripts/lib/probe-cli-target.ts +165 -0
- package/scripts/lib/probe-event-log.ts +383 -0
- package/scripts/lib/probe-verdict.ts +1213 -0
- package/scripts/mutants/acp-cortex.json +196 -0
- package/scripts/mutants/probe-ordering.json +1032 -0
- package/scripts/smoke-acp-cortex-live.ts +392 -0
- 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
|
+
}
|