@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,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PROTOCOL_VERSION = exports.locateWrit = exports.findOnPath = exports.describeBlock = exports.WritUnavailableError = exports.WritTimeoutError = exports.WritProtocolError = exports.WritError = exports.WritBlockedError = exports.guardTools = exports.guard = exports.shouldDispatch = exports.WritClient = void 0;
4
+ var client_js_1 = require("./client.js");
5
+ Object.defineProperty(exports, "WritClient", { enumerable: true, get: function () { return client_js_1.WritClient; } });
6
+ Object.defineProperty(exports, "shouldDispatch", { enumerable: true, get: function () { return client_js_1.shouldDispatch; } });
7
+ var guard_js_1 = require("./guard.js");
8
+ Object.defineProperty(exports, "guard", { enumerable: true, get: function () { return guard_js_1.guard; } });
9
+ Object.defineProperty(exports, "guardTools", { enumerable: true, get: function () { return guard_js_1.guardTools; } });
10
+ var errors_js_1 = require("./errors.js");
11
+ Object.defineProperty(exports, "WritBlockedError", { enumerable: true, get: function () { return errors_js_1.WritBlockedError; } });
12
+ Object.defineProperty(exports, "WritError", { enumerable: true, get: function () { return errors_js_1.WritError; } });
13
+ Object.defineProperty(exports, "WritProtocolError", { enumerable: true, get: function () { return errors_js_1.WritProtocolError; } });
14
+ Object.defineProperty(exports, "WritTimeoutError", { enumerable: true, get: function () { return errors_js_1.WritTimeoutError; } });
15
+ Object.defineProperty(exports, "WritUnavailableError", { enumerable: true, get: function () { return errors_js_1.WritUnavailableError; } });
16
+ Object.defineProperty(exports, "describeBlock", { enumerable: true, get: function () { return errors_js_1.describeBlock; } });
17
+ var locate_js_1 = require("./locate.js");
18
+ Object.defineProperty(exports, "findOnPath", { enumerable: true, get: function () { return locate_js_1.findOnPath; } });
19
+ Object.defineProperty(exports, "locateWrit", { enumerable: true, get: function () { return locate_js_1.locateWrit; } });
20
+ var protocol_js_1 = require("./protocol.js");
21
+ Object.defineProperty(exports, "PROTOCOL_VERSION", { enumerable: true, get: function () { return protocol_js_1.PROTOCOL_VERSION; } });
@@ -0,0 +1,18 @@
1
+ /** A resolved program + leading arguments to spawn. */
2
+ export interface Launch {
3
+ command: string;
4
+ args: string[];
5
+ }
6
+ /**
7
+ * Turn an explicit binary path into a launch. A `.js`/`.mjs`/`.cjs` path runs
8
+ * under the current Node executable (useful for test gateways on Windows,
9
+ * where scripts are not directly executable).
10
+ */
11
+ export declare function launchFor(bin: string): Launch;
12
+ /** Search PATH for `writ` (`writ.exe` / `writ.com` on Windows). */
13
+ export declare function findOnPath(name?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string | undefined;
14
+ /**
15
+ * Locate writ: explicit `bin`, then `WRIT_BIN`, then PATH. Throws
16
+ * `WritUnavailableError` when nothing is found.
17
+ */
18
+ export declare function locateWrit(bin?: string, env?: NodeJS.ProcessEnv): Launch;
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.launchFor = launchFor;
4
+ exports.findOnPath = findOnPath;
5
+ exports.locateWrit = locateWrit;
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ const errors_js_1 = require("./errors.js");
9
+ const SCRIPT_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]);
10
+ function isFile(p) {
11
+ try {
12
+ return (0, node_fs_1.statSync)(p).isFile();
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ /**
19
+ * Turn an explicit binary path into a launch. A `.js`/`.mjs`/`.cjs` path runs
20
+ * under the current Node executable (useful for test gateways on Windows,
21
+ * where scripts are not directly executable).
22
+ */
23
+ function launchFor(bin) {
24
+ const full = (0, node_path_1.isAbsolute)(bin) ? bin : (0, node_path_1.resolve)(bin);
25
+ if (!isFile(full)) {
26
+ throw new errors_js_1.WritUnavailableError(`writ binary not found at '${full}' (fail closed: no tool call will run)`);
27
+ }
28
+ if (SCRIPT_EXTENSIONS.has((0, node_path_1.extname)(full).toLowerCase())) {
29
+ return { command: process.execPath, args: [full] };
30
+ }
31
+ return { command: full, args: [] };
32
+ }
33
+ /** Search PATH for `writ` (`writ.exe` / `writ.com` on Windows). */
34
+ function findOnPath(name = "writ", env = process.env, platform = process.platform) {
35
+ const pathVar = env.PATH ?? env.Path ?? env.path ?? "";
36
+ const dirs = pathVar.split(platform === "win32" ? ";" : node_path_1.delimiter).filter((d) => d.length > 0);
37
+ // Batch files (.cmd/.bat) need a shell to run; they are not accepted.
38
+ const names = platform === "win32" ? [`${name}.exe`, `${name}.com`] : [name];
39
+ for (const dir of dirs) {
40
+ const clean = dir.replace(/^"(.*)"$/, "$1");
41
+ for (const n of names) {
42
+ const candidate = (0, node_path_1.join)(clean, n);
43
+ if (isFile(candidate))
44
+ return candidate;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+ /**
50
+ * Locate writ: explicit `bin`, then `WRIT_BIN`, then PATH. Throws
51
+ * `WritUnavailableError` when nothing is found.
52
+ */
53
+ function locateWrit(bin, env = process.env) {
54
+ if (bin !== undefined && bin !== "")
55
+ return launchFor(bin);
56
+ const fromEnv = env.WRIT_BIN;
57
+ if (fromEnv !== undefined && fromEnv !== "")
58
+ return launchFor(fromEnv);
59
+ const found = findOnPath("writ", env);
60
+ if (found === undefined) {
61
+ throw new errors_js_1.WritUnavailableError("writ binary not found: set WRIT_BIN or put writ on PATH (fail closed: no tool call will run)");
62
+ }
63
+ return { command: found, args: [] };
64
+ }
@@ -0,0 +1,10 @@
1
+ /** Render a tool result as the text writ hashes (and redacts). */
2
+ export declare function outputText(result: unknown): string | undefined;
3
+ /**
4
+ * Map writ's redacted text back onto the original result's shape: strings stay
5
+ * strings; structured results are re-parsed from the redacted JSON when that
6
+ * still parses, otherwise the redacted text itself is returned.
7
+ */
8
+ export declare function fromRedacted(redacted: string, original: unknown): unknown;
9
+ /** Replacement text when a result must be withheld (redaction could not be applied). */
10
+ export declare const WITHHELD_OUTPUT = "[writ: tool output withheld because redaction could not be applied]";
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WITHHELD_OUTPUT = void 0;
4
+ exports.outputText = outputText;
5
+ exports.fromRedacted = fromRedacted;
6
+ /** Render a tool result as the text writ hashes (and redacts). */
7
+ function outputText(result) {
8
+ if (result === undefined)
9
+ return undefined;
10
+ if (typeof result === "string")
11
+ return result;
12
+ if (result instanceof Uint8Array)
13
+ return Buffer.from(result).toString("utf8");
14
+ try {
15
+ const json = JSON.stringify(result);
16
+ return json === undefined ? String(result) : json;
17
+ }
18
+ catch {
19
+ return String(result);
20
+ }
21
+ }
22
+ /**
23
+ * Map writ's redacted text back onto the original result's shape: strings stay
24
+ * strings; structured results are re-parsed from the redacted JSON when that
25
+ * still parses, otherwise the redacted text itself is returned.
26
+ */
27
+ function fromRedacted(redacted, original) {
28
+ if (typeof original === "string" || original === undefined || original instanceof Uint8Array)
29
+ return redacted;
30
+ try {
31
+ return JSON.parse(redacted);
32
+ }
33
+ catch {
34
+ return redacted;
35
+ }
36
+ }
37
+ /** Replacement text when a result must be withheld (redaction could not be applied). */
38
+ exports.WITHHELD_OUTPUT = "[writ: tool output withheld because redaction could not be applied]";
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Wire types for the writ hook gateway (`writ check --stdio`), protocol v1.
3
+ * Source of truth: docs/INTERFACES.md, Contract 6 (and Contract 1 for the
4
+ * `call` envelope). Additive-only: unknown response fields are ignored.
5
+ */
6
+ export declare const PROTOCOL_VERSION: 1;
7
+ /** Who is making the call (Contract 1 `CallerIdentity`). */
8
+ export interface CallerIdentity {
9
+ agent: string;
10
+ agent_version?: string | null;
11
+ user?: string | null;
12
+ non_human_id?: string | null;
13
+ }
14
+ /** Identity of the (MCP) server a call targets (Contract 1 `ServerIdentity`). */
15
+ export interface ServerIdentity {
16
+ name: string;
17
+ /** "stdio" | "sse" | "http", or another transport label. */
18
+ transport: string;
19
+ version?: string | null;
20
+ }
21
+ /** External scanner verdict (Contract 1 `TrustVerdict`). */
22
+ export type TrustVerdict = "verified" | "unverified" | "malicious";
23
+ /**
24
+ * The `call` object of a `decide` request. writ sets `mode = SdkHook` and
25
+ * `captured_at` itself. Credentials must never appear in `args`.
26
+ */
27
+ export interface ToolCallInput {
28
+ /** Optional; writ generates one when omitted. */
29
+ call_id?: string;
30
+ session_id: string;
31
+ tool: string;
32
+ args: Record<string, unknown>;
33
+ /** Defaults to the client's `caller`, then `{ agent: "unknown" }`. */
34
+ caller?: CallerIdentity;
35
+ server?: ServerIdentity | null;
36
+ trust?: TrustVerdict | null;
37
+ }
38
+ export type DecisionKind = "allow" | "deny" | "ask" | "redact";
39
+ /** A validated `decide` or `resolve` response. */
40
+ export interface Decision {
41
+ decision: DecisionKind;
42
+ /** True only for allow / redact / approved ask. */
43
+ dispatch: boolean;
44
+ /** Opaque handle to pass back to `resolve` / `complete`. */
45
+ ref?: string;
46
+ rule_id?: string;
47
+ reason?: string;
48
+ location?: string;
49
+ /** "required" when `--ask defer` deferred the ask to the adapter. */
50
+ approval?: "required";
51
+ irreversible?: boolean;
52
+ timeout_ms?: number;
53
+ /** Redaction patterns (writ applies them on `complete`). */
54
+ patterns?: string[];
55
+ }
56
+ /** A validated `complete` response. */
57
+ export interface CompleteResult {
58
+ recorded: true;
59
+ /** Redacted output; present only for a redact verdict. */
60
+ output?: string;
61
+ }
62
+ export interface CompleteInput {
63
+ ok: boolean;
64
+ exit?: number;
65
+ /** Tool result as text. Hashed into the ledger, never stored. */
66
+ output?: string;
67
+ }
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ /**
3
+ * Wire types for the writ hook gateway (`writ check --stdio`), protocol v1.
4
+ * Source of truth: docs/INTERFACES.md, Contract 6 (and Contract 1 for the
5
+ * `call` envelope). Additive-only: unknown response fields are ignored.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.PROTOCOL_VERSION = void 0;
9
+ exports.PROTOCOL_VERSION = 1;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * writ integration for the Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`).
3
+ *
4
+ * Plugs into `query({ options: { hooks, canUseTool } })`:
5
+ * - `PreToolUse` asks writ for a verdict and returns the SDK's
6
+ * `permissionDecision` (`allow` / `deny` / `ask`).
7
+ * - `PostToolUse` / `PostToolUseFailure` record the execution (`complete`)
8
+ * and, for a redact verdict, replace the tool output via `updatedToolOutput`.
9
+ * - `canUseTool` resolves writ asks that were routed to the SDK's own
10
+ * permission flow.
11
+ *
12
+ * Only SDK *types* are imported; this module has no runtime dependency on the SDK.
13
+ */
14
+ import type { CanUseTool, HookCallback, HookCallbackMatcher, HookEvent } from "@anthropic-ai/claude-agent-sdk";
15
+ import { type Approver, type WritClient } from "./client.js";
16
+ import type { CallerIdentity, ServerIdentity } from "./protocol.js";
17
+ /** A Claude tool use normalized to writ's vocabulary. */
18
+ export interface MappedToolCall {
19
+ tool: string;
20
+ args: Record<string, unknown>;
21
+ server?: ServerIdentity | null;
22
+ }
23
+ /** MCP server provenance as the SDK reports it (`mcp_server` / `mcpServer`). */
24
+ export interface McpProvenance {
25
+ name: string;
26
+ source: string;
27
+ }
28
+ /**
29
+ * Map a Claude tool name + input to writ's policy vocabulary. Kept identical
30
+ * to `writ check --format claude-code` (crates/writ-cli/src/hook.rs):
31
+ * - `Bash` / `PowerShell` → `bash` (with `command`)
32
+ * - `Read` / `Glob` / `Grep` / `LS` / `NotebookRead` → `fs.read`; `Write` / `Edit` / `MultiEdit` / `NotebookEdit` → `fs.write` (with `path`)
33
+ * - `WebFetch` → `http` (with `url`); `WebSearch` → `web.search` (with `query`)
34
+ * - `mcp__<server>__<tool>` → tool `<tool>` with `server: { name: <server> }`
35
+ * - anything else keeps its SDK name.
36
+ * The original input fields are kept; normalized keys are added alongside.
37
+ */
38
+ export declare function mapClaudeTool(toolName: string, input: unknown, mcpServer?: McpProvenance): MappedToolCall;
39
+ export interface WritClaudeOptions {
40
+ client: WritClient;
41
+ /**
42
+ * Decides deferred asks inline, inside the PreToolUse hook (client must use
43
+ * `ask: "defer"`). Takes precedence over `canUseTool`.
44
+ */
45
+ approver?: Approver;
46
+ /**
47
+ * Your own SDK permission callback. With `ask: "defer"` and no `approver`,
48
+ * writ asks are handed to the SDK's permission flow (`permissionDecision:
49
+ * "ask"`) and this callback decides them; its answer is sent to writ as
50
+ * `resolve`. It is also consulted for SDK permission prompts that writ did
51
+ * not raise. Without it, those are denied.
52
+ */
53
+ canUseTool?: CanUseTool;
54
+ /** Default `{ agent: "claude-agent-sdk" }`. */
55
+ caller?: CallerIdentity;
56
+ /** Override the tool mapping (default `mapClaudeTool`). */
57
+ mapTool?: (toolName: string, input: unknown, mcpServer?: McpProvenance) => MappedToolCall;
58
+ /** Hook matcher (tool name regex); default: every tool. */
59
+ matcher?: string;
60
+ /** SDK hook timeout in seconds (covers inline approvals). */
61
+ hookTimeoutSec?: number;
62
+ approvalTimeoutMs?: number;
63
+ /**
64
+ * What an allow / redact verdict returns to the SDK: `"allow"` (default, as
65
+ * `writ check --format claude-code`) skips the SDK's own permission prompt;
66
+ * `"passthrough"` returns no decision, so the SDK's permission rules still
67
+ * apply on top of writ.
68
+ */
69
+ onAllow?: "allow" | "passthrough";
70
+ }
71
+ /** The pieces to spread into the SDK's `query({ options })`. */
72
+ export interface WritClaudeIntegration {
73
+ hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
74
+ canUseTool: CanUseTool;
75
+ preToolUse: HookCallback;
76
+ postToolUse: HookCallback;
77
+ postToolUseFailure: HookCallback;
78
+ }
79
+ /**
80
+ * Build writ hooks and a `canUseTool` callback for the Claude Agent SDK.
81
+ * Every failure path denies the tool (fail closed): a hook never throws.
82
+ */
83
+ export declare function createWritIntegration(options: WritClaudeOptions): WritClaudeIntegration;
84
+ /** Just the `hooks` option. Deferred asks need `approver` (or use `createWritIntegration` for `canUseTool`). */
85
+ export declare function writHooks(options: WritClaudeOptions): Partial<Record<HookEvent, HookCallbackMatcher[]>>;
86
+ /**
87
+ * Merge writ's hooks with your own `hooks` option. writ's matchers come first
88
+ * for each event; yours are kept.
89
+ */
90
+ export declare function mergeHooks(...sets: Array<Partial<Record<HookEvent, HookCallbackMatcher[]>> | undefined>): Partial<Record<HookEvent, HookCallbackMatcher[]>>;
@@ -0,0 +1,306 @@
1
+ import { shouldDispatch } from "./client.js";
2
+ import { describeBlock, WritError } from "./errors.js";
3
+ import { fromRedacted, outputText, WITHHELD_OUTPUT } from "./output.js";
4
+ const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
5
+ const READ_TOOLS = new Set(["Read", "Glob", "Grep", "LS", "NotebookRead"]);
6
+ const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
7
+ function asRecord(input) {
8
+ return typeof input === "object" && input !== null && !Array.isArray(input)
9
+ ? { ...input }
10
+ : { input };
11
+ }
12
+ function firstString(args, keys) {
13
+ for (const k of keys) {
14
+ const v = args[k];
15
+ if (typeof v === "string")
16
+ return v;
17
+ }
18
+ return undefined;
19
+ }
20
+ /**
21
+ * Map a Claude tool name + input to writ's policy vocabulary. Kept identical
22
+ * to `writ check --format claude-code` (crates/writ-cli/src/hook.rs):
23
+ * - `Bash` / `PowerShell` → `bash` (with `command`)
24
+ * - `Read` / `Glob` / `Grep` / `LS` / `NotebookRead` → `fs.read`; `Write` / `Edit` / `MultiEdit` / `NotebookEdit` → `fs.write` (with `path`)
25
+ * - `WebFetch` → `http` (with `url`); `WebSearch` → `web.search` (with `query`)
26
+ * - `mcp__<server>__<tool>` → tool `<tool>` with `server: { name: <server> }`
27
+ * - anything else keeps its SDK name.
28
+ * The original input fields are kept; normalized keys are added alongside.
29
+ */
30
+ export function mapClaudeTool(toolName, input, mcpServer) {
31
+ const args = asRecord(input);
32
+ if (SHELL_TOOLS.has(toolName)) {
33
+ const command = firstString(args, ["command"]);
34
+ return { tool: "bash", args: command === undefined ? args : { ...args, command } };
35
+ }
36
+ if (READ_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName)) {
37
+ // An explicit string `path` wins; otherwise the tool's native key.
38
+ const path = firstString(args, ["path", "file_path", "notebook_path"]);
39
+ const tool = WRITE_TOOLS.has(toolName) ? "fs.write" : "fs.read";
40
+ return { tool, args: path === undefined ? args : { ...args, path } };
41
+ }
42
+ if (toolName === "WebFetch")
43
+ return { tool: "http", args };
44
+ if (toolName === "WebSearch")
45
+ return { tool: "web.search", args };
46
+ if (toolName.startsWith("mcp__")) {
47
+ const rest = toolName.slice("mcp__".length);
48
+ const sep = rest.indexOf("__");
49
+ if (sep > 0 && sep < rest.length - 2) {
50
+ const serverName = mcpServer?.name ?? rest.slice(0, sep);
51
+ return {
52
+ tool: rest.slice(sep + 2),
53
+ args,
54
+ server: { name: serverName, transport: "unknown" },
55
+ };
56
+ }
57
+ }
58
+ return { tool: toolName, args };
59
+ }
60
+ const MAX_TRACKED = 10_000;
61
+ function preOutput(permissionDecision, reason) {
62
+ return {
63
+ hookSpecificOutput: {
64
+ hookEventName: "PreToolUse",
65
+ permissionDecision,
66
+ permissionDecisionReason: reason,
67
+ },
68
+ };
69
+ }
70
+ function allowReason(d) {
71
+ const rule = d.rule_id ? `rule '${d.rule_id}'` : "policy default";
72
+ return d.decision === "redact" ? `writ: allowed with redaction by ${rule}` : `writ: allowed by ${rule}`;
73
+ }
74
+ function askReason(d, tool) {
75
+ const rule = d.rule_id ? `rule '${d.rule_id}'` : "policy default";
76
+ const where = d.location ? ` (${d.location})` : "";
77
+ const why = d.reason ? `: ${d.reason}` : "";
78
+ return `writ: '${tool}' needs approval by ${rule}${where}${why}`;
79
+ }
80
+ function errorText(err) {
81
+ return err instanceof Error ? err.message : String(err);
82
+ }
83
+ function sameJson(a, b) {
84
+ try {
85
+ return JSON.stringify(a) === JSON.stringify(b);
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ /**
92
+ * Build writ hooks and a `canUseTool` callback for the Claude Agent SDK.
93
+ * Every failure path denies the tool (fail closed): a hook never throws.
94
+ */
95
+ export function createWritIntegration(options) {
96
+ const { client } = options;
97
+ const caller = options.caller ?? client.caller ?? { agent: "claude-agent-sdk" };
98
+ const mapTool = options.mapTool ?? mapClaudeTool;
99
+ const onAllow = options.onAllow ?? "allow";
100
+ const dispatched = new Map();
101
+ const pendingAsks = new Map();
102
+ const track = (map, key, value) => {
103
+ if (map.size >= MAX_TRACKED) {
104
+ const oldest = map.keys().next();
105
+ if (oldest.done !== true)
106
+ map.delete(oldest.value);
107
+ }
108
+ map.set(key, value);
109
+ };
110
+ const buildCall = (input, toolUseID) => {
111
+ const mapped = mapTool(input.tool_name, input.tool_input, input.mcp_server);
112
+ const call = {
113
+ session_id: input.session_id || client.sessionId,
114
+ tool: mapped.tool,
115
+ args: mapped.args,
116
+ // Subagent tool calls carry the subagent id as the non-human identity.
117
+ caller: input.agent_id ? { ...caller, non_human_id: input.agent_id } : caller,
118
+ };
119
+ const id = input.tool_use_id || toolUseID;
120
+ if (id)
121
+ call.call_id = id;
122
+ if (mapped.server !== undefined)
123
+ call.server = mapped.server;
124
+ return call;
125
+ };
126
+ const preToolUse = async (input, toolUseID, { signal }) => {
127
+ if (input.hook_event_name !== "PreToolUse")
128
+ return {};
129
+ const key = input.tool_use_id || toolUseID || "";
130
+ try {
131
+ if (signal.aborted)
132
+ return preOutput("deny", "writ: aborted before a decision (fail closed)");
133
+ const call = buildCall(input, toolUseID);
134
+ let decision;
135
+ if (options.approver !== undefined) {
136
+ decision = await client.authorize(call, {
137
+ approver: options.approver,
138
+ ...(options.approvalTimeoutMs !== undefined ? { approvalTimeoutMs: options.approvalTimeoutMs } : {}),
139
+ });
140
+ }
141
+ else {
142
+ decision = await client.decide(call);
143
+ if (decision.decision === "ask" && decision.approval === "required") {
144
+ if (decision.ref === undefined)
145
+ throw new WritError("protocol", "deferred ask is missing ref");
146
+ if (options.canUseTool !== undefined && key !== "") {
147
+ track(pendingAsks, key, { ref: decision.ref, decision });
148
+ return preOutput("ask", askReason(decision, input.tool_name));
149
+ }
150
+ decision = await client.resolve(decision.ref, false, "adapter:no-approver");
151
+ }
152
+ }
153
+ if (!shouldDispatch(decision) || decision.ref === undefined) {
154
+ return preOutput("deny", describeBlock(decision, input.tool_name));
155
+ }
156
+ if (key === "" && decision.decision === "redact") {
157
+ return preOutput("deny", `writ: redact verdict for '${input.tool_name}' needs a tool_use_id to apply (fail closed)`);
158
+ }
159
+ if (key !== "")
160
+ track(dispatched, key, { ref: decision.ref, decision });
161
+ return onAllow === "passthrough" ? {} : preOutput("allow", allowReason(decision));
162
+ }
163
+ catch (err) {
164
+ return preOutput("deny", `writ: cannot authorize '${input.tool_name}' (fail closed): ${errorText(err)}`);
165
+ }
166
+ };
167
+ const postToolUse = async (input, toolUseID) => {
168
+ if (input.hook_event_name !== "PostToolUse")
169
+ return {};
170
+ const post = input;
171
+ const key = post.tool_use_id || toolUseID || "";
172
+ const tracked = dispatched.get(key);
173
+ if (tracked === undefined) {
174
+ const unresolved = pendingAsks.get(key);
175
+ if (unresolved !== undefined) {
176
+ // The tool ran although its writ ask was never resolved: record the
177
+ // rejection and keep the output away from the model.
178
+ pendingAsks.delete(key);
179
+ await client.resolve(unresolved.ref, false, "adapter:unresolved-ask").catch(() => undefined);
180
+ return {
181
+ systemMessage: `writ: '${post.tool_name}' ran without a resolved writ approval; output withheld`,
182
+ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: WITHHELD_OUTPUT },
183
+ };
184
+ }
185
+ return {};
186
+ }
187
+ dispatched.delete(key);
188
+ const text = outputText(post.tool_response);
189
+ const redact = tracked.decision.decision === "redact";
190
+ try {
191
+ const recorded = await client.complete(tracked.ref, text === undefined ? { ok: true } : { ok: true, output: text });
192
+ if (!redact || text === undefined)
193
+ return {};
194
+ if (recorded.output === undefined) {
195
+ return {
196
+ systemMessage: "writ: redact verdict but no redacted output was returned; output withheld",
197
+ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: WITHHELD_OUTPUT },
198
+ };
199
+ }
200
+ return {
201
+ hookSpecificOutput: {
202
+ hookEventName: "PostToolUse",
203
+ updatedToolOutput: fromRedacted(recorded.output, post.tool_response),
204
+ },
205
+ };
206
+ }
207
+ catch (err) {
208
+ const message = `writ: could not record '${post.tool_name}' execution: ${errorText(err)}`;
209
+ if (!redact)
210
+ return { systemMessage: message };
211
+ return {
212
+ systemMessage: `${message}; output withheld`,
213
+ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: WITHHELD_OUTPUT },
214
+ };
215
+ }
216
+ };
217
+ const postToolUseFailure = async (input, toolUseID) => {
218
+ if (input.hook_event_name !== "PostToolUseFailure")
219
+ return {};
220
+ const failed = input;
221
+ const key = failed.tool_use_id || toolUseID || "";
222
+ const tracked = dispatched.get(key);
223
+ if (tracked === undefined)
224
+ return {};
225
+ dispatched.delete(key);
226
+ await client.complete(tracked.ref, { ok: false, output: failed.error }).catch(() => undefined);
227
+ return {};
228
+ };
229
+ const canUseTool = async (toolName, input, opts) => {
230
+ const pending = pendingAsks.get(opts.toolUseID);
231
+ if (pending === undefined) {
232
+ if (options.canUseTool !== undefined) {
233
+ try {
234
+ const res = await options.canUseTool(toolName, input, opts);
235
+ return res ?? { behavior: "deny", message: "writ: permission callback returned no result (fail closed)" };
236
+ }
237
+ catch (err) {
238
+ return { behavior: "deny", message: `writ: permission callback failed (fail closed): ${errorText(err)}` };
239
+ }
240
+ }
241
+ return { behavior: "deny", message: `writ: no approver for '${toolName}' (fail closed)` };
242
+ }
243
+ pendingAsks.delete(opts.toolUseID);
244
+ let approved = false;
245
+ try {
246
+ const res = options.canUseTool !== undefined && !opts.signal.aborted ? await options.canUseTool(toolName, input, opts) : null;
247
+ // writ decided on the original input; an edited input is not what was approved.
248
+ approved = res?.behavior === "allow" && (res.updatedInput === undefined || sameJson(res.updatedInput, input));
249
+ }
250
+ catch {
251
+ approved = false;
252
+ }
253
+ try {
254
+ const decision = await client.resolve(pending.ref, approved, "sdk:canUseTool");
255
+ if (!shouldDispatch(decision)) {
256
+ return { behavior: "deny", message: describeBlock({ ...pending.decision, ...decision }, toolName) };
257
+ }
258
+ track(dispatched, opts.toolUseID, { ref: decision.ref ?? pending.ref, decision });
259
+ // No updatedPermissions: a persistent SDK allow rule would bypass future writ asks.
260
+ return { behavior: "allow", updatedInput: input };
261
+ }
262
+ catch (err) {
263
+ return { behavior: "deny", message: `writ: cannot resolve approval for '${toolName}' (fail closed): ${errorText(err)}` };
264
+ }
265
+ };
266
+ const matcher = (hook) => {
267
+ const m = { hooks: [hook] };
268
+ if (options.matcher !== undefined)
269
+ m.matcher = options.matcher;
270
+ if (options.hookTimeoutSec !== undefined)
271
+ m.timeout = options.hookTimeoutSec;
272
+ return m;
273
+ };
274
+ return {
275
+ hooks: {
276
+ PreToolUse: [matcher(preToolUse)],
277
+ PostToolUse: [matcher(postToolUse)],
278
+ PostToolUseFailure: [matcher(postToolUseFailure)],
279
+ },
280
+ canUseTool,
281
+ preToolUse,
282
+ postToolUse,
283
+ postToolUseFailure,
284
+ };
285
+ }
286
+ /** Just the `hooks` option. Deferred asks need `approver` (or use `createWritIntegration` for `canUseTool`). */
287
+ export function writHooks(options) {
288
+ return createWritIntegration(options).hooks;
289
+ }
290
+ /**
291
+ * Merge writ's hooks with your own `hooks` option. writ's matchers come first
292
+ * for each event; yours are kept.
293
+ */
294
+ export function mergeHooks(...sets) {
295
+ const out = {};
296
+ for (const set of sets) {
297
+ if (set === undefined)
298
+ continue;
299
+ for (const [event, matchers] of Object.entries(set)) {
300
+ if (matchers === undefined)
301
+ continue;
302
+ out[event] = [...(out[event] ?? []), ...matchers];
303
+ }
304
+ }
305
+ return out;
306
+ }