mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(sandbox): retire recorded approvals through a policy bump Ahead of tightening which command prefixes may be approved, make the upgrade path safe. A grant already on disk that the narrower rules reject fails validation on read, and that path rejects the entire grants file rather than the one entry. Every caller treats a read error as "no grant" (engine.go LookupCommandPrefix, ApprovedCommandPrefixes, and the Lookup in Evaluate), so one stale prefix would silently disable the user's persisted deny grants too, with the error text never reaching them. Bump the approval policy version instead and let the store's existing migration handle it: it backs the file up, keeps deny grants, invalidates prior approvals, and reports the counts once through the startup notice that both frontends already print. That is what the mechanism was built for. The v1-migration test pinned the policy version as a literal, so it now derives the expected value from the constant and will not need editing on the next bump. * fix(sandbox): normalize launcher names before the command-prefix denylist unsafeCommandPrefixLauncher matched the program name as written, so python3.11, python2.7, python.exe, node.exe and git.exe all validated as ordinary commands and could be persisted as command-prefix grants. Lookup is a string-prefix match, so a single approval then auto-allowed every later invocation: granting [python3.11 -c] covers `python3.11 -c "import os; os.system(...)"` with no further prompt. internal/agent's commandName already normalizes case and Windows executable extensions for the allow side of the same decision; the deny side did not, and the two disagreeing is the bug. Reduce the program to the launcher it actually runs and match on that: executable extension, Windows' trailing dots and spaces (python. starts python.exe), CPython ABI flags, then a trailing version, testing the digit-stripped form as well so python3.11 and python3 both arrive as python. A name is only ever narrowed toward an existing entry, so the failure direction is an extra permission prompt, never a silent grant. Digits without a separator stay part of the name, which keeps base64, 7z and sha256sum grantable. Two Windows spellings cannot be resolved by name at all, so the shape is refused alongside a path: 8.3 short names truncate the stem, which makes POWERS~1.EXE unrecognizable, and python.exe::$DATA reaches the same executable through its default stream. Normalizing the first token before the banned-suggestion comparison keeps git.exe at parity with git. The list also gains launchers of the same class it already covers and that normalization cannot reach: cmd, wsl, the remaining POSIX shells, busybox, uv and uvx. Entries normalization now covers (/bin/bash, /bin/zsh, powershell.exe, python3, pypy3) are gone; the first two were already unreachable behind the path check above them. Covers the persisted store, the session-scoped grants the agent takes during a turn, the legacy-schema migration that prunes such a grant per entry, and the Windows spellings hermetically. * fix(sandbox): treat free-threaded CPython as a launcher too The ABI flags came from the pre-3.13 set, so "python3.13t" and "python3.13t.exe" normalized to themselves and stayed grantable as command prefixes while the GIL build "python3.13" was refused. A free-threaded interpreter runs arbitrary code just the same. Add "t" to the flags trimmed under the existing digit guard, which is what keeps ordinary names intact: "zstd" and "cat" have no digit beneath the trimmed letters, so they keep their own names and stay grantable. Reported by CodeRabbit on #934. * fix(sandbox): refuse the remaining spellings of listed launchers Same defect as the free-threaded build, found by sweeping the families already on the list for names it cannot reach: - nodejs is node on Debian and Ubuntu, and was grantable. - python3-dbg, python3.11-dbg and python3.13t-dbg are interpreters that run arbitrary code; so are bash-static, pwsh-preview and node-nightly. A closed set of build-channel suffixes now normalizes them. The set stays closed deliberately: a general "-word" strip would also swallow node-gyp, python3-config and ruby-lsp, which are ordinary tools a user should still be able to approve, and those are pinned as must-stay-grantable. - pnpm, yarn and bunx run package scripts exactly as npm and npx do, and npm and npx are already refused. - sudoedit escalates like sudo. The launcher-wrapper family (winpty, chroot, unshare, nsenter, parallel, script, unbuffer, taskset, flock) has the same inconsistency against the env/nohup/timeout/setsid entries already on the list, and is left for a separate decision rather than widened here. * test(sandbox): cover the -debug and -beta launcher suffixes Four of the six build-channel suffixes had regression cases; -debug and -beta did not, so removing either from the set left every test green. Both now fail RED when their entry is dropped. Reported by CodeRabbit on #934.
234 lines
8.0 KiB
Go
234 lines
8.0 KiB
Go
package agent
|
|
|
|
import (
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
|
want := []string{"go", "test", "./..."}
|
|
if !equalStringSlices(got, want) {
|
|
t.Fatalf("prefix = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixSupportsExecCommand(t *testing.T) {
|
|
got := proposedCommandPrefix("exec_command", map[string]any{"cmd": "go test ./..."})
|
|
want := []string{"go", "test", "./..."}
|
|
if !equalStringSlices(got, want) {
|
|
t.Fatalf("prefix = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{
|
|
"command": "git status --short",
|
|
"prefix_rule": []any{"git", "status"},
|
|
})
|
|
want := []string{"git", "status"}
|
|
if !equalStringSlices(got, want) {
|
|
t.Fatalf("prefix = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
|
if runtime.GOOS == "windows" {
|
|
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
|
|
// not offer "ps aux" as a reusable prefix here: approving it would
|
|
// escalate the whole command, including the uncovered head segment,
|
|
// to bypass the sandbox unreviewed. See
|
|
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
|
|
// the platform-independent regression coverage of this behavior.
|
|
if got != nil {
|
|
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
|
|
}
|
|
return
|
|
}
|
|
want := []string{"ps", "aux"}
|
|
if !equalStringSlices(got, want) {
|
|
t.Fatalf("prefix = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
|
|
// against proposedCommandPrefix offering to approve one segment of a
|
|
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
|
|
// install") is not known-safe. shellExecutionArgsForApproval escalates the
|
|
// entire command once any prefix is approved, so an uncovered unsafe segment
|
|
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
|
|
// any platform, so the assertion does not depend on runtime.GOOS.
|
|
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
|
|
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
|
|
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{
|
|
"command": "git status --short && git status --branch",
|
|
"prefix_rule": []any{"git", "status"},
|
|
})
|
|
want := []string{"git", "status"}
|
|
if !equalStringSlices(got, want) {
|
|
t.Fatalf("prefix = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixRejectsRequestedPrefixThatDoesNotCoverSegments(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{
|
|
"command": "ps aux && npm install",
|
|
"prefix_rule": []any{"ps", "aux"},
|
|
})
|
|
if got != nil {
|
|
t.Fatalf("partial requested prefix should be rejected, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{
|
|
"command": "git status --short",
|
|
"prefix_rule": []any{"git"},
|
|
})
|
|
if got != nil {
|
|
t.Fatalf("broad requested prefix should be rejected, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// The launcher denylist is only worth anything if it holds on the path a user
|
|
// actually sees. A model naming its own prefix_rule is that path: before
|
|
// launcher names were normalized, "python3.11" was offered as a one-token
|
|
// grant, and hasStringPrefix then matched every later python3.11 invocation.
|
|
func TestProposedCommandPrefixRejectsLauncherSpellingsInRequestedPrefix(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
command string
|
|
rule []any
|
|
}{
|
|
{"python3.11 script.py", []any{"python3.11"}},
|
|
{"python.exe script.py", []any{"python.exe"}},
|
|
{"node.exe app.js", []any{"node.exe"}},
|
|
{"cmd /c whoami", []any{"cmd"}},
|
|
{"busybox sh -c id", []any{"busybox"}},
|
|
{"uv run main.py", []any{"uv", "run"}},
|
|
{"python3.11 -c import_os", []any{"python3.11", "-c"}},
|
|
} {
|
|
got := proposedCommandPrefix("bash", map[string]any{
|
|
"command": testCase.command,
|
|
"prefix_rule": testCase.rule,
|
|
})
|
|
if got != nil {
|
|
t.Errorf("prefix_rule %v for %q was offered as %#v, want rejected", testCase.rule, testCase.command, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixStillOffersOrdinaryCommands(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{
|
|
"command": "cargo build --release",
|
|
"prefix_rule": []any{"cargo", "build"},
|
|
})
|
|
if len(got) != 2 || got[0] != "cargo" || got[1] != "build" {
|
|
t.Fatalf("ordinary prefix rule = %#v, want [cargo build]", got)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
|
cases := []string{
|
|
"cat < in > out",
|
|
"FOO=bar go test",
|
|
"echo $(whoami)",
|
|
"cat *.go",
|
|
"bash -lc go test",
|
|
}
|
|
for _, command := range cases {
|
|
t.Run(command, func(t *testing.T) {
|
|
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
|
t.Fatalf("unsafe command got prefix %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
|
cases := []string{
|
|
"find . -type f",
|
|
"xargs rm -rf /tmp/x",
|
|
"timeout 5 go test ./...",
|
|
"nice go test ./...",
|
|
"nohup go test ./...",
|
|
"watch ls",
|
|
"ssh host ls",
|
|
"make test",
|
|
"npm run dev",
|
|
"command git status",
|
|
"eval echo hi",
|
|
"exec echo hi",
|
|
"python script.py",
|
|
"node script.js",
|
|
"./script.sh --flag",
|
|
"/tmp/script.sh --flag",
|
|
}
|
|
for _, command := range cases {
|
|
t.Run(command, func(t *testing.T) {
|
|
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
|
t.Fatalf("unsafe launcher got prefix %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
|
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
|
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
|
|
command := "ps aux | head -5"
|
|
if runtime.GOOS == "windows" {
|
|
command = "ps aux | echo ok"
|
|
}
|
|
|
|
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
|
|
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
|
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
|
}
|
|
}
|
|
|
|
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("windows-only known-safe MSYS guard")
|
|
}
|
|
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
|
|
if knownSafeCommandSegment(command) {
|
|
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
|
|
}
|
|
}
|
|
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
|
|
t.Fatal("expected echo to remain known-safe on Windows")
|
|
}
|
|
if !tools.MsysProneCommandName("head") {
|
|
t.Fatal("expected head to be MSYS-prone")
|
|
}
|
|
}
|
|
|
|
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
|
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
|
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
|
|
|
if grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}, Options{Sandbox: engine}); ok {
|
|
t.Fatalf("match = %#v session=%v, want no match because npm segment is uncovered", grant, session)
|
|
}
|
|
}
|
|
|
|
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
|
got := proposedCommandPrefix("bash", map[string]any{
|
|
"command": "find . -type f",
|
|
"prefix_rule": []any{"find", "."},
|
|
})
|
|
if got != nil {
|
|
t.Fatalf("unsafe requested launcher prefix should be rejected, got %#v", got)
|
|
}
|
|
}
|