ghostbus 0.5.4 → 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 +13 -0
- package/README.md +1 -1
- package/docs/hosted.md +24 -0
- package/package.json +1 -1
- package/src/board.html +1 -1
- package/src/core.mjs +34 -10
- package/src/handler.mjs +5 -1
- package/src/mcp-server.mjs +7 -1
- package/src/tools.mjs +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
3
16
|
## 0.5.4 — 2026-10-11
|
|
4
17
|
|
|
5
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.
|
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 #
|
|
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
|
@@ -165,6 +165,30 @@ validation:
|
|
|
165
165
|
codebase, and the suite carries canary checks that try anyway through
|
|
166
166
|
the context writer and restore payloads.
|
|
167
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
|
+
|
|
168
192
|
## Option B — serverless on Netlify (Blobs)
|
|
169
193
|
|
|
170
194
|
`deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
|
package/package.json
CHANGED
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.
|
|
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.
|
|
@@ -36,6 +36,19 @@ const RESERVED_FILE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
|
|
|
36
36
|
|
|
37
37
|
const now = () => new Date().toISOString();
|
|
38
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) : []);
|
|
39
52
|
|
|
40
53
|
function emptyState(workspace = {}) {
|
|
41
54
|
return {
|
|
@@ -245,9 +258,13 @@ export class GhostBus {
|
|
|
245
258
|
if (to !== '*') this._existsAgent(to);
|
|
246
259
|
if (!body || !String(body).trim()) throw new Error('message body required');
|
|
247
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);
|
|
248
265
|
const msg = {
|
|
249
266
|
id: this.state.msgSeq, from, to, channel: String(channel).slice(0, 64),
|
|
250
|
-
body: String(body), threadId:
|
|
267
|
+
body: String(body), threadId: Number.isFinite(tid) && tid >= 1 ? Math.floor(tid) : null,
|
|
251
268
|
createdAt: now(), readBy: [from],
|
|
252
269
|
};
|
|
253
270
|
this.state.messages.push(msg);
|
|
@@ -265,7 +282,7 @@ export class GhostBus {
|
|
|
265
282
|
let msgs = this.state.messages.filter(m =>
|
|
266
283
|
m.to === agentName || m.to === '*' || m.from === agentName);
|
|
267
284
|
if (unreadOnly) msgs = msgs.filter(m => !m.readBy.includes(agentName));
|
|
268
|
-
msgs = msgs
|
|
285
|
+
msgs = tailSlice(msgs, clampLimit(limit, 50, 500));
|
|
269
286
|
if (markRead) {
|
|
270
287
|
let changed = false;
|
|
271
288
|
for (const m of msgs) if (!m.readBy.includes(agentName)) { m.readBy.push(agentName); changed = true; }
|
|
@@ -399,7 +416,7 @@ export class GhostBus {
|
|
|
399
416
|
async _putFile({ by, path: p, text, token = null }) {
|
|
400
417
|
await this.init();
|
|
401
418
|
this._requireAgent(by, token);
|
|
402
|
-
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');
|
|
403
420
|
if (String(p).includes('..')) throw new Error('path may not contain ..');
|
|
404
421
|
if (RESERVED_FILE_NAMES.has(p)) throw new Error('path is a reserved name');
|
|
405
422
|
const prev = this.state.files[p];
|
|
@@ -495,11 +512,12 @@ export class GhostBus {
|
|
|
495
512
|
const q = String(query || '').toLowerCase().trim();
|
|
496
513
|
if (q.length < 2) throw new Error('search query needs at least 2 characters');
|
|
497
514
|
const hit = (text) => String(text || '').toLowerCase().includes(q);
|
|
515
|
+
const lim = clampLimit(limit, 20, 500);
|
|
498
516
|
return {
|
|
499
517
|
query,
|
|
500
|
-
messages: clone(this.state.messages.filter(m => hit(m.body))
|
|
501
|
-
tasks: clone(this.state.tasks.filter(t => hit(t.title) || hit(t.body) || hit(t.result))
|
|
502
|
-
files: Object.entries(this.state.files).filter(([p, f]) => hit(p) || hit(f.text)).slice(0,
|
|
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)
|
|
503
521
|
.map(([p, f]) => ({ path: p, updatedBy: f.updatedBy, updatedAt: f.updatedAt, excerpt: f.text.slice(0, 200) })),
|
|
504
522
|
};
|
|
505
523
|
}
|
|
@@ -596,14 +614,20 @@ export class GhostBus {
|
|
|
596
614
|
async sync(...args) { return this._serialized(() => this._sync(...args)); }
|
|
597
615
|
async _sync({ sinceSeq = 0, limit = 500 } = {}) {
|
|
598
616
|
await this.init();
|
|
599
|
-
|
|
600
|
-
|
|
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 };
|
|
601
625
|
}
|
|
602
626
|
|
|
603
627
|
async events(...args) { return this._serialized(() => this._events(...args)); }
|
|
604
628
|
async _events({ limit = 50 } = {}) {
|
|
605
629
|
await this.init();
|
|
606
|
-
return clone(this.state.events
|
|
630
|
+
return clone(tailSlice(this.state.events, clampLimit(limit, 50, 500)));
|
|
607
631
|
}
|
|
608
632
|
|
|
609
633
|
async status(...args) { return this._serialized(() => this._status(...args)); }
|
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
|
-
|
|
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);
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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 });
|