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]>
319 lines
17 KiB
Diff
319 lines
17 KiB
Diff
--- 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);
|