@agent-custody/receipts 0.2.0 → 0.4.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/README.md +24 -12
- package/dist/cli.js +26 -6
- package/dist/config.d.ts +134 -0
- package/dist/config.js +34 -2
- package/dist/gateway.d.ts +9 -1
- package/dist/gateway.js +45 -16
- package/dist/index.d.ts +8 -0
- package/dist/index.js +3 -0
- package/dist/issue.d.ts +9 -2
- package/dist/issue.js +13 -2
- package/dist/log-sink.d.ts +15 -0
- package/dist/log-sink.js +31 -18
- package/dist/log.d.ts +8 -0
- package/dist/log.js +17 -3
- package/dist/otel.d.ts +28 -0
- package/dist/otel.js +87 -0
- package/dist/receipt.d.ts +37 -0
- package/dist/receipt.js +10 -0
- package/dist/rest.d.ts +27 -0
- package/dist/rest.js +52 -0
- package/dist/sdk/index.js +2 -1
- package/dist/verify.d.ts +3 -1
- package/dist/verify.js +37 -2
- package/docs/sdk.md +2 -0
- package/docs/tutorials.md +3 -0
- package/docs/usage.md +26 -1
- package/docs/verification.md +9 -2
- package/package.json +1 -1
- package/vectors/audit.json +27 -27
- package/vectors/canonical.json +5 -5
- package/vectors/receipts.json +440 -162
package/dist/issue.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { type KeyPair } from "./crypto.ts";
|
|
2
2
|
import { type LogSink } from "./log-sink.ts";
|
|
3
|
-
import
|
|
3
|
+
import type { ReceiptExporter } from "./otel.ts";
|
|
4
|
+
import { type AuthorizationBundle, type AuthorizationPredicate, type ReceiptBundle, type ReceiptPredicate } from "./receipt.ts";
|
|
4
5
|
export interface Issuer {
|
|
5
6
|
keyid: string;
|
|
6
7
|
log: LogSink;
|
|
7
8
|
/** Signs the statement, appends it to the log, writes the bundle. Rejects if the log refuses the leaf; no bundle is written then. */
|
|
8
9
|
issue(predicate: ReceiptPredicate): Promise<ReceiptBundle>;
|
|
10
|
+
/** Signs and commits an authorization before a consequential call is forwarded. Rejects if the log refuses; the caller must then not forward. */
|
|
11
|
+
authorize(predicate: AuthorizationPredicate): Promise<AuthorizationBundle>;
|
|
12
|
+
}
|
|
13
|
+
export interface IssuerOptions {
|
|
14
|
+
/** told about every receipt after it is written; an exporter's failure never reaches the caller */
|
|
15
|
+
exporter?: ReceiptExporter | undefined;
|
|
9
16
|
}
|
|
10
17
|
/** `log` is a sink, or a file path for the local log with tree heads signed by the issuer's key. */
|
|
11
|
-
export declare function createIssuer(key: KeyPair, receiptsDir: string, log: string | LogSink): Issuer;
|
|
18
|
+
export declare function createIssuer(key: KeyPair, receiptsDir: string, log: string | LogSink, opts?: IssuerOptions): Issuer;
|
package/dist/issue.js
CHANGED
|
@@ -3,9 +3,9 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { canonicalize, dsseSign } from "./crypto.js";
|
|
5
5
|
import { fileLog } from "./log-sink.js";
|
|
6
|
-
import { buildStatement, RECEIPT_TYPE } from "./receipt.js";
|
|
6
|
+
import { buildAuthorizationStatement, buildStatement, RECEIPT_TYPE } from "./receipt.js";
|
|
7
7
|
/** `log` is a sink, or a file path for the local log with tree heads signed by the issuer's key. */
|
|
8
|
-
export function createIssuer(key, receiptsDir, log) {
|
|
8
|
+
export function createIssuer(key, receiptsDir, log, opts = {}) {
|
|
9
9
|
const sink = typeof log === "string" ? fileLog(log, key) : log;
|
|
10
10
|
mkdirSync(receiptsDir, { recursive: true });
|
|
11
11
|
return {
|
|
@@ -16,6 +16,17 @@ export function createIssuer(key, receiptsDir, log) {
|
|
|
16
16
|
const entry = await sink.append(canonicalize(envelope));
|
|
17
17
|
const bundle = { envelope, treeHead: entry.treeHead, inclusion: entry.inclusion };
|
|
18
18
|
writeFileSync(join(receiptsDir, `${predicate.receiptId}.json`), JSON.stringify(bundle, null, 2));
|
|
19
|
+
// After the evidence, not before, and awaited so a process that exits right after issuing still exports.
|
|
20
|
+
if (opts.exporter)
|
|
21
|
+
await opts.exporter.exported(predicate, bundle).catch(() => { });
|
|
22
|
+
return bundle;
|
|
23
|
+
},
|
|
24
|
+
async authorize(predicate) {
|
|
25
|
+
const envelope = dsseSign(RECEIPT_TYPE, buildAuthorizationStatement(predicate), key);
|
|
26
|
+
const entry = await sink.append(canonicalize(envelope));
|
|
27
|
+
const bundle = { envelope, treeHead: entry.treeHead, inclusion: entry.inclusion };
|
|
28
|
+
// On disk in its own right: if the process dies between forwarding and the receipt, this is the evidence that the call was authorized and sent.
|
|
29
|
+
writeFileSync(join(receiptsDir, `${predicate.receiptId}.authorization.json`), JSON.stringify(bundle, null, 2));
|
|
19
30
|
return bundle;
|
|
20
31
|
},
|
|
21
32
|
};
|
package/dist/log-sink.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare function fileLog(file: string, key: KeyPair): LogSink;
|
|
|
17
17
|
export interface HttpLogOptions {
|
|
18
18
|
/** sent as a bearer token; the log decides what it is worth */
|
|
19
19
|
token?: string;
|
|
20
|
+
/** send only the leaf hash; the log then commits to the receipt without ever holding it. Use it for any log run by someone else. */
|
|
21
|
+
hashOnly?: boolean;
|
|
20
22
|
fetch?: typeof fetch;
|
|
21
23
|
}
|
|
22
24
|
/** A log reached over HTTP: POST <url>/append with {leaf}, expecting a LogAppend back. */
|
|
@@ -26,6 +28,7 @@ export interface LogConfig {
|
|
|
26
28
|
log?: {
|
|
27
29
|
url: string;
|
|
28
30
|
tokenEnv?: string | undefined;
|
|
31
|
+
hashOnly?: boolean | undefined;
|
|
29
32
|
} | undefined;
|
|
30
33
|
}
|
|
31
34
|
/** The sink a config asks for: a remote log when `log` is set, otherwise the local file. */
|
|
@@ -33,6 +36,18 @@ export declare function openLog(cfg: LogConfig, key: KeyPair): LogSink;
|
|
|
33
36
|
export interface LogServerOptions {
|
|
34
37
|
/** bearer tokens accepted on append; when empty, anyone may append */
|
|
35
38
|
tokens?: string[];
|
|
39
|
+
/** the id written into every tree head this log signs, so a head cannot be presented as another log's */
|
|
40
|
+
logId?: string;
|
|
41
|
+
/**
|
|
42
|
+
* More logs behind the same server, one per tenant, reached at /t/<tenant>/...: each with its own file, tokens,
|
|
43
|
+
* and id. The default log stays at the root paths. This is the shape a hosted log takes; the reference server
|
|
44
|
+
* runs it from a small JSON file.
|
|
45
|
+
*/
|
|
46
|
+
tenants?: Record<string, {
|
|
47
|
+
file: string;
|
|
48
|
+
tokens?: string[];
|
|
49
|
+
logId?: string;
|
|
50
|
+
}>;
|
|
36
51
|
}
|
|
37
52
|
/**
|
|
38
53
|
* The reference log server as a node:http request handler.
|
package/dist/log-sink.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
import { timingSafeEqual } from "node:crypto";
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
8
|
import { dsseSign } from "./crypto.js";
|
|
9
|
-
import { MerkleLog } from "./log.js";
|
|
9
|
+
import { leafHash, MerkleLog } from "./log.js";
|
|
10
10
|
import { TREEHEAD_TYPE } from "./receipt.js";
|
|
11
|
-
function appendSigned(log, key, leaf) {
|
|
12
|
-
const e = log.append(leaf);
|
|
13
|
-
const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString() };
|
|
11
|
+
function appendSigned(log, key, leaf, logId) {
|
|
12
|
+
const e = "leaf" in leaf ? log.append(leaf.leaf) : log.appendHash(leaf.leafHash);
|
|
13
|
+
const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString(), ...(logId ? { log: logId } : {}) };
|
|
14
14
|
return { inclusion: { leafIndex: e.leafIndex, treeSize: e.treeSize, hashes: e.hashes }, treeHead: dsseSign(TREEHEAD_TYPE, head, key) };
|
|
15
15
|
}
|
|
16
16
|
/** A local JSONL Merkle log. Tree heads are signed with the given key, normally the issuer's own. */
|
|
@@ -20,7 +20,7 @@ export function fileLog(file, key) {
|
|
|
20
20
|
kind: "file",
|
|
21
21
|
where: file,
|
|
22
22
|
async append(leaf) {
|
|
23
|
-
return appendSigned(log, key, leaf);
|
|
23
|
+
return appendSigned(log, key, { leaf });
|
|
24
24
|
},
|
|
25
25
|
};
|
|
26
26
|
}
|
|
@@ -35,7 +35,7 @@ export function httpLog(url, opts = {}) {
|
|
|
35
35
|
const res = await f(new URL("append", base), {
|
|
36
36
|
method: "POST",
|
|
37
37
|
headers: { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
|
|
38
|
-
body: JSON.stringify({ leaf }),
|
|
38
|
+
body: JSON.stringify(opts.hashOnly ? { leafHash: leafHash(leaf).toString("hex") } : { leaf }),
|
|
39
39
|
});
|
|
40
40
|
if (!res.ok)
|
|
41
41
|
throw new Error(`log ${url} refused the append: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
@@ -52,7 +52,7 @@ export function openLog(cfg, key) {
|
|
|
52
52
|
const token = cfg.log.tokenEnv ? process.env[cfg.log.tokenEnv] : undefined;
|
|
53
53
|
if (cfg.log.tokenEnv && !token)
|
|
54
54
|
throw new Error(`log token: environment variable ${cfg.log.tokenEnv} is not set`);
|
|
55
|
-
return httpLog(cfg.log.url, token === undefined ? {} : { token });
|
|
55
|
+
return httpLog(cfg.log.url, { ...(token === undefined ? {} : { token }), ...(cfg.log.hashOnly ? { hashOnly: true } : {}) });
|
|
56
56
|
}
|
|
57
57
|
if (!cfg.logFile)
|
|
58
58
|
throw new Error("config needs logFile or log.url");
|
|
@@ -66,9 +66,11 @@ export function openLog(cfg, key) {
|
|
|
66
66
|
* GET /head -> {treeHead}, the current tree head signed with the log's key
|
|
67
67
|
*/
|
|
68
68
|
export function logHandler(file, key, opts = {}) {
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
69
|
+
const root = { log: new MerkleLog(file), tokens: opts.tokens ?? [], logId: opts.logId };
|
|
70
|
+
const tenants = new Map();
|
|
71
|
+
for (const [name, t] of Object.entries(opts.tenants ?? {}))
|
|
72
|
+
tenants.set(name, { log: new MerkleLog(t.file), tokens: t.tokens ?? [], logId: t.logId ?? name });
|
|
73
|
+
const authorized = (req, tokens) => {
|
|
72
74
|
if (tokens.length === 0)
|
|
73
75
|
return true;
|
|
74
76
|
const h = req.headers.authorization ?? "";
|
|
@@ -84,22 +86,33 @@ export function logHandler(file, key, opts = {}) {
|
|
|
84
86
|
res.end(JSON.stringify(body));
|
|
85
87
|
};
|
|
86
88
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
89
|
+
// /t/<tenant>/<op> reaches that tenant's log; anything else is the default log.
|
|
90
|
+
const m = /^\/t\/([A-Za-z0-9_.-]+)\/(append|root|consistency|head)$/.exec(url.pathname);
|
|
91
|
+
const which = m ? tenants.get(m[1]) : root;
|
|
92
|
+
if (!which)
|
|
93
|
+
return json(404, { error: "unknown log" });
|
|
94
|
+
const { log, tokens, logId } = which;
|
|
87
95
|
if (req.method === "POST" && url.pathname.endsWith("/append")) {
|
|
88
|
-
if (!authorized(req))
|
|
96
|
+
if (!authorized(req, tokens))
|
|
89
97
|
return json(401, { error: "unauthorized" });
|
|
90
98
|
let body = "";
|
|
91
99
|
for await (const chunk of req)
|
|
92
100
|
body += chunk;
|
|
93
|
-
let
|
|
101
|
+
let parsed;
|
|
94
102
|
try {
|
|
95
|
-
|
|
103
|
+
parsed = JSON.parse(body);
|
|
96
104
|
}
|
|
97
105
|
catch {
|
|
98
|
-
return json(400, { error: "body must be JSON {leaf}" });
|
|
106
|
+
return json(400, { error: "body must be JSON {leaf} or {leafHash}" });
|
|
107
|
+
}
|
|
108
|
+
if (typeof parsed.leafHash === "string") {
|
|
109
|
+
if (!/^[0-9a-f]{64}$/.test(parsed.leafHash))
|
|
110
|
+
return json(400, { error: "leafHash must be 64 lowercase hex characters" });
|
|
111
|
+
return json(200, appendSigned(log, key, { leafHash: parsed.leafHash }, logId));
|
|
99
112
|
}
|
|
100
|
-
if (typeof leaf !== "string" || leaf.length === 0)
|
|
101
|
-
return json(400, { error: "leaf must be a non-empty string" });
|
|
102
|
-
return json(200, appendSigned(log, key, leaf));
|
|
113
|
+
if (typeof parsed.leaf !== "string" || parsed.leaf.length === 0)
|
|
114
|
+
return json(400, { error: "leaf must be a non-empty string, or send leafHash" });
|
|
115
|
+
return json(200, appendSigned(log, key, { leaf: parsed.leaf }, logId));
|
|
103
116
|
}
|
|
104
117
|
if (req.method === "GET" && url.pathname.endsWith("/root")) {
|
|
105
118
|
const size = url.searchParams.has("size") ? Number(url.searchParams.get("size")) : log.size;
|
|
@@ -115,7 +128,7 @@ export function logHandler(file, key, opts = {}) {
|
|
|
115
128
|
return json(200, { oldSize, newSize, hashes: log.consistencyProof(oldSize, newSize) });
|
|
116
129
|
}
|
|
117
130
|
if (req.method === "GET" && url.pathname.endsWith("/head")) {
|
|
118
|
-
const head = { treeSize: log.size, rootHash: log.root(), timestamp: new Date().toISOString() };
|
|
131
|
+
const head = { treeSize: log.size, rootHash: log.root(), timestamp: new Date().toISOString(), ...(logId ? { log: logId } : {}) };
|
|
119
132
|
return json(200, { treeHead: dsseSign(TREEHEAD_TYPE, head, key) });
|
|
120
133
|
}
|
|
121
134
|
return json(404, { error: "not found" });
|
package/dist/log.d.ts
CHANGED
|
@@ -22,6 +22,14 @@ export declare class MerkleLog {
|
|
|
22
22
|
append(leaf: string): InclusionProof & {
|
|
23
23
|
rootHash: string;
|
|
24
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Appends a leaf by its RFC 6962 leaf hash, sha256(0x00 || leaf), as hex. The log commits to the leaf without ever
|
|
27
|
+
* holding it, which is what a log run for someone else should do: the receipt stays with its issuer.
|
|
28
|
+
*/
|
|
29
|
+
appendHash(leafHashHex: string): InclusionProof & {
|
|
30
|
+
rootHash: string;
|
|
31
|
+
};
|
|
32
|
+
private pushHash;
|
|
25
33
|
root(size?: number): string;
|
|
26
34
|
/** Proof that this log at newSize extends its own earlier state at oldSize. */
|
|
27
35
|
consistencyProof(oldSize: number, newSize?: number): string[];
|
package/dist/log.js
CHANGED
|
@@ -165,8 +165,9 @@ export class MerkleLog {
|
|
|
165
165
|
if (!line.trim())
|
|
166
166
|
continue;
|
|
167
167
|
const parsed = JSON.parse(line);
|
|
168
|
-
// A pruned leaf keeps only its hash
|
|
169
|
-
|
|
168
|
+
// A pruned leaf keeps only its hash, and a leaf appended by hash never had content here: the tree, its
|
|
169
|
+
// roots, and every proof are the same either way.
|
|
170
|
+
this.hashes.push(typeof parsed === "string" ? leafHash(parsed) : Buffer.from((parsed.pruned ?? parsed.hash), "hex"));
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
else {
|
|
@@ -179,7 +180,20 @@ export class MerkleLog {
|
|
|
179
180
|
/** Appends a leaf (an opaque string, typically a canonical JSON envelope). Returns its proof against the new root. */
|
|
180
181
|
append(leaf) {
|
|
181
182
|
appendFileSync(this.file, JSON.stringify(leaf) + "\n");
|
|
182
|
-
this.
|
|
183
|
+
return this.pushHash(leafHash(leaf));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Appends a leaf by its RFC 6962 leaf hash, sha256(0x00 || leaf), as hex. The log commits to the leaf without ever
|
|
187
|
+
* holding it, which is what a log run for someone else should do: the receipt stays with its issuer.
|
|
188
|
+
*/
|
|
189
|
+
appendHash(leafHashHex) {
|
|
190
|
+
if (!/^[0-9a-f]{64}$/.test(leafHashHex))
|
|
191
|
+
throw new Error("leafHash must be 64 lowercase hex characters");
|
|
192
|
+
appendFileSync(this.file, JSON.stringify({ hash: leafHashHex }) + "\n");
|
|
193
|
+
return this.pushHash(Buffer.from(leafHashHex, "hex"));
|
|
194
|
+
}
|
|
195
|
+
pushHash(hash) {
|
|
196
|
+
this.hashes.push(hash);
|
|
183
197
|
const treeSize = this.hashes.length;
|
|
184
198
|
return { leafIndex: treeSize - 1, treeSize, hashes: this.tree.path(treeSize - 1, 0, treeSize).map((b) => b.toString("hex")), rootHash: this.tree.mth(0, treeSize).toString("hex") };
|
|
185
199
|
}
|
package/dist/otel.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReceiptBundle, ReceiptPredicate } from "./receipt.ts";
|
|
2
|
+
export interface OtelConfig {
|
|
3
|
+
/** the collector's OTLP/HTTP base, e.g. http://localhost:4318; spans go to <url>/v1/traces */
|
|
4
|
+
url: string;
|
|
5
|
+
/** header name to environment variable, for collectors that need a key; a missing variable fails at startup */
|
|
6
|
+
headersEnv?: Record<string, string> | undefined;
|
|
7
|
+
/** the service.name resource attribute; default agent-custody */
|
|
8
|
+
serviceName?: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface ReceiptExporter {
|
|
11
|
+
readonly where: string;
|
|
12
|
+
/** called after a bundle is written; must not throw into the issuer */
|
|
13
|
+
exported(predicate: ReceiptPredicate, bundle: ReceiptBundle): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/** The span for one receipt, as the OTLP JSON a collector accepts on /v1/traces. Exported so a test or another transport can reuse it. */
|
|
16
|
+
export declare function spanFor(p: ReceiptPredicate, bundle: ReceiptBundle, serviceName: string): unknown;
|
|
17
|
+
export interface OtlpOptions {
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
env?: Record<string, string | undefined>;
|
|
20
|
+
/** where export failures are reported; default stderr */
|
|
21
|
+
warn?: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/** An exporter that posts each receipt's span to an OTLP/HTTP collector. Failures are reported, never thrown. */
|
|
24
|
+
export declare function otlpExporter(cfg: OtelConfig, opts?: OtlpOptions): ReceiptExporter;
|
|
25
|
+
/** The exporter a config asks for, or undefined. */
|
|
26
|
+
export declare function openExporter(cfg: {
|
|
27
|
+
otel?: OtelConfig | undefined;
|
|
28
|
+
}): ReceiptExporter | undefined;
|
package/dist/otel.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const str = (key, v) => (v === null || v === undefined ? [] : [{ key, value: { stringValue: v } }]);
|
|
2
|
+
const int = (key, v) => [{ key, value: { intValue: String(v) } }];
|
|
3
|
+
/** The span for one receipt, as the OTLP JSON a collector accepts on /v1/traces. Exported so a test or another transport can reuse it. */
|
|
4
|
+
export function spanFor(p, bundle, serviceName) {
|
|
5
|
+
const hex = p.receiptId.replace(/-/g, "");
|
|
6
|
+
const traceId = hex.length === 32 ? hex : hex.padEnd(32, "0").slice(0, 32);
|
|
7
|
+
const start = BigInt(Date.parse(p.timestamp)) * 1000000n;
|
|
8
|
+
const end = BigInt(Date.now()) * 1000000n;
|
|
9
|
+
const status = p.execution.status;
|
|
10
|
+
const auth = p.authorization;
|
|
11
|
+
const attributes = [
|
|
12
|
+
...str("agent_custody.receipt_id", p.receiptId),
|
|
13
|
+
...str("agent_custody.issuer.kind", p.issuer.kind),
|
|
14
|
+
...str("agent_custody.issuer.keyid", p.issuer.keyid),
|
|
15
|
+
...str("agent_custody.tool", p.tool.name),
|
|
16
|
+
...str("agent_custody.upstream", p.tool.upstream),
|
|
17
|
+
...str("agent_custody.agent", p.agent.id),
|
|
18
|
+
...str("agent_custody.agent.provenance", p.agent.provenance),
|
|
19
|
+
...str("agent_custody.principal", p.principal.id),
|
|
20
|
+
...str("agent_custody.execution.status", status),
|
|
21
|
+
...str("agent_custody.policy.decision", p.policy?.decision),
|
|
22
|
+
...str("agent_custody.policy.digest", p.policy?.policyDigest),
|
|
23
|
+
...int("agent_custody.log.leaf_index", bundle.inclusion.leafIndex),
|
|
24
|
+
...int("agent_custody.log.tree_size", bundle.inclusion.treeSize),
|
|
25
|
+
...(auth ? int("agent_custody.authorization.leaf_index", auth.inclusion.leafIndex) : []),
|
|
26
|
+
...int("agent_custody.consumed.count", p.consumed?.factIds.length ?? 0),
|
|
27
|
+
...str("agent_custody.model", p.model.id),
|
|
28
|
+
...str("agent_custody.session", p.session.id),
|
|
29
|
+
];
|
|
30
|
+
return {
|
|
31
|
+
resourceSpans: [
|
|
32
|
+
{
|
|
33
|
+
resource: { attributes: str("service.name", serviceName) },
|
|
34
|
+
scopeSpans: [
|
|
35
|
+
{
|
|
36
|
+
scope: { name: "agent-custody", version: p.issuer.version },
|
|
37
|
+
spans: [
|
|
38
|
+
{
|
|
39
|
+
traceId,
|
|
40
|
+
spanId: traceId.slice(0, 16),
|
|
41
|
+
name: `${p.tool.name}`,
|
|
42
|
+
kind: 3, // CLIENT: the agent calling out
|
|
43
|
+
startTimeUnixNano: start.toString(),
|
|
44
|
+
endTimeUnixNano: (end > start ? end : start).toString(),
|
|
45
|
+
attributes,
|
|
46
|
+
status: status === "executed" || status === "denied" || status === "withheld" ? { code: 1 } : { code: 2, message: status === "failed" ? "the upstream reported failure" : p.execution.error ?? status },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** An exporter that posts each receipt's span to an OTLP/HTTP collector. Failures are reported, never thrown. */
|
|
56
|
+
export function otlpExporter(cfg, opts = {}) {
|
|
57
|
+
const f = opts.fetch ?? fetch;
|
|
58
|
+
const env = opts.env ?? process.env;
|
|
59
|
+
const warn = opts.warn ?? ((m) => console.error(m));
|
|
60
|
+
const headers = { "content-type": "application/json" };
|
|
61
|
+
for (const [header, variable] of Object.entries(cfg.headersEnv ?? {})) {
|
|
62
|
+
const v = env[variable];
|
|
63
|
+
if (!v)
|
|
64
|
+
throw new Error(`otel: environment variable ${variable} is not set`);
|
|
65
|
+
headers[header] = v;
|
|
66
|
+
}
|
|
67
|
+
const base = cfg.url.endsWith("/") ? cfg.url : `${cfg.url}/`;
|
|
68
|
+
const url = new URL("v1/traces", base);
|
|
69
|
+
const serviceName = cfg.serviceName ?? "agent-custody";
|
|
70
|
+
return {
|
|
71
|
+
where: cfg.url,
|
|
72
|
+
async exported(p, bundle) {
|
|
73
|
+
try {
|
|
74
|
+
const res = await f(url, { method: "POST", headers, body: JSON.stringify(spanFor(p, bundle, serviceName)), signal: AbortSignal.timeout(5000) });
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
warn(`agent-custody: otel export of receipt ${p.receiptId} refused by ${cfg.url}: ${res.status}`);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
warn(`agent-custody: otel export of receipt ${p.receiptId} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** The exporter a config asks for, or undefined. */
|
|
85
|
+
export function openExporter(cfg) {
|
|
86
|
+
return cfg.otel ? otlpExporter(cfg.otel) : undefined;
|
|
87
|
+
}
|
package/dist/receipt.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { PolicyDecision } from "./policy.ts";
|
|
|
4
4
|
import type { UpstreamEvidence } from "./upstream.ts";
|
|
5
5
|
export declare const RECEIPT_TYPE = "application/vnd.in-toto+json";
|
|
6
6
|
export declare const RECEIPT_PREDICATE_TYPE = "https://agent-custody.dev/receipt/v0.2";
|
|
7
|
+
/** The statement a gateway commits to the log before forwarding a consequential call; the receipt for that call embeds it. */
|
|
8
|
+
export declare const AUTHORIZATION_PREDICATE_TYPE = "https://agent-custody.dev/authorization/v0.1";
|
|
7
9
|
export declare const TREEHEAD_TYPE = "application/vnd.agent-custody.treehead+json";
|
|
8
10
|
/**
|
|
9
11
|
* Provenance of a receipt field. This is the honest part of the design.
|
|
@@ -86,6 +88,13 @@ export interface ReceiptPredicate {
|
|
|
86
88
|
policy: (PolicyDecision & {
|
|
87
89
|
provenance: Provenance;
|
|
88
90
|
}) | null;
|
|
91
|
+
/**
|
|
92
|
+
* Present when the tool is one the gateway treats as consequential: the authorization statement it committed to the
|
|
93
|
+
* log before forwarding the call, with that leaf's inclusion proof and tree head. A verifier checks that it names
|
|
94
|
+
* this receipt, this tool, and these arguments, and that its leaf precedes the receipt's. Without it, evidence of a
|
|
95
|
+
* side effect exists only after the side effect.
|
|
96
|
+
*/
|
|
97
|
+
authorization?: AuthorizationBundle;
|
|
89
98
|
execution: {
|
|
90
99
|
status: "executed" | "failed";
|
|
91
100
|
result: unknown;
|
|
@@ -101,8 +110,34 @@ export interface ReceiptPredicate {
|
|
|
101
110
|
status: "error";
|
|
102
111
|
error: string;
|
|
103
112
|
provenance: Provenance;
|
|
113
|
+
}
|
|
114
|
+
/** the policy allowed the call but the gateway did not forward it, because the log would not commit the authorization first */
|
|
115
|
+
| {
|
|
116
|
+
status: "withheld";
|
|
117
|
+
reason: string;
|
|
118
|
+
provenance: Provenance;
|
|
104
119
|
};
|
|
105
120
|
}
|
|
121
|
+
/** What the gateway commits before a consequential call goes out: everything the receipt will say, except the outcome. */
|
|
122
|
+
export type AuthorizationPredicate = Pick<ReceiptPredicate, "receiptId" | "timestamp" | "issuer" | "principal" | "agent" | "delegation" | "tool" | "request" | "facts" | "consumed" | "policy">;
|
|
123
|
+
export interface AuthorizationStatement {
|
|
124
|
+
_type: "https://in-toto.io/Statement/v1";
|
|
125
|
+
subject: {
|
|
126
|
+
name: string;
|
|
127
|
+
digest: {
|
|
128
|
+
sha256: string;
|
|
129
|
+
};
|
|
130
|
+
}[];
|
|
131
|
+
predicateType: typeof AUTHORIZATION_PREDICATE_TYPE;
|
|
132
|
+
predicate: AuthorizationPredicate;
|
|
133
|
+
}
|
|
134
|
+
/** The committed authorization: the same three parts as a receipt bundle. Written to `receipts/<receiptId>.authorization.json` and embedded in the receipt. */
|
|
135
|
+
export interface AuthorizationBundle {
|
|
136
|
+
envelope: Envelope;
|
|
137
|
+
treeHead: Envelope;
|
|
138
|
+
inclusion: InclusionProof;
|
|
139
|
+
}
|
|
140
|
+
export declare function buildAuthorizationStatement(p: AuthorizationPredicate): AuthorizationStatement;
|
|
106
141
|
export interface ReceiptStatement {
|
|
107
142
|
_type: "https://in-toto.io/Statement/v1";
|
|
108
143
|
subject: {
|
|
@@ -118,6 +153,8 @@ export interface TreeHead {
|
|
|
118
153
|
treeSize: number;
|
|
119
154
|
rootHash: string;
|
|
120
155
|
timestamp: string;
|
|
156
|
+
/** which log this head belongs to, when the log serves more than one; a verifier told which log to expect checks it */
|
|
157
|
+
log?: string;
|
|
121
158
|
}
|
|
122
159
|
/** What gets written to disk and handed to a verifier. Self-contained apart from public keys. */
|
|
123
160
|
export interface ReceiptBundle {
|
package/dist/receipt.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
export const RECEIPT_TYPE = "application/vnd.in-toto+json";
|
|
2
2
|
export const RECEIPT_PREDICATE_TYPE = "https://agent-custody.dev/receipt/v0.2";
|
|
3
|
+
/** The statement a gateway commits to the log before forwarding a consequential call; the receipt for that call embeds it. */
|
|
4
|
+
export const AUTHORIZATION_PREDICATE_TYPE = "https://agent-custody.dev/authorization/v0.1";
|
|
3
5
|
export const TREEHEAD_TYPE = "application/vnd.agent-custody.treehead+json";
|
|
6
|
+
export function buildAuthorizationStatement(p) {
|
|
7
|
+
return {
|
|
8
|
+
_type: "https://in-toto.io/Statement/v1",
|
|
9
|
+
subject: [{ name: `tool-call:${p.tool.name}:${p.receiptId}`, digest: { sha256: p.request.argsDigest } }],
|
|
10
|
+
predicateType: AUTHORIZATION_PREDICATE_TYPE,
|
|
11
|
+
predicate: p,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
4
14
|
export function buildStatement(p) {
|
|
5
15
|
return {
|
|
6
16
|
_type: "https://in-toto.io/Statement/v1",
|
package/dist/rest.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { RestToolConfig, RestUpstreamConfig } from "./config.ts";
|
|
3
|
+
/** The part of an MCP client the gateway uses, so a REST connector can stand where an MCP upstream stands. */
|
|
4
|
+
export interface UpstreamClient {
|
|
5
|
+
listTools(): Promise<{
|
|
6
|
+
tools: Tool[];
|
|
7
|
+
}>;
|
|
8
|
+
callTool(params: {
|
|
9
|
+
name: string;
|
|
10
|
+
arguments?: Record<string, unknown>;
|
|
11
|
+
_meta?: Record<string, unknown>;
|
|
12
|
+
}): Promise<unknown>;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface RestOptions {
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** the environment the header secrets are read from; default process.env */
|
|
18
|
+
env?: Record<string, string | undefined>;
|
|
19
|
+
}
|
|
20
|
+
/** Builds the request for one tool call: path parameters substituted, the rest of the arguments as query or body. */
|
|
21
|
+
export declare function buildRequest(base: string, tool: RestToolConfig, args: Record<string, unknown>): {
|
|
22
|
+
url: URL;
|
|
23
|
+
method: string;
|
|
24
|
+
body: string | null;
|
|
25
|
+
};
|
|
26
|
+
/** A REST API as an upstream. Tools are what the config declares; credentials come from the environment at startup, never from the agent. */
|
|
27
|
+
export declare function restUpstream(name: string, cfg: RestUpstreamConfig, opts?: RestOptions): UpstreamClient;
|
package/dist/rest.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const encode = (v) => encodeURIComponent(typeof v === "string" ? v : JSON.stringify(v));
|
|
2
|
+
/** Builds the request for one tool call: path parameters substituted, the rest of the arguments as query or body. */
|
|
3
|
+
export function buildRequest(base, tool, args) {
|
|
4
|
+
const rest = { ...args };
|
|
5
|
+
const path = tool.path.replace(/\{([A-Za-z0-9_]+)\}/g, (_, name) => {
|
|
6
|
+
if (!(name in rest) || rest[name] === undefined)
|
|
7
|
+
throw new Error(`tool ${tool.name}: path needs argument "${name}", which is missing`);
|
|
8
|
+
const v = rest[name];
|
|
9
|
+
delete rest[name];
|
|
10
|
+
return encode(v);
|
|
11
|
+
});
|
|
12
|
+
const url = new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`);
|
|
13
|
+
const method = tool.method;
|
|
14
|
+
const queryNames = tool.query ?? (method === "GET" || method === "DELETE" ? Object.keys(rest) : []);
|
|
15
|
+
for (const name of queryNames) {
|
|
16
|
+
if (rest[name] === undefined)
|
|
17
|
+
continue;
|
|
18
|
+
url.searchParams.set(name, typeof rest[name] === "string" ? rest[name] : JSON.stringify(rest[name]));
|
|
19
|
+
delete rest[name];
|
|
20
|
+
}
|
|
21
|
+
const body = method === "GET" || method === "DELETE" || tool.body === "none" ? null : JSON.stringify(rest);
|
|
22
|
+
return { url, method, body };
|
|
23
|
+
}
|
|
24
|
+
/** A REST API as an upstream. Tools are what the config declares; credentials come from the environment at startup, never from the agent. */
|
|
25
|
+
export function restUpstream(name, cfg, opts = {}) {
|
|
26
|
+
const f = opts.fetch ?? fetch;
|
|
27
|
+
const env = opts.env ?? process.env;
|
|
28
|
+
const headers = { ...(cfg.headers ?? {}) };
|
|
29
|
+
for (const [header, variable] of Object.entries(cfg.headerEnv ?? {})) {
|
|
30
|
+
const v = env[variable];
|
|
31
|
+
if (!v)
|
|
32
|
+
throw new Error(`upstream ${name}: environment variable ${variable} is not set`);
|
|
33
|
+
headers[header] = v;
|
|
34
|
+
}
|
|
35
|
+
const tools = new Map(cfg.tools.map((t) => [t.name, t]));
|
|
36
|
+
return {
|
|
37
|
+
async listTools() {
|
|
38
|
+
return { tools: cfg.tools.map((t) => ({ name: t.name, description: t.description ?? `${t.method} ${t.path}`, inputSchema: t.inputSchema })) };
|
|
39
|
+
},
|
|
40
|
+
async callTool(params) {
|
|
41
|
+
const tool = tools.get(params.name);
|
|
42
|
+
if (!tool)
|
|
43
|
+
throw new Error(`upstream ${name} has no tool "${params.name}"`);
|
|
44
|
+
const { url, method, body } = buildRequest(cfg.baseUrl, tool, params.arguments ?? {});
|
|
45
|
+
const res = await f(url, { method, headers: { accept: "application/json", ...(body !== null ? { "content-type": "application/json" } : {}), ...headers }, ...(body !== null ? { body } : {}), signal: AbortSignal.timeout(cfg.timeoutMs) });
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
// The result is what the API returned, as text. JSON stays JSON so policy facts and consumers can read it.
|
|
48
|
+
return { content: [{ type: "text", text: text.length ? text : JSON.stringify({ status: res.status }) }], ...(res.ok ? {} : { isError: true }) };
|
|
49
|
+
},
|
|
50
|
+
async close() { },
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/sdk/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { digestOf, loadPrivateKey } from "../crypto.js";
|
|
6
6
|
import { createIssuer } from "../issue.js";
|
|
7
|
+
import { openExporter } from "../otel.js";
|
|
7
8
|
import { openLog } from "../log-sink.js";
|
|
8
9
|
import { evaluate } from "../policy.js";
|
|
9
10
|
export const SDK_VERSION = "0.1.0";
|
|
@@ -21,7 +22,7 @@ export class PolicyDeniedError extends Error {
|
|
|
21
22
|
}
|
|
22
23
|
export function createSdkIssuer(cfg) {
|
|
23
24
|
const key = loadPrivateKey(cfg.identity.keyFile);
|
|
24
|
-
const issuer = createIssuer(key, cfg.receiptsDir, openLog(cfg, key));
|
|
25
|
+
const issuer = createIssuer(key, cfg.receiptsDir, openLog(cfg, key), { exporter: openExporter(cfg) });
|
|
25
26
|
const policyText = cfg.policyFile ? readFileSync(cfg.policyFile, "utf8") : null;
|
|
26
27
|
const decide = (ev) => policyText === null ? null : evaluate(policyText, { agentId: cfg.agentId, tool: ev.tool, context: { args: ev.args, facts: {} } });
|
|
27
28
|
function record(ev, outcome, policy = null) {
|
package/dist/verify.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface VerifyOptions {
|
|
|
18
18
|
providerSecrets?: ProviderSecrets;
|
|
19
19
|
/** If given, the root is recomputed from this log file at the receipt's tree size and compared. */
|
|
20
20
|
logFile?: string;
|
|
21
|
+
/** If given, every tree head in the bundle must name this log, so a head from another tenant's log cannot be presented as this one's. */
|
|
22
|
+
logId?: string;
|
|
21
23
|
}
|
|
22
24
|
export interface VerifyResult {
|
|
23
25
|
ok: boolean;
|
|
@@ -35,6 +37,6 @@ export interface AuditResult {
|
|
|
35
37
|
* Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
|
|
36
38
|
* must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
|
|
37
39
|
*/
|
|
38
|
-
export declare function auditExtends(older: Envelope, newer: Envelope, proof: string[], keys: PublicKeyRef[]): AuditResult;
|
|
40
|
+
export declare function auditExtends(older: Envelope, newer: Envelope, proof: string[], keys: PublicKeyRef[], logId?: string): AuditResult;
|
|
39
41
|
/** Human-readable report: checks, then every field with its provenance so the reader knows what was proven vs. claimed. */
|
|
40
42
|
export declare function formatReport(r: VerifyResult): string;
|
package/dist/verify.js
CHANGED
|
@@ -3,7 +3,7 @@ import { canonicalize, digestOf, dsseVerify } from "./crypto.js";
|
|
|
3
3
|
import { delegationValidAt, verifyDelegation } from "./delegation.js";
|
|
4
4
|
import { leafHash, MerkleLog, verifyConsistency, verifyInclusion } from "./log.js";
|
|
5
5
|
import { checkProvider, checkUpstream, contentDigest, isProviderAttestation } from "./upstream.js";
|
|
6
|
-
import { RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
|
|
6
|
+
import { AUTHORIZATION_PREDICATE_TYPE, RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
|
|
7
7
|
const short = (s) => s.slice(0, 12);
|
|
8
8
|
export function verifyBundle(bundle, opts) {
|
|
9
9
|
const checks = [];
|
|
@@ -64,11 +64,36 @@ export function verifyBundle(bundle, opts) {
|
|
|
64
64
|
add("no policy errors on an allow", !(p.policy.decision === "allow" && p.policy.errors.length > 0));
|
|
65
65
|
}
|
|
66
66
|
const logKeys = opts.logKeys ?? [];
|
|
67
|
+
if (p.authorization) {
|
|
68
|
+
// The gateway says it committed this call to the log before forwarding it. Check that the committed statement is
|
|
69
|
+
// the issuer's, describes this very call, sits in the log, and sits there before the receipt does.
|
|
70
|
+
const a = p.authorization;
|
|
71
|
+
const asig = dsseVerify(a.envelope, opts.issuerKeys);
|
|
72
|
+
const ast = asig.ok ? asig.payload : null;
|
|
73
|
+
const typed = !!ast && a.envelope.payloadType === RECEIPT_TYPE && ast.predicateType === AUTHORIZATION_PREDICATE_TYPE;
|
|
74
|
+
add("authorization signature (issuer key)", asig.ok && typed && asig.keyid === sig.keyid, asig.ok ? (typed ? `keyid ${short(asig.keyid)}` : "not an authorization statement") : asig.error);
|
|
75
|
+
if (ast && typed) {
|
|
76
|
+
const ap = ast.predicate;
|
|
77
|
+
const same = ap.receiptId === p.receiptId && ap.tool.name === p.tool.name && ap.request.argsDigest === p.request.argsDigest && ap.agent.id === p.agent.id && ap.principal.id === p.principal.id && ap.policy?.decision === "allow";
|
|
78
|
+
add("authorization names this call", same, same ? `${ap.tool.name} for receipt ${short(ap.receiptId)}` : "committed for a different receipt, tool, arguments, agent, or decision");
|
|
79
|
+
const ath = dsseVerify(a.treeHead, [...logKeys, ...opts.issuerKeys]);
|
|
80
|
+
add("authorization tree head signature", ath.ok && a.treeHead.payloadType === TREEHEAD_TYPE, ath.ok ? `keyid ${short(ath.keyid)}` : ath.error);
|
|
81
|
+
if (ath.ok) {
|
|
82
|
+
const ahead = ath.payload;
|
|
83
|
+
const included = ahead.treeSize === a.inclusion.treeSize && verifyInclusion(leafHash(canonicalize(a.envelope)), a.inclusion, ahead.rootHash);
|
|
84
|
+
add("authorization log inclusion proof", included, `leaf ${a.inclusion.leafIndex} of ${a.inclusion.treeSize}`);
|
|
85
|
+
const before = a.inclusion.leafIndex < bundle.inclusion.leafIndex && a.inclusion.treeSize <= bundle.inclusion.treeSize;
|
|
86
|
+
add("authorization logged before execution", before, `authorization leaf ${a.inclusion.leafIndex}, receipt leaf ${bundle.inclusion.leafIndex}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
67
90
|
const th = dsseVerify(bundle.treeHead, [...logKeys, ...opts.issuerKeys]);
|
|
68
91
|
const byLog = th.ok && logKeys.some((k) => k.keyid === th.keyid);
|
|
69
92
|
add("tree head signature", th.ok && bundle.treeHead.payloadType === TREEHEAD_TYPE, th.ok ? `${byLog ? "log key" : "issuer key"} ${short(th.keyid)}` : th.error);
|
|
70
93
|
if (th.ok) {
|
|
71
94
|
const head = th.payload;
|
|
95
|
+
if (opts.logId !== undefined)
|
|
96
|
+
add("tree head names the expected log", head.log === opts.logId, head.log ? `log ${head.log}` : "tree head names no log");
|
|
72
97
|
add("tree head matches inclusion proof size", head.treeSize === bundle.inclusion.treeSize);
|
|
73
98
|
const included = verifyInclusion(leafHash(canonicalize(bundle.envelope)), bundle.inclusion, head.rootHash);
|
|
74
99
|
add("log inclusion proof", included, `leaf ${bundle.inclusion.leafIndex} of ${bundle.inclusion.treeSize}, root ${short(head.rootHash)}`);
|
|
@@ -90,7 +115,7 @@ export function verifyBundle(bundle, opts) {
|
|
|
90
115
|
* Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
|
|
91
116
|
* must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
|
|
92
117
|
*/
|
|
93
|
-
export function auditExtends(older, newer, proof, keys) {
|
|
118
|
+
export function auditExtends(older, newer, proof, keys, logId) {
|
|
94
119
|
const checks = [];
|
|
95
120
|
const add = (name, ok, detail) => {
|
|
96
121
|
checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
|
|
@@ -105,6 +130,10 @@ export function auditExtends(older, newer, proof, keys) {
|
|
|
105
130
|
const b = decode("newer", newer);
|
|
106
131
|
if (!a || !b)
|
|
107
132
|
return { ok: false, checks, older: a, newer: b };
|
|
133
|
+
if (logId !== undefined)
|
|
134
|
+
add("both tree heads name the expected log", a.log === logId && b.log === logId, `expected ${logId}, got ${a.log ?? "none"} and ${b.log ?? "none"}`);
|
|
135
|
+
else if (a.log !== b.log)
|
|
136
|
+
add("both tree heads name the same log", false, `${a.log ?? "none"} and ${b.log ?? "none"}`);
|
|
108
137
|
if (!add("older is not larger than newer", a.treeSize <= b.treeSize, `${a.treeSize} -> ${b.treeSize}`))
|
|
109
138
|
return { ok: false, checks, older: a, newer: b };
|
|
110
139
|
const consistent = verifyConsistency(a.treeSize, a.rootHash, b.treeSize, b.rootHash, proof);
|
|
@@ -145,6 +174,12 @@ export function formatReport(r) {
|
|
|
145
174
|
row("policy", "-", "(none evaluated)");
|
|
146
175
|
if (p.consumed)
|
|
147
176
|
row("consumed", p.consumed.provenance, p.consumed.factIds.length === 0 ? "(no facts shown before this call)" : p.consumed.factIds);
|
|
177
|
+
if (p.authorization) {
|
|
178
|
+
const committed = r.checks.filter((c) => c.name.startsWith("authorization ")).every((c) => c.ok);
|
|
179
|
+
row("authorization", "observed", committed ? `committed to the log as leaf ${p.authorization.inclusion.leafIndex}, before the call was forwarded` : "carried, but its checks FAILED");
|
|
180
|
+
}
|
|
181
|
+
else if (p.execution.status === "withheld")
|
|
182
|
+
row("authorization", "observed", "the log would not commit it; the call was not forwarded");
|
|
148
183
|
const upstreamCheck = r.checks.find((c) => c.name === "upstream signature (upstream key)" || c.name === "upstream signature (provider secret)");
|
|
149
184
|
const hasUpstream = (p.execution.status === "executed" || p.execution.status === "failed") && !!p.execution.upstream;
|
|
150
185
|
const byProvider = hasUpstream && isProviderAttestation(p.execution.upstream);
|