policy

package
v0.1.161 Latest Latest
Warning

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

Go to latest
Published: May 31, 2026 License: MIT Imports: 27 Imported by: 0

Documentation

Overview

Package policy is the codefly permission and authority layer.

============================================================= THREE LAYERS, THREE CONCERNS, ENFORCED INDEPENDENTLY =============================================================

  • CAPACITY (sandbox) — what bytes/syscalls a binary CAN touch at the kernel level. Lives in core/runners/sandbox; applied at manager.Load via the sandbox wrap. Independent of authority.

  • AUTHORITY (this package) — what business actions a Principal IS ALLOWED to perform. Lives in this package + saas-starter. Enforced by the PDP at every tool call.

  • ACCOUNTABILITY (audit) — who did what under whose authority. Cross-cuts both layers. saas-starter audit_events and delegation_chain in tokens.

All three travel via the same Principal-bearing Biscuit-or-JWT token. Capacity is enforced by the OS regardless of authority. Authority is checked by the PDP. Audit logs the full chain. Three independent failures still get you defense in depth.

============================================================= THE PIECES IN THIS PACKAGE =============================================================

**Identity:**

  • Principal (principal.go) Unified type for humans, services, and agents. Carries ID, Kind, OrgID, AgentID, optional DelegationChain, the verified credential. Validated at construction; immutable.

  • WithPrincipal / PrincipalFrom (principal.go) Context helpers: stamp a Principal on a ctx, retrieve it downstream. The gRPC interceptor (in core/agents) is the standard stamper; handlers read.

  • EncodePrincipalToken / DecodePrincipalToken (principal_token.go) The wire format. Today: base64url(JSON) v1-unsigned. M6+: Biscuit. Plugin authors never call these directly — the manager + interceptor handle the round-trip.

**Authority manifest:**

  • PermissionPolicy (permissions.go) The plugin manifest's declaration: which actions on which resources the plugin CAN perform. The PDP intersects this with role grants — the manifest is the CEILING.

  • SandboxPolicy (permissions.go) The plugin manifest's CAPACITY declaration: read paths, write paths, network policy. Applied via sandbox.Wrap at manager.Load. Independent of PermissionPolicy.

**Decision interfaces — three audiences, three shapes:**

  • Decider (decider.go) — host-facing. Identical to PDP; hosts (Mind, gateway, CLI) construct and pass these. Full PDPRequest / PDPDecision shape with caveats and delegation proofs. This is the interface that gets wired through manager.Load via WithPermissionsCallback.

  • Authorizer (decider.go) — plugin-facing. Simple `Authorized (ctx, action, resource) → (bool, reason, err)` shape. Plugin authors call this for fine-grained sub-operation gating INSIDE a tool (the outer call is already authorized by the Guard). Implementation is a UDS callback to the host.

  • PermissionsBackend (decider.go) — saas-starter-facing. The thinnest interface a saas-starter gRPC client must satisfy to plug into SaasPDP. Pulls the dependency arrow one-way: core has no compile-time tie to saas-starter; saas-starter wires itself in via this interface.

**PDP — the decision engine:**

  • PDP interface (pdp.go) Evaluate(ctx, *PDPRequest) → PDPDecision. The single point of authorization. policyguard.Guard wraps a Toolbox to consult the PDP on every CallTool.

  • AllowAllPDP / DenyAllPDP / JSONPDP (pdp.go) Built-ins for development, tests, and simple production allow-lists.

  • SaasPDP (pdp_saas.go) Production Decider. Adapts a PermissionsBackend (saas- starter or any other policy store) into the PDP interface. Adds: positive-decision LRU cache (configurable TTL, never caches denies), fail-closed-on-backend-error, observability via PDPMetrics + RecordDecision.

  • CeilingPDP (pdp_ceiling.go) Wraps an inner PDP, intersects with the plugin's manifest PermissionPolicy. First gate — fail fast on undeclared actions before round-tripping to saas-starter.

  • ShadowPDP (pdp_shadow.go) Wraps an inner PDP, ALWAYS allows but records the inner decision via observability. Used during M5 rollout to surface "what would have been denied" before flipping to enforce.

  • BuildPDP / ResolvePDPMode (pdp_mode.go) The standard composition of the above, driven by CODEFLY_PDP_MODE (off / shadow / enforce). Hosts call these at startup.

**Gateway-layer pre-evaluation (gateway.go + scoped_auth.go):**

The host (Mind, codefly's gateway, CLI) evaluates a tool policy BEFORE forwarding the call to the plugin. On allow, mints a short-lived signed token (ScopedAuthorization) carrying the resolved decision. The token rides on outgoing gRPC metadata (`x-codefly-scoped-authz`) and is verified by the plugin's Guard before invoking inner toolbox handlers.

  • GatewayEvaluator (gateway.go) — host-side composer of ToolPolicy + Decider, mints tokens on allow.
  • ToolPolicy (gateway.go) — pluggable per-tool rule type. Built-in: AllowAlwaysToolPolicy, DenyAlwaysToolPolicy, ManifestCeilingPolicy. Operators implement custom for Cedar/Rego/business-specific rules.
  • ScopedAuthorization (scoped_auth.go) — the token type: time-bounded, use-bounded, audience-bound, signed via HMAC-SHA256 with a per-spawn secret.
  • Mint / Verify (scoped_auth.go) — HMAC-signed JSON envelope.
  • ReplayTracker (scoped_auth.go) — plugin-side per-spawn LRU enforcing MaxUses across calls.

**Two-level defense:** Guard tries the fast path (verify token) first; on missing/invalid token, falls back to the full PDP path. Three independent layers: gateway pre-evaluation, plugin outer Guard, inline Authorizer for sub-operations. Any single layer's failure leaves the others enforcing.

See TWO_LEVEL_AUTHZ.md for the design rationale and security analysis.

**Token formats — v1-hmac and v2-ed25519:**

  • `Mint(input, secret)` / `Verify(token, expect, secret)` — v1-hmac (HMAC-SHA256). Shared secret between gateway and plugin. Best for single-host setups.

  • `MintEd25519(input, privateKey)` / `VerifyEd25519(token, expect, publicKey)` — v2-ed25519 (public-key signing). Gateway holds the private key; plugins hold the public key. No secret distribution.

  • `TokenVerifier` (`scoped_auth_v2.go`) — dual-format dispatch by `fmt:` tag. Supports key rotation (multiple public keys; first match wins).

**Hardening (hardening.go):**

  • `TokenRevocationList` — invalidate compromised tokens before expiry. Concurrent-safe; supports bulk Replace for file-backed reloads.
  • Break-glass: `CODEFLY_BREAK_GLASS_JUSTIFICATION` env var bypasses PDP for incident response with mandatory WARN-level audit on every call.
  • Recursion depth caps: `CODEFLY_MAX_DELEGATION_DEPTH` (default 3) bounds delegation-chain length to defend against pathological multi-hop escalation patterns.

**M7 escalation (escalation.go):**

  • `EscalationGrantor` interface — saas-starter implements, core defines. Routes escalation requests through Slack/ email/UI approval pipeline.
  • `RequestEscalation(ctx, req)` — SDK helper plugins call when authority is denied; returns ctx with elevated token attached.
  • `AuthorizedOrEscalate(ctx, authorizer, action, resource, justification)` — ergonomic wrapper.

**M8 pattern grants** — `via_pattern` audit caveat marks tokens minted via auto-approval pattern matches at the saas-starter side.

**Plugin → host callback channel (callback.go):**

Plugin authors invoke `policy.AuthorizerFromContext(ctx). Authorized(ctx, action, resource)` for inline permission checks inside a tool handler. The Authorizer is a UDS-backed HTTP client that calls the host's PermissionsCallbackServer. Wiring:

  • Host: manager.Load(WithPermissionsCallback(decider)) creates the server (UDS, 0600 file perms), sets the path in the plugin's CODEFLY_PERMISSIONS_SOCKET env, binds the spawn- time Principal as the trusted subject. Close shuts the server and removes the socket file.

  • Plugin: agents.Serve's principal interceptor stamps the Authorizer on every request ctx (process-singleton, lazy- constructed from env). Without a callback socket, it's the disabledAuthorizer that fails closed with a clear reason on every call.

**Why a callback channel rather than running the PDP in-process in the plugin.** Plugins must NOT depend on saas-starter's gen client (one-way dependency arrow). Permission state is mutable (revocations must be visible immediately); centralizing the PDP cache + metrics in the host is the only place that property is reasoned about cleanly.

**Security property: principal binding cannot be impersonated.** The PermissionsCallbackServer uses a principalProvider closure that returns the spawn-time Principal. The plugin's request body's principal_id is IGNORED — even a compromised plugin cannot escalate by claiming a different principal. End-to-end test: TestE2E_Authorizer_PluginCannotImpersonate.

**Observability:**

  • PDPMetrics (observability.go) Atomic counters for decisions (allow/deny/require_approval/ fail_closed/cache_hits) plus mean latency. Read via Snapshot(). Operators wire this into Prometheus / OTEL.

  • RecordDecision (observability.go) Single entry point: bumps the counters, logs the decision via wool with structured fields. Every PDP wrapper calls it; one place to instrument.

============================================================= HOW DECISIONS FLOW =============================================================

On every plugin tool call:

  1. host (manager.Load) spawns plugin with WithPrincipal(p) - mints CODEFLY_PRINCIPAL_TOKEN (encoded p) - mints CODEFLY_AGENT_TOKEN (process binding, separate) - sandbox.Wrap applies if WithSandbox set

  2. plugin process starts; agents.Serve registers gRPC server - chains: auth → principal → rpcStats interceptors - if PluginRegistration.PDP != nil: wraps Toolbox with policyguard.Guard

  3. host calls plugin.CallTool(ctx, request) - principalUnaryInterceptor extracts token from env or metadata, decodes, validates, stamps via WithPrincipal - Guard receives the call, builds a PDPRequest (Toolbox + Tool + Args + Identity from principal) - PDP stack evaluates: a. CeilingPDP checks manifest.Allows(action, resource) - undeclared → deny; inner never consulted b. inner PDP (e.g. SaasPDP) checks role grants c. ShadowPDP (if mode=shadow) records but always allows - allow → handler runs with Principal on ctx - deny → CallToolResponse{Error: reason}, handler skipped

  4. handler reads PrincipalFrom(ctx) for audit / branching - NEVER for authorization decisions (PDP did that)

============================================================= PLUGIN AUTHOR SURFACE — three roles, no security code =============================================================

A plugin author's interaction with the permission system:

  • DECLARE: edit toolbox.codefly.yaml's `permissions:` block. This is reviewed by the user at install time; this is the ceiling.

  • READ: optionally call policy.PrincipalFrom(ctx) inside a tool handler — for audit fields, display names, or to decide what to RETURN (e.g. filter data the principal "owns"). Never to gate "may this principal perform this action" — that's the PDP.

  • REQUEST (M7+): if the plugin's role grant doesn't cover a needed action, call host.RequestEscalation to ask a grantor for temporary delegated authority. The host handles the user notification and approval flow.

Plugin authors do NOT:

  • implement a PDP
  • check permissions in handler code
  • construct or verify principal tokens
  • manage role grants or audit logs

See PLUGIN_AUTHORS.md (in this directory) for the practical guide with code examples.

============================================================= BASH-AST + CANONICAL REGISTRY (orthogonal layer) =============================================================

CanonicalRegistry (canonical.go) is a separate enforcement layer for plugins that exec shell commands. It maps a binary name (e.g. "git") to its canonical toolbox owner so a plugin's `bash -c "git push"` is refused with "use the git toolbox instead". The bash AST parser splits on &&/||/;/| and evaluates each command, defeating the canonical chained-binary bypass.

This is the OTHER half of "defense in depth": the PDP gates declared tool calls; the CanonicalRegistry gates ad-hoc shell invocations.

============================================================= EXTENDING THE STACK =============================================================

Add a new PDP wrapper: implement PDP.Evaluate, add a constructor that takes an inner PDP, layer it in BuildPDP.

Add a new caveat: extend the Biscuit verification path (M6+). Until then, hand-validate fields you care about in the inner PDP's Evaluate.

Add a new principal kind: it's already extensible — the principals.kind CHECK constraint in saas-starter governs what's accepted. Update both ends.

============================================================= WHAT'S IN saas-starter, NOT HERE =============================================================

The role-assignment data, audit_events, delegation_grants (M7+), and the actual principal CRUD live in saas-starter's principals/role tables. core/policy is the codefly-side wire + enforcement; saas-starter is the source of truth.

The bridge is the PermissionDecider interface (pdp_saas.go, when implemented) — keeps core decoupled from saas-starter's generated client. Hosts wire a concrete client at startup.

Index

Constants

View Source
const (
	RiskLevelLow      = "low"
	RiskLevelMedium   = "medium"
	RiskLevelHigh     = "high"
	RiskLevelCritical = "critical"
)

Risk-level constants. Use these instead of string literals so rename refactors catch usage at compile time.

View Source
const (
	KindHuman   = "human"
	KindService = "service"
	KindAgent   = "agent"
)

PrincipalKind values. Centralized as constants so callers don't drift on string literals.

View Source
const DefaultMaxDelegationDepth = 3

DefaultMaxDelegationDepth is the per-process cap when the env var is unset.

View Source
const EnvBreakGlass = "CODEFLY_BREAK_GLASS_JUSTIFICATION"

EnvBreakGlass is the env var that controls break-glass mode. Non-empty value = break-glass active; the value is the mandatory justification.

View Source
const EnvMaxDelegationDepth = "CODEFLY_MAX_DELEGATION_DEPTH"

EnvMaxDelegationDepth overrides the default depth cap.

View Source
const EnvPDPMode = "CODEFLY_PDP_MODE"

EnvPDPMode is the env-var name. Centralized as a constant so a rename refactors callers reliably.

View Source
const EnvPDPRequireManifest = "CODEFLY_PDP_REQUIRE_MANIFEST"

EnvPDPRequireManifest controls the CeilingPDP's RequireManifest flag. true = empty manifest denies all; false = empty manifest passes through (M4 rollout default). Operators flip to true once every plugin has been audited and declares its permissions.

View Source
const EnvPermissionsSocket = "CODEFLY_PERMISSIONS_SOCKET"

EnvPermissionsSocket carries the path to the host's permission callback UDS. Set by manager.Load when spawning a plugin if the host has a Decider configured. Plugin-side AuthorizerFromEnv reads it to dial.

Empty/unset → the plugin runs without a callback channel. Authorized() then fails closed (the safe default — no ambient allow).

View Source
const ScopedAuthMetadataKey = "x-codefly-scoped-authz"

ScopedAuthMetadataKey is the gRPC metadata header that carries the encoded token. Lowercase per gRPC convention.

Variables

View Source
var ErrEscalationDenied = errors.New("escalation: denied")

ErrEscalationDenied is returned when the grantor explicitly refuses. Callers can errors.Is to distinguish from infrastructure errors and from timeouts.

View Source
var ErrEscalationTimedOut = errors.New("escalation: timed out")

ErrEscalationTimedOut is returned when the request times out without a decision. Distinct from ErrEscalationDenied so the SDK can retry or surface "no answer yet" appropriately.

View Source
var ErrGatewayDeny = errors.New("gateway: denied")

ErrGatewayDeny is the umbrella error returned by EvaluateAndMint when policy denies the call. Wraps a more specific reason; the caller surfaces it to the model so it can plan around.

View Source
var ErrNoGrantor = errors.New("escalation: no grantor configured (call SetGlobalEscalationGrantor at startup)")

ErrNoGrantor is returned when RequestEscalation runs with no grantor configured. Distinct from infrastructure failure so operators see "you forgot to wire the grantor" loud.

View Source
var ErrPrincipalInvalid = errors.New("principal: invalid")

ErrPrincipalInvalid is the umbrella error for Validate failures. Wrap with %w so callers can errors.Is for the umbrella but still surface the specific reason.

View Source
var ErrScopedAuthExhausted = errors.New("scoped-authz: max uses exhausted")

ErrScopedAuthExhausted is returned when a token's MaxUses has been consumed. Distinct from ErrScopedAuthInvalid because the token is OTHERWISE valid — only repeat-use exceeded.

View Source
var ErrScopedAuthInvalid = errors.New("scoped-authz: invalid")

ErrScopedAuthInvalid is the umbrella error for verification failures. Wrap with %w so callers can errors.Is for the umbrella but still see the specific cause via err.Error().

View Source
var ErrUnknownTokenFormat = errors.New("unknown token format")

ErrUnknownTokenFormat is returned by TokenVerifier.Verify when the envelope's `fmt:` tag isn't recognized.

Functions

func AuthorizedOrEscalate added in v0.1.160

func AuthorizedOrEscalate(
	ctx context.Context,
	authorizer Authorizer,
	action, resource, justification string,
) (context.Context, error)

AuthorizedOrEscalate is the high-ergonomic wrapper for plugin code: try authorization, escalate on RequireApproval, return the elevated ctx.

**Three outcomes:**

  • allowed (no escalation needed): returns the original ctx, nil
  • escalation approved: returns ctx with the elevated token, nil
  • denied / timed-out / no-grantor: returns ctx, error

The plugin uses the returned ctx for the actual call; the SDK hides whether the call's authority came from the principal's own grants OR a grantor's escalation.

Justification is REQUIRED — without it, escalation rejects at validation. Plugin authors who don't have a justification should use Authorized directly and surface the deny to the model.

func CheckDelegationDepth added in v0.1.160

func CheckDelegationDepth(p *Principal) error

CheckDelegationDepth returns an error if the principal's delegation chain exceeds the configured cap. Used by the Guard alongside Verify; called after signature verification passes but before dispatching the call.

nil principal or empty chain always pass — no chain to cap.

func DefaultCaveatVerifiers added in v0.1.160

func DefaultCaveatVerifiers() map[string]CaveatVerifier

DefaultCaveatVerifiers returns the standard set of verifiers the plugin Guard should use to verify built-in caveats. Plugins call this once at startup and pass the result into guard.WithCaveatVerifiers.

The verifiers it returns assume the matching gateway-side producer ran with a SPEC (it gets baked into the closure). For the built-ins, the spec comes from the YAML the operator authored. For custom caveats, operators register their own.

Note: this returns a verifier per name with EMPTY spec — suitable for verifiers that only consult the token's snapshot. For verifiers that need the original spec at verify time (rare), build them explicitly via the factory + your spec.

func EncodePrincipalToken added in v0.1.160

func EncodePrincipalToken(p *Principal) (string, error)

encodePrincipalToken produces the env-var token from a Principal. Returns base64url(json(envelope)) — URL-safe so quoting / shell escaping doesn't mangle.

The Principal must be Validate()'d by the caller; we don't re- validate here because the loader already did upstream. Returns an error only on JSON marshal failure, which would indicate a programmer error in the Principal type itself.

func IsBreakGlassActive added in v0.1.160

func IsBreakGlassActive() bool

IsBreakGlassActive reports whether the env var is set with a non-empty justification. Cached after first read.

**Operator note.** Setting this env var is auditable on its own — it leaves a trail in process startup logs, ConfigMap changes, etc. The point is not secrecy (you announce it); it's TRACEABILITY. "Was break-glass active during 2025-04-12 14:00 UTC?" is a one-grep question.

func LogBreakGlassUsage added in v0.1.160

func LogBreakGlassUsage(ctx context.Context, action, resource string)

LogBreakGlassUsage emits the WARN audit event for a break-glass-bypassed call. Caller is the Guard at CallTool time. Does NOT panic if break-glass is inactive (defensive); returns silently.

func LookupCaveat added in v0.1.160

func LookupCaveat(name string) (producer CaveatProducerFactory, verifier CaveatVerifierFactory, ok bool)

LookupCaveat returns the registration for name. Used by the YAML parser at load time. Returns nil if the caveat isn't registered — caller decides whether to error or skip.

func MaxDelegationDepth added in v0.1.160

func MaxDelegationDepth() int

MaxDelegationDepth returns the configured per-process cap. Reads env once, caches. Invalid values (non-int, negative) fall back to the default with a WARN log on first call.

func NewSpawnSecret added in v0.1.160

func NewSpawnSecret() []byte

NewSpawnSecret returns a 32-byte cryptographically random secret suitable for HMAC-SHA256. Used by manager.Load to mint a per-spawn signing key shared with the plugin.

Panics on rand source failure — that's an unrecoverable system issue, not something to swallow.

func NewULID added in v0.1.160

func NewULID() string

NewULID returns a time-prefixed, base32-encoded random identifier suitable for token IDs.

**Why not a strict-spec ULID.** The proposal called for ULIDs for time-ordered audit scans. For our purposes (token IDs that need to be unique and ideally sortable), a hex-encoded `<unix-millis>-<random>` is simpler and adequate:

  • Unique enough: 64 bits of random per ID, collision-free at codefly's scale.
  • Time-sortable: hex-encoded millis prefix sorts correctly (until year 5138; we're not worried).
  • URL-safe: hex characters fit any header / log format.
  • 24 chars fixed-width: 16 (millis) + 1 (separator) + 16 (random).

If we ever need strict-spec ULIDs (e.g. for cross-system correlation with another service that mints ULIDs), import github.com/oklog/ulid; for now this is dependency-free.

func ParseYAMLToolPolicies added in v0.1.160

func ParseYAMLToolPolicies(data []byte) (map[string]ToolPolicy, error)

ParseYAMLToolPolicies parses YAML bytes into ToolPolicy implementations keyed by tool id, ready for GatewayEvaluator. All caveat references are resolved at parse time; unknown caveat names produce an error.

The returned map keys exactly match what GatewayEvaluator's lookup expects: <toolbox>:<tool> or <toolbox>:*.

func RecordDecision added in v0.1.160

func RecordDecision(ctx context.Context, metrics *PDPMetrics, ev DecisionEvent)

RecordDecision is the central observability entry point. Call it once per PDP decision, AFTER the decision has been computed.

Behavior:

  • Increments the relevant counters on metrics (allow/deny/etc).
  • Logs a structured wool event at the appropriate level (Trace for allows, Info for denies, Warn for fail-closed).
  • If a span is active on ctx, adds a span event with the same fields so traces stay correlated.

Pass nil for metrics to skip counter updates (legacy code paths during migration). Always passes a non-empty event spec.

func RegisterCaveat added in v0.1.160

func RegisterCaveat(name string, producer CaveatProducerFactory, verifier CaveatVerifierFactory)

RegisterCaveat installs a caveat under the given name. Both producer and verifier factories are required (a producer-only caveat passes a no-op verifier; a verifier-only caveat passes a no-op producer).

Re-registration of the same name PANICS — keeps drift between gateway and plugin loud at startup. Operators with custom caveats: pick a unique name (prefix with org/, e.g. "acme/cron").

func RequestEscalation added in v0.1.160

func RequestEscalation(ctx context.Context, req EscalationRequest) (context.Context, error)

RequestEscalation is the SDK entry point for plugins that need to escalate authority. It:

  1. Validates the request (action, principal, justification).
  2. Calls the grantor (global or supplied).
  3. On approve: returns ctx with the scoped-auth metadata attached, ready for the plugin to retry the failed call.
  4. On deny: returns ErrEscalationDenied (wrapping the reason).
  5. On timeout: returns ErrEscalationTimedOut.
  6. On infrastructure error: returns the underlying error.

**Plugin-side usage:**

// Initial call hit RequireApproval; escalate.
ctx, err := policy.RequestEscalation(ctx, policy.EscalationRequest{
    Principal:     policy.PrincipalFrom(callCtx),
    Action:        "github.merge_pr",
    Resource:      "pr:codefly-dev/codefly.dev/456",
    Justification: "PR has 2 approvals, CI green, auto-merge label",
    Timeout:       5 * time.Minute,
})
if err != nil { return err }
// Retry with elevated ctx — the metadata header carries the
// scoped-auth token; downstream Guard verifies + dispatches.
return github.MergePR(ctx, prID)

The ctx returned has the scoped-auth metadata attached as outgoing-context metadata (so it travels on the next gRPC call automatically). For non-gRPC calls, the caller can read the token via ScopedAuthFrom(ctx) — also stamped — and attach it however the downstream channel expects.

func ResetHardeningCachesForTest added in v0.1.160

func ResetHardeningCachesForTest()

ResetHardeningCachesForTest clears the cached env reads so tests can drive different env values per case via t.Setenv. **Exported for tests only** — production code MUST NOT call this. The exposed-for-test pattern is the cleanest way to avoid build-tag gymnastics; the function name's _ForTest suffix is the convention.

func ResolveRequireManifest added in v0.1.160

func ResolveRequireManifest() bool

ResolveRequireManifest reads CODEFLY_PDP_REQUIRE_MANIFEST. Truthy values: "1", "true", "yes" (case-insensitive). Anything else = false. Empty/unset = false (the M4 rollout default).

func SetGlobalEscalationGrantor added in v0.1.160

func SetGlobalEscalationGrantor(g EscalationGrantor)

SetGlobalEscalationGrantor installs the host-wide grantor used by RequestEscalation when no grantor is supplied explicitly. Hosts call this once at startup; tests pass nil to clear.

func SetGlobalGatewayEvaluator added in v0.1.160

func SetGlobalGatewayEvaluator(g *GatewayEvaluator)

SetGlobalGatewayEvaluator installs the host-wide evaluator used by helpers like AttachScopedAuthToOutgoingContext. Called once at host startup; tests can call with nil to clear.

func WithAuthorizer added in v0.1.160

func WithAuthorizer(ctx context.Context, a Authorizer) context.Context

WithAuthorizer stamps an Authorizer on ctx. Called by agents.Serve (or any plugin wiring) once at startup; handlers retrieve via AuthorizerFromContext.

func WithPrincipal added in v0.1.160

func WithPrincipal(ctx context.Context, p *Principal) context.Context

WithPrincipal returns a context carrying p. Use after credential verification — interceptors do this once per call, then handlers use Get to read.

func WithScopedAuth added in v0.1.160

func WithScopedAuth(ctx context.Context, sa *ScopedAuthorization) context.Context

WithScopedAuth stamps a verified ScopedAuthorization on ctx. Called by the Guard after successful Verify; handlers can read via ScopedAuthFrom for audit logging.

Types

type AllowAllPDP

type AllowAllPDP struct{}

AllowAllPDP allows every call. Identity surface for "no policy configured."

func (AllowAllPDP) Evaluate

type AllowAlwaysToolPolicy added in v0.1.160

type AllowAlwaysToolPolicy struct {
	TTL     time.Duration
	MaxUses int
}

AllowAlwaysToolPolicy permits every call with default TTL and max_uses. Useful for tests and for operators who don't have custom rules — the role-grant check is still the gate.

func (AllowAlwaysToolPolicy) Evaluate added in v0.1.160

type AllowlistSpec added in v0.1.160

type AllowlistSpec struct {
	// ContextKey is the field in EvaluationInput.Context to check.
	ContextKey string `yaml:"context_key" json:"context_key"`
	// Allowed is the set of acceptable values.
	Allowed []string `yaml:"allowed" json:"allowed"`
	// MatchMode: "equals" (default) | "any_of_list" (context value is a list, any matches)
	MatchMode string `yaml:"match_mode,omitempty" json:"match_mode,omitempty"`
}

type AuthorizeRequest added in v0.1.160

type AuthorizeRequest struct {
	PrincipalID string `json:"principal_id"`
	Action      string `json:"action"`
	Resource    string `json:"resource,omitempty"`
	OrgID       string `json:"org_id,omitempty"`
}

AuthorizeRequest is the JSON payload from plugin → host. Field names map to saas-starter's Decide RPC for trivial pass-through.

type AuthorizeResponse added in v0.1.160

type AuthorizeResponse struct {
	Allowed bool   `json:"allowed"`
	Reason  string `json:"reason,omitempty"`
}

AuthorizeResponse is the JSON payload from host → plugin.

HTTP semantics:

  • 200 + Allowed=true → plugin proceeds
  • 200 + Allowed=false → policy deny; plugin surfaces Reason
  • 5xx → fail-closed; plugin treats as deny (never as allow)

type Authorizer added in v0.1.160

type Authorizer interface {
	// Authorized reports whether the principal on ctx is
	// permitted to perform action on resource.
	//
	// **Three return values, three failure modes:**
	//
	//   - (true, "", nil)        — proceed
	//   - (false, reason, nil)   — policy says no; surface reason
	//   - (false, "", err)       — backend failure (network, etc).
	//                              Caller decides whether to fail
	//                              closed (recommended) or treat
	//                              the operation as best-effort.
	//
	// Distinct (false, reason, nil) vs (false, "", err) semantics
	// matter: a clean policy deny is the model's responsibility to
	// handle (retry differently, ask for help). A backend error is
	// an operational issue (saas-starter unreachable) — the model
	// can't recover by retrying with different args.
	Authorized(ctx context.Context, action, resource string) (allowed bool, reason string, err error)
}

Authorizer is the plugin-facing permission interface.

**Why this is separate from Decider.** Plugin authors should never construct a PDPRequest by hand. The plugin doesn't know about caveats, delegation proofs, manifest declarations, or risk levels — those are host concerns. The plugin asks one question: "may the calling principal do action X on resource Y?" and gets a yes/no with a reason.

Authorizer wraps a Decider. The wrapping handles:

  • Pulling the Principal from ctx (placed by the principal interceptor — see core/agents/principal_interceptor.go)
  • Building the PDPRequest with the right Toolbox identity
  • Caching positive decisions briefly to avoid hammering saas-starter when a plugin loops over many resources
  • Mapping policy denies into a clear (false, reason) shape

**Plugin authors:** read PLUGIN_AUTHORS.md. The short version is `policy.AuthorizerFromContext(ctx).Authorized(ctx, action, resource)` returns `(allowed bool, reason string, err error)`. Use it for sub-operation gating within a tool that's already outer-authorized; never as a substitute for declaring permissions in your manifest.

func AuthorizerFromContext added in v0.1.160

func AuthorizerFromContext(ctx context.Context) Authorizer

AuthorizerFromContext returns the Authorizer stamped on ctx, or a disabledAuthorizer when none is present (so plugin code can always call .Authorized without a nil check; the disabled variant returns (false, "no authorizer in context", nil)).

**Why never nil.** Defensive: handlers that test against nil can forget the check and crash on a deny path; returning a type that fails-closed by default keeps the contract simple — Authorized always returns a useable answer.

func NewCallbackAuthorizer added in v0.1.160

func NewCallbackAuthorizer(socketPath string, timeout time.Duration) Authorizer

NewCallbackAuthorizer constructs an Authorizer that dials the given UDS path with the supplied per-request timeout. Useful for tests that need to point at a specific socket.

func NewCallbackAuthorizerFromEnv added in v0.1.160

func NewCallbackAuthorizerFromEnv() Authorizer

NewCallbackAuthorizerFromEnv constructs the standard plugin- side Authorizer reading CODEFLY_PERMISSIONS_SOCKET from env. If the env is unset, returns the no-op disabledAuthorizer that fails-closed on every call.

Plugin authors don't call this directly — agents.Serve wires it into the request context. Use AuthorizerFromContext from handlers.

type CanonicalRegistry

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

CanonicalRegistry maps a binary name to the toolbox that owns it.

When a plugin's Bash toolbox parses a command and finds the leaf program is in this registry, it MUST refuse and tell the agent to invoke the canonical toolbox instead. The canonical-tool routing is the high-level enforcement layer — the OS sandbox is the belt-and-suspenders below it.

Population:

  • At plugin manifest load time, every plugin's `canonical_for:` list contributes to the registry. Each binary name may have exactly one canonical owner; a conflict is a load-time error (caught early, not at first invocation).

  • A built-in fallback covers binaries no plugin has claimed yet (`git`, `docker`, `nix`, `kubectl`, `helm`, `curl`, `wget`). Default behavior for the fallback set: DenyMissingToolbox — refuse with a clear "install the X toolbox" hint, instead of silently letting bash run the binary unsupervised.

func NewCanonicalRegistry

func NewCanonicalRegistry() *CanonicalRegistry

NewCanonicalRegistry returns a registry seeded with the built-in fallback. Plugin claims are added via Claim.

func (*CanonicalRegistry) Claim

func (r *CanonicalRegistry) Claim(owner string, binaries ...string) error

Claim records that `owner` is the canonical toolbox for each binary in `binaries`. Returns an error if any binary already has a non-fallback owner — two plugins both claiming `git` is a configuration error that must surface at load time, not at first invocation.

If an existing entry is a built-in fallback (owner == ""), the claim wins silently — the Git toolbox plugin claims `git`, replacing the unclaimed-fallback entry.

func (*CanonicalRegistry) Lookup

func (r *CanonicalRegistry) Lookup(bin string) *Decision

Lookup returns the routing decision for a binary name. A nil decision means the binary is not routed and bash may execute it (subject to whatever other policy layers apply). The lookup strips a leading path: `/usr/bin/git` resolves to `git`.

func (*CanonicalRegistry) Owners

func (r *CanonicalRegistry) Owners() []OwnerEntry

Owners returns a sorted snapshot of (binary, owner) pairs for diagnostic display (`codefly policy show` style commands).

type CaveatPrecheck added in v0.1.160

type CaveatPrecheck func(ctx context.Context, in EvaluationInput) error

CaveatPrecheck runs at mint time to decide if the caller may proceed. Returns nil = proceed; non-nil error = deny with reason. Stateful caveats (rate_limit) are the typical implementers.

type CaveatProducer added in v0.1.160

type CaveatProducer func(in EvaluationInput) (any, error)

CaveatProducer computes a caveat value from the call context at token-mint time. The output is what the verifier matches at plugin side.

Example: a "ci_status" caveat producer reads in.Context["ci_status"] and returns the value, baking the snapshot into the token. If CI status changes between mint and verify, the verifier still sees the snapshot — that's correct: the gateway authorized THIS call based on THIS state.

type CaveatProducerFactory added in v0.1.160

type CaveatProducerFactory func(spec CaveatSpec) (producer CaveatProducer, precheck CaveatPrecheck, err error)

CaveatProducerFactory builds a CaveatProducer + a deny-at-mint pre-check from a YAML spec. Called once per tool policy at load time.

Returns:

  • producer (optional): if non-nil, runs at mint time to compute a value baked into the token's caveats map.
  • precheck (optional): if non-nil, runs at mint time. nil return = mint-allowed; non-nil error = mint-deny with reason. Used for stateful caveats like rate_limit that decide allow/deny at the gateway, no token value needed.

At least one of (producer, precheck) must be non-nil. Factories that return both are valid (snapshot + state check).

type CaveatSpec added in v0.1.160

type CaveatSpec map[string]any

CaveatSpec is the YAML-derived configuration for a single caveat instance. Each registered caveat factory parses Spec into its concrete type (e.g. TimeWindowSpec for time_window).

Using map[string]any keeps the YAML parser caveat-agnostic — it just collects Spec fields and hands them to the factory.

type CaveatVerifier added in v0.1.160

type CaveatVerifier func(value any) error

CaveatVerifier checks a single caveat against the call context. Return nil to accept; return error (with reason) to reject.

type CaveatVerifierFactory added in v0.1.160

type CaveatVerifierFactory func(spec CaveatSpec) (CaveatVerifier, error)

CaveatVerifierFactory builds a CaveatVerifier from a YAML spec. Plugins instantiate these at startup from the same YAML the gateway used; the spec carries the policy parameters the verifier needs.

For caveats whose verification depends ONLY on the token's snapshot (no spec needed at verify time), the factory ignores spec and returns a verifier that closes over the snapshot.

type CeilingPDP added in v0.1.160

type CeilingPDP struct {
	// Inner is the role-grant PDP (typically SaasPDP). Required.
	Inner PDP

	// Manifest is the plugin's declared authority. The ceiling
	// check uses Manifest.Allows(action, resource).
	Manifest PermissionPolicy

	// RequireManifest controls behavior when Manifest is empty
	// (zero PermissionPolicy):
	//
	//   - false (default during M4 rollout): empty manifest =
	//     "no ceiling enforced", every action passes through to
	//     Inner. This preserves backwards-compat with plugins
	//     that haven't declared permissions yet.
	//
	//   - true (after M4 enforcement flip): empty manifest =
	//     "no actions allowed", every action denied. This is the
	//     production target: every plugin MUST declare its
	//     permissions before the host accepts it.
	//
	// Operators flip this via CODEFLY_PDP_REQUIRE_MANIFEST=true
	// once every plugin has been audited.
	RequireManifest bool
}

CeilingPDP enforces the manifest declarations as the maximum authority a plugin can ever exercise — even if a saas-starter role grants more, this layer denies actions outside the manifest.

**Why this wrapper exists separately from the inner PDP.** The role-grant check (saas-starter) and the manifest-ceiling check answer different questions:

  • Role-grant: "is this principal CURRENTLY allowed to do X?" (mutable; revokable by an admin in real time)
  • Manifest-ceiling: "does this plugin's manifest CLAIM the authority to do X?" (set at install; immutable until re-install with a new manifest)

Putting them in the same wrapper makes the order explicit:

  1. Manifest ceiling — fast, local, fail-loud on undeclared
  2. Inner PDP (typically saas-starter) — slower, remote, deals with role grants and approval flows

First-deny-wins. If the manifest doesn't declare the action, the inner PDP is never consulted — saves a network round-trip for actions the plugin couldn't perform anyway.

**Why this is "defense in depth."** Without the ceiling, a compromised plugin could have an admin-mistake role grant expanded into authority the user never reviewed. With the ceiling, the install-time review IS the contract — the plugin can never silently exceed it, no matter what role grants happen later.

func NewCeilingPDP added in v0.1.160

func NewCeilingPDP(inner PDP, manifest PermissionPolicy, requireManifest bool) CeilingPDP

NewCeilingPDP wraps inner with manifest enforcement. Panics on nil inner — same as ShadowPDP, the constructor refuses misconfiguration that would silently make the wrapper a no-op.

func (CeilingPDP) Evaluate added in v0.1.160

func (c CeilingPDP) Evaluate(ctx context.Context, req *PDPRequest) PDPDecision

Evaluate runs the ceiling check, then defers to Inner. The manifest decision is the FIRST gate — it short-circuits before the inner PDP is touched. This both:

  1. Avoids a saas-starter round-trip for actions the plugin couldn't perform anyway (faster + cheaper)
  2. Makes the ceiling check unmistakable in audit logs (the decision_path includes "manifest-ceiling" when this layer refused; "role-grant" or "no-grant" come from Inner)

type Decider added in v0.1.160

type Decider = PDP

Decider is the host-facing interface for permission decisions.

Conceptually identical to PDP — the same Evaluate signature. The alias exists for *documentation clarity*: when reading host code (Mind, gateway, CLI) alongside plugin code, "Decider" pairs naturally with "Authorizer" — different audiences, different shapes, same engine underneath.

┌──────────────┐      ┌──────────────┐
│ host (Mind,  │      │ plugin       │
│  gateway)    │      │              │
└──────────────┘      └──────────────┘
      │                       │
      │ Decider                │ Authorizer
      │ (full PDPRequest +     │ (action, resource → bool)
      │  PDPDecision shape)    │
      ▼                       ▼
   ┌─────────────────────────────────┐
   │ Same underlying engine          │
   │ (CeilingPDP → SaasPDP → … )     │
   └─────────────────────────────────┘

Implementations:

  • SaasPDP (pdp_saas.go) — calls saas-starter's Decide RPC
  • CeilingPDP (pdp_ceiling.go) — manifest enforcement wrapper
  • ShadowPDP (pdp_shadow.go) — observability-only wrapper
  • JSONPDP (pdp.go) — file-backed allow-list for dev/test
  • FakePDP (testharness/pdp.go) — programmable test double

Production hosts compose the stack via BuildPDP (pdp_mode.go).

type Decision

type Decision struct {
	// Routed indicates the binary has a canonical toolbox; the bash
	// executor must refuse and direct the caller there.
	Routed bool

	// Owner is the plugin name that owns the canonical surface, or ""
	// for the built-in fallback (no plugin yet ships the toolbox; the
	// binary is denied with a hint to install one).
	Owner string

	// Reason is the human-readable explanation for the routing —
	// suitable for surfacing verbatim in the bash-toolbox error.
	Reason string
}

Decision is the result of looking up a binary in the registry.

type DecisionEvent added in v0.1.160

type DecisionEvent struct {
	// Toolbox + Tool — what was being authorized.
	Toolbox string
	Tool    string

	// PrincipalID — who was asking. Empty when no Principal was
	// stamped on the call (legacy paths).
	PrincipalID string

	// PrincipalKind — "human" | "service" | "agent" | "" if unset.
	PrincipalKind string

	// AgentID — publisher/name:version for kind=agent; empty otherwise.
	AgentID string

	// DelegationDepth — 0 if the call wasn't delegated; else the
	// length of the delegation chain. Useful for "show me deeply-
	// delegated calls" audit queries.
	DelegationDepth int

	// Decision — the verdict.
	Decision PDPDecision

	// Latency — wall time from PDP entry to verdict. Includes
	// network calls to the auth backend; useful for SLI alerting.
	Latency time.Duration

	// CacheHit — true if the decision came from local cache (no
	// backend round-trip).
	CacheHit bool

	// FailClosed — true if the backend was unreachable and the PDP
	// defaulted to deny. Distinct from a normal Deny.
	FailClosed bool
}

DecisionEvent describes one PDP decision for observability. Carries the structured fields downstream backends (logs, metrics, traces) need. Fields are flat / primitive so backends don't need codec tricks to serialize.

type DelegationLink struct {
	// PrincipalID is the lender at this link.
	PrincipalID string

	// Kind mirrors Principal.Kind for the lender.
	Kind string

	// DisplayName mirrors Principal.DisplayName for the lender.
	// Surfaced in audit + approval UI; not authoritative for auth.
	DisplayName string

	// GrantID is the saas-starter delegation_grants.id row that
	// authorized this hop. Lets auditors trace from a tool call back
	// to the exact grant that allowed it. Empty if this link
	// pre-dates the delegation_grants table (legacy delegation).
	GrantID string
}

DelegationLink is one node in a chain of authority lending. Carries enough to audit who-lent-what without requiring a roundtrip to saas-starter to resolve.

type DenyAllPDP

type DenyAllPDP struct{}

DenyAllPDP denies every call. Useful in tests + as a sanity check.

func (DenyAllPDP) Evaluate

type DenyAlwaysToolPolicy added in v0.1.160

type DenyAlwaysToolPolicy struct {
	Reason string
}

DenyAlwaysToolPolicy is the symmetric opposite: refuses every call with the supplied reason. Useful for kill-switch-style configurations ("temporarily block all force-push tool calls").

func (DenyAlwaysToolPolicy) Evaluate added in v0.1.160

type EscalationDecision added in v0.1.160

type EscalationDecision int

EscalationDecision is the grantor's verdict.

const (
	EscalationDecisionUnspecified EscalationDecision = iota
	// EscalationApproved: grantor approved; the gateway minted
	// a fresh ScopedAuthorization (in EscalationResult.Token).
	EscalationApproved
	// EscalationDenied: grantor explicitly refused. Reason in
	// EscalationResult.Reason.
	EscalationDenied
	// EscalationTimedOut: nobody decided within the request's
	// Timeout. Distinct from EscalationDenied so the SDK can
	// surface "no answer yet" vs "explicit no".
	EscalationTimedOut
)

func (EscalationDecision) String added in v0.1.160

func (d EscalationDecision) String() string

type EscalationGrantor added in v0.1.160

type EscalationGrantor interface {
	// Request submits the escalation, blocks until decided OR
	// the request's Timeout elapses, returns the result.
	//
	// Errors are for INFRASTRUCTURE failures (network,
	// auth-backend down). A grantor-decided deny is NOT an
	// error — it's EscalationResult{Decision: EscalationDenied,
	// Reason: ...}. The SDK treats infrastructure errors as
	// fail-closed (the action doesn't proceed); a deny is
	// distinguished from a timeout so the model knows whether
	// to retry differently or give up.
	Request(ctx context.Context, req EscalationRequest) (*EscalationResult, error)
}

EscalationGrantor is the interface mind/saas-starter implements. Concrete implementations route the request through their notification + approval pipeline (Slack, in-app inbox, email, etc.) and return the grantor's verdict synchronously.

**Why this is a one-method interface.** The agent SDK's flow is: build a request, call Request, get a result. Anything richer (the request_id mid-flight, polling, etc.) is the implementation's concern.

**Why core defines the interface but no implementation.** Core has zero dependency on saas-starter (one-way arrow). Operators implementing this interface call their own RequestDelegation / WaitForDelegation RPCs and translate to/from these types.

func GetGlobalEscalationGrantor added in v0.1.160

func GetGlobalEscalationGrantor() EscalationGrantor

GetGlobalEscalationGrantor returns the registered grantor or nil if none.

type EscalationRequest added in v0.1.160

type EscalationRequest struct {
	// Action and Resource are what's being requested. Must match
	// the failed authorization attempt — the elevated token
	// will authorize EXACTLY this (action, resource).
	Action   string
	Resource string

	// Principal is who's requesting. Bound to the spawn-time
	// principal so the plugin can't request escalation as
	// someone else.
	Principal *Principal

	// Justification is the human-readable reason this principal
	// needs the action right now. Surfaced to the grantor in
	// the approval UI. Required — empty justifications are
	// rejected; without one, the grantor has no basis to decide.
	//
	// Examples:
	//   "PR has 2 approvals, CI green, auto-merge label"
	//   "Customer support ticket #123 needs db access"
	//   "Out-of-hours hotfix for INC-456"
	Justification string

	// Grantor optionally targets a specific approver or role
	// ("user:antoine", "team:engineering", "role:admin"). Empty
	// means "the saas-starter default approver chain for this
	// (action, resource)" — typically anyone with the matching
	// role assignment.
	Grantor string

	// Timeout caps how long RequestEscalation blocks. Defaults
	// to 5 minutes if zero. A timeout produces a timeout-deny
	// response (caller distinguishes from a grantor-deny).
	Timeout time.Duration

	// Context carries metadata for the approval UI (PR number,
	// commit sha, etc.). Same shape as EvaluationInput.Context
	// so callers can pass through without translation.
	Context map[string]any

	// PriorRequestID, when set, references a previous
	// RequireApproval decision that triggered this escalation.
	// The grantor can correlate the request with the failed
	// authorization attempt in audit logs.
	PriorRequestID string
}

EscalationRequest is the input to RequestEscalation. The agent SDK builds one from the failed authz attempt + the plugin's caller-supplied justification.

**Why a separate type from PDPRequest.** PDPRequest is for fast authorization decisions (microseconds). EscalationRequest can block for minutes waiting for human approval; carries notification-relevant fields (justification, requested grantor, urgency) that don't belong in a PDP request.

func (*EscalationRequest) Validate added in v0.1.160

func (r *EscalationRequest) Validate() error

Validate checks the request is structurally complete. Called by RequestEscalation before going to the grantor; saves a round-trip on misconfigured calls.

type EscalationResult added in v0.1.160

type EscalationResult struct {
	// Decision is the verdict.
	Decision EscalationDecision

	// Token is the encoded ScopedAuthorization minted on
	// approve. Empty on deny / timeout.
	Token string

	// Authorization is the decoded form (same data as Token) for
	// inspection / audit logging.
	Authorization *ScopedAuthorization

	// Reason is human-readable. Required on deny; optional on
	// approve (some approvers add a note explaining why); empty
	// on timeout.
	Reason string

	// Decider is the principal that made the decision. Empty on
	// timeout. Always populated on approve/deny so audit traces
	// which grantor signed off.
	Decider string

	// GrantID is the saas-starter delegation_grants.id row that
	// recorded this decision. Lets auditors trace from a tool
	// call back to the exact grant that allowed it.
	GrantID string
}

EscalationResult is the grantor's response. On Approved, Token carries a ScopedAuthorization the agent can attach to its retry via the standard metadata header.

type EvaluationInput added in v0.1.160

type EvaluationInput struct {
	Principal *Principal
	Toolbox   string // canonical identity, e.g. "codefly.dev/github-bot:0.1.0"
	Tool      string // dotted action, e.g. "github.merge_pr"
	Resource  string // typed identifier, e.g. "repo:codefly/x"

	// Context is the runtime context for caveat evaluation
	// (ci_status, labels, request body summary, etc.). Tool
	// policies consume this; pass through to caveats.
	Context map[string]any
}

EvaluationInput carries the call context the gateway needs to produce a decision.

type EvaluationResult added in v0.1.160

type EvaluationResult struct {
	// Token is the encoded ScopedAuthorization, ready to attach
	// to outgoing gRPC metadata via AttachToOutgoingContext.
	Token string

	// Authorization is the decoded form (same data as Token)
	// for inspection / audit.
	Authorization *ScopedAuthorization

	// DecisionPath is a trace-style explanation of how the
	// decision was reached ("manifest-allow → role-grant:
	// editor → tool-policy: ci-green").
	DecisionPath string
}

EvaluationResult is what EvaluateAndMint returns on success.

type ExternalDecision added in v0.1.160

type ExternalDecision struct {
	// Allow is the verdict.
	Allow bool

	// Reason is required when Allow is false. Surfaces to the
	// model via the gateway's deny path.
	Reason string

	// TTL — token lifetime when Allow=true. Zero uses
	// GatewayEvaluator.DefaultTTL.
	TTL time.Duration

	// MaxUses — single-shot (0 → 1) by default.
	MaxUses int

	// Caveats — name → value pairs to bake into the token.
	// Plugin verifiers must be registered for each name.
	Caveats map[string]any
}

ExternalDecision is what an external engine returns. Maps 1:1 to the gateway's ResolvedToolPolicy plus a deny path.

type ExternalPolicyEvaluator added in v0.1.160

type ExternalPolicyEvaluator interface {
	Evaluate(ctx context.Context, in EvaluationInput) (ExternalDecision, error)
}

ExternalPolicyEvaluator is the bridge interface. Implementations wrap a Cedar bundle, OPA bundle, or any other rule engine.

**Implementation notes:**

  • Evaluate is on the gateway hot path. Cache aggressively inside your implementation (Cedar's PolicySet is already-compiled; OPA's prepared queries are cheap to reuse).

  • Return errors only for genuine evaluation faults (engine bug, malformed policy data). A clean policy deny is NOT an error — return ExternalDecision{Allow: false, Reason: ...}. Errors are reported as ErrGatewayDeny by the adapter.

  • The Caveats map in ExternalDecision is what gets baked into the ScopedAuthorization. Engines that compute conditional allows (e.g. "allow only when ci_status=green") should snapshot the matched values here so the plugin's verifiers re-check them.

type ExternalToolPolicy added in v0.1.160

type ExternalToolPolicy struct {
	// Evaluator is the wrapped engine. Required.
	Evaluator ExternalPolicyEvaluator
}

ExternalToolPolicy wraps an ExternalPolicyEvaluator as a ToolPolicy GatewayEvaluator can consume.

Usage:

cedarEngine := myteam.NewCedarEngine(policyBundle)  // your code
gatewayPolicies := map[string]policy.ToolPolicy{
    "codefly.dev/github-bot:0.1.0:*": &policy.ExternalToolPolicy{
        Evaluator: cedarEngine,
    },
}

func (*ExternalToolPolicy) Evaluate added in v0.1.160

Evaluate implements ToolPolicy.

type FakeBackend added in v0.1.160

type FakeBackend struct {

	// Err, when non-nil, makes EVERY call return Err. Used to
	// exercise the fail-closed path. Takes precedence over rules.
	Err error
	// contains filtered or unexported fields
}

FakeBackend is an in-memory PermissionsBackend used by tests of SaasPDP and any code that takes a PermissionsBackend. It records calls and answers from a programmable rule set.

Concurrent-safe. Tests of cache behavior under load rely on this.

func NewFakeBackend added in v0.1.160

func NewFakeBackend(defaultAllow bool) *FakeBackend

NewFakeBackend returns a backend whose default verdict is (defaultAllow, "default-rule"). Pair with Allow / Deny to install specific rules.

func (*FakeBackend) Allow added in v0.1.160

func (f *FakeBackend) Allow(principalID, action, resource, orgID string) *FakeBackend

Allow installs a rule that grants (principalID, action, resource, orgID). Returns the receiver for chaining.

func (*FakeBackend) CallCount added in v0.1.160

func (f *FakeBackend) CallCount() int

CallCount returns the number of Decide calls observed.

func (*FakeBackend) Calls added in v0.1.160

func (f *FakeBackend) Calls() []FakeBackendCall

Calls returns a snapshot of every Decide call. Safe to retain.

func (*FakeBackend) Decide added in v0.1.160

func (f *FakeBackend) Decide(ctx context.Context, principalID, action, resource, orgID, scope string) (bool, string, string, error)

Decide implements PermissionsBackend.

func (*FakeBackend) Deny added in v0.1.160

func (f *FakeBackend) Deny(principalID, action, resource, orgID, reason string) *FakeBackend

Deny installs a deny rule with the supplied reason. Reason must be non-empty — silent denies harm test debuggability.

func (*FakeBackend) Reset added in v0.1.160

func (f *FakeBackend) Reset()

Reset clears the call log. Rules persist.

type FakeBackendCall added in v0.1.160

type FakeBackendCall struct {
	PrincipalID  string
	Action       string
	Resource     string
	OrgID        string
	Scope        string
	Decision     bool
	Reason       string
	DecisionPath string
}

FakeBackendCall is one recorded Decide call. Snapshot — safe to retain after Calls() returns.

type GatewayEvaluator added in v0.1.160

type GatewayEvaluator struct {
	// ToolPolicies maps "<toolbox>:<tool>" or "<toolbox>:*" to
	// the operator's tool policy. Lookups try the exact key
	// first, then the wildcard. nil/missing = manifest-only
	// policy (no operator rules).
	ToolPolicies map[string]ToolPolicy

	// Decider is the inner role-grant check. Typically a
	// SaasPDP that calls saas-starter's Decide RPC.
	Decider Decider

	// Secret is the HMAC key used to sign minted tokens. Must
	// be at least 32 bytes (enforced at Mint time).
	Secret []byte

	// DefaultTTL is the time window applied when the tool
	// policy doesn't specify. Recommended: 120s. Operators
	// who need different windows configure per-tool.
	DefaultTTL time.Duration

	// Metrics, if non-nil, gets every decision recorded.
	Metrics *PDPMetrics
}

GatewayEvaluator is the host-side composer of tool policies and role grants. Hosts (Mind, codefly's gateway, the CLI) construct one of these at startup and call EvaluateAndMint per outgoing CallTool.

Responsibilities:

  1. Look up the registered ToolPolicy for the (toolbox, tool).
  2. Run the tool policy against the input (manifest ceiling + operator rules).
  3. Run the inner Decider (typically SaasPDP) for the role- grant check.
  4. If both pass, mint a ScopedAuthorization carrying the resolved decision (action, resource, principal, time window, max uses, computed caveats).
  5. Record observability for every decision (allow/deny/error).

The gateway is the FIRST defense layer; the plugin's Guard is the second. Even if a host bug causes the gateway to mint a fraudulent token, the plugin's Guard verifies the signature and — if absent or invalid — falls back to the PDP-via-callback path. Three independent layers; defense in depth.

func GetGlobalGatewayEvaluator added in v0.1.160

func GetGlobalGatewayEvaluator() *GatewayEvaluator

GetGlobalGatewayEvaluator returns the registered evaluator, or nil if none.

func (*GatewayEvaluator) EvaluateAndMint added in v0.1.160

func (g *GatewayEvaluator) EvaluateAndMint(ctx context.Context, in EvaluationInput) (*EvaluationResult, error)

EvaluateAndMint runs the full pipeline. Returns:

  • (result, nil) on allow with a fresh signed token
  • (nil, err) on deny — err wraps ErrGatewayDeny with the reason
  • (nil, err) on backend failure — err is the underlying error (typically a saas-starter network issue). The caller MUST fail the request; gateway never silently allows on backend failure.

type JSONPDP

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

JSONPDP evaluates a JSONPolicy. Construct with NewJSONPDPFromFile or directly via NewJSONPDP for in-memory use (tests).

func NewJSONPDP

func NewJSONPDP(p JSONPolicy) *JSONPDP

NewJSONPDP returns a PDP backed by the given parsed policy.

func NewJSONPDPFromFile

func NewJSONPDPFromFile(path string) (*JSONPDP, error)

NewJSONPDPFromFile reads a JSON file and constructs a JSONPDP. The file shape mirrors JSONPolicy directly; example:

{
  "default": "deny",
  "rules": [
    {"toolbox": "git", "allow": true},
    {"toolbox": "docker", "tool": "docker.list_containers", "allow": true},
    {"toolbox": "web", "allow": false, "reason": "no outbound HTTP from this workspace"}
  ]
}

func (*JSONPDP) Evaluate

func (j *JSONPDP) Evaluate(_ context.Context, req *PDPRequest) PDPDecision

Evaluate runs the rules in order. First match wins; the rule's Allow field is the decision. If no rule matches, the policy's Default applies.

type JSONPolicy

type JSONPolicy struct {
	Default string       `json:"default"` // "allow" or "deny"
	Rules   []PolicyRule `json:"rules"`
}

JSONPolicy is a minimal-but-real allow-list. Each rule names a toolbox + tool (or "*" for any) and a verdict. First match wins; the default if no rule matches is Default (typically "deny" for safety, "allow" for development).

Why this exists alongside a Rego target: Rego is great for expressive policies (group membership, attribute-based access), but it's heavy and operators often start with "git is allowed, docker is not." The JSON shape covers that without dragging in the OPA Go library.

Migrate to Rego when you find yourself writing predicates the JSON shape can't express (boolean logic across attributes, time-of-day rules, etc.). Same PDP interface; different evaluator.

type ManifestCeilingPolicy added in v0.1.160

type ManifestCeilingPolicy struct {
	Manifest PermissionPolicy
	TTL      time.Duration
	MaxUses  int
}

ManifestCeilingPolicy enforces that the tool MUST be declared in the plugin's PermissionPolicy. Mirrors the M4 CeilingPDP logic at the gateway layer — moves the manifest check upstream of the plugin process.

**Why duplicate the M4 check at the gateway.** Defense in depth. If the gateway evaluates first and denies undeclared actions, the plugin never sees them — minimizing attack surface and audit noise. The plugin's CeilingPDP is still the second defense layer (catches anything the gateway missed).

func (ManifestCeilingPolicy) Evaluate added in v0.1.160

type MapExpander

type MapExpander map[string]string

MapExpander is the standard PathExpander backed by an explicit map of placeholder name (without the ${}) → expansion. Use NewExpander to construct one with sensible defaults seeded from the current process (HOME, TMPDIR) plus a caller-supplied WORKSPACE.

Lookups for placeholders not in the map fail loud — that's the contract: a typo in a manifest must surface at load time, never silently expand to "".

func NewExpander

func NewExpander(workspace string) MapExpander

NewExpander returns a MapExpander seeded with HOME (from os.UserHomeDir, falling back to $HOME), TMPDIR (from os.TempDir), and WORKSPACE (from the supplied argument). Empty workspace means "no ${WORKSPACE} expansion available" — manifests using it will fail loudly. Callers that have additional placeholders (CACHE_DIR, AGENT_ROOT, …) can copy the map and add to it.

func (MapExpander) Expand

func (e MapExpander) Expand(s string) (string, error)

Expand replaces ${KEY} tokens with their mapped expansions. Strings without "${" pass through unchanged. Unknown placeholders return an error naming the offending input.

type MintInput added in v0.1.160

type MintInput struct {
	Principal  *Principal // required; supplies PrincipalID/Kind/OrgID
	Action     string     // required
	Resource   string     // optional; "" = any resource
	AudienceID string     // recommended; binds to a specific plugin
	TTL        time.Duration
	MaxUses    int            // 0 → defaults to 1
	Caveats    map[string]any // optional
	NowFunc    func() time.Time
	IDFunc     func() string
}

MintInput carries everything Mint needs to produce a signed token. Fields are required unless tagged optional.

type NetworkPolicy

type NetworkPolicy string

NetworkPolicy mirrors sandbox.NetworkPolicy but is the YAML-facing type. Translation is one-way at policy.Apply time.

const (
	// NetworkDeny severs all network access. Default for new
	// manifests; explicit zero value avoids "what does empty mean?"
	// ambiguity.
	NetworkDeny NetworkPolicy = "deny"

	// NetworkOpen leaves network unrestricted. Explicit opt-in only.
	// Auditors should grep for `network: open` in manifests.
	NetworkOpen NetworkPolicy = "open"

	// NetworkLoopback allows 127.0.0.1 only — required for the
	// agent loader's gRPC handshake to reach the plugin's
	// loopback listener, while denying every external connection.
	// Recommended secure default for plugin manifests.
	NetworkLoopback NetworkPolicy = "loopback"
)

type OwnerEntry

type OwnerEntry struct {
	Binary string
	Owner  string
	Reason string
}

OwnerEntry is one row in a registry snapshot.

type PDP

type PDP interface {
	Evaluate(ctx context.Context, req *PDPRequest) PDPDecision
}

PDP is the policy decision point a toolbox dispatch layer consults before invoking a tool. Implementations:

  • AllowAll: always allows; the codefly default while operators migrate. Useful for development.
  • DenyAll: always denies; useful in tests + as a sanity check that the wrap is wired (a flipped flag should refuse every call, surfacing the wrap's effect).
  • JSONPDP: reads a JSON allow-list from disk. Working default for production until a real Rego policy is in place.
  • (operator-supplied) RegoPDP: github.com/open-policy-agent/opa/rego.Rego. Registered via a small adapter the operator writes; codefly does NOT depend on OPA directly (heavy transitive surface).

The interface is intentionally tiny so any of the above is a drop-in replacement.

func BuildPDP added in v0.1.160

func BuildPDP(mode PDPMode, inner PDP, manifest PermissionPolicy, requireManifest bool, metrics *PDPMetrics) PDP

BuildPDP composes the PDP stack the host installs into PluginRegistration.PDP. Layers (top-down):

  • Mode shadow: ShadowPDP wraps the rest (logs, always allow)
  • Mode off: AllowAllPDP returned (no Guard installed)
  • Mode enforce: no extra wrapper; raw stack enforces

Inside the mode wrapper, the stack is always:

  • CeilingPDP wraps inner (manifest ceiling check first)
  • Inner is whatever the caller supplies (typically SaasPDP)

**Why this composition.** Manifest ceiling SHOULD short-circuit before the saas-starter round-trip, so it's the innermost wrapper (closest to the request). Shadow wraps the OUTSIDE so it observes the final composed decision (including ceiling-denies) and can log+allow them all the same way.

Caller passes `manifest` per plugin (different plugins have different manifests). `inner` is shared across plugins (one SaasPDP for the whole host).

Pass nil metrics to opt out of counter recording in shadow mode.

func NewCallbackPDPFromEnv added in v0.1.160

func NewCallbackPDPFromEnv() PDP

NewCallbackPDPFromEnv constructs a PDP backed by the host's permission callback (the UDS server set up via manager.WithPermissionsCallback). When the env var is unset, returns a disabledPDP that fails closed on every call.

**Usage in a plugin:**

pdp := policy.NewCallbackPDPFromEnv()
guard := policyguard.New(myToolbox, pdp, audience)
agents.Serve(agents.PluginRegistration{Toolbox: guard})

The Guard's defense path now consults the host's PDP — same principal, same role grants, same fail-closed semantics.

type PDPDecision

type PDPDecision struct {
	// Allow is the load-bearing field. When false (and
	// RequireApproval is also false), the toolbox dispatch layer
	// short-circuits with Reason as the user-visible refusal.
	Allow bool

	// Reason is required when Allow is false; ignored when true.
	// Surface verbatim — the model uses it to decide whether to
	// retry with different arguments, escalate to the user, or
	// abandon the path.
	Reason string

	// RequireApproval signals that the action is conditionally
	// permitted but needs a grantor's approval first (M7+
	// synchronous escalation flow). Distinct from a plain Deny:
	// the agent SDK will turn this into a RequestEscalation call
	// rather than failing the model's plan immediately.
	//
	// Until M7 lands, no PDP returns this — the field is reserved
	// so M7's introduction is a backwards-compatible additive
	// change rather than a breaking enum bump.
	RequireApproval bool

	// ApprovalRequestID is the saas-starter delegation_grants.id
	// row created for a RequireApproval decision. The agent SDK
	// passes this to WaitForDelegation. Empty when RequireApproval
	// is false.
	ApprovalRequestID string
}

PDPDecision is what the PDP returns. Three terminal states:

  • Allow=true: action permitted; dispatch proceeds
  • Allow=false, RequireApproval=false: refused; surface Reason
  • Allow=false, RequireApproval=true: held pending approval; the agent SDK can request escalation and retry (M7+)

"No decision" maps to Deny per zero-trust. Reason carries a human-readable explanation that surfaces back to the agent so the model understands WHY it was refused (and can plan around it).

type PDPMetrics added in v0.1.160

type PDPMetrics struct {
	// DecisionsTotal is bumped on every Evaluate call, regardless
	// of outcome. Pair with AllowsTotal/DeniesTotal/RequireApprovalsTotal
	// to get the breakdown.
	DecisionsTotal atomic.Int64

	// AllowsTotal — successful authorizations.
	AllowsTotal atomic.Int64

	// DeniesTotal — refused authorizations. A spike here is the
	// signal that triggers operator alerts.
	DeniesTotal atomic.Int64

	// RequireApprovalsTotal — calls that returned RequireApproval
	// (M7+). Until the synchronous escalation flow lands, this stays
	// at zero in production.
	RequireApprovalsTotal atomic.Int64

	// CacheHitsTotal — Decide call short-circuited via the local
	// cache (see SaasPDP, M3+). Used to verify cache effectiveness
	// in load tests.
	CacheHitsTotal atomic.Int64

	// FailClosedTotal — PDP couldn't reach saas-starter (or other
	// backend failure) and defaulted to deny. Critical reliability
	// signal: spikes here mean the auth backend is unreachable.
	FailClosedTotal atomic.Int64

	// LatencyNanosTotal + LatencySamples let callers compute mean
	// decision latency without keeping a histogram. For p99-grade
	// observability operators should plug in a real histogram via
	// the wool span on each call (Tracer adds the span timing).
	LatencyNanosTotal atomic.Int64
	LatencySamples    atomic.Int64
}

PDPMetrics is the lightweight, in-process metric surface for the permission system. We intentionally don't depend on Prometheus or OpenTelemetry here:

  1. core/ ships as a library; pulling in Prometheus drags a 10MB+ transitive dependency onto every plugin
  2. operators who DO want Prometheus can wire their backend by reading these counters periodically — the type stays simple
  3. tests can assert against the counters directly without setting up a metric backend

The fields are atomic.Int64 so callers can read them concurrently with Decide loops. Use Snapshot() to grab a coherent set of values at a single instant.

func (*PDPMetrics) Reset added in v0.1.160

func (m *PDPMetrics) Reset()

Reset zeros every counter. ONLY for tests — production metrics should never reset (counter resets are an anti-pattern in monitoring; use rate calculations instead).

func (*PDPMetrics) Snapshot added in v0.1.160

func (m *PDPMetrics) Snapshot() PDPMetricsSnapshot

Snapshot returns the current values atomically. Concurrent calls to RecordDecision during a snapshot are safe — values may shift mid-read but each field is consistent on its own.

type PDPMetricsSnapshot added in v0.1.160

type PDPMetricsSnapshot struct {
	DecisionsTotal        int64
	AllowsTotal           int64
	DeniesTotal           int64
	RequireApprovalsTotal int64
	CacheHitsTotal        int64
	FailClosedTotal       int64
	MeanLatency           time.Duration
}

PDPMetricsSnapshot is an immutable copy of PDPMetrics at one instant. Returned by Snapshot() for stable reads.

type PDPMode added in v0.1.160

type PDPMode string

PDPMode is the env-driven mode selector for the permission system.

Operators set CODEFLY_PDP_MODE before starting codefly host. The chosen mode determines which PDP wrappers stack on the inner (saas-starter-backed) PDP at startup, controlling how decisions surface in production.

**Why an env var rather than config file.** Mode flipping is an ops-time operation (switching enforce on after a shadow burn-in) that should be a single env-var change + restart, not a code release. Same pattern as CODEFLY_DEBUG, OTEL_*, etc.

const (
	// PDPModeOff disables permission enforcement entirely. NO Guard
	// is wired around the toolbox; every CallTool passes through.
	// Used for development, tests, and any deploy where saas-starter
	// isn't available.
	//
	// **Production should NEVER run with mode=off.** Operators must
	// flip to shadow first (decisions logged) before enforce.
	PDPModeOff PDPMode = "off"

	// PDPModeShadow logs every decision via observability but always
	// returns Allow. Use this DURING the M5 rollout: real PDP runs
	// against real traffic, you watch what would-have-been denied,
	// fix policy drift, then flip to enforce. Without shadow, the
	// first deny in production is also the first time anyone sees
	// what enforce mode would block — high incident risk.
	PDPModeShadow PDPMode = "shadow"

	// PDPModeEnforce is the production target. Decisions are honored
	// (deny short-circuits the call). Only flip to enforce after a
	// burn-in period in shadow mode shows zero false-deny against
	// legitimate traffic.
	PDPModeEnforce PDPMode = "enforce"
)

func ResolvePDPMode added in v0.1.160

func ResolvePDPMode() (PDPMode, error)

ResolvePDPMode reads CODEFLY_PDP_MODE from the environment and returns the validated mode. Returns an error if the value is set but unrecognized — silently defaulting on a typo would be a security footgun ("CODEFLY_PDP_MODE=enfocre" silently → off).

Empty/unset env defaults to PDPModeOff with a clear startup log from the host (the host should log the resolved mode loudly so it appears in incident-investigation logs).

type PDPRequest

type PDPRequest struct {
	Toolbox  string         // identity from manifest, e.g. "git"
	Tool     string         // dotted tool name, e.g. "git.status"
	Args     map[string]any // structured arguments (post-decoded)
	Identity map[string]any // caller attribution (agent id, user id, ...)
}

PDPRequest is everything a policy decision point needs to make a call. Mirrors the toolbox CallTool surface so a Rego policy can reason about exactly what's about to happen — toolbox name, tool name, structured arguments, and the calling identity.

Identity is intentionally a free-form map so callers can route whatever attribution context the host has (an agent ID, a user ID, the parent session). The PDP is responsible for interpreting the keys it cares about; unknown keys are ignored.

type PathExpander

type PathExpander interface {
	Expand(s string) (string, error)
}

PathExpander resolves placeholders in path strings: ${WORKSPACE}, ${TMPDIR}, ${HOME}. Implementations are caller-provided so the policy package doesn't need to know how to find the workspace.

type PermissionDeclaration added in v0.1.160

type PermissionDeclaration struct {
	// Action is the canonical dotted name. Examples:
	//   "github.read_pr", "fs.write", "deploy.staging".
	// May contain "*" for wildcards. Empty Action is invalid.
	Action string `yaml:"action" json:"action"`

	// Resource is the typed resource pattern.
	// Examples: "repo:${ORG}/*", "env:staging", "file:/tmp/*".
	// May contain "*" wildcards. Placeholders (${ORG}, ${WORKSPACE})
	// are expanded at install time, not at PDP-call time.
	// Empty Resource means "any resource of any type".
	Resource string `yaml:"resource" json:"resource"`

	// Reason is a human-readable explanation surfaced in the
	// install-time review. The user sees this when granting or
	// rejecting the plugin's permissions; an empty Reason makes
	// the manifest LESS reviewable, so we require it for
	// "required" entries (see PermissionPolicy.Validate).
	Reason string `yaml:"reason" json:"reason"`
}

PermissionDeclaration is one entry in a plugin's permissions manifest block. Mirrors saas-starter's role_permissions row shape, with a `Reason` field that surfaces in the install-time review UI ("this plugin wants Action X to do Y").

Strings are matched as glob patterns at PDP time:

  • "*" matches anything
  • "repo:codefly-dev/*" matches any repo path under that org
  • "github.merge_pr" matches exactly that action

func (PermissionDeclaration) String added in v0.1.160

func (d PermissionDeclaration) String() string

String produces the canonical "action on resource (reason)" representation used in audit logs and review prompts.

type PermissionPolicy added in v0.1.160

type PermissionPolicy struct {
	// Required permissions MUST be granted at install. If any
	// required entry has no matching role grant, the install
	// fails. Plugin code can assume required permissions are
	// available.
	Required []PermissionDeclaration `yaml:"required,omitempty" json:"required,omitempty"`

	// Optional permissions enhance functionality. Plugin code
	// must handle their absence gracefully — a denied optional
	// permission must not crash; it must skip the dependent
	// behavior or fall back.
	Optional []PermissionDeclaration `yaml:"optional,omitempty" json:"optional,omitempty"`

	// RiskLevels maps action name → risk tier. Used by the PDP
	// to decide whether an action with role-grant should also
	// require human approval (M7+ flow). Valid values: "low",
	// "medium", "high", "critical". Missing entries default to
	// "low".
	RiskLevels map[string]string `yaml:"risk_levels,omitempty" json:"risk_levels,omitempty"`
}

PermissionPolicy is the YAML-shaped authority block a plugin manifest declares. Example:

permissions:
  required:
    - action: github.read_pr
      resource: "repo:${ORG}/*"
      reason: "Inspect PRs to decide auto-merge eligibility"
    - action: github.merge_pr
      resource: "repo:${ORG}/*"
      reason: "Auto-merge approved PRs with green CI"
  optional:
    - action: github.deploy_staging
      resource: "env:staging"
      reason: "Trigger staging deploy after merge"
  risk_levels:
    github.merge_pr: medium
    github.force_push: critical

**required vs optional:**

  • Required entries MUST be granted at install. Without them, the plugin can't function — refusing to install fails fast before any tool call.
  • Optional entries CAN be granted, but the plugin advertises graceful degradation when they're not. The plugin must still handle "permission denied" responses for optional actions without crashing.

**risk_levels** annotate actions with a risk tier (low/medium/ high/critical). At PDP time, high-risk actions can require approval (M7 escalation flow) even when the role grants them. Manifest is the source of truth for risk classification — the plugin author knows the impact of their actions best.

func (PermissionPolicy) All added in v0.1.160

All returns Required ∪ Optional in install order. The PDP uses this to compute the ceiling — any action not present in All() is outside the manifest's claimed authority and gets denied.

func (PermissionPolicy) Allows added in v0.1.160

func (p PermissionPolicy) Allows(action, resource string) bool

Allows reports whether the policy DECLARES authority for the given (action, resource). Used by the PDP as the manifest ceiling check — even if a role grants the action, a manifest that doesn't Allow it gets denied.

Matching:

  • Action: exact match OR a declared "*" wildcard OR a glob where the declared pattern ends with "*" (prefix match).
  • Resource: exact match OR declared empty (means "any resource") OR declared "*" OR a glob.

**Note**: this is the manifest-ceiling check, NOT the role-grant check. The PDP also runs role-grant checks against saas-starter; both must pass.

func (PermissionPolicy) IsEmpty added in v0.1.160

func (p PermissionPolicy) IsEmpty() bool

IsEmpty reports whether the policy declares zero permissions. Used by the PDP to decide between "no manifest ceiling" (empty policy → no ceiling check; legacy behavior during M4 rollout) and "explicit empty manifest" (declared no permissions → deny every action). The distinction lives in the wrapping Toolbox resource, not here — this method is just a convenience.

func (PermissionPolicy) RiskLevelOf added in v0.1.160

func (p PermissionPolicy) RiskLevelOf(action string) string

RiskLevelOf returns the risk tier for an action, defaulting to low when the manifest doesn't classify it.

func (*PermissionPolicy) Validate added in v0.1.160

func (p *PermissionPolicy) Validate() error

Validate checks structural invariants. Empty policies are allowed; the wrapping resource (Toolbox) decides what an empty policy means. The validation is on individual entries.

type PermissionsBackend added in v0.1.160

type PermissionsBackend interface {
	// Decide answers "is this principal allowed to perform this
	// action on this resource?" via the backing policy store.
	//
	// Inputs:
	//
	//   - principalID: opaque identifier the principal carries
	//     (saas-starter's principals.id).
	//   - action: dotted name. Mirrors the codefly tool name when
	//     the call is routed from a tool dispatch, but backends
	//     may also see synthetic actions emitted by Authorizer.
	//   - resource: typed identifier ("repo:foo/bar", "env:staging").
	//   - orgID: organization scope. Required when the backend
	//     enforces per-org isolation.
	//   - scope: optional fine-grained scope (saas-starter's
	//     role_assignments.scope column).
	//
	// Outputs:
	//
	//   - allowed: the verdict.
	//   - reason: human-readable when allowed=false. Surfaces to
	//     the model so it can plan around the limitation.
	//   - decisionPath: trace-style explanation
	//     ("role:editor → perm:github.read_pr"). Optional;
	//     backends that can't trace return "".
	//   - err: only for backend faults (network, deserialization).
	//     Policy denies are NOT errors — they're (false, reason, "").
	//     Errors trigger fail-closed at the SaasPDP layer.
	Decide(ctx context.Context, principalID, action, resource, orgID, scope string) (allowed bool, reason, decisionPath string, err error)
}

PermissionsBackend is the THINNEST interface that a saas-starter gRPC client implementation must satisfy. Pulling this out (rather than importing saas-starter's full gen package into core) keeps the dependency graph one-way: core has no compile-time knowledge of saas-starter; saas-starter (or any other backend) wires itself into core via this interface.

**Why a separate interface from Decider/PDP.** Decider speaks "PDPRequest with caveats and delegation_proof"; PermissionsBackend speaks the simpler shape that maps 1:1 to saas-starter's gRPC. SaasPDP is the bridge: it takes a PermissionsBackend and adapts it to the Decider interface, layering caching + observability + metrics on top.

Implementations:

  • core/policy/pdp_saas_grpc.go (next session) — concrete client against saas-starter's PermissionService.Decide RPC
  • testharness/pdp.go (in-memory) — for tests
  • operator-supplied (e.g. Cedar/OPA bridge) — for custom policy

type PermissionsCallbackServer added in v0.1.160

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

PermissionsCallbackServer is the host-side HTTP server that answers Authorize requests from spawned plugins. One server per plugin spawn; the socket path is unique per spawn so concurrent plugins don't share a listener (and can't cross-query each other's PDPs).

**Lifecycle:**

srv, err := policy.NewPermissionsCallbackServer(decider)
defer srv.Close()
// pass srv.SocketPath() into env, spawn plugin

Close removes the socket file and shuts the listener cleanly. If the plugin process is killed, defer cleanup catches it; an orphaned socket on disk only happens on host crash and is removed on next start (NewPermissionsCallbackServer unlinks existing files at the path).

func NewPermissionsCallbackServer added in v0.1.160

func NewPermissionsCallbackServer(decider Decider) (*PermissionsCallbackServer, error)

NewPermissionsCallbackServer creates a server backed by the given Decider. Generates a unique socket path under the OS temp dir; caller passes that path to the plugin via env.

Listener starts immediately on a goroutine. Plugin can dial as soon as the env is set.

Returns the server (with SocketPath, Close) or an error if the listener couldn't be created (typically: temp dir not writable).

func (*PermissionsCallbackServer) Close added in v0.1.160

func (s *PermissionsCallbackServer) Close() error

Close shuts down the server and removes the socket file. Idempotent — safe to defer + call again from a parent cleanup.

func (*PermissionsCallbackServer) SocketPath added in v0.1.160

func (s *PermissionsCallbackServer) SocketPath() string

SocketPath returns the path the listener is bound to. Caller passes this to the plugin via CODEFLY_PERMISSIONS_SOCKET env.

func (*PermissionsCallbackServer) WithPrincipalProvider added in v0.1.160

func (s *PermissionsCallbackServer) WithPrincipalProvider(p func() *Principal) *PermissionsCallbackServer

WithPrincipalProvider sets the trusted-principal-resolver. If provided, the callback uses the resolver's Principal instead of trusting the request's principal_id field — the plugin can claim any principal_id, but the host overrides with the spawn-time binding. This is the standard production wiring.

type PolicyRule

type PolicyRule struct {
	Toolbox string `json:"toolbox,omitempty"` // "" = any
	Tool    string `json:"tool,omitempty"`    // "" = any (matches with or without dotted prefix)
	Allow   bool   `json:"allow"`
	Reason  string `json:"reason,omitempty"` // surfaced when Allow=false; ignored when true
}

PolicyRule matches a tool call. Empty fields are wildcards (treat as "any"). Allow controls the decision when the rule matches.

type Principal added in v0.1.160

type Principal struct {
	// ID is the saas-starter principals.id UUID. Stable across
	// credential rotations; this is what audit logs reference and
	// what role assignments target.
	ID string

	// Kind is the principal's flavor: "human", "service", or "agent".
	// Used for filtering and UI affordances. NEVER branch on Kind for
	// authorization decisions — that's the auth layer's job, and
	// branching here re-introduces the special-casing the unified
	// model exists to avoid.
	Kind string

	// OrgID is the organization the principal belongs to. Cross-org
	// access requires an explicit cross-org grant, never inferred
	// from the principal alone.
	OrgID string

	// AgentID is the publisher/name:version identifier when Kind is
	// "agent"; empty otherwise. Lets the host correlate a Principal
	// back to a specific agent manifest at install time.
	AgentID string

	// DisplayName is human-readable. NEVER use as an identity key
	// (display names change; IDs don't). Surfaced in audit log
	// previews and approval-request UI.
	DisplayName string

	// Token is the verified credential the principal presented. Held
	// here so downstream layers (PDP, audit) can include it in
	// requests without re-extracting from gRPC metadata. Format is
	// opaque to most callers — the PDP knows whether it's JWT,
	// Biscuit, or something else.
	Token string

	// ExpiresAt is when the credential expires. Independent of the
	// principal's lifetime in saas-starter (a service principal can
	// live forever; this token might be 15 minutes old). Code that
	// caches Principals MUST honor this — refusing the cache hit
	// past expiry is what prevents stale-credential authority.
	ExpiresAt time.Time

	// DelegationChain records the principals whose authority is
	// being acted on, oldest-first. Empty for non-delegated calls;
	// length 1+ when authority was lent. The actor is always the
	// last entry (or the Principal itself if the chain is empty).
	//
	// Example: User U invokes Agent A; A acquires escalation from
	// User V to merge a PR. The chain on the resulting tool call is
	// [U, V] — A's own ID is the Principal.ID; the chain shows whose
	// authority is in play. Audit logs the full chain verbatim.
	DelegationChain []DelegationLink
}

Principal is the unified identity type for everything codefly authorizes — humans, services, and agents. It exists at the core layer so the runners, agent loader, and PDP can all speak the same vocabulary without one of them owning the others.

**Why one type for humans + services + agents.** The auth layer shouldn't care whether a row in saas-starter's principals table represents Antoine, the auto-merge bot, or Mind. They all hold permissions via the same role-assignment table. Kind is metadata for the UI ("show me my agents") and audit ("filter to humans"), not a fundamental of the auth model. Treating them uniformly is what lets a service principal request escalation from a human principal using the same delegation primitive that a user uses to invoke an agent.

**What this type is NOT.** It's not the credential. The credential (JWT/Biscuit/...) lives in Token; Principal is the resolved identity claims after the credential has been verified. Code that receives a Principal can trust the fields — verification has already happened upstream.

func DecodePrincipalToken added in v0.1.160

func DecodePrincipalToken(s string) (*Principal, error)

DecodePrincipalToken is the plugin-side inverse of encodePrincipalToken. Exported because the plugin-side interceptor (in agents/agents.go) needs it. Returns an error if the encoding is malformed, the format isn't recognized, or the envelope fails the Principal Validate (e.g. agent without agent_id).

Does NOT verify a signature — see the comment at the top of this file. The SaasPDP (M3) is responsible for re-validating against saas-starter for any decision-making.

func PrincipalFrom added in v0.1.160

func PrincipalFrom(ctx context.Context) *Principal

PrincipalFrom retrieves the Principal stamped on ctx, or nil if none was set. nil is the meaningful zero — callers (PDP, audit) branch on "no principal" as a distinct policy state, not an error.

func (*Principal) AsIdentity added in v0.1.160

func (p *Principal) AsIdentity() map[string]any

AsIdentity converts the Principal to the free-form Identity map that PDPRequest carries. Centralized so the key shape stays consistent across every call site — drift here means the PDP can't reliably look up principal_id and falls back to default-deny.

Keys (stable; do not rename):

  • principal_id, principal_kind, principal_org_id
  • agent_id (only set when Kind=agent)
  • delegation_chain (slice of DelegationLink-as-map)
  • principal_token (the raw credential, for downstream PDP verification of signature + caveats)

Unknown extra keys are tolerated — callers can add their own (e.g. an HTTP request ID) without breaking anything.

func (*Principal) IsExpired added in v0.1.160

func (p *Principal) IsExpired() bool

IsExpired reports whether the credential has expired as of now. Treats zero-time as never-expires (e.g. a permanently-issued service principal credential whose expiry is managed externally).

Callers should check this BEFORE using the Principal for any auth-bearing call. The PDP also checks; this is an early-out for hot paths.

func (*Principal) IsExpiredAt added in v0.1.160

func (p *Principal) IsExpiredAt(now time.Time) bool

IsExpiredAt is IsExpired against an explicit clock — used by tests to avoid time.Now flakiness and by future cache implementations that already hold a clock.

func (*Principal) Validate added in v0.1.160

func (p *Principal) Validate() error

Validate asserts the structural minimum every Principal must satisfy. Does NOT verify the token signature — that's the credential verifier's job, run before Principal is constructed. This is the "did upstream verification fill in the right shape" check.

Returns wrapped ErrPrincipalInvalid; the wrapped message identifies the specific field at fault for log / error surface.

type RateLimitSpec added in v0.1.160

type RateLimitSpec struct {
	PerMinute int    `yaml:"per_minute" json:"per_minute"`
	Scope     string `yaml:"scope,omitempty" json:"scope,omitempty"` // "principal" (default) | "global" | "principal_org"
}

RateLimitSpec configures rate_limit. PerMinute is the maximum number of mint operations per (principal_id, action) tuple in the most recent 60-second window.

**Mint-only.** rate_limit decides at the gateway and emits no caveat into the token. The plugin doesn't know about rate limits — by design, that's a gateway concern. Once the token is minted, rate-limit was already accepted; using it within the (short) TTL is fine.

type ReplayTracker added in v0.1.160

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

ReplayTracker records token consumption to enforce MaxUses. Plugin holds one of these per Guard. Tracker doesn't survive process restart — that's acceptable: tokens expire in seconds, so a restart-induced replay window is bounded by the token's own expiry.

Implementation: in-memory map keyed by token ID. Entries are pruned lazily — we don't run a background sweeper because tokens self-expire within seconds.

func NewReplayTracker added in v0.1.160

func NewReplayTracker() *ReplayTracker

NewReplayTracker constructs an empty tracker.

func (*ReplayTracker) Consume added in v0.1.160

func (r *ReplayTracker) Consume(sa *ScopedAuthorization) error

Consume reserves one use of the token. Returns ErrScopedAuthExhausted if max_uses has been consumed already. Lazily prunes expired entries on each call.

func (*ReplayTracker) Size added in v0.1.160

func (r *ReplayTracker) Size() int

Size returns the number of tracked tokens. Used by tests.

type ResolvedToolPolicy added in v0.1.160

type ResolvedToolPolicy struct {
	// TTL is how long the minted token should live. Zero =
	// use GatewayEvaluator.DefaultTTL.
	TTL time.Duration

	// MaxUses caps token reuse. Zero = single-shot (1).
	MaxUses int

	// Caveats produced by the policy. CaveatProducer functions
	// run at allow time to compute caveat values from the call
	// context (e.g. snapshot the ci_status into the caveat).
	CaveatProducers map[string]CaveatProducer
}

ResolvedToolPolicy is what a ToolPolicy returns on allow. It configures the token mint — TTL, MaxUses, plus any caveats the policy wants baked into the token for plugin-side verification.

func (ResolvedToolPolicy) ResolvedCaveats added in v0.1.160

func (r ResolvedToolPolicy) ResolvedCaveats(ctxData map[string]any) map[string]any

ResolvedCaveats runs every producer and collects the results. Errors from a producer are NOT swallowed — they propagate up as deny via the gateway pipeline.

Returns nil when there are no producers (avoids an empty map in the token).

type SaasPDP added in v0.1.160

type SaasPDP struct {
	// Backend is the saas-starter (or test) implementation that
	// answers Decide. Required.
	Backend PermissionsBackend

	// CacheTTL is how long a positive decision stays valid in the
	// local LRU. Zero disables the cache entirely (every call
	// hits the backend). Recommended: 5-30 seconds for hot paths.
	// Longer TTLs trade staleness for latency.
	CacheTTL time.Duration

	// Metrics, when non-nil, gets every decision recorded via
	// RecordDecision. Wire to Prometheus/OTEL via Snapshot().
	Metrics *PDPMetrics

	// FailClosed controls behavior when Backend returns err. Default
	// (zero value) is fail-closed: backend faults deny. Setting to
	// false would fail-open — STRONGLY discouraged for production.
	// Kept as a field (not a constant) so tests can exercise the
	// fail-open path explicitly.
	//
	// **For production:** leave default. The whole architecture
	// rests on "saas-starter unreachable → calls deny". Without
	// this, a network blip becomes a permission bypass.
	FailClosed bool
	// contains filtered or unexported fields
}

SaasPDP is the production Decider for hosts that authenticate against saas-starter (or any other backend exposing a PermissionsBackend). It adapts the simpler backend shape into the full PDP/Decider interface, layering on:

  • Local cache of positive decisions (TTL, configurable)
  • NEVER cache denies (a freshly-revoked grant must fail immediately on the next call)
  • Observability: every decision goes through RecordDecision so dashboards reflect real production traffic
  • Fail-closed: backend errors surface as deny+FailClosed, NEVER as silent allow
  • Latency tracking: PDPMetrics gets the wall-time per call

**Why this lives in core, not in saas-starter.** core has no compile-time dependency on saas-starter — SaasPDP takes a PermissionsBackend interface that saas-starter (or any other implementation) wires concretely. The dependency arrow is always saas-starter → core, never the reverse.

**Cache invalidation.** TTL only, no broadcast invalidation in v1. Operators tune Cache TTL per their tolerance for stale allows. M10 hardening adds Postgres NOTIFY-driven invalidation for permission changes.

func NewSaasPDP added in v0.1.160

func NewSaasPDP(backend PermissionsBackend) *SaasPDP

NewSaasPDP constructs a SaasPDP with the standard production defaults: fail-closed, no metrics (caller wires later), no cache (caller sets explicitly to opt in).

Use this constructor rather than &SaasPDP{} directly — it's the place future defaults will land without breaking callers.

func (*SaasPDP) Evaluate added in v0.1.160

func (s *SaasPDP) Evaluate(ctx context.Context, req *PDPRequest) PDPDecision

Evaluate implements PDP. Pulls principal+resource from req, consults cache, calls Backend on miss, records observability, returns the verdict.

func (*SaasPDP) WithCache added in v0.1.160

func (s *SaasPDP) WithCache(ttl time.Duration) *SaasPDP

WithCache enables the LRU cache with the given TTL. Returns the receiver for fluent configuration.

func (*SaasPDP) WithMetrics added in v0.1.160

func (s *SaasPDP) WithMetrics(m *PDPMetrics) *SaasPDP

WithMetrics attaches a PDPMetrics for observability. Returns the receiver for fluent configuration.

type SandboxPolicy

type SandboxPolicy struct {
	ReadPaths   []string      `yaml:"read_paths,omitempty" json:"read_paths,omitempty"`
	WritePaths  []string      `yaml:"write_paths,omitempty" json:"write_paths,omitempty"`
	Network     NetworkPolicy `yaml:"network,omitempty" json:"network,omitempty"`
	UnixSockets []string      `yaml:"unix_sockets,omitempty" json:"unix_sockets,omitempty"`
}

SandboxPolicy is the YAML-shaped permission block a plugin manifest declares. Example:

sandbox:
  read_paths:
    - "${WORKSPACE}"
  write_paths:
    - "${WORKSPACE}"
    - "${TMPDIR}"
  network: deny
  unix_sockets:
    - "/var/run/docker.sock"  # if this plugin needs docker access

Path strings may use ${WORKSPACE}, ${TMPDIR}, ${HOME} placeholders that are expanded at Apply time. Absolute paths pass through.

func (*SandboxPolicy) Apply

func (p *SandboxPolicy) Apply(sb sandbox.Sandbox, expand PathExpander) error

Apply translates a SandboxPolicy into a configured sandbox.Sandbox. The resulting sandbox is ready to Wrap() commands that should run under the policy.

**Mutation contract.** Apply MUTATES sb in-place — it calls the fluent With* setters on the passed-in sandbox. Callers who Apply a policy and then call sb.WithNetwork(NetworkOpen) afterward will override the manifest's intent silently. Recommended pattern:

sb, _ := sandbox.New()
policy.Apply(&pol, sb, expand)   // configure
cmd := exec.Command("...")
sb.Wrap(cmd)                     // use; no further With* calls

Don't share a sandbox across goroutines after Apply unless every goroutine treats it as immutable.

expand is called on every path entry; it should fail loudly when a referenced placeholder is unset rather than silently substituting "" (which would create an unintended catch-all subpath rule).

Paths are expanded then handed to the sandbox in single batched calls per category — the underlying With* methods are variadic and storing many slices' worth of one-element appends is wasteful.

func (*SandboxPolicy) Validate

func (p *SandboxPolicy) Validate() error

Validate checks the policy is internally consistent. Empty policies are allowed (zero-trust default applied at Apply time).

type ScopedAuthorization added in v0.1.160

type ScopedAuthorization struct {
	// ID is a unique identifier for THIS specific mint. ULID-
	// shaped (time-ordered, base32). Used for audit correlation
	// and replay tracking.
	ID string `json:"id"`

	// Format is the envelope format tag. v1-hmac is the only
	// recognized value today; future formats (e.g. v2-biscuit
	// for M6) coexist via dispatch on this field.
	Format string `json:"fmt"`

	// Principal claims — match what's stamped on the request ctx.
	PrincipalID    string `json:"principal_id"`
	PrincipalKind  string `json:"principal_kind,omitempty"`
	PrincipalOrgID string `json:"principal_org_id,omitempty"`

	// Action+Resource scope. The verifier matches these against
	// the actual call; mismatch → reject.
	Action   string `json:"action"`
	Resource string `json:"resource,omitempty"`

	// IssuedAtUnix / ExpiresAtUnix bound the time window. Both
	// are unix seconds. ExpiresAt is an absolute deadline; clock
	// skew tolerance is applied at verify time.
	IssuedAtUnix  int64 `json:"iat"`
	ExpiresAtUnix int64 `json:"exp"`

	// MaxUses caps reuse. Most actions are single-shot (1).
	// Bulk operations may opt up. Verifier tracks consumption
	// per-token-id; max_uses=0 is treated as 1 for safety.
	MaxUses int `json:"max_uses,omitempty"`

	// AudienceID is the canonical identifier of the plugin that
	// may verify this token. "" means "any audience" — used in
	// tests; production tokens always set this.
	AudienceID string `json:"audience,omitempty"`

	// Caveats carry per-tool conditions the verifier checks. The
	// keys are well-known names (ci_status, labels, body_hash,
	// ip_range, ...) plus operator-defined extensions. The
	// verifier consults a registered caveat-checker per key;
	// unknown caveats fail closed (refuse to verify).
	Caveats map[string]any `json:"caveats,omitempty"`
}

ScopedAuthorization is a short-lived, signed bearer token that proves "this principal has been authorized for this action on this resource by the gateway, valid for this time window".

See TWO_LEVEL_AUTHZ.md for the full design rationale. Quick recap of the security properties:

  • Time-bounded: tokens have an explicit ExpiresAt; expired tokens fail verification.
  • Use-bounded: MaxUses caps reuse; the verifier tracks consumption per-token-id in an in-memory LRU.
  • Audience-bound: AudienceID names the plugin that may verify; tokens minted for plugin A fail at plugin B.
  • Action+resource-scoped: the verifier matches the token's declared (action, resource) against the request — leaked tokens grant minimal authority.
  • Per-spawn signing key: the HMAC secret is generated by manager.Load per plugin spawn; secret leak only forges tokens for one plugin's lifetime.

**Wire format.** `<canonical-json>.<base64url-hmac>` — same shape as JWT/HS256 for debuggability (`cut -d. -f1 | base64 -d` shows the envelope as JSON). Distinct format tag (`fmt:v1-hmac`) so future formats (Biscuit, M6) coexist via tag dispatch.

**What this is NOT:**

  • Not a JWT. We could have used JWT/HS256 verbatim, but the simpler envelope keeps the format tag explicit (we'll need to migrate to Biscuit) and avoids dragging a JWT library.
  • Not encryption. Tokens carry no secrets — they're verified- authorization claims that the verifier matches against the request. Tampering is detected via HMAC; the contents are intentionally readable for debugging.

func Mint added in v0.1.160

func Mint(input MintInput, secret []byte) (string, *ScopedAuthorization, error)

Mint produces a signed token from input + secret. The secret MUST be at least 32 bytes; shorter secrets reduce HMAC strength and are rejected.

Caller-supplied NowFunc / IDFunc are escape hatches for tests; production callers leave them nil and get time.Now / NewULID.

Returns the encoded token (`<json>.<hmac>`) ready to attach as gRPC metadata.

func MintEd25519 added in v0.1.160

func MintEd25519(input MintInput, privateKey ed25519.PrivateKey) (string, *ScopedAuthorization, error)

MintEd25519 produces a v2 token signed with the supplied ed25519 private key. The verifier needs the matching public key (extractable from the private key as `priv.Public()`).

Same input semantics as Mint (v1): Principal must be valid, Action must be non-empty, TTL must be > 0. MaxUses defaults to 1.

**Signing key length.** ed25519.PrivateKey is 64 bytes (32 seed + 32 public). Generate via:

pub, priv, err := ed25519.GenerateKey(crypto/rand.Reader)

Caller stores priv on the gateway and distributes pub to plugins (env var, JWKS endpoint, or static config).

func ScopedAuthFrom added in v0.1.160

func ScopedAuthFrom(ctx context.Context) *ScopedAuthorization

ScopedAuthFrom returns the ScopedAuthorization stamped on ctx, or nil if none. Plugin handlers can read this for audit logging — show the token id alongside other request context.

Handlers SHOULD NOT make authorization decisions based on the presence of a token; the Guard already gated the call.

func Verify added in v0.1.160

func Verify(token string, expect VerifyExpectations, secret []byte) (*ScopedAuthorization, error)

Verify decodes + validates the token against expectations and the secret. Returns the decoded ScopedAuthorization on success.

Failure modes (all wrap ErrScopedAuthInvalid):

  • decode fails (bad base64, bad JSON, missing parts)
  • format tag unrecognized (forward-compat: future versions will dispatch on Format and call a different verifier)
  • HMAC mismatch (tampered or wrong secret)
  • expired (now > ExpiresAt + skew)
  • audience/action/resource/principal mismatch
  • unknown caveat key
  • registered caveat verifier rejected

func VerifyEd25519 added in v0.1.160

func VerifyEd25519(token string, expect VerifyExpectations, publicKey ed25519.PublicKey) (*ScopedAuthorization, error)

VerifyEd25519 decodes + verifies a v2 token using the supplied ed25519 public key. Token format mismatch (e.g. v1-hmac passed here) returns an error — use TokenVerifier for dual-format dispatch.

All other expectations behave identically to Verify (v1): time bounds, audience/action/resource/principal matching, caveat verification.

type ShadowPDP added in v0.1.160

type ShadowPDP struct {
	// Inner is the real PDP whose decisions are recorded but
	// ignored. Required.
	Inner PDP

	// Metrics, if non-nil, gets the standard counters bumped per
	// the inner PDP's decision. Lets operators graph "shadow-mode
	// would-deny rate" alongside production allow rate.
	Metrics *PDPMetrics
}

ShadowPDP wraps another PDP and logs its decisions, but ALWAYS returns Allow. Used during M5 production rollout: operators flip `CODEFLY_PDP_MODE=shadow` before flipping to enforce, watch the audit logs for false-deny patterns, and fix policy drift before any developer actually gets locked out of their own tools.

**Design contract:**

  • Inner.Evaluate is called for every request — produces the decision the operator WOULD see in enforce mode.
  • Decision is logged via RecordDecision (Counter + structured wool entry) so dashboards show "what would have been denied" without affecting plugin behavior.
  • Returned decision is always Allow with a reason that makes the shadow nature obvious in any error surface that might accidentally surface it. (It shouldn't — shadow always allows — but defense at the boundary is cheap.)

**What this is NOT:**

  • Not a fail-closed wrapper. ShadowPDP can't fail closed by design. If the inner PDP errors, ShadowPDP still allows. The error is logged; no enforcement.
  • Not for production enforce mode. Production must use the bare PDP (not Shadow). Operators graduate from Shadow → bare PDP after burn-in.

Usage:

innerPDP := NewSaasPDP(...)
pdp := ShadowPDP{Inner: innerPDP, Metrics: &metrics}
// Plug pdp into PluginRegistration.PDP — every tool call now
// surfaces "what would have happened" in observability.

func NewShadowPDP added in v0.1.160

func NewShadowPDP(inner PDP, metrics *PDPMetrics) ShadowPDP

NewShadowPDP constructs a ShadowPDP with required Inner. Panics if inner is nil — startup-time check, never silently no-op.

func (ShadowPDP) Evaluate added in v0.1.160

func (s ShadowPDP) Evaluate(ctx context.Context, req *PDPRequest) PDPDecision

Evaluate runs Inner, records the decision, and returns Allow.

type TimeWindowSpec added in v0.1.160

type TimeWindowSpec struct {
	StartHour  int      `yaml:"start_hour" json:"start_hour"`
	EndHour    int      `yaml:"end_hour" json:"end_hour"`
	Timezone   string   `yaml:"timezone,omitempty" json:"timezone,omitempty"` // IANA name; default UTC
	DaysOfWeek []string `yaml:"days_of_week,omitempty" json:"days_of_week,omitempty"`
}

TimeWindowSpec configures the time_window caveat. Hours are 0-23 in the configured timezone; DaysOfWeek is a list of strings ("mon", "tue", ...) — empty means "any day".

Both the producer (gateway) and verifier (plugin) check current time against the window. The token carries a snapshot of the SPEC (not the current time), so verifier re-checks against its own clock. This catches drift: even if the gateway's clock was off, the plugin's clock check has the operator-authored window.

type TokenRevocationList added in v0.1.160

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

TokenRevocationList holds a set of token IDs that must be rejected even with valid signatures. Used to invalidate compromised or otherwise-suspect tokens before they expire.

**Where this fits.** Verify (v1 + v2) accepts tokens based on signature + claims. The TRL is consulted INSIDE the Guard alongside the replay tracker — both check token id; replay rejects "seen before for this token", TRL rejects "this token is revoked, period".

**Distribution.** Operators populate TRL in two ways:

  1. Programmatic: call Add(tokenID) when receiving a revocation event (e.g. from saas-starter via Postgres NOTIFY or webhook).

  2. File-backed: TrackFile(path) watches a JSON file containing the revoked-id list. Reload on file change. Useful for kubectl-style ConfigMap distribution.

**Why not "always check saas-starter on every call".** It'd be trivially correct, but the M3-era PDP cache exists exactly to avoid that round-trip on every request. TRL is the invalidation channel for the cache: revocations push, the cache reads from local TRL.

func NewTokenRevocationList added in v0.1.160

func NewTokenRevocationList() *TokenRevocationList

NewTokenRevocationList returns an empty TRL.

func (*TokenRevocationList) Add added in v0.1.160

func (t *TokenRevocationList) Add(tokenID string)

Add marks tokenID as revoked. Idempotent: re-adding the same id is a no-op.

func (*TokenRevocationList) AddMany added in v0.1.160

func (t *TokenRevocationList) AddMany(tokenIDs []string)

AddMany is the bulk variant. Used by file-backed TRL reload.

func (*TokenRevocationList) IsRevoked added in v0.1.160

func (t *TokenRevocationList) IsRevoked(tokenID string) bool

IsRevoked reports whether tokenID is on the list. The Guard calls this after a successful signature+claims verify and rejects the token if true.

func (*TokenRevocationList) Remove added in v0.1.160

func (t *TokenRevocationList) Remove(tokenID string)

Remove unrevokes a token id. Useful for tests; rare in production (revocations are typically one-way).

func (*TokenRevocationList) Replace added in v0.1.160

func (t *TokenRevocationList) Replace(tokenIDs []string)

Replace atomically swaps the entire revocation set. Used by file-backed reload to avoid intermediate states where the TRL is partially updated.

func (*TokenRevocationList) Size added in v0.1.160

func (t *TokenRevocationList) Size() int

Size returns the count of revoked tokens. Used in tests + observability.

type TokenVerifier added in v0.1.160

type TokenVerifier struct {
	// HMACSecret accepts v1-hmac tokens. Empty disables v1.
	HMACSecret []byte

	// PublicKeys are the ed25519 public keys that accept
	// v2-ed25519 tokens. Multi-key for rotation; first match
	// wins. Empty disables v2.
	PublicKeys []ed25519.PublicKey
}

TokenVerifier verifies scoped-auth tokens, dispatching on the envelope's `fmt:` tag. Holds the keys for each format it accepts. Zero value rejects every token (no keys configured) — callers must explicitly opt in via WithHMACSecret / WithEd25519PublicKey.

func NewTokenVerifier added in v0.1.160

func NewTokenVerifier() *TokenVerifier

NewTokenVerifier returns a verifier with no keys. Callers chain WithHMACSecret / WithEd25519PublicKey.

func (*TokenVerifier) Verify added in v0.1.160

func (v *TokenVerifier) Verify(token string, expect VerifyExpectations) (*ScopedAuthorization, error)

Verify dispatches on the envelope's fmt tag. Returns an error if the token's format isn't supported by any configured key.

func (*TokenVerifier) WithEd25519PublicKey added in v0.1.160

func (v *TokenVerifier) WithEd25519PublicKey(key ed25519.PublicKey) *TokenVerifier

WithEd25519PublicKey enables v2-ed25519 verification with the given public key. Calling multiple times accumulates keys for rotation.

func (*TokenVerifier) WithHMACSecret added in v0.1.160

func (v *TokenVerifier) WithHMACSecret(secret []byte) *TokenVerifier

WithHMACSecret enables v1-hmac verification.

type ToolPolicy added in v0.1.160

type ToolPolicy interface {
	// Evaluate runs the policy against the call input. Returns
	// a ResolvedToolPolicy on allow (carrying TTL, MaxUses,
	// caveats to bake into the token) or an error on deny.
	//
	// **Errors are denies, not faults.** A tool policy that
	// returns err means "this call is not allowed by this
	// policy"; the GatewayEvaluator wraps the err with
	// ErrGatewayDeny.
	Evaluate(ctx context.Context, in EvaluationInput) (ResolvedToolPolicy, error)
}

ToolPolicy is the gateway-evaluated rule set per tool. Built-in implementations cover the common cases (manifest declaration check, simple allow-rule); operators implement custom logic for richer policies (Cedar, Rego, business-specific).

**Why an interface rather than a concrete type.** Operators have genuinely different needs — some want declarative YAML, some want Cedar, some want code. The interface hides those choices from GatewayEvaluator; only Evaluate matters for the pipeline.

type VerifyExpectations added in v0.1.160

type VerifyExpectations struct {
	// Action: the actual call's action. Token must declare
	// the SAME action; mismatch → reject.
	Action string

	// Resource: the actual call's resource. Token's Resource
	// must match exactly OR be empty (token-says-any-resource).
	// Empty Expectations.Resource skips the check.
	Resource string

	// Audience: the verifying plugin's identity. Token's
	// AudienceID must match OR be empty (test tokens). Empty
	// Expectations.Audience skips the check (test fixture).
	Audience string

	// PrincipalID: the principal stamped on the request ctx.
	// Token's PrincipalID must match. Empty skips check.
	PrincipalID string

	// Now: the clock the verifier uses for expiry. Empty →
	// time.Now. Tests pass an explicit clock.
	Now time.Time

	// CaveatVerifiers, when non-empty, are consulted for each
	// caveat key in the token. A caveat key not in this map
	// causes verification to FAIL (unknown caveats are denied
	// — security default; an attacker could try to encode a
	// caveat the verifier doesn't understand).
	CaveatVerifiers map[string]CaveatVerifier
}

VerifyExpectations is what the verifier checks the token's claims against. Empty fields skip the corresponding check.

**The verifier is the contract enforcer.** A token's claims are just JSON until the verifier matches them against runtime reality. Skipping a check (leaving an Expect* field empty) is a security choice the caller makes; document yours.

type YAMLToolPolicies added in v0.1.160

type YAMLToolPolicies struct {
	Tools []YAMLToolPolicy `yaml:"tools"`
}

YAMLToolPolicies is the top-level YAML structure.

func (YAMLToolPolicies) Build added in v0.1.160

func (y YAMLToolPolicies) Build() (map[string]ToolPolicy, error)

Build is ParseYAMLToolPolicies's second half — turns a parsed YAMLToolPolicies into ToolPolicy implementations. Exported so callers who already have a YAMLToolPolicies in memory (e.g. from a config-merge pipeline) can build without re-parsing.

type YAMLToolPolicy added in v0.1.160

type YAMLToolPolicy struct {
	// ID is the tool key: "<toolbox>:<tool>" or "<toolbox>:*".
	// Examples:
	//   "codefly.dev/github-bot:0.1.0:github.merge_pr"
	//   "codefly.dev/github-bot:0.1.0:*"
	ID string `yaml:"id"`

	// Allow / Deny — exactly one must be true.
	Allow bool `yaml:"allow,omitempty"`
	Deny  bool `yaml:"deny,omitempty"`

	// Reason — required for Deny, optional for Allow.
	Reason string `yaml:"reason,omitempty"`

	// TTL is the token lifetime. Parsed via time.ParseDuration.
	// Examples: "60s", "5m", "1h". Zero/empty uses
	// GatewayEvaluator.DefaultTTL.
	TTL string `yaml:"ttl,omitempty"`

	// MaxUses caps token reuse. Zero/missing → 1 (single-shot).
	MaxUses int `yaml:"max_uses,omitempty"`

	// Caveats maps caveat name → spec. Each name must be
	// registered (built-ins or operator-supplied via
	// RegisterCaveat). Unknown names fail at parse.
	Caveats map[string]CaveatSpec `yaml:"caveats,omitempty"`
}

YAMLToolPolicy is one policy entry. Either Allow or Deny must be true; both true is rejected at parse.

Directories

Path Synopsis
Package testharness provides in-process test scaffolding for the permission system.
Package testharness provides in-process test scaffolding for the permission system.

Jump to

Keyboard shortcuts

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