@coproduct_inc/verify 0.3.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 CHANGED
@@ -2,6 +2,27 @@
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
+
5
26
  ## 0.3.0
6
27
 
7
28
  ### Added
package/README.md CHANGED
@@ -12,6 +12,70 @@ Offline, **zero-trust** verification for Nucleus. Two receipt families share one
12
12
 
13
13
  ---
14
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
+
15
79
  ## In-bounds attestation — the drop-in
16
80
 
17
81
  ```ts
@@ -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));
@@ -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>;
package/dist/claim.js ADDED
@@ -0,0 +1,186 @@
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
+ import { verify, recomputeClearing } from "./index.js";
39
+ import { verifyReceipt } from "./attestation.js";
40
+ function receiptJson(ev) {
41
+ return typeof ev.receipt === "string" ? ev.receipt : JSON.stringify(ev.receipt);
42
+ }
43
+ /**
44
+ * Adjudicate a claim by recomputation. Never throws — a malformed receipt,
45
+ * an unknown kind, or an engine error surface as `verified: false` with a
46
+ * `reason`, so the widget can always render a definite ✓/✗.
47
+ */
48
+ export async function verifyClaim(claim) {
49
+ const base = { kind: claim.kind, statement: claim.statement };
50
+ try {
51
+ switch (claim.kind) {
52
+ case "clearing":
53
+ return await verifyClearingClaim(claim, base);
54
+ case "in_bounds":
55
+ return verifyInBoundsClaim(claim, base);
56
+ case "signature":
57
+ return await verifySignatureClaim(claim, base);
58
+ default:
59
+ return {
60
+ ...base,
61
+ verified: false,
62
+ recomputed: null,
63
+ asserted: null,
64
+ trace: [{ check: "kind-supported", ok: false, detail: `unsupported kind: ${claim.kind}` }],
65
+ reason: `unsupported claim kind: ${claim.kind} (not recomputable)`,
66
+ };
67
+ }
68
+ }
69
+ catch (e) {
70
+ return {
71
+ ...base,
72
+ verified: false,
73
+ recomputed: null,
74
+ asserted: null,
75
+ trace: [{ check: "engine", ok: false, detail: String(e) }],
76
+ reason: `recompute engine error: ${e instanceof Error ? e.message : String(e)}`,
77
+ };
78
+ }
79
+ }
80
+ async function verifyClearingClaim(claim, base) {
81
+ const json = receiptJson(claim.evidence);
82
+ const trace = [];
83
+ let recomputedPrice;
84
+ let coreOk;
85
+ let reason;
86
+ if (claim.evidence.jwks) {
87
+ // Full path: signature + root hash + price recompute.
88
+ const r = await verify(json, claim.evidence.jwks);
89
+ trace.push({ check: "signature", ok: r.signature_ok });
90
+ trace.push({ check: "root-hash", ok: r.root_hash_ok });
91
+ trace.push({ check: "price-recomputed", ok: r.price_recomputed_ok });
92
+ recomputedPrice = r.recomputed_price_micro_usd;
93
+ coreOk = r.ok;
94
+ reason = r.reason;
95
+ }
96
+ else {
97
+ // Recompute-only path (no pinned JWKS supplied).
98
+ const r = await recomputeClearing(json);
99
+ trace.push({ check: "price-recomputed", ok: r.matches_receipt });
100
+ trace.push({ check: "winner-recomputed", ok: r.winner_matches });
101
+ recomputedPrice = r.recomputed_price_micro_usd;
102
+ coreOk = r.matches_receipt;
103
+ reason = r.reason;
104
+ }
105
+ const asserted = crossCheck(claim.asserted, recomputedPrice, trace, "asserted-price");
106
+ return {
107
+ ...base,
108
+ verified: coreOk && (asserted?.matched ?? true),
109
+ recomputed: { label: "clearing_price_micro_usd", value: recomputedPrice },
110
+ asserted,
111
+ trace,
112
+ reason: !coreOk ? (reason ?? "clearing did not recompute") : assertedReason(asserted),
113
+ };
114
+ }
115
+ function verifyInBoundsClaim(claim, base) {
116
+ const receipt = typeof claim.evidence.receipt === "string"
117
+ ? JSON.parse(claim.evidence.receipt)
118
+ : claim.evidence.receipt;
119
+ const r = verifyReceipt(receipt, {
120
+ expectPrincipal: claim.evidence.expectPrincipal,
121
+ expectPublicKey: claim.evidence.expectPublicKey,
122
+ });
123
+ const trace = [
124
+ { check: "signature", ok: r.signatureOk },
125
+ { check: "root-hash", ok: r.rootHashOk },
126
+ { check: "verdict-consistent", ok: r.verdictConsistentOk },
127
+ { check: "in-bounds", ok: r.inBounds },
128
+ ];
129
+ if (claim.evidence.expectPrincipal !== undefined) {
130
+ trace.push({ check: "principal-pinned", ok: r.principalOk ?? false });
131
+ }
132
+ const asserted = crossCheck(claim.asserted, r.inBounds, trace, "asserted-in-bounds");
133
+ return {
134
+ ...base,
135
+ verified: r.ok && (asserted?.matched ?? true),
136
+ recomputed: { label: "in_bounds", value: r.inBounds },
137
+ asserted,
138
+ trace,
139
+ reason: !r.ok ? (r.reason ?? "attestation did not verify") : assertedReason(asserted),
140
+ };
141
+ }
142
+ async function verifySignatureClaim(claim, base) {
143
+ if (!claim.evidence.jwks) {
144
+ return {
145
+ ...base,
146
+ verified: false,
147
+ recomputed: null,
148
+ asserted: null,
149
+ trace: [{ check: "jwks-present", ok: false }],
150
+ reason: "signature claim requires evidence.jwks",
151
+ };
152
+ }
153
+ // Reuse the full verifier but gate on authenticity only (signature + root
154
+ // hash), not price correctness — the `signature` kind asserts provenance.
155
+ const r = await verify(receiptJson(claim.evidence), claim.evidence.jwks);
156
+ const ok = r.signature_ok && r.root_hash_ok;
157
+ return {
158
+ ...base,
159
+ verified: ok,
160
+ recomputed: { label: "authentic", value: ok },
161
+ asserted: null,
162
+ trace: [
163
+ { check: "signature", ok: r.signature_ok },
164
+ { check: "root-hash", ok: r.root_hash_ok },
165
+ ],
166
+ reason: ok ? null : (r.reason ?? "signature or root hash failed"),
167
+ };
168
+ }
169
+ /** Cross-check a prose-asserted value against the recomputed value. */
170
+ function crossCheck(asserted, recomputed, trace, check) {
171
+ if (asserted === undefined)
172
+ return null;
173
+ const matched = asserted.value === recomputed;
174
+ trace.push({
175
+ check,
176
+ ok: matched,
177
+ detail: matched ? undefined : `asserted ${String(asserted.value)} ≠ recomputed ${String(recomputed)}`,
178
+ });
179
+ return { value: asserted.value, matched };
180
+ }
181
+ function assertedReason(asserted) {
182
+ if (asserted && !asserted.matched) {
183
+ return `asserted value (${String(asserted.value)}) does not match the recompute`;
184
+ }
185
+ return null;
186
+ }
package/dist/index.d.ts CHANGED
@@ -95,4 +95,7 @@ export declare function recomputeClearing(receiptJson: string): Promise<Recomput
95
95
  export declare function verifySignature(receiptJson: string, jwksJson: string): Promise<unknown>;
96
96
  export * from "./attestation.js";
97
97
  export * from "./toolproxy.js";
98
+ export * from "./cage.js";
99
+ export * from "./workflow.js";
98
100
  export * from "./license.js";
101
+ export * from "./claim.js";
package/dist/index.js CHANGED
@@ -70,6 +70,16 @@ export * from "./attestation.js";
70
70
  // Producer-side instrumentation: turn a nucleus-tool-proxy trace into a signed
71
71
  // in-bounds receipt. See `./toolproxy` and the `nucleus-attest` CLI.
72
72
  export * from "./toolproxy.js";
73
+ // The 5-minute drop-in: a runtime `Cage` wraps an agent's tool calls (enforce +
74
+ // record) and emits a signed receipt the lines above verify. See `./cage`.
75
+ export * from "./cage.js";
76
+ // The DAG cage: compose a multi-agent run into one verifiable `WorkflowReceipt`,
77
+ // including the cross-node information flow ("locally fine, globally leaks").
78
+ export * from "./workflow.js";
73
79
  // Offline Ed25519 license keys: entitlement with no user DB / no callback.
74
80
  // See `./license` and the `nucleus-license` CLI.
75
81
  export * from "./license.js";
82
+ // Recompute-verified CLAIM façade: one `verifyClaim(claim) → ClaimVerdict`
83
+ // over all of the above engines, for embeddable ✓/✗ + recompute-trace
84
+ // widgets. See `./claim` and docs/RECOMPUTE-VERIFIED-CLAIMS.md.
85
+ export * from "./claim.js";
@@ -0,0 +1,85 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ import { Cage } from "./cage.js";
3
+ import { type TraceEvent } from "./attestation.js";
4
+ declare const WF_KIND = "nucleus.workflow-receipt.v1";
5
+ export interface WorkflowNodeSpec {
6
+ /** Unique node id within the workflow. */
7
+ id: string;
8
+ /** This node's capability boundary (the tools it may call). */
9
+ boundary: string[];
10
+ /** Parent node ids — the DAG edges (data flows parent → this node). */
11
+ after?: string[];
12
+ }
13
+ export interface WorkflowReceiptNode {
14
+ id: string;
15
+ after: string[];
16
+ boundary: string[];
17
+ events: TraceEvent[];
18
+ /** Independently recomputed by the verifier; the emitter's claim is not trusted. */
19
+ inBounds: boolean;
20
+ }
21
+ export interface WorkflowReceipt {
22
+ kind: typeof WF_KIND;
23
+ principal: string;
24
+ nodes: WorkflowReceiptNode[];
25
+ rootHash: string;
26
+ publicKey: string;
27
+ signature: string;
28
+ signedAt?: string;
29
+ }
30
+ /** An information-flow policy checked across the whole DAG. */
31
+ export interface FlowPolicy {
32
+ /** Tools that introduce taint (e.g. reading a secret). */
33
+ sources: string[];
34
+ /** Tools that must never be reached while tainted (e.g. an external write). */
35
+ sinks: string[];
36
+ /** Nodes that sanitize — they clear incoming taint (a sanitizer in the chain). */
37
+ declassifiers?: string[];
38
+ }
39
+ export interface FlowLeak {
40
+ node: string;
41
+ sink: string;
42
+ taintedVia: "reads-source" | "upstream";
43
+ }
44
+ export interface WorkflowVerifyReport {
45
+ /** `signatureOk && dagOk && nodesOk && flowOk`. */
46
+ ok: boolean;
47
+ /** Ed25519 signature verifies AND the recomputed rootHash matches. */
48
+ signatureOk: boolean;
49
+ /** Edges reference real nodes and the graph is acyclic. */
50
+ dagOk: boolean;
51
+ /** Every node's recorded calls were within its declared boundary. */
52
+ nodesOk: boolean;
53
+ /** No source→sink information flow across the DAG (per the flow policy). */
54
+ flowOk: boolean;
55
+ /** The source→sink leaks found (empty when `flowOk`). */
56
+ leaks: FlowLeak[];
57
+ /** Nodes whose recompute found an out-of-boundary call. */
58
+ outOfBoundsNodes: string[];
59
+ reason: string | null;
60
+ }
61
+ /**
62
+ * A DAG cage. Register nodes (each gets a {@link Cage}), run your agents through
63
+ * the caged tools, then {@link Workflow.attest} a single signed receipt over the
64
+ * whole graph — verifiable offline, including the cross-node information flow.
65
+ */
66
+ export declare class Workflow {
67
+ private readonly principal;
68
+ private readonly nodes;
69
+ constructor(principal: string);
70
+ /** Register a node and return its cage. The cage's tool calls are recorded
71
+ * under this node; `after` declares the edges into it. */
72
+ node(spec: WorkflowNodeSpec): Cage;
73
+ private receiptNodes;
74
+ /** Sign one workflow receipt over the whole DAG. */
75
+ attest(privateKey: KeyObject, opts?: {
76
+ signedAt?: string;
77
+ }): WorkflowReceipt;
78
+ }
79
+ /**
80
+ * Verify a workflow receipt fully and offline: the signature + the per-node
81
+ * in-bounds recompute + the DAG (acyclic, real edges) + the cross-node
82
+ * information flow. Never throws — failures surface as `ok:false` + `reason`.
83
+ */
84
+ export declare function verifyWorkflowReceipt(wr: WorkflowReceipt, flow?: FlowPolicy): WorkflowVerifyReport;
85
+ export {};
@@ -0,0 +1,179 @@
1
+ // The DAG cage: compose a multi-agent run into ONE verifiable workflow receipt.
2
+ //
3
+ // A single agent you can eyeball; a DAG of agents you can't. The dangerous
4
+ // failure mode only exists at DAG scale: every node is locally in-bounds, yet a
5
+ // secret laundered from a source node, through an innocent middle, to a sink.
6
+ // Per-node checks ALL pass; only the whole-workflow check catches it. This is
7
+ // that check, at the orchestration layer — a receipt-level analog of the
8
+ // bytecode noninterference proof, that rides on whatever orchestrator you use.
9
+ //
10
+ // const wf = new Workflow("spiffe://acme/intake-pipeline");
11
+ // const reader = wf.node({ id: "reader", boundary: ["read_secret", "emit"] });
12
+ // const middle = wf.node({ id: "middle", boundary: ["transform", "emit"], after: ["reader"] });
13
+ // const writer = wf.node({ id: "writer", boundary: ["publish"], after: ["middle"] });
14
+ // …run your agents, calling these caged tools…
15
+ // const receipt = wf.attest(privateKey);
16
+ //
17
+ // // your customer verifies the whole DAG offline, including the flow policy:
18
+ // verifyWorkflowReceipt(receipt, { sources: ["read_secret"], sinks: ["publish"] });
19
+ // // → flowOk:false if a secret could reach `publish`, even though every node was in-bounds.
20
+ import { createHash, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
21
+ import { Cage } from "./cage.js";
22
+ import { canonicalize, exportPublicKey } from "./attestation.js";
23
+ import { traceEventsFromRecords } from "./toolproxy.js";
24
+ const WF_KIND = "nucleus.workflow-receipt.v1";
25
+ function sha256Hex(s) {
26
+ return createHash("sha256").update(s).digest("hex");
27
+ }
28
+ function importPublicKey(x) {
29
+ return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });
30
+ }
31
+ /** Topological order of the node ids; throws on a cycle. */
32
+ function topoOrder(nodes) {
33
+ const ids = new Set(nodes.map((n) => n.id));
34
+ const indeg = new Map();
35
+ const children = new Map();
36
+ for (const n of nodes) {
37
+ indeg.set(n.id, 0);
38
+ children.set(n.id, []);
39
+ }
40
+ for (const n of nodes) {
41
+ for (const p of n.after) {
42
+ if (!ids.has(p))
43
+ throw new Error(`edge from unknown node "${p}"`);
44
+ indeg.set(n.id, (indeg.get(n.id) ?? 0) + 1);
45
+ children.get(p).push(n.id);
46
+ }
47
+ }
48
+ const queue = [...indeg].filter(([, d]) => d === 0).map(([id]) => id);
49
+ const order = [];
50
+ while (queue.length) {
51
+ const id = queue.shift();
52
+ order.push(id);
53
+ for (const c of children.get(id)) {
54
+ indeg.set(c, indeg.get(c) - 1);
55
+ if (indeg.get(c) === 0)
56
+ queue.push(c);
57
+ }
58
+ }
59
+ if (order.length !== nodes.length)
60
+ throw new Error("workflow graph has a cycle");
61
+ return order;
62
+ }
63
+ /** Recompute information-flow taint across the DAG → the leaks (independent). */
64
+ function recomputeFlow(nodes, flow) {
65
+ const sources = new Set(flow.sources);
66
+ const sinks = new Set(flow.sinks);
67
+ const declass = new Set(flow.declassifiers ?? []);
68
+ const byId = new Map(nodes.map((n) => [n.id, n]));
69
+ const tainted = new Map();
70
+ const leaks = [];
71
+ for (const id of topoOrder(nodes)) {
72
+ const n = byId.get(id);
73
+ const tools = new Set(n.events.map((e) => e.tool));
74
+ const readsSource = [...tools].some((t) => sources.has(t));
75
+ const parentTainted = n.after.some((p) => tainted.get(p));
76
+ const isTainted = declass.has(id) ? false : readsSource || parentTainted;
77
+ tainted.set(id, isTainted);
78
+ if (isTainted) {
79
+ for (const t of tools) {
80
+ if (sinks.has(t))
81
+ leaks.push({ node: id, sink: t, taintedVia: readsSource ? "reads-source" : "upstream" });
82
+ }
83
+ }
84
+ }
85
+ return leaks;
86
+ }
87
+ function nodesForHash(nodes) {
88
+ return nodes.map((n) => ({ id: n.id, after: n.after, boundary: n.boundary, events: n.events }));
89
+ }
90
+ /**
91
+ * A DAG cage. Register nodes (each gets a {@link Cage}), run your agents through
92
+ * the caged tools, then {@link Workflow.attest} a single signed receipt over the
93
+ * whole graph — verifiable offline, including the cross-node information flow.
94
+ */
95
+ export class Workflow {
96
+ principal;
97
+ nodes = new Map();
98
+ constructor(principal) {
99
+ this.principal = principal;
100
+ }
101
+ /** Register a node and return its cage. The cage's tool calls are recorded
102
+ * under this node; `after` declares the edges into it. */
103
+ node(spec) {
104
+ if (this.nodes.has(spec.id))
105
+ throw new Error(`duplicate node id "${spec.id}"`);
106
+ const cage = new Cage({ principal: `${this.principal}#${spec.id}`, allowedTools: spec.boundary });
107
+ this.nodes.set(spec.id, { id: spec.id, after: spec.after ?? [], boundary: spec.boundary, cage });
108
+ return cage;
109
+ }
110
+ receiptNodes() {
111
+ return [...this.nodes.values()].map((n) => {
112
+ const events = traceEventsFromRecords(n.cage.toExport().records);
113
+ const inBounds = events.every((e) => n.boundary.includes(e.tool));
114
+ return { id: n.id, after: n.after, boundary: n.boundary, events, inBounds };
115
+ });
116
+ }
117
+ /** Sign one workflow receipt over the whole DAG. */
118
+ attest(privateKey, opts = {}) {
119
+ const nodes = this.receiptNodes();
120
+ const rootHash = sha256Hex(canonicalize(nodesForHash(nodes)));
121
+ const pub = createPublicKey(privateKey);
122
+ const body = canonicalize({ kind: WF_KIND, principal: this.principal, rootHash });
123
+ const signature = edSign(null, Buffer.from(body, "utf8"), privateKey).toString("base64");
124
+ return {
125
+ kind: WF_KIND,
126
+ principal: this.principal,
127
+ nodes,
128
+ rootHash,
129
+ publicKey: exportPublicKey(pub),
130
+ signature,
131
+ ...(opts.signedAt ? { signedAt: opts.signedAt } : {}),
132
+ };
133
+ }
134
+ }
135
+ /**
136
+ * Verify a workflow receipt fully and offline: the signature + the per-node
137
+ * in-bounds recompute + the DAG (acyclic, real edges) + the cross-node
138
+ * information flow. Never throws — failures surface as `ok:false` + `reason`.
139
+ */
140
+ export function verifyWorkflowReceipt(wr, flow) {
141
+ // 1. signature + rootHash (the receipt is bound to its node set).
142
+ const recomputedRoot = sha256Hex(canonicalize(nodesForHash(wr.nodes)));
143
+ const rootOk = recomputedRoot === wr.rootHash;
144
+ let sigOnly = false;
145
+ try {
146
+ const body = canonicalize({ kind: wr.kind, principal: wr.principal, rootHash: wr.rootHash });
147
+ sigOnly = edVerify(null, Buffer.from(body, "utf8"), importPublicKey(wr.publicKey), Buffer.from(wr.signature, "base64"));
148
+ }
149
+ catch {
150
+ sigOnly = false;
151
+ }
152
+ const signatureOk = sigOnly && rootOk;
153
+ // 2. per-node in-bounds, recomputed from the signed events (not trusted).
154
+ const outOfBoundsNodes = wr.nodes.filter((n) => !n.events.every((e) => n.boundary.includes(e.tool))).map((n) => n.id);
155
+ const nodesOk = outOfBoundsNodes.length === 0;
156
+ // 3. DAG well-formed + 4. information flow.
157
+ let dagOk = true;
158
+ let leaks = [];
159
+ try {
160
+ topoOrder(wr.nodes); // throws on cycle / unknown edge
161
+ if (flow)
162
+ leaks = recomputeFlow(wr.nodes, flow);
163
+ }
164
+ catch {
165
+ dagOk = false;
166
+ }
167
+ const flowOk = leaks.length === 0;
168
+ const ok = signatureOk && nodesOk && dagOk && flowOk;
169
+ let reason = null;
170
+ if (!signatureOk)
171
+ reason = rootOk ? "signature did not verify" : "rootHash does not match the node set (tampered)";
172
+ else if (!dagOk)
173
+ reason = "workflow graph is cyclic or references an unknown node";
174
+ else if (!nodesOk)
175
+ reason = `nodes out of bounds: ${outOfBoundsNodes.join(", ")}`;
176
+ else if (!flowOk)
177
+ reason = `information-flow leak: ${leaks.map((l) => `${l.node}→${l.sink}`).join(", ")}`;
178
+ return { ok, signatureOk, dagOk, nodesOk, flowOk, leaks, outOfBoundsNodes, reason };
179
+ }
@@ -0,0 +1,37 @@
1
+ // The 5-minute drop-in, end to end. Run: node examples/cage-quickstart.mjs
2
+ //
3
+ // You ship an AI agent that takes actions for a customer. They won't take your
4
+ // word that it stayed in bounds. So: cage its tool calls, and hand them a
5
+ // receipt they verify themselves — trust the math, not you.
6
+ import { Cage, generateKeypair, verifyReceipt } from "../dist/index.js";
7
+
8
+ // 1. Declare the agent's capability boundary (what it's allowed to touch).
9
+ const cage = new Cage({
10
+ principal: "spiffe://acme/support-agent", // any stable id keyed to a key, not a name
11
+ allowedTools: ["search", "read_ticket", "send_reply", "refund"],
12
+ });
13
+
14
+ // 2. Wrap each tool. A guard adds a finer runtime check (never refund over $50).
15
+ const search = cage.tool("search", async (q) => `(results for "${q}")`);
16
+ const reply = cage.tool("send_reply", async (msg) => `sent: ${msg}`);
17
+ const refund = cage.tool("refund", async (cents) => `refunded ${cents}c`, { guard: (c) => c <= 5000 });
18
+ const wire = cage.tool("wire_transfer", async (acct) => `wired to ${acct}`); // NOT granted
19
+
20
+ // 3. Run your agent — calling ONLY the caged tools.
21
+ await search("refund policy");
22
+ await reply("Here's our policy …");
23
+ try { await refund(999999); } catch (e) { console.log("⛔ guard blocked over-cap refund:", e.message); }
24
+ try { await wire("0xbad"); } catch (e) { console.log("⛔ blocked out-of-boundary tool:", e.message); }
25
+
26
+ // 4. Emit a signed receipt and hand it to your customer.
27
+ const { privateKey } = generateKeypair();
28
+ const receipt = cage.attest(privateKey);
29
+
30
+ // 5. THEY verify it — offline, no trust in you. (Or: npx @coproduct_inc/verify receipt.json)
31
+ const report = verifyReceipt(receipt);
32
+ console.log("\nreceipt verified:", report.ok ? "✓ clean run" : "✗ " + report.reason);
33
+ console.log(" signature ok :", report.signatureOk);
34
+ console.log(" in bounds :", report.inBounds, "(every call was to an allowed tool)");
35
+ console.log(" recorded calls:", receipt.events.map((e) => e.tool).join(", "));
36
+ console.log("\nThe receipt is the wedge: useful to you alone today, more valuable as a");
37
+ console.log("network of recomputable receipts forms. Don't trust the seller — recompute.");
@@ -0,0 +1,40 @@
1
+ // The DAG cage, end to end — and the failure that only exists at multi-agent
2
+ // scale. Run: node examples/workflow-leak.mjs
3
+ //
4
+ // A single agent you can eyeball. A DAG of agents you can't: every node passes
5
+ // its OWN check, yet a secret launders through the pipeline to a public write.
6
+ // Per-node review misses it. The workflow receipt catches it.
7
+ import { Workflow, verifyWorkflowReceipt, generateKeypair } from "../dist/index.js";
8
+
9
+ // A 3-agent intake pipeline: reader → enricher → publisher.
10
+ const wf = new Workflow("spiffe://acme/intake-pipeline");
11
+ const reader = wf.node({ id: "reader", boundary: ["read_secret", "emit"] });
12
+ const enrich = wf.node({ id: "enricher", boundary: ["transform", "emit"], after: ["reader"] });
13
+ const publish = wf.node({ id: "publisher", boundary: ["publish"], after: ["enricher"] });
14
+
15
+ // Run your agents through the caged tools. Each node only touches tools it's
16
+ // allowed to — locally, everything is in policy.
17
+ reader.tool("read_secret", () => "customer SSN")(); // allowed for the reader
18
+ reader.tool("emit", (x) => x)();
19
+ enrich.tool("transform", (x) => x)(); // an "innocent" pass-through middle
20
+ enrich.tool("emit", (x) => x)();
21
+ publish.tool("publish", (x) => x)(); // allowed for the publisher
22
+
23
+ const { privateKey } = generateKeypair();
24
+ const receipt = wf.attest(privateKey);
25
+
26
+ // Your customer verifies the WHOLE DAG offline, with a flow policy:
27
+ // the secret source must never reach the public sink.
28
+ const report = verifyWorkflowReceipt(receipt, { sources: ["read_secret"], sinks: ["publish"] });
29
+
30
+ console.log("per-node review (what a code reviewer sees):");
31
+ for (const n of receipt.nodes) console.log(` ${n.id.padEnd(10)} in-bounds: ${n.inBounds} (${n.events.map((e) => e.tool).join(", ")})`);
32
+ console.log("\nevery node is in-bounds:", report.nodesOk);
33
+ console.log("signature valid :", report.signatureOk);
34
+ console.log("\nwhole-workflow verdict:", report.ok ? "✓ ok" : "✗ " + report.reason);
35
+ for (const l of report.leaks) console.log(` ⛔ leak: a secret reaches "${l.sink}" at node "${l.node}" (${l.taintedVia})`);
36
+
37
+ console.log("\nThe per-node checks ALL passed. Only the composition catches it.");
38
+ console.log("Add a sanitizer? declare the middle a declassifier:");
39
+ const fixed = verifyWorkflowReceipt(receipt, { sources: ["read_secret"], sinks: ["publish"], declassifiers: ["enricher"] });
40
+ console.log(" with enricher sanitizing →", fixed.ok ? "✓ ok (flow broken)" : "✗ " + fixed.reason);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@coproduct_inc/verify",
3
- "version": "0.3.0",
4
- "description": "Offline, zero-trust verification of agent capability-boundary in-bounds attestation receipts: emit and verify an Ed25519-signed, hash-chained receipt that an agent run stayed within a declared boundary, keyed on a cryptographic principal (not a mutable display name) the evidence layer above probabilistic guardrails. Also verifies Nucleus auction receipts by re-running the clearing locally. Zero runtime deps for the attestation core (node:crypto).",
3
+ "version": "0.5.0",
4
+ "description": "Offline, zero-trust verification of agent capability-boundary in-bounds attestation receipts: emit and verify an Ed25519-signed, hash-chained receipt that an agent run stayed within a declared boundary, keyed on a cryptographic principal (not a mutable display name) \u2014 the evidence layer above probabilistic guardrails. Also verifies Nucleus auction receipts by re-running the clearing locally. Zero runtime deps for the attestation core (node:crypto).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -9,13 +9,18 @@
9
9
  "bin": {
10
10
  "nucleus-verify": "dist/cli.js",
11
11
  "nucleus-attest": "dist/producer-cli.js",
12
- "nucleus-license": "dist/license-cli.js"
12
+ "nucleus-license": "dist/license-cli.js",
13
+ "nucleus-verify-claim": "dist/claim-cli.js"
13
14
  },
14
15
  "exports": {
15
16
  ".": {
16
17
  "types": "./dist/index.d.ts",
17
18
  "import": "./dist/index.js"
18
19
  },
20
+ "./workflow": {
21
+ "types": "./dist/workflow.d.ts",
22
+ "import": "./dist/workflow.js"
23
+ },
19
24
  "./attestation": {
20
25
  "types": "./dist/attestation.d.ts",
21
26
  "import": "./dist/attestation.js"
@@ -24,6 +29,10 @@
24
29
  "types": "./dist/toolproxy.d.ts",
25
30
  "import": "./dist/toolproxy.js"
26
31
  },
32
+ "./cage": {
33
+ "types": "./dist/cage.d.ts",
34
+ "import": "./dist/cage.js"
35
+ },
27
36
  "./license": {
28
37
  "types": "./dist/license.d.ts",
29
38
  "import": "./dist/license.js"
@@ -37,6 +46,8 @@
37
46
  "wasm/nodejs/nucleus_wasm_bg.wasm",
38
47
  "wasm/nodejs/nucleus_wasm_bg.wasm.d.ts",
39
48
  "examples/openclaw-replay.mjs",
49
+ "examples/cage-quickstart.mjs",
50
+ "examples/workflow-leak.mjs",
40
51
  "examples/converting-demo.sh",
41
52
  "examples/sample-toolproxy-trace.clean.json",
42
53
  "examples/sample-toolproxy-trace.bypass.json",
@@ -50,7 +61,8 @@
50
61
  "build": "tsc -p tsconfig.json",
51
62
  "typecheck": "tsc -p tsconfig.json --noEmit",
52
63
  "test": "node --test",
53
- "demo": "node examples/openclaw-replay.mjs"
64
+ "demo": "node examples/openclaw-replay.mjs",
65
+ "build:browser": "esbuild src/attestation.browser.ts --bundle --format=esm --alias:node:crypto=./src/shim/node-crypto.mjs --banner:js=\"globalThis.Buffer||={from:(i,e)=>e==='base64'?Uint8Array.from(atob(i),c=>c.charCodeAt(0)):new TextEncoder().encode(i)};\" --outfile=dist/attestation.browser.js"
54
66
  },
55
67
  "keywords": [
56
68
  "nucleus",
@@ -73,5 +85,9 @@
73
85
  "devDependencies": {
74
86
  "@types/node": "^25.9.1",
75
87
  "typescript": "^5.7.2"
88
+ },
89
+ "dependencies": {
90
+ "@noble/ed25519": "3.1.0",
91
+ "@noble/hashes": "2.2.0"
76
92
  }
77
93
  }