@cohortapp/agent-sdk 2.10.0 → 2.11.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.
Files changed (64) hide show
  1. package/.claude/commands/init-maestro.md +16 -9
  2. package/docs/guides/mac-mini.md +11 -1
  3. package/docs/runbooks/cohort-cutover.md +16 -0
  4. package/lib/channels/inbox-item.mjs +12 -0
  5. package/lib/channels/inbox-item.test.mjs +33 -0
  6. package/lib/execution/disposition.mjs +13 -2
  7. package/lib/execution/disposition.test.mjs +19 -2
  8. package/lib/execution/pipeline.test.mjs +4 -1
  9. package/lib/mcp/server.test.mjs +16 -4
  10. package/lib/org/client.mjs +58 -1
  11. package/lib/org/messaging.mjs +5 -0
  12. package/lib/org/messaging.test.mjs +7 -0
  13. package/lib/org/protocol.checksum +1 -1
  14. package/lib/org/protocol.mjs +98 -0
  15. package/lib/org/protocol.test.mjs +19 -2
  16. package/lib/org/resource-tools.mjs +317 -0
  17. package/lib/org/resource-tools.test.mjs +361 -0
  18. package/lib/org/tool-access.mjs +176 -0
  19. package/lib/org/tool-access.test.mjs +144 -0
  20. package/lib/org/tool-surface.mjs +431 -5
  21. package/lib/org/tool-surface.test.mjs +385 -8
  22. package/lib/org/ui-parity.mjs +196 -3
  23. package/lib/org/ui-parity.test.mjs +126 -7
  24. package/lib/tool-definitions.js +23 -2
  25. package/package.json +2 -2
  26. package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
  27. package/plugins/maestro-skills/plugin.json +4 -0
  28. package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
  29. package/policies/information-barriers.yaml +34 -7
  30. package/scripts/ci/check-no-residual-identity.mjs +281 -9
  31. package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
  32. package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
  33. package/scripts/cloud-relay/voice/server.mjs +42 -2
  34. package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
  35. package/scripts/cost/track-claude-usage.mjs +113 -4
  36. package/scripts/daemon/agent-daemon.mjs +212 -5
  37. package/scripts/daemon/agent-daemon.test.mjs +307 -0
  38. package/scripts/daemon/assurance.mjs +38 -15
  39. package/scripts/daemon/assurance.test.mjs +39 -1
  40. package/scripts/daemon/cadence-handlers.mjs +48 -5
  41. package/scripts/daemon/cadence-handlers.test.mjs +57 -2
  42. package/scripts/daemon/classifier-identity.test.mjs +137 -0
  43. package/scripts/daemon/classifier.mjs +98 -17
  44. package/scripts/daemon/inbox-deferral.mjs +49 -24
  45. package/scripts/daemon/inbox-deferral.test.mjs +39 -1
  46. package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
  47. package/scripts/daemon/prompt-builder.mjs +264 -41
  48. package/scripts/daemon/prompt-builder.test.mjs +5 -5
  49. package/scripts/daemon/responder.mjs +9 -0
  50. package/scripts/disclosure_boundaries.py +56 -5
  51. package/scripts/huddle/huddle-prompt.test.mjs +176 -0
  52. package/scripts/huddle/huddle-server.mjs +128 -13
  53. package/scripts/local-triggers/autoupdate.sh +83 -0
  54. package/scripts/local-triggers/generate-plists.sh +9 -0
  55. package/scripts/local-triggers/generate-plists.test.mjs +12 -10
  56. package/scripts/media-generation/brand-clause.test.mjs +135 -0
  57. package/scripts/media-generation/gemini-image-client.mjs +27 -9
  58. package/scripts/media-generation/generate-assets.mjs +102 -7
  59. package/scripts/poller/inbox-scan-poller.mjs +7 -0
  60. package/scripts/poller/utils.mjs +11 -0
  61. package/scripts/pre-draft-context.py +91 -15
  62. package/scripts/spawn-session.sh +36 -6
  63. package/scripts/test-employer-grounding.py +348 -0
  64. package/scripts/validate_outbound.py +190 -26
@@ -0,0 +1,361 @@
1
+ /**
2
+ * resource-tools.test.mjs — the venture-deliverable desk, and the parity that
3
+ * keeps it from drifting the way every other desk already has.
4
+ *
5
+ * WHAT THIS FILE IS FOR. hq exposes 239 responder ops across 11 families;
6
+ * maestro's curated table exposed 69 desk tools across 9 — files 29 vs 7, crm
7
+ * 44 vs 8. One cause: both tool tables are hand-written, so the moment one side
8
+ * moves, the other is stale and NOTHING is red. The `resource_*` block is
9
+ * generated from the vendored protocol declaration instead, and the assertions
10
+ * below are what make "generated" a guarantee rather than a claim: they read
11
+ * the protocol table at run time and demand the tool block match it exactly, in
12
+ * BOTH directions.
13
+ *
14
+ * It also pins the access-level rule the wave was called on: reads at "read"
15
+ * (org.read → every tier including least-privilege "default"), writes at
16
+ * "write" (org.write → ceo + leadership), and NOTHING at "admin", which is the
17
+ * CEO-only escape-hatch tier where a capability is exactly as unreachable as
18
+ * the org_rpc route a curated tool exists to replace.
19
+ *
20
+ * Hermetic: no network, no env dependence beyond the snapshot below.
21
+ * Run: node --test lib/org/resource-tools.test.mjs
22
+ */
23
+
24
+ "use strict";
25
+
26
+ import { test, before, after } from "node:test";
27
+ import assert from "node:assert/strict";
28
+
29
+ import { METHODS, methodDef } from "./protocol.mjs";
30
+ import { ADMIN_TOOLS, expectedAccessFor } from "./tool-access.mjs";
31
+ import {
32
+ RESOURCE_FAMILY,
33
+ RESOURCE_DESK,
34
+ RESOURCE_PRESENTATION,
35
+ RESOURCE_TOOLS,
36
+ buildResourceTools,
37
+ toolNameForMethod,
38
+ } from "./resource-tools.mjs";
39
+ import { ORG_TOOLS, getOrgTools, orgToolDef, executeOrgTool, deskFamilyAvailable } from "./tool-surface.mjs";
40
+ import { getToolNamesForAccessLevel } from "../tool-definitions.js";
41
+
42
+ // ── env hygiene ────────────────────────────────────────────────────────────
43
+ const ENV_KEYS = [
44
+ "COHORT_API_TOKEN", "COHORT_TOKEN", "COHORT_API_KEY", "COHORT_ORG_ID",
45
+ "COHORT_BASE", "COHORT_API_URL", "COHORT_AGENT_ROOT", "AGENT_ROOT",
46
+ ];
47
+ const saved = {};
48
+ before(() => {
49
+ for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
50
+ });
51
+ after(() => {
52
+ for (const k of ENV_KEYS) {
53
+ if (saved[k] === undefined) delete process.env[k];
54
+ else process.env[k] = saved[k];
55
+ }
56
+ });
57
+
58
+ const CFG = { org: { cohort: { enabled: true, base: "https://org.example", orgId: "acme", token: "nlk_secret" } } };
59
+
60
+ /** fetch stub: records calls, returns a res-frame body. */
61
+ function fakeFetch(body = { ok: true, result: { fine: true } }, { status = 200, ok = true } = {}) {
62
+ const calls = [];
63
+ const fn = async (url, init) => {
64
+ calls.push({ url: String(url), init: init || {} });
65
+ return { ok, status, json: async () => body, headers: { get: () => undefined } };
66
+ };
67
+ fn.calls = calls;
68
+ return fn;
69
+ }
70
+
71
+ /** The family, straight from the vendored protocol — the ONLY authority here. */
72
+ const PROTOCOL_OPS = Object.entries(METHODS)
73
+ .filter(([, d]) => d.family === RESOURCE_FAMILY)
74
+ .map(([name]) => name);
75
+
76
+ // ── parity: the block IS the protocol declaration ──────────────────────────
77
+
78
+ test("parity: one curated tool per registered resource op, and NO extras", () => {
79
+ // Eight is not a magic number typed here — it is whatever the vendored
80
+ // protocol declares, which is what makes this an anti-drift test rather than
81
+ // a second hand-list. It is asserted non-empty so a protocol that lost the
82
+ // family cannot make this file pass vacuously.
83
+ assert.ok(PROTOCOL_OPS.length > 0, "the vendored protocol carries a resource family");
84
+ assert.equal(PROTOCOL_OPS.length, 8, "eight resource methods (list/get + 6 writes)");
85
+
86
+ const toolMethods = RESOURCE_TOOLS.map((t) => t.binding.method).sort();
87
+ assert.deepEqual(
88
+ toolMethods,
89
+ [...PROTOCOL_OPS].sort(),
90
+ "every resource method has exactly one curated tool, and no tool names a method that is not one",
91
+ );
92
+ assert.equal(
93
+ new Set(RESOURCE_TOOLS.map((t) => t.name)).size,
94
+ RESOURCE_TOOLS.length,
95
+ "tool names are unique",
96
+ );
97
+ });
98
+
99
+ test("parity: every binding names a REAL protocol method, resolved through methodDef", () => {
100
+ for (const t of RESOURCE_TOOLS) {
101
+ assert.equal(t.binding.kind, "rpc", `${t.name} is rpc-bound (no local shim to drift)`);
102
+ const def = methodDef(t.binding.method);
103
+ assert.ok(def, `${t.name} binds a method the frozen table carries (${t.binding.method})`);
104
+ assert.equal(def.family, RESOURCE_FAMILY, `${t.name} stays inside its own family`);
105
+ }
106
+ });
107
+
108
+ test("parity: access MIRRORS the declaration — sideEffecting false→read, true→write", () => {
109
+ for (const t of RESOURCE_TOOLS) {
110
+ const def = methodDef(t.binding.method);
111
+ assert.equal(
112
+ t.access,
113
+ def.sideEffecting ? "write" : "read",
114
+ `${t.name} access must mirror sideEffecting (${t.binding.method})`,
115
+ );
116
+ // Same answer from the scope lane, which is what hq actually gates on. For
117
+ // this family the two rules coincide exactly (org.read ↔ not side-effecting,
118
+ // org.write ↔ side-effecting), so asserting both pins the coincidence: a
119
+ // future resource method that broke it would fail here rather than ship a
120
+ // tool at a tier hq will refuse.
121
+ assert.equal(t.access, expectedAccessFor(t.binding.method), `${t.name} access mirrors its scope lane`);
122
+ assert.match(def.scope, /^org\.(read|write)$/, `${t.binding.method} rides org.read/org.write — no new scope minted`);
123
+ }
124
+ assert.deepEqual(
125
+ RESOURCE_TOOLS.filter((t) => t.access === "read").map((t) => t.name),
126
+ ["resource_list", "resource_get"],
127
+ "exactly the two reads",
128
+ );
129
+ assert.equal(RESOURCE_TOOLS.filter((t) => t.access === "write").length, 6, "exactly six writes");
130
+ });
131
+
132
+ test("parity: NOTHING at access:\"admin\", and nothing carries `outbound`", () => {
133
+ for (const t of RESOURCE_TOOLS) {
134
+ // access:"admin" is the CEO-only escape-hatch tier. A venture's own
135
+ // colleagues are default- and leadership-tier agents, so a deliverable verb
136
+ // parked there would be exactly as dark as the org_rpc route it replaces —
137
+ // i.e. this whole wave would ship as a no-op for the seats it is for.
138
+ assert.notEqual(t.access, "admin", `${t.name} must not be admin-tier`);
139
+ assert.ok(!ADMIN_TOOLS.has(t.name), `${t.name} is not an escape hatch`);
140
+ // No free text a human reads leaves the org through this desk: a card is
141
+ // org-internal metadata. The send-gate lane stays messaging_send /
142
+ // email_send / email_draft_send / org_call_share_step.
143
+ assert.equal(t.outbound, undefined, `${t.name} is not an outbound-content verb`);
144
+ assert.equal(t.email, undefined, `${t.name} is not an email tool`);
145
+ assert.equal(t.integration, undefined, `${t.name} is not an integration proxy`);
146
+ }
147
+ });
148
+
149
+ // ── generation, not transcription ──────────────────────────────────────────
150
+
151
+ test("the block is GENERATED: a protocol method with no presentation still gets a tool", () => {
152
+ // The point of the generator, proved against a synthetic table. A ninth
153
+ // resource method must not be able to arrive and be silently absent from the
154
+ // tool surface — that silence is the 239-vs-69 mechanism. It appears with a
155
+ // derived description (an under-described capability is a documentation bug;
156
+ // a MISSING one is the failure this wave exists to fix), and the coverage
157
+ // test below is what goes red.
158
+ const synthetic = {
159
+ ...METHODS,
160
+ "resource.archive": { family: "resource", scope: "org.write", sideEffecting: true, idempotent: true },
161
+ };
162
+ const built = buildResourceTools(synthetic);
163
+ const archive = built.find((t) => t.binding.method === "resource.archive");
164
+ assert.ok(archive, "a brand-new family method produces a tool with no edit to the tool file");
165
+ assert.equal(archive.name, "resource_archive");
166
+ assert.equal(archive.access, "write", "its tier is derived from its scope, not typed by hand");
167
+ assert.equal(archive.desk, RESOURCE_DESK);
168
+ assert.match(archive.description, /add a presentation entry/i, "and it says out loud that it is undescribed");
169
+ });
170
+
171
+ test("the block is GENERATED: a presentation entry for a NON-family method is never emitted", () => {
172
+ // The reverse drift: a description left behind after a method was removed
173
+ // from the protocol must not resurrect a tool that binds a method the frozen
174
+ // table no longer carries.
175
+ const withoutDetach = Object.fromEntries(
176
+ Object.entries(METHODS).filter(([m]) => m !== "resource.detachFile"),
177
+ );
178
+ const built = buildResourceTools(withoutDetach);
179
+ assert.equal(
180
+ built.find((t) => t.binding.method === "resource.detachFile"),
181
+ undefined,
182
+ "a stale presentation entry cannot emit a tool for a method the protocol dropped",
183
+ );
184
+ assert.equal(built.length, PROTOCOL_OPS.length - 1);
185
+ });
186
+
187
+ test("the block is GENERATED: an older vendored protocol degrades to an empty block", () => {
188
+ const noFamily = Object.fromEntries(
189
+ Object.entries(METHODS).filter(([, d]) => d.family !== RESOURCE_FAMILY),
190
+ );
191
+ assert.deepEqual(buildResourceTools(noFamily), [], "no family → no tools, never a broken binding");
192
+ });
193
+
194
+ test("presentation covers the family EXACTLY — this is the assertion that goes red on drift", () => {
195
+ assert.deepEqual(
196
+ Object.keys(RESOURCE_PRESENTATION).sort(),
197
+ [...PROTOCOL_OPS].sort(),
198
+ "every resource method is described, and no description outlives its method",
199
+ );
200
+ for (const [method, p] of Object.entries(RESOURCE_PRESENTATION)) {
201
+ assert.ok(p.title && p.title.length > 3, `${method} titled`);
202
+ assert.ok(p.description && p.description.length > 80, `${method} described in earnest`);
203
+ assert.equal(p.input_schema.type, "object", `${method} schema is an object`);
204
+ assert.ok(p.input_schema.properties, `${method} schema has properties`);
205
+ assert.ok(Array.isArray(p.input_schema.required), `${method} schema declares required[]`);
206
+ // Presentation is presentation. A `name`/`access`/`binding` literal here
207
+ // would be the hand-copy the generator exists to prevent.
208
+ for (const forbidden of ["name", "access", "binding", "desk"]) {
209
+ assert.equal(p[forbidden], undefined, `${method} presentation must not hand-write ${forbidden}`);
210
+ }
211
+ }
212
+ });
213
+
214
+ test("tool names are a pure function of the method name", () => {
215
+ assert.equal(toolNameForMethod("resource.list"), "resource_list");
216
+ assert.equal(toolNameForMethod("resource.attachFile"), "resource_attach_file");
217
+ assert.equal(toolNameForMethod("resource.detachFile"), "resource_detach_file");
218
+ for (const t of RESOURCE_TOOLS) {
219
+ assert.equal(t.name, toolNameForMethod(t.binding.method), `${t.name} derives from its method`);
220
+ assert.match(t.name, /^[a-z0-9_]+$/, `${t.name} snake_case`);
221
+ }
222
+ });
223
+
224
+ // ── the schemas the model actually sees ────────────────────────────────────
225
+
226
+ test("the schemas require what hq requires, and nothing hq refuses", () => {
227
+ // .strict() on the hq side means an unknown key is a BAD_REQUEST, so every
228
+ // property advertised here has to be one the handler accepts.
229
+ const req = (n) => orgToolDef(n).input_schema.required;
230
+ const props = (n) => Object.keys(orgToolDef(n).input_schema.properties);
231
+
232
+ // Ref-addressed verbs take EITHER slug OR resourceId, so neither can be
233
+ // schema-`required` — the "exactly one" rule is a refine, and the
234
+ // description carries it.
235
+ for (const n of ["resource_get", "resource_update", "resource_delete", "resource_detach_file"]) {
236
+ assert.deepEqual(req(n), [], `${n} cannot mark either ref required`);
237
+ assert.ok(props(n).includes("slug") && props(n).includes("resourceId"), `${n} offers both refs`);
238
+ }
239
+ assert.deepEqual(req("resource_create"), ["kind", "title"]);
240
+ assert.deepEqual(req("resource_reorder"), ["slugs"]);
241
+ assert.deepEqual(req("resource_attach_file"), ["fileId"]);
242
+
243
+ // `source` is server-owned: an agent must never be able to relabel its own
244
+ // card as "ftlab-portal" and inherit the portal's provenance.
245
+ assert.ok(!props("resource_create").includes("source"), "create cannot set source");
246
+ assert.ok(!props("resource_update").includes("source"), "update cannot set source");
247
+ // slug is the ref AND the portal's re-sync key — renaming it would orphan the
248
+ // upsert and duplicate the card on the next push.
249
+ assert.ok(!props("resource_update").includes("slug_new"), "update has no slug rename");
250
+ // Re-pointing a card at a different drive row is a separately-audited act.
251
+ assert.ok(!props("resource_update").includes("fileId"), "update cannot re-point the file");
252
+ assert.equal(orgToolDef("resource_reorder").input_schema.properties.slugs.type, "array");
253
+ });
254
+
255
+ test("the descriptions carry the semantics the SCHEMA cannot", () => {
256
+ // A tool schema can say `metadata: object`. It cannot say that a null value
257
+ // deletes a key, that reorder is total, or that the file survives a delete —
258
+ // and an agent that does not know those three will destroy delivery
259
+ // provenance, scramble a catalogue, or tell a human their deck is gone.
260
+ assert.match(orgToolDef("resource_update").description, /shallow merge/i);
261
+ assert.match(orgToolDef("resource_update").description, /null is DELETED|null is deleted/i);
262
+ assert.match(orgToolDef("resource_reorder").description, /TOTAL, not partial/i);
263
+ assert.match(orgToolDef("resource_reorder").description, /CONFLICT/);
264
+ assert.match(orgToolDef("resource_delete").description, /fileRetained/);
265
+ assert.match(orgToolDef("resource_delete").description, /ftlab-portal/);
266
+ assert.match(orgToolDef("resource_create").description, /CONFLICT/);
267
+ assert.match(orgToolDef("resource_attach_file").description, /setUrl` defaults to FALSE|defaults to FALSE/i);
268
+ assert.match(orgToolDef("resource_detach_file").description, /CONFLICT/);
269
+ // The files bridge is a pointer: say where bytes actually come from.
270
+ assert.match(orgToolDef("resource_list").description, /files_get|files_doc_read/);
271
+ });
272
+
273
+ // ── the desk gate + the surrounding table ──────────────────────────────────
274
+
275
+ test("the desk registers only when the vendored protocol carries the family", () => {
276
+ assert.equal(deskFamilyAvailable(RESOURCE_DESK), true, "the vendored protocol carries resource.list");
277
+ for (const t of RESOURCE_TOOLS) assert.equal(t.desk, RESOURCE_DESK, `${t.name} is desk-gated`);
278
+ // desksAvailable:false is the older-protocol shape — the block disappears
279
+ // cleanly rather than binding methods the server does not implement.
280
+ const off = getOrgTools({ desksAvailable: false }).filter((t) => t.desk === RESOURCE_DESK);
281
+ assert.equal(off.length, 0, "an older protocol drops the block whole");
282
+ const on = getOrgTools({}).filter((t) => t.desk === RESOURCE_DESK);
283
+ assert.equal(on.length, PROTOCOL_OPS.length, "and carries all of it when present");
284
+ });
285
+
286
+ test("the generated block is spliced into ORG_TOOLS itself (both planes see it)", () => {
287
+ for (const t of RESOURCE_TOOLS) {
288
+ assert.ok(orgToolDef(t.name), `${t.name} is resolvable by name`);
289
+ assert.equal(orgToolDef(t.name), ORG_TOOLS.find((x) => x.name === t.name));
290
+ }
291
+ });
292
+
293
+ // ── the access-level bug, at its consumer ──────────────────────────────────
294
+
295
+ test("ACCESS LEVELS: the reads reach EVERY tier; the writes reach ceo + leadership; none is CEO-only", () => {
296
+ // This is the assertion the wave turns on. lib/tool-definitions.js routes
297
+ // access:"read" into lookupTools (every tier, including least-privilege
298
+ // "default"), access:"write" into writeToolsCeo + writeToolsLeadership, and
299
+ // access:"admin" into writeToolsCeo ALONE. A venture's own colleagues are
300
+ // default- and leadership-tier agents, so the family is only actually shipped
301
+ // if it lands in those two surfaces.
302
+ const ceo = new Set(getToolNamesForAccessLevel("ceo"));
303
+ const leadership = new Set(getToolNamesForAccessLevel("leadership"));
304
+ const dflt = new Set(getToolNamesForAccessLevel("default"));
305
+
306
+ for (const t of RESOURCE_TOOLS) {
307
+ assert.ok(ceo.has(t.name), `${t.name} reaches ceo`);
308
+ assert.ok(leadership.has(t.name), `${t.name} reaches leadership — the tier a venture's colleagues run at`);
309
+ }
310
+ assert.ok(dflt.has("resource_list"), "a least-privilege seat can SEE its venture's deliverables");
311
+ assert.ok(dflt.has("resource_get"), "and open one");
312
+ for (const t of RESOURCE_TOOLS.filter((x) => x.access === "write")) {
313
+ assert.ok(!dflt.has(t.name), `${t.name} does not leak into the least-privilege tier`);
314
+ }
315
+ });
316
+
317
+ // ── dispatch ───────────────────────────────────────────────────────────────
318
+
319
+ test("every tool POSTS its protocol method with the params verbatim", async () => {
320
+ for (const t of RESOURCE_TOOLS) {
321
+ const fetchImpl = fakeFetch({ ok: true, result: { resources: [] } });
322
+ const frame = await executeOrgTool(t.name, { probe: 1 }, { orgConfig: CFG, agentRoot: "/tmp/none", fetchImpl });
323
+ assert.equal(frame.ok, true, `${t.name} dispatches`);
324
+ assert.equal(
325
+ fetchImpl.calls[0].url,
326
+ `https://org.example/api/v1/${t.binding.method}`,
327
+ `${t.name} hits its own method`,
328
+ );
329
+ const body = JSON.parse(fetchImpl.calls[0].init.body);
330
+ assert.equal(body.probe, 1, `${t.name} passes params through untouched`);
331
+ // sideEffecting:true at the dispatcher → a fresh idempotency header per
332
+ // invocation; the reads must not carry one.
333
+ const hasKey = Boolean(fetchImpl.calls[0].init.headers["x-idempotency-key"]);
334
+ assert.equal(hasKey, methodDef(t.binding.method).sideEffecting, `${t.name} idempotency header matches the declaration`);
335
+ }
336
+ });
337
+
338
+ test("the desk FAILS CLOSED with no credential — a refusal, never a silent success", async () => {
339
+ const fetchImpl = fakeFetch();
340
+ for (const t of RESOURCE_TOOLS) {
341
+ const frame = await executeOrgTool(t.name, {}, { orgConfig: {}, agentRoot: "/tmp/none", fetchImpl });
342
+ assert.equal(frame.ok, false, `${t.name} refuses`);
343
+ assert.equal(frame.error.code, "UNAUTHORIZED");
344
+ }
345
+ assert.equal(fetchImpl.calls.length, 0, "nothing dispatched without a credential");
346
+ });
347
+
348
+ test("a FORBIDDEN from hq surfaces verbatim — the scope gate is the server's answer, not ours", async () => {
349
+ const fetchImpl = fakeFetch(
350
+ { ok: false, error: { code: "FORBIDDEN", message: "scope org.write required" } },
351
+ { ok: false, status: 403 },
352
+ );
353
+ const frame = await executeOrgTool(
354
+ "resource_create",
355
+ { kind: "deck", title: "Seed deck", url: "https://example.com/deck" },
356
+ { orgConfig: CFG, agentRoot: "/tmp/none", fetchImpl },
357
+ );
358
+ assert.equal(frame.ok, false);
359
+ assert.equal(frame.error.code, "FORBIDDEN");
360
+ assert.match(frame.error.message, /org\.write/);
361
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * lib/org/tool-access.mjs — the ONE place a curated tool's access tier is decided.
3
+ *
4
+ * THE BUG THIS MODULE EXISTS TO CLOSE. `access` used to be a hand-written
5
+ * literal on all 133 `ORG_TOOLS` entries with nothing deriving it and nothing
6
+ * checking it. Two failures follow from that, and both are silent:
7
+ *
8
+ * (1) THE ADMIN TRAPDOOR. `lib/tool-definitions.js` routes by tier:
9
+ * `access:"read"` lands in `lookupTools` and therefore in EVERY tier
10
+ * including least-privilege "default"; `"write"` lands in `writeToolsCeo`
11
+ * + `writeToolsLeadership`; `"admin"` is withheld from
12
+ * `writeToolsLeadership` AND from the default lookup set — i.e. CEO ONLY.
13
+ * tool-surface.mjs's own header says what that costs: "for a leadership
14
+ * or default agent, 'the long tail is reachable via org_rpc' is false — a
15
+ * capability with no curated tool simply does not exist for it." A tool
16
+ * parked at "admin" is in exactly that state: shipped, tested, and dark
17
+ * for every seat below the operator's console. The rule against it was
18
+ * enforced only by per-delta, HAND-LISTED name arrays in
19
+ * tool-surface.test.mjs (ten names for the mandate/subagent/preference
20
+ * delta), so it policed the tools whose author remembered to list them
21
+ * and nothing else. Every future family was unpoliced by default — the
22
+ * same "only guards what someone already thought to claim" hole hq
23
+ * plugged with its closed-set family assertion. `ADMIN_TOOLS` below
24
+ * inverts it: the admin tier is a CLOSED, NAMED set of two escape
25
+ * hatches, asserted table-wide, so parking a ninth capability there now
26
+ * takes a deliberate edit to this file.
27
+ *
28
+ * (2) THE TYPO TRAPDOOR. The tier buckets are built with exact string
29
+ * equality (`t.access === access`), so an entry whose `access` is absent
30
+ * or mis-cased ("Read", " write ") matches NO bucket and reaches NO
31
+ * native tier — not ceo, not leadership, not voice, not default — while
32
+ * the MCP plane keeps publishing it (lib/mcp/server.mjs reads `access`
33
+ * only to set `readOnlyHint`). That is the tool-side twin of the
34
+ * caller-side footgun `normalizeAccessLevel` was written to kill —
35
+ * "'CEO' (instead of 'ceo') yielded zero tools, a silent UNDER-privilege
36
+ * footgun" — fixed on one side of the same comparison and never on the
37
+ * other. `normalizeToolAccess` closes it: normalise the casing, and send
38
+ * a genuinely unrecognised value to the MOST-restricted tier with a loud
39
+ * warning, so a table typo fails CLOSED and NOISY instead of silently
40
+ * vanishing from every surface.
41
+ *
42
+ * THE DERIVATION (`expectedAccessFor`). For an rpc-bound tool the tier is not
43
+ * a matter of taste — hq gates on the method's SCOPE, so the native clamp must
44
+ * mirror the scope lane or it either withholds a tool hq would have allowed or
45
+ * offers one hq will refuse. `*.read` → "read" (a VIEWER-tier seat can call it,
46
+ * so it belongs in the lookup set every tier gets); everything else → "write".
47
+ *
48
+ * NOTE THE RULE IT IS *NOT*: `sideEffecting ? "write" : "read"`. That reading
49
+ * is wrong for seven tools already in the table and would demote every one of
50
+ * them. `knowledge.search`, `directory.ask`, `branding.renderTemplate` /
51
+ * `rewriteInVoice` / `exportKit` are side-effecting (audited reads: they land
52
+ * on the chain or the AI meter) yet ride a `.read` scope on purpose, because a
53
+ * VIEWER seat may run them — exactly the ability its human seat has; hq keeps
54
+ * the same list under `AUDITED_READ_SCOPE_WRITES`. Conversely `artifact.create`
55
+ * / `artifact.act` are not side-effecting yet ride `messaging.write`. Scope is
56
+ * the authority both planes actually resolve against; `sideEffecting` governs
57
+ * idempotency keys and chain appends, not privilege. Verified: the scope rule
58
+ * holds for all 120 rpc-bound curated tools with ZERO exceptions, which is why
59
+ * this module ships no allowlist.
60
+ *
61
+ * Node builtins only. ESM. Imported by lib/org/tool-surface.mjs,
62
+ * lib/org/resource-tools.mjs and lib/tool-definitions.js — deliberately a leaf
63
+ * module (it imports only the vendored protocol) so none of those three can
64
+ * form an import cycle through it.
65
+ *
66
+ * @module lib/org/tool-access
67
+ */
68
+
69
+ "use strict";
70
+
71
+ import { methodDef } from "./protocol.mjs";
72
+
73
+ /**
74
+ * The tiers `lib/tool-definitions.js` buckets by. Ordered LEAST → MOST
75
+ * restricted, which is what makes `MOST_RESTRICTED_ACCESS` a definition rather
76
+ * than a magic string.
77
+ * @type {readonly string[]}
78
+ */
79
+ export const TOOL_ACCESS_LEVELS = Object.freeze(["read", "write", "admin"]);
80
+
81
+ /** The tier an unrecognised `access` falls back to: CEO-only. Fail closed. */
82
+ export const MOST_RESTRICTED_ACCESS = "admin";
83
+
84
+ /**
85
+ * A Set whose mutators throw. `Object.freeze(new Set(...))` does NOT do this —
86
+ * a Set's contents are internal slots, not own properties, so a frozen Set is
87
+ * still `.add()`-able. For a table that decides who reaches which tier, "the
88
+ * allowlist can be widened at runtime with no diff" is not an acceptable
89
+ * property, so the mutators are replaced rather than merely frozen.
90
+ * @param {Iterable<string>} values
91
+ * @returns {ReadonlySet<string>}
92
+ */
93
+ function sealedSet(values) {
94
+ const s = new Set(values);
95
+ const refuse = (op) => () => {
96
+ throw new TypeError(`this set is sealed — ${op}() would change an access decision with no diff`);
97
+ };
98
+ for (const op of ["add", "delete", "clear"]) {
99
+ Object.defineProperty(s, op, { value: refuse(op), writable: false, configurable: false });
100
+ }
101
+ return Object.freeze(s);
102
+ }
103
+
104
+ /**
105
+ * The ONLY curated tools allowed at `access:"admin"` — the two operator escape
106
+ * hatches. Both are `binding.kind:"local"`: they take a method/path name and
107
+ * validate it against the frozen protocol table before any network I/O, so
108
+ * they are a console over the whole surface rather than one capability.
109
+ *
110
+ * Nothing that represents a CAPABILITY belongs here. A capability at "admin" is
111
+ * reachable by the CEO tier alone, which is indistinguishable — for a
112
+ * leadership or default agent — from never having shipped it.
113
+ * @type {ReadonlySet<string>}
114
+ */
115
+ export const ADMIN_TOOLS = sealedSet(["org_rpc", "org_read"]);
116
+
117
+ /**
118
+ * The access tier an rpc-bound curated tool MUST carry, derived from the
119
+ * vendored protocol declaration rather than chosen by the table author.
120
+ *
121
+ * @param {string} method - Full dotted protocol method, e.g. "resource.list".
122
+ * @returns {"read"|"write"|null} null when the method is not in the vendored
123
+ * table (an older protocol, or a typo — the caller decides how loud to be).
124
+ */
125
+ export function expectedAccessFor(method) {
126
+ const def = methodDef(method);
127
+ if (!def) return null;
128
+ return String(def.scope || "").endsWith(".read") ? "read" : "write";
129
+ }
130
+
131
+ /**
132
+ * Normalise a TOOL's declared access tier (the mirror of
133
+ * `normalizeAccessLevel`, which normalises the CALLER's level).
134
+ *
135
+ * Known tiers are returned unchanged. Case/whitespace variants normalise.
136
+ * Anything else — missing, misspelled, non-string — coerces to the
137
+ * most-restricted tier and warns, so the entry stays reachable by the operator
138
+ * and visibly wrong, instead of falling out of every bucket unannounced.
139
+ *
140
+ * @param {unknown} access - The raw `access` field from a tool descriptor.
141
+ * @param {string} [toolName] - Named in the warning so the bad row is findable.
142
+ * @param {(msg: string) => void} [warn] - Injectable sink (tests assert on it).
143
+ * @returns {"read"|"write"|"admin"}
144
+ */
145
+ export function normalizeToolAccess(access, toolName = "<unnamed>", warn = console.warn) {
146
+ const key = typeof access === "string" ? access.trim().toLowerCase() : "";
147
+ if (TOOL_ACCESS_LEVELS.includes(key)) return /** @type {"read"|"write"|"admin"} */ (key);
148
+ try {
149
+ warn(
150
+ `[tool-access] Tool ${JSON.stringify(String(toolName))} declares unknown access ` +
151
+ `${JSON.stringify(access)}; coercing to most-restricted ` +
152
+ `"${MOST_RESTRICTED_ACCESS}". Fix the table entry — an unrecognised tier used to ` +
153
+ `drop the tool from EVERY native access level silently.`,
154
+ );
155
+ } catch {
156
+ /* a throwing sink must not take the tool table down */
157
+ }
158
+ return MOST_RESTRICTED_ACCESS;
159
+ }
160
+
161
+ /**
162
+ * Partition a tool table into the three native tiers, EXHAUSTIVELY — every
163
+ * entry lands in exactly one bucket, which is the property the old
164
+ * `filter(t => t.access === x)` triple did not have.
165
+ *
166
+ * @param {Array<{name?:string, access?:unknown}>} tools
167
+ * @param {(msg: string) => void} [warn]
168
+ * @returns {{read: object[], write: object[], admin: object[]}}
169
+ */
170
+ export function partitionByAccess(tools, warn = console.warn) {
171
+ const out = { read: [], write: [], admin: [] };
172
+ for (const t of tools || []) {
173
+ out[normalizeToolAccess(t && t.access, t && t.name, warn)].push(t);
174
+ }
175
+ return out;
176
+ }