@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.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- 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();
|