decoyrail

Audit log & spend metering

Every decision Decoyrail makes (allow, deny, tripwire, response alert) is appended to a tamper-evident audit log. Alongside it, a meter tracks per-host traffic and enforces a monthly budget.

The audit log

~/.decoyrail/audit.jsonl: one JSON event per line, append-only.

You rarely need to read the file raw. decoyrail log pretty-prints it, and decoyrail log -t follows it live, like tail -f. Keeping that open in a second terminal while an agent runs is the fastest way to see what it is doing and why something was denied.

{
  "seq": 42,
  "ts": "2026-07-05T17:03:11Z",
  "host": "api.anthropic.com",
  "path": "/v1/messages",
  "method": "POST",
  "action": "allow",
  "rule": "anthropic",
  "escalated": false,
  "swaps": ["anthropic@header:authorization"],
  "tripwires": [],
  "status": 200,
  "note": "",
  "pid": 812,
  "prev_hash": "9f2c…",
  "hash": "a41b…"
}
Field Meaning
action allow, warn (forwarded under the warn action, no secret released), deny, alert (real secret echoed in a response, or a config hot-reload failure), session (a decoyrail run or proxy launch, labeled in note), usage (deferred token counts for a streamed response), cache (a prompt-cache marker injected, Pro), or keepalive (a proxy-initiated cache pre-warm, Pro)
rule the policy rule that decided it (default when nothing matched)
escalated the matching rule said escalate (resolved via fallback)
swaps secrets substituted, as name@location
tripwires decoys seen off-policy, as name@location (including path, encoded:base64, body:raw, name@response)
status HTTP status returned to the agent (403 deny, 413 cap, upstream status on allow)
note human-readable reason (tripwire: …, budget exhausted, …)
pid process id of the decoyrail process that recorded the event (0 on events written before this field existed)
sid session id of the recording process, stable where pids get reused; what decoyrail stats --by session groups on
dur_ms request duration in milliseconds; on streamed responses it moves to the companion usage event so nothing is measured twice
bytes_up, bytes_down request and response sizes as seen at the proxy (omitted when zero)
usage structured token counts and cost for LLM requests: {model, input, output, cache_read, cache_write, cost_usd}
req_seq on usage events: the seq of the allow or warn event the counts belong to

The analytics fields (sid through req_seq) exist so decoyrail stats can aggregate the log without parsing prose; see Analytics. Like the pid before them, they are part of the hashed payload: events written before they existed verify through a fallback, and rewriting any of them on a real event breaks the chain.

Several decoyrail processes can share one log. To follow a single session, filter on its pid; decoyrail run prints it at launch:

decoyrail log --pid 812          # only that session's events
decoyrail log -t --pid 812       # follow it live

The pid filter applies before -n, so --pid 812 -n 20 means "the last 20 events of session 812", not "session 812's share of the last 20 events".

The log is treated as required, not best-effort. If an append fails (disk full, permissions, lock error), the error prints to stderr, and for an allowed request the response is withheld with a 503 audit log unavailable; failing closed instead of being delivered. Traffic does not flow unrecorded. (Denied requests are already blocked, so a failed deny-event write costs visibility, not enforcement.)

The hash chain

Each event's hash commits to the previous event's hash plus the event's own canonical payload, so history can't be edited or thinned without detection:

flowchart LR
    z["genesis<br/>prev = 000…0"] --> e0["event 0<br/>hash₀ = SHA-256(prev | payload₀)"]
    e0 --> e1["event 1<br/>hash₁ = SHA-256(hash₀ | payload₁)"]
    e1 --> e2["event 2<br/>hash₂ = SHA-256(hash₁ | payload₂)"]
    e2 -.latest seq + hash,<br/>written atomically.-> anchor[("audit.head<br/>(head anchor)")]

The payload is hashed as a canonical JSON array (not delimiter-joined strings), so no crafted field value can shift field boundaries and make two different events hash identically. The pid is part of the hashed payload; events written before the field existed still verify through a legacy fallback, and rewriting a real event's pid breaks the chain.

What decoyrail log --verify catches

flowchart TD
    v["decoyrail log --verify"] --> walk["re-derive every hash<br/>from the genesis"]
    walk --> broken{chain intact?}
    broken -->|no| bad1["❌ edit or mid-file deletion<br/>reported with the breaking seq"]
    broken -->|yes| head{"last event ==<br/>head anchor?"}
    head -->|"log missing but anchor exists"| bad2["❌ log deleted"]
    head -->|"last seq behind anchor"| bad3["❌ tail truncated"]
    head -->|match| ok["✓ N events verified"]

Known limit: an attacker with write access to ~/.decoyrail can rewrite the log and the anchor consistently. The anchor defeats naive truncation, not a full local compromise. Hardware-backed or off-box head storage is on the roadmap, and shipping events to a central log system in near-real time (which enterprises already do for other logs) bounds the rewrite window to seconds. See the threat model.

Concurrent writers

Multiple Decoyrail processes (a decoyrail proxy plus one or more decoyrail run sessions) can share one log. Appends take an exclusive OS file lock and re-derive seq/prev_hash from the tail if another process wrote in between; without this, each process would chain from stale state and fork the chain, tripping a false tamper alarm.

Spend metering & budget

For LLM provider hosts (api.anthropic.com and api.openai.com out of the box), metering is exact: the proxy reads the token counts the provider itself reports in each response (the usage fields, including prompt-cache reads and writes) and prices them per model. Streaming responses are scanned incrementally as the bytes pass through, so the SSE passthrough stays untouched. Everything the proxy can't parse falls back to a coarse byte estimate (about 4 bytes per token at a blended per-provider rate; non-LLM egress is metered but costed at zero), and decoyrail status labels which number is which.

Billing mode matters for real cost, so Decoyrail tracks it: a request authenticated the way flat-rate plans authenticate is tagged [subscription] and counted at zero marginal cost. For Anthropic that means an OAuth Authorization: Bearer with no x-api-key, the shape Claude Code sends when signed into a Claude plan. Its tokens still show in status, in full; they just don't burn the budget, because a flat plan adds no per-request bill. That isn't the same as free: plan allowances are finite, heavy sessions hit plan limits, and usage beyond the plan bills at API rates. An API-equivalent reference cost for subscription traffic is on the roadmap, so you'll be able to see what your plan absorbed and how close you are to outgrowing it.

decoyrail status        # tokens + $ per model for LLM hosts, MB for the rest
decoyrail budget 50     # monthly cap in USD; 0 = unlimited

The prompt-cache report

Provider prompt caches cut repeated-context cost by up to 90%, and agent traffic breaks them constantly, usually by accident: a timestamp in the system prompt, a tool list that changes order, a request landing just past the cache TTL. decoyrail cache explains what the cache did for you and what keeps breaking it:

Token counts come from the same provider-reported usage the meter records. The request-side diagnosis is observe-only: requests leave byte-identical whether it runs or not, and its state file (cache.json) holds counters, offsets, and section labels, never prompt content. Diagnosis is free for everyone.

Cache repair and active management (Pro)

Diagnosis names the waste; the Pro tier fixes it. All three behaviors are off by default and switch on in the policy's [cache] table (hot-reloaded, like the rest of the policy); none touches what the model reads, and every mutation and proxy-initiated request is audited.

These are Pro features: without a license, or with the knobs left off, the proxy runs the free, observe-only doctor and forwards every request byte-identical.

Per-model rates ship built in. ~/.decoyrail/pricing.json (hot-reloaded) overrides or extends them, maps extra hosts to a provider protocol (an internal gateway, say), and can force a billing mode per host:

{
  "hosts":   {"llm.corp.internal": "openai"},
  "billing": {"api.anthropic.com": "subscription"},
  "models":  {"claude-sonnet-5": {"input": 3.0, "output": 15.0,
              "cache_read": 0.3, "cache_write": 3.75}}
}

How it behaves: