Files
stack/packages/harness/src/gate.mjs
T
jason.woltjeandClaude Opus 5.5 fe5c68fa03 feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.

Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.

Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 12:22:50 -05:00

165 lines
7.5 KiB
JavaScript
Raw 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 a sandbox. `bash`
// isn't confined (the role's tool limit decides whether it exists at all),
// and anything bash can reach, the session can reach. 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 };
}