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:
- A declarative manifest (
PolicyManifest) that names each sink, says whether the operator has consented to it making network calls, and is loaded at startup. - A runtime gate (
Policy.Allow) that the orchestrator consults before opening any sink whoseCapabilities.Network = true. - 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.
- An audit event stream that records every consent decision via the open
audit/v1hook (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 withNetwork: trueis 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 ispromptfires 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 stayspromptn/deny once— deny this run; decision stayspromptalways— persistdecision: allownever— persistdecision: 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 withdecision: promptreturnDenialHeadlessPrompt
Consent prompt flow¶
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.jsonis plain JSON. Thevox policy exportcommand prints the live manifest as JSON (not YAML). The stdlibencoding/jsonpackage 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).