@heretek-ai/epistemic-swarm 0.7.12 → 0.7.13
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/package.json +1 -1
- package/prompts/agent_darkharvest.md +20 -1
- 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__/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__/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__/state_machine.cpython-311.pyc +0 -0
- package/runner/auditor_engine.py +101 -10
- package/runner/darkharvest_claims.py +326 -0
- package/runner/research_swarm.py +30 -1
- package/runner/state_machine.py +35 -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_darkharvest_audit.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_grounding.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_refinement.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/test_darkharvest_audit.py +290 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.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/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heretek-ai/epistemic-swarm",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
4
4
|
"description": "IUMBTEMS (Epistemic Swarm) — High-Integrity Dialectic Research Agent Harness for Claude Code, OpenCode V2, Pi (pi.dev), OMP (oh-my-pi), Gemini CLI, Codex CLI, and AntiGravity",
|
|
5
5
|
"main": "bin/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -33,6 +33,17 @@ partial per capability), benchmark/API quotes, and per-feature harvest
|
|
|
33
33
|
proposals. Every cell needs `[VERIFIED: <hash>]` from
|
|
34
34
|
`.research/sources/<sha256>.md` or `file://<path>#L<start>-L<end>`.
|
|
35
35
|
|
|
36
|
+
Discover and cache every cited source before you cite it (zero-key, no API keys):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python3 skills/epistemic_search/scripts/search.py "<query>"
|
|
40
|
+
python3 skills/research_cache/hasher.py cache --url "<URL>" --content "$(cat fetched.md)" --title "<TITLE>"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Confirm a repository actually exists (`owner/repo`) before citing it. A run that
|
|
44
|
+
never searches caches zero sources and is flagged `WARNING_LOW_GROUNDING` — the
|
|
45
|
+
runner prints `retrieval: N queries, M results, K cached` as evidence either way.
|
|
46
|
+
|
|
36
47
|
### Beta — Red Team
|
|
37
48
|
License gate (LICENSE file + package metadata → SPDX), transitive weight,
|
|
38
49
|
CVE/takeover notes, staleness (`⚠️ STALE` when >12mo or solo-maintainer),
|
|
@@ -51,7 +62,9 @@ AND what we lack). Gates are warn-only: badge inline, never auto-skip.
|
|
|
51
62
|
## 4. OUTPUT SPECIFICATION
|
|
52
63
|
|
|
53
64
|
Per scope write `alpha_dossier.json` / `beta_dossier.json` with
|
|
54
|
-
`candidate_repositories[]` entries
|
|
65
|
+
`candidate_repositories[]` entries. Every entry MUST carry `source_hash` **and**
|
|
66
|
+
`verbatim_quote` (an exact substring of the cached `.research/sources/<hash>.md`);
|
|
67
|
+
entries without both are rejected as unverified by the audit.
|
|
55
68
|
|
|
56
69
|
```json
|
|
57
70
|
{
|
|
@@ -59,6 +72,7 @@ Per scope write `alpha_dossier.json` / `beta_dossier.json` with
|
|
|
59
72
|
"name": "owner/repo",
|
|
60
73
|
"url": "https://github.com/owner/repo",
|
|
61
74
|
"source_hash": "<sha256>",
|
|
75
|
+
"verbatim_quote": "<exact substring of the cached source>",
|
|
62
76
|
"license": "Apache-2.0",
|
|
63
77
|
"license_risk": "SAFE | COPYLEFT_WARNING | PROHIBITIVE",
|
|
64
78
|
"harvest_policy": "depend-or-vendor | clean-room-rebuild-only",
|
|
@@ -89,3 +103,8 @@ candidates.
|
|
|
89
103
|
3. No unverified harvest verdicts. Metadata ranks; only VERIFIED harvests.
|
|
90
104
|
4. No unbounded expansion past `--max-repos` or per-repo caps without user
|
|
91
105
|
confirmation. Firecrawl missing → gap note, never abort.
|
|
106
|
+
5. **Do NOT self-report audit results.** Never emit `epistemic_audit`,
|
|
107
|
+
`confidence`, `verified_*`, `unverified_*`, `mean_epistemic_score`, or any
|
|
108
|
+
`*.verified` / `*.total_sources` field. The runner computes verification from
|
|
109
|
+
cached witnesses; agent-declared counts are ignored (renamed to
|
|
110
|
+
`self_reported_*`) and only mislead a human reader.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/runner/auditor_engine.py
CHANGED
|
@@ -5,6 +5,7 @@ Verifies quote authenticity against content-addressed source cache,
|
|
|
5
5
|
calculates divergence metrics, prunes ungrounded claims, and generates synthesis reports.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import os
|
|
8
9
|
import sys
|
|
9
10
|
import json
|
|
10
11
|
from pathlib import Path
|
|
@@ -17,6 +18,22 @@ from skills.research_cache.hasher import SourceHasher
|
|
|
17
18
|
from runner.state_machine import ResearchStateMachine, ScopeStatus
|
|
18
19
|
from runner.refinement import compute_epistemic_score
|
|
19
20
|
|
|
21
|
+
from runner.darkharvest_claims import (
|
|
22
|
+
RepoValidator,
|
|
23
|
+
cross_check_repos,
|
|
24
|
+
normalize_dossier_claims,
|
|
25
|
+
sanitize_self_reported,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _repo_validation_enabled() -> bool:
|
|
30
|
+
return os.environ.get("IUMBTEMS_REPO_VALIDATE", "1").strip().lower() not in (
|
|
31
|
+
"0",
|
|
32
|
+
"false",
|
|
33
|
+
"no",
|
|
34
|
+
"off",
|
|
35
|
+
)
|
|
36
|
+
|
|
20
37
|
|
|
21
38
|
def _mark_rejected_claims(score_claims: list, *result_lists: list) -> None:
|
|
22
39
|
"""Mirror the auditor's UNVERIFIED_REJECTED verdict onto ClaimWitness records."""
|
|
@@ -114,10 +131,16 @@ def _apply_living_dossiers_degradation(
|
|
|
114
131
|
|
|
115
132
|
|
|
116
133
|
class EpistemicAuditorEngine:
|
|
117
|
-
def __init__(
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
base_dir: Optional[Path] = None,
|
|
137
|
+
repo_validator: Optional[Any] = None,
|
|
138
|
+
):
|
|
118
139
|
self.base_dir = base_dir or Path(".research")
|
|
119
140
|
self.hasher = SourceHasher(base_dir=self.base_dir)
|
|
120
141
|
self.state_machine = ResearchStateMachine(base_dir=self.base_dir)
|
|
142
|
+
# Ground-truth repo checks for darkharvest; injectable for tests/offline.
|
|
143
|
+
self.repo_validator = repo_validator
|
|
121
144
|
|
|
122
145
|
def audit_scope(
|
|
123
146
|
self,
|
|
@@ -149,16 +172,26 @@ class EpistemicAuditorEngine:
|
|
|
149
172
|
with open(beta_file, "r", encoding="utf-8") as f:
|
|
150
173
|
beta_dossier = json.load(f)
|
|
151
174
|
|
|
175
|
+
# Darkharvest dossiers emit `candidate_repositories[]`, not the standard
|
|
176
|
+
# claim keys, and may self-report audit-shaped fields (#5 layers 1 & 3).
|
|
177
|
+
for label, dossier in (("alpha", alpha_dossier), ("beta", beta_dossier)):
|
|
178
|
+
_, renamed = sanitize_self_reported(dossier)
|
|
179
|
+
if renamed:
|
|
180
|
+
print(
|
|
181
|
+
f" [Audit] ignored agent self-reported audit fields "
|
|
182
|
+
f"({label}): {', '.join(sorted(renamed))}"
|
|
183
|
+
)
|
|
184
|
+
|
|
152
185
|
self.state_machine.update_scope_status(scope_id, ScopeStatus.AUDITING)
|
|
153
186
|
|
|
154
|
-
# 1. Audit Alpha Claims
|
|
187
|
+
# 1. Audit Alpha Claims (normalized from either schema)
|
|
155
188
|
alpha_results, alpha_verified, alpha_rejected = self._verify_claims(
|
|
156
|
-
alpha_dossier
|
|
189
|
+
normalize_dossier_claims(alpha_dossier, "alpha"), constitution=constitution
|
|
157
190
|
)
|
|
158
191
|
|
|
159
192
|
# 2. Audit Beta Claims
|
|
160
193
|
beta_results, beta_verified, beta_rejected = self._verify_claims(
|
|
161
|
-
beta_dossier
|
|
194
|
+
normalize_dossier_claims(beta_dossier, "beta"), constitution=constitution
|
|
162
195
|
)
|
|
163
196
|
|
|
164
197
|
counts = {
|
|
@@ -196,6 +229,28 @@ class EpistemicAuditorEngine:
|
|
|
196
229
|
alpha_dossier, beta_dossier
|
|
197
230
|
)
|
|
198
231
|
|
|
232
|
+
# 4b. Darkharvest: license cross-check + repo ground truth. A dialectic
|
|
233
|
+
# that recommends `depend-or-vendor` on an unstated license must not pass
|
|
234
|
+
# as CERTIFIED (observed: MIT vs NOASSERTION for the same repo, #5).
|
|
235
|
+
harvest_findings: List[Dict[str, Any]] = []
|
|
236
|
+
if mode == "darkharvest":
|
|
237
|
+
validator = self.repo_validator
|
|
238
|
+
if validator is None:
|
|
239
|
+
validator = RepoValidator(enabled=_repo_validation_enabled())
|
|
240
|
+
harvest_findings = cross_check_repos(
|
|
241
|
+
alpha_dossier, beta_dossier, validator=validator
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
blocking_findings = [
|
|
245
|
+
f for f in harvest_findings if f.get("severity") == "BLOCKING"
|
|
246
|
+
]
|
|
247
|
+
if blocking_findings:
|
|
248
|
+
verdict = "WARNING_LICENSE_CONFLICT"
|
|
249
|
+
elif epistemic_score >= accept_threshold:
|
|
250
|
+
verdict = "CERTIFIED"
|
|
251
|
+
else:
|
|
252
|
+
verdict = "WARNING_LOW_GROUNDING"
|
|
253
|
+
|
|
199
254
|
# 5. Build Audit Report
|
|
200
255
|
audit_report = {
|
|
201
256
|
"auditor": "Epistemic Auditor Engine v1.0",
|
|
@@ -209,13 +264,13 @@ class EpistemicAuditorEngine:
|
|
|
209
264
|
"epistemic_score": epistemic_score,
|
|
210
265
|
"divergence_score": divergence_score,
|
|
211
266
|
"mode": mode,
|
|
212
|
-
"
|
|
213
|
-
|
|
214
|
-
else "WARNING_LOW_GROUNDING",
|
|
267
|
+
"blocking_findings": len(blocking_findings),
|
|
268
|
+
"verdict": verdict,
|
|
215
269
|
},
|
|
216
270
|
"alpha_claims_audit": alpha_results,
|
|
217
271
|
"beta_claims_audit": beta_results,
|
|
218
272
|
"divergence_matrix": divergence_matrix,
|
|
273
|
+
"harvest_findings": harvest_findings,
|
|
219
274
|
"degradation": degradation,
|
|
220
275
|
}
|
|
221
276
|
|
|
@@ -397,7 +452,9 @@ class EpistemicAuditorEngine:
|
|
|
397
452
|
return lines
|
|
398
453
|
|
|
399
454
|
@staticmethod
|
|
400
|
-
def _render_rejected_section(
|
|
455
|
+
def _render_rejected_section(
|
|
456
|
+
claims: list, summary: Optional[Dict[str, Any]] = None
|
|
457
|
+
) -> List[str]:
|
|
401
458
|
lines = ["\n## 3. Rejected & Unverified Assertions"]
|
|
402
459
|
rejected = [c for c in claims if c["audited_tag"] == "UNVERIFIED_REJECTED"]
|
|
403
460
|
if rejected:
|
|
@@ -405,9 +462,26 @@ class EpistemicAuditorEngine:
|
|
|
405
462
|
lines.append(
|
|
406
463
|
f'- ⚠️ **PURGED**: "{r["statement"]}" — *Reason: {r["reason"]}*'
|
|
407
464
|
)
|
|
465
|
+
return lines
|
|
466
|
+
|
|
467
|
+
# No rejected claims is only "all verified" when the audit actually
|
|
468
|
+
# verified something. Otherwise this is static text contradicting the
|
|
469
|
+
# verdict (observed in darkharvest: 0 verified + "100% verified", #5).
|
|
470
|
+
summary = summary or {}
|
|
471
|
+
verified = summary.get("verified_passed", 0)
|
|
472
|
+
if summary.get("verdict") == "WARNING_LICENSE_CONFLICT":
|
|
473
|
+
lines.append(
|
|
474
|
+
f"No claims rejected, but {summary.get('blocking_findings', 0)} "
|
|
475
|
+
"blocking license/ground-truth finding(s) require review before acting."
|
|
476
|
+
)
|
|
477
|
+
elif verified:
|
|
478
|
+
lines.append(
|
|
479
|
+
f"No claims rejected. {verified} claim(s) verified against the source cache."
|
|
480
|
+
)
|
|
408
481
|
else:
|
|
409
482
|
lines.append(
|
|
410
|
-
"
|
|
483
|
+
"No claims rejected — but **0 claims were verified** (empty source "
|
|
484
|
+
"cache). Treat every assertion above as unverified."
|
|
411
485
|
)
|
|
412
486
|
return lines
|
|
413
487
|
|
|
@@ -446,8 +520,25 @@ class EpistemicAuditorEngine:
|
|
|
446
520
|
md.extend(self._render_tensions_section(audit.get("divergence_matrix", [])))
|
|
447
521
|
md.extend(
|
|
448
522
|
self._render_rejected_section(
|
|
449
|
-
audit["alpha_claims_audit"] + audit["beta_claims_audit"]
|
|
523
|
+
audit["alpha_claims_audit"] + audit["beta_claims_audit"],
|
|
524
|
+
summary,
|
|
450
525
|
)
|
|
451
526
|
)
|
|
452
527
|
md.extend(self._render_negative_knowledge_section(alpha, beta))
|
|
528
|
+
md.extend(self._render_harvest_findings(audit.get("harvest_findings", [])))
|
|
453
529
|
return "\n".join(md)
|
|
530
|
+
|
|
531
|
+
@staticmethod
|
|
532
|
+
def _render_harvest_findings(findings: list) -> List[str]:
|
|
533
|
+
if not findings:
|
|
534
|
+
return []
|
|
535
|
+
lines = ["\n## 5. Harvest Safety Findings"]
|
|
536
|
+
for f in findings:
|
|
537
|
+
icon = "⛔" if f.get("severity") == "BLOCKING" else "⚠️"
|
|
538
|
+
detail = f.get("detail") or f.get("kind", "")
|
|
539
|
+
resolution = f.get("resolution")
|
|
540
|
+
suffix = f" → **{resolution}**" if resolution else ""
|
|
541
|
+
lines.append(
|
|
542
|
+
f"- {icon} `{f.get('repo', '?')}` — {f.get('kind', '')}: {detail}{suffix}"
|
|
543
|
+
)
|
|
544
|
+
return lines
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Darkharvest claim normalization, license cross-check, and repo validation.
|
|
3
|
+
|
|
4
|
+
Heretek-AI/IUMBTEMS#5: darkharvest dossiers emit `candidate_repositories[]` with
|
|
5
|
+
their own `source_hash` witnesses, but the auditor only reads
|
|
6
|
+
`affirmative_claims` / `falsification_claims` — so `total_claims_audited` was
|
|
7
|
+
always 0 and the dialectic was structurally inert. Separately, alpha and beta
|
|
8
|
+
can disagree about a repo's license (observed: `MIT` vs `NOASSERTION` for the
|
|
9
|
+
same repo, with alpha recommending `depend-or-vendor`) and nothing cross-checked
|
|
10
|
+
them, because the audit produced no claims to compare.
|
|
11
|
+
|
|
12
|
+
This module supplies the three missing pieces:
|
|
13
|
+
- `normalize_dossier_claims` — fold darkharvest's shape into the standard claim
|
|
14
|
+
schema so one contract flows to the auditor, PCRB, and ledger.
|
|
15
|
+
- `cross_check_repos` — alpha/beta license disagreement is a blocking finding,
|
|
16
|
+
resolved conservatively.
|
|
17
|
+
- `RepoValidator` — ground-truth (existence / license / archived) with fail-open
|
|
18
|
+
offline behavior.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
# Top-level dossier keys that *look* like audit output but are agent-authored.
|
|
29
|
+
# Renamed to `self_reported_*` on load so no consumer mistakes them for verified
|
|
30
|
+
# counts (see #5 layer 3).
|
|
31
|
+
AUDIT_SHAPED_KEYS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"epistemic_audit",
|
|
34
|
+
"confidence",
|
|
35
|
+
"gaps",
|
|
36
|
+
"verified_claims",
|
|
37
|
+
"unverified_claims",
|
|
38
|
+
"verified_sources",
|
|
39
|
+
"total_sources",
|
|
40
|
+
"total_claims",
|
|
41
|
+
"total_candidates",
|
|
42
|
+
"claims_total",
|
|
43
|
+
"claims_verified",
|
|
44
|
+
"claims_unverified",
|
|
45
|
+
"claims_falsified",
|
|
46
|
+
"claims_unverifiable",
|
|
47
|
+
"rejected_claims",
|
|
48
|
+
"mean_epistemic_score",
|
|
49
|
+
"primary_sources",
|
|
50
|
+
"primary_benchmarks",
|
|
51
|
+
"technical_specs",
|
|
52
|
+
"peer_reviewed",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Licenses that may be `depend`ed on or `vendor`ed. Everything else (incl.
|
|
57
|
+
# unknown / NOASSERTION) resolves to clean-room-rebuild-only.
|
|
58
|
+
PERMISSIVE_LICENSES = frozenset({"MIT", "APACHE-2.0", "BSD-3-CLAUSE", "ISC"})
|
|
59
|
+
|
|
60
|
+
UNKNOWN_LICENSES = frozenset({"", "UNKNOWN", "NOASSERTION", "NONE", "OTHER"})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _norm_license(value: Any) -> str:
|
|
64
|
+
return str(value or "").strip().upper()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_permissive(value: Any) -> bool:
|
|
68
|
+
return _norm_license(value) in PERMISSIVE_LICENSES
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sanitize_self_reported(dossier: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
|
|
72
|
+
"""Rename agent-authored audit-shaped keys; return (dossier, renamed_keys).
|
|
73
|
+
|
|
74
|
+
Never deletes — the values are preserved under `self_reported_*` so a human
|
|
75
|
+
can still see what the agent claimed, but nothing counts them.
|
|
76
|
+
"""
|
|
77
|
+
renamed: List[str] = []
|
|
78
|
+
if not isinstance(dossier, dict):
|
|
79
|
+
return dossier, renamed
|
|
80
|
+
for key in list(dossier.keys()):
|
|
81
|
+
if key in AUDIT_SHAPED_KEYS:
|
|
82
|
+
dossier[f"self_reported_{key}"] = dossier.pop(key)
|
|
83
|
+
renamed.append(key)
|
|
84
|
+
return dossier, renamed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _repo_key(candidate: Dict[str, Any]) -> Optional[str]:
|
|
88
|
+
"""Stable key for a candidate repo: `owner/repo`, lowercased."""
|
|
89
|
+
raw = str(candidate.get("name") or candidate.get("repo") or "").strip()
|
|
90
|
+
if not raw:
|
|
91
|
+
url = str(candidate.get("url") or "").strip()
|
|
92
|
+
match = re.search(r"github\.com/([^/]+/[^/#?]+)", url)
|
|
93
|
+
if match:
|
|
94
|
+
raw = match.group(1)
|
|
95
|
+
raw = raw.removesuffix(".git").strip("/")
|
|
96
|
+
return raw.lower() if raw else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _verdict_claims(role: str, candidate: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
100
|
+
"""One standard claim per candidate verdict (verifiable when a quote exists)."""
|
|
101
|
+
name = candidate.get("name") or candidate.get("url") or "unknown-repo"
|
|
102
|
+
source_hash = candidate.get("source_hash")
|
|
103
|
+
source_url = candidate.get("url")
|
|
104
|
+
quote = candidate.get("verbatim_quote")
|
|
105
|
+
out: List[Dict[str, Any]] = []
|
|
106
|
+
for i, verdict in enumerate(candidate.get("verdicts") or [], start=1):
|
|
107
|
+
if not isinstance(verdict, dict):
|
|
108
|
+
continue
|
|
109
|
+
rid = candidate.get("repo_id") or name
|
|
110
|
+
out.append(
|
|
111
|
+
{
|
|
112
|
+
"claim_id": f"{rid}-{role}-V{i}",
|
|
113
|
+
"tag": "VERIFIED",
|
|
114
|
+
"statement": (
|
|
115
|
+
f"{name}: {verdict.get('feature', '?')} → "
|
|
116
|
+
f"{verdict.get('verdict', '?')} ({verdict.get('reason', '')})"
|
|
117
|
+
),
|
|
118
|
+
"source_hash": source_hash,
|
|
119
|
+
"source_url": source_url,
|
|
120
|
+
# Prefer a verdict-specific quote; fall back to the candidate's.
|
|
121
|
+
"verbatim_quote": verdict.get("verbatim_quote") or quote,
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def normalize_dossier_claims(
|
|
128
|
+
dossier: Dict[str, Any], role: str
|
|
129
|
+
) -> List[Dict[str, Any]]:
|
|
130
|
+
"""Standard-schema claims for a dossier.
|
|
131
|
+
|
|
132
|
+
Uses the canonical keys when present; otherwise (darkharvest) derives them
|
|
133
|
+
from `candidate_repositories[]` so the auditor has something to verify.
|
|
134
|
+
"""
|
|
135
|
+
if not isinstance(dossier, dict):
|
|
136
|
+
return []
|
|
137
|
+
standard = dossier.get("affirmative_claims") or dossier.get("falsification_claims")
|
|
138
|
+
if isinstance(standard, list) and standard:
|
|
139
|
+
return [c for c in standard if isinstance(c, dict)]
|
|
140
|
+
|
|
141
|
+
out: List[Dict[str, Any]] = []
|
|
142
|
+
for candidate in dossier.get("candidate_repositories") or []:
|
|
143
|
+
if isinstance(candidate, dict):
|
|
144
|
+
out.extend(_verdict_claims(role, candidate))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _resolved_policy(alpha: Dict[str, Any], beta: Dict[str, Any]) -> str:
|
|
149
|
+
"""Conservative harvest policy when the two dossiers disagree."""
|
|
150
|
+
for side in (alpha, beta):
|
|
151
|
+
policy = str(side.get("harvest_policy") or "").lower()
|
|
152
|
+
risk = str(side.get("license_risk") or "").upper()
|
|
153
|
+
if policy == "clean-room-rebuild-only" or risk in (
|
|
154
|
+
"COPYLEFT_WARNING",
|
|
155
|
+
"PROHIBITIVE",
|
|
156
|
+
):
|
|
157
|
+
return "clean-room-rebuild-only"
|
|
158
|
+
for side in (alpha, beta):
|
|
159
|
+
if not _is_permissive(side.get("license")):
|
|
160
|
+
return "clean-room-rebuild-only"
|
|
161
|
+
return "depend-or-vendor"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class RepoValidator:
|
|
165
|
+
"""Ground-truth repo checks via the GitHub API.
|
|
166
|
+
|
|
167
|
+
Fail-open: any network/timeout error yields `exists=None` and blocks nothing,
|
|
168
|
+
so an offline or rate-limited run degrades rather than aborting.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
token: Optional[str] = None,
|
|
174
|
+
timeout: float = 5.0,
|
|
175
|
+
enabled: bool = True,
|
|
176
|
+
):
|
|
177
|
+
self.token = token if token is not None else os.environ.get("GITHUB_TOKEN")
|
|
178
|
+
self.timeout = timeout
|
|
179
|
+
self.enabled = enabled
|
|
180
|
+
self._cache: Dict[str, Dict[str, Any]] = {}
|
|
181
|
+
|
|
182
|
+
def validate(self, owner_repo: str) -> Dict[str, Any]:
|
|
183
|
+
key = owner_repo.strip().lower()
|
|
184
|
+
if key in self._cache:
|
|
185
|
+
return self._cache[key]
|
|
186
|
+
result = {"exists": None, "license": None, "archived": None}
|
|
187
|
+
if self.enabled and key:
|
|
188
|
+
result = self._fetch(owner_repo)
|
|
189
|
+
self._cache[key] = result
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
def _fetch(self, owner_repo: str) -> Dict[str, Any]:
|
|
193
|
+
request = urllib.request.Request(
|
|
194
|
+
f"https://api.github.com/repos/{owner_repo}",
|
|
195
|
+
headers={
|
|
196
|
+
"Accept": "application/vnd.github+json",
|
|
197
|
+
"User-Agent": "iumbtems-darkharvest-audit",
|
|
198
|
+
**({"Authorization": f"Bearer {self.token}"} if self.token else {}),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as resp:
|
|
203
|
+
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
204
|
+
license_field = data.get("license") or {}
|
|
205
|
+
return {
|
|
206
|
+
"exists": True,
|
|
207
|
+
"license": license_field.get("spdx_id"),
|
|
208
|
+
"archived": bool(data.get("archived")),
|
|
209
|
+
}
|
|
210
|
+
except urllib.error.HTTPError as e:
|
|
211
|
+
if e.code == 404:
|
|
212
|
+
return {"exists": False, "license": None, "archived": None}
|
|
213
|
+
return {"exists": None, "license": None, "archived": None}
|
|
214
|
+
except Exception:
|
|
215
|
+
return {"exists": None, "license": None, "archived": None}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cross_check_repos(
|
|
219
|
+
alpha_dossier: Dict[str, Any],
|
|
220
|
+
beta_dossier: Dict[str, Any],
|
|
221
|
+
validator: Optional[RepoValidator] = None,
|
|
222
|
+
) -> List[Dict[str, Any]]:
|
|
223
|
+
"""Blocking findings for license disagreement and ground-truth failures.
|
|
224
|
+
|
|
225
|
+
A dialectic that produces a `depend-or-vendor` verdict on an unstated license
|
|
226
|
+
must not reach a human as `divergence: 0`. Each finding carries the
|
|
227
|
+
conservative resolution.
|
|
228
|
+
"""
|
|
229
|
+
alpha = {
|
|
230
|
+
key: c
|
|
231
|
+
for c in (alpha_dossier.get("candidate_repositories") or [])
|
|
232
|
+
if isinstance(c, dict) and (key := _repo_key(c))
|
|
233
|
+
}
|
|
234
|
+
beta = {
|
|
235
|
+
key: c
|
|
236
|
+
for c in (beta_dossier.get("candidate_repositories") or [])
|
|
237
|
+
if isinstance(c, dict) and (key := _repo_key(c))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
findings: List[Dict[str, Any]] = []
|
|
241
|
+
for key in sorted(set(alpha) | set(beta)):
|
|
242
|
+
a = alpha.get(key)
|
|
243
|
+
b = beta.get(key)
|
|
244
|
+
if a and b:
|
|
245
|
+
alic, blic = (
|
|
246
|
+
_norm_license(a.get("license")),
|
|
247
|
+
_norm_license(b.get("license")),
|
|
248
|
+
)
|
|
249
|
+
if alic != blic:
|
|
250
|
+
findings.append(
|
|
251
|
+
{
|
|
252
|
+
"repo": key,
|
|
253
|
+
"kind": "LICENSE_DISAGREEMENT",
|
|
254
|
+
"severity": "BLOCKING",
|
|
255
|
+
"alpha": {
|
|
256
|
+
"license": a.get("license"),
|
|
257
|
+
"risk": a.get("license_risk"),
|
|
258
|
+
},
|
|
259
|
+
"beta": {
|
|
260
|
+
"license": b.get("license"),
|
|
261
|
+
"risk": b.get("license_risk"),
|
|
262
|
+
},
|
|
263
|
+
"resolution": _resolved_policy(a, b),
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
elif str(a.get("harvest_policy") or "") != str(
|
|
267
|
+
b.get("harvest_policy") or ""
|
|
268
|
+
):
|
|
269
|
+
findings.append(
|
|
270
|
+
{
|
|
271
|
+
"repo": key,
|
|
272
|
+
"kind": "POLICY_DISAGREEMENT",
|
|
273
|
+
"severity": "BLOCKING",
|
|
274
|
+
"alpha": {"policy": a.get("harvest_policy")},
|
|
275
|
+
"beta": {"policy": b.get("harvest_policy")},
|
|
276
|
+
"resolution": _resolved_policy(a, b),
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Ground truth (fail-open when the API is unreachable).
|
|
281
|
+
if validator is not None:
|
|
282
|
+
for side_name, side in (("alpha", a), ("beta", b)):
|
|
283
|
+
if not side:
|
|
284
|
+
continue
|
|
285
|
+
truth = validator.validate(key)
|
|
286
|
+
if truth.get("exists") is False:
|
|
287
|
+
findings.append(
|
|
288
|
+
{
|
|
289
|
+
"repo": key,
|
|
290
|
+
"kind": "REPO_UNRESOLVABLE",
|
|
291
|
+
"severity": "BLOCKING",
|
|
292
|
+
"source": side_name,
|
|
293
|
+
"detail": "GitHub API returned 404",
|
|
294
|
+
"resolution": "reject-citation",
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
break
|
|
298
|
+
if truth.get("archived") is True:
|
|
299
|
+
findings.append(
|
|
300
|
+
{
|
|
301
|
+
"repo": key,
|
|
302
|
+
"kind": "REPO_ARCHIVED",
|
|
303
|
+
"severity": "WARNING",
|
|
304
|
+
"detail": "repository is archived",
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
break
|
|
308
|
+
truth_license = _norm_license(truth.get("license"))
|
|
309
|
+
if (
|
|
310
|
+
truth.get("exists")
|
|
311
|
+
and truth_license
|
|
312
|
+
and not _is_permissive(truth.get("license"))
|
|
313
|
+
and _is_permissive(side.get("license"))
|
|
314
|
+
):
|
|
315
|
+
findings.append(
|
|
316
|
+
{
|
|
317
|
+
"repo": key,
|
|
318
|
+
"kind": "LICENSE_CONTRADICTS_GROUND_TRUTH",
|
|
319
|
+
"severity": "BLOCKING",
|
|
320
|
+
"source": side_name,
|
|
321
|
+
"detail": f"dossier says {side.get('license')}, GitHub says {truth.get('license')}",
|
|
322
|
+
"resolution": "clean-room-rebuild-only",
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
break
|
|
326
|
+
return findings
|
package/runner/research_swarm.py
CHANGED
|
@@ -270,7 +270,17 @@ class SwarmRunner:
|
|
|
270
270
|
self.state_machine = ResearchStateMachine(
|
|
271
271
|
base_dir=self.base_dir, mode=self.mode
|
|
272
272
|
)
|
|
273
|
-
|
|
273
|
+
from runner.auditor_engine import _repo_validation_enabled
|
|
274
|
+
from runner.darkharvest_claims import RepoValidator
|
|
275
|
+
|
|
276
|
+
self.auditor = EpistemicAuditorEngine(
|
|
277
|
+
base_dir=self.base_dir,
|
|
278
|
+
# Ground-truth repo checks are for real runs; mock dossiers are
|
|
279
|
+
# synthetic, so the unit suite must stay offline.
|
|
280
|
+
repo_validator=RepoValidator(
|
|
281
|
+
enabled=(not self.mock_mode) and _repo_validation_enabled()
|
|
282
|
+
),
|
|
283
|
+
)
|
|
274
284
|
self.hasher = SourceHasher(base_dir=self.base_dir)
|
|
275
285
|
self.prompts_dir = PROJECT_ROOT / "prompts"
|
|
276
286
|
|
|
@@ -1252,11 +1262,30 @@ Output ONLY valid JSON representing the scope decomposition conforming to prompt
|
|
|
1252
1262
|
)
|
|
1253
1263
|
report_path = self._compile_master_synthesis(objective)
|
|
1254
1264
|
self.state_machine.update_session_status(SessionStatus.COMPLETED)
|
|
1265
|
+
self._write_orphan_report()
|
|
1255
1266
|
|
|
1256
1267
|
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
|
1257
1268
|
print(f" {self._retrieval_banner()}")
|
|
1258
1269
|
print(f"\n🎉 Swarm run completed in {duration:.1f}s. Report: {report_path}")
|
|
1259
1270
|
|
|
1271
|
+
def _write_orphan_report(self) -> None:
|
|
1272
|
+
"""Record scope dirs absent from every manifest (mark-only, never delete)."""
|
|
1273
|
+
try:
|
|
1274
|
+
orphans = self.state_machine.find_orphan_scopes()
|
|
1275
|
+
except Exception: # noqa: BLE001 - hygiene must not fail the run
|
|
1276
|
+
return
|
|
1277
|
+
if not orphans:
|
|
1278
|
+
return
|
|
1279
|
+
path = self.base_dir / "orphans.json"
|
|
1280
|
+
try:
|
|
1281
|
+
path.write_text(json.dumps(orphans, indent=2), encoding="utf-8")
|
|
1282
|
+
except OSError:
|
|
1283
|
+
return
|
|
1284
|
+
print(
|
|
1285
|
+
f" ⚠️ {len(orphans)} orphaned scope dir(s) not referenced by any "
|
|
1286
|
+
f"manifest -> {path.name} (not deleted)"
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1260
1289
|
def _retrieval_banner(self) -> str:
|
|
1261
1290
|
"""One-line retrieval summary for the current run (issue #3).
|
|
1262
1291
|
|
package/runner/state_machine.py
CHANGED
|
@@ -219,6 +219,41 @@ class ResearchStateMachine:
|
|
|
219
219
|
def get_scope_dir(self, scope_id: str) -> Path:
|
|
220
220
|
return self.scratchpads_dir / scope_id
|
|
221
221
|
|
|
222
|
+
def find_orphan_scopes(self) -> List[Dict[str, Any]]:
|
|
223
|
+
"""Scope dirs not referenced by any manifest — marked, never deleted.
|
|
224
|
+
|
|
225
|
+
A dir may belong to a concurrently running session, so this reports
|
|
226
|
+
rather than prunes (issue #5 layer 6: 14 manifest-only leftovers).
|
|
227
|
+
"""
|
|
228
|
+
referenced: set = set()
|
|
229
|
+
manifests = [self.manifest_file, *self.base_dir.glob("manifest.*.json")]
|
|
230
|
+
for manifest_path in manifests:
|
|
231
|
+
if not manifest_path.exists():
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
235
|
+
except (OSError, ValueError):
|
|
236
|
+
continue
|
|
237
|
+
for scope in data.get("scopes") or []:
|
|
238
|
+
if isinstance(scope, dict) and scope.get("scope_id"):
|
|
239
|
+
referenced.add(scope["scope_id"])
|
|
240
|
+
|
|
241
|
+
orphans: List[Dict[str, Any]] = []
|
|
242
|
+
if not self.scratchpads_dir.is_dir():
|
|
243
|
+
return orphans
|
|
244
|
+
for scope_dir in sorted(self.scratchpads_dir.iterdir()):
|
|
245
|
+
if not scope_dir.is_dir() or scope_dir.name in referenced:
|
|
246
|
+
continue
|
|
247
|
+
orphans.append(
|
|
248
|
+
{
|
|
249
|
+
"scope_id": scope_dir.name,
|
|
250
|
+
"path": str(scope_dir),
|
|
251
|
+
"alpha_dossier": (scope_dir / "alpha_dossier.json").exists(),
|
|
252
|
+
"beta_dossier": (scope_dir / "beta_dossier.json").exists(),
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
return orphans
|
|
256
|
+
|
|
222
257
|
def _read_scope_unlocked(self, scope_id: str) -> Dict[str, Any]:
|
|
223
258
|
scope_manifest_file = self.get_scope_dir(scope_id) / MANIFEST_FILENAME
|
|
224
259
|
if not scope_manifest_file.exists():
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Darkharvest audit regressions (issue #5).
|
|
3
|
+
|
|
4
|
+
Covers the three layers that made darkharvest ungrounded by construction:
|
|
5
|
+
claim-schema normalization (auditor could not see darkharvest claims), the
|
|
6
|
+
alpha/beta license cross-check (MIT vs NOASSERTION reached a human as
|
|
7
|
+
divergence 0), and self-reported verification fields that must never count.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
17
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
19
|
+
|
|
20
|
+
from runner.auditor_engine import EpistemicAuditorEngine # noqa: E402
|
|
21
|
+
from runner.darkharvest_claims import ( # noqa: E402
|
|
22
|
+
RepoValidator,
|
|
23
|
+
cross_check_repos,
|
|
24
|
+
normalize_dossier_claims,
|
|
25
|
+
sanitize_self_reported,
|
|
26
|
+
)
|
|
27
|
+
from runner.research_swarm import SwarmRunner # noqa: E402
|
|
28
|
+
from skills.research_cache.hasher import SourceHasher # noqa: E402
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FakeValidator:
|
|
32
|
+
def __init__(self, result):
|
|
33
|
+
self.result = result
|
|
34
|
+
self.calls = []
|
|
35
|
+
|
|
36
|
+
def validate(self, owner_repo):
|
|
37
|
+
self.calls.append(owner_repo)
|
|
38
|
+
return self.result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _candidate(license_, risk, policy, shash, quote):
|
|
42
|
+
return {
|
|
43
|
+
"repo_id": "DH-01",
|
|
44
|
+
"name": "getpaseo/paseo",
|
|
45
|
+
"url": "https://github.com/getpaseo/paseo",
|
|
46
|
+
"source_hash": shash,
|
|
47
|
+
"verbatim_quote": quote,
|
|
48
|
+
"license": license_,
|
|
49
|
+
"license_risk": risk,
|
|
50
|
+
"harvest_policy": policy,
|
|
51
|
+
"verdicts": [
|
|
52
|
+
{"feature": "worktree isolation", "verdict": "vendor", "reason": "r"}
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestNormalization(unittest.TestCase):
|
|
58
|
+
def test_darkharvest_shape_becomes_standard_claims(self):
|
|
59
|
+
dossier = {
|
|
60
|
+
"candidate_repositories": [
|
|
61
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", "abc", "q")
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
claims = normalize_dossier_claims(dossier, "alpha")
|
|
65
|
+
self.assertEqual(len(claims), 1)
|
|
66
|
+
self.assertEqual(claims[0]["source_hash"], "abc")
|
|
67
|
+
self.assertEqual(claims[0]["verbatim_quote"], "q")
|
|
68
|
+
|
|
69
|
+
def test_standard_keys_win_when_present(self):
|
|
70
|
+
dossier = {
|
|
71
|
+
"affirmative_claims": [{"claim_id": "C1", "statement": "s"}],
|
|
72
|
+
"candidate_repositories": [
|
|
73
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", "abc", "q")
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
claims = normalize_dossier_claims(dossier, "alpha")
|
|
77
|
+
self.assertEqual([c["claim_id"] for c in claims], ["C1"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestSelfReported(unittest.TestCase):
|
|
81
|
+
def test_audit_shaped_keys_are_renamed_not_counted(self):
|
|
82
|
+
dossier = {
|
|
83
|
+
"epistemic_audit": {"verified_claims": 15},
|
|
84
|
+
"confidence": "HIGH",
|
|
85
|
+
"scope_id": "s1",
|
|
86
|
+
}
|
|
87
|
+
dossier, renamed = sanitize_self_reported(dossier)
|
|
88
|
+
self.assertIn("epistemic_audit", renamed)
|
|
89
|
+
self.assertIn("confidence", renamed)
|
|
90
|
+
self.assertNotIn("epistemic_audit", dossier)
|
|
91
|
+
self.assertEqual(
|
|
92
|
+
dossier["self_reported_epistemic_audit"]["verified_claims"], 15
|
|
93
|
+
)
|
|
94
|
+
# Nothing audit-shaped remains to be mistaken for verified counts.
|
|
95
|
+
self.assertFalse(set(dossier) & {"epistemic_audit", "confidence"})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestLicenseCrossCheck(unittest.TestCase):
|
|
99
|
+
def test_mit_vs_noassertion_is_blocking_and_conservative(self):
|
|
100
|
+
from runner.auditor_engine import _repo_validation_enabled # noqa: F401
|
|
101
|
+
|
|
102
|
+
alpha = {
|
|
103
|
+
"candidate_repositories": [
|
|
104
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
beta = {
|
|
108
|
+
"candidate_repositories": [
|
|
109
|
+
_candidate(
|
|
110
|
+
"NOASSERTION", "PROHIBITIVE", "clean-room-rebuild-only", "a", "q"
|
|
111
|
+
)
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
findings = cross_check_repos(alpha, beta)
|
|
115
|
+
kinds = {f["kind"] for f in findings}
|
|
116
|
+
self.assertIn("LICENSE_DISAGREEMENT", kinds)
|
|
117
|
+
self.assertTrue(all(f["severity"] == "BLOCKING" for f in findings))
|
|
118
|
+
self.assertEqual(findings[0]["resolution"], "clean-room-rebuild-only")
|
|
119
|
+
|
|
120
|
+
def test_agreement_yields_no_findings(self):
|
|
121
|
+
alpha = {
|
|
122
|
+
"candidate_repositories": [
|
|
123
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
beta = {
|
|
127
|
+
"candidate_repositories": [
|
|
128
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
self.assertEqual(cross_check_repos(alpha, beta), [])
|
|
132
|
+
|
|
133
|
+
def test_404_is_an_unresolvable_blocking_finding(self):
|
|
134
|
+
alpha = {
|
|
135
|
+
"candidate_repositories": [
|
|
136
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
findings = cross_check_repos(
|
|
140
|
+
alpha, {}, validator=FakeValidator({"exists": False})
|
|
141
|
+
)
|
|
142
|
+
self.assertTrue(any(f["kind"] == "REPO_UNRESOLVABLE" for f in findings))
|
|
143
|
+
|
|
144
|
+
def test_offline_fails_open(self):
|
|
145
|
+
alpha = {
|
|
146
|
+
"candidate_repositories": [
|
|
147
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", "a", "q")
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
findings = cross_check_repos(
|
|
151
|
+
alpha,
|
|
152
|
+
{},
|
|
153
|
+
validator=FakeValidator(
|
|
154
|
+
{"exists": None, "license": None, "archived": None}
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
self.assertEqual(findings, [])
|
|
158
|
+
|
|
159
|
+
def test_validator_fail_open_on_error(self):
|
|
160
|
+
"""Network failure must yield exists=None, never raise (offline-safe)."""
|
|
161
|
+
import urllib.error
|
|
162
|
+
from unittest import mock
|
|
163
|
+
|
|
164
|
+
v = RepoValidator(enabled=True, timeout=0.001)
|
|
165
|
+
with mock.patch(
|
|
166
|
+
"urllib.request.urlopen", side_effect=urllib.error.URLError("offline")
|
|
167
|
+
):
|
|
168
|
+
self.assertIsNone(v.validate("owner/repo")["exists"])
|
|
169
|
+
|
|
170
|
+
def test_validator_404_is_not_exists(self):
|
|
171
|
+
import urllib.error
|
|
172
|
+
from unittest import mock
|
|
173
|
+
|
|
174
|
+
v = RepoValidator(enabled=True, timeout=0.001)
|
|
175
|
+
err = urllib.error.HTTPError("u", 404, "not found", {}, None)
|
|
176
|
+
with mock.patch("urllib.request.urlopen", side_effect=err):
|
|
177
|
+
self.assertFalse(v.validate("owner/missing")["exists"])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestDarkharvestAuditIntegration(unittest.TestCase):
|
|
181
|
+
def _runner_and_scope(self):
|
|
182
|
+
tmp = tempfile.mkdtemp()
|
|
183
|
+
base = Path(tmp) / ".research"
|
|
184
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="darkharvest")
|
|
185
|
+
sm = runner.state_machine
|
|
186
|
+
sm.init_session("arena")
|
|
187
|
+
sm.set_scopes([{"scope_id": "s1", "title": "t", "objective": "o"}])
|
|
188
|
+
hasher = SourceHasher(base_dir=base)
|
|
189
|
+
shash = hasher.store_source(
|
|
190
|
+
"https://github.com/getpaseo/paseo",
|
|
191
|
+
"Paseo archives a clean worktree with no confirmation.",
|
|
192
|
+
"paseo",
|
|
193
|
+
)
|
|
194
|
+
quote = "Paseo archives a clean worktree with no confirmation."
|
|
195
|
+
scope_dir = sm.get_scope_dir("s1")
|
|
196
|
+
(scope_dir / "alpha_dossier.json").write_text(
|
|
197
|
+
json.dumps(
|
|
198
|
+
{
|
|
199
|
+
"scope_id": "s1",
|
|
200
|
+
"candidate_repositories": [
|
|
201
|
+
_candidate("MIT", "SAFE", "depend-or-vendor", shash, quote)
|
|
202
|
+
],
|
|
203
|
+
"epistemic_audit": {"verified_claims": 15, "confidence": "HIGH"},
|
|
204
|
+
}
|
|
205
|
+
),
|
|
206
|
+
encoding="utf-8",
|
|
207
|
+
)
|
|
208
|
+
(scope_dir / "beta_dossier.json").write_text(
|
|
209
|
+
json.dumps(
|
|
210
|
+
{
|
|
211
|
+
"scope_id": "s1",
|
|
212
|
+
"candidate_repositories": [
|
|
213
|
+
_candidate(
|
|
214
|
+
"NOASSERTION",
|
|
215
|
+
"PROHIBITIVE",
|
|
216
|
+
"clean-room-rebuild-only",
|
|
217
|
+
shash,
|
|
218
|
+
quote,
|
|
219
|
+
)
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
),
|
|
223
|
+
encoding="utf-8",
|
|
224
|
+
)
|
|
225
|
+
return runner, base
|
|
226
|
+
|
|
227
|
+
def test_auditor_sees_claims_and_blocks_license_conflict(self):
|
|
228
|
+
runner, base = self._runner_and_scope()
|
|
229
|
+
auditor = EpistemicAuditorEngine(
|
|
230
|
+
base_dir=base,
|
|
231
|
+
repo_validator=FakeValidator(
|
|
232
|
+
{"exists": True, "license": "NOASSERTION", "archived": False}
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
report = auditor.audit_scope("s1", mode="darkharvest")
|
|
236
|
+
summary = report["summary"]
|
|
237
|
+
|
|
238
|
+
self.assertGreater(summary["total_claims_audited"], 0)
|
|
239
|
+
self.assertGreaterEqual(summary["verified_passed"], 1)
|
|
240
|
+
self.assertEqual(summary["verdict"], "WARNING_LICENSE_CONFLICT")
|
|
241
|
+
self.assertTrue(
|
|
242
|
+
any(f["kind"] == "LICENSE_DISAGREEMENT" for f in report["harvest_findings"])
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def test_synthesis_section_no_longer_claims_one_hundred_percent(self):
|
|
246
|
+
from runner.auditor_engine import EpistemicAuditorEngine as AE
|
|
247
|
+
|
|
248
|
+
lines = AE._render_rejected_section(
|
|
249
|
+
[], {"verified_passed": 0, "verdict": "WARNING_LOW_GROUNDING"}
|
|
250
|
+
)
|
|
251
|
+
text = "\n".join(lines)
|
|
252
|
+
self.assertIn("0 claims were verified", text)
|
|
253
|
+
self.assertNotIn("100%", text)
|
|
254
|
+
|
|
255
|
+
def test_synthesis_section_reports_license_conflict(self):
|
|
256
|
+
from runner.auditor_engine import EpistemicAuditorEngine as AE
|
|
257
|
+
|
|
258
|
+
lines = AE._render_rejected_section(
|
|
259
|
+
[],
|
|
260
|
+
{
|
|
261
|
+
"verified_passed": 0,
|
|
262
|
+
"verdict": "WARNING_LICENSE_CONFLICT",
|
|
263
|
+
"blocking_findings": 2,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
self.assertIn("blocking license/ground-truth finding", "\n".join(lines))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestOrphanScopes(unittest.TestCase):
|
|
270
|
+
def test_orphan_detected_and_not_deleted(self):
|
|
271
|
+
tmp = tempfile.mkdtemp()
|
|
272
|
+
base = Path(tmp) / ".research"
|
|
273
|
+
runner = SwarmRunner(base_dir=base, mock_mode=True, mode="research")
|
|
274
|
+
sm = runner.state_machine
|
|
275
|
+
sm.init_session("x")
|
|
276
|
+
sm.set_scopes([{"scope_id": "kept", "title": "t", "objective": "o"}])
|
|
277
|
+
stray = sm.get_scope_dir("abandoned")
|
|
278
|
+
stray.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
(stray / "manifest.json").write_text("{}", encoding="utf-8")
|
|
280
|
+
|
|
281
|
+
orphans = sm.find_orphan_scopes()
|
|
282
|
+
ids = {o["scope_id"] for o in orphans}
|
|
283
|
+
self.assertIn("abandoned", ids)
|
|
284
|
+
self.assertNotIn("kept", ids)
|
|
285
|
+
# Marked, never deleted.
|
|
286
|
+
self.assertTrue(stray.exists())
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
unittest.main()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|