@heretek-ai/epistemic-swarm 0.4.6 → 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 +310 -134
  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 +398 -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
@@ -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
 
@@ -18,6 +18,7 @@ V(c_i) has never been what the runtime computes. Tier weighting becomes an
18
18
  opt-in constitution — nothing flips without an explicit pack.
19
19
  """
20
20
 
21
+ import math
21
22
  from dataclasses import dataclass, field
22
23
  from typing import Any, Dict, Iterable, List, Optional, Tuple
23
24
 
@@ -154,6 +155,59 @@ def claim_verdict(
154
155
  }
155
156
 
156
157
 
158
+ def _check_verified_and_inferred(c: ClaimWitness, hasher: Any, out: List[Violation]) -> None:
159
+ if c.tag == TAG_VERIFIED or c.kind == KIND_CLAIM:
160
+ if not c.source_hash:
161
+ out.append(Violation(c.claim_id, "VERIFIED_REQUIRES_HASH", "source_hash missing"))
162
+ if not c.verbatim_quote:
163
+ out.append(Violation(c.claim_id, "VERIFIED_REQUIRES_QUOTE", "verbatim_quote missing"))
164
+ if hasher is not None and c.source_hash and c.verbatim_quote:
165
+ passed, _conf, msg = witness_check(c, hasher)
166
+ if not passed:
167
+ out.append(Violation(c.claim_id, "WITNESS_CHECK_FAILED", msg or "quote not in source"))
168
+
169
+ if c.tag == TAG_INFERRED or c.kind == KIND_INFERENCE:
170
+ if not c.parent_claims:
171
+ out.append(Violation(c.claim_id, "INFERRED_REQUIRES_PARENTS", "parent_claims empty"))
172
+ if not c.deductive_logic:
173
+ out.append(Violation(c.claim_id, "INFERRED_REQUIRES_LOGIC", "deductive_logic missing"))
174
+
175
+
176
+ def _check_hypotheses_and_neg_knowledge(c: ClaimWitness, out: List[Violation]) -> None:
177
+ if (c.tag == TAG_HYPOTHESIS or c.kind == KIND_HYPOTHESIS) and not c.falsification:
178
+ out.append(Violation(c.claim_id, "HYPOTHESIS_REQUIRES_FALSIFICATION", "falsification missing"))
179
+
180
+ if c.tag == TAG_NEGATIVE_KNOWLEDGE or c.kind == KIND_NEGATIVE_KNOWLEDGE:
181
+ if not c.query:
182
+ out.append(Violation(c.claim_id, "NEG_KNOWLEDGE_REQUIRES_QUERY", "query missing"))
183
+ if not c.finding:
184
+ out.append(Violation(c.claim_id, "NEG_KNOWLEDGE_REQUIRES_FINDING", "finding missing"))
185
+
186
+
187
+ def _check_constitution_and_parents(
188
+ c: ClaimWitness,
189
+ known_ids: set,
190
+ constitution: Constitution,
191
+ out: List[Violation],
192
+ ) -> None:
193
+ for parent in c.parent_claims:
194
+ if parent not in known_ids:
195
+ out.append(
196
+ Violation(c.claim_id, "PARENT_UNRESOLVED", f"parent_claims '{parent}' not in dossier set")
197
+ )
198
+
199
+ if constitution.mandatory_tags and not c.tag:
200
+ out.append(Violation(c.claim_id, "MISSING_TAG", "tag required by constitution"))
201
+
202
+ if constitution.banned_domains and c.source_url:
203
+ src_lower = c.source_url.lower()
204
+ for dom in constitution.banned_domains:
205
+ if dom.lower() in src_lower:
206
+ out.append(
207
+ Violation(c.claim_id, "BANNED_DOMAIN", f"source_url on banned domain '{dom}'")
208
+ )
209
+
210
+
157
211
  def check_invariants(
158
212
  claims: Iterable[ClaimWitness],
159
213
  hasher: Any = None,
@@ -165,54 +219,14 @@ def check_invariants(
165
219
  VERIFIED claims; without it, only structural rules are enforced.
166
220
  """
167
221
  constitution = constitution or LEGACY_CONSTITUTION
168
- claims = list(claims)
169
- known_ids = {c.claim_id for c in claims}
222
+ claim_list = list(claims)
223
+ known_ids = {c.claim_id for c in claim_list}
170
224
  out: List[Violation] = []
171
225
 
172
- for c in claims:
173
- # --- tag/kind-specific structural rules ---
174
- if c.tag == TAG_VERIFIED or c.kind == KIND_CLAIM:
175
- if not c.source_hash:
176
- out.append(Violation(c.claim_id, "VERIFIED_REQUIRES_HASH", "source_hash missing"))
177
- if not c.verbatim_quote:
178
- out.append(Violation(c.claim_id, "VERIFIED_REQUIRES_QUOTE", "verbatim_quote missing"))
179
- if hasher is not None and c.source_hash and c.verbatim_quote:
180
- passed, _conf, msg = witness_check(c, hasher)
181
- if not passed:
182
- out.append(Violation(c.claim_id, "WITNESS_CHECK_FAILED", msg or "quote not in source"))
183
-
184
- if c.tag == TAG_INFERRED or c.kind == KIND_INFERENCE:
185
- if not c.parent_claims:
186
- out.append(Violation(c.claim_id, "INFERRED_REQUIRES_PARENTS", "parent_claims empty"))
187
- if not c.deductive_logic:
188
- out.append(Violation(c.claim_id, "INFERRED_REQUIRES_LOGIC", "deductive_logic missing"))
189
-
190
- if c.tag == TAG_HYPOTHESIS or c.kind == KIND_HYPOTHESIS:
191
- if not c.falsification:
192
- out.append(Violation(c.claim_id, "HYPOTHESIS_REQUIRES_FALSIFICATION", "falsification missing"))
193
-
194
- if c.tag == TAG_NEGATIVE_KNOWLEDGE or c.kind == KIND_NEGATIVE_KNOWLEDGE:
195
- if not c.query:
196
- out.append(Violation(c.claim_id, "NEG_KNOWLEDGE_REQUIRES_QUERY", "query missing"))
197
- if not c.finding:
198
- out.append(Violation(c.claim_id, "NEG_KNOWLEDGE_REQUIRES_FINDING", "finding missing"))
199
-
200
- # --- cross-reference resolution ---
201
- for parent in c.parent_claims:
202
- if parent not in known_ids:
203
- out.append(
204
- Violation(c.claim_id, "PARENT_UNRESOLVED", f"parent_claims '{parent}' not in dossier set")
205
- )
206
-
207
- # --- domain-pack rules ---
208
- if constitution.mandatory_tags and not c.tag:
209
- out.append(Violation(c.claim_id, "MISSING_TAG", "tag required by constitution"))
210
- if constitution.banned_domains and c.source_url:
211
- for dom in constitution.banned_domains:
212
- if dom.lower() in c.source_url.lower():
213
- out.append(
214
- Violation(c.claim_id, "BANNED_DOMAIN", f"source_url on banned domain '{dom}'")
215
- )
226
+ for c in claim_list:
227
+ _check_verified_and_inferred(c, hasher, out)
228
+ _check_hypotheses_and_neg_knowledge(c, out)
229
+ _check_constitution_and_parents(c, known_ids, constitution, out)
216
230
 
217
231
  return out
218
232
 
@@ -294,7 +308,7 @@ def compute_epistemic_score_from_claims(
294
308
  }
295
309
  weight_sum = 0.0
296
310
  tier_weighted = any(
297
- w != 1.0 for w in constitution.tier_weights.values()
311
+ not math.isclose(w, 1.0, rel_tol=1e-7) for w in constitution.tier_weights.values()
298
312
  )
299
313
 
300
314
  for c in claims: