Row 92, round 2. Darkwing built it. A role session runs in a bwrap sandbox that sees only its own workspace, a private /proc, /dev and IPC namespace, and the variables ENV_ALLOW passes. The launcher checks the session credential before a Claude launch, records a runner killed from the host as killed rather than failed, and the bus unit template carries the credential file (decision 119). The harness gate and the cli, seat and harness READMEs say what the sandbox does and does not hide. Candidate manifest 8bf4141cbe69fc7bed188555bcc88dcab0b1ea2149912cb6f970ba8c2629dd97 (13 paths), baseac37cd17, packet agents/darkwing/work/queue-92/r2/. Approvals: Dewey 27978, Filbert 27984 (round 1: Dewey 27942, Filbert asked for changes in 27951). Sage's gate rerun 22:27:37Z-22:30:35Z on1b7f3781plus the patch, manifest 13/13: seat 33, cli 120, harness 65, bus 108, webui 86, control-board 124, and every scripts/test-*.sh exited 0 (test-release 4, Docker cases skipped at a dead socket). Live at the next bus restart and the next role session launch (decision 109). Installing the new unit and restarting is Mos's call. Co-Authored-By: Claude Opus 5.5 <[email protected]>
165 lines
7.6 KiB
JavaScript
165 lines
7.6 KiB
JavaScript
// The tool gate both harnesses share. decide(policy, tool, input) answers
|
||
// one tool call: the policy's built-in tools and its typed tools pass,
|
||
// everything else is blocked, and every path argument of a file tool must
|
||
// resolve inside the workspace.
|
||
//
|
||
// This is a gate on the harness's own tool calls, not the sandbox. `bash`
|
||
// isn't gated (the role's tool limit decides whether it exists at all):
|
||
// what it can reach is what the session's bubblewrap sandbox holds
|
||
// (packages/seat/src/sandbox.mjs). See the README's limits.
|
||
|
||
import { lstatSync, realpathSync } from "node:fs";
|
||
import { homedir } from "node:os";
|
||
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
// pi built-in tool -> Claude Code tool. find and ls both map to Glob.
|
||
export const CLAUDE_TOOLS = Object.freeze({ read: "Read", write: "Write", edit: "Edit", bash: "Bash", grep: "Grep", find: "Glob", ls: "Glob" });
|
||
export const MCP_PREFIX = "mcp__mosaic__";
|
||
|
||
// The input field that holds a path, per tool, per harness. A missing
|
||
// optional path means the working directory, which is the workspace.
|
||
const PATH_FIELDS = {
|
||
pi: { read: "path", write: "path", edit: "path", grep: "path", find: "path", ls: "path" },
|
||
"claude-code": { Read: "file_path", Write: "file_path", Edit: "file_path", Grep: "path", Glob: "path" },
|
||
};
|
||
|
||
// Claude Code tool names the session may call, for --tools and the gate.
|
||
export function claudeBuiltins(policy) {
|
||
return [...new Set(policy.tools.map((t) => CLAUDE_TOOLS[t]))];
|
||
}
|
||
|
||
// Pi's own path normalisation (dist/utils/paths.js normalizePath with the
|
||
// options resolveToCwd passes): unicode spaces, a leading @, ~ and file://.
|
||
const UNICODE_SPACES = /[ - ]/g;
|
||
function normalise(p) {
|
||
let s = p.replace(UNICODE_SPACES, " ");
|
||
if (s.startsWith("@")) s = s.slice(1);
|
||
if (s === "~") return homedir();
|
||
if (s.startsWith("~/")) return join(homedir(), s.slice(2));
|
||
if (/^file:\/\//.test(s)) return fileURLToPath(s);
|
||
return s;
|
||
}
|
||
|
||
// Realpath of the nearest ancestor that exists as a name, with the missing
|
||
// tail kept. lstat, not exists: a dangling symlink exists as a name, and a
|
||
// write through it would create its target wherever that is. So a path that
|
||
// reaches a dangling symlink, at any depth, can't be checked and is refused.
|
||
function real(p) {
|
||
let head = p;
|
||
const tail = [];
|
||
while (!lstatSync(head, { throwIfNoEntry: false })) {
|
||
const up = dirname(head);
|
||
if (up === head) break;
|
||
tail.unshift(basename(head));
|
||
head = up;
|
||
}
|
||
try {
|
||
return join(realpathSync(head), ...tail);
|
||
} catch (error) {
|
||
if (error.code === "ENOENT") throw Object.assign(new Error("dangling symlink"), { code: "dangling-symlink" });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function within(workspace, path) {
|
||
const root = realpathSync(workspace);
|
||
const target = real(path);
|
||
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);
|
||
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
|
||
// 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) {
|
||
const no = (reason) => ({ allow: false, reason: `mosaic gate: ${reason}` });
|
||
if (typeof tool !== "string") return no("tool call without a name");
|
||
const claude = policy.harness === "claude-code";
|
||
if (claude ? tool.startsWith(MCP_PREFIX) && policy.typed.includes(tool.slice(MCP_PREFIX.length)) : policy.typed.includes(tool)) {
|
||
return { allow: true };
|
||
}
|
||
const builtins = claude ? claudeBuiltins(policy) : policy.tools;
|
||
if (!builtins.includes(tool)) return no(`${tool} isn't in this session's policy`);
|
||
// A glob pattern is a second way to name a path: Glob and find take it
|
||
// relative to their root, so an absolute pattern, ~ or a .. segment
|
||
// could leave the workspace.
|
||
const pattern = (claude ? tool === "Glob" : tool === "find") ? input?.pattern : undefined;
|
||
if (pattern !== undefined && (typeof pattern !== "string" || /^[~/@]/.test(normalise(pattern).trim()) || /(^|[/\\])\.\.([/\\]|$)/.test(pattern))) {
|
||
return no(`${tool} pattern must stay inside the workspace: ${pattern}`);
|
||
}
|
||
const field = PATH_FIELDS[policy.harness]?.[tool];
|
||
if (!field) return { allow: true };
|
||
const value = input?.[field];
|
||
if (value === undefined || value === null || value === "") return { allow: true };
|
||
if (typeof value !== "string") return no(`${tool}.${field} isn't a string`);
|
||
try {
|
||
if (!insideWorkspace(policy.workspace, value)) return no(`${tool} path is outside the workspace: ${value}`);
|
||
} catch (error) {
|
||
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 };
|
||
}
|