Skip to content

policy/v1 — Operator Consent + Data-Policy Gate

Status: locked, v1.0

Lock date: 2026-05-17

Parent epic: blackrim-vox-8ys — Local-first / air-gap mode

Why this exists

Blackrim Vox's open core supports a wide range of sinks: local-file, BYOK LLM, S3-compatible storage, email, bd, the SageOx ledger, and future subscription-passthrough LLMs. Some of those sinks make network calls that carry transcripts off the host. Today the open core has no uniform mechanism to gate those calls — each sink encodes its own ad-hoc "is the API key set?" check, and there is no operator-facing answer to the question "what happens if I want this machine to never send transcripts off the host?".

policy/v1 is that answer. It defines:

  1. A declarative manifest (PolicyManifest) that names each sink, says whether the operator has consented to it making network calls, and is loaded at startup.
  2. A runtime gate (Policy.Allow) that the orchestrator consults before opening any sink whose Capabilities.Network = true.
  3. A consent prompt that fires once per (sink, identity) pair the first time the operator wants to enable a networked sink, naming the destination explicitly.
  4. An audit event stream that records every consent decision via the open audit/v1 hook (operator-local by default; routable to the enterprise audit sink when present).

The gate is orthogonal to sink/v1 — it adds an opt-in capability flag on the sink and a check at orchestrator-Open time. Sinks don't need to change their interface; they just declare Network: true in Capabilities() and the gate handles the rest.

Surface inventory entry

Field Value
Surface policy/v1
Status Design-locked (this doc)
Used by Every sink that may emit network IO (llm-anthropic, llm-bundled, s3, email-*, ox-ledger, future passthroughs)
Implementation lives in internal/policy/ (impl tracked as bd lgm)
Operator surface vox policy … subcommand (impl tracked as bd 5f2)
Versioning Independent of binary; v1 stable; breaking changes ship as v2 alongside v1

Concepts

Policy

The gate. Single process-wide instance, owned by the orchestrator, loaded at startup from a PolicySource. Every networked sink's Open call routes through Policy.Allow.

PolicyManifest

The declarative config the operator authors (or accepts via prompt). JSON format; programmatic API supported for embedding cases.

// ~/.vox/policy.json
// mode is required: "air-gap" | "permissive" | "selective"
{
  "mode": "selective",
  "sinks": {
    "llm-anthropic": {
      "decision": "allow",
      "consent_at": "2026-05-17T15:32:11Z",
      "consent_method": "interactive"
    },
    "llm-bundled": { "decision": "deny" },
    "s3":          { "decision": "prompt" }
  }
}

NetworkCapability

A flag on sink.Capabilities declared by sinks that may emit non-loopback IO. Non-breaking extension of sink/v1 — additive field on the existing struct.

PolicyMode

Three modes:

  • air-gap — every sink with Network: true is denied. No prompts. Hard block.
  • permissive — every networked sink is allowed without prompt. Useful for headless / CI / trusted-machine cases where the operator has decided once globally.
  • selective — per-sink decisions. First attempt to open a networked sink whose decision is prompt fires the consent prompt; subsequent runs read the persisted decision.

The mode in ~/.vox/policy.json always takes precedence. When no file exists, the gate falls back to the per-edition default:

Edition Default mode
open (default build) air-gap — fail closed; no network without an explicit manifest
enterprise (-tags enterprise) selective — consent prompt on first use of each networked sink

Run vox policy show to see the active edition and current mode.

ConsentPrompt

The runtime UX that fires under selective mode when a sink has decision=prompt. Names the destination explicitly. Four operator responses:

  • y / allow once — allow this run; decision stays prompt
  • n / deny once — deny this run; decision stays prompt
  • always — persist decision: allow
  • never — persist decision: deny

AuditEvent

Every Policy.Allow call emits an audit event regardless of verdict. Schema: {timestamp, sink_id, sink_provider, decision, mode, reason, consent_method?}. Audit events route through audit/v1 (open hook); default sink is audit-local (operator-local JSONL at ~/.vox/audit.jsonl, mode 0600).

Interface

package policy

import (
    "context"
    "time"

    "github.com/Blackrim-Vox/blackrim-vox/internal/audit"
)

// Policy is the runtime gate.
type Policy interface {
    Name() string

    // Lifecycle
    Load(ctx context.Context, src PolicySource) error
    Reload(ctx context.Context) error
    Close(ctx context.Context) error

    // State
    Mode() PolicyMode

    // Hot path — orchestrator calls before opening a networked sink
    Allow(ctx context.Context, decision PolicyDecision) (Verdict, error)

    // Async audit stream
    Audit() <-chan audit.Event

    // Diagnostics
    Stats() Stats
    Health(ctx context.Context) Health
}

// PolicySource describes where the manifest is loaded from.
type PolicySource interface {
    Read(ctx context.Context) (*PolicyManifest, error)
    Path() string  // for diagnostics; "" if programmatic
}

// PolicyDecision is what a caller asks the gate to evaluate.
type PolicyDecision struct {
    SinkID        string                 // identifier in the manifest
    SinkProvider  string                 // e.g. "anthropic", "openai"
    NetworkScope  NetworkScope           // declared by the sink
    Reason        string                 // human-readable; routed to audit
    Interactive   bool                   // false = headless context (CI, daemon)
    Extra         map[string]string
}

// Verdict is what the gate returns.
type Verdict struct {
    Allowed     bool
    Reason      DenialReason
    ConsentURL  string                   // unused in v1; reserved for v2 remote-consent flows
    AuditID     string                   // correlation handle into the audit stream
}

// PolicyMode — declared in the manifest top-level.
type PolicyMode string

const (
    ModeAirGap      PolicyMode = "air-gap"
    ModePermissive  PolicyMode = "permissive"
    ModeSelective   PolicyMode = "selective"
)

// NetworkScope describes what the sink actually does over the network.
// Sinks declare this in their Capabilities so the operator can read the
// manifest and understand the blast radius.
type NetworkScope struct {
    Destinations   []string  // canonical hostnames e.g. ["api.anthropic.com"]
    Direction      string    // "egress" | "ingress" | "bidirectional"
    Payload        string    // "transcripts" | "audio" | "metadata" | "control"
    DataMinimized  bool      // true if sink declares it strips PII / quantizes / etc.
}

// DenialReason classifies a deny verdict.
type DenialReason string

const (
    DenialAirGap          DenialReason = "air-gap-mode"
    DenialExplicitDeny    DenialReason = "explicit-deny"
    DenialConsentDeclined DenialReason = "consent-declined"
    DenialHeadlessPrompt  DenialReason = "headless-prompt-required"
    DenialUnknownSink     DenialReason = "unknown-sink-in-manifest"
)

// Stats — running counters.
type Stats struct {
    AllowCalls         uint64
    AllowedDecisions   uint64
    DeniedDecisions    uint64
    PromptsTriggered   uint64
    PromptsDeclined    uint64
    ReloadCount        uint64
    LastReloadAt       time.Time
}

type Health struct {
    State  HealthState
    Reason string
}

type HealthState string

const (
    HealthOK         HealthState = "ok"
    HealthDegraded   HealthState = "degraded"  // manifest readable but stale
    HealthUnhealthy  HealthState = "unhealthy" // manifest unreadable; defaulting to air-gap
)

Error model

type PolicyErrorKind string

const (
    ErrManifestNotFound   PolicyErrorKind = "manifest-not-found"
    ErrManifestParse      PolicyErrorKind = "manifest-parse"
    ErrUnknownMode        PolicyErrorKind = "unknown-mode"
    ErrUnknownSink        PolicyErrorKind = "unknown-sink"
    ErrInteractiveRequired PolicyErrorKind = "interactive-required"
    ErrConsentDeclined    PolicyErrorKind = "consent-declined"
    ErrAuditUnavailable   PolicyErrorKind = "audit-unavailable"
    ErrInternal           PolicyErrorKind = "internal"
)

type PolicyError struct {
    Kind    PolicyErrorKind
    SinkID  string
    Inner   error
    Retry   bool
}

Policy.Allow returns Verdict for normal decisions (including Allowed=false) and an error only for hard failures (manifest unreadable, audit unavailable). The distinction matters: a denied verdict is a successful gate decision; an error is an infrastructure failure.

Per-sink capability declaration

sink.Capabilities gains one additive field:

type Capabilities struct {
    // ... existing fields ...

    // Network declares what this sink does over the network.
    // Empty NetworkScope (zero value) means the sink is local-only —
    // the policy gate skips it.
    Network policy.NetworkScope
}

Sinks that declare a non-empty Network are subject to the gate. Sinks that declare nothing (or a zero-value NetworkScope) are local-only and pass through unconditionally. This is non-breaking — every existing sink that doesn't declare Network continues to work.

Example sink declaration:

func (s *AnthropicSink) Capabilities() sink.Capabilities {
    return sink.Capabilities{
        Name: "llm-anthropic",
        Network: policy.NetworkScope{
            Destinations:  []string{"api.anthropic.com"},
            Direction:     "egress",
            Payload:       "transcripts",
            DataMinimized: false,
        },
        // ...
    }
}

Operator UX

vox policy … subcommand (impl: bd 5f2)

vox policy status               # print current mode + per-sink decisions + manifest path
vox policy set <mode>           # air-gap | permissive | selective
vox policy allow <sink>         # whitelist a sink (selective mode)
vox policy deny <sink>          # blacklist a sink
vox policy revoke-consent <sink>  # reset to prompt on next run
vox policy load <path>          # load a specific manifest
vox policy export               # print the current manifest as YAML
vox policy reset                # revert to defaults (air-gap, no consents)

Global flags on every subcommand

  • --policy <path> — load this manifest for the run instead of ~/.vox/policy.json
  • --air-gap — shorthand for --policy=<built-in air-gap manifest>; overrides any manifest
  • --policy-interactive — force interactive prompt behavior even when stdout is not a TTY (default: prompts only fire when stdout is a TTY and stdin is interactive)
  • --policy-non-interactive — refuse to fire prompts; sinks with decision: prompt return DenialHeadlessPrompt

When a sink with decision: prompt is opened under selective mode in an interactive context, the operator sees:

vox: about to open a networked sink:

  Sink:        llm-anthropic
  Destination: api.anthropic.com
  Payload:     transcripts (egress)

  This will route your spoken transcripts to Anthropic for LLM
  processing. The transcripts leave your machine.

  Allow? [y / n / always / never]
  y       allow this run, ask again next time
  n       deny this run, ask again next time
  always  persist allow (manifest update)
  never   persist deny (manifest update)

> _

On a non-TTY stdout or with --policy-non-interactive, the sink fails fast with DenialHeadlessPrompt; the operator is expected to update the manifest out-of-band.

PolicyManifest JSON schema

// ~/.vox/policy.json
// "mode" is required. One of: "air-gap" | "permissive" | "selective".
// The file is written and read as JSON (not YAML); no yaml dependency is used.
{
  "mode": "selective",

  "sinks": {
    "llm-anthropic": {
      "decision": "allow",
      "consent_at": "2026-05-17T15:32:11Z",
      "consent_method": "interactive",
      "note": "Approved via consent prompt on 2026-05-17"
    },
    "llm-bundled": {
      "decision": "deny"
    },
    "s3-archive": {
      "decision": "prompt"
    },
    "ox-ledger": {
      "decision": "allow",
      "consent_method": "file"
    }
  },

  "default_for_unknown": "prompt"
}

Note on format: ~/.vox/policy.json is plain JSON. The vox policy export command prints the live manifest as JSON (not YAML). The stdlib encoding/json package is used; no third-party YAML dependency is introduced.

Lifecycle

Closed → Load(ctx, PolicySource)
       → Loaded (mode + per-sink decisions in memory)
       → Allow(ctx, PolicyDecision) → Verdict   [called per sink Open]
       → Reload(ctx)                            [optional; re-reads manifest]
       → Close(ctx)
       → Closed

Startup

Orchestrator constructs Policy once. Load is called with the manifest source (default: file at ~/.vox/policy.json; --policy <path> overrides). If the manifest is missing, the policy initializes in air-gap mode with no per-sink decisions — fail closed.

Per-sink Open

Before calling sink.Open() on any sink, the orchestrator inspects sink.Capabilities().Network. If NetworkScope is non-empty, it constructs a PolicyDecision and calls Policy.Allow. If Verdict.Allowed == false, the sink is not opened and the orchestrator surfaces Verdict.Reason to the operator.

Reload

Operator may call vox policy load <path> at runtime (signals the running process via a SIGUSR1 or a control-socket message — impl detail in bd lgm). On reload, the orchestrator re-validates every currently-open networked sink: any that lose their allow verdict are closed gracefully.

Error states

Scenario Verdict Audit Operator-visible behavior
Mode = air-gap, sink declares Network Allowed=false, Reason=air-gap-mode yes Sink Open fails; error names policy + mode
Mode = selective, sink decision=allow Allowed=true yes Sink opens
Mode = selective, sink decision=deny Allowed=false, Reason=explicit-deny yes Sink Open fails; suggests vox policy revoke-consent
Mode = selective, sink decision=prompt, interactive runs prompt; returns Verdict per response yes (decision + method) Prompt fires; operator chooses
Mode = selective, sink decision=prompt, non-interactive Allowed=false, Reason=headless-prompt-required yes Sink Open fails; instructs operator to set decision
Manifest unreadable at startup n/a — policy defaults to air-gap yes (manifest-error event) Health is Unhealthy; all networked sinks denied

Audit + telemetry posture

policy/v1 is operator-local first. Every audit event flows through audit/v1 (open hook); the default sink is audit-local which writes JSONL to ~/.vox/audit.jsonl (mode 0600). No remote audit egress unless the operator wires the audit-cloud (enterprise) sink.

This is separate from telemetry. Telemetry (anonymous crash reports, perf metrics) is governed by build flag WITH_TELEMETRY (bd qji); it is OFF by default and never emits transcripts or audio. Policy audit is local-by-default but operator-routable; it does emit metadata (sink ID, destination, decision) by design — that's the whole point of an audit trail.

What is NOT in v1

  • Hardware-firewall integration. Vox does not install rules in pf, iptables, nftables. The gate is at the sink boundary inside the process. v2 may add a "platform-level enforcer" shim.
  • Per-IP / CIDR allow-lists. Sinks declare canonical hostnames; the gate is per-sink, not per-IP.
  • Mid-request traffic inspection. Once a sink is allowed and opened, Vox doesn't inspect or interfere with its requests.
  • Per-call rate-limiting. That's a sink-internal concern.
  • Cross-host policy sync. Each host's manifest is local. v2 may add a "team policy" import for Enterprise.
  • Cryptographic attestation of "I did not phone home". The audit log is the closest analog; verifying the log against actual syscalls is out of scope.

Reference implementation order

Phase Component Bead Notes
1 Parse PolicyManifest YAML; static Policy with no Allow logic (subset of lgm) Stand-alone; testable without orchestrator wiring
2 Policy.Allow enforcement + DenialReason wiring lgm Core gate; integrates with orchestrator
3 Consent-prompt UX (interactive + non-interactive paths) 5f2 Depends on lgm
4 vox policy … subcommand (subset of 5f2) Operator surface
5 Audit-event stream via audit/v1 (after audit/v1 lock) audit/v1 itself is listed as "Open" in extension-points; lock that first
6 Runtime reload (Reload) + open-sink re-validation lgm followup Optional; can ship without it in v1.0

sink/v1 itself does NOT change in this contract beyond the additive Network NetworkScope field on sink.Capabilities. Existing sinks compile unchanged.

Acceptance criteria

  • [x] All 14 sections present
  • [x] Interface compiles in head (no missing types)
  • [x] Consent-prompt UX concrete enough to implement
  • [x] Three policy modes defined with clear semantics
  • [x] Sink integration is non-breaking
  • [x] Error model classifies every failure path
  • [x] YAML schema is concrete with example
  • [x] Lifecycle covers startup / per-sink / reload
  • [x] Audit posture distinct from telemetry posture
  • [x] What's NOT in v1 enumerated

This contract is the basis for implementation. Code lives at internal/policy/ (bd lgm). The operator-facing posture page is bd ymz (docs/posture/local-first.md).