Skip to content

Threat model

Status: week 10, for the first auditor session. SPEC §17 names this file and lists nine MUSTs; the last two were built in week 10 and this document says which, where, and how far.

This is written for somebody who has to decide whether the evidence qed produces is worth anything. So it leads with the limits rather than the controls: the section you want is What qed does not protect against, and it is deliberately longer than the STRIDE table.

One claim runs through everything below, and it is the only one worth testing: an auditor can verify what happened without trusting the instance that recorded it. qed-verify is a separate package with one dependency (cryptography), written against the format rather than against the implementation. Where a control depends on trusting the instance, this document says so.


What is being protected

Three things, in this order.

  1. The chain's integrity. A record that was written cannot be changed or removed without that being detectable by somebody holding only the public keys.
  2. The decision's honesty. A verdict says what the policy actually concluded, and the record says how much of it was enforced rather than merely observed.
  3. The confidentiality of what passes through. Credentials never reach the chain, logs or events; action parameters reach it only as the adapter already redacted them.

Note what is not on that list: preventing the action. qed prevents an action only where an adapter holds the credential it needs (assurance: adapter). On every other path it decides and records, and the agent could proceed anyway. That is the first limitation below and it is not a gap to be closed later — it is the shape of the product.


Trust boundaries

  agent ──① adapter ──② API ──③ kernel ──④ store
                         │                   │
   operator ──⑤ CLI ─────┘                   │
                                             │
  third party ──⑥ pack bundle                │
  anchor log  ──⑦ (outbound only)            │
                                             │
  auditor ──⑧ export + qed-verify ───────────┘
Boundary What crosses it Trusted?
① agent → adapter tool calls, parameters No. The agent is the subject of the policy.
② adapter → API signed ActionProposal Authenticated per adapter (HMAC over a canonical string). The contents are not trusted.
③ API → kernel parsed proposal Validated; unknown fields rejected.
④ kernel → store signed records Trusted within the instance. An operator with database access can write here, which is what anchoring addresses.
⑤ operator → CLI admin operations Self-asserted identity. See limitation 2.
⑥ third party → pack bundle control catalogues No. Signature required by default (ADR-0012).
⑦ instance → anchor log six values, outbound only The log is not trusted with anything: it receives hashes (ADR-0013).
⑧ store → auditor JSONL export Verified independently; the instance is not trusted.

STRIDE

Spoofing

Threat Control Where
An unknown adapter submits proposals Adapter authentication is mandatory; unknown adapter ids are refused qed/api/auth.py; a sweep in tests/api/test_auth.py walks every /v1 route and asserts an unsigned request cannot get a 2xx
A replayed signed request Timestamp window (api.max_clock_skew_seconds, 300s) plus nonce qed/api/auth.py
An agent claims a human principal it does not have Delegation token; AG-IDN-001 blocks a proposal with no token qed/core/delegation/
A token from another issuer or audience Issuer allow-list, alg fixed per issuer, audience checked qed/core/delegation/claims.py
A forged mandate authorises an unattended agent The signature is verified against this instance's public keys before the mandate reaches the engine; an unknown key_id is a refusal, not a skip verify_session_mandate in qed/runtime.py; tests/delegation/test_mandate_is_verified_at_runtime.py
An approver approves as somebody else Not prevented on the CLI. See limitation 2 —

Tampering

Threat Control Where
A record is edited in the database payload_hash → hash → next record's prev_hash; any edit breaks linkage qed-verify reports the first broken link
A record is deleted seq is contiguous; a gap is reported qed-verify
The whole chain is rewritten by the operator Only detectable with an anchor. Checkpoint roots published outside the instance (S3 object-lock, git tag, transparency log). The object-lock path is tested against a real S3 server that enforces retention, not a mock: the locked version cannot be deleted, cannot be deleted with BypassGovernanceRetention, and survives both a delete marker and an overwrite qed/infra/publishers/, qed/remote/anchor.py, tests/evidence/test_s3_object_lock_live.py. qed doctor says "anchors: none" in yellow when there are none, because without one this row is not covered
A forged history signed with a leaked key Rotation destroys the old private key; key_rotated is signed by the retiring key ADR-0010
A chain from another instance is substituted into a running instance genesis binds instance_id; the writer refuses a mismatch InstanceMismatch
A chain is fabricated wholesale and handed to an auditor Partial, and auditor-side only. qed-verify --instance-id binds record 0 to an identity, which fails a forgery labelled with a different id — but the id and the public key must both reach the auditor out of band, since both appear in the export and were chosen by whoever wrote it. InstanceMismatch protects the operator's own chain from reuse and does nothing here. Without an out-of-band id and key, every other check in the verifier is satisfied by records made five minutes ago qed-verify, docs/evidence-format.md conformance 11
A control catalogue is swapped to change what an export claims Pack bundles are signed; an import is an admin_action carrying the bundle hash and key id ADR-0012
An imported catalogue is rolled back to remove a mapping A bundle older than the installed one is refused check_not_older

Repudiation

Threat Control Where
"The agent never proposed that" Every proposal is a record, before the verdict proposal + verdict records
"Nobody approved that" approval_decided carries the approver's hash SPEC §9.3
"The policy said something else at the time" policy_loaded records the bundle hash; every verdict references it _record_bundle_once
"The control labels meant something else" An unsigned catalogue sets unsigned: true on every later policy_loaded ADR-0012
"The operator rotated the key quietly" keys_rotate writes an admin_action saying whether it was approved, by whom, or that break-glass was used ADR-0014
Retention erased the evidence admin_action records every purge and is itself in NEVER_PURGE; structural records are never purged qed/core/evidence/retention.py

Information disclosure

Threat Control Where
A credential reaches the chain Broker credentials are in memory only; never in records or events qed/core/broker/
A secret reaches the chain via parameters Adapters redact before sending; params_hash still covers the originals; AG-DAT-001 blocks secret material outright qed/adapters/common.py
Masking removes too much, or too little Profiles, with STRUCTURAL_TYPES never masked — a key_rotated payload holds a public key, a checkpoint holds its root qed/core/evidence/masking.py
An anchor log learns the operator's infrastructure The anchor sends six values and no payload; a checkpoint payload carries the previous receipts, which name buckets ADR-0013
An approval queue accumulates secrets An approver sees the already-redacted parameters, never the raw call SPEC §9.2
An error message leaks a DSN The Postgres DSN is never echoed, including in qed doctor qed/infra/storage/postgres.py

Denial of service

Threat Control Where
A caller floods the API Per-adapter and per-principal token buckets, 429 with Retry-After; a refusal writes nothing to the chain qed/core/ratelimit.py
An oversized proposal 64 KB proposal, 16 KB params_redacted qed/adapters/common.py
A catastrophic regex in a rule regex module with a 1000 ms timeout; the timeout skips that rule and records AG-PRD-001, so one pattern can neither block every action nor silently disappear qed/core/policy/predicates.py, ADR-0016
A malformed input crashes a parser Fuzzed: a malformed input yields the refusal its caller handles tests/test_fuzz_parsers.py
An unreachable database hangs every command connect_timeout (10s) qed/infra/storage/postgres.py
An unreachable anchor stalls the chain A publisher returns a failed receipt; it never raises qed/infra/publishers/__init__.py
A third party's catalogue stops the kernel deciding An import that would orphan a rule's control reference is refused before anything is written ADR-0012

Elevation of privilege

Threat Control Where
An agent edits the policy it is judged by AG-IDN-009 blocks any action targeting qed's own keys, policy or evidence identity-extended.yaml
An agent approves its own action check_separation_of_duties: the approver may not be the principal (AG-SOD-001), must be a person and must be in the named group (AG-SOD-002) qed/core/approval/model.py
An agent escalates through a delegation chain AG-IDN-002 requires approval beyond two hops; budget is charged along the chain SPEC §6
An operator changes a key or catalogue unobserved Self-governance: an approval with SoD, or a recorded break-glass ADR-0014
An approval for one thing is spent on another An admin approval is bound to its operation and spent once ADR-0014
An agent reaches the metadata service or a credential endpoint AG-DAT-013, AG-SKL-006 baseline pack

What qed does not protect against

This is the section to read twice.

1. On most paths, qed does not stop anything

An adapter enforces only when it holds the credential the action needs — the MCP proxy with a brokered credential, the HTTP proxy for a host it fronts. Those records carry assurance: adapter.

Everywhere else — the Claude Code hook, the Python SDK, an advisory proxy — the agent holds its own credentials and qed is advisory. It decides and records, and a determined agent (or a developer who removes the hook) can act anyway. docs/assurance-classes.md is the page an auditor should read next: it maps enforcement: advisory to assurance: self so the distinction is in every record rather than in a footnote. qed doctor prints "every adapter is ADVISORY" when no upstreams are configured.

What the evidence then supports is "this is what the agent proposed and what policy concluded", not "this is what the agent was able to do".

A fail-open adapter that cannot reach the kernel writes an ungated_window record, so a gap in coverage is visible rather than silent.

2. On the CLI, identity is self-asserted

qed approve --by you@example.com takes your word for it, and so does self-governance's --by. The CLI runs on the operator's own machine; there is no token to check.

So self-governance on the CLI buys two separate invocations naming two separate subjects. That is a real obstacle to a careless action and no obstacle at all to one operator who lies about who they are. Identity is proven only on the API path, where a delegation token says who is asking — which means the web approval page with OIDC, not the terminal.

Claiming cryptographic separation of duties here would be the same kind of overclaim as a compliance mapping nobody checked.

3. An operator with database access can rewrite the chain, and only an anchor catches it

Everything in the Tampering table detects edits by somebody who cannot recompute the signatures. An operator holding the signing key can rewrite every record and every hash, and the export will verify.

The only defence is a checkpoint root published where the instance cannot change it (evidence.checkpoint.publishers). An instance with none configured has no answer to this threat, which is why qed doctor says so in yellow rather than reporting "anchors: none" as a neutral fact.

Two of the five publishers have had that refusal measured against a real server rather than a fake. s3-object-lock in compliance mode is the stronger of the two: tests/evidence/test_s3_object_lock_live.py runs in CI and the job fails if those tests skip. gcs-retention was measured by hand against real GCS (tests/evidence/test_gcs_retention_live.py) — a delete and an overwrite both refused for a project owner — but against an unlocked retention policy, and it is only a locked policy that an administrator cannot remove. That last link is Google's documented guarantee rather than something measured here; docs/checkpoints.md says so in the same words. The other three publishers move the root somewhere else without a server that refuses to give it back, which is weaker and worth knowing when you pick one.

qed replay <proposal_id> narrows it, and the distinction is worth being precise about. It decides a recorded proposal again against the bundle whose hash the record names, so a rewritten verdict that no longer follows from that policy is reported even though it verifies. tests/cli/test_replay.py does exactly this: rewrites a verdict, re-signs the whole chain with the real key, confirms qed verify returns 0, and then shows qed replay returning 2.

It is not a substitute for an anchor, for two reasons. An operator who can rewrite the chain can rewrite the policy_loaded record and ship a bundle to match, and then the replay agrees -- the anchor is what fixes a root in time where that cannot be done. And a rewrite that simply deletes records leaves nothing to replay. What replay adds is that forging one verdict now requires forging a consistent policy to go with it, rather than editing a number.

4. Break-glass bypasses self-governance by design

--break-glass "<reason>" rotates a key with no approval. It exists because the reason to rotate is that the key may have leaked, which is exactly when the approver is asleep — and because a gate with no bypass gets bypassed outside the tool, where nothing records it.

It costs a permanent admin_action with the reason, and admin_action is in NEVER_PURGE. An auditor should expect to find break-glass uses and ask about each one.

5. Rate limits are per process

Four replicas with per_adapter_rps: 100 admit 400/s between them. GET /v1/status reports scope: "this process only". A shared limiter would need Redis or a database write per proposal; the honest number is the one reported.

6. The clock is not verified against anything

qed does not query NTP — it is offline by design. It warns when the newest record's timestamp is more than 5 seconds in the future, which catches a clock that went backwards. It cannot detect a clock that is consistently wrong, so timestamps are only as good as the host's clock.

Record order does not depend on this: seq and prev_hash fix it.

7. A signed pack bundle is only as trustworthy as its publisher

Signature verification proves a bundle came from the key you supplied. It says nothing about whether the control titles in it are accurate. qed's own catalogues were written against the standards' published material (ADR-0011); an imported one carries whatever its publisher wrote.

8. Shadow mode is the shipped default

A rule in mode: shadow, or an instance in mode: shadow, records and does not stop. The verdict records carry enforced: false and the OSCAL export marks shadow findings, so an auditor reading one should not read it as a control that worked — but the default means a fresh install enforces nothing.

9. The kernel judges what the adapter tells it

A verdict is about the ActionProposal, and the proposal is what the adapter chose to send. An adapter that summarises an action inaccurately — by accident or otherwise — gets an accurate verdict about an inaccurate description.

params_hash covers the original parameters even when params_redacted is trimmed, so a later dispute can prove what the adapter had in hand. But nothing in the kernel can tell whether name: "postgres.execute_sql" with a harmless-looking statement was really a harmless statement. The adapters in this repository are the ones to read for that; a third-party adapter is a trust decision of its own, which is why adapters authenticate individually and the records say which one spoke.

10. Coverage is a property of the rules, not of qed

An action no rule matches gets the bundle default — AG-DEF-001, and ALLOW unless the deployment changed it. The baseline pack is 46 rules; nobody should read that as "everything dangerous is covered".

qed packs list reports which controls the installed rules claim and, more usefully, which they do not. The honest reading of an export is "these rules examined these actions", and the gap between that and "the agent did nothing bad" is exactly the size of the gap in the rules.

11. A verifier binds a chain to an identity only if that identity arrives out of band

Every check qed-verify performs except one is internal to the file it is given. Linkage, recomputed hashes, canonicalisation, signatures, Merkle roots — all of them are satisfied exactly as well by records fabricated this morning and signed with a key generated for the purpose. The one check that reaches outside the file is the genesis binding, prev_hash of record 0 against sha256("evidence-chain/1:genesis:" + instance_id).

That check is only as good as the instance id, and the instance id is in the export — it is in the genesis payload, in plain text. So is the key id of every signing key. An auditor who reads the id out of the file and passes it back with --instance-id has checked the file against itself and learned nothing: the forger chose both halves.

The id and the public key have to come from somewhere the operator did not control at the moment the evidence was produced — an engagement letter, a contract, a key published before the period under review, a fingerprint confirmed by another channel. qed cannot enforce this and does not try. What it does is refuse to hide it: without --instance-id the verifier reports bound to : NOTHING and names the check that did not run, --require-complete turns that into a non-zero exit, and docs/evidence-format.md makes both a conformance requirement for any other implementation.

The failure this removes is narrow and was real: until 11 October 2026 the reference verifier printed OK -- chain is contiguous, unmodified and signed with the binding skipped and no field anywhere recording the omission, and the README's own quick start omitted the flag.

12. Not yet built

  • mode: mitm for the HTTP proxy: a CONNECT tunnel is decided on the host name and SNI only, so the path and body are not seen.
  • The Codex hook (its API is unconfirmed).
  • The remote.packs URL feed; air-gap import only.
  • The transparency log itself (the client is here; the log is not in this repository).

SPEC §17's nine MUSTs

MUST State
Refuse to start without an evidence signing key (readyz=false) Built. qed/api/app.py
Admin operations through approval with SoD and admin_action Built week 10 for keys_rotate and packs_import, the two that exist. Off by default; see limitation 2 (ADR-0014)
Broker credentials in memory only; redacted everywhere Built
Adapter authentication mandatory; unknown adapters rejected Built
Duplicate proposal.id → 409, no evidence written Built
Clock: warn if drift > 5s; seq guarantees order Built week 10, measured locally rather than against NTP; see limitation 6
Input limits and regex timeouts Built, and fuzzed week 10
Supply chain: SBOM, cosign, provenance In release.yml; signing is gated off until the SPEC §24.1 name reservations are done
Dependencies pinned; pip-audit in CI Built week 10: its own job, on push and daily

Reporting a vulnerability

SECURITY.md. Please do not open a public issue for anything in the Tampering or Elevation of privilege tables.