Skip to content

Commit f5cd8cf

Browse files
bokelleyclaude
andauthored
feat(decisioning): Account.mode + sandbox-authority gate for comply_test_controller (Phase 1) (#483)
* feat(decisioning): Account.mode + sandbox-authority gate for comply_test_controller (Phase 1 of #1435 port) Port of JS PR adcontextprotocol/adcp-client#1453. Phase 1 of the lifecycle-state-and-sandbox-authority work — the SDK now enforces that `comply_test_controller` only operates on accounts whose resolved `mode` is `sandbox` or `mock`. Trust boundary is the resolved account, NOT the wire — buyer-supplied `account.sandbox: true` is ignored when the resolver returned a live account. What ships: - `Account.mode: Literal['live', 'sandbox', 'mock']` (default `'live'`) + derived `Account.sandbox` property for back-compat with adopters reading the boolean. - `Account._mode_explicit` flag (resolver opts in) so the observed-modes tracker distinguishes deliberate stamping from the dataclass default. - `adcp.decisioning.account_mode` — `get_account_mode`, `is_sandbox_or_mock_account`, `assert_sandbox_account` (mirrors JS `account-mode.ts`). - `adcp.decisioning.observed_modes` — process-scoped tracker of explicit mode values from `accounts.resolve` calls. - `register_test_controller(..., account_resolver=...)` — applies the Phase 1 gate when a resolver is wired. Decisioning's `serve()` auto-builds the resolver from `platform.accounts`. - Gate fires on the universal `_handle_test_controller` choke point so both MCP and A2A pick it up. - `ADCP_SANDBOX=1` env-fallback retained for back-compat AND fail-closed: throws loudly when paired with any observed explicit live-mode resolution in the same process. - `list_scenarios` (capability probe) exempt from the gate. - 16-test matrix in `tests/test_account_mode_gate.py` covering resolver / context.sandbox / env-fallback paths + the trust-boundary invariants (spoofed wire flag ignored, resolved-live beats context override). Back-compat posture: when no `account_resolver` is wired AND `ADCP_SANDBOX` is not set, the gate is dormant. Adopters who haven't opted in keep pre-Phase-1 behavior; opt-in happens automatically when they wire `serve(test_controller=...)` against a `DecisioningPlatform`, or by setting `ADCP_SANDBOX=1`. Migration: adopters running the conformance harness with `ADCP_SANDBOX=1` and otherwise un-flagged accounts must mark conformance accounts in their `AccountStore.resolve` implementation. For `SingletonAccounts` deployments, pass `mode='sandbox'` at construction: accounts = SingletonAccounts(account_id="hello", mode="sandbox") For `ExplicitAccounts` / `FromAuthAccounts`, return Accounts with `mode='sandbox'` (or `_mode_explicit=True` to opt into observed-modes tracking) from your loader. Out of scope (Phase 2): mock-mode dispatch, PlatformRouter, mock-server routing. Refs: - adcontextprotocol/adcp-client#1435 — umbrella issue - adcontextprotocol/adcp-client#1453 — JS Phase 1 implementation - docs/proposals/lifecycle-state-and-sandbox-authority.md (in adcp-client) — full design Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(decisioning): close sandbox-authority gate trust-boundary holes Security review on PR #483 found two ways the comply controller's sandbox gate fails open. Both are closed here. B1 — FromAuthAccounts adopters fail-open via wire ``account.sandbox``. The auto-wired resolver in ``decisioning.serve`` called ``platform.accounts.resolve(ref, auth_info=None)``; ``FromAuthAccounts.resolve`` raises ``AUTH_INVALID`` when auth_info is absent. The gate's bare ``except Exception`` swallowed the raise, set ``resolved_account = None``, and admitted via the buyer-supplied ``account.sandbox: true`` wire flag — letting a buyer flip state on a live principal. Fix: - Thread verified ``AuthInfo`` from ``ToolContext.metadata['adcp.auth_info']`` through ``_handle_test_controller`` into ``_apply_sandbox_gate`` and ultimately the resolver, mirroring ``PlatformHandler._extract_auth_info``. - ``_AccountResolver`` Protocol now declares ``auth_info`` kwarg. - The auto-wired resolver in ``decisioning.serve`` forwards auth_info to ``platform.accounts.resolve``. - Resolver-raise is now fail-CLOSED (DENY), not fall-through. The buyer's wire claim never trumps a resolver that signaled it could not affirm sandbox status. B2 — Gate fail-OPEN by default. When no resolver was wired AND ``ADCP_SANDBOX`` was unset, the gate returned None (admit) before any check. Manually-wired ``ADCPHandler`` / ``ComplianceHandler`` deployments were unprotected. Fix: - Default fail-CLOSED: no resolver + no env opt-in → DENY. - Add ``INSECURE_ALLOW_ALL`` sentinel for tests / dev fixtures that intentionally bypass the gate. Adopter production code must not use it. - Existing comply tests in ``test_test_controller_context.py``, ``test_force_create_media_buy_arm_and_force_task_completion.py``, ``test_server_dx.py``, ``test_a2a_server.py``, and the A2A conformance suite get a per-file autouse fixture setting ``ADCP_SANDBOX=1`` so they keep exercising the dispatcher / scenarios / wire-shape contracts they were written to test. The gate's own behavior continues to be exercised in ``test_account_mode_gate.py``. Tests added: - B1 regression: resolver raises ``AUTH_INVALID`` + buyer-supplied wire ``account.sandbox: true`` → DENIED. - B2 regression: no resolver + no ADCP_SANDBOX → DENIED. - B2 escape: ``INSECURE_ALLOW_ALL`` sentinel admits. - Auth-info threading: resolver receives the AuthInfo from ``ctx.metadata['adcp.auth_info']``. Docstrings: - ``_apply_sandbox_gate`` docstring rewritten to reflect the new fail-closed posture; the prior text claimed wire-flag fallback was fail-closed (it was fail-open). - ``observed_modes`` module docstring now flags the multi-tenant SaaS blast radius — the observed-modes set is process-scoped, so one tenant resolving live + ``ADCP_SANDBOX=1`` raises across all tenants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(examples): demo seller declares test-controller bypass intent Phase 1's sandbox-authority gate is now fail-closed by default — adopters who wire test_controller= without a resolver and without ADCP_SANDBOX get DENY on every comply call. Demo example now passes test_controller_account_resolver=INSECURE_ALLOW_ALL to declare its 'demo, not production' posture explicitly. Production sellers MUST populate Account.mode and let the framework gate enforce. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 55613f8 commit f5cd8cf

18 files changed

Lines changed: 1311 additions & 16 deletions

examples/seller_agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
sync_governance_response,
3939
update_media_buy_response,
4040
)
41+
from adcp.server import INSECURE_ALLOW_ALL
4142
from adcp.server.test_controller import TestControllerError, TestControllerStore
4243

4344
PORT = int(os.environ.get("ADCP_PORT") or os.environ.get("PORT") or 3001)
@@ -958,4 +959,9 @@ async def seed_creative_format(
958959
name="demo-seller",
959960
port=PORT,
960961
test_controller=DemoStore(),
962+
# Demo example: bypass the comply_test_controller sandbox-mode gate
963+
# so storyboard runs work without an Account.mode-aware AccountStore.
964+
# Production sellers MUST populate Account.mode (live/sandbox/mock) on
965+
# resolved accounts and let the framework's gate enforce it.
966+
test_controller_account_resolver=INSECURE_ALLOW_ALL,
961967
)

src/adcp/decisioning/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ def create_media_buy(
4949

5050
from __future__ import annotations
5151

52+
from adcp.decisioning.account_mode import (
53+
AccountMode,
54+
assert_sandbox_account,
55+
get_account_mode,
56+
is_sandbox_or_mock_account,
57+
)
5258
from adcp.decisioning.account_projection import (
5359
project_account_for_response,
5460
project_business_entity_for_response,
@@ -234,6 +240,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
234240

235241
__all__ = [
236242
"Account",
243+
"AccountMode",
237244
"AccountNotFoundError",
238245
"AccountStore",
239246
"AccountStoreList",
@@ -319,6 +326,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
319326
"ShortCircuit",
320327
"assert_creative_transition",
321328
"assert_media_buy_transition",
329+
"assert_sandbox_account",
330+
"get_account_mode",
331+
"is_sandbox_or_mock_account",
322332
"bearer_only_registry",
323333
"compose_method",
324334
"create_adcp_server_from_platform",
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Account-mode primitives for sandbox-authority enforcement of
2+
``comply_test_controller`` and other test-only surfaces.
3+
4+
The hard rule: under no circumstances should the comply test controller
5+
(or any test-only surface) operate on a ``live``-mode account. The flag
6+
must live on the resolved account, not on a process-level env var —
7+
env vars are operator-error-prone proxies for what is fundamentally an
8+
authority decision per principal.
9+
10+
Mirrors the JS-side ``src/lib/server/account-mode.ts`` for cross-language
11+
parity. Phase 1 of the lifecycle-state-and-sandbox-authority proposal —
12+
ships the field, helpers, and dispatch gate. Mock-mode routing
13+
(Phase 2) is deferred.
14+
15+
See ``docs/proposals/lifecycle-state-and-sandbox-authority.md`` for the
16+
full three-mode design.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import Any, Literal, cast
22+
23+
from adcp.decisioning.types import AdcpError
24+
25+
#: Three operationally distinct account modes:
26+
#:
27+
#: - ``live``: production traffic. Adopter's upstream is truth.
28+
#: Test-only surfaces (comply controller, force_*, simulate_*) are denied.
29+
#: - ``sandbox``: adopter's own test account. Their code path runs against
30+
#: their test infrastructure. Test-only surfaces are allowed.
31+
#: - ``mock``: SDK-routed-to-mock-server. Adopter's code is bypassed; the
32+
#: SDK forwards to the mock upstream backend (Phase 2). Test-only
33+
#: surfaces are allowed.
34+
#:
35+
#: Default when unspecified: ``live``. A missing or unknown ``mode`` reads
36+
#: as production, fail-closed for any test-only dispatch.
37+
AccountMode = Literal["live", "sandbox", "mock"]
38+
39+
40+
def get_account_mode(account: Any) -> AccountMode:
41+
"""Read ``mode`` off any account-shaped value, with back-compat for
42+
the legacy ``sandbox: bool`` field.
43+
44+
Returns the explicit mode if present; otherwise infers ``'sandbox'``
45+
from ``sandbox is True``; otherwise ``'live'``.
46+
47+
Adopters that have not yet migrated to the ``mode`` field continue to
48+
work — ``account.sandbox is True`` reads as sandbox mode through this
49+
helper. New code should prefer ``mode`` directly.
50+
51+
Unknown / unrecognized ``mode`` values fall through to ``'live'`` —
52+
we never silently admit on a misspelled mode string.
53+
"""
54+
if account is None:
55+
return "live"
56+
mode = _attr(account, "mode")
57+
if mode == "live" or mode == "sandbox" or mode == "mock":
58+
return cast(AccountMode, mode)
59+
# Back-compat: legacy `sandbox: True` flag reads as `sandbox` mode.
60+
if _attr(account, "sandbox") is True:
61+
return "sandbox"
62+
return "live"
63+
64+
65+
def is_sandbox_or_mock_account(account: Any) -> bool:
66+
"""Predicate: is the account in a non-production mode that admits
67+
test-only surfaces (comply controller, force_*, simulate_*)?
68+
69+
Returns ``True`` for ``mode in {'sandbox', 'mock'}`` (or legacy
70+
``sandbox is True``); ``False`` for ``mode == 'live'`` or any account
71+
shape that doesn't carry the field.
72+
"""
73+
mode = get_account_mode(account)
74+
return mode == "sandbox" or mode == "mock"
75+
76+
77+
def assert_sandbox_account(
78+
account: Any,
79+
*,
80+
tool: str | None = None,
81+
message: str | None = None,
82+
) -> None:
83+
"""Raise ``AdcpError('PERMISSION_DENIED')`` if the account is not in
84+
a non-production mode. Use to gate dispatch of test-only surfaces.
85+
86+
Fail-closed semantics:
87+
88+
- ``account is None`` (no resolved account): raises.
89+
- ``mode == 'live'`` or unspecified + no ``sandbox is True``: raises.
90+
- ``mode in {'sandbox', 'mock'}`` (or legacy ``sandbox is True``):
91+
no-op, dispatch proceeds.
92+
93+
The ``details`` payload carries ``{scope: 'sandbox-gate', tool?}``
94+
so dashboards can distinguish gate-rejections from other permission
95+
denials.
96+
97+
**Resolver discipline.** The strength of this gate depends entirely
98+
on how the adopter's :meth:`AccountStore.resolve` constructs its
99+
return value. Resolvers MUST NOT spread untrusted input (request
100+
body, headers, ``ctx_metadata``, query params) into the resolved
101+
account — doing so lets a buyer self-promote to ``mode='sandbox'``
102+
and unlock test-only surfaces on a live principal. Source ``mode``
103+
(and ``sandbox``) from a trusted store keyed by the authenticated
104+
principal; never from request data.
105+
106+
**opts.message must be a static string literal.** The message is
107+
echoed on the wire inside the error envelope. Interpolating
108+
user-controlled values into it creates a reflection sink (PII
109+
leakage, log injection, downstream HTML rendering). Pick from a
110+
fixed set of messages keyed by ``tool`` if you need variants.
111+
"""
112+
if is_sandbox_or_mock_account(account):
113+
return
114+
details: dict[str, Any] = {
115+
"scope": "sandbox-gate",
116+
"reason": "sandbox-or-mock-required",
117+
}
118+
if tool is not None:
119+
details["tool"] = tool
120+
raise AdcpError(
121+
"PERMISSION_DENIED",
122+
message=message
123+
or (
124+
"Test-only surface requires a sandbox or mock account; "
125+
"resolved account is in live mode."
126+
),
127+
recovery="terminal",
128+
details=details,
129+
)
130+
131+
132+
def _attr(obj: Any, name: str) -> Any:
133+
"""Own-attribute read mirroring the JS-side ``Object.hasOwn`` posture.
134+
135+
Python doesn't have prototype-pollution the same way JS does, but
136+
the equivalent risk is class-level attribute inheritance: an
137+
adopter who defines ``Account`` subclasses and accidentally sets a
138+
class-level ``mode = 'sandbox'`` would have every instance read as
139+
sandbox. We only consult instance state (``__dict__``) for known
140+
attribute carriers; for dataclass instances the field lives there.
141+
Falls back to ``getattr`` for dict-shaped accounts.
142+
143+
Returns ``None`` when the attribute is absent — the caller treats
144+
that as "field not present" and falls through to the next check.
145+
"""
146+
if isinstance(obj, dict):
147+
return obj.get(name)
148+
inst = getattr(obj, "__dict__", None)
149+
if isinstance(inst, dict) and name in inst:
150+
return inst[name]
151+
# Fallback: bare attribute read for objects without a writable
152+
# ``__dict__`` (slots, namedtuple, custom descriptors).
153+
return getattr(obj, name, None)

src/adcp/decisioning/accounts.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,15 @@ class TrainingAgentSeller(DecisioningPlatform):
386386
:param metadata_factory: Optional factory for ``Account.metadata``
387387
— adopters with typed metadata pass a closure that returns the
388388
right TypedDict / dataclass instance.
389+
:param mode: Optional account-mode flag. When set, every resolved
390+
:class:`Account` carries ``mode`` and is stamped explicit so
391+
the framework's observed-modes tracker counts the resolution.
392+
Default ``None`` — leaves accounts at the implicit-default
393+
``'live'`` (pre-mode behavior). Pass ``'sandbox'`` for a
394+
single-platform conformance / dev deployment that should admit
395+
``comply_test_controller``; pass ``'live'`` to deliberately mark
396+
the singleton as production (the env-fallback guard then trips
397+
loudly if ``ADCP_SANDBOX=1`` is also set).
389398
"""
390399

391400
resolution: Literal["derived"] = "derived"
@@ -396,6 +405,7 @@ def __init__(
396405
*,
397406
name: str = "",
398407
metadata_factory: Callable[[], TMeta] | None = None,
408+
mode: Literal["live", "sandbox", "mock"] | None = None,
399409
) -> None:
400410
if not account_id or not isinstance(account_id, str):
401411
raise ValueError(
@@ -404,6 +414,7 @@ def __init__(
404414
self._account_id = account_id
405415
self._name = name or account_id
406416
self._metadata_factory = metadata_factory
417+
self._mode = mode
407418

408419
def resolve(
409420
self,
@@ -416,12 +427,21 @@ def resolve(
416427
metadata: TMeta = (
417428
self._metadata_factory() if self._metadata_factory else {} # type: ignore[assignment]
418429
)
430+
kwargs: dict[str, Any] = {}
431+
if self._mode is not None:
432+
# Adopter passed explicit mode at construction — stamp it on
433+
# the Account AND mark explicit so the observed-modes tracker
434+
# counts this resolution for the env-fallback fail-closed
435+
# guard.
436+
kwargs["mode"] = self._mode
437+
kwargs["_mode_explicit"] = True
419438
return Account(
420439
id=scoped_id,
421440
name=f"{self._name} ({principal})" if principal != "anonymous" else self._name,
422441
status="active",
423442
metadata=metadata,
424443
auth_info=_auth_info_to_dict(auth_info),
444+
**kwargs,
425445
)
426446

427447

src/adcp/decisioning/handler.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -742,8 +742,18 @@ async def _resolve_account(
742742
ref_dict = cast("dict[str, Any]", ref)
743743
result = self._platform.accounts.resolve(ref_dict, auth_info=auth_info)
744744
if asyncio.iscoroutine(result):
745-
return cast("Account[Any]", await result)
746-
return cast("Account[Any]", result)
745+
resolved = cast("Account[Any]", await result)
746+
else:
747+
resolved = cast("Account[Any]", result)
748+
# Phase 1 sandbox-authority — track explicit mode values for the
749+
# comply controller's env-fallback fail-closed guard. Implicit
750+
# default-live (resolver didn't populate mode) is intentionally
751+
# NOT recorded so pre-migration adopters keep working with
752+
# ADCP_SANDBOX=1.
753+
from adcp.decisioning.observed_modes import record_resolved_account_mode
754+
755+
record_resolved_account_mode(resolved)
756+
return resolved
747757

748758
@staticmethod
749759
def _extract_auth_info(ctx: ToolContext) -> AuthInfo | None:
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Process-scoped tracker of explicit ``Account.mode`` values returned
2+
from :meth:`AccountStore.resolve` during framework-side comply-controller
3+
dispatch.
4+
5+
Used by the sandbox gate's deprecated env-fallback path (``ADCP_SANDBOX=1``)
6+
to fail closed when a process has resolved any explicit live-mode account.
7+
8+
Rationale: the env-fallback exists for back-compat with adopters that
9+
have not yet adopted the per-account ``mode`` field. If those adopters
10+
have ALSO begun returning explicit ``mode='live'`` from their resolver,
11+
the env var is a misconfiguration — leaving it set would re-open the
12+
gate for live principals after the resolver was meant to close it.
13+
14+
Implicit-default ``live`` (resolver returns no mode field, so the
15+
dataclass default ``'live'`` applies) is NOT observed here — those
16+
adopters are exactly who the env-fallback bridge exists for. Only the
17+
deliberate explicit mode (resolver populated ``mode='...'`` and stamped
18+
``_mode_explicit=True``) trips the guard.
19+
20+
**Multi-tenant blast radius.** This tracker is process-scoped, not
21+
tenant-scoped. In a multi-tenant SaaS process (salesagent-style — many
22+
tenants share one Python process), if ANY tenant's resolver returns an
23+
explicit ``mode='live'`` account, every subsequent comply-controller
24+
call that would admit only via ``ADCP_SANDBOX=1`` raises ``RuntimeError``
25+
across ALL tenants. SaaS adopters MUST NOT set ``ADCP_SANDBOX=1`` in
26+
shared dev/staging environments where some tenants may resolve live
27+
accounts; either gate per tenant via ``mode='sandbox'`` on the resolved
28+
account or run sandbox tenants in a separate process. JS takes the same
29+
posture; the cross-tenant tripping is intentional (silent admission
30+
under env-fallback would be the worse failure mode) but worth
31+
surfacing.
32+
33+
Mirrors the JS-side
34+
``src/lib/server/decisioning/runtime/observed-modes.ts``.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import os
40+
from typing import Any
41+
42+
from adcp.decisioning.account_mode import AccountMode
43+
44+
_observed: set[AccountMode] = set()
45+
46+
47+
def record_resolved_account_mode(account: Any) -> None:
48+
"""Record an account returned from :meth:`AccountStore.resolve`.
49+
50+
Only counts EXPLICIT mode values — those where the resolver
51+
deliberately populated ``mode`` (signaled via the
52+
``_mode_explicit=True`` flag on the framework's :class:`Account`
53+
dataclass). Adopters whose resolvers don't yet stamp ``mode`` keep
54+
working with the env-fallback bridge: their accounts read as
55+
implicit live, which doesn't trip this guard.
56+
57+
No-op when ``account`` is ``None`` / not an account-shaped value /
58+
lacks an explicit mode flag / mode is not a known string. The set
59+
only collects deliberate, resolver-stamped mode values.
60+
"""
61+
if account is None:
62+
return
63+
# Only count explicit modes — implicit-default live (legacy adopter
64+
# whose resolver didn't populate mode) is the back-compat target the
65+
# env-fallback exists for, not a misconfiguration.
66+
if not getattr(account, "_mode_explicit", False):
67+
return
68+
mode = getattr(account, "mode", None)
69+
if mode in ("live", "sandbox", "mock"):
70+
_observed.add(mode)
71+
72+
73+
def has_observed_live_mode() -> bool:
74+
"""``True`` when this process has observed at least one explicit
75+
``mode='live'`` account from :meth:`AccountStore.resolve`.
76+
77+
The sandbox-gate env-fallback consults this to decide whether
78+
``ADCP_SANDBOX=1`` is a safe legacy bridge or a misconfiguration.
79+
"""
80+
return "live" in _observed
81+
82+
83+
def _reset_observed_account_modes() -> None:
84+
"""Test seam — reset the observed-modes set between test cases.
85+
86+
Refuses to clear when ``ADCP_ENV`` is anything that looks like
87+
production. The observed-modes set is intentionally process-scoped
88+
— clearing it in production would re-arm the env-fallback admit
89+
path for live principals already seen, defeating the whole point
90+
of the fail-closed guard.
91+
92+
Not part of the public API. Adopter code MUST NOT call this.
93+
"""
94+
env = os.environ.get("ADCP_ENV", "").strip().lower()
95+
if env in {"prod", "production"}:
96+
raise RuntimeError(
97+
f"_reset_observed_account_modes is a test seam; refusing to clear "
98+
f"observed account modes in ADCP_ENV={env!r}. The set is process-"
99+
f"scoped to keep the comply controller's env-fallback fail-closed "
100+
f"guard armed once a live account has been seen."
101+
)
102+
_observed.clear()

0 commit comments

Comments
 (0)