Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e6c9896
Fail closed on acceptance keys: whitespace reject, demo flag off (#53)
cursoragent Oct 19, 2026
c063f8d
docs: refresh evidence counts for #53 key-handling suite
cursoragent Oct 19, 2026
d89a950
Harden durable demo key files after REA seq 1690 (#53)
cursoragent Oct 19, 2026
70b0fc6
docs: clarify --allow-demo-key durable file posture in server help
cursoragent Oct 19, 2026
2a1bbfe
Map key-file OSError cleanly; O_NONBLOCK; load durable key once (REA …
cursoragent Oct 19, 2026
e36c22b
Bind durable acceptance-key commitment via HMAC (REA N-b, droppable)
cursoragent Oct 19, 2026
730d11b
test: never-skip chmod-000 and sudo chown foreign-owner key checks
cursoragent Oct 19, 2026
c7b1ac7
fix: refuse foreign-owned demo keys before open
cursoragent Oct 19, 2026
09b851a
fix: honour env acceptance key with --db; enforce HMAC at every Sqlit…
cursoragent Oct 19, 2026
c61011f
test: kill demo-key mutants (NOFOLLOW, EXCL, fsync, chmod, map, symli…
cursoragent Oct 19, 2026
7070b63
ci: add -rs so pytest prints skip reasons
cursoragent Oct 19, 2026
19b62ce
ci: keep -rs on suite; print key-file PASSED lines by name
cursoragent Oct 19, 2026
6bed5ff
fix: durable ledger commitment, exact-32 keys, fchmod mutant (REA 1888)
cursoragent Oct 19, 2026
92c5fd6
fix: fail-closed acceptance-key commitment (REA r5 1-4)
cursoragent Oct 19, 2026
312e89b
fix: persist harness demo key for K1 reopen (REA r5 N-h)
cursoragent Oct 19, 2026
a898740
docs: acceptance-key rebind upgrade path and UTF-8 wording
cursoragent Oct 19, 2026
5a4ceef
test: REA r5 commitment bars + expand mutant sweep to 31
cursoragent Oct 19, 2026
61a2018
docs: update suite count for REA r5 (461 passed)
cursoragent Oct 19, 2026
7306aab
test: subprocess durability_harness.py K1 --allow-demo-key exit 0
cursoragent Oct 19, 2026
83ac6aa
chore: point mutant sweep at renamed K1 subprocess test
cursoragent Oct 19, 2026
90fc583
test: rebind covers 48-byte and token_hex(32) old keys
cursoragent Oct 19, 2026
0de2140
docs: suite count 463 for r5 rebind length params
cursoragent Oct 19, 2026
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
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,22 @@ jobs:
run: python -m pip install --upgrade pip "pytest>=8" "pytest-cov>=5"
- name: tests
if: matrix.python-version != '3.12'
run: python -m pytest -q
run: python -m pytest -q -rs
- name: tests with coverage
if: matrix.python-version == '3.12'
run: >
python -m pytest -q
python -m pytest -q -rs
--cov=permit --cov=settlement
--cov-report=xml:coverage.xml
--cov-report=term-missing:skip-covered
- name: key-file tests by name
if: matrix.python-version == '3.12'
# Named -v pass lines for REA (sudo chown foreign-owner must not skip).
run: >
python -m pytest -v -rs
tests/test_demo_key_rea_n_b.py::test_chmod_000_key_file_is_clean_valueerror
tests/test_demo_key_rea_n_b.py::test_chmod_000_server_exits_clean_no_traceback
tests/test_demo_key_rea_n_b.py::test_foreign_owner_real_chown_clean_valueerror
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: matrix.python-version == '3.12'
with:
Expand All @@ -50,6 +58,9 @@ jobs:
retention-days: 14
- name: mock demo smoke
if: matrix.python-version == '3.12'
env:
# Test-only key for the smoke path (#53: demo key is off by default).
PERMIT_ACCEPTANCE_KEY: permit-ci-only-key-32bytes!!!!!!
run: python demo.py

test-optimized:
Expand All @@ -64,7 +75,7 @@ jobs:
run: python -m pip install --upgrade pip "pytest>=8"
- name: tests under -O
# Asserts are stripped under -O; suite must still pass (trust-boundary asserts are a known debt).
run: python -O -m pytest -q
run: python -O -m pytest -q -rs

lint:
name: ruff
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/forge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
- permit-cleanup-20261004
- "permit-forge-*"
- main
pull_request:
branches:
- main
schedule:
# 06:00 UTC nightly (~00:00 MDT). Same job as push; default branch is the schedule ref.
- cron: "0 6 * * *"
Expand Down Expand Up @@ -118,8 +121,10 @@ 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"
# Demo key is off by default (#53); forge opts in explicitly for the
# mock-rail smoke. A set-but-invalid env key would still fail closed.
(cd /tmp && /tmp/forge-venv/bin/permit demo --allow-demo-key) 2>&1 | tee "$root/forge-demos/demo.out"
(cd /tmp && /tmp/forge-venv/bin/permit demo-recovery --allow-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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ demo_transcript.replay.jsonl
coverage.xml
.coverage

# Durable mock-mode demo acceptance keys (server.py --db --allow-demo-key).
*.acceptance_key

# The shipped replay fixture IS committed (judge path); local recordings stay ignored.
!demos/fixtures/demo_transcript.jsonl
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ 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
export PERMIT_ACCEPTANCE_KEY="$(python -c 'import secrets; print(secrets.token_hex(16))')"
python demo.py # end-to-end mock demo, ends with the ledger chain check
# Or, for a one-shot mock demo only: python demo.py --allow-demo-key
```

More demos (in `demos/`; `trace.py` is the canonical evidence path —
Expand Down Expand Up @@ -71,9 +73,10 @@ Permit fails closed. When state is uncertain, money does not move.
| Tampered delivery evidence | Release refused, hold voided |

**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.
- 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. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `<db>.acceptance_key`: a regular file owned by the server euid, mode exactly `0600`, opened with `O_NOFOLLOW`, exactly 32 bytes with EOF, created via `O_CREAT|O_EXCL|O_NOFOLLOW` and fsynced (file + directory). If the ledger already has entries and the key file is missing, startup refuses rather than regenerating (#53 / REA seq 1690). Whenever `PERMIT_ACCEPTANCE_KEY` is set it wins over the file key; empty, whitespace-only, and wrong-length env values still refuse startup (#53 bar 3 / REA seq 1854). The durable ledger also stores an HMAC commitment of the acceptance key on first use (required unless the ledger is explicitly ephemeral). Only an empty store may first-bind; a nonempty store without a commitment refuses startup. **Rotating the acceptance key refuses startup** — use the one-time rebind to migrate (see SECURITY.md / `python -m scripts.rebind_acceptance_key`). Keys are UTF-8 encoded bytes; nothing is decoded.
- 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 exactly 32 non-whitespace bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected. A key that is set but invalid (missing content, not exactly 32 bytes, or whitespace-only) raises even when `--allow-demo-key` is on. Demo entry points (`demo.py`, `demos/*`, `python -m permit`, `trace.py`, `server.py` mock mode) require that flag to generate a key; it is off by default (#35, #53).
- `sign_acceptance(escrow_id, artifact_hash, amount_cents, key)` requires an explicit key argument of exactly 32 bytes. There is no module-level fallback key (#35/#53 API change).
- 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 Expand Up @@ -103,8 +106,8 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered

## Evidence

- **389 tests pass, 1 skipped (390 collected):** `python -m pytest -q` on `main` at `21d7d7c`, 2026-10-15, both locally and in the green `ci` run for that commit. CI runs the same suite plus `python demo.py` on every push.
- **Mock end-to-end demo:** `python demo.py` ends with `ledger chain: VERIFIED (16 receipts)` (run 2026-10-15 at `21d7d7c`).
- **463 tests pass, 1 skipped:** `python -m pytest -rs` and `python -O -m pytest -rs` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854 + 1888 + r5). CI runs the suite with `-q -rs`, plus `-v` for the key-file tests only; also `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. Mutant sweep: `python -m scripts.mutant_sweep_demo_key` → 31/31.
- **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`.
- **Mock rail vs sandbox, kept separate:**
- `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path.
- The Oct 2 sandbox spike made real REST calls (OAuth, order, authorize, capture, void) against PayPal sandbox and verified merchant identity. Its report left the tree in `3f88feb`. Read it with `git show 3f88feb^:spike-report.md`.
Expand Down
44 changes: 44 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,50 @@ There is **no bug bounty**, and no monetary reward is offered or implied. This i
research project maintained by one person. What it can offer is a fast, honest response and public
credit.

## Acceptance key + durable ledgers

- `PERMIT_ACCEPTANCE_KEY`, when set, is always used (including with `--db --allow-demo-key`).
The value is taken as UTF-8 encoded bytes (nothing is decoded). Empty,
whitespace-only, and wrong-length values (not exactly 32 bytes) refuse startup;
the demo flag never papers over a bad configured key. The durable key file uses
the same exact-32-byte rule.
- Every durable ledger binds an HMAC commitment of the acceptance key on first use
(`ensure_acceptance_key_commitment` via `ReleaseVerifier`):
- `SqliteLedger` (server `--db`)
- `DurableLedger` (file-backed under `durable_dir`, including
`harness/durability_harness.py`)
Commitment is **required** unless the ledger is explicitly ephemeral (in-memory
`Ledger`, `:memory:` SqliteLedger, or DurableLedger without `durable_dir`).
File-backed SqliteLedger cannot opt out. Ledgers that need the commitment but
lack `get_meta` / `set_meta` refuse startup (no silent skip).
Only an **empty** store may bind a key for the first time; a nonempty store
without a commitment refuses startup (no silent rebind). Concurrent first-boot
binds atomically (`INSERT OR IGNORE`, then re-read and compare). Corrupt
`meta.json` is refused (never treated as empty).
**Rotating the acceptance key on an existing durable store refuses startup.**

### Upgrading from a pre-exact-32 key (e.g. commit `19b62ce`)

Databases bound with a key longer than 32 bytes (for example 64 hex characters from
`secrets.token_hex(32)`) are locked out after the exact-32 upgrade. Run the one-time
rebind with the OLD key and a new exact-32-byte key:

```bash
# OLD may be any non-empty length (e.g. 64 hex chars from token_hex(32)).
# NEW must be exactly 32 UTF-8 bytes (e.g. token_hex(16)).
printf '%s' "$OLD_LONG_KEY" > /tmp/old.key
python -c 'import secrets; open("/tmp/new.key","w").write(secrets.token_hex(16))'

python -m scripts.rebind_acceptance_key --db /path/to/ledger.db \
--old-key-file /tmp/old.key --new-key-file /tmp/new.key

export PERMIT_ACCEPTANCE_KEY="$(cat /tmp/new.key)"
python server.py --db /path/to/ledger.db
```

The command checks the OLD key against the stored HMAC commitment and rebinds
atomically. A wrong OLD key is refused; the commitment is left unchanged.

## Related

- Our agent-systems threat posture and the method behind these reviews: see the `adversarial-seat`
Expand Down
24 changes: 19 additions & 5 deletions demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,22 @@ def show(receipts, prefix=""):
print(f"{prefix}#{r.seq:02d} {r.event_type:10s} {detail}")


def build_pipeline():
def build_pipeline(*, allow_demo_key: bool = False):
ledger = Ledger()
permits = PermitStore(ledger=ledger)
paypal = MockPayPalClient()
verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True)
try:
verifier = ReleaseVerifier(
paypal, permits, ledger=ledger, allow_demo_key=allow_demo_key
)
except ValueError as exc:
raise SystemExit(f"acceptance key error: {exc}") from exc
flow = SpendPipeline(permits, paypal, verifier, ledger=ledger)
return flow, paypal, permits, ledger


def demo_mock():
flow, paypal, permits, ledger = build_pipeline()
def demo_mock(*, allow_demo_key: bool = False):
flow, paypal, permits, ledger = build_pipeline(allow_demo_key=allow_demo_key)
merchant = "demo_merchant"

print("== 1. grant ==")
Expand Down Expand Up @@ -120,13 +125,22 @@ 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(
"--allow-demo-key",
action="store_true",
default=False,
help=(
"Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY "
"is unset (demo only; 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(allow_demo_key=args.allow_demo_key)


if __name__ == "__main__":
Expand Down
35 changes: 26 additions & 9 deletions demos/demo_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -37,11 +38,13 @@
ARTIFACT_HASH = "ab" * 32


def build(cap_cents):
def build(cap_cents, *, allow_demo_key: bool = False):
ledger = Ledger()
permits = PermitStore(ledger=ledger)
paypal = MockPayPalClient()
verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True)
verifier = ReleaseVerifier(
paypal, permits, ledger=ledger, allow_demo_key=allow_demo_key
)
flow = SpendPipeline(permits, paypal, verifier, ledger=ledger)
permit, _ = permits.grant(
agent_id="demo-agent",
Expand All @@ -59,9 +62,9 @@ def attempt(flow, permit_id, amount_cents):
)


def beat_runaway_loop():
def beat_runaway_loop(*, allow_demo_key: bool = False):
print("=== beat A: runaway loop refused at budget exhaustion ===")
flow, paypal, permits, permit = build(3000)
flow, paypal, permits, permit = build(3000, allow_demo_key=allow_demo_key)
refused = 0
for i in range(1, 7):
a = attempt(flow, permit.permit_id, 1000)
Expand All @@ -76,9 +79,9 @@ def beat_runaway_loop():
return permits


def beat_concurrent_grab():
def beat_concurrent_grab(*, allow_demo_key: bool = False):
print("=== beat B: parallel overspend attempts cannot exceed the budget ===")
flow, paypal, permits, permit = build(5000)
flow, paypal, permits, permit = build(5000, allow_demo_key=allow_demo_key)
outcomes = []
lock = threading.Lock()

Expand All @@ -105,10 +108,24 @@ def worker():


def main():
ap = argparse.ArgumentParser(description="Permit admission-gating demo")
ap.add_argument(
"--allow-demo-key",
action="store_true",
default=False,
help=(
"Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY "
"is unset (demo only; off by default, #53)"
),
)
args = ap.parse_args()
print("Permit admission-gating demo (mock rail)")
permits = beat_runaway_loop()
print()
permits_b = beat_concurrent_grab()
try:
permits = beat_runaway_loop(allow_demo_key=args.allow_demo_key)
print()
permits_b = beat_concurrent_grab(allow_demo_key=args.allow_demo_key)
except ValueError as exc:
raise SystemExit(f"acceptance key error: {exc}") from exc
print()
for label, p in (("beat A", permits), ("beat B", permits_b)):
ok, reason = p.ledger.verify_chain()
Expand Down
16 changes: 15 additions & 1 deletion demos/demo_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,26 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument("--fast", action="store_true")
ap.add_argument("--port", type=int, default=8474)
ap.add_argument(
"--allow-demo-key",
action="store_true",
default=False,
help=(
"Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY "
"is unset (demo only; 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)
try:
verifier = ReleaseVerifier(
paypal, permits, ledger=ledger, allow_demo_key=args.allow_demo_key
)
except ValueError as exc:
raise SystemExit(f"acceptance key error: {exc}") from exc
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
16 changes: 15 additions & 1 deletion demos/demo_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,26 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument("--fast", action="store_true")
ap.add_argument("--port", type=int, default=8471)
ap.add_argument(
"--allow-demo-key",
action="store_true",
default=False,
help=(
"Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY "
"is unset (demo only; 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)
try:
verifier = ReleaseVerifier(
paypal, permits, ledger=ledger, allow_demo_key=args.allow_demo_key
)
except ValueError as exc:
raise SystemExit(f"acceptance key error: {exc}") from exc
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
16 changes: 15 additions & 1 deletion demos/demo_six_beat.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ 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(
"--allow-demo-key",
action="store_true",
default=False,
help=(
"Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY "
"is unset (demo only; off by default, #53)"
),
)
args = ap.parse_args()
if args.replay:
import os as _os
Expand All @@ -133,7 +142,12 @@ def main():
ledger = Ledger()
permits = PermitStore(ledger=ledger)
paypal = MockPayPalClient()
verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True)
try:
verifier = ReleaseVerifier(
paypal, permits, ledger=ledger, allow_demo_key=args.allow_demo_key
)
except ValueError as exc:
raise SystemExit(f"acceptance key error: {exc}") from exc
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
Loading
Loading