mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* fix(lint): resolve static analysis baseline * fix(lint): clear linux staticcheck findings Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-b2ee-7442-8a31-80b2cf9dd270 Co-authored-by: Pierre Bruno <[email protected]> --------- Co-authored-by: Amp <[email protected]>
649 lines
22 KiB
Go
649 lines
22 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type EngineOptions struct {
|
|
WorkspaceRoot string
|
|
Policy Policy
|
|
Store *GrantStore
|
|
Backend Backend
|
|
Scope *Scope
|
|
// SensitiveEnvKeys adds config-derived credential variable names to the
|
|
// catalog and namespace secrets scrubbed from sandboxed commands.
|
|
SensitiveEnvKeys []string
|
|
}
|
|
|
|
type Engine struct {
|
|
workspaceRoot string
|
|
policy Policy
|
|
store *GrantStore
|
|
backend Backend
|
|
scope *Scope
|
|
sensitiveEnvKeys []string
|
|
sessionGrants *memoryGrantSet
|
|
sessionProfiles *permissionProfileGrantSet
|
|
turnProfiles *permissionProfileGrantSet
|
|
commandPrefixes *commandPrefixGrantSet
|
|
}
|
|
|
|
func NewEngine(options EngineOptions) *Engine {
|
|
policy := options.Policy
|
|
if policy.Mode == "" {
|
|
policy = DefaultPolicy()
|
|
}
|
|
scope := options.Scope
|
|
workspaceRoot := strings.TrimSpace(options.WorkspaceRoot)
|
|
if scope != nil && workspaceRoot == "" {
|
|
// Scope-only construction must still populate workspaceRoot: Evaluate's
|
|
// path classification and EnforceWorkspace denial both guard on
|
|
// request.WorkspaceRoot != "", and resolveCommandDir hard-requires it, so
|
|
// leaving it blank would silently skip enforcement. Roots()[0] is the
|
|
// workspace root by the Scope contract.
|
|
if roots := scope.Roots(); len(roots) > 0 {
|
|
workspaceRoot = roots[0]
|
|
}
|
|
}
|
|
if scope == nil && workspaceRoot != "" {
|
|
scope = newScopeBestEffort(workspaceRoot)
|
|
}
|
|
return &Engine{
|
|
workspaceRoot: workspaceRoot,
|
|
policy: policy,
|
|
store: options.Store,
|
|
backend: options.Backend,
|
|
scope: scope,
|
|
sensitiveEnvKeys: normalizeSensitiveEnvKeys(options.SensitiveEnvKeys),
|
|
sessionGrants: newMemoryGrantSet(),
|
|
sessionProfiles: newPermissionProfileGrantSet(),
|
|
turnProfiles: newPermissionProfileGrantSet(),
|
|
commandPrefixes: newCommandPrefixGrantSet(),
|
|
}
|
|
}
|
|
|
|
// Scope returns the engine's shared write scope (nil when the engine was
|
|
// built without a workspace root and no explicit Scope option). The TUI uses
|
|
// it for /add-dir.
|
|
func (engine *Engine) Scope() *Scope {
|
|
if engine == nil {
|
|
return nil
|
|
}
|
|
return engine.scope
|
|
}
|
|
|
|
func (engine *Engine) CanPersistGrants() bool {
|
|
return engine != nil && engine.store != nil
|
|
}
|
|
|
|
func (engine *Engine) ConsumeGrantMigrationNotice() (string, error) {
|
|
if engine == nil || engine.store == nil {
|
|
return "", nil
|
|
}
|
|
return engine.store.ConsumeMigrationNotice()
|
|
}
|
|
|
|
func (engine *Engine) GrantCommandPrefixForSession(toolName string, prefix []string) {
|
|
if engine == nil || len(prefix) == 0 {
|
|
return
|
|
}
|
|
prefix, ok := NormalizeCommandPrefix(prefix)
|
|
if !ok {
|
|
return
|
|
}
|
|
engine.commandPrefixes.add(CommandPrefixGrant{
|
|
ToolName: toolName,
|
|
Prefix: prefix,
|
|
Session: true,
|
|
})
|
|
}
|
|
|
|
func (engine *Engine) GrantCommandPrefix(input CommandPrefixInput) (CommandPrefixGrant, error) {
|
|
if engine == nil || engine.store == nil {
|
|
return CommandPrefixGrant{}, errors.New("sandbox grant store is not configured")
|
|
}
|
|
return engine.store.GrantCommandPrefix(input)
|
|
}
|
|
|
|
func (engine *Engine) LookupCommandPrefix(toolName string, command []string) (CommandPrefixGrant, bool) {
|
|
if engine == nil || engine.store == nil || len(command) == 0 {
|
|
return CommandPrefixGrant{}, false
|
|
}
|
|
grant, matched, err := engine.store.LookupCommandPrefix(toolName, command)
|
|
if err != nil {
|
|
return CommandPrefixGrant{}, false
|
|
}
|
|
return grant, matched
|
|
}
|
|
|
|
func (engine *Engine) ApprovedCommandPrefixes() []CommandPrefixGrant {
|
|
if engine == nil || engine.store == nil {
|
|
return nil
|
|
}
|
|
grants, err := engine.store.ListCommandPrefixes()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return grants
|
|
}
|
|
|
|
func (engine *Engine) LookupCommandPrefixForSession(toolName string, command []string) (CommandPrefixGrant, bool) {
|
|
if engine == nil || len(command) == 0 {
|
|
return CommandPrefixGrant{}, false
|
|
}
|
|
return engine.commandPrefixes.match(toolName, command)
|
|
}
|
|
|
|
// ReadExclusions returns the resolved DenyRead/AllowRead exclusion matcher for
|
|
// this engine's policy, resolving each policy entry ONCE. The search tools build
|
|
// it a single time per run and reuse it across the whole walk so the predicates
|
|
// don't re-run Abs/EvalSymlinks per visited path. Returns nil for a nil engine
|
|
// (the matcher's methods treat nil as "exclude nothing").
|
|
func (engine *Engine) ReadExclusions() *ReadExclusions {
|
|
// A disabled policy enforces nothing, so it must not filter search results
|
|
// either (Evaluate already allows every request under ModeDisabled).
|
|
if engine == nil {
|
|
return nil
|
|
}
|
|
policy := engine.effectivePolicy(engine.policy)
|
|
if policy.Mode == ModeDisabled {
|
|
return nil
|
|
}
|
|
return &ReadExclusions{
|
|
workspaceRoot: engine.workspaceRoot,
|
|
denyRoots: resolvePolicyPaths(policy.DenyRead),
|
|
allowRoots: resolvePolicyPaths(policy.AllowRead),
|
|
}
|
|
}
|
|
|
|
// ReadExclusionGlobs returns the ripgrep-style --glob exclusion args for this
|
|
// engine's policy + scope (see the package-level ReadExclusionGlobs). Empty when
|
|
// DenyRead is unset or the engine has no scope.
|
|
func (engine *Engine) ReadExclusionGlobs() []string {
|
|
// A disabled policy filters nothing (parity with ReadExclusions / Evaluate).
|
|
if engine == nil {
|
|
return nil
|
|
}
|
|
policy := engine.effectivePolicy(engine.policy)
|
|
if policy.Mode == ModeDisabled {
|
|
return nil
|
|
}
|
|
return ReadExclusionGlobs(policy, engine.scope)
|
|
}
|
|
|
|
// effectiveNetworkMode is the single source of truth for sandboxed command
|
|
// egress. Shell network policy is binary: restricted commands use NetworkDeny,
|
|
// and approved network access widens it to NetworkAllow.
|
|
func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode {
|
|
return NormalizeNetworkMode(policy.Network)
|
|
}
|
|
|
|
// UnsandboxedExecutionAllowed reports whether an escalated shell attempt may
|
|
// bypass the native sandbox without dropping active denied-read restrictions.
|
|
func (engine *Engine) UnsandboxedExecutionAllowed() bool {
|
|
if engine == nil {
|
|
return true
|
|
}
|
|
policy := engine.effectivePolicy(engine.policy)
|
|
if policy.Mode == ModeDisabled {
|
|
return true
|
|
}
|
|
return len(normalizeProfilePaths(policy.DenyRead)) == 0
|
|
}
|
|
|
|
// toolNetworkExempt reports whether a request is exempt from the engine-level
|
|
// network deny because it is a first-party, in-process network tool. Such tools
|
|
// do not use sandboxed shell egress; they keep their own SSRF/host safeguards. A
|
|
// shell command merely classified as network (SideEffectShell) is NOT exempt, so
|
|
// shell egress stays blocked under deny.
|
|
func (engine *Engine) toolNetworkExempt(request Request) bool {
|
|
return request.SideEffect == SideEffectNetwork
|
|
}
|
|
|
|
// scopeFor returns the scope to validate request paths against. The engine's
|
|
// shared scope applies only when the request targets the engine's own
|
|
// workspace root; a per-request override root gets an ad-hoc single-root scope
|
|
// (single-root semantics; it deliberately ignores the engine's extra roots so
|
|
// an override can never inherit broader write access). The ad-hoc root is left
|
|
// unnormalized on purpose: validateWorkspacePath re-resolves roots internally,
|
|
// and skipping normalization avoids per-Evaluate EvalSymlinks syscalls.
|
|
func (engine *Engine) scopeFor(requestRoot string) *Scope {
|
|
if engine.scope != nil && requestRoot == engine.workspaceRoot {
|
|
return engine.scope
|
|
}
|
|
return newScopeBestEffort(requestRoot)
|
|
}
|
|
|
|
func newScopeBestEffort(workspaceRoot string) *Scope {
|
|
scope, err := NewScope(workspaceRoot, nil)
|
|
if err == nil {
|
|
return scope
|
|
}
|
|
return &Scope{workspaceRoot: normalizeWorkspaceRootBestEffort(workspaceRoot)}
|
|
}
|
|
|
|
// shellSandboxActive reports whether a native wrapping sandbox would actually
|
|
// wrap a shell command under the given policy. It is true only when the policy
|
|
// is enforcing and the engine's backend can wrap commands with native isolation
|
|
// (bubblewrap / sandbox-exec available). An unavailable backend, a disabled
|
|
// policy, or a nil engine all report false, so ordinary shell auto-allow never
|
|
// runs an unsandboxed command.
|
|
func (engine *Engine) shellSandboxActive(policy Policy) bool {
|
|
if engine == nil {
|
|
return false
|
|
}
|
|
if policy.Mode == ModeDisabled {
|
|
return false
|
|
}
|
|
// Re-entrancy skips wrapping: when the process carries the sandbox markers,
|
|
// the runner returns a pass-through (unwrapped) plan, so the native sandbox
|
|
// is NOT the boundary for THIS command. Because the markers are ambient and
|
|
// forgeable at an unsandboxed process boundary (issue #727), auto-allowing a
|
|
// shell command on that basis would run it unwrapped with no prompt. Mirror
|
|
// the runner: report inactive so the command takes the normal approval path.
|
|
if IsAlreadySandboxed() {
|
|
return false
|
|
}
|
|
backend := engine.backend
|
|
if !backend.Available || backend.Executable == "" || !backend.CommandWrapping || !backend.NativeIsolation {
|
|
return false
|
|
}
|
|
// On Windows the command is only actually wrapped once `zero sandbox setup`
|
|
// has written the marker; until then execution DEGRADES to unwrapped (see
|
|
// manager.BuildExecutionRequest). The engine must mirror that — otherwise it
|
|
// would auto-allow a shell command as "sandboxed" while it really runs
|
|
// unsandboxed, breaking the invariant above and the per-command approval
|
|
// floor. When not initialized, fall through so the command prompts.
|
|
if backend.Platform == "windows" && !windowsSandboxInitialized() {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Precheck reports the sandbox blocks that would block a tool request BEFORE
|
|
// it executes, so a caller (e.g. a batch confirmation or a "would this run?"
|
|
// check) can fail fast and surface the reason instead of discovering it mid-run.
|
|
// It reuses Evaluate, so policy is never duplicated: a request the engine would
|
|
// allow or merely prompt for yields no blocks; a denied request yields its
|
|
// block. A nil engine (sandbox disabled) yields no blocks.
|
|
func (engine *Engine) Precheck(ctx context.Context, request Request) []Block {
|
|
if engine == nil {
|
|
return nil
|
|
}
|
|
return blocksFromDecision(engine.Evaluate(ctx, request))
|
|
}
|
|
|
|
// blocksFromDecision extracts the blocking reasons from a decision. Only a deny
|
|
// carries one; the fallback synthesizes one for the rare deny without a
|
|
// structured block so a caller always gets a reason.
|
|
func blocksFromDecision(decision Decision) []Block {
|
|
if decision.Action != ActionDeny {
|
|
return nil
|
|
}
|
|
if decision.Block != nil {
|
|
return []Block{*decision.Block}
|
|
}
|
|
return []Block{{
|
|
Code: BlockDenied,
|
|
Action: ActionDeny,
|
|
Risk: decision.Risk,
|
|
Reason: decision.ErrorString(),
|
|
}}
|
|
}
|
|
|
|
func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
risk := Classify(request)
|
|
return deny(request, risk, BlockContextCanceled, "", "sandbox evaluation cancelled: "+err.Error(), false)
|
|
}
|
|
if engine == nil {
|
|
return Decision{Action: ActionAllow, Risk: Classify(request), Reason: "sandbox disabled"}
|
|
}
|
|
policy := engine.effectivePolicy(engine.policy)
|
|
if policy.Mode == "" {
|
|
policy = DefaultPolicy()
|
|
}
|
|
request.WorkspaceRoot = firstNonEmpty(request.WorkspaceRoot, engine.workspaceRoot)
|
|
request.Permission = NormalizePermission(request.Permission)
|
|
request.PermissionMode = NormalizePermissionMode(request.PermissionMode)
|
|
request.SideEffect = NormalizeSideEffect(request.SideEffect)
|
|
scope := engine.scopeFor(request.WorkspaceRoot)
|
|
risk := classifyWithScope(request, scope)
|
|
|
|
if policy.Mode == ModeDisabled {
|
|
return Decision{Action: ActionAllow, Risk: risk, Reason: "sandbox disabled"}
|
|
}
|
|
if request.Permission == PermissionDeny {
|
|
return deny(request, risk, BlockDeniedPermission, "", permissionReason(request), false)
|
|
}
|
|
reqRaw, reqKind := DeriveScope(request.ToolName, request.Args)
|
|
reqScope := resolveScopeForKind(reqRaw, reqKind, request.WorkspaceRoot)
|
|
var persistentAllow *Grant
|
|
if engine.store != nil {
|
|
match, err := engine.store.Lookup(request.ToolName, reqScope)
|
|
if err == nil && match.Matched {
|
|
grant := match.Grant
|
|
if grant.Decision == GrantDeny {
|
|
decision := deny(request, risk, BlockPersistentDeny, "", "persistent sandbox deny grant matched", true)
|
|
decision.GrantMatched = true
|
|
decision.Grant = &grant
|
|
return decision
|
|
}
|
|
persistentAllow = &grant
|
|
}
|
|
}
|
|
var sessionAllow *Grant
|
|
if match := engine.lookupSessionGrant(request.ToolName, reqScope); match.Matched {
|
|
grant := match.Grant
|
|
sessionAllow = &grant
|
|
}
|
|
// The fine-grained path lists (DenyRead/DenyWrite/AllowRead/AllowWrite) apply
|
|
// whenever the sandbox is enforcing, independent of EnforceWorkspace and even
|
|
// when there is no workspace root (absolute paths are still resolved and
|
|
// matched), so they are honored consistently with the grep/glob exclusion path
|
|
// and can't be bypassed by an engine built without a workspace root. The
|
|
// workspace boundary itself needs a root, so it is gated on having one. Mode is
|
|
// already known to be enforcing here (ModeDisabled returned above).
|
|
enforceWorkspace := policy.EnforceWorkspace && request.WorkspaceRoot != ""
|
|
if block := applyPatchPathBlock(request); block != nil {
|
|
return deny(request, risk, block.Code, block.Path, block.Reason, false)
|
|
}
|
|
var promptableBlock *pathBlock
|
|
for _, requested := range requestPaths(request) {
|
|
if block := validatePathWithPolicy(scope, policy, request.SideEffect, enforceWorkspace, request.WorkspaceRoot, requested); block != nil {
|
|
if promptablePathBlock(request, block) {
|
|
if promptableBlock == nil {
|
|
promptableBlock = block
|
|
}
|
|
continue
|
|
}
|
|
return deny(request, risk, block.Code, block.Path, block.Reason, false)
|
|
}
|
|
}
|
|
netMode := engine.effectiveNetworkMode(policy)
|
|
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(request) {
|
|
if request.SideEffect == SideEffectShell && request.PermissionMode != PermissionUnsafe {
|
|
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonNetworkBlocked}
|
|
}
|
|
return deny(request, risk, BlockNetwork, "", ReasonNetworkBlocked, false)
|
|
}
|
|
if HasRiskCategory(risk, "destructive") {
|
|
if request.SideEffect == SideEffectShell && !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
|
|
return Decision{Action: ActionPrompt, Risk: risk, Reason: "destructive shell command requires approval"}
|
|
}
|
|
if request.SideEffect == SideEffectShell && !request.PermissionGranted {
|
|
return deny(request, risk, BlockDestructiveCommand, "", "destructive shell command requires approval", false)
|
|
}
|
|
}
|
|
if request.SideEffect == SideEffectShell && requestRequiresEscalatedSandbox(request) {
|
|
if !request.PermissionGranted && request.PermissionMode != PermissionUnsafe {
|
|
return Decision{Action: ActionPrompt, Risk: risk, Reason: ReasonEscalatedSandboxRequired}
|
|
}
|
|
// Unsafe mode may auto-allow ordinary shell prompts, but it must not
|
|
// silently convert a sandboxed shell run into an unsandboxed one.
|
|
if !request.PermissionGranted {
|
|
return deny(request, risk, BlockDeniedPermission, "", ReasonEscalatedSandboxRequired, false)
|
|
}
|
|
}
|
|
if persistentAllow != nil {
|
|
return Decision{
|
|
Action: ActionAllow,
|
|
Reason: "persistent sandbox allow grant matched",
|
|
Risk: risk,
|
|
GrantMatched: true,
|
|
Grant: persistentAllow,
|
|
}
|
|
}
|
|
if sessionAllow != nil {
|
|
return Decision{
|
|
Action: ActionAllow,
|
|
Reason: "session sandbox allow grant matched",
|
|
Risk: risk,
|
|
GrantMatched: true,
|
|
Grant: sessionAllow,
|
|
}
|
|
}
|
|
if promptableBlock != nil {
|
|
return promptPathBlock(request, risk, promptableBlock)
|
|
}
|
|
if request.Permission == PermissionAllow {
|
|
return Decision{Action: ActionAllow, Risk: risk, Reason: permissionReason(request)}
|
|
}
|
|
if workspaceWriteAutoAllowed(policy, request, scope) {
|
|
return Decision{Action: ActionAllow, Risk: risk, Reason: "workspace write is allowed", AutoAllowed: true}
|
|
}
|
|
// Auto-allow an ordinary shell command when the active native sandbox is the
|
|
// safety boundary. Network, destructive, and path checks run before this
|
|
// branch, so they still prompt or deny as configured. If the backend is
|
|
// unavailable or disabled, shell commands keep the normal approval prompt.
|
|
if request.SideEffect == SideEffectShell && engine.shellSandboxActive(policy) {
|
|
return Decision{Action: ActionAllow, Risk: risk, Reason: "auto-allowed: sandbox is active for this shell command", AutoAllowed: true}
|
|
}
|
|
if request.PermissionGranted || request.PermissionMode == PermissionUnsafe {
|
|
return Decision{Action: ActionAllow, Risk: risk, Reason: permissionReason(request)}
|
|
}
|
|
return Decision{Action: ActionPrompt, Risk: risk, Reason: permissionReason(request)}
|
|
}
|
|
|
|
func requestRequiresEscalatedSandbox(request Request) bool {
|
|
return strings.TrimSpace(firstArgString(request.Args, "sandbox_permissions")) == "require_escalated"
|
|
}
|
|
|
|
func (engine *Engine) Grant(input GrantInput) (Grant, error) {
|
|
if engine == nil || engine.store == nil {
|
|
return Grant{}, errors.New("sandbox grant store is not configured")
|
|
}
|
|
input, err := engine.normalizeGrantInput(input)
|
|
if err != nil {
|
|
return Grant{}, err
|
|
}
|
|
return engine.store.Grant(input)
|
|
}
|
|
|
|
func (engine *Engine) GrantForSession(input GrantInput) (Grant, error) {
|
|
if engine == nil {
|
|
return Grant{}, errors.New("sandbox engine is not configured")
|
|
}
|
|
input, err := engine.normalizeGrantInput(input)
|
|
if err != nil {
|
|
return Grant{}, err
|
|
}
|
|
grant, err := createGrant(input, time.Now)
|
|
if err != nil {
|
|
return Grant{}, err
|
|
}
|
|
grant.Session = true
|
|
engine.sessionGrants.add(grant)
|
|
return grant, nil
|
|
}
|
|
|
|
func (engine *Engine) normalizeGrantInput(input GrantInput) (GrantInput, error) {
|
|
kind, err := normalizeScopeKind(input.ScopeKind)
|
|
if err != nil {
|
|
return GrantInput{}, err
|
|
}
|
|
scope, kind := reconcileScope(strings.TrimSpace(input.Scope), kind)
|
|
if kind != ScopeToolWide {
|
|
// Anchor relative path scopes to this workspace so the grant cannot match
|
|
// a same-named path in another project. Host scopes remain network hosts.
|
|
scope = resolveScopeForKind(scope, kind, engine.workspaceRoot)
|
|
}
|
|
input.Scope = scope
|
|
input.ScopeKind = kind
|
|
return input, nil
|
|
}
|
|
|
|
func (engine *Engine) lookupSessionGrant(toolName string, reqScope string) GrantLookup {
|
|
if engine == nil {
|
|
return GrantLookup{}
|
|
}
|
|
return engine.sessionGrants.lookup(toolName, reqScope)
|
|
}
|
|
|
|
func workspaceWriteAutoAllowed(policy Policy, request Request, scope *Scope) bool {
|
|
if !policy.EnforceWorkspace || request.WorkspaceRoot == "" || request.SideEffect != SideEffectWrite {
|
|
return false
|
|
}
|
|
paths := requestPaths(request)
|
|
if len(paths) == 0 || requestPathsTouchProtectedMetadata(scope, request.WorkspaceRoot, paths) {
|
|
return false
|
|
}
|
|
switch request.ToolName {
|
|
case "write_file", "edit_file", "apply_patch":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func promptablePathBlock(request Request, block *pathBlock) bool {
|
|
if block == nil || block.Code != BlockOutsideWorkspace {
|
|
return false
|
|
}
|
|
if request.PermissionMode == PermissionUnsafe {
|
|
return false
|
|
}
|
|
if request.ToolName == "apply_patch" {
|
|
return false
|
|
}
|
|
switch request.SideEffect {
|
|
case SideEffectRead, SideEffectWrite, SideEffectOutOfWorkspace:
|
|
default:
|
|
return false
|
|
}
|
|
path := strings.TrimSpace(block.Path)
|
|
if path == "" {
|
|
return false
|
|
}
|
|
if !filepath.IsAbs(path) && strings.TrimSpace(request.WorkspaceRoot) == "" {
|
|
return false
|
|
}
|
|
return !strings.Contains(block.Reason, "cannot be validated without a workspace root")
|
|
}
|
|
|
|
func promptPathBlock(request Request, risk Risk, block *pathBlock) Decision {
|
|
promptBlock := &Block{
|
|
Code: block.Code,
|
|
ToolName: request.ToolName,
|
|
Action: ActionPrompt,
|
|
Risk: risk,
|
|
Path: block.Path,
|
|
Reason: block.Reason,
|
|
Recoverable: true,
|
|
}
|
|
return Decision{
|
|
Action: ActionPrompt,
|
|
Reason: block.Reason,
|
|
Risk: risk,
|
|
Block: promptBlock,
|
|
}
|
|
}
|
|
|
|
func requestPathsTouchProtectedMetadata(scope *Scope, workspaceRoot string, paths []string) bool {
|
|
if len(paths) == 0 {
|
|
return false
|
|
}
|
|
for _, path := range paths {
|
|
if pathTouchesProtectedMetadata(scope, workspaceRoot, path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func pathTouchesProtectedMetadata(scope *Scope, workspaceRoot string, path string) bool {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return false
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return relativePathTouchesProtectedMetadata(path)
|
|
}
|
|
roots := []string{}
|
|
if scope != nil {
|
|
roots = scope.Roots()
|
|
} else if workspaceRoot != "" {
|
|
roots = []string{workspaceRoot}
|
|
}
|
|
for _, root := range roots {
|
|
root = normalizeWorkspaceRootBestEffort(root)
|
|
if root == "" {
|
|
continue
|
|
}
|
|
normalized := NormalizePrefixForRoot(filepath.Clean(path), root)
|
|
relative, err := filepath.Rel(root, normalized)
|
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
continue
|
|
}
|
|
if relativePathTouchesProtectedMetadata(relative) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func relativePathTouchesProtectedMetadata(path string) bool {
|
|
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(path)))
|
|
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || filepath.IsAbs(cleaned) {
|
|
return false
|
|
}
|
|
first := cleaned
|
|
if index := strings.Index(first, string(filepath.Separator)); index >= 0 {
|
|
first = first[:index]
|
|
}
|
|
for _, protected := range protectedMetadataNames {
|
|
if first == protected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func deny(request Request, risk Risk, code BlockCode, path string, reason string, recoverable bool) Decision {
|
|
block := &Block{
|
|
Code: code,
|
|
ToolName: request.ToolName,
|
|
Action: ActionDeny,
|
|
Risk: risk,
|
|
Path: path,
|
|
Reason: reason,
|
|
Recoverable: recoverable,
|
|
}
|
|
return Decision{
|
|
Action: ActionDeny,
|
|
Reason: reason,
|
|
Risk: risk,
|
|
Block: block,
|
|
}
|
|
}
|
|
|
|
func permissionReason(request Request) string {
|
|
if request.Reason != "" {
|
|
return request.Reason
|
|
}
|
|
switch request.Permission {
|
|
case PermissionAllow:
|
|
return "tool safety allows execution"
|
|
case PermissionDeny:
|
|
return "tool safety denies execution"
|
|
default:
|
|
return "tool requires approval before execution"
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|