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]>
165 lines
7.5 KiB
JavaScript
165 lines
7.5 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 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 };
|
||
}
|