mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
DetectInteractiveCommand treated node/python/ruby/... as interactive REPLs unless an argument was a script file or one of a small allow-list of eval/print flags. So 'node --version' (and -v, --check, --help; 'python3 --version', etc.) — all print-and-exit, non-interactive — were wrongly blocked before execution with "Blocked interactive command 'node': node with no script". Add universal print-and-exit flags (--version/--help) that suppress the guard for every repl program, plus node's unambiguous short forms (-v, --check, -h). Kept conservative: --version/--help only universally (e.g. 'mysql -v' is *verbose*, not version, so it still opens a prompt — covered by a new test). Bare 'node'/'python' still blocked. Tests: the exact 'node --version && npm --version' is now allowed; mysql -v stays blocked.
607 lines
27 KiB
Go
607 lines
27 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
// InteractiveCommandResult describes the outcome of inspecting a shell command
|
|
// for interactive programs that would hang a non-interactive agent (the agent
|
|
// has no TTY to type into, so an editor/pager/REPL would block until timeout).
|
|
type InteractiveCommandResult struct {
|
|
// Interactive is true when the command launches a program that waits for
|
|
// terminal input the agent cannot supply.
|
|
Interactive bool
|
|
// Command is the matched program/segment (e.g. "vim", "git rebase -i").
|
|
Command string
|
|
// Reason is a short human-readable explanation of why it would hang.
|
|
Reason string
|
|
// Suggestion is an actionable non-interactive alternative.
|
|
Suggestion string
|
|
}
|
|
|
|
// interactiveProgram pairs a detected program with the guidance shown to the agent.
|
|
type interactiveProgram struct {
|
|
reason string
|
|
suggestion string
|
|
// windowsOnly limits the match to GOOS == "windows" (e.g. notepad).
|
|
windowsOnly bool
|
|
}
|
|
|
|
// interactivePrograms maps a bare command name to its non-interactive guidance.
|
|
// These programs open a TTY session and block forever without one.
|
|
var interactivePrograms = map[string]interactiveProgram{
|
|
// Editors.
|
|
"vim": {reason: "vim is a full-screen editor that waits for keystrokes", suggestion: "Use a non-interactive edit (the edit_file/write_file tools) or `sed -i`/`printf` to modify files."},
|
|
"vi": {reason: "vi is a full-screen editor that waits for keystrokes", suggestion: "Use a non-interactive edit (the edit_file/write_file tools) or `sed -i` to modify files."},
|
|
"nvim": {reason: "nvim is a full-screen editor that waits for keystrokes", suggestion: "Use a non-interactive edit (the edit_file/write_file tools) or `sed -i` to modify files."},
|
|
"nano": {reason: "nano is a full-screen editor that waits for keystrokes", suggestion: "Use a non-interactive edit (the edit_file/write_file tools) or `sed -i` to modify files."},
|
|
"emacs": {reason: "emacs opens an interactive session", suggestion: "Use `emacs --batch` for scripting, or the edit_file/write_file tools."},
|
|
"pico": {reason: "pico is a full-screen editor that waits for keystrokes", suggestion: "Use the edit_file/write_file tools or `sed -i`."},
|
|
// Pagers.
|
|
"less": {reason: "less is a pager that waits for navigation keys", suggestion: "Use `cat`, `head`, or `tail -n N` to print file contents non-interactively."},
|
|
"more": {reason: "more is a pager that waits for navigation keys", suggestion: "Use `cat`, `head`, or `tail -n N` to print file contents non-interactively."},
|
|
"most": {reason: "most is a pager that waits for navigation keys", suggestion: "Use `cat`, `head`, or `tail -n N` to print file contents non-interactively."},
|
|
// 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."},
|
|
"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 '<code>'`."},
|
|
"python3": {reason: "python3 with no script drops into an interactive REPL", suggestion: "Run `python3 script.py` or `python3 -c '<code>'`."},
|
|
"node": {reason: "node with no script drops into an interactive REPL", suggestion: "Run `node script.js` or `node -e '<code>'`."},
|
|
"irb": {reason: "irb is the interactive Ruby REPL", suggestion: "Run `ruby script.rb` or `ruby -e '<code>'`."},
|
|
"ruby": {reason: "ruby with no script may drop into an interactive session", suggestion: "Run `ruby script.rb` or `ruby -e '<code>'`."},
|
|
"pry": {reason: "pry is an interactive Ruby REPL", suggestion: "Run `ruby script.rb` instead."},
|
|
"php": {reason: "php with no script (-a) opens an interactive shell", suggestion: "Run `php script.php` or `php -r '<code>'`."},
|
|
"ghci": {reason: "ghci is the interactive Haskell REPL", suggestion: "Use `runghc script.hs` instead."},
|
|
// Database / remote clients (interactive when no command/query is supplied).
|
|
"psql": {reason: "psql opens an interactive SQL prompt", suggestion: "Pass a query with `psql -c '<sql>'` or a file with `psql -f file.sql`."},
|
|
"mysql": {reason: "mysql opens an interactive SQL prompt", suggestion: "Pass a query with `mysql -e '<sql>'` or a file with `mysql < file.sql`."},
|
|
"sqlite3": {reason: "sqlite3 with no SQL opens an interactive prompt", suggestion: "Pass SQL inline: `sqlite3 db.sqlite '<sql>'`."},
|
|
"redis-cli": {reason: "redis-cli with no command opens an interactive prompt", suggestion: "Pass the command inline: `redis-cli GET key`."},
|
|
"mongo": {reason: "mongo opens an interactive shell", suggestion: "Pass `--eval '<js>'` or a script file."},
|
|
"mongosh": {reason: "mongosh opens an interactive shell", suggestion: "Pass `--eval '<js>'` or a script file."},
|
|
// Remote/terminal sessions (interactive when no remote command is supplied).
|
|
"ssh": {reason: "ssh with no remote command opens an interactive login shell", suggestion: "Append the command to run remotely: `ssh host 'command'`."},
|
|
"telnet": {reason: "telnet opens an interactive session", suggestion: "Use `curl`/`nc` with piped input for scripted access."},
|
|
"ftp": {reason: "ftp opens an interactive session", suggestion: "Use `curl`/`wget` for scripted transfers."},
|
|
"sftp": {reason: "sftp opens an interactive session", suggestion: "Use `scp` for scripted transfers."},
|
|
// Debuggers.
|
|
"gdb": {reason: "gdb opens an interactive debugger prompt", suggestion: "Use `gdb -batch -ex '<cmd>'` for scripted debugging."},
|
|
"lldb": {reason: "lldb opens an interactive debugger prompt", suggestion: "Use `lldb --batch -o '<cmd>'` for scripted debugging."},
|
|
// Fuzzy finders / selectors.
|
|
"fzf": {reason: "fzf is an interactive fuzzy finder", suggestion: "Use `grep`/`rg` to filter non-interactively."},
|
|
"peco": {reason: "peco is an interactive selector", suggestion: "Use `grep`/`rg` to filter non-interactively."},
|
|
// Windows-only interactive launchers.
|
|
"notepad": {reason: "notepad opens a GUI editor", suggestion: "Use the edit_file/write_file tools instead.", windowsOnly: true},
|
|
}
|
|
|
|
// replPrograms only hang when no script/expression argument is provided. The
|
|
// listed flags switch them into non-interactive mode and should suppress the
|
|
// guard.
|
|
var nonInteractiveREPLFlags = map[string][]string{
|
|
"python": {"-c", "-m"},
|
|
"python3": {"-c", "-m"},
|
|
"node": {"-e", "--eval", "-p", "--print", "-v", "--check", "-h"},
|
|
"ruby": {"-e"},
|
|
"php": {"-r", "-f"},
|
|
"psql": {"-c", "--command", "-f", "--file", "-l", "--list"},
|
|
"mysql": {"-e", "--execute"},
|
|
"mongo": {"--eval", "-f", "--file"},
|
|
"mongosh": {"--eval", "-f", "--file"},
|
|
}
|
|
|
|
// infoExitFlags make ANY repl/interactive program print a message and exit
|
|
// instead of opening a prompt. `--version` and `--help` are unambiguous across
|
|
// these programs (unlike, say, `-v`, which is "version" for node but "verbose"
|
|
// for mysql), so they universally suppress the interactive guard — without them
|
|
// a harmless `node --version` / `python3 --version` was wrongly blocked.
|
|
var infoExitFlags = map[string]bool{
|
|
"--version": true,
|
|
"--help": true,
|
|
}
|
|
|
|
// interactiveSegments are multi-word interactive invocations. The detector
|
|
// 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: "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 <base>`) 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 <base>`)."},
|
|
{match: "git add -i", command: "git add -i", reason: "interactive add opens a selection prompt", suggestion: "Stage paths explicitly: `git add <path>`."},
|
|
{match: "git add -p", command: "git add -p", reason: "interactive patch staging opens a prompt", suggestion: "Stage paths explicitly: `git add <path>`."},
|
|
{match: "git commit -p", command: "git commit -p", reason: "interactive patch commit opens a prompt", suggestion: "Stage with `git add <path>` then `git commit -m`."},
|
|
{match: "tail -f", command: "tail -f", reason: "tail -f follows a file forever", suggestion: "Use `tail -n N <file>` for a bounded read."},
|
|
{match: "tail --follow", command: "tail -f", reason: "tail --follow follows a file forever", suggestion: "Use `tail -n N <file>` for a bounded read."},
|
|
{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`."},
|
|
{match: "docker attach", command: "docker attach", reason: "docker attach joins an interactive container session", suggestion: "Use `docker exec <id> <command>` for one-shot execution."},
|
|
}
|
|
|
|
// DetectInteractiveCommand inspects a shell command for interactive programs
|
|
// that would block a non-interactive agent. goos selects platform-specific
|
|
// rules (pass "" to use the host runtime.GOOS).
|
|
func DetectInteractiveCommand(command string, goos string) InteractiveCommandResult {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" {
|
|
return InteractiveCommandResult{}
|
|
}
|
|
if goos == "" {
|
|
goos = runtime.GOOS
|
|
}
|
|
|
|
normalized := normalizeWhitespace(command)
|
|
|
|
// Multi-word interactive invocations (flags/subcommands) take priority so
|
|
// the more specific message wins. Match only at a real command boundary —
|
|
// the start of a shell segment (after leading env-assignments and wrapper
|
|
// prefixes) — so the segment text appearing inside a quoted argument (e.g.
|
|
// `echo "git rebase -i ..."`) is NOT a false positive.
|
|
for _, segment := range splitShellSegments(normalized) {
|
|
body := strings.ToLower(commandBody(strings.Fields(segment)))
|
|
for _, seg := range interactiveSegments {
|
|
if body == seg.match || strings.HasPrefix(body, seg.match+" ") {
|
|
return InteractiveCommandResult{
|
|
Interactive: true,
|
|
Command: seg.command,
|
|
Reason: seg.reason,
|
|
Suggestion: seg.suggestion,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inspect each shell segment (split on &&, ||, ;, |) so an interactive
|
|
// program hidden behind an operator is still caught.
|
|
for _, segment := range splitShellSegments(normalized) {
|
|
fields := strings.Fields(segment)
|
|
first := firstProgram(fields)
|
|
if first == "" {
|
|
continue
|
|
}
|
|
// `sh -c <payload>` / `bash -c <payload>` runs the payload as a fresh
|
|
// command; recurse into it so an interactive program inside the payload
|
|
// is detected (e.g. `sh -c 'vim x'`).
|
|
if payload := shellDashCPayload(first, fields); payload != "" {
|
|
if inner := DetectInteractiveCommand(payload, goos); inner.Interactive {
|
|
return inner
|
|
}
|
|
continue
|
|
}
|
|
program, ok := interactivePrograms[first]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if program.windowsOnly && goos != "windows" {
|
|
continue
|
|
}
|
|
if hasNonInteractiveFlag(first, fields) {
|
|
continue
|
|
}
|
|
return InteractiveCommandResult{
|
|
Interactive: true,
|
|
Command: first,
|
|
Reason: program.reason,
|
|
Suggestion: program.suggestion,
|
|
}
|
|
}
|
|
|
|
return InteractiveCommandResult{}
|
|
}
|
|
|
|
// wrapperPrograms are launcher prefixes that precede the real program. After
|
|
// one of these we keep scanning for the actual executable.
|
|
var wrapperPrograms = map[string]bool{
|
|
"sudo": true, "command": true, "env": true, "nohup": true, "time": true,
|
|
"exec": true, "doas": true, "nice": true, "timeout": true, "stdbuf": true,
|
|
"setsid": true, "ionice": true, "xargs": true,
|
|
}
|
|
|
|
// wrapperValueOptionsByProg lists, PER wrapper program, the options that consume
|
|
// the FOLLOWING token as their value (both short `-u` and long `--user` spellings,
|
|
// e.g. `sudo -u root` / `sudo --user root` / `timeout -s KILL`). It is keyed by
|
|
// wrapper because the same flag means different things to different launchers —
|
|
// `sudo -n` is valueless (non-interactive) while `nice -n` takes an adjustment —
|
|
// so a single global set would wrongly swallow the real program after a valueless
|
|
// flag (e.g. skip `rm` in `sudo -n rm -rf`). The `--flag=value` spelling is a
|
|
// single token and needs no entry; only the space-separated form consumes a value.
|
|
var wrapperValueOptionsByProg = map[string]map[string]bool{
|
|
"sudo": {"-u": true, "--user": true, "-g": true, "--group": true, "-p": true, "--prompt": true, "-C": true, "--close-from": true, "-r": true, "--role": true, "-T": true, "--command-timeout": true, "-U": true, "--other-user": true, "-h": true, "--host": true, "-D": true, "--chdir": true, "-R": true, "--chroot": true},
|
|
"doas": {"-u": true, "-C": true},
|
|
"env": {"-u": true, "--unset": true, "-S": true, "--split-string": true, "-C": true, "--chdir": true},
|
|
"timeout": {"-s": true, "--signal": true, "-k": true, "--kill-after": true},
|
|
"nice": {"-n": true, "--adjustment": true},
|
|
"ionice": {"-c": true, "--class": true, "-n": true, "--classdata": true, "-p": true, "--pid": true},
|
|
"stdbuf": {"-i": true, "--input": true, "-o": true, "--output": true, "-e": true, "--error": true},
|
|
"xargs": {"-a": true, "--arg-file": true, "-d": true, "--delimiter": true, "-E": true, "-I": true, "--replace": true, "-L": true, "--max-lines": true, "-n": true, "--max-args": true, "-P": true, "--max-procs": true, "-s": true, "--max-chars": true},
|
|
}
|
|
|
|
// wrapperConsumesValue reports whether option is a value-consuming flag of the
|
|
// active wrapper. With no active wrapper (or an unknown one, or a flag not listed
|
|
// for it) it returns false, so token scanning never skips a token that might be
|
|
// the real program. A `--flag=value` token carries its own value, so it never
|
|
// consumes the next token.
|
|
func wrapperConsumesValue(wrapper, option string) bool {
|
|
if strings.Contains(option, "=") {
|
|
return false
|
|
}
|
|
opts, ok := wrapperValueOptionsByProg[wrapper]
|
|
return ok && opts[option]
|
|
}
|
|
|
|
// firstProgram returns the first executable name in a segment, skipping leading
|
|
// environment-variable assignments (FOO=bar cmd), wrapper prefixes (sudo, env,
|
|
// nice, timeout, stdbuf, setsid, ionice, xargs, ...), and the option tokens that
|
|
// belong to those wrappers (e.g. `sudo -u root`, `env -i`, `timeout 5`).
|
|
func firstProgram(fields []string) string {
|
|
wrapper := ""
|
|
for index := 0; index < len(fields); index++ {
|
|
field := fields[index]
|
|
if strings.Contains(field, "=") && !strings.HasPrefix(field, "=") {
|
|
// Environment assignment prefix; keep scanning.
|
|
continue
|
|
}
|
|
// An option token (or a bare numeric argument such as `timeout 5`)
|
|
// belongs to a preceding wrapper, not the program; skip it, and also
|
|
// skip the value of an option that the active wrapper consumes.
|
|
if strings.HasPrefix(field, "-") {
|
|
if wrapperConsumesValue(wrapper, field) && index+1 < len(fields) {
|
|
index++
|
|
}
|
|
continue
|
|
}
|
|
if isNumericToken(field) {
|
|
continue
|
|
}
|
|
token := normalizeProgramToken(field)
|
|
if wrapperPrograms[token] {
|
|
// Wrapper prefix; the real program follows. Track it so its options'
|
|
// value-consumption is interpreted in its own context.
|
|
wrapper = token
|
|
continue
|
|
}
|
|
return token
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// commandBody returns the segment's command portion with leading
|
|
// environment-variable assignments (FOO=bar) and wrapper prefixes (sudo, env,
|
|
// nice, timeout, ...) and their consumed option values removed, joined back
|
|
// into a string. It lets interactive-segment matching anchor on the real
|
|
// command boundary (e.g. `sudo git rebase -i` -> "git rebase -i") instead of
|
|
// matching the segment text anywhere as a raw substring.
|
|
func commandBody(fields []string) string {
|
|
wrapper := ""
|
|
for index := 0; index < len(fields); index++ {
|
|
field := fields[index]
|
|
if strings.Contains(field, "=") && !strings.HasPrefix(field, "=") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(field, "-") {
|
|
if wrapperConsumesValue(wrapper, field) && index+1 < len(fields) {
|
|
index++
|
|
}
|
|
continue
|
|
}
|
|
if isNumericToken(field) {
|
|
continue
|
|
}
|
|
if token := normalizeProgramToken(field); wrapperPrograms[token] {
|
|
wrapper = token
|
|
continue
|
|
}
|
|
// First real command token: the body starts here.
|
|
return strings.Join(fields[index:], " ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isNumericToken reports whether a token is purely digits (e.g. the duration
|
|
// argument of `timeout 5`), so wrapper-argument scanning can skip it.
|
|
func isNumericToken(field string) bool {
|
|
if field == "" {
|
|
return false
|
|
}
|
|
for _, r := range field {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// shellDashCPayload returns the command string passed to `sh -c`/`bash -c`
|
|
// (and other POSIX shells) so the caller can recurse into it, or "" when the
|
|
// segment is not a `<shell> -c <payload>` invocation. The payload is returned
|
|
// with one layer of surrounding quotes stripped.
|
|
func shellDashCPayload(program string, fields []string) string {
|
|
switch program {
|
|
case "sh", "bash", "zsh", "ksh", "dash":
|
|
default:
|
|
return ""
|
|
}
|
|
start := programIndex(program, fields)
|
|
if start < 0 {
|
|
return ""
|
|
}
|
|
args := fields[start+1:]
|
|
for i, arg := range args {
|
|
if arg == "-c" || arg == "--command" {
|
|
if i+1 < len(args) {
|
|
return strings.Join(args[i+1:], " ")
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// normalizeProgramToken reduces a raw command token to a bare, lowercased program
|
|
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
|
|
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
|
|
// leading command-substitution markers, removes any directory prefix (so
|
|
// /usr/bin/vim and C:\tools\vim.exe match "vim"), and lowercases. This closes
|
|
// path/quote/substitution evasions of the detector.
|
|
func normalizeProgramToken(field string) string {
|
|
token := strings.TrimSpace(field)
|
|
token = strings.TrimLeft(token, "$(")
|
|
token = strings.TrimRight(token, ")")
|
|
// Strip shell quoting/escaping characters (", ', `, \) wherever they appear
|
|
// in the token — surrounding, embedded, or as a mid-word escape — so
|
|
// "vim", v"i"m, 'v'im, and vi\m all collapse to the program name. This is
|
|
// done BEFORE the directory-prefix trim so an escape can't masquerade as a
|
|
// path separator (e.g. vi\m must become vim, not m).
|
|
token = stripChars(token, "\"'`\\")
|
|
// Strip a directory prefix so /usr/bin/vim reduces to the basename. (A
|
|
// Windows-style backslash path separator is already removed above, so only
|
|
// the POSIX separator remains to split on.)
|
|
if i := strings.LastIndex(token, "/"); i >= 0 {
|
|
token = token[i+1:]
|
|
}
|
|
return strings.ToLower(token)
|
|
}
|
|
|
|
// stripChars returns s with every rune in cutset removed.
|
|
func stripChars(s, cutset string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
if strings.ContainsRune(cutset, r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
}
|
|
|
|
// hasNonInteractiveFlag reports whether a REPL-style program was invoked in a
|
|
// non-interactive way (an inline expression/script flag or a positional script
|
|
// argument), in which case it will not hang.
|
|
func hasNonInteractiveFlag(program string, fields []string) bool {
|
|
flags, isREPL := nonInteractiveREPLFlags[program]
|
|
if !isREPL {
|
|
// SSH and friends are interactive only with no trailing command. If
|
|
// there is an argument that is not an option, treat it as a remote
|
|
// command/host+command and let it through for ssh-like programs.
|
|
return hasTrailingCommand(program, fields)
|
|
}
|
|
// Find the program's own index, then inspect the args after it.
|
|
start := programIndex(program, fields)
|
|
if start < 0 {
|
|
return false
|
|
}
|
|
for _, arg := range fields[start+1:] {
|
|
// Universal print-and-exit flags (`--version`/`--help`) make the program
|
|
// print and quit rather than open a prompt, for every repl program.
|
|
base := arg
|
|
if i := strings.IndexByte(base, '='); i >= 0 {
|
|
base = base[:i]
|
|
}
|
|
if infoExitFlags[base] {
|
|
return true
|
|
}
|
|
for _, flag := range flags {
|
|
if arg == flag || strings.HasPrefix(arg, flag+"=") {
|
|
return true
|
|
}
|
|
}
|
|
// A positional (non-flag) argument means a script path was supplied.
|
|
if !strings.HasPrefix(arg, "-") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasTrailingCommand handles ssh/telnet/db clients: presence of a trailing
|
|
// non-option argument (beyond the host) implies a one-shot command rather than
|
|
// an interactive session.
|
|
func hasTrailingCommand(program string, fields []string) bool {
|
|
start := programIndex(program, fields)
|
|
if start < 0 {
|
|
return false
|
|
}
|
|
args := fields[start+1:]
|
|
switch program {
|
|
case "ssh":
|
|
// ssh <host> <command...>: a host plus at least one more token.
|
|
positional := 0
|
|
for _, arg := range args {
|
|
if !strings.HasPrefix(arg, "-") {
|
|
positional++
|
|
}
|
|
}
|
|
return positional >= 2
|
|
case "sqlite3":
|
|
// sqlite3 <db> <sql>: a db plus an SQL argument.
|
|
positional := 0
|
|
for _, arg := range args {
|
|
if !strings.HasPrefix(arg, "-") {
|
|
positional++
|
|
}
|
|
}
|
|
return positional >= 2
|
|
case "redis-cli":
|
|
// redis-cli <command ...>: any positional command token.
|
|
for _, arg := range args {
|
|
if !strings.HasPrefix(arg, "-") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func programIndex(program string, fields []string) int {
|
|
for index, field := range fields {
|
|
// Normalize each field the SAME way firstProgram does (basename + strip
|
|
// quotes/escapes + lowercase) so a full-path invocation like
|
|
// /usr/bin/python or /bin/bash matches the normalized program name —
|
|
// otherwise hasNonInteractiveFlag / shellDashCPayload can't locate the
|
|
// program and mis-classify (false positives and missed detections).
|
|
if normalizeProgramToken(field) == program {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// splitShellSegments splits a command on the common shell operators (&&, ||, ;,
|
|
// |) and command-substitution boundaries ($(...), `...`) so each pipeline/list
|
|
// element — and any interactive program hidden inside a substitution (e.g.
|
|
// `echo $(vim x)`) — can be inspected independently.
|
|
//
|
|
// It is quote-aware, which the previous strings.Replacer was not: an operator
|
|
// inside quotes is a literal, not a separator, so `git commit -m "use top | less"`
|
|
// and `echo "a; vim b"` no longer split mid-argument and falsely flag less/vim.
|
|
// The quoting rules mirror the shell:
|
|
// - single quotes make everything literal (no operator OR substitution inside);
|
|
// - double quotes keep $(...)/`...` substitution active but make |, ;, &&
|
|
// literal;
|
|
// - unquoted text splits on everything.
|
|
//
|
|
// Real, unquoted operators still split, so a genuinely interactive command behind
|
|
// a separator is still caught (no new false negatives).
|
|
func splitShellSegments(command string) []string {
|
|
segments := make([]string, 0)
|
|
var current strings.Builder
|
|
flush := func() {
|
|
if seg := strings.TrimSpace(current.String()); seg != "" {
|
|
segments = append(segments, seg)
|
|
}
|
|
current.Reset()
|
|
}
|
|
|
|
inSingle := false
|
|
inDouble := false
|
|
// substStack saves the quoting state entering each command substitution (`$(`
|
|
// or a backtick) so the substitution runs in a fresh quoting context (POSIX)
|
|
// and the outer quoting is restored when it closes. backtick marks which
|
|
// delimiter opened the frame so `)` only closes `$(` frames and a backtick only
|
|
// closes a backtick frame.
|
|
type substFrame struct {
|
|
inSingle, inDouble bool
|
|
backtick bool
|
|
}
|
|
var substStack []substFrame
|
|
runes := []rune(command)
|
|
for i := 0; i < len(runes); i++ {
|
|
c := runes[i]
|
|
|
|
// Inside single quotes everything is literal until the closing quote.
|
|
if inSingle {
|
|
current.WriteRune(c)
|
|
if c == '\'' {
|
|
inSingle = false
|
|
}
|
|
continue
|
|
}
|
|
// Outside single quotes a backslash escapes the next rune (the shell treats
|
|
// \X as a literal X), so an escaped quote or operator must not toggle quoting
|
|
// or manufacture a segment boundary — e.g. echo foo\|less or "a \" b".
|
|
if c == '\\' && i+1 < len(runes) {
|
|
current.WriteRune(c)
|
|
current.WriteRune(runes[i+1])
|
|
i++
|
|
continue
|
|
}
|
|
if c == '\'' && !inDouble {
|
|
inSingle = true
|
|
current.WriteRune(c)
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inDouble = !inDouble
|
|
current.WriteRune(c)
|
|
continue
|
|
}
|
|
|
|
// Command substitution boundaries. `$(` opens a substitution in a fresh
|
|
// quoting context; its matching `)` closes it and restores the outer
|
|
// quoting. A `)` is only a boundary when it actually closes an active,
|
|
// UNQUOTED substitution — a `)` inside quotes (e.g. echo $(printf "a)
|
|
// less") or a bare "a) less") is literal and must not split.
|
|
switch {
|
|
case c == '`':
|
|
// A backtick opens a substitution, or closes the active one if the top
|
|
// frame is itself a backtick. Saving/restoring quote state means inner
|
|
// separators in `echo "`a | less`"` are seen instead of staying hidden
|
|
// by the outer double quotes.
|
|
if n := len(substStack); n > 0 && substStack[n-1].backtick {
|
|
prev := substStack[n-1]
|
|
substStack = substStack[:n-1]
|
|
inSingle, inDouble = prev.inSingle, prev.inDouble
|
|
flush()
|
|
continue
|
|
}
|
|
flush()
|
|
substStack = append(substStack, substFrame{inSingle, inDouble, true})
|
|
inSingle, inDouble = false, false
|
|
continue
|
|
case c == '$' && i+1 < len(runes) && runes[i+1] == '(':
|
|
flush()
|
|
substStack = append(substStack, substFrame{inSingle, inDouble, false})
|
|
inSingle, inDouble = false, false
|
|
i++ // consume the '('
|
|
continue
|
|
case c == ')' && len(substStack) > 0 && !substStack[len(substStack)-1].backtick && !inSingle && !inDouble:
|
|
prev := substStack[len(substStack)-1]
|
|
substStack = substStack[:len(substStack)-1]
|
|
inSingle, inDouble = prev.inSingle, prev.inDouble
|
|
flush()
|
|
continue
|
|
}
|
|
|
|
// Control operators are literal inside double quotes; only split on them
|
|
// when fully unquoted. Match the original operator set exactly: ;, | (which
|
|
// also covers ||), and && — never a lone & (background).
|
|
if !inDouble {
|
|
switch {
|
|
case c == ';' || c == '|':
|
|
flush()
|
|
continue
|
|
case c == '&' && i+1 < len(runes) && runes[i+1] == '&':
|
|
flush()
|
|
i++
|
|
continue
|
|
}
|
|
}
|
|
|
|
current.WriteRune(c)
|
|
}
|
|
flush()
|
|
return segments
|
|
}
|
|
|
|
func normalizeWhitespace(value string) string {
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|