Files
stack/agents/darkwing/work/s6-review/r3/interdiff.patch
T
jason.woltjeandClaude Opus 5.5 974da6edd5 docs(review): row 41 round 3 review packet, changes (darkwing)
R4 holds. R5: a relative Pi path resolves against the given workspace in
the gate and against the real path in Pi, so a workspace on a symlink lets
.. reach outside. Comment 27032, queue rev 271.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 22:19:02 -05:00

319 lines
17 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- r2wt/packages/cli/tests/host.test.mjs 2026-10-09 22:01:00.286672229 -0500
+++ wt/packages/cli/tests/host.test.mjs 2026-10-09 22:00:50.898562239 -0500
@@ -262,6 +262,29 @@
}
});
+test("a broker reply with no request waiting breaks the channel and the host exits 1", { timeout: 30000 }, async (t) => {
+ // A request sent to the real broker past the host's queue: its reply
+ // arrives when nothing is waiting (Darkwing round 2, Mr).
+ const children = [];
+ const onChild = ({ process: child }) => children.push(child);
+ subscribe("child_process", onChild);
+ t.after(() => unsubscribe("child_process", onChild));
+ const root = tmp(t);
+ const f = fixture(root);
+ makeDeployment(root);
+ const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
+ const logs = [];
+ const host = await startHost({ boot, business: "acme", log: (l) => logs.push(l) });
+ t.after(() => host.close(0));
+ const record = { business: "acme", role: "coder", run: "coder-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) };
+ const coder = await host.bindLaunch(record);
+ children.find((c) => c.pid === host.pids.broker).send({ op: "identity", cap: coder.cap, id: 1_000_000 });
+ const late = new Promise((r) => setTimeout(r, 5000, "still running").unref());
+ assert.equal(await Promise.race([host.done, late]), 1);
+ assert.ok(logs.includes("broker channel broken (reply with no request waiting); stopping the host"), logs.join("\n"));
+ await assert.rejects(host.op({ op: "identity", cap: coder.cap }), (e) => e.code === "broker-channel-broken");
+});
+
test("a notifier that refuses stops the broker and the host refuses with exit 3", async (t) => {
const root = tmp(t);
const f = fixture(root);
--- r2wt/packages/cli/tests/launcher.test.mjs 2026-10-09 22:01:00.286672229 -0500
+++ wt/packages/cli/tests/launcher.test.mjs 2026-10-09 22:00:50.898562239 -0500
@@ -103,7 +103,7 @@
const log = (f) => readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
-test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip }, async (t) => {
+test("bus start --pm: the PM runs under its own PID namespace, registered, with a manifest", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, host, cto, close } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
assert.equal(statSync(sock).mode & 0o777, 0o600);
@@ -173,7 +173,7 @@
assert.equal(end.exitCode, 0);
});
-test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip }, async (t) => {
+test("the PM launches a coder through its launch tool; the coder answers; refusals name their code", { skip, timeout: 30000 }, async (t) => {
const { f, launcher, client, cto } = await setup(t);
const sock = launchSocketPath(f.dataRoot);
const pm = await launcher.launchPm();
@@ -234,7 +234,7 @@
assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run));
});
-test("a runner that stops at once ends its launch with the runner's reason", { skip }, async (t) => {
+test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => {
// pm's tracker token has expired: the runner's founder-credential stop
// exits 20 before role.claim, and the host records founder-credentials.
const { f, launcher } = await setup(t, (doc) => {
@@ -250,7 +250,7 @@
});
for (const exited of [false, true]) {
- test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip }, async (t) => {
+ test(`a host that died hard leaves its sessions to the next host, which ${exited ? "finds one already exited (23)" : "kills them"} and ends their runs so the role can be launched again`, { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
const { f } = given;
const old = spawn(process.execPath, [HOST, given.adapter], { env: f.env, stdio: ["ignore", "pipe", "inherit"] });
@@ -311,7 +311,7 @@
});
}
-test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", async (t) => {
+test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
const given = prepare(t);
const file = sessionsFile(given.f.dataRoot);
mkdirSync(join(given.f.dataRoot, "bus-host"), { mode: 0o700 });
@@ -323,7 +323,7 @@
const within = (p, ms, what) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(`${what} took over ${ms} ms`)), ms).unref())]);
-test("an over-long launch request is refused at once, not at the 10 s idle timeout", async (t) => {
+test("an over-long launch request is refused at once, not at the 10 s idle timeout", { timeout: 30000 }, async (t) => {
const { f } = await setup(t);
const s = connect(launchSocketPath(f.dataRoot));
t.after(() => s.destroy());
--- r2wt/packages/harness/README.md 2026-10-09 22:01:00.286672229 -0500
+++ wt/packages/harness/README.md 2026-10-09 22:00:50.898562239 -0500
@@ -83,6 +83,27 @@
directories first. Pi calls it from the extension, Claude Code from
`claude-gate.mjs`.
+Pi's `read` doesn't always open the name it is given. When that name
+doesn't exist, it tries other spellings of the whole resolved path: a
+narrow no-break space (U+202F) before ` AM.` or ` PM.`, the NFD form, a
+curly apostrophe (U+2019) for `'`, and NFD with the curly apostrophe. So
+for `read`, the gate also checks every one of those spellings that exists
+as a name, built from both the workspace as given and its real path (Pi's
+working directory). If any of them resolves outside the workspace or goes
+through a dangling symlink, the read is refused, whichever one Pi would
+pick. A spelling that doesn't exist is ignored. Claude Code's `Read` gets
+the same check, though in Darkwing's round 2 probe Claude Code's own
+symlink check already stopped the one variant it tries (AM/PM). `write`,
+`edit`, `grep`, `find` and `ls` take the name as given in Pi 0.85.1, so
+they get no such check.
+
+The gate copies Pi 0.85.1 code, so recheck these files on a Pi upgrade:
+`dist/utils/paths.js` (`normalizePath`), `dist/core/tools/path-utils.js`
+(`resolveToCwd`, `resolveReadPathAsync`) and which tools in
+`dist/core/tools/` call `resolveReadPath*`. The `pi` binary runs the
+bundled copy under `dist/bundle/`; in 0.85.1 it holds the same code, and
+only `read` and the CLI's own file arguments call `resolveReadPath*`.
+
## The runner
`node runner.mjs <run dir>`, with `{"cap": "..."}` and a newline on stdin.
@@ -156,6 +177,11 @@
manifest but not applied to either harness.
- **`--restricted`** (Claude Code) is not one of the S0 lines. It is what
keeps `CLAUDE.md` files and auto-memory out of the prompt (above).
+- **Other spellings.** A read is refused when a spelling Pi might open
+ points outside the workspace, even if the name as given exists and is
+ inside, and Pi would open that one. The same holds when a directory
+ next to the workspace has another spelling of its path, for example
+ `jo’s/ws` beside a workspace in `jo's/ws`.
- **The gate checks a path when the call is made.** A link created or
changed between the check and the tool's own open (by `bash`, or by
another process of the same user) isn't seen. That is the same reach as
--- r2wt/packages/harness/src/gate.mjs 2026-10-09 22:01:00.287853049 -0500
+++ wt/packages/harness/src/gate.mjs 2026-10-09 22:00:50.899987075 -0500
@@ -62,13 +62,60 @@
}
}
-export function insideWorkspace(workspace, p) {
+function within(workspace, path) {
const root = realpathSync(workspace);
- const s = normalise(p);
- const target = real(isAbsolute(s) ? resolve(s) : resolve(workspace, s));
+ const target = real(path);
return target === root || target.startsWith(root + sep);
}
+export function insideWorkspace(workspace, p) {
+ const s = normalise(p);
+ return within(workspace, isAbsolute(s) ? resolve(s) : resolve(workspace, s));
+}
+
+// Pi's read (dist/core/tools/path-utils.js resolveReadPathAsync) opens
+// another spelling when the resolved path doesn't exist: U+202F before
+// AM/PM, then NFD, then U+2019 for ', then both. Each applies to the whole
+// resolved path, directory names included, and Pi resolves against its cwd,
+// the workspace's real path. So every spelling that exists as a name is
+// checked, not only the one Pi would pick, and the check doesn't depend on
+// which of them exists when Pi opens it. Claude Code's Read gets the same
+// check; its own retries aren't documented.
+function spellings(workspace, p) {
+ const s = normalise(p);
+ const bases = isAbsolute(s) ? [resolve(s)] : [resolve(workspace, s), resolve(realpathSync(workspace), s)];
+ const curly = (v) => v.replace(/'/g, "\u2019");
+ const out = new Set();
+ for (const r of bases) {
+ const nfd = r.normalize("NFD");
+ for (const v of [r.replace(/ (AM|PM)\./gi, "\u202F$1."), nfd, curly(r), curly(nfd)]) if (v !== r) out.add(v);
+ }
+ return out;
+}
+
+// The first spelling that exists and resolves outside, or through a
+// dangling link, as a refusal reason; null if there is none.
+function otherSpelling(workspace, tool, p) {
+ for (const v of spellings(workspace, p)) {
+ let found;
+ try {
+ found = lstatSync(v, { throwIfNoEntry: false });
+ } catch (error) {
+ // A file used as a directory: Pi's access() fails too.
+ if (error.code === "ENOTDIR") continue;
+ return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
+ }
+ if (!found) continue;
+ try {
+ if (!within(workspace, v)) return `${tool} path is outside the workspace under another spelling: ${p} -> ${JSON.stringify(v)}`;
+ } catch (error) {
+ if (error.code === "dangling-symlink") return `${tool} path goes through a dangling symlink under another spelling: ${p} -> ${JSON.stringify(v)}`;
+ return `${tool} path can't be checked under another spelling: ${error.code ?? error.message}`;
+ }
+ }
+ return null;
+}
+
// decide(policy, tool, input) -> { allow: true } | { allow: false, reason }
// policy { harness: "pi" | "claude-code", workspace, tools: [pi names], typed: [typed tool names] }
export function decide(policy, tool, input) {
@@ -98,5 +145,14 @@
if (error.code === "dangling-symlink") return no(`${tool} path goes through a dangling symlink: ${value}`);
return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
}
+ if (tool === (claude ? "Read" : "read")) {
+ let other;
+ try {
+ other = otherSpelling(policy.workspace, tool, value);
+ } catch (error) {
+ return no(`${tool} path can't be checked: ${error.code ?? error.message}`);
+ }
+ if (other) return no(other);
+ }
return { allow: true };
}
--- r2wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:01:00.288274261 -0500
+++ wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:00:50.900285193 -0500
@@ -102,6 +102,62 @@
}
});
+// Pi's read opens another spelling when the name it's given doesn't exist
+// (path-utils.js resolveReadPathAsync). Asked name -> name on disk.
+const SPELLINGS = [
+ ["quote", "notes's.txt", "notes\u2019s.txt"],
+ ["ampm", "shot 9.41 AM.png", "shot 9.41\u202FAM.png"],
+ ["nfd", "r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")],
+ ["nfd and quote", "d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")],
+ // Each family alone, on a name with both an apostrophe and an accent.
+ ["nfd, straight quote", "l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")],
+ ["quote, composed", "l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"],
+];
+
+test("read is checked under every spelling pi's read would open, in both harnesses", (t) => {
+ for (const [harness, read, write, field] of [["pi", "read", "write", "path"], ["claude-code", "Read", "Write", "file_path"]]) {
+ for (const [, asked, disk] of SPELLINGS) {
+ const { dir, workspace, policy } = setup(t, harness, ["read", "write"]);
+ mkdirSync(join(dir, "outside"));
+ writeFileSync(join(dir, "outside", "secret.txt"), "s");
+ symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk));
+ const why = new RegExp(`outside the workspace under another spelling: (.*/)?${asked.replace(/[.']/g, "\\$&")}`);
+ blocked(decide(policy, read, { [field]: asked }), why);
+ blocked(decide(policy, read, { [field]: join(workspace, asked) }), why);
+ // Write takes the name as given, so writing the asked name stays inside.
+ allowed(decide(policy, write, { [field]: asked }));
+ // The same spelling pointing inside is fine.
+ const inner = setup(t, harness, ["read"]);
+ symlinkSync(join(inner.workspace, "a.txt"), join(inner.workspace, disk));
+ allowed(decide(inner.policy, read, { [field]: asked }));
+ }
+ }
+});
+
+test("other spellings cover directories, dangling links and pi's cwd", (t) => {
+ const { dir, workspace, policy } = setup(t, "pi", ["read"]);
+ mkdirSync(join(dir, "outside"));
+ writeFileSync(join(dir, "outside", "s.txt"), "s");
+ // A directory name is respelled too.
+ symlinkSync(join(dir, "outside"), join(workspace, "it\u2019s"));
+ blocked(decide(policy, "read", { path: "it's/s.txt" }), /outside the workspace under another spelling/);
+ // A dangling link under another spelling is refused, like the name itself.
+ symlinkSync(join(dir, "nowhere"), join(workspace, "gone\u2019s"));
+ blocked(decide(policy, "read", { path: "gone's" }), /dangling symlink under another spelling/);
+ // A spelling that doesn't exist, or uses a file as a directory, isn't opened.
+ allowed(decide(policy, "read", { path: "nobody's.txt" }));
+ writeFileSync(join(workspace, "f\u2019s"), "f");
+ allowed(decide(policy, "read", { path: "f's/x" }));
+ // Pi resolves against its cwd, the workspace's real path, and respells
+ // that too: here the real path has an apostrophe the given path lacks.
+ mkdirSync(join(dir, "jo's", "ws"), { recursive: true });
+ mkdirSync(join(dir, "jo\u2019s", "ws"), { recursive: true });
+ writeFileSync(join(dir, "jo\u2019s", "ws", "x.txt"), "x");
+ symlinkSync(join(dir, "jo's"), join(dir, "jo"));
+ const viaLink = { ...policy, workspace: join(dir, "jo", "ws") };
+ blocked(decide(viaLink, "read", { path: "x.txt" }), /outside the workspace under another spelling/);
+});
+
test("claude path fields per tool", (t) => {
const { workspace, policy } = setup(t, "claude-code", ["read", "write", "edit", "grep", "find"]);
allowed(decide(policy, "Read", { file_path: join(workspace, "a.txt") }));
--- r2wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:01:00.288274261 -0500
+++ wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:00:50.900285193 -0500
@@ -120,6 +120,39 @@
assert.ok(existsSync(s.turnMarker));
});
+test("pi: a read is refused when pi would open another spelling outside", async (t) => {
+ // Asked name -> link on disk; pi's read opens the link when the asked
+ // name doesn't exist (path-utils.js resolveReadPathAsync).
+ const spellings = [
+ ["notes's.txt", "notes\u2019s.txt"],
+ ["shot 9.41 AM.png", "shot 9.41\u202FAM.png"],
+ ["r\u00E9sum\u00E9.txt", "r\u00E9sum\u00E9.txt".normalize("NFD")],
+ ["d'\u00E9cran.png", "d\u2019\u00E9cran.png".normalize("NFD")],
+ ["l'\u00E9t\u00E9.txt", "l'\u00E9t\u00E9.txt".normalize("NFD")],
+ ["l'\u00E9t\u00E9.md", "l\u2019\u00E9t\u00E9.md"],
+ ];
+ const { dir, workspace, s, env } = await session(t, [
+ ...spellings.map(([asked]) => ({ name: "read", input: { path: asked } })),
+ { name: "read", input: { path: "inside's.txt" } },
+ ]);
+ mkdirSync(join(dir, "outside"));
+ writeFileSync(join(dir, "outside", "secret.txt"), "SECRET-OUTSIDE\n");
+ for (const [, disk] of spellings) symlinkSync(join(dir, "outside", "secret.txt"), join(workspace, disk));
+ // The same respelling pointing inside is still read.
+ symlinkSync(join(workspace, "notes.txt"), join(workspace, "inside\u2019s.txt"));
+ const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread the files", workspace);
+ assert.equal(r.code, 0, r.stderr);
+ assert.doesNotMatch(r.stdout, /SECRET/);
+ const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length));
+ assert.equal(results.length, spellings.length + 1);
+ spellings.forEach(([asked], i) => {
+ assert.equal(results[i][0], true, asked);
+ assert.match(results[i][1], /outside the workspace under another spelling/, asked);
+ });
+ assert.deepEqual(results.at(-1), [false, "inside\n"]);
+ assert.ok(existsSync(s.turnMarker));
+});
+
test("pi: a missing extension refuses before any model call", async (t) => {
const { dir, api, s, env } = await session(t, []);
const r = await turn({ ...env, MOSAIC_EXTENSIONS: join(dir, "missing.mjs") }, "x", s.workspace);