@agent-custody/receipts 0.6.1 → 0.6.3
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 +2 -1
- package/dist/cli.js +6 -5
- package/dist/delegation.d.ts +2 -0
- package/dist/delegation.js +12 -2
- package/dist/gateway.d.ts +6 -0
- package/dist/gateway.js +22 -1
- package/dist/log-sink.d.ts +6 -0
- package/dist/log-sink.js +10 -3
- package/dist/policy.d.ts +7 -0
- package/dist/policy.js +29 -0
- package/dist/portal.js +3 -2
- package/dist/verify.js +2 -2
- package/docs/threat-model.md +5 -2
- package/docs/usage.md +2 -2
- package/package.json +1 -1
- package/vectors/audit.json +27 -27
- package/vectors/canonical.json +5 -5
- package/vectors/receipts.json +283 -234
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ Two producers, one receipt format, one verifier.
|
|
|
9
9
|
|
|
10
10
|
Anyone holding the public keys can verify a receipt offline. The agent is not trusted. The layer around it is, and the receipt says exactly how far that trust extends, starting with who issued it.
|
|
11
11
|
|
|
12
|
-
- [
|
|
12
|
+
- [Reference](https://docs.agent-custody.dev/reference/): every function, endpoint, MCP tool, and command with its request and response
|
|
13
|
+
- [Tutorials](docs/tutorials.md): twenty runnable examples, one per aspect of the code, all executed by the test suite
|
|
13
14
|
- [Usage guide](docs/usage.md): gateway setup, wiring into Claude Desktop, Claude Code, or your own agent loop
|
|
14
15
|
- [The interceptor SDK](docs/sdk.md): Claude Code hooks, the Claude Agent SDK, adapters for the OpenAI Agents SDK, Vercel AI SDK and LangChain, and wrapping tool functions in anything else
|
|
15
16
|
- [Writing policies](docs/policies.md): how a tool call becomes a Cedar request, with tested examples
|
package/dist/cli.js
CHANGED
|
@@ -54,7 +54,8 @@ const USAGE = `agent-custody <command>
|
|
|
54
54
|
sign with a key in this process, or through a signer process that holds it; publish a signed
|
|
55
55
|
checkpoint per log that has grown, every 300 s by default, to the directory (and, with a
|
|
56
56
|
database, to its heads table); serve the key document at /.well-known/agent-custody-log.json
|
|
57
|
-
log ... [--trust-proxy] behind a reverse proxy you run: key per-address limits by
|
|
57
|
+
log ... [--trust-proxy] behind a reverse proxy you run: key per-address limits by the address the proxy appended
|
|
58
|
+
log ... [--hash-only] accept only leaf hashes on append; a log run for other people must never receive a receipt
|
|
58
59
|
log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
|
|
59
60
|
the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
|
|
60
61
|
the welcome sheet; the public URLs fill the sheet in
|
|
@@ -73,10 +74,10 @@ const USAGE = `agent-custody <command>
|
|
|
73
74
|
the one process that holds the log's key: POST /sign, GET /keys
|
|
74
75
|
log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id> | tenant plan <id> <free|team|enterprise>
|
|
75
76
|
log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
|
|
76
|
-
log-admin --db-env NAME audit [--tenant <id>]
|
|
77
|
+
log-admin --db-env NAME audit [--tenant <id>] who did what to tenants and tokens, newest first
|
|
77
78
|
portal --db-env NAME --secret-env NAME --public-url <log url> [--checkpoints-url <url>] [--portal-url <url>] [--port 8792] [--host 127.0.0.1]
|
|
78
79
|
[--stripe-key-env NAME --stripe-webhook-env NAME --stripe-price-team <price id>] [--trust-proxy]
|
|
79
|
-
the tenant portal: register, first key, usage against plan, keys, billing, export
|
|
80
|
+
the tenant portal: register, first key, usage against plan, keys, billing, export
|
|
80
81
|
log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
|
|
81
82
|
audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--witness-key <pub> | --witness-url <url>] [--json]
|
|
82
83
|
with --log-url the log's published keys are fetched and pinned by keyid; with a witness key or
|
|
@@ -365,7 +366,7 @@ async function main(argv) {
|
|
|
365
366
|
case "log": {
|
|
366
367
|
const { values } = parseArgs({
|
|
367
368
|
args: rest,
|
|
368
|
-
options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" }, "db-env": { type: "string" }, "signer-url": { type: "string" }, "signer-token-env": { type: "string" }, "retired-key": { type: "string", multiple: true }, "checkpoint-dir": { type: "string" }, "checkpoint-every": { type: "string", default: "300" }, "checkpoint-heartbeat": { type: "string", default: "21600" }, "admin-token-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" }, "trust-proxy": { type: "boolean", default: false } },
|
|
369
|
+
options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" }, "db-env": { type: "string" }, "signer-url": { type: "string" }, "signer-token-env": { type: "string" }, "retired-key": { type: "string", multiple: true }, "checkpoint-dir": { type: "string" }, "checkpoint-every": { type: "string", default: "300" }, "checkpoint-heartbeat": { type: "string", default: "21600" }, "admin-token-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" }, "trust-proxy": { type: "boolean", default: false }, "hash-only": { type: "boolean", default: false } },
|
|
369
370
|
});
|
|
370
371
|
if (!values.key === !values["signer-url"])
|
|
371
372
|
throw new Error("log needs exactly one of --key or --signer-url");
|
|
@@ -420,7 +421,7 @@ async function main(argv) {
|
|
|
420
421
|
resolver = fileResolver(values.file, { ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
|
|
421
422
|
where = `file=${values.file}${values["log-id"] ? ` log=${values["log-id"]}` : ""} ${token ? "bearer token required" : "open, anyone may append"}${tenants ? ` tenants=${Object.keys(tenants).join(",")}` : ""}`;
|
|
422
423
|
}
|
|
423
|
-
const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}), trustProxy: values["trust-proxy"] });
|
|
424
|
+
const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}), trustProxy: values["trust-proxy"], hashOnly: values["hash-only"] });
|
|
424
425
|
const publisher = checkpoints ? new CheckpointPublisher(resolver, signer, checkpoints, everyMs, undefined, heartbeatMs) : null;
|
|
425
426
|
publisher?.start();
|
|
426
427
|
console.error(`agent-custody log: ${running.url} keyid=${signer.keyid} ${values["signer-url"] ? `signer=${values["signer-url"]} ` : ""}${where}${checkpoints ? ` checkpoints every ${values["checkpoint-every"]}s${values["checkpoint-dir"] ? ` to ${values["checkpoint-dir"]}` : ""}` : ""}${admin ? " admin page at /admin" : ""}`);
|
package/dist/delegation.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface SubDelegation {
|
|
|
30
30
|
/** the sub-agent's own public key, so it may delegate further */
|
|
31
31
|
agentKey?: string;
|
|
32
32
|
}
|
|
33
|
+
/** True when the envelope's payload, verified or not, carries a parent: what decides whether the chain check is reported. */
|
|
34
|
+
export declare function delegationHasParent(env: Envelope): boolean;
|
|
33
35
|
/** The parent's payload as written, without verifying it; the verifier does that. */
|
|
34
36
|
export declare function decodeDelegation(env: Envelope): Delegation | null;
|
|
35
37
|
/**
|
package/dist/delegation.js
CHANGED
|
@@ -16,8 +16,8 @@ export const DelegationSchema = z.object({
|
|
|
16
16
|
principal: z.string().min(1),
|
|
17
17
|
agent: z.string().min(1),
|
|
18
18
|
scopes: z.array(z.string().min(1)).min(1),
|
|
19
|
-
issuedAt: z.iso.datetime(),
|
|
20
|
-
expiresAt: z.iso.datetime(),
|
|
19
|
+
issuedAt: z.iso.datetime({ offset: true }),
|
|
20
|
+
expiresAt: z.iso.datetime({ offset: true }),
|
|
21
21
|
/** the agent's own public key, SPKI PEM; with it the agent may delegate to a sub-agent */
|
|
22
22
|
agentKey: z.string().min(1).optional(),
|
|
23
23
|
/** the grant this one was delegated from; the chain ends at a grant signed by a trusted principal */
|
|
@@ -26,6 +26,16 @@ export const DelegationSchema = z.object({
|
|
|
26
26
|
export function createDelegation(principalKey, d) {
|
|
27
27
|
return dsseSign(DELEGATION_TYPE, DelegationSchema.parse(d), principalKey);
|
|
28
28
|
}
|
|
29
|
+
/** True when the envelope's payload, verified or not, carries a parent: what decides whether the chain check is reported. */
|
|
30
|
+
export function delegationHasParent(env) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(Buffer.from(env.payload, "base64").toString("utf8"));
|
|
33
|
+
return !!raw && typeof raw === "object" && raw.parent !== undefined;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
29
39
|
/** The parent's payload as written, without verifying it; the verifier does that. */
|
|
30
40
|
export function decodeDelegation(env) {
|
|
31
41
|
try {
|
package/dist/gateway.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface Gateway {
|
|
|
26
26
|
handleCall(params: CallParams): Promise<CallToolResult>;
|
|
27
27
|
close(): Promise<void>;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The environment a stdio upstream is started with: what a child process needs to run, plus what the config names.
|
|
31
|
+
* Never the gateway's own environment, which holds the tokens other upstreams and the log are given. An explicit
|
|
32
|
+
* `env` in the config is added to this base, not to the gateway's variables.
|
|
33
|
+
*/
|
|
34
|
+
export declare function upstreamEnv(extra?: Record<string, string>): Record<string, string>;
|
|
29
35
|
export interface GatewayOptions {
|
|
30
36
|
/** the log to append to, in place of the one the config names; for embedding and tests */
|
|
31
37
|
log?: LogSink;
|
package/dist/gateway.js
CHANGED
|
@@ -57,6 +57,20 @@ function extractValue(result) {
|
|
|
57
57
|
return text.text;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The environment a stdio upstream is started with: what a child process needs to run, plus what the config names.
|
|
62
|
+
* Never the gateway's own environment, which holds the tokens other upstreams and the log are given. An explicit
|
|
63
|
+
* `env` in the config is added to this base, not to the gateway's variables.
|
|
64
|
+
*/
|
|
65
|
+
export function upstreamEnv(extra) {
|
|
66
|
+
const base = {};
|
|
67
|
+
for (const k of ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "SystemRoot", "SYSTEMROOT", "USERPROFILE", "APPDATA", "NODE_OPTIONS"]) {
|
|
68
|
+
const v = process.env[k];
|
|
69
|
+
if (v !== undefined)
|
|
70
|
+
base[k] = v;
|
|
71
|
+
}
|
|
72
|
+
return { ...base, ...(extra ?? {}) };
|
|
73
|
+
}
|
|
60
74
|
export async function createGatewayHost(cfg, options = {}) {
|
|
61
75
|
const gatewayKey = loadPrivateKey(cfg.identity.keyFile);
|
|
62
76
|
const trusted = cfg.trustedPrincipalKeys.map(loadPublicKey);
|
|
@@ -65,6 +79,13 @@ export async function createGatewayHost(cfg, options = {}) {
|
|
|
65
79
|
const issuer = createIssuer(gatewayKey, cfg.receiptsDir, options.log ?? openLog(cfg, gatewayKey), { exporter: options.exporter ?? openExporter(cfg) });
|
|
66
80
|
const precommit = new Set(cfg.precommit);
|
|
67
81
|
const consequential = (tool) => precommit.has("*") || precommit.has(tool);
|
|
82
|
+
// A fact lookup runs before the policy can decide, because the policy decides on it. It is therefore a read the
|
|
83
|
+
// gateway makes on the agent's request, never a consequential call: a fact tool named in precommit is a config error.
|
|
84
|
+
// "*" covers the tools the agent calls; a lookup is the gateway's own read, so only an explicit name makes it consequential.
|
|
85
|
+
for (const f of cfg.facts) {
|
|
86
|
+
if (precommit.has(f.tool))
|
|
87
|
+
throw new Error(`fact "${f.name}" uses tool ${f.tool}, which precommit names as consequential; a fact lookup runs before the policy decides and must be a read`);
|
|
88
|
+
}
|
|
68
89
|
// One host, as many upstreams as the agents' jobs need. Each tool name belongs to exactly one upstream, decided at
|
|
69
90
|
// startup, so a receipt's tool is unambiguous and consumed facts flow across them.
|
|
70
91
|
const upstreamConfigs = cfg.upstreams ? cfg.upstreams.map((u) => ({ name: u.name, cfg: u })) : [{ name: "upstream", cfg: cfg.upstream }];
|
|
@@ -85,7 +106,7 @@ export async function createGatewayHost(cfg, options = {}) {
|
|
|
85
106
|
}
|
|
86
107
|
else {
|
|
87
108
|
client = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
|
|
88
|
-
await client.connect(new StdioClientTransport({ command: u.command, args: u.args, env: u.env, stderr: "inherit" }));
|
|
109
|
+
await client.connect(new StdioClientTransport({ command: u.command, args: u.args, env: upstreamEnv(u.env), stderr: "inherit" }));
|
|
89
110
|
}
|
|
90
111
|
upstreams.set(name, client);
|
|
91
112
|
const { tools } = await client.listTools();
|
package/dist/log-sink.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface LogServerOptions {
|
|
|
62
62
|
}>;
|
|
63
63
|
/** appends per token (or per address without one); default 50 a second, burst 100 */
|
|
64
64
|
rateLimit?: RateLimitOptions;
|
|
65
|
+
/** accept only { leafHash } on append: a log run for other people must never receive a receipt */
|
|
66
|
+
hashOnly?: boolean;
|
|
65
67
|
/** largest append body accepted, in bytes; default 65536 */
|
|
66
68
|
maxBodyBytes?: number;
|
|
67
69
|
/** where published checkpoints go and are listed from; without one, /checkpoints answers with none */
|
|
@@ -76,6 +78,10 @@ export interface LogServerOptions {
|
|
|
76
78
|
trustProxy?: boolean;
|
|
77
79
|
}
|
|
78
80
|
/** The address a limit is keyed by: the socket's, or the proxy's forwarded one when the proxy is trusted. */
|
|
81
|
+
/**
|
|
82
|
+
* The address a per-client limit keys on. Behind a proxy you run, the proxy appends the real client to
|
|
83
|
+
* X-Forwarded-For, so the rightmost entry is the one it wrote; everything left of it is whatever the client sent.
|
|
84
|
+
*/
|
|
79
85
|
export declare function clientAddress(req: IncomingMessage, trustProxy?: boolean): string;
|
|
80
86
|
/** One log as the handler sees it, whatever stands behind it. */
|
|
81
87
|
export interface TenantUsage {
|
package/dist/log-sink.js
CHANGED
|
@@ -97,12 +97,17 @@ export function openLog(cfg, key) {
|
|
|
97
97
|
return fileLog(cfg.logFile, key);
|
|
98
98
|
}
|
|
99
99
|
/** The address a limit is keyed by: the socket's, or the proxy's forwarded one when the proxy is trusted. */
|
|
100
|
+
/**
|
|
101
|
+
* The address a per-client limit keys on. Behind a proxy you run, the proxy appends the real client to
|
|
102
|
+
* X-Forwarded-For, so the rightmost entry is the one it wrote; everything left of it is whatever the client sent.
|
|
103
|
+
*/
|
|
100
104
|
export function clientAddress(req, trustProxy = false) {
|
|
101
105
|
if (trustProxy) {
|
|
102
106
|
const xff = req.headers["x-forwarded-for"];
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
const entries = (Array.isArray(xff) ? xff.join(",") : xff ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
108
|
+
const last = entries[entries.length - 1];
|
|
109
|
+
if (last)
|
|
110
|
+
return last;
|
|
106
111
|
}
|
|
107
112
|
return req.socket.remoteAddress ?? "?";
|
|
108
113
|
}
|
|
@@ -324,6 +329,8 @@ export function logHandler(source, keyOrSigner, opts = {}) {
|
|
|
324
329
|
which.appended?.();
|
|
325
330
|
return json(200, r);
|
|
326
331
|
}
|
|
332
|
+
if (opts.hashOnly)
|
|
333
|
+
return json(400, { error: "this log accepts leaf hashes only; set \"hashOnly\": true in your log config so the receipt never leaves your machine" });
|
|
327
334
|
if (typeof parsed.leaf !== "string" || parsed.leaf.length === 0)
|
|
328
335
|
return json(400, { error: "leaf must be a non-empty string, or send leafHash" });
|
|
329
336
|
const r = await appendSigned(log, signer, { leaf: parsed.leaf }, logId);
|
package/dist/policy.d.ts
CHANGED
|
@@ -13,4 +13,11 @@ export interface PolicyRequest {
|
|
|
13
13
|
context: Record<string, unknown>;
|
|
14
14
|
}
|
|
15
15
|
export declare function policyDigest(policyText: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Cedar's JSON context reads `{ "__entity": … }` and `{ "__extn": … }` as typed values. Arguments come from the agent,
|
|
18
|
+
* so an argument shaped that way could satisfy a policy comparing it to an entity, an address, or a decimal by its
|
|
19
|
+
* shape rather than by anything the gateway observed. Arguments are data: the first such form found is the path
|
|
20
|
+
* returned, and the evaluation is a deny with that error.
|
|
21
|
+
*/
|
|
22
|
+
export declare function reservedCedarForm(value: unknown, path?: string): string | null;
|
|
16
23
|
export declare function evaluate(policyText: string, req: PolicyRequest): PolicyDecision;
|
package/dist/policy.js
CHANGED
|
@@ -4,8 +4,37 @@ import { sha256Hex } from "./crypto.js";
|
|
|
4
4
|
export function policyDigest(policyText) {
|
|
5
5
|
return sha256Hex(policyText);
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Cedar's JSON context reads `{ "__entity": … }` and `{ "__extn": … }` as typed values. Arguments come from the agent,
|
|
9
|
+
* so an argument shaped that way could satisfy a policy comparing it to an entity, an address, or a decimal by its
|
|
10
|
+
* shape rather than by anything the gateway observed. Arguments are data: the first such form found is the path
|
|
11
|
+
* returned, and the evaluation is a deny with that error.
|
|
12
|
+
*/
|
|
13
|
+
export function reservedCedarForm(value, path = "args") {
|
|
14
|
+
if (Array.isArray(value)) {
|
|
15
|
+
for (let i = 0; i < value.length; i++) {
|
|
16
|
+
const hit = reservedCedarForm(value[i], `${path}[${i}]`);
|
|
17
|
+
if (hit)
|
|
18
|
+
return hit;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
for (const [k, v] of Object.entries(value)) {
|
|
24
|
+
if (k === "__entity" || k === "__extn")
|
|
25
|
+
return `${path}.${k}`;
|
|
26
|
+
const hit = reservedCedarForm(v, `${path}.${k}`);
|
|
27
|
+
if (hit)
|
|
28
|
+
return hit;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
7
33
|
export function evaluate(policyText, req) {
|
|
8
34
|
const digest = policyDigest(policyText);
|
|
35
|
+
const reserved = reservedCedarForm(req.context.args);
|
|
36
|
+
if (reserved)
|
|
37
|
+
return { decision: "deny", reasons: [], errors: [`argument ${reserved} uses a reserved Cedar form; arguments are data, never entities or extension values`], policyDigest: digest };
|
|
9
38
|
const answer = cedar.isAuthorized({
|
|
10
39
|
principal: { type: "Agent", id: req.agentId },
|
|
11
40
|
action: { type: "Action", id: req.tool },
|
package/dist/portal.js
CHANGED
|
@@ -365,7 +365,8 @@ const PORTAL_PAGE = `<!doctype html>
|
|
|
365
365
|
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
366
366
|
.top { display: flex; align-items: center; gap: 1rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
367
367
|
.brand { font-weight: 700; letter-spacing: .04em; } .brand b { color: var(--accent); }
|
|
368
|
-
.top .
|
|
368
|
+
.top .links { margin-left: auto; display: flex; gap: .9rem; font-size: .9rem; } .top .links a { color: var(--accent); text-decoration: none; }
|
|
369
|
+
.top .who { color: var(--ink2); font-size: .9rem; margin-left: .5rem; }
|
|
369
370
|
.pill { display: inline-block; padding: .05rem .5rem; border-radius: 999px; font: 600 .72rem/1.6 var(--mono); letter-spacing: .06em; text-transform: uppercase; background: var(--accent-bg); color: var(--accent); }
|
|
370
371
|
.layout { display: grid; grid-template-columns: 15rem 1fr; min-height: calc(100vh - 3.3rem); }
|
|
371
372
|
nav { border-right: 1px solid var(--line); background: var(--panel); padding: 1rem 0; }
|
|
@@ -404,7 +405,7 @@ const PORTAL_PAGE = `<!doctype html>
|
|
|
404
405
|
[hidden] { display: none !important; }
|
|
405
406
|
@media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } nav { display: flex; flex-wrap: wrap; padding: .3rem; border-right: 0; border-bottom: 1px solid var(--line); } nav .group { display: none; } nav a { border-left: 0; border-bottom: 3px solid transparent; } nav a.on { border-bottom-color: var(--accent); } }
|
|
406
407
|
</style>
|
|
407
|
-
<div class="top"><span class="brand"><b>◆</b> agent-custody</span><span id="tenantTag" class="pill" hidden></span><span id="planTag" class="pill" hidden></span><span class="who" id="who"></span></div>
|
|
408
|
+
<div class="top"><span class="brand"><b>◆</b> agent-custody</span><span id="tenantTag" class="pill" hidden></span><span id="planTag" class="pill" hidden></span><span class="links"><a href="https://agent-custody.dev/guide/getting-started">Guide</a><a href="https://docs.agent-custody.dev/reference/">Docs</a><a href="https://agent-custody.dev/verify">Verify a receipt</a></span><span class="who" id="who"></span></div>
|
|
408
409
|
<section id="auth" class="auth" hidden>
|
|
409
410
|
<h1 id="authTitle">Sign in</h1>
|
|
410
411
|
<form id="authForm">
|
package/dist/verify.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Independent verification of a receipt bundle. Needs only public keys, and optionally a copy of the log.
|
|
2
2
|
import { canonicalize, digestOf, dsseVerifiers, dsseVerify } from "./crypto.js";
|
|
3
|
-
import {
|
|
3
|
+
import { delegationHasParent, delegationValidAt, describeChain, 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
6
|
import { AUTHORIZATION_PREDICATE_TYPE, RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
|
|
@@ -33,7 +33,7 @@ export function verifyBundle(bundle, opts) {
|
|
|
33
33
|
add("delegation signature (principal key)", del.ok, del.ok ? `signed by ${short(del.keyid)}` : del.error);
|
|
34
34
|
// A chained grant: every link signed by the key its parent names, scopes and windows nested, one principal
|
|
35
35
|
// throughout. The line appears only when the grant embeds a parent, and fails with the link that broke.
|
|
36
|
-
const chained =
|
|
36
|
+
const chained = delegationHasParent(p.delegation.envelope);
|
|
37
37
|
if (chained)
|
|
38
38
|
add("delegation chain to the principal", del.ok, del.ok ? `${describeChain(del.chain)} (${del.chain.length - 1} delegation(s))` : del.error);
|
|
39
39
|
if (del.ok) {
|
package/docs/threat-model.md
CHANGED
|
@@ -38,7 +38,9 @@ Each row names the attacker, the move, the defence, and whether the defence is a
|
|
|
38
38
|
| attack | defence | kind |
|
|
39
39
|
| --- | --- | --- |
|
|
40
40
|
| Call a tool the grant does not allow | The gateway evaluates the policy against the grant the principal signed; the agent never sees the policy or the key. A denied call never reaches the upstream and still produces a receipt. | deployment: only calls through the gateway are covered |
|
|
41
|
-
| Feed the policy lies in the arguments | `context.args` is `claimed`; policies that matter read `context.facts`, which the gateway fetched itself and marks `observed`. A policy that decides on args alone is the operator's choice and the receipt shows it. | evidence: provenance is in the receipt |
|
|
41
|
+
| Feed the policy lies in the arguments | `context.args` is `claimed`; policies that matter read `context.facts`, which the gateway fetched itself and marks `observed`. A policy that decides on args alone is the operator's choice and the receipt shows it. Arguments are passed to Cedar as data only: a value shaped as a Cedar entity or extension (`__entity`, `__extn`) is a deny before evaluation, so an argument cannot satisfy a comparison by its shape. | evidence: provenance is in the receipt |
|
|
42
|
+
| Make the gateway run a fact lookup on the way to a denial | A fact lookup runs before the policy decides, because the policy decides on it, and only after the tool is in the grant's scope. It is therefore a read the gateway makes on the agent's request: a fact tool named in `precommit` is refused at startup, so no consequential call can be reached through a lookup, and every lookup is on the receipt with its arguments. | deployment: fact tools must be reads |
|
|
43
|
+
| Read the gateway's secrets from a tool process | A stdio upstream is started with a minimal environment (the path, home, and temp directories, plus what its own config names), never the gateway's, so the tokens other upstreams and the log are given do not reach it. | deployment |
|
|
42
44
|
| Bypass the gateway with a credential in its own environment | Nothing in the packages prevents this. The gateway covers the tools behind it and no others. | not covered; the deployment must keep credentials out of the agent |
|
|
43
45
|
| Skip an in-process hook or SDK wrapper | Same: an SDK receipt is the agent's own report, and every field in it is `claimed`. | not covered; use the gateway for anything consequential |
|
|
44
46
|
| Write memory the fleet will trust | Writes not made through the gateway are `claimed` and quarantined until a gateway confirms them; reads leave them out unless asked for. | deployment |
|
|
@@ -73,7 +75,7 @@ Each row names the attacker, the move, the defence, and whether the defence is a
|
|
|
73
75
|
|
|
74
76
|
| attack | defence | kind |
|
|
75
77
|
| --- | --- | --- |
|
|
76
|
-
| Read receipt contents |
|
|
78
|
+
| Read receipt contents | The hosted log runs with `--hash-only` and refuses a full leaf at append, so no receipt reaches the log process even from a misconfigured gateway or a stolen token; the tenant's `hashOnly` setting keeps the receipt from ever leaving their machine. A log an operator runs for themselves may accept full leaves. | evidence: the server refuses; deployment on the tenant's side |
|
|
77
79
|
| Rewrite a tenant's tree | Every head is signed and published as a checkpoint on a second host; a rewrite means two signed heads at one size with different roots, or a later head that does not extend an earlier one. Anyone holding an earlier head detects it with `audit`; the monitor does it every ten minutes; the witness countersigns only heads that extend the last it signed. | evidence, given a witness or an earlier head held elsewhere |
|
|
78
80
|
| Show different histories to different verifiers | Same: checkpoints are public and the witness sees one history. Without a witness, two verifiers who compare heads detect it, and nobody else does. | evidence with a witness; otherwise detection needs comparison |
|
|
79
81
|
| Mint a token for a tenant and append noise | Appends are hashes with no content; the tenant's export and usage show leaves and live tokens they did not make, and the audit trail, which the tenant's export carries, records who minted what and from where. | deployment |
|
|
@@ -96,6 +98,7 @@ Each row names the attacker, the move, the defence, and whether the defence is a
|
|
|
96
98
|
| Alter an append in flight | TLS between gateway and log; the leaf hash is over a signed envelope, so an altered hash simply fails inclusion for the real receipt. | evidence |
|
|
97
99
|
| Drop the log's answer | The gateway retries, then errors or withholds; no receipt is handed out without an inclusion proof. Timeouts bound the wait. | evidence |
|
|
98
100
|
| Steal a tenant token | Tokens are bearer secrets; a stolen one appends noise to that tenant's log until revoked. Rate limits bound the damage per second; the tenant's usage shows it. | deployment |
|
|
101
|
+
| Escape a per-address limit by forging `X-Forwarded-For` | Behind the proxy the deployment runs, the limiter keys on the address the proxy appended, the rightmost entry, never on one the client supplied. | deployment |
|
|
99
102
|
|
|
100
103
|
## What is not defended
|
|
101
104
|
|
package/docs/usage.md
CHANGED
|
@@ -80,7 +80,7 @@ when {
|
|
|
80
80
|
}
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
`upstream` is spawned by the gateway exactly as an MCP host would spawn it
|
|
83
|
+
`upstream` is spawned by the gateway exactly as an MCP host would spawn it, with a minimal environment: the path, home, and temp directories, the locale, and what `env` names. It never inherits the gateway's own variables, where the tokens for other upstreams and the log live, so a tool process cannot read them; `env` is where that upstream's own credentials go, and the agent never sees them. Several upstreams sit behind one gateway and one grant with `"upstreams": [{ "name": "memory", "command": ..., "args": [...] }, { "name": "payments", "url": ... }]` in place of `upstream`. Each tool name must be offered by exactly one of them, checked at startup; the receipt's `tool.upstream` says which served the call, and consumed facts flow across them, so a refund made after a memory read carries the facts the agent had been shown. An upstream that is already running is reached instead with `"upstream": { "url": "https://memory.internal/mcp", "tokenEnv": "MEMORY_TOKEN" }`, over Streamable HTTP with a bearer token from the environment; the shared memory server in `@agent-custody/state` is the usual case.
|
|
84
84
|
|
|
85
85
|
An upstream need not be an MCP server. A plain HTTP API is described as tools:
|
|
86
86
|
|
|
@@ -107,7 +107,7 @@ An upstream need not be an MCP server. A plain HTTP API is described as tools:
|
|
|
107
107
|
"log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
Exactly one of the two. The bearer token comes from the named environment variable, never from the file, and a missing variable fails at startup. Add `"hashOnly": true` for any log run by someone else: the gateway then sends only the leaf hash, sha256 of the receipt envelope with the RFC 6962 prefix, so the log commits to the receipt without ever holding it, and the receipts with their arguments and results stay in `receiptsDir`. The verifier does not change; it hashes the envelope itself. A log that serves several tenants is reached at `<url>/t/<tenant>/`, and each of its tree heads names its log, which a verifier checks with `--log-id`. With a remote log the tree head in each receipt is signed by the log's key, and a verifier must be given that key with `--log-key`. An append that gets no answer within `timeoutMs` (default 10000) counts as unreachable and is retried like a server error, so a log that accepts connections and never answers cannot hold a call forever. If the log refuses a leaf, the receipt is not issued and the call returns an error to the agent. For an ordinary call the upstream action has already happened by then, and the error says so; a receipt that was never logged must not be handed out. For a tool named in `precommit` the order is reversed, below, and the action never happens. The reference log server is `node src/cli.ts log --file log.jsonl --key keys/log.key --port 8787 --token-env AGENT_CUSTODY_LOG_TOKEN [--log-id <id>] [--tenants tenants.json]`. It serves `POST /append` with `{leaf}` or `{leafHash}` (token required when one is configured), `GET /root?size=N`, `GET /consistency?old=M&new=N`, and `GET /head`; [verification.md](verification.md) says what each proves. `--log-id` writes that id into every tree head. `--tenants` names a JSON file, `{ "acme": { "file": "acme.jsonl", "tokenEnv": "ACME_TOKEN", "logId": "acme-eu" } }`, and each tenant is its own log at `/t/acme/…` with its own token and id; the default log stays at the root paths. With `--db-env DATABASE_URL` the server keeps its logs in Postgres instead of files, and needs the `pg` package beside it: leaves as hashes in one table keyed by tenant, one writer per tenant enforced with an advisory lock so a second instance is safe, tenants and their tokens in tables of their own with tokens stored only as hashes, and rate limits per token (50 appends a second, burst 100, a 64 KB body cap; a refused append answers 429 with `retry-after`, and the gateway's sink retries a few times). Tenants are managed with `log-admin --db-env DATABASE_URL`: `tenant add <id> [--log-id <id>]`, `token add <tenant> --label <text>` (the token is printed once), `token revoke <tenant> <hash-prefix>`, `tenant disable <id>`, `tenant plan <id> <free|team|enterprise>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. Every tenant is on a plan, `free` unless moved: free allows ten thousand appends a calendar month, team a million, enterprise has no allowance. An append past the allowance is refused with 429, the numbers, and a `retry-after` that reaches the start of next month; the gateway behind it then withholds pre-committed calls, so a tenant out of quota never acts without evidence. The tenant's own `GET /t/<name>/usage` reports the plan and quota beside the month's appends. The tenant portal, `agent-custody portal --db-env DATABASE_URL --secret-env PORTAL_SECRET --public-url <log url> [--checkpoints-url <url>] [--portal-url <url>] [--stripe-key-env NAME --stripe-webhook-env NAME --stripe-price-team <price id>] [--trust-proxy]`, is the self-serve front of the same tables: a team registers with an email, a password (scrypt), and a tenant id and gets the tenant and its first key shown once with the welcome sheet; the dashboard shows appends against the plan, the tree size and root, the latest checkpoint, the log's URLs, keys, and the audit rows; keys are minted and revoked there, recorded as `portal:<email>`; the export command is on the page; and with the three Stripe variables the team plan is bought through Stripe Checkout, the signed webhook moving the plan (`stripe:<event>` in the audit trail) and the customer portal handling cancellation. Sessions are a signed cookie, `SameSite=Strict`, and every write needs a JSON body. The compose file runs it as the `portal` service at `PORTAL_HOST`. The root paths serve the tenant `default`, created on first start with `--log-id`, and `--token-env` still works for it. The key that signs tree heads can live in its own process: `agent-custody signer --key keys/log.key --port 8790 --token-env SIGNER_TOKEN` holds it and answers `POST /sign` with the shared secret and `GET /keys` to anyone; the log server then runs with `--signer-url http://signer:8790/ --signer-token-env SIGNER_TOKEN` instead of `--key`, and the process that faces the internet never holds the key. Either way the log serves its keys at `/.well-known/agent-custody-log.json`, current key first and retired keys (`--retired-key old.pub`) after it, so verifiers fetch and pin them with `verify --log-url` and `audit --log-url` rather than receiving a key file from the operator. With `--checkpoint-dir <dir>` the server publishes a signed checkpoint, every `--checkpoint-every` seconds (default 300), for each log whose tree has grown, and every `--checkpoint-heartbeat` seconds (default 21600, six hours) for a log that has not, so a quiet log's latest checkpoint is never more than six hours old and the monitor can tell quiet from stalled, as `<dir>/<tenant>/<treeSize>.json` and `latest.json`, and with a database also as rows; `GET /checkpoints?since=<size>` and `GET /t/<tenant>/checkpoints` list them. Serve the directory read-only from a second host, so the record of what the log signed does not depend on the log's API being up; a verifier who kept an earlier head audits against a later checkpoint with `audit --older <bundle> --newer <checkpoint> --log-url <url>`. With `--admin-token-env ADMIN_TOKEN` (Postgres only) the server also serves the operator's page at `/admin` and its API under `/admin/`: list and create tenants, mint a token that is shown once beside the tenant's welcome sheet, revoke tokens, disable tenants. Everything under `/admin`, the page included, needs the admin token: the browser asks for it (any user name, the token as the password) and an API client sends it as a bearer; a handful of wrong attempts from one address are throttled for a minute. Every change made there or with `log-admin` is recorded: who (the name entered at the browser prompt and the address, `bearer` for an API client, or the user and host for the command line), what (`tenant.add`, `tenant.disable`, `token.add`, `token.revoke`), which tenant, and the detail, never the token itself; the page shows it under Activity, `GET /admin/audit?tenant=&limit=` and `log-admin audit` list it, and a tenant reads their own rows at `GET /t/<name>/audit` with their token. Nothing else is stored by the page. Behind a reverse proxy, start the server with `--trust-proxy` so those per-address limits key on `X-Forwarded-For` instead of on the proxy's own address, and only there, since the header is otherwise the client's to forge. `--public-url` and `--checkpoints-url` fill the sheet in. The witness closes the last gap: `agent-custody witness --key witness.key --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]...` runs on a machine the log's operator does not control, fetches each watched log's latest checkpoint, verifies it against the log's published keys, proves with the log's consistency proof that it extends the last head the witness signed, and countersigns it into `<dir>/<tenant>/<size>.json` and `latest.json`; a checkpoint that does not extend, or a second history at the same size, gets `ALARM.json` instead. Its key document is `<dir>/.well-known/agent-custody-witness.json`. Serve `<dir>` from the witness's own host; verifiers add `--witness-url` (or `--witness-key`) to `audit`, and the newer head must then carry the witness's signature. Two more things an operator needs. `agent-custody log-check --log-url <url> --checkpoints-url <url> [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>]` is the outside monitor: it verifies the head against the published keys, that the latest checkpoint verifies and keeps up with the head, that the head extends the checkpoint, and, with a witness, that the witness has countersigned, keeps up, and has raised no alarm; it exits 1 on any failure, so cron or a scheduled workflow on a machine that is not the log's turns it into an alert. `GET /health` on the server is the liveness check for a load balancer. And `GET /admin/usage?month=YYYY-MM`, on the admin page and as `/admin/usage.csv`, is the metering: appends per tenant for the month, leaves in total, live tokens, the numbers any invoice rests on. A tenant needs none of that to leave with their evidence: `agent-custody log-export --log-url <url> --tenant <name> --token-env AGENT_CUSTODY_LOG_TOKEN --out <dir>` fetches, with their own token, every leaf hash (`GET /t/<name>/leaves?since=&limit=`, pages of up to ten thousand), the signed head, the published keys, the signed checkpoints, their own usage (`GET /t/<name>/usage?month=`), and the administrative actions on their tenant (`GET /t/<name>/audit`, into `audit.json`), checks that the head and every checkpoint verify against the keys and that the leaves fetched hash to their roots, and writes `log.jsonl` in the format `verify --log` and `audit --log` read, so the export verifies receipts with no server at all. It exits 1 and says what did not add up if anything does not. Both routes answer only to that tenant's token. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers, and [deploy/witness/](../../deploy/witness/) the witness.
|
|
110
|
+
Exactly one of the two. The bearer token comes from the named environment variable, never from the file, and a missing variable fails at startup. Add `"hashOnly": true` for any log run by someone else: the gateway then sends only the leaf hash, sha256 of the receipt envelope with the RFC 6962 prefix, so the log commits to the receipt without ever holding it, and the receipts with their arguments and results stay in `receiptsDir`. The verifier does not change; it hashes the envelope itself. A log that serves several tenants is reached at `<url>/t/<tenant>/`, and each of its tree heads names its log, which a verifier checks with `--log-id`. With a remote log the tree head in each receipt is signed by the log's key, and a verifier must be given that key with `--log-key`. An append that gets no answer within `timeoutMs` (default 10000) counts as unreachable and is retried like a server error, so a log that accepts connections and never answers cannot hold a call forever. If the log refuses a leaf, the receipt is not issued and the call returns an error to the agent. For an ordinary call the upstream action has already happened by then, and the error says so; a receipt that was never logged must not be handed out. For a tool named in `precommit` the order is reversed, below, and the action never happens. A fact lookup is different: it runs before the policy decides, because the policy decides on it, and only once the intercepted tool is in the grant's scope, so a fact tool must be a read. Naming a fact tool in `precommit` is refused at startup; `"*"` covers the tools the agent calls, not the gateway's own lookups. Every lookup is on the receipt with its arguments. The reference log server is `node src/cli.ts log --file log.jsonl --key keys/log.key --port 8787 --token-env AGENT_CUSTODY_LOG_TOKEN [--log-id <id>] [--tenants tenants.json]`. It serves `POST /append` with `{leaf}` or `{leafHash}` (token required when one is configured), `GET /root?size=N`, `GET /consistency?old=M&new=N`, and `GET /head`; [verification.md](verification.md) says what each proves. `--log-id` writes that id into every tree head. `--tenants` names a JSON file, `{ "acme": { "file": "acme.jsonl", "tokenEnv": "ACME_TOKEN", "logId": "acme-eu" } }`, and each tenant is its own log at `/t/acme/…` with its own token and id; the default log stays at the root paths. With `--hash-only` the server refuses `{leaf}` at append and accepts only `{leafHash}`, so a log run for other people never receives a receipt regardless of the client's configuration; the hosted deployment sets it. With `--db-env DATABASE_URL` the server keeps its logs in Postgres instead of files, and needs the `pg` package beside it: leaves as hashes in one table keyed by tenant, one writer per tenant enforced with an advisory lock so a second instance is safe, tenants and their tokens in tables of their own with tokens stored only as hashes, and rate limits per token (50 appends a second, burst 100, a 64 KB body cap; a refused append answers 429 with `retry-after`, and the gateway's sink retries a few times). Tenants are managed with `log-admin --db-env DATABASE_URL`: `tenant add <id> [--log-id <id>]`, `token add <tenant> --label <text>` (the token is printed once), `token revoke <tenant> <hash-prefix>`, `tenant disable <id>`, `tenant plan <id> <free|team|enterprise>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. Every tenant is on a plan, `free` unless moved: free allows ten thousand appends a calendar month, team a million, enterprise has no allowance. An append past the allowance is refused with 429, the numbers, and a `retry-after` that reaches the start of next month; the gateway behind it then withholds pre-committed calls, so a tenant out of quota never acts without evidence. The tenant's own `GET /t/<name>/usage` reports the plan and quota beside the month's appends. The tenant portal, `agent-custody portal --db-env DATABASE_URL --secret-env PORTAL_SECRET --public-url <log url> [--checkpoints-url <url>] [--portal-url <url>] [--stripe-key-env NAME --stripe-webhook-env NAME --stripe-price-team <price id>] [--trust-proxy]`, is the self-serve front of the same tables: a team registers with an email, a password (scrypt), and a tenant id and gets the tenant and its first key shown once with the welcome sheet; the dashboard shows appends against the plan, the tree size and root, the latest checkpoint, the log's URLs, keys, and the audit rows; keys are minted and revoked there, recorded as `portal:<email>`; the export command is on the page; and with the three Stripe variables the team plan is bought through Stripe Checkout, the signed webhook moving the plan (`stripe:<event>` in the audit trail) and the customer portal handling cancellation. Sessions are a signed cookie, `SameSite=Strict`, and every write needs a JSON body. The compose file runs it as the `portal` service at `PORTAL_HOST`. The root paths serve the tenant `default`, created on first start with `--log-id`, and `--token-env` still works for it. The key that signs tree heads can live in its own process: `agent-custody signer --key keys/log.key --port 8790 --token-env SIGNER_TOKEN` holds it and answers `POST /sign` with the shared secret and `GET /keys` to anyone; the log server then runs with `--signer-url http://signer:8790/ --signer-token-env SIGNER_TOKEN` instead of `--key`, and the process that faces the internet never holds the key. Either way the log serves its keys at `/.well-known/agent-custody-log.json`, current key first and retired keys (`--retired-key old.pub`) after it, so verifiers fetch and pin them with `verify --log-url` and `audit --log-url` rather than receiving a key file from the operator. With `--checkpoint-dir <dir>` the server publishes a signed checkpoint, every `--checkpoint-every` seconds (default 300), for each log whose tree has grown, and every `--checkpoint-heartbeat` seconds (default 21600, six hours) for a log that has not, so a quiet log's latest checkpoint is never more than six hours old and the monitor can tell quiet from stalled, as `<dir>/<tenant>/<treeSize>.json` and `latest.json`, and with a database also as rows; `GET /checkpoints?since=<size>` and `GET /t/<tenant>/checkpoints` list them. Serve the directory read-only from a second host, so the record of what the log signed does not depend on the log's API being up; a verifier who kept an earlier head audits against a later checkpoint with `audit --older <bundle> --newer <checkpoint> --log-url <url>`. With `--admin-token-env ADMIN_TOKEN` (Postgres only) the server also serves the operator's page at `/admin` and its API under `/admin/`: list and create tenants, mint a token that is shown once beside the tenant's welcome sheet, revoke tokens, disable tenants. Everything under `/admin`, the page included, needs the admin token: the browser asks for it (any user name, the token as the password) and an API client sends it as a bearer; a handful of wrong attempts from one address are throttled for a minute. Every change made there or with `log-admin` is recorded: who (the name entered at the browser prompt and the address, `bearer` for an API client, or the user and host for the command line), what (`tenant.add`, `tenant.disable`, `token.add`, `token.revoke`), which tenant, and the detail, never the token itself; the page shows it under Activity, `GET /admin/audit?tenant=&limit=` and `log-admin audit` list it, and a tenant reads their own rows at `GET /t/<name>/audit` with their token. Nothing else is stored by the page. Behind a reverse proxy, start the server with `--trust-proxy` so those per-address limits key on `X-Forwarded-For` instead of on the proxy's own address, and only there, since the header is otherwise the client's to forge. `--public-url` and `--checkpoints-url` fill the sheet in. The witness closes the last gap: `agent-custody witness --key witness.key --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]...` runs on a machine the log's operator does not control, fetches each watched log's latest checkpoint, verifies it against the log's published keys, proves with the log's consistency proof that it extends the last head the witness signed, and countersigns it into `<dir>/<tenant>/<size>.json` and `latest.json`; a checkpoint that does not extend, or a second history at the same size, gets `ALARM.json` instead. Its key document is `<dir>/.well-known/agent-custody-witness.json`. Serve `<dir>` from the witness's own host; verifiers add `--witness-url` (or `--witness-key`) to `audit`, and the newer head must then carry the witness's signature. Two more things an operator needs. `agent-custody log-check --log-url <url> --checkpoints-url <url> [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>]` is the outside monitor: it verifies the head against the published keys, that the latest checkpoint verifies and keeps up with the head, that the head extends the checkpoint, and, with a witness, that the witness has countersigned, keeps up, and has raised no alarm; it exits 1 on any failure, so cron or a scheduled workflow on a machine that is not the log's turns it into an alert. `GET /health` on the server is the liveness check for a load balancer. And `GET /admin/usage?month=YYYY-MM`, on the admin page and as `/admin/usage.csv`, is the metering: appends per tenant for the month, leaves in total, live tokens, the numbers any invoice rests on. A tenant needs none of that to leave with their evidence: `agent-custody log-export --log-url <url> --tenant <name> --token-env AGENT_CUSTODY_LOG_TOKEN --out <dir>` fetches, with their own token, every leaf hash (`GET /t/<name>/leaves?since=&limit=`, pages of up to ten thousand), the signed head, the published keys, the signed checkpoints, their own usage (`GET /t/<name>/usage?month=`), and the administrative actions on their tenant (`GET /t/<name>/audit`, into `audit.json`), checks that the head and every checkpoint verify against the keys and that the leaves fetched hash to their roots, and writes `log.jsonl` in the format `verify --log` and `audit --log` read, so the export verifies receipts with no server at all. It exits 1 and says what did not add up if anything does not. Both routes answer only to that tenant's token. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers, and [deploy/witness/](../../deploy/witness/) the witness.
|
|
111
111
|
|
|
112
112
|
`otel`, optional in both the gateway and SDK configs, sends every receipt to the collector you already run as one span over OTLP/HTTP, after the receipt is issued: `"otel": { "url": "http://localhost:4318", "headersEnv": { "x-api-key": "OTEL_KEY" }, "serviceName": "support-agents" }`. The span's trace id is the receipt id, its attributes carry the tool, agent, principal, execution status, policy decision, and log position, and its status is an error only when the upstream failed or errored, since a denial is the policy working. Export is best effort: a collector that is down or refuses costs a line on stderr, never a receipt. Tutorial 18 shows it against a stand-in collector.
|
|
113
113
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-custody/receipts",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Chain of custody for AI agents: signed, independently verifiable receipts for tool calls. MCP gateway + Cedar policy + Merkle transparency log",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
package/vectors/audit.json
CHANGED
|
@@ -7,26 +7,26 @@
|
|
|
7
7
|
"description": "The tree head from the first remote receipt and the log's later head, with the proof the log served.",
|
|
8
8
|
"older": {
|
|
9
9
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
10
|
-
"payload": "
|
|
10
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiODdiZTMxNDY1OTU2MDYyZmFiY2FiMTNhMzdiZWE2NDYwZTgwZDVkODU4YTU1ZDhiMzkzNTA2NGNlY2YwN2NjNiIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ0WiIsInRyZWVTaXplIjoxfQ==",
|
|
11
11
|
"signatures": [
|
|
12
12
|
{
|
|
13
|
-
"keyid": "
|
|
14
|
-
"sig": "
|
|
13
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
14
|
+
"sig": "Jvyh0cZC04MAmBqCsZtcq3Qh2OQP5ly0TIIlxtcqmy+QdLfoNGbxOyksEZ3D1yITFualDWNgOB/V4hk9Ii9KBw=="
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
},
|
|
18
18
|
"newer": {
|
|
19
19
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
20
|
-
"payload": "
|
|
20
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiNWZhYjAwZGM4NzVkY2ExZjAzMGRhZGMwYWE0MjQyN2Q5YWRmYjhhOThlZTA1OWIyYWEyMDljODEzMzg0MDc1ZSIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ5WiIsInRyZWVTaXplIjoyfQ==",
|
|
21
21
|
"signatures": [
|
|
22
22
|
{
|
|
23
|
-
"keyid": "
|
|
24
|
-
"sig": "
|
|
23
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
24
|
+
"sig": "D5gGBJGgZ3vgx/lUTabGLpQZVLQL52nXrHThWOolNJD7THa+oiItUAqVGKRFZl0iZog8zEwMC2yily+xz6yeDg=="
|
|
25
25
|
}
|
|
26
26
|
]
|
|
27
27
|
},
|
|
28
28
|
"proof": [
|
|
29
|
-
"
|
|
29
|
+
"3d549ac46dd00f77add2766bc8eddd5cddd4028c2c158667759a78ac8003f561"
|
|
30
30
|
],
|
|
31
31
|
"keys": [
|
|
32
32
|
"log"
|
|
@@ -41,26 +41,26 @@
|
|
|
41
41
|
"description": "The same heads the wrong way round.",
|
|
42
42
|
"older": {
|
|
43
43
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
44
|
-
"payload": "
|
|
44
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiNWZhYjAwZGM4NzVkY2ExZjAzMGRhZGMwYWE0MjQyN2Q5YWRmYjhhOThlZTA1OWIyYWEyMDljODEzMzg0MDc1ZSIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ5WiIsInRyZWVTaXplIjoyfQ==",
|
|
45
45
|
"signatures": [
|
|
46
46
|
{
|
|
47
|
-
"keyid": "
|
|
48
|
-
"sig": "
|
|
47
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
48
|
+
"sig": "D5gGBJGgZ3vgx/lUTabGLpQZVLQL52nXrHThWOolNJD7THa+oiItUAqVGKRFZl0iZog8zEwMC2yily+xz6yeDg=="
|
|
49
49
|
}
|
|
50
50
|
]
|
|
51
51
|
},
|
|
52
52
|
"newer": {
|
|
53
53
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
54
|
-
"payload": "
|
|
54
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiODdiZTMxNDY1OTU2MDYyZmFiY2FiMTNhMzdiZWE2NDYwZTgwZDVkODU4YTU1ZDhiMzkzNTA2NGNlY2YwN2NjNiIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ0WiIsInRyZWVTaXplIjoxfQ==",
|
|
55
55
|
"signatures": [
|
|
56
56
|
{
|
|
57
|
-
"keyid": "
|
|
58
|
-
"sig": "
|
|
57
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
58
|
+
"sig": "Jvyh0cZC04MAmBqCsZtcq3Qh2OQP5ly0TIIlxtcqmy+QdLfoNGbxOyksEZ3D1yITFualDWNgOB/V4hk9Ii9KBw=="
|
|
59
59
|
}
|
|
60
60
|
]
|
|
61
61
|
},
|
|
62
62
|
"proof": [
|
|
63
|
-
"
|
|
63
|
+
"3d549ac46dd00f77add2766bc8eddd5cddd4028c2c158667759a78ac8003f561"
|
|
64
64
|
],
|
|
65
65
|
"keys": [
|
|
66
66
|
"log"
|
|
@@ -77,26 +77,26 @@
|
|
|
77
77
|
"description": "Tree heads checked against the app key, which did not sign them.",
|
|
78
78
|
"older": {
|
|
79
79
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
80
|
-
"payload": "
|
|
80
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiODdiZTMxNDY1OTU2MDYyZmFiY2FiMTNhMzdiZWE2NDYwZTgwZDVkODU4YTU1ZDhiMzkzNTA2NGNlY2YwN2NjNiIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ0WiIsInRyZWVTaXplIjoxfQ==",
|
|
81
81
|
"signatures": [
|
|
82
82
|
{
|
|
83
|
-
"keyid": "
|
|
84
|
-
"sig": "
|
|
83
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
84
|
+
"sig": "Jvyh0cZC04MAmBqCsZtcq3Qh2OQP5ly0TIIlxtcqmy+QdLfoNGbxOyksEZ3D1yITFualDWNgOB/V4hk9Ii9KBw=="
|
|
85
85
|
}
|
|
86
86
|
]
|
|
87
87
|
},
|
|
88
88
|
"newer": {
|
|
89
89
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
90
|
-
"payload": "
|
|
90
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiNWZhYjAwZGM4NzVkY2ExZjAzMGRhZGMwYWE0MjQyN2Q5YWRmYjhhOThlZTA1OWIyYWEyMDljODEzMzg0MDc1ZSIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ5WiIsInRyZWVTaXplIjoyfQ==",
|
|
91
91
|
"signatures": [
|
|
92
92
|
{
|
|
93
|
-
"keyid": "
|
|
94
|
-
"sig": "
|
|
93
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
94
|
+
"sig": "D5gGBJGgZ3vgx/lUTabGLpQZVLQL52nXrHThWOolNJD7THa+oiItUAqVGKRFZl0iZog8zEwMC2yily+xz6yeDg=="
|
|
95
95
|
}
|
|
96
96
|
]
|
|
97
97
|
},
|
|
98
98
|
"proof": [
|
|
99
|
-
"
|
|
99
|
+
"3d549ac46dd00f77add2766bc8eddd5cddd4028c2c158667759a78ac8003f561"
|
|
100
100
|
],
|
|
101
101
|
"keys": [
|
|
102
102
|
"app"
|
|
@@ -114,21 +114,21 @@
|
|
|
114
114
|
"description": "A proof with a hash removed.",
|
|
115
115
|
"older": {
|
|
116
116
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
117
|
-
"payload": "
|
|
117
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiODdiZTMxNDY1OTU2MDYyZmFiY2FiMTNhMzdiZWE2NDYwZTgwZDVkODU4YTU1ZDhiMzkzNTA2NGNlY2YwN2NjNiIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ0WiIsInRyZWVTaXplIjoxfQ==",
|
|
118
118
|
"signatures": [
|
|
119
119
|
{
|
|
120
|
-
"keyid": "
|
|
121
|
-
"sig": "
|
|
120
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
121
|
+
"sig": "Jvyh0cZC04MAmBqCsZtcq3Qh2OQP5ly0TIIlxtcqmy+QdLfoNGbxOyksEZ3D1yITFualDWNgOB/V4hk9Ii9KBw=="
|
|
122
122
|
}
|
|
123
123
|
]
|
|
124
124
|
},
|
|
125
125
|
"newer": {
|
|
126
126
|
"payloadType": "application/vnd.agent-custody.treehead+json",
|
|
127
|
-
"payload": "
|
|
127
|
+
"payload": "eyJsb2ciOiJ2ZWN0b3JzLWxvZyIsInJvb3RIYXNoIjoiNWZhYjAwZGM4NzVkY2ExZjAzMGRhZGMwYWE0MjQyN2Q5YWRmYjhhOThlZTA1OWIyYWEyMDljODEzMzg0MDc1ZSIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjZUMTc6NTE6MjYuNTQ5WiIsInRyZWVTaXplIjoyfQ==",
|
|
128
128
|
"signatures": [
|
|
129
129
|
{
|
|
130
|
-
"keyid": "
|
|
131
|
-
"sig": "
|
|
130
|
+
"keyid": "27e4e80dbd49e866f4e30d5d7d172c0b4b9d130e1ef74420a2cb8f22e0e549ba",
|
|
131
|
+
"sig": "D5gGBJGgZ3vgx/lUTabGLpQZVLQL52nXrHThWOolNJD7THa+oiItUAqVGKRFZl0iZog8zEwMC2yily+xz6yeDg=="
|
|
132
132
|
}
|
|
133
133
|
]
|
|
134
134
|
},
|
package/vectors/canonical.json
CHANGED
|
@@ -53,18 +53,18 @@
|
|
|
53
53
|
}
|
|
54
54
|
],
|
|
55
55
|
"keyid": {
|
|
56
|
-
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\
|
|
57
|
-
"keyid": "
|
|
56
|
+
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEATuYv4pWsVxijz+9LMxlkISYMW4gIvcRuiH0TUJSZGwM=\n-----END PUBLIC KEY-----\n",
|
|
57
|
+
"keyid": "33759d74bba15d321c7ff09df0d299a81dd642f926ad3d95c494d161b6657eac"
|
|
58
58
|
},
|
|
59
59
|
"dsse": {
|
|
60
|
-
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\
|
|
60
|
+
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEATuYv4pWsVxijz+9LMxlkISYMW4gIvcRuiH0TUJSZGwM=\n-----END PUBLIC KEY-----\n",
|
|
61
61
|
"envelope": {
|
|
62
62
|
"payloadType": "application/vnd.example+json",
|
|
63
63
|
"payload": "eyJoZWxsbyI6IndvcmxkIn0=",
|
|
64
64
|
"signatures": [
|
|
65
65
|
{
|
|
66
|
-
"keyid": "
|
|
67
|
-
"sig": "/
|
|
66
|
+
"keyid": "33759d74bba15d321c7ff09df0d299a81dd642f926ad3d95c494d161b6657eac",
|
|
67
|
+
"sig": "DuIXwl+AYJ1feySFUE4HLcIjQzQl/y6knjDQ9ozHOBhdPsSvrVn7c7CteI1pVmHCvZDbEY0cQQx2Y9buRRzkDg=="
|
|
68
68
|
}
|
|
69
69
|
]
|
|
70
70
|
},
|