@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/policy.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Cedar policy evaluation. Fails closed: any evaluation error is a deny.
|
|
2
|
+
import * as cedar from "@cedar-policy/cedar-wasm/nodejs";
|
|
3
|
+
import { sha256Hex } from "./crypto.js";
|
|
4
|
+
export function policyDigest(policyText) {
|
|
5
|
+
return sha256Hex(policyText);
|
|
6
|
+
}
|
|
7
|
+
export function evaluate(policyText, req) {
|
|
8
|
+
const digest = policyDigest(policyText);
|
|
9
|
+
const answer = cedar.isAuthorized({
|
|
10
|
+
principal: { type: "Agent", id: req.agentId },
|
|
11
|
+
action: { type: "Action", id: req.tool },
|
|
12
|
+
resource: { type: "Tool", id: req.tool },
|
|
13
|
+
context: req.context,
|
|
14
|
+
policies: { staticPolicies: policyText },
|
|
15
|
+
entities: [],
|
|
16
|
+
});
|
|
17
|
+
if (answer.type === "failure") {
|
|
18
|
+
return { decision: "deny", reasons: [], errors: answer.errors.map((e) => e.message), policyDigest: digest };
|
|
19
|
+
}
|
|
20
|
+
const { decision, diagnostics } = answer.response;
|
|
21
|
+
const errors = diagnostics.errors.map((e) => `${e.policyId}: ${e.error.message}`);
|
|
22
|
+
return {
|
|
23
|
+
decision: errors.length > 0 ? "deny" : decision,
|
|
24
|
+
reasons: diagnostics.reason,
|
|
25
|
+
errors,
|
|
26
|
+
policyDigest: digest,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Envelope } from "./crypto.ts";
|
|
2
|
+
import type { InclusionProof } from "./log.ts";
|
|
3
|
+
import type { PolicyDecision } from "./policy.ts";
|
|
4
|
+
export declare const RECEIPT_TYPE = "application/vnd.in-toto+json";
|
|
5
|
+
export declare const RECEIPT_PREDICATE_TYPE = "https://agent-custody.dev/receipt/v0.2";
|
|
6
|
+
export declare const TREEHEAD_TYPE = "application/vnd.agent-custody.treehead+json";
|
|
7
|
+
/**
|
|
8
|
+
* Provenance of a receipt field. This is the honest part of the design.
|
|
9
|
+
* - attested: signed by a key other than the issuer's (today: the principal's delegation key)
|
|
10
|
+
* - observed: the issuer obtained this deterministically, outside the agent's control (gateway only)
|
|
11
|
+
* - claimed: originates from the agent, the model, or the agent's own process, with no independent check
|
|
12
|
+
*/
|
|
13
|
+
export type Provenance = "attested" | "observed" | "claimed";
|
|
14
|
+
/**
|
|
15
|
+
* Who produced the receipt. This is the first thing a verifier should read.
|
|
16
|
+
* - gateway: an out-of-process enforcement point; the agent could neither skip nor forge it
|
|
17
|
+
* - sdk: an interceptor inside the agent's own process; self-reported, tamper-evident after issue but not before
|
|
18
|
+
*/
|
|
19
|
+
export type IssuerKind = "gateway" | "sdk";
|
|
20
|
+
export interface FactRecord {
|
|
21
|
+
tool: string;
|
|
22
|
+
args: Record<string, unknown>;
|
|
23
|
+
value: unknown;
|
|
24
|
+
resultDigest: string;
|
|
25
|
+
provenance: "observed";
|
|
26
|
+
}
|
|
27
|
+
export interface ReceiptPredicate {
|
|
28
|
+
receiptId: string;
|
|
29
|
+
timestamp: string;
|
|
30
|
+
issuer: {
|
|
31
|
+
kind: IssuerKind;
|
|
32
|
+
keyid: string;
|
|
33
|
+
version: string;
|
|
34
|
+
framework?: string;
|
|
35
|
+
};
|
|
36
|
+
principal: {
|
|
37
|
+
id: string;
|
|
38
|
+
keyid: string;
|
|
39
|
+
provenance: "attested";
|
|
40
|
+
} | {
|
|
41
|
+
id: string | null;
|
|
42
|
+
provenance: "claimed";
|
|
43
|
+
};
|
|
44
|
+
agent: {
|
|
45
|
+
id: string;
|
|
46
|
+
provenance: Provenance;
|
|
47
|
+
};
|
|
48
|
+
/** Present on gateway receipts. Absent when the issuer had no signed grant to check against. */
|
|
49
|
+
delegation?: {
|
|
50
|
+
envelope: Envelope;
|
|
51
|
+
provenance: "attested";
|
|
52
|
+
};
|
|
53
|
+
/** Correlation ids from the host, when it supplied any. Never checked, always claimed. */
|
|
54
|
+
session: {
|
|
55
|
+
id: string | null;
|
|
56
|
+
toolUseId: string | null;
|
|
57
|
+
provenance: "claimed";
|
|
58
|
+
};
|
|
59
|
+
model: {
|
|
60
|
+
id: string | null;
|
|
61
|
+
provenance: "claimed";
|
|
62
|
+
};
|
|
63
|
+
tool: {
|
|
64
|
+
name: string;
|
|
65
|
+
provenance: Provenance;
|
|
66
|
+
};
|
|
67
|
+
request: {
|
|
68
|
+
args: Record<string, unknown>;
|
|
69
|
+
argsDigest: string;
|
|
70
|
+
provenance: "claimed";
|
|
71
|
+
};
|
|
72
|
+
facts: Record<string, FactRecord>;
|
|
73
|
+
/** null when the issuer evaluated no policy. */
|
|
74
|
+
policy: (PolicyDecision & {
|
|
75
|
+
provenance: Provenance;
|
|
76
|
+
}) | null;
|
|
77
|
+
execution: {
|
|
78
|
+
status: "executed" | "failed";
|
|
79
|
+
result: unknown;
|
|
80
|
+
resultDigest: string;
|
|
81
|
+
provenance: Provenance;
|
|
82
|
+
} | {
|
|
83
|
+
status: "denied";
|
|
84
|
+
reason: string;
|
|
85
|
+
provenance: Provenance;
|
|
86
|
+
} | {
|
|
87
|
+
status: "error";
|
|
88
|
+
error: string;
|
|
89
|
+
provenance: Provenance;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export interface ReceiptStatement {
|
|
93
|
+
_type: "https://in-toto.io/Statement/v1";
|
|
94
|
+
subject: {
|
|
95
|
+
name: string;
|
|
96
|
+
digest: {
|
|
97
|
+
sha256: string;
|
|
98
|
+
};
|
|
99
|
+
}[];
|
|
100
|
+
predicateType: typeof RECEIPT_PREDICATE_TYPE;
|
|
101
|
+
predicate: ReceiptPredicate;
|
|
102
|
+
}
|
|
103
|
+
export interface TreeHead {
|
|
104
|
+
treeSize: number;
|
|
105
|
+
rootHash: string;
|
|
106
|
+
timestamp: string;
|
|
107
|
+
}
|
|
108
|
+
/** What gets written to disk and handed to a verifier. Self-contained apart from public keys. */
|
|
109
|
+
export interface ReceiptBundle {
|
|
110
|
+
envelope: Envelope;
|
|
111
|
+
treeHead: Envelope;
|
|
112
|
+
inclusion: InclusionProof;
|
|
113
|
+
}
|
|
114
|
+
export declare function buildStatement(p: ReceiptPredicate): ReceiptStatement;
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const RECEIPT_TYPE = "application/vnd.in-toto+json";
|
|
2
|
+
export const RECEIPT_PREDICATE_TYPE = "https://agent-custody.dev/receipt/v0.2";
|
|
3
|
+
export const TREEHEAD_TYPE = "application/vnd.agent-custody.treehead+json";
|
|
4
|
+
export function buildStatement(p) {
|
|
5
|
+
return {
|
|
6
|
+
_type: "https://in-toto.io/Statement/v1",
|
|
7
|
+
subject: [{ name: `tool-call:${p.tool.name}:${p.receiptId}`, digest: { sha256: p.request.argsDigest } }],
|
|
8
|
+
predicateType: RECEIPT_PREDICATE_TYPE,
|
|
9
|
+
predicate: p,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createSdkIssuer, type SdkIssuer } from "./index.ts";
|
|
2
|
+
export interface HookInput {
|
|
3
|
+
hook_event_name: string;
|
|
4
|
+
session_id?: string;
|
|
5
|
+
tool_name: string;
|
|
6
|
+
tool_input?: unknown;
|
|
7
|
+
tool_response?: unknown;
|
|
8
|
+
tool_use_id?: string;
|
|
9
|
+
error?: unknown;
|
|
10
|
+
[k: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface HookOutput {
|
|
13
|
+
continue?: boolean;
|
|
14
|
+
hookSpecificOutput?: {
|
|
15
|
+
hookEventName: string;
|
|
16
|
+
permissionDecision?: "allow" | "deny" | "ask";
|
|
17
|
+
permissionDecisionReason?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* PreToolUse: evaluate policy; on deny, issue a denial receipt and block. On allow or no policy, return no decision,
|
|
22
|
+
* so the host's normal permission flow still applies. This adapter never auto-approves.
|
|
23
|
+
* PostToolUse / PostToolUseFailure: issue the receipt for the completed call.
|
|
24
|
+
*/
|
|
25
|
+
export declare function handleHookEvent(issuer: SdkIssuer, input: HookInput): HookOutput;
|
|
26
|
+
/**
|
|
27
|
+
* Hooks for the Claude Agent SDK's query({ hooks }) option. Register all three events.
|
|
28
|
+
* Typed loosely on purpose so this file does not depend on the SDK package.
|
|
29
|
+
*/
|
|
30
|
+
export declare function claudeAgentHooks(issuer: SdkIssuer, matcher?: string): {
|
|
31
|
+
PreToolUse: ({
|
|
32
|
+
hooks: ((input: unknown) => Promise<HookOutput>)[];
|
|
33
|
+
matcher?: undefined;
|
|
34
|
+
} | {
|
|
35
|
+
matcher: string;
|
|
36
|
+
hooks: ((input: unknown) => Promise<HookOutput>)[];
|
|
37
|
+
})[];
|
|
38
|
+
PostToolUse: ({
|
|
39
|
+
hooks: ((input: unknown) => Promise<HookOutput>)[];
|
|
40
|
+
matcher?: undefined;
|
|
41
|
+
} | {
|
|
42
|
+
matcher: string;
|
|
43
|
+
hooks: ((input: unknown) => Promise<HookOutput>)[];
|
|
44
|
+
})[];
|
|
45
|
+
PostToolUseFailure: ({
|
|
46
|
+
hooks: ((input: unknown) => Promise<HookOutput>)[];
|
|
47
|
+
matcher?: undefined;
|
|
48
|
+
} | {
|
|
49
|
+
matcher: string;
|
|
50
|
+
hooks: ((input: unknown) => Promise<HookOutput>)[];
|
|
51
|
+
})[];
|
|
52
|
+
};
|
|
53
|
+
export { createSdkIssuer };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Adapter for Claude Code hooks (command hooks over stdin/stdout) and the Claude Agent SDK (in-process hooks).
|
|
2
|
+
// Both use the same input and output JSON, so one handler serves both.
|
|
3
|
+
import { createSdkIssuer, receiptIdOf } from "./index.js";
|
|
4
|
+
function toEvent(input) {
|
|
5
|
+
const raw = input.tool_input;
|
|
6
|
+
const args = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : { input: raw ?? null };
|
|
7
|
+
return { tool: input.tool_name, args, session: { id: input.session_id ?? null, toolUseId: input.tool_use_id ?? null } };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* PreToolUse: evaluate policy; on deny, issue a denial receipt and block. On allow or no policy, return no decision,
|
|
11
|
+
* so the host's normal permission flow still applies. This adapter never auto-approves.
|
|
12
|
+
* PostToolUse / PostToolUseFailure: issue the receipt for the completed call.
|
|
13
|
+
*/
|
|
14
|
+
export function handleHookEvent(issuer, input) {
|
|
15
|
+
const ev = toEvent(input);
|
|
16
|
+
switch (input.hook_event_name) {
|
|
17
|
+
case "PreToolUse": {
|
|
18
|
+
const policy = issuer.decide(ev);
|
|
19
|
+
if (policy && policy.decision === "deny") {
|
|
20
|
+
const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
|
|
21
|
+
const bundle = issuer.record(ev, { status: "denied", reason }, policy);
|
|
22
|
+
return {
|
|
23
|
+
continue: true,
|
|
24
|
+
hookSpecificOutput: {
|
|
25
|
+
hookEventName: "PreToolUse",
|
|
26
|
+
permissionDecision: "deny",
|
|
27
|
+
permissionDecisionReason: `agent-custody: ${reason} (receipt ${receiptIdOf(bundle)})`,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
case "PostToolUse":
|
|
34
|
+
issuer.record(ev, { status: "executed", result: input.tool_response ?? null }, issuer.decide(ev));
|
|
35
|
+
return {};
|
|
36
|
+
case "PostToolUseFailure":
|
|
37
|
+
issuer.record(ev, { status: "failed", result: input.error ?? input.tool_response ?? null }, issuer.decide(ev));
|
|
38
|
+
return {};
|
|
39
|
+
default:
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Hooks for the Claude Agent SDK's query({ hooks }) option. Register all three events.
|
|
45
|
+
* Typed loosely on purpose so this file does not depend on the SDK package.
|
|
46
|
+
*/
|
|
47
|
+
export function claudeAgentHooks(issuer, matcher) {
|
|
48
|
+
const cb = async (input) => handleHookEvent(issuer, input);
|
|
49
|
+
const entry = matcher === undefined ? { hooks: [cb] } : { matcher, hooks: [cb] };
|
|
50
|
+
return { PreToolUse: [entry], PostToolUse: [entry], PostToolUseFailure: [entry] };
|
|
51
|
+
}
|
|
52
|
+
export { createSdkIssuer };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SdkConfig } from "../config.ts";
|
|
2
|
+
import { type PolicyDecision } from "../policy.ts";
|
|
3
|
+
import type { ReceiptBundle } from "../receipt.ts";
|
|
4
|
+
export declare const SDK_VERSION = "0.1.0";
|
|
5
|
+
export interface ToolEvent {
|
|
6
|
+
tool: string;
|
|
7
|
+
args: Record<string, unknown>;
|
|
8
|
+
model?: string | null;
|
|
9
|
+
session?: {
|
|
10
|
+
id?: string | null;
|
|
11
|
+
toolUseId?: string | null;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export type Outcome = {
|
|
15
|
+
status: "executed" | "failed";
|
|
16
|
+
result: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
status: "denied";
|
|
19
|
+
reason: string;
|
|
20
|
+
} | {
|
|
21
|
+
status: "error";
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
export interface SdkIssuer {
|
|
25
|
+
agentId: string;
|
|
26
|
+
keyid: string;
|
|
27
|
+
/** Evaluates the configured policy for a call. Returns null when no policy is configured. */
|
|
28
|
+
decide(ev: ToolEvent): PolicyDecision | null;
|
|
29
|
+
/** Issues one receipt for a completed, failed, denied, or errored call. */
|
|
30
|
+
record(ev: ToolEvent, outcome: Outcome, policy?: PolicyDecision | null): ReceiptBundle;
|
|
31
|
+
/** Wraps a tool function: decide, run, record. Throws PolicyDeniedError on deny, after issuing the denial receipt. */
|
|
32
|
+
wrap<A extends Record<string, unknown>, R>(tool: string, fn: (args: A) => R | Promise<R>, meta?: Omit<ToolEvent, "tool" | "args">): (args: A) => Promise<R>;
|
|
33
|
+
}
|
|
34
|
+
export declare class PolicyDeniedError extends Error {
|
|
35
|
+
readonly tool: string;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
readonly receiptId: string;
|
|
38
|
+
constructor(tool: string, reason: string, receiptId: string);
|
|
39
|
+
}
|
|
40
|
+
export declare function createSdkIssuer(cfg: SdkConfig): SdkIssuer;
|
|
41
|
+
export declare function receiptIdOf(bundle: ReceiptBundle): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The interceptor SDK: issues receipts from inside the agent's own process.
|
|
2
|
+
// Everything it records is "claimed", because the issuer shares a process with the agent. The receipt says so.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { digestOf, loadPrivateKey } from "../crypto.js";
|
|
6
|
+
import { createIssuer } from "../issue.js";
|
|
7
|
+
import { evaluate } from "../policy.js";
|
|
8
|
+
export const SDK_VERSION = "0.1.0";
|
|
9
|
+
export class PolicyDeniedError extends Error {
|
|
10
|
+
tool;
|
|
11
|
+
reason;
|
|
12
|
+
receiptId;
|
|
13
|
+
constructor(tool, reason, receiptId) {
|
|
14
|
+
super(`Denied by policy: ${reason} (receipt ${receiptId})`);
|
|
15
|
+
this.name = "PolicyDeniedError";
|
|
16
|
+
this.tool = tool;
|
|
17
|
+
this.reason = reason;
|
|
18
|
+
this.receiptId = receiptId;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function createSdkIssuer(cfg) {
|
|
22
|
+
const key = loadPrivateKey(cfg.identity.keyFile);
|
|
23
|
+
const issuer = createIssuer(key, cfg.receiptsDir, cfg.logFile);
|
|
24
|
+
const policyText = cfg.policyFile ? readFileSync(cfg.policyFile, "utf8") : null;
|
|
25
|
+
const decide = (ev) => policyText === null ? null : evaluate(policyText, { agentId: cfg.agentId, tool: ev.tool, context: { args: ev.args, facts: {} } });
|
|
26
|
+
function record(ev, outcome, policy = null) {
|
|
27
|
+
const execution = outcome.status === "denied"
|
|
28
|
+
? { status: "denied", reason: outcome.reason, provenance: "claimed" }
|
|
29
|
+
: outcome.status === "error"
|
|
30
|
+
? { status: "error", error: outcome.error, provenance: "claimed" }
|
|
31
|
+
: { status: outcome.status, result: outcome.result, resultDigest: digestOf(outcome.result), provenance: "claimed" };
|
|
32
|
+
return issuer.issue({
|
|
33
|
+
receiptId: randomUUID(),
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
issuer: { kind: "sdk", keyid: issuer.keyid, version: SDK_VERSION, ...(cfg.framework ? { framework: cfg.framework } : {}) },
|
|
36
|
+
principal: { id: cfg.principalId ?? null, provenance: "claimed" },
|
|
37
|
+
agent: { id: cfg.agentId, provenance: "claimed" },
|
|
38
|
+
session: { id: ev.session?.id ?? null, toolUseId: ev.session?.toolUseId ?? null, provenance: "claimed" },
|
|
39
|
+
model: { id: ev.model ?? null, provenance: "claimed" },
|
|
40
|
+
tool: { name: ev.tool, provenance: "claimed" },
|
|
41
|
+
request: { args: ev.args, argsDigest: digestOf(ev.args), provenance: "claimed" },
|
|
42
|
+
facts: {},
|
|
43
|
+
policy: policy ? { ...policy, provenance: "claimed" } : null,
|
|
44
|
+
execution,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
agentId: cfg.agentId,
|
|
49
|
+
keyid: issuer.keyid,
|
|
50
|
+
decide,
|
|
51
|
+
record,
|
|
52
|
+
wrap(tool, fn, meta = {}) {
|
|
53
|
+
return async (args) => {
|
|
54
|
+
const ev = { tool, args, ...meta };
|
|
55
|
+
const policy = decide(ev);
|
|
56
|
+
if (policy && policy.decision === "deny") {
|
|
57
|
+
const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
|
|
58
|
+
const bundle = record(ev, { status: "denied", reason }, policy);
|
|
59
|
+
throw new PolicyDeniedError(tool, reason, receiptIdOf(bundle));
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const result = await fn(args);
|
|
63
|
+
record(ev, { status: "executed", result }, policy);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function receiptIdOf(bundle) {
|
|
75
|
+
const st = JSON.parse(Buffer.from(bundle.envelope.payload, "base64").toString());
|
|
76
|
+
return st.predicate.receiptId;
|
|
77
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
|
|
2
|
+
import type { SdkIssuer } from "./index.ts";
|
|
3
|
+
export declare class ReceiptCallbackHandler extends BaseCallbackHandler {
|
|
4
|
+
name: string;
|
|
5
|
+
private readonly issuer;
|
|
6
|
+
private readonly pending;
|
|
7
|
+
constructor(issuer: SdkIssuer);
|
|
8
|
+
handleToolStart(tool: {
|
|
9
|
+
name?: string;
|
|
10
|
+
}, input: string, runId: string, _parentRunId?: string, _tags?: string[], _metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): void;
|
|
11
|
+
handleToolEnd(output: unknown, runId: string): void;
|
|
12
|
+
handleToolError(err: Error, runId: string): void;
|
|
13
|
+
}
|
|
14
|
+
/** Convenience: `tool.invoke(args, receiptCallbacks(issuer))`, or spread into any RunnableConfig. */
|
|
15
|
+
export declare function receiptCallbacks(issuer: SdkIssuer): {
|
|
16
|
+
callbacks: ReceiptCallbackHandler[];
|
|
17
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// LangChain / LangGraph (JS) adapter: a callback handler that issues a receipt for every tool run it observes.
|
|
2
|
+
// Observe-only. LangChain callbacks cannot block a tool, so this handler evaluates no policy. For enforcement,
|
|
3
|
+
// construct the tool with issuer.wrap(): tool(issuer.wrap("name", fn), { name, schema }). Do not combine both on
|
|
4
|
+
// one tool, or it will be recorded twice.
|
|
5
|
+
import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
|
|
6
|
+
function parseArgs(input) {
|
|
7
|
+
try {
|
|
8
|
+
const v = JSON.parse(input);
|
|
9
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : { input: v };
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return { input };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** LangChain hands the handler either the raw return value or a ToolMessage. Record the content either way. */
|
|
16
|
+
function unwrapOutput(output) {
|
|
17
|
+
if (output && typeof output === "object" && "content" in output && "tool_call_id" in output) {
|
|
18
|
+
const content = output.content;
|
|
19
|
+
if (typeof content === "string") {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(content);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return content;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return content;
|
|
28
|
+
}
|
|
29
|
+
return output;
|
|
30
|
+
}
|
|
31
|
+
export class ReceiptCallbackHandler extends BaseCallbackHandler {
|
|
32
|
+
name = "agent-custody";
|
|
33
|
+
issuer;
|
|
34
|
+
pending = new Map();
|
|
35
|
+
constructor(issuer) {
|
|
36
|
+
super();
|
|
37
|
+
this.issuer = issuer;
|
|
38
|
+
}
|
|
39
|
+
handleToolStart(tool, input, runId, _parentRunId, _tags, _metadata, runName, toolCallId) {
|
|
40
|
+
const name = runName ?? tool.name ?? "unknown";
|
|
41
|
+
this.pending.set(runId, { tool: name, args: parseArgs(input), session: { id: null, toolUseId: toolCallId ?? null } });
|
|
42
|
+
}
|
|
43
|
+
handleToolEnd(output, runId) {
|
|
44
|
+
const ev = this.pending.get(runId);
|
|
45
|
+
if (!ev)
|
|
46
|
+
return;
|
|
47
|
+
this.pending.delete(runId);
|
|
48
|
+
this.issuer.record(ev, { status: "executed", result: unwrapOutput(output) }, null);
|
|
49
|
+
}
|
|
50
|
+
handleToolError(err, runId) {
|
|
51
|
+
const ev = this.pending.get(runId);
|
|
52
|
+
if (!ev)
|
|
53
|
+
return;
|
|
54
|
+
this.pending.delete(runId);
|
|
55
|
+
this.issuer.record(ev, { status: "error", error: err.message }, null);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Convenience: `tool.invoke(args, receiptCallbacks(issuer))`, or spread into any RunnableConfig. */
|
|
59
|
+
export function receiptCallbacks(issuer) {
|
|
60
|
+
return { callbacks: [new ReceiptCallbackHandler(issuer)] };
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type SdkIssuer } from "./index.ts";
|
|
2
|
+
type InvokeFn = (runContext: any, input: string, details?: any) => Promise<unknown>;
|
|
3
|
+
export declare function wrapTools<T extends {
|
|
4
|
+
name: string;
|
|
5
|
+
invoke: InvokeFn;
|
|
6
|
+
}>(issuer: SdkIssuer, tools: readonly T[]): T[];
|
|
7
|
+
interface Listenable {
|
|
8
|
+
on(event: any, listener: (...args: any[]) => void): unknown;
|
|
9
|
+
}
|
|
10
|
+
export declare function observeRunner(issuer: SdkIssuer, runner: Listenable): void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// OpenAI Agents SDK (JS) adapter.
|
|
2
|
+
// wrapTools: enforcement + receipts, by wrapping each FunctionTool's invoke(). A denied call never runs; the
|
|
3
|
+
// model receives the denial text as the tool result and the run continues.
|
|
4
|
+
// observeRunner: receipts only, from the runner's agent_tool_start / agent_tool_end events. Cannot block, so it
|
|
5
|
+
// evaluates no policy. Use one or the other per tool, not both.
|
|
6
|
+
// Typed structurally so this file does not import the package.
|
|
7
|
+
import { receiptIdOf } from "./index.js";
|
|
8
|
+
function parseArgs(input) {
|
|
9
|
+
if (!input)
|
|
10
|
+
return {};
|
|
11
|
+
try {
|
|
12
|
+
const v = JSON.parse(input);
|
|
13
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : { input: v };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { input };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function parseResult(result) {
|
|
20
|
+
if (typeof result !== "string")
|
|
21
|
+
return result;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(result);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function wrapTools(issuer, tools) {
|
|
30
|
+
return tools.map((t) => {
|
|
31
|
+
const invoke = async (ctx, input, details) => {
|
|
32
|
+
const ev = { tool: t.name, args: parseArgs(input), session: { id: null, toolUseId: details?.toolCall?.callId ?? null } };
|
|
33
|
+
const policy = issuer.decide(ev);
|
|
34
|
+
if (policy && policy.decision === "deny") {
|
|
35
|
+
const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
|
|
36
|
+
const bundle = issuer.record(ev, { status: "denied", reason }, policy);
|
|
37
|
+
return `Denied by policy: ${reason} (receipt ${receiptIdOf(bundle)})`;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const result = await t.invoke(ctx, input, details);
|
|
41
|
+
issuer.record(ev, { status: "executed", result: parseResult(result) }, policy);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
return { ...t, invoke };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export function observeRunner(issuer, runner) {
|
|
53
|
+
const pending = new Map();
|
|
54
|
+
runner.on("agent_tool_start", (_ctx, _agent, tool, details) => {
|
|
55
|
+
const callId = details?.toolCall?.callId;
|
|
56
|
+
if (!callId)
|
|
57
|
+
return;
|
|
58
|
+
pending.set(callId, { tool: tool.name, args: parseArgs(details?.toolCall?.arguments), session: { id: null, toolUseId: callId } });
|
|
59
|
+
});
|
|
60
|
+
runner.on("agent_tool_end", (_ctx, _agent, tool, result, details) => {
|
|
61
|
+
const callId = details?.toolCall?.callId ?? "";
|
|
62
|
+
const ev = pending.get(callId) ?? { tool: tool.name, args: {}, session: { id: null, toolUseId: callId || null } };
|
|
63
|
+
pending.delete(callId);
|
|
64
|
+
issuer.record(ev, { status: "executed", result: parseResult(result) }, null);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SdkIssuer } from "./index.ts";
|
|
2
|
+
type ExecuteFn = (input: any, options: any) => unknown;
|
|
3
|
+
/** Returns a new ToolSet. Tools without execute (client-side or provider-executed) pass through unchanged. */
|
|
4
|
+
export declare function wrapTools<T extends Record<string, {
|
|
5
|
+
execute?: ExecuteFn;
|
|
6
|
+
}>>(issuer: SdkIssuer, tools: T): T;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Vercel AI SDK adapter: wraps every tool's execute() in a ToolSet. Decides, runs, records.
|
|
2
|
+
// Typed structurally so this file does not import the `ai` package.
|
|
3
|
+
import { PolicyDeniedError, receiptIdOf } from "./index.js";
|
|
4
|
+
/** Returns a new ToolSet. Tools without execute (client-side or provider-executed) pass through unchanged. */
|
|
5
|
+
export function wrapTools(issuer, tools) {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const [name, t] of Object.entries(tools)) {
|
|
8
|
+
const original = t.execute;
|
|
9
|
+
if (!original) {
|
|
10
|
+
out[name] = t;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const execute = async (input, options) => {
|
|
14
|
+
const args = input && typeof input === "object" && !Array.isArray(input) ? input : { input };
|
|
15
|
+
const ev = { tool: name, args, session: { id: null, toolUseId: options?.toolCallId ?? null } };
|
|
16
|
+
const policy = issuer.decide(ev);
|
|
17
|
+
if (policy && policy.decision === "deny") {
|
|
18
|
+
const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
|
|
19
|
+
const bundle = issuer.record(ev, { status: "denied", reason }, policy);
|
|
20
|
+
throw new PolicyDeniedError(name, reason, receiptIdOf(bundle));
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const result = await original(input, options);
|
|
24
|
+
issuer.record(ev, { status: "executed", result }, policy);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
out[name] = { ...t, execute };
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type PublicKeyRef } from "./crypto.ts";
|
|
2
|
+
import { type ReceiptBundle, type ReceiptStatement } from "./receipt.ts";
|
|
3
|
+
export interface Check {
|
|
4
|
+
name: string;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
detail?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface VerifyOptions {
|
|
9
|
+
/** keys trusted to have issued receipts: gateway keys, SDK application keys */
|
|
10
|
+
issuerKeys: PublicKeyRef[];
|
|
11
|
+
principalKeys: PublicKeyRef[];
|
|
12
|
+
/** If given, the root is recomputed from this log file at the receipt's tree size and compared. */
|
|
13
|
+
logFile?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface VerifyResult {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
checks: Check[];
|
|
18
|
+
statement: ReceiptStatement | null;
|
|
19
|
+
}
|
|
20
|
+
export declare function verifyBundle(bundle: ReceiptBundle, opts: VerifyOptions): VerifyResult;
|
|
21
|
+
/** Human-readable report: checks, then every field with its provenance so the reader knows what was proven vs. claimed. */
|
|
22
|
+
export declare function formatReport(r: VerifyResult): string;
|