getclaros 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # getclaros guard
2
2
 
3
- A local stdio MCP proxy. It wraps an app's MCP server and checks every write tool call against the company's
4
- expert-approved Work Maps (`POST /v1/check`) before the call reaches the app. A call that breaks a rule is never
3
+ A local stdio MCP proxy, plus native hooks for coding agents. It wraps an app's MCP server and checks every write tool
4
+ call against the company's expert-approved Work Maps (`POST /v1/check`) before the call reaches the app; the hooks
5
+ check a coding agent's shell commands and file edits the same way ([Coding agents](#coding-agents-claude-code-codex-cursor-shell-commands-and-file-edits)). A call that breaks a rule is never
5
6
  forwarded; the agent gets a tool error with the rule, the expert's words and who to ask.
6
7
 
7
8
  The package name (`getclaros`) is a placeholder and lives only in `package.json`.
@@ -21,7 +22,7 @@ or browser-use project, installs the browser hook with that project's own packag
21
22
  is guarded and can reach Claros.
22
23
 
23
24
  ```
24
- claros” getclaros 0.2.0
25
+ claros” getclaros 0.3.0
25
26
 
26
27
  Sign in opening your browser
27
28
  › https://claros-eta.vercel.app/device?code=ZQRC-ZCTP
@@ -86,6 +87,37 @@ What it changes, in files that already exist (it never creates a client config):
86
87
  up front with `get_workflow`; in each client's user config (or its project config when it has no user config).
87
88
  - Every file is backed up before it changes (`~/.config/getclaros/backups/<time>/`) and written atomically.
88
89
 
90
+ ### Coding agents (Claude Code, Codex, Cursor): shell commands and file edits
91
+
92
+ The MCP guard sees MCP tool calls only. A coding agent also runs shell commands and edits files with its own built-in
93
+ tools, so `init` adds a native hook to each coding agent it finds (its directory exists in your home or the project):
94
+ the agent runs `npx -y getclaros hook <agent>` before each shell command or file edit, the hook asks Claros
95
+ (`POST /v1/check`) and answers in the agent's own protocol. MCP calls stay with the MCP guard, so nothing is checked
96
+ twice.
97
+
98
+ | Agent | Hooks file (project scope; `--user`: all projects) | Checked | stop | ask | nudge |
99
+ |---|---|---|---|---|---|
100
+ | Claude Code | `.claude/settings.json` (`~/.claude/settings.json`), `PreToolUse` on `Bash\|PowerShell\|Edit\|Write\|NotebookEdit` | shell, edits | denied; the rule, the expert's words and who to ask go to Claude | Claude Code's own ask: you confirm, with the owner and check id | allowed, the rule and fix added to Claude's context |
101
+ | Codex | `.codex/hooks.json` (`$CODEX_HOME/hooks.json`), `PreToolUse` on `Bash` and `apply_patch` | shell, edits (the files named in the patch) | denied, with the rule | denied with "ask <owner>" and the check id (Codex has no ask on `PreToolUse`) | allowed, added as context |
102
+ | Cursor | `.cursor/hooks.json` (`~/.cursor/hooks.json`), `beforeShellExecution` + `preToolUse` on `Write\|Delete` | shell, edits | denied; the rule goes to the agent | shell: Cursor's own ask; edits: denied with the owner (Cursor doesn't enforce ask on `preToolUse` yet) | allowed, the rule and fix go to the agent |
103
+
104
+ - The action Claros sees: `tool` `shell` with `{command, cwd, project}`, or `write_file` / `edit_file` /
105
+ `delete_file` with `{file_path (relative to the project), content or diff, project}`; `actor` is the agent. The
106
+ project is the agent's project directory, else the git root, else the working directory. Content over 20,000
107
+ characters is cut.
108
+ - Merged into what the file holds: your own hooks and settings stay, ours is appended once, and `init` again changes
109
+ nothing. `undo` restores the file from its backup, or, if you changed it since, removes only our entries.
110
+ - Fail-open by default: when Claros can't be reached, times out (`--timeout`, 5000 ms) or rejects the key, the action
111
+ runs and the agent shows a warning ("Claros couldn't check … it ran unchecked"). `init --fail-closed` installs
112
+ `hook <agent> --fail-closed`, which blocks instead.
113
+ - The hook reads the key from `$CLAROS_KEY`, the saved config or the project's `.env`, and the server from the saved
114
+ config (asked from the web app once, then cached). Nothing secret goes into the hooks file, so a project
115
+ `.claude/settings.json` can be committed; a teammate without a key gets the fail-open warning until they run `init`.
116
+ - Codex runs a new or changed hook only after you trust it in `/hooks`, and project hooks only in a trusted project.
117
+ - Each call starts `npx`; for less latency install the package (`npm i -g getclaros`) and run `init` with
118
+ `GETCLAROS_LAUNCHER=getclaros`.
119
+ - `--no-hooks` skips this part of `init`; `doctor` lists which agents are hooked and where.
120
+
89
121
  ### Browser agents (Playwright, browser-use)
90
122
 
91
123
  `init` also looks at the project directory (the current one, or `--project <dir>`):
package/dist/cli.js CHANGED
@@ -7,11 +7,15 @@
7
7
  // When Claros needs the record a call targets (a submit by name, a partial record, an item's group), it answers
8
8
  // `need`; the guard reads those records itself through the wrapped server's read tools (ids the client never sees),
9
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
+ // An "ask" opens a question to the rule's owner in Claros: the guard waits (--ask-wait, default 120 s) on the decision
11
+ // URL and forwards the call only when it is approved for this exact call (sha256 of {app, tool, arguments}); a denial
12
+ // or no answer comes back as the stop, with the reason.
10
13
  import { spawn } from "node:child_process";
11
14
  import { createHash, randomUUID } from "node:crypto";
12
15
  import { readFileSync } from "node:fs";
13
16
  import { parseArgs } from "node:util";
14
17
  import { reduceRows, rowsOf } from "./query.js";
18
+ import { nudgesOf, nudgeText, stopText } from "./verdict.js";
15
19
  import { ask, serve } from "./peers.js";
16
20
  import { DEFAULT_WEB, loadConfig, relocate, resolve } from "./config.js";
17
21
  // the package name lives in package.json only (placeholder until decided)
@@ -20,8 +24,11 @@ const NAME = JSON.parse(readFileSync(new URL("../package.json", import.meta.url)
20
24
  const READ = /^(get|list|search|read|find|describe|fetch)|^(check|run|export)_|^(version|ping)$/i;
21
25
  const LOOKUP_TTL = 60_000, KINDS_TTL = 30_000;
22
26
  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" ...])
27
+ [--timeout <ms>] [--fail-open] [--ask-wait <seconds, 0 = don't wait>]
28
+ (-- <command> [args...] | --remote <mcp url> [--header "K: V" ...])
24
29
  ${NAME} mcp (the Claros MCP server over stdio, with the saved key)
30
+ ${NAME} hook <claude-code|codex|cursor> [--fail-closed] [--timeout <ms>] (run by the agent per shell
31
+ command or file edit; init installs it)
25
32
  Server: --url / CLAROS_URL, else the saved one, else asked from the web app (--web / CLAROS_WEB, default
26
33
  https://claros-eta.vercel.app, at /.well-known/claros.json). Help: ${NAME} <command> --help`;
27
34
  const log = (s) => process.stderr.write(`[${NAME} guard] ${s}\n`);
@@ -60,52 +67,11 @@ function parse(b) {
60
67
  return undefined;
61
68
  }
62
69
  }
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
- }
70
+ /** JSON with sorted keys, as the server's approvals.payload_hash canonicalises it. */
71
+ const canon = (v) => Array.isArray(v) ? v.map(canon) : v !== null && typeof v === "object"
72
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])])) : v;
73
+ /** sha256 of the exact action: an approval in Claros carries the hash of the call it was given for. */
74
+ const payloadHash = (app, tool, args) => createHash("sha256").update(JSON.stringify(canon({ app, tool, arguments: args }))).digest("hex");
109
75
  /** fetch, and once more at the web app's current server address if this one didn't answer (it moved). */
110
76
  async function reach(o, path, init = {}) {
111
77
  const go = async () => {
@@ -128,12 +94,34 @@ async function check(o, tool, args, fetched = {}) {
128
94
  method: "POST",
129
95
  headers: { "content-type": "application/json", ...(o.key ? { authorization: `Bearer ${o.key}` } : {}) },
130
96
  body: JSON.stringify({ app: o.server, tool, action: tool, state: flatten(args ?? {}), arguments: args ?? {},
131
- ...(Object.keys(fetched).length ? { fetched } : {}) }),
97
+ ...(Object.keys(fetched).length ? { fetched } : {}), ...(o.askWait > 0 ? { ask_wait: o.askWait } : {}) }),
132
98
  });
133
99
  if (!res.ok)
134
100
  throw new Error(`HTTP ${res.status}`);
135
101
  return res.json();
136
102
  }
103
+ /** An ask's answer: long-polls the decision URL until the owner decides or Claros times the question out (at
104
+ * ask_wait, plus a few seconds' grace). null = still open (Claros unreachable or late). */
105
+ async function decision(o, r, hash) {
106
+ const end = Date.now() + o.askWait * 1000 + 5000;
107
+ const headers = o.key ? { authorization: `Bearer ${o.key}` } : {};
108
+ while (Date.now() < end) {
109
+ const wait = Math.max(1, Math.min(25, Math.ceil((end - Date.now()) / 1000)));
110
+ try {
111
+ const res = await fetch(`${o.url}${r.question.decision_url}?wait=${wait}&payload_sha256=${hash}`, { headers, signal: AbortSignal.timeout(wait * 1000 + o.timeout) });
112
+ if (!res.ok)
113
+ throw new Error(`HTTP ${res.status}`);
114
+ const v = await res.json();
115
+ if (v.status !== "open")
116
+ return v;
117
+ }
118
+ catch (e) {
119
+ log(`decision poll failed (${e.message}); retrying`);
120
+ await new Promise((ok) => setTimeout(ok, 1000));
121
+ }
122
+ }
123
+ return null;
124
+ }
137
125
  /** A read tool's result → the record: structuredContent, else JSON in the text (whole, or its outermost {...}). */
138
126
  function recordOf(tool, m) {
139
127
  if (m?.error || m?.result?.isError)
@@ -369,7 +357,20 @@ function guard(o, connect) {
369
357
  return toServer(line);
370
358
  }
371
359
  log(`${o.server} ${name} → ${r.result === "ask" ? "ask" : "stop"} ${wf} ${ids} ${ms} ${r.check_id ?? ""}`);
372
- reply(stopText(r));
360
+ if (r.result !== "ask" || !r.question?.decision_url || o.askWait <= 0)
361
+ return reply(stopText(r));
362
+ const hash = payloadHash(o.server, name, args ?? {});
363
+ const who = r.violations?.find((v) => v.action === "ask")?.who_to_ask || "the rule's owner";
364
+ log(` waiting up to ${o.askWait}s for ${who}'s answer`);
365
+ const v = await decision(o, r, hash);
366
+ if (v?.decision === "approve" && v.payload_sha256 === hash) {
367
+ log(`${o.server} ${name} → approved by ${v.approver} (${v.authority}) ${r.check_id}`);
368
+ return toServer(line);
369
+ }
370
+ log(`${o.server} ${name} → ${v?.decision ?? "no answer"} ${r.check_id}`);
371
+ reply(stopText(r, v?.decision === "approve" ? "Claros stopped this action: the approval was for a different action than this call. Do not run it."
372
+ : v?.decision === "deny" ? `Claros stopped this action: ${v.approver ?? who} said no${v.reason ? `: ${v.reason}` : "."} Do not retry it as is.`
373
+ : `Claros stopped this action: no answer from ${who} within ${o.askWait} s. Do not run it without their answer.`));
373
374
  }
374
375
  lines(process.stdin, (line) => {
375
376
  const msg = parse(line);
@@ -404,6 +405,10 @@ async function main(argv) {
404
405
  }
405
406
  if (argv[0] === "mcp")
406
407
  return void import("./bridge.js").then((m) => m.bridge());
408
+ if (argv[0] === "hook") { // a coding agent's per-tool-call hook (hooks.ts)
409
+ process.exitCode = await (await import("./hooks.js")).main(argv.slice(1));
410
+ return;
411
+ }
407
412
  if (["init", "test", "status", "doctor", "undo", "export", "override"].includes(argv[0])) {
408
413
  process.exitCode = await (await import("./setup.js")).run(argv);
409
414
  return;
@@ -421,7 +426,7 @@ async function main(argv) {
421
426
  options: {
422
427
  server: { type: "string" }, url: { type: "string" }, web: { type: "string" }, key: { type: "string" },
423
428
  timeout: { type: "string" }, "fail-open": { type: "boolean" }, remote: { type: "string" },
424
- header: { type: "string", multiple: true },
429
+ header: { type: "string", multiple: true }, "ask-wait": { type: "string" },
425
430
  },
426
431
  }).values;
427
432
  }
@@ -432,6 +437,9 @@ async function main(argv) {
432
437
  return fail(); // a command or a remote URL, not both
433
438
  if (v.remote && !/^https?:\/\/\S+$/.test(v.remote))
434
439
  return fail("--remote wants an http(s) URL\n");
440
+ const askWait = Number(v["ask-wait"] ?? 120);
441
+ if (!(askWait >= 0))
442
+ return fail("--ask-wait wants seconds (0 = don't wait)\n");
435
443
  const headers = {};
436
444
  for (const h of v.header ?? []) {
437
445
  const i = h.indexOf(":");
@@ -451,6 +459,6 @@ async function main(argv) {
451
459
  }
452
460
  if (!at.key)
453
461
  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)));
462
+ guard({ ...at, server: v.server, failOpen: !!v["fail-open"], timeout: Number(v.timeout ?? 5000), askWait }, v.remote ? http(v.remote, headers) : stdio(cmd[0], cmd.slice(1)));
455
463
  }
456
464
  main(process.argv.slice(2));
package/dist/hooks.js ADDED
@@ -0,0 +1,348 @@
1
+ // Coding-agent hooks: `getclaros hook <agent>` runs once per tool call, reads the agent's hook payload on stdin, maps a
2
+ // shell command or a file edit to a Claros action, asks POST /v1/check and answers in the agent's own protocol.
3
+ // MCP tool calls are left to the MCP guard (cli.ts), so nothing is checked twice.
4
+ // Protocols (official docs, read 2026-10-24):
5
+ // - Claude Code https://code.claude.com/docs/en/hooks settings.json {hooks: {PreToolUse: [{matcher, hooks: [{type:
6
+ // "command", command, timeout}]}]}}; stdin {cwd, tool_name, tool_input}; stdout hookSpecificOutput {permissionDecision
7
+ // allow|deny|ask, permissionDecisionReason (deny: to Claude, ask: to the user), additionalContext}, systemMessage.
8
+ // - Codex https://developers.openai.com/codex/hooks hooks.json, same shape; shell = Bash, edits = apply_patch (the
9
+ // patch in tool_input.command). "ask" is not supported on PreToolUse (the call would run): ask blocks instead.
10
+ // Hooks run only after the user trusts them in /hooks; project hooks only in a trusted project.
11
+ // - Cursor https://cursor.com/docs/hooks hooks.json {version: 1, hooks: {beforeShellExecution: [{command}],
12
+ // preToolUse: [{command, matcher}]}}; stdout {permission allow|deny|ask, user_message, agent_message}. There is no
13
+ // before-edit event: preToolUse on Write/Delete gates edits; its "ask" isn't enforced yet, so ask blocks there.
14
+ // Kept light: node builtins, config.js and verdict.js at run time; jsonc-parser only when installing.
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
18
+ import { parseArgs } from "node:util";
19
+ import { loadConfig, relocate, resolve, saveConfig } from "./config.js";
20
+ import { nudgesOf, nudgeText, stopText } from "./verdict.js";
21
+ export const AGENTS = ["claude-code", "codex", "cursor"];
22
+ const MAX = 20_000; // ponytail: a huge write is checked on its first 20k chars; send a summary if rules need the rest
23
+ const cut = (s) => { const t = String(s ?? ""); return t.length > MAX ? t.slice(0, MAX) + "\n…(truncated)" : t; };
24
+ /** The project root: the agent's own variable, else the nearest git root above cwd, else cwd. */
25
+ export function rootOf(p, cwd, env = process.env) {
26
+ const set = env.CLAUDE_PROJECT_DIR || env.CURSOR_PROJECT_DIR || p.workspace_roots?.[0];
27
+ if (set)
28
+ return set;
29
+ for (let d = cwd;; d = dirname(d)) {
30
+ if (existsSync(join(d, ".git")))
31
+ return d;
32
+ if (dirname(d) === d)
33
+ return cwd;
34
+ }
35
+ }
36
+ const rel = (f, root) => {
37
+ const r = isAbsolute(f) ? relative(root, f) : f;
38
+ return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(sep).join("/") : f;
39
+ };
40
+ const diffOf = (edits) => edits.map((e) => [...String(e.old_string ?? "").split("\n").map((l) => `-${l}`), ...String(e.new_string ?? "").split("\n").map((l) => `+${l}`)].join("\n"))
41
+ .join("\n");
42
+ /** Files an apply_patch envelope touches (*** Add / Update / Delete File: …, *** Move to: …). */
43
+ export const patchFiles = (patch) => [...patch.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm)].map((m) => m[1].trim());
44
+ /** A hook payload → the action Claros checks, or null (not a shell command or an edit: let it run unchecked). */
45
+ export function actionOf(agent, p, env = process.env) {
46
+ const cwd = String(p.cwd ?? p.tool_input?.working_directory ?? process.cwd()), root = rootOf(p, cwd, env);
47
+ const base = { project: basename(root), cwd: cwd === root ? "." : rel(cwd, root) };
48
+ const t = p.tool_name, i = p.tool_input ?? {};
49
+ const shell = (command) => ({ tool: "shell", state: { command: cut(command), ...base }, arguments: { command } });
50
+ const file = (tool, f, more) => ({ tool, state: { file_path: rel(f, root), ...more, project: base.project }, arguments: i });
51
+ if (agent === "cursor") {
52
+ if (p.hook_event_name === "beforeShellExecution")
53
+ return shell(String(p.command ?? ""));
54
+ if (t === "Shell")
55
+ return shell(String(i.command ?? ""));
56
+ // ponytail: Cursor documents no tool_input for Write / Delete; these are the field names its other events use
57
+ if (t === "Write") {
58
+ const f = String(i.file_path ?? i.path ?? "");
59
+ return file("write_file", f, i.edits ? { diff: cut(diffOf(i.edits)) } : { content: cut(i.content ?? i.contents ?? "") });
60
+ }
61
+ if (t === "Delete")
62
+ return file("delete_file", String(i.file_path ?? i.path ?? ""), {});
63
+ return null;
64
+ }
65
+ if (t === "Bash" || t === "PowerShell")
66
+ return shell(String(i.command ?? ""));
67
+ if (agent === "codex") {
68
+ if (t !== "apply_patch")
69
+ return null;
70
+ const patch = String(i.command ?? i.patch ?? ""), files = patchFiles(patch);
71
+ return file("edit_file", files[0] ?? "", { diff: cut(patch), ...(files.length > 1 ? { files: files.map((f) => rel(f, root)).join(", ") } : {}) });
72
+ }
73
+ if (t === "Write")
74
+ return file("write_file", String(i.file_path ?? ""), { content: cut(i.content) });
75
+ if (t === "Edit")
76
+ return file("edit_file", String(i.file_path ?? ""), { diff: cut(diffOf([i])) });
77
+ if (t === "MultiEdit")
78
+ return file("edit_file", String(i.file_path ?? ""), { diff: cut(diffOf(i.edits ?? [])) });
79
+ if (t === "NotebookEdit")
80
+ return file("edit_file", String(i.notebook_path ?? ""), { content: cut(i.new_source) });
81
+ return null;
82
+ }
83
+ const who = (r) => {
84
+ const v = (r.violations ?? []).find((x) => x.action === "ask" || x.action === "stop") ?? {};
85
+ return v.who_to_ask || v.owner || v.expert || "the rule's owner";
86
+ };
87
+ const firstRule = (r) => (r.violations ?? []).find((x) => x.action === "ask" || x.action === "stop")?.rule ?? "a team rule";
88
+ /** A /v1/check answer (or why the check failed) → the agent's hook output. `cursorPre`: Cursor's preToolUse (no ask). */
89
+ export function outputOf(agent, r, err, o = {}) {
90
+ const what = o.tool ?? "this action";
91
+ let kind, text = "", short = "";
92
+ if (err) {
93
+ if (o.failClosed) {
94
+ kind = "stop";
95
+ text = `Claros couldn't check this action (${err}), so it was not run. Ask a person to confirm it is allowed before trying again.`;
96
+ short = `Claros couldn't check ${what} (${err}); blocked (fail-closed).`;
97
+ }
98
+ else {
99
+ kind = "warn";
100
+ short = `Claros couldn't check ${what} (${err}); it ran unchecked. Run: npx getclaros doctor`;
101
+ }
102
+ }
103
+ else if (r.allowed) {
104
+ kind = nudgesOf(r).length ? "nudge" : "silent";
105
+ text = kind === "nudge" ? nudgeText(r) : "";
106
+ }
107
+ else {
108
+ kind = r.result === "ask" ? "ask" : "stop";
109
+ text = stopText(r) + (kind === "ask" && r.check_id && !r.question ? `\nCheck: ${r.check_id} (give it to ${who(r)} with the question)` : "");
110
+ short = kind === "ask" ? `Claros: ask ${who(r)} before ${what}: ${firstRule(r)}${r.check_id ? ` (check ${r.check_id})` : ""}`
111
+ : `Claros stopped ${what}: ${firstRule(r)}`;
112
+ }
113
+ const json = (x) => ({ stdout: JSON.stringify(x), stderr: "", code: 0 });
114
+ if (agent === "cursor") {
115
+ const noAsk = o.event === "preToolUse"; // Cursor: ask accepted but not enforced on preToolUse
116
+ if (kind === "silent")
117
+ return json({ permission: "allow" });
118
+ if (kind === "warn")
119
+ return json({ permission: "allow", user_message: short });
120
+ if (kind === "nudge")
121
+ return json({ permission: "allow", agent_message: text });
122
+ if (kind === "ask" && !noAsk)
123
+ return json({ permission: "ask", user_message: short, agent_message: text });
124
+ return json({ permission: "deny", user_message: short, agent_message: text });
125
+ }
126
+ const hso = (x) => ({ hookSpecificOutput: { hookEventName: "PreToolUse", ...x } });
127
+ if (kind === "silent")
128
+ return { stdout: "", stderr: "", code: 0 };
129
+ if (kind === "warn")
130
+ return json({ systemMessage: short });
131
+ if (kind === "nudge")
132
+ return json(hso({ additionalContext: text.slice(0, 10_000) }));
133
+ if (kind === "ask" && agent === "claude-code") { // native ask: the person at the keyboard decides, with the reason
134
+ return json({ ...hso({ permissionDecision: "ask", permissionDecisionReason: short, additionalContext: text.slice(0, 10_000) }) });
135
+ }
136
+ return json({ ...hso({ permissionDecision: "deny", permissionDecisionReason: text }), systemMessage: short });
137
+ }
138
+ /** The key: flag → CLAROS_KEY → saved config → the project's .env. */
139
+ function keyFor(flag, root) {
140
+ if (flag || process.env.CLAROS_KEY)
141
+ return (flag || process.env.CLAROS_KEY);
142
+ const saved = loadConfig().key;
143
+ if (saved)
144
+ return saved;
145
+ try {
146
+ return /^(?:export\s+)?CLAROS_KEY=["']?([^"'\r\n]*)/m.exec(readFileSync(join(root, ".env"), "utf8"))?.[1].trim() ?? "";
147
+ }
148
+ catch {
149
+ return "";
150
+ }
151
+ }
152
+ async function check(at, a, actor, timeout) {
153
+ const go = async () => {
154
+ const res = await fetch(at.url + "/v1/check", { method: "POST", signal: AbortSignal.timeout(timeout),
155
+ headers: { "content-type": "application/json", ...(at.key ? { authorization: `Bearer ${at.key}` } : {}) },
156
+ body: JSON.stringify({ tool: a.tool, action: a.tool, actor, state: a.state, arguments: a.arguments }) });
157
+ if (!res.ok)
158
+ throw new Error(`HTTP ${res.status}`);
159
+ return res.json();
160
+ };
161
+ try {
162
+ return await go();
163
+ }
164
+ catch (e) {
165
+ if (e.name === "TimeoutError" || !(await relocate(at)))
166
+ throw e;
167
+ return go(); // the server moved: the saved address is updated for the next call
168
+ }
169
+ }
170
+ /** `getclaros hook <agent> [--fail-closed] [--timeout ms] [--url …] [--web …] [--key …]` → exit code. */
171
+ export async function main(argv) {
172
+ let v, agent;
173
+ try {
174
+ const p = parseArgs({ args: argv, allowPositionals: true, options: { "fail-closed": { type: "boolean" },
175
+ timeout: { type: "string" }, url: { type: "string" }, web: { type: "string" }, key: { type: "string" } } });
176
+ v = p.values;
177
+ agent = p.positionals[0];
178
+ if (!AGENTS.includes(agent))
179
+ throw new Error(`usage: getclaros hook <${AGENTS.join("|")}> [--fail-closed] [--timeout ms]`);
180
+ }
181
+ catch (e) {
182
+ process.stderr.write(`${e.message}\n`);
183
+ return 2;
184
+ }
185
+ let payload;
186
+ try {
187
+ payload = JSON.parse(readFileSync(0, "utf8"));
188
+ }
189
+ catch {
190
+ payload = null;
191
+ }
192
+ const emit = (o) => {
193
+ if (o.stdout)
194
+ process.stdout.write(o.stdout + "\n");
195
+ if (o.stderr)
196
+ process.stderr.write(o.stderr + "\n");
197
+ return o.code;
198
+ };
199
+ // Cursor also runs Claude Code's hooks (.claude/settings.json); its own hooks.json entry checks those calls
200
+ if (agent !== "cursor" && payload?.cursor_version)
201
+ return 0;
202
+ const a = payload && actionOf(agent, payload);
203
+ if (!payload)
204
+ return emit(outputOf(agent, null, "unreadable hook payload", { failClosed: v["fail-closed"] }));
205
+ if (!a)
206
+ return emit(outputOf(agent, { allowed: true }, null, { event: payload.hook_event_name }));
207
+ const o = { failClosed: !!v["fail-closed"], tool: a.tool === "shell" ? `\`${String(a.state.command).slice(0, 80)}\``
208
+ : `${a.tool} ${a.state.file_path}`, event: payload.hook_event_name };
209
+ let r = null, err = null;
210
+ try {
211
+ const root = rootOf(payload, String(payload.cwd ?? process.cwd()));
212
+ const at = await resolve({ url: v.url, web: v.web, key: keyFor(v.key, root) });
213
+ const s = loadConfig();
214
+ if (at.source === "discovered" && !s.url)
215
+ saveConfig({ ...s, url: at.url, web: at.web }); // cache discovery
216
+ if (!at.key)
217
+ throw new Error("no Claros key: run npx getclaros init");
218
+ r = await check(at, a, agent, Number(v.timeout ?? 5000));
219
+ if (r?.need?.length)
220
+ throw new Error("the check needs records from an app the hook can't read");
221
+ }
222
+ catch (e) {
223
+ err = e.name === "TimeoutError" ? `timed out after ${v.timeout ?? 5000}ms` : e.message;
224
+ }
225
+ return emit(outputOf(agent, r, err, o));
226
+ }
227
+ const isOurs = (cmd) => /(getclaros|[\\/]dist[\\/]cli\.js)\s+hook\s/.test(String(cmd ?? "") + " ");
228
+ /** Where each agent keeps its hooks, project and user scope. */
229
+ export function hookFiles(project) {
230
+ const h = homedir(), codex = process.env.CODEX_HOME || join(h, ".codex");
231
+ return [
232
+ { agent: "claude-code", client: "Claude Code", path: join(project, ".claude", "settings.json"), user: false },
233
+ { agent: "claude-code", client: "Claude Code", path: join(h, ".claude", "settings.json"), user: true },
234
+ { agent: "codex", client: "Codex", path: join(project, ".codex", "hooks.json"), user: false },
235
+ { agent: "codex", client: "Codex", path: join(codex, "hooks.json"), user: true },
236
+ { agent: "cursor", client: "Cursor", path: join(project, ".cursor", "hooks.json"), user: false },
237
+ { agent: "cursor", client: "Cursor", path: join(h, ".cursor", "hooks.json"), user: true },
238
+ ].filter((f, i, all) => all.findIndex((x) => x.path === f.path) === i);
239
+ }
240
+ /** Agents installed on this machine (their home directory exists) or configured in this project. */
241
+ export function hookAgents(project) {
242
+ const h = homedir(), codex = process.env.CODEX_HOME || join(h, ".codex");
243
+ const dirs = { "claude-code": [join(h, ".claude"), join(project, ".claude")],
244
+ codex: [codex, join(project, ".codex")], cursor: [join(h, ".cursor"), join(project, ".cursor")] };
245
+ return AGENTS.filter((a) => dirs[a].some((d) => existsSync(d)));
246
+ }
247
+ /** [event, entry] pairs init adds for an agent. */
248
+ export function entriesFor(agent, command) {
249
+ if (agent === "claude-code")
250
+ return [["PreToolUse", { matcher: "Bash|PowerShell|Edit|Write|NotebookEdit",
251
+ hooks: [{ type: "command", command, timeout: 30 }] }]];
252
+ if (agent === "codex")
253
+ return [["PreToolUse", { matcher: "^Bash$|^apply_patch$",
254
+ hooks: [{ type: "command", command, timeout: 30, statusMessage: "Checking with Claros" }] }]];
255
+ return [["beforeShellExecution", { command, timeout: 30 }], ["preToolUse", { command, matcher: "Write|Delete", timeout: 30 }]];
256
+ }
257
+ const entryOurs = (e) => isOurs(e?.command) || (Array.isArray(e?.hooks) && e.hooks.some((h) => isOurs(h?.command)));
258
+ async function jsonc() {
259
+ const j = await import("jsonc-parser");
260
+ const fmt = (text) => {
261
+ const ind = text.match(/^([ \t]+)\S/m)?.[1] ?? " ";
262
+ return { insertSpaces: !ind.startsWith("\t"), tabSize: ind.startsWith("\t") ? 1 : ind.length, eol: text.includes("\r\n") ? "\r\n" : "\n" };
263
+ };
264
+ const set = (t, path, value) => j.applyEdits(t, j.modify(t, path, value, { formattingOptions: fmt(t) }));
265
+ const items = (t, path) => j.findNodeAtLocation(j.parseTree(t), path)?.children ?? [];
266
+ const cut = (t, a, b, r = "") => t.slice(0, a) + r + t.slice(b);
267
+ // by hand, like clients.ts addClaros / removeEntry: jsonc's modify would reformat the neighbouring entries
268
+ /** value appended to the array at path, or as `key` to the object at path */
269
+ const put = (t, path, value, key) => {
270
+ const last = items(t, path).at(-1);
271
+ if (!last)
272
+ return set(t, key === undefined ? path : [...path, key], key === undefined ? [value] : value);
273
+ const at = last.offset + last.length, indent = /^[ \t]*/.exec(t.slice(t.lastIndexOf("\n", last.offset) + 1))[0];
274
+ return cut(t, at, at, `,${fmt(t).eol}${indent}${key === undefined ? "" : `${JSON.stringify(key)}: `}${JSON.stringify(value)}`);
275
+ };
276
+ /** the k-th element (array) or property (object) at path out, with its comma */
277
+ const drop = (t, path, k) => {
278
+ const xs = items(t, path), p = xs[k];
279
+ if (k > 0)
280
+ return cut(t, xs[k - 1].offset + xs[k - 1].length, p.offset + p.length);
281
+ if (xs.length > 1)
282
+ return cut(t, p.offset, xs[1].offset);
283
+ return set(t, [...path, p.type === "property" ? p.children[0].value : k], undefined);
284
+ };
285
+ const read = (t) => {
286
+ const errors = [], doc = j.parse(t, errors, { allowTrailingComma: true });
287
+ if (errors.length || (doc !== undefined && (typeof doc !== "object" || Array.isArray(doc))))
288
+ throw new Error("not valid JSON");
289
+ return doc ?? {};
290
+ };
291
+ return { set, read, put, drop };
292
+ }
293
+ /** The file's text with our hooks in it (added once; a changed launcher is updated in place). Throws on bad JSON. */
294
+ export async function addHooks(text, agent, command) {
295
+ const { set, read, put } = await jsonc();
296
+ let t = text.trim() ? text : "{}\n";
297
+ if (agent === "cursor" && read(t).version === undefined)
298
+ t = put(t, [], 1, "version");
299
+ for (const [event, entry] of entriesFor(agent, command)) {
300
+ const hooks = read(t).hooks, list = hooks?.[event];
301
+ if (!hooks || typeof hooks !== "object") {
302
+ t = put(t, [], { [event]: [entry] }, "hooks");
303
+ continue;
304
+ }
305
+ if (!Array.isArray(list)) {
306
+ t = list === undefined ? put(t, ["hooks"], [entry], event) : set(t, ["hooks", event], [entry]);
307
+ continue;
308
+ }
309
+ const at = list.findIndex(entryOurs);
310
+ if (at < 0)
311
+ t = put(t, ["hooks", event], entry);
312
+ else if (JSON.stringify(list[at]) !== JSON.stringify(entry))
313
+ t = set(t, ["hooks", event, at], entry);
314
+ }
315
+ return t;
316
+ }
317
+ /** The inverse: our entries out; an event list or `hooks` object left empty by that goes too. */
318
+ export async function removeHooks(text) {
319
+ const { read, drop } = await jsonc();
320
+ let t = text;
321
+ for (const [event, list] of Object.entries(read(t).hooks ?? {})) {
322
+ if (!Array.isArray(list) || !list.some(entryOurs))
323
+ continue;
324
+ if (list.every(entryOurs))
325
+ t = drop(t, ["hooks"], Object.keys(read(t).hooks).indexOf(event));
326
+ else
327
+ for (let i = list.length - 1; i >= 0; i--)
328
+ if (entryOurs(list[i]))
329
+ t = drop(t, ["hooks", event], i);
330
+ }
331
+ const doc = read(t);
332
+ if (doc.hooks && typeof doc.hooks === "object" && !Object.keys(doc.hooks).length && text !== t) {
333
+ t = drop(t, [], Object.keys(doc).indexOf("hooks"));
334
+ }
335
+ return t;
336
+ }
337
+ /** Does this file hold our hooks? (false when absent or unreadable) */
338
+ export async function hasHooks(path) {
339
+ if (!existsSync(path))
340
+ return false;
341
+ try {
342
+ const { read } = await jsonc();
343
+ return Object.values(read(readFileSync(path, "utf8")).hooks ?? {}).some((l) => Array.isArray(l) && l.some(entryOurs));
344
+ }
345
+ catch {
346
+ return false;
347
+ }
348
+ }
package/dist/setup.js CHANGED
@@ -6,11 +6,12 @@
6
6
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import { homedir, hostname } from "node:os";
9
- import { basename, join } from "node:path";
9
+ import { basename, dirname, join } from "node:path";
10
10
  import { createInterface } from "node:readline/promises";
11
11
  import { parseArgs } from "node:util";
12
12
  import { addClaros, argvOf, CLAROS, detect, launcher, load, removeEntry, sha, unwrapServer, wrapServer, writeAtomic, } from "./clients.js";
13
13
  import { cfgDir, DEFAULT_WEB, loadConfig, relocate, resolve, saveConfig } from "./config.js";
14
+ import { addHooks, hasHooks, hookAgents, hookFiles, removeHooks } from "./hooks.js";
14
15
  import { detectBrowser, envTarget, envValue, fixtureImport, fixtureText, setEnv, short } from "./projects.js";
15
16
  import { c, dur, header, mark, say, spinner, VERSION, wrap } from "./ui.js";
16
17
  const NAME = launcher().at(-1) === "getclaros" ? "npx getclaros" : "getclaros";
@@ -19,6 +20,7 @@ const tilde = (p) => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) :
19
20
  const keyId = (k) => k.split("_").slice(0, 2).join("_") + "_…"; // clr_<prefix>_… (the prefix is the lookup id, not the secret)
20
21
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
21
22
  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] [--no-browser-hooks]
23
+ [--no-hooks] [--user] [--fail-closed] (coding-agent hooks: project scope, --user for all projects)
22
24
  ${NAME} test [--app <server>]
23
25
  ${NAME} doctor [--project <dir>] (status: the same)
24
26
  ${NAME} undo [--all]
@@ -33,6 +35,7 @@ export async function run(argv) {
33
35
  project: { type: "string" }, all: { type: "boolean" }, "no-mcp": { type: "boolean" }, app: { type: "string" },
34
36
  paste: { type: "boolean" }, "no-browser": { type: "boolean" }, "no-browser-hooks": { type: "boolean" }, format: { type: "string" },
35
37
  reason: { type: "string" }, rule: { type: "string" }, verdict: { type: "string" },
38
+ user: { type: "boolean" }, "no-hooks": { type: "boolean" }, "fail-closed": { type: "boolean" },
36
39
  } }));
37
40
  }
38
41
  catch (e) {
@@ -292,8 +295,8 @@ async function init(v, project) {
292
295
  ? join(cfgDir(), "config.json") : saveConfig({ url, web, key, ...(workspace ? { workspace } : {}) });
293
296
  say(` ${c.ask(mark.ok)} Signed in${workspace?.name ? ` to workspace ${c.bold(`"${workspace.name}"`)}` : ""} ${c.dim(`key ${keyId(key)}`)}`);
294
297
  say(c.dim(` ${url} ${mark.dot} key saved in ${tilde(cfg)}, readable only by you`));
295
- const files = detect(project), browsers = detectBrowser(project);
296
- if (!files.length && !browsers.length) {
298
+ const files = detect(project), browsers = detectBrowser(project), agents = v["no-hooks"] ? [] : hookAgents(project);
299
+ if (!files.length && !browsers.length && !agents.length) {
297
300
  say(`\n See a stop now: ${c.bold(`${NAME} test`)}`);
298
301
  say(c.dim(wrap(" To guard an agent: add an MCP server to Claude Code, Claude Desktop, Cursor, VS Code or Codex (none has one yet), or run init in a Playwright or browser-use project, then run init again.", 2)));
299
302
  return 1;
@@ -310,8 +313,9 @@ async function init(v, project) {
310
313
  say(`\n ${c.bold("MCP agents")}`);
311
314
  say(c.dim(wrap(" none found (Claude Code, Claude Desktop, Cursor, VS Code, Codex); add an MCP server to one, then run init again", 4)));
312
315
  }
316
+ const hk = await hookSetup(v, agents, project, run);
313
317
  const br = await browserSetup(v, browsers, project, at, key, run);
314
- const { changed, guardedIn, added } = mcp;
318
+ const { guardedIn, added } = mcp, changed = [...mcp.changed, ...hk.clients];
315
319
  if (!changed.length && !br.done.length) {
316
320
  if (br.todo)
317
321
  say(` ${c.bold("Next")} add the hook shown above, then run your browser agent`);
@@ -323,6 +327,8 @@ async function init(v, project) {
323
327
  say(` ${c.ask(mark.ok)} Guarded ${c.bold(n)} ${c.dim(`in ${list(cl)}`)}`);
324
328
  if (added.length)
325
329
  say(` ${c.ask(mark.ok)} Added the Claros MCP server ${c.dim(`in ${list(added)}`)}`);
330
+ for (const l of hk.done)
331
+ say(` ${c.ask(mark.ok)} ${l}`);
326
332
  for (const l of br.done)
327
333
  say(` ${c.ask(mark.ok)} ${l}`);
328
334
  if (run.files.length)
@@ -458,6 +464,42 @@ async function mcpSetup(v, files, url, key, run) {
458
464
  }
459
465
  return { changed, guardedIn, added };
460
466
  }
467
+ // ---- coding-agent hooks (Claude Code, Codex, Cursor): shell commands and file edits checked before they run ----
468
+ /** Adds `getclaros hook <agent>` to each detected agent's hooks file (project scope; --user: the user's), merged
469
+ * into what is there. Never asks: like the Claros MCP entry, it is part of init (--no-hooks skips it). */
470
+ async function hookSetup(v, agents, project, run) {
471
+ const done = [], clients = [];
472
+ if (!agents.length)
473
+ return { done, clients };
474
+ say(`\n ${c.bold("Coding-agent hooks")} ${c.dim("shell commands and file edits checked before they run")}`);
475
+ const cmd = [...launcher(), "hook"].join(" ");
476
+ for (const a of agents) {
477
+ const f = hookFiles(project).find((x) => x.agent === a && x.user === !!v.user);
478
+ const before = existsSync(f.path) ? readFileSync(f.path, "utf8") : null;
479
+ let after;
480
+ try {
481
+ after = await addHooks(before ?? "", a, `${cmd} ${a}${v["fail-closed"] ? " --fail-closed" : ""}`);
482
+ }
483
+ catch (e) {
484
+ warn(` ${tilde(f.path)}: ${e.message}; left unchanged`);
485
+ continue;
486
+ }
487
+ if (after === before) {
488
+ say(c.dim(` ${f.client}: already hooked in ${tilde(f.path)}`));
489
+ continue;
490
+ }
491
+ say(` ${f.client.padEnd(12)} ${c.dim(tilde(f.path))}`);
492
+ if (before === null)
493
+ mkdirSync(dirname(f.path), { recursive: true });
494
+ commit(run, f.path, before, after);
495
+ done.push(`Hooked ${f.client} ${c.dim(`in ${tilde(f.path)}`)}`);
496
+ clients.push(f.client);
497
+ }
498
+ if (clients.includes("Codex"))
499
+ say(c.dim(wrap(" Codex runs a new hook only after you trust it: open /hooks in Codex once (project hooks also need a trusted project).", 4)));
500
+ return { done, clients };
501
+ }
502
+ const isHookPath = (p, project) => /[\\/]\.(claude[\\/]settings|codex[\\/]hooks|cursor[\\/]hooks)\.json$/.test(p) || hookFiles(project).some((f) => f.path === p);
461
503
  // ---- browser agents (Playwright, browser-use) in this project ----
462
504
  /** A shell line that exports the saved key without printing it. */
463
505
  const exportKey = () => `export CLAROS_KEY="$(node -p "require('${join(cfgDir(), "config.json").replace(homedir(), "$HOME")}').key")"`;
@@ -744,6 +786,21 @@ async function doctor(v, project) {
744
786
  const servers = files.flatMap((f) => f.servers.filter((s) => !s.ours));
745
787
  if (servers.some((s) => !s.guarded && !s.gap))
746
788
  fix(`${NAME} init ${c.dim(`guards the servers you pick${servers.some((s) => s.guarded) ? "" : " (none is guarded yet)"}`)}`);
789
+ // coding-agent hooks: per agent, the files that hold ours
790
+ const agents = hookAgents(project), hooked = new Map();
791
+ for (const f of hookFiles(project))
792
+ if (await hasHooks(f.path))
793
+ hooked.set(f.client, [...(hooked.get(f.client) ?? []), tilde(f.path)]);
794
+ const named = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor" };
795
+ const clientsHere = [...new Set([...agents.map((a) => named[a]), ...hooked.keys()])];
796
+ if (clientsHere.length)
797
+ say(`\n ${c.bold("Coding-agent hooks")} ${c.dim("shell commands and file edits")}`);
798
+ for (const cl of clientsHere) {
799
+ const at = hooked.get(cl);
800
+ say(` ${at ? c.ask(mark.ok) : c.dim(mark.off)} ${cl.padEnd(24)} ${at ? `${c.ask("hooked")} ${c.dim(at.join(", "))}` : "not hooked"}`);
801
+ }
802
+ if (clientsHere.some((cl) => !hooked.has(cl)))
803
+ fix(`${NAME} init ${c.dim("adds the hooks (project scope; --user for all projects)")}`);
747
804
  // browser agents: package installed, hook present, a key the agent can read (env or a safe .env)
748
805
  let broken = false;
749
806
  const ps = detectBrowser(project);
@@ -775,6 +832,18 @@ async function doctor(v, project) {
775
832
  return !key ? 2 : !(up && ks === "valid") ? 3 : broken ? 1 : 0;
776
833
  }
777
834
  // ---- undo ----
835
+ /** Our hook entries out of a hooks file that changed since init; the rest stays. */
836
+ async function unhook(safety, path, cur) {
837
+ try {
838
+ const t = await removeHooks(cur);
839
+ if (t !== cur)
840
+ commit(safety, path, cur, t);
841
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: changed since init, so removed the Claros hooks in place and kept the other edits`);
842
+ }
843
+ catch (e) {
844
+ warn(`${tilde(path)}: couldn't read it (${e.message}); remove the getclaros hook entries yourself`);
845
+ }
846
+ }
778
847
  /** Unwraps every guarded server and removes our Claros entry in place; the rest of the file stays. */
779
848
  function unwrapAll(f) {
780
849
  let n = 0;
@@ -815,6 +884,8 @@ async function undo(v, project) {
815
884
  unlinkSync(path);
816
885
  say(` ${c.ask(mark.ok)} ${tilde(path)}: removed (init wrote it)`);
817
886
  }
887
+ else if (isHookPath(path, project))
888
+ await unhook(safety, path, cur);
818
889
  else
819
890
  warn(`${tilde(path)}: changed since init wrote it; left in place (delete it yourself)`);
820
891
  }
@@ -828,6 +899,8 @@ async function undo(v, project) {
828
899
  commit(safety, path, cur, readFileSync(chain[0].backup, "utf8"));
829
900
  say(` ${c.ask(mark.ok)} ${tilde(path)}: restored from the backup taken before init (unchanged since)`);
830
901
  }
902
+ else if (isHookPath(path, project))
903
+ await unhook(safety, path, cur);
831
904
  else {
832
905
  const f = byPath.get(path) ?? detect(project).find((x) => x.path === path);
833
906
  if (!f || f.error) {
@@ -842,6 +915,9 @@ async function undo(v, project) {
842
915
  byPath.delete(path);
843
916
  }
844
917
  if (v.all) {
918
+ for (const f of hookFiles(project))
919
+ if (!chains.has(f.path) && await hasHooks(f.path))
920
+ await unhook(safety, f.path, readFileSync(f.path, "utf8"));
845
921
  for (const f of byPath.values()) {
846
922
  if (f.error)
847
923
  continue;
@@ -0,0 +1,54 @@
1
+ // What an agent reads when Claros stops, holds or nudges an action: shared by the MCP guard (cli.ts) and the coding-agent
2
+ // hooks (hooks.ts).
3
+ import { readFileSync } from "node:fs";
4
+ const NAME = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).name;
5
+ /** `head` replaces the first line (a decided ask: who said no, or that nobody answered). */
6
+ export function stopText(r, head) {
7
+ const hits = (r.violations || []).filter((v) => v.action === "stop" || v.action === "ask");
8
+ const maps = [...new Set(hits.map((v) => v.workflow_id ?? r.workflow_id))];
9
+ const from = `expert-approved Work Map${maps.length > 1 ? "s" : ""} ${maps.map((m) => `"${m}"`).join(", ")}`;
10
+ const out = [head ?? (r.result === "ask" // ask only: not refused for good, waiting on a person
11
+ ? `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.`
12
+ : `Claros stopped this action: it breaks ${hits.length === 1 ? "a rule" : "rules"} from the ${from}. Do not retry it as is.`)];
13
+ for (const v of hits) {
14
+ const who = v.who_to_ask || v.owner || v.expert || "the process owner";
15
+ out.push("", `Rule: ${v.rule}`);
16
+ if (v.reason)
17
+ out.push(`Why Claros stopped it: ${v.reason}`); // fuzzy rules: the judge's one-line reason
18
+ if (v.quote)
19
+ out.push(`${v.source === "doc" ? "In the written policy" : "In the expert's words"}${v.expert ? ` (${v.expert})` : ""}: "${v.quote}"`);
20
+ out.push(`Who to ask: ${who}`);
21
+ out.push(v.action === "ask"
22
+ ? `Next: stop and ask ${who} before doing this; continue only with their answer.`
23
+ : `Next: change the record so the rule holds, or ask ${who} to approve an exception. Tell the user why.`);
24
+ }
25
+ // an ask opened a question to the rule's owner (server: knowledge/approvals.py); its answer is at the decision URL
26
+ if (r.question?.decision_url && !head)
27
+ out.push("", `Claros asked the rule's owner (check ${r.check_id}); their answer: GET ${r.question.decision_url}`);
28
+ // an override goes to the rule's expert as a report; the rule stays as it is until they confirm a change
29
+ if (r.check_id) {
30
+ out.push("", "If this stop is wrong, tell the rule's expert why (the rule stays until they change it):");
31
+ for (const v of hits)
32
+ out.push(`npx ${NAME} override ${r.check_id} --rule ${v.guardrail_id} --reason "<why it was fine>"`);
33
+ }
34
+ return out.join("\n");
35
+ }
36
+ // a nudge lets the call through; "warn" is its old name (older Claros servers)
37
+ export const nudgesOf = (r) => (r.violations || []).filter((v) => v.action === "nudge" || v.action === "warn");
38
+ /** The extra text item on a nudged call's result: the rule, the expert's words and the fix, so the agent can
39
+ * correct this record (if it still can) and the next ones. */
40
+ export function nudgeText(r) {
41
+ const hits = nudgesOf(r);
42
+ const out = [`Claros nudge: the call went through, but it doesn't follow ${hits.length === 1 ? "a rule" : "rules"} ` +
43
+ `your team holds. Fix it if you still can, and follow ${hits.length === 1 ? "it" : "them"} from now on.`];
44
+ for (const v of hits) {
45
+ out.push("", `Rule: ${v.rule}`);
46
+ if (v.reason)
47
+ out.push(`Why: ${v.reason}`);
48
+ if (v.quote)
49
+ out.push(`${v.source === "doc" ? "In the written policy" : "In the expert's words"}${v.expert ? ` (${v.expert})` : ""}: "${v.quote}"`);
50
+ if (v.fix)
51
+ out.push(`Suggested fix: ${v.fix}`);
52
+ }
53
+ return out.join("\n");
54
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "getclaros",
3
- "version": "0.2.0",
4
- "description": "Claros guard: checks an agent's MCP tool calls against your experts' rules before they run",
3
+ "version": "0.3.0",
4
+ "description": "Claros guard: checks an agent's MCP tool calls, shell commands and file edits against your experts' rules before they run",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {