approvals

package
v0.21.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package approvals: the ApprovalAgent seam (SP-141 phase 3, increment 5). ApprovalAgent is the narrow interface the approval broker operates on — the same pattern as changes.AgentView and workflow.LoopAgent. *Agent satisfies it directly: most members are existing exported accessors; the seam accessors in pkg/agent/approval_seam_accessors.go expose the private surface (debug logger, interrupt ctx, security-analysis cache, effective cwd, the shell-approval picker). The import arrow is one-way: pkg/agent -> approvals.

Package approvals: the approval broker (SP-141 phase 3, increment 5). RequestApproval is the unified approval flow for a RiskAssessment: command-policy, allowlist, unsafe/elevated bypasses, optional LLM security analysis, then the WebUI / CLI interactive surfaces, with permissive auto-approve as the last resort in non-interactive mode. It operates on the ApprovalAgent seam (approval_agent.go) so it lives here, out of the god package.

Package approvals: CLI approval-choice mapping (SP-141 phase 3, increment 5).

Package approvals: filesystem path-tier classification (risk inputs).

Pure, agent-free: no *Agent dependency. The LLM/client and the session allowlist live in pkg/agent; this package owns only the path -> tier mapping.

Package approvals: the RiskAgent seam (SP-141 phase 3, increment 6). RiskAgent is the narrow interface the risk resolver (ResolveToolRisk) operates on — the same pattern as ApprovalAgent, changes.AgentView, and workflow.LoopAgent. *Agent satisfies it directly: most members are pre-existing exported methods; the seam accessors in pkg/agent/risk_seam_accessors.go expose the private surface (the persona git-write capability check and the test-overridable home-dir hook). The import arrow is one-way: pkg/agent -> approvals.

Package approvals: the risk resolver (SP-141 phase 3, increment 6). ResolveToolRisk produces the unified risk assessment for a tool call by folding all security inputs onto the Low/Medium/High/Critical scale: the static classifier, the persona cascade, the git gates, the workspace security policy, and the filesystem path tiers. It operates on the RiskAgent seam (risk_agent.go) so it lives here, out of the god package.

Package approvals: LLM-augmented security analysis for shell commands.

The package is deliberately free of any *Agent dependency: the LLM client and the model name are injected by the caller (pkg/agent), which owns the session-model override and client locking.

Package approvals: session-scoped cache for LLM security analyses.

Index

Constants

View Source
const MaxChainSubcommandsForBatchPrompt = 10

MaxChainSubcommandsForBatchPrompt caps chain length for the batch prompt; longer chains fall back to per-subcommand analysis.

Variables

View Source
var DetectHomeDir = func() string {
	h, err := os.UserHomeDir()
	if err != nil {
		return ""
	}
	return h
}

DetectHomeDir returns the user's home directory or empty if it can't be resolved (very rare). Wrapped so tests can stub it.

View Source
var ErrNilAgent = agenterrors.NewPermission("nil agent", nil)

ErrNilAgent is the error the *Agent forwarders return for the persist methods when the agent is nil (matching the pre-move "nil agent" Permission error).

Functions

func AccessModeForTool

func AccessModeForTool(toolName string) string

AccessModeForTool returns "write" for mutating tools and "read" for read-only tools.

func ApprovalDecisionFromCLIChoice

func ApprovalDecisionFromCLIChoice(c utils.ApprovalChoice) security.ApprovalDecision

ApprovalDecisionFromCLIChoice maps the CLI prompt's typed choice onto the shared ApprovalDecision.

func CanonicalPath

func CanonicalPath(p string) string

CanonicalPath is NormalizePath without the Windows case folding — the form to show a user, since it keeps the path's real spelling.

func ChainCacheKey

func ChainCacheKey(input string) string

ChainCacheKey returns the cache key for storing/retrieving analyses of a shell chain. The key is normalized so that equivalent chains (modulo whitespace and outer trimming) collide, but distinct operators keep distinct keys.

func EvaluateCommandPolicy

func EvaluateCommandPolicy(
	command string,
	policies *configuration.CommandPolicies,
) (configuration.CommandPolicyAction, string, bool)

EvaluateCommandPolicy checks user-defined command policies against a shell command. Returns the matched action, the matched pattern, and whether a match was found.

Algorithm:

  1. Split the command on &&, ||, ;, | (quote-aware) using SplitChainedCommand.
  2. For each subcommand, check rules in order (first-match-wins).
  3. Pattern matching uses Go path.Match (glob), case-insensitive.
  4. Return the highest-severity action across all subcommands: deny > ask > allow.
  5. If no subcommand matched any rule, return ("", "", false).

func IsGitRebaseCommand

func IsGitRebaseCommand(command string) bool

IsGitRebaseCommand reports whether `command` contains a `git rebase` invocation that rewrites history (i.e. NOT `git rebase --abort`).

func IsGitStashCommand

func IsGitStashCommand(command string) bool

IsGitStashCommand checks whether `command` contains a `git stash` invocation that is not purely read-only. `git stash` (bare or with `push`) saves the working tree and reverts it to HEAD — the save itself isn't destructive, but the typical pattern is stash + build + pop, and the pop is where merge conflicts silently revert files. `stash pop`, `stash apply`, `stash drop`, and `stash clear` are all destructive. `stash list` and `stash show` are read-only and handled by shellLooksReadOnly, but we include them here for completeness — the gate blocks all stash subcommands except list/show.

The gate is intentionally broad: any `git stash` that isn't `list` or `show` is blocked, because even `git stash push` sets up the pop-that-can-corrupt that we want to prevent.

func IsGitWriteCommand

func IsGitWriteCommand(command string) bool

IsGitWriteCommand reports whether `command` contains a git invocation whose intent (not safety) requires the orchestrator git-write flow: `commit`, `push`, branch/tag CREATE operations (not delete — those go through the history-rewrite gate), and pushes via `clone`/`init` of existing-repo destinations.

Tier A working-tree-mutating ops (`checkout`, `switch`, `restore`, `reset` without history-loss, `clean`, `rm`, `mv`, `am`, `apply`, `cherry-pick`, `revert`, `fetch`, `pull`, `stash` mutations) used to be gated here; they're now allowed because the change tracker captures before-content and recover_file / recover_bulk are the recovery path. Tier B history-loss ops (`rebase`, `reset --hard <commit-ish>`, `branch -D`, `tag -d`) are routed through isGitHistoryRewriteCommand instead — that gate has its own opt-in flag (AllowGitHistoryRewrite) and prompts the user (auto-approved when AllowGitHistoryRewrite=true).

func IsShellCommandAllowlisted

func IsShellCommandAllowlisted(cfg *configuration.Config, command string) bool

IsShellCommandAllowlisted reports whether the command matches an approved literal or glob pattern. Critical-tier commands are still blocked regardless of allowlist matches.

func IsUnderPrefix

func IsUnderPrefix(path, prefix string) bool

IsUnderPrefix reports whether `path` equals `prefix` or sits in a subdirectory of it. Both args must already be normalized. The check is path-component aware: "/foobar" is NOT under "/foo".

func NormalizeChain

func NormalizeChain(chain Chain) string

NormalizeChain returns a normalized cache key for a chain. It walks chain.Original to recover operators and produces distinct keys for "a && b" and "a || b". Chains with identical subcommands and operators normalize to the same key regardless of internal whitespace.

func NormalizePath

func NormalizePath(p string) string

NormalizePath cleans the path so prefix comparisons aren't fooled by trailing slashes or "./" segments. Symlinks aren't resolved here — that's the filesystem layer's job. On Windows the path becomes case-insensitive for comparison. NormalizePath cleans the path so prefix comparisons aren't fooled by trailing slashes or "./" segments. Symlinks aren't resolved here — that's the filesystem layer's job. On Windows the path becomes case-insensitive for comparison.

func PersistShellCommandAllowlist

func PersistShellCommandAllowlist(mgr *configuration.Manager, command string) error

PersistShellCommandAllowlist appends command to the user's persistent approved-commands list (Config.ApprovedShellCommands) and saves to disk. Used by the "Always approve this command" choice on the approval dialog. Idempotent: re-adding an existing entry is a no-op but still triggers a save so the file's mtime updates (cheap).

func PersistShellCommandAskPolicy

func PersistShellCommandAskPolicy(mgr *configuration.Manager, command string) error

PersistShellCommandAskPolicy adds a "always ask" command policy rule for the given command.

func PersistShellCommandPattern

func PersistShellCommandPattern(mgr *configuration.Manager, pattern string) error

PersistShellCommandPattern appends pattern to the user's persistent approved-command-pattern list (Config.ApprovedShellCommandPatterns) and saves to disk. Patterns use Go path.Match glob syntax (`*`, `?`, `[]`). Idempotent: re-adding an existing entry is a no-op but still triggers a save so the file's mtime updates (cheap).

func ResolveOldDecision

func ResolveOldDecision(res tools.SecurityResult) string

ResolveOldDecision derives a one-word gating decision from the old dual-gate path's SecurityResult for shadow-mode comparison.

func ResolveUnifiedDecision

func ResolveUnifiedDecision(ra RiskAssessment) string

ResolveUnifiedDecision derives a one-word gating decision from a RiskAssessment for shadow-mode comparison with the old path.

Types

type ApprovalAgent

type ApprovalAgent interface {
	// Config + approval-policy state.
	GetConfig() *configuration.Config
	IsShellCommandAllowlisted(cmd string) bool
	GetUnsafeMode() bool
	GetUnsafeShellMode() bool
	IsSessionElevated() bool
	IsSubagent() bool

	// Interactive-approval surfaces.
	HasActiveWebUIClients() bool
	GetSecurityApprovalMgr() *security.ApprovalManager
	GetEventBus() *events.EventBus
	GetEventClientID() string
	GetEventUserID() string
	MarkWorkflowApprovedInSession(workflow string)

	// Seam accessors (see pkg/agent/approval_seam_accessors.go).
	Client() api.ClientInterface
	GetModel() string
	InterruptCtx() context.Context
	EffectiveCwd() string
	DebugEnabled() bool
	DebugLogf(format string, args ...interface{})
	IsNonInteractive() bool
	GetSecurityAnalysisCache() *SecurityAnalysisCache
	LogSecurityDecision(tool string, args map[string]interface{}, assessment RiskAssessment, action string)
	ApplyApprovalDecision(decision security.ApprovalDecision, command string)
	ApproveShellCommandParts(ctx context.Context, command string) (map[string]bool, []string, error)
}

ApprovalAgent is the surface the approval broker (RequestApproval) needs from the agent.

type BrokerDecision

type BrokerDecision struct {
	Approved   bool
	Decision   security.ApprovalDecision
	Outcome    security.ApprovalOutcome
	Surface    string            // "webui" or "cli" — which surface answered
	Assessment RiskAssessment    // echoed for caller diagnostics
	Analysis   *SecurityAnalysis // LLM-derived security analysis when available; nil otherwise
}

BrokerDecision is the typed verdict returned by RequestApproval.

func RequestApproval

func RequestApproval(a ApprovalAgent, assessment RiskAssessment, toolName string, args map[string]interface{}) (BrokerDecision, error)

RequestApproval performs the unified approval flow for a RiskAssessment. Low-risk auto-approves. Critical/hard-blocks deny unconditionally. Medium/High/IntentConfirmation checks bypass paths then tries WebUI, CLI, or falls back to permissive auto-approve in non-interactive mode.

type Chain

type Chain struct {
	Original    string
	Operators   []string // len(Subcommands)-1
	Subcommands []string // len >= 1 (split via SplitChainedCommand)
}

Chain is a top-level decomposition of a shell command. For unchained input, Subcommands has length 1. Operators carries the chain operator between each adjacent pair of subcommands.

func ParseChain

func ParseChain(s string) Chain

ParseChain splits a shell command string into a Chain value, delegating to SplitChainedCommand.

type PathTier

type PathTier int

PathTier classifies a filesystem path for approval purposes. See ClassifyPathAccess for the resolution rules. The tiers are ordered from least to most restrictive.

const (
	// PathTierUnknown is the zero value and shouldn't be returned;
	// it exists so a missing tier shows up loudly in tests rather
	// than silently behaving as "allow".
	PathTierUnknown PathTier = iota

	// PathTierWorkspace — the path is inside the agent's workspace
	// root (or the sprout config dir). No approval required.
	PathTierWorkspace

	// PathTierExternal — outside the workspace but not in a system
	// or off-CWD home directory. Eligible for the "Allow this folder
	// for the rest of the session" approval choice. Once a parent
	// folder is in the agent's session allowlist, future accesses
	// under it auto-approve.
	PathTierExternal

	// PathTierSensitive — system directories (/etc, /usr, ...) OR
	// home-directory paths when the agent's CWD is outside the user's
	// home. These ALWAYS prompt and CANNOT be added to the session
	// allowlist. The "Allow folder this session" choice is hidden
	// from the dialog for this tier.
	PathTierSensitive
)

func ClassifyPathAccess

func ClassifyPathAccess(path, workspaceRoot, homeDir, cwd string) PathTier

ClassifyPathAccess decides which approval tier a path falls into. All three input paths should be absolute (or empty for unset fields). Behavior:

  1. Inside workspaceRoot → PathTierWorkspace.
  2. Under a known system directory (e.g. /etc, /usr, /var on Unix; C:\Windows, C:\Program Files on Windows) → PathTierSensitive.
  3. Under the user's home dir AND the agent's CWD is NOT under home → PathTierSensitive (working in /tmp, accessing ~ is unusual and shouldn't get session-wide allowlisting).
  4. Anything else outside the workspace → PathTierExternal.

homeDir and cwd are passed explicitly so tests can drive each branch deterministically. Production callers use os.UserHomeDir and the agent's effective CWD.

Symlinks: this classifier compares cleaned path strings; it does NOT call filepath.EvalSymlinks. The filesystem layer (pkg/filesystem) resolves symlinks before returning ErrOutsideWorkingDirectory, so the path we receive here is already the symlink-resolved target — the comparison is correct for cases where the filesystem layer hands us a real path. For non-filesystem callers (e.g. the WebUI file API consulting IsFolderSessionAllowed), the path is whatever the caller resolved. The classifier is therefore advisory: if you've crafted a clever symlink to dodge tier classification, the filesystem layer still enforces its own checks at write time.

func (PathTier) String

func (t PathTier) String() string

String returns a stable lowercase identifier used in dialog extras (the WebUI uses it to pick a button set) and tests.

type RiskAgent

type RiskAgent interface {
	// Workspace + session state.
	GetWorkspaceRoot() string
	HasPasswordPrompter() bool
	IsFolderSessionAllowed(absPath string) bool
	GetConfig() *configuration.Config

	// Persona cascade.
	EvaluateOperationRisk(command string) configuration.RiskLevel

	// Seam accessors (see pkg/agent/risk_seam_accessors.go +
	// approval_seam_accessors.go).
	IsGitWriteAllowed() bool
	EffectiveCwd() string
	HomeDir() string
	DebugEnabled() bool
	DebugLogf(format string, args ...interface{})
}

RiskAgent is the surface the risk resolver (ResolveToolRisk) needs from the agent.

type RiskAssessment

type RiskAssessment struct {
	Level configuration.RiskLevel

	// IsHardBlock is true for critical-tier operations that no approval can
	// override (rm -rf /, fork bombs, mkfs).
	IsHardBlock bool

	RequiresIntentConfirmation bool

	Sources []RiskSource

	Reason string

	// PathTier and FileMode are structured fields for file-touching tools.
	PathTier PathTier

	FileMode string
}

RiskAssessment is the canonical, single-vocabulary verdict for a tool call.

func AssessmentFromClassifier

func AssessmentFromClassifier(res tools.SecurityResult) RiskAssessment

AssessmentFromClassifier maps a static-classifier SecurityResult onto the canonical scale: SAFE→Low, CAUTION→Medium, DANGEROUS→High, hard-block→Critical.

func AssessmentFromPersonaCascade

func AssessmentFromPersonaCascade(level configuration.RiskLevel, reason string) RiskAssessment

AssessmentFromPersonaCascade builds an assessment from the persona/risk- profile cascade's RiskLevel verdict for a command.

func ResolveToolRisk

func ResolveToolRisk(a RiskAgent, toolName string, args map[string]interface{}) RiskAssessment

func (RiskAssessment) Combine

func (ra RiskAssessment) Combine(other RiskAssessment) RiskAssessment

Combine folds two assessments into one, taking the most restrictive Level. Critical always hard-blocks. Preserves PathTier and FileMode from the original.

func (RiskAssessment) Explain

func (ra RiskAssessment) Explain() string

Explain renders a one-line human-readable summary of the assessment for diagnostics ("why was this gated?"). Sources are listed alphabetically for a stable rendering regardless of combination order.

type RiskSource

type RiskSource string

RiskSource identifies which check contributed to an assessment.

const (
	RiskSourceClassifier        RiskSource = "classifier"
	RiskSourcePersonaCascade    RiskSource = "persona-cascade"
	RiskSourceCriticalOp        RiskSource = "critical-op"
	RiskSourceGitHistoryRewrite RiskSource = "git-history-rewrite"
	RiskSourceGitRebase         RiskSource = "git-rebase"
	RiskSourceGitWrite          RiskSource = "git-write"
	RiskSourceFSTier            RiskSource = "fs-tier"
	RiskSourceWorkspacePolicy   RiskSource = "workspace-policy"
	RiskSourceHandler           RiskSource = "handler"
	RiskSourcePasswordPrompter  RiskSource = "password-prompter"
)

func MergeRiskSources

func MergeRiskSources(a, b []RiskSource) []RiskSource

MergeRiskSources concatenates two source lists, de-duplicating while preserving first-seen order so Explain() reads deterministically.

type SecurityAnalysis

type SecurityAnalysis struct {
	// Summary is a one-sentence plain-language description of what the
	// command does. Required.
	Summary string `json:"summary"`
	// Modifies lists files, directories, or system resources the command
	// touches (e.g. "No local files; executes arbitrary code from URL").
	Modifies string `json:"modifies"`
	// RiskAssessment is one of "low", "moderate", "high" — the LLM's own
	// assessment. Independent of (often overrides) the static classifier.
	RiskAssessment string `json:"risk_assessment"`
	// Recommendation is one of "approve", "review", "reject".
	Recommendation string `json:"recommendation"`

	// ChainLength is the number of subcommands in the analyzed chain.
	// 0 means single-command path or analyzer didn't run.
	ChainLength int `json:"chain_length,omitempty"`

	// ChainSubcommands are the per-subcommand strings, in order. Used by the UI stepper.
	ChainSubcommands []string `json:"chain_subcommands,omitempty"`

	// ChainClassifications holds the per-subcommand risk classification for the stepper dots.
	ChainClassifications []string `json:"chain_classifications,omitempty"`
}

SecurityAnalysis is the structured output of AnalyzeShellCommand.

func AnalyzeChain

func AnalyzeChain(ctx context.Context, client api.ClientInterface, model string, chain Chain, classifications []agenttools.ChainedClassification, cwd string) (*SecurityAnalysis, error)

AnalyzeChain analyzes a command chain using the LLM. Single subcommands use the single-command prompt; chains up to MaxChainSubcommandsForBatchPrompt use the chain-aware prompt with per-subcommand classifications; longer chains fall back to per-subcommand analyses via AnalyzeChainFallback.

func AnalyzeChainFallback

func AnalyzeChainFallback(ctx context.Context, client api.ClientInterface, model string, chain Chain, classifications []agenttools.ChainedClassification, cwd string) (*SecurityAnalysis, error)

AnalyzeChainFallback handles chains longer than MaxChainSubcommandsForBatchPrompt. It runs per-subcommand single-command analysis on each subcommand and synthesizes a single SecurityAnalysis: max severity, worst recommendation, deduped modifies.

func AnalyzeShellCommand

func AnalyzeShellCommand(ctx context.Context, client api.ClientInterface, model string, command, cwd string) (*SecurityAnalysis, error)

AnalyzeShellCommand sends a shell command to the agent's LLM for plain-language analysis.

type SecurityAnalysisCache

type SecurityAnalysisCache struct {
	// contains filtered or unexported fields
}

SecurityAnalysisCache caches LLM security analyses keyed by command string.

func NewSecurityAnalysisCache

func NewSecurityAnalysisCache() *SecurityAnalysisCache

NewSecurityAnalysisCache creates an empty cache.

func (*SecurityAnalysisCache) Clear

func (c *SecurityAnalysisCache) Clear()

Clear resets the cache to empty.

func (*SecurityAnalysisCache) Get

func (c *SecurityAnalysisCache) Get(normalizedKey string) (*SecurityAnalysis, bool)

Get returns the cached analysis for a normalized key, or false if not found.

func (*SecurityAnalysisCache) Set

func (c *SecurityAnalysisCache) Set(normalizedKey string, sa *SecurityAnalysis)

Set stores an analysis under a normalized key.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL