approval

package
v1.2.0 Latest Latest
Warning

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

Go to latest
Published: May 30, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package approval ships Harbor's tool-side synchronous approval-gate subsystem — the second consumer of the unified pause/resume primitive (Phase 50 / D-067), layered on the same Coordinator + bus + steering inbox seams Phase 30 (tool-side OAuth) built. Where Phase 30's gate is "we need a bearer token from an authorization server," Phase 31's gate is "we need a human to say yes" — same primitive, simpler payload (no token, no URL, no third-party flow).

The flow

  1. A caller (the planner via the runtime dispatcher in a later phase; or a test today) builds an `ApprovalRequest{Tool, Args, Identity, Tags}` describing the tool call about to fire.

  2. The runtime calls `ApprovalGate.RunGuarded(ctx, req)`.

  3. The gate asks the configured `ApprovalPolicy.ShouldApprove(ctx, req)`. When the policy returns `Required=false`, the gate returns `(req.Args, nil)` immediately and the caller proceeds — there is NO pause, NO bus emit. When `Required=true`, the gate parks via `Coordinator.Request(ApprovalRequired)` and publishes `tool.approval_requested` (audit-redacted) onto the bus.

  4. An admin / fleet-control caller resolves the approval by submitting APPROVE / REJECT through the Phase 53 steering inbox (or, in-process, via `ApprovalGate.ResolveApproval`). The Coordinator resumes the parked run; the gate's per-pause channel unblocks.

  5. APPROVE → the gate publishes `tool.approved` and returns `(req.Args, nil)`. The original args are held in the gate's pending map throughout — they were NEVER on the bus — so a redactor that elides a secret-shaped field does not corrupt the executed tool call.

  6. REJECT → the gate publishes `tool.rejected` (the master-plan acceptance event) and returns `(nil, *ErrToolRejected)`.

Distinct from Phase 30 OAuth

Phase 30's gate uses `ReasonExternalEvent` (waiting on an out-of-band callback). Phase 31's gate uses `ReasonApprovalRequired` (the textbook RFC §6.3 reason for a HITL approval gate — brief 02 §"Pause-reason taxonomy"). The two paths share the Coordinator but emit different events with different shapes:

  • OAuth: `tool.auth_required{Source, AuthorizeURL, State, ...}`
  • Approval: `tool.approval_requested{Tool, Identity, Tags, ...}`

CLAUDE.md §13 forbids "two parallel implementations of the same conceptual feature" — the approval gate is NOT another pause primitive; it is another CONSUMER of the one primitive.

Scope-gating

Approval resolution is privileged. A non-admin user cannot approve their own tool call (a self-approval would defeat the gate's purpose). `ResolveApproval` enforces `auth.HasScope(ctx, ScopeAdmin) || auth.HasScope(ctx, ScopeConsoleFleet)` — Phase 61's verified-JWT scope claims — and rejects unscoped callers with `ErrApprovalScopeRequired`. The Phase 54 Protocol edge also enforces this at the JWT boundary; the in-process helper is the second line of defence.

Audit redaction

`tool.approval_requested` carries Tool name + identity triple + caller-supplied Tags + a REDACTED summary of args — never the original arg bytes. The gate runs the summary through `audit.Redactor.Redact` BEFORE publish; a redactor error fails the gate's Request loud (the §3.4 fail-loudly principle — there is no "emit anyway with raw args" fallback). The ORIGINAL args are held in the gate's per-pause map and used to drive the post-APPROVE tool invocation, so a redactor that elides a secret does NOT corrupt the executed call.

Concurrent reuse (D-025)

`*ApprovalGate` is a compiled artifact: immutable after construction except for a mutex-guarded pending-resolutions map. One gate is safe to share across N concurrent goroutines; concurrent_test.go pins N≥128 under -race.

§13 amendment — no silent stub default

`NewApprovalGate(GateDeps{})` with a nil `Policy` fails loud with `ErrPolicyRequired`. Approval gates with no policy attached are dead code at best, a privilege-escalation footgun at worst — the binary REFUSES to construct one. Compare Phase 61's `NewMux` requiring `WithValidator` after PR #95.

§13 primitive-with-consumer

The primitive Phase 31 ships is `ApprovalGate` + the typed event triple `(tool.approval_requested, tool.approved, tool.rejected)` + the `*ErrToolRejected` sentinel. The first consumers are:

  • `test/integration/phase31_approval_gates_test.go` — end-to-end APPROVE and REJECT cycles against real `pauseresume.Coordinator` + real `events.EventBus` + real `audit.Redactor` + real `steering.Inbox`.
  • `concurrent_test.go` — N≥128 concurrent invocations under `-race` (the D-025 obligation).

A later phase wires the gate into the runtime dispatcher so every gated tool call routes through it automatically; Phase 31 ships the gate as the explicit middleware tools opt into.

Index

Constants

View Source
const (
	// EventTypeToolApprovalRequested — emitted when a tool invocation
	// is gated and the `ApprovalPolicy` returned `Required=true`.
	// Payload is `ToolApprovalRequestedPayload`.
	EventTypeToolApprovalRequested events.EventType = "tool.approval_requested"

	// EventTypeToolApproved — emitted by `ApprovalGate` on a
	// resolution of `DecisionApprove`. Payload is `ToolApprovedPayload`.
	EventTypeToolApproved events.EventType = "tool.approved"

	// EventTypeToolRejected — emitted by `ApprovalGate` on a
	// resolution of `DecisionReject`. THE master-plan acceptance
	// event ("reject path raises typed `tool.rejected` events").
	// Payload is `ToolRejectedPayload`.
	EventTypeToolRejected events.EventType = "tool.rejected"
)

Canonical tool-approval event types. Registered from this package's init() so a Publish never trips events.ErrUnknownEventType.

The three events form the gate's full lifecycle:

  • `tool.approval_requested` — emitted at gate entry when the `ApprovalPolicy` returns `Required=true`. Payload is `ToolApprovalRequestedPayload`. SafePayload — never carries the original args (those stay in the gate's pending map).
  • `tool.approved` — emitted on APPROVE resolution. Payload is `ToolApprovedPayload`.
  • `tool.rejected` — emitted on REJECT resolution; this is the master-plan acceptance criterion event. Payload is `ToolRejectedPayload`.

All three payload types embed `events.SafeSealed` so the bus accepts them on the typed path. The audit redactor is still run by the gate on `ToolApprovalRequestedPayload.ArgsSummary` BEFORE construction — the SafePayload tag asserts "this struct itself carries no secret-shaped data" once the summary has been redacted.

Variables

View Source
var (
	// ErrToolRejectedSentinel — comparison target for
	// `errors.Is(err, approval.ErrToolRejectedSentinel)`. The typed
	// `*ErrToolRejected`'s Is() method matches this sentinel.
	ErrToolRejectedSentinel = errors.New("approval: tool call rejected")

	// ErrPolicyRequired — `NewApprovalGate` was called with a nil
	// `GateDeps.Policy`. The §13 amendment trip-wire — silent
	// auto-approve is forbidden.
	ErrPolicyRequired = errors.New("approval: ApprovalPolicy required at construction")

	// ErrCoordinatorRequired — `NewApprovalGate` was called with a
	// nil `GateDeps.Coordinator`.
	ErrCoordinatorRequired = errors.New("approval: pauseresume.Coordinator required at construction")

	// ErrBusRequired — `NewApprovalGate` was called with a nil
	// `GateDeps.Bus`. The bus is mandatory because every gate
	// publishes lifecycle events; an unobservable gate is a design
	// smell.
	ErrBusRequired = errors.New("approval: events.EventBus required at construction")

	// ErrRedactorRequired — `NewApprovalGate` was called with a nil
	// `GateDeps.Redactor`. Audit redaction is mandatory on the
	// approval-request payload (CLAUDE.md §7 rule 6).
	ErrRedactorRequired = errors.New("approval: audit.Redactor required at construction")

	// ErrIdentityRequired — `RunGuarded` was called with a request
	// whose Identity is incomplete. Identity is mandatory
	// (CLAUDE.md §6 rule 9).
	ErrIdentityRequired = errors.New("approval: identity triple incomplete")

	// ErrInvalidDecision — `ResolveApproval` was called with a
	// decision that is not one of Approve / Reject. A pending
	// no-op resolution is rejected.
	ErrInvalidDecision = errors.New("approval: decision must be approve or reject")

	// ErrApprovalScopeRequired — `ResolveApproval` was called from a
	// ctx that does NOT carry `auth.ScopeAdmin` OR
	// `auth.ScopeConsoleFleet`. The gate enforces the scope check in
	// addition to the Phase 54 Protocol edge's JWT check; defence in
	// depth.
	ErrApprovalScopeRequired = errors.New("approval: admin or console:fleet scope required")

	// ErrApprovalNotFound — `ResolveApproval` was called with a
	// Token the gate has no pending record for. Either the gate
	// never opened it, or it was already resolved / cancelled.
	ErrApprovalNotFound = errors.New("approval: no pending approval for token")

	// ErrApprovalAlreadyResolved — a second `ResolveApproval` for the
	// same Token (idempotency surface). The first resolution wins;
	// the second is rejected loud. Mirrors
	// `pauseresume.ErrAlreadyResumed`.
	ErrApprovalAlreadyResolved = errors.New("approval: already resolved")

	// ErrApprovalCancelled — the caller's ctx was cancelled before
	// the approver resolved the gate. Returned by `RunGuarded` so
	// the runtime distinguishes "approver said no" from "ctx died."
	ErrApprovalCancelled = errors.New("approval: cancelled before resolution")

	// ErrGateClosed — any operation called after `Close`.
	ErrGateClosed = errors.New("approval: gate closed")

	// ErrPolicyFailed — the configured `ApprovalPolicy.ShouldApprove`
	// returned a non-nil Err. The gate refuses to invoke — there is
	// no silent auto-approve fallback (§13 amendment).
	ErrPolicyFailed = errors.New("approval: policy decision failed")
)

Sentinel errors. Callers compare via errors.Is.

Functions

func IsValidDecision

func IsValidDecision(d ApprovalDecision) bool

IsValidDecision reports whether d is a callable resolution (i.e. Approve or Reject; Pending is the implicit parked state).

Types

type AlwaysApprovePolicy

type AlwaysApprovePolicy struct{}

AlwaysApprovePolicy returns Required=false for every request — the gate short-circuits and the caller proceeds immediately, no pause, no bus emit.

AlwaysApprove is the test-grade policy that exists for unit tests of the gate's "no approval needed" short-circuit path. The §13 amendment forbids it as a production default — the binary's gate configuration MUST be an explicit operator choice, not a stub fallback. The constructor accepts AlwaysApprove only because a human operator can legitimately want it (a dev-loop sandbox where every tool is allowed); the constructor's invariant is NOT "the policy must require approval" but "the policy must be explicit."

func (AlwaysApprovePolicy) ShouldApprove

ShouldApprove implements ApprovalPolicy.

type AlwaysDenyPolicy

type AlwaysDenyPolicy struct {
	// Reason is the operator-facing classification carried on
	// `tool.approval_requested`. Empty defaults to "policy: deny-all".
	Reason string
}

AlwaysDenyPolicy returns Required=true with a fixed Reason for every request — every gated call routes through the approver. Useful in tests; also a reasonable default for a high-security operator posture where every tool call goes through a human.

Because policies are configured at gate construction (the §13 amendment forbids a nil default), the operator's deliberate choice of AlwaysDeny is fail-safe: the worst case is "every call asks an approver," not "every call silently fires."

func (AlwaysDenyPolicy) ShouldApprove

func (p AlwaysDenyPolicy) ShouldApprove(_ context.Context, _ *ApprovalRequest) (bool, string, error)

ShouldApprove implements ApprovalPolicy.

type ApprovalDecision

type ApprovalDecision string

ApprovalDecision is the resolution any approval gate eventually receives. `DecisionPending` is the implicit state while a gate is parked; callers never construct it.

const (
	// DecisionPending — the implicit state of a parked approval. A
	// caller that submits `DecisionPending` to `ResolveApproval` is
	// rejected with `ErrInvalidDecision` (a no-op resolution is
	// nonsensical).
	DecisionPending ApprovalDecision = "pending"
	// DecisionApprove — the approver said yes; the gate proceeds.
	DecisionApprove ApprovalDecision = "approve"
	// DecisionReject — the approver said no; the gate returns
	// `*ErrToolRejected`.
	DecisionReject ApprovalDecision = "reject"
)

type ApprovalGate

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

ApprovalGate is the V1 concrete approval-gate artifact.

Concurrent reuse contract (D-025): every field below is either set once at construction (deps + closed flag) or is the pending map guarded by mu. There is no per-run state on the struct itself. One gate is safe to share across N goroutines; concurrent_test.go pins N≥128 under -race.

func NewApprovalGate

func NewApprovalGate(deps GateDeps) (*ApprovalGate, error)

NewApprovalGate constructs an ApprovalGate. Every dep is mandatory; a nil dep returns the matching sentinel error (no silent stub default — CLAUDE.md §13 amendment).

func (*ApprovalGate) Close

func (g *ApprovalGate) Close(_ context.Context) error

Close idempotently retires the gate. Any in-flight RunGuarded calls see their pending entries dropped from the map; the Coordinator's pause records remain (the gate is not the source of truth for the pause record — the Coordinator is). After Close, RunGuarded / ResolveApproval return ErrGateClosed.

Close is safe to call concurrently with RunGuarded — atomic flag + the mutex on map operations cover the race. Mirrors the Phase 30 Provider.Close pattern.

func (*ApprovalGate) ResolveApproval

func (g *ApprovalGate) ResolveApproval(ctx context.Context, token pauseresume.Token, decision ApprovalDecision, reason string) error

ResolveApproval is the in-process resolution helper. The Phase 53 steering-inbox + Phase 54 Protocol edge dispatches APPROVE / REJECT control events through this surface (in-process callers reach the gate directly). Path:

  1. Enforce `auth.HasScope(ctx, ScopeAdmin) || HasScope(ctx, ScopeConsoleFleet)`. A non-elevated caller is rejected with `ErrApprovalScopeRequired`. The Phase 54 edge also enforces this at the JWT boundary; the in-process helper is the defence-in-depth layer.

  2. Validate the decision is Approve or Reject (Pending is rejected with `ErrInvalidDecision`).

  3. Call `Coordinator.Resume(ctx, token, decision, {rejected: bool})` with the typed `pauseresume.Decision` (Approve or Reject) so the emitted `pause.resumed` event carries a typed marker wire consumers branch on (issue #113, D-096). This is where cross-identity rejection happens — the Coordinator's `ErrScopeMismatch` propagates verbatim if the caller's identity does not match the original pause's identity. For elevated callers, the Phase 50 Coordinator's `sameScope` check applies the same `(tenant, user, session)` equality — so a console-fleet admin resolver MUST present a ctx whose triple matches the original pause's triple. (The Coordinator's scope check is identity-based; the gate's scope check is privilege-based; both fire.)

  4. Dispatch the resolution to the RunGuarded waiter via the pending entry's channel. The waiter unblocks and returns APPROVE / REJECT to the original caller.

Idempotency: a second ResolveApproval for the same Token returns `ErrApprovalAlreadyResolved` (the first call removed the entry from the pending map). Mirrors `pauseresume.ErrAlreadyResumed`.

func (*ApprovalGate) RunGuarded

func (g *ApprovalGate) RunGuarded(ctx context.Context, req *ApprovalRequest) (json.RawMessage, error)

RunGuarded is the gate's entry point. Caller path:

  1. Build an `ApprovalRequest{Tool, Args, Identity, Tags}`.
  2. Call `args, err := gate.RunGuarded(ctx, req)`.
  3. On nil err: proceed to invoke the tool with `args`.
  4. On `*ErrToolRejected`: do NOT invoke the tool; surface the rejection to the planner (the runtime translates this into the Finish{ConstraintsConflict} outcome a later phase will wire).

The gate consults the ApprovalPolicy. When `Required=false`, it short-circuits — no pause, no bus emit, returns `(req.Args, nil)`. When `Required=true`:

  • The gate parks the run via `Coordinator.Request` with `ReasonApprovalRequired` (the textbook RFC §6.3 reason).
  • The gate publishes `tool.approval_requested` (audit-redacted).
  • The gate blocks on the per-pause resolution channel.
  • On APPROVE: returns `(req.Args, nil)`. Publishes `tool.approved`.
  • On REJECT: returns `(nil, *ErrToolRejected)`. Publishes `tool.rejected`.
  • On ctx cancellation before resolution: returns `ErrApprovalCancelled`. The pause record stays parked (the Coordinator is the source of truth); an out-of-band resolver can still land an APPROVE / REJECT later, but the original caller is gone.

Concurrent-safe (D-025): one ApprovalGate is shared by N runs.

type ApprovalPolicy

type ApprovalPolicy interface {
	ShouldApprove(ctx context.Context, req *ApprovalRequest) (Required bool, Reason string, Err error)
}

ApprovalPolicy decides, per ApprovalRequest, whether approval is required. A `Required=false` return short-circuits the gate (no pause, no bus emit). `Required=true` parks the call until the approver resolves it.

`Reason` is the operator-facing classification the gate carries on `tool.approval_requested` so the Console can render "Approval required: <Reason>." It is NOT raw user data — operators keep Reason values to a small, stable set (the Phase 56 cardinality rule). The gate trusts the policy here; if Reason ever needs redaction, the policy is the bug.

`Err` is the loud-failure path. A policy that cannot decide (e.g. missing operator configuration; a corrupt rule table) returns a non-nil Err and the gate refuses to invoke — there is NO silent auto-approve fallback (CLAUDE.md §13 amendment).

type ApprovalRequest

type ApprovalRequest struct {
	// Tool is the planner-visible tool description. The gate emits
	// Tool.Name on `tool.approval_requested` so the Console can
	// render "Approve call to <Tool.Name>?".
	Tool tools.Tool
	// Args is the original argument blob the gate will return to the
	// caller post-APPROVE. NEVER published on the bus.
	Args json.RawMessage
	// Identity is the (tenant, user, session) triple the call is
	// running under. The Coordinator's pause record scopes against
	// it; a cross-tenant resolver is rejected with
	// `pauseresume.ErrScopeMismatch`.
	Identity identity.Identity
	// Tags is the caller-supplied classification surface. Operators
	// reach for Tags in `TaggedPolicy` to decide "this is a write to
	// a sensitive endpoint — require approval."
	Tags []string
}

ApprovalRequest is the gate's input: a description of the tool call about to fire. The gate forwards the request to the `ApprovalPolicy`, which returns whether approval is required + a caller-facing Reason. The request's Args field is the ORIGINAL tool args; they NEVER appear on the bus (the redactor sees only the gate's summary payload).

func (*ApprovalRequest) Validate

func (r *ApprovalRequest) Validate() error

Validate reports whether the ApprovalRequest is structurally valid. The gate calls this on every RunGuarded entry — a missing identity or empty Tool name is a programmer error, not a recoverable runtime state.

type ErrToolRejected

type ErrToolRejected struct {
	// Tool is the name of the tool whose call was rejected.
	Tool string
	// Reason is the operator-facing classification the approver
	// supplied (or the policy's pre-emptive reject reason). Free-form
	// but low-cardinality by convention; never raw user data.
	Reason string
	// Identity is the (tenant, user, session) triple the rejected
	// call was running under — preserved so logs / audits can
	// correlate the rejection back to the originating run.
	Identity identity.Identity
}

ErrToolRejected is the typed sentinel `RunGuarded` returns on a REJECT resolution. Callers reach it via `errors.As`. Field set is SafePayload by construction — Tool name + classification reason + identity triple, no caller-controlled arg bytes.

func (*ErrToolRejected) Error

func (e *ErrToolRejected) Error() string

Error implements the error interface.

func (*ErrToolRejected) Is

func (e *ErrToolRejected) Is(target error) bool

Is supports errors.Is comparisons against the sentinel `ErrToolRejectedSentinel`.

type GateDeps

type GateDeps struct {
	// Policy decides per-request whether approval is required.
	// Mandatory — there is no silent-auto-approve default
	// (CLAUDE.md §13 amendment).
	Policy ApprovalPolicy
	// Coordinator is the unified pause/resume primitive (Phase 50).
	// Mandatory — RunGuarded parks a pause record on it; an APPROVE /
	// REJECT control event resumes through it.
	Coordinator pauseresume.Coordinator
	// Bus is the event bus the gate emits
	// tool.approval_requested / tool.approved / tool.rejected on.
	// Mandatory — an unobservable gate is a design smell.
	Bus events.EventBus
	// Redactor processes the ToolApprovalRequestedPayload's
	// ArgsSummary before emission. Mandatory — CLAUDE.md §7 rule 6
	// requires audit redaction on every emit path.
	Redactor audit.Redactor
}

GateDeps bundles the collaborators an ApprovalGate needs. The production binary wires all four; tests may stub the bus / redactor with in-memory equivalents that satisfy the same interface.

Every field is mandatory — the §13 amendment forbids silent default fallbacks on operator-facing seams. A nil field is rejected at construction (NewApprovalGate returns the matching sentinel).

type TaggedPolicy

type TaggedPolicy struct {
	// RequireTags lists the tags that, when present on the request,
	// trigger approval. An empty list means "approve nothing"
	// (i.e. every call short-circuits — explicit operator choice).
	RequireTags []string
	// Reason is the operator-facing classification carried on
	// `tool.approval_requested` when the policy fires. Empty
	// defaults to "policy: tagged".
	Reason string
}

TaggedPolicy is the V1 production reference: approval is required when the request's Tags slice contains any tag in RequireTags. An empty RequireTags list means "approve nothing" (the gate short-circuits every call) — explicit operator config, not a stub.

Operators reach for TaggedPolicy when their workflow is "tools marked `sensitive` or `write:prod` go through the approver." More complex flows (per-args predicates, per-identity rules) are post-V1 and live behind a different policy type; the ApprovalPolicy interface is the seam.

func (TaggedPolicy) ShouldApprove

func (p TaggedPolicy) ShouldApprove(_ context.Context, req *ApprovalRequest) (bool, string, error)

ShouldApprove implements ApprovalPolicy.

type ToolApprovalRequestedPayload

type ToolApprovalRequestedPayload struct {
	events.SafeSealed
	// Tool is the tool name the planner / runtime chose; surfaced by
	// the Console as "Approve call to <Tool>?".
	Tool string
	// PauseToken is the unified pause/resume Coordinator's Token —
	// observers correlate this to the `pause.requested` event and the
	// later `pause.resumed` / `tool.approved` / `tool.rejected`.
	PauseToken string
	// Reason is the operator-facing classification the
	// `ApprovalPolicy` returned. Low-cardinality by convention.
	Reason string
	// Tags is the caller-supplied classification surface the policy
	// branched on.
	Tags []string
	// ArgsSummary is the audit-redacted summary of the tool args.
	// The redactor runs over a generic shape (map[string]any) at the
	// gate's emit boundary; secret-shaped values are elided. The
	// post-APPROVE tool invocation uses the ORIGINAL args (held in
	// the gate's pending map), so a redactor that elides a field
	// does NOT corrupt the executed call.
	ArgsSummary any
}

ToolApprovalRequestedPayload is the typed payload for a `tool.approval_requested` event. SafePayload by construction: every field is either runtime bookkeeping or operator-supplied configuration metadata. The ArgsSummary field is the redactor's output, NOT the original args; the original args stay in the gate's pending map and never reach the bus.

type ToolApprovedPayload

type ToolApprovedPayload struct {
	events.SafeSealed
	// Tool is the tool name that was approved.
	Tool string
	// PauseToken correlates with the originating
	// `tool.approval_requested` payload.
	PauseToken string
	// ApproverReason is the optional caller-supplied note attached
	// to the APPROVE submission. Free-form but low-cardinality by
	// convention.
	ApproverReason string
}

ToolApprovedPayload is the typed payload for a `tool.approved` event. SafePayload by construction.

type ToolRejectedPayload

type ToolRejectedPayload struct {
	events.SafeSealed
	// Tool is the tool name that was rejected.
	Tool string
	// PauseToken correlates with the originating
	// `tool.approval_requested` payload.
	PauseToken string
	// Reason is the approver's free-form classification of the
	// rejection. Low-cardinality by convention.
	Reason string
}

ToolRejectedPayload is the typed payload for a `tool.rejected` event. THIS is the master-plan acceptance criterion shape. The rejected RunID + identity triple live on the Event envelope (`Event.Identity`); the payload carries the per-event detail. SafePayload by construction.

Jump to

Keyboard shortcuts

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