From 35d2e7f36dd67893af1d5d615e3d9b6f52fdd9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Thu, 21 May 2026 08:41:58 +0800 Subject: [PATCH] fix(discover): register ssh in the rewrite rule table so hook routes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rtk verify --filter ssh` reports 3/3 tests passing because the TOML filter at `src/filters/ssh.toml` is valid, but `rtk rewrite "ssh ..."` returned exit 1 for every shape ‑- the Claude Code hook's rewrite path therefore never produced `rtk ssh ...` and the ssh filter was effectively dark in production, despite passing verify. Root cause: `rewrite_command` ➜ `rewrite_segment_inner` looks up the base command in `RULES` (`src/discover/rules.rs`). `RULES` is the source of truth for the hook rewrite path, but it didn't list ssh. Verify and rewrite consult two different tables, so a TOML-only filter with no RULES entry passes verify yet is invisible to the hook. Fix: add a single `RtkRule` for ssh next to the existing curl/wget entries (same `Network` category, same `\s+` pattern shape). Once rewritten, main.rs's TOML-filter dispatch picks up `src/filters/ssh.toml` unchanged ‑- no other code paths needed. Behaviour: - `ssh user@host uptime` → `rtk ssh user@host uptime` (Allow + filter) - `ssh -o ConnectTimeout=5 h cmd` → `rtk ssh -o ConnectTimeout=5 h cmd` - `ssh host` → `rtk ssh host` (login banners still benefit from strip_lines_matching) - bare `ssh` (no args) → unchanged (`\s+` guard, nothing to filter) Tests: - test_classify_ssh_with_remote_command pins the Supported / Network classification so future tweaks to RULES order can't silently undo this. - test_rewrite_ssh_shapes locks in the three reproduction shapes from the issue verbatim. - test_rewrite_ssh_with_host_only covers the no-remote-command shape that the issue's repro script also exits 1 for today. - test_rewrite_bare_ssh_not_rewritten guards the `\s+` boundary so a future loosened pattern doesn't start rewriting argv-less `ssh`. cargo fmt / clippy --all-targets / test --bin rtk: 1913 passed, 0 warnings. Fixes #1654 --- src/discover/registry.rs | 61 ++++++++++++++++++++++++++++++++++++++++ src/discover/rules.rs | 16 +++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/discover/registry.rs b/src/discover/registry.rs index bb7b11f2a..780f2b6db 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1011,6 +1011,67 @@ mod tests { ); } + // --- #1654: ssh classify + rewrite roundtrip --- + + #[test] + fn test_classify_ssh_with_remote_command() { + // Hook path must classify ssh-with-args as Supported so `rtk rewrite` + // returns the prefixed form instead of bailing with exit 1. + match classify_command("ssh user@host uptime") { + Classification::Supported { + rtk_equivalent, + category, + .. + } => { + assert_eq!(rtk_equivalent, "rtk ssh"); + assert_eq!(category, "Network"); + } + other => panic!("expected Supported, got {:?}", other), + } + } + + #[test] + fn test_rewrite_ssh_shapes() { + // Spot-check the four shapes from #1654's reproduction script. + let cases = [ + ("ssh root@host uptime", "rtk ssh root@host uptime"), + ("ssh user@example.test ls", "rtk ssh user@example.test ls"), + ( + "ssh -o ConnectTimeout=5 host echo OK", + "rtk ssh -o ConnectTimeout=5 host echo OK", + ), + ]; + for (input, expected) in cases { + assert_eq!( + rewrite_command_no_prefixes(input, &[]), + Some(expected.into()), + "rewrite for {:?} should be Some({:?})", + input, + expected + ); + } + } + + #[test] + fn test_rewrite_ssh_with_host_only() { + // `ssh host` (no remote command, just login) is the fourth shape + // from #1654's reproduction script. It still needs to flow through + // the filter — even login banners benefit from `strip_lines_matching` + // in `src/filters/ssh.toml`. + assert_eq!( + rewrite_command_no_prefixes("ssh host", &[]), + Some("rtk ssh host".into()) + ); + } + + #[test] + fn test_rewrite_bare_ssh_not_rewritten() { + // `ssh` with no arguments at all is left alone (the regex requires + // `\s+` after `ssh`); without an argv it would just print usage to + // stderr and there's nothing to filter. + assert_eq!(rewrite_command_no_prefixes("ssh", &[]), None); + } + #[test] fn test_classify_sudo_stripped() { assert_eq!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03..0cde350db 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -425,6 +425,22 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // #1654: ssh has a TOML filter (`src/filters/ssh.toml`) that passes + // `rtk verify --filter ssh` but the hook rewrite path never reached it + // because the registry didn't list a matching rule. Adding the rule lets + // `ssh user@host cmd` flow through `rtk rewrite` → `rtk ssh user@host cmd` + // → main.rs's TOML-filter dispatch. Interactive `ssh host` (no remote + // command, banners only) is intentionally not rewritten — `\s+` requires + // at least one argument, mirroring wget/curl above. + RtkRule { + pattern: r"^ssh\s+", + rtk_cmd: "rtk ssh", + rewrite_prefixes: &["ssh"], + category: "Network", + savings_pct: 65.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^(python3?\s+-m\s+)?mypy(\s|$)", rtk_cmd: "rtk mypy",