dumbtv
dumbtv가 ADB로 Fire TV에서 광고·추적·업데이트 알림을 제거, 루트·앱 변경 없이, 무료 오픈소스
POST /v1/decide에서 에이전트 행동을 결정적으로 허용·거부·보류하고, 서명된 비용 제한 토큰으로 보류 해제, LLM 미사용
Deterministic allow, deny or hold for every agent action over POST /v1/decide. Holds resolve through signed, cost-bound approval tokens; no LLM in the decision path. Validated live: 176 decisions, a 3
보안Go · GherkinGitHub @TAIPANBOX온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
Policy decision point (PDP) for AI agent actions: the policy plane of the agent-governance stack
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Decide allow, deny, or hold for every action your own agents attempt, before an enforcement point lets it through.README 전문 보기 ↗Decide allow, deny, or hold for every action your own agents attempt, before an enforcement point lets it through.
Wardryx protects the operator by governing its own agents' actions: it blocks or holds, it never acts. Given a proposed action from one of the operator's own AI agents (a tool call, a spend, a delegated step), it decides allow, deny, or hold and returns that decision to whatever enforces it, an MCP gateway, a tool-calling runtime, or TokenFuse's proxy. Every decision is a deterministic function of the loaded policy set and the request: no LLM anywhere in the decision path, so the same policy and the same request always return the same answer.
The same service as its room on it-rat.com draws it, where the diagram sits next to a simulation you can scrub back and forth.
Wardryx is the policy plane: given a proposed action from one of the operator's own agents, it decides allow, deny, or hold. TokenFuse (the spend plane) calls it per request as the enforcement point (PEP), and Scopyx (the egress plane) calls it per destination and again per redirect; Wardryx is the decision point (PDP) for both. Two enforcement points, one decision point, which is the shape this design is for: a policy an operator wrote once is applied at every place an agent can act.
flowchart TB
Agent["AI agent (any framework)"] -->|"LLM call (base-URL swap)"| TF["TokenFuse proxy: spend + enforcement"]
TF -->|"POST /v1/decide (PEP)"| WX["Wardryx: policy PDP"]
WX -.->|"allow / deny / hold"| TF
TF -->|"cheapest model, budget OK"| LLM[("LLM provider")]
TF -->|"CallRecords"| CL["TokenFuse Cloud: control plane, incidents, replay, evidence, kill-switch"]
VCX["Vouchryx: delegation proved, and endable"] -->|"short-lived token: act + cnf"| TF
TF -.->|"polls /v1/revocations"| VCX
VCX ==>|"delegation_issued / denied / revoked"| BUS
TF ==>|"agent-event NDJSON"| BUS{{"agent-event bus + Agent Passport"}}
WX ==> BUS
Agent -->|"web fetch"| SCX["Scopyx: governed web egress"]
SCX -->|"POST /v1/decide"| WX
SCX ==>|"web_fetch / web_blocked"| BUS
ENG["Engram: memory"] -->|"reflect via base_url"| TF
ENG ==> BUS
BUS ==> IDX["Idryx: identity graph, detectors, Agent-BOM"]
IDX ==>|"identity_finding"| BUS
BUS ==> QX["Qryx: crypto / PQC, passport + hash-chain scan"]
QX ==>|"crypto events"| BUS
BUS ==> VX["Verdryx: quality / drift"]
VX ==>|"quality events"| BUS
TF -->|"outcome-tagged traces"| VX
MX["Mockryx: pre-prod safety rehearsal"] -->|"hostile scenarios"| TF
MX ==>|"sim events"| BUS
BILL[("cloud, SaaS and model bills")] --> CC["CostCrew: the bill, worked by a crew of agents"]
CC ==>|"spend_spike / budget_threshold / crew moves"| BUS
BUS ==> TRX["Trailryx: the record plane, sealed and packed"]
BUS ==> HX["reads the log, mails you (heraldyx)"]
HX -->|"one mail, a view and never an action"| OPS["your mailbox"]
HX ==>|"alert_sent"| HJ[("heraldyx's own hash-chained journal, not this bus")]
YOU(["you, in a browser over your own tunnel"]) --> GX[["Genaryx: the console over all of it"]]
GX -->|"signed commands: the kill, an approval, a policy"| CL
GX -->|"signed commands"| WX
GX ==>|"console_command"| BUS
GX -.->|"reads it"| IDX
GX -.->|"reads it"| QX
GX -.->|"reads it"| VX
GX -.->|"reads it"| MX
GX -.->|"reads it"| ENG
GX -.->|"reads it"| SCX
GX -.->|"reads it"| CC
TFP["terraform-provider-taipan"] -->|"budgets + passports as code"| CL
ASG[["agent-stack-go: shared Go contract"]] -.->|imported by| IDX
ASG -.->|imported by| WX
ASG -.->|imported by| MX
ASG -.->|imported by| TFP
ASG -.->|imported by| HX
ASG -.->|imported by| QX
SPEC[["agent-passport: the spec"]] -.->|governs| BUS
/v1/decide calls from TokenFuse (the PEP), Agent Passports, and policies. Tool names, step count, referenced domains, and estimated cost arrive per request.allow / deny / hold decisions (each carrying a cacheable flag), short-lived signed approval tokens, and source: wardryx events on the shared bus.The full stack is TokenFuse (spend), Wardryx (policy), Vouchryx (delegation), Engram (memory), Idryx (access), Qryx (crypto), Verdryx (quality), Mockryx (pre-prod), scopyx (governed web egress), CostCrew (the bill), Trailryx (the record) and heraldyx (the mail out), on the shared Agent Passport + agent-event contract (agent-stack-go / agent-passport), configured via terraform-provider-taipan and driven from Genaryx, the console over all of it.
Run the whole open stack locally with one command via stack-up; the stack's home on the web is it-rat.com.
Before any public launch, Wardryx was run as a live policy decision point under real concurrent load: 176 real decisions with differentiated per-agent rights, correctly filtered under a 34-request concurrent burst - including two real enforcement gaps that only concurrent traffic exposed, both found and fixed.
Then measured at cluster scale on Hetzner, AWS and GCP (six five-node k3s clusters, 25 to 27 July 2026): about 2,450 decisions per second per pod at a p50 of 3.2ms, with no throughput cliff out to 256 concurrent callers on dedicated cores, a freeze reaching live traffic in one PDP round trip, and 426 bytes of audit for every decision because every decision is audited rather than a sample. That last number is the one to plan against: it is 614 MB a day at a thousand calls a minute, and it is the only part of running this that grows without anyone deciding to grow it.
The same runs made us withdraw a published conclusion: we had written that throughput collapses past 64 concurrent callers. It does not. That was a shared-vCPU instance, not this software.
Full write-up, the cluster numbers, the retraction, and both bugs live testing found (and fixed):
VALIDATION.md.
An operator running AI agents needs a place to say, once and declaratively, what its own agents may do: which tools are off-limits, which agents must carry a live attestation before they can act, and which spend levels need a human in the loop before the agent proceeds. Wardryx is that place: a Policy Decision Point (PDP) that an enforcement point (an MCP gateway, a tool-calling runtime, a proxy) calls before letting an agent's action through.
Wardryx sits next to, and imports the same shared contract as, the rest of the TAIPANBOX stack (Idryx's identity graph, agent-stack-go's Agent Passport and agent-event types). It is entirely deterministic: no LLM appears anywhere in the decision path, matching Idryx's own rule for its detectors. Given the same loaded policy set and the same request, Decide always returns the same answer.
Policies (internal/policy) load from a YAML or JSON file or directory. A YAML file may hold several documents separated by ---, and a JSON file several values; every one of them loads, in order, and data after a JSON value that is not a whole value is refused, not ignored. A document or value that fails to decode fails the whole file, which then does not load. Each policy targets an agent:// glob and can set deny_tool, allow_tool, allow_models, allow_domains, require_human_above_usd, deny_above_usd, max_steps, deny_if_unattested, require_identity_source, max_chain_depth, require_root_principal, deny_if_chain_unproven, and hold_if_signal (see Typed risk signals); they compile into an in-memory matcher with a stable PolicyVersion (a short sha256 hex digest of the normalized rule set), so every decision ties back to the exact rule generation that produced it. Leaving require_human_above_usd or deny_above_usd out of a policy means no threshold or ceiling at all, exactly as before; writing either one as 0 is not the same thing and means what it says, a threshold or ceiling of zero, holding or denying any priced call (see CLAUDE.md's zero-ceiling invariant).
Engine.Decide (internal/pdp) matches policies for the requesting agent and applies, in order: an invalid delegation chain denies outright; a requested tool in deny_tool denies (the tool list and the name of the tool call are both checked); a requested tool that a matched allow_tool does not list denies (the tool list and the tool call's name are both checked, and every matched allow-list must list the tool); a chain longer than a matched max_chain_depth denies; a chain whose root is not one a matched require_root_principal names denies; a matched deny_if_chain_unproven policy on a request the enforcement point did not verify denies; a matched deny_if_unattested policy with no live attestation denies; a matched require_identity_source policy whose list does not name the request's identity_source denies (every matched list must name it, and an absent or unknown source is none); on a model call, a model that a matched allow_models does not name denies, an empty one included (every matched list must name it; a request with a tool_call, or with no model key at all, is not a model call and is not judged); a run's accumulated step count at or over a matched max_steps denies; a declared domain absent from a matched allow_domains denies; an estimated cost over a matched deny_above_usd hard ceiling denies outright (no approval can authorize it, and this precedes the hold below); an estimated cost over a matched require_human_above_usd threshold holds, unless a valid approval_token is presented (then it allows) or an invalid token is presented (then it denies, rather than being quietly treated the same as no token at all); a typed signal that a matched hold_if_signal rule reads holds, and so, under that rule's when_absent: hold, does a tool call that arrived with no signal of the rule's name, unless a valid approval_token is presented (then it allows; an invalid one holds again, it never denies; a hold on a missing signal is lifted only by a token granted for that call); otherwise it allows. A deny from any rule wins outright and short-circuits the rest.
The three chain rules read on_behalf_of and chain_proven, both supplied by the enforcement point on every /v1/decide call. chain_proven is believed. This service does not verify a delegation proof and must not: it decides at a 3.2 ms p50, and a signature check per decision taxes every decision in the estate. Verification is a library call at the enforcement point (agent-stack-go/delegation, tokenfuse-delegation), and what arrives here is a fact somebody else established. A caller that lies about it is believed, which is not a weakness of the field but where the trust boundary is; estate-gates C11 is what checks the other side of it.
identity_source says how the enforcement point knew the agent, and a policy can require a source. It is one of agent-token (with WARDRYX_AGENT_TOKEN_ISSUER set, wardryx verified the agent token and its proof itself; without it, the enforcement point's report that it verified the token at its own door), client-key (a static key) or none. An absent value, and any value that is not one of the three, is none, so a caller that predates the field is decided exactly as before, and no value it sends can earn more than it says. A policy's require_identity_source lists the sources it accepts ([agent-token] admits only a verified token), is refused at load if it names anything else, and denies a request whose source it does not list. Each matched policy's list must name the source. When WARDRYX_AGENT_TOKEN_ISSUER is set, wardryx verifies an agent token it is sent in agent_token itself: the ES256 signature against the key set it holds, the exact issuer and one of the configured audiences, the expiry with a 30-second leeway, the sub against the request's agent_id (a token for another agent is refused as agent_contradicts_token, and an identity_mismatch event is written), and the revocation list it polls. The agent also sends a DPoP proof for the call in agent_dpop_proof, which must be made with the key the token is bound to (cnf.jkt), name the token, be fresh within the proof window (60 seconds by default, at most 300) and be sent to one of the doors in WARDRYX_AGENT_TOKEN_DOOR_ORIGINS. A token that does not verify is 403 agent_token_invalid, a token with no proof is 403 agent_token_proof_required, a proof that does not verify is 403 agent_token_proof_invalid, and a request that claims agent-token with no token is 403 agent_token_required. A proof is single use: its jti is remembered until its window closes, so a second presentation of the same proof is refused as agent_token_proof_invalid, and a caller sends a fresh proof with each decide. The record is bounded; when it is full a new proof is refused until an entry's window closes. It is held by one process, so behind several instances a proof can still be presented once to each. The source a verified token sets is wardryx's own check, not the claim. With no issuer set, the token is ignored and the source is the enforcement point's report, believed the way chain_proven is. Either way the enforcement point can skip wardryx and decide without it, the residual that is named rather than closed (CLAUDE.md invariant 13). The source is recorded on every decision and its event, and a decision under a policy that requires one is never cacheable (CLAUDE.md invariant 39). has no enforcement point, so it judges such a rule as .
allow_domains is enforced over the domains the caller declares in the request (its tools' or MCP servers' declared destinations), not over what a tool actually reaches at runtime: an empty declared-domains list is a no-op, never a denial, and full runtime tool-egress enforcement is the job of whatever proxies the tool call (an MCP broker), not Wardryx. max_steps is enforced over the run's accumulated step count, supplied by the enforcement point on every /v1/decide call.
allow_tool is the closed list in the other direction: it names the only tools the agents a policy matches may use, and any other requested tool is denied. For /v1/decide the tool list and the name of tool_call are both checked, so a call that names an unlisted tool is refused even when the list itself names only listed tools. Matching folds case and surrounding space the way deny_tool does, and nothing looser, so apply-staff-coupon is not apply_staff_coupon. An empty or absent list restricts nothing. When several matched policies set one, a tool must be listed by each of them. A valid approval_token does not lift an allow-list denial, and a blank entry is refused at load, because it would otherwise be dropped and read as no restriction.
name: shop-agent
target: "agent://acme.example/shop/*"
allow_tool:
- search
- apply_staff_coupon
Two rollback limits. A policy file that uses allow_tool is refused at start by an older build, which is loud. A policy stored through PUT /v1/policies/{id} is decoded without that check, so an older build would drop the list silently and stop restricting the agent's tools. Remove allow_tool from stored policies before a rollback.
allow_models names the models the agents a policy matches may use: a request whose model matches none of its entries is denied, so an agent that switches to a model its policy does not name is refused on that call, and allowed again when it switches back. Each entry is a model id or a glob in the syntax target uses (* any run of characters, ? exactly one), matched exactly: no case or space folding, since folding would widen an allow-list, so Claude-Sonnet-5 is not claude-sonnet-5. A model holding a control character or invalid UTF-8 matches no entry. When several matched policies set a list, the model must be named by each of them. An empty or absent list restricts nothing, and a blank entry is refused at load and at PUT. Only a model call is judged: a request with no tool_call that sends a model key. A model call whose model is empty or blank is refused, since an upstream that fills in a default would otherwise serve whatever model it likes. A request that carries a tool_call is a tool decision (TokenFuse's MCP broker sends the call and an empty model), and a request with no model key at all is not about a model call (scopyx's egress checks send agent, run and domains only): allow_models judges neither, the way allow_domains does not judge a request that declares no domain. TokenFuse's gateway sends the body's model as it is on both wires (/v1/messages and /v1/chat/completions), unknown when the body has none or not as text, and a list refuses unknown unless it names it. The rule runs before the cost gate, so no approval_token lifts it. A decision under a policy that sets it is never cacheable, whatever it decided: the model is per call, and an enforcement point's cache keyed on agent and tool set would otherwise answer a call naming a refused model with an allow it stored for a listed one. The model is recorded with every decision, so wardryx replay reproduces a model denial from the record.
name: support-fleet
target: "agent://acme.example/support/*"
allow_models:
- "claude-sonnet-*"
- claude-haiku-5
The same rollback limits as allow_tool: an older build refuses a policy file that uses allow_models, which is loud, and reads a policy stored in Postgres with a decoder that drops the key, so it would stop restricting models without a word. Remove allow_models from stored policies before a rollback.
| Decision | Trigger | Wire behavior | Response |
|---|---|---|---|
allow | no rule denied, est_cost_usd is at or under any matched deny_above_usd and require_human_above_usd, and no matched hold_if_signal fired; or a valid approval_token lifted a hold (once, under the single-use default) | no HTTP error; the enforcement point forwards the call | decision:"allow"; cacheable:true unless a matched policy sets max_steps, allow_domains, allow_tool, allow_models, require_human_above_usd, deny_above_usd, hold_if_signal, or any of the three chain rules, or the request carries a tool_call and a matched policy sets deny_tool |
deny | an invalid on_behalf_of delegation chain (whatever the policy), deny_tool matched by the tool list or the tool call's name, a tool outside a matched allow_tool, a model outside a matched allow_models, a chain over max_chain_depth, a root outside require_root_principal, deny_if_chain_unproven on an unverified chain, deny_if_unattested with no live attestation, require_identity_source that does not list the request's identity_source, max_steps exhausted, a domain outside allow_domains, est_cost_usd over a matched deny_above_usd hard ceiling, or an invalid approval_token presented for a cost over a matched require_human_above_usd | HTTP 403 Forbidden from the enforcement point back to the agent | decision:"deny"; reason names the exact rule; approval_token_required:false, except true for a refused token |
hold | est_cost_usd exceeds a matched require_human_above_usd with no approval_token presented, or a signal satisfies a matched hold_if_signal with no valid one, or a tool call arrives with no signal for a matched hold_if_signal that sets when_absent: hold; or a valid token is presented again after its single use |
Request:
POST /v1/decide
Authorization: Bearer <key>
Content-Type: application/json
{
"agent_id": "agent://acme.example/finance/bot1",
"run_id": "run-42",
"on_behalf_of": ["user://acme.example/alice"],
"tool_names": ["send_wire_transfer"],
"domains": ["payments.acme.example"],
"steps": 3,
"model": "claude-sonnet-5",
"est_cost_usd": 1200.00,
"attestation_method": "spiffe-svid",
"identity_source": "agent-token",
"approval_token": ""
}
domains, steps and model are optional and default to "impose no restriction" when omitted: a request with no model key is not checked against allow_models (one that sends "model": "" is a model call naming none, and a list refuses it); an empty/absent domains means the caller declared no network destinations to check against allow_domains, and a zero/absent steps never trips max_steps on its own (a matched policy's max_steps still has to be positive for the rule to fire at all).
Response (a hold, in this example, because send_wire_transfer's cost is above the matched policy's require_human_above_usd):
{
"decision": "hold",
"policy_version": "3f9a2b7c1d4e",
"reason": "estimated cost $1200.00 exceeds policy \"finance-guardrail\" threshold $500.00; human approval required",
"approval_id": "ap_8f3e1c2a...",
"approval_token_required": true,
"cacheable": false
}
decision is always one of allow, deny, or hold. approval_id is only set on a hold. approval_token_required is true whenever the estimated cost crossed a threshold at all, or a matched hold_if_signal fired, whether or not a token ultimately satisfied it. A denied max_steps or allow_domains check reports its own reason (e.g. "policy \"finance-guardrail\" step budget exhausted: 5 >= max_steps 5" or "domain \"evil.example.com\" is not allowed by policy \"finance-guardrail\" (target agent://acme.example/finance/*)") with decision: "deny" and approval_token_required: false, since a deny from an earlier rule short-circuits before the cost threshold is ever reached.
cacheable tells the caller whether this decision may be stored and later served again, by whatever cache the caller keeps in front of /v1/decide, for another request against the same agent_id and tool set, without calling /v1/decide again. It is true only when no policy matched by agent_id sets max_steps, allow_domains, allow_tool, allow_models, require_human_above_usd, deny_above_usd, max_chain_depth, require_root_principal, or deny_if_chain_unproven (the fields Decide checks against this request's own steps, domains, model, est_cost_usd and on_behalf_of chain, and the name of its tool call, any of which can differ on the very next call even for the same agent and the same tools), or when no policy matched at all (a hold_if_signal rule also makes a decision uncacheable, since a signal belongs to one call's arguments and not to the agent and tool set). A request that carries a tool_call is likewise uncacheable under a matched deny_tool, because the deny-list reads the call's name, which is outside the agent and tool set. A request with no tool_call under a deny_tool is still cacheable, so a cache keyed on agent and tool set alone can serve that allow to a later call whose tool call names a denied tool: a caller must key on whether a tool call is present. It is false whenever a matched policy sets any of those nine or a hold_if_signal, regardless of which rule actually produced this decision: Wardryx is the source of truth for whether a decision generalizes beyond the one request that produced it, not the caller. cacheable is independent of decision itself, a cacheable decision can be allow, deny, or hold alike, though a caller's own cache should still never store a hold regardless of cacheable, since a replayed one would hand out a stale approval_id for what looks like a fresh hold. TokenFuse's gateway (crates/gateway/src/wardryx.rs) is the reference implementation: its decision cache gates every store on cacheable and is keyed on (agent_id, tool-set hash, attestation_method), coarser than the full request but no longer blind to attestation. That third field is load-bearing, not cosmetic: a deny_if_unattested-only decision is still under the rule above, since attestation is not one of the three fields that make a decision request-specific, so a cache keyed just on could serve an attested agent's cached to a later, unattested request against the same agent and tool set within the TTL, silently defeating . TokenFuse hit exactly that gap in a concurrent multi-agent test on 2026-07-11 and closed it by folding into the cache key (TokenFuse PR #110). The bug and its fix were both entirely on TokenFuse's side: Wardryx never caches anything itself, it only returns the decision and the hint that tells a caller's own cache what it may safely store.
1. agent -> POST /v1/decide -> wardryx
2. wardryx -> {"decision": "hold", "approval_id": "..."} -> agent
(pending row written to store; nothing blocks)
3. admin -> POST /v1/approvals/{id}/decide {"decision":"grant", ...} -> wardryx
(wardryx mints an approval_token bound to agent_id/run_id/tool set,
and, when the held request carried a tool_call, to that call)
4. wardryx -> {"approval_token": "..."} -> admin
5. agent -> POST /v1/decide (same action, approval_token set) -> wardryx
6. wardryx -> {"decision": "allow"} -> agent
Who decided is the key, not the text. The approval records as decided_by the key that authenticated the decide call, unless a console statement names the person (see the paragraph after this one), written key: and the first 16 hex characters of SHA-256 over the bearer key (the same fingerprint policy_updated carries as key_fp), never the text the request sent. A decided_by in the request body is optional display text, at most 256 bytes with no control character and no bidirectional override; it is kept beside the approver as decided_by_label in GET /v1/approvals, in wardryx approvals and in the approval_granted and approval_denied events, and it is never read as who decided. An approval belongs to the org of the key that made the hold. An admin decides the approvals of its own org; an approval with no org (a row written before org scoping) needs a principal allowed across orgs, see Which agents a key speaks for.
A person can be named, with a statement the console signs. With WARDRYX_APPROVER_JWKS set, a decide may carry approver_statement: a compact JWS signed by the console's device key after the person's passkey assertion. It is ES256, its kid is the RFC 7638 thumbprint of a key in that file, and its claims name the approver (sub), the passkey credential (cred), the approval (aid), the decision (dec) and a short validity (at most five minutes). A statement that does not verify for this approval and this decision is 403 approver_statement_invalid, and the hold stays pending. A verified statement records decided_by as approver: and the approver, and approver_kind as person. With a key set configured, a decision with no statement is refused with 403 approver_statement_required and the hold stays pending; with WARDRYX_ALLOW_KEY_APPROVALS=1 it is recorded as the key, with approver_kind key, and logged each time. With no key set, a decision with no statement is recorded as the key, as before. The statement is the console's word about the person, verified against the console's published key, so the person is believed as far as that console is (CLAUDE.md invariant 43).
Wardryx reads the token from the approval_token field of the /v1/decide body and from nowhere else; no header carries it. An agent behind TokenFuse does not call /v1/decide itself: it resubmits its original request to the gateway with the x-fuse-approval-token header, and TokenFuse puts that value in the approval_token field of its own /v1/decide call.
No connection is ever parked waiting for the human: the agent (or its orchestrator) polls GET /v1/approvals or is notified out of band, and the eventual grant is proven by the signed token, not by wardryx remembering an open request. This mirrors the stateless kill-switch pattern already used elsewhere in the TAIPANBOX stack (TokenFuse).
The token is a compact base64url(claims) + "." + hex(HMAC-SHA256) string, where claims is {agent_id, run_id, tools (sorted), max_cost_usd, exp, nonce}, plus v and tcd when the token is bound to a tool call (below). Verification recomputes the HMAC over the still-encoded payload before decoding anything, checks the expiry (default 10 minutes from grant), checks that the agent/run/tool-set presented at /v1/decide exactly match what was granted, and checks that the presented est_cost_usd does not exceed max_cost_usd. WARDRYX_APPROVAL_SECRET is fail-closed: unset, minting and verifying both refuse rather than accept, since there is no such thing as an unsigned or always-valid token.
Signed by Keyryx (v3). With WARDRYX_APPROVAL_SIGNER=keyryx the grant does not sign with a secret wardryx holds. Keyryx, the key custody service, signs the token with the approval key it holds, and the token is a compact JWS (ES256) whose header names the key's kid. The claims carry the approval id (aid) and the same agent, run, tool set, cost ceiling, and call digest a v2 token carries. The signature is made before the approval is written: if Keyryx cannot sign, the grant answers 503 and the hold stays pending. Verification uses only the key set wardryx holds, fetched from Keyryx's published set once a minute. A kid it does not hold is refused, a key carried in the token is never read, and when Keyryx is unreachable the keys already fetched keep verifying. A v2 token stays accepted only until WARDRYX_APPROVAL_V2_UNTIL; with Keyryx signing and no deadline, v2 tokens are refused.
An approval is for the call a person read. When the held request carries a tool_call, the approval records the call's name, target, arguments_truncated and a digest (never the arguments), so GET /v1/approvals and wardryx approvals show the tool and target being approved, and the granted token carries the same digest in a signed claim (v: 2, tcd). The digest is a sha-256 over the tool name, the target, the sha-256 of the arguments exactly as received (the arguments_sha256 the decision event records) and the truncated flag, each part length-prefixed so none can be moved into another. A token bound to a call is refused for any other call: another argument, target, tool name or truncation, and the same JSON re-spaced, is a different call. It is also refused on a request that carries no tool_call. Under a cost threshold the refusal is the same deny as any invalid token; under a signal hold the other call holds again and gets an approval of its own. A refused attempt does not use up a single-use token. A hold created for a request with no tool_call, and every token minted before this change, keep working exactly as they did: the token carries neither a version nor a digest and binds agent, run, tool set and cost only. Two limits: a call whose arguments were truncated (too large to send) binds only name, target and the flag, and a build from before this change checks a bound token without the digest, so a rollback drops the binding for tokens still inside their TTL.
max_cost_usd is a real ceiling, not a label carried along for reference: it is set to the exact est_cost_usd that triggered the hold (the amount a human is actually approving, not merely the policy's require_human_above_usd threshold it crossed), and /v1/decide rejects any later presentation of the token whose est_cost_usd exceeds it, even with a correct signature, an unexpired token, and a matching agent/run/tool set. A legitimate retry at the same or a lower cost still succeeds, since the check is "at or under the ceiling", not "exactly equal". This makes a granted approval narrower than it might look: it authorizes spend up to a specific dollar figure for that agent/run/tool set, not blanket permission for whatever the agent tries next under the same token. A token minted before max_cost_usd existed decodes it as 0, and 0 is deliberately never treated as "no ceiling": such a token fails closed against any positive est_cost_usd, the same as a token whose ceiling was explicitly set to zero.
Since 1.0 a granted token is single-use: the first /v1/decide call that redeems it records the redemption, and a second presentation of the same token returns a fresh hold rather than an allow, because replaying an approval is the whole attack. WARDRYX_APPROVAL_SINGLE_USE=false restores the pre-1.0 behaviour, a token valid for every /v1/decide call within its TTL window, so steps 5-6 above can repeat; an unset or unparsable value is single-use, never silently reusable.
Single-use redemption tracking has the same durability split as approval holds themselves: with -db/WARDRYX_DB set, TryRedeem is a Postgres INSERT .. ON CONFLICT DO NOTHING (atomic across every wardryx instance sharing that database); with no -db, redemptions live in one process's memory only, so single-use is enforced per-process, not across multiple wardryx instances behind a load balancer. serve prints a startup warning to stderr whenever single-use is on (anything but an explicit WARDRYX_APPROVAL_SINGLE_USE=false) with no -db, so this caveat is never silent.
Resolving a hold out of band means its failure mode is silence: the approval sits pending, the agent's action stays blocked, and nothing anywhere says so. A background sweep reports exactly that. Once a hold has been pending longer than WARDRYX_APPROVAL_UNANSWERED_AFTER (a Go duration, default 15 minutes), Wardryx emits one approval_unanswered event for it, severity high, carrying the approval_id, how many seconds it has waited, and the threshold it crossed.
A sweep rather than a check on the request path, and that is the design rather than an implementation detail: this condition is defined by the absence of activity, so a check that only runs when a request arrives cannot see the case it exists for. The hold nobody answered is exactly the hold nobody is asking about.
It reports and never decides. The hold is left precisely as the engine left it: nothing here grants, denies or expires an approval on a timer. A timeout that quietly became a denial would be a decision made by a clock, and one that quietly became an allow would be worse; either is a behavior change hiding inside an observability feature.
One event per hold per process lifetime, so an approval nobody answers all afternoon is reported once instead of on every tick. Setting the variable to 0 turns the sweep off entirely, a real choice for an operator who watches GET /v1/approvals themselves, and serve says so on stderr rather than going quiet about it. An unparsable value is treated as off rather than as the default, since this is the one feature here that writes events on its own initiative.
A policy belongs to an org. A policy may carry org, the org a WARDRYX_KEYS entry names. A policy with an org governs only the decisions that org's keys ask for; a policy without one applies across orgs, to every decision, which is what every policy did before the field existed (and why a policy without it keeps its policy_version). A policy an admin writes through PUT /v1/policies/{id} belongs to the admin's org, whatever the body says; a body naming another org is a 403. A policy across orgs, and a policy of another org, can be written, replaced or deleted only by a principal allowed across orgs. An admin lists and reads its own org's policies and those across orgs; another org's policy reads as 404. Every decision event records the org it was asked in, so wardryx replay asks the same question. With one org in WARDRYX_KEYS nothing here changes: its admin is the principal allowed across orgs, and a policy it writes without an org applies across orgs as before.
-policy/WARDRYX_POLICY (above) loads policy files once at startup and never hot-reloads them -- that stays exactly true. On top of it, four admin-only routes let an operator manage a second, additional layer of policies at runtime, without a restart:
GET /v1/policies list the store-managed policies of the caller's org and those across orgs
GET /v1/policies/{id} fetch one
PUT /v1/policies/{id} create or replace one (body: one policy.Policy document, e.g. the YAML example above as JSON; data after the policy is refused with 400, and a key the policy does not declare is refused with 400 naming it, and nothing is stored; `id` and `updated_at` as a GET returns them are accepted and ignored, so a GET body can be sent back unchanged)
DELETE /v1/policies/{id} remove one
The file-loaded set is a permanent floor: every one of these routes recompiles file-loaded policies + everything currently in the store together (internal/api.ComputePolicySet) and swaps the result into pdp.Engine atomically (Engine.SetPolicies, an atomic.Pointer[policy.Set] under the hood) -- an API write can add, replace, or remove a store-managed rule, but it can never make a file-loaded one disappear, and the file-loaded policies aren't individually addressable through this API at all (they have no id). A -db/WARDRYX_DB Postgres store persists what's written here across a restart (a new policies table, additive migration); with no -db, an in-memory store still accepts writes for the life of the process, same "lost on restart" caveat every other in-memory piece of state already carries.
Every PUT/DELETE is validate-then-apply, never partial: the whole prospective combined set is compiled (policy.Compile) before anything is persisted or swapped, so a request that would produce an invalid policy (empty target, a negative threshold, a bad glob) is rejected with both the store and the live Engine left exactly as they were -- the same "malformed policy is a hard error" rule the file path already enforces, now also covering runtime writes. A successful write also emits a policy_updated agent-event (severity high, source: "wardryx") if an event log is configured.
curl -X PUT localhost:8090/v1/policies/ops-guard \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{"target": "agent://acme.example/ops/*", "deny_tool": ["shell_exec"]}'
curl localhost:8090/v1/policies -H "Authorization: Bearer $ADMIN_KEY"
curl -X DELETE localhost:8090/v1/policies/ops-guard -H "Authorization: Bearer $ADMIN_KEY"
Two things are true at once with -db pointing at a Postgres that has gone away, and a launcher needs to see both. /v1/decide keeps answering from the policy set already in memory: the data plane needs nothing from the store for an allow or a deny, and that is the right behaviour, so GET /healthz stays 200 for as long as the process runs. A liveness check that restarted wardryx for a store outage would trade a working decision point for a crash loop.
What cannot work without the store is a policy write, and the honest answer to that is a different route:
GET /readyz 200 {"store":"ok"} the store answered a ping
503 {"store":"unreachable"} it did not, within the store deadline
No credential, no body, nothing read from the request: a compose healthcheck, a Kubernetes readinessProbe, or a plain curl -fsS reads the status and knows whether this wardryx is healthy or deciding from memory. Point a livenessProbe at /healthz and a readinessProbe at /readyz, and give the readiness probe a timeout above the store deadline (three seconds) or it will time out before the 503 arrives, which reads the same but says less.
Every store call a PUT or DELETE /v1/policies/{id} makes shares that same three-second deadline, so a write against a store that has gone away answers 503 with a reason (the policy store did not answer within 3s while writing the policy; the change is not in force, or is unreachable when the network said no outright) instead of holding the request until the kernel gives up on a dial nothing refuses. The reason is wardryx's own sentence; the driver's text goes to the log, and it goes there once per outage (policy store unreachable while ...) and once more when the store is back (policy store reachable again), not once per request, so the line that matters is not buried under a launcher retrying every second.
"The change is not in force" is a statement about this process: its live set was not swapped. A write the store applied after wardryx stopped waiting is restored on the next start like any stored policy, and until then the store and the live set differ, so retry the write once /readyz is 200 rather than assuming either state. Reads under /v1/policies, /v1/approvals and /v1/status are not bounded by this deadline.
/v1/policies answers a narrower question than it looks like it does: it lists the store's
operator-managed policies only. A deployment whose rules come entirely from a -policy file sees an
empty /v1/policies list, while every one of those file-loaded rules is being enforced on every
/v1/decide call. A console or health check reading only /v1/policies concludes "no policies,
everything is allowed" and reports enforcement as off while it is on - the most damaging thing a posture
check can do, since an operator who catches that once stops trusting every other warning the same
console shows.
GET /v1/status exists to prevent exactly that drift. It answers how many rules are actually being
enforced right now, and where they came from, independent of whether any of them are visible through
/v1/policies:
GET /v1/status
Authorization: Bearer <key>
{
"policy_version": "3f9a2b7c1d4e",
"base_policies": 3,
"store_policies": 1,
"effective_policies": 4
}
base_policies is what -policy/WARDRYX_POLICY loaded at startup (never individually addressable
through /v1/policies); store_policies is the same count /v1/policies would list; effective_policies
is their sum, the combined set /v1/decide actually evaluates against. effective_policies: 0, and only
that, means every request really is allowed. Any authenticated key can read it, admin, viewer or pep. The
counts are those of the policies that govern the caller's decisions: its org's and those across orgs, and
every one for a principal allowed across orgs, which with one org is every policy.
A policy can hold a call, for a person to approve, because a typed fact about it says so: some classifier is at least 80% sure the pending tool call is destructive. The fact is a signal (name, value, probability, source, answer_id), and the rule that reads it is hold_if_signal:
name: support-risk
target: "agent://acme.example/support/*"
hold_if_signal:
name: action.risk_class
values: [destructive, external_send, financial]
min_probability: 0.8
A signal can add a hold and nothing else. hold_if_signal runs after every deny rule and after the cost gate, so it can turn an allow into a hold and change no other verdict: it never turns a deny into anything, never replaces another rule's hold or its reason, and never allows. A policy that tries to make a signal deny (deny_if_signal, or a decision, action or on_match key inside hold_if_signal) is refused at load and at PUT /v1/policies/{id}, with a message saying a signal can only hold. All three of name, values and min_probability are required, and when_absent is optional (see below); a threshold of 0.8 holds at 0.8 and above. A granted approval_token lifts a signal hold exactly as it lifts a cost hold (single-use by default); a token that does not verify holds again and never denies. A decision under a hold_if_signal policy is never cacheable, whatever it decided (the hold, an approved allow, an allow the rule did not fire on, a deny), because a signal belongs to one call's arguments and a cache keyed on the agent and tool set cannot see them.
A signal is an input, never something wardryx fetches. The caller of /v1/decide sends signals (at most 16) and, optionally, the tool_call they are about (name, arguments as raw JSON up to 12 KiB, target, and arguments_truncated when the arguments were too large to send, in which case none are sent). An enforcement point must cap the arguments itself: arguments over the cap are refused with 400, not truncated. Wardryx believes a signal the way it believes chain_proven: a false signal costs a person a delay and cannot cost anything else, and the source is the caller's own claim, recorded and never branched on. A missing signal is another matter, and the next paragraph is about it. Who produces signals is deployment configuration outside this service: for example a proxy placed in front of wardryx that classifies tool calls and adds a signal to the request it forwards. Nothing in internal/pdp, internal/policy or internal/replay makes a network call, and scripts/decision-path-purity.sh fails if anything they stand on in this module imports net/http or os/exec. A deployment with no signal producer runs exactly as it did before: hold_if_signal simply never fires.
A missing signal. A signal that never arrives gives a rule nothing to read, and by default the call goes on as if it had been harmless. It goes missing when the enforcement point leaves out arguments over its cap (TokenFuse's MCP broker sends such a call with arguments_truncated: true and no arguments, and a classifier does not answer about a call it cannot read), when the producer's own budget is spent, when it times out, and when it is down; the first is the caller's choice, since padding a call past the cap is enough. when_absent: hold closes that:
hold_if_signal:
name: action.risk_class
values: [destructive, external_send, financial]
min_probability: 0.8
when_absent: hold
With it, a request that carries a tool_call and no signal named action.risk_class is held for a person, and the reason says which signal was missing, for which call, and whether its arguments were sent. A signal of that name that did arrive, with any value, is an answer and is read as above. A request with no tool_call (a model call, where no call has been chosen yet) is never held for a missing signal. Only an approval token granted for that call lifts the hold: a token granted for a hold that showed no call does not. when_absent takes allow (the default, and what a rule without the key does, with the same PolicyVersion) or hold; deny is refused at load and at PUT, because a missing signal can only hold like any other, and so is the key written with no value. The cost of hold is that every tool call the producer does not answer waits for a person, a producer outage included. One limit, named: a call whose arguments were not sent has none in its digest, so every padded call to the same tool and target in one run has the same digest, and a person who approves one approves it without seeing what it does. A token granted for one padded call therefore fits the next one too. Under single-use, the default, it lets exactly one of them through; with WARDRYX_APPROVAL_SINGLE_USE set to false it lets every one of them through for the token's lifetime. Wherever a policy sets when_absent: hold, leave single-use on. This repository ships no policy, so whether a deployment holds on a missing signal is decided by the policy its launcher writes.
What is recorded. The decision event and the approval context carry every signal the decision read (name, value, probability, source, answer id), so a person deciding the hold sees who said what, and wardryx replay feeds the recorded signals back: a hold caused by a signal reproduces with whatever produced it unreachable. The tool call is recorded as its name, its target, a sha-256 of its arguments exactly as received, whether they were truncated, and the digest an approval of the call is bound to, never the arguments, since they can carry customer data. A decision that carried neither is recorded as it always was. Replay also feeds the recorded tool call's name, target and truncated flag back, so a hold on a missing signal reproduces from the record.
WARDRYX_OTLP_ENDPOINT (or -otlp-endpoint) turns on one exported span per /v1/decide outcome -- allow, deny, or hold -- posted to <endpoint>/v1/traces. internal/otel builds the OTLP/HTTP-JSON payload directly (no OpenTelemetry SDK dependency), mirroring TokenFuse's own exporter (crates/gateway/src/otel.rs): small, hand-rolled, and a fire-and-forget POST from a background goroutine, so a slow or unreachable collector never adds latency to /v1/decide and a delivery failure is dropped silently, never surfaced to the caller.
Every decision belonging to one run_id shares a trace id (derived from run_id alone, SHA-256-based), so a run's hold followed later by an allow once approved shows up as one trace with two spans in Grafana/Datadog/Honeycomb, not two unrelated traces. Each span carries wardryx.agent_id, wardryx.run_id, wardryx.decision, and, when present, wardryx.reason, wardryx.policy_version, and wardryx.tool_names. The trace id derivation is Wardryx-internal, not shared with TokenFuse's own (Rust's hasher has no portable Go equivalent), so spans from the two services for the same run_id correlate today via the shared run_id attribute, not automatic trace grouping across services.
Empty (the default): zero cost, no goroutines, no allocation beyond the config read at startup -- identical to the exporter never having been added.
Wardryx itself never acts: it is the enforcement point, TokenFuse's proxy or any MCP gateway calling /v1/decide, that chooses how much weight to give a decision. The pattern this stack follows has three modes at that call site, plus an explicit choice for what happens when Wardryx can't be reached at all.
| Mode | Behavior | Typical use |
|---|---|---|
off | /v1/decide is never called; every action proceeds unchecked | local dev, a policy-free environment, or a service with no loaded policy |
shadow | /v1/decide runs on every call and the result is recorded, but the action always proceeds | validate a new policy set against real traffic before it can ever block anything |
enforce | a deny stops the call outright; a hold pauses it until a signed approval_token is presented | production: the PDP's decision is binding |
| If the PDP is unreachable | Behavior | Risk |
|---|---|---|
| fail-open | the enforcement point treats an unreachable Wardryx as allow | availability wins; an outage silently disables policy |
| fail-closed | the enforcement point treats an unreachable Wardryx as deny | policy wins; an outage blocks every governed action until Wardryx recovers |
This mirrors Wardryx's own stated defaults for its own availability: with no -policy/WARDRYX_POLICY configured, serve starts anyway and allows every request rather than refusing to start, an explicit, logged choice, not a silent one (see Security). Approval tokens follow the same "explicit over implicit" rule: single-use by default since 1.0, so each granted token redeems exactly once before falling back to a fresh hold, and reuse for the full TTL (10 minutes) happens only when an operator sets WARDRYX_APPROVAL_SINGLE_USE=false (see Stateless human-in-the-loop).
Beyond the decision engine and the approval flow above, Wardryx ships:
internal/api): POST /v1/decide, POST /v1/filter-tools (experimental: Decide's deny_tool and allow_tool rules applied to each offered tool alone, for a caller measuring what a policy would remove before anything enforces on the answer; see compat/1.0.json), POST /v1/approvals/{id}/decide (admin only), GET /v1/approvals (org-scoped), GET /v1/status (see GET /v1/status), the admin-only /v1/policies policy-as-code routes (see Policy-as-code), GET /healthz (liveness) and GET /readyz (readiness, see When the store is down). Bearer-key auth mirrors the Cloud plane's key:org[:role] convention (TokenFuse crates/cloud/src/keys.rs), reimplemented in Go for the same wire format, with an optional fourth segment naming the agents a key speaks for (see Which agents a key speaks for).internal/store): Postgres via pgx/v5 with an embedded, idempotent schema.sql, or an in-memory store when no DSN is configured. Both implementations satisfy the same Store interface.source: wardryx): optional NDJSON agent-event output (WARDRYX_EVENTS_PATH) via agent-stack-go/event: policy_allow, policy_deny, approval_requested, approval_granted, approval_denied, approval_timeout (an agent presenting an approval token whose window had already closed, so usually a human did decide and the agent came back late), approval_unanswered (a hold nobody decided, see The hold nobody decided), (a runtime write), and (a request that named an agent its key may not speak for, refused with no decision; its carries the , the and , the key's , and , and its is wardryx's own, never the agent it was made in the name of). Every decision event's carries the it was asked in. and carry ( and the key's fingerprint), , and when the decision was sent with one. 's carries , , , (the org of the policy written or removed, empty for one across orgs), (the organisation that made the write) and : the first 16 hex characters of SHA-256 over the bearer key that made it, so the record names the key without holding it. To match a to a key, run on each candidate key. The key itself is never written to the event file or anywhere else. Events now carry the SPEC §6.5 chain; verify a stream with . (a verified agent token names a different agent than the request; names the , the , the , the key's , and , and its is wardryx's own).cmd/wardryx/main.go CLI: serve | check | approvals | replay | version
internal/policy policy model, YAML/JSON loader, glob matcher, PolicyVersion
internal/pdp Engine.Decide: the pure decision algorithm
internal/approval approval_token minting/verification (HMAC-SHA256) + hold/decide orchestration
internal/store Store interface; Postgres (pgx/v5, embedded schema.sql) + in-memory; approvals + policies
internal/api net/http API: bearer auth, /v1/decide, /v1/approvals, /v1/policies, /healthz, /readyz
internal/passports directory loader for the offline `check` command (agent-stack-go/passport)
internal/otel OTLP/HTTP-JSON span export, one per /v1/decide outcome
internal/config WARDRYX_* environment variables, read once at startup
Design principles, held as hard rules:
pdp.Decide is a pure function of the loaded policy set and the request. No LLM anywhere in the decision path.WARDRYX_APPROVAL_SECRET means no token can ever mint or verify: never an implicit "allow." With WARDRYX_APPROVAL_SIGNER=keyryx, a grant needs Keyryx to sign, and a grant that cannot be signed stays pending./v1/policies write that would produce an invalid combined policy set is rejected before anything is persisted or the live Engine is touched -- the same "malformed policy is a hard error" rule the file path enforces, extended to runtime writes.Build from source (Go 1.27+):
make build # -> ./bin/wardryx
Every image is signed keyless with Sigstore and carries a build-provenance
attestation and an SBOM. With cosign and gh installed:
cosign verify ghcr.io/taipanbox/wardryx:<tag> \
--certificate-identity-regexp '^https://github.com/TAIPANBOX/wardryx/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify oci://ghcr.io/taipanbox/wardryx:<tag> -R TAIPANBOX/wardryx
Releases through v0.1.0 have none of this; the next tag is the first to carry it.
make build
# serve: HTTP policy decision API
# refuses to start with no WARDRYX_KEYS and no WARDRYX_ALLOW_DEVKEY: see the
# "Authentication" section below.
WARDRYX_ALLOW_DEVKEY=1 ./bin/wardryx serve -addr 127.0.0.1:8090 # loopback, in-memory store, no policy (allows everything)
WARDRYX_KEYS="$KEYS" ./bin/wardryx serve -addr :9000 -policy ./policies -db "$DSN" -events ./events.ndjson -otlp-endpoint http://localhost:4318
# check: offline dry-run over a directory of Agent Passports
./bin/wardryx check ./passports ./policies/finance.yaml
./bin/wardryx check -format json ./passports ./policies/finance.yaml
# approvals: list pending/decided approvals from Postgres, naming the tool call each one is about
./bin/wardryx approvals -db "$DSN"
# replay: what a candidate policy would have done to decisions already taken
./bin/wardryx replay -events ./events.ndjson -archive ./policy-archive
./bin/wardryx replay -events ./events.ndjson -archive ./policy-archive -policy ./policies/candidate.yaml
./bin/wardryx version
Run against the bundled fixtures:
make check # offline dry-run over cmd/wardryx/testdata
make serve # then curl -H "Authorization: Bearer devkey" localhost:8090/healthz
Example policy file (policies/finance.yaml):
name: finance-guardrail
target: "agent://acme.example/finance/*"
deny_tool:
- send_wire_transfer
- delete_account
allow_domains:
- payments.acme.example
require_human_above_usd: 500
max_steps: 40
deny_if_unattested: true
Editing a policy is a blind act. wardryx replay takes the decisions that
already happened and puts each recorded question back to the PDP, first
against the version it was decided under and then against a candidate set.
That first pass is what makes the second one worth anything. Decide is a
deterministic function of the loaded set and the request, so a recorded
question put back to the same set must return the same answer. A
counterfactual is offered only for the rows that reproduced; everything else
is counted and named.
Replayed 4 decision(s) from ./events.ndjson
reproduced 4
approval-decided 0
approval-spent 0
approval-refused 0
not archived 0
unreadable 0
diverged 0
Against ./policies/candidate.yaml, 2 of 4 replayable decision(s) change:
deny -> allow agent://acme.example/finance/bot1 run r-egress
was: domain "payouts.partner.example" is not allowed by policy "finance-guardrail"
now: allowed: request satisfies all matched policy rules
hold -> allow agent://acme.example/finance/bot1 run r-costly
was: estimated cost $420.00 exceeds policy "finance-guardrail" threshold $100.00
now: allowed: request satisfies all matched policy rules
The six rows that are not a plain reproduction each mean something specific:
| row | meaning |
|---|---|
approval-decided | the PDP held it and a human answered. The approval token is deliberately never recorded, so replay reaches the hold, not the answer, and the counterfactual is measured against the hold |
approval-spent | the PDP held it again because the approval a human gave had already been used once (the single-use default since 1.0.0). Same hold as replay reaches, a reason only the unrecorded token explains; the counterfactual is measured against the hold, as for approval-decided |
approval-refused | the PDP denied it because what the caller presented as an approval did not verify (expired, forged, bound to another agent, run or tool set). Replay presents nothing and reaches the hold; the counterfactual is measured against the hold, as for the two rows above |
not archived | the policy version it names was never kept, so there are no rules to put the question to. Set WARDRYX_POLICY_ARCHIVE |
unreadable | recorded before the emitter carried the decision input, so the question was never written down |
diverged | replaying it against the version it itself names disagrees with the record. The record and this build no longer agree about the past, and the command exits non-zero |
A count of changes never appears without the count of decisions the run could not examine: a change tally that silently omitted them would read as coverage it does not have.
It replays a decision, not the agent. If a candidate turns a refusal into an allowance, everything the agent would have done next is a world nothing recorded. The honest question is aggregate: over this history, which decisions would the candidate have taken differently.
When WARDRYX_EVENTS_PATH is set, every allow, deny and hold is written to the event file, and an allow or a hold is written before it is returned. WARDRYX_RECORD_FAILMODE says what happens when that write fails (invariant 40, decided 2026-10-18 for finding F-011 of the 2026-10-17 review):
closed, the default. An allow or a hold whose record was not written is not returned. /v1/decide answers 503 with {"error": "record_unavailable"}. A single-use approval token redeemed for that allow stays spent, so a person grants again. A refused hold stays pending in the approval queue, where nothing waits on it.open. The allow or the hold is returned anyway, and /readyz answers 503 with "record": "failing" until a write succeeds again. The log carries one line when the record starts failing and one when it writes again.A deny is returned whether or not its record was written. It starts no action, so refusing it would trade a known refusal for an unknown one. Any value of WARDRYX_RECORD_FAILMODE other than open is closed, and serve warns at startup when open is chosen.
Named limits: with no event file configured there is no record, so nothing can fail to be written. The records of approval grants and denials and of policy writes are still written fail-open: the approval's own row in the store is the durable record of a grant, and the allow that uses its token is refused under closed if its record cannot be written. scripts/decision-record-is-checked.sh reads the source to hold the two refusals in place; the tests in internal/api prove their behaviour.
Every WARDRYX_* variable is read once at process startup (internal/config), never per-request. Each serve flag falls back to its environment variable when the flag itself is left unset. The Default column is what applies when the variable is unset, and TestReadmeEnvTableStatesTheDefaultsTheCodeUses checks every cell against the code; TestReadmeProseNeverContradictsTheCodeDefaults holds the rest of this README to the same defaults.
| Variable | Flag | Default | Meaning |
|---|---|---|---|
WARDRYX_ADDR | -addr | :8090 | Listen address |
WARDRYX_KEYS | (none) | (empty) | key:org[:role[:agents]],... bearer keys; with no valid entry, serve refuses to start unless WARDRYX_ALLOW_DEVKEY is set (see below) |
WARDRYX_ALLOW_DEVKEY | (none) | false | Explicit opt-in to the insecure dev key devkey -> default/admin when WARDRYX_KEYS has no valid entry; refused outright (not merely warned about) together with a non-loopback -addr |
WARDRYX_DB | -db | (empty) | Postgres DSN; empty uses the in-memory store |
WARDRYX_POLICY | -policy | (empty) | Policy file or directory (YAML/JSON); empty allows every request |
WARDRYX_EVENTS_PATH | -events | (empty) | NDJSON agent-event output path; empty disables events |
WARDRYX_POLICY_ARCHIVE | - | (empty) | Directory keeping every policy set this process makes effective, named by its policy_version; empty disables it, and a decision recorded without it names a version nothing can produce later |
WARDRYX_APPROVAL_SECRET | (none) | (empty) | HMAC key for v2 approval tokens; unset, a grant with no Keyryx signer fails closed and no v2 token verifies |
WARDRYX_APPROVAL_SINGLE_USE | (none) | true | true, or any value that does not parse as a bool, makes each granted token allow exactly one /v1/decide call; only an explicit false keeps a token reusable for its full TTL (see Stateless human-in-the-loop) |
WARDRYX_APPROVAL_UNANSWERED_AFTER |
The [:role] segment of a WARDRYX_KEYS entry is one of admin (every endpoint, including POST /v1/approvals/{id}/decide, for its own org), cross-org-admin (every endpoint, for every org), pep (an enforcement point: /v1/decide, /v1/filter-tools, and what a viewer reads) or viewer (GET /v1/approvals and GET /v1/status), and defaults to admin when the segment is omitted. The [:agents] segment is everything after the third colon: agent patterns separated by |, in a policy target's glob syntax, for example gw-secret:acme:pep:agent://acme.example/*|agent://ops.acme.example/runner.
serve fails closed: with no valid WARDRYX_KEYS entry and no WARDRYX_ALLOW_DEVKEY opt-in, it refuses to start rather than either authenticating nobody silently or, as it did before this section was written, installing the built-in devkey -> default/admin credential on whatever address -addr bound (every interface, by default). Set WARDRYX_KEYS to a real key:org[:role] spec for anything but a local run, or set WARDRYX_ALLOW_DEVKEY=1 for a local, loopback-only development run (make serve does exactly this, binding 127.0.0.1:8090). The devkey fallback together with a non-loopback bind is refused outright rather than warned about, since that pairing is precisely what let a self-labelled pod turn a policy deny into allow in this stack's own history.
The agent_id of a /v1/decide or /v1/filter-tools request is bound to the key that asks. A request naming an agent outside the key's allowance is a 403 that echoes nothing it was sent, with no decision, no hold and no approval token read or spent, and one agent_assertion_refused event. The rule:
cross-org-admin key speaks for any agent.pep or admin key speaks for any agent of its own org. The enforcement point authenticated the agent before it asked, and an admin can rewrite the rules that decide, so binding either buys nothing within its org. Wardryx knows which agents are an org's only when every key in WARDRYX_KEYS belongs to one org, where every agent is; with more than one org, such a key speaks for no agent until its entry lists the agents of its org.viewer key speaks for no agent.The same "one org" reading decides who is allowed across orgs: a cross-org-admin key, and, when every key belongs to one org, that org's admin, since there is no other org to cross into. A cross-org-admin key does not count as an org of its own, so adding one never turns a one-org deployment into a multi-org one. serve prints, at start, every key that speaks for no agent (by fingerprint) and, with more than one org, that admins are confined to their org.
What an existing deployment changes. A key the enforcement point uses (TokenFuse's gateway and its MCP broker, scopyx) and that carries the viewer role today must carry pep, or every /v1/decide it asks is a 403: gw-secret:default:viewer becomes gw-secret:default:pep. An admin key, the local devkey, and an entry that already uses pep keep working unchanged in a one-org deployment. A deployment with more than one org lists, on every pep and admin key that asks for agents, the agents of its org.
make test # go test ./...
make test-race # go test -race ./...
make lint # go vet + staticcheck + gofmt check
make test-integration # Postgres-backed internal/store tests; needs DATABASE_URL
CI runs two more gates that have no local make target: govulncheck ./... (known-vulnerability
scanning against the Go vulnerability database) and gosec ./... (static analysis for common Go security
mistakes), both in the security job in .github/workflows/ci.yml.
The decision engine's table tests (internal/pdp/pdp_test.go) cover every rule and its boundary: allow; deny on a denied tool, on an unattested agent, at and over max_steps, and on a domain outside allow_domains; the empty-domains no-op; hold over require_human_above_usd; allow with a valid approval token; deny with an expired or wrong-binding one; and deny_above_usd as a hard ceiling that a cleanly-verifying token still cannot cross and that fires before the hold. Separate suites pin the normalization traps (a deny_tool entry matched whatever the case or trailing whitespace, an attestation_method of none, NONE, n/a or a bare space treated as unattested), the cacheable rule field by field, PolicyVersion stability, and the offline check path. The typed-signal suites (internal/pdp/signal_test.go, internal/policy/signal_test.go, internal/replay/signals_test.go, internal/api/signals_test.go) hold a signal to adding a hold and nothing else, over two hundred seeded random policies as well as by case, and hold the record and the replay to what a decision actually read. The allow-list suites (internal/pdp/allow_tool_test.go, internal/policy/allow_tool_test.go, internal/replay/allow_tool_test.go, internal/api/allow_tool_test.go) hold allow_tool to its one-sided failures: a renamed tool, an empty or blank list, two lists that must intersect, and a tool call that names a tool the list leaves out.
Wardryx is itself a security-relevant component, so a few of its own defaults are worth stating plainly:
-policy/WARDRYX_POLICY configured, serve starts anyway and allows every request (an explicit, logged choice, not a silent one).-db/WARDRYX_DB configured, approval state lives only in process memory and is lost on restart.serve and check refuse to start rather than silently loading a smaller rule set than intended.WARDRYX_APPROVAL_SECRET unset fails every mint and verify closed; there is no fallback to an unsigned or always-valid token. With WARDRYX_APPROVAL_SIGNER=keyryx, a grant is signed by Keyryx and verifies from a key set wardryx caches, so the secret is needed only for HMAC tokens still inside their deadline.-db/WARDRYX_DB are only enforced within that one process, not across multiple wardryx instances sharing the load; serve warns about this combination at startup rather than silently giving weaker guarantees than the name implies./v1/policies grants whoever holds an admin bearer key the ability to change enforcement rules at runtime, same trust level /v1/approvals/{id}/decide already requires -- there is no separate, narrower role for policy management. A leaked admin key is a policy-tampering risk, not just an approvals-tampering one. With more than one org it is confined to its org's policies and approvals; a cross-org-admin key is not.chain_proven and attestation_method are still the enforcement point's word, believed as before.agent:// glob targeting, stable PolicyVersion)deny_tool, allow_tool, allow_models, deny_if_unattested, max_steps, allow_domains, require_human_above_usd, deny_above_usd (a hard ceiling no approval can cross), the chain rules (max_chain_depth, require_root_principal, deny_if_chain_unproven) and hold_if_signal/v1/decide takes caller-supplied signals and a tool_call, and hold_if_signal can add a hold and never a deny; with when_absent: hold it also holds a tool call that arrived with no signal, lifted only by an approval of that call (see Typed risk signals)allow_models names the models a policy's agents may use, and a model call naming any other model, or none, is denied; tool calls and egress checks are not judged, and no decision under a list is cacheable (see Decision engine)WARDRYX_KEYS entry may list the agents it speaks for, the approver recorded is the deciding key unless a console statement names the person, and a policy may carry an org that confines it to that org's decisions (see Which agents a key speaks for)/v1/decide takes an optional identity_source (how the enforcement point knew the agent), and a policy's require_identity_source denies a request whose source it does not list; a request that sends none is none (see )wardryx checknone| HTTP 403 Forbidden; stateless, nothing blocks waiting on a human |
approval_id issued; to redeem the grant, resubmit the same call with the token in the approval_token field of the /v1/decide body (behind TokenFuse an agent sends it in the x-fuse-approval-token header, and TokenFuse forwards it in that field) |
cacheable:true(agent_id, tool-set hash)allowdeny_if_unattestedattestation_methodcacheablepolicy_updated/v1/policiesagent_assertion_refuseddatarouteasserted_agent_idasserted_run_idorgrolekey_fpagent_iddataorgapproval_grantedapproval_denieddecided_bykey:key_fpdecided_by_labelpolicy_updateddataactionpolicy_idpolicy_versionpolicy_orgdecided_bykey_fpkey_fpprintf %s "$KEY" | shasum -a 256 | cut -c1-16prev_hashagent-conform -chain <file>identity_mismatchdatarouteasserted_agent_idtoken_subjectorgrolekey_fpagent_idinternal/otel): optional one-span-per-decision export to an OTLP/HTTP collector (WARDRYX_OTLP_ENDPOINT), see OTLP export.cmd/wardryx): serve, check (an offline dry-run over a directory of Agent Passports), approvals (list from Postgres), replay (put recorded decisions back to the engine, see Replay), version.| (none) |
15m |
How long a hold may sit undecided before one approval_unanswered event is raised for it; a Go duration; 0 turns the sweep off (see The hold nobody decided) |
WARDRYX_APPROVAL_SIGNER | (none) | (empty) | Where a granted approval token is signed: empty signs the HMAC token under WARDRYX_APPROVAL_SECRET; keyryx signs an ES256 token through Keyryx and needs WARDRYX_KEYRYX_URL; any other value refuses to start |
WARDRYX_KEYRYX_URL | (none) | (empty) | The Keyryx custody endpoint an ES256 approval signer calls: an http or https URL, or unix:///path for its socket |
WARDRYX_KEYRYX_CREDENTIAL_FILE | (none) | (empty) | A file holding the bearer key wardryx presents to Keyryx over http; a socket needs none |
WARDRYX_AGENT_TOKEN_ISSUER | (none) | (empty) | The exact iss of the agent tokens wardryx verifies. Setting it turns verification on, and then the other four settings are required, or the process refuses to start |
WARDRYX_AGENT_TOKEN_JWKS_URL | (none) | (empty) | Where the key set for agent tokens is polled, an http or https URL (vouchryx publishes it). One of this and WARDRYX_AGENT_TOKEN_JWKS_FILE is required |
WARDRYX_AGENT_TOKEN_JWKS_FILE | (none) | (empty) | A file holding the key set for agent tokens, re-read on each refresh. Used with, or instead of, the URL |
WARDRYX_AGENT_TOKEN_AUDIENCES | (none) | (empty) | A comma-separated list of the audiences this wardryx accepts an agent token for. Required with the issuer |
WARDRYX_AGENT_TOKEN_REVOCATION_URL | (none) | (empty) | An http or https URL for the revocation list, polled every 30 seconds. With none, no token is revoked by a list |
WARDRYX_AGENT_TOKEN_DOOR_ORIGINS | (none) | (empty) | The scheme://host:port origins of the doors whose DPoP proofs are accepted, comma-separated. Required with the issuer: a proof's htu must name one of them |
WARDRYX_AGENT_TOKEN_REQUIRE_PROOF | (none) | (empty) | Unset requires a DPoP proof with every agent token (a missing proof is 403 agent_token_proof_required); 0 turns that off, with a warning at startup |
WARDRYX_AGENT_TOKEN_PROOF_MAX_AGE_SECONDS | (none) | 60 | How far a DPoP proof's iat may sit from now, either way. From 1 to 300 |
WARDRYX_APPROVER_JWKS | (none) | (empty) | The file holding the JWK set of the console keys whose approval statements wardryx verifies. Unset, a statement in a decide is refused and every decision is recorded as the key that made it. Set, a decision with no verified statement is refused with 403 approver_statement_required and its hold stays pending, unless WARDRYX_ALLOW_KEY_APPROVALS=1. Refused at start if the file does not read |
WARDRYX_ALLOW_KEY_APPROVALS | (none) | false | Set to exactly 1, it lets a decision with no verified approver statement be recorded as the bearer key that made it, while WARDRYX_APPROVER_JWKS is set. Each such decision is logged, and the start says so. With WARDRYX_APPROVER_JWKS unset it changes nothing |
WARDRYX_APPROVAL_V2_UNTIL | (none) | (empty) | An RFC 3339 instant after which the HMAC tokens of earlier grants are refused; with Keyryx signing and no value, those tokens are refused from the start |
WARDRYX_OTLP_ENDPOINT | -otlp-endpoint | (empty) | OTLP/HTTP endpoint for decision spans (see OTLP export); empty disables it |
WARDRYX_RECORD_FAILMODE | (none) | closed | What an allow or a hold does when its record cannot be written: closed refuses it (503 record_unavailable), open returns it and /readyz answers 503 with the record named as failing; any value other than open is closed (see The record of a decision) |
WARDRYX_AGENT_TOKEN_ISSUER set, wardryx verifies an agent's own token and the DPoP proof sent with it, rather than trusting the enforcement point's word for who the agent is (see Decision engine)WARDRYX_APPROVER_JWKS set, a grant or a denial may carry a statement the console signed, and the approval records that person rather than the key; with a key set, a decision with no statement is refused unless WARDRYX_ALLOW_KEY_APPROVALS=1 (see Stateless human-in-the-loop)WARDRYX_EVENTS_PATH set, an allow or a hold whose record cannot be written is refused with 503 record_unavailable unless WARDRYX_RECORD_FAILMODE is open, which returns it; /readyz answers 503 while a record write is failing (see The record of a decision)WARDRYX_APPROVAL_SIGNER=keyryx a grant is signed by a key Keyryx holds and the token verifies from a cached key set, so a party that can verify an approval cannot mint one; WARDRYX_APPROVAL_V2_UNTIL ends the HMAC tokens of earlier grants (see Stateless human-in-the-loop)tool_call records its name and target, and the granted token carries a digest of them, so an approval for one call is refused for any other (see Stateless human-in-the-loop)WARDRYX_APPROVAL_SINGLE_USE=false/v1/decide, /v1/approvals/{id}/decide, /v1/approvals, /v1/policies (admin policy-as-code, see Policy-as-code), /healthz (liveness), /readyz (readiness: reads the store, see When the store is down), bearer-key auth with org/role scopingpgx/v5, embedded schema) and in-memory, behind one Store interface; approvals and policy-as-code documentsagent-event NDJSON output (policy_allow / policy_deny / approval_* / policy_updated)approval_unanswered per hold nobody decided (WARDRYX_APPROVAL_UNANSWERED_AFTER, default 15m), which reports and never decidesserve, check (offline dry-run), approvals, replay, version/v1/decide outcome to WARDRYX_OTLP_ENDPOINT/-otlp-endpoint, fire-and-forget, no-op when unset (internal/otel)/v1/policies, this repo's side): file-loaded policies stay a permanent floor, store-managed policies layer on top, validate-then-apply, live-swapped with no restarttaipan_wardryx_policy Terraform resource (that repo's side) drives the API above -- create/update/read/delete, live-verified against a running Wardryx instance including drift detection and destroy