Files
stack/agents/darkwing/work/s6-review/r4/interdiff.patch
T

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);