@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.
- 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 +308 -142
- 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 +368 -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
package/runner/mcp_server.py
CHANGED
|
@@ -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 =
|
|
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=
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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")
|
|
256
|
-
|
|
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 =
|
|
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
|
|
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
|
|