@luckydraw/cumulus 1.0.3 → 1.0.4

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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Tool inventory + allowlist compilation (task 137).
3
+ *
4
+ * WHY THIS EXISTS
5
+ * A thread that should only be able to do a few things (a public web-app visitor
6
+ * panel) previously had to be configured with a DENYLIST — @cdda hand-enumerated 47
7
+ * tool names to get a safe surface. That inverts maintenance onto every app author:
8
+ * any tool cumulus or the Claude CLI adds in a future release is silently granted to
9
+ * every visitor on every deployed app until each author notices and edits their list.
10
+ *
11
+ * The fix is an allowlist. But `--allowedTools` on the Claude CLI is an AUTO-APPROVE
12
+ * list, not a restriction — measured under three permission modes, `--allowedTools Glob`
13
+ * failed to block Bash every time, and cumulus spawns with `--permission-mode
14
+ * bypassPermissions` where an auto-approve list is a guaranteed no-op. `--disallowedTools`
15
+ * DOES restrict there (also measured). So an allowlist has to be COMPILED into a denylist:
16
+ *
17
+ * denylist = (everything that exists) − (what this thread may use)
18
+ *
19
+ * which is why this module has to know the full inventory.
20
+ *
21
+ * WHERE THE INVENTORY COMES FROM (three sources, because no single one is complete)
22
+ * 1. Built-in CLI tools — read from the `system:init` stream event, which carries the
23
+ * CLI's own `tools[]`. Probed once per process (see probeBuiltinTools).
24
+ * 2. cumulus's own MCP tools — derived from the definitions cumulus itself ships.
25
+ * 3. Namespace `extraMcpServers` — NOT knowable here, and deliberately not closed over.
26
+ * Those are the app author's own shim tools, granted to that namespace on purpose.
27
+ *
28
+ * The seed list is a FLOOR, not merely a fallback: the inventory is always the union of
29
+ * the probe and the seed, so a probe that returns a short list (different cwd, different
30
+ * plugin set, a future CLI that changes the event shape) can never quietly widen what a
31
+ * constrained thread is allowed to reach.
32
+ */
33
+ import { spawn } from 'child_process';
34
+ import * as os from 'os';
35
+ import { getToolDefinitions } from '../mcp/tool-handler.js';
36
+ /**
37
+ * Built-in Claude CLI tools, as reported by `system:init` on CLI 2.1.217.
38
+ *
39
+ * This is a FLOOR for the inventory, not a fallback — see the module comment. Adding a
40
+ * name here can only ever deny more on an allowlisted thread; it has no effect on a
41
+ * thread without `allowedTools`. Safe to update from a probe of a newer CLI.
42
+ */
43
+ export const SEED_BUILTIN_TOOLS = [
44
+ 'Bash',
45
+ 'CronCreate',
46
+ 'CronDelete',
47
+ 'CronList',
48
+ 'DesignSync',
49
+ 'Edit',
50
+ 'EnterWorktree',
51
+ 'ExitWorktree',
52
+ 'Monitor',
53
+ 'NotebookEdit',
54
+ 'PushNotification',
55
+ 'Read',
56
+ 'RemoteTrigger',
57
+ 'ReportFindings',
58
+ 'ScheduleWakeup',
59
+ 'SendMessage',
60
+ 'Skill',
61
+ 'Task',
62
+ 'TaskCreate',
63
+ 'TaskGet',
64
+ 'TaskList',
65
+ 'TaskOutput',
66
+ 'TaskStop',
67
+ 'TaskUpdate',
68
+ 'ToolSearch',
69
+ 'WebFetch',
70
+ 'WebSearch',
71
+ 'Workflow',
72
+ 'Write',
73
+ ];
74
+ /**
75
+ * Tools served by the `gateway-agents` MCP server.
76
+ *
77
+ * Duplicated here rather than imported because `gateway-agents-mcp.ts` calls `main()`
78
+ * at top level — it is a stdio binary, and importing it would start a server. A source
79
+ * tripwire test asserts this list matches the tool definitions in that file, so the two
80
+ * cannot drift (same shape as task 132's artifact tripwire).
81
+ *
82
+ * `broadcast` is absent on purpose: it is deliberately not served (task 135).
83
+ */
84
+ export const GATEWAY_AGENTS_TOOL_NAMES = [
85
+ 'cancel_schedule',
86
+ 'create_plastic_app',
87
+ 'list_agents',
88
+ 'list_emails',
89
+ 'list_schedules',
90
+ 'notify_user',
91
+ 'schedule_trigger',
92
+ 'send_email',
93
+ 'send_to_agent',
94
+ 'update_pipeline',
95
+ 'upload_media',
96
+ ];
97
+ /** MCP server keys cumulus attaches itself (see generateMcpConfig). */
98
+ const CUMULUS_HISTORY_SERVER = 'cumulus-history';
99
+ const GATEWAY_AGENTS_SERVER = 'gateway-agents';
100
+ /** How long to wait for the CLI to emit its init event before giving up on the probe. */
101
+ const PROBE_TIMEOUT_MS = 15_000;
102
+ /** Fully-qualified names of the MCP tools cumulus ships itself. */
103
+ export function cumulusMcpToolNames() {
104
+ const history = getToolDefinitions().map(t => `mcp__${CUMULUS_HISTORY_SERVER}__${t.name}`);
105
+ const agents = GATEWAY_AGENTS_TOOL_NAMES.map(n => `mcp__${GATEWAY_AGENTS_SERVER}__${n}`);
106
+ return [...history, ...agents];
107
+ }
108
+ /**
109
+ * Ask the Claude CLI what built-in tools it has, by reading the `tools[]` array off its
110
+ * `system:init` stream event and killing the subprocess immediately.
111
+ *
112
+ * Measured cost: init arrives ~871ms in and is the ONLY event before the kill — no
113
+ * assistant output, no result event, so nothing comes back from the model. A prompt is
114
+ * required: with stdin closed the CLI exits before emitting init (0 lines), and merely
115
+ * holding stdin open emits nothing at all (20s). Hence the one-character `x`.
116
+ *
117
+ * Runs in a neutral cwd so a project's own settings cannot shape the answer, and the
118
+ * result is unioned with the seed floor by the caller either way.
119
+ *
120
+ * Rejects on timeout, spawn failure, or a missing/empty `tools[]`. Never throws
121
+ * synchronously; the caller treats any rejection as "use the floor".
122
+ *
123
+ * `claudePath` is supplied by the caller, which already has it resolved. Taking it as a
124
+ * parameter rather than importing `resolveClaudeCli` avoids a module cycle: that function
125
+ * lives in gateway.ts, which imports this module.
126
+ */
127
+ export function probeBuiltinTools(claudePath) {
128
+ return new Promise((resolve, reject) => {
129
+ const bin = claudePath || 'claude';
130
+ // Same env hygiene as the real spawn: CLAUDE* vars would leak the parent session.
131
+ const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('CLAUDE') && key !== 'CLAUDECODE'));
132
+ let child;
133
+ try {
134
+ child = spawn(bin, [
135
+ '--print',
136
+ '--output-format',
137
+ 'stream-json',
138
+ '--verbose',
139
+ '--permission-mode',
140
+ 'bypassPermissions',
141
+ 'x',
142
+ ], { stdio: ['ignore', 'pipe', 'ignore'], cwd: os.tmpdir(), env: cleanEnv });
143
+ }
144
+ catch (err) {
145
+ reject(err instanceof Error ? err : new Error(String(err)));
146
+ return;
147
+ }
148
+ let settled = false;
149
+ let buf = '';
150
+ const finish = (fn) => {
151
+ if (settled)
152
+ return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ try {
156
+ child.kill('SIGKILL');
157
+ }
158
+ catch {
159
+ // already gone
160
+ }
161
+ fn();
162
+ };
163
+ const timer = setTimeout(() => finish(() => reject(new Error('tool inventory probe timed out'))), PROBE_TIMEOUT_MS);
164
+ // Don't hold the event loop open on a probe nobody is waiting for.
165
+ timer.unref?.();
166
+ child.stdout?.on('data', (chunk) => {
167
+ buf += chunk.toString();
168
+ let nl;
169
+ while ((nl = buf.indexOf('\n')) >= 0) {
170
+ const line = buf.slice(0, nl);
171
+ buf = buf.slice(nl + 1);
172
+ if (!line.trim())
173
+ continue;
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(line);
177
+ }
178
+ catch {
179
+ continue;
180
+ }
181
+ if (parsed.type !== 'system' || parsed.subtype !== 'init')
182
+ continue;
183
+ const tools = Array.isArray(parsed.tools)
184
+ ? parsed.tools.filter((t) => typeof t === 'string')
185
+ : [];
186
+ if (tools.length === 0) {
187
+ finish(() => reject(new Error('init event carried no tools')));
188
+ return;
189
+ }
190
+ finish(() => resolve(tools));
191
+ return;
192
+ }
193
+ });
194
+ child.on('error', err => finish(() => reject(err)));
195
+ // 'close', not 'exit': close fires after stdio has drained, so a CLI that prints init
196
+ // and exits immediately still resolves. On 'exit' the reject could win the race.
197
+ child.on('close', () => finish(() => reject(new Error('CLI exited before emitting init'))));
198
+ });
199
+ }
200
+ /** Memoized so the probe runs at most once per gateway process. */
201
+ let inventoryPromise = null;
202
+ /**
203
+ * The full set of tool names a spawned turn could otherwise reach.
204
+ *
205
+ * Deliberately NOT threaded through `MessagePipelineOptions`: task 113 is the recorded
206
+ * case of a field added to one of four spawn paths and silently doing nothing in the
207
+ * other three, and here a miss would fail OPEN. Module-scope memoization means the single
208
+ * args-assembly site is the only consumer and it cannot be half-wired.
209
+ */
210
+ export function getToolInventory(claudePath) {
211
+ if (!inventoryPromise) {
212
+ inventoryPromise = probeBuiltinTools(claudePath)
213
+ .catch(err => {
214
+ console.warn(`[Gateway] Tool inventory probe failed (${err instanceof Error ? err.message : err}); ` +
215
+ 'using the seed floor. Allowlisted threads stay closed.');
216
+ return [];
217
+ })
218
+ .then(probed => {
219
+ const all = new Set([...SEED_BUILTIN_TOOLS, ...probed, ...cumulusMcpToolNames()]);
220
+ return [...all].sort();
221
+ });
222
+ }
223
+ return inventoryPromise;
224
+ }
225
+ /** Test seam: drop the memo so a suite can probe with a stub. */
226
+ export function resetToolInventoryCache() {
227
+ inventoryPromise = null;
228
+ }
229
+ /** The bare tool name of an `mcp__server__tool` entry, or the name itself. */
230
+ export function bareToolName(name) {
231
+ if (!name.startsWith('mcp__'))
232
+ return name;
233
+ const parts = name.split('__');
234
+ return parts.length > 2 ? parts.slice(2).join('__') : name;
235
+ }
236
+ /**
237
+ * Compile an allowlist into the denylist the CLI actually honours.
238
+ *
239
+ * Returns `existingDisallowed` UNCHANGED when no allowlist is configured — additive, so
240
+ * every thread that does not opt in behaves exactly as before.
241
+ *
242
+ * An allowlist entry matches an inventory entry exactly, or as its bare name, so
243
+ * `read_file` allows `mcp__cumulus-history__read_file` (the system prompt calls it
244
+ * `read_file`; a user should not have to know the prefix). An entry that matches nothing
245
+ * is inert — and inertness DENIES, so a typo fails closed.
246
+ *
247
+ * `disallowedTools` still applies on top: it can subtract from an allowlist but never add.
248
+ */
249
+ export function compileDisallowedTools(allowedTools, inventory, existingDisallowed) {
250
+ if (!allowedTools)
251
+ return existingDisallowed;
252
+ const allowed = new Set(allowedTools);
253
+ const denied = new Set(existingDisallowed ?? []);
254
+ for (const name of inventory) {
255
+ if (allowed.has(name) || allowed.has(bareToolName(name)))
256
+ continue;
257
+ denied.add(name);
258
+ }
259
+ return denied.size ? [...denied].sort() : undefined;
260
+ }
261
+ /**
262
+ * Is `name` (a bare tool name) reachable on this thread?
263
+ *
264
+ * Deliberately inventory-INDEPENDENT: it reads the thread's stated intent, so it gives the
265
+ * same answer on the Claude CLI path and the direct-provider path, whose real inventories
266
+ * differ. Available iff the allowlist admits it (or there is no allowlist) and the denylist
267
+ * does not name it.
268
+ */
269
+ export function toolAvailable(name, policy) {
270
+ const denied = (policy.disallowedTools ?? []).some(d => d === name || bareToolName(d) === name);
271
+ if (denied)
272
+ return false;
273
+ if (!policy.allowedTools)
274
+ return true;
275
+ return policy.allowedTools.some(a => a === name || bareToolName(a) === name);
276
+ }
277
+ export function promptCapabilities(policy) {
278
+ return {
279
+ backgroundWork: toolAvailable('Bash', policy),
280
+ scheduling: toolAvailable('schedule_trigger', policy),
281
+ interAgent: toolAvailable('send_to_agent', policy),
282
+ };
283
+ }
284
+ /** Section header prefixes, keyed by the capability that keeps them. */
285
+ const GATED_SECTIONS = [
286
+ ['backgroundWork', 'BACKGROUND WORK:'],
287
+ ['scheduling', 'SCHEDULING'],
288
+ ['interAgent', 'INTER-AGENT MESSAGING:'],
289
+ ];
290
+ /**
291
+ * Remove prompt sections whose tools this thread cannot reach.
292
+ *
293
+ * Sections in SYSTEM_PROMPT_TEMPLATE are blank-line-delimited blocks whose first line is a
294
+ * header, so this is a split/filter/join — lossless when nothing is gated (regression-locked),
295
+ * and a no-op on a custom template that has no such headers.
296
+ *
297
+ * Only ever REMOVES text, so an unconstrained thread's prompt is byte-identical and the task
298
+ * 117 static-prompt budget cannot be breached by this function.
299
+ */
300
+ export function applyCapabilityGates(template, caps) {
301
+ const dropPrefixes = GATED_SECTIONS.filter(([cap]) => !caps[cap]).map(([, prefix]) => prefix);
302
+ if (dropPrefixes.length === 0)
303
+ return template;
304
+ return template
305
+ .split('\n\n')
306
+ .filter(block => !dropPrefixes.some(prefix => block.startsWith(prefix)))
307
+ .join('\n\n');
308
+ }
309
+ //# sourceMappingURL=tool-inventory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-inventory.js","sourceRoot":"","sources":["../../src/lib/tool-inventory.ts"],"names":[],"mappings":"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"}
@@ -152,6 +152,7 @@ myapp.config.json (only if the -v file is absent)
152
152
  "claudeModel": "claude-haiku-4-5",
153
153
  "effort": "medium",
154
154
  "alwaysInclude": ["docs/myapp-system-prompt.md"],
155
+ "allowedTools": ["Read", "read_file", "search_content", "retrieve_content", "search_history"],
155
156
  "disallowedTools": ["AskUserQuestion"]
156
157
  }
157
158
  ```
@@ -159,7 +160,33 @@ myapp.config.json (only if the -v file is absent)
159
160
  - `projectDir` — the working directory for the agent's turns (where `alwaysInclude` paths resolve).
160
161
  - `alwaysInclude` — the app's **system prompt document**: what the app is, how to talk to its users, when to use which commands, tone. This is where the agent's product knowledge and persona live. Usually the same document for both threads.
161
162
  - `model` / `effort` / `claudeModel` — the per-thread quality, latency and cost dial. `claudeModel` pins the specific Claude model; leave it out to follow the gateway default.
162
- - `disallowedTools` — on visitor threads, strip `AskUserQuestion`: there is no operator on the other end, so a turn that asks one hangs.
163
+ - `allowedTools` — **the only tools a visitor turn may use.** See below; this is the most important line in the visitor file.
164
+ - `disallowedTools` — applied on top of the allowlist, and it can only subtract. Keep `AskUserQuestion` here: there is no operator on the other end of a visitor turn, so a question hangs.
165
+
166
+ #### `allowedTools` is deny-by-default, and that is the whole point
167
+
168
+ Omit it and the thread is unrestricted — it gets the same harness as your maintainer
169
+ thread, including shell, file writes, sub-agent spawning, and the ability to message your
170
+ other threads. Set it and **everything not named is refused, including tools a future
171
+ cumulus or Claude CLI release adds.**
172
+
173
+ That last clause is why an allowlist rather than a denylist. With a denylist, every newly
174
+ shipped tool is silently granted to every visitor of every deployed app until each author
175
+ notices and edits their list. One real app on this gateway needed **47 deny entries** to
176
+ reach a safe surface; the allowlist above is five.
177
+
178
+ Your app's **own MCP shim tools are not affected** — cumulus does not know their names, so
179
+ it cannot deny them. They stay available, which is what you want: they are the tools you
180
+ deliberately gave this namespace. The allowlist governs cumulus's and the CLI's tools.
181
+
182
+ Entries may be bare (`read_file`) or fully qualified (`mcp__cumulus-history__read_file`).
183
+ An entry that matches nothing is **inert, and inertness denies** — a typo fails closed, so
184
+ check the journal after a deploy rather than assuming silence means success.
185
+
186
+ Setting `allowedTools` also, deliberately, shrinks the system prompt: sections describing
187
+ tools the thread cannot reach (background work, scheduling, inter-agent messaging) are
188
+ dropped from that thread's prompt. Dead instructions are worse than absent ones — they cost
189
+ tokens every turn and invite the model to improvise a substitute for a tool it cannot call.
163
190
 
164
191
  Both files ship as editable examples in the kit (`thread-config.example.json`, `thread-config.visitor.example.json`), with a one-command applier:
165
192
 
@@ -168,7 +195,7 @@ GATEWAY_ORIGIN=https://gw.example.com GATEWAY_ADMIN_KEY=sk-... \
168
195
  node agent/apply-thread-configs.mjs --namespace myapp
169
196
  ```
170
197
 
171
- Use the **admin** key: a namespace covers `myapp-*`, so the app's scoped key can write `myapp-v` but is refused (403) on the bare `myapp` base thread. Note that the config API applies a whitelist — `projectDir`, `template`, `model`, `effort`, `claudeModel`, `contextLimit` — so `alwaysInclude` and `disallowedTools` must be added to the file on the gateway host. (That is deliberate: `alwaysInclude` plus `projectDir` would let a scoped key read an arbitrary file into its own prompt.) The applier reads each config back and names anything that did not stick, so the gap is visible rather than silent.
198
+ Use the **admin** key: a namespace covers `myapp-*`, so the app's scoped key can write `myapp-v` but is refused (403) on the bare `myapp` base thread. Note that the config API applies a whitelist — `projectDir`, `template`, `model`, `effort`, `claudeModel`, `contextLimit` — so `alwaysInclude`, `allowedTools` and `disallowedTools` must be added to the file on the gateway host. That is deliberate in each case: `alwaysInclude` plus `projectDir` would let a scoped key read an arbitrary file into its own prompt, and `allowedTools` is writable in the _widening_ direction, so a scoped key could relax its own restrictions. The applier reads each config back and names anything that did not stick, so the gap is visible rather than silent.
172
199
 
173
200
  No gateway reload is needed — thread config is read per turn.
174
201
 
@@ -538,7 +565,51 @@ Per the global standards: every interactive element gets a `data-testid` (`agent
538
565
 
539
566
  ### 5.4 What else the agent can do
540
567
 
541
- Every gateway thread also gets cumulus's standard tools: persistent RAG over its own history and stored content (`search_history`, `read_file`, …), inter-agent messaging (`send_to_agent` — e.g. a visitor session escalating to your `myapp` management thread), push notifications (`notify_user`), and email if configured. The system prompt doc decides what the agent should actually use.
568
+ Every gateway thread also gets cumulus's standard tools: persistent RAG over its own history and stored content (`search_history`, `read_file`, …), inter-agent messaging (`send_to_agent` — e.g. a visitor session escalating to your `myapp` management thread), push notifications (`notify_user`), and email if configured. `allowedTools` decides what the agent _can_ use; the system prompt doc decides what it _should_.
569
+
570
+ ### 5.5 How retrieval behaves on a visitor surface (it is not like a dev thread)
571
+
572
+ Worth understanding before you debug a strange answer, because visitor threads sit at the
573
+ opposite end of cumulus's retrieval design from the threads it was tuned on.
574
+
575
+ Cumulus replaces the model's context every turn: recent conversation, plus whatever
576
+ semantic + keyword search pulls out of that thread's own history and content store. On a
577
+ maintainer thread that works well — the store is full of prose about the same subject the
578
+ next question will be about, so the query lands _on_ the corpus.
579
+
580
+ A visitor thread inverts both sides:
581
+
582
+ - **The store is mostly JSON.** Its content is tool results — `records_get` payloads,
583
+ `app_describe` snapshots — not prose. Little of it reads like a sentence.
584
+ - **The queries are English questions** about your product, often about things no prior turn
585
+ ever touched.
586
+
587
+ So an **off-manifold query — one that matches nothing in the store — is routine here, not
588
+ exceptional.** That matters because retrieval has a relevance floor, not a hard cutoff: when
589
+ nothing scores well, whatever survives the floor is what gets packed. On a visitor thread
590
+ that can be a document with no topical relationship to the question at all, simply because
591
+ it is the least-bad match available.
592
+
593
+ This was measured, not theorised: a topic-free operational document seeded into every store
594
+ was retrieved on **2 of 24** visitor turns on a live app — including one turn where it
595
+ outranked 24 genuine tool results — and on one of those turns the model acted on it,
596
+ inventing an HTTP call that did not exist. The document has since been removed from
597
+ visitor threads (it is only seeded into threads that could actually run it), but the
598
+ mechanism is general and will apply to anything else in the store.
599
+
600
+ Practical consequences:
601
+
602
+ - **Put product knowledge in `alwaysInclude`, not in the store.** `alwaysInclude` is
603
+ unconditional — it is in every prompt regardless of what retrieval scores. Anything the
604
+ agent must always know belongs there. Retrieval is a bonus, not a guarantee.
605
+ - **Say so in the system prompt doc.** Instruct the agent to answer from its tools and the
606
+ system prompt, and to call a tool rather than infer from retrieved fragments. A visitor
607
+ thread should reach for `records_get`, not for whatever came back from search.
608
+ - **Expect a cold first turn.** A brand-new visitor thread has an almost empty store, so its
609
+ first turns retrieve very little (or the only thing present). Do not tune retrieval on the
610
+ first exchange of a fresh session.
611
+ - **The store still earns its keep within a session** — it is what makes turn 12 remember
612
+ turn 3 for that visitor. The caution is about cross-topic recall, not about memory.
542
613
 
543
614
  ---
544
615
 
@@ -197,7 +197,7 @@ anything.
197
197
 
198
198
  ---
199
199
 
200
- ## The six things that are easy to get wrong
200
+ ## The seven things that are easy to get wrong
201
201
 
202
202
  ### 1. The thread name is the capability
203
203
 
@@ -294,19 +294,51 @@ GATEWAY_ORIGIN=http://127.0.0.1:8080 GATEWAY_ADMIN_KEY=sk-... \
294
294
  The **admin** key, not the app's: a namespace covers `demoapp-*`, so the scoped
295
295
  key can write `demoapp-v` but is refused on the bare `demoapp`. The config API
296
296
  takes `projectDir`, `template`, `model`, `effort`, `claudeModel` and
297
- `contextLimit`; `alwaysInclude` and `disallowedTools` have to be added to the file
298
- on the gateway host (that is deliberate `alwaysInclude` plus `projectDir` would
299
- let a public scoped key read any file into its own prompt). The applier reads each
300
- config back and tells you exactly what didn't stick. No reload needed.
297
+ `contextLimit`; `alwaysInclude`, `allowedTools` and `disallowedTools` have to be
298
+ added to the file on the gateway host. That is deliberate in both directions
299
+ `alwaysInclude` plus `projectDir` would let a public scoped key read any file into
300
+ its own prompt, and `allowedTools` is writable in the _widening_ direction, so a
301
+ scoped key could relax its own restrictions. The applier reads each config back and
302
+ tells you exactly what didn't stick. No reload needed.
303
+
304
+ ### 4. Visitor threads need an `allowedTools` allowlist
305
+
306
+ The model is the cost dial; `allowedTools` is the **capability** dial, and omitting
307
+ it is the other expensive mistake. A thread with no allowlist gets the full harness:
308
+ shell, file writes, sub-agent spawning, and the ability to message your other
309
+ threads — handed to anonymous traffic.
310
+
311
+ ```json
312
+ "allowedTools": ["Read", "read_file", "search_content", "retrieve_content", "search_history"]
313
+ ```
314
+
315
+ It is **deny-by-default**: anything not named is refused, _including tools a future
316
+ cumulus or Claude CLI release adds_. That is why it is an allowlist and not a
317
+ denylist — with a denylist, every newly shipped tool is silently granted to every
318
+ visitor of every deployed app until you notice. One real app needed **47 deny
319
+ entries** to reach a safe surface; the list above is five.
320
+
321
+ Two things people expect wrongly:
322
+
323
+ - **Your own shim tools are unaffected.** Cumulus doesn't know their names, so it
324
+ can't deny them. They stay available — they're the tools you deliberately gave
325
+ this namespace.
326
+ - **A typo denies.** An entry matching nothing is inert, and inertness is a denial,
327
+ so a misspelled tool silently disappears. Check the journal after a deploy.
328
+
329
+ Setting it also shrinks that thread's system prompt: sections about tools it can't
330
+ reach (background work, scheduling, inter-agent messaging) are dropped, because dead
331
+ instructions cost tokens every turn and invite the model to improvise a substitute
332
+ for a tool it cannot call.
301
333
 
302
- ### 4. Commands act through an adapter, not the DOM
334
+ ### 5. Commands act through an adapter, not the DOM
303
335
 
304
336
  `window.HostApp` is the app's own actions exposed as functions. Commands call
305
337
  those. That's why validation, persistence, and re-render work identically
306
338
  whether a human or the model is driving — and why your commands survive a UI
307
339
  rewrite.
308
340
 
309
- ### 5. Descriptions are the interface
341
+ ### 6. Descriptions are the interface
310
342
 
311
343
  The model decides what to call based entirely on the `description` string.
312
344
  Write for a reader who cannot see your UI, and say what a command is _for_, not
@@ -321,7 +353,7 @@ description: 'Set the on-screen filter so the human sees a subset. ' +
321
353
 
322
354
  The second one prevents a whole class of annoying behaviour.
323
355
 
324
- ### 6. Pick the risk tier honestly
356
+ ### 7. Pick the risk tier honestly
325
357
 
326
358
  | tier | meaning | gated? |
327
359
  | --------- | ----------------------------------------------- | ---------------------- |
@@ -33,9 +33,37 @@
33
33
  "effort": "medium",
34
34
  "alwaysInclude": ["docs/demoapp-system-prompt.md"],
35
35
 
36
+ "_allowedTools": [
37
+ "THE ONLY TOOLS A VISITOR TURN MAY USE. This is an allowlist, so it is",
38
+ "deny-by-default: anything not named here is refused, including tools that a",
39
+ "future cumulus or Claude CLI release adds. That is the point — the alternative",
40
+ "is a denylist, which silently grants every newly-added tool to every visitor of",
41
+ "every deployed app until you notice and edit it. One real app needed 47 deny",
42
+ "entries to get a safe surface; this list is five.",
43
+ "",
44
+ "Your app's own MCP shim tools are NOT affected: cumulus does not know their",
45
+ "names, so it cannot deny them. They stay available, which is what you want —",
46
+ "they are the tools you deliberately gave this namespace.",
47
+ "",
48
+ "Names may be bare ('read_file') or fully qualified",
49
+ "('mcp__cumulus-history__read_file'). An entry that matches nothing is inert,",
50
+ "and inertness DENIES — a typo fails closed, so check the journal after a deploy.",
51
+ "",
52
+ "Start from this list and add only what your app actually needs. Note what is",
53
+ "absent and why: Bash/Write/Edit (a visitor must not run shell or write files),",
54
+ "Task/Workflow (spawning sub-agents on anonymous traffic), send_to_agent (a",
55
+ "visitor could message your maintainer threads), schedule_trigger (a visitor",
56
+ "could arm turns that fire long after they leave), forget_content (destructive).",
57
+ "",
58
+ "Omit this key entirely to leave a thread unrestricted."
59
+ ],
60
+ "allowedTools": ["Read", "read_file", "search_content", "retrieve_content", "search_history"],
61
+
36
62
  "_disallowedTools": [
37
- "Visitor-facing threads should not be able to stop and ask the operator a",
38
- "question there is nobody on the other end, and the turn would hang. Strip it:"
63
+ "Applied ON TOP of the allowlist it can subtract, never add. Redundant while",
64
+ "the allowlist above omits AskUserQuestion, and kept as the explicit statement",
65
+ "of why: there is nobody on the other end of a visitor turn, so a question hangs.",
66
+ "If you widen allowedTools, this line keeps holding."
39
67
  ],
40
68
  "disallowedTools": ["AskUserQuestion"]
41
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckydraw/cumulus",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "RLM-based CLI chat wrapper for Claude with external history context management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",