diff --git a/internal/sandbox/safe_command.go b/internal/sandbox/safe_command.go index 8cb79c976..d6c2c8ea3 100644 --- a/internal/sandbox/safe_command.go +++ b/internal/sandbox/safe_command.go @@ -24,12 +24,12 @@ type InteractiveCommandResult struct { type interactiveProgram struct { reason string suggestion string + // windowsSuggestion overrides suggestion when goos == "windows", for cases + // where the POSIX suggestion (cat/head/tail/ps, etc.) has no cmd.exe + // equivalent and would just trade one broken command for another. + windowsSuggestion string // windowsOnly limits the match to GOOS == "windows" (e.g. notepad). windowsOnly bool - // windowsSuggestion overrides suggestion on GOOS == "windows", for cases - // where the default suggestion names POSIX-only tools (cat/head/tail) - // that cmd.exe does not have. - windowsSuggestion string } // interactivePrograms maps a bare command name to its non-interactive guidance. @@ -59,10 +59,10 @@ var interactivePrograms = map[string]interactiveProgram{ windowsSuggestion: "Use `type` to print file contents non-interactively, or the read_file tool with offset/limit for a partial view.", }, // Process/system monitors. - "top": {reason: "top runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` (optionally `| head`) for a one-shot snapshot."}, - "htop": {reason: "htop runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` (optionally `| head`) for a one-shot snapshot."}, - "btop": {reason: "btop runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` (optionally `| head`) for a one-shot snapshot."}, - "btm": {reason: "btm runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` for a one-shot snapshot."}, + "top": {reason: "top runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` (optionally `| head`) for a one-shot snapshot.", windowsSuggestion: "Use `tasklist` for a one-shot process snapshot."}, + "htop": {reason: "htop runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` (optionally `| head`) for a one-shot snapshot.", windowsSuggestion: "Use `tasklist` for a one-shot process snapshot."}, + "btop": {reason: "btop runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` (optionally `| head`) for a one-shot snapshot.", windowsSuggestion: "Use `tasklist` for a one-shot process snapshot."}, + "btm": {reason: "btm runs a live full-screen dashboard until you quit it", suggestion: "Use `ps aux` for a one-shot snapshot.", windowsSuggestion: "Use `tasklist` for a one-shot process snapshot."}, "watch": {reason: "watch re-runs a command on a loop until interrupted", suggestion: "Run the underlying command once instead of wrapping it in `watch`."}, // Language REPLs (only interactive when invoked with no script/expression). "python": {reason: "python with no script drops into an interactive REPL", suggestion: "Run `python script.py` or `python -c ''`."}, @@ -124,18 +124,19 @@ var infoExitFlags = map[string]bool{ // matches them as substrings (after normalizing whitespace) so flags like // `git rebase -i` or `tail -f` are caught even mid-pipeline. var interactiveSegments = []struct { - match string - command string - reason string - suggestion string + match string + command string + reason string + suggestion string + windowsSuggestion string }{ {match: "git rebase -i", command: "git rebase -i", reason: "interactive rebase opens an editor for the todo list", suggestion: "Use a non-interactive rebase (`git rebase `) or scripted `git rebase --onto`, and resolve via `git rebase --continue`."}, {match: "git rebase --interactive", command: "git rebase -i", reason: "interactive rebase opens an editor for the todo list", suggestion: "Use a non-interactive rebase (`git rebase `)."}, {match: "git add -i", command: "git add -i", reason: "interactive add opens a selection prompt", suggestion: "Stage paths explicitly: `git add `."}, {match: "git add -p", command: "git add -p", reason: "interactive patch staging opens a prompt", suggestion: "Stage paths explicitly: `git add `."}, {match: "git commit -p", command: "git commit -p", reason: "interactive patch commit opens a prompt", suggestion: "Stage with `git add ` then `git commit -m`."}, - {match: "tail -f", command: "tail -f", reason: "tail -f follows a file forever", suggestion: "Use `tail -n N ` for a bounded read."}, - {match: "tail --follow", command: "tail -f", reason: "tail --follow follows a file forever", suggestion: "Use `tail -n N ` for a bounded read."}, + {match: "tail -f", command: "tail -f", reason: "tail -f follows a file forever", suggestion: "Use `tail -n N ` for a bounded read.", windowsSuggestion: "Read the file with the read_file tool (offset/limit), or `type ` for the whole file."}, + {match: "tail --follow", command: "tail -f", reason: "tail --follow follows a file forever", suggestion: "Use `tail -n N ` for a bounded read.", windowsSuggestion: "Read the file with the read_file tool (offset/limit), or `type ` for the whole file."}, {match: "journalctl -f", command: "journalctl -f", reason: "journalctl -f streams logs forever", suggestion: "Use `journalctl -n N` for a bounded read."}, {match: "kubectl logs -f", command: "kubectl logs -f", reason: "kubectl logs -f streams logs forever", suggestion: "Drop -f and use `kubectl logs --tail=N`."}, {match: "docker logs -f", command: "docker logs -f", reason: "docker logs -f streams logs forever", suggestion: "Drop -f and use `docker logs --tail N`."}, @@ -165,11 +166,15 @@ func DetectInteractiveCommand(command string, goos string) InteractiveCommandRes body := strings.ToLower(commandBody(strings.Fields(segment))) for _, seg := range interactiveSegments { if body == seg.match || strings.HasPrefix(body, seg.match+" ") { + suggestion := seg.suggestion + if goos == "windows" && seg.windowsSuggestion != "" { + suggestion = seg.windowsSuggestion + } return InteractiveCommandResult{ Interactive: true, Command: seg.command, Reason: seg.reason, - Suggestion: seg.suggestion, + Suggestion: suggestion, } } } diff --git a/internal/sandbox/safe_command_test.go b/internal/sandbox/safe_command_test.go index 34a4518c5..3e817b66f 100644 --- a/internal/sandbox/safe_command_test.go +++ b/internal/sandbox/safe_command_test.go @@ -88,6 +88,42 @@ func TestDetectInteractiveCommandHonorsWindows(t *testing.T) { } } +func TestDetectInteractiveCommandSuggestsWindowsAlternativesOnWindows(t *testing.T) { + for _, tc := range []struct { + command string + avoid []string + want string + }{ + {command: "more file.txt", avoid: []string{"cat", "head", "tail"}, want: "type"}, + {command: "less file.txt", avoid: []string{"cat", "head", "tail"}, want: "type"}, + {command: "most file.txt", avoid: []string{"cat", "head", "tail"}, want: "type"}, + {command: "top", avoid: []string{"ps aux"}, want: "tasklist"}, + {command: "htop", avoid: []string{"ps aux"}, want: "tasklist"}, + {command: "btop", avoid: []string{"ps aux"}, want: "tasklist"}, + {command: "btm", avoid: []string{"ps aux"}, want: "tasklist"}, + {command: "tail -f app.log", avoid: []string{"tail -n"}, want: "read_file"}, + } { + result := DetectInteractiveCommand(tc.command, "windows") + if !result.Interactive { + t.Fatalf("DetectInteractiveCommand(%q, windows) = not interactive, want interactive", tc.command) + } + for _, bad := range tc.avoid { + if strings.Contains(result.Suggestion, bad) { + t.Fatalf("DetectInteractiveCommand(%q, windows).Suggestion = %q, want it to avoid POSIX-only %q", tc.command, result.Suggestion, bad) + } + } + if !strings.Contains(result.Suggestion, tc.want) { + t.Fatalf("DetectInteractiveCommand(%q, windows).Suggestion = %q, want it to mention %q", tc.command, result.Suggestion, tc.want) + } + } + + // The same commands on Linux should keep the original POSIX suggestion. + result := DetectInteractiveCommand("more file.txt", "linux") + if !strings.Contains(result.Suggestion, "cat") { + t.Fatalf("DetectInteractiveCommand(more, linux).Suggestion = %q, want the POSIX suggestion unchanged", result.Suggestion) + } +} + // TestDetectInteractiveCommandPagerSuggestionIsPlatformSpecific covers a fix // for suggesting POSIX-only tools (cat/head/tail) as the escape hatch for a // blocked pager on Windows, where cmd.exe has none of them.