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///
320/// Source: opencode.ai/docs/permissions, the provider tool lists captured from v1.19.3 and
321/// v2.0.29 (`opencode-matrix.md`), and strings from both binaries. `edit` is the permission
322/// that covers `edit`, `write` and `patch`, so `write` has no name of its own. The task list
323/// is `todowrite` alone: `todoread` is in neither binary. `plan_enter` / `plan_exit` are v1.19.3
324/// permissions (its agent defaults deny them); v2.0.29 has no such names, where a rule for
325/// them is inert. OpenCode has no notebook, cron, monitor or tool-search tool.
326const OPENCODE: Table = &[
327    ("bash", Tools(&["bash"])),
328    ("ls", Tools(&["list"])),
329    ("work", Absent),
330    ("read", Tools(&["read"])),
331    ("write", Tools(&["edit"])),
332    ("edit", Tools(&["edit"])),
333    ("notebook", Absent),
334    ("agent", Tools(&["task"])),
335    ("skill", Tools(&["skill"])),
336    ("workflow", Absent),
337    ("glob", Tools(&["glob"])),
338    ("grep", Tools(&["grep"])),
339    ("web", Tools(&["websearch", "webfetch"])),
340    ("web_search", Tools(&["websearch"])),
341    ("web_fetch", Tools(&["webfetch"])),
342    ("ask_user", Tools(&["question"])),
343    ("task", Tools(&["todowrite"])),
344    ("plan_mode", Tools(&["plan_enter", "plan_exit"])),
345    ("cron", Absent),
346    ("notifications", Absent),
347    ("worktree", Absent),
348    ("lsp", Tools(&["lsp"])),
349    ("monitor", Absent),
350    ("send_user_file", Absent),
351    ("schedule_wakeup", Absent),
352    ("remote_trigger", Absent),
353    ("tool_search", Absent),
354    (
355        "image",
356        NoSwitch("opencode views images through read; no switch"),
357    ),
358];
359
360/// Pi 1.1.0 / 1.2.0, built-in tools only.
361///
362/// Source: `pi --help` and the installed package's tool registry
363/// (`dist/core/tools/index.js`: bash, edit, find, grep, ls, powershell, read, write). Pi
364/// grows by extensions, which Mars cannot see: `absent` means "no built-in tool", and
365/// Meridian's own extensions register tools (`bash_manage`) that no capability names. Pi's
366/// `edit` and `write` are separate tools, so the `edit` capability covers both. Pi has skills
367/// (`--skill`, `--no-skills`) but they load by reading `SKILL.md`, with no tool to switch
368/// (`NoSwitch`).
369const PI: Table = &[
370    ("bash", Tools(&["bash"])),
371    ("ls", Tools(&["ls"])),
372    ("work", Absent),
373    ("read", Tools(&["read"])),
374    ("write", Tools(&["write"])),
375    ("edit", Tools(&["edit", "write"])),
376    ("notebook", Absent),
377    ("agent", Absent),
378    (
379        "skill",
380        NoSwitch("pi loads skills by reading files; no switch"),
381    ),
382    ("workflow", Absent),
383    ("glob", Tools(&["find"])),
384    ("grep", Tools(&["grep"])),
385    ("web", Absent),
386    ("web_search", Absent),
387    ("web_fetch", Absent),
388    ("ask_user", Absent),
389    ("task", Absent),
390    ("plan_mode", Absent),
391    ("cron", Absent),
392    ("notifications", Absent),
393    ("worktree", Absent),
394    ("lsp", Absent),
395    ("monitor", Absent),
396    ("send_user_file", Absent),
397    ("schedule_wakeup", Absent),
398    ("remote_trigger", Absent),
399    ("tool_search", Absent),
400    ("image", NoSwitch("pi views images through read; no switch")),
401];
402
403/// Cursor. Unmaintained: these rows are carried over unchanged from Mars 0.16's
404/// overrides and PascalCase convention, not re-verified against a current Cursor. Cursor
405/// `edit` has always named only `StrReplace`, so it does not cover `Write`. Anything the old
406/// convention guessed beyond these rows is now `unknown`.
407const CURSOR: Table = &[
408    ("bash", Tools(&["Shell"])),
409    ("ls", Absent),
410    ("work", Absent),
411    ("read", Tools(&["Read"])),
412    ("write", Tools(&["Write"])),
413    ("edit", Tools(&["StrReplace"])),
414    ("notebook", Tools(&["EditNotebook"])),
415    ("agent", Tools(&["Task"])),
416    ("skill", Tools(&["Skill"])),
417    ("glob", Tools(&["Glob"])),
418    ("grep", Tools(&["Grep"])),
419    ("web", Tools(&["WebSearch", "WebFetch"])),
420    ("web_search", Tools(&["WebSearch"])),
421    ("web_fetch", Tools(&["WebFetch"])),
422    ("ask_user", Tools(&["AskQuestion"])),
423    ("task", Tools(&["TodoWrite"])),
424    ("plan_mode", Tools(&["SwitchMode"])),
425];
426
427/// Meridian Flow's own tools (mars-agents#207). Flow is a consumer, not a `HarnessId`, so
428/// its table is published in the vocabulary but never used to lower a native file. `search`
429/// is Flow's name for `grep`; `ask_user` maps to `ask`, which is not a capability alias because
430/// `ask` is a tool-policy action word.
431const FLOW: Table = &[
432    ("read", Tools(&["read"])),
433    ("write", Tools(&["write"])),
434    ("edit", Tools(&["write"])),
435    ("grep", Tools(&["grep"])),
436    ("glob", Tools(&["glob"])),
437    ("ls", Tools(&["ls"])),
438    ("skill", Tools(&["skill"])),
439    ("ask_user", Tools(&["ask"])),
440    (
441        "agent",
442        Tools(&[
443            "spawn",
444            "thread_message",
445            "thread_report",
446            "thread_ls",
447            "thread_history",
448        ]),
449    ),
450    ("work", Tools(&["work"])),
451];
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    /// Every tool each harness ships, with where the list came from. A name in a table above
458    /// that is not here is a guess.
459    fn real_tools(harness: HarnessId) -> &'static [&'static str] {
460        match harness {
461            // Tools reference + 2.1.307 binary, plus `TaskOutput`: removed in 2.1.277, so 2.1.307
462            // no longer offers it, but older builds do (see the `task` row).
463            HarnessId::Claude => &[
464                "Agent",
465                "Artifact",
466                "AskUserQuestion",
467                "Bash",
468                "CronCreate",
469                "CronDelete",
470                "CronList",
471                "DesignSync",
472                "Edit",
473                "EndConversation",
474                "EnterPlanMode",
475                "EnterWorktree",
476                "ExitPlanMode",
477                "ExitWorktree",
478                "Glob",
479                "Grep",
480                "ListAgents",
481                "ListMcpResourcesTool",
482                "LSP",
483                "Monitor",
484                "NotebookEdit",
485                "PowerShell",
486                "PushNotification",
487                "Read",
488                "ReadMcpResourceTool",
489                "RemoteTrigger",
490                "ReportFindings",
491                "ScheduleWakeup",
492                "SendFeedback",
493                "SendMessage",
494                "SendUserFile",
495                "ShareOnboardingGuide",
496                "Skill",
497                "SubagentHandback",
498                "TaskCreate",
499                "TaskGet",
500                "TaskList",
501                "TaskOutput",
502                "TaskStop",
503                "TaskUpdate",
504                "TodoWrite",
505                "ToolSearch",
506                "WaitForMcpServers",
507                "WebFetch",
508                "WebSearch",
509                "Workflow",
510                "Write",
511            ],
512            // 0.163.0 binary: tool handlers (`core/src/tools/handlers/`) and registered tool
513            // names. Leaves out experimental and internal tools no capability names (`sleep`,
514            // `current_time`, `skills.list`, `skills.read`, memory and message-board tools).
515            HarnessId::Codex => &[
516                "apply_patch",
517                "close_agent",
518                "exec_command",
519                "followup_task",
520                "image_generation",
521                "interrupt_agent",
522                "js_repl",
523                "list_agents",
524                "list_mcp_resource_templates",
525                "list_mcp_resources",
526                "read_mcp_resource",
527                "request_permissions",
528                "request_user_input",
529                "resume_agent",
530                "send_input",
531                "send_message",
532                "spawn_agent",
533                "tool_search",
534                "update_plan",
535                "view_image",
536                "wait_agent",
537                "web_search",
538                "write_stdin",
539            ],
540            // v1 permission names: opencode.ai/docs/permissions, v1.19.3 / v2.0.29 captures and
541            // binaries.
542            HarnessId::OpenCode => &[
543                "bash",
544                "edit",
545                "glob",
546                "grep",
547                "list",
548                "lsp",
549                "plan_enter",
550                "plan_exit",
551                "question",
552                "read",
553                "skill",
554                "task",
555                "todowrite",
556                "webfetch",
557                "websearch",
558            ],
559            // Package `@earendil-works/pi-coding-agent` dist/core/tools/index.js.
560            HarnessId::Pi => &[
561                "bash",
562                "edit",
563                "find",
564                "grep",
565                "ls",
566                "powershell",
567                "read",
568                "write",
569            ],
570            // Mars 0.16 table; not re-verified (target unmaintained).
571            HarnessId::Cursor => &[
572                "AskQuestion",
573                "EditNotebook",
574                "Glob",
575                "Grep",
576                "Read",
577                "Shell",
578                "Skill",
579                "StrReplace",
580                "SwitchMode",
581                "Task",
582                "TodoWrite",
583                "WebFetch",
584                "WebSearch",
585                "Write",
586            ],
587        }
588    }
589
590    #[test]
591    fn every_native_name_and_native_tool_is_a_real_tool_of_its_harness() {
592        for &harness in crate::harness::ALL {
593            let real = real_tools(harness);
594            for (capability, native) in table(harness) {
595                let lists: &[&[&str]] = match native {
596                    Tools(names) => &[names],
597                    ScopedTools {
598                        unscoped, scoped, ..
599                    } => &[unscoped, scoped],
600                    Absent | NoSwitch(_) => &[],
601                };
602                for names in lists {
603                    assert!(
604                        !names.is_empty(),
605                        "{harness:?} `{capability}` names no tool"
606                    );
607                    for name in *names {
608                        assert!(
609                            real.contains(name),
610                            "{harness:?} `{capability}` maps to `{name}`, which is not in its real tool list"
611                        );
612                    }
613                }
614                assert!(
615                    native.native_tools().iter().all(|name| real.contains(name)),
616                    "{harness:?} `{capability}` has a native tool outside its real tool list"
617                );
618            }
619        }
620    }
621
622    #[test]
623    fn claude_scoped_file_rules_separate_permission_names_from_native_tools() {
624        for (capability, unscoped, scoped) in [
625            ("write", &["Write"][..], &["Edit"][..]),
626            (
627                "edit",
628                &["Edit", "Write", "NotebookEdit"][..],
629                &["Edit"][..],
630            ),
631            ("notebook", &["NotebookEdit"][..], &["Edit"][..]),
632            ("glob", &["Glob"][..], &["Read"][..]),
633            ("grep", &["Grep"][..], &["Read"][..]),
634        ] {
635            let Some(ScopedTools {
636                unscoped: actual_unscoped,
637                scoped: actual_scoped,
638                note,
639            }) = lookup(capability, HarnessId::Claude)
640            else {
641                panic!("Claude `{capability}` must distinguish bare and scoped rules");
642            };
643            assert_eq!(actual_unscoped, unscoped, "{capability} bare rule");
644            assert_eq!(actual_scoped, scoped, "{capability} scoped rule");
645            assert_eq!(
646                lookup(capability, HarnessId::Claude)
647                    .expect("row")
648                    .native_tools(),
649                unscoped,
650                "{capability} availability tools"
651            );
652            assert!(
653                !note.is_empty(),
654                "{capability} must explain the wider category"
655            );
656        }
657
658        assert_eq!(
659            lookup("web_fetch", HarnessId::Claude),
660            Some(Tools(&["WebFetch"]))
661        );
662    }
663
664    #[test]
665    fn every_row_is_a_canonical_capability_named_once() {
666        for &harness in crate::harness::ALL {
667            let mut seen = std::collections::HashSet::new();
668            for (capability, _) in table(harness) {
669                assert!(
670                    crate::tool_names::is_canonical_capability(capability),
671                    "{harness:?} has a row for `{capability}`, which is not a canonical capability"
672                );
673                assert!(
674                    seen.insert(*capability),
675                    "{harness:?} repeats `{capability}`"
676                );
677            }
678        }
679    }
680
681    #[test]
682    fn maintained_harnesses_answer_for_every_canonical_capability() {
683        // A missing row means Mars has not decided. The maintained harnesses decide every
684        // capability: tools, `Absent`, or `NoSwitch`.
685        for harness in [
686            HarnessId::Claude,
687            HarnessId::Codex,
688            HarnessId::OpenCode,
689            HarnessId::Pi,
690        ] {
691            for capability in crate::tool_names::canonical_capabilities() {
692                assert!(
693                    lookup(capability, harness).is_some(),
694                    "{harness:?} has no row for `{capability}`"
695                );
696            }
697        }
698    }
699}