Skip to content

Commit 451e9e7

Browse files
bar-capsuleclaude
andcommitted
Promote NAT adapter to reference implementation
Built a real NAT middleware adapter against the actual nvidia-nat-core 1.7.0 public API (verified by installing in a Python 3.13 venv and running the integration tests against real NAT types). Adapter implementation: - ACSMiddlewareConfig inherits from FunctionMiddlewareBaseConfig with name="acs_guardian" (NAT's TypedBaseModel registration mechanism). - ACSMiddleware subclasses FunctionMiddleware; implements pre_invoke / post_invoke against real InvocationContext. - Block mechanism feature-detects InvocationAction.SKIP (NAT dev branch) and falls back to raising ACSGuardianDenied (NAT 1.7.0 documented exception-based abort). - MODIFY honored on both pre (parameter_overrides -> modified_kwargs) and post (modified_content -> context.output) invocation. - Registered via @register_middleware(config_type=ACSMiddlewareConfig) factory function. Integration tests: - 7 tests against real NAT types (FunctionMiddlewareContext, InvocationContext) constructed exactly as NAT's runtime would. - pre_invoke / post_invoke invoked through the actual NAT API. - Verdicts round-trip against the shared example Guardian. - All 7 passing. - Tests skipped cleanly when NAT not installed (@unittest.skipUnless). Compatibility verified against nvidia-nat-core 1.7.0 (PyPI). Status table updated. NAT is now reference implementation alongside Claude Code and Cursor; the only remaining manual step is running a full NAT workflow with the middleware attached (the integration tests exercise NAT's middleware API but not a complete agent run). Final test count: 33 tests passing across all three adapters (13 claude-code + 13 cursor + 7 nat). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 34473a2 commit 451e9e7

6 files changed

Lines changed: 623 additions & 44 deletions

File tree

adapters/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Reference implementations that wire popular agent frameworks to an ACS Guardian.
88
|---|---|---|---|---|---|
99
| [claude-code](./claude-code/) | Reference implementation ||| ✓ 13 round-trip tests | ✓ ALLOW + DENY paths verified against a real `claude --print` session |
1010
| [cursor](./cursor/) | Reference implementation ||| ✓ 13 round-trip tests | ⚠ Manual verification by reviewer with Cursor installed (Cursor has no headless mode) |
11-
| [nat](./nat/) | Draft || | | |
11+
| [nat](./nat/) | Reference implementation || | ✓ 7 integration tests against real `nvidia-nat-core` 1.7.0 | ⚠ Manual verification with a real NAT workflow (the integration tests use real NAT types but not a full agent run) |
1212

1313
## The adapter pattern
1414

adapters/nat/README.md

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,103 @@
11
# ACS adapter: NVIDIA Agent Toolkit (NAT)
22

3-
> **Status: draft.** Mapping documented; working adapter pending engagement with the NAT team.
3+
A drop-in middleware that wires [NVIDIA NeMo Agent Toolkit](https://github.com/NVIDIA/NeMo-Agent-Toolkit) to an ACS Guardian. No agent code changes; YAML configuration only.
44

5-
NAT exposes config-level middleware at the lifecycle points ACS standardizes. This mapping doc describes how NAT's middleware nodes correspond to ACS `steps/*` methods, so an adapter can be built without modifying agent code.
5+
## What it does
66

7-
See [mapping.md](./mapping.md) for the hook-by-hook translation.
7+
NAT exposes a `Middleware` abstraction that wraps any function — tools, sub-workflows, LLM calls, retrievers, memory operations — at the call site. This adapter is a NAT middleware that:
88

9-
## Why this adapter shape works for NAT
9+
1. Receives every wrapped call's `InvocationContext` (function name, arguments, output) from NAT's middleware pipeline.
10+
2. Translates it to an ACS JSON-RPC request and POSTs it to a Guardian.
11+
3. Applies the Guardian's verdict by blocking the call (raising `ACSGuardianDenied`, or setting `context.action = InvocationAction.SKIP` on NAT versions that expose it), modifying the arguments (`context.modified_kwargs.update(...)`), or passing through.
1012

11-
NAT's defense middleware runs inside the framework's pipeline, declared in configuration rather than written per agent. ACS-Core's hook taxonomy targets the same lifecycle points (tool call, message, memory operations), so the integration is a translation layer between NAT's middleware payload shape and ACS's JSON-RPC envelope, plus a Guardian client that NAT can call from middleware.
13+
## Schema source
1214

13-
## What's needed before this can be a working adapter
15+
The middleware interface, config base class, and registration mechanism are taken directly from NAT's public source (`packages/nvidia_nat_core/src/nat/middleware/`). The adapter is built against and verified with **nvidia-nat-core 1.7.0** (PyPI).
1416

15-
1. NAT middleware payload schemas (versioned). The mapping doc uses public-doc descriptions; the working adapter needs the exact field names and types.
16-
2. NAT extension point for a middleware that performs an HTTP round-trip without blocking the agent loop unacceptably. NAT's middleware contract should support this; verifying with the NAT team.
17-
3. NAT configuration syntax for declaring the middleware. Equivalent of Claude Code's `settings.json` block.
17+
## Quick start
1818

19-
## Contributions welcome
19+
```bash
20+
# 1. Install NAT + adapter
21+
pip install nvidia-nat-core
22+
cp acs_middleware.py /path/in/your/project/
2023

21-
If you work on NAT and can fill in the middleware payload schemas, or wire up a reference adapter, open an issue or PR against `Agent-Control-Standard/ACS`.
24+
# 2. Run the example Guardian (shared with the Claude Code adapter)
25+
python3 ../claude-code/example_guardian.py
26+
# [guardian] listening on 127.0.0.1:8787
27+
28+
# 3. Wire into your NAT workflow YAML
29+
cat > workflow.yml <<'EOF'
30+
middleware:
31+
acs:
32+
_type: acs_guardian
33+
guardian_url: http://127.0.0.1:8787/acs
34+
default_deny: true
35+
36+
function_groups:
37+
my_tools:
38+
middleware: [acs]
39+
40+
workflow:
41+
_type: react_agent
42+
middleware: [acs]
43+
EOF
44+
```
45+
46+
## Files
47+
48+
- `acs_middleware.py` — the middleware class + config + NAT registration. Stdlib + nvidia-nat-core only.
49+
- `mapping.md` — NAT lifecycle point → ACS step method table.
50+
- `tests/test_adapter.py` — 7 integration tests against the real NAT API (skipped automatically if NAT is not installed).
51+
52+
## How it differs from the Claude Code / Cursor adapters
53+
54+
| Aspect | Claude Code / Cursor | NAT |
55+
|---|---|---|
56+
| Interception mechanism | Shell-command-with-stdin-JSON (process spawn per hook) | In-process Python middleware class (`FunctionMiddleware`) |
57+
| Configuration | `settings.json` / `hooks.json` | NAT workflow YAML `middleware:` block |
58+
| Block mechanism | JSON stdout with deny shape, or `exit 2` | Raise `ACSGuardianDenied` (NAT 1.7.0) or set `InvocationAction.SKIP` (NAT dev) |
59+
| Modify mechanism | Updated input field in JSON response | Mutate `context.modified_kwargs` / `context.output` |
60+
| Lifecycle coverage | Whichever events the framework's hook surface exposes | Every function NAT wraps — tools, LLMs, retrievers, memory, sub-workflows |
61+
62+
## Verification status
63+
64+
| Test | Status | Evidence |
65+
|---|---|---|
66+
| Real NAT integration tests | ✓ 7/7 passing | Tests construct real `InvocationContext` + `FunctionMiddlewareContext`, invoke adapter's `pre_invoke` / `post_invoke` via the actual NAT API, assert allow/deny/modify behavior against a live example Guardian. |
67+
| NAT version tested | nvidia-nat-core 1.7.0 (PyPI) | |
68+
| Live integration (full NAT workflow) | ⚠ Manual | Spin up a real NAT workflow with this middleware attached and run it; not yet automated in CI. |
69+
70+
## Compatibility
71+
72+
The adapter works across multiple NAT releases by feature-detecting the block mechanism:
73+
74+
- **NAT 1.7.0 (public release):** blocks by raising `ACSGuardianDenied` (NAT documents "Raises: Any exception to abort execution" for `pre_invoke`).
75+
- **NAT dev branch (with `InvocationAction.SKIP`):** prefers setting `context.action = InvocationAction.SKIP` (cleaner, no exception in logs). The adapter detects the symbol's availability at import time.
76+
77+
## Conformance status
78+
79+
| ACS-Core item | Status in this adapter |
80+
|---|---|
81+
| Handshake | Assumed (per-session negotiation not implemented in the minimal adapter) |
82+
| JSON-RPC envelope ||
83+
| Hook taxonomy minimum | ✓ via NAT's function-wrapping (every tool/LLM/retriever call surfaces as a `steps/toolCallRequest` + `steps/toolCallResult` pair) |
84+
| Dispositions | ALLOW (pass-through) / DENY (block) / MODIFY (mutate context.modified_kwargs or context.output) supported. ASK/DEFER substituted to DENY at the middleware boundary; deployments wanting pause-and-resume should compose with NAT's HITL middleware (`nat.middleware.hitl`). |
85+
| SessionContext | session_id sent on every request (auto-generated per process unless configured) |
86+
| Replay protection | ✓ (UUID + timestamp) |
87+
| Baseline integrity | ⚠ Deferred to transport layer in this minimal adapter |
88+
| Decision honoring | ✓ (NAT's middleware contract guarantees the function will not execute if `pre_invoke` raises or sets SKIP) |
89+
90+
## How NAT's defense middleware composes with this
91+
92+
NAT ships `defense_middleware` (in `nvidia-nat-security`) for prompt-injection and PII checks. The ACS adapter does not replace those — it composes with them. A NAT YAML can list multiple middlewares per group, and they execute in order. Recommended composition: ACS first (policy gate), then NAT defense middleware (content filters), then the function. ACS sees every call; defense filters add content-level checks ACS doesn't model.
93+
94+
## Running the tests
95+
96+
```bash
97+
# Tests require nvidia-nat-core
98+
pip install nvidia-nat-core
99+
cd adapters/nat
100+
python -m unittest tests.test_adapter -v
101+
```
102+
103+
If NAT is not installed, the test class is skipped cleanly (`@unittest.skipUnless`).

adapters/nat/acs_middleware.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"""
2+
ACS middleware for the NVIDIA Agent Toolkit (NAT / NeMo Agent Toolkit).
3+
4+
Wires NAT's Middleware abstraction to an ACS Guardian. Intercepts every
5+
function (tool / sub-workflow / LLM / etc.) call configured to use this
6+
middleware, sends an ACS JSON-RPC request to the Guardian, and applies
7+
the verdict to NAT's invocation context.
8+
9+
Schema source: NAT public repo `packages/nvidia_nat_core/src/nat/middleware/`.
10+
11+
Requires:
12+
pip install nvidia-nat-core
13+
(and nvidia-nat-security if you also want to register alongside NAT's
14+
defense middleware suite)
15+
16+
Compatibility:
17+
- nvidia-nat-core >= 1.7 (public release). Block via raising
18+
ACSGuardianDenied; modify via setting context.modified_kwargs / output.
19+
- Future versions that expose InvocationAction.SKIP are also supported:
20+
if the symbol is importable, the adapter sets context.action instead
21+
of raising, which produces cleaner traces.
22+
23+
Usage in NAT YAML:
24+
25+
middleware:
26+
acs_guardian:
27+
_type: acs_middleware
28+
guardian_url: http://127.0.0.1:8787/acs
29+
target_function_or_group: <tool-or-group-or-workflow-name>
30+
default_deny: true
31+
32+
function_groups:
33+
my_tools:
34+
middleware: [acs_guardian]
35+
36+
workflow:
37+
_type: react_agent
38+
middleware: [acs_guardian]
39+
"""
40+
from __future__ import annotations
41+
42+
import json
43+
import os
44+
import time
45+
import urllib.error
46+
import urllib.request
47+
import uuid
48+
from typing import Any, Optional
49+
50+
try:
51+
from nat.middleware.function_middleware import FunctionMiddleware
52+
from nat.middleware.middleware import InvocationContext
53+
from nat.data_models.middleware import FunctionMiddlewareBaseConfig
54+
_NAT_AVAILABLE = True
55+
except ImportError:
56+
FunctionMiddleware = object # type: ignore[assignment, misc]
57+
InvocationContext = Any # type: ignore[assignment, misc]
58+
FunctionMiddlewareBaseConfig = object # type: ignore[assignment, misc]
59+
_NAT_AVAILABLE = False
60+
61+
# InvocationAction.SKIP is on the dev branch; not in NAT 1.7.0 release.
62+
try:
63+
from nat.middleware.middleware import InvocationAction # type: ignore[attr-defined]
64+
_HAS_INVOCATION_ACTION = True
65+
except (ImportError, AttributeError):
66+
InvocationAction = None # type: ignore[assignment]
67+
_HAS_INVOCATION_ACTION = False
68+
69+
try:
70+
from nat.cli.register_workflow import register_middleware
71+
_HAS_REGISTRATION = True
72+
except ImportError:
73+
register_middleware = None # type: ignore[assignment]
74+
_HAS_REGISTRATION = False
75+
76+
try:
77+
from pydantic import BaseModel, Field
78+
except ImportError:
79+
BaseModel = object # type: ignore[assignment, misc]
80+
Field = lambda **kw: None # type: ignore[assignment, misc]
81+
82+
83+
ACS_VERSION = "0.1.0"
84+
85+
86+
class ACSGuardianDenied(Exception):
87+
"""Raised by the ACS middleware to block a function call.
88+
89+
NAT's documented blocking mechanism is to raise from pre_invoke (the
90+
docstring: "Raises: Any exception to abort execution"). This custom
91+
exception type lets observers and tests distinguish a policy-driven
92+
block from unrelated errors.
93+
"""
94+
95+
96+
# ----- Config -----
97+
98+
if _NAT_AVAILABLE:
99+
100+
class ACSMiddlewareConfig(FunctionMiddlewareBaseConfig, name="acs_guardian"): # type: ignore[misc, valid-type, call-arg]
101+
"""Config schema for the ACS NAT middleware.
102+
103+
Registered with NAT under `_type: acs_guardian` (the `name=` class kwarg
104+
is NAT's TypedBaseModel registration mechanism — see
105+
`nat/data_models/common.py`).
106+
"""
107+
guardian_url: str = Field(
108+
default="http://127.0.0.1:8787/acs",
109+
description="ACS Guardian endpoint to POST requests to.",
110+
)
111+
default_deny: bool = Field(
112+
default=True,
113+
description="Block the call when the Guardian is unreachable or returns malformed responses.",
114+
)
115+
session_id: Optional[str] = Field(
116+
default=None,
117+
description="Session id sent on every request. Auto-generated per-process if absent.",
118+
)
119+
timeout_s: float = Field(
120+
default=5.0,
121+
description="Per-request timeout for the Guardian round-trip.",
122+
)
123+
target_function_or_group: Optional[str] = None
124+
target_location: str = "input"
125+
126+
127+
# ----- Middleware class -----
128+
129+
class ACSMiddleware(FunctionMiddleware): # type: ignore[misc, valid-type]
130+
"""NAT middleware that defers each call's allow/deny/modify decision to an ACS Guardian."""
131+
132+
def __init__(self, config):
133+
if _NAT_AVAILABLE:
134+
super().__init__()
135+
self._config = config
136+
self._session_id = (
137+
getattr(config, "session_id", None)
138+
or os.environ.get("ACS_SESSION_ID")
139+
or f"nat-{uuid.uuid4().hex[:16]}"
140+
)
141+
142+
@property
143+
def enabled(self) -> bool:
144+
return True
145+
146+
async def pre_invoke(self, context):
147+
"""Gate the function call. Block via raising or InvocationAction.SKIP; modify args in place."""
148+
request = self._build_request(
149+
method="steps/toolCallRequest",
150+
tool_name=context.function_context.name,
151+
tool_arguments=dict(context.modified_kwargs or {}),
152+
)
153+
154+
try:
155+
response = self._call_guardian(request)
156+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as e:
157+
if self._config.default_deny:
158+
return self._block(context, f"Guardian unreachable: {e}")
159+
return None # fail-open: proceed
160+
161+
result = (response or {}).get("result", {})
162+
decision = (result.get("decision") or "").lower()
163+
reasoning = result.get("reasoning", "")
164+
165+
if decision == "allow":
166+
return None # proceed unchanged
167+
if decision == "deny":
168+
return self._block(context, reasoning or "denied by Guardian")
169+
if decision == "modify":
170+
mods = result.get("modifications", {})
171+
overrides = mods.get("parameter_overrides")
172+
if isinstance(overrides, dict):
173+
context.modified_kwargs.update(overrides)
174+
return context
175+
return self._block(context, f"MODIFY substituted to DENY: {reasoning}")
176+
if decision in ("ask", "defer"):
177+
# NAT has no native pause-and-resume primitive on the middleware
178+
# boundary. Substitute block; deployments wanting ASK/DEFER
179+
# should compose with NAT's HITL middleware
180+
# (nat.middleware.hitl) and have the Guardian resolve before
181+
# responding.
182+
return self._block(context, f"{decision}: {reasoning}")
183+
184+
# Unknown decision: apply fail posture
185+
if self._config.default_deny:
186+
return self._block(context, f"unknown disposition: {decision}")
187+
return None
188+
189+
async def post_invoke(self, context):
190+
"""Record the result. Optionally modify the output."""
191+
request = self._build_request(
192+
method="steps/toolCallResult",
193+
tool_name=context.function_context.name,
194+
tool_arguments=dict(context.modified_kwargs or {}),
195+
result=context.output,
196+
)
197+
198+
try:
199+
response = self._call_guardian(request)
200+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
201+
return None # post-hoc is best-effort
202+
203+
result = (response or {}).get("result", {})
204+
decision = (result.get("decision") or "").lower()
205+
if decision == "modify":
206+
mods = result.get("modifications", {})
207+
modified_content = mods.get("modified_content")
208+
if modified_content is not None:
209+
context.output = modified_content
210+
return context
211+
return None
212+
213+
# ----- helpers -----
214+
215+
def _block(self, context, reason: str):
216+
"""Block the invocation. Prefer InvocationAction when available,
217+
fall back to raising for NAT releases that don't expose it."""
218+
if _HAS_INVOCATION_ACTION:
219+
context.action = InvocationAction.SKIP # type: ignore[attr-defined]
220+
return context
221+
raise ACSGuardianDenied(reason)
222+
223+
def _build_request(
224+
self,
225+
method: str,
226+
tool_name: str,
227+
tool_arguments: dict,
228+
result: Any = None,
229+
) -> dict:
230+
params: dict[str, Any] = {
231+
"session_id": self._session_id,
232+
"step_id": str(uuid.uuid4()),
233+
"tool": {"name": tool_name, "arguments": tool_arguments},
234+
}
235+
if result is not None:
236+
params["result"] = result if isinstance(result, (str, int, float, bool, dict, list)) else str(result)
237+
return {
238+
"jsonrpc": "2.0",
239+
"id": str(uuid.uuid4()),
240+
"method": method,
241+
"params": params,
242+
"acs_version": ACS_VERSION,
243+
"request_id": str(uuid.uuid4()),
244+
"timestamp": int(time.time() * 1000),
245+
"metadata": {"source": "acs-adapter-nat"},
246+
}
247+
248+
def _call_guardian(self, request: dict) -> dict:
249+
body = json.dumps(request).encode("utf-8")
250+
req = urllib.request.Request(
251+
self._config.guardian_url,
252+
data=body,
253+
headers={"Content-Type": "application/json"},
254+
method="POST",
255+
)
256+
with urllib.request.urlopen(req, timeout=self._config.timeout_s) as resp:
257+
return json.loads(resp.read().decode("utf-8"))
258+
259+
260+
# ----- NAT registration -----
261+
262+
if _NAT_AVAILABLE and _HAS_REGISTRATION:
263+
@register_middleware(config_type=ACSMiddlewareConfig) # type: ignore[misc]
264+
async def build_acs_middleware(config: "ACSMiddlewareConfig", builder): # type: ignore[name-defined]
265+
"""NAT factory entry point. Yields the middleware instance for NAT to wire up."""
266+
yield ACSMiddleware(config)

0 commit comments

Comments
 (0)