Skip to content

fix(openclaw): let the host own approval without losing RTK's deny gate - #3909

Merged
KuSh merged 2 commits into
rtk-ai:developfrom
lucabattistini:feat/openclaw-host-permissions
Sep 26, 2026
Merged

KuSh merged 2 commits into
rtk-ai:developfrom
lucabattistini:feat/openclaw-host-permissions

Conversation

@lucabattistini

@lucabattistini lucabattistini commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3908.

Summary

rtk rewrite judged every command against Claude Code's four settings files, whatever agent called it. OpenClaw applies tools.exec.mode and security to whatever its before_tool_call hook returns, so RTK's ask became a second approval gate: a host running exec.mode=full still stopped on every rewritable command, waiting on a decision derived from another agent's config file.

A delegate can now name itself in RTK_REWRITE_HOST. OpenClaw sets it, so a default ask — no rule matched — collapses to an allow and no longer prompts. An explicit ask rule the user wrote still returns exit 3, and OpenClaw's plugin still prompts for it, so a user-authored instruction is not discarded. An explicit deny still exits 2 for every delegate, named or not.

What changed

  • src/hooks/decision.rs gains ApprovalOwner (Rtk | Delegate) and REWRITE_HOST_ENV. The name resolves through AgentPath::lookup, so there is one agent vocabulary rather than a second list of host names; AgentPath::ViaRewrite carries the owner, and ApprovalOwner::apply(decision, verdict) relaxes on (Delegate, Default, AskRewrite) alone. AgentPath::decide and rewrite_cmd::run thread the verdict. from_env fails closed — an unknown, misspelled, empty or differently-cased name, an in-process host naming itself, or no variable at all gives Rtk.
  • openclaw/index.ts sets the variable, keeps requireApproval for exit 3 (an explicit ask rule, or any ask/default on an older rtk), and drops it for exit 0. The exit-2 branch stays.
  • hooks/claude/rtk-rewrite.sh, .claude/hooks/rtk-rewrite.sh and hooks/cursor/rtk-rewrite.sh scrub RTK_REWRITE_HOST (env -u) before calling rtk rewrite: they turn exit 0 into an explicit allow, so they must not honour a channel they did not set.
  • docs/contributing/TECHNICAL.md, docs/guide/getting-started/supported-agents.md, src/hooks/README.md and openclaw/README.md carry the rule for readers arriving from each direction, plus an upgrade note.

Approval ownership — default relaxes, explicit ask survives

The relaxation is scoped to Default deliberately. A Default verdict means no rule matched, so RTK is imposing another agent's settings on a runtime that never opted into them — the prompt #3908 is about. An explicit ask is the user's own instruction and is kept: RTK still returns exit 3 and OpenClaw's plugin still prompts. A deny is structurally out of apply's reach, so a host name cannot turn a denied command into an allowed rewrite.

The two halves ship together: relaxing only Default without restoring the plugin's prompt would still drop explicit asks, and restoring the prompt without the split would re-break #3908.

Why the environment and not argv

Commands::Rewrite's positional is trailing_var_arg = true, allow_hyphen_values = true, so an rtk that predates a flag absorbs it into the command text it judges: the gate is shown --host openclaw git push, and Bash(git push *) stops matching — passthrough of a denied command. An rtk that predates the variable ignores it and keeps its current behaviour, which is a prompt. Old and new cannot disagree about a deny, so there is no minimum rtk version and no upgrade order. the_host_is_not_an_argv_token_so_versions_cannot_disagree pins both halves.

Answer: does OpenClaw's own policy see the command it is gating?

Yes for the checks that matter (openclaw/README.md, "Writing exec rules"): tools.exec.mode, tools.exec.security, tools.exec.ask and the exec-approvals allowlist match the string the exec tool receives — rtk git push — because OpenClaw carries hook adjustments forward. This was already true on the exit-0 allow path before this PR. A trusted tool policy registered with api.registerTrustedToolPolicy(...) is the exception: it runs before ordinary before_tool_call hooks and is shown git push. The plugin's exit-2 deny branch is unaffected either way.

The #1155 invariant

Default still exits 3 for every host that reads the exit code as a permission decision: other_delegates_keep_the_default_ask_gate asserts exit 3 for claude, pi, hermes, opencode, omp and vibe. The invariant is restated as host-scoped rather than global, because it binds a host that consumes the exit code that way; OpenClaw consumes it as a rewrite decision and then applies its own policy.

Backward compatibility

  • No variable: byte-identical to the merge-base. rtk rewrite <cmd> and every adapter that does not set it are unchanged.
  • New plugin, older rtk: the variable is ignored, the command reaches the gate untouched, deny rules keep matching, and exit 3 still prompts — an older rtk prompts for more commands because it cannot collapse the default ask.
  • No minimum rtk version, no upgrade order.

Tests

tests/hook_decision_protocol_test.rs gains rewrite_host_scoping (9 tests): the default ask collapses to allow under openclaw; an explicit ask rule survives; an explicit allow is unchanged; the deny matrix (du -sh ., git push, git push origin main) and a compound deny exit 2 under all six names; four unattestable constructs stay passthrough; a non-rewritable command stays passthrough; every other delegate keeps exit 3; unknown, misspelled, empty and mixed-case names keep the default gate and cannot weaken a deny; the argv-token pin; a denied command records no recall under a self-approving host; and an_exported_host_cannot_relax_a_caller_that_did_not_set_it pins the harness scrub.

src/hooks/decision.rs gains: Delegate relaxes Default and nothing else; Rtk is the identity on all four decisions; openclaw is the only agent whose owner is Delegate; openclaw's verdict is still check_command_for(cmd, Host::Claude); and from_env fails closed for thirteen names plus unset.

hooks/claude/test-rtk-rewrite.sh gains a case that exports RTK_REWRITE_HOST=openclaw into a sandboxed hook run and asserts no permissionDecision: "allow".

Local run

c6f484f on 0c279f5, macOS arm64, rustc 1.97.1:

  • cargo fmt --all -- --check: clean.
  • cargo clippy --all-targets: no warnings.
  • cargo test --all: 3936 passed, 0 failed, 8 ignored.

@CLAassistant

CLAassistant commented Sep 7, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@lucabattistini
lucabattistini force-pushed the feat/openclaw-host-permissions branch from 7c3a0ef to 320e28d Compare September 10, 2026 11:37
@lucabattistini lucabattistini changed the title feat(rewrite): scope permission authority to the calling host feat(rewrite): add --host, stop gating OpenClaw on Claude rules Sep 10, 2026

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 1 of 3 — CHANGES REQUESTED

Claim: rtk rewrite gains --host, and OpenClaw is registered as a host that is its own permission authority, so Ask/Default collapse to Allow and the plugin stops raising a second approval prompt sourced from Claude Code's settings.

Scope: accept. #3908 is a real problem, defaulting to claude keeps every other adapter untouched, and the PR body is unusually precise about what changes and why. The blocking item below is one class, and once it is closed this is mergeable.

Ran: rtk rewrite on both binaries (merge-base 8e9aa04c vs head 320e28de, one CARGO_TARGET_DIR each, md5sum confirmed they differ) × 21 cases — no-flag baseline, --host claude, --host openclaw, an unknown host, a hyphen-typo host, a missing flag value, flag-after-command, unattestable construct, non-rewritable command, compound commands, and deny/allow/default verdicts driven from a real CLAUDE_CONFIG_DIR/settings.json. Invariants: every arm of the new decision on both hosts, both sides of the version boundary, and the full chain an agent walks (ask rtk, then run what it hands back). Savings: not applicable — no filter changes, so there is no bash-output ratio to measure against the 20% floor.

Matrix: executed this round (first time on this PR).

CI @320e28de: never run — only license/cla (pass) and check-target (skipping). The workflow is waiting for maintainer approval, so this head has not been built or tested on any platform. My local gate is clean (cargo fmt --check, clippy --all-targets 0 warnings, 3413 tests) but that is evidence about this machine, not about Windows or macOS.

Blocks merge (1 class, 2 sites)

--host openclaw loses the deny gate — on both sides of the version boundary. Two sites, one cause: whenever the flag is in play, a command the user explicitly denied stops being denied. Both verified end to end against real settings.json rules, not synthetic inputs.

Site 1 — new rtk: Deny is unreachable, so it becomes auto-apply. load_rules_for gives Host::OpenClaw an empty triple, so check_command_for can never return Deny for it (your own test_openclaw_verdict_is_default_not_allow pins that), and Default then collapses to Allow. With {"permissions":{"deny":["Bash(du *)","Bash(git push *)"]}}:

command merge-base (no flag) head --host claude head --host openclaw
du -sh . exit 2 exit 2 exit 0 → rtk du -sh .
git push exit 2 exit 2 exit 0 → rtk git push

and with requireApproval removed the plugin applies that without a prompt:

$ rtk rewrite --host openclaw "du -sh ."
rtk du -sh .            # exit 0
$ rtk du -sh src
5,0M    src             # the denied command ran

Compounds inherit it: --host openclaw "git push && rm -rf /tmp/zz" → exit 0, stdout rtk git push && rm -rf /tmp/zz.

This is a regression against develop, not only a change of policy: today's shipped plugin calls rtk rewrite <cmd> with no flag and branches on status === 2, so an OpenClaw user has deny protection now and would lose it silently.

Site 2 — older rtk, during the upgrade window: Deny becomes passthrough. openclaw/README.md:83 says that on an older rtk "rewriting silently stops and no command is blocked or denied". The first half holds; the second does not, in both directions. With deny: ["Bash(git push *)"] on the merge-base binary:

$ rtk rewrite "git push origin main"                    # old plugin argv
exit 2                                                   # blocked
$ rtk rewrite --host openclaw "git push origin main"    # new plugin argv
exit 1                                                   # passthrough — OpenClaw runs the original denied command

The flag is swallowed into the command string, so the text that reaches the permission gate is --host openclaw git push origin main, which no longer matches Bash(git push *). The user's deny rule silently stops applying. It also fails the other way — --host openclaw "git push && rm -rf /tmp/zz" on the old binary exits 2, which index.ts turns into a hard block — so a new plugin against an older rtk both over-blocks and under-blocks depending on the command text.

I read site 1 as unintended rather than by design, because evaluate_with_verdict_for short-circuits Deny first for every host — the intent is visibly that deny survives; the empty rule source is what defeats it. The fix is a design call I would rather you make, since "consult Claude's deny list for OpenClaw" is the exact coupling this PR exists to remove:

  • (a) keep Deny host-independent — give Host::OpenClaw a deny source (Claude's, or one the plugin passes) and leave ask/allow empty, so the collapse applies only to Ask/Default; or
  • (b) accept that RTK asserts nothing for this host, and say so everywhere it currently claims otherwise — drop the unreachable exit-2 branch from index.ts, and correct supported-agents.md:168 ("a deny verdict from RTK still blocks the tool call") and the index.ts protocol table, which today advertise a safety net that cannot fire.

Site 2 needs handling either way, since it is about older binaries already in the wild: a version probe before passing the flag, or wording that tells users upgrade order matters. A test asserting that no host and no flag arrangement can turn a denied command into Allow or passthrough is what closes the class.

Needs your answer before I can call the design settled

openclaw/index.ts:133 — does OpenClaw's own policy actually see the command it is gating? The justification for removing requireApproval is that OpenClaw enforces its own exec policy. What I can verify is that RTK hands back the rewritten command — rtk git push, not git push — which openclaw/README.md:77 also states. What I cannot verify here is how OpenClaw matches its own security/ask rules against that string. If those rules are written against the user's command, a rule naming git push will not match rtk git push, and the net effect for rule-based configs is that RTK's gate is removed and the replacement gate is blinded; only tools.exec.mode=full users would be unaffected. You know that runtime better than I do — if OpenClaw matches on the rewritten string, this concern is void and I will drop it.

Optional — will not hold merge

  • src/main.rs:1785 — the unknown-host fallback does not fail closed for the caller. A typo resolves to Claude's rules, and the stderr warning changes no exit code any adapter acts on. Verified: --host open-claw "git push && rm -rf /tmp/zz" → exit 2, so the plugin blocks the call under a rule set the OpenClaw user never chose; with no matching rule, --host open-claw git push → exit 3, which the new plugin now applies silently. "No rules, not an authority" — or a dedicated exit 1 — expresses the failure better than borrowing another host's rules.
  • src/hooks/permissions.rs:41 — the exit-2 rationale is incomplete. The comment explains that ValueEnum was avoided because clap exits 2 on an unknown value and the protocol reserves 2 for deny. Clap still exits 2 on a missing value: rtk rewrite --host → exit 2, error: a value is required for '--host <HOST>'. A caller that builds argv from config and drops an empty value gets a spurious deny.
  • src/hooks/permissions.rs:36 — HOST_NAMES is tested in one direction only. Verified by mutation: deleting "openclaw" from it leaves all 3413 tests green, so the flag's own host would silently vanish from the help text and from the "expected one of" warning. There is a third hardcoded copy of the list in the --host help at src/main.rs:941. This bites at rebase — develop's Host already carries Codex and Trae, which from_cli_name does not know, so --host codex would warn and fall back to Claude. The compiler catches the missing load_rules_for arm but not the missing name. AgentPath::AGENTS on develop already tests both directions and is the pattern to copy.

Checked and correct — no need to re-verify

  • No-flag behaviour is byte-identical to the merge-base: git status → 3, ls -al → 0 under an allow rule, rm -rf /tmp/x → 2 under a deny rule. The backward-compatibility claim holds.
  • --host claude preserves the #1155 invariant: Default → exit 3.
  • contains_unattestable_construct still wins for the new host: --host openclaw "echo $(whoami)" → exit 1, and a file-target redirect likewise.
  • A non-rewritable command (frobnicate x) → exit 1 under the new host.
  • --host after the positional is swallowed into the command, as test_rewrite_clap_host_after_command_is_part_of_the_command says; behaviour is identical on both binaries.
  • No host-injection path: every shipped adapter passes the command as a single argv element, so an agent-emitted command cannot select a host.

Hypotheses built and dropped

  • "--host openclaw lets a denied rm -rf through." Dropped: rm -rf /tmp/x exits 1 under every host because it has no RTK rewrite at all, so it never reaches the verdict branch. The finding above uses du and git push precisely because they are rewritable, which is what makes the deny reachable.
  • "Version skew is harmless." Dropped as stated, and it became site 2 above. --host openclaw "git status" on the old binary does exit 1 with no output, which is what I checked first — but that is the no-matching-rule case, and generalising from it was wrong.

Rebase note

develop has since moved this decision into src/hooks/decision.rs — decide(cmd, verdict) -> HookDecision (AllowRewrite/AskRewrite/Deny/Defer), shared by rtk rewrite, rtk hook <agent> and rtk hook check, with the verdict passed in precisely so each host can consult its own rules (#3146). evaluate_for/evaluate_with_verdict_for duplicate that, and all three source files conflict. Threading a host through decide is likely smaller than the current shape, and puts --host next to AgentPath::lookup instead of a third name vocabulary. Worth targeting current develop rather than carrying the old shape forward. #3900 also edits rewrite_cmd.rs from the same pre-refactor base, and there is unmerged work on permissions.rs command segmentation, so whichever lands first moves the ground under the others.

Next

Pick (a) or (b) for the deny question and handle the upgrade window; that is the whole of what stands between this and an approval. The three optional items and the rebase can ride along. I'll re-run the deny matrix and the round-1 rows on the next push. I have not approved a CI run on this head, since the rebase means it will be replaced anyway — CI will get its first real go at the next push, and that is also the first time any of this is exercised on Windows or macOS.

Rounds: 1/3. Threads: 1 open (deny under --host), 0 resolved.

Comment thread src/hooks/permissions.rs Outdated
Comment thread openclaw/README.md Outdated
lucabattistini added a commit to lucabattistini/rtk that referenced this pull request Sep 20, 2026
The review on rtk-ai#3909 found that scoping `rtk rewrite`'s permission authority
to the calling host let an explicitly denied command come back as an allowed
rewrite, on both sides of a version boundary. Nothing asserted that it could
not.

Add `rewrite_host_scoping` to the end-to-end protocol test, driven from real
`settings.json` rules in the existing sandbox. Nine of its ten cases pin what
must not move -- a deny stays exit 2 under every host name and for a compound
carrying one denied segment, unattestable constructs stay exit 1, a
non-rewritable command stays exit 1, an unrecognized host keeps the ask gate,
every other delegate keeps the rtk-ai#1155 default-ask gate, and a denied command
still records no recall. They pass today and are the regression net.

The tenth, `openclaw_collapses_ask_and_default_to_allow`, fails: it is the
behaviour rtk-ai#3908 asks for.

`the_host_is_not_an_argv_token_so_versions_cannot_disagree` states the version
boundary as a property rather than a hope. It asserts that `--host openclaw`
is command text, and shows what that costs: against a `Bash(git push *)` deny
rule the flag form exits 1 rather than 2, because the gate is shown
`--host openclaw git push`. An older rtk does the same with a flag a newer
delegate passes, which is why the host must travel in the environment instead.

`Sandbox::run_with_env` and `Sandbox::rewrite_as` carry the environment
through; `run` keeps its signature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lucabattistini added a commit to lucabattistini/rtk that referenced this pull request Sep 20, 2026
The published tip (`320e28d`) carries the pre-refactor shape of this feature:
a `--host` CLI flag, a `Host::OpenClaw` variant with an empty rule triple, and
`rewrite_cmd::evaluate_for`. The review on rtk-ai#3909 found that shape lets an
explicitly denied command come back as an allowed rewrite -- the empty rule
triple makes `Deny` unreachable for the new host -- and that a `--host` flag
is folded into the command text by any rtk that predates it, which loses a
deny rule during the upgrade window.

`develop` has since moved this decision into `src/hooks/decision.rs`, and this
branch now implements rtk-ai#3908 there instead: `ApprovalOwner`, read from
`RTK_REWRITE_HOST`, relaxing an ask and nothing else, with the verdict source
left alone so every delegate keeps Claude Code's deny rules.

Recorded with `-s ours`: the tree is this branch's, and the only content the
published tip holds that is not in it is the superseded implementation and an
older `develop`, which `e4d3bb8` already merged forward. Merging rather than
rewriting keeps the SHAs the reviewer tested (`df8f818`, `320e28d`) reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lucabattistini
lucabattistini requested a review from KuSh September 20, 2026 17:59
@KuSh KuSh self-assigned this Sep 20, 2026
@lucabattistini
lucabattistini force-pushed the feat/openclaw-host-permissions branch from e8e9fe8 to bc0c06b Compare September 21, 2026 16:48
@lucabattistini lucabattistini changed the title feat(rewrite): add --host, stop gating OpenClaw on Claude rules fix(openclaw): let the host own approval without losing RTK's deny gate Sep 21, 2026
@lucabattistini

lucabattistini commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Round 1 is addressed. Head is now bc0c06b: same diff, squashed onto current develop (0c279f5) as one commit.

Deny class, site 1 — option (a). RTK keeps the deny gate; only approval moves. The rule source is untouched — AgentPath::verdict still calls check_command_for(cmd, Host::Claude) on every ViaRewrite path — and the whole relaxation is ApprovalOwner::Delegate, which matches on AskRewrite alone, so Deny and Defer are structurally out of its reach. Your matrix is now a test: du -sh ., git push and git push origin main exit 2 with no host and under all six names, and git push && rm -rf /tmp/zz exits 2 whole. No exit-2 branch is left advertising a gate that cannot fire.

Site 2 — the environment, not argv. --host is gone entirely; the plugin sets RTK_REWRITE_HOST=openclaw. An rtk that predates the variable ignores it, so the command reaches the gate untouched and your Bash(git push *) case keeps matching — a prompt, not a passthrough. No minimum version, no upgrade order. the_host_is_not_an_argv_token_so_versions_cannot_disagree pins the flag-form corruption you measured, and pins that RTK ships no argv token for the host at all.

Your open question — does OpenClaw's own policy see the command it is gating? For the exec tool's own checks, yes: tools.exec.mode, tools.exec.security, tools.exec.ask and the exec-approvals allowlist match the string the exec tool receives, which is rtk git push, because OpenClaw carries hook adjustments forward into the parameters it passes to exec. That was already true on the exit-0 allow path before this PR, so this makes the docs say it rather than changing behaviour (openclaw/README.md, "Writing exec rules"). The one gate that sees the original command is a trusted tool policy (api.registerTrustedToolPolicy(...)), which runs before ordinary before_tool_call hooks; that is documented as the exception. If the case behind your question was a rule-based config whose rules name the raw command, that guidance is the mitigation, and the exit-2 branch is unaffected either way.

The three optional items fall out by construction: the unknown-name path is the stricter default rather than another host's rules (from_env fails closed for unknown, empty, mixed-case and in-process names, all tested); there is no argv flag left for clap to reject with exit 2; and the names resolve through AgentPath::lookup, one vocabulary, which AgentPath::AGENTS already tests in both directions.

On #3953: nothing here changes recall accounting beyond what develop already does, except that a denied command still records no read — now pinned for the self-approving host too.

Two things I cannot do from here. The workflow run on this head is still waiting for maintainer approval, so bc0c06b has not been built on any platform yet — if you can approve the run, that would close it. And your re-run of the deny matrix is the evidence that counts here, not my tests.

Local gate on bc0c06b (macOS arm64, rustc 1.97.1): cargo fmt --all -- --check clean; cargo clippy --all-targets no warnings; hook_decision_protocol_test 33 passed, 0 failed; the decision unit tests 35 passed, 0 failed.

@lucabattistini

Copy link
Copy Markdown
Contributor Author

Full gate on bc0c06b: cargo test --all → 3934 passed, 0 failed, 8 ignored; cargo fmt --all -- --check clean; cargo clippy --all-targets no warnings. Body updated with the same.

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 2 of 3 — CHANGES REQUESTED

Claim: the OpenClaw plugin tells RTK, through RTK_REWRITE_HOST=openclaw, that it applies its own exec policy, so RTK relaxes Ask/Default to an allow rather than raising a second approval gate sourced from Claude Code's settings — while keeping its deny gate.

Scope: accept (round 1, frozen).

Ran: rtk rewrite on head bc0c06bb vs develop feac25d1 (one CARGO_TARGET_DIR each, md5sum differ) × 17 cases; the real hooks/claude/rtk-rewrite.sh end to end with and without the variable; explicit permissions.ask rules; the suite with the variable exported; two mutations. Invariants: every arm of the new decision, both sides of the version boundary, under the hook, fail-closed on six malformed names. Savings: not applicable — no filter changes.

Matrix: executed this round (and at round 1).

CI @bc0c06bb: still never run — license/cla pass, check-target skipping, and run 35627981451 sits action_required. I have not approved it, because the findings below need another push anyway and the run would be spent on a head that is about to be replaced.

Round 1 is closed — verified, not taken on trust

Both sites of the round-1 deny class are genuinely fixed, and the fix is the good kind: structural rather than patched.

develop feac25d1 head bc0c06bb
du -sh ., git push, git push origin main (denied), any host exit 2 exit 2
git push && rm -rf /tmp/zz (denied segment) exit 2 exit 2
git status, no env exit 3 exit 3
git status + RTK_REWRITE_HOST=openclaw exit 3 exit 0
six malformed host names exit 3 exit 3

That last-but-one row is the PR's entire behavioural footprint against develop. Site 2 is closed by construction: develop's binary ignores the variable completely, so nothing can be corrupted in argv.

Both are closed by tests, not only by code. Mutating apply to relax Deny fails four tests, including no_host_can_turn_a_deny_into_an_allow_or_a_passthrough. Removing "openclaw" from AGENTS now fails agent_path_covers_every_install_target — at round 1 the equivalent deletion left all 3413 tests green. The three optional items are closed the same way: one vocabulary through AgentPath, no argv token for clap to reject, and unknown/empty/mixed-case/in-process names all falling back to the strict default.

Blocks merge (2 new, introduced by bc0c06bb)

Neither existed at round 1 — both arrive with the environment-variable mechanism, so they are admissible as new regressions rather than a reopened list.

1. src/hooks/rewrite_cmd.rs:94 — the variable is ambient, and rtk rewrite is every delegate's entry point. Nothing binds RTK_REWRITE_HOST to the OpenClaw plugin. Any process that inherits it — a shell rc, a .envrc, a container or CI environment, or a user who exported it after reading openclaw/README.md — makes RTK answer every caller as a self-approving host. hooks/claude/rtk-rewrite.sh turns exit 0 into an explicit permissionDecision: allow, and hooks/cursor/rtk-rewrite.sh sets PERMISSION="allow" for anything that is not exit 3.

Run through the real Claude hook script, same input, only the environment differs:

$ printf '%s' "$INPUT" | bash hooks/claude/rtk-rewrite.sh
{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"command":"rtk gh pr merge 1 --admin"}}}

$ printf '%s' "$INPUT" | RTK_REWRITE_HOST=openclaw bash hooks/claude/rtk-rewrite.sh
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
 "permissionDecisionReason":"RTK auto-rewrite","updatedInput":{"command":"rtk gh pr merge 1 --admin"}}}

Same for aws s3 rm s3://bucket --recursive and psql -c 'drop table users'. A Claude Code session with that variable set never prompts for a rewritable command again — which is #1155, the invariant this PR's own body sets out to preserve. RTK_DISABLED is prior art for env-configured behaviour, but it only ever makes RTK stricter; this is the first variable that relaxes a permission decision, so it is the first one where inheritance matters.

The cheapest containment that keeps the design: have the strict consumers scrub it, env -u RTK_REWRITE_HOST rtk rewrite "$CMD" in the two Claude hooks and the Cursor hook, so the channel is only honoured where the caller is the one that set it. Whether that is the right shape is your call — a handshake the plugin alone can produce would be stronger, and harder.

2. An explicit "ask me first" is dropped — two sites, one class. apply collapses PermissionVerdict::Ask as well as Default, so a rule the user wrote is discarded while deny from the very same file is honoured.

settings.json: {"deny":["Bash(rm -rf *)"], "ask":["Bash(aws s3 rm *)","Bash(git push *)"]}

aws s3 rm s3://prod --recursive     no env: exit 3   openclaw: exit 0   <- explicit ask collapsed
git push origin main                no env: exit 3   openclaw: exit 0   <- explicit ask collapsed
rm -rf /tmp/x                       no env: exit 2   openclaw: exit 2   <- deny honoured

src/hooks/decision.rs:101 is site A, and openclaw_collapses_ask_and_default_to_allow pins it deliberately, so I take it as intended rather than an oversight. But the trust model ends up saying "this file's denies bind you, its asks do not", and #3908's complaint is about Default — no rule matched — producing a prompt from settings the runtime never opted into. An ask rule the user typed is not that. Threading the verdict so only Default relaxes keeps the whole of the #3908 fix.

openclaw/index.ts:143 is site B, and it is why this matters rather than being philosophy: requireApproval is gone, so the gate is now OpenClaw's alone — and both openclaw/README.md:81 and supported-agents.md:195 state that tools.exec.ask and the exec-approvals allowlist match the rewritten string, rtk git push. A user whose OpenClaw ask-rules are written against the original command text had a prompt before this commit (exit 3 → requireApproval, timeoutBehavior: "deny") and has nothing after it. That is the answer to my round-1 question, narrowed: you are right that the exec tool sees the rewritten string on the allow path and always did — but the commands that used to reach a prompt no longer do. It ships with no version bump and no migration note, so it is silent for people upgrading.

Optional — will not hold merge

  • docs/guide/getting-started/supported-agents.md:197 — "an rtk that predates RTK_REWRITE_HOST ignores it and keeps its previous behaviour, which is a prompt rather than a missing gate". True of the binary, not of what the user sees: the plugin in this same commit treats exit 3 exactly like exit 0 (index.ts:95, "Same meaning as 0 here") and never raises requireApproval, so on an older rtk there is no prompt. openclaw/README.md:87 and the index.ts header state the 0≡3 handling correctly; this sentence contradicts them.

  • tests/hook_decision_protocol_test.rs:77 — run_with_env pins HOME, XDG_CONFIG_HOME, CLAUDE_CONFIG_DIR, RTK_DB_PATH, RTK_TEE_DIR, RTK_RECALL_DB and LC_ALL, but not RTK_REWRITE_HOST. Exporting it turns the suite red on the machine rather than on the code — including default_verdict_exits_three_never_zero, the test that guards #1155:

    $ RTK_REWRITE_HOST=openclaw cargo test --test hook_decision_protocol_test
    rewrite_exit_codes::default_verdict_exits_three_never_zero ... FAILED
    rewrite_exit_codes::ask_rule_exits_three_with_the_rewrite ... FAILED
    ... 6 failures

    command.env_remove(REWRITE_HOST_ENV) before the per-call env slice is the fix, and it is the same #3146 hermeticity point the module doc-comment makes.

Checked and correct — no need to re-verify

  • Deny survives every host, including on a compound carrying a denied segment; the rule source is unchanged (ViaRewrite(_) => check_command_for(cmd, Host::Claude)).
  • apply cannot relax Defer or a passthrough; suppress_identity runs before it and apply(Defer) is the identity.
  • Fail-closed on empty, bogus, OpenClaw, claude, codex and "openclaw ".
  • Non-rewritable and unattestable constructs stay exit 1 under the delegate.
  • from_env has exactly one production call site.
  • Merges cleanly into feac25d1; the merged tree is green at 3968 tests. Local only — this says nothing about Windows or macOS, which is what the CI run is for.

Hypotheses built and dropped

  • "The plugin exports the variable too broadly." Dropped: openclaw/index.ts:79 sets it per child (env: { ...process.env, RTK_REWRITE_HOST }) and src/hooks/README.md:87 says "on that subprocess". The plugin's own handling is right — blocker 1 is about every other caller that inherits it, not about the plugin.

Your questions

  • "Does OpenClaw's own policy see the command it is gating?" — answered, and I accept it: the exec tool receives rtk git push and OpenClaw carries hook adjustments forward, which was already true on the exit-0 path. What it does not cover is the exit-3 population, which used to prompt and now does not; that is site B above, and it is the only part of the question still live.
  • "If you can approve the run, that would close it." — holding, deliberately. Both blockers need another push, and a run on bc0c06bb would be spent on a head that will not be the final one. I will approve the run on your next head and wait for it before approving the PR.
  • "Your re-run of the deny matrix is the evidence that counts here, not my tests." — agreed, and it is above; the deny class passes on every row. Your tests then earned their keep independently: the mutation shows four of them catch the round-1 regression if it returns.

Next

Contain the variable so it only relaxes the caller that set it (blocker 1), and decide whether an explicit ask should survive (blocker 2 — threading the verdict so only Default relaxes is the smallest version). The two optional items are a sentence and a line of test setup. Then push, and I will approve the CI run on that head and review against it.

Rounds: 2/3. Threads: 2 open (both new, filed this round), 2 resolved this round (the round-1 deny class, both sites).

Comment thread src/hooks/rewrite_cmd.rs Outdated
Comment thread src/hooks/decision.rs Outdated
Comment thread openclaw/index.ts Outdated
Closes rtk-ai#3908.

`rtk rewrite` judged every command against Claude Code's four settings
files, whatever agent called it. OpenClaw applies `tools.exec.mode` and
`security` to whatever its `before_tool_call` hook returns, so RTK's ask
became a second approval gate: a host running `exec.mode=full` still
stopped on every rewritable command, waiting on a decision derived from
another agent's config file.

A delegate can now name itself in `RTK_REWRITE_HOST`. OpenClaw sets it,
so a *default* ask -- no rule matched -- collapses to an allow. An
explicit `ask` rule the user wrote still exits 3 and the plugin still
prompts, so a user-authored instruction is not discarded; an explicit
`deny` still exits 2 for every delegate, named or not.

The name resolves through `AgentPath::lookup`, so there is one agent
vocabulary. `ApprovalOwner::apply` matches on
`(Delegate, Default, AskRewrite)` alone. The variable travels in the
environment, not argv, so an older rtk ignores it and cannot corrupt what
the permission gate is shown. The Claude and Cursor hooks scrub it before
calling `rtk rewrite`, since they turn exit 0 into an explicit allow.
@lucabattistini
lucabattistini force-pushed the feat/openclaw-host-permissions branch from bc0c06b to c6f484f Compare September 25, 2026 13:04
@lucabattistini

Copy link
Copy Markdown
Contributor Author

Round 2 — addressed, head c6f484f

Both blockers fixed, both optional items closed. It is back to one commit on 0c279f5.

Blocker 1 — the variable is ambient

You're right that the plugin's own scoping was fine and every other caller was the problem. Containment is the scrub you suggested, in the three consumers that turn exit 0 into an affirmative allow:

  • hooks/claude/rtk-rewrite.sh
  • .claude/hooks/rtk-rewrite.sh
  • hooks/cursor/rtk-rewrite.sh

Each now runs env -u RTK_REWRITE_HOST rtk rewrite "$CMD", so the channel is honoured only where the caller set it. The other delegates are inert here — pi/hermes/opencode treat exit 0 and 3 identically and set no permission decision — so scrubbing them changes nothing; I can add it as uniform hygiene if you'd prefer.

Through the real hook, empty settings sandbox, RTK_REWRITE_HOST=openclaw exported: new binary raw → exit 0 (rtk git status); scrubbed → exit 3; an un-scrubbed copy of the hook → permissionDecision: "allow". The new case in hooks/claude/test-rtk-rewrite.sh rejects exactly that last one, and an_exported_host_cannot_relax_a_caller_that_did_not_set_it pins the same at the harness level.

Blocker 2 — the dropped ask (sites A and B, one fix)

Only Default relaxes now. ApprovalOwner::apply(decision, verdict) matches on (Delegate, Default, AskRewrite) alone, so an explicit Ask survives and still exits 3; AgentPath::decide and rewrite_cmd::run thread the verdict through.

openclaw/index.ts restores requireApproval for exit 3, so the commands that used to reach a prompt reach one again, while exit 0 still never prompts. The two sites were one change: relaxing only Default without the prompt would still drop explicit asks, and restoring the prompt without the split would re-break #3908.

With {"deny":["Bash(rm -rf *)"],"ask":["Bash(aws s3 rm *)","Bash(git push *)"]} under RTK_REWRITE_HOST=openclaw:

aws s3 rm s3://prod --recursive   exit 3   explicit ask kept -> plugin prompts
git push origin main              exit 3   explicit ask kept -> plugin prompts
rm -rf /tmp/x                     exit 2   deny kept
git status                        exit 0   default relaxed -> no prompt

On an older rtk, exit 3 still prompts, so the upgrade window keeps today's behaviour; upgrading removes only the default prompt.

Optional

  • supported-agents.md:197 is now true, not merely intended: the plugin prompts on exit 3. Added an "Upgrading from an earlier build" note to openclaw/README.md; corrected the Ask/Default split in src/hooks/README.md, openclaw/README.md, supported-agents.md, TECHNICAL.md and the module headers.
  • run_with_env now does command.env_remove("RTK_REWRITE_HOST") before the per-call env slice; the suite is green with the variable exported.

Tests

cargo fmt --all -- --check clean, cargo clippy --all-targets 0 warnings, cargo test --all 3936 passed / 0 failed / 8 ignored. rewrite_host_scoping gained openclaw_keeps_an_explicit_ask_rule and the hermeticity test; the collapse test is narrowed to openclaw_collapses_the_default_ask_to_allow; decision.rs has a_delegate_owning_approval_relaxes_the_default_ask_only. The deny matrix and no_host_can_turn_a_deny_into_an_allow_or_a_passthrough are unchanged and green.

Please approve the CI run on c6f484f.

The Claude and Cursor hooks turn rtk rewrite's exit 0 into an explicit
allow, so an inherited RTK_REWRITE_HOST reaching them would auto-approve
every rewritable command in another agent's session. The repo copies
scrub it with `env -u RTK_REWRITE_HOST`, but copies already deployed keep
the old text, and hook_check graded their version 3 as current, so
nothing asked their owners to migrate. The Claude hooks move to version
4, so hook_check reports those installs as outdated and `rtk init -g`
moves them to the in-process hook, which never reads the variable.

A test now runs each of the three hooks the way the agent runs them, with
the variable set, and checks the JSON they return in the keys that host
reads: the rewrite must come back and must not carry an allow. It clears
the environment before starting bash, so an inherited BASH_ENV or another
rtk cannot stand in for the built one, and it keeps config, data and
cache in the sandbox. Nothing covered the scrub before: the protocol
harness strips the variable before spawning the binary, so removing it
from all three hooks left the suite green. A second test keeps the
shipped Claude hook and CURRENT_HOOK_VERSION in step.

Code comments now state the invariant they stand for instead of pointing
at review rounds. The Claude hooks' exit-code notes and `rtk rewrite`'s
own exit table now say exit 3 covers both an ask rule and no rule, and
the hooks say exit 0 means an allow rule matched: for a hook that scrubs
the variable, no rule never yields exit 0. The OpenClaw docs say, as
Claude Code's own do, that a deny rule matches the command as written and
is not a security boundary, that an explicit ask prompts when RTK
rewrites the command, and point at the exit-code table instead of pi's
README, which documents the opposite convention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 3 of 3 — APPROVED, follow-ups filed

Claim: the OpenClaw plugin tells RTK, through RTK_REWRITE_HOST=openclaw, that it applies its own exec policy, so RTK relaxes the default ask to an allow instead of raising a second approval sourced from Claude Code's settings — while keeping its deny gate and any ask rule the user wrote.

Scope: accept (round 1, frozen).

Ran: rtk rewrite on develop, the round-2 head bc0c06bb and this head c6f484fd (one CARGO_TARGET_DIR each, md5sum differ) × 20 cases — deny, explicit ask, default and allow verdicts driven from a real settings.json, seven malformed host names, non-rewritable and unattestable commands; the real Claude, .claude and Cursor hook scripts end to end with RTK_REWRITE_HOST exported, on both heads; a deployed version-3 legacy hook driving the new binary, and hook_check plus rtk init -g against it with and without CLAUDE_CONFIG_DIR. Invariants: every arm of the decision, both sides of the version boundary, under the hook. Savings: not applicable — no filter changes.

Matrix: executed this round (and at rounds 1 and 2).

CI @0b07197a: all green — 11/11, including test on ubuntu, macOS and Windows. The new hook test is cfg(unix), so it ran on the ubuntu and macOS runners.

Blocks merge (0)

Both round-2 blockers are fixed, and verified by execution rather than by the thread resolves:

develop round-2 head this head
explicit ask, RTK_REWRITE_HOST=openclaw 3 0 3
default (no rule), RTK_REWRITE_HOST=openclaw 3 0 0
denied command, any host 2 2 2
Claude hook, variable exported no decision permissionDecision: allow no decision
Cursor hook, variable exported ask allow ask

Against develop, the whole behavioural footprint is the one intended cell: the default ask relaxing under OpenClaw.

One new blocker surfaced this round and is closed by the fixup below: the scrub reached only the repo's copies of the hooks. A version-3 Claude hook already deployed on a user's machine has no scrub, and driving the new binary with the variable inherited it answered permissionDecision: allow — while hook_check graded version 3 as current, so nothing asked its owner to migrate.

Fixup 0b07197a — what it changed

  • Reaches deployed Claude hooks. rtk-hook-version and CURRENT_HOOK_VERSION move to 4, so hook_check reports older deployed copies as outdated, and rtk init -g migrates them to rtk hook claude, which never reads the variable. Verified end to end on the default ~/.claude path: warning shown, legacy script removed, settings.json rewired.
  • Pins the scrub with a behavioural test. affirmative_allow_hooks_ignore_an_inherited_host runs each of the three hooks through bash with the variable set, in a cleared environment with config, data and cache in the sandbox, and checks the JSON reply in the keys that host reads: the rewrite comes back, and no allow does. It fails if the scrub is removed from any hook, if an inherited BASH_ENV substitutes another rtk, or if a Claude hook answers in Cursor's keys. No test covered this before: the protocol harness strips the variable before spawning the binary, so removing the scrub from all three hooks left the suite green. test_shipped_claude_hook_carries_the_current_version keeps the shipped Claude hook and the constant in step.
  • Comments and docs. Code comments state their invariant instead of citing review rounds. The Claude hooks' exit-code notes and rtk rewrite's own exit table now say exit 3 covers both an ask rule and no rule, and the hooks say exit 0 means an allow rule matched. The OpenClaw docs say, as Claude Code's own do, that a deny rule matches the command as written and is not a security boundary; that an explicit ask prompts when RTK rewrites the command; and point at the exit-code table instead of pi's README, which documents the opposite convention.

Filed as follow-ups

  • #4281 — deny and ask rules miss a command behind an environment assignment or a process wrapper that Claude Code strips (HUSKY=0 git push, timeout 30 git push, command git push). Pre-existing on develop; RTK's rewriter already peels these prefixes, and the permission gate is the one matcher that doesn't. Filed.
  • #4282 — an unreadable or unparseable settings.json drops its deny rules instead of failing closed. Pre-existing. Filed.
  • Cursor legacy hooks have no update path — hook_check never grades Cursor's script, so the version mechanism above reaches Claude only. Absorbed by existing #3465, comment added.
  • CLAUDE_CONFIG_DIR installs are not migrated by rtk init -g: migrate_old_hook_script looks only in ~/.claude while hook_check honours the variable. Already documented on #3465. With the version now at 4, those installs will see the outdated warning once a day until that is fixed.

git -C . push and other git global options are not stopped by Bash(git push *) — in Claude Code either, whose docs use that exact example. RTK matches Claude Code there, so nothing is filed for it.

Checked and correct — no need to re-verify

  • Deny survives every host, including a compound with one denied segment; the rule source is unchanged.
  • An explicit ask stays exit 3 under OpenClaw; only the default relaxes. Both production callers of ApprovalOwner::apply pass the same verdict they decided on.
  • from_env has one production call site, and the in-process rtk hook claude that rtk init installs never reads the variable.
  • hooks/pi, hooks/hermes and hooks/opencode treat exit 0 and 3 identically and set no permission decision, so the variable changes nothing observable there.
  • The protocol suite stays green with the variable exported.
  • Seven malformed host names fail closed to exit 3.

Your questions

  • "I can add it as uniform hygiene if you'd prefer" (the scrub in pi, hermes and opencode) → not needed. Those delegates can't observe the difference, so it would be code without a behaviour to pin.
  • "Please approve the CI run on c6f484f." → done; its first run finished green.

Next

Nothing — approving.

Rounds: 3/3. Threads: 0 open, 0 new this round.

@KuSh
KuSh merged commit a89a314 into rtk-ai:develop Sep 26, 2026
11 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Sep 26, 2026
KuSh added a commit that referenced this pull request Sep 26, 2026
…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 deny and ask loops now also try `peel_for_matching(segment)`: the command
behind every prefix that only runs the next word. Wrappers are read from the
tables the rewrite already uses, so what each one consumes — `timeout`'s
duration, `nice -n 5` — has one definition. `stdbuf` and a bare `xargs` are
recognised but never rewritten through: `stdbuf` exists to unbuffer the wrapped
command's output, which routing through rtk would buffer. They sit in a
match-only table the rewrite does not consult.

Assignments are recognised in bash's own sense: a name of letters, digits and
`_` not starting with a digit, then `=` or `+=`, in any case — `foo=bar rm -rf
tmp/` is `rm` to bash. Words come from the lexer, so a quoted value is one word
however it ends. The rewrite's `ENV_PREFIX` is not reused for this: when a
quoted value is not followed by a space it falls back to its unquoted form,
which stops at the first space inside the quotes, and a deny rule would then
fire on `D='# git push now'`, which runs nothing.

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. For the same reason `xargs` is looked through only when bare — `-n1`
or `-I{}` changes what it runs.

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.

Fixes #4281

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KuSh added a commit that referenced this pull request Sep 26, 2026
…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
`peel_for_matching(segment)`, which applies the rewrite's own peels — its env
peel, its keyword prefixes, its wrapper table — to a fixpoint, so the gate and
the rewrite cannot disagree again.

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.

Because assignments go through the rewrite's env peel, the gate reads them
exactly as the rewrite does, including where that peel falls short of bash:
uppercase names only, and a quoted value not followed by a space (#3262). Both
are fixed in that peel itself, where #4136 replaces it, and the gate follows.

Fixes #4281

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KuSh added a commit that referenced this pull request Oct 5, 2026
…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
`peel_for_matching`, which applies the rewrite's own peels — its env peel, its
keyword prefixes, its wrapper table — to a fixpoint, so the gate and the
rewrite cannot disagree again. It reads the segment after the split's leftover
grammar is gone, since `{ HUSKY=0 git push; }` reaches the gate as
`{ HUSKY=0 git push`, and the grammar-only reading stays a try of its own for a
rule that names the assignment.

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.

Because assignments go through the rewrite's env peel, the gate reads them
exactly as the rewrite does, including where that peel falls short of bash:
uppercase names only, and a quoted value not followed by a space (#3262). Both
are fixed in that peel itself, where #4136 replaces it, and the gate follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KuSh added a commit that referenced this pull request Oct 7, 2026
…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
`peel_for_matching`, which applies the rewrite's own peels — its env peel, its
keyword prefixes, its wrapper table — to a fixpoint, so the gate and the
rewrite cannot disagree again. It reads the segment after the split's leftover
grammar is gone, since `{ HUSKY=0 git push; }` reaches the gate as
`{ HUSKY=0 git push`, and the grammar-only reading stays a try of its own for a
rule that names the assignment.

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.

Because assignments go through the rewrite's env peel, the gate reads them
exactly as the rewrite does, including where that peel falls short of bash:
uppercase names only, and a quoted value not followed by a space (#3262). Both
are fixed in that peel itself, where #4136 replaces it, and the gate follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KuSh added a commit that referenced this pull request Oct 9, 2026
…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 added a commit that referenced this pull request Oct 11, 2026
…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. Inside a `bash -c` or
`sh -c` script, the deny check reads each inner segment the same way.

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 added a commit that referenced this pull request Oct 14, 2026
…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>
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.

rewrite: host-scoped permission authority so plugin hosts stop adding a second approval gate

3 participants