@writ-agent/sdk 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.
@@ -0,0 +1,312 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapClaudeTool = mapClaudeTool;
4
+ exports.createWritIntegration = createWritIntegration;
5
+ exports.writHooks = writHooks;
6
+ exports.mergeHooks = mergeHooks;
7
+ const client_js_1 = require("./client.js");
8
+ const errors_js_1 = require("./errors.js");
9
+ const output_js_1 = require("./output.js");
10
+ const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
11
+ const READ_TOOLS = new Set(["Read", "Glob", "Grep", "LS", "NotebookRead"]);
12
+ const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
13
+ function asRecord(input) {
14
+ return typeof input === "object" && input !== null && !Array.isArray(input)
15
+ ? { ...input }
16
+ : { input };
17
+ }
18
+ function firstString(args, keys) {
19
+ for (const k of keys) {
20
+ const v = args[k];
21
+ if (typeof v === "string")
22
+ return v;
23
+ }
24
+ return undefined;
25
+ }
26
+ /**
27
+ * Map a Claude tool name + input to writ's policy vocabulary. Kept identical
28
+ * to `writ check --format claude-code` (crates/writ-cli/src/hook.rs):
29
+ * - `Bash` / `PowerShell` → `bash` (with `command`)
30
+ * - `Read` / `Glob` / `Grep` / `LS` / `NotebookRead` → `fs.read`; `Write` / `Edit` / `MultiEdit` / `NotebookEdit` → `fs.write` (with `path`)
31
+ * - `WebFetch` → `http` (with `url`); `WebSearch` → `web.search` (with `query`)
32
+ * - `mcp__<server>__<tool>` → tool `<tool>` with `server: { name: <server> }`
33
+ * - anything else keeps its SDK name.
34
+ * The original input fields are kept; normalized keys are added alongside.
35
+ */
36
+ function mapClaudeTool(toolName, input, mcpServer) {
37
+ const args = asRecord(input);
38
+ if (SHELL_TOOLS.has(toolName)) {
39
+ const command = firstString(args, ["command"]);
40
+ return { tool: "bash", args: command === undefined ? args : { ...args, command } };
41
+ }
42
+ if (READ_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName)) {
43
+ // An explicit string `path` wins; otherwise the tool's native key.
44
+ const path = firstString(args, ["path", "file_path", "notebook_path"]);
45
+ const tool = WRITE_TOOLS.has(toolName) ? "fs.write" : "fs.read";
46
+ return { tool, args: path === undefined ? args : { ...args, path } };
47
+ }
48
+ if (toolName === "WebFetch")
49
+ return { tool: "http", args };
50
+ if (toolName === "WebSearch")
51
+ return { tool: "web.search", args };
52
+ if (toolName.startsWith("mcp__")) {
53
+ const rest = toolName.slice("mcp__".length);
54
+ const sep = rest.indexOf("__");
55
+ if (sep > 0 && sep < rest.length - 2) {
56
+ const serverName = mcpServer?.name ?? rest.slice(0, sep);
57
+ return {
58
+ tool: rest.slice(sep + 2),
59
+ args,
60
+ server: { name: serverName, transport: "unknown" },
61
+ };
62
+ }
63
+ }
64
+ return { tool: toolName, args };
65
+ }
66
+ const MAX_TRACKED = 10_000;
67
+ function preOutput(permissionDecision, reason) {
68
+ return {
69
+ hookSpecificOutput: {
70
+ hookEventName: "PreToolUse",
71
+ permissionDecision,
72
+ permissionDecisionReason: reason,
73
+ },
74
+ };
75
+ }
76
+ function allowReason(d) {
77
+ const rule = d.rule_id ? `rule '${d.rule_id}'` : "policy default";
78
+ return d.decision === "redact" ? `writ: allowed with redaction by ${rule}` : `writ: allowed by ${rule}`;
79
+ }
80
+ function askReason(d, tool) {
81
+ const rule = d.rule_id ? `rule '${d.rule_id}'` : "policy default";
82
+ const where = d.location ? ` (${d.location})` : "";
83
+ const why = d.reason ? `: ${d.reason}` : "";
84
+ return `writ: '${tool}' needs approval by ${rule}${where}${why}`;
85
+ }
86
+ function errorText(err) {
87
+ return err instanceof Error ? err.message : String(err);
88
+ }
89
+ function sameJson(a, b) {
90
+ try {
91
+ return JSON.stringify(a) === JSON.stringify(b);
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Build writ hooks and a `canUseTool` callback for the Claude Agent SDK.
99
+ * Every failure path denies the tool (fail closed): a hook never throws.
100
+ */
101
+ function createWritIntegration(options) {
102
+ const { client } = options;
103
+ const caller = options.caller ?? client.caller ?? { agent: "claude-agent-sdk" };
104
+ const mapTool = options.mapTool ?? mapClaudeTool;
105
+ const onAllow = options.onAllow ?? "allow";
106
+ const dispatched = new Map();
107
+ const pendingAsks = new Map();
108
+ const track = (map, key, value) => {
109
+ if (map.size >= MAX_TRACKED) {
110
+ const oldest = map.keys().next();
111
+ if (oldest.done !== true)
112
+ map.delete(oldest.value);
113
+ }
114
+ map.set(key, value);
115
+ };
116
+ const buildCall = (input, toolUseID) => {
117
+ const mapped = mapTool(input.tool_name, input.tool_input, input.mcp_server);
118
+ const call = {
119
+ session_id: input.session_id || client.sessionId,
120
+ tool: mapped.tool,
121
+ args: mapped.args,
122
+ // Subagent tool calls carry the subagent id as the non-human identity.
123
+ caller: input.agent_id ? { ...caller, non_human_id: input.agent_id } : caller,
124
+ };
125
+ const id = input.tool_use_id || toolUseID;
126
+ if (id)
127
+ call.call_id = id;
128
+ if (mapped.server !== undefined)
129
+ call.server = mapped.server;
130
+ return call;
131
+ };
132
+ const preToolUse = async (input, toolUseID, { signal }) => {
133
+ if (input.hook_event_name !== "PreToolUse")
134
+ return {};
135
+ const key = input.tool_use_id || toolUseID || "";
136
+ try {
137
+ if (signal.aborted)
138
+ return preOutput("deny", "writ: aborted before a decision (fail closed)");
139
+ const call = buildCall(input, toolUseID);
140
+ let decision;
141
+ if (options.approver !== undefined) {
142
+ decision = await client.authorize(call, {
143
+ approver: options.approver,
144
+ ...(options.approvalTimeoutMs !== undefined ? { approvalTimeoutMs: options.approvalTimeoutMs } : {}),
145
+ });
146
+ }
147
+ else {
148
+ decision = await client.decide(call);
149
+ if (decision.decision === "ask" && decision.approval === "required") {
150
+ if (decision.ref === undefined)
151
+ throw new errors_js_1.WritError("protocol", "deferred ask is missing ref");
152
+ if (options.canUseTool !== undefined && key !== "") {
153
+ track(pendingAsks, key, { ref: decision.ref, decision });
154
+ return preOutput("ask", askReason(decision, input.tool_name));
155
+ }
156
+ decision = await client.resolve(decision.ref, false, "adapter:no-approver");
157
+ }
158
+ }
159
+ if (!(0, client_js_1.shouldDispatch)(decision) || decision.ref === undefined) {
160
+ return preOutput("deny", (0, errors_js_1.describeBlock)(decision, input.tool_name));
161
+ }
162
+ if (key === "" && decision.decision === "redact") {
163
+ return preOutput("deny", `writ: redact verdict for '${input.tool_name}' needs a tool_use_id to apply (fail closed)`);
164
+ }
165
+ if (key !== "")
166
+ track(dispatched, key, { ref: decision.ref, decision });
167
+ return onAllow === "passthrough" ? {} : preOutput("allow", allowReason(decision));
168
+ }
169
+ catch (err) {
170
+ return preOutput("deny", `writ: cannot authorize '${input.tool_name}' (fail closed): ${errorText(err)}`);
171
+ }
172
+ };
173
+ const postToolUse = async (input, toolUseID) => {
174
+ if (input.hook_event_name !== "PostToolUse")
175
+ return {};
176
+ const post = input;
177
+ const key = post.tool_use_id || toolUseID || "";
178
+ const tracked = dispatched.get(key);
179
+ if (tracked === undefined) {
180
+ const unresolved = pendingAsks.get(key);
181
+ if (unresolved !== undefined) {
182
+ // The tool ran although its writ ask was never resolved: record the
183
+ // rejection and keep the output away from the model.
184
+ pendingAsks.delete(key);
185
+ await client.resolve(unresolved.ref, false, "adapter:unresolved-ask").catch(() => undefined);
186
+ return {
187
+ systemMessage: `writ: '${post.tool_name}' ran without a resolved writ approval; output withheld`,
188
+ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: output_js_1.WITHHELD_OUTPUT },
189
+ };
190
+ }
191
+ return {};
192
+ }
193
+ dispatched.delete(key);
194
+ const text = (0, output_js_1.outputText)(post.tool_response);
195
+ const redact = tracked.decision.decision === "redact";
196
+ try {
197
+ const recorded = await client.complete(tracked.ref, text === undefined ? { ok: true } : { ok: true, output: text });
198
+ if (!redact || text === undefined)
199
+ return {};
200
+ if (recorded.output === undefined) {
201
+ return {
202
+ systemMessage: "writ: redact verdict but no redacted output was returned; output withheld",
203
+ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: output_js_1.WITHHELD_OUTPUT },
204
+ };
205
+ }
206
+ return {
207
+ hookSpecificOutput: {
208
+ hookEventName: "PostToolUse",
209
+ updatedToolOutput: (0, output_js_1.fromRedacted)(recorded.output, post.tool_response),
210
+ },
211
+ };
212
+ }
213
+ catch (err) {
214
+ const message = `writ: could not record '${post.tool_name}' execution: ${errorText(err)}`;
215
+ if (!redact)
216
+ return { systemMessage: message };
217
+ return {
218
+ systemMessage: `${message}; output withheld`,
219
+ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: output_js_1.WITHHELD_OUTPUT },
220
+ };
221
+ }
222
+ };
223
+ const postToolUseFailure = async (input, toolUseID) => {
224
+ if (input.hook_event_name !== "PostToolUseFailure")
225
+ return {};
226
+ const failed = input;
227
+ const key = failed.tool_use_id || toolUseID || "";
228
+ const tracked = dispatched.get(key);
229
+ if (tracked === undefined)
230
+ return {};
231
+ dispatched.delete(key);
232
+ await client.complete(tracked.ref, { ok: false, output: failed.error }).catch(() => undefined);
233
+ return {};
234
+ };
235
+ const canUseTool = async (toolName, input, opts) => {
236
+ const pending = pendingAsks.get(opts.toolUseID);
237
+ if (pending === undefined) {
238
+ if (options.canUseTool !== undefined) {
239
+ try {
240
+ const res = await options.canUseTool(toolName, input, opts);
241
+ return res ?? { behavior: "deny", message: "writ: permission callback returned no result (fail closed)" };
242
+ }
243
+ catch (err) {
244
+ return { behavior: "deny", message: `writ: permission callback failed (fail closed): ${errorText(err)}` };
245
+ }
246
+ }
247
+ return { behavior: "deny", message: `writ: no approver for '${toolName}' (fail closed)` };
248
+ }
249
+ pendingAsks.delete(opts.toolUseID);
250
+ let approved = false;
251
+ try {
252
+ const res = options.canUseTool !== undefined && !opts.signal.aborted ? await options.canUseTool(toolName, input, opts) : null;
253
+ // writ decided on the original input; an edited input is not what was approved.
254
+ approved = res?.behavior === "allow" && (res.updatedInput === undefined || sameJson(res.updatedInput, input));
255
+ }
256
+ catch {
257
+ approved = false;
258
+ }
259
+ try {
260
+ const decision = await client.resolve(pending.ref, approved, "sdk:canUseTool");
261
+ if (!(0, client_js_1.shouldDispatch)(decision)) {
262
+ return { behavior: "deny", message: (0, errors_js_1.describeBlock)({ ...pending.decision, ...decision }, toolName) };
263
+ }
264
+ track(dispatched, opts.toolUseID, { ref: decision.ref ?? pending.ref, decision });
265
+ // No updatedPermissions: a persistent SDK allow rule would bypass future writ asks.
266
+ return { behavior: "allow", updatedInput: input };
267
+ }
268
+ catch (err) {
269
+ return { behavior: "deny", message: `writ: cannot resolve approval for '${toolName}' (fail closed): ${errorText(err)}` };
270
+ }
271
+ };
272
+ const matcher = (hook) => {
273
+ const m = { hooks: [hook] };
274
+ if (options.matcher !== undefined)
275
+ m.matcher = options.matcher;
276
+ if (options.hookTimeoutSec !== undefined)
277
+ m.timeout = options.hookTimeoutSec;
278
+ return m;
279
+ };
280
+ return {
281
+ hooks: {
282
+ PreToolUse: [matcher(preToolUse)],
283
+ PostToolUse: [matcher(postToolUse)],
284
+ PostToolUseFailure: [matcher(postToolUseFailure)],
285
+ },
286
+ canUseTool,
287
+ preToolUse,
288
+ postToolUse,
289
+ postToolUseFailure,
290
+ };
291
+ }
292
+ /** Just the `hooks` option. Deferred asks need `approver` (or use `createWritIntegration` for `canUseTool`). */
293
+ function writHooks(options) {
294
+ return createWritIntegration(options).hooks;
295
+ }
296
+ /**
297
+ * Merge writ's hooks with your own `hooks` option. writ's matchers come first
298
+ * for each event; yours are kept.
299
+ */
300
+ function mergeHooks(...sets) {
301
+ const out = {};
302
+ for (const set of sets) {
303
+ if (set === undefined)
304
+ continue;
305
+ for (const [event, matchers] of Object.entries(set)) {
306
+ if (matchers === undefined)
307
+ continue;
308
+ out[event] = [...(out[event] ?? []), ...matchers];
309
+ }
310
+ }
311
+ return out;
312
+ }
@@ -0,0 +1,107 @@
1
+ import { type CallerIdentity, type CompleteInput, type CompleteResult, type Decision, type ToolCallInput } from "./protocol.js";
2
+ /** What `writ check` does with an `ask` verdict. */
3
+ export type AskMode = "deny" | "defer";
4
+ export interface WritClientOptions {
5
+ /** Path to the writ binary. Default: `WRIT_BIN`, then `writ` on PATH. A `.js`/`.mjs`/`.cjs` path runs under Node. */
6
+ bin?: string;
7
+ /**
8
+ * Explicit program to spawn instead of locating writ (e.g. `process.execPath`
9
+ * for a test gateway). `args` are placed before writ's own arguments.
10
+ */
11
+ command?: string;
12
+ args?: string[];
13
+ /** `--policy` (default: writ's own default, `./writ.yaml`). */
14
+ policy?: string;
15
+ /** `--ledger` (default: writ's own default, `.writ/ledger.jsonl`). */
16
+ ledger?: string;
17
+ /**
18
+ * `--ask deny` (default, fail closed) or `--ask defer`, which hands the
19
+ * decision to an `approver` callback or the agent's own UI.
20
+ */
21
+ ask?: AskMode;
22
+ /** Per-request timeout in ms (default 30000). A timeout kills the gateway and fails closed. */
23
+ timeoutMs?: number;
24
+ /** Default caller identity for calls that do not set one. */
25
+ caller?: CallerIdentity;
26
+ /** Default session id for calls that do not set one (default: a random UUID per client). */
27
+ sessionId?: string;
28
+ /** Working directory for the gateway process. */
29
+ cwd?: string;
30
+ /** Environment for the gateway process (default: `process.env`). */
31
+ env?: NodeJS.ProcessEnv;
32
+ /** Start a fresh gateway after a crash or timeout (default true). In-flight requests still fail closed. */
33
+ respawn?: boolean;
34
+ /** Receives the gateway's stderr (diagnostics). */
35
+ onStderr?: (text: string) => void;
36
+ /** Longest accepted response line in bytes (default 16 MiB). */
37
+ maxLineBytes?: number;
38
+ }
39
+ /** Input to an `approver` callback for a deferred ask. */
40
+ export interface ApprovalRequest {
41
+ call: ToolCallInput;
42
+ decision: Decision;
43
+ /** Aborted when the ask's `timeout_ms` elapses. */
44
+ signal: AbortSignal;
45
+ }
46
+ export type ApprovalAnswer = boolean | {
47
+ approved: boolean;
48
+ approver?: string;
49
+ };
50
+ /** Obtains a human decision for a deferred ask. Anything but an explicit approval is a denial. */
51
+ export type Approver = (request: ApprovalRequest) => ApprovalAnswer | Promise<ApprovalAnswer>;
52
+ export interface AuthorizeOptions {
53
+ /** Called for a deferred ask (`--ask defer`). Without one, deferred asks are rejected. */
54
+ approver?: Approver;
55
+ /** Upper bound on waiting for the approver when the ask carries no `timeout_ms` (default 5 min). */
56
+ approvalTimeoutMs?: number;
57
+ }
58
+ /** True only when a decision says the tool may run. */
59
+ export declare function shouldDispatch(decision: Decision): boolean;
60
+ /**
61
+ * A long-lived `writ check --stdio` child process speaking protocol v1.
62
+ * Every failure (missing binary, crash, timeout, malformed line, `error`
63
+ * response) rejects with a `WritError`; callers must then not run the tool.
64
+ */
65
+ export declare class WritClient implements AsyncDisposable {
66
+ readonly askMode: AskMode;
67
+ readonly sessionId: string;
68
+ readonly caller: CallerIdentity | undefined;
69
+ private readonly options;
70
+ private readonly timeoutMs;
71
+ private readonly maxLineBytes;
72
+ private proc;
73
+ private queue;
74
+ private buffer;
75
+ private seq;
76
+ private closed;
77
+ private broken;
78
+ private stderrTail;
79
+ constructor(options?: WritClientOptions);
80
+ /** The argv (after the program) passed to writ. */
81
+ gatewayArgs(): string[];
82
+ /** Ask writ for a verdict. Rejects on any gateway failure. */
83
+ decide(call: ToolCallInput): Promise<Decision>;
84
+ /** Resolve a deferred ask. The response is a final decision. */
85
+ resolve(ref: string, approved: boolean, approver?: string): Promise<Decision>;
86
+ /** Record the execution of a dispatched call. For redact, `output` in the result is the redacted text. */
87
+ complete(ref: string, input: CompleteInput): Promise<CompleteResult>;
88
+ /**
89
+ * decide, and for a deferred ask obtain an answer from `approver` and
90
+ * `resolve` it. Returns the final decision; check `shouldDispatch`.
91
+ * A missing, throwing, late or non-approving approver is a denial.
92
+ */
93
+ authorize(call: ToolCallInput, options?: AuthorizeOptions): Promise<Decision>;
94
+ /** Close stdin, wait briefly for writ to exit, then kill it. Idempotent. */
95
+ close(): Promise<void>;
96
+ [Symbol.asyncDispose](): Promise<void>;
97
+ private request;
98
+ private ensureProcess;
99
+ private resolveLaunch;
100
+ private onData;
101
+ private onLine;
102
+ /** Fail every in-flight request and drop the process (fail closed). */
103
+ private poison;
104
+ private failAll;
105
+ /** Keep the event loop alive only while requests are in flight. */
106
+ private setRef;
107
+ }