@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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.js +59 -7
- package/config/mcp_launcher.py +75 -48
- package/extensions/pi/index.js +25 -5
- package/install.sh +5 -5
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/gemini/gemini-extension.json +1 -1
- package/plugins/opencode/index.js +310 -134
- package/plugins/opencode/tui.js +7 -9
- package/plugins/research-cache/skills/research_cache/hasher.py +27 -9
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +26 -18
- 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__/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__/state_machine.cpython-311.pyc +0 -0
- package/runner/auditor_engine.py +122 -102
- package/runner/claim_store.py +5 -4
- package/runner/living_dossiers.py +32 -10
- package/runner/mcp_protocol.py +1 -1
- package/runner/mcp_server.py +23 -11
- package/runner/path_safety.py +62 -0
- package/runner/pcrb.py +68 -39
- package/runner/pcrb_verify.py +82 -52
- package/runner/refinement.py +61 -47
- package/runner/research_swarm.py +61 -7
- package/runner/state_machine.py +9 -6
- package/runner/tests/__pycache__/test_auction_order.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_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_opencode_ux.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_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_mcp_server.py +0 -1
- package/runner/tests/test_opencode_ux.py +398 -33
- package/runner/tests/test_swarm.py +20 -9
- package/runner/tests/test_sweep_regressions.py +373 -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/scripts/auction_experiment.py +38 -30
- package/scripts/bet1_advisory_spike.py +41 -25
- package/scripts/build_adapters.py +52 -37
- package/scripts/divergence_experiment.py +61 -31
- package/skills/brainstorming/scripts/brainstorm.py +3 -2
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/fetch.py +0 -1
- package/skills/epistemic_search/scripts/search.py +202 -73
- package/skills/epistemic_search/scripts/webcache.py +1 -1
- package/skills/grilling/socratic_tree.py +26 -18
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +27 -9
- 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 +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
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
scope_dirs
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
out_path=
|
|
229
|
+
canonical_dir,
|
|
230
|
+
out_path=canonical_out,
|
|
202
231
|
scope_ids=args.scope_ids,
|
|
203
232
|
objective=args.objective,
|
|
204
|
-
key_path=
|
|
233
|
+
key_path=canonical_key,
|
|
205
234
|
key_id=args.key_id,
|
|
206
235
|
)
|
|
207
236
|
print(json.dumps({"status": "exported", "path": str(path)}))
|
package/runner/pcrb_verify.py
CHANGED
|
@@ -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
|
|
49
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
if alg
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
215
|
-
|
|
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
|
|
package/runner/refinement.py
CHANGED
|
@@ -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
|
-
|
|
169
|
-
known_ids = {c.claim_id for c in
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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:
|