diff --git a/docs/adr/0040-codedb-stays-when-licensed.md b/docs/adr/0040-codedb-stays-when-licensed.md new file mode 100644 index 00000000..21337306 --- /dev/null +++ b/docs/adr/0040-codedb-stays-when-licensed.md @@ -0,0 +1,30 @@ +# 0040. Native codedb (and read_file) stay the default readers + +Status: accepted 2026-08-27 + +## Context + +v0.0.277 hid and refused native `codedb` and `read_file` whenever +`codedb-pro probe` succeeded, so a licensed suite was the only read +surface. `rlm codedb(...)` hit the same exec gate. A walk of a foreign +repo then failed with "codedb is blocked while the LICENSED +code-intelligence suite is in charge" and fell through to metered +`inspect`/`search`. Native codedb is local, indexed, and free. + +## Decision + +Ordinary reads use native `codedb` or `read_file`, licensed or not, +including as rlm host functions. Do not hide, refuse, or redirect those +to `mcp__codedbpro__read`. codedb-pro is extra search/batch +(`faster_search`, `meta_search`) when codedb cannot answer — not the +default reader. Leading shell `grep`/`rg` may still point at zigrep. +Companion write tools stay hidden (they bypass `/rewind`). + +## Consequences + +- A licensed session can `codedb context` / `around` / `callpath` / + `list_dir` / `status` and `read_file` without loading MCP schemas. +- `cat`/`head` of source are not rewritten to pro read; #626 still + steers a concrete source-file scan toward codedb. +- Do not re-add `codedb` or `read_file` to `hideBuiltin` or + `replacedNative`. diff --git a/docs/adr/README.md b/docs/adr/README.md index fcc12358..ed9c0579 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -49,6 +49,7 @@ record only when you need the evidence or the edge cases. | [0036](0036-computer-use-keeps-the-signed-codex-bridge.md) | Codex Computer Use keeps its authenticated node_repl process chain: Graff launches it through the signed Codex sandbox wrapper, never embeds V8 or spoofs the service. | | [0037](0037-experiment-pool-is-opt-in.md) | `--experiment N` / `/experiment N` pre-mints a child worktree pool; default isolation stays `shared_cwd`. 279 continuation — not on main until a later cut. | | [0038](0038-in-process-acp-core.md) | Same-process embed is `libgraff` + `graff-core.wasm` + `createGraffAgent()` (ACP core, echo turn). Live coding stays `graff acp`. 279 continuation — not on main until a later cut. | +| [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | ## When to write one diff --git a/src/codedbpro_report.zig b/src/codedbpro_report.zig index c40124e3..cce396a4 100644 --- a/src/codedbpro_report.zig +++ b/src/codedbpro_report.zig @@ -142,11 +142,11 @@ pub fn fallbackOpen() bool { return g_fallback_open.load(.acquire); } -/// The native tools the licensed pro tools replace. edit_file/write_file are -/// deliberately NOT here: edits stay native regardless (they are -/// /rewind-snapshotted; codedb-pro edits bypass /rewind). +/// Native reads (`codedb`, `read_file`) are never replaced (ADR 0040). +/// edit_file/write_file stay native too (/rewind; pro edits bypass it). fn replacedNative(name: []const u8) bool { - return std.mem.eql(u8, name, "read_file") or std.mem.eql(u8, name, "codedb"); + _ = name; + return false; } /// A bash call is only intercepted when the search command LEADS the line — @@ -156,7 +156,7 @@ fn leadingSearchCommand(cmd: []const u8) bool { const trimmed = std.mem.trimStart(u8, cmd, " \t"); const end = std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len; const first = trimmed[0..end]; - return @import("tool_surface.zig").isSearchBash(first); + return @import("tool_surface.zig").isContentSearchBash(first); } /// Whether the licensed-pro enforcement applies to this call right now. @@ -172,14 +172,14 @@ fn enforcementActive(ctx: tools.ToolCtx) bool { /// What a blocked call is pointed at. Shell searches go to zigrep — the /// suite's own CLI, run directly via bash, no MCP round trip — when the -/// binary is on PATH; reads and the codedb tool point at the pro MCP tools. +/// binary is on PATH. Ordinary reads stay on codedb / read_file. fn replacementFor(call_name: []const u8, is_bash_search: bool, zigrep_installed: bool) []const u8 { + _ = call_name; if (is_bash_search) return if (zigrep_installed) "zigrep — run it directly via bash (e.g. `zigrep PATTERN src/`); it is the suite's search CLI. Caveat: zigrep always skips vendor dirs (node_modules & co) even with --no-ignore — use `rg -uu` for vendor dives" else - "mcp__codedbpro__faster_search / meta_search"; - if (std.mem.eql(u8, call_name, "read_file")) return "mcp__codedbpro__read (mode=outline first, then symbol/lines)"; - return "mcp__codedbpro__faster_search / meta_search"; + "codedb, or mcp__codedbpro__faster_search / meta_search when codedb cannot answer"; + return "codedb or read_file"; } /// exec.zig consults this on every tool call: when the licensed pro tools are @@ -220,13 +220,6 @@ pub fn licensedGate(ctx: tools.ToolCtx, call: tools.ToolCall) ?tools.ToolOutput pub const Redirect = struct { name: []const u8, input: std.json.Value }; -fn readRedirect(gpa: Allocator, file: []const u8, mode: []const u8) ?Redirect { - var obj: std.json.ObjectMap = .empty; - obj.put(gpa, "file", .{ .string = file }) catch return null; - obj.put(gpa, "mode", .{ .string = mode }) catch return null; - return .{ .name = "mcp__codedbpro__read", .input = .{ .object = obj } }; -} - fn searchRedirect(gpa: Allocator, pattern: []const u8) ?Redirect { if (pattern.len == 0) return null; var obj: std.json.ObjectMap = .empty; @@ -234,38 +227,11 @@ fn searchRedirect(gpa: Allocator, pattern: []const u8) ?Redirect { return .{ .name = "mcp__codedbpro__faster_search", .input = .{ .object = obj } }; } -/// exec.zig consults this BEFORE nativeRefusal — the guard's premise made -/// executable (user direction): a blocked native read/search becomes its -/// codedb-pro equivalent inline, skipping the refuse → load → re-call round -/// trip. null = no clean translation; the caller keeps the refusal. Edits -/// never redirect (native edit tools own /rewind). The dispatch site skips -/// the schema gate: arguments are harness-built on the daemon's contract. +/// Leading `grep`/`rg` may become zigrep / faster_search. Ordinary reads +/// (`read_file`, `codedb`, `cat`) never redirect to mcp__codedbpro__read. pub fn redirect(ctx: tools.ToolCtx, call: tools.ToolCall) ?Redirect { if (!enforcementActive(ctx)) return null; const gpa = ctx.gpa; - if (std.mem.eql(u8, call.name, "read_file")) { - const path = tools.strField(call.input, "path") orelse return null; - if (tools.intField(call.input, "start_line")) |s| { - // lines mode takes a "N-M" range string (handler_read.zig). - const e = tools.intField(call.input, "end_line") orelse s + 400; - var obj: std.json.ObjectMap = .empty; - obj.put(gpa, "file", .{ .string = path }) catch return null; - obj.put(gpa, "mode", .{ .string = "lines" }) catch return null; - obj.put(gpa, "range", .{ .string = std.fmt.allocPrint(gpa, "{d}-{d}", .{ s, e }) catch return null }) catch return null; - return .{ .name = "mcp__codedbpro__read", .input = .{ .object = obj } }; - } - return readRedirect(gpa, path, "full"); - } - if (std.mem.eql(u8, call.name, "codedb")) { - const cmd = tools.strField(call.input, "command") orelse return null; - const sub_end = std.mem.indexOfAny(u8, cmd, " \t") orelse cmd.len; - const sub = cmd[0..sub_end]; - const rest = std.mem.trim(u8, cmd[sub_end..], " \t"); - if (std.mem.eql(u8, sub, "outline")) return readRedirect(gpa, rest, "outline"); - if (std.mem.eql(u8, sub, "read")) return readRedirect(gpa, rest, "full"); - if (std.mem.eql(u8, sub, "search")) return searchRedirect(gpa, rest); - return null; // symbol/callers/deps/tree have no lossless map — refusal stays - } if (std.mem.eql(u8, call.name, "bash")) { const cmd = tools.strField(call.input, "command") orelse return null; if (!leadingSearchCommand(cmd)) return null; @@ -436,11 +402,11 @@ fn namedCall(name: []const u8) tools.ToolCall { return .{ .id = "t", .name = name, .input = .null }; } -test "replacementFor: shell searches point at zigrep directly, reads at the pro MCP tools" { +test "replacementFor: shell searches point at zigrep; reads stay native" { try std.testing.expect(std.mem.indexOf(u8, replacementFor("bash", true, true), "zigrep") != null); - try std.testing.expect(std.mem.indexOf(u8, replacementFor("bash", true, false), "mcp__codedbpro__faster_search") != null); - try std.testing.expect(std.mem.indexOf(u8, replacementFor("read_file", false, true), "mcp__codedbpro__read") != null); - try std.testing.expect(std.mem.indexOf(u8, replacementFor("codedb", false, true), "mcp__codedbpro__faster_search") != null); + try std.testing.expect(std.mem.indexOf(u8, replacementFor("bash", true, false), "codedb") != null); + try std.testing.expect(std.mem.indexOf(u8, replacementFor("read_file", false, true), "read_file") != null); + try std.testing.expect(std.mem.indexOf(u8, replacementFor("codedb", false, true), "codedb") != null); } test "nativeRefusal: licensed pro tools block the natives they replaced" { @@ -461,12 +427,12 @@ test "nativeRefusal: licensed pro tools block the natives they replaced" { main_mod.plan_mode = false; g_fallback_open.store(false, .release); - try std.testing.expect(nativeRefusal(ctx, namedCall("read_file")) != null); - try std.testing.expect(nativeRefusal(ctx, namedCall("codedb")) != null); - try std.testing.expect(nativeRefusal(ctx, bashCall(a, "find . -name '*.zig'")) != null); + try std.testing.expect(nativeRefusal(ctx, namedCall("read_file")) == null); + try std.testing.expect(nativeRefusal(ctx, namedCall("codedb")) == null); + try std.testing.expect(nativeRefusal(ctx, bashCall(a, "find . -name '*.zig'")) == null); try std.testing.expect(nativeRefusal(ctx, bashCall(a, "rg TODO src")) != null); - try std.testing.expect(nativeRefusal(ctx, bashCall(a, "cat src/main.zig")) != null); - try std.testing.expect(nativeRefusal(ctx, bashCall(a, "head src/main.zig")) != null); + try std.testing.expect(nativeRefusal(ctx, bashCall(a, "cat src/main.zig")) == null); + try std.testing.expect(nativeRefusal(ctx, bashCall(a, "head src/main.zig")) == null); try std.testing.expect(nativeRefusal(ctx, namedCall("edit_file")) == null); // edits stay native try std.testing.expect(nativeRefusal(ctx, bashCall(a, "git status")) == null); try std.testing.expect(nativeRefusal(ctx, bashCall(a, "curl -s x | grep err")) == null); @@ -494,19 +460,10 @@ test "redirect: blocked natives translate to their codedb-pro equivalents" { main_mod.plan_mode = false; g_fallback_open.store(false, .release); - const r1 = redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"src/main.zig\"}")).?; - try std.testing.expectEqualStrings("mcp__codedbpro__read", r1.name); - try std.testing.expectEqualStrings("full", r1.input.object.get("mode").?.string); - try std.testing.expectEqualStrings("src/main.zig", r1.input.object.get("file").?.string); - const r2 = redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"a.zig\",\"start_line\":10,\"end_line\":40}")).?; - try std.testing.expectEqualStrings("lines", r2.input.object.get("mode").?.string); - try std.testing.expectEqualStrings("10-40", r2.input.object.get("range").?.string); - const r3 = redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"outline src/main.zig\"}")).?; - try std.testing.expectEqualStrings("outline", r3.input.object.get("mode").?.string); - const r4 = redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"search parseHeader\"}")).?; - try std.testing.expectEqualStrings("mcp__codedbpro__faster_search", r4.name); - try std.testing.expectEqualStrings("parseHeader", r4.input.object.get("pattern").?.string); - try std.testing.expect(redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"callers foo\"}")) == null); + try std.testing.expect(redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"src/main.zig\"}")) == null); + try std.testing.expect(redirect(ctx, jsonCall(a, "read_file", "{\"path\":\"a.zig\",\"start_line\":10,\"end_line\":40}")) == null); + try std.testing.expect(redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"outline src/main.zig\"}")) == null); + try std.testing.expect(redirect(ctx, jsonCall(a, "codedb", "{\"command\":\"search parseHeader\"}")) == null); // leading grep/rg → faster_search on the first bare token; find and piped grep stay out const r5 = redirect(ctx, bashCall(a, "rg TODO src")).?; try std.testing.expectEqualStrings("TODO", r5.input.object.get("pattern").?.string); diff --git a/src/session_start.zig b/src/session_start.zig index ed194136..99654bd2 100644 --- a/src/session_start.zig +++ b/src/session_start.zig @@ -487,13 +487,10 @@ pub fn probeLicensed(gpa: Allocator, io: Io) bool { return skills.probeCodedbproLicensed(gpa, io); } -/// A licensed codedb-pro is IN CHARGE of reads/searches (its guard refuses -/// the native codedb/read_file and points at these tools), which inverts the -/// #416 deferral premise: this is not a server "most sessions never call" — -/// the guard makes it mandatory, and the load_tool_schemas discovery dance is -/// a measured ~2 model round-trips (~8-12s) per task on K3. Pin it eager. -/// (#476 kept it deferred on a "zero discovery turns" claim; the benchmark -/// traces show every code-reading run paying the dance.) +/// A licensed codedb-pro still pins these tools eager (search/batch extras; +/// ADR 0040: not the default reader). Skipping that pin costs a measured +/// ~2 load_tool_schemas discovery turns per task that does need pro search. +/// (#476 kept it deferred; traces showed every such run paying the dance.) pub fn pinCompanionEager(arena: Allocator) void { if (mcp_schema_gate.pinnedEager("codedbpro")) return; // env/config already pinned const gate = &mcp_schema_gate.g_policy; diff --git a/src/skills.zig b/src/skills.zig index 627027e0..4160c911 100644 --- a/src/skills.zig +++ b/src/skills.zig @@ -60,23 +60,21 @@ pub const skills_registry = [_]SkillDef{ /// model knows when to reach for its tools. The native tools stay registered /// regardless — they are the fallback whenever an MCP call fails, is denied, /// or the server is disconnected/skipped. -/// Licensed-aware variant of the codedbpro note. When `codedb-pro probe` -/// succeeds (paid + usable) we inject THIS instead of the conservative -/// "prefer free codedb" note below — leaning into the tools the user pays for. -/// Edits inside the cwd stay native: edit_file/write_file are -/// /rewind-snapshotted and already splice via zigpatch, whereas codedb-pro -/// edit/patch/replace bypass /rewind. Explicit external targets use gated bash. -const codedbpro_note_licensed = "The codedb-pro MCP server is connected and LICENSED — its mcp__codedbpro__* tools (load once per session via load_tool_schemas, e.g. by query) REPLACE native read/search (read_file and the legacy codedb tool are hidden). Shell cat/grep/sed/head of source is redirected or refused. KEEP EDITS on native edit_file/write_file — codedb-pro write tools are hidden because they bypass /rewind. Size reads to the file: mode=full in ONE call for small files — outline/symbol/lines for files too big to read whole. Any codedb-pro failure unblocks the natives for the rest of the session."; +/// Licensed-aware variant of the codedbpro note. Native `codedb` and +/// `read_file` are the default readers (ADR 0040) — do not send ordinary +/// file reads through mcp__codedbpro__read. Pro is extra search/batch. +/// Edits stay native: edit_file/write_file are /rewind-snapshotted. +const codedbpro_note_licensed = "The codedb-pro MCP server is connected and LICENSED — mcp__codedbpro__* tools (load once per session via load_tool_schemas, e.g. by query) are extra, not the default reader. READ with native codedb (context/around/callpath/list_dir/status) or read_file. Do not use mcp__codedbpro__read for ordinary files. SEARCH: codedb first; faster_search/meta_search only when codedb cannot answer (raw regex, fuzzy, non-indexed). KEEP EDITS on native edit_file/write_file — codedb-pro write tools are hidden because they bypass /rewind."; const McpNote = struct { server: []const u8, note: []const u8 }; pub const mcp_notes = [_]McpNote{ .{ .server = "codedbpro", - .note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath/list_dir/status); reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex content matches, fuzzy queries, non-indexed files) — codedb-pro is metered. Prefer mcp__codedbpro__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__codedbpro__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.", + .note = "The codedb-pro MCP server is connected (mcp__codedbpro__* tools). READ with native codedb (context/around/callpath/list_dir/status) or read_file — do not use mcp__codedbpro__read for ordinary files. SEARCH ORDER: codedb first; reach for mcp__codedbpro__faster_search or meta_search only when codedb can't answer (raw literal/regex, fuzzy, non-indexed) — codedb-pro is metered. Keep edits on native edit_file/write_file (/rewind-tracked). These tools are accelerators, not requirements: if an mcp__codedbpro__ call fails, fall back to read_file/codedb/bash and continue.", }, .{ .server = "muonry", - .note = "The muonry MCP server is connected (mcp__muonry__* tools). SEARCH ORDER: the native codedb tool is free and indexed — always try it first (context/around/callpath/list_dir/status); use mcp__muonry__search or faster_search only when codedb can't answer (raw literal/regex content matches, non-code or non-indexed files) — muonry is metered. Prefer mcp__muonry__read (mode=outline first, then symbol) over read_file for navigating large code files, and mcp__muonry__batch to run several independent reads/searches/edits in one round-trip. Keep edits on the native edit_file/write_file tools (/rewind-tracked; the cwd and explicit-external-target rules above apply). These tools are accelerators, not requirements: if an mcp__muonry__ call fails, fall back to read_file/codedb/bash and continue.", + .note = "The muonry MCP server is connected (mcp__muonry__* tools). READ with native codedb or read_file — do not use mcp__muonry__read for ordinary files. SEARCH ORDER: codedb first; muonry search/faster_search only when codedb can't answer. Keep edits on native edit_file/write_file (/rewind-tracked). Accelerators, not requirements: if a muonry call fails, fall back to read_file/codedb/bash and continue.", }, }; @@ -393,6 +391,8 @@ test "codedbproNote: licensed flips codedbpro to the lean-in note" { try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "mcp__codedbpro__*") != null); try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "load_tool_schemas") != null); try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "/rewind") != null); + try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "Do not use mcp__codedbpro__read") != null); + try std.testing.expect(std.mem.indexOf(u8, codedbpro_note_licensed, "READ with native codedb") != null); } test "skillIndex: registry lookup" { diff --git a/src/tool_gates.zig b/src/tool_gates.zig index b1700607..749ccc0a 100644 --- a/src/tool_gates.zig +++ b/src/tool_gates.zig @@ -92,7 +92,7 @@ pub fn withAvailable(comptime Spec: type, arena: Allocator, base: []const Spec, } /// One catalog-assembly pass: optional tools, then #330/#lean, then the -/// licensed surface filter (hide read_file/codedb when codedb-pro is in charge). +/// licensed surface filter (ADR 0040: do not hide codedb or read_file). pub fn assembleRoot(comptime Spec: type, arena: Allocator, base: []const Spec, optional: []const Spec) ![]const Spec { const no_local_tools = @import("no_local_tools.zig"); const tool_surface = @import("tool_surface.zig"); diff --git a/src/tool_surface.zig b/src/tool_surface.zig index 06096a3e..bb990d74 100644 --- a/src/tool_surface.zig +++ b/src/tool_surface.zig @@ -1,9 +1,9 @@ -//! Licensed catalog/exec policy: one read/search surface, one edit surface. +//! Licensed catalog/exec policy: one edit surface; reads stay native. //! -//! When codedb-pro is licensed, hide native `read_file` and legacy `codedb`. -//! Companion write tools (edit/patch/create/replace) are omitted always — they -//! bypass /rewind. Native `edit_file`/`write_file` stay. `subagent` stays. -//! webfetch is not hidden here (optional extra, not this change). +//! Native `codedb` and `read_file` stay advertised (ADR 0040). Companion +//! write tools (edit/patch/create/replace) are omitted always — they bypass +//! /rewind. Native `edit_file`/`write_file` stay. `subagent` stays. webfetch +//! is not hidden here (optional extra, not this change). const std = @import("std"); const Allocator = std.mem.Allocator; @@ -20,11 +20,21 @@ const search_bash = [_][]const u8{ "grep", "rg", "find", "cat", "head", "tail", "sed", "awk", "egrep", "ripgrep", }; +/// Content-search CLIs only — not `cat`/`head` (those are reads; codedb/read_file). +const content_search_bash = [_][]const u8{ + "grep", "rg", "egrep", "ripgrep", +}; + pub fn isSearchBash(first: []const u8) bool { for (search_bash) |w| if (std.mem.eql(u8, first, w)) return true; return false; } +pub fn isContentSearchBash(first: []const u8) bool { + for (content_search_bash) |w| if (std.mem.eql(u8, first, w)) return true; + return false; +} + pub fn isCompanionWrite(qualified: []const u8) bool { const prefixes = [_][]const u8{ "mcp__codedbpro__", "mcp__muonry__" }; for (prefixes) |prefix| { @@ -41,10 +51,11 @@ pub fn omitMcp(qualified: []const u8) bool { return isCompanionWrite(qualified); } -/// Built-ins hidden from the advertised catalog. Never hides `subagent`. +/// Built-ins hidden from the advertised catalog. Never hides `subagent`, +/// `codedb`, or `read_file` (ADR 0040 — pro is not the default reader). pub fn hideBuiltin(name: []const u8) bool { - if (!main_mod.g_codedbpro_licensed) return false; - return std.mem.eql(u8, name, "read_file") or std.mem.eql(u8, name, "codedb"); + _ = name; + return false; } pub fn filterSpecs(comptime Spec: type, arena: Allocator, specs: []const Spec) ![]const Spec { @@ -126,21 +137,18 @@ test "companion writes are omitted; reads are not" { try std.testing.expect(!keepWorkerMcp("mcp__codedbpro__edit")); } -test "hideBuiltin drops read_file/codedb only when licensed; never subagent" { +test "hideBuiltin never drops codedb, read_file, or subagent" { const saved = main_mod.g_codedbpro_licensed; defer main_mod.g_codedbpro_licensed = saved; - main_mod.g_codedbpro_licensed = false; - try std.testing.expect(!hideBuiltin("read_file")); - try std.testing.expect(!hideBuiltin("codedb")); - try std.testing.expect(!hideBuiltin("subagent")); - try std.testing.expect(!hideBuiltin("edit_file")); - main_mod.g_codedbpro_licensed = true; - try std.testing.expect(hideBuiltin("read_file")); - try std.testing.expect(hideBuiltin("codedb")); - try std.testing.expect(!hideBuiltin("subagent")); - try std.testing.expect(!hideBuiltin("edit_file")); - try std.testing.expect(!hideBuiltin("write_file")); - try std.testing.expect(!hideBuiltin("webfetch")); + for ([_]bool{ false, true }) |lic| { + main_mod.g_codedbpro_licensed = lic; + try std.testing.expect(!hideBuiltin("read_file")); + try std.testing.expect(!hideBuiltin("codedb")); + try std.testing.expect(!hideBuiltin("subagent")); + try std.testing.expect(!hideBuiltin("edit_file")); + try std.testing.expect(!hideBuiltin("write_file")); + try std.testing.expect(!hideBuiltin("webfetch")); + } } test "isSearchBash covers the licensed first-token list" { @@ -156,6 +164,11 @@ test "isSearchBash covers the licensed first-token list" { try std.testing.expect(isSearchBash("ripgrep")); try std.testing.expect(!isSearchBash("git")); try std.testing.expect(!isSearchBash("zig")); + try std.testing.expect(isContentSearchBash("rg")); + try std.testing.expect(isContentSearchBash("grep")); + try std.testing.expect(!isContentSearchBash("cat")); + try std.testing.expect(!isContentSearchBash("head")); + try std.testing.expect(!isContentSearchBash("find")); } const FakeSpec = struct { name: []const u8 }; @@ -176,13 +189,12 @@ test "filterSpecs drops hidden builtins and leaves the rest" { try std.testing.expectEqual(@as(usize, 5), all.len); main_mod.g_codedbpro_licensed = true; const filtered = try filterSpecs(FakeSpec, arena_state.allocator(), &specs); - try std.testing.expectEqual(@as(usize, 3), filtered.len); - try std.testing.expectEqualStrings("bash", filtered[0].name); - try std.testing.expectEqualStrings("edit_file", filtered[1].name); - try std.testing.expectEqualStrings("subagent", filtered[2].name); + try std.testing.expectEqual(@as(usize, 5), filtered.len); + try std.testing.expectEqualStrings("read_file", filtered[1].name); + try std.testing.expectEqualStrings("codedb", filtered[3].name); } -test "licensed root catalog drops read_file and codedb, keeps subagent and edit_file" { +test "licensed root catalog keeps read_file, codedb, subagent, and edit_file" { const schema = @import("schema.zig"); const saved = main_mod.g_codedbpro_licensed; defer main_mod.g_codedbpro_licensed = saved; @@ -205,15 +217,18 @@ test "licensed root catalog drops read_file and codedb, keeps subagent and edit_ const on = try schema.effectiveRootSpecs(a); var saw_edit = false; var saw_sub2 = false; + var saw_codedb = false; + var saw_read2 = false; for (on) |t| { - try std.testing.expect(!std.mem.eql(u8, t.name, "read_file")); - try std.testing.expect(!std.mem.eql(u8, t.name, "codedb")); + if (std.mem.eql(u8, t.name, "read_file")) saw_read2 = true; + if (std.mem.eql(u8, t.name, "codedb")) saw_codedb = true; if (std.mem.eql(u8, t.name, "edit_file")) saw_edit = true; if (std.mem.eql(u8, t.name, "subagent")) saw_sub2 = true; - if (std.mem.eql(u8, t.name, "write_file")) {} } try std.testing.expect(saw_edit); try std.testing.expect(saw_sub2); + try std.testing.expect(saw_codedb); + try std.testing.expect(saw_read2); } test "skipOptionalServer keeps deepwiki/mobbin opt-in" { @@ -227,7 +242,7 @@ test "skipOptionalServer keeps deepwiki/mobbin opt-in" { try std.testing.expect(!skipOptionalServer("mobbin", env)); } -test "worker specs drop read_file/codedb; worker MCP keeps reads not writes" { +test "worker specs keep read_file and codedb; worker MCP keeps pro reads not writes" { const schema = @import("schema.zig"); const saved = main_mod.g_codedbpro_licensed; defer main_mod.g_codedbpro_licensed = saved; @@ -237,13 +252,17 @@ test "worker specs drop read_file/codedb; worker MCP keeps reads not writes" { const a = arena_state.allocator(); const specs = try filterSpecs(@TypeOf(schema.base_specs[0]), a, schema.base_specs[0..]); var saw_edit = false; + var saw_codedb = false; + var saw_read = false; for (specs) |t| { - try std.testing.expect(!std.mem.eql(u8, t.name, "read_file")); - try std.testing.expect(!std.mem.eql(u8, t.name, "codedb")); try std.testing.expect(!std.mem.eql(u8, t.name, "subagent")); + if (std.mem.eql(u8, t.name, "read_file")) saw_read = true; + if (std.mem.eql(u8, t.name, "codedb")) saw_codedb = true; if (std.mem.eql(u8, t.name, "edit_file")) saw_edit = true; } try std.testing.expect(saw_edit); + try std.testing.expect(saw_codedb); + try std.testing.expect(saw_read); const fake = [_]mcp.Tool{ .{ .server_index = 0, .original_name = "read", .qualified_name = "mcp__codedbpro__read", .description = "d", .input_schema = .null }, .{ .server_index = 0, .original_name = "edit", .qualified_name = "mcp__codedbpro__edit", .description = "d", .input_schema = .null }, @@ -257,6 +276,6 @@ test "worker specs drop read_file/codedb; worker MCP keeps reads not writes" { mcp_gate.g_policy = .{ .eager = &.{"codedbpro"} }; const json = try schema.renderRootTools(a, .openai, specs, kept); try std.testing.expect(std.mem.indexOf(u8, json, "mcp__codedbpro__read") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "read_file") == null); + try std.testing.expect(std.mem.indexOf(u8, json, "read_file") != null); try std.testing.expect(std.mem.indexOf(u8, json, "mcp__codedbpro__edit") == null); } diff --git a/src/tools.zig b/src/tools.zig index 2a33d58d..29470607 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -195,7 +195,8 @@ pub fn runPostToolHooks(ctx: ToolCtx, call: ToolCall, out: ToolOutput) void { /// model adapts — same contract as a pre_tool hook's exit 2. pub fn codedbGuard(ctx: ToolCtx, call: ToolCall) ?ToolOutput { if (!main_mod.g_codedb_guard) return null; - if (main_mod.g_codedbpro_licensed) return null; // licensedGate owns read/search + // Licensed or not: shell reads of a concrete source file go to codedb, + // not mcp__codedbpro__read (ADR 0040). if (!std.mem.eql(u8, call.name, "bash")) return null; const cmd = strField(call.input, "command") orelse return null;