A security release. Three fail-open paths found by re-auditing the enforcement core, plus the demo is now runnable from an installed package.
Upgrade if you are running 0.1.0. The approval defect below can hand out spending authority that current policy forbids.
Fixed: approvals never re-evaluated policy
Enforcer._finalize_locked() built an ALLOW and minted a mandate directly from the queued item — it never called evaluate() again. Policy was checked when a request entered the queue and never when authority was actually granted, so anything that changed while it waited was invisible. Reproduced three ways:
| What changed while the item waited | 0.1.0 behaviour |
|---|---|
| Other spending consumed the rolling budget | Committed spend reached 110.00 against a 100.00 ceiling |
| Merchant added to the denylist | Mandate minted to the denylisted merchant |
| Policy file stopped parsing | A new request was correctly denied POLICY_INVALID, while a parked one was still granted |
The audit row also recorded the policy hash from queue time, attributing the grant to a policy that might no longer exist — which undercuts what the log exists to prove.
A grant now re-runs evaluate() against current policy and current spend history. REQUIRE_APPROVAL is the expected verdict — it is why the item was queued, and the human has now supplied the judgment it was waiting for — but a DENY is a hard rule and no approval overrides one. The row records the policy hash in force at the moment authority was granted or refused.
This is what the design already claimed: approvers exercise judgment inside policy, never over it.
Fixed: a tightened policy could be silently ignored
LocalFileSource cached parses on (mtime, size). An edit preserving both — a same-length change such as a cap going from 90.00 to 10.00, or any restore that keeps timestamps, as rsync --times, backup restores and configuration management all do — left the engine enforcing the previous policy indefinitely. The stale copy is usually the looser one, so it failed open.
The cache key is now a SHA-256 of the file contents. Reading is cheap; parsing and validating the YAML is what the cache still skips.
Fixed: the forward proxy blocked the event loop
ProxyServer._handle is async, but called _authorize and both tunnel decisions synchronously. Each takes the decision lock and commits under synchronous=FULL, so one decision stalled every other proxied connection behind an fsync. They now run via asyncio.to_thread, matching the fix already made in api.py.
Added: bouncer demo
The demonstration lived in examples/, which ships in the repository but not in the wheel — so anyone who installed from PyPI could not run it. It now lives in the package:
pip install --upgrade agent-bouncer
bouncer demoSix purchases judged, an approval routed to a human and resolved, then the audit chain verified and caught after a row is tampered with. It builds its own key, policy and database in a temporary directory and removes them afterwards, so it never touches your ~/.bouncer.
Also
- The API reported a hardcoded
0.1.0in its OpenAPI document; it derives from__version__now. - Removed a dead assignment in
cmd_export.
Verification
265 tests, 89% statement coverage, mypy --strict clean across 40 source files. CI runs the suite, the type checker and the demo end to end on Ubuntu and Windows against Python 3.11 and 3.12. Every fix above carries a regression test, including one asserting the ordinary approval path still grants — the risk in a fix like this is over-correcting into refusing everything.