Skip to content

fix(mcp): the operator's spend cap can be narrowed, never widened - #44

Open
enot3615 wants to merge 1 commit into
mainfrom
fix/spend-cap-narrows-only
Open

fix(mcp): the operator's spend cap can be narrowed, never widened#44
enot3615 wants to merge 1 commit into
mainfrom
fix/spend-cap-narrows-only

Conversation

@enot3615

@enot3615 enot3615 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator
const maxAmountUsd = typeof args.max_amount_usd === "number"
  ? args.max_amount_usd            // ← whatever the model asked for
  : ctx.config.maxAmountUsd;

A tool argument replaced the operator's limit outright. AIFINPAY_MAX_USD=0.10 and a model that asked for max_amount_usd: 1000 got 1000.

The cap is the one control an operator cannot express any other way in this server — and it was the one a prompt could remove.

Raised in the v1.3 audit follow-up as C-9, "fail-closed spend cap invariant", with exactly this remedy: Math.min(operator_cap, requested_cap).

Latent, not exploited

payable_fetch is not registered on the current server — it registers only agent_address, agent_passport_resolve, agent_quota. But this file is what SDK 2.0 re-registers, and a spend cap a caller can raise is not a cap.

The arithmetic

operator requested result
0.10 1000 0.10 — cannot widen
10 0.5 0.5 — narrowing is legitimate
unset 5 5 — no policy to violate
0.10 unset 0.10
unset unset no cap, the documented default

Math.min only where both exist. Refusing when the operator has set nothing would make the tool unusable for anyone who has not set the variable.

Also: NaN and Infinity are numbers

typeof x === "number" accepted both, and Math.min(0.1, NaN) is NaN — a cap that compares false against every amount. That is no cap, wearing the shape of one. Now requires Number.isFinite.

The schema description said "Defaults to AIFINPAY_MAX_USD env or no cap" — which described the bug. It now says the value can only lower the operator's cap.

Tests assert the shape too

The regression is a one-token edit back to ?. Arithmetic tests alone would not catch a rewrite, so Math.min(operatorMax, requestedMax) is asserted directly.

81 tests pass across 7 files. @aifinpay/mcp → 2.0.0-rc.4.

Refs AIFINP-211.

    const maxAmountUsd = typeof args.max_amount_usd === "number"
      ? args.max_amount_usd            // ← whatever the model asked for
      : ctx.config.maxAmountUsd;

A tool argument replaced the operator's limit outright. AIFINPAY_MAX_USD=0.10
and a model that asked for max_amount_usd: 1000 got 1000. The cap is the one
control an operator cannot express any other way in this server, and it was the
one a prompt could remove.

Raised in the v1.3 audit follow-up as C-9, "fail-closed spend cap invariant",
with exactly this remedy: Math.min(operator_cap, requested_cap).

Latent rather than exploited — payable_fetch is not registered on the current
server, which registers only agent_address, agent_passport_resolve and
agent_quota. But this file is what SDK 2.0 re-registers, and a spend cap a
caller can raise is not a cap.

Math.min only where both exist. An unset operator cap means there is no policy
to violate, so a model-supplied value stands on its own; refusing there would
make the tool unusable for anyone who has not set the variable.

Also requires Number.isFinite on the argument. NaN and Infinity are numbers, so
`typeof x === "number"` accepted both, and Math.min(0.1, NaN) is NaN — a cap
that compares false against every amount, which is the same as no cap while
looking like one.

The tool's schema description said "Defaults to AIFINPAY_MAX_USD env or no cap",
which described the bug. It now says the value can only lower the operator's cap.

Tests assert the arithmetic AND the shape: the regression is a one-token edit
back to `?`, which the arithmetic tests alone would not catch if someone rewrote
the resolution.

81 tests pass across 7 files.

Refs AIFINP-211

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We1vVZLdj2vYtYaj7fjahX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant