Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c0538df
fix(gateway): check permit allowlist before tool lookup so unnamed to…
PetrefiedThunder Oct 25, 2026
776faaf
fix(mcp): preserve unknown-tool denial replay details
PetrefiedThunder Oct 26, 2026
1babe40
Merge remote-tracking branch 'origin/main' into hermes/permit-unknown…
PetrefiedThunder Oct 26, 2026
d14a2f0
Merge branch 'main' into hermes/permit-unknown-tool
PetrefiedThunder Oct 26, 2026
f2f51af
fix(mcp): validate permit before reading its allowlist for unregister…
PetrefiedThunder Oct 26, 2026
c83b15c
fix(mcp): hide tool registration on permit denials
PetrefiedThunder Oct 26, 2026
93e81a2
fix(permits): verify signature before reading the allowlist (#232)
PetrefiedThunder Oct 26, 2026
fd98b6d
Merge remote-tracking branch 'origin/main' into hermes/permit-unknown…
PetrefiedThunder Oct 26, 2026
fc994a3
fix(permits): verify the signature before any permit field (#232)
PetrefiedThunder Oct 27, 2026
dedab8f
Merge remote-tracking branch 'origin/main' into hermes/permit-unknown…
PetrefiedThunder Oct 27, 2026
55d976c
test: re-sign the backdated permit in the expiry-denial test (#232)
PetrefiedThunder Oct 27, 2026
e83c5e5
Merge remote-tracking branch 'origin/hermes/permit-unknown-tool' into…
PetrefiedThunder Oct 27, 2026
e27b2a6
Merge remote-tracking branch 'origin/main' into hermes/permit-unknown…
PetrefiedThunder Oct 27, 2026
0938d76
test(permits): per-item registration-parity tests for DeepSeek review
PetrefiedThunder Oct 27, 2026
916ce1c
test(permits): generate made-up quote and request ids at runtime
PetrefiedThunder Oct 27, 2026
f0916d6
fix(mcp): release idempotency claim on Tool not found/not executable
PetrefiedThunder Oct 27, 2026
460722c
Merge remote-tracking branch 'origin/main' into hermes/permit-unknown…
PetrefiedThunder Oct 27, 2026
468a36f
fix(mcp): keep terminal Tool-not-found record for permitless calls
PetrefiedThunder Oct 27, 2026
fe7671c
fix(mcp): action-permit scope denial is registry-independent and repl…
PetrefiedThunder Oct 27, 2026
dd2ddb2
Merge remote-tracking branch 'origin/main' into hermes/permit-unknown…
PetrefiedThunder Oct 27, 2026
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
534 changes: 369 additions & 165 deletions gateway/app/routers/mcp.py

Large diffs are not rendered by default.

29 changes: 20 additions & 9 deletions gateway/app/routers/mcp_standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 19 additions & 8 deletions gateway/app/services/permits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 8 additions & 6 deletions gateway/docs/POLICY_ENFORCEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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` |
Expand All @@ -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
Expand Down
54 changes: 43 additions & 11 deletions gateway/docs/failure-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 9 additions & 0 deletions gateway/scripts/red_team_trust_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions gateway/tests/test_denial_details_and_self_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
BOOTSTRAP_HEADERS,
create_tool_permit,
provision_agent_wallet,
resign_permit_model,
)

TOOL = "denial-echo"
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions gateway/tests/test_mcp_upstream_governed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
3 changes: 2 additions & 1 deletion gateway/tests/test_permit_break_it.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
Loading
Loading