Repository navigation
fix(openclaw): let the host own approval without losing RTK's deny gate - #3909
Conversation
7c3a0ef to
320e28d
Compare
KuSh
left a comment
There was a problem hiding this comment.
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 ranCompounds 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 commandThe 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
Denyhost-independent — giveHost::OpenClawa deny source (Claude's, or one the plugin passes) and leave ask/allow empty, so the collapse applies only toAsk/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 correctsupported-agents.md:168("a deny verdict from RTK still blocks the tool call") and theindex.tsprotocol 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 thatValueEnumwas 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_NAMESis 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--hosthelp atsrc/main.rs:941. This bites at rebase —develop'sHostalready carriesCodexandTrae, whichfrom_cli_namedoes not know, so--host codexwould warn and fall back to Claude. The compiler catches the missingload_rules_forarm but not the missing name.AgentPath::AGENTSon 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 claudepreserves the #1155 invariant:Default→ exit 3.contains_unattestable_constructstill 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. --hostafter the positional is swallowed into the command, astest_rewrite_clap_host_after_command_is_part_of_the_commandsays; 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 openclawlets a deniedrm -rfthrough." Dropped:rm -rf /tmp/xexits 1 under every host because it has no RTK rewrite at all, so it never reaches the verdict branch. The finding above usesduandgit pushprecisely 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.
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>
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>
e8e9fe8 to
bc0c06b
Compare
|
Round 1 is addressed. Head is now Deny class, site 1 — option (a). RTK keeps the deny gate; only approval moves. The rule source is untouched — Site 2 — the environment, not argv. Your open question — does OpenClaw's own policy see the command it is gating? For the exec tool's own checks, yes: The three optional items fall out by construction: the unknown-name path is the stricter default rather than another host's rules ( On Two things I cannot do from here. The workflow run on this head is still waiting for maintainer approval, so Local gate on |
|
Full gate on |
KuSh
left a comment
There was a problem hiding this comment.
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 honouredsrc/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 predatesRTK_REWRITE_HOSTignores 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 raisesrequireApproval, so on an older rtk there is no prompt.openclaw/README.md:87and theindex.tsheader state the 0≡3 handling correctly; this sentence contradicts them. -
tests/hook_decision_protocol_test.rs:77—run_with_envpinsHOME,XDG_CONFIG_HOME,CLAUDE_CONFIG_DIR,RTK_DB_PATH,RTK_TEE_DIR,RTK_RECALL_DBandLC_ALL, but notRTK_REWRITE_HOST. Exporting it turns the suite red on the machine rather than on the code — includingdefault_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-callenvslice 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)). applycannot relaxDeferor a passthrough;suppress_identityruns before it andapply(Defer)is the identity.- Fail-closed on empty,
bogus,OpenClaw,claude,codexand"openclaw ". - Non-rewritable and unattestable constructs stay exit 1 under the delegate.
from_envhas 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:79sets it per child (env: { ...process.env, RTK_REWRITE_HOST }) andsrc/hooks/README.md:87says "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 pushand 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
bc0c06bbwould 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).
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.
bc0c06b to
c6f484f
Compare
|
Round 2 — addressed, head Both blockers fixed, both optional items closed. It is back to one commit on Blocker 1 — the variable is ambientYou'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:
Each now runs Through the real hook, empty settings sandbox, Blocker 2 — the dropped ask (sites A and B, one fix)Only
With On an older rtk, exit 3 still prompts, so the upgrade window keeps today's behaviour; upgrading removes only the default prompt. Optional
Tests
Please approve the CI run on |
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
left a comment
There was a problem hiding this comment.
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-versionandCURRENT_HOOK_VERSIONmove to 4, sohook_checkreports older deployed copies as outdated, andrtk init -gmigrates them tortk hook claude, which never reads the variable. Verified end to end on the default~/.claudepath: warning shown, legacy script removed,settings.jsonrewired. - Pins the scrub with a behavioural test.
affirmative_allow_hooks_ignore_an_inherited_hostruns 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 inheritedBASH_ENVsubstitutes anotherrtk, 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_versionkeeps 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 ondevelop; 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.jsondrops its deny rules instead of failing closed. Pre-existing. Filed. - Cursor legacy hooks have no update path —
hook_checknever grades Cursor's script, so the version mechanism above reaches Claude only. Absorbed by existing #3465, comment added. CLAUDE_CONFIG_DIRinstalls are not migrated byrtk init -g:migrate_old_hook_scriptlooks only in~/.claudewhilehook_checkhonours 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::applypass the same verdict they decided on. from_envhas one production call site, and the in-processrtk hook claudethatrtk initinstalls never reads the variable.hooks/pi,hooks/hermesandhooks/opencodetreat 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.
…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>
…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>
…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>
…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>
…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>
…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>
…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>
Closes #3908.
Summary
rtk rewritejudged every command against Claude Code's four settings files, whatever agent called it. OpenClaw appliestools.exec.modeandsecurityto whatever itsbefore_tool_callhook returns, so RTK's ask became a second approval gate: a host runningexec.mode=fullstill 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 explicitaskrule 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.rsgainsApprovalOwner(Rtk|Delegate) andREWRITE_HOST_ENV. The name resolves throughAgentPath::lookup, so there is one agent vocabulary rather than a second list of host names;AgentPath::ViaRewritecarries the owner, andApprovalOwner::apply(decision, verdict)relaxes on(Delegate, Default, AskRewrite)alone.AgentPath::decideandrewrite_cmd::runthread the verdict.from_envfails closed — an unknown, misspelled, empty or differently-cased name, an in-process host naming itself, or no variable at all givesRtk.openclaw/index.tssets the variable, keepsrequireApprovalfor exit 3 (an explicitaskrule, 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.shandhooks/cursor/rtk-rewrite.shscrubRTK_REWRITE_HOST(env -u) before callingrtk 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.mdandopenclaw/README.mdcarry the rule for readers arriving from each direction, plus an upgrade note.Approval ownership — default relaxes, explicit ask survives
The relaxation is scoped to
Defaultdeliberately. ADefaultverdict 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 explicitaskis the user's own instruction and is kept: RTK still returns exit 3 and OpenClaw's plugin still prompts. Adenyis structurally out ofapply's reach, so a host name cannot turn a denied command into an allowed rewrite.The two halves ship together: relaxing only
Defaultwithout 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 istrailing_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, andBash(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_disagreepins 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.askand 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 withapi.registerTrustedToolPolicy(...)is the exception: it runs before ordinarybefore_tool_callhooks and is showngit push. The plugin's exit-2 deny branch is unaffected either way.The #1155 invariant
Defaultstill exits 3 for every host that reads the exit code as a permission decision:other_delegates_keep_the_default_ask_gateasserts exit 3 forclaude,pi,hermes,opencode,ompandvibe. 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
rtk rewrite <cmd>and every adapter that does not set it are unchanged.Tests
tests/hook_decision_protocol_test.rsgainsrewrite_host_scoping(9 tests): the default ask collapses to allow underopenclaw; 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; andan_exported_host_cannot_relax_a_caller_that_did_not_set_itpins the harness scrub.src/hooks/decision.rsgains:DelegaterelaxesDefaultand nothing else;Rtkis the identity on all four decisions;openclawis the only agent whose owner isDelegate;openclaw's verdict is stillcheck_command_for(cmd, Host::Claude); andfrom_envfails closed for thirteen names plus unset.hooks/claude/test-rtk-rewrite.shgains a case that exportsRTK_REWRITE_HOST=openclawinto a sandboxed hook run and asserts nopermissionDecision: "allow".Local run
c6f484fon0c279f5, 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.