@heretek-ai/epistemic-swarm 0.4.7 → 0.4.8

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.
Files changed (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/cli.js +59 -7
  3. package/config/mcp_launcher.py +75 -48
  4. package/extensions/pi/index.js +25 -5
  5. package/install.sh +5 -5
  6. package/package.json +1 -1
  7. package/plugins/antigravity/plugin.json +1 -1
  8. package/plugins/gemini/gemini-extension.json +1 -1
  9. package/plugins/opencode/index.js +308 -142
  10. package/plugins/opencode/tui.js +7 -9
  11. package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
  12. package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
  13. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  27. package/runner/auditor_engine.py +122 -102
  28. package/runner/claim_store.py +5 -4
  29. package/runner/living_dossiers.py +32 -10
  30. package/runner/mcp_protocol.py +1 -1
  31. package/runner/mcp_server.py +23 -11
  32. package/runner/path_safety.py +62 -0
  33. package/runner/pcrb.py +68 -39
  34. package/runner/pcrb_verify.py +82 -52
  35. package/runner/refinement.py +61 -47
  36. package/runner/research_swarm.py +61 -7
  37. package/runner/state_machine.py +9 -6
  38. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  52. package/runner/tests/test_mcp_server.py +0 -1
  53. package/runner/tests/test_opencode_ux.py +368 -33
  54. package/runner/tests/test_swarm.py +20 -9
  55. package/runner/tests/test_sweep_regressions.py +373 -0
  56. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  58. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  59. package/scripts/auction_experiment.py +38 -30
  60. package/scripts/bet1_advisory_spike.py +41 -25
  61. package/scripts/build_adapters.py +52 -37
  62. package/scripts/divergence_experiment.py +61 -31
  63. package/skills/brainstorming/scripts/brainstorm.py +3 -2
  64. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  65. package/skills/epistemic_search/scripts/fetch.py +0 -1
  66. package/skills/epistemic_search/scripts/search.py +202 -73
  67. package/skills/epistemic_search/scripts/webcache.py +1 -1
  68. package/skills/grilling/socratic_tree.py +26 -18
  69. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  70. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  71. package/skills/research_cache/hasher.py +27 -9
  72. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  73. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  74. package/skills/swarm_config/configure.py +32 -28
@@ -63,6 +63,13 @@ def _capture_stdout(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> str:
63
63
  # tool handlers
64
64
  # ---------------------------------------------------------------------------
65
65
 
66
+ DEFAULT_RESEARCH_DIR = ".research"
67
+
68
+
69
+ def _resolve_base_dir(args: Dict[str, Any]) -> Path:
70
+ raw = args.get("base_dir") or args.get("dir") or DEFAULT_RESEARCH_DIR
71
+ return Path(os.path.realpath(str(raw)))
72
+
66
73
  def _handle_config(args: Dict[str, Any]) -> str:
67
74
  """Inspect or modify swarm parameters in .research/config.json."""
68
75
  from skills.swarm_config.configure import (
@@ -71,7 +78,7 @@ def _handle_config(args: Dict[str, Any]) -> str:
71
78
  save_config,
72
79
  )
73
80
 
74
- base_dir = args.get("base_dir") or args.get("dir") or ".research"
81
+ base_dir = str(_resolve_base_dir(args))
75
82
  cfg = load_config(base_dir)
76
83
 
77
84
  updates = {}
@@ -102,7 +109,7 @@ def _run_swarm_mode(mode: Optional[str], args: Dict[str, Any]) -> str:
102
109
 
103
110
  frontier = args.get("frontier") or args.get("frontier_file")
104
111
  runner = SwarmRunner(
105
- base_dir=Path(args.get("base_dir") or args.get("dir") or ".research"),
112
+ base_dir=_resolve_base_dir(args),
106
113
  mock_mode=bool(args.get("mock_claude") or args.get("mock_mode")),
107
114
  mode=mode or args.get("mode"),
108
115
  engine=args.get("engine"),
@@ -155,7 +162,7 @@ def _handle_verify_quote(args: Dict[str, Any]) -> str:
155
162
  if not content_hash or quote is None:
156
163
  raise ValueError("hash and quote are required")
157
164
 
158
- hasher = SourceHasher(Path(args.get("base_dir") or args.get("dir") or ".research"))
165
+ hasher = SourceHasher(_resolve_base_dir(args))
159
166
  is_verified, confidence, message = hasher.verify_quote(content_hash, quote)
160
167
  return _tool_text({
161
168
  "verified": bool(is_verified),
@@ -173,7 +180,7 @@ def _handle_report_retraction(args: Dict[str, Any]) -> str:
173
180
  event = args.get("event")
174
181
  if not source_hash or not event:
175
182
  raise ValueError("hash and event are required")
176
- base_dir = Path(args.get("base_dir") or args.get("dir") or ".research")
183
+ base_dir = _resolve_base_dir(args)
177
184
  path = write_retraction(
178
185
  base_dir,
179
186
  source_hash,
@@ -188,7 +195,7 @@ def _handle_check_staleness(args: Dict[str, Any]) -> str:
188
195
  """Run one claim-degradation pass (retractions -> STALE/SUSPECT + requeue)."""
189
196
  from runner.living_dossiers import check_staleness
190
197
 
191
- base_dir = Path(args.get("base_dir") or args.get("dir") or ".research")
198
+ base_dir = _resolve_base_dir(args)
192
199
  scope_ids = args.get("scope_ids")
193
200
  return _tool_text(check_staleness(base_dir, scope_ids=scope_ids))
194
201
 
@@ -202,7 +209,7 @@ def _handle_set_domain_pack(args: Dict[str, Any]) -> str:
202
209
  if not pack:
203
210
  raise ValueError("pack is required")
204
211
  constitution = load_domain_pack(pack) # raises if unknown — fail loudly
205
- base_dir = args.get("base_dir") or args.get("dir") or ".research"
212
+ base_dir = str(_resolve_base_dir(args))
206
213
  cfg = load_config(base_dir)
207
214
  cfg["domain_pack"] = pack
208
215
  save_config(cfg, base_dir)
@@ -219,7 +226,7 @@ def _handle_export_brief(args: Dict[str, Any]) -> str:
219
226
  """Export a proof-carrying research brief (signed, self-contained bundle)."""
220
227
  from runner.pcrb import export_brief
221
228
 
222
- base_dir = Path(args.get("base_dir") or args.get("dir") or ".research")
229
+ base_dir = _resolve_base_dir(args)
223
230
  key = args.get("key")
224
231
  key_path = Path(args["key_file"]) if args.get("key_file") else None
225
232
  path = export_brief(
@@ -244,7 +251,7 @@ def _handle_verify_brief(args: Dict[str, Any]) -> str:
244
251
  brief = args.get("brief") or args.get("path")
245
252
  if not brief:
246
253
  raise ValueError("brief path is required")
247
- p = Path(brief)
254
+ p = Path(os.path.realpath(str(brief)))
248
255
  if not p.exists():
249
256
  raise FileNotFoundError(f"no such brief: {p}")
250
257
  bundle = json.loads(p.read_text(encoding="utf-8"))
@@ -252,8 +259,13 @@ def _handle_verify_brief(args: Dict[str, Any]) -> str:
252
259
  key = None
253
260
  if args.get("key"):
254
261
  key = args["key"].encode("utf-8")
255
- elif args.get("key_file") and Path(args["key_file"]).exists():
256
- key = Path(args["key_file"]).read_bytes().strip()
262
+ elif args.get("key_file"):
263
+ kf = Path(os.path.realpath(str(args["key_file"])))
264
+ # An explicit key_file that cannot be read must surface as an error, not
265
+ # a silent fallthrough to `alg: "none"`.
266
+ if not kf.is_file():
267
+ raise ValueError(f"key file is not a readable file: {args['key_file']!r}")
268
+ key = kf.read_bytes().strip()
257
269
  elif os.environ.get(ENV_KEY):
258
270
  key = os.environ[ENV_KEY].encode("utf-8")
259
271
 
@@ -264,7 +276,7 @@ def _handle_reindex_claims(args: Dict[str, Any]) -> str:
264
276
  """Rebuild the derived claims.sqlite index from .research flat files."""
265
277
  from runner.claim_store import reindex
266
278
 
267
- base_dir = Path(args.get("base_dir") or args.get("dir") or ".research")
279
+ base_dir = _resolve_base_dir(args)
268
280
  return _tool_text(reindex(base_dir))
269
281
 
270
282
 
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Canonical path safety and containment helpers.
3
+
4
+ Trust boundary
5
+ --------------
6
+ These helpers exist for the real S8707 pattern: an **untrusted relative segment
7
+ joined under a trusted base** (a cache key, a scope id, a filename from an LLM).
8
+ That is where containment must be enforced.
9
+
10
+ They are deliberately *not* applied to caller-chosen CLI/tool arguments such as
11
+ ``pcrb --out``, ``socratic_tree --file`` or ``--key-file``. Those are the
12
+ principal's own paths: the user (or the orchestrating agent) is the trust root,
13
+ and restricting them would break legitimate use like writing a brief to
14
+ ``~/reports/``. Those sites canonicalise with ``os.path.realpath`` only. Do not
15
+ "tighten" them by routing through :func:`safe_resolve_path` — that is a behavior
16
+ change, not a hardening.
17
+ """
18
+
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Union
22
+
23
+
24
+ def safe_resolve_path(untrusted_path: Union[str, Path], base_dir: Union[str, Path]) -> Path:
25
+ """Canonicalise a path and require that it stays inside ``base_dir``.
26
+
27
+ Accepts either an absolute path or one relative to ``base_dir``. An absolute
28
+ path is allowed only when it already resolves inside the base; ``..``
29
+ segments and symlinks are resolved *before* the containment test, so neither
30
+ can be used to escape.
31
+
32
+ Raises ValueError if the resolved path escapes ``base_dir``.
33
+ """
34
+ base = os.path.realpath(str(base_dir))
35
+ raw = str(untrusted_path)
36
+ # os.path.join would silently drop `base` for an absolute second argument,
37
+ # so decide the candidate explicitly.
38
+ candidate = raw if os.path.isabs(raw) else os.path.join(base, raw)
39
+ target = os.path.realpath(candidate)
40
+ base_prefix = base if base.endswith(os.sep) else base + os.sep
41
+ if target != base and not target.startswith(base_prefix):
42
+ raise ValueError(f"Path traversal detected: {untrusted_path!r} escapes {base_dir!r}")
43
+ return Path(target)
44
+
45
+
46
+ def safe_join(base_dir: Union[str, Path], *segments: str) -> Path:
47
+ """Join untrusted *relative* segments under a trusted base, with containment.
48
+
49
+ This is the S8707 shape: ``base_dir / f"{user_token}.md"``. Each segment must
50
+ be a bare name — no separators, no ``..`` — and the joined result must still
51
+ resolve inside ``base_dir``.
52
+
53
+ Raises ValueError on any segment that could escape.
54
+ """
55
+ for segment in segments:
56
+ if not segment or segment in (".", ".."):
57
+ raise ValueError(f"Unsafe path segment: {segment!r}")
58
+ if os.sep in segment or (os.altsep and os.altsep in segment):
59
+ raise ValueError(f"Path separator in segment: {segment!r}")
60
+ if Path(segment).name != segment:
61
+ raise ValueError(f"Unsafe path segment: {segment!r}")
62
+ return safe_resolve_path(os.path.join(*segments), base_dir)
package/runner/pcrb.py CHANGED
@@ -70,8 +70,16 @@ def _resolve_key(key: Optional[str], key_path: Optional[Path]) -> Optional[bytes
70
70
  env = os.environ.get(ENV_KEY)
71
71
  if env:
72
72
  return env.encode("utf-8")
73
- if key_path and Path(key_path).exists():
74
- return Path(key_path).read_bytes().strip()
73
+ if key_path:
74
+ # An explicitly supplied key file that cannot be read must fail loudly.
75
+ # Returning None here would sign the brief with `alg: "none"` and report
76
+ # success — a typo'd path silently produces an unauthenticated bundle.
77
+ canonical_key_path = Path(os.path.realpath(str(key_path)))
78
+ if not canonical_key_path.is_file():
79
+ raise ValueError(
80
+ f"key file is not a readable file: {key_path!r} (resolved {canonical_key_path})"
81
+ )
82
+ return canonical_key_path.read_bytes().strip()
75
83
  return None
76
84
 
77
85
 
@@ -82,40 +90,19 @@ def sign_manifest(manifest: Dict[str, Any], key: Optional[bytes], key_id: str =
82
90
  return {"alg": "hmac-sha256", "key_id": key_id, "sig": sig}
83
91
 
84
92
 
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 = ""
93
+ def _resolve_synthesis(base_dir: Path) -> str:
102
94
  final_md = base_dir / "final_synthesis.md"
103
95
  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)
96
+ return final_md.read_text(encoding="utf-8")
97
+ parts = sorted((base_dir / "scratchpads").glob("*/scope_synthesis.md"))
98
+ return "\n\n---\n\n".join(p.read_text(encoding="utf-8") for p in parts)
108
99
 
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
- )
100
+
101
+ def _collect_claims(
102
+ scope_dirs: List[Path], hasher: Any
103
+ ) -> Tuple[List[Dict[str, Any]], set]:
116
104
  claim_records: List[Dict[str, Any]] = []
117
105
  needed_hashes = set()
118
-
119
106
  for scope_dir in scope_dirs:
120
107
  for dossier_name in ("alpha_dossier.json", "beta_dossier.json"):
121
108
  dpath = scope_dir / dossier_name
@@ -140,6 +127,46 @@ def export_brief(
140
127
  needed_hashes.add(c.source_hash)
141
128
  rec["witness"] = witness
142
129
  claim_records.append(rec)
130
+ return claim_records, needed_hashes
131
+
132
+
133
+ def _resolve_objective(base_dir: Path, objective: Optional[str]) -> Optional[str]:
134
+ if objective:
135
+ return objective
136
+ manifest_p = base_dir / "manifest.json"
137
+ if manifest_p.exists():
138
+ try:
139
+ return json.loads(manifest_p.read_text(encoding="utf-8")).get("objective")
140
+ except (OSError, json.JSONDecodeError):
141
+ return None
142
+ return None
143
+
144
+
145
+ def export_brief(
146
+ base_dir: Path,
147
+ out_path: Optional[Path] = None,
148
+ scope_ids: Optional[List[str]] = None,
149
+ objective: Optional[str] = None,
150
+ key: Optional[str] = None,
151
+ key_path: Optional[Path] = None,
152
+ key_id: str = "local",
153
+ ) -> Path:
154
+ """Assemble a PCRB from a .research workspace. Returns the bundle path."""
155
+ from skills.research_cache.hasher import SourceHasher
156
+
157
+ base_dir = Path(os.path.realpath(str(base_dir)))
158
+ hasher = SourceHasher(base_dir)
159
+ synthesis = _resolve_synthesis(base_dir)
160
+
161
+ scratch = base_dir / "scratchpads"
162
+ if scope_ids:
163
+ scope_dirs = [scratch / s for s in scope_ids]
164
+ elif scratch.exists():
165
+ scope_dirs = sorted(p for p in scratch.iterdir() if p.is_dir())
166
+ else:
167
+ scope_dirs = []
168
+
169
+ claim_records, needed_hashes = _collect_claims(scope_dirs, hasher)
143
170
 
144
171
  # --- bundled sources (self-contained: full text in the bundle) ---
145
172
  sources: Dict[str, Dict[str, Any]] = {}
@@ -162,13 +189,12 @@ def export_brief(
162
189
  "sources": {h: sha256_text(s["content"]) for h, s in sources.items()},
163
190
  }
164
191
 
165
- signature = sign_manifest(manifest, _resolve_key(key, key_path), key_id=key_id)
192
+ resolved_key = _resolve_key(key, key_path)
193
+ signature = sign_manifest(manifest, resolved_key, key_id=key_id)
166
194
 
167
195
  bundle = {
168
196
  "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),
197
+ "objective": _resolve_objective(base_dir, objective),
172
198
  "exported_at": datetime.now(timezone.utc).isoformat(),
173
199
  "synthesis_markdown": synthesis,
174
200
  "claims": claim_records,
@@ -177,7 +203,7 @@ def export_brief(
177
203
  "signature": signature,
178
204
  }
179
205
 
180
- out = Path(out_path) if out_path else (base_dir / "brief.pcrb.json")
206
+ out = Path(os.path.realpath(str(out_path))) if out_path else (base_dir / "brief.pcrb.json")
181
207
  out.parent.mkdir(parents=True, exist_ok=True)
182
208
  with open(out, "w", encoding="utf-8") as f:
183
209
  json.dump(bundle, f, indent=2)
@@ -196,12 +222,15 @@ def main(argv: Optional[List[str]] = None) -> int:
196
222
  parser.add_argument("--key-id", default="local")
197
223
  args = parser.parse_args(argv)
198
224
 
225
+ canonical_dir = Path(os.path.realpath(str(args.dir)))
226
+ canonical_out = Path(os.path.realpath(str(args.out))) if args.out else None
227
+ canonical_key = Path(os.path.realpath(str(args.key_file))) if args.key_file else None
199
228
  path = export_brief(
200
- Path(args.dir),
201
- out_path=Path(args.out) if args.out else None,
229
+ canonical_dir,
230
+ out_path=canonical_out,
202
231
  scope_ids=args.scope_ids,
203
232
  objective=args.objective,
204
- key_path=Path(args.key_file) if args.key_file else None,
233
+ key_path=canonical_key,
205
234
  key_id=args.key_id,
206
235
  )
207
236
  print(json.dumps({"status": "exported", "path": str(path)}))
@@ -25,6 +25,7 @@ import argparse
25
25
  import hashlib
26
26
  import hmac
27
27
  import json
28
+ import os
28
29
  import sys
29
30
  import tempfile
30
31
  import time
@@ -45,21 +46,8 @@ from runner.pcrb import ( # noqa: E402
45
46
  )
46
47
 
47
48
 
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 ---
49
+ def _check_source_identity(sources: Dict[str, Any]) -> List[Dict[str, str]]:
50
+ failures = []
63
51
  for src_hash, meta in sources.items():
64
52
  content = meta.get("content") or ""
65
53
  actual = sha256_text(content)
@@ -69,8 +57,16 @@ def verify_brief(
69
57
  "member": src_hash,
70
58
  "message": f"bundled source content hashes to {actual}, not its key {src_hash}",
71
59
  })
60
+ return failures
61
+
72
62
 
73
- # --- 2. manifest integrity ---
63
+ def _check_manifest_integrity(
64
+ bundle: Dict[str, Any],
65
+ manifest: Dict[str, Any],
66
+ claims: List[Dict[str, Any]],
67
+ sources: Dict[str, Any],
68
+ ) -> List[Dict[str, str]]:
69
+ failures = []
74
70
  synth_hash = sha256_text(bundle.get("synthesis_markdown") or "")
75
71
  if manifest.get("synthesis_markdown") != synth_hash:
76
72
  failures.append({
@@ -79,9 +75,10 @@ def verify_brief(
79
75
  "message": "synthesis_markdown does not match its manifest hash",
80
76
  })
81
77
 
78
+ manifest_claims = manifest.get("claims") or {}
82
79
  for rec in claims:
83
80
  cid = rec.get("claim_id", "UNKNOWN")
84
- expected = (manifest.get("claims") or {}).get(cid)
81
+ expected = manifest_claims.get(cid)
85
82
  actual = member_hash(rec)
86
83
  if expected is None:
87
84
  failures.append({"check": "manifest_integrity", "member": cid, "message": "claim missing from manifest"})
@@ -92,8 +89,9 @@ def verify_brief(
92
89
  "message": f"claim hash mismatch (manifest {expected[:16]}… vs actual {actual[:16]}…)",
93
90
  })
94
91
 
92
+ manifest_sources = manifest.get("sources") or {}
95
93
  for src_hash, meta in sources.items():
96
- expected = (manifest.get("sources") or {}).get(src_hash)
94
+ expected = manifest_sources.get(src_hash)
97
95
  actual = sha256_text(meta.get("content") or "")
98
96
  if expected is None:
99
97
  failures.append({"check": "manifest_integrity", "member": src_hash, "message": "source missing from manifest"})
@@ -103,42 +101,49 @@ def verify_brief(
103
101
  "member": src_hash,
104
102
  "message": "source content does not match its manifest hash",
105
103
  })
104
+ return failures
105
+
106
106
 
107
- # --- 3. signature ---
107
+ def _check_signature(
108
+ signature: Dict[str, Any],
109
+ manifest: Dict[str, Any],
110
+ key: Optional[bytes],
111
+ ) -> Tuple[str, List[Dict[str, str]]]:
108
112
  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.
113
+ failures = []
114
+ if alg != "hmac-sha256":
115
+ return "unsigned", failures
116
+ if not key:
117
+ failures.append({
118
+ "check": "signature",
119
+ "member": "manifest",
120
+ "message": "brief is HMAC-signed but no key was provided (env IUMBTEMS_PCRB_KEY or --key-file)",
121
+ })
122
+ return "unverifiable", failures
123
+
124
+ expected_sig = hmac.new(key, canonical_json(manifest).encode("utf-8"), hashlib.sha256).hexdigest()
125
+ provided = signature.get("sig") or ""
126
+ if not hmac.compare_digest(expected_sig, provided):
127
+ failures.append({
128
+ "check": "signature",
129
+ "member": "manifest",
130
+ "message": "HMAC signature does not match manifest (manifest was rewritten or key differs)",
131
+ })
132
+ return "invalid", failures
133
+ return "valid", failures
134
+
132
135
 
133
- # --- 4. quotes against BUNDLED sources (single verification stack) ---
136
+ def _check_quotes(
137
+ claims: List[Dict[str, Any]],
138
+ sources: Dict[str, Any],
139
+ ) -> Tuple[int, int, List[Dict[str, str]]]:
134
140
  quotes_verified = 0
135
141
  quotes_failed = 0
142
+ failures = []
136
143
  with tempfile.TemporaryDirectory(prefix="pcrb_verify_") as tmp:
137
144
  from skills.research_cache.hasher import SourceHasher
138
145
 
139
146
  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
147
  for src_hash, meta in sources.items():
143
148
  content = meta.get("content") or ""
144
149
  md_path = Path(tmp) / "sources" / f"{src_hash}.md"
@@ -151,7 +156,7 @@ def verify_brief(
151
156
  src_hash = witness.get("source_hash") or rec.get("source_hash")
152
157
  quote = witness.get("quote") or rec.get("verbatim_quote")
153
158
  if not src_hash or not quote:
154
- continue # structural rules live in refinement.check_invariants
159
+ continue
155
160
  if src_hash not in sources:
156
161
  quotes_failed += 1
157
162
  failures.append({
@@ -170,6 +175,27 @@ def verify_brief(
170
175
  "member": cid,
171
176
  "message": f"quote not found in bundled source (conf={conf}): {msg}",
172
177
  })
178
+ return quotes_verified, quotes_failed, failures
179
+
180
+
181
+ def verify_brief(
182
+ bundle: Dict[str, Any],
183
+ key: Optional[bytes] = None,
184
+ ) -> Dict[str, Any]:
185
+ """Run all checks. Returns a structured report dict."""
186
+ started = time.perf_counter()
187
+ manifest = bundle.get("manifest") or {}
188
+ signature = bundle.get("signature") or {}
189
+ claims = bundle.get("claims") or []
190
+ sources = bundle.get("sources") or {}
191
+
192
+ failures: List[Dict[str, str]] = []
193
+ failures.extend(_check_source_identity(sources))
194
+ failures.extend(_check_manifest_integrity(bundle, manifest, claims, sources))
195
+ sig_status, sig_failures = _check_signature(signature, manifest, key)
196
+ failures.extend(sig_failures)
197
+ quotes_verified, quotes_failed, quote_failures = _check_quotes(claims, sources)
198
+ failures.extend(quote_failures)
173
199
 
174
200
  elapsed = time.perf_counter() - started
175
201
  return {
@@ -198,8 +224,8 @@ def main(argv: Optional[List[str]] = None) -> int:
198
224
  parser.add_argument("--key-file", default=None)
199
225
  args = parser.parse_args(argv)
200
226
 
201
- path = Path(args.brief)
202
- if not path.exists():
227
+ path = Path(os.path.realpath(str(args.brief)))
228
+ if not path.is_file():
203
229
  print(f"error: no such brief: {path}", file=sys.stderr)
204
230
  return 2
205
231
  try:
@@ -208,11 +234,15 @@ def main(argv: Optional[List[str]] = None) -> int:
208
234
  print(f"error: unreadable brief: {exc}", file=sys.stderr)
209
235
  return 2
210
236
 
211
- import os
212
-
213
237
  key = None
214
- if args.key_file and Path(args.key_file).exists():
215
- key = Path(args.key_file).read_bytes().strip()
238
+ if args.key_file:
239
+ key_file_path = Path(os.path.realpath(str(args.key_file)))
240
+ # Fail loudly rather than fall through to an unsigned verification:
241
+ # a typo'd --key-file would otherwise silently verify with no key.
242
+ if not key_file_path.is_file():
243
+ print(f"error: key file is not a readable file: {args.key_file}", file=sys.stderr)
244
+ return 2
245
+ key = key_file_path.read_bytes().strip()
216
246
  elif os.environ.get(ENV_KEY):
217
247
  key = os.environ[ENV_KEY].encode("utf-8")
218
248