approval

package
v1.3.1 Latest Latest
Warning

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

Go to latest
Published: Jun 11, 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.

Resolve authorization (Phase 111f, D-203)

Approval resolution is privileged. Who may resolve is decided by the INJECTED `GateDeps.Authorizer` seam (see authorizer.go): the runtime-vocabulary default (`IdentityAuthorizer` — the pause's originating identity tuple OR the elevated `registry.WithControlScope` claim) for direct construction and the in-process steering bridge; the Protocol-side `internal/server.ProtocolScopeAuthorizer` adapter (admin / console:fleet — Phase 61's verified-JWT scope claims) at wire-driven gate assembly. Unauthorized resolvers are rejected with a wrapped `ErrResolveForbidden`. The Phase 54 Protocol edge also enforces the RFC §6.3 steering scopes at the JWT boundary; the in-process check is the second line of defence.

This package deliberately imports NO `internal/protocol/auth` — per the D-203 direction rule, runtime packages may import `internal/protocol/types` (data), never protocol auth / methods / transports (behaviour).

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.

authorizer.go — the injected resolve-privilege seam (Phase 111f, D-203).

Pre-111f, ResolveApproval hard-coded a Protocol-vocabulary check (`internal/protocol/auth` scope claims on ctx), which forced the runtime's own steering bridge to SELF-ELEVATE with protocol scopes to call its own gate — wire-layer auth vocabulary inside an in-process runtime control path (the SDK friction audit §4 tell that the check sat one layer too low). The seam moves the privilege decision onto GateDeps.Authorizer:

  • Direct construction / the runtime control path wires IdentityAuthorizer (runtime vocabulary: the resolving ctx carries the pause's originating identity tuple, or the elevated `registry.WithControlScope` claim).
  • Wire-driven assembly injects the Protocol-side adapter (`internal/server.ProtocolScopeAuthorizer`) which preserves today's admin / console:fleet acceptance exactly and falls through to the runtime default for in-process callers.

D-203 records the direction rule this closes: runtime packages may import `internal/protocol/types` (pure data projection); they must never import protocol auth / methods / transports (behaviour).

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

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

View Source
var (
	// ErrAuthorizerRequired — `NewApprovalGate` was called with a nil
	// `GateDeps.Authorizer`. An approval gate with no resolve
	// privilege check is a misconfiguration, not a permissive mode
	// (CLAUDE.md §13 fail-loudly; the same posture as the other
	// mandatory GateDeps fields).
	ErrAuthorizerRequired = errors.New("approval: ResolveAuthorizer required at construction")

	// ErrResolveForbidden — the gate's configured ResolveAuthorizer
	// rejected the resolving ctx. The Phase 111f replacement for the
	// pre-seam ErrApprovalScopeRequired: same fail-closed posture,
	// runtime vocabulary. With the default IdentityAuthorizer it means
	// the ctx carried neither the pause's originating identity tuple
	// nor the elevated control-scope claim.
	ErrResolveForbidden = errors.New("approval: resolve not authorized")
)

Sentinel errors for the authorizer seam. 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. Validate the decision is Approve or Reject (Pending is rejected with `ErrInvalidDecision`).

  2. Locate the pending entry and consult the injected `ResolveAuthorizer` (Phase 111f, D-203) with the entry\'s PendingInfo. An unauthorized resolver is rejected with a wrapped `ErrResolveForbidden` BEFORE any pause state mutates. The Phase 54 edge also enforces the RFC §6.3 steering scopes at the JWT boundary; the in-process check is the defence-in-depth layer — in runtime vocabulary, never protocol scopes (the pre-seam `internal/protocol/auth` check lives on as the server-side `ProtocolScopeAuthorizer` adapter, injected at wire-driven gate assembly).

  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
	// Authorizer is the injected resolve-privilege check (Phase 111f,
	// D-203). Mandatory — an approval gate with no resolve privilege
	// check is a misconfiguration, not a permissive mode. Direct
	// construction wires the runtime-vocabulary default
	// (NewIdentityAuthorizer); wire-driven assembly injects the
	// Protocol-side adapter (`internal/server.ProtocolScopeAuthorizer`).
	Authorizer ResolveAuthorizer
}

GateDeps bundles the collaborators an ApprovalGate needs. The production binary wires all five; 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 IdentityAuthorizer added in v1.3.0

type IdentityAuthorizer struct{}

IdentityAuthorizer is the package-default, runtime-vocabulary ResolveAuthorizer: a resolving ctx is authorized when it carries

  • the pause's ORIGINATING identity tuple (identity.From(ctx) equals PendingInfo.Identity — the steering bridge's shape: the run resolving its own gate after the Protocol edge already vetted the wire caller's RFC §6.3 steering scope), OR
  • the elevated fleet-control-scope claim (`registry.WithControlScope` — reused rather than minting a new claim shape; the same trust-based-in-V1 claim the Agent Registry's fleet-control commands consume, with the same audit-trail posture).

Everything else is rejected with a wrapped ErrResolveForbidden — fail closed, no permissive default. Note the Coordinator's own Resume-side identity check still applies AFTER the authorizer (defence in depth): a control-scoped resolver whose ctx identity does not match the pause's triple is rejected by `pauseresume.ErrScopeMismatch`, exactly as before the seam.

IdentityAuthorizer is stateless and safe for concurrent use.

func NewIdentityAuthorizer added in v1.3.0

func NewIdentityAuthorizer() IdentityAuthorizer

NewIdentityAuthorizer constructs the package-default runtime-vocabulary authorizer. The constructor exists so call sites read as an explicit wiring choice (`Authorizer: approval.NewIdentityAuthorizer()`), not a zero-value accident.

func (IdentityAuthorizer) AuthorizeResolve added in v1.3.0

func (IdentityAuthorizer) AuthorizeResolve(ctx context.Context, pending PendingInfo) error

AuthorizeResolve implements ResolveAuthorizer.

type PendingInfo added in v1.3.0

type PendingInfo struct {
	// Tool is the name of the tool whose call is awaiting approval.
	Tool string
	// Token is the pause token the resolver presented.
	Token pauseresume.Token
	// Identity is the (tenant, user, session) triple the gated call
	// is running under — the pause's originating identity.
	Identity identity.Identity
	// Tags is the caller-supplied classification surface from the
	// original ApprovalRequest.
	Tags []string
}

PendingInfo describes the parked approval a resolver is targeting. The gate builds it from its pending entry and hands it to the configured ResolveAuthorizer so the privilege decision can compare the resolving ctx against the ORIGINATING call's identity / shape. No caller-controlled arg bytes ride here — Tool name, token, identity triple, and classification tags only.

type ResolveAuthorizer added in v1.3.0

type ResolveAuthorizer interface {
	// AuthorizeResolve reports whether the caller represented by ctx
	// may resolve the pending approval described by pending.
	AuthorizeResolve(ctx context.Context, pending PendingInfo) error
}

ResolveAuthorizer is the injected privilege check deciding who may resolve a pending approval (Phase 111f, D-203). `AuthorizeResolve` returns nil to permit the resolution, or an error (conventionally wrapping ErrResolveForbidden) to reject it. The gate calls it after locating the pending entry and BEFORE driving the Coordinator, so an unauthorized resolver never mutates pause state.

Implementations MUST be safe for concurrent use (D-025): one authorizer instance serves every ResolveApproval call on the gate.

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