Compare commits

..
Author SHA1 Message Date
anandh8x 9fc61fc433 test(sandbox): keep zero credential directory deny path expectations 2026-07-27 14:51:00 +05:30
anandh8x bb103b2bc2 fix(sandbox): deny zero credential directory 2026-07-27 14:09:52 +05:30
anandh8x 538b31ed75 fix(sandbox): protect custom gcloud config
CLOUDSDK_CONFIG can relocate gcloud credentials outside the XDG-derived default. Include the configured directory in the credential deny profile and cover the override with a regression test.
2026-07-27 01:22:20 +05:30
anandh8x 968d0830f1 fix(sandbox): mask preserved credential configs
Preserving caller configuration made credential-bearing tool files discoverable under Linux's read-all sandbox profile. Add default and environment-overridden npm, GitHub CLI, netrc, Docker, Kubernetes, and Zero config paths to the credential deny set, with regression coverage.

Clarify the typed-denial retry and platform read-model comments without changing the approval behavior.
2026-07-27 00:32:10 +05:30
anandh8x 7dd3d575d8 fix(sandbox): trust typed denials and restore Cargo state
Stop promoting command output keywords into recoverable unrestricted denials, so only structured adapter facts can enter the approved unsandboxed retry path.

Restore the managed CARGO_HOME directory and environment override so Cargo remains writable while the caller's real home stays read-only.
2026-07-26 13:55:19 +05:30
anandh8x 0684cf4c4b Merge remote-tracking branch 'origin/main' into fix/preserve-user-environment 2026-07-24 13:27:57 +05:30
anandh8x 3b9be6060e test(sandbox): assert denial contract 2026-07-24 12:55:38 +05:30
anandh8x c2429d00ac fix(sandbox): tighten denial retry safeguards 2026-07-24 12:45:00 +05:30
anandh8x 99c74d9b86 fix(sandbox): preserve user config and retry denied writes
Keep user identity and configuration paths discoverable while retaining managed cache and temporary storage. Classify native sandbox failures as typed recoverable denials so approved commands can retry outside the sandbox.

Add regression coverage for global Git ignores, typed denial handling, and permission retry behavior.
2026-07-24 12:23:38 +05:30
31 changed files with 529 additions and 1047 deletions

No files matched your search

+9 -3
View File
@@ -1617,9 +1617,15 @@ func sandboxRestrictedShellRetryCandidate(call ToolCall, args map[string]any, re
func sandboxDeniedShellResult(result tools.Result) bool {
if result.ExecutionOutcome != nil {
// Typed denials must be handled by their exact capability path. Never
// turn a structured narrow denial into the legacy unrestricted retry.
return false
// Only a typed platform denial that explicitly requests unrestricted
// recovery may enter the legacy retry path. Narrow capability denials
// remain on their exact capability path.
denial := result.ExecutionOutcome.Denial
return denial != nil &&
denial.Source == execution.DenialSourcePlatformSandbox &&
denial.Capability.Kind == execution.CapabilityUnrestricted &&
denial.Recoverable &&
denial.NextAction == execution.DenialNextActionRequestApproval
}
return result.Status == tools.StatusError && result.Meta[tools.SandboxLikelyDeniedMeta] == "true"
}
+43 -24
View File
@@ -64,6 +64,22 @@ func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
}
nonRecoverableDenial := protectedDenial
nonRecoverableDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
Source: execution.DenialSourcePlatformSandbox,
Reason: "sandbox enforcement failed closed",
Recoverable: false,
NextAction: execution.DenialNextActionRequestApproval,
},
}
if sandboxRestrictedShellRetryCandidate(call, nil, nonRecoverableDenial, options) {
t.Fatal("non-recoverable sandbox denial must not become an unrestricted retry")
}
networkDenial := protectedDenial
networkDenial.ExecutionOutcome = &execution.Outcome{
State: execution.StateDenied,
@@ -288,6 +304,18 @@ func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any)
return tools.Result{
Status: tools.StatusError,
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
ExecutionOutcome: &execution.Outcome{
State: execution.StateDenied,
Kind: execution.OutcomeEnforcementDenied,
Exit: &execution.Exit{Code: 1},
Denial: &execution.Denial{
Capability: execution.Capability{Kind: execution.CapabilityUnrestricted, Scope: "host"},
Source: execution.DenialSourcePlatformSandbox,
Reason: "sandbox blocked command execution",
Recoverable: true,
NextAction: execution.DenialNextActionRequestApproval,
},
},
Meta: map[string]string{
"exit_code": "1",
tools.SandboxLikelyDeniedMeta: "true",
@@ -2404,11 +2432,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
root := t.TempDir()
command := "PATH=.:$PATH curl https://example.com"
if runtime.GOOS == "windows" {
if windowsTestUsesPowerShell() {
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
} else {
command = "set PATH=.;%PATH% && curl https://example.com"
}
command = "set PATH=.;%PATH% && curl https://example.com"
fakeCurl := filepath.Join(root, "curl.cmd")
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
t.Fatal(err)
@@ -2529,17 +2553,13 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
root := t.TempDir()
command := "echo rm -rf /"
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
command = `Write-Output 'rm -rf /'`
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2635,12 +2655,6 @@ 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 {
for _, decision := range decisions {
if decision == want {
@@ -3174,17 +3188,22 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
wants := []string{"workdir/cwd", "MSYS binaries"}
if windowsTestUsesPowerShell() {
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
} else {
wants = append(wants, "cmd.exe", "double quotes")
}
for _, want := range wants {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
// The examples must themselves use the safe (double-quoted) form; a
// 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) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, 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") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
}
+5 -2
View File
@@ -11,7 +11,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -311,7 +310,11 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
if runtime.GOOS == "windows" {
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 != "" {
b.WriteString("Git branch: " + branch + "\n")
}
+2 -29
View File
@@ -27,22 +27,11 @@ var destructivePrograms = map[string]bool{
"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.
var networkPrograms = map[string]bool{
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
"invoke-restmethod": true,
"ftp": true,
}
var localServerPrograms = map[string]bool{
@@ -183,10 +172,7 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
(prog == "find" && hasFindDelete(rest)) {
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
result.Destructive = true
}
return true
@@ -493,19 +479,6 @@ func hasRecursiveForce(args []*syntax.Word) bool {
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 {
for _, arg := range args {
if wordText(arg) == "-delete" {
-21
View File
@@ -45,27 +45,6 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
{name: "curl", script: "curl https://example.com", network: true},
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
-42
View File
@@ -2,7 +2,6 @@ package sandbox
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
@@ -74,25 +73,6 @@ 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) {
root := t.TempDir()
policy := DefaultPolicy()
@@ -505,28 +485,6 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
}
}
func TestEnginePromptsForOutOfWorkspaceReadWithReadSpecificReason(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "external")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "list_directory",
SideEffect: SideEffectRead,
Permission: PermissionAllow,
WorkspaceRoot: root,
Args: map[string]any{"path": outside},
})
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if decision.Action != ActionPrompt || decision.Block == nil || !decision.Block.Recoverable {
t.Fatalf("outside read decision = %#v, want recoverable prompt", decision)
}
if decision.Reason != want || decision.Block.Reason != want {
t.Fatalf("outside read reasons = decision %q, block %q; want %q", decision.Reason, decision.Block.Reason, want)
}
}
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
+92 -2
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
@@ -378,7 +379,31 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
home := t.TempDir()
awsDir := filepath.Join(home, ".aws")
gcloudDir := filepath.Join(home, ".config", "gcloud")
if err := mkdirAll(awsDir, gcloudDir); err != nil {
npmrc := filepath.Join(home, ".npmrc")
ghHosts := filepath.Join(home, ".config", "gh", "hosts.yml")
netrc := filepath.Join(home, ".netrc")
dockerConfig := filepath.Join(home, ".docker", "config.json")
kubeConfig := filepath.Join(home, ".kube", "config")
zeroConfig := filepath.Join(home, ".config", "zero")
if err := mkdirAll(
awsDir,
gcloudDir,
filepath.Dir(ghHosts),
filepath.Dir(dockerConfig),
filepath.Dir(kubeConfig),
filepath.Dir(zeroConfig),
); err != nil {
t.Fatal(err)
}
for _, path := range []string{npmrc, ghHosts, netrc, dockerConfig, kubeConfig, zeroConfig} {
if path == zeroConfig {
continue
}
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
if err := os.MkdirAll(zeroConfig, 0o755); err != nil {
t.Fatal(err)
}
keyFile := filepath.Join(home, "sa-key.json")
@@ -387,7 +412,17 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
}
paths := credentialDenyReadPathsIn(home, keyFile, nil)
for _, want := range normalizeProfilePaths([]string{awsDir, gcloudDir, keyFile}) {
for _, want := range normalizeProfilePaths([]string{
awsDir,
gcloudDir,
keyFile,
npmrc,
ghHosts,
netrc,
dockerConfig,
kubeConfig,
zeroConfig,
}) {
if !stringSliceContains(paths, want) {
t.Errorf("credential deny paths = %#v, want %q included", paths, want)
}
@@ -418,3 +453,58 @@ func TestCredentialDenyReadPathsIn(t *testing.T) {
t.Errorf("credential deny paths without home = %#v, want key file included", homeless)
}
}
func TestCredentialDenyReadPathsForEnvironmentHonorsConfigOverrides(t *testing.T) {
root := t.TempDir()
configHome := filepath.Join(root, "xdg")
npmrc := filepath.Join(root, "npm", "userconfig")
ghConfigDir := filepath.Join(root, "gh")
cloudSDKConfig := filepath.Join(root, "gcloud")
netrc := filepath.Join(root, "netrc")
dockerConfigDir := filepath.Join(root, "docker")
kubeConfigA := filepath.Join(root, "kube", "a")
kubeConfigB := filepath.Join(root, "kube", "b")
zeroConfig := filepath.Join(configHome, "zero")
filePaths := []string{
npmrc,
filepath.Join(ghConfigDir, "hosts.yml"),
netrc,
filepath.Join(dockerConfigDir, "config.json"),
kubeConfigA,
kubeConfigB,
zeroConfig,
}
if err := os.MkdirAll(cloudSDKConfig, 0o755); err != nil {
t.Fatal(err)
}
for _, path := range filePaths {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if path == zeroConfig {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
continue
}
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
got := credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
ConfigHome: configHome,
CloudSDKConfig: cloudSDKConfig,
NPMUserConfig: npmrc,
GHConfigDir: ghConfigDir,
Netrc: netrc,
DockerConfigDir: dockerConfigDir,
KubeConfig: strings.Join([]string{kubeConfigA, kubeConfigB}, string(filepath.ListSeparator)),
}, nil)
wantPaths := append(filePaths, cloudSDKConfig)
for _, want := range normalizeProfilePaths(wantPaths) {
if !stringSliceContains(got, want) {
t.Errorf("credential deny paths = %#v, want override %q included", got, want)
}
}
}
+91 -12
View File
@@ -145,9 +145,10 @@ func permissionProfileReadRoots(workspaceRoot string, policy Policy, scope *Scop
}
// credentialDenyReadPaths returns default deny-read entries for well-known
// credential stores (~/.aws, ~/.config/gcloud, ~/.azure, and the file
// GOOGLE_APPLICATION_CREDENTIALS points to) so sandboxed commands cannot read
// cloud secrets under the read-all workspace posture. Two deliberate limits:
// credential stores so sandboxed commands cannot read secrets under the
// read-all workspace posture. This includes tool configuration files that can
// carry credentials and are now discoverable through the preserved caller
// environment. Two deliberate limits:
//
// - Windows is skipped: a non-empty profile DenyRead switches the Windows
// runner onto the capability-SID/ACL deny path and away from the
@@ -166,23 +167,100 @@ func credentialDenyReadPaths(policy Policy) []string {
// A failed home lookup only drops the home-based candidates; the
// GOOGLE_APPLICATION_CREDENTIALS target must be protected regardless.
home, _ := os.UserHomeDir()
return credentialDenyReadPathsIn(home, os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"), policy.AllowRead)
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
Home: home,
ConfigHome: os.Getenv("XDG_CONFIG_HOME"),
CloudSDKConfig: os.Getenv("CLOUDSDK_CONFIG"),
GoogleCredentials: os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"),
NPMUserConfig: firstNonEmpty(os.Getenv("NPM_CONFIG_USERCONFIG"), os.Getenv("npm_config_userconfig")),
GHConfigDir: os.Getenv("GH_CONFIG_DIR"),
Netrc: os.Getenv("NETRC"),
DockerConfigDir: os.Getenv("DOCKER_CONFIG"),
KubeConfig: os.Getenv("KUBECONFIG"),
}, policy.AllowRead)
}
// credentialDenyReadPathsIn is the pure core of credentialDenyReadPaths,
// separated so tests can exercise it against a synthetic home directory.
func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead []string) []string {
return credentialDenyReadPathsForEnvironment(credentialPathEnvironment{
Home: home,
GoogleCredentials: googleCredentials,
}, allowRead)
}
type credentialPathEnvironment struct {
Home string
ConfigHome string
CloudSDKConfig string
GoogleCredentials string
NPMUserConfig string
GHConfigDir string
Netrc string
DockerConfigDir string
KubeConfig string
}
func credentialDenyReadPathsForEnvironment(env credentialPathEnvironment, allowRead []string) []string {
var candidates []string
if home = strings.TrimSpace(home); home != "" {
home := strings.TrimSpace(env.Home)
configHome := strings.TrimSpace(env.ConfigHome)
if configHome == "" && home != "" {
configHome = filepath.Join(home, ".config")
}
if home != "" {
candidates = append(candidates,
filepath.Join(home, ".aws"),
filepath.Join(home, ".config", "gcloud"),
filepath.Join(home, ".azure"),
)
}
if target := strings.TrimSpace(googleCredentials); target != "" {
npmUserConfig := strings.TrimSpace(env.NPMUserConfig)
if npmUserConfig == "" && home != "" {
npmUserConfig = filepath.Join(home, ".npmrc")
}
netrc := strings.TrimSpace(env.Netrc)
if netrc == "" && home != "" {
netrc = filepath.Join(home, ".netrc")
}
dockerConfigDir := strings.TrimSpace(env.DockerConfigDir)
if dockerConfigDir == "" && home != "" {
dockerConfigDir = filepath.Join(home, ".docker")
}
candidates = append(candidates, npmUserConfig, netrc)
if dockerConfigDir != "" {
candidates = append(candidates, filepath.Join(dockerConfigDir, "config.json"))
}
if configHome != "" {
candidates = append(candidates,
filepath.Join(configHome, "zero"),
)
}
cloudSDKConfig := strings.TrimSpace(env.CloudSDKConfig)
if cloudSDKConfig == "" && configHome != "" {
cloudSDKConfig = filepath.Join(configHome, "gcloud")
}
if cloudSDKConfig != "" {
candidates = append(candidates, cloudSDKConfig)
}
ghConfigDir := strings.TrimSpace(env.GHConfigDir)
if ghConfigDir == "" && configHome != "" {
ghConfigDir = filepath.Join(configHome, "gh")
}
if ghConfigDir != "" {
candidates = append(candidates, filepath.Join(ghConfigDir, "hosts.yml"))
}
if target := strings.TrimSpace(env.GoogleCredentials); target != "" {
candidates = append(candidates, target)
}
kubeConfig := strings.TrimSpace(env.KubeConfig)
if kubeConfig == "" && home != "" {
kubeConfig = filepath.Join(home, ".kube", "config")
}
for _, path := range filepath.SplitList(kubeConfig) {
if path = strings.TrimSpace(path); path != "" {
candidates = append(candidates, path)
}
}
allowRoots := normalizeProfilePaths(allowRead)
out := make([]string, 0, len(candidates))
for _, path := range normalizeProfilePaths(candidates) {
@@ -206,11 +284,12 @@ func credentialDenyReadPathsIn(home string, googleCredentials string, allowRead
// userGitConfigReadPaths returns the user's global git config FILES so a
// sandboxed git can read identity and config (user.name/email, aliases) instead
// of failing with "unable to access ~/.gitconfig". It is deliberately the config
// files only — not the ~/.config/git directory, which can hold an XDG credential
// store — so credentials and the rest of HOME stay unreadable. Granted at the
// macOS-seatbelt read rule (not the cross-platform PermissionProfile) so the
// HOME-dependent paths don't leak into the platform-agnostic policy snapshot.
// of failing with "unable to access ~/.gitconfig". On macOS, where reads are
// allow-listed, granting only these files avoids exposing the surrounding
// configuration directory. Linux uses a read-all profile with explicit
// credential deny rules. The paths are granted at the macOS seatbelt read rule
// rather than the cross-platform PermissionProfile so HOME-dependent paths
// don't leak into the platform-agnostic policy snapshot.
func userGitConfigReadPaths() []string {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
@@ -266,74 +266,6 @@ 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 {
t.Helper()
if path := os.Getenv(envKey); path != "" {
+8 -22
View File
@@ -27,13 +27,10 @@ var fallbackSandboxRuntimes = struct {
}{roots: make(map[string]string)}
type SandboxRuntime struct {
Root string `json:"root,omitempty"`
Home string `json:"home,omitempty"`
Cache string `json:"cache,omitempty"`
Config string `json:"config,omitempty"`
Data string `json:"data,omitempty"`
State string `json:"state,omitempty"`
Temp string `json:"temp,omitempty"`
Root string `json:"root,omitempty"`
Cache string `json:"cache,omitempty"`
Data string `json:"data,omitempty"`
Temp string `json:"temp,omitempty"`
}
func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error) {
@@ -75,21 +72,15 @@ func prepareSandboxRuntime(workspaceRoot string) (SandboxRuntime, func(), error)
}
}()
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
Root: root,
Cache: filepath.Join(root, "cache"),
Data: filepath.Join(root, "data"),
Temp: filepath.Join(root, "tmp"),
}
directories := []string{
runtimeState.Root,
runtimeState.Home,
runtimeState.Cache,
runtimeState.Config,
runtimeState.Data,
runtimeState.State,
runtimeState.Temp,
filepath.Join(runtimeState.Cache, "npm"),
filepath.Join(runtimeState.Cache, "yarn"),
@@ -207,16 +198,11 @@ func sandboxRuntimeEnvironment(env []string, runtimeState *SandboxRuntime) []str
return env
}
overrides := []string{
"HOME=" + runtimeState.Home,
"XDG_CACHE_HOME=" + runtimeState.Cache,
"XDG_CONFIG_HOME=" + runtimeState.Config,
"XDG_DATA_HOME=" + runtimeState.Data,
"XDG_STATE_HOME=" + runtimeState.State,
"TMPDIR=" + runtimeState.Temp,
"TMP=" + runtimeState.Temp,
"TEMP=" + runtimeState.Temp,
"npm_config_cache=" + filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG=" + filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER=" + filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME=" + filepath.Join(runtimeState.Cache, "corepack"),
"PIP_CACHE_DIR=" + filepath.Join(runtimeState.Cache, "pip"),
+143 -22
View File
@@ -2,7 +2,9 @@ package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -22,12 +24,18 @@ func TestPrepareSandboxRuntimeStaysOutsideWorkspace(t *testing.T) {
if pathWithinRoot(workspace, runtimeState.Root) {
t.Fatalf("runtime root %q must stay outside workspace %q", runtimeState.Root, workspace)
}
for _, path := range []string{runtimeState.Home, runtimeState.Cache, runtimeState.Config, runtimeState.Data, runtimeState.State, runtimeState.Temp} {
for _, path := range []string{runtimeState.Cache, runtimeState.Data, runtimeState.Temp, filepath.Join(runtimeState.Data, "cargo")} {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Fatalf("managed runtime directory %q was not prepared: %v", path, err)
}
}
for _, name := range []string{"home", "config", "state"} {
path := filepath.Join(runtimeState.Root, name)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("obsolete synthetic runtime directory %q exists: %v", path, err)
}
}
}
func TestPrepareSandboxRuntimeCleansExpiredSibling(t *testing.T) {
@@ -116,34 +124,63 @@ func TestCleanupSandboxRuntimeSkipsActiveLease(t *testing.T) {
}
}
func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
func TestSandboxRuntimeEnvironmentPreservesUserConfiguration(t *testing.T) {
root := filepath.Join(t.TempDir(), "runtime")
runtimeState := SandboxRuntime{
Root: root,
Home: filepath.Join(root, "home"),
Cache: filepath.Join(root, "cache"),
Config: filepath.Join(root, "config"),
Data: filepath.Join(root, "data"),
State: filepath.Join(root, "state"),
Temp: filepath.Join(root, "tmp"),
Root: root,
Cache: filepath.Join(root, "cache"),
Data: filepath.Join(root, "data"),
Temp: filepath.Join(root, "tmp"),
}
env := sandboxRuntimeEnvironment([]string{
"HOME=/workspace",
"HOME=/home/user",
"XDG_CACHE_HOME=/host/cache",
"XDG_CONFIG_HOME=/home/user/.config",
"XDG_DATA_HOME=/home/user/.local/share",
"XDG_STATE_HOME=/home/user/.local/state",
"NPM_CONFIG_USERCONFIG=/home/user/.npmrc",
"PATH=/usr/bin",
}, &runtimeState)
for key, want := range map[string]string{
"HOME": runtimeState.Home,
"XDG_CACHE_HOME": runtimeState.Cache,
"XDG_CONFIG_HOME": runtimeState.Config,
"XDG_DATA_HOME": runtimeState.Data,
"XDG_STATE_HOME": runtimeState.State,
"TMPDIR": runtimeState.Temp,
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
"NPM_CONFIG_USERCONFIG": filepath.Join(runtimeState.Config, "npmrc"),
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
"HOME": "/home/user",
"XDG_CONFIG_HOME": "/home/user/.config",
"XDG_DATA_HOME": "/home/user/.local/share",
"XDG_STATE_HOME": "/home/user/.local/state",
"NPM_CONFIG_USERCONFIG": "/home/user/.npmrc",
} {
if got := envListValue(env, key, ""); got != want {
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
}
}
}
func TestSandboxRuntimeEnvironmentUsesManagedWritableState(t *testing.T) {
root := filepath.Join(t.TempDir(), "runtime")
runtimeState := SandboxRuntime{
Root: root,
Cache: filepath.Join(root, "cache"),
Data: filepath.Join(root, "data"),
Temp: filepath.Join(root, "tmp"),
}
env := sandboxRuntimeEnvironment([]string{
"XDG_CACHE_HOME=/host/cache",
"TMPDIR=/host/tmp",
"PATH=/usr/bin",
}, &runtimeState)
for key, want := range map[string]string{
"XDG_CACHE_HOME": runtimeState.Cache,
"TMPDIR": runtimeState.Temp,
"TMP": runtimeState.Temp,
"TEMP": runtimeState.Temp,
"npm_config_cache": filepath.Join(runtimeState.Cache, "npm"),
"YARN_CACHE_FOLDER": filepath.Join(runtimeState.Cache, "yarn"),
"COREPACK_HOME": filepath.Join(runtimeState.Cache, "corepack"),
"PIP_CACHE_DIR": filepath.Join(runtimeState.Cache, "pip"),
"GOCACHE": filepath.Join(runtimeState.Cache, "go-build"),
"GOMODCACHE": filepath.Join(runtimeState.Data, "go-mod"),
"CARGO_HOME": filepath.Join(runtimeState.Data, "cargo"),
} {
if got := envListValue(env, key, ""); got != want {
t.Fatalf("%s = %q, want %q; env=%#v", key, got, want, env)
@@ -154,9 +191,93 @@ func TestSandboxRuntimeEnvironmentUsesManagedState(t *testing.T) {
}
}
func TestSandboxRuntimeEnvironmentPreservesGitGlobalIgnore(t *testing.T) {
git, err := exec.LookPath("git")
if err != nil {
t.Skip("git is unavailable")
}
root := t.TempDir()
home := filepath.Join(root, "home")
configHome := filepath.Join(home, ".config")
repository := filepath.Join(root, "repository")
runtimeRoot := filepath.Join(root, "runtime")
for _, directory := range []string{
filepath.Join(configHome, "git"),
repository,
filepath.Join(runtimeRoot, "cache"),
filepath.Join(runtimeRoot, "data"),
filepath.Join(runtimeRoot, "tmp"),
} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(configHome, "git", "ignore"), []byte("ignored.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repository, "ignored.txt"), []byte("ignored\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repository, "visible.txt"), []byte("visible\n"), 0o600); err != nil {
t.Fatal(err)
}
runtimeState := SandboxRuntime{
Root: runtimeRoot,
Cache: filepath.Join(runtimeRoot, "cache"),
Data: filepath.Join(runtimeRoot, "data"),
Temp: filepath.Join(runtimeRoot, "tmp"),
}
inherited := append(os.Environ(),
"GIT_CONFIG_GLOBAL="+filepath.Join(root, "missing-global-config"),
"GIT_DIR="+filepath.Join(root, "wrong-repository"),
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=core.excludesFile",
"GIT_CONFIG_VALUE_0="+filepath.Join(root, "missing-ignore"),
)
env := sandboxRuntimeEnvironment(upsertEnvList(withoutGitEnvironmentOverrides(inherited),
"HOME="+home,
"XDG_CONFIG_HOME="+configHome,
"GIT_CONFIG_NOSYSTEM=1",
"GIT_TERMINAL_PROMPT=0",
), &runtimeState)
runGit := func(args ...string) string {
t.Helper()
command := exec.Command(git, args...)
command.Dir = repository
command.Env = env
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
}
return string(output)
}
runGit("init", "--quiet")
status := runGit("status", "--short", "--untracked-files=all")
if strings.Contains(status, "ignored.txt") {
t.Fatalf("global ignore was not honored:\n%s", status)
}
if !strings.Contains(status, "visible.txt") {
t.Fatalf("non-ignored file is missing from status:\n%s", status)
}
}
func withoutGitEnvironmentOverrides(env []string) []string {
out := make([]string, 0, len(env))
for _, value := range env {
key, _, ok := strings.Cut(value, "=")
if ok && strings.HasPrefix(strings.ToUpper(key), "GIT_") {
continue
}
out = append(out, value)
}
return out
}
func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
workspace := t.TempDir()
cacheRoot := t.TempDir()
t.Setenv("HOME", filepath.Join(t.TempDir(), "home"))
original := sandboxUserCacheDir
sandboxUserCacheDir = func() (string, error) { return cacheRoot, nil }
t.Cleanup(func() { sandboxUserCacheDir = original })
@@ -189,8 +310,8 @@ func TestEngineCommandPlanCarriesManagedRuntime(t *testing.T) {
} else if !inUse {
t.Fatal("command plan runtime must be marked in use")
}
if got := envListValue(plan.Env, "HOME", ""); got != plan.PermissionProfile.Runtime.Home {
t.Fatalf("HOME = %q, want managed home %q", got, plan.PermissionProfile.Runtime.Home)
if got := envListValue(plan.Env, "HOME", ""); got != os.Getenv("HOME") {
t.Fatalf("HOME = %q, want caller home %q", got, os.Getenv("HOME"))
}
foundWriteRoot := false
for _, root := range plan.PermissionProfile.FileSystem.WriteRoots {
+14 -59
View File
@@ -430,70 +430,25 @@ func shellDashCPayload(program string, fields []string) string {
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
// leading command-substitution markers, removes any directory prefix (so
// /usr/bin/vim and C:\tools\vim.exe match "vim"), removes Windows executable
// 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.
// /usr/bin/vim and C:\tools\vim.exe match "vim"), and lowercases. This closes
// path/quote/substitution evasions of the detector.
func normalizeProgramToken(field string) string {
token := strings.TrimSpace(field)
token = strings.TrimLeft(token, "$(")
token = strings.TrimRight(token, ")")
// PowerShell commonly invokes drive-letter, UNC, and relative paths. Extract
// their basename while backslashes still carry path-separator meaning;
// removing them first would turn C:\tools\curl.exe into c:toolscurl.exe and
// bypass the canonical curl risk entry.
pathToken := stripChars(token, "\"'`")
if windowsPathBasename, ok := windowsExecutablePathBasename(pathToken); ok {
token = windowsPathBasename
} else {
// Strip shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token — surrounding, embedded, or as a mid-word escape
// — so "vim", v"i"m, 'v'im, and vi\m all collapse to the program name.
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:]
}
// Strip shell quoting/escaping characters (", ', `, \) wherever they appear
// in the token — surrounding, embedded, or as a mid-word escape — so
// "vim", v"i"m, 'v'im, and vi\m all collapse to the program name. This is
// done BEFORE the directory-prefix trim so an escape can't masquerade as a
// path separator (e.g. vi\m must become vim, not m).
token = stripChars(token, "\"'`\\")
// Strip a directory prefix so /usr/bin/vim reduces to the basename. (A
// Windows-style backslash path separator is already removed above, so only
// the POSIX separator remains to split on.)
if i := strings.LastIndex(token, "/"); i >= 0 {
token = token[i+1:]
}
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
return strings.ToLower(token)
}
// stripChars returns s with every rune in cutset removed.
+2 -15
View File
@@ -203,24 +203,11 @@ func dedupeScopeRoots(roots []string) []string {
// only on outside_workspace results. The returned block always carries
// the caller's original requestedPath.
func (s *Scope) validate(requestedPath string) *pathBlock {
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.Roots()), requestedPath, SideEffectWrite)
return s.validateAgainstRoots(requestedPath, s.Roots())
}
func (s *Scope) validateRead(requestedPath string) *pathBlock {
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.ReadRoots()), requestedPath, SideEffectRead)
}
func withOutsideWorkspaceAccessReason(block *pathBlock, requestedPath string, sideEffect SideEffect) *pathBlock {
if block == nil || block.Code != BlockOutsideWorkspace || !strings.Contains(block.Reason, " is outside the workspace") {
return block
}
switch sideEffect {
case SideEffectRead:
block.Reason = fmt.Sprintf("Reading %s requires access outside the workspace.", requestedPath)
case SideEffectWrite, SideEffectOutOfWorkspace:
block.Reason = fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", requestedPath)
}
return block
return s.validateAgainstRoots(requestedPath, s.ReadRoots())
}
func (s *Scope) validateAgainstRoots(requestedPath string, roots []string) *pathBlock {
-40
View File
@@ -1,7 +1,6 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -280,45 +279,6 @@ func TestScopeValidateAllowsAnyRootButRelativeOnlyWorkspace(t *testing.T) {
}
}
func TestScopeValidateReadExplainsReadAccessOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
block := scope.validateRead(outside)
if block == nil {
t.Fatal("validateRead(outside workspace) = nil, want block")
}
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if block.Reason != want {
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
}
if strings.Contains(block.Reason, "--add-dir") || strings.Contains(block.Reason, "writes") {
t.Fatalf("read block reason must not suggest write access: %q", block.Reason)
}
}
func TestScopeValidateWriteExplainsWriteAccessOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
block := scope.validate(outside)
if block == nil {
t.Fatal("validate(outside workspace) = nil, want block")
}
want := fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", outside)
if block.Reason != want {
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
}
}
func TestScopeValidateKeepsSymlinkTraversalProtection(t *testing.T) {
workspace := t.TempDir()
outside := outsideDefaultTempPath(workspace, "symlink-target")
+27 -9
View File
@@ -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
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
@@ -293,19 +293,23 @@ func shellIssueBlockResult(issue shellIssue) Result {
}
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
// the fallback. Only cmd.exe needs the raw command-line override.
// commandText. On Windows, when the command is not wrapped by the sandbox
// engine (plan.Wrapped == false), it also overrides the child's raw command
// 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) {
hostShell := detectShellRuntime(runtime.GOOS)
spec := zeroSandbox.CommandSpec{
Name: hostShell.Executable,
Args: hostShell.arguments(commandText),
Name: shellExecutable(),
Args: shellArguments(commandText),
Dir: absoluteCwd,
}
if engine != nil {
command, plan, err := engine.CommandContext(ctx, spec)
if err == nil {
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
}
return command, plan, err
}
@@ -329,7 +333,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
return command, plan, nil
}
@@ -381,6 +385,20 @@ 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 {
if err == nil {
return 0
@@ -603,7 +621,7 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+3 -3
View File
@@ -36,6 +36,6 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine is a no-op outside Windows.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
}
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
// cmd.exe command-line-parsing quirk to work around here.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
+9 -6
View File
@@ -31,12 +31,15 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine overrides the cmd.exe fallback's raw child
// command line so commandText reaches cmd.exe unescaped. PowerShell consumes
// ordinary argv quoting and must not take this path. Wrapped commands are
// handled after unwrapping by the Windows sandbox runner.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
if wrapped || !cmdFallback {
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
// is true: the sandbox engine then routes execution through a separate
// zero-windows-command-runner process, which builds its own child command
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
// inheriting whatever this outer exec.Cmd is configured with.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
if wrapped {
return
}
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
+12 -31
View File
@@ -124,15 +124,11 @@ func TestBashToolDescribesHostShellSyntax(t *testing.T) {
}
if runtime.GOOS == "windows" {
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
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)
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in bash description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in bash description, got %q", description)
}
return
}
@@ -459,14 +455,13 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
wantExitCode := strconv.Itoa(helperFailureExitCode())
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: " + wantExitCode} {
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
}
}
if result.Meta["exit_code"] != wantExitCode {
t.Fatalf("expected exit_code metadata %s, got %q", wantExitCode, result.Meta["exit_code"])
if result.Meta["exit_code"] != "7" {
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
}
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)
@@ -583,16 +578,10 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
}
registry := NewRegistry()
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) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": msysCommand,
"command": "cat somefile.txt",
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
@@ -605,7 +594,7 @@ func TestBashToolRequireEscalatedMsysGuard(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{
"command": msysCommand,
"command": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -659,9 +648,8 @@ func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T)
"command": command,
})
wantExitCode := strconv.Itoa(helperFailureExitCode())
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.Status != StatusError || result.Meta["exit_code"] != "7" {
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
}
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)
@@ -872,9 +860,6 @@ func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
if runtime.GOOS != "windows" {
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()
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
@@ -896,10 +881,6 @@ func helperCommand(name string) string {
return executable + " --zero-bash-helper " + name
}
func helperFailureExitCode() int {
return 7
}
func shellQuote(value string) string {
if runtime.GOOS == "windows" {
return value
+2 -2
View File
@@ -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
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
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)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
+30 -21
View File
@@ -76,15 +76,11 @@ func TestExecCommandToolDescribesHostShellSyntax(t *testing.T) {
description := strings.ToLower(strings.Join(descriptionParts, " "))
if runtime.GOOS == "windows" {
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
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)
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in exec_command description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in exec_command description, got %q", description)
}
}
}
@@ -196,15 +192,9 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
Policy: sandbox.DefaultPolicy(),
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{
"cmd": msysCommand,
"cmd": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -214,8 +204,8 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, the command actually runs, and its real, PATH-dependent output
// could otherwise trip the unrelated
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
@@ -294,9 +284,8 @@ func TestExecCommandApplicationFailureHasTypedOutcome(t *testing.T) {
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)
}
wantExitCode := helperFailureExitCode()
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != wantExitCode {
t.Fatalf("execution exit = %#v, want code %d", result.ExecutionOutcome.Exit, wantExitCode)
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != 7 {
t.Fatalf("execution exit = %#v, want code 7", result.ExecutionOutcome.Exit)
}
}
@@ -327,6 +316,26 @@ func TestExecCommandUsesStructuredAdapterDenial(t *testing.T) {
}
}
func TestExecCommandDoesNotInferNativeSandboxDenialFromOutput(t *testing.T) {
result := execToolResult(execToolResultInput{
commandText: "touch \"$HOME/probe\"",
output: "touch: cannot touch '/home/user/probe': Read-only file system",
exited: true,
exitCode: 1,
enforcement: execution.Enforcement{Backend: string(sandbox.BackendLinuxBwrap), Level: string(sandbox.EnforcementNative)},
})
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)
}
if result.ExecutionOutcome.Denial != nil {
t.Fatalf("command output created a typed denial: %#v", result.ExecutionOutcome.Denial)
}
if result.Meta[SandboxLikelyDeniedMeta] == "true" {
t.Fatalf("command output created sandbox-denial metadata: %#v", result.Meta)
}
}
func executionRequestHasCapability(request execution.Request, kind execution.CapabilityKind, scope string) bool {
for _, capability := range request.Capabilities {
if capability.Kind == kind && (scope == "" || capability.Scope == scope) {
+13 -300
View File
@@ -1,33 +1,15 @@
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 {
@@ -38,18 +20,7 @@ type shellIssue struct {
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
)
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."
// windowsMsysProneNames is the single source of truth for POSIX coreutil and
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
@@ -83,139 +54,24 @@ var (
)
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: "cmd.exe", Syntax: "Windows cmd.exe"}
}
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
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh"}
}
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)
runtime := 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."
}
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 := "Uses " + runtime.Syntax + " syntax."
if 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 {
@@ -316,15 +172,7 @@ func msysProneCommandWord(word string) bool {
}
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" {
if goos != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
@@ -336,24 +184,7 @@ func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shel
// 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.",
@@ -367,128 +198,18 @@ func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shel
// 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 {
for _, segment := range windowsCommandSegments(trimmed) {
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
@@ -535,15 +256,7 @@ func stripCmdCaretEscapes(command string) string {
// 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" {
if goos != "windows" {
return nil
}
lower := strings.ToLower(output)
@@ -553,8 +266,8 @@ func detectShellOutputIssueForRuntime(output string, shell shellRuntime) *shellI
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")) {
if 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.",
-208
View File
@@ -1,218 +1,10 @@
package tools
import (
"errors"
"os/exec"
"runtime"
"strings"
"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) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
+12 -6
View File
@@ -2,11 +2,12 @@ package tui
import (
"context"
"os/exec"
"runtime"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
@@ -29,7 +30,8 @@ func runBashEscape(cwd, command string) tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), bashEscapeTimeout)
defer cancel()
cmd := tools.NewHostShellCommandContext(ctx, command)
name, shellArgs := escapeShell(command)
cmd := exec.CommandContext(ctx, name, shellArgs...)
if strings.TrimSpace(cwd) != "" {
cmd.Dir = cwd
}
@@ -49,11 +51,15 @@ func runBashEscape(cwd, command string) tea.Cmd {
}
}
// escapeShell returns the platform shell and arguments for tests and display.
// Execution itself uses tools.NewHostShellCommandContext so the cmd.exe
// fallback receives its required raw Windows command line.
// escapeShell returns the platform shell and arguments for a "!cmd" escape,
// matching the agent bash tool: cmd.exe on Windows, /bin/sh elsewhere. Hardcoding
// "bash" broke the escape on stock Windows (no bash on PATH) and anywhere bash is
// not installed at a predictable path.
func escapeShell(command string) (string, []string) {
return tools.HostShellCommand(command)
if runtime.GOOS == "windows" {
return "cmd.exe", []string{"/d", "/s", "/c", command}
}
return "/bin/sh", []string{"-c", command}
}
func appendNote(text, note string) string {
+1 -11
View File
@@ -2212,16 +2212,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
promptEvent := permissionEventFromRequest(msg.request)
promptRow := permissionTranscriptRow(promptEvent)
// The focused modal owns the actionable reason while the decision is
// pending. Keep only structured block context in the transcript row so
// the same explanation is not shown immediately above the card.
if promptEvent.Block == nil {
promptRow.detail = ""
} else {
promptRow.detail = permissionBlockDetail(promptEvent)
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
m.pendingPermission = &pendingPermissionPrompt{
@@ -4816,7 +4807,6 @@ func (m model) beginRun(cancel context.CancelFunc) model {
// suppressed by a stale preference.
m.sidebarHidden = false
m.turnStartedAt = m.now()
m.lastStreamActivity = m.turnStartedAt
m.turnStreamedRunes = 0
m.spinnerTicking = true
return m
-31
View File
@@ -1606,9 +1606,6 @@ func TestPermissionRequestShowsFocusedPrompt(t *testing.T) {
if strings.Contains(view, "risk:") || strings.Contains(view, "risk=") {
t.Fatalf("focused permission prompt must not render risk labels, got %q", view)
}
if count := strings.Count(view, request.Reason); count != 1 {
t.Fatalf("focused permission reason rendered %d times, want once:\n%s", count, view)
}
}
func TestPermissionPromptChoicesResolveDecision(t *testing.T) {
@@ -1723,34 +1720,6 @@ func TestPermissionRowRendersSandboxBlocks(t *testing.T) {
}
}
func TestPermissionRowRendersIdenticalEventAndBlockReasonOnce(t *testing.T) {
const reason = "Reading /home/dev/.nvm requires access outside the workspace."
event := agent.PermissionEvent{
ToolCallID: "call_read",
ToolName: "list_directory",
Action: agent.PermissionActionDeny,
DecisionAction: agent.PermissionDecisionDeny,
SideEffect: "read",
Reason: reason,
Scope: "/home/dev/.nvm",
Block: &sandbox.Block{
Code: sandbox.BlockOutsideWorkspace,
Path: "/home/dev/.nvm",
Reason: reason,
},
}
row := permissionTranscriptRow(event)
if count := strings.Count(event.Reason+"\n"+row.detail, reason); count != 1 {
t.Fatalf("permission reason represented %d times, want once: detail=%q", count, row.detail)
}
for _, want := range []string{"denied by user", "outside workspace", "path: /home/dev/.nvm"} {
if !strings.Contains(row.detail, want) {
t.Fatalf("permission detail missing %q: %q", want, row.detail)
}
}
}
func TestAppendTranscriptRowDedupesRuntimeRowsByID(t *testing.T) {
event := agent.PermissionEvent{
ToolCallID: "call_1",
-23
View File
@@ -126,29 +126,6 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
}
}
func TestPermissionPromptWrapsLongReasonWithoutTruncating(t *testing.T) {
request := agent.PermissionRequest{
ToolName: "list_directory",
SideEffect: "read",
Reason: "Reading /home/dev/projects/a-very-long-directory-name/with/nested/configuration requires access outside the workspace.",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
},
}
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 60)
got := plainRender(t, card)
for _, want := range []string{"Reading", "/home/dev/projects", "requires access outside the workspace."} {
if !strings.Contains(got, want) {
t.Fatalf("permission card truncated reason; missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "…") {
t.Fatalf("permission card must wrap the decision reason instead of truncating it:\n%s", got)
}
}
func TestPermissionOptionsExposePersistentCommandPrefixApproval(t *testing.T) {
request := agent.PermissionRequest{
ToolName: "bash",
+1 -7
View File
@@ -1131,13 +1131,7 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
}
lines := []string{top, body}
if reason := permissionDisplayReason(request.Reason); reason != "" {
reasonWidth := width
if widthTier(width) != tierTiny {
reasonWidth = maxInt(1, width-4)
}
for _, line := range wrapPlainText(reason, reasonWidth) {
lines = append(lines, fill(zeroTheme.muted).Render(line))
}
lines = append(lines, fill(zeroTheme.muted).Render(reason))
}
// Surface exactly what the grant covers (file/dir/host) so "always" is a
// clear, bounded choice rather than a blind tool-wide yes.
+3 -7
View File
@@ -398,10 +398,8 @@ func permissionDetailText(event agent.PermissionEvent) string {
if event.GrantMatched {
parts = append(parts, "approved by saved permission")
}
if event.Action == agent.PermissionActionPrompt {
if reason := permissionDisplayReason(event.Reason); reason != "" {
parts = append(parts, reason)
}
if event.Reason != "" {
parts = append(parts, permissionDisplayReason(event.Reason))
}
if event.Block != nil {
parts = append(parts, permissionBlockDetail(event))
@@ -440,9 +438,7 @@ func permissionBlockDetail(event agent.PermissionEvent) string {
if path := strings.TrimSpace(event.Block.Path); path != "" {
parts = append(parts, "path: "+path)
}
reason := permissionDisplayReason(event.Block.Reason)
eventReason := permissionDisplayReason(event.Reason)
if reason != "" && reason != eventReason {
if reason := permissionDisplayReason(event.Block.Reason); reason != "" {
parts = append(parts, reason)
}
return strings.Join(parts, " ")
+6 -6
View File
@@ -42,19 +42,19 @@ func TestBashEscapeGatedByPermissionMode(t *testing.T) {
}
}
// The "!" escape must use the same detected host shell as the agent tools.
// The "!" escape must use the platform shell (cmd.exe on Windows, /bin/sh
// elsewhere), not a hardcoded "bash" that is absent on stock Windows.
func TestEscapeShellWrapsCommandForPlatform(t *testing.T) {
name, args := escapeShell("echo hi")
if name == "" {
t.Fatal("escapeShell returned an empty executable")
}
if len(args) == 0 || !strings.Contains(args[len(args)-1], "echo hi") {
t.Fatalf("escapeShell args = %v, want the command in the final arg", args)
if len(args) == 0 || args[len(args)-1] != "echo hi" {
t.Fatalf("escapeShell args = %v, want the command as the final arg", args)
}
if runtime.GOOS == "windows" {
lowerName := strings.ToLower(filepath.Base(name))
if lowerName != "pwsh.exe" && lowerName != "powershell.exe" && lowerName != "cmd.exe" {
t.Fatalf("windows shell = %q %v, want PowerShell or cmd.exe fallback", name, args)
if name != "cmd.exe" || args[0] != "/d" {
t.Fatalf("windows shell = %q %v, want cmd.exe /d /s /c", name, args)
}
} else if name != "/bin/sh" || args[0] != "-c" {
t.Fatalf("unix shell = %q %v, want /bin/sh -c", name, args)
-14
View File
@@ -106,20 +106,6 @@ func TestQuietGenerationHint(t *testing.T) {
}
}
func TestBeginRunResetsQuietGenerationClock(t *testing.T) {
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
m := model{
now: func() time.Time { return now },
lastStreamActivity: now.Add(-time.Minute),
}
m = m.beginRun(nil)
if got := m.quietGenerationHint(); got != "" {
t.Fatalf("new run inherited a stale quiet-generation warning: %q", got)
}
}
// TestQuietGenerationHintEscalatesPastHalfIdleTimeout: a heartbeating-but-
// silent stream (chatgpt/gpt-5.x, ollama reasoning models — see
// providerio.ErrStreamStalled) and a genuinely healthy-but-slow one look
+1 -1
View File
@@ -52,7 +52,7 @@ case "$(go env GOOS)" in
ZERO_SANDBOX_REAL_SMOKE=1 \
ZERO_WINDOWS_COMMAND_RUNNER_EXE="$tmpdir/windows-zero-windows-command-runner.exe" \
ZERO_WINDOWS_SANDBOX_SETUP_EXE="$tmpdir/windows-zero-windows-sandbox-setup.exe" \
go test ./internal/sandbox -run 'TestWindowsRestrictedTokenRealSandboxSmoke|TestWindowsRestrictedTokenPowerShell' -count=1
go test ./internal/sandbox -run TestWindowsRestrictedTokenRealSandboxSmoke -count=1
;;
*)
echo "No real sandbox smoke is defined for this host platform."