diff --git a/gateway/app/routers/mcp.py b/gateway/app/routers/mcp.py index 49a9727c..6e4985a3 100644 --- a/gateway/app/routers/mcp.py +++ b/gateway/app/routers/mcp.py @@ -1197,6 +1197,103 @@ async def _assert_action_current_access( raise ToolPermissionDenied(policy.reason or "policy_denied") +async def _deny_tool_outside_permit( + *, + idem: Any, + idem_started: bool, + permit_id: str, + wallet_id: str, + auth: AuthContext, + tool_name: str, + arguments: dict[str, Any], + endpoint: str, + idempotency_endpoint: str, + idempotency_key: str | None, + transport: str, + request_id: str | None, + effective_request_payload: dict[str, Any], + effective_request_hash: str, +) -> None: + """Refuse a permit denial before registration can influence its evidence. + + Returns without effect when the permit allows the tool. No credits are + authorized, reserved, charged, or dispatched for a refusal. + """ + # Validate the permit (wallet, key, signature, expiry) BEFORE its allowlist + # influences the outcome. Reading allowed_tools first let a caller probe a + # permit_id belonging to another wallet: "not found" vs. a denial revealed + # whether that permit names the tool. A permit that fails validation keeps + # its narrower reason and details without exposing the allowlist. + validation = await get_permit_service().validate_for_action( + permit_id=permit_id, + wallet_id=wallet_id or "", + tool_name=tool_name, + estimated_credits=Decimal("0"), + key_id=auth.key_id, + arguments=arguments, + ) + if validation.allowed: + # Continue to registration and price checks. + return + reason = validation.reason or "permit_tool_not_allowed" + details = validation.details + decision = evaluate_tool_invocation( + auth=auth, + wallet_id=wallet_id, + tool_name=tool_name, + estimated_cost=None, + request_id=request_id, + ) + audit_event = await _audit_mcp_invocation( + effects_committed=False, + decision=decision, + endpoint=endpoint, + transport=transport, + ok=False, + error=reason, + extra_metadata={ + "permit_id": permit_id, + "idempotency_key": idempotency_key, + "request_hash": effective_request_hash, + }, + ) + receipt_payload = None + # A signed receipt is minted only for an idempotent call: without a key + # there is no record to bind it to and retries would mint one per call. + if validation.permit is not None and wallet_id and idempotency_key: + receipt_payload = await _finalize_governed_denial( + unaccepted_action_owner_id=None, + idem=idem, + effects_committed=False, + approval_consumed=False, + permit_model=validation.permit, + wallet_id=wallet_id, + key_id=auth.key_id, + endpoint=idempotency_endpoint, + idempotency_key=idempotency_key, + tool_name=tool_name, + request_payload=effective_request_payload, + arguments=arguments, + registered_cost=Decimal("0"), + audit_event_id=audit_event.event_id, + reason=reason, + reason_code=reason, + outcome="denied", + status_code=403, + response_extra_data={"details": details}, + ) + else: + await _complete_governed_denial_idempotency( + idem=idem, + idem_started=idem_started, + wallet_id=wallet_id, + endpoint=idempotency_endpoint, + idempotency_key=idempotency_key, + reason=reason, + ) + raise ToolPermissionDenied(reason, receipt=receipt_payload, details=details) + + async def _execute_registered_tool_inner( *, tool_name: str, @@ -1292,25 +1389,75 @@ async def _execute_registered_tool_inner( service = registry.get_local(tool_name) if not service: service = await registry.get_persistent(tool_name) + if service and service.get("require_permit"): + governed_call = True + idempotency_endpoint = GOVERNED_MCP_IDEMPOTENCY_ENDPOINT action_identity = None action_binding = registry.get_action_binding(service) if service else None action_model = None if permit_id: async with get_session_factory()() as session: action_model = await session.get(PermitModel, permit_id) - is_action = action_model is not None and any( - getattr(action_model, field) is not None - for field in ( - "action_contract_version", - "action_payload_hash", - "action_schema_id", - "action_schema_version", - "action_public_tool_id", - "action_upstream_binding_hash", - ) + action_fields = ( + [getattr(action_model, field) for field in _ACTION_PERMIT_FIELDS] + if action_model is not None + else [] ) - if action_binding is not None or is_action: + is_action = any(value is not None for value in action_fields) + # An action permit that does not name this tool is a permit-scope denial, + # decided before any registry-dependent action branch (binding lookup, + # validate_action_request). It takes the same governed path as a tool + # permit below: idempotency-key and quote gates, idempotency begin, then + # _deny_tool_outside_permit, which validates the permit (signature first) + # and, with an idempotency key, mints one signed denial receipt that a + # same-key retry replays. Registered and unregistered tools are + # indistinguishable. + action_out_of_scope = ( + is_action + # A partially populated action row (some action_* fields set, others + # NULL) is malformed storage and stays on the action branch below. + and all(value is not None for value in action_fields) + and permit_id is not None + and tool_name not in _permit_allowed_tools(action_model) + ) + if (action_binding is not None or is_action) and not action_out_of_scope: + if ( + is_action + and permit_id + and not all(value is not None for value in action_fields) + ): + # A partial action row is validated (signature first) before any + # binding decision, whatever the registry holds; a complete + # in-scope one is validated by validate_action_request below. No + # idempotency record has been begun here and none is passed, so + # _deny_tool_outside_permit itself mints nothing and completes + # nothing. The ToolPermissionDenied it raises reaches the caller + # through _execute_registered_tool, which attaches an unbound + # receipt (idempotency_record_id = None, one per call, never + # replayed) via _action_request_denial_receipt when the row still + # verifies; a row that fails signature verification (e.g. + # permit_signature_invalid) gets no receipt at all. + await _deny_tool_outside_permit( + idem=idem, + idem_started=False, + permit_id=permit_id, + wallet_id=wallet_id, + auth=auth, + tool_name=tool_name, + arguments=arguments, + endpoint=endpoint, + idempotency_endpoint=idempotency_endpoint, + idempotency_key=None, + transport=transport, + request_id=request_id, + effective_request_payload=effective_request_payload, + effective_request_hash=effective_request_hash, + ) if action_binding is None: + # An in-scope action permit for a tool with no action binding + # (registered without one, or unregistered). Nothing has been + # begun or recorded; _execute_registered_tool attaches an unbound + # denial receipt when the permit row verifies. raise ToolPermissionDenied("action_tool_binding_required") if not is_action or action_model is None: raise ToolPermissionDenied("action_permit_required") @@ -1332,52 +1479,151 @@ async def _execute_registered_tool_inner( effective_request_payload = action_identity.request_payload effective_request_hash = sha256_hex(effective_request_payload) - if not service: - # Durable replay is authoritative even if an operator has since - # removed the executable registration. This is essential for terminal - # evidence recovery and avoids turning a completed invocation into a - # fresh "not found" response after restart or reconfiguration. - if governed_call and idempotency_key: - try: - idem_begin = await _begin_governed_mcp_idempotency( - idem=idem, + if action_identity is None: + if governed_call and permit_id and not idempotency_key: + await _audit_mcp_invocation( + effects_committed=False, + decision=tenant_decision, + endpoint=endpoint, + transport=transport, + ok=False, + error="idempotency_key_required", + extra_metadata={ + "permit_id": permit_id, + "request_hash": effective_request_hash, + }, + ) + raise ValueError("idempotency_key_required") + + # Quote validation and idempotency replay do not depend on registration. + # A refused request must cross the same gates for either registry state. + quoted = None + if quote_id and action_identity is None: + quoted = await get_quote_service().validate_for_action( + quote_id=quote_id, + wallet_id=wallet_id, + tool_name=tool_name, + ) + if not quoted.allowed or quoted.quote is None: + reason = quoted.reason or "quote_invalid" + await _audit_mcp_invocation( + effects_committed=False, + decision=tenant_decision, + endpoint=endpoint, + transport=transport, + ok=False, + error=reason, + extra_metadata={ + "quote_id": quote_id, + "permit_id": permit_id, + "idempotency_key": idempotency_key, + "request_hash": effective_request_hash, + }, + ) + raise PermissionError(reason) + + if ( + governed_call + and action_identity is None + and idempotency_key + and (permit_id or not service) + ): + execution_kind = ( + str(service.get("execution_backend") or "metadata_only") + if service + else "unresolved" + ) + try: + idem_begin = await _begin_governed_mcp_idempotency( + idem=idem, + wallet_id=wallet_id, + idempotency_key=idempotency_key, + tool_name=tool_name, + endpoint=endpoint, + logical_request_payload=effective_request_payload, + legacy_request_payload=request_payload, + operation_kind=execution_kind, + wait_timeout_seconds=( + idempotency_wait_seconds + if execution_kind == "upstream_mcp" + else 0.0 + ), + ) + replay = idem_begin.replay + idem_started = True + if owned_record is not None: + owned_record["record_id"] = idem_begin.record_id + except (IdempotencyConflictError, IdempotencyInProgressError) as exc: + await _audit_mcp_invocation( + effects_committed=False, + decision=tenant_decision, + endpoint=endpoint, + transport=transport, + ok=False, + error=str(exc), + extra_metadata={ + "permit_id": permit_id, + "idempotency_key": idempotency_key, + "request_hash": effective_request_hash, + }, + ) + if isinstance(exc, IdempotencyInProgressError): + raise + raise ValueError(str(exc)) from exc + if replay and replay.response_json: + if permit_id: + await _assert_governed_replay_access( + permit_id=permit_id, wallet_id=wallet_id, - idempotency_key=idempotency_key, tool_name=tool_name, - endpoint=endpoint, - logical_request_payload=effective_request_payload, - legacy_request_payload=request_payload, - operation_kind="unresolved", + key_id=auth.key_id, + replay=replay, ) - replay = idem_begin.replay - idem_started = True - if owned_record is not None: - owned_record["record_id"] = idem_begin.record_id - except IdempotencyInProgressError: - raise - except IdempotencyConflictError as exc: - raise ValueError(str(exc)) from exc - if replay and replay.response_json: - if permit_id: - await _assert_governed_replay_access( - permit_id=permit_id, - wallet_id=wallet_id, - tool_name=tool_name, - key_id=auth.key_id, - replay=replay, - ) - await _raise_replayed_error(replay) - return replay.response_json - reason = f"Tool not found: {tool_name}" - await _complete_governed_denial_idempotency( + await _raise_replayed_error(replay) + return replay.response_json + + if governed_call and permit_id and action_identity is None: + # Decide permit refusals without a registered price. Denials authorize + # no credits and cannot reveal whether this tool is executable. + await _deny_tool_outside_permit( idem=idem, idem_started=idem_started, + permit_id=permit_id, wallet_id=wallet_id, - endpoint=idempotency_endpoint, + auth=auth, + tool_name=tool_name, + arguments=arguments, + endpoint=endpoint, + idempotency_endpoint=idempotency_endpoint, idempotency_key=idempotency_key, - reason=reason, - status_code=400, + transport=transport, + request_id=request_id, + effective_request_payload=effective_request_payload, + effective_request_hash=effective_request_hash, ) + + if not service: + reason = f"Tool not found: {tool_name}" + if permit_id: + # A permitted call: registry state is not the key's final answer. + await _release_unresolved_tool_idempotency( + idem=idem, + idem_begin=idem_begin if idem_started else None, + wallet_id=wallet_id, + endpoint=idempotency_endpoint, + idempotency_key=idempotency_key, + ) + else: + # No permit: unchanged contract, a terminal replayable record. + await _complete_governed_denial_idempotency( + idem=idem, + idem_started=idem_started, + wallet_id=wallet_id, + endpoint=idempotency_endpoint, + idempotency_key=idempotency_key, + reason=reason, + status_code=400, + ) raise ValueError(reason) execution_backend = str(service.get("execution_backend") or "metadata_only") @@ -1385,51 +1631,38 @@ async def _execute_registered_tool_inner( upstream_executor = registry.get_executor(tool_name) if execution_backend == "local" and func is None: reason = f"Tool not executable: {tool_name}" - await _complete_governed_denial_idempotency( + await _release_unresolved_tool_idempotency( idem=idem, - idem_started=idem_started, + idem_begin=idem_begin if idem_started else None, wallet_id=wallet_id, endpoint=idempotency_endpoint, idempotency_key=idempotency_key, - reason=reason, - status_code=400, ) raise ValueError(reason) if execution_backend == "upstream_mcp" and upstream_executor is None: reason = f"Tool not executable: {tool_name}" - await _complete_governed_denial_idempotency( + await _release_unresolved_tool_idempotency( idem=idem, - idem_started=idem_started, + idem_begin=idem_begin if idem_started else None, wallet_id=wallet_id, endpoint=idempotency_endpoint, idempotency_key=idempotency_key, - reason=reason, - status_code=400, ) raise ValueError(reason) if execution_backend not in {"local", "upstream_mcp"}: reason = f"Tool not executable: {tool_name}" - await _complete_governed_denial_idempotency( + await _release_unresolved_tool_idempotency( idem=idem, - idem_started=idem_started, + idem_begin=idem_begin if idem_started else None, wallet_id=wallet_id, endpoint=idempotency_endpoint, idempotency_key=idempotency_key, - reason=reason, - status_code=400, ) raise ValueError(reason) category = ServiceCategory( service.get("category", ServiceCategory.PLATFORM_FEE.value) ) - # High-value tools can force the permit path even when legacy - # unpermitted MCP is otherwise allowed. - if service.get("require_permit"): - governed_call = True - if governed_call and action_identity is None: - idempotency_endpoint = GOVERNED_MCP_IDEMPOTENCY_ENDPOINT - registered_cost = _registered_tool_cost(service, category) # A quote is a signed commitment to a price. Resolve it before anything @@ -1437,40 +1670,7 @@ async def _execute_registered_tool_inner( # check, and the charge all see the number the caller was promised — not # the live price it may have drifted from. Nothing is spent here; the # single-use consume happens immediately before the charge. - quoted = None - if quote_id: - quoted = await get_quote_service().validate_for_action( - quote_id=quote_id, - wallet_id=wallet_id, - tool_name=tool_name, - ) - if not quoted.allowed or quoted.quote is None: - reason = quoted.reason or "quote_invalid" - await _audit_mcp_invocation( - effects_committed=False, - decision=tenant_decision, - endpoint=endpoint, - transport=transport, - ok=False, - error=reason, - extra_metadata={ - "quote_id": quote_id, - "permit_id": permit_id, - "idempotency_key": idempotency_key, - "request_hash": effective_request_hash, - }, - ) - await _complete_governed_denial_idempotency( - idem=idem, - idem_started=idem_started, - wallet_id=wallet_id, - endpoint=idempotency_endpoint, - idempotency_key=idempotency_key, - reason=reason, - ) - # Denying beats quietly charging a different number: a price lock - # that silently reprices is worse than no lock at all. - raise PermissionError(reason) + if quoted is not None and quoted.quote is not None: registered_cost = quoted.quote.quoted_credits charge_units = _charge_units_for_registered_cost(registered_cost, category) @@ -1507,29 +1707,7 @@ async def _execute_registered_tool_inner( "request_hash": effective_request_hash, }, ) - await _complete_governed_denial_idempotency( - idem=idem, - idem_started=idem_started, - wallet_id=wallet_id, - endpoint=idempotency_endpoint, - idempotency_key=idempotency_key, - reason="permit_required", - ) raise PermissionError("permit_required") - if governed_call and not idempotency_key: - await _audit_mcp_invocation( - effects_committed=False, - decision=decision, - endpoint=endpoint, - transport=transport, - ok=False, - error="idempotency_key_required", - extra_metadata={ - "permit_id": permit_id, - "request_hash": effective_request_hash, - }, - ) - raise ValueError("idempotency_key_required") if action_identity is not None: existing = await idem.get_action_record( @@ -1569,37 +1747,19 @@ async def _execute_registered_tool_inner( admission.reason or "action_permit_denied" ) - if governed_call and idempotency_key and not idem_started: + if governed_call and idempotency_key and action_identity is not None: try: - if action_identity is not None: - idem_begin = await idem.begin_action_with_record( - wallet_id=wallet_id, - identity=action_identity, - wait_timeout_seconds=idempotency_wait_seconds, - ) - else: - idem_begin = await _begin_governed_mcp_idempotency( - idem=idem, - wallet_id=wallet_id, - idempotency_key=idempotency_key, - tool_name=tool_name, - endpoint=endpoint, - logical_request_payload=effective_request_payload, - legacy_request_payload=request_payload, - operation_kind=execution_backend, - wait_timeout_seconds=( - idempotency_wait_seconds - if execution_backend == "upstream_mcp" - else 0.0 - ), - ) + idem_begin = await idem.begin_action_with_record( + wallet_id=wallet_id, + identity=action_identity, + wait_timeout_seconds=idempotency_wait_seconds, + ) replay = idem_begin.replay idem_started = True if owned_record is not None: owned_record["record_id"] = idem_begin.record_id - if action_identity is not None: - owned_record["endpoint"] = action_identity.endpoint - owned_record["key"] = action_identity.idempotency_key + owned_record["endpoint"] = action_identity.endpoint + owned_record["key"] = action_identity.idempotency_key except (IdempotencyConflictError, IdempotencyInProgressError) as exc: await _audit_mcp_invocation( effects_committed=False, @@ -1618,19 +1778,18 @@ async def _execute_registered_tool_inner( raise raise ValueError(str(exc)) if replay and replay.response_json: - if action_identity is not None: - # A waiter may observe completion after authority was revoked. - # This denial must never finalize or overwrite the accepted owner. - await _assert_action_current_access( - permit_id=permit_id or "", - wallet_id=wallet_id, - auth=auth, - tool_name=tool_name, - arguments=arguments, - money=money, - estimated_cost=Decimal("0"), - phase="replay", - ) + # A waiter may observe completion after authority was revoked. + # This denial must never finalize or overwrite the accepted owner. + await _assert_action_current_access( + permit_id=permit_id or "", + wallet_id=wallet_id, + auth=auth, + tool_name=tool_name, + arguments=arguments, + money=money, + estimated_cost=Decimal("0"), + phase="replay", + ) await _assert_governed_replay_access( permit_id=permit_id, wallet_id=wallet_id, @@ -3714,6 +3873,51 @@ def _governed_error_payload( return payload +_ACTION_PERMIT_FIELDS = ( + "action_contract_version", + "action_payload_hash", + "action_schema_id", + "action_schema_version", + "action_public_tool_id", + "action_upstream_binding_hash", +) + + +def _permit_allowed_tools(model: Any) -> list[str]: + """The permit row's allowed_tools; malformed storage reads as empty.""" + try: + value = json.loads(model.allowed_tools_json or "[]") + except (TypeError, ValueError): + return [] + return [str(item) for item in value] if isinstance(value, list) else [] + + +async def _release_unresolved_tool_idempotency( + *, + idem: Any, + idem_begin: IdempotencyBegin | None, + wallet_id: str, + endpoint: str, + idempotency_key: str | None, +) -> None: + """Release a governed claim when the tool cannot run yet. + + "Tool not found" / "Tool not executable" depend on the current registry, + not on the request, so they must not be stored as the key's final answer: + a same-key retry after the tool is registered or made executable has to + execute instead of replaying the old failure. Nothing has run and nothing + was charged, so the pinned record is simply deleted. + """ + if idem_begin is None or not idempotency_key: + return + await idem.abandon( + wallet_id=wallet_id, + endpoint=endpoint, + idempotency_key=idempotency_key, + expected_record_id=idem_begin.record_id, + ) + + async def _complete_governed_denial_idempotency( *, idem: Any, @@ -4246,6 +4450,7 @@ async def _raise_replayed_error(replay: Any) -> None: return reason = str(replay.response_json.get("error") or "governed_call_failed") receipt = replay.response_json.get("receipt") + details = replay.response_json.get("details") extra_data = {} reconciliation = replay.response_json.get("refund_reconciliation") if isinstance(reconciliation, dict): @@ -4280,7 +4485,6 @@ async def _raise_replayed_error(replay: Any) -> None: if isinstance(dispatch, dict): extra_data["dispatch"] = dispatch if replay.status_code == 403: - details = replay.response_json.get("details") raise ToolPermissionDenied( reason, receipt=receipt, @@ -4483,7 +4687,7 @@ async def _audit_mcp_invocation( error=error, metadata={ "transport": transport, - "estimated_cost": decision.estimated_cost, + **({"estimated_cost": decision.estimated_cost} if ok else {}), "policy_reason": decision.reason, **(extra_metadata or {}), }, diff --git a/gateway/app/routers/mcp_standard.py b/gateway/app/routers/mcp_standard.py index 31f8fe1d..d97939a9 100644 --- a/gateway/app/routers/mcp_standard.py +++ b/gateway/app/routers/mcp_standard.py @@ -465,10 +465,18 @@ async def _governed_tools_call( # require a prior tools/list before they resolve here. _ensure_local_mcp_tools_registered() record = get_service_registry().get_local(tool_name) - if record is None: + if record is None and not permit_id: + # No permit is minted for a tool that does not exist. With an explicit + # permit the governed pipeline decides instead: it checks the permit's + # allowlist first, so an unnamed tool is a -32003 permit denial with a + # receipt, and only a tool the permit names falls back to -32001. raise _mcp_error(-32001, f"Tool not found: {tool_name}") - if get_service_registry().get_action_binding(record) is not None and not permit_id: + if ( + record is not None + and get_service_registry().get_action_binding(record) is not None + and not permit_id + ): raise _mcp_error(-32003, "action_permit_required") # Wallet policy shapes the permit this surface mints. A policy demanding a @@ -499,13 +507,16 @@ async def _governed_tools_call( auto_minted = permit_id is None idempotency_key = client_idempotency_key or f"mcp-auto-{uuid.uuid4().hex}" - permit_id = permit_id or await _mint_auto_permit( - auth=auth, - tool_name=tool_name, - record=record, - client_key=client_idempotency_key, - requires_human_approval=requires_human_approval, - ) + if not permit_id: + if record is None: # Checked before any auto-mint. + raise RuntimeError("auto-permit mint requires a resolved tool record") + permit_id = await _mint_auto_permit( + auth=auth, + tool_name=tool_name, + record=record, + client_key=client_idempotency_key, + requires_human_approval=requires_human_approval, + ) call_params = { "name": tool_name, diff --git a/gateway/app/services/permits.py b/gateway/app/services/permits.py index 19053f7a..934e39fd 100644 --- a/gateway/app/services/permits.py +++ b/gateway/app/services/permits.py @@ -952,6 +952,18 @@ async def _validate_model_for_action( # reported as allowed and later subtracted from spent_credits. if not _storable_reservation_amount(estimated_credits): return PermitValidation(False, "permit_credits_invalid", model) + # Verify the signature before reading any permit content. Every check + # below reads permit fields (status, binding, allowed_tools, scopes, + # budgets) and some echo them back in denial details, so a forged or + # tampered row must be rejected before any of it is trusted or + # disclosed. + if not await self.verify_signature(model, session=session): + return PermitValidation( + False, + "permit_signature_invalid", + model, + {"key_id": model.key_id}, + ) now = utc_now() if model.status != "active": return PermitValidation( @@ -1109,13 +1121,6 @@ async def _validate_model_for_action( {"field": hit, "forbidden_fields": forbidden}, ) - if not await self.verify_signature(model, session=session): - return PermitValidation( - False, - "permit_signature_invalid", - model, - {"key_id": model.key_id}, - ) return PermitValidation(True, None, model) @staticmethod @@ -2248,7 +2253,13 @@ async def verify_signature( *, session: AsyncSession | None = None, ) -> bool: - payload = self._verification_payload(model) + # A row whose signed fields cannot even be rebuilt (for example a + # partial action contract) cannot carry a valid signature. Fail + # closed instead of raising, now that this runs before other checks. + try: + payload = self._verification_payload(model) + except ValueError: + return False return await get_signing_key_service().verify_payload( payload, signature=model.signature, diff --git a/gateway/docs/POLICY_ENFORCEMENT.md b/gateway/docs/POLICY_ENFORCEMENT.md index 3d668961..145fbde7 100644 --- a/gateway/docs/POLICY_ENFORCEMENT.md +++ b/gateway/docs/POLICY_ENFORCEMENT.md @@ -45,11 +45,13 @@ only reachable in local/dev/test, and it logs a loud startup warning. | # | Gate | Source | On failure | |---|------|--------|------------| | 1 | **Wallet ownership — unpriced** (before the idempotency store is touched) | `evaluate_tool_invocation` | `wallet_access_denied` | -| 2 | **Tool resolution + executability** | service registry | `Tool not found` / `Tool not executable` | -| 3 | **Wallet ownership — priced** (re-run with real cost, for the audit record) | `evaluate_tool_invocation` | `wallet_access_denied` | -| 4 | **Governed preconditions** | router | `permit_required` / `idempotency_key_required` | -| 5 | **Idempotency begin** (completed prior call replays its stored signed receipt) | `get_idempotency_service()` | replay short-circuit | -| 6 | **Permit validation** (Layer B) | `PermitService.validate_for_action` | `permit_*` | +| 2 | **Governed preconditions** (independent of tool registration) | router | `idempotency_key_required` | +| 3 | **Quote validation**, when a `quote_id` is supplied | quote service | `quote_*` | +| 4 | **Idempotency begin** (completed prior call replays its stored signed receipt) | `get_idempotency_service()` | replay short-circuit | +| 5 | **Permit validation** (Layer B), including `allowed_tools`, decided with no registered price. A tool outside the permit's allowlist is denied `permit_tool_not_allowed` for tool permits and action permits alike, identically for registered and unregistered tools; with an idempotency key the denial mints a signed receipt with `credits_authorized = 0` bound to the record from step 4, and a same-key retry replays that receipt | `PermitService.validate_for_action` | `permit_*` | +| 5a | **Action binding** for an in-scope *action* permit (decided ahead of step 4, so no idempotency record is begun). A tool with no action binding (registered without one, or unregistered) is denied `action_tool_binding_required`, never `Tool not found`, and the unbound denial receipt is minted per call. A bound tool is then checked by `validate_action_request` | service registry + `validate_action_request` | `action_*` | +| 6 | **Tool resolution + executability**. On a tool-permit call the idempotency claim from step 4 is released, so a same-key retry runs normally once the tool is registered or executable | service registry | `Tool not found` / `Tool not executable` | +| 6a | **Wallet ownership — priced** (re-run with real cost, for the audit record); calls without a permit get `permit_required` here on a resolved tool | `evaluate_tool_invocation` / router | `wallet_access_denied` / `permit_required` | | 7 | **Wallet policy bundles** (Layer C) | `evaluate_wallet_policy` | policy reason | | 8 | **Human-approval gate** (Layer D) | `_require_human_approval` | blocks / `human_approval_unavailable` | | 9 | **Recipient-domain binding** (Layer E) | router | `permit_recipient_domain_mismatch` | @@ -82,6 +84,7 @@ capability bound to a wallet (and optionally an API key). | Check | Reason code | |-------|-------------| +| Ed25519 signature over the permit verifies — checked **first**, right after the credits-amount check and before any other permit field is read | `permit_signature_invalid` | | Status must be `active` | `permit_{status}` (e.g. `permit_revoked`) | | Not past `expires_at` | `permit_expired` | | Permit's wallet == caller wallet | `permit_wallet_mismatch` | @@ -92,7 +95,6 @@ capability bound to a wallet (and optionally an API key). | Per-tool call cap `max_calls_per_tool` (v2) — atomically reserved on local governed tools and remote (`upstream_mcp`) tools via a durable `call_slot_reserved` marker linked to the prepared dispatch attempt; cross-key retries are denied `permit_max_calls_exceeded` once the cap is reached; same-key retries replay the stored receipt | `permit_max_calls_exceeded` | | Cumulative `aggregate_value_cap` (v2) — enforced on the local MCP and AWI HTTP paths as a predicate of the atomic reservation against `spent_credits` floored to receipt history, so in-flight reservations and already-receipted charges both count; configured upstream calls fail closed | `permit_aggregate_value_cap_exceeded` locally; `permit_constraint_unsupported_for_upstream` remotely | | `forbidden_fields` (v2): deep scan of tool arguments for banned keys | `permit_forbidden_field:{field}` | -| Ed25519 signature over the permit verifies — checked **last** | `permit_signature_invalid` | The local per-tool call limit is enforced with a persisted reservation counter, including an optimistic compare-and-swap when the database does not honor the diff --git a/gateway/docs/failure-semantics.md b/gateway/docs/failure-semantics.md index 5a366e39..18e74e01 100644 --- a/gateway/docs/failure-semantics.md +++ b/gateway/docs/failure-semantics.md @@ -55,17 +55,49 @@ classified as already sent; they are never claimable again. | `failed_unrefunded` | A refund was owed and the refund **itself** failed | Charged; a durable operator work item is created atomically with the receipt | Depends on the underlying failure | 500 / `-32603` | `test_governed_refund_failure_keeps_permit_budget_reserved`, `test_refund_reconciliation_retries_exactly_once_and_preserves_agent_replay` | Requests rejected before a valid permit and an executable tool are established -produce no receipt — there is no authority to bill against. The README states -this limit. What they leave behind differs by reason: - -- An unknown tool (`Tool not found`) or `permit_not_found` on a governed call - that carries an idempotency key completes a terminal *idempotency record*; - a same-key replay returns the original error envelope. -- `permit_required` on a resolved tool, and a resolved tool that is not - executable, record nothing: the governed idempotency record is only begun - after the permit check, so the completion call in `app/routers/mcp.py` is a - no-op. A same-key retry returns the same error while nothing has changed, - and after minting a permit it executes normally +produce no billing receipt — there is no authority to bill against. The README +states this limit. What they leave behind differs by reason: + +- A governed call with a valid permit that names a tool outside the permit's + `allowed_tools` gets 403 / `-32003` `permit_tool_not_allowed`, whether or not + the tool is registered and whether the permit is a tool permit or an action + permit. With an idempotency key it mints a signed denial receipt + (`credits_authorized = 0`, `credits_charged = 0`) bound to the idempotency + record (`idempotency_record_id` set) and completes that record as terminal, + so a same-key replay returns the same envelope and the same receipt + (`test_denials_do_not_reveal_registration`, + `test_action_permit_denial_identical_for_registry_state`, + `test_action_permit_denial_replays_same_receipt_for_same_key`). +- An action permit that *does* name the tool, used against a tool that has no + action binding (registered without one, or not registered), gets 403 / + `-32003` `action_tool_binding_required`, not `Tool not found`. No idempotency + record is begun. The response carries an unbound denial receipt + (`idempotency_record_id = null`) minted per call while the permit row still + verifies, so a same-key retry returns a fresh receipt rather than a replay. +- An unknown tool (`Tool not found`) on a governed call *without* a permit, or + `permit_not_found`, on a call that carries an idempotency key completes a + terminal *idempotency record*; a same-key replay returns the original error + envelope + (`test_strict_mode_replays_missing_tool_lookup_error_with_idempotency_key`). +- A call *without* a permit to a registered but non-executable tool (`Tool not + executable`) records nothing: no governed record is begun for a resolved tool + without a permit, so the key is untouched + (`test_strict_mode_replays_non_executable_tool_error_with_idempotency_key`). +- On a call that carries a valid *tool* permit naming the tool, `Tool not found` + and `Tool not executable` record nothing: they depend on the current + registry, not on the request, so the governed idempotency claim is released + (`_release_unresolved_tool_idempotency` in `app/routers/mcp.py`). A same-key + retry executes normally once the tool is registered or executable + (`test_unresolved_tool_failure_is_not_replayed_after_fix`). This holds when + the release runs. If the process dies between saving the claim and releasing + it, the row stays behind with `operation_kind` `unresolved` or `local`; + `reconcile_stuck_records` sweeps only `upstream_mcp` rows, and no TTL + applies to idempotency records, so a same-key retry answers + `idempotency_in_progress` until an operator clears the row. Retrying under + a new key is unaffected. +- `permit_required` on a resolved tool records nothing. A same-key retry returns + the same error while nothing has changed, and after minting a permit it + executes normally (`test_strict_mode_missing_permit_denial_leaves_no_idempotency_record`). Whether that stays the contract is the open D2 decision in [authority-required-flow.md](authority-required-flow.md). diff --git a/gateway/scripts/red_team_trust_plane.py b/gateway/scripts/red_team_trust_plane.py index 130fe419..2ebc2962 100644 --- a/gateway/scripts/red_team_trust_plane.py +++ b/gateway/scripts/red_team_trust_plane.py @@ -71,7 +71,9 @@ def configure_environment() -> None: from app.db.models import PermitModel # noqa: E402 from app.main import app # noqa: E402 from app.schemas.billing import ServiceCategory # noqa: E402 +from app.services.permits import get_permit_service # noqa: E402 from app.services.service_registry import get_service_registry # noqa: E402 +from app.services.signing_keys import get_signing_key_service # noqa: E402 def remove_demo_db() -> None: @@ -223,6 +225,13 @@ async def _force_permit_expired(permit_id: str) -> None: ) model = result.scalar_one() model.expires_at = datetime.now(timezone.utc) - timedelta(minutes=5) + # The signature is verified before expiry is read, so re-sign the + # row to model a genuinely expired permit rather than a forged one. + signature, kid, _ = await get_signing_key_service().sign_payload( + get_permit_service()._unsigned_payload(model) + ) + model.signature = signature + model.key_id = kid session.add(model) await session.commit() diff --git a/gateway/tests/test_denial_details_and_self_service.py b/gateway/tests/test_denial_details_and_self_service.py index df0040ca..bbb661bd 100644 --- a/gateway/tests/test_denial_details_and_self_service.py +++ b/gateway/tests/test_denial_details_and_self_service.py @@ -34,6 +34,7 @@ BOOTSTRAP_HEADERS, create_tool_permit, provision_agent_wallet, + resign_permit_model, ) TOOL = "denial-echo" @@ -163,6 +164,7 @@ async def test_expired_denial_says_when_it_expired( model.expires_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( minutes=5 ) + await resign_permit_model(model) session.add(model) await session.commit() diff --git a/gateway/tests/test_mcp_upstream_governed.py b/gateway/tests/test_mcp_upstream_governed.py index 7aa8a573..fd0e23ea 100644 --- a/gateway/tests/test_mcp_upstream_governed.py +++ b/gateway/tests/test_mcp_upstream_governed.py @@ -62,6 +62,7 @@ register_configured_upstream_mcp, ) from tests.test_trust_helpers import ( + resign_permit_model, BOOTSTRAP_HEADERS, create_tool_permit, provision_agent_wallet, @@ -2777,6 +2778,7 @@ async def test_upstream_permit_denials_never_charge_or_dispatch( model = await session.get(PermitModel, permit["permit_id"]) assert model is not None model.expires_at = utc_now() - timedelta(seconds=1) + await resign_permit_model(model) session.add(model) await session.commit() diff --git a/gateway/tests/test_permit_break_it.py b/gateway/tests/test_permit_break_it.py index 7068a90d..24933558 100644 --- a/gateway/tests/test_permit_break_it.py +++ b/gateway/tests/test_permit_break_it.py @@ -25,7 +25,7 @@ from app.services.ownership import CallerIdentity from app.services.permits import PermitError, get_permit_service from app.services.receipts import get_receipt_service -from tests.test_trust_helpers import provision_agent_wallet +from tests.test_trust_helpers import provision_agent_wallet, resign_permit_model @pytest.fixture @@ -84,6 +84,7 @@ async def _backdate_expiry(permit_id: str) -> None: model = await session.get(PermitModel, permit_id) assert model is not None model.expires_at = utc_now() - timedelta(minutes=5) + await resign_permit_model(model) session.add(model) diff --git a/gateway/tests/test_permit_unknown_tool.py b/gateway/tests/test_permit_unknown_tool.py new file mode 100644 index 00000000..87215985 --- /dev/null +++ b/gateway/tests/test_permit_unknown_tool.py @@ -0,0 +1,998 @@ +"""A permit's allowlist is checked before the tool registry. + +When a governed call carries an explicit permit and names a tool the permit +does not allow, the refusal is ``permit_tool_not_allowed`` (-32003 / HTTP 403) +with a signed denial receipt, whether or not the tool is registered. Calls +without a permit, and tools the permit does name, keep "Tool not found". +""" + +from __future__ import annotations + +from decimal import Decimal +from uuid import uuid4 + +import pytest +from httpx import ASGITransport, AsyncClient +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, PermitModel, ReceiptModel +from app.main import app +from app.routers import mcp as mcp_router +from app.services.dogfood_tool import ( + DOGFOOD_SECOND_TOOL_ID, + DOGFOOD_TOOL_ID, + sync_dogfood_tool_registration, +) +from app.services.audit_log import list_audit_events +from app.services.quotes import get_quote_service +from app.services.receipts import get_receipt_service +from app.services.service_registry import get_service_registry +from tests.test_mcp_trust_mode import ( + _assert_denial_audited, + _assert_no_tool_debits, + _governed_idempotency_record_count, + _jsonrpc_body, + strict_trust_mode, # noqa: F401 - fixture +) +from tests.test_trust_helpers import ( + create_tool_permit, + provision_agent_wallet, + resign_permit_model, +) + +MCP_HEADERS = { + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-06-18", +} + +# Real product tool ids from app/services/dogfood_tool.py. The fixture below +# registers partner.notes.write (ENABLE_DOGFOOD_TOOL) and keeps +# partner.notes.count unregistered (ENABLE_DOGFOOD_SECOND_TOOL off). +ALLOWED = DOGFOOD_TOOL_ID +UNKNOWN = DOGFOOD_SECOND_TOOL_ID + + +@pytest.fixture(autouse=True) +def real_tools(monkeypatch): + monkeypatch.setenv("ENABLE_DOGFOOD_TOOL", "true") + monkeypatch.setenv("ENABLE_DOGFOOD_SECOND_TOOL", "false") + get_settings.cache_clear() + sync_dogfood_tool_registration() + registry = get_service_registry() + assert registry.get_local(ALLOWED) is not None + assert registry.get_local(UNKNOWN) is None + yield + monkeypatch.setenv("ENABLE_DOGFOOD_TOOL", "false") + get_settings.cache_clear() + sync_dogfood_tool_registration() + + +@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 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() + + +async def _permit_for_allowed_tool(client: AsyncClient) -> tuple[dict, dict]: + 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=ALLOWED, + ) + return provisioned, permit + + +async def _assert_denial_receipt(receipt: dict, *, permit_id: str, wallet_id: str): + assert receipt["outcome"] == "denied" + assert receipt["reason_code"] == "permit_tool_not_allowed" + assert receipt["permit_id"] == permit_id + assert receipt["wallet_id"] == wallet_id + assert receipt["tool"] == UNKNOWN + assert receipt["ledger_entry_id"] is None + assert str(receipt["credits_charged"]) in {"0", "0.0", "0.00"} + assert (await get_receipt_service().verify_receipt(receipt["receipt_id"]))[0] + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_jsonrpc_unregistered_tool_outside_permit_is_permit_denial( + client, clean_database +): + provisioned, permit = await _permit_for_allowed_tool(client) + resp = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"unknown-tool-permit-call-{uuid4()}", + permit_id=permit["permit_id"], + idempotency_key=f"unknown-tool-permit-idem-{uuid4()}", + ), + headers=provisioned["agent_headers"], + ) + + error = resp.json()["error"] + assert error["code"] == -32003 + assert error["message"] == "permit_tool_not_allowed" + assert error["data"]["details"]["requested_tool"] == UNKNOWN + assert error["data"]["details"]["allowed_tools"] == [ALLOWED] + await _assert_denial_receipt( + error["data"]["receipt"], + permit_id=permit["permit_id"], + wallet_id=provisioned["agent_wallet_id"], + ) + await _assert_denial_audited( + wallet_id=provisioned["agent_wallet_id"], + tool_name=UNKNOWN, + reason="permit_tool_not_allowed", + ) + await _assert_no_tool_debits( + client=client, + wallet_id=provisioned["agent_wallet_id"], + headers=provisioned["agent_headers"], + tool_name=UNKNOWN, + ) + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_jsonrpc_permit_denial_for_unregistered_tool_replays( + client, clean_database +): + provisioned, permit = await _permit_for_allowed_tool(client) + body = _jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"unknown-tool-permit-replay-{uuid4()}", + permit_id=permit["permit_id"], + idempotency_key=f"unknown-tool-permit-replay-idem-{uuid4()}", + ) + first = await client.post( + "/mcp/messages", json=body, headers=provisioned["agent_headers"] + ) + replay = await client.post( + "/mcp/messages", json=body, headers=provisioned["agent_headers"] + ) + first_error = first.json()["error"] + replay_error = replay.json()["error"] + assert first_error["code"] == replay_error["code"] == -32003 + assert first_error["message"] == "permit_tool_not_allowed" + assert replay_error["message"] == first_error["message"] + expected_details = { + "requested_tool": UNKNOWN, + "allowed_tools": [ALLOWED], + } + assert first_error["data"]["details"] == expected_details + assert replay_error["data"]["details"] == expected_details + assert first_error["data"]["receipt"] == replay_error["data"]["receipt"] + await _assert_denial_receipt( + replay_error["data"]["receipt"], + permit_id=permit["permit_id"], + wallet_id=provisioned["agent_wallet_id"], + ) + async with get_session_factory()() as session: + receipts = await session.scalar( + select(func.count()) + .select_from(ReceiptModel) + .where( + ReceiptModel.wallet_id == provisioned["agent_wallet_id"], + ReceiptModel.tool == UNKNOWN, + ) + ) + debits = await session.scalar( + select(func.count()) + .select_from(LedgerEntryModel) + .where( + LedgerEntryModel.wallet_id == provisioned["agent_wallet_id"], + LedgerEntryModel.action == "debit", + ) + ) + assert receipts == 1 + assert debits == 0 + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_rest_unregistered_tool_outside_permit_is_403_with_receipt( + client, clean_database +): + provisioned, permit = await _permit_for_allowed_tool(client) + resp = await client.post( + f"/mcp/tools/{UNKNOWN}/invoke", + json={ + "name": UNKNOWN, + "arguments": {"message": "hello"}, + "mcp_context": { + "wallet_id": provisioned["agent_wallet_id"], + "permit_id": permit["permit_id"], + "idempotency_key": f"unknown-tool-permit-rest-idem-{uuid4()}", + }, + }, + headers=provisioned["agent_headers"], + ) + assert resp.status_code == 403 + detail = resp.json()["detail"] + assert detail["error"] == "permit_tool_not_allowed" + await _assert_denial_receipt( + detail["receipt"], + permit_id=permit["permit_id"], + wallet_id=provisioned["agent_wallet_id"], + ) + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_unregistered_tool_named_by_permit_is_still_not_found( + client, clean_database +): + 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=UNKNOWN, + ) + body = { + "name": UNKNOWN, + "arguments": {"message": "hello"}, + "mcp_context": { + "wallet_id": provisioned["agent_wallet_id"], + "permit_id": permit["permit_id"], + "idempotency_key": f"named-unknown-rest-idem-{uuid4()}", + }, + } + rest = await client.post( + f"/mcp/tools/{UNKNOWN}/invoke", json=body, headers=provisioned["agent_headers"] + ) + assert rest.status_code == 404 + rpc = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"named-unknown-rpc-{uuid4()}", + permit_id=permit["permit_id"], + idempotency_key=f"named-unknown-rpc-idem-{uuid4()}", + ), + headers=provisioned["agent_headers"], + ) + assert rpc.json()["error"]["code"] == -32001 + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_unregistered_tool_without_permit_is_unchanged(client, clean_database): + provisioned = await provision_agent_wallet(client) + rpc = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"no-permit-unknown-{uuid4()}", + idempotency_key=f"no-permit-unknown-idem-{uuid4()}", + ), + headers=provisioned["agent_headers"], + ) + assert rpc.json()["error"]["code"] == -32001 + assert rpc.json()["error"]["message"] == f"Tool not found: {UNKNOWN}" + assert mcp_router.settings.TRUST_MODE_ENABLED is True + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_standard_mcp_explicit_permit_unregistered_tool_is_permit_denial( + client, clean_database, standard_mcp_enabled +): + provisioned, permit = await _permit_for_allowed_tool(client) + resp = await client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": UNKNOWN, + "arguments": {}, + "_meta": {"io.agentmiddleware/permit_id": permit["permit_id"]}, + }, + }, + headers={**provisioned["agent_headers"], **MCP_HEADERS}, + ) + error = resp.json()["error"] + assert error["code"] == -32003 + assert error["message"] == "permit_tool_not_allowed" + await _assert_denial_receipt( + error["data"]["receipt"], + permit_id=permit["permit_id"], + wallet_id=provisioned["agent_wallet_id"], + ) + + +@pytest.mark.anyio +async def test_standard_mcp_without_permit_unknown_tool_still_not_found( + client, clean_database, standard_mcp_enabled +): + """No permit is auto-minted for a tool that does not exist.""" + provisioned = await provision_agent_wallet(client) + resp = await client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": UNKNOWN, "arguments": {}}, + }, + headers={**provisioned["agent_headers"], **MCP_HEADERS}, + ) + assert resp.json()["error"]["code"] == -32001 + + +async def _receipt_count(wallet_id: str) -> int: + async with get_session_factory()() as session: + return await session.scalar( + select(func.count()) + .select_from(ReceiptModel) + .where(ReceiptModel.wallet_id == wallet_id) + ) + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +@pytest.mark.parametrize("transport", ["jsonrpc", "rest"]) +@pytest.mark.parametrize( + ("case", "expected_reason"), + [ + ("missing_key", "idempotency_key_required"), + ("missing_quote", "permit_tool_not_allowed"), + ("invalid_quote", "quote_not_found"), + ("denied_permit", "permit_tool_not_allowed"), + ], +) +async def test_denials_do_not_reveal_registration( + client, clean_database, monkeypatch, transport, case, expected_reason +): + provisioned, permit = await _permit_for_allowed_tool(client) + wallet_id = provisioned["agent_wallet_id"] + quote_id = None + if case == "denied_permit": + quote = await get_quote_service().create_quote( + wallet_id=wallet_id, + tool=UNKNOWN, + quoted_credits=Decimal("1"), + category="agent_comms", + ) + quote_id = quote.quote_id + elif case == "invalid_quote": + quote_id = f"quote-{uuid4()}" + + errors = [] + audits = [] + for registered in (True, False): + monkeypatch.setenv("ENABLE_DOGFOOD_SECOND_TOOL", str(registered).lower()) + get_settings.cache_clear() + sync_dogfood_tool_registration() + assert (get_service_registry().get_local(UNKNOWN) is not None) == registered + + request_id = f"registration-{case}-{registered}-{uuid4()}" + idempotency_key = None if case == "missing_key" else request_id + if transport == "jsonrpc": + body = _jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=wallet_id, + request_id=request_id, + permit_id=permit["permit_id"], + idempotency_key=idempotency_key, + ) + if quote_id is not None: + body["params"]["mcpContext"]["quote_id"] = quote_id + resp = await client.post( + "/mcp/messages", json=body, headers=provisioned["agent_headers"] + ) + assert resp.status_code == 200 + error = resp.json()["error"] + assert error["message"] == expected_reason + assert error["code"] == -32003 + else: + context = {"wallet_id": wallet_id, "permit_id": permit["permit_id"]} + if idempotency_key is not None: + context["idempotency_key"] = idempotency_key + if quote_id is not None: + context["quote_id"] = quote_id + resp = await client.post( + f"/mcp/tools/{UNKNOWN}/invoke", + json={ + "name": UNKNOWN, + "arguments": {"message": "hello"}, + "mcp_context": context, + }, + headers=provisioned["agent_headers"], + ) + assert resp.status_code == (400 if case == "missing_key" else 403) + error = resp.json()["detail"] + assert (error.get("error") if isinstance(error, dict) else error) == ( + expected_reason + ) + errors.append(error) + events = await list_audit_events( + event="mcp.invoke", + wallet_id=wallet_id, + tool=UNKNOWN, + request_id=request_id if transport == "jsonrpc" else None, + ok=False, + limit=1, + ) + assert len(events) == 1 + assert events[0].error == expected_reason + metadata = dict(events[0].metadata) + assert "tool_registered" not in metadata + assert "estimated_cost" not in metadata + metadata.pop("idempotency_key", None) + audits.append(metadata) + + assert audits[0] == audits[1] + for error in errors: + if transport == "jsonrpc": + receipt = error.get("data", {}).get("receipt") + else: + receipt = error.get("receipt") if isinstance(error, dict) else None + if case in {"missing_key", "invalid_quote"}: + assert receipt is None + else: + assert receipt is not None + assert (await get_receipt_service().verify_receipt(receipt["receipt_id"]))[ + 0 + ] + + def comparable(error): + if not isinstance(error, dict): + return error + normalized = dict(error) + container = ( + dict(error.get("data", {})) if transport == "jsonrpc" else normalized + ) + receipt = container.get("receipt") + if receipt is not None: + container["receipt"] = { + key: value + for key, value in receipt.items() + if key + not in { + "receipt_id", + "audit_event_id", + "idempotency_record_id", + "signature", + "created_at", + "issued_at", + } + } + if transport == "jsonrpc": + normalized["data"] = container + return normalized + + assert comparable(errors[0]) == comparable(errors[1]) + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_foreign_wallet_permit_does_not_leak_allowlist(client, clean_database): + """Another wallet's permit is validated before its allowlist is read. + + The caller gets the same wallet-mismatch denial whether or not the foreign + permit names the tool, and never sees that permit's allowed_tools. + """ + victim = await provision_agent_wallet(client) + names_unknown = await create_tool_permit( + client, + wallet_id=victim["agent_wallet_id"], + key_id=victim["key_id"], + tool_name=UNKNOWN, + idem_key="victim-permit-names-unknown", + ) + names_other = await create_tool_permit( + client, + wallet_id=victim["agent_wallet_id"], + key_id=victim["key_id"], + tool_name=ALLOWED, + idem_key="victim-permit-names-other", + ) + attacker = await provision_agent_wallet(client) + errors = [] + for permit in (names_unknown, names_other): + resp = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=attacker["agent_wallet_id"], + request_id=f"foreign-permit-{uuid4()}", + permit_id=permit["permit_id"], + idempotency_key=f"foreign-permit-idem-{uuid4()}", + ), + headers=attacker["agent_headers"], + ) + errors.append(resp.json()["error"]) + for error in errors: + assert error["code"] == -32003 + assert error["message"] == "permit_wallet_mismatch" + assert "allowed_tools" not in str(error.get("data", {}).get("details")) + assert ALLOWED not in str(error) + assert errors[0]["message"] == errors[1]["message"] + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_permit_denial_without_idempotency_key_mints_no_receipt( + client, clean_database +): + provisioned, permit = await _permit_for_allowed_tool(client) + before = await _receipt_count(provisioned["agent_wallet_id"]) + resp = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"no-idem-unknown-{uuid4()}", + permit_id=permit["permit_id"], + ), + headers=provisioned["agent_headers"], + ) + error = resp.json()["error"] + assert error["code"] == -32003 + assert error["message"] == "idempotency_key_required" + assert not error.get("data", {}).get("receipt") + assert await _receipt_count(provisioned["agent_wallet_id"]) == before + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_action_permit_unregistered_tool_gets_permit_decision( + client, clean_database +): + """Action permits are no longer excluded from the permit-scope check.""" + provisioned, permit = await _permit_for_allowed_tool(client) + async with get_session_factory()() as session: + model = await session.get(PermitModel, permit["permit_id"]) + # A complete, validly signed action binding for ALLOWED, so the call + # reaches the permit-scope check instead of a signature/binding error. + model.action_contract_version = 1 + model.action_payload_hash = "a" * 64 + model.action_schema_id = f"{ALLOWED}.args" + model.action_schema_version = "1" + model.action_public_tool_id = ALLOWED + model.action_upstream_binding_hash = "b" * 64 + await resign_permit_model(model) + await session.commit() + before = await _receipt_count(provisioned["agent_wallet_id"]) + resp = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"action-permit-unknown-{uuid4()}", + permit_id=permit["permit_id"], + idempotency_key=f"action-permit-unknown-idem-{uuid4()}", + ), + headers=provisioned["agent_headers"], + ) + error = resp.json()["error"] + assert error["code"] == -32003 + # The re-signed action permit reaches the permit-scope check. + assert error["message"] == "permit_tool_not_allowed", error + # A valid action permit's scope denial mints one signed denial receipt. + assert await _receipt_count(provisioned["agent_wallet_id"]) == before + 1 + receipt = error["data"]["receipt"] + await _assert_denial_receipt( + receipt, + permit_id=permit["permit_id"], + wallet_id=provisioned["agent_wallet_id"], + ) + + +async def _forge_signature(permit_id: str) -> None: + async with get_session_factory()() as session: + model = await session.get(PermitModel, permit_id) + sig = model.signature + model.signature = ("A" if sig[0] != "A" else "B") + sig[1:] + await session.commit() + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +@pytest.mark.parametrize("action", [False, True], ids=["tool", "action"]) +async def test_bad_signature_rejected_before_allowlist_is_read( + client, clean_database, action +): + """A tampered permit is refused on its signature, never on its allowlist. + + Before the fix the allowlist was read (and echoed) before the signature + was checked, so a forged row produced permit_tool_not_allowed with the + row's allowed_tools in the details. + """ + provisioned, permit = await _permit_for_allowed_tool(client) + await _forge_signature(permit["permit_id"]) + if action: + async with get_session_factory()() as session: + model = await session.get(PermitModel, permit["permit_id"]) + model.action_contract_version = 1 + await session.commit() + before = await _receipt_count(provisioned["agent_wallet_id"]) + resp = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"forged-permit-{uuid4()}", + permit_id=permit["permit_id"], + idempotency_key=f"forged-permit-idem-{uuid4()}", + ), + headers=provisioned["agent_headers"], + ) + error = resp.json()["error"] + assert error["code"] == -32003 + assert error["message"] == "permit_signature_invalid" + assert "allowed_tools" not in str(error) + assert ALLOWED not in str(error) + if action: + assert await _receipt_count(provisioned["agent_wallet_id"]) == before + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_bad_signature_rejected_before_any_permit_field(client, clean_database): + """The signature is checked before status, expiry or binding are read. + + A forged row that is also marked revoked must still be refused as + permit_signature_invalid, not answered from its unsigned status. + """ + provisioned, permit = await _permit_for_allowed_tool(client) + await _forge_signature(permit["permit_id"]) + async with get_session_factory()() as session: + model = await session.get(PermitModel, permit["permit_id"]) + model.status = "revoked" + await session.commit() + resp = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"forged-revoked-{uuid4()}", + permit_id=permit["permit_id"], + idempotency_key=f"forged-revoked-idem-{uuid4()}", + ), + headers=provisioned["agent_headers"], + ) + error = resp.json()["error"] + assert error["code"] == -32003 + assert error["message"] == "permit_signature_invalid" + assert "allowed_tools" not in str(error) + + +# DeepSeek review 5653058263: one test per leak path. Each sends the same +# permitted-but-denied call twice, once with UNKNOWN registered (registered +# but not allowed by the permit) and once unregistered, and compares a single +# caller-visible surface so each item fails on its own on the old code. +_VOLATILE_RECEIPT_FIELDS = { + "receipt_id", + "audit_event_id", + "idempotency_record_id", + "signature", + "created_at", + "issued_at", +} + + +async def _denial_for_each_registry_state( + client, monkeypatch, *, idempotency=True, quote_id=None +): + provisioned, permit = await _permit_for_allowed_tool(client) + wallet_id = provisioned["agent_wallet_id"] + results = {} + for registered in (True, False): + monkeypatch.setenv("ENABLE_DOGFOOD_SECOND_TOOL", str(registered).lower()) + get_settings.cache_clear() + sync_dogfood_tool_registration() + assert (get_service_registry().get_local(UNKNOWN) is not None) == registered + request_id = f"parity-{uuid4()}" + body = _jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=wallet_id, + request_id=request_id, + permit_id=permit["permit_id"], + idempotency_key=f"parity-idem-{uuid4()}" if idempotency else None, + ) + if quote_id is not None: + body["params"]["mcpContext"]["quote_id"] = quote_id + resp = await client.post( + "/mcp/messages", json=body, headers=provisioned["agent_headers"] + ) + error = resp.json()["error"] + events = await list_audit_events( + event="mcp.invoke", + wallet_id=wallet_id, + tool=UNKNOWN, + request_id=request_id, + ok=False, + limit=1, + ) + assert len(events) == 1 + metadata = dict(events[0].metadata) + metadata.pop("idempotency_key", None) + metadata.pop("request_hash", None) + metadata.pop("request_id", None) + results[registered] = (error, events[0].error, metadata) + return results + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_item1_audit_metadata_identical_for_registry_state( + client, clean_database, monkeypatch +): + results = await _denial_for_each_registry_state(client, monkeypatch) + (_, reg_err, reg_meta), (_, unreg_err, unreg_meta) = results[True], results[False] + assert reg_err == unreg_err == "permit_tool_not_allowed" + assert "tool_registered" not in reg_meta and "tool_registered" not in unreg_meta + assert reg_meta == unreg_meta + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_item2_denial_receipt_identical_for_registry_state( + client, clean_database, monkeypatch +): + results = await _denial_for_each_registry_state(client, monkeypatch) + receipts = [] + for registered in (True, False): + error = results[registered][0] + assert error["code"] == -32003 + assert error["message"] == "permit_tool_not_allowed" + receipt = error["data"]["receipt"] + assert Decimal(str(receipt["credits_authorized"])) == 0 + receipts.append( + { + k: v + for k, v in receipt.items() + if k not in _VOLATILE_RECEIPT_FIELDS | {"request_hash"} + } + ) + assert receipts[0] == receipts[1] + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +@pytest.mark.parametrize( + ("idempotency", "bogus_quote", "first_gate"), + [ + (False, False, "idempotency_key_required"), + (True, True, "quote_not_found"), + ], + ids=["no-idempotency-key", "bogus-quote"], +) +async def test_item3_same_gate_fires_first_for_registry_state( + client, clean_database, monkeypatch, idempotency, bogus_quote, first_gate +): + results = await _denial_for_each_registry_state( + client, + monkeypatch, + idempotency=idempotency, + quote_id=f"quote-{uuid4()}" if bogus_quote else None, + ) + for registered in (True, False): + error, audit_error, _ = results[registered] + assert error["code"] == -32003 + assert error["message"] == first_gate, registered + assert audit_error == first_gate + assert not error.get("data", {}).get("receipt") + assert results[True][2] == results[False][2] + + +async def _call_with_key(client, provisioned, permit_id, tool, key): + body = _jsonrpc_body( + tool_name=tool, + wallet_id=provisioned["agent_wallet_id"], + request_id=f"retry-{uuid4()}", + permit_id=permit_id, + idempotency_key=key, + ) + # Real dogfood signatures: write takes ``text``, count takes nothing. + body["params"]["arguments"] = {"text": "hello"} if tool == ALLOWED else {} + return await client.post( + "/mcp/messages", json=body, headers=provisioned["agent_headers"] + ) + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +@pytest.mark.parametrize("state", ["not_executable", "not_registered"]) +async def test_unresolved_tool_failure_is_not_replayed_after_fix( + client, clean_database, monkeypatch, state +): + """DeepSeek 5662392959 #1: a registry-state failure is not the key's + final answer; a same-key retry runs once the tool works.""" + provisioned = await provision_agent_wallet(client) + tool = ALLOWED if state == "not_executable" else UNKNOWN + permit = await create_tool_permit( + client, + wallet_id=provisioned["agent_wallet_id"], + key_id=provisioned["key_id"], + tool_name=tool, + ) + registry = get_service_registry() + if state == "not_executable": + real = registry.get_local_func + monkeypatch.setattr( + registry, + "get_local_func", + lambda name: None if name == ALLOWED else real(name), + ) + key = f"retry-idem-{uuid4()}" + first = ( + await _call_with_key(client, provisioned, permit["permit_id"], tool, key) + ).json() + expected = "not executable" if state == "not_executable" else "not found" + assert expected in first["error"]["message"], first + + if state == "not_executable": + monkeypatch.setattr(registry, "get_local_func", real) + else: + monkeypatch.setenv("ENABLE_DOGFOOD_SECOND_TOOL", "true") + get_settings.cache_clear() + sync_dogfood_tool_registration() + retry = ( + await _call_with_key(client, provisioned, permit["permit_id"], tool, key) + ).json() + assert "error" not in retry, retry + assert "result" in retry + + +async def _action_permit_for_allowed_tool(client, tool_name: str = ALLOWED): + """A validly signed action permit whose allowlist names only tool_name.""" + 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=tool_name, + ) + async with get_session_factory()() as session: + model = await session.get(PermitModel, permit["permit_id"]) + model.action_contract_version = 1 + model.action_payload_hash = "a" * 64 + model.action_schema_id = f"{tool_name}.args" + model.action_schema_version = "1" + model.action_public_tool_id = tool_name + model.action_upstream_binding_hash = "b" * 64 + await resign_permit_model(model) + await session.commit() + return provisioned, permit + + +async def _action_call(client, provisioned, permit_id, key, request_id=None): + resp = await client.post( + "/mcp/messages", + json=_jsonrpc_body( + tool_name=UNKNOWN, + wallet_id=provisioned["agent_wallet_id"], + request_id=request_id or f"action-{uuid4()}", + permit_id=permit_id, + idempotency_key=key, + ), + headers=provisioned["agent_headers"], + ) + return resp.json()["error"] + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_action_permit_denial_identical_for_registry_state( + client, clean_database, monkeypatch +): + """DeepSeek 5662554086 #1: an action permit must not reveal registration.""" + provisioned, permit = await _action_permit_for_allowed_tool(client) + wallet_id = provisioned["agent_wallet_id"] + seen = {} + for registered in (True, False): + monkeypatch.setenv("ENABLE_DOGFOOD_SECOND_TOOL", str(registered).lower()) + get_settings.cache_clear() + sync_dogfood_tool_registration() + assert (get_service_registry().get_local(UNKNOWN) is not None) == registered + request_id = f"action-parity-{uuid4()}" + error = await _action_call( + client, + provisioned, + permit["permit_id"], + f"action-parity-idem-{uuid4()}", + request_id, + ) + assert error["code"] == -32003, (registered, error) + assert error["message"] == "permit_tool_not_allowed", (registered, error) + receipt = error["data"]["receipt"] + await _assert_denial_receipt( + receipt, permit_id=permit["permit_id"], wallet_id=wallet_id + ) + events = await list_audit_events( + event="mcp.invoke", + wallet_id=wallet_id, + tool=UNKNOWN, + request_id=request_id, + ok=False, + limit=1, + ) + assert len(events) == 1 + metadata = dict(events[0].metadata) + for volatile in ("idempotency_key", "request_hash", "request_id"): + metadata.pop(volatile, None) + seen[registered] = ( + events[0].error, + metadata, + { + k: v + for k, v in receipt.items() + if k not in _VOLATILE_RECEIPT_FIELDS | {"request_hash"} + }, + ) + assert seen[True] == seen[False] + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_action_permit_denial_replays_same_receipt_for_same_key( + client, clean_database +): + """DeepSeek 5662554086 #2: same key, same signed denial receipt.""" + provisioned, permit = await _action_permit_for_allowed_tool(client) + key = f"action-replay-idem-{uuid4()}" + first = await _action_call(client, provisioned, permit["permit_id"], key) + before = await _receipt_count(provisioned["agent_wallet_id"]) + second = await _action_call(client, provisioned, permit["permit_id"], key) + assert first["message"] == second["message"] == "permit_tool_not_allowed" + assert first["data"]["receipt"]["idempotency_record_id"] is not None + assert second["data"]["receipt"] == first["data"]["receipt"] + assert await _receipt_count(provisioned["agent_wallet_id"]) == before + + +@pytest.mark.anyio +@pytest.mark.usefixtures("strict_trust_mode") +async def test_in_scope_action_permit_without_binding_is_binding_denial( + client, clean_database, monkeypatch +): + """docs/failure-semantics.md: an in-scope action permit for a tool with + no action binding is action_tool_binding_required, not Tool not found, + for either registry state, with no idempotency record begun.""" + provisioned, permit = await _action_permit_for_allowed_tool(client, UNKNOWN) + wallet_id = provisioned["agent_wallet_id"] + for registered in (True, False): + monkeypatch.setenv("ENABLE_DOGFOOD_SECOND_TOOL", str(registered).lower()) + get_settings.cache_clear() + sync_dogfood_tool_registration() + service = get_service_registry().get_local(UNKNOWN) + assert (service is not None) == registered + if registered: + assert get_service_registry().get_action_binding(service) is None + key = f"action-binding-idem-{uuid4()}" + error = await _action_call(client, provisioned, permit["permit_id"], key) + assert error["code"] == -32003, (registered, error) + assert error["message"] == "action_tool_binding_required", (registered, error) + receipt = error["data"]["receipt"] + assert receipt["outcome"] == "denied" + assert receipt["permit_id"] == permit["permit_id"] + assert receipt["idempotency_record_id"] is None + assert ( + await _governed_idempotency_record_count( + wallet_id=wallet_id, idempotency_key=key + ) + == 0 + ) diff --git a/gateway/tests/test_permit_v2_constraints.py b/gateway/tests/test_permit_v2_constraints.py index 3ff0b647..5cc5b785 100644 --- a/gateway/tests/test_permit_v2_constraints.py +++ b/gateway/tests/test_permit_v2_constraints.py @@ -22,6 +22,7 @@ from app.services.permits import get_permit_service from app.services.service_registry import get_service_registry from tests.test_trust_helpers import ( + resign_permit_model, BOOTSTRAP_HEADERS, provision_agent_wallet, ) @@ -1057,6 +1058,7 @@ async def test_max_calls_fractional_limit_fails_closed(client, clean_database): model = await session.get(PermitModel, permit_id) assert model is not None model.max_calls_per_tool_json = json.dumps({tool_name: 2.5}) + await resign_permit_model(model) session.add(model) await session.commit() diff --git a/gateway/tests/test_permits.py b/gateway/tests/test_permits.py index 80e87e7d..70032b6d 100644 --- a/gateway/tests/test_permits.py +++ b/gateway/tests/test_permits.py @@ -21,6 +21,7 @@ from app.services.ownership import CallerIdentity from app.services.permits import PermitError, get_permit_service from tests.test_trust_helpers import ( + resign_permit_model, BOOTSTRAP_HEADERS, create_tool_permit, provision_agent_wallet, @@ -640,6 +641,7 @@ async def test_authorize_and_reserve_rechecks_terminal_state_after_stale_prechec model.revoked_at = utc_now() else: model.expires_at = utc_now() - timedelta(seconds=1) + await resign_permit_model(model) session.add(model) await session.commit() @@ -776,6 +778,7 @@ async def test_permit_service_validation_denial_reasons(client, clean_database): assert model is not None model.allowed_tools_json = json.dumps([]) model.scopes_json = json.dumps(["billing:charge"]) + await resign_permit_model(model) session.add(model) await session.commit() @@ -853,6 +856,7 @@ async def test_reserve_budget_refuses_an_expired_permit( model = await session.get(PermitModel, permit.permit_id) assert model is not None model.expires_at = utc_now() - timedelta(seconds=1) + await resign_permit_model(model) session.add(model) with pytest.raises(PermitError) as excinfo: diff --git a/gateway/tests/test_trust_helpers.py b/gateway/tests/test_trust_helpers.py index 3b84fb37..a9b740d8 100644 --- a/gateway/tests/test_trust_helpers.py +++ b/gateway/tests/test_trust_helpers.py @@ -83,3 +83,20 @@ async def create_tool_permit( ) assert permit_resp.status_code == 201 return permit_resp.json() + + +async def resign_permit_model(model: Any) -> None: + """Re-sign a permit row after a test edits its signed fields in place. + + validate_for_action verifies the signature before reading any permit + field, so a test that backdates expires_at or plants a malformed limit + must re-sign the row to reach the check it is exercising. + """ + from app.services.permits import get_permit_service + from app.services.signing_keys import get_signing_key_service + + signature, kid, _ = await get_signing_key_service().sign_payload( + get_permit_service()._unsigned_payload(model) + ) + model.signature = signature + model.key_id = kid diff --git a/gateway/tests/test_trust_negative_security.py b/gateway/tests/test_trust_negative_security.py index a0550f4f..565e3aa2 100644 --- a/gateway/tests/test_trust_negative_security.py +++ b/gateway/tests/test_trust_negative_security.py @@ -27,7 +27,11 @@ from app.services.policies import evaluate_wallet_policy as real_evaluate_wallet_policy from app.services.receipts import get_receipt_service from app.services.service_registry import get_service_registry -from tests.test_trust_helpers import create_tool_permit, provision_agent_wallet +from tests.test_trust_helpers import ( + create_tool_permit, + provision_agent_wallet, + resign_permit_model, +) @pytest.fixture @@ -101,6 +105,7 @@ async def test_expired_permit_is_denied_without_charge(client, clean_database): ) model = result.scalar_one() model.expires_at = datetime.now(timezone.utc) - timedelta(minutes=5) + await resign_permit_model(model) session.add(model) await session.commit() @@ -197,6 +202,7 @@ async def transition_permit_during_policy(**kwargs): model.revoked_at = utc_now() else: model.expires_at = utc_now() - timedelta(seconds=1) + await resign_permit_model(model) session.add(model) await session.commit() return await real_evaluate_wallet_policy(**kwargs)