noa-receipt
Version:
NOA Agent Action Receipt — open, offline-verifiable provenance for AI-agent actions. The governance/receipt organ only; the NOA brain is separate and proprietary.
394 lines (367 loc) • 33 kB
Markdown
# NOA Receipt — Threat Model
This document is deliberately blunt. A trust layer that overstates what it proves is worse
than none. Here is exactly what a NOA Receipt defends against, what it does not, and why.
## What a receipt proves
A verified chain proves: **each record was produced under the stated governance rules,
signed by the pinned key, and the sequence has not been edited or re-ordered in the middle.**
That is the whole claim. It is a verifiable, tamper-evident provenance log — not an oracle of
truth or safety.
## Assets & trust boundary
- **Trusted:** the signing key(s) and the keyring used to verify them. The keyring is the
trust root; obtain genesis public keys out-of-band (TOFU or distribution). Compromise of a
private key lets the holder author valid receipts — protect keys accordingly (HSM/KMS in
production; `kid` rotation in v0.2).
- **Untrusted:** the receipt bytes themselves (attacker-controlled input to the verifier),
storage/transport, and any party downstream of the signer.
## Threats addressed (with the mechanism)
| # | Threat | Defense | Tested by |
|---|--------|---------|-----------|
| T1 | Edit a past record's content | hash over JCS bytes; mismatch detected | `attack/tampered-content` |
| T2 | Re-order / drop a middle record | seq contiguity + prevHash linkage | `attack/seq-gap`, `attack/relinked` |
| T3 | Forge a fresh genesis | genesis must have `prevHash:null`, signed | `attack/forged-genesis` |
| T4 | Strip signature, alter, re-sign with new key | `sig.kid` is inside the hash → breaks linkage | `attack/key-swap` |
| T5 | Re-sign a record **mid-chain** with a different trusted key | `sig.kid` pinned per `agent.id` *within a chain* → mid-chain key-swap rejected (does **not** stop a fresh forged chain — see cross-agent impersonation below) | `attack/key-swap-resigned` |
| T6 | Present a corrupted/forged signature | Ed25519 verify against keyring | `attack/wrong-signature` |
| T6b | Unknown signing key while a keyring is supplied | treated as TAMPERED (no silent TOFU on attacker input) | `attack/unknown-kid` |
| T7 | Number-serialization / canonicalization disagreement | integer-only JCS, frozen rules, pinned vectors | `jcs.test`, conformance |
| T7b | Unpaired-surrogate hash collision (collapse to U+FFFD) | reject non-well-formed Unicode in canonicalizer + parser | `jcs.test`, `safe-json.test`, `malformed/*surrogate*` |
| T8 | Duplicate-key parser divergence | strict parser rejects duplicate keys | `safe-json.test` |
| T9 | Smuggle PII/data in an **unknown** field | `additionalProperties:false` everywhere | `schema.test`, `malformed/pii-smuggle` |
| T10 | Malicious input → verifier DoS/pollution | depth/size bounds, `__proto__` reject, no eval/network | `safe-json.test`, `malformed/deep-nest` |
| T11 | Cross-protocol signature reuse | domain-separated signing preimage (`NOA-Receipt-v0.1-sig:`) | `roundtrip.test`, conformance |
| T12 | "Compliant" claimed off a **forged** carrier (L2) | `verifyReceiptCompliance(…, { keyring })` authenticates the carrier (own-hash + Ed25519) BEFORE the L2 check; or require `verifyChain → VALID` first | `policy/compliance.test` (carrier authenticity) |
| T13 | L2 verdict the receipt **never re-derives** | committed `verdict` (ALLOW\|DENY) is reconciled against a re-run of the evaluator → `ok:false` on mismatch | `policy/compliance.test` (verdict reconciliation) |
| T14 | Ed25519 signature malleability (`S' = S+L`) | both reference verifiers reject non-canonical `S ≥ L` and non-canonical point/base64 encodings — `verifyEd25519` asserts `S < L` explicitly (not just via node:crypto/OpenSSL's own runtime behavior) | `keys.test` ("verifyEd25519 REJECTS a malleated signature S' = S+L"), `verify.test` ("T14: a malleated signature..."), `conformance` (S-malleability + non-canonical-base64 vectors) |
| T15 | Low-order / non-canonical **public key** consensus split (OpenSSL's key acceptance admits a small-subgroup key that the Python reference's point decode rejects → `VALID` in one impl, `TAMPERED` in the other on identical signed bytes) | both reference verifiers reject the 8 canonical small-order point encodings (torsion subgroup of order dividing 8) AND any non-canonical `y ≥ q` public-key encoding, decoding `A` with identical strictness | `keys.test`, `conformance` (small-order + non-canonical public-key vectors) |
> Note on T9: this stops PII in **unknown** fields. It does NOT stop a caller putting PII in a
> **known** opaque string (e.g. `approval.by`, `agent.model`). Those fields are opaque by
> contract and MUST NOT carry PII — the format cannot enforce that. Don't read "PII-free" as a
> guarantee about caller-supplied identifiers.
> Note on T15 (chosen convention, stated precisely): the normative rule is **"reject the 8 canonical
> small-order public-key encodings AND any non-canonical `y ≥ q` public-key encoding."** This is the
> minimal pin that makes the two reference verifiers agree on the public key `A`; it is **not** a claim of
> full ZIP-215 semantics. All *other* malformed/non-canonical encodings are rejected by each verifier's
> normal decoding rules, and the cross-impl conformance suite asserts no split on the low-order vectors.
> The signature's `R` point is **not** separately blocklisted: `R` is bound by the verification equation
> `[S]B = R + [h]A`, which both stacks enforce, so a low-order/non-canonical `R` (absent a crafted matching
> `S`/`h`) fails the equation in **both** impls — no verify-`true` split. A third-party verifier that does
> not adopt this same public-key rule will diverge from NOA on a low-order key; conformance vectors are
> versioned so the rule is testable.
## Threats NOT fully addressed in v0.1 (stated honestly)
- **Tail-truncation (T-tail):** deleting the most-recent receipts leaves a valid prefix.
*Mitigation now:* signed **checkpoints** (§6 of the spec) detect it when supplied; the
verifier **warns** when no checkpoint is given. With an `identityManifest`, a checkpoint is
authorized by the chain **OPENER** (the genesis `agent.id`), **not** the mutable head — see the
re-heading sub-threat below. *Full fix:* external anchor / transparency
log in v1.0. Without an anchor, offline verification cannot distinguish "nothing happened
after seq N" from "records after seq N were deleted."
- **Re-heading truncation among co-trusted keys (T-tail-reheading):** a `scope.chain`
is a *shared* partition with no opener/ownership binding, so a co-trusted key holder can APPEND its
own receipt onto a victim's prefix, BECOME the head, DROP the victim's incriminating tail, and forge
a checkpoint over its OWN head. Earlier the checkpoint §5b binding checked the kid against the **head**
`agent.id` — i.e. the attacker's own authorized id — returning `VALID` + `tailChecked:true` while the
victim's tail was silently erased. *Mitigation now (shipped):* the checkpoint authority is bound to the
chain **OPENER** (the `seq == 0` `agent.id`), which an appended tail cannot re-write → the re-heading
attacker's checkpoint is `UNTRUSTED`; the verifier also **warns** (opener-scoped completeness) whenever
a chain holds more than one `agent.id`. *Residual (needs the v1.0 external anchor):* the opener itself
dropping a co-agent's tail, and the **no-`identityManifest`** case (kid-level only — any keyring-trusted
key can forge a checkpoint over any head). §5b is therefore an *opener-scoped* truncation defense, not a
general anti-truncation guarantee against a co-trusted key.
- **Private-key compromise / no revocation / no forward-security:** a leaked private key lets
the holder retroactively re-sign an entirely fabricated history (bounded only by an external
checkpoint/anchor someone already holds). v0.1 has **no revocation list and no key-evolution**.
Use KMS/HSM; rotate via `kid`. Cryptographic *attribution to a keyring-trusted key* is provided;
binding that key to a specific `agent.id` is **not** (see cross-agent impersonation below), and
key-exfiltration prevention is not.
- **Cross-agent impersonation among co-trusted keys:** the trust root is the keyring (`kid → public
key`) only — there is **no** authenticated `agent.id → allowed-kid` binding. The per-`agent.id` `kid`
pin (T5) enforces *continuity within one chain*, not *who may open a chain*. So in a keyring holding
more than one trusted key, the holder of ANY trusted private key can author a fully **VALID** chain
that asserts ANY other `agent.id` (PoC: a low-privilege signer emits a `payment.refund` / CRITICAL
chain under a high-privilege `agent.id` and it verifies VALID). A VALID receipt therefore proves
*"a keyring-trusted key signed this"*, **not** *"this specific `agent.id` acted"* — the `agent.id` is
a signer-asserted label, authenticated only at the `kid` level. **Single-key keyrings are unaffected.**
*Mitigation now (v0.2, shipped):* supply an **`identityManifest`** (`agent.id → authorized kid(s)`) to
`verifyChain`. When present, a receipt whose `(agent.id, sig.kid)` pairing is not authorized is rejected
as **`UNTRUSTED`** (distinct from `TAMPERED`: the bytes + key are real, the *binding* is not) — this
upgrades a `VALID` result from "a keyring-trusted key signed" to "THIS `agent.id` signed". Without a
manifest, attribution stays kid-level (and `verifyChain` emits an explicit warning saying so). The
manifest is a trust input the operator vouches for (same class as the keyring); distributing it as a
*signed* statement is a deployment concern. *Remaining:* in-band rotation-attestation (one endorsed
key→key transition) so live chains survive rotation without manual manifest edits.
*On the COSE path, the manifest is checked against the receipt's own `sig.kid`, never the envelope's*
(fixed 2026-08-15; spec §8, draft §6). An enveloped receipt has two signers and they attribute two
different parties: the receipt's own key is the agent, the envelope's key is whoever presented it.
`receiptFromCose` had checked the envelope's, so an envelope signed by a key authorized for the
victim laundered a rogue-signed receipt to `ok:true`, while a genuine receipt merely relayed by a
third party was refused. Both directions are now conformance vectors
(`conformance/cose-attribution/`), the native signature is verified there rather than assumed, and
the result reports the two claims separately (`agentClaim` / `envelopeClaim`).
- **Replay / freshness / liveness:** a wholly-valid chain, head, or checkpoint can be re-presented
later as if current. The format carries no nonce/epoch/expiry. **Freshness is the caller's
responsibility** — pin an expected chain id + head hash from a fresh, trusted channel; do not
treat "VALID" as "current".
- **Namespace / context binding:** a signature proves "this key signed this receipt graph", not
"this graph belongs to *your* deployment/customer/task" unless the caller checks `scope.chain`
(and any agreed `tenant`/subject) against what it expected. `scope.chain` IS in the signed body
(cross-chain splice is rejected), but matching it to *your* context is policy you must apply.
*Mitigation (fail-closed by DEFAULT since the tenant-consistency change; the earlier A1 revision
of this paragraph said the default verdict was unaffected — that is no longer true):*
`scope.tenant` was once not checked for consistency across a chain at all, so a caller relying on
"one chain = one tenant" got a silent `VALID` over a mixed-tenant chain. `verifyChain` now scans
`scope.tenant` across the whole (seq-ordered) chain and distinguishes TWO kinds of drift:
- **present → a DIFFERENT present value** (`acme` → `globex`) is a **cross-tenant splice**: every
receipt is individually intact and correctly signed, and the forgery is a property of the SET.
This is `TAMPERED` **by default** — the same verdict class as a `scope.chain` partition split,
since it is the identical class of problem for the sibling scope field. Pass
`requireTenantConsistency: false` for the previous warn-only behaviour.
- **absent ↔ present** (a deployment starting or stopping emission of an OPTIONAL field) is a
producer-version change, not a splice. `scope.tenant` is optional in the schema and this
profile has never declared it immutable, so this is **reported, never rejected** — labelling it
`TAMPERED` would send an operator hunting a forgery that does not exist.
**An omission does not RESET the boundary.** The comparison is against the last **present** tenant,
not against the adjacent receipt. While it was adjacent, the tolerance above was a laundering step:
`acme → globex` was `TAMPERED` and `acme → absent → globex` — the same splice, with one optional
field left out of the receipt in between — was `VALID`, in all five implementations. Dropping an
optional field is not a capability an attacker lacks. Carrying the last present value forward keeps
the relaxation intact (`acme → absent → acme` and `absent → acme` stay valid) while giving the
splice the same verdict however many tenant-less receipts are interleaved.
**Where each kind is reported.** A non-fatal drift lands in machine-readable `warnings`
(`tenant-drift: seq A "x" -> seq B "y"`). A FATAL drift is reported in `reason`, in that same
machine-readable form, with `badSeq` pointing at the receipt that contradicts the committed tenant
— a rejected result carries `warnings: []` by construction, so do not look for the fatal case
there. (An earlier revision of this paragraph said both kinds appear in `warnings`; that was never
true of the fatal one.) All five verifiers implement the identical rule.
Matching the tenant value to *your* expected tenant remains the caller's job.
- **Omission ≠ tampering:** this proves the integrity of the receipts that EXIST. An agent that
simply never emits a receipt for a bad action leaves no trace to detect. It is log-integrity,
not a guarantee of behavioral honesty.
- **Signer-asserted timestamps:** `ts` is set by the signer and is therefore backdatable. The
verifier only warns on non-monotonic `ts`; do not treat timestamps as trusted wall-clock.
- **Signer-asserted timestamps (opt-in mitigation):** `packages/tsa-anchor` (Apache-2.0, opt-in —
the core `noa-receipt` package gains no new dependency) can request an RFC 3161 trusted timestamp
over a witness anchor from an independent Time-Stamping Authority.
TSA proves the anchor existed at time T — it does not prove receipts' own `ts` fields. It only
covers anchors that already went through the opt-in witness-federation path
(`--anchors`/`--trust-set`); a chain with no anchor has no TSA coverage. Full cryptographic
verification of a TSA token's own certificate chain is `openssl ts -verify` (documented in
`packages/tsa-anchor/README.md`), not reimplemented in-package.
- **Equivocation — one signer, two histories (opt-in detection, not prevention):** an issuer can
build two internally-perfect chains under the same key and show each to a different witness. Every
signature verifies on both, and a verifier holding one branch sees nothing wrong
(`docs/federation-spec.md` §7). `packages/tsa-anchor`'s `scanForEquivocation` is the monitor for
the case where the two views MEET: given a pool of published anchors and a pinned trust-set it
finds the contradiction and emits a proof a third party re-checks offline. What that buys is
narrower than it first sounds, and the limit is not a footnote: **nothing authenticates that a
pool is COMPLETE**, so the scanner cannot tell an incomplete pool from a complete one. Withholding
a single anchor therefore makes a forked chain read clean, and doing so needs **no compromised
signer and no forged signature** — only control over what reaches the verifier. Detection is
real for whoever ends up holding both halves; making that happen is a distribution problem this
code does not solve. It also does not adjudicate which branch is true, it sees only what was
published (omission, again), and from anchors alone a rewrite that also EXTENDS the chain is
indistinguishable from ordinary growth, so catching that additionally needs the presented chain.
Nothing in this repository collects the pool — the verifier does (NON-CLAIMS NC-4.3).
- **Keyring is the root of trust:** every property above stops holding if the verifier's keyring
is wrong. Distributing/securing/updating the keyring is out of band and out of scope for v0.1.
- **Unknown `kid` is reported `TAMPERED` (fail-closed tradeoff):** when a keyring is supplied, a
signature by a key not in it (receipt OR checkpoint) is `TAMPERED`. This is deliberate
(no silent trust-on-first-use of attacker input). The cost: a *legitimately rotated* key looks
`TAMPERED` until verifiers update their keyring — so treat a `TAMPERED` "unknown key" reason as
"update the keyring if this key rotated; otherwise it's a forgery." (A distinct `UNTRUSTED`
status is a v0.2 consideration.)
- **paramsHash correlation / brute-force:** plain `sha256` of low-entropy params (an amount,
an id, a boolean) is guessable and identical across tenants → cross-tenant correlation.
*Mitigation:* use `hmac-sha256` with a tenant-scoped key. The offline verifier then cannot
recompute the params hash (it has no key) — but it still verifies the chain, because
`paramsHash` is covered by the receipt hash. This tradeoff is intentional and documented.
- **Truthfulness of the action:** a receipt records *what was authorized and decided*, not
that the downstream system actually did it. Pair with the receiving system's own logs for
end-to-end assurance (receiver-attestation is a v1.0 goal).
- **L2 input-authenticity / the oracle limit:** `verifyReceiptCompliance` proves the recorded decision
re-runs to the recorded verdict over the policy + the RECORDED inputs — it does NOT prove those inputs
reflect external ground truth. A compromised or lying agent can emit a fully-valid, fully-verifying
receipt over inputs it fabricated (e.g. "balance read = 0"). Closing this needs source/tool
co-signatures over the read-set (a v1.0 witnessed-input goal); today L2 certifies *consistency of a
self-reported decision on an authenticated carrier*, not the truth of its inputs.
- **Enforcement bypass — general (see SECURITY.md):** `noa.guard()` is advisory unless placed at the
credential/write boundary.
- **Enforcement bypass — config-artifact redirection (NON-CLAIMS.md NC-6.9):** until 2026-08-12 the
MCP proxy read its own gate config (`--approval-rules`, `--approver-keyring`, `--approver-identity`,
`--pending-store`) by path, so a symlink swap turned human approval off entirely — a `transfer_funds`
above the configured threshold was forwarded and executed with no approval at all. `config-artifact.mjs`
now opens that config through one `O_NOFOLLOW` descriptor, closing the symlink path. Two measured
bypasses still execute the identical unapproved transfer against the FIXED code and are **not**
closed: an **in-place content rewrite as the same uid** (`printf '[]' > approval-rules.json` — no
symlink, no unlink, no mode change, so nothing for `O_NOFOLLOW`, the owner check or the mode check to
catch), and an **ancestor-directory repoint** (`O_NOFOLLOW` guards only the FINAL path component, so
redirecting an ancestor directory is not caught). Full statement and scope: NC-6.9.
- **In-process-API hostile-getter class — CLOSED in `0.6.0`, not an ongoing residual:** through
`0.5.0`, `verifyChain`, `verifyCheckpoint` and `verifyReceiptCompliance` accepted a caller-supplied
LIVE object and defended it by snapshotting every such input once via `structuredClone`. As of
`0.6.0` (CHANGELOG, "the public verifiers take BYTES, not objects") those entry points no longer
accept an object at all — every one of them takes `Uint8Array | string` only, and `verifyChainText`
is not a separate, narrower-guarantee path anymore: both route through the same `parseDocument`, so
a document is parsed once into a null-prototype, accessor-free tree before any verifier logic runs.
There is no live caller object left below the boundary to read twice, which is why the
`structuredClone` snapshot machinery this file used to carry was DELETED rather than kept as a second
line of defence (`src/verify.ts:33-38`; confirmed at the call site, `src/verify.ts:205-207`: "there
is no live caller object anywhere below this line"). That closes the hostile-ACCESSOR class (class A,
`docs/ADR-0001-trust-kernel-vnext.md` §2.2) at the source — a getter or Proxy trap can only run
during traversal of a live object, and no live object reaches the boundary anymore.
This does **not** close the intrinsic-POISONING class (class B — an attacker who already has code
execution in the realm, e.g. a rewritten `Array.prototype.includes`); per ADR §5.2, nothing a
library does closes that class, and it is not declared fixed here. What did land against it is
defence-in-depth: the specific sink this section used to point at — cross-agent-authorization
membership resolved through a live, writable `Array.prototype.includes` — no longer dispatches
through that global slot. It resolves through `arrayIncludes`, a copy of `Array.prototype.includes`
captured at module load and invoked via a captured `Reflect.apply`, so reassigning the global no
longer changes the decision (`src/verify.ts:543-548`, `src/intrinsics.ts:403`). New same-class
in-process-getter or intrinsic-poisoning findings are still tracked and fixed on discovery — that
practice continues; it is just no longer gating an open "declared known-limitation."
> **RESOLVED (`0.6.0`), superseding the review correction below.** That correction measured
> `verifyChainText` as it existed before `0.6.0`, when it forwarded into an object-accepting
> `verifyChain` — its citations to `src/verify.ts:178` and `:426` are accurate for that commit, not
> this one. As of `0.6.0`, `verifyChainText` is a pure alias of `verifyChain`
> (`src/verify.ts:712-718`): there is only one entry point left, it takes bytes/text only, and it no
> longer "deep-copies through the live global `structuredClone`" because that machinery does not exist
> (`src/verify.ts:33-38`, `:205-207`). The note below is kept unedited as the record of what that
> review found and when.
> **CORRECTION (review #7, 2026-07-28) — this paragraph called `verifyChainText` "the immune path".
> It is not immune; it is immune to ONE of the two classes.** Measured, not argued: a probe against
> the built kernel showed `verifyChainText` fully exploitable by both C-01 and C-02
> (`clean = TAMPERED / POISON = VALID, sigVerified = true`). It calls `safeParse` and then hands the
> result to `verifyChain`, which still deep-copies through the *live global* `structuredClone`
> (`src/verify.ts:178`) and still resolves membership through live `Array.prototype.includes`
> (`src/verify.ts:426`). Parsing from text removes the hostile-ACCESSOR class (A) and leaves the
> intrinsic-POISONING class (B) untouched.
>
> Why the advice is still worth following, stated precisely: against the DECLARED threat model — a
> data-only attacker who controls the receipt bytes and nothing else — pre-parsing does close the
> class, because removing object traversal removes the only route by which untrusted DATA obtains
> code execution. It does not make the poisons fail; it removes the attacker's ability to run them.
> Against an attacker who already has code execution in the realm it closes nothing, and neither
> does anything else a library can do (see "The residual, stated plainly", below).
>
> Full reasoning and evidence: `docs/ADR-0001-trust-kernel-vnext.md` §2.3. Consolidated limits:
> `NON-CLAIMS.md`.
**Update (review #6, 2026-07-28) — the class was NOT closed by snapshotting, and the paragraph above
was too optimistic.** Reading a hostile object necessarily RUNS the attacker's code (a getter, a
Proxy trap), and `structuredClone` closed only the flipping half. A getter fired *during* ingestion
could rewrite a shared intrinsic — `Array.prototype.includes = () => true`, `Set.prototype.has = () =>
false`, `Array.prototype.find = () => attackerHead` — and the snapshot that came back was frozen,
accessor-free and completely honest while the DECISION taken over it was attacker-controlled,
because membership resolved through a globally-mutable slot. Three verdicts flipped that way.
What now holds, and how it is measured:
* every builtin the verifier core uses is captured at module load (`src/intrinsics.ts`) and called
through a captured `Reflect.apply`, so a decision never dispatches through a mutable slot;
* every snapshot node is inert — objects are null-prototype, arrays are re-rooted onto a frozen,
null-rooted prototype carrying pristine methods and a self-contained iterator, so nothing the
boundary produces inherits from anything writable;
* policy tables are built with `frozenTable`, which REFUSES a `Set`/`Map`/accessor at construction;
* `test/security/intrinsic-poisoning.test.ts` asserts the class property over every entry point,
every fixture and ~74 poisoned intrinsics.
> ## 🔴 SECOND WITHDRAWAL — THE OUT-OF-PROCESS VERDICT IS NOT AN ENFORCEMENT CONTROL (owner-ratified 2026-07-29)
>
> The withdrawal immediately below removed the *in-realm* claim and pointed at an isolated kernel
> as the answer. **Round 5 removed that answer too, for the caller-protection case.** An ambient
> attacker poisoning only `child_process.spawnSync` made a protected action execute while the
> honest out-of-process kernel returned `DENY` — application source unmodified, call site intact
> (`docs/ROUND5-FINDINGS.md` R5-01). The signed envelope does not close it, because the envelope
> check runs in the same poisoned realm (`docs/T7-trust-root.md` §1).
>
> **Beneficiary B-1 is withdrawn.** We do not claim that a separate kernel verdict protects a
> caller whose realm, transport, signature verification, or action path is compromised. The
> replacement invariant — *a critical action must be technically impossible without authority
> controlled by the independent boundary* — is normative in `NON-CLAIMS.md` NC-6.6, with the
> architecture options in `docs/ADR-0003-enforcement-boundary.md`.
>
> Both withdrawals stand. Neither is retracted by the other; they remove two different claims.
> ## ⚠ THE SECURITY OBJECTIVE IS NOT MET IN-REALM — WITHDRAWN CLAIM (ratified 2026-07-29)
>
> This section previously asserted, as a property of the shipped TypeScript library:
> *"no mutation of any shared intrinsic may make a verdict more permissive."*
>
> **That claim is WITHDRAWN. It is not true of same-realm TypeScript, and it cannot be made true
> by this library.**
>
> Four independent cross-vendor adversarial rounds (2026-07-28/29) each closed the call sites a
> review named and each found the identical class one call further out: the parse layer, then the
> hash layer, then the live `node:crypto` binding, then arrays manufactured *downstream* of the
> fix by `Object.keys`. Sixteen CRITICAL findings, four rounds, **zero clean rounds.** Every one
> was found while the project's own gates reported green.
>
> The generalisation is not "some primitives were missed". It is structural:
>
> **In a shared realm, the set of operations trusted code performs is not enumerable by that
> trusted code.** A capture list is a snapshot of the spellings someone thought of; the adversary
> chooses the spelling afterwards. A defence whose completeness cannot be decided is not a
> boundary.
>
> **What this library actually offers in-realm:** substantial, measured, best-effort hardening —
> captured intrinsics, inert data, AST-enforced dispatch gates, ~74 poisons and a durable exploit
> corpus. That raises the cost of an in-realm attack considerably. It does **not** meet the
> objective, and this document will not say that it does.
>
> **Where the objective is to be met — and what exists TODAY.** The isolated **Go kernel**
> (ADR-0002) is **SPECIFIED AND NOT YET BUILT.** There is no kernel directory in this repository.
> Do not read the paragraphs above as pointing at a shipped remedy; that would replace one unmet
> claim with another.
>
> What ships today is the **CLI**: `npx noa verify` runs in its own process and this package
> declares `"dependencies": {}`, so no third-party module is evaluated before it and a hostile
> *document* cannot poison that process's realm. **Against an attacker who controls only the data
> being verified, the CLI boundary holds now.** Its limit is NC-6.2: the CLI's output is not
> authenticated, so a compromised caller can still discard or misreport a correct verdict.
>
> *(This sentence used to end "— which is precisely what the kernel's signed-response envelope
> exists to close." **Withdrawn 2026-07-29**, same withdrawal as `README.md`. A signed verdict
> returned into a compromised caller is not an enforcement control. This survivor was missed by
> the first sweep because the sweep went document-by-document instead of claim-by-claim; the
> withdrawal block sits ~45 lines above and said "both withdrawals stand" while this line still
> asserted the withdrawn claim.)*
>
> TypeScript's in-process API is a best-effort compatibility and orchestration layer and makes no
> security claim of its own.
>
> **Pre-load compromise, specifically.** A host that mutates an intrinsic in a module evaluated
> BEFORE `noa-receipt` defeats the capture entirely — reproduced: a pre-load `Proxy` on `Number`
> yields `VALID` on a forged document. This was previously filed as a narrow residual. It is not
> narrow: it covers any dependency, bundler output, instrumentation shim or test harness that
> loads first, in an order the library does not control. An ordinary JavaScript module cannot
> enforce load order against its own host.
> **CORRECTION (review #7, 2026-07-28) — the advice to "run the verifier in a separate realm" is
> WITHDRAWN.** The first half (load `noa-receipt` first) is sound and stands. The second half was
> wrong, and wrong in the direction that invites a caller to believe they have mitigated something
> they have not.
>
> A same-realm "isolated realm" (`ShadowRealm`, `vm.createContext`) is not a boundary against the
> attacker who motivates it. That attacker controls the code that CONSTRUCTS the realm, marshals
> the input into it, and reads the verdict out. It adds a marshalling boundary and a second set of
> intrinsics to audit while moving the attacker's cost approximately nowhere — and it LOOKS like a
> boundary in documentation, which is the failure mode this project has spent four review rounds
> learning to detect.
>
> A separate PROCESS is genuinely different: it raises the required capability from "code execution
> in the host process" to "code execution in the verifier process". But it protects the INTEGRITY OF
> THE COMPUTATION and never the INTEGRITY OF THE CONSUMPTION. An attacker inside the host process
> can discard a correct verdict as easily as forge one, and that ceiling is not liftable by any
> isolation mechanism.
>
> **What actually defeats this attacker is already in the product, and is not an isolation mechanism
> at all:** the receipt is signed and offline-verifiable, so the party who CARES about the verdict
> re-verifies it themselves, in their own process, with their own copy. An attacker who owns the
> relying party's process has already won for reasons that have nothing to do with this kernel.
>
> Reasoning: `docs/ADR-0001-trust-kernel-vnext.md` §5.2-§5.3. Consolidated limits: `NON-CLAIMS.md`.
## Clean-room / scope boundary (why this is safe to open-source)
This repository is the **governance/receipt organ only**. It must never contain NOA-brain
internals. Concretely, the OSS surface accepts and emits **only** generic action envelopes:
enums (riskClass, verdict, mode, principal), opaque ids/handles, and hashes. It contains:
- **No** cognition, memory, planning, or model-routing logic.
- **No** tenant data, customer data, secrets, or private keys (the conformance keypairs — a
chain signing key plus a second adversary key for the key-pinning vector — are published
test fixtures, clearly marked).
- **No** proprietary policy content — *policy decisions enter as a verdict enum*, not as the
engine that produced them.
A contribution that would pull brain internals across this line is rejected on principle, not
just on review. The receipt is a format and a verifier; the brain is the product.