@cygnuxlabs/tracekit 0.0.0-stage → 1.2.2

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.
Files changed (40) hide show
  1. package/README.md +294 -2
  2. package/bin/tracekit.mjs +30 -0
  3. package/dist/v1.d.ts +97 -0
  4. package/dist/v1.js +535 -0
  5. package/dist/v2/adapters/claude-agent-sdk.d.ts +45 -0
  6. package/dist/v2/adapters/claude-agent-sdk.js +142 -0
  7. package/dist/v2/adapters/gate.d.ts +62 -0
  8. package/dist/v2/adapters/gate.js +195 -0
  9. package/dist/v2/adapters/openai-agents.d.ts +85 -0
  10. package/dist/v2/adapters/openai-agents.js +114 -0
  11. package/dist/v2/adapters/vercel-ai.d.ts +51 -0
  12. package/dist/v2/adapters/vercel-ai.js +132 -0
  13. package/dist/v2/client.d.ts +159 -0
  14. package/dist/v2/client.js +749 -0
  15. package/dist/v2/executor.d.ts +49 -0
  16. package/dist/v2/executor.js +57 -0
  17. package/dist/v2/jcs.d.ts +5 -0
  18. package/dist/v2/jcs.js +12 -0
  19. package/dist/v2/launcher.d.ts +10 -0
  20. package/dist/v2/launcher.js +50 -0
  21. package/dist/v2/rpc_schema.d.ts +3 -0
  22. package/dist/v2/rpc_schema.js +43 -0
  23. package/dist/v2/validate.d.ts +6 -0
  24. package/dist/v2/validate.js +70 -0
  25. package/dist/verify/cli.d.ts +1 -0
  26. package/dist/verify/cli.js +46 -0
  27. package/dist/verify/detect.d.ts +41 -0
  28. package/dist/verify/detect.js +289 -0
  29. package/dist/verify/index.d.ts +34 -0
  30. package/dist/verify/index.js +930 -0
  31. package/dist/verify/jcs.d.ts +19 -0
  32. package/dist/verify/jcs.js +154 -0
  33. package/dist/verify/primitives.d.ts +62 -0
  34. package/dist/verify/primitives.js +298 -0
  35. package/dist/verify/schema.d.ts +11 -0
  36. package/dist/verify/schema.js +98 -0
  37. package/dist/verify/tracekit.event.v2.json +4588 -0
  38. package/dist/verify/zip.d.ts +17 -0
  39. package/dist/verify/zip.js +145 -0
  40. 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
+ };