--- r3wt/packages/harness/README.md 2026-10-09 22:31:22.166070702 -0500 +++ wt/packages/harness/README.md 2026-10-09 22:31:15.039068214 -0500 @@ -83,6 +83,19 @@ directories first. Pi calls it from the extension, Claude Code from `claude-gate.mjs`. +A relative path is resolved the way Pi resolves it: against its working +directory. Both adapters `cd` into the workspace, and a process's working +directory is the real path, so `..` climbs the real path's parents. With +the workspace or the dataRoot behind a symlink, those differ from the +parents of the path as given, and `../../data/ws/x` can be inside as given +but outside for Pi. The gate requires a relative path to be inside from +both the real path and the path as given. The second check is stricter +than Pi: a path that only climbs out and back in through the real path's +parents is refused. That keeps the gate fail-closed whichever directory it +runs in. Claude Code isn't affected the same way: it makes a path absolute +against its own (real) working directory before the `PreToolUse` hook +sees it, so the gate gets the path Claude Code will open. + 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 --- r3wt/packages/harness/src/gate.mjs 2026-10-09 22:31:22.166883857 -0500 +++ wt/packages/harness/src/gate.mjs 2026-10-09 22:31:15.039927044 -0500 @@ -68,9 +68,15 @@ return target === root || target.startsWith(root + sep); } +// A relative path is resolved the way the tool resolves it: against its +// cwd. Both adapters cd into the workspace, and a process's cwd is the real +// path, so `..` climbs the real path's parents, not those of a workspace or +// dataRoot given through a symlink. It must be inside against the path as +// given too, so the check doesn't rest on how the harness was started. export function insideWorkspace(workspace, p) { const s = normalise(p); - return within(workspace, isAbsolute(s) ? resolve(s) : resolve(workspace, s)); + if (isAbsolute(s)) return within(workspace, resolve(s)); + return within(workspace, resolve(realpathSync(workspace), s)) && within(workspace, resolve(workspace, s)); } // Pi's read (dist/core/tools/path-utils.js resolveReadPathAsync) opens --- r3wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:31:22.167166446 -0500 +++ wt/packages/harness/tests/gate.test.mjs 2026-10-09 22:31:15.040230821 -0500 @@ -1,8 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { claudeBuiltins, decide, insideWorkspace } from "../src/gate.mjs"; import { scratch } from "./helpers.mjs"; @@ -158,6 +158,53 @@ blocked(decide(viaLink, "read", { path: "x.txt" }), /outside the workspace under another spelling/); }); +// Both adapters cd into the workspace, and the tool's cwd is its real path, +// so `..` climbs the real path's parents. Through a symlinked workspace or +// dataRoot those differ from the given path's. +const CLIMBS = { + // /a/ws -> /deep/store/ws: up two is as given, /deep for pi. + workspace(dir) { + mkdirSync(join(dir, "deep", "store", "ws"), { recursive: true }); + mkdirSync(join(dir, "a")); + symlinkSync(join(dir, "deep", "store", "ws"), join(dir, "a", "ws")); + return { workspace: join(dir, "a", "ws"), outside: join(dir, "deep", "a", "ws"), escape: "../../a/ws", stay: "../ws", strict: "../../store/ws" }; + }, + // dataRoot /data -> /deep/store, the workspace under it as the + // launcher builds it: up four is as given, /deep for pi. + dataRoot(dir) { + mkdirSync(join(dir, "deep", "store", "workspaces", "b", "i"), { recursive: true }); + symlinkSync(join(dir, "deep", "store"), join(dir, "data")); + return { workspace: join(dir, "data", "workspaces", "b", "i"), outside: join(dir, "deep", "data", "workspaces", "b", "i"), escape: "../../../../data/workspaces/b/i", stay: "../i", strict: "../../../../store/workspaces/b/i" }; + }, +}; + +test("a relative path climbs from the workspace's real path, in both harnesses", (t) => { + for (const [name, build] of Object.entries(CLIMBS)) { + for (const harness of ["pi", "claude-code"]) { + const dir = scratch(t); + const { workspace, outside, escape, stay, strict } = build(dir); + writeFileSync(join(workspace, "a.txt"), "a"); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, "secret.txt"), "s"); + const policy = { harness, workspace, tools: ["read", "write"], typed: [] }; + const [read, write, field] = harness === "pi" ? ["read", "write", "path"] : ["Read", "Write", "file_path"]; + const at = `${name}, ${harness}`; + // As given, the escape is inside; from the real path it is outside. + assert.equal(resolve(workspace, escape), workspace, at); + assert.equal(resolve(realpathSync(workspace), escape), outside, at); + blocked(decide(policy, read, { [field]: `${escape}/secret.txt` }), /outside the workspace/); + blocked(decide(policy, write, { [field]: `${escape}/planted.txt` }), /outside the workspace/); + // Climbing out and back in by the same name stays inside on both paths. + allowed(decide(policy, read, { [field]: `${stay}/a.txt` })); + allowed(decide(policy, write, { [field]: `${stay}/new.txt` })); + // The other way round, inside for pi but outside as given, is refused + // too: the gate doesn't rest on the cwd the harness started in. + assert.equal(resolve(realpathSync(workspace), strict), realpathSync(workspace), at); + blocked(decide(policy, read, { [field]: `${strict}/a.txt` }), /outside the workspace/); + } + } +}); + 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") })); --- r3wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:31:22.167166446 -0500 +++ wt/packages/harness/tests/pi-session.test.mjs 2026-10-09 22:31:15.040230821 -0500 @@ -12,11 +12,11 @@ const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh"); -async function session(t, script) { +async function session(t, script, place = (dir) => join(dir, "ws")) { const dir = scratch(t); - const workspace = join(dir, "ws"); + const workspace = place(dir); const agentDir = join(dir, "pi-agent"); - mkdirSync(workspace); + mkdirSync(workspace, { recursive: true }); mkdirSync(agentDir); writeFileSync(join(workspace, "notes.txt"), "inside\n"); writeFileSync(join(dir, "secret.txt"), "outside\n"); @@ -153,6 +153,65 @@ assert.ok(existsSync(s.turnMarker)); }); +// The adapter cds into the workspace, and pi resolves `..` from that real +// path. Each layout: where the workspace is given, and where `escape` lands +// for pi (as given, it is the workspace itself). +const CLIMBS = { + workspace: { + place(dir) { + mkdirSync(join(dir, "deep", "store", "ws"), { recursive: true }); + mkdirSync(join(dir, "a")); + symlinkSync(join(dir, "deep", "store", "ws"), join(dir, "a", "ws")); + return join(dir, "a", "ws"); + }, + outside: (dir) => join(dir, "deep", "a", "ws"), + escape: "../../a/ws", + stay: "../ws", + }, + dataRoot: { + place(dir) { + mkdirSync(join(dir, "deep", "store", "workspaces", "b", "i"), { recursive: true }); + symlinkSync(join(dir, "deep", "store"), join(dir, "data")); + return join(dir, "data", "workspaces", "b", "i"); + }, + outside: (dir) => join(dir, "deep", "data", "workspaces", "b", "i"), + escape: "../../../../data/workspaces/b/i", + stay: "../i", + }, +}; + +for (const [name, { place, outside, escape, stay }] of Object.entries(CLIMBS)) { + test(`pi: a relative path climbs from the real path of a ${name} behind a symlink`, async (t) => { + const { dir, workspace, s, env } = await session( + t, + [ + { name: "read", input: { path: `${escape}/secret.txt` } }, + { name: "write", input: { path: `${escape}/planted.txt`, content: "planted\n" } }, + { name: "read", input: { path: `${stay}/notes.txt` } }, + { name: "write", input: { path: `${stay}/fine.md`, content: "inside\n" } }, + ], + place, + ); + mkdirSync(outside(dir), { recursive: true }); + writeFileSync(join(outside(dir), "secret.txt"), "SECRET-OUTSIDE\n"); + const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nread and write", 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, 4); + for (const i of [0, 1]) { + assert.equal(results[i][0], true); + assert.match(results[i][1], /outside the workspace/); + } + assert.deepEqual(readdirSync(outside(dir)), ["secret.txt"]); + assert.ok(!existsSync(join(workspace, "planted.txt"))); + assert.deepEqual(results[2], [false, "inside\n"]); + assert.equal(results[3][0], false); + assert.ok(existsSync(join(workspace, "fine.md")), "the write inside landed"); + 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);