@agent-custody/receipts 0.5.4 → 0.5.6

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 CHANGED
@@ -14,6 +14,7 @@ Anyone holding the public keys can verify a receipt offline. The agent is not tr
14
14
  - [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
15
  - [Writing policies](docs/policies.md): how a tool call becomes a Cedar request, with tested examples
16
16
  - [Verifying a receipt](docs/verification.md): what each check means and what a verified receipt does and does not prove
17
+ - [What the evidence satisfies](docs/compliance.md): the receipts, packs, and certificates mapped to SOC 2, ISO 27001, the EU AI Act, and UK GDPR, with what none of them claims
17
18
 
18
19
  ## Getting started
19
20
 
@@ -58,7 +59,7 @@ flowchart LR
58
59
  R[("receipt bundles<br/>receipts/*.json")]
59
60
  L[("Merkle log<br/>local file, or a remote log<br/>run by someone else")]
60
61
  V["Verifier<br/>auditor, counterparty, CI job"]
61
- O["Observability<br/>OTel, LangSmith, Arize"]
62
+ O["Observability<br/>OTel, Splunk, LangSmith, Arize"]
62
63
 
63
64
  P -- "signed delegation grant" --> G
64
65
  A -- "MCP tools/call" --> G
@@ -105,7 +106,7 @@ Every receipt names its issuer, and the verifier prints what that issuer kind is
105
106
  | Vercel AI SDK | SDK | `wrapTools` | | a real `generateText` loop over the SDK's mock model |
106
107
  | LangChain / LangGraph (JS) | SDK | `tool(issuer.wrap(fn))` | `ReceiptCallbackHandler` | real `StructuredTool` invocations |
107
108
  | anything else | SDK | `issuer.wrap(name, fn)` | `issuer.record` | plain functions |
108
- | Python: LangChain, OpenAI Agents SDK, Claude Agent SDK | sidecar + [Python package](../python/README.md) | `wrap_tools`, `claude_hook` PreToolUse deny, `client.wrap` | `ReceiptCallbackHandler` | the real Python packages, receipts checked by this verifier |
109
+ | Python: LangChain, OpenAI Agents SDK, CrewAI, Claude Agent SDK | sidecar + [Python package](../python/README.md) | `wrap_tools` (OpenAI Agents, CrewAI), `claude_hook` PreToolUse deny, `client.wrap` | `ReceiptCallbackHandler` | the real Python packages, receipts checked by this verifier |
109
110
  | Go, Java, Rust, any language with HTTP | sidecar | decide then record | record | [examples/languages](examples/languages), each run against a live sidecar |
110
111
  | any MCP host in any language: Claude Agent SDK Python, OpenAI Agents Python | gateway | yes | | the gateway is an MCP server; [usage.md](docs/usage.md#python-hosts) |
111
112
  | any REST API, as tools the agent reaches through the gateway | gateway, `rest` upstream | yes | | a stand-in HTTP API; [usage.md](docs/usage.md#setup-step-by-step) |
@@ -225,6 +226,7 @@ If a vendor tells you their receipts prove more than the first five rows, ask th
225
226
  src/config.ts gateway and SDK config schemas, path resolution
226
227
  src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
227
228
  src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
229
+ src/log-check.ts the outside monitor: verifies the head, checkpoints, and witness of a running log
228
230
  src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
229
231
  src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
230
232
  src/checkpoints.ts signed heads published on a schedule, to files and to Postgres
@@ -240,6 +242,7 @@ src/sdk/claude.ts Claude Code command hook and Claude Agent SDK in-process hook
240
242
  src/sdk/openai-agents.ts, vercel-ai.ts, langchain.ts framework adapters, tested against the real packages
241
243
  src/sidecar.ts the SDK issuer behind a local HTTP API, for agents in other languages
242
244
  src/otel.ts OpenTelemetry export: one OTLP/HTTP span per receipt, after the receipt, no SDK dependency
245
+ src/splunk.ts Splunk export: one HTTP Event Collector event per receipt, token from the environment, beside or instead of otel
243
246
  src/rest.ts the REST connector: an HTTP API described as tools, standing where an MCP upstream stands
244
247
  src/upstream.ts attested execution: an upstream signs its result for the receipt; the verifier checks it with the upstream key
245
248
  vectors/ conformance vectors: receipts, keys, logs, proofs, and expected verdicts; `bun run vectors` regenerates them
@@ -267,7 +270,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
267
270
  - Claude Code command hook for PreToolUse, PostToolUse, and PostToolUseFailure, with blocking on deny.
268
271
  - Claude Agent SDK in-process hooks over the same handler.
269
272
  - Provider-native deliveries: an upstream wrapping Stripe or GitHub attaches the signed webhook or delivery for the call; a verifier with the shared secret checks the HMAC, the timestamp, and the binding to the result, and reports the execution as attested by shared secret.
270
- - Logarithmic appends: the Merkle log caches complete subtrees, so issuing a receipt costs the same at the millionth leaf as at the first; measured at 0.15 ms per receipt and about half a millisecond per gateway call including policy, a fact lookup, and the upstream signature.
273
+ - Logarithmic appends: the Merkle log caches complete subtrees, so issuing a receipt costs the same at the millionth leaf as at the first; measured at 0.15 ms per receipt and about half a millisecond per gateway call including policy, a fact lookup, and the upstream signature. A remote log adds one network round trip plus about five milliseconds of server work per call, two for a pre-committed call; the [deployment guide](https://agent-custody.dev/guide/deployment) has the measurements against the live log.
271
274
  - Retention on the log: `prune` replaces leaves older than a cutoff with their hashes and removes their bundles, so proofs still verify and the content is gone.
272
275
  - Several upstreams under one gateway and one grant, each tool owned by exactly one, with the receipt naming which served the call; consumed facts flow across them.
273
276
  - Attested execution: an upstream that holds a key signs its result for the receipt, the gateway embeds it, and a verifier given the upstream key reports the execution as attested rather than observed. The memory server and the demo upstream sign.
@@ -281,11 +284,13 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
281
284
  - Remote log: the issuer can append to a log run by someone else over HTTP, whose key then signs the tree heads, so a verifier learns the receipt was in a log the operator could not rewrite. Includes the reference log server, bearer-token auth, and a root endpoint for auditors.
282
285
  - Framework adapters, each tested against the real package with a scripted model and no network: OpenAI Agents SDK (`wrapTools` enforces, `observeRunner` records from lifecycle events), Vercel AI SDK (`wrapTools` over a real `generateText` loop), LangChain (`ReceiptCallbackHandler` records, `issuer.wrap` enforces).
283
286
 
287
+ - Monitoring and metering: `log-check`, the outside probe that verifies the head, the checkpoints, and the witness and exits 1 on trouble, run every ten minutes by the `monitor` workflow; `GET /health`; and usage per tenant per month on the admin page and as CSV.
284
288
  - The witness: a second signer on a machine the log's operator does not control countersigns each checkpoint after proving it extends the last one it signed, refuses a rewritten or forked history with an alarm, and publishes its key; `audit --witness-url` requires it. Phase 6 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
285
289
  - The signer, keys, and checkpoints: the key in its own process (`signer`, `--signer-url`), the key document at `/.well-known/agent-custody-log.json` fetched and pinned by `verify --log-url` and `audit --log-url`, and signed checkpoints per log published to a directory and a table for a verifier who was not watching. Phase 3 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
286
290
  - The log over Postgres: `log --db-env`, leaves as hashes in one table keyed by tenant, one writer per tenant by advisory lock, tenants and hashed tokens in tables managed by `log-admin`, rate limits and a body cap, retries in the sink, and `import` for an existing file log. Phase 2 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
287
291
  - A log for someone else: `hashOnly` sends leaf hashes so the log never holds a receipt; the reference server runs several tenant logs at `/t/<tenant>/` with their own tokens and ids; tree heads name their log and the verifier checks it with `--log-id`. Phase 1 of the hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
288
292
  - OpenTelemetry export: with `otel` in either config, every receipt is also one span at the collector the team already runs, trace id equal to the receipt id, attributes for tool, agent, principal, status, decision, and log position; after the receipt, best effort, never on the evidence path.
293
+ - Splunk export: with `splunk` in either config, every receipt is also one event at the HTTP Event Collector, with the receipt id, tool, agent, principal, status, decision, and log position as searchable fields and the token from the environment; the same best-effort rule.
289
294
  - The REST connector: a plain HTTP API described as tools in the gateway config, credentials from the environment, so an agent's direct API calls become receipted, policy-checked tool calls through the gateway.
290
295
  - Pre-commit authorization for consequential tools: named in `precommit`, a call is signed and logged before it is forwarded, withheld if the log will not take it, and its receipt carries the committed authorization with proof that it precedes the execution.
291
296
 
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { importLogFile, PostgresTenancy } from "./log-store.js";
11
11
  import { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
12
12
  import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.js";
13
13
  import { fetchWitnessKeys, Witness } from "./witness.js";
14
+ import { checkLog, formatLogCheck } from "./log-check.js";
14
15
  import { CheckpointPublisher, fileResolver } from "./log-sink.js";
15
16
  import { createRequire } from "node:module";
16
17
  import { pruneLog } from "./retention.js";
@@ -48,6 +49,10 @@ const USAGE = `agent-custody <command>
48
49
  log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
49
50
  the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
50
51
  the welcome sheet; the public URLs fill the sheet in
52
+ log-check --log-url <url> [--checkpoints-url <url>] [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>] [--json]
53
+ the outside monitor: verifies the head against the published keys, that checkpoints keep up
54
+ with the head and the head extends them, and that the witness countersigns and raises no
55
+ alarm; exits 1 on any failure. Run it from cron or a scheduled workflow elsewhere.
51
56
  witness --key <witness.key> --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]... [--every <seconds>] [--once]
52
57
  a second signer, run by someone who is not the log's operator: fetches the log's latest
53
58
  checkpoint per watched log, proves it extends the last one it signed, and countersigns it
@@ -164,6 +169,14 @@ async function main(argv) {
164
169
  await running.close();
165
170
  return 0;
166
171
  }
172
+ case "log-check": {
173
+ const { values } = parseArgs({ args: rest, options: { "log-url": { type: "string" }, "checkpoints-url": { type: "string" }, "witness-url": { type: "string" }, tenant: { type: "string", multiple: true }, "max-lag": { type: "string", default: "900" }, json: { type: "boolean", default: false } } });
174
+ if (!values["log-url"])
175
+ throw new Error("log-check needs --log-url");
176
+ const r = await checkLog({ logUrl: values["log-url"], ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}), ...(values["witness-url"] ? { witnessUrl: values["witness-url"] } : {}), tenants: values.tenant?.length ? values.tenant : ["default"], maxLagMs: Number(values["max-lag"]) * 1000 });
177
+ console.log(values.json ? JSON.stringify(r, null, 2) : formatLogCheck(r));
178
+ return r.ok ? 0 : 1;
179
+ }
167
180
  case "witness": {
168
181
  const { values } = parseArgs({ args: rest, options: { key: { type: "string" }, "log-url": { type: "string" }, "checkpoints-url": { type: "string" }, out: { type: "string" }, tenant: { type: "string", multiple: true }, every: { type: "string", default: "300" }, once: { type: "boolean", default: false } } });
169
182
  if (!values.key || !values["log-url"] || !values["checkpoints-url"] || !values.out)
package/dist/config.d.ts CHANGED
@@ -181,6 +181,14 @@ export declare const GatewayConfigSchema: z.ZodObject<{
181
181
  headersEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
182
182
  serviceName: z.ZodOptional<z.ZodString>;
183
183
  }, z.core.$strip>>;
184
+ splunk: z.ZodOptional<z.ZodObject<{
185
+ url: z.ZodString;
186
+ tokenEnv: z.ZodString;
187
+ index: z.ZodOptional<z.ZodString>;
188
+ source: z.ZodOptional<z.ZodString>;
189
+ sourcetype: z.ZodOptional<z.ZodString>;
190
+ host: z.ZodOptional<z.ZodString>;
191
+ }, z.core.$strip>>;
184
192
  }, z.core.$strip>;
185
193
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
186
194
  export type FactConfig = z.infer<typeof FactSchema>;
@@ -205,6 +213,14 @@ export declare const SdkConfigSchema: z.ZodObject<{
205
213
  headersEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
206
214
  serviceName: z.ZodOptional<z.ZodString>;
207
215
  }, z.core.$strip>>;
216
+ splunk: z.ZodOptional<z.ZodObject<{
217
+ url: z.ZodString;
218
+ tokenEnv: z.ZodString;
219
+ index: z.ZodOptional<z.ZodString>;
220
+ source: z.ZodOptional<z.ZodString>;
221
+ sourcetype: z.ZodOptional<z.ZodString>;
222
+ host: z.ZodOptional<z.ZodString>;
223
+ }, z.core.$strip>>;
208
224
  framework: z.ZodOptional<z.ZodString>;
209
225
  }, z.core.$strip>;
210
226
  export type SdkConfig = z.infer<typeof SdkConfigSchema>;
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ const LogSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1).
6
6
  const oneLog = { message: "exactly one of logFile or log is required" };
7
7
  /** Optional OpenTelemetry export: every receipt also becomes a span at this OTLP/HTTP collector, after it is issued. Never on the evidence path. */
8
8
  const OtelSchema = z.object({ url: z.string().url(), headersEnv: z.record(z.string(), z.string().min(1)).optional(), serviceName: z.string().min(1).optional() });
9
+ const SplunkSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1), index: z.string().min(1).optional(), source: z.string().min(1).optional(), sourcetype: z.string().min(1).optional(), host: z.string().min(1).optional() });
9
10
  const hasOneLog = (c) => (c.logFile ? 1 : 0) + (c.log ? 1 : 0) === 1;
10
11
  /** One REST endpoint offered to the agent as a tool. `{name}` segments in the path come from the call's arguments; the rest go to the query on GET and DELETE, or to a JSON body otherwise. */
11
12
  const RestToolSchema = z.object({
@@ -65,6 +66,7 @@ export const GatewayConfigSchema = z.object({
65
66
  logFile: z.string().optional(),
66
67
  log: LogSchema.optional(),
67
68
  otel: OtelSchema.optional(),
69
+ splunk: SplunkSchema.optional(),
68
70
  }).refine(hasOneLog, oneLog).refine((c) => (c.upstream ? 1 : 0) + (c.upstreams ? 1 : 0) === 1, { message: "exactly one of upstream or upstreams is required" });
69
71
  /** Loads a config file and resolves every path relative to the file's directory. */
70
72
  export function loadConfig(path) {
@@ -92,6 +94,7 @@ export const SdkConfigSchema = z.object({
92
94
  logFile: z.string().optional(),
93
95
  log: LogSchema.optional(),
94
96
  otel: OtelSchema.optional(),
97
+ splunk: SplunkSchema.optional(),
95
98
  /** free-text label of the host framework, e.g. "claude-code", "openai-agents" */
96
99
  framework: z.string().optional(),
97
100
  }).refine(hasOneLog, oneLog);
package/dist/index.d.ts CHANGED
@@ -3,18 +3,22 @@ export type { AuthorizationBundle, AuthorizationPredicate, AuthorizationStatemen
3
3
  export type { GatewayOptions } from "./gateway.ts";
4
4
  export { buildRequest, restUpstream } from "./rest.ts";
5
5
  export { openExporter, otlpExporter, spanFor } from "./otel.ts";
6
+ export { hecEvent, splunkExporter } from "./splunk.ts";
6
7
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
7
8
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
8
9
  export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
9
10
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
10
11
  export { adminRoutes, welcomeSheet } from "./log-admin.ts";
11
12
  export { fetchWitnessKeys, Witness } from "./witness.ts";
13
+ export { checkLog, formatLogCheck } from "./log-check.ts";
14
+ export type { LogCheck, LogCheckOptions, LogCheckResult } from "./log-check.ts";
12
15
  export type { WitnessOptions, WitnessOutcome, WitnessedCheckpoint } from "./witness.ts";
13
16
  export type { AuditOptions } from "./verify.ts";
14
17
  export type { AdminOptions } from "./log-admin.ts";
15
18
  export type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
16
19
  export type { AppendResult, LogBackend, PostgresLike, PostgresLogOptions, RateLimitOptions, Tenant, TokenRecord } from "./log-store.ts";
17
20
  export type { OtelConfig, OtlpOptions, ReceiptExporter } from "./otel.ts";
21
+ export type { SplunkConfig, SplunkOptions } from "./splunk.ts";
18
22
  export type { IssuerOptions } from "./issue.ts";
19
23
  export type { RestOptions, UpstreamClient } from "./rest.ts";
20
24
  export * from "./config.ts";
package/dist/index.js CHANGED
@@ -2,11 +2,13 @@
2
2
  export { AUTHORIZATION_PREDICATE_TYPE, buildAuthorizationStatement } from "./receipt.js";
3
3
  export { buildRequest, restUpstream } from "./rest.js";
4
4
  export { openExporter, otlpExporter, spanFor } from "./otel.js";
5
+ export { hecEvent, splunkExporter } from "./splunk.js";
5
6
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
6
7
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
7
8
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
8
9
  export { adminRoutes, welcomeSheet } from "./log-admin.js";
9
10
  export { fetchWitnessKeys, Witness } from "./witness.js";
11
+ export { checkLog, formatLogCheck } from "./log-check.js";
10
12
  export * from "./config.js";
11
13
  export * from "./crypto.js";
12
14
  export * from "./delegation.js";
@@ -31,5 +31,7 @@ export declare function welcomeSheet(o: {
31
31
  * GET /admin/tenants/:id/tokens [{ label, tokenHash, createdAt, revokedAt }]
32
32
  * POST /admin/tenants/:id/tokens { label } { token, tokenHash, welcome } token shown once
33
33
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
34
+ * GET /admin/usage?month=YYYY-MM { month, tenants: [{ id, logId, appends, totalLeaves, liveTokens, disabled }] }
35
+ * GET /admin/usage.csv?month=YYYY-MM the same as CSV, for an invoice
34
36
  */
35
37
  export declare function adminRoutes(opts: AdminOptions): (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<boolean>;
package/dist/log-admin.js CHANGED
@@ -50,6 +50,8 @@ export function welcomeSheet(o) {
50
50
  * GET /admin/tenants/:id/tokens [{ label, tokenHash, createdAt, revokedAt }]
51
51
  * POST /admin/tenants/:id/tokens { label } { token, tokenHash, welcome } token shown once
52
52
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
53
+ * GET /admin/usage?month=YYYY-MM { month, tenants: [{ id, logId, appends, totalLeaves, liveTokens, disabled }] }
54
+ * GET /admin/usage.csv?month=YYYY-MM the same as CSV, for an invoice
53
55
  */
54
56
  export function adminRoutes(opts) {
55
57
  // Five wrong tokens from one address, then one more a minute: enough to stop guessing, not enough to lock out a typo.
@@ -97,7 +99,17 @@ export function adminRoutes(opts) {
97
99
  try {
98
100
  const t = opts.tenancy;
99
101
  const parts = url.pathname.split("/").filter(Boolean); // ["admin", ...]
100
- if (req.method === "GET" && parts.length === 2 && parts[1] === "info") {
102
+ const month = url.searchParams.get("month") ?? new Date().toISOString().slice(0, 7);
103
+ if (req.method === "GET" && parts.length === 2 && parts[1] === "usage") {
104
+ json(200, await t.usage(month));
105
+ }
106
+ else if (req.method === "GET" && parts.length === 2 && parts[1] === "usage.csv") {
107
+ const u = await t.usage(month);
108
+ const csv = ["month,tenant,log_id,appends,total_leaves,live_tokens,disabled", ...u.tenants.map((x) => [u.month, x.id, x.logId, x.appends, x.totalLeaves, x.liveTokens, x.disabled].join(","))].join("\n") + "\n";
109
+ res.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="agent-custody-usage-${u.month}.csv"`, "cache-control": "no-store" });
110
+ res.end(csv);
111
+ }
112
+ else if (req.method === "GET" && parts.length === 2 && parts[1] === "info") {
101
113
  json(200, { publicUrl: opts.publicUrl ?? null, checkpointsUrl: opts.checkpointsUrl ?? null, keyid: opts.keyid ?? null });
102
114
  }
103
115
  else if (req.method === "GET" && parts.length === 2 && parts[1] === "tenants") {
@@ -191,6 +203,9 @@ const ADMIN_PAGE = `<!doctype html>
191
203
  <button class="quiet" id="copyTok">Copy token</button> <button class="quiet" id="copySheet">Copy welcome sheet</button>
192
204
  <pre id="sheet"></pre>
193
205
  </div>
206
+ <h2>Usage</h2>
207
+ <div class="row"><label>month<input id="month" type="month"></label><button class="quiet" id="loadUsage">Show</button><a id="csv" class="quiet" href="#" style="align-self:center">Download CSV</a></div>
208
+ <table><thead><tr><th>tenant</th><th>log id</th><th>appends this month</th><th>leaves in total</th><th>live tokens</th></tr></thead><tbody id="usage"></tbody></table>
194
209
  <h2>Tokens of a tenant</h2>
195
210
  <div class="row"><label>tenant<input id="ltid" placeholder="acme" autocomplete="off"></label><button class="quiet" id="listTokens">List</button></div>
196
211
  <table><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="tokens"></tbody></table>
@@ -223,6 +238,7 @@ const ADMIN_PAGE = `<!doctype html>
223
238
  const info = await api("GET", "/admin/info");
224
239
  $("where").textContent = (info.publicUrl || location.origin) + " · keyid " + (info.keyid ? info.keyid.slice(0, 12) : "?") + (info.checkpointsUrl ? " · checkpoints at " + info.checkpointsUrl : "");
225
240
  await loadTenants();
241
+ await loadUsage();
226
242
  } catch (e) { say(e.message, "err"); }
227
243
  };
228
244
  $("addTenant").onclick = async () => { try { const t = await api("POST", "/admin/tenants", { id: $("tid").value.trim(), logId: $("lid").value.trim() }); say("tenant " + t.id + " created; reached at /t/" + t.id + "/", "ok"); $("ttid").value = t.id; await loadTenants(); } catch (e) { say(e.message, "err"); } };
@@ -237,6 +253,14 @@ const ADMIN_PAGE = `<!doctype html>
237
253
  $("copyTok").onclick = () => navigator.clipboard.writeText($("tokval").textContent).then(() => say("token copied", "ok"));
238
254
  $("copySheet").onclick = () => navigator.clipboard.writeText($("sheet").textContent).then(() => say("welcome sheet copied", "ok"));
239
255
  $("listTokens").onclick = () => loadTokens($("ltid").value.trim()).catch((e) => say(e.message, "err"));
256
+ const loadUsage = async () => {
257
+ const month = $("month").value || new Date().toISOString().slice(0, 7);
258
+ const u = await api("GET", "/admin/usage?month=" + encodeURIComponent(month));
259
+ $("csv").href = "/admin/usage.csv?month=" + encodeURIComponent(month);
260
+ $("usage").innerHTML = u.tenants.map((t) => "<tr><td><code>" + esc(t.id) + "</code>" + (t.disabled ? " <span class=muted>disabled</span>" : "") + "</td><td><code>" + esc(t.logId) + "</code></td><td>" + t.appends + "</td><td>" + t.totalLeaves + "</td><td>" + t.liveTokens + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>no tenants</td></tr>";
261
+ };
262
+ $("loadUsage").onclick = () => loadUsage().catch((e) => say(e.message, "err"));
263
+ $("month").value = new Date().toISOString().slice(0, 7);
240
264
  document.addEventListener("click", async (e) => {
241
265
  const b = e.target.closest("button"); if (!b) return;
242
266
  if (b.dataset.disable && confirm("Disable tenant " + b.dataset.disable + "? Its paths answer 404 within ten seconds.")) { try { await api("POST", "/admin/tenants/" + encodeURIComponent(b.dataset.disable) + "/disable"); await loadTenants(); say("disabled " + b.dataset.disable, "ok"); } catch (err) { say(err.message, "err"); } }
@@ -0,0 +1,25 @@
1
+ export interface LogCheckOptions {
2
+ logUrl: string;
3
+ checkpointsUrl?: string;
4
+ witnessUrl?: string;
5
+ /** which logs to probe: "default" for the root paths, else tenant names */
6
+ tenants?: string[];
7
+ /** how far a checkpoint may trail the head, in milliseconds, before that is a failure; default fifteen minutes */
8
+ maxLagMs?: number;
9
+ /** how old a checkpoint may be while the head has not moved; default a day, since an idle log is not a broken one */
10
+ maxIdleMs?: number;
11
+ fetch?: typeof fetch;
12
+ now?: () => number;
13
+ }
14
+ export interface LogCheck {
15
+ tenant: string | null;
16
+ name: string;
17
+ ok: boolean;
18
+ detail?: string;
19
+ }
20
+ export interface LogCheckResult {
21
+ ok: boolean;
22
+ checks: LogCheck[];
23
+ }
24
+ export declare function checkLog(o: LogCheckOptions): Promise<LogCheckResult>;
25
+ export declare function formatLogCheck(r: LogCheckResult): string;
@@ -0,0 +1,118 @@
1
+ // The probe: what an outside monitor runs against a hosted log every few minutes. It does not trust the log's
2
+ // answers; it verifies them the way an auditor would, with the log's published keys, and it fails loudly when the
3
+ // log is down, its head does not verify, its checkpoints have fallen behind its head, or its witness has stopped
4
+ // countersigning. Run it from cron on a machine that is not the log's, or from a scheduled workflow; the exit code
5
+ // is the alert.
6
+ import { dsseVerify, dsseVerifiers } from "./crypto.js";
7
+ import { verifyConsistency } from "./log.js";
8
+ import { TREEHEAD_TYPE } from "./receipt.js";
9
+ import { fetchLogKeys } from "./signer.js";
10
+ import { fetchWitnessKeys } from "./witness.js";
11
+ const short = (s) => s.slice(0, 12);
12
+ export async function checkLog(o) {
13
+ const f = o.fetch ?? fetch;
14
+ const now = o.now ?? Date.now;
15
+ const maxLag = o.maxLagMs ?? 15 * 60_000;
16
+ const maxIdle = o.maxIdleMs ?? 24 * 3_600_000;
17
+ const checks = [];
18
+ const add = (tenant, name, ok, detail) => {
19
+ checks.push(detail === undefined ? { tenant, name, ok } : { tenant, name, ok, detail });
20
+ return ok;
21
+ };
22
+ const base = o.logUrl.endsWith("/") ? o.logUrl : `${o.logUrl}/`;
23
+ const get = async (url) => {
24
+ const res = await f(url, { signal: AbortSignal.timeout(10_000) });
25
+ if (!res.ok)
26
+ throw new Error(`${res.status} from ${url.pathname}`);
27
+ return res.json();
28
+ };
29
+ let keys = [];
30
+ try {
31
+ keys = (await fetchLogKeys(base, f)).keys;
32
+ add(null, "key document served", true, `${keys.length} key(s), current ${short(keys[0].keyid)}`);
33
+ }
34
+ catch (e) {
35
+ add(null, "key document served", false, e instanceof Error ? e.message : String(e));
36
+ return { ok: false, checks };
37
+ }
38
+ let witnessKeys = [];
39
+ if (o.witnessUrl) {
40
+ try {
41
+ witnessKeys = await fetchWitnessKeys(o.witnessUrl, f);
42
+ add(null, "witness key document served", true, `witness ${short(witnessKeys[0].keyid)}`);
43
+ }
44
+ catch (e) {
45
+ add(null, "witness key document served", false, e instanceof Error ? e.message : String(e));
46
+ }
47
+ }
48
+ for (const tenant of o.tenants ?? ["default"]) {
49
+ const path = (op) => new URL(tenant === "default" ? op : `t/${tenant}/${op}`, base);
50
+ let head = null;
51
+ try {
52
+ const { treeHead } = (await get(path("head")));
53
+ const v = dsseVerify(treeHead, keys);
54
+ head = v.ok && treeHead.payloadType === TREEHEAD_TYPE ? v.payload : null;
55
+ add(tenant, "head verifies against the published keys", head !== null, head ? `size ${head.treeSize}, signed by ${short(v.ok ? v.keyid : "?")}` : v.ok ? "not a tree head" : v.error);
56
+ }
57
+ catch (e) {
58
+ add(tenant, "head verifies against the published keys", false, e instanceof Error ? e.message : String(e));
59
+ continue;
60
+ }
61
+ if (!head)
62
+ continue;
63
+ if (!o.checkpointsUrl)
64
+ continue;
65
+ const cpBase = o.checkpointsUrl.endsWith("/") ? o.checkpointsUrl : `${o.checkpointsUrl}/`;
66
+ let cp = null;
67
+ try {
68
+ const fetched = (await get(new URL(`${tenant}/latest.json`, cpBase)));
69
+ const v = dsseVerify(fetched.envelope, keys);
70
+ add(tenant, "latest checkpoint verifies", v.ok, v.ok ? `size ${fetched.treeSize} signed ${fetched.signedAt}` : v.error);
71
+ if (v.ok)
72
+ cp = fetched;
73
+ }
74
+ catch (e) {
75
+ add(tenant, "latest checkpoint verifies", false, e instanceof Error ? e.message : String(e));
76
+ }
77
+ if (!cp)
78
+ continue;
79
+ const age = now() - Date.parse(cp.signedAt);
80
+ if (cp.treeSize < head.treeSize) {
81
+ // the head moved on; the publisher must follow within maxLag of the head's own timestamp
82
+ const lag = now() - Date.parse(head.timestamp);
83
+ add(tenant, "checkpoint keeps up with the head", lag <= maxLag, `checkpoint at ${cp.treeSize}, head at ${head.treeSize}, head signed ${Math.round(lag / 1000)}s ago`);
84
+ }
85
+ else {
86
+ add(tenant, "checkpoint keeps up with the head", cp.treeSize === head.treeSize && age <= maxIdle, cp.treeSize > head.treeSize ? `checkpoint at ${cp.treeSize} is AHEAD of the head at ${head.treeSize}` : `at the head, checkpoint signed ${Math.round(age / 60_000)} min ago`);
87
+ }
88
+ if (cp.treeSize <= head.treeSize) {
89
+ try {
90
+ const proof = (await get(path(`consistency?old=${cp.treeSize}&new=${head.treeSize}`)));
91
+ add(tenant, "head extends the checkpoint", verifyConsistency(cp.treeSize, cp.rootHash, head.treeSize, head.rootHash, proof.hashes), `${cp.treeSize} -> ${head.treeSize}`);
92
+ }
93
+ catch (e) {
94
+ add(tenant, "head extends the checkpoint", false, e instanceof Error ? e.message : String(e));
95
+ }
96
+ }
97
+ if (o.witnessUrl && witnessKeys.length > 0) {
98
+ const wBase = o.witnessUrl.endsWith("/") ? o.witnessUrl : `${o.witnessUrl}/`;
99
+ try {
100
+ const w = (await get(new URL(`${tenant}/latest.json`, wBase)));
101
+ const by = dsseVerifiers(w.envelope, witnessKeys);
102
+ add(tenant, "witness has countersigned", by.length > 0, by.length ? `at size ${w.treeSize}` : "latest witnessed checkpoint carries no witness signature");
103
+ add(tenant, "witness keeps up with the checkpoints", w.treeSize >= cp.treeSize || now() - Date.parse(cp.signedAt) <= maxLag, `witness at ${w.treeSize}, checkpoint at ${cp.treeSize}`);
104
+ const alarm = await f(new URL(`${tenant}/ALARM.json`, wBase), { signal: AbortSignal.timeout(10_000) });
105
+ add(tenant, "witness has raised no alarm", alarm.status === 404, alarm.status === 404 ? undefined : `ALARM.json is present (${alarm.status})`);
106
+ }
107
+ catch (e) {
108
+ add(tenant, "witness has countersigned", false, e instanceof Error ? e.message : String(e));
109
+ }
110
+ }
111
+ }
112
+ return { ok: checks.every((c) => c.ok), checks };
113
+ }
114
+ export function formatLogCheck(r) {
115
+ const lines = r.checks.map((c) => `${c.ok ? "PASS" : "FAIL"} ${c.tenant ? `${c.tenant.padEnd(16)} ` : "".padEnd(17)}${c.name}${c.detail ? ` (${c.detail})` : ""}`);
116
+ lines.push("", r.ok ? "RESULT: LOG HEALTHY" : "RESULT: LOG NEEDS ATTENTION");
117
+ return lines.join("\n");
118
+ }
package/dist/log-sink.js CHANGED
@@ -237,6 +237,17 @@ export function logHandler(source, keyOrSigner, opts = {}) {
237
237
  const url = new URL(req.url ?? "/", "http://localhost");
238
238
  if (admin && (await admin(req, res, url)))
239
239
  return;
240
+ if (req.method === "GET" && url.pathname === "/health") {
241
+ // Liveness for a load balancer or a container: the signer answers and the default log answers. No secrets, no sizes.
242
+ try {
243
+ const doc = await signer.keys();
244
+ const root = await resolver.resolve(null);
245
+ return json(root ? 200 : 503, { ok: !!root, keyid: doc.keys[0]?.keyid ?? null, checkpoints: !!opts.checkpoints }, { "cache-control": "no-store" });
246
+ }
247
+ catch (e) {
248
+ return json(503, { ok: false, error: e instanceof Error ? e.message : String(e) });
249
+ }
250
+ }
240
251
  if (req.method === "GET" && url.pathname === "/.well-known/agent-custody-log.json") {
241
252
  try {
242
253
  const doc = await signer.keys();
@@ -95,6 +95,21 @@ export declare class PostgresTenancy {
95
95
  }>;
96
96
  /** Revokes the tokens of a tenant whose hash starts with the prefix; returns how many. */
97
97
  revokeToken(tenantId: string, hashPrefix: string): Promise<number>;
98
+ /**
99
+ * Appends per tenant for one month, YYYY-MM in UTC, plus each tenant's total leaves and live tokens: the numbers
100
+ * any pricing rests on. One query on the leaves table, grouped; tenants with no appends that month show zero.
101
+ */
102
+ usage(month: string): Promise<{
103
+ month: string;
104
+ tenants: {
105
+ id: string;
106
+ logId: string;
107
+ appends: number;
108
+ totalLeaves: number;
109
+ liveTokens: number;
110
+ disabled: boolean;
111
+ }[];
112
+ }>;
98
113
  listTokens(tenantId: string): Promise<TokenRecord[]>;
99
114
  }
100
115
  /**
package/dist/log-store.js CHANGED
@@ -263,6 +263,25 @@ export class PostgresTenancy {
263
263
  this.tokenCache.delete(`${tenantId}:${r.token_hash}`);
264
264
  return rows.length;
265
265
  }
266
+ /**
267
+ * Appends per tenant for one month, YYYY-MM in UTC, plus each tenant's total leaves and live tokens: the numbers
268
+ * any pricing rests on. One query on the leaves table, grouped; tenants with no appends that month show zero.
269
+ */
270
+ async usage(month) {
271
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
272
+ throw new Error("month must be YYYY-MM");
273
+ await this.init();
274
+ const start = `${month}-01T00:00:00Z`;
275
+ const [y, m] = month.split("-").map(Number);
276
+ const end = `${m === 12 ? y + 1 : y}-${String(m === 12 ? 1 : m + 1).padStart(2, "0")}-01T00:00:00Z`;
277
+ const p = this.prefix;
278
+ const rows = (await this.client.query(`SELECT t.id, t.log_id, t.disabled_at,
279
+ (SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id AND l.appended_at >= $1::timestamptz AND l.appended_at < $2::timestamptz) AS appends,
280
+ (SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id) AS total,
281
+ (SELECT COUNT(*) FROM ${p}tokens k WHERE k.tenant_id = t.id AND k.revoked_at IS NULL) AS live
282
+ FROM ${p}tenants t ORDER BY t.created_at`, [start, end])).rows;
283
+ return { month, tenants: rows.map((r) => ({ id: String(r.id), logId: String(r.log_id), appends: Number(r.appends), totalLeaves: Number(r.total), liveTokens: Number(r.live), disabled: !!r.disabled_at })) };
284
+ }
266
285
  async listTokens(tenantId) {
267
286
  await this.init();
268
287
  return (await this.client.query(`SELECT tenant_id, label, token_hash, created_at, revoked_at FROM ${this.prefix}tokens WHERE tenant_id = $1 ORDER BY created_at`, [tenantId])).rows.map((r) => ({ tenantId: String(r.tenant_id), label: String(r.label), tokenHash: String(r.token_hash), createdAt: new Date(r.created_at).toISOString(), revokedAt: r.revoked_at ? new Date(r.revoked_at).toISOString() : null }));
package/dist/otel.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReceiptBundle, ReceiptPredicate } from "./receipt.ts";
2
+ import { type SplunkConfig } from "./splunk.ts";
2
3
  export interface OtelConfig {
3
4
  /** the collector's OTLP/HTTP base, e.g. http://localhost:4318; spans go to <url>/v1/traces */
4
5
  url: string;
@@ -22,7 +23,8 @@ export interface OtlpOptions {
22
23
  }
23
24
  /** An exporter that posts each receipt's span to an OTLP/HTTP collector. Failures are reported, never thrown. */
24
25
  export declare function otlpExporter(cfg: OtelConfig, opts?: OtlpOptions): ReceiptExporter;
25
- /** The exporter a config asks for, or undefined. */
26
+ /** The exporters a config asks for, as one; undefined when it asks for none. Each is told independently, so one failing never silences another. */
26
27
  export declare function openExporter(cfg: {
27
28
  otel?: OtelConfig | undefined;
29
+ splunk?: SplunkConfig | undefined;
28
30
  }): ReceiptExporter | undefined;
package/dist/otel.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { splunkExporter } from "./splunk.js";
1
2
  const str = (key, v) => (v === null || v === undefined ? [] : [{ key, value: { stringValue: v } }]);
2
3
  const int = (key, v) => [{ key, value: { intValue: String(v) } }];
3
4
  /** The span for one receipt, as the OTLP JSON a collector accepts on /v1/traces. Exported so a test or another transport can reuse it. */
@@ -81,7 +82,21 @@ export function otlpExporter(cfg, opts = {}) {
81
82
  },
82
83
  };
83
84
  }
84
- /** The exporter a config asks for, or undefined. */
85
+ /** The exporters a config asks for, as one; undefined when it asks for none. Each is told independently, so one failing never silences another. */
85
86
  export function openExporter(cfg) {
86
- return cfg.otel ? otlpExporter(cfg.otel) : undefined;
87
+ const all = [];
88
+ if (cfg.otel)
89
+ all.push(otlpExporter(cfg.otel));
90
+ if (cfg.splunk)
91
+ all.push(splunkExporter(cfg.splunk));
92
+ if (all.length === 0)
93
+ return undefined;
94
+ if (all.length === 1)
95
+ return all[0];
96
+ return {
97
+ where: all.map((e) => e.where).join(", "),
98
+ async exported(p, bundle) {
99
+ await Promise.all(all.map((e) => e.exported(p, bundle)));
100
+ },
101
+ };
87
102
  }
@@ -0,0 +1,26 @@
1
+ import type { ReceiptBundle, ReceiptPredicate } from "./receipt.ts";
2
+ import type { ReceiptExporter } from "./otel.ts";
3
+ export interface SplunkConfig {
4
+ /** the collector's base, e.g. https://splunk.example.com:8088; events go to <url>/services/collector/event */
5
+ url: string;
6
+ /** environment variable holding the HEC token; a missing variable fails at startup */
7
+ tokenEnv: string;
8
+ /** index to write to; omitted, the token's default index */
9
+ index?: string | undefined;
10
+ /** the event's source field; default agent-custody */
11
+ source?: string | undefined;
12
+ /** the event's sourcetype; default agent-custody:receipt */
13
+ sourcetype?: string | undefined;
14
+ /** the event's host field; omitted, the collector fills it in */
15
+ host?: string | undefined;
16
+ }
17
+ /** The HEC event for one receipt: flat fields Splunk can search without a props stanza. Exported for tests and other transports. */
18
+ export declare function hecEvent(p: ReceiptPredicate, bundle: ReceiptBundle, cfg: SplunkConfig): Record<string, unknown>;
19
+ export interface SplunkOptions {
20
+ fetch?: typeof fetch;
21
+ env?: Record<string, string | undefined>;
22
+ /** where export failures are reported; default stderr */
23
+ warn?: (message: string) => void;
24
+ }
25
+ /** An exporter that posts each receipt as one event to a Splunk HTTP Event Collector. Failures are reported, never thrown. */
26
+ export declare function splunkExporter(cfg: SplunkConfig, opts?: SplunkOptions): ReceiptExporter;
package/dist/splunk.js ADDED
@@ -0,0 +1,59 @@
1
+ /** The HEC event for one receipt: flat fields Splunk can search without a props stanza. Exported for tests and other transports. */
2
+ export function hecEvent(p, bundle, cfg) {
3
+ const auth = p.authorization;
4
+ const exec = p.execution;
5
+ return {
6
+ time: Date.parse(p.timestamp) / 1000,
7
+ source: cfg.source ?? "agent-custody",
8
+ sourcetype: cfg.sourcetype ?? "agent-custody:receipt",
9
+ ...(cfg.index ? { index: cfg.index } : {}),
10
+ ...(cfg.host ? { host: cfg.host } : {}),
11
+ event: {
12
+ receipt_id: p.receiptId,
13
+ issuer_kind: p.issuer.kind,
14
+ issuer_keyid: p.issuer.keyid,
15
+ tool: p.tool.name,
16
+ upstream: p.tool.upstream ?? null,
17
+ agent: p.agent.id,
18
+ agent_provenance: p.agent.provenance,
19
+ principal: p.principal.id,
20
+ status: exec.status,
21
+ reason: exec.reason ?? exec.error ?? null,
22
+ policy_decision: p.policy?.decision ?? null,
23
+ policy_digest: p.policy?.policyDigest ?? null,
24
+ policy_reasons: p.policy?.reasons ?? [],
25
+ args_digest: p.request.argsDigest,
26
+ log_leaf_index: bundle.inclusion.leafIndex,
27
+ log_tree_size: bundle.inclusion.treeSize,
28
+ authorization_leaf_index: auth ? auth.inclusion.leafIndex : null,
29
+ consumed_count: p.consumed?.factIds.length ?? 0,
30
+ model: p.model.id,
31
+ session: p.session.id,
32
+ },
33
+ };
34
+ }
35
+ /** An exporter that posts each receipt as one event to a Splunk HTTP Event Collector. Failures are reported, never thrown. */
36
+ export function splunkExporter(cfg, opts = {}) {
37
+ const f = opts.fetch ?? fetch;
38
+ const env = opts.env ?? process.env;
39
+ const warn = opts.warn ?? ((m) => console.error(m));
40
+ const token = env[cfg.tokenEnv];
41
+ if (!token)
42
+ throw new Error(`splunk: environment variable ${cfg.tokenEnv} is not set`);
43
+ const headers = { "content-type": "application/json", authorization: `Splunk ${token}` };
44
+ const base = cfg.url.endsWith("/") ? cfg.url : `${cfg.url}/`;
45
+ const url = new URL("services/collector/event", base);
46
+ return {
47
+ where: cfg.url,
48
+ async exported(p, bundle) {
49
+ try {
50
+ const res = await f(url, { method: "POST", headers, body: JSON.stringify(hecEvent(p, bundle, cfg)), signal: AbortSignal.timeout(5000) });
51
+ if (!res.ok)
52
+ warn(`agent-custody: splunk export of receipt ${p.receiptId} refused by ${cfg.url}: ${res.status}`);
53
+ }
54
+ catch (e) {
55
+ warn(`agent-custody: splunk export of receipt ${p.receiptId} failed: ${e instanceof Error ? e.message : String(e)}`);
56
+ }
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,64 @@
1
+ # What the evidence satisfies
2
+
3
+ Auditors ask for controls by their names. This page maps the requirements they cite most to the artefacts agent-custody produces, so a security questionnaire can be answered with a file rather than a paragraph. It is a map, not a certificate: the artefacts are evidence that a control operated, and the control itself, the policy, the grant, the retention window, the person who reviews an alarm, is yours.
4
+
5
+ Two rules keep the answers honest. First, say which producer made the receipt: a gateway receipt was enforced outside the agent's process, an SDK receipt is the agent's own report, and the receipt's `issuer.kind` says which. Second, say where the log runs: with the default local log the operator can rewrite history, with a log run by someone else they cannot, and with a witness neither operator can. The [proof table](../README.md#what-a-receipt-proves-and-what-it-does-not) has the full list of claims and against whom each holds.
6
+
7
+ ## The artefacts
8
+
9
+ | artefact | what it is | how to produce it |
10
+ | --- | --- | --- |
11
+ | receipt | one signed record per tool call: who authorized it, what the agent saw, what it did, the policy decision, its position in the log | issued by the gateway or the SDK; `receipts/<id>.json` |
12
+ | authorization | the same, committed to the log before a consequential call was forwarded | `precommit` in the gateway config |
13
+ | verification report | the check list a third party gets from the receipt and public keys alone | `agent-custody verify` |
14
+ | audit | proof that the log at a later size extends its earlier state | `agent-custody audit` between any two tree heads or checkpoints |
15
+ | checkpoint | a signed tree head published on a schedule, countersigned by a witness where one runs | the log's checkpoints host, the witness's host |
16
+ | action pack | one receipt explained and packed with every downstream receipt, signed | `agent-custody-memory explain --out --sign` |
17
+ | custody pack | one fact's history, receipts, blast radius, holds, and forget certificate, signed | `agent-custody-memory pack` |
18
+ | forget certificate | the receipt of the call that erased a value from the ledger and the stores, with each store's answer | `memory.forget` through the gateway |
19
+ | eval report | scores for stale reads, contradictions, and blast radius after retraction, signed | `agent-custody-memory eval --sign` |
20
+ | usage | appends per tenant per month on the hosted log | `/admin/usage.csv` |
21
+
22
+ ## SOC 2, Trust Services Criteria
23
+
24
+ | criterion | what it asks | what answers it |
25
+ | --- | --- | --- |
26
+ | CC6.1, logical access | access to systems is restricted to authorized users | the delegation grant: a human-signed statement of which agent may call which tools, for how long, embedded in every gateway receipt and re-checked by the verifier |
27
+ | CC6.3, authorization changes | access is granted, modified, and removed by authorized parties | grants have validity windows and are signed by a principal key the gateway is configured to trust; a new grant is a new signed file, a revoked one expires |
28
+ | CC7.2, monitoring for anomalies | the entity monitors system components for anomalies | every call has a receipt, allowed or denied, and `explain` answers who, why, and what depended on it; the OpenTelemetry and Splunk exports carry each receipt into the existing SIEM |
29
+ | CC7.3, evaluation of security events | events are evaluated to determine whether they are incidents | the action pack: one receipt with every downstream receipt, verifiable by the evaluator without access to the system |
30
+ | CC7.4, incident response | incidents are contained and remediated | blast radius names every action and belief that depended on a wrong fact; retract and forget are receipted calls, so the remediation has its own evidence |
31
+ | CC8.1, change management | changes are authorized and tracked | the policy digest in every receipt identifies the exact policy that decided the call, so a policy change is visible in the receipts on either side of it |
32
+
33
+ ## ISO/IEC 27001:2022, Annex A
34
+
35
+ | control | what it asks | what answers it |
36
+ | --- | --- | --- |
37
+ | A.5.15, access control | rules for access based on business requirements | the grant and the Cedar policy, with the policy decision on facts the gateway fetched itself |
38
+ | A.5.28, collection of evidence | evidence is collected in a form that stands up | receipts are signed, hashed into a Merkle log, and verifiable offline; the audit proves nothing was rewritten between two points; the packs are single signed artefacts for a case file |
39
+ | A.8.10, information deletion | information is deleted when no longer required | forget erases a value from the ledger and the adapted stores and the receipt is the certificate, with each store's own answer; retention sweeps run as receipted calls; a legal hold refuses both |
40
+ | A.8.15, logging | logs are produced, stored, protected, and analysed | the transparency log, append-only and hashed, with a log run by someone else where the operator must not be trusted |
41
+ | A.8.16, monitoring activities | networks, systems, and applications are monitored | `log-check` from a machine that is not the log's, and the per-receipt spans in the SIEM |
42
+ | A.8.32, change management | changes are subject to change management | the policy digest, the grant's window, and the receipts either side of a change |
43
+
44
+ ## EU AI Act, obligations on high-risk systems and their deployers
45
+
46
+ | article | what it asks | what answers it |
47
+ | --- | --- | --- |
48
+ | Article 12, record-keeping | automatic recording of events over the system's lifetime, enabling traceability | one receipt per tool call, in a tamper-evident log, with the facts the agent was shown and the beliefs it wrote; `explain` traces any action to its causes and consequences |
49
+ | Article 14, human oversight | humans can understand, oversee, and intervene | the grant is a human's signature over what the agent may do; the policy decides on facts the agent did not supply; retract and forget are the intervention, receipted |
50
+ | Article 19, retention of logs | logs are kept for a period appropriate to the purpose | retention windows per space in the memory server; `prune` on the receipt log keeps every proof valid while the content is gone; a legal hold overrides both |
51
+ | Article 26, deployer obligations | deployers keep logs and monitor operation | the same receipts and the same monitor; a tenant on the hosted log has its own log id and checkpoints an auditor can fetch |
52
+
53
+ ## UK GDPR
54
+
55
+ | provision | what it asks | what answers it |
56
+ | --- | --- | --- |
57
+ | Article 5(2), accountability | the controller demonstrates compliance | signed receipts and packs demonstrate what was done and why, to a party who has no access to the controller's systems |
58
+ | Article 17, right to erasure | personal data is erased on request | the forget certificate, including `stillIndexed` when a store has not caught up, which is what an honest response to a data subject says |
59
+ | Article 30, records of processing | records of processing activities are kept | the receipts, and the memory ledger's history of every fact with its source receipt |
60
+ | Article 32, security of processing | appropriate technical measures | the hosted log holds hashes only, and receipts stay with the controller |
61
+
62
+ ## What no artefact here claims
63
+
64
+ That the agent's arguments were correct, that the model named in a receipt produced the call, that the upstream executed the action unless it signed the result or a provider delivery is embedded, or that an SDK receipt was enforced outside the agent. The [proof table](../README.md#what-a-receipt-proves-and-what-it-does-not) says so, and a questionnaire answer that repeats it will survive the reviewer who checks.
package/docs/sdk.md CHANGED
@@ -30,7 +30,7 @@ Use the SDK for reach. Use the gateway for anything that moves money, touches pr
30
30
 
31
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
- `"otel": { "url": "http://localhost:4318" }` additionally exports every receipt as one span to that OTLP/HTTP collector, after the receipt is written, with the receipt id as the trace id; see the [usage guide](usage.md#setup-step-by-step) for the fields. Export never blocks or fails a receipt.
33
+ `"otel": { "url": "http://localhost:4318" }` additionally exports every receipt as one span to that OTLP/HTTP collector, after the receipt is written, with the receipt id as the trace id; see the [usage guide](usage.md#setup-step-by-step) for the fields. Export never blocks or fails a receipt. `"splunk": { "url": ..., "tokenEnv": "HEC_TOKEN" }` does the same to a Splunk HTTP Event Collector, one event per receipt with the receipt id and log position as fields; both blocks may be set together.
34
34
 
35
35
  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.
36
36
 
package/docs/tutorials.md CHANGED
@@ -28,6 +28,7 @@ Suggested reading order is the numbering. Output lands in `examples-out/`, which
28
28
  | 16 | consequential tools, committed first | [16-precommit.ts](../examples/16-precommit.ts) | a refund named in `precommit`: the authorization leaf before the receipt leaf, the five authorization checks in the report, and the same call withheld when the log refuses | `src/gateway.ts`, `src/issue.ts`, `src/verify.ts` |
29
29
  | 17 | a REST API as an upstream | [17-rest-upstream.ts](../examples/17-rest-upstream.ts) | a stand-in payments API described as two tools, the token from the environment, a refund allowed on the gateway's own lookup and one denied before reaching the API, the receipt verified | `src/rest.ts`, `src/gateway.ts` |
30
30
  | 18 | OpenTelemetry export | [18-opentelemetry.ts](../examples/18-opentelemetry.ts) | a stand-in OTLP collector, `otel` in the config, one span per receipt with the receipt id as trace id, the collector going away and the next receipt still issued | `src/otel.ts`, `src/issue.ts` |
31
+ | 19 | Splunk export | [19-splunk.ts](../examples/19-splunk.ts) | a stand-in HTTP Event Collector, `splunk` in the config with the token from the environment, one event per receipt with the receipt id and log position as fields, the collector going away and the next receipt still issued | `src/splunk.ts`, `src/otel.ts` |
31
32
 
32
33
  ## How policies are defined, in one paragraph
33
34
 
package/docs/usage.md CHANGED
@@ -105,10 +105,12 @@ An upstream need not be an MCP server. A plain HTTP API is described as tools:
105
105
  "log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }
106
106
  ```
107
107
 
108
- 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`. 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>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. 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, 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. Nothing 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. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers, and [deploy/witness/](../../deploy/witness/) the witness.
108
+ 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`. 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>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. 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, 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. Nothing 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. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers, and [deploy/witness/](../../deploy/witness/) the witness.
109
109
 
110
110
  `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.
111
111
 
112
+ `splunk`, optional in both configs and usable beside `otel`, sends every receipt to a Splunk HTTP Event Collector as one event: `"splunk": { "url": "https://splunk.example.com:8088", "tokenEnv": "HEC_TOKEN", "index": "agents" }`, with optional `source` (default `agent-custody`), `sourcetype` (default `agent-custody:receipt`), and `host`. The token is read from the named environment variable at startup and never appears in the config. Each event carries `receipt_id`, `tool`, `agent`, `principal`, `status`, `reason`, `policy_decision`, `policy_digest`, `args_digest`, `log_leaf_index`, `log_tree_size`, and `authorization_leaf_index` as plain fields, so a search like `sourcetype="agent-custody:receipt" status=denied` needs no field extraction and every alert leads to the receipt by id. The same rule holds: the event is a copy, the receipt is the evidence, and a collector outage costs a warning. Tutorial 19 shows it.
113
+
112
114
  `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. A lookup with `"optional": true` is skipped when a `$args.<key>` it needs is absent from the call, and the fact is then simply not present, which a policy tests with `context.facts has <name>`; this is how a policy sees the fact a `memory.write` is about to supersede without denying every write that supersedes nothing.
113
115
 
114
116
  `precommit` names the consequential tools, or `["*"]` for all of them. For every other tool the gateway forwards the call and then records it, so if the log is unreachable at that moment the side effect exists before its evidence does. For a tool in `precommit` the gateway first signs an authorization statement, everything the receipt will say except the outcome, and appends it to the log. Only if the log took it does the call go upstream. The receipt then embeds that authorization with its own inclusion proof, and a verifier checks that it names this call and sits in the log before the receipt does. If the log will not take it, the call is not forwarded, the agent is told `Not executed`, and the receipt records `execution.status: "withheld"` with the policy's `allow` beside it. The authorization is also written on its own as `receipts/<receiptId>.authorization.json`, which is the evidence that survives if the gateway dies between forwarding and the receipt. Use it for money, for anything irreversible, and for anything a counterparty could later dispute; the cost is one extra log append per call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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": {