@heretek-ai/epistemic-swarm 0.7.25 → 1.0.2
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/index.ts +1 -0
- package/package.json +63 -139
- package/rpc.ts +1 -0
- package/server.ts +1 -0
- package/src/agents.ts +69 -0
- package/src/continue.ts +40 -0
- package/src/hooks.ts +283 -0
- package/src/panels.tsx +220 -0
- package/src/policy.ts +221 -0
- package/src/pr.ts +37 -0
- package/src/research.ts +63 -0
- package/src/rpc-def.ts +136 -0
- package/src/rpc.ts +331 -0
- package/src/runtime.ts +112 -0
- package/src/server.ts +311 -0
- package/src/session.ts +61 -0
- package/src/tools.ts +61 -0
- package/src/tui.tsx +206 -0
- package/tui.tsx +1 -0
- package/.agents/skills/brainstorming/SKILL.md +0 -19
- package/.agents/skills/code_audit/SKILL.md +0 -19
- package/.agents/skills/darkharvest/SKILL.md +0 -19
- package/.agents/skills/epistemic_search/SKILL.md +0 -19
- package/.agents/skills/factory/SKILL.md +0 -19
- package/.agents/skills/grilling/SKILL.md +0 -19
- package/.agents/skills/oss_scout/SKILL.md +0 -19
- package/.agents/skills/research_cache/SKILL.md +0 -19
- package/.agents/skills/swarm_config/SKILL.md +0 -19
- package/.claude-plugin/marketplace.json +0 -61
- package/.claude-plugin/plugin.json +0 -76
- package/.omp/README.md +0 -39
- package/.omp/SYSTEM.md +0 -17
- package/.omp/commands/audit.md +0 -10
- package/.omp/commands/brainstorming.md +0 -12
- package/.omp/commands/darkharvest.md +0 -11
- package/.omp/commands/domainexpansion.md +0 -9
- package/.omp/commands/factory.md +0 -9
- package/.omp/commands/grill.md +0 -9
- package/.omp/commands/scout.md +0 -10
- package/.omp/commands/swarm-config.md +0 -10
- package/.omp/commands/swarm.md +0 -9
- package/.omp/hooks/post/epistemic-audit.ts +0 -16
- package/.omp/hooks/pre/epistemic-redirect.ts +0 -21
- package/.omp/prompts/brainstorming.md +0 -8
- package/.omp/prompts/swarm.md +0 -8
- package/LICENSE +0 -126
- package/MARKETPLACE.md +0 -130
- package/README.md +0 -403
- package/bin/cli.js +0 -297
- package/config/claude-settings-patch.json +0 -10
- package/config/docker-compose.infra.yml +0 -23
- package/config/domain_packs/biopharma.json +0 -23
- package/config/domain_packs/legal.json +0 -19
- package/config/domain_packs/quant.json +0 -19
- package/config/mcp-research-servers.json +0 -33
- package/config/mcp_launcher.py +0 -153
- package/config/opencode-snippet.json +0 -156
- package/config/searxng_mcp.py +0 -92
- package/extensions/pi/index.js +0 -357
- package/hooks/hooks.json +0 -45
- package/install.sh +0 -473
- package/plugins/antigravity/README.md +0 -72
- package/plugins/antigravity/agents/alpha-thesis.md +0 -36
- package/plugins/antigravity/agents/beta-antithesis.md +0 -35
- package/plugins/antigravity/agents/brainstormer.md +0 -27
- package/plugins/antigravity/agents/code-auditor.md +0 -28
- package/plugins/antigravity/agents/darkharvester.md +0 -30
- package/plugins/antigravity/agents/epistemic-auditor.md +0 -28
- package/plugins/antigravity/agents/manager.md +0 -26
- package/plugins/antigravity/agents/oss-scout.md +0 -27
- package/plugins/antigravity/agents/programmer.md +0 -25
- package/plugins/antigravity/agents/qa-a.md +0 -22
- package/plugins/antigravity/agents/qa-b.md +0 -22
- package/plugins/antigravity/antigravity_hooks.py +0 -265
- package/plugins/antigravity/commands/audit.md +0 -11
- package/plugins/antigravity/commands/brainstorm.md +0 -9
- package/plugins/antigravity/commands/brainstorming.md +0 -10
- package/plugins/antigravity/commands/darkharvest.md +0 -12
- package/plugins/antigravity/commands/domainexpansion.md +0 -12
- package/plugins/antigravity/commands/factory.md +0 -14
- package/plugins/antigravity/commands/grill.md +0 -10
- package/plugins/antigravity/commands/scout.md +0 -11
- package/plugins/antigravity/commands/swarm-config.md +0 -9
- package/plugins/antigravity/commands/swarm.md +0 -12
- package/plugins/antigravity/hook_bus_bridge.js +0 -352
- package/plugins/antigravity/hooks.json +0 -44
- package/plugins/antigravity/mcp_config.json +0 -33
- package/plugins/antigravity/mcp_launcher.py +0 -58
- package/plugins/antigravity/plugin.json +0 -13
- package/plugins/antigravity/rules/AGENTS.md +0 -127
- package/plugins/antigravity/rules/epistemic-integrity.md +0 -16
- package/plugins/antigravity/skills/brainstorming/SKILL.md +0 -19
- package/plugins/antigravity/skills/code_audit/SKILL.md +0 -19
- package/plugins/antigravity/skills/darkharvest/SKILL.md +0 -19
- package/plugins/antigravity/skills/epistemic_search/SKILL.md +0 -19
- package/plugins/antigravity/skills/factory/SKILL.md +0 -19
- package/plugins/antigravity/skills/grilling/SKILL.md +0 -19
- package/plugins/antigravity/skills/oss_scout/SKILL.md +0 -19
- package/plugins/antigravity/skills/research_cache/SKILL.md +0 -19
- package/plugins/antigravity/skills/swarm_config/SKILL.md +0 -19
- package/plugins/codex/AGENTS.md.snippet +0 -10
- package/plugins/codex/README.md +0 -37
- package/plugins/codex/config.toml.snippet +0 -28
- package/plugins/codex/openai.yaml +0 -24
- package/plugins/codex/skills/brainstorming/SKILL.md +0 -19
- package/plugins/codex/skills/code_audit/SKILL.md +0 -19
- package/plugins/codex/skills/darkharvest/SKILL.md +0 -19
- package/plugins/codex/skills/epistemic_search/SKILL.md +0 -19
- package/plugins/codex/skills/factory/SKILL.md +0 -19
- package/plugins/codex/skills/grilling/SKILL.md +0 -19
- package/plugins/codex/skills/oss_scout/SKILL.md +0 -19
- package/plugins/codex/skills/research_cache/SKILL.md +0 -19
- package/plugins/codex/skills/swarm_config/SKILL.md +0 -19
- package/plugins/darkharvest/.claude-plugin/plugin.json +0 -15
- package/plugins/darkharvest/agents/harvest-proponent.md +0 -22
- package/plugins/darkharvest/agents/harvest-redteam.md +0 -20
- package/plugins/darkharvest/evals/teardown-verdict/graders/license-line.md +0 -6
- package/plugins/darkharvest/evals/teardown-verdict/graders/skill-fired.md +0 -5
- package/plugins/darkharvest/evals/teardown-verdict/prompt.md +0 -6
- package/plugins/darkharvest/skills/darkharvest/SKILL.md +0 -70
- package/plugins/darkharvest/skills/darkharvest/scripts/harvest.py +0 -289
- package/plugins/factory/.claude-plugin/plugin.json +0 -15
- package/plugins/factory/agents/factory-manager.md +0 -22
- package/plugins/factory/agents/programmer.md +0 -16
- package/plugins/factory/agents/qa-adversarial.md +0 -17
- package/plugins/factory/agents/qa-functional.md +0 -17
- package/plugins/factory/evals/gate-halt/graders/gates-first.md +0 -6
- package/plugins/factory/evals/gate-halt/graders/skill-fired.md +0 -5
- package/plugins/factory/evals/gate-halt/prompt.md +0 -6
- package/plugins/factory/skills/factory/SKILL.md +0 -61
- package/plugins/factory/skills/factory/scripts/factory.py +0 -300
- package/plugins/gemini/GEMINI.md +0 -16
- package/plugins/gemini/README.md +0 -20
- package/plugins/gemini/commands/audit.toml +0 -6
- package/plugins/gemini/commands/brainstorming.toml +0 -10
- package/plugins/gemini/commands/darkharvest.toml +0 -8
- package/plugins/gemini/commands/domainexpansion.toml +0 -7
- package/plugins/gemini/commands/factory.toml +0 -8
- package/plugins/gemini/commands/grill.toml +0 -6
- package/plugins/gemini/commands/scout.toml +0 -7
- package/plugins/gemini/commands/swarm-config.toml +0 -7
- package/plugins/gemini/commands/swarm.toml +0 -8
- package/plugins/gemini/gemini-extension.json +0 -38
- package/plugins/gemini/hooks/hooks.json +0 -11
- package/plugins/gemini/skills/brainstorming/SKILL.md +0 -19
- package/plugins/gemini/skills/code_audit/SKILL.md +0 -19
- package/plugins/gemini/skills/darkharvest/SKILL.md +0 -19
- package/plugins/gemini/skills/epistemic_search/SKILL.md +0 -19
- package/plugins/gemini/skills/factory/SKILL.md +0 -19
- package/plugins/gemini/skills/grilling/SKILL.md +0 -19
- package/plugins/gemini/skills/oss_scout/SKILL.md +0 -19
- package/plugins/gemini/skills/research_cache/SKILL.md +0 -19
- package/plugins/gemini/skills/swarm_config/SKILL.md +0 -19
- package/plugins/opencode/analysis-bridge.js +0 -620
- package/plugins/opencode/config-io.js +0 -1651
- package/plugins/opencode/dep-health.js +0 -1039
- package/plugins/opencode/hook-bus.js +0 -496
- package/plugins/opencode/index.js +0 -2992
- package/plugins/opencode/tui.js +0 -1551
- package/plugins/opencode/weight-baseline.json +0 -16
- package/plugins/opencode/weight-signals.js +0 -769
- package/plugins/research-cache/.claude-plugin/plugin.json +0 -15
- package/plugins/research-cache/skills/research_cache/SKILL.md +0 -36
- package/plugins/research-cache/skills/research_cache/__init__.py +0 -0
- package/plugins/research-cache/skills/research_cache/hasher.py +0 -302
- package/plugins/socratic-grilling/.claude-plugin/plugin.json +0 -15
- package/plugins/socratic-grilling/skills/grilling/SKILL.md +0 -61
- package/plugins/socratic-grilling/skills/grilling/__init__.py +0 -0
- package/plugins/socratic-grilling/skills/grilling/socratic_tree.py +0 -363
- package/prompts/agent_alpha_thesis.md +0 -70
- package/prompts/agent_beta_antithesis.md +0 -78
- package/prompts/agent_brainstormer.md +0 -143
- package/prompts/agent_code_auditor.md +0 -88
- package/prompts/agent_darkharvest.md +0 -140
- package/prompts/agent_oss_scout.md +0 -78
- package/prompts/antigravity_repo_factcheck.md +0 -143
- package/prompts/base_epistemic_system.md +0 -50
- package/prompts/epistemic_auditor.md +0 -74
- package/prompts/orchestrator.md +0 -70
- package/runner/__init__.py +0 -0
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.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__/preflight.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__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/antigravity_hooks.py +0 -265
- package/runner/auctioneer.py +0 -169
- package/runner/auditor_engine.py +0 -711
- package/runner/claim_store.py +0 -417
- package/runner/claim_witness.py +0 -412
- package/runner/darkharvest_claims.py +0 -358
- package/runner/errors.py +0 -51
- package/runner/living_dossiers.py +0 -395
- package/runner/mcp_protocol.py +0 -188
- package/runner/mcp_server.py +0 -1447
- package/runner/path_safety.py +0 -62
- package/runner/pcrb.py +0 -290
- package/runner/pcrb_verify.py +0 -267
- package/runner/preflight.py +0 -1011
- package/runner/refinement.py +0 -580
- package/runner/research_swarm.py +0 -1848
- package/runner/retrieval_log.py +0 -90
- package/runner/schema_validate.py +0 -193
- package/runner/schemas.py +0 -630
- package/runner/state_machine.py +0 -463
- package/runner/tests/__pycache__/test_analysis_bridge.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.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_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dep_health.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hardening_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_hook_bus.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_nk_hardening.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_phase08_waiver_reopens.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.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/__pycache__/test_weight_signals.cpython-311.pyc +0 -0
- package/runner/tests/fixtures/auction_objective.json +0 -35
- package/runner/tests/fixtures/borderline_claims.json +0 -27
- package/runner/tests/fixtures/config_validation_cases.json +0 -276
- package/runner/tests/fixtures/ddg_anomaly_page.html +0 -176
- package/runner/tests/fixtures/ddg_results_page.html +0 -28
- package/runner/tests/fixtures/divergence_objectives.json +0 -31
- package/runner/tests/test_analysis_bridge.py +0 -450
- package/runner/tests/test_antigravity_plugin.py +0 -361
- package/runner/tests/test_auction_order.py +0 -173
- package/runner/tests/test_backends.py +0 -457
- package/runner/tests/test_bet1_spike.py +0 -95
- package/runner/tests/test_claim_store.py +0 -162
- package/runner/tests/test_claim_witness.py +0 -186
- package/runner/tests/test_claude_plugin.py +0 -170
- package/runner/tests/test_config_schema.py +0 -879
- package/runner/tests/test_contract_tooling.py +0 -312
- package/runner/tests/test_darkharvest_audit.py +0 -395
- package/runner/tests/test_dep_health.py +0 -595
- package/runner/tests/test_domain_packs.py +0 -217
- package/runner/tests/test_dossier_contract.py +0 -196
- package/runner/tests/test_factory.py +0 -258
- package/runner/tests/test_fleet_seam.py +0 -113
- package/runner/tests/test_frontier_concurrency.py +0 -288
- package/runner/tests/test_frontier_mirror_standalone.py +0 -156
- package/runner/tests/test_grounding.py +0 -183
- package/runner/tests/test_hardening_contract.py +0 -391
- package/runner/tests/test_hook_bus.py +0 -956
- package/runner/tests/test_living_dossiers.py +0 -204
- package/runner/tests/test_manifest_concurrency.py +0 -187
- package/runner/tests/test_mcp_server.py +0 -257
- package/runner/tests/test_nk_hardening.py +0 -1046
- package/runner/tests/test_opencode_ux.py +0 -2438
- package/runner/tests/test_opencode_v2_registrars.py +0 -2157
- package/runner/tests/test_pcrb.py +0 -240
- package/runner/tests/test_phase08_waiver_reopens.py +0 -539
- package/runner/tests/test_refinement.py +0 -255
- package/runner/tests/test_search_anomaly.py +0 -342
- package/runner/tests/test_search_provider.py +0 -1534
- package/runner/tests/test_search_provider_plugin.py +0 -401
- package/runner/tests/test_swarm.py +0 -444
- package/runner/tests/test_swarm_config.py +0 -520
- package/runner/tests/test_sweep_regressions.py +0 -453
- package/runner/tests/test_webcache.py +0 -271
- package/runner/tests/test_weight_signals.py +0 -401
- package/schemas/alpha_dossier.schema.json +0 -204
- package/schemas/audit_report.schema.json +0 -79
- package/schemas/beta_dossier.schema.json +0 -214
- package/schemas/config.schema.json +0 -311
- package/schemas/frontier.schema.json +0 -61
- package/schemas/manifest.schema.json +0 -49
- package/schemas/scope_manifest.schema.json +0 -47
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/scripts/auction_experiment.py +0 -188
- package/scripts/bet1_advisory_spike.py +0 -442
- package/scripts/build_adapters.py +0 -417
- package/scripts/check_docs.py +0 -66
- package/scripts/divergence_experiment.py +0 -214
- package/scripts/gen_schemas.py +0 -69
- package/skills/brainstorming/SKILL.md +0 -106
- package/skills/brainstorming/__init__.py +0 -1
- package/skills/brainstorming/scripts/brainstorm.py +0 -201
- package/skills/code_audit/SKILL.md +0 -45
- package/skills/darkharvest/SKILL.md +0 -70
- package/skills/darkharvest/scripts/harvest.py +0 -289
- package/skills/epistemic_search/SKILL.md +0 -67
- package/skills/epistemic_search/__init__.py +0 -1
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/fetch.py +0 -156
- package/skills/epistemic_search/scripts/search.py +0 -551
- package/skills/epistemic_search/scripts/webcache.py +0 -339
- package/skills/factory/SKILL.md +0 -61
- package/skills/factory/scripts/factory.py +0 -300
- package/skills/grilling/SKILL.md +0 -61
- package/skills/grilling/__init__.py +0 -0
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/grilling/socratic_tree.py +0 -363
- package/skills/oss_scout/SKILL.md +0 -49
- package/skills/research_cache/SKILL.md +0 -36
- package/skills/research_cache/__init__.py +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/research_cache/hasher.py +0 -302
- package/skills/swarm_config/SKILL.md +0 -178
- package/skills/swarm_config/__init__.py +0 -35
- 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 +0 -1002
package/runner/path_safety.py
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Proof-Carrying Research Brief (PCRB) — export side (Stream D).
|
|
4
|
-
|
|
5
|
-
A PCRB is a self-contained signed bundle: the synthesis, every claim with its
|
|
6
|
-
quote witness, the full text of every cited source, an integrity manifest, and
|
|
7
|
-
an optional HMAC signature. A third party can re-verify everything WITHOUT
|
|
8
|
-
trusting the LLM that produced it and WITHOUT access to the original
|
|
9
|
-
.research/ workspace — verification runs against the bundled sources.
|
|
10
|
-
|
|
11
|
-
Bundle shape (brief.pcrb.json):
|
|
12
|
-
{
|
|
13
|
-
"pcrb_version": "1.0",
|
|
14
|
-
"objective": ..., "exported_at": ...,
|
|
15
|
-
"synthesis_markdown": ...,
|
|
16
|
-
"claims": [ {claim fields..., "witness": {quote, source_hash, confidence, verified_at}} ],
|
|
17
|
-
"sources": { "<hash>": {"url","title","tier","content"} },
|
|
18
|
-
"manifest": { "synthesis_markdown": sha, "claims": {id: sha}, "sources": {hash: sha} },
|
|
19
|
-
"signature": {"alg": "hmac-sha256" | "none", "key_id": ..., "sig": ...}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
Member hashes use SourceHasher.compute_sha256 semantics so a bundled source's
|
|
23
|
-
member hash equals its cache hash.
|
|
24
|
-
|
|
25
|
-
Signing: stdlib hmac when a key is provided (env IUMBTEMS_PCRB_KEY or key
|
|
26
|
-
file). alg="none" provides integrity (member-vs-manifest) but NOT authenticity
|
|
27
|
-
— a coordinated rewrite of content + manifest is undetectable without a key.
|
|
28
|
-
Real PKI (ed25519) is a gated follow-on; HMAC is sufficient for the tamper
|
|
29
|
-
probe, not for the regulatory story.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
import hashlib
|
|
33
|
-
import hmac
|
|
34
|
-
import json
|
|
35
|
-
import os
|
|
36
|
-
import sys
|
|
37
|
-
from datetime import datetime, timezone
|
|
38
|
-
from pathlib import Path
|
|
39
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
40
|
-
|
|
41
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
42
|
-
PROJECT_ROOT = SCRIPT_DIR.parent
|
|
43
|
-
if str(PROJECT_ROOT) not in sys.path:
|
|
44
|
-
sys.path.insert(0, str(PROJECT_ROOT))
|
|
45
|
-
|
|
46
|
-
from runner.claim_witness import claims_from_dossier, load_dossier # noqa: E402
|
|
47
|
-
|
|
48
|
-
PCRB_VERSION = "1.0"
|
|
49
|
-
ENV_KEY = "IUMBTEMS_PCRB_KEY"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def canonical_json(obj: Any) -> str:
|
|
53
|
-
"""Deterministic serialization for hashing/signing."""
|
|
54
|
-
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def sha256_text(text: str) -> str:
|
|
58
|
-
"""SourceHasher.compute_sha256 semantics (strip + utf-8)."""
|
|
59
|
-
return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def member_hash(obj: Any) -> str:
|
|
63
|
-
"""Hash a structured member via canonical JSON."""
|
|
64
|
-
return sha256_text(canonical_json(obj))
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _resolve_key(key: Optional[str], key_path: Optional[Path]) -> Optional[bytes]:
|
|
68
|
-
if key:
|
|
69
|
-
return key.encode("utf-8")
|
|
70
|
-
env = os.environ.get(ENV_KEY)
|
|
71
|
-
if env:
|
|
72
|
-
return env.encode("utf-8")
|
|
73
|
-
if key_path:
|
|
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()
|
|
83
|
-
return None
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def sign_manifest(
|
|
87
|
-
manifest: Dict[str, Any], key: Optional[bytes], key_id: str = "local"
|
|
88
|
-
) -> Dict[str, Any]:
|
|
89
|
-
if not key:
|
|
90
|
-
return {"alg": "none", "key_id": None, "sig": None}
|
|
91
|
-
sig = hmac.new(
|
|
92
|
-
key, canonical_json(manifest).encode("utf-8"), hashlib.sha256
|
|
93
|
-
).hexdigest()
|
|
94
|
-
return {"alg": "hmac-sha256", "key_id": key_id, "sig": sig}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _resolve_synthesis(base_dir: Path) -> str:
|
|
98
|
-
final_md = base_dir / "final_synthesis.md"
|
|
99
|
-
if final_md.exists():
|
|
100
|
-
return final_md.read_text(encoding="utf-8")
|
|
101
|
-
parts = sorted((base_dir / "scratchpads").glob("*/scope_synthesis.md"))
|
|
102
|
-
return "\n\n---\n\n".join(p.read_text(encoding="utf-8") for p in parts)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _witness_for(c, hasher: Any, needed_hashes: set) -> Dict[str, Any]:
|
|
106
|
-
"""Build one claim's witness record and track hashes worth bundling.
|
|
107
|
-
|
|
108
|
-
R8 (01-nk-hardening tiebreak-2 F1): source_hash/verbatim_quote are
|
|
109
|
-
untrusted-adjacent (hand-built witnesses may carry int/dict/list) —
|
|
110
|
-
isinstance-guard both before hasher/set. Non-string sides degrade to
|
|
111
|
-
reject (confidence None, no bundling), never TypeError via
|
|
112
|
-
`re.match` (hasher) or `set.add` (unhashable dict).
|
|
113
|
-
"""
|
|
114
|
-
_sh = getattr(c, "source_hash", None)
|
|
115
|
-
_q = getattr(c, "verbatim_quote", None)
|
|
116
|
-
witness: Dict[str, Any] = {
|
|
117
|
-
"quote": _q,
|
|
118
|
-
"source_hash": _sh,
|
|
119
|
-
"confidence": None,
|
|
120
|
-
"verified_at": None,
|
|
121
|
-
}
|
|
122
|
-
if not isinstance(_sh, str) or not _sh:
|
|
123
|
-
return witness
|
|
124
|
-
if not isinstance(_q, str) or not _q:
|
|
125
|
-
try:
|
|
126
|
-
needed_hashes.add(_sh)
|
|
127
|
-
except TypeError:
|
|
128
|
-
pass
|
|
129
|
-
return witness
|
|
130
|
-
try:
|
|
131
|
-
passed, conf, _msg = hasher.verify_quote(_sh, _q)
|
|
132
|
-
except (TypeError, AttributeError):
|
|
133
|
-
return witness
|
|
134
|
-
try:
|
|
135
|
-
witness["confidence"] = float(conf)
|
|
136
|
-
except (TypeError, ValueError):
|
|
137
|
-
witness["confidence"] = None
|
|
138
|
-
witness["verified_at"] = datetime.now(timezone.utc).isoformat()
|
|
139
|
-
if passed:
|
|
140
|
-
try:
|
|
141
|
-
needed_hashes.add(_sh)
|
|
142
|
-
except TypeError:
|
|
143
|
-
pass
|
|
144
|
-
# Legacy: a present-but-unverified quote bundles nothing (verifier
|
|
145
|
-
# re-checks independently); a missing quote bundles the hash for audit.
|
|
146
|
-
return witness
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _iter_dossier_claims(scope_dirs: List[Path]):
|
|
150
|
-
"""Yield claim witnesses from every alpha/beta dossier under scopes."""
|
|
151
|
-
for scope_dir in scope_dirs:
|
|
152
|
-
for dossier_name in ("alpha_dossier.json", "beta_dossier.json"):
|
|
153
|
-
dpath = scope_dir / dossier_name
|
|
154
|
-
if not dpath.exists():
|
|
155
|
-
continue
|
|
156
|
-
for c in claims_from_dossier(load_dossier(dpath)):
|
|
157
|
-
yield c
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _collect_claims(
|
|
161
|
-
scope_dirs: List[Path], hasher: Any
|
|
162
|
-
) -> Tuple[List[Dict[str, Any]], set]:
|
|
163
|
-
claim_records: List[Dict[str, Any]] = []
|
|
164
|
-
needed_hashes: set = set()
|
|
165
|
-
for c in _iter_dossier_claims(scope_dirs):
|
|
166
|
-
rec = c.to_dict()
|
|
167
|
-
rec["witness"] = _witness_for(c, hasher, needed_hashes)
|
|
168
|
-
claim_records.append(rec)
|
|
169
|
-
return claim_records, needed_hashes
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _resolve_objective(base_dir: Path, objective: Optional[str]) -> Optional[str]:
|
|
173
|
-
if objective:
|
|
174
|
-
return objective
|
|
175
|
-
from runner.state_machine import find_any_manifest
|
|
176
|
-
|
|
177
|
-
manifest_p = find_any_manifest(base_dir)
|
|
178
|
-
if manifest_p is not None and manifest_p.exists():
|
|
179
|
-
try:
|
|
180
|
-
return json.loads(manifest_p.read_text(encoding="utf-8")).get("objective")
|
|
181
|
-
except (OSError, json.JSONDecodeError):
|
|
182
|
-
return None
|
|
183
|
-
return None
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def export_brief(
|
|
187
|
-
base_dir: Path,
|
|
188
|
-
out_path: Optional[Path] = None,
|
|
189
|
-
scope_ids: Optional[List[str]] = None,
|
|
190
|
-
objective: Optional[str] = None,
|
|
191
|
-
key: Optional[str] = None,
|
|
192
|
-
key_path: Optional[Path] = None,
|
|
193
|
-
key_id: str = "local",
|
|
194
|
-
) -> Path:
|
|
195
|
-
"""Assemble a PCRB from a .research workspace. Returns the bundle path."""
|
|
196
|
-
from skills.research_cache.hasher import SourceHasher
|
|
197
|
-
|
|
198
|
-
base_dir = Path(os.path.realpath(str(base_dir)))
|
|
199
|
-
hasher = SourceHasher(base_dir)
|
|
200
|
-
synthesis = _resolve_synthesis(base_dir)
|
|
201
|
-
|
|
202
|
-
scratch = base_dir / "scratchpads"
|
|
203
|
-
if scope_ids:
|
|
204
|
-
scope_dirs = [scratch / s for s in scope_ids]
|
|
205
|
-
elif scratch.exists():
|
|
206
|
-
scope_dirs = sorted(p for p in scratch.iterdir() if p.is_dir())
|
|
207
|
-
else:
|
|
208
|
-
scope_dirs = []
|
|
209
|
-
|
|
210
|
-
claim_records, needed_hashes = _collect_claims(scope_dirs, hasher)
|
|
211
|
-
|
|
212
|
-
# --- bundled sources (self-contained: full text in the bundle) ---
|
|
213
|
-
sources: Dict[str, Dict[str, Any]] = {}
|
|
214
|
-
for src_hash in sorted(needed_hashes):
|
|
215
|
-
content = hasher.get_source_content(src_hash)
|
|
216
|
-
meta = hasher.get_source_metadata(src_hash) or {}
|
|
217
|
-
if content is None:
|
|
218
|
-
continue
|
|
219
|
-
sources[src_hash] = {
|
|
220
|
-
"url": meta.get("url"),
|
|
221
|
-
"title": meta.get("title"),
|
|
222
|
-
"tier": meta.get("tier"),
|
|
223
|
-
"content": content,
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
# --- manifest (integrity) ---
|
|
227
|
-
manifest: Dict[str, Any] = {
|
|
228
|
-
"synthesis_markdown": sha256_text(synthesis),
|
|
229
|
-
"claims": {rec["claim_id"]: member_hash(rec) for rec in claim_records},
|
|
230
|
-
"sources": {h: sha256_text(s["content"]) for h, s in sources.items()},
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
resolved_key = _resolve_key(key, key_path)
|
|
234
|
-
signature = sign_manifest(manifest, resolved_key, key_id=key_id)
|
|
235
|
-
|
|
236
|
-
bundle = {
|
|
237
|
-
"pcrb_version": PCRB_VERSION,
|
|
238
|
-
"objective": _resolve_objective(base_dir, objective),
|
|
239
|
-
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
240
|
-
"synthesis_markdown": synthesis,
|
|
241
|
-
"claims": claim_records,
|
|
242
|
-
"sources": sources,
|
|
243
|
-
"manifest": manifest,
|
|
244
|
-
"signature": signature,
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
out = (
|
|
248
|
-
Path(os.path.realpath(str(out_path)))
|
|
249
|
-
if out_path
|
|
250
|
-
else (base_dir / "brief.pcrb.json")
|
|
251
|
-
)
|
|
252
|
-
out.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
-
with open(out, "w", encoding="utf-8") as f:
|
|
254
|
-
json.dump(bundle, f, indent=2)
|
|
255
|
-
return out
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def main(argv: Optional[List[str]] = None) -> int:
|
|
259
|
-
import argparse
|
|
260
|
-
|
|
261
|
-
parser = argparse.ArgumentParser(
|
|
262
|
-
description="Export a proof-carrying research brief"
|
|
263
|
-
)
|
|
264
|
-
parser.add_argument("--dir", default=".research")
|
|
265
|
-
parser.add_argument("--out", default=None)
|
|
266
|
-
parser.add_argument("--scope", action="append", dest="scope_ids")
|
|
267
|
-
parser.add_argument("--objective", default=None)
|
|
268
|
-
parser.add_argument("--key-file", default=None)
|
|
269
|
-
parser.add_argument("--key-id", default="local")
|
|
270
|
-
args = parser.parse_args(argv)
|
|
271
|
-
|
|
272
|
-
canonical_dir = Path(os.path.realpath(str(args.dir)))
|
|
273
|
-
canonical_out = Path(os.path.realpath(str(args.out))) if args.out else None
|
|
274
|
-
canonical_key = (
|
|
275
|
-
Path(os.path.realpath(str(args.key_file))) if args.key_file else None
|
|
276
|
-
)
|
|
277
|
-
path = export_brief(
|
|
278
|
-
canonical_dir,
|
|
279
|
-
out_path=canonical_out,
|
|
280
|
-
scope_ids=args.scope_ids,
|
|
281
|
-
objective=args.objective,
|
|
282
|
-
key_path=canonical_key,
|
|
283
|
-
key_id=args.key_id,
|
|
284
|
-
)
|
|
285
|
-
print(json.dumps({"status": "exported", "path": str(path)}))
|
|
286
|
-
return 0
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if __name__ == "__main__":
|
|
290
|
-
sys.exit(main())
|
package/runner/pcrb_verify.py
DELETED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Proof-Carrying Research Brief (PCRB) — standalone verifier (Stream D).
|
|
4
|
-
|
|
5
|
-
Verifies a brief.pcrb.json WITHOUT trusting the LLM that produced it and
|
|
6
|
-
WITHOUT the original .research/ workspace: every quote is re-checked against
|
|
7
|
-
the sources BUNDLED INSIDE the brief.
|
|
8
|
-
|
|
9
|
-
Checks (all must pass for exit 0):
|
|
10
|
-
1. source identity — sha256(source content) == its hash key in the bundle
|
|
11
|
-
2. manifest integrity — every member hash recomputes to the manifest value
|
|
12
|
-
3. signature — HMAC over the manifest (when alg != "none")
|
|
13
|
-
4. quotes — every claim's verbatim_quote re-verified against the bundled
|
|
14
|
-
source text, using the SAME verification stack as the auditor
|
|
15
|
-
(SourceHasher.verify_quote via a throwaway cache directory)
|
|
16
|
-
|
|
17
|
-
Usage:
|
|
18
|
-
python3 runner/pcrb_verify.py brief.pcrb.json [--json] [--key-file ...]
|
|
19
|
-
|
|
20
|
-
Exit codes: 0 = all checks passed, 1 = tampering/failure detected,
|
|
21
|
-
2 = unreadable bundle.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
import argparse
|
|
25
|
-
import hashlib
|
|
26
|
-
import hmac
|
|
27
|
-
import json
|
|
28
|
-
import os
|
|
29
|
-
import sys
|
|
30
|
-
import tempfile
|
|
31
|
-
import time
|
|
32
|
-
from pathlib import Path
|
|
33
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
34
|
-
|
|
35
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
36
|
-
PROJECT_ROOT = SCRIPT_DIR.parent
|
|
37
|
-
if str(PROJECT_ROOT) not in sys.path:
|
|
38
|
-
sys.path.insert(0, str(PROJECT_ROOT))
|
|
39
|
-
|
|
40
|
-
# Reuse the export-side canonicalization so hashes always agree.
|
|
41
|
-
from runner.pcrb import ( # noqa: E402
|
|
42
|
-
ENV_KEY,
|
|
43
|
-
canonical_json,
|
|
44
|
-
member_hash,
|
|
45
|
-
sha256_text,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _check_source_identity(sources: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
50
|
-
failures = []
|
|
51
|
-
for src_hash, meta in sources.items():
|
|
52
|
-
content = meta.get("content") or ""
|
|
53
|
-
actual = sha256_text(content)
|
|
54
|
-
if actual != src_hash:
|
|
55
|
-
failures.append({
|
|
56
|
-
"check": "source_identity",
|
|
57
|
-
"member": src_hash,
|
|
58
|
-
"message": f"bundled source content hashes to {actual}, not its key {src_hash}",
|
|
59
|
-
})
|
|
60
|
-
return failures
|
|
61
|
-
|
|
62
|
-
|
|
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 = []
|
|
70
|
-
synth_hash = sha256_text(bundle.get("synthesis_markdown") or "")
|
|
71
|
-
if manifest.get("synthesis_markdown") != synth_hash:
|
|
72
|
-
failures.append({
|
|
73
|
-
"check": "manifest_integrity",
|
|
74
|
-
"member": "synthesis_markdown",
|
|
75
|
-
"message": "synthesis_markdown does not match its manifest hash",
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
manifest_claims = manifest.get("claims") or {}
|
|
79
|
-
for rec in claims:
|
|
80
|
-
cid = rec.get("claim_id", "UNKNOWN")
|
|
81
|
-
expected = manifest_claims.get(cid)
|
|
82
|
-
actual = member_hash(rec)
|
|
83
|
-
if expected is None:
|
|
84
|
-
failures.append({"check": "manifest_integrity", "member": cid, "message": "claim missing from manifest"})
|
|
85
|
-
elif expected != actual:
|
|
86
|
-
failures.append({
|
|
87
|
-
"check": "manifest_integrity",
|
|
88
|
-
"member": cid,
|
|
89
|
-
"message": f"claim hash mismatch (manifest {expected[:16]}… vs actual {actual[:16]}…)",
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
manifest_sources = manifest.get("sources") or {}
|
|
93
|
-
for src_hash, meta in sources.items():
|
|
94
|
-
expected = manifest_sources.get(src_hash)
|
|
95
|
-
actual = sha256_text(meta.get("content") or "")
|
|
96
|
-
if expected is None:
|
|
97
|
-
failures.append({"check": "manifest_integrity", "member": src_hash, "message": "source missing from manifest"})
|
|
98
|
-
elif expected != actual:
|
|
99
|
-
failures.append({
|
|
100
|
-
"check": "manifest_integrity",
|
|
101
|
-
"member": src_hash,
|
|
102
|
-
"message": "source content does not match its manifest hash",
|
|
103
|
-
})
|
|
104
|
-
return failures
|
|
105
|
-
|
|
106
|
-
|
|
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]]]:
|
|
112
|
-
alg = signature.get("alg") or "none"
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
def _check_quotes(
|
|
137
|
-
claims: List[Dict[str, Any]],
|
|
138
|
-
sources: Dict[str, Any],
|
|
139
|
-
) -> Tuple[int, int, List[Dict[str, str]]]:
|
|
140
|
-
quotes_verified = 0
|
|
141
|
-
quotes_failed = 0
|
|
142
|
-
failures = []
|
|
143
|
-
with tempfile.TemporaryDirectory(prefix="pcrb_verify_") as tmp:
|
|
144
|
-
from skills.research_cache.hasher import SourceHasher
|
|
145
|
-
|
|
146
|
-
hasher = SourceHasher(Path(tmp))
|
|
147
|
-
for src_hash, meta in sources.items():
|
|
148
|
-
content = meta.get("content") or ""
|
|
149
|
-
md_path = Path(tmp) / "sources" / f"{src_hash}.md"
|
|
150
|
-
md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
-
md_path.write_text(content, encoding="utf-8")
|
|
152
|
-
|
|
153
|
-
for rec in claims:
|
|
154
|
-
cid = rec.get("claim_id", "UNKNOWN")
|
|
155
|
-
witness = rec.get("witness") or {}
|
|
156
|
-
src_hash = witness.get("source_hash") or rec.get("source_hash")
|
|
157
|
-
quote = witness.get("quote") or rec.get("verbatim_quote")
|
|
158
|
-
if not src_hash or not quote:
|
|
159
|
-
continue
|
|
160
|
-
if src_hash not in sources:
|
|
161
|
-
quotes_failed += 1
|
|
162
|
-
failures.append({
|
|
163
|
-
"check": "quote",
|
|
164
|
-
"member": cid,
|
|
165
|
-
"message": f"claim cites source {src_hash} which is not bundled",
|
|
166
|
-
})
|
|
167
|
-
continue
|
|
168
|
-
passed, conf, msg = hasher.verify_quote(src_hash, quote)
|
|
169
|
-
if passed:
|
|
170
|
-
quotes_verified += 1
|
|
171
|
-
else:
|
|
172
|
-
quotes_failed += 1
|
|
173
|
-
failures.append({
|
|
174
|
-
"check": "quote",
|
|
175
|
-
"member": cid,
|
|
176
|
-
"message": f"quote not found in bundled source (conf={conf}): {msg}",
|
|
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)
|
|
199
|
-
|
|
200
|
-
elapsed = time.perf_counter() - started
|
|
201
|
-
return {
|
|
202
|
-
"ok": not failures,
|
|
203
|
-
"failures": failures,
|
|
204
|
-
"checks": {
|
|
205
|
-
"source_identity": "pass" if not any(f["check"] == "source_identity" for f in failures) else "fail",
|
|
206
|
-
"manifest_integrity": "pass" if not any(f["check"] == "manifest_integrity" for f in failures) else "fail",
|
|
207
|
-
"signature": sig_status,
|
|
208
|
-
"quotes": "pass" if quotes_failed == 0 else "fail",
|
|
209
|
-
},
|
|
210
|
-
"stats": {
|
|
211
|
-
"claims": len(claims),
|
|
212
|
-
"sources": len(sources),
|
|
213
|
-
"quotes_verified": quotes_verified,
|
|
214
|
-
"quotes_failed": quotes_failed,
|
|
215
|
-
"elapsed_s": round(elapsed, 4),
|
|
216
|
-
},
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def main(argv: Optional[List[str]] = None) -> int:
|
|
221
|
-
parser = argparse.ArgumentParser(description="Verify a proof-carrying research brief")
|
|
222
|
-
parser.add_argument("brief", help="Path to brief.pcrb.json")
|
|
223
|
-
parser.add_argument("--json", action="store_true", help="Print JSON report")
|
|
224
|
-
parser.add_argument("--key-file", default=None)
|
|
225
|
-
args = parser.parse_args(argv)
|
|
226
|
-
|
|
227
|
-
path = Path(os.path.realpath(str(args.brief)))
|
|
228
|
-
if not path.is_file():
|
|
229
|
-
print(f"error: no such brief: {path}", file=sys.stderr)
|
|
230
|
-
return 2
|
|
231
|
-
try:
|
|
232
|
-
bundle = json.loads(path.read_text(encoding="utf-8"))
|
|
233
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
234
|
-
print(f"error: unreadable brief: {exc}", file=sys.stderr)
|
|
235
|
-
return 2
|
|
236
|
-
|
|
237
|
-
key = None
|
|
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()
|
|
246
|
-
elif os.environ.get(ENV_KEY):
|
|
247
|
-
key = os.environ[ENV_KEY].encode("utf-8")
|
|
248
|
-
|
|
249
|
-
report = verify_brief(bundle, key=key)
|
|
250
|
-
|
|
251
|
-
if args.json:
|
|
252
|
-
print(json.dumps(report, indent=2))
|
|
253
|
-
else:
|
|
254
|
-
status = "PASS" if report["ok"] else "FAIL"
|
|
255
|
-
print(f"[{status}] {path}")
|
|
256
|
-
for name, st in report["checks"].items():
|
|
257
|
-
print(f" {name}: {st}")
|
|
258
|
-
for f in report["failures"]:
|
|
259
|
-
print(f" ✗ [{f['check']}] {f['member']}: {f['message']}")
|
|
260
|
-
s = report["stats"]
|
|
261
|
-
print(f" quotes verified: {s['quotes_verified']}/{s['claims']} in {s['elapsed_s']}s")
|
|
262
|
-
|
|
263
|
-
return 0 if report["ok"] else 1
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if __name__ == "__main__":
|
|
267
|
-
sys.exit(main())
|