@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,802 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for Phase 01-hook-bus-spec: six-event blockable hook bus.
|
|
3
|
+
|
|
4
|
+
Mock-host only (no live TUI): bus unit tests (order, fast/slow timeouts,
|
|
5
|
+
fail-open, audit completeness, all-six-events blockable), the pre-commit gate
|
|
6
|
+
on the settings-RPC write path (stale-write preservation), registrar-adopt
|
|
7
|
+
(bus adopted + disposed alongside the existing four), and parity-doc pointer
|
|
8
|
+
resolution.
|
|
9
|
+
|
|
10
|
+
Phase evidence cited in-code per brief:
|
|
11
|
+
[VERIFIED: sha256:52ac5b4d26062cfc6093440faa415ab152f8acd0d18c4496eb0087ed18a27d76]
|
|
12
|
+
[VERIFIED: sha256:06fff10d7c9d77a86c42d6f412b22de46a6f6bba89dea23a863075bdd3d98bda]
|
|
13
|
+
Gate-0 parents: 08229740d0a7f752b0c388876ec1588e513f2b3bc8130a72771bee9593d92665,
|
|
14
|
+
131bcb343879bfadcba9a40719ddd0b77540eb21f4cdc937d18b1b2943579afa.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import unittest
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
25
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
27
|
+
|
|
28
|
+
SIX_EVENTS = [
|
|
29
|
+
"pre-tool-use",
|
|
30
|
+
"post-tool-use",
|
|
31
|
+
"pre-commit",
|
|
32
|
+
"stop",
|
|
33
|
+
"notification",
|
|
34
|
+
"session-start",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_node(code):
|
|
39
|
+
return subprocess.run(
|
|
40
|
+
["node", "--input-type=module", "-e", code],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
cwd=str(PROJECT_ROOT),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def last_json_object(stdout):
|
|
48
|
+
lines = [l.strip() for l in stdout.strip().split("\n") if l.strip().startswith("{")]
|
|
49
|
+
return json.loads(lines[-1])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
BUS_IMPORT = 'import { createHookBus } from "./plugins/opencode/hook-bus.js";\n'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestHookBusSixDenyBlocks(unittest.TestCase):
|
|
56
|
+
def test_each_event_deny_blocks(self):
|
|
57
|
+
# Functional: every one of the six events has a deny-blocks path.
|
|
58
|
+
res = run_node(
|
|
59
|
+
BUS_IMPORT
|
|
60
|
+
+ f"""
|
|
61
|
+
const results = [];
|
|
62
|
+
for (const event of {json.dumps(SIX_EVENTS)}) {{
|
|
63
|
+
const bus = createHookBus();
|
|
64
|
+
bus.on(event, () => "deny", {{tier: "fast"}});
|
|
65
|
+
const r = await bus.emit(event, {{tool: "webfetch", eventId: "e1"}});
|
|
66
|
+
results.push({{event, allowed: r.allowed}});
|
|
67
|
+
}}
|
|
68
|
+
console.log(JSON.stringify({{results}}));
|
|
69
|
+
"""
|
|
70
|
+
)
|
|
71
|
+
self.assertEqual(res.returncode, 0, f"deny-blocks test failed: {res.stderr}")
|
|
72
|
+
for row in last_json_object(res.stdout)["results"]:
|
|
73
|
+
self.assertFalse(row["allowed"], f"{row['event']} deny did not block")
|
|
74
|
+
|
|
75
|
+
def test_allow_passes(self):
|
|
76
|
+
res = run_node(
|
|
77
|
+
BUS_IMPORT
|
|
78
|
+
+ """
|
|
79
|
+
const bus = createHookBus();
|
|
80
|
+
bus.on("pre-tool-use", () => "allow", {tier: "fast"});
|
|
81
|
+
const r = await bus.emit("pre-tool-use", {tool: "webfetch"});
|
|
82
|
+
console.log(JSON.stringify({allowed: r.allowed}));
|
|
83
|
+
"""
|
|
84
|
+
)
|
|
85
|
+
self.assertEqual(res.returncode, 0, f"allow test failed: {res.stderr}")
|
|
86
|
+
self.assertTrue(last_json_object(res.stdout)["allowed"])
|
|
87
|
+
|
|
88
|
+
def test_slow_deny_inside_budget_blocks(self):
|
|
89
|
+
res = run_node(
|
|
90
|
+
BUS_IMPORT
|
|
91
|
+
+ """
|
|
92
|
+
const bus = createHookBus();
|
|
93
|
+
bus.on("stop", async () => {
|
|
94
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
95
|
+
return "deny";
|
|
96
|
+
}, {tier: "slow"});
|
|
97
|
+
const r = await bus.emit("stop", {});
|
|
98
|
+
console.log(JSON.stringify({allowed: r.allowed}));
|
|
99
|
+
"""
|
|
100
|
+
)
|
|
101
|
+
self.assertEqual(res.returncode, 0, f"slow-deny test failed: {res.stderr}")
|
|
102
|
+
self.assertFalse(last_json_object(res.stdout)["allowed"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestHookBusTimeouts(unittest.TestCase):
|
|
106
|
+
def test_slow_overrun_degrades_to_advisory(self):
|
|
107
|
+
# Slow handler exceeding the slow budget: fail-open allow + advisory
|
|
108
|
+
# annotation + timeout audit entry (never bricks, never throws).
|
|
109
|
+
res = run_node(
|
|
110
|
+
BUS_IMPORT
|
|
111
|
+
+ """
|
|
112
|
+
const bus = createHookBus();
|
|
113
|
+
bus.on("pre-tool-use", async () => {
|
|
114
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
115
|
+
return "deny";
|
|
116
|
+
}, {tier: "slow"});
|
|
117
|
+
const r = await bus.emit("pre-tool-use", {tool: "webfetch"}, {slowTimeoutMs: 50});
|
|
118
|
+
const audit = bus.getAuditLog();
|
|
119
|
+
console.log(JSON.stringify({
|
|
120
|
+
allowed: r.allowed,
|
|
121
|
+
annotations: r.annotations.length,
|
|
122
|
+
outcomes: audit.map((a) => a.outcome)
|
|
123
|
+
}));
|
|
124
|
+
"""
|
|
125
|
+
)
|
|
126
|
+
self.assertEqual(res.returncode, 0, f"slow-overrun test failed: {res.stderr}")
|
|
127
|
+
data = last_json_object(res.stdout)
|
|
128
|
+
self.assertTrue(data["allowed"], "slow overrun must fail open")
|
|
129
|
+
self.assertGreaterEqual(data["annotations"], 1)
|
|
130
|
+
self.assertIn("timeout", data["outcomes"])
|
|
131
|
+
|
|
132
|
+
def test_slow_overrun_does_not_brick_fast_path(self):
|
|
133
|
+
# A slow deny landing inside the slow budget still blocks, while the
|
|
134
|
+
# fast handler's verdict is recorded independently (tiers concurrent).
|
|
135
|
+
res = run_node(
|
|
136
|
+
BUS_IMPORT
|
|
137
|
+
+ """
|
|
138
|
+
const bus = createHookBus();
|
|
139
|
+
const order = [];
|
|
140
|
+
bus.on("stop", () => { order.push("fast"); return "allow"; }, {tier: "fast"});
|
|
141
|
+
bus.on("stop", async () => {
|
|
142
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
143
|
+
order.push("slow");
|
|
144
|
+
return "deny";
|
|
145
|
+
}, {tier: "slow"});
|
|
146
|
+
const t0 = Date.now();
|
|
147
|
+
const r = await bus.emit("stop", {});
|
|
148
|
+
const elapsed = Date.now() - t0;
|
|
149
|
+
const audit = bus.getAuditLog();
|
|
150
|
+
const fastEntry = audit.find((a) => a.tier === "fast");
|
|
151
|
+
console.log(JSON.stringify({
|
|
152
|
+
allowed: r.allowed, order, elapsed,
|
|
153
|
+
fastLatency: fastEntry && fastEntry.latencyMs
|
|
154
|
+
}));
|
|
155
|
+
"""
|
|
156
|
+
)
|
|
157
|
+
self.assertEqual(res.returncode, 0, f"fast-path test failed: {res.stderr}")
|
|
158
|
+
data = last_json_object(res.stdout)
|
|
159
|
+
self.assertFalse(data["allowed"], "slow deny inside budget must block")
|
|
160
|
+
self.assertIn("fast", data["order"])
|
|
161
|
+
# The fast verdict landed on the fast path, not after the slow 800 ms.
|
|
162
|
+
self.assertLess(data["fastLatency"], 800)
|
|
163
|
+
|
|
164
|
+
def test_fast_timeout_fails_open(self):
|
|
165
|
+
res = run_node(
|
|
166
|
+
BUS_IMPORT
|
|
167
|
+
+ """
|
|
168
|
+
const bus = createHookBus();
|
|
169
|
+
bus.on("notification", async () => {
|
|
170
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
171
|
+
return "deny";
|
|
172
|
+
}, {tier: "fast"});
|
|
173
|
+
const r = await bus.emit("notification", {}, {fastTimeoutMs: 50});
|
|
174
|
+
console.log(JSON.stringify({allowed: r.allowed}));
|
|
175
|
+
"""
|
|
176
|
+
)
|
|
177
|
+
self.assertEqual(res.returncode, 0, f"fast-timeout test failed: {res.stderr}")
|
|
178
|
+
self.assertTrue(last_json_object(res.stdout)["allowed"])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestHookBusFailOpen(unittest.TestCase):
|
|
182
|
+
def test_throwing_handler_fails_open_with_audit(self):
|
|
183
|
+
res = run_node(
|
|
184
|
+
BUS_IMPORT
|
|
185
|
+
+ """
|
|
186
|
+
const bus = createHookBus();
|
|
187
|
+
bus.on("pre-tool-use", () => { throw new Error("boom"); }, {tier: "fast"});
|
|
188
|
+
let threw = false;
|
|
189
|
+
let r = null;
|
|
190
|
+
try {
|
|
191
|
+
r = await bus.emit("pre-tool-use", {tool: "webfetch", eventId: "e9"});
|
|
192
|
+
} catch { threw = true; }
|
|
193
|
+
const audit = bus.getAuditLog();
|
|
194
|
+
console.log(JSON.stringify({
|
|
195
|
+
threw, allowed: r && r.allowed, outcomes: audit.map((a) => a.outcome)
|
|
196
|
+
}));
|
|
197
|
+
"""
|
|
198
|
+
)
|
|
199
|
+
self.assertEqual(res.returncode, 0, f"throw test failed: {res.stderr}")
|
|
200
|
+
data = last_json_object(res.stdout)
|
|
201
|
+
self.assertFalse(data["threw"], "emit must never throw into the host")
|
|
202
|
+
self.assertTrue(data["allowed"])
|
|
203
|
+
self.assertIn("error", data["outcomes"])
|
|
204
|
+
|
|
205
|
+
def test_rejecting_handler_fails_open(self):
|
|
206
|
+
res = run_node(
|
|
207
|
+
BUS_IMPORT
|
|
208
|
+
+ """
|
|
209
|
+
const bus = createHookBus();
|
|
210
|
+
bus.on("stop", async () => { throw new Error("async boom"); }, {tier: "slow"});
|
|
211
|
+
const r = await bus.emit("stop", {});
|
|
212
|
+
console.log(JSON.stringify({allowed: r.allowed}));
|
|
213
|
+
"""
|
|
214
|
+
)
|
|
215
|
+
self.assertEqual(res.returncode, 0, f"reject test failed: {res.stderr}")
|
|
216
|
+
self.assertTrue(last_json_object(res.stdout)["allowed"])
|
|
217
|
+
|
|
218
|
+
def test_unknown_event_fails_open(self):
|
|
219
|
+
res = run_node(
|
|
220
|
+
BUS_IMPORT
|
|
221
|
+
+ """
|
|
222
|
+
const bus = createHookBus();
|
|
223
|
+
const r = await bus.emit("not-an-event", {});
|
|
224
|
+
const badOn = bus.on("not-an-event", () => "deny");
|
|
225
|
+
const r2 = await bus.emit("not-an-event", {});
|
|
226
|
+
console.log(JSON.stringify({
|
|
227
|
+
allowed: r.allowed, allowed2: r2.allowed,
|
|
228
|
+
unsub: typeof badOn
|
|
229
|
+
}));
|
|
230
|
+
"""
|
|
231
|
+
)
|
|
232
|
+
self.assertEqual(res.returncode, 0, f"unknown-event test failed: {res.stderr}")
|
|
233
|
+
data = last_json_object(res.stdout)
|
|
234
|
+
self.assertTrue(data["allowed"])
|
|
235
|
+
self.assertTrue(data["allowed2"])
|
|
236
|
+
self.assertEqual(data["unsub"], "function")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TestHookBusOrderAndAudit(unittest.TestCase):
|
|
240
|
+
def test_registration_order_preserved(self):
|
|
241
|
+
# Verdicts report in registration order; the audit log carries the
|
|
242
|
+
# same handler multiset (tiers run concurrently, so completion order
|
|
243
|
+
# may interleave — each entry's seq preserves the total order).
|
|
244
|
+
res = run_node(
|
|
245
|
+
BUS_IMPORT
|
|
246
|
+
+ """
|
|
247
|
+
const bus = createHookBus();
|
|
248
|
+
bus.on("post-tool-use", () => "allow", {tier: "fast", name: "first"});
|
|
249
|
+
bus.on("post-tool-use", () => "allow", {tier: "fast", name: "second"});
|
|
250
|
+
bus.on("post-tool-use", () => "allow", {tier: "slow", name: "third"});
|
|
251
|
+
const r = await bus.emit("post-tool-use", {});
|
|
252
|
+
console.log(JSON.stringify({
|
|
253
|
+
verdicts: r.verdicts.map((v) => v.handler),
|
|
254
|
+
audit: bus.getAuditLog().map((a) => a.handler)
|
|
255
|
+
}));
|
|
256
|
+
"""
|
|
257
|
+
)
|
|
258
|
+
self.assertEqual(res.returncode, 0, f"order test failed: {res.stderr}")
|
|
259
|
+
data = last_json_object(res.stdout)
|
|
260
|
+
self.assertEqual(data["verdicts"], ["first", "second", "third"])
|
|
261
|
+
self.assertEqual(sorted(data["audit"]), ["first", "second", "third"])
|
|
262
|
+
|
|
263
|
+
def test_audit_completeness(self):
|
|
264
|
+
# Every decision audit-logs verdict + outcome + latency + tool/event id.
|
|
265
|
+
res = run_node(
|
|
266
|
+
BUS_IMPORT
|
|
267
|
+
+ """
|
|
268
|
+
const bus = createHookBus();
|
|
269
|
+
bus.on("pre-tool-use", () => "deny", {tier: "fast", name: "gate"});
|
|
270
|
+
bus.on("pre-tool-use", async () => {
|
|
271
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
272
|
+
return "allow";
|
|
273
|
+
}, {tier: "slow", name: "checker"});
|
|
274
|
+
await bus.emit("pre-tool-use", {tool: "webfetch", eventId: "e7"});
|
|
275
|
+
const audit = bus.getAuditLog();
|
|
276
|
+
const keys = audit.map((a) => Object.keys(a).sort());
|
|
277
|
+
console.log(JSON.stringify({audit, keys}));
|
|
278
|
+
"""
|
|
279
|
+
)
|
|
280
|
+
self.assertEqual(res.returncode, 0, f"audit test failed: {res.stderr}")
|
|
281
|
+
data = last_json_object(res.stdout)
|
|
282
|
+
self.assertEqual(len(data["audit"]), 2)
|
|
283
|
+
required = {
|
|
284
|
+
"event",
|
|
285
|
+
"tier",
|
|
286
|
+
"handler",
|
|
287
|
+
"verdict",
|
|
288
|
+
"outcome",
|
|
289
|
+
"latencyMs",
|
|
290
|
+
"tool",
|
|
291
|
+
"eventId",
|
|
292
|
+
}
|
|
293
|
+
for entry_keys in data["keys"]:
|
|
294
|
+
self.assertTrue(
|
|
295
|
+
required.issubset(set(entry_keys)),
|
|
296
|
+
f"audit entry missing keys: {required - set(entry_keys)}",
|
|
297
|
+
)
|
|
298
|
+
outcomes = {a["outcome"] for a in data["audit"]}
|
|
299
|
+
self.assertIn("deny", outcomes)
|
|
300
|
+
self.assertIn("allow", outcomes)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestPreCommitGate(unittest.TestCase):
|
|
304
|
+
GATE_SETUP = """
|
|
305
|
+
import { createSettingsHandlers } from "./plugins/opencode/index.js";
|
|
306
|
+
import { createHookBus } from "./plugins/opencode/hook-bus.js";
|
|
307
|
+
const calls = [];
|
|
308
|
+
const okImpl = async (tool, args, root) => {
|
|
309
|
+
calls.push({tool, args});
|
|
310
|
+
return {content: JSON.stringify({status: "updated", config: {mode: "audit"}, hash: "h1"})};
|
|
311
|
+
};
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def test_precommit_deny_blocks_write(self):
|
|
315
|
+
res = run_node(
|
|
316
|
+
self.GATE_SETUP
|
|
317
|
+
+ """
|
|
318
|
+
const bus = createHookBus();
|
|
319
|
+
bus.on("pre-commit", () => "deny", {tier: "fast"});
|
|
320
|
+
const h = createSettingsHandlers({root: "/tmp/oc-gate-deny", callMcpImpl: okImpl, hookBus: bus});
|
|
321
|
+
let rpcType = null;
|
|
322
|
+
let data = null;
|
|
323
|
+
try {
|
|
324
|
+
await h.set({updates: {mode: "audit"}}, {});
|
|
325
|
+
} catch (err) { rpcType = err.rpcType; data = err.rpcData; }
|
|
326
|
+
const audit = bus.getAuditLog();
|
|
327
|
+
console.log(JSON.stringify({
|
|
328
|
+
rpcType, written: data && data.written,
|
|
329
|
+
mcpCalls: calls.length,
|
|
330
|
+
gateAudited: audit.some((a) => a.event === "pre-commit" && a.outcome === "deny")
|
|
331
|
+
}));
|
|
332
|
+
"""
|
|
333
|
+
)
|
|
334
|
+
self.assertEqual(res.returncode, 0, f"gate-deny test failed: {res.stderr}")
|
|
335
|
+
data = last_json_object(res.stdout)
|
|
336
|
+
self.assertEqual(data["rpcType"], "invalid")
|
|
337
|
+
self.assertFalse(data["written"])
|
|
338
|
+
self.assertEqual(data["mcpCalls"], 0, "denied write must not reach MCP")
|
|
339
|
+
self.assertTrue(data["gateAudited"])
|
|
340
|
+
|
|
341
|
+
def test_precommit_allow_writes_and_stale_guard_preserved(self):
|
|
342
|
+
# Allow proceeds to the MCP write; a stale MCP verdict still surfaces
|
|
343
|
+
# as `stale` (nothing written) — the expected-hash guard is intact.
|
|
344
|
+
res = run_node(
|
|
345
|
+
self.GATE_SETUP
|
|
346
|
+
+ """
|
|
347
|
+
const bus = createHookBus();
|
|
348
|
+
bus.on("pre-commit", () => "allow", {tier: "fast"});
|
|
349
|
+
const h = createSettingsHandlers({root: "/tmp/oc-gate-allow", callMcpImpl: okImpl, hookBus: bus});
|
|
350
|
+
const ok = await h.set({updates: {mode: "audit"}}, {});
|
|
351
|
+
const staleImpl = async () => ({content: JSON.stringify({
|
|
352
|
+
status: "stale", expected_hash: "e", current_hash: "c", config: {}
|
|
353
|
+
})});
|
|
354
|
+
const h2 = createSettingsHandlers({root: "/tmp/oc-gate-stale", callMcpImpl: staleImpl, hookBus: bus});
|
|
355
|
+
let staleType = null;
|
|
356
|
+
let staleWritten = null;
|
|
357
|
+
try {
|
|
358
|
+
await h2.set({updates: {mode: "audit"}, expected_hash: "abcdef12"}, {});
|
|
359
|
+
} catch (err) { staleType = err.rpcType; staleWritten = err.rpcData && err.rpcData.written; }
|
|
360
|
+
console.log(JSON.stringify({
|
|
361
|
+
wrote: ok.config.mode, hash: ok.hash, staleType, staleWritten
|
|
362
|
+
}));
|
|
363
|
+
"""
|
|
364
|
+
)
|
|
365
|
+
self.assertEqual(res.returncode, 0, f"gate-allow test failed: {res.stderr}")
|
|
366
|
+
data = last_json_object(res.stdout)
|
|
367
|
+
self.assertEqual(data["wrote"], "audit")
|
|
368
|
+
self.assertEqual(data["hash"], "h1")
|
|
369
|
+
self.assertEqual(data["staleType"], "stale")
|
|
370
|
+
self.assertFalse(data["staleWritten"])
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class TestHookBusRegistrarAdopt(unittest.TestCase):
|
|
374
|
+
def test_setup_adopts_bus_alongside_existing(self):
|
|
375
|
+
# The bus is adopted with the same adopt/best-effort pattern as the
|
|
376
|
+
# existing four registrations; cleanup disposes (detaches) it.
|
|
377
|
+
res = run_node(
|
|
378
|
+
"""
|
|
379
|
+
import plugin, { hookBus } from "./plugins/opencode/index.js";
|
|
380
|
+
const addedCommands = [];
|
|
381
|
+
const addedTools = [];
|
|
382
|
+
const host = {
|
|
383
|
+
options: {},
|
|
384
|
+
command: {
|
|
385
|
+
list: async () => ({data: []}),
|
|
386
|
+
transform: async (fn) => { fn({add: (d) => addedCommands.push(d)}); return {dispose: async () => {}}; }
|
|
387
|
+
},
|
|
388
|
+
tool: {
|
|
389
|
+
transform: async (fn) => { fn({add: (t) => addedTools.push(t)}); return {dispose: async () => {}}; }
|
|
390
|
+
},
|
|
391
|
+
session: { prompt: async () => ({}) }
|
|
392
|
+
};
|
|
393
|
+
const cleanup = await plugin.setup(host);
|
|
394
|
+
const attached = host.iumbtemsHookBus === hookBus;
|
|
395
|
+
await cleanup();
|
|
396
|
+
console.log(JSON.stringify({
|
|
397
|
+
attached,
|
|
398
|
+
detached: host.iumbtemsHookBus === undefined,
|
|
399
|
+
commands: addedCommands.length,
|
|
400
|
+
tools: addedTools.length
|
|
401
|
+
}));
|
|
402
|
+
"""
|
|
403
|
+
)
|
|
404
|
+
self.assertEqual(res.returncode, 0, f"adopt test failed: {res.stderr}")
|
|
405
|
+
data = last_json_object(res.stdout)
|
|
406
|
+
self.assertTrue(data["attached"], "bus must be adopted on setup")
|
|
407
|
+
self.assertTrue(data["detached"], "cleanup must dispose the bus")
|
|
408
|
+
self.assertGreater(data["commands"], 0, "existing commands still adopted")
|
|
409
|
+
self.assertGreater(data["tools"], 0, "existing tools still adopted")
|
|
410
|
+
|
|
411
|
+
def test_session_start_deny_never_drops_compaction_push(self):
|
|
412
|
+
# `session-start` is advisory: even a deny still pushes state.
|
|
413
|
+
import tempfile
|
|
414
|
+
|
|
415
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
416
|
+
research = Path(tmp) / ".research"
|
|
417
|
+
research.mkdir()
|
|
418
|
+
research.joinpath("config.json").write_text(
|
|
419
|
+
json.dumps({"mode": "audit", "search_engine": "duckduckgo"}),
|
|
420
|
+
encoding="utf-8",
|
|
421
|
+
)
|
|
422
|
+
res = run_node(
|
|
423
|
+
f"""
|
|
424
|
+
import plugin, {{ hookBus }} from "./plugins/opencode/index.js";
|
|
425
|
+
hookBus.on("session-start", () => "deny", {{tier: "fast"}});
|
|
426
|
+
let handler = null;
|
|
427
|
+
const host = {{
|
|
428
|
+
options: {{}},
|
|
429
|
+
location: {{directory: {tmp!r}}},
|
|
430
|
+
command: {{list: async () => ({{data: []}}), transform: async () => ({{dispose: async () => {{}}}})}},
|
|
431
|
+
tool: {{transform: async () => ({{dispose: async () => {{}}}})}},
|
|
432
|
+
session: {{
|
|
433
|
+
prompt: async () => ({{}}),
|
|
434
|
+
hook: async (name, fn) => {{ if (name === "compaction") handler = fn; return {{dispose: async () => {{}}}}; }}
|
|
435
|
+
}}
|
|
436
|
+
}};
|
|
437
|
+
await plugin.setup(host);
|
|
438
|
+
const event = {{system: [], directory: {tmp!r}}};
|
|
439
|
+
if (handler) await handler(event);
|
|
440
|
+
const audit = hookBus.getAuditLog().filter((a) => a.event === "session-start");
|
|
441
|
+
console.log(JSON.stringify({{pushed: event.system.length, audited: audit.length}}));
|
|
442
|
+
"""
|
|
443
|
+
)
|
|
444
|
+
self.assertEqual(res.returncode, 0, f"advisory test failed: {res.stderr}")
|
|
445
|
+
data = last_json_object(res.stdout)
|
|
446
|
+
self.assertEqual(data["pushed"], 1, "deny must not drop the state push")
|
|
447
|
+
self.assertGreaterEqual(data["audited"], 1, "verdict must be audit-logged")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class TestParityDocPointers(unittest.TestCase):
|
|
451
|
+
def test_every_pointer_resolves(self):
|
|
452
|
+
# Every parity-table / wire-in row resolves to a real file/line; the
|
|
453
|
+
# gap list names tool.execute.before explicitly.
|
|
454
|
+
doc = (PROJECT_ROOT / "docs" / "HOOK_BUS_PARITY.md").read_text(encoding="utf-8")
|
|
455
|
+
self.assertIn("tool.execute.before", doc)
|
|
456
|
+
refs = re.findall(
|
|
457
|
+
r"(plugins/[A-Za-z0-9_./-]+|runner/[A-Za-z0-9_./-]+):(\d+)", doc
|
|
458
|
+
)
|
|
459
|
+
self.assertGreater(len(refs), 0, "parity doc must carry file:line pointers")
|
|
460
|
+
for path, line in sorted(set(refs)):
|
|
461
|
+
target = PROJECT_ROOT / path
|
|
462
|
+
self.assertTrue(target.is_file(), f"parity pointer missing file: {path}")
|
|
463
|
+
total = sum(1 for _ in target.open(encoding="utf-8"))
|
|
464
|
+
self.assertTrue(
|
|
465
|
+
1 <= int(line) <= total,
|
|
466
|
+
f"parity pointer out of range: {path}:{line} (file has {total} lines)",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class TestHookBusFixRound(unittest.TestCase):
|
|
471
|
+
# QA-B fix-round pins: malformed-verdict fallback tags (FAIL 3a),
|
|
472
|
+
# always-present tool/eventId keys (FAIL 3b), ring-cap drop counter +
|
|
473
|
+
# clear tombstone (FAIL 3c), direct-fs pre-commit gate (FAIL 1),
|
|
474
|
+
# plugin-tool pre/post-tool-use host emits (FAIL 2), and the
|
|
475
|
+
# caller-latency contract (CONDITIONAL note). All fast: small budgets,
|
|
476
|
+
# sync handlers, one MCP spawn at most.
|
|
477
|
+
|
|
478
|
+
def test_malformed_verdict_tagged_fallback(self):
|
|
479
|
+
res = run_node(
|
|
480
|
+
BUS_IMPORT
|
|
481
|
+
+ """
|
|
482
|
+
const bus = createHookBus();
|
|
483
|
+
bus.on("pre-tool-use", () => 42, {tier: "fast", name: "num"});
|
|
484
|
+
bus.on("pre-tool-use", () => ({verdict: "bogus"}), {tier: "fast", name: "obj"});
|
|
485
|
+
bus.on("pre-tool-use", () => ["deny"], {tier: "slow", name: "arr"});
|
|
486
|
+
bus.on("pre-tool-use", () => "needs review", {tier: "slow", name: "reason"});
|
|
487
|
+
bus.on("pre-tool-use", () => ({}), {tier: "fast", name: "emptyobj"});
|
|
488
|
+
const r = await bus.emit("pre-tool-use", {tool: "webfetch", eventId: "e1"});
|
|
489
|
+
console.log(JSON.stringify({allowed: r.allowed, audit: bus.getAuditLog()}));
|
|
490
|
+
"""
|
|
491
|
+
)
|
|
492
|
+
self.assertEqual(res.returncode, 0, f"fallback-tag test failed: {res.stderr}")
|
|
493
|
+
data = last_json_object(res.stdout)
|
|
494
|
+
self.assertTrue(data["allowed"], "malformed verdicts must fail open")
|
|
495
|
+
self.assertEqual(len(data["audit"]), 5)
|
|
496
|
+
for entry in data["audit"]:
|
|
497
|
+
self.assertTrue(
|
|
498
|
+
entry.get("fallback"), f"malformed verdict not tagged: {entry}"
|
|
499
|
+
)
|
|
500
|
+
self.assertTrue(
|
|
501
|
+
entry.get("note"), f"malformed verdict has no note: {entry}"
|
|
502
|
+
)
|
|
503
|
+
self.assertEqual(entry["verdict"], "allow")
|
|
504
|
+
by_name = {a["handler"]: a for a in data["audit"]}
|
|
505
|
+
self.assertEqual(by_name["reason"].get("reason"), "needs review")
|
|
506
|
+
|
|
507
|
+
def test_audit_always_carries_tool_and_eventId(self):
|
|
508
|
+
res = run_node(
|
|
509
|
+
BUS_IMPORT
|
|
510
|
+
+ """
|
|
511
|
+
const bus = createHookBus();
|
|
512
|
+
bus.on("stop", () => "allow", {tier: "fast", name: "w"});
|
|
513
|
+
await bus.emit("stop", {});
|
|
514
|
+
await bus.emit("not-an-event", {});
|
|
515
|
+
console.log(JSON.stringify({audit: bus.getAuditLog()}));
|
|
516
|
+
"""
|
|
517
|
+
)
|
|
518
|
+
self.assertEqual(res.returncode, 0, f"audit-keys test failed: {res.stderr}")
|
|
519
|
+
data = last_json_object(res.stdout)
|
|
520
|
+
self.assertGreater(len(data["audit"]), 0)
|
|
521
|
+
for entry in data["audit"]:
|
|
522
|
+
self.assertIn("tool", entry, f"audit entry missing tool key: {entry}")
|
|
523
|
+
self.assertIn("eventId", entry, f"audit entry missing eventId key: {entry}")
|
|
524
|
+
self.assertIsNone(entry["tool"])
|
|
525
|
+
self.assertIsNone(entry["eventId"])
|
|
526
|
+
|
|
527
|
+
def test_ring_cap_drop_counter(self):
|
|
528
|
+
res = run_node(
|
|
529
|
+
BUS_IMPORT
|
|
530
|
+
+ """
|
|
531
|
+
const bus = createHookBus();
|
|
532
|
+
bus.on("stop", () => "allow", {tier: "fast"});
|
|
533
|
+
for (let i = 0; i < 1002; i++) {
|
|
534
|
+
await bus.emit("stop", {eventId: "e" + i});
|
|
535
|
+
}
|
|
536
|
+
const stats = bus.getAuditStats();
|
|
537
|
+
console.log(JSON.stringify({
|
|
538
|
+
entries: bus.getAuditLog().length,
|
|
539
|
+
dropped: stats.dropped,
|
|
540
|
+
capacity: stats.capacity
|
|
541
|
+
}));
|
|
542
|
+
"""
|
|
543
|
+
)
|
|
544
|
+
self.assertEqual(res.returncode, 0, f"drop-counter test failed: {res.stderr}")
|
|
545
|
+
data = last_json_object(res.stdout)
|
|
546
|
+
self.assertEqual(data["entries"], 1000)
|
|
547
|
+
self.assertEqual(data["dropped"], 2)
|
|
548
|
+
self.assertEqual(data["capacity"], 1000)
|
|
549
|
+
|
|
550
|
+
def test_clear_audit_log_writes_tombstone(self):
|
|
551
|
+
res = run_node(
|
|
552
|
+
BUS_IMPORT
|
|
553
|
+
+ """
|
|
554
|
+
const bus = createHookBus();
|
|
555
|
+
bus.on("stop", () => "allow", {tier: "fast"});
|
|
556
|
+
await bus.emit("stop", {});
|
|
557
|
+
bus.clearAuditLog();
|
|
558
|
+
const log = bus.getAuditLog();
|
|
559
|
+
console.log(JSON.stringify({len: log.length, entry: log[0]}));
|
|
560
|
+
"""
|
|
561
|
+
)
|
|
562
|
+
self.assertEqual(res.returncode, 0, f"tombstone test failed: {res.stderr}")
|
|
563
|
+
data = last_json_object(res.stdout)
|
|
564
|
+
self.assertEqual(data["len"], 1)
|
|
565
|
+
entry = data["entry"]
|
|
566
|
+
self.assertEqual(entry["event"], "audit-clear")
|
|
567
|
+
self.assertTrue(entry.get("fallback"))
|
|
568
|
+
self.assertIn("1 entries discarded", entry.get("note", ""))
|
|
569
|
+
self.assertIn("tool", entry)
|
|
570
|
+
self.assertIn("eventId", entry)
|
|
571
|
+
|
|
572
|
+
def test_emit_latency_bounded_by_slow_budget(self):
|
|
573
|
+
# CONDITIONAL note: a hung slow handler delays the caller by at most
|
|
574
|
+
# the slow budget — never indefinitely.
|
|
575
|
+
res = run_node(
|
|
576
|
+
BUS_IMPORT
|
|
577
|
+
+ """
|
|
578
|
+
const bus = createHookBus();
|
|
579
|
+
bus.on("stop", () => new Promise(() => {}), {tier: "slow", name: "hung"});
|
|
580
|
+
const t0 = Date.now();
|
|
581
|
+
const r = await bus.emit("stop", {}, {slowTimeoutMs: 120});
|
|
582
|
+
const elapsed = Date.now() - t0;
|
|
583
|
+
const audit = bus.getAuditLog();
|
|
584
|
+
console.log(JSON.stringify({
|
|
585
|
+
allowed: r.allowed, elapsed,
|
|
586
|
+
outcomes: audit.map((a) => a.outcome)
|
|
587
|
+
}));
|
|
588
|
+
"""
|
|
589
|
+
)
|
|
590
|
+
self.assertEqual(res.returncode, 0, f"latency-bound test failed: {res.stderr}")
|
|
591
|
+
data = last_json_object(res.stdout)
|
|
592
|
+
self.assertTrue(data["allowed"], "hung slow handler must fail open")
|
|
593
|
+
self.assertLess(data["elapsed"], 2000, "emit must resolve near the slow budget")
|
|
594
|
+
self.assertIn("timeout", data["outcomes"])
|
|
595
|
+
|
|
596
|
+
def test_direct_fs_gate_deny_blocks_and_audits(self):
|
|
597
|
+
# FAIL 1: the direct-fs fallback leg gates on pre-commit too.
|
|
598
|
+
res = run_node(
|
|
599
|
+
"""
|
|
600
|
+
import { gateDirectFsPreCommit } from "./plugins/opencode/tui.js";
|
|
601
|
+
import { createHookBus } from "./plugins/opencode/hook-bus.js";
|
|
602
|
+
const bus = createHookBus();
|
|
603
|
+
bus.on("pre-commit", () => "deny", {tier: "fast"});
|
|
604
|
+
const denied = await gateDirectFsPreCommit(bus, {
|
|
605
|
+
draft: {mode: "audit"}, baseDir: "/tmp/oc-fs-gate", guard: {expectedHash: "abcdef12"}
|
|
606
|
+
});
|
|
607
|
+
const open = await gateDirectFsPreCommit(null, {draft: {}, baseDir: "/tmp/oc-fs-gate"});
|
|
608
|
+
const audit = bus.getAuditLog();
|
|
609
|
+
console.log(JSON.stringify({
|
|
610
|
+
denied: denied.allowed, open: open.allowed,
|
|
611
|
+
audited: audit.some((a) => a.event === "pre-commit" && a.outcome === "deny")
|
|
612
|
+
}));
|
|
613
|
+
"""
|
|
614
|
+
)
|
|
615
|
+
self.assertEqual(res.returncode, 0, f"direct-fs gate test failed: {res.stderr}")
|
|
616
|
+
data = last_json_object(res.stdout)
|
|
617
|
+
self.assertFalse(data["denied"], "direct-fs deny must block")
|
|
618
|
+
self.assertTrue(data["open"], "null bus must fail open")
|
|
619
|
+
self.assertTrue(
|
|
620
|
+
data["audited"], "denied fallback write must leave an audit entry"
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def test_direct_fs_stale_guard_preserved(self):
|
|
624
|
+
# FAIL 1: the gate does NOT replace the stale-write guard — a stale
|
|
625
|
+
# expected hash still refuses the write via saveConfig (CONFIG_STALE).
|
|
626
|
+
res = run_node(
|
|
627
|
+
"""
|
|
628
|
+
import fs from "node:fs";
|
|
629
|
+
import os from "node:os";
|
|
630
|
+
import path from "node:path";
|
|
631
|
+
import { saveConfig, loadConfig, configHash } from "./plugins/opencode/config-io.js";
|
|
632
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-fs-stale-"));
|
|
633
|
+
const seed = loadConfig(dir);
|
|
634
|
+
saveConfig({...seed}, dir, {});
|
|
635
|
+
let staleCode = null;
|
|
636
|
+
try {
|
|
637
|
+
saveConfig({...seed, mode: "scout"}, dir, {expected_hash: "00000000"});
|
|
638
|
+
} catch (err) { staleCode = err.code; }
|
|
639
|
+
const h = configHash(dir);
|
|
640
|
+
const second = saveConfig({...seed}, dir, {expected_hash: h});
|
|
641
|
+
console.log(JSON.stringify({staleCode, wrote: typeof second.hash === "string"}));
|
|
642
|
+
"""
|
|
643
|
+
)
|
|
644
|
+
self.assertEqual(res.returncode, 0, f"stale-guard test failed: {res.stderr}")
|
|
645
|
+
data = last_json_object(res.stdout)
|
|
646
|
+
self.assertEqual(data["staleCode"], "CONFIG_STALE")
|
|
647
|
+
self.assertTrue(data["wrote"])
|
|
648
|
+
|
|
649
|
+
def test_shipped_path_threads_bus_rpc_null_unthreaded(self):
|
|
650
|
+
# qa-b bypass repro: the SHIPPED TUI path is RPC-null with no manual
|
|
651
|
+
# threading — a denying pre-commit handler must still deny the write
|
|
652
|
+
# AND leave a pre-commit audit entry (previously: write landed, 0
|
|
653
|
+
# audits — fail-open on missing wiring). Stale-guard coverage lives in
|
|
654
|
+
# test_direct_fs_stale_guard_preserved.
|
|
655
|
+
import tempfile
|
|
656
|
+
|
|
657
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
658
|
+
shipped = Path(tmp) / "shipped.js"
|
|
659
|
+
shipped_body = """
|
|
660
|
+
import fs from "node:fs";
|
|
661
|
+
import os from "node:os";
|
|
662
|
+
import path from "node:path";
|
|
663
|
+
import { hookBus } from "__INDEX__";
|
|
664
|
+
import { openSettingsWizard, setupTui, registerWizardSlashCommand } from "__TUI__";
|
|
665
|
+
function mockApi(dir, selectQueue) {
|
|
666
|
+
const toasts = [];
|
|
667
|
+
const q = [...selectQueue];
|
|
668
|
+
return {
|
|
669
|
+
api: {
|
|
670
|
+
directory: dir,
|
|
671
|
+
ui: {
|
|
672
|
+
toast: { show: (t) => toasts.push(t) },
|
|
673
|
+
dialog: {
|
|
674
|
+
select: async () => (q.length ? q.shift() : null),
|
|
675
|
+
prompt: async () => null,
|
|
676
|
+
confirm: async () => true,
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
command: {
|
|
680
|
+
list: async () => ({data: []}),
|
|
681
|
+
transform: async (fn) => {
|
|
682
|
+
const adds = [];
|
|
683
|
+
fn({add: (d) => adds.push(d)});
|
|
684
|
+
return {dispose: async () => {}, adds};
|
|
685
|
+
},
|
|
686
|
+
reload: async () => {},
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
toasts,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
// Leg 1: direct shipped call — openSettingsWizard(api, dir)
|
|
693
|
+
// with NO opts and NO preset host bus (RPC-null).
|
|
694
|
+
const dir1 = fs.mkdtempSync(path.join(os.tmpdir(), "oc-shipped1-"));
|
|
695
|
+
const m1 = mockApi(dir1, ["__iumbtems_save__"]);
|
|
696
|
+
setupTui(m1.api);
|
|
697
|
+
const attached = m1.api.iumbtemsHookBus === hookBus;
|
|
698
|
+
const mark1 = hookBus.getAuditLog().length;
|
|
699
|
+
const off = hookBus.on("pre-commit", () => "deny", {tier: "fast", name: "shipped-deny"});
|
|
700
|
+
let r1 = null;
|
|
701
|
+
try { r1 = await openSettingsWizard(m1.api, dir1); }
|
|
702
|
+
finally { /* keep handler for leg 2 */ }
|
|
703
|
+
// Leg 2: slash-command shipped path — execute() captured from
|
|
704
|
+
// registerWizardSlashCommand (previously called the wizard
|
|
705
|
+
// with NO opts).
|
|
706
|
+
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "oc-shipped2-"));
|
|
707
|
+
const m2 = mockApi(dir2, ["__iumbtems_save__"]);
|
|
708
|
+
setupTui(m2.api);
|
|
709
|
+
const reg = await registerWizardSlashCommand(m2.api);
|
|
710
|
+
const adds = (reg && reg.adds) || [];
|
|
711
|
+
const slash = adds.find((d) => d.name === "swarm-config");
|
|
712
|
+
const mark2 = hookBus.getAuditLog().length;
|
|
713
|
+
if (slash) await slash.execute();
|
|
714
|
+
off();
|
|
715
|
+
const audit = hookBus.getAuditLog();
|
|
716
|
+
const denied1 = audit.slice(mark1).some((a) => a.event === "pre-commit" && a.outcome === "deny");
|
|
717
|
+
const denied2 = audit.slice(mark2).some((a) => a.event === "pre-commit" && a.outcome === "deny");
|
|
718
|
+
let wrote1 = false;
|
|
719
|
+
try { fs.statSync(path.join(dir1, ".research", "config.json")); wrote1 = true; } catch { wrote1 = false; }
|
|
720
|
+
let wrote2 = false;
|
|
721
|
+
try { fs.statSync(path.join(dir2, ".research", "config.json")); wrote2 = true; } catch { wrote2 = false; }
|
|
722
|
+
console.log(JSON.stringify({
|
|
723
|
+
attached, hasSlash: !!slash,
|
|
724
|
+
ok: r1 && r1.ok, reason: r1 && r1.reason, written: r1 && r1.written,
|
|
725
|
+
denied1, denied2, wrote1, wrote2
|
|
726
|
+
}));
|
|
727
|
+
"""
|
|
728
|
+
shipped_body = shipped_body.replace(
|
|
729
|
+
"__INDEX__", str(PROJECT_ROOT / "plugins" / "opencode" / "index.js")
|
|
730
|
+
).replace("__TUI__", str(PROJECT_ROOT / "plugins" / "opencode" / "tui.js"))
|
|
731
|
+
shipped.write_text(shipped_body, encoding="utf-8")
|
|
732
|
+
res = subprocess.run(
|
|
733
|
+
["node", str(shipped)],
|
|
734
|
+
capture_output=True,
|
|
735
|
+
text=True,
|
|
736
|
+
cwd=str(PROJECT_ROOT),
|
|
737
|
+
)
|
|
738
|
+
self.assertEqual(
|
|
739
|
+
res.returncode, 0, f"shipped-path test failed: {res.stderr}"
|
|
740
|
+
)
|
|
741
|
+
data = last_json_object(res.stdout)
|
|
742
|
+
self.assertTrue(data["attached"], "setupTui must adopt the shared bus")
|
|
743
|
+
self.assertTrue(data["hasSlash"], "slash command must be registered")
|
|
744
|
+
self.assertFalse(data["ok"], "shipped denying write must not report ok")
|
|
745
|
+
self.assertEqual(data["reason"], "pre-commit-denied")
|
|
746
|
+
self.assertFalse(data["written"])
|
|
747
|
+
self.assertTrue(data["denied1"], "shipped direct call must audit the deny")
|
|
748
|
+
self.assertTrue(
|
|
749
|
+
data["denied2"], "shipped slash execute must audit the deny"
|
|
750
|
+
)
|
|
751
|
+
self.assertFalse(
|
|
752
|
+
data["wrote1"], "denied shipped write must not land (leg 1)"
|
|
753
|
+
)
|
|
754
|
+
self.assertFalse(
|
|
755
|
+
data["wrote2"], "denied shipped write must not land (leg 2)"
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
def test_plugin_tool_pre_and_post_gates(self):
|
|
759
|
+
# FAIL 2: host emit sites for pre/post-tool-use in the plugin-tool
|
|
760
|
+
# execute leg — deny blocks execution / suppresses delivery, shape
|
|
761
|
+
# stays `{content}`-only either way.
|
|
762
|
+
res = run_node(
|
|
763
|
+
"""
|
|
764
|
+
import plugin, { hookBus } from "./plugins/opencode/index.js";
|
|
765
|
+
let toolExec = null;
|
|
766
|
+
const host = {
|
|
767
|
+
options: {},
|
|
768
|
+
command: {
|
|
769
|
+
list: async () => ({data: []}),
|
|
770
|
+
transform: async () => ({dispose: async () => {}})
|
|
771
|
+
},
|
|
772
|
+
tool: {
|
|
773
|
+
transform: async (fn) => {
|
|
774
|
+
const adds = [];
|
|
775
|
+
fn({add: (t) => adds.push(t)});
|
|
776
|
+
toolExec = adds.find((t) => t.name === "iumbtems_config").execute;
|
|
777
|
+
return {dispose: async () => {}};
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
session: { prompt: async () => ({}) }
|
|
781
|
+
};
|
|
782
|
+
const cleanup = await plugin.setup(host);
|
|
783
|
+
const offPre = hookBus.on("pre-tool-use", () => "deny", {tier: "fast"});
|
|
784
|
+
let blocked = null;
|
|
785
|
+
try { blocked = await toolExec({}, {}); } finally { offPre(); }
|
|
786
|
+
const offPost = hookBus.on("post-tool-use", () => "deny", {tier: "fast"});
|
|
787
|
+
let suppressed = null;
|
|
788
|
+
try { suppressed = await toolExec({}, {}); } finally { offPost(); }
|
|
789
|
+
await cleanup();
|
|
790
|
+
console.log(JSON.stringify({blocked, suppressed}));
|
|
791
|
+
"""
|
|
792
|
+
)
|
|
793
|
+
self.assertEqual(res.returncode, 0, f"tool-gate test failed: {res.stderr}")
|
|
794
|
+
data = last_json_object(res.stdout)
|
|
795
|
+
self.assertEqual(sorted(data["blocked"].keys()), ["content"])
|
|
796
|
+
self.assertIn("denied by pre-tool-use", data["blocked"]["content"])
|
|
797
|
+
self.assertEqual(sorted(data["suppressed"].keys()), ["content"])
|
|
798
|
+
self.assertIn("suppressed by post-tool-use", data["suppressed"]["content"])
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
if __name__ == "__main__":
|
|
802
|
+
unittest.main()
|