mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
Treat platform temp directories as default writable roots so file tools, shell commands, and sandbox profiles share the same temp view. On Linux, bind host temp roots instead of mounting an isolated tmpfs at /tmp so artifacts created under /tmp remain visible across tool and shell calls. Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/cli ./internal/agent
195 lines
6.9 KiB
Go
195 lines
6.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
// TestRunAddDirDispatchForwardsGrantIntoExecScope pins the dispatch seam
|
|
// end-to-end: a leading --add-dir on the ROOT argv (both the "exec" and the
|
|
// "-p" dispatch shapes in runWithDeps) must reach exec's sandbox scope so a
|
|
// write_file call inside the extra root succeeds. The negative control runs
|
|
// the identical provider WITHOUT --add-dir and must be denied (fail-closed).
|
|
// If addDirFlagArgs forwarding were dropped from either dispatch case, the
|
|
// positive runs here would fail while every parser/registry unit test stayed
|
|
// green.
|
|
func TestRunAddDirDispatchForwardsGrantIntoExecScope(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
args func(extra string) []string
|
|
}{
|
|
{
|
|
name: "exec subcommand",
|
|
args: func(extra string) []string {
|
|
return []string{"--add-dir", extra, "exec", "--skip-permissions-unsafe", "write the note"}
|
|
},
|
|
},
|
|
{
|
|
name: "prompt flag",
|
|
args: func(extra string) []string {
|
|
return []string{"--add-dir", extra, "-p", "write the note", "--skip-permissions-unsafe"}
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
cwd := t.TempDir()
|
|
extra := tempDirOutsideDefaultTemp(t)
|
|
target := filepath.Join(extra, "granted.txt")
|
|
|
|
exitCode, stderr := runExecAddDirWriteProbe(t, cwd, tc.args(extra), target)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("exit = %d, want %d; stderr = %q", exitCode, exitSuccess, stderr)
|
|
}
|
|
content, err := os.ReadFile(target)
|
|
if err != nil {
|
|
t.Fatalf("expected write inside --add-dir root to land, read %s: %v", target, err)
|
|
}
|
|
if string(content) != "granted" {
|
|
t.Fatalf("content = %q, want %q", string(content), "granted")
|
|
}
|
|
|
|
// Negative control: the SAME run without --add-dir must be denied —
|
|
// the grant is the only thing that widens the scope.
|
|
denied := filepath.Join(extra, "denied.txt")
|
|
deniedArgs := tc.args(extra)[2:]
|
|
deniedExit, deniedStderr := runExecAddDirWriteProbe(t, cwd, deniedArgs, denied)
|
|
if deniedExit != exitSuccess {
|
|
t.Fatalf("denied-run exit = %d, want %d (tool denial is not fatal); stderr = %q", deniedExit, exitSuccess, deniedStderr)
|
|
}
|
|
if _, err := os.Stat(denied); !os.IsNotExist(err) {
|
|
t.Fatalf("write outside the workspace without --add-dir must not land, stat err=%v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// runExecAddDirWriteProbe dispatches the given root argv through runWithDeps
|
|
// with a provider that calls write_file on target, then answers. It returns
|
|
// the exit code and stderr.
|
|
func runExecAddDirWriteProbe(t *testing.T, cwd string, args []string, target string) (int, string) {
|
|
t.Helper()
|
|
|
|
arguments, err := json.Marshal(map[string]string{"path": target, "content": "granted"})
|
|
if err != nil {
|
|
t.Fatalf("marshal write_file arguments: %v", err)
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
|
return execResolvedConfig(), nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return toolCallingExecProvider{
|
|
toolCallID: "call_write_extra",
|
|
toolName: "write_file",
|
|
arguments: string(arguments),
|
|
answer: "done",
|
|
}, nil
|
|
},
|
|
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
|
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
|
},
|
|
})
|
|
return exitCode, stderr.String()
|
|
}
|
|
|
|
// TestExecScopeReRegistrationSwapsCoreToolsByName pins the mechanism runExec
|
|
// relies on: a nil-scope core registry is built first (so --list-tools and
|
|
// tool-filter validation work before config resolve), then once the run scope
|
|
// is known the scoped core tools are re-registered and Registry.Register
|
|
// replaces the earlier instances BY NAME. The before/after write_file probes
|
|
// prove both the overwrite and the scoped enforcement it ships.
|
|
func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
|
|
root := t.TempDir()
|
|
extra := tempDirOutsideDefaultTemp(t)
|
|
inside := filepath.Join(extra, "inside.txt")
|
|
|
|
registry := newCoreRegistry(root)
|
|
|
|
// Before re-registration the tools carry a nil scope, so an absolute path
|
|
// inside the extra root (but outside the workspace) must be denied.
|
|
denied := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
|
"path": inside,
|
|
"content": "too early",
|
|
}, tools.RunOptions{PermissionGranted: true})
|
|
if denied.Status == tools.StatusOK {
|
|
t.Fatalf("nil-scope registry must deny extra-root write, got ok: %s", denied.Output)
|
|
}
|
|
if _, err := os.Stat(inside); !os.IsNotExist(err) {
|
|
t.Fatalf("expected inside file to remain absent before re-registration, stat err=%v", err)
|
|
}
|
|
|
|
// Re-register exactly like exec.go does once the run scope is resolved.
|
|
scope, err := sandbox.NewScope(root, []string{extra})
|
|
if err != nil {
|
|
t.Fatalf("NewScope: %v", err)
|
|
}
|
|
for _, tool := range tools.CoreToolsScoped(root, scope) {
|
|
registry.Register(tool)
|
|
}
|
|
|
|
allowed := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
|
"path": inside,
|
|
"content": "granted",
|
|
}, tools.RunOptions{PermissionGranted: true})
|
|
if allowed.Status != tools.StatusOK {
|
|
t.Fatalf("scoped registry must allow extra-root write, got %s: %s", allowed.Status, allowed.Output)
|
|
}
|
|
content, err := os.ReadFile(inside)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", inside, err)
|
|
}
|
|
if string(content) != "granted" {
|
|
t.Fatalf("inside content = %q, want %q", string(content), "granted")
|
|
}
|
|
|
|
// A path outside every scope root must still be denied after re-registration.
|
|
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
|
|
deniedOutside := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
|
"path": outside,
|
|
"content": "never",
|
|
}, tools.RunOptions{PermissionGranted: true})
|
|
if deniedOutside.Status == tools.StatusOK {
|
|
t.Fatalf("write outside all roots must fail, got ok: %s", deniedOutside.Output)
|
|
}
|
|
if !strings.Contains(deniedOutside.Output, "must stay inside the workspace") {
|
|
t.Fatalf("expected outside-workspace denial, got %q", deniedOutside.Output)
|
|
}
|
|
if _, err := os.Stat(outside); !os.IsNotExist(err) {
|
|
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func tempDirOutsideDefaultTemp(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
|
|
if err != nil {
|
|
t.Fatalf("MkdirTemp outside default temp: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
abs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
t.Fatalf("Abs(%q): %v", dir, err)
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
return resolved
|
|
}
|
|
return filepath.Clean(abs)
|
|
}
|