Files
zero/internal/sandbox/analyzer_test.go
Anandan 518d2e53ba fix(windows): use PowerShell for command execution (#804)
* fix(windows): prefer PowerShell for command execution

Detect PowerShell 7 or Windows PowerShell before falling back to cmd.exe, and share the selected host shell across agent tools and the interactive shell escape.

Configure UTF-8 output, preserve restricted-sandbox MSYS safeguards, and add Windows coverage for pipelines, workspace writes, and interactive stdin sessions.

* test(windows): align shell coverage with PowerShell

Make Windows command fixtures and guidance assertions follow the selected host shell, preserve cmd fallback coverage, and use a genuine MSYS executable when PowerShell aliases are available.

Remove the unsupported non-TTY PowerShell stdin assertion; Windows interactive stdin requires a separate PTY transport.

* fix(sandbox): classify Windows executable suffixes

Normalize .exe, .cmd, .bat, and .com command names before safety and network lookup so PowerShell invocations such as curl.cmd receive the same network permission handling as curl.

Add analyzer and engine-level regressions for the Windows PowerShell command shape observed in CI.

* fix(sandbox): normalize absolute Windows command paths

Extract drive-letter and UNC basenames before removing shell escape characters so fully qualified Windows executables continue matching canonical safety and network classifications.

Cover both drive and UNC curl paths with analyzer regressions.

* fix(sandbox): normalize relative Windows paths

Recognize drive-relative, explicit relative, and suffixed backslash executable paths before generic shell escape removal so Windows commands continue to match canonical sandbox risk entries.

* fix(windows): harden PowerShell execution

* fix(sandbox): classify PowerShell removal aliases
2026-07-27 13:21:47 +05:30

112 lines
7.4 KiB
Go

package sandbox
import "testing"
func TestAnalyzeCommand(t *testing.T) {
cases := []struct {
name string
script string
interactive bool
destructive bool
network bool
tooComplex bool
}{
{name: "editor", script: "vim foo.txt", interactive: true},
{name: "pager in pipe", script: "cat log | less", interactive: true},
{name: "interactive name only inside quotes", script: `echo "vim is a great editor"`, interactive: false},
{name: "printf quoted arg", script: `printf 'open with vim\n'`, interactive: false},
{name: "repl suppressed by -e", script: `node -e "require('repl').start()"`, interactive: false},
{name: "repl suppressed by script", script: "python3 app.py", interactive: false},
{name: "bare repl", script: "python3", interactive: true},
{name: "rm recursive force", script: "rm -rf /tmp/x", destructive: true},
{name: "rm bundled flags reversed", script: "rm -fr ./build", destructive: true},
{name: "rm without force", script: "rm file.txt", destructive: false},
{name: "rm inside quoted arg", script: `git commit -m "rm -rf /"`, destructive: false},
{name: "rm end-of-options literal filename", script: "rm -- -rf", destructive: false},
{name: "dd", script: "dd if=/dev/zero of=/dev/disk2", destructive: true},
{name: "find delete", script: "find . -type f -delete", destructive: true},
// Wrappers are unwrapped to the real payload, not classified on the launcher.
{name: "sudo wraps rm -rf", script: "sudo rm -rf /tmp/x", destructive: true},
{name: "env wraps curl", script: "env curl https://x.test", network: true},
{name: "bash -c wraps editor", script: `bash -c 'vim file'`, interactive: true},
{name: "sudo wraps bare repl", script: "sudo python3", interactive: true},
// A valueless wrapper flag must not swallow the real payload command.
{name: "sudo -n keeps rm payload", script: "sudo -n rm -rf /tmp/x", destructive: true},
{name: "sudo -n keeps curl payload", script: "sudo -n curl https://x.test", network: true},
{name: "sudo -u consumes its value", script: "sudo -u root vim file", interactive: true},
// Long wrapper flags consume a separate value too (space and = forms).
{name: "sudo --user space value", script: "sudo --user root vim file", interactive: true},
{name: "sudo --user= joined value", script: "sudo --user=root vim file", interactive: true},
{name: "env --unset then curl", script: "env --unset FOO curl https://x.test", network: true},
// A dynamic ($x) wrapper arg must not hide the literal payload that follows.
{name: "env dynamic flag then curl", script: `env "$opts" curl https://x.test`, network: true},
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
{name: "curl", script: "curl https://example.com", network: true},
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
{name: "npm install", script: "npm install", network: true},
{name: "npm ci", script: "npm ci", network: true},
{name: "npm create", script: "npm create vite@latest .", network: true},
{name: "npm registry query", script: "npm view typescript version --fetch-retries=0", network: true},
{name: "npm metadata search", script: "npm search typescript", network: true},
{name: "npm offline install", script: "npm install --offline", network: false},
{name: "npm version is offline", script: "npm --version", network: false},
{name: "npm start", script: "npm start", network: true},
{name: "npm run dev", script: "npm run dev", network: true},
{name: "npx http server", script: "npx http-server public -p 8080 -a 127.0.0.1", network: true},
{name: "direct http server", script: "http-server public -p 8080 -a 127.0.0.1", network: true},
{name: "direct vite", script: "vite --host 127.0.0.1", network: true},
{name: "next dev", script: "next dev", network: true},
{name: "git clone", script: "git clone https://example.com/repo.git", network: true},
{name: "git fetch", script: "git fetch origin", network: true},
{name: "git status is offline", script: "git status", network: false},
{name: "gh release download", script: "gh release download v1.0.0", network: true},
{name: "no network", script: "ls -la && echo done", network: false},
{name: "process pattern is not network", script: `pkill -f "python3 -m http.server 8000"`, network: false},
{name: "process listing is not special-cased", script: "ps aux", network: false},
{name: "unparseable", script: `'unterminated quote`, tooComplex: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := AnalyzeCommand(tc.script)
if got.Interactive != tc.interactive || got.Destructive != tc.destructive ||
got.Network != tc.network || got.TooComplex != tc.tooComplex {
t.Fatalf("AnalyzeCommand(%q) = %#v, want interactive=%v destructive=%v network=%v tooComplex=%v",
tc.script, got, tc.interactive, tc.destructive, tc.network, tc.tooComplex)
}
})
}
}
func TestAnalyzeCommandEmptyIsClean(t *testing.T) {
if got := AnalyzeCommand(" "); got.Interactive || got.Destructive || got.Network || got.TooComplex {
t.Fatalf("empty script should be clean, got %#v", got)
}
}