Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e76f5f1
feat(goose): AMW StreamableHttp proxy, PreToolUse permit hook, fallba…
PetrefiedThunder Oct 24, 2026
0392a06
fix(goose): depend on amw-sdk>=0.4.0 and reuse its canonical_json (on…
PetrefiedThunder Oct 24, 2026
360d864
fix(goose): real-data-only tests; refuse writes without agent-session…
PetrefiedThunder Oct 24, 2026
2a0dd83
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 24, 2026
80694f3
fix(goose): pre-dispatch upstream failures are retryable and leave th…
PetrefiedThunder Oct 25, 2026
aa14791
fix(goose): pin filesystem server 2.0.1; write error results are fail…
PetrefiedThunder Oct 25, 2026
9ec727b
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 25, 2026
fba1c8c
fix(goose): permits deny relative and ~ paths (upstream resolves them…
PetrefiedThunder Oct 25, 2026
5cdd1c2
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 25, 2026
5721c5b
fix(goose): keep a received tool result when stdio teardown fails
PetrefiedThunder Oct 25, 2026
b54fac3
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 25, 2026
fcc665d
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 25, 2026
54ba8fc
fix(mcp): derive deterministic retry key for unkeyed tools/call (SEL-…
PetrefiedThunder Oct 25, 2026
a7e3e2b
fix(mcp): sliding retry window, IGA-bound replay, mutating-only unkey…
PetrefiedThunder Oct 25, 2026
c386ffb
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 25, 2026
bfbde23
fix(mcp): fence unkeyed retry lock by record id (SEL-484)
PetrefiedThunder Oct 25, 2026
23b5373
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 25, 2026
1061ec3
fix(mcp): fence auto-permit mint on unkeyed retry lock atomically (SE…
PetrefiedThunder Oct 26, 2026
325d8bf
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 26, 2026
de6ab23
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 26, 2026
e833b7a
fix(mcp): cap unkeyed retry window at permit TTL, validate it, shield…
PetrefiedThunder Oct 26, 2026
0247dff
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 26, 2026
77efddf
fix(mcp): cap unkeyed retry window at the demo auto-permit TTL
PetrefiedThunder Oct 26, 2026
47cf25e
Merge branch 'main' into g1/goose-hooks-idempotency
PetrefiedThunder Oct 26, 2026
8304261
Merge remote-tracking branch 'origin/main' into wt233-merge
PetrefiedThunder Oct 26, 2026
33f5ed5
fix(mcp): refuse unkeyed calls with no retry slot instead of minting …
PetrefiedThunder Oct 27, 2026
2b3e316
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 27, 2026
28fd3ff
fix(mcp): register idempotency_key_required reason code and document it
PetrefiedThunder Oct 27, 2026
722a6e5
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 27, 2026
97583bb
test(mcp): prove read-only tool calls are metered, so unkeyed reads s…
PetrefiedThunder Oct 27, 2026
67afd3c
docs(registry): read_only tools are metered; unkeyed calls are refused
PetrefiedThunder Oct 27, 2026
a073408
fix(idempotency): portable fence claim, truthful retry window, real p…
PetrefiedThunder Oct 27, 2026
858947a
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
PetrefiedThunder Oct 27, 2026
20de684
fix(idempotency): map fence-claim lock timeout to in_progress; determ…
PetrefiedThunder Oct 27, 2026
4e7564b
test(#233): restore unkeyed retry window Settings validation test; re…
PetrefiedThunder Oct 27, 2026
c92e000
Merge remote-tracking branch 'origin/main' into g1/goose-hooks-idempo…
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
27 changes: 27 additions & 0 deletions gateway/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,33 @@ def demo_allowed_tools_list(self) -> list[str]:
ENABLE_STANDARD_MCP_ENDPOINT: bool = True
STANDARD_MCP_PERMIT_TTL_SECONDS: int = 120

# Unkeyed tools/call retry safety (SEL-484 / G1, MCP issue #3394).
# A mutating call without a client idempotency key gets a deterministic
# key derived from wallet, API key, verified enterprise principal, tool and
# canonical arguments. The window slides from the first call (a retry that
# crosses a bucket edge still joins it), so a lost-response retry within
# this many seconds replays the original receipt instead of dispatching
# again; a refunded pre-dispatch failure is retried, not replayed.
# Read-only tools get no derived key, so unkeyed read-only calls are
# refused with idempotency_key_required (SEL-498); there is never a
# random per-call key. Identical deliberate unkeyed calls inside one
# window collapse to one dispatch; callers needing repeats must send
# distinct keys. 0 disables derived keys, so every unkeyed call is
# refused. The effective window is capped at the auto-permit TTL
# (STANDARD_MCP_PERMIT_TTL_SECONDS, default 120; lower for demo): a
# joined slot reuses its auto permit. With defaults the window is 120 s,
# and a later identical retry runs and bills again.
MCP_UNKEYED_RETRY_WINDOW_SECONDS: int = 300
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@field_validator("MCP_UNKEYED_RETRY_WINDOW_SECONDS")
@classmethod
def _validate_unkeyed_retry_window(cls, value: int) -> int:
if value < 0:
raise ValueError(
"MCP_UNKEYED_RETRY_WINDOW_SECONDS must be >= 0 (0 disables)"
)
return value

# --- Public MCP endpoint (POST /mcp/public) ---
# Unauthenticated, read-only Streamable HTTP surface for MCP clients
# that cannot hold an API key (public plugin runtimes such as ChatGPT
Expand Down
1 change: 1 addition & 0 deletions gateway/app/core/reason_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"idempotency_key_control_characters",
"idempotency_key_not_a_string",
"idempotency_key_not_utf8",
"idempotency_key_required",
"idempotency_key_required_for_human_approval",
"idempotency_key_reused",
"idempotency_key_too_long",
Expand Down
72 changes: 60 additions & 12 deletions gateway/app/routers/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,27 @@ def _verified_enterprise_principal(
return parse_enterprise_token(enterprise_bearer_token)


async def _assert_replay_iga_access(*, auth: AuthContext, tool_name: str) -> None:
"""Run the enterprise IGA gate before serving any stored result.

A replay must not hand one human principal another's result (or a result
it is no longer entitled to). The check spends no use (``consume=False``):
the original call already did.
"""
try:
principal = _verified_enterprise_principal(auth.enterprise_bearer_token)
if principal is None:
return
decision = await enforce_tool_call(principal, tool_name, consume=False)
except IGAError as exc:
raise ToolPermissionDenied(exc.reason) from exc
if not decision.allowed:
raise ToolPermissionDenied(
decision.reason,
details={"reason_code": decision.reason, **decision.details},
)


async def _execute_registered_tool(
*,
tool_name: str,
Expand Down Expand Up @@ -1366,6 +1387,9 @@ async def _execute_registered_tool_inner(
key_id=auth.key_id,
replay=replay,
)
# Same IGA gate as the registered-tool replay path: a revoked
# principal must not read stored results via a removed tool.
await _assert_replay_iga_access(auth=auth, tool_name=tool_name)
await _raise_replayed_error(replay)
return replay.response_json
reason = f"Tool not found: {tool_name}"
Expand Down Expand Up @@ -1638,6 +1662,7 @@ async def _execute_registered_tool_inner(
key_id=auth.key_id,
replay=replay,
)
await _assert_replay_iga_access(auth=auth, tool_name=tool_name)
Comment thread
cursor[bot] marked this conversation as resolved.
await _raise_replayed_error(replay)
return replay.response_json

Expand Down Expand Up @@ -4558,19 +4583,43 @@ async def _maybe_mint_demo_permit(request: GovernedRequest) -> str | None:
if registry.get_action_binding(record) is not None:
raise ToolPermissionDenied("action_permit_required")

from .mcp_standard import _mint_auto_permit, _permit_error
from .mcp_standard import (
_mint_auto_permit,
_permit_error,
_resolve_unkeyed_retry,
)

try:
permit_id = await _mint_auto_permit(
auth=request.auth,
tool_name=request.tool_name,
record=record,
client_key=request.idempotency_key,
requires_human_approval=await wallet_human_approval_required(
request.auth.wallet_id
),
map_mcp_errors=False,
)
requires_approval = await wallet_human_approval_required(request.auth.wallet_id)
resolved = None
if not request.idempotency_key:
# Deterministic retry slot for unkeyed calls so a retry resolves
# to the same auto permit and replays instead of dispatching
# twice (SEL-484).
resolved = await _resolve_unkeyed_retry(
auth=request.auth,
tool_name=request.tool_name,
record=record,
arguments=request.arguments,
requires_human_approval=requires_approval,
map_mcp_errors=False,
)
if resolved is not None:
request.idempotency_key, permit_id = resolved
elif not request.idempotency_key:
# No deterministic retry slot and no client key: refuse rather
# than mint a random per-attempt key that would let a retry
# dispatch and bill twice (SEL-498 / #208).
raise ValueError("idempotency_key_required")
else:
permit_id = await _mint_auto_permit(
auth=request.auth,
tool_name=request.tool_name,
record=record,
client_key=request.idempotency_key,
requires_human_approval=requires_approval,
map_mcp_errors=False,
)
except PermitWriteContendedError:
raise
except PermitError as exc:
Expand All @@ -4586,7 +4635,6 @@ async def _maybe_mint_demo_permit(request: GovernedRequest) -> str | None:
except IdempotencyConflictError as exc:
raise ValueError("idempotency_key_reused") from exc
request.permit_id = permit_id
request.idempotency_key = request.idempotency_key or f"mcp-auto-{uuid.uuid4().hex}"
return permit_id


Expand Down
Loading
Loading