@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/verify.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Independent verification of a receipt bundle. Needs only public keys, and optionally a copy of the log.
|
|
2
|
+
import { canonicalize, digestOf, dsseVerify } from "./crypto.js";
|
|
3
|
+
import { delegationValidAt, verifyDelegation } from "./delegation.js";
|
|
4
|
+
import { leafHash, MerkleLog, verifyInclusion } from "./log.js";
|
|
5
|
+
import { RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
|
|
6
|
+
const short = (s) => s.slice(0, 12);
|
|
7
|
+
export function verifyBundle(bundle, opts) {
|
|
8
|
+
const checks = [];
|
|
9
|
+
const add = (name, ok, detail) => {
|
|
10
|
+
checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
|
|
11
|
+
return ok;
|
|
12
|
+
};
|
|
13
|
+
const done = (statement) => ({ ok: checks.every((c) => c.ok), checks, statement });
|
|
14
|
+
const sig = dsseVerify(bundle.envelope, opts.issuerKeys);
|
|
15
|
+
if (!sig.ok) {
|
|
16
|
+
add("receipt signature (issuer key)", false, sig.error);
|
|
17
|
+
return done(null);
|
|
18
|
+
}
|
|
19
|
+
add("receipt signature (issuer key)", true, `keyid ${short(sig.keyid)}`);
|
|
20
|
+
const st = sig.payload;
|
|
21
|
+
if (!add("receipt payload type", bundle.envelope.payloadType === RECEIPT_TYPE && st.predicateType === RECEIPT_PREDICATE_TYPE))
|
|
22
|
+
return done(null);
|
|
23
|
+
const p = st.predicate;
|
|
24
|
+
add("issuer kind is known", p.issuer.kind === "gateway" || p.issuer.kind === "sdk", `${p.issuer.kind}${p.issuer.framework ? ` / ${p.issuer.framework}` : ""}`);
|
|
25
|
+
add("issuer keyid matches signer", p.issuer.keyid === sig.keyid);
|
|
26
|
+
if (p.issuer.kind === "gateway") {
|
|
27
|
+
add("gateway receipt carries a delegation", p.delegation !== undefined);
|
|
28
|
+
add("gateway receipt carries a policy decision", p.policy !== null);
|
|
29
|
+
}
|
|
30
|
+
if (p.delegation) {
|
|
31
|
+
const del = verifyDelegation(p.delegation.envelope, opts.principalKeys);
|
|
32
|
+
add("delegation signature (principal key)", del.ok, del.ok ? `signed by ${short(del.keyid)}` : del.error);
|
|
33
|
+
if (del.ok) {
|
|
34
|
+
const d = del.delegation;
|
|
35
|
+
const principalKeyid = p.principal.provenance === "attested" ? p.principal.keyid : null;
|
|
36
|
+
add("delegation binds principal and agent", d.principal === p.principal.id && d.agent === p.agent.id && del.keyid === principalKeyid);
|
|
37
|
+
add("delegation valid at receipt time", delegationValidAt(d, p.timestamp), `${d.issuedAt} .. ${d.expiresAt}`);
|
|
38
|
+
const inScope = d.scopes.includes(p.tool.name);
|
|
39
|
+
const executed = p.execution.status === "executed" || p.execution.status === "failed";
|
|
40
|
+
add("executed tool within delegated scope", !executed || inScope, inScope ? p.tool.name : `${p.tool.name} not in [${d.scopes.join(", ")}]`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
add("principal is claimed, not attested", p.principal.provenance === "claimed", "no signed delegation in this receipt");
|
|
45
|
+
}
|
|
46
|
+
add("request args digest", digestOf(p.request.args) === p.request.argsDigest && st.subject[0]?.digest.sha256 === p.request.argsDigest);
|
|
47
|
+
if (p.policy) {
|
|
48
|
+
const consistent = p.policy.decision === "allow" ? p.execution.status !== "denied" : p.execution.status === "denied";
|
|
49
|
+
add("policy decision consistent with execution", consistent, `${p.policy.decision} -> ${p.execution.status}`);
|
|
50
|
+
add("no policy errors on an allow", !(p.policy.decision === "allow" && p.policy.errors.length > 0));
|
|
51
|
+
}
|
|
52
|
+
const th = dsseVerify(bundle.treeHead, opts.issuerKeys);
|
|
53
|
+
add("tree head signature", th.ok && bundle.treeHead.payloadType === TREEHEAD_TYPE, th.ok ? undefined : th.error);
|
|
54
|
+
if (th.ok) {
|
|
55
|
+
const head = th.payload;
|
|
56
|
+
add("tree head matches inclusion proof size", head.treeSize === bundle.inclusion.treeSize);
|
|
57
|
+
const included = verifyInclusion(leafHash(canonicalize(bundle.envelope)), bundle.inclusion, head.rootHash);
|
|
58
|
+
add("log inclusion proof", included, `leaf ${bundle.inclusion.leafIndex} of ${bundle.inclusion.treeSize}, root ${short(head.rootHash)}`);
|
|
59
|
+
if (opts.logFile) {
|
|
60
|
+
let root = "";
|
|
61
|
+
try {
|
|
62
|
+
root = MerkleLog.rootFromFile(opts.logFile, head.treeSize);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
add("log file root matches tree head", false, String(e instanceof Error ? e.message : e));
|
|
66
|
+
}
|
|
67
|
+
if (root)
|
|
68
|
+
add("log file root matches tree head", root === head.rootHash, `recomputed ${short(root)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return done(st);
|
|
72
|
+
}
|
|
73
|
+
const ISSUER_NOTE = {
|
|
74
|
+
gateway: "enforced outside the agent's process; the agent could neither skip nor forge this receipt",
|
|
75
|
+
sdk: "self-reported by the agent's own process; tamper-evident after issue, but nothing here was enforced outside the agent",
|
|
76
|
+
};
|
|
77
|
+
/** Human-readable report: checks, then every field with its provenance so the reader knows what was proven vs. claimed. */
|
|
78
|
+
export function formatReport(r) {
|
|
79
|
+
const lines = [];
|
|
80
|
+
for (const c of r.checks)
|
|
81
|
+
lines.push(`${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(r.ok ? "RESULT: VERIFIED" : "RESULT: NOT VERIFIED");
|
|
84
|
+
if (!r.statement)
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
const p = r.statement.predicate;
|
|
87
|
+
lines.push("");
|
|
88
|
+
lines.push(`ISSUER: ${p.issuer.kind}${p.issuer.framework ? ` (${p.issuer.framework})` : ""}, ${ISSUER_NOTE[p.issuer.kind] ?? "unknown issuer kind"}`);
|
|
89
|
+
lines.push("");
|
|
90
|
+
lines.push("field provenance value");
|
|
91
|
+
const row = (f, prov, v) => lines.push(`${f.padEnd(15)} ${prov.padEnd(11)} ${typeof v === "string" ? v : JSON.stringify(v)}`);
|
|
92
|
+
row("principal", p.principal.provenance, p.principal.id ?? "(none)");
|
|
93
|
+
row("agent", p.agent.provenance, p.agent.id);
|
|
94
|
+
if (p.session.id || p.session.toolUseId)
|
|
95
|
+
row("session", p.session.provenance, `${p.session.id ?? "-"} / ${p.session.toolUseId ?? "-"}`);
|
|
96
|
+
row("model", p.model.provenance, p.model.id ?? "(none supplied)");
|
|
97
|
+
row("tool", p.tool.provenance, p.tool.name);
|
|
98
|
+
row("args", p.request.provenance, p.request.args);
|
|
99
|
+
for (const [k, f] of Object.entries(p.facts))
|
|
100
|
+
row(`fact.${k}`, f.provenance, f.value);
|
|
101
|
+
if (p.policy)
|
|
102
|
+
row("policy", p.policy.provenance, `${p.policy.decision} [${p.policy.reasons.join(",")}] policy ${short(p.policy.policyDigest)}`);
|
|
103
|
+
else
|
|
104
|
+
row("policy", "-", "(none evaluated)");
|
|
105
|
+
row("execution", p.execution.provenance, p.execution.status);
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
package/docs/policies.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Writing policies
|
|
2
|
+
|
|
3
|
+
Policies are written in [Cedar](https://www.cedarpolicy.com/), the language AWS uses for AgentCore and Verified Permissions. There is no home-grown policy language here. Every example on this page is executed against the real evaluator in the test suite.
|
|
4
|
+
|
|
5
|
+
## How a tool call becomes a Cedar request
|
|
6
|
+
|
|
7
|
+
For an intercepted call to tool `T` by the agent named in the grant:
|
|
8
|
+
|
|
9
|
+
| Cedar slot | value |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `principal` | `Agent::"<agent id from the grant>"` |
|
|
12
|
+
| `action` | `Action::"<T>"` |
|
|
13
|
+
| `resource` | `Tool::"<T>"` |
|
|
14
|
+
| `context.args` | the call's arguments, exactly as the agent sent them. **Provenance: claimed.** |
|
|
15
|
+
| `context.facts` | results of the gateway's own upstream lookups configured in `facts`. **Provenance: observed.** |
|
|
16
|
+
| `context.grant` | `{ principal, scopes }` from the signed delegation. **Provenance: attested.** |
|
|
17
|
+
|
|
18
|
+
No entity hierarchy or schema is loaded yet, so policies reason about `context` and the three identifiers above.
|
|
19
|
+
|
|
20
|
+
Two things happen before Cedar runs and cannot be overridden by policy:
|
|
21
|
+
|
|
22
|
+
1. If `T` is not in the grant's scopes, the call is denied. No facts are fetched.
|
|
23
|
+
2. Each configured fact for `T` is fetched from upstream. If any lookup fails, the call is denied.
|
|
24
|
+
|
|
25
|
+
## The rules Cedar applies
|
|
26
|
+
|
|
27
|
+
- **Default deny.** With no matching `permit`, the decision is deny.
|
|
28
|
+
- **`forbid` wins.** A matching `forbid` overrides every `permit`.
|
|
29
|
+
- **Errors deny.** If evaluating any policy raises an error, such as a missing attribute or a float, the gateway denies regardless of what other policies said. The errors are recorded in the receipt.
|
|
30
|
+
- **The policy is pinned.** The receipt carries the sha256 of the policy file, so a verifier knows which text produced the decision.
|
|
31
|
+
|
|
32
|
+
## Examples
|
|
33
|
+
|
|
34
|
+
Each of these is a complete, valid policy file.
|
|
35
|
+
|
|
36
|
+
**Permit a read to any agent holding the scope.**
|
|
37
|
+
|
|
38
|
+
```cedar
|
|
39
|
+
permit(principal, action == Action::"customer.lookup", resource);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Cap an amount.** Amounts are integer minor units. 100000 pence is £1,000.
|
|
43
|
+
|
|
44
|
+
```cedar
|
|
45
|
+
permit(principal, action == Action::"stripe.refund", resource)
|
|
46
|
+
when { context.args.amount <= 100000 };
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A call without `amount` is an error, hence a deny. That is the behaviour you want from a payments policy.
|
|
50
|
+
|
|
51
|
+
**Require a fact the gateway fetched, not something the agent asserted.** This is the pattern the whole project exists for.
|
|
52
|
+
|
|
53
|
+
```cedar
|
|
54
|
+
permit(principal, action == Action::"stripe.refund", resource)
|
|
55
|
+
when {
|
|
56
|
+
context.args.amount <= 100000 &&
|
|
57
|
+
context.facts has customer &&
|
|
58
|
+
context.facts.customer.verified == true
|
|
59
|
+
};
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
With the matching `facts` entry in the gateway config, `context.facts.customer` is whatever `customer.lookup` returned for the customer id in the call. An agent that sends `{ "verified": true }` in its arguments changes nothing, because the policy never reads `context.args.verified`.
|
|
63
|
+
|
|
64
|
+
**Restrict to a named agent.**
|
|
65
|
+
|
|
66
|
+
```cedar
|
|
67
|
+
permit(principal == Agent::"support-agent", action == Action::"stripe.refund", resource);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Several tools in one rule.**
|
|
71
|
+
|
|
72
|
+
```cedar
|
|
73
|
+
permit(principal, action in [Action::"customer.lookup", Action::"customer.search"], resource);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**A hard block that no permit can override.**
|
|
77
|
+
|
|
78
|
+
```cedar
|
|
79
|
+
permit(principal, action == Action::"stripe.refund", resource)
|
|
80
|
+
when { context.args.amount <= 100000 };
|
|
81
|
+
|
|
82
|
+
forbid(principal, action == Action::"stripe.refund", resource)
|
|
83
|
+
when { context.facts has customer && context.facts.customer.flagged == true };
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Pattern-match a string argument.**
|
|
87
|
+
|
|
88
|
+
```cedar
|
|
89
|
+
permit(principal, action == Action::"github.merge", resource)
|
|
90
|
+
when { context.args.repo like "acme/*" && context.args.base == "main" };
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**`unless` reads better for exceptions.**
|
|
94
|
+
|
|
95
|
+
```cedar
|
|
96
|
+
permit(principal, action == Action::"stripe.refund", resource)
|
|
97
|
+
unless { context.args.currency != "GBP" };
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Reach the grant itself.**
|
|
101
|
+
|
|
102
|
+
```cedar
|
|
103
|
+
permit(principal, action, resource)
|
|
104
|
+
when { context.grant.principal == "user_456" };
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Gotchas
|
|
108
|
+
|
|
109
|
+
- **Integers only.** `12.50` is not a Cedar value. Send `1250`.
|
|
110
|
+
- **Test `has` before reading an optional attribute.** `context.facts.customer.verified` errors if there is no `customer` fact, and an error is a deny. Sometimes that is what you want. When it is not, guard with `context.facts has customer`.
|
|
111
|
+
- **No schema means no typo protection.** A policy that reads `context.args.ammount` never matches and every refund is denied. Fail-closed hides typos as denials, so test your policies.
|
|
112
|
+
- **Scope is enforced outside Cedar.** You cannot use a policy to grant a tool the delegation did not include.
|
|
113
|
+
|
|
114
|
+
## Testing a policy
|
|
115
|
+
|
|
116
|
+
The evaluator is a pure function, so a policy test is a unit test. See [test/policy.test.ts](../test/policy.test.ts).
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { evaluate } from "../src/policy.ts";
|
|
120
|
+
import { readFileSync } from "node:fs";
|
|
121
|
+
|
|
122
|
+
const policy = readFileSync("policy.cedar", "utf8");
|
|
123
|
+
const d = evaluate(policy, {
|
|
124
|
+
agentId: "support-agent",
|
|
125
|
+
tool: "stripe.refund",
|
|
126
|
+
context: {
|
|
127
|
+
args: { customer_id: "cust_123", amount: 50000 },
|
|
128
|
+
facts: { customer: { verified: true } },
|
|
129
|
+
grant: { principal: "user_456", scopes: ["customer.lookup", "stripe.refund"] },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
// d.decision "allow" | "deny"
|
|
133
|
+
// d.reasons ids of the policies that decided it
|
|
134
|
+
// d.errors evaluation errors, non-empty always means deny
|
|
135
|
+
// d.policyDigest sha256 of the policy text, the same value a receipt will carry
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Write one case per branch of every `when` clause, plus one for the missing-attribute path.
|
package/docs/sdk.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# The interceptor SDK
|
|
2
|
+
|
|
3
|
+
The gateway sees only MCP traffic. The SDK sees whatever the agent framework lets it hook, from inside the agent's own process. It issues the same receipt format, verified by the same command, with one difference a verifier cannot miss: the receipt names its issuer as `sdk`, and every field is `claimed`.
|
|
4
|
+
|
|
5
|
+
| | gateway | SDK |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| runs | as a separate process between agent and tools | inside the agent's process |
|
|
8
|
+
| sees | MCP tool calls only | whatever the framework's hooks expose |
|
|
9
|
+
| can enforce | yes, the call never reaches upstream on deny | only where the hook can block, and only if nobody bypasses the hook |
|
|
10
|
+
| facts | fetched by the gateway itself, `observed` | none; policies see `context.args` only |
|
|
11
|
+
| delegation | required, signed by the principal | none; principal is a config string, `claimed` |
|
|
12
|
+
| a verifier learns | the agent could not have skipped or forged this | the agent's process reported this, and it has not changed since |
|
|
13
|
+
| install | change one line in the host's MCP config | add a hook or wrap a tool function |
|
|
14
|
+
|
|
15
|
+
Use the SDK for reach. Use the gateway for anything that moves money, touches production, or handles personal data. Both write to the same receipt directory and log if you point them there.
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"agentId": "billing-bot",
|
|
22
|
+
"principalId": "user_456",
|
|
23
|
+
"identity": { "keyFile": "keys/app.key" },
|
|
24
|
+
"policyFile": "policy.cedar",
|
|
25
|
+
"receiptsDir": "receipts",
|
|
26
|
+
"logFile": "log.jsonl",
|
|
27
|
+
"framework": "claude-code"
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`policyFile` and `principalId` are optional. Without a policy the SDK records and never denies. Paths resolve relative to the config file. Generate the key with `node src/cli.ts keygen --dir keys --name app`.
|
|
32
|
+
|
|
33
|
+
Policies see `context.args` and an empty `context.facts`. A policy that reads `context.facts` or `context.grant` errors, which is a deny. That is intended: an SDK policy cannot pretend it checked something outside the agent's process.
|
|
34
|
+
|
|
35
|
+
## Claude Code
|
|
36
|
+
|
|
37
|
+
Register the hook command in `.claude/settings.json`. The same command handles all three events.
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"hooks": {
|
|
42
|
+
"PreToolUse": [
|
|
43
|
+
{ "matcher": "mcp__.*|Bash|Write|Edit", "hooks": [{ "type": "command", "command": "node /abs/path/agent-custody/packages/receipts/src/cli.ts hook --config /abs/path/sdk.json" }] }
|
|
44
|
+
],
|
|
45
|
+
"PostToolUse": [
|
|
46
|
+
{ "hooks": [{ "type": "command", "command": "node /abs/path/agent-custody/packages/receipts/src/cli.ts hook --config /abs/path/sdk.json" }] }
|
|
47
|
+
],
|
|
48
|
+
"PostToolUseFailure": [
|
|
49
|
+
{ "hooks": [{ "type": "command", "command": "node /abs/path/agent-custody/packages/receipts/src/cli.ts hook --config /abs/path/sdk.json" }] }
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`AGENT_CUSTODY_CONFIG` works instead of `--config`. Behaviour per event:
|
|
56
|
+
|
|
57
|
+
- **PreToolUse.** Evaluates the policy. On deny, issues a denial receipt and returns `permissionDecision: "deny"` with the receipt id in the reason. On allow, or with no policy, returns no decision, so Claude Code's own permission prompts still apply. The hook never auto-approves.
|
|
58
|
+
- **PostToolUse.** Issues an executed receipt carrying `tool_response`.
|
|
59
|
+
- **PostToolUseFailure.** Issues a failed receipt.
|
|
60
|
+
|
|
61
|
+
Session and tool-use ids from the event are recorded so a receipt can be matched to the transcript. If the user declines a call at the permission prompt, no PostToolUse fires and no receipt is issued for it. Claude Code records that in its own transcript, not here.
|
|
62
|
+
|
|
63
|
+
## Claude Agent SDK, in-process
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
67
|
+
import { loadSdkConfig } from "@agent-custody/receipts";
|
|
68
|
+
import { claudeAgentHooks, createSdkIssuer } from "@agent-custody/receipts/sdk/claude";
|
|
69
|
+
|
|
70
|
+
const issuer = createSdkIssuer(loadSdkConfig("./sdk.json"));
|
|
71
|
+
|
|
72
|
+
for await (const msg of query({
|
|
73
|
+
prompt: "Refund the customer",
|
|
74
|
+
options: { hooks: claudeAgentHooks(issuer) },
|
|
75
|
+
})) {
|
|
76
|
+
// ...
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`claudeAgentHooks(issuer, matcher?)` returns entries for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` with the same behaviour as the command hook. The hook callback receives the same JSON fields, so the handler is shared. This adapter is typed loosely and does not import the SDK package; it has been exercised against the documented hook contract, not against a live `query()` run.
|
|
81
|
+
|
|
82
|
+
## OpenAI Agents SDK (JS)
|
|
83
|
+
|
|
84
|
+
Two adapters in [src/sdk/openai-agents.ts](../src/sdk/openai-agents.ts). Both are tested against the real package with a scripted model and a real `Runner`, no network.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { Agent, Runner } from "@openai/agents";
|
|
88
|
+
import { wrapTools, observeRunner } from "@agent-custody/receipts/sdk/openai-agents";
|
|
89
|
+
|
|
90
|
+
// enforcement + receipts: wrap the tools you hand to the agent
|
|
91
|
+
const agent = new Agent({ name: "billing", tools: wrapTools(issuer, [refundTool, lookupTool]) });
|
|
92
|
+
|
|
93
|
+
// receipts only: attach to the runner's lifecycle events, nothing to wrap, no policy evaluated
|
|
94
|
+
const runner = new Runner();
|
|
95
|
+
observeRunner(issuer, runner);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`wrapTools` wraps each tool's `invoke`. On a policy deny the tool never runs; the model receives the denial text as the tool result, with the receipt id, and the run continues. That matches what a model sees when a human declines a tool. `observeRunner` listens to `agent_tool_start` and `agent_tool_end`, pairs them by call id, and records executed receipts with no policy. Use one or the other for a given tool, not both.
|
|
99
|
+
|
|
100
|
+
## Vercel AI SDK
|
|
101
|
+
|
|
102
|
+
[src/sdk/vercel-ai.ts](../src/sdk/vercel-ai.ts), tested with a real `generateText` loop over a mock model.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { generateText } from "ai";
|
|
106
|
+
import { wrapTools } from "@agent-custody/receipts/sdk/vercel-ai";
|
|
107
|
+
|
|
108
|
+
const result = await generateText({ model, prompt, tools: wrapTools(issuer, tools) });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`wrapTools` returns a new tool set with every `execute` wrapped. Tools without `execute` pass through untouched. On deny it throws `PolicyDeniedError`, which the AI SDK turns into a `tool-error` part that the model sees; the loop continues. The receipt records the `toolCallId`.
|
|
112
|
+
|
|
113
|
+
## LangChain / LangGraph (JS)
|
|
114
|
+
|
|
115
|
+
[src/sdk/langchain.ts](../src/sdk/langchain.ts), tested against real `StructuredTool` invocations.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { tool } from "@langchain/core/tools";
|
|
119
|
+
import { receiptCallbacks, ReceiptCallbackHandler } from "@agent-custody/receipts/sdk/langchain";
|
|
120
|
+
|
|
121
|
+
// receipts only: a callback handler, attach per call or on the whole graph
|
|
122
|
+
await refund.invoke({ customer_id, amount }, receiptCallbacks(issuer));
|
|
123
|
+
const graph = workflow.compile().withConfig({ callbacks: [new ReceiptCallbackHandler(issuer)] });
|
|
124
|
+
|
|
125
|
+
// enforcement: build the tool from issuer.wrap()
|
|
126
|
+
const refund = tool(issuer.wrap("stripe.refund", fn), { name: "stripe.refund", schema });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
LangChain callbacks cannot block a tool, so the handler evaluates no policy; it records what happened, including the `tool_call_id` when one is present, and unwraps `ToolMessage` outputs. For enforcement wrap the function at construction. Do not do both on one tool or it will be recorded twice.
|
|
130
|
+
|
|
131
|
+
## Any other framework: wrap the function
|
|
132
|
+
|
|
133
|
+
Every agent framework ends up calling a function. Wrap it.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { loadSdkConfig } from "@agent-custody/receipts";
|
|
137
|
+
import { createSdkIssuer, PolicyDeniedError } from "@agent-custody/receipts";
|
|
138
|
+
|
|
139
|
+
const issuer = createSdkIssuer(loadSdkConfig("./sdk.json"));
|
|
140
|
+
|
|
141
|
+
const refund = issuer.wrap("stripe.refund", async (args: { customer_id: string; amount: number }) => {
|
|
142
|
+
return stripe.refunds.create({ customer: args.customer_id, amount: args.amount });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await refund({ customer_id: "cust_123", amount: 50000 }); // executed receipt
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (e instanceof PolicyDeniedError) console.log(e.receiptId); // denial receipt, tool never ran
|
|
149
|
+
throw e; // any other error: error receipt, rethrown
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
For finer control use the two primitives `wrap` is built from:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const decision = issuer.decide({ tool, args }); // PolicyDecision | null
|
|
157
|
+
const bundle = issuer.record({ tool, args, model, session }, { status: "executed", result }, decision);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Which adapter enforces
|
|
161
|
+
|
|
162
|
+
| framework | enforce + record | record only |
|
|
163
|
+
| --- | --- | --- |
|
|
164
|
+
| Claude Code | `hook` command, PreToolUse deny | PostToolUse |
|
|
165
|
+
| Claude Agent SDK | `claudeAgentHooks` | same |
|
|
166
|
+
| OpenAI Agents SDK | `wrapTools` | `observeRunner` |
|
|
167
|
+
| Vercel AI SDK | `wrapTools` | wrap with a policy-less issuer |
|
|
168
|
+
| LangChain / LangGraph | `tool(issuer.wrap(...))` | `ReceiptCallbackHandler` |
|
|
169
|
+
| anything else | `issuer.wrap` | `issuer.record` |
|
|
170
|
+
|
|
171
|
+
Record-only adapters evaluate no policy on purpose. A receipt that said "policy: deny" next to "execution: executed" would fail verification, and the verifier would be right: that is not a receipt, that is a finding. Enforce, or observe, but do not pretend.
|
|
172
|
+
|
|
173
|
+
The three framework packages are optional peer dependencies. Each adapter imports only from its own package, so installing none of them costs nothing.
|
|
174
|
+
|
|
175
|
+
## What an SDK receipt is worth
|
|
176
|
+
|
|
177
|
+
A verified SDK receipt establishes that a process holding the application key reported this call, at this time, with these arguments and this result, and that the record has not changed since. It does not establish that the process reported every call, that the arguments are what the tool really received, or that anyone outside the process checked anything. The verifier prints exactly that sentence under `ISSUER`. Keep it in the dashboard too.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Tutorials
|
|
2
|
+
|
|
3
|
+
One runnable example per aspect of the code. Each prints what it is doing, step by step, and ends with `OK`. The test suite runs all of them, so what you read here is what the code does today.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
node examples/01-keys-and-signing.ts
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Suggested reading order is the numbering. Output lands in `examples-out/`, which is gitignored.
|
|
10
|
+
|
|
11
|
+
| # | aspect | file | you will see | source |
|
|
12
|
+
| --- | --- | --- | --- | --- |
|
|
13
|
+
| 01 | identities and signatures | [01-keys-and-signing.ts](../examples/01-keys-and-signing.ts) | key generation, keyids, a DSSE envelope, verification with the public key, a tampered payload rejected | `src/crypto.ts` |
|
|
14
|
+
| 02 | delegated authority | [02-delegation-grant.ts](../examples/02-delegation-grant.ts) | a principal signs a grant, a stranger's key is rejected, validity windows, scopes | `src/delegation.ts` |
|
|
15
|
+
| 03 | policies | [03-policies.ts](../examples/03-policies.ts) | a Cedar policy evaluated against eight calls: reads, limits, gateway facts versus agent claims, forbid, floats, default deny, the policy digest | `src/policy.ts` |
|
|
16
|
+
| 04 | the transparency log | [04-merkle-log.ts](../examples/04-merkle-log.ts) | appends, inclusion proofs, recomputing the root from the file, an edited line detected | `src/log.ts` |
|
|
17
|
+
| 05 | the gateway | [05-gateway.ts](../examples/05-gateway.ts) | an MCP client connects to the gateway over stdio, sees filtered tools, gets one execution and one denial with receipt ids | `src/gateway.ts`, `src/cli.ts` |
|
|
18
|
+
| 06 | verification and auditing | [06-verify-and-audit.ts](../examples/06-verify-and-audit.ts) | the full check list, with and without a log copy, a tampered receipt, an untrusted key, checks as data for CI | `src/verify.ts` |
|
|
19
|
+
| 07 | the in-process SDK | [07-sdk-wrap.ts](../examples/07-sdk-wrap.ts) | wrap a function, allowed and denied and errored calls, the decide/record primitives, an SDK receipt's report | `src/sdk/index.ts` |
|
|
20
|
+
| 08 | Claude Code and Agent SDK hooks | [08-claude-code-hook.ts](../examples/08-claude-code-hook.ts) | the settings.json entry, PreToolUse allow and deny, PostToolUse, the real command over stdin, Agent SDK hooks | `src/sdk/claude.ts` |
|
|
21
|
+
| 09 | OpenAI Agents SDK | [09-openai-agents.ts](../examples/09-openai-agents.ts) | a real Runner with a scripted model, enforcement via wrapped tools, what the model sees on deny, record-only via lifecycle events | `src/sdk/openai-agents.ts` |
|
|
22
|
+
| 10 | Vercel AI SDK | [10-vercel-ai.ts](../examples/10-vercel-ai.ts) | a real generateText loop over the SDK's mock model, a denial as a tool-error part | `src/sdk/vercel-ai.ts` |
|
|
23
|
+
| 11 | LangChain | [11-langchain.ts](../examples/11-langchain.ts) | the callback handler, tool_call ids, enforcement by wrapping the function | `src/sdk/langchain.ts` |
|
|
24
|
+
| 12 | inside a receipt | [12-read-a-receipt.ts](../examples/12-read-a-receipt.ts) | the bundle's three parts, the in-toto statement, every predicate field with its provenance, the tree head | `src/receipt.ts` |
|
|
25
|
+
|
|
26
|
+
## How policies are defined, in one paragraph
|
|
27
|
+
|
|
28
|
+
A policy is a Cedar file. The gateway turns each tool call into a Cedar request: the principal is `Agent::"<agent id from the grant>"`, the action and resource are the tool name, and the context has three parts. `context.args` is what the agent sent and is only ever claimed. `context.facts` is what the gateway fetched itself before deciding, configured per tool in `gateway.json`, and is observed. `context.grant` is the signed delegation and is attested. Nothing matches means deny. A `forbid` beats every `permit`. An evaluation error, such as a missing attribute or a float, is a deny and is written into the receipt. The receipt also carries the sha256 of the policy text, so a verifier knows exactly which policy decided. Example 03 runs one; [policies.md](policies.md) has nine more, each executed by the test suite.
|
|
29
|
+
|
|
30
|
+
## Where each guide goes deeper
|
|
31
|
+
|
|
32
|
+
- [usage.md](usage.md): gateway setup and wiring into hosts
|
|
33
|
+
- [sdk.md](sdk.md): the interceptor and every adapter
|
|
34
|
+
- [policies.md](policies.md): the Cedar mapping, evaluation rules, tested examples, gotchas
|
|
35
|
+
- [verification.md](verification.md): every check and what a verified receipt does and does not prove
|
package/docs/usage.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Usage guide: the gateway
|
|
2
|
+
|
|
3
|
+
This page covers the gateway, the out-of-process producer. For the in-process interceptor that hooks Claude Code, the Claude Agent SDK, or any framework's tool functions, see [sdk.md](sdk.md).
|
|
4
|
+
|
|
5
|
+
## The parts
|
|
6
|
+
|
|
7
|
+
| part | what it is | who controls it |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| principal | the human or organisation on whose authority the agent acts | you |
|
|
10
|
+
| agent | any MCP client: Claude Desktop, Claude Code, a LangGraph node, your own loop | you, but its behaviour is not trusted |
|
|
11
|
+
| gateway | this project, run as an MCP server over stdio | you, holds the gateway signing key |
|
|
12
|
+
| upstream | the real MCP server the agent wants: Stripe, a database, GitHub | the tool provider |
|
|
13
|
+
| grant | a signed statement: principal P lets agent A use tools [..] from T1 to T2 | signed by the principal's key |
|
|
14
|
+
| policy | a Cedar file evaluated on every call | you |
|
|
15
|
+
| receipt bundle | one JSON file per call, signed, with a log inclusion proof | produced by the gateway |
|
|
16
|
+
| log | an append-only JSONL file whose Merkle root every receipt commits to | produced by the gateway |
|
|
17
|
+
|
|
18
|
+
One gateway process serves one delegation grant. That maps cleanly onto "one agent session, spawned per user, with a scoped grant". Run several gateways for several agents.
|
|
19
|
+
|
|
20
|
+
## Setup, step by step
|
|
21
|
+
|
|
22
|
+
All commands run from `packages/receipts`. `node src/cli.ts` works on Node 22 and later without a build step.
|
|
23
|
+
|
|
24
|
+
**1. Generate keys.** One pair for the gateway, one for the principal. Keep the `.key` files private; distribute the `.pub` files to anyone who will verify receipts.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node src/cli.ts keygen --dir ./keys --name gateway
|
|
28
|
+
node src/cli.ts keygen --dir ./keys --name principal
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**2. Issue a grant.** The principal signs which agent may use which tools, and for how long.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
node src/cli.ts grant \
|
|
35
|
+
--key ./keys/principal.key \
|
|
36
|
+
--principal user_456 \
|
|
37
|
+
--agent support-agent \
|
|
38
|
+
--scopes customer.lookup,stripe.refund \
|
|
39
|
+
--ttl-hours 8 \
|
|
40
|
+
--out ./grant.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The gateway refuses to start if the grant is outside its validity window, and every receipt records the grant so a verifier can re-check it.
|
|
44
|
+
|
|
45
|
+
**3. Write a policy.** A Cedar file. Default is deny. See [policies.md](policies.md).
|
|
46
|
+
|
|
47
|
+
```cedar
|
|
48
|
+
permit(principal, action == Action::"customer.lookup", resource);
|
|
49
|
+
|
|
50
|
+
permit(principal, action == Action::"stripe.refund", resource)
|
|
51
|
+
when {
|
|
52
|
+
context.args.amount <= 100000 &&
|
|
53
|
+
context.facts has customer &&
|
|
54
|
+
context.facts.customer.verified == true
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**4. Write the gateway config.** Paths resolve relative to the config file.
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"identity": { "keyFile": "keys/gateway.key" },
|
|
63
|
+
"upstream": { "command": "node", "args": ["/path/to/stripe-mcp-server.js"], "env": { "STRIPE_KEY": "sk_..." } },
|
|
64
|
+
"grantFile": "grant.json",
|
|
65
|
+
"trustedPrincipalKeys": ["keys/principal.pub"],
|
|
66
|
+
"policyFile": "policy.cedar",
|
|
67
|
+
"facts": [
|
|
68
|
+
{
|
|
69
|
+
"name": "customer",
|
|
70
|
+
"tool": "customer.lookup",
|
|
71
|
+
"args": { "customer_id": "$args.customer_id" },
|
|
72
|
+
"forTools": ["stripe.refund"]
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"receiptsDir": "receipts",
|
|
76
|
+
"logFile": "log.jsonl"
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`upstream` is spawned by the gateway exactly as an MCP host would spawn it. `env` is passed through, which is where upstream credentials go. The agent never sees them.
|
|
81
|
+
|
|
82
|
+
`facts` tells the gateway which upstream tool to call before evaluating policy for a given tool. `$args.<key>` copies a value from the intercepted call. The result appears in Cedar as `context.facts.<name>` and in the receipt with its own digest, labelled `observed`. If a fact lookup fails, the call is denied and the receipt says why.
|
|
83
|
+
|
|
84
|
+
**5. Run the gateway.** It speaks MCP on stdin/stdout and logs to stderr only.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
node src/cli.ts gateway --config ./gateway.json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You will not normally run this by hand. The agent host spawns it, as below.
|
|
91
|
+
|
|
92
|
+
## Wiring it into an agent host
|
|
93
|
+
|
|
94
|
+
The gateway is an ordinary MCP server, so any host that can launch a stdio MCP server can use it. Point the host at the gateway instead of at the upstream server.
|
|
95
|
+
|
|
96
|
+
### Claude Desktop
|
|
97
|
+
|
|
98
|
+
In `claude_desktop_config.json`:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"mcpServers": {
|
|
103
|
+
"stripe": {
|
|
104
|
+
"command": "node",
|
|
105
|
+
"args": ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Claude sees only the tools inside the grant's scopes. Every call it makes produces a receipt. Denials come back as tool errors with the receipt id in the text.
|
|
112
|
+
|
|
113
|
+
### Claude Code
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
claude mcp add stripe -- node /abs/path/agent-custody/packages/receipts/src/cli.ts gateway --config /abs/path/gateway.json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Your own agent loop (TypeScript)
|
|
120
|
+
|
|
121
|
+
This is what [scripts/demo.ts](../scripts/demo.ts) does.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
125
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
126
|
+
|
|
127
|
+
const agent = new Client({ name: "my-agent", version: "1.0.0" });
|
|
128
|
+
await agent.connect(new StdioClientTransport({
|
|
129
|
+
command: "node",
|
|
130
|
+
args: ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"],
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const { tools } = await agent.listTools(); // only tools in the grant's scopes
|
|
134
|
+
|
|
135
|
+
const result = await agent.callTool({
|
|
136
|
+
name: "stripe.refund",
|
|
137
|
+
arguments: { customer_id: "cust_123", amount: 50000 },
|
|
138
|
+
_meta: { "agent-custody/model": "claude-fable-5-1" }, // optional, recorded as "claimed"
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const receiptId = result._meta?.["agent-custody/receipt"];
|
|
142
|
+
if (result.isError) {
|
|
143
|
+
// denied by scope or policy, or upstream failed; the text says which, and a receipt exists either way
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Any other MCP client works the same way: Python's `mcp` package, LangGraph's MCP adapters, or the OpenAI Agents SDK MCP support. None of them need to know the gateway is there.
|
|
148
|
+
|
|
149
|
+
## What the agent gets back
|
|
150
|
+
|
|
151
|
+
| outcome | `isError` | content | receipt |
|
|
152
|
+
| --- | --- | --- | --- |
|
|
153
|
+
| executed | as returned by upstream | upstream's content, untouched | `_meta["agent-custody/receipt"]` |
|
|
154
|
+
| upstream returned an error | `true` | upstream's content | same |
|
|
155
|
+
| denied by scope or policy | `true` | `Denied by policy: <reason> (receipt <id>)` | same |
|
|
156
|
+
| upstream unreachable | `true` | `Upstream error: <message> (receipt <id>)` | same |
|
|
157
|
+
|
|
158
|
+
The receipt id is the file name under `receiptsDir`.
|
|
159
|
+
|
|
160
|
+
## Operational notes
|
|
161
|
+
|
|
162
|
+
- **Money is integer minor units.** Cedar has no floating point. A float in `args` that a policy touches is an evaluation error, which is a deny.
|
|
163
|
+
- **The gateway key is the trust root for receipts.** Keep it out of the agent's reach. The upstream credentials in `upstream.env` are likewise never exposed to the agent.
|
|
164
|
+
- **Rotate keys by adding, not replacing.** The verifier accepts a list of gateway keys and principal keys and matches by keyid, so old receipts stay verifiable.
|
|
165
|
+
- **The log is append-only by convention, not enforcement.** Copy it somewhere the operator cannot rewrite, on a schedule. The receipts' tree heads let an auditor check that the copy matches.
|
|
166
|
+
- **stdout is the MCP channel.** Anything the gateway prints goes to stderr. Do not add `console.log` to gateway code paths.
|