permission

package
v1.11.0 Latest Latest
Warning

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

Go to latest
Published: Jul 11, 2026 License: MIT Imports: 12 Imported by: 0

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

View Source
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).

View Source
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.

View Source
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

View Source
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.

View Source
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

func FormatRule(toolName, content string) string

FormatRule serializes the canonical identity of a rule (ToolName + optional escaped Content). Behavior and Source are stored separately.

func IsAutoMemPath

func IsAutoMemPath(memDir, absPath string) bool

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

func IsCheckpointFilePath(workdir, absPath string) bool

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

func IsPlanFilePath(workdir, absPath string) bool

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

func Load(workdir, evvaHome string) (*Store, []Warning)

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

func LoadMember(workdir, evvaHome, memberPath string) (*Store, []Warning)

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

func ParseRule(s string) (toolName, content string, ok bool)

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

func Save(workdir string, store *Store) error

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

func SetOnRequest(b Broker, fn OnRequest)

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 Behavior

type Behavior string

Behavior is the outcome of a Rule match or a Decide() call.

const (
	BehaviorAllow Behavior = "allow"
	BehaviorDeny  Behavior = "deny"
	BehaviorAsk   Behavior = "ask"
)

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.

func NewBroker

func NewBroker() Broker

NewBroker returns a Broker backed by an in-memory request map. There's no persistent state — a fresh Broker per process is correct.

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

func Decide(call ToolCall, mode Mode, store *Store, hint Hint, workdir, memDir string) Decision

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:

  1. 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).
  2. ModeBypass → allow (no further rule lookup; bypass silences prompts, not prohibitions).
  3. Ask rules → ask (a user-forced prompt overrides mode auto-allow).
  4. 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".
  5. Write/Edit confined to memDir → allow (auto-memory carve-out; after the plan-mode ceiling so plan mode still denies, inert when memDir == "").
  6. ReadOnlyOrSelfTools → allow (the baseline safe set).
  7. Bash + classifier says read-only → allow.
  8. ModeAcceptEdits-only: - tool ∈ AcceptEditsAutoAllow (edit/write/notebook_edit) → allow. - Bash + classifier says common-fs (mkdir/mv/cp/touch/...) → allow.
  9. Allow rules → allow.
  10. 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.
const (
	ModeDefault     Mode = "default"
	ModeAcceptEdits Mode = "accept_edits"
	ModePlan        Mode = "plan"
	ModeBypass      Mode = "bypass"
)

func ParseMode

func ParseMode(s string) (Mode, bool)

ParseMode returns the Mode for s, or (ModeDefault, false) if s is unknown.

func (Mode) Next

func (m Mode) Next() Mode

Next returns the next mode in the Shift+Tab cycle. Falls back to ModeDefault for an unknown mode (defensive — shouldn't happen).

func (Mode) Valid

func (m Mode) Valid() bool

Valid reports whether m is a known mode.

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

type Rule struct {
	ToolName string
	Content  string
	Behavior Behavior
	Source   Source
}

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.

const (
	SourceProject Source = "project"
	SourceUser    Source = "user"
	SourceSession Source = "session"
)

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

func (s *Store) AddSessionRule(r Rule)

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

func (s *Store) ReplaceAll(rs []Rule)

ReplaceAll atomically swaps the entire rule list. Used by the loader so a settings-file reload doesn't expose a half-written intermediate state.

func (*Store) Snapshot

func (s *Store) Snapshot() []Rule

Snapshot returns a defensive copy of the current rule list for inspection. Callers should treat the result as immutable.

type ToolCall

type ToolCall struct {
	Name  string
	Input []byte // raw JSON; tool-specific matchers parse what they need
}

ToolCall is the minimum shape Decide() needs to evaluate a rule. The agent loop fills this in from llm.ToolCall before calling Decide.

type Warning

type Warning struct {
	Path string
	Err  error
}

Warning is a non-fatal load error. The caller surfaces these on stderr like memdir / skill warnings; the agent still starts so a malformed permissions.json doesn't brick the session.

func (Warning) Error

func (w Warning) Error() string

Jump to

Keyboard shortcuts

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