confirmation

package
v1.2.3 Latest Latest
Warning

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

Go to latest
Published: May 12, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package confirmation implements server-issued HMAC confirmation tokens for high-risk tool calls as specified by docs/adr/0018-risk-class-confirmation-tokens.md (Q1 option B).

The contract: a high-risk tool call (RiskBilling | RiskAdmin | RiskPermissionChange | RiskExternalSideEffect | RiskDestructive) arriving with dry_run:true returns a stateless HMAC token bound to the tool name, the argument fingerprint (with dry_run and confirmation_token excluded), the risk class bitmask, the principal's tenant/subject/session if present, and a server-side expiry. A non-dry-run call to the same tool must include the returned token; the verifier recomputes the binding and rejects mismatches, expirations, and tampering. This gate is independent from the policy gate — policy decides whether the tool is callable at all; this gate decides whether the caller has previewed the effect first.

Layout:

  • Config carries the HMAC secret, TTL, and enable flag.
  • Signer wraps Mint/Verify; Config.Signer() returns one.
  • TokenClaims is the on-wire payload (compact JSON, base64url + "." + base64url(HMAC(...))).
  • BuildArgumentFingerprint stripping is in fingerprint.go.

The package is stdlib-only — no new module dependencies.

Index

Constants

View Source
const (
	// MinTokenTTL is the lower bound on the configured token lifetime.
	// Anything shorter would make the dry-run-then-execute UX impossibly
	// race-prone (the agent's round trip plus network latency).
	MinTokenTTL = time.Minute
	// MaxTokenTTL is the upper bound. Confirmation tokens are a "you
	// previewed this recently" signal — letting them live for hours
	// would erode the value of the dry-run-first flow.
	MaxTokenTTL = time.Hour
)
View Source
const DefaultTTL = 10 * time.Minute

DefaultTTL is the token expiry window when Config.TTL is unset.

View Source
const EnvVarTokenSecret = "CLOCKIFY_CONFIRMATION_TOKEN_SECRET"

EnvVarTokenSecret is the optional explicit secret. Hex- or base64-encoded; >=32 raw bytes after decode.

View Source
const EnvVarTokenTTL = "CLOCKIFY_CONFIRMATION_TOKEN_TTL"

EnvVarTokenTTL is the token lifetime. Parsed by time.ParseDuration; clamped to [1m,1h].

View Source
const EnvVarTokensEnabled = "CLOCKIFY_CONFIRMATION_TOKENS"

EnvVarTokensEnabled is the on/off knob for the confirmation gate.

View Source
const MinSecretBytes = 32

MinSecretBytes is the minimum raw-byte length of the HMAC secret the signer accepts. 32 bytes (256 bits) is the SHA-256 block-fit minimum; rejecting shorter secrets prevents accidental misuse of a short literal.

View Source
const TokenVersion = 1

TokenVersion is the on-wire version byte. Older tokens decoded with a different version are rejected so a future binding-format change can roll forward without ambiguity.

Variables

View Source
var (
	// ErrTokenMalformed signals a token whose on-wire shape is wrong
	// (missing "." separator, undecodable base64, undecodable JSON).
	ErrTokenMalformed = errors.New("confirmation token malformed")
	// ErrTokenSignatureMismatch signals a token whose HMAC does not
	// match the configured secret. Includes wrong-secret and
	// payload-tampering cases.
	ErrTokenSignatureMismatch = errors.New("confirmation token signature mismatch")
	// ErrTokenExpired signals a token whose exp is at or before
	// time.Now (per the Signer's clock).
	ErrTokenExpired = errors.New("confirmation token expired")
	// ErrTokenBindingMismatch signals a token whose embedded claims
	// disagree with the verifier's expected binding (different tool
	// name, different args fingerprint, different
	// tenant/subject/session, different risk class).
	ErrTokenBindingMismatch = errors.New("confirmation token binding mismatch")
	// ErrTokenVersionUnsupported signals a token whose on-wire
	// version byte differs from TokenVersion. Rolling forward
	// requires accepting older versions explicitly; the default is
	// to reject.
	ErrTokenVersionUnsupported = errors.New("confirmation token version unsupported")
	// ErrSecretTooShort signals a Config whose secret has fewer than
	// MinSecretBytes raw bytes. Returned by Signer construction.
	ErrSecretTooShort = errors.New("confirmation token secret too short")
)

Sentinel errors. Verify returns one of these so callers (the enforcement gate, the audit recorder) can branch on the failure mode without parsing strings.

View Source
var ErrHostedSecretRequired = errors.New("CLOCKIFY_CONFIRMATION_TOKEN_SECRET is required for hosted profiles (multi-replica deployments need a shared HMAC secret)")

ErrHostedSecretRequired is returned by ConfigFromEnv when the caller indicates a hosted/multi-replica deployment but CLOCKIFY_CONFIRMATION_TOKEN_SECRET is unset. A process-local random secret cannot survive a cross-replica request, so falling back silently would produce a confirmation_invalid error on every pod hop — worse than declining to boot.

View Source
var FingerprintExcludedKeys = []string{
	"dry_run",
	"confirmation_token",
}

FingerprintExcludedKeys is the set of argument keys stripped before fingerprinting. dry_run flips the request between preview and execution and would otherwise invalidate every token between mint and verify. confirmation_token is the binding itself — including it in its own fingerprint would be circular.

Functions

func BuildArgumentFingerprint

func BuildArgumentFingerprint(args map[string]any) string

BuildArgumentFingerprint returns a base64url-encoded SHA-256 hash of the canonical JSON serialization of args, with the FingerprintExcludedKeys removed.

Canonical JSON: encoding/json sorts map keys alphabetically by default, including in nested map[string]any. Slices preserve order (intentional — argument order matters for tool semantics like tag_ids). Numbers, booleans, strings, and nil round-trip deterministically. The args map is assumed to be JSON-decodable (the MCP tools/call dispatch hands us a map[string]any decoded from the request body), so json.Marshal can never fail here in practice; we still return a stable empty-string sentinel on the unlikely error path so the caller's "binding mismatch" path is reached instead of a panic.

func NewRandomSecret

func NewRandomSecret() ([]byte, error)

NewRandomSecret generates a cryptographically random secret of MinSecretBytes bytes. Used by the runtime fallback when an operator has not configured CLOCKIFY_CONFIRMATION_TOKEN_SECRET.

Types

type Config

type Config struct {
	// Enabled toggles the gate. When false the enforcement layer
	// should skip Verify entirely and never call Mint. The package
	// itself does not inspect this — it's a contract for callers.
	Enabled bool
	// Secret is the HMAC-SHA256 key. Required when Enabled. Use
	// crypto/rand for production; a short literal is rejected at
	// Signer construction.
	Secret []byte
	// TTL is the lifetime applied to Mint'd tokens. Zero defaults to
	// DefaultTTL.
	TTL time.Duration
	// Ephemeral marks a secret that was generated at startup rather
	// than supplied by the operator. The enforcement layer logs a
	// warning so operators on hosted multi-replica deployments see
	// that cross-replica verification will not survive a pod hop.
	Ephemeral bool
}

Config carries the operator-facing knobs. Build a Signer with Config.Signer() to mint/verify tokens; a zero-Config Signer rejects every verification with ErrSecretTooShort so misuse is loud.

type LoadResult

type LoadResult struct {
	Config Config
	Notes  []string
}

LoadResult bundles the parsed Config with operator-facing diagnostic notes the runtime should surface as startup log lines. Returning notes alongside the Config keeps the package free of logging dependencies; the caller decides whether to log, expose in /metrics, or echo to a doctor command.

func ConfigFromEnv

func ConfigFromEnv(hosted bool) (LoadResult, error)

ConfigFromEnv reads the three CLOCKIFY_CONFIRMATION_TOKEN_* env vars into a Config plus diagnostic notes. The `hosted` flag drives the hosted-secret-required policy: hosted profiles refuse to boot without an explicit shared secret because process-local randomness cannot satisfy the stateless verification contract across replicas.

Errors surface as boot-time config errors so a misconfigured hosted deployment fails closed instead of silently falling back to an ephemeral secret nobody else can verify against.

type MintInput

type MintInput struct {
	Tool      string
	ArgsHash  string
	RiskClass uint32
	Tenant    string
	Subject   string
	Session   string
}

MintInput names the contract for a Mint call. Tool, ArgsHash, and RiskClass are required; Tenant/Subject/Session are populated when the call has a principal in context.

type Signer

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

Signer mints and verifies confirmation tokens with a fixed secret + TTL. It does not own the Enabled flag — that belongs to Config and is enforced by the call site.

func NewSigner

func NewSigner(cfg Config) (*Signer, error)

NewSigner builds a Signer from a Config, returning ErrSecretTooShort when the secret is too short to be safe. Pass a nil time-source to use time.Now; tests inject their own.

func (*Signer) Mint

func (s *Signer) Mint(in MintInput) (token string, expiresAt time.Time, err error)

Mint returns a compact token of the form `b64url(claims).b64url(mac)` bound to the input. ExpiresAt is the wall-clock instant the token becomes invalid; surface it to the client so the client knows when to re-issue the dry-run.

func (*Signer) TTL

func (s *Signer) TTL() time.Duration

TTL returns the configured token lifetime; useful for logging the `confirmation_expires_at` field on a dry-run preview.

func (*Signer) Verify

func (s *Signer) Verify(in VerifyInput) (TokenClaims, error)

Verify checks a token against the expected binding. The contract is "constant-time on signature; field-by-field on binding". Returns the decoded claims when verification succeeds so callers can record the nonce for replay tracking if they wish; today we treat tokens as single-use-implicit via TTL but emit the nonce for diagnostics.

type TokenClaims

type TokenClaims struct {
	Version   int    `json:"v"`
	Tool      string `json:"tool"`
	ArgsHash  string `json:"args"`
	RiskClass uint32 `json:"risk"`
	Tenant    string `json:"tenant,omitempty"`
	Subject   string `json:"subject,omitempty"`
	Session   string `json:"session,omitempty"`
	Expires   int64  `json:"exp"`
	Nonce     string `json:"n"`
}

TokenClaims is the JSON payload signed by Mint and re-derived by Verify. All fields are lowercase + short so the on-wire form is compact.

type VerifyInput

type VerifyInput struct {
	Tool      string
	ArgsHash  string
	RiskClass uint32
	Tenant    string
	Subject   string
	Session   string
	Token     string
}

VerifyInput names the contract for a Verify call. The verifier receives the expected binding and the on-wire token; mismatch in any field produces ErrTokenBindingMismatch (so the caller can log a single audit outcome without enumerating which field drifted).

Jump to

Keyboard shortcuts

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