approval

package
v0.9.0 Latest Latest
Warning

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

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

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrCommandNotFound = errors.New("approval: command not found in contract")

ErrCommandNotFound is returned by RequiresApproval when commandName does not match any github.com/mediusfy/modulex/contract.CommandDecl.Name in the given contract. See RequiresApproval's doc comment for why a caller must treat this as "requires approval", not as "does not require approval" — the (false, err) return shape keeps the boolean itself always strictly meaningful, but the error is not a green light.

Functions

func DefaultStorePath added in v0.8.0

func DefaultStorePath(root string) string

DefaultStorePath returns the well-known FileStore path for a repository at root: <root>/.modulex/approvals.json. Shared by every caller that needs to agree on this exact path without duplicating it — tools/mcpserver's run_verification and tools/agentcli's `modulex agent approve` are separate Go modules/processes, and a grant only bridges between them if both resolve the same path for the same root.

func RequiresApproval

func RequiresApproval(c contract.Contract, commandName string) (bool, error)

RequiresApproval reports whether commandName, as declared by c.Commands, requires human approval before running: true if its github.com/mediusfy/modulex/provenance.CommandClass is provenance.ClassApprovalRequired or provenance.ClassDestructive, false for provenance.ClassSafe, ClassMutating, or ClassNetworked.

Types

type Broker

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

Broker is the live, in-memory decision point for whether an elevated action identified by a Scope is currently authorized. See the package doc comment for the guarantees it makes: nothing is approved by default, every decision fails closed, and a matched grant is scoped exactly (never by action alone) and single-use.

func NewBroker

func NewBroker() *Broker

NewBroker returns a Broker with zero grants. No configuration, environment variable, or argument can start a Broker in an already-approved state: every Broker.Check/Broker.CheckToken call against a freshly constructed Broker returns provenance.StatusApprovalRequired until Broker.Grant is called explicitly. This is the concrete mechanism behind ADR-0032's "no elevated operation is enabled by default" requirement.

func (*Broker) ActiveGrants

func (b *Broker) ActiveGrants() []Grant

ActiveGrants returns every currently active grant — unexpired and unused — sorted deterministically by ApprovedAt then TokenHash, for auditing. The returned slice is a snapshot: mutating it, or the Grant values in it, does not affect the Broker's internal state. Always non-nil.

Remember that each returned Grant still carries its raw Token; see the package doc comment's "Token sensitivity" section before logging or displaying these — prefer Grant.String()/the default %v formatting, which never prints Token.

func (*Broker) Check

func (b *Broker) Check(scope Scope) provenance.Status

Check reports whether scope is currently authorized by any stored grant, searching across every grant regardless of which token it was issued under. If exactly one matching, unexpired, unused grant exists, Check consumes it (marks it Used) and returns provenance.StatusPass. In every other case — no grant at all, a grant for a different action or resource, an expired grant, an already-used grant, or any state this package did not anticipate — Check returns provenance.StatusApprovalRequired. There is no code path in Check that can return anything other than one of these two statuses.

Use Broker.CheckToken instead when the caller has (and should be required to present) the specific token a human handed it, rather than relying on the broker's own bookkeeping to find a matching grant.

func (*Broker) CheckToken

func (b *Broker) CheckToken(token string, scope Scope) provenance.Status

CheckToken reports whether token, presented as the credential for scope, is currently valid: token must identify a stored grant that is unexpired, unused, and whose Scope exactly matches scope (both Action and Resource). An empty token is always denied outright — it never falls back to Check's any-matching-grant search. On a match, the grant is consumed (marked Used) and CheckToken returns provenance.StatusPass; every other case returns provenance.StatusApprovalRequired.

func (*Broker) DryRunCheck

func (b *Broker) DryRunCheck(scope Scope) provenance.Status

DryRunCheck reports what Broker.Check would return for scope right now, without consuming a matching grant. Use this to answer "would this be approved" (e.g. to preview a plan before actually attempting the elevated action) without spending a single-use grant.

func (*Broker) DryRunCheckToken

func (b *Broker) DryRunCheckToken(token string, scope Scope) provenance.Status

DryRunCheckToken reports what Broker.CheckToken would return for token and scope right now, without consuming a matching grant.

func (*Broker) Grant

func (b *Broker) Grant(scope Scope, approvedBy string, ttl time.Duration) (Grant, error)

Grant creates and stores a new approval for scope, attributed to approvedBy, valid for ttl from now. ttl must be strictly positive and approvedBy must be non-empty; scope.Action must be non-empty. Any violation returns an error and stores nothing — there is no partial or best-effort grant creation.

The returned Grant's Token is the only way to later authorize via Broker.CheckToken; the caller is responsible for handing it to whatever party is expected to present it, and for treating it as sensitive (see the package doc comment's "Token sensitivity" section).

type FileStore added in v0.8.0

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

FileStore persists Broker grants to a JSON file, bridging approvals across process boundaries — e.g. a human running a CLI grant command in one process, and a separately-running MCP server's Broker seeing it in another. This is the durable store the package doc's "No persistence" section anticipated as future work, "only worth building if a real CLI/MCP integration needs approvals to outlive a single process."

FileStore holds no grants itself; every Save/Load round-trips through the file on disk, so two processes stay consistent via the filesystem alone, with no other IPC. Save is not safe for concurrent writers across processes (last write wins) — this targets the expected usage pattern of an infrequent, human-run grant, not concurrent automated writers.

func NewFileStore added in v0.8.0

func NewFileStore(path string) *FileStore

NewFileStore returns a FileStore backed by path. The file and its parent directory are created on first Save if they don't exist yet.

func (*FileStore) Load added in v0.8.0

func (s *FileStore) Load() (*Broker, error)

Load reads previously-saved grants from disk into a fresh Broker. A missing file is not an error: it returns an empty, zero-grant Broker, matching NewBroker's own "no elevated operation is approved by default" guarantee for a store nothing has ever written to. An expired or already-used grant is loaded exactly as saved, not dropped — Broker's existing expiry/single-use checks (Grant.expired, the Used flag) already treat it as denied, so Load does not need to duplicate that logic.

func (*FileStore) Save added in v0.8.0

func (s *FileStore) Save(b *Broker) error

Save writes every grant currently in b — active or not — to the store, replacing any previous content. Save does not filter expired/used grants itself; Load is where that happens, so a Save/Load round trip is lossless. Writes to a temp file and renames into place, so a concurrent Load never observes a partially-written file.

type Grant

type Grant struct {
	// Token is an unguessable, crypto/rand-generated bearer credential
	// identifying this grant. Treat it as sensitive: never log, print, or
	// persist it unredacted. See the package doc comment's "Token
	// sensitivity" section. Excluded from JSON encoding; see TokenHash.
	Token string `json:"-"`
	// TokenHash is the SHA-256 hex digest of Token: a stable identifier
	// that is safe to log, display, or embed in an audit artifact,
	// because it cannot be reversed back into Token. This is what is
	// JSON-marshaled and printed in place of Token.
	TokenHash string `json:"token_hash"`
	// Scope is the specific action/resource pair this grant authorizes.
	Scope Scope `json:"scope"`
	// ApprovedBy identifies who granted this approval (e.g. an email
	// address or username). Required — an unattributed approval cannot be
	// audited.
	ApprovedBy string `json:"approved_by"`
	// ApprovedAt is when the grant was created.
	ApprovedAt time.Time `json:"approved_at"`
	// ExpiresAt is when the grant stops being valid, regardless of Used.
	// Required and non-zero: [Broker.Grant] rejects any attempt to create
	// a Grant without a positive TTL, and every match performed by this
	// package additionally treats a zero-value ExpiresAt as already
	// expired (never as "no expiry"), as a defense-in-depth measure
	// against a Grant that reached a Broker's internal state some other
	// way than through Broker.Grant.
	ExpiresAt time.Time `json:"expires_at"`
	// Used records whether this grant has already been consumed by a
	// matching Broker.Check/Broker.CheckToken call. Grants in this
	// package are single-use by design — see the package doc comment's
	// "Single-use grants" section for why: it is the safer default for
	// "prevents approval reuse outside its scope", since a multi-use
	// grant would remain valid for repeated actions within its Scope for
	// its entire TTL, widening the window an approval covers beyond what
	// a human explicitly approved once. A caller who legitimately wants
	// to authorize N actions should request N grants.
	Used bool `json:"used"`
}

Grant is an approval that has actually been given: unlike github.com/mediusfy/modulex/provenance.Approval (a flat audit record), a Grant carries the Scope it covers and a required expiry, and is the unit Broker matches against and consumes. See the package doc comment's "A broker, not a record" and "Token sensitivity" sections.

The only supported way to create a Grant is Broker.Grant (or Broker.Check/Broker.DryRunCheck, which return copies of an existing Grant to describe a decision) — Broker.Grant is what enforces "a Grant cannot exist without an expiry" and "every Grant is attributable to an approver". A caller that builds a Grant{} struct literal directly bypasses those checks entirely and must not do so; nothing in this package accepts a hand-built Grant as input to a Broker.

func (Grant) String

func (g Grant) String() string

String renders g without ever including the raw Token, so that %v, %+v, and %s (fmt's Stringer-triggering verbs) are always safe to log or print. Always prefer this (or the default %v/%+v formatting, which uses it automatically) over printing g.Token directly.

func (Grant) ToProvenanceApproval

func (g Grant) ToProvenanceApproval() provenance.Approval

ToProvenanceApproval converts g to a github.com/mediusfy/modulex/provenance.Approval record, for continuity with the handoff/provenance schema (e.g. so a granted, consumed approval can be recorded in a provenance.Envelope's Approvals list). The resource (if any) and the grant's TokenHash (never the raw Token) are folded into Notes so the audit trail can still be tied back to this specific grant without exposing the bearer credential itself.

type Scope

type Scope struct {
	// Action is the specific action class or name this scope covers (e.g.
	// "push", "release", "delete-branch", or a provenance.CommandClass
	// value such as string(provenance.ClassDestructive)).
	Action string
	// Resource is what Action applies to (e.g. a branch name, a PR
	// number, a migration name), or "" if Action is not further scoped to
	// a specific resource.
	Resource string
}

Scope identifies exactly what an approval covers: a specific action (e.g. "push", "release", or a provenance.CommandClass value used as a string) and, optionally, a specific resource that action applies to (e.g. a branch name or PR number). Both fields participate in every match a Broker performs — a Scope is never matched on Action alone. An empty Resource ("") is itself a specific value (meaning "this action, unscoped to any particular resource"), not a wildcard that matches any Resource: a grant for Scope{Action: "push", Resource: ""} does not authorize Scope{Action: "push", Resource: "branch-a"}, and vice versa.

func (Scope) String

func (s Scope) String() string

String renders scope as "action" or "action:resource", for use in error messages and logs. It never contains a Token, so it is always safe to log.

Jump to

Keyboard shortcuts

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