clearotron 0.3.1-beta.2 → 0.3.1-beta.3
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/bin/grant.mjs +21 -22
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +25 -0
- package/driver/package.json +1 -1
- package/driver/portal-config-view.mjs +26 -1
- package/driver/portal-report.mjs +16 -2
- package/driver/portal-service.mjs +286 -6
- package/driver/publish/render.mjs +0 -45
- package/driver/publish/templates/report.css +1 -30
- package/driver/skills/knockout-assess/SKILL.md +35 -6
- package/driver/skills/knockout-frame/SKILL.md +2 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +3 -3
- package/driver/stages.mjs +2 -2
- package/driver/suite-census.json +75 -9
- package/driver/verify-knockout.mjs +58 -0
- package/mcp-server/CHANGELOG.md +7 -0
- package/mcp-server/lib/audit.mjs +96 -3
- package/mcp-server/lib/http-handler.mjs +76 -3
- package/mcp-server/lib/runs.mjs +42 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/client/CONNECT.md +10 -6
- package/mcp-server/server.mjs +37 -3
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-D8ITW-aD.js → index-C-QysgZM.js} +1082 -630
- package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-CaSZbEMb.css} +15 -0
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +359 -0
- package/shared/grants-edit.mjs +99 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
11
11
|
import { AuthError } from "./cf-access.mjs";
|
|
12
12
|
import { appendAudit } from "./audit.mjs";
|
|
13
|
-
import { resolveScope, isFirmDomain, verifyToken } from "./scope.mjs";
|
|
13
|
+
import { resolveScope, isFirmDomain, verifyToken, addressesInGrants } from "./scope.mjs";
|
|
14
14
|
|
|
15
15
|
const hdr = (v) => (Array.isArray(v) ? v[0] : v);
|
|
16
16
|
|
|
@@ -69,13 +69,63 @@ export function evictOldest(sessions) {
|
|
|
69
69
|
* presents another identity's mcp-session-id is refused (403) — a leaked/guessed session id must never
|
|
70
70
|
* let one CF-authed person attach to another's session (which may carry an ops-scoped inner token).
|
|
71
71
|
*/
|
|
72
|
-
export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions, createSession, ns = "trademark-artifacts", sessionMax = 500, maxBody = 4 * 1024 * 1024, authHeader = "cf-access-jwt-assertion", firmDomains = [], clientSurface = false, devMode = false, tokenOnly = false, keyDoorPath = null,
|
|
72
|
+
export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions, createSession, ns = "trademark-artifacts", sessionMax = 500, maxBody = 4 * 1024 * 1024, authHeader = "cf-access-jwt-assertion", firmDomains = [], clientSurface = false, devMode = false, tokenOnly = false, keyDoorPath = null,
|
|
73
|
+
// Is this identity still on the guest list? ASKED PER REQUEST on an ACCOUNT session, cached on the
|
|
74
|
+
// grants file's mtime, so it costs a stat between edits. Injected so an arm can move the answer
|
|
75
|
+
// without a file; the default is the real read, because a door composed without this seam would
|
|
76
|
+
// silently go back to resolving reach once and holding it for half an hour.
|
|
77
|
+
stillEnrolled = (email) => addressesInGrants().includes(String(email ?? "").trim().toLowerCase()),
|
|
78
|
+
log = () => {} }) {
|
|
73
79
|
if (!verify && !devMode && !tokenOnly) throw new Error("makeHttpHandler: verify is required unless devMode:true or tokenOnly:true (fail-closed; refusing to build an unauthenticated handler)");
|
|
74
80
|
// The two verify-less modes mean OPPOSITE things and must never be combined: devMode trusts the local
|
|
75
81
|
// operator and hands out a synthetic identity, tokenOnly trusts NOBODY without a valid key. Together,
|
|
76
82
|
// the synthetic identity would be the thing that answers — an open door wearing a locked door's label.
|
|
77
83
|
if (tokenOnly && devMode) throw new Error("makeHttpHandler: tokenOnly and devMode are mutually exclusive (devMode's synthetic identity would defeat the mandatory key)");
|
|
78
84
|
if (tokenOnly && verify) throw new Error("makeHttpHandler: tokenOnly is for a door with no auth proxy in front — pass verify:null");
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Has this identity been taken off the guest list since its session was opened? Null when it has not,
|
|
88
|
+
* or when the question does not apply — and the body to send back when it has.
|
|
89
|
+
*
|
|
90
|
+
* ── WHY THIS IS ASKED AGAIN AT ALL ──────────────────────────────────────────────────────────────
|
|
91
|
+
*
|
|
92
|
+
* An ACCOUNT session's reach is resolved ONCE, at `resolveScope`, and lives in the server the
|
|
93
|
+
* transport was built around. After that, this branch compared the caller's email against the
|
|
94
|
+
* session's and nothing else — so somebody removed from an installation kept a whole account's reach
|
|
95
|
+
* through their assistant until the session went idle for `SESSION_TTL_MS`, half an hour by default.
|
|
96
|
+
* The portal's own side has never had that gap: it reads the file on every request. The People page
|
|
97
|
+
* now tells a manager that removing somebody takes their access away "straight away, here and through
|
|
98
|
+
* their AI", and this is what makes the second half of that sentence true rather than nearly true.
|
|
99
|
+
*
|
|
100
|
+
* ── WHY IT IS THIS NARROW ───────────────────────────────────────────────────────────────────────
|
|
101
|
+
*
|
|
102
|
+
* Four kinds of session reach this branch and only one of them is answerable from the guest list.
|
|
103
|
+
* A run-bound `user` session and an `ops` session carry a token whose subject is a run id or an
|
|
104
|
+
* automation principal, neither of which is an address anybody enrols; `internal` is firm staff,
|
|
105
|
+
* admitted by a domain rule that does not live in this file. Asking the guest list about any of them
|
|
106
|
+
* would refuse a caller for not being something they were never supposed to be — so the gate is the
|
|
107
|
+
* client surface and the `account` kind, which is exactly the population the removal sentence is
|
|
108
|
+
* about. Every other session behaves as it did.
|
|
109
|
+
*
|
|
110
|
+
* ── AND WHY IT FAILS CLOSED ─────────────────────────────────────────────────────────────────────
|
|
111
|
+
*
|
|
112
|
+
* A guest list that names nobody refuses. `CLEAROTRON_ACCESS_FILE` is mandatory on any door that can
|
|
113
|
+
* hold an account session (the boot guard exits on its absence), and the file is written by an atomic
|
|
114
|
+
* rename, so a reader sees the old file or the new one and never a torn one. An empty answer is
|
|
115
|
+
* therefore a real state — the file was emptied, or it stopped parsing — and not a transient worth
|
|
116
|
+
* holding a door open for.
|
|
117
|
+
*/
|
|
118
|
+
const revokedMidSession = (entry) => {
|
|
119
|
+
// WHO, read off the SCOPE and never off `user.email`. The two are the same address on a
|
|
120
|
+
// CF-fronted door and are NOT on the others: a key door names the token's subject, and dev mode
|
|
121
|
+
// hands out a synthetic identity that was never enrolled anywhere. Asking the guest list about the
|
|
122
|
+
// synthetic one refuses every session on a developer's box, which is how this was found.
|
|
123
|
+
if (!clientSurface || entry?.kind !== "account" || !entry?.sub) return null;
|
|
124
|
+
if (stillEnrolled(entry.sub)) return null;
|
|
125
|
+
log(`session withdrawn mid-session: ${entry.sub} is no longer on this installation's guest list`);
|
|
126
|
+
return { error: "your access to this installation has been withdrawn — ask whoever manages it, then start a new session" };
|
|
127
|
+
};
|
|
128
|
+
|
|
79
129
|
return async (req, res) => {
|
|
80
130
|
try {
|
|
81
131
|
// THE BASE IS A CONSTANT, AND WHAT THE `Host` HEADER IS USED FOR HERE IS: NOTHING. Only
|
|
@@ -175,6 +225,7 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
175
225
|
|
|
176
226
|
const sid = hdr(req.headers["mcp-session-id"]);
|
|
177
227
|
let entry = sid ? sessions.get(sid) : null;
|
|
228
|
+
let stampScope = null;
|
|
178
229
|
if (!entry) {
|
|
179
230
|
if (sid) return send(res, 404, { error: "unknown or expired session" });
|
|
180
231
|
if (!isInitializeRequest(body)) return send(res, 400, { error: "no session — the first request must be an MCP initialize" });
|
|
@@ -204,11 +255,29 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
204
255
|
}
|
|
205
256
|
const transport = await createSession(sessions, scope, user.email);
|
|
206
257
|
entry = { transport, sub: scope.sub ?? null, kind: scope.kind ?? null };
|
|
258
|
+
// STAMP THE SCOPE'S OWN FACTS ONTO THE STORED ENTRY, HERE, AND AFTER THE HANDSHAKE.
|
|
259
|
+
//
|
|
260
|
+
// `createSession` is injected, and every caller carries its own copy of the entry shape — two
|
|
261
|
+
// servers and every arm that builds a door — so `sub` and `kind` are recorded by some of them
|
|
262
|
+
// and omitted by others. A gate reading either would be true about whichever copies happened
|
|
263
|
+
// to set it and silently inert everywhere else, which is the one failure a gate must not have.
|
|
264
|
+
// The local `entry` above is this request's only; the map's is what every later request reads.
|
|
265
|
+
//
|
|
266
|
+
// AFTER, because the session has no id until the transport has answered the initialize: the
|
|
267
|
+
// id is minted inside `handleRequest`, and `onsessioninitialized` is what puts the entry in
|
|
268
|
+
// the map. Stamping before that read `sessions.get(undefined)`, found nothing, wrote nothing,
|
|
269
|
+
// and left the gate reading a field nobody had set — green, and doing nothing.
|
|
270
|
+
stampScope = () => {
|
|
271
|
+
const stored = transport.sessionId ? sessions.get(transport.sessionId) : null;
|
|
272
|
+
if (stored) { stored.sub = scope.sub ?? null; stored.kind = scope.kind ?? null; }
|
|
273
|
+
};
|
|
207
274
|
} else {
|
|
208
275
|
if (entry.email && entry.email !== user.email) {
|
|
209
276
|
log(`session owner mismatch: ${user.email} presented a session created by another identity`);
|
|
210
277
|
return send(res, 403, { error: "session belongs to another identity" });
|
|
211
278
|
}
|
|
279
|
+
const gone = revokedMidSession(entry);
|
|
280
|
+
if (gone) return send(res, 403, gone);
|
|
212
281
|
entry.lastSeen = Date.now();
|
|
213
282
|
}
|
|
214
283
|
// OPS-TOKENS item 6 — automation principals get their own (lower) bucket, keyed by the token's
|
|
@@ -219,7 +288,9 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
219
288
|
// Audit AFTER scope resolution so the line names the PRINCIPAL (token sub), not just the
|
|
220
289
|
// transport identity — still strictly before any tool dispatch. Best-effort, never blocks.
|
|
221
290
|
try { appendAudit({ email: user.email, sub: entry.sub ?? null, body }); } catch { /* best-effort */ }
|
|
222
|
-
|
|
291
|
+
const answered = entry.transport.handleRequest(req, res, body);
|
|
292
|
+
if (stampScope) { try { await answered; } finally { stampScope(); } }
|
|
293
|
+
return answered;
|
|
223
294
|
}
|
|
224
295
|
|
|
225
296
|
if (req.method === "GET" || req.method === "DELETE") {
|
|
@@ -230,6 +301,8 @@ export function makeHttpHandler({ verify, limiter, opsLimiter = null, sessions,
|
|
|
230
301
|
log(`session owner mismatch: ${user.email} presented a session created by another identity`);
|
|
231
302
|
return send(res, 403, { error: "session belongs to another identity" });
|
|
232
303
|
}
|
|
304
|
+
const gone = revokedMidSession(entry);
|
|
305
|
+
if (gone) return send(res, 403, gone);
|
|
233
306
|
entry.lastSeen = Date.now();
|
|
234
307
|
return entry.transport.handleRequest(req, res);
|
|
235
308
|
}
|
package/mcp-server/lib/runs.mjs
CHANGED
|
@@ -70,11 +70,23 @@ function runFromStatusFile(statusFile, agent) {
|
|
|
70
70
|
// exist". `mark` is the name-shaped filter: case-insensitive, on a PART of the word, and
|
|
71
71
|
// over the mark name AND the slug, because a client may hold either. `slug` stays exact — enumerateRuns
|
|
72
72
|
// has driver consumers (status-snapshot.mjs, repair-digest.mjs) that pass a slug meaning that one run.
|
|
73
|
+
//
|
|
74
|
+
// AND OVER THE CLIENT'S NAME AND THE PROJECT'S, which is the same defect a second time. A lawyer asked
|
|
75
|
+
// for one client's recent searches by that client's name; every row matched the mark filter on nothing,
|
|
76
|
+
// the list came back empty, and the assistant answered that no such search existed — of work delivered
|
|
77
|
+
// to that client the same day. The note above records the first instance and fixed only the name the
|
|
78
|
+
// mark is filed under. A name-shaped question is asked with whichever name the asker holds, and for a
|
|
79
|
+
// firm acting for many clients that is the client's as often as the mark's.
|
|
80
|
+
//
|
|
81
|
+
// IT WIDENS WHAT IS FOUND, NEVER WHO MAY SEE IT. The account gate runs on the RESULT of the tool call
|
|
82
|
+
// (filterByAccounts), after this, so a scoped session naming another firm's client still receives
|
|
83
|
+
// nothing — the row is found here and dropped there, exactly as a row found by mark name always was.
|
|
73
84
|
function markMatches(run, mark) {
|
|
74
85
|
const needle = String(mark).trim().toLowerCase();
|
|
75
86
|
if (!needle) return true;
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
const facts = runProfileFacts(run);
|
|
88
|
+
return [run.markName, run.slug, facts.clientName, facts.projectName]
|
|
89
|
+
.some((field) => String(field ?? "").toLowerCase().includes(needle));
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
// All runs (newest-first), optionally filtered by agent / state / slug / mark.
|
|
@@ -191,6 +203,34 @@ export function runAccountKey(run) {
|
|
|
191
203
|
} catch { return null; }
|
|
192
204
|
}
|
|
193
205
|
|
|
206
|
+
// WHO THE RUN WAS FOR, from the sidecar the driver freezes at the start of every run. The engine has
|
|
207
|
+
// always known this — `runAccountKey` reads the same file to decide who may SEE a run — and no surface
|
|
208
|
+
// ever showed it, so a list of eight runs for two clients named neither.
|
|
209
|
+
//
|
|
210
|
+
// A SEPARATE READER RATHER THAN A REFACTOR of the two below. Those two are the access path: the grants
|
|
211
|
+
// tests pin them against the real frozen shape, the legacy `{key}` shape and the unreadable case, and
|
|
212
|
+
// this issue asks for a field on a row rather than a rewrite of who can see what. The cost is one more
|
|
213
|
+
// parse of a small file on the calls that ask for these facts.
|
|
214
|
+
//
|
|
215
|
+
// AN UNREADABLE SIDECAR IS ANSWERED, NOT OMITTED. A row with no client field reads as a run belonging
|
|
216
|
+
// to nobody in particular, which is how eight rows managed to say nothing about who they were for;
|
|
217
|
+
// `known: false` says the engine cannot tell, which is a different sentence and an actionable one.
|
|
218
|
+
export function runProfileFacts(run) {
|
|
219
|
+
try {
|
|
220
|
+
const p = JSON.parse(readFileSync(driverDir(run.runDir, "profile.json"), "utf8"));
|
|
221
|
+
const key = p.profileKey ?? p.key ?? null;
|
|
222
|
+
return {
|
|
223
|
+
known: true,
|
|
224
|
+
account: key,
|
|
225
|
+
clientName: typeof p.name === "string" && p.name ? p.name : null,
|
|
226
|
+
projectKey: typeof p.projectKey === "string" && p.projectKey ? p.projectKey : null,
|
|
227
|
+
projectName: typeof p.projectName === "string" && p.projectName ? p.projectName : null,
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return { known: false, account: null, clientName: null, projectKey: null, projectName: null };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
194
234
|
// A Generic run's organisation, from the same frozen sidecar. Null for a company's run, for one filed
|
|
195
235
|
// before organisations existed, and for an unreadable sidecar — each of which the account gate treats as
|
|
196
236
|
// visible only to a full-grant session.
|
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.1-beta.
|
|
3
|
+
"version": "0.3.1-beta.3",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
# Connect your AI to your clearance report
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Connect once, and the **Ask AI** button on any of your reports opens Claude or ChatGPT with a question
|
|
4
|
+
about that report already typed in.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
Your connector address is on the **Use your own AI** page in the portal, not on the report. It is
|
|
7
|
+
read-only and takes a minute to add.
|
|
8
|
+
|
|
9
|
+
**Treat the address like the report itself**: it carries your access. Don't forward it beyond the
|
|
10
|
+
people who may read the report.
|
|
8
11
|
|
|
9
12
|
## Claude — Desktop or claude.ai (recommended)
|
|
10
13
|
|
|
11
14
|
1. **Settings → Connectors** (paid plan required).
|
|
12
|
-
2. **Add custom connector** → paste the address from your
|
|
13
|
-
3.
|
|
15
|
+
2. **Add custom connector** → paste the address from the Use your own AI page → **Add**.
|
|
16
|
+
3. Open a report and press **Ask AI → Ask Claude**.
|
|
14
17
|
|
|
15
18
|
## ChatGPT — Business / Enterprise / Edu
|
|
16
19
|
|
|
17
20
|
1. **Settings → Connectors → Advanced → Developer mode** (an admin may need to enable it).
|
|
18
21
|
2. **Add a connector / MCP server** → paste the address.
|
|
22
|
+
3. Open a report and press **Ask AI → Ask ChatGPT**.
|
|
19
23
|
|
|
20
24
|
## Command-line / IDE tools (Claude Code, Cursor, …)
|
|
21
25
|
|
package/mcp-server/server.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import "../shared/env-local.mjs"; // side effect: apply <repo>/.env when THIS file is the CLI entry (never on library import)
|
|
21
21
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
|
+
import { appendAudit } from "./lib/audit.mjs";
|
|
23
24
|
import {
|
|
24
25
|
ListToolsRequestSchema, CallToolRequestSchema,
|
|
25
26
|
ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema,
|
|
@@ -30,7 +31,7 @@ import { join, basename } from "node:path";
|
|
|
30
31
|
import { driverDir } from "../shared/driver-dir.mjs"; //
|
|
31
32
|
import { fileURLToPath } from "node:url";
|
|
32
33
|
|
|
33
|
-
import { enumerateRuns, resolveRun, runAccountKey, runOrganisation } from "./lib/runs.mjs";
|
|
34
|
+
import { enumerateRuns, resolveRun, runAccountKey, runOrganisation, runProfileFacts } from "./lib/runs.mjs";
|
|
34
35
|
import { ORDERABLE_PRODUCTS } from "../driver/search-policy.mjs";
|
|
35
36
|
import { PRODUCTS } from "../driver/products.mjs";
|
|
36
37
|
|
|
@@ -179,8 +180,19 @@ function getStages(runDir) {
|
|
|
179
180
|
|
|
180
181
|
function runSummary(run) {
|
|
181
182
|
const s = run.status ?? {};
|
|
183
|
+
// WHO THE SEARCH WAS FOR, on every row. A session that holds several clients was handed eight rows
|
|
184
|
+
// naming none of them, so no reading of that list could answer "which of these is for <client>" —
|
|
185
|
+
// and the assistant answered that none were, of a search delivered to that client the same day. The
|
|
186
|
+
// engine knew: the same sidecar decides who may see the run, and it was read for the gate and never
|
|
187
|
+
// shown. An unreadable one says so on the row rather than leaving the field out, because a missing
|
|
188
|
+
// field is what made the list unanswerable in the first place.
|
|
189
|
+
const facts = runProfileFacts(run);
|
|
182
190
|
return {
|
|
183
191
|
runId: run.runId, slug: run.slug, codename: run.codename, date: run.date, agent: run.agent,
|
|
192
|
+
client: facts.known
|
|
193
|
+
? { key: facts.account, name: facts.clientName }
|
|
194
|
+
: { key: null, name: null, known: false, note: "this run's client could not be read from its own record" },
|
|
195
|
+
project: facts.projectKey ? { key: facts.projectKey, name: facts.projectName ?? facts.projectKey } : null,
|
|
184
196
|
state: run.state, location: run.location, verdict: run.verdict, url: run.url,
|
|
185
197
|
markName: run.markName, ref: run.ref, classes: run.classes,
|
|
186
198
|
step: s.stepN ? `${s.stepN}/${s.stepTotal} ${s.stepLabel ?? ""}`.trim() : null,
|
|
@@ -603,7 +615,7 @@ const TOOL_DEFS = [
|
|
|
603
615
|
// recipient is a lawyer who layers advice on top, and a tool description promising advice is how it
|
|
604
616
|
// comes back. The brief names WHICH PRODUCT the run was instead, resolved from the registry.
|
|
605
617
|
{ name: "brief", description: "START HERE for any 'what do you have on X / what did the search find' question — ONE plain-language briefing of a run, in the client-voiced wording (which search product this run was, overall risk in plain words, each conflict in 1–2 plain sentences, any conditions on the result). Facts, never advice. No internal stage names, no risk-formula codes, no model names. Reach for the audit tools (get_run, trace, decision_timeline, get_telemetry) only when the user asks HOW the system reached a conclusion.", inputSchema: { type: "object", properties: { ...runIdProp }, required: ["runId"] } },
|
|
606
|
-
{ name: "list_runs", description: "List the searches
|
|
618
|
+
{ name: "list_runs", description: "List the searches this session can see (in progress and finished), newest first. A session may hold more than one client, so the list is NOT one client's: every row names the client it was run for, and the project where there is one. TO FIND ONE CLIENT'S SEARCHES, pass `mark` with the client's name — the same name-shaped filter reads the client and the project as well as the mark. TO FIND A SEARCH BY THE NAME IT CLEARED, pass `mark` — the stored identifier is a longer prefixed slug a client has never seen, so a bare mark name will not match `slug`. If a filtered call comes back empty, list unfiltered and read down the mark names before telling anyone there is nothing there. States: 'postponed' = paused on a provider cap and resuming by itself (resetsAt); 'recovering' = backing off and retrying (recoveryResumesAt); 'parked-for-human' = interrupted mid-search, resumes on the next activation. sendPending:true is the authoritative list of finished searches still owed their delivery notification. Follow up with brief for a plain-language summary of one search.", inputSchema: { type: "object", properties: { agent: { type: "string" }, state: { type: "string", enum: ["running", "delivered", "failed", "postponed", "recovering", "parked-for-human", "cancelled"] }, mark: { type: "string", description: "Find a search by NAME: the mark it cleared, the client it was run for, the project it was run under, or the identifier — case-insensitively, on part of the word. This is the parameter a name-shaped question wants, whichever of those names the asker holds." }, slug: { type: "string", description: "EXACT identifier match, for a caller that already holds one. A mark name is not a slug — use `mark`." }, sendPending: { type: "boolean", description: "true = only searches still owed a delivery notification; false = only already-settled ones." }, limit: { type: "number" } } } },
|
|
607
619
|
{ name: "list_profiles", description: "List the customer ACCOUNTS the firm runs clearotron clearances for (key + name + industry + their PROJECTS) — the roster the intake step resolves a job's profileKey (and optional projectKey) against. profileKey selects that customer's marketplaces, default classes/jurisdictions, report format and risk posture; a project (spec 62) is an engagement UNDER a customer that overlays its own marketplaces/classes/sector/posture. Resolve by JUDGMENT (explicit name, misspelling, or implicit reference); OMIT profileKey for a new/unknown customer (uses the neutral generic profile — a customer who does not exist here yet never blocks a search; tell the requester it is running under the neutral profile, and that their own company can be set up in the portal if they want its framework, territories and marketplaces applied instead); set projectKey only when the request names a specific project in that customer's projects[], else OMIT it (runs on the customer profile); never pick a profile from the sender's email domain. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
608
620
|
{ name: "get_run", description: "ENGINEERING/AUDIT view — full run mechanics: status, every pipeline stage (trigger/outcome), failover facts, artifact validity, and a coverage summary. Use when the user asks how the run executed; for 'what did it find', call brief instead.", inputSchema: { type: "object", properties: { ...runIdProp }, required: ["runId"] } },
|
|
609
621
|
{ name: "read_artifact", description: "Read a run artifact's raw text (or one '# Section', or its front-matter). For briefing a person, the report is the plain-language layer; the rest are internal working documents. Names: report, audit, narrative, registerFindings, commonLaw, placement, matterContext, variantManifest, skepticFlags, seniorEyeReview, caseLaw, clientSummary (internal cover-note source; ops only), a register axis (e.g. primary-sweep), status.json, run.jsonl.", inputSchema: { type: "object", properties: { ...runIdProp, name: { type: "string" }, section: { type: "string", description: "Return only this '# Section'." }, frontMatterOnly: { type: "boolean" } }, required: ["runId", "name"] } },
|
|
@@ -739,6 +751,13 @@ export function promptText(kind, name) {
|
|
|
739
751
|
const CLIENT_TOOL_TEXT = {
|
|
740
752
|
read_artifact: (kind) =>
|
|
741
753
|
`Read one of this search's documents, or a single "# Section" of one. "report" is the report itself — the plain-language layer, and the right answer to almost every question about what the search concluded. Readable here: ${[...(kind === "user" ? USER_ARTIFACTS : ACCOUNT_ARTIFACTS)].join(", ")}${kind === "user" ? "" : ', or one register pass as "registerUnit:<axis>". "commonLaw" carries the off-register use layer AND the meaning-and-connotation reading — read it before saying a search did not look at what a name means'}. Anything else on the search is internal and refused by name.`,
|
|
754
|
+
// THE DESCRIPTION WAS HALF THE DEFECT, and this tool had no client cut at all — so a lawyer's session
|
|
755
|
+
// was handed the operator's line, "the searches on this account", while holding several clients. Told
|
|
756
|
+
// the list is one client's, an empty result reads as "that client has nothing" rather than "this list
|
|
757
|
+
// was not asked the right way". It names no client and offers no way to enumerate them: it says the
|
|
758
|
+
// rows carry their own, which is true whatever this session happens to hold.
|
|
759
|
+
list_runs: () =>
|
|
760
|
+
"Every search this session can see, newest first, in progress and finished. Each row says which client the search was run for, and which project where there is one — so a session acting for several clients can tell them apart without asking again. To find one client's searches, pass `mark` with that client's name; the same filter also matches the mark and the project, on part of the word, case-insensitively. If a filtered call comes back empty, list unfiltered and read down the rows before telling anyone there is nothing there. Follow up with brief for a plain-language summary of one search.",
|
|
742
761
|
get_run: () =>
|
|
743
762
|
"What actually happened during this search: which steps ran, in what order, what each produced, whether anything was retried, and where it stands now. Use it when the user asks how the search was carried out; for what it found, call brief instead.",
|
|
744
763
|
trace: () =>
|
|
@@ -1063,6 +1082,21 @@ export { tools, TOOL_DEFS, NS, log };
|
|
|
1063
1082
|
const isMain = isEntrypoint(import.meta.url);
|
|
1064
1083
|
if (isMain) {
|
|
1065
1084
|
makeServer().connect(new StdioServerTransport())
|
|
1066
|
-
.then(() =>
|
|
1085
|
+
.then(() => {
|
|
1086
|
+
log("ready — read-only interrogation + gated what-if over clearotron runs");
|
|
1087
|
+
// ── LEAVE A RECORD THE PORTAL CAN READ ────────────────────────────────────────────────────────
|
|
1088
|
+
//
|
|
1089
|
+
// The HTTP doors write the caller's email to the access log on every request; this route wrote
|
|
1090
|
+
// nothing, so a reader whose only connector is the local one was invisible to the portal and its
|
|
1091
|
+
// Ask-AI control could only offer them setup they had already done.
|
|
1092
|
+
//
|
|
1093
|
+
// NO EMAIL, AND NONE IS SYNTHESIZED. This transport has no signed-in identity — it is a process an
|
|
1094
|
+
// assistant spawned on somebody's own machine — and an invented address would match a person who
|
|
1095
|
+
// did nothing. The record says what is true: a connector on this box was started over the local
|
|
1096
|
+
// route. On an install with no hosted client door the reader IS the operator, which is the same
|
|
1097
|
+
// split `stdioConnectOffer` already trusts, so that fact answers the question by itself; on a
|
|
1098
|
+
// hosted install it answers for nobody and the portal ignores it.
|
|
1099
|
+
appendAudit({ email: null, sub: null, body: { method: "initialize" }, status: "connected", transport: "stdio" });
|
|
1100
|
+
})
|
|
1067
1101
|
.catch((e) => { log(`fatal: ${e?.stack ?? e}`); process.exit(1); });
|
|
1068
1102
|
}
|