@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.
- package/LICENSE +202 -0
- package/README.md +182 -0
- package/dist/cjs/claude-agent-sdk.d.ts +90 -0
- package/dist/cjs/claude-agent-sdk.js +312 -0
- package/dist/cjs/client.d.ts +107 -0
- package/dist/cjs/client.js +387 -0
- package/dist/cjs/errors.d.ts +35 -0
- package/dist/cjs/errors.js +60 -0
- package/dist/cjs/guard.d.ts +56 -0
- package/dist/cjs/guard.js +113 -0
- package/dist/cjs/index.d.ts +9 -0
- package/dist/cjs/index.js +21 -0
- package/dist/cjs/locate.d.ts +18 -0
- package/dist/cjs/locate.js +64 -0
- package/dist/cjs/output.d.ts +10 -0
- package/dist/cjs/output.js +38 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/protocol.d.ts +67 -0
- package/dist/cjs/protocol.js +9 -0
- package/dist/esm/claude-agent-sdk.d.ts +90 -0
- package/dist/esm/claude-agent-sdk.js +306 -0
- package/dist/esm/client.d.ts +107 -0
- package/dist/esm/client.js +382 -0
- package/dist/esm/errors.d.ts +35 -0
- package/dist/esm/errors.js +51 -0
- package/dist/esm/guard.d.ts +56 -0
- package/dist/esm/guard.js +109 -0
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/locate.d.ts +18 -0
- package/dist/esm/locate.js +59 -0
- package/dist/esm/output.d.ts +10 -0
- package/dist/esm/output.js +33 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/protocol.d.ts +67 -0
- package/dist/esm/protocol.js +6 -0
- package/package.json +95 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { WritError, WritProtocolError, WritTimeoutError, WritUnavailableError } from "./errors.js";
|
|
4
|
+
import { launchFor, locateWrit } from "./locate.js";
|
|
5
|
+
import { PROTOCOL_VERSION, } from "./protocol.js";
|
|
6
|
+
const DECISIONS = new Set(["allow", "deny", "ask", "redact"]);
|
|
7
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
|
+
/** True only when a decision says the tool may run. */
|
|
9
|
+
export function shouldDispatch(decision) {
|
|
10
|
+
return decision.dispatch === true && decision.decision !== "deny";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A long-lived `writ check --stdio` child process speaking protocol v1.
|
|
14
|
+
* Every failure (missing binary, crash, timeout, malformed line, `error`
|
|
15
|
+
* response) rejects with a `WritError`; callers must then not run the tool.
|
|
16
|
+
*/
|
|
17
|
+
export class WritClient {
|
|
18
|
+
askMode;
|
|
19
|
+
sessionId;
|
|
20
|
+
caller;
|
|
21
|
+
options;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
maxLineBytes;
|
|
24
|
+
proc;
|
|
25
|
+
queue = [];
|
|
26
|
+
buffer = "";
|
|
27
|
+
seq = 0;
|
|
28
|
+
closed = false;
|
|
29
|
+
broken;
|
|
30
|
+
stderrTail = "";
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.askMode = options.ask ?? "deny";
|
|
34
|
+
if (this.askMode !== "deny" && this.askMode !== "defer") {
|
|
35
|
+
throw new WritError("bad_option", `ask must be "deny" or "defer", got ${String(options.ask)}`);
|
|
36
|
+
}
|
|
37
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
38
|
+
this.maxLineBytes = options.maxLineBytes ?? 16 * 1024 * 1024;
|
|
39
|
+
this.sessionId = options.sessionId ?? randomUUID();
|
|
40
|
+
this.caller = options.caller;
|
|
41
|
+
}
|
|
42
|
+
/** The argv (after the program) passed to writ. */
|
|
43
|
+
gatewayArgs() {
|
|
44
|
+
const args = [];
|
|
45
|
+
if (this.options.policy !== undefined)
|
|
46
|
+
args.push("--policy", this.options.policy);
|
|
47
|
+
if (this.options.ledger !== undefined)
|
|
48
|
+
args.push("--ledger", this.options.ledger);
|
|
49
|
+
args.push("check", "--stdio", "--ask", this.askMode);
|
|
50
|
+
return args;
|
|
51
|
+
}
|
|
52
|
+
/** Ask writ for a verdict. Rejects on any gateway failure. */
|
|
53
|
+
async decide(call) {
|
|
54
|
+
const body = {
|
|
55
|
+
op: "decide",
|
|
56
|
+
call: {
|
|
57
|
+
...call,
|
|
58
|
+
session_id: call.session_id || this.sessionId,
|
|
59
|
+
caller: call.caller ?? this.caller ?? { agent: "unknown" },
|
|
60
|
+
server: call.server ?? null,
|
|
61
|
+
trust: call.trust ?? null,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
return parseDecision(await this.request(body), "decide");
|
|
65
|
+
}
|
|
66
|
+
/** Resolve a deferred ask. The response is a final decision. */
|
|
67
|
+
async resolve(ref, approved, approver) {
|
|
68
|
+
const body = { op: "resolve", ref, approved };
|
|
69
|
+
if (approver !== undefined)
|
|
70
|
+
body.approver = approver;
|
|
71
|
+
const decision = parseDecision(await this.request(body), "resolve");
|
|
72
|
+
if (!approved && shouldDispatch(decision)) {
|
|
73
|
+
throw new WritProtocolError("gateway returned dispatch:true for a rejected ask");
|
|
74
|
+
}
|
|
75
|
+
return decision;
|
|
76
|
+
}
|
|
77
|
+
/** Record the execution of a dispatched call. For redact, `output` in the result is the redacted text. */
|
|
78
|
+
async complete(ref, input) {
|
|
79
|
+
const body = { op: "complete", ref, ok: input.ok };
|
|
80
|
+
if (input.exit !== undefined)
|
|
81
|
+
body.exit = input.exit;
|
|
82
|
+
if (input.output !== undefined)
|
|
83
|
+
body.output = input.output;
|
|
84
|
+
const res = await this.request(body);
|
|
85
|
+
if (res.recorded !== true) {
|
|
86
|
+
throw new WritProtocolError("complete response is missing recorded:true");
|
|
87
|
+
}
|
|
88
|
+
if (res.output !== undefined && typeof res.output !== "string") {
|
|
89
|
+
throw new WritProtocolError("complete response output is not a string");
|
|
90
|
+
}
|
|
91
|
+
return typeof res.output === "string" ? { recorded: true, output: res.output } : { recorded: true };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* decide, and for a deferred ask obtain an answer from `approver` and
|
|
95
|
+
* `resolve` it. Returns the final decision; check `shouldDispatch`.
|
|
96
|
+
* A missing, throwing, late or non-approving approver is a denial.
|
|
97
|
+
*/
|
|
98
|
+
async authorize(call, options = {}) {
|
|
99
|
+
const decision = await this.decide(call);
|
|
100
|
+
if (decision.decision !== "ask" || decision.approval !== "required")
|
|
101
|
+
return decision;
|
|
102
|
+
if (decision.ref === undefined)
|
|
103
|
+
throw new WritProtocolError("deferred ask is missing ref");
|
|
104
|
+
const { approved, approver } = await runApprover(call, decision, options);
|
|
105
|
+
return this.resolve(decision.ref, approved, approver);
|
|
106
|
+
}
|
|
107
|
+
/** Close stdin, wait briefly for writ to exit, then kill it. Idempotent. */
|
|
108
|
+
async close() {
|
|
109
|
+
this.closed = true;
|
|
110
|
+
const proc = this.proc;
|
|
111
|
+
if (proc === undefined)
|
|
112
|
+
return;
|
|
113
|
+
await new Promise((done) => {
|
|
114
|
+
if (proc.exitCode !== null || proc.signalCode !== null)
|
|
115
|
+
return done();
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
proc.kill();
|
|
118
|
+
done();
|
|
119
|
+
}, 2000);
|
|
120
|
+
proc.once("exit", () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
done();
|
|
123
|
+
});
|
|
124
|
+
proc.stdin?.end();
|
|
125
|
+
});
|
|
126
|
+
this.failAll(new WritError("closed", "writ client closed"));
|
|
127
|
+
this.proc = undefined;
|
|
128
|
+
}
|
|
129
|
+
async [Symbol.asyncDispose]() {
|
|
130
|
+
await this.close();
|
|
131
|
+
}
|
|
132
|
+
// --- transport -----------------------------------------------------------
|
|
133
|
+
request(body) {
|
|
134
|
+
if (this.closed)
|
|
135
|
+
return Promise.reject(new WritError("closed", "writ client is closed"));
|
|
136
|
+
if (this.broken !== undefined && this.options.respawn === false)
|
|
137
|
+
return Promise.reject(this.broken);
|
|
138
|
+
let proc;
|
|
139
|
+
try {
|
|
140
|
+
proc = this.ensureProcess();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return Promise.reject(toWritError(err));
|
|
144
|
+
}
|
|
145
|
+
const id = `r${++this.seq}`;
|
|
146
|
+
const op = String(body.op);
|
|
147
|
+
const line = JSON.stringify({ v: PROTOCOL_VERSION, id, ...body }) + "\n";
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
this.poison(new WritTimeoutError(`writ check did not answer ${op} ${id} within ${this.timeoutMs} ms`));
|
|
151
|
+
}, this.timeoutMs);
|
|
152
|
+
this.queue.push({ id, op, resolve, reject, timer });
|
|
153
|
+
this.setRef(true);
|
|
154
|
+
const stdin = proc.stdin;
|
|
155
|
+
if (stdin === null || !stdin.writable) {
|
|
156
|
+
this.poison(new WritProtocolError("writ check stdin is not writable"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
stdin.write(line);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
ensureProcess() {
|
|
163
|
+
if (this.proc !== undefined)
|
|
164
|
+
return this.proc;
|
|
165
|
+
const launch = this.resolveLaunch();
|
|
166
|
+
const args = [...launch.args, ...this.gatewayArgs()];
|
|
167
|
+
const proc = spawn(launch.command, args, {
|
|
168
|
+
cwd: this.options.cwd,
|
|
169
|
+
env: this.options.env ?? process.env,
|
|
170
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
171
|
+
shell: false,
|
|
172
|
+
windowsHide: true,
|
|
173
|
+
});
|
|
174
|
+
this.proc = proc;
|
|
175
|
+
this.broken = undefined;
|
|
176
|
+
this.buffer = "";
|
|
177
|
+
this.stderrTail = "";
|
|
178
|
+
proc.stdout?.setEncoding("utf8");
|
|
179
|
+
proc.stderr?.setEncoding("utf8");
|
|
180
|
+
proc.stdout?.on("data", (chunk) => {
|
|
181
|
+
if (this.proc === proc)
|
|
182
|
+
this.onData(chunk);
|
|
183
|
+
});
|
|
184
|
+
proc.stderr?.on("data", (chunk) => {
|
|
185
|
+
this.stderrTail = (this.stderrTail + chunk).slice(-4096);
|
|
186
|
+
this.options.onStderr?.(chunk);
|
|
187
|
+
});
|
|
188
|
+
proc.stdin?.on("error", (err) => {
|
|
189
|
+
if (this.proc === proc)
|
|
190
|
+
this.poison(new WritProtocolError(`writ check stdin failed: ${err.message}`, { cause: err }));
|
|
191
|
+
});
|
|
192
|
+
proc.on("error", (err) => {
|
|
193
|
+
if (this.proc !== proc)
|
|
194
|
+
return;
|
|
195
|
+
const code = err.code;
|
|
196
|
+
this.poison(code === "ENOENT" || code === "EACCES"
|
|
197
|
+
? new WritUnavailableError(`cannot start writ (${launch.command}): ${err.message}`, { cause: err })
|
|
198
|
+
: new WritProtocolError(`writ check failed: ${err.message}`, { cause: err }));
|
|
199
|
+
});
|
|
200
|
+
// "close" fires after stdout/stderr are drained, so the stderr tail is complete.
|
|
201
|
+
proc.on("close", (code, signal) => {
|
|
202
|
+
if (this.proc !== proc)
|
|
203
|
+
return;
|
|
204
|
+
const how = signal !== null ? `signal ${signal}` : `code ${String(code)}`;
|
|
205
|
+
const tail = this.stderrTail.trim();
|
|
206
|
+
this.poison(new WritProtocolError(`writ check exited (${how})${tail ? `: ${tail}` : ""}`));
|
|
207
|
+
});
|
|
208
|
+
return proc;
|
|
209
|
+
}
|
|
210
|
+
resolveLaunch() {
|
|
211
|
+
if (this.options.command !== undefined) {
|
|
212
|
+
return { command: this.options.command, args: [...(this.options.args ?? [])] };
|
|
213
|
+
}
|
|
214
|
+
const launch = this.options.bin !== undefined ? launchFor(this.options.bin) : locateWrit(undefined, this.options.env ?? process.env);
|
|
215
|
+
return { command: launch.command, args: [...launch.args, ...(this.options.args ?? [])] };
|
|
216
|
+
}
|
|
217
|
+
onData(chunk) {
|
|
218
|
+
this.buffer += chunk;
|
|
219
|
+
let nl;
|
|
220
|
+
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
221
|
+
const raw = this.buffer.slice(0, nl).replace(/\r$/, "");
|
|
222
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
223
|
+
if (raw.trim() === "")
|
|
224
|
+
continue;
|
|
225
|
+
this.onLine(raw);
|
|
226
|
+
if (this.proc === undefined)
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (Buffer.byteLength(this.buffer, "utf8") > this.maxLineBytes) {
|
|
230
|
+
this.poison(new WritProtocolError(`writ check response line exceeds ${this.maxLineBytes} bytes`));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
onLine(raw) {
|
|
234
|
+
let msg;
|
|
235
|
+
try {
|
|
236
|
+
msg = JSON.parse(raw);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
this.poison(new WritProtocolError(`malformed line from writ check: ${truncate(raw)}`));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const head = this.queue[0];
|
|
243
|
+
if (head === undefined) {
|
|
244
|
+
this.poison(new WritProtocolError(`unsolicited line from writ check: ${truncate(raw)}`));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!isObject(msg) || msg.v !== PROTOCOL_VERSION || msg.id !== head.id) {
|
|
248
|
+
this.poison(new WritProtocolError(`unexpected response (wanted v:1 id:${head.id}): ${truncate(raw)}`));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
this.queue.shift();
|
|
252
|
+
clearTimeout(head.timer);
|
|
253
|
+
if (this.queue.length === 0)
|
|
254
|
+
this.setRef(false);
|
|
255
|
+
if (msg.error !== undefined) {
|
|
256
|
+
const e = isObject(msg.error) ? msg.error : {};
|
|
257
|
+
const code = typeof e.code === "string" ? e.code : "error";
|
|
258
|
+
const message = typeof e.message === "string" ? e.message : "writ check returned an error";
|
|
259
|
+
head.reject(new WritError(code, `writ ${head.op} failed (${code}): ${message}`));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
head.resolve(msg);
|
|
263
|
+
}
|
|
264
|
+
/** Fail every in-flight request and drop the process (fail closed). */
|
|
265
|
+
poison(error) {
|
|
266
|
+
const proc = this.proc;
|
|
267
|
+
this.proc = undefined;
|
|
268
|
+
this.broken = error;
|
|
269
|
+
this.buffer = "";
|
|
270
|
+
if (proc !== undefined && proc.exitCode === null && proc.signalCode === null) {
|
|
271
|
+
proc.kill();
|
|
272
|
+
}
|
|
273
|
+
this.failAll(error);
|
|
274
|
+
}
|
|
275
|
+
failAll(error) {
|
|
276
|
+
const pending = this.queue;
|
|
277
|
+
this.queue = [];
|
|
278
|
+
for (const p of pending) {
|
|
279
|
+
clearTimeout(p.timer);
|
|
280
|
+
p.reject(error);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Keep the event loop alive only while requests are in flight. */
|
|
284
|
+
setRef(on) {
|
|
285
|
+
const proc = this.proc;
|
|
286
|
+
if (proc === undefined)
|
|
287
|
+
return;
|
|
288
|
+
const method = on ? "ref" : "unref";
|
|
289
|
+
proc[method]();
|
|
290
|
+
for (const s of [proc.stdin, proc.stdout, proc.stderr]) {
|
|
291
|
+
const handle = s;
|
|
292
|
+
handle?.[method]?.();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// --- helpers ---------------------------------------------------------------
|
|
297
|
+
async function runApprover(call, decision, options) {
|
|
298
|
+
const approver = options.approver;
|
|
299
|
+
if (approver === undefined)
|
|
300
|
+
return { approved: false, approver: "adapter:no-approver" };
|
|
301
|
+
const limit = decision.timeout_ms ?? options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
let timer;
|
|
304
|
+
const timeout = new Promise((done) => {
|
|
305
|
+
timer = setTimeout(() => {
|
|
306
|
+
controller.abort();
|
|
307
|
+
done("timeout");
|
|
308
|
+
}, limit);
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
const answer = await Promise.race([
|
|
312
|
+
Promise.resolve().then(() => approver({ call, decision, signal: controller.signal })),
|
|
313
|
+
timeout,
|
|
314
|
+
]);
|
|
315
|
+
if (answer === "timeout")
|
|
316
|
+
return { approved: false, approver: "adapter:approval-timeout" };
|
|
317
|
+
if (answer === true)
|
|
318
|
+
return { approved: true, approver: "adapter:approver" };
|
|
319
|
+
if (isObject(answer) && answer.approved === true) {
|
|
320
|
+
return { approved: true, approver: typeof answer.approver === "string" ? answer.approver : "adapter:approver" };
|
|
321
|
+
}
|
|
322
|
+
const who = isObject(answer) && typeof answer.approver === "string" ? answer.approver : "adapter:approver";
|
|
323
|
+
return { approved: false, approver: who };
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return { approved: false, approver: "adapter:approver-error" };
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
if (timer !== undefined)
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function parseDecision(msg, op) {
|
|
334
|
+
const kind = msg.decision;
|
|
335
|
+
if (typeof kind !== "string" || !DECISIONS.has(kind)) {
|
|
336
|
+
throw new WritProtocolError(`${op} response has no valid decision`);
|
|
337
|
+
}
|
|
338
|
+
if (typeof msg.dispatch !== "boolean") {
|
|
339
|
+
throw new WritProtocolError(`${op} response has no boolean dispatch`);
|
|
340
|
+
}
|
|
341
|
+
const decision = { decision: kind, dispatch: msg.dispatch };
|
|
342
|
+
// A decision that contradicts itself is not trusted.
|
|
343
|
+
if (kind === "deny" && msg.dispatch)
|
|
344
|
+
throw new WritProtocolError(`${op}: deny with dispatch:true`);
|
|
345
|
+
if (op === "decide" && kind === "ask" && msg.dispatch)
|
|
346
|
+
throw new WritProtocolError("decide: ask with dispatch:true");
|
|
347
|
+
if (op === "decide" && (kind === "allow" || kind === "redact") && !msg.dispatch) {
|
|
348
|
+
throw new WritProtocolError(`decide: ${kind} with dispatch:false`);
|
|
349
|
+
}
|
|
350
|
+
if (typeof msg.ref === "string")
|
|
351
|
+
decision.ref = msg.ref;
|
|
352
|
+
if (typeof msg.rule_id === "string")
|
|
353
|
+
decision.rule_id = msg.rule_id;
|
|
354
|
+
if (typeof msg.reason === "string")
|
|
355
|
+
decision.reason = msg.reason;
|
|
356
|
+
if (typeof msg.location === "string")
|
|
357
|
+
decision.location = msg.location;
|
|
358
|
+
if (msg.approval === "required")
|
|
359
|
+
decision.approval = "required";
|
|
360
|
+
if (typeof msg.irreversible === "boolean")
|
|
361
|
+
decision.irreversible = msg.irreversible;
|
|
362
|
+
if (typeof msg.timeout_ms === "number")
|
|
363
|
+
decision.timeout_ms = msg.timeout_ms;
|
|
364
|
+
if (Array.isArray(msg.patterns))
|
|
365
|
+
decision.patterns = msg.patterns.filter((p) => typeof p === "string");
|
|
366
|
+
if (decision.dispatch && decision.ref === undefined) {
|
|
367
|
+
throw new WritProtocolError(`${op}: dispatching decision is missing ref`);
|
|
368
|
+
}
|
|
369
|
+
return decision;
|
|
370
|
+
}
|
|
371
|
+
function isObject(v) {
|
|
372
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
373
|
+
}
|
|
374
|
+
function truncate(s) {
|
|
375
|
+
return s.length > 200 ? `${s.slice(0, 200)}...` : s;
|
|
376
|
+
}
|
|
377
|
+
function toWritError(err) {
|
|
378
|
+
if (err instanceof WritError)
|
|
379
|
+
return err;
|
|
380
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
381
|
+
return new WritUnavailableError(`cannot start writ: ${message}`, { cause: err });
|
|
382
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Decision } from "./protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* Base class for every writ failure. Any `WritError` means the tool call must
|
|
4
|
+
* not run (fail closed).
|
|
5
|
+
*/
|
|
6
|
+
export declare class WritError extends Error {
|
|
7
|
+
/** Machine-readable code, e.g. `unavailable`, `timeout`, `protocol`, or a gateway `error.code`. */
|
|
8
|
+
readonly code: string;
|
|
9
|
+
constructor(code: string, message: string, options?: {
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** The writ binary could not be found or started. */
|
|
14
|
+
export declare class WritUnavailableError extends WritError {
|
|
15
|
+
constructor(message: string, options?: {
|
|
16
|
+
cause?: unknown;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** The gateway did not answer within the per-request timeout. */
|
|
20
|
+
export declare class WritTimeoutError extends WritError {
|
|
21
|
+
constructor(message: string);
|
|
22
|
+
}
|
|
23
|
+
/** The gateway wrote something that is not a valid protocol v1 response, or exited. */
|
|
24
|
+
export declare class WritProtocolError extends WritError {
|
|
25
|
+
constructor(message: string, options?: {
|
|
26
|
+
cause?: unknown;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** writ decided the call must not run (deny, rejected or unresolved ask). */
|
|
30
|
+
export declare class WritBlockedError extends WritError {
|
|
31
|
+
readonly decision: Decision;
|
|
32
|
+
constructor(decision: Decision, tool: string);
|
|
33
|
+
}
|
|
34
|
+
/** Human-readable reason for a non-dispatching decision, naming the rule. */
|
|
35
|
+
export declare function describeBlock(decision: Decision, tool: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for every writ failure. Any `WritError` means the tool call must
|
|
3
|
+
* not run (fail closed).
|
|
4
|
+
*/
|
|
5
|
+
export class WritError extends Error {
|
|
6
|
+
/** Machine-readable code, e.g. `unavailable`, `timeout`, `protocol`, or a gateway `error.code`. */
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.name = "WritError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** The writ binary could not be found or started. */
|
|
15
|
+
export class WritUnavailableError extends WritError {
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super("unavailable", message, options);
|
|
18
|
+
this.name = "WritUnavailableError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The gateway did not answer within the per-request timeout. */
|
|
22
|
+
export class WritTimeoutError extends WritError {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super("timeout", message);
|
|
25
|
+
this.name = "WritTimeoutError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The gateway wrote something that is not a valid protocol v1 response, or exited. */
|
|
29
|
+
export class WritProtocolError extends WritError {
|
|
30
|
+
constructor(message, options) {
|
|
31
|
+
super("protocol", message, options);
|
|
32
|
+
this.name = "WritProtocolError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** writ decided the call must not run (deny, rejected or unresolved ask). */
|
|
36
|
+
export class WritBlockedError extends WritError {
|
|
37
|
+
decision;
|
|
38
|
+
constructor(decision, tool) {
|
|
39
|
+
super("blocked", describeBlock(decision, tool));
|
|
40
|
+
this.name = "WritBlockedError";
|
|
41
|
+
this.decision = decision;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Human-readable reason for a non-dispatching decision, naming the rule. */
|
|
45
|
+
export function describeBlock(decision, tool) {
|
|
46
|
+
const rule = decision.rule_id ? `rule '${decision.rule_id}'` : "policy default";
|
|
47
|
+
const where = decision.location ? ` (${decision.location})` : "";
|
|
48
|
+
const why = decision.reason ? `: ${decision.reason}` : "";
|
|
49
|
+
const verb = decision.decision === "ask" ? "needs approval and was not approved" : "was blocked";
|
|
50
|
+
return `writ: '${tool}' ${verb} by ${rule}${where}${why}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Approver, type WritClient } from "./client.js";
|
|
2
|
+
import type { CallerIdentity, ServerIdentity, TrustVerdict } from "./protocol.js";
|
|
3
|
+
export interface GuardOptions<A extends unknown[]> {
|
|
4
|
+
client: WritClient;
|
|
5
|
+
/** Tool name as writ policies see it, e.g. `bash`, `fs.read`, `postgres.query`. */
|
|
6
|
+
tool: string;
|
|
7
|
+
/**
|
|
8
|
+
* Build the policy-visible `args` from the call's parameters. Default: the
|
|
9
|
+
* first parameter when it is a plain object, else `{ args: [...params] }`.
|
|
10
|
+
* Never include credentials.
|
|
11
|
+
*/
|
|
12
|
+
args?: (...params: A) => Record<string, unknown>;
|
|
13
|
+
/** Stable id for this call (default: writ generates one). */
|
|
14
|
+
callId?: (...params: A) => string | undefined;
|
|
15
|
+
/** Default: the client's session id. */
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
caller?: CallerIdentity;
|
|
18
|
+
server?: ServerIdentity | null;
|
|
19
|
+
trust?: TrustVerdict | null;
|
|
20
|
+
/** Decides deferred asks (client with `ask: "defer"`). Default: reject. */
|
|
21
|
+
approver?: Approver;
|
|
22
|
+
approvalTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a tool function so every invocation is decided by writ first and its
|
|
26
|
+
* execution recorded afterwards.
|
|
27
|
+
*
|
|
28
|
+
* - deny, rejected or unresolved ask: throws `WritBlockedError`; `fn` never runs.
|
|
29
|
+
* - any gateway failure before dispatch: throws `WritError`; `fn` never runs.
|
|
30
|
+
* - redact: returns writ's redacted output instead of the raw result.
|
|
31
|
+
* - `complete` failure after `fn` ran: throws `WritError` (the result is withheld).
|
|
32
|
+
*/
|
|
33
|
+
export declare function guard<A extends unknown[], R>(fn: (...params: A) => R | Promise<R>, options: GuardOptions<A>): (...params: A) => Promise<Awaited<R>>;
|
|
34
|
+
/** A `{ description, parameters, execute }` tool object (Vercel AI SDK and similar). */
|
|
35
|
+
export interface ExecutableTool {
|
|
36
|
+
execute?: (...params: never[]) => unknown;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
export interface GuardToolsOptions {
|
|
40
|
+
client: WritClient;
|
|
41
|
+
/** Map a tool key to the writ tool name (default: the key itself). */
|
|
42
|
+
toolName?: (key: string) => string;
|
|
43
|
+
/** Build policy-visible args from the tool's first parameter (default: the parameter itself). */
|
|
44
|
+
args?: (key: string, input: unknown) => Record<string, unknown>;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
caller?: CallerIdentity;
|
|
47
|
+
approver?: Approver;
|
|
48
|
+
approvalTimeoutMs?: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wrap the `execute` of every tool in a record of tool objects (for example
|
|
52
|
+
* Vercel AI SDK `tools`). Tools without `execute` are returned unchanged.
|
|
53
|
+
* Extra `execute` parameters (e.g. the AI SDK's `{ toolCallId }`) are passed
|
|
54
|
+
* through, and `toolCallId` becomes writ's `call_id` when present.
|
|
55
|
+
*/
|
|
56
|
+
export declare function guardTools<T extends Record<string, ExecutableTool>>(tools: T, options: GuardToolsOptions): T;
|