Skip to content

fix(permissions): match deny and ask rules past assignments and wrappers (#4281) - #4284

Merged
KuSh merged 1 commit into
developfrom
fix/4281-gate-peel
Oct 14, 2026
Merged

KuSh merged 1 commit into
developfrom
fix/4281-gate-peel

Conversation

@KuSh

@KuSh KuSh commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Claude Code matches a deny or ask rule past any leading assignment and past a fixed set of wrappers — timeout, time, nice, nohup, stdbuf, command, builtin, noglob and a bare xargs — so Bash(git push *) stops HUSKY=0 git push and timeout 30 git push as well as git push. RTK's gate matched the segment as written and nothing else, so every one of those forms fell through to Default; under a host that treats Default as its own decision, as #3909's OpenClaw does, they auto-apply.

The rewrite already looked through the same prefixes: timeout 30 git push became timeout 30 rtk git push while the gate did not recognise git push inside it. That was two readings of which command a line runs, and the gap between them is this bug. The deny and ask loops now also try every reading matching_readings gives: the rewrite's own peels — its env peel, its keyword prefixes, its wrapper table — applied one prefix at a time, each assignment and each env word a prefix of its own, each intermediate command kept. Where both look through a prefix, the gate and the rewrite read the same command behind it, and a rule may name any layer: HUSKY=0 git push * stops env HUSKY=0 git push, and nohup HUSKY=0 git push too (stricter than the shell, since nohup looks for a program named HUSKY=0). The readings start after the split's leftover grammar is gone, since { HUSKY=0 git push; } reaches the gate as { HUSKY=0 git push.

The readings are computed once per segment, not once per rule, and only when a deny or ask rule exists. They stop after 32 prefixes (each assignment counts, so ordinary variable runs stay well inside); past that the command behind them is unread, so the verdict is at least Ask and an allow rule on the outer wrapper cannot answer for it.

Two wrappers the host strips were not in the rewrite's table, for a reason that still holds: stdbuf exists to unbuffer the wrapped command's output, which routing through rtk would buffer, and xargs runs whatever it is handed. Recognising a command is not the same decision as rewriting it, so they sit in a match-only table the rewrite never consults. xargs is peeled only in its bare form, as the host does; any option makes the wrapper grammar give up.

The line is drawn at the shell. env, exec and nocorrect are peeled too, though the host's list stops short of them; each runs the next word as the command. A program's own arguments are not: git -C . push stays git, as the host documents, since reading past it would mean knowing every program's option grammar.

Allow is deliberately left as it was. The host matches allow past known-safe variables only, and peeling a wrapper for it would let timeout 30 <cmd> inherit <cmd>'s allow rule — so the peel can only make a verdict stricter.

The gate reads prefixes with the rewrite's own peels, so it stops where they stop, and there it reads the command as before: no verdict is looser than on develop. A second, gate-only reading would cover more forms sooner, but it is the same drift this PR removes; the env peel is replaced by one that follows bash's assignment rule in #4136, and the gate follows.

Test plan

  • cargo fmt --all --check && cargo clippy --all-targets && cargo test --all --no-fail-fast on develop f53e60337 — 4892 passing, 0 failing, clippy clean; wincheck.sh (cfg(windows) type-check) clean
  • Every prefix and assignment row was run through bash with a stub for the command first, so each expected verdict is bash's
  • Deny fires past the issue's twelve rows, a stacked HUSKY=0 nohup timeout 30 git push, empty and quoted values (FOO=, FOO="a b", FOO='a b'), and env, env FOO=1, exec. Ask fires on three of the same shapes
  • Deny does not fire where bash runs something else: 1A=b, =x, FOO-BAR=x and "FOO"=x are commands, not assignments; FOO="x git push y" echo hi runs echo
  • Allow is not widened through HUSKY=0, timeout 30, nice or env
  • xargs -n1 git push and git -C . push origin main stay Default
  • Grammar and a prefix together are matched past: true && { HUSKY=0 git push ; }, ! timeout 30 git push, ( nice git push ); a rule naming the assignment, HUSKY=0 git push *, still matches { HUSKY=0 git push x ; }
  • A rule naming a middle layer fires, with an allow rule on the outer wrapper: nohup HUSKY=0 git push x, env HUSKY=0 git push x, CI=1 HUSKY=0 git push x; deny and ask win over allow timeout * for timeout 30 git push
  • Cap: 31 and 32 stacked prefixes are read through, 33 and 5000 ask; with no deny or ask rule the verdict is unchanged; 20 leading variables stay Allow
  • Mutation — each compiled and each caught: the env peel dropped from the gate; the match-only wrappers emptied; the peel removed from the deny loop alone; from the ask loop alone; the peel reading the raw segment instead of the grammar-stripped one; the grammar-only reading dropped; the peel added to allow; only the innermost reading matched; the whole assignment run peeled at once; the cap ignored; the cap at 10 or off by one

For #4281. Independent of the lexing stack (#4130–#4138); when the stack rebases over this, matching_readings follows #4136's rename of strip_disabled_prefix to split_env_prefix.

🤖 Generated with Claude Code

@rtk-wshm-sync-bot rtk-wshm-sync-bot Bot added bug Something isn't working permissions security labels Sep 26, 2026
@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

🐛 Type bug-fix
🟡 Risk medium

Summary

Fixes RTK's permission gate so deny and ask rules match a command even when it's preceded by an environment variable assignment or wrapped in commands like timeout, nice, nohup, stdbuf, or a bare xargs, aligning it with Claude Code's own matching behavior. It adds a peel_for_matching function that reuses the rewrite's existing prefix-peeling logic plus a new match-only wrapper table for stdbuf/xargs, and applies it to both the deny and ask loops while intentionally leaving allow rules and git's…

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

Linked issues: #4281


Analyzed automatically by wshm · This is an automated analysis, not a human review.

@KuSh
KuSh marked this pull request as ready for review September 26, 2026 18:03
@KuSh
KuSh force-pushed the fix/4281-gate-peel branch 2 times, most recently from c8b1821 to c9a3f6e Compare September 26, 2026 21:22
@KuSh
KuSh force-pushed the fix/4281-gate-peel branch 2 times, most recently from efff29f to 7f36883 Compare October 7, 2026 00:44

@aeppling aeppling left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve after changes.

The fix is right. Deny and ask rules now use the rewrite's prefix stripping, and allow rules are unchanged. A differential fuzz run against develop found no regression.

Blocking

  1. Middle-prefix rule missed. Deny HUSKY=0 git push * doesn't stop nohup HUSKY=0 git push x, and with allow nohup * the hook answers allow. Please match against every stripping step, not just the final result.
  2. Quadratic slowdown. Stripping is redone for every rule. 5,000 stacked prefixes with 12 rules take 46 s, against 0.02 s on develop. Compute it once per segment and cap the depth at 10.

Nits

  • Docs not updated: discover/README.md:95, the permission table in hooks/README.md, and supported-agents.md:194, which still says rules match "the command as written".
  • Missing test: deny or ask winning over an allow rule, e.g. allow timeout * plus deny git push *. Develop answered allow here.

@KuSh
KuSh force-pushed the fix/4281-gate-peel branch from 7f36883 to 828c44f Compare October 9, 2026 21:56
@KuSh

KuSh commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, both blockers are fixed in the new push.

  1. Middle-prefix rules: deny and ask now match every layer, one prefix at a time, each assignment and each env word counted separately. HUSKY=0 git push * stops nohup HUSKY=0 git push x with allow nohup *, and also env HUSKY=0 git push x and CI=1 HUSKY=0 git push x.
  2. Quadratic cost: the readings are computed once per segment, and only when a deny or ask rule exists. They are capped at 32 rather than 10, because each assignment is now a step of its own and 10 made an ordinary 11-variable line prompt. Past the cap the verdict is at least Ask, never Allow; 5000 stacked prefixes stop after 32 steps.

Nits: discover/README.md, the permission section of hooks/README.md and supported-agents.md are updated, and allow timeout * plus deny or ask git push * on timeout 30 git push now has a test (Deny and Ask).

@aeppling aeppling left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, both blockers are fixed and verified (middle-prefix deny/ask, and 5000 stacked prefixes back to develop speed).

Nit: a quoted assignment with spaces that is a whole statement is read past its first space. With deny Bash(rm -rf *), MSG="chore: rm -rf build dir" && git commit -m "$MSG" is now denied (develop: ask). The assignment regex expects a space after the closing quote, so at the end of a statement it falls back to the unquoted branch. The same misread already makes the rewrite edit inside the string on develop (MSG="fix: git push x" becomes MSG="fix: rtk git push x"), so fixing the regex would cover both.

@KuSh

KuSh commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks @aeppling. Confirmed: the quoted-value branch needs a blank after the closing quote, so at the end of a statement it falls back to the unquoted one, in the gate and in develop's rewrite alike.

#4136 (in the lexing stack) replaces that regex with split_env_prefix, which reads a quoted value as one word up to its closing quote; test_a_quoted_value_is_one_word pins D="x y" at the end of a line as one assignment with nothing behind it. When the stack rebases over this PR, the gate's per-word peel follows it, so both cases are fixed there. Leaving this PR as it is.

…ers (#4281)

Claude Code matches a deny or ask rule past any leading assignment and past a
fixed set of wrappers — `timeout`, `time`, `nice`, `nohup`, `stdbuf`,
`command`, `builtin`, `noglob` and a bare `xargs` — so `Bash(git push *)` stops
`HUSKY=0 git push` and `timeout 30 git push` as well as `git push`. RTK's gate
matched the segment as written and nothing else, so every one of those forms
fell through to Default; under a host that treats Default as its own decision,
as #3909's OpenClaw does, they auto-apply.

The rewrite already looked through the same prefixes: `timeout 30 git push`
became `timeout 30 rtk git push` while the gate did not recognise `git push`
inside it. That was two readings of which command a line runs, and the gap
between them is this bug. The deny and ask loops now also try every reading
`matching_readings` gives: the rewrite's own peels — its env peel, its keyword
prefixes, its wrapper table — applied one prefix at a time, each assignment and
each `env` word a prefix of its own, each intermediate command kept. Where both
look through a prefix, the gate and the rewrite now read the same command
behind it, and a rule may name any layer: `HUSKY=0 git push *` stops
`env HUSKY=0 git push`, and `nohup HUSKY=0 git push` too, where denying is
stricter than the shell, since nohup looks for a program named `HUSKY=0`. The
readings start after the split's leftover grammar is gone, since
`{ HUSKY=0 git push; }` reaches the gate as `{ HUSKY=0 git push`. They are
computed once per segment, not once per rule, and stop after 32 prefixes; past
that the command behind them is unread, so where a deny or ask rule exists the
verdict is at least Ask and an allow rule on the outer wrapper cannot answer for
it. With no deny or ask rule, none of this is read.

Two wrappers the host strips were not in the rewrite's table, for a reason that
still holds: `stdbuf` exists to unbuffer the wrapped command's output, which
routing through rtk would buffer, and `xargs` runs whatever it is handed.
Recognising a command is not the same decision as rewriting it, so they sit in
a match-only table the rewrite never consults. `xargs` is peeled only in its
bare form, as the host does; any option makes the wrapper grammar give up.

The line is drawn at the shell. `env`, `exec` and `nocorrect` are peeled too,
though the host's list stops short of them; each runs the next word as the
command. A program's own arguments are not: `git -C . push` stays `git`, as the
host documents, since reading past it would mean knowing every program's option
grammar.

Allow is deliberately left as it was. The host matches allow past known-safe
variables only, and peeling a wrapper for it would let `timeout 30 <cmd>`
inherit `<cmd>`'s allow rule — so the peel can only make a verdict stricter.

The gate reads prefixes with the rewrite's own peels, so it stops where they
stop; there it reads the command as before, so no verdict is looser than it
was. A line setting more than 32 variables before its command now prompts
where a deny or ask rule exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KuSh
KuSh force-pushed the fix/4281-gate-peel branch from ceeb7b6 to b465def Compare October 14, 2026 22:41
@KuSh
KuSh merged commit 2bcc599 into develop Oct 14, 2026
21 checks passed
@KuSh
KuSh deleted the fix/4281-gate-peel branch October 14, 2026 23:17
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working permissions security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants