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/setup.js ADDED
@@ -0,0 +1,589 @@
1
+ // getclaros init | test | undo | status (| export: a map's rules as markdown): puts the guard in front of the MCP servers an agent client already runs.
2
+ // Edits only existing config files, backs each one up first (~/.config/getclaros/backups/<time>/, 0600), writes
3
+ // atomically, and never prints the key.
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { spawn } from "node:child_process";
6
+ import { homedir, hostname } from "node:os";
7
+ import { basename, join } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { parseArgs } from "node:util";
10
+ import { addClaros, argvOf, CLAROS, detect, launcher, load, removeEntry, sha, unwrapServer, wrapServer, writeAtomic, } from "./clients.js";
11
+ import { cfgDir, loadConfig, relocate, resolve, saveConfig } from "./config.js";
12
+ const NAME = launcher().at(-1) === "getclaros" ? "npx getclaros" : "getclaros";
13
+ const say = (s = "") => process.stdout.write(s + "\n");
14
+ const warn = (s) => process.stderr.write(s + "\n");
15
+ const tilde = (p) => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p;
16
+ const keyId = (k) => k.split("_").slice(0, 2).join("_") + "_…"; // clr_<prefix>_… (the prefix is the lookup id, not the secret)
17
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
18
+ export const USAGE = `usage: ${NAME} init [--key clr_… | env CLAROS_KEY | --paste] [--no-browser] [--url <claros server> | --web <claros web app>] [--yes] [--servers a,b] [--project <dir>] [--no-mcp]
19
+ ${NAME} test [--app <server>]
20
+ ${NAME} status
21
+ ${NAME} undo [--all]
22
+ ${NAME} export <workflow_id> [--format agents|design|skill] [> AGENTS.md]
23
+ ${NAME} override <check_id> --reason "…" [--rule <rule id>] [--verdict wrong_stop|wrong_nudge|missed|right]`;
24
+ export async function run(argv) {
25
+ const [cmd, ...rest] = argv;
26
+ let v, pos;
27
+ try {
28
+ ({ values: v, positionals: pos } = parseArgs({ args: rest, allowPositionals: cmd === "export" || cmd === "override", options: {
29
+ url: { type: "string" }, web: { type: "string" }, key: { type: "string" }, yes: { type: "boolean", short: "y" }, servers: { type: "string" },
30
+ project: { type: "string" }, all: { type: "boolean" }, "no-mcp": { type: "boolean" }, app: { type: "string" },
31
+ paste: { type: "boolean" }, "no-browser": { type: "boolean" }, format: { type: "string" },
32
+ reason: { type: "string" }, rule: { type: "string" }, verdict: { type: "string" },
33
+ } }));
34
+ }
35
+ catch (e) {
36
+ warn(`${e.message}\n${USAGE}`);
37
+ return 2;
38
+ }
39
+ const project = v.project ?? process.cwd();
40
+ if (cmd === "init")
41
+ return init(v, project);
42
+ if (cmd === "test")
43
+ return test(v, project);
44
+ if (cmd === "status")
45
+ return status(v, project);
46
+ if (cmd === "undo")
47
+ return undo(v, project);
48
+ if (cmd === "export" && pos.length === 1 && ["agents", "design", "skill"].includes(v.format ?? "agents")) {
49
+ return exportMap(v, pos[0]);
50
+ }
51
+ if (cmd === "override" && pos.length === 1 && VERDICTS.includes(v.verdict ?? "wrong_stop"))
52
+ return override(v, pos[0]);
53
+ warn(USAGE);
54
+ return 2;
55
+ }
56
+ // ---- Claros API ----
57
+ async function api(url, key, path, body) {
58
+ const r = await fetch(url + path, {
59
+ method: body === undefined ? "GET" : "POST", signal: AbortSignal.timeout(10_000),
60
+ headers: { authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "content-type": "application/json" }) },
61
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
62
+ });
63
+ return { status: r.status, json: await r.json().catch(() => null) };
64
+ }
65
+ /** "valid" | why not. GET /v1/tools/kinds is the cheapest agent-scope read. */
66
+ async function keyStatus(url, key) {
67
+ try {
68
+ const { status } = await api(url, key, "/v1/tools/kinds?app=getclaros");
69
+ return status === 200 ? "valid" : status === 401 ? "rejected (unknown or revoked key)"
70
+ : status === 403 ? "missing the agent scope" : status === 429 ? "rate limited, try again in a minute" : `HTTP ${status}`;
71
+ }
72
+ catch (e) {
73
+ return `unreachable (${e.message})`;
74
+ }
75
+ }
76
+ /** The server (resolve), asking the web app again once if it doesn't answer at all (it moved). null = no server. */
77
+ async function locate(v) {
78
+ let r;
79
+ try {
80
+ r = await resolve(v);
81
+ }
82
+ catch (e) {
83
+ warn(e.message);
84
+ return null;
85
+ }
86
+ const answers = await fetch(r.url + "/healthz", { signal: AbortSignal.timeout(10_000) }).then(() => true, () => false);
87
+ if (!answers && await relocate(r))
88
+ warn(`Claros moved: now ${r.url}`);
89
+ return r;
90
+ }
91
+ // ---- browser sign-in (RFC 8628 device flow) ----
92
+ const VERSION = "0.1.0";
93
+ const sleep = (s) => new Promise((ok) => setTimeout(ok, s * Number(process.env.GETCLAROS_INTERVAL_MS ?? 1000)));
94
+ function openBrowser(url) {
95
+ if (process.env.GETCLAROS_NO_BROWSER)
96
+ return;
97
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32"
98
+ ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
99
+ try {
100
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => { }).unref();
101
+ }
102
+ catch { /* no browser: the URL is printed */ }
103
+ }
104
+ /** Device flow → an agent key, or why not. "unsupported" = this Claros has no device sign-in (fall back to a key). */
105
+ async function deviceSignIn(url, browser) {
106
+ let d;
107
+ try {
108
+ const r = await fetch(url + "/v1/device", { method: "POST", signal: AbortSignal.timeout(10_000),
109
+ headers: { "content-type": "application/json" },
110
+ body: JSON.stringify({ client_name: `getclaros ${VERSION}`, device_name: hostname() }) });
111
+ if (!r.ok)
112
+ return "unsupported";
113
+ d = await r.json();
114
+ }
115
+ catch {
116
+ return "unsupported";
117
+ }
118
+ say(`Sign in to Claros in your browser:
119
+
120
+ ${d.verification_uri_complete ?? d.verification_uri}
121
+
122
+ and check it shows the code ${d.user_code}. Waiting…`);
123
+ if (browser)
124
+ openBrowser(d.verification_uri_complete ?? d.verification_uri);
125
+ let interval = Number(d.interval) || 5;
126
+ const until = Date.now() + (Number(d.expires_in) || 600) * 1000;
127
+ while (Date.now() < until) {
128
+ await sleep(interval);
129
+ let r, j;
130
+ try {
131
+ r = await fetch(url + "/v1/device/token", { method: "POST", signal: AbortSignal.timeout(10_000),
132
+ headers: { "content-type": "application/x-www-form-urlencoded" },
133
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: d.device_code }) });
134
+ j = await r.json();
135
+ }
136
+ catch {
137
+ continue; // a network blip: keep polling until the code expires
138
+ }
139
+ if (r.ok && typeof j?.access_token === "string")
140
+ return { key: j.access_token, workspace: j.workspace };
141
+ if (j?.error === "authorization_pending")
142
+ continue;
143
+ if (j?.error === "slow_down") {
144
+ interval += 5;
145
+ continue;
146
+ }
147
+ if (j?.error === "access_denied")
148
+ return "the sign-in was denied in the browser";
149
+ if (j?.error === "expired_token")
150
+ return "the code expired before it was approved";
151
+ return `sign-in failed (${j?.error ?? `HTTP ${r.status}`})`;
152
+ }
153
+ return "the code expired before it was approved";
154
+ }
155
+ /** The key's workspace, from GET /v1/whoami (older servers: undefined). */
156
+ async function whoami(url, key) {
157
+ try {
158
+ const r = await api(url, key, "/v1/whoami");
159
+ return r.status === 200 && r.json?.workspace?.id ? r.json.workspace : undefined;
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ }
165
+ // ---- prompts ----
166
+ async function ask(q) {
167
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
168
+ try {
169
+ return await rl.question(q);
170
+ }
171
+ finally {
172
+ rl.close();
173
+ }
174
+ }
175
+ function askHidden(q) {
176
+ return new Promise((ok) => {
177
+ const stdin = process.stdin;
178
+ process.stderr.write(q);
179
+ stdin.setRawMode(true);
180
+ stdin.resume();
181
+ stdin.setEncoding("utf8");
182
+ let s = "";
183
+ const on = (chunk) => {
184
+ for (const ch of chunk) {
185
+ if (ch === "\u0003") {
186
+ stdin.setRawMode(false);
187
+ process.exit(130);
188
+ }
189
+ if (ch === "\r" || ch === "\n" || ch === "\u0004") {
190
+ stdin.setRawMode(false);
191
+ stdin.pause();
192
+ stdin.off("data", on);
193
+ process.stderr.write("\n");
194
+ return ok(s);
195
+ }
196
+ s = ch === "\u007f" || ch === "\b" ? s.slice(0, -1) : s + ch;
197
+ }
198
+ };
199
+ stdin.on("data", on);
200
+ });
201
+ }
202
+ const backupsDir = () => join(cfgDir(), "backups");
203
+ const newRun = (suffix = "") => ({ dir: join(backupsDir(), new Date().toISOString().replace(/[:.]/g, "-") + suffix), files: [] });
204
+ /** Backs up `before`, writes `after` atomically, records both hashes. */
205
+ function commit(run, path, before, after) {
206
+ if (!run.files.length) {
207
+ mkdirSync(cfgDir(), { recursive: true, mode: 0o700 });
208
+ mkdirSync(run.dir, { recursive: true, mode: 0o700 });
209
+ }
210
+ const backup = join(run.dir, `${run.files.length}-${basename(path)}`);
211
+ writeFileSync(backup, before, { mode: 0o600 });
212
+ writeAtomic(path, after);
213
+ run.files.push({ path, backup, before: sha(before), after: sha(after) });
214
+ writeFileSync(join(run.dir, "manifest.json"), JSON.stringify({ files: run.files }, null, 2), { mode: 0o600 });
215
+ }
216
+ function runs() {
217
+ if (!existsSync(backupsDir()))
218
+ return [];
219
+ return readdirSync(backupsDir()).filter((d) => !d.endsWith(".undone")).sort().flatMap((d) => {
220
+ try {
221
+ return [{ dir: join(backupsDir(), d), ...JSON.parse(readFileSync(join(backupsDir(), d, "manifest.json"), "utf8")) }];
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ });
227
+ }
228
+ /** A command line for the terminal: at most 3 parts, anything that looks like a secret masked. */
229
+ function show(argv) {
230
+ const safe = argv.slice(0, 3).map((a, i) => i && (/^[A-Za-z0-9_\-+/=.]{24,}$/.test(a) || /(key|token|secret|pass)/i.test(a) &&
231
+ a.includes("=")) ? "***" : a);
232
+ return safe.join(" ") + (argv.length > 3 ? " …" : "");
233
+ }
234
+ /** A remote server's address for the terminal: no query string (it may carry a token). */
235
+ const site = (u) => { try {
236
+ const x = new URL(u);
237
+ return x.origin + x.pathname;
238
+ }
239
+ catch {
240
+ return "(bad url)";
241
+ } };
242
+ const where = (s) => s.scope[0] === "projects" ? ` (project ${tilde(s.scope[1])})` : "";
243
+ // ---- init ----
244
+ async function init(v, project) {
245
+ const at = await locate(v);
246
+ if (!at)
247
+ return 3;
248
+ const { url } = at;
249
+ const web = at.source === "pinned" && !v.web && !process.env.CLAROS_WEB ? undefined : at.web; // --url alone: pinned
250
+ let { key } = at;
251
+ let workspace = key && key === loadConfig().key ? loadConfig().workspace : undefined;
252
+ if (!key && !v.paste) {
253
+ const d = await deviceSignIn(url, !v["no-browser"]);
254
+ if (typeof d === "object")
255
+ ({ key, workspace } = d);
256
+ else if (d !== "unsupported") {
257
+ warn(`Claros at ${url}: ${d}. Run ${NAME} init again.`);
258
+ return 3;
259
+ }
260
+ }
261
+ if (!key) {
262
+ if (!process.stdin.isTTY) {
263
+ warn(`No Claros key, and ${url} has no browser sign-in. Pass --key clr_… or set CLAROS_KEY (an \`agent\` key).`);
264
+ return 2;
265
+ }
266
+ key = (await askHidden("Paste your Claros key (clr_…, agent scope; input hidden): ")).trim();
267
+ }
268
+ const ks = await keyStatus(url, key);
269
+ if (ks !== "valid") {
270
+ warn(`Claros at ${url}: key ${keyId(key)} ${ks}.`);
271
+ return 3;
272
+ }
273
+ const saved = loadConfig();
274
+ workspace ??= await whoami(url, key);
275
+ const cfg = saved.url === url && saved.web === web && saved.key === key && saved.workspace?.name === workspace?.name
276
+ ? join(cfgDir(), "config.json") : saveConfig({ url, web, key, ...(workspace ? { workspace } : {}) });
277
+ say(`Signed in to ${url}${workspace?.name ? ` (workspace "${workspace.name}")` : ""} with key ${keyId(key)} (saved in ${tilde(cfg)}, readable only by you).`);
278
+ const files = detect(project);
279
+ if (!files.length) {
280
+ say(`\nSee a stop now: ${NAME} test`);
281
+ say("To guard an agent's tools: add an MCP server to Claude Code, Claude Desktop, Cursor, VS Code or Codex (none has one yet), then run init again.");
282
+ return 1;
283
+ }
284
+ const maps = await api(url, key, "/api/workflows").then((r) => (Array.isArray(r.json) ? r.json : []), () => []);
285
+ const mapOf = (name) => maps.find((m) => (m.apps ?? []).some((a) => norm(a) && norm(name) && (norm(a).includes(norm(name)) || norm(name).includes(norm(a)))));
286
+ const cands = [];
287
+ say();
288
+ for (const f of files) {
289
+ say(`${f.client} ${tilde(f.path)}`);
290
+ if (f.error)
291
+ say(` couldn't read it (${f.error}); left alone`);
292
+ for (const s of f.servers) {
293
+ if (s.ours)
294
+ say(` ${s.name}${where(s)} Claros MCP server (agents read the Work Map with get_workflow)`);
295
+ else if (s.guarded)
296
+ say(` [=] ${s.name}${where(s)} already guarded`);
297
+ else if (s.gap)
298
+ say(` [ ] ${s.name}${where(s)} remote ${site(s.entry.url)}: ${s.gap}`);
299
+ else {
300
+ const m = mapOf(s.name);
301
+ cands.push(s);
302
+ say(` ${String(cands.length).padStart(2)}. ${s.name}${where(s)} ${s.remote ? `remote ${site(s.entry.url)}` : show(argvOf(s.entry))}` +
303
+ (m ? ` ← Work Map "${m.name}"` : ""));
304
+ }
305
+ }
306
+ }
307
+ say();
308
+ const byName = (names) => cands.filter((s) => names.includes(s.name));
309
+ let picks = cands.filter((s) => mapOf(s.name));
310
+ if (v.servers !== undefined) {
311
+ const names = v.servers.split(",").map((s) => s.trim()).filter(Boolean);
312
+ const all = files.flatMap((f) => f.servers);
313
+ for (const n of names) {
314
+ const hit = all.filter((s) => s.name === n);
315
+ if (!hit.length) {
316
+ warn(`No MCP server named "${n}". Found: ${[...new Set(all.filter((s) => !s.ours).map((s) => s.name))].join(", ") || "none"}.`);
317
+ return 2;
318
+ }
319
+ if (hit.every((s) => s.gap))
320
+ say(`${n}: remote server, ${hit[0].gap}.`);
321
+ else if (hit.every((s) => s.guarded || s.gap))
322
+ say(`${n}: already guarded.`);
323
+ }
324
+ picks = byName(names);
325
+ }
326
+ else if (!v.yes) {
327
+ if (!process.stdin.isTTY) {
328
+ warn("Not a terminal: pass --yes (guard the servers that match a Work Map) or --servers a,b.");
329
+ return 2;
330
+ }
331
+ const def = picks.map((s) => cands.indexOf(s) + 1).join(",");
332
+ for (;;) {
333
+ const a = (await ask(`Guard which? Numbers (1,3), "all", "none"${def ? `, Enter = ${def}` : ""}: `)).trim().toLowerCase();
334
+ if (!a)
335
+ break;
336
+ if (a === "none") {
337
+ picks = [];
338
+ break;
339
+ }
340
+ if (a === "all") {
341
+ picks = cands;
342
+ break;
343
+ }
344
+ const ns = a.split(/[\s,]+/).map(Number);
345
+ if (ns.every((n) => n >= 1 && n <= cands.length)) {
346
+ picks = ns.map((n) => cands[n - 1]);
347
+ break;
348
+ }
349
+ warn(`Pick numbers between 1 and ${cands.length}.`);
350
+ }
351
+ }
352
+ else if (!picks.length && cands.length) {
353
+ say("None of these servers matches a Work Map yet, so none is guarded. Pick them with --servers a,b.");
354
+ }
355
+ const run = newRun();
356
+ const changed = [];
357
+ const userClients = new Set(files.filter((f) => f.user && !f.error).map((f) => f.client));
358
+ for (const f of files.filter((x) => !x.error)) {
359
+ const before = f.text, mine = picks.filter((s) => s.file === f);
360
+ try {
361
+ for (const s of mine) {
362
+ f.text = wrapServer(f, s);
363
+ load(f);
364
+ say(`guarded ${s.name}${where(s)} in ${f.client} (${tilde(f.path)})`);
365
+ }
366
+ // the Claros MCP server: in each client's user config, or its project file when the client has none
367
+ const top = f.servers.find((s) => s.name === CLAROS && s.scope.length === 1);
368
+ if (!v["no-mcp"] && (f.user || !userClients.has(f.client))) {
369
+ if (!top) {
370
+ f.text = addClaros(f);
371
+ load(f);
372
+ say(`added the Claros MCP server to ${f.client} (${tilde(f.path)})`);
373
+ }
374
+ else if (!top.ours)
375
+ say(`kept the existing "${CLAROS}" entry in ${tilde(f.path)} (not ours)`);
376
+ }
377
+ }
378
+ catch (e) {
379
+ warn(`${tilde(f.path)}: ${e.message}; left unchanged`);
380
+ f.text = before;
381
+ continue;
382
+ }
383
+ if (f.text !== before) {
384
+ commit(run, f.path, before, f.text);
385
+ changed.push(f.client);
386
+ }
387
+ }
388
+ if (!changed.length) {
389
+ say("Nothing to change.");
390
+ return 0;
391
+ }
392
+ say(`\nBacked up every file before changing it (${tilde(run.dir)}).`);
393
+ say(`Restart ${[...new Set(changed)].join(", ")} to load the change. Then: ${NAME} test Undo: ${NAME} undo`);
394
+ return 0;
395
+ }
396
+ // ---- test ----
397
+ async function test(v, project) {
398
+ const at = await locate(v);
399
+ if (!at)
400
+ return 3;
401
+ const { url, key } = at;
402
+ if (!key) {
403
+ warn(`Not signed in: run ${NAME} init first (or set CLAROS_KEY).`);
404
+ return 2;
405
+ }
406
+ const guarded = v.app ? [v.app] : [...new Set(detect(project).flatMap((f) => f.servers.filter((s) => s.guarded).map((s) => s.name)))];
407
+ try {
408
+ const q = guarded.map((a) => `app=${encodeURIComponent(a)}`).join("&");
409
+ const ex = await api(url, key, `/v1/check/example${q ? "?" + q : ""}`);
410
+ if (ex.status === 401 || ex.status === 403) {
411
+ warn(`Claros rejected key ${keyId(key)} (HTTP ${ex.status}). Run ${NAME} init with a valid agent key.`);
412
+ return 3;
413
+ }
414
+ if (ex.status === 404) {
415
+ say(`No approved Work Map with an exact rule to test yet. Paste a policy at ${at.web}/policy, approve the map, then run this again.`);
416
+ return 1;
417
+ }
418
+ if (ex.status !== 200)
419
+ throw new Error(`HTTP ${ex.status}`);
420
+ const e = ex.json, t0 = Date.now();
421
+ const r = await api(url, key, "/v1/check", { workflow_id: e.workflow_id, app: e.app ?? guarded[0] ?? undefined,
422
+ tool: "getclaros_test", actor: "getclaros test", state: e.state });
423
+ if (r.status !== 200)
424
+ throw new Error(`HTTP ${r.status}`);
425
+ const ms = Date.now() - t0;
426
+ if (e.demo)
427
+ say(`DEMO: ${guarded.length ? `none of your guarded servers (${guarded.join(", ")}) matches` : "no server is guarded yet, so nothing matches"} a Work Map, so this checks a made-up record against "${e.name}".`);
428
+ say(`Made-up test action for ${e.app ?? "a demo app"} (only checked, never run): ${Object.entries(e.state).map(([k, x]) => `${k} = ${x}`).join(", ")}`);
429
+ if (r.json.allowed) {
430
+ say(`\nClaros let it through (check ${r.json.check_id}). It should have been stopped by rule ${e.guardrail_id}; tell the Claros team.`);
431
+ return 1;
432
+ }
433
+ say(`\nClaros stopped it in ${ms} ms (Work Map "${e.name}", check ${r.json.check_id}):`);
434
+ for (const x of r.json.violations.filter((x) => x.action === "stop" || x.action === "ask")) {
435
+ say(`\n Rule: ${x.rule}`);
436
+ if (x.quote)
437
+ say(` In the expert's words${x.expert ? ` (${x.expert})` : ""}: "${x.quote}"`);
438
+ say(` Who to ask: ${x.who_to_ask || x.owner || x.expert || "the process owner"}`);
439
+ }
440
+ say(`\nYour agent gets this as the tool's error instead of the call going through. Nothing was sent to ${e.app ?? "any app"}.`);
441
+ return 0;
442
+ }
443
+ catch (err) {
444
+ warn(`Couldn't reach Claros at ${url} (${err.message}).`);
445
+ return 3;
446
+ }
447
+ }
448
+ // ---- export ----
449
+ /** A Work Map's rules as markdown on stdout (GET /v1/maps/<id>/export): redirect it into AGENTS.md, DESIGN.md or a
450
+ * skill's SKILL.md. An advisory copy: Claros still enforces the rules when an agent acts. */
451
+ async function exportMap(v, workflow) {
452
+ const at = await locate(v);
453
+ if (!at)
454
+ return 3;
455
+ if (!at.key) {
456
+ warn(`No key: run ${NAME} init, or set CLAROS_KEY.`);
457
+ return 2;
458
+ }
459
+ try {
460
+ const r = await fetch(`${at.url}/v1/maps/${encodeURIComponent(workflow)}/export?format=${v.format ?? "agents"}`, { headers: { authorization: `Bearer ${at.key}` }, signal: AbortSignal.timeout(10_000) });
461
+ if (r.status === 404) {
462
+ warn(`No Work Map "${workflow}" in this workspace.`);
463
+ return 1;
464
+ }
465
+ if (!r.ok) {
466
+ warn(`Claros answered HTTP ${r.status}${r.status === 401 || r.status === 403 ? ` for key ${keyId(at.key)}` : ""}.`);
467
+ return 3;
468
+ }
469
+ process.stdout.write(await r.text());
470
+ return 0;
471
+ }
472
+ catch (err) {
473
+ warn(`Couldn't reach Claros at ${at.url} (${err.message}).`);
474
+ return 3;
475
+ }
476
+ }
477
+ // ---- override: "this check got a rule wrong, because …" → the rule's expert (POST /v1/checks/{id}/override) ----
478
+ const VERDICTS = ["wrong_stop", "wrong_nudge", "missed", "right"];
479
+ async function override(v, checkId) {
480
+ const at = await locate(v);
481
+ if (!at)
482
+ return 3;
483
+ if (!at.key) {
484
+ warn(`No key: run ${NAME} init, or set CLAROS_KEY.`);
485
+ return 2;
486
+ }
487
+ try {
488
+ const { status, json } = await api(at.url, at.key, `/v1/checks/${encodeURIComponent(checkId)}/override`, { verdict: v.verdict ?? "wrong_stop", rule_id: v.rule ?? null, reason: v.reason ?? "" });
489
+ if (status === 200) {
490
+ say(`Sent to the rule's expert (${json.override.id}). The rule stays as it is until they confirm a change.`);
491
+ return 0;
492
+ }
493
+ const d = json?.detail;
494
+ warn(status === 404 ? `No check "${checkId}" in this workspace.`
495
+ : status === 422 ? `Not sent: ${Array.isArray(d?.invalid) ? d.invalid.join("; ") : "check the arguments"}.`
496
+ : `Claros answered HTTP ${status}${status === 401 || status === 403 ? ` for key ${keyId(at.key)}` : ""}.`);
497
+ return status === 422 || status === 404 ? 1 : 3;
498
+ }
499
+ catch (err) {
500
+ warn(`Couldn't reach Claros at ${at.url} (${err.message}).`);
501
+ return 3;
502
+ }
503
+ }
504
+ // ---- status ----
505
+ async function status(v, project) {
506
+ const at = await locate(v);
507
+ if (!at)
508
+ return 3;
509
+ const { url, key } = at;
510
+ const up = await fetch(url + "/healthz", { signal: AbortSignal.timeout(10_000) }).then((r) => r.ok, () => false);
511
+ const ks = key ? await keyStatus(url, key) : "not set (run init)";
512
+ const ws = ks === "valid" ? (await whoami(url, key))?.name ?? (loadConfig().key === key ? loadConfig().workspace?.name : null) : null;
513
+ say(`Claros ${url} ${up ? "reachable" : "unreachable"} key ${key ? keyId(key) + " " : ""}${ks}${ws ? ` workspace "${ws}"` : ""}`);
514
+ for (const f of detect(project)) {
515
+ say(`\n${f.client} ${tilde(f.path)}${f.error ? ` couldn't read it (${f.error})` : ""}`);
516
+ for (const s of f.servers) {
517
+ say(` ${(s.name + where(s)).padEnd(24)} ${s.ours ? "Claros MCP server" : s.guarded ? "guarded"
518
+ : s.gap ? `remote, ${s.gap}` : s.remote ? "remote, not guarded" : "not guarded"}`);
519
+ }
520
+ }
521
+ return !key ? 2 : up && ks === "valid" ? 0 : 3;
522
+ }
523
+ // ---- undo ----
524
+ /** Unwraps every guarded server and removes our Claros entry in place; the rest of the file stays. */
525
+ function unwrapAll(f) {
526
+ let n = 0;
527
+ for (const s of f.servers.filter((x) => x.guarded)) {
528
+ f.text = unwrapServer(f, s);
529
+ load(f);
530
+ n++;
531
+ }
532
+ for (const s of f.servers.filter((x) => x.ours)) {
533
+ f.text = removeEntry(f, s);
534
+ load(f);
535
+ }
536
+ return n;
537
+ }
538
+ async function undo(v, project) {
539
+ const scope = v.all ? runs() : runs().slice(-1);
540
+ if (!scope.length && !v.all) {
541
+ say(`Nothing to undo: no backups from ${NAME} init.`);
542
+ return 0;
543
+ }
544
+ const chains = new Map();
545
+ for (const r of scope)
546
+ for (const b of r.files)
547
+ chains.set(b.path, [...(chains.get(b.path) ?? []), { ...b, run: r.dir }]);
548
+ const safety = newRun("-undo.undone"); // what undo itself overwrites is backed up too
549
+ const byPath = new Map(detect(project).map((f) => [f.path, f]));
550
+ for (const [path, chain] of chains) {
551
+ if (!existsSync(path)) {
552
+ say(`${tilde(path)}: gone; nothing to undo`);
553
+ continue;
554
+ }
555
+ const cur = readFileSync(path, "utf8");
556
+ const same = sha(cur) === chain.at(-1).after && chain.every((b, i) => !i || b.before === chain[i - 1].after);
557
+ if (same) {
558
+ commit(safety, path, cur, readFileSync(chain[0].backup, "utf8"));
559
+ say(`${tilde(path)}: restored from the backup taken before init (unchanged since)`);
560
+ }
561
+ else {
562
+ const f = byPath.get(path) ?? detect(project).find((x) => x.path === path);
563
+ if (!f || f.error) {
564
+ warn(`${tilde(path)}: changed since init and couldn't be read (${f?.error ?? "unknown format"}); backup: ${chain[0].backup}`);
565
+ continue;
566
+ }
567
+ const n = unwrapAll(f);
568
+ if (f.text !== cur)
569
+ commit(safety, path, cur, f.text);
570
+ say(`${tilde(path)}: changed since init, so unwrapped ${n} server(s) in place and kept the other edits`);
571
+ }
572
+ byPath.delete(path);
573
+ }
574
+ if (v.all) {
575
+ for (const f of byPath.values()) {
576
+ if (f.error)
577
+ continue;
578
+ const cur = f.text, n = unwrapAll(f);
579
+ if (f.text !== cur) {
580
+ commit(safety, f.path, cur, f.text);
581
+ say(`${tilde(f.path)}: unwrapped ${n} server(s) in place`);
582
+ }
583
+ }
584
+ }
585
+ for (const r of scope)
586
+ renameSync(r.dir, r.dir + ".undone");
587
+ say(`Done. Restart your agent clients to load the change.${safety.files.length ? ` What undo replaced is in ${tilde(safety.dir)}.` : ""}`);
588
+ return 0;
589
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "getclaros",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Claros guard: checks an agent's MCP tool calls against your experts' rules before they run",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "getclaros": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "test": "tsc && node --test test/*.test.mjs",
19
+ "smoke": "tsc && node scripts/smoke.mjs && node scripts/smoke-init.mjs"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^20.17.0",
23
+ "typescript": "^5.6.0"
24
+ },
25
+ "dependencies": {
26
+ "jsonc-parser": "3.3.1"
27
+ }
28
+ }