ghostbus 0.5.3 → 0.5.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.5 — 2026-10-11
4
+
5
+ Security hardening, round 4 — the client side of the wire. Rounds 2–3 hardened what the server stores and how it authenticates; this round fuzzed what a client can *send*: MCP tool arguments over stdio JSON-RPC, board payloads end-to-end, and raw HTTP parameters. One real crash, a class of silent state corruption, and a set of numeric edge cases were found and fixed. Several surfaces audited clean and are now pinned by tests instead — the entry says which, as usual.
6
+
7
+ - **A single `null` line crashed the MCP server (fixed)** — `src/mcp-server.mjs` destructured every parsed line; a client (or a prompt-injected agent driving one) that sent a bare `null`, a string, a number, or an array killed the process with an unhandled TypeError, taking the bus down for every connected agent. Non-object frames are now ignored, and message handling is wrapped so one bad message can never be fatal. Verified: the pre-fix server exits with code 1 on the first hostile line; the fixed server drops all of them and keeps answering.
8
+ - **Tool arguments are validated against their schemas (`src/tools.mjs`)** — the tools layer passed argument values straight into the core, which coerced: a message body sent as an object was *stored* as the literal string `"[object Object]"`, a body sent as an array as its first character, a numeric file path became a state key of the same number, a `threadId` sent as an object landed in memory as `NaN` (invisible until reload, when JSON rewrote it to `null`), and a null capsule slug created a capsule named `"null"`. One shared validator now runs before dispatch: required arguments must be present, declared types are enforced (strings stay strings, numbers must be finite, arrays and plain objects must be arrays and plain objects), and violations return the suite's existing clean error results. Arguments the schema doesn't declare (the optional per-agent `token`, for one) pass through untouched.
9
+ - **Numeric parameters are clamped in the core** — the list/sync reads trusted their numbers: `limit=0` returned *every* message (slice(-0)), `limit=-1` returned all-but-one, events the same, and a garbage `sinceSeq` cursor made `bus_sync` report an agent as caught up (`truncated: false`) while replaying nothing. Limits now clamp (≤0 → none, absurd → the documented 500 cap, non-finite → the default page size) and a garbage cursor replays honestly from the start. Message thread ids are sanitized the same way — a non-numeric `threadId` is stored as `null`, never `NaN`.
10
+ - **`/api/wait` never waits on a garbage timeout (fixed)** — `timeout=abc` made the long-poll reply instantly with an empty inbox and `waited: true`. A non-numeric or negative timeout now means the 25 s default; real values still clamp to [0, 55] s.
11
+ - **Board task ids escape too** — the one board interpolation not routed through `esc()` was the task id in two templates. Ids are numbers from the core, so it was unreachable in normal operation, but a hand-crafted imported snapshot could put a string there; it goes through `esc()` now like everything else.
12
+ - **Audited, already safe — now pinned by tests.** Board data path: hostile roles, message bodies, task titles, and file names seeded through the real API come back verbatim as JSON (inert by content type), every interpolated field is escaped, and the workspace key lives in local storage and travels as a header — never in a URL. `/probe` and `/health` return exactly their documented counts-only shapes. HTTP edges: method mismatches, trailing slashes, unknown routes, and URL-encoded traversal in workspace segments (`%2e%2e%2f`) all return clean 404s; a traversal-looking file path is an inert key miss (400), never a filesystem read. CLI: missing arguments, unknown commands, and bad values already exit non-zero with one-line errors and no stack dumps — pinned as-is. Prototype pollution through tool arguments: not reachable (JSON clone discipline), canary-checked in the fuzz.
13
+ - **Tests** — five new checks (64 in the main suite, was 59): tools-boundary validation + core clamps (including that rejected calls store nothing), a bounded stdio fuzz (hostile raw frames, one wrong-typed variant for every declared property of all 29 tools, a 100 KB body, a 300-deep nested context, then bus-integrity assertions), the board data path, HTTP parameter edges on relay + hosted, and the CLI pins. Against the 0.5.4 code the new checks fail on the crash, the coercions, and the numeric semantics; with the change the full suite is green, serverless stays 6/6, and both demos pass.
14
+ - **Scope boundary, honestly** — what did NOT change: no tool, route, or CLI behavior changes for well-formed input (valid calls produce identical results; the fuzz asserts registered-agent counts and a normal round-trip after the battery); argument validation rejects only values the schemas never allowed; the deferred networked multi-writer store is still future work.
15
+
16
+ ## 0.5.4 — 2026-10-11
17
+
18
+ Security hardening, round 3 — hostile input: the values a malicious (or merely buggy) client controls, followed to the filesystem, the object graph, and state validation. Audit-first as usual: two of the five surfaces turned out to be already safe, and the entry says which and why rather than dressing them up as fixes.
19
+
20
+ - **Shared-file name `__proto__` corrupted the file store (fixed)** — file names are keys in one plain object, and `_putFile` blocked `..` but nothing else. Writing a file named `__proto__` invoked the prototype setter on the files map: the record became the map's *prototype*, the "file" enumerated nowhere, and the store was silently corrupt (verified against 0.5.3). `constructor` had the mirror-image problem on reads (an inherited member instead of "not found"). Now: `__proto__` / `constructor` / `prototype` are refused at write ("path is a reserved name"), treated as absent at read/delete (own-property checks), and refused inside snapshots. Names that merely look like paths (`/abs/looking.txt`, backslashes, a NUL byte) remain what they always were — inert state keys, never filesystem paths; the suite proves nothing but the one store file appears on disk.
21
+ - **Snapshot validation deepened from shape to usability** (`validateSnapshot` in `src/core.mjs`) — a snapshot is untrusted input (hosted import, CLI import, restore), and the old check waved through states that then crashed the bus on first use: `agents: [null]` passed and bricked every subsequent operation with a TypeError, a file with non-string `text` passed and broke search/sizing, negative seq counters passed. Now entries must be objects, agents need string names, files need string text, counters must be finite and non-negative, context must be an object or null, and counts have sanity caps far above anything the bus itself can produce (stored messages trim at 10k, events at 5k; caps are 10k agents/files, 100k messages/tasks/events). Compatibility is a hard constraint and is tested: real exports produced by the current code import cleanly (hosted round trip + core restore), as they did from v0.5.0–v0.5.3 shapes. Unknown *extra* top-level keys stay allowed — rejecting them would break forward compatibility for no safety gain; every known key is typed.
22
+ - **Workspace ids validated at every boundary (defense in depth)** — create and import already enforced `^[a-z0-9][a-z0-9-]{1,62}$` and the routes are regex-bound, so remote traversal was already refused; the thin spot was the registry *file*: `getWorkspace` trusted its keys blindly, and a planted or hand-edited entry like `../escape` made the admin list load (and the bus write) a state file **outside** the data dir. The registry is now filtered through the id rule at load (invalid entries are ignored, with a one-time stderr warning per id), and workspace resolution re-checks the id before it can become a path.
23
+ - **Channel named `__proto__` was dropped from channel counts (fixed)** — `_listChannels` accumulated into a plain `{}`; reads of the `__proto__` key hit the prototype and the count never landed. The accumulator is a null-prototype object now; the channel counts like any other.
24
+ - **Prototype pollution: audited, not reachable — now pinned by canaries.** State enters only through `JSON.parse`, the core's JSON round-trip clone, and object spread — all own-property semantics — and a full-codebase hunt found no recursive merge and no `Object.assign` of caller data (the remaining `Object.assign` uses build Error objects from literals). Canary checks now attempt pollution through the context writer and through restore payloads (nested `__proto__` in workspace and context) and assert `Object.prototype` stays clean. The board page was audited in the same pass: every interpolated value already goes through its `esc()` helper, and the workspace name renders via `textContent`.
25
+ - **Audit-log injection: audited, safe.** The v0.5.3 admin audit line is a single `JSON.stringify` of a fixed-shape record (timestamp, action literal, validated workspace id, socket IP); JSON encoding escapes any control characters, and no other user-controlled string (workspace names included) reaches the line.
26
+ - **Tests** — five new checks (59 in the main suite, was 54): reserved file names + inert path-like names (with an on-disk assertion), deep snapshot validation + pollution canaries, `__proto__` channel counting, hosted traversal refusal at create and at import (explicit id, envelope id, and state id) with a hostile-envelope refusal and a clean legit import, and the planted-registry escape. All five fail against the 0.5.3 code (54 passed, 5 failed — each for its intended reason; in the hosted check, the id-traversal asserts already passed on 0.5.3 and the red assertion is the hostile envelope importing with 201) and pass with the change; serverless stays 6/6 and both demos green.
27
+ - **Scope boundary, honestly** — what did NOT change: no API changes; no behavior change for legitimate names, ids, or exports; the single relay's store path is still operator-supplied (argv/env) and out of the remote-input surface; the serverless pack's registry lives in a KV store where ids are opaque keys, not paths — its ids still enter only through the same validated creation, and the core fixes (reserved file names, deep snapshot validation where it applies, channel counts) flow to it through the shared core. The networked multi-writer store is still future work.
28
+
3
29
  ## 0.5.3 — 2026-10-11
4
30
 
5
31
  Security hardening, round 2 — the layer under the v0.5.2 defaults. v0.5.2 made enforcement the default; this round removes the sharp edges an attacker could still lean on: non-timing-safe secret compares, unthrottled key guessing at the hosted layer, unbounded request bodies, framable HTML pages, world-readable data files, and internal error text leaking to clients.
package/README.md CHANGED
@@ -23,7 +23,7 @@ Built and in use: 29 MCP tools across presence, messages, tasks, a shared worksp
23
23
  ```bash
24
24
  git clone https://github.com/littlestjames82-sys/ghostbus.git ghostbus && cd ghostbus
25
25
  node examples/two-agent-demo.mjs # watch a planner and a builder cooperate
26
- node test.mjs # 54 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security
26
+ node test.mjs # 64 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security, hostile input, input fuzz
27
27
  node examples/three-agent-team.mjs # planner + builder + reviewer, no human relaying
28
28
  ```
29
29
 
package/docs/hosted.md CHANGED
@@ -138,6 +138,57 @@ Round 2 (v0.5.3) closed the next layer:
138
138
  - **Reporting a vulnerability:** see `SECURITY.md` at the repo root
139
139
  (private email reporting, one-business-day acknowledgement).
140
140
 
141
+ Round 3 (v0.5.4) hardened the input surface — values a hostile or buggy
142
+ client controls, followed to the filesystem, the object graph, and state
143
+ validation:
144
+
145
+ - **Workspace ids are validated at every boundary.** Create and import
146
+ require `^[a-z0-9][a-z0-9-]{1,62}$`, the workspace routes only match that
147
+ shape, workspace resolution re-checks it, and the registry file itself is
148
+ filtered through the same rule when loaded — a planted or hand-edited
149
+ registry entry can never turn an id into a path outside the data dir.
150
+ - **Shared-file names are state keys, never paths.** A name that merely
151
+ looks like a path is stored and returned as an inert key. The three names
152
+ that are special to a JavaScript object — `__proto__`, `constructor`,
153
+ `prototype` — are refused at write, treated as absent at read, and refused
154
+ inside snapshots (before v0.5.4, writing a file named `__proto__`
155
+ silently corrupted the workspace's file map).
156
+ - **Snapshots and imports are deeply validated.** Collection entries must
157
+ be usable objects (agents need string names, files need string text),
158
+ counters must be finite and non-negative, and counts carry sanity caps
159
+ far above anything the bus itself produces. Every export made by
160
+ v0.5.0–v0.5.3 still imports — compatibility is covered by tests, not
161
+ assumed.
162
+ - **Prototype pollution is not reachable.** State enters only through
163
+ `JSON.parse`, JSON round-trip cloning, and spread (all own-property
164
+ semantics); there is no recursive merge of caller data anywhere in the
165
+ codebase, and the suite carries canary checks that try anyway through
166
+ the context writer and restore payloads.
167
+
168
+ Round 4 (v0.5.5) treated the clients themselves as untrusted input —
169
+ including MCP clients and any agent driving one:
170
+
171
+ - **MCP clients are untrusted input — say it plainly.** A client (or a
172
+ prompt-injected agent behind one) can send any argument shapes it wants.
173
+ Tool arguments are validated against each tool's declared schema at the
174
+ tools boundary: wrong-typed or non-finite values are rejected with a
175
+ clean error result, never coerced into shared state, and a malformed
176
+ JSON-RPC frame can no longer kill the stdio server process — bad frames
177
+ are dropped, the bus keeps serving every other agent.
178
+ - **Numeric parameters are clamped everywhere.** Query and argument
179
+ numbers (`limit`, `sinceSeq`, timeouts) that arrive as garbage,
180
+ negatives, or absurd magnitudes fall back to defaults and documented
181
+ caps instead of slice-arithmetic surprises (a `limit` of 0 or -1 used
182
+ to return *more* data, and a garbage sync cursor falsely reported an
183
+ agent as caught up).
184
+ - **The board's data path is pinned end-to-end.** Hostile roles, message
185
+ bodies, task titles, and file names are served verbatim as JSON data
186
+ (inert by content type), the page routes every interpolated field
187
+ through its HTML escaper, and the workspace key lives in local storage
188
+ and travels as a header — never in a URL, where it would land in logs
189
+ and referers. `/health` and `/probe` return exactly their documented
190
+ counts-only shapes.
191
+
141
192
  ## Option B — serverless on Netlify (Blobs)
142
193
 
143
194
  `deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
package/package.json CHANGED
@@ -44,5 +44,5 @@
44
44
  "hosted": "node src/hosted-server.mjs"
45
45
  },
46
46
  "type": "module",
47
- "version": "0.5.3"
47
+ "version": "0.5.5"
48
48
  }
package/src/board.html CHANGED
@@ -64,7 +64,7 @@ async function refresh(){
64
64
  `<div class="agent"><strong>${esc(a.name)}</strong><span class="pill ${a.online?'green':''}">${a.online?'online':'away'}</span><br><span class="muted">${esc(a.role||'agent')}${a.capabilities?.length ? ' · ' + esc(a.capabilities.join(', ')) : ''} · last seen ${esc(a.lastSeenAt)}</span></div>`).join('') : '<span class="muted">none registered</span>';
65
65
  const open = tasks.tasks.filter(t => !['done','cancelled'].includes(t.status));
66
66
  $('tasks').innerHTML = open.length ? open.map(t =>
67
- `<div class="task">#${t.id} <strong>${esc(t.title)}</strong><span class="pill ${t.status==='needs-approval'?'amber':''}">${esc(t.status)}</span><br><span class="muted">from ${esc(t.from)}${t.claimedBy ? ' · claimed by ' + esc(t.claimedBy) : ''}${t.assignee ? ' · for ' + esc(t.assignee) : ''}</span></div>`).join('') : '<span class="muted">queue is empty</span>';
67
+ `<div class="task">#${esc(t.id)} <strong>${esc(t.title)}</strong><span class="pill ${t.status==='needs-approval'?'amber':''}">${esc(t.status)}</span><br><span class="muted">from ${esc(t.from)}${t.claimedBy ? ' · claimed by ' + esc(t.claimedBy) : ''}${t.assignee ? ' · for ' + esc(t.assignee) : ''}</span></div>`).join('') : '<span class="muted">queue is empty</span>';
68
68
  $('files').innerHTML = files.files.length ? files.files.map(f =>
69
69
  `<div class="file">📄 ${esc(f.path)}<br><span class="muted">${f.bytes} bytes · ${esc(f.updatedBy)} · ${esc(f.updatedAt)}</span></div>`).join('') : '<span class="muted">no shared files</span>';
70
70
  $('events').innerHTML = events.events.slice().reverse().map(e =>
package/src/core.mjs CHANGED
@@ -14,7 +14,7 @@ import path from 'node:path';
14
14
  import crypto from 'node:crypto';
15
15
  import { AsyncLocalStorage } from 'node:async_hooks';
16
16
 
17
- export const GHOSTBUS_VERSION = '0.5.3';
17
+ export const GHOSTBUS_VERSION = '0.5.5';
18
18
  const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
19
19
  // Timing-safe equality for hex digests (v0.5.3): secret comparisons must not
20
20
  // short-circuit on the first differing character the way === does.
@@ -24,9 +24,31 @@ const hashEqHex = (aHex, bHex) => {
24
24
  };
25
25
  export const TASK_STATUSES = ['queued', 'claimed', 'needs-approval', 'done', 'cancelled'];
26
26
  const CLAIM_LEASE_MS = 15 * 60 * 1000;
27
+ // Shared-file names are keys in one plain object, never filesystem paths —
28
+ // but three names are special to the object itself (v0.5.4): assigning
29
+ // files['__proto__'] invokes the prototype setter and silently replaces the
30
+ // map's prototype (the "file" then enumerates nowhere and the store is
31
+ // corrupted), while reading files['constructor'] / files['__proto__'] on a
32
+ // fresh map returns inherited members instead of "not found". Writes refuse
33
+ // the three outright; reads treat them as absent; snapshots may not carry
34
+ // them either (validateSnapshot below).
35
+ const RESERVED_FILE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
27
36
 
28
37
  const now = () => new Date().toISOString();
29
38
  const clone = (v) => JSON.parse(JSON.stringify(v));
39
+ // Untrusted numeric inputs (HTTP query params, MCP arguments, direct API
40
+ // callers) arrive as NaN, negatives, and absurd magnitudes, and raw
41
+ // slice(-n) arithmetic turns them into nonsense: limit=0 or NaN returned
42
+ // EVERYTHING (slice(-0) / slice(NaN) both mean "from the start"), limit=-1
43
+ // returned all-but-one (v0.5.5). Every list/read path clamps through here:
44
+ // non-finite -> the default, fractional -> floored, out-of-range -> the
45
+ // [0, max] bound; a clamped limit of 0 honestly means "return none".
46
+ const clampLimit = (v, def, max) => {
47
+ const n = Number(v);
48
+ if (!Number.isFinite(n)) return def;
49
+ return Math.max(0, Math.min(Math.floor(n), max));
50
+ };
51
+ const tailSlice = (arr, lim) => (lim > 0 ? arr.slice(-lim) : []);
30
52
 
31
53
  function emptyState(workspace = {}) {
32
54
  return {
@@ -236,9 +258,13 @@ export class GhostBus {
236
258
  if (to !== '*') this._existsAgent(to);
237
259
  if (!body || !String(body).trim()) throw new Error('message body required');
238
260
  this.state.msgSeq += 1;
261
+ // threadId names a message id; anything that isn't a positive finite
262
+ // number (an object used to land here as NaN, invisible to thread()
263
+ // until a reload quietly rewrote it to null) is simply "no thread".
264
+ const tid = Number(threadId);
239
265
  const msg = {
240
266
  id: this.state.msgSeq, from, to, channel: String(channel).slice(0, 64),
241
- body: String(body), threadId: threadId ? Number(threadId) : null,
267
+ body: String(body), threadId: Number.isFinite(tid) && tid >= 1 ? Math.floor(tid) : null,
242
268
  createdAt: now(), readBy: [from],
243
269
  };
244
270
  this.state.messages.push(msg);
@@ -256,7 +282,7 @@ export class GhostBus {
256
282
  let msgs = this.state.messages.filter(m =>
257
283
  m.to === agentName || m.to === '*' || m.from === agentName);
258
284
  if (unreadOnly) msgs = msgs.filter(m => !m.readBy.includes(agentName));
259
- msgs = msgs.slice(-Math.min(limit, 500));
285
+ msgs = tailSlice(msgs, clampLimit(limit, 50, 500));
260
286
  if (markRead) {
261
287
  let changed = false;
262
288
  for (const m of msgs) if (!m.readBy.includes(agentName)) { m.readBy.push(agentName); changed = true; }
@@ -390,8 +416,9 @@ export class GhostBus {
390
416
  async _putFile({ by, path: p, text, token = null }) {
391
417
  await this.init();
392
418
  this._requireAgent(by, token);
393
- if (!p || typeof text !== 'string') throw new Error('path and text required');
419
+ if (typeof p !== 'string' || !p || typeof text !== 'string') throw new Error('path and text required');
394
420
  if (String(p).includes('..')) throw new Error('path may not contain ..');
421
+ if (RESERVED_FILE_NAMES.has(p)) throw new Error('path is a reserved name');
395
422
  const prev = this.state.files[p];
396
423
  const history = prev ? [...(prev.history || []), { text: prev.text, updatedBy: prev.updatedBy, updatedAt: prev.updatedAt }].slice(-10) : [];
397
424
  this.state.files[p] = { text, updatedBy: by, updatedAt: now(), history };
@@ -403,7 +430,7 @@ export class GhostBus {
403
430
  async getFile(...args) { return this._serialized(() => this._getFile(...args)); }
404
431
  async _getFile(p, { withHistory = false } = {}) {
405
432
  await this.init();
406
- const f = this.state.files[p];
433
+ const f = Object.prototype.hasOwnProperty.call(this.state.files, p) ? this.state.files[p] : undefined;
407
434
  if (!f) throw new Error(`file not found: ${p}`);
408
435
  const { history, ...rest } = f;
409
436
  return { path: p, ...clone(rest), versions: (history || []).length + 1, ...(withHistory ? { history: clone(history || []) } : {}) };
@@ -412,7 +439,7 @@ export class GhostBus {
412
439
  async fileHistory(...args) { return this._serialized(() => this._fileHistory(...args)); }
413
440
  async _fileHistory(p) {
414
441
  await this.init();
415
- const f = this.state.files[p];
442
+ const f = Object.prototype.hasOwnProperty.call(this.state.files, p) ? this.state.files[p] : undefined;
416
443
  if (!f) throw new Error(`file not found: ${p}`);
417
444
  return { path: p, current: { text: f.text, updatedBy: f.updatedBy, updatedAt: f.updatedAt }, history: clone(f.history || []) };
418
445
  }
@@ -461,7 +488,7 @@ export class GhostBus {
461
488
  async _deleteFile({ by, path: p, token = null }) {
462
489
  await this.init();
463
490
  this._requireAgent(by, token);
464
- if (!this.state.files[p]) throw new Error(`file not found: ${p}`);
491
+ if (!Object.prototype.hasOwnProperty.call(this.state.files, p)) throw new Error(`file not found: ${p}`);
465
492
  delete this.state.files[p];
466
493
  this._event('file.delete', by, `${by} deleted ${p}`);
467
494
  await this._save();
@@ -471,7 +498,9 @@ export class GhostBus {
471
498
  async listChannels(...args) { return this._serialized(() => this._listChannels(...args)); }
472
499
  async _listChannels() {
473
500
  await this.init();
474
- const counts = {};
501
+ // Channel names are attacker-chosen strings; a null-prototype map counts
502
+ // '__proto__' like any other channel instead of dropping it (v0.5.4).
503
+ const counts = Object.create(null);
475
504
  for (const m of this.state.messages) counts[m.channel] = (counts[m.channel] || 0) + 1;
476
505
  return Object.entries(counts).map(([channel, messages]) => ({ channel, messages })).sort((a, b) => b.messages - a.messages);
477
506
  }
@@ -483,11 +512,12 @@ export class GhostBus {
483
512
  const q = String(query || '').toLowerCase().trim();
484
513
  if (q.length < 2) throw new Error('search query needs at least 2 characters');
485
514
  const hit = (text) => String(text || '').toLowerCase().includes(q);
515
+ const lim = clampLimit(limit, 20, 500);
486
516
  return {
487
517
  query,
488
- messages: clone(this.state.messages.filter(m => hit(m.body)).slice(-limit)),
489
- tasks: clone(this.state.tasks.filter(t => hit(t.title) || hit(t.body) || hit(t.result)).slice(-limit)),
490
- files: Object.entries(this.state.files).filter(([p, f]) => hit(p) || hit(f.text)).slice(0, limit)
518
+ messages: clone(tailSlice(this.state.messages.filter(m => hit(m.body)), lim)),
519
+ tasks: clone(tailSlice(this.state.tasks.filter(t => hit(t.title) || hit(t.body) || hit(t.result)), lim)),
520
+ files: Object.entries(this.state.files).filter(([p, f]) => hit(p) || hit(f.text)).slice(0, lim)
491
521
  .map(([p, f]) => ({ path: p, updatedBy: f.updatedBy, updatedAt: f.updatedAt, excerpt: f.text.slice(0, 200) })),
492
522
  };
493
523
  }
@@ -584,14 +614,20 @@ export class GhostBus {
584
614
  async sync(...args) { return this._serialized(() => this._sync(...args)); }
585
615
  async _sync({ sinceSeq = 0, limit = 500 } = {}) {
586
616
  await this.init();
587
- const evs = this.state.events.filter(e => e.seq > Number(sinceSeq));
588
- return { events: clone(evs.slice(0, Math.min(limit, 1000))), headSeq: this.state.seq, truncated: evs.length > Math.min(limit, 1000) };
617
+ // A garbage cursor (NaN from a non-numeric query param) used to match
618
+ // NOTHING and report truncated:false — an offline agent would believe
619
+ // itself caught up while missing everything (v0.5.5). It now means
620
+ // what a missing cursor means: replay from the start, limit-bounded.
621
+ const since = Number.isFinite(Number(sinceSeq)) ? Math.max(0, Math.floor(Number(sinceSeq))) : 0;
622
+ const lim = clampLimit(limit, 500, 1000);
623
+ const evs = this.state.events.filter(e => e.seq > since);
624
+ return { events: clone(evs.slice(0, lim)), headSeq: this.state.seq, truncated: evs.length > lim };
589
625
  }
590
626
 
591
627
  async events(...args) { return this._serialized(() => this._events(...args)); }
592
628
  async _events({ limit = 50 } = {}) {
593
629
  await this.init();
594
- return clone(this.state.events.slice(-Math.min(limit, 500)));
630
+ return clone(tailSlice(this.state.events, clampLimit(limit, 50, 500)));
595
631
  }
596
632
 
597
633
  async status(...args) { return this._serialized(() => this._status(...args)); }
@@ -638,13 +674,46 @@ export class GhostBus {
638
674
  }
639
675
 
640
676
  /** Shape-check a candidate workspace snapshot. Returns null when valid,
641
- * otherwise a short description of the first problem found. */
677
+ * otherwise a short description of the first problem found.
678
+ *
679
+ * v0.5.4 deepened this from "the collections exist" to "the collections are
680
+ * usable": a snapshot is untrusted input (hosted import, CLI import,
681
+ * restore), and the shallow check waved through states that then crashed
682
+ * the bus on first use — agents:[null] (agent lookups destructure entries),
683
+ * a file whose text is not a string (search/sizing throw), a negative seq
684
+ * counter, a '__proto__' file name (see RESERVED_FILE_NAMES). Entries must
685
+ * be objects, agents need string names (they are lookup keys), files need
686
+ * string text, counters must be finite and non-negative, and counts get
687
+ * sanity caps set far above anything the bus itself can produce (stored
688
+ * messages trim at 10k, events at 5k), so every export made by v0.5.0–0.5.3
689
+ * still validates. Unknown EXTRA top-level keys stay allowed on purpose:
690
+ * rejecting them would break forward compatibility with newer exports for
691
+ * no safety gain — the known keys are all typed here. */
692
+ const SNAPSHOT_CAPS = { agents: 10_000, messages: 100_000, tasks: 100_000, events: 100_000, files: 10_000 };
693
+ const isStateObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
642
694
  export function validateSnapshot(s) {
643
- if (!s || typeof s !== 'object' || Array.isArray(s)) return 'not an object';
644
- if (!s.workspace || typeof s.workspace !== 'object') return 'missing workspace';
645
- for (const k of ['seq', 'msgSeq', 'taskSeq']) if (typeof s[k] !== 'number') return `missing numeric ${k}`;
646
- for (const k of ['agents', 'messages', 'tasks', 'events']) if (!Array.isArray(s[k])) return `missing array ${k}`;
647
- if (!s.files || typeof s.files !== 'object' || Array.isArray(s.files)) return 'missing files object';
695
+ if (!isStateObject(s)) return 'not an object';
696
+ if (!isStateObject(s.workspace)) return 'missing workspace';
697
+ if (s.workspace.id !== undefined && typeof s.workspace.id !== 'string') return 'workspace.id must be a string';
698
+ if (s.workspace.name !== undefined && typeof s.workspace.name !== 'string') return 'workspace.name must be a string';
699
+ for (const k of ['seq', 'msgSeq', 'taskSeq']) {
700
+ if (typeof s[k] !== 'number' || !Number.isFinite(s[k]) || s[k] < 0) return `missing numeric ${k}`;
701
+ }
702
+ for (const k of ['agents', 'messages', 'tasks', 'events']) {
703
+ if (!Array.isArray(s[k])) return `missing array ${k}`;
704
+ if (s[k].length > SNAPSHOT_CAPS[k]) return `${k} exceeds sanity cap ${SNAPSHOT_CAPS[k]}`;
705
+ for (const el of s[k]) if (!isStateObject(el)) return `${k} entries must be objects`;
706
+ }
707
+ for (const a of s.agents) if (typeof a.name !== 'string' || !a.name) return 'agent entries need a string name';
708
+ if (!isStateObject(s.files)) return 'missing files object';
709
+ const fileNames = Object.keys(s.files);
710
+ if (fileNames.length > SNAPSHOT_CAPS.files) return `files exceeds sanity cap ${SNAPSHOT_CAPS.files}`;
711
+ for (const name of fileNames) {
712
+ if (RESERVED_FILE_NAMES.has(name)) return `files may not use reserved name: ${name}`;
713
+ const f = s.files[name];
714
+ if (!isStateObject(f) || typeof f.text !== 'string') return `file ${name} must be an object with string text`;
715
+ }
716
+ if (s.context !== undefined && s.context !== null && !isStateObject(s.context)) return 'context must be an object or null';
648
717
  return null;
649
718
  }
650
719
 
package/src/handler.mjs CHANGED
@@ -139,7 +139,11 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
139
139
  if (req.method === 'GET' && url.pathname === '/api/wait') {
140
140
  const agent = url.searchParams.get('agent');
141
141
  if (!agent) return send(400, { error: 'agent query param required' });
142
- const timeout = Math.min(Number(url.searchParams.get('timeout') || 25), 55) * 1000;
142
+ // Garbage timeouts (timeout=abc -> NaN -> an instant empty reply;
143
+ // negative -> the same) fall back to the 25s default; real values
144
+ // clamp into [0, 55]s (v0.5.5).
145
+ const tParam = Number(url.searchParams.get('timeout') || 25);
146
+ const timeout = (Number.isFinite(tParam) ? Math.min(Math.max(tParam, 0), 55) : 25) * 1000;
143
147
  const sinceSeq = url.searchParams.has('sinceSeq') ? Number(url.searchParams.get('sinceSeq')) : (await bus.sync({})).headSeq;
144
148
  const immediate = await bus.sync({ sinceSeq, limit: 100 });
145
149
  const relevantNow = immediate.events.filter(ev => ev.type !== 'message.send' || ev.to === agent || ev.to === '*' || ev.actor === agent);
@@ -27,6 +27,10 @@
27
27
  * per IP at this layer, JSON bodies are capped at 1 MB (413), registry +
28
28
  * workspace files are written 0600, HTML pages carry CSP/frame headers,
29
29
  * and every admin action is audit-logged to stderr (never keys/hashes).
30
+ * - v0.5.4: workspace ids are validated at EVERY boundary — create, import,
31
+ * route matching (regex), workspace resolution, and the registry itself
32
+ * when loaded: ids become file names inside the data dir, so a planted or
33
+ * hand-edited registry entry must never turn one into a path outside it.
30
34
  */
31
35
  import http from 'node:http';
32
36
  import fs from 'node:fs';
@@ -62,7 +66,26 @@ if (!REQUIRE_TOKENS) {
62
66
 
63
67
  fs.mkdirSync(dataDir, { recursive: true });
64
68
  const registryPath = path.join(dataDir, 'registry.json');
65
- const loadRegistry = () => { try { return JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return { workspaces: {} }; } };
69
+ const validId = (id) => /^[a-z0-9][a-z0-9-]{1,62}$/.test(String(id || ''));
70
+ const warnedRegistryIds = new Set();
71
+ const loadRegistry = () => {
72
+ let reg;
73
+ try { reg = JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return { workspaces: {} }; }
74
+ if (!reg || typeof reg !== 'object' || !reg.workspaces || typeof reg.workspaces !== 'object') return { workspaces: {} };
75
+ // Defense in depth (v0.5.4): create/import validate ids, but the registry
76
+ // is also a file on disk — never let a planted or hand-edited entry turn
77
+ // into a path outside the data dir via getWorkspace's `${id}.json`.
78
+ for (const id of Object.keys(reg.workspaces)) {
79
+ if (!validId(id)) {
80
+ if (!warnedRegistryIds.has(id)) {
81
+ warnedRegistryIds.add(id);
82
+ process.stderr.write(`ghostbus hosted: ignoring registry entry with invalid workspace id ${JSON.stringify(id)} (ids become file names inside the data dir)\n`);
83
+ }
84
+ delete reg.workspaces[id];
85
+ }
86
+ }
87
+ return reg;
88
+ };
66
89
  const saveRegistry = (reg) => {
67
90
  // Unique temp per save: two processes sharing this registry must never
68
91
  // collide on one fixed '.tmp' name (first rename wins, second throws ENOENT).
@@ -77,10 +100,10 @@ const saveRegistry = (reg) => {
77
100
  }
78
101
  };
79
102
  const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
80
- const validId = (id) => /^[a-z0-9][a-z0-9-]{1,62}$/.test(String(id || ''));
81
103
 
82
104
  const buses = new Map(); // id -> {bus, handle}
83
105
  async function getWorkspace(id) {
106
+ if (!validId(id)) return null; // an id is a file name inside dataDir — nothing else resolves (v0.5.4)
84
107
  if (buses.has(id)) return buses.get(id);
85
108
  const reg = loadRegistry();
86
109
  const meta = reg.workspaces[id];
@@ -56,6 +56,12 @@ rl.on('line', async (line) => {
56
56
  if (!line.trim()) return;
57
57
  let msg;
58
58
  try { msg = JSON.parse(line); } catch { return; }
59
- await handle(msg);
59
+ // A JSON-RPC message is a plain object. Anything else on the wire — null,
60
+ // a bare string/number, an array — is ignored, never fatal (v0.5.5: one
61
+ // bare `null` line used to throw in handle() and kill the server process
62
+ // for every connected agent). And handle() itself is wrapped: one bad
63
+ // message must never take the bus down.
64
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
65
+ try { await handle(msg); } catch { /* a malformed message is dropped, not fatal */ }
60
66
  });
61
67
  process.stderr.write(`ghostbus mcp-server v${GHOSTBUS_VERSION} · store=${storePath} · workspace="${wsName}"\n`);
package/src/tools.mjs CHANGED
@@ -35,7 +35,45 @@ export const TOOLS = [
35
35
  { name: 'bus_events', description: 'Read the provenance event log (who did what, when).', inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 50 } } } },
36
36
  ];
37
37
 
38
+ // Argument validation (v0.5.5): an MCP client — or a prompt-injected agent
39
+ // driving one — can send ANY argument shapes, and the core bus deliberately
40
+ // coerces a lot (String(body) turned an object body into a stored
41
+ // "[object Object]" message; a numeric `path` became a state key). The
42
+ // tools layer is the trust boundary, so arguments are checked against the
43
+ // tool's own declared inputSchema here, once, instead of per-tool ad-hoc
44
+ // checks: required arguments must be present (null counts as missing, the
45
+ // codebase's absent-value convention), and every provided argument must
46
+ // match its declared type — numbers must be finite, objects must be plain
47
+ // objects (not arrays/null). Undeclared extra arguments pass through
48
+ // untouched (several tools read an undeclared `token`). Violations throw a
49
+ // plain Error, which both transports already surface as a clean isError /
50
+ // 400 result — never a crash, never coerced garbage in shared state.
51
+ const ARG_TYPE_CHECKS = {
52
+ string: { ok: (v) => typeof v === 'string', label: 'a string' },
53
+ number: { ok: (v) => typeof v === 'number' && Number.isFinite(v), label: 'a finite number' },
54
+ boolean: { ok: (v) => typeof v === 'boolean', label: 'a boolean' },
55
+ array: { ok: (v) => Array.isArray(v), label: 'an array' },
56
+ object: { ok: (v) => !!v && typeof v === 'object' && !Array.isArray(v), label: 'an object' },
57
+ };
58
+ function validateToolArgs(tool, args) {
59
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
60
+ throw new Error(`tool ${tool.name}: arguments must be an object`);
61
+ }
62
+ const schema = tool.inputSchema || {};
63
+ for (const k of schema.required || []) {
64
+ if (args[k] === undefined || args[k] === null) throw new Error(`tool ${tool.name}: missing required argument "${k}"`);
65
+ }
66
+ for (const [k, spec] of Object.entries(schema.properties || {})) {
67
+ const v = args[k];
68
+ if (v === undefined || v === null) continue; // absent optional — the ?? defaults below apply
69
+ const check = ARG_TYPE_CHECKS[spec.type];
70
+ if (check && !check.ok(v)) throw new Error(`tool ${tool.name}: argument "${k}" must be ${check.label}`);
71
+ }
72
+ }
73
+
38
74
  export async function callTool(bus, name, args = {}) {
75
+ const tool = TOOLS.find(t => t.name === name);
76
+ if (tool) validateToolArgs(tool, args);
39
77
  switch (name) {
40
78
  case 'bus_status': return bus.status();
41
79
  case 'bus_register': return bus.registerAgent({ name: args.agent, role: args.role, capabilities: args.capabilities, token: args.token ?? null });