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 +35 -3
- package/dist/cli.js +59 -51
- package/dist/hooks.js +348 -0
- package/dist/setup.js +80 -4
- package/dist/verdict.js +54 -0
- package/package.json +2 -2
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
|
|
4
|
-
expert-approved Work Maps (`POST /v1/check`) before the call reaches the app
|
|
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.
|
|
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]
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|
package/dist/verdict.js
ADDED
|
@@ -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.
|
|
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": {
|