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 ¶
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.
The runtime calls `ApprovalGate.RunGuarded(ctx, req)`.
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.
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.
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.
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
- Variables
- func IsValidDecision(d ApprovalDecision) bool
- type AlwaysApprovePolicy
- type AlwaysDenyPolicy
- type ApprovalDecision
- type ApprovalGate
- type ApprovalPolicy
- type ApprovalRequest
- type ErrToolRejected
- type GateDeps
- type TaggedPolicy
- type ToolApprovalRequestedPayload
- type ToolApprovedPayload
- type ToolRejectedPayload
Constants ¶
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 ¶
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 ¶
func (AlwaysApprovePolicy) ShouldApprove(_ context.Context, _ *ApprovalRequest) (bool, string, error)
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:
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.
Validate the decision is Approve or Reject (Pending is rejected with `ErrInvalidDecision`).
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.)
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:
- Build an `ApprovalRequest{Tool, Args, Identity, Tags}`.
- Call `args, err := gate.RunGuarded(ctx, req)`.
- On nil err: proceed to invoke the tool with `args`.
- 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.