Skip to content

Commit d9e7458

Browse files
kylehgcclaude
andcommitted
fix(hooks): add rtk run to wrappers; never assert for wrapped invocations
Round-4 review fixes on the rtk-ai#3195 adoption: - COMMAND_WRAPPERS missed 'run' — the one subcommand that shells out its argument via sh -c — so 'rtk run rm -rf /' evaded a Bash(rm:*) deny while Bash(rtk:*) allow-asserted it. 'run' is now stripped like proxy, and the exhaustiveness doc is corrected ('check' lives under rtk hook, not top-level). - Adding the token is not sufficient: wrapper arguments are arbitrary text ('rtk run -c "rm -rf /"', quoted args in any wrapper) that token-literal matching cannot decompose. New total rule: wrapped invocations are matched best-effort for deny/ask (over-firing is fail-safe) but are excluded from the hook's assert gates entirely (is_wrapped_invocation) — RTK never speaks for text it cannot attest. - The unattestable-construct branch pre-empted the already-rtk ask assertion: 'rtk git push > f' silently deferred into a host-native rtk:* allowlist. Already-rtk unattestables now assert ask (forced prompt); plain ones keep the silent defer. - test_claude_already_rtk_passthrough migrated to injected rules — since rtk-ai#3152 its outcome depends on permission rules, and reading the real on-disk settings made it environment-dependent. - tests: run-wrapper deny, never-assert sweep across wrapper shapes, unattestable ask assertion, is_wrapped_invocation contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 505e355 commit d9e7458

2 files changed

Lines changed: 138 additions & 13 deletions

File tree

‎src/hooks/hook_cmd.rs‎

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,15 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision {
279279
return HookDecision::Deny;
280280
}
281281
if crate::discover::lexer::contains_unattestable_construct(cmd) {
282+
// RTK can't attest what a substitution / file redirect really does,
283+
// so it never asserts allow here. For an already-rtk command a
284+
// silent defer would hand the decision to a host-native
285+
// `Bash(rtk:*)` allowlist that is blind to the underlying tool —
286+
// force the prompt by asserting ask instead. Plain commands keep
287+
// the pre-existing silent defer.
288+
if contains_already_rtk_segment(cmd) {
289+
return HookDecision::AskRewrite(cmd.to_string());
290+
}
282291
return HookDecision::Defer;
283292
}
284293
match get_rewritten(cmd) {
@@ -703,7 +712,10 @@ fn contains_already_rtk_segment(cmd: &str) -> bool {
703712
}
704713

705714
/// True when `cmd` has at least one non-empty segment and every non-empty
706-
/// segment is rtk-prefixed. See the quantifier note above.
715+
/// segment is rtk-prefixed AND directly names its tool (not a wrapper like
716+
/// `rtk proxy`/`rtk run`, whose arbitrary argument text RTK cannot fully
717+
/// attest — see `permissions::is_wrapped_invocation`). See the quantifier
718+
/// note above.
707719
fn all_segments_already_rtk(cmd: &str) -> bool {
708720
let segments = crate::discover::lexer::split_for_permissions(cmd);
709721
let mut saw_segment = false;
@@ -712,7 +724,7 @@ fn all_segments_already_rtk(cmd: &str) -> bool {
712724
if segment.is_empty() {
713725
continue;
714726
}
715-
if !permissions::is_rtk_prefixed(segment) {
727+
if !permissions::is_rtk_prefixed(segment) || permissions::is_wrapped_invocation(segment) {
716728
return false;
717729
}
718730
saw_segment = true;
@@ -1671,7 +1683,24 @@ mod tests {
16711683

16721684
#[test]
16731685
fn test_claude_already_rtk_passthrough() {
1674-
assert!(run_claude_inner(&claude_input("rtk git status")).is_none());
1686+
// No rules → Default verdict → already-rtk command still defers.
1687+
// Driven through the injected decision path: since #3152 the
1688+
// outcome depends on permission rules, so reading the real on-disk
1689+
// settings here would make the test environment-dependent.
1690+
let input = json!({"tool_name": "Bash", "tool_input": {"command": "rtk git status"}});
1691+
let action = process_claude_payload_impl(&input, |cmd| {
1692+
decide_from_verdict(
1693+
cmd,
1694+
permissions::check_command_with_rules(cmd, &[], &[], &[]),
1695+
)
1696+
});
1697+
assert!(matches!(
1698+
action,
1699+
PayloadAction::Skip {
1700+
reason: "skip:defer",
1701+
..
1702+
}
1703+
));
16751704
}
16761705

16771706
// --- contains_already_rtk_segment (used by the active-deny path, #3152) ---
@@ -2210,6 +2239,7 @@ mod tests {
22102239
let allow = vec!["rtk:*".to_string()];
22112240
for cmd in [
22122241
"rtk proxy rm -rf /",
2242+
"rtk run rm -rf /",
22132243
"rtk err rm -rf /",
22142244
"rtk test rm -rf /",
22152245
"rtk summary rm -rf /",
@@ -2225,15 +2255,60 @@ mod tests {
22252255
}
22262256
}
22272257

2258+
#[test]
2259+
fn test_wrapped_invocations_never_asserted() {
2260+
// Wrapper arguments are arbitrary (`rtk run` takes a whole shell
2261+
// string; quoting defeats token matching in any wrapper), so RTK
2262+
// must never speak for them: deny stays best-effort, allow/ask
2263+
// always defer — even under a catch-all allow rule.
2264+
let allow = vec!["*".to_string()];
2265+
for cmd in [
2266+
"rtk run rm -rf /",
2267+
"rtk run -c \"rm -rf /\"",
2268+
"rtk run \"rm -rf /\"",
2269+
"rtk proxy \"rm\" -rf /",
2270+
"rtk proxy rm -rf /",
2271+
"rtk err rm -rf /",
2272+
] {
2273+
assert!(
2274+
matches!(
2275+
decide_with_rules(cmd, &[], &[], &allow),
2276+
HookDecision::Defer
2277+
),
2278+
"{cmd} must defer, never be asserted"
2279+
);
2280+
}
2281+
}
2282+
2283+
#[test]
2284+
fn test_unattestable_already_rtk_asserts_ask() {
2285+
// A redirect/substitution on an already-rtk command can't be
2286+
// attested; silence would let a host-native `Bash(rtk:*)`
2287+
// allowlist auto-run it, so the ask must be asserted.
2288+
let allow = vec!["*".to_string()];
2289+
match decide_with_rules("rtk git log > /tmp/out.txt", &[], &[], &allow) {
2290+
HookDecision::AskRewrite(r) => assert_eq!(r, "rtk git log > /tmp/out.txt"),
2291+
other => panic!("expected AskRewrite(cmd unchanged), got {other:?}"),
2292+
}
2293+
// The plain (non-rtk) unattestable path keeps its silent defer.
2294+
assert!(matches!(
2295+
decide_with_rules("git log > /tmp/out.txt", &[], &[], &allow),
2296+
HookDecision::Defer
2297+
));
2298+
}
2299+
22282300
#[test]
22292301
fn test_probe_shapes_fail_safe() {
22302302
// Shapes that must never reach the already-rtk assert paths: each
22312303
// stays out of the gates so the host's native matcher (or its
22322304
// prompt) remains authoritative.
22332305
let allow = vec!["*".to_string()];
2306+
// The lexer surfaces the rtk token inside the substitution, so this
2307+
// lands on the unattestable+already-rtk arm: an asserted ask
2308+
// (forced prompt) rather than a silent defer — never an allow.
22342309
assert!(matches!(
22352310
decide_with_rules("$(rtk rm -rf /)", &[], &[], &allow),
2236-
HookDecision::Defer
2311+
HookDecision::AskRewrite(_)
22372312
));
22382313
assert!(!all_segments_already_rtk("FOO=1 rtk rm -rf /"));
22392314
assert!(!all_segments_already_rtk("'rtk' rm -rf /"));

‎src/hooks/permissions.rs‎

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -410,15 +410,41 @@ pub(crate) fn normalize_for_matching(segment: &str) -> &str {
410410
}
411411
}
412412

413-
/// RTK subcommands whose trailing arguments are themselves the executed
414-
/// command: `rtk proxy <cmd>` runs `<cmd>` raw; `rtk err <cmd>`,
415-
/// `rtk test <cmd>`, and `rtk summary <cmd>` run `<cmd>` and filter its
416-
/// output. For permission matching the effective command is the wrapped
417-
/// one, so a `Bash(rm:*)` deny must see through `rtk proxy rm -rf /`.
418-
/// This list is exhaustive over `main.rs`'s `Commands` enum: every other
419-
/// variant either proxies the tool it is named after (`rtk grep …` IS
420-
/// `grep …`) or never executes its argument (`rtk check`, `rtk rewrite`).
421-
const COMMAND_WRAPPERS: [&str; 4] = ["proxy", "err", "test", "summary"];
413+
/// RTK subcommands whose arguments are themselves the executed command:
414+
/// `rtk proxy <cmd>` runs `<cmd>` raw, `rtk run …` shells out its string
415+
/// via `sh -c`, and `rtk err/test/summary <cmd>` run `<cmd>` and filter
416+
/// its output. For permission matching the effective command is the
417+
/// wrapped one, so deny/ask rules are matched against the stripped form —
418+
/// best-effort only: matching is token-literal, and quoted or string-form
419+
/// arguments (`rtk run -c "rm -rf /"`, `rtk proxy "rm" -rf /`) may not
420+
/// match. That is why wrapped invocations are also excluded from the
421+
/// hook's assert gates via [`is_wrapped_invocation`]: over-firing a deny
422+
/// is fail-safe, asserting an allow for text RTK cannot decompose is not.
423+
/// This list is exhaustive over `main.rs`'s top-level `Commands` enum:
424+
/// every other variant either proxies the tool it is named after
425+
/// (`rtk grep …` IS `grep …`) or never executes its argument
426+
/// (`rtk rewrite`, `rtk hook check`).
427+
const COMMAND_WRAPPERS: [&str; 5] = ["proxy", "run", "err", "test", "summary"];
428+
429+
/// True when `segment` reaches its executed command through one of
430+
/// [`COMMAND_WRAPPERS`]. Such segments are matched best-effort for
431+
/// deny/ask rules but must never be allow/ask-ASSERTED by the hook: the
432+
/// wrapper's argument is arbitrary text RTK cannot fully attest.
433+
pub(crate) fn is_wrapped_invocation(segment: &str) -> bool {
434+
let mut s = segment;
435+
loop {
436+
let Some(after_rtk) = strip_token(s, "rtk") else {
437+
return false;
438+
};
439+
if COMMAND_WRAPPERS
440+
.iter()
441+
.any(|wrapper| strip_token(after_rtk, wrapper).is_some())
442+
{
443+
return true;
444+
}
445+
s = after_rtk;
446+
}
447+
}
422448

423449
/// Strips `token` from the start of `s` when it is followed by at least
424450
/// one ASCII IFS whitespace character (space, tab, CR, LF) and more text;
@@ -1318,6 +1344,30 @@ mod tests {
13181344
assert_eq!(normalize_for_matching("rtk proxy"), "proxy");
13191345
}
13201346

1347+
#[test]
1348+
fn test_is_wrapped_invocation_contract() {
1349+
assert!(is_wrapped_invocation("rtk proxy rm -rf /"));
1350+
assert!(is_wrapped_invocation("rtk run -c \"rm -rf /\""));
1351+
assert!(is_wrapped_invocation("rtk rtk proxy rm"));
1352+
assert!(is_wrapped_invocation("rtk err cargo build"));
1353+
assert!(!is_wrapped_invocation("rtk grep foo"));
1354+
assert!(!is_wrapped_invocation("proxy rm -rf /"));
1355+
assert!(!is_wrapped_invocation("rtk proxy"));
1356+
assert!(!is_wrapped_invocation("rm -rf /"));
1357+
assert!(!is_wrapped_invocation("rtk proxyfoo bar"));
1358+
}
1359+
1360+
#[test]
1361+
fn test_run_wrapper_deny_rule_fires() {
1362+
// `rtk run <cmd>` shells out via sh -c — same class as proxy.
1363+
let deny = vec!["rm:*".to_string()];
1364+
let allow = vec!["rtk:*".to_string()];
1365+
assert_eq!(
1366+
check_command_with_rules("rtk run rm -rf /", &deny, &[], &allow),
1367+
PermissionVerdict::Deny
1368+
);
1369+
}
1370+
13211371
#[test]
13221372
fn test_wrapped_command_deny_rule_fires() {
13231373
// Security half of the wrapper strip: a deny against the wrapped

0 commit comments

Comments
 (0)