diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 938fbc7..e4f3d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: retention-days: 14 - name: mock demo smoke if: matrix.python-version == '3.12' - run: python demo.py + run: python demo.py --demo-key test-optimized: name: pytest (python -O) diff --git a/.github/workflows/forge.yml b/.github/workflows/forge.yml index bcbe310..06df2b8 100644 --- a/.github/workflows/forge.yml +++ b/.github/workflows/forge.yml @@ -118,8 +118,8 @@ jobs: set -euo pipefail root="$PWD" mkdir -p "$root/forge-demos" - (cd /tmp && /tmp/forge-venv/bin/permit demo) 2>&1 | tee "$root/forge-demos/demo.out" - (cd /tmp && /tmp/forge-venv/bin/permit demo-recovery) 2>&1 | tee "$root/forge-demos/demo-recovery.out" + (cd /tmp && /tmp/forge-venv/bin/permit demo --demo-key) 2>&1 | tee "$root/forge-demos/demo.out" + (cd /tmp && /tmp/forge-venv/bin/permit demo-recovery --demo-key) 2>&1 | tee "$root/forge-demos/demo-recovery.out" c13=$(grep -c "LEDGER VERIFIED: 13" "$root/forge-demos/demo.out" || true) c6=$(grep -c "LEDGER VERIFIED: 6" "$root/forge-demos/demo-recovery.out" || true) echo "demo LEDGER VERIFIED: 13 count=${c13}" diff --git a/README.md b/README.md index 123f675..27c392c 100644 --- a/README.md +++ b/README.md @@ -36,23 +36,23 @@ where `remaining(P) = cap(P) − reserved(P) − captured(P)`. Four clauses, no ```bash python -m pip install pytest python -m pytest -q # full suite: mock rail, no credentials, no network -python demo.py # end-to-end mock demo, ends with the ledger chain check +python demo.py --demo-key # end-to-end mock demo, ends with the ledger chain check ``` More demos (in `demos/`; `trace.py` is the canonical evidence path — it asserts the real gate and doubles as the run's evidence log): ```bash -python demos/demo_sla_escrow.py # offline: agent-to-agent SLA escrow, acceptance-signed capture, forged signature refused -python server.py # the service at http://127.0.0.1:8741 -python trace.py # with server.py running: allow, block, delegate, cascade revoke, chain check -python demos/demo_six_beat.py # LLM agent: grant → capture → over-cap block → delegate + cascade → tampered evidence → timeout/reconcile -python demos/demo_delegation.py --fast # LLM agents: buyer and researcher, five beats, live dashboard -python demos/demo_approval.py --fast # LLM agent: auto-allow, approve, deny, fail-closed -python demos/demo_admission.py # offline: runaway loop refused at budget exhaustion, concurrent overspend cannot exceed cap +python demos/demo_sla_escrow.py --demo-key # offline: agent-to-agent SLA escrow, acceptance-signed capture, forged signature refused +python server.py --demo-key # the service at http://127.0.0.1:8741 +python trace.py --demo-key # starts its own server: allow, block, delegate, cascade revoke, chain check +python demos/demo_six_beat.py --demo-key # LLM agent: grant → capture → over-cap block → delegate + cascade → tampered evidence → timeout/reconcile +python demos/demo_delegation.py --fast --demo-key # LLM agents: buyer and researcher, five beats, live dashboard +python demos/demo_approval.py --fast --demo-key # LLM agent: auto-allow, approve, deny, fail-closed +python demos/demo_admission.py --demo-key # offline: runaway loop refused at budget exhaustion, concurrent overspend cannot exceed cap ``` -The three LLM demos call the runner set by `PERMIT_AGENT_CLI` (the older name `PERMIT_GROK_CLI` is still read as a deprecated alias when `PERMIT_AGENT_CLI` is unset). There is no default: if it is unset, the demo exits 1 with a `RuntimeError` that names the fix. `demos/demo_six_beat.py` also takes `--replay `, which replays the recorded reasoning and re-executes every recorded action against the live tools, fully offline. A live run records `demo_transcript.jsonl` (gitignored — your own recordings stay local). A fresh clone ships one recorded transcript as a fixture, so the judge path runs with no recording of your own: `python3 demos/demo_six_beat.py --fast --replay demos/fixtures/demo_transcript.jsonl` (provenance: `demos/fixtures/README.md`). +The three LLM demos call the runner set by `PERMIT_AGENT_CLI` (the older name `PERMIT_GROK_CLI` is still read as a deprecated alias when `PERMIT_AGENT_CLI` is unset). There is no default: if it is unset, the demo exits 1 with a `RuntimeError` that names the fix. `demos/demo_six_beat.py` also takes `--replay `, which replays the recorded reasoning and re-executes every recorded action against the live tools, fully offline. A live run records `demo_transcript.jsonl` (gitignored; your own recordings stay local). A fresh clone ships one recorded transcript as a fixture, so the judge path runs with no recording of your own: `python3 demos/demo_six_beat.py --fast --demo-key --replay demos/fixtures/demo_transcript.jsonl` (provenance: `demos/fixtures/README.md`). ## How it fails @@ -73,7 +73,7 @@ Permit fails closed. When state is uncertain, money does not move. **What it does not defend (production boundaries):** - With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. - HTTP POSTs require a single shared bearer token (`PERMIT_API_TOKEN`, #23) with no role split between principal and agent (F5). GETs, including `/api/ledger`, are unauthenticated; the server binds to 127.0.0.1. -- Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of at least 32 bytes (`PERMIT_ACCEPTANCE_KEY`); demos opt in explicitly to a per-run generated key (F12, #35). +- Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of at least 32 bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected, and a configured-but-invalid key (empty, under 32 bytes, whitespace) is fatal even in demo mode: the demo fallback applies only when no key is configured at all (#53). Demo entry points (`server.py`, `demo.py`, `demos/*`, `python -m permit`, `trace.py`) opt in only through an explicit `--demo-key` flag, off by default, generating a per-run key; with `server.py --db`, that key instead persists in a 0600 `.demo-key` sidecar file so it does not change across restarts. API change (#35): `sign_acceptance` takes the key as a required argument; the old module-level fallback key is gone. - The ledger is a hash chain, not a signature. It detects edits and reordering, but it cannot detect removal of final entries without an external checkpoint. - It is a single-merchant sandbox prototype, not a claim about all agent payments. - E-stop voids uncaptured authorizations. A completed capture needs a refund path, which is out of scope. diff --git a/demo.py b/demo.py index c1937e1..49fbf1a 100644 --- a/demo.py +++ b/demo.py @@ -29,7 +29,7 @@ from permit.permit import PermitStore from permit.flow import SpendPipeline from settlement.paypal_client import MockPayPalClient -from settlement.verifier import Evidence, PredicateType, ReleaseVerifier +from settlement.verifier import Evidence, PredicateType, entrypoint_verifier def show(receipts, prefix=""): @@ -47,17 +47,17 @@ def show(receipts, prefix=""): print(f"{prefix}#{r.seq:02d} {r.event_type:10s} {detail}") -def build_pipeline(): +def build_pipeline(demo_key=False): ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = entrypoint_verifier(paypal, permits, ledger=ledger, demo_key=demo_key) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) return flow, paypal, permits, ledger -def demo_mock(): - flow, paypal, permits, ledger = build_pipeline() +def demo_mock(demo_key=False): + flow, paypal, permits, ledger = build_pipeline(demo_key=demo_key) merchant = "demo_merchant" print("== 1. grant ==") @@ -120,13 +120,16 @@ def demo_mock(): def main(): ap = argparse.ArgumentParser(description="Permit end-to-end demo") ap.add_argument("--sandbox", action="store_true", help="use the real sandbox rail") + ap.add_argument("--demo-key", action="store_true", + help="opt in to a per-run generated demo acceptance key " + "when PERMIT_ACCEPTANCE_KEY is unset (off by default, #53)") args = ap.parse_args() if args.sandbox: print("Sandbox mode needs PERMIT_PAYPAL_CLIENT_ID/SECRET and interactive") print("payer approval per order - run from a session with a browser tap.") print("Use mock mode for the scripted camera run.") return - demo_mock() + demo_mock(demo_key=args.demo_key) if __name__ == "__main__": diff --git a/demos/demo_admission.py b/demos/demo_admission.py index f95e0d8..0249485 100644 --- a/demos/demo_admission.py +++ b/demos/demo_admission.py @@ -24,6 +24,7 @@ from pathlib import Path as _Path _sys.path.insert(0, str(_Path(__file__).resolve().parents[1])) +import argparse import threading from datetime import datetime, timedelta, timezone @@ -31,17 +32,17 @@ from permit.ledger import Ledger from permit.permit import PermitStore from settlement.paypal_client import MockPayPalClient -from settlement.verifier import PredicateType, ReleaseVerifier +from settlement.verifier import PredicateType, entrypoint_verifier MERCHANT = "coffee-shop" ARTIFACT_HASH = "ab" * 32 -def build(cap_cents): +def build(cap_cents, demo_key=False): ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = entrypoint_verifier(paypal, permits, ledger=ledger, demo_key=demo_key) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) permit, _ = permits.grant( agent_id="demo-agent", @@ -59,9 +60,9 @@ def attempt(flow, permit_id, amount_cents): ) -def beat_runaway_loop(): +def beat_runaway_loop(demo_key=False): print("=== beat A: runaway loop refused at budget exhaustion ===") - flow, paypal, permits, permit = build(3000) + flow, paypal, permits, permit = build(3000, demo_key=demo_key) refused = 0 for i in range(1, 7): a = attempt(flow, permit.permit_id, 1000) @@ -76,9 +77,9 @@ def beat_runaway_loop(): return permits -def beat_concurrent_grab(): +def beat_concurrent_grab(demo_key=False): print("=== beat B: parallel overspend attempts cannot exceed the budget ===") - flow, paypal, permits, permit = build(5000) + flow, paypal, permits, permit = build(5000, demo_key=demo_key) outcomes = [] lock = threading.Lock() @@ -105,10 +106,15 @@ def worker(): def main(): + ap = argparse.ArgumentParser(description="Permit admission-gating demo") + ap.add_argument("--demo-key", action="store_true", + help="opt in to a per-run generated demo acceptance key " + "when PERMIT_ACCEPTANCE_KEY is unset (off by default, #53)") + args = ap.parse_args() print("Permit admission-gating demo (mock rail)") - permits = beat_runaway_loop() + permits = beat_runaway_loop(demo_key=args.demo_key) print() - permits_b = beat_concurrent_grab() + permits_b = beat_concurrent_grab(demo_key=args.demo_key) print() for label, p in (("beat A", permits), ("beat B", permits_b)): ok, reason = p.ledger.verify_chain() diff --git a/demos/demo_approval.py b/demos/demo_approval.py index e23aaa3..dfb0fbc 100644 --- a/demos/demo_approval.py +++ b/demos/demo_approval.py @@ -40,7 +40,7 @@ from permit.ledger import Ledger from permit.permit import PermitStore from settlement.paypal_client import MockPayPalClient -from settlement.verifier import ReleaseVerifier +from settlement.verifier import entrypoint_verifier MERCHANT = "dataset_mart" CATALOG = { @@ -99,12 +99,14 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--fast", action="store_true") ap.add_argument("--port", type=int, default=8474) + ap.add_argument("--demo-key", action="store_true", + help="opt in to a per-run generated demo acceptance key when PERMIT_ACCEPTANCE_KEY is unset (off by default, #53)") args = ap.parse_args() ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = entrypoint_verifier(paypal, permits, ledger=ledger, demo_key=args.demo_key) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) dash = Dashboard(permits, ledger, flow, verifier, port=args.port).start() print(f"dashboard: http://127.0.0.1:{args.port}") diff --git a/demos/demo_delegation.py b/demos/demo_delegation.py index 1c51646..a6297da 100644 --- a/demos/demo_delegation.py +++ b/demos/demo_delegation.py @@ -38,7 +38,7 @@ from permit.ledger import Ledger from permit.permit import PermitStore from settlement.paypal_client import MockPayPalClient -from settlement.verifier import ReleaseVerifier +from settlement.verifier import entrypoint_verifier MERCHANT = "dataset_mart" CATALOG = { @@ -83,12 +83,14 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--fast", action="store_true") ap.add_argument("--port", type=int, default=8471) + ap.add_argument("--demo-key", action="store_true", + help="opt in to a per-run generated demo acceptance key when PERMIT_ACCEPTANCE_KEY is unset (off by default, #53)") args = ap.parse_args() ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = entrypoint_verifier(paypal, permits, ledger=ledger, demo_key=args.demo_key) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) dash = Dashboard(permits, ledger, flow, verifier, port=args.port).start() print(f"dashboard: http://127.0.0.1:{args.port}") diff --git a/demos/demo_six_beat.py b/demos/demo_six_beat.py index 3536c9e..d81b9b9 100644 --- a/demos/demo_six_beat.py +++ b/demos/demo_six_beat.py @@ -49,7 +49,7 @@ from permit.ledger import Ledger from permit.permit import PermitStore from settlement.paypal_client import MockPayPalClient -from settlement.verifier import PredicateType, ReleaseVerifier +from settlement.verifier import PredicateType, entrypoint_verifier MERCHANT = "dataset_mart" CATALOG = { @@ -120,6 +120,8 @@ def main(): ap.add_argument("--replay", default=None) ap.add_argument("--fast", action="store_true") ap.add_argument("--port", type=int, default=8471) + ap.add_argument("--demo-key", action="store_true", + help="opt in to a per-run generated demo acceptance key when PERMIT_ACCEPTANCE_KEY is unset (off by default, #53)") args = ap.parse_args() if args.replay: import os as _os @@ -133,7 +135,7 @@ def main(): ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = entrypoint_verifier(paypal, permits, ledger=ledger, demo_key=args.demo_key) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) dash = Dashboard(permits, ledger, flow, verifier, port=args.port).start() print(f"dashboard: http://127.0.0.1:{args.port}") diff --git a/demos/demo_sla_escrow.py b/demos/demo_sla_escrow.py index 6ed7658..c9d8e13 100644 --- a/demos/demo_sla_escrow.py +++ b/demos/demo_sla_escrow.py @@ -52,12 +52,15 @@ def beat(n: int, title: str, fast: bool) -> None: def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--fast", action="store_true") + ap.add_argument("--demo-key", action="store_true", + help="opt in to a per-run generated demo acceptance key " + "when PERMIT_ACCEPTANCE_KEY is unset (off by default, #53)") args = ap.parse_args() paypal = MockPayPalClient() ledger = Ledger() print("Running SLA escrow on the mock rail (no network, no credentials).") - t = run_sla_escrow(paypal, ledger=ledger) + t = run_sla_escrow(paypal, ledger=ledger, demo_key=args.demo_key) for i, b in enumerate(t["beats"], 1): beat(i, TITLES[b["beat"]], args.fast) diff --git a/permit/cli.py b/permit/cli.py index bee209d..e358626 100644 --- a/permit/cli.py +++ b/permit/cli.py @@ -8,7 +8,7 @@ from typing import Sequence from settlement.paypal_client import MockPayPalClient -from settlement.verifier import Evidence, PredicateType, ReleaseVerifier +from settlement.verifier import Evidence, PredicateType, ReleaseVerifier, entrypoint_verifier from .flow import SpendPipeline from .ledger import Ledger @@ -24,13 +24,13 @@ def _require(condition: bool, message: str) -> None: raise DemoInvariantError(message) -def _pipeline() -> tuple[ +def _pipeline(demo_key: bool = False) -> tuple[ SpendPipeline, MockPayPalClient, PermitStore, ReleaseVerifier, Ledger ]: ledger = Ledger() permits = PermitStore(ledger=ledger) provider = MockPayPalClient() - verifier = ReleaseVerifier(provider, permits, ledger=ledger, allow_demo_key=True) + verifier = entrypoint_verifier(provider, permits, ledger=ledger, demo_key=demo_key) flow = SpendPipeline(permits, provider, verifier, ledger=ledger) return flow, provider, permits, verifier, ledger @@ -44,9 +44,9 @@ def _grant(permits: PermitStore, cap_cents: int, merchant: str): )[0] -def run_demo() -> None: +def run_demo(demo_key: bool = False) -> None: """Exercise capture, cap blocking, and e-stop using only in-memory mocks.""" - flow, provider, permits, _verifier, ledger = _pipeline() + flow, provider, permits, _verifier, ledger = _pipeline(demo_key=demo_key) merchant = "offline_demo_merchant" artifact = b"offline demo deliverable" digest = hashlib.sha256(artifact).hexdigest() @@ -91,9 +91,9 @@ def run_demo() -> None: print("OFFLINE MOCK ONLY: no network, credentials, or real money were used") -def run_recovery_demo() -> None: +def run_recovery_demo(demo_key: bool = False) -> None: """Exercise dropped-response reconciliation using only the mock provider.""" - flow, provider, permits, verifier, ledger = _pipeline() + flow, provider, permits, verifier, ledger = _pipeline(demo_key=demo_key) merchant = "offline_demo_merchant" artifact = b"offline recovery deliverable" digest = hashlib.sha256(artifact).hexdigest() @@ -141,14 +141,16 @@ def _parser() -> argparse.ArgumentParser: description="Run credential-free, headless Permit mock demonstrations.", ) commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser( - "demo", - help="show mock capture, cap blocking, e-stop, and ledger verification", - ) - commands.add_parser( - "demo-recovery", - help="show mock UNKNOWN-to-CAPTURED reconciliation without duplicate capture", - ) + for name, help_text in ( + ("demo", "show mock capture, cap blocking, e-stop, and ledger verification"), + ("demo-recovery", "show mock UNKNOWN-to-CAPTURED reconciliation without duplicate capture"), + ): + sub = commands.add_parser(name, help=help_text) + sub.add_argument( + "--demo-key", action="store_true", + help="opt in to a per-run generated demo acceptance key when " + "PERMIT_ACCEPTANCE_KEY is unset (off by default, #53)", + ) return parser @@ -156,9 +158,9 @@ def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) try: if args.command == "demo": - run_demo() + run_demo(demo_key=args.demo_key) elif args.command == "demo-recovery": - run_recovery_demo() + run_recovery_demo(demo_key=args.demo_key) except DemoInvariantError as exc: print(f"DEMO FAILED: {exc}") return 1 diff --git a/permit/ledger.py b/permit/ledger.py index ffc67a4..4febca1 100644 --- a/permit/ledger.py +++ b/permit/ledger.py @@ -10,7 +10,9 @@ settlement verifier refuses to proceed on a broken chain. No external format dependency (Interlock cut 2026-10-02, agent review). -Demo keys are hardcoded and labeled as such — no key-management scope. +No keys live in this module: the settlement verifier fails closed +without a configured acceptance key of at least 32 bytes (#35, #53); +demo entry points opt in per run via --demo-key. """ from __future__ import annotations diff --git a/scenarios/sla_escrow.py b/scenarios/sla_escrow.py index b727723..97f8ae9 100644 --- a/scenarios/sla_escrow.py +++ b/scenarios/sla_escrow.py @@ -39,7 +39,7 @@ from settlement.verifier import ( Evidence, PredicateType, - ReleaseVerifier, + entrypoint_verifier, ) MERCHANT = "report_foundry" @@ -50,11 +50,11 @@ def money(cents: int) -> str: return f"${cents / 100:.2f}" -def run_sla_escrow(paypal, ledger: Ledger | None = None) -> dict: +def run_sla_escrow(paypal, ledger: Ledger | None = None, demo_key: bool = False) -> dict: """Run the SLA escrow scenario. Returns a transcript dict of beats.""" ledger = ledger if ledger is not None else Ledger() permits = PermitStore(ledger=ledger) - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = entrypoint_verifier(paypal, permits, ledger=ledger, demo_key=demo_key) pipeline = SpendPipeline(permits, paypal, verifier, ledger=ledger) beats: list[dict] = [] now = datetime.now(timezone.utc) diff --git a/server.py b/server.py index 828aed9..ccbee5a 100644 --- a/server.py +++ b/server.py @@ -499,6 +499,37 @@ def do_POST(self): # ---------------------------------------------------------------- main +def _verifier_kwargs(args): + """Acceptance-key kwargs for ReleaseVerifier (issue #53). + + No --demo-key: no demo fallback at all; a configured key is required + and an invalid one is fatal. With --demo-key and --db, the generated + key persists in a 0600 sidecar file next to the db so it does not + change silently across a restart; without --db it is per-run. + """ + if not args.demo_key: + return {} + if args.db and "PERMIT_ACCEPTANCE_KEY" not in os.environ: + from settlement.verifier import load_or_create_demo_key_file + try: + key = load_or_create_demo_key_file(args.db + ".demo-key") + except ValueError as e: + raise SystemExit(f"error: {e}") from None + return {"acceptance_key": key} + return {"allow_demo_key": True} + + +def _make_verifier(pp, ps, lg, vkwargs): + try: + return ReleaseVerifier(pp, ps, ledger=lg, **vkwargs) + except ValueError as e: + raise SystemExit( + f"error: {e}\nFix: set PERMIT_ACCEPTANCE_KEY to a key of at " + "least 32 bytes, or pass --demo-key to opt in to a generated " + "demo key (issue #53)." + ) from None + + def main(): global paypal, verifier, flow, MOCK_MODE, ledger, permits ap = argparse.ArgumentParser(description="Permit service") @@ -508,7 +539,13 @@ def main(): " default is in-memory (no persistence)") ap.add_argument("--sandbox", action="store_true", help="use the real sandbox rail (needs env creds + interactive payer approval)") + ap.add_argument("--demo-key", action="store_true", + help="opt in to a generated demo acceptance key when " + "PERMIT_ACCEPTANCE_KEY is unset (off by default, " + "#53); with --db the key persists in a 0600 " + ".demo-key sidecar so it survives restarts") args = ap.parse_args() + vkwargs = _verifier_kwargs(args) if args.sandbox: from settlement.sandbox_client import SandboxPayPalClient @@ -527,7 +564,7 @@ def main(): paypal = MockPayPalClient() log.info("mock rail (no network, no credentials)") - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=MOCK_MODE) + verifier = _make_verifier(paypal, permits, ledger, vkwargs) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) if args.db: @@ -539,7 +576,7 @@ def main(): folded = fold_receipts(db_ledger.receipts()) folded.ledger = db_ledger ledger, permits = db_ledger, folded - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=MOCK_MODE) + verifier = _make_verifier(paypal, permits, ledger, vkwargs) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) log.info(f"durable ledger at {args.db}" f" ({len(ledger)} receipts restored)") diff --git a/settlement/verifier.py b/settlement/verifier.py index f637842..2ec6b77 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -119,28 +119,84 @@ def _coerce_key(key: bytes | str | None) -> bytes | None: return key.encode() if isinstance(key, str) else bytes(key) +def _validate_acceptance_key(resolved: bytes | None, source: str) -> bytes: + """Fail closed on any configured-but-unusable acceptance key (#53). + + A key that is present but invalid (missing bytes, under the minimum + length, or whitespace-only) is a configuration error, never a cue to + fall back to a demo key: a whitespace-only key of 32+ bytes passes a + naive length check while being trivially guessable. + """ + if resolved is None or len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: + raise ValueError( + f"acceptance key from {source} is missing, empty, or under " + f"{MIN_ACCEPTANCE_KEY_BYTES} bytes (fail closed, issue #35/#53)" + ) + if not resolved.strip(): + raise ValueError( + f"acceptance key from {source} is whitespace-only " + "(fail closed, issue #53)" + ) + return resolved + + def resolve_acceptance_key( key: bytes | str | None = None, *, allow_demo_key: bool = False ) -> bytes: """Resolve and validate the acceptance key, failing closed. Order: explicit ``key`` argument, then PERMIT_ACCEPTANCE_KEY. - Missing, empty, or short (under 32 byte) keys raise ValueError, - unless ``allow_demo_key`` is set, in which case a fresh per-call - random key is generated (demo use only, off by default). + A key that is configured but invalid (empty, under 32 bytes, or + whitespace-only) raises ValueError even when ``allow_demo_key`` is + set: the demo fallback applies only when NO key is configured at + all, in which case a fresh per-call random key is generated (demo + use only, off by default). (#35, #53) """ - resolved = _coerce_key(key) - if resolved is None: - resolved = _coerce_key(_os.environ.get("PERMIT_ACCEPTANCE_KEY")) - if resolved is None or len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: - if allow_demo_key: - return _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) - raise ValueError( - "acceptance key missing, empty, or under " - f"{MIN_ACCEPTANCE_KEY_BYTES} bytes: pass acceptance_key or set " - "PERMIT_ACCEPTANCE_KEY (fail closed, issue #35)" + if key is not None: + return _validate_acceptance_key(_coerce_key(key), "the explicit argument") + if "PERMIT_ACCEPTANCE_KEY" in _os.environ: + return _validate_acceptance_key( + _coerce_key(_os.environ.get("PERMIT_ACCEPTANCE_KEY")), + "PERMIT_ACCEPTANCE_KEY", ) - return resolved + if allow_demo_key: + return _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) + raise ValueError( + "no acceptance key configured: pass acceptance_key or set " + "PERMIT_ACCEPTANCE_KEY (at least " + f"{MIN_ACCEPTANCE_KEY_BYTES} bytes), or opt in to a per-run demo " + "key with allow_demo_key=True (fail closed, issue #35/#53)" + ) + + +def load_or_create_demo_key_file(path) -> bytes: + """Load a persisted demo acceptance key, creating it on first use. + + For durable demo runs (server.py --db --demo-key): the key must not + change silently across a restart, or acceptances signed before the + restart stop verifying. The key is stored hex-encoded in ``path``, + created with 0600 permissions; an existing file is tightened to + 0600 on load. A configured-but-invalid file content raises. + """ + p = _os.fspath(path) + if _os.path.exists(p): + _os.chmod(p, 0o600) + raw = open(p, encoding="ascii").read().strip() + try: + stored = bytes.fromhex(raw) + except ValueError: + raise ValueError( + f"demo key file {p} does not contain a hex key " + "(fail closed, issue #53)" + ) from None + return _validate_acceptance_key(stored, f"demo key file {p}") + key = _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) + fd = _os.open(p, _os.O_WRONLY | _os.O_CREAT | _os.O_EXCL, 0o600) + try: + _os.write(fd, (key.hex() + "\n").encode("ascii")) + finally: + _os.close(fd) + return key def sign_acceptance( @@ -149,14 +205,35 @@ def sign_acceptance( """What the counterparty (acceptance key holder) does off-camera. The key is required: there is no module-level key to fall back to. + API change (#35): the ``key`` argument is mandatory; earlier code + signed with a module-level fallback key. """ - resolved = _coerce_key(key) - if resolved is None or len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: - raise ValueError("sign_acceptance requires a key of at least 32 bytes") + resolved = _validate_acceptance_key(_coerce_key(key), "sign_acceptance") msg = f"{escrow_id}|{artifact_hash}|{amount_cents}".encode() return hmac.new(resolved, msg, hashlib.sha256).hexdigest() +def entrypoint_verifier(paypal, permits, ledger=None, *, demo_key: bool = False): + """Build a ReleaseVerifier for a CLI entry point (#53). + + Entry points opt in to a per-run demo key only through their + explicit ``--demo-key`` flag. Without a configured key and without + the flag, this exits non-zero with the fix named, instead of + surfacing a traceback or silently generating a key. + """ + try: + return ReleaseVerifier( + paypal, permits, ledger=ledger, allow_demo_key=demo_key + ) + except ValueError as e: + raise SystemExit( + f"error: {e}\n" + "Fix: set PERMIT_ACCEPTANCE_KEY to a key of at least " + f"{MIN_ACCEPTANCE_KEY_BYTES} bytes, or pass --demo-key to opt " + "in to a per-run generated demo key (issue #53)." + ) from None + + class ReleaseVerifier: def __init__( self, diff --git a/tests/test_cli.py b/tests/test_cli.py index a7f37c8..65f6367 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,7 +80,7 @@ def test_help_lists_only_offline_demo_commands(tmp_path): def test_invariant_failure_returns_nonzero(monkeypatch, capsys): - def fail(): + def fail(demo_key=False): # signature matches cli.run_demo (#53) raise cli.DemoInvariantError("boom") monkeypatch.setattr(cli, "run_demo", fail) diff --git a/tests/test_issue53_acceptance_key.py b/tests/test_issue53_acceptance_key.py new file mode 100644 index 0000000..57b774a --- /dev/null +++ b/tests/test_issue53_acceptance_key.py @@ -0,0 +1,255 @@ +"""Issue #53: acceptance-key handling after #44. + +REA's after-the-fact check of main 9809cc7 (#44) failed two bars. The +pass bar, written before this fix: + +1. The verifier and ledger constructors reject a whitespace-only key, + including one of 32 bytes or more, with a test. +2. The demo key is an explicit opt-in flag, off by default. server.py + in mock mode (including --db), demo.py, demos/*, python -m permit + and trace.py exit with a non-zero code when no valid key is set and + the flag is absent, with a test for each entry point. +3. A key set through env but invalid (empty, 31 bytes, or whitespace) + raises an error even when the demo flag is on. +4. In --db mode with the demo flag, the key does not change silently + across a restart: it persists in a 0600 sidecar file. +5. The stale ledger.py docstring is fixed and the sign_acceptance API + change is noted in the README (checked by inspection, not here). +6. Every test in this file fails on 9809cc7 and passes on the fix. +""" + +import os +import socket +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +import pytest + +from permit.ledger import Ledger +from permit.permit import PermitStore +from settlement.paypal_client import MockPayPalClient +from settlement.verifier import ReleaseVerifier, sign_acceptance + +ROOT = Path(__file__).resolve().parents[1] +GOOD_KEY = b"k" * 32 + + +def _verifier(**kwargs): + ledger = Ledger() + permits = PermitStore(ledger=ledger) + return ReleaseVerifier( + MockPayPalClient(), permits, ledger=ledger, **kwargs + ) + + +# --- bar 1: whitespace-only keys are rejected everywhere ------------------- + +@pytest.mark.parametrize( + "bad_key", + [b" " * 32, b" " * 64, " " * 40, "\t\n " * 12, b"\x0b\x0c" * 20], +) +def test_whitespace_only_explicit_key_rejected(monkeypatch, bad_key): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + with pytest.raises(ValueError): + _verifier(acceptance_key=bad_key) + + +def test_whitespace_only_env_key_rejected(monkeypatch): + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", " " * 32) + with pytest.raises(ValueError): + _verifier() + + +def test_whitespace_only_key_rejected_by_durable_verifier(monkeypatch): + from permit.durability import DurablePermitStore, DurableReleaseVerifier + + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + ledger = Ledger() + permits = DurablePermitStore(ledger=ledger) + with pytest.raises(ValueError): + DurableReleaseVerifier( + MockPayPalClient(), permits, ledger=ledger, + acceptance_key=b" " * 32, + ) + + +def test_sign_acceptance_rejects_whitespace_key(): + with pytest.raises(ValueError): + sign_acceptance("esc", "hash", 100, b" " * 32) + assert len(sign_acceptance("esc", "hash", 100, GOOD_KEY)) == 64 + + +# --- bar 3: a configured-but-invalid key is fatal even with the demo flag -- + +@pytest.mark.parametrize("bad_env", ["", "x" * 31, " " * 32]) +def test_invalid_env_key_raises_even_with_demo_flag(monkeypatch, bad_env): + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", bad_env) + with pytest.raises(ValueError): + _verifier(allow_demo_key=True) + + +def test_invalid_explicit_key_raises_even_with_demo_flag(monkeypatch): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + with pytest.raises(ValueError): + _verifier(acceptance_key=b"x" * 31, allow_demo_key=True) + with pytest.raises(ValueError): + _verifier(acceptance_key=b"", allow_demo_key=True) + + + +# --- bar 4 (unit): the persisted demo key file ------------------------------ + +def test_demo_key_file_persists_with_0600(tmp_path): + from settlement.verifier import load_or_create_demo_key_file + + path = tmp_path / "ledger.db.demo-key" + key1 = load_or_create_demo_key_file(path) + assert len(key1) >= 32 + assert (os.stat(path).st_mode & 0o777) == 0o600 + first_bytes = path.read_bytes() + key2 = load_or_create_demo_key_file(path) + assert key2 == key1 + assert path.read_bytes() == first_bytes + + +def test_demo_key_file_rejects_garbage(tmp_path): + from settlement.verifier import load_or_create_demo_key_file + + path = tmp_path / "bad.demo-key" + path.write_text("not-a-hex-key\n") + with pytest.raises(ValueError): + load_or_create_demo_key_file(path) + + +# --- bar 2: every entry point exits non-zero without key or flag ----------- + +def _clean_env(extra=None): + env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + env["PYTHONPATH"] = str(ROOT) + if extra: + env.update(extra) + return env + + +def _free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _run(argv, timeout=90, extra_env=None): + return subprocess.run( + [sys.executable, *argv], cwd=ROOT, env=_clean_env(extra_env), + capture_output=True, text=True, timeout=timeout, + ) + + +ENTRYPOINTS_NO_FLAG = [ + ["demo.py"], + ["demos/demo_admission.py"], + ["demos/demo_approval.py", "--fast"], + ["demos/demo_delegation.py", "--fast"], + ["demos/demo_six_beat.py", "--fast"], + ["demos/demo_sla_escrow.py", "--fast"], + ["-m", "permit", "demo"], + ["trace.py"], +] + + +@pytest.mark.parametrize("argv", ENTRYPOINTS_NO_FLAG) +def test_entrypoint_exits_nonzero_without_key_or_flag(argv): + proc = _run(argv) + assert proc.returncode != 0, f"{argv} exited 0 without a key or --demo-key" + combined = proc.stdout + proc.stderr + assert "demo-key" in combined or "PERMIT_ACCEPTANCE_KEY" in combined, ( + f"{argv} failed for the wrong reason:\n{combined[-800:]}" + ) + + +def test_server_exits_nonzero_without_key_or_flag(): + proc = _run(["server.py", "--port", str(_free_port())], timeout=30) + assert proc.returncode != 0 + assert "demo-key" in proc.stdout + proc.stderr + + +def test_server_invalid_env_key_fatal_even_with_demo_flag(): + proc = _run( + ["server.py", "--port", str(_free_port()), "--demo-key"], + timeout=30, extra_env={"PERMIT_ACCEPTANCE_KEY": "x" * 31}, + ) + assert proc.returncode != 0 + assert "under 32 bytes" in proc.stdout + proc.stderr + + +# --- bar 2/4 (positive): the flag opts in, and --db persists the key ------- + +@pytest.mark.parametrize( + "argv", + [ + ["demo.py", "--demo-key"], + ["demos/demo_sla_escrow.py", "--fast", "--demo-key"], + ["-m", "permit", "demo", "--demo-key"], + ], +) +def test_entrypoint_runs_with_demo_key_flag(argv): + proc = _run(argv, timeout=120) + assert proc.returncode == 0, (proc.stdout + proc.stderr)[-800:] + + +def _wait_for_server(proc, port, timeout=15): + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + raise AssertionError( + f"server exited {proc.returncode}: {proc.stdout.read()[-500:]}" + ) + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/api/ledger", timeout=2 + ): + return + except Exception: + time.sleep(0.1) + raise AssertionError("server did not start listening") + + +def _start_server(argv, extra_env=None): + proc = subprocess.Popen( + [sys.executable, *argv], cwd=ROOT, env=_clean_env(extra_env), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + return proc + + +def test_server_starts_with_demo_key_flag(): + port = _free_port() + proc = _start_server(["server.py", "--port", str(port), "--demo-key"]) + try: + _wait_for_server(proc, port) + assert proc.poll() is None + finally: + proc.terminate() + proc.wait(timeout=10) + + +def test_server_db_demo_key_persists_across_restart(tmp_path): + db = tmp_path / "ledger.db" + sidecar = Path(str(db) + ".demo-key") + seen = [] + for _ in range(2): + port = _free_port() + proc = _start_server( + ["server.py", "--port", str(port), "--db", str(db), "--demo-key"] + ) + try: + _wait_for_server(proc, port) + finally: + proc.terminate() + proc.wait(timeout=10) + assert sidecar.exists() + assert (os.stat(sidecar).st_mode & 0o777) == 0o600 + seen.append(sidecar.read_bytes()) + assert seen[0] == seen[1], "demo key changed silently across a restart" diff --git a/trace.py b/trace.py index 5a47373..273e524 100644 --- a/trace.py +++ b/trace.py @@ -64,9 +64,23 @@ def show(tag, obj): def main(): + demo_key = "--demo-key" in sys.argv[1:] + # Acceptance-key gate (#53): fail here, non-zero, before spawning a + # server that would die on the same check. Without a configured key + # the only opt-in is the explicit --demo-key flag. + from settlement.verifier import resolve_acceptance_key + try: + resolve_acceptance_key(allow_demo_key=demo_key) + except ValueError as e: + raise SystemExit( + f"error: {e}\nFix: set PERMIT_ACCEPTANCE_KEY to a key of at " + "least 32 bytes, or pass --demo-key (issue #53).") env = dict(os.environ, PERMIT_API_TOKEN=TRACE_TOKEN) + server_cmd = [sys.executable, str(HERE / "server.py"), "--port", str(PORT)] + if demo_key: + server_cmd.append("--demo-key") srv = subprocess.Popen( - [sys.executable, str(HERE / "server.py"), "--port", str(PORT)], + server_cmd, cwd=HERE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, ) @@ -151,7 +165,7 @@ def main(): from permit.ledger import Ledger from permit.permit import PermitStore from settlement.paypal_client import PayPalClient - from settlement.verifier import PredicateType, ReleaseVerifier + from settlement.verifier import PredicateType, entrypoint_verifier class StrictSpy(PayPalClient): merchant_account_id = None @@ -167,7 +181,7 @@ def get_authorization(self, auth_id): lg = Ledger() ps = PermitStore(ledger=lg) spy = StrictSpy() - vf = ReleaseVerifier(spy, ps, ledger=lg, allow_demo_key=True) + vf = entrypoint_verifier(spy, ps, ledger=lg, demo_key=demo_key) fl = SpendPipeline(ps, spy, vf, ledger=lg) p2, _ = ps.grant("spy_agent", 1000, [merchant], datetime.now(timezone.utc) + timedelta(hours=1))