getclaros 0.0.0-stage → 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/dist/cli.js ADDED
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ // getclaros guard: a stdio MCP proxy. Relays newline-delimited JSON-RPC between the agent (our stdin/stdout) and
3
+ // the wrapped MCP server (child stdin/stdout) byte for byte, except tools/call on write tools: those are checked
4
+ // with POST <url>/v1/check first and, when stopped (stop or ask), answered with an isError result and never forwarded;
5
+ // when nudged, forwarded, and the server's result comes back with one more text item: the rule and its fix.
6
+ // A remote server (--remote <url>) is reached over Streamable HTTP instead; the client still speaks stdio to us.
7
+ // When Claros needs the record a call targets (a submit by name, a partial record, an item's group), it answers
8
+ // `need`; the guard reads those records itself through the wrapped server's read tools (ids the client never sees),
9
+ // or through another guarded server's guard when the need names its app (peers.ts), and asks again. stdout carries the protocol: every log line goes to stderr.
10
+ import { spawn } from "node:child_process";
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import { readFileSync } from "node:fs";
13
+ import { parseArgs } from "node:util";
14
+ import { reduceRows, rowsOf } from "./query.js";
15
+ import { ask, serve } from "./peers.js";
16
+ import { DEFAULT_WEB, loadConfig, relocate, resolve } from "./config.js";
17
+ // the package name lives in package.json only (placeholder until decided)
18
+ const NAME = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).name;
19
+ // until Claros has classified a tool (GET /v1/tools/kinds): readOnlyHint, else this name rule (same as the server's)
20
+ const READ = /^(get|list|search|read|find|describe|fetch)|^(check|run|export)_|^(version|ping)$/i;
21
+ const LOOKUP_TTL = 60_000, KINDS_TTL = 30_000;
22
+ const GUARD_USAGE = ` ${NAME} guard --server <name> [--url <claros server> | --web <claros web app>] [--key <key> | env CLAROS_KEY]
23
+ [--timeout <ms>] [--fail-open] (-- <command> [args...] | --remote <mcp url> [--header "K: V" ...])
24
+ ${NAME} mcp (the Claros MCP server over stdio, with the saved key)
25
+ Server: --url / CLAROS_URL, else the saved one, else asked from the web app (--web / CLAROS_WEB, default
26
+ https://claros-eta.vercel.app, at /.well-known/claros.json). Help: ${NAME} <command> --help`;
27
+ const log = (s) => process.stderr.write(`[${NAME} guard] ${s}\n`);
28
+ /** {a: {b: [{c: 1}]}, d: 2} → {"a.b.0.c": 1, d: 2}. Empty objects/arrays vanish. */
29
+ function flatten(v, prefix = "", out = {}) {
30
+ if (v !== null && typeof v === "object") {
31
+ for (const [k, x] of Object.entries(v))
32
+ flatten(x, prefix ? `${prefix}.${k}` : k, out);
33
+ }
34
+ else if (prefix)
35
+ out[prefix] = v;
36
+ return out;
37
+ }
38
+ /** Splits a byte stream into lines (without the \n), keeping each line's exact bytes. */
39
+ function lines(stream, onLine, onEnd) {
40
+ let buf = Buffer.alloc(0);
41
+ stream.on("data", (chunk) => {
42
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
43
+ let i;
44
+ while ((i = buf.indexOf(0x0a)) >= 0) {
45
+ onLine(buf.subarray(0, i));
46
+ buf = buf.subarray(i + 1);
47
+ }
48
+ });
49
+ stream.on("end", () => {
50
+ if (buf.length)
51
+ onLine(buf);
52
+ onEnd();
53
+ });
54
+ }
55
+ function parse(b) {
56
+ try {
57
+ return JSON.parse(b.toString("utf8"));
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ function stopText(r) {
64
+ const hits = (r.violations || []).filter((v) => v.action === "stop" || v.action === "ask");
65
+ const maps = [...new Set(hits.map((v) => v.workflow_id ?? r.workflow_id))];
66
+ const from = `expert-approved Work Map${maps.length > 1 ? "s" : ""} ${maps.map((m) => `"${m}"`).join(", ")}`;
67
+ const out = [r.result === "ask" // ask only: not refused for good, waiting on a person
68
+ ? `Claros held this action: ${hits.length === 1 ? "a rule" : "rules"} from the ${from} ${hits.length === 1 ? "says" : "say"} to ask first. Do not run it without that answer.`
69
+ : `Claros stopped this action: it breaks ${hits.length === 1 ? "a rule" : "rules"} from the ${from}. Do not retry it as is.`];
70
+ for (const v of hits) {
71
+ const who = v.who_to_ask || v.owner || v.expert || "the process owner";
72
+ out.push("", `Rule: ${v.rule}`);
73
+ if (v.reason)
74
+ out.push(`Why Claros stopped it: ${v.reason}`); // fuzzy rules: the judge's one-line reason
75
+ if (v.quote)
76
+ out.push(`${v.source === "doc" ? "In the written policy" : "In the expert's words"}${v.expert ? ` (${v.expert})` : ""}: "${v.quote}"`);
77
+ out.push(`Who to ask: ${who}`);
78
+ out.push(v.action === "ask"
79
+ ? `Next: stop and ask ${who} before doing this; continue only with their answer.`
80
+ : `Next: change the record so the rule holds, or ask ${who} to approve an exception. Tell the user why.`);
81
+ }
82
+ // an override goes to the rule's expert as a report; the rule stays as it is until they confirm a change
83
+ if (r.check_id) {
84
+ out.push("", "If this stop is wrong, tell the rule's expert why (the rule stays until they change it):");
85
+ for (const v of hits)
86
+ out.push(`npx ${NAME} override ${r.check_id} --rule ${v.guardrail_id} --reason "<why it was fine>"`);
87
+ }
88
+ return out.join("\n");
89
+ }
90
+ // a nudge lets the call through; "warn" is its old name (older Claros servers)
91
+ const nudgesOf = (r) => (r.violations || []).filter((v) => v.action === "nudge" || v.action === "warn");
92
+ /** The extra text item on a nudged call's result: the rule, the expert's words and the fix, so the agent can
93
+ * correct this record (if it still can) and the next ones. */
94
+ function nudgeText(r) {
95
+ const hits = nudgesOf(r);
96
+ const out = [`Claros nudge: the call went through, but it doesn't follow ${hits.length === 1 ? "a rule" : "rules"} ` +
97
+ `your team holds. Fix it if you still can, and follow ${hits.length === 1 ? "it" : "them"} from now on.`];
98
+ for (const v of hits) {
99
+ out.push("", `Rule: ${v.rule}`);
100
+ if (v.reason)
101
+ out.push(`Why: ${v.reason}`);
102
+ if (v.quote)
103
+ out.push(`${v.source === "doc" ? "In the written policy" : "In the expert's words"}${v.expert ? ` (${v.expert})` : ""}: "${v.quote}"`);
104
+ if (v.fix)
105
+ out.push(`Suggested fix: ${v.fix}`);
106
+ }
107
+ return out.join("\n");
108
+ }
109
+ /** fetch, and once more at the web app's current server address if this one didn't answer (it moved). */
110
+ async function reach(o, path, init = {}) {
111
+ const go = async () => {
112
+ if (!o.url)
113
+ throw new Error("no Claros server address (the web app didn't say where it is)");
114
+ return fetch(o.url + path, { ...init, signal: AbortSignal.timeout(o.timeout) });
115
+ };
116
+ try {
117
+ return await go();
118
+ }
119
+ catch (e) {
120
+ if (e.name === "TimeoutError" || !(await relocate(o)))
121
+ throw e;
122
+ log(`Claros moved: now ${o.url}`);
123
+ return go();
124
+ }
125
+ }
126
+ async function check(o, tool, args, fetched = {}) {
127
+ const res = await reach(o, "/v1/check", {
128
+ method: "POST",
129
+ headers: { "content-type": "application/json", ...(o.key ? { authorization: `Bearer ${o.key}` } : {}) },
130
+ body: JSON.stringify({ app: o.server, tool, action: tool, state: flatten(args ?? {}), arguments: args ?? {},
131
+ ...(Object.keys(fetched).length ? { fetched } : {}) }),
132
+ });
133
+ if (!res.ok)
134
+ throw new Error(`HTTP ${res.status}`);
135
+ return res.json();
136
+ }
137
+ /** A read tool's result → the record: structuredContent, else JSON in the text (whole, or its outermost {...}). */
138
+ function recordOf(tool, m) {
139
+ if (m?.error || m?.result?.isError)
140
+ throw new Error(`couldn't read the record: ${tool} returned an error`);
141
+ if (m.result?.structuredContent && typeof m.result.structuredContent === "object")
142
+ return m.result.structuredContent;
143
+ const text = (m.result?.content ?? []).filter((c) => c?.type === "text").map((c) => c.text).join("\n");
144
+ for (const s of [text, text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)]) {
145
+ try {
146
+ const v = JSON.parse(s);
147
+ if (v && typeof v === "object" && !Array.isArray(v))
148
+ return v;
149
+ }
150
+ catch { /* next */ }
151
+ }
152
+ throw new Error(`couldn't read the record: ${tool} returned no record`);
153
+ }
154
+ /** Registers tools seen in tools/list with Claros (POST /v1/tools) so it can map their arguments to a Work Map.
155
+ * Fire and forget: never awaited by the relay; a failure is logged and retried on the next tools/list.
156
+ * `onDone` runs after a successful registration (the guard then asks for the tools' read/write kinds). */
157
+ function registrar(o, onDone) {
158
+ const sent = new Map(); // tool name → hash of what Claros has
159
+ return (list) => {
160
+ const fresh = list.filter((t) => t?.name && sent.get(t.name) !== hashOf(t));
161
+ if (!fresh.length)
162
+ return false;
163
+ for (const t of fresh)
164
+ sent.set(t.name, hashOf(t));
165
+ const tools = fresh.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, annotations }));
166
+ reach(o, "/v1/tools", {
167
+ method: "POST",
168
+ headers: { "content-type": "application/json", ...(o.key ? { authorization: `Bearer ${o.key}` } : {}) },
169
+ body: JSON.stringify({ app: o.server, tools }),
170
+ }).then((r) => {
171
+ if (!r.ok)
172
+ throw new Error(`HTTP ${r.status}`);
173
+ log(`registered ${tools.length} tool(s) with Claros`);
174
+ onDone();
175
+ }).catch((e) => {
176
+ for (const t of fresh)
177
+ sent.delete(t.name);
178
+ log(`tool registration failed (${e.message}); calls are still checked`);
179
+ });
180
+ return true;
181
+ };
182
+ }
183
+ const hashOf = (t) => createHash("sha256").update(JSON.stringify(t)).digest("hex");
184
+ /** A local server: a child process speaking newline-delimited JSON-RPC; we exit with its exit code. */
185
+ const stdio = (cmd, argv) => (onLine) => {
186
+ // ponytail: plain spawn, no shell — Windows .cmd shims (npx) need shell:true; add when someone runs it there
187
+ const child = spawn(cmd, argv, { stdio: ["pipe", "pipe", "inherit"] });
188
+ lines(child.stdout, onLine, () => { });
189
+ child.stdin.on("error", () => { }); // server gone: its exit below ends us
190
+ child.on("error", (e) => { log(`could not start ${cmd}: ${e.message}`); process.exit(127); });
191
+ child.on("close", (code, sig) => process.stdout.write("", () => process.exit(code ?? (sig ? 1 : 0))));
192
+ for (const s of ["SIGINT", "SIGTERM", "SIGHUP"])
193
+ process.on(s, () => child.kill(s));
194
+ return { send: (b) => child.stdin.write(Buffer.concat([b, Buffer.from("\n")])), end: () => child.stdin.end() };
195
+ };
196
+ /** A remote server over Streamable HTTP: one POST per message (with the client config's headers, which go nowhere
197
+ * else); the answer is JSON or an SSE stream of messages. A request it can't deliver is answered with an error. */
198
+ const http = (url, headers) => (onLine) => {
199
+ const host = new URL(url).host; // in messages: the URL's path or query may carry a token
200
+ let session, version, inflight = 0, ending = false;
201
+ const emit = (s) => {
202
+ if (!s.trim())
203
+ return;
204
+ const m = parse(Buffer.from(s));
205
+ version ??= m?.result?.protocolVersion;
206
+ onLine(Buffer.from(s.includes("\n") && m ? JSON.stringify(m) : s)); // stdout carries one message per line
207
+ };
208
+ const done = () => { if (ending && !inflight)
209
+ process.stdout.write("", () => process.exit(0)); };
210
+ // ponytail: no GET stream for server-initiated messages and no re-initialize on an expired session (404);
211
+ // add when a server needs them
212
+ async function post(b) {
213
+ const msg = parse(b);
214
+ try {
215
+ const r = await fetch(url, { method: "POST", body: b.toString("utf8"), headers: { ...headers, "content-type": "application/json",
216
+ accept: "application/json, text/event-stream", ...(session ? { "mcp-session-id": session } : {}),
217
+ ...(version ? { "mcp-protocol-version": version } : {}) } });
218
+ session = r.headers.get("mcp-session-id") ?? session;
219
+ if (!r.ok)
220
+ throw new Error(`HTTP ${r.status}`);
221
+ if (!(r.headers.get("content-type") ?? "").includes("event-stream") || !r.body)
222
+ return emit(await r.text());
223
+ let buf = "";
224
+ const event = (ev) => emit(ev.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n"));
225
+ for await (const chunk of r.body.pipeThrough(new TextDecoderStream())) {
226
+ buf = (buf + chunk).replace(/\r\n?/g, "\n");
227
+ for (let i; (i = buf.indexOf("\n\n")) >= 0; buf = buf.slice(i + 2))
228
+ event(buf.slice(0, i));
229
+ }
230
+ event(buf);
231
+ }
232
+ catch (e) {
233
+ log(`${host}: ${e.message}`);
234
+ if (msg?.method && msg.id !== undefined) { // a request: its caller (the client, or our own read) gets the error
235
+ emit(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: `MCP server at ${host}: ${e.message}` } }));
236
+ }
237
+ }
238
+ }
239
+ return {
240
+ send: (b) => { inflight++; post(b).finally(() => { inflight--; done(); }); },
241
+ end: () => { ending = true; done(); },
242
+ };
243
+ };
244
+ function guard(o, connect) {
245
+ const kinds = new Map(); // tool → read | write, as Claros classified it (LLM or expert)
246
+ let kindsAt = 0;
247
+ const refreshKinds = () => {
248
+ kindsAt = Date.now();
249
+ reach(o, `/v1/tools/kinds?app=${encodeURIComponent(o.server)}`, {
250
+ headers: o.key ? { authorization: `Bearer ${o.key}` } : {},
251
+ }).then(async (r) => {
252
+ if (!r.ok)
253
+ throw new Error(`HTTP ${r.status}`);
254
+ for (const [k, v] of Object.entries((await r.json()).kinds ?? {}))
255
+ kinds.set(k, String(v));
256
+ }).catch((e) => log(`tool kinds unavailable (${e.message}); using names`));
257
+ };
258
+ const register = registrar(o, refreshKinds);
259
+ const tools = new Map(); // from tools/list responses passing through
260
+ const listIds = new Set(); // ids of tools/list requests in flight
261
+ const nudges = new Map(); // id of a nudged call in flight → the text its result gets
262
+ let pending = 0, stdinDone = false;
263
+ const toClient = (b) => process.stdout.write(Buffer.concat([Buffer.from(b), Buffer.from("\n")]));
264
+ // the guard's own reads of the wrapped server: ids under a per-process prefix, answered here, never relayed
265
+ const PREFIX = `claros-guard-${randomUUID()}-`;
266
+ const own = new Map();
267
+ const up = connect((line) => {
268
+ if (line.includes(PREFIX)) {
269
+ const msg = parse(line);
270
+ if (typeof msg?.id === "string" && msg.id.startsWith(PREFIX))
271
+ return own.get(msg.id)?.(msg); // ours, late or not
272
+ }
273
+ if (listIds.size) {
274
+ const msg = parse(line), id = JSON.stringify(msg?.id);
275
+ if (msg && listIds.has(id)) {
276
+ listIds.delete(id);
277
+ for (const t of msg.result?.tools ?? [])
278
+ tools.set(t.name, t);
279
+ if (Array.isArray(msg.result?.tools) && !register(msg.result.tools))
280
+ refreshKinds();
281
+ }
282
+ }
283
+ if (nudges.size) {
284
+ const msg = parse(line), id = JSON.stringify(msg?.id), text = msg && nudges.get(id);
285
+ if (text !== undefined) {
286
+ nudges.delete(id);
287
+ if (Array.isArray(msg.result?.content)) { // a JSON-RPC error has no content: relayed as is
288
+ msg.result.content.push({ type: "text", text });
289
+ return toClient(JSON.stringify(msg));
290
+ }
291
+ }
292
+ }
293
+ toClient(line);
294
+ });
295
+ const toServer = (b) => up.send(b);
296
+ const maybeEnd = () => { if (stdinDone && !pending)
297
+ up.end(); };
298
+ const isRead = (name) => kinds.has(name) ? kinds.get(name) === "read"
299
+ : tools.get(name)?.annotations?.readOnlyHint === true || READ.test(name);
300
+ let seq = 0;
301
+ const callServer = (tool, args) => new Promise((ok, no) => {
302
+ const id = PREFIX + ++seq;
303
+ const t = setTimeout(() => { own.delete(id); no(new Error(`reading ${tool} timed out after ${o.timeout}ms`)); }, o.timeout);
304
+ own.set(id, (m) => { clearTimeout(t); own.delete(id); ok(m); });
305
+ toServer(Buffer.from(JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name: tool, arguments: args } })));
306
+ });
307
+ // ponytail: lookups (an item's group, a supplier's invoice count) cached per process for a minute, unbounded below
308
+ // 1000 entries; two queries with the same arguments but different reducers each call the tool
309
+ const cache = new Map();
310
+ // the user's other guards may read through this one (cross-server lookups, peers.ts), read tools only
311
+ serve(o.server, o.key, isRead, callServer, log);
312
+ async function read(n) {
313
+ const peer = typeof n.app === "string" && n.app !== o.server; // a read tool of another guarded server
314
+ if (!peer && !isRead(String(n.tool)))
315
+ throw new Error(`asked to read with ${n.tool}, which isn't a read tool`);
316
+ const hit = n.cache && cache.get(n.key);
317
+ if (hit && Date.now() - hit.t < LOOKUP_TTL)
318
+ return hit.v;
319
+ const m = peer ? await ask(n.app, o.key, n.tool, n.arguments, o.timeout) : await callServer(n.tool, n.arguments);
320
+ // a query lookup: the rows stay here, only the reduced value goes to Claros
321
+ const v = n.reduce ? { value: reduceRows(rowsOf(n.tool, m, n.reduce.rows), n.reduce) } : recordOf(n.tool, m);
322
+ if (n.cache) {
323
+ if (cache.size > 1000)
324
+ cache.clear();
325
+ cache.set(n.key, { v, t: Date.now() });
326
+ }
327
+ return v;
328
+ }
329
+ async function intercept(line, msg) {
330
+ const name = String(msg.params?.name ?? ""), args = msg.params?.arguments;
331
+ const t0 = Date.now();
332
+ const reply = (text) => msg.id !== undefined && // an id-less call (invalid) is just dropped
333
+ toClient(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }], isError: true } }));
334
+ let r, reads = 0;
335
+ try {
336
+ const fetched = {};
337
+ r = await check(o, name, args);
338
+ for (let round = 0; r.need?.length; round++) { // fetch the record, then lookups on it
339
+ if (round >= 3)
340
+ throw new Error("couldn't resolve the record");
341
+ const got = await Promise.all(r.need.map((n) => read(n)));
342
+ r.need.forEach((n, i) => (fetched[n.key] = got[i]));
343
+ reads += got.length;
344
+ r = await check(o, name, args, fetched);
345
+ }
346
+ }
347
+ catch (e) {
348
+ const why = e.name === "TimeoutError" ? `timed out after ${o.timeout}ms` : e.message;
349
+ if (o.failOpen) {
350
+ log(`${o.server} ${name} → pass (fail-open: check failed, ${why})`);
351
+ return toServer(line);
352
+ }
353
+ log(`${o.server} ${name} → blocked (check failed, ${why})`);
354
+ return reply(`Claros couldn't check this action (${why}), so it was not run. ` +
355
+ "Ask a person to confirm it is allowed before trying again.");
356
+ }
357
+ const multi = (r.workflow_ids?.length ?? 0) > 1; // routed to several maps: name them all
358
+ const wf = multi ? r.workflow_ids.join("+") : r.workflow_id;
359
+ const ids = (r.violations || []).map((v) => multi ? `${v.workflow_id}:${v.guardrail_id}` : v.guardrail_id).join(",");
360
+ const ms = `${Date.now() - t0}ms${reads ? ` (${reads} read${reads > 1 ? "s" : ""})` : ""}`;
361
+ if (r.allowed) {
362
+ const nudged = nudgesOf(r);
363
+ log(`${o.server} ${name} → ${nudged.length ? "nudge" : "allow"} ${wf ?? `(${r.reason ?? "no map"})`}${ids ? ` ${ids}` : ""} ${ms} ${r.check_id ?? ""}`);
364
+ for (const v of nudged)
365
+ log(` nudge: ${v.rule}${v.fix ? ` (fix: ${v.fix})` : ""}`);
366
+ // forwarded either way; a nudge rides back on the result (an id-less call has no result to carry it)
367
+ if (nudged.length && msg.id !== undefined)
368
+ nudges.set(JSON.stringify(msg.id), nudgeText(r));
369
+ return toServer(line);
370
+ }
371
+ log(`${o.server} ${name} → ${r.result === "ask" ? "ask" : "stop"} ${wf} ${ids} ${ms} ${r.check_id ?? ""}`);
372
+ reply(stopText(r));
373
+ }
374
+ lines(process.stdin, (line) => {
375
+ const msg = parse(line);
376
+ if (msg?.method === "tools/list" && msg.id !== undefined)
377
+ listIds.add(JSON.stringify(msg.id));
378
+ if (Array.isArray(msg) && msg.some((m) => m?.method === "tools/call" && !isRead(String(m.params?.name ?? "")))) {
379
+ log(`${o.server} batch with a write tools/call → rejected`); // MCP dropped batching in 2025-06-18
380
+ return toClient(JSON.stringify(msg.filter((m) => m?.id !== undefined).map((m) => ({ jsonrpc: "2.0", id: m.id,
381
+ error: { code: -32600, message: "Claros guard: send tools/call one at a time, not in a batch" } }))));
382
+ }
383
+ if (msg?.method !== "tools/call")
384
+ return toServer(line);
385
+ const name = String(msg.params?.name ?? "");
386
+ // in the background, on reads too (a tool Claros reclassified as write must not pass forever); this call uses what's known
387
+ if (Date.now() - kindsAt > KINDS_TTL && tools.size)
388
+ refreshKinds();
389
+ if (isRead(name)) {
390
+ log(`${o.server} ${name} → pass (read)`);
391
+ return toServer(line);
392
+ }
393
+ pending++; // ponytail: checks run concurrently; a write may reach the server after later messages (ids keep JSON-RPC sound)
394
+ intercept(line, msg).finally(() => { pending--; maybeEnd(); });
395
+ }, () => { stdinDone = true; maybeEnd(); });
396
+ }
397
+ async function main(argv) {
398
+ const sep = argv.indexOf("--");
399
+ const head = sep < 0 ? argv : argv.slice(0, sep), cmd = sep < 0 ? [] : argv.slice(sep + 1);
400
+ const usage = async () => `${(await import("./setup.js")).USAGE}\n${GUARD_USAGE}\n`;
401
+ if (head.includes("--help") || head.includes("-h") || head[0] === "help") { // any command: usage on stdout, exit 0
402
+ process.stdout.write(await usage());
403
+ return;
404
+ }
405
+ if (argv[0] === "mcp")
406
+ return void import("./bridge.js").then((m) => m.bridge());
407
+ if (["init", "test", "status", "undo", "export", "override"].includes(argv[0])) {
408
+ process.exitCode = await (await import("./setup.js")).run(argv);
409
+ return;
410
+ }
411
+ const fail = async (why = "") => {
412
+ process.stderr.write(why + await usage());
413
+ process.exit(2);
414
+ };
415
+ if (head[0] !== "guard")
416
+ return fail();
417
+ let v;
418
+ try {
419
+ v = parseArgs({
420
+ args: head.slice(1),
421
+ options: {
422
+ server: { type: "string" }, url: { type: "string" }, web: { type: "string" }, key: { type: "string" },
423
+ timeout: { type: "string" }, "fail-open": { type: "boolean" }, remote: { type: "string" },
424
+ header: { type: "string", multiple: true },
425
+ },
426
+ }).values;
427
+ }
428
+ catch (e) {
429
+ return fail(`${e.message}\n`);
430
+ }
431
+ if (!v.server || !cmd.length === !v.remote)
432
+ return fail(); // a command or a remote URL, not both
433
+ if (v.remote && !/^https?:\/\/\S+$/.test(v.remote))
434
+ return fail("--remote wants an http(s) URL\n");
435
+ const headers = {};
436
+ for (const h of v.header ?? []) {
437
+ const i = h.indexOf(":");
438
+ if (i < 1)
439
+ return fail(`--header wants "Name: value"\n`);
440
+ headers[h.slice(0, i).trim()] = h.slice(i + 1).trim();
441
+ }
442
+ let at;
443
+ try {
444
+ at = await resolve(v); // flag → env → ~/.config/getclaros (written by init) → the web app's /.well-known/claros.json
445
+ }
446
+ catch (e) { // still start the server: reads pass, writes fail closed, and each check asks the web app again
447
+ log(`${e.message} Write checks fail closed until it answers.`);
448
+ const s = loadConfig();
449
+ at = { url: "", key: v.key ?? process.env.CLAROS_KEY ?? s.key ?? "", source: "discovered",
450
+ web: (v.web ?? process.env.CLAROS_WEB ?? s.web ?? DEFAULT_WEB).replace(/\/+$/, "") };
451
+ }
452
+ if (!at.key)
453
+ log("no key (set CLAROS_KEY): every write check will fail" + (v["fail-open"] ? " open" : " closed"));
454
+ guard({ ...at, server: v.server, failOpen: !!v["fail-open"], timeout: Number(v.timeout ?? 5000) }, v.remote ? http(v.remote, headers) : stdio(cmd[0], cmd.slice(1)));
455
+ }
456
+ main(process.argv.slice(2));