@heretek-ai/epistemic-swarm 0.7.21 → 0.7.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/bin/cli.js +3 -2
- package/extensions/pi/index.js +3 -2
- package/package.json +5 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/factory/skills/factory/SKILL.md +1 -1
- package/plugins/opencode/analysis-bridge.js +566 -0
- package/plugins/opencode/config-io.js +598 -31
- package/plugins/opencode/dep-health.js +1039 -0
- package/plugins/opencode/hook-bus.js +434 -0
- package/plugins/opencode/index.js +252 -19
- package/plugins/opencode/tui.js +230 -17
- package/plugins/opencode/weight-baseline.json +16 -0
- package/plugins/opencode/weight-signals.js +769 -0
- package/plugins/research-cache/skills/research_cache/hasher.py +33 -8
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +249 -81
- package/runner/refinement.py +5 -0
- package/runner/schema_validate.py +63 -1
- package/runner/schemas.py +9 -4
- package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/config_validation_cases.json +60 -0
- package/runner/tests/test_analysis_bridge.py +356 -0
- package/runner/tests/test_config_schema.py +119 -15
- package/runner/tests/test_dep_health.py +484 -0
- package/runner/tests/test_hardening_contract.py +391 -0
- package/runner/tests/test_hook_bus.py +802 -0
- package/runner/tests/test_opencode_ux.py +232 -0
- package/runner/tests/test_opencode_v2_registrars.py +548 -16
- package/runner/tests/test_phase08_waiver_reopens.py +539 -0
- package/runner/tests/test_weight_signals.py +361 -0
- package/schemas/config.schema.json +4 -3
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/factory/SKILL.md +1 -1
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +33 -8
- package/skills/swarm_config/SKILL.md +6 -1
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- package/skills/swarm_config/configure.py +254 -17
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IUMBTEMS hook bus (Phase 01-hook-bus-spec): minimal six-event blockable
|
|
3
|
+
* event bus with tiered enforcement + audit log.
|
|
4
|
+
*
|
|
5
|
+
* Zero runtime dependencies; host-agnostic (no OpenCode host import) so the
|
|
6
|
+
* mock-host suites can drive it without a live TUI. Fail-open everywhere:
|
|
7
|
+
* bus-internal faults audit-log and allow — never throw into the host.
|
|
8
|
+
*
|
|
9
|
+
* Phase evidence (cited per programmer brief):
|
|
10
|
+
* - Transform-shaped registrations today: commands via
|
|
11
|
+
* `host.command.transform` [VERIFIED: sha256:52ac5b4d26062cfc6093440faa415ab152f8acd0d18c4496eb0087ed18a27d76
|
|
12
|
+
* `file:///home/john/Projects/IUMBTEMS/plugins/opencode/index.js`:1038-1117],
|
|
13
|
+
* tools via `host.tool.transform` [VERIFIED: same hash, `:1119-1158`].
|
|
14
|
+
* - Documented asymmetry: no `tool.execute.before` registration point, so host
|
|
15
|
+
* webfetch/websearch calls cannot be intercepted the way Claude Code's
|
|
16
|
+
* hooks/hooks.json does [VERIFIED: same hash, `:1121-1126`].
|
|
17
|
+
* - Session hooks are best-effort: compaction via `session.hook("compaction")`
|
|
18
|
+
* [VERIFIED: same hash, `:1750-1775`], per-role temperature via
|
|
19
|
+
* `session.hook("context")` [VERIFIED: same hash, `:2615-2710`].
|
|
20
|
+
* - Control-plane precedent: `iumbtems.settings` RPC via `ctx.rpc.register`
|
|
21
|
+
* [VERIFIED: same hash, `:2072-2510`]; MCP toggles via `ctx.mcp.transform`
|
|
22
|
+
* [VERIFIED: same hash, `:2579-2610`]; setup adopts all four registrations
|
|
23
|
+
* [VERIFIED: same hash, `:2714-2750`].
|
|
24
|
+
* - QA home: mock-host harness for the plugin transforms
|
|
25
|
+
* [VERIFIED: sha256:06fff10d7c9d77a86c42d6f412b22de46a6f6bba89dea23a863075bdd3d98bda
|
|
26
|
+
* `file:///home/john/Projects/IUMBTEMS/runner/tests/test_opencode_ux.py`].
|
|
27
|
+
* - Gate-0 parents: brainstorm manifest
|
|
28
|
+
* `08229740d0a7f752b0c388876ec1588e513f2b3bc8130a72771bee9593d92665`,
|
|
29
|
+
* darkharvest manifest
|
|
30
|
+
* `131bcb343879bfadcba9a40719ddd0b77540eb21f4cdc937d18b1b2943579afa`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Event registry + tier table.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** The six blockable bus events (H1+H5). All six deny-block per tier table. */
|
|
38
|
+
export const HOOK_EVENTS = Object.freeze([
|
|
39
|
+
'pre-tool-use',
|
|
40
|
+
'post-tool-use',
|
|
41
|
+
'pre-commit',
|
|
42
|
+
'stop',
|
|
43
|
+
'notification',
|
|
44
|
+
'session-start',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tier timeouts (H2+H6). Fast checks get a short budget and block on deny;
|
|
49
|
+
* slow checks get a longer budget and a deny blocks only when it lands inside
|
|
50
|
+
* that budget — a slow overrun degrades to an advisory annotation (fail-open
|
|
51
|
+
* allow) with an audit entry, so a hung slow check can never brick the fast
|
|
52
|
+
* path.
|
|
53
|
+
*/
|
|
54
|
+
export const FAST_TIMEOUT_MS = 500;
|
|
55
|
+
export const SLOW_TIMEOUT_MS = 5000;
|
|
56
|
+
|
|
57
|
+
/** Handler tiers. Unknown/missing tier normalises to `slow` (fail-open side). */
|
|
58
|
+
export const HOOK_TIERS = Object.freeze(['fast', 'slow']);
|
|
59
|
+
|
|
60
|
+
/** Max audit entries retained per bus (ring cap; oldest dropped first). */
|
|
61
|
+
export const MAX_AUDIT_ENTRIES = 1000;
|
|
62
|
+
|
|
63
|
+
function normalizeTier(tier) {
|
|
64
|
+
return tier === 'fast' ? 'fast' : 'slow';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function timeoutForTier(tier, overrides = {}) {
|
|
68
|
+
if (tier === 'fast') {
|
|
69
|
+
const v = Number(overrides.fastTimeoutMs ?? FAST_TIMEOUT_MS);
|
|
70
|
+
return Number.isFinite(v) && v >= 0 ? v : FAST_TIMEOUT_MS;
|
|
71
|
+
}
|
|
72
|
+
const v = Number(overrides.slowTimeoutMs ?? SLOW_TIMEOUT_MS);
|
|
73
|
+
return Number.isFinite(v) && v >= 0 ? v : SLOW_TIMEOUT_MS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalise a handler's return into `allow` | `deny` | `advisory`.
|
|
78
|
+
* `undefined`/`null`/`true` mean allow (non-blocking default); `false` and
|
|
79
|
+
* `'deny'` mean deny; `'advisory'` (or `{verdict:'advisory'}`) annotates
|
|
80
|
+
* without blocking.
|
|
81
|
+
*
|
|
82
|
+
* Fix-round (FAIL 3a): the detailed form reports whether the input was a
|
|
83
|
+
* recognised spelling. Anything unrecognised still degrades to fail-open
|
|
84
|
+
* `allow`, but the caller MUST tag the audit entry with `fallback: true` +
|
|
85
|
+
* a `note` — silent normalisation is what made malformed verdicts invisible.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeVerdictEx(value) {
|
|
88
|
+
if (value === undefined || value === null || value === true) {
|
|
89
|
+
return { verdict: 'allow', recognized: true };
|
|
90
|
+
}
|
|
91
|
+
if (value === false) return { verdict: 'deny', recognized: true };
|
|
92
|
+
if (typeof value === 'string') {
|
|
93
|
+
const v = value.trim().toLowerCase();
|
|
94
|
+
if (v === 'deny' || v === 'block') return { verdict: 'deny', recognized: true };
|
|
95
|
+
if (v === 'advisory' || v === 'warn') return { verdict: 'advisory', recognized: true };
|
|
96
|
+
if (v === 'allow' || v === 'ok' || v === '') return { verdict: 'allow', recognized: true };
|
|
97
|
+
// Non-empty unlisted string: fail-open allow (kept as `reason` by the
|
|
98
|
+
// caller), flagged — not silent.
|
|
99
|
+
return { verdict: 'allow', recognized: false };
|
|
100
|
+
}
|
|
101
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
102
|
+
// Narrow fix round (qa-b): the empty object carries no verdict and no
|
|
103
|
+
// reason — it is NOT a reason-only object. Treat it as unrecognized so
|
|
104
|
+
// the caller tags fallback:true + note, consistent with 42/{verdict:BOGUS}/
|
|
105
|
+
// "gibberish"/0/[].
|
|
106
|
+
if (Object.keys(value).length === 0) {
|
|
107
|
+
return { verdict: 'allow', recognized: false };
|
|
108
|
+
}
|
|
109
|
+
const raw = value.verdict;
|
|
110
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
111
|
+
return { verdict: 'allow', recognized: true }; // reason-only object
|
|
112
|
+
}
|
|
113
|
+
const v = String(raw).trim().toLowerCase();
|
|
114
|
+
if (v === 'deny') return { verdict: 'deny', recognized: true };
|
|
115
|
+
if (v === 'advisory') return { verdict: 'advisory', recognized: true };
|
|
116
|
+
if (v === 'allow') return { verdict: 'allow', recognized: true };
|
|
117
|
+
return { verdict: 'allow', recognized: false };
|
|
118
|
+
}
|
|
119
|
+
return { verdict: 'allow', recognized: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function normalizeVerdict(value) {
|
|
123
|
+
return normalizeVerdictEx(value).verdict;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function reasonOf(value) {
|
|
127
|
+
if (value && typeof value === 'object' && typeof value.reason === 'string') {
|
|
128
|
+
return value.reason;
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === 'string' && value.trim() && !['allow', 'deny', 'advisory', 'block', 'ok', 'warn'].includes(value.trim().toLowerCase())) {
|
|
131
|
+
return value.trim();
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Run one handler with a timeout bound. Never rejects: resolves a record.
|
|
137
|
+
*
|
|
138
|
+
* Caller-latency contract (GOAL §5/§6 fix-round note): this promise always
|
|
139
|
+
* settles within ~`ms` — the timer fires even for a handler that never
|
|
140
|
+
* settles, so `emit` (tiers concurrent) resolves within
|
|
141
|
+
* max(fastBudget, slowBudget) + scheduling epsilon. A hung slow handler
|
|
142
|
+
* delays the caller by at most the slow budget (default `SLOW_TIMEOUT_MS`),
|
|
143
|
+
* never indefinitely. Pinned by `test_emit_latency_bounded_by_slow_budget`.
|
|
144
|
+
*/
|
|
145
|
+
function runHandlerBounded(handler, payload, ms) {
|
|
146
|
+
const started = Date.now();
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
let done = false;
|
|
149
|
+
const finish = (record) => {
|
|
150
|
+
if (done) return;
|
|
151
|
+
done = true;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
resolve({ ...record, latencyMs: Date.now() - started });
|
|
154
|
+
};
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
finish({ status: 'timeout', verdict: 'allow', fallback: true });
|
|
157
|
+
}, ms);
|
|
158
|
+
// NOTE: intentionally NOT unref'd — an unref'd timer lets a node process
|
|
159
|
+
// drain while a hung handler settles, so the timeout would never fire.
|
|
160
|
+
let result;
|
|
161
|
+
try {
|
|
162
|
+
result = handler(payload);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
finish({ status: 'error', verdict: 'allow', fallback: true, error: String(err?.message ?? err) });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
Promise.resolve(result).then(
|
|
168
|
+
(value) => {
|
|
169
|
+
const norm = normalizeVerdictEx(value);
|
|
170
|
+
finish({
|
|
171
|
+
status: 'ok',
|
|
172
|
+
verdict: norm.verdict,
|
|
173
|
+
reason: reasonOf(value),
|
|
174
|
+
// FAIL 3a: an unrecognised verdict is NOT silently normalised —
|
|
175
|
+
// the audit entry carries fallback + note (verdict stays fail-open
|
|
176
|
+
// allow).
|
|
177
|
+
...(norm.recognized ? {} : {
|
|
178
|
+
fallback: true,
|
|
179
|
+
note: 'unrecognized verdict; degraded to fail-open allow',
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
(err) => finish({ status: 'error', verdict: 'allow', fallback: true, error: String(err?.message ?? err) }),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Bus.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create a hook bus. `on(event, handler, {tier})` registers; `emit(event,
|
|
194
|
+
* payload)` runs fast-tier and slow-tier handlers concurrently (slow overruns
|
|
195
|
+
* never brick the fast path) and resolves `{allowed, verdicts, annotations}`.
|
|
196
|
+
* Every handler outcome — allow/deny/timeout/fallback — appends one audit
|
|
197
|
+
* entry. Neither `on` nor `emit` ever throws.
|
|
198
|
+
*/
|
|
199
|
+
export function createHookBus(opts = {}) {
|
|
200
|
+
const handlers = new Map(HOOK_EVENTS.map((e) => [e, []]));
|
|
201
|
+
const audit = [];
|
|
202
|
+
let seq = 0;
|
|
203
|
+
// FAIL 3c: ring-cap evictions are counted (never silent). `dropped` counts
|
|
204
|
+
// capacity evictions only; `clearAuditLog()` writes a tombstone entry whose
|
|
205
|
+
// note carries the cleared count. Both are surfaced via `getAuditStats()`.
|
|
206
|
+
let droppedAuditEntries = 0;
|
|
207
|
+
const clock = typeof opts.clock === 'function' ? opts.clock : () => new Date().toISOString();
|
|
208
|
+
|
|
209
|
+
function appendAudit(entry) {
|
|
210
|
+
try {
|
|
211
|
+
audit.push({ seq: seq += 1, ts: clock(), ...entry });
|
|
212
|
+
while (audit.length > MAX_AUDIT_ENTRIES) {
|
|
213
|
+
audit.shift();
|
|
214
|
+
droppedAuditEntries += 1;
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
/* audit must never break the bus */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function on(event, handler, runOpts = {}) {
|
|
222
|
+
try {
|
|
223
|
+
if (!handlers.has(event) || typeof handler !== 'function') {
|
|
224
|
+
appendAudit({
|
|
225
|
+
event: String(event), tier: normalizeTier(runOpts?.tier),
|
|
226
|
+
handler: 'n/a', verdict: 'allow', outcome: 'fallback',
|
|
227
|
+
latencyMs: 0, fallback: true, note: 'on(): unknown event or non-function handler; ignored',
|
|
228
|
+
tool: null, eventId: null, // FAIL 3b: keys always present (null when absent)
|
|
229
|
+
});
|
|
230
|
+
return () => {};
|
|
231
|
+
}
|
|
232
|
+
const tier = normalizeTier(runOpts?.tier);
|
|
233
|
+
const name = runOpts?.name || handler.name || 'anonymous';
|
|
234
|
+
const record = { handler, tier, name };
|
|
235
|
+
handlers.get(event).push(record);
|
|
236
|
+
return () => {
|
|
237
|
+
try {
|
|
238
|
+
const list = handlers.get(event) || [];
|
|
239
|
+
const idx = list.indexOf(record);
|
|
240
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
241
|
+
} catch {
|
|
242
|
+
/* unsubscribe never throws */
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
} catch {
|
|
246
|
+
return () => {};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function emit(event, payload = {}, emitOpts = {}) {
|
|
251
|
+
try {
|
|
252
|
+
if (!handlers.has(event)) {
|
|
253
|
+
appendAudit({
|
|
254
|
+
event: String(event), tier: 'n/a', handler: 'n/a',
|
|
255
|
+
verdict: 'allow', outcome: 'fallback', latencyMs: 0,
|
|
256
|
+
fallback: true, note: 'emit(): unknown event; fail-open allow',
|
|
257
|
+
tool: null, eventId: null, // FAIL 3b: keys always present (null when absent)
|
|
258
|
+
});
|
|
259
|
+
return { allowed: true, verdicts: [], annotations: [{ event, outcome: 'fallback', note: 'unknown event' }] };
|
|
260
|
+
}
|
|
261
|
+
const list = [...(handlers.get(event) || [])];
|
|
262
|
+
const fastBudget = timeoutForTier('fast', emitOpts);
|
|
263
|
+
const slowBudget = timeoutForTier('slow', emitOpts);
|
|
264
|
+
|
|
265
|
+
// Tiers run concurrently so a slow overrun never bricks the fast path;
|
|
266
|
+
// order is preserved within each tier (registration order).
|
|
267
|
+
const runTier = async (tier) => {
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const h of list.filter((r) => r.tier === tier)) {
|
|
270
|
+
const budget = tier === 'fast' ? fastBudget : slowBudget;
|
|
271
|
+
const rec = await runHandlerBounded(h.handler, payload, budget);
|
|
272
|
+
const outcome = rec.status === 'ok'
|
|
273
|
+
? (rec.verdict === 'deny' ? 'deny' : rec.verdict === 'advisory' ? 'advisory' : 'allow')
|
|
274
|
+
: rec.status; // 'timeout' | 'error'
|
|
275
|
+
appendAudit({
|
|
276
|
+
event, tier, handler: h.name, verdict: rec.verdict, outcome,
|
|
277
|
+
latencyMs: rec.latencyMs,
|
|
278
|
+
// FAIL 3b: `tool` / `eventId` keys are ALWAYS present (null when
|
|
279
|
+
// the payload carries none) so audit consumers can rely on shape.
|
|
280
|
+
tool: payload?.tool ?? null,
|
|
281
|
+
eventId: payload?.eventId ?? null,
|
|
282
|
+
...(rec.fallback ? { fallback: true } : {}),
|
|
283
|
+
...(rec.note ? { note: rec.note } : {}),
|
|
284
|
+
...(rec.error ? { error: rec.error } : {}),
|
|
285
|
+
...(rec.reason ? { reason: rec.reason } : {}),
|
|
286
|
+
});
|
|
287
|
+
out.push({ tier, handler: h.name, ...rec, outcome });
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const [fastRecs, slowRecs] = await Promise.all([runTier('fast'), runTier('slow')]);
|
|
293
|
+
// Report in registration order (fast/slow interleave preserved by seq).
|
|
294
|
+
const ordered = [...fastRecs, ...slowRecs].sort(
|
|
295
|
+
(a, b) => list.findIndex((r) => r.name === a.handler) - list.findIndex((r) => r.name === b.handler),
|
|
296
|
+
);
|
|
297
|
+
const denied = ordered.some((r) => r.outcome === 'deny');
|
|
298
|
+
const annotations = ordered
|
|
299
|
+
.filter((r) => r.outcome === 'advisory' || r.status === 'timeout' || r.status === 'error')
|
|
300
|
+
.map((r) => ({
|
|
301
|
+
event, tier: r.tier, handler: r.handler, outcome: r.outcome,
|
|
302
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
303
|
+
...(r.error ? { error: r.error } : {}),
|
|
304
|
+
}));
|
|
305
|
+
return { allowed: !denied, verdicts: ordered, annotations };
|
|
306
|
+
} catch (err) {
|
|
307
|
+
try {
|
|
308
|
+
appendAudit({
|
|
309
|
+
event: String(event), tier: 'n/a', handler: 'n/a',
|
|
310
|
+
verdict: 'allow', outcome: 'fallback', latencyMs: 0,
|
|
311
|
+
fallback: true, error: String(err?.message ?? err),
|
|
312
|
+
tool: payload?.tool ?? null, eventId: payload?.eventId ?? null,
|
|
313
|
+
});
|
|
314
|
+
} catch {
|
|
315
|
+
/* last-resort: never throw */
|
|
316
|
+
}
|
|
317
|
+
return { allowed: true, verdicts: [], annotations: [{ event, outcome: 'fallback' }] };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function getAuditLog() {
|
|
322
|
+
try {
|
|
323
|
+
return audit.map((e) => ({ ...e }));
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function clearAuditLog() {
|
|
330
|
+
// FAIL 3c: clearing leaves a tombstone entry (the clear itself is
|
|
331
|
+
// auditable) whose note carries the discarded count. Never throws.
|
|
332
|
+
try {
|
|
333
|
+
const cleared = audit.length;
|
|
334
|
+
audit.length = 0;
|
|
335
|
+
audit.push({
|
|
336
|
+
seq: seq += 1,
|
|
337
|
+
ts: clock(),
|
|
338
|
+
event: 'audit-clear',
|
|
339
|
+
tier: 'n/a',
|
|
340
|
+
handler: 'n/a',
|
|
341
|
+
verdict: 'allow',
|
|
342
|
+
outcome: 'fallback',
|
|
343
|
+
latencyMs: 0,
|
|
344
|
+
fallback: true,
|
|
345
|
+
note: `audit log cleared; ${cleared} entries discarded`,
|
|
346
|
+
tool: null,
|
|
347
|
+
eventId: null,
|
|
348
|
+
});
|
|
349
|
+
} catch {
|
|
350
|
+
/* never throws */
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Ring-cap ledger: live entry count, capacity evictions, cap size. */
|
|
355
|
+
function getAuditStats() {
|
|
356
|
+
try {
|
|
357
|
+
return { entries: audit.length, dropped: droppedAuditEntries, capacity: MAX_AUDIT_ENTRIES };
|
|
358
|
+
} catch {
|
|
359
|
+
return { entries: 0, dropped: 0, capacity: MAX_AUDIT_ENTRIES };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function handlerCount(event) {
|
|
364
|
+
try {
|
|
365
|
+
if (event === undefined) {
|
|
366
|
+
return [...handlers.values()].reduce((n, l) => n + l.length, 0);
|
|
367
|
+
}
|
|
368
|
+
return (handlers.get(event) || []).length;
|
|
369
|
+
} catch {
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { on, emit, getAuditLog, clearAuditLog, getAuditStats, handlerCount, events: [...HOOK_EVENTS] };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Pre-commit gate helper for the settings-RPC write path: emits `pre-commit`
|
|
379
|
+
* and maps the verdict to `{allowed, result}`. Fail-open on any bus fault.
|
|
380
|
+
* Stale-write guards are NOT replaced — the caller still runs the
|
|
381
|
+
* expected-hash check / MCP write after an allow (see `index.js`
|
|
382
|
+
* `createSettingsHandlers().set`).
|
|
383
|
+
*/
|
|
384
|
+
export async function gatePreCommit(bus, payload = {}, emitOpts = {}) {
|
|
385
|
+
try {
|
|
386
|
+
if (!bus || typeof bus.emit !== 'function') return { allowed: true };
|
|
387
|
+
const res = await bus.emit('pre-commit', payload, emitOpts);
|
|
388
|
+
return res && res.allowed === false ? { allowed: false, result: res } : { allowed: true, result: res };
|
|
389
|
+
} catch {
|
|
390
|
+
return { allowed: true };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Adopt the bus alongside the existing transform/hook/rpc registrations
|
|
396
|
+
* (same adopt/best-effort pattern as `setup` in `index.js`). Attaches the bus
|
|
397
|
+
* to the host for debugging (`host.iumbtemsHookBus`, best-effort) and returns
|
|
398
|
+
* a disposable registration; returns null (never throws) when there is no
|
|
399
|
+
* bus to adopt.
|
|
400
|
+
*/
|
|
401
|
+
export async function registerHookBus(host, opts = {}) {
|
|
402
|
+
try {
|
|
403
|
+
const bus = opts.bus || null;
|
|
404
|
+
if (!bus || typeof bus.emit !== 'function' || typeof bus.on !== 'function') return null;
|
|
405
|
+
try {
|
|
406
|
+
if (host && typeof host === 'object') host.iumbtemsHookBus = bus;
|
|
407
|
+
} catch {
|
|
408
|
+
/* host attach is best-effort */
|
|
409
|
+
}
|
|
410
|
+
let disposed = false;
|
|
411
|
+
return {
|
|
412
|
+
bus,
|
|
413
|
+
// Synchronous on purpose: the setup() disposer stays fully synchronous
|
|
414
|
+
// when every adopted disposal is sync, so a non-awaited cleanup() still
|
|
415
|
+
// stops nudge streams before returning (pinned by
|
|
416
|
+
// test_setup_cleanup_aborts_nudge_streams).
|
|
417
|
+
dispose: () => {
|
|
418
|
+
disposed = true;
|
|
419
|
+
try {
|
|
420
|
+
if (host && typeof host === 'object' && host.iumbtemsHookBus === bus) {
|
|
421
|
+
delete host.iumbtemsHookBus;
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
/* dispose never throws */
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
get disposed() {
|
|
428
|
+
return disposed;
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
} catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|