Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/daemon/providers/tool-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { BLACKBOARD_MCP_SERVER_NAME } from "../blackboard/mcp-http.js";
import { MEMORY_MCP_SERVER_NAME } from "../memory/mcp-http.js";
import { MEMORY_TOOL_NAMES } from "../memory/tools.js";
import { FLEET_READ_TOOLS, FLEET_TOOL_PREFIX } from "../../protocol/types.js";

/** Built-in read-only tools that never require confirmation. */
const SAFE_TOOLS = new Set<string>(["Read", "Grep", "Glob"]);
Expand All @@ -24,6 +25,18 @@ const BLACKBOARD_TOOL_PREFIXES = [
`${BLACKBOARD_MCP_SERVER_NAME}__`,
] as const;

/**
* Same two namespacing conventions, for the conductor's fleet mount.
*
* The bare form is unused today — the fleet server is a Claude in-process MCP
* object — but is listed so a mounted fleet (#245) does not silently regress to
* prompting on every read.
*/
const FLEET_TOOL_PREFIXES = [
FLEET_TOOL_PREFIX, // `mcp__codeoid_fleet__` — Claude in-process MCP
FLEET_TOOL_PREFIX.replace(/^mcp__/, ""), // bare mount
] as const;

/**
* Blackboard tools that may run unprompted.
*
Expand Down Expand Up @@ -60,6 +73,17 @@ export function isSafeTool(name: string): boolean {
return (BLACKBOARD_SAFE_TOOLS as readonly string[]).includes(name.slice(prefix.length));
}
}
// Fleet READS only. The send-class verbs are absent by construction —
// `FLEET_READ_TOOLS` is the read half of the shared vocabulary, so a verb
// added to the send half can never leak in here by editing one list. They are
// additionally hard-gated before this function is ever consulted
// (`isFleetSendTool` in Session#shouldAutoApprove), which is the invariant;
// this is defence in depth, not the fence.
for (const prefix of FLEET_TOOL_PREFIXES) {
if (name.startsWith(prefix)) {
return (FLEET_READ_TOOLS as readonly string[]).includes(name.slice(prefix.length));
}
}
return false;
}

Expand Down
39 changes: 39 additions & 0 deletions src/tests/tool-safety.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, test, expect } from "bun:test";
import { FLEET_READ_TOOLS, FLEET_SEND_TOOLS } from "../protocol/types.js";
import { isElicitationTool, isSafeTool } from "../daemon/providers/tool-safety.js";
import { MEMORY_TOOL_NAMES } from "../daemon/memory/tools.js";

Expand Down Expand Up @@ -50,3 +51,41 @@ describe("isElicitationTool", () => {
}
});
});

describe("isSafeTool — the conductor's fleet mount", () => {
// The fleet READ surface is specified to run silently (conductor-design §3):
// "fleet_list / fleet_find / fleet_summary / fleet_recall / fleet_tasks /
// machine_map run silently". They did not — `isSafeTool` knew the memory and
// blackboard mounts but not the fleet, so in guarded mode every `fleet_find`
// raised an approval prompt. An assistant that asks permission to look
// something up is not an assistant.

test("every read verb runs unprompted, under both namespacings", () => {
for (const verb of FLEET_READ_TOOLS) {
expect(isSafeTool(`mcp__codeoid_fleet__${verb}`)).toBe(true);
expect(isSafeTool(`codeoid_fleet__${verb}`)).toBe(true);
}
});

test("NO send-class verb is ever safe", () => {
// The one that must never regress. These are hard-gated earlier too
// (isFleetSendTool, before any mode logic), so this is the second fence.
for (const verb of FLEET_SEND_TOOLS) {
expect(isSafeTool(`mcp__codeoid_fleet__${verb}`)).toBe(false);
expect(isSafeTool(`codeoid_fleet__${verb}`)).toBe(false);
}
});

test("an unknown verb on the fleet prefix prompts rather than auto-approving", () => {
expect(isSafeTool("mcp__codeoid_fleet__fleet_detonate")).toBe(false);
expect(isSafeTool("mcp__codeoid_fleet__")).toBe(false);
});

test("a look-alike server segment does not inherit fleet safety", () => {
// Matching on the server segment alone would let these through; the prefix
// must match exactly, then the suffix must be a known read verb.
expect(isSafeTool("mcp__evil_codeoid_fleet__fleet_find")).toBe(false);
expect(isSafeTool("x_codeoid_fleet__fleet_find")).toBe(false);
expect(isSafeTool("mcp__codeoid_fleet_x__fleet_find")).toBe(false);
});
});
Loading