@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.
- package/.claude/commands/init-maestro.md +16 -9
- package/docs/guides/mac-mini.md +11 -1
- package/docs/runbooks/cohort-cutover.md +16 -0
- package/lib/channels/inbox-item.mjs +12 -0
- package/lib/channels/inbox-item.test.mjs +33 -0
- package/lib/execution/disposition.mjs +13 -2
- package/lib/execution/disposition.test.mjs +19 -2
- package/lib/execution/pipeline.test.mjs +4 -1
- package/lib/mcp/server.test.mjs +16 -4
- package/lib/org/client.mjs +58 -1
- package/lib/org/messaging.mjs +5 -0
- package/lib/org/messaging.test.mjs +7 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +98 -0
- package/lib/org/protocol.test.mjs +19 -2
- package/lib/org/resource-tools.mjs +317 -0
- package/lib/org/resource-tools.test.mjs +361 -0
- package/lib/org/tool-access.mjs +176 -0
- package/lib/org/tool-access.test.mjs +144 -0
- package/lib/org/tool-surface.mjs +431 -5
- package/lib/org/tool-surface.test.mjs +385 -8
- package/lib/org/ui-parity.mjs +196 -3
- package/lib/org/ui-parity.test.mjs +126 -7
- package/lib/tool-definitions.js +23 -2
- package/package.json +2 -2
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
- package/policies/information-barriers.yaml +34 -7
- package/scripts/ci/check-no-residual-identity.mjs +281 -9
- package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
- package/scripts/cloud-relay/voice/server.mjs +42 -2
- package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
- package/scripts/cost/track-claude-usage.mjs +113 -4
- package/scripts/daemon/agent-daemon.mjs +212 -5
- package/scripts/daemon/agent-daemon.test.mjs +307 -0
- package/scripts/daemon/assurance.mjs +38 -15
- package/scripts/daemon/assurance.test.mjs +39 -1
- package/scripts/daemon/cadence-handlers.mjs +48 -5
- package/scripts/daemon/cadence-handlers.test.mjs +57 -2
- package/scripts/daemon/classifier-identity.test.mjs +137 -0
- package/scripts/daemon/classifier.mjs +98 -17
- package/scripts/daemon/inbox-deferral.mjs +49 -24
- package/scripts/daemon/inbox-deferral.test.mjs +39 -1
- package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
- package/scripts/daemon/prompt-builder.mjs +264 -41
- package/scripts/daemon/prompt-builder.test.mjs +5 -5
- package/scripts/daemon/responder.mjs +9 -0
- package/scripts/disclosure_boundaries.py +56 -5
- package/scripts/huddle/huddle-prompt.test.mjs +176 -0
- package/scripts/huddle/huddle-server.mjs +128 -13
- package/scripts/local-triggers/autoupdate.sh +83 -0
- package/scripts/local-triggers/generate-plists.sh +9 -0
- package/scripts/local-triggers/generate-plists.test.mjs +12 -10
- package/scripts/media-generation/brand-clause.test.mjs +135 -0
- package/scripts/media-generation/gemini-image-client.mjs +27 -9
- package/scripts/media-generation/generate-assets.mjs +102 -7
- package/scripts/poller/inbox-scan-poller.mjs +7 -0
- package/scripts/poller/utils.mjs +11 -0
- package/scripts/pre-draft-context.py +91 -15
- package/scripts/spawn-session.sh +36 -6
- package/scripts/test-employer-grounding.py +348 -0
- 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
|
+
}
|