@coproduct_inc/verify 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +69 -0
- package/WHY-VERIFY.md +81 -0
- package/dist/attestation.browser.d.ts +1 -0
- package/dist/attestation.browser.js +3 -0
- package/dist/cage.d.ts +54 -0
- package/dist/cage.js +66 -0
- package/dist/claim-cli.d.ts +23 -0
- package/dist/claim-cli.js +185 -0
- package/dist/claim.d.ts +98 -0
- package/dist/claim.js +186 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +13 -0
- package/dist/license-cli.d.ts +18 -0
- package/dist/license-cli.js +96 -0
- package/dist/license.d.ts +59 -0
- package/dist/license.js +94 -0
- package/dist/workflow.d.ts +85 -0
- package/dist/workflow.js +179 -0
- package/examples/cage-quickstart.mjs +37 -0
- package/examples/converting-demo.sh +117 -0
- package/examples/sample-toolproxy-trace.bypass.json +34 -0
- package/examples/sample-toolproxy-trace.clean.json +41 -0
- package/examples/sample-toolproxy-trace.openclaw.json +24 -0
- package/examples/workflow-leak.mjs +40 -0
- package/package.json +30 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@coproduct_inc/verify`.
|
|
4
4
|
|
|
5
|
+
## 0.5.0
|
|
6
|
+
|
|
7
|
+
- **`Workflow` — the DAG cage** (`./workflow`, re-exported from root). Compose a
|
|
8
|
+
multi-agent run into ONE signed `WorkflowReceipt`: register nodes (each a
|
|
9
|
+
`Cage`) with `after` edges, then `wf.attest(privateKey)`. `verifyWorkflowReceipt`
|
|
10
|
+
checks it offline — signature + per-node in-bounds recompute + the DAG (acyclic,
|
|
11
|
+
real edges) + a **cross-node information-flow policy** (`{ sources, sinks,
|
|
12
|
+
declassifiers }`). The keystone: every node can be locally in-bounds yet the
|
|
13
|
+
workflow refuses a run where a secret laundered source→…→sink across the DAG —
|
|
14
|
+
the failure that only exists at multi-agent scale, caught at the orchestration
|
|
15
|
+
layer. See `examples/workflow-leak.mjs`.
|
|
16
|
+
|
|
17
|
+
## 0.4.0
|
|
18
|
+
|
|
19
|
+
- **`Cage` — the 5-minute producer drop-in** (`./cage`, re-exported from root).
|
|
20
|
+
A runtime cage wraps an agent's tool calls (`cage.tool(name, fn, { guard,
|
|
21
|
+
subject })`): out-of-boundary calls are blocked, every call is recorded, and
|
|
22
|
+
`cage.attest(privateKey)` emits a signed in-bounds receipt the existing
|
|
23
|
+
`verifyReceipt` / CLI checks offline. Closes the loop — producers cage,
|
|
24
|
+
consumers verify, nobody trusts the seller. See `examples/cage-quickstart.mjs`.
|
|
25
|
+
|
|
26
|
+
## 0.3.0
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- **Offline Ed25519 license keys** (`./license` export + `nucleus-license` bin) —
|
|
30
|
+
entitlement with no user database and no callback: the vendor signs a tiny
|
|
31
|
+
claims payload; the Pro path verifies it against a pinned public key.
|
|
32
|
+
`keygen` / `issue` / `verify`; expiry enforced; tamper-evident.
|
|
33
|
+
- **Converting demo** (`examples/converting-demo.sh`) — recordable 4-beat demo
|
|
34
|
+
(clean attests · OpenClaw impersonation refused · forged verdict caught two
|
|
35
|
+
ways · CI gate), runs against the published package or `LOCAL=1`.
|
|
36
|
+
- **`WHY-VERIFY.md`** — positioning one-pager (the independent-recompute
|
|
37
|
+
differentiator + honest Microsoft-AGT contrast) and a static `site/` landing
|
|
38
|
+
page (zero-JS, zero-backend) wired for a Merchant-of-Record checkout.
|
|
39
|
+
|
|
5
40
|
## 0.2.0
|
|
6
41
|
|
|
7
42
|
### Added
|
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @coproduct_inc/verify
|
|
2
2
|
|
|
3
|
+
> **New here?** Read [WHY-VERIFY.md](./WHY-VERIFY.md) ("verify, don't trust" — the
|
|
4
|
+
> one-pager + Microsoft-AGT contrast), or run the 90-second demo:
|
|
5
|
+
> `bash examples/converting-demo.sh` (clean attests · OpenClaw bypass refused ·
|
|
6
|
+
> forged verdict caught · CI gate).
|
|
7
|
+
|
|
3
8
|
Offline, **zero-trust** verification for Nucleus. Two receipt families share one package:
|
|
4
9
|
|
|
5
10
|
1. **Capability-boundary in-bounds attestation** (headline) — given an agent run's tool-call trace and a *declared* capability boundary, emit and offline-verify an Ed25519-signed, hash-chained receipt that the agent **stayed within bounds**. The boundary is keyed on a **cryptographic principal**, never a mutable display name — which is exactly what rules out OpenClaw-class trust-boundary bypass.
|
|
@@ -7,6 +12,70 @@ Offline, **zero-trust** verification for Nucleus. Two receipt families share one
|
|
|
7
12
|
|
|
8
13
|
---
|
|
9
14
|
|
|
15
|
+
## Cage your agent — the 5-minute drop-in
|
|
16
|
+
|
|
17
|
+
You ship an agent that takes actions for a customer. They won't take your word
|
|
18
|
+
that it stayed in bounds. **Cage its tool calls; hand them a receipt they verify
|
|
19
|
+
themselves.** Useful to you alone today (a tighter agent + an artifact your
|
|
20
|
+
customer can check); more valuable as a network of recomputable receipts forms.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Cage, generateKeypair, verifyReceipt } from "@coproduct_inc/verify";
|
|
24
|
+
|
|
25
|
+
const cage = new Cage({
|
|
26
|
+
principal: "spiffe://acme/support-agent", // a stable id, not a name
|
|
27
|
+
allowedTools: ["search", "read_ticket", "refund"], // its capability boundary
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Wrap each tool. Out-of-boundary calls are blocked; a guard adds a runtime
|
|
31
|
+
// check (never refund over $50). Works for sync and async tools.
|
|
32
|
+
const search = cage.tool("search", realSearch);
|
|
33
|
+
const refund = cage.tool("refund", realRefund, { guard: (cents) => cents <= 5000 });
|
|
34
|
+
const wire = cage.tool("wire_transfer", realWire); // NOT granted
|
|
35
|
+
|
|
36
|
+
await search("refund policy"); // recorded: allow
|
|
37
|
+
try { await wire("0xbad"); } catch {} // BLOCKED + recorded — your customer sees the attempt
|
|
38
|
+
|
|
39
|
+
// Emit a signed receipt and hand it over:
|
|
40
|
+
const { privateKey } = generateKeypair();
|
|
41
|
+
const receipt = cage.attest(privateKey);
|
|
42
|
+
|
|
43
|
+
// THEY verify it — offline, no trust in you (or: npx @coproduct_inc/verify receipt.json):
|
|
44
|
+
const report = verifyReceipt(receipt); // report.ok === clean run, in bounds, signature valid
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run it end to end: `node examples/cage-quickstart.mjs`. The receipt attests the
|
|
48
|
+
**tool boundary** (every call was to a permitted tool); for argument-level
|
|
49
|
+
*proofs* ("amount < cap over all inputs") see the bytecode prover.
|
|
50
|
+
|
|
51
|
+
### Multi-agent? Compose the DAG — and catch what node-checks can't
|
|
52
|
+
|
|
53
|
+
A single agent you can eyeball; a DAG of agents you can't. The dangerous failure
|
|
54
|
+
only exists at DAG scale: **every node is locally in-bounds, yet a secret
|
|
55
|
+
launders from a source, through an innocent middle, to a public sink.** Per-node
|
|
56
|
+
review all passes; only the whole-workflow check catches it.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { Workflow, verifyWorkflowReceipt, generateKeypair } from "@coproduct_inc/verify";
|
|
60
|
+
|
|
61
|
+
const wf = new Workflow("spiffe://acme/intake-pipeline");
|
|
62
|
+
const reader = wf.node({ id: "reader", boundary: ["read_secret", "emit"] });
|
|
63
|
+
const enrich = wf.node({ id: "enricher", boundary: ["transform", "emit"], after: ["reader"] });
|
|
64
|
+
const pub = wf.node({ id: "publisher", boundary: ["publish"], after: ["enricher"] });
|
|
65
|
+
// …run your agents through reader/enrich/pub's caged tools…
|
|
66
|
+
|
|
67
|
+
const receipt = wf.attest(generateKeypair().privateKey);
|
|
68
|
+
|
|
69
|
+
verifyWorkflowReceipt(receipt, { sources: ["read_secret"], sinks: ["publish"] });
|
|
70
|
+
// → nodesOk: true (every node in-bounds) … but ok: false, flowOk: false:
|
|
71
|
+
// "information-flow leak: publisher→publish" — the secret reached the sink across the DAG.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It rides on whatever orchestrator you already use (LangGraph, Temporal, CrewAI…)
|
|
75
|
+
— you cage the tools, not the framework. Run it: `node examples/workflow-leak.mjs`.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
10
79
|
## In-bounds attestation — the drop-in
|
|
11
80
|
|
|
12
81
|
```ts
|
package/WHY-VERIFY.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Verify, don't trust.
|
|
2
|
+
|
|
3
|
+
**`@coproduct_inc/verify`** turns an agent run into **verifiable evidence** that it
|
|
4
|
+
stayed inside a declared capability boundary — evidence a third party can check
|
|
5
|
+
**offline, without trusting the agent or the platform that produced it.**
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx @coproduct_inc/verify nucleus-verify run-receipt.json
|
|
9
|
+
# exit 0 → in bounds · exit 1 → refused
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The problem everyone now has
|
|
13
|
+
|
|
14
|
+
97% of security leaders expect a material AI-agent incident in the next 12 months;
|
|
15
|
+
6% of budgets address it. The dominant failure mode has a name: the **lethal
|
|
16
|
+
trifecta** — an agent with private data + untrusted content + an exfiltration sink
|
|
17
|
+
gets turned into a *confused deputy*, exercising its authority for an attacker
|
|
18
|
+
(OpenClaw, CVE-2026-25253; Invariant Labs' GitHub-MCP exploit). Regulators are
|
|
19
|
+
arriving too: the EU AI Act's high-risk regime (Aug 2, 2026) demands **productized
|
|
20
|
+
evidence tied to real runs and operator identity** — not a dashboard, an artifact.
|
|
21
|
+
|
|
22
|
+
The runtime's job is to make that trifecta safe by *non-interference*. This
|
|
23
|
+
package's job is to **prove it happened**: an offline-verifiable receipt that the
|
|
24
|
+
agent stayed inside its declared boundary — i.e. was *not* a confused deputy.
|
|
25
|
+
|
|
26
|
+
## The one thing that's different: we recompute the verdict
|
|
27
|
+
|
|
28
|
+
Most "agent receipts" — including Microsoft's Agent Governance Toolkit — are a
|
|
29
|
+
**signed record of what the platform's own engine decided.** You trust the engine.
|
|
30
|
+
|
|
31
|
+
`@coproduct_inc/verify` is a **second, independent check.** Given the declared
|
|
32
|
+
boundary and the observed trace, it **re-derives** the in-bounds verdict and
|
|
33
|
+
confirms it matches the receipt — then checks an Ed25519 signature and a SHA-256
|
|
34
|
+
hash-chain. A **forged or mistaken** verdict is caught two ways: the recompute
|
|
35
|
+
disagrees, *and* the signature (taken over the honest verdict) breaks.
|
|
36
|
+
|
|
37
|
+
> A platform vendor is structurally incentivized to be *trusted*. A neutral
|
|
38
|
+
> verifier is incentivized to be *checkable*. That's the wedge.
|
|
39
|
+
|
|
40
|
+
| | Microsoft Agent Governance Toolkit | `@coproduct_inc/verify` |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Capability manifest / policy | ✅ (denies outside the manifest) | relies on yours (any runtime) |
|
|
43
|
+
| Signed, hash-chained receipts | ✅ Ed25519 decision receipts | ✅ Ed25519 + SHA-256 chain |
|
|
44
|
+
| **Independent verdict recompute** | ❌ (trust the engine's decision) | ✅ **soundness floor — catches a forged claim** |
|
|
45
|
+
| Keyed on cryptographic principal, not display name | partial | ✅ rules out OpenClaw by construction |
|
|
46
|
+
| Verdict logic backed by machine-checked theorems | ❌ | ✅ (IFC noninterference, leanchecker-rechecked) |
|
|
47
|
+
| Footprint | a governance framework | one `npx` / one GitHub Action, **zero runtime deps** |
|
|
48
|
+
| Position | the platform | the **neutral verifier above any platform** (incl. AGT) |
|
|
49
|
+
|
|
50
|
+
We don't compete with the guardrail. We're the evidence layer **above** it — and
|
|
51
|
+
we verify *their* agents too.
|
|
52
|
+
|
|
53
|
+
## See it (90 seconds)
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx @coproduct_inc/verify --help # or: bash examples/converting-demo.sh
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
1. **Clean run → attests** (exit 0). Auditor-grade evidence the agent stayed in bounds.
|
|
60
|
+
2. **OpenClaw bypass** — a call wearing an allowlisted *display name* but a different
|
|
61
|
+
cryptographic principal → **refused** (exit 1). The CVE class, ruled out by construction.
|
|
62
|
+
3. **Forge the verdict** (claim `inBounds:true`) → **caught** by the independent recompute
|
|
63
|
+
*and* the broken signature. This is the difference between a signed log and verifiable evidence.
|
|
64
|
+
4. **CI gate** — drop the Action in a PR; an out-of-bounds run **fails the build.** A merge gate, not a dashboard.
|
|
65
|
+
|
|
66
|
+
## Who it's for
|
|
67
|
+
|
|
68
|
+
- **AppSec** — a 30-second drop-in CI/PR gate, no platform migration, works above your existing guardrails.
|
|
69
|
+
- **GRC / SOC 2 / EU AI Act** — the receipt **is** the exportable audit artifact: offline-verifiable, tied to real runs and operator identity.
|
|
70
|
+
|
|
71
|
+
## Honest scope
|
|
72
|
+
|
|
73
|
+
It attests that the **observed trace** stayed within the **declared boundary** —
|
|
74
|
+
integrity-axis, model-level evidence. It is **not** a proof of arbitrary agent
|
|
75
|
+
behavior, and it does not vouch that the trace is a *complete* record of what the
|
|
76
|
+
agent did (completeness of capture is the runtime's job). It's the verifiable
|
|
77
|
+
evidence layer above probabilistic guardrails, not a replacement for them.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
Sources for the market claims: [VentureBeat / Arkose 2026 agent-security survey](https://venturebeat.com/security/most-enterprises-cant-stop-stage-three-ai-agent-threats-venturebeat-survey-finds), [EU AI Act Art. 16 / Aug 2026](https://artificialintelligenceact.eu/article/16/), [Microsoft Agent Governance Toolkit](https://opensource.microsoft.com/blog/2026/04/02/introducing-the-agent-governance-toolkit-open-source-runtime-security-for-ai-agents/), [PipeLab — mediator receipts / independent attestation](https://pipelab.org/blog/independent-attestation-mediator-receipts/).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { verifyReceipt, verifyReceiptJson, recomputeVerdict, canonicalize, rootHash, makeBoundary, permissionFingerprint, } from "./attestation.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Browser-only entry: the SAME in-bounds attestation verifier, with node:crypto
|
|
2
|
+
// aliased to a @noble shim at bundle time. Verify-only (no signReceipt).
|
|
3
|
+
export { verifyReceipt, verifyReceiptJson, recomputeVerdict, canonicalize, rootHash, makeBoundary, permissionFingerprint, } from "./attestation.js";
|
package/dist/cage.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { KeyObject } from "node:crypto";
|
|
2
|
+
import type { Receipt } from "./attestation.js";
|
|
3
|
+
import { type ToolProxyExport } from "./toolproxy.js";
|
|
4
|
+
/** Thrown when a caged tool call violates policy (and is blocked). */
|
|
5
|
+
export declare class CageDenied extends Error {
|
|
6
|
+
readonly tool: string;
|
|
7
|
+
constructor(tool: string, message: string);
|
|
8
|
+
}
|
|
9
|
+
export interface CageOptions {
|
|
10
|
+
/** Cryptographic principal the receipt is keyed on — a stable id (SPIFFE id,
|
|
11
|
+
* key fingerprint, anything), NOT a mutable display name. */
|
|
12
|
+
principal: string;
|
|
13
|
+
/** The tools this agent is permitted to call — its capability boundary. A call
|
|
14
|
+
* to anything else is denied and recorded. */
|
|
15
|
+
allowedTools: string[];
|
|
16
|
+
}
|
|
17
|
+
export interface ToolOptions<A extends unknown[]> {
|
|
18
|
+
/** A per-call predicate to deny an in-boundary tool on specific arguments
|
|
19
|
+
* (e.g. only allow `pay` below a cap). Returning false → blocked at runtime +
|
|
20
|
+
* recorded as a deny. NOTE: the receipt attests the TOOL boundary (tool ∈
|
|
21
|
+
* allowedTools); a guard is finer RUNTIME hardening — a guard-denied call to
|
|
22
|
+
* an allowed tool is still "in bounds" in the receipt (the tool was permitted).
|
|
23
|
+
* Argument-level *proof* (e.g. "amount < cap over all inputs") is the
|
|
24
|
+
* bytecode prover's job, not this receipt's. */
|
|
25
|
+
guard?: (...args: A) => boolean;
|
|
26
|
+
/** Bind the call to its target (a URL, path, counterparty…) — recorded as a
|
|
27
|
+
* digest in the receipt, so the receipt is specific to what was acted on. */
|
|
28
|
+
subject?: (...args: A) => string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A runtime cage for an agent's tool calls. Wrap each tool with {@link Cage.tool};
|
|
32
|
+
* every call is policy-checked and recorded, and a denied call is BLOCKED. At the
|
|
33
|
+
* end {@link Cage.attest} signs an in-bounds receipt verifiable offline by this
|
|
34
|
+
* same package.
|
|
35
|
+
*/
|
|
36
|
+
export declare class Cage {
|
|
37
|
+
private readonly opts;
|
|
38
|
+
private readonly records;
|
|
39
|
+
constructor(opts: CageOptions);
|
|
40
|
+
/** Wrap a tool function so each call is caged + recorded. Works for sync and
|
|
41
|
+
* async tools (a denied call throws `CageDenied` before the tool runs). */
|
|
42
|
+
tool<A extends unknown[], R>(name: string, fn: (...args: A) => R, options?: ToolOptions<A>): (...args: A) => R;
|
|
43
|
+
/** Record a call you invoke yourself (when wrapping the fn isn't convenient). */
|
|
44
|
+
record(name: string, allowed: boolean, subject?: string): void;
|
|
45
|
+
/** How many calls have been caged so far. */
|
|
46
|
+
get callCount(): number;
|
|
47
|
+
/** The raw verdict log — the tool-proxy export the verifier consumes. */
|
|
48
|
+
toExport(): ToolProxyExport;
|
|
49
|
+
/** Sign an in-bounds attestation receipt. Verify it anywhere with
|
|
50
|
+
* `verifyReceipt` / the `@coproduct_inc/verify` CLI — no trust in you required. */
|
|
51
|
+
attest(privateKey: KeyObject, opts?: {
|
|
52
|
+
signedAt?: string;
|
|
53
|
+
}): Receipt;
|
|
54
|
+
}
|
package/dist/cage.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { attestToolProxyRun } from "./toolproxy.js";
|
|
2
|
+
/** Thrown when a caged tool call violates policy (and is blocked). */
|
|
3
|
+
export class CageDenied extends Error {
|
|
4
|
+
tool;
|
|
5
|
+
constructor(tool, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.tool = tool;
|
|
8
|
+
this.name = "CageDenied";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A runtime cage for an agent's tool calls. Wrap each tool with {@link Cage.tool};
|
|
13
|
+
* every call is policy-checked and recorded, and a denied call is BLOCKED. At the
|
|
14
|
+
* end {@link Cage.attest} signs an in-bounds receipt verifiable offline by this
|
|
15
|
+
* same package.
|
|
16
|
+
*/
|
|
17
|
+
export class Cage {
|
|
18
|
+
opts;
|
|
19
|
+
records = [];
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.opts = opts;
|
|
22
|
+
}
|
|
23
|
+
/** Wrap a tool function so each call is caged + recorded. Works for sync and
|
|
24
|
+
* async tools (a denied call throws `CageDenied` before the tool runs). */
|
|
25
|
+
tool(name, fn, options) {
|
|
26
|
+
return (...args) => {
|
|
27
|
+
const inBoundary = this.opts.allowedTools.includes(name);
|
|
28
|
+
const guardOk = !options?.guard || options.guard(...args);
|
|
29
|
+
const ok = inBoundary && guardOk;
|
|
30
|
+
const rec = { operation: name, verdict: ok ? "allow" : "deny", actor: this.opts.principal };
|
|
31
|
+
const subject = options?.subject?.(...args);
|
|
32
|
+
if (subject)
|
|
33
|
+
rec.subject = subject;
|
|
34
|
+
this.records.push(rec);
|
|
35
|
+
if (!ok) {
|
|
36
|
+
throw new CageDenied(name, inBoundary
|
|
37
|
+
? `tool "${name}" denied by its guard on these arguments`
|
|
38
|
+
: `tool "${name}" is outside the agent's capability boundary {${this.opts.allowedTools.join(", ")}}`);
|
|
39
|
+
}
|
|
40
|
+
return fn(...args);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Record a call you invoke yourself (when wrapping the fn isn't convenient). */
|
|
44
|
+
record(name, allowed, subject) {
|
|
45
|
+
const rec = { operation: name, verdict: allowed ? "allow" : "deny", actor: this.opts.principal };
|
|
46
|
+
if (subject)
|
|
47
|
+
rec.subject = subject;
|
|
48
|
+
this.records.push(rec);
|
|
49
|
+
}
|
|
50
|
+
/** How many calls have been caged so far. */
|
|
51
|
+
get callCount() {
|
|
52
|
+
return this.records.length;
|
|
53
|
+
}
|
|
54
|
+
/** The raw verdict log — the tool-proxy export the verifier consumes. */
|
|
55
|
+
toExport() {
|
|
56
|
+
return {
|
|
57
|
+
boundary: { principal: this.opts.principal, allowedTools: this.opts.allowedTools },
|
|
58
|
+
records: [...this.records],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Sign an in-bounds attestation receipt. Verify it anywhere with
|
|
62
|
+
* `verifyReceipt` / the `@coproduct_inc/verify` CLI — no trust in you required. */
|
|
63
|
+
attest(privateKey, opts) {
|
|
64
|
+
return attestToolProxyRun(this.toExport(), privateKey, opts).receipt;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `nucleus-verify-claim` — the **proof gate**. Adjudicate one or more
|
|
4
|
+
* recompute-verified claims and use the exit code as a CI/merge gate.
|
|
5
|
+
*
|
|
6
|
+
* 0 → every claim verified (or no claim files found, unless --require)
|
|
7
|
+
* 1 → at least one claim failed verification
|
|
8
|
+
* 2 → usage / I/O error
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* nucleus-verify-claim <claim.json...> [--summary <file>] [--json]
|
|
12
|
+
* [--require] [--quiet]
|
|
13
|
+
*
|
|
14
|
+
* Each `<claim.json>` holds a `Claim` (or an array of them). Evidence may be
|
|
15
|
+
* inlined (`evidence.receipt`) or referenced by path (`evidence.receiptPath`,
|
|
16
|
+
* resolved relative to the claim file) so a repo commits a small claim that
|
|
17
|
+
* points at a checked-in receipt.
|
|
18
|
+
*
|
|
19
|
+
* Designed as a drop-in GitHub Action step: writes a Markdown table to
|
|
20
|
+
* `--summary` (point it at `$GITHUB_STEP_SUMMARY`) and a one-line-per-claim
|
|
21
|
+
* log, then fails the job if any claim did not recompute.
|
|
22
|
+
*/
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `nucleus-verify-claim` — the **proof gate**. Adjudicate one or more
|
|
4
|
+
* recompute-verified claims and use the exit code as a CI/merge gate.
|
|
5
|
+
*
|
|
6
|
+
* 0 → every claim verified (or no claim files found, unless --require)
|
|
7
|
+
* 1 → at least one claim failed verification
|
|
8
|
+
* 2 → usage / I/O error
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* nucleus-verify-claim <claim.json...> [--summary <file>] [--json]
|
|
12
|
+
* [--require] [--quiet]
|
|
13
|
+
*
|
|
14
|
+
* Each `<claim.json>` holds a `Claim` (or an array of them). Evidence may be
|
|
15
|
+
* inlined (`evidence.receipt`) or referenced by path (`evidence.receiptPath`,
|
|
16
|
+
* resolved relative to the claim file) so a repo commits a small claim that
|
|
17
|
+
* points at a checked-in receipt.
|
|
18
|
+
*
|
|
19
|
+
* Designed as a drop-in GitHub Action step: writes a Markdown table to
|
|
20
|
+
* `--summary` (point it at `$GITHUB_STEP_SUMMARY`) and a one-line-per-claim
|
|
21
|
+
* log, then fails the job if any claim did not recompute.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
24
|
+
import { dirname, resolve } from "node:path";
|
|
25
|
+
import { verifyClaim } from "./claim.js";
|
|
26
|
+
const HELP = `nucleus-verify-claim — recompute-verified proof gate
|
|
27
|
+
|
|
28
|
+
USAGE:
|
|
29
|
+
nucleus-verify-claim <claim.json...> [options]
|
|
30
|
+
|
|
31
|
+
OPTIONS:
|
|
32
|
+
--summary <file> append a Markdown result table to <file>
|
|
33
|
+
(use "$GITHUB_STEP_SUMMARY" inside a GitHub Action)
|
|
34
|
+
--json print the full verdicts as JSON to stdout
|
|
35
|
+
--require fail (exit 1) if NO claim files were supplied/matched
|
|
36
|
+
--quiet suppress per-claim log lines (exit code + summary only)
|
|
37
|
+
-h, --help show this help
|
|
38
|
+
|
|
39
|
+
EXIT CODES:
|
|
40
|
+
0 all claims verified 1 a claim failed / none found with --require 2 usage / I/O error`;
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const args = { paths: [], json: false, require: false, quiet: false, help: false };
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
switch (a) {
|
|
46
|
+
case "-h":
|
|
47
|
+
case "--help":
|
|
48
|
+
args.help = true;
|
|
49
|
+
break;
|
|
50
|
+
case "--json":
|
|
51
|
+
args.json = true;
|
|
52
|
+
break;
|
|
53
|
+
case "--require":
|
|
54
|
+
args.require = true;
|
|
55
|
+
break;
|
|
56
|
+
case "--quiet":
|
|
57
|
+
args.quiet = true;
|
|
58
|
+
break;
|
|
59
|
+
case "--summary":
|
|
60
|
+
args.summary = argv[++i];
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
if (a.startsWith("--"))
|
|
64
|
+
throw new Error(`unknown option ${a}`);
|
|
65
|
+
args.paths.push(a);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return args;
|
|
69
|
+
}
|
|
70
|
+
/** Load the claims from one file, resolving any `receiptPath` against it. */
|
|
71
|
+
function loadClaims(path) {
|
|
72
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
73
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
74
|
+
return entries.map((entry) => {
|
|
75
|
+
const ev = entry.evidence ?? {};
|
|
76
|
+
if (ev.receiptPath && ev.receipt === undefined) {
|
|
77
|
+
const receiptText = readFileSync(resolve(dirname(path), ev.receiptPath), "utf8");
|
|
78
|
+
// Keep object receipts as objects (in_bounds) and string receipts as
|
|
79
|
+
// strings (clearing wire form) — try to parse, fall back to raw text.
|
|
80
|
+
let receipt;
|
|
81
|
+
try {
|
|
82
|
+
receipt = JSON.parse(receiptText);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
receipt = receiptText;
|
|
86
|
+
}
|
|
87
|
+
return { ...entry, evidence: { ...ev, receipt } };
|
|
88
|
+
}
|
|
89
|
+
return entry;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function statusCell(v) {
|
|
93
|
+
return v.verified ? "✓ verified" : `✗ ${v.reason ?? "failed"}`;
|
|
94
|
+
}
|
|
95
|
+
function writeSummary(file, verdicts) {
|
|
96
|
+
const failed = verdicts.filter((v) => !v.verified).length;
|
|
97
|
+
const lines = [
|
|
98
|
+
"## Proof gate — recompute-verified claims",
|
|
99
|
+
"",
|
|
100
|
+
"| claim | kind | verdict |",
|
|
101
|
+
"| --- | --- | --- |",
|
|
102
|
+
...verdicts.map((v) => `| ${escapePipes(v.statement)} | \`${v.kind}\` | ${escapePipes(statusCell(v))} |`),
|
|
103
|
+
"",
|
|
104
|
+
failed === 0
|
|
105
|
+
? `**✓ all ${verdicts.length} claim${verdicts.length === 1 ? "" : "s"} recompute-verified.**`
|
|
106
|
+
: `**✗ ${failed} of ${verdicts.length} claim${verdicts.length === 1 ? "" : "s"} failed.**`,
|
|
107
|
+
"",
|
|
108
|
+
];
|
|
109
|
+
// `$GITHUB_STEP_SUMMARY` is appended to across steps; honor that.
|
|
110
|
+
const body = lines.join("\n");
|
|
111
|
+
try {
|
|
112
|
+
appendFileSync(file, body + "\n");
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
writeFileSync(file, body + "\n");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function escapePipes(s) {
|
|
119
|
+
return s.replace(/\|/g, "\\|");
|
|
120
|
+
}
|
|
121
|
+
async function main() {
|
|
122
|
+
let args;
|
|
123
|
+
try {
|
|
124
|
+
args = parseArgs(process.argv.slice(2));
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
process.stderr.write(`error: ${e.message}\n\n${HELP}\n`);
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
if (args.help) {
|
|
131
|
+
process.stdout.write(HELP + "\n");
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (args.paths.length === 0) {
|
|
135
|
+
if (args.require) {
|
|
136
|
+
process.stderr.write("error: no claim files supplied (--require)\n");
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
process.stdout.write("proof gate: no claims to verify.\n");
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
let claims;
|
|
143
|
+
try {
|
|
144
|
+
claims = args.paths.flatMap(loadClaims);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
process.stderr.write(`error: cannot read claims: ${e.message}\n`);
|
|
148
|
+
return 2;
|
|
149
|
+
}
|
|
150
|
+
const verdicts = [];
|
|
151
|
+
for (const claim of claims) {
|
|
152
|
+
verdicts.push(await verifyClaim(claim));
|
|
153
|
+
}
|
|
154
|
+
if (args.json) {
|
|
155
|
+
process.stdout.write(JSON.stringify(verdicts, null, 2) + "\n");
|
|
156
|
+
}
|
|
157
|
+
else if (!args.quiet) {
|
|
158
|
+
for (const v of verdicts) {
|
|
159
|
+
const mark = v.verified ? "✓" : "✗";
|
|
160
|
+
const stream = v.verified ? process.stdout : process.stderr;
|
|
161
|
+
stream.write(`${mark} [${v.kind}] ${v.statement} — ${statusCell(v)}\n`);
|
|
162
|
+
if (!v.verified) {
|
|
163
|
+
for (const step of v.trace.filter((s) => !s.ok)) {
|
|
164
|
+
stream.write(` ↳ ${step.check}${step.detail ? `: ${step.detail}` : ""}\n`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (args.summary) {
|
|
170
|
+
try {
|
|
171
|
+
writeSummary(args.summary, verdicts);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
process.stderr.write(`warning: could not write summary: ${e.message}\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const failed = verdicts.filter((v) => !v.verified).length;
|
|
178
|
+
if (!args.quiet) {
|
|
179
|
+
process.stdout.write(failed === 0
|
|
180
|
+
? `proof gate: ✓ ${verdicts.length} verified.\n`
|
|
181
|
+
: `proof gate: ✗ ${failed} of ${verdicts.length} failed.\n`);
|
|
182
|
+
}
|
|
183
|
+
return failed === 0 ? 0 : 1;
|
|
184
|
+
}
|
|
185
|
+
main().then((code) => process.exit(code));
|
package/dist/claim.d.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claim.ts` — a **recompute-verified claim** façade.
|
|
3
|
+
*
|
|
4
|
+
* One uniform call — `verifyClaim(claim) → ClaimVerdict` — over the package's
|
|
5
|
+
* existing recompute engines, so any surface (a comment-box widget, a CI
|
|
6
|
+
* check, an MCP tool) can render a single ✓/✗ plus a human-readable recompute
|
|
7
|
+
* trace next to a claim, without knowing which engine adjudicated it.
|
|
8
|
+
*
|
|
9
|
+
* ## The honest boundary
|
|
10
|
+
*
|
|
11
|
+
* This verifies **only** claims that reduce to a *deterministic recomputation
|
|
12
|
+
* over committed evidence* — never arbitrary prose. A claim is adjudicable iff
|
|
13
|
+
* its value is the output of a pure function the caller can re-run from the
|
|
14
|
+
* cited inputs:
|
|
15
|
+
*
|
|
16
|
+
* - `clearing` — re-run an auction's clearing from its SIGNED bid set and
|
|
17
|
+
* check the receipt's price/winner (WASM kernel; the exact
|
|
18
|
+
* `verify` / `recomputeClearing` path).
|
|
19
|
+
* - `in_bounds` — re-run an agent run's capability verdict from its tool
|
|
20
|
+
* trace + declared boundary (pure-node attestation engine).
|
|
21
|
+
* - `signature` — authenticity only: Ed25519 + BLAKE3 root hash, no
|
|
22
|
+
* value recompute.
|
|
23
|
+
*
|
|
24
|
+
* Anything NOT reducible to a recompute (e.g. "10k users downloaded this")
|
|
25
|
+
* returns `verified: false` with an explicit `unsupported`/`reason` — it is
|
|
26
|
+
* never silently passed. `eval_score` and `dataset_aggregate` are the planned
|
|
27
|
+
* next kinds (see docs/RECOMPUTE-VERIFIED-CLAIMS.md); until wired they report
|
|
28
|
+
* unsupported rather than faking a check.
|
|
29
|
+
*
|
|
30
|
+
* ## Proof-carrying numbers
|
|
31
|
+
*
|
|
32
|
+
* When a claim carries an `asserted` value (the number a human wrote in prose,
|
|
33
|
+
* e.g. "cleared at 400000 µ$"), it is cross-checked against the recomputed
|
|
34
|
+
* value. A receipt that is authentic but whose prose number was altered fails
|
|
35
|
+
* (`verified: false`, `asserted.matched: false`) — the receipt can't lie, and
|
|
36
|
+
* neither can the sentence quoting it.
|
|
37
|
+
*/
|
|
38
|
+
/** The closed set of recomputable claim kinds. */
|
|
39
|
+
export type ClaimKind = "clearing" | "in_bounds" | "signature";
|
|
40
|
+
/** Evidence a claim is recomputed against. Shape depends on `kind`. */
|
|
41
|
+
export interface ClaimEvidence {
|
|
42
|
+
/**
|
|
43
|
+
* For `clearing` / `signature`: the auction receipt as a JSON **string**
|
|
44
|
+
* (the hub's wire form). For `in_bounds`: the attestation receipt **object**
|
|
45
|
+
* (as produced by `signReceipt` / returned from a tool-proxy run).
|
|
46
|
+
*/
|
|
47
|
+
receipt: string | Record<string, unknown>;
|
|
48
|
+
/** Pinned issuer JWKS JSON string. Required for `signature`; for `clearing`
|
|
49
|
+
* it upgrades the check from recompute-only to recompute + signature. */
|
|
50
|
+
jwks?: string;
|
|
51
|
+
/** `in_bounds`: pin the expected cryptographic principal / public key. */
|
|
52
|
+
expectPrincipal?: string;
|
|
53
|
+
expectPublicKey?: string;
|
|
54
|
+
}
|
|
55
|
+
/** An author-asserted value to cross-check against the recompute. */
|
|
56
|
+
export interface ClaimAssertion {
|
|
57
|
+
/** `clearing`: integer micro-USD price. `in_bounds`: boolean. */
|
|
58
|
+
value: number | boolean | string;
|
|
59
|
+
}
|
|
60
|
+
export interface Claim {
|
|
61
|
+
/** Human-readable statement rendered next to the ✓/✗. */
|
|
62
|
+
statement: string;
|
|
63
|
+
kind: ClaimKind;
|
|
64
|
+
evidence: ClaimEvidence;
|
|
65
|
+
/** Optional prose value to cross-check (proof-carrying numbers). */
|
|
66
|
+
asserted?: ClaimAssertion;
|
|
67
|
+
}
|
|
68
|
+
export interface ClaimTraceStep {
|
|
69
|
+
check: string;
|
|
70
|
+
ok: boolean;
|
|
71
|
+
detail?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface ClaimVerdict {
|
|
74
|
+
/** The single boolean to gate the ✓/✗ on. */
|
|
75
|
+
verified: boolean;
|
|
76
|
+
kind: ClaimKind;
|
|
77
|
+
statement: string;
|
|
78
|
+
/** What the recompute produced (kind-specific), or null on a hard error. */
|
|
79
|
+
recomputed: {
|
|
80
|
+
label: string;
|
|
81
|
+
value: unknown;
|
|
82
|
+
} | null;
|
|
83
|
+
/** The author's asserted value + whether it matched the recompute. */
|
|
84
|
+
asserted: {
|
|
85
|
+
value: unknown;
|
|
86
|
+
matched: boolean;
|
|
87
|
+
} | null;
|
|
88
|
+
/** Ordered, human-readable trace of the checks that ran. */
|
|
89
|
+
trace: ClaimTraceStep[];
|
|
90
|
+
/** First failing reason, or null when verified. */
|
|
91
|
+
reason: string | null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Adjudicate a claim by recomputation. Never throws — a malformed receipt,
|
|
95
|
+
* an unknown kind, or an engine error surface as `verified: false` with a
|
|
96
|
+
* `reason`, so the widget can always render a definite ✓/✗.
|
|
97
|
+
*/
|
|
98
|
+
export declare function verifyClaim(claim: Claim): Promise<ClaimVerdict>;
|