getclaros 0.1.1 → 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`.
@@ -15,8 +16,13 @@ npx getclaros init
15
16
  To try a local build, run `node dist/cli.js init` with `GETCLAROS_LAUNCHER="node /abs/path/to/dist/cli.js"` so the
16
17
  configs launch your build instead of `npx -y getclaros`.
17
18
 
19
+ One command for every kind of agent: it guards the MCP servers your agent clients run, and, when run in a Playwright
20
+ or browser-use project, installs the browser hook with that project's own package manager (see
21
+ [Browser agents](#browser-agents-playwright-browser-use)). `npx getclaros doctor` then says, per agent type, whether it
22
+ is guarded and can reach Claros.
23
+
18
24
  ```
19
- claros” getclaros 0.1.1
25
+ claros” getclaros 0.3.0
20
26
 
21
27
  Sign in opening your browser
22
28
  › https://claros-eta.vercel.app/device?code=ZQRC-ZCTP
@@ -25,7 +31,7 @@ configs launch your build instead of `npx -y getclaros`.
25
31
  ✓ Signed in to workspace "Acme" key clr_7f3a91c2_…
26
32
  <claros server> · key saved in ~/.config/getclaros/config.json, readable only by you
27
33
 
28
- MCP servers
34
+ MCP agents
29
35
 
30
36
  Claude Code ~/.claude.json
31
37
  1 ● erpnext npx -y frappe-mcp-server Work Map "Process a supplier invoice"
@@ -81,9 +87,70 @@ What it changes, in files that already exist (it never creates a client config):
81
87
  up front with `get_workflow`; in each client's user config (or its project config when it has no user config).
82
88
  - Every file is backed up before it changes (`~/.config/getclaros/backups/<time>/`) and written atomically.
83
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
+
121
+ ### Browser agents (Playwright, browser-use)
122
+
123
+ `init` also looks at the project directory (the current one, or `--project <dir>`):
124
+
125
+ | Found | Install (never global) | Hook |
126
+ |---|---|---|
127
+ | `@playwright/test` in package.json | `pnpm add -D` / `yarn add -D` / `bun add -d` / `npm install -D getclaros-browser`, by lockfile | writes `claros.fixture.ts` next to `playwright.config.*`; you change each spec's `from "@playwright/test"` to `from "<rel>/claros.fixture"` (init prints the exact line) |
128
+ | `playwright` (the library) in package.json | the same, as a dependency | printed: `import { guard } from "getclaros-browser"; await guard(page);` |
129
+ | `browser-use` in pyproject.toml / requirements*.txt / uv.lock / poetry.lock | `uv add` / `poetry add claros-browser`, else `pip install` into the project's virtualenv (none: printed, not run) | printed: `from claros_browser import guard_tools; guard_tools(tools)`, with the file that builds the `Agent` |
130
+
131
+ Each project gets its plan (the exact install command, the file it writes, where the key goes, the hook) and one
132
+ question, `Set up Playwright? Enter = yes`. `--yes` says yes without asking; `--no-browser-hooks` shows the plan and
133
+ changes nothing. User source files are never edited: the hook is printed.
134
+
135
+ Why a fixture file for Playwright Test: Playwright has no config-level page hook, and a fixture is its supported way to
136
+ run code for every test's `page`. The file lives in your repo (you can read it, compose it with your own fixtures), it
137
+ imports `getclaros-browser` and `@playwright/test` from your own `node_modules` (works with pnpm's strict layout), and
138
+ it re-exports everything from `@playwright/test`, so swapping the import never loses a type or helper. It also reads
139
+ `CLAROS_*` from `.env` when the shell doesn't set them, because Playwright doesn't read `.env` itself.
140
+
141
+ The browser hooks read `CLAROS_KEY` (and `CLAROS_URL` / `CLAROS_WEB_URL` when you aren't on the hosted Claros) from the
142
+ environment. `init` writes them to the project's `.env` only when it already exists, git ignores it and git doesn't
143
+ track it (no repo: the `.gitignore` next to it decides). Otherwise it prints an export line that reads the saved key
144
+ without showing it: `export CLAROS_KEY="$(node -p "require('$HOME/.config/getclaros/config.json').key")"`.
145
+ browser-use loads `.env` itself.
146
+
147
+ The fixture file is new (undo deletes it, unless you changed it) and `.env` is backed up like any config (undo restores
148
+ it, or, if you edited it since, removes only the `CLAROS_*` lines). Undo doesn't uninstall the package.
149
+
84
150
  ```
85
- npx getclaros test # a made-up record that breaks one of your rules → prints the stop (rule, expert's words, who to ask)
86
- npx getclaros status # which servers are guarded, Claros reachable, key valid
151
+ npx getclaros test # made-up records checked against your rules → prints the stop (and, in the demo, a nudge with its fix)
152
+ npx getclaros doctor # per agent type: guarded? reachable? (server, key, MCP wrapping, browser package + hook + key), with the fix
153
+ npx getclaros status # the same as doctor
87
154
  npx getclaros undo # undo the last init; --all: every init, plus any guard wrapping it finds
88
155
  npx getclaros export <workflow_id> [--format agents|design|skill] > AGENTS.md # a map's rules as markdown
89
156
  ```
@@ -99,7 +166,30 @@ rules page. Exit codes: 0 sent, 1 no such check / not valid, 2 bad usage, 3 unre
99
166
 
100
167
  `test` asks Claros for a record that breaks a precise rule of a Work Map for one of your guarded servers
101
168
  (`GET /v1/check/example?app=…`), checks it with `POST /v1/check` and prints the stop. Nothing is sent to the app.
102
- With no guarded server matching a map it uses the workspace's newest map and ends with a `Demo:` note.
169
+
170
+ With no guarded server matching a map it runs a demo on two Starter maps (added to your workspace the first time, as
171
+ drafts that are never enforced; delete them on the web and they stay deleted): a production deploy during a release
172
+ freeze, which is stopped, and a coding agent's stylesheet with a raw hex colour, which the judge nudges with a fix.
173
+ Both are checked side by side and it ends with a `Demo:` note:
174
+
175
+ ```
176
+ ◉ Stopped · ✎ Nudged 2 checks in 827 ms
177
+
178
+ ◉ stop No production deploys during the release freeze.
179
+ “Nothing goes to prod during the freeze. Not even a typo fix.” — Release manager
180
+ Who to ask: Release manager
181
+ Test action: deploy deploy.environment = production, deploy.freeze_active = true
182
+
183
+ ✎ nudge No hand-picked hex values in UI code; use the design tokens (var(--primary) and the rest).
184
+ “If it's not a token, it's a bug. No hand-picked hex values.” — Design systems lead
185
+ → Replace #7C3AED and #FFFFFF with design tokens like var(--primary)
186
+ Test action: write_file file_path = src/components/Button.css, content = .button { background: #7C3AED; color: #FFFFFF; border-radius: 6px; }
187
+
188
+ Your agent gets a stop as the tool's error; a nudge lets the call through, with the fix. Nothing reached any app.
189
+ Demo: no server is guarded yet, so this used the Starter maps (examples, not enforced; delete them on the web).
190
+ ```
191
+
192
+ For a guarded server whose app has a Work Map:
103
193
 
104
194
  ```
105
195
  ◉ Stopped in 1.3 s Work Map "Process a supplier invoice" · check chk_0b14cb5eef
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,7 +405,11 @@ async function main(argv) {
404
405
  }
405
406
  if (argv[0] === "mcp")
406
407
  return void import("./bridge.js").then((m) => m.bridge());
407
- if (["init", "test", "status", "undo", "export", "override"].includes(argv[0])) {
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
+ }
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;
410
415
  }
@@ -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));