approval

package
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package approval implements a live, in-memory approval broker for elevated agent actions (network access, external mutations, push/release, deletion, infrastructure changes, database migrations, and Jira/PR operations), per ADR-0032 ("Agent-First Development Experience"), P2: "Add an approval broker for elevated agent tools" (Jira MOD-69). The ADR's "Safety and governance" section requires that the system:

require human approval for push, release, deletion, infrastructure
changes, database migrations, and external Jira/PR mutations

and docs/planning/agent-safety-policy.md's "Human approval boundary" section states the scope discipline this package enforces in code:

an approval covers the specific action and scope granted [...], not a
standing blanket authorization for unrelated future actions

A broker, not a record

github.com/mediusfy/modulex/provenance.Approval already exists as part of the handoff/provenance schema: a flat record of an approval that happened (Action, ApprovedBy, ApprovedAt, Notes), meant to travel inside a provenance.Envelope for audit continuity. It has no scope or expiry field and nothing consults it before an action runs — it is written down after the fact.

This package is a different, more active thing: a live mechanism a caller consults *before* running an elevated action to decide whether it is currently authorized. Grant adds the two properties provenance.Approval deliberately lacks — Scope (so a grant for one action/resource pair cannot silently cover another) and ExpiresAt (so a grant issued once does not remain valid forever) — and Broker is the concurrency-safe decision point that checks both. Grant.ToProvenanceApproval converts a Grant to a provenance.Approval so a decision made here can still be recorded in a handoff envelope.

No elevated operation is approved by default

NewBroker starts with zero grants. Broker.Check and Broker.DryRunCheck deny (return provenance.StatusApprovalRequired) for any Scope until an explicit, scoped, unexpired Broker.Grant call has granted it. There is no configuration, flag, or constructor argument that starts a Broker in an already-approved state.

Fail closed

Broker.Check has exactly one path that returns approval (provenance.StatusPass): finding a matching, unexpired, unused grant for the exact requested Scope while holding the broker's lock. Every other path — no grant at all, a grant for a different Action, a grant for a different Resource, an expired grant, an already-used single-use grant, a zero-value or malformed Scope, an empty token presented to Broker.CheckToken — returns provenance.StatusApprovalRequired. There is no default branch, error path, or early return anywhere in this package that produces an approval outcome other than that one matched case; see approval_test.go and broker_test.go for adversarial tests that attempt to violate this from several angles.

Single-use grants

A Grant is consumed (marked Used) the first time Broker.Check or Broker.CheckToken matches it. A second Check for the same scope, even before expiry, is denied. This is the safer default for "prevents approval reuse outside its scope": a caller who wants to authorize several actions must request several grants (one per action), rather than a single grant being silently reusable an unbounded number of times within its TTL. Broker.DryRunCheck and Broker.DryRunCheckToken exist precisely so a caller can ask "would this be approved" without spending the grant.

Token sensitivity

Grant.Token is an unguessable, crypto/rand-generated bearer credential: treat it exactly like a secret. It is deliberately excluded from Grant's JSON encoding (`json:"-"`) and from Grant.String()/the %v/%+v/%s fmt verbs, which print Grant.TokenHash (a SHA-256 hex digest of Token) instead — a stable identifier safe to log, display, or embed in a provenance artifact, that cannot be reversed back into the token itself. Do not print, log, or persist g.Token directly.

Deriving approval policy from a repository contract

RequiresApproval answers "does this contract-declared command need approval" by reading github.com/mediusfy/modulex/contract.Contract.Commands and its existing provenance.CommandClass classification — this package adds no new field to contract.Contract itself (see RequiresApproval's doc comment for the fail-closed handling of an unknown command name).

Not yet wired into anything

This package is a standalone mechanism: no CLI, no MCP server, no actual call site in this repository invokes it yet. It is the trust boundary a future `modulex agent` CLI or MCP server is expected to consult before running push/release/delete/infrastructure/migration/Jira-PR actions. See docs/planning/agent-approval-broker-guide.md for a worked example and the full list of guarantees this package makes (and does not make).

No persistence

A Broker's grants live in process memory only. A process restart invalidates every outstanding grant. This is intentional, not a gap: an approval broker that survived a restart with no operator involvement would be a wider, harder-to-audit trust boundary than one that requires re-approval after any restart. A durable grant store is future work if a real integration needs approvals to outlive a single process.

Example

b := approval.NewBroker()

scope := approval.Scope{Action: "push", Resource: "release/v1.2.0"}
grant, err := b.Grant(scope, "drew@jocham.io", 10*time.Minute)
if err != nil {
    return err
}

// A caller who only knows the scope (trusts the broker's own state):
if b.Check(scope) != provenance.StatusPass {
    return errors.New("push not approved")
}

// A caller who must present the specific token a human handed it:
if b.CheckToken(grant.Token, scope) != provenance.StatusPass {
    return errors.New("push not approved")
}

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

This derives approval policy entirely from data already declared in contract.Contract.Commands (each a contract.CommandDecl with a Class field) — it adds no new field to contract.Contract, per this ticket's scope: modifying contract/*.go is out of bounds, and the contract package's existing per-command Class is already exactly what ADR-0032's "Approval-required command classes are defined by the repository contract" acceptance criterion asks for.

Fail closed on an unknown command — the caller's responsibility

If commandName does not appear in c.Commands at all, RequiresApproval returns (false, ErrCommandNotFound). It does not itself return (true, err): the boolean result is reserved exclusively for "the contract says this command's class is/isn't approval-required", so it never silently asserts a policy the contract didn't declare. This mirrors discovery.ClassifyCommand and verify's own "unknown = safest assumption" precedent (see docs/planning/agent-discovery-guide.md and docs/planning/agent-verification-guide.md), but the fail-safe action itself is the caller's job:

needsApproval, err := approval.RequiresApproval(c, "release")
if err != nil {
    // Unknown to the contract: fail closed. Treat exactly like
    // needsApproval == true rather than proceeding unchecked.
    needsApproval = true
}

A caller that checks only `if err == nil && needsApproval` and otherwise proceeds unchecked has reintroduced the exact "missing approval fails open" defect this package exists to prevent — the fail-closed handling documented above is not automatic and must be applied by every caller.

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.

A Broker is safe for concurrent use: every method takes an internal mutex, so Broker.Grant and Broker.Check may be called from multiple goroutines simultaneously (e.g. one goroutine granting approvals while others check them) without a data race, and — critically — two concurrent Broker.Check calls racing to consume the same single-use grant can never both succeed; see broker_test.go's concurrency test.

The zero value of Broker is not ready to use; construct one with NewBroker.

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