Files
accounted/lib/entitlements/__tests__/capability-maps.test.ts
T
Jakob WennbergandClaude Opus 4.8 6be6510d73 fix(entitlements): gate paid AI document OCR server-side (free-tier leak) (#852)
* fix(entitlements): gate paid AI document OCR server-side (free-tier leak)

Free/manual-tier companies could trigger paid Bedrock OCR (extractInvoiceFields)
with no `ai` capability check, on every transport:
- invoice-inbox HTTP paths — /upload + email /inbound (shared uploadAndExtract),
  /items/:id/attach-document, /items/:id/retry-extraction (4 call sites, zero
  capability refs);
- the gnubok_upload_document MCP tool — absent from MCP_TOOL_CAPABILITY_MAP, so a
  free-tier API key (incl. the claude.ai connector's minted gnubok_sk_ key) got
  unlimited AI extraction. This disproved the keys.ts "no MCP tool invokes AI"
  comment.

Fix (money-blocker for the free/paid tier cutover):
- Gate the 3 inbox call sites on hasCapability(CAPABILITY.ai). Upload + attach
  degrade gracefully (document still stored; extraction skipped with reason
  `no_ai_entitlement`, highest priority in the existing skipReason chain). Retry
  is an explicit "run AI now" action, so it hard-blocks with 403
  capabilityBlockedResponse.
- Register gnubok_upload_document -> CAPABILITY.ai in MCP_TOOL_CAPABILITY_MAP; the
  dispatcher already enforces the map. Correct the stale keys.ts comment and the
  misleading "deterministic field extraction" tool description + manifest copy
  (the extension migrated regex -> AI OCR).

Tests: no-AI upload/attach skip + retry 403 (sandbox-skip-extraction), retry 403
(retry-extraction), and the MCP map contract + refined dispatch<->commit parity
(capability-maps: upload_document is dispatch-only, no commit counterpart).

Self-hosted stays all-on (hasCapability short-circuits). No migration.
Follow-up (not in scope): capability-blind DashboardNav (free/paid rails identical)
and /chat gated on isVerified not `ai` — see dev_docs/nav_ia_redesign.md Part 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): assert gnubok_upload_document is ai-gated at dispatch

The gnubok_upload_document handler runs extractInvoiceFields (Bedrock OCR)
inline rather than through the entitlement-gated uploadAndExtract, so the
central MCP_TOOL_CAPABILITY_MAP dispatch check is the only paywall on that
transport. Lock it with a test (flagged by PR review as an untested money
path) so a free-tier connector key can never reach paid OCR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:36:15 +02:00

68 lines
2.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
MCP_TOOL_CAPABILITY_MAP,
PAID_OPERATION_CAPABILITY_MAP,
PAID_CAPABILITIES,
CAPABILITY,
} from '../keys'
/**
* These maps are the contract that gates the paid MCP/agent path (dispatch +
* commit). Locking the exact entries is the guard against a future paid
* external-service tool silently bypassing the paywall — mirrors the
* TOOL_SCOPE_MAP assertions in the mcp-server tests.
*/
/**
* MCP tools that invoke a paid capability directly (no stage→commit round-trip),
* so they are gated at DISPATCH only and have no commit-time (operation-map)
* counterpart. gnubok_upload_document runs Bedrock OCR inline via
* extractInvoiceFields — it never stages a pending_operation.
*/
const DISPATCH_ONLY_MCP_TOOLS = new Set<string>(['gnubok_upload_document'])
describe('MCP_TOOL_CAPABILITY_MAP', () => {
it('gates exactly the paid MCP tools (3 external-service staging tools + the AI OCR tool)', () => {
expect(MCP_TOOL_CAPABILITY_MAP).toEqual({
gnubok_send_invoice: CAPABILITY.email_send,
gnubok_vat_declaration_submit: CAPABILITY.skatteverket,
gnubok_agi_submit: CAPABILITY.skatteverket,
// Dispatch-only AI tool — inline Bedrock OCR, no staged operation.
gnubok_upload_document: CAPABILITY.ai,
})
})
it('only maps tools to PAID capabilities', () => {
for (const key of Object.values(MCP_TOOL_CAPABILITY_MAP)) {
expect(PAID_CAPABILITIES).toContain(key)
}
})
})
describe('PAID_OPERATION_CAPABILITY_MAP', () => {
it('gates exactly the three paid pending-operation types', () => {
expect(PAID_OPERATION_CAPABILITY_MAP).toEqual({
send_invoice: CAPABILITY.email_send,
submit_vat_declaration: CAPABILITY.skatteverket,
submit_agi: CAPABILITY.skatteverket,
})
})
it('only maps operations to PAID capabilities', () => {
for (const key of Object.values(PAID_OPERATION_CAPABILITY_MAP)) {
expect(PAID_CAPABILITIES).toContain(key)
}
})
it('covers the same capabilities as the STAGING MCP tools (dispatch ↔ commit parity)', () => {
// Parity applies to staging tools only: an op that can be staged via MCP OR
// approved in the UI must be gated on both transports. Dispatch-only tools
// (inline AI OCR) have no commit counterpart and are excluded.
const stagingMcpCaps = new Set(
Object.entries(MCP_TOOL_CAPABILITY_MAP)
.filter(([tool]) => !DISPATCH_ONLY_MCP_TOOLS.has(tool))
.map(([, cap]) => cap),
)
expect(new Set(Object.values(PAID_OPERATION_CAPABILITY_MAP))).toEqual(stagingMcpCaps)
})
})