Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/discover/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,14 @@ pub(crate) fn redirect_has_file_target(tokens: &[ParsedToken], i: usize) -> bool
/// hiding behind a construct another segmenter would leave intact.
/// Callers must still gate on [`contains_unattestable_construct`] first.
pub fn split_for_permissions(cmd: &str) -> Vec<&str> {
split_segments(cmd, true)
}

pub fn split_for_wrappers(cmd: &str) -> Vec<&str> {
split_segments(cmd, false)
}

fn split_segments(cmd: &str, truncate_at_redirect: bool) -> Vec<&str> {
let trimmed = cmd.trim();
if trimmed.is_empty() {
return vec![];
Expand Down Expand Up @@ -734,7 +742,7 @@ pub fn split_for_permissions(cmd: &str) -> Vec<&str> {
seg_start = end;
i = next;
continue;
} else if seg_end.is_none() {
} else if truncate_at_redirect && seg_end.is_none() {
seg_end = Some(tok.offset);
}
} else if tok.kind == TokenKind::Arg
Expand Down
21 changes: 19 additions & 2 deletions src/discover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,12 @@ fn estimate_hook_coverage(permission_cmd: &str, rewrite_cmd: &str, ctx: &Coverag
if !ctx.hook_installed {
return false;
}
let verdict = permissions::check_command_with_rules(
let verdict = permissions::check_command_with_rules_and_prefixes(
permission_cmd,
&ctx.rules.deny,
&ctx.rules.ask,
&ctx.rules.allow,
&ctx.normalized_transparent_prefixes,
);
estimate_hook_coverage_with_verdict(permission_cmd, rewrite_cmd, verdict, ctx)
}
Expand All @@ -186,7 +187,7 @@ fn estimate_hook_coverage_with_verdict(
ctx: &CoverageContext,
) -> bool {
ctx.hook_installed
&& verdict != PermissionVerdict::Deny
&& !matches!(verdict, PermissionVerdict::Deny | PermissionVerdict::Defer)
&& !lexer::contains_unattestable_construct(permission_cmd)
&& registry::rewrite_command_precompiled(
rewrite_cmd,
Expand Down Expand Up @@ -730,6 +731,22 @@ mod tests {
));
}

#[test]
fn test_estimate_hook_coverage_false_for_deferred_wrapper() {
assert!(!estimate_hook_coverage_with_verdict(
"env bash -c 'git push'",
"env bash -c 'git push'",
PermissionVerdict::Defer,
&test_ctx(true),
));
assert!(estimate_hook_coverage_with_verdict(
"env bash -c 'git push'",
"env bash -c 'git push'",
PermissionVerdict::Ask,
&test_ctx(true),
));
}

#[test]
fn test_estimate_hook_coverage_true_for_rewritable_command() {
let ctx = test_ctx(true);
Expand Down
113 changes: 112 additions & 1 deletion src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ static BASH_JOIN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\\\r?\n").u
/// Replace every bash line continuation with a single space, mirroring what
/// bash does before dispatching the command. Returns a borrowed `&str` when the
/// input contains no continuations, so the common fast path allocates nothing.
fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> {
pub(crate) fn collapse_line_continuations(s: &str) -> std::borrow::Cow<'_, str> {
LINE_CONTINUATION_RE.replace_all(s, " ")
}

Expand Down Expand Up @@ -1669,6 +1669,74 @@ fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool {
})
}

/// Every position in `seg` where [`rewrite_segment_inner`] would try a quoted
/// shell wrapper: the segment itself and each tail reached by stripping one
/// prefix the rewriter is transparent to, following the same branches and
/// depth bound. The permission gate checks exactly these, so a wrapper a
/// rewrite can reach is never auto-allowed on the outer rule, and a shell name
/// that is merely an argument (`grep -rn bash -c src`) is never a wrapper.
pub(crate) fn shell_wrapper_positions<'a>(
seg: &'a str,
normalized_transparent_prefixes: &[String],
) -> Vec<&'a str> {
let mut positions = Vec::new();
collect_shell_wrapper_positions(seg, normalized_transparent_prefixes, 0, &mut positions);
positions
}

fn collect_shell_wrapper_positions<'a>(
seg: &'a str,
transparent_prefixes: &[String],
depth: usize,
positions: &mut Vec<&'a str>,
) {
let trimmed = seg.trim();
if trimmed.is_empty() {
return;
}
positions.push(trimmed);
if depth >= MAX_PREFIX_DEPTH {
return;
}

let (env_prefix, rest_after_env) = strip_disabled_prefix(trimmed);
if !env_prefix.is_empty() {
if env_prefix.contains("RTK_DISABLED=") {
return;
}
collect_shell_wrapper_positions(rest_after_env, transparent_prefixes, depth + 1, positions);
return;
}

for (prefix, routable) in builtin_transparent_prefixes() {
if let Some(rest) = strip_word_prefix(trimmed, prefix) {
if rest.is_empty() {
return;
}
collect_shell_wrapper_positions(rest, transparent_prefixes, depth + 1, positions);
if !routable {
return;
}
break;
}
}

if let Some((_, rest)) = strip_process_wrapper_prefix(trimmed) {
collect_shell_wrapper_positions(rest, transparent_prefixes, depth + 1, positions);
return;
}

for prefix in transparent_prefixes {
if let Some(rest) = strip_word_prefix(trimmed, prefix) {
if rest.is_empty() {
return;
}
collect_shell_wrapper_positions(rest, transparent_prefixes, depth + 1, positions);
return;
}
}
}

fn rewrite_segment_inner(
seg: &str,
excluded: &[ExcludePattern],
Expand Down Expand Up @@ -2114,6 +2182,49 @@ mod tests {
super::rewrite_command(cmd, excluded, &[])
}

#[test]
fn test_shell_wrapper_positions_match_where_the_rewrite_reaches() {
let prefixes = normalize_transparent_prefixes(&["direnv exec .".to_string()]);
for (cmd, reached) in [
("bash -c 'git status'", true),
("env bash -c 'git status'", true),
("FOO=1 bash -c 'git status'", true),
("env FOO=1 timeout 5 bash -c 'git status'", true),
("nice -n 5 bash -c 'git status'", true),
("time bash -c 'git status'", true),
("nohup sh -c 'git status'", true),
("command bash -c 'git status'", true),
("exec bash -c 'git status'", true),
("uv run bash -c 'git status'", true),
("direnv exec . bash -c 'git status'", true),
("echo bash -c 'git status'", false),
("ssh host bash -c 'git status'", false),
("find . -exec bash -c 'git status' \\;", false),
("sudo bash -c 'git status'", false),
("RTK_DISABLED=1 bash -c 'git status'", false),
("docker exec c bash -c 'git status'", false),
] {
let rewritten = rewrite_command(cmd, &[], &prefixes)
.is_some_and(|rewritten| rewritten.contains("-c 'rtk git status'"));
let seen = shell_wrapper_positions(cmd, &prefixes)
.into_iter()
.any(|position| parse_shell_wrapper(position).is_some());
assert_eq!(rewritten, reached, "rewrite {cmd:?}");
assert_eq!(seen, reached, "positions {cmd:?}");
}
}

#[test]
fn test_shell_wrapper_positions_stop_at_the_command_word() {
let cmd = format!("echo {}bash -c 'git status'", "a ".repeat(50_000));
assert_eq!(shell_wrapper_positions(&cmd, &[]), vec![cmd.as_str()]);
let nested = format!("{}bash -c 'git status'", "command ".repeat(15));
assert_eq!(
shell_wrapper_positions(&nested, &[]).len(),
MAX_PREFIX_DEPTH + 1
);
}

// Three compound-command segmenters look at the same kind of input for
// different, deliberate purposes — split_for_permissions (the permission
// gate, most conservative), split_on_operators/split_command_chain
Expand Down
20 changes: 15 additions & 5 deletions src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,24 +72,34 @@ All file operations use atomic writes (tempfile + rename) to prevent corruption
RTK enforces a permission precedence that matches Claude Code's least-privilege default:

```
Deny > Ask > Allow (explicit) > Default (ask)
Deny > Defer > Ask > Allow (explicit) > Default (ask)
```

Rules are loaded from all Claude Code `settings.json` files (project + global, including `.local` variants). Only `Bash(...)` rules are extracted; other scopes (Read, Write) are ignored.

Quoted `sh -c`, `bash -c`, `zsh -c` and `fish -c` scripts add a
second command-parsing boundary. RTK checks deny rules against both the outer
wrapper and the parsed inner segments. A wrapper rewrite is never auto-allowed:
its strongest RTK verdict is `Ask`. Ask-capable hosts prompt, while other
hosts retain their existing native permission flow. Unsupported or ambiguous
wrapper syntax is left unchanged for the host to evaluate.
wrapper and the parsed inner segments, including a wrapper behind a prefix the
rewrite strips (`env bash -c '…'`, `timeout 5 sh -c '…'`, a configured
`transparent_prefixes` entry — `registry::shell_wrapper_positions`). A shell
name that is merely an argument (`grep -rn bash -c src`) is not a wrapper. A
deny rule matching an inner segment yields `Defer`: RTK neither rewrites nor
decides, so the host sees the command exactly as typed.
A wrapper at the start of a segment is never auto-allowed: its strongest RTK
verdict is `Ask`. A wrapper behind a prefix is only checked for deny rules and
otherwise keeps the outer command's verdict, as on master. Ask-capable hosts
prompt, while other hosts retain their existing native permission flow.
Unsupported or ambiguous wrapper syntax is left unchanged for the host to
evaluate. Line continuations are joined before the check, as the rewrite joins
them.

| Verdict | Trigger | rewrite_cmd exit | Hook behavior |
|---------|---------|-----------------|---------------|
| Deny | `permissions.deny` rule matched | 2 | Passthrough — host tool handles denial |
| Ask | `permissions.ask` rule matched | 3 | Rewrite + let host tool prompt user |
| Allow | `permissions.allow` rule matched | 0 | Rewrite + auto-allow |
| Default | No rule matched | 3 | Rewrite + let host tool prompt user |
| Defer | `permissions.deny` rule matched inside a shell wrapper | 1 | Passthrough — command runs as without RTK |

A delegate that shells out to `rtk rewrite` and applies its own exec policy to
the result can set `RTK_REWRITE_HOST=<agent>` on that subprocess. For an agent
Expand Down
21 changes: 19 additions & 2 deletions src/hooks/decision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ impl ApprovalOwner {
/// The gate order is load-bearing:
///
/// 1. **Deny wins outright.** Checked before anything else so a denied command
/// is never even considered for rewriting.
/// is never even considered for rewriting. A deny rule matched inside a
/// shell wrapper's script yields `Defer` instead: no rewrite, no decision.
/// 2. **Unattestable constructs are refused.** Command substitution and
/// file-target redirects can't be decomposed into segments the permission
/// gate can check individually, so a rewrite could smuggle an unchecked
Expand All @@ -146,7 +147,7 @@ pub(crate) fn decide(cmd: &str, verdict: PermissionVerdict) -> HookDecision {
}

/// [`decide`] with the rewrite parameters supplied by the caller, mirroring
/// [`check_command_with_rules`](super::permissions::check_command_with_rules).
/// [`check_command_with_rules_and_prefixes`](super::permissions::check_command_with_rules_and_prefixes).
///
/// `hook_rewrite_params` reads `config.toml`, which in a test build is the
/// calling test's own. Taking the parameters lets a test state the
Expand All @@ -163,6 +164,10 @@ pub(crate) fn decide_with_params(
return HookDecision::Deny;
}

if verdict == PermissionVerdict::Defer {
return HookDecision::Defer;
}

if crate::discover::lexer::contains_unattestable_construct(cmd) {
return HookDecision::Defer;
}
Expand Down Expand Up @@ -392,6 +397,18 @@ mod tests {
);
}

#[test]
fn defer_verdict_defers_a_rewritable_command() {
assert_eq!(
decide_with_params("env bash -c 'git push'", PermissionVerdict::Defer, &[], &[]),
HookDecision::Defer
);
assert!(matches!(
decide_with_params("env bash -c 'git push'", PermissionVerdict::Ask, &[], &[]),
HookDecision::AskRewrite(_)
));
}

/// Refused whatever the verdict: the permission gate can't decompose these
/// into segments it can check, so a rewrite could carry an unchecked
/// command past an allow rule.
Expand Down
Loading
Loading