Skip to content

Commit 30557a6

Browse files
albatrossflyon-coderkylehgc
authored andcommitted
fix(hooks): honor permission rules for already-rtk-prefixed commands
Fixes rtk-ai#3152. Claude Code sometimes issues the already-rewritten form of a command directly (e.g. `rtk grep foo` instead of `grep foo`) once it has seen it once in an earlier turn. Permission rules like `Bash(grep *)` are written against the underlying tool, but neither RTK's own matcher nor the host's native one recognized `rtk grep foo` as a match for that pattern — RTK's hook fell back to Defer (nothing to rewrite), and the host's native check independently failed the same way, so both allow and deny rules silently stopped applying once a command arrived pre-rewritten. - permissions.rs: strip a leading `rtk ` from each compound-command segment before matching against deny/ask/allow patterns, so rules match regardless of which form the command arrives in. - hook_cmd.rs (decide_from_verdict): an Allow verdict with nothing left to rewrite is no longer silently discarded as Defer — it's asserted directly, since the corrected matching above means it's a real Allow. - hook_cmd.rs (process_claude_payload): a Deny verdict on an already-rtk segment is now asserted explicitly instead of silently deferring to the host's native check — deferring only works when the host evaluates text that still matches the user's original pattern, which isn't true once the segment carries the `rtk ` prefix. This closes a real bypass: a deny rule could previously be sidestepped just by the command arriving pre-rewritten. The original (non-rtk) command path is untouched — same defer-to-native behavior as before. Verified live against the reporter's exact settings.local.json + rtk-prefixed grep/ls repro, the already-rtk deny case, and a compound already-rtk `||` chain. Added regression tests in both files; full suite (2498), clippy, and fmt all clean.
1 parent e3f9581 commit 30557a6

2 files changed

Lines changed: 178 additions & 3 deletions

File tree

‎src/hooks/hook_cmd.rs‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ fn get_rewritten(cmd: &str) -> Option<String> {
266266
Some(rewritten)
267267
}
268268

269+
#[derive(Debug)]
269270
enum HookDecision {
270271
AllowRewrite(String),
271272
AskRewrite(String),
@@ -283,6 +284,14 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision {
283284
match get_rewritten(cmd) {
284285
Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r),
285286
Some(r) => HookDecision::AskRewrite(r),
287+
// No text to rewrite — most commonly because `cmd` is already in
288+
// `rtk …` form (Claude issued it directly after seeing it once).
289+
// `verdict` was computed with #3152's already-rtk-aware matching,
290+
// so an Allow here is real: assert it instead of silently
291+
// deferring to the host's native check, which won't recognize the
292+
// rewritten form either and would prompt for something the user
293+
// already allowlisted.
294+
None if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(cmd.to_string()),
286295
None => HookDecision::Defer,
287296
}
288297
}
@@ -617,6 +626,16 @@ enum PayloadAction {
617626
reason: &'static str,
618627
cmd: String,
619628
},
629+
/// A deny rule matched a segment that's already in `rtk …` form. Unlike
630+
/// the ordinary Skip-on-deny path (which relies on the host's own native
631+
/// check catching it), the host's native check evaluates the raw text
632+
/// unchanged and has no concept of `rtk` as an alias — it would not
633+
/// recognize `rtk rm -rf …` as matching a `Bash(rm:*)` deny rule. Assert
634+
/// the deny explicitly instead of silently stepping aside. See #3152.
635+
Deny {
636+
cmd: String,
637+
output: Value,
638+
},
620639
Ignore,
621640
}
622641

@@ -636,6 +655,21 @@ fn claude_payload_input(v: &Value) -> Option<(&Value, &str)> {
636655
None
637656
}
638657

658+
/// True if any segment of `cmd` is already in `rtk …` form. Used to decide
659+
/// whether a Deny verdict is safe to leave to the host's own native
660+
/// permission check (works for the original, un-rewritten command text)
661+
/// or must be asserted explicitly (the host's native check evaluates the
662+
/// raw text unchanged and has no concept of `rtk` as an alias for the
663+
/// underlying tool). See #3152.
664+
fn contains_already_rtk_segment(cmd: &str) -> bool {
665+
crate::discover::lexer::split_for_permissions(cmd)
666+
.iter()
667+
.any(|segment| {
668+
let segment = segment.trim();
669+
segment == "rtk" || segment.starts_with("rtk ")
670+
})
671+
}
672+
639673
fn process_claude_payload(v: &Value) -> PayloadAction {
640674
let (input, cmd) = match claude_payload_input(v) {
641675
Some((input, cmd)) => (input, cmd),
@@ -644,10 +678,23 @@ fn process_claude_payload(v: &Value) -> PayloadAction {
644678

645679
let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) {
646680
HookDecision::Deny => {
681+
if contains_already_rtk_segment(cmd) {
682+
let output = json!({
683+
"hookSpecificOutput": {
684+
"hookEventName": PRE_TOOL_USE_KEY,
685+
"permissionDecision": "deny",
686+
"permissionDecisionReason": "RTK: matches a configured deny rule"
687+
}
688+
});
689+
return PayloadAction::Deny {
690+
cmd: cmd.to_string(),
691+
output,
692+
};
693+
}
647694
return PayloadAction::Skip {
648695
reason: "skip:deny_rule",
649696
cmd: cmd.to_string(),
650-
}
697+
};
651698
}
652699
HookDecision::Defer => {
653700
return PayloadAction::Skip {
@@ -721,6 +768,10 @@ pub fn run_claude() -> Result<()> {
721768
PayloadAction::Skip { reason, cmd } => {
722769
audit_log(reason, &cmd, "");
723770
}
771+
PayloadAction::Deny { cmd, output } => {
772+
audit_log("deny:already_rtk", &cmd, "");
773+
let _ = writeln!(io::stdout(), "{output}");
774+
}
724775
PayloadAction::Ignore => {}
725776
}
726777

@@ -733,6 +784,7 @@ fn run_claude_inner(input: &str) -> Option<String> {
733784
let v: Value = serde_json::from_str(input).ok()?;
734785
match process_claude_payload(&v) {
735786
PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
787+
PayloadAction::Deny { output, .. } => Some(output.to_string()),
736788
_ => None,
737789
}
738790
}
@@ -1565,6 +1617,26 @@ mod tests {
15651617
assert!(run_claude_inner(&claude_input("rtk git status")).is_none());
15661618
}
15671619

1620+
// --- contains_already_rtk_segment (used by the active-deny path, #3152) ---
1621+
1622+
#[test]
1623+
fn test_contains_already_rtk_segment_detects_prefix() {
1624+
assert!(contains_already_rtk_segment("rtk rm -rf /"));
1625+
assert!(contains_already_rtk_segment("rtk"));
1626+
assert!(contains_already_rtk_segment(
1627+
"git status && rtk rm -rf /tmp/x"
1628+
));
1629+
}
1630+
1631+
#[test]
1632+
fn test_contains_already_rtk_segment_ignores_original_form() {
1633+
assert!(!contains_already_rtk_segment("rm -rf /"));
1634+
assert!(!contains_already_rtk_segment("git status && cargo test"));
1635+
// A tool name that merely starts with "rtk" as a substring, not a
1636+
// whole segment/prefix, must not false-positive.
1637+
assert!(!contains_already_rtk_segment("rtkinit --help"));
1638+
}
1639+
15681640
#[test]
15691641
fn test_claude_empty_command_passthrough() {
15701642
let input = json!({
@@ -1977,6 +2049,40 @@ mod tests {
19772049
));
19782050
}
19792051

2052+
// --- Regression tests for #3152 ---
2053+
// An already-rtk command with no text left to rewrite must still assert
2054+
// Allow when the (now already-rtk-aware) verdict says so, instead of
2055+
// deferring to the host's native check, which doesn't recognize the
2056+
// rewritten form and would prompt for something the user allowlisted.
2057+
2058+
#[test]
2059+
fn test_decide_allow_for_already_rtk_command() {
2060+
let allow = vec!["grep *".to_string()];
2061+
match decide_with_rules("rtk grep -n foo bar.py", &[], &[], &allow) {
2062+
HookDecision::AllowRewrite(r) => assert_eq!(r, "rtk grep -n foo bar.py"),
2063+
other => panic!("expected AllowRewrite(cmd unchanged), got {other:?}"),
2064+
}
2065+
}
2066+
2067+
#[test]
2068+
fn test_decide_deny_for_already_rtk_command() {
2069+
let deny = vec!["rm:*".to_string()];
2070+
assert!(matches!(
2071+
decide_with_rules("rtk rm -rf /", &deny, &[], &all_allowed()),
2072+
HookDecision::Deny
2073+
));
2074+
}
2075+
2076+
#[test]
2077+
fn test_decide_defer_for_already_rtk_command_with_no_matching_rule() {
2078+
// No allow rule matches "ls" — must stay Defer (ask), not silently allow.
2079+
let allow = vec!["grep *".to_string()];
2080+
assert!(matches!(
2081+
decide_with_rules("rtk ls -la", &[], &[], &allow),
2082+
HookDecision::Defer
2083+
));
2084+
}
2085+
19802086
// --- Gemini rendering ---
19812087

19822088
fn gemini_render(cmd: &str, deny: &[String], ask: &[String], allow: &[String]) -> String {

‎src/hooks/permissions.rs‎

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub(crate) fn check_command_with_rules(
6161

6262
// Deny takes highest priority and pre-empts every other construct.
6363
for segment in &segments {
64-
let segment = segment.trim();
64+
let segment = normalize_for_matching(segment.trim());
6565
for pattern in deny_rules {
6666
if command_matches_pattern(segment, pattern) {
6767
return PermissionVerdict::Deny;
@@ -82,7 +82,7 @@ pub(crate) fn check_command_with_rules(
8282
let mut saw_segment = false;
8383

8484
for segment in &segments {
85-
let segment = segment.trim();
85+
let segment = normalize_for_matching(segment.trim());
8686
if segment.is_empty() {
8787
continue;
8888
}
@@ -375,6 +375,20 @@ pub(crate) fn extract_bash_pattern(rule: &str) -> &str {
375375
rule
376376
}
377377

378+
/// Strips a leading `rtk ` invocation from a permission-check segment.
379+
///
380+
/// Permission rules (e.g. `Bash(grep *)`) are written against the
381+
/// underlying tool the user actually typed, but once an agent host has
382+
/// seen RTK's rewritten form it sometimes issues `rtk grep …` directly on
383+
/// a later turn — a segment that no longer looks like the original tool
384+
/// invocation to either RTK's own matcher or the host's native one, so
385+
/// deny/ask/allow rules alike silently stop applying (rtk-ai/rtk#3152).
386+
/// Normalizing here — right before matching — keeps the deny/ask/allow
387+
/// checks meaningful regardless of which form the command arrives in.
388+
fn normalize_for_matching(segment: &str) -> &str {
389+
segment.strip_prefix("rtk ").unwrap_or(segment)
390+
}
391+
378392
/// Check if `cmd` matches a Claude Code permission pattern.
379393
///
380394
/// Pattern forms:
@@ -1166,6 +1180,61 @@ mod tests {
11661180
assert_eq!(out, vec!["git", "npm test", "*"]);
11671181
}
11681182

1183+
// --- Regression tests for #3152 ---
1184+
// Allow/ask/deny rules are written against the underlying tool (e.g.
1185+
// `Bash(grep *)`), but Claude sometimes issues the already-rewritten
1186+
// `rtk grep …` form directly on a later turn. Rules must still apply.
1187+
1188+
#[test]
1189+
fn test_already_rtk_matches_allow_rule() {
1190+
let allow = vec!["grep *".to_string(), "ls *".to_string()];
1191+
assert_eq!(
1192+
check_command_with_rules("rtk grep -n foo bar.py", &[], &[], &allow),
1193+
PermissionVerdict::Allow
1194+
);
1195+
assert_eq!(
1196+
check_command_with_rules("rtk ls -la", &[], &[], &allow),
1197+
PermissionVerdict::Allow
1198+
);
1199+
}
1200+
1201+
#[test]
1202+
fn test_already_rtk_still_matches_deny_rule() {
1203+
// A deny rule must not be bypassable just because the command
1204+
// arrives pre-rewritten — this is the security-relevant half of #3152.
1205+
let deny = vec!["rm:*".to_string()];
1206+
let allow = vec!["*".to_string()];
1207+
assert_eq!(
1208+
check_command_with_rules("rtk rm -rf /", &deny, &[], &allow),
1209+
PermissionVerdict::Deny
1210+
);
1211+
}
1212+
1213+
#[test]
1214+
fn test_already_rtk_still_matches_ask_rule() {
1215+
let ask = vec!["git push".to_string()];
1216+
assert_eq!(
1217+
check_command_with_rules("rtk git push origin main", &[], &ask, &[]),
1218+
PermissionVerdict::Ask
1219+
);
1220+
}
1221+
1222+
#[test]
1223+
fn test_already_rtk_compound_all_segments_checked() {
1224+
// Mirrors #1213's per-segment requirement: every segment of an
1225+
// already-rtk compound command must independently match.
1226+
let allow = vec!["git status *".to_string(), "git status".to_string()];
1227+
assert_eq!(
1228+
check_command_with_rules("rtk git status && rtk git add .", &[], &[], &allow),
1229+
PermissionVerdict::Default,
1230+
"unallowed second segment must demote the whole chain, even pre-rewritten"
1231+
);
1232+
assert_eq!(
1233+
check_command_with_rules("rtk git status && rtk git status -s", &[], &[], &allow),
1234+
PermissionVerdict::Allow
1235+
);
1236+
}
1237+
11691238
#[test]
11701239
fn test_wrapped_rules_extracted_patterns_match() {
11711240
let mut allow = Vec::new();

0 commit comments

Comments
 (0)