ROADMAP #94: permission rules accept typos, case-sensitive match disagrees with ecosystem convention, invisible in all diagnostic surfaces

Dogfooded 2026-04-18 on main HEAD 7f76e6b from /tmp/cdI. Three
stacked failures on the permission-rule surface:

(1) Typo tolerance. parse_optional_permission_rules at
    runtime/src/config.rs:780-798 is just optional_string_array with
    no per-entry validation. Typo rules like 'Reed', 'Bsh(echo:*)',
    'WebFech' load silently; doctor reports config: ok.

(2) Case-sensitive match against lowercase runtime names.
    PermissionRule::matches does self.tool_name != tool_name strict
    compare. Runtime registers tools lowercase (bash).
    Claude Code convention / MCP docs use capitalized (Bash). So
    'deny: ["Bash(rm:*)"]' never fires because tool_name='bash' !=
    rule.tool_name='Bash'. Cross-harness config portability fails
    open, not closed.

(3) Loaded rules invisible. status JSON has no permission_rules
    field. doctor has no rules check. A clawhip preflight asking
    'does this lane actually deny Bash(rm:*)?' has no
    machine-readable answer; has to re-parse .claw.json and
    re-implement parse semantics.

Contrast: --allowedTools CLI flag HAS tool-name validation with a
50+ tool registry. The same registry is not consulted when parsing
permissions.allow/deny/ask. Asymmetric validation, same shape as
#91 (config accepts more permission-mode labels than CLI).

Fix shape (~30-45 lines): validate rule tool names against the
same registry --allowedTools uses; case-fold tool_name compare in
PermissionRule::matches; expose loaded rules in status/doctor JSON
with unknown_tool flag.

Filed in response to Clawhip pinpoint nudge 1494736729582862446 in
#clawcode-building-in-public.
This commit is contained in:
YeonGyu-Kim 2026-04-18 01:34:15 +09:00
parent 7f76e6bbd6
commit b7539e679e

View File

@ -1894,3 +1894,42 @@ Original filing (2026-04-13): user requested a `-acp` parameter to support ACP p
**Blocker.** None. Canonicalize-and-check-prefix is ~15 lines in `resolve_reference`, plus error-type + test updates. The explicit-shape split is orthogonal and can ship separately.
**Source.** Jobdori dogfood 2026-04-18 against `/tmp/cdH` on main HEAD `bab66bb` in response to Clawhip pinpoint nudge at `1494729188895359097`. Sits between clusters: it's *partially* a discovery-overreach item (like #85/#88, the reference resolution reaches outside the workspace), *partially* a truth-audit item (the two error strings for the two branches don't tell the operator which branch was taken), and *partially* a reporting-surface item (the heuristic is invisible in `claw --help` and in `--output-format json` error payloads). Best filed as the first member of a new "reference-resolution semantics split" sub-cluster; if #80 (error copy lies about the managed-session search path) were reframed today it would be the natural sibling.
94. **Permission rules (`permissions.allow` / `permissions.deny` / `permissions.ask`) are loaded without validating tool names against the known tool registry, case-sensitively matched against the lowercase runtime tool names, and invisible in every diagnostic surface — so typos and case mismatches silently become non-enforcement** — dogfooded 2026-04-18 on main HEAD `7f76e6b` from `/tmp/cdI`. Operators copy `"Bash(rm:*)"` (capital-B, the convention used in most Claude Code docs and community configs) into `permissions.deny`; `claw doctor` reports `config: ok`; the rule never fires because the runtime tool name is lowercase `bash`.
**Three stacked failures.**
*Typos pass silently.*
```json
{"permissions":{"allow":["Reed","Bsh(echo:*)"],"deny":["Bash(rm:*)"],"ask":["WebFech"]}}
```
`claw --output-format json doctor``config: ok / runtime config loaded successfully`. None of `Reed`, `Bsh`, `WebFech` exists as a tool. All four rules load into the policy; three of them will never match anything.
*Case-sensitive match disagrees with ecosystem convention.* Upstream Claude Code documentation and community MCP-server READMEs uniformly write rule patterns as `Bash(...)` / `WebFetch` / `Read` (capitalized, matching the tool class name in TypeScript source). `claw`'s runtime registers tools in lowercase (`rust/crates/tools/src/lib.rs:388``name: "bash"`), and `PermissionRule::matches` at `runtime/src/permissions.rs:…` is a direct `self.tool_name != tool_name` early return with no case fold. Result: `"deny":["Bash(rm:*)"]` never denies anything because tool-name `bash` doesn't equal rule-name `Bash`.
*Loaded rules are invisible in every diagnostic surface.* `claw --output-format json status``{"permission_mode":"danger-full-access", ...}` with no `permission_rules` / `allow_rules` / `deny_rules` field. `claw --output-format json doctor``config: ok` with no detail about which rules loaded. `claw mcp` / `claw skills` / `claw agents` have their own JSON surfaces but `claw` has no `rules`-or-equivalent subcommand. A clawhip preflight that wants to verify "does this lane actually deny `Bash(rm:*)`?" has no machine-readable answer. The only way to confirm is to trigger the rule via a real tool invocation — which requires credentials and a live session.
**Trace path.**
- `rust/crates/runtime/src/config.rs:780-798``parse_optional_permission_rules` is `optional_string_array(permissions, "allow", ...)` / `"deny"` / `"ask"` with no per-entry validation. The schema validator at `rust/crates/runtime/src/config_validate.rs` enforces the top-level `permissions` key shape but not the *content* of the string arrays.
- `rust/crates/runtime/src/permissions.rs:~350``PermissionRule::parse(raw)` extracts `tool_name` and `matcher` from `<name>(<pattern>)` syntax but does not check `tool_name` against any registry. Typo tokens land in `PermissionPolicy.deny_rules` as `PermissionRule { raw: "Bsh(echo:*)", tool_name: "Bsh", matcher: Prefix("echo") }` and sit there unused.
- `rust/crates/runtime/src/permissions.rs:~390``PermissionRule::matches(&self, tool_name, input)``if self.tool_name != tool_name { return false; }`. Strict exact-string compare. No case fold, no alias table.
- `rust/crates/rusty-claude-cli/src/main.rs:4951-4955``status_context_json` emits `permission_mode` but not `permission_rules`. `check_workspace_health` / `check_sandbox_health` / `check_config_health` none mention rules. A claw that wants to audit its policy has to `cat .claw.json | jq` and hope the file is the only source.
**Contrast with the `--allowedTools` CLI flag — validation exists, just not here.** `claw --allowedTools FooBar` returns a clean error listing every registered tool alias (`bash, read_file, write_file, edit_file, glob_search, ..., PowerShell, ...` — 50+ tools). The same set is not consulted when parsing `permissions.allow` / `.deny` / `.ask`. Asymmetric validation — same shape as #91 (config accepts more permission-mode labels than the CLI flag) — but on a different surface.
**Why this is specifically a clawability gap.**
1. *Silent non-enforcement of safety rules.* An operator who writes `"deny":["Bash(rm:*)"]` expecting rm to be denied gets **no** enforcement on two independent failure modes: (a) the tool name `Bash` doesn't match the runtime's `bash`; (b) even if spelled correctly, a typo like `"Bsh(rm:*)"` accepts silently. Both produce the same observable state as "no rule configured" — `config: ok`, `permission_mode: ...`, indistinguishable from never having written the rule at all.
2. *Cross-harness config-portability break.* ROADMAP's implicit goal of running existing `.mcp.json` / Claude Code configs on `claw` (see PARITY.md) assumes the convention overlap is wide. Case-sensitive tool-name matching breaks portability at the permission layer specifically, silently, in exactly the direction that fails *open* (permissive) rather than fails *closed* (denying unknown tools).
3. *No preflight audit surface.* Clawhip-style orchestrators cannot implement "refuse to spawn this lane unless it denies `Bash(rm:*)`" because they can't read the policy post-parse. They have to re-parse `.claw.json` themselves — which means they also have to re-implement the `parse_optional_permission_rules` + `PermissionRule::parse` semantics to match what claw actually loaded.
4. *Runs contrary to the existing `--allowedTools` validation precedent.* The binary already knows the tool registry (as the `--allowedTools` error proves). Not threading the same list into the permission-rule parser is a small oversight with a large blast radius.
**Fix shape — three pieces, each small.**
1. *Validate rule tool names against the registered tool set at config-load time.* In `parse_optional_permission_rules`, call into the same tool-alias table used by `--allowedTools` normalization (likely `tools::normalize_tool_alias` or similar) and either (a) reject unknown names with `ConfigError::Parse`, or (b) capture them into `ConfigLoader::all_warnings` so a typo becomes visible in `doctor` without hard-failing startup. Option (a) is stricter; option (b) is less breaking for existing configs that already work by accident.
2. *Case-fold the tool-name compare in `PermissionRule::matches`.* Normalize both sides to lowercase (or to the registry's canonical casing) before the `!=` compare. Covers the `Bash` vs `bash` ecosystem-convention gap. Document the normalization in `USAGE.md` / `CLAUDE.md`.
3. *Expose loaded permission rules in `status` and `doctor` JSON.* Add `workspace.permission_rules: { allow: [...], deny: [...], ask: [...] }` to status JSON (each entry carrying `raw`, `resolved_tool_name`, `matcher`, and an `unknown_tool: bool` flag that flips true when the tool name didn't match the registry). Emit a `permission_rules` doctor check that reports `Warn` when any loaded rule references an unknown tool. Clawhip can now preflight on a typed field instead of re-parsing `.claw.json`.
**Acceptance.** A typo'd `"deny":["Bsh(rm:*)"]` produces a visible warning in `claw doctor` (and/or a hard error if piece 1(a) is chosen) naming the offending rule. `"deny":["Bash(rm:*)"]` actually denies `bash` invocations (via piece 2). `claw --output-format json status` exposes the resolved rule set so orchestrators can audit policy without re-parsing config.
**Blocker.** None. Tool-name validation is ~1015 lines reusing the existing `--allowedTools` registry. Case-fold is one `eq_ignore_ascii_case` call site. Status JSON exposure is ~2030 lines with a new `permission_rules_json` helper mirroring the existing `mcp_server_details_json` shape.
**Source.** Jobdori dogfood 2026-04-18 against `/tmp/cdI` on main HEAD `7f76e6b` in response to Clawhip pinpoint nudge at `1494736729582862446`. Stacks three independent failures on the permission-rule surface: (a) typo-accepting parser (truth-audit / diagnostic-integrity flavor — sibling of #86), (b) case-sensitive matcher against lowercase runtime names (reporting-surface / config-hygiene flavor — sibling of #91's alias-collapse), (c) rules invisible in every diagnostic surface (sibling of #87 permission-mode-source invisibility). Shares the permission-audit PR bundle alongside #50 / #87 / #91 — all four plug the same surface from different angles.