Skip to content

A deny rule naming a tool literally failed open when the name was also a glob - #75

Merged
Shashankss1205 merged 2 commits into
mainfrom
fix/issue-59
Aug 3, 2026
Merged

A deny rule naming a tool literally failed open when the name was also a glob#75
Shashankss1205 merged 2 commits into
mainfrom
fix/issue-59

Conversation

@Shashankss1205

Copy link
Copy Markdown
Collaborator

Fixes #59.

What was wrong

PermissionPolicy.decide matched rules with fnmatch(tool_name, rule.pattern) and nothing else. pattern is documented as a glob, so the obvious way to write a rule — paste the tool's exact name — silently stopped matching the moment that name carried [], because fnmatch reads it as a character class. The rule did not fire, evaluation fell through to whatever came next (typically a broad ALLOW "*"), and the tool ran.

ToolRegistry.visible() decides through the same call, so the tool the operator had just forbidden was also described to the model as available. The model was invited to call the thing it may not have, and the call went through.

Before, on main:

decide:   allow
visible:  ['exfil[all]']
call:     DID IT

After:

decide:   deny
visible:  []
call:     PermissionDenied: tool 'exfil[all]' denied by policy

The failure was inconsistent as well as silent: DENY "tool?x" happened to hold, because a ? glob matches a literal ?. And it was the one place in this tree where a deny failed open — an unmatched tool defaults to DENY, an unregistered kind is refused, an unreachable backend raises.

The fix

A rule now matches on fnmatch(name, pattern) or name == pattern, moved onto PermissionRule.matches so decide() and visible() cannot drift apart — visible() already goes through decide(), and there is now a single place where matching is defined.

Which tiers were widened, and why only those. The literal equality is bound to DENY and ASK. That binding is the whole of the safety argument:

  • Adding a match to a deny rule can only take a tool from ask/allow/default to deny. Adding one to an ask rule can only take a tool from allow or the default to ask, since deny is evaluated first and its own matching is only ever wider than before. Neither can turn a refusal into a grant — the widening moves strictly in the fail-closed direction, which is the direction the rest of this module already fails.
  • The same widening on allow would move in the opposite direction: it could grant a tool on a string the operator wrote intending a glob, which is a policy loosened by an upgrade. So allow keeps glob-only matching.

For the case allow genuinely needs — permitting one tool whose name carries *, ? or [ — there is PermissionRule.literal(action, name), which stores glob.escape(name). That escapes rather than widens, so it names exactly one tool at any tier and is the right constructor whenever the name comes from a registry rather than from an operator writing patterns by hand.

Callers checked

  • grapharc/stdlib.py:default_harness builds ALLOW rules from literal core tool names, which is exactly the escape-don't-widen case, so it uses PermissionRule.literal now. The core tool names (read_file, grep, run_command, …) hold no metacharacters and core_tools(include=…) rejects any name outside that set, so this changes no behaviour today; it is correct by construction rather than by luck if the set ever grows.
  • grapharc/cli/agent.py:build_policy turns --allow/--deny/--ask flags into rules. Those are operator-written patterns and stay globs; the deny/ask ones simply pick up the literal fallback.
  • grapharc/policy/engine.py:permission_policy compiles a TOML document's match fields, documented as fnmatch, and is unchanged — it inherits the same tier semantics, so the compiled policy and check_tool still agree.

Preserved behaviour

Explicitly tested, not just asserted: rm* still denies rmdir and rm, * still matches everything, a glob still matches through every tier rather than only its own text, the deny → ask → allow ordering is independent of rule order, and an unmatched tool still falls to the DENY default.

Tests

Added to tests/test_harness_gate.py:

  • test_deny_by_literal_name_beats_a_broad_allow_when_the_name_is_a_glob — the issue's acceptance criterion, checked in all three directions: decide(), visible(), and end to end through Harness.call.
  • test_ask_by_literal_name_gates_a_glob_shaped_name — the same for ASK with an MCP-style mcp__srv__do[all]; it stays visible (ASK is not DENY) but is refused without an approver.
  • test_allow_stays_glob_only_and_literal_escapes_instead — pins the asymmetry, so a later "make it consistent" cleanup has to argue with a test: an ALLOW "exfil[all]" glob rule leaves the tool on the DENY default, and PermissionRule.literal is what grants it.
  • test_glob_matching_is_unchanged_by_the_literal_fallback — the regression guard for every existing glob semantic.

The first three fail on main (two as allow/visible/executed, one as AttributeError: literal); the fourth passes before and after, which is its job.

Docs: the permissions recipe in docs/cookbook/03-agents-and-tools.md now states the rule, and CHANGELOG.md carries the defect entry in the section's usual form.

Verification

1779 passed, 12 deselected — the full suite, green, with no flaky SIGALRM timing failures on either this branch or untouched main. ruff check . clean.

🤖 Generated with Claude Code

Shashankss1205 and others added 2 commits August 4, 2026 00:36
…o a glob

`PermissionPolicy.decide` matched rules with `fnmatch(tool_name, rule.pattern)` and
nothing else. `pattern` is documented as a glob, so the obvious way to write a rule —
paste the tool's exact name — silently stopped matching the moment that name carried
`[`…`]`, because fnmatch reads it as a character class:

    DENY "exfil[all]"   does not match the tool exfil[all]
    ALLOW "*"           does
    -> allow

The operator got no error, no warning and no deny. `ToolRegistry.visible()` decides
through the same call, so the tool the operator had just forbidden was also described
to the model as available — the model was actively invited to call the thing it may
not have, and the call went through. Verified end to end, not only at the policy layer.

The failure was inconsistent as well as silent: `DENY "tool?x"` happened to hold,
because a `?` glob matches a literal `?`. And it was the one place in this tree where
a *deny* failed open — an unmatched tool defaults to DENY, an unregistered kind is
refused, an unreachable backend raises.

A rule now matches on `fnmatch(name, pattern) or name == pattern`, with the equality
bound to the DENY and ASK tiers. That binding is the whole of the safety argument:
adding a match to deny or ask can only ever refuse or gate a call that would otherwise
have run, so it cannot loosen any policy. The same widening on ALLOW could grant a
tool on a pattern the operator wrote as a glob, so ALLOW stays glob-only, and the case
it needs is served by `PermissionRule.literal(action, name)`, which stores
`glob.escape(name)` and so names one tool exactly at any tier. `default_harness`
builds its ALLOW rules from registry names rather than from operator patterns, so it
uses `literal` now; the core tool names carry no metacharacters, so nothing there
changes behaviour today.

Glob semantics are untouched, and tested as such: `rm*` still spans `rmdir`, `*` still
matches everything, a glob still matches through every tier rather than only its own
text, the deny -> ask -> allow ordering is unchanged, and an unmatched tool still falls
to the DENY default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Shashankss1205
Shashankss1205 merged commit ecfea58 into main Aug 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

permissions: a DENY rule naming a tool literally fails open when the name contains fnmatch metacharacters

1 participant