Files
zero/internal/cli/exec_scope_test.go
T
jatmnandClaude Opus 5.5 833219aeb4 Merge #829 (feat/zeromaxing) onto current main
Brings gnanam1990's 195-commit zeromaxing/orchestrate branch (head
6916f853) onto main b32f7ce4, with every original commit unchanged.
All conflict resolution lives in this merge commit.

Resolutions that combine both sides:
- module path: Gitlawb/zero imports rewritten to Twigpine/zero
- config: project maxTurns stays tighten-only and records maxTurnsSet
  only when it applies; the partial (no-provider) config keeps Profiles
- config writer: SetKeepFinishedAgents takes main's config file lock
- exec: execTurnBudget applies before the zeromaxing refusal/delta notice
- exec_command: tty-gated interactive guard (main) plus the
  sandbox-selected shell preflight (PR); the Windows description
  guidance is the <environment> pointer, kept paired with
  hostExecCommandShellGuidance for the token ratchet
- specialist: LifecycleHooks (main) alongside the PR's executor fields;
  main and the PR both fixed resume --model, so it is emitted once
- cli: registerSpecialistTools keeps the PR's signature and wires main's
  lifecycle hooks bridge into the executor
- tui: main's file-view refresh, run-details clicks, notify fields and
  locked model selection, combined with the PR's plan messages, chip,
  background-plan /new guard and spinner-tick ordering
- resume model tests: both add/add files kept (resume_parent_model_test.go)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Rebased integration onto b32f7ce4; original #829 commits and integration resolutions retained.
2026-11-09 08:26:38 -08:00

215 lines
7.9 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/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.
// --add-read-dir grants READ, not write, at the process boundary: a write_file
// inside the read-only root is DENIED. This pins exec's readDirs loop to
// scope.AddRead — had it used scope.Add (write), or emitted --add-dir, this write
// would land, and a path the parent may only read would be writable in the child.
func TestAddReadDirGrantsReadOnlyAtTheProcessBoundary(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
readRoot := tempDirOutsideDefaultTemp(t)
target := filepath.Join(readRoot, "must-not-write.txt")
args := []string{"exec", "--add-read-dir", readRoot, "--skip-permissions-unsafe", "write the note"}
exitCode, stderr := runExecAddDirWriteProbe(t, cwd, args, target)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d; stderr = %q", exitCode, exitSuccess, stderr)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("a write inside a --add-read-dir root landed — a read grant escalated to write (stat err=%v)", err)
}
}
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)
}