@agent-custody/receipts 0.1.0 → 0.1.2

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/dist/log.js CHANGED
@@ -38,6 +38,64 @@ function path(m, leaves, lo, hi) {
38
38
  ? [...path(m, leaves, lo, lo + k), mth(leaves, lo + k, hi)]
39
39
  : [...path(m - k, leaves, lo + k, hi), mth(leaves, lo, lo + k)];
40
40
  }
41
+ /** RFC 9162 section 2.1.4.1: SUBPROOF(m, D[n], b). */
42
+ function subproof(m, leaves, lo, hi, b) {
43
+ const n = hi - lo;
44
+ if (m === n)
45
+ return b ? [] : [mth(leaves, lo, hi)];
46
+ const k = split(n);
47
+ return m <= k
48
+ ? [...subproof(m, leaves, lo, lo + k, b), mth(leaves, lo + k, hi)]
49
+ : [...subproof(m - k, leaves, lo + k, hi, false), mth(leaves, lo, lo + k)];
50
+ }
51
+ /** Proof that the tree of size newSize extends the tree of size oldSize. Empty when oldSize is 0 or equal to newSize. */
52
+ export function consistencyProof(leafHashes, oldSize, newSize = leafHashes.length) {
53
+ if (oldSize < 0 || oldSize > newSize || newSize > leafHashes.length)
54
+ throw new Error("sizes out of range");
55
+ if (oldSize === 0 || oldSize === newSize)
56
+ return [];
57
+ return subproof(oldSize, leafHashes, 0, newSize, true).map((b) => b.toString("hex"));
58
+ }
59
+ /** RFC 9162 section 2.1.4.2. Pure: needs only the two sizes, the two roots, and the proof. */
60
+ export function verifyConsistency(oldSize, oldRootHex, newSize, newRootHex, proofHex) {
61
+ if (oldSize < 0 || oldSize > newSize)
62
+ return false;
63
+ if (oldSize === newSize)
64
+ return proofHex.length === 0 && oldRootHex === newRootHex;
65
+ if (oldSize === 0)
66
+ return proofHex.length === 0;
67
+ if (proofHex.length === 0)
68
+ return false;
69
+ const proof = proofHex.map((x) => Buffer.from(x, "hex"));
70
+ if ((oldSize & (oldSize - 1)) === 0)
71
+ proof.unshift(Buffer.from(oldRootHex, "hex"));
72
+ let fn = oldSize - 1;
73
+ let sn = newSize - 1;
74
+ while (fn % 2 === 1) {
75
+ fn = Math.floor(fn / 2);
76
+ sn = Math.floor(sn / 2);
77
+ }
78
+ let fr = proof[0];
79
+ let sr = proof[0];
80
+ for (const c of proof.slice(1)) {
81
+ if (sn === 0)
82
+ return false;
83
+ if (fn % 2 === 1 || fn === sn) {
84
+ fr = nodeHash(c, fr);
85
+ sr = nodeHash(c, sr);
86
+ while (fn % 2 === 0 && fn !== 0) {
87
+ fn = Math.floor(fn / 2);
88
+ sn = Math.floor(sn / 2);
89
+ }
90
+ }
91
+ else {
92
+ sr = nodeHash(sr, c);
93
+ }
94
+ fn = Math.floor(fn / 2);
95
+ sn = Math.floor(sn / 2);
96
+ }
97
+ return sn === 0 && fr.toString("hex") === oldRootHex && sr.toString("hex") === newRootHex;
98
+ }
41
99
  export function rootOf(leafHashes, size = leafHashes.length) {
42
100
  return mth(leafHashes, 0, size).toString("hex");
43
101
  }
@@ -102,6 +160,10 @@ export class MerkleLog {
102
160
  root(size = this.size) {
103
161
  return rootOf(this.hashes, size);
104
162
  }
163
+ /** Proof that this log at newSize extends its own earlier state at oldSize. */
164
+ consistencyProof(oldSize, newSize = this.size) {
165
+ return consistencyProof(this.hashes, oldSize, newSize);
166
+ }
105
167
  /** Reads a log file and returns the root at the given size, for auditors holding a copy of the log. */
106
168
  static rootFromFile(file, size) {
107
169
  return new MerkleLog(file).root(size);
@@ -22,7 +22,7 @@ export interface HookOutput {
22
22
  * so the host's normal permission flow still applies. This adapter never auto-approves.
23
23
  * PostToolUse / PostToolUseFailure: issue the receipt for the completed call.
24
24
  */
25
- export declare function handleHookEvent(issuer: SdkIssuer, input: HookInput): HookOutput;
25
+ export declare function handleHookEvent(issuer: SdkIssuer, input: HookInput): Promise<HookOutput>;
26
26
  /**
27
27
  * Hooks for the Claude Agent SDK's query({ hooks }) option. Register all three events.
28
28
  * Typed loosely on purpose so this file does not depend on the SDK package.
@@ -11,14 +11,14 @@ function toEvent(input) {
11
11
  * so the host's normal permission flow still applies. This adapter never auto-approves.
12
12
  * PostToolUse / PostToolUseFailure: issue the receipt for the completed call.
13
13
  */
14
- export function handleHookEvent(issuer, input) {
14
+ export async function handleHookEvent(issuer, input) {
15
15
  const ev = toEvent(input);
16
16
  switch (input.hook_event_name) {
17
17
  case "PreToolUse": {
18
18
  const policy = issuer.decide(ev);
19
19
  if (policy && policy.decision === "deny") {
20
20
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
21
- const bundle = issuer.record(ev, { status: "denied", reason }, policy);
21
+ const bundle = await issuer.record(ev, { status: "denied", reason }, policy);
22
22
  return {
23
23
  continue: true,
24
24
  hookSpecificOutput: {
@@ -31,10 +31,10 @@ export function handleHookEvent(issuer, input) {
31
31
  return {};
32
32
  }
33
33
  case "PostToolUse":
34
- issuer.record(ev, { status: "executed", result: input.tool_response ?? null }, issuer.decide(ev));
34
+ await issuer.record(ev, { status: "executed", result: input.tool_response ?? null }, issuer.decide(ev));
35
35
  return {};
36
36
  case "PostToolUseFailure":
37
- issuer.record(ev, { status: "failed", result: input.error ?? input.tool_response ?? null }, issuer.decide(ev));
37
+ await issuer.record(ev, { status: "failed", result: input.error ?? input.tool_response ?? null }, issuer.decide(ev));
38
38
  return {};
39
39
  default:
40
40
  return {};
@@ -1,4 +1,5 @@
1
1
  import type { SdkConfig } from "../config.ts";
2
+ import { type LogSink } from "../log-sink.ts";
2
3
  import { type PolicyDecision } from "../policy.ts";
3
4
  import type { ReceiptBundle } from "../receipt.ts";
4
5
  export declare const SDK_VERSION = "0.1.0";
@@ -24,10 +25,12 @@ export type Outcome = {
24
25
  export interface SdkIssuer {
25
26
  agentId: string;
26
27
  keyid: string;
28
+ /** where the leaves go: the local file or the remote log */
29
+ log: LogSink;
27
30
  /** Evaluates the configured policy for a call. Returns null when no policy is configured. */
28
31
  decide(ev: ToolEvent): PolicyDecision | null;
29
- /** Issues one receipt for a completed, failed, denied, or errored call. */
30
- record(ev: ToolEvent, outcome: Outcome, policy?: PolicyDecision | null): ReceiptBundle;
32
+ /** Issues one receipt for a completed, failed, denied, or errored call. Rejects if the log refuses it. */
33
+ record(ev: ToolEvent, outcome: Outcome, policy?: PolicyDecision | null): Promise<ReceiptBundle>;
31
34
  /** Wraps a tool function: decide, run, record. Throws PolicyDeniedError on deny, after issuing the denial receipt. */
32
35
  wrap<A extends Record<string, unknown>, R>(tool: string, fn: (args: A) => R | Promise<R>, meta?: Omit<ToolEvent, "tool" | "args">): (args: A) => Promise<R>;
33
36
  }
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 { openLog } from "../log-sink.js";
7
8
  import { evaluate } from "../policy.js";
8
9
  export const SDK_VERSION = "0.1.0";
9
10
  export class PolicyDeniedError extends Error {
@@ -20,7 +21,7 @@ export class PolicyDeniedError extends Error {
20
21
  }
21
22
  export function createSdkIssuer(cfg) {
22
23
  const key = loadPrivateKey(cfg.identity.keyFile);
23
- const issuer = createIssuer(key, cfg.receiptsDir, cfg.logFile);
24
+ const issuer = createIssuer(key, cfg.receiptsDir, openLog(cfg, key));
24
25
  const policyText = cfg.policyFile ? readFileSync(cfg.policyFile, "utf8") : null;
25
26
  const decide = (ev) => policyText === null ? null : evaluate(policyText, { agentId: cfg.agentId, tool: ev.tool, context: { args: ev.args, facts: {} } });
26
27
  function record(ev, outcome, policy = null) {
@@ -47,6 +48,7 @@ export function createSdkIssuer(cfg) {
47
48
  return {
48
49
  agentId: cfg.agentId,
49
50
  keyid: issuer.keyid,
51
+ log: issuer.log,
50
52
  decide,
51
53
  record,
52
54
  wrap(tool, fn, meta = {}) {
@@ -55,16 +57,16 @@ export function createSdkIssuer(cfg) {
55
57
  const policy = decide(ev);
56
58
  if (policy && policy.decision === "deny") {
57
59
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
58
- const bundle = record(ev, { status: "denied", reason }, policy);
60
+ const bundle = await record(ev, { status: "denied", reason }, policy);
59
61
  throw new PolicyDeniedError(tool, reason, receiptIdOf(bundle));
60
62
  }
61
63
  try {
62
64
  const result = await fn(args);
63
- record(ev, { status: "executed", result }, policy);
65
+ await record(ev, { status: "executed", result }, policy);
64
66
  return result;
65
67
  }
66
68
  catch (e) {
67
- record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
69
+ await record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
68
70
  throw e;
69
71
  }
70
72
  };
@@ -8,8 +8,8 @@ export declare class ReceiptCallbackHandler extends BaseCallbackHandler {
8
8
  handleToolStart(tool: {
9
9
  name?: string;
10
10
  }, input: string, runId: string, _parentRunId?: string, _tags?: string[], _metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): void;
11
- handleToolEnd(output: unknown, runId: string): void;
12
- handleToolError(err: Error, runId: string): void;
11
+ handleToolEnd(output: unknown, runId: string): Promise<void>;
12
+ handleToolError(err: Error, runId: string): Promise<void>;
13
13
  }
14
14
  /** Convenience: `tool.invoke(args, receiptCallbacks(issuer))`, or spread into any RunnableConfig. */
15
15
  export declare function receiptCallbacks(issuer: SdkIssuer): {
@@ -40,19 +40,19 @@ export class ReceiptCallbackHandler extends BaseCallbackHandler {
40
40
  const name = runName ?? tool.name ?? "unknown";
41
41
  this.pending.set(runId, { tool: name, args: parseArgs(input), session: { id: null, toolUseId: toolCallId ?? null } });
42
42
  }
43
- handleToolEnd(output, runId) {
43
+ async handleToolEnd(output, runId) {
44
44
  const ev = this.pending.get(runId);
45
45
  if (!ev)
46
46
  return;
47
47
  this.pending.delete(runId);
48
- this.issuer.record(ev, { status: "executed", result: unwrapOutput(output) }, null);
48
+ await this.issuer.record(ev, { status: "executed", result: unwrapOutput(output) }, null);
49
49
  }
50
- handleToolError(err, runId) {
50
+ async handleToolError(err, runId) {
51
51
  const ev = this.pending.get(runId);
52
52
  if (!ev)
53
53
  return;
54
54
  this.pending.delete(runId);
55
- this.issuer.record(ev, { status: "error", error: err.message }, null);
55
+ await this.issuer.record(ev, { status: "error", error: err.message }, null);
56
56
  }
57
57
  }
58
58
  /** Convenience: `tool.invoke(args, receiptCallbacks(issuer))`, or spread into any RunnableConfig. */
@@ -33,16 +33,16 @@ export function wrapTools(issuer, tools) {
33
33
  const policy = issuer.decide(ev);
34
34
  if (policy && policy.decision === "deny") {
35
35
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
36
- const bundle = issuer.record(ev, { status: "denied", reason }, policy);
36
+ const bundle = await issuer.record(ev, { status: "denied", reason }, policy);
37
37
  return `Denied by policy: ${reason} (receipt ${receiptIdOf(bundle)})`;
38
38
  }
39
39
  try {
40
40
  const result = await t.invoke(ctx, input, details);
41
- issuer.record(ev, { status: "executed", result: parseResult(result) }, policy);
41
+ await issuer.record(ev, { status: "executed", result: parseResult(result) }, policy);
42
42
  return result;
43
43
  }
44
44
  catch (e) {
45
- issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
45
+ await issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
46
46
  throw e;
47
47
  }
48
48
  };
@@ -61,6 +61,7 @@ export function observeRunner(issuer, runner) {
61
61
  const callId = details?.toolCall?.callId ?? "";
62
62
  const ev = pending.get(callId) ?? { tool: tool.name, args: {}, session: { id: null, toolUseId: callId || null } };
63
63
  pending.delete(callId);
64
- issuer.record(ev, { status: "executed", result: parseResult(result) }, null);
64
+ // The runner does not await listeners. A log that refuses the leaf is reported on stderr; nothing else can see it here.
65
+ issuer.record(ev, { status: "executed", result: parseResult(result) }, null).catch((e) => console.error(`agent-custody: receipt not issued: ${e instanceof Error ? e.message : e}`));
65
66
  });
66
67
  }
@@ -16,16 +16,16 @@ export function wrapTools(issuer, tools) {
16
16
  const policy = issuer.decide(ev);
17
17
  if (policy && policy.decision === "deny") {
18
18
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
19
- const bundle = issuer.record(ev, { status: "denied", reason }, policy);
19
+ const bundle = await issuer.record(ev, { status: "denied", reason }, policy);
20
20
  throw new PolicyDeniedError(name, reason, receiptIdOf(bundle));
21
21
  }
22
22
  try {
23
23
  const result = await original(input, options);
24
- issuer.record(ev, { status: "executed", result }, policy);
24
+ await issuer.record(ev, { status: "executed", result }, policy);
25
25
  return result;
26
26
  }
27
27
  catch (e) {
28
- issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
28
+ await issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
29
29
  throw e;
30
30
  }
31
31
  };
@@ -0,0 +1,12 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import type { SdkIssuer } from "./sdk/index.ts";
3
+ export declare function sidecarHandler(issuer: SdkIssuer): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
4
+ export interface RunningSidecar {
5
+ url: string;
6
+ close(): Promise<void>;
7
+ }
8
+ /** Starts the sidecar. Port 0 picks a free port. Host defaults to loopback on purpose. */
9
+ export declare function serveSidecar(issuer: SdkIssuer, opts: {
10
+ port: number;
11
+ host?: string;
12
+ }): Promise<RunningSidecar>;
@@ -0,0 +1,93 @@
1
+ // The sidecar: the SDK issuer behind a local HTTP API, so agents written in any language can decide and record.
2
+ // Same config file, same receipts, same key. Everything it records is claimed, exactly as with the in-process SDK:
3
+ // the sidecar trusts what the agent's process tells it. Bind it to localhost; it is a per-host companion, not a service.
4
+ // GET /health -> { agentId, keyid, log: { kind, where } }
5
+ // POST /decide ToolEvent -> PolicyDecision | null
6
+ // POST /record { event, outcome, policy? } -> ReceiptBundle, or 4xx/5xx with { error }
7
+ import { createServer } from "node:http";
8
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
9
+ function parseEvent(v) {
10
+ if (!isRecord(v) || typeof v.tool !== "string" || v.tool.length === 0)
11
+ throw new Error("event needs a non-empty string tool");
12
+ const args = isRecord(v.args) ? v.args : v.args === undefined ? {} : { input: v.args };
13
+ const ev = { tool: v.tool, args };
14
+ if (typeof v.model === "string")
15
+ ev.model = v.model;
16
+ if (isRecord(v.session))
17
+ ev.session = { id: typeof v.session.id === "string" ? v.session.id : null, toolUseId: typeof v.session.toolUseId === "string" ? v.session.toolUseId : null };
18
+ return ev;
19
+ }
20
+ function parseOutcome(v) {
21
+ if (!isRecord(v) || typeof v.status !== "string")
22
+ throw new Error("outcome needs a status");
23
+ switch (v.status) {
24
+ case "executed":
25
+ case "failed":
26
+ return { status: v.status, result: v.result ?? null };
27
+ case "denied":
28
+ return { status: "denied", reason: typeof v.reason === "string" ? v.reason : "denied" };
29
+ case "error":
30
+ return { status: "error", error: typeof v.error === "string" ? v.error : "error" };
31
+ default:
32
+ throw new Error(`unknown outcome status ${v.status}`);
33
+ }
34
+ }
35
+ function parsePolicy(v) {
36
+ if (v === undefined || v === null)
37
+ return null;
38
+ if (!isRecord(v) || (v.decision !== "allow" && v.decision !== "deny") || !Array.isArray(v.reasons) || !Array.isArray(v.errors) || typeof v.policyDigest !== "string")
39
+ throw new Error("policy must be a PolicyDecision from /decide");
40
+ return { decision: v.decision, reasons: v.reasons.map(String), errors: v.errors.map(String), policyDigest: v.policyDigest };
41
+ }
42
+ export function sidecarHandler(issuer) {
43
+ return async (req, res) => {
44
+ const json = (status, body) => {
45
+ res.writeHead(status, { "content-type": "application/json" });
46
+ res.end(JSON.stringify(body));
47
+ };
48
+ const url = new URL(req.url ?? "/", "http://localhost");
49
+ try {
50
+ if (req.method === "GET" && url.pathname === "/health")
51
+ return json(200, { agentId: issuer.agentId, keyid: issuer.keyid, log: { kind: issuer.log.kind, where: issuer.log.where } });
52
+ if (req.method !== "POST")
53
+ return json(404, { error: "not found" });
54
+ let raw = "";
55
+ for await (const chunk of req)
56
+ raw += chunk;
57
+ let body;
58
+ try {
59
+ body = JSON.parse(raw);
60
+ }
61
+ catch {
62
+ return json(400, { error: "body must be JSON" });
63
+ }
64
+ if (url.pathname === "/decide")
65
+ return json(200, issuer.decide(parseEvent(body)));
66
+ if (url.pathname === "/record") {
67
+ if (!isRecord(body))
68
+ return json(400, { error: "body must be {event, outcome, policy?}" });
69
+ const bundle = await issuer.record(parseEvent(body.event), parseOutcome(body.outcome), parsePolicy(body.policy));
70
+ return json(200, bundle);
71
+ }
72
+ return json(404, { error: "not found" });
73
+ }
74
+ catch (e) {
75
+ const msg = e instanceof Error ? e.message : String(e);
76
+ return json(/needs|must|unknown outcome/.test(msg) ? 400 : 502, { error: msg });
77
+ }
78
+ };
79
+ }
80
+ /** Starts the sidecar. Port 0 picks a free port. Host defaults to loopback on purpose. */
81
+ export function serveSidecar(issuer, opts) {
82
+ const host = opts.host ?? "127.0.0.1";
83
+ const handler = sidecarHandler(issuer);
84
+ const server = createServer((req, res) => {
85
+ void handler(req, res);
86
+ });
87
+ return new Promise((resolve) => {
88
+ server.listen(opts.port, host, () => {
89
+ const { port } = server.address();
90
+ resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => server.close(() => r())) });
91
+ });
92
+ });
93
+ }
package/dist/verify.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type PublicKeyRef } from "./crypto.ts";
2
- import { type ReceiptBundle, type ReceiptStatement } from "./receipt.ts";
1
+ import { type Envelope, type PublicKeyRef } from "./crypto.ts";
2
+ import { type ReceiptBundle, type ReceiptStatement, type TreeHead } from "./receipt.ts";
3
3
  export interface Check {
4
4
  name: string;
5
5
  ok: boolean;
@@ -9,6 +9,8 @@ export interface VerifyOptions {
9
9
  /** keys trusted to have issued receipts: gateway keys, SDK application keys */
10
10
  issuerKeys: PublicKeyRef[];
11
11
  principalKeys: PublicKeyRef[];
12
+ /** keys of logs run by someone other than the issuer; tree heads are checked against these and the issuer keys */
13
+ logKeys?: PublicKeyRef[];
12
14
  /** If given, the root is recomputed from this log file at the receipt's tree size and compared. */
13
15
  logFile?: string;
14
16
  }
@@ -18,5 +20,16 @@ export interface VerifyResult {
18
20
  statement: ReceiptStatement | null;
19
21
  }
20
22
  export declare function verifyBundle(bundle: ReceiptBundle, opts: VerifyOptions): VerifyResult;
23
+ export interface AuditResult {
24
+ ok: boolean;
25
+ checks: Check[];
26
+ older: TreeHead | null;
27
+ newer: TreeHead | null;
28
+ }
29
+ /**
30
+ * Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
31
+ * must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
32
+ */
33
+ export declare function auditExtends(older: Envelope, newer: Envelope, proof: string[], keys: PublicKeyRef[]): AuditResult;
21
34
  /** Human-readable report: checks, then every field with its provenance so the reader knows what was proven vs. claimed. */
22
35
  export declare function formatReport(r: VerifyResult): string;
package/dist/verify.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Independent verification of a receipt bundle. Needs only public keys, and optionally a copy of the log.
2
2
  import { canonicalize, digestOf, dsseVerify } from "./crypto.js";
3
3
  import { delegationValidAt, verifyDelegation } from "./delegation.js";
4
- import { leafHash, MerkleLog, verifyInclusion } from "./log.js";
4
+ import { leafHash, MerkleLog, verifyConsistency, verifyInclusion } from "./log.js";
5
5
  import { RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
6
6
  const short = (s) => s.slice(0, 12);
7
7
  export function verifyBundle(bundle, opts) {
@@ -49,8 +49,10 @@ export function verifyBundle(bundle, opts) {
49
49
  add("policy decision consistent with execution", consistent, `${p.policy.decision} -> ${p.execution.status}`);
50
50
  add("no policy errors on an allow", !(p.policy.decision === "allow" && p.policy.errors.length > 0));
51
51
  }
52
- const th = dsseVerify(bundle.treeHead, opts.issuerKeys);
53
- add("tree head signature", th.ok && bundle.treeHead.payloadType === TREEHEAD_TYPE, th.ok ? undefined : th.error);
52
+ const logKeys = opts.logKeys ?? [];
53
+ const th = dsseVerify(bundle.treeHead, [...logKeys, ...opts.issuerKeys]);
54
+ const byLog = th.ok && logKeys.some((k) => k.keyid === th.keyid);
55
+ add("tree head signature", th.ok && bundle.treeHead.payloadType === TREEHEAD_TYPE, th.ok ? `${byLog ? "log key" : "issuer key"} ${short(th.keyid)}` : th.error);
54
56
  if (th.ok) {
55
57
  const head = th.payload;
56
58
  add("tree head matches inclusion proof size", head.treeSize === bundle.inclusion.treeSize);
@@ -70,6 +72,31 @@ export function verifyBundle(bundle, opts) {
70
72
  }
71
73
  return done(st);
72
74
  }
75
+ /**
76
+ * Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
77
+ * must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
78
+ */
79
+ export function auditExtends(older, newer, proof, keys) {
80
+ const checks = [];
81
+ const add = (name, ok, detail) => {
82
+ checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
83
+ return ok;
84
+ };
85
+ const decode = (label, env) => {
86
+ const v = dsseVerify(env, keys);
87
+ add(`${label} tree head signature`, v.ok && env.payloadType === TREEHEAD_TYPE, v.ok ? `keyid ${short(v.keyid)}` : v.error);
88
+ return v.ok ? v.payload : null;
89
+ };
90
+ const a = decode("older", older);
91
+ const b = decode("newer", newer);
92
+ if (!a || !b)
93
+ return { ok: false, checks, older: a, newer: b };
94
+ if (!add("older is not larger than newer", a.treeSize <= b.treeSize, `${a.treeSize} -> ${b.treeSize}`))
95
+ return { ok: false, checks, older: a, newer: b };
96
+ const consistent = verifyConsistency(a.treeSize, a.rootHash, b.treeSize, b.rootHash, proof);
97
+ add("newer log extends older log", consistent, consistent ? `${proof.length} proof hashes` : "history was rewritten, or the proof is for other tree heads");
98
+ return { ok: checks.every((c) => c.ok), checks, older: a, newer: b };
99
+ }
73
100
  const ISSUER_NOTE = {
74
101
  gateway: "enforced outside the agent's process; the agent could neither skip nor forge this receipt",
75
102
  sdk: "self-reported by the agent's own process; tamper-evident after issue, but nothing here was enforced outside the agent",
package/docs/sdk.md CHANGED
@@ -28,7 +28,7 @@ Use the SDK for reach. Use the gateway for anything that moves money, touches pr
28
28
  }
29
29
  ```
30
30
 
31
- `policyFile` and `principalId` are optional. Without a policy the SDK records and never denies. Paths resolve relative to the config file. Generate the key with `node src/cli.ts keygen --dir keys --name app`.
31
+ `policyFile` and `principalId` are optional. Without a policy the SDK records and never denies. Paths resolve relative to the config file. Instead of `logFile`, `"log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }` sends every leaf to a log run by someone else, whose key then signs the tree heads; see [usage.md](usage.md) for what that changes and [verification.md](verification.md) for what it proves. Generate the key with `node src/cli.ts keygen --dir keys --name app`.
32
32
 
33
33
  Policies see `context.args` and an empty `context.facts`. A policy that reads `context.facts` or `context.grant` errors, which is a deny. That is intended: an SDK policy cannot pretend it checked something outside the agent's process.
34
34
 
@@ -154,9 +154,11 @@ For finer control use the two primitives `wrap` is built from:
154
154
 
155
155
  ```ts
156
156
  const decision = issuer.decide({ tool, args }); // PolicyDecision | null
157
- const bundle = issuer.record({ tool, args, model, session }, { status: "executed", result }, decision);
157
+ const bundle = await issuer.record({ tool, args, model, session }, { status: "executed", result }, decision);
158
158
  ```
159
159
 
160
+ `record` returns a promise because the log may be remote. It rejects, and writes no bundle, if the log refuses the leaf. `handleHookEvent` is asynchronous for the same reason. The record-only adapters that cannot await, such as `observeRunner`, report a refused leaf on stderr.
161
+
160
162
  ## Which adapter enforces
161
163
 
162
164
  | framework | enforce + record | record only |
@@ -175,3 +177,26 @@ The three framework packages are optional peer dependencies. Each adapter import
175
177
  ## What an SDK receipt is worth
176
178
 
177
179
  A verified SDK receipt establishes that a process holding the application key reported this call, at this time, with these arguments and this result, and that the record has not changed since. It does not establish that the process reported every call, that the arguments are what the tool really received, or that anyone outside the process checked anything. The verifier prints exactly that sentence under `ISSUER`. Keep it in the dashboard too.
180
+
181
+ ## Other languages: the sidecar
182
+
183
+ The interceptor above is TypeScript. Agents in any other language get the same receipts through the sidecar: the SDK issuer behind a local HTTP API, started from the same config file.
184
+
185
+ ```bash
186
+ agent-custody serve --config sdk.json # 127.0.0.1:8788 by default; --port and --host to change
187
+ ```
188
+
189
+ | | |
190
+ | --- | --- |
191
+ | `GET /health` | `{ agentId, keyid, log: { kind, where } }` |
192
+ | `POST /decide` with a `ToolEvent` `{ tool, args, model?, session? }` | the `PolicyDecision`, or `null` when no policy is configured |
193
+ | `POST /record` with `{ event, outcome, policy? }` | the `ReceiptBundle`; `outcome` is `{ status: "executed" \| "failed", result }`, `{ status: "denied", reason }`, or `{ status: "error", error }` |
194
+
195
+ The client's loop is decide, run the tool, record. A malformed body gets a 400 and nothing is written; a log that refuses the leaf gets a 502 and nothing is written. Bind the sidecar to loopback: it is a per-host companion holding the signing key, not a shared service, and everything it records is `claimed` exactly as with the in-process SDK, because it trusts what the client reports.
196
+
197
+ **Python** has a real package, [packages/python](../../python/README.md): `pip install agent-custody`, a standard-library client with `decide`, `record`, and `wrap`, and adapters for LangChain callbacks, OpenAI Agents function tools, and Claude Agent SDK hooks, each tested against the real package with receipts checked by this verifier.
198
+
199
+ **Go, Java, Rust, and Python without the package** each have a complete client in [examples/languages](../examples/languages): one file, standard library where the language has an HTTP client, decide then record. The test suite runs every one of them against a live sidecar. Any language with an HTTP client is the same forty lines.
200
+
201
+ The gateway needs none of this. It is an MCP server, so a Python or Go agent host that speaks MCP puts it in front of its tools with a config change; [usage.md](usage.md) shows the Python hosts.
202
+
package/docs/tutorials.md CHANGED
@@ -22,6 +22,9 @@ Suggested reading order is the numbering. Output lands in `examples-out/`, which
22
22
  | 10 | Vercel AI SDK | [10-vercel-ai.ts](../examples/10-vercel-ai.ts) | a real generateText loop over the SDK's mock model, a denial as a tool-error part | `src/sdk/vercel-ai.ts` |
23
23
  | 11 | LangChain | [11-langchain.ts](../examples/11-langchain.ts) | the callback handler, tool_call ids, enforcement by wrapping the function | `src/sdk/langchain.ts` |
24
24
  | 12 | inside a receipt | [12-read-a-receipt.ts](../examples/12-read-a-receipt.ts) | the bundle's three parts, the in-toto statement, every predicate field with its provenance, the tree head | `src/receipt.ts` |
25
+ | 13 | a log run by someone else | [13-remote-log.ts](../examples/13-remote-log.ts) | the reference log server on a free port, an SDK config that logs to it, a tree head signed by the log's key, verification failing without that key and passing with it, the root endpoint, a refused token | `src/log-sink.ts` |
26
+ | 14 | proving history was not rewritten | [14-audit-history.ts](../examples/14-audit-history.ts) | three receipts and a kept tree head, a consistency proof that passes, the operator rewriting one leaf and appending a fourth call, the audit failing while the fourth receipt still verifies alone | `src/log.ts`, `src/verify.ts` |
27
+ | 15 | agents in other languages | [15-sidecar.ts](../examples/15-sidecar.ts) | the sidecar on a free port, a client written as a Python or Go program would write it: decide, run, record; a denial recorded without running the tool; both receipts verified | `src/sidecar.ts` |
25
28
 
26
29
  ## How policies are defined, in one paragraph
27
30
 
package/docs/usage.md CHANGED
@@ -79,6 +79,14 @@ when {
79
79
 
80
80
  `upstream` is spawned by the gateway exactly as an MCP host would spawn it. `env` is passed through, which is where upstream credentials go. The agent never sees them.
81
81
 
82
+ `logFile` is the local Merkle log, with tree heads signed by the gateway's own key. To log to a server the operator does not control, replace it with `log`:
83
+
84
+ ```json
85
+ "log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }
86
+ ```
87
+
88
+ 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. 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`. If the log refuses a leaf, the receipt is not issued and the call returns an error to the agent; for an executed call the upstream action has already happened by then, which is the honest outcome, since a receipt that was never logged must not be handed out. 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`. It serves `POST /append` (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.
89
+
82
90
  `facts` tells the gateway which upstream tool to call before evaluating policy for a given tool. `$args.<key>` copies a value from the intercepted call. The result appears in Cedar as `context.facts.<name>` and in the receipt with its own digest, labelled `observed`. If a fact lookup fails, the call is denied and the receipt says why.
83
91
 
84
92
  **5. Run the gateway.** It speaks MCP on stdin/stdout and logs to stderr only.
@@ -116,6 +124,31 @@ Claude sees only the tools inside the grant's scopes. Every call it makes produc
116
124
  claude mcp add stripe -- node /abs/path/agent-custody/packages/receipts/src/cli.ts gateway --config /abs/path/gateway.json
117
125
  ```
118
126
 
127
+ ### Python hosts
128
+
129
+ The gateway is language-neutral: any host that can launch a stdio MCP server can use it. Claude Agent SDK for Python:
130
+
131
+ ```python
132
+ from claude_agent_sdk import ClaudeAgentOptions, query
133
+
134
+ options = ClaudeAgentOptions(mcp_servers={"stripe": {"command": "node", "args": ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"]}})
135
+ async for message in query(prompt="Refund customer cust_123 by 50 dollars", options=options):
136
+ ...
137
+ ```
138
+
139
+ OpenAI Agents SDK for Python:
140
+
141
+ ```python
142
+ from agents import Agent, Runner
143
+ from agents.mcp import MCPServerStdio
144
+
145
+ async with MCPServerStdio(params={"command": "node", "args": ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"]}) as stripe:
146
+ agent = Agent(name="support", instructions="...", mcp_servers=[stripe])
147
+ result = await Runner.run(agent, "Refund customer cust_123 by 50 dollars")
148
+ ```
149
+
150
+ With the npm package installed globally, `"command": "agent-custody", "args": ["gateway", "--config", ...]` replaces the node invocation. Every tool the agent sees comes through the gateway; denied calls never reach Stripe and still produce a receipt. For receipts from tools that are plain Python functions rather than MCP servers, use the sidecar and the Python package, in [sdk.md](sdk.md).
151
+
119
152
  ### Your own agent loop (TypeScript)
120
153
 
121
154
  This is what [scripts/demo.ts](../scripts/demo.ts) does.
@@ -6,7 +6,9 @@ A verifier needs three things and no network access:
6
6
  2. the issuer's public key: the gateway key, or the application key for SDK receipts
7
7
  3. the principal's public key, for gateway receipts, which carry a signed delegation
8
8
 
9
- A fourth is optional: a copy of the issuer's log file, which lets the verifier confirm the receipt sits in a log whose root the verifier recomputed, not one the issuer merely asserted.
9
+ A fourth is optional: a copy of the log file, which lets the verifier confirm the receipt sits in a log whose root the verifier recomputed, not one the issuer merely asserted.
10
+
11
+ When the issuer logs to a remote log, the tree head is signed by the log's key rather than the issuer's. The verifier then needs that public key too, and the report says which key signed the tree head. That is the point of a remote log: a tree head signed by a party that is not the operator says the receipt was in a log the operator could not rewrite. A tree head signed by the issuer's own key says only that the issuer has not changed its story since.
10
12
 
11
13
  ## From the command line
12
14
 
@@ -14,6 +16,7 @@ A fourth is optional: a copy of the issuer's log file, which lets the verifier c
14
16
  node src/cli.ts verify receipts/<id>.json \
15
17
  --issuer-key keys/gateway.pub \
16
18
  --principal-key keys/principal.pub \
19
+ --log-key keys/log.pub \ # only for receipts logged to a remote log
17
20
  --log log.jsonl # optional
18
21
  ```
19
22
 
@@ -106,6 +109,7 @@ const bundle = JSON.parse(readFileSync("receipts/<id>.json", "utf8"));
106
109
  const result = verifyBundle(bundle, {
107
110
  issuerKeys: [loadPublicKey("keys/gateway.pub")], // gateway keys and SDK application keys
108
111
  principalKeys: [loadPublicKey("keys/principal.pub")],
112
+ logKeys: [loadPublicKey("keys/log.pub")], // only for receipts logged to a remote log
109
113
  logFile: "log.jsonl", // optional
110
114
  });
111
115
 
@@ -128,7 +132,7 @@ The formats are standard on purpose, so a verifier in another language needs no
128
132
 
129
133
  ## Auditing a log copy
130
134
 
131
- Take copies of `log.jsonl` on a schedule and keep them where the operator cannot write. Then for any receipt:
135
+ Take copies of `log.jsonl` on a schedule and keep them where the operator cannot write. A remote log serves `GET /root?size=N` so an auditor can compare a tree head with the log's own root without a copy; example 13 does this. Then for any receipt:
132
136
 
133
137
  ```bash
134
138
  node src/cli.ts verify receipts/<id>.json --issuer-key ... --principal-key ... --log /audit/copies/log-2026-09-04.jsonl
@@ -137,3 +141,19 @@ node src/cli.ts verify receipts/<id>.json --issuer-key ... --principal-key ... -
137
141
  The last check recomputes the root at the receipt's tree size from your copy. If the operator later deletes, reorders, or edits a line before that position, the recomputed root changes and the check fails.
138
142
 
139
143
  Today a copy must be at least as long as the receipt's tree size. Consistency proofs between two tree heads, which would let you check that a newer log extends an older copy without holding the whole file, are on the roadmap.
144
+
145
+ ## Proving history was not rewritten
146
+
147
+ An inclusion proof says a receipt was in the log at one moment. It does not say the log still contains, unchanged, everything it contained earlier. That is what a consistency proof is for: given two tree heads, it proves the larger tree extends the smaller one, so nothing before the older head was rewritten. Keep the tree head from any receipt; it is the older head in every later audit.
148
+
149
+ ```bash
150
+ node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log log.jsonl --issuer-key keys/gateway.pub
151
+ node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url https://log.example.com/ --log-key keys/log.pub
152
+ ```
153
+
154
+ Both tree heads must be signed by a trusted key. With `--log` the proof is computed from a copy of the log; with `--log-url` it is fetched from the log's `GET /consistency?old=M&new=N`. Exit code 0 means the newer log extends the older one. A failure means either history was rewritten between the two heads or the proof belongs to other tree heads; example 14 shows a rewritten log failing this way while every individual receipt still verifies.
155
+
156
+ Programmatically, `auditExtends(older.treeHead, newer.treeHead, proof, keys)` returns the same checks. The proof algorithm is RFC 9162 section 2.1.4, so any log implementing it can answer, and any verifier implementing it can check.
157
+
158
+ The remote log also serves `GET /head`, its current tree head signed with the log's key, so an auditor can record heads on a schedule and later audit any two of them without holding a receipt for each.
159
+