mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c4d65c88 | ||
|
|
bdade37c6c | ||
|
|
729d5ee96c | ||
|
|
9994ad49bb | ||
|
|
2a0229e729 | ||
|
|
f51de6dfc6 | ||
|
|
fcc6c72c34 |
No files matched your search
+23
-14
@@ -2404,7 +2404,11 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
command := "PATH=.:$PATH curl https://example.com"
|
command := "PATH=.:$PATH curl https://example.com"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
if windowsTestUsesPowerShell() {
|
||||||
|
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
|
||||||
|
} else {
|
||||||
|
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||||
|
}
|
||||||
fakeCurl := filepath.Join(root, "curl.cmd")
|
fakeCurl := filepath.Join(root, "curl.cmd")
|
||||||
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
|
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -2525,13 +2529,17 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
command := "echo rm -rf /"
|
||||||
|
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
|
||||||
|
command = `Write-Output 'rm -rf /'`
|
||||||
|
}
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||||
provider := &mockProvider{
|
provider := &mockProvider{
|
||||||
turns: [][]zeroruntime.StreamEvent{
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
{
|
{
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||||
{Type: zeroruntime.StreamEventDone},
|
{Type: zeroruntime.StreamEventDone},
|
||||||
},
|
},
|
||||||
@@ -2627,6 +2635,12 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func windowsTestUsesPowerShell() bool {
|
||||||
|
executable, _ := tools.HostShellCommand("")
|
||||||
|
name := strings.ToLower(filepath.Base(executable))
|
||||||
|
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
|
||||||
|
}
|
||||||
|
|
||||||
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
||||||
for _, decision := range decisions {
|
for _, decision := range decisions {
|
||||||
if decision == want {
|
if decision == want {
|
||||||
@@ -3160,22 +3174,17 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
|||||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
|
wants := []string{"workdir/cwd", "MSYS binaries"}
|
||||||
if !strings.Contains(prompt, want) {
|
if windowsTestUsesPowerShell() {
|
||||||
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
|
||||||
}
|
} else {
|
||||||
|
wants = append(wants, "cmd.exe", "double quotes")
|
||||||
}
|
}
|
||||||
// The examples must themselves use the safe (double-quoted) form; a
|
for _, want := range wants {
|
||||||
// single-quoted example here would teach the model the exact syntax
|
|
||||||
// that fails under cmd.exe.
|
|
||||||
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
|
|
||||||
if !strings.Contains(prompt, want) {
|
if !strings.Contains(prompt, want) {
|
||||||
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
|
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
|
|
||||||
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
|
|
||||||
}
|
|
||||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
"github.com/Gitlawb/zero/internal/repomap"
|
"github.com/Gitlawb/zero/internal/repomap"
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,11 +311,7 @@ func workspaceContext(cwd string) string {
|
|||||||
b.WriteString("<environment>\n")
|
b.WriteString("<environment>\n")
|
||||||
b.WriteString("Working directory: " + cwd + "\n")
|
b.WriteString("Working directory: " + cwd + "\n")
|
||||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||||
if runtime.GOOS == "windows" {
|
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
||||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
|
||||||
}
|
|
||||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||||
b.WriteString("Git branch: " + branch + "\n")
|
b.WriteString("Git branch: " + branch + "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,22 @@ var destructivePrograms = map[string]bool{
|
|||||||
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
|
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var powerShellRemoveItemPrograms = map[string]bool{
|
||||||
|
"remove-item": true,
|
||||||
|
"ri": true,
|
||||||
|
"rd": true,
|
||||||
|
"rmdir": true,
|
||||||
|
"del": true,
|
||||||
|
"erase": true,
|
||||||
|
"rm": true,
|
||||||
|
}
|
||||||
|
|
||||||
// networkPrograms are commands that perform network egress/ingress.
|
// networkPrograms are commands that perform network egress/ingress.
|
||||||
var networkPrograms = map[string]bool{
|
var networkPrograms = map[string]bool{
|
||||||
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
|
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
|
||||||
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
|
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
|
||||||
"ftp": true,
|
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
|
||||||
|
"invoke-restmethod": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var localServerPrograms = map[string]bool{
|
var localServerPrograms = map[string]bool{
|
||||||
@@ -172,7 +183,10 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
|
|||||||
if commandUsesNetwork(prog, rest) {
|
if commandUsesNetwork(prog, rest) {
|
||||||
result.Network = true
|
result.Network = true
|
||||||
}
|
}
|
||||||
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
|
if destructivePrograms[prog] ||
|
||||||
|
(prog == "rm" && hasRecursiveForce(rest)) ||
|
||||||
|
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
|
||||||
|
(prog == "find" && hasFindDelete(rest)) {
|
||||||
result.Destructive = true
|
result.Destructive = true
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -479,6 +493,19 @@ func hasRecursiveForce(args []*syntax.Word) bool {
|
|||||||
return recursive && force
|
return recursive && force
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
|
||||||
|
recursive, force := false, false
|
||||||
|
for _, arg := range args {
|
||||||
|
switch strings.ToLower(wordText(arg)) {
|
||||||
|
case "-recurse", "-r":
|
||||||
|
recursive = true
|
||||||
|
case "-force":
|
||||||
|
force = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recursive && force
|
||||||
|
}
|
||||||
|
|
||||||
func hasFindDelete(args []*syntax.Word) bool {
|
func hasFindDelete(args []*syntax.Word) bool {
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if wordText(arg) == "-delete" {
|
if wordText(arg) == "-delete" {
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ func TestAnalyzeCommand(t *testing.T) {
|
|||||||
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: 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: "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: "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 http server", script: "python3 -m http.server 8000", network: true},
|
||||||
{name: "python pip install", script: "python3 -m pip install requests", network: true},
|
{name: "python pip install", script: "python3 -m pip install requests", network: true},
|
||||||
|
|||||||
@@ -74,6 +74,25 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
|
||||||
|
engine := NewEngine(EngineOptions{
|
||||||
|
WorkspaceRoot: t.TempDir(),
|
||||||
|
Policy: DefaultPolicy(),
|
||||||
|
})
|
||||||
|
decision := engine.Evaluate(context.Background(), Request{
|
||||||
|
ToolName: "bash",
|
||||||
|
SideEffect: SideEffectShell,
|
||||||
|
Permission: PermissionPrompt,
|
||||||
|
PermissionMode: PermissionModeAsk,
|
||||||
|
Args: map[string]any{
|
||||||
|
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
|
||||||
|
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
|
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
policy := DefaultPolicy()
|
policy := DefaultPolicy()
|
||||||
|
|||||||
@@ -266,6 +266,74 @@ func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWindowsRestrictedTokenPowerShell exercises the default Windows shell
|
||||||
|
// shape end to end under the write-restricted token. It covers PowerShell
|
||||||
|
// initialization, a native pipeline (which creates IPC objects), UTF-8 output,
|
||||||
|
// and workspace write enforcement.
|
||||||
|
func TestWindowsRestrictedTokenPowerShell(t *testing.T) {
|
||||||
|
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||||
|
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||||
|
}
|
||||||
|
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||||
|
powerShell := realSmokePowerShell(t)
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||||
|
profile := PermissionProfile{
|
||||||
|
FileSystem: FileSystemPolicy{
|
||||||
|
Kind: FileSystemRestricted,
|
||||||
|
ReadRoots: []string{root},
|
||||||
|
WriteRoots: []WritableRoot{{Root: root}},
|
||||||
|
IncludePlatformRoots: true,
|
||||||
|
AllowTemp: true,
|
||||||
|
},
|
||||||
|
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||||
|
}
|
||||||
|
config := WindowsSandboxCommandArgsOptions{
|
||||||
|
SandboxHome: sandboxHome,
|
||||||
|
CommandCWD: root,
|
||||||
|
WorkspaceRoots: []string{root},
|
||||||
|
PermissionProfile: profile,
|
||||||
|
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||||
|
}
|
||||||
|
|
||||||
|
insideMarker := filepath.Join(root, "powershell-ok.txt")
|
||||||
|
script := "$ErrorActionPreference='Stop'; " +
|
||||||
|
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " +
|
||||||
|
"$values = 1,2,3 | ForEach-Object { $_ * 2 }; " +
|
||||||
|
"[IO.File]::WriteAllText(" + powershellSingleQuote(insideMarker) + ", (($values -join ',') + ' ✓'))"
|
||||||
|
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||||
|
powerShell, "-NoLogo", "-NoProfile", "-Command", script,
|
||||||
|
}, 0)
|
||||||
|
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "2,4,6 ✓" {
|
||||||
|
t.Fatalf("PowerShell sandbox marker = %q, %v; want pipeline output", bytes, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outsideMarker := filepath.Join(outside, "powershell-denied.txt")
|
||||||
|
deniedScript := "$ErrorActionPreference='Stop'; " +
|
||||||
|
"[IO.File]::WriteAllText(" + powershellSingleQuote(outsideMarker) + ", 'leaked')"
|
||||||
|
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||||
|
powerShell, "-NoLogo", "-NoProfile", "-Command", deniedScript,
|
||||||
|
}, 1)
|
||||||
|
if _, err := os.Stat(outsideMarker); err == nil {
|
||||||
|
t.Fatal("sandboxed PowerShell wrote outside every granted root")
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stat outside PowerShell marker: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func realSmokePowerShell(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, candidate := range []string{"pwsh.exe", "pwsh", "powershell.exe", "powershell"} {
|
||||||
|
if path, err := exec.LookPath(candidate); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Skip("PowerShell is unavailable")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if path := os.Getenv(envKey); path != "" {
|
if path := os.Getenv(envKey); path != "" {
|
||||||
|
|||||||
@@ -430,25 +430,70 @@ func shellDashCPayload(program string, fields []string) string {
|
|||||||
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
|
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
|
||||||
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
|
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
|
||||||
// leading command-substitution markers, removes any directory prefix (so
|
// leading command-substitution markers, removes any directory prefix (so
|
||||||
// /usr/bin/vim and C:\tools\vim.exe match "vim"), and lowercases. This closes
|
// /usr/bin/vim and C:\tools\vim.exe match "vim"), removes Windows executable
|
||||||
// path/quote/substitution evasions of the detector.
|
// suffixes, and lowercases. This closes path/quote/substitution evasions of the
|
||||||
|
// detector and keeps curl.exe/npm.cmd equivalent to curl/npm for risk analysis.
|
||||||
func normalizeProgramToken(field string) string {
|
func normalizeProgramToken(field string) string {
|
||||||
token := strings.TrimSpace(field)
|
token := strings.TrimSpace(field)
|
||||||
token = strings.TrimLeft(token, "$(")
|
token = strings.TrimLeft(token, "$(")
|
||||||
token = strings.TrimRight(token, ")")
|
token = strings.TrimRight(token, ")")
|
||||||
// Strip shell quoting/escaping characters (", ', `, \) wherever they appear
|
// PowerShell commonly invokes drive-letter, UNC, and relative paths. Extract
|
||||||
// in the token — surrounding, embedded, or as a mid-word escape — so
|
// their basename while backslashes still carry path-separator meaning;
|
||||||
// "vim", v"i"m, 'v'im, and vi\m all collapse to the program name. This is
|
// removing them first would turn C:\tools\curl.exe into c:toolscurl.exe and
|
||||||
// done BEFORE the directory-prefix trim so an escape can't masquerade as a
|
// bypass the canonical curl risk entry.
|
||||||
// path separator (e.g. vi\m must become vim, not m).
|
pathToken := stripChars(token, "\"'`")
|
||||||
token = stripChars(token, "\"'`\\")
|
if windowsPathBasename, ok := windowsExecutablePathBasename(pathToken); ok {
|
||||||
// Strip a directory prefix so /usr/bin/vim reduces to the basename. (A
|
token = windowsPathBasename
|
||||||
// Windows-style backslash path separator is already removed above, so only
|
} else {
|
||||||
// the POSIX separator remains to split on.)
|
// Strip shell quoting/escaping characters (", ', `, \) wherever they
|
||||||
if i := strings.LastIndex(token, "/"); i >= 0 {
|
// appear in the token — surrounding, embedded, or as a mid-word escape
|
||||||
token = token[i+1:]
|
// — so "vim", v"i"m, 'v'im, and vi\m all collapse to the program name.
|
||||||
|
token = stripChars(token, "\"'`\\")
|
||||||
|
// Strip a directory prefix so /usr/bin/vim reduces to the basename.
|
||||||
|
if i := strings.LastIndex(token, "/"); i >= 0 {
|
||||||
|
token = token[i+1:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return strings.ToLower(token)
|
token = strings.ToLower(token)
|
||||||
|
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||||
|
if strings.HasSuffix(token, suffix) {
|
||||||
|
return strings.TrimSuffix(token, suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsExecutablePathBasename(token string) (string, bool) {
|
||||||
|
drivePath := len(token) >= 3 &&
|
||||||
|
((token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z')) &&
|
||||||
|
token[1] == ':'
|
||||||
|
uncPath := strings.HasPrefix(token, `\\`)
|
||||||
|
explicitRelativePath := strings.HasPrefix(token, `.\`) || strings.HasPrefix(token, `..\`)
|
||||||
|
backslashRelativePath := strings.ContainsRune(token, '\\') && hasWindowsExecutableSuffix(token)
|
||||||
|
if !drivePath && !uncPath && !explicitRelativePath && !backslashRelativePath {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
index := strings.LastIndexAny(token, `\/`)
|
||||||
|
if index >= 0 {
|
||||||
|
if index+1 >= len(token) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return token[index+1:], true
|
||||||
|
}
|
||||||
|
if drivePath && len(token) > 2 {
|
||||||
|
return token[2:], true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasWindowsExecutableSuffix(token string) bool {
|
||||||
|
token = strings.ToLower(token)
|
||||||
|
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||||
|
if strings.HasSuffix(token, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripChars returns s with every rune in cutset removed.
|
// stripChars returns s with every rune in cutset removed.
|
||||||
|
|||||||
+9
-27
@@ -106,7 +106,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
|||||||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||||||
// hard-blocked by the same check it was meant to escalate past.
|
// hard-blocked by the same check it was meant to escalate past.
|
||||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||||
return shellIssueBlockResult(*issue)
|
return shellIssueBlockResult(*issue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,23 +293,19 @@ func shellIssueBlockResult(issue shellIssue) Result {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
|
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
|
||||||
// commandText. On Windows, when the command is not wrapped by the sandbox
|
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
|
||||||
// engine (plan.Wrapped == false), it also overrides the child's raw command
|
// the fallback. Only cmd.exe needs the raw command-line override.
|
||||||
// line so commandText reaches cmd.exe unescaped; see
|
|
||||||
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
|
|
||||||
// gets the same treatment inside the sandboxed runner process itself
|
|
||||||
// (internal/sandbox/windows_process_windows.go), since that command line is
|
|
||||||
// built there, not here.
|
|
||||||
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
|
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
|
||||||
|
hostShell := detectShellRuntime(runtime.GOOS)
|
||||||
spec := zeroSandbox.CommandSpec{
|
spec := zeroSandbox.CommandSpec{
|
||||||
Name: shellExecutable(),
|
Name: hostShell.Executable,
|
||||||
Args: shellArguments(commandText),
|
Args: hostShell.arguments(commandText),
|
||||||
Dir: absoluteCwd,
|
Dir: absoluteCwd,
|
||||||
}
|
}
|
||||||
if engine != nil {
|
if engine != nil {
|
||||||
command, plan, err := engine.CommandContext(ctx, spec)
|
command, plan, err := engine.CommandContext(ctx, spec)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
|
||||||
}
|
}
|
||||||
return command, plan, err
|
return command, plan, err
|
||||||
}
|
}
|
||||||
@@ -333,7 +329,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
|
|||||||
}
|
}
|
||||||
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
|
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
|
||||||
command.Dir = spec.Dir
|
command.Dir = spec.Dir
|
||||||
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
|
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
|
||||||
return command, plan, nil
|
return command, plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,20 +381,6 @@ func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Resu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func shellExecutable() string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return "cmd.exe"
|
|
||||||
}
|
|
||||||
return "/bin/sh"
|
|
||||||
}
|
|
||||||
|
|
||||||
func shellArguments(command string) []string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return zeroSandbox.WindowsShellArgs(command)
|
|
||||||
}
|
|
||||||
return []string{"-c", command}
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandExitCode(err error) int {
|
func commandExitCode(err error) int {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -621,7 +603,7 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
|
|||||||
|
|
||||||
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||||
output := formatBashOutput(stdout, stderr, exitCode)
|
output := formatBashOutput(stdout, stderr, exitCode)
|
||||||
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
|
||||||
meta["shell_issue"] = issue.Kind
|
meta["shell_issue"] = issue.Kind
|
||||||
output = appendShellIssueHint(output, *issue)
|
output = appendShellIssueHint(output, *issue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,6 @@ func hardenProcessLifetime(command *exec.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
|
// applyWindowsShellCommandLine is a no-op outside Windows.
|
||||||
// cmd.exe command-line-parsing quirk to work around here.
|
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
|
||||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
|
}
|
||||||
@@ -31,15 +31,12 @@ func hardenProcessLifetime(command *exec.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyWindowsShellCommandLine overrides command's raw child command line so
|
// applyWindowsShellCommandLine overrides the cmd.exe fallback's raw child
|
||||||
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
|
// command line so commandText reaches cmd.exe unescaped. PowerShell consumes
|
||||||
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
|
// ordinary argv quoting and must not take this path. Wrapped commands are
|
||||||
// is true: the sandbox engine then routes execution through a separate
|
// handled after unwrapping by the Windows sandbox runner.
|
||||||
// zero-windows-command-runner process, which builds its own child command
|
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
|
||||||
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
|
if wrapped || !cmdFallback {
|
||||||
// inheriting whatever this outer exec.Cmd is configured with.
|
|
||||||
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
|
|
||||||
if wrapped {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
|
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
|
||||||
|
|||||||
@@ -124,11 +124,15 @@ func TestBashToolDescribesHostShellSyntax(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
|
shell := detectShellRuntime(runtime.GOOS)
|
||||||
t.Fatalf("expected Windows cmd.exe and cwd guidance in bash description, got %q", description)
|
if shell.Kind == shellKindPowerShell {
|
||||||
}
|
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
|
||||||
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
|
if !strings.Contains(description, want) {
|
||||||
t.Fatalf("expected the double-quote metacharacter rule in bash description, got %q", description)
|
t.Fatalf("expected Windows PowerShell guidance %q in bash description, got %q", want, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
|
||||||
|
t.Fatalf("expected Windows cmd.exe fallback guidance in bash description, got %q", description)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -455,13 +459,14 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
|
|||||||
if result.Status != StatusError {
|
if result.Status != StatusError {
|
||||||
t.Fatalf("expected error status, got %s", result.Status)
|
t.Fatalf("expected error status, got %s", result.Status)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
|
wantExitCode := strconv.Itoa(helperFailureExitCode())
|
||||||
|
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: " + wantExitCode} {
|
||||||
if !strings.Contains(result.Output, want) {
|
if !strings.Contains(result.Output, want) {
|
||||||
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result.Meta["exit_code"] != "7" {
|
if result.Meta["exit_code"] != wantExitCode {
|
||||||
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
|
t.Fatalf("expected exit_code metadata %s, got %q", wantExitCode, result.Meta["exit_code"])
|
||||||
}
|
}
|
||||||
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
|
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
|
||||||
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
|
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
|
||||||
@@ -578,10 +583,16 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
registry := NewRegistry()
|
registry := NewRegistry()
|
||||||
registry.Register(NewScopedBashTool(root, nil))
|
registry.Register(NewScopedBashTool(root, nil))
|
||||||
|
msysCommand := "cat somefile.txt"
|
||||||
|
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
|
||||||
|
// cat is a native Get-Content alias in PowerShell. Use a name that
|
||||||
|
// still resolves to an incompatible Git-for-Windows MSYS executable.
|
||||||
|
msysCommand = "grep pattern somefile.txt"
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
|
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
|
||||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||||
"command": "cat somefile.txt",
|
"command": msysCommand,
|
||||||
}, RunOptions{
|
}, RunOptions{
|
||||||
PermissionGranted: true,
|
PermissionGranted: true,
|
||||||
Sandbox: newEngine(),
|
Sandbox: newEngine(),
|
||||||
@@ -594,7 +605,7 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
|
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
|
||||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||||
"command": "cat somefile.txt",
|
"command": msysCommand,
|
||||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||||
}, RunOptions{
|
}, RunOptions{
|
||||||
PermissionGranted: true,
|
PermissionGranted: true,
|
||||||
@@ -648,8 +659,9 @@ func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T)
|
|||||||
"command": command,
|
"command": command,
|
||||||
})
|
})
|
||||||
|
|
||||||
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
|
wantExitCode := strconv.Itoa(helperFailureExitCode())
|
||||||
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
|
if result.Status != StatusError || result.Meta["exit_code"] != wantExitCode {
|
||||||
|
t.Fatalf("expected the helper failure exit %s, got %s: %#v", wantExitCode, result.Status, result)
|
||||||
}
|
}
|
||||||
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
|
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
|
||||||
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
|
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
|
||||||
@@ -860,6 +872,9 @@ func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
|
|||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
|
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
|
||||||
}
|
}
|
||||||
|
if detectShellRuntime(runtime.GOOS).Kind != shellKindCmd {
|
||||||
|
t.Skip("cmd.exe fallback is not selected")
|
||||||
|
}
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
||||||
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
|
||||||
@@ -881,6 +896,10 @@ func helperCommand(name string) string {
|
|||||||
return executable + " --zero-bash-helper " + name
|
return executable + " --zero-bash-helper " + name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func helperFailureExitCode() int {
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
|
||||||
func shellQuote(value string) string {
|
func shellQuote(value string) string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
|||||||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||||||
// hard-blocked by the same check it was meant to escalate past.
|
// hard-blocked by the same check it was meant to escalate past.
|
||||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||||
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||||
return shellIssueBlockResult(*issue)
|
return shellIssueBlockResult(*issue)
|
||||||
}
|
}
|
||||||
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
|
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
|
||||||
@@ -459,7 +459,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
|
|||||||
}
|
}
|
||||||
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
|
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
|
||||||
if status == StatusError && input.exited && !input.interrupted {
|
if status == StatusError && input.exited && !input.interrupted {
|
||||||
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
|
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
|
||||||
meta["shell_issue"] = issue.Kind
|
meta["shell_issue"] = issue.Kind
|
||||||
body = appendShellIssueHint(body, *issue)
|
body = appendShellIssueHint(body, *issue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,11 +76,15 @@ func TestExecCommandToolDescribesHostShellSyntax(t *testing.T) {
|
|||||||
description := strings.ToLower(strings.Join(descriptionParts, " "))
|
description := strings.ToLower(strings.Join(descriptionParts, " "))
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
|
shell := detectShellRuntime(runtime.GOOS)
|
||||||
t.Fatalf("expected Windows cmd.exe and cwd guidance in exec_command description, got %q", description)
|
if shell.Kind == shellKindPowerShell {
|
||||||
}
|
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
|
||||||
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
|
if !strings.Contains(description, want) {
|
||||||
t.Fatalf("expected the double-quote metacharacter rule in exec_command description, got %q", description)
|
t.Fatalf("expected Windows PowerShell guidance %q in exec_command description, got %q", want, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
|
||||||
|
t.Fatalf("expected Windows cmd.exe fallback guidance in exec_command description, got %q", description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,9 +196,15 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
|
|||||||
Policy: sandbox.DefaultPolicy(),
|
Policy: sandbox.DefaultPolicy(),
|
||||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||||
})
|
})
|
||||||
|
msysCommand := "cat somefile.txt"
|
||||||
|
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
|
||||||
|
// cat is a native Get-Content alias in PowerShell, so use an executable
|
||||||
|
// name that still exercises the MSYS guard.
|
||||||
|
msysCommand = "grep pattern somefile.txt"
|
||||||
|
}
|
||||||
|
|
||||||
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
|
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
|
||||||
"cmd": "cat somefile.txt",
|
"cmd": msysCommand,
|
||||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||||
}, RunOptions{
|
}, RunOptions{
|
||||||
PermissionGranted: true,
|
PermissionGranted: true,
|
||||||
@@ -204,8 +214,8 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
|
|||||||
|
|
||||||
// Assert on the preflight block sentinel (exit_code "-1", set only by
|
// Assert on the preflight block sentinel (exit_code "-1", set only by
|
||||||
// shellIssueBlockResult) rather than shell_issue: once the guard is
|
// shellIssueBlockResult) rather than shell_issue: once the guard is
|
||||||
// bypassed, "cat somefile.txt" actually runs, and its real,
|
// bypassed, the command actually runs, and its real, PATH-dependent output
|
||||||
// PATH-dependent output could otherwise trip the unrelated
|
// could otherwise trip the unrelated
|
||||||
// post-execution detectShellOutputIssue heuristic and make this
|
// post-execution detectShellOutputIssue heuristic and make this
|
||||||
// assertion flaky for reasons unrelated to the guard under test.
|
// assertion flaky for reasons unrelated to the guard under test.
|
||||||
if result.Meta["exit_code"] == "-1" {
|
if result.Meta["exit_code"] == "-1" {
|
||||||
@@ -284,8 +294,9 @@ func TestExecCommandApplicationFailureHasTypedOutcome(t *testing.T) {
|
|||||||
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
|
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
|
||||||
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
|
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
|
||||||
}
|
}
|
||||||
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != 7 {
|
wantExitCode := helperFailureExitCode()
|
||||||
t.Fatalf("execution exit = %#v, want code 7", result.ExecutionOutcome.Exit)
|
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != wantExitCode {
|
||||||
|
t.Fatalf("execution exit = %#v, want code %d", result.ExecutionOutcome.Exit, wantExitCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+301
-14
@@ -1,15 +1,33 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type shellKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
shellKindPOSIX shellKind = "posix"
|
||||||
|
shellKindPowerShell shellKind = "powershell"
|
||||||
|
shellKindCmd shellKind = "cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type shellRuntime struct {
|
type shellRuntime struct {
|
||||||
GOOS string
|
GOOS string
|
||||||
Executable string
|
Executable string
|
||||||
Syntax string
|
Syntax string
|
||||||
|
Kind shellKind
|
||||||
}
|
}
|
||||||
|
|
||||||
type shellIssue struct {
|
type shellIssue struct {
|
||||||
@@ -20,7 +38,18 @@ type shellIssue struct {
|
|||||||
|
|
||||||
const windowsMsysSandboxKind = "windows_msys_sandbox"
|
const windowsMsysSandboxKind = "windows_msys_sandbox"
|
||||||
|
|
||||||
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
|
const windowsMsysSandboxSuggestion = "MSYS/Cygwin executables and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer native PowerShell cmdlets or Zero tools (grep, read_file with offset/limit, list_directory, glob). If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
|
||||||
|
|
||||||
|
const windowsPowerShellUTF8Prefix = "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}\n"
|
||||||
|
|
||||||
|
const windowsPowerShellFailurePrefix = "$ErrorActionPreference = 'Stop'\n"
|
||||||
|
|
||||||
|
const windowsPowerShellExitSuffix = "\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\n"
|
||||||
|
|
||||||
|
var (
|
||||||
|
hostShellOnce sync.Once
|
||||||
|
hostShell shellRuntime
|
||||||
|
)
|
||||||
|
|
||||||
// windowsMsysProneNames is the single source of truth for POSIX coreutil and
|
// windowsMsysProneNames is the single source of truth for POSIX coreutil and
|
||||||
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
|
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
|
||||||
@@ -54,24 +83,139 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func detectShellRuntime(goos string) shellRuntime {
|
func detectShellRuntime(goos string) shellRuntime {
|
||||||
if goos == "windows" {
|
if goos != runtime.GOOS {
|
||||||
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe"}
|
return detectShellRuntimeWithLookup(goos, exec.LookPath, os.Getenv)
|
||||||
}
|
}
|
||||||
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh"}
|
hostShellOnce.Do(func() {
|
||||||
|
hostShell = detectShellRuntimeWithProbe(goos, exec.LookPath, os.Getenv, powerShellExecutableUsable)
|
||||||
|
})
|
||||||
|
return hostShell
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectShellRuntimeWithLookup(goos string, lookPath func(string) (string, error), getenv func(string) string) shellRuntime {
|
||||||
|
return detectShellRuntimeWithProbe(goos, lookPath, getenv, func(string) bool { return true })
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectShellRuntimeWithProbe(goos string, lookPath func(string) (string, error), getenv func(string) string, usable func(string) bool) shellRuntime {
|
||||||
|
if goos == "windows" {
|
||||||
|
for _, candidate := range windowsPowerShellCandidates(getenv) {
|
||||||
|
if path, err := lookPath(candidate); err == nil && strings.TrimSpace(path) != "" && usable(path) {
|
||||||
|
return shellRuntime{
|
||||||
|
GOOS: goos,
|
||||||
|
Executable: path,
|
||||||
|
Syntax: "PowerShell",
|
||||||
|
Kind: shellKindPowerShell,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
|
||||||
|
}
|
||||||
|
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
|
||||||
|
}
|
||||||
|
|
||||||
|
func powerShellExecutableUsable(path string) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
command := exec.CommandContext(ctx, path, "-NoLogo", "-NoProfile", "-Command", "exit 0")
|
||||||
|
return command.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsPowerShellCandidates(getenv func(string) string) []string {
|
||||||
|
candidates := []string{"pwsh.exe", "pwsh"}
|
||||||
|
if programFiles := strings.TrimSpace(getenv("ProgramFiles")); programFiles != "" {
|
||||||
|
candidates = append(candidates, filepath.Join(programFiles, "PowerShell", "7", "pwsh.exe"))
|
||||||
|
}
|
||||||
|
candidates = append(candidates, "powershell.exe", "powershell")
|
||||||
|
if systemRoot := strings.TrimSpace(getenv("SystemRoot")); systemRoot != "" {
|
||||||
|
candidates = append(candidates, filepath.Join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell shellRuntime) arguments(command string) []string {
|
||||||
|
switch shell.Kind {
|
||||||
|
case shellKindPowerShell:
|
||||||
|
script := windowsPowerShellUTF8Prefix + windowsPowerShellFailurePrefix + command + windowsPowerShellExitSuffix
|
||||||
|
return []string{"-NoLogo", "-NoProfile", "-Command", script}
|
||||||
|
case shellKindCmd:
|
||||||
|
return zeroSandbox.WindowsShellArgs(command)
|
||||||
|
default:
|
||||||
|
return []string{"-c", command}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostShellCommand returns the shell executable and argv used for a command on
|
||||||
|
// this host. Keeping this in one place makes agent tools and the user-typed TUI
|
||||||
|
// shell escape use the same Windows shell and fallback behavior.
|
||||||
|
func HostShellCommand(command string) (string, []string) {
|
||||||
|
shell := detectShellRuntime(runtime.GOOS)
|
||||||
|
return shell.Executable, shell.arguments(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHostShellCommandContext constructs a direct host-shell command. The
|
||||||
|
// cmd.exe fallback needs a Windows-specific raw command line, while PowerShell
|
||||||
|
// and POSIX shells use normal argv handling.
|
||||||
|
func NewHostShellCommandContext(ctx context.Context, commandText string) *exec.Cmd {
|
||||||
|
shell := detectShellRuntime(runtime.GOOS)
|
||||||
|
command := exec.CommandContext(ctx, shell.Executable, shell.arguments(commandText)...)
|
||||||
|
applyWindowsShellCommandLine(command, commandText, false, shell.Kind == shellKindCmd)
|
||||||
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
func shellGuidanceForGOOS(goos string) string {
|
func shellGuidanceForGOOS(goos string) string {
|
||||||
runtime := detectShellRuntime(goos)
|
return shellGuidanceForRuntime(detectShellRuntime(goos))
|
||||||
if goos == "windows" {
|
}
|
||||||
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
|
|
||||||
|
func shellGuidanceForRuntime(shell shellRuntime) string {
|
||||||
|
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
|
||||||
|
guidance := "Uses PowerShell syntax on Windows; prefer cwd/workdir over Set-Location when changing directories. Examples: Get-ChildItem -Force; Get-ChildItem -Recurse -Filter *.go; Get-ChildItem -Recurse | Select-String -Pattern 'TODO'; Get-Process | Where-Object { $_.ProcessName -like '*node*' }; $env:NAME='value'; @'\nprint('hello')\n'@ | python -. Do not invoke Git-for-Windows MSYS/Cygwin executables (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; prefer PowerShell cmdlets or native Zero tools."
|
||||||
|
return guidance + legacyPowerShellChainGuidance(shell)
|
||||||
}
|
}
|
||||||
guidance := "Uses " + runtime.Syntax + " syntax."
|
if shell.GOOS == "windows" {
|
||||||
if goos == "darwin" {
|
return "Uses " + shell.Syntax + " syntax on Windows because PowerShell was unavailable; prefer cwd/workdir over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
|
||||||
|
}
|
||||||
|
guidance := "Uses " + shell.Syntax + " syntax."
|
||||||
|
if shell.GOOS == "darwin" {
|
||||||
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
|
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
|
||||||
}
|
}
|
||||||
return guidance
|
return guidance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HostShellEnvironmentGuidance returns the concise, model-facing shell rule
|
||||||
|
// for the current host.
|
||||||
|
func HostShellEnvironmentGuidance() string {
|
||||||
|
return hostShellEnvironmentGuidanceForRuntime(detectShellRuntime(runtime.GOOS))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostShellEnvironmentGuidanceForRuntime(shell shellRuntime) string {
|
||||||
|
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
|
||||||
|
guidance := "Shell syntax: PowerShell for exec_command/bash tools. Use PowerShell cmdlets and pipelines (Get-ChildItem, Get-Content, Select-String, Select-Object), use $env:NAME='value' for environment variables, and prefer the workdir/cwd argument over Set-Location. Do not invoke Git-for-Windows MSYS binaries (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; use native PowerShell cmdlets or Zero tools instead, or sandbox_permissions require_escalated only when host-level execution is truly required."
|
||||||
|
return guidance + legacyPowerShellChainGuidance(shell)
|
||||||
|
}
|
||||||
|
if shell.GOOS == "windows" {
|
||||||
|
return "Shell syntax: Windows cmd.exe syntax for exec_command/bash tools because PowerShell is unavailable. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes. Do not invoke Git-for-Windows MSYS binaries inside the restricted sandbox; use native Zero tools instead. Prefer the workdir/cwd argument over cd."
|
||||||
|
}
|
||||||
|
return "Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories."
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyPowerShellChainGuidance(shell shellRuntime) string {
|
||||||
|
if !shell.isWindowsPowerShell() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " This host uses Windows PowerShell 5.1, which does not support && or ||. Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (shell shellRuntime) isWindowsPowerShell() bool {
|
||||||
|
if shell.Kind != shellKindPowerShell {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
executable := strings.TrimSpace(shell.Executable)
|
||||||
|
if index := strings.LastIndexAny(executable, `\/`); index >= 0 {
|
||||||
|
executable = executable[index+1:]
|
||||||
|
}
|
||||||
|
return strings.EqualFold(executable, "powershell") || strings.EqualFold(executable, "powershell.exe")
|
||||||
|
}
|
||||||
|
|
||||||
// MsysProneCommandName reports whether a bare command name commonly resolves to
|
// MsysProneCommandName reports whether a bare command name commonly resolves to
|
||||||
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
|
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
|
||||||
func MsysProneCommandName(name string) bool {
|
func MsysProneCommandName(name string) bool {
|
||||||
@@ -172,7 +316,15 @@ func msysProneCommandWord(word string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func detectShellCommandIssue(command string, goos string) *shellIssue {
|
func detectShellCommandIssue(command string, goos string) *shellIssue {
|
||||||
if goos != "windows" {
|
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
|
||||||
|
if goos == "windows" {
|
||||||
|
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
|
||||||
|
}
|
||||||
|
return detectShellCommandIssueForRuntime(command, shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shellIssue {
|
||||||
|
if shell.GOOS != "windows" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
trimmed := strings.TrimSpace(command)
|
trimmed := strings.TrimSpace(command)
|
||||||
@@ -184,7 +336,24 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
|
|||||||
// cat), so an escaped metachar can't stand in as a fake segment boundary
|
// cat), so an escaped metachar can't stand in as a fake segment boundary
|
||||||
// either.
|
// either.
|
||||||
unquoted := stripCmdCaretEscapes(stripDoubleQuotedSpans(trimmed))
|
unquoted := stripCmdCaretEscapes(stripDoubleQuotedSpans(trimmed))
|
||||||
|
if shell.Kind == shellKindPowerShell {
|
||||||
|
unquoted = stripPowerShellQuotedSpans(trimmed)
|
||||||
|
if shell.isWindowsPowerShell() && (strings.Contains(unquoted, "&&") || strings.Contains(unquoted, "||")) {
|
||||||
|
return &shellIssue{
|
||||||
|
Kind: "windows_powershell_version",
|
||||||
|
Message: "Command uses && or ||, but this host runs Windows PowerShell 5.1, which does not support those operators.",
|
||||||
|
Suggestion: "Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if windowsBashStyleCDPattern.MatchString(unquoted) {
|
if windowsBashStyleCDPattern.MatchString(unquoted) {
|
||||||
|
if shell.Kind == shellKindPowerShell {
|
||||||
|
return &shellIssue{
|
||||||
|
Kind: "windows_shell_syntax",
|
||||||
|
Message: "Command looks like POSIX/Bash syntax, but Zero runs PowerShell commands on this Windows host.",
|
||||||
|
Suggestion: "Use the cwd/workdir argument instead of cd and use native PowerShell syntax or Zero tools such as list_directory, read_file, grep, and glob.",
|
||||||
|
}
|
||||||
|
}
|
||||||
return &shellIssue{
|
return &shellIssue{
|
||||||
Kind: "windows_shell_syntax",
|
Kind: "windows_shell_syntax",
|
||||||
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
|
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
|
||||||
@@ -198,18 +367,128 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
|
|||||||
// Being segment/word anchored rather than a whole-string regex or scan,
|
// Being segment/word anchored rather than a whole-string regex or scan,
|
||||||
// neither check matches text that only appears inside a quoted argument
|
// neither check matches text that only appears inside a quoted argument
|
||||||
// (e.g. a commit message or PR comment body discussing head.exe).
|
// (e.g. a commit message or PR comment body discussing head.exe).
|
||||||
for _, segment := range windowsCommandSegments(trimmed) {
|
segments := windowsCommandSegments(trimmed)
|
||||||
|
if shell.Kind == shellKindPowerShell {
|
||||||
|
segments = powerShellCommandSegments(trimmed)
|
||||||
|
}
|
||||||
|
for _, segment := range segments {
|
||||||
word := firstCommandWord(segment)
|
word := firstCommandWord(segment)
|
||||||
if windowsMsysBinaryPathPattern.MatchString(word) {
|
if windowsMsysBinaryPathPattern.MatchString(word) {
|
||||||
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
|
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
|
||||||
}
|
}
|
||||||
if msysProneCommandWord(word) {
|
if msysProneCommandWord(word) {
|
||||||
|
if shell.Kind == shellKindPowerShell && powerShellAliasWord(word) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
|
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// powerShellCommandSegments splits the subset of PowerShell syntax needed for
|
||||||
|
// command-position checks. It respects single/double quoted strings and the
|
||||||
|
// backtick escape, then splits on pipeline/statement operators. Complex script
|
||||||
|
// interpretation remains PowerShell's job; this scanner only determines
|
||||||
|
// whether a known-incompatible MSYS executable is actually invoked.
|
||||||
|
func powerShellCommandSegments(command string) []string {
|
||||||
|
var segments []string
|
||||||
|
var current strings.Builder
|
||||||
|
var quote rune
|
||||||
|
escaped := false
|
||||||
|
for _, c := range command {
|
||||||
|
if escaped {
|
||||||
|
current.WriteRune(c)
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '`' {
|
||||||
|
current.WriteRune(c)
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if quote != 0 {
|
||||||
|
current.WriteRune(c)
|
||||||
|
if c == quote {
|
||||||
|
quote = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\'' || c == '"' {
|
||||||
|
quote = c
|
||||||
|
current.WriteRune(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == ';' || c == '|' || c == '&' {
|
||||||
|
if segment := strings.TrimSpace(current.String()); segment != "" {
|
||||||
|
segments = append(segments, segment)
|
||||||
|
}
|
||||||
|
current.Reset()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current.WriteRune(c)
|
||||||
|
}
|
||||||
|
if segment := strings.TrimSpace(current.String()); segment != "" {
|
||||||
|
segments = append(segments, segment)
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
func powerShellAliasWord(word string) bool {
|
||||||
|
trimmed := strings.Trim(word, `"'`)
|
||||||
|
if strings.ContainsAny(trimmed, `\/`) || strings.HasSuffix(strings.ToLower(trimmed), ".exe") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(trimmed) {
|
||||||
|
case "cat", "ls":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripPowerShellQuotedSpans(command string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(command))
|
||||||
|
var quote rune
|
||||||
|
escaped := false
|
||||||
|
runes := []rune(command)
|
||||||
|
for index := 0; index < len(runes); index++ {
|
||||||
|
c := runes[index]
|
||||||
|
if escaped {
|
||||||
|
b.WriteRune(' ')
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '`' {
|
||||||
|
b.WriteRune(' ')
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if quote != 0 {
|
||||||
|
b.WriteRune(' ')
|
||||||
|
if c == quote {
|
||||||
|
// PowerShell escapes a single quote inside a single-quoted
|
||||||
|
// string by doubling it.
|
||||||
|
if quote == '\'' && index+1 < len(runes) && runes[index+1] == '\'' {
|
||||||
|
index++
|
||||||
|
b.WriteRune(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
quote = 0
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\'' || c == '"' {
|
||||||
|
quote = c
|
||||||
|
b.WriteRune(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(c)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// stripDoubleQuotedSpans replaces the contents of every double-quoted span
|
// stripDoubleQuotedSpans replaces the contents of every double-quoted span
|
||||||
// (quotes included) with spaces, preserving the string's length and the
|
// (quotes included) with spaces, preserving the string's length and the
|
||||||
// position of unquoted text. cmd.exe treats a double-quoted span as a single
|
// position of unquoted text. cmd.exe treats a double-quoted span as a single
|
||||||
@@ -256,7 +535,15 @@ func stripCmdCaretEscapes(command string) string {
|
|||||||
// quoted-text false positives the preflight command-position check exists to
|
// quoted-text false positives the preflight command-position check exists to
|
||||||
// avoid, just after execution instead of before it.
|
// avoid, just after execution instead of before it.
|
||||||
func detectShellOutputIssue(output string, goos string) *shellIssue {
|
func detectShellOutputIssue(output string, goos string) *shellIssue {
|
||||||
if goos != "windows" {
|
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
|
||||||
|
if goos == "windows" {
|
||||||
|
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
|
||||||
|
}
|
||||||
|
return detectShellOutputIssueForRuntime(output, shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectShellOutputIssueForRuntime(output string, shell shellRuntime) *shellIssue {
|
||||||
|
if shell.GOOS != "windows" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(output)
|
lower := strings.ToLower(output)
|
||||||
@@ -266,8 +553,8 @@ func detectShellOutputIssue(output string, goos string) *shellIssue {
|
|||||||
if wslServiceDeniedInOutput(lower) {
|
if wslServiceDeniedInOutput(lower) {
|
||||||
return windowsMsysSandboxIssue("WSL bash could not connect to the WSL service under Zero's Windows sandbox (Bash/Service/CreateInstance/E_ACCESSDENIED).")
|
return windowsMsysSandboxIssue("WSL bash could not connect to the WSL service under Zero's Windows sandbox (Bash/Service/CreateInstance/E_ACCESSDENIED).")
|
||||||
}
|
}
|
||||||
if strings.Contains(lower, "the syntax of the command is incorrect") ||
|
if shell.Kind == shellKindCmd && (strings.Contains(lower, "the syntax of the command is incorrect") ||
|
||||||
strings.Contains(lower, "is not recognized as an internal or external command") {
|
strings.Contains(lower, "is not recognized as an internal or external command")) {
|
||||||
return &shellIssue{
|
return &shellIssue{
|
||||||
Kind: "windows_shell_syntax",
|
Kind: "windows_shell_syntax",
|
||||||
Message: "Windows cmd.exe rejected the command syntax.",
|
Message: "Windows cmd.exe rejected the command syntax.",
|
||||||
|
|||||||
@@ -1,10 +1,218 @@
|
|||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestDetectShellRuntimePrefersPowerShellSevenOnWindows(t *testing.T) {
|
||||||
|
lookPath := func(name string) (string, error) {
|
||||||
|
if name == "pwsh.exe" {
|
||||||
|
return `C:\Program Files\PowerShell\7\pwsh.exe`, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("not found")
|
||||||
|
}
|
||||||
|
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
|
||||||
|
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\pwsh.exe`) {
|
||||||
|
t.Fatalf("shell = %#v, want PowerShell 7", shell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectShellRuntimeFallsBackThroughWindowsPowerShellToCmd(t *testing.T) {
|
||||||
|
t.Run("windows powershell", func(t *testing.T) {
|
||||||
|
lookPath := func(name string) (string, error) {
|
||||||
|
if name == "powershell.exe" {
|
||||||
|
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("not found")
|
||||||
|
}
|
||||||
|
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
|
||||||
|
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\powershell.exe`) {
|
||||||
|
t.Fatalf("shell = %#v, want Windows PowerShell", shell)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cmd", func(t *testing.T) {
|
||||||
|
shell := detectShellRuntimeWithLookup(
|
||||||
|
"windows",
|
||||||
|
func(string) (string, error) { return "", errors.New("not found") },
|
||||||
|
func(string) string { return "" },
|
||||||
|
)
|
||||||
|
if shell.Kind != shellKindCmd || shell.Executable != "cmd.exe" {
|
||||||
|
t.Fatalf("shell = %#v, want cmd.exe fallback", shell)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectShellRuntimeSkipsUnusablePowerShell(t *testing.T) {
|
||||||
|
lookPath := func(name string) (string, error) {
|
||||||
|
switch name {
|
||||||
|
case "pwsh.exe":
|
||||||
|
return `C:\broken\pwsh.exe`, nil
|
||||||
|
case "powershell.exe":
|
||||||
|
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New("not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shell := detectShellRuntimeWithProbe(
|
||||||
|
"windows",
|
||||||
|
lookPath,
|
||||||
|
func(string) string { return "" },
|
||||||
|
func(path string) bool { return !strings.Contains(path, `\broken\`) },
|
||||||
|
)
|
||||||
|
if shell.Kind != shellKindPowerShell || strings.Contains(shell.Executable, `\broken\`) {
|
||||||
|
t.Fatalf("shell = %#v, want usable Windows PowerShell fallback", shell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPowerShellArgumentsDisableProfilesAndRequestUTF8(t *testing.T) {
|
||||||
|
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
|
||||||
|
args := shell.arguments("Write-Output 'hello'")
|
||||||
|
joined := strings.Join(args, "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
"-NoLogo",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
windowsPowerShellUTF8Prefix,
|
||||||
|
"$ErrorActionPreference = 'Stop'",
|
||||||
|
"Write-Output 'hello'",
|
||||||
|
"exit $LASTEXITCODE",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("PowerShell args missing %q: %#v", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
script := args[len(args)-1]
|
||||||
|
preferenceIndex := strings.Index(script, "$ErrorActionPreference")
|
||||||
|
commandIndex := strings.Index(script, "Write-Output 'hello'")
|
||||||
|
exitIndex := strings.Index(script, "exit $LASTEXITCODE")
|
||||||
|
if preferenceIndex < 0 || commandIndex <= preferenceIndex || exitIndex <= commandIndex {
|
||||||
|
t.Fatalf("PowerShell failure handling is not ordered around the command: %q", script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowsPowerShellArgumentsPropagateFailures(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("Windows PowerShell integration test")
|
||||||
|
}
|
||||||
|
executable, err := exec.LookPath("powershell.exe")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Windows PowerShell unavailable: %v", err)
|
||||||
|
}
|
||||||
|
shell := shellRuntime{
|
||||||
|
GOOS: "windows",
|
||||||
|
Executable: executable,
|
||||||
|
Syntax: "PowerShell",
|
||||||
|
Kind: shellKindPowerShell,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("native exit code after cmdlet", func(t *testing.T) {
|
||||||
|
command := exec.Command(executable, shell.arguments("cmd /c exit 5; Write-Output done")...)
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 5 {
|
||||||
|
t.Fatalf("exit = %v, output = %q; want exact native exit code 5", err, output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), "done") {
|
||||||
|
t.Fatalf("output = %q, want command output before propagated failure", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cmdlet error stops script", func(t *testing.T) {
|
||||||
|
missing := strings.ReplaceAll(t.TempDir()+`\missing`, `'`, `''`)
|
||||||
|
commandText := "Get-Item -LiteralPath '" + missing + "'; Write-Output after"
|
||||||
|
command := exec.Command(executable, shell.arguments(commandText)...)
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("cmdlet failure reported success: %q", output)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(output), "after") {
|
||||||
|
t.Fatalf("script continued after cmdlet failure: %q", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowsPowerShellGuidanceAvoidsUnsupportedChainOperators(t *testing.T) {
|
||||||
|
shell := shellRuntime{
|
||||||
|
GOOS: "windows",
|
||||||
|
Executable: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`,
|
||||||
|
Syntax: "PowerShell",
|
||||||
|
Kind: shellKindPowerShell,
|
||||||
|
}
|
||||||
|
for _, guidance := range []string{
|
||||||
|
shellGuidanceForRuntime(shell),
|
||||||
|
hostShellEnvironmentGuidanceForRuntime(shell),
|
||||||
|
} {
|
||||||
|
for _, want := range []string{"Windows PowerShell 5.1", "&&", "||", "if ($?)"} {
|
||||||
|
if !strings.Contains(guidance, want) {
|
||||||
|
t.Fatalf("legacy PowerShell guidance missing %q: %q", want, guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowsPowerShellCommandIssueFlagsUnsupportedChainOperators(t *testing.T) {
|
||||||
|
legacy := shellRuntime{
|
||||||
|
GOOS: "windows",
|
||||||
|
Executable: "powershell.exe",
|
||||||
|
Syntax: "PowerShell",
|
||||||
|
Kind: shellKindPowerShell,
|
||||||
|
}
|
||||||
|
for _, command := range []string{
|
||||||
|
"Write-Output one && Write-Output two",
|
||||||
|
"Write-Output one || Write-Output two",
|
||||||
|
} {
|
||||||
|
issue := detectShellCommandIssueForRuntime(command, legacy)
|
||||||
|
if issue == nil || issue.Kind != "windows_powershell_version" {
|
||||||
|
t.Fatalf("legacy PowerShell command %q issue = %#v, want version issue", command, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, command := range []string{
|
||||||
|
`Write-Output "one && two"`,
|
||||||
|
"Write-Output 'one || two'",
|
||||||
|
"Write-Output one `&`& Write-Output two",
|
||||||
|
} {
|
||||||
|
if issue := detectShellCommandIssueForRuntime(command, legacy); issue != nil {
|
||||||
|
t.Fatalf("literal legacy PowerShell operators in %q produced issue %#v", command, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modern := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
|
||||||
|
if issue := detectShellCommandIssueForRuntime("Write-Output one && Write-Output two", modern); issue != nil {
|
||||||
|
t.Fatalf("PowerShell 7 chain operator produced issue %#v", issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPowerShellCommandIssueAllowsAliasesAndBlocksMsysExecutables(t *testing.T) {
|
||||||
|
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
|
||||||
|
for _, command := range []string{
|
||||||
|
`ls -Force`,
|
||||||
|
`cat README.md`,
|
||||||
|
`Get-ChildItem | Select-Object -First 10`,
|
||||||
|
`Write-Output 'grep README.md; head file.txt'`,
|
||||||
|
`Write-Output 'cd /tmp'`,
|
||||||
|
} {
|
||||||
|
if issue := detectShellCommandIssueForRuntime(command, shell); issue != nil {
|
||||||
|
t.Fatalf("PowerShell-native command %q was blocked: %#v", command, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, command := range []string{
|
||||||
|
`grep README.md`,
|
||||||
|
`Get-Content README.md | head -10`,
|
||||||
|
`cat.exe README.md`,
|
||||||
|
`bash -lc "make test"`,
|
||||||
|
`Write-Output ok; sed -n '1,5p' README.md`,
|
||||||
|
} {
|
||||||
|
if issue := detectShellCommandIssueForRuntime(command, shell); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||||
|
t.Fatalf("MSYS-prone command %q was not blocked: %#v", command, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
|
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
|
||||||
for _, command := range []string{
|
for _, command := range []string{
|
||||||
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
|
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
|
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
|
||||||
@@ -30,8 +29,7 @@ func runBashEscape(cwd, command string) tea.Cmd {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), bashEscapeTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), bashEscapeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
name, shellArgs := escapeShell(command)
|
cmd := tools.NewHostShellCommandContext(ctx, command)
|
||||||
cmd := exec.CommandContext(ctx, name, shellArgs...)
|
|
||||||
if strings.TrimSpace(cwd) != "" {
|
if strings.TrimSpace(cwd) != "" {
|
||||||
cmd.Dir = cwd
|
cmd.Dir = cwd
|
||||||
}
|
}
|
||||||
@@ -51,15 +49,11 @@ func runBashEscape(cwd, command string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapeShell returns the platform shell and arguments for a "!cmd" escape,
|
// escapeShell returns the platform shell and arguments for tests and display.
|
||||||
// matching the agent bash tool: cmd.exe on Windows, /bin/sh elsewhere. Hardcoding
|
// Execution itself uses tools.NewHostShellCommandContext so the cmd.exe
|
||||||
// "bash" broke the escape on stock Windows (no bash on PATH) and anywhere bash is
|
// fallback receives its required raw Windows command line.
|
||||||
// not installed at a predictable path.
|
|
||||||
func escapeShell(command string) (string, []string) {
|
func escapeShell(command string) (string, []string) {
|
||||||
if runtime.GOOS == "windows" {
|
return tools.HostShellCommand(command)
|
||||||
return "cmd.exe", []string{"/d", "/s", "/c", command}
|
|
||||||
}
|
|
||||||
return "/bin/sh", []string{"-c", command}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendNote(text, note string) string {
|
func appendNote(text, note string) string {
|
||||||
|
|||||||
@@ -42,19 +42,19 @@ func TestBashEscapeGatedByPermissionMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "!" escape must use the platform shell (cmd.exe on Windows, /bin/sh
|
// The "!" escape must use the same detected host shell as the agent tools.
|
||||||
// elsewhere), not a hardcoded "bash" that is absent on stock Windows.
|
|
||||||
func TestEscapeShellWrapsCommandForPlatform(t *testing.T) {
|
func TestEscapeShellWrapsCommandForPlatform(t *testing.T) {
|
||||||
name, args := escapeShell("echo hi")
|
name, args := escapeShell("echo hi")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
t.Fatal("escapeShell returned an empty executable")
|
t.Fatal("escapeShell returned an empty executable")
|
||||||
}
|
}
|
||||||
if len(args) == 0 || args[len(args)-1] != "echo hi" {
|
if len(args) == 0 || !strings.Contains(args[len(args)-1], "echo hi") {
|
||||||
t.Fatalf("escapeShell args = %v, want the command as the final arg", args)
|
t.Fatalf("escapeShell args = %v, want the command in the final arg", args)
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if name != "cmd.exe" || args[0] != "/d" {
|
lowerName := strings.ToLower(filepath.Base(name))
|
||||||
t.Fatalf("windows shell = %q %v, want cmd.exe /d /s /c", name, args)
|
if lowerName != "pwsh.exe" && lowerName != "powershell.exe" && lowerName != "cmd.exe" {
|
||||||
|
t.Fatalf("windows shell = %q %v, want PowerShell or cmd.exe fallback", name, args)
|
||||||
}
|
}
|
||||||
} else if name != "/bin/sh" || args[0] != "-c" {
|
} else if name != "/bin/sh" || args[0] != "-c" {
|
||||||
t.Fatalf("unix shell = %q %v, want /bin/sh -c", name, args)
|
t.Fatalf("unix shell = %q %v, want /bin/sh -c", name, args)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ case "$(go env GOOS)" in
|
|||||||
ZERO_SANDBOX_REAL_SMOKE=1 \
|
ZERO_SANDBOX_REAL_SMOKE=1 \
|
||||||
ZERO_WINDOWS_COMMAND_RUNNER_EXE="$tmpdir/windows-zero-windows-command-runner.exe" \
|
ZERO_WINDOWS_COMMAND_RUNNER_EXE="$tmpdir/windows-zero-windows-command-runner.exe" \
|
||||||
ZERO_WINDOWS_SANDBOX_SETUP_EXE="$tmpdir/windows-zero-windows-sandbox-setup.exe" \
|
ZERO_WINDOWS_SANDBOX_SETUP_EXE="$tmpdir/windows-zero-windows-sandbox-setup.exe" \
|
||||||
go test ./internal/sandbox -run TestWindowsRestrictedTokenRealSandboxSmoke -count=1
|
go test ./internal/sandbox -run 'TestWindowsRestrictedTokenRealSandboxSmoke|TestWindowsRestrictedTokenPowerShell' -count=1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "No real sandbox smoke is defined for this host platform."
|
echo "No real sandbox smoke is defined for this host platform."
|
||||||
|
|||||||
Reference in new issue
Block a user