mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* 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]>
259 lines
9.4 KiB
Go
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))
|
|
}
|