mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* 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
613 lines
25 KiB
Go
613 lines
25 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
type shellKind string
|
|
|
|
const (
|
|
shellKindPOSIX shellKind = "posix"
|
|
shellKindPowerShell shellKind = "powershell"
|
|
shellKindCmd shellKind = "cmd"
|
|
)
|
|
|
|
type shellRuntime struct {
|
|
GOOS string
|
|
Executable string
|
|
Syntax string
|
|
Kind shellKind
|
|
}
|
|
|
|
type shellIssue struct {
|
|
Kind string
|
|
Message string
|
|
Suggestion string
|
|
}
|
|
|
|
const windowsMsysSandboxKind = "windows_msys_sandbox"
|
|
|
|
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
|
|
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
|
|
// rather than a cmd.exe-native command, and so fail under the write-restricted
|
|
// Windows sandbox (#458). Every Windows MSYS-detection path (the preflight
|
|
// command scan below, the exported MsysProneCommandName, and the
|
|
// known-safe-segment guard in internal/agent/command_prefix.go) derives from
|
|
// this one set, so they cannot drift out of sync with each other.
|
|
var windowsMsysProneNames = map[string]bool{
|
|
"cat": true, "cut": true, "expr": true, "grep": true, "head": true,
|
|
"id": true, "ls": true, "nl": true, "paste": true, "rev": true,
|
|
"seq": true, "stat": true, "tail": true, "tr": true, "uname": true,
|
|
"uniq": true, "wc": true, "which": true, "awk": true, "sed": true,
|
|
"xargs": true,
|
|
// Shells. Git-for-Windows bash.exe/sh.exe are MSYS binaries and die during
|
|
// MSYS runtime init under the restricted token ("couldn't create signal
|
|
// pipe" or "CreateFileMapping <SID>.1", both Win32 error 5), and the
|
|
// System32 WSL bash launcher fails equivalently at a different layer (the
|
|
// restricted token cannot connect to the WSL service:
|
|
// Bash/Service/CreateInstance/E_ACCESSDENIED), so every executable a bare
|
|
// `bash` can resolve to fails under the sandbox.
|
|
"bash": true, "sh": true,
|
|
}
|
|
|
|
var (
|
|
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
|
|
// windowsMsysBinaryPathPattern catches explicit Git-for-Windows / MSYS usr\bin
|
|
// paths. These executables are valid Windows PE files but fail under the
|
|
// write-restricted sandbox with CreateFileMapping ACCESS_DENIED (#458).
|
|
windowsMsysBinaryPathPattern = regexp.MustCompile(`(?i)(?:\\usr\\bin\\|\\mingw64\\bin\\|msys-2\.0\.dll|cygwin1\.dll)`)
|
|
)
|
|
|
|
func detectShellRuntime(goos string) shellRuntime {
|
|
if goos != runtime.GOOS {
|
|
return detectShellRuntimeWithLookup(goos, exec.LookPath, os.Getenv)
|
|
}
|
|
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 {
|
|
return shellGuidanceForRuntime(detectShellRuntime(goos))
|
|
}
|
|
|
|
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)
|
|
}
|
|
if shell.GOOS == "windows" {
|
|
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."
|
|
}
|
|
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
|
|
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
|
|
func MsysProneCommandName(name string) bool {
|
|
return windowsMsysProneNames[strings.ToLower(strings.TrimSpace(name))]
|
|
}
|
|
|
|
func windowsMsysSandboxIssue(message string) *shellIssue {
|
|
return &shellIssue{
|
|
Kind: windowsMsysSandboxKind,
|
|
Message: message,
|
|
Suggestion: windowsMsysSandboxSuggestion,
|
|
}
|
|
}
|
|
|
|
// windowsCommandSegments splits a command into cmd.exe-operator-separated
|
|
// segments (&, |, and their doubled forms &&/||), respecting double quotes
|
|
// (cmd.exe's grouping construct) and the caret (^) escape character, so an
|
|
// operator or command name mentioned inside a quoted argument (e.g. a commit
|
|
// message or PR comment body), or an operator escaped with ^ (cmd.exe prints
|
|
// `echo ^| head` as literal text instead of piping to head), is not mistaken
|
|
// for a real segment boundary or invocation. Unlike bash, cmd.exe does not
|
|
// treat ; as a statement separator, so it is left as ordinary argument text
|
|
// (e.g. `echo foo; head` is a single `echo` invocation with literal args).
|
|
func windowsCommandSegments(command string) []string {
|
|
var segments []string
|
|
var current strings.Builder
|
|
inQuotes := false
|
|
runes := []rune(command)
|
|
for i := 0; i < len(runes); i++ {
|
|
c := runes[i]
|
|
if !inQuotes && c == '^' && i+1 < len(runes) {
|
|
current.WriteRune(c)
|
|
i++
|
|
current.WriteRune(runes[i])
|
|
continue
|
|
}
|
|
if c == '"' {
|
|
inQuotes = !inQuotes
|
|
current.WriteRune(c)
|
|
continue
|
|
}
|
|
if !inQuotes && (c == '&' || c == '|') {
|
|
if seg := strings.TrimSpace(current.String()); seg != "" {
|
|
segments = append(segments, seg)
|
|
}
|
|
current.Reset()
|
|
continue
|
|
}
|
|
current.WriteRune(c)
|
|
}
|
|
if seg := strings.TrimSpace(current.String()); seg != "" {
|
|
segments = append(segments, seg)
|
|
}
|
|
return segments
|
|
}
|
|
|
|
// firstCommandWord returns the first token of a command segment. A leading
|
|
// double-quoted span counts as one token with its quotes stripped, since
|
|
// cmd.exe treats a quoted path as a single argument: the command invoked by
|
|
// `"C:\Program Files\Git\usr\bin\head.exe" file.txt` is the quoted path, not
|
|
// "C:\Program. For an unquoted command, the token ends at whitespace or a
|
|
// redirection operator (<, >): cmd.exe accepts redirection attached directly
|
|
// to the command name with no separating space (head>out.txt, cat<in.txt), so
|
|
// stopping only at whitespace would return "head>out.txt" as one word and
|
|
// miss the invoked command.
|
|
func firstCommandWord(segment string) string {
|
|
trimmed := strings.TrimSpace(segment)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if trimmed[0] == '"' {
|
|
if end := strings.IndexByte(trimmed[1:], '"'); end >= 0 {
|
|
return trimmed[1 : end+1]
|
|
}
|
|
return trimmed[1:]
|
|
}
|
|
if end := strings.IndexFunc(trimmed, isCommandWordBoundary); end >= 0 {
|
|
return trimmed[:end]
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func isCommandWordBoundary(r rune) bool {
|
|
return unicode.IsSpace(r) || r == '<' || r == '>'
|
|
}
|
|
|
|
// msysProneCommandWord reports whether word (the first token of a command
|
|
// segment, as returned by firstCommandWord) names an MSYS-prone coreutil,
|
|
// bare or with a directory prefix and/or .exe suffix (head, head.exe,
|
|
// C:\...\usr\bin\head.exe, ...).
|
|
func msysProneCommandWord(word string) bool {
|
|
word = strings.Trim(word, `"`)
|
|
if i := strings.LastIndexAny(word, `\/`); i >= 0 {
|
|
word = word[i+1:]
|
|
}
|
|
word = strings.TrimSuffix(strings.ToLower(word), ".exe")
|
|
return MsysProneCommandName(word)
|
|
}
|
|
|
|
func detectShellCommandIssue(command string, goos string) *shellIssue {
|
|
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
|
|
}
|
|
trimmed := strings.TrimSpace(command)
|
|
// Blank out double-quoted spans before matching, so a `cd /foo`-shaped
|
|
// string that only appears inside a quoted argument value (e.g. a `gh`
|
|
// or `git commit` message) is not mistaken for an actual command cmd.exe
|
|
// would interpret. Then neutralize cmd.exe caret escapes of its own
|
|
// metacharacters (foo^|cat is the literal token foo|cat, not a pipe into
|
|
// cat), so an escaped metachar can't stand in as a fake segment boundary
|
|
// either.
|
|
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 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{
|
|
Kind: "windows_shell_syntax",
|
|
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
|
|
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
|
|
}
|
|
}
|
|
// Check the first word of each operator-separated segment (not the raw
|
|
// text anywhere in the command) against the MSYS binary-path pattern and
|
|
// the single MSYS-prone name set, covering bare names (head), .exe names
|
|
// (head.exe), and directory-prefixed forms (C:\...\head.exe) uniformly.
|
|
// Being segment/word anchored rather than a whole-string regex or scan,
|
|
// neither check matches text that only appears inside a quoted argument
|
|
// (e.g. a commit message or PR comment body discussing head.exe).
|
|
segments := windowsCommandSegments(trimmed)
|
|
if shell.Kind == shellKindPowerShell {
|
|
segments = powerShellCommandSegments(trimmed)
|
|
}
|
|
for _, segment := range segments {
|
|
word := firstCommandWord(segment)
|
|
if windowsMsysBinaryPathPattern.MatchString(word) {
|
|
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
|
|
}
|
|
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 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
|
|
// (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
|
|
// literal token, so operators/utility names inside one are not real command
|
|
// syntax; blanking them out keeps the Windows command-issue regexes anchored
|
|
// to text cmd.exe would actually interpret.
|
|
func stripDoubleQuotedSpans(command string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(command))
|
|
inQuotes := false
|
|
for _, c := range command {
|
|
if c == '"' {
|
|
inQuotes = !inQuotes
|
|
b.WriteByte(' ')
|
|
continue
|
|
}
|
|
if inQuotes {
|
|
b.WriteByte(' ')
|
|
continue
|
|
}
|
|
b.WriteRune(c)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// cmdEscapedMetacharPattern matches a cmd.exe caret escape of one of its own
|
|
// metacharacters. cmd.exe treats the escaped character as a literal, not an
|
|
// operator, so e.g. `foo^|cat` is the single literal token foo|cat, not a
|
|
// pipe into cat.
|
|
var cmdEscapedMetacharPattern = regexp.MustCompile(`\^[&|;^<>]`)
|
|
|
|
// stripCmdCaretEscapes blanks out cmd.exe caret-escape sequences (both
|
|
// characters), so an escaped metacharacter cannot be mistaken by the Windows
|
|
// command-issue regexes for a real operator/segment boundary.
|
|
func stripCmdCaretEscapes(command string) string {
|
|
return cmdEscapedMetacharPattern.ReplaceAllString(command, " ")
|
|
}
|
|
|
|
// detectShellOutputIssue looks for MSYS runtime crash markers and cmd.exe
|
|
// syntax-error text in output only, never in the command that was run. The
|
|
// command line is attacker/user-controlled argument text (e.g. a `gh pr
|
|
// comment --body` quoting a sample failure), not something the shell
|
|
// produced, so treating it as evidence would reintroduce the same
|
|
// quoted-text false positives the preflight command-position check exists to
|
|
// avoid, just after execution instead of before it.
|
|
func detectShellOutputIssue(output string, goos string) *shellIssue {
|
|
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
|
|
}
|
|
lower := strings.ToLower(output)
|
|
if msysRuntimeFailedInOutput(lower) {
|
|
return windowsMsysSandboxIssue("An MSYS/Cygwin runtime failed under Zero's Windows sandbox (ACCESS_DENIED during MSYS startup).")
|
|
}
|
|
if wslServiceDeniedInOutput(lower) {
|
|
return windowsMsysSandboxIssue("WSL bash could not connect to the WSL service under Zero's Windows sandbox (Bash/Service/CreateInstance/E_ACCESSDENIED).")
|
|
}
|
|
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")) {
|
|
return &shellIssue{
|
|
Kind: "windows_shell_syntax",
|
|
Message: "Windows cmd.exe rejected the command syntax.",
|
|
Suggestion: "Use Windows cmd.exe syntax. Quote args with | using double quotes (e.g. --jq \".a | b\"). Avoid | head; use --jq or PowerShell Select-Object -First N instead. Prefer native tools.",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func msysRuntimeFailedInOutput(lower string) bool {
|
|
if strings.Contains(lower, "fatal error - createfilemapping") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "couldn't create signal pipe") && strings.Contains(lower, "win32 error 5") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "cygheap_user::init") && strings.Contains(lower, "fatal error") {
|
|
return true
|
|
}
|
|
if strings.Contains(lower, "usr\\bin\\") && strings.Contains(lower, "fatal error") {
|
|
return true
|
|
}
|
|
if !strings.Contains(lower, "win32 error 5") || !strings.Contains(lower, "terminating") {
|
|
return false
|
|
}
|
|
// Anchor the broad win32-error-5 fallback to an MSYS-specific marker so
|
|
// unrelated access-denied failures are not mislabeled as MSYS sandbox
|
|
// incompatibilities.
|
|
return strings.Contains(lower, `usr\bin\`) ||
|
|
strings.Contains(lower, "cygheap") ||
|
|
strings.Contains(lower, "msys-2.0.dll") ||
|
|
strings.Contains(lower, "cygwin1.dll") ||
|
|
strings.Contains(lower, "[main]")
|
|
}
|
|
|
|
// wslServiceDeniedInOutput matches the WSL bash launcher's failure to open its
|
|
// service connection under the restricted token. The launcher writes UTF-16LE
|
|
// to its (piped, non-console) stderr, which the byte-based capture renders as
|
|
// ASCII interleaved with NUL bytes, so the NULs are stripped before matching.
|
|
func wslServiceDeniedInOutput(lower string) bool {
|
|
compact := strings.ReplaceAll(lower, "\x00", "")
|
|
return strings.Contains(compact, "bash/service/") && strings.Contains(compact, "e_accessdenied")
|
|
}
|
|
|
|
func appendShellIssueHint(output string, issue shellIssue) string {
|
|
output = strings.TrimRight(output, "\r\n")
|
|
hint := "[zero] shell issue: " + issue.Message
|
|
if strings.TrimSpace(issue.Suggestion) != "" {
|
|
hint += "\nSuggestion: " + issue.Suggestion
|
|
}
|
|
if output == "" {
|
|
return hint
|
|
}
|
|
return output + "\n" + hint
|
|
}
|