approval

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package approval is the write gate: the seam where a mutating tool call stops and waits for an operator (docs/v0.3-plan.md W2, hitl_policy.on_mutation).

It is deliberately two halves that meet here and nowhere else:

  • pkg/permissions decides POLICY — may this call proceed without asking, must it ask, or is it refused outright. That package is ADK-independent and stays that way.
  • ADK's tool-confirmation flow performs the PAUSE. It is durable: the request is a function call in the session event log, so the turn survives a process death and resumes from the log. mast's own permissions.Prompter is a synchronous in-process ask and by construction cannot survive a restart (scoreboard row 5), which is why the pause is not built on it.

The seam itself is an ADK runner plugin's BeforeToolCallback, which is the only place that sees every tool call — builtin, MCP, or specialist-scoped — before it runs. Registration order matters and is settled: the pkg/effects outbox plugin goes first, this one second, so a replayed call is answered from the outbox without asking an operator to approve a mutation that already happened (resolved-decision 144).

The substrate facts this package is built on are pinned by adkseam_test.go rather than assumed; each is load-bearing and none is documented by ADK.

Index

Constants

View Source
const EditStateKeyPrefix = "mast_approval_edit_"

EditStateKeyPrefix namespaces the durable record of an applied edit in the session's state, one key per function call.

The record exists because the event log alone cannot answer "what executed?" after an edit. ADK re-fires the parked call verbatim, so the durable FunctionCall part still carries the arguments the *model* proposed, and the FunctionResponse next to it is the result of running the *operator's*. Reading the pair without this record gives an operator a confident, wrong answer about what mast did to their cluster (docs/v0.3-plan.md W2.5, "an audit gap the workstream has to close, not inherit").

View Source
const PluginName = "mast-write-gate"

PluginName is the registered name of the write gate's runner plugin.

Variables

This section is empty.

Functions

func CallKey

func CallKey(name string, args map[string]any) string

CallKey renders a tool call as the one-line description an operator approves and the deny policy matches against. Keys are sorted so the same call always renders the same way — an approval record that depended on Go's map iteration order would be useless as an audit trail and unmatchable as a policy pattern.

Values are rendered as compact JSON and long ones are elided: the key is for a human and a glob, and a 40KB manifest argument in a log line helps neither. The full arguments travel in the confirmation payload, which is what the operator actually inspects.

func ConfirmationResponse

func ConfirmationResponse(v Verdict) map[string]any

ConfirmationResponse builds the FunctionResponse payload that answers a parked mutating call. It is the exact wire shape ADK's RequestConfirmationRequestProcessor looks for before re-dispatching the original call: `confirmed` is the boolean it reads, `payload` carries the verdict it cannot express.

It lives here, next to DecodeVerdict, so the writer and the reader of that shape stay in one place — the daemon's /resume handler and the eval rig both build it from this rather than each deriving it, which is what makes the rig's result evidence about mast.

An edit is confirmed=true: the operator is authorizing a call, just not the one the model proposed. The gate decides which arguments run.

func EditStateKey

func EditStateKey(functionCallID string) string

EditStateKey is the state key holding the AppliedEdit record for one function call.

func New

func New(cfg Config) (*plugin.Plugin, error)

New builds the write gate as an ADK runner plugin.

Register it AFTER the pkg/effects outbox plugin at every runner construction site. ADK runs before-tool callbacks in registration order and the first non-nil response wins, so outbox-first is what makes a replayed effect skip the gate: the mutation already happened, and asking an operator to approve it again would invite them to approve doing it twice (resolved-decision row 144).

func VerdictSchema

func VerdictSchema() *jsonschema.Schema

VerdictSchema is the JSON schema a resume payload answering a parked mutating call must satisfy. It has been three-valued since W2.1 — before `edit` was executable — because the schema is written into the durable log at pause time, and a session paused under a two-valued schema and resumed after an upgrade is a migration across exactly the restart boundary the write gate exists to survive.

Approver is deliberately absent: it is not the client's to state. The resume boundary overwrites it with the authenticated caller.

Types

type AppliedEdit

type AppliedEdit struct {
	Tool         string         `json:"tool"`
	Approver     string         `json:"approver"`
	ProposedKey  string         `json:"proposed_key"`
	ExecutedKey  string         `json:"executed_key"`
	ProposedArgs map[string]any `json:"proposed_args"`
	ExecutedArgs map[string]any `json:"executed_args"`
	Note         string         `json:"note,omitempty"`
}

AppliedEdit is the durable record of an operator's edit being applied: what the model asked for, what actually ran, and who authorized the substitution. Stored as a JSON string so it survives every session backend's state encoding unchanged.

func DecodeAppliedEdit

func DecodeAppliedEdit(v any) (AppliedEdit, error)

DecodeAppliedEdit parses a record written under an EditStateKey. The value is whatever the session backend handed back: the JSON string that was written, or — for a backend that decodes state values — the map it decodes to.

func (AppliedEdit) String

func (e AppliedEdit) String() string

String renders the record for an operator-facing listing.

type Config

type Config struct {
	// Policy is the workload's hitl_policy.on_mutation. Required.
	Policy OnMutation

	// Mutating classifies a tool by name. Required. mast passes
	// effects.Predicate's classification so the outbox and the write
	// gate can never disagree about what counts as a mutation — a tool
	// that is recorded as an effect is a tool that needs approval.
	Mutating func(toolName string) bool

	// Gate adjudicates policy for a parked call and validates the
	// operator's verdict. Required when Policy is
	// OnMutationRequireApproval; unused otherwise.
	Gate *permissions.Gate

	// Logger receives the audit trail. Defaults to slog.Default().
	Logger *slog.Logger
}

Config wires the write gate.

type OnMutation

type OnMutation string

OnMutation is a workload's policy for what happens when a specialist calls a mutating tool (docs/orchestration-design.md, hitl_policy.on_mutation).

const (
	// OnMutationRequireApproval parks the call and waits for an
	// operator. The default, and the only value that is safe when the
	// roster's remediation tools reach a live cluster.
	OnMutationRequireApproval OnMutation = "require_approval"

	// OnMutationApply executes mutating calls without asking. Policy
	// still applies — a configured deny still denies — but no human is
	// consulted. For workloads whose "mutations" are confined to a test
	// fixture, and for reproducing an approved change set unattended.
	OnMutationApply OnMutation = "apply"

	// OnMutationDryRun never executes a mutating call and reports the
	// call it would have made. The agent keeps working with a truthful
	// "this did not happen" instead of a fabricated success.
	OnMutationDryRun OnMutation = "dry_run"
)

func (OnMutation) Valid

func (p OnMutation) Valid() bool

Valid reports whether p is a policy this package implements. The empty string is not valid: callers default it explicitly so that a typo in a bundle cannot silently mean "require approval" in one code path and "apply" in another.

type Outcome

type Outcome string

Outcome is the operator's answer to a parked mutating call.

const (
	// OutcomeApprove runs the call as the agent proposed it.
	OutcomeApprove Outcome = "approve"
	// OutcomeReject refuses the call. The agent is told not to retry and
	// not to look for another way to achieve the same effect.
	OutcomeReject Outcome = "reject"
	// OutcomeEdit runs the call with arguments the operator supplied in
	// place of the agent's. The operator's arguments are validated against
	// the tool's declared input schema, re-adjudicated against policy
	// under their own call key, and recorded durably as an AppliedEdit —
	// an edit that fails any of those is refused rather than narrowed
	// (edit.go).
	OutcomeEdit Outcome = "edit"
)

type Parked

type Parked struct {
	// Hint is the one-line question the gate wrote.
	Hint string
	// Tool is the parked call's tool name, "" if it could not be read.
	Tool string
	// Args are the arguments the agent proposed.
	Args map[string]any
	// Request is the gate's own payload (an approval.Request), as the
	// map the event log round-trips it to. Nil when the confirmation
	// came from somewhere other than mast's write gate — a tool that
	// calls RequestConfirmation itself, for instance.
	Request map[string]any
}

Parked describes a mutating call waiting on an operator, read back out of the durable session log.

The gate writes the question; this reads it. The two are in the same package because the args of an adk_request_confirmation call are an ADK-internal shape that mast happens to be able to read, not a contract — a reader that lived somewhere else would drift.

func DescribeConfirmation

func DescribeConfirmation(args map[string]any) Parked

DescribeConfirmation reads a parked mutating call out of the Args of an adk_request_confirmation function call (internal/llminternal/functions.go builds them: {"originalFunctionCall": *genai.FunctionCall, "toolConfirmation": toolconfirmation.ToolConfirmation}).

Both value shapes are handled. In-process — an attach client tailing live events — the args hold the typed structs; read back from the event log they are the maps JSON leaves behind. Anything unreadable is left zero rather than guessed at: this feeds an operator deciding whether to change a cluster, and a plausible-looking reconstruction is worse than a blank.

func (Parked) Summary

func (p Parked) Summary() string

Summary is the operator-facing one-liner for a parked call: the hint when the gate wrote one, the rendered call otherwise, and an honest admission when neither could be read.

type Request

type Request struct {
	Tool    string         `json:"tool"`
	Args    map[string]any `json:"args"`
	Key     string         `json:"key"`
	Policy  string         `json:"policy"`
	Agent   string         `json:"agent"`
	Verdict map[string]any `json:"verdict_format"`
}

Request is the payload of the parked confirmation: everything an operator needs to answer without reconstructing the call from the transcript, plus a description of the answer mast accepts.

func DecodeRequest

func DecodeRequest(v any) (Request, error)

DecodeRequest reads the parked confirmation's payload back into the typed Request. The value is whatever the caller got out of the durable log — Parked.Request, or a transcript projection's Payload — which is the map JSON leaves behind rather than the struct the gate wrote.

type Scope

type Scope string

Scope is how far an approval reaches. It is on the wire so that a client asking for more than one call gets an explicit refusal instead of a silent narrowing (docs/v0.3-plan.md W2.3): only ScopeOnce is admissible for a mutation, and the refusal is issued by permissions.Gate.RecordMutationVerdict, not here, so that the rule lives with the rest of the grant policy.

const (
	// ScopeOnce authorizes exactly this call. The default, and the only
	// scope a mutating call accepts.
	ScopeOnce Scope = "once"
	// ScopeSession asks to authorize this exact request for the session.
	ScopeSession Scope = "session"
	// ScopeSessionTool asks to authorize every call to this tool for the
	// session.
	ScopeSessionTool Scope = "session_tool"
	// ScopeAlways asks to persist a standing allowlist entry.
	ScopeAlways Scope = "always"
)

type Verdict

type Verdict struct {
	Verdict  Outcome        `json:"verdict"`
	Scope    Scope          `json:"scope,omitempty"`
	Args     map[string]any `json:"args,omitempty"`
	Note     string         `json:"note,omitempty"`
	Approver string         `json:"approver,omitempty"`
}

Verdict is the operator's answer, carried in the Payload of the ADK tool confirmation that resumes a parked call (docs/orchestration-design.md, "Mutation approval").

Approver is not client-supplied trust. Whatever a client puts there is overwritten at the resume boundary with the authenticated principal that presented the verdict (cmd/mast's verdictFor), so every verdict mast itself produces carries one. An OutcomeEdit that nonetheless reaches the gate without an approver is refused: an edit executes arguments no model proposed and no policy pattern vetted, so the attribution is the only record of where they came from.

func DecodeVerdict

func DecodeVerdict(c *toolconfirmation.ToolConfirmation) (Verdict, error)

DecodeVerdict reads mast's verdict record out of an ADK tool confirmation.

ADK's own field is the boolean Confirmed, which cannot express an edit; mast's record rides in Payload. A client that speaks only ADK still works — a payload with no verdict field falls back to the boolean — and a client that speaks both must not contradict itself: a payload saying "approve" under Confirmed:false is a bug somewhere in the caller's serialization, and guessing which half meant it would be guessing about whether to mutate a production cluster.

Everything unrecognized is an error rather than a default. The failure mode of a permissive decoder here is executing a mutation the operator did not authorize.

func (Verdict) Decision

func (v Verdict) Decision() (permissions.Decision, error)

Decision maps the verdict's scope onto the permissions decision the gate adjudicates. A reject is DecisionDeny regardless of scope — there is no such thing as denying more broadly than the call in hand.

Jump to

Keyboard shortcuts

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