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 +26 -0
- package/README.md +1 -1
- package/docs/hosted.md +51 -0
- package/package.json +1 -1
- package/src/board.html +1 -1
- package/src/core.mjs +89 -20
- package/src/handler.mjs +5 -1
- package/src/hosted-server.mjs +25 -2
- package/src/mcp-server.mjs +7 -1
- package/src/tools.mjs +38 -0
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 #
|
|
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
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.
|
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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))
|
|
489
|
-
tasks: clone(this.state.tasks.filter(t => hit(t.title) || hit(t.body) || hit(t.result))
|
|
490
|
-
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)
|
|
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
|
-
|
|
588
|
-
|
|
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
|
|
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 (!
|
|
644
|
-
if (!s.workspace
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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/hosted-server.mjs
CHANGED
|
@@ -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
|
|
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];
|
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 });
|