Help us shape the future of Go! Take the 2026 Go Developer Survey

permissions

package
v0.4.5 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

Capability tool-subjects: reserved grant subjects that fold a family of concrete file-tool subjects so one approval authorizes every member.

Simple-compound recognition, the versioned generalized literal-subject form, and generalized matching for the permission system.

Project-declared command list: the token prefixes a project's settings file approves for Auto posture to run without prompting.

Degradation of a failed permission grant to its in-memory form.

Builds and delivers the synthetic deny-reason envelope for denied tool calls.

Destructive program-of-concern classification: the product-authored set of programs whose `<program> :*` Program-scope grant authorizes known destructive subcommands, and the advisory predicate that lets the program-scope confirm warn more strongly. The signal is advisory only — it never blocks or alters a grant; a program absent from the set is treated as non-destructive for display purposes (allow-list of concern, not of safety).

The same predicate has a second, load-time use: a bare declared_commands entry naming a flagged program is rejected. That reuse does not make it a safety allowlist — a bare entry for an unlisted destructive-capable program (bash, sed, find, mv, curl, …) is still accepted.

Parses, validates, and serializes on-disk permission entries.

Real-grant permission matching across direct, literal, and structured-tool subjects with path-scope rules.

Direct-command stage normalization and direct-subject matching for the permission system.

Literal-command canonicalization and byte-equal literal-subject matching for the permission system.

Whole-command splitting: cd-prefix path-scope hint capture, quote-aware pipeline splitting, and direct-vs-literal-vs-rejected routing for the permission system.

Read-only raw-shell classification: the product-authored allowlist of side-effect-free executables and the per-invocation classifier that lets any active mode auto-allow a pure direct pipeline of allowlisted commands. Originally Plan-only, this classification now drives the read-only auto-allow in every mode; Plan additionally denies every other shape without prompting, while Build and Auto edits prompt for it.

Presentation-only authorization-prompt summary: a plain-language explanation of one prompted request, the requested targets it can establish from a closed shell grammar or a prepared tool's lexical arguments, and the request-shape counts both prompt surfaces render above the decision choices. Nothing here reaches matching, grants, events, or execution.

Path-scope cleaning/resolution, descendant matching, and request path-scope derivation.

Shared shell-word tokenizer for permission-subject normalization: splits a command stage into tokens while preserving quote/escape and original-span metadata.

Turn-scoped approval cache: per-turn memoization of permission outcomes.

Index

Constants

View Source
const (
	CapabilityWrite     = "write"
	CapabilityWriteTree = "write_tree"
	CapabilityRead      = "read*"
)

Reserved capability subjects. A capability subject is valid only as the subject of a kind:tool grant; ParseEntry rejects it for any other kind. Approving a capability grant authorizes every concrete member tool at the grant's path scope and descendants, without a separate prompt per member tool.

View Source
const OutcomeSourceConfined = "confined"
View Source
const OutcomeSourceDeclaredCommand = "declared_command"

OutcomeSourceDeclaredCommand is the permission_matched outcome_source a project's declared command-list hit records, valid only on a non-host path record with outcome once and no grant fields.

View Source
const OutcomeSourceDeclaredScope = "declared_scope"

OutcomeSourceDeclaredScope is the permission_matched outcome_source a run's declared file scopes record, for both the approval and the terminal denial.

View Source
const PromptFactsOverflowNotice = "… additional authorization facts; inspect the complete request"

Variables

This section is empty.

Functions

func AuditSubjectMatches

func AuditSubjectMatches(kind, requestSubject string, requestTokens, requestAssignments []string, requestClearEnv bool, storedSubject string) bool

For direct requests, requestTokens must be the persisted canonical tokens; re-splitting requestSubject loses quoted-token boundaries. requestAssignments and requestClearEnv must likewise come from a recorded request identity — an audit that cannot prove that state must not call this at all, because an empty list and a false flag assert the request carried no assignment and inherited the environment.

func BoundPromptFact

func BoundPromptFact(value string) string

BoundPromptFact renders one variable authorization fact for a fixed prompt block: every ASCII control byte becomes a visible \xHH escape kept whole, and the result is cut on a rune and escape boundary at 120 code points with an ellipsis.

func BuildBlockedPathDenyEnvelope

func BuildBlockedPathDenyEnvelope(target, entry string, floor bool) string

func BuildDenyEnvelope

func BuildDenyEnvelope(userText string) string

BuildDenyEnvelope produces the compact JSON deny envelope for a user denial. The reason is the user's text with leading/trailing ASCII whitespace trimmed and capped to 512 UTF-8 bytes; an empty result after trimming yields the default reason. denied_by is always the literal "user".

func BuildGuardDenyEnvelope

func BuildGuardDenyEnvelope() string

func BuildModeDenyEnvelope

func BuildModeDenyEnvelope(reason string) string

BuildModeDenyEnvelope produces the deny envelope for a mode-posture denial: the same pinned wire shape with denied_by "mode" and reason naming the active mode and restriction. reason is always supplied by the caller (modePosture), so the default-reason fallback never applies here; it is shared with the user path only to reuse the trim/cap normalization.

func BuildUnattendedDenyEnvelope

func BuildUnattendedDenyEnvelope(reason string) string

func CacheIdentity

func CacheIdentity(req MatchRequest) any

CacheIdentity returns the comparable key under which the turn cache records req's outcome.

func CanonicalHost

func CanonicalHost(host string) (string, error)

func CanonicalLiteralForm

func CanonicalLiteralForm(input string) (string, error)

CanonicalLiteralForm returns the canonical literal-command subject for input. Leading and trailing whitespace is stripped, the command is tokenized with the shared shell parser (the same tokenizer direct-command normalization uses), and the tokens are re-emitted separated by a single ASCII space (U+0020).

Each token is normalized conservatively so that commands which execute identically collapse to one subject. A token whose unescaped value is free of any character the tokenizer cannot prove inert is re-emitted from that value in one canonical spelling — bare when the value needs no quoting, single-quoted otherwise — so quoting-style-equivalent spellings (`'x'`, `"x"`, bare `x`, `a\ b`) collapse to one form. A token whose value carries expansion (`$`, “ ` “), globbing (`* ? [ ] { }`), or shell-operator/structure characters (`< > & | ; ( )` and friends) is preserved as its verbatim original span (ShellToken.Raw), because single-quoting it could change what the command executes; this keeps `'$x'`, `"$x"`, and `$x` distinct and leaves redirection/operator forms intact. A token shaped like a shell assignment word (a valid name followed by `=`) is likewise preserved verbatim, so an environment assignment (`FOO=bar`) never collapses onto a quoted command word (`"FOO=bar"`) the shell would instead try to execute as a program. A token whose value is a shell reserved word (`for`, `if`, `in`, …) is likewise preserved verbatim, so the unquoted grammar form (`for`, which the shell parses as a loop keyword) never collapses onto the quoted command-word spelling (`"for"`, an ordinary word naming a program) the shell parses differently. A token whose raw span carries a backslash-newline line continuation is also preserved, because the shell removes that sequence (so `echo\<newline>hi` runs as `echohi`) while the tokenizer models it as a literal newline — re-quoting would collapse it onto a different command. Re-emitting each token in a single fixed spelling separated by one delimiter is idempotent — CanonicalLiteralForm(CanonicalLiteralForm(x)) equals CanonicalLiteralForm(x).

Normalization never widens what a grant authorizes: the program, flags, operators, and token values are unchanged — only the textual quoting representation of provably-equivalent tokens is normalized. The canonical form is what callers store as the subject, hash for the audit-trail argument_hash, and match against. A malformed command (unterminated quote or dangling backslash) returns the tokenizer's error so callers never silently broaden a permission match.

func CapabilityForMember

func CapabilityForMember(member string) (string, bool)

func CapabilityMembers

func CapabilityMembers(subject string) ([]string, bool)

CapabilityMembers returns a copy of the concrete tool subjects the given capability subject expands to, and whether subject is a reserved capability. The copy keeps the underlying source-of-truth table immutable to callers.

func ClassifyDirectTokens

func ClassifyDirectTokens(tokens []string) (offered bool, anchorIdx int)

ClassifyDirectTokens applies the flag/positional rules to the canonical argument tokens that follow the program token. The anchor index is -1 when no safe anchor exists.

A token is a flag when its first byte is '-' — this covers long flags (`--verbose`), `--key=value` forms, bundled short flags (`-abc`), and a bare '-'. The literal '--' token terminates flag parsing and is itself not a positional, so every token after it is positional regardless of a leading '-'. A path-looking token after a flag is skipped; a path-looking token elsewhere suppresses the inferred anchor.

func ClassifyExceptional

func ClassifyExceptional(req MatchRequest, mc ModeContext) (authority string, classifiable bool)

func CleanAbsResolve

func CleanAbsResolve(home, baseDir, path string) (string, error)

CleanAbsResolve normalizes path into a cleaned, absolute, symlink-resolved directory path. It expands a leading ~ against home, resolves a relative path against baseDir, follows symlinks via filepath.EvalSymlinks, and requires the resolved target to be an existing directory. EvalSymlinks requires the path to exist, so a missing target is an error here — correct for a folder-grant anchor, which must name a real directory.

func ComputeArgumentHash

func ComputeArgumentHash(canonicalLiteralSubject string) string

ComputeArgumentHash returns the lowercase hex SHA-256 (64 chars) of the canonical literal subject, per the argument-hash algorithm.

func DecodeDirectRequestSubject

func DecodeDirectRequestSubject(requestSubject, commandSubject string) (assignments []string, clearEnvironment, ok bool)

DecodeDirectRequestSubject validates a recorded direct request identity against its exact command identity and reports the request's ordered assignments and whether it cleared the environment. ok is false unless the pair proves that state, so an assignment-free inherited environment is never inferred from command-only text.

func DeliverDeny

func DeliverDeny(toolCallID string, envelope string, appender RecordAppender)

DeliverDeny appends the deny envelope to the session record stream as a normal tool-result message: a role "tool" record with a fresh UUIDv7 id, the canonical millisecond UTC timestamp, string content equal to envelope, and tool_call_id referencing the denied assistant tool call. It does not flush; durability stays with the store's owner.

func DerivePathScope

func DerivePathScope(home, cdHint, processCWD, toolCwd, subjectKind string) (string, error)

DerivePathScope resolves the path scope of a request being approved, by this precedence: a non-empty cdHint (the cd <path> && prefix) wins, otherwise a structured-tool subject anchors on toolCwd, and otherwise the codemeld process CWD is used. The selected raw path is resolved; relative cd hints and a relative toolCwd resolve against processCWD. The result is always a concrete directory and never the all-paths sentinel "*", which is reserved for stored global grants.

func DirectRequestSubject

func DirectRequestSubject(commandTokens, assignments []string, clearEnvironment bool) (string, error)

DirectRequestSubject returns the request identity for one direct stage. Command tokens are serialized one shell word each and the assignments and clear-environment state are carried inside the versioned form, so no two distinct requests share an identity.

func DirectUnderReadable

func DirectUnderReadable(fixed []string, boundary string, tailWildcard bool) string

DirectUnderReadable is display only; the stored value is the versioned envelope and this spelling is never parsed back.

func EncodeCompoundSubject

func EncodeCompoundSubject(wrapped bool, stageSubjects, operators []string) (string, error)

EncodeCompoundSubject assembles the deterministic, versioned generalized stored subject from a wrapper flag, the per-stage scope subjects the chooser produced (each an Exact / Subcommand / Program direct-subject string such as `gofmt -w a.go` or `go build :*`), and the operators between them. Every stage subject is re-rendered to one canonical spelling so equal generalizations encode byte-identically (and therefore hash and dedup identically). It fails closed — returning an error and no subject — on a malformed shape, an operator that is not simple-compound syntax, a stage that does not parse to a program, or a stage whose program IsDestructiveProgram flags: a destructive program is ineligible for widening, so the whole compound must stay byte-exact and no generalized grant is produced for it.

func EncodeDirectEnvSubject

func EncodeDirectEnvSubject(assignments []string, clearEnvironment bool, subject string) (string, error)

EncodeDirectEnvSubject wraps a canonical direct scope subject and the request's ordered assignments into the versioned stored form. subject is the chooser-produced scope subject, so a bare trailing `:*` wildcard is preserved rather than quoted. clearEnvironment selects the disjoint v2 form, so a cleared stage can never be stored as its otherwise identical inherited-environment form.

func EncodeDirectUnderSubject

func EncodeDirectUnderSubject(tokens []string, pathIndex int, boundary string, tailWildcard bool) (string, error)

EncodeDirectUnderSubject builds the stored containment subject from a request's canonical tokens. Tokens after pathIndex are dropped, so they may vary only when tailWildcard says so.

func ExactSubject

func ExactSubject(program string, args []string) string

ExactSubject returns the Exact stored subject: the full canonical token sequence (program followed by its argument tokens) with no `:*` suffix. Each value is serialized as exactly one shell word, so an empty or whitespace-bearing token survives re-tokenization instead of collapsing and letting a later token be wildcarded.

func ExceptionalAuthority

func ExceptionalAuthority(tokens []string) (authority string, ok bool)

func ExpandBlockedEntry

func ExpandBlockedEntry(home, raw string) (string, error)

func IsCapabilitySubject

func IsCapabilitySubject(subject string) bool

func IsDeeperScope

func IsDeeperScope(candidate, current string) bool

func IsDescendantOrEqual

func IsDescendantOrEqual(path, scope string) bool

IsDescendantOrEqual reports whether path equals scope or lies strictly within it. A grant authorizes its scope and every descendant, never an ancestor or a sibling. Both arguments are expected to be cleaned absolute paths; the relative-path computation makes the check boundary-safe so that scope "/a/foo" does not match the sibling "/a/foobar".

func IsDestructiveProgram

func IsDestructiveProgram(program string) bool

IsDestructiveProgram reports whether granting a direct-command Program scope (`<program> :*`) for the given program should get the stronger destructive-breadth warning. It is advisory only: pure and side-effect-free, with no filesystem lookups, no argument or subcommand parsing, and no grant blocking — it informs warning copy and decides nothing.

The argument must be a canonical direct-command program token (the bare program name produced by direct-command normalization, e.g. PromptRequest.CanonicalProgram). It is matched exactly, so a path-like or otherwise non-canonical token such as `/tmp/rm` does not match by basename: the predicate derives no basename and only program tokens already canonicalized upstream can match.

func IsModeDenyEnvelope

func IsModeDenyEnvelope(envelope string) bool

IsModeDenyEnvelope reports whether envelope is a well-formed deny envelope carrying a recoverable denied_by that the turn loop re-issues, as opposed to a terminal user refusal. It shares the closed recoverability rule with IsRecoverableDeniedBy and fails closed: a malformed, reasonless, or unrecognized envelope returns false, so an unclassifiable denial is still treated as terminal.

func IsRecoverableDeniedBy

func IsRecoverableDeniedBy(deniedBy string) bool

IsRecoverableDeniedBy reports whether a decoded denied_by names a policy denial the turn loop re-issues instead of halting on.

func LiteralRewriteAsks

func LiteralRewriteAsks(stage StageNormalization, runDir string) bool

func LiteralRunsHg

func LiteralRunsHg(canonical string) bool

LiteralRunsHg and LiteralRunsPS report whether a literal-route command runs a named program in any stage, after the `/dev/null` redirects classification strips. They fail closed on text they cannot decompose, which only costs a route that never reached the program.

func LiteralRunsPS

func LiteralRunsPS(canonical string) bool

func MarshalEntry

func MarshalEntry(e PermissionEntry) ([]byte, error)

MarshalEntry serializes one entry to YAML for persistence callers, emitting keys in on-disk order and guaranteeing path_scope == "*" is a quoted scalar rather than a bare "*" (which YAML would read as an alias).

func MatchesDeclaredCommand

func MatchesDeclaredCommand(commandTokens, assignments []string, clearEnvironment bool, entries []DeclaredCommand) bool

A declaration is a prefix of the canonical direct token sequence and spells no environment, so it authorizes only the invocation that inherits codemeld's own environment: it is matched through the same rule a stored unversioned grant subject uses, under which an assignment-bearing or environment-clearing request is a distinct request that never matches. The environment is not cosmetic — dispatch applies it to the child process, an assigned or cleared PATH re-resolves the program name against that environment, and a program's own configuration variables load code the declared tokens never named. classifyReadOnlyShell refuses the read-only auto path on the same ground.

func MatchesDirectSubject

func MatchesDirectSubject(commandTokens, assignments []string, clearEnvironment bool, storedSubject string) bool

MatchesDirectSubject reports whether a direct request — its canonical command token sequence plus the ordered environment assignments stripped from it and whether it clears the environment — matches the stored direct-command grant subject. A versioned subject authorizes only its exact ordered assignments and clear state and applies its decoded scope subject to the command tokens; every unversioned subject keeps its legacy assignment-free, environment-inheriting reading, so neither an assignment-bearing nor a clearing request ever matches one.

func MatchesLiteralSubject

func MatchesLiteralSubject(canonicalInput string, storedSubject string) bool

MatchesLiteralSubject reports whether a canonical literal command subject matches a stored literal grant subject: a literal subject matches only itself, and two literal subjects are equal only when their canonical forms are byte-equal. Both arguments must already be canonical — callers pass the output of CanonicalLiteralForm — because matching performs no normalization itself. Whitespace- and quoting-style-equivalent commands match because CanonicalLiteralForm already collapsed them to one form upstream, not because this function re-normalizes; two forms that are not byte-equal after canonicalization never match here.

func ParseWritableFolders

func ParseWritableFolders(raw any, home, projectRoot string) (folders []string, notices []string, err error)

func PrefixSubject

func PrefixSubject(program string, prefix []string) string

func ProbeStageReadOnly

func ProbeStageReadOnly(tokens []string) bool

func ProgramSubject

func ProgramSubject(program string) string

ProgramSubject returns the Program stored subject: the shell-quoted program token, a space, then `:*` (e.g. `make :*`), authorizing any invocation of the program within the scope. A whitespace-bearing program is quoted first, so the wildcard stays its own token.

func ReadOnlyShell

func ReadOnlyShell(req MatchRequest, mc ModeContext) bool

func RelWithin

func RelWithin(base, path string, foldCase bool) (string, bool)

func ShellPathAsks

func ShellPathAsks(b *BlockedPaths, folder string, argv, candidates []string) bool

func ShellPathCandidates

func ShellPathCandidates(argv []string, folder, home string) []string

A relative candidate is joined without cleaning: `link/../x` names a different file than `x` when `link` is a symlink, and the blocked-path matcher compares both the cleaned and resolved forms.

func ShellQuote

func ShellQuote(value string) string

ShellQuote renders value as one shell word in canonical form: returned bare when every character is safe unquoted, otherwise single-quoted with any embedded single quote spliced as the standard '\” sequence. value is assumed free of expansion/glob/operator characters (see containsShellMeta), so single-quoting reproduces it verbatim with no shell interpretation.

func VisibleControlEscape

func VisibleControlEscape(value string) string

VisibleControlEscape renders untrusted request text for a terminal that has no scrollback discipline of its own: every ASCII control byte other than a line feed or tab becomes a visible \xHH escape, so a request cannot conceal, clear, or reposition what the prompt prints after it. Nothing is dropped or shortened, so the complete request stays inspectable.

Types

type ApprovalCache

type ApprovalCache interface {
	Lookup(req MatchRequest) (outcome string, hit bool)
	Record(req MatchRequest, outcome string)
}

type AuthorizeDeps

type AuthorizeDeps struct {
	Grants      GrantTable
	Cache       ApprovalCache
	Prompt      func(ctx context.Context, esc ScopeEscalation) (PromptOutcome, error)
	PromptBatch func(ctx context.Context, reqs []BatchPromptRequest) (PromptOutcome, error)
	// PromptIndependent asks the user about a group of independently authorizable requests and
	// returns an outcome per prompted request, index-aligned with reqs. It may ask one question for
	// several requests that are the same decision and fan that answer out to each of them, so the
	// number of screens shown is its own concern; this package only requires the per-request
	// outcomes, which it applies independently. Only AuthorizeIndependent uses it.
	PromptIndependent func(ctx context.Context, reqs []BatchPromptRequest) ([]PromptOutcome, error)
	PersistGrant      func(entry PermissionEntry) (PermissionEntry, error)
	// PersistGrantAt binds persistence to one request of an independent group by its index in the
	// AuthorizeIndependent input, so a failed persist degrades to that request's own path scope
	// rather than a sibling's. AuthorizeIndependent falls back to PersistGrant when it is nil.
	PersistGrantAt func(index int) func(entry PermissionEntry) (PermissionEntry, error)
	// EmitPromptShown receives the request's canonical direct tokens verbatim;
	// callers must never rebuild them from the flattened subject.
	EmitPromptShown   func(subjectKind, subject, pathScope string, directTokens []string, id DirectIdentity) error
	EmitDecision      func(subjectKind, subject, pathScope, outcome, grantKind, grantPathScope string, id DirectIdentity) error
	EmitDecisionGrant func(subjectKind, subject, pathScope, grantKind, grantPathScope, grantSubject string, id DirectIdentity) error
	EmitMatched       func(subjectKind, subject, pathScope, grantKind, grantPathScope, outcome, outcomeSource, argumentHash, directRequestSubject string) error
}

Avoid import cycles with internal/config and internal/tui.

type BatchPromptRequest

type BatchPromptRequest struct {
	Req        MatchRequest
	Escalation ScopeEscalation
	// Index is the stage's position in the AuthorizeBatch input.
	Index int
}

type BatchRequest

type BatchRequest struct {
	Req     MatchRequest
	ModeCtx ModeContext
}

type BlockedEntry

type BlockedEntry struct {
	Path   string
	Source string
}

func (BlockedEntry) Floor

func (e BlockedEntry) Floor() bool

type BlockedPaths

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

func NewBlockedPaths

func NewBlockedPaths(home string, entries []BlockedEntry) *BlockedPaths

func NewLiveBlockedPaths

func NewLiveBlockedPaths(home, projectRoot string, entries []BlockedEntry, reload func() BlockedReload) *BlockedPaths

func (*BlockedPaths) BlockedWithin

func (b *BlockedPaths) BlockedWithin(target string) (BlockedEntry, bool)

The floor is checked last so a configured entry, whose reason names it, is reported when both lie inside target.

func (*BlockedPaths) Blocking

func (b *BlockedPaths) Blocking(target string) (BlockedEntry, bool)

func (*BlockedPaths) Current

func (b *BlockedPaths) Current() *BlockedPaths

func (*BlockedPaths) Degraded

func (b *BlockedPaths) Degraded() bool

func (*BlockedPaths) Entries

func (b *BlockedPaths) Entries() []BlockedEntry

func (*BlockedPaths) FoldCase

func (b *BlockedPaths) FoldCase() bool

func (*BlockedPaths) Forms

func (b *BlockedPaths) Forms() []string

func (*BlockedPaths) Home

func (b *BlockedPaths) Home() string

func (*BlockedPaths) InRepository

func (b *BlockedPaths) InRepository(root string) ([]RepositoryEntry, error)

func (*BlockedPaths) OutsideProject

func (b *BlockedPaths) OutsideProject(path string) bool

func (*BlockedPaths) ProjectRoot

func (b *BlockedPaths) ProjectRoot() string

func (*BlockedPaths) SetNotice

func (b *BlockedPaths) SetNotice(notice func(string))

func (*BlockedPaths) SetOnDegradedChange

func (b *BlockedPaths) SetOnDegradedChange(changed func(degraded bool))

Holding the refresh lock makes removal wait out an in-flight refresh, so the old callback never runs after this returns.

type BlockedReload

type BlockedReload struct {
	Changed  bool
	Entries  []BlockedEntry
	Problems map[string]string
}

type Decision

type Decision struct {
	Outcome      string
	Grant        *PermissionEntry
	DenyEnvelope string
	// DenyReason and DenialSource describe a denial this package decided without a prompt,
	// so a caller can classify it without reading the envelope text. Both stay empty for an
	// allow and for a denial the prompt outcome supplied.
	DenyReason   string
	DenialSource string
	// ReadOnlyAuto marks the one allow the active mode granted because the request classified
	// read-only. A prompt, a grant, a cache hit, a declared-scope allow, and every other mode
	// posture leave it false.
	ReadOnlyAuto bool
	PersistErr   error
	// WrapUp is never set by Authorize: a caller's authorizer sets it on a refusal after which the
	// turn must end with one tool-less answer round.
	WrapUp bool
}

func Authorize

func Authorize(ctx context.Context, req MatchRequest, modeCtx ModeContext, deps AuthorizeDeps) (Decision, error)

func AuthorizeBatch

func AuthorizeBatch(ctx context.Context, reqs []BatchRequest, deps AuthorizeDeps) ([]Decision, error)

func AuthorizeIndependent

func AuthorizeIndependent(ctx context.Context, reqs []BatchRequest, deps AuthorizeDeps) ([]Decision, error)

AuthorizeIndependent authorizes a group of requests that are independently authorizable: each resolves on its own, so a denial never stops a sibling, and the requests that still need a decision are gathered into one prompt returning an outcome per request. Each request keeps its own subject, decision, grant, persistence scope, and deny envelope. A prompt error — a cancellation or a transport failure — resolves nothing, so no caller may run any member. AuthorizeBatch stays the direct-pipeline path, where one outcome covers every stage and a deny stops the pipeline.

type DeclaredCommand

type DeclaredCommand struct {
	Subject string
}

func ParseDeclaredCommands

func ParseDeclaredCommands(raw any) (entries []DeclaredCommand, notices []string, err error)

type DedupKey

type DedupKey struct {
	Kind      string
	Subject   string
	PathScope string
	HostScope string
	GrantKind string
}

type DirectIdentity

type DirectIdentity struct {
	CommandSubject string
	RequestSubject string
}

DirectIdentity carries a direct request's boundary-preserving identities to the event log, which stores them opaquely; only this package decodes them. Both are empty for every non-direct request.

type DirectScopeChoice

type DirectScopeChoice struct {
	Kind         DirectScopeKind
	Subject      string
	Pinned       []string
	Readable     string
	Boundaries   []string
	Boundary     int
	TailWildcard bool
}

DirectScopeChoice is one selectable stored subject for a direct-command grant. Pinned holds the argument tokens the subject stores verbatim (all of them for Exact, the wildcard's prefix for Prefix, none for Program), so a surface can render what a choice fixes without re-deriving it.

A DirectScopePath choice carries an opaque versioned envelope in Subject, so a surface renders Readable instead. Boundaries runs from the path argument's immediate parent outward and Boundary indexes the one this choice stores.

func (DirectScopeChoice) RequiresConfirmation

func (c DirectScopeChoice) RequiresConfirmation() bool

type DirectScopeKind

type DirectScopeKind int
const (
	DirectScopeExact DirectScopeKind = iota
	DirectScopePrefix
	DirectScopeProgram
	DirectScopePath
)

type DirectScopeModel

type DirectScopeModel struct {
	Program     string
	Args        []string
	Choices     []DirectScopeChoice
	Recommended int
	// contains filtered or unexported fields
}

DirectScopeModel is the direct-command grant-scope policy. It must stay presentation-neutral: no labels, warnings, or input handling, so every prompt surface renders its own copy over one policy.

func NewDirectScopeModel

func NewDirectScopeModel(tokens []string, home string) (DirectScopeModel, bool)

NewDirectScopeModel derives the scope ladder from a request's canonical tokens: Exact, then one Prefix choice per visible token boundary from the longest pinned prefix to the shortest, then Program. The zero-length prefix is deliberately absent — that subject is the Program choice, which is the only one requiring a confirmation.

home is the bootstrap-resolved home directory, canonicalized here and used only as the containment floor; callers must not resolve it themselves.

func (DirectScopeModel) PathChoiceAt

func (m DirectScopeModel) PathChoiceAt(boundary int) (DirectScopeChoice, bool)

func (DirectScopeModel) PathRow

func (m DirectScopeModel) PathRow() int

PathRow is the index of the containment choice in Choices, or -1 when none is offered.

type GrantTable

type GrantTable interface {
	Permissions() []PermissionEntry
	AppendPermission(PermissionEntry)
}

type HomeExpansion

type HomeExpansion struct {
	// Expand is true only when the token began with a bare (unquoted, unescaped)
	// leading `~` or `$HOME` followed by `/` or end-of-token — the sole forms
	// this slice expands. `~user` and general `$VAR` substitution stay false.
	Expand bool
	// Suffix is the token text after the stripped `~` / `$HOME` prefix (e.g.
	// "/srht/codemeld/que" for `~/srht/codemeld/que`, "" for a bare `~`). The
	// runner argv token is the resolved home directory followed by Suffix.
	Suffix string
}

HomeExpansion is the execution-only home-directory expansion for one direct token, carried parallel to StageNormalization.Tokens so runner argv can expand a bare leading `~` or `$HOME` while the canonical Tokens used for authorization subjects stay literal and unexpanded. It is deliberately kept off Tokens so a stored grant never holds the resolved home path.

type LayerKind

type LayerKind string

LayerKind names the settings layer an entry was read from. It is in-memory provenance: no settings file may carry it, and it takes no part in authorization or deduplication.

const (
	LayerLocal   LayerKind = "local"
	LayerProject LayerKind = "project"
	LayerGlobal  LayerKind = "global"
)

type LiteralReason

type LiteralReason string

LiteralReason names why a stage was routed to literal matching. It is informational (for events and prompts); routing is the same for every value.

const (
	// ReasonWrapperCommand: the leading program is a wrapper/indirect
	// launcher (sh, bash, env, command, nice, nohup, time, xargs).
	ReasonWrapperCommand LiteralReason = "wrapper-command"
	// ReasonBackgrounding: the stage contains an unquoted backgrounding `&`.
	ReasonBackgrounding LiteralReason = "backgrounding"
	// ReasonSubshell: the stage contains an unquoted subshell `(...)`.
	ReasonSubshell LiteralReason = "subshell"
	// ReasonProcessSubstitution: the stage contains an unquoted process
	// substitution `<(...)` or `>(...)`.
	ReasonProcessSubstitution LiteralReason = "process-substitution"
)

type MatchRequest

type MatchRequest struct {
	Kind          string
	Subject       string
	SubjectTokens []string
	// Assignments is part of what a grant authorizes, so an empty list is the
	// request "this command carries no assignment", never "unknown".
	Assignments []string
	// ClearEnvironment is likewise part of what a grant authorizes: false is the
	// request "this command inherits the environment", never "unknown".
	ClearEnvironment  bool
	PathScope         string
	Host              string
	FreshConfirmation bool
}

MatchRequest describes one permission check. Subject is the canonical subject string used by literal and tool matching, cache keys, and events; for a direct request it is the boundary-preserving request identity DirectRequestSubject builds, not the flattened command text. SubjectTokens is the canonical command token sequence and is required only when Kind == subjectKindDirect; literal and tool matching use Subject and ignore SubjectTokens. PathScope is the request's resolved, cleaned, absolute directory and is never the all-paths sentinel "*". Host is an already canonical hostname for host requests.

type MatchResult

type MatchResult struct {
	Matched       bool
	Grant         *PermissionEntry
	OutcomeSource string
}

MatchResult is the outcome of Match. On a real-grant match, Matched is true, Grant points at a copy of the winning entry, and OutcomeSource is outcomeSourceGrant. On no match, Matched is false, Grant is nil, and OutcomeSource is empty; Match never consults the turn cache, so a no-match result carries no cache outcome source — reading the cache after a no-match is owned by the turn cache and prompt orchestration.

func Match

func Match(req MatchRequest, grants []PermissionEntry) MatchResult

type ModeContext

type ModeContext struct {
	Mode            string
	Class           OperationClass
	ToolName        string
	Stages          []StageNormalization
	LiteralRoute    bool
	TargetPath      string
	MoveSource      string
	MoveDestination string
	ProjectRoot     string
	// DeclaredWrite and DeclaredDelete are the run's canonical declared file scopes. A
	// non-empty pair activates the ceiling, so an empty list under an active ceiling denies
	// its operation class rather than deferring it to ordinary authorization. Under ChildAgent a nil
	// DeclaredDelete or DeclaredMoves is an omitted declaration and a non-nil empty one was supplied
	// empty. No codemeld run flag sets DeclaredMoves.
	DeclaredWrite  map[string]bool
	DeclaredDelete map[string]bool
	DeclaredMoves  map[MovePair]bool
	// ChildAgent marks the declared scopes as a child agent's delegate declaration rather than a
	// codemeld run flag pair.
	ChildAgent bool
	// ChildInvocationID identifies which child agent's calls these are, for the attribution the
	// caller's event emitters record. Nothing here reads it; authorization is unchanged by it.
	ChildInvocationID string
	// DeclaredCommands is the project's declared command list: Auto posture only, and
	// never consulted in Plan or Build.
	DeclaredCommands []DeclaredCommand
	// DelegateWrite, DelegateDelete, and DelegateMoves are resolved before the grandchild starts, so the
	// subset checks against the declared forms are an authorization decision rather than a report on a
	// running child.
	DelegateWrite  map[string]bool
	DelegateDelete map[string]bool
	DelegateMoves  map[MovePair]bool
	OpenRead       bool
	ExternalRead   bool
	ShellPathAsk   bool
	NetworkEgress  bool
	// ExecutionConstrained is the launch self-test result, not the displayed capability label.
	ExecutionConstrained bool
	SettingsBroken       bool
	WritableFolders      []string
}

type MovePair

type MovePair struct {
	Source      string
	Destination string
}

type OperationClass

type OperationClass string
const (
	ClassReadOnly OperationClass = "read_only"
	ClassMutating OperationClass = "mutating"
)

type PermissionEntry

type PermissionEntry struct {
	Kind         string
	Subject      string
	PathScope    string
	HostScope    string
	GrantKind    string
	ArgumentHash string
	Layer        LayerKind
}

PermissionEntry is the in-memory form of one on-disk permission entry. PathScope is "*" for a global grant or a cleaned absolute path for a folder grant. ArgumentHash is set only when Kind == "literal". Layer is stamped by the loader that read the entry, never by the entry's own bytes.

func DegradeGrant

func DegradeGrant(entry PermissionEntry, requestPath string) PermissionEntry

DegradeGrant returns the entry to keep active in memory when persisting a grant fails: the grant stays usable for the run but can never be re-persisted. requestPath is the resolved, cleaned absolute request directory (never "*").

func ParseEntry

func ParseEntry(raw map[string]any, filePath string, index int) (PermissionEntry, error)

ParseEntry validates one decoded YAML mapping from the permissions list and returns the typed entry. index is the entry's position in the permissions array; it and filePath are woven into every error so load-time callers can surface -compatible failures (offending file, entry index, and the path_scope/grant_kind field pair) without re-parsing the entry.

func (PermissionEntry) DedupKey

func (e PermissionEntry) DedupKey() DedupKey

DedupKey returns the comparable identity used to merge or dedup entries.

type PromptChoice

type PromptChoice int

Avoid an import cycle: internal/tui imports internal/permissions.

const (
	PromptAllowOnce PromptChoice = iota
	PromptDeny
	PromptAllowSession
	PromptAllowFolder
	PromptAllowGlobal
	PromptAllowProject
)

type PromptOutcome

type PromptOutcome struct {
	Choice        PromptChoice
	StoredSubject string
	DenyEnvelope  string
}

type PromptSummaryInput

type PromptSummaryInput struct {
	Action          string
	Command         string
	RequestedTarget string
	Body            string
	HostRequest     string
	Stages          []string
}

PromptSummaryInput is the display-only payload one prompt renders, carried beside the MatchRequest so the summary never renders matching data.

type PromptSummaryResult

type PromptSummaryResult struct {
	Kind       string
	Sentence   string
	Effect     string
	Targets    []string
	Unknown    bool
	StageCount int
	Lines      int
	Chars      int
}

PromptSummaryResult is display data only: no matching subject, grant input, or path a caller could act on.

func PromptSummary

PromptSummary explains one authorization request for the prompt. It fails closed: any request whose effect and target it cannot establish from the closed shell grammar below or from a prepared tool's lexical arguments reports the unknown-effect fallback rather than a guess.

func (PromptSummaryResult) DisplayRows

func (s PromptSummaryResult) DisplayRows() []string

DisplayRows is the ordered fixed block both prompt surfaces render above the decision choices. The first two rows are the summary sentence and the request-shape line; a renderer with a row budget keeps those two and may drop the target rows that follow.

type RecordAppender

type RecordAppender interface {
	Append(sessions.Record)
}

RecordAppender is the minimal seam BuildDenyEnvelope's delivery path needs: *sessions.Store satisfies it via Append(sessions.Record), and tests pass a stub so they stay independent of session-file flushing.

type RepositoryEntry

type RepositoryEntry struct {
	Entry      BlockedEntry
	Candidates []string
}

type ScopeEscalation

type ScopeEscalation struct {
	NarrowerScope string
}

ScopeEscalation is a display-only signal computed on the prompt path. When NarrowerScope is non-empty, the request's path scope is strictly broader than (an ancestor of) a folder the user already approved for this same subject, and NarrowerScope names that narrowest already-approved folder. The prompt uses it to warn that the request broadens an existing grant; it changes nothing about matching, caching, or persistence. The zero value (empty NarrowerScope) means the request is not a scope escalation.

func FindScopeEscalation

func FindScopeEscalation(req MatchRequest, grants []PermissionEntry) ScopeEscalation

FindScopeEscalation reports whether req's path scope is a strict ancestor of an already-approved, same-subject grant — i.e. the request is broader than, and contains, a folder the user already allowed for the same kind and subject. It scans grants for a non-global grant whose kind and subject match req by the same rules Match uses (so a stored direct ":*" program scope or a tool capability still matches, not just a raw-string-equal subject), and whose path scope is a strict descendant of req's path scope. NarrowerScope is the narrowest (deepest) such already-approved scope, or empty when req broadens no same-subject grant.

It is display-only: the caller passes the result to the prompt purely to make the broadening salient — it reads grants but changes no matching, cache, or persistence. Global grants are skipped: a global grant would have matched req outright (so the prompt is never reached), and "*" is not a narrower folder.

type ShellToken

type ShellToken struct {
	// Value is the unescaped, unquoted content — the string a shell would pass
	// to the program as a single argv entry. Literal canonicalization normalizes
	// most tokens from this value (see CanonicalLiteralForm).
	Value string
	// Raw is the verbatim input span that produced this token, including the
	// original quote characters and backslashes. Literal canonicalization falls
	// back to Raw for tokens whose value carries expansion/glob/operator syntax
	// it cannot prove equivalent.
	Raw string
	// Quoted is true when any character of the token appeared inside single or
	// double quotes. Direct-command normalization uses it to keep a quoted `:*`
	// from being read as the wildcard token.
	Quoted bool
	// contains filtered or unexported fields
}

ShellToken is one whitespace-delimited word parsed from a command stage by tokenizeShell. It carries the parsed value plus enough original-span metadata for both direct-command and literal-command normalization to decide how to re-emit the token.

type SimpleCompound

type SimpleCompound struct {
	Wrapped   bool
	Stages    [][]string
	Operators []string
}

SimpleCompound is the ordered decomposition of a recognized simple compound command: its inner direct-command stages with their pinned operator structure. Wrapped reports the single accepted wrapper flavour, `sh -c '<compound>'`, as opposed to a bare simple compound; the two never cross-match. Stages holds each stage's canonical direct-command token sequence (program name followed by its arguments), and Operators holds the len(Stages)-1 operators between them, each `&&`, `;`, `||`, or `|`. It is the shape the per-stage scope chooser reads to build options, and the shape the generalized matcher compares a request against.

func RecognizeSimpleCompound

func RecognizeSimpleCompound(canonical string) (SimpleCompound, bool)

RecognizeSimpleCompound decomposes a canonical literal command into its ordered direct-command stages, or reports notSimple=false when the command is anything but a simple compound. The accepted shapes are the single wrapper form `sh -c '<stage> [&& <stage>]…'` and bare simple compounds/pipelines whose every stage is otherwise a direct command. Every other construct — a subshell, process substitution, backgrounding, a redirection, `$(...)`, a backtick, a nested wrapper, an env-assignment-bearing stage, an `sh -c` form with extra positional arguments, or any unparseable stage — returns false so the caller keeps today's byte-exact literal behaviour. It is conservative by construction: when in doubt it returns false, because a false "not simple" is only friction while a false "simple" would widen a grant.

canonical must already be the CanonicalLiteralForm output. The matcher decomposes the request command with this function and compares it against a decoded grant; the prompt uses it to drive the per-stage scope chooser.

func RecognizeSimpleCompoundForGrant

func RecognizeSimpleCompoundForGrant(canonical string) (SimpleCompound, bool)

RecognizeSimpleCompoundForGrant decides grant identity only: it ignores bare `/dev/null` redirects, so a caller deciding which executor runs the command must keep using SimpleCompoundStages, which still refuses every redirect.

type SplitResult

type SplitResult struct {
	CdHint            string
	Stages            []StageNormalization
	LiteralRoute      bool
	LiteralInput      string
	AllowedShell      bool
	RejectedConstruct string
}

SplitResult is the outcome of normalizing a whole shell command per the strip-then-split-then-normalize order. Exactly one of three routes applies and the caller selects on the boolean fields:

AllowedShell == false: a rejected construct was found.

  RejectedConstruct names it; the permission system is skipped entirely (no
  prompt, no grant authorizes). Stages is nil and LiteralRoute is false.
- AllowedShell == true && LiteralRoute == true: the command is syntactically
  accepted but outside the direct-command boundary (I/O redirection, a
  wrapper/literal-route stage, a bare simple compound, or a double-quoted
  command substitution). The whole
  prefix-stripped command is matched as a single literal subject — LiteralInput
  carries it (feed to CanonicalLiteralForm).
  Stages is nil.
- AllowedShell == true && LiteralRoute == false: every pipeline stage is a
  direct command. Stages holds one RouteDirect StageNormalization per stage,

each authorized independently.

CdHint carries the raw `<path>` from a leading `cd <path> && ` prefix, unquoted exactly as the shell would pass it to cd, or "" when no such prefix is present. It is preserved on the direct and literal routes so DerivePathScope can anchor the path scope; ~ expansion, absolute conversion, and symlink resolution are DerivePathScope's job, not this function's.

func SplitCommand

func SplitCommand(input string) (SplitResult, error)

SplitCommand normalizes a whole shell command into a SplitResult per the step order: (1) strip a leading `cd <path> && ` prefix once, capturing <path> as CdHint; (2) on the remainder, refuse rejected constructs, then route I/O redirection, wrapper/literal forms, and bare simple compounds (`&&`, `;`, `||`) to a single literal subject, or (3) split on unquoted `|` and normalize each stage through NormalizeDirectStage.

The returned error is reserved for malformed input that cannot be classified at all — an unterminated quote, a dangling backslash, an empty command, or an empty pipeline stage. A command that is well-formed but uses a construct outside the allowlist is NOT an error: it returns AllowedShell == false with RejectedConstruct set, because routes that case to the agent rather than to Go error handling. Operator detection is quote-aware: `|`, `;`, `&&`, `||`, newline/carriage-return separators, redirection, `$(`, and backticks inside single/double quotes or escaped are literal text, never shell syntax, except that a `$(` or backtick inside double quotes routes the command literal.

type StageContext

type StageContext int

StageContext exists for the flagless script-invocation grammar alone (scriptInvocationStage), which is admitted only for a standalone command so a script pipeline stays one literal subject executing through the outer `sh -c`. StageInCompound is the zero value so a caller that omits the context fails closed.

const (
	StageInCompound StageContext = iota
	StageSolo
)

type StageNormalization

type StageNormalization struct {
	Route  StageRoute
	Tokens []string
	// ExecForms is the per-token home-directory expansion for execution, index-
	// aligned with Tokens. It carries the minimal source-form detail dispatch
	// needs to expand a bare leading `~` / `$HOME` in runner argv only, without
	// changing the canonical Tokens used for matching. nil on a RouteLiteral
	// stage; on a RouteDirect stage every entry is present (Expand false for a
	// token that is not a bare leading HOME form, including the program token).
	ExecForms []HomeExpansion
	// Assignments holds the leading KEY=VALUE environment assignments stripped
	// from a RouteDirect stage (step 3), in original order, each as the
	// shell-resolved `KEY=VALUE` string (name and separator unquoted, value
	// already unescaped — `FOO="a b"` yields `FOO=a b`). They are deliberately
	// kept out of Tokens so direct-subject matching authorizes on the program and
	// its arguments alone, but preserved here so an authorized stage executes with
	// the environment the agent's command requested rather than the parent
	// environment only. nil when the stage had no leading assignments or routes to
	// literal matching.
	Assignments []string
	// ClearEnvironment is true for an accepted `env -i` stage: the child receives Assignments alone
	// rather than the inherited environment. It is also part of the stage's authorization identity,
	// so a cleared stage never matches, caches, or persists as its otherwise identical inheriting
	// form.
	ClearEnvironment bool
	// LookupPath is the $PATH resolution captured for an accepted `command -v <name>` or
	// `type <name>` stage (commandLookupStage, typeLookupStage). Both are shell builtins with no
	// executable to launch, so dispatch answers such a stage from this value instead of building runner
	// argv; resolving it a second time at execution would authorize one lookup and perform another.
	// Empty on every other stage.
	LookupPath string
	// ProgramUnescapedSeparator is true when the program word's source spelling has a `/` that was
	// bare or quoted rather than backslash-escaped.
	ProgramUnescapedSeparator bool
	// Source is prompt-only stage text.
	Source string
	Reason LiteralReason
}

StageNormalization is the result of normalizing one pipeline stage. When Route is RouteDirect, Tokens holds the canonical token sequence (bare program name followed by its arguments in original order) and Reason is empty. When Route is RouteLiteral, Tokens is nil and Reason explains the routing.

func LiteralStages

func LiteralStages(canonical string) []StageNormalization

func NormalizeDirectStage

func NormalizeDirectStage(stage string, ctx StageContext) (StageNormalization, error)

NormalizeDirectStage normalizes one already-isolated pipeline stage: it strips leading KEY=VALUE environment assignments, rewrites the leading executable to its bare program name, and returns the canonical token sequence for direct-command matching. Wrapper and indirect forms are tagged for literal routing rather than returned as direct tokens.

Preconditions owned by the caller: the pipeline has already been split on `|`, any `cd <path> && ` prefix has been stripped, rejected constructs (`;`, `&&` outside the allowed form, `||`, `$(...)`, backticks) have already been refused, and I/O redirection has already been routed to literal matching. This function does not re-detect those.

func SimpleCompoundStages

func SimpleCompoundStages(command string) (wrapped bool, stages []StageNormalization, operators []string, ok bool)

SimpleCompoundStages accepts canonical input for matching and source input for prompt display.

type StageRoute

type StageRoute int

StageRoute classifies how a normalized pipeline stage must be matched.

const (
	// RouteDirect means the stage is a plain exec-style command whose canonical
	// token sequence (StageNormalization.Tokens) is matched against
	// direct-command grants.
	RouteDirect StageRoute = iota
	// RouteLiteral means the stage is a wrapper or indirect form that
	// must not be treated as a direct command; the caller routes it to
	// literal-command matching instead. No direct tokens are produced.
	RouteLiteral
)

type TurnCache

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

TurnCache is the process-local, in-memory turn-scoped approval cache. It memoizes the resolved outcome of every permission prompt for the duration of a single model turn so an agent's retry-on-transient-failure pattern does not re-prompt the user within that turn, while every distinct user-driven turn still re-confirms intent. It holds no grants: a hit short-circuits with the cached outcome only and never creates or modifies a session/folder/global PermissionEntry. Real grants live in the matcher's table and always win over the cache, so callers consult it only when Match returns no match. The zero value is ready to use. Not safe for concurrent use; the turn loop drives it from a single goroutine.

func NewTurnCache

func NewTurnCache() *TurnCache

NewTurnCache returns an empty TurnCache ready for the first turn.

func (*TurnCache) Lookup

func (c *TurnCache) Lookup(req MatchRequest) (outcome string, hit bool)

Lookup returns the cached outcome for req and whether one was recorded this turn (read rule). A hit reads no grant — the caller short-circuits with outcome and shows no prompt. Consult Lookup only after the real-grant Match returns no match, since a real grant always wins over the cache.

func (*TurnCache) Record

func (c *TurnCache) Record(req MatchRequest, outcome string)

Record stores req's resolved outcome for the rest of the turn (write rule). Every prompt resolution is recorded, including deny, so a denied retry of the same subject within the turn short-circuits instead of re-prompting. outcome is opaque here — one of once/deny/session_grant/folder_grant/ global_grant per §Reserved Values — and is not validated. Recording creates or modifies no grant.

func (*TurnCache) Reset

func (c *TurnCache) Reset()

Reset empties the cache so the next turn starts with no cached decisions (clear rule). The caller invokes it at the start of every new user submission, before any of the new turn's permission checks run; the boundary itself is owned by prompt/runtime orchestration, while TurnCache only provides the hook.

Jump to

Keyboard shortcuts

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