Skip to main content

mars_schema/
tool_natives.rs

1//! Explicit per-harness native tool names for Mars capabilities.
2//!
3//! One table per harness, one row per capability. Nothing is derived by naming convention: a
4//! row names the harness's real tools (one capability can cover several), says the harness has
5//! no such tool ([`Native::Absent`]), says it does the thing without a tool to switch
6//! ([`Native::NoSwitch`], with the reason), or is missing, which means Mars has not decided for
7//! that harness. Both `NoSwitch` and a missing row report as `unknown`, a gap Meridian
8//! surfaces, because calling an unenforceable deny `absent` would report it as met. A
9//! `NoSwitch` row is a settled fact and carries its reason in the bundle's `note`; a missing
10//! row says Mars has not decided.
11//! [`Native::native_tools`] always returns the unscoped harness tool set. It stays distinct
12//! from a scoped permission spelling such as Claude `Edit(path)` for the `Write` tool.
13//!
14//! Every name below must appear in the harness's real tool list, which the tests pin with the
15//! source each list came from. When a harness ships a tool, add it to that list first.
16
17use std::collections::BTreeMap;
18
19use serde::Serialize;
20
21use crate::HarnessId;
22
23/// What a harness calls a capability.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Native {
26    /// The harness's tools for this capability. A deny or allow covers all of them.
27    Tools(&'static [&'static str]),
28    /// A harness uses different permission names once a rule has a scope. Claude's file tools
29    /// are the motivating case: bare `Write` gates that tool, but only `Edit(path)` gates a
30    /// path for every built-in file editor.
31    ScopedTools {
32        unscoped: &'static [&'static str],
33        scoped: &'static [&'static str],
34        note: &'static str,
35    },
36    /// The harness has no such tool, so there is nothing to allow or deny.
37    Absent,
38    /// The harness can do the thing but offers no tool to switch it off (Codex and Pi load
39    /// skills by reading `SKILL.md`; Codex reads, globs and greps through its shell). The
40    /// string says why, for the bundle's `note`.
41    NoSwitch(&'static str),
42}
43
44impl Native {
45    /// The unscoped harness tool names this permission row concerns. Scoped permission names can
46    /// differ from these tools, but availability controls always use the unscoped tool set.
47    pub fn native_tools(self) -> &'static [&'static str] {
48        match self {
49            Self::Tools(names)
50            | Self::ScopedTools {
51                unscoped: names, ..
52            } => names,
53            Self::Absent | Self::NoSwitch(_) => &[],
54        }
55    }
56}
57
58use Native::{Absent, NoSwitch, ScopedTools, Tools};
59
60type Table = &'static [(&'static str, Native)];
61
62/// The row for `capability` on `harness`, or `None` when Mars has no entry.
63pub fn lookup(capability: &str, harness: HarnessId) -> Option<Native> {
64    table(harness)
65        .iter()
66        .find(|(name, _)| *name == capability)
67        .map(|(_, native)| *native)
68}
69
70/// What a consumer reads of the tool vocabulary: every capability and, per host, what the
71/// host calls it. Derived only from the tables in this file.
72#[derive(Debug, Clone, Serialize)]
73pub struct Vocabulary {
74    pub capabilities: Vec<crate::tool_names::CapabilityEntry>,
75    pub natives: BTreeMap<&'static str, BTreeMap<&'static str, NativeEntry>>,
76}
77
78/// One row of a host's table, in the published shape.
79#[derive(Debug, Clone, Serialize)]
80#[serde(tag = "kind", rename_all = "kebab-case")]
81pub enum NativeEntry {
82    Tools {
83        tools: &'static [&'static str],
84    },
85    ScopedTools {
86        unscoped: &'static [&'static str],
87        scoped: &'static [&'static str],
88        note: &'static str,
89    },
90    Absent,
91    NoSwitch {
92        note: &'static str,
93    },
94}
95
96impl From<Native> for NativeEntry {
97    fn from(native: Native) -> Self {
98        match native {
99            Tools(tools) => Self::Tools { tools },
100            ScopedTools {
101                unscoped,
102                scoped,
103                note,
104            } => Self::ScopedTools {
105                unscoped,
106                scoped,
107                note,
108            },
109            Absent => Self::Absent,
110            NoSwitch(note) => Self::NoSwitch { note },
111        }
112    }
113}
114
115/// The published vocabulary: the capability list plus one native table per harness and Flow.
116pub fn vocabulary() -> Vocabulary {
117    let rows = |table: Table| {
118        table
119            .iter()
120            .map(|(name, native)| (*name, NativeEntry::from(*native)))
121            .collect()
122    };
123    let mut natives: BTreeMap<_, _> = crate::harness::ALL
124        .iter()
125        .map(|host| (host.as_str(), rows(table(*host))))
126        .collect();
127    natives.insert("meridian-flow", rows(FLOW));
128    Vocabulary {
129        capabilities: crate::tool_names::capabilities(),
130        natives,
131    }
132}
133
134fn table(harness: HarnessId) -> Table {
135    match harness {
136        HarnessId::Claude => CLAUDE,
137        HarnessId::Codex => CODEX,
138        HarnessId::OpenCode => OPENCODE,
139        HarnessId::Pi => PI,
140        HarnessId::Cursor => CURSOR,
141    }
142}
143
144/// Claude Code 2.1.308.
145///
146/// Source: the tools and permissions references (code.claude.com/docs/en/tools-reference and
147/// code.claude.com/docs/en/permissions) and the 2.1.308
148/// binary. Probed live with `claude -p --disallowedTools`: `Agent` and `Task` both remove the
149/// sub-agent tool, `Edit` alone leaves `Write` and `NotebookEdit` available, and a misspelled
150/// name (`Websearch`) is silently ignored. Bare `edit` therefore names all three file-editing
151/// tools. Scoped file rules are different: Claude consults only `Edit(path)` and `Read(path)`,
152/// not `Write(path)`, `NotebookEdit(path)`, `Glob(path)`, or `Grep(path)`. Direct 2.1.308 probes
153/// confirmed the documented behavior. `WebFetch(domain:...)` is a valid scoped rule.
154/// `agent` is the sub-agent family, as it is on Codex: `Agent` spawns, `SendMessage` and
155/// `ListAgents` reach the agents it spawned, and an `agent: deny` that left them available would
156/// still let the agent steer sub-agents.
157const CLAUDE: Table = &[
158    ("bash", Tools(&["Bash"])),
159    ("read", Tools(&["Read"])),
160    // Claude lists through Bash; there is no listing tool.
161    ("ls", Absent),
162    ("work", Absent),
163    (
164        "write",
165        ScopedTools {
166            unscoped: &["Write"],
167            scoped: &["Edit"],
168            note: "Claude scopes Write through Edit(path): an allow adds no practical write power because Write can replace the whole file, while a deny also refuses other built-in file edits at that path",
169        },
170    ),
171    (
172        "edit",
173        ScopedTools {
174            unscoped: &["Edit", "Write", "NotebookEdit"],
175            scoped: &["Edit"],
176            note: "Claude uses Edit(path) to scope every built-in file-editing tool",
177        },
178    ),
179    (
180        "notebook",
181        ScopedTools {
182            unscoped: &["NotebookEdit"],
183            scoped: &["Edit"],
184            note: "Claude scopes NotebookEdit through Edit(path), which also covers other built-in file edits at that path",
185        },
186    ),
187    ("agent", Tools(&["Agent", "SendMessage", "ListAgents"])),
188    ("skill", Tools(&["Skill"])),
189    ("workflow", Tools(&["Workflow"])),
190    (
191        "glob",
192        ScopedTools {
193            unscoped: &["Glob"],
194            scoped: &["Read"],
195            note: "Claude scopes Glob paths through Read(path), which also covers other built-in file readers at that path",
196        },
197    ),
198    (
199        "grep",
200        ScopedTools {
201            unscoped: &["Grep"],
202            scoped: &["Read"],
203            note: "Claude scopes Grep paths through Read(path), which also covers other built-in file readers at that path",
204        },
205    ),
206    ("web", Tools(&["WebSearch", "WebFetch"])),
207    ("web_search", Tools(&["WebSearch"])),
208    ("web_fetch", Tools(&["WebFetch"])),
209    ("ask_user", Tools(&["AskUserQuestion"])),
210    // `TaskStop` stops background tasks; Meridian has always grouped it with the task list.
211    // `TaskOutput` was removed in 2.1.277: 2.1.307 lists it only among retired tool names and
212    // offers no such tool, and `--disallowedTools TaskOutput` there is silently ignored. It stays
213    // because older Claude builds still offer it and Meridian's platform rule denies it.
214    (
215        "task",
216        Tools(&[
217            "TaskCreate",
218            "TaskGet",
219            "TaskList",
220            "TaskOutput",
221            "TaskUpdate",
222            "TaskStop",
223            "TodoWrite",
224        ]),
225    ),
226    ("cron", Tools(&["CronCreate", "CronDelete", "CronList"])),
227    ("notifications", Tools(&["PushNotification"])),
228    ("plan_mode", Tools(&["EnterPlanMode", "ExitPlanMode"])),
229    ("worktree", Tools(&["EnterWorktree", "ExitWorktree"])),
230    ("lsp", Tools(&["LSP"])),
231    ("monitor", Tools(&["Monitor"])),
232    ("send_user_file", Tools(&["SendUserFile"])),
233    ("schedule_wakeup", Tools(&["ScheduleWakeup"])),
234    ("remote_trigger", Tools(&["RemoteTrigger"])),
235    ("tool_search", Tools(&["ToolSearch"])),
236    (
237        "image",
238        NoSwitch("claude views images through Read; no switch"),
239    ),
240];
241
242/// Codex 0.163.0.
243///
244/// Source: the 0.163.0 binary (`core/src/tools/handlers/*` and the tool names it registers).
245/// Shell execution is the unified `exec_command`. `write_stdin` only feeds a session
246/// `exec_command` opened, so `bash` names `exec_command` alone: Meridian gates Codex's shell
247/// with a switch, and denying `exec_command` leaves no session for `write_stdin` to feed.
248/// `agent` names both multi-agent tool sets the binary registers: v1
249/// (`multi_agents/`: spawn_agent, send_input, resume_agent, wait_agent, close_agent) and v2
250/// (`multi_agents_v2/`: spawn_agent, send_message, followup_task, wait_agent, interrupt_agent,
251/// list_agents); the model's `multi_agent_version` picks which one a session gets. Codex has no
252/// notebook tool. It has no file-read, glob or grep tool either, but it does all three through
253/// the shell (`NoSwitch`). `web` is Codex's hosted `web_search`, which also opens and searches
254/// within pages (`OpenPage`, `FindInPage` actions), so `web_fetch` has no tool of its own.
255/// `skill` loads by reading `SKILL.md`; the `skills.list` / `skills.read` tools of the skills
256/// extension are not something Mars can name. `update_plan` is the task list ("Updates the task
257/// plan.").
258const CODEX: Table = &[
259    ("bash", Tools(&["exec_command"])),
260    ("ls", Absent),
261    ("work", Absent),
262    (
263        "read",
264        NoSwitch("codex reads files through its shell; no switch"),
265    ),
266    ("write", Tools(&["apply_patch"])),
267    ("edit", Tools(&["apply_patch"])),
268    ("notebook", Absent),
269    (
270        "agent",
271        Tools(&[
272            "spawn_agent",
273            "send_input",
274            "resume_agent",
275            "wait_agent",
276            "close_agent",
277            "send_message",
278            "followup_task",
279            "interrupt_agent",
280            "list_agents",
281        ]),
282    ),
283    (
284        "skill",
285        NoSwitch("codex loads skills by reading files; no switch"),
286    ),
287    ("workflow", Absent),
288    (
289        "glob",
290        NoSwitch("codex finds files through its shell; no switch"),
291    ),
292    (
293        "grep",
294        NoSwitch("codex searches files through its shell; no switch"),
295    ),
296    ("web", Tools(&["web_search"])),
297    ("web_search", Tools(&["web_search"])),
298    (
299        "web_fetch",
300        NoSwitch("codex opens pages through hosted web_search; no separate switch"),
301    ),
302    ("ask_user", Tools(&["request_user_input"])),
303    ("task", Tools(&["update_plan"])),
304    ("cron", Absent),
305    ("notifications", Absent),
306    ("plan_mode", Absent),
307    ("worktree", Absent),
308    ("lsp", Absent),
309    ("monitor", Absent),
310    ("send_user_file", Absent),
311    ("schedule_wakeup", Absent),
312    ("remote_trigger", Absent),
313    ("tool_search", Tools(&["tool_search"])),
314    ("image", Tools(&["view_image"])),
315];
316
317/// OpenCode, in the v1.19.3 permission vocabulary. Meridian knows the running version and
318/// translates to v2 spellings (`bash` → `shell`, `task` → `subagent`, `write` → `edit`).
319/// Mars does the same for plugin agents in [`opencode_v2_action`], so keep the two in step.
320///
321/// Source: opencode.ai/docs/permissions, the provider tool lists captured from v1.19.3 and
322/// v2.0.29 (`opencode-matrix.md`), and strings from both binaries. `edit` is the permission
323/// that covers `edit`, `write` and `patch`, so `write` has no name of its own. The task list
324/// is `todowrite` alone: `todoread` is in neither binary. `plan_enter` / `plan_exit` are v1.19.3
325/// permissions (its agent defaults deny them); v2.0.29 has no such names, where a rule for
326/// them is inert. OpenCode has no notebook, cron, monitor or tool-search tool.
327const OPENCODE: Table = &[
328    ("bash", Tools(&["bash"])),
329    ("ls", Tools(&["list"])),
330    ("work", Absent),
331    ("read", Tools(&["read"])),
332    ("write", Tools(&["edit"])),
333    ("edit", Tools(&["edit"])),
334    ("notebook", Absent),
335    ("agent", Tools(&["task"])),
336    ("skill", Tools(&["skill"])),
337    ("workflow", Absent),
338    ("glob", Tools(&["glob"])),
339    ("grep", Tools(&["grep"])),
340    ("web", Tools(&["websearch", "webfetch"])),
341    ("web_search", Tools(&["websearch"])),
342    ("web_fetch", Tools(&["webfetch"])),
343    ("ask_user", Tools(&["question"])),
344    ("task", Tools(&["todowrite"])),
345    ("plan_mode", Tools(&["plan_enter", "plan_exit"])),
346    ("cron", Absent),
347    ("notifications", Absent),
348    ("worktree", Absent),
349    ("lsp", Tools(&["lsp"])),
350    ("monitor", Absent),
351    ("send_user_file", Absent),
352    ("schedule_wakeup", Absent),
353    ("remote_trigger", Absent),
354    ("tool_search", Absent),
355    (
356        "image",
357        NoSwitch("opencode views images through read; no switch"),
358    ),
359];
360
361/// The v2 spelling of a v1.19.3 OpenCode permission key, for a consumer that skips OpenCode's
362/// own agent-file loader (which does this translation): the plugin `ctx.agent` API takes v2
363/// rule actions. Keys v2 spells the same pass through.
364pub fn opencode_v2_action(v1: &str) -> &str {
365    match v1 {
366        "bash" => "shell",
367        "write" | "patch" => "edit",
368        "task" => "subagent",
369        other => other,
370    }
371}
372
373/// Pi 1.1.0 / 1.2.0, built-in tools only.
374///
375/// Source: `pi --help` and the installed package's tool registry
376/// (`dist/core/tools/index.js`: bash, edit, find, grep, ls, powershell, read, write). Pi
377/// grows by extensions, which Mars cannot see: `absent` means "no built-in tool", and
378/// Meridian's own extensions register tools (`bash_manage`) that no capability names. Pi's
379/// `edit` and `write` are separate tools, so the `edit` capability covers both. Pi has skills
380/// (`--skill`, `--no-skills`) but they load by reading `SKILL.md`, with no tool to switch
381/// (`NoSwitch`).
382const PI: Table = &[
383    ("bash", Tools(&["bash"])),
384    ("ls", Tools(&["ls"])),
385    ("work", Absent),
386    ("read", Tools(&["read"])),
387    ("write", Tools(&["write"])),
388    ("edit", Tools(&["edit", "write"])),
389    ("notebook", Absent),
390    ("agent", Absent),
391    (
392        "skill",
393        NoSwitch("pi loads skills by reading files; no switch"),
394    ),
395    ("workflow", Absent),
396    ("glob", Tools(&["find"])),
397    ("grep", Tools(&["grep"])),
398    ("web", Absent),
399    ("web_search", Absent),
400    ("web_fetch", Absent),
401    ("ask_user", Absent),
402    ("task", Absent),
403    ("plan_mode", Absent),
404    ("cron", Absent),
405    ("notifications", Absent),
406    ("worktree", Absent),
407    ("lsp", Absent),
408    ("monitor", Absent),
409    ("send_user_file", Absent),
410    ("schedule_wakeup", Absent),
411    ("remote_trigger", Absent),
412    ("tool_search", Absent),
413    ("image", NoSwitch("pi views images through read; no switch")),
414];
415
416/// Cursor. Unmaintained: these rows are carried over unchanged from Mars 0.16's
417/// overrides and PascalCase convention, not re-verified against a current Cursor. Cursor
418/// `edit` has always named only `StrReplace`, so it does not cover `Write`. Anything the old
419/// convention guessed beyond these rows is now `unknown`.
420const CURSOR: Table = &[
421    ("bash", Tools(&["Shell"])),
422    ("ls", Absent),
423    ("work", Absent),
424    ("read", Tools(&["Read"])),
425    ("write", Tools(&["Write"])),
426    ("edit", Tools(&["StrReplace"])),
427    ("notebook", Tools(&["EditNotebook"])),
428    ("agent", Tools(&["Task"])),
429    ("skill", Tools(&["Skill"])),
430    ("glob", Tools(&["Glob"])),
431    ("grep", Tools(&["Grep"])),
432    ("web", Tools(&["WebSearch", "WebFetch"])),
433    ("web_search", Tools(&["WebSearch"])),
434    ("web_fetch", Tools(&["WebFetch"])),
435    ("ask_user", Tools(&["AskQuestion"])),
436    ("task", Tools(&["TodoWrite"])),
437    ("plan_mode", Tools(&["SwitchMode"])),
438];
439
440/// Meridian Flow's own tools (mars-agents#207). Flow is a consumer, not a `HarnessId`, so
441/// its table is published in the vocabulary but never used to lower a native file. Flow's
442/// tool keeps the capability's name, `ask_user`; `ask` is only a tool-policy action word. Flow
443/// ships no `glob` tool yet, so `glob` is absent.
444const FLOW: Table = &[
445    ("read", Tools(&["read"])),
446    ("write", Tools(&["write"])),
447    ("edit", Tools(&["write"])),
448    ("grep", Tools(&["grep"])),
449    ("glob", Absent),
450    ("ls", Tools(&["ls"])),
451    ("skill", Tools(&["skill"])),
452    ("ask_user", Tools(&["ask_user"])),
453    (
454        "agent",
455        Tools(&[
456            "spawn",
457            "thread_message",
458            "thread_report",
459            "thread_ls",
460            "thread_history",
461        ]),
462    ),
463    ("work", Tools(&["work"])),
464];
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    /// Every tool each harness ships, with where the list came from. A name in a table above
471    /// that is not here is a guess.
472    fn real_tools(harness: HarnessId) -> &'static [&'static str] {
473        match harness {
474            // Tools reference + 2.1.307 binary, plus `TaskOutput`: removed in 2.1.277, so 2.1.307
475            // no longer offers it, but older builds do (see the `task` row).
476            HarnessId::Claude => &[
477                "Agent",
478                "Artifact",
479                "AskUserQuestion",
480                "Bash",
481                "CronCreate",
482                "CronDelete",
483                "CronList",
484                "DesignSync",
485                "Edit",
486                "EndConversation",
487                "EnterPlanMode",
488                "EnterWorktree",
489                "ExitPlanMode",
490                "ExitWorktree",
491                "Glob",
492                "Grep",
493                "ListAgents",
494                "ListMcpResourcesTool",
495                "LSP",
496                "Monitor",
497                "NotebookEdit",
498                "PowerShell",
499                "PushNotification",
500                "Read",
501                "ReadMcpResourceTool",
502                "RemoteTrigger",
503                "ReportFindings",
504                "ScheduleWakeup",
505                "SendFeedback",
506                "SendMessage",
507                "SendUserFile",
508                "ShareOnboardingGuide",
509                "Skill",
510                "SubagentHandback",
511                "TaskCreate",
512                "TaskGet",
513                "TaskList",
514                "TaskOutput",
515                "TaskStop",
516                "TaskUpdate",
517                "TodoWrite",
518                "ToolSearch",
519                "WaitForMcpServers",
520                "WebFetch",
521                "WebSearch",
522                "Workflow",
523                "Write",
524            ],
525            // 0.163.0 binary: tool handlers (`core/src/tools/handlers/`) and registered tool
526            // names. Leaves out experimental and internal tools no capability names (`sleep`,
527            // `current_time`, `skills.list`, `skills.read`, memory and message-board tools).
528            HarnessId::Codex => &[
529                "apply_patch",
530                "close_agent",
531                "exec_command",
532                "followup_task",
533                "image_generation",
534                "interrupt_agent",
535                "js_repl",
536                "list_agents",
537                "list_mcp_resource_templates",
538                "list_mcp_resources",
539                "read_mcp_resource",
540                "request_permissions",
541                "request_user_input",
542                "resume_agent",
543                "send_input",
544                "send_message",
545                "spawn_agent",
546                "tool_search",
547                "update_plan",
548                "view_image",
549                "wait_agent",
550                "web_search",
551                "write_stdin",
552            ],
553            // v1 permission names: opencode.ai/docs/permissions, v1.19.3 / v2.0.29 captures and
554            // binaries.
555            HarnessId::OpenCode => &[
556                "bash",
557                "edit",
558                "glob",
559                "grep",
560                "list",
561                "lsp",
562                "plan_enter",
563                "plan_exit",
564                "question",
565                "read",
566                "skill",
567                "task",
568                "todowrite",
569                "webfetch",
570                "websearch",
571            ],
572            // Package `@earendil-works/pi-coding-agent` dist/core/tools/index.js.
573            HarnessId::Pi => &[
574                "bash",
575                "edit",
576                "find",
577                "grep",
578                "ls",
579                "powershell",
580                "read",
581                "write",
582            ],
583            // Mars 0.16 table; not re-verified (target unmaintained).
584            HarnessId::Cursor => &[
585                "AskQuestion",
586                "EditNotebook",
587                "Glob",
588                "Grep",
589                "Read",
590                "Shell",
591                "Skill",
592                "StrReplace",
593                "SwitchMode",
594                "Task",
595                "TodoWrite",
596                "WebFetch",
597                "WebSearch",
598                "Write",
599            ],
600        }
601    }
602
603    #[test]
604    fn every_native_name_and_native_tool_is_a_real_tool_of_its_harness() {
605        for &harness in crate::harness::ALL {
606            let real = real_tools(harness);
607            for (capability, native) in table(harness) {
608                let lists: &[&[&str]] = match native {
609                    Tools(names) => &[names],
610                    ScopedTools {
611                        unscoped, scoped, ..
612                    } => &[unscoped, scoped],
613                    Absent | NoSwitch(_) => &[],
614                };
615                for names in lists {
616                    assert!(
617                        !names.is_empty(),
618                        "{harness:?} `{capability}` names no tool"
619                    );
620                    for name in *names {
621                        assert!(
622                            real.contains(name),
623                            "{harness:?} `{capability}` maps to `{name}`, which is not in its real tool list"
624                        );
625                    }
626                }
627                assert!(
628                    native.native_tools().iter().all(|name| real.contains(name)),
629                    "{harness:?} `{capability}` has a native tool outside its real tool list"
630                );
631            }
632        }
633    }
634
635    #[test]
636    fn claude_scoped_file_rules_separate_permission_names_from_native_tools() {
637        for (capability, unscoped, scoped) in [
638            ("write", &["Write"][..], &["Edit"][..]),
639            (
640                "edit",
641                &["Edit", "Write", "NotebookEdit"][..],
642                &["Edit"][..],
643            ),
644            ("notebook", &["NotebookEdit"][..], &["Edit"][..]),
645            ("glob", &["Glob"][..], &["Read"][..]),
646            ("grep", &["Grep"][..], &["Read"][..]),
647        ] {
648            let Some(ScopedTools {
649                unscoped: actual_unscoped,
650                scoped: actual_scoped,
651                note,
652            }) = lookup(capability, HarnessId::Claude)
653            else {
654                panic!("Claude `{capability}` must distinguish bare and scoped rules");
655            };
656            assert_eq!(actual_unscoped, unscoped, "{capability} bare rule");
657            assert_eq!(actual_scoped, scoped, "{capability} scoped rule");
658            assert_eq!(
659                lookup(capability, HarnessId::Claude)
660                    .expect("row")
661                    .native_tools(),
662                unscoped,
663                "{capability} availability tools"
664            );
665            assert!(
666                !note.is_empty(),
667                "{capability} must explain the wider category"
668            );
669        }
670
671        assert_eq!(
672            lookup("web_fetch", HarnessId::Claude),
673            Some(Tools(&["WebFetch"]))
674        );
675    }
676
677    #[test]
678    fn every_row_is_a_canonical_capability_named_once() {
679        for &harness in crate::harness::ALL {
680            let mut seen = std::collections::HashSet::new();
681            for (capability, _) in table(harness) {
682                assert!(
683                    crate::tool_names::is_canonical_capability(capability),
684                    "{harness:?} has a row for `{capability}`, which is not a canonical capability"
685                );
686                assert!(
687                    seen.insert(*capability),
688                    "{harness:?} repeats `{capability}`"
689                );
690            }
691        }
692    }
693
694    #[test]
695    fn maintained_harnesses_answer_for_every_canonical_capability() {
696        // A missing row means Mars has not decided. The maintained harnesses decide every
697        // capability: tools, `Absent`, or `NoSwitch`.
698        for harness in [
699            HarnessId::Claude,
700            HarnessId::Codex,
701            HarnessId::OpenCode,
702            HarnessId::Pi,
703        ] {
704            for capability in crate::tool_names::canonical_capabilities() {
705                assert!(
706                    lookup(capability, harness).is_some(),
707                    "{harness:?} has no row for `{capability}`"
708                );
709            }
710        }
711    }
712}