Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions implementations/acr-control-plane/docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ result = session.evaluate_action(

The Python SDK also includes a real adapter layer for LangGraph/LangChain-style tools.

**Optional dependency limitation (2026-10-25):** the `integrations` extra and the
legacy LangGraph example dependency family are not audit-clean. Do not install
those optional dependencies for production evaluation pending a separately reviewed
migration. The SDK/base-package security fix does not remediate them; the base
`pip install acr-control-plane` path does not install them. See the
[example installation warning](../examples/langgraph_guard_example.md#install).
The plain `guard_tool` and `guard_async_tool` wrappers do not require LangChain.

Use:

- `guard_tool(...)`
Expand Down Expand Up @@ -92,6 +100,14 @@ That wrapper will:
- apply modified parameters on `modify`
- return gateway-managed execution output when configured not to execute locally

The Python SDK rejects ALLOW/MODIFY on non-200 evaluation responses. A MODIFY
response must include an explicit replacement action and parameters for the
requested tool; missing transformations and changed tool names raise an SDK
error rather than executing the original call. An explicitly empty parameter
object remains valid and follows the wrapped function's normal default-argument
behavior. These checks do not authenticate a compromised gateway or provide
approval replay/concurrency protection.

## TypeScript SDK

The TypeScript package lives at:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,22 @@ search or real email is sent.

## Install

> **Security limitation (2026-10-25):** the optional legacy LangChain/LangGraph
> dependency family below is **not audit-clean**. Do not install it for production
> evaluation pending a separately reviewed dependency migration. The SDK/base-package
> security fix does not remediate these optional dependencies. The base
> `pip install acr-control-plane` path does not install them. This command is
> retained only for isolated, disposable regression reproduction without secrets
> or untrusted inputs.

From `implementations/acr-control-plane/`, in your activated development virtual
environment:

```bash
python -m pip install -e '.[dev,integrations]' 'langgraph>=0.3,<0.7'
```

This example uses the repository's supported LangChain Core 0.3 integration range.
This example uses the repository's legacy LangChain Core 0.3 integration range.
The LangGraph bound keeps installation within the matching pre-1.0 framework
family; it is not a claim of compatibility with every newer LangGraph release.

Expand Down
14 changes: 7 additions & 7 deletions implementations/acr-control-plane/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ dependencies = [
"passlib[bcrypt]>=1.7.4,<2.0.0",

# OpenTelemetry — core (stable releases)
"opentelemetry-api>=1.29.0,<2.0.0",
"opentelemetry-sdk>=1.29.0,<2.0.0",
"opentelemetry-exporter-otlp-proto-http>=1.29.0,<2.0.0",
"opentelemetry-api>=1.45.1,<2.0.0",
"opentelemetry-sdk>=1.45.1,<2.0.0",
"opentelemetry-exporter-otlp-proto-http>=1.45.1,<2.0.0",

# OpenTelemetry — instrumentation packages track the core version family.
# Pinned to <0.60b0 to prevent unreviewed major jumps.
"opentelemetry-instrumentation-fastapi==0.59b0",
"opentelemetry-instrumentation-httpx==0.59b0",
"opentelemetry-instrumentation-sqlalchemy==0.59b0",
# This reviewed family permits the CVE-2026-114603 fix in API 1.45.1.
"opentelemetry-instrumentation-fastapi==0.66b1",
"opentelemetry-instrumentation-httpx==0.66b1",
"opentelemetry-instrumentation-sqlalchemy==0.66b1",

# Circuit breakers
"aiobreaker>=1.1",
Expand Down
37 changes: 33 additions & 4 deletions implementations/acr-control-plane/src/acr/sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ def _parse_model(response: httpx.Response, model_type):
return model_type.model_validate(response.json())


def _parse_evaluate_response(response: httpx.Response) -> EvaluateResponse:
def _parse_evaluate_response(
response: httpx.Response, *, expected_tool_name: str
) -> EvaluateResponse:
payload = _parse_json_response(response)
if response.status_code not in _EVALUATE_STATUS_CODES:
raise ACRHTTPError(
Expand All @@ -60,7 +62,34 @@ def _parse_evaluate_response(response: httpx.Response) -> EvaluateResponse:
message="Evaluate response body was not JSON",
body=payload,
)
return EvaluateResponse.model_validate(payload)
result = EvaluateResponse.model_validate(payload)
# Only a successful evaluation may authorize execution. Preserve structured
# deny/escalate results on supported failure statuses, but never let a
# contradictory response body turn an error or pending status into allow.
if response.status_code != 200 and result.is_allowed:
raise ACRHTTPError(
status_code=response.status_code,
message="Non-success evaluate response cannot authorize execution",
body=payload,
)
# Validate transformation completeness and action identity for raw SDK
# callers too, not only callers using the optional local guard wrappers.
modified = result.modified_action
if result.decision == "modify" and modified is None:
raise ACRHTTPError(
status_code=response.status_code,
message="MODIFY response requires an explicit modified action",
body=payload,
)
if modified is not None and (
modified.tool_name != expected_tool_name or "parameters" not in modified.model_fields_set
):
raise ACRHTTPError(
status_code=response.status_code,
message="Modified action must match the requested tool and include explicit parameters",
body=payload,
)
return result


class ACRClient:
Expand Down Expand Up @@ -171,7 +200,7 @@ def evaluate(self, request: EvaluateRequest, *, access_token: str) -> EvaluateRe
headers=self._agent_headers(access_token),
json=_jsonable(request),
)
return _parse_evaluate_response(response)
return _parse_evaluate_response(response, expected_tool_name=request.action.tool_name)

def evaluate_action(
self,
Expand Down Expand Up @@ -307,7 +336,7 @@ async def evaluate(self, request: EvaluateRequest, *, access_token: str) -> Eval
headers=self._agent_headers(access_token),
json=_jsonable(request),
)
return _parse_evaluate_response(response)
return _parse_evaluate_response(response, expected_tool_name=request.action.tool_name)

async def evaluate_action(
self,
Expand Down
17 changes: 14 additions & 3 deletions implementations/acr-control-plane/src/acr/sdk/langgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from acr.gateway.models import EvaluateResponse, IntentRequest
from acr.sdk.client import ACRAgentSession, AsyncACRAgentSession
from acr.sdk.errors import ACRDeniedError, ACREscalatedError
from acr.sdk.errors import ACRDeniedError, ACREscalatedError, ACRSDKError


def _bind_arguments(func: Callable[..., Any], *args: Any, **kwargs: Any) -> dict[str, Any]:
Expand All @@ -27,9 +27,16 @@ def _coerce_intent(intent: IntentRequest | dict[str, Any] | None) -> IntentReque
def _resolve_effective_parameters(
original_parameters: dict[str, Any],
response: EvaluateResponse,
tool_name: str,
) -> dict[str, Any]:
if response.modified_action is None:
if response.decision == "modify":
raise ACRSDKError("MODIFY response requires an explicit modified action")
return original_parameters
if response.modified_action.tool_name != tool_name:
raise ACRSDKError("Modified action does not match the guarded tool")
if "parameters" not in response.modified_action.model_fields_set:
raise ACRSDKError("Modified action requires explicit parameters")
return dict(response.modified_action.parameters)


Expand Down Expand Up @@ -72,9 +79,11 @@ def wrapped(*args: Any, **kwargs: Any) -> Any:
intent=intent,
)
_handle_blocking_decision(response)
effective_parameters = _resolve_effective_parameters(
parameters, response, resolved_tool_name
)
if response.execution_result is not None and not execute_locally_on_allow:
return response.execution_result
effective_parameters = _resolve_effective_parameters(parameters, response)
return func(**effective_parameters)

return wrapped
Expand Down Expand Up @@ -107,9 +116,11 @@ async def wrapped(*args: Any, **kwargs: Any) -> Any:
intent=intent,
)
_handle_blocking_decision(response)
effective_parameters = _resolve_effective_parameters(
parameters, response, resolved_tool_name
)
if response.execution_result is not None and not execute_locally_on_allow:
return response.execution_result
effective_parameters = _resolve_effective_parameters(parameters, response)
result = func(**effective_parameters)
if inspect.isawaitable(result):
return await result
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Bounded, offline regressions for the patched W3C trace-context parser."""

from opentelemetry.trace import get_current_span
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator


def test_trace_context_preserves_valid_trace_and_state():
context = TraceContextTextMapPropagator().extract(
{
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"tracestate": "vendor=value,other=second",
}
)
span_context = get_current_span(context).get_span_context()
assert span_context.is_valid
assert span_context.trace_id == int("0af7651916cd43dd8448eb211c80319c", 16)
assert list(span_context.trace_state.items()) == [("vendor", "value"), ("other", "second")]


def test_trace_state_rejects_combined_headers_over_security_limit():
# Each member is individually valid, and the 32-member limit is respected.
# The combined header is just over the patched 8192-character limit. This
# tests bounded rejection without a timing assertion or a large CPU payload.
members = [f"k{index:02d}=" + "x" * 253 for index in range(32)]
header = ",".join(members)
assert len(header) == 8255
context = TraceContextTextMapPropagator().extract(
{
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"tracestate": header,
}
)
span_context = get_current_span(context).get_span_context()
assert span_context.is_valid
assert list(span_context.trace_state.items()) == []
Loading
Loading