permissions

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package permissions implements the project-local permissions layer that gates tool calls. Two files carry the rules:

  • .yottacode/permissions.json committable, team-shared rules
  • .yottacode/permissions.local.json gitignored, personal additions

Both files merge at load time. Each file holds three pattern lists:

{
  "permissions": {
    "allow": ["Bash(go test *)", "Edit(internal/**)"],
    "ask":   ["Bash(rm *)"],
    "deny":  ["Bash(curl * | sh)", "Edit(/etc/**)"]
  }
}

Each rule is "<Tool>(<pattern>)". Tool names are capitalized rule prefixes (Bash, Read, Write, Edit, Mkdir, Copy, Move, Delete, List, Glob, Grep, Fetch, Git, Github, Memory, Tests, Rollback) — distinct from internal tool names (run_bash, read_file, …). The mapping lives in tool_targets.go so per-tool descriptor extraction stays in one place.

Github(...) descriptors are the canonical verb name:

read_pr, read_pr_review_context, read_issue, list_open_issues,
create_pr, update_pr, add_pr_comment

Wildcards work as usual, so `Github(read_*)` covers every read, `Github(*_pr)` covers every PR verb, and `Github(*)` is the catch-all. Owner/repo scoping is deferred until cross-repo work lands — for now everything resolves against the cwd's git remote.

Pattern semantics:

  • Path-typed permissions (Read/Write/Edit/Mkdir/Copy/Move/Delete/List) match against a cwd-relative path via doublestar so `**` works as expected. Absolute patterns (leading "/") match against the resolved absolute path.
  • String-typed permissions (Bash/Git/Glob/Grep/Fetch/Tests/Rollback) match against a free-form descriptor (Bash command, joined Git args, glob pattern, URL, …) with `*` = "any sequence" and `?` = "any single char".

Decision precedence: Deny > Allow > Ask > Default. Default means "the tool's own RequiresApproval policy decides" (i.e. the agent loop's pre-existing behavior).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DeriveAllowRule

func DeriveAllowRule(toolName, argsJSON, cwd string, normalize PathNormalizer) (rule string, ok bool)

DeriveAllowRule produces a sensible "always allow" pattern from a single tool call. Used when the user hits `a` in the approval modal.

Strategy per tool family:

  • Bash: take the first argv-token of the command + " *". e.g. `go test ./...` → `Bash(go *)`. Compound commands (multiple segments separated by ;|&&|||) and commands whose first token is in a small "obviously dangerous" set return ok=false — the user should write the rule by hand instead of getting a footgun-wide blanket grant.
  • Path-typed tools (Edit/Write/Mkdir/Delete/Read/List): In-cwd descriptors → `Tool(<absolute-cwd>/**)`. The rule documents which working directory it applies to, so a permissions.local.json copied to another project doesn't accidentally grant access there. Out-of-cwd descriptors → `Tool(<parent-dir>/**)`. Lets a single click on a write to `~/Desktop/notes.md` produce `Write(/home/me/Desktop/**)`. Suppressed when the parent dir is `/`, the user's $HOME directly, or a known system root (`/etc`, `/usr`, `/bin`, `/sbin`, `/var`, `/sys`, `/proc`, `/dev`, `/boot`, `/lib`, `/lib64`, `/root`, `/srv`). Hand-write those if you really want them — one-click blanket grants on system trees are a footgun.
  • Move/Copy: src and dst broadened independently with the same rule, joined with " -> ".
  • Git: first arg + " *".
  • Tests: same as Bash — first argv-token of the test command + " *".
  • Glob/Grep/Fetch/Rollback: not derived (too varied or too high-trust to grant blanket on one click).

ok=false means the modal should suppress the [a]lways-allow option for this call.

func DeriveDenyRule added in v0.4.0

func DeriveDenyRule(toolName, argsJSON, cwd string) (rule string, ok bool)

DeriveDenyRule produces a "never allow / block" pattern from a single tool call — the mirror of DeriveAllowRule, used when the user hits the "never" key in the approval modal. Scope is intentionally limited to Bash and Git, the calls a user most wants to block outright; other tools return ok=false so the modal doesn't offer a persistent block for them yet.

Two deliberate differences from DeriveAllowRule:

  • It does NOT suppress "dangerous" verbs — a permanent deny on `curl` or `rm` is the whole point — nor compound commands.
  • It blocks the command's leading VERB (`Bash(curl *)`), not an exact line. run_bash rules are matched per-segment with any-deny precedence, so a verb-level block reliably takes effect wherever that verb appears in a chain (an exact `curl … | sh` string would never match a single segment). The derived rule is shown in the modal before the user commits, so an over-broad block is visible.

func ParseDiffPaths

func ParseDiffPaths(diff string) []string

ParseDiffPaths extracts the unique set of target file paths a unified diff would touch. Used by apply_diff to (a) populate the permissions descriptor so user-authored Deny/Allow rules match the diff's targets, and (b) drive ValidateWritePath against DefaultDenyPaths so yottacode-managed state can't be patched through the diff surface.

Returned paths are repo-relative — the same form `git apply` would resolve against the work tree. The "a/" / "b/" prefixes git emits in `--- a/foo` / `+++ b/foo` are stripped. /dev/null entries (new-file source / deleted-file destination) are skipped; the paired non-null side still produces a path. Renames contribute both source and destination.

Types

type Decision

type Decision int

Decision is the verdict for a single tool call.

const (
	// Default means no rule matched; the loop falls back to the tool's
	// own RequiresApproval (read-only auto-execute, mutators prompt).
	Default Decision = iota
	// Allow means a rule explicitly approves this call — execute
	// silently, no prompt.
	Allow
	// Ask means a rule explicitly forces a prompt even if the tool would
	// normally auto-execute. Useful for things like `Read(.env)`.
	Ask
	// Deny means a rule explicitly refuses this call — never execute,
	// even under --yolo. The user wrote the
	// rule on purpose; bypass is a "skip prompts" knob, not an "ignore
	// my policy" knob.
	Deny
)

func (Decision) String

func (d Decision) String() string

type PathNormalizer added in v0.3.0

type PathNormalizer func(string) string

PathNormalizer is the optional callback DeriveAllowRule applies to path-like inputs (cwd plus any absolute descriptor) before turning them into a glob pattern. Used by the agent loop to inject `worktree.NormalizeForRule`, which rewrites the auto-generated worktree-name segment of a yottacode worktree path to `*` so `[A]-always` clicks don't bake ephemeral names into the saved rule. A nil normalizer is treated as identity.

type Permissions

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

Permissions holds the loaded rules from a project's permissions files. Concurrency-safe: the agent goroutine reads via Evaluate while the TUI may write via AddAllow.

func Load

func Load(cwd string) (*Permissions, error)

Load reads <cwd>/.yottacode/permissions.json and .yottacode/permissions.local.json (both optional), merges their rule lists, and returns a usable Permissions value. Missing files are not errors. Malformed files are surfaced so the user can fix them rather than silently running with stale rules.

func LoadEmpty

func LoadEmpty(cwd string) *Permissions

LoadEmpty returns a Permissions with no rules but with cwd set so AddAllow knows where to write. Useful for tests and for callers that want to skip disk I/O.

func (*Permissions) AddAllow

func (p *Permissions) AddAllow(rule string) error

AddAllow appends a rule to permissions.local.json (creating the file and parent dir if needed). Used by the TUI's "always allow" path. Idempotent: a duplicate rule is silently dropped.

func (*Permissions) AddDeny added in v0.4.0

func (p *Permissions) AddDeny(rule string) error

AddDeny appends a rule to the deny[] list of permissions.local.json (creating the file and parent dir if needed). The mirror of AddAllow, used by the TUI's "never / block" path. Because Deny outranks Allow in the precedence chain, a newly added deny immediately overrides any existing allow for the same pattern. Idempotent: a duplicate rule is silently dropped.

func (*Permissions) EnsureFiles added in v0.2.0

func (p *Permissions) EnsureFiles() error

EnsureFiles writes a full {allow, ask, deny} skeleton to any permissions file that is missing or contains only whitespace. Existing content is preserved — even an empty-arrays-only file is left alone so a user-customized layout (formatting, comments later, extra top-level keys) is never clobbered. The /permissions picker calls this before opening vim so the user always edits a fully- shaped file instead of an empty buffer.

func (*Permissions) Evaluate

func (p *Permissions) Evaluate(toolName, argsJSON string) Decision

Evaluate runs the precedence chain (deny > allow > ask) against a single tool call described by toolName + argsJSON. Returns Default when no rule matches, leaving the existing approval flow in charge.

Multi-target calls (Target.Descriptors set, e.g. apply_diff touching several files) use ratcheted semantics: Deny if any path is denied, Allow only if every path matches an allow rule, Ask if any path matches ask. The conservative Allow rule prevents a diff that mixes rule-covered and unknown paths from skipping the modal.

func (*Permissions) EvaluateWithRule added in v0.4.0

func (p *Permissions) EvaluateWithRule(toolName, argsJSON string) (Decision, Rule)

EvaluateWithRule is Evaluate plus the first matching rule that decided the verdict. The rule is used for human-facing provenance (which permissions file auto-allowed or denied the call); callers that only need the decision should use Evaluate.

func (*Permissions) LocalPath

func (p *Permissions) LocalPath() string

LocalPath returns the path to permissions.local.json for /permissions UX.

func (*Permissions) Reload

func (p *Permissions) Reload() error

Reload re-reads both files from disk, replacing the in-memory rule set. Useful after the user edits permissions.json with their editor.

reloadMu is held for the whole read+swap so concurrent reloads can't interleave: the disk read and the swap happen as one unit per caller. Evaluate still reads through mu and never blocks on a reload's disk I/O.

func (*Permissions) SharedPath

func (p *Permissions) SharedPath() string

SharedPath returns the path to permissions.json for /permissions UX.

func (*Permissions) Snapshot

func (p *Permissions) Snapshot() (deny, allow, ask []Rule)

Snapshot returns a copy of every loaded rule (deny + allow + ask) for /permissions display.

type Rule

type Rule struct {
	Tool    string // e.g. "Bash", "Edit"
	Pattern string // e.g. "go test *", "internal/**"
	// Source is "permissions.json" or "permissions.local.json" — used
	// only for diagnostics in /permissions.
	Source string
}

Rule is one parsed entry from the permissions file: tool prefix + the raw pattern between the parens.

type Target

type Target struct {
	// PermName is the rule prefix the file uses ("Bash", "Edit",
	// "Read", …). Empty when the tool isn't subject to permission
	// matching (no descriptor extractor is registered for it).
	PermName string
	// Descriptor is the string the rule pattern matches against:
	// command for Bash, cwd-relative path for filesystem tools, joined
	// args for Git, URL for Fetch, etc. Used when Descriptors is empty.
	Descriptor string
	// Descriptors carries multi-target calls (e.g. an apply_diff that
	// touches several files). Used in tandem with Multi: Multi=true
	// signals the evaluator to use multi-target precedence (any-deny,
	// all-allow, any-ask) regardless of slice length, so a tool whose
	// path-extraction failed (empty list) doesn't accidentally fall
	// back to single-target evaluation against an empty descriptor and
	// vacuously match Edit(*) style rules.
	Descriptors []string
	// Multi is set on tools whose evaluation must iterate Descriptors
	// even when the slice is empty. apply_diff is the canonical case:
	// a malformed or headerless diff yields zero descriptors but must
	// still avoid matching single-descriptor "" rules.
	Multi bool
	// IsPath flags path-typed targets so matchPattern can route them
	// through doublestar instead of the free-form glob matcher.
	IsPath bool
}

Target is the (permission-name, descriptor) pair extracted from a single tool call. The agent loop produces it per call and Permissions.Evaluate matches it against the rule set.

Jump to

Keyboard shortcuts

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