getclaros 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timur Monasypov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,204 @@
1
- # Temporary Holding Version
1
+ # getclaros guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
5
+ forwarded; the agent gets a tool error with the rule, the expert's words and who to ask.
6
+
7
+ The package name (`getclaros`) is a placeholder and lives only in `package.json`.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npx getclaros init
13
+ ```
14
+
15
+ Not published yet (`"private": true`); until then run `node dist/cli.js init` with
16
+ `GETCLAROS_LAUNCHER="node /abs/path/to/dist/cli.js"` so the configs launch your build instead of `npx -y getclaros`.
17
+
18
+ `init` signs you in through the browser (RFC 8628 device flow): it prints a URL and a code (XXXX-XXXX), opens the
19
+ URL (`--no-browser` to skip), and once you approve on Claros's `/device` page and pick a workspace it receives an
20
+ `agent` key for that workspace. A key from `--key` / `$CLAROS_KEY` / the saved config wins; `--paste` (or a Claros
21
+ without device sign-in) asks for a hidden paste instead. It checks the key, finds your agent clients' MCP configs, lists their servers and
22
+ asks which to guard. Servers whose name matches an app with a Work Map are preselected. `--yes` takes that default,
23
+ `--servers a,b` picks by name, `--project <dir>` sets where project configs are read (default: the current
24
+ directory), `--no-mcp` skips the Claros MCP entry. Restart the agent client afterwards.
25
+
26
+ | Client | Files read |
27
+ |---|---|
28
+ | Claude Code | `<project>/.mcp.json`; `~/.claude.json` (`mcpServers` and each `projects[path].mcpServers`) |
29
+ | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json`, `%APPDATA%\Claude\…`, `~/.config/Claude/…` |
30
+ | Cursor | `~/.cursor/mcp.json`, `<project>/.cursor/mcp.json` |
31
+ | VS Code | the profile's `mcp.json` (`~/Library/Application Support/Code/User/mcp.json`, …), `<project>/.vscode/mcp.json` |
32
+ | Codex | `~/.codex/config.toml` (`$CODEX_HOME`), `<project>/.codex/config.toml`: `[mcp_servers.<name>]` tables |
33
+
34
+ What it changes, in files that already exist (it never creates a client config):
35
+
36
+ - Each picked stdio server: `command` becomes `npx`, and `-y getclaros guard --server <name> --` plus the original
37
+ command go in front of the original `args`. `env`, `cwd`, comments and everything else stay. Already-guarded
38
+ servers are left alone, so running `init` again changes nothing.
39
+ - Each picked remote (`url`) server with static headers: the entry becomes a stdio one running
40
+ `npx -y getclaros guard --server <name> --remote <url> --header "K: V"…`, which reaches the server over Streamable
41
+ HTTP. The headers stay in that file and are never sent to Claros. Servers that need OAuth (no static header), take
42
+ their token from an env var or helper, or speak legacy SSE are listed as not guarded yet.
43
+ - A `claros` server (`npx -y getclaros mcp`, a stdio bridge to Claros's `/mcp`) so agents can read the Work Map
44
+ up front with `get_workflow`; in each client's user config (or its project config when it has no user config).
45
+ - Every file is backed up before it changes (`~/.config/getclaros/backups/<time>/`) and written atomically.
46
+
47
+ ```
48
+ npx getclaros test # a made-up record that breaks one of your rules → prints the stop (rule, expert's words, who to ask)
49
+ npx getclaros status # which servers are guarded, Claros reachable, key valid
50
+ npx getclaros undo # undo the last init; --all: every init, plus any guard wrapping it finds
51
+ npx getclaros export <workflow_id> [--format agents|design|skill] > AGENTS.md # a map's rules as markdown
52
+ ```
53
+
54
+ `export` prints a Work Map's rules (`GET /v1/maps/<id>/export`) as an AGENTS.md section, a DESIGN.md-style file or a
55
+ SKILL.md: an advisory copy for tools that read rules files; Claros still enforces the rules when the agent acts.
56
+ Exit codes: 0 printed, 1 no such map, 2 not signed in / bad usage, 3 Claros unreachable or key rejected.
57
+
58
+ `override <check_id> --rule <id> --reason "…"` tells the rule's expert a check got it wrong
59
+ (`POST /v1/checks/<id>/override`; `--verdict wrong_stop` by default, or `wrong_nudge | missed | right`). Every stop
60
+ message ends with this command filled in. Nothing changes until the expert confirms a suggested change on the map's
61
+ rules page. Exit codes: 0 sent, 1 no such check / not valid, 2 bad usage, 3 unreachable or key rejected.
62
+
63
+ `test` asks Claros for a record that breaks a precise rule of a Work Map for one of your guarded servers
64
+ (`GET /v1/check/example?app=…`), checks it with `POST /v1/check` and prints the stop. Nothing is sent to the app.
65
+ With no guarded server matching a map it uses the newest map and says `DEMO`. Exit codes: 0 stopped, 1 not stopped
66
+ or no map to test, 2 not signed in / bad usage, 3 Claros unreachable or key rejected (`status` uses 0 / 2 / 3 too).
67
+
68
+ `undo` restores a file from its backup when it is byte-for-byte what `init` wrote; if anything changed it since, it
69
+ unwraps the guarded servers and removes the `claros` entry in place instead, keeping the other edits, and says which
70
+ it did. What `undo` overwrites is backed up as well.
71
+
72
+ Security model:
73
+
74
+ - The key is stored once, in `~/.config/getclaros/config.json` (`$XDG_CONFIG_HOME`), mode 0600 in a 0700 directory,
75
+ never in a client config (project files such as `.mcp.json` are often committed). The guard and the bridge read it
76
+ from there (`--key` / `$CLAROS_KEY` still win). The key is never printed; output shows only its id
77
+ (`clr_<prefix>_…`).
78
+ - Backups hold the full previous config files (which can contain other servers' tokens): 0600 files in a 0700
79
+ directory. Delete `~/.config/getclaros/backups` once you're happy.
80
+ - The guard adds no credentials to the wrapped server: its `env` is passed through as before.
81
+
82
+ ## Usage
83
+
84
+ ```
85
+ export CLAROS_KEY=clr_... # a key with the `agent` scope
86
+ getclaros guard --server erpnext -- npx -y some-erpnext-mcp --site https://erp.example.com
87
+ ```
88
+
89
+ In an MCP client config, replace the server's `command`/`args` with the guard in front of them:
90
+
91
+ ```json
92
+ { "erpnext": { "command": "getclaros",
93
+ "args": ["guard", "--server", "erpnext", "--", "npx", "-y", "some-erpnext-mcp"],
94
+ "env": { "CLAROS_KEY": "clr_..." } } }
95
+ ```
96
+
97
+ | Flag | Default | |
98
+ |---|---|---|
99
+ | `--server <name>` | required | Sent as `app`; helps route the call to the right Work Map |
100
+ | `--url <base>` | `$CLAROS_URL`, else the URL `init` saved, else the one the web app names | Claros server base URL |
101
+ | `--web <origin>` | `$CLAROS_WEB`, else the web origin `init` saved, else `https://claros-eta.vercel.app` | Where to ask for the server address (`/.well-known/claros.json`) |
102
+ | `--key <key>` | `$CLAROS_KEY`, else the key `init` saved | Prefer the env var: flags show up in process lists |
103
+ | `--timeout <ms>` | `5000` | How long to wait for a check |
104
+ | `--fail-open` | off | Forward writes when Claros can't be reached (default: block them) |
105
+
106
+ Server address: `--url` / `$CLAROS_URL` win and are never changed. Otherwise the CLI uses the URL `init` saved, or asks
107
+ the web app (`GET <web>/.well-known/claros.json` → `{"api": "<server>"}`) and saves the answer with the web origin.
108
+ When the saved server doesn't answer at all (a connection error, not an HTTP error), it asks the web app once more and
109
+ follows a moved server, updating the saved config: no reinstall. A config made with `init --url` alone stays pinned to
110
+ that URL. `--help` / `-h` on any command prints the usage.
111
+
112
+ ## What it does
113
+
114
+ - Relays newline-delimited JSON-RPC both ways byte for byte; only `tools/call` is inspected.
115
+ - Every `tools/list` response is passed on unchanged, then its tools are registered with Claros
116
+ (`POST /v1/tools {app, tools:[{name, description, inputSchema, annotations}]}`) so Claros can map their arguments
117
+ to a Work Map's fields once per tool. Only new tools or tools whose definition changed are sent. Registration is
118
+ fire and forget: a failure is logged, retried on the next `tools/list`, and never delays or blocks a call.
119
+ - Read tools pass without a check. Claros's classification (`GET /v1/tools/kinds`: LLM-proposed, expert-overridable)
120
+ wins once known; until then `annotations.readOnlyHint === true`, or a name starting with get / list / search / read /
121
+ find / describe / fetch / check_ / run_ / export_, or exactly version / ping. Everything else is a write.
122
+ Kinds are refreshed after registration, on every `tools/list` and at most every 30 s on writes.
123
+ - Writes: `POST /v1/check` with `{app, tool, action: tool, state, arguments}`. `state` is the arguments flattened
124
+ (`{"items.0.amount": 7200, "supplier": "..."}`); the server checks each array row as its own record. When the
125
+ tool has a mapping (proposed by Claros, confirmed by the expert), the server reads `arguments` through it instead,
126
+ so argument names don't have to match the map (`debit_to`, `items[].amount`).
127
+ - Record resolution: when the tool's mapping references a record (a submit by name, a partial doc) or needs values the
128
+ app fills on save (an item's group), Claros answers `need: [{key, tool, arguments}]`. The guard calls those read
129
+ tools on the wrapped server itself, with ids under a per-process prefix that are never relayed to the agent, waits
130
+ at most `--timeout`, takes the record from `structuredContent` or the JSON in the text, and checks again with
131
+ `fetched`. A failed or missing read blocks the write (or forwards it with `--fail-open`). A `need` naming a tool the
132
+ guard doesn't consider a read is refused. Lookups (`cache: true`) are cached for 60 s; the record itself is read
133
+ every time.
134
+ - Cross-server lookups: a `need` with `app` (another guarded server's read tool) goes to that server's guard over a
135
+ Unix socket, `<config dir>/run/<hash of the server name>.sock` (0600, dir 0700). Each guard serves its own server's
136
+ read tools there to guards that present the same key (sha256), and only read tools. No guard for that app → the
137
+ read fails → the write is blocked. Not on Windows yet.
138
+ - Allowed → forwarded unchanged. Stopped (`stop` or `ask`) → an `isError` result under the same id; the app never sees the call.
139
+ Nudged (a rule broken whose response is `nudge`, e.g. a taste rule) → forwarded; the app's result comes back with
140
+ one more text item, "Claros nudge: …" (rule, the expert's words, the suggested fix), so the agent can correct itself.
141
+ A call several Work Maps govern is checked against each (milestone 7); the stop names every map whose rule it
142
+ breaks, and a rule taken from a written policy is quoted as "In the written policy (file § section)".
143
+ - Claros unreachable, timed out or rejecting the key → writes are blocked with "ask a person"; reads still pass.
144
+ - A JSON-RPC batch containing a write `tools/call` is rejected.
145
+ - One log line per decision on stderr (stdout carries the protocol).
146
+
147
+ ## How a stop looks
148
+
149
+ What the agent receives as the tool result (`isError: true`):
150
+
151
+ ```
152
+ Claros stopped this action: it breaks a rule from the expert-approved Work Map "wf_ap_invoice". Do not retry it as is.
153
+
154
+ Rule: Equipment over 5,000 is capex: book to the capital equipment account, never to expense.
155
+ In the expert's words (AP lead): "Equipment above five thousand is an asset, it goes to capex."
156
+ Who to ask: AP lead
157
+ Next: change the record so the rule holds, or ask AP lead to approve an exception. Tell the user why.
158
+ ```
159
+
160
+ An ask-first rule is held the same way (never run), but opens with "Claros held this action: a rule from the expert-approved Work Map … says to ask first." and its Next line says who to ask.
161
+
162
+ A nudge, appended to the app's own result (not an error):
163
+
164
+ ```
165
+ Claros nudge: the call went through, but it doesn't follow a rule your team holds. Fix it if you still can, and follow it from now on.
166
+
167
+ Rule: <the rule>
168
+ In the expert's words (<expert>): "<their words>"
169
+ Suggested fix: <the judge's fix, or the rule's fix template filled from the record>
170
+ ```
171
+
172
+ And on stderr:
173
+
174
+ ```
175
+ [getclaros guard] erpnext submit_purchase_invoice → stop wf_ap_invoice g1 31ms chk_0b14cb5eef
176
+ [getclaros guard] erpnext call_method → stop wf_ap_invoice g1 12ms (1 read) chk_8187eaa79f
177
+ [getclaros guard] erpnext create_purchase_invoice → nudge wf_ap_invoice g1 2ms chk_25cd79b34a
178
+ [getclaros guard] nudge: <the rule> (fix: <the fix>)
179
+ ```
180
+
181
+ ## Develop
182
+
183
+ ```
184
+ npm install
185
+ npm test # tsc + node:test (fake MCP server + fake Claros; init/test/undo against a temp HOME)
186
+ npm run smoke # starts its own Claros on a free port with a temp DB, seeds wf_ap_invoice; create_purchase_invoice
187
+ # (supplier_name, debit_to, items[].amount) passes unmapped, is stopped once the expert maps it;
188
+ # update_record {doctype, name, values: {docstatus: 1}} is stopped after the guard reads the record
189
+ # and the item (fetch recipe + derived amount + lookup)
190
+ # then smoke-init: empty temp HOME with a Claude Code config → init --yes → test (timed) → status,
191
+ # the Claros MCP bridge against the real /mcp, undo
192
+ cd e2e && npm run e2e # against a real ERPNext through frappe-mcp-server (docs/GATEWAY_E2E_ERPNEXT.md)
193
+ cd e2e && npm run horizontal # four maps made from written policies, guards around mcp-sqlite (helpdesk, crm),
194
+ # mcp-server-git, the filesystem server and (when it answers) the local Zammad; the real
195
+ # LLM mapper, router and judge; prints mapper / routing / per-case tables (docs/HORIZONTAL.md)
196
+ ```
197
+
198
+ The smoke runs the server with `CLAROS_TOOL_MAPPER=off` (no LLM proposals, offline) and maps the tool the way an
199
+ expert would, through `POST /v1/tools/{id}/mapping`.
200
+
201
+ Limits: until a tool is mapped, only argument names that match the map's field names or aliases are understood, and
202
+ an unmapped write nothing matches is allowed (logged `mapping_status: unmapped`). The record is read just before the
203
+ write, not locked: it can change in between. No Windows `.cmd` shims (spawn
204
+ without a shell). Fuzzy rules (`judge_yourself`) are not enforced yet (milestone 5).
package/dist/bridge.js ADDED
@@ -0,0 +1,72 @@
1
+ // getclaros mcp: the Claros MCP server (get_workflow, check_action, …) for any client, as a stdio server.
2
+ // Relays each newline-delimited JSON-RPC message to <url>/mcp (streamable HTTP) with the saved key, and the answer
3
+ // (JSON, or the `data:` events of an SSE answer) back on stdout. Every client speaks stdio, and the key stays in
4
+ // ~/.config/getclaros instead of each client's config.
5
+ import { relocate, resolve } from "./config.js";
6
+ export async function bridge() {
7
+ const log = (s) => process.stderr.write(`[getclaros mcp] ${s}\n`);
8
+ let at;
9
+ try {
10
+ at = await resolve();
11
+ }
12
+ catch (e) {
13
+ log(e.message);
14
+ return process.exit(2);
15
+ }
16
+ const key = at.key;
17
+ if (!key)
18
+ log("no key: run `npx getclaros init` (or set CLAROS_KEY)");
19
+ let session, version, chain = Promise.resolve();
20
+ const out = (m) => process.stdout.write(m.trim() + "\n");
21
+ async function send(line) {
22
+ let msg;
23
+ try {
24
+ msg = JSON.parse(line);
25
+ }
26
+ catch {
27
+ return;
28
+ }
29
+ const post = () => fetch(`${at.url}/mcp`, {
30
+ method: "POST", body: line, signal: AbortSignal.timeout(30_000),
31
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream",
32
+ ...(key ? { authorization: `Bearer ${key}` } : {}), ...(session ? { "mcp-session-id": session } : {}),
33
+ ...(version ? { "mcp-protocol-version": version } : {}) },
34
+ });
35
+ try {
36
+ // a server that doesn't answer may have moved: ask the web app once, then retry there
37
+ const r = await post().catch(async (e) => {
38
+ if (e.name === "TimeoutError" || !(await relocate(at)))
39
+ throw e;
40
+ log(`Claros moved: now ${at.url}`);
41
+ return post();
42
+ });
43
+ session = r.headers.get("mcp-session-id") ?? session;
44
+ const text = await r.text();
45
+ if (!r.ok)
46
+ throw new Error(`HTTP ${r.status}`);
47
+ const msgs = (r.headers.get("content-type") ?? "").includes("event-stream")
48
+ ? text.split(/\r?\n\r?\n/).map((ev) => ev.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n"))
49
+ : [text];
50
+ for (const m of msgs.filter((x) => x.trim())) {
51
+ version ??= JSON.parse(m)?.result?.protocolVersion;
52
+ out(m);
53
+ }
54
+ }
55
+ catch (e) {
56
+ if (msg?.id !== undefined) {
57
+ out(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: `Claros MCP at ${at.url}: ${e.message}` } }));
58
+ }
59
+ }
60
+ }
61
+ let buf = "";
62
+ process.stdin.setEncoding("utf8");
63
+ process.stdin.on("data", (c) => {
64
+ buf += c;
65
+ for (let i; (i = buf.indexOf("\n")) >= 0; buf = buf.slice(i + 1)) {
66
+ const line = buf.slice(0, i);
67
+ if (line.trim())
68
+ chain = chain.then(() => send(line)); // in order; the server is stateless, so this is just simple
69
+ }
70
+ });
71
+ process.stdin.on("end", () => chain.then(() => process.exit(0)));
72
+ }