diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..91b0226a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,74 @@ +# Image builds use the repository root as the context. See gateway/Dockerfile. +.git +.github +.agents +**/.agents +.claude +**/.claude +.envrc +**/.envrc +.vercel +**/.vercel +.railway +**/.railway +.venv +**/.venv +.venv-local +**/.venv-local +venv +**/venv +env +**/env +node_modules +**/node_modules +.aws +**/.aws +.ssh +**/.ssh +.netrc +**/.netrc +.npmrc +**/.npmrc +.pypirc +**/.pypirc +.pytest_cache +**/.pytest_cache +.ruff_cache +**/.ruff_cache +__pycache__ +**/__pycache__ +*.py[cod] +**/*.py[cod] +*.pyo +**/*.pyo +*.pyd +**/*.pyd +.env +**/.env +.env.* +**/.env.* +*.db +**/*.db +*.db-wal +**/*.db-wal +*.db-shm +**/*.db-shm +*.log +**/*.log +*.pem +**/*.pem +*.key +**/*.key +railway.json +**/railway.json +railway.toml +**/railway.toml +gateway/tests +gateway/data +gateway/docker-compose.yml +gateway/docker-compose.prod.yml +sdk/typescript +integrations +examples +spec +verifier diff --git a/.github/scripts/smoke_gateway_image.sh b/.github/scripts/smoke_gateway_image.sh new file mode 100644 index 00000000..07480da6 --- /dev/null +++ b/.github/scripts/smoke_gateway_image.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Build both gateway images and import the modules needed at app startup. +set -euo pipefail + +if [[ -e .build_commit_sha || -L .build_commit_sha ]]; then + echo '.build_commit_sha already exists; refusing to replace it' >&2 + exit 1 +fi + +canary_paths=( + gateway/data/quickstart/signing-seed.b64 + .amw-image-smoke.db-wal + gateway/.amw-image-smoke.db-shm + gateway/.amw-image-smoke.log + gateway/.amw-image-smoke.pem + gateway/.agents/amw-image-smoke.txt + gateway/.claude/amw-image-smoke.txt + gateway/.envrc + sdk/python/.amw-image-smoke.key +) +for path in "${canary_paths[@]}"; do + if [[ -e "$path" || -L "$path" ]]; then + echo "Canary path already exists: $path" >&2 + exit 1 + fi +done + +created_dirs=() +created_files=() +stamp_created=false +cleanup() { + if [[ "$stamp_created" == true ]]; then rm -f .build_commit_sha; fi + for path in "${created_files[@]}"; do rm -f "$path"; done + for ((i=${#created_dirs[@]}-1; i>=0; i--)); do + rmdir "${created_dirs[i]}" 2>/dev/null || true + done +} +trap cleanup EXIT + +for dir in gateway/data gateway/data/quickstart gateway/.agents gateway/.claude; do + if [[ -L "$dir" ]]; then + echo "Canary parent is a symlink: $dir" >&2 + exit 1 + fi + if [[ ! -d "$dir" ]]; then + mkdir "$dir" + created_dirs+=("$dir") + fi +done +for path in "${canary_paths[@]}"; do + printf 'synthetic image context canary\n' > "$path" + created_files+=("$path") +done + +# A named COPY of an ignored canary must fail before creating any layer. +for path in "${canary_paths[@]}"; do + if printf 'FROM scratch\nCOPY %s /probe/\n' "$path" | docker build -f - . >/dev/null 2>&1; then + echo "Docker context unexpectedly includes $path" >&2 + exit 1 + fi +done + +image_canaries=( + /app/data/quickstart/signing-seed.b64 + /app/.amw-image-smoke.db-shm + /app/.amw-image-smoke.log + /app/.amw-image-smoke.pem + /app/.agents/amw-image-smoke.txt + /app/.claude/amw-image-smoke.txt + /app/.envrc + /sdk/python/.amw-image-smoke.key +) +stamp="$(git rev-parse HEAD)" +printf '%s\n' "$stamp" > .build_commit_sha +stamp_created=true + +docker build -f gateway/Dockerfile -t amw-gateway:ci-release . +docker run --rm --network none --read-only --entrypoint python amw-gateway:ci-release -c ' +import sys +from pathlib import Path +import app.main +import b2a_sdk.receipt_verifier +assert Path("/app/.build_commit_sha").read_text().strip() == sys.argv[1] +assert all(not Path(path).exists() for path in sys.argv[2:]) +' "$stamp" "${image_canaries[@]}" + +rm .build_commit_sha +docker build -f gateway/Dockerfile.dev -t amw-gateway:ci-dev . +docker run --rm --network none --read-only --entrypoint python amw-gateway:ci-dev -c ' +import sys +from pathlib import Path +import app.main +import b2a_sdk.receipt_verifier +assert not Path("/app/.build_commit_sha").exists() +assert all(not Path(path).exists() for path in sys.argv[1:]) +' "${image_canaries[@]}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57d8c904..139a4c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,10 @@ concurrency: env: FORCE_COLOR: "1" PIP_DISABLE_PIP_VERSION_CHECK: "1" + # Blessed ruff and mypy. Must match gateway/requirements-dev.txt. + # mypy must also match the exact pin in gateway/requirements.txt. RUFF_VERSION: "0.16.10" + MYPY_VERSION: "2.4.0" PYTEST_VERSION: "9.1.1" jobs: @@ -67,7 +70,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: python-version: ${{ matrix.python-version }} enable-cache: false @@ -94,15 +97,57 @@ jobs: if: matrix.python-version == '3.12' run: | if grep -q '^\[tool\.mypy\]' pyproject.toml 2>/dev/null || [ -f mypy.ini ]; then - uv pip install mypy && uv run --no-sync mypy . + if [ "${{ matrix.project }}" = "sdk/python" ]; then + uv pip install -e ".[mcp]" + fi + uv pip install "mypy==${MYPY_VERSION}" && uv run --no-sync mypy . else echo "mypy not configured; skipping" fi + - name: Pre-commit mypy (gateway) + if: matrix.project == 'gateway' && matrix.python-version == '3.12' + working-directory: . + env: + UV_PYTHON: "3.12" + run: | + hook=(uv tool run --from pre-commit==4.6.0 pre-commit run mypy) + "${hook[@]}" --all-files + + probe_stem="$(mktemp gateway/typecheck/_mypy_hook_probe_XXXXXXXX)" + trap 'rm -f "$probe_stem" "$probe_stem.py"' EXIT + probe="$probe_stem.py" + mv "$probe_stem" "$probe" + cat > "$probe" <<'PY' + def probe() -> int: + return "the hook must reject this return type" + PY + + if probe_output="$("${hook[@]}" --files "$probe" 2>&1)"; then + printf '%s\n' "$probe_output" + echo "mypy hook unexpectedly accepted the type error" >&2 + exit 1 + else + probe_status=$? + fi + printf '%s\n' "$probe_output" + expected="typecheck/$(basename "$probe"):2: error: Incompatible return value type (got \"str\", expected \"int\") [return-value]" + if [ "$probe_status" -ne 1 ] || + ! grep -Fqx "$expected" <<< "$probe_output" || + ! grep -Eq '^Found 1 error in 1 file \(checked [0-9]+ source files\)$' <<< "$probe_output"; then + echo "mypy hook failed for a reason other than the single probe error" >&2 + exit 1 + fi + + rm "$probe" + trap - EXIT + "${hook[@]}" --all-files - name: Test (pytest) run: | set +e if [ "${{ matrix.project }}" = "gateway" ]; then - uv run --no-sync pytest -q -m "not production_trust" -n auto --timeout 300 && + # Shared-runner scheduling can add a few ms to the ACP wall-clock + # median. The separate trust gate still checks its strict 45 ms bound. + ACP_CHECKOUT_MEDIAN_BUDGET_S=0.055 uv run --no-sync pytest -q -m "not production_trust" -n auto --timeout 300 && TRUST_MODE_ENABLED=true ALLOW_LEGACY_UNPERMITTED_MCP=false ENABLE_PROOF_SURFACES=false \ uv run --no-sync pytest -q -m production_trust --timeout 300 else @@ -112,6 +157,242 @@ jobs: # exit code 5 = no tests collected; don't fail packages that have none yet if [ "$rc" -eq 5 ]; then echo "::notice::no tests in ${{ matrix.project }}"; exit 0; fi exit $rc + - name: Collect SDK source distribution tests + if: matrix.project == 'sdk/python' && matrix.python-version == '3.12' + run: | + uv build --sdist --out-dir "$RUNNER_TEMP/b2a-sdist" + tar -xzf "$RUNNER_TEMP"/b2a-sdist/b2a_sdk-*.tar.gz -C "$RUNNER_TEMP/b2a-sdist" + test ! -e "$RUNNER_TEMP"/b2a-sdist/b2a_sdk-*/tests/test_receipt_vectors.py + uv run --no-sync pytest --collect-only -qq "$RUNNER_TEMP"/b2a-sdist/b2a_sdk-*/tests + + postgres-concurrency: + # Proves the PostgreSQL row-lock behavior the default SQLite run cannot: + # SQLite treats SELECT ... FOR UPDATE as a silent no-op, so these tests + # skip unless pointed at a disposable PostgreSQL database (test tier 2, + # see gateway/docs/TESTING_TIERS.md). The database is created fresh in + # this job and nothing outside the job can reach it. + name: Postgres concurrency / gateway + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + env: + AMW_TEST_DB_DISPOSABLE: "1" + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: sellers + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 55439:5432 + options: >- + --health-cmd "pg_isready -U sellers" + --health-interval 5s --health-timeout 5s --health-retries 10 + defaults: + run: + working-directory: gateway + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + python-version: "3.12" + enable-cache: false + - name: Install + run: | + uv venv + uv pip install -r requirements.txt -e "../sdk/python[verify]" pytest-xdist pytest-timeout + uv pip install "ruff==${RUFF_VERSION}" "pytest==${PYTEST_VERSION}" + - name: Create disposable database + run: | + uv run --no-sync python - <<'EOF' + import asyncio + import asyncpg + + async def main(): + conn = await asyncpg.connect( + user="sellers", host="127.0.0.1", port=55439, database="postgres" + ) + try: + await conn.execute("CREATE DATABASE amw_action_ci") + finally: + await conn.close() + + asyncio.run(main()) + EOF + - name: Migrate disposable database + env: + DATABASE_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_ci + run: uv run --no-sync alembic upgrade head + - name: Postgres concurrency tests + env: + DATABASE_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_ci + ENVIRONMENT: test + RUN_POSTGRES_CONCURRENCY_TESTS: "1" + run: | + uv run --no-sync pytest -q tests/test_action_postgres.py tests/test_permit_postgres_concurrency.py tests/test_duplicate_guard_postgres_concurrency.py tests/test_action_database_guard.py --timeout 300 + + postgres-proofs: + # Runs the gateway tests that only prove their claim against a live + # database (row-lock concurrency, the real asyncpg driver, Alembic round + # trips). These skip on a normal run; this job provisions throwaway + # databases, sets every opt-in flag, and fails loudly on any skip via + # CI_STRICT=1 (see gateway/tests/support/db_gate.py). + name: Postgres proofs / gateway + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: sellers + POSTGRES_HOST_AUTH_METHOD: trust + ports: + # 55439 is the fixed disposable-test profile the action-proof guard + # requires (user sellers, no password, amw_action_* database). + - 5432:5432 + - 55439:5432 + options: >- + --health-cmd "pg_isready -U sellers -d sellers" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + defaults: + run: + working-directory: gateway + env: + AMW_TEST_DB_DISPOSABLE: "1" + CI_STRICT: "1" + ENVIRONMENT: test + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + python-version: "3.12" + enable-cache: false + - name: Install + run: | + uv venv + uv pip install -r requirements.txt -e "../sdk/python[verify]" pytest-xdist pytest-timeout + uv pip install "ruff==${RUFF_VERSION}" "pytest==${PYTEST_VERSION}" + - name: Wait for Postgres and create proof databases + run: | + uv run --no-sync python - <<'EOF' + import asyncio + + import asyncpg + + DATABASES = ( + "amw_pg_concurrency", + "amw_action_pgproofs", + "amw_pg_pgloop", + "amw_action_migration", + "amw_action_dispatch", + "amw_pg_stress", + "amw_action_multiprocess", + ) + + + async def main(): + for attempt in range(30): + try: + conn = await asyncpg.connect( + user="sellers", host="127.0.0.1", port=55439, + database="postgres", + ) + break + except (OSError, asyncpg.PostgresError): + await asyncio.sleep(2) + else: + raise SystemExit("postgres service never became reachable") + try: + for name in DATABASES: + await conn.execute(f'DROP DATABASE IF EXISTS "{name}"') + await conn.execute(f'CREATE DATABASE "{name}"') + finally: + await conn.close() + + + asyncio.run(main()) + EOF + - name: Migrate proof databases + run: | + for db in amw_pg_concurrency amw_action_pgproofs amw_pg_pgloop amw_pg_stress amw_action_multiprocess; do + DATABASE_URL="postgresql+asyncpg://sellers@127.0.0.1:55439/$db" \ + uv run --no-sync python -m alembic upgrade head + done + - name: Run row-lock concurrency proofs + env: + DATABASE_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_pg_concurrency + RUN_POSTGRES_CONCURRENCY_TESTS: "1" + run: | + uv run --no-sync pytest -q -rs --timeout 300 \ + tests/test_permit_postgres_concurrency.py \ + tests/test_duplicate_guard_postgres_concurrency.py 2>&1 | tee "$GITHUB_WORKSPACE/proof-logs-concurrency.txt" + test "${PIPESTATUS[0]}" -eq 0 + - name: Run action accounting proofs + env: + DATABASE_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_pgproofs + RUN_POSTGRES_CONCURRENCY_TESTS: "1" + run: | + uv run --no-sync pytest -q -rs --timeout 300 \ + tests/test_action_postgres.py 2>&1 | tee "$GITHUB_WORKSPACE/proof-logs-action.txt" + test "${PIPESTATUS[0]}" -eq 0 + - name: Run migration round-trip proofs + env: + ACTION_MIGRATION_DATABASE_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_migration + DISPATCH_MIGRATION_DATABASE_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_dispatch + run: | + uv run --no-sync pytest -q -rs --timeout 300 \ + "tests/test_action_migrations.py::test_postgres_legacy_roundtrip_and_fail_closed_downgrade" \ + tests/test_dispatch_call_slot_migration.py 2>&1 | tee "$GITHUB_WORKSPACE/proof-logs-migration.txt" + test "${PIPESTATUS[0]}" -eq 0 + - name: Run asyncpg driver proofs + env: + DATABASE_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_pg_pgloop + TEST_POSTGRES_URL: postgresql+asyncpg://sellers@127.0.0.1:55439/amw_pg_pgloop + run: | + uv run --no-sync pytest -q -rs --timeout 300 \ + tests/test_postgres_datetime_binding.py 2>&1 | tee "$GITHUB_WORKSPACE/proof-logs-driver.txt" + test "${PIPESTATUS[0]}" -eq 0 + - name: Run multiprocess crash proofs + env: + STATE_BACKEND: postgres + MCP_STRESS_DB_ISOLATED: "1" + RUN_MCP_MULTIPROCESS_TESTS: "1" + run: | + DATABASE_URL=postgresql+asyncpg://sellers@127.0.0.1:55439/amw_pg_stress \ + uv run --no-sync pytest -q -rs --timeout 300 \ + tests/test_mcp_postgres_multiprocess.py 2>&1 | tee "$GITHUB_WORKSPACE/proof-logs-stress.txt" + test "${PIPESTATUS[0]}" -eq 0 + DATABASE_URL=postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_multiprocess \ + uv run --no-sync pytest -q -rs --timeout 300 \ + tests/test_action_multiprocess.py 2>&1 | tee "$GITHUB_WORKSPACE/proof-logs-action-mp.txt" + test "${PIPESTATUS[0]}" -eq 0 + - name: Prove gated tests actually ran + working-directory: . + run: | + python3 gateway/tests/support/proof_log_gate.py \ + "$GITHUB_WORKSPACE"/proof-logs-{concurrency,action,migration,driver,stress,action-mp}.txt + + gateway-image-smoke: + name: Gateway image import smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build and import release and development images + run: bash .github/scripts/smoke_gateway_image.sh typescript: name: Node ${{ matrix.node-version }} / ${{ matrix.project }} @@ -133,7 +414,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} package-manager-cache: false @@ -164,10 +445,44 @@ jobs: - name: Test run: $PM run --if-present test + trust_release_gate: + # Non-required while trust coverage and committed OpenAPI parity fail. + # Keep it out of CI result until both checks are green in CI and the + # required-check decision is made explicitly. + name: Trust release gate (non-required) + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + defaults: + run: + working-directory: gateway + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + python-version: "3.12" + enable-cache: false + - name: Trust release gate + # `uv run` in the gate scripts has no interpreter flag. Pin the same + # 3.12 the other gateway jobs use so the gate does not float to uv's + # default. + env: + UV_PYTHON: "3.12" + run: scripts/trust_release_gate.sh + - name: Strict ACP checkout latency (45 ms) + if: always() + env: + UV_PYTHON: "3.12" + ACP_CHECKOUT_MEDIAN_BUDGET_S: "0.045" + run: uv run --with-requirements requirements.txt python -m pytest -q tests/test_acp_bridge.py::test_acp_checkout_end_to_end_latency_budget + ci-result: # Single stable check name for branch protection, regardless of how the matrices expand. name: CI result - needs: [detect, python, typescript] + needs: [detect, python, typescript, postgres-concurrency, postgres-proofs, gateway-image-smoke] if: always() runs-on: ubuntu-latest timeout-minutes: 2 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 16855414..949401de 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,11 +52,11 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: none queries: security-extended - - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 9260bf9e..505eb84a 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -58,7 +58,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: python-version: "3.12" enable-cache: false @@ -95,7 +95,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: "22" package-manager-cache: false diff --git a/.github/workflows/secrets.yml b/.github/workflows/secrets.yml index 8aadcefa..d8342f87 100644 --- a/.github/workflows/secrets.yml +++ b/.github/workflows/secrets.yml @@ -45,7 +45,9 @@ jobs: run: | set -euo pipefail # Scheduled/manual runs scan every branch and tag; push/PR runs scan all history reachable from HEAD. - log_opts="" + # gitleaks defaults to `git log --all`, and fetch-depth: 0 fetches every branch, so without an + # explicit HEAD an unmerged draft branch fails every PR's required check. + log_opts="HEAD" if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then log_opts="--all" fi @@ -60,7 +62,7 @@ jobs: . - name: Upload report if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: gitleaks-report path: ${{ runner.temp }}/gitleaks.sarif diff --git a/.gitignore b/.gitignore index ac658792..1eac4866 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist/ build/ venv/ .venv/ +.venv-local/ env/ uv.lock diff --git a/.gitleaks.toml b/.gitleaks.toml index 5780e522..6ebaf85a 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -28,6 +28,19 @@ paths = [ '''(?:^|/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|uv\.lock|poetry\.lock|Cargo\.lock)$''', ] +# Test suites and generated vectors use clearly-fake keys (lowercase-dash +# placeholders, the documented public test seed). Line-scoped .gitleaksignore +# entries cannot survive the squash-merge workflow — every merge shifts line +# numbers — so scope the generic-api-key rule off these paths instead. Every +# other rule (private key blocks, AMW_* env secrets, high-entropy strings) +# still scans them. +[[allowlists]] +description = "Fake keys in test files and generated test vectors (generic-api-key only)" +rules = ["generic-api-key"] +paths = [ + '''(?:^|/)(?:gateway/tests/|spec/test-vectors/|sdk/python/tests/|sdk/typescript/test/|integrations/[^/]+/tests/)''', +] + [[allowlists]] description = "Clearly-fake placeholder values in docs, specs and tests" regexTarget = "match" diff --git a/.gitleaksignore b/.gitleaksignore index ff5c0752..77398eb7 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -24,9 +24,11 @@ gateway/tests/test_api_keys.py:generic-api-key:1348 gateway/tests/test_awi.py:generic-api-key:959 gateway/tests/test_comms.py:generic-api-key:247 gateway/tests/test_constant_test_loop.py:generic-api-key:744 +gateway/tests/test_core_db_qa.py:generic-api-key:644 gateway/tests/test_failure_lab_diagnostic.py:amw-private-key-material:492 gateway/tests/test_failure_lab_scenarios.py:generic-api-key:35 gateway/tests/test_failure_lab_stateful.py:generic-api-key:27 +gateway/tests/test_gateway_auth_breakit.py:generic-api-key:32 gateway/tests/test_iga_policy.py:generic-api-key:1060 gateway/tests/test_iga_policy.py:generic-api-key:1124 gateway/tests/test_iga_policy.py:generic-api-key:1201 @@ -44,6 +46,7 @@ gateway/tests/test_mcp_upstream_governed.py:generic-api-key:3103 gateway/tests/test_permits.py:generic-api-key:59 gateway/tests/test_preflight.py:generic-api-key:115 gateway/tests/test_preflight.py:stripe-access-token:133 +gateway/tests/test_qa_money_auth_matrix.py:generic-api-key:40 gateway/tests/test_receipt_wallet_linkage.py:generic-api-key:261 gateway/tests/test_receipt_wallet_linkage.py:generic-api-key:278 gateway/tests/test_revocation_containment.py:generic-api-key:21 @@ -61,3 +64,22 @@ gateway/tests/test_x402.py:generic-api-key:635 gateway/tests/test_x402.py:generic-api-key:644 gateway/tests/test_x402.py:generic-api-key:881 gateway/tests/test_x402.py:generic-api-key:917 +# CI-2026-10-16-001 reviewed 2026-10-17: test fixtures, idempotency keys, identifiers (no live credentials) +799826286f205e211e1a427f1d7d8f9bff5c8582:gateway/tests/test_core_db_qa.py:generic-api-key:644 +14425ae21890033703e2343ba4b5f831716e4e14:gateway/tests/test_gateway_auth_breakit.py:generic-api-key:32 +2d68f60d5dfd8195295457d8c0b93f8324d774d7:gateway/tests/test_gtm62_uncertain_tool_result.py:generic-api-key:101 +e6077da78044877396758afeb6287d0dada79ea7:gateway/tests/test_idempotency_billing_permit_breaks.py:generic-api-key:426 +3426f0563519b28b95e4ccfc8df868e13ba998a9:gateway/tests/test_qa_money_auth_matrix.py:generic-api-key:40 +b832ed901f992cd225b41d74916db202c82a81c4:gateway/tests/test_x402.py:generic-api-key:1641 +b832ed901f992cd225b41d74916db202c82a81c4:gateway/tests/test_x402.py:generic-api-key:1653 +b832ed901f992cd225b41d74916db202c82a81c4:gateway/tests/test_x402.py:generic-api-key:1675 +b832ed901f992cd225b41d74916db202c82a81c4:gateway/tests/test_x402.py:generic-api-key:1680 +6a8fd86f9bbcc303a25b1081428dca7e76a14674:integrations/framework_integrations/tests/test_client_contract.py:generic-api-key:327 +6a8fd86f9bbcc303a25b1081428dca7e76a14674:integrations/framework_integrations/tests/test_client_contract.py:generic-api-key:329 +46f29c640d11981429609766c3f8067ec5245111:sdk/python/tests/test_idempotency_keys.py:generic-api-key:166 +46f29c640d11981429609766c3f8067ec5245111:sdk/python/tests/test_idempotency_keys.py:generic-api-key:174 +8eaae14c6ca41a3f2e55e5d0e38fb3e48d73ae15:spec/test-vectors/generate.py:generic-api-key:169 +923cc826add7856cb81a632d23ba99d85c067559:spec/test-vectors/generate.py:generic-api-key:349 +923cc826add7856cb81a632d23ba99d85c067559:spec/test-vectors/generate.py:generic-api-key:378 +e1abe47d7987cc9a4e9521faeebd6becc9d59504:spec/test-vectors/generate.py:generic-api-key:350 +e1abe47d7987cc9a4e9521faeebd6becc9d59504:spec/test-vectors/generate.py:generic-api-key:379 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3270705f..1a4afb10 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,3 +20,25 @@ repos: - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: f12be1ebaa5351c1fc76472de98db2c3446c8253 # frozen: v0.16.10 + hooks: + - id: ruff-check + - id: ruff-format + args: ["--check"] + + - repo: local + hooks: + - id: mypy + name: mypy + entry: >- + uv run --directory gateway --no-project --isolated --no-env-file + --with-requirements requirements.txt + --with-requirements requirements-dev.txt + --with-editable "../sdk/python[verify]" + python -m mypy --config-file mypy.ini . + language: system + files: ^(gateway/(app|typecheck)/.*\.py|gateway/(mypy\.ini|requirements(-dev)?\.txt|pyproject\.toml)|sdk/python/(src/.*|pyproject\.toml)|\.pre-commit-config\.yaml)$ + pass_filenames: false + require_serial: true diff --git a/BREAK-REPORT.md b/BREAK-REPORT.md new file mode 100644 index 00000000..ea5c778c --- /dev/null +++ b/BREAK-REPORT.md @@ -0,0 +1,184 @@ +# Break Report: gateway/failure_lab (Oct 8, 2026) + +## Summary + +Probed the fail-closed surfaces of `gateway/failure_lab` with 8 adversarial +probes (bad observation counts, mismatched observations, malformed operation +identifiers, oversized bodies, type coercions). Found 5 breaks. Fixed 4 with +root-cause patches plus regression tests, all passing. Fixed but failing: 0. +Blocked / left open: 1 (observation binding without identifiers, needs a +contract decision, see finding 5). + +All work is local only. No production config, secrets, migrations, or network +calls were involved. No database needed: every fix is in pure functions +covered by unit tests. + +## Findings (by severity) + +### 1. High: a boolean `True` observation established downstream execution + +What broke: `verify()` with +`downstream_observation={"executions": True, ...}` reported +`DOWNSTREAM_EXECUTION_ESTABLISHED`. In Python `bool` is a subclass of `int`, +so the `isinstance(executions, int)` guard accepted it and `True > 0` +counted as one execution. + +How to reproduce: sign any receipt, call +`verify(bundle, keys, downstream_observation={"executions": True, ...})`, +read the downstream claim. + +Root cause: `gateway/failure_lab/verifier.py`, `_downstream_claim` +(execution-count validation, formerly around line 378). + +Fix: reject booleans explicitly (`isinstance(executions, bool)` fails the +observation as unusable, status NOT_ESTABLISHED). Same for any non-int type. + +Test results: new test `test_a_boolean_is_not_an_execution_count` +(parametrized True/False) in +`gateway/tests/test_failure_lab_verifier.py`. Confirmed it fails on pre-fix +code (old code returned ESTABLISHED for True) via a read-only +`git show HEAD:` comparison script. Passes on fixed code. + +### 2. Medium: an impossible negative execution count was reported as consistent + +What broke: `{"executions": -4}` with a non-success outcome returned +NOT_ESTABLISHED with reason text calling the observation "consistent with" +the outcome. Minus four executions cannot happen, so blessing it as +consistent is a false clean bill for the instrument. + +How to reproduce: `verify()` with `executions=-4`, outcome +`failed_refunded` or `delivery_uncertain`, read the reason string. + +Root cause: same function as finding 1, no lower-bound validation. + +Fix: negative counts return NOT_ESTABLISHED with reason "the supplied +observation reported an impossible execution count". An impossible reading +is an instrument error, not evidence for or against execution. + +Test results: new test +`test_a_negative_count_is_an_unusable_observation_not_consistency`. +Confirmed old code emitted "consistent" wording; passes on fixed code. + +### 3. Medium: an observation naming a different receipt established execution + +What broke: `verify()` never compared any identifier between the +observation and the signed payload, so an observation collected for receipt +B (with executions > 0) marked receipt A as executed. + +How to reproduce: verify receipt A with an observation carrying +`receipt_id` of receipt B and `executions=1`. Old result: ESTABLISHED. + +Root cause: same function; the documented observation shape carries +`operation_id` but the signed payload has no operation field to compare, so +nothing was ever cross-checked. + +Fix: when the observation carries a non-empty string `receipt_id` and the +signed payload names a different one, the claim FAILS with a reason naming +both receipts. Matching or absent identifiers behave exactly as before, so +no existing caller (including scenario T10, whose observation carries no +receipt_id) changes status. The `verify()` docstring now documents the +binding rule and recommends callers always bind observations. + +Test results: new tests `test_an_observation_of_another_receipt_fails_the_claim` +and `test_an_observation_bound_to_this_receipt_still_establishes`. +Confirmed old code returned ESTABLISHED for the mismatch; both pass fixed. + +### 4. Medium: non-string operation ids were coerced into plausible identifiers + +What broke: `classify_request()` applied `str()` to whatever the request +carried as `operation_id`, so a dict, number, or bool became a +plausible-looking string (for example `"{'nested': True}"`). That value +matches no fault plan, so an injected fault would silently never fire while +the crossing record reads as normally observed. + +How to reproduce: feed a `tools/call` (or `/refunds`) body with +`"operation_id": {"nested": true}` to `classify_request`. Old result: +`(kind, "{'nested': True}")`. + +Root cause: `gateway/failure_lab/faults.py`, `classify_request`, both the +MCP and REST branches. + +Fix: accept the value only when it is already a string, else record `None` +(visibly unidentified). The real gateway only ever sends strings +(`StrictStr` in the refund tool), so no legitimate traffic changes. + +Test results: new `gateway/tests/test_failure_lab_failclosed.py` with +parametrized bad-type cases for both paths plus a positive control. +Confirmed old code coerced; passes fixed. + +### 5. Low (open, needs a decision): observations without identifiers are taken on trust + +What did not break but is weak by design: an observation carrying no +`receipt_id` (the documented shape has only executions, operation_id, +source) is assumed to describe the receipt being verified. Finding 3 closes +the case where binding info is present but ignored; it cannot close the +case where no binding info exists, because the signed payload carries no +operation identifier to compare against (only receipt_id, request_hash, and +dispatch linkage). Closing it fully means adding `receipt_id` (or +`request_hash`) to the observation contract and threading it through every +scenario that builds one, starting with T10. + +No code changed for this. Left as an open question for C.Lee below. + +### 6. Low: oversized fault-layer bodies raised instead of refusing cleanly + +What broke: a POST body over the 1 MiB buffer raised raw `ValueError` out +of `FaultInjectionMiddleware`, surfacing to the gateway as an +unclassifiable transport error instead of a clean refusal. The tool was +never touched (fail-closed in effect), but the failure mode was a crash, +not a refusal, and nothing recorded the refusal. + +How to reproduce: POST more than `_MAX_BODY_BYTES` to `/mcp` through +`FaultInjectionMiddleware`. Old behavior: `ValueError` propagates. + +Root cause: `gateway/failure_lab/faults.py`, +`FaultInjectionMiddleware.__call__` called `_read_body` unguarded. + +Fix: catch `ValueError`, record a crossing (`fault="oversized_body_rejected"`, +`reached_tool=False`, `delivered=True`, kind `unclassified` so it never +pollutes dispatch counts), and answer 413 with a JSON error. This mirrors +the diagnostic server, which already refuses oversized submissions with 413 +(see `test_an_oversized_submission_is_refused`). + +Test results: new test +`test_oversized_body_is_refused_as_413_without_reaching_the_tool` in the new +file. Confirmed old code raised `ValueError`; passes fixed. + +## What was probed and held + +- Float (`1.0`) and string (`"1"`) execution counts: already rejected. +- `False` count on a success receipt: already FAILED the claim correctly. +- Unsigned envelope edits: still verify, and are reported as disagreements. +- Envelope `kid` relabelling: cannot steer key selection (signed bytes win). +- Unknown signed `kid`, disabled keys, key-length checks: all fail closed. +- Redaction and secret plumbing (`secret_values` carries admin key, bearer, + control token plus environment secrets): complete, no change needed. +- Retired signing keys staying published: deliberate (historical receipts + stay verifiable), not a bug. +- Scenario verdict logic (T02, T09, T10): guards against empty probe loops + and ledger reconciliation already present, no change needed. + +## Test summary + +Commands run from `gateway/` through the slot queue: + +- `python -m pytest tests/test_failure_lab_verifier.py tests/test_failure_lab_failclosed.py -q`: 27 passed +- `python -m pytest ...verifier ...failclosed ...evidence ...report ...claims ...harness ...portable_keys -q`: 90 passed +- `python -m pytest ...verifier ...failclosed ...scenarios -q`: 86 passed +- `ruff check failure_lab/ tests/test_failure_lab_failclosed.py tests/test_failure_lab_verifier.py`: all checks passed +- `ruff format` applied to `failure_lab/faults.py`; all touched files format-clean +- No-fix confirmation: new assertions executed against pre-fix modules + extracted read-only via `git show HEAD:`; all 5 failed there and pass + with the fix. + +Full gateway suite was not run (time and shared-machine load); scenario +integration runs needing the app and databases were not run beyond the +`test_failure_lab_scenarios` file, which passed. + +## Open questions + +1. Should the downstream-observation contract require `receipt_id` + (finding 5), with T10 and any other builders updated to supply it? + Recommend yes, as a follow-up task. +2. No migration, no config, no API changes in this work. Nothing needs a + decision to land it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a527449..68892683 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Python 3.11+, [`uv`](https://docs.astral.sh/uv/) (or pip), `make`, and Node 22.1 git clone https://github.com/PetrefiedThunder/agent-middleware.git cd agent-middleware python -m venv .venv && . .venv/bin/activate -pip install -r gateway/requirements.txt -e "./sdk/python[verify]" pytest pytest-xdist pytest-timeout +pip install -r gateway/requirements.txt -r gateway/requirements-dev.txt -e "./sdk/python[verify]" pytest pytest-xdist pytest-timeout # gateway (FSL): full suite, about a minute in parallel cd gateway && pytest tests -m "not production_trust" -n auto && cd .. @@ -36,6 +36,24 @@ python verifier/check_vectors.py exercise the invariants end to end. Never commit `.env` files, keys, or local databases; `pre-commit install` enables the secret scan locally. +## Lint and type-check versions + +Local lint and type-check must use the blessed versions in +`gateway/requirements-dev.txt`. CI installs those same versions +(`.github/workflows/ci.yml`): + +- ruff `0.16.10` (`RUFF_VERSION`) +- mypy `2.4.0` (`MYPY_VERSION`) + +`gateway/requirements.txt` pins mypy to `2.4.0` as well, so an application +install cannot float the type checker. Bump the dev lock, that requirements +pin, and the workflow env vars in the same change. `gateway/scripts/core_quality_gate.sh` +runs `ruff check .` and `mypy app/` with those pins. + +The pre-commit mypy hook needs `uv`. Its first run needs package registry access +to build an isolated environment from the gateway requirements and this checkout's +editable Python SDK; later runs can use the uv cache. + ## Developer Certificate of Origin AMW uses the Developer Certificate of Origin, version 1.1. We do not require a Contributor License Agreement. By contributing, you certify that you have the right to submit the work under the applicable license and that you agree to the DCO. diff --git a/QUICKSTART.md b/QUICKSTART.md index 8ef63957..e9d14e6a 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -49,10 +49,16 @@ uv run --with "mcp>=1.29,<2" python ../examples/mcp-client/call_with_retry.py ``` ```text +idempotency key: mcp-example-... first receipt: rcpt-... retry receipt: rcpt-... <- the same id: no second dispatch, no second debit ``` +If the script stops after printing the key, run it again with +`AMW_IDEMPOTENCY_KEY` set to that value. A new key would be a second charge. +The `mcp.json` config does not set an idempotency key: a connection header +would pin one key onto every tool call. + Check the effect count yourself: `wc -l data/dogfood_partner_notes.jsonl` grew by one, not two. diff --git a/examples/README.md b/examples/README.md index 77a71a6c..e27af0de 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,44 @@ not the supported design-partner deployment path. ## Available Examples +### `mcp-client/call_with_retry.py` — Golden-path idempotent retry demo + +This is the one demo to show a buyer next to `make quickstart`: call a +governed tool through the standard MCP endpoint, then retry with the same +idempotency key. The gateway returns the original signed receipt instead of +dispatching or charging again. + +**Run:** + +```bash +# From gateway/, after `make quickstart` and the key mint in QUICKSTART.md: +AGENT_API_KEY= uv run --with "mcp>=1.29,<2" python ../examples/mcp-client/call_with_retry.py +``` + +Optional overrides: `AMW_MCP_URL` (default `http://127.0.0.1:8000/mcp`) and +`AMW_TOOL` (default `partner.notes.write`). + +**Output shape** (both receipt ids match; a real id is `rcpt-` plus 16 hex characters): + +```text +first receipt: rcpt-9f2c41a7b6e04d21 outcome: success +retry receipt: rcpt-9f2c41a7b6e04d21 outcome: success +``` + +An illustrative copy of that shape lives in +[`mcp-client/expected_output.txt`](mcp-client/expected_output.txt). +`uv run --with "mcp>=1.29,<2"` supplies the MCP SDK for that one command. +`outcome: success` is the receipt field the gateway writes on a successful call. + +### `mcp-client/mcp.json` — local client config template + +Copy-paste starting point for connecting a standard MCP client to a local +gateway. It is a template, not a working buyer config: replace +`${AGENT_API_KEY}` with a real wallet-scoped key (and set a fresh +`${AMW_IDEMPOTENCY_KEY}` per call), or export both variables if your client +expands environment placeholders. The URL points at a local gateway +(`http://127.0.0.1:8000/mcp`); point it at your deploy for anything else. + ### `dry_run_example.py` — Legacy Billing Simulation Demonstrates safe cost estimation without affecting real wallet balances. @@ -85,6 +123,19 @@ python examples/mcp_tool_example.py --serve --- +### `acta_receipt_interop.py` — Research experiment (not a product path) + +Executable research (see the module docstring for the research question): +a signed portable receipt can be transcoded into the ACTA +envelope (an individual Internet-Draft with no IETF standing) without +changing the trust plane. It is not a product surface, route, or SDK +export, the outer signature uses a throwaway generated key, and the demo +entry point reads the API repo's `site/proof/` artifacts, which are not in +this checkout. Do not show it as a buyer integration. Covered by +`gateway/tests/test_acta_receipt_interop.py`. + +--- + ## Troubleshooting | Symptom | Likely Cause | Fix | diff --git a/examples/dry_run_example.py b/examples/dry_run_example.py index 8488aa59..a97a9992 100644 --- a/examples/dry_run_example.py +++ b/examples/dry_run_example.py @@ -18,19 +18,15 @@ import asyncio import os -import sys - -# The SDK sources live under b2a_sdk/src; inserting the repo root instead makes -# "b2a_sdk" resolve to the bare directory and the import fails. -sys.path.insert( - 0, - os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "b2a_sdk", "src" - ), -) -from b2a_sdk import AgentMiddlewareClient -from b2a_sdk.decorators import billable +try: + from b2a_sdk import AgentMiddlewareClient + from b2a_sdk.decorators import billable +except ImportError as exc: + raise SystemExit( + "The b2a_sdk package is not installed. From the repo root run: " + "python -m pip install -e './sdk/python[dev]'" + ) from exc b2a = AgentMiddlewareClient( diff --git a/examples/mcp-client/call_with_retry.py b/examples/mcp-client/call_with_retry.py index e93a2433..a68eaf87 100644 --- a/examples/mcp-client/call_with_retry.py +++ b/examples/mcp-client/call_with_retry.py @@ -6,8 +6,18 @@ AGENT_API_KEY=... python examples/mcp-client/call_with_retry.py -The second call reuses the idempotency key, so the gateway returns the original -receipt instead of dispatching or charging again. +The script prints its idempotency key before the first call. The second call +sends that same key, so the gateway returns the original receipt instead of +dispatching or charging again. If the process dies after the first call, rerun +with that printed value in AMW_IDEMPOTENCY_KEY. A new process with no saved key +is a new charged call. + +examples/mcp-client/mcp.json only sends the API key. A connection header would +attach one idempotency key to every tool call, so the second distinct action +would replay the first or be refused. + +The process exits with an error when either call failed, when a receipt id is +missing, or when the two receipt ids differ. Licensed under Apache-2.0. """ @@ -16,7 +26,9 @@ import asyncio import os +import re import uuid +from collections.abc import Mapping from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client @@ -24,37 +36,182 @@ URL = os.environ.get("AMW_MCP_URL", "http://127.0.0.1:8000/mcp") TOOL = os.environ.get("AMW_TOOL", "partner.notes.write") +RECEIPT_META_KEY = "io.agentmiddleware/receipt" +# Same bound the gateway stores in idempotency_records.idempotency_key. +_MAX_IDEMPOTENCY_KEY_LENGTH = 128 +# Same minimum the gateway applies before it will look up an API key. +_MIN_API_KEY_LENGTH = 8 +_CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]") +_NOTE = {"text": "hello from an MCP client"} + + +def require_api_key(environ: Mapping[str, str]) -> str: + """Return AGENT_API_KEY, or stop before any request is sent.""" + api_key = environ.get("AGENT_API_KEY") + if ( + isinstance(api_key, str) + and _CONTROL_CHARACTERS.search(api_key) is None + and len(api_key.strip()) >= _MIN_API_KEY_LENGTH + ): + return api_key + raise SystemExit( + "AGENT_API_KEY is missing, shorter than 8 characters, or contains a " + "control character. Mint a wallet-scoped key as shown in QUICKSTART.md." + ) + + +def _validate_idempotency_key(key: object) -> str: + if not isinstance(key, str) or key.strip() == "": + raise SystemExit( + "The idempotency key is blank. Set a non-blank key of at most " + "128 characters, or unset AMW_IDEMPOTENCY_KEY to mint one." + ) + if len(key) > _MAX_IDEMPOTENCY_KEY_LENGTH: + raise SystemExit( + "The idempotency key is longer than 128 characters. The gateway " + "refuses that key before any charge." + ) + if _CONTROL_CHARACTERS.search(key): + raise SystemExit( + "The idempotency key contains a control character. The gateway " + "refuses that key before any charge." + ) + return key + + +def idempotency_key_for_run(environ: Mapping[str, str]) -> str: + """Reuse AMW_IDEMPOTENCY_KEY when set, otherwise mint one key for this run.""" + if "AMW_IDEMPOTENCY_KEY" not in environ: + return f"mcp-example-{uuid.uuid4()}" + return _validate_idempotency_key(environ["AMW_IDEMPOTENCY_KEY"]) + + +def _receipt_copies(result: dict) -> list[dict] | None: + """Receipt objects from meta, _meta, and the top-level receipt field. + + None means the payload is malformed. Callers must not pick a receipt id + out of it. An empty list means no receipt was present. + """ + if not isinstance(result, dict): + return None + copies: list[dict] = [] + for key in ("meta", "_meta"): + container = result.get(key) + if container is None: + continue + if not isinstance(container, dict): + return None + if RECEIPT_META_KEY not in container: + continue + receipt = container[RECEIPT_META_KEY] + if not isinstance(receipt, dict): + return None + copies.append(receipt) + if "receipt" in result: + top = result["receipt"] + if not isinstance(top, dict): + return None + copies.append(top) + return copies + + +def _agreed_receipt(result: dict) -> dict | None: + """The one receipt whose copies all name the same id and outcome.""" + copies = _receipt_copies(result) + if not copies: + return None + ids: list[str] = [] + outcomes: list[str] = [] + for receipt in copies: + receipt_value = receipt.get("receipt_id") + if not isinstance(receipt_value, str) or receipt_value.strip() == "": + return None + ids.append(receipt_value) + outcome = receipt.get("outcome") + if not isinstance(outcome, str) or outcome.strip() == "": + return None + outcomes.append(outcome) + if len(set(ids)) != 1 or len(set(outcomes)) > 1: + return None + return copies[0] -async def call_once(api_key: str, idempotency_key: str) -> dict: - headers = {"X-API-Key": api_key, "Idempotency-Key": idempotency_key} - async with streamablehttp_client(URL, headers=headers) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(TOOL, {"text": "hello from an MCP client"}) - return result.model_dump(mode="json") +def receipt_id(result: dict) -> str | None: + """The gateway receipt id, when every copy of the receipt agrees.""" + receipt = _agreed_receipt(result) + if receipt is None: + return None + value = receipt.get("receipt_id") + if not isinstance(value, str) or value.strip() == "": + return None + return value -RECEIPT_META_KEY = "io.agentmiddleware/receipt" +def _outcome(result: dict) -> str | None: + receipt = _agreed_receipt(result) + if receipt is None: + return None + value = receipt.get("outcome") + if not isinstance(value, str) or value.strip() == "": + return None + return value + + +def retry_failure(first: dict, retry: dict) -> str | None: + """Why this pair is not a successful replay, or None when it is.""" + if not isinstance(first, dict) or not isinstance(retry, dict): + return "the tool result was not an object, so the retry cannot be checked" + if first.get("isError") or retry.get("isError"): + return ( + "the tool call returned an error, so this run did not show a " + "successful governed call" + ) + first_id = receipt_id(first) + retry_id = receipt_id(retry) + if not first_id or not retry_id: + return ( + "a signed receipt id was missing or the receipt copies disagreed, " + "so the retry cannot be checked" + ) + if first_id != retry_id: + return ( + "the retry returned a different receipt id, so this was not a " + "replay of the first call" + ) + first_outcome = _outcome(first) + retry_outcome = _outcome(retry) + if first_outcome != retry_outcome: + return "the two calls report different outcomes for the same receipt id" + if first_outcome != "success": + return "the receipt outcome was not success, so this was not a successful governed call" + return None -def receipt_id(result: dict) -> str | None: - """The gateway returns the signed receipt in the tool result's _meta.""" - receipt = (result.get("meta") or result.get("_meta") or {}).get(RECEIPT_META_KEY) or {} - return receipt.get("receipt_id") + +async def call_once(api_key: str, idempotency_key: str) -> dict: + require_api_key({"AGENT_API_KEY": api_key}) + checked_key = _validate_idempotency_key(idempotency_key) + headers = {"X-API-Key": api_key, "Idempotency-Key": checked_key} + async with ( + streamablehttp_client(URL, headers=headers) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + result = await session.call_tool(TOOL, dict(_NOTE)) + return result.model_dump(mode="json", by_alias=True) async def main() -> None: - api_key = os.environ["AGENT_API_KEY"] - key = f"mcp-example-{uuid.uuid4()}" + api_key = require_api_key(os.environ) + key = idempotency_key_for_run(os.environ) + # Printed before the call so a lost response can be retried with the same key. + print("idempotency key:", key, flush=True) first = await call_once(api_key, key) - retry = await call_once(api_key, key) # same key: same receipt, no second charge - print("first receipt:", receipt_id(first), "outcome:", _outcome(first)) - print("retry receipt:", receipt_id(retry), "outcome:", _outcome(retry)) - - -def _outcome(result: dict) -> str | None: - receipt = (result.get("meta") or result.get("_meta") or {}).get(RECEIPT_META_KEY) or {} - return receipt.get("outcome") + retry = await call_once(api_key, key) + problem = retry_failure(first, retry) + if problem is not None: + raise SystemExit(problem) + print("first receipt:", receipt_id(first), "outcome:", _outcome(first), flush=True) + print("retry receipt:", receipt_id(retry), "outcome:", _outcome(retry), flush=True) if __name__ == "__main__": diff --git a/examples/mcp-client/expected_output.txt b/examples/mcp-client/expected_output.txt new file mode 100644 index 00000000..cb88f6c2 --- /dev/null +++ b/examples/mcp-client/expected_output.txt @@ -0,0 +1,8 @@ +# Illustrative output shape for examples/mcp-client/call_with_retry.py. +# Not a captured gateway run. Real receipt ids are rcpt- plus 16 hex +# characters. Local gateway (make quickstart), tool partner.notes.write, +# same idempotency key on both calls. The retry returns the original +# signed receipt: both receipt ids match. +idempotency key: mcp-example-... +first receipt: rcpt-9f2c41a7b6e04d21 outcome: success +retry receipt: rcpt-9f2c41a7b6e04d21 outcome: success diff --git a/examples/mcp-client/mcp.json b/examples/mcp-client/mcp.json index b6313c1b..cb1fcebb 100644 --- a/examples/mcp-client/mcp.json +++ b/examples/mcp-client/mcp.json @@ -4,8 +4,7 @@ "type": "http", "url": "http://127.0.0.1:8000/mcp", "headers": { - "X-API-Key": "${AGENT_API_KEY}", - "Idempotency-Key": "${AMW_IDEMPOTENCY_KEY}" + "X-API-Key": "${AGENT_API_KEY}" } } } diff --git a/examples/mcp-client/test_call_with_retry.py b/examples/mcp-client/test_call_with_retry.py new file mode 100644 index 00000000..b443c775 --- /dev/null +++ b/examples/mcp-client/test_call_with_retry.py @@ -0,0 +1,523 @@ +"""Behavior tests for the standard MCP retry example. + +The example is a client, not the gateway. These tests stay offline: they check +the config, the receipt reader, and the retry decision. They do not open a +socket and they do not read a database URL. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +import sys +from pathlib import Path + +import pytest +from mcp.types import CallToolResult +from typing_extensions import Self + +HERE = Path(__file__).resolve().parent +MODULE_PATH = HERE / "call_with_retry.py" +CONFIG_PATH = HERE / "mcp.json" + +_spec = importlib.util.spec_from_file_location( + "mcp_client_call_with_retry", MODULE_PATH +) +assert _spec is not None and _spec.loader is not None +client = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = client +_spec.loader.exec_module(client) + +API_KEY = "amw_dev_example_key" +RECEIPT_KEY = "io.agentmiddleware/receipt" + + +def _receipt(receipt_id: str, outcome: str = "success") -> dict: + return {"receipt_id": receipt_id, "outcome": outcome} + + +def _sdk_result( + receipt_id: str, *, outcome: str = "success", is_error: bool = False +) -> dict: + """Shape produced by CallToolResult.model_dump (field name meta, plus receipt).""" + receipt = _receipt(receipt_id, outcome) + return { + "meta": {RECEIPT_KEY: dict(receipt)}, + "receipt": dict(receipt), + "isError": is_error, + "content": [], + } + + +def _wire_result(receipt_id: str, *, outcome: str = "success") -> dict: + """Shape of the JSON result body on POST /mcp (alias _meta, plus receipt).""" + receipt = _receipt(receipt_id, outcome) + return { + "_meta": {RECEIPT_KEY: dict(receipt)}, + "receipt": dict(receipt), + "isError": False, + "content": [], + } + + +def test_client_config_authenticates_without_pinning_one_action() -> None: + """A connection header is sent on every request, including every tool call. + + One Idempotency-Key there makes the second distinct action a replay or a + conflict, and an unset placeholder becomes a blank key the gateway refuses + before any effect. The API key stays. The retry script supplies a per-run key. + """ + config = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + server = config["mcpServers"]["agent-middleware"] + assert server["type"] == "http" + assert server["url"] == "http://127.0.0.1:8000/mcp" + headers = server["headers"] + assert headers["X-API-Key"] == "${AGENT_API_KEY}" + folded = {name.lower(): value for name, value in headers.items()} + assert "idempotency-key" not in folded + assert "idempotency" not in json.dumps(headers).lower() + + +def test_receipt_id_reads_sdk_dump_and_wire_result() -> None: + assert client.receipt_id(_sdk_result("rcpt-sdk")) == "rcpt-sdk" + assert client._outcome(_sdk_result("rcpt-sdk")) == "success" + assert client.receipt_id(_wire_result("rcpt-wire")) == "rcpt-wire" + assert client._outcome(_wire_result("rcpt-wire", outcome="denied")) == "denied" + + +def test_receipt_reader_accepts_official_mcp_result_dump() -> None: + receipt = _receipt("rcpt-sdk") + result = CallToolResult.model_validate( + { + "content": [], + "isError": False, + "_meta": {RECEIPT_KEY: receipt}, + "receipt": receipt, + } + ).model_dump(mode="json") + assert client.retry_failure(result, result) is None + + +def test_receipt_id_reads_top_level_receipt_when_meta_is_absent() -> None: + result = {"isError": False, "receipt": _receipt("rcpt-top")} + assert client.receipt_id(result) == "rcpt-top" + assert client._outcome(result) == "success" + + +def test_receipt_reader_does_not_crash_on_a_non_dict_receipt() -> None: + for result in ( + {"_meta": {RECEIPT_KEY: "rcpt-not-an-object"}, "isError": False}, + {"meta": "nope", "isError": False}, + {"receipt": "rcpt-not-an-object", "isError": False}, + ): + assert client.receipt_id(result) is None + assert client._outcome(result) is None + + +def test_receipt_reader_refuses_disagreeing_copies() -> None: + disagreed_ids = { + "meta": {RECEIPT_KEY: _receipt("rcpt-a")}, + "_meta": {RECEIPT_KEY: _receipt("rcpt-b")}, + } + assert client.receipt_id(disagreed_ids) is None + + disagreed_outcomes = { + "meta": {RECEIPT_KEY: _receipt("rcpt-1", "success")}, + "receipt": _receipt("rcpt-1", "denied"), + } + assert client.receipt_id(disagreed_outcomes) is None + assert client._outcome(disagreed_outcomes) is None + + missing_outcome = { + "meta": {RECEIPT_KEY: _receipt("rcpt-1")}, + "receipt": {"receipt_id": "rcpt-1"}, + } + assert client.receipt_id(missing_outcome) is None + assert client._outcome(missing_outcome) is None + + +def test_matching_copies_still_return_the_one_receipt_id() -> None: + result = { + "meta": {RECEIPT_KEY: _receipt("rcpt-same")}, + "_meta": {RECEIPT_KEY: _receipt("rcpt-same")}, + "receipt": _receipt("rcpt-same"), + } + assert client.receipt_id(result) == "rcpt-same" + assert client._outcome(result) == "success" + + +class _Session: + def __init__(self, payload: dict, seen: dict) -> None: + self._payload = payload + self._seen = seen + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *exc: object) -> bool: + return False + + async def initialize(self) -> None: + self._seen["initialized"] = True + + async def call_tool(self, name: str, arguments: dict) -> CallToolResult: + self._seen["tool"] = name + self._seen["arguments"] = arguments + return CallToolResult.model_validate(self._payload) + + +def test_call_once_sends_key_headers_and_does_not_put_the_secret_in_the_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict = {} + + class _Client: + def __init__(self, url: str, headers: dict | None = None) -> None: + seen["url"] = url + seen["headers"] = dict(headers or {}) + + async def __aenter__(self) -> tuple: + return (None, None, None) + + async def __aexit__(self, *exc: object) -> bool: + return False + + monkeypatch.setattr(client, "streamablehttp_client", _Client) + monkeypatch.setattr( + client, + "ClientSession", + lambda read, write: _Session(_sdk_result("rcpt-1"), seen), + ) + + payload = asyncio.run(client.call_once(API_KEY, "retry-key-1")) + + assert payload["isError"] is False + assert seen["initialized"] is True + assert seen["url"] == client.URL + assert API_KEY not in seen["url"] + assert seen["headers"] == { + "X-API-Key": API_KEY, + "Idempotency-Key": "retry-key-1", + } + assert seen["tool"] == client.TOOL + assert seen["arguments"] == {"text": "hello from an MCP client"} + + +@pytest.mark.parametrize( + "actual_receipt", + [ + _receipt("rcpt-other"), + _receipt("rcpt-good", "denied"), + {"receipt_id": "rcpt-good"}, + "malformed", + ], + ids=["conflicting-id", "denied", "missing-outcome", "malformed"], +) +def test_call_once_keeps_wire_meta_when_an_extra_meta_claims_success( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + actual_receipt: dict | str, +) -> None: + """The MCP model must not let an extra meta overwrite the wire _meta.""" + good = _receipt("rcpt-good") + wire = { + "content": [], + "isError": False, + "_meta": {RECEIPT_KEY: actual_receipt}, + "meta": {RECEIPT_KEY: good}, + "receipt": good, + } + + class _Client: + async def __aenter__(self) -> tuple: + return (None, None, None) + + async def __aexit__(self, *exc: object) -> bool: + return False + + monkeypatch.setattr( + client, "streamablehttp_client", lambda *args, **kwargs: _Client() + ) + monkeypatch.setattr(client, "ClientSession", lambda read, write: _Session(wire, {})) + + result = asyncio.run(client.call_once(API_KEY, "retry-key-1")) + assert result["_meta"][RECEIPT_KEY] == actual_receipt + assert client.retry_failure(result, result) is not None + + _patch_main(monkeypatch, [result, result]) + with pytest.raises(SystemExit): + asyncio.run(client.main()) + assert "receipt:" not in capsys.readouterr().out + + +def test_call_once_refuses_a_blank_or_short_api_key_before_connecting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _boom(*args: object, **kwargs: object) -> None: + raise AssertionError("client connected") + + monkeypatch.setattr(client, "streamablehttp_client", _boom) + for api_key in ("", " ", "short", "bad\nkey-value"): + with pytest.raises(SystemExit): + asyncio.run(client.call_once(api_key, "retry-key-1")) + + +def test_call_once_refuses_an_unusable_idempotency_key_before_connecting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _boom(*args: object, **kwargs: object) -> None: + raise AssertionError("client connected") + + monkeypatch.setattr(client, "streamablehttp_client", _boom) + for key in ("", " ", "x" * 129, "bad\nkey", "bad\x7fkey"): + with pytest.raises(SystemExit): + asyncio.run(client.call_once(API_KEY, key)) + + +def _patch_main( + monkeypatch: pytest.MonkeyPatch, + results: list[dict], +) -> list[str]: + keys: list[str] = [] + + async def _fake(api_key: str, idempotency_key: str) -> dict: + assert api_key == API_KEY + keys.append(idempotency_key) + return results[len(keys) - 1] + + monkeypatch.setattr(client, "call_once", _fake) + monkeypatch.setenv("AGENT_API_KEY", API_KEY) + return keys + + +def test_main_reuses_one_generated_key_and_prints_it_before_calling( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + printed_before_call: list[str] = [] + sent: list[str] = [] + + async def _fake(api_key: str, idempotency_key: str) -> dict: + printed_before_call.append(capsys.readouterr().out) + sent.append(idempotency_key) + return _sdk_result("rcpt-1") + + monkeypatch.setattr(client, "call_once", _fake) + monkeypatch.setenv("AGENT_API_KEY", API_KEY) + + asyncio.run(client.main()) + first_out = printed_before_call[0] + assert sent[0] == sent[1] + assert f"idempotency key: {sent[0]}" in first_out + assert API_KEY not in first_out + assert API_KEY not in capsys.readouterr().out + + +def test_main_uses_the_env_key_for_both_calls( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + keys = _patch_main( + monkeypatch, + [_sdk_result("rcpt-1"), _sdk_result("rcpt-1")], + ) + monkeypatch.setenv("AMW_IDEMPOTENCY_KEY", "kept-for-a-lost-response") + asyncio.run(client.main()) + assert keys == ["kept-for-a-lost-response", "kept-for-a-lost-response"] + output = capsys.readouterr().out + assert "idempotency key: kept-for-a-lost-response" in output + assert "first receipt: rcpt-1 outcome: success" in output + assert "retry receipt: rcpt-1 outcome: success" in output + + +def test_main_mints_a_fresh_key_per_process_when_env_is_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + first = _patch_main(monkeypatch, [_sdk_result("rcpt-1"), _sdk_result("rcpt-1")]) + asyncio.run(client.main()) + second = _patch_main(monkeypatch, [_sdk_result("rcpt-2"), _sdk_result("rcpt-2")]) + asyncio.run(client.main()) + assert first[0] == first[1] + assert second[0] == second[1] + assert first[0] != second[0] + assert first[0].startswith("mcp-example-") + assert len(first[0]) <= 128 + + +def test_main_exits_when_the_retry_is_not_the_same_receipt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_main(monkeypatch, [_sdk_result("rcpt-1"), _sdk_result("rcpt-2")]) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(SystemExit) as raised: + asyncio.run(client.main()) + assert raised.value.code not in (0, None) + assert "different receipt" in str(raised.value) + + +def test_main_exits_when_only_the_retry_failed(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_main( + monkeypatch, + [_sdk_result("rcpt-1"), _sdk_result("rcpt-1", is_error=True)], + ) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(SystemExit) as raised: + asyncio.run(client.main()) + assert "error" in str(raised.value).lower() + + +def test_main_exits_when_outcomes_differ(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_main( + monkeypatch, + [ + _sdk_result("rcpt-1", outcome="success"), + _sdk_result("rcpt-1", outcome="denied"), + ], + ) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(SystemExit) as raised: + asyncio.run(client.main()) + assert "outcome" in str(raised.value).lower() + + +def test_main_exits_when_the_tool_call_failed(monkeypatch: pytest.MonkeyPatch) -> None: + failed = _sdk_result("rcpt-1", is_error=True) + _patch_main(monkeypatch, [failed, failed]) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(SystemExit) as raised: + asyncio.run(client.main()) + assert "error" in str(raised.value).lower() + + +def test_main_exits_when_the_receipt_id_is_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + empty = {"meta": {}, "isError": False, "content": []} + _patch_main(monkeypatch, [empty, empty]) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(SystemExit) as raised: + asyncio.run(client.main()) + assert "receipt" in str(raised.value).lower() + + +@pytest.mark.parametrize( + "result", + [ + _sdk_result("rcpt-1", outcome="denied"), + {"meta": {RECEIPT_KEY: {"receipt_id": "rcpt-1"}}, "isError": False}, + _sdk_result(" "), + ], + ids=["denied", "missing-outcome", "blank-receipt-id"], +) +def test_main_refuses_matching_receipts_without_successful_nonblank_claims( + monkeypatch: pytest.MonkeyPatch, result: dict +) -> None: + _patch_main(monkeypatch, [result, result]) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(SystemExit) as raised: + asyncio.run(client.main()) + assert raised.value.code not in (0, None) + + +def test_main_refuses_a_blank_api_key_without_calling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called = False + + async def _fake(api_key: str, idempotency_key: str) -> dict: + nonlocal called + called = True + return _sdk_result("rcpt-1") + + monkeypatch.setattr(client, "call_once", _fake) + monkeypatch.setenv("AGENT_API_KEY", " ") + with pytest.raises(SystemExit): + asyncio.run(client.main()) + assert called is False + + +def test_main_refuses_a_bad_saved_idempotency_key_without_calling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called = False + + async def _fake(api_key: str, idempotency_key: str) -> dict: + nonlocal called + called = True + return _sdk_result("rcpt-1") + + monkeypatch.setattr(client, "call_once", _fake) + monkeypatch.setenv("AGENT_API_KEY", API_KEY) + monkeypatch.setenv("AMW_IDEMPOTENCY_KEY", "bad\nkey") + with pytest.raises(SystemExit): + asyncio.run(client.main()) + assert called is False + + +@pytest.mark.parametrize( + "results", + [ + [_sdk_result("rcpt-1", outcome="denied")] * 2, + [{"meta": {RECEIPT_KEY: {"receipt_id": "rcpt-1"}}}] * 2, + [_sdk_result(" ")] * 2, + [_sdk_result("rcpt-1"), _sdk_result("rcpt-2")], + [{"meta": "malformed"}] * 2, + [ + { + "meta": {RECEIPT_KEY: _receipt("rcpt-1")}, + "receipt": {"receipt_id": "rcpt-1"}, + } + ] + * 2, + ], + ids=[ + "denied", + "missing-outcome", + "blank-id", + "changed-id", + "malformed", + "missing-copy-outcome", + ], +) +def test_failed_pair_does_not_print_a_successful_receipt( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + results: list[dict], +) -> None: + _patch_main(monkeypatch, results) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(SystemExit): + asyncio.run(client.main()) + output = capsys.readouterr().out + assert output.startswith("idempotency key: mcp-example-") + assert "receipt:" not in output + assert "outcome: success" not in output + + +@pytest.mark.parametrize("failure_call", [1, 2]) +def test_transport_failure_does_not_print_a_successful_receipt( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + failure_call: int, +) -> None: + calls = 0 + + async def _fake(api_key: str, idempotency_key: str) -> dict: + nonlocal calls + calls += 1 + if calls == failure_call: + raise OSError("transport failed") + return _sdk_result("rcpt-1") + + monkeypatch.setattr(client, "call_once", _fake) + monkeypatch.setenv("AGENT_API_KEY", API_KEY) + monkeypatch.delenv("AMW_IDEMPOTENCY_KEY", raising=False) + with pytest.raises(OSError, match="transport failed"): + asyncio.run(client.main()) + output = capsys.readouterr().out + assert output.startswith("idempotency key: mcp-example-") + assert "receipt:" not in output + assert "outcome: success" not in output diff --git a/examples/mcp_tool_example.py b/examples/mcp_tool_example.py index a6a8394a..f7929607 100644 --- a/examples/mcp_tool_example.py +++ b/examples/mcp_tool_example.py @@ -26,20 +26,16 @@ import asyncio import json import os -import sys - -# The SDK sources live under b2a_sdk/src; inserting the repo root instead makes -# "b2a_sdk" resolve to the bare directory and the import fails. -sys.path.insert( - 0, - os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "b2a_sdk", "src" - ), -) -from b2a_sdk import AgentMiddlewareClient -from b2a_sdk.decorators import mcp_tool, register_mcp_tool_callback -from b2a_sdk.mcp import generate_manifest, list_tools +try: + from b2a_sdk import AgentMiddlewareClient + from b2a_sdk.decorators import mcp_tool, register_mcp_tool_callback + from b2a_sdk.mcp import generate_manifest, list_tools +except ImportError as exc: + raise SystemExit( + "The b2a_sdk package is not installed. From the repo root run: " + "python -m pip install -e './sdk/python[dev]'" + ) from exc CLIENT = None diff --git a/gateway/.env.example b/gateway/.env.example index 0bc01e39..ee4112bb 100644 --- a/gateway/.env.example +++ b/gateway/.env.example @@ -25,8 +25,9 @@ PUBLIC_CONTACT_URL= # --- CORS --- # Comma-separated origins (e.g., https://app.example.com,https://dashboard.example.com) -# Use * for development only -CORS_ORIGINS=* +# Empty (the default) grants no cross-origin access. Use * for development only; +# production-like boots refuse to start with *. +CORS_ORIGINS= # --- Durable Runtime State --- # Options: postgres, redis, sqlite, memory, auto (default: auto) diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 86df7407..5a1b9e4d 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -2,20 +2,27 @@ FROM python:3.12-slim WORKDIR /app +# b2a_sdk is imported while app.main loads. The build context is the +# repository root (not this directory): sdk/python is not inside gateway/. +# docker build -f gateway/Dockerfile . +# The release stamp .build_commit_sha must sit at that context root. ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/sdk/python/src # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* -# Install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Install Python dependencies, then the in-repo SDK those imports require. +COPY sdk/python /sdk/python +COPY gateway/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt \ + && pip install --no-cache-dir /sdk/python # Copy application code -COPY . . +COPY gateway/ /app/ # Release contexts must carry a commit stamp generated from the exact Git tree. COPY .build_commit_sha /app/.build_commit_sha diff --git a/gateway/Dockerfile.dev b/gateway/Dockerfile.dev index 159dc475..cab33470 100644 --- a/gateway/Dockerfile.dev +++ b/gateway/Dockerfile.dev @@ -2,17 +2,23 @@ FROM python:3.12-slim WORKDIR /app +# b2a_sdk is imported while app.main loads. The build context is the +# repository root (not this directory). See docker-compose.yml. +# docker build -f gateway/Dockerfile.dev . ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/sdk/python/src RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY sdk/python /sdk/python +COPY gateway/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt \ + && pip install --no-cache-dir /sdk/python -COPY . . +COPY gateway/ /app/ RUN chmod +x scripts/docker_entrypoint.sh \ && groupadd --system app \ diff --git a/gateway/Makefile b/gateway/Makefile index b23ab3e1..5a025ae4 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -1,4 +1,4 @@ -.PHONY: quickstart quickstart-check live-loop-proof demo-ambiguous-retry demo-ambiguous-retry-check failure-lab failure-lab-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 check-doc-references trust-coverage-gate trust-release-gate trust-conformance-live adversarial-battery-live failure-lab-suite failure-lab-all failure-lab-evidence failure-lab-list failure-lab-explore failure-lab-integration-check failure-lab-diagnostic failure-lab-verify-bundle +.PHONY: quickstart quickstart-check live-loop-proof demo-ambiguous-retry demo-ambiguous-retry-check failure-lab failure-lab-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 check-doc-references check-railway-iac trust-coverage-gate trust-release-gate trust-conformance-live adversarial-battery-live failure-lab-suite failure-lab-all failure-lab-evidence failure-lab-list failure-lab-explore failure-lab-integration-check failure-lab-diagnostic failure-lab-verify-bundle @@ -28,8 +28,9 @@ live-loop-proof: # 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 + # test deps for the run, so `make test` works on a fresh checkout without a -# separate `pip install -r requirements.txt` (deps live in requirements.txt, -# not pyproject [project.dependencies]). +# separate `pip install -r requirements.txt` (runtime floors are declared in +# pyproject [project.dependencies]; requirements.txt mirrors them and stays +# the install target for Docker/CI/`--with-requirements` runs). test: uv run --with-requirements requirements.txt pytest tests/ -q -m "not proof" @@ -57,15 +58,18 @@ prove-trust-plane-postgres: prove-crash-recovery # recovery, and fail-closed manual review after an ambiguous side effect # (never an automatic redispatch). # -# Requires DATABASE_URL=postgresql+asyncpg://... pointing at a DEDICATED, -# EMPTY database already migrated to head, STATE_BACKEND=postgres, a local/test -# ENVIRONMENT, and explicit MCP_STRESS_DB_ISOLATED=1 acknowledgment. Provision +# Requires DATABASE_URL=postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_* +# pointing at a DEDICATED, EMPTY database already migrated to head, +# STATE_BACKEND=postgres, ENVIRONMENT=test, and explicit +# MCP_STRESS_DB_ISOLATED=1 acknowledgment. Provision # and migrate only the disposable database before invoking this target; this # target never migrates or overrides the caller's isolation settings. # The harness refuses to run otherwise: it fails closed on a # non-PostgreSQL URL, a production-like ENVIRONMENT, a stale Alembic revision, # or any application table that already holds rows, and it takes an advisory -# lock so two runs cannot overlap. This is the same proof CI runs. +# lock so two runs cannot overlap. CI runs this harness and the action crash +# suite in the `postgres-proofs` job, alongside the lighter +# `postgres-concurrency` job. See docs/TESTING_TIERS.md for test levels. prove-crash-recovery: RUN_MCP_MULTIPROCESS_TESTS=1 \ uv run --with-requirements requirements.txt \ @@ -121,6 +125,16 @@ agent-ops-war-room-check: check-doc-references: python scripts/check_doc_references.py +# First step of scripts/trust_release_gate.sh. The supported deploy path in +# this repo is `railway up` from the Dockerfile (WEDGE.md). The hosted +# `.railway/railway.ts` graph is not in this tree, so the in-repo contract is +# the files that build COPYs or executes. `.build_commit_sha` is gitignored +# and stamped at release time, after this gate. +check-railway-iac: + test -f Dockerfile + test -f requirements.txt + test -f scripts/docker_entrypoint.sh + test -x scripts/docker_entrypoint.sh trust-coverage-gate: scripts/trust_coverage_gate.sh diff --git a/gateway/README.md b/gateway/README.md index 097837bc..4c1c2411 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -63,6 +63,33 @@ is receipted `delivery_uncertain` and never silently re-sent. See - Internal credit ledger only: not settlement, not a compliance record, not IAM. - MCP is the only governed adapter; one operator-configured upstream tool. +## Money, pricing, and top-up + +- Credits are internal metering units, not money. The default rate is 1000 + credits per 1.00 USD, and it is operator-configurable (`EXCHANGE_RATE`). + Category defaults live in one pricing table (the base platform fee is 0.1 + credits per call). A registered tool can override that default. The local + demo tool is registered at 2 credits per call, which is not the AGENT_COMMS + table default of 1.5. `GET /v1/billing/pricing` returns the category table + in effect, not each tool's registration price. +- Credits are created by admin-gated sponsor issuance + (`POST /v1/billing/wallets/sponsor`) and, only when + `ENABLE_PROOF_SURFACES` is on, by verified Stripe top-up (USD only, where + Stripe keys are configured). Sponsor wallets may require KYC before a + top-up is prepared. There is no subscription, invoice, or payout flow in + this gateway. Direct client-token top-up answers `410 Gone`. +- x402 and ACP mount only with `ENABLE_PROOF_SURFACES`. x402 prepares a + permit-backed authorization and a signed receipt. The payer wallet signs + anything that would go on chain. This repository does not submit it. ACP + charges USD on the existing Stripe rail with a Shared Payment Token. It + does not mint credits, and it is not an on-chain signature. +- `Idempotency-Key` is honored on agent-wallet creation, charges, and + transfers. The same key and the same payload returns the original result. + The same key with different arguments is refused with 409. A retry without + the header is a new operation and can debit twice. Sponsor-wallet creation + does not read that header. If that call times out, list wallets before + retrying. + Read [`SECURITY_LIMITATIONS.md`](SECURITY_LIMITATIONS.md), [`TRUST_MODEL.md`](TRUST_MODEL.md), and [`docs/threat-model.md`](docs/threat-model.md) before evaluating it for real use. Reviewers: [`docs/security-review-kit.md`](docs/security-review-kit.md). diff --git a/gateway/SECURITY_LIMITATIONS.md b/gateway/SECURITY_LIMITATIONS.md index 4b71232d..8ea2e57e 100644 --- a/gateway/SECURITY_LIMITATIONS.md +++ b/gateway/SECURITY_LIMITATIONS.md @@ -172,17 +172,23 @@ repo; do not Redeploy from GitHub source). ## CORS Posture -The default `CORS_ORIGINS=*` is a deliberate decision, not an oversight: +The default `CORS_ORIGINS` is empty, granting no cross-origin access. An +explicitly configured wildcard remains a deliberate posture, not an +oversight: - Every authenticated route takes explicit header credentials (`X-API-Key` or `Authorization: Bearer`), never cookies or other ambient browser credentials, so there is nothing a cross-origin page can ride. -- `app.main.add_cors_middleware` refuses to pair a wildcard with - credentialed CORS: under `*`, `Access-Control-Allow-Credentials` is never - emitted. An explicit origin list is required before credentialed - cross-origin requests are possible at all. -- What the wildcard actually grants is cross-origin *reads of remaining - public surfaces* (`/.well-known/trust-keys.json`, `/.well-known/jwks.json`, +- `app.main.add_cors_middleware` refuses to pair a wildcard (or an empty + list) with credentialed CORS: under `*` or with no origins configured, + `Access-Control-Allow-Credentials` is never emitted. An explicit origin + list is required before credentialed cross-origin requests are possible + at all. +- Production-like boots refuse to start with `*` in `CORS_ORIGINS` (see the + trust-mode guardrails). +- What an explicitly configured wildcard actually grants is cross-origin + *reads of remaining public surfaces* + (`/.well-known/trust-keys.json`, `/.well-known/jwks.json`, `/.well-known/agent.json`, `/health*`, `/llms.txt`, `/openapi.json`) — not tool catalogs, which require a key on production-like boots. - The one route that is unauthenticated yet returns a secret diff --git a/gateway/app/core/auth.py b/gateway/app/core/auth.py index 8910cc93..6275109b 100644 --- a/gateway/app/core/auth.py +++ b/gateway/app/core/auth.py @@ -47,7 +47,10 @@ class AuthContext: key_id: str | None = None wallet_id: str | None = None is_bootstrap_admin: bool = False - scopes: list[str] = None # type: ignore[assignment] + # Omitted scopes arrive as None. __post_init__ stores [] so a constructed + # context carries a list; readers must still treat None as no scopes, + # because a context that skipped the constructor can leave None in place. + scopes: list[str] | None = None # Enterprise (Okta/Entra) bearer that accompanied an X-API-Key call. Set # ONLY when the Authorization header carried a token whose UNVERIFIED # issuer names a pinned IGA issuer (see get_auth_context). It is identity @@ -235,7 +238,7 @@ async def _resolve_auth_context( valid_keys = [k.strip() for k in settings.VALID_API_KEYS.split(",") if k.strip()] - if any(hmac.compare_digest(stripped, key) for key in valid_keys): + if any(_constant_time_equal(stripped, key) for key in valid_keys): return AuthContext( source="env", raw_key=stripped, @@ -254,7 +257,7 @@ async def _resolve_auth_context( if is_production_like_environment(settings.ENVIRONMENT) else _parse_static_dev_keys(settings.STATIC_DEV_API_KEYS) ) - if any(hmac.compare_digest(stripped, key) for key in static_dev_keys): + if any(_constant_time_equal(stripped, key) for key in static_dev_keys): return AuthContext( source="static-dev", raw_key=stripped, @@ -327,6 +330,18 @@ async def reject_anonymous_production_catalog(request: Request) -> None: ) +def _constant_time_equal(presented: str, expected: str) -> bool: + """Compare two key strings without raising on non-ASCII input. + + ``hmac.compare_digest`` on ``str`` raises ``TypeError`` when either side + has non-ASCII characters, which turned a refused API key into an + unhandled 500. Comparing the UTF-8 bytes keeps arbitrary input safe: a + non-ASCII presentation simply never matches an ASCII configured key, + and a non-ASCII configured key still compares correctly. + """ + return hmac.compare_digest(presented.encode("utf-8"), expected.encode("utf-8")) + + def _parse_static_dev_keys(configured: str) -> list[str]: """Parse STATIC_DEV_API_KEYS, honoring only ``amw_dev_``-prefixed entries. diff --git a/gateway/app/core/blob.py b/gateway/app/core/blob.py index 939cc107..3c4adf6d 100644 --- a/gateway/app/core/blob.py +++ b/gateway/app/core/blob.py @@ -45,7 +45,7 @@ class BlobBackend(ABC): async def put( self, key: str, data: bytes, content_type: str = "application/octet-stream" ) -> str: - """Store ``data`` under ``key``. Return a URL or path callers can store.""" + """Store ``data`` under ``key``. Return an opaque ref callers can store.""" @abstractmethod async def get(self, key: str) -> bytes: @@ -57,7 +57,7 @@ async def delete(self, key: str) -> bool: @abstractmethod async def url(self, key: str) -> str: - """Return a URL or path reference for ``key`` without fetching it.""" + """Return an opaque reference for ``key`` without fetching it.""" class LocalFilesystemBlob(BlobBackend): @@ -122,7 +122,11 @@ async def url(self, key: str) -> str: @staticmethod def _to_url(path: Path) -> str: - return f"file://{path}" + # Opaque reference: the hex digest names the object without exposing + # the host's absolute filesystem layout to API callers. Resolution + # back to bytes always goes through the original key (see _path), so + # the ref itself carries no location information. + return f"blob:{path.name}" class _UnimplementedBlob(BlobBackend): @@ -131,25 +135,27 @@ class _UnimplementedBlob(BlobBackend): def __init__(self, name: str): self._name = name - async def put(self, key, data, content_type="application/octet-stream"): # type: ignore[override] + async def put( + self, key: str, data: bytes, content_type: str = "application/octet-stream" + ) -> str: raise NotImplementedError( f"{self._name} blob backend is outside the frozen proof-surface scope; " "see docs/PROOF_SURFACES.md" ) - async def get(self, key): # type: ignore[override] + async def get(self, key: str) -> bytes: raise NotImplementedError( f"{self._name} blob backend is outside the frozen proof-surface scope; " "see docs/PROOF_SURFACES.md" ) - async def delete(self, key): # type: ignore[override] + async def delete(self, key: str) -> bool: raise NotImplementedError( f"{self._name} blob backend is outside the frozen proof-surface scope; " "see docs/PROOF_SURFACES.md" ) - async def url(self, key): # type: ignore[override] + async def url(self, key: str) -> str: raise NotImplementedError( f"{self._name} blob backend is outside the frozen proof-surface scope; " "see docs/PROOF_SURFACES.md" @@ -167,13 +173,14 @@ def get_blob_backend() -> BlobBackend: if backend_name == "s3": logger.warning( - "S3 blob backend selected but not implemented; falling back to local" + "S3 blob backend selected but not implemented; blob calls will fail loudly" ) return _UnimplementedBlob("s3") if backend_name == "vercel": logger.warning( - "Vercel blob backend selected but unsupported while media is frozen" + "Vercel blob backend selected but unsupported while media is frozen; " + "blob calls will fail loudly" ) return _UnimplementedBlob("vercel") diff --git a/gateway/app/core/config.py b/gateway/app/core/config.py index cfee7f9a..21646045 100644 --- a/gateway/app/core/config.py +++ b/gateway/app/core/config.py @@ -36,7 +36,7 @@ class Settings(BaseSettings): # --- Server --- HOST: str = "0.0.0.0" PORT: int = 8000 - CORS_ORIGINS: str = "*" + CORS_ORIGINS: str = "" # --- Public URL --- # Used for agent manifests and documentation links diff --git a/gateway/app/core/dependencies.py b/gateway/app/core/dependencies.py index 77755510..7fcaf497 100644 --- a/gateway/app/core/dependencies.py +++ b/gateway/app/core/dependencies.py @@ -21,12 +21,15 @@ from ..services.telemetry_scope import TelemetryScope from ..services.oracle_broadcast import OracleBroadcastEngine -settings = get_settings() - @lru_cache() def get_iot_bridge() -> ProtocolBridge: """Singleton IoT Protocol Bridge with ACL engine and MQTT translator.""" + # Settings are read per call, not from a module-level singleton: tests and + # operators rebind env vars and clear the settings cache after this module + # is already imported, and a captured `settings` would keep serving stale + # values (same constraint app.core.auth documents for get_auth_context). + settings = get_settings() bridge = ProtocolBridge( mqtt_broker_url=settings.MQTT_BROKER_URL, mqtt_default_qos=settings.MQTT_DEFAULT_QOS, @@ -37,6 +40,7 @@ def get_iot_bridge() -> ProtocolBridge: @lru_cache() def get_autonomous_pm() -> AutonomousPM: """Singleton Autonomous Product Manager with event store and anomaly detector.""" + settings = get_settings() return AutonomousPM( retention_hours=settings.TELEMETRY_RETENTION_HOURS, git_remote=settings.GIT_REMOTE_URL, diff --git a/gateway/app/core/durable_state.py b/gateway/app/core/durable_state.py index d331f871..ec2b5e6e 100644 --- a/gateway/app/core/durable_state.py +++ b/gateway/app/core/durable_state.py @@ -31,7 +31,12 @@ import redis.asyncio as redis from .config import get_settings -from .db_urls import as_asyncpg_url, is_postgres_url, sqlite_path_from_url +from .db_urls import ( + as_asyncpg_url, + is_postgres_url, + is_sqlite_url, + sqlite_path_from_url, +) from .runtime_degradation import mark_durable_state_fell_back from .trust_mode import is_production_like_environment @@ -146,7 +151,23 @@ def __init__(self): self._state_backend = settings.STATE_BACKEND.strip().lower() self._redis_url = settings.REDIS_URL.strip() self._database_url = settings.DATABASE_URL.strip() - self._sqlite_url = settings.SQLITE_URL.strip() + # SQLITE_URL is documented as a filesystem path (see .env.example), + # but the name invites the SQLAlchemy URL form, and handing a URL to + # aiosqlite.connect would create a file with that literal name while + # the operator believes state is durable elsewhere. Accept the URL + # form too; anything that is not a SQLite URL stays a raw path, and + # an in-memory spelling resolves to "" so it keeps the deliberate + # non-durable treatment below instead of a garbage file. + raw_sqlite_url = settings.SQLITE_URL.strip() + if raw_sqlite_url and is_sqlite_url(raw_sqlite_url): + # SQLAlchemy URL form: normalize to the file path aiosqlite + # expects. An in-memory spelling normalizes to "" so it keeps + # the deliberate non-durable treatment instead of becoming a + # file with a URL for a name. + self._sqlite_url = sqlite_path_from_url(raw_sqlite_url) + else: + # Documented raw-path form (or the ":memory:" shorthand). + self._sqlite_url = raw_sqlite_url # A SQLAlchemy SQLite ``DATABASE_URL`` is the standard local-development # setting. Without this, auto-resolution below would see a non-empty # DATABASE_URL, choose the postgres backend, hand a sqlite DSN to diff --git a/gateway/app/core/health.py b/gateway/app/core/health.py index 75c36c28..3c2dbba3 100644 --- a/gateway/app/core/health.py +++ b/gateway/app/core/health.py @@ -272,6 +272,21 @@ async def _check_mqtt(simulation_modes: dict[str, bool]) -> dict[str, Any]: return {"status": "up", "host": host, "port": port} +def _public_mqtt_probe(result: dict[str, Any]) -> dict[str, Any]: + """Project an MQTT probe onto the fields safe for unauthenticated output. + + The broker host and port name internal infrastructure, and every endpoint + serving these reports (/health/ready always, /health/dependencies on + proof-surface hosts) is unauthenticated, so they stay server-side. + Status, reason, error, and latency are all a monitor needs. + """ + return { + key: value + for key, value in result.items() + if key in {"status", "reason", "error", "latency_ms"} + } + + async def _check_stripe() -> dict[str, Any]: settings = get_settings() if not settings.STRIPE_SECRET_KEY: @@ -450,7 +465,7 @@ async def check_mqtt_readiness() -> dict[str, Any]: touches. """ sim_modes = get_simulation_modes() - return await _run_check("mqtt", lambda: _check_mqtt(sim_modes)) + return _public_mqtt_probe(await _run_check("mqtt", lambda: _check_mqtt(sim_modes))) # --------------------------------------------------------------------------- @@ -532,7 +547,9 @@ async def gather_dependency_report() -> dict[str, Any]: dependencies = { "postgres": postgres, "redis": redis_res, - "mqtt": mqtt, + # The full report is served unauthenticated wherever proof surfaces + # are mounted, so the broker address is projected out here too. + "mqtt": _public_mqtt_probe(mqtt), "stripe": stripe_res, "llm": llm, "upstream_mcp": upstream_mcp, diff --git a/gateway/app/core/rate_limiter.py b/gateway/app/core/rate_limiter.py index 1aa56c19..cc56f52a 100644 --- a/gateway/app/core/rate_limiter.py +++ b/gateway/app/core/rate_limiter.py @@ -168,6 +168,20 @@ def _presented_rate_identity(request: Request) -> str | None: return f"jwt:{payload.key_id}" +def _routing_path(request: Request) -> str: + """Return the path the router matches. + + ``request.url.path`` reparses the path. A literal ``?`` or ``#`` in that + path (a client sends ``%3F`` or ``%23``, and the server decodes it into + the path) is cut off, so ``/health?not-health`` looks like the exempt + ``/health`` route while routing still sees the original path. + """ + path = request.scope["path"] + if not isinstance(path, str): + raise TypeError("ASGI scope path must be a string") + return path + + def _client_id(request: Request) -> str: """Return a non-spoofable client identifier for the supported ingress. @@ -610,7 +624,8 @@ async def dispatch(self, request: Request, call_next) -> Response: # API-key headers there so rotating bogus values cannot mint fresh # rate-limit buckets (and the local test-key bypass cannot be reached # from the Internet). - public_mcp_request = request.url.path.rstrip("/") == _PUBLIC_MCP_PATH + routing_path = _routing_path(request) + public_mcp_request = routing_path.rstrip("/") == _PUBLIC_MCP_PATH if public_mcp_request: client_id = _client_id(request) namespace = ( @@ -654,7 +669,7 @@ async def dispatch(self, request: Request, call_next) -> Response: "/DESIGN_PARTNER_GUIDE.md", "/docs/partner-api-key-bootstrap.md", ) - if request.url.path in skip_paths: + if routing_path in skip_paths: response = await call_next(request) _credentials_were_rejected(response) # strip the internal marker return response # type: ignore[no-any-return] diff --git a/gateway/app/core/resilience.py b/gateway/app/core/resilience.py index 0d9340c7..2a51051d 100644 --- a/gateway/app/core/resilience.py +++ b/gateway/app/core/resilience.py @@ -37,6 +37,8 @@ def retry_with_backoff( async def call_api(): ... """ + if max_attempts < 1: + raise ValueError("max_attempts must be at least 1") def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) @@ -241,6 +243,8 @@ async def run_with_write_conflict_retry( other reason of the same type fail on the first attempt. A restart taken this way spends the same budget and ends in the same ``on_exhausted``. """ + if max_attempts < 1: + raise ValueError("max_attempts must be at least 1") last_exc: BaseException | None = None for attempt in range(max_attempts): try: diff --git a/gateway/app/core/scopes.py b/gateway/app/core/scopes.py index dd275064..2169b030 100644 --- a/gateway/app/core/scopes.py +++ b/gateway/app/core/scopes.py @@ -10,16 +10,19 @@ async def create_permit(...): from __future__ import annotations from functools import wraps -from typing import Callable, TypeVar +from typing import Any, Awaitable, Callable, Coroutine, ParamSpec, TypeVar from fastapi import HTTPException, status from app.core.auth import AuthContext -F = TypeVar("F", bound=Callable) +P = ParamSpec("P") +T = TypeVar("T") -def require_scope(*required_scopes: str) -> Callable[[F], F]: +def require_scope( + *required_scopes: str, +) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]: """Decorator that checks JWT scopes on the auth context. API key callers (source="db" or "env") bypass scope checks @@ -27,12 +30,17 @@ def require_scope(*required_scopes: str) -> Callable[[F], F]: one of the required scopes. """ - def decorator(func: F) -> F: + def decorator( + func: Callable[P, Awaitable[T]], + ) -> Callable[P, Coroutine[Any, Any, T]]: @wraps(func) - async def wrapper(*args, **kwargs) -> F: + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: # Find AuthContext in kwargs - auth: AuthContext | None = kwargs.get("auth") - if auth is None: + auth: AuthContext | None = None + candidate = kwargs.get("auth") + if isinstance(candidate, AuthContext): + auth = candidate + else: for arg in args: if isinstance(arg, AuthContext): auth = arg @@ -53,20 +61,22 @@ async def wrapper(*args, **kwargs) -> F: if auth.source in ("db", "env", "static-dev"): return await func(*args, **kwargs) - # JWT callers must have at least one required scope - if not any(scope in auth.scopes for scope in required_scopes): + # JWT callers must have at least one required scope. None means + # the context never received a scope list (see AuthContext). + provided_scopes = auth.scopes if auth.scopes is not None else [] + if not any(scope in provided_scopes for scope in required_scopes): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ "error": "insufficient_scope", "message": f"Requires one of: {', '.join(required_scopes)}", "required": list(required_scopes), - "provided": auth.scopes, + "provided": provided_scopes, }, ) return await func(*args, **kwargs) - return wrapper # type: ignore[return-value] + return wrapper return decorator diff --git a/gateway/app/core/trust_mode.py b/gateway/app/core/trust_mode.py index ef038ee9..042d7a58 100644 --- a/gateway/app/core/trust_mode.py +++ b/gateway/app/core/trust_mode.py @@ -148,6 +148,7 @@ def validate_trust_mode_config( redis_url: str = "", public_url: str = "", database_url: str = "", + cors_origins: str = "", enable_dogfood_tool: bool = False, enable_dogfood_second_tool: bool = False, ) -> None: @@ -282,6 +283,16 @@ def validate_trust_mode_config( "STATE_BACKEND, which governs the key/value state store rather " "than the ORM engine" ) + for entry in (cors_origins or "").split(","): + cleaned = entry.strip().strip("\"'").strip() + if cleaned == "*": + violations.append( + "CORS_ORIGINS must not contain '*' in production-like " + "environments (wildcard CORS lets any website read " + "cross-origin responses; set an explicit origin list or " + "leave it empty when there is no browser client)" + ) + break if violations: raise TrustModeGuardrailError("; ".join(violations)) @@ -313,6 +324,7 @@ def validate_trust_mode_guardrails(settings: Settings) -> None: database_url=settings.DATABASE_URL, enable_dogfood_tool=settings.ENABLE_DOGFOOD_TOOL, enable_dogfood_second_tool=settings.ENABLE_DOGFOOD_SECOND_TOOL, + cors_origins=settings.CORS_ORIGINS, ) diff --git a/gateway/app/core/url_guard.py b/gateway/app/core/url_guard.py index 6a5bfd4c..1099f09d 100644 --- a/gateway/app/core/url_guard.py +++ b/gateway/app/core/url_guard.py @@ -3,7 +3,7 @@ Agent-supplied URLs reach two genuinely dangerous sinks: the AWI Playwright bridge (a real headless browser that will happily open ``file://`` and intranet URLs) and the behavioral sandbox's HTTP proxy mode (a raw -``aiohttp`` request). Both run inside our infrastructure, so an unchecked +``httpx`` request). Both run inside our infrastructure, so an unchecked URL is an SSRF primitive against link-local metadata services, RFC1918 networks, and the host filesystem. @@ -15,9 +15,17 @@ could not resolve, and the fetching client (a headless browser with its own resolver, or a later retry) may well resolve it — possibly to an intranet address. This matches the upstream MCP URL guard. Tests that exercise -public hostnames pin ``_resolve_host`` instead of relying on live DNS. This -is a pre-flight guard, not a substitute for network-level egress policy, -and it intentionally does not try to defeat DNS rebinding. +public hostnames pin ``_resolve_host`` instead of relying on live DNS. + +``pin_outbound_url`` goes one step further and returns the validated IP +address to connect to alongside the verdict. Hostname validation resolves +DNS exactly once, so callers that connect to the returned pinned address +(rather than resolving the hostname again) are not exposed to DNS +rebinding between validation and connection: the IP that passed validation +is the one connected to. This mirrors the upstream MCP pinned transport. +Callers that cannot control connection DNS (a headless browser) must still +revalidate immediately before use. Either way this guard is a pre-flight +check, not a substitute for network-level egress policy. Setting ``ALLOW_PRIVATE_NETWORK_TARGETS=true`` (local development against mock servers) skips only the private-address checks; non-http(s) schemes @@ -28,7 +36,9 @@ import asyncio import ipaddress +import re import socket +from collections.abc import Awaitable, Callable, Sequence from urllib.parse import urlsplit from app.core.config import get_settings @@ -36,6 +46,71 @@ _ALLOWED_SCHEMES = {"http", "https"} _BLOCKED_HOSTNAMES = {"localhost", "metadata.google.internal"} _BLOCKED_SUFFIXES = (".localhost", ".local", ".internal") +# libc and browsers parse these as IPv4. ipaddress rejects them, so a guard +# that only calls ip_address() then DNS will follow a public DNS answer +# while the client dials the embedded private address (2130706433 is +# 127.0.0.1, 0177.0.0.1 is octal loopback, 127.1 is shorthand loopback). +_LEGACY_IPV4 = re.compile( + r"^(?:0[xX][0-9a-fA-F]+|[0-9]+)(?:\.(?:0[xX][0-9a-fA-F]+|[0-9]+)){0,3}$" +) +# Deprecated IPv4-compatible form (::/96) and the well-known NAT64 prefix. +# DNS64 publishes public IPv4 destinations inside 64:ff9b::/96, so the whole +# prefix must not be refused: only an embedded address that is itself blocked. +_IPV4_COMPATIBLE = ipaddress.IPv6Network("::/96") +_NAT64_WELL_KNOWN = ipaddress.IPv6Network("64:ff9b::/96") +# RFC 2765 SIIT form. Not IPv4-mapped (::ffff:0:0/96). is_global stays true +# on Python 3.11 through 3.14, including when the low 32 bits are metadata. +_IPV4_TRANSLATED = ipaddress.IPv6Network("::ffff:0:0:0/96") + + +def _embedded_ipv4_literal( + ip: ipaddress.IPv6Address, +) -> ipaddress.IPv4Address | None: + """Return the IPv4 a stack may dial for a compatible, translated, or NAT64 literal. + + IPv4-mapped addresses (``::ffff:0:0/96``) are omitted. ``is_global`` + already follows the mapped IPv4 for those. IPv4-translated addresses + (``::ffff:0:0:0/96``) are not mapped, and ``is_global`` is true for them. + """ + if ip.ipv4_mapped is not None: + return None + if ip in _IPV4_COMPATIBLE or ip in _NAT64_WELL_KNOWN or ip in _IPV4_TRANSLATED: + return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) + return None + + +def address_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """True when this address must not be fetched or dialed. + + ``is_global`` misses multicast (it can be global), deprecated site-local + ``fec0::/10``, and IPv6 literals whose embedded IPv4 is private or + link-local. ``::169.254.169.254``, ``::ffff:0:169.254.169.254``, and + ``64:ff9b::10.0.0.1`` are ``is_global`` and still name metadata or + RFC1918. A public embedding such as ``64:ff9b::8.8.8.8`` stays allowed + so DNS64 can publish an IPv4-only host. + """ + if ip.is_multicast or bool(getattr(ip, "is_site_local", False)) or not ip.is_global: + return True + if isinstance(ip, ipaddress.IPv6Address): + embedded = _embedded_ipv4_literal(ip) + if embedded is not None and address_is_blocked(embedded): + return True + return False + + +def is_ambiguous_ipv4_literal(host: str) -> bool: + """True for a numeric host that is not a canonical IP address. + + Callers use this after ``ip_address`` rejects ``host``. Canonical + addresses such as ``8.8.8.8`` parse and return False. + """ + if _LEGACY_IPV4.fullmatch(host) is None: + return False + try: + ipaddress.ip_address(host) + except ValueError: + return True + return False def _address_blocked(address: str) -> bool: @@ -43,10 +118,7 @@ def _address_blocked(address: str) -> bool: ip = ipaddress.ip_address(address) except ValueError: return False - # is_global is False for loopback, RFC1918/ULA, link-local (including - # 169.254.169.254 metadata), CGNAT shared space, reserved, and unspecified - # addresses. Multicast needs an explicit check: it can be classified global. - return ip.is_multicast or not ip.is_global + return address_is_blocked(ip) async def _resolve_host(host: str): @@ -57,43 +129,78 @@ async def _resolve_host(host: str): ) -async def check_outbound_url(url: str) -> str | None: - """Return a block reason for an agent-supplied outbound URL, or None.""" +async def _default_resolver(host: str) -> Sequence[str]: + """Resolve a hostname to address strings via the monkeypatchable hook.""" + infos = await _resolve_host(host) + return tuple(dict.fromkeys(str(info[4][0]) for info in infos)) + + +async def pin_outbound_url( + url: str, + *, + resolver: Callable[[str], Awaitable[Sequence[str]]] | None = None, +) -> tuple[str | None, str | None]: + """Validate an outbound URL and return the IP address to connect to. + + Returns ``(block_reason, pinned_address)``. When the URL is safe, + ``block_reason`` is None and ``pinned_address`` is the IP that passed + validation: the literal itself for literal hosts, or the first resolved + address for hostnames. Hostnames are resolved exactly once and every + resolved address must be globally routable, so connecting to the pinned + address (instead of resolving again) closes DNS rebinding between + validation and connection. When the URL is blocked, the pinned address + is None. The optional ``resolver`` override exists for tests. + """ try: parts = urlsplit(url) except ValueError: - return "unparseable_url" + return "unparseable_url", None if parts.scheme.lower() not in _ALLOWED_SCHEMES: - return "scheme_not_allowed" + return "scheme_not_allowed", None host = parts.hostname if not host: - return "missing_host" + return "missing_host", None if get_settings().ALLOW_PRIVATE_NETWORK_TARGETS: - return None + return None, None host = host.strip("[]").rstrip(".").lower() if host in _BLOCKED_HOSTNAMES or host.endswith(_BLOCKED_SUFFIXES): - return "private_host_blocked" + return "private_host_blocked", None if _address_blocked(host): - return "private_address_blocked" + return "private_address_blocked", None try: ipaddress.ip_address(host) except ValueError: - # A hostname, not a literal address: resolve it and require every - # resolved address to be globally routable. - try: - infos = await _resolve_host(host) - except (OSError, UnicodeError): - # socket.gaierror is an OSError; UnicodeError covers names the - # IDNA codec rejects (e.g. an over-long label). Fail closed. - return "dns_resolution_failed" - for info in infos: - if _address_blocked(str(info[4][0])): - return "private_address_blocked" + # Reject before DNS. A public answer must not hide an octal, hex, + # decimal, or shortened IPv4 literal the client will dial directly. + if is_ambiguous_ipv4_literal(host): + return "private_address_blocked", None + else: + return None, host + + # A hostname, not a literal address: resolve it once and require every + # resolved address to be globally routable. + try: + addresses = tuple(await (resolver or _default_resolver)(host)) + except Exception: + # socket.gaierror is an OSError; UnicodeError covers names the + # IDNA codec rejects (e.g. an over-long label). Fail closed. + return "dns_resolution_failed", None + if not addresses: + return "dns_resolution_failed", None + for address in addresses: + if _address_blocked(address): + return "private_address_blocked", None - return None + return None, addresses[0] + + +async def check_outbound_url(url: str) -> str | None: + """Return a block reason for an agent-supplied outbound URL, or None.""" + reason, _ = await pin_outbound_url(url) + return reason diff --git a/gateway/app/core/webhook_auth.py b/gateway/app/core/webhook_auth.py new file mode 100644 index 00000000..69814f27 --- /dev/null +++ b/gateway/app/core/webhook_auth.py @@ -0,0 +1,115 @@ +"""Shared inbound-webhook signature checks. + +Every inbound third-party webhook in this gateway (currently the two Stripe +receivers in ``app/routers/webhooks.py``) verifies through this module, so +the fail-closed rules live in one place instead of being reimplemented per +receiver: + +* the signing secret is required before any signature is trusted; a receiver + with no secret configured refuses the request instead of skipping the + check (the ``if secret and signature`` fail-open shape must never appear); +* a missing or bad signature is denied, and every denial looks identical to + the caller (routers answer 401) so probes learn nothing about server + configuration; +* signature comparison itself is constant-time (``hmac.compare_digest``), + directly here for generic HMAC webhooks and inside the Stripe SDK's + secure_compare for Stripe's v1 scheme. + +Success responses stay minimal (an accepted marker, never tenant data). +Idempotent application of redelivered events stays with the services (UNIQUE +constraints on payment intent and event ids), not with this module. +""" + +from __future__ import annotations + +import hmac +import logging +from typing import Any + +import stripe + +logger = logging.getLogger(__name__) + + +class WebhookVerificationError(ValueError): + """An inbound webhook cannot be trusted and must be refused. + + Covers a missing signing secret, a missing signature header, and a + signature that does not verify. Routers map this to 401; services treat + it as "drop the event, change nothing". + """ + + +def require_webhook_secret(secret: str | None, *, name: str) -> str: + """Return the configured webhook secret, or refuse to verify without one. + + A receiver with no secret cannot tell a forged event from a real one, so + it must not process anything. Call this before trusting any signature. + """ + if not secret or not secret.strip(): + logger.error( + "webhook receiver %s has no signing secret configured; " + "refusing to process unsigned webhook traffic", + name, + ) + raise WebhookVerificationError(f"webhook receiver {name} is not configured") + return secret + + +def secure_compare(first: str | bytes, second: str | bytes) -> bool: + """Compare two signatures in constant time. + + The single choke point for signature equality in this gateway, so a new + HMAC-signed webhook adopts constant-time comparison by calling this + instead of ``==``. Accepts text or bytes; text is UTF-8 encoded first. + """ + if isinstance(first, str): + first = first.encode("utf-8") + if isinstance(second, str): + second = second.encode("utf-8") + return hmac.compare_digest(first, second) + + +def verify_stripe_event( + payload: bytes, + sig_header: str | None, + secret: str | None, + *, + secret_name: str = "STRIPE_WEBHOOK_SECRET", +) -> Any: + """Verify a Stripe webhook payload and return the parsed event. + + Fails closed: raises :class:`WebhookVerificationError` when the secret + is not configured, when the signature header is missing or blank, or + when the Stripe SDK rejects the signature (which covers tampered + payloads, wrong secrets, and timestamps older than the SDK's 300-second + tolerance). The SDK compares the v1 HMAC with its own constant-time + ``secure_compare``. + + Args: + payload: Raw request body bytes, exactly as received. + sig_header: Value of the ``Stripe-Signature`` request header. + secret: The configured webhook signing secret (``whsec_...``). + secret_name: Setting name used in logs and errors. + + Raises: + WebhookVerificationError: The event must not be trusted. + """ + require_webhook_secret(secret, name=secret_name) + if not sig_header or not sig_header.strip(): + raise WebhookVerificationError("missing webhook signature") + if not sig_header.isascii(): + # Stripe's constant-time comparison raises TypeError for non-ASCII text. + raise WebhookVerificationError("invalid webhook signature") + try: + return stripe.Webhook.construct_event( + payload, + sig_header, + secret, + ) + except (ValueError, stripe.SignatureVerificationError) as exc: + # SignatureVerificationError is not a ValueError subclass, so it + # needs naming explicitly; otherwise a bad signature escapes as a + # 500 instead of a denial. + logger.warning("Rejected webhook with invalid signature: %s", exc) + raise WebhookVerificationError("invalid webhook signature") from exc diff --git a/gateway/app/db/database.py b/gateway/app/db/database.py index 36d9a138..a9598198 100644 --- a/gateway/app/db/database.py +++ b/gateway/app/db/database.py @@ -44,12 +44,21 @@ # refuse a healthy service. Before enabling RUN_MIGRATIONS_ON_START, operators # must compare physical schema and data-migration history and review recovery # under docs/schema-042-rollout.md; table presence does not prove parity. +# The set covers the full loop, not just issuance: permits and receipts plus +# the audit hash chain (control_plane_audit_events with its audit_chain_heads +# pointers), the signing-key registry receipts and audit events point at, the +# human-approval gate on governed invokes, and the idempotency and dispatch +# records that make retries safe. REQUIRED_TRUST_TABLES = frozenset( { "permits", "receipts", "idempotency_records", "mcp_dispatch_attempts", + "control_plane_audit_events", + "audit_chain_heads", + "signing_keys", + "human_approvals", } ) REQUIRED_TRUST_COLUMNS = { diff --git a/gateway/app/main.py b/gateway/app/main.py index f0f9fa24..84280706 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -484,7 +484,11 @@ async def periodic_cleanup(): await close_db() await close_durable_state() - await asyncio.sleep(2) # Allow in-flight requests to drain + # Fixed 2s pause before process exit. This coordinates with nothing and + # drains nothing: graceful connection draining belongs to the server + # (uvicorn --timeout-graceful-shutdown), not to this sleep. Kept only as + # a brief settling window for background log and metric flushes. + await asyncio.sleep(2) logger.info( "app_shutdown", phase="complete", @@ -495,6 +499,28 @@ async def periodic_cleanup(): public_contact = _public_contact_metadata(settings) +def build_openapi_servers( + public_url: str, environment: str +) -> list[dict[str, str]] | None: + """Servers advertised in the OpenAPI spec, or None to omit the field. + + No placeholder domain is ever advertised: when PUBLIC_URL is unset the + servers field is omitted entirely (consumers default to "/") instead of + pointing agents at a domain the operator does not control. The loopback + entry is only ever listed alongside a real public URL, and only outside + production-like environments. + """ + public_url = (public_url or "").strip() + if not public_url: + return None + servers = [{"url": public_url, "description": "Public API (set PUBLIC_URL)"}] + if not is_production_like_environment(environment): + servers.append( + {"url": "http://localhost:8000", "description": "Local Development"} + ) + return servers + + app = FastAPI( lifespan=lifespan, title=settings.APP_NAME, @@ -541,17 +567,7 @@ async def periodic_cleanup(): "name": "Business Source License 1.1", "url": "https://github.com/PetrefiedThunder/agent-middleware/blob/main/LICENSE", }, - servers=[ - { - "url": settings.PUBLIC_URL or "https://api.thisisatest.tech", - "description": "Public API (set PUBLIC_URL)", - }, - *( - [] - if is_production_like_environment(settings.ENVIRONMENT) - else [{"url": "http://localhost:8000", "description": "Local Development"}] - ), - ], + servers=build_openapi_servers(settings.PUBLIC_URL, settings.ENVIRONMENT), ) # --- Middleware Stack --- @@ -582,27 +598,28 @@ def add_cors_middleware(application: FastAPI, origins: list[str]) -> None: application.add_middleware( CORSMiddleware, allow_origins=origins, - allow_credentials=not allow_all_origins, + allow_credentials=bool(origins) and not allow_all_origins, allow_methods=["*"], allow_headers=["*"], ) # CORS origins are configurable via the CORS_ORIGINS env var (comma-separated). -# The default wildcard is a deliberate posture, not an accident: every -# authenticated route takes explicit header credentials (X-API-Key / Bearer), -# never cookies, and add_cors_middleware disables credentialed CORS under a -# wildcard, so `*` here grants cross-origin reads of public discovery -# surfaces and nothing else. Operators fronting a browser app that sends -# credentials must set an explicit origin list instead. Decision documented +# The default is empty (no cross-origin grant). Every authenticated route +# takes explicit header credentials (X-API-Key / Bearer), never cookies, +# and add_cors_middleware disables credentialed CORS under a wildcard or +# an empty list, so `*` here would only grant credential-less cross-origin +# reads of public discovery surfaces. Operators fronting a browser app that +# sends credentials must set an explicit origin list instead. Production-like +# boots refuse `*` via the trust-mode guardrails. Decision documented # in SECURITY_LIMITATIONS.md ("CORS posture"). cors_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()] add_cors_middleware(app, cors_origins) if "*" in cors_origins: logger.info( - "cors_wildcard_active: credential-less wildcard CORS is the documented " - "default for this header-authenticated API; set CORS_ORIGINS to an " - "explicit list to change it (see SECURITY_LIMITATIONS.md)" + "cors_wildcard_active: credential-less wildcard CORS is an explicit " + "opt-in, not the default; production-like boots refuse it " + "(see SECURITY_LIMITATIONS.md)" ) # Baseline response hardening (nosniff, framing, referrer, CSP, no-store diff --git a/gateway/app/middleware/request_body_limit.py b/gateway/app/middleware/request_body_limit.py index fff74849..6cfedbe2 100644 --- a/gateway/app/middleware/request_body_limit.py +++ b/gateway/app/middleware/request_body_limit.py @@ -43,6 +43,22 @@ def _too_large(limit: int) -> JSONResponse: ) +def _malformed_body() -> JSONResponse: + """400 when an http.request frame carries something other than bytes.""" + return JSONResponse( + status_code=400, + content={"detail": "Request body frame is not bytes."}, + ) + + +def _frame_chunk(message: Message) -> bytes | bytearray | None: + """Return the optional bytes body, or None for an invalid ASGI frame.""" + body = message.get("body", b"") + if isinstance(body, (bytes, bytearray)): + return body + return None + + class RequestBodyLimitMiddleware: """Reject request bodies larger than ``max_body_size`` with a 413.""" @@ -83,10 +99,18 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: break saw_body = True - buffered.extend(message.get("body", b"")) - if len(buffered) > self.max_body_size: + chunk = _frame_chunk(message) + if chunk is None: + await _malformed_body()(scope, receive, send) + return + # Count the frame before copying it. One ASGI message can carry + # the whole body, so copying first would ignore the ceiling. + incoming = len(chunk) + if len(buffered) + incoming > self.max_body_size: await _too_large(self.max_body_size)(scope, receive, send) return + if incoming: + buffered.extend(chunk) if not message.get("more_body", False): complete = True break diff --git a/gateway/app/middleware/security_headers.py b/gateway/app/middleware/security_headers.py index 834dd58f..8ebb8ed4 100644 --- a/gateway/app/middleware/security_headers.py +++ b/gateway/app/middleware/security_headers.py @@ -124,13 +124,26 @@ async def dispatch(self, request: Request, call_next) -> Response: "Content-Security-Policy", _content_security_policy(request, response), ) - if not _is_public_discovery(request.url.path): + if not _is_public_discovery(_request_path(request)): response.headers.setdefault("Cache-Control", "no-store") if _is_secure(request): response.headers.setdefault("Strict-Transport-Security", HSTS_VALUE) return response +def _request_path(request: Request) -> str: + """Return the path the router matches. + + ``request.url.path`` reparses the path and drops a literal ``?`` or ``#``. + A decoded ``%3F`` would otherwise make ``/health?not-health`` look like + the cacheable ``/health`` document. + """ + path = request.scope["path"] + if not isinstance(path, str): + raise TypeError("ASGI scope path must be a string") + return path + + def _normalized_path(path: str) -> str: if path != "/": return path.rstrip("/") or "/" @@ -138,7 +151,7 @@ def _normalized_path(path: str) -> str: def _content_security_policy(request: Request, response: Response) -> str: - path = _normalized_path(request.url.path) + path = _normalized_path(_request_path(request)) if path in _DOCS_HTML_PATHS: return DOCS_HTML_CSP content_type = ( diff --git a/gateway/app/routers/auth.py b/gateway/app/routers/auth.py index 8ff24646..1633fb00 100644 --- a/gateway/app/routers/auth.py +++ b/gateway/app/routers/auth.py @@ -13,8 +13,9 @@ from sqlmodel import col from app.core.jwt import JWTError, get_jwt_service +from app.core.time import utc_now from app.db.database import get_session_factory -from app.db.models import RefreshTokenModel +from app.db.models import APIKeyModel, RefreshTokenModel from app.schemas.auth import ( TokenExchangeRequest, TokenExchangeResponse, @@ -119,6 +120,17 @@ async def refresh_access_token( # and each mint a new chain, so a replayed stolen token survived rotation. # The guarded UPDATE matches for exactly one caller; every other one (and # an unknown jti) sees rowcount 0 and is refused. + # + # The claim, the originating-key liveness check, and the successor insert + # below all run in ONE transaction with a single commit. Committing the + # claim early would strand the client when a later step hits a transient + # DB error: RT1 revoked, no RT2 stored, 500, logged out despite a valid + # token. Business denials (unbound/dead origin key) still burn the token — + # the claim is committed before refusing — but an infrastructure failure + # before the single commit rolls everything back, so the client can retry + # with the same refresh token. + from app.services.api_key_service import _key_is_live + factory = get_session_factory() async with factory() as session: claimed = await session.execute( @@ -148,75 +160,78 @@ async def refresh_access_token( ) ) ).scalar_one() - await session.commit() - - # A JWT is derived authority: it exists only because an API key was - # presented at /token. Refreshing checked the signature and the revoked - # flag but never re-checked the underlying credential, so tokens minted - # from a stolen key kept renewing for the refresh lifetime after that key - # was revoked — revocation did not contain the compromise. - # - # Check the ORIGINATING key, not merely the wallet. Wallet-level liveness is - # too coarse: a wallet with several keys stays live after the compromised one - # is revoked, and auto_rotate_on_suspicious_activity revokes the suspect key - # while issuing a replacement, so the wallet is never keyless and the - # attacker's chain would survive the rotation meant to contain it. Binding - # also makes the revoke/refresh race benign — a token that wins the timing - # window is still bound to the revoked key, so it cannot be renewed. - # - # Tokens issued before binding existed carry no key_id. They cannot be - # safely associated with a still-live credential, so fail closed and force - # the caller to exchange an API key again. The migration also revokes all - # such rows, but this guard protects partially migrated/restored databases. - if origin_key_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "unbound_refresh_token", - "message": ( - "This refresh token predates API-key binding and cannot be " - "renewed; authenticate with an active API key." - ), - }, - ) - from app.services.api_key_service import get_api_key_service - - key_svc = get_api_key_service() - still_live = await key_svc.is_key_live(origin_key_id) - if not still_live: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={ - "error": "no_active_api_key", - "message": ( - "The API key this token was issued from is no longer active; " - "it cannot be renewed." - ), - }, - ) + # A JWT is derived authority: it exists only because an API key was + # presented at /token. Refreshing checked the signature and the revoked + # flag but never re-checked the underlying credential, so tokens minted + # from a stolen key kept renewing for the refresh lifetime after that key + # was revoked — revocation did not contain the compromise. + # + # Check the ORIGINATING key, not merely the wallet. Wallet-level liveness is + # too coarse: a wallet with several keys stays live after the compromised one + # is revoked, and auto_rotate_on_suspicious_activity revokes the suspect key + # while issuing a replacement, so the wallet is never keyless and the + # attacker's chain would survive the rotation meant to contain it. Binding + # also makes the revoke/refresh race benign — a token that wins the timing + # window is still bound to the revoked key, so it cannot be renewed. + # + # Tokens issued before binding existed carry no key_id. They cannot be + # safely associated with a still-live credential, so fail closed and force + # the caller to exchange an API key again. The migration also revokes all + # such rows, but this guard protects partially migrated/restored databases. + if origin_key_id is None: + # Unbound legacy token: burn it, then fail closed. + await session.commit() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "unbound_refresh_token", + "message": ( + "This refresh token predates API-key binding and cannot be " + "renewed; authenticate with an active API key." + ), + }, + ) - # Issue new tokens - new_access = jwt_svc.create_access_token( - wallet_id=payload.sub, - key_id=origin_key_id, - scopes=payload.scopes, - ) - new_refresh = jwt_svc.create_refresh_token( - wallet_id=payload.sub, scopes=payload.scopes - ) + # Liveness of the originating key, read in THIS transaction so a + # failing key store cannot burn the token silently: without a commit + # nothing is persisted and the client can retry. + origin_key = await session.get(APIKeyModel, origin_key_id) + if origin_key is None or not _key_is_live(origin_key, utc_now()): + # Dead origin: burn the token, then refuse renewal. + await session.commit() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": "no_active_api_key", + "message": ( + "The API key this token was issued from is no longer active; " + "it cannot be renewed." + ), + }, + ) - # Store new refresh token, carrying the binding forward so rotation cannot - # launder a chain into an unbound one. - new_refresh_payload = jwt_svc.verify_refresh_token(new_refresh) - async with factory() as session: - model = RefreshTokenModel( - jti=new_refresh_payload.jti, + # Issue new tokens (pure JWT operations, no I/O) and store the + # successor in the same transaction, carrying the binding forward so + # rotation cannot launder a chain into an unbound one. The single + # commit below persists the claim and the successor together. + new_access = jwt_svc.create_access_token( wallet_id=payload.sub, key_id=origin_key_id, - expires_at=_exp_to_datetime(new_refresh_payload.exp), + scopes=payload.scopes, + ) + new_refresh = jwt_svc.create_refresh_token( + wallet_id=payload.sub, scopes=payload.scopes + ) + new_refresh_payload = jwt_svc.verify_refresh_token(new_refresh) + session.add( + RefreshTokenModel( + jti=new_refresh_payload.jti, + wallet_id=payload.sub, + key_id=origin_key_id, + expires_at=_exp_to_datetime(new_refresh_payload.exp), + ) ) - session.add(model) await session.commit() return TokenRefreshResponse( diff --git a/gateway/app/routers/billing.py b/gateway/app/routers/billing.py index 38e4328e..653e5953 100644 --- a/gateway/app/routers/billing.py +++ b/gateway/app/routers/billing.py @@ -1,11 +1,14 @@ """ Agent Financial Gateways Router --------------------------------- -Two-tier wallet system: human sponsors (liability sinks) fund agent wallets. -Per-action micro-metering charges fractions of a cent per API call. -Swarm arbitrage silently books margin on every transaction. - -This is how the API generates revenue autonomously. +Two-tier wallet system: human sponsors fund agent wallets from an internal +credit balance. Per-action micro-metering charges credits per API call +against the calling wallet, with one ledger entry per charge. + +Credits are internal metering units, topped up through verified Stripe +webhooks where configured (or by explicit admin-gated operator issuance). +Core routes do not pay merchants out. ACP checkout, on the expansion +router, charges a Stripe Shared Payment Token and mints no credits. """ from decimal import Decimal @@ -27,6 +30,7 @@ WalletNotFoundError, ) from ..services.governance import record_governed_action +from ..services.ownership import CallerIdentity, OwnershipDeniedError from ..services.idempotency import ( IdempotencyConflictError, IdempotencyInProgressError, @@ -70,6 +74,22 @@ def _require_wallet_access(auth: AuthContext, wallet_id: str) -> None: auth.require_wallet_access(wallet_id) +def _ownership_denied(wallet_id: str) -> HTTPException: + """Map a service-layer ownership denial to 403. + + Routes still check first; this covers the service backstop so a denial + raised below the route keeps its meaning instead of escaping as a 500. + """ + return HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "wallet_access_denied", + "message": "API key is not authorized for this wallet.", + "wallet_id": wallet_id, + }, + ) + + def _session_not_found(session_id: str) -> HTTPException: return HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -228,8 +248,10 @@ async def _enforce_billing_policy( description=( "Bootstrap operator provisioning only. Create a human-owned root " "account that acts as the 'liability sink' for agent spending. " - "Sponsors ingest fiat currency via payment rails and convert it to " - "ecosystem credits. Agent wallets are provisioned from sponsor balances." + "Sponsors hold internal credits funded by verified Stripe top-ups " + "(USD only, where Stripe is configured) or by this explicit " + "admin-gated issuance. Agent wallets are provisioned from sponsor " + "balances. No settlement or payouts are performed." ), responses={ status.HTTP_403_FORBIDDEN: { @@ -321,12 +343,17 @@ async def create_agent_wallet( auto_refill=request.auto_refill, auto_refill_threshold=Decimal(str(request.auto_refill_threshold)), auto_refill_amount=Decimal(str(request.auto_refill_amount)), + caller=CallerIdentity.from_auth(auth), ) except WalletNotFoundError as e: # Every terminal branch must complete the record, or the key stays # in-progress and a retry answers 409 until the stale-record sweep. await guard.complete({"detail": str(e)}, 404) raise HTTPException(status_code=404, detail=str(e)) + except OwnershipDeniedError: + denied = _ownership_denied(request.sponsor_wallet_id) + await guard.complete({"detail": denied.detail}, denied.status_code) + raise denied except InsufficientFundsError as e: insufficient_detail = { "error": "insufficient_funds", @@ -390,6 +417,7 @@ async def create_child_wallet( task_description=request.task_description, ttl_seconds=request.ttl_seconds, auto_reclaim=request.auto_reclaim, + caller=CallerIdentity.from_auth(auth), ) return ChildWalletResponse( wallet_id=response.wallet_id, @@ -428,6 +456,8 @@ async def create_child_wallet( "message": str(e), }, ) + except OwnershipDeniedError: + raise _ownership_denied(request.parent_wallet_id) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -451,7 +481,9 @@ async def reclaim_child_wallet( ): _require_wallet_access(auth, wallet_id) try: - result = await money.reclaim_child_wallet(wallet_id) + result = await money.reclaim_child_wallet( + wallet_id, caller=CallerIdentity.from_auth(auth) + ) return ReclaimResponse( child_wallet_id=result["child_wallet_id"], parent_wallet_id=result["parent_wallet_id"], @@ -461,6 +493,8 @@ async def reclaim_child_wallet( ) except WalletNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) + except OwnershipDeniedError: + raise _ownership_denied(wallet_id) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -711,6 +745,7 @@ async def _complete_idempotency(response_json: dict, status_code: int) -> None: units=Decimal(str(units)), request_path=request_path, description=description or "", + caller=CallerIdentity.from_auth(auth), ) if isinstance(result, SimulatedChargeResult): @@ -776,6 +811,31 @@ async def _complete_idempotency(response_json: dict, status_code: int) -> None: ) await _complete_idempotency(result.model_dump(mode="json"), 200) return result + except OwnershipDeniedError: + # The route already authorized this wallet above; reaching here means + # the service backstop disagreed, which is still a 403, recorded and + # completed like every other charge denial. + await _record_billing_governance( + event="billing.charge", + auth=auth, + wallet_id=wallet_id, + service_category=category.value, + endpoint=endpoint, + request_id=request_id, + ok=False, + error="wallet_access_denied", + metadata={"units": units, "request_path": request_path}, + ) + denied_detail = { + "error": "wallet_access_denied", + "wallet_id": wallet_id, + "message": "API key is not authorized for this wallet.", + } + await _complete_idempotency( + {"detail": denied_detail}, + status.HTTP_403_FORBIDDEN, + ) + raise _ownership_denied(wallet_id) except WalletNotFoundError as e: # Every other endpoint in this router answers 404 for an unknown # wallet; this one let the exception escape as a 500, which reads as @@ -1025,7 +1085,9 @@ async def transfer_wallets( """ Transfer credits between two wallets. - This enables agent-to-agent payments for completed tasks. + This moves internal metering units between wallets, for example when + one agent compensates another from its budget. It is not a payment or + settlement: no money moves, only internal credits. Both wallets are locked during the transaction for ACID compliance. """ _require_wallet_access(auth, from_wallet_id) @@ -1053,6 +1115,7 @@ async def transfer_wallets( amount=Decimal(str(amount)), description=description or "", correlation_id=correlation_id, + caller=CallerIdentity.from_auth(auth), ) await _record_billing_governance( event="billing.transfer", @@ -1087,6 +1150,22 @@ async def transfer_wallets( ) await guard.complete({"error": "wallet_not_found", "message": str(e)}, 404) raise HTTPException(status_code=404, detail=str(e)) + except OwnershipDeniedError: + await _record_billing_governance( + event="billing.transfer", + auth=auth, + wallet_id=from_wallet_id, + service_category="transfer", + endpoint="/v1/billing/transfer", + request_id=correlation_id, + estimated_cost=amount, + ok=False, + error="wallet_access_denied", + metadata={"to_wallet_id": to_wallet_id}, + ) + denied = _ownership_denied(from_wallet_id) + await guard.complete({"detail": denied.detail}, denied.status_code) + raise denied except InsufficientFundsError as e: await _record_billing_governance( event="billing.transfer", @@ -1822,6 +1901,7 @@ async def simulate_charge( description=request.description or "", dry_run=True, dry_run_session_id=None, + caller=CallerIdentity.from_auth(auth), ) # AgentMoney._dry_run_charge (which charge(dry_run=True) always delegates diff --git a/gateway/app/routers/comms.py b/gateway/app/routers/comms.py index 9d9b3b17..d26b8b90 100644 --- a/gateway/app/routers/comms.py +++ b/gateway/app/routers/comms.py @@ -156,10 +156,12 @@ async def require_agent_owner( Applied to every endpoint that acts on behalf of an agent (send, ack, handoff, inbox, here and on ``/v1/agent-comms``) so an authenticated caller cannot impersonate an agent they do not own. Fails closed: an - unregistered id is 404 and an owner-less record is 403, because neither - has a tenant the caller could prove it belongs to. Bootstrap admins - (operator keys, outside every tenant) may still address those two, but - not an agent another caller registered. + unregistered id and an owned-by-someone-else id are both 404, so a + caller cannot tell whether another tenant's agent id exists. An + owner-less record is 403, because it has no tenant the caller could + prove it belongs to. Bootstrap admins (operator keys, outside every + tenant) may still address an unregistered id or an owner-less record, + but not an agent another caller registered. """ agent = await comms.registry.get(agent_id) if agent is None: @@ -176,6 +178,14 @@ async def require_agent_owner( return if not agent.owner_key and auth.is_bootstrap_admin: return + if agent.owner_key and not auth.is_bootstrap_admin: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "agent_not_found", + "message": f"Agent '{agent_id}' is not registered.", + }, + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ diff --git a/gateway/app/routers/dev_keys.py b/gateway/app/routers/dev_keys.py index 72789064..235ea93f 100644 --- a/gateway/app/routers/dev_keys.py +++ b/gateway/app/routers/dev_keys.py @@ -42,6 +42,7 @@ from ..core.trust_mode import is_production_like_environment from ..services.agent_money import get_agent_money from ..services.api_key_service import get_api_key_service +from ..services.ownership import CallerIdentity router = APIRouter( prefix="/v1/dev-keys", @@ -164,6 +165,7 @@ async def self_provision_dev_key( sponsor_wallet_id=sponsor.wallet_id, agent_id=agent_id, budget_credits=budget, + caller=CallerIdentity.for_wallet(sponsor.wallet_id), ) key = await get_api_key_service().create_key( wallet_id=agent.wallet_id, diff --git a/gateway/app/routers/iot.py b/gateway/app/routers/iot.py index 24062dea..5a058861 100644 --- a/gateway/app/routers/iot.py +++ b/gateway/app/routers/iot.py @@ -103,10 +103,16 @@ async def register_device( auth: AuthContext = Depends(get_auth_context), bridge: ProtocolBridge = Depends(get_iot_bridge), ): - # device_id is globally unique, so a taken id is a 409 for any caller; the - # response never names the existing owner. + # device_id is globally unique. A caller that already owns the id gets a + # 409; any other non-admin caller gets the same 404 as a missing id, so + # the response never reveals whether someone else's id exists and never + # names the existing owner. Bootstrap admins keep the 409. existing = await bridge.registry.get(device.device_id) if existing: + if not auth.is_bootstrap_admin and ( + auth.wallet_id is None or existing.owner_wallet_id != auth.wallet_id + ): + raise _device_not_found(device.device_id) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ diff --git a/gateway/app/routers/mcp.py b/gateway/app/routers/mcp.py index c2fb9145..33eb294e 100644 --- a/gateway/app/routers/mcp.py +++ b/gateway/app/routers/mcp.py @@ -58,6 +58,7 @@ LedgerOperationConflictError, LedgerWriteContendedError, ) +from ..services.ownership import CallerIdentity from ..services.service_registry import get_service_registry from ..services.action_permits import ( action_execution_identity, @@ -117,10 +118,12 @@ get_human_approval_service, get_idempotency_service, charge_units_for, + extract_recipient_identity, get_permit_service, get_quote_service, get_receipt_service, permit_constraints_snapshot, + recipient_binding_matches, record_audit_event, get_refund_reconciliation_service, resolve_client_idempotency_key, @@ -2003,11 +2006,10 @@ async def _execute_registered_tool_inner( and execution_backend == "upstream_mcp" ): upstream_origin = str(service.get("upstream_origin", "")) - from urllib.parse import urlparse - - parsed = urlparse(upstream_origin) - origin_domain = parsed.hostname or upstream_origin - if origin_domain != permit_model.recipient_domain: + origin_domain = extract_recipient_identity(upstream_origin) + if not recipient_binding_matches( + permit_model.recipient_domain, upstream_origin + ): audit_event = await _audit_mcp_invocation( effects_committed=False, decision=decision, @@ -2508,7 +2510,8 @@ async def _repair_pre_dispatch(exc: BaseException) -> Any: # release is best-effort, so it cannot stop the budget going back. await _release_iga_use(iga_granted_use, tool_name, reason=denial_reason) await get_permit_service().release_dispatch_budget_once( - dispatch_attempt.attempt_id + dispatch_attempt.attempt_id, + caller=CallerIdentity.for_wallet(wallet_id), ) else: if governed_call and permit_model: @@ -2652,6 +2655,7 @@ async def _repair_pre_dispatch(exc: BaseException) -> Any: wallet_id=wallet_id, charge_entry_id=charge_result.entry_id, description=f"Refund {description}", + caller=CallerIdentity.for_wallet(wallet_id), ) except Exception as refund_exc: # Two reasons. The diagnostic one carries the refund store's (and, @@ -2761,6 +2765,7 @@ async def _repair_pre_dispatch(exc: BaseException) -> Any: await get_permit_service().release_budget( permit_model.permit_id, registered_cost, + caller=CallerIdentity.for_wallet(permit_model.subject_wallet_id), ) except PermitWriteContendedError: # Guarded for the same reason _release_local_permit_reservation @@ -3020,6 +3025,7 @@ async def _charge_and_checkpoint( request_path=request_path, description=description, operation_key=idempotency_record_id if governed_call else None, + caller=CallerIdentity.for_wallet(wallet_id), ) if isinstance(charge_result, InsufficientFundsResponse): return charge_result, None, dispatch_attempt @@ -3383,6 +3389,7 @@ async def _raise_refunded_upstream_failure( wallet_id=wallet_id, charge_entry_id=ledger_entry_id, description=f"Refund {description}", + caller=CallerIdentity.for_wallet(wallet_id), ) await dispatch_service.mark_debit_refunded( attempt_id=dispatch_attempt.attempt_id, @@ -3441,7 +3448,8 @@ async def _raise_refunded_upstream_failure( try: await get_permit_service().release_dispatch_budget_once( - dispatch_attempt.attempt_id + dispatch_attempt.attempt_id, + caller=CallerIdentity.for_wallet(permit_model.subject_wallet_id), ) except PermitWriteContendedError: # The upstream half of the guard above, and the safer of the two: this @@ -3593,6 +3601,7 @@ async def _raise_charged_upstream_failure( idempotency_record_id=dispatch_attempt.idempotency_record_id, dispatch_attempt_id=dispatch_attempt.attempt_id, approval_id=(approval_check.approval_id if approval_check else None), + constraints_evaluated=_permit_constraints_snapshot(permit_model), ) except ReceiptWriteContendedError as exc: # Post-charge: the tool ran and the wallet moved, so a lost receipt @@ -3780,14 +3789,22 @@ async def _release_local_permit_reservation( """ permits = get_permit_service() try: - await permits.release_budget(permit_model.permit_id, registered_cost) + await permits.release_budget( + permit_model.permit_id, + registered_cost, + caller=CallerIdentity.for_wallet(permit_model.subject_wallet_id), + ) except Exception: logger.exception( "mcp_release_budget_failed", extra={"permit_id": permit_model.permit_id, "reason": reason}, ) try: - await permits.release_tool_call(permit_model.permit_id, tool_name) + await permits.release_tool_call( + permit_model.permit_id, + tool_name, + caller=CallerIdentity.for_wallet(permit_model.subject_wallet_id), + ) except Exception: logger.exception( "mcp_release_tool_call_failed", @@ -3894,6 +3911,11 @@ async def _finalize_governed_denial( dispatch_attempt_id=dispatch_attempt_id, response_hash_override=response_hash_override, approval_id=approval_id, + constraints_evaluated=( + _permit_constraints_snapshot(permit_model) + if dispatch_attempt_id is not None + else None + ), ) except ReceiptWriteContendedError as exc: if effects_committed: diff --git a/gateway/app/routers/mcp_public.py b/gateway/app/routers/mcp_public.py index a3141084..d5d8cad1 100644 --- a/gateway/app/routers/mcp_public.py +++ b/gateway/app/routers/mcp_public.py @@ -60,38 +60,55 @@ ) +# Image layout: both Dockerfiles copy the in-repo SDK to /sdk/python, install +# it, and set PYTHONPATH=/sdk/python/src. From /app/app/routers/mcp_public.py, +# parents[3] is /, so the checkout-relative path is this same file. +_IMAGE_SDK_RECEIPT_VERIFIER = Path("/sdk/python/src/b2a_sdk/receipt_verifier.py") +_CHECKOUT_SDK_RECEIPT_VERIFIER = ( + Path(__file__).resolve().parents[3] + / "sdk" + / "python" + / "src" + / "b2a_sdk" + / "receipt_verifier.py" +) + + +def _receipt_verifier_file_candidates() -> tuple[Path, ...]: + """Checkout path first. In the image that path is ``/sdk/python/...``.""" + if _CHECKOUT_SDK_RECEIPT_VERIFIER == _IMAGE_SDK_RECEIPT_VERIFIER: + return (_IMAGE_SDK_RECEIPT_VERIFIER,) + return (_CHECKOUT_SDK_RECEIPT_VERIFIER, _IMAGE_SDK_RECEIPT_VERIFIER) + + +def _load_receipt_verifier_file(module_path: Path): + spec = importlib.util.spec_from_file_location( + "_agent_middleware_receipt_verifier", + module_path, + ) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load receipt verifier from {module_path}") + receipt_verifier = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = receipt_verifier + spec.loader.exec_module(receipt_verifier) + return receipt_verifier + + def _import_receipt_verifier(): - """Import the first-party offline verifier, in-repo when not installed. + """Import the first-party offline verifier. - ``b2a_sdk`` is packaged for external verifiers and is not a declared app - dependency; tests see it via pytest's ``pythonpath`` and deployed images - carry the source tree at the repo root. Falling back to that tree keeps - the public tool running the *same* verifier code an outside party runs, - instead of a drift-prone reimplementation. + Images declare the dependency by installing ``/sdk/python`` and putting + ``/sdk/python/src`` on ``PYTHONPATH``. A checkout that has not installed + the package still loads that same file from the repository root. The + fallback is not a second implementation. """ try: - receipt_verifier = importlib.import_module("b2a_sdk.receipt_verifier") - except ImportError: - module_path = ( - Path(__file__).resolve().parents[3] - / "sdk" - / "python" - / "src" - / "b2a_sdk" - / "receipt_verifier.py" - ) - if not module_path.is_file(): - raise - spec = importlib.util.spec_from_file_location( - "_agent_middleware_receipt_verifier", - module_path, - ) - if spec is None or spec.loader is None: - raise ImportError(f"cannot load receipt verifier from {module_path}") - receipt_verifier = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = receipt_verifier - spec.loader.exec_module(receipt_verifier) - return receipt_verifier + return importlib.import_module("b2a_sdk.receipt_verifier") + except ImportError as import_error: + for module_path in _receipt_verifier_file_candidates(): + if module_path.is_file(): + return _load_receipt_verifier_file(module_path) + raise import_error _receipt_verifier = _import_receipt_verifier() @@ -452,8 +469,11 @@ async def _tool_verify_receipt( "endpoint will verify." ) - # Classify malformed/unsupported/presentation mismatches before touching - # the key store. A key outage must not mask a caller-input error. + # Malformed bundles and issuer mismatches need no key. An algorithm or + # canonicalization string is not authenticated until the signature is + # checked, so an empty key set must not classify it: that used to report + # UNSUPPORTED and skip the published keys. Unknown kid falls through and + # the real key set then decides invalid, unsupported, or unknown_key. preflight = _receipt_verifier.verify_bundle( bundle, {}, expected_issuer=expected_issuer ) diff --git a/gateway/app/routers/permits.py b/gateway/app/routers/permits.py index da8e454b..95920f2f 100644 --- a/gateway/app/routers/permits.py +++ b/gateway/app/routers/permits.py @@ -18,11 +18,13 @@ PermitVerifyResponse, ReceiptListResponse, ) +from app.services.ownership import CallerIdentity from app.trust import ( AgentMoney, IdempotencyConflictError, IdempotencyCreationDisabledError, IdempotencyInProgressError, + PermitAccessDeniedError, PermitCreationRejectedError, PermitError, get_agent_money, @@ -246,7 +248,11 @@ async def revoke_permit( raise HTTPException(status_code=404, detail="permit_not_found") auth.require_wallet_access(existing.issuer_wallet_id) try: - permit = await service.revoke_permit(permit_id) + permit = await service.revoke_permit( + permit_id, caller=CallerIdentity.from_auth(auth) + ) + except PermitAccessDeniedError as exc: + raise HTTPException(status_code=403, detail=exc.reason) except PermitError as exc: raise HTTPException(status_code=404, detail=exc.reason) return permit diff --git a/gateway/app/routers/webhooks.py b/gateway/app/routers/webhooks.py index bbfa5ef5..c51d0a61 100644 --- a/gateway/app/routers/webhooks.py +++ b/gateway/app/routers/webhooks.py @@ -37,15 +37,13 @@ async def handle_stripe_webhook(request: Request): - identity.verification_session.requires_input → Log - identity.verification_session.redacted → Expire verification """ + # Signature checks live in app.core.webhook_auth (fail closed, constant + # time); this router only maps the verdict to a status code. Missing and + # bad signatures both answer 401 with the same body, so probes cannot + # tell an unconfigured receiver from a wrong secret. payload = await request.body() sig_header = request.headers.get("stripe-signature", "") - if not sig_header: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing Stripe signature", - ) - stripe_integration = get_stripe_integration() try: success = await stripe_integration.handle_webhook(payload, sig_header) @@ -61,7 +59,7 @@ async def handle_stripe_webhook(request: Request): if not success: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Stripe signature", ) @@ -77,21 +75,17 @@ async def handle_stripe_identity_webhook(request: Request): Note: Stripe often sends these to the same /webhooks/stripe endpoint, but we handle them separately here for clarity. """ + # Same shared verification as the payment receiver above: missing and + # bad signatures both answer 401 with the same body. payload = await request.body() sig_header = request.headers.get("stripe-signature", "") - if not sig_header: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing Stripe signature", - ) - kyc_service = get_kyc_service() success = await kyc_service.handle_webhook(payload, sig_header) if not success: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Stripe signature", ) diff --git a/gateway/app/schemas/trust.py b/gateway/app/schemas/trust.py index 16681c43..4fb09e45 100644 --- a/gateway/app/schemas/trust.py +++ b/gateway/app/schemas/trust.py @@ -12,6 +12,7 @@ field_validator, model_validator, ) +from pydantic.json_schema import GetJsonSchemaHandler, JsonSchemaValue from app.core.credits import credit_amount_fits_storage from app.schemas.policies import PolicyBundleResponse @@ -32,10 +33,32 @@ def _require_storable_credit(amount: Decimal) -> Decimal: # Numeric scale alone cannot detect large amounts rounded by SQLite's float # conversion. Apply this before signing, hashing approval terms, or acceptance. +# +# pydantic 2.14 stopped emitting the decimal-string pattern in JSON schema for +# constrained Decimals. That pattern is part of the published permit contract +# (clients validate amounts against it before requesting a signature), so pin +# it explicitly instead of depending on pydantic internals. +_CREDIT_STRING_PATTERN = ( + r"^(?!^[-+.]*$)[+-]?0*(?:\d{0,12}|(?=[\d.]{1,21}0*$)\d{0,12}\.\d{0,8}0*$)" +) + + +class _StoredCreditJsonSchema: + def __get_pydantic_json_schema__( + self, schema: JsonSchemaValue, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + result = handler(schema) + for branch in result.get("anyOf", []): + if branch.get("type") == "string": + branch["pattern"] = _CREDIT_STRING_PATTERN + return result + + _PositiveStoredCredit = Annotated[ Decimal, Field(gt=0, max_digits=_CREDIT_DIGITS, decimal_places=_CREDIT_DECIMAL_PLACES), AfterValidator(_require_storable_credit), + _StoredCreditJsonSchema(), ] @@ -435,8 +458,12 @@ class PortableReceiptResponse(BaseModel): ) alg: str = "Ed25519" kid: str = Field(description="Identifies which published key signed this receipt.") + # Literal, not the RECEIPT_CANONICALIZATION constant: schemas stay free + # of service imports. The value is pinned to that constant (and to the + # spec and SDK copies) by tests/test_spec_conformance.py. canonicalization: str = Field( - description="Canonical-JSON contract version the signing_input follows." + default="awi-canonical-json/1", + description="Canonical-JSON contract version the signing_input follows.", ) signing_input: str = Field( description=( diff --git a/gateway/app/services/acp_bridge.py b/gateway/app/services/acp_bridge.py index dead210f..ef09227f 100644 --- a/gateway/app/services/acp_bridge.py +++ b/gateway/app/services/acp_bridge.py @@ -46,6 +46,7 @@ IdempotencyInProgressError, get_idempotency_service, ) +from app.services.ownership import CallerIdentity from app.services.permits import PermitError, get_permit_service from app.services.receipts import get_receipt_service from app.services.signing_keys import sha256_hex @@ -493,7 +494,11 @@ async def execute_checkout( # short-TTL permit; a wedged intent record has no such expiry). async def _rollback() -> None: try: - await get_permit_service().release_budget(permit.permit_id, credits) + await get_permit_service().release_budget( + permit.permit_id, + credits, + caller=CallerIdentity.for_wallet(agent_wallet_id), + ) except Exception: logger.exception( "ACP rollback failed to release %s credits on permit %s " diff --git a/gateway/app/services/agent_money.py b/gateway/app/services/agent_money.py index a70bc272..21248d54 100644 --- a/gateway/app/services/agent_money.py +++ b/gateway/app/services/agent_money.py @@ -24,6 +24,7 @@ WalletResponse, ) from .billing_engine import BillingEngine +from .ownership import CallerIdentity # DEFAULT_PRICING lives in .pricing so the governed price path, this facade, # and the dry-run shadow ledger all read one table. Re-exported here because @@ -163,6 +164,8 @@ async def create_agent_wallet( auto_refill_threshold: Decimal = Decimal("100.0"), auto_refill_amount: Decimal = Decimal("1000.0"), session: AsyncSession | None = None, + *, + caller: CallerIdentity, ) -> WalletResponse: return await self._wallet_engine.create_agent_wallet( sponsor_wallet_id=sponsor_wallet_id, @@ -173,6 +176,7 @@ async def create_agent_wallet( auto_refill_threshold=auto_refill_threshold, auto_refill_amount=auto_refill_amount, session=session, + caller=caller, ) async def create_child_wallet( @@ -184,6 +188,8 @@ async def create_child_wallet( task_description: str = "", ttl_seconds: int | None = None, auto_reclaim: bool = True, + *, + caller: CallerIdentity, ) -> WalletResponse: return await self._wallet_engine.create_child_wallet( parent_wallet_id=parent_wallet_id, @@ -193,10 +199,15 @@ async def create_child_wallet( task_description=task_description, ttl_seconds=ttl_seconds, auto_reclaim=auto_reclaim, + caller=caller, ) - async def reclaim_child_wallet(self, child_wallet_id: str) -> dict: - return await self._wallet_engine.reclaim_child_wallet(child_wallet_id) + async def reclaim_child_wallet( + self, child_wallet_id: str, *, caller: CallerIdentity + ) -> dict: + return await self._wallet_engine.reclaim_child_wallet( + child_wallet_id, caller=caller + ) async def transfer( self, @@ -205,6 +216,8 @@ async def transfer( amount: Decimal, description: str = "", correlation_id: str | None = None, + *, + caller: CallerIdentity, ) -> dict: return await self._wallet_engine.transfer( from_wallet_id=from_wallet_id, @@ -212,6 +225,7 @@ async def transfer( amount=amount, description=description, correlation_id=correlation_id, + caller=caller, ) async def is_wallet_or_descendant( @@ -273,6 +287,8 @@ async def charge( dry_run: bool = False, dry_run_session_id: str | None = None, operation_key: str | None = None, + *, + caller: CallerIdentity, ) -> LedgerEntry | InsufficientFundsResponse | SimulatedChargeResult: return await self._billing_engine.charge( wallet_id=wallet_id, @@ -283,6 +299,7 @@ async def charge( dry_run=dry_run, dry_run_session_id=dry_run_session_id, operation_key=operation_key, + caller=caller, ) async def refund_charge( @@ -291,11 +308,13 @@ async def refund_charge( wallet_id: str, charge_entry_id: str, description: str = "", + caller: CallerIdentity, ) -> LedgerEntry: return await self._billing_engine.refund_charge( wallet_id=wallet_id, charge_entry_id=charge_entry_id, description=description, + caller=caller, ) async def top_up( diff --git a/gateway/app/services/api_key_service.py b/gateway/app/services/api_key_service.py index 4a7ac465..9da804fa 100644 --- a/gateway/app/services/api_key_service.py +++ b/gateway/app/services/api_key_service.py @@ -435,12 +435,20 @@ async def validate_key(self, api_key: str) -> Optional[APIKeyModel]: # requests race for the remaining budget instead of all # reading the same stale count, so max_uses cannot be # overshot. rowcount 0 means the budget is spent. + # The expiry predicate is re-read here (not reused from the + # check above) so a key that passes the read and then times + # out before this write lands consumes nothing: expiry is + # enforced at write time, mirroring consume_derived_key_use. consumed = await session.execute( update(APIKeyModel) .where( col(APIKeyModel.key_id) == key.key_id, col(APIKeyModel.status) == APIKeyStatus.ACTIVE.value, col(APIKeyModel.use_count) < col(APIKeyModel.max_uses), + or_( + col(APIKeyModel.expires_at).is_(None), + col(APIKeyModel.expires_at) >= to_naive_utc(utc_now()), + ), ) .values( use_count=APIKeyModel.use_count + 1, @@ -659,33 +667,51 @@ async def revoke_key( """ Revoke an API key. + The write matches only a key that is not already revoked. A repeat + call returns True and leaves the stored ``revoked_at`` and + ``revoke_reason`` in place. A missing key still raises + ``KeyNotFoundError``. + Args: wallet_id: Wallet owning the key key_id: Key to revoke reason: Reason for revocation Returns: - True if revoked successfully + True when the key is revoked, including when it already was. """ async with self._session_factory()() as session: - result = await session.execute( - select(APIKeyModel).where( + updated = await session.execute( + update(APIKeyModel) + .where( col(APIKeyModel.key_id) == key_id, col(APIKeyModel.wallet_id) == wallet_id, + col(APIKeyModel.status) != APIKeyStatus.REVOKED.value, ) + .values( + status=APIKeyStatus.REVOKED.value, + revoked_at=utc_now(), + revoke_reason=reason, + ) + .execution_options(synchronize_session=False) ) - key = result.scalar_one_or_none() - - if not key: - raise KeyNotFoundError(key_id) - - key.status = APIKeyStatus.REVOKED.value - key.revoked_at = utc_now() - key.revoke_reason = reason - session.add(key) + wrote = (cast(Any, updated).rowcount or 0) == 1 + if not wrote: + existing = await session.execute( + select(APIKeyModel).where( + col(APIKeyModel.key_id) == key_id, + col(APIKeyModel.wallet_id) == wallet_id, + ) + ) + if existing.scalar_one_or_none() is None: + raise KeyNotFoundError(key_id) + # Commit whenever the key exists. rowcount decides only whether + # this call performed the revocation; a false 0 must not roll + # the guarded write back and report success. await session.commit() - logger.warning(f"Revoked API key {key_id} for wallet {wallet_id}: {reason}") + if wrote: + logger.warning(f"Revoked API key {key_id} for wallet {wallet_id}: {reason}") return True async def emergency_revocation( diff --git a/gateway/app/services/awi_http_governance.py b/gateway/app/services/awi_http_governance.py index 523ba9fb..9001879c 100644 --- a/gateway/app/services/awi_http_governance.py +++ b/gateway/app/services/awi_http_governance.py @@ -35,6 +35,7 @@ get_idempotency_service, resolve_client_idempotency_key, ) +from app.services.ownership import CallerIdentity from app.services.permits import PermitError, get_permit_service from app.services.receipts import get_receipt_service @@ -335,7 +336,11 @@ async def _admit_awi_http_governed(ctx: AwiHttpGovernedContext) -> None: permits = get_permit_service() idem = get_idempotency_service() try: - await permits.reserve_budget(ctx.permit_id, ctx.credits) + await permits.reserve_budget( + ctx.permit_id, + ctx.credits, + caller=CallerIdentity.for_wallet(ctx.wallet_id), + ) except PermitError as exc: detail = {"error": exc.reason, "tool": ctx.tool_name} if exc.reason == "permit_write_contended": @@ -363,6 +368,7 @@ async def _admit_awi_http_governed(ctx: AwiHttpGovernedContext) -> None: request_path=ctx.endpoint, description=f"AWI HTTP {ctx.tool_name}", operation_key=ctx.record_id, + caller=CallerIdentity.for_wallet(ctx.wallet_id), ) except LedgerWriteContendedError as exc: # Billing's contract proves every attempt rolled back (including its @@ -490,6 +496,7 @@ async def _refund_undispatched(ctx: AwiHttpGovernedContext) -> None: wallet_id=ctx.wallet_id, charge_entry_id=ctx.ledger_entry_id, description=f"AWI HTTP {ctx.tool_name}: not dispatched", + caller=CallerIdentity.for_wallet(ctx.wallet_id), ) ctx.credits_charged = Decimal("0") await _release_reservation(get_permit_service(), ctx) @@ -530,7 +537,11 @@ async def complete_awi_http_governed( async def _release_reservation(permits: Any, ctx: AwiHttpGovernedContext) -> bool: try: - await permits.release_budget(ctx.permit_id, ctx.credits) + await permits.release_budget( + ctx.permit_id, + ctx.credits, + caller=CallerIdentity.for_wallet(ctx.wallet_id), + ) return True except Exception: logger.exception( diff --git a/gateway/app/services/awi_playwright_bridge.py b/gateway/app/services/awi_playwright_bridge.py index d4b5e2e6..130b7936 100644 --- a/gateway/app/services/awi_playwright_bridge.py +++ b/gateway/app/services/awi_playwright_bridge.py @@ -502,6 +502,9 @@ async def create_session( await self._navigate_to(session, target_url) except Exception as e: logger.warning(f"Browser setup for session {session_id}: {e}") + # A refused or failed navigation must not leave the session + # claiming to stand at the target URL. + session.current_url = None logger.info(f"Created DOM bridge session {session_id} for {target_url}") @@ -1895,6 +1898,14 @@ async def _execute_single_command( await asyncio.sleep(0.3) # Allow render elif cmd_type == CommandType.GOTO: + # Revalidate at execution time: GOTO commands are built (and + # checked) in _handle_navigate_to but run later, so DNS may + # have rebound in between. Refuse rather than navigate. + goto_block_reason = await check_outbound_url(command.target) + if goto_block_reason: + raise ValueError( + f"navigation blocked ({goto_block_reason}): {command.target}" + ) await page.goto( command.target, wait_until="networkidle", @@ -2023,6 +2034,14 @@ async def _navigate_to(self, session: BridgeSession, url: str) -> None: session.current_url = url return + # Revalidate immediately before navigation: the pre-flight check in + # create_session may have run long enough ago for DNS to rebind to + # an intranet address. The browser does its own resolution, so this + # last-moment check is the closest pinning available on this path. + block_reason = await check_outbound_url(url) + if block_reason: + raise ValueError(f"navigation blocked ({block_reason}): {url}") + try: await session._page.goto( url, wait_until="networkidle", timeout=self._default_timeout_ms diff --git a/gateway/app/services/awi_session.py b/gateway/app/services/awi_session.py index 28e21c98..229f9dce 100644 --- a/gateway/app/services/awi_session.py +++ b/gateway/app/services/awi_session.py @@ -238,25 +238,33 @@ async def execute_action( error=f"Preconditions not met: {unmet}", ) - # Phase 9: Check passkey requirement for high-risk actions - from .webauthn_provider import get_webauthn_provider + # Phase 9: Check passkey requirement for high-risk actions. Proof + # surfaces only: the provider is dev-gated (in-memory credentials, not + # a production login path), so outside ENABLE_PROOF_SURFACES there is + # no passkey surface to enforce and the singleton must not be built. + from ..core.config import get_settings - webauthn = get_webauthn_provider() + if get_settings().ENABLE_PROOF_SURFACES: + from .webauthn_provider import get_webauthn_provider - if await webauthn.requires_passkey(request.session_id, request.action.value): - if not await webauthn.is_action_verified( + webauthn = get_webauthn_provider() + + if await webauthn.requires_passkey( request.session_id, request.action.value ): - return AWIExecutionResponse( - execution_id=execution_id, - session_id=request.session_id, - action=request.action, - status="passkey_required", - parameters=response_parameters, - effect_status="not_dispatched", - error="This action requires biometric verification. " - "Call POST /v1/awi/passkey/challenge first.", - ) + if not await webauthn.is_action_verified( + request.session_id, request.action.value + ): + return AWIExecutionResponse( + execution_id=execution_id, + session_id=request.session_id, + action=request.action, + status="passkey_required", + parameters=response_parameters, + effect_status="not_dispatched", + error="This action requires biometric verification. " + "Call POST /v1/awi/passkey/challenge first.", + ) # Phase 9: Route to Playwright DOM bridge if attached dom_session_id = self._dom_sessions.get(request.session_id) diff --git a/gateway/app/services/behavioral_sandbox.py b/gateway/app/services/behavioral_sandbox.py index 88cadff8..9d362ee2 100644 --- a/gateway/app/services/behavioral_sandbox.py +++ b/gateway/app/services/behavioral_sandbox.py @@ -23,13 +23,15 @@ import uuid from datetime import datetime, timezone from typing import Any +from urllib.parse import urlsplit +import httpx import redis.asyncio as redis from ..core.config import get_settings from ..core.durable_state import get_durable_state from ..core.trust_mode import UNSAFE_HOST_SANDBOX_BACKENDS -from ..core.url_guard import check_outbound_url +from ..core.url_guard import pin_outbound_url from ..schemas.sandbox_behavioral import ( ExecutionStatus, SandboxEnvironment, @@ -42,6 +44,48 @@ logger = logging.getLogger(__name__) +class _PinnedOutboundTransport(httpx.AsyncBaseTransport): + """Connect to one validated address while keeping Host header and TLS SNI. + + Mirrors the upstream MCP ``_PinnedAsyncTransport`` approach, but for + agent-supplied sandbox URLs that vary per request: the caller validates + once via ``pin_outbound_url`` and hands the pinned address here, so the + connection can only reach the IP that passed validation. Resolving the + hostname again at connect time would reopen DNS rebinding (a second + lookup can return a private address after validation saw a public one). + """ + + def __init__( + self, + *, + server_name: str, + pinned_address: str, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._server_name = server_name + self._pinned_address = pinned_address + self._transport = transport or httpx.AsyncHTTPTransport( + verify=True, + retries=0, + trust_env=False, + ) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + extensions = dict(request.extensions) + extensions["sni_hostname"] = self._server_name + pinned_request = httpx.Request( + request.method, + request.url.copy_with(host=self._pinned_address), + headers=request.headers, + stream=request.stream, + extensions=extensions, + ) + return await self._transport.handle_async_request(pinned_request) + + async def aclose(self) -> None: + await self._transport.aclose() + + def _limit_child_process(memory_limit_mb: int) -> None: """Apply best-effort resource limits before executing opt-in host Python.""" memory_bytes = memory_limit_mb * 1024 * 1024 @@ -599,13 +643,16 @@ async def _execute_http_proxy( timeout_seconds: int, *, network_access: bool = False, + transport: httpx.AsyncBaseTransport | None = None, ) -> dict[str, Any]: """Execute an HTTP request in proxy mode.""" url = tool_input.get("url") method = tool_input.get("method", "GET") - if not url: + if not url or not isinstance(url, str): return {"success": False, "error": "URL required for HTTP proxy mode"} + if not isinstance(method, str) or not method: + return {"success": False, "error": "HTTP method must be a string"} if dry_run: return { @@ -628,7 +675,7 @@ async def _execute_http_proxy( "success": False, "error": "network_access is disabled for this environment", } - block_reason = await check_outbound_url(url) + block_reason, pinned_address = await pin_outbound_url(url) if block_reason: return { "success": False, @@ -636,36 +683,43 @@ async def _execute_http_proxy( } try: - import aiohttp - - async with aiohttp.ClientSession() as session: + server_name = (urlsplit(url).hostname or "").strip("[]").rstrip(".").lower() + pinned_transport: httpx.AsyncBaseTransport | None = None + if pinned_address is not None: + # Connect to the IP that passed validation instead of + # resolving the hostname a second time: a fresh lookup here + # could return a private address after validation saw a + # public one (DNS rebinding). + pinned_transport = _PinnedOutboundTransport( + server_name=server_name, + pinned_address=pinned_address, + transport=transport, + ) + async with httpx.AsyncClient( + transport=pinned_transport, # Don't follow redirects: the URL guard only vetted the # original target, so a 3xx to a loopback/metadata address # would otherwise slip past it. - async with session.request( - method, - url, - timeout=aiohttp.ClientTimeout(total=timeout_seconds), - allow_redirects=False, - ) as resp: - return { - "success": True, - "output": { - "sandboxed": True, - "status_code": resp.status, - "url": str(resp.url), - }, - } - except ImportError: - return { - "success": True, - "output": { - "sandboxed": True, - "mode": "http_proxy_mock", - "url": url, - "method": method, - }, - } + follow_redirects=False, + # Proxy env vars must not steer the pinned connection away + # from the validated address. + trust_env=False, + timeout=timeout_seconds, + ) as client: + # Only the status code is used, so stream and close after + # the headers arrive: a huge or never-ending body must not + # buffer into gateway memory (the byte budget applies to the + # subprocess path, not this proxy fetch). + async with client.stream(method.upper(), url) as resp: + status_code = resp.status_code + return { + "success": True, + "output": { + "sandboxed": True, + "status_code": status_code, + "url": url, + }, + } except Exception as e: return {"success": False, "error": str(e)} diff --git a/gateway/app/services/billing_engine.py b/gateway/app/services/billing_engine.py index a4575e2e..cd3ad3f0 100644 --- a/gateway/app/services/billing_engine.py +++ b/gateway/app/services/billing_engine.py @@ -46,6 +46,7 @@ WalletStatus, WalletType, ) +from .ownership import CallerIdentity, require_wallet_owner from .pricing import PROOF_SURFACE_CATEGORIES from .shadow_ledger import SimulatedChargeResult, get_shadow_ledger from .velocity_monitor import get_velocity_monitor @@ -609,6 +610,8 @@ async def charge( dry_run: bool = False, dry_run_session_id: str | None = None, operation_key: str | None = None, + *, + caller: CallerIdentity, ) -> LedgerEntry | InsufficientFundsResponse | SimulatedChargeResult: """ Charge an agent wallet for API usage with ACID transaction. @@ -618,7 +621,11 @@ async def charge( If dry_run=True, simulates the charge without affecting real balance or triggering velocity monitoring. Uses the shadow ledger for tracking. + + The caller must own the wallet, simulated or not: a dry run still + answers whether the wallet could cover the charge. """ + require_wallet_owner(caller, wallet_id) # Non-finite units are refused here, not only at the HTTP boundary. # This is the choke point every caller passes through -- the governed # MCP path and the SDK reach it without going near the billing router @@ -1063,8 +1070,10 @@ async def refund_charge( wallet_id: str, charge_entry_id: str, description: str = "", + caller: CallerIdentity, ) -> LedgerEntry: """Reverse a prior debit with a correlated refund ledger entry.""" + require_wallet_owner(caller, wallet_id) async with self._session_factory()() as session: async with session.begin(): refund_entry_id = f"refund-{charge_entry_id}" diff --git a/gateway/app/services/kyc_service.py b/gateway/app/services/kyc_service.py index 23c1e51f..8b95533e 100644 --- a/gateway/app/services/kyc_service.py +++ b/gateway/app/services/kyc_service.py @@ -23,6 +23,7 @@ from ..db.database import get_session_factory from ..db.models import KYCVerificationModel, WalletModel from ..core.config import get_settings +from ..core.webhook_auth import WebhookVerificationError, verify_stripe_event from ..schemas.billing import KYCDocumentType, KYCStatus, WalletStatus from .agent_money import WalletNotFoundError from .wallet_status import SPENDABLE_WALLET_STATUSES @@ -327,15 +328,13 @@ async def handle_webhook(self, payload: bytes, sig_header: str) -> bool: True if processed successfully, False on signature failure """ try: - event = stripe.Webhook.construct_event( + event = verify_stripe_event( payload, sig_header, - settings.STRIPE_WEBHOOK_SECRET, + get_settings().STRIPE_WEBHOOK_SECRET, ) - # SignatureVerificationError is a StripeError, not a ValueError, so it - # must be named: otherwise a bad signature escapes as a 500. - except (ValueError, stripe.SignatureVerificationError) as e: - logger.error(f"Invalid Stripe signature: {e}") + except WebhookVerificationError as e: + logger.warning("Rejected Stripe Identity webhook: %s", e) return False handler_map = { diff --git a/gateway/app/services/mcp_dispatch_reconciliation.py b/gateway/app/services/mcp_dispatch_reconciliation.py index d4939359..9ed7dc0a 100644 --- a/gateway/app/services/mcp_dispatch_reconciliation.py +++ b/gateway/app/services/mcp_dispatch_reconciliation.py @@ -27,6 +27,7 @@ from app.services.audit_log import AuditEvent, record_audit_event from app.services.jev_guard_metadata import jev_audit_id, load_jev_guard_metadata from app.services.idempotency import IdempotencyService, get_idempotency_service +from app.services.ownership import CallerIdentity from app.services.mcp_dispatch_attempts import ( DISPATCH_SENT_STATES, DISPATCH_PREPARED, @@ -43,9 +44,20 @@ permit_constraints_snapshot, ) from app.services.receipts import ReceiptError, ReceiptService, get_receipt_service -from app.services.signing_keys import sha256_hex +from app.services.signing_keys import canonical_json, sha256_hex logger = logging.getLogger(__name__) +_PERMIT_SNAPSHOT_KEYS = frozenset( + { + "max_calls_per_tool", + "aggregate_value_cap", + "forbidden_fields", + "recipient_domain", + } +) +_LEGACY_UNSNAPSHOTTED_OUTCOMES = frozenset( + {"failed_refunded", "delivery_uncertain", "response_rejected"} +) @dataclass(frozen=True) @@ -220,7 +232,10 @@ async def reconcile( if await self._find_operation_debit(attempt) is not None: continue try: - if await self._permits.release_dispatch_budget_once(attempt.attempt_id): + if await self._permits.release_dispatch_budget_once( + attempt.attempt_id, + caller=CallerIdentity.for_wallet(attempt.wallet_id), + ): budget_released += 1 except Exception as exc: failed.append(attempt.attempt_id) @@ -447,6 +462,7 @@ async def _compensate_returned_error( description=( f"Reconcile refunded upstream MCP dispatch {attempt.attempt_id}" ), + caller=CallerIdentity.for_wallet(attempt.wallet_id), ) await self._dispatch.mark_debit_refunded( attempt_id=attempt.attempt_id, @@ -477,7 +493,10 @@ async def _compensate_returned_error( raise DispatchAttemptError("dispatch_receipt_outcome_conflict") if attempt.budget_released_at is not None: return - await self._permits.release_dispatch_budget_once(attempt.attempt_id) + await self._permits.release_dispatch_budget_once( + attempt.attempt_id, + caller=CallerIdentity.for_wallet(attempt.wallet_id), + ) async def _find_operation_debit( self, @@ -724,21 +743,31 @@ async def _get_or_create_receipt( audit_event_id: str, constraints_evaluated: dict[str, Any] | None = None, ) -> ReceiptResponse: - existing = await self._receipts.get_receipt_by_idempotency_record_id( - attempt.idempotency_record_id - ) - if existing is not None: + async def adopt_existing(existing: ReceiptResponse) -> ReceiptResponse: valid, _, _ = await self._receipts.verify_receipt(existing.receipt_id) if not valid: raise DispatchAttemptError("dispatch_receipt_signature_invalid") await self._receipts.assert_action_receipt_binding(existing) + effective_constraints = await self._receipts.effective_constraints( + constraints_evaluated, + audit_event_id, + attempt.wallet_id, + include_jev="jev_risk_guard" in existing.constraints_evaluated, + ) self._assert_receipt_match( existing, attempt=attempt, outcome=outcome, audit_event_id=audit_event_id, + constraints_evaluated=effective_constraints, ) return existing + + existing = await self._receipts.get_receipt_by_idempotency_record_id( + attempt.idempotency_record_id + ) + if existing is not None: + return await adopt_existing(existing) credits_charged = ( Decimal("0") if attempt.state == "returned_error" @@ -776,14 +805,7 @@ async def _get_or_create_receipt( ) if existing is None: raise - await self._receipts.assert_action_receipt_binding(existing) - self._assert_receipt_match( - existing, - attempt=attempt, - outcome=outcome, - audit_event_id=audit_event_id, - ) - return existing + return await adopt_existing(existing) @staticmethod def _assert_receipt_match( @@ -792,6 +814,7 @@ def _assert_receipt_match( attempt: McpDispatchAttemptModel, outcome: str, audit_event_id: str, + constraints_evaluated: dict[str, Any] | None, ) -> None: expected_charged = ( Decimal("0") @@ -816,6 +839,28 @@ def _assert_receipt_match( } if any(getattr(receipt, key) != value for key, value in expected.items()): raise DispatchAttemptError("dispatch_receipt_linkage_conflict") + signed_constraints = receipt.constraints_evaluated + expected_constraints = constraints_evaluated or {} + if canonical_json(signed_constraints) != canonical_json(expected_constraints): + # Older direct failure paths signed valid receipts before permit-v2 + # snapshots were attached. Recover only that exact legacy shape; + # a receipt with any signed permit evidence must match in full. + legacy_omission = ( + outcome in _LEGACY_UNSNAPSHOTTED_OUTCOMES + and set(signed_constraints) <= {"jev_risk_guard"} + and set(expected_constraints) + <= _PERMIT_SNAPSHOT_KEYS | {"jev_risk_guard"} + and canonical_json(signed_constraints) + == canonical_json( + { + key: value + for key, value in expected_constraints.items() + if key == "jev_risk_guard" + } + ) + ) + if not legacy_omission: + raise DispatchAttemptError("dispatch_receipt_linkage_conflict") expected_reason_code = ( None if attempt.state == "succeeded" diff --git a/gateway/app/services/ownership.py b/gateway/app/services/ownership.py new file mode 100644 index 00000000..970886c1 --- /dev/null +++ b/gateway/app/services/ownership.py @@ -0,0 +1,136 @@ +"""Caller identity and ownership enforcement for money and permit services. + +Pattern 3 fix. Routes used to be the only place that checked whether the +caller owned the wallet or permit a mutation touched; the services trusted +whatever id they were handed. Every service function in this module's scope +now takes a :class:`CallerIdentity` and enforces ownership itself, so a +route (or a future caller) that forgets its check is denied at the row +instead of spending someone else's money. + +The identity is a plain value: wallet id, key id, and whether the caller is +a bootstrap admin. Engine paths that already authorized the wallet another +way (permit validation, an admitted governed context, a reconciled attempt +row) pass :meth:`CallerIdentity.for_wallet` with that proven wallet id, so +the service still verifies the row belongs to it. There is no silent +system bypass: ``is_bootstrap_admin`` is the only exemption, and only real +bootstrap credentials construct it (see ``from_auth``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +class OwnershipDeniedError(RuntimeError): + """The caller owns neither the wallet nor the permit a mutation touches. + + A plain ``RuntimeError`` on purpose: it must not be caught by the + ``except ValueError`` handlers around money writes, which map bad input + to 400. Routers map this to 403. + """ + + def __init__(self, reason: str = "owner_access_denied") -> None: + self.reason = reason + super().__init__(reason) + + +@dataclass(frozen=True) +class CallerIdentity: + """Who is asking a money or permit service to mutate a row.""" + + wallet_id: str | None = None + key_id: str | None = None + is_bootstrap_admin: bool = False + is_engine_proven: bool = False + + @classmethod + def from_auth(cls, auth: Any) -> CallerIdentity: + """Build the service-layer identity from a route's ``AuthContext``.""" + return cls( + wallet_id=auth.wallet_id, + key_id=auth.key_id, + is_bootstrap_admin=bool(auth.is_bootstrap_admin), + ) + + @classmethod + def for_wallet( + cls, wallet_id: str | None, key_id: str | None = None + ) -> CallerIdentity: + """Identity for an engine path whose wallet was already authorized. + + The wallet id must come from proof the engine holds (a validated + permit, an admitted governed context, a reconciled attempt row), + never from an unverified request field. + """ + return cls(wallet_id=wallet_id, key_id=key_id, is_engine_proven=True) + + @classmethod + def admin(cls) -> CallerIdentity: + """Bootstrap operator identity. Routes never build this from input.""" + return cls(is_bootstrap_admin=True) + + +def require_wallet_owner(caller: CallerIdentity, wallet_id: str | None) -> None: + """Allow only the owning wallet (or a bootstrap admin) to mutate it.""" + if caller.is_bootstrap_admin: + return + if ( + caller.wallet_id is not None + and wallet_id is not None + and caller.wallet_id == wallet_id + ): + return + raise OwnershipDeniedError("wallet_access_denied") + + +def require_permit_party( + caller: CallerIdentity, + *, + issuer_wallet_id: str | None, + subject_wallet_id: str | None, + subject_key_id: str | None, + error: type[RuntimeError] = OwnershipDeniedError, + reason: str = "permit_access_denied", +) -> None: + """Allow the issuer or the bound subject key to mutate permit accounting.""" + if caller.is_bootstrap_admin: + return + # When issuer and subject are the same wallet, another key in that wallet + # must not bypass the subject-key binding by presenting as the issuer. + if ( + caller.wallet_id is not None + and caller.wallet_id == issuer_wallet_id + and issuer_wallet_id != subject_wallet_id + ): + return + if ( + caller.wallet_id is not None + and caller.wallet_id == subject_wallet_id + and ( + subject_key_id is None + or caller.key_id == subject_key_id + or (caller.key_id is None and caller.is_engine_proven) + ) + ): + return + raise error(reason) + + +def require_permit_issuer( + caller: CallerIdentity, + *, + issuer_wallet_id: str | None, + error: type[RuntimeError] = OwnershipDeniedError, + reason: str = "permit_access_denied", +) -> None: + """Allow only the wallet that issued the permit (revocation owns authority).""" + if caller.is_bootstrap_admin: + return + if ( + caller.wallet_id is not None + and issuer_wallet_id is not None + and caller.wallet_id == issuer_wallet_id + ): + return + raise error(reason) diff --git a/gateway/app/services/permit_requests.py b/gateway/app/services/permit_requests.py index 0d28f1b0..c3492f38 100644 --- a/gateway/app/services/permit_requests.py +++ b/gateway/app/services/permit_requests.py @@ -261,22 +261,7 @@ async def create( ) -> PermitRequestModel: """Record the request and page a human for the decision.""" simulated = is_simulation("human_approval") - - available, reason = human_approval_available() - if not available: - # No way to reach a human: refuse rather than bank a request that - # can never be decided. - raise PermitRequestError(reason or "human_approval_not_configured") - expires_at = to_naive_utc(permit_expires_at) - now = utc_now() - if expires_at <= now: - raise PermitRequestError("permit_expired_at_request") - if max_credits.is_finite() and max_credits <= Decimal("0"): - raise PermitRequestError("max_credits_must_be_positive") - if not credit_amount_fits_storage(max_credits): - raise PermitRequestError("max_credits_not_storable") - effective_scopes = scopes or [f"tool:{tool}:invoke" for tool in allowed_tools] if "billing:charge" not in effective_scopes: effective_scopes = [*effective_scopes, "billing:charge"] @@ -299,6 +284,20 @@ async def create( raise PermitRequestConflictError() return await self.advance(existing) + available, reason = human_approval_available() + if not available: + # No way to reach a human: refuse rather than bank a request that + # can never be decided. + raise PermitRequestError(reason or "human_approval_not_configured") + + now = utc_now() + if expires_at <= now: + raise PermitRequestError("permit_expired_at_request") + if max_credits.is_finite() and max_credits <= Decimal("0"): + raise PermitRequestError("max_credits_must_be_positive") + if not credit_amount_fits_storage(max_credits): + raise PermitRequestError("max_credits_not_storable") + model = PermitRequestModel( request_id=f"preq-{uuid.uuid4().hex[:16]}", issuer_wallet_id=issuer_wallet_id, diff --git a/gateway/app/services/permits.py b/gateway/app/services/permits.py index 70391695..83e6492d 100644 --- a/gateway/app/services/permits.py +++ b/gateway/app/services/permits.py @@ -2,11 +2,13 @@ import json import logging +import re import uuid from dataclasses import dataclass from datetime import datetime, timedelta from decimal import Decimal from typing import Any, cast +from urllib.parse import urlparse from sqlalchemy import case, func, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession @@ -29,6 +31,11 @@ ) from app.schemas.billing import AlertType from app.schemas.trust import ActionPermitFields, PermitCreateRequest, PermitResponse +from app.services.ownership import ( + CallerIdentity, + require_permit_issuer, + require_permit_party, +) from app.services.signing_keys import get_signing_key_service, sha256_hex logger = logging.getLogger(__name__) @@ -40,6 +47,18 @@ def __init__(self, reason: str) -> None: super().__init__(reason) +class PermitAccessDeniedError(PermitError): + """The caller owns neither side of the permit it tried to mutate. + + A ``PermitError`` so existing handlers keep classifying it, with a + distinct reason so routers answer 403 instead of the 404/400 those + handlers give every other permit failure. + """ + + def __init__(self, reason: str = "permit_access_denied") -> None: + super().__init__(reason) + + class PermitCreationRejectedError(PermitError): """Issuance validation rejected before permit signing or persistence. @@ -207,6 +226,45 @@ def _find_forbidden_field(arguments: Any, forbidden: set[str]) -> str | None: return None +def extract_recipient_identity(target: str) -> str: + """Reduce one recipient value to the identity the permit binds. + + A URL contributes its hostname; a bare value with no host part (an + x402 pay_to chain address, a registered origin without a scheme) counts + as itself. This is the same reduction the upstream MCP path has always + applied before comparing against the permit's recipient constraint. + """ + return urlparse(target).hostname or target + + +_EVM_HEX_ADDRESS = re.compile(r"0x[0-9a-fA-F]{40}") + + +def recipient_binding_matches(recipient_domain: str | None, target: str) -> bool: + """Check one recipient value against a permit's recipient constraint. + + An unset constraint allows everything. Otherwise the target is reduced + with extract_recipient_identity and must equal the bound value. + x402 callers pass the demand's pay_to address: x402 demands + name no resource host, so the payee address is the only recipient the + demand names, and a permit that binds x402 spending must carry that + address exactly as the demand presents it. + + EVM addresses are 20-byte values: hex-letter casing (lowercase vs + EIP-55 checksum form) does not change the identity, so two EVM-shaped + values compare case-insensitively. Solana base58 addresses, hostnames, + and every other recipient form stay case-sensitive. + """ + if not recipient_domain: + return True + candidate = extract_recipient_identity(target) + if _EVM_HEX_ADDRESS.fullmatch(recipient_domain) and _EVM_HEX_ADDRESS.fullmatch( + candidate + ): + return recipient_domain.lower() == candidate.lower() + return candidate == recipient_domain + + def permit_model_to_response(model: PermitModel) -> PermitResponse: return PermitResponse( **ActionPermitFields.model_validate( @@ -453,17 +511,52 @@ async def _persist_permit( await session.refresh(model) return permit_model_to_response(model) - async def revoke_permit(self, permit_id: str) -> PermitResponse: + async def revoke_permit( + self, permit_id: str, *, caller: CallerIdentity + ) -> PermitResponse: + """Revoke an active permit. + + The write matches only ``status = 'active'``. A repeat call, or a call + against a permit that is already expired or exhausted, returns the + stored row and does not move ``revoked_at`` or change ``status``. + """ factory = get_session_factory() async with factory() as session: - model = await session.get(PermitModel, permit_id) - if not model: + owner = await session.get(PermitModel, permit_id) + if owner is None: + raise PermitError("permit_not_found") + require_permit_issuer( + caller, + issuer_wallet_id=owner.issuer_wallet_id, + error=PermitAccessDeniedError, + ) + # Guarded write, same shape as the budget reservation: a second + # caller cannot overwrite revoked_at after the status has left + # active, including when the two calls overlap. + await session.execute( + sa_update(PermitModel) + .where( + cast(ColumnElement[bool], PermitModel.permit_id == permit_id), + cast(ColumnElement[bool], PermitModel.status == "active"), + ) + .values(status="revoked", revoked_at=utc_now()) + .execution_options(synchronize_session=False) + ) + model = ( + await session.execute( + select(PermitModel) + .where( + cast( + ColumnElement[bool], + PermitModel.permit_id == permit_id, + ) + ) + .execution_options(populate_existing=True) + ) + ).scalar_one_or_none() + if model is None: raise PermitError("permit_not_found") - model.status = "revoked" - model.revoked_at = utc_now() - session.add(model) await session.commit() - await session.refresh(model) return permit_model_to_response(model) async def validate_for_action( @@ -843,8 +936,13 @@ async def _validate_model_for_action( return PermitValidation( False, "permit_key_mismatch", model, {"bound_to": "subject_key_id"} ) + # An empty allowlist names no tool, so it admits none. The previous + # `if allowed_tools and ...` form skipped this check for empty lists + # and let the scope check below decide, so a permit carrying explicit + # scopes but no tool restriction could authorize tools it never + # named. Deny-all here makes the empty case explicit. allowed_tools = _loads_list(model.allowed_tools_json) - if allowed_tools and tool_name not in allowed_tools: + if tool_name not in allowed_tools: return PermitValidation( False, "permit_tool_not_allowed", @@ -1047,7 +1145,9 @@ async def _sum_permit_charges( total = result.scalar() return Decimal(str(total)) if total is not None else Decimal("0") - async def reserve_budget(self, permit_id: str, amount: Decimal) -> None: + async def reserve_budget( + self, permit_id: str, amount: Decimal, *, caller: CallerIdentity + ) -> None: factory = get_session_factory() async def _once() -> None: @@ -1063,6 +1163,13 @@ async def _once() -> None: ) if model is None: raise PermitError("permit_not_found") + require_permit_party( + caller, + issuer_wallet_id=model.issuer_wallet_id, + subject_wallet_id=model.subject_wallet_id, + subject_key_id=model.subject_key_id, + error=PermitAccessDeniedError, + ) # Atomic guarded reserve (see authorize_and_reserve): the cap # *and the expiry* are enforced by the WHERE clause, not a # read-then-write. The expiry term matters for the same @@ -1173,12 +1280,30 @@ async def _once() -> None: await self._run_with_write_retry(_once) - async def release_budget(self, permit_id: str, amount: Decimal) -> None: + async def release_budget( + self, permit_id: str, amount: Decimal, *, caller: CallerIdentity + ) -> None: factory = get_session_factory() async def _once() -> None: async with factory() as session: async with session.begin(): + # Ownership is checked on a locked read before the guarded + # decrement: the no-op-on-missing-permit below must not + # become a silent success for a caller with no claim on + # the permit. + owner = await session.get( + PermitModel, permit_id, with_for_update=True + ) + if owner is None: + return + require_permit_party( + caller, + issuer_wallet_id=owner.issuer_wallet_id, + subject_wallet_id=owner.subject_wallet_id, + subject_key_id=owner.subject_key_id, + error=PermitAccessDeniedError, + ) # Atomic clamped decrement: a single UPDATE so a concurrent # reservation on the same permit cannot be lost to a # read-modify-write refund. A missing permit is a no-op. @@ -1285,7 +1410,9 @@ async def record_absorbed_release_drift( ) return False - async def release_tool_call(self, permit_id: str, tool_name: str) -> None: + async def release_tool_call( + self, permit_id: str, tool_name: str, *, caller: CallerIdentity + ) -> None: """Give back one ``max_calls_per_tool`` use consumed by a reservation. Compensation partner to :meth:`release_budget`: ``authorize_and_reserve`` @@ -1310,6 +1437,13 @@ async def _once() -> None: ) if model is None: return + require_permit_party( + caller, + issuer_wallet_id=model.issuer_wallet_id, + subject_wallet_id=model.subject_wallet_id, + subject_key_id=model.subject_key_id, + error=PermitAccessDeniedError, + ) original_counts_json = model.tool_call_counts_json counts = _loads_dict(original_counts_json or "{}") current = counts.get(tool_name, 0) @@ -1357,7 +1491,9 @@ async def _once() -> None: await self._run_with_write_retry(_once) - async def release_dispatch_budget_once(self, attempt_id: str) -> bool: + async def release_dispatch_budget_once( + self, attempt_id: str, *, caller: CallerIdentity + ) -> bool: """Release one remote attempt's reservation exactly once. The permit mutation and attempt checkpoint share one transaction. This @@ -1373,6 +1509,19 @@ async def _once() -> bool: attempt = await session.get(McpDispatchAttemptModel, attempt_id) if attempt is None: raise PermitError("dispatch_attempt_not_found") + # Attempt-keyed, but the money it guards belongs to the + # linked permit: a caller with no claim on that permit + # must not drive its budget, even with the attempt id. + permit_owner = await session.get(PermitModel, attempt.permit_id) + if permit_owner is None: + raise PermitError("permit_not_found") + require_permit_party( + caller, + issuer_wallet_id=permit_owner.issuer_wallet_id, + subject_wallet_id=permit_owner.subject_wallet_id, + subject_key_id=permit_owner.subject_key_id, + error=PermitAccessDeniedError, + ) # Only a terminal returned_error attempt has a reservation # to give back. Releasing budget for a prepared or # dispatched attempt frees credits that attempt may still diff --git a/gateway/app/services/pods.py b/gateway/app/services/pods.py index f69fc043..e748828a 100644 --- a/gateway/app/services/pods.py +++ b/gateway/app/services/pods.py @@ -46,6 +46,7 @@ from ..db.database import get_session_factory from .agent_money import AgentMoney, WalletNotFoundError, get_agent_money from .api_key_service import APIKeyService, get_api_key_service +from .ownership import CallerIdentity POD_METADATA_KIND = "pod" @@ -208,6 +209,7 @@ async def create_pod( agent_id=agent_id_in_flight, budget_credits=member_budget, session=session, + caller=CallerIdentity.for_wallet(sponsor.wallet_id), ) key = await self._keys.create_key( wallet_id=agent_wallet.wallet_id, diff --git a/gateway/app/services/policies.py b/gateway/app/services/policies.py index 4f4c1b26..beb5f8c1 100644 --- a/gateway/app/services/policies.py +++ b/gateway/app/services/policies.py @@ -20,6 +20,13 @@ logger = logging.getLogger(__name__) +# The planner's task tiers run low < medium < high (risk budgets in +# app/optimizer/policy.py grow with tier, and app/routers/mcp.py maps them to +# the same order for the risk guard). A bundle's risk_tier is the highest tier +# it permits: an action at or below the ceiling passes, anything above it is +# denied. Tiers outside this map fail closed. +_RISK_TIER_ORDER = {"low": 0, "medium": 1, "high": 2} + @dataclass(frozen=True) class PolicyEvaluation: @@ -260,6 +267,21 @@ async def evaluate_wallet_policy( # Decimal and the incoming cost is normalized rather than compared as float. est = _as_decimal(estimated_cost) daily = _as_decimal(daily_spend_used) + for amount in (est, daily): + if amount is not None and not amount.is_finite(): + # A non-finite cost poisons the monetary gates below: NaN makes + # Decimal ordering comparisons raise InvalidOperation (an + # unclassified 500 with no denial recorded), and -Infinity is + # smaller than every cap so it silently passes them. Neither can + # be shown under a cap, so deny instead of crashing or allowing. + # None keeps its existing meaning for each input: estimated cost + # skips its comparison, while unknown daily spend denies a cap. + return PolicyEvaluation( + False, + "policy_cost_invalid", + None, + {"policy_count": len(models)}, + ) evaluated: list[dict[str, Any]] = [] for policy in models: @@ -342,18 +364,23 @@ async def evaluate_wallet_policy( policy.policy_id, {"evaluated": evaluated}, ) - if ( - policy.daily_spend_limit is not None - and daily is not None - and est is not None - and daily + est > policy.daily_spend_limit - ): - return PolicyEvaluation( - False, - "daily_spend_limit_exceeded", - policy.policy_id, - {"evaluated": evaluated}, - ) + if policy.daily_spend_limit is not None: + if daily is None: + # Past spending is unknown, so the cap cannot be shown to + # hold. Fail closed instead of skipping the check. + return PolicyEvaluation( + False, + "daily_spend_unknown", + policy.policy_id, + {"evaluated": evaluated}, + ) + if est is not None and daily + est > policy.daily_spend_limit: + return PolicyEvaluation( + False, + "daily_spend_limit_exceeded", + policy.policy_id, + {"evaluated": evaluated}, + ) if policy.require_real_effects and simulation: return PolicyEvaluation( False, @@ -361,8 +388,21 @@ async def evaluate_wallet_policy( policy.policy_id, {"evaluated": evaluated}, ) - if risk_tier is not None and policy.risk_tier != risk_tier: - constraints["requested_risk_tier"] = risk_tier + if risk_tier is not None and policy.risk_tier is not None: + requested_rank = _RISK_TIER_ORDER.get(risk_tier) + allowed_rank = _RISK_TIER_ORDER.get(policy.risk_tier) + if ( + requested_rank is None + or allowed_rank is None + or requested_rank > allowed_rank + ): + constraints["requested_risk_tier"] = risk_tier + return PolicyEvaluation( + False, + "risk_tier_not_allowed", + policy.policy_id, + {"evaluated": evaluated}, + ) return PolicyEvaluation( True, "allowed", models[0].policy_id, {"evaluated": evaluated} diff --git a/gateway/app/services/receipts.py b/gateway/app/services/receipts.py index 714de2f7..6305a5b5 100644 --- a/gateway/app/services/receipts.py +++ b/gateway/app/services/receipts.py @@ -26,6 +26,18 @@ _REASON_CODE_PATTERN = re.compile(r"[a-z][a-z0-9_.:-]{0,127}\Z") +_RECEIPT_OUTCOMES = frozenset( + { + "success", + "denied", + "insufficient_funds", + "failed_refunded", + "failed_unrefunded", + "delivery_uncertain", + "response_rejected", + } +) +_UNCHARGED_OUTCOMES = frozenset({"denied", "insufficient_funds", "failed_refunded"}) class ReceiptError(RuntimeError): @@ -229,6 +241,10 @@ def _assert_idempotent_match( } if _action_binding(model) != (action_binding or {}): raise ReceiptError("receipt_action_binding_conflict") + if canonical_json( + _loads_dict(model.constraints_evaluated_json) + ) != canonical_json(constraints_evaluated or {}): + raise ReceiptError("receipt_idempotency_conflict") if any(getattr(model, name) != value for name, value in expected.items()): raise ReceiptError("receipt_idempotency_conflict") @@ -268,6 +284,26 @@ async def assert_action_receipt_binding(self, receipt: ReceiptResponse) -> None: ): raise ReceiptError("receipt_action_binding_conflict") + @staticmethod + async def effective_constraints( + constraints_evaluated: dict[str, Any] | None, + audit_event_id: str | None, + wallet_id: str, + *, + session: AsyncSession | None = None, + include_jev: bool | None = None, + ) -> dict[str, Any] | None: + """Include the same JEV evidence when signing and adopting a replay.""" + if include_jev is None: + include_jev = get_settings().JEV_RISK_GUARD != DuplicateGuardMode.OFF + if include_jev: + jev = await load_jev_guard_metadata( + audit_event_id, wallet_id, session=session + ) + if jev is not None: + return {**(constraints_evaluated or {}), "jev_risk_guard": jev} + return constraints_evaluated + async def create_receipt( self, *, @@ -297,17 +333,16 @@ async def create_receipt( for amount in (credits_authorized, credits_charged) ): raise ReceiptError("receipt_credits_invalid") + if outcome not in _RECEIPT_OUTCOMES: + raise ReceiptError("receipt_outcome_invalid") + if outcome in _UNCHARGED_OUTCOMES and credits_charged != Decimal("0"): + raise ReceiptError("receipt_denied_charge_invalid") # Reuse the existing signed JSON field; no receipt schema/migration. # The audit link also covers denial/refund/reconciliation helpers. - if get_settings().JEV_RISK_GUARD != DuplicateGuardMode.OFF: - jev = await load_jev_guard_metadata( - audit_event_id, wallet_id, session=session - ) - if jev is not None: - constraints_evaluated = { - **(constraints_evaluated or {}), - "jev_risk_guard": jev, - } + requested_constraints = constraints_evaluated + constraints_evaluated = await self.effective_constraints( + constraints_evaluated, audit_event_id, wallet_id, session=session + ) if reason_code is not None: if outcome == "success" or not _REASON_CODE_PATTERN.fullmatch(reason_code): raise ReceiptError("receipt_reason_code_invalid") @@ -364,6 +399,14 @@ async def existing_response( ) if existing is None: return None + stored_constraints = _loads_dict(existing.constraints_evaluated_json) + replay_constraints = await self.effective_constraints( + requested_constraints, + audit_event_id, + wallet_id, + session=target_session, + include_jev="jev_risk_guard" in stored_constraints, + ) self._assert_idempotent_match( existing, idempotency_record_id=idempotency_record_id, @@ -381,14 +424,20 @@ async def existing_response( reason_code=reason_code, audit_event_id=audit_event_id, approval_id=approval_id, - constraints_evaluated=constraints_evaluated, + constraints_evaluated=replay_constraints, action_binding=action_binding, ) + if not await self.verify_model(existing, session=target_session): + raise ReceiptError("receipt_signature_invalid") return receipt_model_to_response(existing) target_session = session - if target_session is not None: - existing = await existing_response(target_session) + if idempotency_record_id is not None: + if target_session is None: + async with get_session_factory()() as preflight_session: + existing = await existing_response(preflight_session) + else: + existing = await existing_response(target_session) if existing is not None: return existing diff --git a/gateway/app/services/shadow_ledger.py b/gateway/app/services/shadow_ledger.py index 575ea752..935c4a8d 100644 --- a/gateway/app/services/shadow_ledger.py +++ b/gateway/app/services/shadow_ledger.py @@ -32,6 +32,7 @@ # One pricing table, not a second copy. The dry run must quote exactly what # the real charge path would take, so both read .pricing. +from .ownership import CallerIdentity from .pricing import DEFAULT_PRICING logger = logging.getLogger(__name__) @@ -529,6 +530,7 @@ async def commit_session( units=Decimal(str(charge.units)), description=f"[COMMITTED] {charge.description}", dry_run=False, + caller=CallerIdentity.for_wallet(session.wallet_id), ) if isinstance(result, InsufficientFundsResponse): diff --git a/gateway/app/services/stripe_integration.py b/gateway/app/services/stripe_integration.py index d61bcb6f..c3772851 100644 --- a/gateway/app/services/stripe_integration.py +++ b/gateway/app/services/stripe_integration.py @@ -25,6 +25,7 @@ from ..db.database import get_session_factory from ..db.models import BillingAlertModel, LedgerEntryModel, WalletModel from ..core.config import get_settings +from ..core.webhook_auth import WebhookVerificationError, verify_stripe_event from ..core.time import utc_now from ..core.resilience import run_with_write_conflict_retry from ..schemas.billing import AlertSeverity, AlertType, WalletStatus @@ -288,13 +289,13 @@ async def handle_webhook( ValueError: For unhandled event types """ try: - event = stripe.Webhook.construct_event( + event = verify_stripe_event( payload, sig_header, - settings.STRIPE_WEBHOOK_SECRET, + get_settings().STRIPE_WEBHOOK_SECRET, ) - except (ValueError, stripe.SignatureVerificationError) as e: - logger.error(f"Invalid Stripe signature: {e}") + except WebhookVerificationError as e: + logger.warning("Rejected Stripe webhook: %s", e) return False # The refund handler is dispatched separately because it also needs the diff --git a/gateway/app/services/upstream_mcp.py b/gateway/app/services/upstream_mcp.py index 79231cb2..9e26f980 100644 --- a/gateway/app/services/upstream_mcp.py +++ b/gateway/app/services/upstream_mcp.py @@ -20,6 +20,7 @@ import time from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextlib import asynccontextmanager +from copy import deepcopy from dataclasses import dataclass, field from datetime import timedelta from decimal import Decimal, InvalidOperation, localcontext @@ -34,6 +35,7 @@ from ..core.config import Settings, get_settings from ..core.trust_mode import is_production_like_environment +from ..core.url_guard import address_is_blocked, is_ambiguous_ipv4_literal from ..schemas.billing import ServiceCategory from .mcp_dispatch_attempts import ( MAX_UPSTREAM_CALL_TIMEOUT_SECONDS, @@ -358,6 +360,10 @@ async def validate_upstream_url( literal = ipaddress.ip_address(host) except ValueError: literal = None + if is_ambiguous_ipv4_literal(host): + raise UpstreamMcpConfigurationError( + "MCP_UPSTREAM_URL must not use an ambiguous numeric host" + ) explicit_loopback = host == "localhost" or host.endswith(".localhost") addresses: Sequence[str] @@ -403,10 +409,11 @@ async def validate_upstream_url( raise UpstreamMcpConfigurationError( "MCP_UPSTREAM_URL must use https except for loopback local/test servers" ) - # is_global alone admits multicast: the stdlib classifies 224.0.0.0/4 and - # ff00::/8 as global, so a multicast destination is refused by name. + # is_global alone admits multicast, site-local fec0::/10, and IPv6 + # literals that embed a private IPv4 (compatible, translated, NAT64). + # address_is_blocked is the same predicate the outbound URL guard uses. if not loopback_exception and any( - address.is_multicast or not address.is_global for address in parsed_addresses + address_is_blocked(address) for address in parsed_addresses ): raise UpstreamMcpConfigurationError( "MCP_UPSTREAM_URL must not resolve to private, loopback, link-local, " @@ -941,6 +948,20 @@ async def _call_tool_once( session, response_guard=response_guard, ) + # A fresh ClientSession otherwise lists tools after a + # successful call to find its output schema. Seed the + # already-discovered schema before claiming dispatch so a + # later tools/list cannot discard the paid result, while + # retaining the SDK's structured-output validation. + schemas = getattr(session, "_tool_output_schemas", None) + if not isinstance(schemas, dict): + raise UpstreamMcpPreDispatchError( + "upstream_client_schema_cache_unavailable" + ) + schema = self._discovered_tool.output_schema + schemas[self.configuration.tool_name] = ( + deepcopy(schema) if schema is not None else None + ) try: await before_dispatch() except UpstreamMcpDispatchClaimUnavailableError as exc: diff --git a/gateway/app/services/wallet_engine.py b/gateway/app/services/wallet_engine.py index f4baece6..0b371f76 100644 --- a/gateway/app/services/wallet_engine.py +++ b/gateway/app/services/wallet_engine.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import math import uuid from collections.abc import Callable from datetime import datetime, timedelta, timezone @@ -29,6 +30,7 @@ WalletStatus, WalletType, ) +from .ownership import CallerIdentity, OwnershipDeniedError, require_wallet_owner from .wallet_status import SPENDABLE_WALLET_STATUSES logger = logging.getLogger(__name__) @@ -41,6 +43,11 @@ _MAX_WALLET_HIERARCHY_DEPTH = 32 +def _is_finite_amount(value: Decimal | float) -> bool: + """Check both Decimal and float inputs before any ordered comparison.""" + return value.is_finite() if isinstance(value, Decimal) else math.isfinite(value) + + class WalletExpiredError(RuntimeError): """Raised when an operation targets an expired wallet authority.""" @@ -343,6 +350,17 @@ async def _create_sponsor_wallet_in( No BEGIN/COMMIT here — the caller's transaction owns those. """ + if not _is_finite_amount(initial_credits): + raise ValueError("initial_credits must be finite") + if initial_credits < Decimal("0"): + # A negative seed opens the wallet holding a negative balance + # with no ledger entry to explain it: the initial-credit row is + # only written for positive seeds, so the missing money would be + # invisible to every ledger-derived balance. The billing router + # refuses these through ``ge=0``; the service refuses them too, + # matching the agent and child wallet paths. Zero stays allowed: + # it opens an empty wallet to be topped up later. + raise ValueError("initial_credits cannot be negative") wallet_id = f"spn-{uuid.uuid4().hex[:12]}" wallet = WalletModel( @@ -389,13 +407,21 @@ async def create_agent_wallet( auto_refill_threshold: Decimal = Decimal("100.0"), auto_refill_amount: Decimal = Decimal("1000.0"), session: AsyncSession | None = None, + *, + caller: CallerIdentity, ) -> WalletResponse: """Provision a pre-paid agent wallet from a sponsor's balance. ``session``, when given, must already be inside a transaction the CALLER owns end to end — same contract as ``create_sponsor_wallet``. Omit it for the original standalone behavior. + + The caller must own the sponsor: the new wallet is funded from its + balance. """ + require_wallet_owner(caller, sponsor_wallet_id) + if not _is_finite_amount(budget_credits): + raise ValueError("budget_credits must be finite") if budget_credits < Decimal("0"): # A negative budget inverts the debit into a credit: the guard # ``balance >= budget_credits`` is trivially true for a negative @@ -552,15 +578,27 @@ async def create_child_wallet( task_description: str = "", ttl_seconds: int | None = None, auto_reclaim: bool = True, + *, + caller: CallerIdentity, ) -> WalletResponse: - """Spawn a child sub-agent wallet from a parent agent's balance.""" + """Spawn a child sub-agent wallet from a parent agent's balance. + + The caller must own the parent: the new wallet is funded from its + balance. Checked before any row is loaded so a stranger cannot use + the error (or its absence) to probe which wallet ids exist. + """ + require_wallet_owner(caller, parent_wallet_id) _validate_child_wallet_ttl(ttl_seconds) + if not _is_finite_amount(budget_credits): + raise ValueError("budget_credits must be finite") if budget_credits < Decimal("0"): # Same inversion as the sponsor path, and worse: a child opened # with a negative balance cannot be unwound by a later reclaim, # which only credits the parent back a positive reclaim_amount. # Zero is allowed here too, for the same reason. raise ValueError("budget_credits cannot be negative") + if not _is_finite_amount(max_spend) or max_spend <= Decimal("0"): + raise ValueError("max_spend must be a finite number greater than zero") async with self._session_factory()() as session: async with session.begin(): # Lock parent wallet @@ -691,8 +729,14 @@ async def create_child_wallet( logger.info(f"Spawned child wallet {child_wallet_id} for {child_agent_id}") return wallet_model_to_response(child) - async def reclaim_child_wallet(self, child_wallet_id: str) -> dict: - """Reclaim unspent credits from a child wallet back to its parent.""" + async def reclaim_child_wallet( + self, child_wallet_id: str, *, caller: CallerIdentity + ) -> dict: + """Reclaim unspent credits from a child wallet back to its parent. + + The caller must own the child wallet or the parent it pays into; + anyone else is denied even though both rows are named by id. + """ async with self._session_factory()() as session: async with session.begin(): # Discover the (immutable) parent id with an unlocked read, so @@ -710,6 +754,16 @@ async def reclaim_child_wallet(self, child_wallet_id: str) -> dict: ) ) ).scalar_one_or_none() + owns_child = ( + caller.wallet_id is not None and caller.wallet_id == child_wallet_id + ) + owns_parent = ( + caller.wallet_id is not None + and child_peek is not None + and caller.wallet_id == child_peek.parent_wallet_id + ) + if not caller.is_bootstrap_admin and not (owns_child or owns_parent): + raise OwnershipDeniedError("wallet_access_denied") if not child_peek: raise self._wallet_not_found_error(child_wallet_id) if not child_peek.parent_wallet_id: @@ -842,6 +896,8 @@ async def transfer( amount: Decimal, description: str = "", correlation_id: str | None = None, + *, + caller: CallerIdentity, ) -> dict: """ Transfer credits between two wallets (agent-to-agent handoff). @@ -862,14 +918,19 @@ async def transfer( Raises: WalletNotFoundError: If either wallet doesn't exist InsufficientFundsError: If source has insufficient balance - ValueError: If amount <= 0 or same wallet IDs + ValueError: If amount is non-positive or non-finite, or same wallet IDs """ - if amount <= Decimal("0"): + if not amount.is_finite() or amount <= Decimal("0"): raise ValueError("Transfer amount must be positive") if from_wallet_id == to_wallet_id: raise ValueError("Cannot transfer to the same wallet") + # Only the source wallet's owner may move its credits. Checked before + # any row is loaded so a stranger cannot use the error (or its + # absence) to probe which wallet ids exist. + require_wallet_owner(caller, from_wallet_id) + async with self._session_factory()() as session: async with session.begin(): # Lock both wallets in a globally consistent (sorted) order via diff --git a/gateway/app/services/webauthn_provider.py b/gateway/app/services/webauthn_provider.py index 330fafdf..56e7a2f3 100644 --- a/gateway/app/services/webauthn_provider.py +++ b/gateway/app/services/webauthn_provider.py @@ -19,6 +19,17 @@ 2. Client uses navigator.credentials.get() with challenge → gets credential 3. Client calls POST /v1/awi/passkey/verify → verifies credential (cryptographic!) 4. Subsequent AWI action executions check verification status + +Development-only provider, not a production login path +------------------------------------------------------ +Registered credentials and authenticator replay counters are held only in +process memory: a restart forgets every credential and every sign count, and +a second instance knows none of them. The singleton constructor below +therefore refuses to build a provider unless ENABLE_PROOF_SURFACES is +explicitly true (local development and proof-surface demos only; +production-like boots refuse that flag). No persistence is offered on +purpose: promoting passkeys to a real login path needs durable credential +storage first, which is out of scope for this module. """ import base64 @@ -131,6 +142,10 @@ class WebAuthnProvider: """ WebAuthn/Passkey flow for high-risk AWI actions. + Development-only: credentials and replay counters live in process memory + (see module docstring). App code must use get_webauthn_provider(), which + enforces the dev-surface gate; direct construction is for tests. + This provider implements the FIDO2 WebAuthn specification to enable biometric authentication (TouchID, FaceID, Windows Hello, etc.) for critical operations. @@ -537,8 +552,10 @@ async def register_credential( """ Register a new WebAuthn credential for a user. - This stores the credential's public key for later verification. - In production, credentials would be stored in a persistent database. + This stores the credential's public key for later verification in + process memory only. It does not survive restarts and is not shared + between instances, which is why this provider is dev-only and never + a production login path. Args: user_id: The user identifier. @@ -702,11 +719,26 @@ async def _verify_authenticator_assertion( def get_webauthn_provider() -> WebAuthnProvider: - """Get or create the WebAuthnProvider singleton.""" + """Get or create the WebAuthnProvider singleton. + + Refuses unless ENABLE_PROOF_SURFACES is explicitly true. Credentials and + replay counters are process-memory only, so handing out a provider on a + production-shaped boot would offer login-grade checks with none of the + durability they need. Local development and proof-surface demos opt in + with that flag; production-like boots refuse it. + """ + from ..core.config import get_settings + + if not get_settings().ENABLE_PROOF_SURFACES: + raise RuntimeError( + "WebAuthn/passkey provider is disabled: passkey credentials and " + "authenticator replay counters are held only in process memory, " + "so this provider is not a production login path. It is available " + "only with ENABLE_PROOF_SURFACES=true (local development and " + "proof-surface demos)." + ) global _webauthn_provider if _webauthn_provider is None: - from ..core.config import get_settings - settings = get_settings() _webauthn_provider = WebAuthnProvider( diff --git a/gateway/app/services/x402_engine.py b/gateway/app/services/x402_engine.py index 3490536c..e5a52594 100644 --- a/gateway/app/services/x402_engine.py +++ b/gateway/app/services/x402_engine.py @@ -26,7 +26,8 @@ from app.core.config import get_settings from app.schemas.billing import ServiceCategory from app.services.audit_log import record_audit_event -from app.services.permits import get_permit_service +from app.services.ownership import CallerIdentity +from app.services.permits import get_permit_service, recipient_binding_matches from app.services.pricing import charge_units_for from app.services.receipts import ReceiptWriteContendedError, get_receipt_service from app.services.shadow_ledger import get_shadow_ledger @@ -341,8 +342,8 @@ async def settle( key_id: str | None, requirement: X402PaymentRequirement, idempotency_key: str, + idempotency_record_id: str, payer: str | None = None, - idempotency_record_id: str | None = None, ) -> X402Settlement: """Authorize a 402 demand against a permit and record the settlement. @@ -370,6 +371,25 @@ async def settle( raise X402Error("x402_payer_invalid") permits = get_permit_service() + # A permit bound to one recipient must never fund another payee. + # x402 demands name no resource host, only the pay_to chain address, + # so that address is the recipient compared here, with the same + # hostname-or-raw comparison the upstream MCP path enforces. This + # runs before authorize_and_reserve, so a mismatched demand is + # denied with no budget reserved. A missing permit is left for + # authorize_and_reserve to report as permit_not_found. + bound = await permits.get_permit(permit_id) + if bound is not None: + # Ownership before recipient: a permit bound to another wallet + # answers permit_wallet_mismatch whatever the pay_to, so a + # foreign permit ID plus varied pay_to values cannot probe the + # bound recipient through differing denial reasons. + if bound.subject_wallet_id != wallet_id: + raise X402Error("permit_wallet_mismatch") + if not recipient_binding_matches( + bound.recipient_domain, requirement.pay_to + ): + raise X402Error("permit_recipient_domain_mismatch") validation = await permits.authorize_and_reserve( permit_id=permit_id, wallet_id=wallet_id, @@ -377,7 +397,7 @@ async def settle( estimated_credits=credits, key_id=key_id, # pay_to / network / amount ride along so permit v2 constraints - # (forbidden_fields, and recipient checks where enforced) can bite. + # (forbidden_fields) can bite. arguments={ "pay_to": requirement.pay_to, "network": requirement.network, @@ -516,17 +536,16 @@ async def settle( # prove rollback. Only the typed exhaustion above guarantees # that no receipt was written and compensation is safe. reason = "x402_settlement_needs_review" - if idempotency_record_id is not None: - try: - persisted = await get_receipt_service().get_receipt_by_idempotency_record_id( - idempotency_record_id - ) - if persisted is not None: - reason = "x402_settled_unrecoverable_replay" - except Exception: - logger.exception( - "x402 receipt lookup failed for %s", idempotency_record_id - ) + try: + persisted = await get_receipt_service().get_receipt_by_idempotency_record_id( + idempotency_record_id + ) + if persisted is not None: + reason = "x402_settled_unrecoverable_replay" + except Exception: + logger.exception( + "x402 receipt lookup failed for %s", idempotency_record_id + ) raise X402SettlementUncertainError(reason) from exc compensation_complete = True @@ -542,7 +561,11 @@ async def settle( shadow_session_id, ) try: - await permits.release_budget(permit_id, credits) + await permits.release_budget( + permit_id, + credits, + caller=CallerIdentity.for_wallet(wallet_id), + ) budget_released = True except Exception: compensation_complete = False @@ -552,7 +575,11 @@ async def settle( # one-call permit's legitimate retry is denied # permit_max_calls_exceeded with no receipt to show for it. try: - await permits.release_tool_call(permit_id, X402_TOOL_NAME) + await permits.release_tool_call( + permit_id, + X402_TOOL_NAME, + caller=CallerIdentity.for_wallet(wallet_id), + ) tool_call_released = True except Exception: compensation_complete = False diff --git a/gateway/app/trust/__init__.py b/gateway/app/trust/__init__.py index ce12deab..7148a4af 100644 --- a/gateway/app/trust/__init__.py +++ b/gateway/app/trust/__init__.py @@ -85,14 +85,17 @@ get_agent_money, ) from .permits import ( + PermitAccessDeniedError, PermitCreationRejectedError, PermitError, PermitService, PermitValidation, PermitWriteContendedError, + extract_recipient_identity, get_permit_service, permit_constraints_snapshot, permit_model_to_response, + recipient_binding_matches, ) from .permit_requests import ( REQUEST_STATUS_APPROVED, @@ -189,14 +192,17 @@ "human_approval_configured", "approval_window_seconds", # permits + "PermitAccessDeniedError", "PermitCreationRejectedError", "PermitService", "PermitValidation", "PermitError", "PermitWriteContendedError", + "extract_recipient_identity", "get_permit_service", "permit_constraints_snapshot", "permit_model_to_response", + "recipient_binding_matches", # permit requests (agent asks, human approves, middleware mints) "ApprovalCardView", "PermitRequestConflictError", diff --git a/gateway/app/trust/evidence.py b/gateway/app/trust/evidence.py index 9df31e70..1088c936 100644 --- a/gateway/app/trust/evidence.py +++ b/gateway/app/trust/evidence.py @@ -443,7 +443,11 @@ async def build_receipt_evidence( }, ) ) - tool_allowed = not permit.allowed_tools or receipt.tool in permit.allowed_tools + # An empty or unset allowlist names no tool, so it admits none — + # the same deny-all the authorization path applies (permits.py). + # Evidence must not report permit_tool_scope as passed for an action + # the permit itself would refuse. + tool_allowed = receipt.tool in (permit.allowed_tools or []) required_scope = f"tool:{receipt.tool}:invoke" scope_allowed = ( required_scope in permit.scopes and "billing:charge" in permit.scopes diff --git a/gateway/app/trust/permits.py b/gateway/app/trust/permits.py index c8f044ef..4a704a62 100644 --- a/gateway/app/trust/permits.py +++ b/gateway/app/trust/permits.py @@ -6,23 +6,29 @@ from __future__ import annotations from app.services.permits import ( + PermitAccessDeniedError, PermitCreationRejectedError, PermitError, PermitService, PermitValidation, PermitWriteContendedError, + extract_recipient_identity, get_permit_service, permit_constraints_snapshot, permit_model_to_response, + recipient_binding_matches, ) __all__ = [ + "PermitAccessDeniedError", "PermitCreationRejectedError", "PermitError", "PermitService", "PermitValidation", "PermitWriteContendedError", + "extract_recipient_identity", "get_permit_service", "permit_constraints_snapshot", "permit_model_to_response", + "recipient_binding_matches", ] diff --git a/gateway/docker-compose.yml b/gateway/docker-compose.yml index 437243f2..6fb7dc4c 100644 --- a/gateway/docker-compose.yml +++ b/gateway/docker-compose.yml @@ -3,8 +3,10 @@ services: api: build: - context: . - dockerfile: Dockerfile.dev + # Repository root, so the image can COPY sdk/python. Paths inside the + # Dockerfile are relative to that root; this file stays in gateway/. + context: .. + dockerfile: gateway/Dockerfile.dev ports: - "8000:8000" env_file: diff --git a/gateway/docs/POLICY_ENFORCEMENT.md b/gateway/docs/POLICY_ENFORCEMENT.md index 6ef68384..ecd809f5 100644 --- a/gateway/docs/POLICY_ENFORCEMENT.md +++ b/gateway/docs/POLICY_ENFORCEMENT.md @@ -121,14 +121,16 @@ All monetary comparisons are done in `Decimal` end-to-end (thresholds stored as | Check | Reason code | |-------|-------------| +| Non-finite cost input (`NaN`, `Infinity`) for estimated cost or daily spend | `policy_cost_invalid` (denied; a cap that cannot be evaluated must not pass or crash) | | Bundle requires human approval | `human_approval_required` — satisfied instead of denied when the invoke's permit carries `requires_human_approval` (the Layer D gate provides the demanded decision; every other check below still runs) | | Tool not in the bundle allow-list | `tool_not_allowed` | | Service category not in the bundle allow-list | `service_category_not_allowed` | | An allow-list column is present but is not a JSON array of strings | `policy_constraint_corrupt` (denied; never read as unrestricted) | | `estimated > max_cost_per_action` | `max_cost_per_action_exceeded` | | `daily_spend_used + estimated > daily_spend_limit` | `daily_spend_limit_exceeded` | +| A daily spend cap is set but past spending cannot be determined | `daily_spend_unknown` (fail-closed: the cap cannot be shown to hold, so the action is denied) | | Bundle requires real effects but the call is in simulation mode | `real_effects_required` | -| `risk_tier` mismatch | *recorded on the decision, does not deny* | +| Requested risk tier exceeds the bundle's tier ceiling (low < medium < high; the bundle's `risk_tier` is the highest tier it permits) | `risk_tier_not_allowed` | Layer B (permit) and Layer C (wallet policy) are complementary: the permit is a per-delegation capability issued to one agent for one job; policy bundles are @@ -282,6 +284,7 @@ authorization guarantee. | `max_cost_per_action_exceeded` | C | Per-action cost cap hit | | `daily_spend_limit_exceeded` | C | Daily spend cap hit | | `real_effects_required` | C | Real-effects policy vs simulation mode | +| `policy_cost_invalid` | C | Non-finite estimated cost or daily spend cannot be compared against caps | | `human_approval_unavailable` | D | Approval service unreachable (**retryable**) | | `permit_recipient_domain_mismatch` | E | Upstream origin ≠ permit's bound domain | diff --git a/gateway/docs/PROOF_MATRIX.md b/gateway/docs/PROOF_MATRIX.md index 397c2eee..dbdbecbe 100644 --- a/gateway/docs/PROOF_MATRIX.md +++ b/gateway/docs/PROOF_MATRIX.md @@ -193,12 +193,15 @@ rate limiting. | Command | Enforces | |---|---| | `make trust-coverage-gate` | 43 focused trust test files at an **80% coverage floor** across 38 named trust-plane control modules | -| `make trust-release-gate` | The offline Railway IaC contract first (lock-pinned package install with lifecycle scripts disabled, then fail-closed API-only graph validation), followed by the 20-file trust suite including the focused claim/migration/debit, schema-boot, Standard MCP, and `tests/test_adversarial_five_claims.py` regressions, coverage, demo, discovery-drift, committed-OpenAPI, and simulation-inventory gates | - -CI runs `scripts/trust_release_gate.sh` as a dedicated required check -(`trust_release_gate`) so `main` cannot advance past an unproven claim; the -exact branch-protection settings are in -`trust-release-gate-branch-protection.md`. +| `make trust-release-gate` | The in-repo Railway deploy contract first (`Dockerfile`, `requirements.txt`, and an executable `scripts/docker_entrypoint.sh`; the hosted `.railway/railway.ts` graph is not in this repository), followed by the 20-file trust suite including the focused claim/migration/debit, schema-boot, Standard MCP, and `tests/test_adversarial_five_claims.py` regressions, coverage, demo, discovery-drift, committed-OpenAPI, and simulation-inventory gates | + +From `gateway/`, run `make trust-release-gate` for the full local release gate. +CI runs `scripts/trust_release_gate.sh` in a separate, non-required +`trust_release_gate` job. It is outside `CI result` until trust coverage and +committed OpenAPI parity pass in CI and the required-check decision is made +explicitly. The [active main ruleset](https://github.com/agentmiddleware/agent-middleware/rules/24444399) +requires `CI result` and `gitleaks` with strict status checks, linear history, +and resolved review threads. The [stranger test](stranger-test.md) is the human milestone that checks the same five claims are reachable and checkable from the published docs alone. diff --git a/gateway/docs/PROOF_SURFACES.md b/gateway/docs/PROOF_SURFACES.md index 588234d6..f31ec120 100644 --- a/gateway/docs/PROOF_SURFACES.md +++ b/gateway/docs/PROOF_SURFACES.md @@ -11,8 +11,7 @@ and audit. That makes those individual examples governed proof workloads; it does not promote AWI, browser, passkey, DOM, or RAG capabilities into the supported product surface. -Product wedge: [`WEDGE.md`](../WEDGE.md). -Remediation context: `tech-debt-remediation-plan.md` Phase 6. +Product wedge: [`WEDGE.md`](../WEDGE.md). ## Mount gate @@ -96,13 +95,15 @@ Product decision (2026-08 teardown follow-up): the public production surface is the wedge — sponsor/agent wallets, ledger, charge, permits, MCP invoke, receipts, evidence, audit, policies, discovery. Trust features beyond that are **dormant**: real code kept warm, unmounted and unadvertised until a -named customer needs them (see AGENTS.md, "Current Company Phase"). +named customer needs them. Re-promotion needs the evidence bar stated +later in this section. | Module / router | Surface | |-----------------|---------| | `app.routers.auth` (dormant) | JWT exchange (`/v1/auth/*`) — second auth story; wedge contract is `X-API-Key` | | `app.routers.kyc` (dormant) | Stripe Identity KYC | | `app.routers.planner` (dormant) | Budget optimizer | +| `app.routers.pods` (dormant) | Shared-budget agent-key groups ([pods](pods.md)) | | `app.routers.billing.expansion_router` (dormant) | Child/swarm wallets, transfers, top-ups, marketplace, velocity status, dry-run sandbox | They mount via `app.main.mount_dormant_trust_surfaces` when @@ -113,8 +114,8 @@ for those modules). The service layer underneath (transfers, velocity enforcement, KYC checks on charge paths) stays active — only the HTTP surface gates. -Re-promoting one to `CORE_TRUST_ROUTERS` requires the unfreeze evidence bar -in AGENTS.md: a named active prospect, a concrete tool, a documented +Re-promoting one to `CORE_TRUST_ROUTERS` requires this evidence bar: +a named active prospect, a concrete tool, a documented workflow blocker, a committed owner and date. ## Agent rules diff --git a/gateway/docs/README.md b/gateway/docs/README.md index 203e4116..6da44e4c 100644 --- a/gateway/docs/README.md +++ b/gateway/docs/README.md @@ -16,9 +16,9 @@ consequential MCP tool behind a governed boundary. Start with the | Measure the lost-response fault against a correctly used native idempotency key | [Failure lab](failure-lab.md) | | Run fourteen injected failures and get a signed evidence bundle | [Failure lab suite](failure-lab-suite.md) | | Review security claims and limits | [Security limitations](../SECURITY_LIMITATIONS.md) and [security review kit](security-review-kit.md) | -| Read the public article on what the boundary does and does not guarantee | Intent is not authority | -| Confirm which commit production is actually running | Deployment verification checklist | +| Rotate or revoke API keys | [API key rotation](api-key-rotation.md) | | Plan a design-partner evaluation | [Design partner guide](../DESIGN_PARTNER_GUIDE.md) | +| Tell plain pytest, Postgres, and live runs apart | [Test tiers](TESTING_TIERS.md) | | Contribute to the repository | [Contributing guide](../../CONTRIBUTING.md) | ## Agent and API discovery @@ -48,8 +48,7 @@ use them as product onboarding or a public capability list: - [`agent-recipes.md`](agent-recipes.md) and [`human-onboarding.md`](human-onboarding.md) are legacy proof-surface material, not the supported one-tool pilot. -- `mcp-registry-submission.md` is a gated - publication runbook; do not submit while the deployed standard MCP endpoint - is disabled. -- `aegis/` and `superpowers/` contain internal planning records, not product - documentation. +- [Pods](pods.md) are a dormant trust feature, not part of the supported pilot. +- This tree has no registry-submission runbook. Do not submit the server + to a public MCP registry from this repository. The standard MCP endpoint + stays off unless `ENABLE_STANDARD_MCP_ENDPOINT` is set. diff --git a/gateway/docs/TESTING_TIERS.md b/gateway/docs/TESTING_TIERS.md new file mode 100644 index 00000000..85f18477 --- /dev/null +++ b/gateway/docs/TESTING_TIERS.md @@ -0,0 +1,89 @@ +# Test tiers: plain pytest vs Postgres vs live + +The gateway suite runs at three levels. A green run at one level says +nothing about the others, so read the level before quoting a result. + +## Tier 1: plain pytest (default, no credentials or containers) + +SQLite plus in-memory state, permissive trust flags, no rate limits. +This is what most tests prove. + +```bash +cd gateway +make test # fast loop: trust-plane tests only (pytest -m "not proof") +make test-all # full suite, what CI runs (proof surfaces included) +``` + +CI additionally runs the strict production-posture subset with +production flags (`TRUST_MODE_ENABLED=true`, +`ALLOW_LEGACY_UNPERMITTED_MCP=false`, `ENABLE_PROOF_SURFACES=false`, +marker `production_trust` in `tests/test_production_trust_posture.py`). +The default run never exercises strict trust mode, real rate limiting, +or real row locks: those are pinned by `tests/test_suite_posture_defaults.py` +so the gap stays visible. + +Golden signing snapshots (`tests/test_*_signing_input_snapshot.py`) run +here. Regenerating them after an intentional format change requires +`GOLDEN_REGEN_ACK=1` next to the `UPDATE_*_GOLDEN=1` flag, plus a schema +version bump and a CHANGELOG entry (see the test module docstrings). + +## Tier 2: Postgres opt-in (needs a disposable local database) + +Concurrency and row-lock proofs skip by default because SQLite treats +`SELECT ... FOR UPDATE` as a silent no-op with different semantics from +Postgres. To run them, point at a throwaway database you create and drop +yourself. Never use a shared or production database. Ordinary local Postgres runs +require the fixed disposable action-test profile below. + +```bash +# Ordinary local runs only accept postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_* +# with ENVIRONMENT=test and an explicit Postgres test flag. createdb, Alembic, +# and pytest must all use port 55439. +# If the server listens on another port, forward that port onto 127.0.0.1:55439 +# before these commands. There is no forward example in this file. +createdb -h 127.0.0.1 -p 55439 -U sellers amw_action_local +cd gateway +DATABASE_URL=postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_local \ + ENVIRONMENT=test alembic upgrade head +RUN_POSTGRES_CONCURRENCY_TESTS=1 ENVIRONMENT=test \ + DATABASE_URL=postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_local \ + pytest tests/test_action_postgres.py \ + tests/test_permit_postgres_concurrency.py \ + tests/test_duplicate_guard_postgres_concurrency.py \ + tests/test_action_database_guard.py -q +dropdb -h 127.0.0.1 -p 55439 -U sellers amw_action_local +``` + +The action-proof guard (`tests/support/action_database_guard.py`) only +accepts `postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_*` with +`ENVIRONMENT=test`; anything else fails before connecting. The +two-process crash harness (`make prove-crash-recovery`, +`tests/test_mcp_postgres_multiprocess.py`) additionally needs +`STATE_BACKEND=postgres` and `MCP_STRESS_DB_ISOLATED=1`. + +CI runs tier 2 on every pull request in `postgres-concurrency` and +`postgres-proofs` (`.github/workflows/ci.yml`). Both jobs use disposable +Postgres service databases and set `AMW_TEST_DB_DISPOSABLE=1` in their job +environment; the shared fixture then accepts PostgreSQL URLs with +`ENVIRONMENT=test`. `postgres-proofs` runs row-lock, action +accounting, migration, asyncpg driver, and both multiprocess crash suites +(`tests/test_mcp_postgres_multiprocess.py` and +`tests/test_action_multiprocess.py`) with `CI_STRICT=1`; its final step +fails if any proof logged a skip. + +## Tier 3: live batteries (needs a running server, sometimes keys) + +These drive an already-running gateway over real HTTP and may create +persistent rows on their target, so use staging unless you intend to +keep the data. + +```bash +cd gateway +make trust-conformance-live # needs AGENT_MIDDLEWARE_API_KEY plus a target URL +make adversarial-battery-live # needs BOOTSTRAP_KEY plus a target URL +``` + +Live verdict scripts (`scripts/stress_test_live.py` and friends) are +covered in-process by fakes in tier 1; a green tier 1 does not prove +the live deployment batteries pass. Stripe tests mock the Stripe SDK; +no suite test touches real money movement. diff --git a/gateway/docs/api-key-rotation.md b/gateway/docs/api-key-rotation.md new file mode 100644 index 00000000..859bfa25 --- /dev/null +++ b/gateway/docs/api-key-rotation.md @@ -0,0 +1,162 @@ +# API key rotation + +This is the runbook for API keys. It is not the trust-plane signing key. +Signing-key custody, rotation, and compromise response are in +[key-management.md](key-management.md). There is no script and no route that +retires signing-key metadata. + +Run the script commands below from the gateway directory, which contains +`scripts/rotate_api_keys.py`. + +Two API key classes rotate. A third class must not. + +| Class | Where it lives | How it dies | +| --- | --- | --- | +| Bootstrap admin key | Host secret `VALID_API_KEYS`, comma-separated | Replace the secret and restart every worker | +| Wallet key | Database, minted by `POST /v1/api-keys`. The secret starts with `b2a_` and is shown once | HTTP revoke or rotate. No worker restart | +| Static dev key | `STATIC_DEV_API_KEYS`, prefix `amw_dev_` | Not rotated. See [static-dev-api-keys.md](static-dev-api-keys.md) | + +`get_settings` caches `VALID_API_KEYS` for the life of the process. A worker +keeps accepting the old bootstrap list until that worker restarts. + +## Bootstrap keys + +Generate replacements. The command prints key material to stdout and a short +note to stderr. The default count is 2. Each generated key starts with +`amw_live_`. That prefix is only a label from the generator. The server does +not require it: each comma-separated entry in `VALID_API_KEYS` must be at +least 8 characters after trimming and must not start with `Bearer ` or have a +JWT shape (two dots and more than 50 characters). Those values route to JWT +verification before env-key lookup. + +```bash +python scripts/rotate_api_keys.py generate +``` + +Put the new comma-separated list in the host secret manager. Restart or +redeploy every worker. Until a worker restarts, that worker still accepts the +old key. + +For a cutover with no gap where nobody can authenticate, deploy once with +both the old key and the new key in `VALID_API_KEYS`, wait until every worker +has restarted, then deploy again with the old key removed. + +Retirement is complete only when host configuration and rollout evidence +confirms that the exact intended replacement is in `VALID_API_KEYS`, the old +value is absent, every serving worker runs that configuration revision, and +all old workers are stopped or drained. Check this through the host secret +manager and deployment status without printing key material. + +Then run `verify` as a smoke check. It makes exactly one request per key, and +a load balancer can send those requests to different workers. Exit 0 shows only +sampled rejection and acceptance; it proves neither fleet-wide retirement nor +that the accepted replacement came from `VALID_API_KEYS`. Repeating random +probes does not replace the host configuration and rollout evidence. + +```bash +export AGENT_MIDDLEWARE_API_URL=https://the-origin-you-just-rotated +export OLD_API_KEY=... # exact old bootstrap key +export NEW_API_KEY=... # exact intended replacement bootstrap key +python scripts/rotate_api_keys.py verify +``` + +Use only the intended bootstrap keys. `verify` sends `GET /v1/audit/summary` +with `X-API-Key` and does not follow redirects or make a mutation request. +That route also accepts wallet keys, so a 200 does not establish bootstrap +authority or configuration provenance. Accidentally supplying a database key +can update `last_used_at` and consume a bounded use during authentication. +Exit codes: + +- 0: the old-key request received 401 or 403, and the new-key request returned + 200; this is sampled response evidence only +- 1: either sampled response did not match those conditions +- 2: an env var is missing, or the URL is unsafe (embedded credentials, a + path, a query, a fragment, or remote HTTP) + +With bootstrap keys configured, an unknown well-formed API key returns `403` +with `invalid_api_key`. A missing credential returns `401` with +`missing_credentials`; an empty or short API key returns `401` with +`invalid_api_key`, and an invalid Bearer or JWT-shaped token returns `401`. +All count as "rejected" for the old key. + +The checker allows the canonical production origin. Point it at the +deployment whose secret you just changed, after confirming the host rollout +and configuration evidence above. + +## Wallet keys + +These routes take effect as soon as the database write commits. They do not +wait for a restart. + +Who may call them: + +- A bootstrap admin key may act on any wallet. +- A database key may act only on its own `wallet_id`. Any other wallet is + `403` `wallet_access_denied`. +- A JWT cannot mint a replacement key (`403` `jwt_cannot_mint_api_key`). +- A bounded database key (`max_uses` or `expires_at`) cannot create a fresh + key or rotate another key. An omitted or different `key_id` on rotate is + `403` `bounded_key_cannot_mint`. It may rotate its own key, and the + replacement keeps its remaining bounds. A key with `max_uses` must set + `revoke_old: true`, or rotate returns `422` `invalid_rotation_request`. +- A bounded wallet key may call emergency revoke. If it requests a replacement, + the new key inherits only the caller key's remaining bounds. + +### Rotate one key + +`POST /v1/api-keys/rotate` + +```json +{ + "wallet_id": "wallet_...", + "key_id": "key_...", + "revoke_old": true, + "reason": "suspected_leak" +} +``` + +`revoke_old` defaults to false. A rotation that omits it leaves the old key +active. Set it to true when the old secret must stop working. + +The response includes the new secret once. An optional `Idempotency-Key` +header replays that first result on a retry, and the replay does not include +the secret again (`new_key.api_key` is null). Save the first response. + +### Revoke one key + +`DELETE /v1/api-keys/{wallet_id}/{key_id}` + +Returns 204. The optional `reason` query parameter defaults to +`user_request`. This does not mint a replacement. + +### Revoke every key on a wallet + +`POST /v1/api-keys/emergency-revoke` + +```json +{ + "wallet_id": "wallet_...", + "reason": "security_incident", + "create_new_key": false +} +``` + +`create_new_key` defaults to true. An emergency call that omits it mints a +replacement and returns that secret once. Set it to false when you only want +the old keys dead. This route also revokes the wallet's refresh tokens. + +Rotation history for a wallet is `GET /v1/api-keys/{wallet_id}/logs`. + +## Incident table + +| What happened | What to do | +| --- | --- | +| A `VALID_API_KEYS` bootstrap key leaked, or may have leaked | Generate a replacement. Finish the cutover only with host evidence that the exact intended replacement is configured, the old value is absent, every serving worker runs that revision, and old workers are stopped or drained. Then run `verify` for a two-request smoke check; exit 0 alone does not close the incident. | +| A `b2a_` wallet key leaked | `DELETE` that key, or `POST /v1/api-keys/rotate` with `revoke_old` true. To revoke every wallet key and its refresh tokens, use `POST /v1/api-keys/emergency-revoke` and set `create_new_key` explicitly. No restart. | +| An `amw_dev_` static key leaked | Production-like processes refuse to boot when `STATIC_DEV_API_KEYS` is set, and they ignore those keys if that guard is bypassed. Remove the value from the local env if the machine is shared. Do not copy it into `VALID_API_KEYS`. Entries without the `amw_dev_` prefix never authenticate, including a live key pasted into the wrong variable. | +| The signing private key leaked, or may have leaked | Stop and follow [key-management.md](key-management.md). This runbook does not retire signing keys, and nothing in `scripts/` does either. | + +## What this does not rotate + +Stripe webhook secrets, database credentials, and TLS material stay in the +host secret manager. Changing them is a deploy change, not an API call. diff --git a/gateway/docs/authority-required-flow.md b/gateway/docs/authority-required-flow.md index 49c11a4b..35518417 100644 --- a/gateway/docs/authority-required-flow.md +++ b/gateway/docs/authority-required-flow.md @@ -1,11 +1,11 @@ # Authority-required flow: tools/call → authority_required → approval → resume **Status: design proposal. Nothing in this document is implemented by merging -it.** Implementation is gated on the customer-validation invariant in -`AGENTS.md`: one named active prospect, one concrete consequential tool, a -documented current-workflow blocker, a committed owner and date, and the -smallest vertical slice that clears the blocker. This document exists so that -when that evidence arrives, the design conversation is already over. +it.** Implementation is gated on this evidence: one named active prospect, +one concrete consequential tool, a documented current-workflow blocker, a +committed owner and date, and the smallest vertical slice that clears the +blocker. This document exists so that when that evidence arrives, the design +conversation is already over. ## The problem this designs away diff --git a/gateway/docs/denial-details.md b/gateway/docs/denial-details.md index 926c6ccd..559c47c3 100644 --- a/gateway/docs/denial-details.md +++ b/gateway/docs/denial-details.md @@ -102,6 +102,8 @@ adjacent `details.field` value identifies the rejected field. | `max_cost_per_action_exceeded` | The quoted action cost exceeds the wallet policy's per-action limit. | Lower the action cost or raise the policy limit. | | `daily_spend_limit_exceeded` | Current daily spend plus this action exceeds the wallet policy limit. | Wait for the policy window to reset or have an administrator change the limit. | | `real_effects_required` | The policy forbids a simulated execution path. | Use a real-effects tool configuration or revise the policy. | +| `risk_tier_not_allowed` | The action's risk tier exceeds the wallet policy bundle's tier ceiling (tiers run low < medium < high; the bundle's `risk_tier` is the highest tier it permits). | Lower the action's risk tier or have an administrator raise the policy ceiling. | +| `daily_spend_unknown` | The policy sets a daily spend limit but past spending cannot be determined, so the cap cannot be shown to hold. Fail-closed denial. | Retry once spend accounting is healthy; if it persists, have an administrator investigate the ledger before spending resumes. | | `policy_denied` | Policy evaluation denied without a narrower stable reason. | Inspect the adjacent audit policy ID and evaluated constraints, then correct the blocking policy. | ### Terminal human approval diff --git a/gateway/docs/golden-path.md b/gateway/docs/golden-path.md index f9f03701..0725f485 100644 --- a/gateway/docs/golden-path.md +++ b/gateway/docs/golden-path.md @@ -79,6 +79,10 @@ export SPONSOR_WALLET_ID=$(echo "$SPONSOR_JSON" | jq -r '.wallet_id') echo "$SPONSOR_WALLET_ID" ``` +Sponsor creation is a bootstrap operator step with no replay protection: if +it times out, list wallets before retrying rather than re-posting blindly. +The agent-wallet, charge, and transfer calls below honor `Idempotency-Key`. + ## 3. Provision An Agent Wallet ```bash @@ -86,6 +90,7 @@ AGENT_JSON=$( curl -s -X POST "$API_URL/v1/billing/wallets/agent" \ -H "X-API-Key: $BOOTSTRAP_KEY" \ -H "Content-Type: application/json" \ + -H "Idempotency-Key: golden-path-agent-001" \ -d "{ \"sponsor_wallet_id\": \"$SPONSOR_WALLET_ID\", \"agent_id\": \"research-agent-001\", @@ -98,6 +103,12 @@ export AGENT_WALLET_ID=$(echo "$AGENT_JSON" | jq -r '.wallet_id') echo "$AGENT_WALLET_ID" ``` +Send an `Idempotency-Key` on this call. A retry with the same key +and the same payload returns the original wallet instead of provisioning +and funding a second one. The same key with different arguments is +refused with 409. A retry with no header is a new operation and can debit +the sponsor's credits twice. + ## 4. Issue A Wallet-Scoped Agent API Key ```bash diff --git a/gateway/docs/human-onboarding.md b/gateway/docs/human-onboarding.md index 86f7c5e0..2008d7af 100644 --- a/gateway/docs/human-onboarding.md +++ b/gateway/docs/human-onboarding.md @@ -141,7 +141,7 @@ settlement, or public arbitrary-code execution without strong isolation. ## Preflight script -From the repository root, with the API running: +From the gateway directory (the directory that contains `scripts/human_preflight.sh`), with the API running: ```bash export API_URL=http://127.0.0.1:8000 # or your deployed URL @@ -169,7 +169,7 @@ when the selected deployment exposes the full proof-surface report. It does | What is simulated right now? | Proof-surface deployment: `GET /health/dependencies` → `simulation_modes`; supported production: startup `runtime_posture` log + deployed configuration | | Can I trust sandbox isolation? | README + `docs/threat-model.md` | | End-to-end wallet + key + tool flow | `docs/golden-path.md` | -| What “beta” still means | `docs/production-beta-roadmap.md` | +| What the supported pilot still is | [Supported pilot boundary](README.md#supported-pilot-boundary) | | Env flags | `.env.example` | | Railway deploy (single path) | the hosted-service deploy runbook (not in this repository) — `railway up`; never Redeploy from GitHub source | | Database migrations | This section §7 + `scripts/docker_entrypoint.sh` | diff --git a/gateway/docs/openapi.json b/gateway/docs/openapi.json index 0bc20fa3..157ea7e5 100644 --- a/gateway/docs/openapi.json +++ b/gateway/docs/openapi.json @@ -9,16 +9,6 @@ }, "version": "1.3.0" }, - "servers": [ - { - "url": "https://api.thisisatest.tech", - "description": "Public API (set PUBLIC_URL)" - }, - { - "url": "http://localhost:8000", - "description": "Local Development" - } - ], "paths": { "/v1/audit/events": { "get": { @@ -714,7 +704,7 @@ "Agent Financial Gateways" ], "summary": "Create a sponsor wallet (liability sink)", - "description": "Bootstrap operator provisioning only. Create a human-owned root account that acts as the 'liability sink' for agent spending. Sponsors ingest fiat currency via payment rails and convert it to ecosystem credits. Agent wallets are provisioned from sponsor balances.", + "description": "Bootstrap operator provisioning only. Create a human-owned root account that acts as the 'liability sink' for agent spending. Sponsors hold internal credits funded by verified Stripe top-ups (USD only, where Stripe is configured) or by this explicit admin-gated issuance. Agent wallets are provisioned from sponsor balances. No settlement or payouts are performed.", "operationId": "create_sponsor_wallet_v1_billing_wallets_sponsor_post", "security": [ { @@ -6097,12 +6087,10 @@ }, "balance": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Balance" }, "daily_spend_used": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Daily Spend Used" }, "human_approval_required": { @@ -6544,12 +6532,10 @@ }, "credits_authorized": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Credits Authorized" }, "credits_charged": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Credits Charged" }, "error_code": { @@ -7613,7 +7599,6 @@ }, "max_credits": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Max Credits" }, "permit_expires_at": { @@ -7834,12 +7819,10 @@ }, "max_credits": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Max Credits" }, "spent_credits": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Spent Credits" }, "expires_at": { @@ -7895,8 +7878,7 @@ "aggregate_value_cap": { "anyOf": [ { - "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + "type": "string" }, { "type": "null" @@ -7994,8 +7976,7 @@ "type": "number" }, { - "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + "type": "string" }, { "type": "null" @@ -8436,7 +8417,8 @@ "canonicalization": { "type": "string", "title": "Canonicalization", - "description": "Canonical-JSON contract version the signing_input follows." + "description": "Canonical-JSON contract version the signing_input follows.", + "default": "awi-canonical-json/1" }, "signing_input": { "type": "string", @@ -8459,7 +8441,6 @@ "receipt_id", "issuer", "kid", - "canonicalization", "signing_input", "signature", "keys_url" @@ -8738,7 +8719,6 @@ }, "quoted_credits": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Quoted Credits" }, "category": { @@ -9120,12 +9100,10 @@ }, "credits_authorized": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Credits Authorized" }, "credits_charged": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Credits Charged" }, "outcome": { @@ -9279,7 +9257,6 @@ }, "credits": { "type": "string", - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", "title": "Credits" }, "status": { diff --git a/gateway/docs/owasp-agentic-top10-mapping.md b/gateway/docs/owasp-agentic-top10-mapping.md index 02c41b4f..0be8c2d9 100644 --- a/gateway/docs/owasp-agentic-top10-mapping.md +++ b/gateway/docs/owasp-agentic-top10-mapping.md @@ -169,10 +169,19 @@ paused on an explicit human decision ([human-approval-gate.md](human-approval-ga ## ASI10 — Rogue Agents **Posture: contained.** A rogue or runaway agent exhausts its permit and -stops: spend caps and use counts bound total damage, expiry bounds duration, -and every action — allowed or refused — is metered and receipted. Revocation -is operational today via key retirement and rotation -(`scripts/retire_owner_keys.py`, `scripts/rotate_api_keys.py`). +stops: spend caps and use counts bound total damage, and expiry bounds duration. +Terminalized or reconcilable governed calls have signed terminal accounting. +Requests rejected before usable authority and an executable tool have no +receipt; local post-effect failures held for manual review may also have no +receipt ([failure semantics](failure-semantics.md)). API-key revocation and +rotation are operational today. Env bootstrap keys use +`scripts/rotate_api_keys.py` plus a worker restart +([api-key-rotation.md](api-key-rotation.md)). Wallet keys use +`DELETE /v1/api-keys/{wallet_id}/{key_id}`, +`POST /v1/api-keys/rotate` with `revoke_old` true, and +`POST /v1/api-keys/emergency-revoke`. There is no separate retirement +script in `scripts/`. Signing-key retirement is not operational: see +[key-management.md](key-management.md). - Proof: the ASI02 budget-race fix (HELD on both engines); `tests/test_constant_loop_permit_budget.py`. diff --git a/gateway/docs/partner-first-tool-runbook.md b/gateway/docs/partner-first-tool-runbook.md index 1c06c1f9..3207f802 100644 --- a/gateway/docs/partner-first-tool-runbook.md +++ b/gateway/docs/partner-first-tool-runbook.md @@ -164,9 +164,9 @@ measured integration burden is recorded. **Commercial signal:** the partner says what unacceptable risk would return if the boundary were removed and commits an owner, next action, and decision date. -A follow-up meeting or "cool demo" response alone is not validation. Apply the -full gate in -`docs/30-day-customer-validation.md`. +A follow-up meeting or "cool demo" response alone is not validation. The +pass and fail rules in this section are the gate. There is no separate +validation document in this repository. **Fail / stop:** partner asks for settlement, KMS, multi-framework policy, or broad migration before the single-tool loop is trusted. Point to diff --git a/gateway/docs/pods.md b/gateway/docs/pods.md new file mode 100644 index 00000000..b13bc581 --- /dev/null +++ b/gateway/docs/pods.md @@ -0,0 +1,24 @@ +# Pods + +A pod is a name for a group of agent API keys that share one budget. It is +not a new wallet type, and it is not part of the supported one-tool pilot. + +`POST /v1/pods` and `GET /v1/pods/{pod_id}` both call +`require_bootstrap_admin`. A wallet-scoped key is refused. The create call +writes one sponsor wallet, one agent wallet per member, and one key per +member, in one database transaction. If any step fails, that transaction +rolls back and no wallet or key from the call is left behind. The read call +returns budget status and does not return key secrets. + +The router is in `DORMANT_TRUST_ROUTERS`. It is mounted only when +`ENABLE_PROOF_SURFACES=true`. With that flag off, production does not mount +or advertise `/v1/pods`. + +Limits, matching `app/schemas/pods.py`: + +- no department templates +- no on-demand provisioning trigger +- no cross-pod budget sharing +- at most 50 members (`MAX_POD_MEMBERS`) + +Code: `app/routers/pods.py`, `app/services/pods.py`, `app/schemas/pods.py`. diff --git a/gateway/docs/quickstart.md b/gateway/docs/quickstart.md index e0c49dad..ea53ce75 100644 --- a/gateway/docs/quickstart.md +++ b/gateway/docs/quickstart.md @@ -56,7 +56,7 @@ Three things worth knowing about what just started: deployments refuse to boot with these flags set. Leave the server running and do the rest **in a second terminal**, from the -repository root: +gateway directory (the same directory where you ran `make quickstart`): ```bash export API_URL=http://127.0.0.1:8000 @@ -287,8 +287,8 @@ curl -s -X POST "$API_URL/mcp/messages" \ ``` `permit_budget_exceeded`, with the arithmetic in `details` -(`required_credits: 2.0`, `remaining_credits: 1.0`) and — read the denial -receipt closely — `credits_charged: "0"` and `ledger_entry_id: null`. The +(`required_credits: "2.0"`, `remaining_credits: "1.00000000"`). Read the denial +receipt closely: `credits_charged: "0"` and `ledger_entry_id: null`. The denial is **signed evidence that carries no charge**: authority ran out before money moved, and you can prove it. The tool did not run (the notes file still has three notes), and the ledger still shows exactly three diff --git a/gateway/docs/related-work.md b/gateway/docs/related-work.md index 912f34e6..439ba6f1 100644 --- a/gateway/docs/related-work.md +++ b/gateway/docs/related-work.md @@ -45,8 +45,8 @@ Verified from repo: | Problem evidence | [stripe/ai#402 — agent-level retry creates duplicate charges](https://github.com/stripe/ai/issues/402) | Third-party issue report, with a reproduction, of the exact failure this wedge exists to prevent: agent frameworks retry as a new invocation with a fresh idempotency key, so SDK-level keys do not cover it. Concludes the guard belongs above the tool layer. | Verified external source; read directly 2026-08-15. It is a reported reproduction, **not** evidence that a duplicate charge reached production, and it establishes the problem rather than demand for this product. | | Problem evidence | [langchain-ai/langgraph#7417 — long tool calls silently re-executed on LangGraph Cloud](https://github.com/langchain-ai/langgraph/issues/7417) | Confirmed production incident of the retry-re-execution mechanism: managed infrastructure re-dispatches a tool call the caller believes is still running, "2–3x redundant work and cost." Platform-level instance of the ambiguous-outcome case `delivery_uncertain` models. | Verified external source; read directly 2026-08-15. Duplicated execution and cost in production — not a confirmed duplicated payment charge. | | Problem evidence | [crewAIInc/crewAI#5802 — tool re-execution on retry has no idempotency guard](https://github.com/crewAIInc/crewAI/issues/5802) | A second major framework documenting the same gap, with reproduction steps and a `stripe.charge()`-fires-twice worked example. | Verified external source; read directly 2026-08-15. Reproduction, not a production charge incident. | -| Receipt formats | [draft-farley-acta-signed-receipts](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) | The protect-mcp/ScopeBlind receipt format: Ed25519 over JCS-canonicalized JSON, namespaced receipt types, and (from -02) a `spending_authority` type plus Merkle commitment mode. Structurally open to a custom `agentmiddleware:*` receipt type carrying ledger and idempotency references — see `market-research-2026-08.md` §8.1. | Verified external source; read directly 2026-08-15. **Individual submission with no IETF standing** — cite it as one vendor's draft, not as a standards-track document. | -| Competitive set | `market-research-2026-08.md` | MCP-native competitors, market sizing, and the verification level of each claim. Records that signed offline-verifiable receipts are no longer differentiating. | Repo document; per-claim verification levels stated inline. | +| Receipt formats | [draft-farley-acta-signed-receipts](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) | The protect-mcp/ScopeBlind receipt format: Ed25519 over JCS-canonicalized JSON, namespaced receipt types, and (from -02) a `spending_authority` type plus Merkle commitment mode. Structurally open to a custom `agentmiddleware:*` receipt type carrying ledger and idempotency references. | Verified external source; read directly 2026-08-15. **Individual submission with no IETF standing** — cite it as one vendor's draft, not as a standards-track document. | +| Competitive set | Not in this repository | This page does not state competitor lists or market sizes. A market note that used to be cited here is not in the tree. | No repo document. | ## Claim Evidence Matrix diff --git a/gateway/docs/settlement-rails.md b/gateway/docs/settlement-rails.md index 4d3a6f52..dc358a1e 100644 --- a/gateway/docs/settlement-rails.md +++ b/gateway/docs/settlement-rails.md @@ -51,7 +51,7 @@ gate is entirely ours: Adopting a second rail does not outsource any of that. It means re-implementing and re-testing all of it against a different rail's semantics. For scale: the existing single rail carries roughly 900 lines of dedicated negative-path -tests, and `AGENTS.md` designates billing as security-critical, requiring +tests, and `app/services/AGENTS.md` designates billing as security-critical, requiring invalid-input, unauthorized-access, and negative-path coverage. So the accurate framing is: **partner for the rail, build the verification.** @@ -110,10 +110,9 @@ enforces, generalized. **Authenticity** 1. Settlement notifications are cryptographically verifiable against a secret or key the operator holds, and verification failure is a hard rejection. -2. Verification is shared code, not re-implemented per consumer. (The - repository currently fails this: the KYC webhook handler and the settlement - handler verify the same signing secret with different exception handling and - no shared helper.) +2. Verification is shared code, not re-implemented per consumer. (Met: + `app/core/webhook_auth.py`, used by the settlement and KYC + handlers, with `tests/test_webhook_auth.py`.) **Settlement validity — separate from authenticity** 3. The credited amount is re-derivable from fields the *rail* controls, never @@ -246,8 +245,8 @@ Against the checklist above, read the two surfaces this way: **This repository contains no settlement basis for either.** When this note was first written, the strings `x402`, `USDC`, `Payman`, and `stablecoin` -appeared zero times outside it and its companion -`PRODUCT_STRATEGY.md`. That has since changed for +appeared zero times outside it and a companion strategy note that is not +in this repository. That has since changed for `x402` and `USDC` — but only in the facilitation sense documented in the section above, where the trust plane authorizes and evidences a payment demand without executing it. There is still no rail dependency, no custody, @@ -312,6 +311,8 @@ ever appears: - Share one webhook signature-verification helper between the settlement and KYC handlers so both catch signature-verification failures identically. + (Done: `app/core/webhook_auth.py`, used by both handlers and + covered by `tests/test_webhook_auth.py`.) - Make `_handle_payment_failed` use the safe field accessor the other handlers use, rather than raw dictionary access. - Replace string-matched prior-refund detection with a structured reference. diff --git a/gateway/docs/stranger-test.md b/gateway/docs/stranger-test.md index 183c5b9a..14bf48e8 100644 --- a/gateway/docs/stranger-test.md +++ b/gateway/docs/stranger-test.md @@ -16,8 +16,8 @@ doc, an error message, or a discovery field — not a test. This is a usability and independent-verification test, not customer validation. A local tool, self-issued public proof, or friendly evaluator cannot satisfy the -partner-owned pilot gate in -`docs/30-day-customer-validation.md`. +partner-owned pilot gate. The pass and fail rules for that gate are in +[partner-first-tool-runbook.md](partner-first-tool-runbook.md) under Pass / fail. ## Ground rules diff --git a/gateway/failure_lab/faults.py b/gateway/failure_lab/faults.py index 8ee6a6b4..568b217c 100644 --- a/gateway/failure_lab/faults.py +++ b/gateway/failure_lab/faults.py @@ -302,10 +302,15 @@ def classify_request( params.get("arguments") if isinstance(params.get("arguments"), dict) else {} ) meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {} - operation_id = arguments.get("operation_id") + # Only a string names a business operation. Anything else (a number, a + # bool, a nested object) is malformed, and str() coercion would turn it + # into a plausible-looking identifier that silently matches no fault + # plan while reading as observed. Leave it unidentified instead. + raw_operation_id = arguments.get("operation_id") + operation_id = raw_operation_id if isinstance(raw_operation_id, str) else None return ( KIND_TOOLS_CALL, - str(operation_id) if operation_id is not None else None, + operation_id, request_id_text, meta.get(_IDEMPOTENCY_META_KEY), meta.get(_INVOCATION_META_KEY), @@ -313,10 +318,10 @@ def classify_request( if normalized == REST_PATH: if not isinstance(payload, dict): return None, None, None, None, None - operation_id = payload.get("operation_id") + raw_operation_id = payload.get("operation_id") return ( KIND_REFUND_REST, - str(operation_id) if operation_id is not None else None, + raw_operation_id if isinstance(raw_operation_id, str) else None, headers.get("x-request-id"), headers.get("idempotency-key"), None, @@ -398,7 +403,35 @@ async def __call__( await self.app(scope, receive, send) return - body = await _read_body(receive) + try: + body = await _read_body(receive) + except ValueError: + # An unbounded read would let one malformed request exhaust the + # harness; an unhandled raise would surface as an ambiguous + # transport error the gateway cannot classify. Refuse it as 413 + # without touching the tool, and record the refusal so a run that + # never delivered a body cannot read as a run that did. + self.injector._record( + kind="unclassified", + path=path, + operation_id=None, + request_id=None, + idempotency_key=None, + invocation_id=None, + fault="oversized_body_rejected", + reached_tool=False, + tool_status=None, + delivered=True, + ) + await _send_json( + send, + 413, + { + "error": "failure_lab_body_too_large", + "fault": "oversized_body_rejected", + }, + ) + return headers = _headers(scope) kind, operation_id, request_id, idempotency_key, invocation_id = ( classify_request(path, body, headers) diff --git a/gateway/failure_lab/verifier.py b/gateway/failure_lab/verifier.py index f3690310..d53648a0 100644 --- a/gateway/failure_lab/verifier.py +++ b/gateway/failure_lab/verifier.py @@ -228,6 +228,14 @@ def verify( ``{"executions": int, "operation_id": str, "source": str}``. Without it, downstream execution is reported NOT_ESTABLISHED -- which is the correct answer for a signature alone, not a missing feature. + + ``executions`` must be a genuine integer count: booleans and negative + counts are rejected as unusable observations rather than read as + evidence. When the observation also carries ``receipt_id`` and the + signed payload names a different one, the claim FAILS: the observation + describes a different receipt. An observation that names no receipt is + taken on the caller's word to describe this one, so callers proving + execution should always bind it. """ notes: list[str] = [] @@ -377,12 +385,36 @@ def _downstream_claim( ) executions = observation.get("executions") source = observation.get("source", "unspecified independent observer") - if not isinstance(executions, int): + if isinstance(executions, bool) or not isinstance(executions, int): return Claim( "DOWNSTREAM_EXECUTION_ESTABLISHED", ClaimStatus.NOT_ESTABLISHED, "the supplied observation did not report an execution count", ) + if executions < 0: + return Claim( + "DOWNSTREAM_EXECUTION_ESTABLISHED", + ClaimStatus.NOT_ESTABLISHED, + "the supplied observation reported an impossible execution count", + ) + bound_receipt = observation.get("receipt_id") + signed_receipt = payload.get("receipt_id") + if ( + isinstance(bound_receipt, str) + and bound_receipt + and isinstance(signed_receipt, str) + and signed_receipt + and bound_receipt != signed_receipt + ): + return Claim( + "DOWNSTREAM_EXECUTION_ESTABLISHED", + ClaimStatus.FAILED, + ( + f"the observation describes receipt {bound_receipt!r} but the " + f"signed payload is receipt {signed_receipt!r} -- an observation " + "of a different receipt cannot establish this one executed" + ), + ) if outcome == "delivery_uncertain": return Claim( "DOWNSTREAM_EXECUTION_ESTABLISHED", diff --git a/gateway/mypy.ini b/gateway/mypy.ini index 4283252a..c1447830 100644 --- a/gateway/mypy.ini +++ b/gateway/mypy.ini @@ -4,7 +4,7 @@ strict = false warn_return_any = false warn_unused_configs = false disallow_untyped_defs = false -ignore_missing_imports = true +ignore_missing_imports = false check_untyped_defs = true # Type-check the application package; scripts and tests are run, not imported. exclude = ^(scripts|tests|failure_lab|migrations|static|data)/ @@ -20,9 +20,21 @@ ignore_errors = true follow_imports = skip follow_imports_for_stubs = true -# SQLModel's `table=True` metaclass typing is unreliable under mypy when the -# checker runs without a fully resolved SQLModel install (pre-commit -# `uv run --with mypy`). Ignore this module so the hook stays useful for the -# rest of `app/`. -[mypy-app.db.models] -ignore_errors = true +# asyncpg ships neither stubs nor a py.typed marker, so its imports are +# unchecked rather than silencing every unchecked import globally. +[mypy-asyncpg.*] +ignore_missing_imports = true + +# Optional integrations, absent from requirements.txt installs (and Docker/CI): +# webauthn is imported behind try/except ImportError with fail-closed +# behavior, playwright only under TYPE_CHECKING with a runtime ImportError +# guard, and openai is imported only when LLM/embedding features are used. +# mypy still resolves them statically, so keep explicit overrides. +[mypy-webauthn.*] +ignore_missing_imports = true + +[mypy-playwright.*] +ignore_missing_imports = true + +[mypy-openai.*] +ignore_missing_imports = true diff --git a/gateway/pyproject.toml b/gateway/pyproject.toml index e59ba86c..f06b37a6 100644 --- a/gateway/pyproject.toml +++ b/gateway/pyproject.toml @@ -1,9 +1,66 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "agent-native-middleware" version = "1.3.0" description = "Governed MCP authorization, metering, signed receipts, and audit evidence" license = "LicenseRef-FSL-1.1-ALv2" requires-python = ">=3.11" +# Runtime dependency floors (source of truth). requirements.txt mirrors this +# set and stays the install target for Docker/CI/`uv run --with-requirements` +# runs; tests/test_packaging_deps.py keeps the two in sync. +dependencies = [ + "fastapi>=0.143.0", + "uvicorn[standard]>=0.54.0", + # 2.14 drops the string "pattern" from the permit credit decimal JSON + # schema (tests/test_permit_numeric_storage.py); lift with a schema fix. + "pydantic>=2.14.1,<2.15", + "pydantic-settings>=2.14.2", + "email-validator>=2.3.0", + "structlog>=24.1.0", + "aiomqtt>=2.5.1", + "aiocoap>=0.4.17", + # Upstream MCP DNS pinning relies on httpcore's sni_hostname extension + # contract. + "httpx>=0.28.1,<0.29", + # Verified implementation of the DNS-pin + TLS SNI handoff; update only + # with its integration test. + "httpcore==1.0.9", + # Transitive via httpx/starlette; >=4.14.2 fixes CVE-2026-63374 (TLSStream + # IDNA 2003 cert spoofing, critical) and CVE-2026-64847 (process-pool + # stderr block). + "anyio>=4.14.2", + # File upload support (>=0.0.30 fixes CVE-2026-53538/53539, >=0.0.31 + # fixes CVE-2026-53540). + "python-multipart>=0.0.32", + "python-dotenv>=1.2.4", + # Ed25519 trust-plane signatures (>=50.0.0 fixes GHSA-g6cj-pr64-35w5). + "cryptography>=50.0.0", + # JWT auth (app/core/jwt.py, oidc_iga); explicit — previously only + # transitive via mcp's pyjwt[crypto]. + "PyJWT>=2.15.1", + "redis>=8.1.0", + "asyncpg>=0.32.0", + "greenlet>=3.5.6", + "stripe>=16.0.0", + # MCP SDK; >=1.29.0 provides the pre-parse request-body guard used by + # public/partner transports. <2 is deliberate: 2.x renamed FastMCP to + # MCPServer, moved McpError, and changed the lowlevel Server internals + # that mcp_public/mcp_standard override. Dependabot major bumps are + # ignored (#460); lift only in a dedicated migration PR. + "mcp>=1.29.0,<2", + # Naive-UTC mappings are qualified with this minor release. + "sqlmodel>=0.0.48,<0.0.49", + "alembic>=1.20.0", + "aiosqlite>=0.22.1", +] + +# The gateway ships as a container image, not a wheel, and only `app/` is +# importable at runtime; keep tests, scripts, and failure_lab out of builds. +[tool.hatch.build.targets.wheel] +packages = ["app"] [project.optional-dependencies] # mcp <2 is deliberate and must match requirements.txt: 2.x renamed FastMCP to diff --git a/gateway/requirements-dev.txt b/gateway/requirements-dev.txt new file mode 100644 index 00000000..e56b3064 --- /dev/null +++ b/gateway/requirements-dev.txt @@ -0,0 +1,7 @@ +# Dev-tool lock for local lint and type-check. +# These exact versions are the blessed local tools. CI installs the same +# versions via RUFF_VERSION and MYPY_VERSION in .github/workflows/ci.yml. +# mypy is also pinned in requirements.txt. Bump all of them in one change. +# See CONTRIBUTING.md. +ruff==0.16.10 +mypy==2.4.0 diff --git a/gateway/requirements.txt b/gateway/requirements.txt index a89babd3..401d53a5 100644 --- a/gateway/requirements.txt +++ b/gateway/requirements.txt @@ -1,10 +1,12 @@ # Agent Middleware API - Dependencies # Python 3.11+ +# Bounds source of truth: [project].dependencies in pyproject.toml. +# Keep the two in sync (enforced by tests/test_packaging_deps.py). # --- Core Framework --- -fastapi>=0.141.1 -uvicorn[standard]>=0.53.0 -pydantic>=2.13.5 +fastapi>=0.143.0 +uvicorn[standard]>=0.54.0 +pydantic>=2.14.1,<2.15 # 2.14 drops the string "pattern" from the permit credit decimal JSON schema (tests/test_permit_numeric_storage.py); lift with a schema fix pydantic-settings>=2.14.2 email-validator>=2.3.0 # Keep FastAPI/Pydantic OpenAPI email schema stable in CI structlog>=24.1.0 # Structured startup/runtime logging @@ -22,22 +24,25 @@ anyio>=4.14.2 # Transitive via httpx/starlette; >=4.14.2 fixes CVE-202 python-multipart>=0.0.32 # File upload support (>=0.0.30 fixes CVE-2026-53538/53539, >=0.0.31 fixes CVE-2026-53540) # --- Utilities --- -python-dotenv>=1.2.3 # .env file loading +python-dotenv>=1.2.4 # .env file loading cryptography>=50.0.0 # Ed25519 trust-plane signatures (>=50.0.0 fixes GHSA-g6cj-pr64-35w5) -PyJWT>=2.15.0 # JWT auth (app/core/jwt.py, oidc_iga); explicit — previously only transitive via mcp's pyjwt[crypto] +PyJWT>=2.15.1 # JWT auth (app/core/jwt.py, oidc_iga); explicit — previously only transitive via mcp's pyjwt[crypto] redis>=8.1.0 # Redis-backed rate limiting and runtime persistence -asyncpg>=0.31.0 # PostgreSQL-backed runtime persistence +asyncpg>=0.32.0 # PostgreSQL-backed runtime persistence greenlet>=3.5.6 # SQLAlchemy async engine support -stripe>=15.6.1 # Stripe payment processing +stripe>=16.0.0 # Stripe payment processing mcp>=1.29.0,<2 # MCP SDK; >=1.29.0 provides the pre-parse request-body guard used by public/partner transports; <2 is deliberate: 2.x renamed FastMCP to MCPServer, moved McpError, and changed the lowlevel Server internals that mcp_public/mcp_standard override. Dependabot major bumps are ignored (#460); lift only in a dedicated migration PR. # --- Database --- -sqlmodel>=0.0.47,<0.0.48 # Naive-UTC mappings are qualified with this minor release. +sqlmodel>=0.0.48,<0.0.49 # Naive-UTC mappings are qualified with this minor release. alembic>=1.20.0 # Database migrations aiosqlite>=0.22.1 # Async SQLite driver for testing # --- Development --- +# ruff is pinned in requirements-dev.txt, not here, so the runtime image does +# not install the linter. mypy stays an exact pin so this file cannot float +# the type checker ahead of CI (MYPY_VERSION in .github/workflows/ci.yml). pytest>=9.1.1 pytest-asyncio>=1.4.0 pytest-cov>=7.1.0 -mypy>=2.3.1 # Type checking +mypy==2.4.0 # Type checking. Must match requirements-dev.txt and MYPY_VERSION diff --git a/gateway/scripts/core_quality_gate.sh b/gateway/scripts/core_quality_gate.sh index 62257d2f..fcf00a33 100755 --- a/gateway/scripts/core_quality_gate.sh +++ b/gateway/scripts/core_quality_gate.sh @@ -7,10 +7,10 @@ # unconditionally on every run and inflated its failure count. set -euo pipefail -# ruff is not in requirements.txt (CI installs it separately), so ask for it -# explicitly in the uv fallback. +# ruff and mypy are exact pins in requirements-dev.txt, the same versions as +# RUFF_VERSION and MYPY_VERSION in CI. Do not --with either tool unpinned. PYTHON_ENV_REQUIRE="ruff mypy" -PYTHON_ENV_EXTRA_WITH="ruff" +PYTHON_ENV_EXTRA_REQUIREMENTS="requirements-dev.txt" # shellcheck source=scripts/lib/python_env.sh source "$(dirname "${BASH_SOURCE[0]}")/lib/python_env.sh" diff --git a/gateway/scripts/lib/python_env.sh b/gateway/scripts/lib/python_env.sh index f9279ce5..527eb709 100644 --- a/gateway/scripts/lib/python_env.sh +++ b/gateway/scripts/lib/python_env.sh @@ -5,8 +5,11 @@ # Set these BEFORE sourcing to tune the selection: # PYTHON_ENV_REQUIRE space-separated modules the interpreter must import # (default: "pytest") +# PYTHON_ENV_EXTRA_REQUIREMENTS +# extra requirements file for the uv fallback, relative +# to the gateway root (e.g. "requirements-dev.txt") # PYTHON_ENV_EXTRA_WITH space-separated extra packages for the uv fallback, -# for tools absent from requirements.txt (e.g. "ruff") +# for tools absent from those requirements files # # Sourcing this file sets two arrays: # PYTHON_CMD — runs a Python module/script ("${PYTHON_CMD[@]}" -m ruff check .) @@ -35,6 +38,7 @@ REPO_ROOT="$(_python_env_repo_root)" cd "$REPO_ROOT" _python_env_require="${PYTHON_ENV_REQUIRE:-pytest}" +_python_env_extra_requirements="${PYTHON_ENV_EXTRA_REQUIREMENTS:-}" _python_env_extra_with="${PYTHON_ENV_EXTRA_WITH:-}" _python_env_can_import_all() { @@ -52,6 +56,9 @@ if [[ -n "${PYTHON:-}" ]]; then PYTHON_CMD=("$PYTHON") elif command -v uv >/dev/null 2>&1; then PYTHON_CMD=(uv run --with-requirements requirements.txt) + if [[ -n "${_python_env_extra_requirements}" ]]; then + PYTHON_CMD+=(--with-requirements "${_python_env_extra_requirements}") + fi for _extra in $_python_env_extra_with; do PYTHON_CMD+=(--with "$_extra") done @@ -66,10 +73,17 @@ else fi if [[ ${#PYTHON_CMD[@]} -eq 0 ]]; then + _pip_req_args="-r requirements.txt" + if [[ -n "${_python_env_extra_requirements}" ]]; then + _pip_req_args+=" -r ${_python_env_extra_requirements}" + fi + if [[ -n "${_python_env_extra_with}" ]]; then + _pip_req_args+=" ${_python_env_extra_with}" + fi echo "[python-env] No interpreter provides: $_python_env_require" >&2 echo "[python-env] and uv is not available. Fix with one of:" >&2 echo "[python-env] - install uv (https://docs.astral.sh/uv/) and re-run" >&2 - echo "[python-env] - python3 -m pip install -r requirements.txt $_python_env_extra_with" >&2 + echo "[python-env] - python3 -m pip install ${_pip_req_args}" >&2 echo "[python-env] - PYTHON=/path/to/python scripts/$(basename "$0")" >&2 exit 1 fi diff --git a/gateway/scripts/rotate_api_keys.py b/gateway/scripts/rotate_api_keys.py index 72ac8436..37f3cf5c 100644 --- a/gateway/scripts/rotate_api_keys.py +++ b/gateway/scripts/rotate_api_keys.py @@ -6,18 +6,23 @@ # 1. Generate replacement keys for VALID_API_KEYS (prints to stdout only) python scripts/rotate_api_keys.py generate [--count 2] - # 2. Verify a completed rotation against a live deployment + # 2. Smoke-check after host configuration and rollout are confirmed export AGENT_MIDDLEWARE_API_URL=https://api.thisisatest.tech - export OLD_API_KEY=... # the retired key — must now be rejected - export NEW_API_KEY=... # the replacement key — must authenticate + export OLD_API_KEY=... # exact old bootstrap key + export NEW_API_KEY=... # exact intended replacement bootstrap key python scripts/rotate_api_keys.py verify -`verify` exits non-zero unless BOTH hold: - - OLD_API_KEY is rejected (401/403) on a bootstrap-admin read endpoint - - NEW_API_KEY succeeds (200) on the same endpoint +`verify` makes one request per key and exits non-zero unless BOTH sampled +responses match: + - OLD_API_KEY receives 401/403 on GET /v1/audit/summary + - NEW_API_KEY receives 200 on the same route -The check endpoint is GET /v1/audit/summary: read-only, admin-gated, and -mutation-free, so verification never writes to production state. +The requests may reach different load-balanced workers. This route also +accepts wallet keys, so a 200 does not prove bootstrap authority or that the +replacement is in VALID_API_KEYS. Use only intended bootstrap keys and host +configuration/rollout evidence to establish rotation completion. GET makes no +mutation request, but database-key authentication can update last_used_at or +consume a bounded use. Rotation itself happens at the host's secret manager (Railway: service variables), not here — see docs/api-key-rotation.md for the full runbook. @@ -77,7 +82,7 @@ def verify() -> int: return 2 try: - # This read-only verifier supports an explicitly selected production + # This GET-only probe supports an explicitly selected production # origin, but credentials must never travel over remote plaintext HTTP. base_url = resolve_live_target(base_url, confirm_production=True, environ={}) except LiveTargetError as exc: @@ -114,7 +119,7 @@ def main() -> int: sub = parser.add_subparsers(dest="command", required=True) gen = sub.add_parser("generate", help="print fresh replacement keys") gen.add_argument("--count", type=int, default=2) - sub.add_parser("verify", help="assert old key dead, new key live") + sub.add_parser("verify", help="sample old-key rejection and new-key acceptance") args = parser.parse_args() if args.command == "generate": diff --git a/gateway/scripts/trust_coverage_gate.sh b/gateway/scripts/trust_coverage_gate.sh index 5f08f85a..85582524 100755 --- a/gateway/scripts/trust_coverage_gate.sh +++ b/gateway/scripts/trust_coverage_gate.sh @@ -100,7 +100,13 @@ for module in "${TRUST_COVERAGE_MODULES[@]}"; do done echo "[trust-coverage] enforcing 80% coverage over trust-plane control modules" -"${PYTEST_CMD[@]}" -q \ +# Coverage resolves each --cov target before pytest collects tests, then +# evicts that temporary import. The eviction orphans the compiled SQLAlchemy +# cache-key module's traversal symbols, so cache-key generation fails with +# "TypeError: 'InternalTraversal' object is not callable". Preload SQLAlchemy +# in this pytest process (before coverage starts) so the eviction never +# touches it. This stays on PYTEST_CMD so a PYTEST override is honored. +"${PYTEST_CMD[@]}" -p sqlalchemy -q \ "${TRUST_COVERAGE_TESTS[@]}" \ "${COV_ARGS[@]}" \ --cov-report=term-missing \ diff --git a/gateway/tests/AGENTS.md b/gateway/tests/AGENTS.md index 67321001..67fd9136 100644 --- a/gateway/tests/AGENTS.md +++ b/gateway/tests/AGENTS.md @@ -13,3 +13,8 @@ Prefer: - malformed input tests Do not remove tests unless replacing them with equal or better coverage. + +Know which level proves what before quoting a green run: +[test tiers](../docs/TESTING_TIERS.md) (plain pytest vs Postgres opt-in vs live). +The default run is tier 1 only: SQLite, permissive trust flags, no rate +limits, golden snapshots compared not regenerated. diff --git a/gateway/tests/conftest.py b/gateway/tests/conftest.py index 3c80e69a..2c920f8a 100644 --- a/gateway/tests/conftest.py +++ b/gateway/tests/conftest.py @@ -11,7 +11,8 @@ import pytest import pytest_asyncio from sqlalchemy import DateTime, event, inspect as sa_inspect, text -from sqlalchemy.engine import Engine +from sqlalchemy.engine import Engine, make_url +from sqlalchemy.exc import ArgumentError from sqlalchemy.orm import Session # --- Proof-surface test classification ------------------------------------- @@ -79,9 +80,11 @@ "test_kyc", "test_mcp_trust_mode", "test_mcp_upstream_governed", + "test_owner_enforcement", "test_planner_constraints", "test_pods", "test_policy_bundles", + "test_qa_money_auth_matrix", "test_revocation_containment", "test_secret_persistence", "test_stripe_integration", @@ -135,6 +138,45 @@ def iter_routes(routes): os.environ.setdefault("DATABASE_URL", _DEFAULT_TEST_DB_URL) if _XDIST_WORKER and os.environ["DATABASE_URL"] == _DEFAULT_TEST_DB_URL: os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///./{TEST_DB_FILE}" + + +def require_disposable_test_database() -> None: + """Refuse inherited databases before test setup or destructive cleanup.""" + url = os.environ.get("DATABASE_URL", "") + if url == f"sqlite+aiosqlite:///./{TEST_DB_FILE}": + return + if ( + os.environ.get("AMW_TEST_DB_DISPOSABLE") == "1" + and os.environ.get("ENVIRONMENT") == "test" + ): + try: + marked_url = make_url(url) + except (ArgumentError, ValueError): + pass + else: + if ( + marked_url.drivername == "postgresql+asyncpg" + and marked_url.host + and marked_url.database + ): + return + if ( + os.environ.get("RUN_POSTGRES_CONCURRENCY_TESTS") == "1" + or os.environ.get("RUN_MCP_MULTIPROCESS_TESTS") == "1" + ): + from tests.support.action_database_guard import ( + require_action_database_url, + require_action_test_environment, + ) + + require_action_test_environment(os.environ.get("ENVIRONMENT")) + require_action_database_url(url) + return + # An inherited URL may contain credentials; never include it in the error. + raise ValueError("unsafe_test_database_url") + + +require_disposable_test_database() # Durable state stays in memory for the suite. State this explicitly: the tests # always ran on memory state, but only as an accident of auto-resolution # choosing postgres and asyncpg then failing on the SQLite DSN above. Once a @@ -343,6 +385,7 @@ def mount_dormant_surfaces_for_marked_tests(request): @pytest_asyncio.fixture(scope="session", autouse=True) async def setup_database(): """Set up database tables before any tests run.""" + require_disposable_test_database() from app.core.durable_state import close_durable_state from app.db.database import init_db, close_db @@ -408,6 +451,7 @@ async def setup_database(): @pytest_asyncio.fixture(scope="function") async def clean_database(): """Clean database tables between tests.""" + require_disposable_test_database() from app.db.database import get_session_factory factory = get_session_factory() diff --git a/gateway/tests/support/db_gate.py b/gateway/tests/support/db_gate.py new file mode 100644 index 00000000..3d09ba2b --- /dev/null +++ b/gateway/tests/support/db_gate.py @@ -0,0 +1,111 @@ +"""One shared gate for opt-in real-database tests. + +Several test modules only prove their claim against a live database +(PostgreSQL row locks, the asyncpg driver, Alembic round trips). They stay +skipped on a normal run and must never skip quietly in CI: a green suite that +never executed the money-safety proofs certifies nothing. + +Convention: every database-gated skip in this suite goes through this module. +The gate is open when the caller names an opt-in that is set +(``flag="NAME"`` needs ``NAME=1``, ``url_env="NAME"`` needs that variable to +hold a URL). When the gate is closed the test skips, unless ``CI_STRICT=1``, +in which case it fails loudly instead. CI sets ``CI_STRICT=1`` in every job +that provisions the databases, plus a prove step asserting zero skips. +""" + +from __future__ import annotations + +import os + +import pytest + +#: Set to "1" by CI jobs that provision real databases. Closed gates fail +#: instead of skipping so a misconfigured job goes red, not green. +CI_STRICT_ENV_VAR = "CI_STRICT" + + +def ci_strict() -> bool: + """True when skips must fail loudly instead of passing silently.""" + return os.environ.get(CI_STRICT_ENV_VAR) == "1" + + +def gate_open(*, flag: str | None = None, url_env: str | None = None) -> bool: + """True when the named opt-in is set (no opt-in named means open).""" + if flag is not None and os.environ.get(flag) != "1": + return False + if url_env is not None and not os.environ.get(url_env, "").strip(): + return False + return True + + +def _refuse_quiet_skip(reason: str) -> None: + if ci_strict(): + pytest.fail( + f"CI_STRICT=1 but a database-gated test would skip: {reason}", + pytrace=False, + ) + pytest.skip(reason) + + +def require_opt_in( + *, flag: str | None = None, url_env: str | None = None, reason: str +) -> None: + """Call inside a test or fixture: skip unless opted in, fail under CI_STRICT.""" + if gate_open(flag=flag, url_env=url_env): + return + _refuse_quiet_skip(reason) + + +def require_live_postgres(*, flag: str | None = None, reason: str) -> None: + """Skip unless opted in AND the live engine speaks PostgreSQL. + + Both halves fail loudly under CI_STRICT: a job that sets the flag but + points at SQLite (or vice versa) goes red instead of silently skipping. + """ + require_opt_in(flag=flag, reason=reason) + from app.db.database import get_engine + + engine = get_engine() + if engine is not None and engine.dialect.name == "postgresql": + return + _refuse_quiet_skip(reason) + + +class _ClosedGateCondition: + """Evaluate the gate when pytest sets up a selected test, not on import.""" + + def __init__(self, *, flag: str | None, url_env: str | None, reason: str): + self.flag = flag + self.url_env = url_env + self.reason = reason + + def __bool__(self) -> bool: + if gate_open(flag=self.flag, url_env=self.url_env): + return False + if ci_strict(): + pytest.fail( + f"CI_STRICT=1 but a database-gated test would skip: {self.reason}", + pytrace=False, + ) + return True + + +def collection_mark( + *, flag: str | None = None, url_env: str | None = None, reason: str +): + """Skip or fail a selected gated test during setup. + + Call sites may append the mark unconditionally:: + + pytestmark = [ + pytest.mark.asyncio(loop_scope="session"), + db_gate.collection_mark(flag="RUN_FOO_TESTS", reason="..."), + ] + + An imported module cannot fail a separate proof's collection. Under + CI_STRICT, only a selected gated test fails when its gate is closed. + """ + return pytest.mark.skipif( + _ClosedGateCondition(flag=flag, url_env=url_env, reason=reason), + reason=reason, + ) diff --git a/gateway/tests/support/golden_regen.py b/gateway/tests/support/golden_regen.py new file mode 100644 index 00000000..426f883f --- /dev/null +++ b/gateway/tests/support/golden_regen.py @@ -0,0 +1,39 @@ +"""Review gate for golden-file regeneration. + +The permit and receipt signing snapshots pin the exact bytes signatures +cover. Rewriting the expected output is sometimes legitimate (an +intentional format change with a schema version bump and a CHANGELOG +entry), but it must never be the easy way to make a refactor PR green. +Requiring a second, explicit acknowledgment next to the ``UPDATE_*`` flag +keeps regeneration a conscious, review-visible act: CI never sets the ack +variable, so a PR that rewrites goldens without review discussion fails. +""" + +from __future__ import annotations + +import os + +import pytest + +#: Second flag required next to UPDATE_PERMIT_GOLDEN / UPDATE_RECEIPT_GOLDEN. +REGEN_ACK_ENV_VAR = "GOLDEN_REGEN_ACK" + + +def require_golden_regen_ack(update_env_var: str) -> bool: + """Return True when the caller should regenerate its golden files. + + Returns False when regeneration was not requested. Fails the test when + regeneration was requested without the review acknowledgment, before + the caller writes anything. + """ + if os.environ.get(update_env_var) != "1": + return False + if os.environ.get(REGEN_ACK_ENV_VAR) != "1": + pytest.fail( + f"{update_env_var}=1 regenerates golden snapshots, which " + f"requires {REGEN_ACK_ENV_VAR}=1 as review acknowledgment. " + f"Regeneration is only for intentional format changes, with a " + f"schema version bump and a CHANGELOG entry (see the test " + f"module docstring). Never regenerate to make a refactor green." + ) + return True diff --git a/gateway/tests/support/proof_log_gate.py b/gateway/tests/support/proof_log_gate.py new file mode 100644 index 00000000..19f8626b --- /dev/null +++ b/gateway/tests/support/proof_log_gate.py @@ -0,0 +1,39 @@ +"""Fail the Postgres proof job if any pytest log contains a skipped proof.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def skipped_proofs(paths: list[Path]) -> list[str]: + """Return skip summaries after removing pytest's ANSI color codes.""" + skipped = [] + for path in paths: + for line in path.read_text().splitlines(): + plain = ANSI_ESCAPE.sub("", line) + if plain.startswith("SKIPPED"): + skipped.append(f"{path.name}: {plain}") + return skipped + + +def main() -> int: + paths = [Path(arg) for arg in sys.argv[1:]] + if not paths or any(not path.is_file() for path in paths): + print("::error::missing Postgres proof log", file=sys.stderr) + return 1 + skipped = skipped_proofs(paths) + if skipped: + for line in skipped: + print(line) + print("::error::a database-gated proof skipped instead of running") + return 1 + print("no gated skips in proof logs") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gateway/tests/test_acp_bridge.py b/gateway/tests/test_acp_bridge.py index 863a30f3..d2da67a2 100644 --- a/gateway/tests/test_acp_bridge.py +++ b/gateway/tests/test_acp_bridge.py @@ -238,8 +238,8 @@ async def test_acp_checkout_end_to_end(client, spt_stub, clean_database): @pytest.mark.anyio -async def test_acp_checkout_end_to_end_under_45ms(client, spt_stub, clean_database): - """Median service-level checkout latency stays under 45ms (SPT stubbed).""" +async def test_acp_checkout_end_to_end_latency_budget(client, spt_stub, clean_database): + """Median service-level checkout latency stays within the selected budget.""" ctx = await provision_agent_wallet(client) adapter = get_acp_commerce_adapter() @@ -265,9 +265,9 @@ async def run(intent_id: str): samples.append(time.perf_counter() - started) assert result.status == "settled" - # Median rather than max: a single noisy CI scheduling blip must not - # flake the suite, but the typical checkout must stay fast. The bound is - # env-overridable so a slow runner can widen it without editing code. + # Median rather than max limits sensitivity to a single scheduling blip. + # The default and the separate trust gate enforce 45 ms; ordinary CI uses + # 55 ms for shared-runner noise through the existing environment override. budget_s = float(os.environ.get("ACP_CHECKOUT_MEDIAN_BUDGET_S", "0.045")) assert statistics.median(samples) < budget_s, samples @@ -911,7 +911,6 @@ async def test_acp_rollback_release_failure_neither_masks_nor_skips_abandon( ORIGINAL error the caller sees, and must not stop the abandon — the immediate retry re-runs (Stripe-side idempotency makes the second charge a replay of the first) instead of being 409-wedged.""" - from app.services.permits import PermitService from app.services.receipts import ReceiptService ctx = await provision_agent_wallet(client) @@ -923,32 +922,26 @@ async def test_acp_rollback_release_failure_neither_masks_nor_skips_abandon( "key_id": ctx["key_id"], } - original_create_receipt = ReceiptService.create_receipt - original_release = PermitService.release_budget - async def _receipt_failure(self, **receipt_kwargs): raise RuntimeError("induced receipt failure") release_calls: list[str] = [] - async def _broken_release(self, permit_id, amount): + async def _broken_release(permit_id, amount, *, caller): release_calls.append(permit_id) raise RuntimeError("induced release failure") - monkeypatch.setattr(ReceiptService, "create_receipt", _receipt_failure) - monkeypatch.setattr(PermitService, "release_budget", _broken_release) - with pytest.raises(ACPBridgeError) as failed: - await adapter.execute_checkout(request, **kwargs) + with monkeypatch.context() as patch: + patch.setattr(ReceiptService, "create_receipt", _receipt_failure) + patch.setattr(get_permit_service(), "release_budget", _broken_release) + with pytest.raises(ACPBridgeError) as failed: + await adapter.execute_checkout(request, **kwargs) # The ORIGINAL settlement-record failure surfaces, not the rollback's. assert failed.value.reason == "acp_settlement_record_failed" assert len(release_calls) == 1 # the release WAS attempted... assert len(spt_stub) == 1 - # Restore original services and backdate the record for stale recovery. - monkeypatch.setattr(ReceiptService, "create_receipt", original_create_receipt) - monkeypatch.setattr(PermitService, "release_budget", original_release) - - # The stale recovery allows the retry to proceed (no immediate retry due to + # Stale recovery allows the retry to proceed (no immediate retry due to # ledger_entry_id protection from the security fix). await _backdate_intent_record("intent-rollbk-1", wallet_id=ctx["agent_wallet_id"]) diff --git a/gateway/tests/test_action_database_guard.py b/gateway/tests/test_action_database_guard.py index 1a1d4dac..d2a09b13 100644 --- a/gateway/tests/test_action_database_guard.py +++ b/gateway/tests/test_action_database_guard.py @@ -79,6 +79,7 @@ async def setup_sentinel(): ): env.pop(key, None) for key in ( + "AMW_TEST_DB_DISPOSABLE", "RUN_MCP_MULTIPROCESS_TESTS", "RUN_POSTGRES_CONCURRENCY_TESTS", "ACTION_MIGRATION_DATABASE_URL", @@ -87,7 +88,7 @@ async def setup_sentinel(): env["DATABASE_URL"] = VALID_URL + query if flag == "ACTION_MIGRATION_DATABASE_URL": env[flag] = VALID_URL + query - env["DATABASE_URL"] = f"sqlite+aiosqlite:///{tmp_path / 'runner.db'}" + env["DATABASE_URL"] = "sqlite+aiosqlite:///./test.db" else: env[flag] = "1" result = subprocess.run( diff --git a/gateway/tests/test_action_invocation.py b/gateway/tests/test_action_invocation.py index 21fbe0c6..fb4a2767 100644 --- a/gateway/tests/test_action_invocation.py +++ b/gateway/tests/test_action_invocation.py @@ -288,7 +288,10 @@ async def test_action_transport_refusals_do_not_claim_authority( }, ) assert response.status_code == (422 if surface == "rest" else 400) - assert response.headers["access-control-allow-origin"] == "*" + # Import-time middleware uses CORS_ORIGINS. Clean CI leaves it empty, so + # no CORS headers. A local .env may still opt into "*". Never reflect Origin. + allowed_origin = response.headers.get("access-control-allow-origin") + assert allowed_origin in (None, "*") assert "access-control-allow-credentials" not in response.headers assert await action_rows() == [] await assert_action_accounting(action_runtime, expected=0) diff --git a/gateway/tests/test_action_migrations.py b/gateway/tests/test_action_migrations.py index 97fc8ccc..b99c0f38 100644 --- a/gateway/tests/test_action_migrations.py +++ b/gateway/tests/test_action_migrations.py @@ -7,6 +7,7 @@ import pytest +from tests.support import db_gate from tests.support.action_database_guard import ( require_action_database_url, require_action_test_environment, @@ -164,8 +165,8 @@ def test_action_migration_retains_legacy_and_blocks_authority_loss( ] -@pytest.mark.skipif( - not os.environ.get("ACTION_MIGRATION_DATABASE_URL"), +@db_gate.collection_mark( + url_env="ACTION_MIGRATION_DATABASE_URL", reason="requires fresh disposable local action migration DB", ) def test_postgres_legacy_roundtrip_and_fail_closed_downgrade(): diff --git a/gateway/tests/test_action_postgres.py b/gateway/tests/test_action_postgres.py index 12de192e..33db7b8a 100644 --- a/gateway/tests/test_action_postgres.py +++ b/gateway/tests/test_action_postgres.py @@ -5,8 +5,10 @@ import pytest import pytest_asyncio +from app.services.ownership import CallerIdentity from tests import test_action_invocation as action from tests.conftest import clean_database as sqlite_clean_database +from tests.support import db_gate from tests.support.action_database_guard import ( require_action_database_url, require_action_test_environment, @@ -20,8 +22,8 @@ pytestmark = [ pytest.mark.asyncio(loop_scope="session"), - pytest.mark.skipif( - os.environ.get("RUN_POSTGRES_CONCURRENCY_TESTS") != "1", + db_gate.collection_mark( + flag="RUN_POSTGRES_CONCURRENCY_TESTS", reason="requires explicitly opted-in disposable local amw_action PostgreSQL database", ), ] @@ -141,7 +143,9 @@ async def hold_validation(**kwargs): monkeypatch.setattr(permits, "_validate_model_for_action", hold_validation) pending = asyncio.create_task(action.prepare_action(action_runtime, args)) await asyncio.wait_for(locked.wait(), timeout=5) - revocation = asyncio.create_task(permits.revoke_permit(action_runtime[2])) + revocation = asyncio.create_task( + permits.revoke_permit(action_runtime[2], caller=CallerIdentity.admin()) + ) try: await wait_for_lock_waiter(blocker) finally: diff --git a/gateway/tests/test_action_recovery.py b/gateway/tests/test_action_recovery.py index 3078fa7f..cabc4168 100644 --- a/gateway/tests/test_action_recovery.py +++ b/gateway/tests/test_action_recovery.py @@ -229,6 +229,13 @@ async def test_terminal_and_uncertain_owners_never_reopen(action_runtime, mode): action_runtime[3].mode = mode await invoke_action(action_runtime, "standard", "first") owner = (await action_rows())[0] + if mode in {"pre_dispatch_failure", "delivery_uncertain", "returned_error"}: + async with get_session_factory()() as session: + receipt = await session.get(ReceiptModel, owner.response_reference) + assert receipt is not None + assert json.loads(receipt.constraints_evaluated_json or "{}") == { + "max_calls_per_tool": {"partner.pay": 1} + } before = owner.model_dump() calls = len(action_runtime[3].calls) effects = action_runtime[3].dispatch_count diff --git a/gateway/tests/test_agent_comms_durable.py b/gateway/tests/test_agent_comms_durable.py index 1b9924f8..4b8a9299 100644 --- a/gateway/tests/test_agent_comms_durable.py +++ b/gateway/tests/test_agent_comms_durable.py @@ -287,8 +287,8 @@ async def test_durable_wallet_cannot_read_or_send_as_foreign_agent(clean_databas ), ] for resp in denied: - assert resp.status_code == 403 - assert resp.json()["detail"]["error"] == "access_denied" + assert resp.status_code == 404, resp.text + assert resp.json()["detail"]["error"] == "agent_not_found" assert "a-only" not in resp.text assert a["agent_wallet_id"] not in resp.text @@ -336,8 +336,8 @@ async def test_durable_inbox_denied_to_jwt_from_another_wallet( params={"agent_id": a_agent}, headers=b_bearer, ) - assert foreign.status_code == 403, foreign.text - assert foreign.json()["detail"]["error"] == "access_denied" + assert foreign.status_code == 404, foreign.text + assert foreign.json()["detail"]["error"] == "agent_not_found" spoof = await client.post( "/v1/agent-comms/send", @@ -349,7 +349,8 @@ async def test_durable_inbox_denied_to_jwt_from_another_wallet( }, headers=b_bearer, ) - assert spoof.status_code == 403, spoof.text + assert spoof.status_code == 404, spoof.text + assert spoof.json()["detail"]["error"] == "agent_not_found" own = await client.get( "/v1/agent-comms/inbox", diff --git a/gateway/tests/test_agent_url_and_input_hardening.py b/gateway/tests/test_agent_url_and_input_hardening.py index d3500a4a..ab7b97cf 100644 --- a/gateway/tests/test_agent_url_and_input_hardening.py +++ b/gateway/tests/test_agent_url_and_input_hardening.py @@ -103,6 +103,104 @@ async def test_ip_encoding_tricks_are_blocked(self, url): # literals; the guard must still reject them. assert await check_outbound_url(url) == "private_address_blocked" + @pytest.mark.anyio + @pytest.mark.parametrize( + "url", + [ + "http://[::169.254.169.254]/latest/meta-data/", + "http://[::10.0.0.1]/", + "http://[::127.0.0.1]/", + "http://[64:ff9b::169.254.169.254]/", + "http://[64:ff9b::10.0.0.1]/", + "http://[64:ff9b::7f00:1]/", + "http://[::ffff:0:169.254.169.254]/", + "http://[::ffff:0:10.0.0.1]/", + "http://[::ffff:0:7f00:1]/", + "http://[fec0::1]/", + ], + ) + async def test_reserved_and_site_local_literals_are_blocked(self, monkeypatch, url): + """Addresses the stdlib marks global must still be blocked. + + IPv4-compatible (::169.254.169.254), IPv4-translated + (::ffff:0:169.254.169.254), and well-known NAT64 (64:ff9b::/96) + are is_global, and stacks still deliver them to the embedded + private IPv4, including link-local metadata. Deprecated site-local + fec0::/10 is is_global too. + """ + monkeypatch.setattr(get_settings(), "ALLOW_PRIVATE_NETWORK_TARGETS", False) + assert await check_outbound_url(url) == "private_address_blocked" + + @pytest.mark.anyio + @pytest.mark.parametrize( + "url", + [ + "http://2130706433/", + "http://0x7f000001/", + "http://0177.0.0.1/", + "http://127.1/", + "http://0/", + ], + ) + async def test_ambiguous_numeric_host_blocked_when_dns_is_public( + self, monkeypatch, url + ): + """A numeric host libc may parse as IPv4 is blocked before DNS. + + ipaddress rejects these forms, so a guard that only resolves them + accepts the URL when DNS returns a public address. Chromium and libc + can still dial the embedded private address (127.0.0.1 for the + examples above). + """ + + async def resolve(_host): + return [(None, None, None, None, ("93.184.216.34", 0))] + + monkeypatch.setattr(get_settings(), "ALLOW_PRIVATE_NETWORK_TARGETS", False) + monkeypatch.setattr(url_guard, "_resolve_host", resolve) + assert await check_outbound_url(url) == "private_address_blocked" + + @pytest.mark.anyio + @pytest.mark.parametrize( + "address", + [ + "::169.254.169.254", + "64:ff9b::10.0.0.1", + "fec0::1", + "::10.0.0.1", + "::ffff:0:169.254.169.254", + ], + ) + async def test_dns_to_reserved_or_site_local_is_blocked(self, monkeypatch, address): + async def resolve(_host): + return [ + (None, None, None, None, ("93.184.216.34", 0)), + (None, None, None, None, (address, 0)), + ] + + monkeypatch.setattr(get_settings(), "ALLOW_PRIVATE_NETWORK_TARGETS", False) + monkeypatch.setattr(url_guard, "_resolve_host", resolve) + assert ( + await check_outbound_url("https://partner.example/page") + == "private_address_blocked" + ) + + @pytest.mark.anyio + async def test_public_nat64_and_compatible_embeddings_stay_allowed( + self, monkeypatch + ): + """DNS64 publishes public IPv4 inside 64:ff9b::/96. That stays allowed.""" + monkeypatch.setattr(get_settings(), "ALLOW_PRIVATE_NETWORK_TARGETS", False) + + async def resolve(_host): + return [(None, None, None, None, ("64:ff9b::8.8.8.8", 0))] + + monkeypatch.setattr(url_guard, "_resolve_host", resolve) + assert await check_outbound_url("https://[64:ff9b::8.8.8.8]/") is None + assert await check_outbound_url("https://[::8.8.8.8]/") is None + assert await check_outbound_url("https://[::ffff:0:8.8.8.8]/") is None + assert await check_outbound_url("https://ipv4-only.example/") is None + @pytest.mark.anyio async def test_userinfo_does_not_mask_a_public_host(self, public_documentation_dns): # Here the real host is the public one; the loopback string is only diff --git a/gateway/tests/test_api_keys.py b/gateway/tests/test_api_keys.py index d6e35054..c70e880a 100644 --- a/gateway/tests/test_api_keys.py +++ b/gateway/tests/test_api_keys.py @@ -307,6 +307,106 @@ async def test_revoke_api_key_not_found(client, api_headers, sponsor_wallet): assert resp.status_code == 404 +@pytest.mark.anyio +async def test_repeat_revoke_keeps_original_timestamp_and_reason( + client, + api_headers, + sponsor_wallet, + enforce_naive_utc_datetime_columns, +): + """A second revoke must not replace the recorded time or reason. + + An emergency reason such as ``EMERGENCY: …`` is evidence. A later + ``user_request`` call has to leave that record alone. + """ + from app.services.api_key_service import KeyNotFoundError, get_api_key_service + + wallet_id = sponsor_wallet["wallet_id"] + created = await client.post( + "/v1/api-keys", + json={"wallet_id": wallet_id}, + headers=api_headers, + ) + assert created.status_code == 201 + key_id = created.json()["key_id"] + service = get_api_key_service() + + assert await service.revoke_key(wallet_id, key_id, "EMERGENCY: containment") is True + + original = datetime(2020, 1, 2, 3, 4, 5) + factory = get_session_factory() + async with factory() as session: + key = await session.get(APIKeyModel, key_id) + assert key is not None + assert key.status == "revoked" + assert key.revoke_reason == "EMERGENCY: containment" + key.revoked_at = original + await session.commit() + + assert await service.revoke_key(wallet_id, key_id, "user_request") is True + + async with factory() as session: + key = await session.get(APIKeyModel, key_id) + assert key is not None + assert key.status == "revoked" + assert key.revoked_at == original + assert key.revoke_reason == "EMERGENCY: containment" + + with pytest.raises(KeyNotFoundError): + await service.revoke_key(wallet_id, "missing-key", "user_request") + with pytest.raises(KeyNotFoundError): + await service.revoke_key("other-wallet", key_id, "user_request") + + async with factory() as session: + key = await session.get(APIKeyModel, key_id) + assert key is not None + assert key.status == "revoked" + assert key.revoke_reason == "EMERGENCY: containment" + + +@pytest.mark.anyio +@pytest.mark.parametrize("prior_status", ["suspended", "expired"]) +async def test_revoke_key_still_revokes_unrevoked_non_active_key( + client, + api_headers, + sponsor_wallet, + enforce_naive_utc_datetime_columns, + prior_status, +): + """Only an already-revoked key is left untouched. Suspended or expired is not.""" + from app.services.api_key_service import get_api_key_service + + wallet_id = sponsor_wallet["wallet_id"] + created = await client.post( + "/v1/api-keys", + json={"wallet_id": wallet_id}, + headers=api_headers, + ) + assert created.status_code == 201 + key_id = created.json()["key_id"] + + factory = get_session_factory() + async with factory() as session: + key = await session.get(APIKeyModel, key_id) + assert key is not None + key.status = prior_status + key.revoked_at = None + key.revoke_reason = None + await session.commit() + + assert ( + await get_api_key_service().revoke_key(wallet_id, key_id, "user_request") + is True + ) + + async with factory() as session: + key = await session.get(APIKeyModel, key_id) + assert key is not None + assert key.status == "revoked" + assert key.revoke_reason == "user_request" + assert key.revoked_at is not None + + @pytest.mark.anyio async def test_emergency_revoke(client, api_headers, sponsor_wallet): """Test emergency key revocation.""" diff --git a/gateway/tests/test_audit_low_surface.py b/gateway/tests/test_audit_low_surface.py new file mode 100644 index 00000000..c8529bbf --- /dev/null +++ b/gateway/tests/test_audit_low_surface.py @@ -0,0 +1,181 @@ +"""Low-severity audit surface: defaults that must stay restrictive. + +Covers the gateway half of the API-repo low fixes: CORS defaults to no +origins, the OpenAPI spec never names a domain the operator does not +control, unauthenticated health output carries no broker host or port, and +an empty permit tool allowlist admits no tool. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.config import Settings +from app.core.health import check_mqtt_readiness, gather_dependency_report +from app.db.database import get_session_factory +from app.db.models import PermitModel +from app.main import app, build_openapi_servers +from app.schemas.trust import PermitCreateRequest +from app.services.permits import get_permit_service +from tests.test_trust_helpers import provision_agent_wallet + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# --------------------------------------------------------------------------- +# CORS: default deny, explicit opt-in only +# --------------------------------------------------------------------------- + + +def test_cors_default_is_empty(): + """A fresh Settings object must not grant any origin CORS headers.""" + assert Settings().CORS_ORIGINS == "" + origins = [o.strip() for o in Settings().CORS_ORIGINS.split(",") if o.strip()] + assert origins == [] + + +# --------------------------------------------------------------------------- +# OpenAPI servers: no placeholder domain +# --------------------------------------------------------------------------- + + +def test_openapi_servers_omitted_without_public_url(): + assert build_openapi_servers("", "local") is None + assert build_openapi_servers(" ", "production") is None + + +def test_openapi_servers_uses_public_url_when_set(): + servers = build_openapi_servers("https://api.example.com", "local") + assert servers is not None + assert servers[0]["url"] == "https://api.example.com" + assert all("thisisatest" not in entry["url"] for entry in servers) + + +def test_openapi_servers_never_contains_test_domain(): + for public_url, environment in [ + ("", "local"), + ("", "production"), + ("https://api.example.com", "local"), + ("https://api.example.com", "production"), + ]: + servers = build_openapi_servers(public_url, environment) + assert servers is None or all( + "thisisatest" not in entry["url"] for entry in servers + ) + + +def test_openapi_servers_loopback_only_beside_public_url_in_dev(): + servers = build_openapi_servers("https://api.example.com", "local") + assert servers is not None + assert [entry["url"] for entry in servers] == [ + "https://api.example.com", + "http://localhost:8000", + ] + prod_servers = build_openapi_servers("https://api.example.com", "production") + assert prod_servers is not None + assert [entry["url"] for entry in prod_servers] == ["https://api.example.com"] + + +# --------------------------------------------------------------------------- +# Readiness: no broker host/port on the unauthenticated payload +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_ready_mqtt_reports_status_without_host_or_port(monkeypatch): + """Even a reachable broker must not have its address published.""" + import app.core.health as health_module + + async def fake_mqtt(_sim_modes): + return {"status": "up", "host": "broker.internal.example", "port": 1883} + + monkeypatch.setattr(health_module, "_check_mqtt", fake_mqtt) + result = await check_mqtt_readiness() + assert result["status"] == "up" + rendered = json.dumps(result) + assert "broker.internal.example" not in rendered + assert "1883" not in rendered + assert "host" not in result + assert "port" not in result + + +@pytest.mark.anyio +async def test_ready_endpoint_publishes_no_broker_address(client): + resp = await client.get("/health/ready") + assert resp.status_code in (200, 503) + body = resp.json() + assert "host" not in body["checks"]["mqtt"] + assert "port" not in body["checks"]["mqtt"] + + +@pytest.mark.anyio +async def test_full_report_mqtt_carries_no_broker_address(monkeypatch): + """The full dependency report is also served without login on + proof-surface hosts, so the projection applies there too.""" + import app.core.health as health_module + + async def fake_mqtt(_sim_modes): + return {"status": "up", "host": "broker.internal.example", "port": 1883} + + monkeypatch.setattr(health_module, "_check_mqtt", fake_mqtt) + report = await gather_dependency_report() + mqtt = report["dependencies"]["mqtt"] + assert mqtt["status"] == "up" + assert "host" not in mqtt + assert "port" not in mqtt + + +# --------------------------------------------------------------------------- +# Permits: empty allowlist admits no tool, even with matching scopes +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_empty_allowlist_denies_despite_matching_scope(client, clean_database): + """The case the old `if allowed_tools and ...` guard let through. + + A permit with no tool restriction but an explicit scope for the tool + used to pass the skipped allowlist check and authorize on scopes alone. + It must now be denied with permit_tool_not_allowed. + """ + provisioned = await provision_agent_wallet(client) + service = get_permit_service() + permit = await service.create_permit( + PermitCreateRequest( + issuer_wallet_id=provisioned["agent_wallet_id"], + subject_wallet_id=provisioned["agent_wallet_id"], + subject_key_id=provisioned["key_id"], + allowed_tools=[], + scopes=["tool:scoped-hole-tool:invoke", "billing:charge"], + max_credits=Decimal("50"), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=30), + ), + subject_key_id=provisioned["key_id"], + ) + + validation = await service.validate_for_action( + permit_id=permit.permit_id, + wallet_id=provisioned["agent_wallet_id"], + tool_name="scoped-hole-tool", + estimated_credits=Decimal("1"), + key_id=provisioned["key_id"], + ) + assert validation.allowed is False + assert validation.reason == "permit_tool_not_allowed" + + # The stored row still says what it says; only the verdict changed. + factory = get_session_factory() + async with factory() as session: + model = await session.get(PermitModel, permit.permit_id) + assert model is not None + assert json.loads(model.allowed_tools_json) == [] diff --git a/gateway/tests/test_awi_admission_ordering.py b/gateway/tests/test_awi_admission_ordering.py index 3e96be06..c89ddfe2 100644 --- a/gateway/tests/test_awi_admission_ordering.py +++ b/gateway/tests/test_awi_admission_ordering.py @@ -14,6 +14,7 @@ from sqlalchemy import select from app.core.time import utc_now +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import LedgerEntryModel, PermitModel, WalletModel from app.main import app @@ -455,7 +456,9 @@ async def test_new_call_denied_but_prior_receipt_remains_replayable( first = await client.post(case.path, json=case.body, headers=case.headers) assert first.status_code == 200 if constraint == "revoked": - await get_permit_service().revoke_permit(case.permit) + await get_permit_service().revoke_permit( + case.permit, caller=CallerIdentity.admin() + ) else: # Mutable expiry is signed; advancing the validation clock preserves # signature identity and models expiry without corrupting the permit. diff --git a/gateway/tests/test_billing.py b/gateway/tests/test_billing.py index e961035d..b2d15dad 100644 --- a/gateway/tests/test_billing.py +++ b/gateway/tests/test_billing.py @@ -13,6 +13,7 @@ from sqlalchemy import select from app.main import app +from app.services.ownership import CallerIdentity from app.core.config import Settings, get_settings from app.db.database import get_session_factory from app.db.models import LedgerEntryModel, WalletModel @@ -377,11 +378,13 @@ async def test_refund_charge_reverses_debit(client, api_headers, clean_database) wallet_id=agent_wallet_id, charge_entry_id=charge["entry_id"], description="Refund failed MCP tool", + caller=CallerIdentity.admin(), ) duplicate_refund = await get_agent_money().refund_charge( wallet_id=agent_wallet_id, charge_entry_id=charge["entry_id"], description="Refund failed MCP tool", + caller=CallerIdentity.admin(), ) assert refund.action.value == "refund" assert refund.entry_id == f"refund-{charge['entry_id']}" @@ -479,6 +482,7 @@ async def test_late_refund_does_not_reduce_new_period_spend( wallet_id=agent_wallet_id, charge_entry_id=charge["entry_id"], description="Late refund failed MCP tool", + caller=CallerIdentity.admin(), ) assert refund.entry_id == f"refund-{charge['entry_id']}" @@ -1375,6 +1379,7 @@ async def test_billing_engine_refuses_non_positive_units(units): service_category=ServiceCategory.IOT_BRIDGE, units=units, request_path="/non-positive", + caller=CallerIdentity.admin(), ) @@ -1398,6 +1403,7 @@ async def test_billing_engine_refuses_non_finite_units(units): service_category=ServiceCategory.IOT_BRIDGE, units=units, request_path="/non-finite", + caller=CallerIdentity.admin(), ) @@ -2429,6 +2435,7 @@ async def test_losing_an_operation_key_race_does_not_leave_velocity_overcounted( service_category=ServiceCategory.AGENT_COMMS, units=Decimal("2"), operation_key=operation_key, + caller=CallerIdentity.admin(), ) # The winner: an ordinary charge under the same operation key. diff --git a/gateway/tests/test_billing_engine_adversarial.py b/gateway/tests/test_billing_engine_adversarial.py new file mode 100644 index 00000000..c4eb9f99 --- /dev/null +++ b/gateway/tests/test_billing_engine_adversarial.py @@ -0,0 +1,348 @@ +"""Engine amount guards reject malformed creation inputs before money moves.""" + +from __future__ import annotations + +import asyncio +from decimal import Decimal + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.database import get_session_factory +from app.db.models import LedgerEntryModel, WalletModel +from app.schemas.billing import InsufficientFundsResponse, ServiceCategory +from app.services.agent_money import get_agent_money +from app.services.ownership import CallerIdentity + +pytestmark = pytest.mark.anyio + +NON_FINITE = [ + pytest.param(Decimal("NaN"), id="decimal-nan"), + pytest.param(Decimal("sNaN"), id="decimal-snan"), + pytest.param(Decimal("Infinity"), id="decimal-positive-infinity"), + pytest.param(Decimal("-Infinity"), id="decimal-negative-infinity"), + pytest.param(float("nan"), id="float-nan"), + pytest.param(float("inf"), id="float-positive-infinity"), + pytest.param(float("-inf"), id="float-negative-infinity"), +] +NEGATIVE = [ + pytest.param(Decimal("-1"), id="decimal-negative"), + pytest.param(-1.0, id="float-negative"), +] + + +def _owner(wallet_id: str) -> CallerIdentity: + return CallerIdentity(wallet_id=wallet_id) + + +async def _state(session: AsyncSession): + """Capture persisted money state, flushing any staged ORM writes first.""" + await session.flush() + wallets = (await session.execute(select(WalletModel))).scalars().all() + entries = (await session.execute(select(LedgerEntryModel))).scalars().all() + return ( + sorted( + ( + wallet.wallet_id, + wallet.balance, + wallet.status, + wallet.lifetime_credits, + wallet.lifetime_debits, + ) + for wallet in wallets + ), + sorted( + ( + entry.entry_id, + entry.wallet_id, + entry.action, + entry.amount, + entry.balance_after, + ) + for entry in entries + ), + ) + + +async def _committed_state(): + async with get_session_factory()() as session: + return await _state(session) + + +async def _sponsor(credits: Decimal = Decimal("100")): + return await get_agent_money().create_sponsor_wallet( + sponsor_name="Amount guard sponsor", + email="amount-guard@example.com", + initial_credits=credits, + require_kyc=False, + ) + + +async def _agent(): + sponsor = await _sponsor() + agent = await get_agent_money().create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="amount-guard-parent", + budget_credits=Decimal("60"), + caller=_owner(sponsor.wallet_id), + ) + return sponsor, agent + + +@pytest.mark.parametrize("raw", NON_FINITE) +@pytest.mark.parametrize("caller_owned", [False, True]) +async def test_sponsor_rejects_non_finite_before_writes( + clean_database, raw, caller_owned +): + money = get_agent_money() + before = await _committed_state() + if caller_owned: + async with get_session_factory()() as session: + async with session.begin(): + with pytest.raises(ValueError) as error: + await money.create_sponsor_wallet( + sponsor_name="Invalid", + email="invalid@example.com", + initial_credits=raw, + require_kyc=False, + session=session, + ) + assert str(error.value) == "initial_credits must be finite" + assert session.in_transaction() + assert await _state(session) == before + else: + with pytest.raises(ValueError) as error: + await money.create_sponsor_wallet( + sponsor_name="Invalid", + email="invalid@example.com", + initial_credits=raw, + require_kyc=False, + ) + assert str(error.value) == "initial_credits must be finite" + assert await _committed_state() == before + + +@pytest.mark.parametrize("raw", NEGATIVE) +async def test_sponsor_keeps_negative_refusal(clean_database, raw): + before = await _committed_state() + with pytest.raises(ValueError) as error: + await _sponsor(raw) + assert str(error.value) == "initial_credits cannot be negative" + assert await _committed_state() == before + + +@pytest.mark.parametrize("credits", [Decimal("0"), 0.0, Decimal("12.5"), 12.5]) +async def test_sponsor_keeps_zero_and_finite_seed(clean_database, credits): + wallet = await _sponsor(credits) + state = await _committed_state() + assert state[0][0][0] == wallet.wallet_id + assert state[0][0][1] == Decimal(str(credits)) + assert len(state[1]) == (1 if credits else 0) + + +@pytest.mark.parametrize("raw", NON_FINITE) +@pytest.mark.parametrize("caller_owned", [False, True]) +async def test_agent_rejects_non_finite_without_debit( + clean_database, raw, caller_owned +): + sponsor = await _sponsor() + money = get_agent_money() + before = await _committed_state() + if caller_owned: + async with get_session_factory()() as session: + async with session.begin(): + with pytest.raises(ValueError) as error: + await money.create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="invalid", + budget_credits=raw, + session=session, + caller=_owner(sponsor.wallet_id), + ) + assert str(error.value) == "budget_credits must be finite" + assert session.in_transaction() + assert await _state(session) == before + else: + with pytest.raises(ValueError) as error: + await money.create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="invalid", + budget_credits=raw, + caller=_owner(sponsor.wallet_id), + ) + assert str(error.value) == "budget_credits must be finite" + assert await _committed_state() == before + + +@pytest.mark.parametrize("raw", NEGATIVE) +async def test_agent_keeps_negative_refusal(clean_database, raw): + sponsor = await _sponsor() + before = await _committed_state() + with pytest.raises(ValueError) as error: + await get_agent_money().create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="invalid", + budget_credits=raw, + caller=_owner(sponsor.wallet_id), + ) + assert str(error.value) == "budget_credits cannot be negative" + assert await _committed_state() == before + + +@pytest.mark.parametrize("budget", [Decimal("0"), 0.0, Decimal("12.5"), 12.5]) +async def test_agent_keeps_zero_and_finite_budget(clean_database, budget): + sponsor = await _sponsor() + agent = await get_agent_money().create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="valid", + budget_credits=budget, + caller=_owner(sponsor.wallet_id), + ) + state = await _committed_state() + balances = {wallet_id: balance for wallet_id, balance, *_ in state[0]} + assert balances[sponsor.wallet_id] == Decimal("100") - Decimal(str(budget)) + assert balances[agent.wallet_id] == Decimal(str(budget)) + assert len(state[1]) == 3 + + +@pytest.mark.parametrize("raw", NON_FINITE) +async def test_child_rejects_non_finite_budget_without_debit(clean_database, raw): + _, parent = await _agent() + before = await _committed_state() + with pytest.raises(ValueError) as error: + await get_agent_money().create_child_wallet( + parent_wallet_id=parent.wallet_id, + child_agent_id="invalid", + budget_credits=raw, + max_spend=Decimal("20"), + caller=_owner(parent.wallet_id), + ) + assert str(error.value) == "budget_credits must be finite" + assert await _committed_state() == before + + +@pytest.mark.parametrize("raw", NEGATIVE) +async def test_child_keeps_negative_budget_refusal(clean_database, raw): + _, parent = await _agent() + before = await _committed_state() + with pytest.raises(ValueError) as error: + await get_agent_money().create_child_wallet( + parent_wallet_id=parent.wallet_id, + child_agent_id="invalid", + budget_credits=raw, + max_spend=Decimal("20"), + caller=_owner(parent.wallet_id), + ) + assert str(error.value) == "budget_credits cannot be negative" + assert await _committed_state() == before + + +@pytest.mark.parametrize("budget", [Decimal("0"), 0.0, Decimal("12.5"), 12.5]) +async def test_child_keeps_zero_and_finite_budget(clean_database, budget): + _, parent = await _agent() + child = await get_agent_money().create_child_wallet( + parent_wallet_id=parent.wallet_id, + child_agent_id="valid", + budget_credits=budget, + max_spend=Decimal("20"), + caller=_owner(parent.wallet_id), + ) + state = await _committed_state() + balances = {wallet_id: balance for wallet_id, balance, *_ in state[0]} + assert balances[parent.wallet_id] == Decimal("60") - Decimal(str(budget)) + assert balances[child.wallet_id] == Decimal(str(budget)) + assert len(state[1]) == 5 + + +@pytest.mark.parametrize("raw", [*NON_FINITE, *NEGATIVE, Decimal("0"), 0.0]) +async def test_child_rejects_invalid_spend_cap_without_debit(clean_database, raw): + _, parent = await _agent() + before = await _committed_state() + with pytest.raises(ValueError) as error: + await get_agent_money().create_child_wallet( + parent_wallet_id=parent.wallet_id, + child_agent_id="invalid", + budget_credits=Decimal("10"), + max_spend=raw, + caller=_owner(parent.wallet_id), + ) + assert str(error.value) == "max_spend must be a finite number greater than zero" + assert await _committed_state() == before + + +@pytest.mark.parametrize("max_spend", [Decimal("20"), 20.0]) +async def test_child_accepts_finite_positive_spend_cap(clean_database, max_spend): + _, parent = await _agent() + child = await get_agent_money().create_child_wallet( + parent_wallet_id=parent.wallet_id, + child_agent_id="valid", + budget_credits=Decimal("10"), + max_spend=max_spend, + caller=_owner(parent.wallet_id), + ) + async with get_session_factory()() as session: + wallet = await session.get(WalletModel, child.wallet_id) + assert wallet is not None + assert wallet.max_spend == Decimal("20") + + +async def test_empty_wallet_charge_reports_full_shortfall(clean_database): + wallet = await _sponsor(Decimal("0")) + before = await _committed_state() + result = await get_agent_money().charge( + wallet.wallet_id, + ServiceCategory.SANDBOX, + Decimal("1"), + caller=_owner(wallet.wallet_id), + ) + assert isinstance(result, InsufficientFundsResponse) + assert Decimal(result.shortfall_exact) == Decimal(result.required_amount_exact) + assert Decimal(result.current_balance_exact) == Decimal("0") + assert await _committed_state() == before + + +async def test_concurrent_double_refund_credits_once(clean_database): + sponsor = await _sponsor(Decimal("10000")) + money = get_agent_money() + agent = await money.create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="refund-race", + budget_credits=Decimal("1000"), + caller=_owner(sponsor.wallet_id), + ) + charge = await money.charge( + agent.wallet_id, + ServiceCategory.SANDBOX, + Decimal("1"), + caller=_owner(agent.wallet_id), + ) + assert not isinstance(charge, InsufficientFundsResponse) + before = await _committed_state() + before_balance = next( + balance for wallet_id, balance, *_ in before[0] if wallet_id == agent.wallet_id + ) + await asyncio.gather( + money.refund_charge( + wallet_id=agent.wallet_id, + charge_entry_id=charge.entry_id, + caller=_owner(agent.wallet_id), + ), + money.refund_charge( + wallet_id=agent.wallet_id, + charge_entry_id=charge.entry_id, + caller=_owner(agent.wallet_id), + ), + return_exceptions=True, + ) + after = await _committed_state() + after_balance = next( + balance for wallet_id, balance, *_ in after[0] if wallet_id == agent.wallet_id + ) + assert after_balance - before_balance == Decimal("1000") - before_balance + refunds = [ + entry + for entry in after[1] + if entry[1] == agent.wallet_id and entry[2] == "refund" + ] + assert len(refunds) == 1 diff --git a/gateway/tests/test_blob.py b/gateway/tests/test_blob.py index baad99a9..d2f3640b 100644 --- a/gateway/tests/test_blob.py +++ b/gateway/tests/test_blob.py @@ -7,11 +7,14 @@ succeed. """ +import inspect from pathlib import Path +from typing import get_type_hints import pytest from app.core.blob import ( + BlobBackend, LocalFilesystemBlob, _UnimplementedBlob, blob_backend_name, @@ -38,15 +41,27 @@ def _clear_backend_cache(): @pytest.mark.anyio -async def test_local_backend_put_get_round_trip(local_blob): +async def test_local_backend_put_get_round_trip(local_blob, tmp_path): payload = b"hello world" url = await local_blob.put("my-video.mp4", payload, "video/mp4") - assert url.startswith("file://") + # Refs are opaque: no scheme, no host path, only the content digest. + assert url.startswith("blob:") + assert "file://" not in url + assert str(tmp_path) not in url got = await local_blob.get("my-video.mp4") assert got == payload +@pytest.mark.anyio +async def test_local_backend_url_ref_is_opaque(local_blob, tmp_path): + """Ref lookups must not echo the blob root back to the caller.""" + ref = await local_blob.url("my-video.mp4") + assert ref.startswith("blob:") + assert str(tmp_path) not in ref + assert "/" not in ref + + @pytest.mark.anyio async def test_local_backend_isolates_keys_under_hashed_path(tmp_path): """Path-traversal attempts must not escape the blob root.""" @@ -125,3 +140,12 @@ async def test_stub_methods_raise_not_implemented_with_freeze_guidance(): ): with pytest.raises(NotImplementedError, match="docs/PROOF_SURFACES.md"): await operation() + + +def test_unimplemented_blob_keeps_base_parameter_annotations(): + """Bare overrides widen every parameter to Any and hide caller mistakes.""" + for name in ("put", "get", "delete", "url"): + base = getattr(BlobBackend, name) + override = getattr(_UnimplementedBlob, name) + assert inspect.signature(override) == inspect.signature(base) + assert get_type_hints(override) == get_type_hints(base) diff --git a/gateway/tests/test_clean_database_adoption.py b/gateway/tests/test_clean_database_adoption.py new file mode 100644 index 00000000..b703be61 --- /dev/null +++ b/gateway/tests/test_clean_database_adoption.py @@ -0,0 +1,158 @@ +"""Ratchet: tests that touch the shared database must clean up after itself. + +``clean_database`` (tests/conftest.py) empties an explicit table list, and +tests/test_clean_database_coverage.py keeps that list in sync with the +schema. The remaining gap is files that never take the fixture: about half +the suite relies on other isolation, so a new test that writes wallets, +permits, or keys against the shared app can leak rows into the next test +without any gate noticing. + +This test flags any test module that reaches the shared database (imports +the shared ``app`` from app.main, or a session factory from +app.db.database) without taking ``clean_database``. Modules that predate +the gate are frozen in ``LEGACY_MODULES_WITHOUT_CLEANUP`` below: a new +module may only join that set with a review-visible reason, and the +long-term direction is to shrink the set by adopting the fixture. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent + +# Session entry points to the shared test database. Importing the shared +# app counts because every request through it can write (permits, wallets, +# keys, ledger entries); read-only users of the app are grandfathered in +# the legacy set, not exempted, so the set stays an honest census. +DB_SESSION_IMPORTS = frozenset( + {"get_session_factory", "get_engine", "init_db", "get_session"} +) + +# Test modules that reach the shared database without taking +# clean_database, frozen at gate introduction. New modules must not be +# added without a comment explaining their isolation; prefer adopting +# clean_database so this set shrinks over time. +LEGACY_MODULES_WITHOUT_CLEANUP = frozenset( + { + "test_action_multiprocess", + "test_action_preacceptance", + "test_agent_first_contract", + "test_api_keys", + "test_awi_adoption", + "test_backend_contract_regressions", + "test_behavioral_sandbox", + "test_build_provenance", + "test_commit_sha_precedence", + "test_content_store", + "test_discover_kyc_honesty", + "test_discovery", + "test_discovery_consistency", + "test_discovery_drift", + "test_discovery_honesty", + "test_discovery_tools_consistency", + "test_duplicate_guard_config", + "test_duplicate_guard_postgres_concurrency", + "test_head_method", + "test_health_public_projection", + "test_health_redis_liveness", + "test_health_sim_parity", + "test_human_accessibility", + "test_iot", + "test_iot_registry", + "test_key_distribution_references", + "test_kyc", + "test_mcp_postgres_multiprocess", + "test_naive_utc_datetime_columns", + "test_oracle_durable_crawl", + "test_oracle_store", + "test_permit_postgres_concurrency", + "test_phase7_followups", + "test_pods", + "test_production_trust_posture", + "test_public_metadata", + "test_rate_limit_threshold", + "test_request_body_limit", + "test_route_auth_inventory", + "test_runtime_degradation", + "test_runtime_mode", + "test_sandbox_integration", + "test_scaffolding_harden", + "test_schema_boot", + "test_security_headers", + "test_security_store", + "test_stale_commit_sha_prevention", + "test_stripe_integration", + "test_swarm_wallets", + "test_telemetry_scope", + "test_telemetry_store", + "test_transfers", + "test_v1_production", + "test_wedge_honesty", + } +) + + +def _module_status(path: Path) -> tuple[bool, bool]: + """Return (touches shared DB, takes clean_database) for one module.""" + tree = ast.parse(path.read_text()) + touches_db = False + uses_cleanup = False + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.module == "app.main" and any( + alias.name == "app" for alias in node.names + ): + touches_db = True + elif node.module == "app.db.database" and any( + alias.name in DB_SESSION_IMPORTS for alias in node.names + ): + touches_db = True + elif node.module == "tests.conftest" and any( + alias.name == "clean_database" for alias in node.names + ): + uses_cleanup = True + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if "clean_database" in {arg.arg for arg in node.args.args}: + uses_cleanup = True + return touches_db, uses_cleanup + + +def _modules_without_cleanup() -> set[str]: + found = set() + for path in sorted(TESTS_DIR.glob("test_*.py")): + touches_db, uses_cleanup = _module_status(path) + if touches_db and not uses_cleanup: + found.add(path.stem) + return found + + +def test_no_new_modules_skip_database_cleanup(): + new = _modules_without_cleanup() - LEGACY_MODULES_WITHOUT_CLEANUP + assert not new, ( + f"test modules reach the shared database without clean_database: " + f"{sorted(new)}. Take the clean_database fixture, or document the " + f"module's isolation by adding it to " + f"LEGACY_MODULES_WITHOUT_CLEANUP with reviewer approval." + ) + + +def test_legacy_cleanup_set_has_no_stale_entries(): + existing = {path.stem for path in TESTS_DIR.glob("test_*.py")} + assert not LEGACY_MODULES_WITHOUT_CLEANUP - existing, ( + f"stale legacy entries for modules that no longer exist or now " + f"take clean_database: " + f"{sorted(LEGACY_MODULES_WITHOUT_CLEANUP - existing)}" + ) + adopted = { + stem + for stem in LEGACY_MODULES_WITHOUT_CLEANUP + if stem in existing + and _module_status(TESTS_DIR / f"{stem}.py") != (True, False) + } + assert not adopted, ( + f"modules adopted clean_database or stopped touching the shared DB " + f"but are still listed as legacy: {sorted(adopted)}. Remove them " + f"from LEGACY_MODULES_WITHOUT_CLEANUP." + ) diff --git a/gateway/tests/test_comms.py b/gateway/tests/test_comms.py index f9b4b39f..f616ae42 100644 --- a/gateway/tests/test_comms.py +++ b/gateway/tests/test_comms.py @@ -317,8 +317,8 @@ async def test_wallet_cannot_drive_another_wallets_agent(client, clean_database) ), ] for resp in denied: - assert resp.status_code == 403 - assert resp.json()["detail"]["error"] == "access_denied" + assert resp.status_code == 404, resp.text + assert resp.json()["detail"]["error"] == "agent_not_found" _assert_no_leak(resp, "a-only", a["agent_wallet_id"]) # The owner still reads and acknowledges its own inbox. @@ -375,6 +375,63 @@ async def test_unregistered_agent_ids_fail_closed_for_wallet_callers( assert inbox.json()["count"] == 0 +def _not_found_shape(resp, agent_id: str) -> tuple: + body = resp.json()["detail"] + return ( + resp.status_code, + body["error"], + body["message"].replace(agent_id, ""), + ) + + +@pytest.mark.proof +@pytest.mark.anyio +async def test_foreign_agent_matches_unregistered_agent(client, clean_database): + """Acting on another wallet's agent must look like an unknown id. + + A 403 access_denied told the caller the id was registered to someone + else. Inbox, send, ack, and handoff share one denial. + """ + a = await provision_agent_wallet(client) + b = await provision_agent_wallet(client) + a_agent = await _register(client, a["agent_headers"], "a-hidden") + ghost = "agent-never-registered-probe" + + async def probe(agent_id: str): + headers = b["agent_headers"] + return [ + await client.get(f"/v1/comms/messages/{agent_id}/inbox", headers=headers), + await client.post( + f"/v1/comms/messages?from_agent={agent_id}", + json={"to_agent": a_agent, "subject": "spoof", "body": {}}, + headers=headers, + ), + await client.post( + f"/v1/comms/messages/{agent_id}/ack/no-such-message", + headers=headers, + ), + await client.post( + f"/v1/comms/handoff?from_agent={agent_id}", + json={"capability": "x", "context": {}}, + headers=headers, + ), + ] + + foreign = await probe(a_agent) + missing = await probe(ghost) + for got, baseline in zip(foreign, missing, strict=True): + assert got.status_code == 404, got.text + assert _not_found_shape(got, a_agent) == _not_found_shape(baseline, ghost) + assert "access_denied" not in got.text + assert a["agent_wallet_id"] not in got.text + + # The owner is unaffected. + own = await client.get( + f"/v1/comms/messages/{a_agent}/inbox", headers=a["agent_headers"] + ) + assert own.status_code == 200 + + @pytest.mark.proof @pytest.mark.anyio async def test_ownerless_agent_denied_to_wallet_callers( @@ -484,7 +541,8 @@ async def test_jwt_callers_from_different_wallets_do_not_share_ownership( ), ] for resp in denied: - assert resp.status_code == 403, resp.text + assert resp.status_code == 404, resp.text + assert resp.json()["detail"]["error"] == "agent_not_found" _assert_no_leak(resp, "a-only") listed = await client.get("/v1/comms/agents", headers=b_jwt_headers) diff --git a/gateway/tests/test_core_db_qa.py b/gateway/tests/test_core_db_qa.py new file mode 100644 index 00000000..0070a6d5 --- /dev/null +++ b/gateway/tests/test_core_db_qa.py @@ -0,0 +1,1460 @@ +"""Deep QA for gateway/app/core and gateway/app/db. + +Covers untested behavior in the trust-adjacent core (money validation, +auth helpers, guardrails, URL safety) and the DB layer (converters, +bind normalization, schema gates), with regression tests for four small +fixes: blob backend warning honesty, per-call settings in dependencies, +max_attempts guards in resilience, and SQLITE_URL form handling. +""" + +from __future__ import annotations + +import base64 +import logging +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from app.core.config import get_settings +from app.core.credits import credit_amount_fits_storage +from app.core.db_urls import ( + as_asyncpg_url, + as_sqlalchemy_url, + is_postgres_url, + is_sqlite_url, + sqlite_path_from_url, +) +from app.core.product_positioning import POSITIONING_SEMANTICS, get_product_positioning +from app.core.public_contact import validated_public_contact +from app.core.runtime_degradation import ( + get_runtime_degradation, + mark_durable_state_fell_back, + mark_rate_limiter_memory_fallback, + reset_runtime_degradation, +) +from app.core.runtime_mode import ( + UnknownServiceError, + get_simulation_modes, + is_simulation, + require_simulation, +) +from app.core.sentinel_target import ( + SentinelTargetError, + normalize_sentinel_origin, + sentinel_api_key_is_valid, + sentinel_health_url, +) +from app.core.time import to_naive_utc, utc_now +from app.core.trust_mode import ( + TrustModeGuardrailError, + is_production_like_environment, + normalize_environment, + require_explicit_environment_on_hosted_runtime, + validate_trust_mode_config, +) + +VALID_PROD_SIGNING_KEY = base64.b64encode(bytes(range(32))).decode() +VALID_PROD_DATABASE_URL = "postgresql+asyncpg://user:pw@db.internal:5432/trust" + + +def _prod_config(**overrides): + base = { + "environment": "production", + "trust_mode_enabled": True, + "signing_private_key_b64": VALID_PROD_SIGNING_KEY, + "allow_legacy_unpermitted_mcp": False, + "enable_proof_surfaces": False, + "database_url": VALID_PROD_DATABASE_URL, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# credits: money storage contract +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("amount", "expected"), + [ + (Decimal("0"), True), + (Decimal("1"), True), + (Decimal("1.12345678"), True), + (Decimal("0.00000001"), True), + (Decimal("999999999999.99999999"), False), # float roundtrip loses it + (Decimal("1000000000000"), False), # exclusive upper bound + (Decimal("-1"), False), + (Decimal("-0.00000001"), False), + (Decimal("1.123456789"), False), # ninth decimal never survives + (Decimal("0.000000001"), False), # rounds to zero on readback + (Decimal("NaN"), False), + (Decimal("Infinity"), False), + (Decimal("-Infinity"), False), + ], +) +def test_credit_amount_fits_storage_boundaries(amount, expected): + assert credit_amount_fits_storage(amount) is expected + + +def test_credit_amount_boundary_just_inside_limit(): + assert credit_amount_fits_storage(Decimal("999999999999")) is True + + +# --------------------------------------------------------------------------- +# db_urls +# --------------------------------------------------------------------------- + + +def test_sqlite_path_rejects_in_memory_spellings(): + assert sqlite_path_from_url("sqlite:///:memory:") == "" + assert sqlite_path_from_url("sqlite+aiosqlite:///:memory:") == "" + assert sqlite_path_from_url("sqlite:///file:" + ":memory:") == "" + assert sqlite_path_from_url("sqlite:///file:cache=shared?mode=memory") == "" + + +def test_sqlite_path_keeps_durable_lookalikes(): + # A substring test on "mode=memory" would misroute this durable file. + assert sqlite_path_from_url("sqlite:///./mode=memory.db") == "./mode=memory.db" + assert sqlite_path_from_url(" SQLITE:///./X.DB ") == "./X.DB" + assert sqlite_path_from_url("sqlite+aiosqlite:///./s.db") == "./s.db" + assert sqlite_path_from_url("sqlite:////abs/s.db") == "/abs/s.db" + + +def test_sqlite_url_detection_covers_memory(): + assert is_sqlite_url("sqlite+aiosqlite:///:memory:") is True + assert is_sqlite_url("SQLITE:///./x.db") is True + assert is_sqlite_url("postgresql://h/db") is False + assert is_sqlite_url("") is False + + +def test_converters_leave_credentials_untouched(): + upper = "POSTGRESQL://Us3R:PaSs@Host/DB" + assert as_asyncpg_url(upper) == "postgresql://Us3R:PaSs@Host/DB" + assert as_sqlalchemy_url(upper) == "postgresql+asyncpg://Us3R:PaSs@Host/DB" + assert is_postgres_url(" postgres://u:p@h/db ") is True + assert as_sqlalchemy_url("") == "" + assert as_asyncpg_url("") == "" + + +# --------------------------------------------------------------------------- +# time +# --------------------------------------------------------------------------- + + +def test_to_naive_utc_preserves_instant(): + aware = datetime( + 2026, 1, 2, 3, 4, 5, tzinfo=timezone(timedelta(hours=5, minutes=30)) + ) + assert to_naive_utc(aware) == datetime(2026, 1, 1, 21, 34, 5) + assert to_naive_utc(aware).tzinfo is None + + +def test_to_naive_utc_leaves_naive_untouched(): + naive = datetime(2026, 1, 1, 12, 0, 0) + assert to_naive_utc(naive) == naive + assert utc_now().tzinfo is None + + +# --------------------------------------------------------------------------- +# trust_mode +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "env", ["production", "PROD", "prod-us", "staging-1", "qa", " Stage ", "PRE_PROD"] +) +def test_production_like_classifier_true(env): + assert is_production_like_environment(env) is True + + +@pytest.mark.parametrize( + "env", ["", "local", "dev", "development", "test", "testing", "ci", "localhost"] +) +def test_production_like_classifier_false(env): + assert is_production_like_environment(env) is False + + +def test_normalize_environment_underscores_and_case(): + assert normalize_environment("PRE_PROD") == "pre-prod" + assert normalize_environment(None) == "" + + +def test_prod_guardrails_accept_minimal_strict_config(): + validate_trust_mode_config(**_prod_config()) + + +def test_prod_guardrails_reject_sqlite_database_url(): + with pytest.raises(TrustModeGuardrailError, match="must not be SQLite"): + validate_trust_mode_config( + **_prod_config(database_url="sqlite+aiosqlite:///./prod.db") + ) + with pytest.raises(TrustModeGuardrailError, match="must be set"): + validate_trust_mode_config(**_prod_config(database_url="")) + + +def test_prod_guardrails_reject_unsafe_sandbox_backend(): + with pytest.raises(TrustModeGuardrailError, match="BEHAVIORAL_SANDBOX"): + validate_trust_mode_config( + **_prod_config(behavioral_sandbox_python_backend="unsafe_host") + ) + with pytest.raises(TrustModeGuardrailError, match="ALLOW_UNSAFE_HOST"): + validate_trust_mode_config( + **_prod_config(allow_unsafe_host_python_sandbox=True) + ) + + +def test_hosted_runtime_requires_explicit_environment(): + with pytest.raises(TrustModeGuardrailError, match="ENVIRONMENT must be set"): + require_explicit_environment_on_hosted_runtime( + None, runtime_env={"RAILWAY_ENVIRONMENT_ID": "abc"} + ) + require_explicit_environment_on_hosted_runtime( + "dev", runtime_env={"RAILWAY_ENVIRONMENT_ID": "abc"} + ) + require_explicit_environment_on_hosted_runtime(None, runtime_env={}) + + +# --------------------------------------------------------------------------- +# sentinel_target +# --------------------------------------------------------------------------- + + +def test_sentinel_origin_accepts_public_https_and_strips_default_port(): + assert ( + normalize_sentinel_origin("https://approvals.example.com:443") + == "https://approvals.example.com" + ) + assert ( + sentinel_health_url("https://approvals.example.com") + == "https://approvals.example.com/health" + ) + + +@pytest.mark.parametrize( + "url", + [ + "http://approvals.example.com", # remote http is never allowed + "https://user:pass@approvals.example.com", # embedded credentials + "https://approvals.example.com/some/path", # must be a root origin + "https://approvals.example.com?x=1", + "https://127.0.0.1", # loopback without the local opt-in + "http://localhost:3000", + "https://169.254.169.254", # cloud metadata + "https://10.0.0.5", # RFC1918 + "https://metadata.google.internal", + "https://approvals.internal", + "ftp://approvals.example.com", + "https://2130706433", # legacy ambiguous IPv4 form + " https://approvals.example.com ", + "https://approvals.example.com\n", + ], +) +def test_sentinel_origin_rejects_unsafe_targets(url): + with pytest.raises(SentinelTargetError): + normalize_sentinel_origin(url) + + +def test_sentinel_origin_allows_loopback_only_when_opted_in(): + assert ( + normalize_sentinel_origin("http://127.0.0.1:3000", allow_loopback=True) + == "http://127.0.0.1:3000" + ) + with pytest.raises(SentinelTargetError): + normalize_sentinel_origin("http://127.0.0.1:3000", allow_loopback=False) + + +def test_sentinel_api_key_validation(): + assert sentinel_api_key_is_valid("live-key-123") is True + assert sentinel_api_key_is_valid("") is False + assert sentinel_api_key_is_valid(" padded ") is False + assert sentinel_api_key_is_valid("has space") is False + assert sentinel_api_key_is_valid("uni\x00code") is False + + +# --------------------------------------------------------------------------- +# public_contact +# --------------------------------------------------------------------------- + + +class _Contact: + def __init__(self, name="", email="", url=""): + self.PUBLIC_CONTACT_NAME = name + self.PUBLIC_CONTACT_EMAIL = email + self.PUBLIC_CONTACT_URL = url + + +def test_public_contact_none_when_unset(): + assert validated_public_contact(_Contact()) is None + + +def test_public_contact_partial_config_fails(): + with pytest.raises(ValueError, match="together"): + validated_public_contact(_Contact(name="Acme Ops")) + + +def test_public_contact_valid_triple(): + out = validated_public_contact( + _Contact( + name="Acme Operations", + email="ops@acme-corp.com", + url="https://acme-corp.com/contact", + ) + ) + assert out == { + "name": "Acme Operations", + "email": "ops@acme-corp.com", + "url": "https://acme-corp.com/contact", + } + + +@pytest.mark.parametrize( + "contact", + [ + _Contact(name="x", email="ops@acme-corp.com", url="https://acme-corp.com/c"), + _Contact( + name="Test API Team", + email="ops@acme-corp.com", + url="https://acme-corp.com/c", + ), + _Contact( + name="Acme Operations", + email="ops@example.com", + url="https://acme-corp.com/c", + ), + _Contact( + name="Acme Operations", + email="ops@b.test", + url="https://acme-corp.com/c", + ), + _Contact( + name="Acme Operations", + email="not-an-email", + url="https://acme-corp.com/c", + ), + _Contact( + name="Acme Operations", + email="ops@acme-corp.com", + url="http://acme-corp.com/c", + ), + _Contact( + name="Acme Operations", + email="ops@acme-corp.com", + url="https://api.thisisatest.tech/book", + ), + _Contact( + name="Agent Middleware API", + email="ops@acme-corp.com", + url="https://acme-corp.com/c", + ), + ], +) +def test_public_contact_rejects_placeholders(contact): + with pytest.raises(ValueError): + validated_public_contact(contact) + + +# --------------------------------------------------------------------------- +# product_positioning +# --------------------------------------------------------------------------- + + +def test_positioning_returns_fresh_copy(): + first = get_product_positioning() + assert first["semantics"] == list(POSITIONING_SEMANTICS) + first["semantics"].append("mutant") + first["canonical_loop"].clear() + second = get_product_positioning() + assert "mutant" not in second["semantics"] + assert len(second["canonical_loop"]) > 0 + + +# --------------------------------------------------------------------------- +# runtime_mode and runtime_degradation +# --------------------------------------------------------------------------- + + +def test_runtime_mode_unknown_service_raises(): + with pytest.raises(UnknownServiceError): + is_simulation("nope") + with pytest.raises(UnknownServiceError): + require_simulation("nope") + + +def test_runtime_mode_snapshot_covers_every_service(monkeypatch): + settings = get_settings() + monkeypatch.setattr(settings, "SIMULATION_MODE_ORACLE", False) + assert is_simulation("oracle") is False + with pytest.raises(NotImplementedError): + require_simulation("oracle") + modes = get_simulation_modes() + assert set(modes) >= {"oracle", "human_approval"} + assert modes["oracle"] is False + + +def test_runtime_degradation_flags_and_reset(): + reset_runtime_degradation() + try: + assert get_runtime_degradation()["degraded"] is False + mark_rate_limiter_memory_fallback() + degraded = get_runtime_degradation() + assert degraded["degraded"] is True + assert degraded["rate_limiter"]["using_memory_fallback"] is True + mark_durable_state_fell_back("redis") + assert get_runtime_degradation()["durable_state"]["intended_backend"] == "redis" + finally: + reset_runtime_degradation() + assert get_runtime_degradation()["degraded"] is False + + +# --------------------------------------------------------------------------- +# blob backend selection honesty (fix: no silent "fallback to local") +# --------------------------------------------------------------------------- + + +def test_unimplemented_backends_fail_loudly_not_local(monkeypatch, caplog): + from app.core.blob import _UnimplementedBlob, get_blob_backend + + get_blob_backend.cache_clear() + try: + for name in ("s3", "vercel"): + monkeypatch.setenv("BLOB_BACKEND", name) + get_blob_backend.cache_clear() + with caplog.at_level(logging.WARNING): + backend = get_blob_backend() + assert isinstance(backend, _UnimplementedBlob) + with pytest.raises(NotImplementedError): + import anyio + + anyio.run(backend.put, "k", b"v") + assert "falling back to local" not in caplog.text + assert "fail loudly" in caplog.text + finally: + get_blob_backend.cache_clear() + + +# --------------------------------------------------------------------------- +# auth: bearer strictness, static-dev prefix, wallet isolation +# --------------------------------------------------------------------------- + +from fastapi import HTTPException # noqa: E402 + +from app.core.auth import ( # noqa: E402 + AuthContext, + _parse_bearer_authorization, + _parse_static_dev_keys, + _resolve_auth_context, +) + + +def test_parse_bearer_accepts_exact_shape_only(): + scheme = "Bearer" + " " + good = scheme + "abc" + "." + "def" + "." + "ghi" + assert _parse_bearer_authorization(good) == "abc.def.ghi" + for bad in [ + "Token abc", + "bearer" + " " + "abc", + "Bearer" + " ", + "Bearer", + "Bearer" + " " + "abc", + "Bearer" + " " + "abc" + " ", + "Bearer" + " " + "a" + " " + "b", + "Bearer\tabc", + ]: + with pytest.raises(HTTPException) as exc_info: + _parse_bearer_authorization(bad) + assert exc_info.value.status_code == 401 + + +def test_parse_static_dev_keys_honors_prefix_only(): + assert _parse_static_dev_keys("amw_dev_a, amw_live_b, , amw_dev_c ") == [ + "amw_dev_a", + "amw_dev_c", + ] + assert _parse_static_dev_keys("") == [] + assert _parse_static_dev_keys("amw_live_only") == [] + + +def test_require_wallet_access_matrix(): + admin = AuthContext(source="env", raw_key="k", is_bootstrap_admin=True) + admin.require_wallet_access("other-wallet") + admin.require_wallet_access(None) + + owner = AuthContext(source="db", raw_key="k", wallet_id="w1") + owner.require_wallet_access("w1") + + with pytest.raises(HTTPException) as exc_info: + owner.require_wallet_access("w2") + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["error"] == "wallet_access_denied" + + # Ownerless resources stay admin-only. + with pytest.raises(HTTPException): + owner.require_wallet_access(None) + + keyless = AuthContext(source="db", raw_key="k", wallet_id=None) + with pytest.raises(HTTPException): + keyless.require_wallet_access(None) + + +def test_require_bootstrap_admin(): + AuthContext( + source="env", raw_key="k", is_bootstrap_admin=True + ).require_bootstrap_admin() + with pytest.raises(HTTPException) as exc_info: + AuthContext(source="db", raw_key="k", wallet_id="w1").require_bootstrap_admin() + assert exc_info.value.status_code == 403 + + +@pytest.mark.anyio +async def test_resolve_missing_and_short_keys_401(monkeypatch): + settings = get_settings() + monkeypatch.setattr(settings, "ENABLE_DEV_KEY_SELF_PROVISION", False) + with pytest.raises(HTTPException) as exc_info: + await _resolve_auth_context(None, None) + assert exc_info.value.status_code == 401 + assert exc_info.value.detail["error"] == "missing_credentials" + with pytest.raises(HTTPException) as exc_info: + await _resolve_auth_context(" short ", None) + assert exc_info.value.status_code == 401 + + +@pytest.mark.anyio +async def test_resolve_unknown_key_403_with_reject_marker(monkeypatch): + from app.core.auth import CREDENTIAL_REJECTED_HEADER + + settings = get_settings() + monkeypatch.setattr(settings, "VALID_API_KEYS", "test-key") + monkeypatch.setattr(settings, "STATIC_DEV_API_KEYS", "") + monkeypatch.setattr(settings, "DEBUG", False) + with pytest.raises(HTTPException) as exc_info: + await _resolve_auth_context("unknown-key-123", None) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["error"] == "invalid_api_key" + assert exc_info.value.headers[CREDENTIAL_REJECTED_HEADER] == "1" + + +@pytest.mark.anyio +async def test_resolve_static_dev_key_needs_prefix_and_local_env(monkeypatch): + settings = get_settings() + monkeypatch.setattr(settings, "VALID_API_KEYS", "") + monkeypatch.setattr(settings, "STATIC_DEV_API_KEYS", "amw_dev_good,amw_live_leaked") + monkeypatch.setattr(settings, "ENVIRONMENT", "local") + monkeypatch.setattr(settings, "DEBUG", False) + ctx = await _resolve_auth_context("amw_dev_good", None) + assert ctx.source == "static-dev" + assert ctx.is_bootstrap_admin is True + # A live key pasted into the dev variable never authenticates that path. + with pytest.raises(HTTPException) as exc_info: + await _resolve_auth_context("amw_live_leaked", None) + assert exc_info.value.status_code == 403 + + +@pytest.mark.anyio +async def test_resolve_static_dev_keys_dead_in_production(monkeypatch): + settings = get_settings() + monkeypatch.setattr(settings, "VALID_API_KEYS", "") + monkeypatch.setattr(settings, "STATIC_DEV_API_KEYS", "amw_dev_good") + monkeypatch.setattr(settings, "ENVIRONMENT", "production") + monkeypatch.setattr(settings, "DEBUG", False) + with pytest.raises(HTTPException): + await _resolve_auth_context("amw_dev_good", None) + + +@pytest.mark.anyio +async def test_enterprise_bearer_rides_alongside_api_key(monkeypatch): + import jwt as pyjwt + + settings = get_settings() + monkeypatch.setattr(settings, "VALID_API_KEYS", "test-key") + monkeypatch.setattr(settings, "DEBUG", False) + monkeypatch.setattr( + settings, + "IGA_TRUSTED_ISSUERS", + '{"https://example.okta.com": {"audience": "api://x", ' + '"algorithms": ["RS256"], "provider": "okta", "jwks": {"keys": []}}}', + ) + monkeypatch.setattr(settings, "STATIC_DEV_API_KEYS", "") + token = pyjwt.encode( + {"iss": "https://example.okta.com", "sub": "human"}, + "routing-peek-only-dummy-secret-32bytes", + algorithm="HS256", + ) + ctx = await _resolve_auth_context("test-key", f"Bearer {token}") + assert ctx.source == "env" + assert ctx.is_bootstrap_admin is True + assert ctx.enterprise_bearer_token == token + + +@pytest.mark.anyio +async def test_jwt_without_live_key_fails_closed(monkeypatch): + from app.core.jwt import get_jwt_service + + settings = get_settings() + monkeypatch.setattr( + settings, + "TRUST_SIGNING_PRIVATE_KEY_B64", + "dGVzdC1zaWduaW5nLWtleS1tYXRlcmlhbC0zMmJ5dGU=", + ) + monkeypatch.setattr(settings, "VALID_API_KEYS", "") + monkeypatch.setattr(settings, "STATIC_DEV_API_KEYS", "") + monkeypatch.setattr(settings, "DEBUG", False) + token = get_jwt_service().create_access_token("w1", "key-1", ["mcp:invoke"]) + with pytest.raises(HTTPException) as exc_info: + await _resolve_auth_context(token, None) + assert exc_info.value.status_code == 401 + assert exc_info.value.detail["error"] == "no_active_api_key" + + +# --------------------------------------------------------------------------- +# scopes +# --------------------------------------------------------------------------- + +from app.core.scopes import require_scope # noqa: E402 + + +def _ctx(source, scopes=None): + return AuthContext(source=source, raw_key="k", wallet_id="w1", scopes=scopes) + + +@pytest.mark.anyio +async def test_require_scope_bypass_and_jwt_paths(): + @require_scope("billing:charge") + async def handler(auth: AuthContext): + return "ok" + + for source in ("db", "env", "static-dev"): + assert await handler(auth=_ctx(source, scopes=[])) == "ok" + assert await handler(auth=_ctx("jwt", scopes=["billing:charge"])) == "ok" + with pytest.raises(HTTPException) as exc_info: + await handler(auth=_ctx("jwt", scopes=["other"])) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["error"] == "insufficient_scope" + assert exc_info.value.detail["provided"] == ["other"] + with pytest.raises(HTTPException) as exc_info: + await handler() + assert exc_info.value.status_code == 401 + + +def test_auth_context_omitted_scopes_normalize_to_empty_list(): + omitted = AuthContext(source="env", raw_key="k") + explicit_none = AuthContext(source="jwt", raw_key="k", scopes=None) + assert omitted.scopes == [] + assert explicit_none.scopes == [] + + +@pytest.mark.anyio +async def test_require_scope_treats_unset_scopes_as_empty(): + """A context that skipped constructor None-coercion must not raise TypeError.""" + + @require_scope("billing:charge") + async def handler(auth: AuthContext): + return "ok" + + auth = AuthContext( + source="jwt", raw_key="k", wallet_id="w1", scopes=["billing:charge"] + ) + object.__setattr__(auth, "scopes", None) + with pytest.raises(HTTPException) as exc_info: + await handler(auth=auth) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["error"] == "insufficient_scope" + assert exc_info.value.detail["provided"] == [] + + +# --------------------------------------------------------------------------- +# jwt service +# --------------------------------------------------------------------------- + +JWT_DUMMY_KEY = "dGVzdC1zaWduaW5nLWtleS1tYXRlcmlhbC0zMmJ5dGU=" + + +@pytest.fixture +def jwt_keyed(monkeypatch): + monkeypatch.setattr(get_settings(), "TRUST_SIGNING_PRIVATE_KEY_B64", JWT_DUMMY_KEY) + from app.core.jwt import get_jwt_service + + return get_jwt_service() + + +def _mint_raw(payload): + import jwt as pyjwt + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + private = Ed25519PrivateKey.from_private_bytes(base64.b64decode(JWT_DUMMY_KEY)) + return pyjwt.encode(payload, private, algorithm="EdDSA") + + +def _access_payload(**overrides): + now = datetime.now(timezone.utc) + base = { + "sub": "w1", + "key_id": "key-1", + "scopes": ["mcp:invoke"], + "iat": now, + "exp": now + timedelta(minutes=15), + "iss": "agent-middleware-api", + "aud": "agent-middleware-api", + "jti": "jti-1", + "type": "access", + } + base.update(overrides) + return base + + +def test_jwt_access_roundtrip(jwt_keyed): + token = jwt_keyed.create_access_token("w1", "key-1", ["mcp:invoke"]) + payload = jwt_keyed.verify_access_token(token) + assert (payload.sub, payload.key_id, payload.scopes) == ( + "w1", + "key-1", + ["mcp:invoke"], + ) + + +def test_jwt_rejects_wrong_type_and_bad_scopes(jwt_keyed): + refresh = jwt_keyed.create_refresh_token("w1") + with pytest.raises(Exception, match="token_type_mismatch"): + jwt_keyed.verify_access_token(refresh) + with pytest.raises(Exception, match="invalid_or_missing_scopes"): + jwt_keyed.verify_access_token(_mint_raw(_access_payload(scopes="admin"))) + with pytest.raises(Exception, match="token_expired"): + jwt_keyed.verify_access_token( + _mint_raw( + _access_payload(exp=datetime.now(timezone.utc) - timedelta(seconds=5)) + ) + ) + + +def test_jwt_requires_signing_key(monkeypatch): + from app.core.jwt import JWTError, get_jwt_service + + monkeypatch.setattr(get_settings(), "TRUST_SIGNING_PRIVATE_KEY_B64", "") + with pytest.raises(JWTError, match="signing_key_not_configured"): + get_jwt_service().create_access_token("w1", "key-1", []) + + +# --------------------------------------------------------------------------- +# oidc_iga fail-closed config and routing +# --------------------------------------------------------------------------- + +from app.core.oidc_iga import ( # noqa: E402 + EnterprisePrincipal, + IGAError, + _infer_provider, + _trusted_issuers, + enforce_tool_call, + is_iga_issuer_token, + parse_enterprise_token, + release_tool_use, + reset_iga_counters, + resolve_policy_grants, + token_issuer_is_trusted, +) + + +def _iga_config(monkeypatch, issuers=True, mapping="{}"): + settings = get_settings() + if issuers: + monkeypatch.setattr( + settings, + "IGA_TRUSTED_ISSUERS", + '{"https://example.okta.com": {"audience": "api://x", ' + '"algorithms": ["RS256"], "provider": "okta", ' + '"jwks": {"keys": []}}}', + ) + else: + monkeypatch.setattr(settings, "IGA_TRUSTED_ISSUERS", "") + monkeypatch.setattr(settings, "IGA_GROUP_POLICY_MAP", mapping) + reset_iga_counters() + + +@pytest.mark.parametrize( + "raw", + [ + "not-json", + "[1, 2]", + '{"https://x": {"audience": "a", "algorithms": ["none"], ' + '"provider": "okta", "jwks": {"keys": []}}}', + '{"https://x": {"audience": "a", "algorithms": ["RS256"], "provider": "okta"}}', + '{"https://x": {"audience": "a", "algorithms": ["RS256"], ' + '"provider": "okta", "jwks": {"keys": []}, "public_key_pem": "k"}}', + '{"https://x": {"audience": "", "algorithms": ["RS256"], ' + '"provider": "okta", "jwks": {"keys": []}}}', + '{"https://x": {"audience": "a", "algorithms": ["RS256"], ' + '"provider": "saml", "jwks": {"keys": []}}}', + ], +) +def test_iga_issuer_config_fails_closed(monkeypatch, raw): + monkeypatch.setattr(get_settings(), "IGA_TRUSTED_ISSUERS", raw) + with pytest.raises(IGAError) as exc_info: + _trusted_issuers() + # The stable snake_case reason is the contract; str(exc) is the human + # message and never carries it. + assert exc_info.value.reason == "iga_config_invalid" + + +@pytest.mark.parametrize( + "mapping", + [ + '{"g": {"policy_id": "", "max_uses": 1}}', + '{"g": {"policy_id": "p", "max_uses": true}}', + '{"g": {"policy_id": "p", "velocity_window_seconds": 60}}', + '{"g": {"policy_id": "p", "velocity_max_calls": 5}}', + "not-json", + ], +) +def test_iga_group_map_fails_closed(monkeypatch, mapping): + _iga_config(monkeypatch, mapping=mapping) + principal = EnterprisePrincipal( + subject="u", provider="okta", issuer="https://example.okta.com", groups=("g",) + ) + with pytest.raises(IGAError) as exc_info: + resolve_policy_grants(principal) + assert exc_info.value.reason == "iga_config_invalid" + + +def test_iga_provider_inference(): + assert _infer_provider("https://acme.okta.com/oauth2/default") == "okta" + assert _infer_provider("https://login.microsoftonline.com/tenant/v2.0") == "entra" + assert _infer_provider("https://idp.acme-corp.com/realms/x") is None + + +def test_iga_routing_peek_never_authenticates(monkeypatch): + _iga_config(monkeypatch, issuers=False) + assert token_issuer_is_trusted("garbage") is False + assert is_iga_issuer_token("garbage") is False + _iga_config(monkeypatch) + assert token_issuer_is_trusted("garbage") is False + assert is_iga_issuer_token("garbage") is False + with pytest.raises(IGAError): + parse_enterprise_token("garbage") + import jwt as pyjwt + + foreign = pyjwt.encode( + {"iss": "https://unknown.example.com", "sub": "u"}, + "routing-peek-only-dummy-secret-32bytes", + algorithm="HS256", + ) + assert token_issuer_is_trusted(foreign) is False + assert is_iga_issuer_token(foreign) is False + with pytest.raises(IGAError) as exc_info: + parse_enterprise_token(foreign) + assert exc_info.value.reason == "iga_issuer_not_trusted" + + +@pytest.mark.anyio +async def test_iga_blocks_principal_without_matching_role(monkeypatch): + _iga_config(monkeypatch) + principal = EnterprisePrincipal( + subject="u", + provider="okta", + issuer="https://example.okta.com", + groups=("other",), + ) + decision = await enforce_tool_call(principal, "partner.echo") + assert decision.allowed is False + assert decision.reason == "iga_no_matching_role" + await release_tool_use( + principal, "partner.echo", group="g", policy_id="p", reservation=None + ) + + +# --- rate_limiter: bucket hygiene, marker stripping, discovery --- + +from app.core.rate_limiter import ( # noqa: E402 + _api_key_bucket, + _client_id, + _credentials_were_rejected, + rate_limit_discovery, +) + + +def test_api_key_buckets_are_digests_not_plaintext(): + bucket = _api_key_bucket("live-secret-key") + assert "live-secret-key" not in bucket + assert _api_key_bucket("live-secret-key") == bucket + assert _api_key_bucket("other-key") != bucket + assert _api_key_bucket(None) == "anonymous" + assert _api_key_bucket("key") == _api_key_bucket("key".strip()) + + +def test_credentials_rejected_strips_marker_and_detects_401(): + from fastapi import Response + + from app.core.auth import CREDENTIAL_REJECTED_HEADER + + marked = Response("x", status_code=403, headers={CREDENTIAL_REJECTED_HEADER: "1"}) + assert _credentials_were_rejected(marked) is True + assert CREDENTIAL_REJECTED_HEADER not in marked.headers + assert _credentials_were_rejected(Response("x", status_code=401)) is True + assert _credentials_were_rejected(Response("x", status_code=200)) is False + + +def test_client_id_uses_peer_without_railway_marker(monkeypatch): + from fastapi import Request + + monkeypatch.delenv("RAILWAY_ENVIRONMENT_ID", raising=False) + req = Request( + { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "client": ("1.2.3.4", 5000), + } + ) + assert _client_id(req) == "1.2.3.4" + + +def test_rate_limit_discovery_shape(): + disc = rate_limit_discovery() + assert disc["requests_per_minute"] == get_settings().RATE_LIMIT_PER_MINUTE + assert disc["window_seconds"] == 60 + assert disc["burst_allowance"] == 0 + assert disc["per_partner_override"] is False + + +# --- health: sanitized errors, public projection, not_probed honesty --- + +from app.core.health import ( # noqa: E402 + _OK_STATUSES, + _check_llm, + _run_check, + build_public_dependency_report, +) + + +@pytest.mark.anyio +async def test_run_check_sanitizes_errors_and_times(): + async def boom(): + raise RuntimeError("secret-host-marker-123") + + result = await _run_check("x", boom) + assert result["status"] == "down" + assert result["error"] == "RuntimeError" + assert "secret-host-marker-123" not in str(result) + assert result["latency_ms"] >= 0 + + +@pytest.mark.anyio +async def test_unknown_llm_provider_is_not_probed_and_not_ok(monkeypatch): + settings = get_settings() + monkeypatch.setattr(settings, "LLM_PROVIDER", "anthropic") + monkeypatch.setattr(settings, "LLM_API_KEY", "k") + monkeypatch.setattr(settings, "SIMULATION_MODE_TELEMETRY_PM", False) + result = await _check_llm({"telemetry_pm": False}) + assert result["status"] == "not_probed" + assert "not_probed" not in _OK_STATUSES # pins: degrades overall health + + +def _full_report(**overrides): + report = { + "status": "healthy", + "version": "1.3.0", + "commit_sha": None, + "build_provenance": "unstamped", + "environment": "local", + "production_like": False, + "dependencies": { + "postgres": {"status": "up"}, + "redis": {"status": "up"}, + "signing_key": {"status": "up"}, + "upstream_mcp": {"status": "up"}, + "llm": {"status": "down", "error": "X"}, + }, + "enable_proof_surfaces": False, + "runtime_degradation": {"degraded": False}, + "metric_scopes": {}, + "unhealthy": ["llm"], + } + report.update(overrides) + return report + + +def test_public_projection_hides_non_wedge_deps(): + public = build_public_dependency_report(_full_report()) + assert set(public["dependencies"]) == { + "postgres", + "redis", + "signing_key", + "upstream_mcp", + } + assert public["unhealthy"] == [] + assert public["status"] == "healthy" + + +def test_public_projection_keeps_wedge_failures(): + report = _full_report( + dependencies={ + "postgres": {"status": "up"}, + "redis": {"status": "up"}, + "signing_key": {"status": "down", "error": "X"}, + "upstream_mcp": {"status": "up"}, + } + ) + public = build_public_dependency_report(report) + assert public["unhealthy"] == ["signing_key"] + assert public["status"] == "degraded" + degraded = build_public_dependency_report( + _full_report(runtime_degradation={"degraded": True}) + ) + assert degraded["status"] == "degraded" + assert "runtime_degradation" in degraded["unhealthy"] + + +# --- durable_state: backend resolution, sqlite form fix, roundtrip --- + +from app.core.durable_state import ( # noqa: E402 + DurableStateConfigError, + DurableStateStore, + _json_default, +) + + +def _durable(monkeypatch, **attrs): + settings = get_settings() + base = { + "STATE_BACKEND": "auto", + "STATE_NAMESPACE": "qa", + "DATABASE_URL": "", + "REDIS_URL": "", + "SQLITE_URL": "", + "ENVIRONMENT": "test", + } + base.update(attrs) + for key, value in base.items(): + monkeypatch.setattr(settings, key, value) + return DurableStateStore() + + +def test_durable_explicit_backend_missing_fails_closed_in_prod(monkeypatch): + with pytest.raises(DurableStateConfigError): + _durable( + monkeypatch, STATE_BACKEND="postgres", ENVIRONMENT="production" + )._resolve_backend() + with pytest.raises(DurableStateConfigError): + _durable( + monkeypatch, STATE_BACKEND="sqlite", ENVIRONMENT="production" + )._resolve_backend() + with pytest.raises(DurableStateConfigError): + _durable(monkeypatch, ENVIRONMENT="production")._resolve_backend() + + +def test_durable_explicit_backend_missing_falls_back_in_dev(monkeypatch): + from app.core.runtime_degradation import ( + get_runtime_degradation as snap, + reset_runtime_degradation as reset, + ) + + reset() + try: + store = _durable(monkeypatch, STATE_BACKEND="postgres") + assert store._resolve_backend() == "memory" + assert snap()["durable_state"]["fell_back_to_memory"] is True + finally: + reset() + + +def test_durable_auto_resolution_matrix(monkeypatch): + assert ( + _durable( + monkeypatch, DATABASE_URL="sqlite+aiosqlite:///./x.db" + )._resolve_backend() + == "sqlite" + ) + assert ( + _durable(monkeypatch, REDIS_URL="redis://localhost:6379")._resolve_backend() + == "redis" + ) + assert _durable(monkeypatch)._resolve_backend() == "memory" + + +def test_durable_sqlite_url_accepts_url_and_path_forms(monkeypatch, tmp_path): + as_url = f"sqlite+aiosqlite:///{tmp_path}/s.db" + assert _durable(monkeypatch, SQLITE_URL=as_url)._sqlite_url == f"{tmp_path}/s.db" + raw = str(tmp_path / "r.db") + assert _durable(monkeypatch, SQLITE_URL=raw)._sqlite_url == raw + assert _durable(monkeypatch, SQLITE_URL=":memory:")._sqlite_url == ":memory:" + assert _durable(monkeypatch, SQLITE_URL="sqlite:///:memory:")._sqlite_url == "" + + +@pytest.mark.anyio +async def test_durable_sqlite_roundtrip_and_memory_noop(monkeypatch, tmp_path): + store = _durable( + monkeypatch, STATE_BACKEND="sqlite", SQLITE_URL=str(tmp_path / "s.db") + ) + try: + assert await store.save_json("a:1", {"n": 1}) is True + assert await store.load_json("a:1") == {"n": 1} + assert await store.list_keys("a:") == ["a:1"] + assert await store.delete("a:1") is True + assert await store.load_json("a:1") is None + assert (await store.health_report())["backend"] == "sqlite" + finally: + await store.close() + + mem = _durable(monkeypatch) + try: + assert await mem.save_json("k", {}) is False + assert await mem.load_json("k") is None + assert await mem.list_keys() == [] + assert await mem.delete("k") is False + finally: + await mem.close() + + +def test_durable_json_default_handles_types(): + assert _json_default(Decimal("1.5")) == 1.5 + assert _json_default(datetime(2026, 1, 1, 12)) == "2026-01-01T12:00:00" + assert sorted(_json_default({"b", "a"})) == ["a", "b"] + with pytest.raises(TypeError): + _json_default(object()) + + +# --- database: create_all gate, bind normalization, schema verify --- + +from app.db.database import ( # noqa: E402 + SchemaInitError, + _normalize_bind_parameters, + allows_metadata_create_all, + verify_required_schema, +) + + +def test_allows_create_all_only_for_ephemeral_sqlite(monkeypatch): + monkeypatch.delenv("ALLOW_METADATA_CREATE_ALL", raising=False) + assert allows_metadata_create_all(dialect_name="sqlite", environment="test") is True + assert ( + allows_metadata_create_all(dialect_name="sqlite", environment="prod") is False + ) + assert ( + allows_metadata_create_all(dialect_name="postgresql", environment="test") + is False + ) + monkeypatch.setenv("ALLOW_METADATA_CREATE_ALL", "true") + assert allows_metadata_create_all(dialect_name="sqlite", environment="prod") is True + assert ( + allows_metadata_create_all(dialect_name="postgresql", environment="prod") + is False + ) + + +def test_normalize_bind_parameters_coerces_aware(): + aware = datetime(2026, 1, 2, 3, tzinfo=timezone.utc) + assert _normalize_bind_parameters({"ts": aware}) == {"ts": datetime(2026, 1, 2, 3)} + naive = datetime(2026, 1, 2, 3) + params = {"ts": naive, "n": 1} + assert _normalize_bind_parameters(params) is params + assert _normalize_bind_parameters([aware])[0] == naive + assert _normalize_bind_parameters((aware,))[0] == naive + assert _normalize_bind_parameters("plain") == "plain" + + +@pytest.mark.anyio +async def test_verify_required_schema_gate(): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel import SQLModel + + import app.db.models # noqa: F401 (register tables) + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + try: + with pytest.raises(SchemaInitError, match="Required database tables missing"): + await verify_required_schema(engine) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + await verify_required_schema(engine) + finally: + await engine.dispose() + + +# --- converters: money precision, lenient metadata, strict enums --- + +from app.db.converters import ( # noqa: E402 + ledger_entry_model_to_schema, + metadata_dict_to_json, + parse_metadata_json, + telemetry_event_model_to_schema, + telemetry_event_to_model, + wallet_model_to_response, +) +from app.db.models import LedgerEntryModel, WalletModel # noqa: E402 + + +def test_wallet_converter_keeps_exact_decimals(): + row = WalletModel( + wallet_id="w1", + wallet_type="sponsor", + balance=Decimal("10.5"), + lifetime_credits=Decimal("100"), + lifetime_debits=Decimal("89.5"), + ) + out = wallet_model_to_response(row) + assert out.balance == 10.5 + assert out.balance_exact == "10.5" + assert out.lifetime_debits_exact == "89.5" + + +def test_wallet_converter_lenient_metadata_and_kyc_but_strict_type(): + row = WalletModel( + wallet_id="w1", + wallet_type="sponsor", + metadata_json="{broken", + kyc_status="mystery", + ) + out = wallet_model_to_response(row) + assert out.metadata == {} + # Display-only leniency: enforcement reads the model row, not this schema. + assert out.kyc_status.value == "not_required" + with pytest.raises(ValueError): + wallet_model_to_response(WalletModel(wallet_id="w1", wallet_type="bogus")) + + +def test_ledger_converter_preserves_eight_places(): + row = LedgerEntryModel( + entry_id="e1", + wallet_id="w1", + action="credit", + amount=Decimal("1.12345678"), + balance_after=Decimal("11.12345678"), + ) + out = ledger_entry_model_to_schema(row) + assert out.amount_exact == "1.12345678" + assert out.balance_after_exact == "11.12345678" + with pytest.raises(ValueError): + ledger_entry_model_to_schema( + LedgerEntryModel( + entry_id="e2", + wallet_id="w1", + action="bogus", + amount=Decimal("1"), + balance_after=Decimal("1"), + ) + ) + + +def test_metadata_helpers_roundtrip(): + assert metadata_dict_to_json({}) is None + assert metadata_dict_to_json(None) is None + assert parse_metadata_json(metadata_dict_to_json({"a": 1})) == {"a": 1} + assert parse_metadata_json("{broken") == {} + assert parse_metadata_json(None) == {} + + +def test_telemetry_converter_normalizes_aware_timestamps(): + from app.schemas.telemetry import TelemetryEvent, TelemetryEventType + + aware = datetime(2026, 1, 2, 3, tzinfo=timezone.utc) + event = TelemetryEvent( + event_type=TelemetryEventType.ERROR, + source="s", + message="m", + metadata={"k": 1}, + timestamp=aware, + ) + row = telemetry_event_to_model("ev1", "b1", event, aware) + assert row.event_timestamp == datetime(2026, 1, 2, 3) + back = telemetry_event_model_to_schema(row) + assert back.timestamp == datetime(2026, 1, 2, 3) + assert back.metadata == {"k": 1} + + +# --- resilience: retry, breaker, write-conflict restart --- + +from sqlalchemy.exc import OperationalError # noqa: E402 + +from app.core.resilience import ( # noqa: E402 + CircuitBreaker, + CircuitBreakerOpen, + is_retryable_write_conflict, + retry_with_backoff, + run_with_write_conflict_retry, +) + + +def _conflict(message="database is locked"): + return OperationalError("SELECT 1", {}, Exception(message)) + + +@pytest.mark.anyio +async def test_retry_succeeds_after_failures_then_exhausts(): + calls = 0 + + @retry_with_backoff(max_attempts=3, base_delay=0) + async def flaky(): + nonlocal calls + calls += 1 + if calls < 3: + raise RuntimeError("boom") + return "ok" + + assert await flaky() == "ok" + + @retry_with_backoff(max_attempts=2, base_delay=0) + async def down(): + raise RuntimeError("still down") + + with pytest.raises(RuntimeError, match="still down"): + await down() + + +def test_retry_guards_reject_zero_attempts(): + with pytest.raises(ValueError): + retry_with_backoff(max_attempts=0) + + +@pytest.mark.anyio +async def test_write_conflict_retry_restarts_and_maps_exhaustion(): + attempts = 0 + + async def op(): + nonlocal attempts + attempts += 1 + if attempts < 3: + raise _conflict() + return "done" + + assert await run_with_write_conflict_retry(op, on_exhausted=lambda e: e) == "done" + + async def stuck(): + raise _conflict() + + with pytest.raises(RuntimeError, match="busy"): + await run_with_write_conflict_retry( + stuck, on_exhausted=lambda e: RuntimeError("busy"), max_attempts=2 + ) + + +@pytest.mark.anyio +async def test_write_conflict_retry_passes_through_real_errors(): + async def broken(): + raise _conflict("no such table: wallets") + + with pytest.raises(OperationalError): + await run_with_write_conflict_retry(broken, on_exhausted=lambda e: e) + + seen = 0 + + async def stale_then_ok(): + nonlocal seen + seen += 1 + if seen == 1: + raise ValueError("stale row, restart me") + return "ok" + + assert ( + await run_with_write_conflict_retry( + stale_then_ok, + on_exhausted=lambda e: e, + restart_on=lambda e: isinstance(e, ValueError) and "stale" in str(e), + ) + == "ok" + ) + + +@pytest.mark.anyio +async def test_write_conflict_guard_rejects_zero_attempts(): + async def op(): + return 1 + + with pytest.raises(ValueError): + await run_with_write_conflict_retry( + op, on_exhausted=lambda e: e, max_attempts=0 + ) + + +def test_write_conflict_classifier(): + assert is_retryable_write_conflict(_conflict("database is locked")) is True + assert is_retryable_write_conflict(_conflict("snapshot conflict")) is True + assert is_retryable_write_conflict(_conflict("no such table")) is False + + +def test_circuit_breaker_opens_and_recovers(): + breaker = CircuitBreaker( + failure_threshold=2, recovery_timeout=30, half_open_max_calls=1 + ) + breaker.record_failure() + assert breaker.is_allowed() is True + breaker.record_failure() + assert breaker.state == "open" + assert breaker.is_allowed() is False + with pytest.raises(CircuitBreakerOpen): + import anyio + + anyio.run(breaker.call, _sync_boom) + + recovering = CircuitBreaker( + failure_threshold=1, recovery_timeout=0, half_open_max_calls=1 + ) + recovering.record_failure() + # recovery_timeout=0 expires immediately, so the next read is half_open. + assert recovering.state == "half_open" + assert recovering.is_allowed() is True + recovering.record_success() + assert recovering.state == "closed" + + +def _sync_boom(): + raise RuntimeError("nope") + + +# --- sql_expressions and db types --- + + +def test_clamped_decrement_never_goes_negative(): + from sqlalchemy import Column, MetaData, Numeric, Table, create_engine, select + + from app.db.sql_expressions import clamped_decrement + + engine = create_engine("sqlite:///:memory:") + meta = MetaData() + table = Table( + "t", meta, Column("id", Numeric, primary_key=True), Column("balance", Numeric) + ) + meta.create_all(engine) + with engine.begin() as conn: + conn.execute(table.insert().values(id=1, balance=Decimal("5"))) + conn.execute( + table.update().values( + balance=clamped_decrement(table.c.balance, Decimal("3")) + ) + ) + assert conn.execute(select(table.c.balance)).scalar() == Decimal("2") + conn.execute( + table.update().values( + balance=clamped_decrement(table.c.balance, Decimal("10")) + ) + ) + assert conn.execute(select(table.c.balance)).scalar() == Decimal("0") + + +def test_naive_utc_type_coerces_binds(): + from app.db.types import NaiveUTCDateTime + + aware = datetime(2026, 1, 2, 3, tzinfo=timezone.utc) + assert NaiveUTCDateTime().process_bind_param(aware, None) == datetime(2026, 1, 2, 3) + assert NaiveUTCDateTime().process_bind_param(None, None) is None + + +# --- dependencies: singletons must read current settings (fix) --- + + +def test_singletons_read_current_settings(monkeypatch): + from datetime import timedelta as _td + + from app.core import dependencies as deps + + monkeypatch.setenv("MQTT_BROKER_URL", "mqtt://custom:1883") + monkeypatch.setenv("TELEMETRY_RETENTION_HOURS", "99") + get_settings.cache_clear() + deps.get_iot_bridge.cache_clear() + deps.get_autonomous_pm.cache_clear() + try: + assert deps.get_iot_bridge().mqtt.broker_url == "mqtt://custom:1883" + assert deps.get_autonomous_pm().event_store._retention == _td(hours=99) + finally: + deps.get_iot_bridge.cache_clear() + deps.get_autonomous_pm.cache_clear() + get_settings.cache_clear() diff --git a/gateway/tests/test_cors_security.py b/gateway/tests/test_cors_security.py index 7e7ea7c4..cbcbf057 100644 --- a/gateway/tests/test_cors_security.py +++ b/gateway/tests/test_cors_security.py @@ -10,10 +10,14 @@ non-allowed origin is not reflected. """ +import base64 + import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from app.core.config import Settings +from app.core.trust_mode import TrustModeGuardrailError, validate_trust_mode_config from app.main import add_cors_middleware EVIL_ORIGIN = "https://evil.example" @@ -72,3 +76,92 @@ async def test_explicit_allowlist_does_not_reflect_other_origins(): async with _client(_app_with_origins([GOOD_ORIGIN])) as client: resp = await client.get("/ping", headers={"Origin": EVIL_ORIGIN}) assert resp.headers.get("access-control-allow-origin") != EVIL_ORIGIN + + +def test_default_cors_origins_allow_no_browser_origin(): + """A fresh process must not open every browser origin. + + The field default is what boots when CORS_ORIGINS is unset. Wildcard + here would let any website read cross-origin responses, and a later + cookie would turn that into a credentialed read. + """ + assert Settings.model_fields["CORS_ORIGINS"].default == "" + + +@pytest.mark.anyio +async def test_empty_origins_grant_no_cross_origin_access(): + """No configured origins means no CORS grant, including credentials. + + Starlette stamps Access-Control-Allow-Credentials on every Origin + request when allow_credentials is true, even if the origin itself is + not allowed. An empty list must not do that. + """ + async with _client(_app_with_origins([])) as client: + resp = await client.get("/ping", headers={"Origin": EVIL_ORIGIN}) + assert "access-control-allow-origin" not in resp.headers + assert "access-control-allow-credentials" not in resp.headers + + +_SIGNING_KEY = base64.b64encode(bytes(range(32))).decode() +_POSTGRES = "postgresql+asyncpg://user:pw@db.internal:5432/trust" + + +def _production_cors(**overrides: str) -> dict: + values = { + "environment": "production", + "trust_mode_enabled": True, + "signing_private_key_b64": _SIGNING_KEY, + "allow_legacy_unpermitted_mcp": False, + "enable_proof_surfaces": False, + "debug": False, + "webauthn_allow_mock": False, + "database_url": _POSTGRES, + "cors_origins": "https://app.example.com", + } + values.update(overrides) + return values + + +@pytest.mark.parametrize( + "cors_origins", + ["*", " * ", "*,https://app.example.com", "https://app.example.com,*"], +) +def test_production_refuses_wildcard_cors(cors_origins: str): + with pytest.raises(TrustModeGuardrailError, match="CORS_ORIGINS"): + validate_trust_mode_config(**_production_cors(cors_origins=cors_origins)) + + +def test_production_accepts_explicit_cors_origins(): + validate_trust_mode_config( + **_production_cors( + cors_origins="https://app.example.com, https://admin.example.com" + ) + ) + + +def test_production_accepts_empty_cors_origins(): + """An API with no browser client is a valid explicit choice.""" + validate_trust_mode_config(**_production_cors(cors_origins="")) + + +def test_local_still_allows_wildcard_cors(): + validate_trust_mode_config( + environment="local", + trust_mode_enabled=True, + signing_private_key_b64="", + allow_legacy_unpermitted_mcp=True, + cors_origins="*", + ) + + +@pytest.mark.anyio +async def test_lifespan_refuses_wildcard_cors_in_production(monkeypatch): + """The boot path must refuse '*', not only the helper called in isolation.""" + import app.main as main_module + + monkeypatch.setattr(main_module.settings, "ENVIRONMENT", "production") + monkeypatch.setattr(main_module.settings, "CORS_ORIGINS", "*") + + with pytest.raises(TrustModeGuardrailError, match="CORS_ORIGINS"): + async with main_module.lifespan(main_module.app): + pytest.fail("lifespan must not start with wildcard CORS in production") diff --git a/gateway/tests/test_db_gate.py b/gateway/tests/test_db_gate.py new file mode 100644 index 00000000..6594ab43 --- /dev/null +++ b/gateway/tests/test_db_gate.py @@ -0,0 +1,231 @@ +"""The database gate itself: closed gates skip quietly, never loudly in CI. + +Every real-database proof in this suite gates on ``tests.support.db_gate``. +These tests pin that contract: an unset opt-in skips locally but fails when +``CI_STRICT=1``, so a CI job that forgets to provision its database goes red +instead of reporting a green suite that proved nothing. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.support import db_gate + +PROBE_FLAG = "FLEET_DB_GATE_PROBE_FLAG_NEVER_SET" + + +def test_ci_strict_reads_environment(monkeypatch): + monkeypatch.delenv(db_gate.CI_STRICT_ENV_VAR, raising=False) + assert db_gate.ci_strict() is False + monkeypatch.setenv(db_gate.CI_STRICT_ENV_VAR, "1") + assert db_gate.ci_strict() is True + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({}, False), + ({PROBE_FLAG: "0"}, False), + ({PROBE_FLAG: "1"}, True), + ], +) +def test_gate_open_flag(monkeypatch, env, expected): + monkeypatch.delenv(PROBE_FLAG, raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) + assert db_gate.gate_open(flag=PROBE_FLAG) is expected + + +def test_gate_open_url_env(monkeypatch): + monkeypatch.delenv("FLEET_DB_GATE_PROBE_URL", raising=False) + assert db_gate.gate_open(url_env="FLEET_DB_GATE_PROBE_URL") is False + monkeypatch.setenv("FLEET_DB_GATE_PROBE_URL", "postgresql://x") + assert db_gate.gate_open(url_env="FLEET_DB_GATE_PROBE_URL") is True + + +def test_require_opt_in_passes_when_set(monkeypatch): + monkeypatch.setenv(PROBE_FLAG, "1") + db_gate.require_opt_in(flag=PROBE_FLAG, reason="probe") # must not raise + + +def test_require_opt_in_skips_when_closed(monkeypatch): + monkeypatch.delenv(PROBE_FLAG, raising=False) + monkeypatch.delenv(db_gate.CI_STRICT_ENV_VAR, raising=False) + with pytest.raises(pytest.skip.Exception): + db_gate.require_opt_in(flag=PROBE_FLAG, reason="probe") + + +def test_require_opt_in_fails_under_ci_strict(monkeypatch): + monkeypatch.delenv(PROBE_FLAG, raising=False) + monkeypatch.setenv(db_gate.CI_STRICT_ENV_VAR, "1") + with pytest.raises(pytest.fail.Exception, match="CI_STRICT"): + db_gate.require_opt_in(flag=PROBE_FLAG, reason="probe") + + +class _FakeDialect: + def __init__(self, name): + self.name = name + + +class _FakeEngine: + def __init__(self, name): + self.dialect = _FakeDialect(name) + + +def test_require_live_postgres_passes_on_postgres(monkeypatch): + monkeypatch.setenv(PROBE_FLAG, "1") + monkeypatch.setattr("app.db.database.get_engine", lambda: _FakeEngine("postgresql")) + db_gate.require_live_postgres(flag=PROBE_FLAG, reason="probe") + + +def test_require_live_postgres_skips_on_sqlite(monkeypatch): + monkeypatch.setenv(PROBE_FLAG, "1") + monkeypatch.delenv(db_gate.CI_STRICT_ENV_VAR, raising=False) + monkeypatch.setattr("app.db.database.get_engine", lambda: _FakeEngine("sqlite")) + with pytest.raises(pytest.skip.Exception): + db_gate.require_live_postgres(flag=PROBE_FLAG, reason="probe") + + +def test_require_live_postgres_fails_on_sqlite_under_ci_strict(monkeypatch): + monkeypatch.setenv(PROBE_FLAG, "1") + monkeypatch.setenv(db_gate.CI_STRICT_ENV_VAR, "1") + monkeypatch.setattr("app.db.database.get_engine", lambda: _FakeEngine("sqlite")) + with pytest.raises(pytest.fail.Exception, match="CI_STRICT"): + db_gate.require_live_postgres(flag=PROBE_FLAG, reason="probe") + + +def test_collection_mark_is_noop_when_open(monkeypatch): + monkeypatch.setenv(PROBE_FLAG, "1") + mark = db_gate.collection_mark(flag=PROBE_FLAG, reason="probe") + assert mark.name == "skipif" + assert bool(mark.args[0]) is False + + +def test_collection_mark_skips_when_closed(monkeypatch): + monkeypatch.delenv(PROBE_FLAG, raising=False) + monkeypatch.delenv(db_gate.CI_STRICT_ENV_VAR, raising=False) + mark = db_gate.collection_mark(flag=PROBE_FLAG, reason="probe") + assert mark.name == "skipif" + assert bool(mark.args[0]) is True + + +def test_collection_mark_fails_under_ci_strict_when_evaluated(monkeypatch): + monkeypatch.delenv(PROBE_FLAG, raising=False) + monkeypatch.setenv(db_gate.CI_STRICT_ENV_VAR, "1") + mark = db_gate.collection_mark(flag=PROBE_FLAG, reason="probe") + with pytest.raises(pytest.fail.Exception, match="CI_STRICT"): + bool(mark.args[0]) + + +def _run_probe( + tmp_path: Path, *, ci_strict: bool, import_only: bool = False +) -> subprocess.CompletedProcess: + probe = tmp_path / "test_db_gate_probe.py" + probe.write_text( + "import pytest\n" + "from tests.support import db_gate\n" + "\n" + "pytestmark = db_gate.collection_mark(\n" + f' flag="{PROBE_FLAG}",\n' + ' reason="probe",\n' + ")\n" + "\n" + "FIELDS = ('permit_id',)\n" + "\n" + "\n" + "def test_probe():\n" + " pass\n" + ) + importer = tmp_path / "test_importer.py" + importer.write_text( + "from test_db_gate_probe import FIELDS\n" + "\n" + "def test_imported_constant():\n" + " assert FIELDS == ('permit_id',)\n" + ) + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join( + filter( + None, + [ + str(Path(__file__).resolve().parents[1]), + os.environ.get("PYTHONPATH"), + ], + ) + ), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + } + env.pop(PROBE_FLAG, None) + if ci_strict: + env[db_gate.CI_STRICT_ENV_VAR] = "1" + else: + env.pop(db_gate.CI_STRICT_ENV_VAR, None) + return subprocess.run( + [ + sys.executable, + "-m", + "pytest", + str(importer if import_only else probe), + "-q", + "-p", + "no:cacheprovider", + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=120, + ) + + +def test_closed_gate_skips_outside_ci(tmp_path): + result = _run_probe(tmp_path, ci_strict=False) + output = result.stdout + result.stderr + assert result.returncode == 0, output + assert "skipped" in output.lower(), output + + +def test_closed_gate_errors_under_ci_strict(tmp_path): + result = _run_probe(tmp_path, ci_strict=True) + output = result.stdout + result.stderr + assert result.returncode != 0, output + assert "CI_STRICT" in output, output + assert "1 skipped" not in output, output + + +def test_importing_closed_gate_does_not_fail_unrelated_strict_test(tmp_path): + result = _run_probe( + tmp_path, + ci_strict=True, + import_only=True, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, output + assert "1 passed" in output, output + + +@pytest.mark.parametrize( + ("summary", "expected_code"), + [ + ("\x1b[33mSKIPPED\x1b[0m [1] tests/test_proof.py: closed gate\n", 1), + ("\x1b[32m1 passed\x1b[0m in 0.01s\n", 0), + ], +) +def test_proof_log_gate_handles_colored_pytest_output(tmp_path, summary, expected_code): + log = tmp_path / "proof-logs-probe.txt" + log.write_text(summary) + script = Path(__file__).resolve().parent / "support" / "proof_log_gate.py" + result = subprocess.run( + [sys.executable, str(script), str(log)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == expected_code, result.stdout + result.stderr diff --git a/gateway/tests/test_delivery_uncertain_replay.py b/gateway/tests/test_delivery_uncertain_replay.py index 2c5d06bc..dfd2e6af 100644 --- a/gateway/tests/test_delivery_uncertain_replay.py +++ b/gateway/tests/test_delivery_uncertain_replay.py @@ -20,6 +20,7 @@ from sqlalchemy import update as sa_update from app.core.config import get_settings +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import IdempotencyRecordModel from app.main import app @@ -168,6 +169,7 @@ async def test_replay_after_dispatched_triggers_reconciliation( units=Decimal("4") / Decimal("3"), request_path="/test", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) await dispatch.attach_charge( attempt_id=attempt.attempt_id, diff --git a/gateway/tests/test_dev_tool_pins.py b/gateway/tests/test_dev_tool_pins.py new file mode 100644 index 00000000..efd2824c --- /dev/null +++ b/gateway/tests/test_dev_tool_pins.py @@ -0,0 +1,106 @@ +"""Keep the local lint and type-check pins identical to CI. + +mypy used to be `>=2.4.0` in requirements.txt, and the CI type-check step +installed whatever `uv pip install mypy` resolved that day. ruff was pinned +in CI (`RUFF_VERSION`) but not for local runs. These checks fail if either +tool can change versions without a repo change. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from packaging.requirements import Requirement + +ROOT = Path(__file__).resolve().parent.parent.parent +GATEWAY = ROOT / "gateway" +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" + + +def _requirements_from_lines(lines: list[str]) -> dict[str, Requirement]: + requirements: dict[str, Requirement] = {} + for line in lines: + value = line.partition("#")[0].strip() + if not value or value.startswith("-r ") or value.startswith("-c "): + continue + requirement = Requirement(value) + requirements[requirement.name.lower()] = requirement + return requirements + + +def _workflow_env(workflow: str, name: str) -> str: + match = re.search( + rf'^[ \t]*{name}:[ \t]*"([^"]+)"', + workflow, + re.MULTILINE, + ) + assert match is not None, f"{name} missing from CI workflow" + return match.group(1) + + +def _exact_version(requirement: Requirement) -> str: + specifiers = list(requirement.specifier) + assert len(specifiers) == 1, requirement + specifier = specifiers[0] + assert specifier.operator == "==", requirement + return specifier.version + + +def test_mypy_and_ruff_pins_match_ci_env() -> None: + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + ruff_version = _workflow_env(workflow, "RUFF_VERSION") + mypy_version = _workflow_env(workflow, "MYPY_VERSION") + + runtime = _requirements_from_lines( + (GATEWAY / "requirements.txt").read_text(encoding="utf-8").splitlines() + ) + dev = _requirements_from_lines( + (GATEWAY / "requirements-dev.txt").read_text(encoding="utf-8").splitlines() + ) + + assert _exact_version(runtime["mypy"]) == mypy_version + assert _exact_version(dev["mypy"]) == mypy_version + assert _exact_version(dev["ruff"]) == ruff_version + # The runtime image installs requirements.txt. Keep the linter out of it. + assert "ruff" not in runtime + + +def test_ci_installs_pinned_mypy_and_ruff() -> None: + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + installs = re.findall(r"^.*uv pip install.*$", workflow, re.MULTILINE) + mypy_installs = [line for line in installs if "mypy" in line] + ruff_installs = [line for line in installs if "ruff" in line] + + assert mypy_installs, "CI never installs mypy" + assert ruff_installs, "CI never installs ruff" + for line in mypy_installs: + assert "mypy==${MYPY_VERSION}" in line + for line in ruff_installs: + assert "ruff==${RUFF_VERSION}" in line + assert "uv pip install mypy &&" not in workflow + assert "uv pip install mypy\n" not in workflow + + +def test_local_gate_uses_the_dev_lock() -> None: + gate = (GATEWAY / "scripts" / "core_quality_gate.sh").read_text(encoding="utf-8") + env_script = (GATEWAY / "scripts" / "lib" / "python_env.sh").read_text( + encoding="utf-8" + ) + + assert 'PYTHON_ENV_EXTRA_REQUIREMENTS="requirements-dev.txt"' in gate + assert 'PYTHON_ENV_EXTRA_WITH="ruff"' not in gate + assert "--with ruff" not in gate + assert "--with mypy" not in gate + assert "PYTHON_ENV_EXTRA_REQUIREMENTS" in env_script + assert '--with-requirements "${_python_env_extra_requirements}"' in env_script + + +def test_contributing_documents_blessed_versions() -> None: + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + contributing = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") + + assert _workflow_env(workflow, "RUFF_VERSION") in contributing + assert _workflow_env(workflow, "MYPY_VERSION") in contributing + assert "gateway/requirements-dev.txt" in contributing + assert "blessed" in contributing diff --git a/gateway/tests/test_dispatch_call_slot_migration.py b/gateway/tests/test_dispatch_call_slot_migration.py index eae41028..88560f93 100644 --- a/gateway/tests/test_dispatch_call_slot_migration.py +++ b/gateway/tests/test_dispatch_call_slot_migration.py @@ -10,6 +10,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.ext.asyncio import create_async_engine +from tests.support import db_gate from tests.support.action_database_guard import ( require_action_database_url, require_action_test_environment, @@ -25,7 +26,10 @@ INDEX = "ix_mcp_dispatch_attempts_duplicate_detection" -@pytest.mark.skipif(not TARGET, reason="requires fresh disposable migration PostgreSQL") +@db_gate.collection_mark( + url_env="DISPATCH_MIGRATION_DATABASE_URL", + reason="requires fresh disposable migration PostgreSQL", +) def test_dispatch_call_slot_upgrade_recovers_after_concurrent_index_failure( monkeypatch, ): diff --git a/gateway/tests/test_dns_rebinding_pinning.py b/gateway/tests/test_dns_rebinding_pinning.py new file mode 100644 index 00000000..679d907f --- /dev/null +++ b/gateway/tests/test_dns_rebinding_pinning.py @@ -0,0 +1,224 @@ +"""DNS rebinding protection for agent-driven outbound fetches. + +``check_outbound_url`` validates a URL once, but the sandbox HTTP proxy and +the headless browser used to resolve the hostname again at connect time. A +name that validated as public could therefore rebind to a private address +before the connection, and the connection would follow it. The sandbox now +connects to the pinned IP that passed validation (same approach as the +upstream MCP pinned transport), and browser navigation revalidates +immediately before ``page.goto``. + +These tests use a resolver that returns a public address and then a private +address to prove the validated IP is the one connected to. +""" + +from __future__ import annotations + +import pytest + +import httpx + +from app.core import url_guard +from app.core.url_guard import check_outbound_url, pin_outbound_url +from app.services.awi_playwright_bridge import ( + AWIPlaywrightBridge, + BridgeSession, +) +from app.services.behavioral_sandbox import BehavioralSandboxEngine + +_PUBLIC = "93.184.216.34" +_PRIVATE = "10.0.0.5" + + +def _addrinfo(address: str): + return [(None, None, None, None, (address, 0))] + + +def _rebinding_resolve_factory(monkeypatch, calls: list): + """First lookup returns public, every later lookup returns private.""" + + async def resolve(_host): + calls.append(1) + if len(calls) == 1: + return _addrinfo(_PUBLIC) + return _addrinfo(_PRIVATE) + + monkeypatch.setattr(url_guard, "_resolve_host", resolve) + + +@pytest.fixture +def bridge(): + return AWIPlaywrightBridge() + + +@pytest.fixture +def session(): + return BridgeSession(session_id="test-session", current_url="https://example.com") + + +@pytest.fixture +def engine(): + return BehavioralSandboxEngine(redis_url="redis://localhost:6379") + + +class _RecordingPage: + """Fake Playwright page that records navigations.""" + + def __init__(self): + self.url = "https://example.com/" + self.goto_calls: list[str] = [] + + async def goto(self, url, **kwargs): + self.goto_calls.append(url) + self.url = url + + async def title(self): + return "Example" + + +class TestPinOutboundUrl: + @pytest.mark.anyio + async def test_pin_returns_first_validated_address(self): + async def resolver(_host): + return (_PUBLIC, "93.184.216.35") + + reason, pinned = await pin_outbound_url( + "https://example.com/data", resolver=resolver + ) + assert reason is None + assert pinned == _PUBLIC + + @pytest.mark.anyio + async def test_pin_blocks_private_resolution(self): + async def resolver(_host): + return (_PRIVATE,) + + reason, pinned = await pin_outbound_url( + "https://example.com/data", resolver=resolver + ) + assert reason == "private_address_blocked" + assert pinned is None + + @pytest.mark.anyio + async def test_pin_returns_literal_for_literal_host(self): + reason, pinned = await pin_outbound_url("https://93.184.216.34/data") + assert reason is None + assert pinned == _PUBLIC + + @pytest.mark.anyio + async def test_check_outbound_url_still_reports_verdict_only(self, monkeypatch): + async def resolve(_host): + return _addrinfo(_PUBLIC) + + monkeypatch.setattr(url_guard, "_resolve_host", resolve) + assert await check_outbound_url("https://example.com/page") is None + + +class TestSandboxRebindingPinning: + @pytest.mark.anyio + async def test_connects_to_pinned_address_despite_rebinding( + self, engine, monkeypatch + ): + resolve_calls: list = [] + _rebinding_resolve_factory(monkeypatch, resolve_calls) + + connected: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + connected.append(request) + return httpx.Response(200, text="ok") + + result = await engine._execute_http_proxy( + "http_get", + {"url": "https://example.com/data", "method": "GET"}, + False, + 5, + network_access=True, + transport=httpx.MockTransport(handler), + ) + + assert result["success"] is True + assert result["output"]["status_code"] == 200 + # DNS was consulted exactly once: validation saw the public address + # and the connection reused it instead of resolving again (where it + # would have found the private rebound address). + assert len(resolve_calls) == 1 + assert len(connected) == 1 + assert connected[0].url.host == _PUBLIC + # HTTP Host and TLS SNI still name the original hostname. + assert connected[0].headers["Host"] == "example.com" + assert connected[0].extensions["sni_hostname"] == "example.com" + + @pytest.mark.anyio + async def test_never_connects_when_validation_sees_private( + self, engine, monkeypatch + ): + async def resolve(_host): + return _addrinfo(_PRIVATE) + + monkeypatch.setattr(url_guard, "_resolve_host", resolve) + + def handler(_request: httpx.Request) -> httpx.Response: + raise AssertionError("blocked fetch must not connect") + + result = await engine._execute_http_proxy( + "http_get", + {"url": "https://example.com/data"}, + False, + 5, + network_access=True, + transport=httpx.MockTransport(handler), + ) + + assert result["success"] is False + assert "private_address_blocked" in result["error"] + + +class TestBrowserNavigationRevalidation: + @pytest.mark.anyio + async def test_goto_refuses_after_rebinding(self, bridge, session, monkeypatch): + resolve_calls: list = [] + _rebinding_resolve_factory(monkeypatch, resolve_calls) + + # Command build validates while DNS still answers public. + [command] = await bridge._handle_navigate_to( + session, {"url": "https://example.com/products"} + ) + + # By execution time DNS has rebound to private: refuse, no goto. + page = _RecordingPage() + session._page = page + with pytest.raises(ValueError, match="navigation blocked"): + await bridge._execute_single_command(session, command) + assert page.goto_calls == [] + + @pytest.mark.anyio + async def test_navigate_to_refuses_after_rebinding( + self, bridge, session, monkeypatch + ): + resolve_calls: list = [] + _rebinding_resolve_factory(monkeypatch, resolve_calls) + + # An earlier pre-flight check (create_session) saw the public address. + assert await check_outbound_url("https://example.com/") is None + + # The last-moment check before goto sees the rebound private address. + page = _RecordingPage() + session._page = page + with pytest.raises(ValueError, match="navigation blocked"): + await bridge._navigate_to(session, "https://example.com/") + assert page.goto_calls == [] + + @pytest.mark.anyio + async def test_navigate_to_still_runs_for_stable_public_dns( + self, bridge, session, monkeypatch + ): + async def resolve(_host): + return _addrinfo(_PUBLIC) + + monkeypatch.setattr(url_guard, "_resolve_host", resolve) + + page = _RecordingPage() + session._page = page + await bridge._navigate_to(session, "https://example.com/") + assert page.goto_calls == ["https://example.com/"] diff --git a/gateway/tests/test_docs_reference_integrity.py b/gateway/tests/test_docs_reference_integrity.py new file mode 100644 index 00000000..95713c3b --- /dev/null +++ b/gateway/tests/test_docs_reference_integrity.py @@ -0,0 +1,226 @@ +"""Docs in gateway/docs must name files and revocation behavior that exist. + +Operators follow these pages during key incidents and the local quickstart. +A path or script that is not in the tree, or a denial amount the API does +not return, is a broken instruction, not a style nit. +""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +from app.main import DORMANT_TRUST_ROUTERS +from app.schemas.billing import ( + EmergencyKeyRevocationRequest, + RotateAPIKeyRequest, +) +from app.schemas.pods import MAX_POD_MEMBERS + +GATEWAY = Path(__file__).resolve().parents[1] +DOCS = GATEWAY / "docs" +REPO = GATEWAY.parent + +BACKTICK_MD = re.compile(r"`([^`\n]*?\.md)`") +BACKTICK_SCRIPT = re.compile(r"`(scripts/[A-Za-z0-9_./-]+\.py)`") +MARKDOWN_LINK = re.compile(r"(? bool: + target = ref.strip().split("#", 1)[0].split("?", 1)[0] + if not target.endswith(".md") or target.startswith("http"): + return True + relative = Path(target) + if linked: + return (doc.parent / relative).is_file() + candidates = [ + (doc.parent / relative).resolve(), + (GATEWAY / relative).resolve(), + (REPO / relative).resolve(), + ] + return any(path.is_file() for path in candidates) + + +def test_docs_markdown_references_exist() -> None: + missing: list[str] = [] + for doc in sorted(DOCS.glob("*.md")): + text = doc.read_text(encoding="utf-8") + for match in BACKTICK_MD.finditer(text): + ref = match.group(1) + if not _markdown_ref_resolves(doc, ref): + missing.append(f"{doc.name}: {ref}") + for match in MARKDOWN_LINK.finditer(text): + ref = match.group(1) + if not _markdown_ref_resolves(doc, ref, linked=True): + missing.append(f"{doc.name}: {ref}") + assert not missing, ( + "docs name markdown files that are not in the tree:\n" + "\n".join(missing) + ) + + +def test_docs_script_references_exist() -> None: + missing: list[str] = [] + for doc in sorted(DOCS.glob("*.md")): + text = doc.read_text(encoding="utf-8") + assert "retire_owner_keys.py" not in text, doc.name + for match in BACKTICK_SCRIPT.finditer(text): + ref = match.group(1) + if not (GATEWAY / ref).is_file(): + missing.append(f"{doc.name}: {ref}") + assert not missing, "docs name scripts that are not in the tree:\n" + "\n".join( + missing + ) + + +def test_api_key_rotation_runbook_matches_the_code() -> None: + text = (DOCS / "api-key-rotation.md").read_text(encoding="utf-8") + words = " ".join(text.split()) + rotate = _load_rotate_script() + + assert RotateAPIKeyRequest.model_fields["revoke_old"].default is False + assert EmergencyKeyRevocationRequest.model_fields["create_new_key"].default is True + assert "`revoke_old` defaults to false" in text + assert "`create_new_key` defaults to true" in text + assert rotate.CHECK_PATH == "/v1/audit/summary" + assert rotate.CHECK_PATH in text + assert rotate.KEY_PREFIX == "amw_live_" + assert rotate.KEY_PREFIX in text + for name in ("AGENT_MIDDLEWARE_API_URL", "OLD_API_KEY", "NEW_API_KEY"): + assert name in text + assert "401" in text and "403" in text + assert "must not start with `Bearer ` or have a JWT shape" in words + assert "verification before env-key lookup" in words + assert "an empty or short API key returns `401`" in words + assert "an unknown well-formed API key returns `403`" in words + assert "bounded_key_cannot_mint" in text + assert "wallet_access_denied" in text + assert "scripts/rotate_api_keys.py" in text + assert "from the gateway directory" in text + assert "`revoke_old: true`" in text + assert "`422` `invalid_rotation_request`" in text + assert "bounded wallet key may call emergency revoke" in text + assert "caller key's remaining bounds" in text + assert "revokes the wallet's refresh tokens" in text + assert "POST /v1/api-keys/rotate" in text + assert "POST /v1/api-keys/emergency-revoke" in text + assert "DELETE /v1/api-keys/{wallet_id}/{key_id}" in text + assert "STATIC_DEV_API_KEYS" in text + assert "amw_dev_" in text + assert "key-management.md" in text + # A restart is required: get_settings caches VALID_API_KEYS for the process. + assert "restart" in text + bootstrap = text.split("## Bootstrap keys", 1)[1].split("## Wallet keys", 1)[0] + bootstrap_words = " ".join(bootstrap.split()) + incident = text.split("## Incident table", 1)[1] + for claim in ( + "exact intended replacement is in `VALID_API_KEYS`", + "every serving worker runs that configuration revision", + "all old workers are stopped or drained", + "exactly one request per key", + "different workers", + "neither fleet-wide retirement", + "Repeating random probes does not replace", + "That route also accepts wallet keys", + "can update `last_used_at` and consume a bounded use", + "does not follow redirects or make a mutation request", + ): + assert claim in bootstrap_words + assert "exit 0 alone does not close the incident" in incident + assert "every serving worker runs that revision" in incident + + +def test_release_gate_doc_matches_committed_ci() -> None: + proof = (DOCS / "PROOF_MATRIX.md").read_text(encoding="utf-8") + makefile = (GATEWAY / "Makefile").read_text(encoding="utf-8") + ci = (REPO / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + workflows = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted((REPO / ".github" / "workflows").iterdir()) + if path.suffix in {".yml", ".yaml"} + ) + + assert "trust-release-gate:\n\tscripts/trust_release_gate.sh" in makefile + assert "make trust-release-gate" not in workflows + assert "trust_release_gate.sh" in ci + assert "name: CI result" in ci + assert "`make trust-release-gate`" in proof + assert "CI runs `scripts/trust_release_gate.sh`" in proof + assert "`CI result` and `gitleaks`" in proof + assert "rules/24444399" in proof + + +def test_rogue_agent_doc_preserves_receipt_exceptions() -> None: + text = (DOCS / "owasp-agentic-top10-mapping.md").read_text(encoding="utf-8") + section = text.split("## ASI10 — Rogue Agents", 1)[1] + + assert "[failure semantics](failure-semantics.md)" in section + assert "Terminalized or reconcilable governed calls" in section + assert "before usable authority and an executable tool have no" in section + assert "manual review may also have no" in section + assert "every action, allowed or refused" not in section + + +def test_pods_doc_matches_the_dormant_router() -> None: + text = (DOCS / "pods.md").read_text(encoding="utf-8") + schema = " ".join( + (GATEWAY / "app" / "schemas" / "pods.py").read_text(encoding="utf-8").split() + ) + names = {module.__name__.rsplit(".", 1)[-1] for module in DORMANT_TRUST_ROUTERS} + assert "pods" in names + assert MAX_POD_MEMBERS == 50 + assert "50" in text + assert "ENABLE_PROOF_SURFACES" in text + assert "require_bootstrap_admin" in text + assert "POST /v1/pods" in text + for limit in ( + "no department templates", + "no on-demand provisioning trigger", + "no cross-pod budget sharing", + ): + assert limit in schema + assert limit in text + + +def test_quickstart_denial_amounts_and_working_directory() -> None: + text = (DOCS / "quickstart.md").read_text(encoding="utf-8") + pin = (GATEWAY / "tests" / "test_quickstart_path.py").read_text(encoding="utf-8") + assert 'details["required_credits"] == "2.0"' in pin + assert 'details["remaining_credits"] == "1.00000000"' in pin + assert '`required_credits: "2.0"`' in text + assert '`remaining_credits: "1.00000000"`' in text + second_terminal = text.split("second terminal", 1)[1].split("## 2.", 1)[0] + assert "gateway directory" in second_terminal + assert "repository root" not in second_terminal + + +def test_human_preflight_uses_the_gateway_directory() -> None: + text = (DOCS / "human-onboarding.md").read_text(encoding="utf-8") + section = text.split("## Preflight script", 1)[1].split("---", 1)[0] + assert "bash scripts/human_preflight.sh" in section + assert "gateway directory" in section + assert "repository root" not in section + + +def test_readme_index_does_not_name_missing_material() -> None: + text = (DOCS / "README.md").read_text(encoding="utf-8") + assert "[API key rotation](api-key-rotation.md)" in text + assert "[Pods](pods.md) are a dormant trust feature" in text + for missing in ( + "mcp-registry-submission.md", + "aegis/", + "superpowers/", + "Intent is not authority", + "Deployment verification checklist", + ): + assert missing not in text, missing diff --git a/gateway/tests/test_duplicate_guard_postgres_concurrency.py b/gateway/tests/test_duplicate_guard_postgres_concurrency.py index 4db17b5f..8577cbe7 100644 --- a/gateway/tests/test_duplicate_guard_postgres_concurrency.py +++ b/gateway/tests/test_duplicate_guard_postgres_concurrency.py @@ -7,7 +7,6 @@ from __future__ import annotations import asyncio -import os from datetime import timedelta from decimal import Decimal @@ -17,13 +16,14 @@ from app.core.config import DuplicateGuardMode from app.core.time import utc_now -from app.db.database import get_engine, get_session_factory +from app.db.database import get_session_factory from app.db.models import LedgerEntryModel, McpDispatchAttemptModel, ReceiptModel from app.main import app from app.schemas.billing import ServiceCategory from app.services.service_registry import get_service_registry from app.services.signing_keys import canonical_json, sha256_hex from app.services.upstream_mcp import UpstreamMcpResult +from tests.support.db_gate import require_live_postgres from tests.test_trust_helpers import ( BOOTSTRAP_HEADERS, provision_agent_wallet, @@ -32,14 +32,14 @@ def _require_opted_in_postgres() -> None: """Skip test if not running against PostgreSQL with opt-in flag.""" - engine = get_engine() - if engine is None or engine.dialect.name != "postgresql": - pytest.skip("requires a PostgreSQL DATABASE_URL for real row-lock semantics") - if os.environ.get("RUN_POSTGRES_CONCURRENCY_TESTS") != "1": - pytest.skip( + require_live_postgres( + flag="RUN_POSTGRES_CONCURRENCY_TESTS", + reason=( + "requires a PostgreSQL DATABASE_URL for real row-lock semantics; " "set RUN_POSTGRES_CONCURRENCY_TESTS=1 only with an isolated " - "PostgreSQL database" - ) + "PostgreSQL test database" + ), + ) class FakeUpstreamExecutor: diff --git a/gateway/tests/test_examples_smoke.py b/gateway/tests/test_examples_smoke.py new file mode 100644 index 00000000..2196b624 --- /dev/null +++ b/gateway/tests/test_examples_smoke.py @@ -0,0 +1,170 @@ +"""Smoke tests for the buyer-visible examples. + +Every script under examples/ is something a prospect may copy-paste, and CI +waves through packages with zero tests, so these examples used to rot +silently. These tests pin the cheap, stdlib-only properties: each script +compiles, none relies on a sys.path hack, the MCP client config is a valid +template, the retry demo's receipt parsing works on sample payloads, and the +README documents every script with the golden-path versus legacy split. + +Only stdlib plus pytest is used so the file also runs with --noconftest on a +bare interpreter; under CI it runs inside the gateway suite like the rest. +""" + +from __future__ import annotations + +import importlib.util +import json +import py_compile +import sys +import types +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +EXAMPLES = REPO / "examples" +MCP_CLIENT = EXAMPLES / "mcp-client" +README = EXAMPLES / "README.md" + +SCRIPTS = [ + EXAMPLES / "dry_run_example.py", + EXAMPLES / "mcp_tool_example.py", + EXAMPLES / "acta_receipt_interop.py", + MCP_CLIENT / "call_with_retry.py", +] + + +def test_all_example_scripts_compile() -> None: + """Each example must at least parse, so edits cannot rot silently.""" + for script in SCRIPTS: + assert script.is_file(), f"missing example script: {script.name}" + py_compile.compile(str(script), doraise=True) + + +def test_no_sys_path_hacks_in_examples() -> None: + """Examples run from the documented pip install, not path shims. + + The old shims pointed at /b2a_sdk/src, which does not exist (the + SDK lives at sdk/python/src), so they never worked on a fresh checkout. + """ + for script in SCRIPTS: + text = script.read_text(encoding="utf-8") + assert "sys.path" not in text, f"{script.name} still mutates sys.path" + + +def test_sdk_imports_fail_with_install_hint() -> None: + """SDK-backed examples name the documented install when not installed.""" + for name in ("dry_run_example.py", "mcp_tool_example.py"): + text = (EXAMPLES / name).read_text(encoding="utf-8") + assert "python -m pip install -e './sdk/python[dev]'" in text + + +def _load_retry_module() -> types.ModuleType: + """Load call_with_retry without the third-party MCP SDK installed.""" + inserted: list[str] = [] + if "mcp" not in sys.modules: + mcp = types.ModuleType("mcp") + client_pkg = types.ModuleType("mcp.client") + stream_pkg = types.ModuleType("mcp.client.streamable_http") + + class _ClientSession: # pragma: no cover - import-time stub only + def __init__(self, *args, **kwargs): + raise AssertionError("network stub must not be used in tests") + + def _streamablehttp_client(*args, **kwargs): # pragma: no cover + raise AssertionError("network stub must not be used in tests") + + mcp.ClientSession = _ClientSession + stream_pkg.streamablehttp_client = _streamablehttp_client + for name, mod in ( + ("mcp", mcp), + ("mcp.client", client_pkg), + ("mcp.client.streamable_http", stream_pkg), + ): + sys.modules[name] = mod + inserted.append(name) + try: + spec = importlib.util.spec_from_file_location( + "example_call_with_retry", MCP_CLIENT / "call_with_retry.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + for name in inserted: + sys.modules.pop(name, None) + + +def _sample_result(receipt_id: str, outcome: str = "success") -> dict: + return { + "_meta": { + "io.agentmiddleware/receipt": { + "receipt_id": receipt_id, + "outcome": outcome, + } + } + } + + +def test_retry_receipt_parsing_returns_identical_ids() -> None: + """Same idempotency key means the same receipt id on first call and retry.""" + module = _load_retry_module() + first = _sample_result("rcpt-9f2c41a7") + retry = _sample_result("rcpt-9f2c41a7") + assert module.receipt_id(first) == "rcpt-9f2c41a7" + assert module.receipt_id(retry) == module.receipt_id(first) + assert module._outcome(first) == "success" + + +def test_retry_receipt_parsing_tolerates_meta_shapes() -> None: + """The gateway may spell the envelope meta or _meta; both must parse.""" + module = _load_retry_module() + via_meta = { + "meta": { + "io.agentmiddleware/receipt": { + "receipt_id": "r1", + "outcome": "success", + } + } + } + assert module.receipt_id(via_meta) == "r1" + assert module._outcome(via_meta) == "success" + missing_outcome = {"meta": {"io.agentmiddleware/receipt": {"receipt_id": "r1"}}} + assert module.receipt_id(missing_outcome) is None + assert module.receipt_id({}) is None + + +def test_recorded_sample_output_matches_script_format() -> None: + """The checked-in sample output must look like the script's own prints.""" + sample = (MCP_CLIENT / "expected_output.txt").read_text(encoding="utf-8") + lines = [ + line for line in sample.splitlines() if line.startswith(("first", "retry")) + ] + assert len(lines) == 2, "sample must show the first call and the retry" + ids = [line.split("receipt:")[1].split()[0] for line in lines] + assert ids[0] == ids[1], "retry must return the same receipt id, not a new charge" + for line in lines: + assert "outcome:" in line + + +def test_mcp_json_is_a_valid_local_template() -> None: + """The client config must parse and point at the local gateway path.""" + config = json.loads((MCP_CLIENT / "mcp.json").read_text(encoding="utf-8")) + server = config["mcpServers"]["agent-middleware"] + assert server["type"] == "http" + assert server["url"].endswith("/mcp") + assert "X-API-Key" in server["headers"] + assert "Idempotency-Key" not in server["headers"] + + +def test_readme_documents_every_script_with_status() -> None: + """README must name each script and split golden path from legacy.""" + text = README.read_text(encoding="utf-8") + for script in SCRIPTS: + assert script.name in text, f"README does not mention {script.name}" + assert "mcp.json" in text + assert "expected_output" in text + lowered = text.lower() + assert "golden" in lowered + assert "legacy" in lowered + assert "research" in lowered diff --git a/gateway/tests/test_failure_lab_failclosed.py b/gateway/tests/test_failure_lab_failclosed.py new file mode 100644 index 00000000..9e860169 --- /dev/null +++ b/gateway/tests/test_failure_lab_failclosed.py @@ -0,0 +1,104 @@ +"""Fail-closed instrumentation: the fault layer must not invent identifiers. + +The layer stands between the gateway and the tool, so anything it records +can end up quoted as an observation. A non-string ``operation_id`` coerced +with ``str()`` would read as a real identifier while matching no fault plan, +and an oversized body must be refused as 413 without touching the tool, +never raise out of the middleware as an unclassifiable transport error. +""" + +from __future__ import annotations + +import asyncio +import json + +from failure_lab.faults import ( + FaultInjectionMiddleware, + FaultInjector, + classify_request, +) + + +def _tools_call(operation_id): + return json.dumps( + { + "method": "tools/call", + "id": 1, + "params": {"arguments": {"operation_id": operation_id}}, + } + ).encode() + + +def test_non_string_operation_ids_stay_unidentified(): + """A dict, number or bool operation id is malformed, not a name.""" + for bad in ({"nested": True}, 42, True, ["op-1"]): + kind, operation_id, *_ = classify_request("/mcp", _tools_call(bad), {}) + assert kind == "tools_call" + assert operation_id is None + + +def test_rest_non_string_operation_ids_stay_unidentified(): + body = json.dumps({"operation_id": {"nested": True}}).encode() + kind, operation_id, *_ = classify_request("/refunds", body, {}) + assert kind == "refund_rest" + assert operation_id is None + + +def test_string_operation_ids_still_classify(): + kind, operation_id, *_ = classify_request("/mcp", _tools_call("op-1"), {}) + assert (kind, operation_id) == ("tools_call", "op-1") + body = json.dumps({"operation_id": "op-2"}).encode() + kind, operation_id, *_ = classify_request("/refunds", body, {}) + assert (kind, operation_id) == ("refund_rest", "op-2") + + +def test_oversized_body_is_refused_as_413_without_reaching_the_tool(): + """Over the buffer limit: 413, no tool contact, no raise, recorded.""" + from failure_lab.faults import _MAX_BODY_BYTES + + reached_tool = [] + + async def inner(scope, receive, send): + reached_tool.append(1) + raise AssertionError("the tool must not run for an oversized body") + + injector = FaultInjector() + middleware = FaultInjectionMiddleware(inner, injector) + + big = b"x" * (_MAX_BODY_BYTES + 1) + chunks = [big, b""] + + async def receive_chunks(): + if chunks: + chunk = chunks.pop(0) + return { + "type": "http.request", + "body": chunk, + "more_body": bool(chunks), + } + return {"type": "http.disconnect"} + + messages = [] + + async def send(message): + messages.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [], + } + asyncio.run(middleware(scope, receive_chunks, send)) + + assert reached_tool == [] + starts = [m for m in messages if m["type"] == "http.response.start"] + assert len(starts) == 1 + assert starts[0]["status"] == 413 + crossings = injector.crossings(kinds=("unclassified",)) + assert len(crossings) == 1 + assert crossings[0].reached_tool is False + assert crossings[0].delivered is True + assert crossings[0].fault == "oversized_body_rejected" + # The refusal is not a dispatch: execution-counting views ignore it. + assert injector.dispatch_count() == 0 diff --git a/gateway/tests/test_failure_lab_verifier.py b/gateway/tests/test_failure_lab_verifier.py index 00caebeb..4a920805 100644 --- a/gateway/tests/test_failure_lab_verifier.py +++ b/gateway/tests/test_failure_lab_verifier.py @@ -213,3 +213,69 @@ def test_disabled_keys_are_dropped_from_the_key_set(): def test_envelope_only_fields_are_enumerated_for_the_tampering_report(): assert "issuer" in ENVELOPE_ONLY_FIELDS assert "signing_input" not in ENVELOPE_ONLY_FIELDS + + +@pytest.mark.parametrize("count", [True, False]) +def test_a_boolean_is_not_an_execution_count(signed_bundle, count): + """Booleans are ints in Python; True must not read as one execution.""" + bundle, keys = signed_bundle + report = verify( + bundle, + keys, + downstream_observation={ + "executions": count, + "operation_id": "refund:pay_1", + "source": "effect ledger", + }, + ) + assert report.signature.status is ClaimStatus.ESTABLISHED + assert report.downstream_execution.status is ClaimStatus.NOT_ESTABLISHED + + +def test_a_negative_count_is_an_unusable_observation_not_consistency(signed_bundle): + """Minus four executions is impossible, never agreement with the receipt.""" + bundle, keys = signed_bundle + report = verify( + bundle, + keys, + downstream_observation={ + "executions": -4, + "operation_id": "refund:pay_1", + "source": "effect ledger", + }, + ) + assert report.downstream_execution.status is ClaimStatus.NOT_ESTABLISHED + assert "consistent" not in report.downstream_execution.reason + + +def test_an_observation_of_another_receipt_fails_the_claim(signed_bundle): + """An observation bound to a different receipt cannot establish execution.""" + bundle, keys = signed_bundle + report = verify( + bundle, + keys, + downstream_observation={ + "executions": 1, + "operation_id": "refund:someone-elses", + "receipt_id": "rcpt-someone-elses", + "source": "effect ledger", + }, + ) + assert report.signature.status is ClaimStatus.ESTABLISHED + assert report.downstream_execution.status is ClaimStatus.FAILED + + +def test_an_observation_bound_to_this_receipt_still_establishes(signed_bundle): + """The binding check only fires on a mismatch, never on agreement.""" + bundle, keys = signed_bundle + report = verify( + bundle, + keys, + downstream_observation={ + "executions": 1, + "operation_id": "refund:pay_1", + "receipt_id": "rcpt-abc123", + "source": "effect ledger", + }, + ) + assert report.downstream_execution.status is ClaimStatus.ESTABLISHED diff --git a/gateway/tests/test_gateway_auth_breakit.py b/gateway/tests/test_gateway_auth_breakit.py new file mode 100644 index 00000000..8c344247 --- /dev/null +++ b/gateway/tests/test_gateway_auth_breakit.py @@ -0,0 +1,424 @@ +"""Adversarial break-it tests for gateway auth and API key routes/services. + +Local test setups only. Covers revoked keys, wrong signing keys, missing +and malformed credentials, cross-wallet IDOR, garbage inputs, token type +confusion, and max_uses races. +""" + +from __future__ import annotations + +import asyncio +import base64 +from datetime import datetime, timedelta, timezone + +import jwt as pyjwt +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from httpx import ASGITransport, AsyncClient + +from app.core.auth import get_auth_context +from app.core.config import get_settings +from app.core.jwt import JWT_AUDIENCE, JWT_ISSUER +from app.db.database import get_session_factory +from app.db.models import APIKeyModel +from app.main import app +from tests.test_trust_helpers import BOOTSTRAP_HEADERS, provision_agent_wallet +from sqlalchemy import select +from sqlmodel import col + +pytestmark = pytest.mark.dormant + +# 32 raw bytes, strict base64. Same non-secret test material CI uses. +TEST_SIGNING_KEY = "dGVzdC1zaWduaW5nLWtleS1tYXRlcmlhbC0zMmJ5dGU=" + + +@pytest.fixture +def signing_key(monkeypatch): + monkeypatch.setenv("TRUST_SIGNING_PRIVATE_KEY_B64", TEST_SIGNING_KEY) + get_settings.cache_clear() + try: + yield + finally: + get_settings.cache_clear() + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +def _decode_test_private_key() -> Ed25519PrivateKey: + raw = base64.b64decode(TEST_SIGNING_KEY) + return Ed25519PrivateKey.from_private_bytes(raw) + + +async def _expire_key_now(key_id: str) -> None: + factory = get_session_factory() + async with factory() as session: + result = await session.execute( + select(APIKeyModel).where(col(APIKeyModel.key_id) == key_id) + ) + key = result.scalar_one() + key.expires_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( + seconds=10 + ) + session.add(key) + await session.commit() + + +@pytest.mark.anyio +async def test_revoked_key_rejected_everywhere(client, clean_database, signing_key): + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + agent_headers = provisioned["agent_headers"] + + listed = await client.get(f"/v1/api-keys/{wallet_id}", headers=agent_headers) + assert listed.status_code == 200 + key_id = listed.json()["keys"][0]["key_id"] + + revoke = await client.delete( + f"/v1/api-keys/{wallet_id}/{key_id}", + headers=BOOTSTRAP_HEADERS, + ) + assert revoke.status_code == 204 + + after = await client.get(f"/v1/api-keys/{wallet_id}", headers=agent_headers) + assert after.status_code == 403 + + mint = await client.post( + "/v1/auth/token", + json={"api_key": agent_headers["X-API-Key"]}, + ) + assert mint.status_code == 401 + + +@pytest.mark.anyio +async def test_expired_key_rejected(client, clean_database, signing_key): + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + agent_headers = provisioned["agent_headers"] + + listed = await client.get(f"/v1/api-keys/{wallet_id}", headers=agent_headers) + assert listed.status_code == 200 + await _expire_key_now(listed.json()["keys"][0]["key_id"]) + + after = await client.get(f"/v1/api-keys/{wallet_id}", headers=agent_headers) + assert after.status_code == 403 + + mint = await client.post( + "/v1/auth/token", + json={"api_key": agent_headers["X-API-Key"]}, + ) + assert mint.status_code == 401 + + +@pytest.mark.anyio +async def test_max_uses_budget_survives_parallel_use(client, clean_database): + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + + created = await client.post( + "/v1/api-keys", + json={"wallet_id": wallet_id, "key_name": "capped", "max_uses": 5}, + headers=BOOTSTRAP_HEADERS, + ) + assert created.status_code == 201 + capped = {"X-API-Key": created.json()["api_key"]} + + async def one_call(local_client): + resp = await local_client.get(f"/v1/api-keys/{wallet_id}", headers=capped) + return resp.status_code + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c2: + results = await asyncio.gather(*[one_call(c2) for _ in range(20)]) + + assert results.count(200) == 5 + assert results.count(403) == 15 + + +@pytest.mark.anyio +async def test_jwt_signed_by_wrong_key_rejected(signing_key): + other = Ed25519PrivateKey.generate() + now = datetime.now(timezone.utc) + token = pyjwt.encode( + { + "sub": "wallet_x", + "key_id": "key_x", + "scopes": [], + "iat": now, + "exp": now + timedelta(minutes=15), + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "jti": "jwt-wrong-key", + "type": "access", + }, + other, + algorithm="EdDSA", + ) + with pytest.raises(Exception): + await get_auth_context(api_key=None, authorization=f"Bearer {token}") + + +@pytest.mark.anyio +async def test_tampered_jwt_rejected(client, clean_database, signing_key): + provisioned = await provision_agent_wallet(client) + mint = await client.post( + "/v1/auth/token", + json={"api_key": provisioned["agent_headers"]["X-API-Key"]}, + ) + assert mint.status_code == 200 + access = mint.json()["access_token"] + tampered = access[:-2] + ("AA" if not access.endswith("AA") else "BB") + + resp = await client.get( + "/v1/me/authority", headers={"Authorization": f"Bearer {tampered}"} + ) + assert resp.status_code == 401 + + +@pytest.mark.anyio +async def test_missing_credentials_rejected(client, clean_database): + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + + assert (await client.get(f"/v1/api-keys/{wallet_id}")).status_code == 401 + assert ( + await client.post( + "/v1/api-keys", + json={"wallet_id": wallet_id}, + ) + ).status_code in (401, 422) + assert ( + await client.post("/v1/api-keys/rotate", json={"wallet_id": wallet_id}) + ).status_code in (401, 422) + + +@pytest.mark.anyio +async def test_malformed_bearer_never_falls_back_to_api_key( + client, clean_database, signing_key +): + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + good_key = provisioned["agent_headers"]["X-API-Key"] + + bad_authorizations = [ + "Basic xyz", + "Bearer", + "Bearer ", + "Bearer not-a-jwt", + "Bearer spaced token", + "bearer lower", + ] + for bad in bad_authorizations: + resp = await client.get( + f"/v1/api-keys/{wallet_id}", + headers={"X-API-Key": good_key, "Authorization": bad}, + ) + assert resp.status_code == 401, bad + + resp = await client.get( + f"/v1/api-keys/{wallet_id}", + headers={ + "X-API-Key": good_key, + "Authorization": f"Bearer {good_key}", + }, + ) + assert resp.status_code == 401 + + +@pytest.mark.anyio +async def test_cross_wallet_idor_blocked(client, clean_database, signing_key): + first = await provision_agent_wallet(client) + second = await provision_agent_wallet(client) + a_headers = first["agent_headers"] + b_wallet = second["agent_wallet_id"] + + assert ( + await client.get(f"/v1/api-keys/{b_wallet}", headers=a_headers) + ).status_code == 403 + + assert ( + await client.post( + "/v1/api-keys", json={"wallet_id": b_wallet}, headers=a_headers + ) + ).status_code == 403 + + assert ( + await client.post( + "/v1/api-keys/rotate", + json={"wallet_id": b_wallet}, + headers=a_headers, + ) + ).status_code == 403 + + assert ( + await client.get(f"/v1/api-keys/{b_wallet}/logs", headers=a_headers) + ).status_code == 403 + + before = await client.get(f"/v1/api-keys/{b_wallet}", headers=BOOTSTRAP_HEADERS) + assert before.status_code == 200 + before_count = len(before.json()["keys"]) + victim_key_id = before.json()["keys"][0]["key_id"] + + assert ( + await client.delete( + f"/v1/api-keys/{b_wallet}/{victim_key_id}", headers=a_headers + ) + ).status_code == 403 + + after = await client.get(f"/v1/api-keys/{b_wallet}", headers=BOOTSTRAP_HEADERS) + assert len(after.json()["keys"]) == before_count + + assert ( + await client.post( + "/v1/api-keys/emergency-revoke", + json={"wallet_id": b_wallet, "create_new_key": False}, + headers=a_headers, + ) + ).status_code == 403 + + +@pytest.mark.anyio +async def test_garbage_api_keys_never_500(client, clean_database): + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + + garbage = [ + "", + " ", + "short", + "x" * 7, + "' OR '1'='1", + "b2a_" + "A" * 10000, + "\x00\x01\x02b2a_fake", + "Bearer ", + ".".join(["a", "b", "c"]), + ] + for bad in garbage: + resp = await client.get(f"/v1/api-keys/{wallet_id}", headers={"X-API-Key": bad}) + assert resp.status_code in (401, 403), (bad, resp.status_code) + + # Non-ASCII material cannot travel in a real header (httpx refuses to + # send it), so exercise it at the unit level: it must be refused, and + # must never raise a 500-class error out of the auth layer. + from fastapi import HTTPException as _HTTPException + + for bad in ("b2a_caf\xe9key", "clé secrète"): + try: + await get_auth_context(api_key=bad) + except _HTTPException as exc: + assert exc.status_code in (401, 403) + else: + raise AssertionError(bad) + + +@pytest.mark.anyio +async def test_access_refresh_type_swap_rejected(client, clean_database, signing_key): + provisioned = await provision_agent_wallet(client) + mint = await client.post( + "/v1/auth/token", + json={"api_key": provisioned["agent_headers"]["X-API-Key"]}, + ) + assert mint.status_code == 200 + tokens = mint.json() + + swap = await client.post( + "/v1/auth/refresh", json={"refresh_token": tokens["access_token"]} + ) + assert swap.status_code == 401 + + as_bearer = await client.get( + "/v1/me/authority", + headers={"Authorization": f"Bearer {tokens['refresh_token']}"}, + ) + assert as_bearer.status_code == 401 + + +@pytest.mark.anyio +async def test_expired_jwt_rejected(signing_key): + private = _decode_test_private_key() + now = datetime.now(timezone.utc) + token = pyjwt.encode( + { + "sub": "wallet_x", + "key_id": "key_x", + "scopes": [], + "iat": now - timedelta(hours=2), + "exp": now - timedelta(hours=1), + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "jti": "jwt-expired", + "type": "access", + }, + private, + algorithm="EdDSA", + ) + with pytest.raises(Exception): + await get_auth_context(api_key=None, authorization=f"Bearer {token}") + + +@pytest.mark.anyio +async def test_key_expiring_mid_validation_consumes_nothing( + client, clean_database, monkeypatch +): + """A use budget key that times out between the liveness read and the + guarded increment must not authenticate and must not spend budget.""" + import app.services.api_key_service as key_module + + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + + created = await key_module.get_api_key_service().create_key( + wallet_id=wallet_id, key_name="boundary", max_uses=5 + ) + expiring_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta( + minutes=30 + ) + factory = get_session_factory() + async with factory() as session: + result = await session.execute( + select(APIKeyModel).where(col(APIKeyModel.key_id) == created["key_id"]) + ) + row = result.scalar_one() + row.expires_at = expiring_at + session.add(row) + await session.commit() + + before = expiring_at - timedelta(minutes=1) + after = expiring_at + timedelta(minutes=1) + ticks = [before, after] + + def scripted_clock(): + return ticks.pop(0) if len(ticks) > 1 else ticks[0] + + monkeypatch.setattr(key_module, "utc_now", scripted_clock) + + assert ( + await key_module.get_api_key_service().validate_key(created["api_key"]) is None + ) + + async with factory() as session: + result = await session.execute( + select(APIKeyModel).where(col(APIKeyModel.key_id) == created["key_id"]) + ) + assert result.scalar_one().use_count == 0 + + +@pytest.mark.anyio +async def test_jwt_cannot_mint_api_keys(client, clean_database, signing_key): + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + mint = await client.post( + "/v1/auth/token", + json={"api_key": provisioned["agent_headers"]["X-API-Key"]}, + ) + assert mint.status_code == 200 + jwt_headers = {"Authorization": f"Bearer {mint.json()['access_token']}"} + + resp = await client.post( + "/v1/api-keys", json={"wallet_id": wallet_id}, headers=jwt_headers + ) + assert resp.status_code == 403 diff --git a/gateway/tests/test_gateway_image_sdk.py b/gateway/tests/test_gateway_image_sdk.py new file mode 100644 index 00000000..1f6ef6ab --- /dev/null +++ b/gateway/tests/test_gateway_image_sdk.py @@ -0,0 +1,132 @@ +"""Gateway images must ship the in-repo SDK that app import loads. + +``app.main`` imports ``mcp_public``, which imports ``b2a_sdk.receipt_verifier`` +at import time. The SDK is not inside ``gateway/``. A build whose context is +only that directory installs requirements.txt, then fails when uvicorn loads +``app.main:app``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from app.routers import mcp_public + +GATEWAY = Path(__file__).resolve().parents[1] +REPO = GATEWAY.parent +_FILE_LOADER = "_agent_middleware_receipt_verifier" + + +def _dockerfile(name: str) -> str: + return (GATEWAY / name).read_text(encoding="utf-8") + + +def test_checkout_still_has_the_verifier_the_fallback_loads(): + assert mcp_public._CHECKOUT_SDK_RECEIPT_VERIFIER.is_file() + assert mcp_public._IMAGE_SDK_RECEIPT_VERIFIER == Path( + "/sdk/python/src/b2a_sdk/receipt_verifier.py" + ) + # Inside the image the module is /app/app/routers/mcp_public.py, so + # parents[3] is /. That is the same file the Dockerfiles copy into place. + container_module = Path("/app/app/routers/mcp_public.py") + derived = ( + container_module.parents[3] + / "sdk" + / "python" + / "src" + / "b2a_sdk" + / "receipt_verifier.py" + ) + assert derived == mcp_public._IMAGE_SDK_RECEIPT_VERIFIER + # On a host checkout the two paths differ, and the repository file wins + # over a stray /sdk tree. In the image they are the same path. + assert mcp_public._receipt_verifier_file_candidates() == ( + mcp_public._CHECKOUT_SDK_RECEIPT_VERIFIER, + mcp_public._IMAGE_SDK_RECEIPT_VERIFIER, + ) + + +def test_image_build_context_contains_the_sdk(): + compose = (GATEWAY / "docker-compose.yml").read_text(encoding="utf-8") + assert "context: .." in compose + assert "dockerfile: gateway/Dockerfile.dev" in compose + context = (GATEWAY / "..").resolve() + assert context == REPO + assert (context / "sdk/python/pyproject.toml").is_file() + assert (context / "sdk/python/src/b2a_sdk/receipt_verifier.py").is_file() + assert (context / "gateway/requirements.txt").is_file() + + +@pytest.mark.parametrize("name", ["Dockerfile", "Dockerfile.dev"]) +def test_images_install_sdk_and_put_it_on_pythonpath(name: str): + text = _dockerfile(name) + sdk_copy = text.index("COPY sdk/python /sdk/python") + requirements = text.index("COPY gateway/requirements.txt /tmp/requirements.txt") + install = text.index("pip install --no-cache-dir /sdk/python") + user = text.index("\nUSER app\n") + assert "PYTHONPATH=/sdk/python/src" in text + assert "COPY gateway/ /app/" in text + assert sdk_copy < install < user + assert requirements < install + + +def test_release_dockerfile_still_requires_the_context_root_stamp(): + text = _dockerfile("Dockerfile") + assert "COPY .build_commit_sha /app/.build_commit_sha" in text + assert "COPY .build_commit_sha" not in _dockerfile("Dockerfile.dev") + + +def test_root_dockerignore_keeps_sdk_sources_and_the_stamp(): + lines = [ + line.strip() + for line in (REPO / ".dockerignore").read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + assert "sdk" not in lines + assert "sdk/python" not in lines + assert "sdk/python/src" not in lines + assert ".build_commit_sha" not in lines + assert "gateway/tests" in lines + assert ".env" in lines + + +def _package_not_installed(_name: str): + raise ImportError("b2a_sdk is not installed") + + +def test_fallback_uses_the_image_file_when_the_package_is_not_installed( + monkeypatch, tmp_path +): + image_copy = tmp_path / "receipt_verifier.py" + image_copy.write_text( + mcp_public._CHECKOUT_SDK_RECEIPT_VERIFIER.read_text(encoding="utf-8") + + "\nMARKER = 'image-layout'\n", + encoding="utf-8", + ) + monkeypatch.setattr(mcp_public.importlib, "import_module", _package_not_installed) + monkeypatch.setattr( + mcp_public, + "_receipt_verifier_file_candidates", + lambda: (image_copy, Path("/no/such/checkout/receipt_verifier.py")), + ) + sys.modules.pop(_FILE_LOADER, None) + + loaded = mcp_public._import_receipt_verifier() + + assert loaded.MARKER == "image-layout" + assert hasattr(loaded, "verify_bundle") + + +def test_fallback_reraises_the_original_import_error_when_no_file_exists(monkeypatch): + monkeypatch.setattr(mcp_public.importlib, "import_module", _package_not_installed) + monkeypatch.setattr( + mcp_public, + "_receipt_verifier_file_candidates", + lambda: (Path("/no/such/image/receipt_verifier.py"),), + ) + + with pytest.raises(ImportError, match="b2a_sdk is not installed"): + mcp_public._import_receipt_verifier() diff --git a/gateway/tests/test_golden_regen_gate.py b/gateway/tests/test_golden_regen_gate.py new file mode 100644 index 00000000..69731a5d --- /dev/null +++ b/gateway/tests/test_golden_regen_gate.py @@ -0,0 +1,45 @@ +"""Unit tests for the golden-file regeneration review gate. + +Golden snapshots pin the bytes that permits and receipts sign. Rewriting +the expected output to make a refactor PR green must stay a conscious, +reviewed act, never a bare env var. These tests pin the gate helper itself; +the snapshot suites call it before touching any golden file. +""" + +from __future__ import annotations + +import pytest + +from tests.support.golden_regen import ( + REGEN_ACK_ENV_VAR, + require_golden_regen_ack, +) + + +def test_no_update_flag_means_no_regeneration(monkeypatch): + monkeypatch.delenv("UPDATE_PERMIT_GOLDEN", raising=False) + monkeypatch.delenv(REGEN_ACK_ENV_VAR, raising=False) + assert require_golden_regen_ack("UPDATE_PERMIT_GOLDEN") is False + + +def test_update_flag_without_ack_fails(monkeypatch): + """UPDATE=1 alone must fail, before any file is rewritten.""" + monkeypatch.setenv("UPDATE_PERMIT_GOLDEN", "1") + monkeypatch.delenv(REGEN_ACK_ENV_VAR, raising=False) + with pytest.raises(pytest.fail.Exception, match="GOLDEN_REGEN_ACK"): + require_golden_regen_ack("UPDATE_PERMIT_GOLDEN") + + +def test_ack_must_be_exact(monkeypatch): + """Near-miss ack values do not count as review.""" + monkeypatch.setenv("UPDATE_RECEIPT_GOLDEN", "1") + for value in ("yes", "true", "0", ""): + monkeypatch.setenv(REGEN_ACK_ENV_VAR, value) + with pytest.raises(pytest.fail.Exception, match="GOLDEN_REGEN_ACK"): + require_golden_regen_ack("UPDATE_RECEIPT_GOLDEN") + + +def test_update_flag_with_ack_allows_regeneration(monkeypatch): + monkeypatch.setenv("UPDATE_PERMIT_GOLDEN", "1") + monkeypatch.setenv(REGEN_ACK_ENV_VAR, "1") + assert require_golden_regen_ack("UPDATE_PERMIT_GOLDEN") is True diff --git a/gateway/tests/test_governed_debit_dispatch_fence.py b/gateway/tests/test_governed_debit_dispatch_fence.py index f33069b1..d9d39325 100644 --- a/gateway/tests/test_governed_debit_dispatch_fence.py +++ b/gateway/tests/test_governed_debit_dispatch_fence.py @@ -11,6 +11,7 @@ from sqlalchemy import func, select from app.core import health +from app.services.ownership import CallerIdentity from app.core.config import get_settings from app.db.database import get_session_factory from app.db.models import ( @@ -126,6 +127,7 @@ async def test_idempotency_checkpoint_conflict_precedes_wallet_mutation( units=Decimal("1"), request_path=ENDPOINT, operation_key=(await _attempt(seed.attempt_id)).idempotency_record_id, + caller=CallerIdentity.admin(), ) assert await _wallet_snapshot(seed.wallet_id) == before @@ -162,6 +164,7 @@ async def test_claimed_dispatch_blocks_debit_and_velocity_mutation( units=Decimal("1"), request_path=ENDPOINT, operation_key=attempt.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert await _wallet_snapshot(seed.wallet_id) == before @@ -182,6 +185,7 @@ async def test_retry_repairs_checkpoint_without_second_debit_or_velocity_increme units=Decimal("1"), request_path=ENDPOINT, operation_key=attempt.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert hasattr(first, "entry_id") after_first = await _wallet_snapshot(seed.wallet_id) @@ -203,6 +207,7 @@ async def test_retry_repairs_checkpoint_without_second_debit_or_velocity_increme units=Decimal("1"), request_path=ENDPOINT, operation_key=attempt.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert replay.entry_id == first.entry_id @@ -249,6 +254,7 @@ async def fail_after_velocity(session, **_kwargs): units=Decimal("1"), request_path=ENDPOINT, operation_key=attempt.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert await _wallet_snapshot(seed.wallet_id) == before @@ -287,6 +293,7 @@ async def assert_committed(wallet: WalletModel) -> None: units=Decimal("1"), request_path=ENDPOINT, operation_key=attempt.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert result.error == "wallet_frozen" diff --git a/gateway/tests/test_governed_persistence.py b/gateway/tests/test_governed_persistence.py index 18c3ce34..f0768a8c 100644 --- a/gateway/tests/test_governed_persistence.py +++ b/gateway/tests/test_governed_persistence.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, AsyncSessionTransaction from app.core.time import utc_now +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import ( HumanApprovalModel, @@ -227,6 +228,7 @@ async def test_operation_key_replays_one_debit_without_recounting_velocity( units=Decimal("2"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) replay = await money.charge( wallet_id=wallet_id, @@ -234,6 +236,7 @@ async def test_operation_key_replays_one_debit_without_recounting_velocity( units=Decimal("2"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) assert first.entry_id == replay.entry_id # type: ignore[union-attr] @@ -277,6 +280,7 @@ async def test_operation_key_rejects_changed_debit_and_cross_wallet_identity( units=Decimal("1"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) with pytest.raises(LedgerOperationConflictError): await money.charge( @@ -285,6 +289,7 @@ async def test_operation_key_rejects_changed_debit_and_cross_wallet_identity( units=Decimal("2"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) with pytest.raises(ValueError, match="ledger_operation_record_not_found"): await money.charge( @@ -293,6 +298,7 @@ async def test_operation_key_rejects_changed_debit_and_cross_wallet_identity( units=Decimal("1"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) factory = get_session_factory() @@ -627,6 +633,7 @@ async def test_dispatch_attempt_state_machine_and_bounded_result( units=Decimal("1"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) attached = await service.attach_charge( attempt_id=prepared.attempt_id, diff --git a/gateway/tests/test_iga_policy.py b/gateway/tests/test_iga_policy.py index 5fd0b59e..be75de6f 100644 --- a/gateway/tests/test_iga_policy.py +++ b/gateway/tests/test_iga_policy.py @@ -1373,7 +1373,7 @@ async def test_remote_insufficient_funds_releases_the_use_before_the_fallible_bu token = _mint(rsa_key, extra={"groups": ["payments-ops"]}) headers = {**setup["agent_headers"], "Authorization": f"Bearer {token}"} - async def _exhausted(self, attempt_id): + async def _exhausted(self, attempt_id, *, caller): raise PermitWriteContendedError() monkeypatch.setattr(PermitService, "release_dispatch_budget_once", _exhausted) diff --git a/gateway/tests/test_kyc.py b/gateway/tests/test_kyc.py index 49c7b746..a1ec1ef8 100644 --- a/gateway/tests/test_kyc.py +++ b/gateway/tests/test_kyc.py @@ -13,6 +13,7 @@ from unittest.mock import patch, MagicMock from uuid import uuid4 from httpx import AsyncClient, ASGITransport +from app.core.config import get_settings from app.main import app from app.services import kyc_service as kyc_service_module @@ -628,7 +629,7 @@ def _stripe_signature_header(payload: bytes, secret: str) -> str: async def pending_identity_session(client, api_headers, sponsor_wallet, monkeypatch): """A sponsor wallet with an open Stripe Identity session and a known secret.""" monkeypatch.setattr( - kyc_service_module.settings, "STRIPE_WEBHOOK_SECRET", IDENTITY_WEBHOOK_SECRET + get_settings(), "STRIPE_WEBHOOK_SECRET", IDENTITY_WEBHOOK_SECRET ) session_id = f"vs_sig_{sponsor_wallet['wallet_id']}" with patch( @@ -668,10 +669,10 @@ async def pending_identity_session(client, api_headers, sponsor_wallet, monkeypa pytest.param("not-a-stripe-signature", id="unparseable-header"), ], ) -async def test_identity_webhook_rejects_bad_signature_with_400( +async def test_identity_webhook_rejects_bad_signature_with_401( client, api_headers, pending_identity_session, signature ): - """A bad signature is a client error (400), not an unhandled 500, and changes nothing.""" + """A bad signature is denied with 401, not an unhandled 500, and changes nothing.""" payload = pending_identity_session["payload"] if signature == "forged": signature = _stripe_signature_header(payload, "whsec_attacker") @@ -682,7 +683,7 @@ async def test_identity_webhook_rejects_bad_signature_with_400( headers={"stripe-signature": signature}, ) - assert resp.status_code == 400 + assert resp.status_code == 401 assert resp.json()["detail"] == "Invalid Stripe signature" status_resp = await client.get( f"/v1/kyc/status/{pending_identity_session['wallet_id']}", @@ -695,7 +696,7 @@ async def test_identity_webhook_rejects_bad_signature_with_400( async def test_identity_webhook_rejects_signature_error_without_dispatch( client, pending_identity_session ): - """stripe.SignatureVerificationError is not a ValueError; it must still map to 400.""" + """stripe.SignatureVerificationError is not a ValueError; it must still map to 401.""" assert not issubclass(stripe.SignatureVerificationError, ValueError) with ( patch( @@ -714,15 +715,15 @@ async def test_identity_webhook_rejects_signature_error_without_dispatch( headers={"stripe-signature": "invalid_sig"}, ) - assert resp.status_code == 400 + assert resp.status_code == 401 mock_verified.assert_not_called() @pytest.mark.anyio -async def test_identity_webhook_missing_signature_is_400(client): +async def test_identity_webhook_missing_signature_is_401(client): resp = await client.post("/v1/webhooks/stripe/identity", content=b"{}") - assert resp.status_code == 400 - assert resp.json()["detail"] == "Missing Stripe signature" + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid Stripe signature" @pytest.mark.anyio diff --git a/gateway/tests/test_late_debit_reconciliation.py b/gateway/tests/test_late_debit_reconciliation.py index 9934c5f6..111762f0 100644 --- a/gateway/tests/test_late_debit_reconciliation.py +++ b/gateway/tests/test_late_debit_reconciliation.py @@ -20,6 +20,7 @@ from sqlalchemy.sql.dml import Update from app.core.time import utc_now +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import BillingAlertModel, WalletModel from app.schemas.billing import ServiceCategory @@ -131,6 +132,7 @@ async def test_delayed_debit_cannot_contradict_completed_no_dispatch_cleanup( units=Decimal("1"), request_path=ENDPOINT, operation_key=prepared.idempotency_record_id, + caller=CallerIdentity.admin(), ) except (LedgerOperationConflictError, ValueError): pass @@ -198,6 +200,7 @@ async def gated_execute(session, statement, *args, **kwargs): units=Decimal("1"), request_path=ENDPOINT, operation_key=prepared.idempotency_record_id, + caller=CallerIdentity.admin(), ) ) try: @@ -276,6 +279,7 @@ async def fail_apply(session, **kwargs): units=Decimal("1"), request_path=ENDPOINT, operation_key=prepared.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert await _wallet_accounting_snapshot(seed.wallet_id) == before assert await _ledger_counts(seed.wallet_id) == (0, 0) @@ -324,6 +328,7 @@ async def verify_committed(wallet): units=Decimal("1"), request_path=ENDPOINT, operation_key=prepared.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert result.error == "wallet_frozen" else: @@ -363,6 +368,7 @@ async def test_debit_after_cleanup_observation_is_captured_or_yields_safely( units=Decimal("1"), request_path=ENDPOINT, operation_key=observed_attempt.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert hasattr(debit, "entry_id") assert await _ledger_counts(seed.wallet_id) == (1, 0) @@ -450,6 +456,7 @@ async def test_existing_debit_is_adopted_after_its_cleanup_refund( units=Decimal("1"), request_path=ENDPOINT, operation_key=prepared.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert replayed_debit.entry_id == seed.ledger_entry_id after = await get_agent_money().get_wallet(seed.wallet_id) @@ -503,6 +510,7 @@ async def observed_complete(**kwargs): units=Decimal("1"), request_path=ENDPOINT, operation_key=prepared.idempotency_record_id, + caller=CallerIdentity.admin(), ) ) cleanup_task = None diff --git a/gateway/tests/test_ledger_write_contention.py b/gateway/tests/test_ledger_write_contention.py index 8a450258..fada7ecf 100644 --- a/gateway/tests/test_ledger_write_contention.py +++ b/gateway/tests/test_ledger_write_contention.py @@ -36,6 +36,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db.database import get_session_factory +from app.services.ownership import CallerIdentity from app.db.models import McpDispatchAttemptModel from app.main import app from app.schemas.billing import ServiceCategory @@ -414,6 +415,7 @@ async def test_invalid_units_are_refused_before_any_retry( service_category=ServiceCategory.AGENT_COMMS, units=Decimal("-1"), operation_key="never-used", + caller=CallerIdentity.admin(), ) with pytest.raises(ValueError, match="finite"): await engine.charge( @@ -421,6 +423,7 @@ async def test_invalid_units_are_refused_before_any_retry( service_category=ServiceCategory.AGENT_COMMS, units=Decimal("Infinity"), operation_key="never-used", + caller=CallerIdentity.admin(), ) monkeypatch.undo() # Refused before a transaction was ever opened. diff --git a/gateway/tests/test_live_owner_waits.py b/gateway/tests/test_live_owner_waits.py index 008d5a67..eec41eb5 100644 --- a/gateway/tests/test_live_owner_waits.py +++ b/gateway/tests/test_live_owner_waits.py @@ -17,6 +17,7 @@ from sqlalchemy import update as sa_update from app.core.config import get_settings +from app.services.ownership import CallerIdentity from app.core.time import utc_now from app.db.database import get_session_factory from app.db.models import McpDispatchAttemptModel @@ -257,6 +258,7 @@ async def test_live_claimed_attempt_inside_global_idle_window_waits_not_steal( / Decimal("1.5"), # AGENT_COMMS pricing is 1.5 credits/unit request_path="/test", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) await dispatch.attach_charge( attempt_id=attempt.attempt_id, diff --git a/gateway/tests/test_mcp_dispatch_breaks.py b/gateway/tests/test_mcp_dispatch_breaks.py new file mode 100644 index 00000000..5e6b832d --- /dev/null +++ b/gateway/tests/test_mcp_dispatch_breaks.py @@ -0,0 +1,409 @@ +"""Adversarial checks for one upstream MCP dispatch. + +A paid tools/call must stand on its own response. The SDK client helper +also sends tools/list when it has not cached an output schema, and that +second request runs only after the tool has already succeeded. These +tests use the real client session against a scripted upstream so a +hostile follow-up cannot be hidden by a fake session. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Any + +import httpx +import pytest + +from app.services.upstream_mcp import ( + DiscoveredUpstreamTool, + UpstreamMcpAdapter, + UpstreamMcpConfiguration, + UpstreamMcpDeliveryUncertainError, + UpstreamMcpResponseRejectedError, + UpstreamMcpReturnedError, +) +from app.core.config import Settings + + +async def _global_resolver(_host: str, _port: int | None) -> Sequence[str]: + return ("93.184.216.34",) + + +def _configuration() -> UpstreamMcpConfiguration: + return UpstreamMcpConfiguration.from_settings( + Settings( + _env_file=None, + ENVIRONMENT="local", + MCP_UPSTREAM_ENABLED=True, + MCP_UPSTREAM_URL="https://partner.example/mcp", + MCP_UPSTREAM_TOOL_NAME="partner.write", + MCP_UPSTREAM_PUBLIC_TOOL_ID="partner.notes.write", + MCP_UPSTREAM_BEARER_TOKEN="secret-partner-token", + MCP_UPSTREAM_CREDITS_PER_CALL="7.5", + MCP_UPSTREAM_CALL_TIMEOUT_SECONDS=3.0, + MCP_UPSTREAM_MAX_RESPONSE_BYTES=4096, + ) + ) + + +def _scripted_upstream( + follow_up: str, + *, + tool_result: dict[str, Any] | None = None, + call_failure: str | None = None, + trace: list[str] | None = None, +) -> tuple[httpx.AsyncClient, list[dict[str, Any]]]: + """Script initialize, the tool call, and a hostile tools/list. + + ``follow_up`` is what a second tools/list would receive. The paid + call is only correct when that request is never sent. + """ + seen: list[dict[str, Any]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads((await request.aread()).decode("utf-8")) + method = payload.get("method") + if trace is not None: + trace.append(method) + seen.append( + { + "method": method, + "session_id": request.headers.get("mcp-session-id"), + "params": payload.get("params"), + } + ) + if payload.get("id") is None: + return httpx.Response(202) + if method == "initialize": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": {"name": "partner", "version": "1.0"}, + }, + } + return httpx.Response( + 200, + headers={ + "Content-Type": "application/json", + "mcp-session-id": "partner-session-1", + }, + content=json.dumps(body).encode("utf-8"), + ) + if method == "tools/call": + if call_failure == "transport": + raise httpx.ReadTimeout("upstream timeout") + if request.headers.get("mcp-session-id") != "partner-session-1": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "error": { + "code": -32000, + "message": "session required", + }, + } + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + content=json.dumps(body).encode("utf-8"), + ) + if call_failure == "invalid_json": + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + content=b"{broken", + ) + result = tool_result or { + "content": [{"type": "text", "text": "paid-result"}], + "structuredContent": {"ok": True}, + "isError": False, + } + if call_failure == "secret": + result = { + "content": [{"type": "text", "text": "secret-partner-token"}], + "structuredContent": {"ok": True}, + "isError": False, + } + if call_failure == "oversized": + result = { + "content": [{"type": "text", "text": "x" * 8000}], + "structuredContent": {"ok": True}, + "isError": False, + } + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": result, + } + return httpx.Response( + 200, + headers={ + "Content-Type": "application/json", + "mcp-session-id": "partner-session-1", + }, + content=json.dumps(body).encode("utf-8"), + ) + if method == "tools/list": + if follow_up == "unknown_method": + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "error": {"code": -32601, "message": "Method not found"}, + } + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + content=json.dumps(body).encode("utf-8"), + ) + # This unsolicited list can advertise a changed schema after + # the call. It must not replace the schema found at discovery. + body = { + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "tools": [ + { + "name": "partner.write", + "inputSchema": {"type": "object"}, + "outputSchema": { + "type": "object", + "required": [ + "different" + if follow_up == "output_schema" + else "ok" + ], + }, + } + ] + }, + } + encoded = json.dumps(body).encode("utf-8") + if follow_up == "oversized": + encoded = encoded + (b" " * 8000) + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + content=encoded, + ) + raise AssertionError(f"unexpected MCP method: {method}") + + client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + headers={"Authorization": "Bearer secret-partner-token"}, + follow_redirects=False, + ) + return client, seen + + +def _mark_discovered( + adapter: UpstreamMcpAdapter, *, output_schema: dict[str, Any] | None +) -> None: + adapter._discovered_tool = DiscoveredUpstreamTool( + name="partner.write", + description="Partner write", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "follow_up", + ["unknown_method", "output_schema", "oversized"], +) +async def test_paid_tool_call_ignores_a_hostile_follow_up_list( + follow_up: str, +) -> None: + trace: list[str] = [] + client, seen = _scripted_upstream(follow_up, trace=trace) + adapter = UpstreamMcpAdapter( + _configuration(), + http_client=client, + resolver=_global_resolver, + ) + _mark_discovered(adapter, output_schema={"type": "object", "required": ["ok"]}) + claimed = False + + async def before_dispatch() -> None: + nonlocal claimed + claimed = True + trace.append("claim") + + try: + result = await adapter.call_tool( + {"note": "hello"}, + invocation_id="inv-paid", + idempotency_key="idem-paid", + before_dispatch=before_dispatch, + ) + finally: + await client.aclose() + + methods = [entry["method"] for entry in seen] + assert claimed is True + assert trace.index("claim") < trace.index("tools/call") + assert methods == [ + "initialize", + "notifications/initialized", + "tools/call", + ] + call = next(entry for entry in seen if entry["method"] == "tools/call") + assert call["session_id"] == "partner-session-1" + assert call["params"]["_meta"] == { + "io.agentmiddleware/invocation_id": "inv-paid", + "io.agentmiddleware/idempotency_key": "idem-paid", + } + assert result.payload["content"] == [{"type": "text", "text": "paid-result"}] + assert result.payload["structuredContent"] == {"ok": True} + assert result.payload["isError"] is False + assert result.is_error is False + + +@pytest.mark.anyio +async def test_tool_without_advertised_output_schema_keeps_text_result() -> None: + tool_result = { + "content": [{"type": "text", "text": "paid-result"}], + "isError": False, + } + client, seen = _scripted_upstream("unknown_method", tool_result=tool_result) + adapter = UpstreamMcpAdapter( + _configuration(), http_client=client, resolver=_global_resolver + ) + _mark_discovered(adapter, output_schema=None) + + async def before_dispatch() -> None: + return None + + try: + result = await adapter.call_tool( + {}, + invocation_id="inv-no-schema", + idempotency_key="idem-no-schema", + before_dispatch=before_dispatch, + ) + finally: + await client.aclose() + + assert result.payload == tool_result + assert [entry["method"] for entry in seen].count("tools/call") == 1 + assert "tools/list" not in [entry["method"] for entry in seen] + + +@pytest.mark.anyio +@pytest.mark.parametrize("structured", [None, {"wrong": True}]) +async def test_known_output_schema_rejects_missing_or_invalid_content( + structured: dict[str, Any] | None, +) -> None: + tool_result: dict[str, Any] = { + "content": [{"type": "text", "text": "paid-result"}], + "isError": False, + } + if structured is not None: + tool_result["structuredContent"] = structured + client, seen = _scripted_upstream("unknown_method", tool_result=tool_result) + adapter = UpstreamMcpAdapter( + _configuration(), http_client=client, resolver=_global_resolver + ) + _mark_discovered(adapter, output_schema={"type": "object", "required": ["ok"]}) + + async def before_dispatch() -> None: + return None + + try: + # The SDK raises after receiving a result that fails its discovered + # schema. The adapter's existing error contract is delivery-uncertain. + with pytest.raises(UpstreamMcpDeliveryUncertainError): + await adapter.call_tool( + {}, + invocation_id="inv-invalid-output", + idempotency_key="idem-invalid-output", + before_dispatch=before_dispatch, + ) + finally: + await client.aclose() + + methods = [entry["method"] for entry in seen] + assert methods.count("tools/call") == 1 + assert "tools/list" not in methods + + +@pytest.mark.anyio +async def test_tool_level_error_keeps_its_error_result_with_known_schema() -> None: + tool_result = { + "content": [{"type": "text", "text": "partner rejected"}], + "isError": True, + } + client, seen = _scripted_upstream("unknown_method", tool_result=tool_result) + adapter = UpstreamMcpAdapter( + _configuration(), http_client=client, resolver=_global_resolver + ) + _mark_discovered(adapter, output_schema={"type": "object", "required": ["ok"]}) + + async def before_dispatch() -> None: + return None + + try: + with pytest.raises(UpstreamMcpReturnedError) as exc_info: + await adapter.call_tool( + {}, + invocation_id="inv-error", + idempotency_key="idem-error", + before_dispatch=before_dispatch, + ) + finally: + await client.aclose() + + assert exc_info.value.result.payload == tool_result + assert [entry["method"] for entry in seen].count("tools/call") == 1 + assert "tools/list" not in [entry["method"] for entry in seen] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("failure", "error_type", "code"), + [ + ("oversized", UpstreamMcpResponseRejectedError, "upstream_response_too_large"), + ("secret", UpstreamMcpResponseRejectedError, "upstream_response_rejected"), + ( + "invalid_json", + UpstreamMcpDeliveryUncertainError, + "upstream_delivery_uncertain", + ), + ("transport", UpstreamMcpDeliveryUncertainError, "upstream_delivery_uncertain"), + ], +) +async def test_failed_call_preserves_dispatch_classification_without_relisting( + failure: str, error_type: type[Exception], code: str +) -> None: + trace: list[str] = [] + client, seen = _scripted_upstream( + "unknown_method", call_failure=failure, trace=trace + ) + adapter = UpstreamMcpAdapter( + _configuration(), http_client=client, resolver=_global_resolver + ) + _mark_discovered(adapter, output_schema={"type": "object", "required": ["ok"]}) + + async def before_dispatch() -> None: + trace.append("claim") + + try: + with pytest.raises(error_type) as exc_info: + await adapter.call_tool( + {}, + invocation_id=f"inv-{failure}", + idempotency_key=f"idem-{failure}", + before_dispatch=before_dispatch, + ) + finally: + await client.aclose() + + assert exc_info.value.code == code + methods = [entry["method"] for entry in seen] + assert trace.index("claim") < trace.index("tools/call") + assert methods.count("tools/call") == 1 + assert "tools/list" not in methods diff --git a/gateway/tests/test_mcp_dispatch_claim.py b/gateway/tests/test_mcp_dispatch_claim.py index daa3697f..e9a821e4 100644 --- a/gateway/tests/test_mcp_dispatch_claim.py +++ b/gateway/tests/test_mcp_dispatch_claim.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSessionTransaction from app.db.database import get_session_factory +from app.services.ownership import CallerIdentity from app.db.models import LedgerEntryModel, McpDispatchAttemptModel, PermitModel from app.main import app from app.schemas.billing import ServiceCategory @@ -74,6 +75,7 @@ async def _prepare_attempt( units=Decimal("1"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) attempt = await service.attach_charge( attempt_id=attempt.attempt_id, @@ -488,6 +490,7 @@ async def test_effect_free_abandon_refuses_an_operation_with_a_debit( units=Decimal("1"), request_path="/mcp/messages", operation_key=attempt.idempotency_record_id, + caller=CallerIdentity.admin(), ) assert debit.entry_id diff --git a/gateway/tests/test_mcp_dispatch_evidence.py b/gateway/tests/test_mcp_dispatch_evidence.py index 75224313..b2a2e2d1 100644 --- a/gateway/tests/test_mcp_dispatch_evidence.py +++ b/gateway/tests/test_mcp_dispatch_evidence.py @@ -8,6 +8,7 @@ from httpx import ASGITransport, AsyncClient from app.db.database import get_session_factory +from app.services.ownership import CallerIdentity from app.db.models import ( ControlPlaneAuditEventModel, McpDispatchAttemptModel, @@ -74,6 +75,7 @@ async def _seed_dispatch_receipt( units=Decimal("1"), request_path="/mcp/messages", operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) assert charge is not None await dispatch_service.attach_charge( @@ -202,6 +204,7 @@ async def test_legacy_receipt_skips_dispatch_linkage_without_invalidating_eviden service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/mcp/messages", + caller=CallerIdentity.admin(), ) assert charge is not None audit_event = await record_audit_event( diff --git a/gateway/tests/test_mcp_dispatch_reconciliation.py b/gateway/tests/test_mcp_dispatch_reconciliation.py index 61fa4c46..1610a300 100644 --- a/gateway/tests/test_mcp_dispatch_reconciliation.py +++ b/gateway/tests/test_mcp_dispatch_reconciliation.py @@ -11,7 +11,9 @@ from httpx import ASGITransport, AsyncClient from sqlalchemy import func, select +from app.core.config import DuplicateGuardMode, get_settings from app.core.time import utc_now +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import ( ControlPlaneAuditEventModel, @@ -23,6 +25,7 @@ ) from app.main import app from app.schemas.billing import ServiceCategory +from app.schemas.trust import ReceiptResponse from app.services.agent_money import get_agent_money from app.services.audit_chain import verify_audit_chain from app.services.audit_log import record_audit_event @@ -33,6 +36,7 @@ from app.services.mcp_dispatch_attempts import ( DISPATCH_CLAIMED, DispatchClaimUnavailableError, + DispatchAttemptError, DispatchAttemptContext, McpDispatchAttemptService, get_mcp_dispatch_attempt_service, @@ -42,8 +46,12 @@ get_mcp_dispatch_reconciliation_service, ) from app.services.permits import PermitError, get_permit_service -from app.services.receipts import get_receipt_service -from tests.test_trust_helpers import create_tool_permit, provision_agent_wallet +from app.services.receipts import ReceiptError, ReceiptService, get_receipt_service +from tests.test_trust_helpers import ( + BOOTSTRAP_HEADERS, + create_tool_permit, + provision_agent_wallet, +) CREDITS = Decimal("1.5") ENDPOINT = "/mcp/messages" @@ -82,17 +90,39 @@ async def _seed_attempt( requires_human_approval: bool = False, create_charge: bool = True, claim_dispatch: bool = True, + max_calls_per_tool: int | None = None, ) -> SeededAttempt: provisioned = await provision_agent_wallet(client) tool_name = f"reconcile-{suffix}" - permit = await create_tool_permit( - client, - wallet_id=provisioned["agent_wallet_id"], - key_id=provisioned["key_id"], - tool_name=tool_name, - idem_key=f"reconcile-permit-{suffix}", - requires_human_approval=requires_human_approval, - ) + if max_calls_per_tool is None: + permit = await create_tool_permit( + client, + wallet_id=provisioned["agent_wallet_id"], + key_id=provisioned["key_id"], + tool_name=tool_name, + idem_key=f"reconcile-permit-{suffix}", + requires_human_approval=requires_human_approval, + ) + else: + permit_response = await client.post( + "/v1/permits", + json={ + "issuer_wallet_id": provisioned["agent_wallet_id"], + "subject_wallet_id": provisioned["agent_wallet_id"], + "subject_key_id": provisioned["key_id"], + "allowed_tools": [tool_name], + "scopes": [f"tool:{tool_name}:invoke", "billing:charge"], + "max_credits": 50, + "max_calls_per_tool": {tool_name: max_calls_per_tool}, + "expires_at": (utc_now() + timedelta(minutes=30)).isoformat(), + }, + headers={ + **BOOTSTRAP_HEADERS, + "Idempotency-Key": f"reconcile-permit-{suffix}", + }, + ) + assert permit_response.status_code == 201 + permit = permit_response.json() if not requires_human_approval: validation = await get_permit_service().authorize_and_reserve( permit_id=permit["permit_id"], @@ -164,6 +194,7 @@ async def _seed_attempt( units=Decimal("1"), request_path=ENDPOINT, operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) assert hasattr(charge, "entry_id") ledger_entry_id = charge.entry_id @@ -250,6 +281,64 @@ async def _replay(seed: SeededAttempt) -> tuple[dict[str, Any], int]: return json.loads(record.response_json), record.status_code +async def _seed_receipt_for_adoption( + client: AsyncClient, + *, + suffix: str, +) -> tuple[ + McpDispatchAttemptModel, + dict[str, Any], + str, + ReceiptResponse, + dict[str, Any], +]: + result_payload = {"error": "wallet_expired"} + seed = await _seed_attempt( + client, + suffix=suffix, + state="returned_error", + result_payload=result_payload, + error_code="wallet_expired", + idempotency_endpoint=GOVERNED_MCP_IDEMPOTENCY_ENDPOINT, + create_charge=False, + claim_dispatch=False, + ) + attempt = await _attempt(seed.attempt_id) + audit = await record_audit_event( + event="mcp.invoke", + wallet_id=seed.wallet_id, + tool=seed.tool_name, + endpoint=ENDPOINT, + auth_source="governed_dispatch", + key_id=seed.key_id, + request_id=attempt.attempt_id, + ok=False, + error="wallet_expired", + metadata={"jev_risk_guard": {"tier": "high", "signal": {"safe": True}}}, + ) + constraints = {"policy": {"quota": 1, "flags": ["a", "b"]}} + receipt = await get_receipt_service().create_receipt( + idempotency_record_id=attempt.idempotency_record_id, + dispatch_attempt_id=attempt.attempt_id, + response_hash_override=attempt.response_hash, + permit_id=attempt.permit_id, + wallet_id=attempt.wallet_id, + key_id=attempt.key_id, + tool=attempt.public_tool_id, + request_payload=None, + request_hash=attempt.request_hash, + response_payload=result_payload, + ledger_entry_id=attempt.ledger_entry_id, + credits_authorized=attempt.credits_authorized, + credits_charged=Decimal("0"), + outcome="failed_refunded", + audit_event_id=audit.event_id, + reason_code="wallet_expired", + constraints_evaluated=constraints, + ) + return attempt, result_payload, audit.event_id, receipt, constraints + + @pytest.mark.anyio async def test_effect_free_stale_mcp_identity_is_released_for_safe_retry( client: AsyncClient, @@ -985,6 +1074,231 @@ async def test_existing_receipt_missing_idempotency_completion_replays_full_resu assert int(audit_count or 0) == 1 +@pytest.mark.anyio +async def test_existing_receipt_adoption_matches_effective_jev_constraints( + client: AsyncClient, + clean_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.LOG) + ( + attempt, + result_payload, + audit_event_id, + receipt, + constraints, + ) = await _seed_receipt_for_adoption(client, suffix="adopt-jev-constraints") + reconciler = McpDispatchReconciliationService() + assert receipt.constraints_evaluated == { + **constraints, + "jev_risk_guard": {"tier": "high", "signal": {"safe": True}}, + } + + # Key order is irrelevant, and the signed audit evidence replaces a + # caller-supplied JEV value on both creation and replay. + matching = { + "jev_risk_guard": {"tier": "forged"}, + "policy": {"flags": ["a", "b"], "quota": 1}, + } + adopted = await reconciler._get_or_create_receipt( + attempt=attempt, + result_payload=result_payload, + outcome="failed_refunded", + audit_event_id=audit_event_id, + constraints_evaluated=matching, + ) + assert adopted.receipt_id == receipt.receipt_id + + for changed in ( + {"policy": {"quota": 2, "flags": ["a", "b"]}}, + {"policy": {"quota": True, "flags": ["a", "b"]}}, + ): + with pytest.raises( + DispatchAttemptError, match="^dispatch_receipt_linkage_conflict$" + ): + await reconciler._get_or_create_receipt( + attempt=attempt, + result_payload=result_payload, + outcome="failed_refunded", + audit_event_id=audit_event_id, + constraints_evaluated=changed, + ) + async with get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(ReceiptModel)) == 1 + valid, reason, _ = await get_receipt_service().verify_receipt(receipt.receipt_id) + assert (valid, reason) == (True, None) + + +@pytest.mark.anyio +async def test_existing_jev_receipt_adopts_after_guard_is_disabled( + client: AsyncClient, + clean_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.LOG) + ( + attempt, + result_payload, + audit_event_id, + receipt, + constraints, + ) = await _seed_receipt_for_adoption(client, suffix="adopt-jev-after-off") + assert receipt.constraints_evaluated["jev_risk_guard"] == { + "tier": "high", + "signal": {"safe": True}, + } + valid, reason, _ = await get_receipt_service().verify_receipt(receipt.receipt_id) + assert (valid, reason) == (True, None) + + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.OFF) + adopted = await McpDispatchReconciliationService()._get_or_create_receipt( + attempt=attempt, + result_payload=result_payload, + outcome="failed_refunded", + audit_event_id=audit_event_id, + constraints_evaluated=constraints, + ) + + assert adopted.receipt_id == receipt.receipt_id + assert adopted.constraints_evaluated == receipt.constraints_evaluated + async with get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(ReceiptModel)) == 1 + + +@pytest.mark.anyio +async def test_receipt_race_adoption_rejects_invalid_signature( + client: AsyncClient, + clean_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.LOG) + ( + attempt, + result_payload, + audit_event_id, + receipt, + constraints, + ) = await _seed_receipt_for_adoption(client, suffix="race-invalid-signature") + async with get_session_factory()() as session: + async with session.begin(): + row = await session.get(ReceiptModel, receipt.receipt_id) + assert row is not None + row.signature = "invalid-signature" + receipt_service = ReceiptService() + original_get = receipt_service.get_receipt_by_idempotency_record_id + reads = 0 + creates = 0 + + async def read_after_race(record_id: str) -> ReceiptResponse | None: + nonlocal reads + reads += 1 + return None if reads == 1 else await original_get(record_id) + + async def lose_create_race(**kwargs: Any) -> ReceiptResponse: + nonlocal creates + creates += 1 + raise ReceiptError("receipt_idempotency_conflict") + + monkeypatch.setattr( + receipt_service, "get_receipt_by_idempotency_record_id", read_after_race + ) + monkeypatch.setattr(receipt_service, "create_receipt", lose_create_race) + reconciler = McpDispatchReconciliationService(receipt_service=receipt_service) + with pytest.raises( + DispatchAttemptError, match="^dispatch_receipt_signature_invalid$" + ): + await reconciler._get_or_create_receipt( + attempt=attempt, + result_payload=result_payload, + outcome="failed_refunded", + audit_event_id=audit_event_id, + constraints_evaluated=constraints, + ) + assert (reads, creates) == (2, 1) + + +@pytest.mark.anyio +async def test_v2_dispatch_receipt_without_constraints_recovers_idempotency( + client: AsyncClient, + clean_database, +) -> None: + result_payload = {"error": "wallet_expired"} + seed = await _seed_attempt( + client, + suffix="v2-direct-receipt-before-idem-complete", + state="returned_error", + result_payload=result_payload, + error_code="wallet_expired", + idempotency_endpoint=GOVERNED_MCP_IDEMPOTENCY_ENDPOINT, + create_charge=False, + claim_dispatch=False, + max_calls_per_tool=5, + ) + attempt = await _attempt(seed.attempt_id) + reconciler = McpDispatchReconciliationService() + assert await reconciler._permit_constraints_snapshot(seed.permit_id) == { + "max_calls_per_tool": {seed.tool_name: 5} + } + audit = await record_audit_event( + event="mcp.invoke", + wallet_id=seed.wallet_id, + tool=seed.tool_name, + endpoint=ENDPOINT, + auth_source="governed_dispatch", + key_id=seed.key_id, + request_id=attempt.attempt_id, + ok=False, + error="wallet_expired", + metadata={ + "transport": "jsonrpc", + "permit_id": seed.permit_id, + "request_hash": attempt.request_hash, + "ledger_entry_id": None, + "dispatch_attempt_id": attempt.attempt_id, + "dispatch_state": attempt.state, + "dispatch_response_hash": attempt.response_hash, + "upstream_tool_name": attempt.upstream_tool_name, + "upstream_origin": attempt.upstream_origin, + }, + ) + # The previous direct denial finalizer signed this linked receipt without + # the v2 snapshot. A crash before idempotency completion must still adopt + # the valid receipt instead of leaving the key permanently in progress. + receipt = await get_receipt_service().create_receipt( + idempotency_record_id=attempt.idempotency_record_id, + dispatch_attempt_id=attempt.attempt_id, + response_hash_override=attempt.response_hash, + permit_id=attempt.permit_id, + wallet_id=attempt.wallet_id, + key_id=attempt.key_id, + tool=attempt.public_tool_id, + request_payload=None, + request_hash=attempt.request_hash, + response_payload=result_payload, + ledger_entry_id=None, + credits_authorized=attempt.credits_authorized, + credits_charged=Decimal("0"), + outcome="failed_refunded", + audit_event_id=audit.event_id, + reason_code="wallet_expired", + ) + assert receipt.constraints_evaluated == {} + valid, reason, _ = await get_receipt_service().verify_receipt(receipt.receipt_id) + assert (valid, reason) == (True, None) + record = await get_idempotency_service().get_record( + wallet_id=seed.wallet_id, + endpoint=seed.idempotency_endpoint, + idempotency_key=seed.idempotency_key, + ) + assert record is not None and record.response_json is None + + await reconciler.reconcile_attempt(seed.attempt_id) + + replay, status = await _replay(seed) + assert status == 403 + assert replay["receipt"]["receipt_id"] == receipt.receipt_id + + @pytest.mark.anyio async def test_terminal_returned_error_completes_crash_compensation( client: AsyncClient, @@ -1105,15 +1419,20 @@ async def test_crash_after_budget_release_does_not_release_unrelated_reservation await get_agent_money().refund_charge( wallet_id=seed.wallet_id, charge_entry_id=seed.ledger_entry_id or "", + caller=CallerIdentity.admin(), ) await dispatch.mark_debit_refunded( attempt_id=seed.attempt_id, ledger_entry_id=seed.ledger_entry_id or "", ) - released = await get_permit_service().release_dispatch_budget_once(seed.attempt_id) + released = await get_permit_service().release_dispatch_budget_once( + seed.attempt_id, caller=CallerIdentity.admin() + ) assert released is True # A later reservation on the same permit must survive reconciliation. - await get_permit_service().reserve_budget(seed.permit_id, Decimal("2")) + await get_permit_service().reserve_budget( + seed.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) await _make_stale(seed.attempt_id) result = await get_mcp_dispatch_reconciliation_service().reconcile(idle_seconds=300) @@ -1374,8 +1693,12 @@ async def test_concurrent_budget_release_decrements_exactly_once( spent_before = before.spent_credits results = await asyncio.gather( - permits.release_dispatch_budget_once(seed.attempt_id), - permits.release_dispatch_budget_once(seed.attempt_id), + permits.release_dispatch_budget_once( + seed.attempt_id, caller=CallerIdentity.admin() + ), + permits.release_dispatch_budget_once( + seed.attempt_id, caller=CallerIdentity.admin() + ), return_exceptions=True, ) ok = [r for r in results if r is True] @@ -1389,7 +1712,12 @@ async def test_concurrent_budget_release_decrements_exactly_once( assert after.spent_credits == spent_before - CREDITS # A third call is still a no-op. - assert await permits.release_dispatch_budget_once(seed.attempt_id) is False + assert ( + await permits.release_dispatch_budget_once( + seed.attempt_id, caller=CallerIdentity.admin() + ) + is False + ) final = await permits.get_permit(seed.permit_id) assert final is not None and final.spent_credits == after.spent_credits @@ -1448,7 +1776,7 @@ async def execute(self, *args, **kwargs): permits_module, "get_session_factory", lambda: real_factory ) state["second_result"] = await permits.release_dispatch_budget_once( - seed.attempt_id + seed.attempt_id, caller=CallerIdentity.admin() ) return await self._inner.execute(*args, **kwargs) @@ -1468,7 +1796,9 @@ async def __aexit__(self, *exc): lambda: lambda: _StaleReadFactory(real_factory()), ) - first_result = await permits.release_dispatch_budget_once(seed.attempt_id) + first_result = await permits.release_dispatch_budget_once( + seed.attempt_id, caller=CallerIdentity.admin() + ) assert state["fired"], "the interleave never ran — the test proved nothing" # Exactly one caller may claim the release. @@ -1486,7 +1816,9 @@ async def test_release_dispatch_budget_rejects_a_missing_attempt( ) -> None: """An unknown attempt id must not move budget.""" with pytest.raises(PermitError) as excinfo: - await get_permit_service().release_dispatch_budget_once("att-does-not-exist") + await get_permit_service().release_dispatch_budget_once( + "att-does-not-exist", caller=CallerIdentity.admin() + ) assert excinfo.value.reason == "dispatch_attempt_not_found" @@ -1512,7 +1844,9 @@ async def test_release_dispatch_budget_rejects_a_non_terminal_attempt( assert before is not None with pytest.raises(PermitError) as excinfo: - await permits.release_dispatch_budget_once(seed.attempt_id) + await permits.release_dispatch_budget_once( + seed.attempt_id, caller=CallerIdentity.admin() + ) assert excinfo.value.reason == "dispatch_budget_release_state_invalid" # And no budget moved on the way to the refusal. diff --git a/gateway/tests/test_mcp_postgres_multiprocess.py b/gateway/tests/test_mcp_postgres_multiprocess.py index f963e77f..28d7127a 100644 --- a/gateway/tests/test_mcp_postgres_multiprocess.py +++ b/gateway/tests/test_mcp_postgres_multiprocess.py @@ -33,6 +33,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection from app.core.trust_mode import is_production_like_environment +from tests.support.db_gate import require_opt_in from tests.test_trust_helpers import create_tool_permit, provision_agent_wallet @@ -115,11 +116,13 @@ class UpstreamSnapshot: def _require_explicit_isolation() -> str: - if os.environ.get("RUN_MCP_MULTIPROCESS_TESTS") != "1": - pytest.skip( + require_opt_in( + flag="RUN_MCP_MULTIPROCESS_TESTS", + reason=( "set RUN_MCP_MULTIPROCESS_TESTS=1 only for the isolated " "PostgreSQL multiprocess harness" - ) + ), + ) if os.environ.get("MCP_STRESS_DB_ISOLATED") != "1": pytest.fail( "MCP_STRESS_DB_ISOLATED=1 is required; refusing to inspect or " diff --git a/gateway/tests/test_mcp_router_breaks.py b/gateway/tests/test_mcp_router_breaks.py new file mode 100644 index 00000000..eedf303b --- /dev/null +++ b/gateway/tests/test_mcp_router_breaks.py @@ -0,0 +1,352 @@ +"""Adversarial probes for the MCP HTTP routers. + +Named cases: a malformed initialize, a tool call with no session, an +oversized body, and an unknown method. Refusals must stay client errors. +A refusal must not run a governed tool. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.config import get_settings +from app.main import app +from app.schemas.billing import ServiceCategory +from app.services.service_registry import get_service_registry +from tests.test_trust_helpers import BOOTSTRAP_HEADERS, provision_agent_wallet + +MCP_HEADERS = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", +} +BOOTSTRAP_MCP_HEADERS = {**BOOTSTRAP_HEADERS, **MCP_HEADERS} +TOOL = "router-break.effect" + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app, raise_app_exceptions=False) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def standard_mcp_enabled(monkeypatch): + monkeypatch.setenv("ENABLE_STANDARD_MCP_ENDPOINT", "true") + get_settings.cache_clear() + yield + monkeypatch.setenv("ENABLE_STANDARD_MCP_ENDPOINT", "false") + get_settings.cache_clear() + + +@pytest.fixture +def effect_tool(): + calls: list[Any] = [] + + def effect(**kwargs: Any) -> dict[str, Any]: + calls.append(kwargs) + return {"ok": True} + + registry = get_service_registry() + registry.register_local( + service_id=TOOL, + name="Router break effect", + description="Must not run on a refused MCP message", + category=ServiceCategory.AGENT_COMMS, + func=effect, + credits_per_unit=2.0, + unit_name="call", + ) + try: + yield calls + finally: + registry.unregister_local(TOOL) + + +def _initialize(params: Any, request_id: Any = 1) -> dict[str, Any]: + body: dict[str, Any] = { + "jsonrpc": "2.0", + "method": "initialize", + "params": params, + } + if request_id is not None: + body["id"] = request_id + return body + + +_VALID_INIT_PARAMS = { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "probe", "version": "0"}, +} + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "body", + [ + b"", + b"null", + b"[]", + b"{", + b"\xff", + b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":NaN,"capabilities":{},"clientInfo":{"name":"p","version":"0"}}}', + json.dumps( + _initialize( + { + "protocolVersion": None, + "capabilities": {}, + "clientInfo": {"name": "p", "version": "0"}, + } + ) + ).encode(), + json.dumps( + _initialize( + { + "protocolVersion": True, + "capabilities": {}, + "clientInfo": {"name": "p", "version": "0"}, + } + ) + ).encode(), + json.dumps( + _initialize( + { + "protocolVersion": 1.5, + "capabilities": {}, + "clientInfo": {"name": "p", "version": "0"}, + } + ) + ).encode(), + json.dumps( + _initialize( + { + "protocolVersion": {"nested": "2025-06-18"}, + "capabilities": {}, + "clientInfo": {"name": "p", "version": "0"}, + } + ) + ).encode(), + json.dumps( + _initialize( + { + "protocolVersion": ["2025-06-18"], + "capabilities": {}, + "clientInfo": {"name": "p", "version": "0"}, + } + ) + ).encode(), + json.dumps( + _initialize( + { + "protocolVersion": "", + "capabilities": [], + "clientInfo": "nope", + } + ) + ).encode(), + json.dumps(_initialize(None)).encode(), + json.dumps(_initialize([])).encode(), + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode(), + json.dumps(_initialize(_VALID_INIT_PARAMS, request_id=None)).encode(), + json.dumps(_initialize(_VALID_INIT_PARAMS, request_id=True)).encode(), + json.dumps(_initialize(_VALID_INIT_PARAMS, request_id=[1])).encode(), + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": None, + "params": _VALID_INIT_PARAMS, + } + ).encode(), + json.dumps( + {"jsonrpc": "1.0", "id": 1, "method": "initialize", "params": {}} + ).encode(), + ], +) +async def test_malformed_initialize_is_not_a_server_error( + client, standard_mcp_enabled, effect_tool, body: bytes +): + response = await client.post( + "/mcp", + content=body, + headers=BOOTSTRAP_MCP_HEADERS, + ) + assert response.status_code < 500, response.text + assert effect_tool == [] + + +@pytest.mark.anyio +async def test_tool_call_without_session_does_not_skip_auth_or_crash( + client, standard_mcp_enabled, clean_database, effect_tool +): + provisioned = await provision_agent_wallet(client) + wallet_headers = {**provisioned["agent_headers"], **MCP_HEADERS} + call = { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": TOOL, "arguments": {"text": "run"}}, + } + + missing_key = await client.post( + "/mcp", + json=call, + headers={**MCP_HEADERS, "Mcp-Session-Id": "forged-session"}, + ) + assert missing_key.status_code == 401 + assert effect_tool == [] + + no_session = await client.post("/mcp", json=call, headers=wallet_headers) + assert no_session.status_code < 500, no_session.text + assert "Mcp-Session-Id" not in no_session.headers + assert ( + no_session.json()["result"]["receipt"]["wallet_id"] + == (provisioned["agent_wallet_id"]) + ) + assert effect_tool == [{"text": "run"}] + + forged = await client.post( + "/mcp", + json={**call, "id": 8}, + headers={**wallet_headers, "Mcp-Session-Id": "forged-session"}, + ) + assert forged.status_code < 500, forged.text + assert ( + forged.json()["result"]["receipt"]["wallet_id"] + == (provisioned["agent_wallet_id"]) + ) + assert effect_tool == [{"text": "run"}, {"text": "run"}] + + for bad_id in (None, True, [], {}): + body: dict[str, Any] = { + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": TOOL, "arguments": {"text": "nope"}}, + } + if bad_id is not None: + body["id"] = bad_id + refused = await client.post("/mcp", json=body, headers=wallet_headers) + assert refused.status_code < 500, refused.text + assert effect_tool == [{"text": "run"}, {"text": "run"}] + + +@pytest.mark.anyio +async def test_oversized_standard_mcp_body_is_refused_before_the_tool( + client, standard_mcp_enabled, effect_tool +): + limit = get_settings().MAX_REQUEST_BODY_BYTES + padding = "x" * (limit + 1) + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": TOOL, "arguments": {"text": padding}}, + } + ).encode() + assert len(body) > limit + response = await client.post( + "/mcp", + content=body, + headers=BOOTSTRAP_MCP_HEADERS, + ) + assert response.status_code == 413 + assert effect_tool == [] + + # Chunked transfer omits Content-Length. The measured stream still + # has to stop. + chunked = await client.post( + "/mcp", + content=body, + headers={**BOOTSTRAP_MCP_HEADERS, "Content-Length": ""}, + ) + # An empty Content-Length is not a size. Either the limit or the + # header parser may refuse it, and neither may run the tool. + assert chunked.status_code in {400, 411, 413}, chunked.text + assert chunked.status_code < 500 + assert effect_tool == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "method", + [ + "resources/list", + "prompts/list", + "logging/setLevel", + "tools/execute", + "INITIALIZE", + "initialize ", + "tools/call\x00", + "", + "notifications/initialized", + ], +) +async def test_unknown_method_is_not_a_server_error( + client, standard_mcp_enabled, effect_tool, method: str +): + params: dict[str, Any] = {} + if method == "logging/setLevel": + params = {"level": "debug"} + if method == "notifications/initialized": + body: dict[str, Any] = { + "jsonrpc": "2.0", + "method": method, + "params": params, + } + else: + body = { + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + } + response = await client.post( + "/mcp", + json=body, + headers=BOOTSTRAP_MCP_HEADERS, + ) + assert response.status_code < 500, response.text + if method == "notifications/initialized": + assert response.status_code == 202 + elif method in { + "resources/list", + "prompts/list", + "logging/setLevel", + }: + # Known MCP methods this server does not implement. + payload = response.json() + assert payload["error"]["code"] == -32601, response.text + elif method in {"tools/execute", "INITIALIZE"}: + # Methods the SDK does not model are rejected as invalid params. + # They still must not run a tool. + payload = response.json() + assert payload["error"]["code"] == -32602, response.text + assert effect_tool == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "body", + [ + {"jsonrpc": "2.0", "id": 1, "method": 1, "params": {}}, + {"jsonrpc": "2.0", "id": 1, "method": None, "params": {}}, + {"jsonrpc": "2.0", "id": 1, "method": ["tools/call"], "params": {}}, + {"jsonrpc": "2.0", "id": 1, "params": {"name": TOOL}}, + ], +) +async def test_non_string_method_is_not_a_server_error( + client, standard_mcp_enabled, effect_tool, body: dict +): + response = await client.post( + "/mcp", + json=body, + headers=BOOTSTRAP_MCP_HEADERS, + ) + assert response.status_code < 500, response.text + assert effect_tool == [] diff --git a/gateway/tests/test_mcp_upstream_governed.py b/gateway/tests/test_mcp_upstream_governed.py index 5173237d..deb604eb 100644 --- a/gateway/tests/test_mcp_upstream_governed.py +++ b/gateway/tests/test_mcp_upstream_governed.py @@ -15,6 +15,7 @@ from sqlalchemy import func, select from app.routers import mcp as mcp_router +from app.services.ownership import CallerIdentity from app.core.time import utc_now from app.db.database import get_session_factory from app.db.models import ( @@ -344,7 +345,9 @@ async def lose_quote_consume( # A later, unrelated reservation must survive repeated sweeps. The # quote-loser left no attempt for reconciliation to refund twice or to # convert into a new receipt-bearing public response. - await get_permit_service().reserve_budget(permit["permit_id"], Decimal("2")) + await get_permit_service().reserve_budget( + permit["permit_id"], Decimal("2"), caller=CallerIdentity.admin() + ) first = await get_mcp_dispatch_reconciliation_service().reconcile( idle_seconds=0, terminal_idle_seconds=0, @@ -2601,7 +2604,9 @@ async def test_upstream_permit_denials_never_charge_or_dispatch( idem_key=f"partner-upstream-denied-{denial}-permit", ) if denial == "revoked": - await get_permit_service().revoke_permit(permit["permit_id"]) + await get_permit_service().revoke_permit( + permit["permit_id"], caller=CallerIdentity.admin() + ) elif denial == "expired": factory = get_session_factory() async with factory() as session: diff --git a/gateway/tests/test_money_model_typecheck.py b/gateway/tests/test_money_model_typecheck.py new file mode 100644 index 00000000..e46c503e --- /dev/null +++ b/gateway/tests/test_money_model_typecheck.py @@ -0,0 +1,92 @@ +"""Wallet columns stay inside the mypy gate. + +A module-wide ``ignore_errors`` on ``app.db.models`` used to hide a float +assigned to a Decimal balance in the schema that stores money. Call-site +checks do not cover that file: the checker has to read the model module. +""" + +from __future__ import annotations + +import fnmatch +from configparser import ConfigParser +from pathlib import Path + +GATEWAY = Path(__file__).resolve().parents[1] +MYPY_INI = GATEWAY / "mypy.ini" +MODULE = "app.db.models" + + +def _ignored_modules() -> list[str]: + parser = ConfigParser() + read = parser.read(MYPY_INI) + assert read, f"mypy.ini missing at {MYPY_INI}" + ignored: list[str] = [] + for section in parser.sections(): + if not section.startswith("mypy-"): + continue + if not parser.getboolean(section, "ignore_errors", fallback=False): + continue + pattern = section.removeprefix("mypy-") + if fnmatch.fnmatch(MODULE, pattern): + ignored.append(section) + return ignored + + +def test_mypy_does_not_ignore_db_models() -> None: + assert _ignored_modules() == [] + + +def test_mypy_flags_float_assigned_to_wallet_balance(tmp_path: Path) -> None: + """A float written onto WalletModel.balance must be a type error. + + ``ignore_errors`` on this module name makes the same file pass, which is + the regression this locks out. + """ + package = tmp_path / "app" / "db" + package.mkdir(parents=True) + (tmp_path / "app" / "__init__.py").write_text("") + (package / "__init__.py").write_text("") + source = package / "models.py" + source.write_text( + "from decimal import Decimal\n" + "from sqlmodel import Field, SQLModel\n" + "\n" + "class WalletModel(SQLModel, table=True):\n" + " wallet_id: str = Field(primary_key=True)\n" + " balance: Decimal = Field(default=Decimal('0'))\n" + "\n" + " def credit(self) -> None:\n" + " self.balance = 1.5\n" + ) + from mypy.api import run + + stdout, stderr, code = run( + [ + "--config-file", + str(MYPY_INI), + "--no-incremental", + "--cache-dir", + str(tmp_path / "mypy-cache"), + str(source), + ] + ) + assert code != 0, stdout + stderr + assert 'expression has type "float"' in stdout + assert 'variable has type "Decimal"' in stdout + + +def test_real_db_models_module_typechecks(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(GATEWAY) + from mypy.api import run + + stdout, stderr, code = run( + [ + "--config-file", + str(MYPY_INI), + "--no-incremental", + "--cache-dir", + str(tmp_path / "mypy-cache"), + "app/db/models.py", + ] + ) + assert code == 0, stdout + stderr diff --git a/gateway/tests/test_owner_enforcement.py b/gateway/tests/test_owner_enforcement.py new file mode 100644 index 00000000..40da1166 --- /dev/null +++ b/gateway/tests/test_owner_enforcement.py @@ -0,0 +1,602 @@ +"""Pattern 3: money and permit services enforce caller ownership themselves. + +Before this fix the routes were the only ownership check: every service +function below accepted a bare wallet or permit id and acted on it, so any +future (or compromised) caller past the route could move another tenant's +money. Each test names a stranger wallet and proves the service denies it, +then proves the owner (and the bootstrap admin) still succeeds. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.core.dependencies import get_agent_money as get_route_agent_money +from app.db.database import get_session_factory +from app.db.models import ( + LedgerEntryModel, + McpDispatchAttemptModel, + WalletModel, +) +from app.main import app +from app.schemas.billing import LedgerAction, ServiceCategory +from app.schemas.trust import PermitCreateRequest +from app.services.agent_money import WalletNotFoundError, get_agent_money +from app.services.idempotency import get_idempotency_service +from app.services.ownership import CallerIdentity, OwnershipDeniedError +from app.services.permits import ( + PermitAccessDeniedError, + get_permit_service, +) +from tests.test_trust_helpers import BOOTSTRAP_HEADERS, provision_agent_wallet + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +def _owner_of(wallet_id: str) -> CallerIdentity: + return CallerIdentity.for_wallet(wallet_id) + + +async def _permit( + owner: dict, + *, + issuer_wallet_id: str | None = None, + subject_wallet_id: str | None = None, + max_credits: str = "50", + tool: str = "owner-tool", +): + return await get_permit_service().create_permit( + PermitCreateRequest( + issuer_wallet_id=issuer_wallet_id or owner["agent_wallet_id"], + subject_wallet_id=subject_wallet_id or owner["agent_wallet_id"], + subject_key_id=owner["key_id"], + allowed_tools=[tool], + scopes=[f"tool:{tool}:invoke"], + max_credits=Decimal(max_credits), + expires_at=(datetime.now(timezone.utc) + timedelta(minutes=30)).replace( + tzinfo=None + ), + ) + ) + + +@pytest.mark.anyio +async def test_transfer_denies_a_stranger(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + money = get_agent_money() + + with pytest.raises(OwnershipDeniedError): + await money.transfer( + owner["agent_wallet_id"], + stranger["agent_wallet_id"], + Decimal("10"), + caller=_owner_of(stranger["agent_wallet_id"]), + ) + + receipt = await money.transfer( + owner["agent_wallet_id"], + stranger["agent_wallet_id"], + Decimal("10"), + caller=_owner_of(owner["agent_wallet_id"]), + ) + assert receipt["status"] == "completed" + + receipt = await money.transfer( + stranger["agent_wallet_id"], + owner["agent_wallet_id"], + Decimal("1"), + caller=CallerIdentity.admin(), + ) + assert receipt["status"] == "completed" + + +@pytest.mark.anyio +async def test_create_and_reclaim_child_wallet_deny_a_stranger(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + money = get_agent_money() + + with pytest.raises(OwnershipDeniedError): + await money.create_child_wallet( + owner["agent_wallet_id"], + "stranger-child", + Decimal("10"), + Decimal("10"), + caller=_owner_of(stranger["agent_wallet_id"]), + ) + + child = await money.create_child_wallet( + owner["agent_wallet_id"], + "owner-child", + Decimal("10"), + Decimal("10"), + caller=_owner_of(owner["agent_wallet_id"]), + ) + + with pytest.raises(OwnershipDeniedError): + await money.reclaim_child_wallet( + child.wallet_id, caller=_owner_of(stranger["agent_wallet_id"]) + ) + + reclaimed = await money.reclaim_child_wallet( + child.wallet_id, caller=_owner_of(owner["agent_wallet_id"]) + ) + assert reclaimed["child_wallet_id"] == child.wallet_id + + +@pytest.mark.anyio +async def test_reclaim_denies_unowned_wallets_without_revealing_their_state( + client, clean_database +): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + money = get_agent_money() + child = await money.create_child_wallet( + owner["agent_wallet_id"], + "owner-child", + Decimal("10"), + Decimal("10"), + caller=_owner_of(owner["agent_wallet_id"]), + ) + + async def state(): + async with get_session_factory()() as session: + wallets = (await session.execute(select(WalletModel))).scalars().all() + ledger = (await session.execute(select(LedgerEntryModel))).scalars().all() + return ( + { + wallet.wallet_id: (wallet.balance, wallet.status) + for wallet in wallets + }, + len(ledger), + ) + + before = await state() + for caller in ( + _owner_of(stranger["agent_wallet_id"]), + CallerIdentity(), + CallerIdentity.for_wallet(None), + ): + for wallet_id in ( + "missing-reclaim-wallet", + owner["sponsor_wallet_id"], # parentless wallet + child.wallet_id, + ): + with pytest.raises(OwnershipDeniedError) as denied: + await money.reclaim_child_wallet(wallet_id, caller=caller) + assert denied.value.reason == "wallet_access_denied" + assert str(denied.value) == "wallet_access_denied" + assert await state() == before + + +@pytest.mark.anyio +async def test_reclaim_preserves_parent_child_engine_and_admin_access( + client, clean_database +): + owner = await provision_agent_wallet(client) + money = get_agent_money() + parent_id = owner["agent_wallet_id"] + + for index, caller_for_child in enumerate( + ( + lambda child_id: CallerIdentity(wallet_id=parent_id), + lambda child_id: CallerIdentity(wallet_id=child_id), + lambda child_id: CallerIdentity.for_wallet(parent_id), + lambda child_id: CallerIdentity.for_wallet(child_id), + lambda child_id: CallerIdentity.admin(), + ) + ): + child = await money.create_child_wallet( + parent_id, + f"authorized-child-{index}", + Decimal("10"), + Decimal("10"), + caller=_owner_of(parent_id), + ) + reclaimed = await money.reclaim_child_wallet( + child.wallet_id, caller=caller_for_child(child.wallet_id) + ) + assert reclaimed["child_wallet_id"] == child.wallet_id + wallet = await money.get_wallet(child.wallet_id) + assert wallet is not None + assert wallet.status.value == "closed" + with pytest.raises(ValueError, match="Wallet already closed"): + await money.reclaim_child_wallet( + child.wallet_id, caller=caller_for_child(child.wallet_id) + ) + + with pytest.raises(WalletNotFoundError) as missing: + await money.reclaim_child_wallet( + "missing-reclaim-wallet", caller=CallerIdentity.admin() + ) + assert missing.value.wallet_id == "missing-reclaim-wallet" + with pytest.raises(ValueError, match="Child wallet has no parent"): + await money.reclaim_child_wallet( + owner["sponsor_wallet_id"], + caller=CallerIdentity(wallet_id=owner["sponsor_wallet_id"]), + ) + with pytest.raises(ValueError, match="Can only reclaim from child wallets"): + await money.reclaim_child_wallet( + parent_id, caller=CallerIdentity(wallet_id=parent_id) + ) + + +@pytest.mark.anyio +async def test_create_agent_wallet_denies_a_stranger(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + money = get_agent_money() + + with pytest.raises(OwnershipDeniedError): + await money.create_agent_wallet( + owner["sponsor_wallet_id"], + "stranger-agent", + Decimal("10"), + caller=_owner_of(stranger["agent_wallet_id"]), + ) + + agent = await money.create_agent_wallet( + owner["sponsor_wallet_id"], + "owner-agent", + Decimal("10"), + caller=_owner_of(owner["sponsor_wallet_id"]), + ) + assert agent.wallet_id + + +@pytest.mark.anyio +async def test_charge_and_refund_deny_a_stranger(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + money = get_agent_money() + + with pytest.raises(OwnershipDeniedError): + await money.charge( + owner["agent_wallet_id"], + ServiceCategory.AGENT_COMMS, + units=Decimal("1"), + caller=_owner_of(stranger["agent_wallet_id"]), + ) + + charge = await money.charge( + owner["agent_wallet_id"], + ServiceCategory.AGENT_COMMS, + units=Decimal("1"), + caller=_owner_of(owner["agent_wallet_id"]), + ) + assert hasattr(charge, "entry_id") + + with pytest.raises(OwnershipDeniedError): + await money.refund_charge( + wallet_id=owner["agent_wallet_id"], + charge_entry_id=charge.entry_id, + caller=_owner_of(stranger["agent_wallet_id"]), + ) + + refund = await money.refund_charge( + wallet_id=owner["agent_wallet_id"], + charge_entry_id=charge.entry_id, + caller=_owner_of(owner["agent_wallet_id"]), + ) + assert refund.entry_id == f"refund-{charge.entry_id}" + assert refund.action == LedgerAction.REFUND + + +@pytest.mark.anyio +async def test_revoke_permit_denies_a_stranger_and_a_mere_subject( + client, clean_database +): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + service = get_permit_service() + + permit = await _permit(owner) + + with pytest.raises(PermitAccessDeniedError): + await service.revoke_permit( + permit.permit_id, caller=_owner_of(stranger["agent_wallet_id"]) + ) + stored = await service.get_permit(permit.permit_id) + assert stored.status == "active" + assert stored.revoked_at is None + + # The subject spends under the permit but does not own its authority: + # revocation stays with the issuer, matching the route's check. + sponsored = await _permit( + owner, + issuer_wallet_id=owner["sponsor_wallet_id"], + subject_wallet_id=owner["agent_wallet_id"], + ) + with pytest.raises(PermitAccessDeniedError): + await service.revoke_permit( + sponsored.permit_id, caller=_owner_of(owner["agent_wallet_id"]) + ) + + # Revocation belongs to the issuer wallet, even when the permit is bound + # to another key in that same wallet for spending/accounting. + revoked = await service.revoke_permit( + permit.permit_id, + caller=CallerIdentity( + wallet_id=owner["agent_wallet_id"], key_id="another-key-in-the-same-wallet" + ), + ) + assert revoked.status == "revoked" + revoked = await service.revoke_permit( + sponsored.permit_id, caller=CallerIdentity.admin() + ) + assert revoked.status == "revoked" + + +@pytest.mark.anyio +async def test_permit_budget_mutations_deny_a_stranger(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + service = get_permit_service() + stranger_caller = _owner_of(stranger["agent_wallet_id"]) + owner_caller = _owner_of(owner["agent_wallet_id"]) + other_key_caller = CallerIdentity( + wallet_id=owner["agent_wallet_id"], key_id="another-key-in-the-same-wallet" + ) + bound_key_caller = CallerIdentity( + wallet_id=owner["agent_wallet_id"], key_id=owner["key_id"] + ) + + permit = await _permit(owner) + + with pytest.raises(PermitAccessDeniedError): + await service.reserve_budget( + permit.permit_id, Decimal("5"), caller=stranger_caller + ) + with pytest.raises(PermitAccessDeniedError): + await service.reserve_budget( + permit.permit_id, Decimal("5"), caller=other_key_caller + ) + with pytest.raises(PermitAccessDeniedError): + await service.reserve_budget( + permit.permit_id, + Decimal("5"), + caller=CallerIdentity.for_wallet( + owner["agent_wallet_id"], key_id="another-key-in-the-same-wallet" + ), + ) + with pytest.raises(PermitAccessDeniedError): + await service.reserve_budget( + permit.permit_id, + Decimal("5"), + caller=CallerIdentity(wallet_id=owner["agent_wallet_id"]), + ) + await service.reserve_budget( + permit.permit_id, Decimal("5"), caller=bound_key_caller + ) + + with pytest.raises(PermitAccessDeniedError): + await service.release_budget( + permit.permit_id, Decimal("5"), caller=stranger_caller + ) + with pytest.raises(PermitAccessDeniedError): + await service.release_budget( + permit.permit_id, Decimal("5"), caller=other_key_caller + ) + await service.release_budget( + permit.permit_id, Decimal("5"), caller=bound_key_caller + ) + + with pytest.raises(PermitAccessDeniedError): + await service.release_tool_call( + permit.permit_id, "owner-tool", caller=stranger_caller + ) + with pytest.raises(PermitAccessDeniedError): + await service.release_tool_call( + permit.permit_id, "owner-tool", caller=other_key_caller + ) + # No per-tool cap is configured, so the owner's release is a no-op. + await service.release_tool_call(permit.permit_id, "owner-tool", caller=owner_caller) + + sponsored = await _permit(owner, issuer_wallet_id=owner["sponsor_wallet_id"]) + issuer_caller = CallerIdentity( + wallet_id=owner["sponsor_wallet_id"], key_id="issuer-key" + ) + await service.reserve_budget( + sponsored.permit_id, Decimal("1"), caller=issuer_caller + ) + await service.release_budget( + sponsored.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) + + +@pytest.mark.anyio +async def test_release_dispatch_budget_denies_a_stranger(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + service = get_permit_service() + wallet_id = owner["agent_wallet_id"] + + permit = await _permit(owner) + begun = await get_idempotency_service().begin_with_record( + wallet_id=wallet_id, + endpoint="/v1/owner-enforcement", + idempotency_key="owner-dispatch-release", + request_payload={"tool": "owner-tool"}, + ) + factory = get_session_factory() + async with factory() as session: + async with session.begin(): + session.add( + McpDispatchAttemptModel( + attempt_id="att-owner-enforcement", + idempotency_record_id=begun.record_id, + wallet_id=wallet_id, + permit_id=permit.permit_id, + public_tool_id="owner-tool", + upstream_tool_name="partner_lookup", + upstream_origin="https://partner.example", + request_hash="a" * 64, + credits_authorized=Decimal("2"), + state="returned_error", + ) + ) + + with pytest.raises(PermitAccessDeniedError): + await service.release_dispatch_budget_once( + "att-owner-enforcement", + caller=_owner_of(stranger["agent_wallet_id"]), + ) + with pytest.raises(PermitAccessDeniedError): + await service.release_dispatch_budget_once( + "att-owner-enforcement", + caller=CallerIdentity(wallet_id=wallet_id, key_id="another-key"), + ) + + assert ( + await service.release_dispatch_budget_once( + "att-owner-enforcement", caller=_owner_of(wallet_id) + ) + is True + ) + + +@pytest.mark.anyio +async def test_revoke_route_answers_403_for_a_stranger_key(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + permit = await _permit(owner) + + denied = await client.post( + f"/v1/permits/{permit.permit_id}/revoke", + headers=stranger["agent_headers"], + ) + assert denied.status_code == 403 + + revoked = await client.post( + f"/v1/permits/{permit.permit_id}/revoke", + headers=owner["agent_headers"], + ) + assert revoked.status_code == 200, revoked.text + + +@pytest.mark.anyio +async def test_transfer_route_answers_403_for_a_stranger_key(client, clean_database): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + + denied = await client.post( + "/v1/billing/transfer", + params={ + "from_wallet_id": owner["agent_wallet_id"], + "to_wallet_id": stranger["agent_wallet_id"], + "amount": 5, + }, + headers=stranger["agent_headers"], + ) + assert denied.status_code == 403 + + +@pytest.mark.anyio +@pytest.mark.parametrize("operation", ["create", "transfer"]) +async def test_billing_ownership_backstop_replays_exact_denial( + client, clean_database, monkeypatch, operation +): + owner = await provision_agent_wallet(client) + stranger = await provision_agent_wallet(client) + if operation == "create": + wallet_id = owner["sponsor_wallet_id"] + endpoint = "/v1/billing/wallets/agent" + request_key = "json" + payload = { + "sponsor_wallet_id": wallet_id, + "agent_id": "backstop-agent", + "budget_credits": 10, + } + changed_payload = {**payload, "budget_credits": 11} + method = "create_agent_wallet" + owner_headers = BOOTSTRAP_HEADERS + else: + wallet_id = owner["agent_wallet_id"] + endpoint = "/v1/billing/transfer" + request_key = "params" + payload = { + "from_wallet_id": wallet_id, + "to_wallet_id": stranger["agent_wallet_id"], + "amount": 5, + } + changed_payload = {**payload, "amount": 6} + method = "transfer" + owner_headers = owner["agent_headers"] + + key = f"owner-backstop-{operation}" + denied_call = AsyncMock(side_effect=OwnershipDeniedError("service_backstop")) + monkeypatch.setattr(get_route_agent_money(), method, denied_call) + + async def money_state(): + async with get_session_factory()() as session: + wallets = (await session.execute(select(WalletModel))).scalars().all() + ledger_ids = ( + (await session.execute(select(LedgerEntryModel.entry_id))) + .scalars() + .all() + ) + return ( + sorted( + (wallet.wallet_id, wallet.balance, wallet.lifetime_debits) + for wallet in wallets + ), + sorted(ledger_ids), + ) + + before = await money_state() + stranger_response = await client.post( + endpoint, + **{request_key: payload}, + headers={**stranger["agent_headers"], "Idempotency-Key": key}, + ) + assert stranger_response.status_code == 403 + denied_call.assert_not_awaited() + idem = get_idempotency_service() + assert ( + await idem.get_record( + wallet_id=wallet_id, endpoint=endpoint, idempotency_key=key + ) + is None + ) + + headers = {**owner_headers, "Idempotency-Key": key} + first = await client.post(endpoint, **{request_key: payload}, headers=headers) + expected = { + "detail": { + "error": "wallet_access_denied", + "message": "API key is not authorized for this wallet.", + "wallet_id": wallet_id, + } + } + assert first.status_code == 403 + assert first.json() == expected + stored = await idem.get_record( + wallet_id=wallet_id, endpoint=endpoint, idempotency_key=key + ) + assert stored is not None + assert stored.status_code == 403 + assert json.loads(stored.response_json) == expected + + replay = await client.post(endpoint, **{request_key: payload}, headers=headers) + assert replay.status_code == 403 + assert replay.content == first.content + changed = await client.post( + endpoint, **{request_key: changed_payload}, headers=headers + ) + assert changed.status_code == 409 + assert changed.json()["detail"]["error"] == "idempotency_key_reused" + denied_call.assert_awaited_once() + assert await money_state() == before diff --git a/gateway/tests/test_packaging_deps.py b/gateway/tests/test_packaging_deps.py new file mode 100644 index 00000000..724a017b --- /dev/null +++ b/gateway/tests/test_packaging_deps.py @@ -0,0 +1,124 @@ +"""Runtime dependencies must be installable metadata, not just a lockfile. + +`pip install -e .` / `uv sync` resolve only `[project].dependencies`, so every +runtime floor in requirements.txt has to be declared there too. requirements.txt +stays the install target for Docker/CI/`--with-requirements` runs; these tests +keep the two files in sync in both directions. +""" + +from __future__ import annotations + +import configparser +import tomllib +from pathlib import Path + +import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +REPO_ROOT = Path(__file__).resolve().parents[1] + +#: Installed only to run the suite; never imported by `app/`. +DEV_ONLY = {"pytest", "pytest-asyncio", "pytest-cov", "mypy"} + + +def _requirements() -> list[Requirement]: + requirements: list[Requirement] = [] + for raw in (REPO_ROOT / "requirements.txt").read_text().splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + requirements.append(Requirement(line)) + return requirements + + +def _pyproject_dependencies() -> list[Requirement]: + project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())["project"] + return [Requirement(dep) for dep in project["dependencies"]] + + +def _missing_requirements( + required: list[Requirement], declared: list[Requirement] +) -> list[str]: + declarations = set(declared) + return [str(req) for req in required if req not in declarations] + + +def test_runtime_requirements_declared_in_pyproject() -> None: + """Every non-dev requirement, including its constraints, must be declared. + + Previously `[project]` declared no `dependencies` at all, so a fresh + `pip install -e .` / `uv sync` installed nothing and imports resolved + only via ambient installs. + """ + runtime = [ + req for req in _requirements() if canonicalize_name(req.name) not in DEV_ONLY + ] + missing = _missing_requirements(runtime, _pyproject_dependencies()) + assert not missing, ( + "requirements.txt entries missing from pyproject [project].dependencies: " + f"{missing}" + ) + + +def test_pyproject_has_no_extra_runtime_dependencies() -> None: + """The sync runs the other way too: pyproject must not drift from the lock.""" + runtime = [ + req for req in _requirements() if canonicalize_name(req.name) not in DEV_ONLY + ] + extra = _missing_requirements(_pyproject_dependencies(), runtime) + assert not extra, ( + "pyproject [project].dependencies entries missing from requirements.txt: " + f"{extra}" + ) + + +@pytest.mark.parametrize( + ("required", "declared"), + [ + ("pydantic>=2.14.1,<2.15", "pydantic>=2.14.1"), + ("uvicorn[standard]>=0.54.0", "uvicorn>=0.54.0"), + ("httpx>=0.28.1; python_version < '3.13'", "httpx>=0.28.1"), + ], +) +def test_sync_detects_dropped_bounds_extras_and_markers( + required: str, declared: str +) -> None: + requirement = Requirement(required) + assert _missing_requirements([requirement], [Requirement(declared)]) == [ + str(requirement) + ] + + +def test_mcp_major_bound_preserved() -> None: + """The mcp<2 ceiling is deliberate: 2.x renamed FastMCP to MCPServer and + moved McpError. Dependabot major bumps are ignored (#460).""" + for req in _pyproject_dependencies(): + if canonicalize_name(req.name) == "mcp": + assert req.specifier.contains("1.99.0"), f"mcp lower bound slipped: {req}" + assert not req.specifier.contains("2.0.0"), ( + f"mcp<2 ceiling lifted without the dedicated migration PR: {req}" + ) + return + raise AssertionError("mcp missing from pyproject [project].dependencies") + + +def test_mypy_has_no_global_missing_imports_ignore() -> None: + """The global `ignore_missing_imports = true` hid unresolved imports; only + explicit per-module overrides for stub-less or optional dependencies may + remain (asyncpg ships no stubs; webauthn/playwright/openai are optional + integrations absent from requirements.txt installs).""" + config = configparser.ConfigParser() + config.read(REPO_ROOT / "mypy.ini") + assert config.getboolean("mypy", "ignore_missing_imports") is False, ( + "mypy.ini must not ignore missing imports globally; use per-module " + "overrides for the documented exceptions" + ) + for module in ("asyncpg", "webauthn", "playwright", "openai"): + section = f"mypy-{module}.*" + assert config.has_section(section), ( + f"mypy.ini needs an explicit [{section}] override" + ) + assert config.getboolean(section, "ignore_missing_imports") is True, ( + f"[{section}] must set ignore_missing_imports = true" + ) diff --git a/gateway/tests/test_permit_budget_integrity.py b/gateway/tests/test_permit_budget_integrity.py index 92afe26a..30ebce21 100644 --- a/gateway/tests/test_permit_budget_integrity.py +++ b/gateway/tests/test_permit_budget_integrity.py @@ -24,6 +24,7 @@ from httpx import ASGITransport, AsyncClient from app.core.time import utc_now +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import PermitModel from app.main import app @@ -99,7 +100,9 @@ async def test_reserve_budget_refuses_a_permit_past_its_deadline( await _backdate_expiry(permit.permit_id) with pytest.raises(PermitError) as denied: - await get_permit_service().reserve_budget(permit.permit_id, Decimal("1")) + await get_permit_service().reserve_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) assert denied.value.reason == "permit_expired" # And no budget moved on the way to the denial. @@ -117,10 +120,14 @@ async def test_reserve_budget_reports_revocation_rather_than_exhaustion( operator to top up a permit that more money cannot revive. """ _, permit = await _permit_with_budget(client) - await get_permit_service().revoke_permit(permit.permit_id) + await get_permit_service().revoke_permit( + permit.permit_id, caller=CallerIdentity.admin() + ) with pytest.raises(PermitError) as denied: - await get_permit_service().reserve_budget(permit.permit_id, Decimal("1")) + await get_permit_service().reserve_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) assert denied.value.reason == "permit_revoked" assert await _spent(permit.permit_id) == Decimal("0") @@ -133,9 +140,13 @@ async def test_reserve_budget_still_denies_a_genuine_cap_breach( """The added predicates must not swallow the reason they sit in front of.""" _, permit = await _permit_with_budget(client, max_credits="5") - await get_permit_service().reserve_budget(permit.permit_id, Decimal("4")) + await get_permit_service().reserve_budget( + permit.permit_id, Decimal("4"), caller=CallerIdentity.admin() + ) with pytest.raises(PermitError) as denied: - await get_permit_service().reserve_budget(permit.permit_id, Decimal("2")) + await get_permit_service().reserve_budget( + permit.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) assert denied.value.reason == "permit_budget_exceeded" assert await _spent(permit.permit_id) == Decimal("4") diff --git a/gateway/tests/test_permit_budget_threshold_alerts.py b/gateway/tests/test_permit_budget_threshold_alerts.py index 03368ec2..db57076d 100644 --- a/gateway/tests/test_permit_budget_threshold_alerts.py +++ b/gateway/tests/test_permit_budget_threshold_alerts.py @@ -25,6 +25,7 @@ from httpx import ASGITransport, AsyncClient from app.main import app +from app.services.ownership import CallerIdentity from app.schemas.billing import AlertType from app.services.permits import get_permit_service from tests.test_trust_helpers import create_tool_permit, provision_agent_wallet @@ -60,7 +61,9 @@ async def test_a_budget_threshold_alert_is_readable_through_the_alert_api( ) # 41 of 50 credits: past 80% and short of 90%, so exactly one alert fires. - await get_permit_service().reserve_budget(permit["permit_id"], Decimal("41")) + await get_permit_service().reserve_budget( + permit["permit_id"], Decimal("41"), caller=CallerIdentity.admin() + ) mine = await client.get("/v1/me/alerts", headers=ctx["agent_headers"]) assert mine.status_code == 200, mine.text @@ -106,9 +109,11 @@ async def test_every_budget_threshold_the_writer_can_store_is_readable( service = get_permit_service() # 82%, then 92%, then exactly 100%: one crossing per reservation. - await service.reserve_budget(permit_id, Decimal("41")) - await service.reserve_budget(permit_id, Decimal("5")) - await service.reserve_budget(permit_id, Decimal("4")) + await service.reserve_budget( + permit_id, Decimal("41"), caller=CallerIdentity.admin() + ) + await service.reserve_budget(permit_id, Decimal("5"), caller=CallerIdentity.admin()) + await service.reserve_budget(permit_id, Decimal("4"), caller=CallerIdentity.admin()) mine = await client.get("/v1/me/alerts", headers=ctx["agent_headers"]) assert mine.status_code == 200, mine.text diff --git a/gateway/tests/test_permit_postgres_concurrency.py b/gateway/tests/test_permit_postgres_concurrency.py index 2a9d8615..b58fb3fd 100644 --- a/gateway/tests/test_permit_postgres_concurrency.py +++ b/gateway/tests/test_permit_postgres_concurrency.py @@ -11,7 +11,6 @@ import base64 import hashlib import json -import os import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -28,7 +27,7 @@ from sqlalchemy.exc import IntegrityError from app.core.time import utc_now -from app.db.database import get_engine, get_session_factory +from app.db.database import get_session_factory from app.db.models import ( BillingAlertModel, ControlPlaneAuditEventModel, @@ -55,6 +54,7 @@ from app.services.mcp_dispatch_reconciliation import ( McpDispatchReconciliationService, ) +from app.services.ownership import CallerIdentity from app.services.permits import PermitService, PermitValidation from app.services.receipts import ReceiptError, get_receipt_service from app.services.refund_reconciliation import ( @@ -67,6 +67,7 @@ UpstreamMcpDeliveryUncertainError, UpstreamMcpResult, ) +from tests.support.db_gate import require_live_postgres from tests.test_trust_helpers import ( BOOTSTRAP_HEADERS, create_tool_permit, @@ -142,14 +143,14 @@ async def call_tool( def _require_opted_in_postgres() -> None: - engine = get_engine() - if engine is None or engine.dialect.name != "postgresql": - pytest.skip("requires a PostgreSQL DATABASE_URL for real row-lock semantics") - if os.environ.get("RUN_POSTGRES_CONCURRENCY_TESTS") != "1": - pytest.skip( + require_live_postgres( + flag="RUN_POSTGRES_CONCURRENCY_TESTS", + reason=( + "requires a PostgreSQL DATABASE_URL for real row-lock semantics; " "set RUN_POSTGRES_CONCURRENCY_TESTS=1 only with an isolated " "PostgreSQL test database" - ) + ), + ) @pytest_asyncio.fixture(loop_scope="session") @@ -240,6 +241,11 @@ async def seeded_permit() -> SeededPermit: finally: async with factory() as session: async with session.begin(): + # Receipts from any test using this permit must go first: + # receipts reference permits (receipts_permit_id_fkey). + await session.execute( + delete(ReceiptModel).where(ReceiptModel.permit_id == permit_id) + ) await session.execute( delete(PermitModel).where(PermitModel.permit_id == permit_id) ) @@ -358,29 +364,29 @@ async def seeded_refund_reconciliation( finally: async with factory() as session: async with session.begin(): + # Children before parents: receipts reference the permit, the + # idempotency record, and ledger entries. The refund retry + # under test issues a second receipt via create_receipt, so + # sweep by permit_id instead of deleting only the seeded one. await session.execute( - delete(IdempotencyRecordModel).where( - IdempotencyRecordModel.record_id == record_id + delete(ReceiptModel).where( + ReceiptModel.permit_id == seeded_permit.permit_id ) ) await session.execute( - delete(ReceiptModel).where( - ReceiptModel.receipt_id == receipt.receipt_id + delete(IdempotencyRecordModel).where( + IdempotencyRecordModel.wallet_id == seeded_permit.wallet_id ) ) await session.execute( delete(LedgerEntryModel).where( - LedgerEntryModel.entry_id.in_( - [charge_entry_id, reconciliation["refund_entry_id"]] - ) + LedgerEntryModel.wallet_id == seeded_permit.wallet_id ) ) - if receipt.signature_key_id != seeded_permit.signing_key_id: - await session.execute( - delete(SigningKeyModel).where( - SigningKeyModel.key_id == receipt.signature_key_id - ) - ) + # The receipt signature key is suite-wide trust state owned by + # the receipt service, not by this fixture. Deleting it can + # roll back the cleanup above whenever another module's rows + # still reference it, so leave it in place. def _authorization_kwargs(seed: SeededPermit) -> dict[str, object]: @@ -424,6 +430,7 @@ async def test_concurrent_operation_key_charge_is_one_debit_in_postgres( units=Decimal("1"), request_path="/mcp/messages", operation_key=record_id, + caller=CallerIdentity.admin(), ), money.charge( wallet_id=seeded_permit.wallet_id, @@ -431,6 +438,7 @@ async def test_concurrent_operation_key_charge_is_one_debit_in_postgres( units=Decimal("1"), request_path="/mcp/messages", operation_key=record_id, + caller=CallerIdentity.admin(), ), ), timeout=10, @@ -662,7 +670,9 @@ async def paused_validation(**kwargs: object) -> PermitValidation: async def revoke() -> object: revocation_started.set() - return await service.revoke_permit(seeded_permit.permit_id) + return await service.revoke_permit( + seeded_permit.permit_id, caller=CallerIdentity.admin() + ) revocation = asyncio.create_task(revoke()) await asyncio.wait_for(revocation_started.wait(), timeout=5) @@ -1338,6 +1348,7 @@ async def hold_debit_after_attempt_fence(session, **kwargs): # noqa: ANN001 units=Decimal("1"), request_path=GOVERNED_MCP_IDEMPOTENCY_ENDPOINT, operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) ) await asyncio.wait_for(debit_fence_acquired.wait(), timeout=5) @@ -1536,6 +1547,7 @@ async def hold_debit_after_attempt_fence(session, **kwargs): # noqa: ANN001 units=Decimal("1"), request_path=GOVERNED_MCP_IDEMPOTENCY_ENDPOINT, operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) ) await asyncio.wait_for(debit_fence_acquired.wait(), timeout=5) @@ -1724,6 +1736,7 @@ async def test_dispatch_claim_cas_is_exclusive_in_postgres() -> None: units=Decimal("1"), request_path=GOVERNED_MCP_IDEMPOTENCY_ENDPOINT, operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) assert hasattr(charge, "entry_id") await dispatch.attach_charge( @@ -1810,6 +1823,7 @@ async def test_dispatch_claim_cas_is_exclusive_in_postgres() -> None: units=Decimal("1"), request_path=GOVERNED_MCP_IDEMPOTENCY_ENDPOINT, operation_key=race_begun.record_id, + caller=CallerIdentity.admin(), ) assert hasattr(race_charge, "entry_id") race_attempt = await dispatch.attach_charge( @@ -1922,6 +1936,7 @@ async def test_concurrent_dispatch_reconcilers_create_one_signed_audit_in_postgr units=Decimal("1"), request_path=GOVERNED_MCP_IDEMPOTENCY_ENDPOINT, operation_key=begun.record_id, + caller=CallerIdentity.admin(), ) assert hasattr(charge, "entry_id") await dispatch.attach_charge( @@ -2132,6 +2147,7 @@ async def provision(index: int) -> object: sponsor_wallet_id=sponsor_id, agent_id=f"pg-conc-{index}-{uuid.uuid4().hex[:6]}", budget_credits=Decimal("100"), + caller=CallerIdentity.admin(), ) except Exception as exc: # refusal is a valid outcome, not a failure return exc @@ -2162,11 +2178,13 @@ async def test_concurrent_transfers_conserve_credits_in_postgres() -> None: sponsor_wallet_id=sponsor_id, agent_id=f"pg-src-{uuid.uuid4().hex[:6]}", budget_credits=Decimal("300"), + caller=CallerIdentity.admin(), ) dest = await money.create_agent_wallet( sponsor_wallet_id=sponsor_id, agent_id=f"pg-dst-{uuid.uuid4().hex[:6]}", budget_credits=Decimal("0"), + caller=CallerIdentity.admin(), ) async def move() -> object: @@ -2175,6 +2193,7 @@ async def move() -> object: from_wallet_id=source.wallet_id, to_wallet_id=dest.wallet_id, amount=Decimal("100"), + caller=CallerIdentity.admin(), ) except Exception as exc: return exc @@ -2202,18 +2221,22 @@ async def test_concurrent_reclaims_credit_the_parent_once_in_postgres() -> None: sponsor_wallet_id=sponsor_id, agent_id=f"pg-parent-{uuid.uuid4().hex[:6]}", budget_credits=Decimal("400"), + caller=CallerIdentity.admin(), ) child = await money.create_child_wallet( parent_wallet_id=parent.wallet_id, child_agent_id=f"pg-child-{uuid.uuid4().hex[:6]}", budget_credits=Decimal("150"), max_spend=Decimal("150"), + caller=CallerIdentity.admin(), ) parent_before = await _wallet_balance(parent.wallet_id) async def reclaim() -> object: try: - return await money.reclaim_child_wallet(child.wallet_id) + return await money.reclaim_child_wallet( + child.wallet_id, caller=CallerIdentity.admin() + ) except Exception as exc: return exc diff --git a/gateway/tests/test_permit_request_flow.py b/gateway/tests/test_permit_request_flow.py index 67ca835f..ba686ef7 100644 --- a/gateway/tests/test_permit_request_flow.py +++ b/gateway/tests/test_permit_request_flow.py @@ -386,6 +386,56 @@ async def test_same_idempotency_key_with_different_terms_conflicts( assert len(sentinel.created) == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "new_request_error", + ["permit_expired_at_request", "human_approval_not_configured"], +) +async def test_approved_request_replays_after_creation_becomes_unavailable( + client, clean_database, monkeypatch, sentinel, new_request_error +): + _sentinel_env(monkeypatch, simulated=False) + agent = await provision_agent_wallet(client) + created = await _request(client, agent, idem="preq-settled") + assert created.status_code == 202 + + sentinel.status = "approved" + approved = await client.get( + f"/v1/permit-requests/{created.json()['request_id']}", + headers=agent["agent_headers"], + ) + assert approved.status_code == 200 + permit_id = approved.json()["permit_id"] + assert permit_id + + if new_request_error == "permit_expired_at_request": + monkeypatch.setattr( + permit_requests_module, "utc_now", lambda: datetime(2035, 1, 2) + ) + else: + _sentinel_env(monkeypatch, simulated=False, configured=False) + + replay = await _request(client, agent, idem="preq-settled") + assert replay.status_code == 200, replay.text + assert replay.json()["request_id"] == created.json()["request_id"] + assert replay.json()["status"] == "approved" + assert replay.json()["permit_id"] == permit_id + + changed = await _request(client, agent, idem="preq-settled", max_credits=21) + assert changed.status_code == 409 + assert changed.json()["detail"] == "permit_request_terms_conflict" + + fresh = await _request(client, agent, idem="preq-new") + assert fresh.status_code == 400 + assert fresh.json()["detail"] == new_request_error + + assert len(sentinel.created) == 1 + factory = get_session_factory() + async with factory() as session: + permits = (await session.execute(select(PermitModel))).scalars().all() + assert len(permits) == 1 + + @pytest.mark.asyncio async def test_approved_request_mints_stored_terms_not_polled_terms( client, clean_database, monkeypatch, sentinel diff --git a/gateway/tests/test_permit_signing_input_snapshot.py b/gateway/tests/test_permit_signing_input_snapshot.py index 883a9386..dc2462c6 100644 --- a/gateway/tests/test_permit_signing_input_snapshot.py +++ b/gateway/tests/test_permit_signing_input_snapshot.py @@ -11,7 +11,9 @@ If this test fails, the change is either a bug or an intentional permit format change. An intentional change requires: (1) a permit schema version bump, (2) an entry in CHANGELOG.md, (3) regenerating the golden files with -``UPDATE_PERMIT_GOLDEN=1 pytest tests/test_permit_signing_input_snapshot.py``. +``GOLDEN_REGEN_ACK=1 UPDATE_PERMIT_GOLDEN=1 pytest +tests/test_permit_signing_input_snapshot.py``. The ack flag is the review +gate: setting UPDATE_PERMIT_GOLDEN=1 without it fails instead of writing. Never regenerate to make a refactor PR green. """ @@ -19,7 +21,6 @@ import base64 import json -import os from datetime import datetime, timedelta, timezone from decimal import Decimal from pathlib import Path @@ -36,6 +37,7 @@ from app.services.signing_keys import canonical_json, sha256_hex from b2a_sdk.edge_client import LocalPermitValidator from b2a_sdk.receipt_verifier import canonical_json as sdk_canonical_json +from tests.support.golden_regen import require_golden_regen_ack from tests.test_trust_helpers import create_tool_permit, provision_agent_wallet GOLDEN_DIR = Path(__file__).parent / "fixtures" / "permit_signing_golden" @@ -149,7 +151,7 @@ def test_permit_signing_input_is_byte_stable(name: str) -> None: "signature_b64_over_test_seed": signature, } - if os.environ.get("UPDATE_PERMIT_GOLDEN") == "1": + if require_golden_regen_ack("UPDATE_PERMIT_GOLDEN"): GOLDEN_DIR.mkdir(parents=True, exist_ok=True) golden_path.write_text(json.dumps(actual, indent=2, sort_keys=True) + "\n") pytest.skip(f"golden regenerated: {golden_path}") diff --git a/gateway/tests/test_permit_v2_constraints.py b/gateway/tests/test_permit_v2_constraints.py index 463d7626..b4e3d830 100644 --- a/gateway/tests/test_permit_v2_constraints.py +++ b/gateway/tests/test_permit_v2_constraints.py @@ -14,6 +14,7 @@ from httpx import ASGITransport, AsyncClient from app.db.database import get_session_factory +from app.services.ownership import CallerIdentity from app.db.models import PermitModel from app.main import app from app.schemas.billing import ServiceCategory @@ -392,7 +393,9 @@ async def reserve_once(): assert (await reserve_once()).allowed is True assert (await reserve_once()).reason == "permit_aggregate_value_cap_exceeded" - await service.release_budget(permit.permit_id, Decimal("1")) + await service.release_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) assert (await reserve_once()).allowed is True @@ -441,7 +444,9 @@ async def test_aggregate_value_cap_receipt_history_floors_concurrent_reservation # Reserve, receipt the charge, then hand the reservation back: the shape # the charge-then-refund-failure path leaves behind. - await service.reserve_budget(permit.permit_id, Decimal("2")) + await service.reserve_budget( + permit.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) await get_receipt_service().create_receipt( permit_id=permit.permit_id, wallet_id=wallet_id, @@ -455,7 +460,9 @@ async def test_aggregate_value_cap_receipt_history_floors_concurrent_reservation outcome="failed_unrefunded", audit_event_id=None, ) - await service.release_budget(permit.permit_id, Decimal("2")) + await service.release_budget( + permit.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) factory = get_session_factory() async with factory() as session: @@ -526,7 +533,9 @@ async def test_aggregate_value_cap_receipt_history_floor_then_sequential_reserva ) ) - await service.reserve_budget(permit.permit_id, Decimal("2")) + await service.reserve_budget( + permit.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) await get_receipt_service().create_receipt( permit_id=permit.permit_id, wallet_id=wallet_id, @@ -540,7 +549,9 @@ async def test_aggregate_value_cap_receipt_history_floor_then_sequential_reserva outcome="failed_unrefunded", audit_event_id=None, ) - await service.release_budget(permit.permit_id, Decimal("2")) + await service.release_budget( + permit.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) async def reserve_once(): return await service.authorize_and_reserve( @@ -599,14 +610,22 @@ async def test_reserve_budget_enforces_aggregate_value_cap(client, clean_databas ) ) - await service.reserve_budget(permit.permit_id, Decimal("1")) + await service.reserve_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) with pytest.raises(PermitError) as excinfo: - await service.reserve_budget(permit.permit_id, Decimal("1")) + await service.reserve_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) assert excinfo.value.reason == "permit_aggregate_value_cap_exceeded" # Releasing the in-flight reservation makes the cap share admissible again. - await service.release_budget(permit.permit_id, Decimal("1")) - await service.reserve_budget(permit.permit_id, Decimal("1")) + await service.release_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) + await service.reserve_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) # Parallel reservations against a fresh capped permit admit at most # floor(cap / amount), same guarantee as the MCP path. @@ -625,7 +644,9 @@ async def test_reserve_budget_enforces_aggregate_value_cap(client, clean_databas async def reserve_or_reason() -> str: try: - await service.reserve_budget(permit2.permit_id, Decimal("2")) + await service.reserve_budget( + permit2.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) except PermitError as exc: return exc.reason return "allowed" diff --git a/gateway/tests/test_permits.py b/gateway/tests/test_permits.py index 8bd0deea..6480a134 100644 --- a/gateway/tests/test_permits.py +++ b/gateway/tests/test_permits.py @@ -18,6 +18,7 @@ PermitRequestCreate, ) from app.services.idempotency import get_idempotency_service +from app.services.ownership import CallerIdentity from app.services.permits import PermitError, get_permit_service from tests.test_trust_helpers import ( BOOTSTRAP_HEADERS, @@ -419,7 +420,9 @@ async def test_permit_service_budget_lifecycle_and_filters( assert total == 1 assert permits[0].permit_id == permit.permit_id - await service.reserve_budget(permit.permit_id, Decimal("4")) + await service.reserve_budget( + permit.permit_id, Decimal("4"), caller=CallerIdentity.admin() + ) validation = await service.validate_for_action( permit_id=permit.permit_id, wallet_id=provisioned["agent_wallet_id"], @@ -430,7 +433,9 @@ async def test_permit_service_budget_lifecycle_and_filters( assert validation.allowed is False assert validation.reason == "permit_budget_exceeded" - await service.release_budget(permit.permit_id, Decimal("2")) + await service.release_budget( + permit.permit_id, Decimal("2"), caller=CallerIdentity.admin() + ) validation = await service.validate_for_action( permit_id=permit.permit_id, wallet_id=provisioned["agent_wallet_id"], @@ -441,11 +446,17 @@ async def test_permit_service_budget_lifecycle_and_filters( assert validation.allowed is True with pytest.raises(PermitError) as missing_reserve: - await service.reserve_budget("permit-missing", Decimal("1")) + await service.reserve_budget( + "permit-missing", Decimal("1"), caller=CallerIdentity.admin() + ) assert missing_reserve.value.reason == "permit_not_found" - await service.release_budget("permit-missing", Decimal("1")) + await service.release_budget( + "permit-missing", Decimal("1"), caller=CallerIdentity.admin() + ) - revoked = await service.revoke_permit(permit.permit_id) + revoked = await service.revoke_permit( + permit.permit_id, caller=CallerIdentity.admin() + ) assert revoked.status == "revoked" assert revoked.revoked_at is not None validation = await service.validate_for_action( @@ -459,6 +470,124 @@ async def test_permit_service_budget_lifecycle_and_filters( assert validation.reason == "permit_revoked" +async def _stored_permit_revocation(permit_id: str) -> tuple[str, datetime | None]: + factory = get_session_factory() + async with factory() as session: + model = await session.get(PermitModel, permit_id) + assert model is not None + return model.status, model.revoked_at + + +@pytest.mark.anyio +async def test_repeat_revoke_permit_keeps_original_revoked_at( + client, + clean_database, + enforce_naive_utc_datetime_columns, +): + """A second revoke is a no-op on the stored revocation evidence. + + ``revoked_at`` is returned in permit denial details. Moving it forward on + a repeat call would make that evidence describe the later call. + """ + provisioned = await provision_agent_wallet(client) + service = get_permit_service() + permit = await service.create_permit( + PermitCreateRequest( + issuer_wallet_id=provisioned["agent_wallet_id"], + subject_wallet_id=provisioned["agent_wallet_id"], + subject_key_id=provisioned["key_id"], + allowed_tools=["repeat-revoke-tool"], + scopes=["tool:repeat-revoke-tool:invoke"], + max_credits=Decimal("10"), + expires_at=(datetime.now(timezone.utc) + timedelta(minutes=30)).replace( + tzinfo=None + ), + ) + ) + + issuer = CallerIdentity( + wallet_id=provisioned["agent_wallet_id"], key_id=provisioned["key_id"] + ) + first = await service.revoke_permit(permit.permit_id, caller=issuer) + assert first.status == "revoked" + assert first.revoked_at is not None + + original = datetime(2020, 1, 2, 3, 4, 5) + factory = get_session_factory() + async with factory() as session: + model = await session.get(PermitModel, permit.permit_id) + assert model is not None + model.revoked_at = original + await session.commit() + + second = await service.revoke_permit(permit.permit_id, caller=issuer) + assert second.status == "revoked" + assert second.revoked_at == original + assert await _stored_permit_revocation(permit.permit_id) == ("revoked", original) + + validation = await service.validate_for_action( + permit_id=permit.permit_id, + wallet_id=provisioned["agent_wallet_id"], + tool_name="repeat-revoke-tool", + estimated_credits=Decimal("1"), + key_id=provisioned["key_id"], + ) + assert validation.allowed is False + assert validation.reason == "permit_revoked" + assert validation.details == { + "status": "revoked", + "revoked_at": "2020-01-02T03:04:05Z", + } + + with pytest.raises(PermitError) as missing: + await service.revoke_permit("permit-does-not-exist", caller=issuer) + assert missing.value.reason == "permit_not_found" + + +@pytest.mark.anyio +@pytest.mark.parametrize("terminal_status", ["expired", "exhausted"]) +async def test_revoke_permit_does_not_relabel_non_active_permit( + client, + clean_database, + enforce_naive_utc_datetime_columns, + terminal_status, +): + """Revoke only transitions an active permit. Expired or exhausted stays put.""" + provisioned = await provision_agent_wallet(client) + service = get_permit_service() + permit = await service.create_permit( + PermitCreateRequest( + issuer_wallet_id=provisioned["agent_wallet_id"], + subject_wallet_id=provisioned["agent_wallet_id"], + subject_key_id=provisioned["key_id"], + allowed_tools=["terminal-revoke-tool"], + scopes=["tool:terminal-revoke-tool:invoke"], + max_credits=Decimal("10"), + expires_at=(datetime.now(timezone.utc) + timedelta(minutes=30)).replace( + tzinfo=None + ), + ) + ) + + factory = get_session_factory() + async with factory() as session: + model = await session.get(PermitModel, permit.permit_id) + assert model is not None + model.status = terminal_status + model.revoked_at = None + await session.commit() + + returned = await service.revoke_permit( + permit.permit_id, + caller=CallerIdentity( + wallet_id=provisioned["agent_wallet_id"], key_id=provisioned["key_id"] + ), + ) + assert returned.status == terminal_status + assert returned.revoked_at is None + assert await _stored_permit_revocation(permit.permit_id) == (terminal_status, None) + + @pytest.mark.anyio @pytest.mark.parametrize( ("terminal_state", "expected_reason"), @@ -658,11 +787,20 @@ async def test_permit_service_validation_denial_reasons(client, clean_database): key_id=provisioned["key_id"], ) assert validation.allowed is False - assert validation.reason == "permit_scope_missing" + # An empty allowlist admits no tool: the tool check fires before the + # scope check, so the reason names the empty list, not the missing scope. + assert validation.reason == "permit_tool_not_allowed" + assert validation.details == { + "requested_tool": "service-validate-tool", + "allowed_tools": [], + } async with factory() as session: model = await session.get(PermitModel, permit.permit_id) assert model is not None + # Restore a well-formed allowlist so this block proves signature + # checking (not the empty-allowlist denial asserted above). + model.allowed_tools_json = json.dumps(["service-validate-tool"]) model.scopes_json = json.dumps( ["tool:service-validate-tool:invoke", "billing:charge"] ) @@ -718,7 +856,9 @@ async def test_reserve_budget_refuses_an_expired_permit( session.add(model) with pytest.raises(PermitError) as excinfo: - await service.reserve_budget(permit.permit_id, Decimal("1")) + await service.reserve_budget( + permit.permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) assert str(excinfo.value) == "permit_expired" # And no budget moved. diff --git a/gateway/tests/test_policy_bundles.py b/gateway/tests/test_policy_bundles.py index 38a3c423..bf3c93c3 100644 --- a/gateway/tests/test_policy_bundles.py +++ b/gateway/tests/test_policy_bundles.py @@ -304,7 +304,10 @@ async def test_planner_policy_rejects_actions(client, clean_database): "simulation_flags": {"iot_bridge": False, "agent_comms": False}, "auth_scope": ["invoke"], "task_context": { - "tier": "high", + # "medium" matches the bundle's default risk_tier, so the + # tier ceiling lets both candidates through and this test + # keeps proving only the category rejection. + "tier": "medium", "candidate_actions": [ { "id": "bad-iot", @@ -733,6 +736,197 @@ async def test_policy_refuses_unknown_risk_tier_and_overlong_name( assert stored.json()["name"] == "Tiered" +# --- Risk-tier ceiling and unknown-spend fail-closed --------------------------- +# +# The bundle's risk_tier used to be recorded on the evaluation without ever +# denying, so a "low risk only" bundle still approved high-risk actions. The +# daily spend cap used to be skipped whenever past spending was unknown. Both +# now fail closed. Tier order (low < medium < high) follows the planner tiers +# in app/optimizer/policy.py and the tier_order map in app/routers/mcp.py, so +# a bundle's tier is the highest tier it permits. + + +@pytest.mark.anyio +async def test_policy_enforces_risk_tier_ceiling(client, clean_database): + wallet_id = await _wallet(client, "policy-tier-ceiling") + created = await client.post( + "/v1/policies", + json={"wallet_id": wallet_id, "name": "Low risk only", "risk_tier": "low"}, + headers={"X-API-Key": "test-key"}, + ) + assert created.status_code == 201 + + matching = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="any-tool", risk_tier="low" + ) + assert matching.allowed is True + assert matching.reason == "allowed" + + for tier in ("medium", "high"): + denied = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="any-tool", risk_tier=tier + ) + assert denied.allowed is False, tier + assert denied.reason == "risk_tier_not_allowed", tier + assert ( + denied.evaluated_constraints["evaluated"][-1]["requested_risk_tier"] == tier + ) + + # An unknown requested tier cannot be shown to sit under the ceiling. + unknown = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="any-tool", risk_tier="critical" + ) + assert unknown.allowed is False + assert unknown.reason == "risk_tier_not_allowed" + + # No stated action tier (the MCP and billing callers pass none) means + # there is nothing to compare, so it stays allowed. + unstated = await evaluate_wallet_policy(wallet_id=wallet_id, tool_name="any-tool") + assert unstated.allowed is True + + +@pytest.mark.anyio +async def test_policy_medium_bundle_permits_lower_tier(client, clean_database): + wallet_id = await _wallet(client, "policy-tier-medium") + created = await client.post( + "/v1/policies", + json={"wallet_id": wallet_id, "name": "Default tier bundle"}, + headers={"X-API-Key": "test-key"}, + ) + assert created.status_code == 201 + assert created.json()["risk_tier"] == "medium" + + for tier, allowed in (("low", True), ("medium", True), ("high", False)): + evaluation = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="any-tool", risk_tier=tier + ) + assert evaluation.allowed is allowed, tier + assert evaluation.reason == ("allowed" if allowed else "risk_tier_not_allowed") + + +@pytest.mark.anyio +async def test_policy_denies_daily_cap_when_spend_unknown(client, clean_database): + wallet_id = await _wallet(client, "policy-unknown-spend") + created = await client.post( + "/v1/policies", + json={ + "wallet_id": wallet_id, + "name": "Capped but spend unknown", + "daily_spend_limit": 100, + }, + headers={"X-API-Key": "test-key"}, + ) + assert created.status_code == 201 + + # Past spending unknown: the cap cannot be shown to hold, so deny. + missing = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="any-tool", estimated_cost=1.0 + ) + assert missing.allowed is False + assert missing.reason == "daily_spend_unknown" + + # Known spend still enforces the cap in both directions. + within = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="any-tool", + estimated_cost=1.0, + daily_spend_used=0, + ) + assert within.allowed is True + + over = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="any-tool", + estimated_cost=2.0, + daily_spend_used=99, + ) + assert over.allowed is False + assert over.reason == "daily_spend_limit_exceeded" + + +@pytest.mark.anyio +async def test_planner_enforces_policy_risk_tier_and_daily_cap(client, clean_database): + tier_wallet = await _wallet(client, "policy-planner-tier") + tier_policy = await client.post( + "/v1/policies", + json={ + "wallet_id": tier_wallet, + "name": "Low risk only", + "risk_tier": "low", + }, + headers={"X-API-Key": "test-key"}, + ) + assert tier_policy.status_code == 201 + + cap_wallet = await _wallet(client, "policy-planner-cap") + cap_policy = await client.post( + "/v1/policies", + json={ + "wallet_id": cap_wallet, + "name": "Tiny daily cap", + "daily_spend_limit": 1, + }, + headers={"X-API-Key": "test-key"}, + ) + assert cap_policy.status_code == 201 + + def _payload(wallet_id: str, tier: str, daily_spend_used: float) -> dict: + return { + "state": { + "wallet_id": wallet_id, + "agent_id": "a1", + "task_id": "t1", + "request_id": "policy-planner-guard", + "wallet_balance": 100, + "daily_spend_used": daily_spend_used, + "daily_limit": 100, + "rate_limit_headroom": 1, + "service_health": {"agent_comms": "healthy"}, + "simulation_flags": {"agent_comms": False}, + "auth_scope": ["invoke"], + "task_context": { + "tier": tier, + "candidate_actions": [ + { + "id": "candidate", + "service": "agent_comms", + "credit_cost": 5, + "latency_ms": 10, + "risk_score": 0.01, + "expected_value": 50, + "reliability": 1, + }, + ], + }, + "remaining_budget": 10, + "slo_window_seconds": 1, + }, + "max_actions": 2, + } + + # A high-risk task against a low-risk bundle is rejected at the planner. + tiered = await client.post( + "/v1/planner/optimize", + json=_payload(tier_wallet, "high", 0), + headers={"X-API-Key": "test-key", "X-Request-ID": "policy-planner-tier"}, + ) + assert tiered.status_code == 200 + tiered_body = tiered.json() + assert tiered_body["policy_reasons"] == {"candidate": "risk_tier_not_allowed"} + assert tiered_body["governance"]["policy_ids"] == [tier_policy.json()["policy_id"]] + + # Spending past the daily cap is rejected at the planner. + capped = await client.post( + "/v1/planner/optimize", + json=_payload(cap_wallet, "medium", 0), + headers={"X-API-Key": "test-key", "X-Request-ID": "policy-planner-cap"}, + ) + assert capped.status_code == 200 + capped_body = capped.json() + assert capped_body["policy_reasons"] == {"candidate": "daily_spend_limit_exceeded"} + assert capped_body["governance"]["policy_ids"] == [cap_policy.json()["policy_id"]] + + @pytest.mark.anyio async def test_policy_fields_at_storage_limits_are_accepted(client, clean_database): wallet_id = await _wallet(client, "policy-limits") diff --git a/gateway/tests/test_policy_nonfinite_cost.py b/gateway/tests/test_policy_nonfinite_cost.py new file mode 100644 index 00000000..f25a1326 --- /dev/null +++ b/gateway/tests/test_policy_nonfinite_cost.py @@ -0,0 +1,287 @@ +"""Non-finite money inputs must deny, never crash or bypass policy caps. + +Regression tests for the fail-closed guard in +``app.services.policies.evaluate_wallet_policy``: a NaN estimated cost or +daily spend used to raise ``decimal.InvalidOperation`` out of the evaluator +(an unclassified 500 with no denial recorded), and a negative-infinity cost +is smaller than every cap so it silently passed both monetary gates. Both +now deny with ``policy_cost_invalid``. All HTTP boundaries already refuse +non-finite numbers, so this guard is defense in depth at the trust plane +for direct service callers and future inputs. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.database import get_session_factory +from app.db.models import PolicyBundleModel +from app.main import app +from app.services.policies import evaluate_wallet_policy + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def _wallet(client: AsyncClient, agent_id: str) -> str: + headers = {"X-API-Key": "test-key"} + sponsor = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": f"{agent_id} sponsor", + "email": f"{agent_id}@example.com", + "initial_credits": 10000, + "require_kyc": False, + }, + headers=headers, + ) + assert sponsor.status_code == 201 + agent = await client.post( + "/v1/billing/wallets/agent", + json={ + "sponsor_wallet_id": sponsor.json()["wallet_id"], + "agent_id": agent_id, + "budget_credits": 1000, + }, + headers=headers, + ) + assert agent.status_code == 201 + return agent.json()["wallet_id"] + + +async def _capped_wallet(client: AsyncClient, agent_id: str) -> str: + wallet_id = await _wallet(client, agent_id) + created = await client.post( + "/v1/policies", + json={ + "wallet_id": wallet_id, + "name": "Capped", + "max_cost_per_action": 5, + "daily_spend_limit": 50, + }, + headers={"X-API-Key": "test-key"}, + ) + assert created.status_code == 201 + return wallet_id + + +@pytest.mark.anyio +@pytest.mark.parametrize("field", ["estimated_cost", "daily_spend_used"]) +@pytest.mark.parametrize( + "value", + [ + float("nan"), + float("inf"), + float("-inf"), + Decimal("NaN"), + Decimal("sNaN"), + Decimal("Infinity"), + Decimal("-Infinity"), + ], +) +async def test_nonfinite_money_denies_without_comparison( + client, clean_database, field, value +): + wallet_id = await _capped_wallet(client, "policy-nonfinite") + money = {"estimated_cost": 1.0, "daily_spend_used": 0.0} + money[field] = value + evaluation = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="some-tool", **money + ) + assert evaluation.allowed is False + assert evaluation.reason == "policy_cost_invalid" + assert evaluation.policy_id is None + assert evaluation.evaluated_constraints == {"policy_count": 1} + + +@pytest.mark.anyio +async def test_finite_costs_keep_existing_allow_and_deny_behavior( + client, clean_database +): + wallet_id = await _capped_wallet(client, "policy-finite-cost") + allowed = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=2.0, + daily_spend_used=10.0, + ) + assert allowed.allowed is True + assert allowed.reason == "allowed" + + at_action_cap = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=Decimal("5"), + daily_spend_used=0.0, + ) + assert at_action_cap.allowed is True + + at_daily_cap = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=Decimal("2"), + daily_spend_used=Decimal("48"), + ) + assert at_daily_cap.allowed is True + + over_max = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=6.0, + daily_spend_used=0.0, + ) + assert over_max.allowed is False + assert over_max.reason == "max_cost_per_action_exceeded" + + over_daily = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=2.0, + daily_spend_used=49.0, + ) + assert over_daily.allowed is False + assert over_daily.reason == "daily_spend_limit_exceeded" + + +@pytest.mark.anyio +async def test_unknown_cost_and_daily_spend_keep_current_contract( + client, clean_database +): + wallet_id = await _capped_wallet(client, "policy-none-cost") + evaluation = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=None, + daily_spend_used=0, + ) + assert evaluation.allowed is True + assert evaluation.reason == "allowed" + + missing_daily = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=None, + daily_spend_used=None, + ) + assert missing_daily.allowed is False + assert missing_daily.reason == "daily_spend_unknown" + + +@pytest.mark.anyio +async def test_nonfinite_cost_with_no_bundles_stays_allowed(client, clean_database): + # With no policy bundles there are no caps to evaluate, matching the + # documented no-bundles-allow default. + wallet_id = await _wallet(client, "policy-nan-nobundle") + evaluation = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + estimated_cost=float("nan"), + ) + assert evaluation.allowed is True + assert evaluation.reason == "allowed" + + daily_nonfinite = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + daily_spend_used=Decimal("sNaN"), + ) + assert daily_nonfinite.allowed is True + assert daily_nonfinite.reason == "allowed" + + +@pytest.mark.anyio +async def test_explicit_empty_allowlist_denies_every_tool(client, clean_database): + # Behavior pin for the "empty policy" case: an explicit empty allowlist + # is deny-all, never unrestricted (NULL is the only unrestricted value). + wallet_id = await _wallet(client, "policy-empty-allowlist") + created = await client.post( + "/v1/policies", + json={ + "wallet_id": wallet_id, + "name": "Deny all tools", + "allowed_tools": [], + }, + headers={"X-API-Key": "test-key"}, + ) + assert created.status_code == 201 + for tool_name in ("some-tool", ""): + evaluation = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name=tool_name + ) + assert evaluation.allowed is False, tool_name + assert evaluation.reason == "tool_not_allowed", tool_name + + +@pytest.mark.anyio +async def test_corrupt_allowlist_still_denies(client, clean_database): + wallet_id = await _wallet(client, "policy-corrupt-allowlist") + created = await client.post( + "/v1/policies", + json={"wallet_id": wallet_id, "name": "Tools", "allowed_tools": ["some-tool"]}, + headers={"X-API-Key": "test-key"}, + ) + assert created.status_code == 201 + factory = get_session_factory() + async with factory() as session: + policy = await session.get(PolicyBundleModel, created.json()["policy_id"]) + assert policy is not None + policy.allowed_tools_json = "not-json" + await session.commit() + + evaluation = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="some-tool" + ) + assert evaluation.allowed is False + assert evaluation.reason == "policy_constraint_corrupt" + + +@pytest.mark.anyio +async def test_risk_ceiling_and_approval_gate_still_apply(client, clean_database): + wallet_id = await _wallet(client, "policy-risk-approval") + created = await client.post( + "/v1/policies", + json={ + "wallet_id": wallet_id, + "name": "Low risk with approval", + "risk_tier": "low", + "human_approval_required": True, + }, + headers={"X-API-Key": "test-key"}, + ) + assert created.status_code == 201 + + no_gate = await evaluate_wallet_policy( + wallet_id=wallet_id, tool_name="some-tool", risk_tier="low" + ) + assert no_gate.allowed is False + assert no_gate.reason == "human_approval_required" + + high_risk = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + risk_tier="high", + approval_gate_active=True, + ) + assert high_risk.allowed is False + assert high_risk.reason == "risk_tier_not_allowed" + + approved = await evaluate_wallet_policy( + wallet_id=wallet_id, + tool_name="some-tool", + risk_tier="low", + approval_gate_active=True, + ) + assert approved.allowed is True + assert ( + approved.evaluated_constraints["evaluated"][0][ + "human_approval_satisfied_by_gate" + ] + is True + ) diff --git a/gateway/tests/test_postgres_datetime_binding.py b/gateway/tests/test_postgres_datetime_binding.py index 81f65f57..379493fd 100644 --- a/gateway/tests/test_postgres_datetime_binding.py +++ b/gateway/tests/test_postgres_datetime_binding.py @@ -74,11 +74,13 @@ def test_scalar_and_none_parameters_are_safe(self): assert _normalize_bind_parameters("raw") == "raw" +from tests.support import db_gate + POSTGRES_URL = os.environ.get("TEST_POSTGRES_URL", "").strip() -@pytest.mark.skipif( - not POSTGRES_URL, +@db_gate.collection_mark( + url_env="TEST_POSTGRES_URL", reason="TEST_POSTGRES_URL not set; Postgres-dialect coverage skipped", ) class TestPostgresTrustLoop: diff --git a/gateway/tests/test_precommit_mypy_hook.py b/gateway/tests/test_precommit_mypy_hook.py new file mode 100644 index 00000000..5cd8dab5 --- /dev/null +++ b/gateway/tests/test_precommit_mypy_hook.py @@ -0,0 +1,88 @@ +"""Keep the local mypy hook aligned with the gateway's installed dependencies.""" + +from __future__ import annotations + +import re +import shlex +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] + + +def _mypy_hook() -> dict[str, object]: + config = yaml.safe_load((ROOT / ".pre-commit-config.yaml").read_text()) + hooks = [ + hook + for repo in config["repos"] + if repo["repo"] == "local" + for hook in repo["hooks"] + if hook["id"] == "mypy" + ] + assert len(hooks) == 1 + return hooks[0] + + +def test_mypy_hook_uses_isolated_checkout_dependencies() -> None: + hook = _mypy_hook() + tokens = shlex.split(hook["entry"]) + + assert hook["language"] == "system" + assert hook["pass_filenames"] is False + assert hook["require_serial"] is True + assert tokens[:4] == ["uv", "run", "--directory", "gateway"] + assert {"--no-project", "--isolated", "--no-env-file"} <= set(tokens) + assert [ + tokens[index + 1] + for index, token in enumerate(tokens) + if token == "--with-requirements" + ] == ["requirements.txt", "requirements-dev.txt"] + assert tokens[tokens.index("--with-editable") + 1] == "../sdk/python[verify]" + assert tokens[-6:] == ["python", "-m", "mypy", "--config-file", "mypy.ini", "."] + assert tokens.count(".") == 1 + + +def test_mypy_hook_triggers_on_every_type_environment_input() -> None: + pattern = re.compile(_mypy_hook()["files"]) + required = ( + ".pre-commit-config.yaml", + "gateway/app/main.py", + "gateway/typecheck/scopes_coroutine.py", + "gateway/mypy.ini", + "gateway/requirements.txt", + "gateway/requirements-dev.txt", + "gateway/pyproject.toml", + "sdk/python/src/b2a_sdk/models.py", + "sdk/python/src/b2a_sdk/py.typed", + "sdk/python/pyproject.toml", + ) + for path in required: + assert pattern.fullmatch(path), path + for path in ("gateway/tests/test_api_keys.py", "sdk/python/tests/test_sdk.py"): + assert not pattern.fullmatch(path), path + + +def test_gateway_python312_ci_runs_the_actual_hook() -> None: + workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text()) + steps = workflow["jobs"]["python"]["steps"] + hook_steps = [ + step for step in steps if step.get("name") == "Pre-commit mypy (gateway)" + ] + assert len(hook_steps) == 1 + step = hook_steps[0] + assert ( + step["if"] == "matrix.project == 'gateway' && matrix.python-version == '3.12'" + ) + assert step["working-directory"] == "." + assert step["env"]["UV_PYTHON"] == "3.12" + run = step["run"] + assert "hook=(uv tool run --from pre-commit==4.6.0 pre-commit run mypy)" in run + assert run.count('"${hook[@]}" --all-files') == 2 + assert "mktemp gateway/typecheck/_mypy_hook_probe_XXXXXXXX" in run + assert 'trap \'rm -f "$probe_stem" "$probe_stem.py"\' EXIT' in run + assert '"${hook[@]}" --files "$probe"' in run + assert '"$probe_status" -ne 1' in run + assert "Incompatible return value type" in run + assert "Found 1 error in 1 file" in run diff --git a/gateway/tests/test_public_mcp_endpoint.py b/gateway/tests/test_public_mcp_endpoint.py index 5f1f4543..81237af9 100644 --- a/gateway/tests/test_public_mcp_endpoint.py +++ b/gateway/tests/test_public_mcp_endpoint.py @@ -626,37 +626,6 @@ async def test_expected_issuer_mismatch_is_not_a_signature_verdict( ("bundle", "expected_status"), [ ("not-json", "malformed"), - # The unsupported cases must be declared inside the SIGNED bytes. - # Reading them from the envelope is what let an edited byte downgrade a - # genuine receipt to "this verifier is too old", so the envelope is no - # longer trusted for that decision -- which also means an envelope-only - # bad alg is no longer cheaply rejectable, by design. - ( - { - **_unverifiable_bundle(), - "signing_input": json.dumps( - { - "receipt_id": "rcpt-no-key", - "kid": "no-published-key", - "alg": "RSA", - } - ), - }, - "unsupported", - ), - ( - { - **_unverifiable_bundle(), - "signing_input": json.dumps( - { - "receipt_id": "rcpt-no-key", - "kid": "no-published-key", - "canonicalization": "unknown/1", - } - ), - }, - "unsupported", - ), ], ) async def test_preflight_failures_do_not_touch_the_key_store( @@ -679,6 +648,69 @@ async def fail_if_called(): assert resp.json()["result"]["structuredContent"]["status"] == expected_status +@pytest.mark.anyio +@pytest.mark.parametrize( + "signing_input", + [ + json.dumps( + { + "receipt_id": "rcpt-no-key", + "kid": "no-published-key", + "alg": "RSA", + } + ), + json.dumps( + { + "receipt_id": "rcpt-no-key", + "kid": "no-published-key", + "canonicalization": "unknown/1", + } + ), + ], +) +async def test_unverified_algorithm_claim_is_unknown_key_not_unsupported( + client, public_mcp_enabled, signing_input +): + """A changed alg is not "unsupported" until a signature check can say so. + + This kid is not published, so the status is unknown_key. Calling it + unsupported used to skip the key store and hide a tampered receipt whose + key the gateway does hold. + """ + await get_signing_key_service().ensure_active_key() + bundle = {**_unverifiable_bundle(), "signing_input": signing_input} + resp = await client.post( + PUBLIC_PATH, + json=_call("verify_receipt", {"bundle": bundle}), + headers=MCP_HEADERS, + ) + + assert resp.json()["result"]["structuredContent"]["status"] == "unknown_key" + + +@pytest.mark.anyio +async def test_verify_receipt_rejects_an_algorithm_edit_as_invalid( + client, public_mcp_enabled +): + bundle = await _signed_bundle() + await get_signing_key_service().ensure_active_key() + forged = { + **bundle, + "signing_input": bundle["signing_input"].replace( + '"alg":"Ed25519"', '"alg":"RS256"', 1 + ), + } + resp = await client.post( + PUBLIC_PATH, + json=_call("verify_receipt", {"bundle": forged}), + headers=MCP_HEADERS, + ) + structured = resp.json()["result"]["structuredContent"] + assert structured["ok"] is False + assert structured["status"] == "invalid" + assert structured["claims"] == {} + + @pytest.mark.anyio async def test_key_store_failure_is_retryable_not_tampering( client, public_mcp_enabled, monkeypatch diff --git a/gateway/tests/test_qa_money_auth_matrix.py b/gateway/tests/test_qa_money_auth_matrix.py new file mode 100644 index 00000000..4d5e7c1b --- /dev/null +++ b/gateway/tests/test_qa_money_auth_matrix.py @@ -0,0 +1,551 @@ +"""QA matrix for money-moving routes: auth plus cross-wallet access. + +Consolidates the checks an API consumer needs in one place: every route +that moves money (charge, transfer, top-up prepare, permit revoke, refund +retry, x402 settle) answers 401 with no credentials, refuses a key bound +to a foreign wallet with 403 without moving funds, and accepts a JWT +jwt_headers minted for the owning wallet. + +Each test below covers behavior no existing test asserts (checked against +test_billing, test_transfers, test_permits, test_refund_reconciliation, +test_stripe_integration, test_x402, test_jwt_auth and +test_arbitrage_regressions before writing). The module stem is registered +in conftest's DORMANT_SURFACE_TEST_MODULES so the transfer, top-up, x402 +and auth-token routers are mounted, mirroring production with +ENABLE_PROOF_SURFACES=true. + +No test touches the network: top-up prepare paths that would reach Stripe +are either refused before the Stripe call (auth, wallet-type checks) or +run with Stripe's PaymentIntent.create patched out. +""" + +from __future__ import annotations + +import itertools +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.config import get_settings +from app.main import app +from tests.test_trust_helpers import ( + BOOTSTRAP_HEADERS, + create_tool_permit, + provision_agent_wallet, +) + +# 32 raw bytes, strict base64. Same non-secret test material CI uses. +TEST_SIGNING_KEY = "dGVzdC1zaWduaW5nLWtleS1tYXRlcmlhbC0zMmJ5dGU=" + +EVM_PAY_TO = "0x1111111111111111111111111111111111111111" +EVM_PAYER = "0x2222222222222222222222222222222222222222" + +_counter = itertools.count(1) + + +@pytest.fixture(autouse=True) +async def _clean_shared_db(clean_database): + """Every money-route test starts from an empty shared database.""" + yield + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def api_headers(): + return dict(BOOTSTRAP_HEADERS) + + +@pytest.fixture +def signing_key(monkeypatch): + monkeypatch.setenv("TRUST_SIGNING_PRIVATE_KEY_B64", TEST_SIGNING_KEY) + get_settings.cache_clear() + try: + yield + finally: + get_settings.cache_clear() + + +async def _wallet_balance(client, wallet_id, headers) -> Decimal: + resp = await client.get(f"/v1/billing/wallets/{wallet_id}", headers=headers) + assert resp.status_code == 200, resp.text + return Decimal(str(resp.json()["balance_exact"])) + + +async def _exchange(client, api_key: str, scopes: list[str] | None = None) -> dict: + body: dict = {"api_key": api_key} + if scopes is not None: + body["scopes"] = scopes + resp = await client.post("/v1/auth/token", json=body) + assert resp.status_code == 200, resp.text + return resp.json() + + +def _settle_body(permit_id: str, wallet_id: str) -> dict: + return { + "permit_id": permit_id, + "wallet_id": wallet_id, + "amount": "0.01", + "pay_to": EVM_PAY_TO, + "network": "base", + "payer": EVM_PAYER, + } + + +# --------------------------------------------------------------------------- +# Transfer +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_transfer_rejects_missing_api_key(client, api_headers): + sponsor = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": "QA Matrix A", + "email": "qa-matrix-a@t.com", + "initial_credits": 1000, + }, + headers=api_headers, + ) + sender_id = sponsor.json()["wallet_id"] + resp = await client.post( + "/v1/billing/transfer", + params={"from_wallet_id": sender_id, "to_wallet_id": sender_id, "amount": 10}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.anyio +async def test_transfer_rejects_malformed_and_unknown_keys_without_oracle( + client, api_headers +): + sponsor = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": "QA Matrix B", + "email": "qa-matrix-b@t.com", + "initial_credits": 1000, + }, + headers=api_headers, + ) + real_wallet = sponsor.json()["wallet_id"] + fake_wallet = "spn-does-not-exist" + cases = ( + ({"X-API-Key": "short"}, 401), + ({"X-API-Key": "not-a-real-key"}, 403), + ) + for headers, expected in cases: + real = await client.post( + "/v1/billing/transfer", + params={ + "from_wallet_id": real_wallet, + "to_wallet_id": fake_wallet, + "amount": 10, + }, + headers=headers, + ) + fake = await client.post( + "/v1/billing/transfer", + params={ + "from_wallet_id": fake_wallet, + "to_wallet_id": real_wallet, + "amount": 10, + }, + headers=headers, + ) + assert real.status_code == expected, real.text + assert fake.status_code == expected, fake.text + assert real.text == fake.text + assert real_wallet not in real.text + + +@pytest.mark.anyio +async def test_transfer_negative_amount_is_422(client, api_headers): + first = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": "QA Matrix C1", + "email": "qa-matrix-c1@t.com", + "initial_credits": 1000, + }, + headers=api_headers, + ) + second = await client.post( + "/v1/billing/wallets/sponsor", + json={"sponsor_name": "QA Matrix C2", "email": "qa-matrix-c2@t.com"}, + headers=api_headers, + ) + resp = await client.post( + "/v1/billing/transfer", + params={ + "from_wallet_id": first.json()["wallet_id"], + "to_wallet_id": second.json()["wallet_id"], + "amount": -5, + }, + headers=api_headers, + ) + assert resp.status_code == 422, resp.text + + +@pytest.mark.anyio +async def test_transfer_scoped_key_cannot_spend_foreign_wallet(client, api_headers): + owner = await provision_agent_wallet(client) + other = await provision_agent_wallet(client) + before = await _wallet_balance(client, other["agent_wallet_id"], api_headers) + resp = await client.post( + "/v1/billing/transfer", + params={ + "from_wallet_id": other["agent_wallet_id"], + "to_wallet_id": owner["agent_wallet_id"], + "amount": 10, + }, + headers=owner["agent_headers"], + ) + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error"] == "wallet_access_denied" + after = await _wallet_balance(client, other["agent_wallet_id"], api_headers) + assert after == before + + +@pytest.mark.anyio +async def test_transfer_scoped_key_can_spend_own_wallet(client, api_headers): + owner = await provision_agent_wallet(client) + other = await provision_agent_wallet(client) + before = await _wallet_balance(client, owner["agent_wallet_id"], api_headers) + resp = await client.post( + "/v1/billing/transfer", + params={ + "from_wallet_id": owner["agent_wallet_id"], + "to_wallet_id": other["agent_wallet_id"], + "amount": 25, + }, + headers=owner["agent_headers"], + ) + assert resp.status_code == 200, resp.text + after = await _wallet_balance(client, owner["agent_wallet_id"], api_headers) + assert after == before - Decimal("25") + + +# --------------------------------------------------------------------------- +# Top-up +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_direct_top_up_rejects_unauthenticated_before_410(client, api_headers): + sponsor = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": "QA Matrix D", + "email": "qa-matrix-d@t.com", + "initial_credits": 1000, + }, + headers=api_headers, + ) + wallet_id = sponsor.json()["wallet_id"] + resp = await client.post( + "/v1/billing/top-up", + json={"wallet_id": wallet_id, "amount_fiat": 5.0, "payment_method": "card"}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.anyio +async def test_top_up_prepare_rejects_missing_api_key(client, api_headers): + sponsor = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": "QA Matrix E", + "email": "qa-matrix-e@t.com", + "initial_credits": 1000, + }, + headers=api_headers, + ) + resp = await client.post( + "/v1/billing/top-up/prepare" + f"?wallet_id={sponsor.json()['wallet_id']}&amount_fiat=5.0", + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.anyio +async def test_top_up_prepare_denies_foreign_wallet_key(client, api_headers): + owner = await provision_agent_wallet(client) + other = await provision_agent_wallet(client) + with patch( + "app.services.stripe_integration.stripe.PaymentIntent.create" + ) as mock_create: + mock_create.side_effect = AssertionError("Stripe must not be reached") + resp = await client.post( + "/v1/billing/top-up/prepare" + f"?wallet_id={other['agent_wallet_id']}&amount_fiat=5.0", + headers=owner["agent_headers"], + ) + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error"] == "wallet_access_denied" + + +@pytest.mark.anyio +async def test_top_up_prepare_scoped_key_succeeds_for_own_sponsor(client, api_headers): + sponsor = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": "QA Matrix F", + "email": "qa-matrix-f@t.com", + "initial_credits": 1000, + }, + headers=api_headers, + ) + wallet_id = sponsor.json()["wallet_id"] + key_resp = await client.post( + "/v1/api-keys", json={"wallet_id": wallet_id}, headers=api_headers + ) + assert key_resp.status_code == 201, key_resp.text + scoped = {"X-API-Key": key_resp.json()["api_key"]} + with patch( + "app.services.stripe_integration.stripe.PaymentIntent.create" + ) as mock_create: + mock_create.return_value = MagicMock( + id="pi_qa_matrix", + client_secret="pi_qa_matrix_secret", + status="requires_payment_method", + ) + resp = await client.post( + f"/v1/billing/top-up/prepare?wallet_id={wallet_id}&amount_fiat=5.0", + headers=scoped, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["payment_intent_id"] == "pi_qa_matrix" + + +# --------------------------------------------------------------------------- +# Permit reserve and revoke +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_permit_revoke_rejects_missing_api_key(client): + resp = await client.post("/v1/permits/permit-does-not-exist/revoke") + assert resp.status_code == 401, resp.text + + +@pytest.mark.anyio +async def test_permit_revoke_unknown_permit_is_404(client, api_headers): + resp = await client.post( + "/v1/permits/permit-does-not-exist/revoke", headers=api_headers + ) + assert resp.status_code == 404, resp.text + + +@pytest.mark.anyio +async def test_permit_revoke_denies_foreign_wallet_key(client): + victim = await provision_agent_wallet(client) + attacker = await provision_agent_wallet(client) + permit = await create_tool_permit( + client, + wallet_id=victim["agent_wallet_id"], + key_id=victim["key_id"], + tool_name="billing.charge", + max_credits=50, + idem_key=f"qa-matrix-revoke-{next(_counter)}", + ) + resp = await client.post( + f"/v1/permits/{permit['permit_id']}/revoke", + headers=attacker["agent_headers"], + ) + assert resp.status_code == 403, resp.text + check = await client.get( + f"/v1/permits/{permit['permit_id']}", headers=BOOTSTRAP_HEADERS + ) + assert check.status_code == 200, check.text + assert check.json()["status"] == "active" + + +@pytest.mark.anyio +async def test_permit_revoke_bootstrap_admin_succeeds(client, api_headers): + provisioned = await provision_agent_wallet(client) + permit = await create_tool_permit( + client, + wallet_id=provisioned["agent_wallet_id"], + key_id=provisioned["key_id"], + tool_name="billing.charge", + max_credits=50, + idem_key=f"qa-matrix-revoke-{next(_counter)}", + ) + resp = await client.post( + f"/v1/permits/{permit['permit_id']}/revoke", headers=api_headers + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "revoked" + + +@pytest.mark.anyio +async def test_transfer_bootstrap_admin_can_spend_any_wallet(client, api_headers): + owner = await provision_agent_wallet(client) + other = await provision_agent_wallet(client) + before = await _wallet_balance(client, other["agent_wallet_id"], api_headers) + resp = await client.post( + "/v1/billing/transfer", + params={ + "from_wallet_id": other["agent_wallet_id"], + "to_wallet_id": owner["agent_wallet_id"], + "amount": 25, + }, + headers=api_headers, + ) + assert resp.status_code == 200, resp.text + after = await _wallet_balance(client, other["agent_wallet_id"], api_headers) + assert after == before - Decimal("25") + + +@pytest.mark.anyio +async def test_permit_revoke_owner_succeeds(client): + provisioned = await provision_agent_wallet(client) + permit = await create_tool_permit( + client, + wallet_id=provisioned["agent_wallet_id"], + key_id=provisioned["key_id"], + tool_name="billing.charge", + max_credits=50, + idem_key=f"qa-matrix-revoke-{next(_counter)}", + ) + resp = await client.post( + f"/v1/permits/{permit['permit_id']}/revoke", + headers=provisioned["agent_headers"], + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "revoked" + + +# --------------------------------------------------------------------------- +# Refund reconciliation +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_refund_list_rejects_missing_api_key(client): + resp = await client.get("/v1/receipts/reconciliation/refunds") + assert resp.status_code == 401, resp.text + + +@pytest.mark.anyio +async def test_refund_retry_rejects_missing_api_key(client): + resp = await client.post( + "/v1/receipts/reconciliation/refunds/rcpt-does-not-exist/retry" + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.anyio +async def test_refund_retry_admin_reaches_handler_for_unknown_receipt( + client, api_headers +): + resp = await client.post( + "/v1/receipts/reconciliation/refunds/rcpt-does-not-exist/retry", + headers=api_headers, + ) + assert resp.status_code == 404, resp.text + + +# --------------------------------------------------------------------------- +# x402 settle +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_x402_settle_rejects_missing_api_key(client): + provisioned = await provision_agent_wallet(client) + resp = await client.post( + "/v1/x402/settle", + json=_settle_body("permit-does-not-exist", provisioned["agent_wallet_id"]), + headers={"Idempotency-Key": f"qa-matrix-x402-{next(_counter)}"}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.anyio +async def test_x402_settle_rejects_unknown_key(client): + provisioned = await provision_agent_wallet(client) + resp = await client.post( + "/v1/x402/settle", + json=_settle_body("permit-does-not-exist", provisioned["agent_wallet_id"]), + headers={ + "X-API-Key": "not-a-real-key", + "Idempotency-Key": f"qa-matrix-x402-{next(_counter)}", + }, + ) + assert resp.status_code == 403, resp.text + + +# --------------------------------------------------------------------------- +# JWT and admin on money routes +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_auth_token_exchange_rejects_invalid_key(client): + resp = await client.post("/v1/auth/token", json={"api_key": "not-a-real-key"}) + assert resp.status_code == 401, resp.text + assert resp.json()["detail"]["error"] == "invalid_api_key" + + +@pytest.mark.anyio +async def test_jwt_bearer_can_charge_own_wallet(client, signing_key): + provisioned = await provision_agent_wallet(client) + tokens = await _exchange(client, provisioned["agent_headers"]["X-API-Key"]) + jwt_headers = {"Authorization": f"Bearer {tokens['access_token']}"} + before = await _wallet_balance( + client, provisioned["agent_wallet_id"], BOOTSTRAP_HEADERS + ) + resp = await client.post( + f"/v1/billing/charge?wallet_id={provisioned['agent_wallet_id']}" + "&service=iot_bridge&units=10", + headers=jwt_headers, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["action"] == "debit" + after = await _wallet_balance( + client, provisioned["agent_wallet_id"], BOOTSTRAP_HEADERS + ) + assert after == before - Decimal("20") + + +@pytest.mark.anyio +async def test_jwt_bearer_cannot_charge_foreign_wallet(client, signing_key): + owner = await provision_agent_wallet(client) + other = await provision_agent_wallet(client) + tokens = await _exchange(client, owner["agent_headers"]["X-API-Key"]) + jwt_headers = {"Authorization": f"Bearer {tokens['access_token']}"} + before = await _wallet_balance(client, other["agent_wallet_id"], BOOTSTRAP_HEADERS) + resp = await client.post( + f"/v1/billing/charge?wallet_id={other['agent_wallet_id']}" + "&service=iot_bridge&units=10", + headers=jwt_headers, + ) + assert resp.status_code == 403, resp.text + assert resp.json()["detail"]["error"] == "wallet_access_denied" + after = await _wallet_balance(client, other["agent_wallet_id"], BOOTSTRAP_HEADERS) + assert after == before + + +@pytest.mark.anyio +async def test_tampered_jwt_bearer_rejected_on_charge(client, signing_key): + provisioned = await provision_agent_wallet(client) + tokens = await _exchange(client, provisioned["agent_headers"]["X-API-Key"]) + tampered = tokens["access_token"][:-1] + ( + "a" if tokens["access_token"][-1] != "a" else "b" + ) + resp = await client.post( + f"/v1/billing/charge?wallet_id={provisioned['agent_wallet_id']}" + "&service=iot_bridge&units=1", + headers={"Authorization": f"Bearer {tampered}"}, + ) + assert resp.status_code == 401, resp.text diff --git a/gateway/tests/test_qa_svc_money_gaps.py b/gateway/tests/test_qa_svc_money_gaps.py new file mode 100644 index 00000000..c4e83913 --- /dev/null +++ b/gateway/tests/test_qa_svc_money_gaps.py @@ -0,0 +1,582 @@ +"""Deep QA gaps across the money services. + +Covers validation and safety behavior the existing suite does not pin down: + +- pricing: exact-price override precedence and quote/charge unit conversion. +- child wallet TTL bounds (bools, zero, oversize). +- quote creation amount validation and release semantics. +- transfer argument validation and non-finite amounts moving no money. +- sponsor creation refusing negative seed credits (service level). +- refund idempotency and wallet scoping. +- receipt request-identity and reason-code validation. +- x402 requirement strictness (asset case, USDC precision, nonce determinism). +- Stripe settlement-field validation and the payment-failed notify path. + +All tests are fast and deterministic: no sleeps, no network. DB-backed tests +use the shared ``clean_database`` fixture and the service facade directly. +""" + +from __future__ import annotations + +from datetime import timedelta +from decimal import Decimal +from typing import Any + +import pytest +from sqlalchemy import func, select + +from app.core.config import get_settings +from app.db.database import get_session_factory +from app.db.models import LedgerEntryModel, QuoteModel, WalletModel +from app.schemas.billing import ServiceCategory +from app.services.agent_money import get_agent_money +from app.services.ownership import CallerIdentity +from app.services.pricing import charge_units_for, tool_price +from app.services.quotes import QuoteError, QuoteService +from app.services.receipts import ReceiptError, ReceiptService +from app.services.stripe_integration import ( + StripeIntegration, + StripeSettlementError, +) +from app.services.wallet_engine import ( + MAX_CHILD_WALLET_TTL_SECONDS, + _validate_child_wallet_ttl, +) +from app.services.x402_engine import X402Error, X402PaymentHandler + + +def test_charge_units_for_roundtrips_through_default_price(): + for category in ( + ServiceCategory.AGENT_COMMS, + ServiceCategory.PLATFORM_FEE, + ServiceCategory.MEDIA_ENGINE, + ): + price = tool_price({}, category) + credits = price * 3 + units = charge_units_for(credits, category) + assert units == Decimal("3") + + +def test_tool_price_exact_override_wins_over_registered_price(): + service = { + "credits_per_unit": Decimal("5.0"), + "credits_per_unit_exact": Decimal("2.5"), + } + assert tool_price(service, ServiceCategory.AGENT_COMMS) == Decimal("2.5") + + +def test_tool_price_falls_back_to_category_default(): + assert tool_price({}, ServiceCategory.AGENT_COMMS) == Decimal("1.5") + + +@pytest.mark.parametrize( + "bad_ttl", + [True, False, 0, -1, -3600, MAX_CHILD_WALLET_TTL_SECONDS + 1, "3600", 3.5], +) +def test_child_wallet_ttl_rejects_bad_values(bad_ttl: Any): + with pytest.raises((ValueError, TypeError)): + _validate_child_wallet_ttl(bad_ttl) + + +@pytest.mark.parametrize("good_ttl", [None, 1, 60, MAX_CHILD_WALLET_TTL_SECONDS]) +def test_child_wallet_ttl_accepts_bounds(good_ttl: Any): + _validate_child_wallet_ttl(good_ttl) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "bad_amount", + [ + Decimal("-1"), + Decimal("-0.00000001"), + Decimal("0"), + Decimal("NaN"), + Decimal("Infinity"), + Decimal("-Infinity"), + ], +) +async def test_quote_create_rejects_non_positive_or_non_finite( + bad_amount: Decimal, +): + service = QuoteService() + with pytest.raises(QuoteError): + await service.create_quote( + wallet_id="wlt-qa-unused", + tool="qa-tool", + quoted_credits=bad_amount, + category=ServiceCategory.AGENT_COMMS.value, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad_amount", [Decimal("0"), Decimal("-5"), Decimal("NaN")]) +async def test_transfer_rejects_non_positive_amounts(bad_amount: Decimal): + money = get_agent_money() + with pytest.raises(ValueError, match="Transfer amount must be positive"): + await money.transfer( + from_wallet_id="wlt-qa-a", + to_wallet_id="wlt-qa-b", + amount=bad_amount, + caller=CallerIdentity.for_wallet("wlt-qa-a"), + ) + + +@pytest.mark.asyncio +async def test_transfer_rejects_self_transfer(): + money = get_agent_money() + with pytest.raises(ValueError, match="same wallet"): + await money.transfer( + from_wallet_id="wlt-qa-a", + to_wallet_id="wlt-qa-a", + amount=Decimal("1"), + caller=CallerIdentity.for_wallet("wlt-qa-a"), + ) + + +@pytest.mark.asyncio +async def test_is_wallet_or_descendant_matches_self_without_a_lookup(): + money = get_agent_money() + assert await money.is_wallet_or_descendant("wlt-qa-a", "wlt-qa-a") is True + + +@pytest.mark.asyncio +async def test_sponsor_rejects_negative_seed_credits(clean_database): + money = get_agent_money() + with pytest.raises(ValueError, match="cannot be negative"): + await money.create_sponsor_wallet( + sponsor_name="Negative Seed", + email="negative@example.com", + initial_credits=Decimal("-100"), + ) + factory = get_session_factory() + async with factory() as session: + count = ( + await session.execute( + select(func.count()) + .select_from(WalletModel) + .where(WalletModel.owner_name == "Negative Seed") + ) + ).scalar_one() + assert count == 0 + + +async def _funded_agent(money, *, sponsor_credits="1000", budget="100"): + sponsor = await money.create_sponsor_wallet( + sponsor_name="QA Sponsor", + email="qa@example.com", + initial_credits=Decimal(sponsor_credits), + require_kyc=False, + ) + agent = await money.create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="qa-agent", + budget_credits=Decimal(budget), + caller=CallerIdentity.for_wallet(sponsor.wallet_id), + ) + return sponsor, agent + + +async def _ledger_count(wallet_id: str) -> int: + factory = get_session_factory() + async with factory() as session: + return ( + await session.execute( + select(func.count()) + .select_from(LedgerEntryModel) + .where(LedgerEntryModel.wallet_id == wallet_id) + ) + ).scalar_one() + + +@pytest.mark.asyncio +async def test_non_finite_transfer_moves_no_money(clean_database): + money = get_agent_money() + sponsor, agent = await _funded_agent(money) + before = (await money.get_wallet(agent.wallet_id)).balance + with pytest.raises(Exception): + await money.transfer( + from_wallet_id=agent.wallet_id, + to_wallet_id=sponsor.wallet_id, + amount=Decimal("Infinity"), + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + with pytest.raises(Exception): + await money.transfer( + from_wallet_id=agent.wallet_id, + to_wallet_id=sponsor.wallet_id, + amount=Decimal("NaN"), + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + after = await money.get_wallet(agent.wallet_id) + assert after.balance == before + assert await _ledger_count(agent.wallet_id) == 1 + + +@pytest.mark.asyncio +async def test_infinite_transfer_is_rejected_before_debit(clean_database): + money = get_agent_money() + sponsor, agent = await _funded_agent(money) + with pytest.raises(ValueError, match="Transfer amount must be positive"): + await money.transfer( + from_wallet_id=agent.wallet_id, + to_wallet_id=sponsor.wallet_id, + amount=Decimal("Infinity"), + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + + +@pytest.mark.asyncio +async def test_refund_recredit_is_idempotent_and_scoped(clean_database): + money = get_agent_money() + _, agent = await _funded_agent(money) + charge = await money.charge( + wallet_id=agent.wallet_id, + service_category=ServiceCategory.PLATFORM_FEE, + units=Decimal("2"), + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + assert charge.balance_after is not None + first = await money.refund_charge( + wallet_id=agent.wallet_id, + charge_entry_id=charge.entry_id, + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + second = await money.refund_charge( + wallet_id=agent.wallet_id, + charge_entry_id=charge.entry_id, + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + assert first.entry_id == second.entry_id + wallet = await money.get_wallet(agent.wallet_id) + assert wallet.balance == 100.0 + with pytest.raises(ValueError, match="not found"): + await money.refund_charge( + wallet_id=agent.wallet_id, + charge_entry_id="ledger-entry-that-does-not-exist", + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + + +@pytest.mark.asyncio +async def test_refund_cannot_redeem_another_wallets_charge(clean_database): + money = get_agent_money() + sponsor, agent = await _funded_agent(money) + other = await money.create_agent_wallet( + sponsor_wallet_id=sponsor.wallet_id, + agent_id="qa-agent-two", + budget_credits=Decimal("50"), + caller=CallerIdentity.for_wallet(sponsor.wallet_id), + ) + charge = await money.charge( + wallet_id=agent.wallet_id, + service_category=ServiceCategory.PLATFORM_FEE, + units=Decimal("1"), + caller=CallerIdentity.for_wallet(agent.wallet_id), + ) + with pytest.raises(ValueError, match="not found"): + await money.refund_charge( + wallet_id=other.wallet_id, + charge_entry_id=charge.entry_id, + caller=CallerIdentity.for_wallet(other.wallet_id), + ) + + +@pytest.mark.asyncio +async def test_quote_release_only_restores_a_live_consumed_quote(clean_database): + money = get_agent_money() + _, agent = await _funded_agent(money) + service = QuoteService() + quote = await service.create_quote( + wallet_id=agent.wallet_id, + tool="qa-tool", + quoted_credits=Decimal("2"), + category=ServiceCategory.AGENT_COMMS.value, + ) + assert await service.release(quote.quote_id) is False + assert await service.consume(quote.quote_id) is True + assert await service.release(quote.quote_id) is True + assert await service.consume(quote.quote_id) is True + assert await service.release(quote.quote_id) is True + factory = get_session_factory() + async with factory() as session: + async with session.begin(): + model = await session.get(QuoteModel, quote.quote_id) + assert model is not None + model.expires_at = model.expires_at - timedelta(seconds=7200) + assert await service.consume(quote.quote_id) is False + assert await service.release(quote.quote_id) is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad_amount", [Decimal("-1"), Decimal("NaN")]) +async def test_receipt_rejects_unstorable_credit_amounts(bad_amount: Decimal): + service = ReceiptService() + with pytest.raises(ReceiptError, match="receipt_credits_invalid"): + await service.create_receipt( + permit_id="permit-qa-missing", + wallet_id="wlt-qa-a", + key_id=None, + tool="qa-tool", + request_payload={"hello": "world"}, + response_payload={"ok": True}, + ledger_entry_id=None, + credits_authorized=bad_amount, + credits_charged=Decimal("1"), + outcome="success", + audit_event_id=None, + ) + + +@pytest.mark.asyncio +async def test_receipt_rejects_success_with_a_reason_code(clean_database): + service = ReceiptService() + with pytest.raises(ReceiptError, match="receipt_reason_code_invalid"): + await service.create_receipt( + permit_id="permit-qa-missing", + wallet_id="wlt-qa-a", + key_id=None, + tool="qa-tool", + request_payload={"hello": "world"}, + response_payload={"ok": True}, + ledger_entry_id=None, + credits_authorized=Decimal("1"), + credits_charged=Decimal("1"), + outcome="success", + reason_code="permit_budget_exceeded", + audit_event_id=None, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kwargs", [{}, {"request_hash": "ab" * 32}]) +async def test_receipt_rejects_ambiguous_request_identity( + clean_database, kwargs: dict[str, Any] +): + service = ReceiptService() + payload: dict[str, Any] | None = None + if kwargs: + payload = {"hello": "world"} + with pytest.raises(ReceiptError, match="receipt_request_identity_invalid"): + await service.create_receipt( + permit_id="permit-qa-missing", + wallet_id="wlt-qa-a", + key_id=None, + tool="qa-tool", + request_payload=payload, + response_payload={"ok": True}, + ledger_entry_id=None, + credits_authorized=Decimal("1"), + credits_charged=Decimal("1"), + outcome="success", + audit_event_id=None, + **kwargs, + ) + + +EVM_PAY_TO = "0x1111111111111111111111111111111111111111" + + +def test_x402_build_requirement_accepts_six_decimal_usdc(): + handler = X402PaymentHandler() + requirement = handler.build_requirement( + amount="1.123456", pay_to=EVM_PAY_TO, network="base" + ) + assert requirement.amount_usd == Decimal("1.123456") + assert requirement.amount_base_units == 1123456 + assert requirement.asset == "USDC" + + +@pytest.mark.parametrize( + "amount", + ["0", "-1.5", "0.0000000", "1.1234567", "10000.000001", "nan", "inf", "abc", ""], +) +def test_x402_build_requirement_rejects_bad_amounts(amount: str): + handler = X402PaymentHandler() + with pytest.raises(X402Error): + handler.build_requirement(amount=amount, pay_to=EVM_PAY_TO, network="base") + + +def test_x402_build_requirement_rejects_non_usdc_asset_case(): + handler = X402PaymentHandler() + with pytest.raises(X402Error, match="x402_asset_unsupported"): + handler.build_requirement( + amount="1", pay_to=EVM_PAY_TO, network="base", asset="usdc" + ) + + +def test_x402_build_requirement_rejects_unknown_network_and_pay_to(): + handler = X402PaymentHandler() + with pytest.raises(X402Error, match="x402_network_unsupported"): + handler.build_requirement(amount="1", pay_to=EVM_PAY_TO, network="mainnet-evil") + with pytest.raises(X402Error, match="x402_pay_to_invalid"): + handler.build_requirement(amount="1", pay_to="not-an-address", network="base") + + +def test_x402_parse_accepts_any_header_case(): + handler = X402PaymentHandler() + requirement = handler.parse_402( + 402, + { + "X-402-AMOUNT": "2.5", + "X-402-PAYTO": EVM_PAY_TO, + "X-402-NETWORK": "base", + }, + ) + assert requirement.amount_usd == Decimal("2.5") + + +def test_x402_nonce_is_deterministic_per_permit_and_key(): + handler = X402PaymentHandler() + first = handler._derive_nonce_hex("permit-1", "key-a") + assert first == handler._derive_nonce_hex("permit-1", "key-a") + assert len(first) == 64 + assert handler._derive_nonce_hex("permit-1", "key-b") != first + assert handler._derive_nonce_hex("permit-2", "key-a") != first + + +def _payment_intent(**overrides: Any) -> dict[str, Any]: + rate = get_settings().EXCHANGE_RATE + credits = Decimal(500) * rate / Decimal("100") + intent: dict[str, Any] = { + "id": "pi_qa_1", + "status": "succeeded", + "currency": "usd", + "amount": 500, + "amount_received": 500, + "metadata": {"wallet_id": "spn-qa", "credits": str(credits)}, + } + intent.update(overrides) + return intent + + +def test_stripe_payment_intent_validation_accepts_settled_usd(): + rate = get_settings().EXCHANGE_RATE + _, wallet_id, credits = StripeIntegration._validate_succeeded_payment_intent( + _payment_intent() + ) + assert wallet_id == "spn-qa" + assert credits == Decimal(500) * rate / Decimal("100") + + +@pytest.mark.parametrize( + "override", + [ + {"status": "requires_payment_method"}, + {"currency": "eur"}, + {"amount_received": 400}, + {"amount": 0}, + {"amount": True}, + {"metadata": {"wallet_id": "spn-qa", "credits": "999999"}}, + {"metadata": {"wallet_id": "", "credits": "5000"}}, + {"metadata": None}, + {"id": ""}, + ], +) +def test_stripe_payment_intent_validation_rejects(override: dict[str, Any]): + with pytest.raises(StripeSettlementError): + StripeIntegration._validate_succeeded_payment_intent( + _payment_intent(**override) + ) + + +def _refund_charge(**overrides: Any) -> dict[str, Any]: + charge: dict[str, Any] = { + "id": "ch_qa_1", + "payment_intent": "pi_qa_1", + "currency": "usd", + "amount": 500, + "amount_refunded": 200, + } + charge.update(overrides) + return charge + + +def test_stripe_refund_charge_validation_accepts_partial(): + payment_intent_id, charge_amount, refunded, _ = ( + StripeIntegration._validate_refund_charge(_refund_charge()) + ) + assert (payment_intent_id, charge_amount, refunded) == ("pi_qa_1", 500, 200) + + +@pytest.mark.parametrize( + "override", + [ + {"amount_refunded": 0}, + {"amount_refunded": 600}, + {"amount_refunded": True}, + {"amount": 0}, + {"currency": "eur"}, + {"payment_intent": ""}, + {"payment_intent": None}, + ], +) +def test_stripe_refund_charge_validation_rejects(override: dict[str, Any]): + with pytest.raises(StripeSettlementError): + StripeIntegration._validate_refund_charge(_refund_charge(**override)) + + +@pytest.mark.asyncio +async def test_stripe_payment_failed_notifies_wallet_owner(monkeypatch): + calls: list[dict[str, Any]] = [] + + async def _fake_alert(*, wallet_id, error_message, payment_intent_id): + calls.append( + { + "wallet_id": wallet_id, + "error_message": error_message, + "payment_intent_id": payment_intent_id, + } + ) + + class _FakeNotifications: + send_payment_failed_alert = staticmethod(_fake_alert) + + monkeypatch.setattr( + "app.services.notifications.get_notification_service", + lambda: _FakeNotifications(), + ) + integration = StripeIntegration() + await integration._handle_payment_failed( + { + "id": "pi_qa_failed", + "metadata": {"wallet_id": "spn-qa"}, + "last_payment_error": {"message": "card declined"}, + } + ) + assert calls == [ + { + "wallet_id": "spn-qa", + "error_message": "card declined", + "payment_intent_id": "pi_qa_failed", + } + ] + + +@pytest.mark.asyncio +async def test_stripe_payment_failed_without_wallet_sends_nothing(monkeypatch): + def _boom() -> Any: + raise AssertionError("no notification should be sent") + + monkeypatch.setattr("app.services.notifications.get_notification_service", _boom) + integration = StripeIntegration() + await integration._handle_payment_failed({"id": "pi_qa_failed", "metadata": {}}) + + +@pytest.mark.asyncio +async def test_receipt_rejects_malformed_request_hash(): + service = ReceiptService() + with pytest.raises(ReceiptError, match="receipt_request_hash_invalid"): + await service.create_receipt( + permit_id="permit-qa-missing", + wallet_id="wlt-qa-a", + key_id=None, + tool="qa-tool", + request_payload=None, + response_payload={"ok": True}, + ledger_entry_id=None, + credits_authorized=Decimal("1"), + credits_charged=Decimal("1"), + outcome="success", + audit_event_id=None, + request_hash="not-hex-" + "z" * 56, + ) diff --git a/gateway/tests/test_rate_limit_threshold.py b/gateway/tests/test_rate_limit_threshold.py index 4c8c216c..2eae8e61 100644 --- a/gateway/tests/test_rate_limit_threshold.py +++ b/gateway/tests/test_rate_limit_threshold.py @@ -609,3 +609,83 @@ async def ok(_request): assert first.status_code == 200 assert second.status_code == 200 assert third.status_code == 429 + + +async def _status_for(app, path: str, *, api_key: str | None = None) -> int: + """Drive one raw ASGI request with an already decoded routing path.""" + seen: list[int] = [] + headers = [(b"host", b"test")] + if api_key is not None: + headers.append((b"x-api-key", api_key.encode("ascii"))) + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.replace("?", "%3F").replace("#", "%23").encode("ascii"), + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 5000), + "server": ("test", 80), + } + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + if message["type"] == "http.response.start": + seen.append(message["status"]) + + await app(scope, receive, send) + return seen[0] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "path", + ["/health?other", "/health#other", "/docs?other", "/docs#other"], +) +async def test_encoded_delimiter_in_path_does_not_skip_limit(path: str) -> None: + """The decoded router path is different from the exempt route.""" + + async def ok(scope, receive, send): + await PlainTextResponse("ok")(scope, receive, send) + + limited = RateLimitMiddleware(ok, requests_per_minute=1) + # Stay on the in-memory counter. This probe must not open a Redis client. + limited._redis_url = "" + + assert await _status_for(limited, path) == 200 + assert await _status_for(limited, path) == 429 + + +@pytest.mark.anyio +@pytest.mark.parametrize("path", ["/health", "/docs"]) +async def test_actual_health_and_docs_paths_stay_exempt(path: str) -> None: + async def ok(scope, receive, send): + await PlainTextResponse("ok")(scope, receive, send) + + exempt = RateLimitMiddleware(ok, requests_per_minute=1) + exempt._redis_url = "" + assert await _status_for(exempt, path) == 200 + assert await _status_for(exempt, path) == 200 + + +@pytest.mark.anyio +@pytest.mark.parametrize("path", ["/mcp/public?other", "/mcp/public#other"]) +async def test_encoded_delimiter_does_not_use_public_mcp_bucket(path: str) -> None: + async def ok(scope, receive, send): + await PlainTextResponse("ok")(scope, receive, send) + + limited = RateLimitMiddleware(ok, requests_per_minute=1) + limited._redis_url = "" + assert await _status_for(limited, path, api_key="first-key") == 200 + assert await _status_for(limited, path, api_key="second-key") == 200 + assert not any("route:mcp-public" in key for key in limited._requests) + + public = RateLimitMiddleware(ok, requests_per_minute=1) + public._redis_url = "" + assert await _status_for(public, "/mcp/public", api_key="first-key") == 200 + assert await _status_for(public, "/mcp/public", api_key="second-key") == 429 diff --git a/gateway/tests/test_receipt_shape_adversarial.py b/gateway/tests/test_receipt_shape_adversarial.py new file mode 100644 index 00000000..431ae8cf --- /dev/null +++ b/gateway/tests/test_receipt_shape_adversarial.py @@ -0,0 +1,358 @@ +"""Receipt shapes and replay evidence stay consistent with signed money state.""" + +from __future__ import annotations + +import json +from decimal import Decimal + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.core.config import DuplicateGuardMode, get_settings +from app.db.database import get_session_factory +from app.db.models import LedgerEntryModel, ReceiptModel, SigningKeyModel, WalletModel +from app.main import app +from app.services.audit_log import record_audit_event +from app.services.idempotency import get_idempotency_service +from app.services.receipts import ReceiptError, get_receipt_service +from app.services.signing_keys import get_signing_key_service +from tests.test_trust_helpers import create_tool_permit, provision_agent_wallet + +pytestmark = pytest.mark.anyio + +TOOL = "receipt-shape-adversarial" + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as value: + yield value + + +async def _setup(client): + owner = await provision_agent_wallet(client) + permit = await create_tool_permit( + client, + wallet_id=owner["agent_wallet_id"], + key_id=owner["key_id"], + tool_name=TOOL, + idem_key="receipt-shape-permit", + ) + return owner, permit + + +def _kwargs(owner, permit, **overrides): + values = { + "permit_id": permit["permit_id"], + "wallet_id": owner["agent_wallet_id"], + "key_id": owner["key_id"], + "tool": TOOL, + "request_payload": {"attempt": "shape"}, + "response_payload": {"ok": True}, + "ledger_entry_id": None, + "credits_authorized": Decimal("1"), + "credits_charged": Decimal("0"), + "outcome": "success", + "audit_event_id": None, + } + values.update(overrides) + return values + + +async def _money_and_receipt_state(wallet_id): + async with get_session_factory()() as session: + wallet = await session.get(WalletModel, wallet_id) + assert wallet is not None + ledger = ( + ( + await session.execute( + select(LedgerEntryModel).where( + LedgerEntryModel.wallet_id == wallet_id + ) + ) + ) + .scalars() + .all() + ) + receipts = (await session.execute(select(ReceiptModel))).scalars().all() + keys = (await session.execute(select(SigningKeyModel))).scalars().all() + return ( + (wallet.balance, wallet.lifetime_credits, wallet.lifetime_debits), + tuple( + sorted( + (row.entry_id, row.action, row.amount, row.balance_after) + for row in ledger + ) + ), + tuple( + sorted( + ( + row.receipt_id, + row.signature, + row.signature_key_id, + row.constraints_evaluated_json, + row.outcome, + row.credits_charged, + row.response_hash, + ) + for row in receipts + ) + ), + tuple( + sorted( + (row.key_id, row.status, row.activated_at, row.retired_at) + for row in keys + ) + ), + ) + + +async def _idempotent_kwargs(owner, permit, suffix): + request = {"attempt": suffix} + begun = await get_idempotency_service().begin_with_record( + wallet_id=owner["agent_wallet_id"], + endpoint="/mcp/messages", + idempotency_key=f"receipt-shape-{suffix}", + request_payload=request, + ) + assert begun.replay is None + return _kwargs( + owner, + permit, + request_payload=request, + idempotency_record_id=begun.record_id, + ) + + +@pytest.mark.parametrize( + "outcome", ["bogus", "SUCCESS", "success ", " denied", "", "denied\n"] +) +async def test_unknown_outcome_rejected_before_enrichment_or_write( + client, clean_database, monkeypatch, outcome +): + owner, permit = await _setup(client) + before = await _money_and_receipt_state(owner["agent_wallet_id"]) + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.LOG) + + async def unexpected_enrichment(*args, **kwargs): + raise AssertionError("invalid outcome reached JEV enrichment") + + monkeypatch.setattr( + "app.services.receipts.load_jev_guard_metadata", unexpected_enrichment + ) + with pytest.raises(ReceiptError, match="^receipt_outcome_invalid$"): + await get_receipt_service().create_receipt( + **_kwargs(owner, permit, outcome=outcome) + ) + assert await _money_and_receipt_state(owner["agent_wallet_id"]) == before + + +@pytest.mark.parametrize("outcome", ["denied", "insufficient_funds", "failed_refunded"]) +async def test_charged_uncharged_outcome_rejected_without_mutation( + client, clean_database, monkeypatch, outcome +): + owner, permit = await _setup(client) + before = await _money_and_receipt_state(owner["agent_wallet_id"]) + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.LOG) + + async def unexpected_enrichment(*args, **kwargs): + raise AssertionError("charged uncharged outcome reached JEV enrichment") + + monkeypatch.setattr( + "app.services.receipts.load_jev_guard_metadata", unexpected_enrichment + ) + with pytest.raises(ReceiptError, match="^receipt_denied_charge_invalid$"): + await get_receipt_service().create_receipt( + **_kwargs( + owner, + permit, + outcome=outcome, + credits_charged=Decimal("0.5"), + reason_code="upstream_error", + ) + ) + assert await _money_and_receipt_state(owner["agent_wallet_id"]) == before + + +@pytest.mark.parametrize( + ("outcome", "charged"), + [ + ("success", Decimal("1")), + ("denied", Decimal("0")), + ("insufficient_funds", Decimal("0")), + ("failed_refunded", Decimal("0")), + ("failed_unrefunded", Decimal("0.5")), + ("delivery_uncertain", Decimal("0.5")), + ("response_rejected", Decimal("0.5")), + ], +) +async def test_supported_outcome_keeps_signed_charge( + client, clean_database, outcome, charged +): + owner, permit = await _setup(client) + service = get_receipt_service() + receipt = await service.create_receipt( + **_kwargs( + owner, + permit, + outcome=outcome, + credits_charged=charged, + reason_code=None if outcome == "success" else "upstream_error", + ) + ) + assert receipt.outcome == outcome + assert receipt.credits_charged == charged + assert (await service.verify_receipt(receipt.receipt_id))[0] is True + signed = await service.signing_input(receipt.receipt_id) + assert signed is not None + assert json.loads(signed)["outcome"] == outcome + + +async def test_constraints_replay_compares_signed_semantics(client, clean_database): + owner, permit = await _setup(client) + kwargs = await _idempotent_kwargs(owner, permit, "constraints") + service = get_receipt_service() + first = await service.create_receipt( + **kwargs, + constraints_evaluated={"policy": {"enabled": True, "count": 2}}, + ) + reordered = await service.create_receipt( + **kwargs, + constraints_evaluated={"policy": {"count": 2, "enabled": True}}, + ) + assert reordered.receipt_id == first.receipt_id + before = await _money_and_receipt_state(owner["agent_wallet_id"]) + for changed in ( + {"policy": {"enabled": True, "count": 3}}, + {"policy": {"enabled": 1, "count": 2}}, + ): + with pytest.raises(ReceiptError, match="^receipt_idempotency_conflict$"): + await service.create_receipt(**kwargs, constraints_evaluated=changed) + assert await _money_and_receipt_state(owner["agent_wallet_id"]) == before + + +@pytest.mark.parametrize("caller_owned", [False, True]) +@pytest.mark.parametrize("conflicting", [False, True]) +async def test_replay_checks_existing_evidence_before_signing( + client, clean_database, monkeypatch, caller_owned, conflicting +): + owner, permit = await _setup(client) + kwargs = await _idempotent_kwargs(owner, permit, "before-signing") + service = get_receipt_service() + first = await service.create_receipt( + **kwargs, constraints_evaluated={"checked": True} + ) + before = await _money_and_receipt_state(owner["agent_wallet_id"]) + + async def forbidden_sign(*_args, **_kwargs): + raise AssertionError("replay reached signing") + + def forbidden_prepared_sign(*_args, **_kwargs): + raise AssertionError("replay reached prepared signing") + + signing = get_signing_key_service() + monkeypatch.setattr(signing, "sign_payload", forbidden_sign) + monkeypatch.setattr(signing, "sign_payload_with_key_id", forbidden_prepared_sign) + + async def check_replay(session=None): + replay = { + **kwargs, + "constraints_evaluated": {"checked": not conflicting}, + "session": session, + "prepared_signing_key_id": first.signature_key_id if caller_owned else None, + } + if conflicting: + with pytest.raises(ReceiptError, match="^receipt_idempotency_conflict$"): + await service.create_receipt(**replay) + else: + again = await service.create_receipt(**replay) + assert again.receipt_id == first.receipt_id + + if caller_owned: + async with get_session_factory()() as session: + await check_replay(session) + else: + await check_replay() + assert await _money_and_receipt_state(owner["agent_wallet_id"]) == before + + +@pytest.mark.parametrize(("first", "replay"), [(None, {}), ({}, None)]) +async def test_none_and_empty_constraints_replay_equally( + client, clean_database, first, replay +): + owner, permit = await _setup(client) + kwargs = await _idempotent_kwargs(owner, permit, "empty") + service = get_receipt_service() + receipt = await service.create_receipt(**kwargs, constraints_evaluated=first) + again = await service.create_receipt(**kwargs, constraints_evaluated=replay) + assert again.receipt_id == receipt.receipt_id + assert again.constraints_evaluated == {} + + +async def test_jev_enriched_constraints_are_compared_after_enrichment( + client, clean_database, monkeypatch +): + owner, permit = await _setup(client) + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.LOG) + guard = {"verdict": "pass", "advisory": True} + audit = await record_audit_event( + event="mcp.invoke", + wallet_id=owner["agent_wallet_id"], + tool=TOOL, + metadata={"jev_risk_guard": guard}, + ) + kwargs = await _idempotent_kwargs(owner, permit, "jev") + kwargs["audit_event_id"] = audit.event_id + service = get_receipt_service() + first = await service.create_receipt( + **kwargs, constraints_evaluated={"rule": {"allow": True}} + ) + assert first.constraints_evaluated == { + "rule": {"allow": True}, + "jev_risk_guard": guard, + } + same = await service.create_receipt( + **kwargs, + constraints_evaluated={ + "rule": {"allow": True}, + "jev_risk_guard": {"verdict": "caller-supplied"}, + }, + ) + assert same.receipt_id == first.receipt_id + monkeypatch.setattr(get_settings(), "JEV_RISK_GUARD", DuplicateGuardMode.OFF) + after_mode_change = await service.create_receipt( + **kwargs, constraints_evaluated={"rule": {"allow": True}} + ) + assert after_mode_change.receipt_id == first.receipt_id + with pytest.raises(ReceiptError, match="^receipt_idempotency_conflict$"): + await service.create_receipt( + **kwargs, constraints_evaluated={"rule": {"allow": False}} + ) + assert (await service.verify_receipt(first.receipt_id))[0] is True + + +async def test_signed_evidence_and_response_hash_mutation_fail_closed( + client, clean_database +): + owner, permit = await _setup(client) + kwargs = await _idempotent_kwargs(owner, permit, "signed") + service = get_receipt_service() + first = await service.create_receipt( + **kwargs, constraints_evaluated={"checked": True} + ) + with pytest.raises(ReceiptError, match="^receipt_idempotency_conflict$"): + await service.create_receipt( + **{**kwargs, "response_payload": {"ok": False}}, + constraints_evaluated={"checked": True}, + ) + async with get_session_factory()() as session: + row = await session.get(ReceiptModel, first.receipt_id) + assert row is not None + row.constraints_evaluated_json = json.dumps({"checked": False}) + await session.commit() + assert (await service.verify_receipt(first.receipt_id))[0] is False + assert await service.signing_input(first.receipt_id) is None + with pytest.raises(ReceiptError, match="^receipt_signature_invalid$"): + await service.create_receipt(**kwargs, constraints_evaluated={"checked": False}) diff --git a/gateway/tests/test_receipt_signing_input_snapshot.py b/gateway/tests/test_receipt_signing_input_snapshot.py index 70661cd3..2e030a35 100644 --- a/gateway/tests/test_receipt_signing_input_snapshot.py +++ b/gateway/tests/test_receipt_signing_input_snapshot.py @@ -13,7 +13,9 @@ If this test fails, the change is either a bug or an intentional receipt format change. An intentional change requires: (1) a receipt schema version bump, (2) an entry in CHANGELOG.md, (3) regenerating the golden files with -``UPDATE_RECEIPT_GOLDEN=1 pytest tests/test_receipt_signing_input_snapshot.py``. +``GOLDEN_REGEN_ACK=1 UPDATE_RECEIPT_GOLDEN=1 pytest +tests/test_receipt_signing_input_snapshot.py``. The ack flag is the review +gate: setting UPDATE_RECEIPT_GOLDEN=1 without it fails instead of writing. Never regenerate to make a refactor PR green. """ @@ -21,7 +23,6 @@ import base64 import json -import os from datetime import datetime, timezone from decimal import Decimal from pathlib import Path @@ -39,6 +40,7 @@ get_signing_key_service, sha256_hex, ) +from tests.support.golden_regen import require_golden_regen_ack from tests.test_trust_helpers import create_tool_permit, provision_agent_wallet GOLDEN_DIR = Path(__file__).parent / "fixtures" / "receipt_signing_golden" @@ -155,7 +157,7 @@ def test_receipt_signing_input_is_byte_stable(name: str) -> None: "signature_b64_over_test_seed": signature, } - if os.environ.get("UPDATE_RECEIPT_GOLDEN") == "1": + if require_golden_regen_ack("UPDATE_RECEIPT_GOLDEN"): GOLDEN_DIR.mkdir(parents=True, exist_ok=True) golden_path.write_text(json.dumps(actual, indent=2, sort_keys=True) + "\n") pytest.skip(f"golden regenerated: {golden_path}") diff --git a/gateway/tests/test_refund_reconciliation.py b/gateway/tests/test_refund_reconciliation.py index 8b163120..cbb2ec73 100644 --- a/gateway/tests/test_refund_reconciliation.py +++ b/gateway/tests/test_refund_reconciliation.py @@ -11,6 +11,7 @@ from sqlalchemy import select from app.core.config import get_settings +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import HumanApprovalModel, IdempotencyRecordModel, ReceiptModel from app.main import app @@ -229,7 +230,7 @@ async def test_refund_reconciliation_retries_exactly_once_and_preserves_agent_re # The periodic stale-budget reconciler must preserve a real debit while # its failed refund remains pending, even after the permit is revoked. - await get_permit_service().revoke_permit(permit_id) + await get_permit_service().revoke_permit(permit_id, caller=CallerIdentity.admin()) assert await get_permit_service().reconcile_budgets(idle_seconds=0) == 0 pending_permit = await get_permit_service().get_permit(permit_id) assert pending_permit is not None @@ -674,12 +675,13 @@ async def test_lost_refund_ack_releases_budget_without_double_refund( wallet_id=wallet_id, charge_entry_id=ledger_entry_id, description="Committed refund with lost acknowledgement", + caller=CallerIdentity.admin(), ) assert existing_refund.entry_id == f"refund-{ledger_entry_id}" # A background budget sweep must not release a still-pending work item; # the operator transaction remains the exact-once release marker. - await get_permit_service().revoke_permit(permit_id) + await get_permit_service().revoke_permit(permit_id, caller=CallerIdentity.admin()) assert await get_permit_service().reconcile_budgets(idle_seconds=0) == 0 pending_permit = await get_permit_service().get_permit(permit_id) assert pending_permit is not None @@ -803,7 +805,9 @@ async def test_operator_refund_release_cannot_erase_a_concurrent_reservation( state: dict = {} async def _concurrent_reservation() -> None: - await get_permit_service().reserve_budget(permit_id, Decimal("1")) + await get_permit_service().reserve_budget( + permit_id, Decimal("1"), caller=CallerIdentity.admin() + ) monkeypatch.setattr( reconciliation_module, diff --git a/gateway/tests/test_request_body_limit.py b/gateway/tests/test_request_body_limit.py index 65d10c88..ca8caa96 100644 --- a/gateway/tests/test_request_body_limit.py +++ b/gateway/tests/test_request_body_limit.py @@ -8,6 +8,8 @@ from __future__ import annotations +from array import array + import pytest from httpx import ASGITransport, AsyncClient from starlette.applications import Starlette @@ -200,6 +202,190 @@ async def send(message): assert seen == ["lifespan", "websocket"] +@pytest.mark.anyio +async def test_oversized_chunk_is_rejected_without_being_copied(monkeypatch): + """One ASGI frame bigger than the ceiling must not be copied into the buffer. + + The check used to extend first and measure after, so a single message + could force a copy of the whole frame. The ceiling is the bound. + """ + import app.middleware.request_body_limit as body_limit + + ceiling = 8 + chunk = b"y" * 100 + + class GuardedBytearray(bytearray): + def extend(self, item): + incoming = len(item) + if len(self) + incoming > ceiling: + raise RuntimeError(f"copied {incoming} bytes past the ceiling") + super().extend(item) + + # bytearray is a builtin, not a module global, until the test plants one. + monkeypatch.setattr(body_limit, "bytearray", GuardedBytearray, raising=False) + + sent: list[dict] = [] + + async def receive(): + return {"type": "http.request", "body": chunk, "more_body": False} + + async def send(message): + sent.append(message) + + async def app(scope, receive, send): + raise AssertionError("oversized body reached the application") + + scope = { + "type": "http", + "method": "POST", + "path": "/echo", + "headers": [(b"host", b"test")], + "query_string": b"", + } + await body_limit.RequestBodyLimitMiddleware(app, ceiling)(scope, receive, send) + + assert sent[0]["status"] == 413 + + +@pytest.mark.anyio +async def test_omitted_body_frame_is_an_empty_body(limited_app): + """ASGI allows the body key to be omitted for an empty frame.""" + frames = [{"type": "http.request", "more_body": False}] + sent: list[dict] = [] + + async def receive(): + return frames.pop(0) + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/echo", + "headers": [(b"host", b"test")], + "query_string": b"", + } + await RequestBodyLimitMiddleware(limited_app, LIMIT)(scope, receive, send) + + assert sent[0]["status"] == 200 + raw = b"".join( + message.get("body", b"") + for message in sent + if message["type"] == "http.response.body" + ) + assert b'"length":0' in raw or b'"length": 0' in raw + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "body", + [ + None, + ["not", "bytes"], + memoryview(b"bytes"), + memoryview(array("I", [1, 2])), + ], +) +async def test_non_bytes_body_frame_is_rejected(limited_app, body): + """Malformed frames, including views with len < nbytes, get a 400.""" + frames = [{"type": "http.request", "body": body, "more_body": False}] + sent: list[dict] = [] + reached = False + + async def receive(): + return frames.pop(0) + + async def send(message): + sent.append(message) + + async def spy(scope, receive, send): + nonlocal reached + reached = True + await limited_app(scope, receive, send) + + scope = { + "type": "http", + "method": "POST", + "path": "/echo", + "headers": [(b"host", b"test")], + "query_string": b"", + } + # The array view has len 2 and nbytes 8: a four-byte limit catches the + # old memoryview counting bypass if it is accidentally accepted again. + limit = 4 if isinstance(body, memoryview) else LIMIT + await RequestBodyLimitMiddleware(spy, limit)(scope, receive, send) + + assert sent[0]["status"] == 400 + assert reached is False + + +@pytest.mark.anyio +async def test_bytearray_chunks_at_limit_are_replayed_as_bytes(): + frames = [ + {"type": "http.request", "body": bytearray(b"abcd"), "more_body": True}, + {"type": "http.request", "body": b"efgh", "more_body": False}, + ] + received = [] + sent = [] + + async def receive(): + return frames.pop(0) + + async def send(message): + sent.append(message) + + async def app(scope, receive, send): + received.append(await receive()) + await JSONResponse({"ok": True})(scope, receive, send) + + scope = { + "type": "http", + "method": "POST", + "path": "/echo", + "headers": [(b"content-length", b"8")], + "query_string": b"", + } + await RequestBodyLimitMiddleware(app, 8)(scope, receive, send) + + assert sent[0]["status"] == 200 + assert received == [ + {"type": "http.request", "body": b"abcdefgh", "more_body": False} + ] + + +@pytest.mark.anyio +async def test_disconnect_after_partial_body_is_replayed(): + frames = [ + {"type": "http.request", "body": b"ab", "more_body": True}, + {"type": "http.disconnect"}, + ] + received = [] + + async def receive(): + return frames.pop(0) + + async def send(message): + pass + + async def app(scope, receive, send): + received.extend([await receive(), await receive()]) + + scope = { + "type": "http", + "method": "POST", + "path": "/echo", + "headers": [], + "query_string": b"", + } + await RequestBodyLimitMiddleware(app, 8)(scope, receive, send) + + assert received == [ + {"type": "http.request", "body": b"ab", "more_body": True}, + {"type": "http.disconnect"}, + ] + + def test_the_real_app_registers_the_limit_from_settings(): """The ceiling is wired into the shipped app, not just available to it.""" limits = [ diff --git a/gateway/tests/test_revocation_containment.py b/gateway/tests/test_revocation_containment.py index 6ab574ae..d63eddfd 100644 --- a/gateway/tests/test_revocation_containment.py +++ b/gateway/tests/test_revocation_containment.py @@ -454,3 +454,49 @@ async def test_other_wallets_are_unaffected_by_a_revocation( json={"refresh_token": bystander_tokens["refresh_token"]}, ) assert resp.status_code == 200, resp.text + + +@pytest.mark.anyio +async def test_refresh_insert_failure_rolls_back_the_claim( + client, clean_database, signing_key +): + """A transient failure storing the successor must not strand the client. + + Claim, liveness check, and successor insert commit atomically: when the + insert fails, the claim rolls back, the old token stays live, and the + client can retry instead of being logged out with a 500. + """ + from unittest.mock import patch + + from sqlalchemy.ext.asyncio import AsyncSession + + from app.core.jwt import get_jwt_service + from app.db.database import get_session_factory + from app.db.models import RefreshTokenModel + + provisioned = await provision_agent_wallet(client) + tokens = await _mint_tokens(client, provisioned["agent_headers"]["X-API-Key"]) + payload = get_jwt_service().verify_refresh_token(tokens["refresh_token"]) + + with ( + patch.object( + AsyncSession, "add", side_effect=RuntimeError("simulated insert failure") + ), + pytest.raises(RuntimeError, match="simulated insert failure"), + ): + await client.post( + "/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]} + ) + + # The claim was rolled back: the old token is still live... + factory = get_session_factory() + async with factory() as session: + record = await session.get(RefreshTokenModel, payload.jti) + assert record is not None + assert record.revoked is False + + # ...so the client can retry and succeed. + retry = await client.post( + "/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]} + ) + assert retry.status_code == 200, retry.text diff --git a/gateway/tests/test_rotate_api_keys.py b/gateway/tests/test_rotate_api_keys.py index b75c4efd..6f06d795 100644 --- a/gateway/tests/test_rotate_api_keys.py +++ b/gateway/tests/test_rotate_api_keys.py @@ -1,13 +1,13 @@ """Coverage for scripts/rotate_api_keys.py. -The verifier is the last gate in the rotation runbook, so its failure -semantics matter more than its happy path: a rotation is only complete when -the retired key is rejected AND the replacement authenticates, and the -script must exit non-zero on every other combination. +The verifier samples one response for each key; rotation completion also +requires host configuration and rollout evidence. Its exit status reports +whether the two sampled responses match the expected statuses. """ import importlib.util from pathlib import Path +import sys import httpx import pytest @@ -67,7 +67,7 @@ def test_verify_requires_env(rotate, monkeypatch): assert rotate.verify() == 2 -def test_verify_passes_when_old_dead_and_new_live(rotate, monkeypatch): +def test_verify_reports_sampled_old_rejection_and_new_acceptance(rotate, monkeypatch): _env(monkeypatch) monkeypatch.setattr( rotate, "_probe", _fake_probe({"old-key-value": 403, "new-key-value": 200}) @@ -75,6 +75,18 @@ def test_verify_passes_when_old_dead_and_new_live(rotate, monkeypatch): assert rotate.verify() == 0 +def test_verify_help_describes_sampled_responses(rotate, monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["rotate_api_keys.py", "--help"]) + + with pytest.raises(SystemExit) as exc: + rotate.main() + + assert exc.value.code == 0 + assert "sample old-key rejection and new-key acceptance" in capsys.readouterr().out + assert "different load-balanced workers" in rotate.__doc__ + assert "database-key authentication can update last_used_at" in rotate.__doc__ + + def test_verify_fails_when_old_key_still_authenticates(rotate, monkeypatch): _env(monkeypatch) monkeypatch.setattr( diff --git a/gateway/tests/test_schema_boot.py b/gateway/tests/test_schema_boot.py index d645bff4..a2d35199 100644 --- a/gateway/tests/test_schema_boot.py +++ b/gateway/tests/test_schema_boot.py @@ -112,6 +112,66 @@ async def test_init_db_production_like_fails_without_migrations(tmp_path, monkey get_settings.cache_clear() +@pytest.mark.asyncio +async def test_production_boot_fails_with_only_legacy_four_trust_tables( + tmp_path, monkeypatch +): + """A DB with only permits/receipts/idempotency/dispatch must fail closed. + + The audit chain, signing-key registry and approval gate are trust tables + too: booting without them would run issuance and verification with no + durable audit trail, no key registry and no approval records. + """ + import app.db.models # noqa: F401 (register every table on SQLModel.metadata) + from sqlmodel import SQLModel + + db_path = tmp_path / "four_tables.db" + monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{db_path}") + monkeypatch.setenv("ENVIRONMENT", "production") + monkeypatch.setenv("DEBUG", "false") + monkeypatch.setenv("ENABLE_PROOF_SURFACES", "false") + monkeypatch.setenv("ALLOW_METADATA_CREATE_ALL", "false") + get_settings.cache_clear() + await close_db() + + engine = get_engine() + assert engine is not None + legacy_tables = [ + SQLModel.metadata.tables[name] + for name in ( + "permits", + "receipts", + "idempotency_records", + "mcp_dispatch_attempts", + ) + ] + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: SQLModel.metadata.create_all( + sync_conn, tables=legacy_tables + ) + ) + # Boot fresh from the file, like a real restart. + await close_db() + + with pytest.raises( + SchemaInitError, match="Required database tables missing" + ) as exc_info: + await init_db() + message = str(exc_info.value) + for missing in ( + "control_plane_audit_events", + "audit_chain_heads", + "signing_keys", + "human_approvals", + ): + assert missing in message + assert "alembic upgrade head" in message + + await close_db() + get_settings.cache_clear() + + @pytest.mark.asyncio async def test_init_db_production_like_accepts_legacy_create_all_tables( tmp_path, monkeypatch diff --git a/gateway/tests/test_scope_coroutine.py b/gateway/tests/test_scope_coroutine.py new file mode 100644 index 00000000..12da216b --- /dev/null +++ b/gateway/tests/test_scope_coroutine.py @@ -0,0 +1,26 @@ +"""Scoped async handlers remain usable by standard coroutine consumers.""" + +import pytest +from fastapi import HTTPException + +from app.core.auth import AuthContext +from typecheck.scopes_coroutine import create_scoped_task, run_scoped + + +def _auth(scopes: list[str]) -> AuthContext: + return AuthContext(source="jwt", raw_key="test", scopes=scopes) + + +def test_asyncio_run_accepts_scoped_handler(): + assert run_scoped(_auth(["billing:charge"])) == 2 + + +async def test_asyncio_create_task_accepts_scoped_handler(): + task = await create_scoped_task(_auth(["billing:charge"])) + assert await task == 2 + + +def test_scoped_handler_still_denies_missing_scope(): + with pytest.raises(HTTPException) as exc: + run_scoped(_auth(["billing:read"])) + assert exc.value.status_code == 403 diff --git a/gateway/tests/test_secret_persistence.py b/gateway/tests/test_secret_persistence.py index 59d7c351..d7f1ad36 100644 --- a/gateway/tests/test_secret_persistence.py +++ b/gateway/tests/test_secret_persistence.py @@ -232,8 +232,8 @@ async def test_legacy_comms_registry_record_is_scrubbed_and_still_owned( foreign = await client.get( "/v1/comms/messages/agent-legacy/inbox", headers=other["agent_headers"] ) - assert foreign.status_code == 403 - assert foreign.json()["detail"]["error"] == "access_denied" + assert foreign.status_code == 404, foreign.text + assert foreign.json()["detail"]["error"] == "agent_not_found" own = await client.get( "/v1/comms/messages/agent-legacy/inbox", headers=owner["agent_headers"] diff --git a/gateway/tests/test_security_headers.py b/gateway/tests/test_security_headers.py index 30445512..f4cb7fe8 100644 --- a/gateway/tests/test_security_headers.py +++ b/gateway/tests/test_security_headers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from starlette.applications import Starlette from starlette.responses import HTMLResponse, PlainTextResponse from starlette.routing import Route @@ -204,6 +205,60 @@ def test_explicit_route_headers_are_not_overridden() -> None: assert TestClient(app).get("/framed").headers["x-frame-options"] == "DENY" +@pytest.mark.parametrize( + "path", + [ + "/health?other", + "/health#other", + "/docs?other", + "/docs#other", + "/mcp/public?other", + "/mcp/public#other", + ], +) +def test_encoded_delimiter_path_has_private_cache_and_narrow_csp(path: str) -> None: + """Cache and CSP use the decoded router path, including delimiters.""" + import anyio + from starlette.requests import Request + + async def always_ok(scope, receive, send): + await HTMLResponse("private")(scope, receive, send) + + middleware = SecurityHeadersMiddleware(always_ok) + sent: list[dict] = [] + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.replace("?", "%3F").replace("#", "%23").encode(), + "query_string": b"", + "headers": [(b"host", b"test")], + "client": ("127.0.0.1", 9), + "server": ("test", 80), + } + + async def run(): + await middleware(scope, receive, send) + + anyio.run(run) + start = next( + message for message in sent if message["type"] == "http.response.start" + ) + headers = Request({"type": "http", "headers": start["headers"]}).headers + assert headers["cache-control"] == "no-store" + assert headers["content-security-policy"] == FIRST_PARTY_HTML_CSP + + def test_middleware_is_registered_on_the_application() -> None: from app.main import app diff --git a/gateway/tests/test_spec_conformance.py b/gateway/tests/test_spec_conformance.py new file mode 100644 index 00000000..9059ade7 --- /dev/null +++ b/gateway/tests/test_spec_conformance.py @@ -0,0 +1,603 @@ +"""Spec conformance: every example in spec/ validates against its schema, +and the schemas match what gateway/app actually emits. + +Covers spec/permit-v0.1.md, spec/receipt-v0.1.md, spec/key-set-v0.1.md and +spec/test-vectors/. Pure unit tests: no database, no network, no sleeps. +The fixed Ed25519 test seed bytes(range(32)) is public test material only. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import sys +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path +from types import SimpleNamespace + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +ROOT = Path(__file__).resolve().parent.parent.parent +SPEC = ROOT / "spec" +VECTORS = SPEC / "test-vectors" +RECEIPT_GOLDEN = ROOT / "gateway" / "tests" / "fixtures" / "receipt_signing_golden" +PERMIT_GOLDEN = ROOT / "gateway" / "tests" / "fixtures" / "permit_signing_golden" + +sys.path.insert(0, str(ROOT / "sdk" / "python" / "src")) + +from b2a_sdk.receipt_verifier import ( # noqa: E402 + CANONICALIZATION, + key_set_from_document, + verify_bundle, +) + +from app.schemas.trust import PortableReceiptResponse # noqa: E402 +from app.services.permits import PermitService # noqa: E402 +from app.services.receipts import ReceiptService # noqa: E402 +from app.services.signing_keys import canonical_json as gateway_canonical_json # noqa: E402 + +TEST_SEED = bytes(range(32)) +TEST_KID = "sk_golden_0001" +TEST_PUBKEY = Ed25519PrivateKey.from_private_bytes(TEST_SEED).public_key() + +# Members named by the field tables in spec/permit-v0.1.md. +PERMIT_REQUIRED = { + "permit_id", + "issuer_wallet_id", + "subject_wallet_id", + "subject_key_id", + "scopes", + "allowed_tools", + "max_credits", + "issued_at", + "expires_at", + "nonce", + "status", + "alg", + "kid", + "payload_hash", +} +# Optional members named in spec/permit-v0.1.md (additive). +PERMIT_OPTIONAL = { + "requires_human_approval", + "max_calls_per_tool", + "aggregate_value_cap", + "forbidden_fields", + "recipient_domain", + "allow_identical_repeats", + "repeat_window_seconds", + # action_* binding: all six together, or none. + "action_contract_version", + "action_payload_hash", + "action_schema_id", + "action_schema_version", + "action_public_tool_id", + "action_upstream_binding_hash", +} +# Members named by the signed-payload table in spec/receipt-v0.1.md. +RECEIPT_DOCUMENTED = { + "receipt_id", + "permit_id", + "wallet_id", + "key_id", + "tool", + "idempotency_record_id", + "dispatch_attempt_id", + "request_hash", + "response_hash", + "outcome", + "credits_authorized", + "credits_charged", + "ledger_entry_id", + "audit_event_id", + "created_at", + "alg", + "kid", + "payload_hash", +} +# Additive receipt members named in spec/receipt-v0.1.md ("approval ids, +# action bindings, constraint denials") plus reason_code, which the gateway +# signs when present. +RECEIPT_ADDITIVE = { + "reason_code", + "approval_id", + "constraints_evaluated", + "action_contract_version", + "action_payload_hash", + "action_schema_id", + "action_schema_version", + "action_public_tool_id", + "action_upstream_binding_hash", +} +# Always-signed receipt members: present even when the value is null. +RECEIPT_ALWAYS_SIGNED = RECEIPT_DOCUMENTED - { + "idempotency_record_id", + "dispatch_attempt_id", +} +# Portable bundle members from spec/receipt-v0.1.md ("Portable bundle"). +PORTABLE_MEMBERS = { + "receipt_id", + "kid", + "alg", + "canonicalization", + "signing_input", + "signature", +} +# Outcomes table in spec/receipt-v0.1.md. +SPEC_OUTCOMES = { + "success", + "denied", + "insufficient_funds", + "failed_refunded", + "failed_unrefunded", + "delivery_uncertain", + "response_rejected", +} + + +def _code_block(path: Path) -> str: + text = path.read_text() + match = re.search(r"```json\n(.*)\n```", text, re.S) + assert match, f"no json code block in {path}" + return match.group(1).strip() + + +def _recompute_payload_hash(payload: dict) -> bool: + stated = payload.get("payload_hash") + inner = {k: v for k, v in payload.items() if k != "payload_hash"} + # Gateway canonicalization: normalizes Decimal and datetime values the + # way the signer did before hashing. + canonical = gateway_canonical_json(inner) + return hashlib.sha256(canonical.encode()).hexdigest() == stated + + +def _permit_model(**overrides): + from app.db.models import PermitModel + + fields = dict( + permit_id="pmt_spec_0001", + issuer_wallet_id="agt-spec-issuer", + subject_wallet_id="agt-spec-subject", + subject_key_id="key_spec_0001", + scopes_json=json.dumps(["tool:echo:invoke", "billing:charge"]), + allowed_tools_json=json.dumps(["echo"]), + max_credits=Decimal("50"), + expires_at=datetime(2026, 9, 16, 13, 0, tzinfo=timezone.utc), + nonce="nonce_spec_0001", + status="active", + signature="", + key_id=TEST_KID, + issued_at=datetime(2026, 9, 16, 12, 0, tzinfo=timezone.utc), + ) + fields.update(overrides) + return PermitModel(**fields) + + +def _receipt_model(**overrides): + from app.db.models import ReceiptModel + + fields = dict( + receipt_id="rcpt_spec_0001", + idempotency_record_id="idem_spec_0001", + dispatch_attempt_id="disp_spec_0001", + permit_id="pmt_spec_0001", + wallet_id="agt-spec-0001", + key_id="key_spec_0001", + tool="echo", + request_hash="a" * 64, + response_hash="b" * 64, + ledger_entry_id="led_spec_0001", + credits_authorized=Decimal("2.5"), + credits_charged=Decimal("2.5"), + outcome="success", + reason_code=None, + audit_event_id="aud_spec_0001", + approval_id=None, + constraints_evaluated_json=None, + created_at=datetime(2026, 9, 14, 17, 0, tzinfo=timezone.utc), + signature="", + signature_key_id=TEST_KID, + ) + fields.update(overrides) + return ReceiptModel(**fields) + + +class TestPermitExample: + def test_example_has_exactly_the_documented_required_members(self): + payload = json.loads(_code_block(SPEC / "permit-v0.1.md")) + assert set(payload) == PERMIT_REQUIRED + assert isinstance(payload["scopes"], list) and payload["scopes"] + assert isinstance(payload["allowed_tools"], list) and payload["allowed_tools"] + assert re.fullmatch(r"\d+(\.\d+)?", payload["max_credits"]) + assert payload["status"] == "active" + assert payload["alg"] == "Ed25519" + assert isinstance(payload["kid"], str) and payload["kid"] + # Validity window parses as ISO-8601 and is ordered. + issued = datetime.fromisoformat(payload["issued_at"]) + expires = datetime.fromisoformat(payload["expires_at"]) + assert issued < expires + # subject_key_id may be null (unbound permit); here it is bound. + assert isinstance(payload["subject_key_id"], str) + + def test_example_payload_hash_recomputes(self): + payload = json.loads(_code_block(SPEC / "permit-v0.1.md")) + assert _recompute_payload_hash(payload) + + def test_example_is_canonical_byte_order(self): + raw = _code_block(SPEC / "permit-v0.1.md") + assert raw == gateway_canonical_json(json.loads(raw)) + + def test_example_matches_gateway_golden_bytes(self): + raw = _code_block(SPEC / "permit-v0.1.md") + golden = json.loads((PERMIT_GOLDEN / "active_minimal.json").read_text())[ + "signing_input" + ] + assert raw == golden + + def test_example_signature_verifies_under_test_seed(self): + raw = _code_block(SPEC / "permit-v0.1.md") + golden = json.loads((PERMIT_GOLDEN / "active_minimal.json").read_text()) + signature = base64.b64decode(golden["signature_b64_over_test_seed"]) + TEST_PUBKEY.verify(signature, raw.encode()) + + @pytest.mark.parametrize( + "overrides", + [ + {}, + {"subject_key_id": None}, + { + "requires_human_approval": True, + "max_calls_per_tool_json": json.dumps({"echo": 3}), + "forbidden_fields_json": json.dumps(["secret_token"]), + "recipient_domain": "partner.example", + }, + { + "action_contract_version": 1, + "action_payload_hash": "c" * 64, + "action_schema_id": "fixture-payment", + "action_schema_version": "1", + "action_public_tool_id": "echo", + "action_upstream_binding_hash": "d" * 64, + }, + ], + ids=["minimal", "unbound_key", "v2_constraints", "action_binding"], + ) + def test_gateway_permit_payload_matches_spec_schema(self, overrides): + payload = PermitService._verification_payload(_permit_model(**overrides)) + assert PERMIT_REQUIRED <= set(payload) + assert set(payload) <= PERMIT_REQUIRED | PERMIT_OPTIONAL + assert _recompute_payload_hash(payload) + + def test_gateway_never_signs_mutable_spend(self): + payload = PermitService._verification_payload( + _permit_model(spent_credits=Decimal("12.5")) + ) + assert "spent_credits" not in payload + + +class TestReceiptDocBundle: + def test_doc_bundle_shape(self): + bundle = json.loads(_code_block(SPEC / "receipt-v0.1.md")) + assert set(bundle) == PORTABLE_MEMBERS + assert bundle["alg"] == "Ed25519" + assert bundle["canonicalization"] == "awi-canonical-json/1" == CANONICALIZATION + + def test_gateway_portable_response_covers_spec_bundle(self): + fields = set(PortableReceiptResponse.model_fields) + from app.services.signing_keys import RECEIPT_CANONICALIZATION + + assert PORTABLE_MEMBERS <= fields + assert PortableReceiptResponse.model_fields["alg"].default == "Ed25519" + assert ( + PortableReceiptResponse.model_fields["canonicalization"].default + == "awi-canonical-json/1" + == CANONICALIZATION + == RECEIPT_CANONICALIZATION + ) + + def test_sdk_accepts_gateway_shaped_bundle(self): + vectors = json.loads((VECTORS / "receipts.json").read_text()) + keys = key_set_from_document(json.loads((VECTORS / "keys.json").read_text())) + valid = next(c for c in vectors["cases"] if c["expect"] == "verified") + gateway_shaped = dict( + valid["bundle"], + schema_version="1.0", + issuer="https://example.test", + keys_url="/.well-known/trust-keys.json", + ) + assert verify_bundle(gateway_shaped, keys).status.value == "verified" + mismatched = verify_bundle( + gateway_shaped, keys, expected_issuer="https://other.test" + ) + assert mismatched.status.value == "mismatch" + + +class TestKeySetDoc: + def test_doc_example_has_documented_shape(self): + # The doc example is a shape illustration with angle-bracket + # placeholders, not usable key material (see the verifier test + # below, which requires it to yield zero keys). + doc = json.loads(_code_block(SPEC / "key-set-v0.1.md")) + assert set(doc) == {"keys"} + (entry,) = doc["keys"] + assert {"kid", "alg", "status", "public_key_b64", "jwk"} <= set(entry) + assert entry["alg"] == "Ed25519" + assert entry["status"] == "active" + assert set(entry["jwk"]) == {"kty", "crv", "x"} + assert entry["jwk"]["kty"] == "OKP" + assert entry["jwk"]["crv"] == "Ed25519" + assert entry["public_key_b64"].startswith("<") + assert entry["jwk"]["x"].startswith("<") + + def test_doc_example_placeholders_agree_with_each_other(self): + # Both encodings of the placeholder describe the same 32-byte key: + # the raw form names the length, the JWK form mirrors it. + doc = json.loads(_code_block(SPEC / "key-set-v0.1.md")) + (entry,) = doc["keys"] + assert "32-byte" in entry["public_key_b64"] + + def test_doc_example_loads_through_offline_verifier(self): + # The doc example carries angle-bracket placeholders, not key + # material. The offline verifier must yield no keys for it: a + # placeholder that parsed as a usable key would be worse than none. + doc = json.loads(_code_block(SPEC / "key-set-v0.1.md")) + assert key_set_from_document(doc) == {} + + def test_vectors_keys_match_seed_and_doc_shape(self): + doc = json.loads((VECTORS / "keys.json").read_text()) + (entry,) = doc["keys"] + assert {"kid", "alg", "status", "public_key_b64", "jwk"} <= set(entry) + assert entry["kid"] == TEST_KID + assert entry["status"] == "active" + expected_pub = Ed25519PrivateKey.from_private_bytes(TEST_SEED).public_key() + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + ) + + raw_pub = expected_pub.public_bytes(Encoding.Raw, PublicFormat.Raw) + assert base64.b64decode(entry["public_key_b64"]) == raw_pub + keys = key_set_from_document(doc) + assert set(keys) == {TEST_KID} + assert keys[TEST_KID] == raw_pub + + def test_gateway_trust_keys_shape_loads_offline(self): + from app.routers.well_known import _published_key + + now = datetime(2026, 9, 16, 12, 0, tzinfo=timezone.utc) + active = SimpleNamespace( + key_id=TEST_KID, + alg="Ed25519", + public_key_b64=base64.b64encode(b"1" * 32).decode(), + status="active", + created_at=now, + activated_at=now, + retired_at=None, + ) + retired = SimpleNamespace(**{**vars(active), "status": "retired"}) + disabled = SimpleNamespace(**{**vars(active), "status": "disabled"}) + jwk_only = { + "kid": "sk_jwk_only", + "alg": "Ed25519", + "status": "active", + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(b"2" * 32).decode().rstrip("="), + }, + } + doc = { + "keys": [ + _published_key(active), + _published_key(retired), + _published_key(disabled), + jwk_only, + ] + } + # Every gateway entry carries the members the spec doc names. + for entry in doc["keys"][:3]: + assert {"kid", "alg", "status", "public_key_b64", "jwk"} <= set(entry) + assert {"kty", "crv", "x"} <= set(entry["jwk"]) + keys = key_set_from_document(doc) + # Retired keys stay verifiable, disabled keys are dropped, and the + # JWK mirror is accepted when the raw form is absent. + assert set(keys) == {TEST_KID, "sk_jwk_only"} + + +class TestReceiptVectors: + def _load(self): + keys = key_set_from_document(json.loads((VECTORS / "keys.json").read_text())) + cases = json.loads((VECTORS / "receipts.json").read_text())["cases"] + return keys, cases + + def test_every_vector_produces_its_expected_status(self): + keys, cases = self._load() + assert cases + failures = [] + for case in cases: + case_keys = key_set_from_document(case["keys"]) if "keys" in case else keys + actual = verify_bundle(case["bundle"], case_keys).status.value + if actual != case["expect"]: + failures.append((case["name"], actual, case["expect"])) + assert not failures + + def test_vector_bundles_match_spec_portable_shape(self): + _, cases = self._load() + # Cases whose point is a wrong canonicalization member itself must + # still carry the member (shape) but are exempt from the value check. + canonicalization_tamper_cases = { + "synthetic.envelope_canonicalization_mismatch", + "synthetic.unsupported_canonicalization", + } + for case in cases: + if case["expect"] == "malformed": + continue + assert PORTABLE_MEMBERS <= set(case["bundle"]), case["name"] + if case["name"] in canonicalization_tamper_cases: + continue + assert case["bundle"]["canonicalization"] == CANONICALIZATION, case["name"] + + def test_valid_vectors_have_documented_payloads(self): + keys, cases = self._load() + for case in cases: + if case["expect"] != "verified": + continue + payload = json.loads(case["bundle"]["signing_input"]) + assert RECEIPT_ALWAYS_SIGNED <= set(payload), case["name"] + assert set(payload) <= RECEIPT_DOCUMENTED | RECEIPT_ADDITIVE, case["name"] + assert _recompute_payload_hash(payload), case["name"] + assert payload["outcome"] in SPEC_OUTCOMES, case["name"] + assert re.fullmatch(r"\d+(\.\d+)?", payload["credits_authorized"]) + assert re.fullmatch(r"\d+(\.\d+)?", payload["credits_charged"]) + datetime.fromisoformat(payload["created_at"]) + assert payload["kid"] == case["bundle"]["kid"] == TEST_KID + + def test_vectors_cover_every_gateway_golden_fixture(self): + _, cases = self._load() + names = {c["name"] for c in cases} + goldens = sorted(p.stem for p in RECEIPT_GOLDEN.glob("*.json")) + assert goldens + for stem in goldens: + assert f"{stem}.valid" in names + assert f"{stem}.bad_signature" in names + fixture = json.loads((RECEIPT_GOLDEN / f"{stem}.json").read_text()) + signature = base64.b64decode(fixture["signature_b64_over_test_seed"]) + TEST_PUBKEY.verify(signature, fixture["signing_input"].encode()) + + def test_idempotency_linkage_is_signature_bound(self): + # Replay protection rests on idempotency_record_id being inside the + # signed bytes: moving a receipt to another idempotency record must + # break the signature, not produce a second valid receipt. + _, cases = self._load() + linked = next( + c + for c in cases + if c["expect"] == "verified" + and "idempotency_record_id" in json.loads(c["bundle"]["signing_input"]) + ) + payload = json.loads(linked["bundle"]["signing_input"]) + payload["idempotency_record_id"] = "idem_rewritten_by_attacker" + forged_input = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ) + forged = dict(linked["bundle"], signing_input=forged_input) + keys = key_set_from_document(json.loads((VECTORS / "keys.json").read_text())) + assert verify_bundle(forged, keys).status.value == "invalid" + + def test_denied_receipts_move_no_money(self): + _, cases = self._load() + denied = [ + c + for c in cases + if c["expect"] == "verified" + and json.loads(c["bundle"]["signing_input"])["outcome"] + in ("denied", "insufficient_funds") + ] + assert denied, "vectors must pin the no-charge-on-denial invariant" + for case in denied: + payload = json.loads(case["bundle"]["signing_input"]) + assert payload["credits_charged"] == "0", case["name"] + assert payload["ledger_entry_id"] is None, case["name"] + + +class TestGatewayReceiptPayloadParity: + @pytest.mark.parametrize( + "overrides", + [ + {}, + {"idempotency_record_id": None, "dispatch_attempt_id": None}, + {"approval_id": "apr_spec_0001"}, + { + "reason_code": "permit_denied", + "outcome": "denied", + "credits_charged": Decimal("0"), + "ledger_entry_id": None, + "response_hash": None, + }, + {"constraints_evaluated_json": json.dumps({"max_calls": "ok"})}, + { + "action_contract_version": 1, + "action_payload_hash": "c" * 64, + "action_schema_id": "fixture-payment", + "action_schema_version": "1", + "action_public_tool_id": "echo", + "action_upstream_binding_hash": "d" * 64, + }, + ], + ids=[ + "linked", + "legacy", + "approval", + "denial", + "constraints", + "action_binding", + ], + ) + def test_gateway_receipt_payload_matches_spec_schema(self, overrides): + for include_linkage in (True, False): + payload = ReceiptService._verification_payload( + _receipt_model(**overrides), include_linkage=include_linkage + ) + assert RECEIPT_ALWAYS_SIGNED <= set(payload) + assert set(payload) <= RECEIPT_DOCUMENTED | RECEIPT_ADDITIVE + assert _recompute_payload_hash(payload) + + def test_linkage_members_sign_only_when_set(self): + linked = ReceiptService._verification_payload( + _receipt_model(), include_linkage=True + ) + assert linked["idempotency_record_id"] == "idem_spec_0001" + assert linked["dispatch_attempt_id"] == "disp_spec_0001" + legacy = ReceiptService._verification_payload( + _receipt_model(), include_linkage=False + ) + assert "idempotency_record_id" not in legacy + assert "dispatch_attempt_id" not in legacy + + def test_gateway_and_sdk_canonical_json_agree(self): + from b2a_sdk.receipt_verifier import canonical_json as sdk_canonical_json + + payloads = [ + {"amount": "2.50", "note": "caf\u00e9 \u2603 plain"}, + {"nested": {"b": [1, None, True], "a": "x"}, "empty": {}}, + {"unicode_keys": "\u00e9"}, + ] + for payload in payloads: + assert gateway_canonical_json(payload) == sdk_canonical_json(payload) + assert gateway_canonical_json({"v": Decimal("2.5000")}) == '{"v":"2.5"}' + assert ( + gateway_canonical_json( + {"t": datetime(2026, 9, 14, 17, 0, tzinfo=timezone.utc)} + ) + == '{"t":"2026-09-14T17:00:00+00:00"}' + ) + + def test_offline_verifier_ignores_unusable_key_material(self): + # Entries that cannot verify (bad base64, wrong length, unknown + # algorithm) are skipped, never trusted; a document with no key + # list is a caller error. + from b2a_sdk.receipt_verifier import VerificationError + + with pytest.raises(VerificationError): + key_set_from_document({"keys": "not-a-list"}) + with pytest.raises(VerificationError): + key_set_from_document({"no_keys": []}) + assert ( + key_set_from_document( + { + "keys": [ + { + "kid": "bad", + "alg": "Ed25519", + "status": "active", + "public_key_b64": "!!!not-base64!!!", + } + ] + } + ) + == {} + ) diff --git a/gateway/tests/test_sqlite_backend.py b/gateway/tests/test_sqlite_backend.py index f8a44c63..f5373468 100644 --- a/gateway/tests/test_sqlite_backend.py +++ b/gateway/tests/test_sqlite_backend.py @@ -4,6 +4,7 @@ import os import tempfile +from contextlib import contextmanager import pytest @@ -27,6 +28,25 @@ def reset_settings(): get_settings.cache_clear() +@contextmanager +def _cleared_database_url(): + """Hide ``DATABASE_URL`` for one store, then put the suite URL back. + + The session database fixture re-checks this variable before every later + test. Leaving it cleared looks like an inherited database and aborts the + worker. + """ + previous = os.environ.get("DATABASE_URL") + os.environ["DATABASE_URL"] = "" + try: + yield + finally: + if previous is None: + os.environ.pop("DATABASE_URL", None) + else: + os.environ["DATABASE_URL"] = previous + + @pytest.fixture def sqlite_store(): """Create a SQLite-based DurableStateStore for testing.""" @@ -35,20 +55,26 @@ def sqlite_store(): os.environ["SQLITE_URL"] = db_path os.environ["STATE_BACKEND"] = "sqlite" - os.environ["DATABASE_URL"] = "" os.environ["REDIS_URL"] = "" - - store = DurableStateStore() - yield store - - import asyncio - - asyncio.run(store.close()) - - try: - os.unlink(db_path) - except FileNotFoundError: - pass + with _cleared_database_url(): + store = DurableStateStore() + try: + yield store + finally: + import asyncio + + asyncio.run(store.close()) + try: + os.unlink(db_path) + except FileNotFoundError: + pass + + +def test_cleared_database_url_is_restored_before_later_tests(): + original = os.environ["DATABASE_URL"] + with _cleared_database_url(): + assert os.environ["DATABASE_URL"] == "" + assert os.environ["DATABASE_URL"] == original @pytest.mark.asyncio diff --git a/gateway/tests/test_stripe_integration.py b/gateway/tests/test_stripe_integration.py index d8275f2b..bb4d4e66 100644 --- a/gateway/tests/test_stripe_integration.py +++ b/gateway/tests/test_stripe_integration.py @@ -31,6 +31,19 @@ def api_headers(): return {"X-API-Key": "test-key"} +@pytest.fixture(autouse=True) +def _configured_webhook_secret(monkeypatch): + """Opt into webhook verification for this module's mocked-signature tests. + + The shared helper refuses to verify with no secret configured, so tests + that mock stripe.Webhook.construct_event set a dummy secret here; the + fail-closed behavior itself is pinned in test_webhook_auth.py. + """ + from app.core.config import get_settings + + monkeypatch.setattr(get_settings(), "STRIPE_WEBHOOK_SECRET", "whsec_test_stripe") + + @pytest.fixture async def sponsor_wallet(client, api_headers): """Create a sponsor wallet for testing.""" @@ -235,7 +248,7 @@ async def test_prepare_top_up_wallet_not_found(client, api_headers): @pytest.mark.anyio async def test_webhook_signature_verification(client): - """Test that invalid Stripe signatures are rejected.""" + """Test that invalid Stripe signatures are rejected with 401.""" with ( patch( "app.services.stripe_integration.stripe.Webhook.construct_event", @@ -253,18 +266,18 @@ async def test_webhook_signature_verification(client): headers={"stripe-signature": "invalid_sig"}, ) - assert resp.status_code == 400 + assert resp.status_code == 401 mock_mint.assert_not_awaited() @pytest.mark.anyio async def test_webhook_missing_signature(client): - """Test that missing Stripe signatures are rejected.""" + """Test that missing Stripe signatures are rejected with 401.""" resp = await client.post( "/v1/webhooks/stripe", content=b"some_payload", ) - assert resp.status_code == 400 # Missing signature header + assert resp.status_code == 401 # Missing signature header @pytest.mark.anyio diff --git a/gateway/tests/test_suite_posture_defaults.py b/gateway/tests/test_suite_posture_defaults.py new file mode 100644 index 00000000..835929bb --- /dev/null +++ b/gateway/tests/test_suite_posture_defaults.py @@ -0,0 +1,92 @@ +"""Pin the gap between the default test posture and production posture. + +The default suite runs permissive on purpose (legacy unpermitted MCP +allowed, no rate limits) so old tests exercise MCP paths without permits. +That means "all tests pass" overstates production readiness unless three +things stay true: production defaults stay strict, the strict-mode CI run +keeps content, and the Postgres concurrency CI job keeps existing. This +test fails loudly if any of those drift. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" + + +def test_suite_trust_defaults_are_permissive_while_production_is_strict(): + """The suite opts out of strict mode; production must not follow it.""" + from app.core.config import Settings + + assert os.environ.get("TRUST_MODE_ENABLED") == "false" + assert os.environ.get("ALLOW_LEGACY_UNPERMITTED_MCP") == "true" + assert Settings.model_fields["TRUST_MODE_ENABLED"].default is True + assert Settings.model_fields["ALLOW_LEGACY_UNPERMITTED_MCP"].default is False + + +def test_suite_rate_limit_is_disabled_while_production_limits(): + """The suite default removes throttling; production keeps 120/min. + + Real limit behavior is covered only by tests that build their own + middleware (test_runtime_degradation, test_rate_limit_threshold), so + this pin keeps the exemption from leaking into production config. + """ + from app.core.config import Settings + + assert os.environ.get("RATE_LIMIT_PER_MINUTE") == "1000000" + assert Settings.model_fields["RATE_LIMIT_PER_MINUTE"].default == 120 + + +def test_strict_mode_ci_run_has_content(): + """The production_trust CI run must keep proving the strict posture.""" + posture = ( + Path(__file__).resolve().parent / "test_production_trust_posture.py" + ).read_text() + strict_tests = posture.count("pytest.mark.production_trust") + assert strict_tests >= 5, ( + f"only {strict_tests} production_trust marks left; the strict-mode " + f"CI run is losing coverage" + ) + workflow = CI_WORKFLOW.read_text() + assert "TRUST_MODE_ENABLED=true" in workflow + assert "ALLOW_LEGACY_UNPERMITTED_MCP=false" in workflow + + +def test_postgres_proof_ci_jobs_exist(): + """Real database and crash coverage must execute in required CI.""" + workflow = CI_WORKFLOW.read_text() + assert "postgres-concurrency" in workflow + assert "postgres-proofs" in workflow + assert 'CI_STRICT: "1"' in workflow + assert "RUN_POSTGRES_CONCURRENCY_TESTS" in workflow + assert "test_action_postgres.py" in workflow + assert "test_mcp_postgres_multiprocess.py" in workflow + assert "test_action_multiprocess.py" in workflow + assert "Prove gated tests actually ran" in workflow + + +def test_trust_release_gate_is_separate_from_required_ci_result(): + """Keep the release gate visible without blocking unrelated changes. + + The script's first step is ``make check-railway-iac``; an undefined target + aborts the gate before coverage and OpenAPI parity run. + """ + workflow = CI_WORKFLOW.read_text() + assert "\n trust_release_gate:\n" in workflow + assert "name: Trust release gate (non-required)" in workflow + assert "scripts/trust_release_gate.sh" in workflow + assert 'UV_PYTHON: "3.12"' in workflow + assert 'ACP_CHECKOUT_MEDIAN_BUDGET_S: "0.045"' in workflow + assert ( + "tests/test_acp_bridge.py::test_acp_checkout_end_to_end_latency_budget" + in workflow + ) + result_block = workflow.split("name: CI result", 1)[1].split("steps:", 1)[0] + assert "trust_release_gate" not in result_block + assert "gateway-image-smoke" in result_block + + makefile = (ROOT / "gateway" / "Makefile").read_text() + assert "\ncheck-railway-iac:" in makefile diff --git a/gateway/tests/test_tenant_isolation_hardening.py b/gateway/tests/test_tenant_isolation_hardening.py index 37d4f475..bc70f0af 100644 --- a/gateway/tests/test_tenant_isolation_hardening.py +++ b/gateway/tests/test_tenant_isolation_hardening.py @@ -386,6 +386,74 @@ async def test_iot_registration_metadata_cannot_assign_ownership( assert (await client.get(base, headers=b["agent_headers"])).status_code == 200 +@pytest.mark.proof +@pytest.mark.anyio +async def test_foreign_device_register_matches_missing_device(client, clean_database): + """Registering someone else's device id must look like a missing id. + + A 409 device_exists told the caller the id was already taken. The owner + still gets that conflict, because they can already read the device. + """ + a = await provision_agent_wallet(client) + b = await provision_agent_wallet(client) + device_id = _iot_id("taken") + missing_id = _iot_id("missing") + admin_id = _iot_id("admin") + body = _iot_device(device_id) + + created = await client.post( + "/v1/iot/devices", json=body, headers=a["agent_headers"] + ) + assert created.status_code == 201 + + own_again = await client.post( + "/v1/iot/devices", json=body, headers=a["agent_headers"] + ) + assert own_again.status_code == 409 + assert own_again.json()["detail"]["error"] == "device_exists" + + admin_created = await client.post( + "/v1/iot/devices", json=_iot_device(admin_id), headers=BOOTSTRAP_HEADERS + ) + assert admin_created.status_code == 201 + admin_again = await client.post( + "/v1/iot/devices", json=_iot_device(admin_id), headers=BOOTSTRAP_HEADERS + ) + assert admin_again.status_code == 409 + assert admin_again.json()["detail"]["error"] == "device_exists" + + foreign = await client.post( + "/v1/iot/devices", json=body, headers=b["agent_headers"] + ) + foreign_admin = await client.post( + "/v1/iot/devices", json=_iot_device(admin_id), headers=b["agent_headers"] + ) + missing = await client.get( + f"/v1/iot/devices/{missing_id}", headers=b["agent_headers"] + ) + assert missing.status_code == 404 + missing_message = missing.json()["detail"]["message"].replace(missing_id, "") + for resp, taken_id in ((foreign, device_id), (foreign_admin, admin_id)): + assert resp.status_code == 404, resp.text + assert resp.json()["detail"]["error"] == "device_not_found" + assert resp.json()["detail"]["message"].replace(taken_id, "") == ( + missing_message + ) + assert "device_exists" not in resp.text + assert a["agent_wallet_id"] not in resp.text + + # Neither attempt took the id or changed who can read it. + assert ( + await client.get(f"/v1/iot/devices/{device_id}", headers=a["agent_headers"]) + ).status_code == 200 + assert ( + await client.get(f"/v1/iot/devices/{device_id}", headers=b["agent_headers"]) + ).status_code == 404 + assert ( + await client.get(f"/v1/iot/devices/{admin_id}", headers=BOOTSTRAP_HEADERS) + ).status_code == 200 + + @pytest.mark.anyio async def test_bootstrap_admin_key_matches_via_constant_time_compare(clean_database): """A configured bootstrap key is accepted as admin; a key that merely diff --git a/gateway/tests/test_test_database_safety.py b/gateway/tests/test_test_database_safety.py new file mode 100644 index 00000000..b0858c33 --- /dev/null +++ b/gateway/tests/test_test_database_safety.py @@ -0,0 +1,178 @@ +"""The shared test fixtures must reject inherited database targets before I/O.""" + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from tests.conftest import ( + clean_database, + require_disposable_test_database, + setup_database, +) + +ACTION_URL = "postgresql+asyncpg://sellers@127.0.0.1:55439/amw_action_guard_1" +PROOF_URL = "postgresql+asyncpg://sellers@db.example.invalid/amw_pg_concurrency" +CI_PROOF_URL = "postgresql+asyncpg://sellers@127.0.0.1:55439/amw_pg_concurrency" + + +@pytest.mark.parametrize( + "url", + [ + "postgresql+asyncpg://user:synthetic-password@db.example.invalid/production", + "sqlite+aiosqlite:///./other.db", + "sqlite+aiosqlite:///./test.db?mode=ro", + "sqlite+aiosqlite:///:memory:", + "", + ], +) +def test_ordinary_runs_reject_inherited_database_url(monkeypatch, url): + monkeypatch.setenv("DATABASE_URL", url) + monkeypatch.delenv("AMW_TEST_DB_DISPOSABLE", raising=False) + monkeypatch.delenv("RUN_POSTGRES_CONCURRENCY_TESTS", raising=False) + monkeypatch.delenv("RUN_MCP_MULTIPROCESS_TESTS", raising=False) + + with pytest.raises(ValueError, match="^unsafe_test_database_url$"): + require_disposable_test_database() + + +@pytest.mark.parametrize("flag", [None, "0", "true"]) +def test_non_local_postgres_requires_explicit_disposable_flag(monkeypatch, flag): + monkeypatch.setenv("DATABASE_URL", PROOF_URL) + monkeypatch.setenv("ENVIRONMENT", "test") + monkeypatch.delenv("RUN_POSTGRES_CONCURRENCY_TESTS", raising=False) + monkeypatch.delenv("RUN_MCP_MULTIPROCESS_TESTS", raising=False) + if flag is None: + monkeypatch.delenv("AMW_TEST_DB_DISPOSABLE", raising=False) + else: + monkeypatch.setenv("AMW_TEST_DB_DISPOSABLE", flag) + + with pytest.raises(ValueError, match="^unsafe_test_database_url$"): + require_disposable_test_database() + + +@pytest.mark.parametrize( + "url, postgres_flag", + [(PROOF_URL, False), (CI_PROOF_URL, True)], +) +def test_explicit_disposable_flag_allows_postgres_proofs( + monkeypatch, url, postgres_flag +): + monkeypatch.setenv("DATABASE_URL", url) + monkeypatch.setenv("ENVIRONMENT", "test") + monkeypatch.setenv("AMW_TEST_DB_DISPOSABLE", "1") + if postgres_flag: + monkeypatch.setenv("RUN_POSTGRES_CONCURRENCY_TESTS", "1") + + require_disposable_test_database() + + +@pytest.mark.parametrize( + "url", ["sqlite+aiosqlite:///./other.db", "postgresql+asyncpg://"] +) +def test_disposable_flag_rejects_other_or_incomplete_urls(monkeypatch, url): + monkeypatch.setenv("DATABASE_URL", url) + monkeypatch.setenv("ENVIRONMENT", "test") + monkeypatch.setenv("AMW_TEST_DB_DISPOSABLE", "1") + + with pytest.raises(ValueError, match="^unsafe_test_database_url$"): + require_disposable_test_database() + + +def test_disposable_flag_requires_test_environment(monkeypatch): + monkeypatch.setenv("DATABASE_URL", PROOF_URL) + monkeypatch.setenv("ENVIRONMENT", "production") + monkeypatch.setenv("AMW_TEST_DB_DISPOSABLE", "1") + + with pytest.raises(ValueError, match="^unsafe_test_database_url$"): + require_disposable_test_database() + + +@pytest.mark.parametrize( + "flag", ["RUN_POSTGRES_CONCURRENCY_TESTS", "RUN_MCP_MULTIPROCESS_TESTS"] +) +def test_explicit_local_postgres_profile_is_allowed(monkeypatch, flag): + monkeypatch.setenv("DATABASE_URL", ACTION_URL) + monkeypatch.setenv("ENVIRONMENT", "test") + monkeypatch.delenv("AMW_TEST_DB_DISPOSABLE", raising=False) + monkeypatch.setenv(flag, "1") + + require_disposable_test_database() + + +@pytest.mark.parametrize( + "url, environment", + [ + (ACTION_URL, "production"), + (ACTION_URL + "?host=db.example.invalid", "test"), + (ACTION_URL.replace("amw_action_guard_1", "production"), "test"), + (CI_PROOF_URL, "test"), + ], +) +def test_opt_in_still_rejects_unsafe_postgres_profile(monkeypatch, url, environment): + monkeypatch.setenv("DATABASE_URL", url) + monkeypatch.setenv("ENVIRONMENT", environment) + monkeypatch.delenv("AMW_TEST_DB_DISPOSABLE", raising=False) + monkeypatch.setenv("RUN_POSTGRES_CONCURRENCY_TESTS", "1") + + with pytest.raises(ValueError, match="^unsafe_action_test_"): + require_disposable_test_database() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fixture", [setup_database, clean_database]) +async def test_fixture_rechecks_url_before_setup_or_cleanup(monkeypatch, fixture): + monkeypatch.setenv("DATABASE_URL", "sqlite+aiosqlite:///./other.db") + + with pytest.raises(ValueError, match="^unsafe_test_database_url$"): + await anext(fixture.__wrapped__()) + + +def test_unsafe_inherited_url_stops_pytest_before_database_setup(tmp_path): + test_file = Path(__file__).with_name("test_clean_database_coverage.py") + sentinel = tmp_path / "test.db" + sentinel.write_bytes(b"existing database sentinel") + url = "postgresql+asyncpg://user:synthetic-password@db.example.invalid/production" + script = """ +import pathlib, socket, sys +network_marker = pathlib.Path(sys.argv[1]) +def no_network(*args, **kwargs): + network_marker.touch() + raise AssertionError("network reached") +socket.socket.connect = no_network +socket.socket.connect_ex = no_network +socket.create_connection = no_network +socket.getaddrinfo = no_network +import pytest +raise SystemExit(pytest.main([sys.argv[2], "-q"])) +""" + env = { + "PATH": os.environ.get("PATH", ""), + "PYTHONPATH": str(test_file.parents[1]), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_asyncio.plugin", + "DATABASE_URL": url, + } + result = subprocess.run( + [ + sys.executable, + "-c", + script, + str(tmp_path / "network-reached"), + str(test_file), + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + output = result.stdout + result.stderr + assert result.returncode != 0 + assert "unsafe_test_database_url" in output + assert url not in output + assert "synthetic-password" not in output + assert sentinel.read_bytes() == b"existing database sentinel" + assert not (tmp_path / "network-reached").exists() diff --git a/gateway/tests/test_testing_tiers_doc.py b/gateway/tests/test_testing_tiers_doc.py new file mode 100644 index 00000000..9a0e74ea --- /dev/null +++ b/gateway/tests/test_testing_tiers_doc.py @@ -0,0 +1,54 @@ +"""The test-tier guide must keep up with the suite it describes. + +Prospects stall when a skipped or red local run has no explanation, so +docs/TESTING_TIERS.md names the three levels (plain pytest, Postgres +opt-in, live batteries) with the exact flags each needs. This test pins +the guide to the mechanisms: if the flags move, the guide must move. +""" + +from __future__ import annotations + +from pathlib import Path + +DOC = Path(__file__).resolve().parent.parent / "docs" / "TESTING_TIERS.md" + + +def _text() -> str: + assert DOC.exists(), "docs/TESTING_TIERS.md is missing" + return DOC.read_text() + + +def test_tier_guide_names_plain_pytest_level(): + text = _text() + assert "make test-all" in text + assert "production_trust" in text + + +def test_tier_guide_names_postgres_opt_in_flags(): + text = _text() + for token in ( + "RUN_POSTGRES_CONCURRENCY_TESTS", + "ENVIRONMENT=test", + "alembic upgrade head", + "test_action_postgres.py", + ): + assert token in text, f"tier guide no longer documents {token}" + + +def test_tier_guide_names_live_level_and_ci_job(): + text = _text() + for token in ( + "trust-conformance-live", + "adversarial-battery-live", + "postgres-concurrency", + "postgres-proofs", + "tests/test_mcp_postgres_multiprocess.py", + "tests/test_action_multiprocess.py", + ): + assert token in text, f"tier guide no longer documents {token}" + + +def test_tier_guide_warns_about_unproven_levels(): + """A green tier 1 must not be quotable as live or money proof.""" + text = _text() + assert "does not prove" in text diff --git a/gateway/tests/test_trust_coverage_gate_scope.py b/gateway/tests/test_trust_coverage_gate_scope.py index 7f42196c..175b36a4 100644 --- a/gateway/tests/test_trust_coverage_gate_scope.py +++ b/gateway/tests/test_trust_coverage_gate_scope.py @@ -147,6 +147,21 @@ def test_gate_keeps_its_floor_over_the_listed_modules() -> None: assert floors and min(floors) >= 80, "trust coverage floor dropped below 80%" +def test_gate_preloads_sqlalchemy_through_pytest() -> None: + """The gate must import SQLAlchemy in the pytest process before coverage. + + Coverage resolves each ``--cov`` target at startup and then evicts that + temporary import. The eviction orphans the compiled SQLAlchemy cache-key + module's traversal symbols, so cache-key generation fails with + ``TypeError: 'InternalTraversal' object is not callable``. ``-p + sqlalchemy`` loads it before coverage starts, so the eviction never + touches it. This must run through PYTEST_CMD so a PYTEST override is + honored. + """ + text = GATE_SCRIPT.read_text() + assert '"${PYTEST_CMD[@]}" -p sqlalchemy' in text + + def test_scope_check_flags_an_unlisted_unexempted_module() -> None: """The check itself must catch a module that is neither listed nor exempt.""" diff --git a/gateway/tests/test_upstream_mcp.py b/gateway/tests/test_upstream_mcp.py index 14645d51..616a3367 100644 --- a/gateway/tests/test_upstream_mcp.py +++ b/gateway/tests/test_upstream_mcp.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import ipaddress from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone @@ -94,6 +95,7 @@ def __init__( ) self.initialize_error = initialize_error self.call_error = call_error + self._tool_output_schemas: dict[str, Any] = {} self.events: list[str] = [] self.call_arguments: dict[str, Any] | None = None self.call_meta: dict[str, Any] | None = None @@ -408,6 +410,103 @@ async def test_url_guard_allows_only_public_https_or_local_loopback_http() -> No ) +@pytest.mark.anyio +@pytest.mark.parametrize( + "url", + [ + "https://[::169.254.169.254]/mcp", + "https://[::10.0.0.1]/mcp", + "https://[::127.0.0.1]/mcp", + "https://[64:ff9b::169.254.169.254]/mcp", + "https://[64:ff9b::10.0.0.1]/mcp", + "https://[::ffff:0:169.254.169.254]/mcp", + "https://[::ffff:0:10.0.0.1]/mcp", + "https://[fec0::1]/mcp", + ], +) +async def test_upstream_rejects_literals_stdlib_calls_global(url: str) -> None: + """Same hole as the outbound guard: is_global is true for these literals.""" + configuration = _configuration(MCP_UPSTREAM_URL=url, ENVIRONMENT="production") + with pytest.raises(UpstreamMcpConfigurationError): + await validate_upstream_url(configuration, resolver=_global_resolver) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "address", + [ + "::169.254.169.254", + "64:ff9b::10.0.0.1", + "fec0::1", + "::10.0.0.1", + "::ffff:0:10.0.0.1", + ], +) +async def test_upstream_rejects_dns_to_address_stdlib_calls_global( + address: str, +) -> None: + async def resolve(_host: str, _port: int | None) -> Sequence[str]: + return ("93.184.216.34", address) + + configuration = _configuration( + MCP_UPSTREAM_URL="https://partner.example/mcp", + ENVIRONMENT="production", + ) + with pytest.raises(UpstreamMcpConfigurationError): + await validate_upstream_url(configuration, resolver=resolve) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "url", + [ + "https://2130706433/mcp", + "https://0x7f000001/mcp", + "https://0177.0.0.1/mcp", + "https://127.1/mcp", + "https://0/mcp", + ], +) +async def test_upstream_rejects_ambiguous_numeric_host(url: str) -> None: + """DNS returning a public address must not launder a non-canonical IPv4.""" + configuration = _configuration(MCP_UPSTREAM_URL=url, ENVIRONMENT="production") + with pytest.raises(UpstreamMcpConfigurationError) as exc_info: + await validate_upstream_url(configuration, resolver=_global_resolver) + assert ( + exc_info.value.detail + == "MCP_UPSTREAM_URL must not use an ambiguous numeric host" + ) + + +@pytest.mark.anyio +async def test_upstream_allows_public_nat64_embedding() -> None: + """A DNS64 address for a public IPv4 destination is not a private target.""" + + async def resolve(_host: str, _port: int | None) -> Sequence[str]: + return ("64:ff9b::8.8.8.8",) + + configuration = _configuration( + MCP_UPSTREAM_URL="https://partner.example/mcp", + ENVIRONMENT="production", + ) + resolved = await validate_upstream_url(configuration, resolver=resolve) + assert ipaddress.ip_address(resolved) == ipaddress.ip_address("64:ff9b::8.8.8.8") + literal = _configuration( + MCP_UPSTREAM_URL="https://[64:ff9b::8.8.8.8]/mcp", + ENVIRONMENT="production", + ) + pinned = await validate_upstream_url(literal, resolver=resolve) + assert ipaddress.ip_address(pinned) == ipaddress.ip_address("64:ff9b::8.8.8.8") + translated = _configuration( + MCP_UPSTREAM_URL="https://[::ffff:0:8.8.8.8]/mcp", + ENVIRONMENT="production", + ) + pinned_translated = await validate_upstream_url(translated, resolver=resolve) + assert ipaddress.ip_address(pinned_translated) == ipaddress.ip_address( + "::ffff:0:8.8.8.8" + ) + + @pytest.mark.anyio async def test_url_guard_rejects_dns_to_private_link_local_and_reserved() -> None: async def resolver(_host: str, _port: int | None) -> Sequence[str]: @@ -1014,6 +1113,64 @@ async def before_dispatch() -> None: assert result.size_bytes == len(result.canonical_json.encode("utf-8")) +@pytest.mark.anyio +async def test_schema_cache_must_exist_before_dispatch_claim() -> None: + session = FakeSession() + del session._tool_output_schemas + adapter = UpstreamMcpAdapter(_configuration()) + _mark_discovered(adapter) + _bind_session(adapter, session) + claimed = False + + async def before_dispatch() -> None: + nonlocal claimed + claimed = True + + with pytest.raises(UpstreamMcpPreDispatchError) as exc_info: + await adapter.call_tool( + {}, + invocation_id="inv", + idempotency_key="idem", + before_dispatch=before_dispatch, + ) + + assert exc_info.value.code == "upstream_client_schema_cache_unavailable" + assert claimed is False + assert session.events == ["initialize"] + + +@pytest.mark.anyio +async def test_discovered_output_schema_is_copied_before_dispatch_claim() -> None: + output_schema = { + "type": "object", + "properties": {"ok": {"type": "boolean"}}, + } + adapter = UpstreamMcpAdapter(_configuration()) + adapter._discovered_tool = DiscoveredUpstreamTool( + name="partner.write", + description="Partner write", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + session = FakeSession() + _bind_session(adapter, session) + + async def before_dispatch() -> None: + cached = session._tool_output_schemas["partner.write"] + assert cached == output_schema + assert cached is not output_schema + assert cached["properties"] is not output_schema["properties"] + session.events.append("before_dispatch") + + await adapter.call_tool( + {}, + invocation_id="inv-copy", + idempotency_key="idem-copy", + before_dispatch=before_dispatch, + ) + assert session.events == ["initialize", "before_dispatch", "call_tool"] + + @pytest.mark.anyio async def test_call_metrics_are_bounded_and_contain_no_partner_data() -> None: adapter = UpstreamMcpAdapter(_configuration()) diff --git a/gateway/tests/test_upstream_retry_cap_enforcement.py b/gateway/tests/test_upstream_retry_cap_enforcement.py index 44ade961..38bbbb01 100644 --- a/gateway/tests/test_upstream_retry_cap_enforcement.py +++ b/gateway/tests/test_upstream_retry_cap_enforcement.py @@ -24,6 +24,7 @@ from sqlalchemy.orm.attributes import set_committed_value from app.core.config import get_settings +from app.services.ownership import CallerIdentity from app.core.time import utc_now from app.db.database import get_session_factory from app.db.models import ( @@ -1221,14 +1222,24 @@ async def test_pre_dispatch_release_replays_a_lost_slot_cas( misses = _inject_one_lost_counter_cas(monkeypatch) permits = get_permit_service() - assert await permits.release_dispatch_budget_once(attempt.attempt_id) is True + assert ( + await permits.release_dispatch_budget_once( + attempt.attempt_id, caller=CallerIdentity.admin() + ) + is True + ) assert misses["count"] == 1 counts, spent = await _permit_slot_state(permit_id) assert counts == {tool_name: 0} assert spent == Decimal("0") # The guarded claim keeps the release once-only across replays too. - assert await permits.release_dispatch_budget_once(attempt.attempt_id) is False + assert ( + await permits.release_dispatch_budget_once( + attempt.attempt_id, caller=CallerIdentity.admin() + ) + is False + ) @pytest.mark.anyio diff --git a/gateway/tests/test_wallet_concurrency.py b/gateway/tests/test_wallet_concurrency.py index 4a83bd05..beaa5f4f 100644 --- a/gateway/tests/test_wallet_concurrency.py +++ b/gateway/tests/test_wallet_concurrency.py @@ -39,6 +39,7 @@ from sqlalchemy import delete as sa_delete, update as sa_update from app.db.database import get_engine, get_session_factory +from app.services.ownership import CallerIdentity from app.db.models import LedgerEntryModel, WalletModel from app.schemas.billing import WalletStatus from app.services.agent_money import ( @@ -146,6 +147,7 @@ async def test_delegation_refuses_a_parent_balance_that_moved_under_it( sponsor_wallet_id=sponsor_wallet, agent_id=f"agent-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("400"), + caller=CallerIdentity.admin(), ) _interfere_once( monkeypatch, @@ -159,6 +161,7 @@ async def test_delegation_refuses_a_parent_balance_that_moved_under_it( child_agent_id=f"child-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("200"), max_spend=Decimal("200"), + caller=CallerIdentity.admin(), ) assert "insufficient" in str(excinfo.value).lower() @@ -179,11 +182,13 @@ async def test_transfer_refuses_a_source_balance_that_moved_under_it( sponsor_wallet_id=sponsor_wallet, agent_id=f"src-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("300"), + caller=CallerIdentity.admin(), ) dest = await money.create_agent_wallet( sponsor_wallet_id=sponsor_wallet, agent_id=f"dst-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("100"), + caller=CallerIdentity.admin(), ) _interfere_once( monkeypatch, @@ -196,6 +201,7 @@ async def test_transfer_refuses_a_source_balance_that_moved_under_it( from_wallet_id=source.wallet_id, to_wallet_id=dest.wallet_id, amount=Decimal("200"), + caller=CallerIdentity.admin(), ) assert "insufficient" in str(excinfo.value).lower() @@ -226,6 +232,7 @@ async def test_sponsor_provisioning_refuses_a_balance_that_moved_under_it( sponsor_wallet_id=sponsor_wallet, agent_id=f"agent-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("500"), + caller=CallerIdentity.admin(), ) assert "insufficient" in str(excinfo.value).lower() @@ -262,6 +269,7 @@ async def test_delegation_refuses_a_parent_frozen_under_it( sponsor_wallet_id=sponsor_wallet, agent_id=f"agent-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("400"), + caller=CallerIdentity.admin(), ) _interfere_once( monkeypatch, @@ -275,6 +283,7 @@ async def test_delegation_refuses_a_parent_frozen_under_it( child_agent_id=f"child-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("200"), max_spend=Decimal("200"), + caller=CallerIdentity.admin(), ) assert "frozen" in str(excinfo.value).lower() @@ -314,11 +323,13 @@ async def test_transfer_reports_a_destination_that_vanished( sponsor_wallet_id=sponsor_wallet, agent_id=f"src-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("300"), + caller=CallerIdentity.admin(), ) dest = await money.create_agent_wallet( sponsor_wallet_id=sponsor_wallet, agent_id=f"dst-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("100"), + caller=CallerIdentity.admin(), ) _interfere_once( monkeypatch, @@ -331,6 +342,7 @@ async def test_transfer_reports_a_destination_that_vanished( from_wallet_id=source.wallet_id, to_wallet_id=dest.wallet_id, amount=Decimal("50"), + caller=CallerIdentity.admin(), ) assert dest.wallet_id in str(excinfo.value) @@ -373,6 +385,7 @@ async def test_zero_lifetime_cap_is_a_cap_not_an_absent_one( sponsor_wallet_id=sponsor_wallet, agent_id=f"agent-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("400"), + caller=CallerIdentity.admin(), ) await _set_child_cap(parent.wallet_id, Decimal("0")) @@ -382,6 +395,7 @@ async def test_zero_lifetime_cap_is_a_cap_not_an_absent_one( child_agent_id=f"child-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("10"), max_spend=Decimal("10"), + caller=CallerIdentity.admin(), ) assert "cap exceeded" in str(excinfo.value).lower() @@ -398,11 +412,13 @@ async def test_zero_lifetime_cap_refuses_a_transfer_as_a_cap( sponsor_wallet_id=sponsor_wallet, agent_id=f"src-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("300"), + caller=CallerIdentity.admin(), ) dest = await money.create_agent_wallet( sponsor_wallet_id=sponsor_wallet, agent_id=f"dst-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("100"), + caller=CallerIdentity.admin(), ) await _set_child_cap(source.wallet_id, Decimal("0")) @@ -411,6 +427,7 @@ async def test_zero_lifetime_cap_refuses_a_transfer_as_a_cap( from_wallet_id=source.wallet_id, to_wallet_id=dest.wallet_id, amount=Decimal("10"), + caller=CallerIdentity.admin(), ) assert "cap exceeded" in str(excinfo.value).lower() @@ -443,6 +460,7 @@ async def test_negative_provisioning_budget_is_refused( sponsor_wallet_id=sponsor_wallet, agent_id=f"agent-{uuid.uuid4().hex[:8]}", budget_credits=budget, + caller=CallerIdentity.admin(), ) assert await _balance(sponsor_wallet) == before @@ -451,6 +469,7 @@ async def test_negative_provisioning_budget_is_refused( sponsor_wallet_id=sponsor_wallet, agent_id=f"parent-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("200"), + caller=CallerIdentity.admin(), ) with pytest.raises(ValueError, match="negative"): await money.create_child_wallet( @@ -458,6 +477,7 @@ async def test_negative_provisioning_budget_is_refused( child_agent_id=f"child-{uuid.uuid4().hex[:8]}", budget_credits=budget, max_spend=Decimal("50"), + caller=CallerIdentity.admin(), ) assert await _balance(parent.wallet_id) == Decimal("200") @@ -480,6 +500,7 @@ async def test_zero_provisioning_budget_opens_an_empty_wallet( sponsor_wallet_id=sponsor_wallet, agent_id=f"agent-{uuid.uuid4().hex[:8]}", budget_credits=Decimal("0"), + caller=CallerIdentity.admin(), ) assert await _balance(agent.wallet_id) == Decimal("0") diff --git a/gateway/tests/test_wallet_ledger_integrity.py b/gateway/tests/test_wallet_ledger_integrity.py index 5f38d14c..9f50b88f 100644 --- a/gateway/tests/test_wallet_ledger_integrity.py +++ b/gateway/tests/test_wallet_ledger_integrity.py @@ -23,6 +23,7 @@ from httpx import ASGITransport, AsyncClient from app.db.database import get_session_factory +from app.services.ownership import CallerIdentity from sqlalchemy import select from app.db.models import LedgerEntryModel, WalletModel @@ -130,6 +131,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/concurrent-b", + caller=CallerIdentity.admin(), ) monkeypatch.setattr( @@ -143,6 +145,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/concurrent-a", + caller=CallerIdentity.admin(), ) assert state.get("fired"), "the interleave never ran — the test proved nothing" @@ -180,6 +183,7 @@ async def test_reclaiming_a_child_twice_cannot_mint_credits( budget_credits=Decimal("40"), max_spend=Decimal("40"), task_description="reclaim-integrity", + caller=CallerIdentity.admin(), ) child_id = child.wallet_id @@ -200,7 +204,9 @@ async def _concurrent_reclaim() -> None: raising=False, ) try: - state["second"] = await money.reclaim_child_wallet(child_id) + state["second"] = await money.reclaim_child_wallet( + child_id, caller=CallerIdentity.admin() + ) except Exception as exc: # a refusal is a correct outcome here state["second"] = exc @@ -211,7 +217,9 @@ async def _concurrent_reclaim() -> None: ) try: - state["first"] = await money.reclaim_child_wallet(child_id) + state["first"] = await money.reclaim_child_wallet( + child_id, caller=CallerIdentity.admin() + ) except Exception as exc: state["first"] = exc @@ -248,6 +256,7 @@ async def test_concurrent_charges_cannot_exceed_a_child_wallet_spend_cap( # Plenty of balance, but only enough cap for a single charge. max_spend=Decimal("2"), task_description="cap-race", + caller=CallerIdentity.admin(), ) child_id = child.wallet_id @@ -260,6 +269,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/cap-race-b", + caller=CallerIdentity.admin(), ) monkeypatch.setattr( @@ -273,6 +283,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/cap-race-a", + caller=CallerIdentity.admin(), ) assert state.get("fired"), "the interleave never ran — the test proved nothing" @@ -322,6 +333,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/velocity-b", + caller=CallerIdentity.admin(), ) monkeypatch.setattr( @@ -335,6 +347,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/velocity-a", + caller=CallerIdentity.admin(), ) assert state.get("fired"), "the interleave never ran — the test proved nothing" @@ -390,6 +403,7 @@ async def _freeze_the_wallet() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/freeze-race", + caller=CallerIdentity.admin(), ) assert state.get("fired"), "the interleave never ran — the test proved nothing" @@ -431,6 +445,7 @@ async def test_a_concurrent_charge_cannot_erase_a_refund_credit( service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/to-be-refunded", + caller=CallerIdentity.admin(), ) charged = _exact_amount(charge) opening = await _balance(wallet_id) @@ -446,6 +461,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/concurrent-during-refund", + caller=CallerIdentity.admin(), ) monkeypatch.setattr( @@ -458,6 +474,7 @@ async def _concurrent_charge() -> None: wallet_id=wallet_id, charge_entry_id=charge.entry_id, description="refund during a concurrent charge", + caller=CallerIdentity.admin(), ) assert state.get("fired"), "the interleave never ran — the test proved nothing" @@ -494,6 +511,7 @@ async def test_a_concurrent_charge_cannot_erase_a_reconciled_refund( service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/undelivered", + caller=CallerIdentity.admin(), ) charged = _exact_amount(charge) opening = await _balance(wallet_id) @@ -510,6 +528,7 @@ async def _concurrent_charge() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/concurrent-during-reconcile", + caller=CallerIdentity.admin(), ) async with real_factory() as session: @@ -615,6 +634,7 @@ async def _roll_the_period_and_freeze() -> None: service_category=ServiceCategory.AGENT_COMMS, units=Decimal("1"), request_path="/rollover-reversal", + caller=CallerIdentity.admin(), ) assert state.get("fired"), "the interleave never ran — the test proved nothing" diff --git a/gateway/tests/test_wallet_spendable_status.py b/gateway/tests/test_wallet_spendable_status.py index 3d2d32c3..6117df2f 100644 --- a/gateway/tests/test_wallet_spendable_status.py +++ b/gateway/tests/test_wallet_spendable_status.py @@ -6,6 +6,7 @@ from sqlalchemy import func, select, update from app.db.database import get_session_factory +from app.services.ownership import CallerIdentity from app.db.models import LedgerEntryModel, WalletModel from app.services.agent_money import get_agent_money @@ -16,9 +17,11 @@ async def funded_wallets(clean_database): sponsor = await money.create_sponsor_wallet( "Status boundary", "status@example.test", Decimal("100") ) - agent = await money.create_agent_wallet(sponsor.wallet_id, "source", Decimal("50")) + agent = await money.create_agent_wallet( + sponsor.wallet_id, "source", Decimal("50"), caller=CallerIdentity.admin() + ) receiver = await money.create_agent_wallet( - sponsor.wallet_id, "receiver", Decimal("10") + sponsor.wallet_id, "receiver", Decimal("10"), caller=CallerIdentity.admin() ) return sponsor.wallet_id, agent.wallet_id, receiver.wallet_id @@ -47,12 +50,20 @@ async def _snapshot(): async def _debit(operation, source_id, receiver_id): money = get_agent_money() if operation == "transfer": - return await money.transfer(source_id, receiver_id, Decimal("10")) + return await money.transfer( + source_id, receiver_id, Decimal("10"), caller=CallerIdentity.admin() + ) if operation == "agent": - return await money.create_agent_wallet(source_id, "new", Decimal("10")) + return await money.create_agent_wallet( + source_id, "new", Decimal("10"), caller=CallerIdentity.admin() + ) assert operation == "child" return await money.create_child_wallet( - source_id, "new-child", Decimal("10"), Decimal("10") + source_id, + "new-child", + Decimal("10"), + Decimal("10"), + caller=CallerIdentity.admin(), ) diff --git a/gateway/tests/test_wallet_status_enforcement.py b/gateway/tests/test_wallet_status_enforcement.py index 26314489..87bc6a82 100644 --- a/gateway/tests/test_wallet_status_enforcement.py +++ b/gateway/tests/test_wallet_status_enforcement.py @@ -16,6 +16,7 @@ from sqlalchemy import select from app.core.time import utc_now +from app.services.ownership import CallerIdentity from app.db.database import get_session_factory from app.db.models import WalletModel from app.main import app @@ -132,6 +133,7 @@ async def test_unknown_wallet_status_fails_closed_for_charge(client, clean_datab service_category=ServiceCategory.IOT_BRIDGE, units=Decimal("1"), request_path="/tests/operator-hold", + caller=CallerIdentity.admin(), ) assert isinstance(result, InsufficientFundsResponse) @@ -589,6 +591,7 @@ async def test_child_wallet_ttl_rejects_unsafe_values( budget_credits=10, max_spend=10, ttl_seconds=invalid_ttl, + caller=CallerIdentity.admin(), ) parent = await client.get(f"/v1/billing/wallets/{agent}", headers=BOOTSTRAP) diff --git a/gateway/tests/test_webauthn_dev_gate.py b/gateway/tests/test_webauthn_dev_gate.py new file mode 100644 index 00000000..d7d4aad0 --- /dev/null +++ b/gateway/tests/test_webauthn_dev_gate.py @@ -0,0 +1,78 @@ +"""Passkey provider is dev-only: the singleton needs an explicit opt-in. + +Registered credentials and authenticator replay counters live only in +process memory, so the provider is not a production login path. These tests +pin the gate: app code reaching get_webauthn_provider() without +ENABLE_PROOF_SURFACES=true gets a clear refusal, and opting in works. +""" + +from __future__ import annotations + +import pytest + +from app.core.config import get_settings +from app.services import webauthn_provider as provider_module +from app.services.webauthn_provider import WebAuthnProvider, get_webauthn_provider + + +@pytest.fixture +def _isolated_provider_singleton(monkeypatch): + """Point the singleton at None and restore settings/singleton after.""" + monkeypatch.setenv("ENABLE_PROOF_SURFACES", "false") + get_settings.cache_clear() + previous = provider_module._webauthn_provider + provider_module._webauthn_provider = None + try: + yield + finally: + provider_module._webauthn_provider = previous + monkeypatch.undo() + get_settings.cache_clear() + + +def test_provider_refuses_without_explicit_dev_flag(_isolated_provider_singleton): + with pytest.raises(RuntimeError, match="not a production login path") as exc_info: + get_webauthn_provider() + message = str(exc_info.value) + assert "process memory" in message + assert "ENABLE_PROOF_SURFACES=true" in message + + +def test_provider_available_with_explicit_dev_flag( + _isolated_provider_singleton, monkeypatch +): + monkeypatch.setenv("ENABLE_PROOF_SURFACES", "true") + get_settings.cache_clear() + + provider = get_webauthn_provider() + + assert isinstance(provider, WebAuthnProvider) + # Same opt-in boot reuses the singleton rather than forking state. + assert get_webauthn_provider() is provider + + +async def test_execute_action_skips_passkey_without_dev_flag( + _isolated_provider_singleton, +): + """Without the dev flag there is no passkey surface, so execution must + not crash building the gated provider, even for high-risk actions.""" + from app.schemas.awi import ( + AWIExecutionRequest, + AWISessionCreate, + AWIStandardAction, + ) + from app.services.awi_session import AWISessionManager + + manager = AWISessionManager() + session = await manager.create_session( + AWISessionCreate(target_url="https://example.com") + ) + result = await manager.execute_action( + AWIExecutionRequest( + session_id=session.session_id, + action=AWIStandardAction.CHECKOUT, + parameters={}, + ) + ) + + assert result.status != "passkey_required" diff --git a/gateway/tests/test_webhook_auth.py b/gateway/tests/test_webhook_auth.py new file mode 100644 index 00000000..2122f0ca --- /dev/null +++ b/gateway/tests/test_webhook_auth.py @@ -0,0 +1,238 @@ +"""Tests for the shared webhook signature helper (app.core.webhook_auth). + +Pattern 4 (unsigned webhooks): every inbound webhook in this gateway verifies +through one helper that fails closed with constant-time compares. These tests +pin that contract: no secret means refusal even for correctly signed traffic, +missing and bad signatures are denied alike, and the live Stripe receivers +answer 401 while changing nothing. +""" + +import hashlib +import hmac +import json +import time + +import pytest +from httpx import AsyncClient, ASGITransport + +from app.core.config import get_settings +from app.core.webhook_auth import ( + WebhookVerificationError, + require_webhook_secret, + secure_compare, + verify_stripe_event, +) +from app.main import app + + +@pytest.fixture(autouse=True) +async def _clean_shared_db(clean_database): + """Webhook tests post through the shared app; start from an empty DB.""" + yield + + +# The Stripe webhook receivers are dormant trust surfaces: production mounts +# them only with Stripe configured, so the suite mounts them for this module +# (same mechanism as test_kyc / test_stripe_integration). +pytestmark = pytest.mark.dormant + +SHARED_SECRET = "whsec_shared_helper_test" + + +def _sign(payload: bytes, secret: str) -> str: + """A Stripe-Signature header computed the way Stripe signs webhooks.""" + timestamp = int(time.time()) + signed = f"{timestamp}.".encode() + payload + digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() + return f"t={timestamp},v1={digest}" + + +def _event_payload() -> bytes: + return json.dumps({"id": "evt_helper_test", "object": "event"}).encode() + + +def test_require_webhook_secret_accepts_configured_secret(): + assert require_webhook_secret(SHARED_SECRET, name="TEST") == SHARED_SECRET + + +@pytest.mark.parametrize("secret", [None, "", " "]) +def test_require_webhook_secret_refuses_missing_secret(secret): + """No secret means the receiver cannot verify, so it must refuse.""" + with pytest.raises(WebhookVerificationError): + require_webhook_secret(secret, name="TEST") + + +def test_secure_compare_matches_constant_time_semantics(): + assert secure_compare("abc123", "abc123") is True + assert secure_compare("abc123", "abc124") is False + assert secure_compare("abc123", "short") is False + assert secure_compare(b"abc123", b"abc123") is True + assert secure_compare(b"abc123", b"abc124") is False + + +def test_verify_stripe_event_accepts_valid_signature(): + payload = _event_payload() + event = verify_stripe_event(payload, _sign(payload, SHARED_SECRET), SHARED_SECRET) + assert event["id"] == "evt_helper_test" + + +def test_verify_stripe_event_refuses_missing_secret(): + """Fail closed: a correctly signed event is still refused with no secret.""" + payload = _event_payload() + with pytest.raises(WebhookVerificationError): + verify_stripe_event(payload, _sign(payload, SHARED_SECRET), "") + + +@pytest.mark.parametrize("header", [None, "", " "]) +def test_verify_stripe_event_refuses_missing_header(header): + with pytest.raises(WebhookVerificationError): + verify_stripe_event(_event_payload(), header, SHARED_SECRET) + + +def test_verify_stripe_event_refuses_wrong_secret(): + payload = _event_payload() + with pytest.raises(WebhookVerificationError): + verify_stripe_event(payload, _sign(payload, "whsec_attacker"), SHARED_SECRET) + + +def test_verify_stripe_event_refuses_non_ascii_signature(): + payload = _event_payload() + header = _sign(payload, SHARED_SECRET).replace("v1=", "v1=é") + with pytest.raises(WebhookVerificationError): + verify_stripe_event(payload, header, SHARED_SECRET) + + +def test_verify_stripe_event_refuses_tampered_payload(): + signed = _sign(b'{"id":"evt_helper_test","object":"event"}', SHARED_SECRET) + with pytest.raises(WebhookVerificationError): + verify_stripe_event( + b'{"id":"evt_forged","object":"event"}', signed, SHARED_SECRET + ) + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def api_headers(): + return {"X-API-Key": "test-key"} + + +@pytest.fixture +async def sponsor_wallet(client, api_headers): + resp = await client.post( + "/v1/billing/wallets/sponsor", + json={ + "sponsor_name": "Webhook Helper Sponsor", + "email": "webhook-helper@test.com", + "initial_credits": 0, + }, + headers=api_headers, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +def _succeeded_top_up_payload(wallet_id: str) -> bytes: + return json.dumps( + { + "id": "evt_helper_top_up", + "object": "event", + "type": "payment_intent.succeeded", + "data": { + "object": { + "id": "pi_helper_top_up", + "status": "succeeded", + "amount": 5000, + "amount_received": 5000, + "currency": "usd", + "metadata": {"wallet_id": wallet_id, "credits": "50000"}, + } + }, + } + ).encode() + + +@pytest.mark.anyio +async def test_payment_webhook_denies_everything_when_secret_unconfigured( + client, monkeypatch +): + """With no secret configured, even well-formed traffic is refused with 401.""" + monkeypatch.setattr(get_settings(), "STRIPE_WEBHOOK_SECRET", "") + payload = b'{"id":"evt_x","object":"event"}' + + for headers in ( + {}, + {"stripe-signature": "anything"}, + {"stripe-signature": _sign(payload, SHARED_SECRET)}, + ): + resp = await client.post( + "/v1/webhooks/stripe", content=payload, headers=headers + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid Stripe signature" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "path", ["/v1/webhooks/stripe", "/v1/webhooks/stripe/identity"] +) +async def test_stripe_receivers_refuse_non_ascii_signature(client, monkeypatch, path): + monkeypatch.setattr(get_settings(), "STRIPE_WEBHOOK_SECRET", SHARED_SECRET) + resp = await client.post( + path, + content=_event_payload(), + headers={b"stripe-signature": b"t=1,v1=\xe9"}, + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid Stripe signature" + + +@pytest.mark.anyio +async def test_payment_webhook_denies_forged_and_accepts_valid( + client, api_headers, sponsor_wallet, monkeypatch +): + """End to end with real signatures: forged is 401 with no mint, valid mints.""" + monkeypatch.setattr(get_settings(), "STRIPE_WEBHOOK_SECRET", SHARED_SECRET) + wallet_id = sponsor_wallet["wallet_id"] + payload = _succeeded_top_up_payload(wallet_id) + + forged = await client.post( + "/v1/webhooks/stripe", + content=payload, + headers={"stripe-signature": _sign(payload, "whsec_attacker")}, + ) + assert forged.status_code == 401 + + wallet = await client.get(f"/v1/billing/wallets/{wallet_id}", headers=api_headers) + assert wallet.json()["balance"] == 0.0 + + valid = await client.post( + "/v1/webhooks/stripe", + content=payload, + headers={"stripe-signature": _sign(payload, SHARED_SECRET)}, + ) + assert valid.status_code == 200, valid.text + assert valid.json() == {"status": "received"} + + wallet = await client.get(f"/v1/billing/wallets/{wallet_id}", headers=api_headers) + assert wallet.json()["balance"] == 50000.0 + + +@pytest.mark.anyio +async def test_identity_webhook_denies_everything_when_secret_unconfigured( + client, monkeypatch +): + monkeypatch.setattr(get_settings(), "STRIPE_WEBHOOK_SECRET", "") + payload = b'{"id":"evt_y","object":"event"}' + resp = await client.post( + "/v1/webhooks/stripe/identity", + content=payload, + headers={"stripe-signature": _sign(payload, SHARED_SECRET)}, + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid Stripe signature" diff --git a/gateway/tests/test_x402.py b/gateway/tests/test_x402.py index e072b022..9ec30222 100644 --- a/gateway/tests/test_x402.py +++ b/gateway/tests/test_x402.py @@ -22,6 +22,7 @@ from httpx import ASGITransport, AsyncClient from app.main import app +from app.services.ownership import CallerIdentity from app.services.audit_log import list_audit_events from app.services.receipts import ( ReceiptService, @@ -1047,8 +1048,12 @@ async def counter() -> object: return counts.get("x402.payment") # Missing permit and absent counter are silent no-ops. - await permits.release_tool_call("permit-does-not-exist", "x402.payment") - await permits.release_tool_call(permit_id, "x402.payment") + await permits.release_tool_call( + "permit-does-not-exist", "x402.payment", caller=CallerIdentity.admin() + ) + await permits.release_tool_call( + permit_id, "x402.payment", caller=CallerIdentity.admin() + ) assert await counter() is None # Two concurrent-style reservations, one release: the surviving count is @@ -1063,13 +1068,19 @@ async def counter() -> object: ) assert validation.allowed, validation.reason assert await counter() == 2 - await permits.release_tool_call(permit_id, "x402.payment") + await permits.release_tool_call( + permit_id, "x402.payment", caller=CallerIdentity.admin() + ) assert await counter() == 1 # Clamp at zero: releasing past the floor stops at 0, never negative. - await permits.release_tool_call(permit_id, "x402.payment") + await permits.release_tool_call( + permit_id, "x402.payment", caller=CallerIdentity.admin() + ) assert await counter() == 0 - await permits.release_tool_call(permit_id, "x402.payment") + await permits.release_tool_call( + permit_id, "x402.payment", caller=CallerIdentity.admin() + ) assert await counter() == 0 # A malformed boolean counter is rejected, not coerced and decremented. @@ -1085,7 +1096,9 @@ async def counter() -> object: model.tool_call_counts_json = _json.dumps({"x402.payment": True}) session.add(model) await session.commit() - await permits.release_tool_call(permit_id, "x402.payment") + await permits.release_tool_call( + permit_id, "x402.payment", caller=CallerIdentity.admin() + ) assert await counter() is True @@ -1590,3 +1603,232 @@ async def failed_lookup(*args, **kwargs): idempotency_key="uncertain-receipt", ) assert retained.record_id == original.record_id + + +# --------------------------------------------------------------------------- +# Permit recipient binding on the x402 path +# --------------------------------------------------------------------------- + +OTHER_EVM_PAY_TO = "0x3333333333333333333333333333333333333333" + + +async def _create_recipient_permit( + client, + *, + wallet_id: str, + key_id: str | None, + recipient: str, + idem_key: str, +) -> dict: + """Mint an x402.payment permit bound to one recipient address.""" + resp = await client.post( + "/v1/permits", + json={ + "issuer_wallet_id": wallet_id, + "subject_wallet_id": wallet_id, + "subject_key_id": key_id, + "allowed_tools": ["x402.payment"], + "scopes": ["tool:x402.payment:invoke", "billing:charge"], + "max_credits": 100, + "recipient_domain": recipient, + "expires_at": ( + datetime.now(timezone.utc) + timedelta(minutes=30) + ).isoformat(), + }, + headers={**BOOTSTRAP_HEADERS, "Idempotency-Key": idem_key}, + ) + assert resp.status_code == 201 + return resp.json() + + +@pytest.mark.anyio +async def test_x402_settle_recipient_match_allowed(client, clean_database): + """A demand paying the permit's bound address settles normally.""" + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + permit = await _create_recipient_permit( + client, + wallet_id=wallet_id, + key_id=provisioned["key_id"], + recipient=EVM_PAY_TO, + idem_key="x402-recipient-allow-permit", + ) + resp = await client.post( + "/v1/x402/settle", + json=_settle_body( + permit_id=permit["permit_id"], + wallet_id=wallet_id, + amount="0.03", + pay_to=EVM_PAY_TO, + ), + headers={ + **provisioned["agent_headers"], + "Idempotency-Key": "x402-recipient-allow-1", + }, + ) + assert resp.status_code == 200 + assert resp.json()["receipt_id"].startswith("rcpt-") + assert await _permit_spent(client, permit["permit_id"]) == Decimal("30") + + +@pytest.mark.anyio +async def test_x402_settle_recipient_mismatch_denied_no_reserve(client, clean_database): + """A demand paying any other address is denied before budget moves. + + No reservation is held, no receipt exists, and the refused idempotency + key stays reusable for a corrected demand. + """ + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + permit = await _create_recipient_permit( + client, + wallet_id=wallet_id, + key_id=provisioned["key_id"], + recipient=EVM_PAY_TO, + idem_key="x402-recipient-deny-permit", + ) + permit_id = permit["permit_id"] + headers = { + **provisioned["agent_headers"], + "Idempotency-Key": "x402-recipient-deny-1", + } + + denied = await client.post( + "/v1/x402/settle", + json=_settle_body( + permit_id=permit_id, + wallet_id=wallet_id, + amount="0.03", + pay_to=OTHER_EVM_PAY_TO, + ), + headers=headers, + ) + assert denied.status_code == 400 + assert denied.json()["detail"] == "permit_recipient_domain_mismatch" + assert await _permit_spent(client, permit_id) == Decimal("0") + _, total = await get_receipt_service().list_receipts(permit_id=permit_id) + assert total == 0 + + # The refused key was abandoned, so the corrected demand reuses it. + ok = await client.post( + "/v1/x402/settle", + json=_settle_body( + permit_id=permit_id, + wallet_id=wallet_id, + amount="0.03", + pay_to=EVM_PAY_TO, + ), + headers=headers, + ) + assert ok.status_code == 200 + assert await _permit_spent(client, permit_id) == Decimal("30") + + +@pytest.mark.anyio +async def test_x402_settle_recipient_evm_checksum_case_allowed(client, clean_database): + """EVM casing is not identity: a checksum-case demand pays a lowercase binding.""" + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + # Letters make casing observable; EVM_PAY_TO is all digits. + hex_address = "abcdefabcdefabcdefabcdefabcdefabcdefabcd" + permit = await _create_recipient_permit( + client, + wallet_id=wallet_id, + key_id=provisioned["key_id"], + recipient="0x" + hex_address, + idem_key="x402-recipient-case-permit", + ) + checksummed = "0x" + "".join( + c.upper() if i % 2 else c for i, c in enumerate(hex_address) + ) + resp = await client.post( + "/v1/x402/settle", + json=_settle_body( + permit_id=permit["permit_id"], + wallet_id=wallet_id, + amount="0.03", + pay_to=checksummed, + ), + headers={ + **provisioned["agent_headers"], + "Idempotency-Key": "x402-recipient-case-1", + }, + ) + assert resp.status_code == 200 + + +@pytest.mark.anyio +async def test_x402_settle_foreign_permit_gives_no_recipient_oracle( + client, clean_database +): + """A foreign wallet gets permit_wallet_mismatch for any pay_to. + + If the recipient check ran before ownership, varying pay_to would + reveal the bound address through differing denial reasons. + """ + provisioned = await provision_agent_wallet(client) + wallet_id = provisioned["agent_wallet_id"] + permit = await _create_recipient_permit( + client, + wallet_id=wallet_id, + key_id=provisioned["key_id"], + recipient=EVM_PAY_TO, + idem_key="x402-recipient-oracle-permit", + ) + other = await provision_agent_wallet(client) + for i, pay_to in enumerate((EVM_PAY_TO, OTHER_EVM_PAY_TO)): + resp = await client.post( + "/v1/x402/settle", + json=_settle_body( + permit_id=permit["permit_id"], + wallet_id=other["agent_wallet_id"], + amount="0.03", + pay_to=pay_to, + ), + headers={ + **other["agent_headers"], + "Idempotency-Key": f"x402-recipient-oracle-{i}", + }, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "permit_wallet_mismatch" + + +def test_x402_settle_requires_idempotency_record_id(): + """Settle without a record id is a type error: no caller can skip dedupe. + + The permit id does not exist, so without the required argument the old + code would fall through to permit_not_found instead of raising here. + """ + handler = get_x402_handler() + requirement = handler.build_requirement( + amount="0.01", pay_to=EVM_PAY_TO, network="base" + ) + with pytest.raises(TypeError): + handler.settle( + permit_id="permit-does-not-exist", + wallet_id="wallet-does-not-exist", + key_id=None, + requirement=requirement, + idempotency_key="x402-no-record-1", + payer=EVM_PAYER, + ) + + +def test_recipient_binding_matches_addresses_and_hosts(): + """The shared comparison the MCP and x402 paths both enforce.""" + from app.services.permits import recipient_binding_matches + + assert recipient_binding_matches(None, EVM_PAY_TO) is True + assert recipient_binding_matches(EVM_PAY_TO, EVM_PAY_TO) is True + assert recipient_binding_matches(EVM_PAY_TO, OTHER_EVM_PAY_TO) is False + assert ( + recipient_binding_matches( + "partner.example", "https://partner.example/tools/call" + ) + is True + ) + assert ( + recipient_binding_matches("allowed.example.com", "https://other.example") + is False + ) diff --git a/gateway/typecheck/scopes_coroutine.py b/gateway/typecheck/scopes_coroutine.py new file mode 100644 index 00000000..9da6f964 --- /dev/null +++ b/gateway/typecheck/scopes_coroutine.py @@ -0,0 +1,19 @@ +"""Static contract for coroutine consumers of scoped async handlers.""" + +import asyncio + +from app.core.auth import AuthContext +from app.core.scopes import require_scope + + +@require_scope("billing:charge") +async def _handler(auth: AuthContext, count: int) -> int: + return count + 1 + + +async def create_scoped_task(auth: AuthContext) -> asyncio.Task[int]: + return asyncio.create_task(_handler(auth=auth, count=1)) + + +def run_scoped(auth: AuthContext) -> int: + return asyncio.run(_handler(auth=auth, count=1)) diff --git a/integrations/crewai-agent-middleware/src/crewai_b2a/tool.py b/integrations/crewai-agent-middleware/src/crewai_b2a/tool.py index e1d263ee..fd8ac95c 100644 --- a/integrations/crewai-agent-middleware/src/crewai_b2a/tool.py +++ b/integrations/crewai-agent-middleware/src/crewai_b2a/tool.py @@ -1,27 +1,94 @@ """CrewAI Tool for Agent Middleware API.""" +import asyncio from datetime import UTC, datetime, timedelta from decimal import Decimal from typing import Any from b2a_sdk.models import PermitRequest from crewai.tools import BaseTool -from pydantic import BaseModel +from pydantic import BaseModel, Field, PrivateAttr from .client import B2AClient - -class MCPToolSchema(BaseModel): - """Schema for MCP tool input.""" - - tool_name: str - idempotency_key: str - permit_idempotency_key: str - arguments: dict[str, Any] = {} - - -class WalletBalanceSchema(BaseModel): - """Schema for wallet balance check.""" +_MAX_KEY_LENGTH = 128 + + +def _required_text(value: object, name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} is required and must not be blank") + return value.strip() + + +def _idempotency_key(value: object, name: str) -> str: + key = _required_text(value, name) + if len(key) > _MAX_KEY_LENGTH: + raise ValueError(f"{name} must be at most 128 characters") + return key + + +def _tool_arguments(value: object) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, dict): + raise TypeError("arguments must be an object") + return value + + +def _receipt_id_from_payload(payload: object) -> str | None: + if not isinstance(payload, dict): + return None + containers = [payload.get("result")] + error = payload.get("error") + if isinstance(error, dict): + containers.append(error.get("data")) + for container in containers: + if not isinstance(container, dict): + continue + receipt = container.get("receipt") + if not isinstance(receipt, dict): + continue + receipt_id = receipt.get("receipt_id") + if isinstance(receipt_id, str) and receipt_id: + return receipt_id + return None + + +def _error_text(exc: Exception) -> str: + receipt_id = getattr(exc, "receipt_id", None) + if not isinstance(receipt_id, str) or not receipt_id: + receipt_id = _receipt_id_from_payload(getattr(exc, "payload", None)) + if receipt_id: + return f"Error: {exc} receipt_id={receipt_id}" + return f"Error: {exc}" + + +class CrewAIB2AToolInput(BaseModel): + """Arguments a CrewAI agent can pass into this tool.""" + + operation: str = Field(description="One of discover_tools, call_tool, or balance.") + tool_name: str = Field( + default="", + description="MCP tool name. Required for call_tool.", + ) + idempotency_key: str = Field( + default="", + description=( + "Caller-owned invoke key. Required for call_tool. " + "Reuse it only for the same invocation." + ), + ) + permit_idempotency_key: str = Field( + default="", + description=( + "Caller-owned permit key. Required for call_tool. " + "Reuse it to replay the same permit." + ), + ) + arguments: dict[str, Any] = Field( + default_factory=dict, + description="Arguments passed to the MCP tool.", + ) class CrewAIB2ATool(BaseTool): @@ -33,17 +100,22 @@ class CrewAIB2ATool(BaseTool): "Use this to call billable services and manage agent billing. " "All invocations return signed receipts." ) + args_schema: type[BaseModel] = CrewAIB2AToolInput client: B2AClient | None = None base_url: str = "https://api.thisisatest.tech" - api_key: str + api_key: str = Field(repr=False, exclude=True) wallet_id: str permit_budget: Decimal = Decimal(100) permit_ttl_minutes: int = 30 - # Cache permits to avoid 409 on replay (server hashes full permit body including expires_at) - # permit_idempotency_key -> permit_id - _permit_cache: dict[str, str] = {} # noqa: RUF012 - _permit_requests: dict[str, PermitRequest] = {} # noqa: RUF012 + + # One dict per instance. A shared class dict would let one wallet reuse + # another wallet's permit when both agents pick the same permit key. + _permit_cache: dict[str, str] = PrivateAttr(default_factory=dict) + _permit_requests: dict[str, PermitRequest] = PrivateAttr(default_factory=dict) + _permit_ttls: dict[str, int] = PrivateAttr(default_factory=dict) + _sync_loop: asyncio.AbstractEventLoop | None = PrivateAttr(default=None) + _owns_client: bool = PrivateAttr(default=False) def __init__( self, @@ -52,20 +124,23 @@ def __init__( base_url: str = "https://api.thisisatest.tech", permit_budget: Decimal = Decimal(100), permit_ttl_minutes: int = 30, - **kwargs, + **kwargs: Any, ): # BaseTool is a pydantic model: required fields must reach its # validator, so pass them through instead of assigning afterwards. super().__init__( - api_key=api_key, - wallet_id=wallet_id, - base_url=base_url, + api_key=_required_text(api_key, "api_key"), + wallet_id=_required_text(wallet_id, "wallet_id"), + base_url=_required_text(base_url, "base_url"), permit_budget=permit_budget, permit_ttl_minutes=permit_ttl_minutes, **kwargs, ) - self._permit_cache = {} # Instance-specific cache + self._permit_cache = {} self._permit_requests = {} + self._permit_ttls = {} + self._sync_loop = None + self._owns_client = False def _get_client(self) -> B2AClient: if self.client is None: @@ -73,11 +148,13 @@ def _get_client(self) -> B2AClient: api_key=self.api_key, base_url=self.base_url, ) + self._owns_client = True return self.client def _permit_request(self, tool_name: str, permit_key: str) -> PermitRequest: # Both sync and async entry points retain the body before sending it. # A lost response therefore cannot change the expiry on retry. + expected_scopes = [f"tool:{tool_name}:invoke", "billing:charge"] request = self._permit_requests.get(permit_key) if request is None: request = PermitRequest( @@ -87,135 +164,87 @@ def _permit_request(self, tool_name: str, permit_key: str) -> PermitRequest: expires_at=datetime.now(UTC) + timedelta(minutes=self.permit_ttl_minutes), allowed_tools=[tool_name], - scopes=[f"tool:{tool_name}:invoke", "billing:charge"], + scopes=expected_scopes, ) self._permit_requests[permit_key] = request - elif ( - request.subject_wallet_id != self.wallet_id + self._permit_ttls[permit_key] = self.permit_ttl_minutes + return request + if ( + request.issuer_wallet_id != self.wallet_id + or request.subject_wallet_id != self.wallet_id or request.max_credits != self.permit_budget or request.allowed_tools != [tool_name] + or list(request.scopes) != expected_scopes + or self._permit_ttls.get(permit_key) != self.permit_ttl_minutes ): raise ValueError( "permit_idempotency_key reused with different permit terms" ) return request - def _run( - self, - operation: str, - **kwargs, - ) -> str: - """Synchronous operation (for CrewAI compatibility). - - Args: - operation: One of 'discover_tools', 'call_tool', 'balance' - **kwargs: Operation-specific arguments - """ - import asyncio - - client = self._get_client() - + def _drive(self, coro: Any) -> Any: + # CrewAI calls this from a normal thread, where Python 3.12 and later + # have no current event loop. asyncio.run() would also close the loop + # and the HTTP client would fail on the next call. Keep one loop. try: - if operation == "discover_tools": - tools = asyncio.get_event_loop().run_until_complete( - client.discover_tools() - ) - return str( - [{"name": t.name, "description": t.description} for t in tools] - ) - - elif operation == "call_tool": - tool_name = kwargs.get("tool_name") - idempotency_key = kwargs.get("idempotency_key") - permit_idempotency_key = kwargs.get("permit_idempotency_key") - arguments = kwargs.get("arguments", {}) - - if not idempotency_key or not idempotency_key.strip(): - return "Error: idempotency_key is required and must not be blank" - - if not permit_idempotency_key or not permit_idempotency_key.strip(): - return "Error: permit_idempotency_key is required and must not be blank" - - request = self._permit_request(tool_name, permit_idempotency_key) - if permit_idempotency_key in self._permit_cache: - permit_id = self._permit_cache[permit_idempotency_key] - else: - permit = asyncio.get_event_loop().run_until_complete( - client.create_permit( - request, idempotency_key=permit_idempotency_key - ) - ) - permit_id = permit.permit_id - self._permit_cache[permit_idempotency_key] = permit_id - - result = asyncio.get_event_loop().run_until_complete( - client.invoke_tool( - tool_name, - arguments, - wallet_id=self.wallet_id, - permit_id=permit_id, - idempotency_key=idempotency_key, - ) - ) - - return str( - { - "content": result.content, - "structured_content": result.structured_content, - "receipt_id": result.receipt.receipt_id, - "credits_charged": str(result.receipt.credits_charged), - "signature": result.receipt.signature, - } - ) - - elif operation == "balance": - balance = asyncio.get_event_loop().run_until_complete( - client.get_balance(self.wallet_id) - ) - return f"Balance: {balance} credits" - - else: - return f"Unknown operation: {operation}" - - except Exception as e: # noqa: BLE001 - return f"Error: {e!s}" - - async def _arun( - self, - operation: str, - **kwargs, - ) -> str: - """Asynchronous operation (preferred).""" - client = self._get_client() - + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError( + "synchronous run was called while an event loop is running; " + "use the async tool path" + ) + loop = self._sync_loop + if loop is None or loop.is_closed(): + loop = asyncio.new_event_loop() + self._sync_loop = loop + return loop.run_until_complete(coro) + + def close(self) -> None: + """Close the sync loop this tool opened. Safe to call more than once.""" + loop = self._sync_loop + client = self.client if self._owns_client else None + self._sync_loop = None + if loop is None or loop.is_closed(): + return + if client is not None: + try: + loop.run_until_complete(client.close()) + except Exception: # noqa: BLE001, S110 + # Cleanup must not hide the call that already finished. + pass + self.client = None + loop.close() + + async def _execute(self, operation: str, **kwargs: Any) -> str: try: + client = self._get_client() if operation == "discover_tools": tools = await client.discover_tools() return str( [{"name": t.name, "description": t.description} for t in tools] ) - elif operation == "call_tool": - tool_name = kwargs.get("tool_name") - idempotency_key = kwargs.get("idempotency_key") - permit_idempotency_key = kwargs.get("permit_idempotency_key") - arguments = kwargs.get("arguments", {}) - - if not idempotency_key or not idempotency_key.strip(): - return "Error: idempotency_key is required and must not be blank" - - if not permit_idempotency_key or not permit_idempotency_key.strip(): - return "Error: permit_idempotency_key is required and must not be blank" - - request = self._permit_request(tool_name, permit_idempotency_key) - if permit_idempotency_key in self._permit_cache: - permit_id = self._permit_cache[permit_idempotency_key] + if operation == "call_tool": + tool_name = _required_text(kwargs.get("tool_name"), "tool_name") + idempotency_key = _idempotency_key( + kwargs.get("idempotency_key"), "idempotency_key" + ) + permit_key = _idempotency_key( + kwargs.get("permit_idempotency_key"), + "permit_idempotency_key", + ) + arguments = _tool_arguments(kwargs.get("arguments", {})) + request = self._permit_request(tool_name, permit_key) + if permit_key in self._permit_cache: + permit_id = self._permit_cache[permit_key] else: permit = await client.create_permit( - request, idempotency_key=permit_idempotency_key + request, idempotency_key=permit_key ) permit_id = permit.permit_id - self._permit_cache[permit_idempotency_key] = permit_id + self._permit_cache[permit_key] = permit_id result = await client.invoke_tool( tool_name, @@ -224,7 +253,6 @@ async def _arun( permit_id=permit_id, idempotency_key=idempotency_key, ) - return str( { "content": result.content, @@ -235,12 +263,58 @@ async def _arun( } ) - elif operation == "balance": + if operation == "balance": balance = await client.get_balance(self.wallet_id) return f"Balance: {balance} credits" - else: - return f"Unknown operation: {operation}" + return f"Unknown operation: {operation}" + except Exception as exc: # noqa: BLE001 + # CrewAI agents read a string. Any failure here becomes that string. + return _error_text(exc) + + def _run( + self, + operation: str, + tool_name: str = "", + idempotency_key: str = "", + permit_idempotency_key: str = "", + arguments: dict[str, Any] | None = None, + **kwargs: Any, + ) -> str: + """Synchronous operation (for CrewAI compatibility).""" + coro = self._execute( + operation, + tool_name=tool_name, + idempotency_key=idempotency_key, + permit_idempotency_key=permit_idempotency_key, + arguments=arguments, + **kwargs, + ) + try: + return self._drive(coro) + except Exception as exc: # noqa: BLE001 + # The coroutine was not scheduled, or it already finished. + try: + coro.close() + except RuntimeError: + pass + return _error_text(exc) - except Exception as e: # noqa: BLE001 - return f"Error: {e!s}" + async def _arun( + self, + operation: str, + tool_name: str = "", + idempotency_key: str = "", + permit_idempotency_key: str = "", + arguments: dict[str, Any] | None = None, + **kwargs: Any, + ) -> str: + """Asynchronous operation (preferred).""" + return await self._execute( + operation, + tool_name=tool_name, + idempotency_key=idempotency_key, + permit_idempotency_key=permit_idempotency_key, + arguments=arguments, + **kwargs, + ) diff --git a/integrations/crewai-agent-middleware/tests/test_boundaries.py b/integrations/crewai-agent-middleware/tests/test_boundaries.py new file mode 100644 index 00000000..ea3497b5 --- /dev/null +++ b/integrations/crewai-agent-middleware/tests/test_boundaries.py @@ -0,0 +1,682 @@ +"""Edge cases for the CrewAI governed tool: sync calls, auth, and permit terms.""" + +import asyncio +import json +import threading +from datetime import UTC, datetime +from decimal import Decimal + +import httpx +import pytest + +from crewai_b2a import B2AClient, CrewAIB2ATool + + +def _permit_payload() -> dict: + return { + "permit_id": "permit-1", + "issuer_wallet_id": "wallet-1", + "subject_wallet_id": "wallet-1", + "subject_key_id": "key-1", + "scopes": ["tool:partner.search:invoke", "billing:charge"], + "allowed_tools": ["partner.search"], + "max_credits": "100", + "spent_credits": "0", + "expires_at": datetime.now(UTC).isoformat(), + "nonce": "nonce-1", + "status": "active", + "signature": "sig-permit-1", + "key_id": "key-1", + "issued_at": datetime.now(UTC).isoformat(), + "revoked_at": None, + } + + +def _receipt_payload(outcome: str = "success") -> dict: + return { + "receipt_id": f"receipt-{outcome}", + "permit_id": "permit-1", + "wallet_id": "wallet-1", + "key_id": "key-1", + "tool": "partner.search", + "request_hash": "request-hash", + "response_hash": "response-hash", + "ledger_entry_id": "ledger-1", + "dispatch_attempt_id": "dispatch-1", + "credits_authorized": "2", + "credits_charged": "2", + "outcome": outcome, + "audit_event_id": "audit-1", + "created_at": datetime.now(UTC).isoformat(), + "signature": f"sig-{outcome}", + "signature_key_id": "signing-key-1", + } + + +def _invoke_response(outcome: str = "success") -> dict: + return { + "jsonrpc": "2.0", + "id": "invoke", + "result": { + "content": [{"type": "text", "text": '{"ok": true}'}], + "structuredContent": {"ok": True}, + "isError": False, + "receipt": _receipt_payload(outcome), + }, + } + + +def _tool(transport: httpx.MockTransport, **kwargs) -> tuple[CrewAIB2ATool, B2AClient]: + api_key = kwargs.pop("api_key", "test-key") + wallet_id = kwargs.pop("wallet_id", "wallet-1") + tool = CrewAIB2ATool( + api_key=api_key, + wallet_id=wallet_id, + base_url="https://middleware.test", + **kwargs, + ) + client = B2AClient( + api_key=api_key, + base_url="https://middleware.test", + transport=transport, + ) + tool.client = client + return tool, client + + +def _shutdown(tool: CrewAIB2ATool, client: B2AClient) -> None: + """Close the HTTP client on the loop that used it, then stop that loop.""" + loop = getattr(tool, "_sync_loop", None) + if loop is not None and not loop.is_closed(): + try: + loop.run_until_complete(client.close()) + except RuntimeError: + pass + closer = getattr(tool, "close", None) + if callable(closer): + closer() + return + try: + asyncio.run(client.close()) + except RuntimeError: + pass + + +def test_api_key_is_not_visible_in_repr_or_dump(): + tool = CrewAIB2ATool(api_key="super-secret-key", wallet_id="wallet-1") + assert "super-secret-key" not in repr(tool) + assert "super-secret-key" not in str(tool.model_dump()) + copied = tool.model_copy() + assert copied.api_key == "super-secret-key" + assert "super-secret-key" not in repr(copied) + + +def test_blank_api_key_and_wallet_are_rejected(): + with pytest.raises(ValueError, match="api_key"): + CrewAIB2ATool(api_key=" ", wallet_id="wallet-1") + with pytest.raises(ValueError, match="wallet_id"): + CrewAIB2ATool(api_key="test-key", wallet_id="") + + +def test_agent_schema_includes_call_fields(): + tool = CrewAIB2ATool(api_key="test-key", wallet_id="wallet-1") + assert tool.args_schema is not None + fields = set(tool.args_schema.model_fields) + assert { + "operation", + "tool_name", + "idempotency_key", + "permit_idempotency_key", + "arguments", + } <= fields + + +def test_sync_run_calls_the_api_twice_without_a_running_loop(): + """CrewAI calls run() from a worker thread. It must work twice on one client.""" + calls: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + if request.url.path == "/v1/permits": + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + return httpx.Response(200, json=_invoke_response()) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + call = { + "operation": "call_tool", + "tool_name": "partner.search", + "idempotency_key": "invoke-sync", + "permit_idempotency_key": "permit-sync", + "arguments": {"query": "same"}, + } + box: dict[str, str] = {} + + def work() -> None: + try: + box["first"] = tool.run(**call) + box["second"] = tool.run(**call) + finally: + _shutdown(tool, client) + + thread = threading.Thread(target=work) + thread.start() + thread.join() + + first = box["first"] + second = box["second"] + assert "receipt-success" in first + assert first == second + assert calls.count("/v1/permits") == 1 + assert calls.count("/mcp/messages") == 2 + + +@pytest.mark.asyncio +async def test_missing_tool_name_does_not_create_a_permit(): + seen: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.path) + return httpx.Response(500, json={"detail": "should not be called"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + missing = await tool._arun( + operation="call_tool", + idempotency_key="invoke-1", + permit_idempotency_key="permit-1", + ) + blank = await tool._arun( + operation="call_tool", + tool_name=" ", + idempotency_key="invoke-1", + permit_idempotency_key="permit-1", + ) + finally: + await client.close() + + assert seen == [] + assert missing == "Error: tool_name is required and must not be blank" + assert blank == "Error: tool_name is required and must not be blank" + + +@pytest.mark.asyncio +async def test_overlong_invoke_key_does_not_create_a_permit(): + seen: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.path) + return httpx.Response(201, json=_permit_payload()) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + result = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="k" * 129, + permit_idempotency_key="permit-1", + arguments={}, + ) + finally: + await client.close() + + assert seen == [] + assert "128" in result + assert result.startswith("Error:") + + +@pytest.mark.asyncio +async def test_non_object_arguments_do_not_create_a_permit(): + seen: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.path) + if request.url.path == "/v1/permits": + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + body = json.loads(request.content) + if body["params"]["arguments"] != {}: + return httpx.Response( + 400, json={"detail": "arguments must be an object"} + ) + return httpx.Response(200, json=_invoke_response()) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + result = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-1", + permit_idempotency_key="permit-1", + arguments=["not", "a", "dict"], + ) + none_result = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-2", + permit_idempotency_key="permit-2", + arguments=None, + ) + finally: + await client.close() + + assert result == "Error: arguments must be an object" + assert seen == ["/v1/permits", "/mcp/messages"] + assert "receipt-success" in none_result + + +@pytest.mark.asyncio +async def test_shorter_permit_ttl_is_not_silently_ignored(): + created = {"n": 0} + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + created["n"] += 1 + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + return httpx.Response(200, json=_invoke_response()) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler), permit_ttl_minutes=30) + try: + first = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-1", + permit_idempotency_key="permit-ttl", + arguments={}, + ) + tool.permit_ttl_minutes = 5 + second = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-2", + permit_idempotency_key="permit-ttl", + arguments={}, + ) + finally: + await client.close() + + assert "receipt-success" in first + assert second == "Error: permit_idempotency_key reused with different permit terms" + assert created["n"] == 1 + + +@pytest.mark.asyncio +async def test_delivery_uncertain_keeps_the_receipt_id(): + uncertain = _receipt_payload("delivery_uncertain") + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": "invoke-uncertain", + "error": { + "code": -32603, + "message": "delivery_uncertain", + "data": {"receipt": uncertain}, + }, + }, + ) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + result = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-uncertain", + permit_idempotency_key="permit-uncertain", + arguments={}, + ) + finally: + await client.close() + + assert result.startswith("Error:") + assert "delivery_uncertain" in result + assert "receipt-delivery_uncertain" in result + + +@pytest.mark.asyncio +async def test_auth_failure_does_not_invoke_or_stick(): + permits = {"n": 0} + invokes = {"n": 0} + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + permits["n"] += 1 + if permits["n"] == 1: + return httpx.Response(401, json={"detail": "invalid api key"}) + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + invokes["n"] += 1 + return httpx.Response(200, json=_invoke_response()) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + denied = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-auth", + permit_idempotency_key="permit-auth", + arguments={}, + ) + retried = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-auth", + permit_idempotency_key="permit-auth", + arguments={}, + ) + finally: + await client.close() + + assert denied == "Error: invalid api key" + assert invokes["n"] == 1 + assert permits["n"] == 2 + assert "receipt-success" in retried + + +@pytest.mark.asyncio +async def test_two_wallets_do_not_share_a_permit(): + wallets: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + body = json.loads(request.content) + wallets.append(body["subject_wallet_id"]) + payload = _permit_payload() + payload["issuer_wallet_id"] = body["issuer_wallet_id"] + payload["subject_wallet_id"] = body["subject_wallet_id"] + payload["allowed_tools"] = body["allowed_tools"] + return httpx.Response(201, json=payload) + if request.url.path == "/mcp/messages": + return httpx.Response(200, json=_invoke_response()) + if request.url.path == "/mcp/tools.json": + return httpx.Response( + 200, + json={"tools": [{"name": "partner.search", "description": "Search"}]}, + ) + if request.url.path.startswith("/v1/billing/wallets/"): + return httpx.Response(200, json={"balance": 9}) + return httpx.Response(404, json={"detail": "missing"}) + + transport = httpx.MockTransport(handler) + tool_a, client_a = _tool(transport, api_key="key-a", wallet_id="wallet-a") + tool_b, client_b = _tool(transport, api_key="key-b", wallet_id="wallet-b") + try: + result_a = await tool_a._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-a", + permit_idempotency_key="shared-permit-key", + arguments={"query": "a"}, + ) + result_b = await tool_b._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-b", + permit_idempotency_key="shared-permit-key", + arguments={"query": "b"}, + ) + balance = await tool_b._arun(operation="balance") + tools = await tool_a._arun(operation="discover_tools") + finally: + await client_a.close() + await client_b.close() + + assert wallets == ["wallet-a", "wallet-b"] + assert "receipt-success" in result_a + assert "receipt-success" in result_b + assert balance == "Balance: 9 credits" + assert "partner.search" in tools + + +@pytest.mark.asyncio +async def test_balance_and_discovery_use_this_tools_identity(monkeypatch): + captured: dict[str, str] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + captured["key"] = request.headers["x-api-key"] + captured["path"] = request.url.path + if request.url.path == "/mcp/tools.json": + return httpx.Response( + 200, + json={"tools": [{"name": "partner.search", "description": "Search"}]}, + ) + return httpx.Response(200, json={"balance": 4}) + + transport = httpx.MockTransport(handler) + real_client = B2AClient + + class _CaptureClient(real_client): + def __init__( + self, api_key: str, base_url: str = "https://api.thisisatest.tech", **kwargs + ): + captured["constructed_key"] = api_key + captured["constructed_url"] = base_url + super().__init__( + api_key=api_key, + base_url=base_url, + transport=transport, + **kwargs, + ) + + monkeypatch.setattr("crewai_b2a.tool.B2AClient", _CaptureClient) + tool = CrewAIB2ATool( + api_key="key-from-tool", + wallet_id="wallet-from-tool", + base_url="https://middleware.test", + ) + try: + balance = await tool._arun(operation="balance") + finally: + assert tool.client is not None + await tool.client.close() + + assert balance == "Balance: 4 credits" + assert captured["constructed_key"] == "key-from-tool" + assert captured["constructed_url"] == "https://middleware.test" + assert captured["key"] == "key-from-tool" + assert captured["path"] == "/v1/billing/wallets/wallet-from-tool" + + +@pytest.mark.asyncio +async def test_permit_key_reuse_rejects_changed_terms_without_a_second_invoke(): + invokes = {"n": 0} + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + invokes["n"] += 1 + return httpx.Response(200, json=_invoke_response()) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + first = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-1", + permit_idempotency_key="permit-terms", + arguments={"query": "one"}, + ) + changed_tool = await tool._arun( + operation="call_tool", + tool_name="other.tool", + idempotency_key="invoke-2", + permit_idempotency_key="permit-terms", + arguments={}, + ) + tool.permit_budget = Decimal(5) + changed_budget = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-3", + permit_idempotency_key="permit-terms", + arguments={}, + ) + tool.permit_budget = Decimal(100) + tool.wallet_id = "wallet-2" + changed_wallet = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-4", + permit_idempotency_key="permit-terms", + arguments={}, + ) + unknown = await tool._arun(operation="nope") + finally: + await client.close() + + assert "receipt-success" in first + assert ( + changed_tool + == "Error: permit_idempotency_key reused with different permit terms" + ) + assert ( + changed_budget + == "Error: permit_idempotency_key reused with different permit terms" + ) + assert ( + changed_wallet + == "Error: permit_idempotency_key reused with different permit terms" + ) + assert unknown == "Unknown operation: nope" + assert invokes["n"] == 1 + + +@pytest.mark.asyncio +async def test_failed_permit_retry_keeps_the_original_expiry(): + expiries: list[str] = [] + attempts = {"n": 0} + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + attempts["n"] += 1 + expiries.append(json.loads(request.content)["expires_at"]) + if attempts["n"] == 1: + return httpx.Response(503, json={"detail": "unavailable"}) + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + return httpx.Response(200, json=_invoke_response()) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + failed = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-retry", + permit_idempotency_key="permit-retry", + arguments={"query": "same"}, + ) + retried = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-retry", + permit_idempotency_key="permit-retry", + arguments={"query": "same"}, + ) + finally: + await client.close() + + assert failed == "Error: unavailable" + assert expiries[0] == expiries[1] + assert "receipt-success" in retried + + +@pytest.mark.asyncio +async def test_tool_error_receipt_is_not_dropped(): + receipt = _receipt_payload("error") + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + return httpx.Response(201, json=_permit_payload()) + if request.url.path == "/mcp/messages": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": "invoke-err", + "result": { + "content": [{"type": "text", "text": "nope"}], + "isError": True, + "error": "upstream failed", + "receipt": receipt, + }, + }, + ) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + result = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-err", + permit_idempotency_key="permit-err", + arguments={}, + ) + finally: + await client.close() + + assert result.startswith("Error:") + assert "receipt-error" in result + + +@pytest.mark.asyncio +async def test_copied_tool_does_not_spend_the_original_permit_on_another_wallet(): + invokes: list[tuple[str, str]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + body = json.loads(request.content) + payload = _permit_payload() + payload["permit_id"] = f"permit-{body['subject_wallet_id']}" + payload["issuer_wallet_id"] = body["issuer_wallet_id"] + payload["subject_wallet_id"] = body["subject_wallet_id"] + payload["allowed_tools"] = body["allowed_tools"] + return httpx.Response(201, json=payload) + if request.url.path == "/mcp/messages": + body = json.loads(request.content) + context = body["params"]["mcpContext"] + invokes.append((context["wallet_id"], context["permit_id"])) + return httpx.Response(200, json=_invoke_response()) + return httpx.Response(404, json={"detail": "missing"}) + + tool, client = _tool(httpx.MockTransport(handler)) + try: + original = await tool._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-original", + permit_idempotency_key="permit-copy", + arguments={}, + ) + copied = tool.model_copy() + copied.wallet_id = "wallet-2" + moved = await copied._arun( + operation="call_tool", + tool_name="partner.search", + idempotency_key="invoke-copy", + permit_idempotency_key="permit-copy", + arguments={}, + ) + finally: + await client.close() + + assert "receipt-success" in original + assert ("wallet-2", "permit-wallet-1") not in invokes + assert ( + "permit_idempotency_key reused" in moved + or ("wallet-2", "permit-wallet-2") in invokes + ) diff --git a/integrations/framework_integrations/__init__.py b/integrations/framework_integrations/__init__.py index 89f8a547..ffe4d814 100644 --- a/integrations/framework_integrations/__init__.py +++ b/integrations/framework_integrations/__init__.py @@ -11,7 +11,8 @@ ## Installation No PyPI package is published; import this module from a checkout of the -repository after `python -m pip install -r requirements.txt`. +repository after `python -m pip install -r gateway/requirements.txt -e ./sdk/python` +with `PYTHONPATH=integrations`. ## Quick Start diff --git a/integrations/openai-agent-middleware/tests/test_governed_flow.py b/integrations/openai-agent-middleware/tests/test_governed_flow.py index 844f44cb..dccc60d4 100644 --- a/integrations/openai-agent-middleware/tests/test_governed_flow.py +++ b/integrations/openai-agent-middleware/tests/test_governed_flow.py @@ -53,14 +53,14 @@ def _permit_payload(permit_id: str = "permit-1") -> dict: } -def _receipt_payload(idempotency_key: str, receipt_id: str = "rcpt-1") -> dict: +def _receipt_payload(idempotency_key: str, receipt_id: str = "rcpt-1", tool: str = TOOL) -> dict: return { "receipt_id": receipt_id, "idempotency_record_id": "idem-1", "permit_id": "permit-1", "wallet_id": WALLET, "key_id": "key-1", - "tool": TOOL, + "tool": tool, "request_hash": "a" * 64, "response_hash": "b" * 64, "ledger_entry_id": "ledger-1", @@ -106,7 +106,11 @@ def __call__(self, request: httpx.Request) -> httpx.Response: "content": [{"type": "text", "text": "ok"}], "structuredContent": {"ok": True}, "isError": False, - "receipt": _receipt_payload(key, receipt_id), + # Echo the invoked tool: the SDK refuses a receipt that + # names a different call than the one just made. + "receipt": _receipt_payload( + key, receipt_id, tool=body["params"].get("name", TOOL) + ), }, }, ) diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index 02b0f496..2a3d1b6a 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -12,6 +12,15 @@ both with different values (see below). `parse_402_response()` always sets the two equal, so the normal path is unchanged. The tag is not cut by this change — `python-sdk-v0.5.0` is still a release decision. +### Receipt verifier + +- An edited `alg` or `canonicalization` inside `signing_input` is `invalid` + when the signature does not verify and the key is available. Those claims + are `unsupported` only after the signature over the exact bytes checks out. + Reporting them first let a tampered receipt look like a verifier limitation + (`unsupported`), including on the public `verify_receipt` preflight, which + then skipped the published keys. + ### Audit follow-up (2026-10-01) Security and correctness fixes from the 2026-08-27 repository audit. The only diff --git a/sdk/python/README.md b/sdk/python/README.md index 74955944..b64e31c0 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -100,6 +100,37 @@ Callers must provide nonblank idempotency keys when creating permits and invoking tools. Reusing a key with a different request raises `IdempotencyConflictError`. +The client defaults to `http://localhost:8000`, never a hosted server. +Pass `base_url` explicitly for any other deployment. + +## Synchronous use + +`AgentMiddlewareClient` is async-only. Pilot scripts with plain blocking +code can use `SyncAgentMiddlewareClient`, which covers the same +discover, permit, invoke, receipt, and evidence calls with no event loop +of their own: + +```python +from b2a_sdk import PermitRequest, SyncAgentMiddlewareClient + +with SyncAgentMiddlewareClient( + api_key="agt-your-api-key", + base_url="https://your-gateway.example.com", +) as client: + tools = client.discover_tools() + permit = client.create_permit(PermitRequest(...), idempotency_key="permit-run-001") + invocation = client.invoke_tool( + "partner.search", + {"query": "quarterly risk"}, + wallet_id="agt-wallet", + permit_id=permit.permit_id, + idempotency_key="invoke-run-001", + ) + assert client.verify_receipt(invocation.receipt.receipt_id).valid +``` + +For concurrent or long-lived work, use the async client directly. + If a remote tool was dispatched but its outcome could not be confirmed, `invoke_tool` raises `DeliveryUncertainError`. Its `receipt_id` identifies the signed, charged uncertainty receipt; the SDK never retries the dispatch. @@ -195,6 +226,7 @@ use `AgentMiddlewareClient` and the typed trust-loop methods. Legacy `charge()` and `@billable` calls made without an idempotency key are not replay-safe: retrying one whose response was lost bills the wallet again. +Unkeyed `charge()` calls now emit a `UserWarning` to say so. Pass `charge(..., idempotency_key="...")`, or give `@billable` / `@combined` an `idempotency_key_factory` that derives the same key from the call's arguments on every retry, and the server replays the original charge @@ -208,7 +240,7 @@ which can contain secrets. ## Build and test ```bash -uv build b2a_sdk -python -m pytest sdk/python/tests +uv build sdk/python --out-dir dist +uv run --directory sdk/python --extra dev pytest ruff check sdk/python/src sdk/python/tests ``` diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 25cc63f1..0696e4b0 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -77,6 +77,9 @@ target-version = "py310" select = ["E", "F", "W", "I", "N", "UP", "B", "C4"] ignore = ["E501"] +[tool.mypy] +python_version = "3.10" + [tool.hatch.build.targets.wheel] packages = ["src/b2a_sdk"] @@ -89,6 +92,10 @@ include = [ "README.md", "pyproject.toml", ] +exclude = [ + # Repository-only test: its vectors live outside this source distribution. + "tests/test_receipt_vectors.py", +] [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/sdk/python/src/b2a_sdk/__init__.py b/sdk/python/src/b2a_sdk/__init__.py index 3b0b8c0f..203113da 100644 --- a/sdk/python/src/b2a_sdk/__init__.py +++ b/sdk/python/src/b2a_sdk/__init__.py @@ -61,6 +61,7 @@ "combined": "decorators", "monitored": "decorators", "parse_402_response": "x402", + "SyncAgentMiddlewareClient": "sync_client", } if TYPE_CHECKING: # let type checkers and IDEs resolve the lazy names statically @@ -72,6 +73,7 @@ LocalDecision, LocalPermitValidator, ) + from .sync_client import SyncAgentMiddlewareClient from .x402 import X402Client, parse_402_response @@ -115,6 +117,7 @@ def __dir__() -> list[str]: "PermitRequest", "Receipt", "ReceiptVerification", + "SyncAgentMiddlewareClient", "ToolDefinition", "TransportError", "VerificationError", diff --git a/sdk/python/src/b2a_sdk/client.py b/sdk/python/src/b2a_sdk/client.py index b3c0f128..9c4a2ed1 100644 --- a/sdk/python/src/b2a_sdk/client.py +++ b/sdk/python/src/b2a_sdk/client.py @@ -9,9 +9,11 @@ from __future__ import annotations import logging +import re import warnings from contextlib import asynccontextmanager from typing import Any +from urllib.parse import urlsplit import httpx @@ -39,6 +41,39 @@ logger = logging.getLogger("b2a_sdk") +_UNSAFE_PATH_SEGMENT = re.compile(r"[\x00-\x20\x7f\\/?#%]") + + +def require_path_segment(value: object, label: str) -> str: + """Reject an identifier that could change the URL route before sending it.""" + if ( + not isinstance(value, str) + or not value + or value in {".", ".."} + or _UNSAFE_PATH_SEGMENT.search(value) + ): + raise ValueError(f"{label} is not a safe path segment") + return value + + +def _top_up_url(value: Any) -> str | None: + """Keep a valid server link verbatim without masking a 402 parsing error.""" + if not isinstance(value, str) or not value.strip(): + return None + if any(ord(char) < 32 or ord(char) == 127 for char in value): + return None + try: + parsed = urlsplit(value) + # Accessing port also validates malformed values such as :not-a-port. + _ = parsed.port + if parsed.scheme in {"http", "https"}: + return value if parsed.hostname else None + if not parsed.scheme and value.startswith("/") and not value.startswith("//"): + return value + except ValueError: + return None + return None + class DryRunSimulation: """ @@ -100,8 +135,9 @@ async def end(self) -> dict: """End the simulation session and get summary.""" if self.session_id: try: + safe_id = require_path_segment(self.session_id, "session_id") response = await self._client._client.delete( - f"/v1/billing/dry-run/session/{self.session_id}" + f"/v1/billing/dry-run/session/{safe_id}" ) if response.status_code == 200: return response.json() @@ -130,7 +166,7 @@ class AgentMiddlewareClient: def __init__( self, api_key: str, - base_url: str = "https://api.thisisatest.tech", + base_url: str = "http://localhost:8000", timeout: float = 10.0, *, transport: httpx.AsyncBaseTransport | None = None, @@ -141,7 +177,9 @@ def __init__( Args: api_key: API key for authentication (X-API-Key header) - base_url: Base URL of the middleware API + base_url: Base URL of the middleware API. Defaults to the local + gateway; pass the deployment URL explicitly for anything + else, since the default never points at a hosted server. timeout: Request timeout in seconds transport: Optional HTTPX transport, primarily for in-process tests bearer_token: Optional Bearer token for IGA-governed flows. Omit it @@ -150,11 +188,16 @@ def __init__( environment variable fails here instead of at the server. Raises: - ValueError: if bearer_token is supplied but blank. + ValueError: if bearer_token is supplied but blank, or if api_key + or bearer_token contains a control character. A carriage + return or newline in a header value can split the request. """ # The key is held only in the transport's default headers (see the # ``api_key`` property), not duplicated as instance state, so debug # dumps of ``vars(client)`` do not carry the secret. + # Do not strip the key: leading or trailing spaces can be significant, + # and stripping would also hide a newline that belongs in the check. + self._reject_header_controls(api_key, "api_key") self.base_url = base_url.rstrip("/") headers = { "X-API-Key": api_key, @@ -167,6 +210,7 @@ def __init__( raise ValueError( "bearer_token must not be blank; omit it to send no Authorization header" ) + self._reject_header_controls(token, "bearer_token") headers["Authorization"] = f"Bearer {token}" self._client = httpx.AsyncClient( base_url=self.base_url, @@ -181,13 +225,26 @@ def api_key(self) -> str: """The API key this client sends as ``X-API-Key`` (read-only).""" return self._client.headers["X-API-Key"] + @staticmethod + def _reject_header_controls(value: str, field: str) -> None: + """Refuse a header value that could split the request. + + httpx accepts CR, LF, and DEL in a header. Those bytes let a caller + inject another header. Reject them before anything is sent. + """ + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError(f"{field} must not contain control characters") + @staticmethod def _validate_idempotency_key(idempotency_key: str) -> str: + if not isinstance(idempotency_key, str): + raise ValueError("idempotency_key must be a string") key = idempotency_key.strip() if not key: raise ValueError("idempotency_key must not be blank") if len(key) > 128: raise ValueError("idempotency_key must be at most 128 characters") + AgentMiddlewareClient._reject_header_controls(key, "idempotency_key") return key @staticmethod @@ -221,10 +278,16 @@ def _raise_http_error( if status_code == 402: body_detail = payload.get("detail") detail_payload = body_detail if isinstance(body_detail, dict) else {} + reported_wallet = detail_payload.get("wallet_id") raise InsufficientFundsError( - wallet_id=wallet_id or "unknown", + wallet_id=( + reported_wallet + if isinstance(reported_wallet, str) and reported_wallet.strip() + else wallet_id or "unknown" + ), shortfall=detail_payload.get("shortfall"), - top_up_url=detail_payload.get("top_up_url"), + shortfall_exact=detail_payload.get("shortfall_exact"), + top_up_url=_top_up_url(detail_payload.get("top_up_url")), payload=payload, ) if status_code == 403: @@ -285,6 +348,23 @@ def _parse_receipt(data: Any, *, response_name: str) -> Receipt: except ValueError as exc: raise APIError(f"invalid_{response_name}_receipt: {exc}") from exc + @staticmethod + def _assert_receipt_matches_request( + receipt: Receipt, + *, + tool: str, + permit_id: str, + wallet_id: str, + payload: dict[str, Any], + ) -> None: + """Refuse a receipt that names a different call than the one just made. + + A success or error body can still describe another tool, permit, or + wallet. That body is not evidence for this call. + """ + if receipt.tool != tool or receipt.permit_id != permit_id or receipt.wallet_id != wallet_id: + raise APIError("mcp_receipt_does_not_match_request", payload=payload) + async def discover_tools(self) -> list[ToolDefinition]: """Return the executable tools advertised by the MCP gateway.""" payload = await self._request_json("GET", "/mcp/tools.json") @@ -322,6 +402,8 @@ def _raise_mcp_error( self, error: dict[str, Any], *, + tool: str, + permit_id: str, wallet_id: str, envelope: dict[str, Any], ) -> None: @@ -332,6 +414,13 @@ def _raise_mcp_error( receipt: Receipt | None = None if isinstance(receipt_data, dict): receipt = self._parse_receipt(receipt_data, response_name="mcp_error") + self._assert_receipt_matches_request( + receipt, + tool=tool, + permit_id=permit_id, + wallet_id=wallet_id, + payload=envelope, + ) receipt_id = receipt.receipt_id if receipt else None outcome = receipt.outcome if receipt else None @@ -378,6 +467,9 @@ async def invoke_tool( idempotency_key: str, ) -> InvocationResult: """Invoke one MCP tool through the governed permit and receipt loop.""" + for label, value in (("tool", name), ("wallet_id", wallet_id), ("permit_id", permit_id)): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} must not be blank") key = self._validate_idempotency_key(idempotency_key) request_payload = { "jsonrpc": "2.0", @@ -402,12 +494,25 @@ async def invoke_tool( ) error = envelope.get("error") if isinstance(error, dict): - self._raise_mcp_error(error, wallet_id=wallet_id, envelope=envelope) + self._raise_mcp_error( + error, + tool=name, + permit_id=permit_id, + wallet_id=wallet_id, + envelope=envelope, + ) result = envelope.get("result") if not isinstance(result, dict): raise APIError("invalid_mcp_result", payload=envelope) receipt = self._parse_receipt(result.get("receipt"), response_name="mcp") + self._assert_receipt_matches_request( + receipt, + tool=name, + permit_id=permit_id, + wallet_id=wallet_id, + payload=envelope, + ) if receipt.outcome == "delivery_uncertain": raise DeliveryUncertainError( receipt_id=receipt.receipt_id, @@ -435,11 +540,16 @@ async def invoke_tool( async def get_receipt(self, receipt_id: str) -> Receipt: """Fetch a wallet-authorized signed receipt.""" - payload = await self._request_json("GET", f"/v1/receipts/{receipt_id}") - return self._parse_receipt(payload, response_name="get") + safe_id = require_path_segment(receipt_id, "receipt_id") + payload = await self._request_json("GET", f"/v1/receipts/{safe_id}") + receipt = self._parse_receipt(payload, response_name="get") + if receipt.receipt_id != receipt_id: + raise APIError("receipt_id_mismatch", payload=payload) + return receipt async def verify_receipt(self, receipt_id: str) -> ReceiptVerification: """Ask the trust plane to verify a receipt signature and linkage.""" + require_path_segment(receipt_id, "receipt_id") payload = await self._request_json( "POST", "/v1/receipts/verify", @@ -451,6 +561,8 @@ async def verify_receipt(self, receipt_id: str) -> ReceiptVerification: if receipt_data is not None else None ) + if receipt is not None and receipt.receipt_id != receipt_id: + raise APIError("receipt_id_mismatch", payload=payload) valid = payload.get("valid") if not isinstance(valid, bool): raise APIError("invalid_receipt_verification", payload=payload) @@ -463,8 +575,14 @@ async def verify_receipt(self, receipt_id: str) -> ReceiptVerification: async def get_evidence(self, receipt_id: str) -> EvidenceBundle: """Fetch the buyer-facing verification bundle for a receipt.""" - payload = await self._request_json("GET", f"/v1/evidence/{receipt_id}") + safe_id = require_path_segment(receipt_id, "receipt_id") + payload = await self._request_json("GET", f"/v1/evidence/{safe_id}") receipt = self._parse_receipt(payload.get("receipt"), response_name="evidence") + envelope_id = payload.get("receipt_id") + if receipt.receipt_id != receipt_id or ( + envelope_id is not None and envelope_id != receipt.receipt_id + ): + raise APIError("evidence_receipt_id_mismatch", payload=payload) permit_data = payload.get("permit") permit: Permit | None = None if permit_data is not None: @@ -484,7 +602,7 @@ async def get_evidence(self, receipt_id: str) -> EvidenceBundle: audit_event = payload.get("audit_event") dispatch = payload.get("dispatch") return EvidenceBundle( - receipt_id=str(payload.get("receipt_id") or receipt.receipt_id), + receipt_id=receipt.receipt_id, valid=valid, receipt=receipt, permit=permit, @@ -521,7 +639,7 @@ async def charge( request_path: Optional API path for tracking description: Optional description of the charge idempotency_key: Optional key sent as ``Idempotency-Key``; must be - nonblank and at most 128 characters + nonblank, at most 128 characters, and free of control characters Returns: Ledger entry with action, amount, balance_after @@ -530,9 +648,17 @@ async def charge( InsufficientFundsError: If wallet has insufficient balance IdempotencyConflictError: If ``idempotency_key`` was already used for a different charge, or that charge is still in progress - ValueError: If ``idempotency_key`` is blank or too long (nothing - is sent) + ValueError: If ``idempotency_key`` is blank, too long, or contains + a control character (nothing is sent) """ + if idempotency_key is None: + warnings.warn( + "charge() without idempotency_key is not replay-safe: " + "retrying a charge whose response was lost bills the wallet " + "again. Pass a caller-owned idempotency_key.", + UserWarning, + stacklevel=2, + ) headers: dict[str, str] | None = None if idempotency_key is not None: headers = {"Idempotency-Key": self._validate_idempotency_key(idempotency_key)} @@ -550,11 +676,25 @@ async def charge( response = await self._client.post("/v1/billing/charge", params=params, headers=headers) if response.status_code == 402: - data = response.json().get("detail", {}) + try: + body = response.json() + except ValueError as exc: + raise APIError("invalid_json_response", status_code=402) from exc + if not isinstance(body, dict): + raise APIError("invalid_object_response", status_code=402) + detail = body.get("detail") + detail_payload = detail if isinstance(detail, dict) else {} + detail_wallet = detail_payload.get("wallet_id") raise InsufficientFundsError( - wallet_id=wallet_id, - shortfall=data.get("shortfall", "unknown"), - top_up_url=f"{self.base_url}/dashboard/top-up?wallet={wallet_id}", + wallet_id=( + detail_wallet + if isinstance(detail_wallet, str) and detail_wallet.strip() + else wallet_id + ), + shortfall=detail_payload.get("shortfall"), + shortfall_exact=detail_payload.get("shortfall_exact"), + top_up_url=_top_up_url(detail_payload.get("top_up_url")), + payload=body, ) # A 409 is an idempotency conflict only when a key was sent; unkeyed # calls keep the legacy httpx.HTTPStatusError for any non-402 error. @@ -683,7 +823,8 @@ async def create_agent_wallet( async def get_wallet(self, wallet_id: str) -> dict[str, Any]: """Get wallet details by ID.""" - response = await self._client.get(f"/v1/billing/wallets/{wallet_id}") + safe_id = require_path_segment(wallet_id, "wallet_id") + response = await self._client.get(f"/v1/billing/wallets/{safe_id}") response.raise_for_status() return response.json() diff --git a/sdk/python/src/b2a_sdk/decorators.py b/sdk/python/src/b2a_sdk/decorators.py index 69e3cd68..37111829 100644 --- a/sdk/python/src/b2a_sdk/decorators.py +++ b/sdk/python/src/b2a_sdk/decorators.py @@ -17,8 +17,8 @@ import inspect import time import traceback -from collections.abc import Callable, Coroutine -from typing import Any, ParamSpec, TypeVar, get_type_hints +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any, ParamSpec, TypeVar, cast, get_type_hints from .client import B2AClient, DryRunSimulation @@ -93,7 +93,7 @@ def _extract_schema_from_func(func: Callable) -> tuple[dict | None, dict | None] """Extract input/output schemas from a function signature.""" try: sig = inspect.signature(func) - hints = get_type_hints(func) if func else {} + hints = get_type_hints(func) properties = {} required = [] @@ -146,7 +146,7 @@ def monitored( capture_args: bool = False, *, capture_traceback: bool = False, -): +) -> Callable[[Callable[P, T]], Callable[P, T]]: """ Instantly wires a function to the Autonomous Product Manager. @@ -177,16 +177,16 @@ async def scrape_website(url: str): def decorator(func: Callable[P, T]) -> Callable[P, T]: @functools.wraps(func) - async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any: start_time = time.time() - metadata = {} + metadata: dict[str, Any] = {} if capture_args: metadata["args"] = str(args)[:200] metadata["kwargs"] = {k: str(v)[:200] for k, v in kwargs.items()} try: - result = await func(*args, **kwargs) + result = await cast(Awaitable[Any], func(*args, **kwargs)) latency_ms = int((time.time() - start_time) * 1000) metadata["latency_ms"] = latency_ms @@ -226,7 +226,7 @@ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: @functools.wraps(func) def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: start_time = time.time() - metadata = {} + metadata: dict[str, Any] = {} if capture_args: metadata["args"] = str(args)[:200] @@ -271,7 +271,7 @@ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: raise if asyncio.iscoroutinefunction(func): - return async_wrapper + return cast(Callable[P, T], async_wrapper) return sync_wrapper return decorator @@ -285,7 +285,7 @@ def billable( request_path: str | None = None, *, idempotency_key_factory: Callable[..., str] | None = None, -): +) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]: """ Gates function execution behind the Agent Financial Gateway. @@ -337,7 +337,9 @@ async def generate_video(url: str): charged and the function does not run) """ - def decorator(func: Callable[P, T]) -> Callable[P, T]: + def decorator( + func: Callable[P, Awaitable[T]], + ) -> Callable[P, Coroutine[Any, Any, T]]: @functools.wraps(func) async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: sim = _dry_run_context.get() @@ -368,7 +370,7 @@ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: return await func(*args, **kwargs) @functools.wraps(func) - def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, T]: raise RuntimeError( f"@billable requires an async function. Got sync function: {func.__name__}" ) @@ -389,7 +391,7 @@ def combined( request_path: str | None = None, *, idempotency_key_factory: Callable[..., str] | None = None, -): +) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]: """ Combines @monitored and @billable into a single decorator. @@ -415,7 +417,9 @@ async def generate_video(url: str): Decorator function """ - def decorator(func: Callable[P, T]) -> Callable[P, T]: + def decorator( + func: Callable[P, Awaitable[T]], + ) -> Callable[P, Coroutine[Any, Any, T]]: monitored_decorator = monitored(client, service_name) billable_decorator = billable( client, @@ -444,7 +448,7 @@ def mcp_tool( category: str = "custom", credits_per_unit: float = 1.0, unit_name: str = "call", -): +) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Coroutine[Any, Any, T]]]: """ Decorator to register a function as an MCP tool. @@ -475,7 +479,9 @@ async def generate_video(url: str, style: str = "cinematic") -> dict: Decorator function """ - def decorator(func: Callable[P, T]) -> Callable[P, T]: + def decorator( + func: Callable[P, Awaitable[T]], + ) -> Callable[P, Coroutine[Any, Any, T]]: actual_name = name or func.__name__ actual_description = description or func.__doc__ or "No description" @@ -492,7 +498,7 @@ def decorator(func: Callable[P, T]) -> Callable[P, T]: async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: return await func(*args, **kwargs) - wrapper._b2a_mcp_metadata = { + wrapper.__dict__["_b2a_mcp_metadata"] = { "service_id": service_id, "name": actual_name, "description": actual_description, diff --git a/sdk/python/src/b2a_sdk/edge_client.py b/sdk/python/src/b2a_sdk/edge_client.py index 531069ae..a59bf607 100644 --- a/sdk/python/src/b2a_sdk/edge_client.py +++ b/sdk/python/src/b2a_sdk/edge_client.py @@ -43,6 +43,7 @@ import base64 import hashlib import re +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timezone from decimal import Decimal @@ -79,6 +80,12 @@ def __init__( timeout: float = 30.0, ): self.api_url = api_url.rstrip("/") + if isinstance(api_key, str): + # Local import: this module must not import the governed client + # at load time. A key with CR or LF would inject a header. + from .client import AgentMiddlewareClient + + AgentMiddlewareClient._reject_header_controls(api_key, "api_key") self.api_key = api_key self.wallet_id = wallet_id self.timeout = timeout @@ -174,11 +181,13 @@ async def execute_awi_action( permit = permit_id.strip() if isinstance(permit_id, str) else "" if not permit: raise ValueError("permit_id must not be blank") - key = idempotency_key.strip() if isinstance(idempotency_key, str) else "" - if not key: + if not isinstance(idempotency_key, str): raise ValueError("idempotency_key must not be blank") - if len(key) > 128: - raise ValueError("idempotency_key must be at most 128 characters") + # Same blank, length, and control-character rules as the governed client. + from .client import AgentMiddlewareClient + + AgentMiddlewareClient._reject_header_controls(permit, "permit_id") + key = AgentMiddlewareClient._validate_idempotency_key(idempotency_key) payload = { "session_id": session_id, "action": action, @@ -200,6 +209,9 @@ async def get_balance(self) -> float: """Get wallet balance. Requires ``wallet_id`` set on the client.""" if not self.wallet_id: raise ValueError("wallet_id required for balance check") + from .client import require_path_segment + + require_path_segment(self.wallet_id, "wallet_id") response = await self._client.get( f"{self.api_url}/v1/billing/wallets/{self.wallet_id}", headers=self._auth_headers(), @@ -232,6 +244,36 @@ class LocalDecision: reason: str | None = None +def _find_forbidden_field(arguments: Any, forbidden: set[str]) -> str | None: + """Find forbidden nested keys and reject cyclic argument containers.""" + active: set[int] = set() + completed: set[int] = set() + stack: list[tuple[Any, bool]] = [(arguments, True)] + while stack: + node, entering = stack.pop() + if not isinstance(node, (dict, list, tuple)): + continue + identity = id(node) + if not entering: + active.remove(identity) + completed.add(identity) + continue + if identity in active: + raise ValueError("cyclic arguments") + if identity in completed: + continue + active.add(identity) + stack.append((node, False)) + if isinstance(node, dict): + for key, child in node.items(): + if key in forbidden: + return str(key) + stack.append((child, True)) + else: + stack.extend((child, True) for child in node) + return None + + class LocalPermitValidator: """Client-side mirror of the trust plane's permit checks. @@ -349,11 +391,18 @@ def permit_signing_payload(cls, permit: dict[str, Any]) -> dict[str, Any]: } if permit.get("requires_human_approval"): payload["requires_human_approval"] = True - max_calls = permit.get("max_calls_per_tool") or {} + max_calls = permit.get("max_calls_per_tool") + if max_calls is None: + max_calls = {} + if not isinstance(max_calls, Mapping): + raise ValueError("max_calls_per_tool must be an object of integers") if max_calls: - payload["max_calls_per_tool"] = { - str(tool): int(limit) for tool, limit in max_calls.items() - } + normalized_calls: dict[str, int] = {} + for tool, limit in max_calls.items(): + if type(limit) is not int: + raise ValueError("max_calls_per_tool values must be integers") + normalized_calls[str(tool)] = limit + payload["max_calls_per_tool"] = normalized_calls if permit.get("aggregate_value_cap") is not None: payload["aggregate_value_cap"] = cls._canonical_decimal(permit["aggregate_value_cap"]) forbidden = permit.get("forbidden_fields") or [] @@ -364,7 +413,10 @@ def permit_signing_payload(cls, permit: dict[str, Any]) -> dict[str, Any]: if permit.get("allow_identical_repeats"): payload["allow_identical_repeats"] = True if permit.get("repeat_window_seconds") is not None: - payload["repeat_window_seconds"] = int(permit["repeat_window_seconds"]) + window = permit["repeat_window_seconds"] + if type(window) is not int: + raise ValueError("repeat_window_seconds must be an integer") + payload["repeat_window_seconds"] = window # Action bindings are additive signed fields. Match ActionPermitFields # without importing the server or adding an SDK runtime dependency. action_fields = ( @@ -434,17 +486,23 @@ def check( tool_name: str, estimated_credits: Decimal, now: datetime | None = None, + wallet_id: str | None = None, + *, + arguments: dict[str, Any] | None = None, ) -> LocalDecision: """Run the in-process permit checks for one prospective call. Mirrors the server's validation order and reason strings: status, - expiry, allowed tools, scopes (both ``tool:{name}:invoke`` and - ``billing:charge``), remaining budget - (``max_credits - spent_credits - locally reserved``), and - ``max_calls_per_tool`` against the local counter. Purely local — no + expiry, subject wallet (when ``wallet_id`` is passed), allowed tools, + scopes (both ``tool:{name}:invoke`` and ``billing:charge``), remaining + budget (``max_credits - spent_credits - locally reserved``), and + ``max_calls_per_tool`` against the local counter. Purely local: no network, no state change (use :meth:`record_use` after a completed call). Malformed expiry/amount fields fail closed with the matching denial reason. An allow here is advisory; the server still decides. + + Omitting ``wallet_id`` skips the wallet comparison, so older callers + keep the previous advisory check. """ permit = self._permit status = str(permit.get("status") or "active") @@ -459,6 +517,9 @@ def check( return LocalDecision(False, "permit_expired") if expires_at <= moment: return LocalDecision(False, "permit_expired") + # Server order: wallet mismatch after expiry, before tools and scopes. + if wallet_id is not None and permit.get("subject_wallet_id") != wallet_id: + return LocalDecision(False, "permit_wallet_mismatch") # Mirror the server exactly (app/services/permits.py): an EMPTY # allowlist means unrestricted — only a non-empty list that omits the # tool denies. The scope check below still gates every tool. @@ -476,15 +537,29 @@ def check( remaining = max_credits - spent - self._reserved if estimated_credits > remaining: return LocalDecision(False, "permit_budget_exceeded") - limits = permit.get("max_calls_per_tool") or {} + limits = permit.get("max_calls_per_tool") + if limits is None: + limits = {} + if not isinstance(limits, Mapping): + return LocalDecision(False, "permit_max_calls_exceeded") if tool_name in limits: - try: - limit = int(limits[tool_name]) - except (TypeError, ValueError): - # Fail closed on a malformed limit, like the server does. + limit = limits[tool_name] + # bool is an int, and int() would also turn 2.5 or "3" into a cap. + # The server denies any limit that is not a real int. + if type(limit) is not int: return LocalDecision(False, "permit_max_calls_exceeded") if self._call_counts.get(tool_name, 0) >= limit: return LocalDecision(False, "permit_max_calls_exceeded") + forbidden = permit.get("forbidden_fields") or [] + if forbidden and not isinstance(forbidden, list): + return LocalDecision(False, "permit_forbidden_field:malformed") + if arguments is not None: + try: + hit = _find_forbidden_field(arguments, {str(name) for name in forbidden}) + except (TypeError, ValueError): + return LocalDecision(False, "permit_forbidden_field:malformed") + if hit is not None: + return LocalDecision(False, f"permit_forbidden_field:{hit}") return LocalDecision(True, None) def record_use(self, tool_name: str, credits: Decimal) -> None: @@ -495,9 +570,17 @@ def record_use(self, tool_name: str, credits: Decimal) -> None: The mirror errs conservative: an idempotent replay that returns the same receipt still advances the local counters, under-estimating the remaining budget rather than over-estimating it. + + Negative and non-finite amounts do not change the reservation. The + completed call still counts. """ self._call_counts[tool_name] = self._call_counts.get(tool_name, 0) + 1 - self._reserved += credits + try: + amount = Decimal(str(credits)) + except (ArithmeticError, TypeError, ValueError): + return + if amount.is_finite() and amount >= 0: + self._reserved += amount class GovernedEdgeSession: @@ -545,8 +628,10 @@ async def open( already hold (or pin) the issuer's ``trust-keys.json``. Raises :class:`~b2a_sdk.errors.PermitDeniedError` with reason: - * ``permit_signature_invalid`` — the fetched permit does not verify + * ``permit_signature_invalid``: the fetched permit does not verify against the published keys (a genuine cryptographic negative); + * ``permit_wallet_mismatch``: the permit subject wallet is not the + wallet this session will act as; * ``permit_trust_keys_invalid`` — the trust-keys document is not a usable key set (malformed document, no ``keys`` list); * ``permit_verification_unavailable`` — the Ed25519 verifier itself @@ -559,6 +644,11 @@ async def open( so callers see the documented boundary error type without losing the diagnostic. """ + from .client import require_path_segment + + require_path_segment(permit_id, "permit_id") + if not isinstance(wallet_id, str) or not wallet_id.strip(): + raise ValueError("wallet_id must not be blank") permit = await client._request_json("GET", f"/v1/permits/{permit_id}") document = trust_keys_document if document is None: @@ -574,6 +664,10 @@ async def open( raise PermitDeniedError("permit_verification_unavailable") from exc if not verified: raise PermitDeniedError("permit_signature_invalid", payload=permit) + # Signature failure is the stronger finding, so the wallet comparison + # runs only after the permit verifies. + if permit.get("subject_wallet_id") != wallet_id: + raise PermitDeniedError("permit_wallet_mismatch", payload=permit) return cls(client, validator, permit_id=permit_id, wallet_id=wallet_id) async def invoke( @@ -596,6 +690,8 @@ async def invoke( decision = self.validator.check( name, estimated_credits if estimated_credits is not None else Decimal("0"), + wallet_id=self.wallet_id, + arguments=arguments, ) if not decision.allowed: raise PermitDeniedError(decision.reason or "permit_denied") diff --git a/sdk/python/src/b2a_sdk/errors.py b/sdk/python/src/b2a_sdk/errors.py index 38256b25..9085ac32 100644 --- a/sdk/python/src/b2a_sdk/errors.py +++ b/sdk/python/src/b2a_sdk/errors.py @@ -2,9 +2,33 @@ from __future__ import annotations +import math +from decimal import Decimal, InvalidOperation from typing import Any +def _finite_amount(value: Any) -> Decimal | None: + """Parse a credit amount without accepting booleans or non-finite values.""" + if isinstance(value, bool) or value is None: + return None + if isinstance(value, str): + text = value.strip() + if not text or text == "unknown": + return None + elif isinstance(value, (int, float, Decimal)): + try: + text = str(value) + except ValueError: + return None + else: + return None + try: + amount = Decimal(text) + except (InvalidOperation, ValueError): + return None + return amount if amount.is_finite() else None + + class AgentMiddlewareError(Exception): """Base class for all SDK-specific failures.""" @@ -79,17 +103,34 @@ class InsufficientFundsError(APIError): def __init__( self, wallet_id: str, - shortfall: float | str | None = None, + shortfall: float | str | Decimal | None = None, top_up_url: str | None = None, *, receipt_id: str | None = None, payload: dict[str, Any] | None = None, + shortfall_exact: str | None = None, ) -> None: self.wallet_id = wallet_id - self.shortfall = None if shortfall is None or shortfall == "unknown" else float(shortfall) self.top_up_url = top_up_url self.receipt_id = receipt_id - message = f"Insufficient funds in wallet {wallet_id}. Shortfall: {self.shortfall} credits." + exact = _finite_amount(shortfall_exact) if isinstance(shortfall_exact, str) else None + amount = exact if exact is not None else _finite_amount(shortfall) + self.shortfall: float | None = None + self.shortfall_exact: str | None = None + if amount is not None: + try: + approximate = float(amount) + except (OverflowError, ValueError): + pass + else: + if math.isfinite(approximate): + self.shortfall = approximate + if exact is not None and isinstance(shortfall_exact, str): + self.shortfall_exact = shortfall_exact.strip() + elif isinstance(shortfall, str): + self.shortfall_exact = shortfall.strip() + shown = self.shortfall_exact if self.shortfall_exact is not None else self.shortfall + message = f"Insufficient funds in wallet {wallet_id}. Shortfall: {shown} credits." if top_up_url: message = f"{message} Top up at: {top_up_url}" super().__init__(message, status_code=402, payload=payload) diff --git a/sdk/python/src/b2a_sdk/mcp.py b/sdk/python/src/b2a_sdk/mcp.py index 801a9c39..1cf3ae68 100644 --- a/sdk/python/src/b2a_sdk/mcp.py +++ b/sdk/python/src/b2a_sdk/mcp.py @@ -15,13 +15,11 @@ import asyncio import json import os -from typing import Any - -try: - import httpx -except ImportError: - httpx = None +import sys +from typing import Any, Literal +from urllib.parse import urlencode +import httpx DEFAULT_API_URL = os.getenv("B2A_API_URL", "http://localhost:8000") @@ -44,12 +42,9 @@ def generate_manifest( """ url = f"{api_url}/mcp/tools.json" if category: - url = f"{url}?category={category}" - - if httpx is None: - raise RuntimeError("httpx required. Install: pip install httpx") + url = f"{url}?{urlencode({'category': category})}" - response = httpx.get(url, timeout=30.0) + response = httpx.get(url, timeout=30.0, follow_redirects=False) response.raise_for_status() manifest = response.json() @@ -88,7 +83,8 @@ def list_tools( manifest = generate_manifest(api_url=api_url, category=category) tools = manifest.get("tools", []) - print(f"\\nAvailable MCP Tools ({len(tools)})") + print() + print(f"Available MCP Tools ({len(tools)})") print("=" * 60) for tool in tools: @@ -98,16 +94,18 @@ def list_tools( unit = tool.get("annotations", {}).get("unitName", "call") cat = tool.get("annotations", {}).get("category", "custom") - print(f"\\n {name}") + print() + print(f" {name}") print(f" Description: {desc[:60]}...") print(f" Cost: {credits} credits/{unit}") print(f" Category: {cat}") - print("\\n" + "=" * 60) + print() + print("=" * 60) async def serve_async( - transport: str = "stdio", + transport: Literal["stdio", "sse"] = "stdio", port: int = 8001, api_url: str = DEFAULT_API_URL, ) -> None: @@ -128,15 +126,20 @@ async def serve_async( manifest = generate_manifest(api_url=api_url) tools = manifest.get("tools", []) - mcp = FastMCP("B2A Marketplace") + mcp = FastMCP("B2A Marketplace", port=port) async def call_tool(service_id: str, input_data: dict, wallet_id: str, api_key: str) -> dict: async with httpx.AsyncClient() as client: response = await client.post( - f"{api_url}/v1/billing/services/{service_id}/invoke", + f"{api_url}/mcp/tools/{service_id}/invoke", headers={"X-API-Key": api_key, "Content-Type": "application/json"}, - json={"caller_wallet_id": wallet_id, "input_data": input_data}, + json={ + "name": service_id, + "arguments": input_data, + "mcp_context": {"wallet_id": wallet_id}, + }, timeout=30.0, + follow_redirects=False, ) response.raise_for_status() return response.json() @@ -149,30 +152,37 @@ async def create_handler(svc_id: str, svc_name: str): async def handler(**kwargs) -> dict: return await call_tool( service_id=svc_id, - input_data={"input_data": kwargs}, + input_data=kwargs, wallet_id=os.getenv("B2A_WALLET_ID", ""), api_key=os.getenv("B2A_API_KEY", ""), ) return handler - mcp.add_tool(name, desc, await create_handler(name, desc)) + mcp.add_tool(await create_handler(name, desc), name=name, description=desc) - print(f"Starting MCP server with {len(tools)} tools...") - print(f"Transport: {transport}") + output = sys.stderr if transport == "stdio" else sys.stdout + print(f"Starting MCP server with {len(tools)} tools...", file=output) + print(f"Transport: {transport}", file=output) if transport == "sse": - print(f"Port: {port}") - print("\\nConfigure with:") - print(" export B2A_API_KEY=") - print(" export B2A_WALLET_ID=") - print(f" export B2A_API_URL={api_url}") - print() - - mcp.run(transport=transport, port=port) + print(f"Port: {port}", file=output) + print(file=output) + print("Configure with:", file=output) + print(" export B2A_API_KEY=", file=output) + print(" export B2A_WALLET_ID=", file=output) + print(f" export B2A_API_URL={api_url}", file=output) + print(file=output) + + if transport == "stdio": + await mcp.run_stdio_async() + elif transport == "sse": + await mcp.run_sse_async() + else: + raise ValueError(f"Unknown transport: {transport}") def serve( - transport: str = "stdio", + transport: Literal["stdio", "sse"] = "stdio", port: int = 8001, api_url: str = DEFAULT_API_URL, ) -> None: diff --git a/sdk/python/src/b2a_sdk/models.py b/sdk/python/src/b2a_sdk/models.py index ed69d652..2049f2bc 100644 --- a/sdk/python/src/b2a_sdk/models.py +++ b/sdk/python/src/b2a_sdk/models.py @@ -2,12 +2,20 @@ from __future__ import annotations +import re from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime from decimal import Decimal from typing import Any +# Same bounds as gateway app/schemas/acp.py. A checkout the server would +# reject must fail here, before the delegated payment token is sent. +_INTENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_\-\.]{1,128}$") +_HOSTNAME_LABEL = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" +_MERCHANT_DOMAIN_PATTERN = re.compile(rf"^{_HOSTNAME_LABEL}(?:\.{_HOSTNAME_LABEL})+$") +_SUPPORTED_ACP_CURRENCIES = frozenset({"usd"}) + JsonObject = dict[str, Any] @@ -247,18 +255,30 @@ class ACPLineItem: def __post_init__(self) -> None: """Reject line items the server would refuse, before the SPT is sent. - Bounds mirror ``app/schemas/acp.py::ACPLineItem`` exactly. Catching - these here keeps a delegated payment credential off the wire on a - request that could only 422. + Bounds mirror ``app/schemas/acp.py::ACPLineItem``. ``True`` and + ``1.0`` are not integers there, and currency is the lowercase + allowlist only. Catching these here keeps a delegated payment + credential off the wire on a request that could only 422. """ - if not 0 < self.quantity <= 10_000: - raise ValueError(f"quantity must be in 1..10000, got {self.quantity!r}") - if not 0 <= self.unit_amount <= 10_000_000: + self._validate() + + def _validate(self) -> None: + if type(self.quantity) is not int or not 0 < self.quantity <= 10_000: + raise ValueError(f"quantity must be an integer in 1..10000, got {self.quantity!r}") + if type(self.unit_amount) is not int or not 0 <= self.unit_amount <= 10_000_000: raise ValueError( - f"unit_amount must be in 0..10000000 minor units, got {self.unit_amount!r}" + f"unit_amount must be an integer in 0..10000000 minor units, " + f"got {self.unit_amount!r}" ) + if not isinstance(self.name, str) or not 1 <= len(self.name) <= 256: + raise ValueError("name must be 1..256 characters") + if self.sku is not None and (not isinstance(self.sku, str) or len(self.sku) > 128): + raise ValueError("sku must be at most 128 characters") + if self.currency not in _SUPPORTED_ACP_CURRENCIES: + raise ValueError(f"currency must be 'usd', got {self.currency!r}") def to_payload(self) -> JsonObject: + self._validate() payload: JsonObject = { "name": self.name, "quantity": self.quantity, @@ -285,7 +305,48 @@ class ACPCheckoutRequest: merchant_domain: str client_total: int + def __post_init__(self) -> None: + """Reject a checkout the server would 422, before the SPT is sent. + + The error text names the field and does not include the token. + """ + self._validate() + + def _validate(self) -> None: + if ( + not isinstance(self.line_items, list) + or not 1 <= len(self.line_items) <= 100 + or not all(isinstance(item, ACPLineItem) for item in self.line_items) + ): + raise ValueError("line_items must contain 1..100 line items") + total = 0 + for item in self.line_items: + item._validate() + total += item.quantity * item.unit_amount + if ( + not isinstance(self.intent_id, str) + or _INTENT_ID_PATTERN.fullmatch(self.intent_id) is None + ): + raise ValueError( + "intent_id must be 1..128 characters of letters, digits, '_', '-' or '.'" + ) + if not isinstance(self.spt_token, str) or not 1 <= len(self.spt_token) <= 512: + raise ValueError("spt_token must be 1..512 characters") + if ( + not isinstance(self.merchant_domain, str) + or len(self.merchant_domain) > 253 + or _MERCHANT_DOMAIN_PATTERN.fullmatch(self.merchant_domain) is None + ): + raise ValueError( + "merchant_domain must be a lowercase hostname with no scheme, port, or path" + ) + if type(self.client_total) is not int or self.client_total < 0: + raise ValueError("client_total must be an integer >= 0") + if self.client_total != total: + raise ValueError("acp_total_mismatch") + def to_payload(self) -> JsonObject: + self._validate() return { "intent_id": self.intent_id, "line_items": [item.to_payload() for item in self.line_items], diff --git a/sdk/python/src/b2a_sdk/receipt_verifier.py b/sdk/python/src/b2a_sdk/receipt_verifier.py index cb4be2ed..60bca431 100644 --- a/sdk/python/src/b2a_sdk/receipt_verifier.py +++ b/sdk/python/src/b2a_sdk/receipt_verifier.py @@ -350,17 +350,13 @@ def verify_bundle( # Parse the signed bytes before anything else that can decide an outcome. # - # Ordering is the whole security property here. The envelope is - # unauthenticated: anyone in the transport path can edit it. So the - # capability gates below (algorithm, canonicalization) and the key - # selection must read the *signed* payload, never the envelope. An earlier - # version read `alg` and `canonicalization` from the envelope and - # short-circuited to UNSUPPORTED before the signature was ever checked, - # which let one edited byte turn a genuine receipt into "your verifier is - # too old" -- an attacker-chosen status, and the exact integrity/capability - # inversion this taxonomy exists to prevent. Envelope values are still - # checked, but afterwards, and disagreement is MISMATCH (a finding about - # the bundle) rather than UNSUPPORTED (a statement about this verifier). + # The envelope is unauthenticated: anyone on the path can edit it. Key + # selection, algorithm, and canonicalization are therefore read from + # signing_input, never from the envelope. Those claims are still just + # bytes until the signature over those exact bytes holds. Judging them + # first reported a tampered receipt as UNSUPPORTED ("this verifier is too + # old") instead of INVALID. Envelope values are checked after the + # signature verifies, and disagreement is MISMATCH. try: payload = json.loads(signing_input) except (json.JSONDecodeError, UnicodeDecodeError, RecursionError, ValueError): @@ -393,28 +389,9 @@ def verify_bundle( key_id=envelope_key_id, ) + # Held for the check after the signature. Not authenticated yet. algorithm = payload.get("alg", "Ed25519") - if algorithm != "Ed25519": - # Signer-declared, and therefore a genuine statement about what this - # verifier can do. - return _fail( - VerificationStatus.UNSUPPORTED, - f"unsupported signature algorithm: {algorithm!r}", - key_id=key_id, - ) - signed_canonicalization = payload.get("canonicalization") - if isinstance(signed_canonicalization, str) and signed_canonicalization != CANONICALIZATION: - # Verifying under rules the payload was not signed with would produce a - # confident answer to the wrong question. Only trusted because it is - # signed; receipts predating this field carry no counterpart and are - # handled by the envelope cross-check after verification. - return _fail( - VerificationStatus.UNSUPPORTED, - f"unsupported canonicalization: {signed_canonicalization!r} (this " - f"verifier implements {CANONICALIZATION!r})", - key_id=key_id, - ) signature = _b64decode(signature_b64) if signature is None: @@ -487,6 +464,24 @@ def verify_bundle( key_id=key_id, ) + # The signature held, so algorithm and canonicalization are the signer's + # claim. A different algorithm, or a canonicalization this verifier does + # not implement, is UNSUPPORTED. That judgment is not available when the + # signature failed: that case already returned INVALID above. + if algorithm != "Ed25519": + return _fail( + VerificationStatus.UNSUPPORTED, + f"unsupported signature algorithm: {algorithm!r}", + key_id=key_id, + ) + if isinstance(signed_canonicalization, str) and signed_canonicalization != CANONICALIZATION: + return _fail( + VerificationStatus.UNSUPPORTED, + f"unsupported canonicalization: {signed_canonicalization!r} (this " + f"verifier implements {CANONICALIZATION!r})", + key_id=key_id, + ) + # The signature holds, so the signed payload is now authenticated ground # truth. Every envelope value that claims to describe it is checked against # it here. Disagreement is a finding about the bundle -- MISMATCH -- never diff --git a/sdk/python/src/b2a_sdk/sync_client.py b/sdk/python/src/b2a_sdk/sync_client.py new file mode 100644 index 00000000..1562e60d --- /dev/null +++ b/sdk/python/src/b2a_sdk/sync_client.py @@ -0,0 +1,137 @@ +"""Synchronous wrapper around the async trust-loop client. + +Plain ``asyncio.run(...)`` also works, but first-time pilot developers hit +the async-only surface before anything else, so this module offers the six +governed-loop calls as blocking methods with no event-loop management: + +``discover -> permit -> invoke -> receipt -> evidence`` + +The async client stays authoritative; every method here runs exactly one +event loop per call and delegates to it. For concurrent or long-lived +work, use :class:`b2a_sdk.client.AgentMiddlewareClient` directly. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from .models import ( + EvidenceBundle, + InvocationResult, + Permit, + PermitRequest, + Receipt, + ReceiptVerification, + ToolDefinition, +) + + +class SyncAgentMiddlewareClient: + """Blocking client for the governed MCP trust loop. + + Usage: + with SyncAgentMiddlewareClient( + api_key="agt-your-api-key", + base_url="https://your-gateway.example.com", + ) as client: + tools = client.discover_tools() + """ + + def __init__( + self, + api_key: str, + base_url: str = "http://localhost:8000", + timeout: float = 10.0, + *, + transport: Any | None = None, + bearer_token: str | None = None, + ) -> None: + """Create the wrapper and its underlying async client. + + Args: + api_key: API key for authentication (X-API-Key header) + base_url: Base URL of the middleware API. Defaults to the local + gateway; pass the deployment URL explicitly for anything + else, since the default never points at a hosted server. + timeout: Request timeout in seconds + transport: Optional HTTPX transport, primarily for in-process tests + bearer_token: Optional Bearer [REDACTED] for IGA-governed flows + """ + from .client import AgentMiddlewareClient + + self._client = AgentMiddlewareClient( + api_key, + base_url=base_url, + timeout=timeout, + transport=transport, + bearer_token=bearer_token, + ) + + @property + def base_url(self) -> str: + """The base URL the underlying client sends requests to.""" + return self._client.base_url + + @property + def api_key(self) -> str: + """The API key this client sends as ``X-API-Key`` (read-only).""" + return self._client.api_key + + def discover_tools(self) -> list[ToolDefinition]: + """Return the executable tools advertised by the MCP gateway.""" + return asyncio.run(self._client.discover_tools()) + + def create_permit( + self, + request: PermitRequest, + *, + idempotency_key: str, + ) -> Permit: + """Create a signed permit using a caller-owned idempotency key.""" + return asyncio.run(self._client.create_permit(request, idempotency_key=idempotency_key)) + + def invoke_tool( + self, + name: str, + arguments: dict[str, Any], + *, + wallet_id: str, + permit_id: str, + idempotency_key: str, + ) -> InvocationResult: + """Invoke one MCP tool through the governed permit and receipt loop.""" + return asyncio.run( + self._client.invoke_tool( + name, + arguments, + wallet_id=wallet_id, + permit_id=permit_id, + idempotency_key=idempotency_key, + ) + ) + + def get_receipt(self, receipt_id: str) -> Receipt: + """Fetch a wallet-authorized signed receipt.""" + return asyncio.run(self._client.get_receipt(receipt_id)) + + def verify_receipt(self, receipt_id: str) -> ReceiptVerification: + """Ask the trust plane to verify a receipt signature and linkage.""" + return asyncio.run(self._client.verify_receipt(receipt_id)) + + def get_evidence(self, receipt_id: str) -> EvidenceBundle: + """Fetch the buyer-facing verification bundle for a receipt.""" + return asyncio.run(self._client.get_evidence(receipt_id)) + + def close(self) -> None: + """Close the underlying HTTP client.""" + asyncio.run(self._client.close()) + + def __enter__(self) -> SyncAgentMiddlewareClient: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + +__all__ = ["SyncAgentMiddlewareClient"] diff --git a/sdk/python/src/b2a_sdk/verify_cli.py b/sdk/python/src/b2a_sdk/verify_cli.py index 02d22296..51250989 100644 --- a/sdk/python/src/b2a_sdk/verify_cli.py +++ b/sdk/python/src/b2a_sdk/verify_cli.py @@ -31,6 +31,7 @@ import json import sys from typing import Any +from urllib.parse import urlsplit from .receipt_verifier import ( VerificationError, @@ -52,6 +53,36 @@ def _read_json(path: str) -> Any: return json.load(handle) +def _issuer_key_url(issuer: str) -> str: + """Fetch keys from the issuer's origin, never from userinfo or a path.""" + if not isinstance(issuer, str): + raise VerificationError("issuer must be an http or https origin") + text = issuer.strip() + if not text or any(char.isspace() or ord(char) == 127 for char in text): + raise VerificationError("issuer must be an http or https origin") + try: + parsed = urlsplit(text) + host = parsed.hostname + port = parsed.port + userinfo = parsed.username is not None or parsed.password is not None + except ValueError as exc: + raise VerificationError("issuer must be an http or https origin") from exc + if ( + parsed.scheme not in {"http", "https"} + or not host + or userinfo + or parsed.query + or parsed.fragment + ): + raise VerificationError( + "issuer must be an http or https origin with no userinfo, query, or fragment" + ) + if ":" in host: + host = f"[{host}]" + netloc = host if port is None else f"{host}:{port}" + return f"{parsed.scheme}://{netloc}{_KEYS_PATH}" + + def _fetch_key_document(issuer: str, timeout: float) -> Any: try: import httpx @@ -59,8 +90,8 @@ def _fetch_key_document(issuer: str, timeout: float) -> Any: raise VerificationError( "Fetching a key set requires httpx; pass --keys to verify offline." ) from exc - url = issuer.rstrip("/") + _KEYS_PATH - response = httpx.get(url, timeout=timeout) + url = _issuer_key_url(issuer) + response = httpx.get(url, timeout=timeout, follow_redirects=False) response.raise_for_status() return response.json() @@ -117,7 +148,10 @@ def main(argv: list[str] | None = None) -> int: try: bundle = _read_json(args.bundle) - except (OSError, json.JSONDecodeError) as exc: + except (OSError, ValueError, RecursionError) as exc: + # ValueError covers both json.JSONDecodeError and UnicodeDecodeError + # (e.g. a UTF-16 bundle from a PowerShell redirect): undecodable or + # malformed input is "could not determine", never "tampered". print(f"could not read bundle: {exc}", file=sys.stderr) return EXIT_UNDETERMINED diff --git a/sdk/python/src/b2a_sdk/x402.py b/sdk/python/src/b2a_sdk/x402.py index cacef188..10fb7ee8 100644 --- a/sdk/python/src/b2a_sdk/x402.py +++ b/sdk/python/src/b2a_sdk/x402.py @@ -83,13 +83,15 @@ class X402Client: def __init__( self, api_key: str, - base_url: str = "https://api.thisisatest.tech", + base_url: str = "http://localhost:8000", timeout: float = 10.0, *, transport: httpx.AsyncBaseTransport | None = None, ) -> None: # Held only in the transport's default headers (see ``api_key``), not # duplicated as instance state, so ``vars(client)`` dumps omit it. + # Same control-character rule as AgentMiddlewareClient: do not strip. + AgentMiddlewareClient._reject_header_controls(api_key, "api_key") self.base_url = base_url.rstrip("/") self._client = httpx.AsyncClient( base_url=self.base_url, diff --git a/sdk/python/tests/test_363_surface.py b/sdk/python/tests/test_363_surface.py index df765c85..d4765ed0 100644 --- a/sdk/python/tests/test_363_surface.py +++ b/sdk/python/tests/test_363_surface.py @@ -561,6 +561,92 @@ def test_boundary_unit_amounts_accepted(self, unit_amount): item = ACPLineItem(name="Widget", quantity=1, unit_amount=unit_amount, currency="usd") assert item.to_payload()["unit_amount"] == unit_amount + @pytest.mark.parametrize("currency", ["USD", "eur", ""]) + def test_currency_outside_the_allowlist_is_rejected(self, currency): + """Currency is case-sensitive and fail-closed, same as the server.""" + with pytest.raises(ValueError, match="currency"): + ACPLineItem(name="Widget", quantity=1, unit_amount=1, currency=currency) + + def test_bool_and_float_amounts_are_rejected(self): + """True and 1.0 are not integers. The server 422s them.""" + with pytest.raises(ValueError, match="quantity"): + ACPLineItem(name="Widget", quantity=True, unit_amount=1, currency="usd") + with pytest.raises(ValueError, match="unit_amount"): + ACPLineItem(name="Widget", quantity=1, unit_amount=1.0, currency="usd") + + def test_empty_name_is_rejected(self): + with pytest.raises(ValueError, match="name"): + ACPLineItem(name="", quantity=1, unit_amount=1, currency="usd") + + +class TestACPCheckoutRequestBounds: + """A checkout the server would 422 must fail before the SPT can be sent.""" + + def _item(self) -> ACPLineItem: + return ACPLineItem(name="Widget", quantity=1, unit_amount=100, currency="usd") + + def test_bad_merchant_domain_is_rejected(self): + with pytest.raises(ValueError, match="merchant_domain"): + ACPCheckoutRequest( + intent_id="int-abc", + line_items=[self._item()], + spt_token="spt_test_token", + merchant_domain="https://shop.example/pay", + client_total=100, + ) + + def test_uppercase_domain_is_rejected(self): + with pytest.raises(ValueError, match="merchant_domain"): + ACPCheckoutRequest( + intent_id="int-abc", + line_items=[self._item()], + spt_token="spt_test_token", + merchant_domain="Shop.Example", + client_total=100, + ) + + def test_empty_line_items_are_rejected(self): + with pytest.raises(ValueError, match="line_items"): + ACPCheckoutRequest( + intent_id="int-abc", + line_items=[], + spt_token="spt_test_token", + merchant_domain="example.com", + client_total=0, + ) + + def test_negative_client_total_is_rejected(self): + with pytest.raises(ValueError, match="client_total"): + ACPCheckoutRequest( + intent_id="int-abc", + line_items=[self._item()], + spt_token="spt_test_token", + merchant_domain="example.com", + client_total=-1, + ) + + def test_spt_token_length_is_bounded_without_echoing_the_token(self): + secret = "s" * 513 + with pytest.raises(ValueError, match="spt_token") as exc_info: + ACPCheckoutRequest( + intent_id="int-abc", + line_items=[self._item()], + spt_token=secret, + merchant_domain="example.com", + client_total=100, + ) + assert secret not in str(exc_info.value) + + def test_bad_intent_id_is_rejected(self): + with pytest.raises(ValueError, match="intent_id"): + ACPCheckoutRequest( + intent_id="has space", + line_items=[self._item()], + spt_token="spt_test_token", + merchant_domain="example.com", + client_total=100, + ) + class TestBearerTokenValidation: """A blank bearer must not become a malformed `Authorization: Bearer `.""" @@ -582,3 +668,16 @@ def test_omitted_bearer_token_sends_no_header(self): """Omitting it stays the way to send no Authorization header.""" client = AgentMiddlewareClient(api_key="test-key", base_url="http://test") assert "Authorization" not in client._client.headers + + def test_bearer_token_rejects_header_injection(self): + with pytest.raises(ValueError, match="control characters"): + AgentMiddlewareClient( + api_key="test-key", + base_url="http://test", + bearer_token="tok\r\nX-Injected: yes", + ) + + @pytest.mark.parametrize("client_cls", [AgentMiddlewareClient, X402Client]) + def test_api_key_rejects_header_injection(self, client_cls): + with pytest.raises(ValueError, match="control characters"): + client_cls(api_key="sk\r\nX-Injected: yes", base_url="http://test") diff --git a/sdk/python/tests/test_local_permit_validator.py b/sdk/python/tests/test_local_permit_validator.py index 5bcdc1db..8a601cf5 100644 --- a/sdk/python/tests/test_local_permit_validator.py +++ b/sdk/python/tests/test_local_permit_validator.py @@ -255,6 +255,38 @@ def test_verify_permit_covers_v2_constraints_when_present(): assert validator.verify_permit() is False, f"{field} must be signed" +def test_signed_call_caps_and_repeat_windows_require_real_integers(): + permit, keys = _build_signed_permit(max_calls_per_tool={TOOL: 3}) + for value in ("3", True, 3.0): + changed = dict(permit, max_calls_per_tool={TOOL: value}) + assert not _validator(changed, keys).verify_permit() + + permit, keys = _build_signed_permit(repeat_window_seconds=3600) + for value in ("3600", True, 3600.0): + changed = dict(permit, repeat_window_seconds=value) + assert not _validator(changed, keys).verify_permit() + + +def test_malformed_call_cap_containers_fail_closed_without_changing_legacy_absence(): + permit, keys = _build_signed_permit() + expected = LocalPermitValidator.permit_signing_payload(permit) + for value in ([], 0): + changed = dict(permit, max_calls_per_tool=value) + assert not _validator(changed, keys).verify_permit() + decision = LocalPermitValidator(changed, {}).check(TOOL, Decimal("1")) + assert decision.reason == "permit_max_calls_exceeded" + + for value in (None, {}): + changed = dict(permit, max_calls_per_tool=value) + assert LocalPermitValidator.permit_signing_payload(changed) == expected + assert _validator(changed, keys).verify_permit() + assert LocalPermitValidator(changed, {}).check(TOOL, Decimal("1")).allowed + absent = dict(permit) + absent.pop("max_calls_per_tool") + assert LocalPermitValidator.permit_signing_payload(absent) == expected + assert _validator(absent, keys).verify_permit() + + def test_verify_permit_fails_closed_on_unknown_key_and_malformed_input(): permit, key_document = _build_signed_permit() # Key set that does not hold the signing kid. @@ -368,6 +400,107 @@ def test_record_use_advances_local_reservation_and_counters(): assert validator.check(TOOL, Decimal("0")).allowed is True +def test_check_denies_a_different_subject_wallet(): + """The server returns permit_wallet_mismatch. The local mirror must too.""" + validator = LocalPermitValidator(_permit_dict(), {}) + decision = validator.check(TOOL, Decimal("1"), wallet_id="someone-else") + assert decision.allowed is False + assert decision.reason == "permit_wallet_mismatch" + assert validator.check(TOOL, Decimal("1"), None, "someone-else").reason == ( + "permit_wallet_mismatch" + ) + # Callers that do not pass a wallet keep the previous advisory check. + assert validator.check(TOOL, Decimal("1")).allowed is True + + +def test_check_fails_closed_when_the_call_cap_is_not_a_real_int(): + """bool, float, and digit strings must not become a permissive cap.""" + for limit in (True, 1.5, "1"): + validator = LocalPermitValidator(_permit_dict(max_calls_per_tool={TOOL: limit}), {}) + decision = validator.check(TOOL, Decimal("1")) + assert decision.allowed is False, limit + assert decision.reason == "permit_max_calls_exceeded" + + +def test_record_use_ignores_negative_credits(): + """A negative charge must not increase the budget the mirror will allow.""" + validator = LocalPermitValidator(_permit_dict(max_credits="10", spent_credits="0"), {}) + validator.record_use(TOOL, Decimal("-5")) + assert validator.reserved_credits == Decimal("0") + denied = validator.check(TOOL, Decimal("11")) + assert denied.allowed is False + assert denied.reason == "permit_budget_exceeded" + + +def test_record_use_ignores_nonfinite_or_malformed_reservations_but_counts_calls(): + validator = LocalPermitValidator(_permit_dict(max_credits="10", spent_credits="8"), {}) + for amount in ( + Decimal("-5"), + Decimal("NaN"), + Decimal("Infinity"), + Decimal("-Infinity"), + None, + ): + validator.record_use(TOOL, amount) + assert validator.call_counts == {TOOL: 5} + assert validator.reserved_credits == Decimal("0") + assert validator.check(TOOL, Decimal("3")).reason == "permit_budget_exceeded" + assert validator.check(TOOL, Decimal("2")).allowed + + +def test_check_denies_nested_forbidden_keys_but_not_values(): + validator = LocalPermitValidator(_permit_dict(forbidden_fields=["password"]), {}) + denied = validator.check(TOOL, Decimal("1"), arguments={"profile": [{"password": "secret"}]}) + assert denied.reason == "permit_forbidden_field:password" + assert validator.check(TOOL, Decimal("1"), arguments={"note": "password"}).allowed + assert validator.check(TOOL, Decimal("1"), arguments={}).allowed + + +@pytest.mark.parametrize("forbidden_fields", [[], ["password"]]) +def test_check_scans_shared_acyclic_arguments_in_linear_work(forbidden_fields): + class CountingDict(dict): + visits = 0 + + def items(self): + type(self).visits += 1 + return super().items() + + node: dict[str, Any] = CountingDict({"value": "safe"}) + for _ in range(12): + node = CountingDict({"left": node, "right": node}) + validator = LocalPermitValidator(_permit_dict(forbidden_fields=forbidden_fields), {}) + assert validator.check(TOOL, Decimal("1"), arguments=node).allowed + assert CountingDict.visits <= 26 # 13 distinct dicts, including the leaf + + +def test_check_finds_forbidden_key_in_shared_node_and_handles_deep_nesting(): + validator = LocalPermitValidator(_permit_dict(forbidden_fields=["password"]), {}) + shared = {"nested": {"password": "synthetic"}} + assert ( + validator.check(TOOL, Decimal("1"), arguments={"left": shared, "right": shared}).reason + == "permit_forbidden_field:password" + ) + + node: dict[str, Any] = {} + for _ in range(1200): + node = {"next": [node]} + assert validator.check(TOOL, Decimal("1"), arguments=node).allowed + + +@pytest.mark.parametrize("kind", ["dict", "list"]) +def test_check_denies_cyclic_argument_graphs(kind: str): + validator = LocalPermitValidator(_permit_dict(forbidden_fields=["password"]), {}) + arguments: dict[str, Any] = {} + if kind == "dict": + arguments["self"] = arguments + else: + children: list[Any] = [] + children.append(children) + arguments["children"] = children + decision = validator.check(TOOL, Decimal("1"), arguments=arguments) + assert decision.reason == "permit_forbidden_field:malformed" + + # --------------------------------------------------------------------------- # GovernedEdgeSession over MockTransport # --------------------------------------------------------------------------- @@ -455,6 +588,68 @@ async def handler(request: httpx.Request) -> httpx.Response: await client.close() +@pytest.mark.asyncio +async def test_session_rejects_nested_and_cyclic_arguments_before_invoke(): + permit, key_document = _build_signed_permit(forbidden_fields=["password"]) + seen: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.path) + if request.url.path == "/v1/permits/permit-local-1": + return httpx.Response(200, json=permit) + if request.url.path == "/.well-known/trust-keys.json": + return httpx.Response(200, json=key_document) + raise AssertionError(f"unexpected request: {request.url.path}") + + client = AgentMiddlewareClient( + api_key="test-key", + base_url="https://gateway.example", + transport=httpx.MockTransport(handler), + ) + try: + session = await GovernedEdgeSession.open( + client, permit_id="permit-local-1", wallet_id="wallet-1" + ) + with pytest.raises(PermitDeniedError) as denied: + await session.invoke( + TOOL, + {"profile": [{"password": "secret"}]}, + idempotency_key="nested-field-1", + ) + assert denied.value.reason == "permit_forbidden_field:password" + + self_dict: dict[str, Any] = {} + self_dict["self"] = self_dict + self_list: list[Any] = [] + self_list.append(self_list) + for arguments in (self_dict, {"children": self_list}): + with pytest.raises(PermitDeniedError) as denied: + await session.invoke(TOOL, arguments, idempotency_key="cyclic-args-1") + assert denied.value.reason == "permit_forbidden_field:malformed" + assert seen == ["/v1/permits/permit-local-1", "/.well-known/trust-keys.json"] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_session_open_rejects_unsafe_path_and_blank_wallet_without_request(): + async def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"unexpected request: {request.url.path}") + + client = AgentMiddlewareClient( + api_key="test-key", + base_url="https://gateway.example", + transport=httpx.MockTransport(handler), + ) + try: + with pytest.raises(ValueError, match="permit_id"): + await GovernedEdgeSession.open(client, permit_id="../admin", wallet_id="wallet-1") + with pytest.raises(ValueError, match="wallet_id"): + await GovernedEdgeSession.open(client, permit_id="permit-local-1", wallet_id=" ") + finally: + await client.close() + + @pytest.mark.asyncio async def test_session_open_raises_permit_denied_for_malformed_trust_keys(): """A broken trust-keys.json surfaces as the documented boundary error. @@ -514,6 +709,37 @@ async def handler(request: httpx.Request) -> httpx.Response: await client.close() +@pytest.mark.asyncio +async def test_session_open_rejects_a_permit_bound_to_another_wallet(): + """A signed permit for wallet-1 must not open a session for someone else.""" + permit, key_document = _build_signed_permit() + invoked = False + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal invoked + if request.url.path == "/v1/permits/permit-local-1": + return httpx.Response(200, json=permit) + if request.url.path == "/.well-known/trust-keys.json": + return httpx.Response(200, json=key_document) + invoked = True + raise AssertionError(f"unexpected path: {request.url.path}") + + client = AgentMiddlewareClient( + api_key="test-key", + base_url="https://gateway.example", + transport=httpx.MockTransport(handler), + ) + try: + with pytest.raises(PermitDeniedError) as exc_info: + await GovernedEdgeSession.open( + client, permit_id="permit-local-1", wallet_id="other-wallet" + ) + assert exc_info.value.reason == "permit_wallet_mismatch" + assert invoked is False + finally: + await client.close() + + def test_permit_signature_covers_allow_identical_repeats(): """allow_identical_repeats enters the signed payload when true.""" permit_true, keys_true = _build_signed_permit(allow_identical_repeats=True) diff --git a/sdk/python/tests/test_mcp_issuer_boundaries.py b/sdk/python/tests/test_mcp_issuer_boundaries.py new file mode 100644 index 00000000..3662534c --- /dev/null +++ b/sdk/python/tests/test_mcp_issuer_boundaries.py @@ -0,0 +1,73 @@ +"""Focused boundaries for the legacy MCP CLI and issuer key lookup.""" + +from __future__ import annotations + +import httpx +import pytest + +from b2a_sdk import mcp, verify_cli +from b2a_sdk.receipt_verifier import VerificationError + + +def test_issuer_key_fetch_uses_origin_without_following_redirects(monkeypatch): + seen: dict[str, object] = {} + + def fake_get(url: str, **kwargs): + seen["url"] = url + seen["kwargs"] = kwargs + return httpx.Response(200, json={"keys": []}, request=httpx.Request("GET", url)) + + monkeypatch.setattr(httpx, "get", fake_get) + assert verify_cli._fetch_key_document("https://api.example.com/ignored", 5) == {"keys": []} + assert seen["url"] == "https://api.example.com/.well-known/trust-keys.json" + assert seen["kwargs"] == {"timeout": 5, "follow_redirects": False} + + +@pytest.mark.parametrize( + "issuer", + [ + "https://api.example.com@evil.example", + "https://api.example.com?next=evil", + "https://api.example.com#fragment", + "ftp://api.example.com", + "https://[::1", + "https://api.example.com:not-a-port", + ], +) +def test_invalid_issuer_never_fetches_keys(monkeypatch, issuer: str): + def fake_get(*_args, **_kwargs): + raise AssertionError("request was sent") + + monkeypatch.setattr(httpx, "get", fake_get) + with pytest.raises(VerificationError, match="issuer"): + verify_cli._fetch_key_document(issuer, 5) + + +def test_manifest_category_is_encoded_and_redirects_are_off(monkeypatch): + seen: dict[str, object] = {} + + def fake_get(url: str, **kwargs): + seen["url"] = url + seen["kwargs"] = kwargs + return httpx.Response(200, json={"tools": []}, request=httpx.Request("GET", url)) + + assert mcp.httpx is not None + monkeypatch.setattr(mcp.httpx, "get", fake_get) + assert mcp.generate_manifest(api_url="https://api.example.com", category="a&b=c") == { + "tools": [] + } + assert seen["url"] == "https://api.example.com/mcp/tools.json?category=a%26b%3Dc" + assert seen["kwargs"] == {"timeout": 30.0, "follow_redirects": False} + + +def test_list_tools_uses_real_line_breaks(monkeypatch, capsys): + monkeypatch.setattr( + mcp, + "generate_manifest", + lambda **_kwargs: {"tools": [{"name": "partner.search", "description": "Find partners"}]}, + ) + mcp.list_tools() + output = capsys.readouterr().out + assert "\\n" not in output + assert "Available MCP Tools (1)" in output.splitlines() + assert " partner.search" in output.splitlines() diff --git a/sdk/python/tests/test_mcp_serve.py b/sdk/python/tests/test_mcp_serve.py new file mode 100644 index 00000000..588237de --- /dev/null +++ b/sdk/python/tests/test_mcp_serve.py @@ -0,0 +1,114 @@ +"""The legacy serve command uses the FastMCP 1.x serving API.""" + +import asyncio +import json + +import pytest + +from b2a_sdk import mcp + + +@pytest.mark.parametrize("transport", ["stdio", "sse"]) +def test_serve_async_registers_tool_and_starts_transport(monkeypatch, tmp_path, capsys, transport): + fastmcp = pytest.importorskip("mcp.server.fastmcp").FastMCP + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + mcp, + "generate_manifest", + lambda **kwargs: {"tools": [{"name": "partner.search", "description": "Search"}]}, + ) + started = [] + + async def run_stdio(self): + started.append(("stdio", self)) + + async def run_sse(self, mount_path=None): + started.append(("sse", self)) + + monkeypatch.setattr(fastmcp, "run_stdio_async", run_stdio) + monkeypatch.setattr(fastmcp, "run_sse_async", run_sse) + + asyncio.run(mcp.serve_async(transport=transport, port=8765, api_url="https://unused.invalid")) + + output = capsys.readouterr() + if transport == "stdio": + assert output.out == "" + assert "Starting MCP server" in output.err + + assert len(started) == 1 + assert started[0][0] == transport + server = started[0][1] + assert [(tool.name, tool.description) for tool in asyncio.run(server.list_tools())] == [ + ("partner.search", "Search") + ] + if transport == "sse": + assert server.settings.port == 8765 + + +@pytest.mark.parametrize("status_code", [200, 403]) +def test_serve_async_tool_invokes_gateway_with_arguments(monkeypatch, tmp_path, status_code): + fastmcp = pytest.importorskip("mcp.server.fastmcp").FastMCP + httpx = pytest.importorskip("httpx") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("B2A_WALLET_ID", "test-wallet") + monkeypatch.setenv("B2A_API_KEY", "test-key") + monkeypatch.setattr( + mcp, + "generate_manifest", + lambda **kwargs: {"tools": [{"name": "partner.search", "description": "Search"}]}, + ) + registered = [] + requests = [] + add_tool = fastmcp.add_tool + + def capture_tool(self, handler, **kwargs): + registered.append(handler) + return add_tool(self, handler, **kwargs) + + async def run_stdio(self): + pass + + def respond(request): + requests.append(request) + return httpx.Response(status_code, json={"content": [{"type": "text", "text": "ok"}]}) + + client_type = httpx.AsyncClient + transport = httpx.MockTransport(respond) + monkeypatch.setattr(httpx, "AsyncClient", lambda: client_type(transport=transport)) + monkeypatch.setattr(fastmcp, "add_tool", capture_tool) + monkeypatch.setattr(fastmcp, "run_stdio_async", run_stdio) + + asyncio.run(mcp.serve_async(api_url="https://unused.invalid")) + assert len(registered) == 1 + if status_code == 200: + assert asyncio.run(registered[0](query="sample")) == { + "content": [{"type": "text", "text": "ok"}] + } + else: + with pytest.raises(httpx.HTTPStatusError) as exc_info: + asyncio.run(registered[0](query="sample")) + assert exc_info.value.response.status_code == 403 + assert len(requests) == 1 + assert requests[0].method == "POST" + assert str(requests[0].url) == "https://unused.invalid/mcp/tools/partner.search/invoke" + assert requests[0].headers["X-API-Key"] == "test-key" + assert json.loads(requests[0].content) == { + "name": "partner.search", + "arguments": {"query": "sample"}, + "mcp_context": {"wallet_id": "test-wallet"}, + } + + +def test_serve_async_rejects_unknown_transport(monkeypatch, tmp_path): + fastmcp = pytest.importorskip("mcp.server.fastmcp").FastMCP + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(mcp, "generate_manifest", lambda **kwargs: {"tools": []}) + + async def forbidden(self): + pytest.fail("unsupported transport must not start a listener") + + monkeypatch.setattr(fastmcp, "run_stdio_async", forbidden) + monkeypatch.setattr(fastmcp, "run_sse_async", forbidden) + + with pytest.raises(ValueError, match="Unknown transport: websocket"): + asyncio.run(mcp.serve_async(transport="websocket", api_url="https://unused.invalid")) diff --git a/sdk/python/tests/test_qa_boundaries.py b/sdk/python/tests/test_qa_boundaries.py new file mode 100644 index 00000000..2d9f9143 --- /dev/null +++ b/sdk/python/tests/test_qa_boundaries.py @@ -0,0 +1,201 @@ +"""Focused pre-I/O and billing boundaries for the Python SDK.""" + +from __future__ import annotations + +from decimal import Decimal +from typing import cast + +import httpx +import pytest + +from b2a_sdk.client import AgentMiddlewareClient +from b2a_sdk.errors import APIError, InsufficientFundsError +from b2a_sdk.models import ACPCheckoutRequest, ACPLineItem + + +def _client(handler) -> AgentMiddlewareClient: + return AgentMiddlewareClient( + api_key="test-key", + base_url="https://gateway.example", + transport=httpx.MockTransport(handler), + ) + + +@pytest.mark.parametrize("method", ["get_receipt", "verify_receipt", "get_evidence", "get_wallet"]) +@pytest.mark.parametrize("unsafe_id", ["../admin", "wallet/../admin", "a%2Fb", "a?b", " "]) +@pytest.mark.asyncio +async def test_unsafe_identifier_is_rejected_before_io(method: str, unsafe_id: str) -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(500) + + async with _client(handler) as client: + with pytest.raises(ValueError, match="safe path segment"): + await getattr(client, method)(unsafe_id) + assert seen == [] + + +@pytest.mark.parametrize( + ("field", "value"), + [("name", " "), ("wallet_id", " "), ("permit_id", " ")], +) +@pytest.mark.asyncio +async def test_blank_invoke_binding_is_rejected_before_io(field: str, value: str) -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(500) + + arguments = { + "name": "partner.search", + "wallet_id": "wallet-1", + "permit_id": "permit-1", + "idempotency_key": "invoke-1", + } + arguments[field] = value + async with _client(handler) as client: + with pytest.raises(ValueError, match="must not be blank"): + await client.invoke_tool(arguments.pop("name"), {}, **arguments) + assert seen == [] + + +@pytest.mark.parametrize("bad_key", [None, 7, b"key", object()]) +@pytest.mark.asyncio +async def test_non_string_idempotency_key_is_value_error_before_io(bad_key: object) -> None: + seen: list[httpx.Request] = [] + # Exercise the runtime guard with values outside the public str type contract. + invalid_key = cast(str, bad_key) + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(500) + + with pytest.raises(ValueError, match="idempotency_key must be a string"): + AgentMiddlewareClient._validate_idempotency_key(invalid_key) + async with _client(handler) as client: + with pytest.raises(ValueError, match="idempotency_key must be a string"): + await client.invoke_tool( + "partner.search", + {}, + wallet_id="wallet-1", + permit_id="permit-1", + idempotency_key=invalid_key, + ) + assert seen == [] + + +def test_idempotency_validator_normalizes_valid_key_without_client() -> None: + assert AgentMiddlewareClient._validate_idempotency_key(" job-1 ") == "job-1" + + +@pytest.mark.asyncio +async def test_unsafe_dry_run_id_sends_no_delete_and_preserves_body_error() -> None: + seen: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path)) + if request.method == "POST" and request.url.path == "/v1/billing/dry-run/session": + return httpx.Response(200, json={"session_id": "../admin"}) + raise AssertionError("unsafe cleanup sent a request") + + async with _client(handler) as client: + with pytest.raises(RuntimeError, match="original failure"): + async with client.simulate_session("wallet-1"): + raise RuntimeError("original failure") + assert seen == [("POST", "/v1/billing/dry-run/session")] + + +@pytest.mark.parametrize( + "body,detail", + [(b"not json", "invalid_json_response"), (b"[]", "invalid_object_response")], +) +@pytest.mark.asyncio +async def test_malformed_charge_402_keeps_api_error(body: bytes, detail: str) -> None: + async with _client(lambda _: httpx.Response(402, content=body)) as client: + with pytest.raises(APIError, match=detail) as exc_info: + await client.charge("wallet-1", "iot_bridge", idempotency_key="charge-1") + assert type(exc_info.value) is APIError + assert exc_info.value.status_code == 402 + + +@pytest.mark.asyncio +async def test_exact_shortfall_and_relative_top_up_survive_verbatim() -> None: + body = { + "detail": { + "shortfall": 0.30000000000000004, + "shortfall_exact": "0.30", + "top_up_url": "/v1/billing/top-up/prepare", + } + } + async with _client(lambda _: httpx.Response(402, json=body)) as client: + with pytest.raises(InsufficientFundsError) as exc_info: + await client.charge("wallet-1", "iot_bridge", idempotency_key="charge-1") + error = exc_info.value + assert error.shortfall == 0.3 + assert error.shortfall_exact == "0.30" + assert error.top_up_url == "/v1/billing/top-up/prepare" + assert "Shortfall: 0.30 credits" in str(error) + + +@pytest.mark.parametrize("link", ["https://[bad/top", "https://example.com:bad/top"]) +@pytest.mark.asyncio +async def test_malformed_top_up_link_does_not_mask_funds_error(link: str) -> None: + body = {"detail": {"shortfall": "2", "top_up_url": link}} + async with _client(lambda _: httpx.Response(402, json=body)) as client: + with pytest.raises(InsufficientFundsError) as exc_info: + await client.charge("wallet-1", "iot_bridge", idempotency_key="charge-1") + assert exc_info.value.shortfall == 2.0 + assert exc_info.value.top_up_url is None + + +def test_huge_finite_exact_shortfall_never_exposes_infinite_float() -> None: + error = InsufficientFundsError("wallet-1", shortfall="0.5", shortfall_exact="1e9999") + assert error.shortfall is None + assert error.shortfall_exact == "1e9999" + assert "Shortfall: 1e9999 credits" in str(error) + + +def _checkout() -> ACPCheckoutRequest: + return ACPCheckoutRequest( + intent_id="intent-1", + line_items=[ACPLineItem("Widget", 2, 2125, "usd", sku="")], + spt_token="spt_secret", + merchant_domain="example.com", + client_total=4250, + ) + + +def test_checkout_validates_exact_total_and_retains_empty_sku() -> None: + request = _checkout() + assert request.to_payload()["line_items"][0]["sku"] == "" + assert "spt_secret" not in repr(request) + with pytest.raises(ValueError, match="acp_total_mismatch"): + ACPCheckoutRequest( + intent_id="intent-1", + line_items=request.line_items, + spt_token="spt_secret", + merchant_domain="example.com", + client_total=4249, + ) + + +@pytest.mark.parametrize("mutation", ["total", "quantity", "currency"]) +def test_checkout_revalidates_nested_mutations_before_payload(mutation: str) -> None: + request = _checkout() + if mutation == "total": + request.client_total = 1 + elif mutation == "quantity": + request.line_items[0].quantity = 3 + else: + request.line_items[0].currency = "eur" + with pytest.raises(ValueError): + request.to_payload() + + +def test_non_finite_shortfall_is_unknown() -> None: + error = InsufficientFundsError("wallet-1", shortfall=Decimal("NaN")) + assert error.shortfall is None + assert error.shortfall_exact is None diff --git a/sdk/python/tests/test_receipt_vectors.py b/sdk/python/tests/test_receipt_vectors.py new file mode 100644 index 00000000..7e6f0c8e --- /dev/null +++ b/sdk/python/tests/test_receipt_vectors.py @@ -0,0 +1,47 @@ +"""Published receipt vectors for the offline verifier. + +The TypeScript SDK loads ``spec/test-vectors`` in its own tests. This module +does the same for Python, so the SDK CI job checks that corpus. +``verifier/`` has no ``pyproject.toml``, so project detection never enrolls +it and no workflow runs ``verifier/check_vectors.py``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from b2a_sdk.receipt_verifier import key_set_from_document, verify_bundle + +# sdk/python/tests/ -> repository root is parents[3]. +VECTORS = Path(__file__).resolve().parents[3] / "spec" / "test-vectors" + + +def _load_cases() -> list[dict]: + path = VECTORS / "receipts.json" + if not path.is_file(): + raise FileNotFoundError( + f"published receipt vectors not found at {path}; SDK tests must load spec/test-vectors" + ) + cases = json.loads(path.read_text())["cases"] + if not isinstance(cases, list) or not cases: + raise AssertionError(f"{path} contains no cases") + names = [case["name"] for case in cases] + if len(names) != len(set(names)): + raise AssertionError("receipt vector case names must be unique") + return cases + + +_CASES = _load_cases() +_DEFAULT_KEYS = key_set_from_document(json.loads((VECTORS / "keys.json").read_text())) + + +@pytest.mark.parametrize("case", _CASES, ids=[case["name"] for case in _CASES]) +def test_published_receipt_vector(case): + """Each committed vector must produce the status the corpus names.""" + pytest.importorskip("cryptography") + keys = key_set_from_document(case["keys"]) if "keys" in case else _DEFAULT_KEYS + status = verify_bundle(case["bundle"], keys).status.value + assert status == case["expect"] diff --git a/sdk/python/tests/test_receipt_verifier.py b/sdk/python/tests/test_receipt_verifier.py index fa04ee3d..05557abb 100644 --- a/sdk/python/tests/test_receipt_verifier.py +++ b/sdk/python/tests/test_receipt_verifier.py @@ -119,25 +119,6 @@ def test_key_set_rejects_a_document_with_no_keys_list(): ({"kid": ""}, VerificationStatus.MALFORMED), ({"signing_input": "not json"}, VerificationStatus.MALFORMED), ({"signing_input": "[1,2]"}, VerificationStatus.MALFORMED), - # UNSUPPORTED must be driven by what the SIGNER declared, inside the - # signed bytes -- never by the envelope. These still resolve before any - # signature check, so they need no key material. - ( - {"signing_input": json.dumps({"receipt_id": "rcpt-1", "kid": "key-1", "alg": "RS256"})}, - VerificationStatus.UNSUPPORTED, - ), - ( - { - "signing_input": json.dumps( - { - "receipt_id": "rcpt-1", - "kid": "key-1", - "canonicalization": "something-else/9", - } - ) - }, - VerificationStatus.UNSUPPORTED, - ), ], ) def test_unusable_bundles_report_why(overrides, expected): @@ -299,3 +280,99 @@ def test_human_readable_cli_omits_reason_for_success_receipt(monkeypatch, capsys ) assert "\n reason " not in output + + +def _signed_bundle(payload: dict, *, private_key, kid: str = "key-1") -> dict: + signing_input = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + signature = base64.b64encode(private_key.sign(signing_input.encode("utf-8"))).decode() + return _bundle(signing_input=signing_input, signature=signature, kid=kid) + + +def test_edited_algorithm_or_canonicalization_is_invalid_not_unsupported(): + """A one-byte edit inside signing_input must not become "unsupported". + + alg and canonicalization are only the signer's claim after the signature + over those exact bytes checks out. Returning UNSUPPORTED before that check + turned a tampered receipt into "this verifier is too old". + """ + pytest.importorskip("cryptography") + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + private_key = Ed25519PrivateKey.generate() + public = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + kid = "key-1" + keys = {kid: public} + payload = {"receipt_id": "rcpt-1", "kid": kid, "alg": "Ed25519", "tool": "search"} + genuine = _signed_bundle(payload, private_key=private_key, kid=kid) + assert verify_bundle(genuine, keys).status is VerificationStatus.VERIFIED + + flipped_alg = genuine["signing_input"].replace('"alg":"Ed25519"', '"alg":"RS256"', 1) + alg_result = verify_bundle(_bundle(**{**genuine, "signing_input": flipped_alg}), keys) + assert alg_result.status is VerificationStatus.INVALID + assert alg_result.is_tampered + assert not alg_result.ok + + flipped_canon = genuine["signing_input"].replace( + '"alg":"Ed25519"', + '"alg":"Ed25519","canonicalization":"other/9"', + 1, + ) + canon_result = verify_bundle(_bundle(**{**genuine, "signing_input": flipped_canon}), keys) + assert canon_result.status is VerificationStatus.INVALID + assert canon_result.is_tampered + + declared_alg = _signed_bundle({**payload, "alg": "RS256"}, private_key=private_key, kid=kid) + declared_alg_result = verify_bundle(declared_alg, keys) + assert declared_alg_result.status is VerificationStatus.UNSUPPORTED + assert not declared_alg_result.is_tampered + assert not declared_alg_result.ok + + declared_canon = _signed_bundle( + {**payload, "canonicalization": "other/9"}, + private_key=private_key, + kid=kid, + ) + declared_canon_result = verify_bundle(declared_canon, keys) + assert declared_canon_result.status is VerificationStatus.UNSUPPORTED + assert not declared_canon_result.is_tampered + + +def test_undecodable_bundle_is_undetermined_not_tampered(tmp_path, capsys): + """A non-UTF-8 bundle (e.g. UTF-16 from a PowerShell redirect) must exit 2. + + Regression test: _read_json opens with encoding="utf-8", and the bundle + load only caught OSError/JSONDecodeError, so a UnicodeDecodeError escaped + as a traceback with interpreter exit 1 -- the "tampered" code. + """ + bundle_path = tmp_path / "bundle.json" + bundle_path.write_bytes('{"a": 1}'.encode("utf-16")) + keys_path = tmp_path / "keys.json" + keys_path.write_text(json.dumps({"keys": []})) + + exit_code = verify_cli.main(["--bundle", str(bundle_path), "--keys", str(keys_path)]) + + assert exit_code == verify_cli.EXIT_UNDETERMINED + assert "could not read bundle" in capsys.readouterr().err + + +def test_deeply_nested_bundle_is_undetermined_not_tampered(tmp_path, monkeypatch, capsys): + """A bundle that exhausts the JSON parser recursion limit must exit 2.""" + bundle_path = tmp_path / "bundle.json" + bundle_path.write_text("[]") + keys_path = tmp_path / "keys.json" + keys_path.write_text(json.dumps({"keys": []})) + + real_load = json.load + + def exploding_load(handle): + if getattr(handle, "name", "") == str(bundle_path): + raise RecursionError("maximum recursion depth exceeded") + return real_load(handle) + + monkeypatch.setattr(json, "load", exploding_load) + + exit_code = verify_cli.main(["--bundle", str(bundle_path), "--keys", str(keys_path)]) + + assert exit_code == verify_cli.EXIT_UNDETERMINED + assert "could not read bundle" in capsys.readouterr().err diff --git a/sdk/python/tests/test_receipt_verifier_signed.py b/sdk/python/tests/test_receipt_verifier_signed.py new file mode 100644 index 00000000..e6548c61 --- /dev/null +++ b/sdk/python/tests/test_receipt_verifier_signed.py @@ -0,0 +1,403 @@ +"""Signed tamper cases for the offline receipt verifier. + +Every test here signs with a throwaway Ed25519 key, so the suite needs no +gateway, no database, and no network: just the SDK plus ``cryptography``. +Each tamper test mutates either the signed bytes or the envelope of a bundle +that verifies untouched, then asserts the verifier rejects it with the right +status. A test that passes here means an outside party holding the same two +JSON documents reaches the same conclusion. + +Money movement, identity binding, and envelope relabelling get priority: +those are the forgeries that would misattribute a charge or a tool call. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json + +import pytest + +from b2a_sdk import verify_cli +from b2a_sdk.receipt_verifier import ( + VerificationError, + VerificationStatus, + canonical_json, + key_set_from_document, + verify_bundle, + verify_ed25519, +) + + +def _local_signer(kid="local-test-key"): + """Return (kid, key_set, sign) for a throwaway Ed25519 key.""" + pytest.importorskip("cryptography") + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + private_key = Ed25519PrivateKey.generate() + raw_public = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + + def sign(signing_input: str) -> str: + return base64.b64encode(private_key.sign(signing_input.encode("utf-8"))).decode() + + return kid, {kid: raw_public}, sign + + +def _payload(**overrides): + body = { + "receipt_id": "rcpt-tamper", + "kid": "local-test-key", + "permit_id": "permit-1", + "wallet_id": "wallet-1", + "key_id": "key-1", + "tool": "partner.echo", + "idempotency_record_id": "idem-1", + "dispatch_attempt_id": "dispatch-1", + "ledger_entry_id": "ledger-1", + "audit_event_id": "audit-1", + "request_hash": "a" * 64, + "response_hash": "b" * 64, + "outcome": "success", + "credits_authorized": "2", + "credits_charged": "2", + "created_at": "2026-08-11T00:00:00Z", + } + body.update(overrides) + inner = {k: v for k, v in body.items() if k != "payload_hash"} + body["payload_hash"] = hashlib.sha256(canonical_json(inner).encode("utf-8")).hexdigest() + return body + + +def _signed_bundle(payload, *, kid, sign, **overrides): + signing_input = canonical_json(payload) + bundle = { + "receipt_id": payload["receipt_id"], + "issuer": "https://api.example.com", + "alg": "Ed25519", + "kid": kid, + "canonicalization": "awi-canonical-json/1", + "signing_input": signing_input, + "signature": sign(signing_input), + } + bundle.update(overrides) + return bundle + + +def _verified_bundle(**payload_overrides): + kid, key_set, sign = _local_signer() + payload = _payload(kid=kid, **payload_overrides) + bundle = _signed_bundle(payload, kid=kid, sign=sign) + result = verify_bundle(bundle, key_set) + assert result.status is VerificationStatus.VERIFIED, result.reason + return bundle, key_set + + +def _retarget(bundle, old, new): + """Edit the signed bytes, proving the edit bites.""" + edited = dict(bundle, signing_input=bundle["signing_input"].replace(old, new, 1)) + assert new in edited["signing_input"] + assert edited["signing_input"] != bundle["signing_input"] + return edited + + +def test_flipping_credits_charged_is_rejected_as_tampering(): + bundle, key_set = _verified_bundle() + + result = verify_bundle( + _retarget(bundle, '"credits_charged":"2"', '"credits_charged":"0"'), key_set + ) + + assert result.status is VerificationStatus.INVALID + assert result.is_tampered + assert result.is_rejected + + +def test_flipping_credits_authorized_is_rejected_as_tampering(): + bundle, key_set = _verified_bundle() + + result = verify_bundle( + _retarget(bundle, '"credits_authorized":"2"', '"credits_authorized":"200"'), key_set + ) + + assert result.status is VerificationStatus.INVALID + assert result.is_tampered + + +def test_flipping_outcome_success_to_denied_is_rejected(): + """Outcome decides whether money moved; rewriting it is forgery.""" + bundle, key_set = _verified_bundle() + + result = verify_bundle(_retarget(bundle, '"outcome":"success"', '"outcome":"denied"'), key_set) + + assert result.status is VerificationStatus.INVALID + assert result.is_tampered + + +@pytest.mark.parametrize( + "field,old,new", + [ + ("tool", "partner.echo", "partner.delete"), + ("wallet_id", "wallet-1", "wallet-2"), + ("key_id", "key-1", "key-2"), + ("permit_id", "permit-1", "permit-2"), + ("ledger_entry_id", "ledger-1", "ledger-9"), + ("idempotency_record_id", "idem-1", "idem-9"), + ("dispatch_attempt_id", "dispatch-1", "dispatch-9"), + ("audit_event_id", "audit-1", "audit-9"), + ("request_hash", "a" * 64, "c" * 64), + ], +) +def test_relabeling_signed_identity_fields_is_rejected(field, old, new): + bundle, key_set = _verified_bundle() + + result = verify_bundle(_retarget(bundle, f'"{field}":"{old}"', f'"{field}":"{new}"'), key_set) + + assert result.status is VerificationStatus.INVALID, field + assert result.is_tampered, field + + +def test_reserialized_bytes_with_identical_json_are_rejected(): + """The signature covers the bytes verbatim, never a re-serialization.""" + bundle, key_set = _verified_bundle() + reserialized = json.dumps(json.loads(bundle["signing_input"]), indent=2, sort_keys=True) + assert json.loads(reserialized) == json.loads(bundle["signing_input"]) + assert reserialized != bundle["signing_input"] + + result = verify_bundle(dict(bundle, signing_input=reserialized), key_set) + + assert result.status is VerificationStatus.INVALID + assert result.is_tampered + + +def test_unicode_escape_flip_is_rejected(): + bundle, key_set = _verified_bundle(tool="caf\u00e9-echo") + assert "\\u00e9" in bundle["signing_input"] + + result = verify_bundle(_retarget(bundle, "\\u00e9", "\u00e9"), key_set) + + assert result.status is VerificationStatus.INVALID + assert result.is_tampered + + +@pytest.mark.parametrize("index", [0, -1]) +def test_single_bit_signature_flip_is_rejected(index): + bundle, key_set = _verified_bundle() + raw = bytearray(base64.b64decode(bundle["signature"])) + raw[index] ^= 0x01 + + result = verify_bundle(dict(bundle, signature=base64.b64encode(bytes(raw)).decode()), key_set) + + assert result.status is VerificationStatus.INVALID + assert result.is_tampered + + +def test_truncated_signing_input_is_not_verified(): + bundle, key_set = _verified_bundle() + + result = verify_bundle(dict(bundle, signing_input=bundle["signing_input"][:-1]), key_set) + + assert result.status is VerificationStatus.MALFORMED + assert not result.ok + assert not result.is_tampered + + +def test_envelope_receipt_id_relabel_is_a_mismatch_with_signed_id_reported(): + bundle, key_set = _verified_bundle() + + result = verify_bundle(dict(bundle, receipt_id="rcpt_not_the_signed_one"), key_set) + + assert result.status is VerificationStatus.MISMATCH + assert result.is_rejected + assert not result.is_tampered + assert result.receipt_id == "rcpt-tamper" + + +def test_signed_under_a_key_the_payload_does_not_name_is_a_mismatch(): + kid, key_set, sign = _local_signer() + payload = _payload(kid="a-different-key") + bundle = _signed_bundle(payload, kid=kid, sign=sign) + + result = verify_bundle(bundle, key_set) + + assert result.status is VerificationStatus.MISMATCH + assert result.is_rejected + assert not result.is_tampered + + +def test_unknown_signed_kid_with_unknown_envelope_is_unknown_key(): + bundle, key_set = _verified_bundle() + assert "no-such-key" not in key_set + + result = verify_bundle( + _retarget(bundle, '"kid":"local-test-key"', '"kid":"no-such-key"'), + {"other-key": b"\x00" * 32}, + ) + + assert result.status is VerificationStatus.UNKNOWN_KEY + assert not result.is_tampered + assert not result.is_rejected + + +def test_rejected_bundle_carries_no_claims_or_receipt_id(): + bundle, key_set = _verified_bundle() + + result = verify_bundle(_retarget(bundle, '"outcome":"success"', '"outcome":"denied"'), key_set) + + assert result.status is VerificationStatus.INVALID + assert result.claims == {} + assert result.receipt_id is None + + +def test_matching_expected_issuer_still_verifies(): + bundle, key_set = _verified_bundle() + + result = verify_bundle(bundle, key_set, expected_issuer="https://api.example.com") + + assert result.status is VerificationStatus.VERIFIED + assert result.ok + + +def test_payload_without_payload_hash_verifies(): + kid, key_set, sign = _local_signer() + payload = _payload(kid=kid) + del payload["payload_hash"] + + result = verify_bundle(_signed_bundle(payload, kid=kid, sign=sign), key_set) + + assert result.status is VerificationStatus.VERIFIED, result.reason + + +def test_extra_envelope_fields_are_ignored(): + bundle, key_set = _verified_bundle() + bundle = {**bundle, "keys_url": "/.well-known/trust-keys.json", "future_field": {"n": 1}} + + result = verify_bundle(bundle, key_set) + + assert result.status is VerificationStatus.VERIFIED, result.reason + + +def test_bundle_accepted_as_bytes(): + bundle, key_set = _verified_bundle() + + result = verify_bundle(json.dumps(bundle).encode("utf-8"), key_set) + + assert result.status is VerificationStatus.VERIFIED, result.reason + + +def test_verify_ed25519_accepts_genuine_and_rejects_altered_bytes(): + pytest.importorskip("cryptography") + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + private_key = Ed25519PrivateKey.generate() + raw_public = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + message = b'{"receipt_id":"rcpt-1"}' + signature = private_key.sign(message) + + assert verify_ed25519(raw_public, signature, message) is True + assert verify_ed25519(raw_public, signature, message + b" ") is False + + +def test_key_set_accepts_key_id_mirror(): + document = {"keys": [{"key_id": "mirror", "public_key_b64": "A" * 43 + "="}]} + + assert set(key_set_from_document(document)) == {"mirror"} + + +def test_key_set_empty_raw_falls_back_to_jwk_mirror(): + document = { + "keys": [ + { + "kid": "jwk-fallback", + "alg": "Ed25519", + "public_key_b64": "", + "jwk": {"kty": "OKP", "crv": "Ed25519", "x": "B" * 43}, + } + ] + } + + keys = key_set_from_document(document) + + assert set(keys) == {"jwk-fallback"} + assert len(keys["jwk-fallback"]) == 32 + + +@pytest.mark.parametrize( + "entry", + [ + {"kid": "wrong-alg", "alg": "RS256", "public_key_b64": "A" * 43 + "="}, + { + "kid": "wrong-crv", + "alg": "Ed25519", + "jwk": {"kty": "OKP", "crv": "P-256", "x": "B" * 43}, + }, + ], + ids=["non_ed25519_alg", "non_ed25519_crv"], +) +def test_key_set_drops_keys_it_cannot_verify_with(entry): + keys = key_set_from_document({"keys": [entry]}) + + assert keys == {} + + +def test_key_set_rejects_a_non_object_document(): + with pytest.raises(VerificationError): + key_set_from_document(["not", "a", "dict"]) + + +def _write_cli_inputs(tmp_path, bundle, key_set): + bundle_path = tmp_path / "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( + { + "keys": [ + { + "kid": kid, + "alg": "Ed25519", + "status": "active", + "public_key_b64": base64.b64encode(raw).decode(), + } + for kid, raw in key_set.items() + ] + } + ), + encoding="utf-8", + ) + return str(bundle_path), str(keys_path) + + +def test_cli_envelope_mismatch_exits_1_not_2(tmp_path, capsys): + """A caught relabelling is a verdict on the bundle, not an outage.""" + bundle, key_set = _verified_bundle() + bundle_path, keys_path = _write_cli_inputs( + tmp_path, dict(bundle, receipt_id="rcpt_not_the_signed_one"), key_set + ) + + assert verify_cli.main(["--bundle", bundle_path, "--keys", keys_path]) == 1 + assert "MISMATCH" in capsys.readouterr().err + + +def test_cli_malformed_bundle_exits_2(tmp_path, capsys): + bundle_path = tmp_path / "bundle.json" + bundle_path.write_text("{oops", encoding="utf-8") + keys_path = tmp_path / "trust-keys.json" + keys_path.write_text(json.dumps({"keys": []}), encoding="utf-8") + + assert verify_cli.main(["--bundle", str(bundle_path), "--keys", str(keys_path)]) == 2 + + +def test_cli_json_output_for_rejected_bundle(tmp_path, capsys): + bundle, key_set = _verified_bundle() + forged = _retarget(bundle, '"outcome":"success"', '"outcome":"denied"') + bundle_path, keys_path = _write_cli_inputs(tmp_path, forged, key_set) + + assert verify_cli.main(["--bundle", bundle_path, "--keys", keys_path, "--json"]) == 1 + + emitted = json.loads(capsys.readouterr().out) + assert emitted["ok"] is False + assert emitted["status"] == "invalid" + assert emitted["claims"] == {} diff --git a/sdk/python/tests/test_sdk.py b/sdk/python/tests/test_sdk.py index 8adb5382..ba38ffd4 100644 --- a/sdk/python/tests/test_sdk.py +++ b/sdk/python/tests/test_sdk.py @@ -4,6 +4,7 @@ import asyncio import json +import warnings from unittest.mock import AsyncMock, MagicMock import httpx @@ -18,6 +19,8 @@ combined, monitored, ) +from b2a_sdk.edge_client import B2AEdgeClient +from b2a_sdk.errors import APIError def _recording_client( @@ -62,7 +65,8 @@ async def test_charge_success(self): """charge() posts the wallet, service and units as query params.""" client, seen = _recording_client(_CHARGE_OK) async with client: - await client.charge("wallet-123", "iot_bridge", units=10) + with pytest.warns(UserWarning, match="not replay-safe"): + await client.charge("wallet-123", "iot_bridge", units=10) assert len(seen) == 1 request = seen[0] @@ -82,12 +86,13 @@ async def test_charge_success(self): async def test_charge_sends_optional_request_path_and_description(self): client, seen = _recording_client(_CHARGE_OK) async with client: - await client.charge( - "wallet-123", - "iot_bridge", - request_path="/tools/run", - description="nightly batch", - ) + with pytest.warns(UserWarning, match="not replay-safe"): + await client.charge( + "wallet-123", + "iot_bridge", + request_path="/tools/run", + description="nightly batch", + ) params = dict(seen[0].url.params) assert params["request_path"] == "/tools/run" @@ -109,6 +114,21 @@ async def test_charge_forwards_idempotency_key(self): assert seen[0].headers["idempotency-key"] == "charge-key-1" assert dict(seen[0].url.params)["units"] == "10" + @pytest.mark.asyncio + async def test_charge_with_key_emits_no_warning(self): + """A keyed charge is replay-safe, so it stays silent.""" + client, seen = _recording_client(_CHARGE_OK) + async with client: + with warnings.catch_warnings(): + warnings.simplefilter("error") + await client.charge( + "wallet-123", + "iot_bridge", + idempotency_key="charge-key-1", + ) + + assert seen[0].headers["idempotency-key"] == "charge-key-1" + @pytest.mark.asyncio @pytest.mark.parametrize( ("bad_key", "message"), @@ -158,11 +178,80 @@ async def test_charge_insufficient_funds(self): ) async with client: with pytest.raises(InsufficientFundsError) as exc_info: - await client.charge("wallet-123", "iot_bridge", units=100) + with pytest.warns(UserWarning, match="not replay-safe"): + await client.charge("wallet-123", "iot_bridge", units=100) assert exc_info.value.wallet_id == "wallet-123" assert exc_info.value.shortfall == 100.0 + @pytest.mark.asyncio + async def test_charge_uses_the_server_top_up_url(self): + """The 402 body carries the real top-up path. Do not invent one.""" + client, _ = _recording_client( + { + ("POST", "/v1/billing/charge"): httpx.Response( + 402, + json={ + "detail": { + "error": "insufficient_funds", + "shortfall": 1.5, + "top_up_url": "/v1/billing/top-up/prepare", + "wallet_id": "wallet-123", + } + }, + ) + } + ) + async with client: + with pytest.raises(InsufficientFundsError) as exc_info: + await client.charge("wallet-123", "iot_bridge") + + err = exc_info.value + assert err.top_up_url == "/v1/billing/top-up/prepare" + assert "/dashboard/top-up" not in str(err) + assert err.shortfall == 1.5 + assert err.payload["detail"]["top_up_url"] == "/v1/billing/top-up/prepare" + + @pytest.mark.asyncio + async def test_charge_402_string_detail_is_typed_not_a_crash(self): + """A 402 whose detail is a string is still insufficient funds.""" + client, _ = _recording_client( + { + ("POST", "/v1/billing/charge"): httpx.Response( + 402, + json={"detail": "insufficient_funds"}, + ) + } + ) + async with client: + with pytest.raises(InsufficientFundsError) as exc_info: + await client.charge("wallet-123", "iot_bridge") + + assert exc_info.value.wallet_id == "wallet-123" + assert exc_info.value.shortfall is None + + @pytest.mark.asyncio + async def test_charge_402_invalid_json_is_an_api_error(self): + client, _ = _recording_client( + {("POST", "/v1/billing/charge"): httpx.Response(402, content=b"nope")} + ) + async with client: + with pytest.raises(APIError, match="invalid_json_response"): + await client.charge("wallet-123", "iot_bridge") + + @pytest.mark.asyncio + async def test_charge_rejects_idempotency_key_that_would_split_headers(self): + """A key with a newline must not be sent. It would inject headers.""" + client, seen = _recording_client(_CHARGE_OK) + async with client: + with pytest.raises(ValueError, match="control characters"): + await client.charge( + "wallet-123", + "iot_bridge", + idempotency_key="job-1\r\nX-Injected: yes", + ) + assert seen == [] + @pytest.mark.asyncio async def test_charge_cross_wallet_denial_still_raises(self): """A 403 (wallet the key does not own) is not swallowed, with or without a key.""" @@ -175,7 +264,8 @@ async def test_charge_cross_wallet_denial_still_raises(self): ) async with client: with pytest.raises(httpx.HTTPStatusError): - await client.charge("someone-elses-wallet", "iot_bridge") + with pytest.warns(UserWarning, match="not replay-safe"): + await client.charge("someone-elses-wallet", "iot_bridge") with pytest.raises(httpx.HTTPStatusError): await client.charge( "someone-elses-wallet", "iot_bridge", idempotency_key="charge-key-1" @@ -545,3 +635,30 @@ def test_error_unknown_shortfall(self): ) assert error.shortfall is None + + +class TestHeaderInjection: + """Caller-controlled header values must not be able to split the request.""" + + @pytest.mark.asyncio + async def test_edge_execute_rejects_header_injection_before_sending(self): + edge = B2AEdgeClient(api_url="http://test", api_key="test-key", timeout=0.1) + original = edge._client + edge._client = AsyncMock() + try: + with pytest.raises(ValueError, match="control characters"): + await edge.execute_awi_action( + "session-1", + "click", + {}, + permit_id="permit-1", + idempotency_key="job\r\nX-Injected: yes", + ) + edge._client.post.assert_not_called() + finally: + edge._client = original + await edge.close() + + def test_edge_api_key_rejects_header_injection(self): + with pytest.raises(ValueError, match="control characters"): + B2AEdgeClient(api_key="sk\r\nX-Injected: yes") diff --git a/sdk/python/tests/test_sync_client.py b/sdk/python/tests/test_sync_client.py new file mode 100644 index 00000000..66b7c5ef --- /dev/null +++ b/sdk/python/tests/test_sync_client.py @@ -0,0 +1,139 @@ +"""Sync wrapper tests run as plain blocking code: no event loop here.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import httpx + +from b2a_sdk import PermitRequest, SyncAgentMiddlewareClient + + +def _permit_payload() -> dict: + now = datetime.now(timezone.utc) + return { + "permit_id": "permit-1", + "issuer_wallet_id": "wallet-1", + "subject_wallet_id": "wallet-1", + "subject_key_id": "key-1", + "scopes": ["tool:partner.search:invoke", "billing:charge"], + "allowed_tools": ["partner.search"], + "max_credits": "25", + "spent_credits": "2", + "expires_at": (now + timedelta(minutes=30)).isoformat(), + "nonce": "nonce-1", + "status": "active", + "signature": "permit-signature", + "key_id": "signing-key-1", + "issued_at": now.isoformat(), + "revoked_at": None, + } + + +def _receipt_payload() -> dict: + return { + "receipt_id": "receipt-success", + "permit_id": "permit-1", + "wallet_id": "wallet-1", + "key_id": "key-1", + "tool": "partner.search", + "request_hash": "request-hash", + "response_hash": "response-hash", + "ledger_entry_id": "ledger-1", + "dispatch_attempt_id": "dispatch-1", + "credits_authorized": "2", + "credits_charged": "2", + "outcome": "success", + "audit_event_id": "audit-1", + "created_at": datetime.now(timezone.utc).isoformat(), + "signature": "receipt-signature", + "signature_key_id": "signing-key-1", + } + + +def test_sync_default_base_url_is_localhost() -> None: + client = SyncAgentMiddlewareClient(api_key="test-key") + try: + assert client.base_url == "http://localhost:8000" + finally: + client.close() + + +def test_sync_discover_tools_needs_no_event_loop() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/mcp/tools.json" + return httpx.Response( + 200, + json={ + "tools": [ + { + "name": "partner.search", + "description": "Search partner data", + "inputSchema": {"type": "object"}, + } + ] + }, + ) + + with SyncAgentMiddlewareClient( + api_key="test-key", + base_url="http://test", + transport=httpx.MockTransport(handler), + ) as client: + tools = client.discover_tools() + + assert [tool.name for tool in tools] == ["partner.search"] + + +def test_sync_permit_and_invoke_round_trip() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/permits": + assert request.headers["idempotency-key"] == "permit-key-1" + return httpx.Response(201, json=_permit_payload()) + assert request.url.path == "/mcp/messages" + body = json.loads(request.content) + assert body["params"]["mcpContext"]["idempotency_key"] == "invoke-key-1" + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": "invoke-key-1", + "result": { + "content": [{"type": "text", "text": '{"ok": true}'}], + "isError": False, + "receipt": _receipt_payload(), + }, + }, + ) + + with SyncAgentMiddlewareClient( + api_key="test-key", + base_url="http://test", + transport=httpx.MockTransport(handler), + ) as client: + permit = client.create_permit( + PermitRequest( + issuer_wallet_id="wallet-1", + subject_wallet_id="wallet-1", + subject_key_id="key-1", + scopes=["tool:partner.search:invoke", "billing:charge"], + allowed_tools=["partner.search"], + max_credits=Decimal("25"), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=30), + ), + idempotency_key="permit-key-1", + ) + result = client.invoke_tool( + "partner.search", + {"query": "risk"}, + wallet_id="wallet-1", + permit_id=permit.permit_id, + idempotency_key="invoke-key-1", + ) + + assert permit.permit_id == "permit-1" + assert result.receipt.receipt_id == "receipt-success" + + assert client.api_key == "test-key" diff --git a/sdk/python/tests/test_trust_client.py b/sdk/python/tests/test_trust_client.py index 8a7bca93..332739ca 100644 --- a/sdk/python/tests/test_trust_client.py +++ b/sdk/python/tests/test_trust_client.py @@ -18,6 +18,7 @@ PermitRequest, TransportError, ) +from b2a_sdk.errors import APIError from b2a_sdk.x402 import X402Client @@ -72,10 +73,11 @@ def _client(handler) -> AgentMiddlewareClient: @pytest.mark.asyncio -async def test_default_base_url_is_the_live_canonical_api() -> None: +async def test_default_base_url_is_localhost_never_a_hosted_server() -> None: client = AgentMiddlewareClient(api_key="test-key") try: - assert client.base_url == "https://api.thisisatest.tech" + assert client.base_url == "http://localhost:8000" + assert "thisisatest" not in client.base_url finally: await client.close() @@ -208,6 +210,68 @@ async def handler(request: httpx.Request) -> httpx.Response: assert result.structured_content == {"ok": True} +@pytest.mark.asyncio +async def test_invoke_tool_rejects_a_receipt_for_a_different_call() -> None: + """A success body whose receipt names another tool is not this call's evidence.""" + swapped = _receipt_payload() + swapped["tool"] = "other.tool" + + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": "invoke-key-1", + "result": { + "content": [{"type": "text", "text": "{}"}], + "isError": False, + "receipt": swapped, + }, + }, + ) + + async with _client(handler) as client: + with pytest.raises(APIError, match="mcp_receipt_does_not_match_request"): + await client.invoke_tool( + "partner.search", + {}, + wallet_id="wallet-1", + permit_id="permit-1", + idempotency_key="invoke-key-1", + ) + + +@pytest.mark.asyncio +async def test_get_receipt_rejects_a_body_for_a_different_id() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + body = _receipt_payload() + body["receipt_id"] = "receipt-other" + return httpx.Response(200, json=body) + + async with _client(handler) as client: + with pytest.raises(APIError, match="receipt_id_mismatch"): + await client.get_receipt("receipt-success") + + +@pytest.mark.asyncio +async def test_evidence_rejects_an_envelope_id_that_disagrees_with_the_receipt() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "receipt_id": "receipt-other", + "valid": True, + "receipt": _receipt_payload(), + "permit": None, + "verification": {"receipt_signature": "ok"}, + }, + ) + + async with _client(handler) as client: + with pytest.raises(APIError, match="evidence_receipt_id_mismatch"): + await client.get_evidence("receipt-success") + + @pytest.mark.asyncio async def test_delivery_uncertain_error_carries_signed_receipt_id() -> None: uncertain_receipt = _receipt_payload("delivery_uncertain") diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 3ba97fcc..90382358 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -1,12 +1,12 @@ # @agent-middleware/verify (TypeScript) -License: Apache-2.0. Status: source only, not published to npm. +License: Apache-2.0. Status: not published to npm. Offline verification of Agent Middleware receipt bundles, using Node's built-in Ed25519 support. No runtime dependencies and no network access. ```ts -import { keySetFromDocument, verifyBundle } from "./src/verify.ts"; +import { keySetFromDocument, verifyBundle } from "@agent-middleware/verify"; const keys = keySetFromDocument(JSON.parse(trustKeysJson)); // /.well-known/trust-keys.json const result = verifyBundle(JSON.parse(bundleJson), keys); @@ -14,12 +14,18 @@ if (result.ok) console.log(result.claims?.tool, result.claims?.credits_charged); else console.log(result.status, result.reason); // invalid | mismatch | unknown_key | malformed | unsupported ``` +From a checkout, build first (`npm run build` emits `dist/`) or import +the source directly (`./src/verify.ts`); both expose the same surface. + Semantics match the Python verifier in `../python` and the format in [`../../spec/receipt-v0.1.md`](../../spec/receipt-v0.1.md). ```bash -npm test # runs the shared spec/test-vectors (Node 22.18+ runs .ts directly) -npm run typecheck # optional, needs `npm install` +npm install # dev dependencies only (typescript, @types/node) +npm run build # emits dist/verify.js plus types; what a consumer imports +npm run lint # checks types and unused code +npm test # builds, then runs verifier and package export tests (Node 22.18+ runs .ts directly) +npm run typecheck # optional ``` A TypeScript client for the gateway API is not written yet. diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 00000000..7abbfec0 --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,412 @@ +{ + "name": "@agent-middleware/verify", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@agent-middleware/verify", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^26.6.4", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index c23d9686..6c044f09 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -4,17 +4,33 @@ "private": true, "description": "Offline verifier for Agent Middleware signed receipts (Ed25519, no network).", "type": "module", - "exports": "./src/verify.ts", + "main": "./dist/verify.js", + "types": "./dist/verify.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "exports": { + ".": { + "types": "./dist/verify.d.ts", + "default": "./dist/verify.js" + } + }, "engines": { "node": ">=22.18" }, "scripts": { - "test": "node --test test/*.test.ts", + "build": "tsc -p tsconfig.build.json", + "lint": "tsc --noEmit --noUnusedLocals --noUnusedParameters", + "prepublishOnly": "npm run build && npm test", + "pretest": "npm run build", + "test": "node --test test/*.test.ts test/*.test.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { "typescript": "^7.0.2", - "@types/node": "^22.0.0" + "@types/node": "^26.6.4" }, "license": "Apache-2.0" } diff --git a/sdk/typescript/src/verify.ts b/sdk/typescript/src/verify.ts index ede5983f..fff5af76 100644 --- a/sdk/typescript/src/verify.ts +++ b/sdk/typescript/src/verify.ts @@ -84,6 +84,40 @@ export function keySetFromDocument(doc: unknown): KeySet { return keys; } +/** True when decoded JSON contains a lone Unicode surrogate. */ +function hasLoneSurrogate(value: unknown): boolean { + const pending: unknown[] = [value]; + const seen = new Set(); + while (pending.length > 0) { + const current = pending.pop(); + if (typeof current === "string") { + for (let i = 0; i < current.length; i++) { + const code = current.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + const next = current.charCodeAt(i + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + i += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true; + } + } + } else if (Array.isArray(current)) { + if (seen.has(current)) continue; + seen.add(current); + for (const item of current) pending.push(item); + } else if (current !== null && typeof current === "object") { + if (seen.has(current)) continue; + seen.add(current); + const obj = current as Record; + for (const key of Object.keys(obj)) { + pending.push(key); + pending.push(obj[key]); + } + } + } + return false; +} + function edOk(raw: Buffer, sig: Buffer, msg: Buffer): boolean { try { const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") }, format: "jwk" }); @@ -95,6 +129,13 @@ function edOk(raw: Buffer, sig: Buffer, msg: Buffer): boolean { /** Verify one portable receipt bundle against a caller-supplied key set. */ export function verifyBundle(bundle: unknown, keySet: KeySet, expectedIssuer?: string): VerificationResult { + if (typeof bundle === "string") { + try { + bundle = JSON.parse(bundle); + } catch { + return fail("malformed", "bundle is not valid JSON"); + } + } if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) return fail("malformed", "bundle must be a JSON object"); const b = bundle as Record; const signingInput = b.signing_input; @@ -112,13 +153,20 @@ export function verifyBundle(bundle: unknown, keySet: KeySet, expectedIssuer?: s return fail("malformed", "signing_input is not valid JSON", envKid); } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return fail("malformed", "signing_input is not a JSON object", envKid); + // Same order as the Python verifier: unreadable text is malformed, and + // that decision is made before any signature check. A bad signature must + // not turn "cannot read the text" into "tampered". + if (hasLoneSurrogate(payload)) { + return fail("malformed", "signing_input contains invalid Unicode text", envKid); + } const p = payload as Record; const kid = p.kid; if (typeof kid !== "string" || !kid) return fail("malformed", "signed payload has no kid", envKid); - const alg = p.alg ?? "Ed25519"; - if (alg !== "Ed25519") return fail("unsupported", `unsupported signature algorithm: ${String(alg)}`, kid); + // Judge alg and canonicalization only after the signature check. Until + // then they are bytes an editor can change, and treating them as a + // capability gap ("unsupported") hides tampering. + const alg = p.alg === undefined ? "Ed25519" : p.alg; const signedCanon = p.canonicalization; - if (typeof signedCanon === "string" && signedCanon !== CANONICALIZATION) return fail("unsupported", `unsupported canonicalization: ${signedCanon}`, kid); const sig = b64(sigB64); if (!sig) return fail("malformed", "signature is not valid base64", kid); @@ -126,26 +174,47 @@ export function verifyBundle(bundle: unknown, keySet: KeySet, expectedIssuer?: s const msg = Buffer.from(signingInput, "utf8"); const raw = keySet.get(kid); + // A key that is present but the wrong length cannot be judged. Reporting + // that as invalid would call the receipt tampered. + if (raw && raw.length !== RAW_KEY_LEN) { + return fail("malformed", `public key for kid ${kid} is not a 32-byte Ed25519 key`, kid); + } if (!raw) { const envKey = envKid !== kid ? keySet.get(envKid) : undefined; - if (envKey && edOk(envKey, sig, msg)) return fail("mismatch", `signed under kid ${envKid} but the payload names kid ${kid}`, kid); + if (envKey && envKey.length === RAW_KEY_LEN && edOk(envKey, sig, msg)) { + return fail("mismatch", `signed under kid ${envKid} but the payload names kid ${kid}`, kid); + } return fail("unknown_key", `no published key for kid ${kid}`, kid); } + if (raw.length !== RAW_KEY_LEN) return fail("malformed", `public key for kid ${kid} is not a 32-byte Ed25519 key`, kid); if (!edOk(raw, sig, msg)) return fail("invalid", "signature does not verify over signing_input", kid); + if (alg !== "Ed25519") return fail("unsupported", `unsupported signature algorithm: ${String(alg)}`, kid); + if (typeof signedCanon === "string" && signedCanon !== CANONICALIZATION) { + return fail("unsupported", `unsupported canonicalization: ${signedCanon}`, kid); + } + if (envKid !== kid) return fail("mismatch", "bundle names a different kid than the signed payload", kid); - if ((b.alg ?? "Ed25519") !== alg) return fail("mismatch", "bundle algorithm differs from the signed payload", kid); + const envelopeAlg = b.alg === undefined ? "Ed25519" : b.alg; + if (envelopeAlg !== alg) return fail("mismatch", "bundle algorithm differs from the signed payload", kid); const expectedCanon = typeof signedCanon === "string" ? signedCanon : CANONICALIZATION; - if ((b.canonicalization ?? CANONICALIZATION) !== expectedCanon) return fail("mismatch", "bundle canonicalization differs from the signed payload", kid); + const envelopeCanon = b.canonicalization === undefined ? CANONICALIZATION : b.canonicalization; + if (envelopeCanon !== expectedCanon) return fail("mismatch", "bundle canonicalization differs from the signed payload", kid); if (typeof p.payload_hash === "string") { const inner: Record = { ...p }; delete inner.payload_hash; - const computed = createHash("sha256").update(canonicalJson(inner), "utf8").digest("hex"); + let canonical: string; + try { + canonical = canonicalJson(inner); + } catch { + return fail("malformed", "signed payload exceeds the supported JSON nesting depth", kid); + } + const computed = createHash("sha256").update(canonical, "utf8").digest("hex"); if (computed !== p.payload_hash) return fail("mismatch", "payload_hash does not match the signed payload", kid); } const receiptId = p.receipt_id; if (typeof receiptId !== "string" || !receiptId) return fail("malformed", "signed payload has no receipt_id", kid); - if (b.receipt_id !== undefined && b.receipt_id !== receiptId) return fail("mismatch", "bundle receipt_id does not match the signed receipt_id", kid, receiptId); + if (b.receipt_id !== undefined && b.receipt_id !== null && b.receipt_id !== receiptId) return fail("mismatch", "bundle receipt_id does not match the signed receipt_id", kid, receiptId); return { status: "verified", ok: true, receiptId, keyId: kid, claims: p }; } diff --git a/sdk/typescript/test/package-exports.test.mjs b/sdk/typescript/test/package-exports.test.mjs new file mode 100644 index 00000000..661880b4 --- /dev/null +++ b/sdk/typescript/test/package-exports.test.mjs @@ -0,0 +1,8 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { verifyBundle } from "@agent-middleware/verify"; + +test("package name imports the built verifier", () => { + assert.match(import.meta.resolve("@agent-middleware/verify"), /\/dist\/verify\.js$/); + assert.equal(verifyBundle({}, new Map()).status, "malformed"); +}); diff --git a/sdk/typescript/test/vectors.test.ts b/sdk/typescript/test/vectors.test.ts index aa4d297c..2f30d4c9 100644 --- a/sdk/typescript/test/vectors.test.ts +++ b/sdk/typescript/test/vectors.test.ts @@ -19,3 +19,14 @@ for (const c of cases) { test("canonical JSON sorts keys and escapes non-ASCII", () => { assert.equal(canonicalJson({ b: 1, a: ["é", null] }), '{"a":["\\u00e9",null],"b":1}'); }); + +test("a caller-supplied key of the wrong length is malformed, not invalid", () => { + const valid = cases.find((c: any) => c.expect === "verified"); + const signedKid = JSON.parse(valid.bundle.signing_input).kid; + const shortKey = new Map([[signedKid, Buffer.alloc(16)]]); + + // A broken caller key must read as "this verifier cannot judge", matching + // the Python verifier. Reporting it as invalid would allege forgery for a + // local configuration error. + assert.equal(verifyBundle(valid.bundle, shortKey).status, "malformed"); +}); diff --git a/sdk/typescript/test/verifier-edges.test.ts b/sdk/typescript/test/verifier-edges.test.ts new file mode 100644 index 00000000..4484f449 --- /dev/null +++ b/sdk/typescript/test/verifier-edges.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { verifyBundle } from "../src/verify.ts"; + +function bundle(signingInput: string, signature = Buffer.alloc(64).toString("base64")) { + return { + signing_input: signingInput, + signature, + kid: "k1", + alg: "Ed25519", + canonicalization: "awi-canonical-json/1", + }; +} + +test("a lone surrogate is malformed, not a bad signature", () => { + // Python rejects this before it looks at the signature. A bad signature + // must not turn "cannot read the text" into "tampered". + const signing = '{"kid":"k1","receipt_id":"r1","note":"\\ud800"}'; + const result = verifyBundle(bundle(signing), new Map([["k1", Buffer.alloc(32, 1)]])); + assert.equal(result.status, "malformed"); + assert.equal(result.ok, false); +}); + +test("a public key that is not 32 bytes is malformed, not tampered", () => { + const signing = '{"kid":"k1","receipt_id":"r1"}'; + const result = verifyBundle(bundle(signing), new Map([["k1", Buffer.alloc(31, 2)]])); + assert.equal(result.status, "malformed"); + assert.equal(result.ok, false); +}); diff --git a/sdk/typescript/test/verify.test.ts b/sdk/typescript/test/verify.test.ts new file mode 100644 index 00000000..1f4fbcef --- /dev/null +++ b/sdk/typescript/test/verify.test.ts @@ -0,0 +1,406 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { + CANONICALIZATION, + canonicalJson, + keySetFromDocument, + verifyBundle, + type KeySet, + type VerificationResult, +} from "../src/verify.ts"; + +const keyPair = generateKeyPairSync("ed25519"); +const otherPair = generateKeyPairSync("ed25519"); +const rawKey = Buffer.from( + (keyPair.publicKey.export({ format: "jwk" }).x as string), + "base64url", +); +const otherRawKey = Buffer.from( + (otherPair.publicKey.export({ format: "jwk" }).x as string), + "base64url", +); + +function keys(): KeySet { + return new Map([["key-1", rawKey]]); +} + +function payload(overrides: Record = {}): Record { + return { receipt_id: "rcpt-1", kid: "key-1", ...overrides }; +} + +/** Build an envelope around payload, signed with the test key. */ +function seal( + body: Record, + kid = "key-1", + envelope: Record = {}, +): Record { + const signingInput = JSON.stringify(body); + const signature = sign( + null, + Buffer.from(signingInput, "utf8"), + keyPair.privateKey, + ).toString("base64"); + return { signing_input: signingInput, signature, kid, ...envelope }; +} + +function statusOf(bundle: unknown, ks: KeySet = keys(), issuer?: string): string { + return verifyBundle(bundle, ks, issuer).status; +} + +test("valid bundle verifies and reports claims from the signed bytes", () => { + const result = verifyBundle( + seal(payload({ tool: "partner.echo", credits_charged: "1" })), + keys(), + ); + assert.equal(result.status, "verified"); + assert.equal(result.ok, true); + assert.equal(result.receiptId, "rcpt-1"); + assert.equal(result.keyId, "key-1"); + assert.equal(result.claims?.tool, "partner.echo"); + assert.equal(result.claims?.credits_charged, "1"); +}); + +test("verification is stateless: the same bundle verifies twice", () => { + const bundle = seal(payload()); + assert.equal(verifyBundle(bundle, keys()).status, "verified"); + assert.equal(verifyBundle(bundle, keys()).status, "verified"); +}); + +test("one altered byte in the signed payload is invalid, not a mismatch", () => { + const bundle = seal(payload({ credits_charged: "100", credits_authorized: "100" })); + const tampered = { + ...bundle, + signing_input: (bundle.signing_input as string).replace('"100"', '"900"'), + }; + const result = verifyBundle(tampered, keys()); + assert.equal(result.status, "invalid"); + assert.equal(result.ok, false); +}); + +test("signature from a different key is invalid", () => { + const signingInput = JSON.stringify(payload()); + const signature = sign( + null, + Buffer.from(signingInput, "utf8"), + otherPair.privateKey, + ).toString("base64"); + const result = verifyBundle( + { signing_input: signingInput, signature, kid: "key-1" }, + keys(), + ); + assert.equal(result.status, "invalid"); +}); + +test("known kid mapped to the wrong key bytes is invalid", () => { + const result = verifyBundle(seal(payload()), new Map([["key-1", otherRawKey]])); + assert.equal(result.status, "invalid"); +}); + +test("short public key bytes are malformed, not evidence of tampering", () => { + const result = verifyBundle( + seal(payload()), + new Map([["key-1", Buffer.from("too-short")]]), + ); + assert.equal(result.status, "malformed"); + assert.equal(result.ok, false); +}); + +test("unknown kid is unknown_key even when other keys are held", () => { + const signingInput = JSON.stringify(payload({ kid: "ghost" })); + const signature = sign( + null, + Buffer.from(signingInput, "utf8"), + otherPair.privateKey, + ).toString("base64"); + const result = verifyBundle( + { signing_input: signingInput, signature, kid: "key-1" }, + keys(), + ); + assert.equal(result.status, "unknown_key"); + assert.equal(result.keyId, "ghost"); +}); + +test("empty key set reports unknown_key, not invalid", () => { + assert.equal(statusOf(seal(payload()), new Map()), "unknown_key"); +}); + +test("kid confusion that verifies under the envelope key is mismatch", () => { + const signingInput = JSON.stringify(payload({ kid: "ghost" })); + const signature = sign( + null, + Buffer.from(signingInput, "utf8"), + keyPair.privateKey, + ).toString("base64"); + const bundle = { signing_input: signingInput, signature, kid: "key-1" }; + const result = verifyBundle(bundle, keys()); + assert.equal(result.status, "mismatch"); + assert.match(result.reason ?? "", /ghost/); +}); + +test("kid confusion with an unusable envelope key stays unknown_key", () => { + const signingInput = JSON.stringify(payload({ kid: "ghost" })); + const signature = sign( + null, + Buffer.from(signingInput, "utf8"), + keyPair.privateKey, + ).toString("base64"); + const bundle = { signing_input: signingInput, signature, kid: "key-1" }; + const result = verifyBundle(bundle, new Map([["key-1", Buffer.from("short")]])); + assert.equal(result.status, "unknown_key"); +}); + +test("envelope kid differing from a verified payload is mismatch", () => { + const bundle = seal(payload(), "key-2"); + assert.equal(statusOf(bundle, new Map([["key-1", rawKey]])), "mismatch"); +}); + +test("correct payload_hash verifies; wrong payload_hash is mismatch", () => { + const inner = payload({ credits_charged: "1" }); + const good = createHash("sha256").update(canonicalJson(inner), "utf8").digest("hex"); + assert.equal(statusOf(seal({ ...inner, payload_hash: good })), "verified"); + assert.equal( + statusOf(seal({ ...inner, payload_hash: "0".repeat(64) })), + "mismatch", + ); +}); + +function deepField(depth: number): unknown { + let deep: unknown = 0; + for (let i = 0; i < depth; i++) deep = { nest: deep }; + return deep; +} + +test("deeply nested payload with payload_hash is malformed, never a throw", () => { + const body = { ...payload({ payload_hash: "0".repeat(64) }), deep: deepField(3000) }; + const result = verifyBundle(seal(body), keys()); + assert.equal(result.status, "malformed"); +}); + +test("deeply nested payload without payload_hash still verifies", () => { + const body = { ...payload(), deep: deepField(3000) }; + assert.equal(statusOf(seal(body)), "verified"); +}); + +test("lone surrogate in a signed value is malformed", () => { + const signingInput = '{"kid":"key-1","note":"\\ud800","receipt_id":"rcpt-1"}'; + const signature = sign( + null, + Buffer.from(signingInput, "utf8"), + keyPair.privateKey, + ).toString("base64"); + const result = verifyBundle( + { signing_input: signingInput, signature, kid: "key-1" }, + keys(), + ); + assert.equal(result.status, "malformed"); +}); + +test("lone surrogate in a signed key is malformed", () => { + const signingInput = '{"kid":"key-1","receipt_id":"rcpt-1","\\ud800":"x"}'; + const signature = sign( + null, + Buffer.from(signingInput, "utf8"), + keyPair.privateKey, + ).toString("base64"); + const result = verifyBundle( + { signing_input: signingInput, signature, kid: "key-1" }, + keys(), + ); + assert.equal(result.status, "malformed"); +}); + +test("paired surrogates (emoji) verify normally", () => { + assert.equal(statusOf(seal(payload({ note: "😀" }))), "verified"); +}); + +test("bundle given as a JSON string verifies like the parsed object", () => { + const bundle = seal(payload()); + const fromString = verifyBundle(JSON.stringify(bundle), keys()); + assert.equal(fromString.status, "verified"); + assert.equal(fromString.receiptId, "rcpt-1"); +}); + +test("bundle given as a non-JSON string is malformed", () => { + assert.equal(statusOf("{oops"), "malformed"); +}); + +test("null envelope receipt_id reads as absent", () => { + const bundle = { ...seal(payload()), receipt_id: null }; + assert.equal(statusOf(bundle), "verified"); +}); + +test("null envelope alg disagrees with the signed payload", () => { + const bundle = { ...seal(payload()), alg: null }; + assert.equal(statusOf(bundle), "mismatch"); +}); + +test("null envelope canonicalization disagrees with the signed payload", () => { + const bundle = { ...seal(payload()), canonicalization: null }; + assert.equal(statusOf(bundle), "mismatch"); +}); + +test("envelope receipt_id differing from the signed one is mismatch", () => { + const bundle = { ...seal(payload()), receipt_id: "rcpt-2" }; + const result = verifyBundle(bundle, keys()); + assert.equal(result.status, "mismatch"); + assert.equal(result.receiptId, "rcpt-1"); +}); + +test("envelope RS256 claim cannot turn a good Ed25519 receipt into unsupported", () => { + const bundle = seal(payload(), "key-1", { alg: "RS256" }); + assert.equal(statusOf(bundle), "mismatch"); +}); + +test("signed RS256 algorithm is unsupported, driven by the signer", () => { + const bundle = seal({ ...payload(), alg: "RS256" }); + assert.equal(statusOf(bundle), "unsupported"); +}); + +test("signed unknown canonicalization is unsupported", () => { + const bundle = seal({ ...payload(), canonicalization: "other/9" }); + assert.equal(statusOf(bundle), "unsupported"); +}); + +test("issuer expectation mismatch rejects an otherwise valid bundle", () => { + const bundle = { ...seal(payload()), issuer: "https://a.example" }; + const result = verifyBundle(bundle, keys(), "https://b.example"); + assert.equal(result.status, "mismatch"); + assert.match(result.reason ?? "", /issuer/); +}); + +test("issuer expectation match verifies", () => { + const bundle = { ...seal(payload()), issuer: "https://a.example" }; + assert.equal(verifyBundle(bundle, keys(), "https://a.example").status, "verified"); +}); + +test("no issuer expectation means the envelope issuer is ignored", () => { + const bundle = { ...seal(payload()), issuer: "https://a.example" }; + assert.equal(statusOf(bundle), "verified"); +}); + +const malformedBundles: Array<[string, unknown]> = [ + ["null bundle", null], + ["numeric bundle", 42], + ["array bundle", [1, 2]], + ["empty signing_input", { signing_input: "", signature: "eA==", kid: "key-1" }], + ["empty signature", { signing_input: "{}", signature: "", kid: "key-1" }], + ["empty kid", { signing_input: "{}", signature: "eA==", kid: "" }], + ["signing_input not JSON", { signing_input: "not json", signature: "eA==", kid: "key-1" }], + ["signing_input not an object", { signing_input: "[1,2]", signature: "eA==", kid: "key-1" }], + ["payload without kid", { signing_input: '{"receipt_id":"r"}', signature: "eA==", kid: "key-1" }], + ["payload without receipt_id", { signing_input: '{"kid":"key-1"}', signature: "eA==", kid: "key-1" }], + ["non-base64 signature", seal(payload(), "key-1", { signature: "!!!!" })], + ["short signature", seal(payload(), "key-1", { signature: "AA==" })], + [ + "65-byte signature", + seal(payload(), "key-1", { signature: Buffer.alloc(65).toString("base64") }), + ], + ["unpadded signature", seal(payload(), "key-1", { signature: "AA" })], +]; + +for (const [name, bundle] of malformedBundles) { + test(`malformed: ${name}`, () => { + const result: VerificationResult = verifyBundle(bundle, keys()); + assert.equal(result.status, "malformed"); + assert.equal(result.ok, false); + assert.ok(result.reason); + }); +} + +test("every failure carries ok false and a reason", () => { + const failures: unknown[] = [ + null, + seal(payload(), "key-1", { signature: "!!!!" }), + seal(payload(), "ghost"), + seal({ ...payload(), alg: "RS256" }), + { ...seal(payload()), receipt_id: "rcpt-2" }, + ]; + for (const bundle of failures) { + const result = verifyBundle(bundle, keys()); + assert.equal(result.ok, false); + assert.ok(result.reason, `missing reason for ${result.status}`); + } +}); + +test("canonical JSON sorts nested keys with no whitespace", () => { + assert.equal( + canonicalJson({ b: 1, a: { d: 2, c: 3 } }), + '{"a":{"c":3,"d":2},"b":1}', + ); +}); + +test("canonical JSON escapes non-ASCII including astral characters", () => { + assert.equal(canonicalJson({ k: "café" }), '{"k":"caf\\u00e9"}'); + assert.equal(canonicalJson({ k: "😀" }), '{"k":"\\ud83d\\ude00"}'); + assert.equal( + canonicalJson({ z: 1, [CANONICALIZATION]: 2 }), + '{"awi-canonical-json/1":2,"z":1}', + ); +}); + +test("key set reads raw, key_id alias and JWK mirror forms", () => { + const rawB64 = rawKey.toString("base64"); + const jwkX = otherRawKey.toString("base64url"); + const ks = keySetFromDocument({ + keys: [ + { kid: "raw", alg: "Ed25519", public_key_b64: rawB64 }, + { key_id: "alias", alg: "Ed25519", public_key_b64: rawB64 }, + { kid: "jwk-only", alg: "Ed25519", jwk: { crv: "Ed25519", x: jwkX } }, + ], + }); + assert.deepEqual(new Set(ks.keys()), new Set(["raw", "alias", "jwk-only"])); + for (const value of ks.values()) assert.equal(value.length, 32); +}); + +test("key set drops disabled keys, wrong algs and unusable entries", () => { + const rawB64 = rawKey.toString("base64"); + const ks = keySetFromDocument({ + keys: [ + { kid: "revoked", alg: "Ed25519", status: "disabled", public_key_b64: rawB64 }, + { kid: "rsa", alg: "RS256", public_key_b64: rawB64 }, + { kid: "short", alg: "Ed25519", public_key_b64: "AAAA" }, + { kid: "bad-b64", alg: "Ed25519", public_key_b64: "!!!!" }, + { kid: "bad-jwk", alg: "Ed25519", jwk: { crv: "Ed25519", x: "a" } }, + { alg: "Ed25519", public_key_b64: rawB64 }, + "not-an-entry", + { kid: "live", alg: "Ed25519", public_key_b64: rawB64 }, + ], + }); + assert.deepEqual([...ks.keys()], ["live"]); +}); + +test("key set from a document with no keys list throws", () => { + assert.throws(() => keySetFromDocument({ nope: [] }), /no 'keys' list/); + assert.throws(() => keySetFromDocument(null), /no 'keys' list/); +}); + +test("disabled key verifies nothing: its receipts are unknown_key", () => { + const rawB64 = rawKey.toString("base64"); + const ks = keySetFromDocument({ + keys: [{ kid: "key-1", status: "disabled", public_key_b64: rawB64 }], + }); + assert.equal(verifyBundle(seal(payload()), ks).status, "unknown_key"); +}); + +test( + "float payload_hash does not round-trip across languages", + { skip: "JS prints 1.0 as 1, so a hash computed over Python-style 1.0 cannot verify here; numbers must be strings per spec" }, + () => { + assert.equal(canonicalJson({ v: 1.0 }), '{"v":1.0}'); + }, +); + +test( + "unexpected crypto backend failure raises instead of reporting invalid", + { skip: "edOk swallows every exception as a failed signature; Python raises VerificationError so a broken backend is never read as forgery" }, + () => { + assert.fail("needs an injectable verifier to simulate backend failure"); + }, +); + +test("signed null algorithm is unsupported, matching the Python verifier", () => { + const bundle = seal({ ...payload(), alg: null }); + const result = verifyBundle(bundle, keys()); + assert.equal(result.status, "unsupported"); +}); diff --git a/sdk/typescript/tsconfig.build.json b/sdk/typescript/tsconfig.build.json new file mode 100644 index 00000000..d25890f1 --- /dev/null +++ b/sdk/typescript/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/spec/README.md b/spec/README.md index a9459cd0..bc33ac02 100644 --- a/spec/README.md +++ b/spec/README.md @@ -24,11 +24,17 @@ breaking changes will bump the version. Propose changes with a spec RFC issue transmitted. Verifiers must not re-serialize before verifying. - **Canonical JSON (`awi-canonical-json/1`):** object keys sorted at every level, no insignificant whitespace (`,` and `:` separators), non-ASCII - escaped as `\uXXXX`. Credit amounts and timestamps are strings by the time - they are signed, so no number-formatting rules are needed. + escaped as lowercase `\uXXXX`. +- **Amounts and timestamps:** credit amounts and timestamps are strings before + they are signed. Amount strings are fixed point, with no exponent and with + trailing zeros removed (`2.50` is signed as `2.5`, `10.00` as `10`). + Timestamps are UTC with a numeric offset, for example + `2026-09-16T12:00:00+00:00`. A verifier does not reformat these values. It + checks the bytes it was given. - **`payload_hash`:** lowercase hex SHA-256 of the canonical JSON of the signed payload *without* the `payload_hash` member. It is itself part of the signed - bytes. + bytes. A signature over a payload whose `payload_hash` does not match that + computation is a `mismatch`, not a valid receipt. - **`kid`:** the signing key id, inside the signed payload. Key selection uses the signed `kid`, never the unauthenticated envelope. diff --git a/spec/key-set-v0.1.md b/spec/key-set-v0.1.md index 1b0b1fa4..81bed1e5 100644 --- a/spec/key-set-v0.1.md +++ b/spec/key-set-v0.1.md @@ -17,11 +17,20 @@ A gateway publishes its receipt and permit verification keys at } ``` -- A verifier accepts either `public_key_b64` or the `jwk` mirror. -- `status: "disabled"` keys must be dropped before verification; a cached copy - of the document must not keep a revoked key working. -- Rotation: publish the new key, switch signing to it, keep the old key - `active` for as long as its receipts must stay verifiable, then disable it. +- A verifier uses `public_key_b64` when it decodes to 32 bytes. It uses the + `jwk` mirror only when that raw form is missing or unusable. Publish both + only when they are the same key: a usable `public_key_b64` wins when they + disagree, and the signature check then fails closed against the raw key. +- Keys with `status` `active` or `retired` are verification keys. `retired` + means this key no longer signs new objects, and objects it already signed + still verify. Keys with `status` `disabled` must be dropped before + verification. A cached copy of the document must not keep a disabled key + working. +- Rotation: publish the new key, switch signing to it, and set the previous + key to `retired` for as long as its receipts must stay verifiable. Set it + to `disabled` when those receipts should stop verifying. +- Extra members such as `created_at`, `schema_version`, and `issuer` may + appear on the document or on a key. Verifiers ignore members they do not use. - Trust in the key set (which origin you fetched it from, and whether you pinned it) is the verifier's decision, not something the receipt proves. diff --git a/spec/permit-v0.1.md b/spec/permit-v0.1.md index 96b7e2ea..fd5bbdfa 100644 --- a/spec/permit-v0.1.md +++ b/spec/permit-v0.1.md @@ -12,7 +12,7 @@ Required members: | `permit_id` | string | Unique permit id | | `issuer_wallet_id` | string | The operator (sponsor) account that granted the permit | | `subject_wallet_id` | string | The agent account the permit is for | -| `subject_key_id` | string or null | The agent API key the permit is bound to, if bound | +| `subject_key_id` | string or null | Agent API key this permit is bound to. JSON `null` when it is not bound to one key, and that null is part of the signed bytes | | `scopes` | string[] | Granted scopes, for example `tool::invoke`, `billing:charge` | | `allowed_tools` | string[] | Tools this permit may invoke | | `max_credits` | decimal string | Budget ceiling for the permit's lifetime | diff --git a/spec/receipt-v0.1.md b/spec/receipt-v0.1.md index 1b36ce14..d13ee1f3 100644 --- a/spec/receipt-v0.1.md +++ b/spec/receipt-v0.1.md @@ -20,6 +20,11 @@ Everything outside `signing_input` and `signature` is an unauthenticated envelope. A verifier reads every claim from the signed payload and treats any envelope value that disagrees with it as a `mismatch`. +The reference gateway always includes `canonicalization`. A verifier treats a +missing value as `awi-canonical-json/1`. A value that disagrees with the signed +payload, or with that default when the signed payload has no `canonicalization` +of its own, is a `mismatch`. + ## Signed payload | Member | Meaning | @@ -28,18 +33,30 @@ envelope value that disagrees with it as a `mismatch`. | `permit_id` | The permit the call ran under | | `wallet_id`, `key_id` | The agent account and API key that made the call | | `tool` | Tool name | -| `idempotency_record_id` | The gateway's record for the idempotency key (the raw key is not signed) | -| `dispatch_attempt_id` | The one-shot dispatch claim, when a dispatch happened | -| `request_hash`, `response_hash` | SHA-256 of the request and response bodies | +| `idempotency_record_id` | The gateway's record for the idempotency key (the raw key is not signed). Omitted unless this signature included linkage | +| `dispatch_attempt_id` | The one-shot dispatch claim. Omitted unless this signature included linkage, including older receipts of calls that did dispatch | +| `request_hash`, `response_hash` | Lowercase hex SHA-256 of the request and response values. `response_hash` is JSON `null` when there is no response | | `outcome` | See below | | `credits_authorized`, `credits_charged` | Decimal strings | -| `ledger_entry_id` | The debit, when one exists | +| `ledger_entry_id` | The debit. JSON `null` when there is no debit. The member is still present | | `audit_event_id` | Link into the hash-chained audit log | | `created_at` | ISO-8601 UTC | | `alg`, `kid`, `payload_hash` | See [`README.md`](README.md) | Additional members (approval ids, action bindings, constraint denials) are -additive, as for permits. +additive, as for permits. `reason_code`, `approval_id`, +`constraints_evaluated`, and the action-binding members are omitted when they +have no value. `idempotency_record_id` and `dispatch_attempt_id` are omitted +unless that signature included linkage, so an older success receipt can omit +both. `response_hash` and `ledger_entry_id` are not omitted: they are JSON +`null` instead. Dropping a null changes the signed bytes. + +Two members need care. `ledger_entry_id` and `response_hash` are always +signed, with null when there is no value: a denied receipt signs +`"ledger_entry_id":null` and `"response_hash":null`, so verifiers must keep +those nulls in the bytes they hash. `idempotency_record_id` and +`dispatch_attempt_id` are signed only when set; receipts that predate the +linkage fields carry neither. ## Outcomes @@ -70,10 +87,10 @@ Full trigger table: [`../gateway/docs/failure-semantics.md`](../gateway/docs/fai |---|---|---| | `verified` | Signature holds over the signed bytes and the envelope agrees | yes | | `invalid` | Signature does not verify | reject (tampered) | -| `mismatch` | Signature verified, but envelope or caller expectation disagrees | reject | -| `unknown_key` | Verifier does not hold the signed `kid` | cannot judge; refresh keys | -| `malformed` | Bundle cannot be parsed | cannot judge | -| `unsupported` | Signed `alg` or `canonicalization` not implemented | cannot judge | +| `mismatch` | Signature verified, but the bundle disagrees with the signed payload. Covers a different envelope `receipt_id`, `kid`, `alg`, or `canonicalization`, and a `payload_hash` that is not the SHA-256 of the canonical payload with that member removed | reject | +| `unknown_key` | Verifier does not hold the signed `kid`, including when that key is present but `disabled` | cannot judge; refresh keys | +| `malformed` | Bundle cannot be parsed, or the signature is not 64 bytes of base64 | cannot judge | +| `unsupported` | The signed payload's `alg` or `canonicalization` is not implemented, and the signature over `signing_input` checked out. The envelope alone must not produce this status | cannot judge | Reference verifiers: Python (`sdk/python`, CLI `b2a-verify-receipt`) and TypeScript (`sdk/typescript`). Both pass [`test-vectors/`](test-vectors/). diff --git a/spec/test-vectors/README.md b/spec/test-vectors/README.md index 9f610ea6..9ab05647 100644 --- a/spec/test-vectors/README.md +++ b/spec/test-vectors/README.md @@ -4,7 +4,10 @@ `bytes(range(32))`. **Test use only.** - `receipts.json`: cases with a `bundle`, optional `keys` override, and the `expect`ed verifier status. Built from the gateway's golden receipt fixtures - plus tampered variants. + plus tampered variants. Synthetic cases add retired and disabled keys, + envelope fields that disagree with the signed payload, a signed + payload_hash that does not match, an unsupported algorithm, and malformed + bundles. - `generate.py`: regenerates both files from `gateway/tests/fixtures/`. Check an implementation: diff --git a/spec/test-vectors/generate.py b/spec/test-vectors/generate.py index c8fd09cf..be32d37a 100644 --- a/spec/test-vectors/generate.py +++ b/spec/test-vectors/generate.py @@ -5,6 +5,11 @@ come from the gateway's golden fixtures (gateway/tests/fixtures), so a change to the receipt signing input shows up here as a diff. +The generator refuses to write a suite when the golden directory is empty, when +a fixture signature does not match the test seed, or when an outcome tamper +would be ambiguous. Those failures used to be skipped or asserted, and an +assertion disappears under python -O. + Usage (from the repository root): python spec/test-vectors/generate.py """ @@ -12,7 +17,7 @@ from __future__ import annotations import base64 -import copy +import hashlib import json from pathlib import Path @@ -23,92 +28,442 @@ ROOT = HERE.parent.parent GOLDEN = ROOT / "gateway" / "tests" / "fixtures" / "receipt_signing_golden" TEST_SEED = bytes(range(32)) +# Second fixed seed, used only so one vector can carry a well formed raw key +# that is not the key that signed the receipt. +OTHER_SEED = bytes(range(32, 64)) KID = "sk_golden_0001" +# Envelope member the gateway's portable receipt endpoint also emits +# (PortableReceiptResponse.canonicalization). The signature covers only +# signing_input, so this member is unauthenticated envelope data. +CANONICALIZATION = "awi-canonical-json/1" +BASE_FIXTURE = "success_legacy" + + +class VectorError(RuntimeError): + """The generator cannot produce a trustworthy suite.""" def b64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") -def main() -> None: - key = Ed25519PrivateKey.from_private_bytes(TEST_SEED) - public_raw = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) - keys = { - "_comment": "Public half of the fixed test seed bytes(range(32)). Test use only.", +def canonical_json(payload: object) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def signing_key() -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes(TEST_SEED) + + +def _public_raw(key: Ed25519PrivateKey) -> bytes: + return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + + +def _jwk(public_raw: bytes) -> dict[str, str]: + return { + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_raw).decode("ascii").rstrip("="), + } + + +def key_document(public_raw: bytes) -> dict: + return { + "_comment": ( + "Public half of the fixed test seed bytes(range(32)). Test use only." + ), "keys": [ { "kid": KID, "alg": "Ed25519", "status": "active", "public_key_b64": b64(public_raw), - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": base64.urlsafe_b64encode(public_raw).decode("ascii").rstrip("="), - }, + "jwk": _jwk(public_raw), } ], } - (HERE / "keys.json").write_text(json.dumps(keys, indent=2) + "\n") - - cases = [] - for golden in sorted(GOLDEN.glob("*.json")): - fixture = json.loads(golden.read_text()) - signing_input = fixture["signing_input"] - signature = key.sign(signing_input.encode("utf-8")) - assert b64(signature) == fixture["signature_b64_over_test_seed"], golden.name + + +def render_keys(public_raw: bytes | None = None) -> str: + raw = _public_raw(signing_key()) if public_raw is None else public_raw + return json.dumps(key_document(raw), indent=2) + "\n" + + +def tamper_outcome(signing_input: str) -> str: + """Change the signed outcome without re-serializing the payload. + + Re-serializing would rebuild payload_hash and could hide a byte-level + edit. The replacement has to hit the one canonical outcome field, so a + nested copy of the same text cannot be what we tamper by accident. + """ + try: payload = json.loads(signing_input) - bundle = { - "receipt_id": payload["receipt_id"], - "kid": payload["kid"], - "alg": "Ed25519", - "signing_input": signing_input, - "signature": b64(signature), - } - name = golden.stem - cases.append({"name": f"{name}.valid", "bundle": bundle, "expect": "verified"}) - - flipped = bytearray(signature) - flipped[0] ^= 0x01 - bad_sig = dict(bundle, signature=b64(bytes(flipped))) - cases.append({"name": f"{name}.bad_signature", "bundle": bad_sig, "expect": "invalid"}) - - tampered_payload = signing_input.replace('"outcome":"', '"outcome":"x', 1) - if tampered_payload != signing_input: - cases.append({ - "name": f"{name}.tampered_payload", - "bundle": dict(bundle, signing_input=tampered_payload), - "expect": "invalid", - }) - - cases.append({ + except json.JSONDecodeError as exc: + raise VectorError("signing input is not JSON") from exc + if not isinstance(payload, dict): + raise VectorError("signing input is not a JSON object") + outcome = payload.get("outcome") + if not isinstance(outcome, str) or outcome == "": + raise VectorError("golden signing input has no string outcome to tamper") + needle = f'"outcome":"{outcome}"' + count = signing_input.count(needle) + if count != 1: + raise VectorError( + "outcome field is not a single unambiguous canonical string " + f"(found {count})" + ) + tampered = signing_input.replace(needle, f'"outcome":"x{outcome}"', 1) + parsed = json.loads(tampered) + if parsed.get("outcome") != f"x{outcome}": + raise VectorError("outcome tamper did not change the signed outcome") + return tampered + + +def payload_hash(payload: dict) -> str: + """Lowercase hex SHA-256 of the canonical payload without payload_hash.""" + inner = {key: value for key, value in payload.items() if key != "payload_hash"} + return hashlib.sha256(canonical_json(inner).encode("utf-8")).hexdigest() + + +def _with_hash(payload: dict) -> dict: + body = {key: value for key, value in payload.items() if key != "payload_hash"} + updated = dict(body) + updated["payload_hash"] = hashlib.sha256( + canonical_json(body).encode("utf-8") + ).hexdigest() + return updated + + +def sign_bundle(key: Ed25519PrivateKey, payload: dict, **envelope: object) -> dict: + """Sign payload. The envelope kid stays the key that actually signed. + + A caller that needs the envelope to disagree passes that field in + envelope, for example alg="RS256". + """ + signing_input = canonical_json(payload) + signature = b64(key.sign(signing_input.encode("utf-8"))) + bundle = { + "receipt_id": payload["receipt_id"], + "kid": KID, + "alg": "Ed25519", + # Unauthenticated envelope member the gateway's portable receipt + # endpoint also emits; the signature covers only signing_input. + # The spec conformance gate requires it on every well-formed bundle. + "canonicalization": CANONICALIZATION, + "signing_input": signing_input, + "signature": signature, + } + bundle.update(envelope) + return bundle + + +def _key_entry( + *, + public_key_b64: str | None, + jwk: dict[str, str] | None, + status: str = "active", +) -> dict: + entry: dict = {"kid": KID, "alg": "Ed25519", "status": status} + if public_key_b64 is not None: + entry["public_key_b64"] = public_key_b64 + if jwk is not None: + entry["jwk"] = jwk + return entry + + +def cases_for_fixture(path: Path, key: Ed25519PrivateKey) -> list[dict]: + fixture = json.loads(path.read_text()) + signing_input = fixture["signing_input"] + raw_signature = key.sign(signing_input.encode("utf-8")) + signature = b64(raw_signature) + if signature != fixture.get("signature_b64_over_test_seed"): + raise VectorError(f"{path.name} signature does not match the fixed test seed") + payload = json.loads(signing_input) + if not isinstance(payload, dict): + raise VectorError(f"{path.name} signing_input is not a JSON object") + if canonical_json(payload) != signing_input: + raise VectorError(f"{path.name} signing_input is not canonical JSON") + + bundle = { + "receipt_id": payload["receipt_id"], + "kid": payload["kid"], + "alg": "Ed25519", + # Same unauthenticated envelope member as sign_bundle: the portable + # receipt endpoint emits it and the spec conformance gate requires it + # on every well-formed bundle. + "canonicalization": CANONICALIZATION, + "signing_input": signing_input, + "signature": signature, + } + name = path.stem + flipped = bytearray(raw_signature) + flipped[0] ^= 0x01 + tampered_payload = tamper_outcome(signing_input) + return [ + {"name": f"{name}.valid", "bundle": bundle, "expect": "verified"}, + { + "name": f"{name}.bad_signature", + "bundle": dict(bundle, signature=b64(bytes(flipped))), + "expect": "invalid", + }, + { + "name": f"{name}.tampered_payload", + "bundle": dict(bundle, signing_input=tampered_payload), + "expect": "invalid", + }, + { "name": f"{name}.envelope_receipt_id_mismatch", "bundle": dict(bundle, receipt_id="rcpt_not_the_signed_one"), "expect": "mismatch", - }) - - unknown = copy.deepcopy(bundle) - cases.append({ + }, + { "name": f"{name}.unknown_key", - "bundle": unknown, + "bundle": dict(bundle), "keys": {"keys": []}, "expect": "unknown_key", - }) - - cases.append({ - "name": "malformed.no_signature", - "bundle": {"receipt_id": "r", "kid": KID, "signing_input": "{}"}, - "expect": "malformed", - }) - out = { + }, + ] + + +def _synthetic_cases( + base_bundle: dict, key: Ed25519PrivateKey, real_raw: bytes +) -> list[dict]: + payload = json.loads(base_bundle["signing_input"]) + real_b64 = b64(real_raw) + real_jwk = _jwk(real_raw) + other_raw = _public_raw(Ed25519PrivateKey.from_private_bytes(OTHER_SEED)) + if other_raw == real_raw or len(other_raw) != 32: + raise VectorError("the disagreeing test key must not match the signing key") + + kid_mismatch = dict(base_bundle) + kid_mismatch["kid"] = "sk_envelope_only" + alg_mismatch = dict(base_bundle) + alg_mismatch["alg"] = "RS256" + canon_mismatch = dict(base_bundle) + canon_mismatch["canonicalization"] = "not-the-contract/9" + + # Downgrade pinning: editing alg or canonicalization in the signed text + # without re-signing is tampering (invalid), never a capability gap + # (unsupported). Only a claim the test key really signed is unsupported; + # the signer-declared variants are the synthetic.unsupported_* cases. + edited_alg_input = base_bundle["signing_input"].replace( + '"alg":"Ed25519"', '"alg":"RS256"', 1 + ) + if edited_alg_input == base_bundle["signing_input"]: + raise VectorError("golden signing input has no alg field to edit") + edited_canon_input = base_bundle["signing_input"].replace( + '"alg":"Ed25519"', '"alg":"Ed25519","canonicalization":"other/9"', 1 + ) + if edited_canon_input == base_bundle["signing_input"]: + raise VectorError("golden signing input has no anchor for canonicalization") + + lied = dict(payload) + lied["payload_hash"] = "0" * 64 + unsupported_alg = _with_hash(dict(payload, alg="RS256")) + unsupported_canon = _with_hash(dict(payload, canonicalization="other/9")) + + bad_signature = dict(base_bundle) + bad_signature["signature"] = "!!!!" + short_signature = dict(base_bundle) + short_signature["signature"] = b64(b"\x00" * 32) + bad_json = dict(base_bundle) + bad_json["signing_input"] = "not json" + not_object = dict(base_bundle) + not_object["signing_input"] = "[1,2]" + missing_kid = dict(base_bundle) + del missing_kid["kid"] + missing_input = dict(base_bundle) + del missing_input["signing_input"] + + return [ + { + "name": "synthetic.disabled_key", + "bundle": dict(base_bundle), + "keys": { + "keys": [ + _key_entry( + public_key_b64=real_b64, + jwk=real_jwk, + status="disabled", + ) + ] + }, + "expect": "unknown_key", + }, + { + "name": "synthetic.retired_key_still_verifies", + "bundle": dict(base_bundle), + # Retirement is not revocation: receipts signed before retirement + # must still verify, or rotation would falsify its own history. + "keys": { + "keys": [ + _key_entry( + public_key_b64=real_b64, + jwk=real_jwk, + status="retired", + ) + ] + }, + "expect": "verified", + }, + { + "name": "synthetic.envelope_kid_mismatch", + "bundle": kid_mismatch, + "expect": "mismatch", + }, + { + "name": "synthetic.envelope_alg_mismatch", + "bundle": alg_mismatch, + "expect": "mismatch", + }, + { + "name": "synthetic.envelope_canonicalization_mismatch", + "bundle": canon_mismatch, + "expect": "mismatch", + }, + { + "name": "synthetic.payload_hash_mismatch", + "bundle": sign_bundle(key, lied), + "expect": "mismatch", + }, + { + "name": "synthetic.jwk_only_key", + "bundle": dict(base_bundle), + "keys": {"keys": [_key_entry(public_key_b64=None, jwk=real_jwk)]}, + "expect": "verified", + }, + { + "name": "synthetic.raw_key_beats_jwk", + "bundle": dict(base_bundle), + "keys": {"keys": [_key_entry(public_key_b64=b64(other_raw), jwk=real_jwk)]}, + "expect": "invalid", + }, + { + "name": "synthetic.short_raw_key_uses_jwk", + "bundle": dict(base_bundle), + "keys": {"keys": [_key_entry(public_key_b64="AAAA", jwk=real_jwk)]}, + "expect": "verified", + }, + { + "name": "synthetic.unsupported_alg", + # The envelope keeps the real algorithm: `unsupported` must come from the + # signed bytes, never from unauthenticated envelope data. + "bundle": sign_bundle(key, unsupported_alg), + "expect": "unsupported", + }, + { + "name": "synthetic.unsupported_canonicalization", + "bundle": sign_bundle(key, unsupported_canon, canonicalization="other/9"), + "expect": "unsupported", + }, + { + "name": "downgrade.alg_edited", + "bundle": dict(base_bundle, signing_input=edited_alg_input), + "expect": "invalid", + }, + { + "name": "downgrade.canonicalization_edited", + "bundle": dict(base_bundle, signing_input=edited_canon_input), + "expect": "invalid", + }, + { + "name": "synthetic.malformed_signature", + "bundle": bad_signature, + "expect": "malformed", + }, + { + "name": "synthetic.malformed_signature_length", + "bundle": short_signature, + "expect": "malformed", + }, + { + "name": "synthetic.malformed_signing_input", + "bundle": bad_json, + "expect": "malformed", + }, + { + "name": "synthetic.malformed_signing_input_not_object", + "bundle": not_object, + "expect": "malformed", + }, + { + "name": "synthetic.malformed_missing_signature", + "bundle": { + "receipt_id": "rcpt-no-sig", + "kid": KID, + "alg": "Ed25519", + "canonicalization": CANONICALIZATION, + "signing_input": "{}", + }, + "expect": "malformed", + }, + { + "name": "synthetic.malformed_missing_kid", + "bundle": missing_kid, + "expect": "malformed", + }, + { + "name": "synthetic.malformed_missing_signing_input", + "bundle": missing_input, + "expect": "malformed", + }, + ] + + +def build_cases( + golden_dir: Path | None = None, key: Ed25519PrivateKey | None = None +) -> list[dict]: + directory = GOLDEN if golden_dir is None else Path(golden_dir) + paths = sorted(directory.glob("*.json")) + if not paths: + raise VectorError(f"no golden receipt fixtures in {directory}") + signing = signing_key() if key is None else key + cases: list[dict] = [] + base_bundle = None + for path in paths: + produced = cases_for_fixture(path, signing) + cases.extend(produced) + if path.stem == BASE_FIXTURE: + base_bundle = produced[0]["bundle"] + if base_bundle is None: + raise VectorError( + f"{BASE_FIXTURE} fixture is required to build the synthetic cases" + ) + cases.extend(_synthetic_cases(base_bundle, signing, _public_raw(signing))) + cases.append( + { + "name": "malformed.no_signature", + "bundle": {"receipt_id": "r", "kid": KID, "signing_input": "{}"}, + "expect": "malformed", + } + ) + return cases + + +def render_receipts(key: Ed25519PrivateKey | None = None) -> str: + signing = signing_key() if key is None else key + document = { "_comment": ( "Receipt verification vectors. Each case is checked against keys.json " - "unless it carries its own 'keys'. 'expect' is the verifier status." + "unless it carries its own 'keys'. 'expect' is the verifier status. " + "Synthetic cases cover a revoked key, envelope fields that disagree " + "with the signed payload, a signed payload_hash that does not match, " + "an unsupported algorithm, and malformed bundles." ), - "cases": cases, + "cases": build_cases(key=signing), } - (HERE / "receipts.json").write_text(json.dumps(out, indent=2) + "\n") - print(f"wrote {len(cases)} cases") + return json.dumps(document, indent=2) + "\n" + + +def main() -> None: + key = signing_key() + # Build both documents before replacing either file, so a failure cannot + # leave a new key set next to the previous receipt vectors. + keys_text = render_keys(_public_raw(key)) + receipts_text = render_receipts(key) + (HERE / "keys.json").write_text(keys_text) + (HERE / "receipts.json").write_text(receipts_text) + print(f"wrote {len(json.loads(receipts_text)['cases'])} cases") if __name__ == "__main__": diff --git a/spec/test-vectors/receipts.json b/spec/test-vectors/receipts.json index be0a93ec..14834254 100644 --- a/spec/test-vectors/receipts.json +++ b/spec/test-vectors/receipts.json @@ -1,5 +1,5 @@ { - "_comment": "Receipt verification vectors. Each case is checked against keys.json unless it carries its own 'keys'. 'expect' is the verifier status.", + "_comment": "Receipt verification vectors. Each case is checked against keys.json unless it carries its own 'keys'. 'expect' is the verifier status. Synthetic cases cover a revoked key, envelope fields that disagree with the signed payload, a signed payload_hash that does not match, an unsupported algorithm, and malformed bundles.", "cases": [ { "name": "denial_constraints.valid", @@ -7,6 +7,7 @@ "receipt_id": "rcpt_golden_0002", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"constraints_evaluated\":{\"allowed_tools\":[\"golden-path-echo\"],\"max_credits\":\"2.5\"},\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"0\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":null,\"outcome\":\"denied\",\"payload_hash\":\"4382f442c7150186cf873074c391738ea3af7313abebb7c0fcdb4dddff5ec6f1\",\"permit_id\":\"pmt_golden_0001\",\"reason_code\":\"permit_budget_exceeded\",\"receipt_id\":\"rcpt_golden_0002\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":null,\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "Ipxrv7VcxEJoEL5gLBLu0m43tsJehcXPATB6vLgfK/dczRPznce3vp3by7Ndzsptqqnkmsor3XzWKUvmhvwODA==" }, @@ -18,6 +19,7 @@ "receipt_id": "rcpt_golden_0002", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"constraints_evaluated\":{\"allowed_tools\":[\"golden-path-echo\"],\"max_credits\":\"2.5\"},\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"0\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":null,\"outcome\":\"denied\",\"payload_hash\":\"4382f442c7150186cf873074c391738ea3af7313abebb7c0fcdb4dddff5ec6f1\",\"permit_id\":\"pmt_golden_0001\",\"reason_code\":\"permit_budget_exceeded\",\"receipt_id\":\"rcpt_golden_0002\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":null,\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "I5xrv7VcxEJoEL5gLBLu0m43tsJehcXPATB6vLgfK/dczRPznce3vp3by7Ndzsptqqnkmsor3XzWKUvmhvwODA==" }, @@ -29,6 +31,7 @@ "receipt_id": "rcpt_golden_0002", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"constraints_evaluated\":{\"allowed_tools\":[\"golden-path-echo\"],\"max_credits\":\"2.5\"},\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"0\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":null,\"outcome\":\"xdenied\",\"payload_hash\":\"4382f442c7150186cf873074c391738ea3af7313abebb7c0fcdb4dddff5ec6f1\",\"permit_id\":\"pmt_golden_0001\",\"reason_code\":\"permit_budget_exceeded\",\"receipt_id\":\"rcpt_golden_0002\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":null,\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "Ipxrv7VcxEJoEL5gLBLu0m43tsJehcXPATB6vLgfK/dczRPznce3vp3by7Ndzsptqqnkmsor3XzWKUvmhvwODA==" }, @@ -40,6 +43,7 @@ "receipt_id": "rcpt_not_the_signed_one", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"constraints_evaluated\":{\"allowed_tools\":[\"golden-path-echo\"],\"max_credits\":\"2.5\"},\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"0\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":null,\"outcome\":\"denied\",\"payload_hash\":\"4382f442c7150186cf873074c391738ea3af7313abebb7c0fcdb4dddff5ec6f1\",\"permit_id\":\"pmt_golden_0001\",\"reason_code\":\"permit_budget_exceeded\",\"receipt_id\":\"rcpt_golden_0002\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":null,\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "Ipxrv7VcxEJoEL5gLBLu0m43tsJehcXPATB6vLgfK/dczRPznce3vp3by7Ndzsptqqnkmsor3XzWKUvmhvwODA==" }, @@ -51,6 +55,7 @@ "receipt_id": "rcpt_golden_0002", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"constraints_evaluated\":{\"allowed_tools\":[\"golden-path-echo\"],\"max_credits\":\"2.5\"},\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"0\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":null,\"outcome\":\"denied\",\"payload_hash\":\"4382f442c7150186cf873074c391738ea3af7313abebb7c0fcdb4dddff5ec6f1\",\"permit_id\":\"pmt_golden_0001\",\"reason_code\":\"permit_budget_exceeded\",\"receipt_id\":\"rcpt_golden_0002\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":null,\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "Ipxrv7VcxEJoEL5gLBLu0m43tsJehcXPATB6vLgfK/dczRPznce3vp3by7Ndzsptqqnkmsor3XzWKUvmhvwODA==" }, @@ -65,6 +70,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"action_contract_version\":1,\"action_payload_hash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"action_public_tool_id\":\"golden-path-echo\",\"action_schema_id\":\"fixture-payment\",\"action_schema_version\":\"1\",\"action_upstream_binding_hash\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"111bcc38dabf3f209a370dd5aa4f846329764c1174386e004837ffe19047f60a\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "r7GYFvu4Yw/1bH6P13KyM3sCGaRoAxGPbi90aNNvutc5Ebe2+I8vdZ5xa6k1NuJbHntZAC1aq5Us8x2+IoF7Bw==" }, @@ -76,6 +82,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"action_contract_version\":1,\"action_payload_hash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"action_public_tool_id\":\"golden-path-echo\",\"action_schema_id\":\"fixture-payment\",\"action_schema_version\":\"1\",\"action_upstream_binding_hash\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"111bcc38dabf3f209a370dd5aa4f846329764c1174386e004837ffe19047f60a\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "rrGYFvu4Yw/1bH6P13KyM3sCGaRoAxGPbi90aNNvutc5Ebe2+I8vdZ5xa6k1NuJbHntZAC1aq5Us8x2+IoF7Bw==" }, @@ -87,6 +94,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"action_contract_version\":1,\"action_payload_hash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"action_public_tool_id\":\"golden-path-echo\",\"action_schema_id\":\"fixture-payment\",\"action_schema_version\":\"1\",\"action_upstream_binding_hash\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"xsuccess\",\"payload_hash\":\"111bcc38dabf3f209a370dd5aa4f846329764c1174386e004837ffe19047f60a\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "r7GYFvu4Yw/1bH6P13KyM3sCGaRoAxGPbi90aNNvutc5Ebe2+I8vdZ5xa6k1NuJbHntZAC1aq5Us8x2+IoF7Bw==" }, @@ -98,6 +106,7 @@ "receipt_id": "rcpt_not_the_signed_one", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"action_contract_version\":1,\"action_payload_hash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"action_public_tool_id\":\"golden-path-echo\",\"action_schema_id\":\"fixture-payment\",\"action_schema_version\":\"1\",\"action_upstream_binding_hash\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"111bcc38dabf3f209a370dd5aa4f846329764c1174386e004837ffe19047f60a\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "r7GYFvu4Yw/1bH6P13KyM3sCGaRoAxGPbi90aNNvutc5Ebe2+I8vdZ5xa6k1NuJbHntZAC1aq5Us8x2+IoF7Bw==" }, @@ -109,6 +118,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"action_contract_version\":1,\"action_payload_hash\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\",\"action_public_tool_id\":\"golden-path-echo\",\"action_schema_id\":\"fixture-payment\",\"action_schema_version\":\"1\",\"action_upstream_binding_hash\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"111bcc38dabf3f209a370dd5aa4f846329764c1174386e004837ffe19047f60a\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "r7GYFvu4Yw/1bH6P13KyM3sCGaRoAxGPbi90aNNvutc5Ebe2+I8vdZ5xa6k1NuJbHntZAC1aq5Us8x2+IoF7Bw==" }, @@ -123,6 +133,7 @@ "receipt_id": "rcpt_golden_0003", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"140da326607a5ac86da62d2d2c53c513e337e26ec2638b810c34f57d5ab4a664\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0003\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "GbbSo8thFuy+PuUQDPw3GkPR0j5ebx5lgSto6CRz4YrD8KA/RwHMyo+88n5fo4M8BepW8aHVohGL04bBkrzRCQ==" }, @@ -134,6 +145,7 @@ "receipt_id": "rcpt_golden_0003", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"140da326607a5ac86da62d2d2c53c513e337e26ec2638b810c34f57d5ab4a664\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0003\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "GLbSo8thFuy+PuUQDPw3GkPR0j5ebx5lgSto6CRz4YrD8KA/RwHMyo+88n5fo4M8BepW8aHVohGL04bBkrzRCQ==" }, @@ -145,6 +157,7 @@ "receipt_id": "rcpt_golden_0003", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"xsuccess\",\"payload_hash\":\"140da326607a5ac86da62d2d2c53c513e337e26ec2638b810c34f57d5ab4a664\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0003\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "GbbSo8thFuy+PuUQDPw3GkPR0j5ebx5lgSto6CRz4YrD8KA/RwHMyo+88n5fo4M8BepW8aHVohGL04bBkrzRCQ==" }, @@ -156,6 +169,7 @@ "receipt_id": "rcpt_not_the_signed_one", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"140da326607a5ac86da62d2d2c53c513e337e26ec2638b810c34f57d5ab4a664\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0003\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "GbbSo8thFuy+PuUQDPw3GkPR0j5ebx5lgSto6CRz4YrD8KA/RwHMyo+88n5fo4M8BepW8aHVohGL04bBkrzRCQ==" }, @@ -167,6 +181,7 @@ "receipt_id": "rcpt_golden_0003", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"140da326607a5ac86da62d2d2c53c513e337e26ec2638b810c34f57d5ab4a664\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0003\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "GbbSo8thFuy+PuUQDPw3GkPR0j5ebx5lgSto6CRz4YrD8KA/RwHMyo+88n5fo4M8BepW8aHVohGL04bBkrzRCQ==" }, @@ -181,6 +196,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" }, @@ -192,6 +208,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "vtTfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" }, @@ -203,6 +220,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"xsuccess\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" }, @@ -214,6 +232,7 @@ "receipt_id": "rcpt_not_the_signed_one", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" }, @@ -225,6 +244,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" }, @@ -239,6 +259,7 @@ "receipt_id": "rcpt_golden_0004", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"da897ec3ba058eb3d1242dfcc402264f64c4e62c667efd3f74d4d457dd499058\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0004\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "MbzgmetsnxkTF+oYrUoVlv4rmsswxJU7ugleAfR3l5tNC7fTKs3+E0BxyT0BuxPmKUmvv+2Xkz+E/MAAw6zDBw==" }, @@ -250,6 +271,7 @@ "receipt_id": "rcpt_golden_0004", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"da897ec3ba058eb3d1242dfcc402264f64c4e62c667efd3f74d4d457dd499058\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0004\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "MLzgmetsnxkTF+oYrUoVlv4rmsswxJU7ugleAfR3l5tNC7fTKs3+E0BxyT0BuxPmKUmvv+2Xkz+E/MAAw6zDBw==" }, @@ -261,6 +283,7 @@ "receipt_id": "rcpt_golden_0004", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"xsuccess\",\"payload_hash\":\"da897ec3ba058eb3d1242dfcc402264f64c4e62c667efd3f74d4d457dd499058\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0004\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "MbzgmetsnxkTF+oYrUoVlv4rmsswxJU7ugleAfR3l5tNC7fTKs3+E0BxyT0BuxPmKUmvv+2Xkz+E/MAAw6zDBw==" }, @@ -272,6 +295,7 @@ "receipt_id": "rcpt_not_the_signed_one", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"da897ec3ba058eb3d1242dfcc402264f64c4e62c667efd3f74d4d457dd499058\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0004\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "MbzgmetsnxkTF+oYrUoVlv4rmsswxJU7ugleAfR3l5tNC7fTKs3+E0BxyT0BuxPmKUmvv+2Xkz+E/MAAw6zDBw==" }, @@ -283,6 +307,7 @@ "receipt_id": "rcpt_golden_0004", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"approval_id\":\"apr_golden_0001\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"da897ec3ba058eb3d1242dfcc402264f64c4e62c667efd3f74d4d457dd499058\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0004\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "MbzgmetsnxkTF+oYrUoVlv4rmsswxJU7ugleAfR3l5tNC7fTKs3+E0BxyT0BuxPmKUmvv+2Xkz+E/MAAw6zDBw==" }, @@ -297,6 +322,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"d821c53da576e9d1b9cc38e3aa2b3c8fd009ae6558de118c29f4605f17ab5787\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "8CEg2Dwm2gG04zz7PPyVP6o9ZjgVptUZCi9+uCTsCio/0vtkibWA0n0KgS4Dk89+25dTBeA1kRQonxpbmO31AQ==" }, @@ -308,6 +334,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"d821c53da576e9d1b9cc38e3aa2b3c8fd009ae6558de118c29f4605f17ab5787\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "8SEg2Dwm2gG04zz7PPyVP6o9ZjgVptUZCi9+uCTsCio/0vtkibWA0n0KgS4Dk89+25dTBeA1kRQonxpbmO31AQ==" }, @@ -319,6 +346,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"xsuccess\",\"payload_hash\":\"d821c53da576e9d1b9cc38e3aa2b3c8fd009ae6558de118c29f4605f17ab5787\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "8CEg2Dwm2gG04zz7PPyVP6o9ZjgVptUZCi9+uCTsCio/0vtkibWA0n0KgS4Dk89+25dTBeA1kRQonxpbmO31AQ==" }, @@ -330,6 +358,7 @@ "receipt_id": "rcpt_not_the_signed_one", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"d821c53da576e9d1b9cc38e3aa2b3c8fd009ae6558de118c29f4605f17ab5787\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "8CEg2Dwm2gG04zz7PPyVP6o9ZjgVptUZCi9+uCTsCio/0vtkibWA0n0KgS4Dk89+25dTBeA1kRQonxpbmO31AQ==" }, @@ -341,6 +370,7 @@ "receipt_id": "rcpt_golden_0001", "kid": "sk_golden_0001", "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"dispatch_attempt_id\":\"disp_golden_0001\",\"idempotency_record_id\":\"idem_golden_0001\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"d821c53da576e9d1b9cc38e3aa2b3c8fd009ae6558de118c29f4605f17ab5787\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", "signature": "8CEg2Dwm2gG04zz7PPyVP6o9ZjgVptUZCi9+uCTsCio/0vtkibWA0n0KgS4Dk89+25dTBeA1kRQonxpbmO31AQ==" }, @@ -349,6 +379,317 @@ }, "expect": "unknown_key" }, + { + "name": "synthetic.disabled_key", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "keys": { + "keys": [ + { + "kid": "sk_golden_0001", + "alg": "Ed25519", + "status": "disabled", + "public_key_b64": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + } + ] + }, + "expect": "unknown_key" + }, + { + "name": "synthetic.retired_key_still_verifies", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "keys": { + "keys": [ + { + "kid": "sk_golden_0001", + "alg": "Ed25519", + "status": "retired", + "public_key_b64": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + } + ] + }, + "expect": "verified" + }, + { + "name": "synthetic.envelope_kid_mismatch", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_envelope_only", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "mismatch" + }, + { + "name": "synthetic.envelope_alg_mismatch", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "RS256", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "mismatch" + }, + { + "name": "synthetic.envelope_canonicalization_mismatch", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "not-the-contract/9", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "mismatch" + }, + { + "name": "synthetic.payload_hash_mismatch", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "YuG5THQUP2JmMfNUX9sRqw5yQ5CH8a2kwN4aAvN3UUi6YL/WzktI+Zwq9JFI7QmpQiV4cBhisi23UehBsYHcCw==" + }, + "expect": "mismatch" + }, + { + "name": "synthetic.jwk_only_key", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "keys": { + "keys": [ + { + "kid": "sk_golden_0001", + "alg": "Ed25519", + "status": "active", + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + } + ] + }, + "expect": "verified" + }, + { + "name": "synthetic.raw_key_beats_jwk", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "keys": { + "keys": [ + { + "kid": "sk_golden_0001", + "alg": "Ed25519", + "status": "active", + "public_key_b64": "Kay64UG8yvCyLhqU000LxzYeUm0L/hLIl5S8kyKWbdc=", + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + } + ] + }, + "expect": "invalid" + }, + { + "name": "synthetic.short_raw_key_uses_jwk", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "keys": { + "keys": [ + { + "kid": "sk_golden_0001", + "alg": "Ed25519", + "status": "active", + "public_key_b64": "AAAA", + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + } + ] + }, + "expect": "verified" + }, + { + "name": "synthetic.unsupported_alg", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"RS256\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"99c6173d113e63dc0493a843d491be0c64e9c44a5fc29b7f8137d658abae4157\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "bjtZ7WD9d3zCbLqHPjDfM4S/vbHJ62hmAWfTy2zKbj9ZLUHwhgLdvkI0IXyQPZ/7Ag1RsNMjBzahOoK2ES1jBA==" + }, + "expect": "unsupported" + }, + { + "name": "synthetic.unsupported_canonicalization", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "other/9", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"canonicalization\":\"other/9\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"f431f8e16fb30188426ac6810b38229e8010fc5aa45941e96722e978079f36f7\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "UklmYpm47mlj45kaA5F778/MMQbF0PwawPaUy9yJ+n6EuvYR0LsZ2AGKH0cq/3qLeBnKZyP6MC6PimYmm3ppAg==" + }, + "expect": "unsupported" + }, + { + "name": "downgrade.alg_edited", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"RS256\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "invalid" + }, + { + "name": "downgrade.canonicalization_edited", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"canonicalization\":\"other/9\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "invalid" + }, + { + "name": "synthetic.malformed_signature", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "!!!!" + }, + "expect": "malformed" + }, + { + "name": "synthetic.malformed_signature_length", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "expect": "malformed" + }, + { + "name": "synthetic.malformed_signing_input", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "not json", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "malformed" + }, + { + "name": "synthetic.malformed_signing_input_not_object", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "[1,2]", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "malformed" + }, + { + "name": "synthetic.malformed_missing_signature", + "bundle": { + "receipt_id": "rcpt-no-sig", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{}" + }, + "expect": "malformed" + }, + { + "name": "synthetic.malformed_missing_kid", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signing_input": "{\"alg\":\"Ed25519\",\"audit_event_id\":\"aud_golden_0001\",\"created_at\":\"2026-09-14T17:00:00+00:00\",\"credits_authorized\":\"2.5\",\"credits_charged\":\"2.5\",\"key_id\":\"key_golden_0001\",\"kid\":\"sk_golden_0001\",\"ledger_entry_id\":\"led_golden_0001\",\"outcome\":\"success\",\"payload_hash\":\"2f7189572cd1c881279bf88315d4727d1328f49f94e1ddd68e476c4572ffcb14\",\"permit_id\":\"pmt_golden_0001\",\"receipt_id\":\"rcpt_golden_0001\",\"request_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"response_hash\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"tool\":\"golden-path-echo\",\"wallet_id\":\"agt-golden-0001\"}", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "malformed" + }, + { + "name": "synthetic.malformed_missing_signing_input", + "bundle": { + "receipt_id": "rcpt_golden_0001", + "kid": "sk_golden_0001", + "alg": "Ed25519", + "canonicalization": "awi-canonical-json/1", + "signature": "v9Tfw25hdHlpKTxjyfht52SMHn2MgjPkMQfJxXvfE1P9QZgkSd4pZKAnp5oUjgUNXCUJyjdPC6ZPr8O618HGBw==" + }, + "expect": "malformed" + }, { "name": "malformed.no_signature", "bundle": { diff --git a/spec/test-vectors/test_contract.py b/spec/test-vectors/test_contract.py new file mode 100644 index 00000000..7e9f28e7 --- /dev/null +++ b/spec/test-vectors/test_contract.py @@ -0,0 +1,111 @@ +"""Behavior the spec vectors must keep true. + +The committed JSON is generated. A case that is missing here means a verifier +can ignore key retirement, a disabled key, or a lying envelope and still pass. + +Case names follow the generator's synthetic.* vocabulary; the expectations +are the contract independent verifiers must match. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import generate +from b2a_sdk.receipt_verifier import key_set_from_document, verify_bundle + +HERE = Path(__file__).resolve().parent + +# Statuses a correct verifier returns for the cases the generator adds on top +# of the per-fixture valid, bad-signature, tamper, and unknown-key rows. +REQUIRED_CASES = { + "synthetic.retired_key_still_verifies": "verified", + "synthetic.disabled_key": "unknown_key", + "synthetic.jwk_only_key": "verified", + "synthetic.raw_key_beats_jwk": "invalid", + "synthetic.payload_hash_mismatch": "mismatch", + "synthetic.unsupported_alg": "unsupported", + "synthetic.unsupported_canonicalization": "unsupported", + "synthetic.envelope_kid_mismatch": "mismatch", + "synthetic.envelope_alg_mismatch": "mismatch", + "synthetic.envelope_canonicalization_mismatch": "mismatch", + "synthetic.malformed_signing_input": "malformed", + "synthetic.malformed_signature_length": "malformed", + "synthetic.malformed_missing_signature": "malformed", +} + + +def _load_cases() -> list[dict]: + return json.loads((HERE / "receipts.json").read_text())["cases"] + + +def _by_name(cases: list[dict]) -> dict[str, dict]: + found = {case["name"]: case for case in cases} + assert len(found) == len(cases) + return found + + +def test_generator_emits_key_lifecycle_and_envelope_cases(): + receipts = json.loads(generate.render_receipts()) + found = _by_name(receipts["cases"]) + for name, expect in REQUIRED_CASES.items(): + assert name in found, name + assert found[name]["expect"] == expect + + +def test_committed_vectors_match_the_generator(): + assert json.loads((HERE / "keys.json").read_text()) == json.loads( + generate.render_keys() + ) + assert json.loads((HERE / "receipts.json").read_text()) == json.loads( + generate.render_receipts() + ) + + +def test_reference_verifier_matches_every_committed_case(): + default_keys = key_set_from_document(json.loads((HERE / "keys.json").read_text())) + for case in _load_cases(): + keys = key_set_from_document(case["keys"]) if "keys" in case else default_keys + status = verify_bundle(case["bundle"], keys).status.value + assert status == case["expect"], case["name"] + + +def test_verified_bundles_name_the_canonicalization(): + """Issued bundles carry the canonicalization the spec example shows.""" + for case in _load_cases(): + if case["expect"] != "verified": + continue + assert case["bundle"]["canonicalization"] == generate.CANONICALIZATION + + +def test_disabled_key_stays_in_the_document_and_is_not_a_verification_key(): + """A cached key set can still list a disabled key. It must not verify.""" + case = _by_name(_load_cases())["synthetic.disabled_key"] + kids = [entry["kid"] for entry in case["keys"]["keys"]] + assert case["bundle"]["kid"] in kids + disabled = [entry for entry in case["keys"]["keys"] if entry["status"] == "disabled"] + assert [entry["kid"] for entry in disabled] == [case["bundle"]["kid"]] + assert case["bundle"]["kid"] not in key_set_from_document(case["keys"]) + + +def test_retired_key_remains_a_verification_key(): + case = _by_name(_load_cases())["synthetic.retired_key_still_verifies"] + retired = [entry for entry in case["keys"]["keys"] if entry["status"] == "retired"] + assert len(retired) == 1 + assert retired[0]["kid"] in key_set_from_document(case["keys"]) + + +def test_unsupported_alg_is_inside_the_signed_bytes_not_the_envelope(): + """An envelope algorithm must not decide `unsupported` by itself.""" + case = _by_name(_load_cases())["synthetic.unsupported_alg"] + signed = json.loads(case["bundle"]["signing_input"]) + assert signed["alg"] == "RS256" + assert case["bundle"]["alg"] == "Ed25519" + + +def test_payload_hash_mismatch_is_a_signature_that_still_verifies(): + """The signature covers the lying hash. The hash check is separate.""" + case = _by_name(_load_cases())["synthetic.payload_hash_mismatch"] + signed = json.loads(case["bundle"]["signing_input"]) + assert signed["payload_hash"] != generate.payload_hash(signed) diff --git a/spec/test-vectors/test_generate.py b/spec/test-vectors/test_generate.py new file mode 100644 index 00000000..811a81e4 --- /dev/null +++ b/spec/test-vectors/test_generate.py @@ -0,0 +1,230 @@ +"""Conformance checks for the published receipt vectors and their generator. + +The vectors in this directory are how another implementation proves it +judges receipts the same way the reference verifier does. A gap here +certifies a verifier that skips the check. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +REPO = HERE.parent.parent +SDK_SRC = REPO / "sdk" / "python" / "src" + + +def _verifier(): + if str(SDK_SRC) not in sys.path: + sys.path.insert(0, str(SDK_SRC)) + from b2a_sdk.receipt_verifier import key_set_from_document, verify_bundle + + return key_set_from_document, verify_bundle + +# Cases the suite already published before this pass. Their expected status +# must stay put when new cases are added. +ORIGINAL_EXPECTATIONS = { + "denial_constraints.valid": "verified", + "denial_constraints.bad_signature": "invalid", + "denial_constraints.tampered_payload": "invalid", + "denial_constraints.envelope_receipt_id_mismatch": "mismatch", + "denial_constraints.unknown_key": "unknown_key", + "success_action_v1.valid": "verified", + "success_action_v1.bad_signature": "invalid", + "success_action_v1.tampered_payload": "invalid", + "success_action_v1.envelope_receipt_id_mismatch": "mismatch", + "success_action_v1.unknown_key": "unknown_key", + "success_approval.valid": "verified", + "success_approval.bad_signature": "invalid", + "success_approval.tampered_payload": "invalid", + "success_approval.envelope_receipt_id_mismatch": "mismatch", + "success_approval.unknown_key": "unknown_key", + "success_legacy.valid": "verified", + "success_legacy.bad_signature": "invalid", + "success_legacy.tampered_payload": "invalid", + "success_legacy.envelope_receipt_id_mismatch": "mismatch", + "success_legacy.unknown_key": "unknown_key", + "success_legacy_approval.valid": "verified", + "success_legacy_approval.bad_signature": "invalid", + "success_legacy_approval.tampered_payload": "invalid", + "success_legacy_approval.envelope_receipt_id_mismatch": "mismatch", + "success_legacy_approval.unknown_key": "unknown_key", + "success_linked.valid": "verified", + "success_linked.bad_signature": "invalid", + "success_linked.tampered_payload": "invalid", + "success_linked.envelope_receipt_id_mismatch": "mismatch", + "success_linked.unknown_key": "unknown_key", + "malformed.no_signature": "malformed", +} + +# Statuses a verifier has to get right even though no golden fixture produces +# them on its own. Missing any of these leaves a hole in the shared suite. +REQUIRED_EDGES = { + "synthetic.disabled_key": "unknown_key", + "synthetic.envelope_kid_mismatch": "mismatch", + "synthetic.envelope_alg_mismatch": "mismatch", + "synthetic.envelope_canonicalization_mismatch": "mismatch", + "synthetic.payload_hash_mismatch": "mismatch", + "synthetic.jwk_only_key": "verified", + "synthetic.raw_key_beats_jwk": "invalid", + "synthetic.short_raw_key_uses_jwk": "verified", + "synthetic.unsupported_alg": "unsupported", + "synthetic.unsupported_canonicalization": "unsupported", + "synthetic.malformed_signature": "malformed", + "synthetic.malformed_signature_length": "malformed", + "synthetic.malformed_signing_input": "malformed", + "synthetic.malformed_signing_input_not_object": "malformed", + "synthetic.malformed_missing_kid": "malformed", + "synthetic.malformed_missing_signing_input": "malformed", +} + + +def load_generate(): + path = HERE / "generate.py" + spec = importlib.util.spec_from_file_location("receipt_vector_generate", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + # importlib sets this from the spec, but pin it so GOLDEN resolves even + # if a future loader skips that step. + module.__file__ = str(path) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def generated(): + module = load_generate() + receipts = json.loads(module.render_receipts()) + keys = json.loads(module.render_keys()) + return module, receipts, keys + + +def _by_name(receipts: dict) -> dict: + return {case["name"]: case for case in receipts["cases"]} + + +def test_published_file_keeps_original_cases_and_adds_the_edges(): + published = json.loads((HERE / "receipts.json").read_text()) + found = {case["name"]: case["expect"] for case in published["cases"]} + for name, expect in ORIGINAL_EXPECTATIONS.items(): + assert found.get(name) == expect, name + missing = { + name: expect + for name, expect in REQUIRED_EDGES.items() + if found.get(name) != expect + } + assert missing == {} + + +def test_generator_output_matches_the_published_files(): + module = load_generate() + assert module.render_keys() == (HERE / "keys.json").read_text() + assert module.render_receipts() == (HERE / "receipts.json").read_text() + + +def test_reference_verifier_agrees_with_every_generated_case(generated): + key_set_from_document, verify_bundle = _verifier() + _module, receipts, keys = generated + default_keys = key_set_from_document(keys) + for case in receipts["cases"]: + case_keys = ( + key_set_from_document(case["keys"]) if "keys" in case else default_keys + ) + status = verify_bundle(case["bundle"], case_keys).status.value + assert status == case["expect"], case["name"] + + +def test_disabled_key_still_holds_the_real_bytes_but_does_not_verify(generated): + """A cached key document must not keep a revoked key working.""" + key_set_from_document, verify_bundle = _verifier() + _module, receipts, keys = generated + case = _by_name(receipts)["synthetic.disabled_key"] + published_raw = keys["keys"][0]["public_key_b64"] + entry = case["keys"]["keys"][0] + assert entry["status"] == "disabled" + assert entry["kid"] == keys["keys"][0]["kid"] + assert entry["public_key_b64"] == published_raw + assert entry["jwk"] == keys["keys"][0]["jwk"] + assert key_set_from_document(case["keys"]) == {} + + active = key_set_from_document(keys) + assert verify_bundle(case["bundle"], active).status.value == "verified" + assert verify_bundle(case["bundle"], {}).status.value == "unknown_key" + + +def test_wrong_raw_key_is_not_rescued_by_a_matching_jwk(generated): + key_set_from_document, verify_bundle = _verifier() + _module, receipts, _keys = generated + case = _by_name(receipts)["synthetic.raw_key_beats_jwk"] + judged = verify_bundle(case["bundle"], key_set_from_document(case["keys"])) + assert judged.status.value == "invalid" + + entry = dict(case["keys"]["keys"][0]) + del entry["public_key_b64"] + jwk_only = key_set_from_document({"keys": [entry]}) + assert jwk_only, "the JWK mirror on this case has to be the real signing key" + rescued = verify_bundle(case["bundle"], jwk_only) + assert rescued.status.value == "verified" + + +def test_payload_hash_lie_and_kid_downgrade_are_rejected(generated): + key_set_from_document, verify_bundle = _verifier() + _module, receipts, keys = generated + active = key_set_from_document(keys) + by_name = _by_name(receipts) + + lied = verify_bundle(by_name["synthetic.payload_hash_mismatch"]["bundle"], active) + assert lied.status.value == "mismatch" + +def test_outcome_tamper_only_prefixes_the_signed_outcome(generated): + module, receipts, _keys = generated + by_name = _by_name(receipts) + for name, case in by_name.items(): + if not name.endswith(".tampered_payload"): + continue + valid = by_name[name.replace(".tampered_payload", ".valid")] + original = valid["bundle"]["signing_input"] + tampered = case["bundle"]["signing_input"] + assert tampered == module.tamper_outcome(original) + assert case["bundle"]["signature"] == valid["bundle"]["signature"] + before = json.loads(original) + after = json.loads(tampered) + assert after["outcome"] == "x" + before["outcome"] + after["outcome"] = before["outcome"] + assert after == before + + +def test_outcome_tamper_refuses_a_payload_with_no_string_outcome(): + module = load_generate() + with pytest.raises(module.VectorError, match="no string outcome"): + module.tamper_outcome('{"outcome":null,"kid":"sk_golden_0001"}') + + +def test_outcome_tamper_refuses_an_ambiguous_outcome(): + module = load_generate() + signing = '{"nested":{"outcome":"success"},"outcome":"success"}' + with pytest.raises(module.VectorError, match="not a single"): + module.tamper_outcome(signing) + + +def test_empty_fixture_directory_is_refused(tmp_path): + module = load_generate() + with pytest.raises(module.VectorError, match="no golden"): + module.build_cases(tmp_path) + with pytest.raises(module.VectorError, match="no golden"): + module.build_cases(tmp_path / "missing") + + +def test_fixture_signature_mismatch_is_refused(tmp_path): + module = load_generate() + source = json.loads((module.GOLDEN / "success_legacy.json").read_text()) + source["signature_b64_over_test_seed"] = "AAAA" + path = tmp_path / "success_legacy.json" + path.write_text(json.dumps(source)) + with pytest.raises(module.VectorError, match="does not match"): + module.cases_for_fixture(path, module.signing_key())