Files
zero/internal/sandbox/scope.go
KRATOSandClaude Fable 5 e381d8eab1 Additional write roots: --add-dir flag, global config key, /add-dir TUI command (#162)
* docs: spec for additional write roots (--add-dir / /add-dir)

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs: implementation plan for additional write roots

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: add Scope type for multi-root write access

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs: write roots honored from global config only, union merge semantics

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: fix multi-root violation reporting and pin prefix-alias behavior

- validate: prefer ViolationSymlinkTraversal over ViolationOutsideWorkspace
  when all roots deny; return original requestedPath in violation; append
  --add-dir hint only on outside_workspace results; extend doc comment to
  state that a symlink resolving inside any root is allowed.
- normalizePrefixForRoot: dedupe insideRoot check via pathWithinRoot (Fix 3);
  widen EvalSymlinks comment to cover jump-over case; add POSIX-only note.
- WorkspaceRoot: add doc comment noting immutability/lock safety (Fix 4).
- Tests: pin multi-root traversal-preferred behavior, deterministic alias
  test (TestValidateResolvesAliasedPathPrefixes), Add symlink normalization
  test, and tilde-expansion error path test.

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: make engine path validation and risk scope-aware

Thread *Scope through EngineOptions/Engine so Evaluate and Classify use
multi-root scope validation instead of single-root validateWorkspacePaths,
letting extra write roots (--add-dir / /add-dir) be honoured by the policy
gate and risk classifier. Remove now-dead validateWorkspacePaths helper.

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: pin override-root scope isolation, tidy Evaluate scope handling

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: widen seatbelt/bubblewrap profiles and command cwd to scope roots

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: pin extra-root chdir behavior and document bind invariants

Co-Authored-By: Claude Fable 5 <[email protected]>

* tools: resolve paths against the shared write scope

Add PathScope interface and scoped resolver helpers to workspace.go, then
thread a scope field through all 8 file tools (read_file, list_directory,
glob, grep, write_file, edit_file, apply_patch, bash). Each existing
constructor delegates to a new NewScoped* variant; nil scope is byte-identical
to the original behavior. Registry gains CoreReadOnlyToolsScoped,
CoreWriteToolsScoped, CoreShellToolsScoped, and CoreToolsScoped.

Co-Authored-By: Claude Fable 5 <[email protected]>

* tools: restore fail-closed write-target symlink checks, share prefix normalization

* tools: report absolute paths for extra-root changes, pin scoped traversal denial

- resolveScopedPath and resolveScopedTargetPath now return the absolute
  path as the second value when the matched root is an extra (non-workspace)
  root, eliminating ambiguity in ChangedFiles, cwd meta, and display
  summaries downstream
- Add TestScopedWriteRefusesSameRootSymlinkTraversal to pin that same-root
  symlink traversal within an extra root is denied (pre-existing behavior)
- Add TestScopedWriteReportsAbsolutePathForExtraRoot to enforce the new
  absolute-path contract for ChangedFiles on extra-root writes
- Extend doc comments on PathScope, ChangedFiles, changedFilesFromPatch,
  and both scoped resolvers to capture the workspace-vs-absolute invariant

Co-Authored-By: Claude Fable 5 <[email protected]>

* config: add sandbox.additionalWriteRoots (global config only, union merge)

* cli: add repeatable --add-dir flag wiring scope into registry and engine

Co-Authored-By: Claude Fable 5 <[email protected]>

* cli: forward --add-dir across dispatch paths, pin scope wiring with tests

Co-Authored-By: Claude Fable 5 <[email protected]>

* cli: pin --add-dir dispatch forwarding end-to-end, close guard gaps

Review fixes for the --add-dir dispatch commit:

- Add TestRunAddDirDispatchForwardsGrantIntoExecScope: drives
  runWithDeps through both the "exec" and "-p" dispatch shapes with a
  provider that calls write_file inside the extra root, asserting the
  grant reaches the exec sandbox scope (and a negative control without
  --add-dir is denied fail-closed). Mutation-verified: dropping
  addDirFlagArgs forwarding from either case fails the test.
- Remove help/version from the --add-dir allowlist so any non-TUI/
  non-exec leading --add-dir hard-errors, matching the stated
  requirement; extend the rejection test with those cases.
- Fail loud when --add-dir is hidden behind a stray non-flag arg on the
  --skip-permissions-unsafe path instead of silently dropping the grant.
- Reject flag-like values in the inline --add-dir= spelling to match
  the space form (a directory named -foo stays reachable as ./-foo).
- Move TestExecScopeReRegistrationSwapsCoreToolsByName out of the parse
  test file into the new exec_scope_test.go alongside the e2e test.

Co-Authored-By: Claude Fable 5 <[email protected]>

* tui: add /add-dir command for session write-root grants

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: surface write roots in zero sandbox status and plan snapshots

Co-Authored-By: Claude Fable 5 <[email protected]>

* cli: surface write-roots error in --effective --json, normalize fallback root

Review fixes for the Task8 observability range:
- runSandboxPolicyEffective JSON payload gains writeRootsError
  (omitempty) so --json consumers see the same fail-soft signal as
  the text write_roots_error line instead of a misleading
  workspace-only writeRoots list.
- The fail-soft fallback now derives its write roots from a
  workspace-only Scope (which cannot fail) so the error path renders
  the same symlink-resolved root as the success path.
- TestRunSandboxPolicyEffectiveWriteRootsFailSoft gains a --json leg
  asserting writeRootsError names the stale root with a
  workspace-only fallback; the configured-roots test asserts the key
  is absent for valid roots.
- SandboxPlanSnapshot doc comment reworded to match the converter:
  no current builder populates WriteRoots.

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: seatbelt integration test for extra write roots

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs: document --add-dir and /add-dir write-root grants

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: pass volume-qualified paths through prefix normalization verbatim

NormalizePrefixForRoot's POSIX component walk mangled Windows drive paths
(C:\Users -> C:Users), which downstream single-root checks treated as
relative and joined under the workspace — failing the policy gate OPEN on
Windows. Volume-qualified paths now bypass the walk entirely (the macOS
/var alias problem it solves has no Windows equivalent) and windows-gated
tests pin both the verbatim pass-through and the engine denial.

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: make prefix normalization volume-aware for Windows alias resolution

The previous commit disabled NormalizePrefixForRoot on Windows, which broke
it the other direction: a workspace created under an 8.3 short path
(C:\Users\RUNNER~1\...) resolves via EvalSymlinks to its long form
(runneradmin), so raw short-form requests escaped the long-form root and
legitimate extra-root writes were denied. This is the same alias problem as
macOS /var -> /private/var, which the helper already solves. Start the
component walk from the volume root (C:\ or //host/share/) instead of "/"
so it resolves the prefix on Windows too; POSIX VolumeName is empty so the
walk reduces to the original "/"-rooted behavior byte-for-byte. Windows
test now pins allow-inside-root + deny-outside both directions.

Co-Authored-By: Claude Fable 5 <[email protected]>

* sandbox: address PR review findings for additional write roots

Resolve the CHANGES_REQUESTED review on PR #162:

- engine: derive workspaceRoot from scope.Roots()[0] when NewEngine is
  given a Scope without an explicit WorkspaceRoot, so Evaluate's
  EnforceWorkspace/classification guards no longer silently skip
  (+ regression test).
- tools/workspace: scopedRoots now fails closed (returns an error) when a
  non-nil PathScope exposes empty Roots(), instead of returning success
  with an empty path; threaded through the scoped resolve/recheck helpers
  and resolveGrepRoot.
- tools/glob: emit absolute matches when cwd resolves into an extra root
  so results can't be fed back and resolve to a same-named workspace file
  (+ regression test).
- tools/bash,grep,apply_patch: schema descriptions mention granted extra
  roots (relative -> workspace, absolute -> granted root).
- docs/plan: fix markdownlint heading increment, malformed rule+heading,
  and bare code fence.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* tools/grep, tui: address PR review for additional write roots

- grep now emits absolute, symlink-resolved paths for matches in a granted
  extra (non-workspace) root, mirroring the glob fix. A bare workspace-relative
  name like "report.go" otherwise resolves back under the workspace when fed to
  read_file/edit_file and hits the wrong file when the name exists in both
  roots. Covers content and files_with_matches output.
- tui: only reset chatScrollOffset for a real submission, not an empty Enter
  (a no-op), so the viewport is no longer yanked to the bottom when nothing was
  submitted. Real submissions still snap back.
- glob: schema description now mentions granted extra roots, matching the
  bash/grep/apply_patch wording so the --add-dir/-add-dir flow is discoverable.

Regression tests: TestScopedGrepReturnsAbsoluteMatchesForExtraRoot,
TestEmptySubmitKeepsChatScrollOffset.

* docs: add /add-dir to the TUI commands quick-reference table

The command was documented further down but missing from the scannable
commands table, so users browsing the table wouldn't discover the
additional-write-roots flow.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-06-11 15:17:20 +05:30

259 lines
9.4 KiB
Go

package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// Scope is the shared set of directories the sandbox allows writes in: the
// workspace root plus zero or more user-granted extra roots. One instance is
// created per run and shared by the policy engine, the OS command runners, and
// the file tools, so a mid-session Add is immediately visible to every layer.
type Scope struct {
mu sync.RWMutex
workspaceRoot string
extraRoots []string
}
// NewScope builds a scope for workspaceRoot plus the given extra roots. The
// workspace root is normalized best-effort (it may not exist in tests); every
// extra root must normalize strictly via Add and an invalid one fails the
// whole construction so a bad --add-dir/config entry surfaces at startup.
func NewScope(workspaceRoot string, extras []string) (*Scope, error) {
scope := &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
for _, extra := range extras {
if _, err := scope.Add(extra); err != nil {
return nil, fmt.Errorf("write root %q: %w", extra, err)
}
}
return scope, nil
}
// WorkspaceRoot returns the resolved workspace root. It is safe to call
// without acquiring the lock because workspaceRoot is immutable after
// construction.
func (s *Scope) WorkspaceRoot() string {
return s.workspaceRoot
}
// Roots returns the workspace root first, then the extra roots, as a copy.
func (s *Scope) Roots() []string {
s.mu.RLock()
defer s.mu.RUnlock()
roots := make([]string, 0, 1+len(s.extraRoots))
roots = append(roots, s.workspaceRoot)
roots = append(roots, s.extraRoots...)
return roots
}
// Add grants write access under path. The path must be an existing directory;
// it is home-expanded, made absolute, and symlink-resolved before being
// trusted, and the filesystem root is rejected outright. Adding a path already
// covered by an existing root is an idempotent success.
func (s *Scope) Add(path string) (string, error) {
root, err := normalizeScopeRoot(path)
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, existing := range append([]string{s.workspaceRoot}, s.extraRoots...) {
if pathWithinRoot(existing, root) {
return root, nil
}
}
s.extraRoots = append(s.extraRoots, root)
return root, nil
}
// validate reports whether requestedPath is allowed by any scope root.
// Relative paths resolve against the workspace root only; absolute paths are
// accepted if they validate (including per-segment symlink checks) under ANY
// root. A symlink whose final target lies inside any granted root is allowed —
// this is a deliberate semantic widening compared with single-root validation,
// because the true write target is inside an allowed root.
//
// When all roots deny, a ViolationSymlinkTraversal result from any root is
// preferred over ViolationOutsideWorkspace; the --add-dir hint is appended
// only on outside_workspace results. The returned violation always carries
// the caller's original requestedPath.
func (s *Scope) validate(requestedPath string) *pathViolation {
roots := s.Roots()
if !filepath.IsAbs(requestedPath) {
return validateWorkspacePath(roots[0], requestedPath)
}
// For each root, normalize the leading path prefix so that platform-level
// symlinks (e.g. macOS /var -> /private/var) are resolved before comparing
// against the symlink-resolved scope roots, while leaving workspace-internal
// symlinks intact so validateWorkspacePath can detect traversal violations.
var outsideViolation *pathViolation
var traversalViolation *pathViolation
for _, root := range roots {
normalized := NormalizePrefixForRoot(requestedPath, root)
violation := validateWorkspacePath(root, normalized)
if violation == nil {
return nil
}
switch violation.Code {
case ViolationSymlinkTraversal:
if traversalViolation == nil {
traversalViolation = violation
}
default:
if outsideViolation == nil {
outsideViolation = violation
}
}
}
// Prefer symlink-traversal: the path was lexically inside a granted root
// but crossed an in-root symlink — the --add-dir hint would be misleading.
if traversalViolation != nil {
return &pathViolation{
Code: ViolationSymlinkTraversal,
Path: requestedPath,
Reason: traversalViolation.Reason,
}
}
// Plain outside-workspace denial — rebuild with the original path and hint.
return &pathViolation{
Code: ViolationOutsideWorkspace,
Path: requestedPath,
Reason: fmt.Sprintf("%s is outside the workspace (use /add-dir or --add-dir to allow writes there)", requestedPath),
}
}
// NormalizePrefixForRoot resolves platform-level symlinks (e.g. macOS
// /var -> /private/var) in the portion of absPath that lies outside
// resolvedRoot, while leaving workspace-internal path components intact so
// that validateWorkspacePath can detect symlink traversal violations there.
// It is exported because the tools layer shares it to normalize absolute
// paths per scope root before running its own single-root checks.
//
// Algorithm: walk absPath component-by-component, resolving each via
// EvalSymlinks. Once the running resolved prefix equals resolvedRoot we are
// inside the root — stop resolving and append the remaining components
// verbatim. If a component inside the root is a symlink, leave it for
// validateWorkspacePath to handle. Non-existent tail components are always
// appended verbatim.
//
// The walk is volume-aware so it works on Windows as well as POSIX. On
// Windows the same alias problem appears in a different guise — a workspace
// created under an 8.3 short path (C:\Users\RUNNER~1\...) is resolved by
// EvalSymlinks to its long form (C:\Users\runneradmin\...), so a raw
// short-form request would escape the long-form root unless its prefix is
// resolved here first. The component walk must therefore start from the
// volume root (C:\ or \\host\share\), not "/", or it would mangle a drive
// path into a drive-relative form (C:\Users -> C:Users) that the single-root
// checks treat as RELATIVE — failing the policy gate OPEN. On POSIX
// VolumeName is empty and the volume root reduces to "/", so behavior there
// is byte-identical to a plain "/"-rooted walk.
func NormalizePrefixForRoot(absPath, resolvedRoot string) string {
volume := filepath.VolumeName(absPath)
volumeRoot := volume + string(filepath.Separator)
tail := strings.TrimPrefix(filepath.Clean(absPath), volume)
parts := strings.Split(strings.TrimPrefix(tail, string(filepath.Separator)), string(filepath.Separator))
current := volumeRoot
for i, part := range parts {
if part == "" {
continue
}
// If we've reached the resolved root boundary, stop resolving and
// append the remaining tail verbatim so validateWorkspacePath sees the
// original symlink names.
if current == resolvedRoot {
return filepath.Join(append([]string{current}, parts[i:]...)...)
}
next := filepath.Join(current, part)
info, lerr := os.Lstat(next)
if lerr != nil {
// Non-existent component — append rest verbatim.
return filepath.Join(append([]string{current}, parts[i:]...)...)
}
if info.Mode()&os.ModeSymlink != 0 {
// Symlink. Only resolve it if we're still outside the root.
if pathWithinRoot(resolvedRoot, current) {
// Inside root — leave this symlink for validateWorkspacePath.
return filepath.Join(append([]string{current}, parts[i:]...)...)
}
// Outside root (or a jump into the root) — resolve this platform-level symlink.
resolved, err := filepath.EvalSymlinks(next)
if err != nil {
return filepath.Join(append([]string{current}, parts[i:]...)...)
}
current = resolved
continue
}
// Regular component outside root — resolve it.
resolved, err := filepath.EvalSymlinks(next)
if err != nil {
current = next
} else {
current = resolved
}
}
return current
}
func normalizeWorkspaceRootBestEffort(workspaceRoot string) string {
trimmed := strings.TrimSpace(workspaceRoot)
if trimmed == "" {
return ""
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return filepath.Clean(trimmed)
}
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
return resolved
}
return filepath.Clean(absolute)
}
func normalizeScopeRoot(path string) (string, error) {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return "", errors.New("write root path is empty")
}
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("expand ~: %w", err)
}
trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator)))
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return "", fmt.Errorf("write root must exist: %w", err)
}
info, err := os.Stat(resolved)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("write root %s is not a directory", resolved)
}
if filepath.Dir(resolved) == resolved {
return "", fmt.Errorf("refusing filesystem root %s as a write root", resolved)
}
return resolved, nil
}
func pathWithinRoot(root string, candidate string) bool {
if root == "" {
return false
}
relative, err := filepath.Rel(root, candidate)
if err != nil {
return false
}
return relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative))
}