mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* Prompt for shell network permissions Route network-risk shell calls through the existing permission prompt instead of hard-denying them at sandbox preflight. Approved network prompts apply the current turn/session request-permissions overlay so the effective sandbox policy is widened only after user approval. Move network command detection into the shell AST analyzer for package installers, gh/git network subcommands, and python http.server so quoted process patterns such as pkill -f "python3 -m http.server 8000" are not misclassified as network activity. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestEngineClassifiesNetworkAndDestructiveShellCommands|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBash|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Auto-allow native-sandboxed shell commands Remove the old hidden bash auto-allow environment/config path. Ordinary shell commands now use the active native sandbox as the permission boundary by default, while network, destructive, and workspace policy checks still run first. Policy-only and disabled sandbox backends continue to require approval for shell commands, so unsandboxed execution is not silently allowed. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxedBash|TestShellSandboxActive|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBashAuto|TestBashStillPromptsWithoutActiveSandbox|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestApplyConfiguredSandboxPolicy|TestApplyConfiguredAutonomyCeiling' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/daemon -run 'TestScrubWorkerEnvRemovesReentrancyMarkers' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Prompt for destructive shell approval Remove the separate DenyDestructiveShell policy layer from active sandbox decisions. Destructive shell classification now produces an approval prompt in ask/auto flows instead of a sandbox hard-deny, while no-approval paths remain blocked. Drop the removed destructive-shell policy field from sandbox policy output, backend capabilities, snapshots, and Windows golden data. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsForDestructiveShellInsteadOfSandboxDeny|TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineClassifiesNetworkAndDestructiveShellCommands|TestBackendCapabilitiesReflectDisabledPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicy|TestEffectiveSandboxPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/zerocommands -run 'TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove web-tool network policy switch Remove the old EnforceToolNetwork policy option and the NetworkHostAllowed per-tool gate so sandbox network policy only controls sandboxed shell egress. Keep web_search and web_fetch on their own tool permission and URL safety paths, with tests proving deny/scoped shell network policy no longer blocks in-process web tools. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy|TestEngineAllowsNetworkSideEffectWhenShellPolicyBlocksNetwork|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestScopedNetworkGateInEvaluate' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestWebSearchRunWithSandbox|TestWebFetchRunWithSandbox|TestRegistryRoutesWebFetchThroughSandboxPath' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Prompt for outside-workspace file access Convert recoverable outside-workspace file path violations into permission prompts instead of hard-denying in ask/auto modes. Approved prompts now grant the requested filesystem scope through the existing request_permissions overlay for the current turn or session, so the follow-up sandbox evaluation and scoped file tools use the same policy path. Keep unsafe mode, apply_patch escapes, symlink traversal, and non-recoverable path cases hard-denied. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDeniesOutOfWorkspacePaths|TestGrantRequestPermissionsReadDoesNotGrantWrite|TestGrantRequestPermissionsSessionPersists' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsAndAllowsOutsideWorkspaceWrite|TestRunSessionAllowsLaterOutsideWorkspaceWrite|TestRunAppliesSandboxEvenInUnsafeMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryAppliesSandboxBeforeToolExecution|TestRegistrySandboxGatesPathAliasKeys|TestRegistryAllowsPromptToolWithPersistentSandboxGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Fix Windows network prompt test fixture Use a Windows curl.cmd shim and cmd.exe PATH syntax for TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant while keeping the POSIX fake curl fixture for Linux and macOS. This keeps the test focused on network prompt approval and turn network grants instead of relying on a POSIX shell script being executable on Windows. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant|TestRunPersistentCommandPrefixStillPromptsForNetwork' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestClassifyASTCatchesNetworkProgramsRegexMisses|TestClassifyFlagsPipedInstallerVariants' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Remove sandbox autonomy ceilings Sandbox permission decisions now match grants by tool and scope only, without MaxAutonomy or request autonomy checks. Persistent/session grants, permission approvals, workspace-write auto-allow, and native-sandbox shell auto-allow no longer route through an autonomy ceiling. Remove the sandbox.maxAutonomy config and CLI surfaces, including grant --auto, policy output, sandbox check autonomy input, and sandbox grant snapshots. MCP permission autonomy is intentionally unchanged because it is a separate MCP permission model. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/zerocommands ./internal/config ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Fail closed without native sandbox Remove AllowPolicyOnlyRunner from sandbox policy and snapshots. A restricted/managed command plan now errors when native sandbox support is unavailable instead of running through a policy-only direct runner. Policy-only backend data remains as diagnostics for capability/reporting paths, but it no longer authorizes command execution when a platform sandbox is required. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/zerocommands ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove scoped shell network policy Drop the domain-scoped shell network mode and the local scoped-egress proxy path from the active sandbox model. Shell network policy now serializes and evaluates only deny or allow. Request-permissions network grants widen to allow without domain lists, and platform profiles consume the binary mode directly. The Linux helper no longer accepts proxy-route flags, Windows network hashes only the binary mode, and web tool tests assert they remain separate from shell network policy. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/notify Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Align permission grant ordering and prompt text Move persistent and session grant matching ahead of recoverable sandbox prompts while keeping explicit denies and non-recoverable path violations fail-closed. Clean TUI permission transcript/card text so normal prompts show action, reason, scope, and readable block details instead of raw permission/risk/violation key-value metadata. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tui ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove policy-only sandbox terminology Replace the unavailable native-backend diagnostic path with explicit unavailable support/backend states, and stop presenting missing native isolation as a policy-only fallback. Rename structured sandbox denial metadata from violation to block across permission events, stream-json, snapshots, TUI rendering, and sandbox-check output so user-facing flow matches blocked action language. Update README, work split docs, smoke test selectors, and Windows sandbox policy golden output for the new backend/support names and block payloads. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/tui ./internal/cli ./internal/doctor ./internal/zerocommands ./internal/streamjson Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: scripts/sandbox-smoke.sh Tested: git diff --check * Fix sandbox review follow-ups Fail closed for unknown network modes, keep network prompts ahead of bash allow grants, preserve v1 grant-file reads, and classify unparseable commands with obvious network programs as network-risk. Bump stream-json schema to v2 and document the block metadata field after the violation-to-block rename. Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/streamjson ./internal/specialist ./internal/cli -run 'TestExec.*Stream|TestStreamJSON|TestExecProtocol|TestExecReadsStreamJSONImages|TestExecAcceptsImageOnlyStreamJSONMessage|TestExecInputFormatStreamJSON' Tests: GOCACHE=/tmp/zero-go-cache go test ./... Tests: scripts/sandbox-smoke.sh Tests: git diff --check
375 lines
14 KiB
Go
375 lines
14 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// This file implements the fine-grained AllowRead/DenyRead/AllowWrite/DenyWrite
|
|
// path lists (Policy fields). They layer ON TOP of the workspace + Scope guards
|
|
// and never bypass them: AllowRead only re-includes inside a DenyRead carve-out,
|
|
// AllowWrite is consulted only after the workspace/Scope guard already denied a
|
|
// write, and every match is symlink-resolved so a symlink prefix cannot evade a
|
|
// deny or sneak past an allow. All lists default empty, so an unconfigured policy
|
|
// behaves exactly as before.
|
|
|
|
// resolvePolicyPath home-expands, makes absolute, and symlink-resolves a single
|
|
// policy path entry. ok is false for a blank entry or one that does not exist
|
|
// (EvalSymlinks requires existence) so a bogus entry is dropped — a non-existent
|
|
// deny protects nothing and a non-existent allow grants nothing.
|
|
func resolvePolicyPath(entry string) (string, bool) {
|
|
trimmed := strings.TrimSpace(entry)
|
|
if trimmed == "" {
|
|
return "", false
|
|
}
|
|
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator)))
|
|
}
|
|
absolute, err := filepath.Abs(trimmed)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(absolute)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return resolved, true
|
|
}
|
|
|
|
// resolvePolicyPaths resolves and de-duplicates a list of policy path entries,
|
|
// dropping blanks and non-existent entries. Files and directories are both kept
|
|
// (a DenyRead/DenyWrite entry may target a single sensitive file).
|
|
func resolvePolicyPaths(entries []string) []string {
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(entries))
|
|
out := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
resolved, ok := resolvePolicyPath(entry)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[resolved]; dup {
|
|
continue
|
|
}
|
|
seen[resolved] = struct{}{}
|
|
out = append(out, resolved)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resolveWriteRootPaths is resolvePolicyPaths restricted to existing directories
|
|
// that are not the filesystem root — the only valid targets for an OS write bind
|
|
// and for an AllowWrite grant root.
|
|
func resolveWriteRootPaths(entries []string) []string {
|
|
resolved := resolvePolicyPaths(entries)
|
|
if len(resolved) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(resolved))
|
|
for _, path := range resolved {
|
|
info, err := os.Stat(path)
|
|
if err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
if filepath.Dir(path) == path {
|
|
continue // refuse the filesystem root as a write root
|
|
}
|
|
out = append(out, path)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pathUnderPolicyRoot reports whether requestedPath lies within root. A relative
|
|
// requestedPath is resolved against workspaceRoot; the portion of an absolute
|
|
// path outside root is symlink-resolved (via NormalizePrefixForRoot) so a
|
|
// symlink prefix cannot evade the match. root must be an already-resolved
|
|
// absolute path.
|
|
func pathUnderPolicyRoot(requestedPath, root, workspaceRoot string) bool {
|
|
if root == "" {
|
|
return false
|
|
}
|
|
abs := requestedPath
|
|
if !filepath.IsAbs(abs) {
|
|
if workspaceRoot == "" {
|
|
return false
|
|
}
|
|
abs = filepath.Join(workspaceRoot, abs)
|
|
}
|
|
normalized := NormalizePrefixForRoot(abs, root)
|
|
return pathWithinRoot(root, normalized)
|
|
}
|
|
|
|
// readDenied reports whether path is excluded by the DenyRead list with no
|
|
// more-specific AllowRead re-inclusion. "More specific" means an AllowRead entry
|
|
// nested inside the matched DenyRead entry — that subtree is read back in while
|
|
// the rest of the denied tree stays blocked. It resolves the policy entries on
|
|
// each call, so it suits a one-shot check (the Evaluate gate). A search walk that
|
|
// checks many paths should use ReadExclusions, which resolves the entries once.
|
|
func readDenied(policy Policy, workspaceRoot, path string) bool {
|
|
return readDeniedResolved(workspaceRoot, resolvePolicyPaths(policy.DenyRead), resolvePolicyPaths(policy.AllowRead), path)
|
|
}
|
|
|
|
// readDeniedResolved is readDenied operating on ALREADY-resolved deny/allow roots,
|
|
// so a caller that resolved the policy entries once can reuse them across many
|
|
// path checks instead of re-running Abs/EvalSymlinks per path.
|
|
func readDeniedResolved(workspaceRoot string, denyRoots, allowRoots []string, path string) bool {
|
|
if len(denyRoots) == 0 {
|
|
return false
|
|
}
|
|
for _, deny := range denyRoots {
|
|
if !pathUnderPolicyRoot(path, deny, workspaceRoot) {
|
|
continue
|
|
}
|
|
reincluded := false
|
|
for _, allow := range allowRoots {
|
|
// The allow entry must sit inside the deny entry to be "more specific",
|
|
// and the path must fall under that allow entry.
|
|
if pathWithinRoot(deny, allow) && pathUnderPolicyRoot(path, allow, workspaceRoot) {
|
|
reincluded = true
|
|
break
|
|
}
|
|
}
|
|
if !reincluded {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ReadExclusions holds the resolved DenyRead/AllowRead roots for a policy so a
|
|
// search walk resolves each policy entry ONCE (Abs/EvalSymlinks) and reuses the
|
|
// result across every visited path, rather than re-resolving per path. Build it
|
|
// with Engine.ReadExclusions and reuse it for the whole grep/glob walk.
|
|
type ReadExclusions struct {
|
|
workspaceRoot string
|
|
denyRoots []string
|
|
allowRoots []string
|
|
}
|
|
|
|
// Active reports whether any DenyRead root is configured. When false the
|
|
// exclusions are a no-op and the search behaves exactly as before.
|
|
func (rx *ReadExclusions) Active() bool {
|
|
return rx != nil && len(rx.denyRoots) > 0
|
|
}
|
|
|
|
// PathExcluded reports whether reading path is excluded by DenyRead, honoring a
|
|
// more-specific AllowRead re-inclusion. It is the per-file predicate for a walk.
|
|
func (rx *ReadExclusions) PathExcluded(path string) bool {
|
|
if !rx.Active() {
|
|
return false
|
|
}
|
|
return readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path)
|
|
}
|
|
|
|
// DirExcluded reports whether a directory subtree can be skipped wholesale during
|
|
// a walk: it is read-denied AND contains no nested AllowRead root (descending is
|
|
// required to reach a re-included subtree). When it returns false on a denied dir
|
|
// because of a nested allow, PathExcluded still filters the denied siblings.
|
|
func (rx *ReadExclusions) DirExcluded(path string) bool {
|
|
if !rx.Active() {
|
|
return false
|
|
}
|
|
if !readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) {
|
|
return false
|
|
}
|
|
abs := path
|
|
if !filepath.IsAbs(abs) && rx.workspaceRoot != "" {
|
|
abs = filepath.Join(rx.workspaceRoot, path)
|
|
}
|
|
// allowRoots are symlink-resolved (resolvePolicyPaths), so resolve abs the
|
|
// same way before the prefix comparison — otherwise a dir reached THROUGH a
|
|
// symlink would fail to match a nested AllowRead root and be wrongly skipped,
|
|
// dropping a re-included subtree. Best-effort: keep abs if it can't resolve
|
|
// (e.g. a non-existent path), matching the deny check's tolerant behavior.
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
abs = resolved
|
|
}
|
|
return !hasNestedAllowReadResolved(rx.allowRoots, abs)
|
|
}
|
|
|
|
// allowWriteScope builds an ad-hoc Scope from the resolved AllowWrite roots so a
|
|
// write to an AllowWrite path is validated with the SAME symlink-traversal logic
|
|
// the workspace Scope uses. Returns nil when there are no usable AllowWrite roots.
|
|
func allowWriteScope(policy Policy) *Scope {
|
|
roots := resolveWriteRootPaths(policy.AllowWrite)
|
|
if len(roots) == 0 {
|
|
return nil
|
|
}
|
|
return &Scope{workspaceRoot: roots[0], extraRoots: roots[1:]}
|
|
}
|
|
|
|
// validateWritePath enforces the write precedence: DenyWrite wins, then (when
|
|
// workspace enforcement is on) a workspace/Scope-writable path is allowed, then
|
|
// an absolute path under an AllowWrite root is allowed, otherwise the base
|
|
// workspace/Scope block stands. The DenyWrite list applies regardless of
|
|
// enforceWorkspace; the workspace boundary itself applies only when
|
|
// enforceWorkspace. It never bypasses the symlink/out-of-workspace guards.
|
|
func validateWritePath(scope *Scope, policy Policy, enforceWorkspace bool, workspaceRoot, path string) *pathBlock {
|
|
// DenyWrite wins regardless of workspace enforcement.
|
|
for _, deny := range resolvePolicyPaths(policy.DenyWrite) {
|
|
if pathUnderPolicyRoot(path, deny, workspaceRoot) {
|
|
return &pathBlock{
|
|
Code: BlockDenied,
|
|
Path: path,
|
|
Reason: path + " is excluded by the sandbox DenyWrite policy",
|
|
}
|
|
}
|
|
}
|
|
if !enforceWorkspace {
|
|
// Workspace confinement is off: only the explicit DenyWrite list restricts.
|
|
return nil
|
|
}
|
|
base := scope.validate(path)
|
|
if base == nil {
|
|
return nil // writable under the workspace / Scope guard
|
|
}
|
|
// AllowWrite only extends ABSOLUTE paths: a relative path is inherently
|
|
// workspace-relative and already resolved by the base guard above.
|
|
if filepath.IsAbs(path) {
|
|
if allow := allowWriteScope(policy); allow != nil && allow.validate(path) == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
// validatePathWithPolicy is the single entry point the engine uses to validate a
|
|
// request path. The fine-grained read/write lists (DenyRead/DenyWrite, with
|
|
// AllowRead/AllowWrite) apply whenever the sandbox is enforcing, INDEPENDENT of
|
|
// enforceWorkspace, so they match the grep/glob path that also honors DenyRead
|
|
// directly. The workspace boundary (scope.validate) applies only when
|
|
// enforceWorkspace. Behavior is unchanged when the lists are empty.
|
|
func validatePathWithPolicy(scope *Scope, policy Policy, sideEffect SideEffect, enforceWorkspace bool, workspaceRoot, path string) *pathBlock {
|
|
// A relative path cannot be anchored without a workspace root, so it cannot be
|
|
// checked against the (absolute) path lists or workspace boundary. Fail closed
|
|
// when there is anything to enforce; otherwise it is a no-op (unchanged from the
|
|
// pre-path-list behavior, where an empty workspace root skipped validation).
|
|
if workspaceRoot == "" && !filepath.IsAbs(path) {
|
|
if enforceWorkspace || policyHasPathLists(policy) {
|
|
return &pathBlock{
|
|
Code: BlockOutsideWorkspace,
|
|
Path: path,
|
|
Reason: path + " cannot be validated without a workspace root",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
switch sideEffect {
|
|
case SideEffectRead:
|
|
if readDenied(policy, workspaceRoot, path) {
|
|
return &pathBlock{
|
|
Code: BlockDenied,
|
|
Path: path,
|
|
Reason: path + " is excluded by the sandbox DenyRead policy",
|
|
}
|
|
}
|
|
if enforceWorkspace {
|
|
return scope.validateRead(path)
|
|
}
|
|
return nil
|
|
case SideEffectWrite, SideEffectOutOfWorkspace:
|
|
return validateWritePath(scope, policy, enforceWorkspace, workspaceRoot, path)
|
|
default:
|
|
if enforceWorkspace {
|
|
return scope.validate(path)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// hasNestedAllowReadResolved reports whether any already-resolved AllowRead root
|
|
// sits strictly inside dir (an already-resolved absolute path). When true, a
|
|
// read-denied dir must still be descended during a walk so the re-included
|
|
// subtree is reachable.
|
|
func hasNestedAllowReadResolved(allowRoots []string, dir string) bool {
|
|
for _, allow := range allowRoots {
|
|
if allow != dir && pathWithinRoot(dir, allow) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// workspaceRelGlob returns target as a clean, slash-separated path relative to
|
|
// workspaceRoot, or ok=false when target is the root itself or lies outside it
|
|
// (a workspace-rooted search never reaches such a path, so no glob is needed).
|
|
func workspaceRelGlob(workspaceRoot, target string) (string, bool) {
|
|
rel, err := filepath.Rel(workspaceRoot, target)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
|
return "", false
|
|
}
|
|
return filepath.ToSlash(rel), true
|
|
}
|
|
|
|
// ReadExclusionGlobs returns ripgrep-style --glob exclusion args for the policy's
|
|
// DenyRead subtrees that fall inside the scope's workspace root, so a
|
|
// ripgrep-based search never descends into a read-denied subtree. For each such
|
|
// entry it emits `--glob`, `!<rel>` and `--glob`, `!<rel>/**`. Mirrors the
|
|
// read-subtree exclusion globs used by comparable executor sandboxes.
|
|
//
|
|
// The projection is exclusions-only: a positive ripgrep glob would switch the
|
|
// search into whitelist mode and restrict it to only matching files, so AllowRead
|
|
// re-inclusion is NOT expressed here. The Go-native grep/glob tools honor
|
|
// AllowRead precisely via the cached predicate (Engine.ReadExclusions); this
|
|
// function is the coarser ripgrep-format export for an external rg-based
|
|
// consumer. Empty when DenyRead is unset (the default), so search behavior is
|
|
// unchanged.
|
|
func ReadExclusionGlobs(policy Policy, scope *Scope) []string {
|
|
denyRoots := resolvePolicyPaths(policy.DenyRead)
|
|
if len(denyRoots) == 0 || scope == nil {
|
|
return nil
|
|
}
|
|
workspaceRoot := scope.WorkspaceRoot()
|
|
if workspaceRoot == "" {
|
|
return nil
|
|
}
|
|
var globs []string
|
|
for _, deny := range denyRoots {
|
|
rel, ok := workspaceRelGlob(workspaceRoot, deny)
|
|
if !ok {
|
|
continue
|
|
}
|
|
globs = append(globs, "--glob", "!"+rel, "--glob", "!"+rel+"/**")
|
|
}
|
|
return globs
|
|
}
|
|
|
|
// policyHasPathLists reports whether any fine-grained path list has an
|
|
// ENFORCEABLE entry. It resolves the lists (matching how the rest of this file
|
|
// normalizes them) rather than counting raw config, so a typo or non-existent
|
|
// entry — which resolution drops — doesn't spuriously fail-close relative
|
|
// requests when there is no workspace root.
|
|
func policyHasPathLists(policy Policy) bool {
|
|
return len(resolvePolicyPaths(policy.DenyRead)) > 0 ||
|
|
len(resolvePolicyPaths(policy.AllowRead)) > 0 ||
|
|
len(resolvePolicyPaths(policy.DenyWrite)) > 0 ||
|
|
len(resolveWriteRootPaths(policy.AllowWrite)) > 0
|
|
}
|
|
|
|
// dedupeStrings returns xs with duplicates removed, preserving first-seen order.
|
|
func dedupeStrings(xs []string) []string {
|
|
if len(xs) <= 1 {
|
|
return xs
|
|
}
|
|
seen := make(map[string]struct{}, len(xs))
|
|
out := make([]string, 0, len(xs))
|
|
for _, x := range xs {
|
|
if _, dup := seen[x]; dup {
|
|
continue
|
|
}
|
|
seen[x] = struct{}{}
|
|
out = append(out, x)
|
|
}
|
|
return out
|
|
}
|