@heretek-ai/epistemic-swarm 0.2.2 → 0.3.0
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/.agents/skills/brainstorming/SKILL.md +13 -0
- package/.agents/skills/code_audit/SKILL.md +13 -0
- package/.agents/skills/epistemic_search/SKILL.md +13 -0
- package/.agents/skills/grilling/SKILL.md +13 -0
- package/.agents/skills/oss_scout/SKILL.md +13 -0
- package/.agents/skills/research_cache/SKILL.md +13 -0
- package/.agents/skills/swarm_config/SKILL.md +13 -0
- package/.claude-plugin/plugin.json +15 -5
- package/.omp/README.md +39 -0
- package/.omp/SYSTEM.md +12 -0
- package/.omp/commands/audit.md +10 -0
- package/.omp/commands/brainstorming.md +12 -0
- package/.omp/commands/grill.md +9 -0
- package/.omp/commands/scout.md +10 -0
- package/.omp/commands/swarm-config.md +10 -0
- package/.omp/commands/swarm.md +9 -0
- package/.omp/hooks/post/epistemic-audit.ts +16 -0
- package/.omp/hooks/pre/epistemic-redirect.ts +21 -0
- package/.omp/prompts/brainstorming.md +8 -0
- package/.omp/prompts/swarm.md +8 -0
- package/MARKETPLACE.md +8 -0
- package/README.md +53 -8
- package/bin/cli.js +27 -3
- package/config/domain_packs/biopharma.json +23 -0
- package/config/domain_packs/legal.json +19 -0
- package/config/domain_packs/quant.json +19 -0
- package/config/mcp-research-servers.json +7 -0
- package/config/mcp_launcher.py +48 -137
- package/config/opencode-snippet.json +58 -3
- package/config/searxng_mcp.py +42 -83
- package/extensions/pi/index.js +196 -28
- package/install.sh +20 -4
- package/package.json +40 -5
- package/plugins/antigravity/README.md +28 -0
- package/plugins/antigravity/agents/alpha-thesis.md +6 -0
- package/plugins/antigravity/agents/beta-antithesis.md +7 -0
- package/plugins/antigravity/agents/brainstormer.md +7 -0
- package/plugins/antigravity/agents/epistemic-auditor.md +5 -0
- package/plugins/antigravity/hooks.json +23 -0
- package/plugins/antigravity/mcp_config.json +33 -0
- package/plugins/antigravity/plugin.json +21 -0
- package/plugins/antigravity/rules/epistemic-integrity.md +6 -0
- package/plugins/antigravity/skills/brainstorming/SKILL.md +13 -0
- package/plugins/antigravity/skills/code_audit/SKILL.md +13 -0
- package/plugins/antigravity/skills/epistemic_search/SKILL.md +13 -0
- package/plugins/antigravity/skills/grilling/SKILL.md +13 -0
- package/plugins/antigravity/skills/oss_scout/SKILL.md +13 -0
- package/plugins/antigravity/skills/research_cache/SKILL.md +13 -0
- package/plugins/antigravity/skills/swarm_config/SKILL.md +13 -0
- package/plugins/codex/AGENTS.md.snippet +10 -0
- package/plugins/codex/README.md +37 -0
- package/plugins/codex/config.toml.snippet +28 -0
- package/plugins/codex/openai.yaml +24 -0
- package/plugins/codex/skills/brainstorming/SKILL.md +13 -0
- package/plugins/codex/skills/code_audit/SKILL.md +13 -0
- package/plugins/codex/skills/epistemic_search/SKILL.md +13 -0
- package/plugins/codex/skills/grilling/SKILL.md +13 -0
- package/plugins/codex/skills/oss_scout/SKILL.md +13 -0
- package/plugins/codex/skills/research_cache/SKILL.md +13 -0
- package/plugins/codex/skills/swarm_config/SKILL.md +13 -0
- package/plugins/gemini/GEMINI.md +15 -0
- package/plugins/gemini/README.md +19 -0
- package/plugins/gemini/commands/audit.toml +6 -0
- package/plugins/gemini/commands/brainstorming.toml +10 -0
- package/plugins/gemini/commands/grill.toml +6 -0
- package/plugins/gemini/commands/scout.toml +7 -0
- package/plugins/gemini/commands/swarm-config.toml +7 -0
- package/plugins/gemini/commands/swarm.toml +8 -0
- package/plugins/gemini/gemini-extension.json +38 -0
- package/plugins/gemini/hooks/hooks.json +11 -0
- package/plugins/gemini/skills/brainstorming/SKILL.md +13 -0
- package/plugins/gemini/skills/code_audit/SKILL.md +13 -0
- package/plugins/gemini/skills/epistemic_search/SKILL.md +13 -0
- package/plugins/gemini/skills/grilling/SKILL.md +13 -0
- package/plugins/gemini/skills/oss_scout/SKILL.md +13 -0
- package/plugins/gemini/skills/research_cache/SKILL.md +13 -0
- package/plugins/gemini/skills/swarm_config/SKILL.md +13 -0
- package/plugins/opencode/index.js +335 -118
- package/prompts/agent_brainstormer.md +97 -0
- 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__/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__/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__/state_machine.cpython-311.pyc +0 -0
- package/runner/auctioneer.py +169 -0
- package/runner/auditor_engine.py +127 -12
- package/runner/claim_store.py +361 -0
- package/runner/claim_witness.py +183 -0
- package/runner/living_dossiers.py +355 -0
- package/runner/mcp_protocol.py +188 -0
- package/runner/mcp_server.py +567 -0
- package/runner/pcrb.py +212 -0
- package/runner/pcrb_verify.py +237 -0
- package/runner/refinement.py +335 -0
- package/runner/research_swarm.py +378 -94
- package/runner/tests/__pycache__/test_auction_order.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_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.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/fixtures/auction_objective.json +35 -0
- package/runner/tests/fixtures/borderline_claims.json +27 -0
- package/runner/tests/fixtures/divergence_objectives.json +31 -0
- package/runner/tests/test_auction_order.py +173 -0
- package/runner/tests/test_claim_store.py +162 -0
- package/runner/tests/test_claim_witness.py +186 -0
- package/runner/tests/test_domain_packs.py +217 -0
- package/runner/tests/test_fleet_seam.py +113 -0
- package/runner/tests/test_living_dossiers.py +204 -0
- package/runner/tests/test_mcp_server.py +212 -0
- package/runner/tests/test_pcrb.py +240 -0
- package/runner/tests/test_refinement.py +255 -0
- package/runner/tests/test_swarm.py +173 -15
- package/scripts/auction_experiment.py +180 -0
- package/scripts/build_adapters.py +183 -0
- package/scripts/divergence_experiment.py +184 -0
- package/skills/brainstorming/SKILL.md +106 -0
- package/skills/brainstorming/__init__.py +1 -0
- package/skills/brainstorming/scripts/brainstorm.py +200 -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/SKILL.md +1 -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 +45 -11
package/runner/pcrb.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Proof-Carrying Research Brief (PCRB) — export side (Stream D).
|
|
4
|
+
|
|
5
|
+
A PCRB is a self-contained signed bundle: the synthesis, every claim with its
|
|
6
|
+
quote witness, the full text of every cited source, an integrity manifest, and
|
|
7
|
+
an optional HMAC signature. A third party can re-verify everything WITHOUT
|
|
8
|
+
trusting the LLM that produced it and WITHOUT access to the original
|
|
9
|
+
.research/ workspace — verification runs against the bundled sources.
|
|
10
|
+
|
|
11
|
+
Bundle shape (brief.pcrb.json):
|
|
12
|
+
{
|
|
13
|
+
"pcrb_version": "1.0",
|
|
14
|
+
"objective": ..., "exported_at": ...,
|
|
15
|
+
"synthesis_markdown": ...,
|
|
16
|
+
"claims": [ {claim fields..., "witness": {quote, source_hash, confidence, verified_at}} ],
|
|
17
|
+
"sources": { "<hash>": {"url","title","tier","content"} },
|
|
18
|
+
"manifest": { "synthesis_markdown": sha, "claims": {id: sha}, "sources": {hash: sha} },
|
|
19
|
+
"signature": {"alg": "hmac-sha256" | "none", "key_id": ..., "sig": ...}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Member hashes use SourceHasher.compute_sha256 semantics so a bundled source's
|
|
23
|
+
member hash equals its cache hash.
|
|
24
|
+
|
|
25
|
+
Signing: stdlib hmac when a key is provided (env IUMBTEMS_PCRB_KEY or key
|
|
26
|
+
file). alg="none" provides integrity (member-vs-manifest) but NOT authenticity
|
|
27
|
+
— a coordinated rewrite of content + manifest is undetectable without a key.
|
|
28
|
+
Real PKI (ed25519) is a gated follow-on; HMAC is sufficient for the tamper
|
|
29
|
+
probe, not for the regulatory story.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import hmac
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
40
|
+
|
|
41
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
42
|
+
PROJECT_ROOT = SCRIPT_DIR.parent
|
|
43
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
44
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
45
|
+
|
|
46
|
+
from runner.claim_witness import claims_from_dossier, load_dossier # noqa: E402
|
|
47
|
+
|
|
48
|
+
PCRB_VERSION = "1.0"
|
|
49
|
+
ENV_KEY = "IUMBTEMS_PCRB_KEY"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def canonical_json(obj: Any) -> str:
|
|
53
|
+
"""Deterministic serialization for hashing/signing."""
|
|
54
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sha256_text(text: str) -> str:
|
|
58
|
+
"""SourceHasher.compute_sha256 semantics (strip + utf-8)."""
|
|
59
|
+
return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def member_hash(obj: Any) -> str:
|
|
63
|
+
"""Hash a structured member via canonical JSON."""
|
|
64
|
+
return sha256_text(canonical_json(obj))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_key(key: Optional[str], key_path: Optional[Path]) -> Optional[bytes]:
|
|
68
|
+
if key:
|
|
69
|
+
return key.encode("utf-8")
|
|
70
|
+
env = os.environ.get(ENV_KEY)
|
|
71
|
+
if env:
|
|
72
|
+
return env.encode("utf-8")
|
|
73
|
+
if key_path and Path(key_path).exists():
|
|
74
|
+
return Path(key_path).read_bytes().strip()
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def sign_manifest(manifest: Dict[str, Any], key: Optional[bytes], key_id: str = "local") -> Dict[str, Any]:
|
|
79
|
+
if not key:
|
|
80
|
+
return {"alg": "none", "key_id": None, "sig": None}
|
|
81
|
+
sig = hmac.new(key, canonical_json(manifest).encode("utf-8"), hashlib.sha256).hexdigest()
|
|
82
|
+
return {"alg": "hmac-sha256", "key_id": key_id, "sig": sig}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def export_brief(
|
|
86
|
+
base_dir: Path,
|
|
87
|
+
out_path: Optional[Path] = None,
|
|
88
|
+
scope_ids: Optional[List[str]] = None,
|
|
89
|
+
objective: Optional[str] = None,
|
|
90
|
+
key: Optional[str] = None,
|
|
91
|
+
key_path: Optional[Path] = None,
|
|
92
|
+
key_id: str = "local",
|
|
93
|
+
) -> Path:
|
|
94
|
+
"""Assemble a PCRB from a .research workspace. Returns the bundle path."""
|
|
95
|
+
from skills.research_cache.hasher import SourceHasher
|
|
96
|
+
|
|
97
|
+
base_dir = Path(base_dir)
|
|
98
|
+
hasher = SourceHasher(base_dir)
|
|
99
|
+
|
|
100
|
+
# --- synthesis ---
|
|
101
|
+
synthesis = ""
|
|
102
|
+
final_md = base_dir / "final_synthesis.md"
|
|
103
|
+
if final_md.exists():
|
|
104
|
+
synthesis = final_md.read_text(encoding="utf-8")
|
|
105
|
+
else:
|
|
106
|
+
parts = sorted((base_dir / "scratchpads").glob("*/scope_synthesis.md"))
|
|
107
|
+
synthesis = "\n\n---\n\n".join(p.read_text(encoding="utf-8") for p in parts)
|
|
108
|
+
|
|
109
|
+
# --- claims + witnesses ---
|
|
110
|
+
scratch = base_dir / "scratchpads"
|
|
111
|
+
scope_dirs = (
|
|
112
|
+
[scratch / s for s in scope_ids]
|
|
113
|
+
if scope_ids
|
|
114
|
+
else (sorted(p for p in scratch.iterdir() if p.is_dir()) if scratch.exists() else [])
|
|
115
|
+
)
|
|
116
|
+
claim_records: List[Dict[str, Any]] = []
|
|
117
|
+
needed_hashes = set()
|
|
118
|
+
|
|
119
|
+
for scope_dir in scope_dirs:
|
|
120
|
+
for dossier_name in ("alpha_dossier.json", "beta_dossier.json"):
|
|
121
|
+
dpath = scope_dir / dossier_name
|
|
122
|
+
if not dpath.exists():
|
|
123
|
+
continue
|
|
124
|
+
dossier = load_dossier(dpath)
|
|
125
|
+
for c in claims_from_dossier(dossier):
|
|
126
|
+
rec = c.to_dict()
|
|
127
|
+
witness: Dict[str, Any] = {
|
|
128
|
+
"quote": c.verbatim_quote,
|
|
129
|
+
"source_hash": c.source_hash,
|
|
130
|
+
"confidence": None,
|
|
131
|
+
"verified_at": None,
|
|
132
|
+
}
|
|
133
|
+
if c.source_hash and c.verbatim_quote:
|
|
134
|
+
passed, conf, _msg = hasher.verify_quote(c.source_hash, c.verbatim_quote)
|
|
135
|
+
witness["confidence"] = float(conf)
|
|
136
|
+
witness["verified_at"] = datetime.now(timezone.utc).isoformat()
|
|
137
|
+
if passed and c.source_hash:
|
|
138
|
+
needed_hashes.add(c.source_hash)
|
|
139
|
+
elif c.source_hash:
|
|
140
|
+
needed_hashes.add(c.source_hash)
|
|
141
|
+
rec["witness"] = witness
|
|
142
|
+
claim_records.append(rec)
|
|
143
|
+
|
|
144
|
+
# --- bundled sources (self-contained: full text in the bundle) ---
|
|
145
|
+
sources: Dict[str, Dict[str, Any]] = {}
|
|
146
|
+
for src_hash in sorted(needed_hashes):
|
|
147
|
+
content = hasher.get_source_content(src_hash)
|
|
148
|
+
meta = hasher.get_source_metadata(src_hash) or {}
|
|
149
|
+
if content is None:
|
|
150
|
+
continue
|
|
151
|
+
sources[src_hash] = {
|
|
152
|
+
"url": meta.get("url"),
|
|
153
|
+
"title": meta.get("title"),
|
|
154
|
+
"tier": meta.get("tier"),
|
|
155
|
+
"content": content,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# --- manifest (integrity) ---
|
|
159
|
+
manifest: Dict[str, Any] = {
|
|
160
|
+
"synthesis_markdown": sha256_text(synthesis),
|
|
161
|
+
"claims": {rec["claim_id"]: member_hash(rec) for rec in claim_records},
|
|
162
|
+
"sources": {h: sha256_text(s["content"]) for h, s in sources.items()},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
signature = sign_manifest(manifest, _resolve_key(key, key_path), key_id=key_id)
|
|
166
|
+
|
|
167
|
+
bundle = {
|
|
168
|
+
"pcrb_version": PCRB_VERSION,
|
|
169
|
+
"objective": objective
|
|
170
|
+
or (json.loads((base_dir / "manifest.json").read_text()).get("objective")
|
|
171
|
+
if (base_dir / "manifest.json").exists() else None),
|
|
172
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
173
|
+
"synthesis_markdown": synthesis,
|
|
174
|
+
"claims": claim_records,
|
|
175
|
+
"sources": sources,
|
|
176
|
+
"manifest": manifest,
|
|
177
|
+
"signature": signature,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
out = Path(out_path) if out_path else (base_dir / "brief.pcrb.json")
|
|
181
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
183
|
+
json.dump(bundle, f, indent=2)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
188
|
+
import argparse
|
|
189
|
+
|
|
190
|
+
parser = argparse.ArgumentParser(description="Export a proof-carrying research brief")
|
|
191
|
+
parser.add_argument("--dir", default=".research")
|
|
192
|
+
parser.add_argument("--out", default=None)
|
|
193
|
+
parser.add_argument("--scope", action="append", dest="scope_ids")
|
|
194
|
+
parser.add_argument("--objective", default=None)
|
|
195
|
+
parser.add_argument("--key-file", default=None)
|
|
196
|
+
parser.add_argument("--key-id", default="local")
|
|
197
|
+
args = parser.parse_args(argv)
|
|
198
|
+
|
|
199
|
+
path = export_brief(
|
|
200
|
+
Path(args.dir),
|
|
201
|
+
out_path=Path(args.out) if args.out else None,
|
|
202
|
+
scope_ids=args.scope_ids,
|
|
203
|
+
objective=args.objective,
|
|
204
|
+
key_path=Path(args.key_file) if args.key_file else None,
|
|
205
|
+
key_id=args.key_id,
|
|
206
|
+
)
|
|
207
|
+
print(json.dumps({"status": "exported", "path": str(path)}))
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
sys.exit(main())
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Proof-Carrying Research Brief (PCRB) — standalone verifier (Stream D).
|
|
4
|
+
|
|
5
|
+
Verifies a brief.pcrb.json WITHOUT trusting the LLM that produced it and
|
|
6
|
+
WITHOUT the original .research/ workspace: every quote is re-checked against
|
|
7
|
+
the sources BUNDLED INSIDE the brief.
|
|
8
|
+
|
|
9
|
+
Checks (all must pass for exit 0):
|
|
10
|
+
1. source identity — sha256(source content) == its hash key in the bundle
|
|
11
|
+
2. manifest integrity — every member hash recomputes to the manifest value
|
|
12
|
+
3. signature — HMAC over the manifest (when alg != "none")
|
|
13
|
+
4. quotes — every claim's verbatim_quote re-verified against the bundled
|
|
14
|
+
source text, using the SAME verification stack as the auditor
|
|
15
|
+
(SourceHasher.verify_quote via a throwaway cache directory)
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 runner/pcrb_verify.py brief.pcrb.json [--json] [--key-file ...]
|
|
19
|
+
|
|
20
|
+
Exit codes: 0 = all checks passed, 1 = tampering/failure detected,
|
|
21
|
+
2 = unreadable bundle.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
35
|
+
PROJECT_ROOT = SCRIPT_DIR.parent
|
|
36
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
37
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
38
|
+
|
|
39
|
+
# Reuse the export-side canonicalization so hashes always agree.
|
|
40
|
+
from runner.pcrb import ( # noqa: E402
|
|
41
|
+
ENV_KEY,
|
|
42
|
+
canonical_json,
|
|
43
|
+
member_hash,
|
|
44
|
+
sha256_text,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def verify_brief(
|
|
49
|
+
bundle: Dict[str, Any],
|
|
50
|
+
key: Optional[bytes] = None,
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
"""Run all checks. Returns a structured report dict."""
|
|
53
|
+
import os
|
|
54
|
+
started = time.perf_counter()
|
|
55
|
+
failures: List[Dict[str, str]] = []
|
|
56
|
+
|
|
57
|
+
manifest = bundle.get("manifest") or {}
|
|
58
|
+
signature = bundle.get("signature") or {}
|
|
59
|
+
claims = bundle.get("claims") or []
|
|
60
|
+
sources = bundle.get("sources") or {}
|
|
61
|
+
|
|
62
|
+
# --- 1. source identity ---
|
|
63
|
+
for src_hash, meta in sources.items():
|
|
64
|
+
content = meta.get("content") or ""
|
|
65
|
+
actual = sha256_text(content)
|
|
66
|
+
if actual != src_hash:
|
|
67
|
+
failures.append({
|
|
68
|
+
"check": "source_identity",
|
|
69
|
+
"member": src_hash,
|
|
70
|
+
"message": f"bundled source content hashes to {actual}, not its key {src_hash}",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
# --- 2. manifest integrity ---
|
|
74
|
+
synth_hash = sha256_text(bundle.get("synthesis_markdown") or "")
|
|
75
|
+
if manifest.get("synthesis_markdown") != synth_hash:
|
|
76
|
+
failures.append({
|
|
77
|
+
"check": "manifest_integrity",
|
|
78
|
+
"member": "synthesis_markdown",
|
|
79
|
+
"message": "synthesis_markdown does not match its manifest hash",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
for rec in claims:
|
|
83
|
+
cid = rec.get("claim_id", "UNKNOWN")
|
|
84
|
+
expected = (manifest.get("claims") or {}).get(cid)
|
|
85
|
+
actual = member_hash(rec)
|
|
86
|
+
if expected is None:
|
|
87
|
+
failures.append({"check": "manifest_integrity", "member": cid, "message": "claim missing from manifest"})
|
|
88
|
+
elif expected != actual:
|
|
89
|
+
failures.append({
|
|
90
|
+
"check": "manifest_integrity",
|
|
91
|
+
"member": cid,
|
|
92
|
+
"message": f"claim hash mismatch (manifest {expected[:16]}… vs actual {actual[:16]}…)",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
for src_hash, meta in sources.items():
|
|
96
|
+
expected = (manifest.get("sources") or {}).get(src_hash)
|
|
97
|
+
actual = sha256_text(meta.get("content") or "")
|
|
98
|
+
if expected is None:
|
|
99
|
+
failures.append({"check": "manifest_integrity", "member": src_hash, "message": "source missing from manifest"})
|
|
100
|
+
elif expected != actual:
|
|
101
|
+
failures.append({
|
|
102
|
+
"check": "manifest_integrity",
|
|
103
|
+
"member": src_hash,
|
|
104
|
+
"message": "source content does not match its manifest hash",
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
# --- 3. signature ---
|
|
108
|
+
alg = signature.get("alg") or "none"
|
|
109
|
+
sig_status = "unsigned"
|
|
110
|
+
if alg == "hmac-sha256":
|
|
111
|
+
if not key:
|
|
112
|
+
sig_status = "unverifiable"
|
|
113
|
+
failures.append({
|
|
114
|
+
"check": "signature",
|
|
115
|
+
"member": "manifest",
|
|
116
|
+
"message": "brief is HMAC-signed but no key was provided (env IUMBTEMS_PCRB_KEY or --key-file)",
|
|
117
|
+
})
|
|
118
|
+
else:
|
|
119
|
+
expected_sig = hmac.new(key, canonical_json(manifest).encode("utf-8"), hashlib.sha256).hexdigest()
|
|
120
|
+
provided = signature.get("sig") or ""
|
|
121
|
+
if not hmac.compare_digest(expected_sig, provided):
|
|
122
|
+
sig_status = "invalid"
|
|
123
|
+
failures.append({
|
|
124
|
+
"check": "signature",
|
|
125
|
+
"member": "manifest",
|
|
126
|
+
"message": "HMAC signature does not match manifest (manifest was rewritten or key differs)",
|
|
127
|
+
})
|
|
128
|
+
else:
|
|
129
|
+
sig_status = "valid"
|
|
130
|
+
# alg == "none": integrity-only mode — coordinated rewrites are NOT
|
|
131
|
+
# detectable without a key. Reported, not failed.
|
|
132
|
+
|
|
133
|
+
# --- 4. quotes against BUNDLED sources (single verification stack) ---
|
|
134
|
+
quotes_verified = 0
|
|
135
|
+
quotes_failed = 0
|
|
136
|
+
with tempfile.TemporaryDirectory(prefix="pcrb_verify_") as tmp:
|
|
137
|
+
from skills.research_cache.hasher import SourceHasher
|
|
138
|
+
|
|
139
|
+
hasher = SourceHasher(Path(tmp))
|
|
140
|
+
# Materialize bundled sources into the throwaway cache under their
|
|
141
|
+
# claimed hashes so verify_quote runs its exact/normalized/fuzzy ladder.
|
|
142
|
+
for src_hash, meta in sources.items():
|
|
143
|
+
content = meta.get("content") or ""
|
|
144
|
+
md_path = Path(tmp) / "sources" / f"{src_hash}.md"
|
|
145
|
+
md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
md_path.write_text(content, encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
for rec in claims:
|
|
149
|
+
cid = rec.get("claim_id", "UNKNOWN")
|
|
150
|
+
witness = rec.get("witness") or {}
|
|
151
|
+
src_hash = witness.get("source_hash") or rec.get("source_hash")
|
|
152
|
+
quote = witness.get("quote") or rec.get("verbatim_quote")
|
|
153
|
+
if not src_hash or not quote:
|
|
154
|
+
continue # structural rules live in refinement.check_invariants
|
|
155
|
+
if src_hash not in sources:
|
|
156
|
+
quotes_failed += 1
|
|
157
|
+
failures.append({
|
|
158
|
+
"check": "quote",
|
|
159
|
+
"member": cid,
|
|
160
|
+
"message": f"claim cites source {src_hash} which is not bundled",
|
|
161
|
+
})
|
|
162
|
+
continue
|
|
163
|
+
passed, conf, msg = hasher.verify_quote(src_hash, quote)
|
|
164
|
+
if passed:
|
|
165
|
+
quotes_verified += 1
|
|
166
|
+
else:
|
|
167
|
+
quotes_failed += 1
|
|
168
|
+
failures.append({
|
|
169
|
+
"check": "quote",
|
|
170
|
+
"member": cid,
|
|
171
|
+
"message": f"quote not found in bundled source (conf={conf}): {msg}",
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
elapsed = time.perf_counter() - started
|
|
175
|
+
return {
|
|
176
|
+
"ok": not failures,
|
|
177
|
+
"failures": failures,
|
|
178
|
+
"checks": {
|
|
179
|
+
"source_identity": "pass" if not any(f["check"] == "source_identity" for f in failures) else "fail",
|
|
180
|
+
"manifest_integrity": "pass" if not any(f["check"] == "manifest_integrity" for f in failures) else "fail",
|
|
181
|
+
"signature": sig_status,
|
|
182
|
+
"quotes": "pass" if quotes_failed == 0 else "fail",
|
|
183
|
+
},
|
|
184
|
+
"stats": {
|
|
185
|
+
"claims": len(claims),
|
|
186
|
+
"sources": len(sources),
|
|
187
|
+
"quotes_verified": quotes_verified,
|
|
188
|
+
"quotes_failed": quotes_failed,
|
|
189
|
+
"elapsed_s": round(elapsed, 4),
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
195
|
+
parser = argparse.ArgumentParser(description="Verify a proof-carrying research brief")
|
|
196
|
+
parser.add_argument("brief", help="Path to brief.pcrb.json")
|
|
197
|
+
parser.add_argument("--json", action="store_true", help="Print JSON report")
|
|
198
|
+
parser.add_argument("--key-file", default=None)
|
|
199
|
+
args = parser.parse_args(argv)
|
|
200
|
+
|
|
201
|
+
path = Path(args.brief)
|
|
202
|
+
if not path.exists():
|
|
203
|
+
print(f"error: no such brief: {path}", file=sys.stderr)
|
|
204
|
+
return 2
|
|
205
|
+
try:
|
|
206
|
+
bundle = json.loads(path.read_text(encoding="utf-8"))
|
|
207
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
208
|
+
print(f"error: unreadable brief: {exc}", file=sys.stderr)
|
|
209
|
+
return 2
|
|
210
|
+
|
|
211
|
+
import os
|
|
212
|
+
|
|
213
|
+
key = None
|
|
214
|
+
if args.key_file and Path(args.key_file).exists():
|
|
215
|
+
key = Path(args.key_file).read_bytes().strip()
|
|
216
|
+
elif os.environ.get(ENV_KEY):
|
|
217
|
+
key = os.environ[ENV_KEY].encode("utf-8")
|
|
218
|
+
|
|
219
|
+
report = verify_brief(bundle, key=key)
|
|
220
|
+
|
|
221
|
+
if args.json:
|
|
222
|
+
print(json.dumps(report, indent=2))
|
|
223
|
+
else:
|
|
224
|
+
status = "PASS" if report["ok"] else "FAIL"
|
|
225
|
+
print(f"[{status}] {path}")
|
|
226
|
+
for name, st in report["checks"].items():
|
|
227
|
+
print(f" {name}: {st}")
|
|
228
|
+
for f in report["failures"]:
|
|
229
|
+
print(f" ✗ [{f['check']}] {f['member']}: {f['message']}")
|
|
230
|
+
s = report["stats"]
|
|
231
|
+
print(f" quotes verified: {s['quotes_verified']}/{s['claims']} in {s['elapsed_s']}s")
|
|
232
|
+
|
|
233
|
+
return 0 if report["ok"] else 1
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|