@cygnuxlabs/tracekit 0.0.0-stage → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -2
- package/bin/tracekit.mjs +30 -0
- package/dist/v1.d.ts +97 -0
- package/dist/v1.js +535 -0
- package/dist/v2/adapters/claude-agent-sdk.d.ts +45 -0
- package/dist/v2/adapters/claude-agent-sdk.js +142 -0
- package/dist/v2/adapters/gate.d.ts +62 -0
- package/dist/v2/adapters/gate.js +195 -0
- package/dist/v2/adapters/openai-agents.d.ts +85 -0
- package/dist/v2/adapters/openai-agents.js +114 -0
- package/dist/v2/adapters/vercel-ai.d.ts +51 -0
- package/dist/v2/adapters/vercel-ai.js +132 -0
- package/dist/v2/client.d.ts +159 -0
- package/dist/v2/client.js +749 -0
- package/dist/v2/executor.d.ts +49 -0
- package/dist/v2/executor.js +57 -0
- package/dist/v2/jcs.d.ts +5 -0
- package/dist/v2/jcs.js +12 -0
- package/dist/v2/launcher.d.ts +10 -0
- package/dist/v2/launcher.js +50 -0
- package/dist/v2/rpc_schema.d.ts +3 -0
- package/dist/v2/rpc_schema.js +43 -0
- package/dist/v2/validate.d.ts +6 -0
- package/dist/v2/validate.js +70 -0
- package/dist/verify/cli.d.ts +1 -0
- package/dist/verify/cli.js +46 -0
- package/dist/verify/detect.d.ts +41 -0
- package/dist/verify/detect.js +289 -0
- package/dist/verify/index.d.ts +34 -0
- package/dist/verify/index.js +930 -0
- package/dist/verify/jcs.d.ts +19 -0
- package/dist/verify/jcs.js +154 -0
- package/dist/verify/primitives.d.ts +62 -0
- package/dist/verify/primitives.js +298 -0
- package/dist/verify/schema.d.ts +11 -0
- package/dist/verify/schema.js +98 -0
- package/dist/verify/tracekit.event.v2.json +4588 -0
- package/dist/verify/zip.d.ts +17 -0
- package/dist/verify/zip.js +145 -0
- package/package.json +42 -4
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Agent SDK (TS): every tool call is decided by the signer before it runs and recorded after, as
|
|
3
|
+
* tracekit/integrations/claude_agent_sdk.py does in Python.
|
|
4
|
+
*
|
|
5
|
+
* import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
* import { Client } from "@cygnuxlabs/tracekit/v2";
|
|
7
|
+
* import { tracekitHooks, tracekitSessionStore } from "@cygnuxlabs/tracekit/v2/claude-agent-sdk";
|
|
8
|
+
* const run = await new Client().registerRun("my-agent");
|
|
9
|
+
* query({ prompt, options: { hooks: tracekitHooks(run), sessionStore: tracekitSessionStore(myStore, run) } });
|
|
10
|
+
*
|
|
11
|
+
* The gate is the PreToolUse hook, not `canUseTool`: the CLI skips `canUseTool` for every call its permission mode or
|
|
12
|
+
* allow rules approve, while PreToolUse runs for every call. `deny` blocks the call with the reason, which the model
|
|
13
|
+
* gets as the tool's error result, and the session goes on. `ask` holds the call in the hook until a person decides in
|
|
14
|
+
* the signer, for at most APPROVAL_WAIT_MS, below the hook's own timeout (HOOK_TIMEOUT_S, set on the matcher); a call
|
|
15
|
+
* not approved by then is blocked. An approval with a T2 executor runs the signer's copy of the arguments (the hook's
|
|
16
|
+
* `updatedInput`). PostToolUse and PostToolUseFailure complete the call; SessionEnd closes the run. The tools of an MCP
|
|
17
|
+
* server named SERVER (`tracekit-executor`), whose handlers call a tool executor, are left to the executor.
|
|
18
|
+
* The tools run in the CLI subprocess, so the signer's coding packs apply to them.
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { RPCError, SignerUnavailable } from "../client.js";
|
|
22
|
+
import { SERVER } from "../executor.js";
|
|
23
|
+
import { digest } from "../jcs.js";
|
|
24
|
+
import { BLOCKED, Gate, failure, warn } from "./gate.js";
|
|
25
|
+
export const HOOK_TIMEOUT_S = 600; // the SDK's default (60 s) is too short to wait for a person
|
|
26
|
+
export const APPROVAL_WAIT_MS = 540_000; // below HOOK_TIMEOUT_S, so the hook answers before the CLI gives up on it
|
|
27
|
+
const deny = (why) => ({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } });
|
|
28
|
+
/** The `hooks` option of `query()`. To add hooks of your own, extend the lists. */
|
|
29
|
+
export function tracekitHooks(run) {
|
|
30
|
+
const gate = new Gate(run);
|
|
31
|
+
async function pre(inp, toolUseID) {
|
|
32
|
+
const id = inp.tool_use_id ?? toolUseID;
|
|
33
|
+
if (!id)
|
|
34
|
+
return deny(BLOCKED + "the hook got no tool_use_id"); // never defaulted: a result could not be bound to its decision
|
|
35
|
+
if (String(inp.tool_name ?? "").startsWith(`mcp__${SERVER}__`))
|
|
36
|
+
return {}; // the executor decides and records it (T2)
|
|
37
|
+
const why = await gate.admit(id, inp.tool_name ?? "?", inp.tool_input, { waitMs: APPROVAL_WAIT_MS });
|
|
38
|
+
if (why !== null)
|
|
39
|
+
return deny(why);
|
|
40
|
+
const input = gate.input(id, inp.tool_input);
|
|
41
|
+
return input === inp.tool_input ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: input } };
|
|
42
|
+
}
|
|
43
|
+
async function post(inp, toolUseID) {
|
|
44
|
+
const id = inp.tool_use_id ?? toolUseID, resp = inp.tool_response;
|
|
45
|
+
if (inp.hook_event_name === "PostToolUseFailure")
|
|
46
|
+
await gate.complete(id, "error", { error: String(inp.error ?? "").slice(0, 4096) });
|
|
47
|
+
else
|
|
48
|
+
await gate.complete(id, resp?.is_error || resp?.interrupted ? "error" : "ok", gate.result(id, resp));
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
async function end(inp) {
|
|
52
|
+
try {
|
|
53
|
+
await run.close(String(inp.reason ?? "session end").slice(0, 256));
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
// the session is over either way: the signer closes the run on its idle timeout
|
|
57
|
+
if (!(e instanceof RPCError && e.code === "run_closed"))
|
|
58
|
+
warn(`tracekit: could not close the run: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
return { PreToolUse: [{ hooks: [pre], timeout: HOOK_TIMEOUT_S }], PostToolUse: [{ hooks: [post] }],
|
|
63
|
+
PostToolUseFailure: [{ hooks: [post] }], SessionEnd: [{ hooks: [end] }] };
|
|
64
|
+
}
|
|
65
|
+
/** A SessionStore that commits each saved transcript to the signer (L1): after every append a `state_write` of the
|
|
66
|
+
* transcript's digest, from the digest it had when this process last saw it. A transcript changed in the store
|
|
67
|
+
* between two writes (seen on `load`, when a session resumes) makes the signer record a `state_tamper` gap. */
|
|
68
|
+
export function tracekitSessionStore(store, run) {
|
|
69
|
+
// lean: a new stream per process, so each process that resumes the run opens one (the signer allows 64 per run);
|
|
70
|
+
// reuse one per run if resumes run into that cap
|
|
71
|
+
const stream = "claude-agent-sdk-" + randomUUID().replaceAll("-", "");
|
|
72
|
+
let seq = 0, queue = Promise.resolve();
|
|
73
|
+
// lean: one digest per transcript for the store's life; prune at session end if one process serves many
|
|
74
|
+
const digests = new Map(); // what the store holds now; null once an entry is not canonical JSON
|
|
75
|
+
const acked = new Map(); // what the signer last recorded: the next state_write's prev_digest
|
|
76
|
+
const lost = new Map(); // the state_write whose answer was lost, sent again before the next
|
|
77
|
+
const keyOf = (k) => "claude-agent-sdk:" + k.sessionId + (k.subpath ? "/" + k.subpath : "");
|
|
78
|
+
/** The digest of a transcript after `entries`, from its digest before them; canonical JSON per entry, as a store
|
|
79
|
+
* may give entries back with their keys reordered. */
|
|
80
|
+
const chain = (k, entries, d = digest([])) => {
|
|
81
|
+
try {
|
|
82
|
+
for (const e of entries)
|
|
83
|
+
d = digest([d, e]);
|
|
84
|
+
return d;
|
|
85
|
+
}
|
|
86
|
+
catch (e) { // e.g. a number JSON can't hold: no digest to commit
|
|
87
|
+
warn(`transcript ${k} is not canonical JSON (${failure(e)}); not committed`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const load = async (key) => {
|
|
92
|
+
const entries = await store.load(key), k = keyOf(key), d = chain(k, entries ?? []);
|
|
93
|
+
digests.set(k, d);
|
|
94
|
+
acked.set(k, d);
|
|
95
|
+
return entries;
|
|
96
|
+
};
|
|
97
|
+
/** False when the signer could not be reached: it may have recorded the write, so the same request (request_id and
|
|
98
|
+
* client_seq) goes again before the next one, and a write it recorded never reads as tampering. */
|
|
99
|
+
const write = async (k, req, d) => {
|
|
100
|
+
try {
|
|
101
|
+
await run.call("state_write", req);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
warn(`transcript commitment for ${k} not recorded: ${failure(e)}`);
|
|
105
|
+
if (!(e instanceof SignerUnavailable))
|
|
106
|
+
return true;
|
|
107
|
+
lost.set(k, [req, d]);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
acked.set(k, d);
|
|
111
|
+
return true;
|
|
112
|
+
};
|
|
113
|
+
const append = async (key, entries) => {
|
|
114
|
+
const k = keyOf(key);
|
|
115
|
+
if (!digests.has(k))
|
|
116
|
+
await load(key); // the first write this process makes to it: from what the store holds
|
|
117
|
+
await store.append(key, entries); // never throw after this: the SDK would append the entries again
|
|
118
|
+
const d = digests.get(k) === null ? null : chain(k, entries, digests.get(k));
|
|
119
|
+
digests.set(k, d);
|
|
120
|
+
if (d === null)
|
|
121
|
+
return;
|
|
122
|
+
// one write at a time, so client_seq reaches the signer in order
|
|
123
|
+
await (queue = queue.then(async () => {
|
|
124
|
+
const l = lost.get(k);
|
|
125
|
+
lost.delete(k);
|
|
126
|
+
if (l && !(await write(k, ...l)))
|
|
127
|
+
return; // the signer is still away: the next write commits these entries too
|
|
128
|
+
await write(k, { request_id: randomUUID().replaceAll("-", ""), stream, client_seq: seq++, key: k, value_digest: d,
|
|
129
|
+
prev_digest: acked.get(k) ?? null }, d);
|
|
130
|
+
}));
|
|
131
|
+
};
|
|
132
|
+
return new Proxy(store, {
|
|
133
|
+
get(t, p) {
|
|
134
|
+
if (p === "append")
|
|
135
|
+
return append;
|
|
136
|
+
if (p === "load")
|
|
137
|
+
return load;
|
|
138
|
+
const v = Reflect.get(t, p, t);
|
|
139
|
+
return typeof v === "function" ? v.bind(t) : v;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every framework adapter does with a tool call (04-design §7.3), over one run: decide on the model's arguments,
|
|
3
|
+
* have the signer consume an approval (or agree that none is needed), run the call, complete it against its decision.
|
|
4
|
+
* An approval with a T2 executor makes the consume return the signer's copy of the arguments: the call runs those
|
|
5
|
+
* (`input()`), not the framework's, and completes for them.
|
|
6
|
+
*
|
|
7
|
+
* A signer that cannot be reached when a call is decided: the call runs unrecorded if register_run's `fail_modes` say
|
|
8
|
+
* `open`, else it is refused. Any other failure to decide or approve (a refusal, a run the signer closed after its idle
|
|
9
|
+
* timeout) refuses the call. A failure to record an outcome warns and changes nothing the framework sees.
|
|
10
|
+
*/
|
|
11
|
+
import { type Decision, type Frame, type RunHandle } from "../client.js";
|
|
12
|
+
export declare const BLOCKED = "Tool call blocked by policy: ";
|
|
13
|
+
/** Sets m[k]; drops the oldest entry past MAX_OPEN_CALLS. */
|
|
14
|
+
export declare function keep<K, V>(m: Map<K, V>, k: K, v: V): void;
|
|
15
|
+
/** m[k], removed. */
|
|
16
|
+
export declare function take<K, V>(m: Map<K, V>, k: K): V | undefined;
|
|
17
|
+
export declare const refusal: (ruleIds: string[], reason?: string) => string;
|
|
18
|
+
export declare const failure: (e: unknown) => string;
|
|
19
|
+
/** The run's approvals by tool call id: requested, approved, rejected or expired. */
|
|
20
|
+
export declare function approvalStates(run: RunHandle): Promise<Map<string, string>>;
|
|
21
|
+
export declare class Gate {
|
|
22
|
+
private readonly base;
|
|
23
|
+
private attempts;
|
|
24
|
+
private passed;
|
|
25
|
+
private send;
|
|
26
|
+
private t2;
|
|
27
|
+
private runs;
|
|
28
|
+
constructor(base: RunHandle);
|
|
29
|
+
/** The current run (withRun) while one is set, else the run the adapter was built with. */
|
|
30
|
+
get run(): RunHandle;
|
|
31
|
+
/** The run the call was decided in. */
|
|
32
|
+
runOf(id: string): RunHandle;
|
|
33
|
+
attempt(id: string): number;
|
|
34
|
+
/** The signer's decision for the call's next attempt; a refusal rejects (RPCError). */
|
|
35
|
+
decide(id: string, tool: string, args: unknown): Promise<Decision>;
|
|
36
|
+
/** Null when the call may run now, else the refusal the model gets instead of its result. `d` is the decision made
|
|
37
|
+
* for it earlier in this process, `hint` the approval id the framework carried; `waitMs` holds an `ask` until a
|
|
38
|
+
* person decides, for at most that long. */
|
|
39
|
+
admit(id: string, tool: string, args: unknown, { d, hint, waitMs }?: {
|
|
40
|
+
d?: Decision;
|
|
41
|
+
hint?: string;
|
|
42
|
+
waitMs?: number;
|
|
43
|
+
}): Promise<string | null>;
|
|
44
|
+
/** The arguments a call admit() let through runs with: the signer's copy (T2), else `input`. */
|
|
45
|
+
input(id: string | undefined, input: unknown): unknown;
|
|
46
|
+
/** Records the outcome of a call admit() let through. Never rejects. */
|
|
47
|
+
complete(id: string, status: "ok" | "error", fields?: Frame): Promise<void>;
|
|
48
|
+
/** Runs `fn` for a call admit() let through and completes it; its result or exception reaches the caller unchanged. */
|
|
49
|
+
execute<T>(id: string | undefined, fn: () => T | Promise<T>): Promise<T>;
|
|
50
|
+
/** `complete`'s result for a call admit() let through: the result itself when its decision said `send_result` (an
|
|
51
|
+
* untrusted tool: the signer redacts it, commits to it and indexes its values for provenance) and it fits in a
|
|
52
|
+
* frame, else its digest (for an untrusted tool, the signer then marks the run's index partial). */
|
|
53
|
+
result(id: string, out: unknown): Frame;
|
|
54
|
+
}
|
|
55
|
+
/** A `tool_uses` entry of a model_event: a call the model asked for, run by this process or by the provider. */
|
|
56
|
+
export declare function toolUse(id: string, name: string, input: unknown, byProvider?: boolean): Frame;
|
|
57
|
+
export declare function warn(message: string): void;
|
|
58
|
+
/** `o` without its undefined fields and empty arrays. */
|
|
59
|
+
export declare const defined: (o: Frame) => Frame;
|
|
60
|
+
/** A model response as a model_event (T3: what the model asked for, hosted tool calls included), with the fields
|
|
61
|
+
* `fields()` returns. Never rejects. */
|
|
62
|
+
export declare function recordResponse(run: RunHandle, provider: string, model: string, fields: () => Frame): Promise<void>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every framework adapter does with a tool call (04-design §7.3), over one run: decide on the model's arguments,
|
|
3
|
+
* have the signer consume an approval (or agree that none is needed), run the call, complete it against its decision.
|
|
4
|
+
* An approval with a T2 executor makes the consume return the signer's copy of the arguments: the call runs those
|
|
5
|
+
* (`input()`), not the framework's, and completes for them.
|
|
6
|
+
*
|
|
7
|
+
* A signer that cannot be reached when a call is decided: the call runs unrecorded if register_run's `fail_modes` say
|
|
8
|
+
* `open`, else it is refused. Any other failure to decide or approve (a refusal, a run the signer closed after its idle
|
|
9
|
+
* timeout) refuses the call. A failure to record an outcome warns and changes nothing the framework sees.
|
|
10
|
+
*/
|
|
11
|
+
import { currentRun } from "../client.js";
|
|
12
|
+
import { argsDigest, canonicalize, digest } from "../jcs.js";
|
|
13
|
+
export const BLOCKED = "Tool call blocked by policy: ";
|
|
14
|
+
const MAX_OPEN_CALLS = 4096; // per-call entries kept between a framework's hooks for one call
|
|
15
|
+
const MAX_RESULT = 1 << 19; // bytes of a result sent itself (half the signer's frame); a larger one goes as its digest
|
|
16
|
+
/** Sets m[k]; drops the oldest entry past MAX_OPEN_CALLS. */
|
|
17
|
+
export function keep(m, k, v) {
|
|
18
|
+
// lean: an entry for a call that never reaches its next hook (a run abandoned mid-call) stays until pushed out;
|
|
19
|
+
// drop a run's entries when it closes if long-lived processes hold many
|
|
20
|
+
m.delete(k);
|
|
21
|
+
m.set(k, v);
|
|
22
|
+
if (m.size > MAX_OPEN_CALLS)
|
|
23
|
+
m.delete(m.keys().next().value);
|
|
24
|
+
}
|
|
25
|
+
/** m[k], removed. */
|
|
26
|
+
export function take(m, k) {
|
|
27
|
+
const v = m.get(k);
|
|
28
|
+
m.delete(k);
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
export const refusal = (ruleIds, reason) => BLOCKED + (ruleIds.join(", ") || "deny") + (reason ? `: ${reason}` : "");
|
|
32
|
+
export const failure = (e) => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 4096);
|
|
33
|
+
/** The run's approvals by tool call id: requested, approved, rejected or expired. */
|
|
34
|
+
export async function approvalStates(run) {
|
|
35
|
+
// lean: the first page of approval_list (the signer's default limit); follow `cursor` if runs hold more
|
|
36
|
+
const { approvals } = await run.client.call("approval_list", { run_id: run.runId });
|
|
37
|
+
const states = new Map();
|
|
38
|
+
for (const a of approvals)
|
|
39
|
+
if (!states.has(a.tool_call_id))
|
|
40
|
+
states.set(a.tool_call_id, a.state);
|
|
41
|
+
return states;
|
|
42
|
+
}
|
|
43
|
+
export class Gate {
|
|
44
|
+
base;
|
|
45
|
+
attempts = new Map(); // tool_call_id -> the attempt its next execution is
|
|
46
|
+
passed = new Map(); // tool_call_id -> let through (false: unrecorded, fail open)
|
|
47
|
+
send = new Map(); // tool_call_id -> its decision said send_result (an untrusted tool)
|
|
48
|
+
t2 = new Map(); // tool_call_id -> (tool, the signer's copy of the arguments)
|
|
49
|
+
runs = new Map(); // tool_call_id -> the run it was decided in
|
|
50
|
+
constructor(base) {
|
|
51
|
+
this.base = base;
|
|
52
|
+
}
|
|
53
|
+
/** The current run (withRun) while one is set, else the run the adapter was built with. */
|
|
54
|
+
get run() {
|
|
55
|
+
return currentRun() ?? this.base;
|
|
56
|
+
}
|
|
57
|
+
/** The run the call was decided in. */
|
|
58
|
+
runOf(id) {
|
|
59
|
+
return this.runs.get(id) ?? this.run;
|
|
60
|
+
}
|
|
61
|
+
attempt(id) {
|
|
62
|
+
return this.attempts.get(id) ?? 0;
|
|
63
|
+
}
|
|
64
|
+
/** The signer's decision for the call's next attempt; a refusal rejects (RPCError). */
|
|
65
|
+
decide(id, tool, args) {
|
|
66
|
+
const run = this.run;
|
|
67
|
+
keep(this.runs, id, run);
|
|
68
|
+
return run.decide(id, tool, args, { attempt: this.attempt(id) });
|
|
69
|
+
}
|
|
70
|
+
/** Null when the call may run now, else the refusal the model gets instead of its result. `d` is the decision made
|
|
71
|
+
* for it earlier in this process, `hint` the approval id the framework carried; `waitMs` holds an `ask` until a
|
|
72
|
+
* person decides, for at most that long. */
|
|
73
|
+
async admit(id, tool, args, { d, hint, waitMs } = {}) {
|
|
74
|
+
const attempt = this.attempt(id);
|
|
75
|
+
try {
|
|
76
|
+
d ??= await this.decide(id, tool, args);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
return BLOCKED + `signer refused the call: ${failure(e)}`;
|
|
80
|
+
}
|
|
81
|
+
if (d.unavailable) {
|
|
82
|
+
if (d.decision !== "allow")
|
|
83
|
+
return BLOCKED + d.reason;
|
|
84
|
+
keep(this.passed, id, false);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (d.decision === "deny")
|
|
88
|
+
return refusal(d.rule_ids);
|
|
89
|
+
keep(this.send, id, d.send_result === true);
|
|
90
|
+
try {
|
|
91
|
+
if (d.decision === "ask" && waitMs !== undefined) {
|
|
92
|
+
hint = (await this.runOf(id).approvalRequest(id, { attempt })).approval_id;
|
|
93
|
+
let state = "requested";
|
|
94
|
+
for (const end = Date.now() + waitMs; state === "requested" && Date.now() < end;)
|
|
95
|
+
state = (await this.runOf(id).approvalWait(hint, Math.min(end - Date.now(), 300_000))).state;
|
|
96
|
+
if (state !== "approved")
|
|
97
|
+
return refusal(d.rule_ids, `not approved: ${state}`);
|
|
98
|
+
}
|
|
99
|
+
// every call, allowed or approved: nothing the framework saved vouches that no approval is needed
|
|
100
|
+
const c = await this.runOf(id).call("approval_consume", { tool_call_id: id, attempt, tool, args,
|
|
101
|
+
args_source: typeof args === "string" ? "raw" : "parsed", ...(hint ? { approval_id_hint: hint } : {}) });
|
|
102
|
+
if (!c.ok)
|
|
103
|
+
return refusal(c.rule_ids, c.reason);
|
|
104
|
+
if ("args" in c)
|
|
105
|
+
keep(this.t2, id, [tool, c.args]);
|
|
106
|
+
}
|
|
107
|
+
catch (e) { // no fail mode once decided: a refusal or an approval that can't be had blocks
|
|
108
|
+
return BLOCKED + `signer error: ${failure(e)}`;
|
|
109
|
+
}
|
|
110
|
+
keep(this.passed, id, true);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/** The arguments a call admit() let through runs with: the signer's copy (T2), else `input`. */
|
|
114
|
+
input(id, input) {
|
|
115
|
+
const t2 = id === undefined ? undefined : this.t2.get(id);
|
|
116
|
+
return t2 ? t2[1] : input;
|
|
117
|
+
}
|
|
118
|
+
/** Records the outcome of a call admit() let through. Never rejects. */
|
|
119
|
+
async complete(id, status, fields = {}) {
|
|
120
|
+
const recorded = take(this.passed, id), t2 = take(this.t2, id);
|
|
121
|
+
this.send.delete(id);
|
|
122
|
+
if (recorded === undefined)
|
|
123
|
+
return;
|
|
124
|
+
const attempt = this.attempt(id);
|
|
125
|
+
keep(this.attempts, id, attempt + 1);
|
|
126
|
+
if (recorded)
|
|
127
|
+
await (take(this.runs, id) ?? this.run).complete(id, status, { attempt, ...(t2 ? { args_digest: argsDigest(...t2) } : {}), ...fields });
|
|
128
|
+
}
|
|
129
|
+
/** Runs `fn` for a call admit() let through and completes it; its result or exception reaches the caller unchanged. */
|
|
130
|
+
async execute(id, fn) {
|
|
131
|
+
if (id === undefined || !this.passed.has(id))
|
|
132
|
+
throw new Error(BLOCKED + "the call was not admitted");
|
|
133
|
+
let out;
|
|
134
|
+
try {
|
|
135
|
+
out = await fn();
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
await this.complete(id, "error", { error: failure(e) });
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
await this.complete(id, "ok", this.result(id, out));
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** `complete`'s result for a call admit() let through: the result itself when its decision said `send_result` (an
|
|
145
|
+
* untrusted tool: the signer redacts it, commits to it and indexes its values for provenance) and it fits in a
|
|
146
|
+
* frame, else its digest (for an untrusted tool, the signer then marks the run's index partial). */
|
|
147
|
+
result(id, out) {
|
|
148
|
+
if (!take(this.send, id))
|
|
149
|
+
return commitment(out);
|
|
150
|
+
try {
|
|
151
|
+
const c = canonicalize(out ?? null);
|
|
152
|
+
// lean: a larger result goes as its digest; send it in parts if that matters
|
|
153
|
+
return Buffer.byteLength(c, "utf8") <= MAX_RESULT ? { result: JSON.parse(c) } : commitment(out);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** A `tool_uses` entry of a model_event: a call the model asked for, run by this process or by the provider. */
|
|
161
|
+
export function toolUse(id, name, input, byProvider = false) {
|
|
162
|
+
if (byProvider)
|
|
163
|
+
return { id, name, executed_by: "provider" };
|
|
164
|
+
const args_source = typeof input === "string" ? "raw" : "parsed";
|
|
165
|
+
try {
|
|
166
|
+
return { id, name, executed_by: "client", args_source, args_digest: argsDigest(name, input) };
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return { id, name, executed_by: "client", args_source, args_unparseable: true };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export function warn(message) {
|
|
173
|
+
process.emitWarning(`tracekit: ${message}`, "TracekitWarning");
|
|
174
|
+
}
|
|
175
|
+
/** `o` without its undefined fields and empty arrays. */
|
|
176
|
+
export const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && !(Array.isArray(v) && !v.length)));
|
|
177
|
+
/** A model response as a model_event (T3: what the model asked for, hosted tool calls included), with the fields
|
|
178
|
+
* `fields()` returns. Never rejects. */
|
|
179
|
+
export async function recordResponse(run, provider, model, fields) {
|
|
180
|
+
try {
|
|
181
|
+
await run.modelEvent(String(provider).slice(0, 64), String(model).slice(0, 128), "response", defined(fields()));
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
warn(`model response not recorded: ${failure(e)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** `result` as a digest (a commitment, not the result), or nothing when it is not JSON. */
|
|
188
|
+
function commitment(out) {
|
|
189
|
+
try {
|
|
190
|
+
return { result: digest(out ?? null) };
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Agents SDK (JS): every function tool call is decided by the signer before it runs and recorded after, as
|
|
3
|
+
* tracekit/integrations/openai_agents.py does in Python.
|
|
4
|
+
*
|
|
5
|
+
* import { Agent, Runner, tool } from "@openai/agents";
|
|
6
|
+
* import { Client } from "@cygnuxlabs/tracekit/v2";
|
|
7
|
+
* import { TracekitAgents } from "@cygnuxlabs/tracekit/v2/openai-agents";
|
|
8
|
+
* const tk = new TracekitAgents(await new Client().registerRun("payer"));
|
|
9
|
+
* const agent = new Agent({ name: "payer", model: "gpt-5", tools: [tk.tool(tool, { name: "pay", description, parameters, execute })] });
|
|
10
|
+
* const result = await new Runner().run(agent, "pay acct-42 $15", { context: {} });
|
|
11
|
+
* await tk.recordResponses(result.rawResponses, "gpt-5");
|
|
12
|
+
*
|
|
13
|
+
* `tk.tool(tool, options)` builds the tool with the SDK's own `tool()`; the signer's policy replaces `needsApproval`,
|
|
14
|
+
* and a tool input guardrail (after the app's own) gates the call on the model's raw arguments. `deny` gives the model a
|
|
15
|
+
* refusal as the tool output and the run goes on. `ask` interrupts the run; once a person has decided in the signer,
|
|
16
|
+
* `await tk.applyDecisions(result.state)` approves or rejects the paused calls and the app resumes with
|
|
17
|
+
* `runner.run(agent, result.state)`. The RunState JSON is not authenticated, so it carries the approval id only as a
|
|
18
|
+
* hint in `context.tracekit.approvals`: every call runs only after the signer's `approval_consume` agrees, with the
|
|
19
|
+
* signer's copy of the arguments for an approval with a T2 executor. Leave the
|
|
20
|
+
* runner's `toolExecution.preApprovalInputGuardrails` off: the guardrail consumes the approval.
|
|
21
|
+
*
|
|
22
|
+
* Not gated: hosted tools (web and file search, code interpreter, hosted MCP, hosted shell), shell, apply_patch and
|
|
23
|
+
* computer tools and handoffs run outside function tools; they are recorded (T3) only in the signed model responses
|
|
24
|
+
* that `recordResponses` writes.
|
|
25
|
+
*/
|
|
26
|
+
import type { RunHandle } from "../client.js";
|
|
27
|
+
import { Gate } from "./gate.js";
|
|
28
|
+
type Options = {
|
|
29
|
+
name?: string;
|
|
30
|
+
execute: (input: any, context?: any, details?: any) => unknown;
|
|
31
|
+
inputGuardrails?: unknown[];
|
|
32
|
+
[k: string]: unknown;
|
|
33
|
+
};
|
|
34
|
+
export declare class TracekitAgents {
|
|
35
|
+
readonly gate: Gate;
|
|
36
|
+
private decided;
|
|
37
|
+
constructor(run: RunHandle);
|
|
38
|
+
/** The current run (withRun) while one is set, else the run the adapter was built with. */
|
|
39
|
+
get run(): RunHandle;
|
|
40
|
+
/** `build({...options})` (the SDK's `tool`), gated by the signer. */
|
|
41
|
+
tool<T extends {
|
|
42
|
+
name: string;
|
|
43
|
+
}>(build: (options: any) => T, options: Options): T;
|
|
44
|
+
/** needsApproval: decide; on ask, open an approval and keep its id in the run context as a hint. */
|
|
45
|
+
private ask;
|
|
46
|
+
/** A call decided in needsApproval that the app's own guardrail refused: complete its decision as refused, so it
|
|
47
|
+
* isn't left decided and never completed. */
|
|
48
|
+
private refused;
|
|
49
|
+
private guard;
|
|
50
|
+
/** For a local MCP server (`new MCPServerStdio({ ..., ...tk.mcp("filesystem") })`, SSE or streamable HTTP alike):
|
|
51
|
+
* its `toolInputGuardrails` decide each call as `mcp:<server>/<tool>` and hold an `ask` until a person decides (up
|
|
52
|
+
* to `waitMs`), its `toolOutputGuardrails` complete the call with the server's output. */
|
|
53
|
+
mcp(server: string, { waitMs }?: {
|
|
54
|
+
waitMs?: number | undefined;
|
|
55
|
+
}): {
|
|
56
|
+
toolInputGuardrails: {
|
|
57
|
+
type: string;
|
|
58
|
+
name: string;
|
|
59
|
+
run: ({ toolCall }: any) => Promise<{
|
|
60
|
+
behavior: {
|
|
61
|
+
type: string;
|
|
62
|
+
message?: undefined;
|
|
63
|
+
} | {
|
|
64
|
+
type: string;
|
|
65
|
+
message: string;
|
|
66
|
+
};
|
|
67
|
+
}>;
|
|
68
|
+
}[];
|
|
69
|
+
toolOutputGuardrails: {
|
|
70
|
+
type: string;
|
|
71
|
+
name: string;
|
|
72
|
+
run: ({ toolCall, output }: any) => Promise<{
|
|
73
|
+
behavior: {
|
|
74
|
+
type: string;
|
|
75
|
+
};
|
|
76
|
+
}>;
|
|
77
|
+
}[];
|
|
78
|
+
};
|
|
79
|
+
/** Approve or reject the paused calls of a RunState as the signer's approvers decided; returns the calls still
|
|
80
|
+
* waiting. Resuming with a call approved some other way runs nothing: the signer refuses it. */
|
|
81
|
+
applyDecisions(state: any): Promise<any[]>;
|
|
82
|
+
/** T3: each model response (`result.rawResponses`) as a signed commitment, hosted tool calls included. Never rejects. */
|
|
83
|
+
recordResponses(responses: any[], model?: string): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { digest } from "../jcs.js";
|
|
2
|
+
import { Gate, approvalStates, keep, recordResponse, take, toolUse } from "./gate.js";
|
|
3
|
+
export class TracekitAgents {
|
|
4
|
+
gate;
|
|
5
|
+
decided = new Map(); // call id -> its decision, from needsApproval to the guardrail
|
|
6
|
+
constructor(run) {
|
|
7
|
+
this.gate = new Gate(run);
|
|
8
|
+
}
|
|
9
|
+
/** The current run (withRun) while one is set, else the run the adapter was built with. */
|
|
10
|
+
get run() {
|
|
11
|
+
return this.gate.run;
|
|
12
|
+
}
|
|
13
|
+
/** `build({...options})` (the SDK's `tool`), gated by the signer. */
|
|
14
|
+
tool(build, options) {
|
|
15
|
+
const name = options.name ?? options.execute.name;
|
|
16
|
+
return build({
|
|
17
|
+
...options,
|
|
18
|
+
name,
|
|
19
|
+
needsApproval: (ctx, input, callId) => this.ask(ctx, callId, name, input),
|
|
20
|
+
inputGuardrails: [...(options.inputGuardrails ?? []).map((g) => ({ ...g, run: async (data) => {
|
|
21
|
+
const out = await g.run(data);
|
|
22
|
+
if (["rejectContent", "throwException"].includes(out?.behavior?.type))
|
|
23
|
+
await this.refused(data.toolCall.callId);
|
|
24
|
+
return out;
|
|
25
|
+
} })), { name: "tracekit", run: (data) => this.guard(data, name) }],
|
|
26
|
+
execute: (input, ctx, details) => this.gate.execute(details?.toolCall?.callId, () => options.execute(this.gate.input(details?.toolCall?.callId, input), ctx, details)),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** needsApproval: decide; on ask, open an approval and keep its id in the run context as a hint. */
|
|
30
|
+
async ask(ctx, callId, tool, input) {
|
|
31
|
+
if (!callId)
|
|
32
|
+
return false; // the guardrail decides, and refuses
|
|
33
|
+
let d;
|
|
34
|
+
try {
|
|
35
|
+
d = await this.gate.decide(callId, tool, input);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false; // the guardrail decides again, and refuses
|
|
39
|
+
}
|
|
40
|
+
keep(this.decided, callId, d);
|
|
41
|
+
if (d.decision !== "ask")
|
|
42
|
+
return false; // a deny is refused by the guardrail, before the call runs
|
|
43
|
+
let approval_id;
|
|
44
|
+
try {
|
|
45
|
+
({ approval_id } = await this.gate.runOf(callId).approvalRequest(callId, { attempt: this.gate.attempt(callId) }));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false; // no approval to wait for: the guardrail's approval_consume refuses the call
|
|
49
|
+
}
|
|
50
|
+
const c = ctx?.context;
|
|
51
|
+
if (c && typeof c === "object")
|
|
52
|
+
((c.tracekit ??= {}).approvals ??= {})[callId] = approval_id;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/** A call decided in needsApproval that the app's own guardrail refused: complete its decision as refused, so it
|
|
56
|
+
* isn't left decided and never completed. */
|
|
57
|
+
async refused(callId) {
|
|
58
|
+
const d = take(this.decided, callId);
|
|
59
|
+
if (d && d.decision !== "deny")
|
|
60
|
+
await this.gate.runOf(callId).complete(callId, "error", { attempt: this.gate.attempt(callId), error: "refused by the app's guardrail" });
|
|
61
|
+
}
|
|
62
|
+
async guard({ context, toolCall }, tool) {
|
|
63
|
+
const id = toolCall.callId, hint = context?.context?.tracekit?.approvals?.[id];
|
|
64
|
+
const why = await this.gate.admit(id, tool, toolCall.arguments, { d: take(this.decided, id), hint: typeof hint === "string" ? hint : undefined });
|
|
65
|
+
return { behavior: why === null ? { type: "allow" } : { type: "rejectContent", message: why } };
|
|
66
|
+
}
|
|
67
|
+
/** For a local MCP server (`new MCPServerStdio({ ..., ...tk.mcp("filesystem") })`, SSE or streamable HTTP alike):
|
|
68
|
+
* its `toolInputGuardrails` decide each call as `mcp:<server>/<tool>` and hold an `ask` until a person decides (up
|
|
69
|
+
* to `waitMs`), its `toolOutputGuardrails` complete the call with the server's output. */
|
|
70
|
+
mcp(server, { waitMs = 300_000 } = {}) {
|
|
71
|
+
// with the Agent's mcpConfig.includeServerInToolNames the model sees mcp_<server>__<tool>, the server name made
|
|
72
|
+
// safe as agents-core makes it; `server` must then be the MCP server's own `name`
|
|
73
|
+
const shown = `mcp_${server.replace(/[^A-Za-z0-9_-]/g, "_").replace(/^[_-]+|[_-]+$/g, "") || "server"}__`;
|
|
74
|
+
const name = (tool) => `mcp:${server.replace(/[^A-Za-z0-9_.-]+/g, "_") || "unknown"}/${tool.startsWith(shown) ? tool.slice(shown.length) : tool}`;
|
|
75
|
+
return {
|
|
76
|
+
toolInputGuardrails: [{ type: "tool_input", name: "tracekit", run: async ({ toolCall }) => {
|
|
77
|
+
const why = await this.gate.admit(toolCall.callId, name(toolCall.name), toolCall.arguments, { waitMs });
|
|
78
|
+
return { behavior: why === null ? { type: "allow" } : { type: "rejectContent", message: why } };
|
|
79
|
+
} }],
|
|
80
|
+
// lean: a call the MCP server fails reaches no output guardrail: the signer records it decided, not completed;
|
|
81
|
+
// complete it from the SDK's tool-error event once the SDK exposes one
|
|
82
|
+
toolOutputGuardrails: [{ type: "tool_output", name: "tracekit", run: async ({ toolCall, output }) => {
|
|
83
|
+
await this.gate.complete(toolCall.callId, "ok", this.gate.result(toolCall.callId, output));
|
|
84
|
+
return { behavior: { type: "allow" } };
|
|
85
|
+
} }],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Approve or reject the paused calls of a RunState as the signer's approvers decided; returns the calls still
|
|
89
|
+
* waiting. Resuming with a call approved some other way runs nothing: the signer refuses it. */
|
|
90
|
+
async applyDecisions(state) {
|
|
91
|
+
const states = await approvalStates(this.run), waiting = [];
|
|
92
|
+
for (const item of state.getInterruptions()) {
|
|
93
|
+
const s = states.get(item.rawItem?.callId);
|
|
94
|
+
if (s === "approved")
|
|
95
|
+
state.approve(item);
|
|
96
|
+
else if (s === "rejected" || s === "expired")
|
|
97
|
+
state.reject(item, { message: `Tool call ${s} in Tracekit` });
|
|
98
|
+
else
|
|
99
|
+
waiting.push(item);
|
|
100
|
+
}
|
|
101
|
+
return waiting;
|
|
102
|
+
}
|
|
103
|
+
/** T3: each model response (`result.rawResponses`) as a signed commitment, hosted tool calls included. Never rejects. */
|
|
104
|
+
async recordResponses(responses, model = "") {
|
|
105
|
+
for (const r of responses)
|
|
106
|
+
await recordResponse(this.run, "openai", model, () => ({
|
|
107
|
+
content_digest: digest(r.output),
|
|
108
|
+
usage: r.usage && { input_tokens: r.usage.inputTokens, output_tokens: r.usage.outputTokens },
|
|
109
|
+
// lean: function and hosted tool calls; shell, apply_patch and computer calls are in content_digest only
|
|
110
|
+
tool_uses: r.output.flatMap((i) => (i.type === "function_call" ? [toolUse(i.callId, i.name, i.arguments)]
|
|
111
|
+
: i.type === "hosted_tool_call" && i.id ? [toolUse(i.id, i.name, null, true)] : [])).slice(0, 128),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel AI SDK v7: every tool call is decided by the signer before it runs and recorded after.
|
|
3
|
+
*
|
|
4
|
+
* import { generateText, wrapLanguageModel } from "ai";
|
|
5
|
+
* import { Client } from "@cygnuxlabs/tracekit/v2";
|
|
6
|
+
* import { tracekitAI } from "@cygnuxlabs/tracekit/v2/vercel-ai";
|
|
7
|
+
* const tk = tracekitAI(await new Client().registerRun("assistant"));
|
|
8
|
+
* const model = wrapLanguageModel({ model: openai("gpt-5"), middleware: tk.middleware });
|
|
9
|
+
* const r = await generateText({ model, tools: tk.tools({ pay }), toolApproval: tk.toolApproval, messages });
|
|
10
|
+
* // once a person decided the asks in the signer:
|
|
11
|
+
* messages.push(...r.response.messages, { role: "tool", content: await tk.approvalResponses(r.content) });
|
|
12
|
+
*
|
|
13
|
+
* The same options work for `streamText` and `ToolLoopAgent`. `tk.middleware` (for `wrapLanguageModel`) records each
|
|
14
|
+
* model response as a signed model_event (V3 usage, the tool calls asked for) and keeps each call's raw arguments
|
|
15
|
+
* string by `toolCallId`, so the signer decides on what the model sent. `tk.toolApproval` decides each call: `deny`
|
|
16
|
+
* becomes a `denied` status (the model gets the refusal and the loop goes on), `ask` opens an approval in the signer
|
|
17
|
+
* and becomes `user-approval`. `tk.approvalResponses(parts)` turns the `tool-approval-request` parts the signer's
|
|
18
|
+
* approvers decided into `tool-approval-response` parts to resume with. `tk.tools(tools)` wraps each tool's `execute`:
|
|
19
|
+
* every call, allowed or approved, runs only after the signer's `approval_consume` agrees, so an approval response the
|
|
20
|
+
* client forged or replayed runs nothing, and an approval with a T2 executor runs the signer's copy of the arguments;
|
|
21
|
+
* `experimental_toolApprovalSecret` (the SDK's HMAC over its approval
|
|
22
|
+
* requests) can be set as well and does not replace this. Without `tk.toolApproval` an `ask` is refused.
|
|
23
|
+
*
|
|
24
|
+
* Tools without `execute` and provider-executed tools are not gated; they are recorded in the model responses. A tool
|
|
25
|
+
* executor's tools (`vercelTool`, marked EXECUTOR) are left to the executor, which decides and records them (T2).
|
|
26
|
+
*/
|
|
27
|
+
import type { Frame, RunHandle } from "../client.js";
|
|
28
|
+
export declare function tracekitAI(run: RunHandle): {
|
|
29
|
+
middleware: {
|
|
30
|
+
specificationVersion: "v4";
|
|
31
|
+
wrapGenerate({ doGenerate, model }: any): Promise<any>;
|
|
32
|
+
wrapStream({ doStream, model }: any): Promise<any>;
|
|
33
|
+
};
|
|
34
|
+
toolApproval: ({ toolCall, tools }: any) => Promise<"not-applicable" | {
|
|
35
|
+
type: "denied";
|
|
36
|
+
reason: string;
|
|
37
|
+
} | {
|
|
38
|
+
type: "user-approval";
|
|
39
|
+
reason: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** `toolApproval` that also asks the app's own (a function, or one per tool name): the signer's policy first; the
|
|
42
|
+
* app's answer applies only to a call the policy allows. A call the app denies is completed as refused. */
|
|
43
|
+
toolApprovalWith(app: any): (opts: any) => Promise<any>;
|
|
44
|
+
/** `tools` with each `execute` gated by the signer. With `mcp` (the server's name: `tools(await mcp.tools(),
|
|
45
|
+
* { mcp: "filesystem" })`) each is decided as `mcp:<server>/<tool>`. */
|
|
46
|
+
tools<T extends Record<string, any>>(tools: T, { mcp }?: {
|
|
47
|
+
mcp?: string;
|
|
48
|
+
}): T;
|
|
49
|
+
/** `tool-approval-response` parts for the `tool-approval-request` parts the signer's approvers decided. */
|
|
50
|
+
approvalResponses(parts: any[]): Promise<Frame[]>;
|
|
51
|
+
};
|