Skip to content
This repository was archived by the owner on Oct 22, 2026. It is now read-only.
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ data/dogfood_partner_notes.jsonl
data/dogfood_trust_plane.db*
data/demo_trust_plane.db*
data/quickstart/
data/live-loop-proof/

# OS
.DS_Store
Expand Down
16 changes: 16 additions & 0 deletions DEMO_SCRIPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ For machine-readable verification of the same war-room flow:
make agent-ops-war-room-check
```

For the full loop over **real HTTP** as a self-provisioned non-admin caller
— discover → authenticate → authorize → invoke → meter → receipt → replay →
audit-chain verification → out-of-scope denial, with both receipts verified
offline — plus a partner handoff bundle (portable receipts, public key set,
and independent verification instructions) written to
`data/live-loop-proof/`:

```bash
make quickstart # terminal 1: boots the server
make live-loop-proof # terminal 2: drives the loop, writes the bundle
```

Handing that bundle's directory to a partner engineer, and having them run
the verifier themselves, is the partner-owned verification step the
customer-validation milestone asks for.

The proof artifact shape is captured in
[`docs/demo-trust-plane-output.md`](docs/demo-trust-plane-output.md). Use the
live demo flow below when walking a partner through the product story.
Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: quickstart quickstart-check test test-all test-proof coverage prove-trust-plane prove-trust-plane-postgres prove-crash-recovery demo-trust-plane demo-trust-plane-check dogfood-trust-plane dogfood-trust-plane-check red-team-trust-plane red-team-trust-plane-check agent-ops-war-room agent-ops-war-room-check trust-coverage-gate trust-release-gate trust-conformance-live adversarial-battery-live railway-preflight railway-preflight-live
.PHONY: quickstart quickstart-check live-loop-proof test test-all test-proof coverage prove-trust-plane prove-trust-plane-postgres prove-crash-recovery demo-trust-plane demo-trust-plane-check dogfood-trust-plane dogfood-trust-plane-check red-team-trust-plane red-team-trust-plane-check agent-ops-war-room agent-ops-war-room-check trust-coverage-gate trust-release-gate trust-conformance-live adversarial-battery-live railway-preflight railway-preflight-live

# The 15-minute golden path: boot a real local trust plane on loopback with
# self-serve key minting and one invokable governed tool, then follow
Expand All @@ -13,6 +13,15 @@ quickstart:
quickstart-check:
uv run --with-requirements requirements.txt pytest tests/test_quickstart_path.py -v

# One-command live proof against an already-running quickstart server
# (terminal 1: `make quickstart`). Drives discover -> authenticate ->
# authorize -> invoke -> meter -> receipt -> replay -> audit -> govern over
# real HTTP as a self-provisioned non-admin caller, verifies both the
# success and denial receipts offline, and writes a partner handoff bundle
# to data/live-loop-proof/.
live-loop-proof:
uv run --with-requirements requirements.txt python scripts/live_loop_proof.py $(LIVE_LOOP_PROOF_ARGS)

# Fast inner loop: trust-plane (product) tests only. Proof-surface workloads
# are skipped here — run them with `make test-all` (what CI runs) or `make test-proof`.
# `--with-requirements` makes these self-contained: uv installs the runtime +
Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,39 @@ make quickstart
Then follow [docs/quickstart.md](docs/quickstart.md): mint your own
wallet-scoped key (no operator, no pre-shared secret), issue yourself a
permit, invoke a real governed tool, deliberately try to double-charge and
overspend, and finish holding a signed receipt you verified offline — plus
overspend, verify your wallet's tamper-evident audit chain with your own
key, and finish holding a signed receipt you verified offline — plus
a forged one the verifier rejected. Every step of that page runs in CI
against a freshly booted server (`make quickstart-check`), so the
documented path cannot silently rot.

### One command, whole loop, partner handoff bundle

To drive the entire loop end-to-end against a running quickstart server —
and produce artifacts you can hand to someone else to verify — run:

```bash
make quickstart # terminal 1: boots the server
make live-loop-proof # terminal 2: drives the loop, writes the bundle
```

`live-loop-proof` walks the full core loop as a self-provisioned non-admin
caller — `discover → authenticate → authorize → invoke → meter → receipt →
replay → audit → govern` — and asserts each invariant rather than only
printing it: the call charges the known price once, the replay returns the
same receipt with no second debit, the audit chain verifies, and the
out-of-scope call is denied with a signed, zero-charge receipt. It exits
non-zero the moment any stage's invariant breaks.

On success it writes a handoff bundle to `data/live-loop-proof/`: the
portable success and denial receipts, the issuer's public key set, a
machine-readable transcript, and a `VERIFY.md` a partner engineer can
follow to verify both receipts offline — no account, no credential, and no
network access to the issuing server. Handing that directory to a partner
who verifies a receipt themselves is the independent-verification step the
customer-validation milestone asks for. The command runs end to end in CI
(`tests/test_quickstart_path.py`).

## Run the API locally

The following keeps strict trust mode enabled while using local SQLite files.
Expand Down Expand Up @@ -653,6 +681,15 @@ python -m pip install -e './b2a_sdk[dev]'
`B2AClient` remains as deprecated compatibility during the 0.4.x transition.
No TypeScript package is published. Do not advertise PyPI or npm installation.

Offline receipt verification is deliberately dependency-minimal: the
`b2a-verify-receipt` CLI (and `verify_bundle`) verify a signed receipt
against a published key set with only `cryptography` installed — no
networking library and no account. Importing the package no longer pulls in
the HTTP client, so `pip install "./b2a_sdk[verify]"` (or just
`cryptography` with `PYTHONPATH=b2a_sdk/src`) is enough to check a receipt.
The networked `--issuer` fetch is the only path that additionally needs
`httpx`.

## Documentation

- [docs/30-day-customer-validation.md](docs/30-day-customer-validation.md) — active company milestone: customer interviews, partner-owned pilot, and
Expand Down
51 changes: 48 additions & 3 deletions b2a_sdk/src/b2a_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"""Python client for governed Agent Middleware tool calls."""

from .client import AgentMiddlewareClient, B2AClient
from .decorators import billable, combined, monitored
from .edge_client import B2AEdgeClient
from __future__ import annotations

from typing import TYPE_CHECKING

# Dependency-minimal surface, imported eagerly: errors, models, and the offline
# receipt verifier need only the standard library plus ``cryptography`` (loaded
# lazily inside the verifier itself). Keeping these free of the HTTP stack is
# what lets ``python -m b2a_sdk.verify_cli --keys ...`` and
# ``from b2a_sdk import verify_bundle`` run with no networking library
# installed — the whole point of offline receipt verification.
from .errors import (
AgentMiddlewareError,
APIError,
Expand Down Expand Up @@ -32,6 +39,44 @@
)

__version__ = "0.4.0"

# The HTTP client surface (client, edge_client, and the decorators that wrap it)
# pulls in httpx. Load those names lazily via PEP 562 so importing the package —
# or running the offline verifier — does not require httpx to be installed.
# Accessing one of these names imports its backing module on first use, which
# still raises the normal ImportError for httpx only when httpx is genuinely
# needed and absent.
_LAZY_ATTRS = {
"AgentMiddlewareClient": "client",
"B2AClient": "client",
"B2AEdgeClient": "edge_client",
"billable": "decorators",
"combined": "decorators",
"monitored": "decorators",
}

if TYPE_CHECKING: # let type checkers and IDEs resolve the lazy names statically
from .client import AgentMiddlewareClient, B2AClient
from .decorators import billable, combined, monitored
from .edge_client import B2AEdgeClient


def __getattr__(name: str) -> object:
module_name = _LAZY_ATTRS.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib

module = importlib.import_module(f".{module_name}", __name__)
value = getattr(module, name)
globals()[name] = value # cache so later access is a plain attribute lookup
return value


def __dir__() -> list[str]:
return sorted(__all__)


__all__ = [
"APIError",
"AgentMiddlewareClient",
Expand Down
2 changes: 1 addition & 1 deletion b2a_sdk/src/b2a_sdk/verify_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def _read_json(path: str) -> Any:
def _fetch_key_document(issuer: str, timeout: float) -> Any:
try:
import httpx
except ImportError as exc: # pragma: no cover - httpx is a base dependency
except ImportError as exc: # httpx is only needed for the --issuer fetch
raise VerificationError(
"Fetching a key set requires httpx; pass --keys to verify offline."
) from exc
Expand Down
188 changes: 188 additions & 0 deletions b2a_sdk/tests/test_offline_verify_no_httpx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""The offline receipt verifier must work with only ``cryptography`` installed.

The ``--keys`` verification path is the trust plane's promise that anyone can
check a signed receipt with no network access and no account. That promise is
only real if the code path also carries no *network dependency*: a partner who
``pip install cryptography`` and runs the verifier must not hit a
``ModuleNotFoundError`` for httpx.

Regression guard: importing ``b2a_sdk`` used to eagerly import the httpx-backed
HTTP client, so ``python -m b2a_sdk.verify_cli --keys ...`` died at import time
when httpx was absent — even though the verifier itself never touches httpx.
These tests run the offline path in a subprocess with httpx forced unavailable.

Collection must not require ``cryptography`` (the SDK wheel job installs the
bare wheel), so the signing helper imports it lazily and the real-verification
test skips when it is absent, matching ``test_receipt_verifier.py``.
"""

from __future__ import annotations

import base64
import json
import os
import subprocess
import sys
from pathlib import Path

import pytest

SDK_SRC = Path(__file__).resolve().parents[1] / "src"

# A preamble that makes ``import httpx`` fail even when httpx is installed, so
# the test proves independence from httpx on any machine (the wheel job has
# httpx present as a base dependency).
_BLOCK_HTTPX = "import sys; sys.modules['httpx'] = None\n"


def _run(code: str, *, block_httpx: bool) -> subprocess.CompletedProcess:
# Prepend to (not replace) PYTHONPATH: keep any inherited PYTHONPATH intact
# while ensuring the SDK source wins on import.
env = dict(os.environ)
existing = env.get("PYTHONPATH")
env["PYTHONPATH"] = (
f"{SDK_SRC}{os.pathsep}{existing}" if existing else str(SDK_SRC)
)
return subprocess.run(
[sys.executable, "-c", (_BLOCK_HTTPX if block_httpx else "") + code],
env=env,
capture_output=True,
text=True,
timeout=60,
)


def _write_signed_bundle(tmp_path: Path) -> tuple[Path, Path]:
"""Produce a genuinely-signed bundle and its public key document.

Signs with ``cryptography`` only — the same dependency the verifier uses —
so the fixture needs nothing the offline path itself would not have.
"""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

private_key = Ed25519PrivateKey.generate()
public_raw = private_key.public_key().public_bytes_raw()
kid = "offline-test-ed25519"

signing_input = json.dumps(
{"kid": kid, "outcome": "success", "receipt_id": "rcpt-offline-test"},
separators=(",", ":"),
sort_keys=True,
)
signature = private_key.sign(signing_input.encode("utf-8"))

bundle = {
"schema_version": "1.0",
"receipt_id": "rcpt-offline-test",
"alg": "Ed25519",
"kid": kid,
"canonicalization": "awi-canonical-json/1",
"signing_input": signing_input,
"signature": base64.b64encode(signature).decode(),
}
key_doc = {
"schema_version": "1.0",
"alg": "Ed25519",
"keys": [
{
"kid": kid,
"alg": "Ed25519",
"status": "active",
"public_key_b64": base64.b64encode(public_raw).decode(),
}
],
}

bundle_path = tmp_path / "receipt-bundle.json"
keys_path = tmp_path / "trust-keys.json"
bundle_path.write_text(json.dumps(bundle), encoding="utf-8")
keys_path.write_text(json.dumps(key_doc), encoding="utf-8")
return bundle_path, keys_path


def test_package_and_verifier_import_without_httpx():
# Import only — needs neither httpx nor cryptography. This is the exact
# step that used to fail: importing b2a_sdk pulled in the httpx client.
result = _run(
"import b2a_sdk\n"
"from b2a_sdk import verify_bundle, key_set_from_document\n"
"import b2a_sdk.verify_cli\n"
"print('ok')\n",
block_httpx=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "ok" in result.stdout


def test_verify_cli_keys_path_runs_without_httpx(tmp_path):
pytest.importorskip("cryptography")
bundle_path, keys_path = _write_signed_bundle(tmp_path)
# Drive the CLI's main() exactly as `python -m b2a_sdk.verify_cli` would,
# which imports the b2a_sdk package first — the step that used to fail.
result = _run(
"from b2a_sdk.verify_cli import main\n"
f"raise SystemExit(main(['--bundle', {str(bundle_path)!r}, "
f"'--keys', {str(keys_path)!r}]))\n",
block_httpx=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "VERIFIED" in result.stdout


def test_tampered_bundle_rejected_without_httpx(tmp_path):
"""The dependency-minimal path must still REJECT a forged bundle.

VERIFY.md tells partners to branch on exit code 1; prove that contract
holds in the same httpx-free configuration, not just the accept path.
"""
pytest.importorskip("cryptography")
bundle_path, keys_path = _write_signed_bundle(tmp_path)
bundle = json.loads(bundle_path.read_text(encoding="utf-8"))
bundle["signing_input"] = bundle["signing_input"].replace(
'"outcome":"success"', '"outcome":"denied"'
)
assert '"outcome":"denied"' in bundle["signing_input"] # the edit must bite
forged = tmp_path / "forged-bundle.json"
forged.write_text(json.dumps(bundle), encoding="utf-8")
result = _run(
"from b2a_sdk.verify_cli import main\n"
f"raise SystemExit(main(['--bundle', {str(forged)!r}, "
f"'--keys', {str(keys_path)!r}]))\n",
block_httpx=True,
)
# 1 = well-formed but does not verify; must not be 0 and must not crash (2).
assert result.returncode == 1, result.stdout + result.stderr
assert "INVALID" in result.stderr


def test_unknown_attribute_raises_attribute_error():
"""The lazy __getattr__ must raise AttributeError for unknown names, not
ImportError -- wrong type breaks hasattr/getattr-default/pickle probing."""
result = _run(
"import b2a_sdk\n"
"try:\n"
" b2a_sdk.NotAThing\n"
"except AttributeError:\n"
" print('attribute-error-ok')\n"
"else:\n"
" raise SystemExit('expected AttributeError')\n",
block_httpx=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "attribute-error-ok" in result.stdout


def test_lazy_http_client_still_importable_when_httpx_present():
# The lazy PEP 562 surface must not have broken the real client import.
pytest.importorskip("httpx")
result = _run(
"import b2a_sdk\n"
"assert b2a_sdk.B2AClient is not None\n"
"assert b2a_sdk.billable is not None\n"
"from b2a_sdk import AgentMiddlewareClient\n"
"assert AgentMiddlewareClient is not None\n"
"print('client-ok')\n",
block_httpx=False,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "client-ok" in result.stdout
Comment thread
coderabbitai[bot] marked this conversation as resolved.
17 changes: 17 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,23 @@ Confirm the ledger agrees — one funding entry, one debit:
curl -s "$API_URL/v1/billing/ledger/$WALLET_ID" -H "X-API-Key: $AGENT_API_KEY" | python3 -m json.tool
```

Then verify the tamper-evident audit chain behind those entries. Every
governed tool invocation — the successful call you just made, and the
denials you are about to trigger — appends a hash-linked audit event, and
your own key is enough to verify your wallet's chain (tamper evidence you
cannot use yourself protects no one):

```bash
curl -s -X POST "$API_URL/v1/audit/verify-chain" \
-H "X-API-Key: $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}' | python3 -m json.tool
```

`"valid": true` with a non-zero `checked_events` means the recorded history
hash-links end to end; an edited or deleted event would surface as
`"valid": false` with the broken link named in `broken_event_id`.

## 7. Overspend on purpose (~2 minutes)

Spend the rest of the permit — notes two and three succeed:
Expand Down
Loading