You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A deny rule naming a tool literally failed open when the name was also a glob (#75)
`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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -20,3 +20,4 @@ Entries are newest-last within a release, matching the order they were written.
20
20
- a **NUL byte in a path came back as silence**, the worst answer a chat bot can give: `Path(raw).resolve()` raises `ValueError`, `handle_text_live` catches only `SlackCommandError`, so `trace a\x00b` escaped the bolt listener as an unhandled exception and the requester saw no reply at all — indistinguishable from the bot being down. A NUL anywhere in the request is now a refusal in the same voice the core tools already use ("cannot name a file"), and `_confined` turns any `ValueError`/`OSError` out of the filesystem into a refusal too, for callers of its own. Folded in from the same report: the flag allowlist tested `token.startswith("--")`, so a single-dash token slipped it and was spent as a positional — `trace -h` was admitted with `-h` as the path. Any leading dash is a flag now, and one not on the list is refused like any other.
21
21
- the `/live`**token check crashed on the strangers it exists to refuse**. `secrets.compare_digest` rejects `str` outside ASCII, and `_authorized` handed it the raw query parameter, so `?token=café` raised `TypeError` through the handler: an unauthenticated 500 with a traceback in the log on all four `/live` routes, where every ASCII guess correctly got a 401. The 500-vs-401 split was itself an oracle about how the token is compared. Both sides are encoded to UTF-8 now, which drops the ASCII restriction and keeps the constant-time comparison that is the whole reason `compare_digest` is there. A NUL byte in `?trace=` was the same shape one function over — `resolve_trace` raises `ValueError`, not the `LivePathError` the route caught — and is a 404 like any other malformed path now.
22
22
- the `/live`**index advertised traces the reader refuses to serve**. `scan_traces` walked the live root with `rglob("*.jsonl")`, which matches a symlinked file by name, then parsed it and published its name, size, mtime and **run ids** on `GET /live/api/runs` and the HTML index — for a file outside the root that `/live/api/stream` then 404s, the 404 being the proof of intent. One contract, two code paths, and only the reader enforced it; the live root is documented as the Slack bot's working directory, i.e. somewhere other things write. `scan_traces` routes every candidate through `resolve_trace` now and skips symlinks outright, so a refactor of either check cannot reopen the leak. The reader's confinement — `../`, `%2e%2e%2f`, absolute paths, `sub/../../`, symlinked directories — is unchanged.
23
+
- a **`deny` rule naming a tool literally failed open** when the name carried fnmatch metacharacters. `PermissionPolicy.decide` matched with `fnmatch(name, pattern)` alone, so `DENY "exfil[all]"` read as a character class, did not match the tool it spells, and evaluation fell through to whatever came next — typically a broad `ALLOW "*"`. The operator got no error, no warning and no deny; worse, `visible()` decides the same way, so the tool the operator had just forbidden was described to the model as available and then ran when it asked. The failure was inconsistent as well as silent: `DENY "tool?x"` happened to hold, because a `?` glob matches a literal `?`. This was the one place in the tree where a deny failed open — an unmatched tool, an unregistered kind and an unreachable backend all refuse. A `deny` or `ask` rule now also fires on an exact literal match. The widening is bound to those two tiers on purpose: equality can only add a rule that refuses or gates a call, never one that permits it, so it cannot loosen a policy the way the same change on `allow` could. For the `allow` case there is `PermissionRule.literal(action, name)`, which `glob.escape`s the name rather than widening the match, and which `default_harness` now uses for the registry names it allows. Glob semantics are untouched: `rm*` still spans `rmdir`, `*` still matches everything, the tier order and the `deny` default are unchanged.
0 commit comments