UNPKG

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
# NOA ReceiptThreat 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.