From 16d1173ec777fb4039ce49c78d9953f5a7fc4410 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Fri, 9 Oct 2026 19:15:09 +0200 Subject: [PATCH 1/2] test(storage): skip the unwritable-store check when running as root chmod does not stop root from writing, so under root the store stays writable and the health check reports pass. The startup checks for an unwritable database already skip as root or on Windows; this test now skips under the same condition. It failed in containers that run the suite as root, such as the OSS Scanner image. --- .../unit/storage-receipts/persistence.test.ts | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/tests/unit/storage-receipts/persistence.test.ts b/tests/unit/storage-receipts/persistence.test.ts index 9d7786c..120a2d0 100644 --- a/tests/unit/storage-receipts/persistence.test.ts +++ b/tests/unit/storage-receipts/persistence.test.ts @@ -125,32 +125,37 @@ describe('schema lifecycle', () => { await store.close(); }); - it('reports a fixed-vocabulary detail (never the raw error message) when the store is unwritable, and logs the raw message', async () => { - const path = join(dir, 'readonly.db'); - const logger = createCapturingLogger(); - const store = createSqliteReceiptStore({ path, logger }); - await store.init(); - - // Permissions change under a live connection (a host mount going - // read-only, say). Re-opening would not reach health(): opening a store - // over an unwritable file already throws in openSqliteDatabase. - chmodSync(path, 0o444); - - const health = await store.health(); - - expect(health.status).toBe('fail'); - expect(health.detail).toBe('store-unwritable'); - // Neither the absolute path nor the raw OS error reaches the detail - expect(health.detail).not.toContain(path); - expect(health.detail).not.toMatch(/EACCES|permission denied/i); - // The raw error goes to the logger instead - expect(logger.warnings.length).toBeGreaterThan(0); - expect(String(logger.warnings[0]?.obj['err'])).toMatch(/EACCES|permission denied/i); - - // The WAL checkpoint on close needs write access - chmodSync(path, 0o644); - await store.close(); - }); + // chmod does not stop root (or Windows) from writing, so the store stays + // writable there; the startup checks below skip under the same condition + it.runIf(process.platform !== 'win32' && process.getuid?.() !== 0)( + 'reports a fixed-vocabulary detail (never the raw error message) when the store is unwritable, and logs the raw message', + async () => { + const path = join(dir, 'readonly.db'); + const logger = createCapturingLogger(); + const store = createSqliteReceiptStore({ path, logger }); + await store.init(); + + // Permissions change under a live connection (a host mount going + // read-only, say). Re-opening would not reach health(): opening a store + // over an unwritable file already throws in openSqliteDatabase. + chmodSync(path, 0o444); + + const health = await store.health(); + + expect(health.status).toBe('fail'); + expect(health.detail).toBe('store-unwritable'); + // Neither the absolute path nor the raw OS error reaches the detail + expect(health.detail).not.toContain(path); + expect(health.detail).not.toMatch(/EACCES|permission denied/i); + // The raw error goes to the logger instead + expect(logger.warnings.length).toBeGreaterThan(0); + expect(String(logger.warnings[0]?.obj['err'])).toMatch(/EACCES|permission denied/i); + + // The WAL checkpoint on close needs write access + chmodSync(path, 0o644); + await store.close(); + }, + ); it('close() is idempotent', async () => { const store = createSqliteReceiptStore({ path: ':memory:' }); From 2aa3ab8b6f93c827f45072feda54a4a3b75e933f Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Fri, 9 Oct 2026 19:15:09 +0200 Subject: [PATCH 2/2] chore: add the OSS Scanner build and threat model Anthropic's OSS Scanner builds an enrolled project from a Dockerfile and then audits it without network access. .oss-scanner/Dockerfile installs the dependencies and the Foundry version CI uses, builds the package and runs npm test. .oss-scanner/threat_model.md summarizes SECURITY.md for the scanner: where untrusted input enters, which components matter, how to rate severity, and which documented behavior is not a finding. --- .oss-scanner/Dockerfile | 36 +++++++++++++ .oss-scanner/Dockerfile.dockerignore | 23 +++++++++ .oss-scanner/threat_model.md | 76 ++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 .oss-scanner/Dockerfile create mode 100644 .oss-scanner/Dockerfile.dockerignore create mode 100644 .oss-scanner/threat_model.md diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile new file mode 100644 index 0000000..322035e --- /dev/null +++ b/.oss-scanner/Dockerfile @@ -0,0 +1,36 @@ +# Build for Anthropic's OSS Scanner (anthropics/oss-scanner, projects/agent-commerce). +# The scanner builds this with the repository root as the build context. +# Dockerfile.dockerignore next to this file replaces the root .dockerignore, +# which would leave out contracts/lib. Network access exists only during this +# build, so every dependency the tests need is installed here. +FROM node:22-bookworm + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git python3 \ + && rm -rf /var/lib/apt/lists/* + +# Foundry, pinned to the version CI uses (FOUNDRY_VERSION in +# .github/workflows/ci.yml). The local-chain tests spawn a real Anvil and are +# skipped without it. +ARG FOUNDRY_VERSION=v1.7.1 +RUN arch="$(dpkg --print-architecture)" \ + && base="https://github.com/foundry-rs/foundry/releases/download/${FOUNDRY_VERSION}/foundry_${FOUNDRY_VERSION}_linux_${arch}" \ + && curl -fsSL "${base}.tar.gz" -o /tmp/foundry.tar.gz \ + && curl -fsSL "${base}.sha256" -o /tmp/foundry.sha256 \ + && echo "$(cut -d' ' -f1 /tmp/foundry.sha256) /tmp/foundry.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/foundry.tar.gz -C /usr/local/bin anvil forge cast \ + && rm /tmp/foundry.tar.gz /tmp/foundry.sha256 \ + && anvil --version + +# scanner contract: the checkout lives inside the image, at /src +COPY . /src +WORKDIR /src + +# All dependencies, dev included, then the build the package publishes. +RUN npm ci +RUN npm run build + +# Unit, integration and conformance tests (no network needed). A failing test +# does not fail the image. +RUN npm test || echo "WARNING: npm test reported failures" diff --git a/.oss-scanner/Dockerfile.dockerignore b/.oss-scanner/Dockerfile.dockerignore new file mode 100644 index 0000000..9f6719f --- /dev/null +++ b/.oss-scanner/Dockerfile.dockerignore @@ -0,0 +1,23 @@ +# BuildKit uses this file instead of the root .dockerignore when it builds +# .oss-scanner/Dockerfile. The root file is written for the demo images and +# drops contracts/lib, the vendored forge-std the contract tests compile +# against, so it must not apply here. +node_modules +**/node_modules +dist +**/dist +coverage +**/coverage +.git +data +.deploy +contracts/out +contracts/cache +**/*.tsbuildinfo +**/*.log +.env +.env.local +*.sqlite +*.sqlite-* +*.tgz +config.yaml diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md new file mode 100644 index 0000000..f562af7 --- /dev/null +++ b/.oss-scanner/threat_model.md @@ -0,0 +1,76 @@ +# Threat model: Agent Commerce Gateway + +The full security notes are in the repository's `SECURITY.md` and `docs/security.md`. This file summarizes them for the scanner. + +## What this project does and where untrusted input enters + +Agent Commerce Gateway is a self-hosted TypeScript gateway that sits in front of a merchant's existing HTTP API and sells calls to it to AI agents. Agents reach it over plain HTTP (`/api/resources/:id/invoke`), MCP (`/mcp`), A2A (`/a2a`) and, when enabled, ACP checkout routes. Paid resources take x402 v2 (`exact`, EVM / EIP-3009) or MPP payments, and can also require an AP2 Checkout Mandate. The gateway never holds merchant or buyer funds: payments go from the buyer to the merchant's configured address. + +- **Untrusted:** everything a caller sends. + - HTTP, MCP JSON-RPC, A2A JSON-RPC and ACP request bodies. + - Path parameters, query strings and resource inputs. + - `Host` and `Origin` headers. + - Payment proofs: x402 `PAYMENT-SIGNATURE`, MPP `Authorization: Payment …` credentials, and the `_payment` field on MCP and A2A. + - AP2 mandates (SD-JWT). +- **Semi-trusted:** + - responses from the payment facilitator and the RPC node; + - responses from the merchant backend (the built-in executor enforces a timeout and a 1 MiB body cap). +- **Trusted:** the operator's configuration file and environment, including backend URLs, admin and ACP tokens, keys and facilitator settings. + +## Components that matter most / least + +- **Most important:** + - **The payment path:** `src/core` (request pipeline), `src/payments/x402`, `src/payments/mpp`. + - Verify, then replay reservation, then settle, then deliver. + - Every failure must fail closed. + - Delivery must happen at most once per payment authorization. + - **AP2 verification:** `src/authorization/ap2`. + - **Protocol adapters:** `src/protocols/mcp`, `src/protocols/a2a`, `src/protocols/acp`, `src/protocols/http.ts`. + - **Access control and routing:** `src/gateway`. + - Host-header (DNS-rebinding) validation in `access-control.ts`. + - The admin token on `/api/receipts` and `/api/events`. + - The ACP bearer token. + - **Storage** (`src/storage`): receipts, events, replay keys. + - **Secret redaction** in logs and receipts. +- **Lower priority:** + - `src/cli` (operator tooling); + - `src/openapi` (imports an operator-supplied OpenAPI document); + - `src/config`, which still matters because invalid configuration must fail startup. +- **Out of scope:** + - `demo/`, `examples/`, `scripts/`, `contracts/` (local demo chain); + - the deliberately public Anvil development keys; + - the internals of third-party SDKs (x402, MCP SDK, mppx). How the gateway uses them is in scope. + +## How to exercise it + +- `npm test` runs the unit, integration and conformance suites (Vitest). The local-chain suites spawn Anvil, which this image installs. +- `tests/conformance/` drives the MCP, A2A and ACP adapters with the official client SDKs. +- `config-demo.yaml` describes a complete local setup. `npm run agent-commerce -- doctor --config config-demo.yaml` checks it. + +## How we rate severity + +- **Critical:** + - a paid resource delivered without a successfully verified and settled payment; + - a payment that settles to a recipient, amount, asset or network other than the configured one; + - one payment authorization or AP2 mandate unlocking more than one delivery; + - an AP2 requirement bypassed; + - any path that discloses a private key. +- **High:** + - unauthenticated access to the operator routes (`/api/receipts`, `/api/events`) or disclosure of `server.adminToken`; + - an ACP bearer-token bypass; + - a bypass of the Host-header validation; + - credentials, payment proofs or tokens written to logs or receipts despite redaction; + - code execution or path traversal reachable from untrusted input. +- **Medium:** denial of service from unauthenticated input that gets past the configured limits (body size, timeouts, batch handling); disclosure of non-secret configuration. +- **Low:** findings that need a malicious or careless operator configuration; overly detailed error messages without secrets. + +## Anything to leave alone + +`SECURITY.md` → "What the gateway does not protect against" lists behavior that is documented and intended. Please do not report it: +- SSRF through backend URLs the operator configured; +- no buyer vetting or KYC; +- irreversible on-chain settlement; +- the documented divergence between payment and delivery in the `upfront` and `authorization` flows; +- reserved authorizations never being released; +- a compromised host; +- the demo dashboard's `VITE_ADMIN_TOKEN` placeholder.