Files
zero/internal/tools/shell_runtime.go
KRATOS b776a9281f fix(sandbox): grant ancestor metadata so cd into the workspace works (#302)
* fix(sandbox): grant ancestor metadata so cd into the workspace works

macOS seatbelt granted file-read* on each read root via (subpath "<root>"),
which covers the root and its descendants but NOT its parent directories. An
absolute cd (and any path the kernel canonicalises) must stat each ancestor to
traverse to a deeply-nested workspace like /Users/me/Downloads/app — those
parents weren't granted, so seatbelt denied the chdir and surfaced it as the
misleading 'cd: <path>: Not a directory' (ENOTDIR). This blocked the agent from
cd-ing into its own workspace (e.g. 'cd /abs/workspace && go test').

Fix: grant file-read-metadata + file-test-existence on the path-ancestors of
each read root (metadata only — ancestor contents stay unreadable). Same pattern
already used for /opt/homebrew, /usr/local, /System/Volumes/Data/private.

Verified end-to-end: 'cd /abs/workspace && ...' now succeeds under the real
generated profile. go build/vet/test green.

* fix(sandbox): skip path-ancestors for filesystem roots (invalid SBPL)

Follow-up to the ancestor-metadata traversal fix. A read root of "/" has no
ancestors, and (path-ancestors "/") is invalid SBPL — sandbox-exec aborts with
exit 65 and the sandboxed command can't launch at all. This was reachable via the
compat builder (ReadRoots=["/"] flipped to restricted under EnforceWorkspace) and
any degenerate "/" workspace.

seatbeltAncestorMetadataRule now skips any root that is its own parent
(filepath.Dir(clean)==clean), neutralizing "/" and volume roots regardless of
entry point. Verified: the compat profile now compiles under sandbox-exec (exit
0). Found by adversarial verification of the prior commit.

* fix(sandbox): grant the CLT toolchain so git/clang/make work

On macOS, /usr/bin/git (and clang, make, etc.) are thin stubs that resolve the
real binary under the active developer dir — usually
/Library/Developer/CommandLineTools. The sandbox's platform read roots didn't
include it, so the stub couldn't reach the real tool and failed with the
misleading 'xcode-select: No developer tools were found' error. Add
/Library/Developer (read-only) to the platform read roots.

* fix(sandbox): allow reading the user's global git config

git reads identity/config from the user's global git config, but the sandbox
confines reads away from HOME, so it failed with 'unable to access ~/.gitconfig:
Operation not permitted'. Grant read-only on the git config FILES specifically
(~/.gitconfig and ~/.config/git/config) — not the ~/.config/git directory, which
can hold an XDG credential store — so git runs while credentials and the rest of
HOME stay unreadable. The path-ancestors traversal grant covers reaching them.

* fix(sandbox): let the agent terminate user-owned processes on request

The seatbelt profile only allowed signaling the agent's own process group
((target self) (target pgrp)), so a process started by a PREVIOUS session — e.g.
a dev server still listening on a port — was in a different group and could not
be killed. The agent would thrash through kill/pkill/osascript/sudo, all denied,
and never stop the process the user asked it to terminate.

Relax to (allow signal). The kernel still enforces UID ownership: a sandboxed
command runs as the user, so it can only signal the user's own processes, never
root's or another user's. Verified end-to-end: a sandboxed command now kills a
different-group user-owned process under the real generated profile.

* fix(sandbox): let the agent find processes + guide it to working tools

Pairs with the signal fix (8b5018d). Two more gaps blocked process management:
- process-info was scoped to (target same-sandbox), so the agent couldn't even
  inspect processes outside its own sandbox. Relaxed to (allow process-info*).
- `ps` is setuid root and macOS sandbox-exec refuses to exec setuid binaries
  (privilege-escalation guard) — unfixable via profile. `pgrep` needs a blocked
  system service. So the agent kept thrashing on ps/pgrep/os.kill.

Add macOS shell guidance pointing the model at the tools that DO work: lsof to
find a process, kill to stop it. With the signal grant, lsof+kill now manages
processes reliably (verified end-to-end).

* fix(sandbox): grant git config at the seatbelt rule, not the shared profile

The git-config read paths (~/.gitconfig, ~/.config/git/config) were added to the
cross-platform permissionProfileReadRoots, so they leaked HOME-dependent absolute
paths into the platform-agnostic 'sandbox policy --json' snapshot and broke the
golden test on every OS (machine-specific paths + Windows separators).

Move the grant to seatbeltReadRule (macOS only, where reads are actually
confined): the PermissionProfile stays platform-agnostic so the golden is stable,
and the seatbelt profile still grants the config files + their ancestor metadata
for traversal. Verified: golden test green, full 'go test ./...' green, and git
still reads ~/.gitconfig under the real generated profile.

* fix(sandbox): grant ancestor metadata for platform read roots too

Addresses CodeRabbit review on #302. seatbeltAncestorMetadataRule was passed only
fs.ReadRoots + gitConfig, on the assumption that platform read roots are top-level
or already covered. /Library/Developer (the CLT toolchain) breaks that: it needs
/Library stat-able as an ancestor, so a chdir-style traversal into it (cd
/Library/Developer/CommandLineTools) failed with ENOTDIR even though reads
succeeded (exec doesn't require ancestor-stat the way chdir does).

Include the platform read roots in ancestorRoots when IncludePlatformRoots is set.
Verified: cd into the CLT toolchain now succeeds under the real profile; golden +
sandbox tests green. Regression test added.
2026-06-23 11:12:11 +05:30

90 lines
2.9 KiB
Go

package tools
import (
"regexp"
"strings"
)
type shellRuntime struct {
GOOS string
Executable string
Syntax string
}
type shellIssue struct {
Kind string
Message string
Suggestion string
}
var (
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
)
func detectShellRuntime(goos string) shellRuntime {
if goos == "windows" {
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe"}
}
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh"}
}
func shellGuidanceForGOOS(goos string) string {
runtime := detectShellRuntime(goos)
if goos == "windows" {
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
}
guidance := "Uses " + runtime.Syntax + " syntax."
if goos == "darwin" {
// `ps` is setuid root and cannot run under the macOS sandbox; `pgrep` needs a
// blocked system service. Point the model at the tools that DO work so it
// doesn't waste turns: lsof to find a process, kill to stop it.
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
}
func detectShellCommandIssue(command string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
if windowsBashStyleCDPattern.MatchString(trimmed) ||
windowsLSCommandPattern.MatchString(trimmed) {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
}
}
return nil
}
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
if goos != "windows" {
return nil
}
haystack := strings.ToLower(command + "\n" + output)
if strings.Contains(haystack, "the syntax of the command is incorrect") ||
strings.Contains(haystack, "is not recognized as an internal or external command") {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
Suggestion: "Translate the command to Windows cmd.exe syntax, set the bash tool cwd argument instead of running cd, or prefer native Zero tools for file inspection.",
}
}
return nil
}
func appendShellIssueHint(output string, issue shellIssue) string {
output = strings.TrimRight(output, "\r\n")
hint := "[zero] shell issue: " + issue.Message
if strings.TrimSpace(issue.Suggestion) != "" {
hint += "\nSuggestion: " + issue.Suggestion
}
if output == "" {
return hint
}
return output + "\n" + hint
}