From a818e397a96c1b997347a94c2e6f929a4fddca81 Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 5 Nov 2026 10:51:56 -0800 Subject: [PATCH 1/5] docs: add country-specific controls example Co-Authored-By: Claude --- docs.json | 6 +- examples/country-controls.mdx | 186 ++++++++++++++++++++++++++++++++++ examples/overview.mdx | 4 + 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 examples/country-controls.mdx diff --git a/docs.json b/docs.json index 41a28f7..f1fb1da 100644 --- a/docs.json +++ b/docs.json @@ -109,7 +109,8 @@ "group": "Use case examples", "pages": [ "examples/customer-support", - "examples/steer-action-demo" + "examples/steer-action-demo", + "examples/country-controls" ] } ] @@ -170,7 +171,8 @@ "group": "Use case examples", "pages": [ "examples/customer-support", - "examples/steer-action-demo" + "examples/steer-action-demo", + "examples/country-controls" ] } ] diff --git a/examples/country-controls.mdx b/examples/country-controls.mdx new file mode 100644 index 0000000..84e8c8f --- /dev/null +++ b/examples/country-controls.mdx @@ -0,0 +1,186 @@ +--- +title: Country-Specific Controls +description: Apply different rules per country, as one control per country or as one composite control. +--- + +Some policies depend on where a user is. For example, an assistant must not return a German IBAN to a user in Germany, or a US Social Security number to a user in the US. This example shows how to write these country-specific (jurisdiction) rules with Agent Control. + +## What This Demonstrates + +- Country-specific rules: each rule applies only when the request's jurisdiction matches. +- Two ways to author the same policy: + - One control per country. + - One composite control with a branch for each country. +- Two ways to send the jurisdiction with each check: + - In `step.context`, with `evaluate_controls()` or the HTTP API. + - As a function argument, with the `@control()` decorator. + +No LLM or API key is needed. The scripts use fixed text in place of a model answer. + +## Quick Start + +### Prerequisites + +1. Start the Agent Control server: `make server-run` from the repo root. + +### Run the Demo + +```bash +# From the agent-control repo root +uv run python examples/country_controls/country_controls_context.py +uv run python examples/country_controls/country_controls_decorator.py +``` + +Each script creates its agent and controls, then runs the scenarios. The scripts are safe to re-run. + +## Send the Jurisdiction + +The caller must send the jurisdiction with each check. Use an ISO 3166-1 alpha-2 code, such as `DE` or `US`. + +### In `step.context` (recommended) + +`context` holds request metadata, separate from the data that the controls check. Controls select the value with the path `context.jurisdiction`. + +```python +result = await agent_control.evaluate_controls( + "support-answer", + input="What is on file for me?", + output=answer_text, + context={"jurisdiction": "DE"}, + step_type="llm", + stage="post", + agent_name=AGENT_NAME, +) +if not result.is_safe: + ... # blocked +``` + +Gateways and non-Python clients send the same field in the body of `POST /api/v1/evaluation`: + +```json +{ + "agent_name": "my-agent", + "stage": "post", + "step": { + "type": "llm", + "name": "support-answer", + "input": "What is on file for me?", + "output": "Your refund goes to DE89 3704 0044 0532 0130 00.", + "context": {"jurisdiction": "DE"} + } +} +``` + +### As a decorator argument + +`@control()` builds the step from the function's arguments, and it cannot set `step.context`. To use the decorator, pass the jurisdiction as a function argument and select it with `input.jurisdiction`. + +```python +@control(step_name="support-answer", step_type="tool") +async def support_answer(*, text: str, jurisdiction: str) -> str: + return text +``` + + + This works only for `tool` steps and custom step types, where `input` is the dict of all arguments. For an `llm` step, `input` is a single string, so the path `input.jurisdiction` does not resolve. + + +## Author the Controls + +Each country rule is an `and` of two conditions: + +- The jurisdiction is in a list, checked with the `list` evaluator. +- The rule matches, here a `regex` on the output. + +### Option 1: One control per country + +```python +de_iban = { + "execution": "server", + "scope": {"step_types": ["llm"], "stages": ["post"]}, + "condition": { + "and": [ + { + "selector": {"path": "context.jurisdiction"}, + "evaluator": {"name": "list", "config": {"values": ["DE"]}}, + }, + { + "selector": {"path": "output"}, + "evaluator": { + "name": "regex", + "config": {"pattern": r"\bDE\d{2}(?: ?\d{4}){4} ?\d{2}\b"}, + }, + }, + ] + }, + "action": {"decision": "deny"}, +} +``` + +Write a similar control for each country, for example `US` with a Social Security number pattern. + +### Option 2: One composite control + +Put each country's `and` branch inside one `or`: + +```python +countries = { + "execution": "server", + "scope": {"step_types": ["llm"], "stages": ["post"]}, + "condition": { + "or": [ + {"and": [jurisdiction_in(["DE"]), output_matches(DE_IBAN)]}, + {"and": [jurisdiction_in(["US"]), output_matches(US_SSN)]}, + ] + }, + "action": {"decision": "deny"}, +} +``` + +`jurisdiction_in()` and `output_matches()` are small helpers in the example scripts. They return the leaf conditions shown in Option 1. + +### Which option to use + +| | One control per country | One composite control | +|---|---|---| +| Number of controls | One per country | One | +| Match result | Names the country's control | Names the composite control | +| Change one country | Edit or disable only that control | Edit the shared condition | + +Both options give the same decisions. The example runs them side by side: + +| Scenario | Expected | +|---|---| +| DE + IBAN | Blocked | +| US + IBAN (no US rule) | Allowed | +| US + SSN | Blocked | +| DE + SSN (no DE rule) | Allowed | +| US + clean text | Allowed | + +## Missing Jurisdiction + + + If a request has no jurisdiction, no country rule matches, so the request is allowed. Agent Control has no built-in default jurisdiction. + + +If you need a strict default, add a control that matches when the jurisdiction is missing or not covered: + +```python +{ + "and": [ + {"not": jurisdiction_in(["DE", "US"])}, + {"or": [output_matches(DE_IBAN), output_matches(US_SSN)]}, + ] +} +``` + +## Files + +- `country_controls_context.py` - Sends the jurisdiction in `step.context` with `evaluate_controls()` +- `country_controls_decorator.py` - Sends the jurisdiction as a `@control()` function argument + +## Source Code + +View the complete example with all scripts and setup instructions: + +[`Country-Specific Controls Example`](https://github.com/agentcontrol/agent-control/tree/main/examples/country_controls) diff --git a/examples/overview.mdx b/examples/overview.mdx index b94cd91..888a323 100644 --- a/examples/overview.mdx +++ b/examples/overview.mdx @@ -72,5 +72,9 @@ Browse working examples that show how to add Agent Control to different framewor Realistic enterprise scenario with PII protection, prompt-injection defense, and multiple tool types. + + + Apply different rules per country, as one control per country or as one composite control. + From f131bb22a33385a74a2fc24efcf2f78d164fe828 Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 5 Nov 2026 11:06:02 -0800 Subject: [PATCH 2/5] docs: clarify country controls decorator path and strict default - Warn that the decorator path needs step_types ["tool"] and the input.jurisdiction selector. - Show the strict default as a full control, and say it must stay in sync with the country controls. - Document step_type and tools in the @control() SDK reference. - Describe the PASS/FAIL output of the example scripts. Co-Authored-By: Claude --- examples/country-controls.mdx | 31 +++++++++++++++++++++++++------ sdk/python-sdk.mdx | 10 +++++++++- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/examples/country-controls.mdx b/examples/country-controls.mdx index 84e8c8f..1db7766 100644 --- a/examples/country-controls.mdx +++ b/examples/country-controls.mdx @@ -31,7 +31,7 @@ uv run python examples/country_controls/country_controls_context.py uv run python examples/country_controls/country_controls_decorator.py ``` -Each script creates its agent and controls, then runs the scenarios. The scripts are safe to re-run. +Each script creates its agent and controls, then runs the scenarios. It prints PASS or FAIL for each check, and exits non-zero if any check fails. The scripts are safe to re-run. ## Send the Jurisdiction @@ -85,6 +85,13 @@ async def support_answer(*, text: str, jurisdiction: str) -> str: This works only for `tool` steps and custom step types, where `input` is the dict of all arguments. For an `llm` step, `input` is a single string, so the path `input.jurisdiction` does not resolve. + + The controls below use the `context` path. For the decorator path, make two changes in each control, or the controls never match and every request is allowed: + + - Set `"step_types"` to `["tool"]`. + - Set the jurisdiction selector `path` to `"input.jurisdiction"`. + + ## Author the Controls Each country rule is an `and` of two conditions: @@ -166,14 +173,26 @@ Both options give the same decisions. The example runs them side by side: If you need a strict default, add a control that matches when the jurisdiction is missing or not covered: ```python -{ - "and": [ - {"not": jurisdiction_in(["DE", "US"])}, - {"or": [output_matches(DE_IBAN), output_matches(US_SSN)]}, - ] +strict_default = { + "execution": "server", + "scope": {"step_types": ["llm"], "stages": ["post"]}, + "condition": { + "and": [ + {"not": jurisdiction_in(["DE", "US"])}, + {"or": [output_matches(DE_IBAN), output_matches(US_SSN)]}, + ] + }, + "action": {"decision": "deny"}, } ``` +Keep this control in sync with the country controls: + +- The `not` list must name every country that has a rule. +- The `or` must repeat every country rule. + +For example, if you add an `FR` control but do not add its rule to the `or`, a request with no jurisdiction that matches the `FR` rule is allowed. + ## Files - `country_controls_context.py` - Sends the jurisdiction in `step.context` with `evaluate_controls()` diff --git a/sdk/python-sdk.mdx b/sdk/python-sdk.mdx index 8b607a9..829f90f 100644 --- a/sdk/python-sdk.mdx +++ b/sdk/python-sdk.mdx @@ -182,7 +182,13 @@ When background refresh is enabled, the SDK refreshes control snapshots via `GET #### `@control()` ```python -def control(policy: str | None = None, step_name: str | None = None): +def control( + policy: str | None = None, + step_name: str | None = None, + step_type: str | None = None, + *, + tools=None, +): ``` Decorator to protect a function with server-defined controls. @@ -191,6 +197,8 @@ Decorator to protect a function with server-defined controls. - `policy`: Optional policy label for code readability when multiple policies exist - `step_name`: Optional step name override for the registered step +- `step_type`: Optional step type override, for example `"tool"`. If not set, tool-like functions are `tool` steps and other functions are `llm` steps +- `tools`: Optional tool definitions for `llm` steps, as a static list or a provider called at each check **Example:** From 15f8883ed61b1dd8f7dcdca3a82d568002c76a21 Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 5 Nov 2026 11:42:52 -0800 Subject: [PATCH 3/5] docs: add baseline controls and header mapping to country controls - New section on baseline vs country controls, with a baseline control. - Explain copying an x-ai-jurisdiction header into step.context. - Add the baseline scenarios to the scenario table. Co-Authored-By: Claude --- examples/country-controls.mdx | 53 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/examples/country-controls.mdx b/examples/country-controls.mdx index 1db7766..2e3d7e8 100644 --- a/examples/country-controls.mdx +++ b/examples/country-controls.mdx @@ -11,6 +11,7 @@ Some policies depend on where a user is. For example, an assistant must not retu - Two ways to author the same policy: - One control per country. - One composite control with a branch for each country. +- Baseline controls that apply to every country, next to the country controls. - Two ways to send the jurisdiction with each check: - In `step.context`, with `evaluate_controls()` or the HTTP API. - As a function argument, with the `@control()` decorator. @@ -37,6 +38,8 @@ Each script creates its agent and controls, then runs the scenarios. It prints P The caller must send the jurisdiction with each check. Use an ISO 3166-1 alpha-2 code, such as `DE` or `US`. +If the country code arrives in a request header, such as `x-ai-jurisdiction: DE`, the caller copies it into `step.context.jurisdiction`. Agent Control does not read request headers. + ### In `step.context` (recommended) `context` holds request metadata, separate from the data that the controls check. Controls select the value with the path `context.jurisdiction`. @@ -154,20 +157,52 @@ countries = { | Match result | Names the country's control | Names the composite control | | Change one country | Edit or disable only that control | Edit the shared condition | -Both options give the same decisions. The example runs them side by side: +## Baseline and Country Controls + +Most policies combine two kinds of rules: + +- **Baseline controls** apply to every country. They have no jurisdiction condition. +- **Country controls** apply only to some countries. They include the jurisdiction condition. + +Keep them as separate controls. A baseline control is a plain control: + +```python +baseline_email = { + "execution": "server", + "scope": {"step_types": ["llm"], "stages": ["post"]}, + "condition": { + "selector": {"path": "output"}, + "evaluator": { + "name": "regex", + "config": {"pattern": r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"}, + }, + }, + "action": {"decision": "deny"}, +} +``` + +Agent Control evaluates every control in scope, so the baseline and the country controls run together on each check. A baseline control also applies when the request has no jurisdiction. -| Scenario | Expected | -|---|---| -| DE + IBAN | Blocked | -| US + IBAN (no US rule) | Allowed | -| US + SSN | Blocked | -| DE + SSN (no DE rule) | Allowed | -| US + clean text | Allowed | +To tell the two kinds apart, use a naming convention or `tags`, for example `["baseline"]` and `["country", "DE"]`. + +## Scenarios + +Both authoring options give the same decisions. The example runs them side by side, with the baseline control on both: + +| Scenario | Expected | Matched by | +|---|---|---| +| DE + IBAN | Blocked | DE country rule | +| US + IBAN (no US rule) | Allowed | - | +| US + SSN | Blocked | US country rule | +| DE + SSN (no DE rule) | Allowed | - | +| US + clean text | Allowed | - | +| DE + email | Blocked | Baseline | +| No jurisdiction + email | Blocked | Baseline | ## Missing Jurisdiction - If a request has no jurisdiction, no country rule matches, so the request is allowed. Agent Control has no built-in default jurisdiction. + If a request has no jurisdiction, no country rule matches. Only baseline controls apply, so the request is allowed unless a baseline control matches. Agent Control has no built-in default jurisdiction. If you need a strict default, add a control that matches when the jurisdiction is missing or not covered: From f5dc28392d083043c3f4985faac1ff2889ec6e06 Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 5 Nov 2026 11:49:17 -0800 Subject: [PATCH 4/5] docs: fix country controls lint and clarify deny evaluation - Remove the list indent inside (MD007). - State that evaluation stops after the first deny match. - Narrow the header sentence: headers are not mapped into the step. - Add the no-jurisdiction + IBAN scenario. Co-Authored-By: Claude --- examples/country-controls.mdx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/country-controls.mdx b/examples/country-controls.mdx index 2e3d7e8..8b0600f 100644 --- a/examples/country-controls.mdx +++ b/examples/country-controls.mdx @@ -38,7 +38,7 @@ Each script creates its agent and controls, then runs the scenarios. It prints P The caller must send the jurisdiction with each check. Use an ISO 3166-1 alpha-2 code, such as `DE` or `US`. -If the country code arrives in a request header, such as `x-ai-jurisdiction: DE`, the caller copies it into `step.context.jurisdiction`. Agent Control does not read request headers. +If the country code arrives in a request header, such as `x-ai-jurisdiction: DE`, the caller copies it into `step.context.jurisdiction`. Agent Control does not map request headers into the step. ### In `step.context` (recommended) @@ -89,10 +89,10 @@ async def support_answer(*, text: str, jurisdiction: str) -> str: - The controls below use the `context` path. For the decorator path, make two changes in each control, or the controls never match and every request is allowed: +The controls below use the `context` path. For the decorator path, make two changes in each control, or the controls never match and every request is allowed: - - Set `"step_types"` to `["tool"]`. - - Set the jurisdiction selector `path` to `"input.jurisdiction"`. +- Set `"step_types"` to `["tool"]`. +- Set the jurisdiction selector `path` to `"input.jurisdiction"`. ## Author the Controls @@ -181,7 +181,11 @@ baseline_email = { } ``` -Agent Control evaluates every control in scope, so the baseline and the country controls run together on each check. A baseline control also applies when the request has no jurisdiction. +The baseline and the country controls run together on each check. A baseline control also applies when the request has no jurisdiction. + + + Agent Control evaluates the controls in parallel and stops the remaining evaluations after the first `deny` match. If a baseline control and a country control both match, `result.matches` usually names only one of them. + To tell the two kinds apart, use a naming convention or `tags`, for example `["baseline"]` and `["country", "DE"]`. @@ -197,6 +201,7 @@ Both authoring options give the same decisions. The example runs them side by si | DE + SSN (no DE rule) | Allowed | - | | US + clean text | Allowed | - | | DE + email | Blocked | Baseline | +| No jurisdiction + IBAN | Allowed | - (country rules are skipped) | | No jurisdiction + email | Blocked | Baseline | ## Missing Jurisdiction From 6452d7f6759c3e7256f876501b0e214cea2be7ff Mon Sep 17 00:00:00 2001 From: Yixin Date: Thu, 5 Nov 2026 14:38:34 -0800 Subject: [PATCH 5/5] docs: rename country controls page to attribute-based controls Co-Authored-By: Claude --- docs.json | 4 ++-- ...trols.mdx => attribute-based-controls.mdx} | 24 ++++++++++--------- examples/overview.mdx | 4 ++-- 3 files changed, 17 insertions(+), 15 deletions(-) rename examples/{country-controls.mdx => attribute-based-controls.mdx} (84%) diff --git a/docs.json b/docs.json index f1fb1da..e3eddd9 100644 --- a/docs.json +++ b/docs.json @@ -110,7 +110,7 @@ "pages": [ "examples/customer-support", "examples/steer-action-demo", - "examples/country-controls" + "examples/attribute-based-controls" ] } ] @@ -172,7 +172,7 @@ "pages": [ "examples/customer-support", "examples/steer-action-demo", - "examples/country-controls" + "examples/attribute-based-controls" ] } ] diff --git a/examples/country-controls.mdx b/examples/attribute-based-controls.mdx similarity index 84% rename from examples/country-controls.mdx rename to examples/attribute-based-controls.mdx index 8b0600f..294c60f 100644 --- a/examples/country-controls.mdx +++ b/examples/attribute-based-controls.mdx @@ -1,16 +1,18 @@ --- -title: Country-Specific Controls -description: Apply different rules per country, as one control per country or as one composite control. +title: Attribute-Based Controls +description: Apply rules only for some values of a request attribute, such as the country. Author them as one control per value or as one composite control. --- -Some policies depend on where a user is. For example, an assistant must not return a German IBAN to a user in Germany, or a US Social Security number to a user in the US. This example shows how to write these country-specific (jurisdiction) rules with Agent Control. +Some rules apply only when a request attribute has a given value. The attribute can be the user's country, tenant, user tier or channel. Agent Control expresses this as a condition on the attribute, combined with the rule itself. + +This example uses the country (jurisdiction) as the attribute. An assistant must not return a German IBAN to a user in Germany, or a US Social Security number to a user in the US. To use another attribute, replace the jurisdiction field and its values. ## What This Demonstrates -- Country-specific rules: each rule applies only when the request's jurisdiction matches. +- Attribute-based rules: each rule applies only when the request's jurisdiction matches. - Two ways to author the same policy: - - One control per country. - - One composite control with a branch for each country. + - One control per attribute value, here one per country. + - One composite control with a branch for each value. - Baseline controls that apply to every country, next to the country controls. - Two ways to send the jurisdiction with each check: - In `step.context`, with `evaluate_controls()` or the HTTP API. @@ -28,8 +30,8 @@ No LLM or API key is needed. The scripts use fixed text in place of a model answ ```bash # From the agent-control repo root -uv run python examples/country_controls/country_controls_context.py -uv run python examples/country_controls/country_controls_decorator.py +uv run python examples/attribute_controls/attribute_controls_context.py +uv run python examples/attribute_controls/attribute_controls_decorator.py ``` Each script creates its agent and controls, then runs the scenarios. It prints PASS or FAIL for each check, and exits non-zero if any check fails. The scripts are safe to re-run. @@ -235,11 +237,11 @@ For example, if you add an `FR` control but do not add its rule to the `or`, a r ## Files -- `country_controls_context.py` - Sends the jurisdiction in `step.context` with `evaluate_controls()` -- `country_controls_decorator.py` - Sends the jurisdiction as a `@control()` function argument +- `attribute_controls_context.py` - Sends the jurisdiction in `step.context` with `evaluate_controls()` +- `attribute_controls_decorator.py` - Sends the jurisdiction as a `@control()` function argument ## Source Code View the complete example with all scripts and setup instructions: -[`Country-Specific Controls Example`](https://github.com/agentcontrol/agent-control/tree/main/examples/country_controls) +[`Attribute-Based Controls Example`](https://github.com/agentcontrol/agent-control/tree/main/examples/attribute_controls) diff --git a/examples/overview.mdx b/examples/overview.mdx index 888a323..9ceafba 100644 --- a/examples/overview.mdx +++ b/examples/overview.mdx @@ -73,8 +73,8 @@ Browse working examples that show how to add Agent Control to different framewor Realistic enterprise scenario with PII protection, prompt-injection defense, and multiple tool types. - - Apply different rules per country, as one control per country or as one composite control. + + Apply rules only for some attribute values, such as the country, as one control per value or as one composite control.