Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/forge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <transcript>`, 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 <transcript>`, 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

Expand All @@ -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 `<db>.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.
Expand Down
15 changes: 9 additions & 6 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=""):
Expand All @@ -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 ==")
Expand Down Expand Up @@ -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__":
Expand Down
24 changes: 15 additions & 9 deletions demos/demo_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,25 @@
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

from permit.flow import SpendPipeline
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",
Expand All @@ -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)
Expand All @@ -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()

Expand All @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions demos/demo_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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}")
Expand Down
6 changes: 4 additions & 2 deletions demos/demo_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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}")
Expand Down
6 changes: 4 additions & 2 deletions demos/demo_six_beat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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}")
Expand Down
5 changes: 4 additions & 1 deletion demos/demo_sla_escrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 19 additions & 17 deletions permit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -141,24 +141,26 @@ 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


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
Expand Down
4 changes: 3 additions & 1 deletion permit/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading