approval

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package approval implements the three-tier mechanism for Class C (irreversible) tools, replacing naive "approve or deny every call" HITL - which trains reviewers to rubber-stamp - with a tiered design where only the irreducible remainder ever reaches a live person. ARCHITECTURE.md has the reasoning; this file is the runtime implementation.

Persistence for the approval Store: envelopes, pending approvals, and holds are the state that must survive a proxy restart mid-pilot (a crash today silently drops every one of them). The audit log stays a plain JSONL file on purpose - append-only is already the right shape for an audit trail; this file is about state that needs to be re-actionable after a restart, not about durability of the history.

Persistence writes are best-effort: a DB hiccup is logged but never blocks or fails the in-memory operation, since audit.log already has the decision recorded and a storage blip shouldn't take down enforcement.

Postgres-backed persistence for multi-instance/HA deployments.

This is a genuinely different concurrency model from persist.go's SQLite-backed NewPersistentStore, not just a different file format. NewPersistentStore treats the in-memory map as the sole authority and SQLite as a write-behind log purely for restart recovery - correct for one process, since Store's own sync.Mutex serializes every access. Once a second process can read/write the same state, that mutex protects nothing: two reviewers hitting two different instances at nearly the same moment could both read "not yet approved" and both flip status to Approved, double-counting a four-eyes approval. So in Postgres mode, Postgres itself is the authority - every mutating method here runs inside a transaction with row-level locking (SELECT ... FOR UPDATE), which serializes concurrent access across ALL instances the same way the in-process mutex serializes access within one. The in-memory maps on Store are allocated but unused in this mode.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RequestHash

func RequestHash(tool string, args map[string]any, resource, requestedBy string) (string, error)

RequestHash cryptographically binds an approval to the EXACT call - tool, args, resource, and requester. If any of it changes between the ask and the click, the hash no longer matches and the stale approval is rejected.

Types

type ApprovalStatus

type ApprovalStatus string
const (
	StatusPending  ApprovalStatus = "pending"
	StatusApproved ApprovalStatus = "approved"
	StatusDenied   ApprovalStatus = "denied"
	StatusExpired  ApprovalStatus = "expired"
)

type Constraint

type Constraint struct {
	Op    string // "eq" | "lte" | "gte" | "in"
	Value string
}

Constraint is a small, explicit matcher DSL - deliberately not a general expression language, so envelopes stay auditable at a glance.

type Envelope

type Envelope struct {
	ID             string
	Tool           string
	ArgConstraints map[string]Constraint
	MaxUses        int
	UsesRemaining  int
	ExpiresAt      time.Time
}

type Hold

type Hold struct {
	ID            string
	Tool          string
	Args          map[string]any
	RequestedBy   string
	DelegatedRole string
	AgentID       string
	FireAt        time.Time
	Cancelled     bool
	Fired         bool
	// RunID lets cancellation optionally trigger compensation of earlier
	// steps in the same run - see CancelHold and the REST adapter's
	// /holds/{id}/cancel. Empty for an untracked call, same convention as
	// PendingApproval.RunID.
	RunID string
	// ReviewerRole gates who may cancel this hold - the same reviewer_role
	// the tool itself requires, checked in CancelHold the same way Deny
	// checks it for approvals.
	ReviewerRole string
	// contains filtered or unexported fields
}

type PendingApproval

type PendingApproval struct {
	ID           string
	Tool         string
	Args         map[string]any
	Resource     string
	RequestedBy  string
	ReviewerRole string
	Hash         string
	Status       ApprovalStatus
	CreatedAt    time.Time
	ExpiresAt    time.Time
	RunID        string // Phase 3: if set, a denial here can trigger internal/saga compensation for prior steps in this run

	// Four-eyes support (internal/risk decides RequiredApprovals; this
	// package only enforces "N distinct, non-requester approvers", it has
	// no opinion on risk scoring itself). Approvers accumulates as each
	// qualifying approval lands; Status only flips to StatusApproved once
	// len(Approvers) reaches RequiredApprovals.
	RequiredApprovals int
	Approvers         []string
	RiskScore         float64
	TaintedArgs       []string
}

type Store

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

func NewPersistentStore

func NewPersistentStore(dbPath string) (*Store, error)

NewPersistentStore opens (creating if needed) a SQLite-backed store and reloads any live state from a previous run. Holds are loaded but NOT automatically re-armed here - resuming a hold requires an onFire callback that only the handler package can supply (it needs the registry and the upstream client), so the caller (cmd/rinwall) must call ScheduleResumedHold for each id in ResumableHoldIDs() once the handler exists.

func NewPostgresStore

func NewPostgresStore(dsn string) (*Store, error)

NewPostgresStore opens (creating the schema if needed) a Postgres-backed Store safe for concurrent use by multiple proxy processes against the same database. Reuses the "pgx" database/sql driver already registered by cmd/rinwall (github.com/jackc/pgx/v5/stdlib) - blank-imported here too so this file has no hidden dependency on being wired up after main.go's own import runs.

func NewStore

func NewStore() *Store

func (*Store) Approve

func (s *Store) Approve(id, hash, approverUser, approverRole string) (*PendingApproval, error)

Approve records one qualifying approval, only if: the supplied hash still matches (a payload that changed between the ask and the click is rejected automatically, not silently re-approved against new values), the approver's verified role matches the tool's configured ReviewerRole, the approver is not the original requester - closing the self-approval hole outright rather than relying on role mismatch alone - and the approver hasn't already approved this same request (the trivial way to defeat four-eyes is one person clicking twice). Status only flips to StatusApproved once len(Approvers) reaches RequiredApprovals - the caller must check p.Status before treating this as a green light to execute; a return with Status still Pending means more approvers needed.

func (*Store) CancelHold

func (s *Store) CancelHold(id, cancellerRole string) (*Hold, error)

CancelHold requires the canceller's role to match the hold's reviewer_role, the same check Deny applies to approvals - cancelling a queued Class C action is at least as sensitive as approving one. Deliberately does NOT block the original requester from cancelling their own hold, unlike self-approval: cancelling only ever prevents an action from happening, it never lets anyone push one through, so the conflict-of-interest self-approval guards against doesn't apply here. Returns the cancelled Hold so a caller can decide whether to also compensate the run it belongs to - see the REST adapter's /holds/{id}/cancel and its compensate_run option.

func (*Store) CheckPreAuth

func (s *Store) CheckPreAuth(tool string, args map[string]any) (*Envelope, bool)

CheckPreAuth looks for a live envelope covering this exact call and, if found, atomically consumes one use. Tier 1 - most Class C traffic should resolve here, instantly, with no human paged. In Postgres mode, a transient DB error fails closed to "no match" rather than blocking the call - skipping a pre-authorized envelope on a hiccup only costs one extra human approval, never a bypassed check.

func (*Store) CreateApproval

func (s *Store) CreateApproval(tool string, args map[string]any, resource, requestedBy, reviewerRole string, ttl time.Duration, requiredApprovals int, riskScore float64, taintedArgs []string, runID string) (*PendingApproval, error)

CreateApproval opens a Tier 3 (or taint step-up) request, bound to the exact call by hash and routed to whichever reviewer role owns this class of decision. requiredApprovals/riskScore/taintedArgs come from internal/risk's scoring - this package just enforces whatever count it's told, it has no opinion on how that count was decided.

func (*Store) CreateEnvelope

func (s *Store) CreateEnvelope(tool string, constraints map[string]Constraint, maxUses int, ttl time.Duration) (*Envelope, error)

func (*Store) Deny

func (s *Store) Deny(id, approverUser, approverRole string) error

func (*Store) EnqueueHold

func (s *Store) EnqueueHold(tool string, args map[string]any, holdSeconds int, requestedBy, delegatedRole, agentID, runID, reviewerRole string, onFire func()) (*Hold, error)

EnqueueHold queues a Class C action with a natural dispatch delay instead of firing it immediately. onFire runs automatically when the window elapses unless CancelHold is called first - Tier 2. requestedBy/ delegatedRole/agentID are stored purely for audit/resume attribution - they play no role in the fire decision. runID/reviewerRole are stored so CancelHold can enforce who may cancel and, if asked, trigger compensation of the run's earlier steps.

func (*Store) Get

func (s *Store) Get(id string) (*PendingApproval, bool)

func (*Store) GetHold

func (s *Store) GetHold(id string) (*Hold, bool)

GetHold exposes a hold's tool/args/requester so a caller resuming it after a restart (cmd/rinwall) can build the right replay closure - the timer/onFire wiring itself stays inside the store via ScheduleResumedHold. Not used in Postgres mode, where PollAndFireDueHolds replaces both the fresh-fire and restart-resume paths with one mechanism - see postgres.go.

func (*Store) ListEnvelopes

func (s *Store) ListEnvelopes() []*Envelope

func (*Store) ListHolds

func (s *Store) ListHolds() []*Hold

func (*Store) ListPendingApprovals

func (s *Store) ListPendingApprovals() []*PendingApproval

ListPendingApprovals, ListHolds, and ListEnvelopes back the reviewer UI (internal/adapter/rest's GET /approvals, /holds, /envelopes) - point lookups (Get, GetHold) aren't enough for a human to see what's actually waiting on them.

func (*Store) PollAndFireDueHolds

func (s *Store) PollAndFireDueHolds(limit int, onFire func(*Hold))

PollAndFireDueHolds claims and fires holds whose window has elapsed, safe to call concurrently from multiple proxy instances polling the same Postgres database: "FOR UPDATE SKIP LOCKED" lets each instance's poll pass skip rows another instance already has locked, so a due hold is claimed by exactly one instance rather than fired twice. onFire is called once per claimed hold, AFTER the row is durably marked fired - the same "mark fired, then run the side effect" order Logger's SQLite path already uses for its timer callback. Call this on a ticker (see cmd/rinwall/main.go) only when Postgres mode is active; SQLite/in-memory mode keeps using time.AfterFunc/ScheduleResumedHold instead, since a single process needs no distributed claiming.

func (*Store) ResumableHoldIDs

func (s *Store) ResumableHoldIDs() []string

ResumableHoldIDs returns holds from a previous run that never fired and were never cancelled - these need a fresh timer via ScheduleResumedHold.

func (*Store) ScheduleResumedHold

func (s *Store) ScheduleResumedHold(id string, onFire func())

ScheduleResumedHold re-arms a hold loaded from a previous run. If its window already elapsed while the process was down, it fires immediately.

Jump to

Keyboard shortcuts

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