Documentation
¶
Overview ¶
Package permission implements evva's tool-permission system.
The model is small: every tool call is gated by Decide() against the active Mode and a Store of allow/deny/ask Rules drawn from three Sources (project, user, session). Decide() returns either an immediate Allow/Deny or escalates to a Broker that prompts the user through the TUI and writes the answer back to the blocked goroutine via a reply channel.
The package is pure: no logging, no filesystem (loader.go is the one exception, gated behind a separate function), no event sink. Callers wire it into the agent loop at state_machine.go and the TUI in the bubbletea v2 app.
Index ¶
- Constants
- Variables
- func FormatRule(toolName, content string) string
- func IsAutoMemPath(memDir, absPath string) bool
- func IsCheckpointFilePath(workdir, absPath string) bool
- func IsPlanFilePath(workdir, absPath string) bool
- func Load(workdir, evvaHome string) (*Store, []Warning)
- func LoadMember(workdir, evvaHome, memberPath string) (*Store, []Warning)
- func ParseRule(s string) (toolName, content string, ok bool)
- func Save(workdir string, store *Store) error
- func SetOnRequest(b Broker, fn OnRequest)
- type ApprovalRequest
- type Behavior
- type Broker
- type Decision
- type Hint
- type Mode
- type OnRequest
- type PlanModeState
- func (s *PlanModeState) AttachmentsSinceExit() int
- func (s *PlanModeState) ConsumePendingExitReminder() bool
- func (s *PlanModeState) ConsumeReentry() bool
- func (s *PlanModeState) HasExited() bool
- func (s *PlanModeState) PlanName() string
- func (s *PlanModeState) PrePlanMode() Mode
- func (s *PlanModeState) RecordAttachmentEmitted()
- func (s *PlanModeState) RecordTurnWithoutAttachment()
- func (s *PlanModeState) SetPlanName(name string)
- func (s *PlanModeState) SetPrePlanMode(m Mode)
- func (s *PlanModeState) Transition(from, to Mode)
- func (s *PlanModeState) TurnsSinceLastAttachment() int
- type Rule
- type Source
- type Store
- type ToolCall
- type Warning
Constants ¶
const CheckpointDirSegment = ".evva/checkpoints"
CheckpointDirSegment is the workdir-relative directory the checkpoint/rewind engine stores per-turn before-images and metadata under. Kept here next to PlanDirSegment / WorktreeDirSegment so the .evva/* family of evva-owned directories stays single-sourced (and so the user-guide's .gitignore advice has one canonical name to point at).
const PlanDirSegment = ".evva/plans"
PlanDirSegment is the workdir-relative directory that EnterPlanMode writes plan files into. Decide() carves out write-allow for paths under this segment even in ModePlan so the model can compose its plan while everything else stays read-only.
const WorktreeDirSegment = ".evva/worktrees"
WorktreeDirSegment is the workdir-relative directory the EnterWorktree tool (and AgentTool isolation) materializes git worktrees under. Kept here next to PlanDirSegment so the .evva/* family of evva-owned directories stays single-sourced.
Variables ¶
var AcceptEditsAutoAllow = map[string]bool{ "edit": true, "write": true, "notebook_edit": true, }
AcceptEditsAutoAllow is the set of tools auto-allowed in addition to ReadOnlyOrSelfTools when the mode is ModeAcceptEdits. Bash isn't in the map — the gate checks the classifier's IsReadOnly / IsCommonFS hint separately to decide whether to auto-allow a shell call.
var ReadOnlyOrSelfTools = map[string]bool{ "read": true, "tree": true, "grep": true, "glob": true, "web_fetch": true, "web_search": true, "json_query": true, "calc": true, "todo_write": true, "ask_user_question": true, "agent": true, "tool_search": true, "skill": true, "daemon_list": true, "daemon_output": true, "worktree_list": true, "enter_plan_mode": true, "exit_plan_mode": true, "lsp_request": true, "repo_map": true, "structured_output": true, "wf_task_create": true, "wf_task_update": true, "wf_task_verify": true, "wf_task_list": true, "wf_task_get": true, }
ReadOnlyOrSelfTools is the baseline auto-allow set: tools that either don't touch the filesystem (read, grep, web_*, calc) or are part of the agent's own coordination surface (subagent spawn, task list, tool_search, skill). Auto-allowed in every mode except where deny/ask rules intervene.
"Read-only" here means filesystem-write-free, not "doesn't change session state" — task_create / agent spawn do mutate session state, but they don't write files or run arbitrary shell. Those are the model's planning tools and should never block on a prompt.
In ModePlan, anything NOT in this set is denied outright.
Functions ¶
func FormatRule ¶
FormatRule serializes the canonical identity of a rule (ToolName + optional escaped Content). Behavior and Source are stored separately.
func IsAutoMemPath ¶
IsAutoMemPath reports whether absPath sits inside the auto-memory directory memDir. memDir is the already-resolved <appHome>/memory path — the caller derives it from memdir.MemoryDir so pkg/permission stays free of an internal/memdir import. Same containment shape as IsPlanFilePath (rejects the dir root itself, "..", siblings, and absolute-elsewhere). Port of ref/src/memdir/paths.ts:isAutoMemPath, narrowed to one fixed dir — evva drops ref's user-configurable autoMemoryDirectory and its malicious-repo attack surface (PRD §5.8). Empty memDir → false, so the carve-out is inert when auto-memory is off.
func IsCheckpointFilePath ¶ added in v1.8.2
IsCheckpointFilePath reports whether absPath sits inside <workdir>/.evva/checkpoints/. Same containment shape and caveats as IsPlanFilePath. Lets the checkpoint engine and any future carve-out share one definition of "is this evva-owned checkpoint storage."
func IsPlanFilePath ¶
IsPlanFilePath reports whether absPath sits inside <workdir>/.evva/plans/. Both args are resolved with filepath.Abs before comparison so callers can pass user-supplied paths without pre-normalising. An empty workdir or a path that can't be proven contained returns false (the carve-out should never apply when the caller can't prove containment).
func Load ¶
Load reads permission rules from <workdir>/.evva/permissions.json and <evvaHome>/permissions.json. A missing file is not an error (returns empty rules + no warning). A malformed file produces a Warning and is otherwise skipped.
The returned Store is populated and ready to use; callers can add session rules at runtime via Store.AddSessionRule.
func LoadMember ¶ added in v1.4.4
LoadMember is Load plus an optional member-scoped file: a per-member permissions.json whose rules load into ONLY the returned store (RP-11). This is how a swarm grants one non-leader a narrow lever — e.g. risk-monitor's store gets "http_request(POST .../halt)" while every other member's store (and POST .../strategy) still asks — without a shared file widening everyone. memberPath == "" makes it identical to Load. Member rules carry project-source semantics (a persistent configured grant, not a transient session approval).
func ParseRule ¶
ParseRule parses ref-compatible rule strings of the form `ToolName` or `ToolName(content)` with `\(`, `\)`, and `\\` escapes inside the content. Behavior and Source are not parsed — they come from the surrounding (allow|deny|ask) array in the JSON. Returns ok=false for empty or missing-tool-name inputs.
Degenerate forms `ToolName()` and `ToolName(*)` collapse to the tool-wide form (empty Content) — matching ref's behavior.
func Save ¶
Save writes the project-scope rules from store to <workdir>/.evva/permissions.json. User-scope rules are not written by Save — the user maintains <evvaHome>/permissions.json by hand. Session rules are intentionally not persisted.
Creates the .evva directory if missing.
func SetOnRequest ¶
SetOnRequest is exported on the concrete broker, not the interface, so callers building a Broker from NewBroker can register a callback without reflection.
Types ¶
type ApprovalRequest ¶
type ApprovalRequest struct {
ID string // empty on input — Broker.Request assigns one
AgentID string // who's asking (root or subagent ID)
ToolName string
ToolInput []byte // raw JSON; UI summarises to ~200 chars
InputDescription string // model-supplied `description` field from ToolInput; "" when absent
Mode Mode
Reason string // from Decide() — why we're asking
Hint Hint // pre-computed classification (Bash only today)
PlanContent string // ExitPlanMode-only; markdown plan body
}
ApprovalRequest is what the TUI needs to render the prompt.
PlanContent is non-empty only for ExitPlanMode requests — the tool fills it with the markdown body it read from <workdir>/.evva/plans/current.md so the approval overlay can render the plan inline. Other tools leave it empty; the overlay's plan branch keys off the non-empty check.
InputDescription carries the model-supplied `description` field from the tool input (Bash today; any future tool whose input schema includes a top-level `description` string gets the same treatment). It explains what THIS call is about to do — not what the tool does in general — so it's the right size for a user-facing approval prompt. Empty when the input has no such field; the overlay skips the line.
type Broker ¶
type Broker interface {
// Request blocks until the user responds OR ctx is cancelled.
// Returns Deny if the context is cancelled (the caller surfaces the
// ctx error to the LLM as a failed tool call).
Request(ctx context.Context, req ApprovalRequest) (Decision, error)
// Respond delivers the user's choice to the blocked Request. Idempotent
// per id — a second Respond for the same id is a no-op (covers the
// race where the user clicks while ctx cancellation is already in flight).
Respond(id string, d Decision) error
// Cancel discards a pending request without delivering a Decision.
// Used internally by Request when ctx is cancelled.
Cancel(id string) error
}
Broker is the back-channel between the gate (blocked tool goroutine) and the TUI (user interaction). When Decide returns BehaviorAsk, the gate calls Request and parks on the reply channel; the TUI later calls Respond with the user's choice.
Implementations are safe for concurrent Request/Respond/Cancel calls. A single Broker is shared by the root agent and all subagents — request IDs disambiguate.
type Decision ¶
type Decision struct {
Behavior Behavior
Reason string
AddRule *Rule // non-nil when user chose "Allow for this session"
}
Decision is the resolved outcome. Behavior is always Allow or Deny after Decide() returns (Ask is resolved by the Broker before reaching the agent loop). Source is set when the user chose "Allow for this session" so the caller knows where to write the new rule.
func Decide ¶
Decide runs the permission pipeline for a single tool call and returns the resolved Behavior + Reason. An Ask outcome is escalated to the broker by the caller (state_machine.go).
workdir is the project's working directory — used only for the plan-mode plan-file carve-out (step 4a). Callers without a workdir (tests, headless runs) may pass "" to skip the carve-out; the rest of the pipeline is workdir-independent.
memDir is the resolved auto-memory directory (<appHome>/memory) when auto-memory is on, or "" when it's off — callers pass memdir.MemoryDir's value (which is already gated on the auto-memory toggle). A write/edit confined to memDir auto-allows in default + accept-edits so the model can maintain its typed memory files without a prompt; an empty memDir makes the carve-out inert.
Pipeline:
- Deny rules → deny (absolute — they bind in every mode, bypass included). A write/edit into a SIBLING member's memory dir denies here too (the RP-25 fence; active only for swarm-memory-homed agents).
- ModeBypass → allow (no further rule lookup; bypass silences prompts, not prohibitions).
- Ask rules → ask (a user-forced prompt overrides mode auto-allow).
- ModePlan-only: - Write/Edit targeting <workdir>/.evva/plans/ → allow (plan-file carve-out). - Tool ∈ ReadOnlyOrSelfTools → allow. - Bash + classifier says read-only → allow. - Else → deny "plan mode forbids writes".
- Write/Edit confined to memDir → allow (auto-memory carve-out; after the plan-mode ceiling so plan mode still denies, inert when memDir == "").
- ReadOnlyOrSelfTools → allow (the baseline safe set).
- Bash + classifier says read-only → allow.
- ModeAcceptEdits-only: - tool ∈ AcceptEditsAutoAllow (edit/write/notebook_edit) → allow. - Bash + classifier says common-fs (mkdir/mv/cp/touch/...) → allow.
- Allow rules → allow.
- Else → ask.
The order ensures deny rules always win (step 1 — even in bypass mode, since bypass means "never prompt", not "ignore the operator's explicit prohibitions"; RP-24), user-forced asks always show when prompting is possible (step 3), and plan mode's hard ceiling is enforced before the auto-allow paths can let a write through (step 4 before steps 5 / 8).
type Hint ¶
type Hint struct {
IsReadOnly bool
IsCommonFS bool
IsDangerous bool
Matched string // entry that triggered the classification, for UI
Reason string
}
Hint carries pre-computed classification info from a tool-specific classifier. Today only Bash sets this; other tools see a zero-value Hint.
IsCommonFS marks commands like `mkdir`, `mv`, `cp` — they mutate but at a level the user has typically already approved of when picking `accept_edits`. `default` mode still asks for these.
type Mode ¶
type Mode string
Mode is one of the four permission stances. The model is intentionally small: each mode pins a clear safelist, and the gate's pipeline (deny rules → ask rules → mode safelist → allow rules → ask) is the same across every mode.
- ModeDefault: read-only tools and agent self-coordination (subagent, task_*, todo_*, tool_search, skill, ask_user_question) auto-allow. Bash auto-allows when the classifier reports a read-only command. Everything else asks. Safe for beginners and sensitive work.
- ModeAcceptEdits: ModeDefault + edit/write/notebook_edit auto-allow, plus common filesystem bash commands (mkdir, touch, mv, cp, ln, chmod, chown, rmdir). Best for iterating on code under review.
- ModePlan: same read-only safelist as ModeDefault, plus read-only bash commands via the classifier. Any other non-safelist tool is denied outright (no prompt). Best for exploring a codebase before deciding what to change.
- ModeBypass: every tool call runs with no prompting — except calls matching a deny rule, which are still rejected (deny rules bind in every mode; bypass silences prompts, not prohibitions). Dangerous- command classification still happens and is logged, but never blocks. Use only inside isolated containers or VMs.
type OnRequest ¶
type OnRequest func(req ApprovalRequest)
OnRequest is the callback the broker invokes when a new request is created (after the ID is assigned). The TUI subscribes here to render the approval overlay. Set via Broker.(*broker).SetOnRequest before any Request fires.
type PlanModeState ¶
type PlanModeState struct {
// contains filtered or unexported fields
}
PlanModeState collects the per-session state that plan mode needs in order to drive the per-turn <system-reminder> attachment system and restore the prior permission stance on exit.
The struct exists so that every callsite that flips the permission mode — the TUI's Shift+Tab handler, the EnterPlanMode / ExitPlanMode tools, future SDK control messages — funnels through a single side- effect hub (Transition). Without it, side effects (stashing prePlanMode, queuing the exit reminder, resetting reminder counters) end up scattered at every callsite and drift over time. Ports the design of ref/src/utils/permissions/permissionSetup.ts:transitionPermissionMode.
All fields are guarded by an internal mutex; safe for concurrent access from the agent loop and the TUI.
func NewPlanModeState ¶
func NewPlanModeState() *PlanModeState
NewPlanModeState constructs an empty plan-mode state holder. Cheap; the agent constructor calls this exactly once per agent.
func (*PlanModeState) AttachmentsSinceExit ¶
func (s *PlanModeState) AttachmentsSinceExit() int
AttachmentsSinceExit returns the count of plan-mode reminders that have been emitted since the last plan-mode exit. Used to choose full-vs-sparse on each emission.
func (*PlanModeState) ConsumePendingExitReminder ¶
func (s *PlanModeState) ConsumePendingExitReminder() bool
ConsumePendingExitReminder returns whether an "exited plan mode" reminder is owed and clears the flag. Called once per user-prompt turn by the attachment computer.
func (*PlanModeState) ConsumeReentry ¶
func (s *PlanModeState) ConsumeReentry() bool
ConsumeReentry returns whether a re-entry reminder is owed and clears the flag. The attachment computer calls this exactly once on the first user-prompt turn after a re-entry into plan mode.
func (*PlanModeState) HasExited ¶
func (s *PlanModeState) HasExited() bool
HasExited reports whether the session has exited plan mode at least once. The attachment computer reads + clears this when emitting the one-shot re-entry reminder.
func (*PlanModeState) PlanName ¶
func (s *PlanModeState) PlanName() string
PlanName returns the user-provided plan name set by enter_plan_mode. Empty string means "current" — PlanFilePath resolves the default.
func (*PlanModeState) PrePlanMode ¶
func (s *PlanModeState) PrePlanMode() Mode
PrePlanMode returns the mode that was active immediately before plan mode became active. Empty until the first plan-mode entry.
func (*PlanModeState) RecordAttachmentEmitted ¶
func (s *PlanModeState) RecordAttachmentEmitted()
RecordAttachmentEmitted is called by the attachment computer after it emits a plan-mode reminder this turn. Increments the cycle counter and resets the throttle counter.
func (*PlanModeState) RecordTurnWithoutAttachment ¶
func (s *PlanModeState) RecordTurnWithoutAttachment()
RecordTurnWithoutAttachment is called by the attachment computer on every user-prompt turn in plan mode where it chose NOT to emit a reminder. Bumps the throttle counter so the next eligible turn fires.
func (*PlanModeState) SetPlanName ¶
func (s *PlanModeState) SetPlanName(name string)
SetPlanName stores the user-provided plan name. Called by enter_plan_mode when the model supplies a plan_name in its input.
func (*PlanModeState) SetPrePlanMode ¶
func (s *PlanModeState) SetPrePlanMode(m Mode)
SetPrePlanMode lets callers force the pre-plan-mode value. Used by EnterPlanMode prior to the unified Transition refactor (kept on the API for backwards compatibility with the PlanModeController interface). New code should rely on Transition() to set this.
func (*PlanModeState) Transition ¶
func (s *PlanModeState) Transition(from, to Mode)
Transition runs the side effects when permission mode changes. Every entry path (Shift+Tab, EnterPlanMode tool, ExitPlanMode tool, future SDK control messages) MUST funnel through this function so reminder counters and pre-plan-mode stashing stay consistent across paths.
Idempotent on no-op transitions (from == to).
- non-plan → plan: stashes the prior mode (so ExitPlanMode can restore it), resets the attachment counters so the next user-prompt turn fires a full reminder.
- plan → non-plan: clears prePlanMode, sets pendingExitReminder so the next user-prompt turn carries a single "you have exited plan mode" attachment, and marks hasExited so any later re-entry includes a one-shot re-entry reminder.
- any other transition: no plan-mode side effect.
func (*PlanModeState) TurnsSinceLastAttachment ¶
func (s *PlanModeState) TurnsSinceLastAttachment() int
TurnsSinceLastAttachment returns the count of user-prompt turns since the last plan-mode reminder injection. Used by the attachment computer to throttle reminders.
type Rule ¶
Rule is one allow/deny/ask entry. ToolName must match a tool's wire name (see internal/tools/name.go). Content is tool-specific — for Bash it's a shell pattern; for Read/Write/Edit it's a path glob; empty means "every call to this tool."
type Source ¶
type Source string
Source tells you where a Rule came from. Used to scope a "Allow for this session" decision: a project rule goes to <workdir>/.evva/permissions.json, a session rule lives in memory only.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store holds the active set of allow/deny/ask rules across all three Sources. Project + User rules are populated by Load(); Session rules are added at runtime by the broker when the user picks "Allow for this session."
All Store operations are safe for concurrent use — the gate runs in dispatchToolCalls' parallel goroutines.
func NewStore ¶
func NewStore() *Store
NewStore returns an empty Store. The loader populates it with project and user rules at startup; callers add session rules at runtime.
func (*Store) AddSessionRule ¶
AddSessionRule appends a session-scope rule. Idempotent: a duplicate is silently dropped so the rule list doesn't grow unbounded if the user repeatedly approves the same call.
func (*Store) ReplaceAll ¶
ReplaceAll atomically swaps the entire rule list. Used by the loader so a settings-file reload doesn't expose a half-written intermediate state.