@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
|
@@ -60,6 +60,19 @@ VALIDATION_CASES = json.loads(
|
|
|
60
60
|
|
|
61
61
|
CONTROL_ARGS = set(CONFIG_TOOL_CONTROL_ARGS)
|
|
62
62
|
|
|
63
|
+
# H1: every keyword the parity comparator deep-compares at each schema node.
|
|
64
|
+
# `items` / `additionalProperties` recurse as schema nodes; `default` compares
|
|
65
|
+
# by value (deep: dict/list defaults compare element-wise via !=).
|
|
66
|
+
COMPARE_KEYWORDS = (
|
|
67
|
+
"type",
|
|
68
|
+
"enum",
|
|
69
|
+
"minimum",
|
|
70
|
+
"maximum",
|
|
71
|
+
"minLength",
|
|
72
|
+
"pattern",
|
|
73
|
+
"default",
|
|
74
|
+
)
|
|
75
|
+
|
|
63
76
|
# One representative valid value per canonical key; the test asserts this map
|
|
64
77
|
# covers the schema exactly, so a new key cannot slip in without a test.
|
|
65
78
|
VALID_UPDATES = {
|
|
@@ -221,27 +234,45 @@ class TestMcpConfigSurface(unittest.TestCase):
|
|
|
221
234
|
advertised["properties"]["expectedHash"]["description"],
|
|
222
235
|
)
|
|
223
236
|
|
|
237
|
+
def _compare_schema_node(self, sub, advertised_node, path, problems):
|
|
238
|
+
"""Deep-compare one schema node, recursing into subschemas.
|
|
239
|
+
|
|
240
|
+
H1: besides the scalar keywords, `properties` recurses per key,
|
|
241
|
+
`items` recurses as `{path}[]`, and `additionalProperties` recurses
|
|
242
|
+
as `{path}.*`. A canonical subschema missing on the advertised side
|
|
243
|
+
is a drift report, never a silent skip.
|
|
244
|
+
"""
|
|
245
|
+
for keyword in COMPARE_KEYWORDS:
|
|
246
|
+
if sub.get(keyword) != advertised_node.get(keyword):
|
|
247
|
+
problems.append(
|
|
248
|
+
f"{path}: {keyword} {sub.get(keyword)!r} != "
|
|
249
|
+
f"{advertised_node.get(keyword)!r}"
|
|
250
|
+
)
|
|
251
|
+
if isinstance(sub.get("properties"), dict):
|
|
252
|
+
if not isinstance(advertised_node.get("properties"), dict):
|
|
253
|
+
problems.append(f"{path}: properties schema missing on advertised side")
|
|
254
|
+
else:
|
|
255
|
+
self._compare_properties(sub, advertised_node, f"{path}.", problems)
|
|
256
|
+
for keyword, suffix in (("items", "[]"), ("additionalProperties", ".*")):
|
|
257
|
+
sub_node = sub.get(keyword)
|
|
258
|
+
if isinstance(sub_node, dict):
|
|
259
|
+
adv_node = advertised_node.get(keyword)
|
|
260
|
+
if not isinstance(adv_node, dict):
|
|
261
|
+
problems.append(
|
|
262
|
+
f"{path}{suffix}: {keyword} schema missing on advertised side"
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
self._compare_schema_node(
|
|
266
|
+
sub_node, adv_node, f"{path}{suffix}", problems
|
|
267
|
+
)
|
|
268
|
+
|
|
224
269
|
def _compare_properties(self, canonical, advertised, path, problems):
|
|
225
270
|
for key, sub in (canonical.get("properties") or {}).items():
|
|
226
271
|
adv = (advertised.get("properties") or {}).get(key)
|
|
227
272
|
if adv is None:
|
|
228
273
|
problems.append(f"missing advertised key: {path}{key}")
|
|
229
274
|
continue
|
|
230
|
-
|
|
231
|
-
"type",
|
|
232
|
-
"enum",
|
|
233
|
-
"minimum",
|
|
234
|
-
"maximum",
|
|
235
|
-
"minLength",
|
|
236
|
-
"pattern",
|
|
237
|
-
):
|
|
238
|
-
if sub.get(keyword) != adv.get(keyword):
|
|
239
|
-
problems.append(
|
|
240
|
-
f"{path}{key}: {keyword} {sub.get(keyword)!r} != "
|
|
241
|
-
f"{adv.get(keyword)!r}"
|
|
242
|
-
)
|
|
243
|
-
if isinstance(sub.get("properties"), dict):
|
|
244
|
-
self._compare_properties(sub, adv, f"{path}{key}.", problems)
|
|
275
|
+
self._compare_schema_node(sub, adv, f"{path}{key}", problems)
|
|
245
276
|
|
|
246
277
|
def test_parity_comparator_is_not_vacuous(self):
|
|
247
278
|
"""R2: the comparator must bite when the advertised surface drifts."""
|
|
@@ -264,6 +295,79 @@ class TestMcpConfigSurface(unittest.TestCase):
|
|
|
264
295
|
self._compare_properties(artifact, drifted, "", problems)
|
|
265
296
|
self.assertTrue(any("searxng_url: pattern" in p for p in problems))
|
|
266
297
|
|
|
298
|
+
def test_parity_comparator_catches_items_additionalProperties_default_drift(self):
|
|
299
|
+
"""H1: each newly walked keyword bites when the advertised side drifts.
|
|
300
|
+
|
|
301
|
+
Every sub-case mutates a FRESH advertised schema exactly once, so a
|
|
302
|
+
green sub-case proves its own keyword is compared (never vacuous via
|
|
303
|
+
a shared broken fixture).
|
|
304
|
+
"""
|
|
305
|
+
from runner.mcp_server import config_tool_input_schema
|
|
306
|
+
|
|
307
|
+
artifact = json.loads(
|
|
308
|
+
(PROJECT_ROOT / "schemas" / "config.schema.json").read_text(
|
|
309
|
+
encoding="utf-8"
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def complained_of(mutator, needle):
|
|
314
|
+
advertised = config_tool_input_schema()
|
|
315
|
+
mutator(advertised["properties"])
|
|
316
|
+
problems = []
|
|
317
|
+
self._compare_properties(artifact, advertised, "", problems)
|
|
318
|
+
self.assertTrue(
|
|
319
|
+
any(needle in p for p in problems),
|
|
320
|
+
f"{needle!r} not in {problems}",
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# `items` drift: top-level (license_whitelist) and nested
|
|
324
|
+
# (agents.alpha.backend); then a dropped `items` subschema.
|
|
325
|
+
complained_of(
|
|
326
|
+
lambda props: props["license_whitelist"].__setitem__(
|
|
327
|
+
"items", {"type": "integer"}
|
|
328
|
+
),
|
|
329
|
+
"license_whitelist[]: type",
|
|
330
|
+
)
|
|
331
|
+
complained_of(
|
|
332
|
+
lambda props: props["agents"]["properties"]["alpha"]["properties"][
|
|
333
|
+
"backend"
|
|
334
|
+
].__setitem__("items", {"type": "integer"}),
|
|
335
|
+
"agents.alpha.backend[]: type",
|
|
336
|
+
)
|
|
337
|
+
complained_of(
|
|
338
|
+
lambda props: props["license_whitelist"].pop("items"),
|
|
339
|
+
"license_whitelist[]: items schema missing",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# `additionalProperties` drift (mcp_servers) and a dropped subschema.
|
|
343
|
+
complained_of(
|
|
344
|
+
lambda props: props["mcp_servers"].__setitem__(
|
|
345
|
+
"additionalProperties", {"type": "string"}
|
|
346
|
+
),
|
|
347
|
+
"mcp_servers.*: type",
|
|
348
|
+
)
|
|
349
|
+
complained_of(
|
|
350
|
+
lambda props: props["mcp_servers"].pop("additionalProperties"),
|
|
351
|
+
"mcp_servers.*: additionalProperties schema missing",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# `default` drift: top-level (mode) and nested
|
|
355
|
+
# (verify.min_fuzzy_confidence); then a dropped `default`.
|
|
356
|
+
complained_of(
|
|
357
|
+
lambda props: props["mode"].__setitem__("default", "scout"),
|
|
358
|
+
"mode: default",
|
|
359
|
+
)
|
|
360
|
+
complained_of(
|
|
361
|
+
lambda props: props["verify"]["properties"][
|
|
362
|
+
"min_fuzzy_confidence"
|
|
363
|
+
].__setitem__("default", 0.5),
|
|
364
|
+
"verify.min_fuzzy_confidence: default",
|
|
365
|
+
)
|
|
366
|
+
complained_of(
|
|
367
|
+
lambda props: props["mode"].pop("default"),
|
|
368
|
+
"mode: default",
|
|
369
|
+
)
|
|
370
|
+
|
|
267
371
|
def test_valid_update_map_covers_every_canonical_key(self):
|
|
268
372
|
self.assertEqual(set(VALID_UPDATES), set(CONFIG["properties"]))
|
|
269
373
|
self.assertEqual(set(INVALID_UPDATES), set(CONFIG["properties"]))
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for Phase 03-dep-health-gate: programmer-time dep warnings + QA gate.
|
|
3
|
+
|
|
4
|
+
Mock-host only (no live TUI, no real npm/OSV): every runner is injected,
|
|
5
|
+
every budget is tens of milliseconds, at most zero MCP spawns. Pins:
|
|
6
|
+
never-blocks (critical-CVE fixture still allows), offline-unavailable-recorded,
|
|
7
|
+
absent-pass, missing-report-fail, unrecorded-findings-fail, annotation key
|
|
8
|
+
completeness (eleven always-present keys), OSV timeout bound, the repo
|
|
9
|
+
baseline shape (npm scanned, pip absent, 2x :latest advisories, actions
|
|
10
|
+
clean), and the setup adopt/dispose wiring in plugins/opencode/index.js.
|
|
11
|
+
|
|
12
|
+
Phase evidence cited in-code per brief:
|
|
13
|
+
[VERIFIED: sha256:7690b878a654ee4dee2b649b60740bc95d4b746cf786051887153b6f656f2e5e]
|
|
14
|
+
[VERIFIED: sha256:1fd0c2b8b0d37d553f1f841cea4e4b60d92cdf873257172a726d482954dddf8f]
|
|
15
|
+
[VERIFIED: sha256:7b62111192e07c73547a125eeab7ff98f2d1305a04149f1ab51c66a41a3e3f95]
|
|
16
|
+
[VERIFIED: sha256:c381a646894ce66669432e847f0afd647ea5ff00ac64a01cf91f325f5515e601]
|
|
17
|
+
[VERIFIED: sha256:ae16788a0a85caed3983dbc70455eaa05857827c837a5f5999242ef32b0a16a7]
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import unittest
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
27
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
28
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_node(code):
|
|
32
|
+
return subprocess.run(
|
|
33
|
+
["node", "--input-type=module", "-e", code],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
cwd=str(PROJECT_ROOT),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def last_json_object(stdout):
|
|
41
|
+
lines = [
|
|
42
|
+
line.strip()
|
|
43
|
+
for line in stdout.strip().split("\n")
|
|
44
|
+
if line.strip().startswith("{")
|
|
45
|
+
]
|
|
46
|
+
return json.loads(lines[-1])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
DEP_IMPORT = (
|
|
50
|
+
'import { createHookBus } from "./plugins/opencode/hook-bus.js";\n'
|
|
51
|
+
'import { checkDeps, depHealthCoverage, createDepHealthHandler, registerDepHealth, toDepFinding } from "./plugins/opencode/dep-health.js";\n'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
MOCK_CLEAN_RUNNERS = """
|
|
55
|
+
const runOutdated = async () => ({status: "ok", json: {}});
|
|
56
|
+
const runAudit = async () => ({status: "ok", json: {advisories: {}}});
|
|
57
|
+
const queryOsv = async () => ({status: "ok", vulns: []});
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
MOCK_CRITICAL_RUNNERS = """
|
|
61
|
+
const runOutdated = async () => ({status: "ok", json: {
|
|
62
|
+
"left-pad": {current: "1.0.0", wanted: "1.3.0", latest: "1.3.0"},
|
|
63
|
+
}});
|
|
64
|
+
const runAudit = async () => ({status: "ok", json: {vulnerabilities: {
|
|
65
|
+
"left-pad": {severity: "critical", range: "1.0.0", fixAvailable: "1.3.0",
|
|
66
|
+
via: [{title: "CVE-2099-0001 prototype pollution", url: "https://example.invalid/cve"}]},
|
|
67
|
+
}}});
|
|
68
|
+
const queryOsv = async () => ({status: "ok", vulns: [
|
|
69
|
+
{id: "GHSA-xxxx-yyyy", summary: "critical RCE",
|
|
70
|
+
affected: [{ranges: [{events: [{introduced: "0"}, {fixed: "2.0.0"}]}]}]},
|
|
71
|
+
]});
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
MOCK_OFFLINE_RUNNERS = """
|
|
75
|
+
const runOutdated = async () => { throw new Error("ENOTFOUND registry.npmjs.org"); };
|
|
76
|
+
const runAudit = async () => ({status: "error", message: "EAI_AGAIN"});
|
|
77
|
+
const queryOsv = async () => ({status: "error", message: "fetch failed"});
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
SETUP_DEP = """
|
|
81
|
+
const bus = createHookBus();
|
|
82
|
+
const notices = [];
|
|
83
|
+
const notify = (s) => { notices.push(s); };
|
|
84
|
+
const reg = await registerDepHealth(null, { bus, runOutdated, runAudit, queryOsv, notify, osvTimeoutMs: 200 });
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestNeverBlocks(unittest.TestCase):
|
|
89
|
+
def test_critical_fixture_still_allows(self):
|
|
90
|
+
res = run_node(
|
|
91
|
+
DEP_IMPORT
|
|
92
|
+
+ MOCK_CRITICAL_RUNNERS
|
|
93
|
+
+ SETUP_DEP
|
|
94
|
+
+ """
|
|
95
|
+
const manifests = { packageJson: JSON.stringify({dependencies: {"left-pad": "1.0.0"}}) };
|
|
96
|
+
const direct = await checkDeps({ manifests, runOutdated, runAudit, queryOsv, osvTimeoutMs: 200, tool: "edit", eventId: "e-crit" });
|
|
97
|
+
const flat = JSON.stringify(direct);
|
|
98
|
+
const r = await bus.emit("pre-tool-use", {tool: "edit", eventId: "e-crit"});
|
|
99
|
+
const audit = bus.getAuditLog();
|
|
100
|
+
const findingAudits = audit.filter((a) => a.tool === "edit" && a.eventId === "e-crit");
|
|
101
|
+
console.log(JSON.stringify({
|
|
102
|
+
allowed: direct.allowed,
|
|
103
|
+
hasDeny: flat.includes("deny"),
|
|
104
|
+
npmStatus: direct.ecosystems.npm.status,
|
|
105
|
+
severities: direct.ecosystems.npm.findings.map((f) => f.severity),
|
|
106
|
+
emitAllowed: r.allowed,
|
|
107
|
+
notices: notices.length,
|
|
108
|
+
notice: notices[0] && notices[0].message,
|
|
109
|
+
findingAudits: findingAudits.length,
|
|
110
|
+
}));
|
|
111
|
+
"""
|
|
112
|
+
)
|
|
113
|
+
self.assertEqual(res.returncode, 0, f"never-blocks test failed: {res.stderr}")
|
|
114
|
+
data = last_json_object(res.stdout)
|
|
115
|
+
self.assertTrue(data["allowed"], "critical CVE must still allow (all-advisory)")
|
|
116
|
+
self.assertFalse(data["hasDeny"], "no deny verdict anywhere in the result")
|
|
117
|
+
self.assertEqual(data["npmStatus"], "scanned")
|
|
118
|
+
self.assertIn(
|
|
119
|
+
"critical", data["severities"], "critical severity must be recorded"
|
|
120
|
+
)
|
|
121
|
+
self.assertTrue(
|
|
122
|
+
data["emitAllowed"], "critical findings must not block the caller"
|
|
123
|
+
)
|
|
124
|
+
self.assertEqual(data["notices"], 1, "exactly one toast summary per scan")
|
|
125
|
+
self.assertIn("finding", data["notice"])
|
|
126
|
+
# Toast summarizes counts only: no package names, no CVE text.
|
|
127
|
+
self.assertNotIn("left-pad", data["notice"])
|
|
128
|
+
self.assertNotIn("CVE-2099-0001", data["notice"])
|
|
129
|
+
self.assertGreaterEqual(
|
|
130
|
+
data["findingAudits"], 2, "findings must be audit-logged on the bus"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestOfflineUnavailable(unittest.TestCase):
|
|
135
|
+
def test_offline_records_unavailable_and_allows(self):
|
|
136
|
+
res = run_node(
|
|
137
|
+
DEP_IMPORT
|
|
138
|
+
+ MOCK_OFFLINE_RUNNERS
|
|
139
|
+
+ SETUP_DEP
|
|
140
|
+
+ """
|
|
141
|
+
const manifests = { packageJson: JSON.stringify({dependencies: {"left-pad": "1.0.0"}}) };
|
|
142
|
+
let threw = false;
|
|
143
|
+
let direct = null;
|
|
144
|
+
try {
|
|
145
|
+
direct = await checkDeps({ manifests, runOutdated, runAudit, queryOsv, osvTimeoutMs: 200 });
|
|
146
|
+
} catch { threw = true; }
|
|
147
|
+
let r = null;
|
|
148
|
+
try {
|
|
149
|
+
r = await bus.emit("pre-tool-use", {tool: "edit", eventId: "e-off"});
|
|
150
|
+
} catch { threw = true; }
|
|
151
|
+
console.log(JSON.stringify({
|
|
152
|
+
threw, allowed: direct && direct.allowed,
|
|
153
|
+
npmStatus: direct && direct.ecosystems.npm.status,
|
|
154
|
+
recorded: direct && direct.ecosystems.npm.findings.length,
|
|
155
|
+
sources: direct && direct.ecosystems.npm.findings.map((f) => f.source),
|
|
156
|
+
emitAllowed: r && r.allowed,
|
|
157
|
+
}));
|
|
158
|
+
"""
|
|
159
|
+
)
|
|
160
|
+
self.assertEqual(res.returncode, 0, f"offline test failed: {res.stderr}")
|
|
161
|
+
data = last_json_object(res.stdout)
|
|
162
|
+
self.assertFalse(data["threw"], "offline run must never throw")
|
|
163
|
+
self.assertTrue(data["allowed"], "offline run must fail open")
|
|
164
|
+
self.assertEqual(data["npmStatus"], "unavailable")
|
|
165
|
+
self.assertGreaterEqual(data["recorded"], 1, "unavailability must be RECORDED")
|
|
166
|
+
self.assertIn("unavailable", data["sources"])
|
|
167
|
+
self.assertTrue(data["emitAllowed"])
|
|
168
|
+
# The recorded outage passes the mechanical gate.
|
|
169
|
+
res2 = run_node(
|
|
170
|
+
DEP_IMPORT
|
|
171
|
+
+ """
|
|
172
|
+
const cov = depHealthCoverage({
|
|
173
|
+
npm: {status: "unavailable", findings: [{ecosystem: "npm", source: "unavailable"}]},
|
|
174
|
+
pip: {status: "absent", findings: []},
|
|
175
|
+
containers: {status: "absent", findings: []},
|
|
176
|
+
actions: {status: "absent", findings: []},
|
|
177
|
+
});
|
|
178
|
+
console.log(JSON.stringify({pass: cov.pass, fails: cov.gaps.filter((g) => g.fail)}));
|
|
179
|
+
"""
|
|
180
|
+
)
|
|
181
|
+
self.assertEqual(res2.returncode, 0, f"gate test failed: {res2.stderr}")
|
|
182
|
+
data2 = last_json_object(res2.stdout)
|
|
183
|
+
self.assertTrue(data2["pass"], "unavailable(recorded) must pass the gate")
|
|
184
|
+
self.assertEqual(data2["fails"], [])
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestCoverageMatrix(unittest.TestCase):
|
|
188
|
+
def test_matrix(self):
|
|
189
|
+
res = run_node(
|
|
190
|
+
DEP_IMPORT
|
|
191
|
+
+ """
|
|
192
|
+
const scanned = depHealthCoverage({
|
|
193
|
+
npm: {status: "scanned", findings: []},
|
|
194
|
+
pip: {status: "absent", findings: []},
|
|
195
|
+
containers: {status: "scanned", findings: [{ecosystem: "containers"}]},
|
|
196
|
+
actions: {status: "scanned", findings: []},
|
|
197
|
+
});
|
|
198
|
+
const absentNote = depHealthCoverage({
|
|
199
|
+
npm: {status: "scanned", findings: []},
|
|
200
|
+
pip: {status: "absent", findings: []},
|
|
201
|
+
containers: {status: "absent", findings: []},
|
|
202
|
+
actions: {status: "absent", findings: []},
|
|
203
|
+
});
|
|
204
|
+
const missing = depHealthCoverage({npm: {status: "scanned", findings: []}});
|
|
205
|
+
const unrecorded = depHealthCoverage({
|
|
206
|
+
npm: {status: "scanned"},
|
|
207
|
+
pip: {status: "absent", findings: []},
|
|
208
|
+
containers: {status: "absent", findings: []},
|
|
209
|
+
actions: {status: "absent", findings: []},
|
|
210
|
+
});
|
|
211
|
+
const unrecordedFlag = depHealthCoverage({
|
|
212
|
+
npm: {status: "scanned", findings: [], recorded: false},
|
|
213
|
+
pip: {status: "absent", findings: []},
|
|
214
|
+
containers: {status: "absent", findings: []},
|
|
215
|
+
actions: {status: "absent", findings: []},
|
|
216
|
+
});
|
|
217
|
+
const emptyUnavailable = depHealthCoverage({
|
|
218
|
+
npm: {status: "unavailable", findings: []},
|
|
219
|
+
pip: {status: "absent", findings: []},
|
|
220
|
+
containers: {status: "absent", findings: []},
|
|
221
|
+
actions: {status: "absent", findings: []},
|
|
222
|
+
});
|
|
223
|
+
console.log(JSON.stringify({
|
|
224
|
+
scannedPass: scanned.pass, scannedGaps: scanned.gaps,
|
|
225
|
+
absentPass: absentNote.pass,
|
|
226
|
+
absentNotes: absentNote.gaps.filter((g) => !g.fail).length,
|
|
227
|
+
missingPass: missing.pass,
|
|
228
|
+
missingFails: missing.gaps.filter((g) => g.fail).map((g) => g.ecosystem),
|
|
229
|
+
unrecordedPass: unrecorded.pass,
|
|
230
|
+
flagPass: unrecordedFlag.pass,
|
|
231
|
+
emptyUnavailablePass: emptyUnavailable.pass,
|
|
232
|
+
}));
|
|
233
|
+
"""
|
|
234
|
+
)
|
|
235
|
+
self.assertEqual(res.returncode, 0, f"matrix test failed: {res.stderr}")
|
|
236
|
+
data = last_json_object(res.stdout)
|
|
237
|
+
self.assertTrue(data["scannedPass"], "scanned/clean must pass")
|
|
238
|
+
self.assertEqual(
|
|
239
|
+
[g for g in data["scannedGaps"] if g["fail"]],
|
|
240
|
+
[],
|
|
241
|
+
"scanned/clean must carry no failing gaps",
|
|
242
|
+
)
|
|
243
|
+
self.assertTrue(data["absentPass"], "absent must pass-with-note")
|
|
244
|
+
self.assertGreaterEqual(data["absentNotes"], 1, "absent must leave a note")
|
|
245
|
+
self.assertFalse(data["missingPass"], "missing report must fail")
|
|
246
|
+
self.assertEqual(
|
|
247
|
+
set(data["missingFails"]),
|
|
248
|
+
{"pip", "containers", "actions"},
|
|
249
|
+
"every missing ecosystem must be named",
|
|
250
|
+
)
|
|
251
|
+
self.assertFalse(data["unrecordedPass"], "unrecorded findings must fail")
|
|
252
|
+
self.assertFalse(data["flagPass"], "recorded:false must fail")
|
|
253
|
+
self.assertFalse(
|
|
254
|
+
data["emptyUnavailablePass"],
|
|
255
|
+
"unavailable with zero entries is unrecorded and must fail",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TestAnnotationKeys(unittest.TestCase):
|
|
260
|
+
def test_every_finding_key_always_present(self):
|
|
261
|
+
res = run_node(
|
|
262
|
+
DEP_IMPORT
|
|
263
|
+
+ """
|
|
264
|
+
const bare = toDepFinding({});
|
|
265
|
+
const full = toDepFinding({tool: "edit", eventId: "e-k", ecosystem: "pip",
|
|
266
|
+
package: "requests", installed: "2.0", wanted: "2.1", latest: "2.2",
|
|
267
|
+
severity: "HIGH", advisory: "CVE-x", fixedIn: "2.1", source: "osv.dev"});
|
|
268
|
+
const direct = await checkDeps({
|
|
269
|
+
manifests: {packageJson: JSON.stringify({dependencies: {a: "1.0.0"}})},
|
|
270
|
+
runOutdated: async () => ({status: "ok", json: {}}),
|
|
271
|
+
runAudit: async () => ({status: "ok", json: {}}),
|
|
272
|
+
queryOsv: async () => ({status: "error", message: "down"}),
|
|
273
|
+
osvTimeoutMs: 200,
|
|
274
|
+
});
|
|
275
|
+
console.log(JSON.stringify({
|
|
276
|
+
bareKeys: Object.keys(bare).sort(),
|
|
277
|
+
fullSeverity: full.severity,
|
|
278
|
+
npmKeys: direct.ecosystems.npm.findings.length
|
|
279
|
+
? Object.keys(direct.ecosystems.npm.findings[0]).sort() : [],
|
|
280
|
+
}));
|
|
281
|
+
"""
|
|
282
|
+
)
|
|
283
|
+
self.assertEqual(res.returncode, 0, f"keys test failed: {res.stderr}")
|
|
284
|
+
data = last_json_object(res.stdout)
|
|
285
|
+
required = {
|
|
286
|
+
"tool",
|
|
287
|
+
"eventId",
|
|
288
|
+
"ecosystem",
|
|
289
|
+
"package",
|
|
290
|
+
"installed",
|
|
291
|
+
"wanted",
|
|
292
|
+
"latest",
|
|
293
|
+
"severity",
|
|
294
|
+
"advisory",
|
|
295
|
+
"fixedIn",
|
|
296
|
+
"source",
|
|
297
|
+
}
|
|
298
|
+
self.assertTrue(
|
|
299
|
+
required.issubset(set(data["bareKeys"])),
|
|
300
|
+
f"bare finding missing keys: {required - set(data['bareKeys'])}",
|
|
301
|
+
)
|
|
302
|
+
self.assertEqual(
|
|
303
|
+
data["fullSeverity"], "high", "severity normalises to lowercase"
|
|
304
|
+
)
|
|
305
|
+
# Findings from a real scan path carry the same eleven keys.
|
|
306
|
+
if data["npmKeys"]:
|
|
307
|
+
self.assertTrue(required.issubset(set(data["npmKeys"])))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class TestOsvTimeoutBound(unittest.TestCase):
|
|
311
|
+
def test_hung_feed_resolves_inside_budget(self):
|
|
312
|
+
res = run_node(
|
|
313
|
+
DEP_IMPORT
|
|
314
|
+
+ """
|
|
315
|
+
const runOutdated = async () => ({status: "ok", json: {}});
|
|
316
|
+
const runAudit = async () => ({status: "ok", json: {}});
|
|
317
|
+
const queryOsv = () => new Promise(() => {});
|
|
318
|
+
const t0 = Date.now();
|
|
319
|
+
const direct = await checkDeps({
|
|
320
|
+
manifests: {packageJson: JSON.stringify({dependencies: {a: "1.0.0"}})},
|
|
321
|
+
runOutdated, runAudit, queryOsv, osvTimeoutMs: 60,
|
|
322
|
+
});
|
|
323
|
+
const elapsed = Date.now() - t0;
|
|
324
|
+
console.log(JSON.stringify({
|
|
325
|
+
allowed: direct.allowed, elapsed,
|
|
326
|
+
npmStatus: direct.ecosystems.npm.status,
|
|
327
|
+
}));
|
|
328
|
+
"""
|
|
329
|
+
)
|
|
330
|
+
self.assertEqual(res.returncode, 0, f"osv-timeout test failed: {res.stderr}")
|
|
331
|
+
data = last_json_object(res.stdout)
|
|
332
|
+
self.assertTrue(data["allowed"])
|
|
333
|
+
self.assertLess(
|
|
334
|
+
data["elapsed"],
|
|
335
|
+
2000,
|
|
336
|
+
"hung OSV feed must resolve near the slow-tier budget",
|
|
337
|
+
)
|
|
338
|
+
self.assertEqual(
|
|
339
|
+
data["npmStatus"],
|
|
340
|
+
"scanned",
|
|
341
|
+
"local signals decide npm; a hung feed must not flip scanned",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class TestRepoBaseline(unittest.TestCase):
|
|
346
|
+
def test_baseline_shape(self):
|
|
347
|
+
res = run_node(
|
|
348
|
+
DEP_IMPORT
|
|
349
|
+
+ MOCK_CLEAN_RUNNERS
|
|
350
|
+
+ """
|
|
351
|
+
const direct = await checkDeps({
|
|
352
|
+
root: ".", runOutdated, runAudit, queryOsv, osvTimeoutMs: 200,
|
|
353
|
+
});
|
|
354
|
+
const containers = direct.ecosystems.containers;
|
|
355
|
+
const actions = direct.ecosystems.actions;
|
|
356
|
+
const pkgs = containers.findings.map((f) => f.package || "");
|
|
357
|
+
console.log(JSON.stringify({
|
|
358
|
+
allowed: direct.allowed,
|
|
359
|
+
npm: direct.ecosystems.npm.status,
|
|
360
|
+
pip: direct.ecosystems.pip.status,
|
|
361
|
+
containers: containers.status,
|
|
362
|
+
containerCount: containers.findings.length,
|
|
363
|
+
hasSearxng: pkgs.some((p) => p.includes("searxng")),
|
|
364
|
+
hasFirecrawl: pkgs.some((p) => p.includes("firecrawl")),
|
|
365
|
+
containerSources: [...new Set(containers.findings.map((f) => f.source))],
|
|
366
|
+
actions: actions.status,
|
|
367
|
+
actionCount: actions.findings.length,
|
|
368
|
+
keys: containers.findings.length ? Object.keys(containers.findings[0]).sort() : [],
|
|
369
|
+
}));
|
|
370
|
+
"""
|
|
371
|
+
)
|
|
372
|
+
self.assertEqual(res.returncode, 0, f"baseline test failed: {res.stderr}")
|
|
373
|
+
data = last_json_object(res.stdout)
|
|
374
|
+
self.assertTrue(data["allowed"])
|
|
375
|
+
self.assertEqual(data["npm"], "scanned", "repo has package.json: npm scanned")
|
|
376
|
+
self.assertEqual(data["pip"], "absent", "repo has no pip manifests: pip absent")
|
|
377
|
+
self.assertEqual(data["containers"], "scanned")
|
|
378
|
+
self.assertGreaterEqual(
|
|
379
|
+
data["containerCount"], 2, "the 2x :latest advisories (searxng + firecrawl)"
|
|
380
|
+
)
|
|
381
|
+
self.assertTrue(data["hasSearxng"], "searxng :latest advisory recorded")
|
|
382
|
+
self.assertTrue(data["hasFirecrawl"], "firecrawl :latest advisory recorded")
|
|
383
|
+
self.assertTrue(
|
|
384
|
+
data["actions"] == "scanned" and data["actionCount"] == 0,
|
|
385
|
+
"majors-pinned workflows scan clean",
|
|
386
|
+
)
|
|
387
|
+
required = {
|
|
388
|
+
"tool",
|
|
389
|
+
"eventId",
|
|
390
|
+
"ecosystem",
|
|
391
|
+
"package",
|
|
392
|
+
"installed",
|
|
393
|
+
"wanted",
|
|
394
|
+
"latest",
|
|
395
|
+
"severity",
|
|
396
|
+
"advisory",
|
|
397
|
+
"fixedIn",
|
|
398
|
+
"source",
|
|
399
|
+
}
|
|
400
|
+
self.assertTrue(required.issubset(set(data["keys"])))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class TestParsers(unittest.TestCase):
|
|
404
|
+
def test_actions_unpinned_flagged(self):
|
|
405
|
+
res = run_node(
|
|
406
|
+
'import { parseActionPins, parseContainerImages, isFloatingImage, parsePipDeps } from "./plugins/opencode/dep-health.js";\n'
|
|
407
|
+
+ """
|
|
408
|
+
const pins = parseActionPins("steps:\\n - uses: actions/checkout@v4\\n - uses: foo/bar@main\\n - uses: baz/qux\\n");
|
|
409
|
+
const imgs = parseContainerImages("services:\\n a:\\n image: searxng/searxng:latest\\n b:\\n image: redis:7-alpine\\n");
|
|
410
|
+
const pip = parsePipDeps("requests>=2.0\\n# comment\\nflask==3.0; python_version>'3.8'\\n");
|
|
411
|
+
console.log(JSON.stringify({
|
|
412
|
+
pinned: pins.map((p) => p.pinned),
|
|
413
|
+
floating: imgs.map(isFloatingImage),
|
|
414
|
+
pipNames: pip.map((d) => d.name),
|
|
415
|
+
}));
|
|
416
|
+
"""
|
|
417
|
+
)
|
|
418
|
+
self.assertEqual(res.returncode, 0, f"parser test failed: {res.stderr}")
|
|
419
|
+
data = last_json_object(res.stdout)
|
|
420
|
+
self.assertEqual(
|
|
421
|
+
data["pinned"],
|
|
422
|
+
[True, False, False],
|
|
423
|
+
"majors pinned; branch/missing refs flagged",
|
|
424
|
+
)
|
|
425
|
+
self.assertEqual(data["floating"], [True, False])
|
|
426
|
+
self.assertEqual(data["pipNames"], ["requests", "flask"])
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TestSetupAdoptsDepHealth(unittest.TestCase):
|
|
430
|
+
def test_setup_adopts_and_disposes_dep_health(self):
|
|
431
|
+
res = run_node(
|
|
432
|
+
"""
|
|
433
|
+
import plugin, { hookBus } from "./plugins/opencode/index.js";
|
|
434
|
+
const before = {
|
|
435
|
+
pre: hookBus.handlerCount("pre-tool-use"),
|
|
436
|
+
commit: hookBus.handlerCount("pre-commit"),
|
|
437
|
+
};
|
|
438
|
+
const host = {
|
|
439
|
+
options: {},
|
|
440
|
+
command: {
|
|
441
|
+
list: async () => ({data: []}),
|
|
442
|
+
transform: async (fn) => { fn({add: () => {}}); return {dispose: async () => {}}; }
|
|
443
|
+
},
|
|
444
|
+
tool: {
|
|
445
|
+
transform: async (fn) => { fn({add: () => {}}); return {dispose: async () => {}}; }
|
|
446
|
+
},
|
|
447
|
+
session: { prompt: async () => ({}) }
|
|
448
|
+
};
|
|
449
|
+
const cleanup = await plugin.setup(host);
|
|
450
|
+
const after = {
|
|
451
|
+
pre: hookBus.handlerCount("pre-tool-use"),
|
|
452
|
+
commit: hookBus.handlerCount("pre-commit"),
|
|
453
|
+
};
|
|
454
|
+
const attached = !!host.iumbtemsDepHealth;
|
|
455
|
+
await cleanup();
|
|
456
|
+
const closed = {
|
|
457
|
+
pre: hookBus.handlerCount("pre-tool-use"),
|
|
458
|
+
commit: hookBus.handlerCount("pre-commit"),
|
|
459
|
+
};
|
|
460
|
+
console.log(JSON.stringify({before, after, attached, closed}));
|
|
461
|
+
"""
|
|
462
|
+
)
|
|
463
|
+
self.assertEqual(res.returncode, 0, f"adopt test failed: {res.stderr}")
|
|
464
|
+
data = last_json_object(res.stdout)
|
|
465
|
+
self.assertTrue(data["attached"], "dep-health must be adopted on setup")
|
|
466
|
+
self.assertGreater(
|
|
467
|
+
data["after"]["pre"],
|
|
468
|
+
data["before"]["pre"],
|
|
469
|
+
"pre-tool-use dep-health handler subscribed",
|
|
470
|
+
)
|
|
471
|
+
self.assertGreater(
|
|
472
|
+
data["after"]["commit"],
|
|
473
|
+
data["before"]["commit"],
|
|
474
|
+
"pre-commit dep-health handler subscribed",
|
|
475
|
+
)
|
|
476
|
+
self.assertEqual(
|
|
477
|
+
data["closed"],
|
|
478
|
+
data["before"],
|
|
479
|
+
"cleanup must dispose dep-health subscriptions",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
if __name__ == "__main__":
|
|
484
|
+
unittest.main()
|