Skip to content
browse the docs

Threat Model

vanzik v2.0updated view on github

This document states who Vanzik defends against, what it trusts, and what it deliberately does not attempt. It is written from the code: every defence named below points at the function that implements it, and every non-goal is one the README or a design decision already records. Treat it as binding — a change that moves an actor across a boundary is a security decision, not a refactor.

Related: README.md § Limitations & Threat Model (the short form), SECURITY.md (reporting, out-of-scope list), DEPLOYMENT.md (the flags that move the boundaries).

What Vanzik is#

A policy enforcement and audit layer at two protocol boundaries — the MCP stdio gateway and the OpenAI/Anthropic HTTP proxy — plus an advisory SDK layer. Every gated action becomes a /v1/check question to the central server, which answers ALLOW, DENY, or REQUIRE_APPROVAL from in-memory policy state and writes an append-only audit entry.

Actors, from most to least trusted#

Operator and approver — trusted#

Owns the policy file, the API key, the process environment, and the approve/deny decision. Control-plane endpoints (/v1/approve, /v1/deny, /v1/status, /v1/audit, /api/*) require a Bearer token or a session cookie plus CSRF header (pkg/proxy/auth.go, constant-time comparison). Without an API key the server binds 127.0.0.1 only (pkg/proxy/server.go, NewServer) and prints a startup warning; exposing it further is an explicit operator act.

A compromised operator host is out of scope: whoever can edit the policy file or read the API key can disable enforcement.

Embedder — trusted, fallible#

Go code that imports pkg/proxy, pkg/llmproxy, or pkg/mcpgw directly. The packages are nil-safe rather than fail-closed on missing wiring: a nil PolicyCheck hook allows every call with an identifiable rule string, and the legacy HistoryQuerier answers require_prior without a tenant filter behind a one-time warning. Both shipped binaries wire every hook; an embedder must do the same.

Co-tenants — mutually untrusted#

Tenants share one process and one store. Every enforcement key carries the tenant: rate-limit buckets (scope:tenant:agent), session-cost accumulators ({tenant, session}), the approval queue (tenantsMatch on every lookup, resolve, and status read), the require_prior index ({tenant, agent, scope}), audit queries, and every store function (tenant_id is a required explicit parameter; ErrTenantRequired otherwise). Tenant-aware routes validate the tenant before dispatch (pkg/proxy/tenant.go, withTenant). Cross-tenant reads are a bug even in tests.

Metrics are process-wide and carry no tenant label by design: aggregate counters leak nothing tenant-specific, and per-tenant series would make /metrics cardinality client-controlled. The one client-influenced label, vanzik_ratelimit_bucket_evictions_total{scope}, is bounded by the union of policy scope names.

The agent and its model — untrusted#

Prompt injection is assumed. Everything the agent emits is attacker-controlled input: tool-call arguments, scope strings, agent_id, session_id, meta, and any approval_id it presents. Defences:

  • Approval replay is shape-bound and one-shot. An approval_id is honoured only when the retry's operationally-meaningful fields match the original request (matchesOriginalRequest), only within --approval-validity, and only once for an ALLOW (ConsumeResolved, compare-and-consume under the write lock). Mismatches fall through to fresh evaluation rather than a 4xx, so the endpoint is not an oracle. Counted in vanzik_approval_replay_mismatch_total and vanzik_approval_replay_refused_total.
  • Duplicate JSON keys in tool arguments are a hard deny (pkg/llmproxy/streaming.go, runPolicyCheck). The gate projects a Go map (last-wins) while the ALLOW path replays the raw bytes; a first-wins executor would act on a different value than the one gated. This deny is not subject to --fail-mode allow.
  • Secrets in requests are masked before they are stored or shown (server --audit-redact, default on): the audit trail, GET /v1/audit, the SSE stream, the dashboard and the pending list see [REDACTED] for keys, tokens and passwords an agent puts in a command. The approval store keeps the original request for replay matching. See OPERATIONS § Audit redaction.
  • Input normalisation before matching (pkg/policy/engine.go, normalizeRequest): control bytes stripped, domains lower-cased, paths cleaned — so EVIL.com cannot slip past a evil.com deny.
  • Malformed tool-call JSON at completion is refused fail-closed and audited as a forced malformed_tool_call deny (denyMalformedOpenAI / denyMalformedAnthropic), never forwarded.
  • Shell commands are split before matching (pkg/policy/shell_segments.go). Chained commands, substitutions, subshells, command strings run by another shell (bash -c, eval, a here-string fed to a shell), line breaks and redirections are evaluated one command at a time, so an allow rule for the first command can't carry the rest. The command behind a wrapper or launcher (env, timeout, sudo, pkexec, xargs, npm exec, …) is matched against deny and approval rules too, and whole-string rules are matched against each command string and each pipeline; a whole-line approval doesn't hide a deny on a part. Redirection targets are checked as filesystem access. A command line that can't be split safely is denied (deny:shell:unparseable_command); a script inside it that can't be split asks for approval (require_approval:shell:unparseable_script). See POLICY_REFERENCE § Compound shell commands.
  • Glob matching is string matching. An agent can express the same action differently (find / -delete for rm -rf), and a single allowed command can run others through its arguments or its input (find -exec, git -c core.sshCommand=…, python -c, echo … | bash, bash -c "$(curl …)"); see POLICY_REFERENCE § Compound shell commands for what the splitter can't see. Policies are a high-signal filter, not a semantic authorisation model — pair with OS isolation (README).

Anything the agent does outside the gated transport — shelling out directly, pointing itself at a different MCP server or base URL — is out of scope. Vanzik is not a sandbox.

The upstream LLM provider — semi-trusted#

The provider is trusted for the content of its answers but not for the structure of its stream. A non-conformant or hostile stream is in scope, and three fail-closed defences exist for exactly this actor:

Defence Where What it refuses
Interleaved second tool_use block anthropic_parser.go (FeedEvent) A second block opening while one is still buffering would be flushed ungated when the first cycle resets. The whole stream is refused.
Start-seeded input plus streamed deltas anthropic_parser.go Two argument sources would concatenate into invalid JSON and the gate would evaluate a truncated view. Refused.
First-wins function name openai_parser.go A second, different name for the same tool_calls[i] would make the gate evaluate a different call than the client executes. The first name wins, matching client SDKs.

Each refusal is a synthetic refusal to the client, counted in vanzik_llmproxy_protocol_violation_total{provider}, and the buffered bytes are discarded. Oversized argument accumulation is capped by --max-buffer-bytes with a built-in 64 MiB ceiling. The same fail-closed rule covers the rest of the stream structure: a non-streaming body that fails strict decode but still carries a tool call is refused (vanzik_llmproxy_undecodable_tool_call_total); tool input with no block open and tool calls spread across several OpenAI choices are protocol-violation refusals (rule strings in LLM_API_PROXY.md §5.4); a stream that ends with a cycle still open is gated or refused, never dropped; and an empty Anthropic seed with whitespace does not trip the conflict refusal.

The downstream MCP server — untrusted for framing and liveness#

Its frames are untrusted bytes: oversized frames are skipped per-frame with the reader resynchronised (pkg/mcpgw/framing.go), a wedged reader is killed and the supervisor respawns it, explicit "id": null is answered with an error, and server-initiated requests (sampling/createMessage, roots/list, elicitation/create) are dropped, never forwarded to the host. Its tool results are not inspected — data exfiltration through a result payload is a non-goal (see #data-pii in pkg/policy/engine.go).

The MCP host or client application — trusted for framing, untrusted for intent#

The host speaks JSON-RPC to the gateway over stdio and is assumed well-formed, but the tool-call arguments it forwards originate from the model and are treated as agent input above.

Anyone who can reach /v1/check or /metrics — untrusted, by design reachable#

Both endpoints are unauthenticated (DEPLOYMENT.md §4, OBSERVABILITY.md). A network peer can:

  • consume rate-limit budget for any scope and agent id it names,
  • fill the approval queue (bounded at 10 000; refused with 503 and Retry-After when full, resolved entries evicted first),
  • learn policy decisions for requests it composes, and read aggregate counters and policy scope names from /metrics.

It cannot approve, deny, read the audit trail, or reuse an approval it did not obtain the id for without also matching the original request shape. Volumetric abuse is delegated to the network layer (SECURITY.md). Gate /v1/check at a reverse proxy when the threat model needs L7 auth.

Outbound connections#

The enforcement server initiates only the connections the operator configured: the PostgreSQL store (--store-dsn) and the policy's notifiers. vanzik server performs no update check and sends no telemetry. The interactive vanzik subcommands, vanzik setup included, query the GitHub Releases API once at startup unless VANZIK_NO_UPDATE_CHECK is set. When you choose Update in vanzik setup, it downloads the release archive and checksums.txt from GitHub (or VANZIK_DOWNLOAD_URL) and replaces its binaries only if the checksum matches — the same trust as the one-line installers: the checksum catches a corrupted or swapped download, not a compromised release; gh attestation verify checks provenance. CI, not the binaries, talks to OSV.dev, PyPI, npm, and the Go module proxy (pkg/depaudit). The MCP gateway reaches --guard-url and the MCP servers it spawns; the LLM proxy reaches --guard-url and the configured provider. Each of these peers is a trust decision the operator made; The update check only parses a tag name; setup's Update, which you choose, is the only place Vanzik installs code.

Supply chain — checked in CI#

govulncheck (reachability-based) and pkg/depaudit (live OSV.dev per pinned version plus a curated performance-regression registry) gate every push. Python extras are version ranges and Go pseudo-versions are not point-queryable, so those are registry-only; transitive npm packages are covered only by the advisory npm audit job. See pkg/depaudit/advisories.md.

Trust boundaries in one picture#

   untrusted            semi-trusted             trusted
 ┌──────────┐  stream  ┌──────────────┐          ┌────────────┐
 │ LLM      │ ───────► │ LLM proxy    │ /v1/check│ vanzik │ ◄── operator
 │ provider │ ◄─────── │ (gate)       │ ───────► │ server     │ ◄── approver
 └──────────┘          └──────────────┘          │ (policy,   │
 ┌──────────┐  frames  ┌──────────────┐          │  approvals,│ ◄── store (--store-dsn)
 │ MCP      │ ◄──────► │ MCP gateway  │ ───────► │  audit)    │ ──► notifiers
 │ server   │          │ (gate)       │          └────────────┘
 └──────────┘          └──────────────┘               ▲
 ┌──────────┐   guard.check(...)                      │ advisory — the agent
 │ agent    │ ────────────────────────────────────────┘ process decides to call
 │ (model)  │  ── shells out / other base URL ──► out of scope (OS sandboxing)
 └──────────┘

Enforcement is only as strong as the operator's control over the agent's environment (OPENAI_BASE_URL, MCP client config, network egress). The SDK layer is opt-in and therefore advisory; it exists for offline scripts and custom transports and should be paired with a wire-level layer whenever both apply.

Fail-open versus fail-closed#

  • Central server unreachable: both proxies default to --fail-mode deny (deny:<gateway>:fail_closed), with fail-closed-with-audit writing the denial to a local fallback file. allow is an explicit opt-in for trusted development environments; neither proxy logs the mode at startup.
  • /v1/check answers 2xx with an unparseable or unknown decision: hard deny (InvalidResponseRule), regardless of --fail-mode.
  • SDKs: fail-closed by default; fail_mode="allow" / failMode: 'allow' is an explicit opt-in.
  • Audit backend saturated: entries spill to a durable overflow file and the request is still answered — the audit path never blocks or fails the decision.
  • Approval queue full: 503, not a silent allow.

Explicit non-goals#

These are documented decisions, not gaps:

  • OS sandboxing or syscall interception. Use containers, seccomp, AppArmor, egress rules.
  • Semantic understanding of commands. Matching is glob on strings; a single * crosses / (see POLICY_REFERENCE.md).
  • Tamper-proof audit storage. The audit log is append-only JSONL. --audit-seal (file backend, off by default) makes changes detectable: an HMAC-SHA256 hash chain that vanzik audit verify checks, plus anchors (seq and hash of the head) logged and served on GET /v1/audit/head. It does not stop a writer who holds the key, or without a key anyone who can write the file, from rewriting the chain, and it cannot by itself show that the newest entries were cut off. Anchors copied off the box, or WORM storage, cover that. Signed seals and Merkle proofs are not planned.
  • RBAC or multi-key auth. One bearer key; capability-scoped keys are post-v1.
  • PII or secret detection in data flows. Deferred (#data-pii) — a weak classifier would create false confidence.
  • MCP resources, prompts, sampling, elicitation, cancellation. Masked out of the handshake or dropped; tools only.
  • Global strictness across replicas. Multi-node limiting is bounded-overshoot by design (≈ reconcile-interval × peak rate per extra replica).
  • A compromised operator host or a hostile embedder.

Keeping this document true#

When a change adds an outbound connection, a new unauthenticated endpoint, a new actor whose bytes reach a parser, or relaxes one of the defences in the table above, update this file in the same change. cmd/vanzik/docs_consistency_test.go pins the claims that are cheap to check mechanically.