clearotron 0.2.4 → 0.3.0-beta.1
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/.env.example +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +62 -38
- package/bin/brandowner.mjs +18 -169
- package/bin/clearotron.mjs +3 -1
- package/bin/connect.mjs +28 -19
- package/bin/disconnect.mjs +3 -3
- package/bin/example.mjs +7 -7
- package/bin/framework-preflight.mjs +49 -0
- package/bin/grant.mjs +151 -93
- package/bin/onboard.mjs +220 -138
- package/bin/start.mjs +146 -137
- package/bin/stop.mjs +2 -2
- package/bin/update.mjs +1 -1
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +2 -2
- package/docs/E2E.md +12 -2
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +14 -13
- package/docs/SECURITY.md +23 -24
- package/docs/architecture/04-configuration-reference.md +12 -5
- package/docs/architecture/05-config-governance.md +7 -7
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +5 -0
- package/docs/configuration.md +118 -0
- package/docs/decisions/0004-documentation-structure.md +2 -2
- package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
- package/driver/CHANGELOG.md +70 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/cancel.mjs +27 -0
- package/driver/case-law-sources.mjs +3 -3
- package/driver/company-bundle.mjs +261 -0
- package/driver/compare.mjs +1 -1
- package/driver/compose-read.mjs +2 -2
- package/driver/config-inventory.mjs +2 -2
- package/driver/contract-audit.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +1 -1
- package/driver/declination-call.mjs +1 -1
- package/driver/deliver-trigger.sh +3 -3
- package/driver/dev-portal.mjs +3 -1
- package/driver/digest-queue.mjs +1 -1
- package/driver/disposition-tool.mjs +1 -1
- package/driver/doc-constants.mjs +1 -1
- package/driver/drain-posture.mjs +2 -2
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +44 -9
- package/driver/effective-scope.mjs +30 -1
- package/driver/effort-model.mjs +6 -6
- package/driver/engine/CONTRACT.md +2 -2
- package/driver/engine/anthropic-agent.mjs +11 -11
- package/driver/engine/jx-turn.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +29 -4
- package/driver/engine/mcp/recording-server.mjs +73 -1
- package/driver/engine/openai-agent.mjs +1 -1
- package/driver/engine/probe.mjs +28 -4
- package/driver/enqueue-schema.mjs +23 -3
- package/driver/findings-model.mjs +2 -2
- package/driver/flag-snapshot.mjs +2 -2
- package/driver/floor-duty.mjs +2 -2
- package/driver/frame-diff-model.mjs +1 -1
- package/driver/framework-preflight.mjs +143 -0
- package/driver/gateway.mjs +9 -1
- package/driver/hit-list.mjs +1 -1
- package/driver/jx-lanes.mjs +1 -1
- package/driver/jx.mjs +1 -1
- package/driver/knockout-assess-record.mjs +1 -1
- package/driver/knockout-review-record.mjs +435 -0
- package/driver/order-probe.mjs +1 -1
- package/driver/outbox-backoff.mjs +2 -2
- package/driver/owner-use-check.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +105 -8
- package/driver/pipeline.mjs +81 -30
- package/driver/plain-register.mjs +77 -3
- package/driver/portal-access.mjs +141 -74
- package/driver/portal-config-view.mjs +59 -70
- package/driver/portal-report.mjs +4 -4
- package/driver/portal-service.mjs +348 -141
- package/driver/portal-upstream.mjs +105 -17
- package/driver/predelivery-lint.mjs +43 -18
- package/driver/product-rows.mjs +1 -1
- package/driver/products.mjs +1 -1
- package/driver/profile-page.html +30 -5
- package/driver/profile-service.mjs +197 -26
- package/driver/profiles.mjs +48 -1
- package/driver/publish/index.mjs +31 -13
- package/driver/publish/knockout.mjs +9 -5
- package/driver/publish/office-record-links.mjs +189 -0
- package/driver/publish/parse.mjs +3 -3
- package/driver/publish/publish-inputs.mjs +26 -0
- package/driver/publish/render-knockout.mjs +42 -42
- package/driver/publish/render.mjs +29 -4
- package/driver/publish/report-data.mjs +2 -2
- package/driver/publish/seed-pool.mjs +1 -1
- package/driver/publish/templates/report.css +8 -8
- package/driver/publish/xlsx.mjs +49 -7
- package/driver/queue-watch-verdict.mjs +2 -2
- package/driver/recipe-service.mjs +1 -1
- package/driver/record-carry.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/reference-strip-signatures.mjs +1 -1
- package/driver/register-availability.mjs +4 -3
- package/driver/register-count.mjs +3 -3
- package/driver/register-records.mjs +1 -1
- package/driver/repair-composers.mjs +1 -1
- package/driver/repairs.mjs +3 -3
- package/driver/replay-archive.mjs +1 -1
- package/driver/report-card-record.mjs +1 -1
- package/driver/result-noun-fields.mjs +5 -0
- package/driver/roster-verdict.mjs +48 -5
- package/driver/run-activity.mjs +1 -1
- package/driver/run-requirements.mjs +18 -5
- package/driver/runner.mjs +24 -15
- package/driver/search-policy.mjs +8 -8
- package/driver/senior-rights.mjs +1 -1
- package/driver/skills/prelim-search/delivery-contract.md +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
- package/driver/stages-knockout.mjs +72 -6
- package/driver/stages.mjs +10 -10
- package/driver/suite-census.json +238 -70
- package/driver/synthesis-record.mjs +2 -2
- package/driver/systemd/clearotron-client-mcp.service +3 -3
- package/driver/systemd/clearotron-deploy.service +2 -2
- package/driver/systemd/clearotron-mcp-face.service +1 -1
- package/driver/systemd/clearotron-portal.service +3 -3
- package/driver/systemd/clearotron-worker.service +5 -5
- package/driver/systemd/install-census.mjs +1 -1
- package/driver/systemd/render-units.mjs +9 -9
- package/driver/terminal-clamp.mjs +1 -1
- package/driver/trigger-cap.mjs +18 -2
- package/driver/unit-inventory.mjs +8 -8
- package/driver/usage-ledger.mjs +5 -3
- package/driver/verify-knockout.mjs +7 -7
- package/driver/verify.mjs +5 -5
- package/driver/whatif-memo-run.mjs +1 -1
- package/driver/whatif-queue.mjs +3 -3
- package/driver/whatif-worker.mjs +2 -2
- package/examples/README.md +1 -1
- package/examples/grants.example.json +25 -24
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/http-server.mjs +3 -3
- package/mcp-server/key-socket.mjs +1 -1
- package/mcp-server/lib/audit-view.mjs +3 -3
- package/mcp-server/lib/brief.mjs +3 -3
- package/mcp-server/lib/driver.mjs +1 -1
- package/mcp-server/lib/events.mjs +1 -1
- package/mcp-server/lib/http-handler.mjs +2 -2
- package/mcp-server/lib/instructions.mjs +2 -2
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/ops.mjs +6 -3
- package/mcp-server/lib/options.mjs +15 -4
- package/mcp-server/lib/plan.mjs +7 -6
- package/mcp-server/lib/runs.mjs +10 -0
- package/mcp-server/lib/whatif.mjs +5 -5
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/remote/client-mcp-apikey.service +1 -1
- package/mcp-server/remote/client-mcp.service +2 -2
- package/mcp-server/remote/trademark-artifacts-http.service +1 -1
- package/mcp-server/server.mjs +38 -24
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
- package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/lane-probe.mjs +9 -3
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
- package/scripts/citation-drift-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/drive-env-check.mjs +1 -1
- package/scripts/e2e.mjs +5 -5
- package/scripts/env-audit.mjs +13 -1
- package/scripts/headless-page.mjs +5 -5
- package/scripts/live-surface-check.mjs +26 -2
- package/scripts/mint-names-in-force.mjs +19 -5
- package/scripts/mint-reference-strip-backlog.mjs +1 -1
- package/scripts/mint-suite-census.mjs +37 -10
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/preinstall-node-check.mjs +1 -1
- package/scripts/release-await-cut.mjs +3 -3
- package/scripts/release-cut-decision.mjs +1 -1
- package/scripts/release-dist-tag.mjs +1 -1
- package/scripts/release-install-check.mjs +1 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/scripts/release-publish-guard.mjs +1 -1
- package/scripts/release-version-pr-checks.mjs +2 -2
- package/scripts/release-version.mjs +61 -5
- package/scripts/render-brand-banner.mjs +1 -1
- package/scripts/render-check.mjs +2 -2
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-frame-check.mjs +1 -1
- package/scripts/report-screenshot.mjs +2 -2
- package/scripts/retire-bare-refs.mjs +1 -1
- package/scripts/revisit-render-check.mjs +1 -1
- package/scripts/score.mjs +1 -1
- package/scripts/strip-titles-and-attributions.mjs +389 -0
- package/scripts/strip-tracker-citations.mjs +122 -5
- package/scripts/test-run.mjs +4 -4
- package/scripts/third-party-notices.mjs +1 -1
- package/scripts/verify-publishable.mjs +1 -1
- package/shared/access-audience.mjs +2 -2
- package/shared/anon-overlay.mjs +1 -1
- package/shared/brand.mjs +15 -1
- package/shared/bundle-freshness.mjs +1 -1
- package/shared/bundle-rebuild.mjs +1 -1
- package/shared/checkout-move.mjs +2 -2
- package/shared/client-door.mjs +8 -8
- package/shared/connect-clients.mjs +7 -7
- package/shared/connector-signin-probe.mjs +1 -1
- package/shared/env-aliases.mjs +1 -1
- package/shared/env-local.mjs +5 -5
- package/shared/grants-edit.mjs +76 -0
- package/shared/install-auth.mjs +1 -1
- package/shared/listen.mjs +3 -3
- package/shared/mcp-challenge.mjs +1 -1
- package/shared/names-in-force.mjs +2 -1
- package/shared/onboarding-store.mjs +19 -2
- package/shared/reference-guard-classes.mjs +44 -2
- package/shared/register-selection.mjs +1 -1
- package/shared/scope.mjs +223 -56
- package/shared/secret-file.mjs +1 -1
- package/shared/server-units.mjs +1 -1
- package/shared/staff-domain.mjs +45 -78
- package/shared/summary-blocks.mjs +2 -2
- package/shared/systemd-failure.mjs +3 -3
- package/shared/tracked-files.mjs +1 -1
- package/shared/trigger-lane.mjs +1 -1
- package/shared/tty-style.mjs +1 -1
- package/shared/usage-block.mjs +1 -1
- package/shared/vacuous-pass.mjs +1 -1
- package/shared/verb-shim.mjs +1 -1
package/shared/scope.mjs
CHANGED
|
@@ -64,7 +64,7 @@ export function isRevoked(jti, { denylistPath = process.env.TRADEMARK_MCP_TOKEN_
|
|
|
64
64
|
*
|
|
65
65
|
* NOT AN AUTHENTICATOR. This parses without verifying, which is safe for exactly one job: reading the
|
|
66
66
|
* revocation handle out of our own `mintToken` output so `clearotron connect` can write it down
|
|
67
|
-
* (
|
|
67
|
+
* (ruling: record key IDs, never secrets). Anything answering "is this token
|
|
68
68
|
* good" goes through `verifyToken`; a caller handing this function a token from the WIRE is the defect.
|
|
69
69
|
*
|
|
70
70
|
* Returns null rather than throwing on a malformed string — the caller is recording, and a record of
|
|
@@ -212,7 +212,7 @@ export const TOOL_SCOPES = {
|
|
|
212
212
|
// an account session is answered from ITS OWN grant, never from the customer list.
|
|
213
213
|
// `passthrough`, flagged with plan_run — same posture, same open question.
|
|
214
214
|
describe_options: { crossRun: true, accountSafe: true, present: "passthrough" },
|
|
215
|
-
// ---- WHAT-IF, OPENED TO A CLIENT ACCOUNT (
|
|
215
|
+
// ---- WHAT-IF, OPENED TO A CLIENT ACCOUNT (ruling 2026-08-27) -------------------------------
|
|
216
216
|
//
|
|
217
217
|
// `write: true` stays on BOTH, and on what_if_plan that is deliberate rather than inherited. The flag's
|
|
218
218
|
// stated meaning is "mutates state or spends", and a plan does neither — but the ONLY thing that reads
|
|
@@ -230,7 +230,7 @@ export const TOOL_SCOPES = {
|
|
|
230
230
|
// note and drops the other two. The enqueue acknowledgement is projected for the same reason every
|
|
231
231
|
// client-reachable result is — so a field added to it later cannot arrive unruled.
|
|
232
232
|
//
|
|
233
|
-
// `readOnly` IS SET HERE BECAUSE `write` IS THE WRONG ANSWER FOR THIS ONE TOOL
|
|
233
|
+
// `readOnly` IS SET HERE BECAUSE `write` IS THE WRONG ANSWER FOR THIS ONE TOOL.
|
|
234
234
|
// The MCP `readOnlyHint` annotation is derived from `write` so there is one source of truth and not
|
|
235
235
|
// two names for one fact — but the paragraph above says plainly that `write: true` sits on
|
|
236
236
|
// what_if_plan for the OPS ALLOWLIST's sake, not because it mutates or spends. Deriving the hint
|
|
@@ -263,7 +263,7 @@ export const TOOL_SCOPES = {
|
|
|
263
263
|
// status.json, …) is internal and stays sealed from a user token.
|
|
264
264
|
// Exported so the server's Resources surface (ListResources/ReadResource) gates to the SAME set.
|
|
265
265
|
/**
|
|
266
|
-
* Does this tool only READ? — the source of MCP's `readOnlyHint` annotation
|
|
266
|
+
* Does this tool only READ? — the source of MCP's `readOnlyHint` annotation.
|
|
267
267
|
*
|
|
268
268
|
* WHY IT IS DERIVED. Without annotations a client cannot tell `brief` from `start_run`, so it asks
|
|
269
269
|
* before every call — the owner, driving the ops connector: "it prompts all the time." The cost is not
|
|
@@ -286,7 +286,7 @@ export function readOnlyFor(toolName) {
|
|
|
286
286
|
|
|
287
287
|
export const USER_ARTIFACTS = new Set(["report"]);
|
|
288
288
|
|
|
289
|
-
// ---- THE AUDIT CHAIN, OPENED TO A CLIENT ACCOUNT (
|
|
289
|
+
// ---- THE AUDIT CHAIN, OPENED TO A CLIENT ACCOUNT (ruling 2026-08-27) -----------------------
|
|
290
290
|
//
|
|
291
291
|
// "I don't see why we don't open it or just give it to clients. Ignore the call spend." That ruling
|
|
292
292
|
// widens the line the TOOL_SCOPES header draws above — the one that read "every engineering read stays
|
|
@@ -401,20 +401,47 @@ export function loadGrants({ grantsPath = envFrom(process.env, "CLEAROTRON_ACCES
|
|
|
401
401
|
// can still fix it, not on the next client's request. The message names the path INTO the file, what was
|
|
402
402
|
// found, and what the shape is — a reader has to be able to go straight to the line.
|
|
403
403
|
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
404
|
+
// THE TREE. A tenant is an organisation, and it lists the companies (account keys) it holds. A company
|
|
405
|
+
// belongs to exactly one organisation, so a key listed under two tenants is refused: two organisations
|
|
406
|
+
// are invisible to each other because they are sibling branches, and a company under both branches has
|
|
407
|
+
// no single place in the tree. Access to the whole install is a property of a PERSON, not of a tenant,
|
|
408
|
+
// so a tenant's `accounts` is an array and never "*".
|
|
409
|
+
//
|
|
410
|
+
// A user maps to "*" (the whole organisation) or an array of the keys that organisation holds. Anything
|
|
411
|
+
// else is refused rather than coerced, for the reason the file's own header gives — a configured guest
|
|
412
|
+
// list must never fail open.
|
|
413
|
+
//
|
|
414
|
+
// THE PEOPLE. A top-level `people` section, keyed by address, holds each person's two switches — `run`
|
|
415
|
+
// (start and stop clearances) and `manage` (add people, add companies, change settings) — and
|
|
416
|
+
// `everything`, access to the top of the tree. A sibling of `tenants` for the reason `connectKeys` is
|
|
417
|
+
// one: every editor that checks only `tenants` passes it through. A person with no entry holds both
|
|
418
|
+
// switches off, which is the view-only person; nothing is granted by leaving a line out.
|
|
419
|
+
const PERSON_FIELDS = ["run", "manage", "everything"];
|
|
407
420
|
export function assertGrantsShape(g, where) {
|
|
408
421
|
const at = (...parts) => `${where}: tenants.${parts.join(".")}`;
|
|
409
422
|
const keys = (v) => Array.isArray(v) && v.every((k) => typeof k === "string");
|
|
423
|
+
const holder = new Map();
|
|
410
424
|
for (const [tenant, t] of Object.entries(g.tenants ?? {})) {
|
|
411
425
|
if (!t || typeof t !== "object" || Array.isArray(t)) {
|
|
412
426
|
throw new Error(`${at(tenant)} is ${describe(t)} — each tenant must be an object with "accounts" and "users".`);
|
|
413
427
|
}
|
|
414
|
-
if (t.
|
|
415
|
-
throw new Error(`${at(tenant, "
|
|
428
|
+
if (t.name !== undefined && (typeof t.name !== "string" || !t.name.trim())) {
|
|
429
|
+
throw new Error(`${at(tenant, "name")} is ${describe(t.name)} — it must be the organisation's name, as text.`);
|
|
430
|
+
}
|
|
431
|
+
if (t.accounts === "*") throw new Error(wildcardTenant(`${where}: tenants.${tenant}.accounts`));
|
|
432
|
+
if (t.accounts !== undefined && !keys(t.accounts)) {
|
|
433
|
+
throw new Error(`${at(tenant, "accounts")} is ${describe(t.accounts)} — it must be an array of account keys, `
|
|
416
434
|
+ `for example ["${tenant}"].`);
|
|
417
435
|
}
|
|
436
|
+
for (const a of t.accounts ?? []) {
|
|
437
|
+
if (a === "generic") continue; // the house default, not a company — every organisation has its own
|
|
438
|
+
if (holder.has(a)) {
|
|
439
|
+
throw new Error(`${where}: account "${a}" is listed under both tenants.${holder.get(a)} and tenants.${tenant} — `
|
|
440
|
+
+ `a company belongs to exactly one organisation. Keep it under one, and give the people of the other `
|
|
441
|
+
+ `access to it there.`);
|
|
442
|
+
}
|
|
443
|
+
holder.set(a, tenant);
|
|
444
|
+
}
|
|
418
445
|
if (t.users !== undefined && (!t.users || typeof t.users !== "object" || Array.isArray(t.users))) {
|
|
419
446
|
throw new Error(`${at(tenant, "users")} is ${describe(t.users)} — it must be an object mapping each email `
|
|
420
447
|
+ `(or "*@domain") to "*" or an array of account keys.`);
|
|
@@ -426,6 +453,37 @@ export function assertGrantsShape(g, where) {
|
|
|
426
453
|
}
|
|
427
454
|
}
|
|
428
455
|
}
|
|
456
|
+
if (g.people === undefined) return;
|
|
457
|
+
if (!g.people || typeof g.people !== "object" || Array.isArray(g.people)) {
|
|
458
|
+
throw new Error(`${where}: people is ${describe(g.people)} — it must be an object mapping each email to that `
|
|
459
|
+
+ `person's switches, for example {"you@example.com": {"run": true, "manage": true}}.`);
|
|
460
|
+
}
|
|
461
|
+
for (const [who, entry] of Object.entries(g.people)) {
|
|
462
|
+
const here = `${where}: people.${JSON.stringify(who)}`;
|
|
463
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
464
|
+
throw new Error(`${here} is ${describe(entry)} — it must be an object holding "run", "manage" and "everything", `
|
|
465
|
+
+ `each true or false.`);
|
|
466
|
+
}
|
|
467
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
468
|
+
if (!PERSON_FIELDS.includes(k)) {
|
|
469
|
+
throw new Error(`${here}.${k} is not a field of a person — a person holds ${PERSON_FIELDS.map((f) => `"${f}"`).join(", ")}, `
|
|
470
|
+
+ `each true or false. A misspelt switch would read as off, so it is refused rather than ignored.`);
|
|
471
|
+
}
|
|
472
|
+
if (typeof v !== "boolean") throw new Error(`${here}.${k} is ${describe(v)} — it must be true or false.`);
|
|
473
|
+
}
|
|
474
|
+
if (String(who).startsWith("*@") && (entry.manage || entry.everything)) {
|
|
475
|
+
throw new Error(`${here} gives a whole email domain ${entry.everything ? "access to everything" : "Manage"} — `
|
|
476
|
+
+ `that is the staff-by-domain rule this product removed. Name each person who should hold it.`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// THE EDIT FIRST. An organisation left on "*" is what a guest list written before organisations carries
|
|
482
|
+
// when the upgrade was not followed, so the refusal says what to change before it says why.
|
|
483
|
+
function wildcardTenant(path) {
|
|
484
|
+
return `${path} is "*". Replace "*" with the list of companies this organisation holds, for example `
|
|
485
|
+
+ `["acme-main", "acme-eu"]; a company belongs to exactly one organisation. For a person who should see `
|
|
486
|
+
+ `every company, set "everything": true on their entry under "people".`;
|
|
429
487
|
}
|
|
430
488
|
|
|
431
489
|
// What a reader sees, in the words of the file they wrote — "an object", not "[object Object]". The
|
|
@@ -438,46 +496,108 @@ function describe(v) {
|
|
|
438
496
|
return `${typeof v} ${JSON.stringify(v)}`;
|
|
439
497
|
}
|
|
440
498
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
499
|
+
/**
|
|
500
|
+
* Resolve an authenticated email to the PERSON: where on the tree they have access, and their two
|
|
501
|
+
* switches. The ONE resolver both doors read — the portal's `makePrincipal` and the connector's
|
|
502
|
+
* `resolveScope` — so the two cannot disagree about who someone is.
|
|
503
|
+
*
|
|
504
|
+
* null — no access point anywhere: no portal, and no connector reach
|
|
505
|
+
* { email, everything, permissions: { run, manage }, access, accounts, organisations, genericOrgs, accountOrgs }
|
|
506
|
+
*
|
|
507
|
+
* `access` is the points the person was given, collapsed: everything subsumes the rest, and an
|
|
508
|
+
* organisation subsumes the companies under it. `accounts` is every company they see, "*" for
|
|
509
|
+
* everything, and never `generic`, which is not a company. `organisations` is every organisation they see
|
|
510
|
+
* anything in; `genericOrgs` is the ones whose Generic they see — an organisation-level point or
|
|
511
|
+
* everything, never a company-level point. `accountOrgs` maps each visible company to its organisation.
|
|
512
|
+
*
|
|
513
|
+
* A user entry of "*" is the whole organisation; a `*@domain` key matches every email on that domain; an
|
|
514
|
+
* email in several tenants gets the union. A company point counts only under the tenant that HOLDS the
|
|
515
|
+
* company: a row naming a key its own tenant does not hold has no place in the tree and grants nothing,
|
|
516
|
+
* which is what the People page's "dangling" has always said it does.
|
|
517
|
+
*/
|
|
518
|
+
export function resolvePerson(email, grants) {
|
|
519
|
+
if (!grants) return null;
|
|
520
|
+
const e = String(email ?? "").trim().toLowerCase();
|
|
521
|
+
const at = e.lastIndexOf("@");
|
|
522
|
+
if (at <= 0 || e.indexOf("@") !== at) return null; // multi-@ is refused outright — no parse to disagree about
|
|
523
|
+
const domain = e.slice(at + 1);
|
|
524
|
+
const matches = (pat) => { const p = String(pat).toLowerCase(); return p === e || (p.startsWith("*@") && p.slice(2) === domain); };
|
|
525
|
+
const tenants = grants.tenants ?? {};
|
|
526
|
+
const holder = new Map();
|
|
527
|
+
for (const [t, v] of Object.entries(tenants)) {
|
|
528
|
+
// Stated here as well as at the read: grants also arrive from callers that never went through
|
|
529
|
+
// `loadGrants` (an injected fixture, a store read elsewhere), and a wildcard tenant would otherwise
|
|
530
|
+
// be read as "holds nothing" — quietly narrower, and silent about why.
|
|
531
|
+
if (v?.accounts === "*") throw new Error(`grants are malformed: ${wildcardTenant(`tenants.${t}.accounts`)}`);
|
|
532
|
+
for (const a of Array.isArray(v?.accounts) ? v.accounts : []) if (a !== "generic" && !holder.has(a)) holder.set(a, t);
|
|
533
|
+
}
|
|
534
|
+
// The switches. An exact address wins over a `*@domain` entry, and no entry at all is both off. A domain
|
|
535
|
+
// never holds Manage or everything, re-stated here for grants that never passed `assertGrantsShape`.
|
|
536
|
+
const people = grants.people && typeof grants.people === "object" ? grants.people : {};
|
|
537
|
+
const exact = Object.keys(people).find((k) => k.toLowerCase() === e);
|
|
538
|
+
const pattern = exact === undefined ? Object.keys(people).find((k) => k.toLowerCase() === `*@${domain}`) : undefined;
|
|
539
|
+
const key = exact ?? pattern;
|
|
540
|
+
const entry = key === undefined ? {} : (people[key] ?? {});
|
|
541
|
+
const everything = entry.everything === true && exact !== undefined;
|
|
542
|
+
const permissions = { run: entry.run === true, manage: entry.manage === true && exact !== undefined };
|
|
543
|
+
|
|
544
|
+
const orgs = [];
|
|
545
|
+
const companies = [];
|
|
546
|
+
for (const [t, v] of Object.entries(tenants)) {
|
|
547
|
+
for (const [pat, acc] of Object.entries(v?.users ?? {})) {
|
|
548
|
+
if (!matches(pat)) continue;
|
|
549
|
+
if (acc === "*") { if (!orgs.includes(t)) orgs.push(t); continue; }
|
|
550
|
+
// — refuse by name, never by TypeError, for the same reason as the wildcard above.
|
|
551
|
+
if (acc !== undefined && acc !== null && !Array.isArray(acc)) {
|
|
552
|
+
throw new Error(`grants are malformed: the entry for "${pat}" resolves to ${describe(acc)} — it must be "*" `
|
|
462
553
|
+ `or an array of account keys. Fix the grants file (CLEAROTRON_ACCESS_FILE) and try again.`);
|
|
463
554
|
}
|
|
464
|
-
for (const
|
|
555
|
+
for (const k of acc ?? []) if (holder.get(k) === t && !companies.includes(k)) companies.push(k);
|
|
465
556
|
}
|
|
466
557
|
}
|
|
467
|
-
|
|
558
|
+
if (!everything && !orgs.length && !companies.length) return null;
|
|
559
|
+
|
|
560
|
+
const all = Object.keys(tenants);
|
|
561
|
+
if (everything) {
|
|
562
|
+
return { email: e, everything, permissions, access: [{ kind: "everything" }], accounts: "*",
|
|
563
|
+
organisations: all, genericOrgs: all, accountOrgs: Object.fromEntries(holder) };
|
|
564
|
+
}
|
|
565
|
+
const loose = companies.filter((k) => !orgs.includes(holder.get(k)));
|
|
566
|
+
const accounts = [];
|
|
567
|
+
for (const t of orgs) for (const a of tenants[t].accounts ?? []) if (holder.get(a) === t && !accounts.includes(a)) accounts.push(a);
|
|
568
|
+
for (const k of loose) if (!accounts.includes(k)) accounts.push(k);
|
|
569
|
+
const organisations = [...orgs];
|
|
570
|
+
for (const k of loose) if (!organisations.includes(holder.get(k))) organisations.push(holder.get(k));
|
|
571
|
+
return { email: e, everything, permissions,
|
|
572
|
+
access: [...orgs.map((k) => ({ kind: "organisation", key: k })), ...loose.map((k) => ({ kind: "company", key: k, org: holder.get(k) }))],
|
|
573
|
+
accounts, organisations, genericOrgs: [...orgs],
|
|
574
|
+
accountOrgs: Object.fromEntries(accounts.map((a) => [a, holder.get(a)])) };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// The flat view of the same answer, for the callers that only ask which companies: "*" (everything),
|
|
578
|
+
// [keys], or [] (authenticated but granted nothing). No grants file at all still answers "*" — every
|
|
579
|
+
// caller that must not read that as "every customer" refuses it by name (personScope, the boot gates).
|
|
580
|
+
export function accountsForEmail(email, grants) {
|
|
581
|
+
if (!grants) return "*";
|
|
582
|
+
return resolvePerson(email, grants)?.accounts ?? [];
|
|
468
583
|
}
|
|
469
584
|
|
|
470
585
|
// The account gate. scope.accounts: null|"*" ⇒ full visibility (enforcement off / full grant); [keys] ⇒
|
|
471
586
|
// only those accounts. A run with NO account tag (pre-grants history) is visible only to full grants.
|
|
472
|
-
|
|
587
|
+
// A Generic run is its organisation's: visible to a session that sees that organisation's Generic
|
|
588
|
+
// (`genericOrgs`), and one filed under no organisation only to a full-grant session. The portal asks the
|
|
589
|
+
// same question in `mayReadRun` (driver/portal-access.mjs).
|
|
590
|
+
export function assertAccountAccess(scope, accountKey, what = "this run", organisation = null) {
|
|
473
591
|
const acc = scope?.accounts;
|
|
474
592
|
if (acc == null || acc === "*") return;
|
|
475
593
|
if (!Array.isArray(acc)) throw new Error("malformed scope.accounts");
|
|
476
594
|
if (accountKey == null) throw new Error(`${what} carries no account tag — visible only to full-grant sessions`);
|
|
595
|
+
if (accountKey === "generic" && organisation != null && Array.isArray(scope?.genericOrgs)
|
|
596
|
+
&& scope.genericOrgs.includes(organisation)) return;
|
|
477
597
|
if (!acc.includes(accountKey)) throw new Error(`your grant does not include account "${accountKey}"`);
|
|
478
598
|
}
|
|
479
|
-
export function accountVisible(scope, accountKey) {
|
|
480
|
-
try { assertAccountAccess(scope, accountKey); return true; } catch { return false; }
|
|
599
|
+
export function accountVisible(scope, accountKey, organisation = null) {
|
|
600
|
+
try { assertAccountAccess(scope, accountKey, "this run", organisation); return true; } catch { return false; }
|
|
481
601
|
}
|
|
482
602
|
|
|
483
603
|
// Ops-token issuance (INSTALL.md §8): `sub` names the PRINCIPAL the token was minted
|
|
@@ -562,17 +682,41 @@ export function verifyToken(token, { now = Date.now() } = {}) {
|
|
|
562
682
|
// (403, not an empty read-all); and `cap` — an account token's optional accounts[] — can only NARROW the
|
|
563
683
|
// grant, so a key whose cap no longer intersects its identity's grant resolves to nothing and is refused
|
|
564
684
|
// rather than silently widening back to the full grant.
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
685
|
+
//
|
|
686
|
+
// THE KEY PROVES WHO; THE PERSON'S ENTRY DECIDES WHAT, AT THE MOMENT OF THE CALL. The scope carries the
|
|
687
|
+
// person's reach and switches from `resolvePerson` — the resolver the portal reads — so a key held by a
|
|
688
|
+
// person with access to everything reaches everything, a view-only person's key reads and never writes,
|
|
689
|
+
// and the two doors cannot disagree about one address.
|
|
690
|
+
function personScope(identity, cap = null) {
|
|
691
|
+
const grants = loadGrants();
|
|
692
|
+
if (!grants)
|
|
568
693
|
throw new Error("forbidden: client account access requires a configured grants file (refusing an unscoped wildcard)");
|
|
569
|
-
|
|
694
|
+
const person = resolvePerson(identity, grants);
|
|
695
|
+
if (!person)
|
|
570
696
|
throw new Error("forbidden: this identity is not granted any account");
|
|
571
|
-
|
|
572
|
-
|
|
697
|
+
const whole = { accounts: person.accounts, everything: person.everything, permissions: person.permissions,
|
|
698
|
+
genericOrgs: person.genericOrgs };
|
|
699
|
+
if (!Array.isArray(cap) || !cap.length) return whole;
|
|
700
|
+
// A cap names companies, so it narrows to companies: Generic is not one, and a capped key reaches none.
|
|
701
|
+
const narrowed = person.accounts === "*" ? [...cap] : person.accounts.filter((a) => cap.includes(a));
|
|
573
702
|
if (!narrowed.length)
|
|
574
703
|
throw new Error("forbidden: this key is capped to accounts its identity is no longer granted");
|
|
575
|
-
return narrowed;
|
|
704
|
+
return { ...whole, accounts: narrowed, everything: false, genericOrgs: [] };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Which organisation's Generic a connector job means, on a Generic run only: the one named, which the
|
|
708
|
+
// person must see, or the one organisation they see. Neither is a job filed under none, which only a
|
|
709
|
+
// person who sees everything can order.
|
|
710
|
+
function tenantStamp(scope, asked) {
|
|
711
|
+
const orgs = Array.isArray(scope?.genericOrgs) ? scope.genericOrgs : [];
|
|
712
|
+
const t = typeof asked === "string" && asked.trim() ? asked.trim() : null;
|
|
713
|
+
if (t != null) {
|
|
714
|
+
if (!orgs.includes(t)) throw new Error(`your access does not include organisation "${t}"`);
|
|
715
|
+
return { tenant: t };
|
|
716
|
+
}
|
|
717
|
+
if (orgs.length === 1) return { tenant: orgs[0] };
|
|
718
|
+
if (scope?.everything === true) return {};
|
|
719
|
+
throw new Error(`name the organisation whose Generic this is (tenant) — your access covers ${orgs.length}`);
|
|
576
720
|
}
|
|
577
721
|
|
|
578
722
|
// True iff `email`'s domain (the part after the final '@') is one of firmDomains. PURE (no jose), so the HTTP
|
|
@@ -619,7 +763,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
|
|
|
619
763
|
if (!innerToken) {
|
|
620
764
|
if (!accountAccess)
|
|
621
765
|
throw new Error("forbidden: the client surface requires a run-scoped token (no read-all/internal access)");
|
|
622
|
-
return { kind: "account", runId: null, sub: email ?? null, verbs: null,
|
|
766
|
+
return { kind: "account", runId: null, sub: email ?? null, verbs: null, ...personScope(email) };
|
|
623
767
|
}
|
|
624
768
|
const t = verifyToken(innerToken, { now });
|
|
625
769
|
// An ACCOUNT token — the API key. Same principal as the CF-signed-in client above, reached with a
|
|
@@ -629,7 +773,7 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
|
|
|
629
773
|
if (t.scope === "account") {
|
|
630
774
|
if (!accountAccess)
|
|
631
775
|
throw new Error("forbidden: client account access is not enabled on this door");
|
|
632
|
-
return { kind: "account", runId: null, sub: t.sub, verbs: null,
|
|
776
|
+
return { kind: "account", runId: null, sub: t.sub, verbs: null, ...personScope(t.sub, t.accounts) };
|
|
633
777
|
}
|
|
634
778
|
if (t.scope !== "user") throw new Error("forbidden: the client surface accepts only a run-scoped user token or an account key");
|
|
635
779
|
return { kind: "user", runId: t.runId, sub: t.sub, verbs: null, accounts: null }; // run-bound — accounts moot
|
|
@@ -648,7 +792,19 @@ export function resolveScope({ local = false, innerToken = null, email = null, f
|
|
|
648
792
|
// `sub` carries the VERIFIED identity for every other principal (attribution, the audit log, the
|
|
649
793
|
// forwarder stamp) and was the one arm that dropped it — a CF-authed staff member's email was known
|
|
650
794
|
// here and thrown away, which is why a staff plan_run had no identity to stamp a forwarder from.
|
|
651
|
-
|
|
795
|
+
//
|
|
796
|
+
// THE STAFF FACE READS THE SAME PERSON THE PORTAL DOES. `firmStaff` is the sign-in allowlist in front
|
|
797
|
+
// of this face; the reach behind it is the person's entry in the grants file, resolved as the portal
|
|
798
|
+
// resolves it. An allowed address with no entry reaches nothing, and no grants file at all is the
|
|
799
|
+
// enforcement-off posture it always was.
|
|
800
|
+
if (firmStaff) {
|
|
801
|
+
const grants = loadGrants();
|
|
802
|
+
const person = grants ? resolvePerson(email, grants) : null;
|
|
803
|
+
return { kind: "internal", runId: null, sub: email ?? null, verbs: null,
|
|
804
|
+
accounts: !grants ? "*" : person ? person.accounts : [],
|
|
805
|
+
everything: !grants || person?.everything === true, genericOrgs: person?.genericOrgs ?? [],
|
|
806
|
+
permissions: person?.permissions ?? { run: false, manage: false } };
|
|
807
|
+
}
|
|
652
808
|
throw new Error("forbidden: no run-scoped token and not a firm-staff identity — refusing (internal read-all requires proven firm staff)");
|
|
653
809
|
}
|
|
654
810
|
|
|
@@ -776,7 +932,7 @@ export function authorize(scope, toolName, args = {}) {
|
|
|
776
932
|
if (kind === "account") {
|
|
777
933
|
if (!rule.accountSafe)
|
|
778
934
|
throw new Error(`tool "${toolName}" is not available to a client account session`);
|
|
779
|
-
// THE AUDIT CHAIN (
|
|
935
|
+
// THE AUDIT CHAIN (ruling 2026-08-27) — the account layer reads it, the report-link token below
|
|
780
936
|
// does not. Both gates were one line on USER_ARTIFACTS; they are two sets now, for the reason stated
|
|
781
937
|
// at ACCOUNT_ARTIFACTS. The Resources surface in server.mjs gates on the SAME pair — two surfaces
|
|
782
938
|
// disagreeing about one grant is the defect this file cites twice.
|
|
@@ -791,7 +947,7 @@ export function authorize(scope, toolName, args = {}) {
|
|
|
791
947
|
throw new Error(`a client may only list findings by curated group (on-field | off-field | out-of-scope) — pass \`kind\` for the raw audit trail`);
|
|
792
948
|
return { runId: args.runId, group: args.group }; // cards path: drop the raw-view args
|
|
793
949
|
}
|
|
794
|
-
// WHAT-IF (
|
|
950
|
+
// WHAT-IF (ruling 2026-08-27). Two rules, and each closes something the ruling did not open.
|
|
795
951
|
//
|
|
796
952
|
// A CLIENT DOES NOT PICK THE MODEL. `model` is both cost and method — the tier that runs a stage is
|
|
797
953
|
// the firm's cost structure, sealed with get_telemetry above — and offering it on the one tool that
|
|
@@ -807,13 +963,21 @@ export function authorize(scope, toolName, args = {}) {
|
|
|
807
963
|
// the name, so neither half can be satisfied alone.
|
|
808
964
|
if (toolName === "what_if_run" && !args?.runId)
|
|
809
965
|
throw new Error(`what_if_run: pass the runId of the run you planned against — a client session must name the run it is changing.`);
|
|
966
|
+
// RUN CLEARANCES IS A SWITCH ON THE PERSON. Every write this layer reaches — start, stop, what-if —
|
|
967
|
+
// spends or ends a clearance, and the preview is the first step of one, so a view-only person's key
|
|
968
|
+
// reads and never writes. The portal gates the same routes on the same switch.
|
|
969
|
+
if ((rule.write || toolName === "plan_run") && scope.permissions?.run !== true)
|
|
970
|
+
throw new Error(`tool "${toolName}" needs Run clearances, which this person does not hold`);
|
|
810
971
|
if (toolName === "start_run" || toolName === "plan_run") {
|
|
811
|
-
// The
|
|
812
|
-
// with no profileKey runs under,
|
|
813
|
-
//
|
|
972
|
+
// The access bounds which company a person may spend against. `generic` is the neutral profile a
|
|
973
|
+
// job with no profileKey runs under, and it is not a company: it is an organisation's own lane,
|
|
974
|
+
// ordered by a person who holds that organisation whole (or everything) and capped per organisation
|
|
975
|
+
// like any company (ruling 2026-09-10). So omitting the field is still no way out of the access.
|
|
814
976
|
const key = args?.profileKey ?? "generic";
|
|
815
|
-
|
|
816
|
-
|
|
977
|
+
const reach = scope.accounts === "*" ? "everything" : (Array.isArray(scope.accounts) ? scope.accounts.join(", ") : "");
|
|
978
|
+
if (key === "generic" ? !(scope.everything === true || (Array.isArray(scope.genericOrgs) && scope.genericOrgs.length))
|
|
979
|
+
: !(scope.accounts === "*" || (Array.isArray(scope.accounts) && scope.accounts.includes(key))))
|
|
980
|
+
throw new Error(`your grant [${reach}] does not include account "${key}" — ${toolName} refused`);
|
|
817
981
|
}
|
|
818
982
|
if (toolName === "start_run" || toolName === "plan_run") {
|
|
819
983
|
// WHO IS ASKING is server-stamped from the CF-verified identity, never caller-supplied. Both the
|
|
@@ -822,8 +986,10 @@ export function authorize(scope, toolName, args = {}) {
|
|
|
822
986
|
// rides the delivery packet (docs/DELIVERY.md)". A client's assistant cannot act on that — it names
|
|
823
987
|
// an internal doc and a concept the client has no reason to know — and it broke the FREE preview,
|
|
824
988
|
// the one call a client is most likely to make first.
|
|
825
|
-
const
|
|
826
|
-
|
|
989
|
+
const { tenant: askedTenant, ...rest } = args ?? {};
|
|
990
|
+
const stamped = { ...rest, forwarder: args.forwarder || scope.sub || "client-mcp",
|
|
991
|
+
forwarderEmail: scope.sub ?? args.forwarderEmail,
|
|
992
|
+
...((args?.profileKey ?? "generic") === "generic" ? tenantStamp(scope, askedTenant) : {}) };
|
|
827
993
|
// THE DAILY ALLOWANCE, and the reason this branch exists at all. runCaps.dailyRuns is enforced in
|
|
828
994
|
// the runner ONLY for jobs stamped clientPrincipal:true, and that stamp is deliberately POSITIVE-ONLY
|
|
829
995
|
// — absence means UNCAPPED (see checkRunCaps for why every inferred alternative fails dangerously).
|
|
@@ -833,7 +999,8 @@ export function authorize(scope, toolName, args = {}) {
|
|
|
833
999
|
// plan_run does NOT carry it: it spends nothing, so stamping it would let a free preview burn a
|
|
834
1000
|
// day's allowance (the runner counts ledger rows, and a previewed job that never runs must not sit
|
|
835
1001
|
// in that count).
|
|
836
|
-
|
|
1002
|
+
// Everyone but a person who sees everything is capped — the same line the portal draws.
|
|
1003
|
+
return toolName === "start_run" && scope.everything !== true ? { ...stamped, clientPrincipal: true } : stamped;
|
|
837
1004
|
}
|
|
838
1005
|
return args;
|
|
839
1006
|
}
|
package/shared/secret-file.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// THREE COPIES OF THIS EXISTED AND ONE OF THEM SHIPPED A BLOCKER. The wizard, the launcher and the
|
|
7
7
|
// launcher again each composed the same tmp-write, chmod, rename dance beside their own path. When
|
|
8
|
-
// `.env` moved to `~/.config/clearotron/.env`
|
|
8
|
+
// `.env` moved to `~/.config/clearotron/.env` the directory had to be created before
|
|
9
9
|
// the write, the wizard's copy learned it, and the launcher's did not — so a fresh install could not
|
|
10
10
|
// start at all:
|
|
11
11
|
//
|
package/shared/server-units.mjs
CHANGED
|
@@ -86,7 +86,7 @@ export const SERVER_INSTALL_SET = Object.freeze([
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Installed and enabled ONLY by `clearotron connect`, never by an install path (
|
|
89
|
-
*
|
|
89
|
+
* ruling 2026-08-31). Named here so a reader of this file learns it exists and learns it is
|
|
90
90
|
* excluded on purpose — an absence with no reason beside it is the thing this module is against.
|
|
91
91
|
*/
|
|
92
92
|
// SUPERSEDED 2026-09-03 and deliberately kept EMPTY rather than deleted. The
|