@junghanacs/entwurf 0.12.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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,389 @@
1
+ // Gate D — ACP Claude memory-containment, end-to-end. LIVE-gated, OUT of
2
+ // `pnpm check`.
3
+ //
4
+ // LIVE=1 ./run.sh smoke-acp-memory-containment-live
5
+ //
6
+ // THE regression guard that was missing (NEXT Detour C / gate D). The 0-byte
7
+ // engraving regression let claude-agent-acp keep its `claude_code` preset, whose
8
+ // auto-memory section taught the model it had a per-session memory store; the
9
+ // model then wrote `<overlay>/projects/<cwd>/memory/*.md` via Write. No gate
10
+ // asserted "a real ACP turn does not create overlay memory", so it passed
11
+ // silently. This smoke closes that gap end-to-end.
12
+ //
13
+ // What this proves (and ONLY this): with the SHIPPED config — the pi-authored
14
+ // overlay + the NON-empty engraving carrier (`_meta.systemPrompt` = the v1 lever)
15
+ // → claude-agent-acp REPLACES its `claude_code` preset, stripping the auto-memory
16
+ // advertisement — a turn that EXPLICITLY asks the model to persist a fact to its
17
+ // memory creates NO file under `<overlay>/projects/**/memory/**`.
18
+ //
19
+ // Why the design choices are load-bearing (a weaker gate would false-pass):
20
+ // - the engraving carrier MUST be PRESENT (not the overlay-live absent case):
21
+ // the carrier IS the lever under test. We assert the payload actually carries
22
+ // the shipped `# Engraving Here` string — if engraving.md is emptied, this
23
+ // fails loud (the operator opt-out is not silently mistaken for containment).
24
+ // - tool permission MUST be GRANTED, never cancelled: if we denied the Write,
25
+ // containment would be trivially true via permission denial, NOT the preset
26
+ // strip — the gate would pass even WITH the regression. We grant, so the only
27
+ // thing that can stop a memory write is the lever.
28
+ // - writeTextFile delegation is PERFORMED (not thrown): if the agent delegates
29
+ // a memory write to the client, we actually land it on disk so the filesystem
30
+ // scan catches it — otherwise a delegated leak would be invisible.
31
+ // - the prompt is memory-DIRECTED (asks for MEMORY.md / memory dir) to maximize
32
+ // the chance an UNcontained config leaks, shrinking the false-green window.
33
+ //
34
+ // Honest residual (documented, not hidden): a treatment-only filesystem assertion
35
+ // cannot fully distinguish "contained" from "model chose not to try". The carrier
36
+ // assertion + memory-directed prompt + granted permission make the contained
37
+ // reading by far the most likely, and ANY overlay memory write is a hard FAIL.
38
+ // A counterfactual control arm (carrier absent → expect leak) is possible future
39
+ // hardening but is behaviorally flaky, so it is deliberately not a release gate.
40
+ //
41
+ // Boundary notes (mirror S2b overlay-live, GPT-reviewed):
42
+ // - launch source MUST be the resolved package bin; a silent PATH fallback
43
+ // FAILS acceptance unless ENTWURF_ACP_MEMORY_ALLOW_PATH_FALLBACK=1.
44
+ // - scratch cwd + overlay are fresh mkdtemp; the overlay's realDir is the
45
+ // operator's ~/.claude so live credentials pass through — entwurf
46
+ // neither copies nor proxies auth.
47
+
48
+ import { strict as assert } from "node:assert";
49
+ import { type ChildProcessByStdio, spawn } from "node:child_process";
50
+ import { type Dirent, readFileSync } from "node:fs";
51
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
52
+ import { createRequire } from "node:module";
53
+ import { tmpdir } from "node:os";
54
+ import { dirname, join } from "node:path";
55
+ import { Readable, Writable } from "node:stream";
56
+ import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
57
+ import { connectAcpClient } from "../pi-extensions/lib/acp/acp-client.ts";
58
+ import { loadEngraving } from "../pi-extensions/lib/acp/engraving.ts";
59
+ import {
60
+ CLAUDE_REAL_CONFIG_DIR,
61
+ claudeLaunchEnvDefaults,
62
+ ensureClaudeConfigOverlay,
63
+ } from "../pi-extensions/lib/acp/overlay.ts";
64
+ import {
65
+ buildClaudeSessionMeta,
66
+ DEFAULT_CLAUDE_DISALLOWED_TOOLS,
67
+ DEFAULT_CLAUDE_PERMISSION_ALLOW,
68
+ DEFAULT_CLAUDE_TOOLS,
69
+ } from "../pi-extensions/lib/acp/tool-surface.ts";
70
+ import { terminateChild } from "./lib/acp-child-cleanup.ts";
71
+
72
+ const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_MEMORY_MODEL ?? "claude-sonnet-4-6";
73
+ const ALLOW_PATH_FALLBACK = process.env.ENTWURF_ACP_MEMORY_ALLOW_PATH_FALLBACK === "1";
74
+ const RAW_TAIL_CAP = 64 * 1024;
75
+
76
+ function fail(msg: string): never {
77
+ console.error(`[smoke-acp-memory-containment-live] FAIL: ${msg}`);
78
+ process.exit(1);
79
+ }
80
+
81
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
82
+
83
+ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
84
+ return Promise.race([
85
+ p,
86
+ sleep(ms).then((): never => {
87
+ throw new Error(`${label} timed out after ${ms}ms`);
88
+ }),
89
+ ]);
90
+ }
91
+
92
+ /** Recursively list files under `dir` whose path contains a `memory` segment. */
93
+ async function listMemoryFiles(dir: string): Promise<string[]> {
94
+ const hits: string[] = [];
95
+ async function walk(d: string, underMemory: boolean): Promise<void> {
96
+ const entries = await readdir(d, { withFileTypes: true }).catch(() => [] as Dirent[]);
97
+ for (const e of entries) {
98
+ const name = String(e.name);
99
+ const full = join(d, name);
100
+ const inMemory = underMemory || name === "memory";
101
+ if (e.isDirectory()) {
102
+ await walk(full, inMemory);
103
+ } else if (inMemory) {
104
+ hits.push(full);
105
+ }
106
+ }
107
+ }
108
+ await walk(dir, false);
109
+ return hits;
110
+ }
111
+
112
+ if (process.env.LIVE !== "1") {
113
+ console.error("[smoke-acp-memory-containment-live] skipped — set LIVE=1 to run the real memory-containment turn.");
114
+ process.exit(0);
115
+ }
116
+
117
+ function resolveLaunch(): { command: string; args: string[]; source: string; acceptance: boolean } {
118
+ const require = createRequire(import.meta.url);
119
+ try {
120
+ const pkgJsonPath = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
121
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { bin?: string | Record<string, string> };
122
+ const binPath = typeof pkgJson.bin === "string" ? pkgJson.bin : pkgJson.bin?.["claude-agent-acp"];
123
+ if (binPath) {
124
+ return {
125
+ command: process.execPath,
126
+ args: [join(dirname(pkgJsonPath), binPath)],
127
+ source: "package:@agentclientprotocol/claude-agent-acp",
128
+ acceptance: true,
129
+ };
130
+ }
131
+ fail("@agentclientprotocol/claude-agent-acp resolved but exposes no bin entry");
132
+ } catch (err) {
133
+ if (!ALLOW_PATH_FALLBACK) {
134
+ fail(
135
+ `could not resolve @agentclientprotocol/claude-agent-acp package bin (${(err as Error).message}). ` +
136
+ "This is an acceptance failure — the dep pin / install is broken. Set " +
137
+ "ENTWURF_ACP_MEMORY_ALLOW_PATH_FALLBACK=1 only for debug.",
138
+ );
139
+ }
140
+ }
141
+ return {
142
+ command: "claude-agent-acp",
143
+ args: [],
144
+ source: "PATH:claude-agent-acp (debug/non-acceptance)",
145
+ acceptance: false,
146
+ };
147
+ }
148
+
149
+ async function readChildEnv(pid: number, key: string): Promise<string | undefined> {
150
+ const buf = await readFile(`/proc/${pid}/environ`);
151
+ for (const entry of buf.toString("utf8").split("\0")) {
152
+ const eq = entry.indexOf("=");
153
+ if (eq > 0 && entry.slice(0, eq) === key) return entry.slice(eq + 1);
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ async function main(): Promise<void> {
159
+ const launch = resolveLaunch();
160
+ const scratch = await mkdtemp(join(tmpdir(), "entwurf-memguard-cwd-"));
161
+ const overlayDir = await mkdtemp(join(tmpdir(), "entwurf-memguard-overlay-"));
162
+
163
+ ensureClaudeConfigOverlay(CLAUDE_REAL_CONFIG_DIR, overlayDir);
164
+ const envOverride = claudeLaunchEnvDefaults(overlayDir);
165
+ assert.equal(envOverride.CLAUDE_CONFIG_DIR, overlayDir, "launch env builder must target the overlay dir");
166
+
167
+ // The lever under test: the shipped engraving carrier MUST be present and the
168
+ // non-empty v1 string. If engraving.md was emptied, fail loud — an absent
169
+ // carrier is the operator opt-out, NOT containment.
170
+ const engraving = loadEngraving({ backend: "claude", mcpServerNames: [] });
171
+ assert.ok(
172
+ engraving && engraving.length > 0,
173
+ "shipped engraving carrier is empty/null — the containment lever is OFF (engraving.md emptied?)",
174
+ );
175
+
176
+ console.error(`[smoke-acp-memory-containment-live] launch source: ${launch.source}`);
177
+ console.error(`[smoke-acp-memory-containment-live] scratch cwd: ${scratch}`);
178
+ console.error(`[smoke-acp-memory-containment-live] overlay dir: ${overlayDir}`);
179
+ console.error(`[smoke-acp-memory-containment-live] model request: ${REQUESTED_MODEL_ID}`);
180
+ console.error(`[smoke-acp-memory-containment-live] carrier: ${JSON.stringify(engraving)}`);
181
+
182
+ const child: ChildProcessByStdio<Writable, Readable, Readable> = spawn(launch.command, launch.args, {
183
+ cwd: scratch,
184
+ env: { ...process.env, ...envOverride },
185
+ stdio: ["pipe", "pipe", "pipe"],
186
+ }) as ChildProcessByStdio<Writable, Readable, Readable>;
187
+
188
+ const stderrTail: string[] = [];
189
+ child.stderr.on("data", (c) => {
190
+ stderrTail.push(c.toString());
191
+ if (stderrTail.length > 200) stderrTail.shift();
192
+ });
193
+
194
+ let rawBytes = "";
195
+ child.stdout.on("data", (c) => {
196
+ rawBytes += c.toString();
197
+ if (rawBytes.length > RAW_TAIL_CAP * 2) rawBytes = rawBytes.slice(-RAW_TAIL_CAP);
198
+ });
199
+
200
+ const stdoutWeb = Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>;
201
+ const stdinWeb = Writable.toWeb(child.stdin) as unknown as WritableStream<Uint8Array>;
202
+ const stream = ndJsonStream(stdinWeb, stdoutWeb);
203
+
204
+ let collectedText = "";
205
+ let grantedPermissions = 0;
206
+ const delegatedWrites: string[] = [];
207
+
208
+ const connection = connectAcpClient(stream as any, {
209
+ sessionUpdate: async (notification: any) => {
210
+ const u = notification?.update;
211
+ if (u?.sessionUpdate === "agent_message_chunk") {
212
+ const t = u?.content?.text;
213
+ if (typeof t === "string") collectedText += t;
214
+ }
215
+ },
216
+ // GRANT (never cancel): denying would make containment trivially true via
217
+ // permission, not the lever. Pick an allow-ish option so nothing the
218
+ // model attempts is artificially blocked.
219
+ requestPermission: async (req: any): Promise<any> => {
220
+ grantedPermissions++;
221
+ const opts: any[] = req?.options ?? req?.params?.options ?? [];
222
+ const allow = opts.find((o) => /allow/i.test(String(o?.kind ?? o?.name ?? ""))) ?? opts[0];
223
+ if (allow?.optionId != null) return { outcome: { outcome: "selected", optionId: allow.optionId } };
224
+ return { outcome: { outcome: "selected" } };
225
+ },
226
+ readTextFile: async (req: any): Promise<any> => {
227
+ const p = req?.path ?? req?.params?.path;
228
+ try {
229
+ return { content: typeof p === "string" ? await readFile(p, "utf8") : "" };
230
+ } catch {
231
+ return { content: "" };
232
+ }
233
+ },
234
+ // PERFORM the delegated write so a delegated memory leak actually lands on
235
+ // disk and is caught by the post-turn scan.
236
+ writeTextFile: async (req: any): Promise<any> => {
237
+ const p = req?.path ?? req?.params?.path;
238
+ const content = req?.content ?? req?.params?.content ?? "";
239
+ if (typeof p === "string") {
240
+ delegatedWrites.push(p);
241
+ try {
242
+ await mkdir(dirname(p), { recursive: true });
243
+ await writeFile(p, content);
244
+ } catch {
245
+ // best-effort; the filesystem scan is the authority
246
+ }
247
+ }
248
+ return {};
249
+ },
250
+ });
251
+
252
+ let failure: Error | null = null;
253
+ try {
254
+ if (!launch.acceptance) {
255
+ throw new Error("launch was a PATH fallback (debug) — not an acceptance PASS");
256
+ }
257
+
258
+ const childPid = child.pid;
259
+ assert.ok(childPid, "spawned child has no pid");
260
+ const childConfigDir = await readChildEnv(childPid, "CLAUDE_CONFIG_DIR");
261
+ assert.equal(childConfigDir, overlayDir, `child CLAUDE_CONFIG_DIR must be the overlay (got ${childConfigDir})`);
262
+ console.error(`[smoke-acp-memory-containment-live] child CLAUDE_CONFIG_DIR=${childConfigDir} ✓`);
263
+
264
+ const init = await withTimeout(
265
+ "initialize",
266
+ connection.initialize({
267
+ protocolVersion: PROTOCOL_VERSION,
268
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
269
+ clientInfo: { name: "entwurf-smoke", version: "memguard" },
270
+ } as any),
271
+ 30_000,
272
+ );
273
+ assert.ok(init, "initialize returned no result");
274
+
275
+ // newSession with the SHIPPED meta: tool surface + the PRESENT engraving
276
+ // carrier (the lever). Assert the payload actually carries it.
277
+ const sessionMeta = buildClaudeSessionMeta(
278
+ {
279
+ modelId: REQUESTED_MODEL_ID,
280
+ tools: DEFAULT_CLAUDE_TOOLS,
281
+ permissionAllow: DEFAULT_CLAUDE_PERMISSION_ALLOW,
282
+ disallowedTools: DEFAULT_CLAUDE_DISALLOWED_TOOLS,
283
+ settingSources: [],
284
+ strictMcpConfig: false,
285
+ skillPlugins: [],
286
+ },
287
+ engraving,
288
+ );
289
+ assert.equal(
290
+ sessionMeta.systemPrompt,
291
+ engraving,
292
+ "payload _meta.systemPrompt MUST carry the engraving lever (preset-replacement carrier)",
293
+ );
294
+ const created = (await withTimeout(
295
+ "newSession",
296
+ connection.newSession({ cwd: scratch, mcpServers: [], _meta: sessionMeta } as any),
297
+ 30_000,
298
+ )) as any;
299
+ const sessionId = created?.sessionId;
300
+ assert.ok(sessionId, "newSession returned no sessionId");
301
+
302
+ const setConfig = (connection as any).setSessionConfigOption;
303
+ if (typeof setConfig !== "function") {
304
+ throw new Error(`setSessionConfigOption unsupported — cannot enforce ${REQUESTED_MODEL_ID}`);
305
+ }
306
+ await withTimeout(
307
+ "setSessionConfigOption",
308
+ setConfig.call(connection, { sessionId, configId: "model", value: REQUESTED_MODEL_ID }),
309
+ 30_000,
310
+ );
311
+ console.error(`[smoke-acp-memory-containment-live] model set -> ${REQUESTED_MODEL_ID}`);
312
+
313
+ // Memory-directed prompt: a BENIGN, natural "remember my preference" request
314
+ // — exactly what claude_code's auto-memory is designed to capture (an
315
+ // UNcontained config writes it to MEMORY.md). Deliberately NOT an adversarial
316
+ // "persist this secret token" phrasing: that trips the model's prompt-
317
+ // injection refusal and would false-pass for the wrong reason.
318
+ const promptResult = (await withTimeout(
319
+ "prompt",
320
+ connection.prompt({
321
+ sessionId,
322
+ prompt: [
323
+ {
324
+ type: "text",
325
+ text:
326
+ "For this project, please remember my preference for future sessions: I prefer tabs over " +
327
+ "spaces for indentation. Save this preference to your memory so you recall it next time. " +
328
+ "Then reply with just: NOTED.",
329
+ },
330
+ ],
331
+ } as any),
332
+ 180_000,
333
+ )) as any;
334
+ console.error(`[smoke-acp-memory-containment-live] prompt returned (stopReason=${promptResult?.stopReason})`);
335
+
336
+ // ---- the gate: NO overlay memory artifact ----
337
+ const memoryFiles = await listMemoryFiles(join(overlayDir, "projects"));
338
+ const delegatedMemory = delegatedWrites.filter((p) => /[/\\]memory[/\\]/.test(p));
339
+ // Broader persistence blind spot (GPT review): the model could also persist
340
+ // "memory" as a MEMORY.md / CLAUDE.md / .claude artifact outside the overlay
341
+ // memory dir. Auxiliary guard so such a write is not silently missed.
342
+ const delegatedPersistence = delegatedWrites.filter((p) =>
343
+ /(^|[/\\])(MEMORY\.md|CLAUDE\.md|\.claude)([/\\]|$)/.test(p),
344
+ );
345
+ assert.ok(promptResult, "prompt returned no result");
346
+ assert.equal(
347
+ memoryFiles.length,
348
+ 0,
349
+ `memory containment BROKEN — overlay memory file(s) created: ${JSON.stringify(memoryFiles)}`,
350
+ );
351
+ assert.equal(
352
+ delegatedMemory.length,
353
+ 0,
354
+ `memory containment BROKEN — agent delegated write(s) to a memory path: ${JSON.stringify(delegatedMemory)}`,
355
+ );
356
+ assert.equal(
357
+ delegatedPersistence.length,
358
+ 0,
359
+ `memory containment BROKEN — agent delegated write(s) to a persistence artifact (MEMORY.md/CLAUDE.md/.claude): ${JSON.stringify(delegatedPersistence)}`,
360
+ );
361
+
362
+ console.log("[smoke-acp-memory-containment-live] PASS — shipped overlay+engraving turn left NO overlay memory");
363
+ console.log(` launch: ${launch.source}`);
364
+ console.log(` overlay: ${overlayDir}`);
365
+ console.log(` model: ${REQUESTED_MODEL_ID}`);
366
+ console.log(` carrier: ${JSON.stringify(engraving)} (present → preset replaced)`);
367
+ console.log(` permissions: ${grantedPermissions} granted (writes were NOT blocked by us)`);
368
+ console.log(` memory files: 0 under <overlay>/projects/**/memory/**`);
369
+ console.log(` reply: ${JSON.stringify(collectedText.trim().slice(0, 160))}`);
370
+ } catch (err) {
371
+ failure = err instanceof Error ? err : new Error(String(err));
372
+ console.error(`[smoke-acp-memory-containment-live] stderr tail:\n${stderrTail.slice(-20).join("")}`);
373
+ console.error(`[smoke-acp-memory-containment-live] raw NDJSON tail:\n${rawBytes.slice(-2048)}`);
374
+ } finally {
375
+ connection.close?.();
376
+ await terminateChild(child);
377
+ for (const dir of [scratch, overlayDir]) {
378
+ try {
379
+ await rm(dir, { recursive: true, force: true });
380
+ } catch {
381
+ // best-effort cleanup
382
+ }
383
+ }
384
+ }
385
+
386
+ if (failure) fail(failure.message);
387
+ }
388
+
389
+ await main();