192 lines
9.6 KiB
Diff
192 lines
9.6 KiB
Diff
--- 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 = {
|
|
+ // <dir>/a/ws -> <dir>/deep/store/ws: up two is <dir> as given, <dir>/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 <dir>/data -> <dir>/deep/store, the workspace under it as the
|
|
+ // launcher builds it: up four is <dir> as given, <dir>/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);
|