Documentation
¶
Overview ¶
Package permissions implements the permissions layer that gates tool calls. Three files carry the rules:
- /etc/yottacode/permissions.json optional machine-wide administrator rules
- .yottacode/permissions.json committable, project-shared rules
- .yottacode/permissions.local.json gitignored, personal additions
The system file is read-only to yottacode. All three files merge at load time; deny rules are checked before ask rules, which are checked before allows.
{
"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, Browser) — 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 > Ask > Allow > Default. Default means "the tool's own RequiresApproval policy decides" (i.e. the agent loop's pre-existing behavior). Ask outranks allow so a broad convenience allow cannot suppress an explicit "always confirm this sensitive target" rule.
Index ¶
- Variables
- func BrowserNavigateHost(rawURL string) (host string, ok bool)
- func DeriveAllowRule(toolName, argsJSON, cwd string, normalize PathNormalizer) (rule string, ok bool)
- func DeriveAllowRules(toolName, argsJSON, cwd string, normalize PathNormalizer) (rules []string, ok bool)
- func DeriveDenyRule(toolName, argsJSON, cwd string) (rule string, ok bool)
- func KnownPermName(name string) bool
- func NormalizeDiffPath(raw string) (string, error)
- func ParseDiffPaths(diff string) []string
- func ParseDiffPathsStrict(diff string) ([]string, error)
- func StorageRoot(cwd string) string
- func SupportsToolName(toolName string) bool
- type Decision
- type PathNormalizer
- type Permissions
- func (p *Permissions) AddAllow(rule string) error
- func (p *Permissions) AddAllowConfirmed(rule string, confirmed bool) error
- func (p *Permissions) AddDeny(rule string) error
- func (p *Permissions) AddSessionAllow(rule string) error
- func (p *Permissions) EnsureFiles() error
- func (p *Permissions) Evaluate(toolName, argsJSON string) Decision
- func (p *Permissions) EvaluateWithRule(toolName, argsJSON string) (Decision, Rule)
- func (p *Permissions) EvaluateWithRuleAt(liveCwd, toolName, argsJSON string) (Decision, Rule)
- func (p *Permissions) LintWarnings() []string
- func (p *Permissions) LocalPath() string
- func (p *Permissions) Reload() error
- func (p *Permissions) SharedPath() string
- func (p *Permissions) Snapshot() (deny, allow, ask []Rule)
- func (p *Permissions) SystemPath() string
- type PolicyFileReport
- type Rule
- type Target
- type ValidationReport
Constants ¶
This section is empty.
Variables ¶
var ErrRequiresConfirmation = errors.New("permissions: MCP(*) requires explicit confirmation before persisting")
ErrRequiresConfirmation is returned by AddAllowConfirmed when persisting an MCP(*) rule was attempted without confirmed=true. MCP(*) covers every configured server and tool, so it needs an explicit second confirmation before it's written to disk — see AddAllowConfirmed.
Functions ¶
func BrowserNavigateHost ¶ added in v0.5.0
browserWebHost extracts the host of a plain http(s) URL, or reports false. The authority is cut the way Chrome cuts it (at the first of / \ ? #), so a space or backslash later in the path or query — common in model-written URLs like ".../search?q=long island" — doesn't matter, but anything odd in the authority itself does. Go's own parse must then agree on the host. BrowserNavigateHost returns the lowercase host of a plain, unambiguous http(s) URL, or ok=false for anything else (another scheme, userinfo, a backslash or glob character in the authority, non-ASCII, …). It is the same strict reading the Browser(navigate <host>) rules use, exported so other policy code (the /auto safety floor) judges a URL exactly as the permission rules do instead of re-parsing it its own way.
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 *)`. A chained command (;|&&||) yields one rule per distinct segment verb (see DeriveAllowRules). Any segment whose first token is in a small "obviously dangerous" set, or any substitution/unbalanced quoting, makes the whole call ok=false — the user should write the rule by hand instead of getting a footgun-wide blanket grant.
- Worktree: `Worktree(enter *)`, `Worktree(exit keep *)`, `Worktree(exit auto *)`. cleanup=remove is never derived.
- 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 + " *".
- MCP: exact `MCP(<server>/<tool>)` only — never a glob. A destructive MCP tool must not become auto-allowed just because it shares a server prefix with a tool the user actually meant to bless; see the destructive-glob-refusal enforcement in internal/agent/loop.go, which this pairs with.
- Browser: `Browser(<verb>)` for the approval-gated browser_* tools (e.g. `Browser(inspect)`), and `Browser(navigate <host>)` — one exact site — for browser_navigate. Not derived for upload/download (local file bytes cross the write-path boundary) or for navigations that aren't a plain http(s) host. See browser.go.
- 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 DeriveAllowRules ¶ added in v0.5.0
func DeriveAllowRules(toolName, argsJSON, cwd string, normalize PathNormalizer) (rules []string, ok bool)
DeriveAllowRules is DeriveAllowRule for calls that need more than one rule to cover them. Today that is only run_bash with a chained command (`gofmt -w x.go && go test ./...`): Bash rules are matched per segment (see targetFor), so one rule per distinct segment verb covers the whole chain exactly as the evaluator will check it. All-or-nothing — if any segment is dangerous or unparseable, nothing is derived, so a one-click grant never silently covers only part of a chain.
func DeriveDenyRule ¶ added in v0.4.0
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, Git and Browser, 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.
Browser tools are covered too (see deriveBrowserDeny): every approval-gated browser_* verb, and one exact site for browser_navigate.
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 KnownPermName ¶ added in v0.5.0
KnownPermName reports whether name is a permission rule namespace that the tool-target registry can emit. Keeping this exported from the permissions package gives linting, docs tests, and future UX one source of truth instead of each feature carrying its own stale prefix list.
func NormalizeDiffPath ¶ added in v0.5.0
NormalizeDiffPath decodes one Git diff path token and strips the conventional a/ or b/ prefix. It rejects malformed quoted paths so validation and git apply cannot disagree about the target.
func ParseDiffPaths ¶
would touch. Invalid Git quoting returns no paths (the strict variant gives callers the diagnostic); callers must fail closed before applying a patch.
func ParseDiffPathsStrict ¶ added in v0.5.0
ParseDiffPathsStrict decodes Git-quoted paths and rejects ambiguous or malformed encodings rather than validating a different path than git apply.
func StorageRoot ¶ added in v0.5.0
StorageRoot returns the directory whose .yottacode/ holds the personal permissions.local.json for a session running in cwd. Inside a linked git worktree it is the main repository root (canonicalized); otherwise it is cwd exactly as spelled. Exported so the write-path deny list can protect the same file the permissions store actually writes.
Not being in a git repo is the normal non-worktree case, so errors fall back to cwd silently. The .git-directory check keeps submodules (common dir under <super>/.git/modules/) and bare repos from redirecting storage into git internals.
func SupportsToolName ¶ added in v0.5.0
SupportsToolName reports whether toolName produces a permission target at all — i.e. whether calls to it can ever be governed by permissions.json / permissions.local.json rules. targetFor returns an empty Target.PermName for any tool name it doesn't recognize, which Evaluate treats the same as "no rule matched" (Default). That's the right behavior for the agent loop, but a caller presenting the distinction to a human — `yottacode permissions test` — needs to tell "no rule matched, falls back to the tool's own approval policy" apart from "this tool isn't subject to permission-file matching in the first place", hence this export.
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 )
type PathNormalizer ¶ added in v0.3.0
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 the optional system policy and the two project policy files.
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 LoadWithSystemPath ¶ added in v0.5.0
func LoadWithSystemPath(cwd, systemPath string) (*Permissions, error)
LoadWithSystemPath loads permissions using an explicit system-policy path. The project-local permissions file (where grants persist) is stored at the main repository root when cwd is inside a git worktree (see StorageRoot). Rule evaluation still uses cwd so path rules remain relative to the active worktree. An empty systemPath disables the system source, which is useful for isolated callers and tests that must not depend on the host administrator policy.
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.
AddAllow is a thin wrapper around AddAllowConfirmed with confirmed=false; DeriveAllowRule never itself produces an MCP(*) pattern (see its MCP case), so today's two AddAllow callers never trip the confirmation gate. A future entry point that accepts an arbitrary user-typed rule string must call AddAllowConfirmed directly and handle ErrRequiresConfirmation.
func (*Permissions) AddAllowConfirmed ¶ added in v0.5.0
func (p *Permissions) AddAllowConfirmed(rule string, confirmed bool) error
AddAllowConfirmed is AddAllow with an explicit confirmation flag for the one pattern that needs one: persisting MCP(*) (every MCP server, every tool) requires confirmed=true, or it returns ErrRequiresConfirmation without writing anything. This does not apply to AddSessionAllow (in-memory only, lower stakes) and cannot gate a hand-edited permissions.json/ permissions.local.json reload — the /permissions lint warning is the signal there.
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) AddSessionAllow ¶ added in v0.5.0
func (p *Permissions) AddSessionAllow(rule string) error
AddSessionAllow appends an in-memory-only allow rule — the "[S] allow for this session" approval answer. Unlike AddAllow, nothing is written to disk: the grant lives only in p.sessionAllow for the lifetime of this Permissions value (one process), so it can't outlive the session or leak into a teammate's permissions.local.json. Idempotent against both the session list and the already-persisted allow list — no point keeping an in-memory duplicate of a rule that's already on disk.
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 > ask > allow) 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, Ask if any path matches ask, Allow only if every path matches an allow rule. 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) EvaluateWithRuleAt ¶ added in v0.5.0
func (p *Permissions) EvaluateWithRuleAt(liveCwd, toolName, argsJSON string) (Decision, Rule)
EvaluateWithRuleAt is EvaluateWithRule for a caller that knows the session's live working directory. Path rules still resolve against the cwd the policy was loaded with; liveCwd only feeds the worktree scope of commit-family Git calls (see scopeCommitTarget), because enter_worktree moves the session without reloading the policy.
func (*Permissions) LintWarnings ¶ added in v0.5.0
func (p *Permissions) LintWarnings() []string
LintWarnings returns human-readable warnings for permission rules that are valid but risky, stale, or redundant. It is intentionally advisory: the permission engine still honors the user's rules exactly as written, while the TUI can surface these notes in /permissions so broad local grants do not age into invisible footguns.
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 the project-shared permissions file 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.
func (*Permissions) SystemPath ¶ added in v0.5.0
func (p *Permissions) SystemPath() string
SystemPath returns the machine-wide administrator policy path.
type PolicyFileReport ¶ added in v0.5.0
type PolicyFileReport struct {
Name string `json:"name"`
Path string `json:"path"`
Status string `json:"status"`
Diagnostics []string `json:"diagnostics,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
PolicyFileReport describes one project permission policy source without changing the active permission store. Missing files are valid and reported separately so doctor can distinguish an absent policy from a broken one.
type Rule ¶
type Rule struct {
Tool string // e.g. "Bash", "Edit"
Pattern string // e.g. "go test *", "internal/**"
// Source identifies the policy file that supplied the rule. It is the
// absolute path for loaded files and "session" for in-memory grants.
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.
Multi bool
// Unparseable means the command could not be structurally matched with
// confidence; permission evaluation must fall back to normal approval.
Unparseable bool
// through doublestar instead of the free-form glob matcher.
IsPath bool
// Scope is where a commit-family Git call is happening: "worktree" or
// "checkout" (see scopeCommitTarget). It is set by the harness from the
// session's cwd and kept OUT of Descriptor on purpose, so nothing the
// model controls (a commit message, a git arg) can forge it. Empty for
// every other call, which therefore never matches a scoped rule.
Scope string
}
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.
type ValidationReport ¶ added in v0.5.0
type ValidationReport struct {
Status string `json:"status"`
Files []PolicyFileReport `json:"files"`
Issues []string `json:"issues,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
ValidationReport is the read-only result of validating all project policy sources. Issues block startup-quality confidence; warnings are advisory.
func Validate ¶ added in v0.5.0
func Validate(cwd string) ValidationReport
Validate checks both project permission files independently. It never writes files or changes the policy used by the running agent.
func ValidateWithSystemPath ¶ added in v0.5.0
func ValidateWithSystemPath(cwd, systemPath string) ValidationReport
ValidateWithSystemPath validates system, shared, and local policy files. An empty systemPath omits the optional system source for isolated callers.