Skip to content

Releases: nmaltese13/bouncer

v0.1.1 - three fail-open paths closed

Choose a tag to compare

@nmaltese13 nmaltese13 released this 20 Aug 00:31

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 demo

Six 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.0 in 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.

v0.1.0 - policy enforcement for agent spending

Choose a tag to compare

@nmaltese13 nmaltese13 released this 19 Aug 21:41

First tagged release of bouncer — a policy enforcement point that sits between an AI agent and a payment rail, blocks transactions that violate a declarative policy, and writes a tamper-evident, signed audit log of every decision.

It never custodies funds.

What works

Pure policy engine. evaluate(intent, policy, history, now=...) performs no I/O, reads no clock and calls no model, so the same inputs always produce the same decision. Deny-by-default throughout: an unnamed agent cannot spend, a misspelled rule name is a load error rather than a silently-absent restriction, and an empty or malformed policy denies everything. Per-transaction caps, rolling windows, merchant and category allow/denylists, and time windows with real timezones.

Tamper-evident audit log. Every decision is one SQLite row, SHA-256 hashed over canonical JSON, chained to its predecessor and Ed25519-signed. bouncer verify walks the chain and names the first broken row, distinguishing a sequence gap from a broken link, altered content and a bad signature. Exports to line-delimited JSON that re-verifies standalone from the operator's public key.

Signed mandates. Scoped to one merchant and one ceiling, short TTL, single use. Replay protection rests on an atomic primary-key insert rather than a check-then-write, so two concurrent redemptions cannot both succeed.

Human-in-the-loop approvals. Over-threshold decisions park in a queue tagged with the role that may resolve them. Approve and deny run the identical role check — no asymmetric authority where vetoing is easier than approving — and resolution is once-only. A blocking /authorize times out into a deny, never into an allow.

Client library. Client.spend() is a context manager: a denial raises and the guarded block never runs. A returned verdict can be ignored by forgetting to check it, and an ignored denial is an unenforced policy.

Interfaces. A CLI with 12 commands and distinct exit codes, a FastAPI sidecar, and an HTTP forward proxy for plaintext traffic.

Trust boundary

bouncer is the policy decision point; your network is the enforcement point.

  • It never custodies funds — it authorizes, something else settles.
  • It cannot stop an agent that bypasses it. Containment requires egress control at the network or container layer.
  • It does not replace your payment provider's controls. Run both.
  • Nothing here has been security audited. The Stripe adapter refuses live-mode keys on purpose.

See SECURITY.md for the disclosure process and for what does and does not count as a vulnerability.

Known limits

  • Tail truncation of the audit log is not detectable from the log alone. Record the head hash externally and pass it back with bouncer verify --expect-head.
  • CONNECT tunnels cannot be policed, so they are denied by default. With --allow-connect the traffic inside them is unenforced. Enforcing HTTPS payment traffic needs TLS termination, which this release does not do.
  • The API authenticates nobody. agent_id is an assertion by the caller. Bind to loopback and treat network reachability as the boundary.
  • x402 over the proxy is not fully enforceable — a follow-up payment header names an amount in atomic units without asset decimals, and bouncer denies what it cannot price.

docs/v01-audit.md records the remaining gaps against the v0.1 scope, including four open architectural decisions.

Verification

246 tests, 88% statement coverage, mypy --strict clean across 37 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.

Install

pip install agent-bouncer
bouncer init

The distribution is agent-bouncer because bouncer on PyPI is an unrelated 2014 authorization library. The import is import bouncer either way.