Files
stack/packages/harness/src/gate.mjs
jason.woltjeandClaude Opus 5.5 e9f4431ec1 harness: web_search and web_fetch as broker-served tools, search-source plug-ins and the assistant's web grant (vikunja:19/295, row 145)
Jason-approved merge, rebased: git diff 07c12b51b..813671596 from
work/vikunja-19-295, its seven commits as one (9ede6935b through
813671596, which include task 296's searxng check). Business reviewer
verdict 51c71b46 approve on 813671596; Jason resolved merge decision
29ff6589 via the CLI.

Conflict resolution only. packages/harness/src/tools.mjs conflicted with
row 132 (0e947c216): both sides add a block after review_verdict. Kept
row 132's end_run and change_organization block, then 295's web_search and
web_fetch block, each with its own closing brace. Seven other paths that
refactor also changed merged without conflict. For each, the +/- lines on
the new base equal the source commit's. The other 20 paths are the
source blobs. Filbert reviews the rebased diff.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:50:59 -05:00

168 lines
7.9 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
// REQ-HARN-2 (Q162): WebSearch and WebFetch are in the list only when policy.webBuiltin is true,
// which the bundle sets for a granted role under the "harness-builtin" search-source.
export const CLAUDE_WEB_TOOLS = Object.freeze(["WebSearch", "WebFetch"]);
export function claudeBuiltins(policy) {
return [...new Set([...policy.tools.map((t) => CLAUDE_TOOLS[t]), ...(policy.webBuiltin === true ? CLAUDE_WEB_TOOLS : [])])];
}
// 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 };
}