Skip to content

Commit b2c232d

Browse files
authored
Merge pull request #59 from AI-agent-assembly/v0.0.1/AAASM-1560/feat/enforcement_mode
[AAASM-1560] ✨ (sdk): Add enforcement_mode parameter to init_assembly
2 parents 1342fd7 + 13ecd95 commit b2c232d

4 files changed

Lines changed: 177 additions & 2 deletions

File tree

agent_assembly/client/gateway.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def __init__(
2222
delegation_reason: str | None = None,
2323
spawned_by_tool: str | None = None,
2424
depth: int | None = None,
25+
enforcement_mode: str | None = None,
2526
) -> None:
2627
"""
2728
Initialize the GatewayClient.
@@ -36,6 +37,10 @@ def __init__(
3637
delegation_reason: Human-readable reason for delegation
3738
spawned_by_tool: Name of the tool that spawned this agent
3839
depth: Spawn depth in the agent lineage tree
40+
enforcement_mode: Per-agent governance posture sent to the gateway
41+
at registration. ``None`` (the default) omits the field from
42+
the request body so a legacy gateway sees the same wire shape
43+
as before; the gateway then defaults to live enforcement.
3944
"""
4045
self.gateway_url = gateway_url.rstrip("/")
4146
self.agent_id = agent_id
@@ -46,6 +51,7 @@ def __init__(
4651
self.delegation_reason = delegation_reason
4752
self.spawned_by_tool = spawned_by_tool
4853
self.depth = depth
54+
self.enforcement_mode = enforcement_mode
4955
self._client: httpx.Client | None = None
5056

5157
@property
@@ -97,6 +103,8 @@ async def register_agent(self) -> dict:
97103
body["spawned_by_tool"] = self.spawned_by_tool
98104
if self.depth is not None:
99105
body["depth"] = self.depth
106+
if self.enforcement_mode is not None:
107+
body["enforcement_mode"] = self.enforcement_mode
100108
try:
101109
response = self.client.post(
102110
f"/agents/{self.agent_id}/register",

agent_assembly/core/assembly.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,20 @@
1919
RuntimeMode = Literal["auto", "ebpf", "proxy", "sdk-only"]
2020
NetworkMode = Literal["ebpf", "proxy", "sdk-only"]
2121

22+
EnforcementMode = Literal["enforce", "observe", "disabled"]
23+
"""Posture the governance gateway should apply to this agent's actions.
24+
25+
* ``"enforce"`` — default; deny blocks the action, redact strips secrets.
26+
* ``"observe"`` — dry-run; the gateway records what *would* have happened
27+
but lets every action through. Surfaced by ``aa audit list --dry-run-only``.
28+
* ``"disabled"`` — policy evaluation skipped entirely. Hermetic test only.
29+
30+
Mirrors ``aa_core::EnforcementMode`` on the wire; uses the same snake_case
31+
tokens the gateway expects in the registration body."""
32+
2233
_DEFAULT_AGENT_ID = "agent-assembly-default"
2334
_VALID_RUNTIME_MODES = {"auto", "ebpf", "proxy", "sdk-only"}
35+
_VALID_ENFORCEMENT_MODES: frozenset[EnforcementMode] = frozenset({"enforce", "observe", "disabled"})
2436
_INIT_LOCK = Lock()
2537
_ACTIVE_CONTEXT: AssemblyContext | None = None
2638

@@ -115,6 +127,7 @@ def init_assembly(
115127
delegation_reason: str | None = None,
116128
spawned_by_tool: str | None = None,
117129
depth: int | None = None,
130+
enforcement_mode: EnforcementMode | None = None,
118131
) -> AssemblyContext:
119132
"""Initialize the Agent Assembly SDK runtime for this process.
120133
@@ -125,10 +138,18 @@ def init_assembly(
125138
through the resolver chain (env → config file → local default with
126139
optional auto-start) per Epic 17 S-G — see
127140
``agent_assembly.core.gateway_resolver``.
141+
142+
:param enforcement_mode: Per-agent governance posture sent to the gateway
143+
at registration (see :data:`EnforcementMode`). Defaults to ``None``,
144+
which omits the field from the registration body — the gateway then
145+
applies its server-side default (live ``enforce``). Pass ``"observe"``
146+
to register the agent in dry-run / sandbox mode: every action
147+
proceeds and the gateway records would-be violations as shadow audit
148+
events.
128149
"""
129150
gateway_url = resolve_gateway_url(gateway_url)
130151
api_key = resolve_api_key(api_key)
131-
_validate_inputs(gateway_url=gateway_url, mode=mode)
152+
_validate_inputs(gateway_url=gateway_url, mode=mode, enforcement_mode=enforcement_mode)
132153
if delegation_reason is not None and len(delegation_reason) > 256:
133154
raise ValueError("delegation_reason must be <= 256 characters")
134155

@@ -164,6 +185,7 @@ def init_assembly(
164185
delegation_reason=delegation_reason,
165186
spawned_by_tool=spawned_by_tool,
166187
depth=depth,
188+
enforcement_mode=enforcement_mode,
167189
)
168190

169191
registered_adapters: list[FrameworkAdapter] = []
@@ -190,11 +212,17 @@ def init_assembly(
190212
return context
191213

192214

193-
def _validate_inputs(*, gateway_url: str, mode: RuntimeMode) -> None:
215+
def _validate_inputs(
216+
*, gateway_url: str, mode: RuntimeMode, enforcement_mode: EnforcementMode | None = None
217+
) -> None:
194218
if not gateway_url:
195219
raise ConfigurationError("gateway_url is required")
196220
if mode not in _VALID_RUNTIME_MODES:
197221
raise ConfigurationError("mode must be one of: auto, ebpf, proxy, sdk-only")
222+
if enforcement_mode is not None and enforcement_mode not in _VALID_ENFORCEMENT_MODES:
223+
raise ConfigurationError(
224+
f"enforcement_mode must be one of: enforce, observe, disabled (got: {enforcement_mode!r})"
225+
)
198226

199227

200228
def _register_adapters(

test/unit/client/test_gateway_topology.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,59 @@ async def test_register_agent_includes_depth_when_set() -> None:
106106
_, call_kwargs = mock_post.call_args
107107
body = call_kwargs.get("json") or {}
108108
assert body["depth"] == 3
109+
110+
111+
# ── enforcement_mode wire-shape (AAASM-1560) ─────────────────────────────────
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_register_agent_sends_enforcement_mode_when_set() -> None:
116+
"""Registering an agent with enforcement_mode=observe puts the snake_case
117+
string on the wire so the gateway's REST → gRPC bridge can map it to
118+
RegisterRequest.enforcement_mode (proto enum) per AAASM-1555/1557.
119+
"""
120+
client = GatewayClient(
121+
gateway_url="http://gw.test",
122+
agent_id="experimental-agent",
123+
api_key="key",
124+
enforcement_mode="observe",
125+
)
126+
mock_post = MagicMock(return_value=_make_ok_response())
127+
with patch.object(
128+
type(client),
129+
"client",
130+
new_callable=lambda: property(lambda self: MagicMock(post=mock_post)),
131+
):
132+
await client.register_agent()
133+
134+
_, kwargs = mock_post.call_args
135+
body = kwargs.get("json") or {}
136+
assert body["enforcement_mode"] == "observe"
137+
138+
139+
@pytest.mark.asyncio
140+
async def test_register_agent_omits_enforcement_mode_when_none() -> None:
141+
"""A client constructed without enforcement_mode (the pre-feature path)
142+
must NOT include the key in the body — keeps the wire shape identical
143+
to before so a legacy gateway that doesn't know about the field still
144+
accepts the registration cleanly.
145+
"""
146+
client = GatewayClient(
147+
gateway_url="http://gw.test",
148+
agent_id="legacy-agent",
149+
api_key="key",
150+
# no enforcement_mode kwarg
151+
)
152+
mock_post = MagicMock(return_value=_make_ok_response())
153+
with patch.object(
154+
type(client),
155+
"client",
156+
new_callable=lambda: property(lambda self: MagicMock(post=mock_post)),
157+
):
158+
await client.register_agent()
159+
160+
_, kwargs = mock_post.call_args
161+
body = kwargs.get("json")
162+
# body may be None (no fields set) or a dict that omits the key.
163+
if body is not None:
164+
assert "enforcement_mode" not in body

test/unit/test_assembly.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,86 @@ def test_init_assembly_delegation_reason_too_long_raises(
431431
api_key="test-api-key",
432432
delegation_reason="x" * 257,
433433
)
434+
435+
436+
# ── enforcement_mode parameter (AAASM-1560) ──────────────────────────────────
437+
438+
439+
@pytest.mark.parametrize("mode", ["enforce", "observe", "disabled"])
440+
def test_init_assembly_enforcement_mode_forwarded_to_client(
441+
monkeypatch: pytest.MonkeyPatch,
442+
mode: str,
443+
) -> None:
444+
"""All three valid EnforcementMode values land on the GatewayClient."""
445+
monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: [])
446+
monkeypatch.setattr(
447+
core_assembly,
448+
"_start_network_layer",
449+
lambda **kwargs: ("sdk-only", core_assembly._noop_shutdown),
450+
)
451+
452+
context = init_assembly(
453+
gateway_url="http://localhost:8080",
454+
api_key="test-api-key",
455+
agent_id=f"agent-{mode}",
456+
enforcement_mode=mode, # type: ignore[arg-type]
457+
)
458+
459+
try:
460+
assert context.client.enforcement_mode == mode
461+
finally:
462+
context.shutdown()
463+
464+
465+
def test_init_assembly_enforcement_mode_invalid_raises_configuration_error(
466+
monkeypatch: pytest.MonkeyPatch,
467+
) -> None:
468+
"""Unknown enforcement_mode string fails fast at init time.
469+
470+
Catches typos like ``"obesrve"`` before the agent silently registers
471+
under live enforcement — the security-conscious default.
472+
"""
473+
from agent_assembly.exceptions import ConfigurationError
474+
475+
monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: [])
476+
monkeypatch.setattr(
477+
core_assembly,
478+
"_start_network_layer",
479+
lambda **kwargs: ("sdk-only", core_assembly._noop_shutdown),
480+
)
481+
482+
with pytest.raises(ConfigurationError, match="enforcement_mode must be one of"):
483+
init_assembly(
484+
gateway_url="http://localhost:8080",
485+
api_key="test-api-key",
486+
enforcement_mode="invalid-mode", # type: ignore[arg-type]
487+
)
488+
489+
490+
def test_init_assembly_enforcement_mode_defaults_to_none_to_preserve_wire_shape(
491+
monkeypatch: pytest.MonkeyPatch,
492+
) -> None:
493+
"""Omitting enforcement_mode keeps the pre-feature registration body shape.
494+
495+
The default is `None` (not `"enforce"`) so a pre-feature SDK caller's
496+
`register_agent()` request emits a body that omits the field entirely.
497+
The gateway then applies its server-side default of live enforcement,
498+
so semantic behaviour is identical to before.
499+
"""
500+
monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: [])
501+
monkeypatch.setattr(
502+
core_assembly,
503+
"_start_network_layer",
504+
lambda **kwargs: ("sdk-only", core_assembly._noop_shutdown),
505+
)
506+
507+
context = init_assembly(
508+
gateway_url="http://localhost:8080",
509+
api_key="test-api-key",
510+
agent_id="default-mode-agent",
511+
)
512+
513+
try:
514+
assert context.client.enforcement_mode is None
515+
finally:
516+
context.shutdown()

0 commit comments

Comments
 (0)