diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3ca2d8b..51931bbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/hermes-evals.yml b/.github/workflows/hermes-evals.yml index 1e409c94..0810385a 100644 --- a/.github/workflows/hermes-evals.yml +++ b/.github/workflows/hermes-evals.yml @@ -22,6 +22,10 @@ jobs: PYTHONPATH: src steps: - uses: actions/checkout@v4 + with: + # SoT-cleared is local git ancestry of references/claude-sot-cleared.json + # (no live GitHub fetch in doctor). Default depth=1 hides the pin SHA. + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 with: diff --git a/.gitignore b/.gitignore index 6adaf429..1a6d9285 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ build/ !data/backfill/**/*.json !data/phylogeny/**/*.json !.claude-plugin/plugin.json +!references/claude-sot-cleared.json # Temp / build output tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e2a7bd..825f1b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- **P1 fail-closed hardening:** Claude `init` / `install.sh --claude` helpers + are transactional (symlink refuse, target-keyed backup, staged smoke / + UNVERIFIED). Unguarded `copy_claude_helpers` removed. Scored `settle()` / + `settle_and_log()` require token or TTY confirm, non-empty `authority.ref`, + and non-advisory kind; piped yes is refused. X API base allowlists + `api.twitter.com` / `api.x.com` (https only). Cloud vector writes require + `HYPERLEX_CLOUD_WRITE=1` or TTY `--i-understand-cloud-write`. `doctor` + emits `CLAUDE_SOT_CLEARED=` from local pin/provenance (Skill Validation + fetches full git history so the pin SHA is locally present). `receipt.integrity` + is full sha256; `emit_receipt(..., validate=True)` default; legacy 12-char + verify only with `HYPERLEX_RECEIPT_LEGACY_INTEGRITY=1`. - **Claude Code host (additive):** `.claude-plugin/plugin.json`, project `CLAUDE.md`, slash helpers (`.claude/skills/` + plugin `commands/`), `install.sh --claude` / `--claude-plugin`, `scripts/claude_hlx.sh`, diff --git a/SKILL.md b/SKILL.md index 7d318ae3..0ae38f69 100644 --- a/SKILL.md +++ b/SKILL.md @@ -183,7 +183,7 @@ $HLX pipeline "rizz" --route offline $HLX ingest "locked in" # same as pipeline (use --raw-only for signal only) $HLX pipeline "sigma rizz locked in" # expands to atoms automatically $HLX pending # open forecasts -$HLX settle --forecast-id --decision TRUE +$HLX settle --forecast-id --decision TRUE --authority-ref --settle-token "$HYPERLEX_SETTLE_TOKEN" $HLX score-series --mean-shift --verify-chain $HLX scan --route offline --receipt --forecasts --append-log $HLX risk-schedule --tier MODERATE --schedule-out /tmp/hlx-cron diff --git a/SPEC.md b/SPEC.md index 6f3d1370..98a36589 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,7 +16,7 @@ Extended: `hyperlex.API_EXTENDED` (additive; includes Phase 5 simulation) - `compute_lineage_confidence(hits, family_terms, corpus) -> (float, dict)` ### Receipts -- `emit_receipt(result, out_dir=None, validate=False, append_ledger=True, ledger_path=None) -> Path` +- `emit_receipt(result, out_dir=None, validate=True, append_ledger=True, ledger_path=None) -> Path` - `verify_receipt(payload) -> (bool, str)` ### Calibration diff --git a/docs/api-v1.md b/docs/api-v1.md index f4dfcb2b..195fe444 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -40,7 +40,7 @@ or an explicit deprecation window. | `ingest_signal(query, source="mock")` | `str` signal | | `fetch_ingest(query, source="mock", ...)` | structured ingest + `source_fingerprint` | | `detect_memetic_patterns(...)` | result dict; `provenance.brier` is always `null` | -| `emit_receipt(result, ...)` | writes receipt JSON; optional ledger append | +| `emit_receipt(result, ...)` | writes receipt JSON with full sha256 `receipt.integrity`; `validate=True` default (`--no-validate` CLI escape) | | `extract_forecasts(result)` | list of forecasts; **no** Brier field | | `settle` / `score_pair` / `score_series` | Brier only after settlement; else `NOT_COMPUTABLE` | | `relay_from_result(result)` | `RUNE.HLX.*` envelopes | diff --git a/docs/brier-calibration.md b/docs/brier-calibration.md index e0df2a82..d13ba171 100644 --- a/docs/brier-calibration.md +++ b/docs/brier-calibration.md @@ -233,6 +233,7 @@ python3 scripts/hyperlex.py extract-forecasts --input out/result.json --append-l # Operator settles a forecast (TRUE/FALSE/VOID/CONFLICT) python3 scripts/hyperlex.py settle --forecast-id --decision TRUE \ + --authority-ref operator@local --settle-token "$HYPERLEX_SETTLE_TOKEN" \ --authority-note "human review confirmed family" --export-ledger # Recompute series + optional mean-shift diagnostic + chain verify diff --git a/docs/claude-runtime-contract.md b/docs/claude-runtime-contract.md index c3acb914..c41cae9e 100644 --- a/docs/claude-runtime-contract.md +++ b/docs/claude-runtime-contract.md @@ -72,6 +72,10 @@ Operator data is unchanged (outside the skill tree): 3. Never invent numeric Brier. Open analysis keeps `provenance.brier` null. 4. Never auto-settle. Ask for TRUE|FALSE|VOID|CONFLICT, then run `settle`. 5. `doctor` reports `CLAUDE_OK` or `CLAUDE_MISSING`. Missing does not fail Hermes. + `CLAUDE_SOT_CLEARED=true|false` comes from the local pin + `references/claude-sot-cleared.json` plus git/install provenance (not a + live GitHub fetch). A shallow clone that lacks the pin commit stays + uncleared. Claimed Claude packaging + uncleared fails doctor. ## Authority diff --git a/docs/claude-skill.md b/docs/claude-skill.md index c13c8b3f..daa3d3ed 100644 --- a/docs/claude-skill.md +++ b/docs/claude-skill.md @@ -84,7 +84,10 @@ step: `pending` → operator decision → `settle` → `score-series`. | Contract | Same `SKILL.md` | Same `SKILL.md` + Claude section | Fail-closed rules do not change: no invented Brier, no auto-settle, no -phenomenology claims. +phenomenology claims. `hyperlex doctor` emits `CLAUDE_SOT_CLEARED=true|false` +from the local pin `references/claude-sot-cleared.json` plus git/install +provenance (not a live GitHub fetch). When Claude packaging is installed or +claimed and the pin does not match, doctor fails. ## See also diff --git a/docs/spec.md b/docs/spec.md index 00be4dd4..9360022f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -16,7 +16,7 @@ Extended: `hyperlex.API_EXTENDED` (additive; includes Phase 5 simulation) - `compute_lineage_confidence(hits, family_terms, corpus) -> (float, dict)` ### Receipts -- `emit_receipt(result, out_dir=None, validate=False, append_ledger=True, ledger_path=None) -> Path` +- `emit_receipt(result, out_dir=None, validate=True, append_ledger=True, ledger_path=None) -> Path` - `verify_receipt(payload) -> (bool, str)` ### Calibration diff --git a/examples/receipts/golden/MANIFEST.json b/examples/receipts/golden/MANIFEST.json index d5eeb966..fe409d65 100644 --- a/examples/receipts/golden/MANIFEST.json +++ b/examples/receipts/golden/MANIFEST.json @@ -6,7 +6,7 @@ "file": "betting-sharp.json", "has_source_fingerprint": true, "id": "betting-sharp", - "integrity": "13740d87af49", + "integrity": "13740d87af49d5ed6a7d8a8d1ec6a3aaa4a8e37e413b137a6907894b7af6e034", "lineage_family": "betting-sharp", "n_forecasts": 3, "query": "sharp steam square revenge wiseguy hammer", @@ -18,7 +18,7 @@ "file": "crypto-degen.json", "has_source_fingerprint": true, "id": "crypto-degen", - "integrity": "138a57d9b7d1", + "integrity": "138a57d9b7d142c72416177191b81674617728b6690e48352b93ac1c85d42c8e", "lineage_family": "crypto-degen", "n_forecasts": 3, "query": "hodl diamond hands rekt degen moon", @@ -30,7 +30,7 @@ "file": "brainrot-aura.json", "has_source_fingerprint": true, "id": "brainrot-aura", - "integrity": "e8e43b010371", + "integrity": "e8e43b0103716d19d015c238b0d9fb8aef843aa687ac56697223d38706629abc", "lineage_family": "brainrot-aura", "n_forecasts": 3, "query": "brainrot aura farming mid cooked", @@ -42,7 +42,7 @@ "file": "ai-native.json", "has_source_fingerprint": true, "id": "ai-native", - "integrity": "88573ce1608b", + "integrity": "88573ce1608b2f02b456a484080066f36a688dffc58fc6077e0cec3fcefe53f0", "lineage_family": "ai-native", "n_forecasts": 3, "query": "agentic slop skill issue hallucinate", @@ -54,7 +54,7 @@ "file": "kinship-address.json", "has_source_fingerprint": true, "id": "kinship-address", - "integrity": "885f482d77d8", + "integrity": "885f482d77d8d49eb60038b18c2d1f70f3a068f099b39df7d3c5077ca0981372", "lineage_family": "kinship-address", "n_forecasts": 3, "query": "bro sis twin unc cuz family", @@ -66,7 +66,7 @@ "file": "political-status.json", "has_source_fingerprint": true, "id": "political-status", - "integrity": "2a4fcf971e08", + "integrity": "2a4fcf971e08baebb53b648e8fac0fc7065aff6d35de12ca856b4c536a6a61f1", "lineage_family": "political-status", "n_forecasts": 3, "query": "based redpilled cope seethe dilate", @@ -78,7 +78,7 @@ "file": "gaming-meta.json", "has_source_fingerprint": true, "id": "gaming-meta", - "integrity": "74d35f2b5769", + "integrity": "74d35f2b57698ea9eb00f05f5723b6d618b8e69011a049438fdfe6fbbfb19354", "lineage_family": "gaming-meta", "n_forecasts": 3, "query": "nerf buff meta sweaty smurf gg", @@ -90,7 +90,7 @@ "file": "workplace-corp.json", "has_source_fingerprint": true, "id": "workplace-corp", - "integrity": "0fcc0f79803d", + "integrity": "0fcc0f79803dac8a62abd2d1883fc2fa7b8451f6a8ae7add9e0b3425643b6fcb", "lineage_family": "workplace-corp", "n_forecasts": 3, "query": "quiet quitting rto bandwidth act your wage", @@ -102,7 +102,7 @@ "file": "open-no-lineage.json", "has_source_fingerprint": true, "id": "open-no-lineage", - "integrity": "48d6b080d68f", + "integrity": "48d6b080d68fb9cdd71e5350b8c308f762a263379633590b44033168b13b037b", "lineage_family": null, "n_forecasts": 2, "query": "the weather is mild with clouds", diff --git a/examples/receipts/golden/ai-native.json b/examples/receipts/golden/ai-native.json index 6743d04c..95e3267e 100644 --- a/examples/receipts/golden/ai-native.json +++ b/examples/receipts/golden/ai-native.json @@ -175,9 +175,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "88573ce1608b", + "integrity": "88573ce1608b2f02b456a484080066f36a688dffc58fc6077e0cec3fcefe53f0", "path": "examples/receipts/golden/ai-native.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.587 \u0394=0.115. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/betting-sharp.json b/examples/receipts/golden/betting-sharp.json index a21ad2f9..0cf9edeb 100644 --- a/examples/receipts/golden/betting-sharp.json +++ b/examples/receipts/golden/betting-sharp.json @@ -182,9 +182,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "13740d87af49", + "integrity": "13740d87af49d5ed6a7d8a8d1ec6a3aaa4a8e37e413b137a6907894b7af6e034", "path": "examples/receipts/golden/betting-sharp.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "ACTUALIZING hyperstition risk. slang -> public pressure -> line movement -> confirmed. Virality prediction (SPECULATIVE): 0.906 \u0394=0.18. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/brainrot-aura.json b/examples/receipts/golden/brainrot-aura.json index c7d6aad1..ea4a2431 100644 --- a/examples/receipts/golden/brainrot-aura.json +++ b/examples/receipts/golden/brainrot-aura.json @@ -177,9 +177,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "e8e43b010371", + "integrity": "e8e43b0103716d19d015c238b0d9fb8aef843aa687ac56697223d38706629abc", "path": "examples/receipts/golden/brainrot-aura.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.587 \u0394=0.115. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/crypto-degen.json b/examples/receipts/golden/crypto-degen.json index 8b41a9ed..5c0e5109 100644 --- a/examples/receipts/golden/crypto-degen.json +++ b/examples/receipts/golden/crypto-degen.json @@ -177,9 +177,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "138a57d9b7d1", + "integrity": "138a57d9b7d142c72416177191b81674617728b6690e48352b93ac1c85d42c8e", "path": "examples/receipts/golden/crypto-degen.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.744 \u0394=0.105. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/gaming-meta.json b/examples/receipts/golden/gaming-meta.json index ec63c6a6..4a00e261 100644 --- a/examples/receipts/golden/gaming-meta.json +++ b/examples/receipts/golden/gaming-meta.json @@ -182,9 +182,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "74d35f2b5769", + "integrity": "74d35f2b57698ea9eb00f05f5723b6d618b8e69011a049438fdfe6fbbfb19354", "path": "examples/receipts/golden/gaming-meta.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.604 \u0394=0.079. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/kinship-address.json b/examples/receipts/golden/kinship-address.json index b5340257..d22d9516 100644 --- a/examples/receipts/golden/kinship-address.json +++ b/examples/receipts/golden/kinship-address.json @@ -161,9 +161,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "885f482d77d8", + "integrity": "885f482d77d8d49eb60038b18c2d1f70f3a068f099b39df7d3c5077ca0981372", "path": "examples/receipts/golden/kinship-address.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.519 \u0394=0.054. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/open-no-lineage.json b/examples/receipts/golden/open-no-lineage.json index 6dfec90d..392a83e8 100644 --- a/examples/receipts/golden/open-no-lineage.json +++ b/examples/receipts/golden/open-no-lineage.json @@ -115,9 +115,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "48d6b080d68f", + "integrity": "48d6b080d68fb9cdd71e5350b8c308f762a263379633590b44033168b13b037b", "path": "examples/receipts/golden/open-no-lineage.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.37 \u0394=-0.073. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/political-status.json b/examples/receipts/golden/political-status.json index 082511d3..6b406c41 100644 --- a/examples/receipts/golden/political-status.json +++ b/examples/receipts/golden/political-status.json @@ -177,9 +177,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "2a4fcf971e08", + "integrity": "2a4fcf971e08baebb53b648e8fac0fc7065aff6d35de12ca856b4c536a6a61f1", "path": "examples/receipts/golden/political-status.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.573 \u0394=0.116. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/examples/receipts/golden/workplace-corp.json b/examples/receipts/golden/workplace-corp.json index 0ec5dcf8..e808af56 100644 --- a/examples/receipts/golden/workplace-corp.json +++ b/examples/receipts/golden/workplace-corp.json @@ -178,9 +178,9 @@ "version": "0.2.5" }, "receipt": { - "integrity": "0fcc0f79803d", + "integrity": "0fcc0f79803dac8a62abd2d1883fc2fa7b8451f6a8ae7add9e0b3425643b6fcb", "path": "examples/receipts/golden/workplace-corp.json" }, "recommendation": "Bind RUNE.HLX.COMMUNICATION_RELAY via hyperlex.relay; extract_forecasts for calibration; cron LIVE_EMERGENCE_SCAN.", "speculative": "EMERGENT hyperstition risk. narrative circulating but no market confirmation yet. Virality prediction (SPECULATIVE): 0.611 \u0394=0.041. Brier requires settlement via hyperlex.calibration \u2014 not claimed on open forecasts." -} \ No newline at end of file +} diff --git a/install.sh b/install.sh index af4175ed..ad78b031 100755 --- a/install.sh +++ b/install.sh @@ -192,22 +192,23 @@ install_extra_host() { TARGET="$_saved" } -copy_claude_helpers() { +install_claude_helpers() { local dest_root="${HOME}/.claude/skills" local name src dest + local check_args=() + [[ $SKIP_SMOKE -eq 1 ]] && check_args+=(--skip-checks) for name in "${CLAUDE_HELPERS[@]}"; do - src="${ROOT}/.claude/skills/${name}/SKILL.md" - dest="${dest_root}/${name}/SKILL.md" - if [[ ! -f "$src" ]]; then - warn "Claude helper missing in source: ${src}" + src="${ROOT}/.claude/skills/${name}" + dest="${dest_root}/${name}" + if [[ ! -f "${src}/SKILL.md" ]]; then + warn "Claude helper missing in source: ${src}/SKILL.md" continue fi if [[ $DRY_RUN -eq 1 ]]; then - log "DRY RUN: would install Claude helper ${name} → ${dest}" + log "DRY RUN: would transactional-install Claude helper ${name} → ${dest}" continue fi - mkdir -p "$(dirname "$dest")" - cp -f "$src" "$dest" + python3 "${ROOT}/scripts/install_transaction.py" "$src" "$dest" hyperlex-helper "${check_args[@]}" done } @@ -237,8 +238,9 @@ if [[ $DRY_RUN -eq 1 ]]; then [[ -d "$TARGET" ]] && log "DRY RUN: would publish a target-keyed backup under ${HERMES_ROOT}/backups/hyperlex/" [[ $INSTALL_OPENCLAW -eq 1 ]] && log "DRY RUN: would also install to ${OPENCLAW_TARGET}" [[ $INSTALL_CLAUDE -eq 1 ]] && log "DRY RUN: would also install Claude personal skill to ${CLAUDE_SKILL_TARGET}" - [[ $INSTALL_CLAUDE -eq 1 ]] && copy_claude_helpers + [[ $INSTALL_CLAUDE -eq 1 ]] && install_claude_helpers [[ $INSTALL_CLAUDE_PLUGIN -eq 1 ]] && log "DRY RUN: would also install Claude plugin dir to ${CLAUDE_PLUGIN_TARGET}" + [[ $INSTALL_CLAUDE_PLUGIN -eq 1 ]] && install_claude_helpers exit 0 fi @@ -250,11 +252,12 @@ fi if [[ $INSTALL_CLAUDE -eq 1 ]]; then install_extra_host "$CLAUDE_SKILL_TARGET" "Claude personal skill" - copy_claude_helpers + install_claude_helpers fi if [[ $INSTALL_CLAUDE_PLUGIN -eq 1 ]]; then install_extra_host "$CLAUDE_PLUGIN_TARGET" "Claude plugin" + install_claude_helpers fi echo "" diff --git a/intent/2026-09-05-p1-adversary-fail-closed.md b/intent/2026-09-05-p1-adversary-fail-closed.md new file mode 100644 index 00000000..6e2f3896 --- /dev/null +++ b/intent/2026-09-05-p1-adversary-fail-closed.md @@ -0,0 +1,71 @@ +# Intent + +## Problem + +ADVERSARY P1 review found fail-open gaps on org SoT (`feba4b51`): Claude +helper copy is raw `mkdir`+`cp -f`; `init --target claude` overwrites without +backup/smoke; scored settle is API-open; X API base is unsanitized; Chroma +Cloud writes follow auto-loaded creds; doctor has no SoT-cleared marker; +receipt integrity is a 12-char prefix. + +## Proposed outcome + +One additive fail-closed PR meeting ADVERSARY P1-1…P1-6: + +- Claude init + `install.sh --claude` / `--claude-plugin` helpers are + transactional (symlink refuse, target-keyed backup, staged smoke / + UNVERIFIED). Unguarded `copy_claude_helpers` is deleted. +- `settle()` / `settle_and_log()` require token or TTY confirm, non-empty + `authority.ref`, and non-advisory kind for scored TRUE/FALSE. Piped yes + without token is refused. No score_log append on refuse. Token is not logged. +- X API base allowlist: `api.twitter.com` / `api.x.com`, https only; custom + only with explicit escape. +- Cloud vector writes require `HYPERLEX_CLOUD_WRITE=1` or TTY + `--i-understand-cloud-write`. Auto-loaded `.env` keys are not write permission. +- `doctor` emits `CLAUDE_SOT_CLEARED=` from local pin/provenance (not live + GitHub). Fail when Claude packaging is claimed and uncleared. +- `receipt.integrity` is full sha256; `emit_receipt(..., validate=True)` + default; `--no-validate` escape; legacy 12-char only with + `HYPERLEX_RECEIPT_LEGACY_INTEGRITY=1`. + +Out of scope: PyPI publish, new bots/services, exploit PoCs, phenomenology, +chromadb CVE upgrade, score_log FS rewrite / signing, SKILL.md contract +rewrite, official Hermes hub submit. + +## Affected users / systems + +Hermes / Claude install, operator settlement, X ingest, Chroma Cloud promote, +doctor, receipt goldens, CI. + +## Constraints / non-goals + +- Preserve proprietary LICENSE / LICENSE_POLICY / AGENTS.md. +- Do not invent CLI `SKILL.md` does not already name except flags named in + this accepted P1 list (`--settle-token`, `--no-validate`, + `--i-understand-cloud-write`, `--skip-smoke` on init, X custom-base escape). +- Brier only after operator settlement. No auto-settle. +- No live GitHub fetch as sole SoT-cleared path. +- Cheap folds only: http(s) scheme allowlist for LLM/embed base; `contents: read` + on `.github/workflows/ci.yml`. + +## Open questions + +None. Acceptance list is the accepted spec (Cloud Agent task). + +## Verified / assumed claims + +| Claim | Label | Basis | +|---|---|---| +| Org tip `feba4b515cf7856dd0cb85cdc24291caf1330e1b` has Claude helpers via #10 | `OBSERVED` | `git log -1` on main | +| `install.sh` `copy_claude_helpers` is raw `mkdir`+`cp -f` | `OBSERVED` | `install.sh` | +| `settle()` has no token / TTY / authority.ref gate | `OBSERVED` | `src/hyperlex/calibration/settlement.py` | +| X base is unsanitized env string | `OBSERVED` | `src/hyperlex/intake/x_search.py` | +| Cloud write follows creds without write flag | `OBSERVED` | `src/hyperlex/vectordb/chroma.py` | +| `emit_receipt` uses sha256[:12], `validate=False` default | `OBSERVED` | `src/hyperlex/receipt/__init__.py` | +| Public `scrimshawlife-ctrl@c9233c98` may lag post-#10 | `INFERRED` | task statement; not fetched as sole path | +| score_log remains unsigned FS rewrite | `OBSERVED` | residual, out of scope | + +## Author / date + +- Author: Cloud Agent (accepted task: P1 fail-closed) +- Date: 2026-09-05 diff --git a/references/claude-runtime-contract.md b/references/claude-runtime-contract.md index 64a917f9..bec7984f 100644 --- a/references/claude-runtime-contract.md +++ b/references/claude-runtime-contract.md @@ -71,6 +71,10 @@ Operator data is unchanged (outside the skill tree): 3. Never invent numeric Brier. Open analysis keeps `provenance.brier` null. 4. Never auto-settle. Ask for TRUE\|FALSE\|VOID\|CONFLICT, then run `settle`. 5. `doctor` reports `CLAUDE_OK` or `CLAUDE_MISSING`. Missing does not fail Hermes. + `CLAUDE_SOT_CLEARED=true|false` comes from the local pin + `references/claude-sot-cleared.json` plus git/install provenance (not a + live GitHub fetch). A shallow clone that lacks the pin commit stays + uncleared. Claimed Claude packaging + uncleared fails doctor. ## Authority diff --git a/references/claude-sot-cleared.json b/references/claude-sot-cleared.json new file mode 100644 index 00000000..5e36c91b --- /dev/null +++ b/references/claude-sot-cleared.json @@ -0,0 +1,6 @@ +{ + "schema": "hyperlex.claude_sot_cleared.v1", + "cleared": false, + "cleared_sha": "feba4b515cf7856dd0cb85cdc24291caf1330e1b", + "note": "Org SoT tip that mirrored Claude helpers (#10). Doctor sets CLAUDE_SOT_CLEARED=true only when local git HEAD or .install-provenance.json source_commit matches or descends from this SHA. Live GitHub fetch is not used. Public trees that lag this SHA stay uncleared." +} diff --git a/scripts/hyperlex.py b/scripts/hyperlex.py index 0a26ad7b..06169151 100755 --- a/scripts/hyperlex.py +++ b/scripts/hyperlex.py @@ -128,6 +128,25 @@ def _claude_host_check() -> _Check: ) +def _claude_sot_cleared_check() -> _Check: + """Fail-closed SoT marker. Local pin/provenance only — no live GitHub fetch.""" + from hyperlex.claude_sot import ( + claude_packaging_claimed, + doctor_sot_should_fail, + resolve_claude_sot_cleared, + ) + + cleared, reason = resolve_claude_sot_cleared(ROOT) + marker = f"CLAUDE_SOT_CLEARED={'true' if cleared else 'false'}" + claimed = claude_packaging_claimed() + message = f"{marker}; {reason}" + if doctor_sot_should_fail(cleared=cleared, claimed=claimed): + return _check(False, "claude_sot_cleared", "", f"{message}; Claude packaging claimed") + if claimed: + return _check(True, "claude_sot_cleared", f"{message}; Claude packaging claimed", "") + return _check(True, "claude_sot_cleared", f"{message}; Claude packaging not claimed", "") + + def _import_hyperlex(): try: import importlib @@ -382,8 +401,16 @@ def cmd_doctor(_args: argparse.Namespace) -> int: _check(True, "operator_home", f"~/.hyperlex exists={home_hx.is_dir()} path={home_hx}", "") ) checks.append(_claude_host_check()) + checks.append(_claude_sot_cleared_check()) ok = all(c.ok for c in checks) + sot_cleared = False + sot_msg = "CLAUDE_SOT_CLEARED=false" + for c in checks: + if c.name == "claude_sot_cleared": + sot_cleared = bool(c.ok) and c.message.startswith("CLAUDE_SOT_CLEARED=true") + sot_msg = c.message.split(";")[0].strip() if c.message else sot_msg + break _emit({ "ok": ok, "command": "doctor", @@ -393,6 +420,8 @@ def cmd_doctor(_args: argparse.Namespace) -> int: "n_failed": sum(1 for c in checks if not c.ok), "checks": [c.__dict__ for c in checks], "posture": "hermes_skill_python_package_repo", + "CLAUDE_SOT_CLEARED": sot_cleared, + "claude_sot_cleared": sot_msg, }) return 0 if ok else 2 @@ -984,7 +1013,7 @@ def cmd_emit_receipt(args: argparse.Namespace) -> int: path = pkg.emit_receipt( body, out_dir=out_dir, - validate=bool(args.validate), + validate=not bool(getattr(args, "no_validate", False)), append_ledger=not args.no_ledger, ledger_path=ledger, ) @@ -1282,6 +1311,10 @@ def cmd_vector_import(args: argparse.Namespace) -> int: if bool(getattr(args, "cloud", False)): backend = "chroma" try: + if bool(getattr(args, "cloud", False)) and bool(getattr(args, "i_understand_cloud_write", False)): + from hyperlex.guards import set_tty_cloud_write_ack + + set_tty_cloud_write_ack(True) report = import_vectors( in_path=Path(args.input), backend=backend, @@ -1328,6 +1361,10 @@ def cmd_vector_sync(args: argparse.Namespace) -> int: if to_path is None and getattr(args, "to_db", None): to_path = Path(args.to_db) + if to_cloud and bool(getattr(args, "i_understand_cloud_write", False)): + from hyperlex.guards import set_tty_cloud_write_ack + + set_tty_cloud_write_ack(True) try: report = sync_vectors( from_backend=from_backend, @@ -1801,6 +1838,7 @@ def cmd_settle(args: argparse.Namespace) -> int: authority_ref=args.authority_ref or None, authority_note=args.authority_note or None, evidence_ref=args.evidence_ref or None, + settle_token=getattr(args, "settle_token", None) or None, path=log_path, ) except ValueError as exc: @@ -1930,10 +1968,24 @@ def cmd_receipt_verify(args: argparse.Namespace) -> int: _emit({"ok": False, "error": "receipt.integrity missing"}) return 2 - canonical = json.dumps({k: payload[k] for k in payload if k != "receipt"}, sort_keys=True, separators=(",", ":")) - actual = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] - ok = actual == expected - _emit({"ok": ok, "path": str(args.path), "expected": expected, "actual": actual}) + pkg, err = _import_hyperlex() + if pkg is None: + _emit({"ok": False, "error": f"import failure: {err}"}) + return 2 + ok, msg = pkg.verify_receipt(payload) + actual = None + if isinstance(payload, dict): + body = {k: payload[k] for k in payload if k != "receipt"} + actual = hashlib.sha256( + json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + _emit({ + "ok": ok, + "path": str(args.path), + "expected": expected, + "actual": actual, + "message": msg, + }) return 0 if ok else 2 @@ -2497,7 +2549,8 @@ def _add_pipeline_args(p: argparse.ArgumentParser, *, default_route: str = "offl emit_parser.add_argument("--out-dir", default="", help="Receipt directory (default ~/.hyperlex/receipts)") emit_parser.add_argument("--ledger", default="") emit_parser.add_argument("--no-ledger", action="store_true", default=False) - emit_parser.add_argument("--validate", action="store_true", default=False) + emit_parser.add_argument("--validate", action="store_true", default=True) + emit_parser.add_argument("--no-validate", action="store_true", default=False, dest="no_validate") emit_parser.set_defaults(func=cmd_emit_receipt) list_parser = subparsers.add_parser("list-receipts", help="List receipt ledger index entries") @@ -2600,6 +2653,13 @@ def _add_pipeline_args(p: argparse.ArgumentParser, *, default_route: str = "offl default=False, help="Import into Chroma Cloud (uses HYPERLEX_CHROMA_* creds; ignores local path env)", ) + vim.add_argument( + "--i-understand-cloud-write", + action="store_true", + default=False, + dest="i_understand_cloud_write", + help="TTY ack for cloud writes (or set HYPERLEX_CLOUD_WRITE=1)", + ) vim.set_defaults(func=cmd_vector_import) vsy = subparsers.add_parser( @@ -2624,6 +2684,13 @@ def _add_pipeline_args(p: argparse.ArgumentParser, *, default_route: str = "offl vsy.add_argument("--to-db", default="", help="Dest sqlite file when --to sqlite") vsy.add_argument("--to-backend", default="chroma", help="Used with --to path (default chroma)") vsy.add_argument("--kind", default="", help="Optional filter: term | receipt") + vsy.add_argument( + "--i-understand-cloud-write", + action="store_true", + default=False, + dest="i_understand_cloud_write", + help="TTY ack for cloud writes (or set HYPERLEX_CLOUD_WRITE=1)", + ) vsy.set_defaults(func=cmd_vector_sync) lbf = subparsers.add_parser( @@ -2802,6 +2869,11 @@ def _add_pipeline_args(p: argparse.ArgumentParser, *, default_route: str = "offl settle_parser.add_argument("--authority-kind", default="operator", choices=["operator", "automated_rule", "external_oracle"]) settle_parser.add_argument("--authority-ref", default="") settle_parser.add_argument("--authority-note", default="") + settle_parser.add_argument( + "--settle-token", + default="", + help="Human settlement token (or set HYPERLEX_SETTLE_TOKEN). Never logged.", + ) settle_parser.add_argument("--evidence-ref", default="") settle_parser.add_argument("--export-ledger", action="store_true", default=False, help="Emit Abraxas-compatible BrierLedgerEntry") settle_parser.add_argument("--log", default="") diff --git a/scripts/install_transaction.py b/scripts/install_transaction.py index fd30b305..78d5838c 100755 --- a/scripts/install_transaction.py +++ b/scripts/install_transaction.py @@ -20,8 +20,10 @@ from pathlib import Path KIND = "hyperlex" +KIND_HELPER = "hyperlex-helper" CHECKS = { KIND: [("hyperlex.py", "check"), ("hyperlex.py", "smoke")], + KIND_HELPER: [], } IGNORE = shutil.ignore_patterns( ".git", @@ -125,6 +127,11 @@ def _check_env(workspace: Path, stage: Path) -> dict[str, str]: def _run_checks(stage: Path, kind: str, env: dict[str, str], skip_checks: bool) -> None: if skip_checks: return + if kind == KIND_HELPER: + skill = stage / "SKILL.md" + if not skill.is_file() or not skill.read_text(encoding="utf-8").strip(): + raise ValueError("helper SKILL.md missing or empty") + return for command in CHECKS[kind]: subprocess.run( [sys.executable, str(stage / "scripts" / command[0]), *command[1:]], @@ -169,7 +176,7 @@ def _write_receipt( "repository": _git(source, "remote", "get-url", "origin") if own_checkout else None, "source_commit": _git(source, "rev-parse", "HEAD") if own_checkout else None, "source_dirty": bool(dirty) if dirty is not None else None, - "version": (source / "VERSION").read_text().strip(), + "version": (source / "VERSION").read_text().strip() if (source / "VERSION").is_file() else None, "destination": str(target), "status": "UNVERIFIED" if skip_checks else "VALIDATED", "checks_skipped": list(CHECKS[kind]) if skip_checks else [], @@ -247,10 +254,10 @@ def install(source: Path, target: Path, kind: str, skip_checks: bool = False) -> _run_checks(stage, kind, _check_env(workspace, stage), skip_checks) _preserve_legacy_out(target, stage) receipt = _write_receipt(source, target, stage, kind, skip_checks) - expected = { - path: (stage / path).read_bytes() - for path in ("SKILL.md", "VERSION", ".install-provenance.json") - } + expected_names = ["SKILL.md", ".install-provenance.json"] + if (stage / "VERSION").is_file(): + expected_names.append("VERSION") + expected = {path: (stage / path).read_bytes() for path in expected_names} backup = _publish_backup(target, backups) _refuse_symlink_target(target, "target became a symlink during staging") try: diff --git a/src/hyperlex/calibration/score_log.py b/src/hyperlex/calibration/score_log.py index 3433ebb6..80e5ecbf 100644 --- a/src/hyperlex/calibration/score_log.py +++ b/src/hyperlex/calibration/score_log.py @@ -170,6 +170,7 @@ def settle_and_log( authority_ref: Optional[str] = None, authority_note: Optional[str] = None, evidence_ref: Optional[str] = None, + settle_token: Optional[str] = None, path: Optional[Path | str] = None, ) -> Dict[str, Any]: """ @@ -177,16 +178,11 @@ def settle_and_log( Returns {settlement, score, settlement_record, score_record}. Does not invent Brier if decision is VOID/CONFLICT (score status = NOT_COMPUTABLE). + Scored TRUE/FALSE are gated in settle(); a refused gate appends nothing. """ from .settlement import settle - # Ensure forecast is present in the log so series recompute can join pairs. - existing = index_forecasts(read_log(path)) - forecast_record = None - fid = str(forecast.get("forecast_id") or "") - if fid and fid not in existing: - forecast_record = append_forecast(forecast, path=path) - + # Gate before any log write so a refused scored settle cannot append. settlement = settle( forecast, outcome_value=outcome_value, @@ -195,7 +191,15 @@ def settle_and_log( authority_ref=authority_ref, authority_note=authority_note, evidence_ref=evidence_ref, + settle_token=settle_token, ) + + # Ensure forecast is present in the log so series recompute can join pairs. + existing = index_forecasts(read_log(path)) + forecast_record = None + fid = str(forecast.get("forecast_id") or "") + if fid and fid not in existing: + forecast_record = append_forecast(forecast, path=path) settlement_record = append_settlement(settlement, forecast=forecast, path=path) score = score_pair(forecast, settlement) score_record = append_score(score, forecast=forecast, settlement=settlement, path=path) diff --git a/src/hyperlex/calibration/settlement.py b/src/hyperlex/calibration/settlement.py index 9016e0c2..53d2d7d1 100644 --- a/src/hyperlex/calibration/settlement.py +++ b/src/hyperlex/calibration/settlement.py @@ -6,6 +6,8 @@ from datetime import datetime, timezone from typing import Any, Dict, Optional +from hyperlex.guards import require_scored_settle_gate + def is_scorable(settlement: Dict[str, Any]) -> bool: decision = str(settlement.get("settlement_decision", "")).upper() @@ -21,6 +23,7 @@ def settle( authority_ref: Optional[str] = None, authority_note: Optional[str] = None, evidence_ref: Optional[str] = None, + settle_token: Optional[str] = None, ) -> Dict[str, Any]: """ Build a settlement object for a forecast. @@ -28,6 +31,11 @@ def settle( outcome_value must be 0.0 or 1.0. settlement_decision: TRUE | FALSE | VOID | CONFLICT Only TRUE/FALSE are later scored. + + Scored TRUE/FALSE require a human token (HYPERLEX_SETTLE_TOKEN / + settle_token) or an interactive TTY confirm, a non-empty authority.ref, + and authority.kind != advisory. Piped / non-TTY stdin cannot confirm. + The raw token is never stored on the settlement. """ if outcome_value not in (0.0, 1.0): raise ValueError("outcome_value must be 0.0 or 1.0") @@ -35,6 +43,13 @@ def settle( if decision not in ("TRUE", "FALSE", "VOID", "CONFLICT"): raise ValueError("settlement_decision must be TRUE|FALSE|VOID|CONFLICT") + require_scored_settle_gate( + settlement_decision=decision, + authority_kind=authority_kind, + authority_ref=authority_ref, + settle_token=settle_token, + ) + forecast_id = forecast["forecast_id"] settled_at = datetime.now(timezone.utc).isoformat() raw = f"{forecast_id}|{outcome_value}|{decision}|{settled_at}" diff --git a/src/hyperlex/claude_sot.py b/src/hyperlex/claude_sot.py new file mode 100644 index 00000000..ac0ab53d --- /dev/null +++ b/src/hyperlex/claude_sot.py @@ -0,0 +1,134 @@ +"""Local Claude SoT-cleared provenance (no live GitHub fetch).""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +PIN_RELATIVE = Path("references") / "claude-sot-cleared.json" + + +def _git_ok(root: Path, *args: str) -> bool: + try: + result = subprocess.run( + ["git", "-C", str(root), *args], + capture_output=True, + text=True, + check=False, + env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, + ) + except OSError: + return False + return result.returncode == 0 + + +def _git_text(root: Path, *args: str) -> Optional[str]: + try: + result = subprocess.run( + ["git", "-C", str(root), *args], + capture_output=True, + text=True, + check=False, + env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, + ) + except OSError: + return None + if result.returncode != 0: + return None + return (result.stdout or "").strip() or None + + +def load_pin(root: Path) -> Optional[Dict[str, Any]]: + path = Path(root) / PIN_RELATIVE + if not path.is_file() or path.is_symlink(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def _install_source_commit(root: Path) -> Optional[str]: + marker = Path(root) / ".install-provenance.json" + if not marker.is_file() or marker.is_symlink(): + return None + try: + data = json.loads(marker.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict): + return None + raw = str(data.get("source_commit") or "").strip() + return raw or None + + +def _normalize_sha(raw: Optional[str]) -> Optional[str]: + sha = str(raw or "").strip().lower() + if len(sha) < 7 or any(c not in "0123456789abcdef" for c in sha): + return None + return sha + + +def _sha_match(left: str, right: str) -> bool: + return left == right or left.startswith(right) or right.startswith(left) + + +def resolve_claude_sot_cleared(root: Path) -> Tuple[bool, str]: + """Return (cleared, reason) from local pin + git/install provenance. + + Live GitHub fetch is never consulted. + """ + pin = load_pin(root) + if pin is None: + return False, "no local pin file" + pinned = _normalize_sha(str(pin.get("cleared_sha") or "")) + if not pinned: + return False, "pin missing cleared_sha" + + head = _normalize_sha(_git_text(Path(root), "rev-parse", "HEAD")) + if head and _sha_match(head, pinned): + return True, f"local HEAD matches pinned {pinned[:12]}" + pin_present = _git_ok(Path(root), "cat-file", "-e", f"{pinned}^{{commit}}") + if pin_present and _git_ok(Path(root), "merge-base", "--is-ancestor", pinned, "HEAD"): + return True, f"local HEAD descends from pinned {pinned[:12]}" + + installed = _normalize_sha(_install_source_commit(root)) + if installed and _sha_match(installed, pinned): + return True, f"install provenance matches pinned {pinned[:12]}" + + if pin.get("cleared") is True and pinned and not head and not installed: + return True, f"pin cleared=true sha={pinned[:12]} (no local git)" + if head and not pin_present: + shallow = _git_text(Path(root), "rev-parse", "--is-shallow-repository") + extra = "; shallow clone" if shallow == "true" else "" + return False, ( + f"pin commit {pinned[:12]} not in local git objects{extra}; " + f"cannot prove descent (no live GitHub fetch)" + ) + return False, f"local provenance does not match pinned {pinned[:12]}" + + +def doctor_sot_should_fail(*, cleared: bool, claimed: bool) -> bool: + """Prefer fail (not warn-only) when Claude packaging is claimed and uncleared.""" + return bool(claimed) and not bool(cleared) + + +def claude_packaging_claimed(*, home: Optional[Path] = None) -> bool: + """True when a Claude personal skill, plugin, or env skill dir is present.""" + base = Path(home) if home is not None else Path.home() + personal = base / ".claude" / "skills" / "hyperlex" + plugin = base / ".claude" / "plugins" / "hyperlex" + if (personal / "SKILL.md").is_file(): + return True + if (plugin / ".claude-plugin" / "plugin.json").is_file() or (plugin / "SKILL.md").is_file(): + return True + env_raw = os.environ.get("HYPERLEX_SKILL_DIR") or os.environ.get("CLAUDE_SKILL_DIR") or "" + if env_raw: + env_path = Path(env_raw).expanduser() + if (env_path / "SKILL.md").is_file() or (env_path / "scripts" / "hyperlex.py").is_file(): + return True + return False diff --git a/src/hyperlex/cli.py b/src/hyperlex/cli.py index f494ab31..ff15a83a 100644 --- a/src/hyperlex/cli.py +++ b/src/hyperlex/cli.py @@ -263,6 +263,10 @@ def cmd_settle(args: argparse.Namespace) -> int: forecast, outcome_value=outcome, settlement_decision=decision, + authority_kind=getattr(args, "authority_kind", "operator"), + authority_ref=getattr(args, "authority_ref", None) or None, + authority_note=getattr(args, "authority_note", None) or None, + settle_token=getattr(args, "settle_token", None) or None, path=log, ) _emit({"ok": True, "command": "settle", "score": out["score"], "settlement": out["settlement"]}) @@ -537,6 +541,10 @@ def build_parser() -> argparse.ArgumentParser: s.add_argument("--forecast-file", default="") s.add_argument("--decision", required=True) s.add_argument("--outcome", type=float, default=None) + s.add_argument("--authority-kind", default="operator") + s.add_argument("--authority-ref", default="") + s.add_argument("--authority-note", default="") + s.add_argument("--settle-token", default="") s.add_argument("--log", default="") s.set_defaults(func=cmd_settle) diff --git a/src/hyperlex/guards.py b/src/hyperlex/guards.py new file mode 100644 index 00000000..2cb99936 --- /dev/null +++ b/src/hyperlex/guards.py @@ -0,0 +1,192 @@ +"""Fail-closed operator gates (settle, URL, cloud write, receipt digest). + +Additive hardening. Does not invent Brier, auto-settle, or log secrets. +""" + +from __future__ import annotations + +import os +import sys +from typing import Optional +from urllib.parse import urlparse + + +SETTLE_CONFIRM_PHRASE = "SETTLE" +X_API_ALLOWED_HOSTS = frozenset({"api.twitter.com", "api.x.com"}) +HTTP_SCHEMES = frozenset({"http", "https"}) + + +class SettleGateError(ValueError): + """Scored settlement refused (missing token / TTY / authority).""" + + +class CloudWriteError(PermissionError): + """Cloud vector write refused (missing HYPERLEX_CLOUD_WRITE / TTY ack).""" + + +class UrlGateError(ValueError): + """URL refused by allowlist / scheme / userinfo / port rules.""" + + +def _truthy(raw: Optional[str]) -> bool: + return str(raw or "").strip().lower() in {"1", "true", "yes", "on"} + + +def settle_token_from_env() -> str: + return str(os.environ.get("HYPERLEX_SETTLE_TOKEN") or "").strip() + + +def require_scored_settle_gate( + *, + settlement_decision: str, + authority_kind: str, + authority_ref: Optional[str], + settle_token: Optional[str] = None, + stdin=None, + stdout=None, +) -> None: + """Gate scored TRUE/FALSE settlements. VOID/CONFLICT skip this gate. + + Token is never returned or logged. Piped / non-TTY stdin cannot confirm. + """ + decision = str(settlement_decision or "").upper() + if decision not in {"TRUE", "FALSE"}: + return + + kind = str(authority_kind or "").strip().lower() + if kind == "advisory": + raise SettleGateError("scored settlement refuses authority.kind=advisory") + + ref = str(authority_ref or "").strip() + if not ref: + raise SettleGateError("scored settlement requires non-empty authority.ref") + + token = str(settle_token or "").strip() or settle_token_from_env() + if token: + return + + in_stream = stdin if stdin is not None else sys.stdin + out_stream = stdout if stdout is not None else sys.stdout + is_tty = bool(getattr(in_stream, "isatty", lambda: False)()) + if not is_tty: + raise SettleGateError( + "scored settlement requires HYPERLEX_SETTLE_TOKEN / --settle-token " + "(non-TTY stdin cannot confirm; piped yes is refused)" + ) + + if out_stream is not None and hasattr(out_stream, "write"): + out_stream.write( + f"Confirm scored settlement: type {SETTLE_CONFIRM_PHRASE} and press Enter\n" + ) + try: + out_stream.flush() + except Exception: + pass + try: + typed = str(in_stream.readline() if hasattr(in_stream, "readline") else "").strip() + except Exception as exc: + raise SettleGateError("scored settlement TTY confirm failed") from exc + if typed != SETTLE_CONFIRM_PHRASE: + raise SettleGateError( + f"scored settlement TTY confirm refused (expected {SETTLE_CONFIRM_PHRASE})" + ) + + +def x_api_custom_allowed(*, allow_custom: Optional[bool] = None) -> bool: + if allow_custom is True: + return True + return _truthy(os.environ.get("HYPERLEX_X_API_BASE_ALLOW_CUSTOM")) + + +def validate_x_api_base( + raw: str, + *, + allow_custom: Optional[bool] = None, +) -> str: + """Return a sanitized https X API base or raise UrlGateError. No request.""" + base = str(raw or "").strip() + if not base: + raise UrlGateError("X API base is empty") + parsed = urlparse(base) + if parsed.scheme != "https": + raise UrlGateError("X API base must be https") + if parsed.username or parsed.password: + raise UrlGateError("X API base must not include userinfo") + host = (parsed.hostname or "").lower() + if not host: + raise UrlGateError("X API base host is empty") + port = parsed.port + if port is not None and port != 443: + raise UrlGateError("X API base must use the default https port") + custom = x_api_custom_allowed(allow_custom=allow_custom) + if host not in X_API_ALLOWED_HOSTS and not custom: + raise UrlGateError( + f"X API host not allowlisted: {host} " + "(set HYPERLEX_X_API_BASE_ALLOW_CUSTOM=1 to override)" + ) + path = parsed.path.rstrip("/") + return f"https://{host}{path}" + + +def require_http_url(raw: str, *, name: str = "url") -> str: + """Allow only http/https (reject file:// and other schemes).""" + text = str(raw or "").strip() + if not text: + raise UrlGateError(f"{name} is empty") + parsed = urlparse(text) + if parsed.scheme not in HTTP_SCHEMES: + raise UrlGateError(f"{name} must be http or https (refusing {parsed.scheme or 'missing'}://)") + if not parsed.netloc: + raise UrlGateError(f"{name} host is empty") + return text + + +_TTY_CLOUD_WRITE_ACK = False + + +def set_tty_cloud_write_ack(value: bool, *, stdin=None) -> None: + """CLI --i-understand-cloud-write: honor only when stdin is a TTY.""" + global _TTY_CLOUD_WRITE_ACK + in_stream = stdin if stdin is not None else sys.stdin + _TTY_CLOUD_WRITE_ACK = bool(value) and bool(getattr(in_stream, "isatty", lambda: False)()) + + +def cloud_write_permitted(*, i_understand: bool = False, stdin=None) -> bool: + if _truthy(os.environ.get("HYPERLEX_CLOUD_WRITE")): + return True + in_stream = stdin if stdin is not None else sys.stdin + if _TTY_CLOUD_WRITE_ACK and bool(getattr(in_stream, "isatty", lambda: False)()): + return True + if i_understand and bool(getattr(in_stream, "isatty", lambda: False)()): + return True + return False + + +def require_cloud_write(*, i_understand: bool = False, stdin=None) -> None: + if cloud_write_permitted(i_understand=i_understand, stdin=stdin): + return + raise CloudWriteError( + "cloud vector write refused: set HYPERLEX_CLOUD_WRITE=1 " + "or pass --i-understand-cloud-write on a TTY " + "(auto-loaded ~/.hermes/.env / ~/.hyperlex/.env keys are not write permission)" + ) + + +def receipt_legacy_integrity_enabled() -> bool: + return _truthy(os.environ.get("HYPERLEX_RECEIPT_LEGACY_INTEGRITY")) + + +__all__ = [ + "SETTLE_CONFIRM_PHRASE", + "X_API_ALLOWED_HOSTS", + "SettleGateError", + "CloudWriteError", + "UrlGateError", + "require_scored_settle_gate", + "validate_x_api_base", + "require_http_url", + "cloud_write_permitted", + "require_cloud_write", + "receipt_legacy_integrity_enabled", + "x_api_custom_allowed", +] diff --git a/src/hyperlex/init_skill.py b/src/hyperlex/init_skill.py index fd53341e..56459758 100644 --- a/src/hyperlex/init_skill.py +++ b/src/hyperlex/init_skill.py @@ -7,18 +7,28 @@ from __future__ import annotations import argparse +import hashlib import json +import os import shutil +import tempfile +import uuid from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional -TARGETS = { - "hermes": Path.home() / ".hermes" / "skills" / "hyperlex", - "openclaw": Path.home() / ".openclaw" / "skills" / "hyperlex", - "grok": Path.home() / ".grok" / "skills" / "hyperlex", - "claude": Path.home() / ".claude" / "skills" / "hyperlex", -} +def skill_targets() -> Dict[str, Path]: + home = Path.home() + return { + "hermes": home / ".hermes" / "skills" / "hyperlex", + "openclaw": home / ".openclaw" / "skills" / "hyperlex", + "grok": home / ".grok" / "skills" / "hyperlex", + "claude": home / ".claude" / "skills" / "hyperlex", + } + + +# Import-time snapshot for existing tests; run_* recomputes via skill_targets(). +TARGETS = skill_targets() MARKER = "" @@ -54,27 +64,88 @@ def skill_template() -> str: """ -def _write_skill(dest: Path, *, dry_run: bool) -> str: - dest.mkdir(parents=True, exist_ok=True) +def _refuse_symlink_dest(dest: Path) -> None: + # Inspect the lexical path before resolve() follows a dangling link. + absolute = dest.expanduser().absolute() + if any(part.is_symlink() for part in (absolute, *absolute.parents)): + raise ValueError("refusing symlink dest") + + +def _backup_root() -> Path: + raw = os.environ.get("HYPERLEX_HOME") or str(Path.home() / ".hyperlex") + return Path(raw).expanduser() + + +def _publish_target_backup(dest: Path) -> Optional[Path]: + if not dest.exists(): + return None + key = hashlib.sha256(str(dest.resolve()).encode()).hexdigest()[:20] + backups = _backup_root() / "backups" / "init" / key + backups.mkdir(parents=True, exist_ok=True) + backup = backups / uuid.uuid4().hex + if dest.is_file(): + backup.mkdir(parents=True) + shutil.copy2(dest, backup / dest.name) + else: + shutil.copytree(dest, backup, symlinks=True) + marker = backup / ".backup-target.json" + marker.write_text(json.dumps({"destination": str(dest.resolve())}) + "\n", encoding="utf-8") + return backup + + +def _stage_write_skill(dest: Path, body: str, *, skip_smoke: bool) -> Dict[str, object]: + dest.parent.mkdir(parents=True, exist_ok=True) + workspace = Path(tempfile.mkdtemp(prefix=".hyperlex-init-", dir=str(dest.parent))) + stage = workspace / "SKILL.md" + try: + stage.write_text(body, encoding="utf-8") + status = "UNVERIFIED" + if skip_smoke: + status = "UNVERIFIED" + else: + staged = stage.read_text(encoding="utf-8") + if MARKER not in staged or "hyperlex" not in staged.lower(): + raise ValueError("staged SKILL.md failed smoke (marker/cli missing)") + status = "VALIDATED" + final = dest / "SKILL.md" + if dest.exists() and dest.is_file(): + # dest itself is a file (unexpected); replace via parent + os.replace(stage, dest) + return {"path": str(dest), "status": status} + dest.mkdir(parents=True, exist_ok=True) + os.replace(stage, final) + return {"path": str(final), "status": status} + finally: + shutil.rmtree(workspace, ignore_errors=True) + + +def _write_skill(dest: Path, *, dry_run: bool, skip_smoke: bool) -> Dict[str, object]: + _refuse_symlink_dest(dest) path = dest / "SKILL.md" body = skill_template() - if not dry_run: - path.write_text(body, encoding="utf-8") - return str(path) + if dry_run: + return {"target_path": str(path), "path": str(path), "status": "DRY_RUN", "dry_run": True} + backup = _publish_target_backup(dest) + written = _stage_write_skill(dest, body, skip_smoke=skip_smoke) + if backup is not None: + written["backup"] = str(backup) + return written -def run_init(names: List[str], *, dry_run: bool) -> Dict: +def run_init(names: List[str], *, dry_run: bool, skip_smoke: bool = False) -> Dict: written = [] for name in names: - dest = TARGETS[name] - written.append({"target": name, "path": _write_skill(dest, dry_run=dry_run)}) + dest = skill_targets()[name] + row = _write_skill(dest, dry_run=dry_run, skip_smoke=skip_smoke) + row["target"] = name + written.append(row) return {"ok": True, "command": "init", "dry_run": dry_run, "written": written} def run_uninstall(names: List[str], *, dry_run: bool) -> Dict: removed = [] for name in names: - dest = TARGETS[name] + dest = skill_targets()[name] skill = dest / "SKILL.md" if skill.is_file() and MARKER in skill.read_text(encoding="utf-8", errors="ignore"): if not dry_run: @@ -89,20 +160,25 @@ def dispatch(argv: List[str]) -> int: p = argparse.ArgumentParser(prog="hyperlex") sub = p.add_subparsers(dest="cmd", required=True) ini = sub.add_parser("init") - ini.add_argument("--target", action="append", choices=sorted(TARGETS), dest="targets") + ini.add_argument("--target", action="append", choices=sorted(skill_targets()), dest="targets") ini.add_argument("--all", action="store_true") ini.add_argument("--dry-run", action="store_true") + ini.add_argument("--skip-smoke", action="store_true", help="Skip staged smoke (marks UNVERIFIED)") un = sub.add_parser("uninstall-skill") - un.add_argument("--target", action="append", choices=sorted(TARGETS), dest="targets") + un.add_argument("--target", action="append", choices=sorted(skill_targets()), dest="targets") un.add_argument("--all", action="store_true") un.add_argument("--dry-run", action="store_true") args = p.parse_args(argv) names = list(args.targets or []) if args.all or not names: names = ["hermes", "openclaw", "grok"] - if args.cmd == "init": - out = run_init(names, dry_run=bool(args.dry_run)) - else: - out = run_uninstall(names, dry_run=bool(args.dry_run)) + try: + if args.cmd == "init": + out = run_init(names, dry_run=bool(args.dry_run), skip_smoke=bool(getattr(args, "skip_smoke", False))) + else: + out = run_uninstall(names, dry_run=bool(args.dry_run)) + except ValueError as exc: + print(json.dumps({"ok": False, "error": str(exc)}, indent=2, sort_keys=True)) + return 1 print(json.dumps(out, indent=2, sort_keys=True)) return 0 diff --git a/src/hyperlex/intake/x_search.py b/src/hyperlex/intake/x_search.py index 43a0a431..a4048ae5 100644 --- a/src/hyperlex/intake/x_search.py +++ b/src/hyperlex/intake/x_search.py @@ -18,6 +18,8 @@ import subprocess from typing import Any, Dict, Optional, Tuple +from hyperlex.guards import UrlGateError, validate_x_api_base, x_api_custom_allowed + try: import requests except ImportError: @@ -37,19 +39,40 @@ def _bearer() -> str: ) -def _api_base() -> str: - return os.environ.get("HYPERLEX_X_API_BASE", "https://api.twitter.com/2").rstrip("/") +def _api_base(*, allow_custom: Optional[bool] = None) -> str: + raw = os.environ.get("HYPERLEX_X_API_BASE", "https://api.twitter.com/2") + return validate_x_api_base(raw, allow_custom=allow_custom) -def fetch_x_api(query: str) -> Tuple[str, Dict[str, Any]]: +def fetch_x_api(query: str, *, allow_custom: Optional[bool] = None) -> Tuple[str, Dict[str, Any]]: """Twitter API v2 recent search. Returns (signal, meta).""" token = _bearer() - locator = f"{_api_base()}/tweets/search/recent" meta: Dict[str, Any] = { "adapter": "x_api_v2", - "source_locator": locator, "live": False, } + try: + base = _api_base(allow_custom=allow_custom) + except UrlGateError as exc: + meta["reason"] = "x_api_base_refused" + meta["error"] = str(exc) + meta["source_locator"] = "hyperlex://x_search/refused" + if x_api_custom_allowed(allow_custom=allow_custom): + meta["custom_base_allowed"] = True + return f"[X_API_BASE_REFUSED] {exc} for '{query}'", meta + + locator = f"{base}/tweets/search/recent" + meta["source_locator"] = locator + if x_api_custom_allowed(allow_custom=allow_custom): + from urllib.parse import urlparse + + host = (urlparse(base).hostname or "").lower() + from hyperlex.guards import X_API_ALLOWED_HOSTS + + if host not in X_API_ALLOWED_HOSTS: + meta["custom_base"] = True + meta["warn"] = "HYPERLEX_X_API_BASE_ALLOW_CUSTOM override in use" + if _offline(): meta["reason"] = "offline" return f"[X_OFFLINE] search disabled for '{query}'", meta diff --git a/src/hyperlex/llm/governed.py b/src/hyperlex/llm/governed.py index 16fdc196..e9b57e31 100644 --- a/src/hyperlex/llm/governed.py +++ b/src/hyperlex/llm/governed.py @@ -78,7 +78,10 @@ def _openai_compatible_provider(prompt: str, context: Dict[str, Any]) -> str: if not api_key: raise GovernedLLMError("HYPERLEX_LLM_API_KEY (or OPENAI_API_KEY) required") + from hyperlex.guards import require_http_url + base = os.environ.get("HYPERLEX_LLM_BASE_URL", "https://api.openai.com/v1").rstrip("/") + base = require_http_url(base, name="HYPERLEX_LLM_BASE_URL").rstrip("/") model = os.environ.get("HYPERLEX_LLM_MODEL", "gpt-4o-mini").strip() or "gpt-4o-mini" try: timeout = float(os.environ.get("HYPERLEX_LLM_TIMEOUT", "30") or "30") diff --git a/src/hyperlex/receipt/__init__.py b/src/hyperlex/receipt/__init__.py index 21c06870..c1ef8458 100644 --- a/src/hyperlex/receipt/__init__.py +++ b/src/hyperlex/receipt/__init__.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, Optional, Tuple +from ..guards import receipt_legacy_integrity_enabled from ..schemas import validate_receipt from .ledger import ( append_receipt_index, @@ -29,7 +30,7 @@ def _canonical_json(payload: dict) -> str: def emit_receipt( result: dict, out_dir: str | Path | None = None, - validate: bool = False, + validate: bool = True, *, append_ledger: bool = True, ledger_path: str | Path | None = None, @@ -45,10 +46,10 @@ def emit_receipt( out.mkdir(parents=True, exist_ok=True) canonical = _canonical_json(result) - integrity = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] + integrity = hashlib.sha256(canonical.encode("utf-8")).hexdigest() ts = result.get("provenance", {}).get("timestamp", "now").replace(":", "").replace("-", "").split(".")[0][:15] - # integrity suffix avoids collisions when multiple receipts share a second - path = out / f"hyperlex_{ts}_{integrity}.json" + # short filename suffix; receipt.integrity remains the full 64-char digest + path = out / f"hyperlex_{ts}_{integrity[:12]}.json" receipt = dict(result) receipt["receipt"] = { @@ -106,10 +107,16 @@ def verify_receipt(payload: dict) -> Tuple[bool, str]: if not expected: return False, "missing receipt.integrity" canonical = _canonical_json({k: v for k, v in payload.items() if k != "receipt"}) - actual = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12] - if actual != expected: - return False, f"integrity mismatch: expected {expected}, actual {actual}" - return True, "valid" + actual_full = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + if expected == actual_full: + return True, "valid" + if ( + len(expected) == 12 + and expected == actual_full[:12] + and receipt_legacy_integrity_enabled() + ): + return True, "valid (legacy 12-char; HYPERLEX_RECEIPT_LEGACY_INTEGRITY=1)" + return False, f"integrity mismatch: expected {expected}, actual {actual_full}" __all__ = [ diff --git a/src/hyperlex/vectordb/autoindex.py b/src/hyperlex/vectordb/autoindex.py index 5441a934..bfcfaa01 100644 --- a/src/hyperlex/vectordb/autoindex.py +++ b/src/hyperlex/vectordb/autoindex.py @@ -98,6 +98,10 @@ def index_texts( own = store is None try: store = store or _open_store() + if getattr(store, "_cloud", False) or getattr(store, "force_cloud", False): + from hyperlex.guards import require_cloud_write + + require_cloud_write() texts = [str(r.get("text") or "") for r in rows] vecs, info = embed_batch(texts) n = 0 diff --git a/src/hyperlex/vectordb/chroma.py b/src/hyperlex/vectordb/chroma.py index b1b35bbc..b78a2c16 100644 --- a/src/hyperlex/vectordb/chroma.py +++ b/src/hyperlex/vectordb/chroma.py @@ -26,6 +26,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional +from hyperlex.guards import require_cloud_write + try: import chromadb from chromadb import CloudClient @@ -116,6 +118,16 @@ def get_chroma_client( return CloudClient(**kwargs) +def _is_cloud_client(client: Any) -> bool: + if client is None: + return False + name = type(client).__name__ + if name == "CloudClient": + return True + module = getattr(type(client), "__module__", "") + return "chromadb" in module and "Cloud" in name + + def _sanitize_meta(meta: Dict[str, Any]) -> Dict[str, Any]: """Chroma metadata values must be str/int/float/bool; drop None.""" out: Dict[str, Any] = {} @@ -149,6 +161,11 @@ def __init__( self.client = client or get_chroma_client(path=self.path, force_cloud=self.force_cloud) self.collection_name = collection_name or DEFAULT_COLLECTION self._collection = None + self._cloud = self.force_cloud or _is_cloud_client(self.client) + + def _require_write(self) -> None: + if self._cloud: + require_cloud_write() @property def collection(self): @@ -176,6 +193,7 @@ def upsert( "model": model, "family_id": family_id, })) + self._require_write() # Chroma expects list of lists for embeddings self.collection.upsert( ids=[id], @@ -203,6 +221,7 @@ def upsert_many(self, rows: List[Dict[str, Any]]) -> int: "family_id": r.get("family_id"), })) metadatas.append(m) + self._require_write() self.collection.upsert( ids=ids, embeddings=embeddings, diff --git a/src/hyperlex/vectordb/embed.py b/src/hyperlex/vectordb/embed.py index 9fa727ab..38e42266 100644 --- a/src/hyperlex/vectordb/embed.py +++ b/src/hyperlex/vectordb/embed.py @@ -94,11 +94,14 @@ def embed_openai_compatible(texts: Sequence[str]) -> tuple[List[List[float]], Em import urllib.error import urllib.request + from hyperlex.guards import require_http_url + base = os.environ.get("HYPERLEX_EMBED_BASE_URL") or os.environ.get("HYPERLEX_LLM_BASE_URL") or "" key = os.environ.get("HYPERLEX_EMBED_API_KEY") or os.environ.get("HYPERLEX_LLM_API_KEY") or os.environ.get("OPENAI_API_KEY") or "" model = os.environ.get("HYPERLEX_EMBED_MODEL", "text-embedding-3-small") if not base: raise RuntimeError("HYPERLEX_EMBED_BASE_URL or HYPERLEX_LLM_BASE_URL required for openai_compatible embeddings") + base = require_http_url(base, name="HYPERLEX_EMBED_BASE_URL") if str(os.environ.get("HYPERLEX_OFFLINE", "")).strip() in {"1", "true", "yes", "on"}: raise RuntimeError("offline: remote embeddings disabled (HYPERLEX_OFFLINE=1)") diff --git a/src/hyperlex/vectordb/transfer.py b/src/hyperlex/vectordb/transfer.py index 09efd9fb..3dcd0d19 100644 --- a/src/hyperlex/vectordb/transfer.py +++ b/src/hyperlex/vectordb/transfer.py @@ -21,6 +21,8 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Sequence +from hyperlex.guards import require_cloud_write + from .chroma import ChromaVectorStore, DEFAULT_COLLECTION from .store import VectorStore @@ -73,6 +75,8 @@ def open_vector_store( force_cloud: target Chroma Cloud (ignores HYPERLEX_CHROMA_PATH) """ backend = (backend or "sqlite").strip().lower() + if force_cloud: + require_cloud_write() if backend == "chroma": return ChromaVectorStore( client=client, @@ -245,6 +249,8 @@ def sync_vectors( """ own_from = from_store is None own_to = to_store is None + if to_cloud: + require_cloud_write() from_store = from_store or open_vector_store( backend=from_backend, path=from_path, diff --git a/tests/test_api_and_golden_receipts.py b/tests/test_api_and_golden_receipts.py index 43a0c934..3add05cc 100644 --- a/tests/test_api_and_golden_receipts.py +++ b/tests/test_api_and_golden_receipts.py @@ -91,7 +91,7 @@ def test_abraxas_score_and_ledger_packets() -> None: "created_at": "2026-08-05T00:00:00+00:00", "mapping_version": "v1", } - st = settle(fc, outcome_value=1.0, settlement_decision="TRUE") + st = settle(fc, outcome_value=1.0, settlement_decision="TRUE", authority_ref="pytest", settle_token="pytest") sc = score_pair(fc, st) assert sc["status"] == "SCORED" @@ -124,7 +124,13 @@ def test_operator_review_from_series() -> None: "created_at": "2026-08-05T00:00:00+00:00", "mapping_version": "v1", } - st = settle(fc, outcome_value=o, settlement_decision="TRUE" if o == 1.0 else "FALSE") + st = settle( + fc, + outcome_value=o, + settlement_decision="TRUE" if o == 1.0 else "FALSE", + authority_ref="pytest", + settle_token="pytest", + ) pairs.append((fc, st)) series = score_series(pairs) review = to_operator_brier_review(series) diff --git a/tests/test_calibration.py b/tests/test_calibration.py index b7d7d0bd..7e28b352 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -99,7 +99,10 @@ def _fc(fid: str, p: float, signal: str = "lineage.confidence") -> dict: def _st(fid: str, o: float, decision: str = "TRUE") -> dict: - return settle(_fc(fid, 0.5), outcome_value=o, settlement_decision=decision) + kwargs = {} + if str(decision).upper() in ("TRUE", "FALSE"): + kwargs = {"authority_ref": "pytest", "settle_token": "pytest"} + return settle(_fc(fid, 0.5), outcome_value=o, settlement_decision=decision, **kwargs) def test_brier_atomic_known() -> None: @@ -111,7 +114,7 @@ def test_brier_atomic_known() -> None: def test_score_pair_scored() -> None: fc = _fc("f1", 0.8) - st = settle(fc, outcome_value=1.0, settlement_decision="TRUE") + st = settle(fc, outcome_value=1.0, settlement_decision="TRUE", authority_ref="pytest", settle_token="pytest") rec = score_pair(fc, st) assert rec["status"] == "SCORED" assert rec["atomic_score"] == pytest.approx(0.04) @@ -128,7 +131,7 @@ def test_score_pair_void_not_computable() -> None: def test_score_pair_id_mismatch() -> None: fc = _fc("f3", 0.5) - st = settle(_fc("other", 0.5), outcome_value=1.0, settlement_decision="TRUE") + st = settle(_fc("other", 0.5), outcome_value=1.0, settlement_decision="TRUE", authority_ref="pytest", settle_token="pytest") rec = score_pair(fc, st) assert rec["status"] == NOT_COMPUTABLE assert "mismatch" in rec["reason"] @@ -151,7 +154,7 @@ def test_score_series_two_pairs_known_bs() -> None: pairs = [] for fid, p, o, dec in [("a", 0.8, 1.0, "TRUE"), ("b", 0.3, 0.0, "FALSE")]: fc = _fc(fid, p) - st = settle(fc, outcome_value=o, settlement_decision=dec) + st = settle(fc, outcome_value=o, settlement_decision=dec, authority_ref="pytest", settle_token="pytest") pairs.append((fc, st)) series = score_series(pairs) @@ -167,7 +170,7 @@ def test_score_series_two_pairs_known_bs() -> None: def test_score_series_skips_void() -> None: fc1 = _fc("v1", 0.9) - st1 = settle(fc1, outcome_value=1.0, settlement_decision="TRUE") + st1 = settle(fc1, outcome_value=1.0, settlement_decision="TRUE", authority_ref="pytest", settle_token="pytest") fc2 = _fc("v2", 0.1) st2 = settle(fc2, outcome_value=0.0, settlement_decision="VOID") series = score_series([(fc1, st1), (fc2, st2)]) @@ -236,7 +239,9 @@ def test_settle_and_log_roundtrip(tmp_path: Path) -> None: fc, outcome_value=1.0, settlement_decision="TRUE", + authority_ref="pytest", authority_note="operator confirmed family", + settle_token="pytest", path=log, ) assert out["scorable"] is True @@ -262,7 +267,7 @@ def test_recompute_series_empty_log(tmp_path: Path) -> None: def test_to_brier_ledger_entry_compatible() -> None: fc = _fc("led1", 0.6) - st = settle(fc, outcome_value=0.0, settlement_decision="FALSE") + st = settle(fc, outcome_value=0.0, settlement_decision="FALSE", authority_ref="pytest", settle_token="pytest") sc = score_pair(fc, st) entry = to_brier_ledger_entry(fc, sc, settlement=st) assert entry["schema_version"] == "BrierLedgerEntry.v1" @@ -291,7 +296,13 @@ def test_mean_shift_advisory() -> None: pairs = [] for i, (p, o) in enumerate([(0.9, 0.0), (0.8, 0.0), (0.85, 1.0)]): fc = _fc(f"ms{i}", p) - st = settle(fc, outcome_value=o, settlement_decision="TRUE" if o == 1.0 else "FALSE") + st = settle( + fc, + outcome_value=o, + settlement_decision="TRUE" if o == 1.0 else "FALSE", + authority_ref="pytest", + settle_token="pytest", + ) pairs.append((fc, st)) series = score_series(pairs) advisory = mean_shift_from_series(series) @@ -364,7 +375,9 @@ def test_cli_forecasts_settle_score_series(tmp_path: Path) -> None: "settle", "--forecast-id", fid, "--decision", "TRUE", + "--authority-ref", "pytest", "--authority-note", "fixture confirm", + "--settle-token", "pytest", "--export-ledger", "--log", str(log), ) diff --git a/tests/test_connectors.py b/tests/test_connectors.py index 65d733e7..bd976dbd 100644 --- a/tests/test_connectors.py +++ b/tests/test_connectors.py @@ -56,7 +56,13 @@ def test_hyperstition_feedback_from_settled_pairs() -> None: "created_at": "2026-08-05T00:00:00+00:00", "mapping_version": "v1", } - st = settle(fc, outcome_value=o, settlement_decision="TRUE" if o == 1 else "FALSE") + st = settle( + fc, + outcome_value=o, + settlement_decision="TRUE" if o == 1 else "FALSE", + authority_ref="pytest", + settle_token="pytest", + ) pairs.append((fc, st)) series = score_series(pairs) fb = hyperstition_feedback_from_series(series) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 983c55b1..0858fe83 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -38,3 +38,6 @@ def test_doctor_ok() -> None: claude = next(c for c in body["checks"] if c["name"] == "claude_host") assert claude["ok"] is True assert claude["message"].startswith("CLAUDE_") + assert "CLAUDE_SOT_CLEARED" in body + sot = next(c for c in body["checks"] if c["name"] == "claude_sot_cleared") + assert sot["message"].startswith("CLAUDE_SOT_CLEARED=") diff --git a/tests/test_init_skill.py b/tests/test_init_skill.py index 78a3b3fc..2dd82710 100644 --- a/tests/test_init_skill.py +++ b/tests/test_init_skill.py @@ -1,6 +1,6 @@ from pathlib import Path -from hyperlex.init_skill import TARGETS, run_init, run_uninstall, skill_template +from hyperlex.init_skill import TARGETS, run_init, run_uninstall, skill_template, skill_targets def test_template_has_marker_and_cli(): @@ -19,5 +19,7 @@ def test_init_dry_run_lists_default_hosts(): def test_targets_are_under_home(): home = str(Path.home()) + for dest in skill_targets().values(): + assert str(dest).startswith(home) for dest in TARGETS.values(): assert str(dest).startswith(home) diff --git a/tests/test_p1_fail_closed.py b/tests/test_p1_fail_closed.py new file mode 100644 index 00000000..35752098 --- /dev/null +++ b/tests/test_p1_fail_closed.py @@ -0,0 +1,508 @@ +"""ADVERSARY P1 fail-closed gates: install/init, settle, X-base, cloud-write, SoT, receipt.""" + +from __future__ import annotations + +import importlib.util +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from hyperlex.guards import ( + SETTLE_CONFIRM_PHRASE, + CloudWriteError, + SettleGateError, + UrlGateError, + require_cloud_write, + require_http_url, + require_scored_settle_gate, + validate_x_api_base, +) +from hyperlex.calibration.settlement import settle +from hyperlex.calibration.score_log import settle_and_log +from hyperlex.intake.x_search import fetch_x_api +from hyperlex.receipt import emit_receipt, verify_receipt +from hyperlex.init_skill import run_init, skill_targets +from hyperlex.claude_sot import resolve_claude_sot_cleared, claude_packaging_claimed + +ROOT = Path(__file__).resolve().parents[1] +INSTALL = ROOT / "install.sh" +SCRIPT = ROOT / "scripts" / "hyperlex.py" + + +def _fc(fid: str = "p1") -> dict: + return { + "forecast_id": fid, + "receipt_ref": {"integrity": "abc"}, + "signal_key": "lineage.confidence", + "probability": 0.5, + "target_event": "t", + "target_schema": "lineage.family_confirmed", + "created_at": "2026-08-05T00:00:00+00:00", + "mapping_version": "v1", + } + + +def _load_tx(): + spec = importlib.util.spec_from_file_location( + "install_transaction_p1", ROOT / "scripts/install_transaction.py" + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# --------------------------------------------------------------------------- +# P1-1 init + install helpers +# --------------------------------------------------------------------------- + +def test_init_dry_run_writes_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HYPERLEX_HOME", str(home / ".hyperlex")) + out = run_init(["claude"], dry_run=True) + assert out["ok"] is True + assert out["dry_run"] is True + assert not (home / ".claude").exists() + dest = skill_targets()["claude"] / "SKILL.md" + assert not dest.exists() + + +def test_init_refuses_symlink_dest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + dest = home / ".claude" / "skills" / "hyperlex" + dest.parent.mkdir(parents=True) + dest.symlink_to(tmp_path / "elsewhere") + with pytest.raises(ValueError, match="symlink"): + run_init(["claude"], dry_run=False) + + +def test_init_backup_and_skip_smoke_unverified(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HYPERLEX_HOME", str(home / ".hyperlex")) + dest = home / ".claude" / "skills" / "hyperlex" + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("old skill", encoding="utf-8") + out = run_init(["claude"], dry_run=False, skip_smoke=True) + assert out["written"][0]["status"] == "UNVERIFIED" + assert (dest / "SKILL.md").read_text(encoding="utf-8") != "old skill" + assert "backup" in out["written"][0] + backup = Path(out["written"][0]["backup"]) + assert (backup / "SKILL.md").read_text(encoding="utf-8") == "old skill" + + +def test_init_smoke_validated(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HYPERLEX_HOME", str(home / ".hyperlex")) + out = run_init(["claude"], dry_run=False, skip_smoke=False) + assert out["written"][0]["status"] == "VALIDATED" + path = Path(out["written"][0]["path"]) + assert path.is_file() + assert "" in path.read_text(encoding="utf-8") + + +def test_install_sh_has_no_raw_helper_copy() -> None: + text = INSTALL.read_text(encoding="utf-8") + assert "copy_claude_helpers" not in text + assert "cp -f" not in text + assert "install_claude_helpers" in text + assert "hyperlex-helper" in text + assert "scripts/install_transaction.py" in text + + +def test_helper_kind_refuses_symlink_and_backs_up(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + tx = _load_tx() + home = tmp_path / "home" + profile = tmp_path / "profile" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HERMES_HOME", str(profile)) + src = tmp_path / "helper" + src.mkdir() + (src / "SKILL.md").write_text("# helper\n", encoding="utf-8") + dest = tmp_path / "dest" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.symlink_to(tmp_path / "other") + with pytest.raises(ValueError, match="symlink"): + tx.install(src, dest, "hyperlex-helper", skip_checks=True) + + +def test_helper_kind_install_and_unverified(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + tx = _load_tx() + home = tmp_path / "home" + profile = tmp_path / "profile" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HERMES_HOME", str(profile)) + src = tmp_path / "helper" + src.mkdir() + (src / "SKILL.md").write_text("# helper\n", encoding="utf-8") + dest = tmp_path / "skills" / "hyperlex-demo" + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("old", encoding="utf-8") + tx.install(src, dest, "hyperlex-helper", skip_checks=True) + assert (dest / "SKILL.md").read_text(encoding="utf-8") == "# helper\n" + receipt = json.loads((dest / ".install-provenance.json").read_text(encoding="utf-8")) + assert receipt["status"] == "UNVERIFIED" + + +def test_install_sh_claude_dry_run_no_writes(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + env = os.environ.copy() + env.update({"HOME": str(home), "HERMES_HOME": str(home / "profile"), "HYPERLEX_OFFLINE": "1"}) + result = subprocess.run( + ["bash", str(INSTALL), "--claude", "--dry-run"], + capture_output=True, + text=True, + cwd=ROOT, + env=env, + ) + assert result.returncode == 0, result.stderr + result.stdout + assert "hyperlex-demo" in result.stdout + assert "transactional-install Claude helper" in result.stdout + assert not (home / ".claude").exists() + + +def test_hermes_install_dry_run_still_ok(tmp_path: Path) -> None: + home = tmp_path / "home" + home.mkdir() + env = os.environ.copy() + env.update({"HOME": str(home), "HERMES_HOME": str(home / "profile")}) + result = subprocess.run( + ["bash", str(INSTALL), "--dry-run"], + capture_output=True, + text=True, + cwd=ROOT, + env=env, + ) + assert result.returncode == 0, result.stderr + result.stdout + assert "would staged-validate then activate Hyperlex" in result.stdout + assert not (home / "profile" / "skills").exists() + + +# --------------------------------------------------------------------------- +# P1-2 settle +# --------------------------------------------------------------------------- + +def test_settle_refuses_missing_token_non_tty() -> None: + with pytest.raises(SettleGateError): + settle(_fc(), outcome_value=1.0, settlement_decision="TRUE", authority_ref="op") + + +def test_settle_refuses_empty_authority_ref() -> None: + with pytest.raises(SettleGateError, match="authority.ref"): + settle(_fc(), outcome_value=1.0, settlement_decision="TRUE", settle_token="tok") + + +def test_settle_refuses_advisory_kind() -> None: + with pytest.raises(SettleGateError, match="advisory"): + settle( + _fc(), + outcome_value=1.0, + settlement_decision="TRUE", + authority_kind="advisory", + authority_ref="op", + settle_token="tok", + ) + + +def test_settle_refuses_piped_yes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERLEX_SETTLE_TOKEN", raising=False) + fake_in = io.StringIO("yes\n") + fake_in.isatty = lambda: False # type: ignore[method-assign] + with pytest.raises(SettleGateError, match="non-TTY"): + require_scored_settle_gate( + settlement_decision="TRUE", + authority_kind="operator", + authority_ref="op", + settle_token=None, + stdin=fake_in, + stdout=io.StringIO(), + ) + + +def test_settle_tty_confirm_accepts_phrase() -> None: + fake_in = io.StringIO(SETTLE_CONFIRM_PHRASE + "\n") + fake_in.isatty = lambda: True # type: ignore[method-assign] + require_scored_settle_gate( + settlement_decision="TRUE", + authority_kind="operator", + authority_ref="op", + settle_token=None, + stdin=fake_in, + stdout=io.StringIO(), + ) + + +def test_settle_and_log_refuses_without_append(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERLEX_SETTLE_TOKEN", raising=False) + log = tmp_path / "score_log.jsonl" + log.write_text("", encoding="utf-8") + with pytest.raises(SettleGateError): + settle_and_log( + _fc("nolog"), + outcome_value=1.0, + settlement_decision="TRUE", + authority_ref="op", + path=log, + ) + assert log.read_text(encoding="utf-8").strip() == "" + + +def test_settle_does_not_store_token() -> None: + st = settle( + _fc("tok"), + outcome_value=1.0, + settlement_decision="TRUE", + authority_ref="op", + settle_token="super-secret-token", + ) + blob = json.dumps(st) + assert "super-secret-token" not in blob + + +def test_void_settle_skips_gate() -> None: + st = settle(_fc("void"), outcome_value=0.0, settlement_decision="VOID") + assert st["settlement_decision"] == "VOID" + + +# --------------------------------------------------------------------------- +# P1-3 X API base +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "url", + [ + "http://api.twitter.com/2", + "https://user:pass@api.twitter.com/2", + "https://api.twitter.com:8443/2", + "https://evil.example/2", + ], +) +def test_x_api_base_refused(url: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERLEX_X_API_BASE_ALLOW_CUSTOM", raising=False) + with pytest.raises(UrlGateError): + validate_x_api_base(url) + + +def test_x_api_base_allowlisted() -> None: + assert validate_x_api_base("https://api.twitter.com/2") == "https://api.twitter.com/2" + assert validate_x_api_base("https://api.x.com/2") == "https://api.x.com/2" + + +def test_fetch_x_api_refuses_before_request(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HYPERLEX_X_API_BASE", "https://evil.example/2") + monkeypatch.setenv("HYPERLEX_X_BEARER_TOKEN", "secret-bearer") + monkeypatch.delenv("HYPERLEX_X_API_BASE_ALLOW_CUSTOM", raising=False) + monkeypatch.delenv("HYPERLEX_OFFLINE", raising=False) + + def boom(*_a, **_k): + raise AssertionError("request must not fire on refuse") + + monkeypatch.setattr("hyperlex.intake.x_search.requests", type("R", (), {"get": staticmethod(boom)})) + signal, meta = fetch_x_api("rizz") + assert "X_API_BASE_REFUSED" in signal + assert meta["reason"] == "x_api_base_refused" + assert "secret-bearer" not in signal + assert "secret-bearer" not in json.dumps(meta) + + +def test_x_api_custom_override_warns(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HYPERLEX_X_API_BASE", "https://proxy.example/2") + monkeypatch.setenv("HYPERLEX_X_API_BASE_ALLOW_CUSTOM", "1") + monkeypatch.setenv("HYPERLEX_OFFLINE", "1") + signal, meta = fetch_x_api("rizz") + assert meta.get("custom_base") is True + assert "X_OFFLINE" in signal + + +# --------------------------------------------------------------------------- +# P1-4 cloud write +# --------------------------------------------------------------------------- + +def test_cloud_write_refused_without_flag(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERLEX_CLOUD_WRITE", raising=False) + with pytest.raises(CloudWriteError): + require_cloud_write() + + +def test_cloud_write_env_permits(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HYPERLEX_CLOUD_WRITE", "1") + require_cloud_write() + + +def test_cloud_write_env_keys_alone_not_enough(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERLEX_CLOUD_WRITE", raising=False) + monkeypatch.setenv("CHROMA_API_KEY", "ck") + monkeypatch.setenv("HYPERLEX_CHROMA_API_KEY", "ck") + with pytest.raises(CloudWriteError): + require_cloud_write() + + +def test_force_cloud_transfer_gated(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("HYPERLEX_CLOUD_WRITE", raising=False) + from hyperlex.vectordb.transfer import open_vector_store + + with pytest.raises(CloudWriteError): + open_vector_store(backend="chroma", force_cloud=True) + + +def test_autoindex_cloud_store_gated(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERLEX_CLOUD_WRITE", raising=False) + from hyperlex.vectordb.autoindex import index_texts + + class Cloudish: + force_cloud = True + _cloud = True + + def close(self) -> None: + return None + + out = index_texts( + [{"id": "t1", "kind": "term", "text": "rizz"}], + store=Cloudish(), + ) + assert out["ok"] is False + assert "cloud vector write refused" in str(out.get("error") or "") + + +# --------------------------------------------------------------------------- +# P1-5 doctor SoT +# --------------------------------------------------------------------------- + +def test_claude_sot_pin_matches_this_tree() -> None: + cleared, reason = resolve_claude_sot_cleared(ROOT) + assert cleared is True, reason + assert "pinned" in reason + + +def test_claude_sot_missing_pin(tmp_path: Path) -> None: + cleared, reason = resolve_claude_sot_cleared(tmp_path) + assert cleared is False + assert "no local pin" in reason + + +def test_doctor_emits_claude_sot_cleared() -> None: + env = os.environ.copy() + env["HYPERLEX_OFFLINE"] = "1" + env["HYPERLEX_NO_RATE_LIMIT"] = "1" + env["PYTHONPATH"] = str(ROOT / "src") + r = subprocess.run( + [sys.executable, str(SCRIPT), "doctor"], + capture_output=True, + text=True, + cwd=ROOT, + env=env, + ) + assert r.returncode == 0, r.stderr + r.stdout + body = json.loads(r.stdout) + assert "CLAUDE_SOT_CLEARED" in body + assert body["CLAUDE_SOT_CLEARED"] is True + names = {c["name"] for c in body["checks"]} + assert "claude_sot_cleared" in names + sot = next(c for c in body["checks"] if c["name"] == "claude_sot_cleared") + assert sot["message"].startswith("CLAUDE_SOT_CLEARED=") + + +def test_claude_sot_missing_pin_object_is_uncleared(tmp_path: Path) -> None: + """Shallow / unrelated clones cannot prove descent without a live fetch.""" + import shutil + + repo = tmp_path / "repo" + repo.mkdir() + pin_src = ROOT / "references" / "claude-sot-cleared.json" + dest_dir = repo / "references" + dest_dir.mkdir() + shutil.copy(pin_src, dest_dir / "claude-sot-cleared.json") + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "p1@test"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "p1"], cwd=repo, check=True) + subprocess.run(["git", "add", "references/claude-sot-cleared.json"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-m", "pin only"], cwd=repo, check=True, capture_output=True) + cleared, reason = resolve_claude_sot_cleared(repo) + assert cleared is False + assert "not in local git objects" in reason + + +def test_doctor_fails_when_claimed_and_uncleared(tmp_path: Path) -> None: + home = tmp_path / "home" + skill = home / ".claude" / "skills" / "hyperlex" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# x\n", encoding="utf-8") + (skill / "scripts").mkdir() + (skill / "scripts" / "hyperlex.py").write_text("# stub\n", encoding="utf-8") + assert claude_packaging_claimed(home=home) is True + empty = tmp_path / "empty-root" + empty.mkdir() + cleared, reason = resolve_claude_sot_cleared(empty) + assert cleared is False + assert "no local pin" in reason + from hyperlex.claude_sot import doctor_sot_should_fail + + assert doctor_sot_should_fail(cleared=False, claimed=True) is True + assert doctor_sot_should_fail(cleared=True, claimed=True) is False + assert doctor_sot_should_fail(cleared=False, claimed=False) is False + + +# --------------------------------------------------------------------------- +# P1-6 receipt sha256 +# --------------------------------------------------------------------------- + +def test_emit_receipt_full_sha256_and_validate_default(tmp_path: Path) -> None: + from hyperlex import detect_memetic_patterns + + result = detect_memetic_patterns(query="rizz", ingest_source="mock", validate=False) + path = emit_receipt(result, out_dir=tmp_path, append_ledger=False) + payload = json.loads(path.read_text(encoding="utf-8")) + integ = payload["receipt"]["integrity"] + assert len(integ) == 64 + assert all(c in "0123456789abcdef" for c in integ) + assert "schema_validation" in payload["receipt"] + ok, msg = verify_receipt(payload) + assert ok, msg + + +def test_legacy_short_digest_refused_without_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("HYPERLEX_RECEIPT_LEGACY_INTEGRITY", raising=False) + from hyperlex import detect_memetic_patterns + + result = detect_memetic_patterns(query="rizz", ingest_source="mock", validate=False) + path = emit_receipt(result, out_dir=tmp_path, append_ledger=False, validate=False) + payload = json.loads(path.read_text(encoding="utf-8")) + payload["receipt"]["integrity"] = payload["receipt"]["integrity"][:12] + ok, msg = verify_receipt(payload) + assert ok is False + assert "mismatch" in msg + + +def test_legacy_short_digest_ok_with_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HYPERLEX_RECEIPT_LEGACY_INTEGRITY", "1") + from hyperlex import detect_memetic_patterns + + result = detect_memetic_patterns(query="rizz", ingest_source="mock", validate=False) + path = emit_receipt(result, out_dir=tmp_path, append_ledger=False, validate=False) + payload = json.loads(path.read_text(encoding="utf-8")) + payload["receipt"]["integrity"] = payload["receipt"]["integrity"][:12] + ok, msg = verify_receipt(payload) + assert ok is True + assert "legacy" in msg + + +def test_http_scheme_allowlist() -> None: + require_http_url("https://api.openai.com/v1", name="HYPERLEX_LLM_BASE_URL") + require_http_url("http://localhost:8080/v1", name="HYPERLEX_LLM_BASE_URL") + with pytest.raises(UrlGateError): + require_http_url("file:///etc/passwd", name="HYPERLEX_LLM_BASE_URL")