@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/dist/workflow.js
ADDED
|
@@ -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,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# converting-demo.sh — the 90-second "verify, don't trust" demo.
|
|
3
|
+
#
|
|
4
|
+
# Four beats: clean attests · OpenClaw bypass refused · FORGED verdict caught ·
|
|
5
|
+
# CI gate. The money beat is #3 — an independent recompute catches a forged
|
|
6
|
+
# claim that a *signed log* would wave through. That is the line between
|
|
7
|
+
# "we have receipts too" and verifiable evidence.
|
|
8
|
+
#
|
|
9
|
+
# Runs against the PUBLISHED package by default (the real install story):
|
|
10
|
+
# bash examples/converting-demo.sh
|
|
11
|
+
# Use the local build instead (no network):
|
|
12
|
+
# pnpm build && LOCAL=1 bash examples/converting-demo.sh
|
|
13
|
+
# Pacing for screen-recording (pause between beats):
|
|
14
|
+
# PAUSE=1 bash examples/converting-demo.sh
|
|
15
|
+
#
|
|
16
|
+
# Requires: node, and (default mode) npx with network. Trace fixtures are the
|
|
17
|
+
# real output of crates/nucleus-policy/examples/emit_toolproxy_trace.rs.
|
|
18
|
+
|
|
19
|
+
set -u
|
|
20
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
21
|
+
CLEAN_TRACE="$HERE/sample-toolproxy-trace.clean.json"
|
|
22
|
+
# Beat 2 uses the OpenClaw impersonation trace (principal mismatch under an
|
|
23
|
+
# allowlisted display name). sample-toolproxy-trace.bypass.json (a tool-not-
|
|
24
|
+
# allowed git_push) is the other refusal mode, gated by verify-gate.yml.
|
|
25
|
+
BYPASS_TRACE="$HERE/sample-toolproxy-trace.openclaw.json"
|
|
26
|
+
PRINCIPAL="spiffe://acme.example/agent/billing-assistant"
|
|
27
|
+
TMP="$(mktemp -d)"
|
|
28
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
29
|
+
|
|
30
|
+
if [ "${LOCAL:-0}" = "1" ]; then
|
|
31
|
+
DIST="$HERE/../dist"
|
|
32
|
+
ATTEST=(node "$DIST/producer-cli.js")
|
|
33
|
+
VERIFY=(node "$DIST/cli.js")
|
|
34
|
+
SRC="local build ($DIST)"
|
|
35
|
+
else
|
|
36
|
+
PKG="@coproduct_inc/verify@${VERIFY_VERSION:-latest}"
|
|
37
|
+
ATTEST=(npx --yes -p "$PKG" nucleus-attest)
|
|
38
|
+
VERIFY=(npx --yes -p "$PKG" nucleus-verify)
|
|
39
|
+
SRC="published npm package $PKG"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
bold() { printf "\033[1m%s\033[0m\n" "$*"; }
|
|
43
|
+
dim() { printf "\033[2m%s\033[0m\n" "$*"; }
|
|
44
|
+
ok() { printf "\033[32m%s\033[0m\n" "$*"; }
|
|
45
|
+
bad() { printf "\033[31m%s\033[0m\n" "$*"; }
|
|
46
|
+
beat() { echo; bold "── $* ──"; [ "${PAUSE:-0}" = "1" ] && read -r -p " (enter)…" _ || true; }
|
|
47
|
+
|
|
48
|
+
echo
|
|
49
|
+
bold "@coproduct_inc/verify — verify, don't trust"
|
|
50
|
+
dim "source: $SRC"
|
|
51
|
+
dim "An agent run's tool-call trace + a declared capability boundary →"
|
|
52
|
+
dim "a signed, OFFLINE-verifiable in-bounds attestation. We don't trust the"
|
|
53
|
+
dim "issuer's verdict — we recompute it."
|
|
54
|
+
|
|
55
|
+
# ── Beat 1: a clean run attests ──────────────────────────────────────────────
|
|
56
|
+
beat "1/4 Clean run → ATTESTS (exit 0)"
|
|
57
|
+
dim "nucleus-attest < clean-trace → nucleus-verify"
|
|
58
|
+
"${ATTEST[@]}" "$CLEAN_TRACE" > "$TMP/clean.json" 2> "$TMP/clean.err"
|
|
59
|
+
if "${VERIFY[@]}" "$TMP/clean.json" --expect-principal "$PRINCIPAL"; then
|
|
60
|
+
ok " → exit 0. Auditor-grade evidence the agent stayed inside its boundary."
|
|
61
|
+
else
|
|
62
|
+
bad " unexpected: clean run did not verify"; exit 1
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# ── Beat 2: the OpenClaw bypass is refused ───────────────────────────────────
|
|
66
|
+
beat "2/4 OpenClaw bypass (display-name match, principal mismatch) → REFUSED (exit 1)"
|
|
67
|
+
dim "An injected call wears the allowlisted display name but a different SPIFFE id."
|
|
68
|
+
"${ATTEST[@]}" "$BYPASS_TRACE" > "$TMP/bypass.json" 2> "$TMP/bypass.err"
|
|
69
|
+
if "${VERIFY[@]}" "$TMP/bypass.json"; then
|
|
70
|
+
bad " unexpected: bypass verified (should refuse)"; exit 1
|
|
71
|
+
else
|
|
72
|
+
ok " → exit 1. CVE-2026-25253's failure class, ruled out by construction"
|
|
73
|
+
ok " (the boundary is keyed on the cryptographic principal, never the name)."
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# ── Beat 3: the money beat — a forged verdict is caught ───────────────────────
|
|
77
|
+
beat "3/4 FORGE the verdict (claim inBounds:true) → CAUGHT"
|
|
78
|
+
dim "A signed LOG would trust this. We recompute the verdict independently."
|
|
79
|
+
node -e '
|
|
80
|
+
const fs = require("fs");
|
|
81
|
+
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
82
|
+
r.verdict.inBounds = true;
|
|
83
|
+
r.verdict.events = r.verdict.events.map(e => ({...e, inBounds:true, code:"ok", detail:"in bounds"}));
|
|
84
|
+
fs.writeFileSync(process.argv[2], JSON.stringify(r));
|
|
85
|
+
' "$TMP/bypass.json" "$TMP/forged.json"
|
|
86
|
+
"${VERIFY[@]}" "$TMP/forged.json" --json > "$TMP/forged.report.json" 2>/dev/null || true
|
|
87
|
+
SIG=$(node -e 'console.log(require(process.argv[1]).signatureOk)' "$TMP/forged.report.json")
|
|
88
|
+
CONS=$(node -e 'console.log(require(process.argv[1]).verdictConsistentOk)' "$TMP/forged.report.json")
|
|
89
|
+
OKV=$(node -e 'console.log(require(process.argv[1]).ok)' "$TMP/forged.report.json")
|
|
90
|
+
echo " signatureOk=$SIG verdictConsistentOk=$CONS ok=$OKV"
|
|
91
|
+
if [ "$OKV" = "false" ] && [ "$CONS" = "false" ]; then
|
|
92
|
+
ok " → REJECTED two independent ways: the recompute disagrees with the claim,"
|
|
93
|
+
ok " AND the signature (taken over the honest verdict) no longer verifies."
|
|
94
|
+
ok " THIS is verifiable evidence, not a signed log you have to trust."
|
|
95
|
+
else
|
|
96
|
+
bad " unexpected: forged receipt was not caught"; exit 1
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# ── Beat 4: it's a CI merge gate, not a dashboard ────────────────────────────
|
|
100
|
+
beat "4/4 It's a merge gate, not a dashboard"
|
|
101
|
+
dim "Drop the Action in a PR; an out-of-bounds run fails the build:"
|
|
102
|
+
cat <<'YAML'
|
|
103
|
+
- uses: coproduct-private/spiffy/packages/nucleus-verify@main
|
|
104
|
+
with:
|
|
105
|
+
receipt: ./agent-run-receipt.json
|
|
106
|
+
expect-principal: spiffe://acme/agent/billing-assistant
|
|
107
|
+
YAML
|
|
108
|
+
ok " clean → check passes · out-of-bounds → check fails. Exit codes are the gate."
|
|
109
|
+
|
|
110
|
+
echo
|
|
111
|
+
bold "Summary"
|
|
112
|
+
echo " • Works above ANY runtime/guardrail (incl. Microsoft AGT) — neutral verifier."
|
|
113
|
+
echo " • Offline, zero runtime deps (node:crypto). 30-second npx drop-in."
|
|
114
|
+
echo " • The verdict logic is backed by machine-checked theorems (IFC noninterference)."
|
|
115
|
+
echo " • Honest scope: attests the DECLARED boundary over the OBSERVED trace —"
|
|
116
|
+
echo " completeness of trace capture is the runtime's job."
|
|
117
|
+
ok "DEMO OK ✓"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"boundary": {
|
|
3
|
+
"allowedTools": [
|
|
4
|
+
"read_files",
|
|
5
|
+
"glob_search",
|
|
6
|
+
"grep_search"
|
|
7
|
+
],
|
|
8
|
+
"principal": "spiffe://acme.example/agent/billing-assistant"
|
|
9
|
+
},
|
|
10
|
+
"records": [
|
|
11
|
+
{
|
|
12
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
13
|
+
"deny_reason": "",
|
|
14
|
+
"operation": "glob_search",
|
|
15
|
+
"subject": "invoices/*.pdf",
|
|
16
|
+
"verdict": "allow"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
20
|
+
"deny_reason": "",
|
|
21
|
+
"operation": "read_files",
|
|
22
|
+
"subject": "invoices/2026-06.pdf",
|
|
23
|
+
"verdict": "allow"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
27
|
+
"deny_reason": "operation outside declared task allowlist",
|
|
28
|
+
"operation": "git_push",
|
|
29
|
+
"subject": "refs/heads/main",
|
|
30
|
+
"verdict": "deny"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"taskHashHex": "8aa9199f47bc66272e3a40e87d8ef6daeec2e905e5a723252e615c45c4376888"
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"boundary": {
|
|
3
|
+
"allowedTools": [
|
|
4
|
+
"read_files",
|
|
5
|
+
"glob_search",
|
|
6
|
+
"grep_search"
|
|
7
|
+
],
|
|
8
|
+
"principal": "spiffe://acme.example/agent/billing-assistant"
|
|
9
|
+
},
|
|
10
|
+
"records": [
|
|
11
|
+
{
|
|
12
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
13
|
+
"deny_reason": "",
|
|
14
|
+
"operation": "glob_search",
|
|
15
|
+
"subject": "invoices/*.pdf",
|
|
16
|
+
"verdict": "allow"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
20
|
+
"deny_reason": "",
|
|
21
|
+
"operation": "read_files",
|
|
22
|
+
"subject": "invoices/2026-06.pdf",
|
|
23
|
+
"verdict": "allow"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
27
|
+
"deny_reason": "",
|
|
28
|
+
"operation": "grep_search",
|
|
29
|
+
"subject": "TOTAL",
|
|
30
|
+
"verdict": "allow"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
34
|
+
"deny_reason": "",
|
|
35
|
+
"operation": "read_files",
|
|
36
|
+
"subject": "invoices/2026-05.pdf",
|
|
37
|
+
"verdict": "allow"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"taskHashHex": "8aa9199f47bc66272e3a40e87d8ef6daeec2e905e5a723252e615c45c4376888"
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"boundary": {
|
|
3
|
+
"principal": "spiffe://acme.example/agent/billing-assistant",
|
|
4
|
+
"allowedTools": ["read_files", "glob_search", "grep_search"]
|
|
5
|
+
},
|
|
6
|
+
"_note": "OpenClaw-class impersonation trace (CVE-2026-25253 shape): event #1 wears the allowlisted displayName 'Billing Assistant' but carries an ATTACKER SPIFFE id. Real export shape; the boundary is keyed on the cryptographic principal, so the receipt refuses to attest it.",
|
|
7
|
+
"records": [
|
|
8
|
+
{
|
|
9
|
+
"actor": "spiffe://acme.example/agent/billing-assistant",
|
|
10
|
+
"operation": "glob_search",
|
|
11
|
+
"verdict": "allow",
|
|
12
|
+
"subject": "invoices/*.pdf",
|
|
13
|
+
"deny_reason": ""
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"actor": "spiffe://acme.example/agent/unknown-7f3a",
|
|
17
|
+
"operation": "read_files",
|
|
18
|
+
"verdict": "deny",
|
|
19
|
+
"subject": "invoices/2026-06.pdf",
|
|
20
|
+
"displayName": "Billing Assistant",
|
|
21
|
+
"deny_reason": "presented identity not bound to the declared principal"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -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,20 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coproduct_inc/verify",
|
|
3
|
-
"version": "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)
|
|
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",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"bin": {
|
|
10
10
|
"nucleus-verify": "dist/cli.js",
|
|
11
|
-
"nucleus-attest": "dist/producer-cli.js"
|
|
11
|
+
"nucleus-attest": "dist/producer-cli.js",
|
|
12
|
+
"nucleus-license": "dist/license-cli.js",
|
|
13
|
+
"nucleus-verify-claim": "dist/claim-cli.js"
|
|
12
14
|
},
|
|
13
15
|
"exports": {
|
|
14
16
|
".": {
|
|
15
17
|
"types": "./dist/index.d.ts",
|
|
16
18
|
"import": "./dist/index.js"
|
|
17
19
|
},
|
|
20
|
+
"./workflow": {
|
|
21
|
+
"types": "./dist/workflow.d.ts",
|
|
22
|
+
"import": "./dist/workflow.js"
|
|
23
|
+
},
|
|
18
24
|
"./attestation": {
|
|
19
25
|
"types": "./dist/attestation.d.ts",
|
|
20
26
|
"import": "./dist/attestation.js"
|
|
@@ -22,6 +28,14 @@
|
|
|
22
28
|
"./toolproxy": {
|
|
23
29
|
"types": "./dist/toolproxy.d.ts",
|
|
24
30
|
"import": "./dist/toolproxy.js"
|
|
31
|
+
},
|
|
32
|
+
"./cage": {
|
|
33
|
+
"types": "./dist/cage.d.ts",
|
|
34
|
+
"import": "./dist/cage.js"
|
|
35
|
+
},
|
|
36
|
+
"./license": {
|
|
37
|
+
"types": "./dist/license.d.ts",
|
|
38
|
+
"import": "./dist/license.js"
|
|
25
39
|
}
|
|
26
40
|
},
|
|
27
41
|
"files": [
|
|
@@ -32,7 +46,14 @@
|
|
|
32
46
|
"wasm/nodejs/nucleus_wasm_bg.wasm",
|
|
33
47
|
"wasm/nodejs/nucleus_wasm_bg.wasm.d.ts",
|
|
34
48
|
"examples/openclaw-replay.mjs",
|
|
49
|
+
"examples/cage-quickstart.mjs",
|
|
50
|
+
"examples/workflow-leak.mjs",
|
|
51
|
+
"examples/converting-demo.sh",
|
|
52
|
+
"examples/sample-toolproxy-trace.clean.json",
|
|
53
|
+
"examples/sample-toolproxy-trace.bypass.json",
|
|
54
|
+
"examples/sample-toolproxy-trace.openclaw.json",
|
|
35
55
|
"README.md",
|
|
56
|
+
"WHY-VERIFY.md",
|
|
36
57
|
"CHANGELOG.md"
|
|
37
58
|
],
|
|
38
59
|
"scripts": {
|
|
@@ -40,7 +61,8 @@
|
|
|
40
61
|
"build": "tsc -p tsconfig.json",
|
|
41
62
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
42
63
|
"test": "node --test",
|
|
43
|
-
"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"
|
|
44
66
|
},
|
|
45
67
|
"keywords": [
|
|
46
68
|
"nucleus",
|
|
@@ -63,5 +85,9 @@
|
|
|
63
85
|
"devDependencies": {
|
|
64
86
|
"@types/node": "^25.9.1",
|
|
65
87
|
"typescript": "^5.7.2"
|
|
88
|
+
},
|
|
89
|
+
"dependencies": {
|
|
90
|
+
"@noble/ed25519": "3.1.0",
|
|
91
|
+
"@noble/hashes": "2.2.0"
|
|
66
92
|
}
|
|
67
93
|
}
|