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), tamper (a policy load rejected because the file failed integrity verification), policy (a policy write or blessing through a Decoyrail surface; the note carries the file's sha256), 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), keepalive (a proxy-initiated cache pre-warm, Pro), downgrade (a budget soft-landing model rewrite, Pro), or route (a model-router rewrite, Pro; the note names the mapping and prices any warm prompt cache it forfeits)
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, ref_cost_usd} (ref_cost_usd is the API-equivalent reference for subscription traffic, present only when nonzero)
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.

So every subscription request also carries a reference cost: what the same tokens would have billed at API rates, cache reads and writes priced at their own multipliers, from the same pricing table as billed traffic. status and stats show the total as "plan-absorbed", always labeled API-equivalent and never summed into spend; the budget and its kill switch see only billable dollars. It is a reference, what the plan absorbed, not a savings claim: providers don't publish plan allowances, so Decoyrail never invents a "percent of plan used".

If you tell Decoyrail what the plan costs, it reads the absorbed total against that price:

decoyrail plan --price 200 --label "Claude Max"   # declare what you pay
decoyrail plan          # what the plan absorbed this period, vs its price
decoyrail plan --clear  # remove the declared price

With a price declared, status and plan state one of two things: the plan absorbed more API-equivalent usage than it costs (it is paying for itself), or how much of its headroom went unused this period. With no price declared you get the totals and no verdict. The price is a local setting you own; if the provider reprices, the declared figure is shown so a stale number is visible.

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:

The spend tripwire

The monthly budget is a backstop, and it fires far too late for an agent that got stuck at 2am retrying the same failing request. The spend tripwire watches for runaway behavior in near real time and trips in minutes, not at the end of the month. It is a safety feature, so it ships in the free tier, on by default, and no license state affects it.

An agent stuck resending one request is blocked by the spend tripwire; decoyrail log -t shows the trip live, and decoyrail trip clear lifts it

Two purely mechanical signals, no guessing about intent:

It is configured in the policy's [spend_tripwire] table (hot-reloaded like the rest), shown here with its defaults:

[spend_tripwire]
mode = "block"          # block | alert | off
repeats = 15
window_secs = 300
rate_multiplier = 10.0  # 0 disables rate detection
rate_floor_usd = 5.0

Only LLM-bound traffic (hosts the pricing table maps to a provider) is watched and, on a trip, blocked; git push and cargo fetch keep flowing. In block mode the tripping request and everything LLM-bound after it get a 403 whose JSON body names the trigger, the counts, and the clear command, so a coding agent can read why its requests stopped and break its own loop. In alert mode the trip is recorded once and traffic keeps forwarding: visibility before enforcement, same as the DLP detectors' warn posture.

A trip is deliberately sticky. It persists to trip.json, survives a proxy restart, reaches every session sharing the state dir, and also stops Pro keep-alive pre-warms (quiet recurring spend is exactly what a tripped session must not keep accruing). Nothing clears it but an operator: decoyrail trip shows the trip and decoyrail trip clear clears it, resets detection state, and writes its own audit event. decoyrail status puts a standing trip on the front page, and decoyrail log -t renders the events with [TRIP] prominence, same as the honeytoken alarm: both are tripwires, one on secrets, one on cost.

The audit events carry a salted fingerprint of the repeated request and the counts, never request content, exactly like DLP hits. If your agent legitimately polls one endpoint with identical bodies, raise repeats or widen window_secs for your traffic; the defaults are chosen so ordinary retry-with-backoff never gets close.

Budget soft-landing (Pro)

The kill switch is binary: under budget everything flows, at 100% everything stops. Soft-landing adds a band in between. Past a threshold you set, requests that name a model in your downgrade map are rewritten to the cheaper model, so the agent keeps working at lower cost instead of hitting the wall at speed. At 100% the kill switch still stops everything, exactly as above.

It switches on in the policy's [soft_landing] table (hot-reloaded, like the rest of the policy) and is off by default:

[soft_landing]
threshold_pct = 80
map = { "claude-opus-4" = "claude-sonnet-5", "gpt-5" = "gpt-5-mini" }

The map is yours. Decoyrail has no built-in opinion about which models are equivalent, and a model with no entry forwards untouched. Only the top-level model field of recognized LLM request bodies (the Anthropic and OpenAI JSON shapes) is rewritten, byte-surgically, so everything else in the request is exactly what the client sent. A request whose model can't be identified passes through unchanged; Decoyrail never guesses. A map that names a target model the pricing table doesn't know still forwards as configured (the provider errors informatively), and the audit note flags the likely typo.

Past the threshold, decoyrail status shows the degraded band and an opus request comes back rewritten to sonnet, marked with x-decoyrail-downgrade and audited

Downgrades never happen silently:

One caveat, which the audit note also states: provider prompt caches are model-scoped, so a model rewrite invalidates the warm cache and the first downgraded request pays a cold cache write for the new model. The visibility exists so that cost stays attributable instead of mysterious.

When a route rule also matches, soft-landing rewrites first and the route map applies to the result, each with its own audit event.

The threshold reads the same billable spend as the kill switch, so subscription traffic never pushes a session into the band. This is a Pro feature: without a license, or with the table absent, behavior is exactly today's, nothing between a healthy budget and the hard kill switch.