@agent-custody/receipts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +269 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +111 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +66 -0
- package/dist/crypto.d.ts +42 -0
- package/dist/crypto.js +92 -0
- package/dist/delegation.d.ts +24 -0
- package/dist/delegation.js +31 -0
- package/dist/gateway.d.ts +21 -0
- package/dist/gateway.js +158 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/issue.d.ts +7 -0
- package/dist/issue.js +21 -0
- package/dist/log.d.ts +23 -0
- package/dist/log.js +109 -0
- package/dist/policy.d.ts +16 -0
- package/dist/policy.js +28 -0
- package/dist/receipt.d.ts +114 -0
- package/dist/receipt.js +11 -0
- package/dist/sdk/claude.d.ts +53 -0
- package/dist/sdk/claude.js +52 -0
- package/dist/sdk/index.d.ts +41 -0
- package/dist/sdk/index.js +77 -0
- package/dist/sdk/langchain.d.ts +17 -0
- package/dist/sdk/langchain.js +61 -0
- package/dist/sdk/openai-agents.d.ts +11 -0
- package/dist/sdk/openai-agents.js +66 -0
- package/dist/sdk/vercel-ai.d.ts +7 -0
- package/dist/sdk/vercel-ai.js +35 -0
- package/dist/verify.d.ts +22 -0
- package/dist/verify.js +107 -0
- package/docs/policies.md +138 -0
- package/docs/sdk.md +177 -0
- package/docs/tutorials.md +35 -0
- package/docs/usage.md +166 -0
- package/docs/verification.md +139 -0
- package/package.json +87 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
const FactSchema = z.object({
|
|
5
|
+
/** key under context.facts */
|
|
6
|
+
name: z.string().min(1),
|
|
7
|
+
/** upstream tool the gateway calls to obtain the fact */
|
|
8
|
+
tool: z.string().min(1),
|
|
9
|
+
/** argument template; values of the form "$args.<key>" are taken from the intercepted call */
|
|
10
|
+
args: z.record(z.string(), z.string()),
|
|
11
|
+
/** which intercepted tools trigger this lookup */
|
|
12
|
+
forTools: z.array(z.string().min(1)).min(1),
|
|
13
|
+
});
|
|
14
|
+
export const GatewayConfigSchema = z.object({
|
|
15
|
+
identity: z.object({ keyFile: z.string() }),
|
|
16
|
+
upstream: z.object({
|
|
17
|
+
command: z.string(),
|
|
18
|
+
args: z.array(z.string()).default([]),
|
|
19
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
20
|
+
}),
|
|
21
|
+
grantFile: z.string(),
|
|
22
|
+
trustedPrincipalKeys: z.array(z.string()).min(1),
|
|
23
|
+
policyFile: z.string(),
|
|
24
|
+
facts: z.array(FactSchema).default([]),
|
|
25
|
+
receiptsDir: z.string(),
|
|
26
|
+
logFile: z.string(),
|
|
27
|
+
});
|
|
28
|
+
/** Loads a config file and resolves every path relative to the file's directory. */
|
|
29
|
+
export function loadConfig(path) {
|
|
30
|
+
const cfg = GatewayConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
31
|
+
const base = dirname(resolve(path));
|
|
32
|
+
const r = (p) => resolve(base, p);
|
|
33
|
+
return {
|
|
34
|
+
...cfg,
|
|
35
|
+
identity: { keyFile: r(cfg.identity.keyFile) },
|
|
36
|
+
grantFile: r(cfg.grantFile),
|
|
37
|
+
trustedPrincipalKeys: cfg.trustedPrincipalKeys.map(r),
|
|
38
|
+
policyFile: r(cfg.policyFile),
|
|
39
|
+
receiptsDir: r(cfg.receiptsDir),
|
|
40
|
+
logFile: r(cfg.logFile),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export const SdkConfigSchema = z.object({
|
|
44
|
+
/** identity the receipts will name; nothing checks it, so it is recorded as claimed */
|
|
45
|
+
agentId: z.string().min(1),
|
|
46
|
+
principalId: z.string().min(1).optional(),
|
|
47
|
+
identity: z.object({ keyFile: z.string() }),
|
|
48
|
+
/** optional Cedar policy; when present, wrapped tools and PreToolUse hooks can deny */
|
|
49
|
+
policyFile: z.string().optional(),
|
|
50
|
+
receiptsDir: z.string(),
|
|
51
|
+
logFile: z.string(),
|
|
52
|
+
/** free-text label of the host framework, e.g. "claude-code", "openai-agents" */
|
|
53
|
+
framework: z.string().optional(),
|
|
54
|
+
});
|
|
55
|
+
export function loadSdkConfig(path) {
|
|
56
|
+
const cfg = SdkConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
57
|
+
const base = dirname(resolve(path));
|
|
58
|
+
const r = (p) => resolve(base, p);
|
|
59
|
+
return {
|
|
60
|
+
...cfg,
|
|
61
|
+
identity: { keyFile: r(cfg.identity.keyFile) },
|
|
62
|
+
...(cfg.policyFile ? { policyFile: r(cfg.policyFile) } : {}),
|
|
63
|
+
receiptsDir: r(cfg.receiptsDir),
|
|
64
|
+
logFile: r(cfg.logFile),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { KeyObject } from "node:crypto";
|
|
2
|
+
/** Deterministic JSON: sorted keys, no whitespace, undefined dropped. Not full RFC 8785, but stable across runs. */
|
|
3
|
+
export declare function canonicalize(value: unknown): string;
|
|
4
|
+
export declare function sha256Hex(data: string | Uint8Array): string;
|
|
5
|
+
/** sha256 over the canonical JSON of a value. */
|
|
6
|
+
export declare function digestOf(value: unknown): string;
|
|
7
|
+
export interface PublicKeyRef {
|
|
8
|
+
publicKey: KeyObject;
|
|
9
|
+
keyid: string;
|
|
10
|
+
}
|
|
11
|
+
export interface KeyPair extends PublicKeyRef {
|
|
12
|
+
privateKey: KeyObject;
|
|
13
|
+
}
|
|
14
|
+
/** keyid = sha256 of the SPKI DER encoding of the public key. */
|
|
15
|
+
export declare function keyidOf(publicKey: KeyObject): string;
|
|
16
|
+
export declare function generateKeyPair(): KeyPair;
|
|
17
|
+
/** Writes <dir>/<name>.key (PKCS8 PEM, mode 0600) and <dir>/<name>.pub (SPKI PEM). */
|
|
18
|
+
export declare function writeKeyPair(kp: KeyPair, dir: string, name: string): {
|
|
19
|
+
keyFile: string;
|
|
20
|
+
pubFile: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function loadPrivateKey(path: string): KeyPair;
|
|
23
|
+
export declare function loadPublicKey(path: string): PublicKeyRef;
|
|
24
|
+
export interface Envelope {
|
|
25
|
+
payloadType: string;
|
|
26
|
+
payload: string;
|
|
27
|
+
signatures: {
|
|
28
|
+
keyid: string;
|
|
29
|
+
sig: string;
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
export declare function dsseSign(payloadType: string, payloadObj: unknown, kp: KeyPair): Envelope;
|
|
33
|
+
export type DsseVerifyResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
payload: unknown;
|
|
36
|
+
keyid: string;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
error: string;
|
|
40
|
+
};
|
|
41
|
+
/** Verifies the envelope against any of the given trusted keys, matched by keyid. */
|
|
42
|
+
export declare function dsseVerify(env: Envelope, trusted: PublicKeyRef[]): DsseVerifyResult;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Hashing, Ed25519 keys, and DSSE envelopes. No third-party crypto.
|
|
2
|
+
import { createHash, generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey } from "node:crypto";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/** Deterministic JSON: sorted keys, no whitespace, undefined dropped. Not full RFC 8785, but stable across runs. */
|
|
6
|
+
export function canonicalize(value) {
|
|
7
|
+
return JSON.stringify(sortKeys(value));
|
|
8
|
+
}
|
|
9
|
+
function sortKeys(v) {
|
|
10
|
+
if (Array.isArray(v))
|
|
11
|
+
return v.map(sortKeys);
|
|
12
|
+
if (v && typeof v === "object") {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const k of Object.keys(v).sort()) {
|
|
15
|
+
const x = v[k];
|
|
16
|
+
if (x !== undefined)
|
|
17
|
+
out[k] = sortKeys(x);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
export function sha256Hex(data) {
|
|
24
|
+
return createHash("sha256").update(data).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
/** sha256 over the canonical JSON of a value. */
|
|
27
|
+
export function digestOf(value) {
|
|
28
|
+
return sha256Hex(canonicalize(value));
|
|
29
|
+
}
|
|
30
|
+
/** keyid = sha256 of the SPKI DER encoding of the public key. */
|
|
31
|
+
export function keyidOf(publicKey) {
|
|
32
|
+
return sha256Hex(publicKey.export({ type: "spki", format: "der" }));
|
|
33
|
+
}
|
|
34
|
+
export function generateKeyPair() {
|
|
35
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
36
|
+
return { privateKey, publicKey, keyid: keyidOf(publicKey) };
|
|
37
|
+
}
|
|
38
|
+
/** Writes <dir>/<name>.key (PKCS8 PEM, mode 0600) and <dir>/<name>.pub (SPKI PEM). */
|
|
39
|
+
export function writeKeyPair(kp, dir, name) {
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
const keyFile = join(dir, `${name}.key`);
|
|
42
|
+
const pubFile = join(dir, `${name}.pub`);
|
|
43
|
+
writeFileSync(keyFile, kp.privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
|
|
44
|
+
writeFileSync(pubFile, kp.publicKey.export({ type: "spki", format: "pem" }));
|
|
45
|
+
return { keyFile, pubFile };
|
|
46
|
+
}
|
|
47
|
+
export function loadPrivateKey(path) {
|
|
48
|
+
const privateKey = createPrivateKey(readFileSync(path));
|
|
49
|
+
const publicKey = createPublicKey(privateKey);
|
|
50
|
+
return { privateKey, publicKey, keyid: keyidOf(publicKey) };
|
|
51
|
+
}
|
|
52
|
+
export function loadPublicKey(path) {
|
|
53
|
+
const publicKey = createPublicKey(readFileSync(path));
|
|
54
|
+
return { publicKey, keyid: keyidOf(publicKey) };
|
|
55
|
+
}
|
|
56
|
+
function pae(payloadType, payload) {
|
|
57
|
+
const header = `DSSEv1 ${Buffer.byteLength(payloadType)} ${payloadType} ${payload.length} `;
|
|
58
|
+
return Buffer.concat([Buffer.from(header), payload]);
|
|
59
|
+
}
|
|
60
|
+
export function dsseSign(payloadType, payloadObj, kp) {
|
|
61
|
+
const payload = Buffer.from(canonicalize(payloadObj));
|
|
62
|
+
const sig = sign(null, pae(payloadType, payload), kp.privateKey);
|
|
63
|
+
return {
|
|
64
|
+
payloadType,
|
|
65
|
+
payload: payload.toString("base64"),
|
|
66
|
+
signatures: [{ keyid: kp.keyid, sig: sig.toString("base64") }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Verifies the envelope against any of the given trusted keys, matched by keyid. */
|
|
70
|
+
export function dsseVerify(env, trusted) {
|
|
71
|
+
if (!env || typeof env.payload !== "string" || !Array.isArray(env.signatures) || env.signatures.length === 0) {
|
|
72
|
+
return { ok: false, error: "malformed envelope" };
|
|
73
|
+
}
|
|
74
|
+
const payload = Buffer.from(env.payload, "base64");
|
|
75
|
+
const data = pae(env.payloadType, payload);
|
|
76
|
+
for (const s of env.signatures) {
|
|
77
|
+
const key = trusted.find((t) => t.keyid === s.keyid);
|
|
78
|
+
if (!key)
|
|
79
|
+
continue;
|
|
80
|
+
const good = verify(null, data, key.publicKey, Buffer.from(s.sig, "base64"));
|
|
81
|
+
if (good) {
|
|
82
|
+
try {
|
|
83
|
+
return { ok: true, payload: JSON.parse(payload.toString()), keyid: s.keyid };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { ok: false, error: "payload is not JSON" };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ok: false, error: `signature by ${s.keyid.slice(0, 12)} did not verify` };
|
|
90
|
+
}
|
|
91
|
+
return { ok: false, error: `no trusted key matches keyids [${env.signatures.map((s) => s.keyid.slice(0, 12)).join(", ")}]` };
|
|
92
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Envelope, type KeyPair, type PublicKeyRef } from "./crypto.ts";
|
|
3
|
+
export declare const DELEGATION_TYPE = "application/vnd.agent-custody.delegation+json";
|
|
4
|
+
export declare const DelegationSchema: z.ZodObject<{
|
|
5
|
+
version: z.ZodLiteral<"0.1">;
|
|
6
|
+
principal: z.ZodString;
|
|
7
|
+
agent: z.ZodString;
|
|
8
|
+
scopes: z.ZodArray<z.ZodString>;
|
|
9
|
+
issuedAt: z.ZodISODateTime;
|
|
10
|
+
expiresAt: z.ZodISODateTime;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export type Delegation = z.infer<typeof DelegationSchema>;
|
|
13
|
+
export declare function createDelegation(principalKey: KeyPair, d: Delegation): Envelope;
|
|
14
|
+
export type DelegationVerifyResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
delegation: Delegation;
|
|
17
|
+
keyid: string;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function verifyDelegation(env: Envelope, trustedPrincipals: PublicKeyRef[]): DelegationVerifyResult;
|
|
23
|
+
/** True when `at` (ISO) lies inside the grant's validity window. */
|
|
24
|
+
export declare function delegationValidAt(d: Delegation, at: string): boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A delegation grant: a principal signs a statement that an agent may use certain tools for a window of time.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { dsseSign, dsseVerify } from "./crypto.js";
|
|
4
|
+
export const DELEGATION_TYPE = "application/vnd.agent-custody.delegation+json";
|
|
5
|
+
export const DelegationSchema = z.object({
|
|
6
|
+
version: z.literal("0.1"),
|
|
7
|
+
principal: z.string().min(1),
|
|
8
|
+
agent: z.string().min(1),
|
|
9
|
+
scopes: z.array(z.string().min(1)).min(1),
|
|
10
|
+
issuedAt: z.iso.datetime(),
|
|
11
|
+
expiresAt: z.iso.datetime(),
|
|
12
|
+
});
|
|
13
|
+
export function createDelegation(principalKey, d) {
|
|
14
|
+
return dsseSign(DELEGATION_TYPE, DelegationSchema.parse(d), principalKey);
|
|
15
|
+
}
|
|
16
|
+
export function verifyDelegation(env, trustedPrincipals) {
|
|
17
|
+
if (env.payloadType !== DELEGATION_TYPE)
|
|
18
|
+
return { ok: false, error: `unexpected payloadType ${env.payloadType}` };
|
|
19
|
+
const r = dsseVerify(env, trustedPrincipals);
|
|
20
|
+
if (!r.ok)
|
|
21
|
+
return r;
|
|
22
|
+
const parsed = DelegationSchema.safeParse(r.payload);
|
|
23
|
+
if (!parsed.success)
|
|
24
|
+
return { ok: false, error: `invalid delegation: ${parsed.error.message}` };
|
|
25
|
+
return { ok: true, delegation: parsed.data, keyid: r.keyid };
|
|
26
|
+
}
|
|
27
|
+
/** True when `at` (ISO) lies inside the grant's validity window. */
|
|
28
|
+
export function delegationValidAt(d, at) {
|
|
29
|
+
const t = Date.parse(at);
|
|
30
|
+
return t >= Date.parse(d.issuedAt) && t <= Date.parse(d.expiresAt);
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CallToolResult, type Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { GatewayConfig } from "./config.ts";
|
|
3
|
+
import { type Delegation } from "./delegation.ts";
|
|
4
|
+
export declare const GATEWAY_VERSION = "0.1.0";
|
|
5
|
+
export declare const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
6
|
+
export declare const MODEL_META_KEY = "agent-custody/model";
|
|
7
|
+
export interface CallParams {
|
|
8
|
+
name: string;
|
|
9
|
+
arguments?: Record<string, unknown>;
|
|
10
|
+
_meta?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface Gateway {
|
|
13
|
+
agentId: string;
|
|
14
|
+
delegation: Delegation;
|
|
15
|
+
listTools(): Promise<Tool[]>;
|
|
16
|
+
handleCall(params: CallParams): Promise<CallToolResult>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare function createGateway(cfg: GatewayConfig): Promise<Gateway>;
|
|
20
|
+
/** Exposes the gateway as an MCP server over stdio. Everything diagnostic must go to stderr. */
|
|
21
|
+
export declare function serveStdio(gw: Gateway): Promise<void>;
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The MCP gateway: sits between an agent and one upstream MCP server, enforces scope + Cedar policy,
|
|
2
|
+
// and emits a signed, logged receipt for every tool call, allowed or denied.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { digestOf, loadPrivateKey, loadPublicKey } from "./crypto.js";
|
|
11
|
+
import { delegationValidAt, verifyDelegation } from "./delegation.js";
|
|
12
|
+
import { createIssuer } from "./issue.js";
|
|
13
|
+
import { evaluate, policyDigest } from "./policy.js";
|
|
14
|
+
export const GATEWAY_VERSION = "0.1.0";
|
|
15
|
+
export const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
16
|
+
export const MODEL_META_KEY = "agent-custody/model";
|
|
17
|
+
function resolveFactArgs(template, args) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [k, v] of Object.entries(template)) {
|
|
20
|
+
if (v.startsWith("$args.")) {
|
|
21
|
+
const key = v.slice("$args.".length);
|
|
22
|
+
if (!(key in args))
|
|
23
|
+
throw new Error(`fact argument "${k}" needs call argument "${key}", which is missing`);
|
|
24
|
+
out[k] = args[key];
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
out[k] = v;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** First text content item, JSON-parsed when possible. Tool results carry no signature, so this is "observed", never "attested". */
|
|
33
|
+
function extractValue(result) {
|
|
34
|
+
const text = result.content.find((c) => c.type === "text");
|
|
35
|
+
if (!text || text.type !== "text")
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(text.text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return text.text;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function createGateway(cfg) {
|
|
45
|
+
const gatewayKey = loadPrivateKey(cfg.identity.keyFile);
|
|
46
|
+
const trusted = cfg.trustedPrincipalKeys.map(loadPublicKey);
|
|
47
|
+
const grantEnvelope = JSON.parse(readFileSync(cfg.grantFile, "utf8"));
|
|
48
|
+
const grant = verifyDelegation(grantEnvelope, trusted);
|
|
49
|
+
if (!grant.ok)
|
|
50
|
+
throw new Error(`delegation grant rejected: ${grant.error}`);
|
|
51
|
+
if (!delegationValidAt(grant.delegation, new Date().toISOString()))
|
|
52
|
+
throw new Error("delegation grant is outside its validity window");
|
|
53
|
+
const delegation = grant.delegation;
|
|
54
|
+
const principalKeyid = grant.keyid;
|
|
55
|
+
const policyText = readFileSync(cfg.policyFile, "utf8");
|
|
56
|
+
const pDigest = policyDigest(policyText);
|
|
57
|
+
const issuer = createIssuer(gatewayKey, cfg.receiptsDir, cfg.logFile);
|
|
58
|
+
const upstream = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
|
|
59
|
+
await upstream.connect(new StdioClientTransport({ command: cfg.upstream.command, args: cfg.upstream.args, env: cfg.upstream.env, stderr: "inherit" }));
|
|
60
|
+
const callUpstream = async (name, args) => (await upstream.callTool({ name, arguments: args }));
|
|
61
|
+
async function gatherFacts(tool, args) {
|
|
62
|
+
const facts = {};
|
|
63
|
+
for (const f of cfg.facts.filter((f) => f.forTools.includes(tool))) {
|
|
64
|
+
const fargs = resolveFactArgs(f.args, args);
|
|
65
|
+
const result = await callUpstream(f.tool, fargs);
|
|
66
|
+
if (result.isError)
|
|
67
|
+
throw new Error(`fact "${f.name}" lookup via ${f.tool} failed: ${JSON.stringify(extractValue(result))}`);
|
|
68
|
+
facts[f.name] = { tool: f.tool, args: fargs, value: extractValue(result), resultDigest: digestOf(result), provenance: "observed" };
|
|
69
|
+
}
|
|
70
|
+
return facts;
|
|
71
|
+
}
|
|
72
|
+
async function handleCall(params) {
|
|
73
|
+
const tool = params.name;
|
|
74
|
+
const args = params.arguments ?? {};
|
|
75
|
+
const receiptId = randomUUID();
|
|
76
|
+
const timestamp = new Date().toISOString();
|
|
77
|
+
const modelClaim = params._meta?.[MODEL_META_KEY];
|
|
78
|
+
let facts = {};
|
|
79
|
+
let policy;
|
|
80
|
+
let execution;
|
|
81
|
+
if (!delegation.scopes.includes(tool)) {
|
|
82
|
+
policy = { decision: "deny", reasons: [], errors: [`tool "${tool}" is not in the delegation scopes`], policyDigest: pDigest };
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
try {
|
|
86
|
+
facts = await gatherFacts(tool, args);
|
|
87
|
+
const factValues = Object.fromEntries(Object.entries(facts).map(([k, f]) => [k, f.value]));
|
|
88
|
+
policy = evaluate(policyText, {
|
|
89
|
+
agentId: delegation.agent,
|
|
90
|
+
tool,
|
|
91
|
+
context: { args, facts: factValues, grant: { principal: delegation.principal, scopes: delegation.scopes } },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
policy = { decision: "deny", reasons: [], errors: [String(e instanceof Error ? e.message : e)], policyDigest: pDigest };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (policy.decision === "allow") {
|
|
99
|
+
try {
|
|
100
|
+
const result = await callUpstream(tool, args);
|
|
101
|
+
execution = { status: result.isError ? "failed" : "executed", result, resultDigest: digestOf(result), provenance: "observed" };
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
execution = { status: "error", error: String(e instanceof Error ? e.message : e), provenance: "observed" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
execution = { status: "denied", reason: [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched", provenance: "observed" };
|
|
109
|
+
}
|
|
110
|
+
issuer.issue({
|
|
111
|
+
receiptId,
|
|
112
|
+
timestamp,
|
|
113
|
+
issuer: { kind: "gateway", keyid: issuer.keyid, version: GATEWAY_VERSION },
|
|
114
|
+
principal: { id: delegation.principal, keyid: principalKeyid, provenance: "attested" },
|
|
115
|
+
agent: { id: delegation.agent, provenance: "attested" },
|
|
116
|
+
delegation: { envelope: grantEnvelope, provenance: "attested" },
|
|
117
|
+
session: { id: null, toolUseId: null, provenance: "claimed" },
|
|
118
|
+
model: { id: typeof modelClaim === "string" ? modelClaim : null, provenance: "claimed" },
|
|
119
|
+
tool: { name: tool, provenance: "observed" },
|
|
120
|
+
request: { args, argsDigest: digestOf(args), provenance: "claimed" },
|
|
121
|
+
facts,
|
|
122
|
+
policy: { ...policy, provenance: "observed" },
|
|
123
|
+
execution,
|
|
124
|
+
});
|
|
125
|
+
const meta = { [RECEIPT_META_KEY]: receiptId };
|
|
126
|
+
const refuse = (text) => ({ isError: true, content: [{ type: "text", text: `${text} (receipt ${receiptId})` }], _meta: meta });
|
|
127
|
+
switch (execution.status) {
|
|
128
|
+
case "denied":
|
|
129
|
+
return refuse(`Denied by policy: ${execution.reason}`);
|
|
130
|
+
case "error":
|
|
131
|
+
return refuse(`Upstream error: ${execution.error}`);
|
|
132
|
+
default: {
|
|
133
|
+
const result = execution.result;
|
|
134
|
+
return { ...result, _meta: { ...result._meta, ...meta } };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
agentId: delegation.agent,
|
|
140
|
+
delegation,
|
|
141
|
+
async listTools() {
|
|
142
|
+
const { tools } = await upstream.listTools();
|
|
143
|
+
return tools.filter((t) => delegation.scopes.includes(t.name));
|
|
144
|
+
},
|
|
145
|
+
handleCall,
|
|
146
|
+
close: () => upstream.close(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Exposes the gateway as an MCP server over stdio. Everything diagnostic must go to stderr. */
|
|
150
|
+
export async function serveStdio(gw) {
|
|
151
|
+
const server = new Server({ name: "agent-custody-gateway", version: GATEWAY_VERSION }, { capabilities: { tools: {} } });
|
|
152
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await gw.listTools() }));
|
|
153
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => gw.handleCall(req.params));
|
|
154
|
+
await server.connect(new StdioServerTransport());
|
|
155
|
+
await new Promise((resolve) => {
|
|
156
|
+
server.onclose = resolve;
|
|
157
|
+
});
|
|
158
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./config.ts";
|
|
2
|
+
export * from "./crypto.ts";
|
|
3
|
+
export * from "./delegation.ts";
|
|
4
|
+
export * from "./gateway.ts";
|
|
5
|
+
export * from "./issue.ts";
|
|
6
|
+
export * from "./log.ts";
|
|
7
|
+
export * from "./policy.ts";
|
|
8
|
+
export * from "./receipt.ts";
|
|
9
|
+
export * from "./verify.ts";
|
|
10
|
+
export * from "./sdk/index.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public surface of @agent-custody/receipts. Framework adapters live on subpaths, ./sdk/<framework>, because they import optional peers.
|
|
2
|
+
export * from "./config.js";
|
|
3
|
+
export * from "./crypto.js";
|
|
4
|
+
export * from "./delegation.js";
|
|
5
|
+
export * from "./gateway.js";
|
|
6
|
+
export * from "./issue.js";
|
|
7
|
+
export * from "./log.js";
|
|
8
|
+
export * from "./policy.js";
|
|
9
|
+
export * from "./receipt.js";
|
|
10
|
+
export * from "./verify.js";
|
|
11
|
+
export * from "./sdk/index.js";
|
package/dist/issue.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type KeyPair } from "./crypto.ts";
|
|
2
|
+
import { type ReceiptBundle, type ReceiptPredicate } from "./receipt.ts";
|
|
3
|
+
export interface Issuer {
|
|
4
|
+
keyid: string;
|
|
5
|
+
issue(predicate: ReceiptPredicate): ReceiptBundle;
|
|
6
|
+
}
|
|
7
|
+
export declare function createIssuer(key: KeyPair, receiptsDir: string, logFile: string): Issuer;
|
package/dist/issue.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Signing, logging and writing a receipt. Shared by every producer: the gateway and the SDK.
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { canonicalize, dsseSign } from "./crypto.js";
|
|
5
|
+
import { MerkleLog } from "./log.js";
|
|
6
|
+
import { buildStatement, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
|
|
7
|
+
export function createIssuer(key, receiptsDir, logFile) {
|
|
8
|
+
const log = new MerkleLog(logFile);
|
|
9
|
+
mkdirSync(receiptsDir, { recursive: true });
|
|
10
|
+
return {
|
|
11
|
+
keyid: key.keyid,
|
|
12
|
+
issue(predicate) {
|
|
13
|
+
const envelope = dsseSign(RECEIPT_TYPE, buildStatement(predicate), key);
|
|
14
|
+
const entry = log.append(canonicalize(envelope));
|
|
15
|
+
const treeHead = dsseSign(TREEHEAD_TYPE, { treeSize: entry.treeSize, rootHash: entry.rootHash, timestamp: new Date().toISOString() }, key);
|
|
16
|
+
const bundle = { envelope, treeHead, inclusion: { leafIndex: entry.leafIndex, treeSize: entry.treeSize, hashes: entry.hashes } };
|
|
17
|
+
writeFileSync(join(receiptsDir, `${predicate.receiptId}.json`), JSON.stringify(bundle, null, 2));
|
|
18
|
+
return bundle;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/log.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface InclusionProof {
|
|
2
|
+
leafIndex: number;
|
|
3
|
+
treeSize: number;
|
|
4
|
+
hashes: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function leafHash(data: string): Buffer;
|
|
7
|
+
export declare function rootOf(leafHashes: Buffer[], size?: number): string;
|
|
8
|
+
export declare function inclusionProof(leafHashes: Buffer[], leafIndex: number, treeSize?: number): InclusionProof;
|
|
9
|
+
/** RFC 9162 section 2.1.3.2 verification. Pure function: needs only the leaf hash, proof and claimed root. */
|
|
10
|
+
export declare function verifyInclusion(leaf: Buffer, proof: InclusionProof, rootHex: string): boolean;
|
|
11
|
+
export declare class MerkleLog {
|
|
12
|
+
private hashes;
|
|
13
|
+
private readonly file;
|
|
14
|
+
constructor(file: string);
|
|
15
|
+
get size(): number;
|
|
16
|
+
/** Appends a leaf (an opaque string, typically a canonical JSON envelope). Returns its proof against the new root. */
|
|
17
|
+
append(leaf: string): InclusionProof & {
|
|
18
|
+
rootHash: string;
|
|
19
|
+
};
|
|
20
|
+
root(size?: number): string;
|
|
21
|
+
/** Reads a log file and returns the root at the given size, for auditors holding a copy of the log. */
|
|
22
|
+
static rootFromFile(file: string, size: number): string;
|
|
23
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Append-only Merkle log with RFC 6962 / RFC 9162 hashing and inclusion proofs.
|
|
2
|
+
// Leaves are stored as JSONL so anyone holding the file can recompute the root.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
const h = (...parts) => {
|
|
7
|
+
const c = createHash("sha256");
|
|
8
|
+
for (const p of parts)
|
|
9
|
+
c.update(p);
|
|
10
|
+
return c.digest();
|
|
11
|
+
};
|
|
12
|
+
export function leafHash(data) {
|
|
13
|
+
return h(Buffer.from([0x00]), Buffer.from(data));
|
|
14
|
+
}
|
|
15
|
+
const nodeHash = (l, r) => h(Buffer.from([0x01]), l, r);
|
|
16
|
+
/** largest power of two strictly less than n (n >= 2) */
|
|
17
|
+
function split(n) {
|
|
18
|
+
let k = 1;
|
|
19
|
+
while (k * 2 < n)
|
|
20
|
+
k *= 2;
|
|
21
|
+
return k;
|
|
22
|
+
}
|
|
23
|
+
function mth(leaves, lo, hi) {
|
|
24
|
+
const n = hi - lo;
|
|
25
|
+
if (n === 0)
|
|
26
|
+
return createHash("sha256").digest();
|
|
27
|
+
if (n === 1)
|
|
28
|
+
return leaves[lo];
|
|
29
|
+
const k = split(n);
|
|
30
|
+
return nodeHash(mth(leaves, lo, lo + k), mth(leaves, lo + k, hi));
|
|
31
|
+
}
|
|
32
|
+
function path(m, leaves, lo, hi) {
|
|
33
|
+
const n = hi - lo;
|
|
34
|
+
if (n <= 1)
|
|
35
|
+
return [];
|
|
36
|
+
const k = split(n);
|
|
37
|
+
return m < k
|
|
38
|
+
? [...path(m, leaves, lo, lo + k), mth(leaves, lo + k, hi)]
|
|
39
|
+
: [...path(m - k, leaves, lo + k, hi), mth(leaves, lo, lo + k)];
|
|
40
|
+
}
|
|
41
|
+
export function rootOf(leafHashes, size = leafHashes.length) {
|
|
42
|
+
return mth(leafHashes, 0, size).toString("hex");
|
|
43
|
+
}
|
|
44
|
+
export function inclusionProof(leafHashes, leafIndex, treeSize = leafHashes.length) {
|
|
45
|
+
if (leafIndex < 0 || leafIndex >= treeSize || treeSize > leafHashes.length)
|
|
46
|
+
throw new Error("index out of range");
|
|
47
|
+
return { leafIndex, treeSize, hashes: path(leafIndex, leafHashes, 0, treeSize).map((b) => b.toString("hex")) };
|
|
48
|
+
}
|
|
49
|
+
/** RFC 9162 section 2.1.3.2 verification. Pure function: needs only the leaf hash, proof and claimed root. */
|
|
50
|
+
export function verifyInclusion(leaf, proof, rootHex) {
|
|
51
|
+
let fn = proof.leafIndex;
|
|
52
|
+
let sn = proof.treeSize - 1;
|
|
53
|
+
if (fn < 0 || sn < 0 || fn > sn)
|
|
54
|
+
return false;
|
|
55
|
+
let r = leaf;
|
|
56
|
+
for (const hex of proof.hashes) {
|
|
57
|
+
if (sn === 0)
|
|
58
|
+
return false;
|
|
59
|
+
const p = Buffer.from(hex, "hex");
|
|
60
|
+
if (fn % 2 === 1 || fn === sn) {
|
|
61
|
+
r = nodeHash(p, r);
|
|
62
|
+
if (fn % 2 === 0) {
|
|
63
|
+
while (fn % 2 === 0 && fn !== 0) {
|
|
64
|
+
fn = Math.floor(fn / 2);
|
|
65
|
+
sn = Math.floor(sn / 2);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
r = nodeHash(r, p);
|
|
71
|
+
}
|
|
72
|
+
fn = Math.floor(fn / 2);
|
|
73
|
+
sn = Math.floor(sn / 2);
|
|
74
|
+
}
|
|
75
|
+
return sn === 0 && r.toString("hex") === rootHex;
|
|
76
|
+
}
|
|
77
|
+
export class MerkleLog {
|
|
78
|
+
hashes = [];
|
|
79
|
+
file;
|
|
80
|
+
constructor(file) {
|
|
81
|
+
this.file = file;
|
|
82
|
+
if (existsSync(file)) {
|
|
83
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
84
|
+
if (line.trim())
|
|
85
|
+
this.hashes.push(leafHash(JSON.parse(line)));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
get size() {
|
|
93
|
+
return this.hashes.length;
|
|
94
|
+
}
|
|
95
|
+
/** Appends a leaf (an opaque string, typically a canonical JSON envelope). Returns its proof against the new root. */
|
|
96
|
+
append(leaf) {
|
|
97
|
+
appendFileSync(this.file, JSON.stringify(leaf) + "\n");
|
|
98
|
+
this.hashes.push(leafHash(leaf));
|
|
99
|
+
const treeSize = this.hashes.length;
|
|
100
|
+
return { ...inclusionProof(this.hashes, treeSize - 1, treeSize), rootHash: rootOf(this.hashes, treeSize) };
|
|
101
|
+
}
|
|
102
|
+
root(size = this.size) {
|
|
103
|
+
return rootOf(this.hashes, size);
|
|
104
|
+
}
|
|
105
|
+
/** Reads a log file and returns the root at the given size, for auditors holding a copy of the log. */
|
|
106
|
+
static rootFromFile(file, size) {
|
|
107
|
+
return new MerkleLog(file).root(size);
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface PolicyDecision {
|
|
2
|
+
decision: "allow" | "deny";
|
|
3
|
+
/** ids of policies that determined the decision */
|
|
4
|
+
reasons: string[];
|
|
5
|
+
/** evaluation errors; non-empty always yields deny */
|
|
6
|
+
errors: string[];
|
|
7
|
+
policyDigest: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PolicyRequest {
|
|
10
|
+
agentId: string;
|
|
11
|
+
tool: string;
|
|
12
|
+
/** Cedar context. Numbers must be integers; Cedar has no floats. */
|
|
13
|
+
context: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export declare function policyDigest(policyText: string): string;
|
|
16
|
+
export declare function evaluate(policyText: string, req: PolicyRequest): PolicyDecision;
|