Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .oss-scanner/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 23 additions & 0 deletions .oss-scanner/Dockerfile.dockerignore
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions .oss-scanner/threat_model.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 31 additions & 26 deletions tests/unit/storage-receipts/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:' });
Expand Down
Loading