From b7539e679e23cce99b3adfae34a986cee75270ae Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 18 Apr 2026 01:34:15 +0900 Subject: [PATCH] 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. --- ROADMAP.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d25f617..148e67a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 `()` 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 ~10–15 lines reusing the existing `--allowedTools` registry. Case-fold is one `eq_ignore_ascii_case` call site. Status JSON exposure is ~20–30 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.