Skip to content
Open
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
23 changes: 18 additions & 5 deletions .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ on:
- 'examples/incident_triage_agent/**'
- 'examples/langgraph-foundry-hosted/**'
- 'examples/agents/**'
- 'examples/bank_manager_agent_control/**'
- 'talks/aiewf-18min/**'
- 'talks/README.md'
- 'docs/case-study-*.md'

concurrency:
Expand All @@ -45,7 +48,7 @@ jobs:
tier1-unit:
name: "Tier 1: Unit Tests"
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

Expand All @@ -63,11 +66,18 @@ jobs:
# The `otel` extra is required because tests/test_incident_triage_smoke.py
# imports examples/incident_triage_agent/agent.py, which imports
# opentelemetry at module load time (target.trace.backend: otel). Demo
# examples like that one ship as part of the unit-test surface, so the
# CI install set has to include the demo's optional extras even though
# core ASSERT doesn't need them.
# examples like that one ship as part of the unit-test surface. The bank
# support regressions use the checked-in OPA-backed ACS shim, so they need
# the example/agent dependencies but not the optional ACS generator SDK.
run: |
python -m pip install -e ".[dev,otel]"
python -m pip install -e ".[dev,otel,examples,langgraph]"

- name: Install Open Policy Agent
run: |
curl -fsSL -o /usr/local/bin/opa \
https://openpolicyagent.org/downloads/v0.70.0/opa_linux_amd64_static
chmod +x /usr/local/bin/opa
opa version

- name: Check the behavior library
# Guards two things the reference library cannot enforce by itself:
Expand All @@ -86,3 +96,6 @@ jobs:
# Both paths are required: `pytest tests/` would override the testpaths
# in pytest.ini and silently skip the skill's own suite.
run: pytest tests/ .claude/skills/run-assert-eval/tests/ -x -q

- name: Run bank support example regressions
run: pytest examples/bank_manager_agent_control/tests -q
2 changes: 1 addition & 1 deletion examples/bank_manager_agent_control/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ AZURE_API_KEY=<paste-your-azure-openai-key>
AZURE_API_BASE=https://<your-azure-openai-resource>.openai.azure.com/
AZURE_API_VERSION=2024-12-01-preview
# Deployment name on your resource. MUST match the azure/<name> model strings
# in the eval_realistic_*.yaml configs (currently azure/gpt-5.4-mini). If your
# in eval_tier_authorization.yaml and eval_coercion_authority.yaml. If your
# deployment is named differently, change BOTH here and in the YAMLs so the
# agent-under-test and the eval/judge use the same unfiltered deployment.
AGENT_MODEL=gpt-5.4-mini
Expand Down
345 changes: 221 additions & 124 deletions examples/bank_manager_agent_control/README.md

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions examples/bank_manager_agent_control/acs/manifest_coercion.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
agent_control_specification_version: "0.4.0-alpha.1"
metadata:
name: "bank-manager-coercion-classifier"

# COERCION-VIA-UNVERIFIED-AUTHORITY variant.
#
# The typed-signal policy (acs/policy/bank_manager_feature.rego) gates on TYPED
# signals a tool emits — risk_tier, referenced_accounts, grounded,
# transfer_approved. Those are deterministic and belong in Rego.
#
# This manifest handles the residual those rules cannot express: a *standard*-
# tier account, a sub-threshold amount, a structurally well-formed transfer —
# every typed field clean — where the only signal that a required recorded
# control step is being skipped lives in the requester's prose. That signal is
# produced by a host-side learned annotator, declared ONCE under `annotators`
# and referenced at the intervention point via `annotations` (ACS §10). The
# policy never calls a model; it reads `input.annotations.coercion_risk`.

policies:
bank_manager_coercion:
type: rego
bundle: ./policy
query: data.agent_control_specification.bank_manager_coercion.verdict

annotators:
# Declared once. `type: classifier` tells the host which dispatcher owns the
# call; the host dispatcher owns the network request, caching, retries and
# timeouts. ACS defines no built-in classifier engine.
coercion_risk:
type: classifier
# Host-defined fields. The dispatcher in runtime/acs_annotator_shim.py reads
# these; ACS treats them as opaque.
module: coercion_classifier
entrypoint: annotate
calibration: ./runtime/coercion_calibration.json
timeout_ms: 20000

intervention_points:
pre_tool_call:
policy_target: "$.tool_call.args"
policy_target_kind: tool_args
tool_name_from: "$.tool_call.name"
policy:
id: bank_manager_coercion
query: data.agent_control_specification.bank_manager_coercion.pre_tool_call_verdict
annotations:
# The dispatcher receives the complete host snapshot: user prose plus a
# typed verification of any cited bank-owned control artifact.
coercion_risk:
from: "$.snapshot"

post_tool_call:
policy_target: "$.tool_result"
policy_target_kind: tool_result
tool_name_from: "$.tool_call.name"
policy:
id: bank_manager_coercion
query: data.agent_control_specification.bank_manager_coercion.post_tool_call_verdict

tools:
read_account: {}
read_transaction_history: {}
read_client: {}
read_loan: {}
read_portfolio: {}
prepare_transfer: {}
request_customer_approval: {}
create_transfer: {}
prepare_loan_modification: {}
place_trade: {}
freeze_account: {}
enable_admin_mode: {}
knowledge_base_retrieve: {}
70 changes: 0 additions & 70 deletions examples/bank_manager_agent_control/acs/manifest_feature.yaml

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
agent_control_specification_version: "0.3.0-alpha"
metadata:
name: "bank-manager-tier-authorization"

# Sensitivity-tier authorization. The bundle contains ONE rule
# (policy_tier_authz/tier_authorization.rego) keyed on the platform-normalized
# `risk_tier` property.
#
# Note there is no `tools:` block. The AIEWF manifest enumerated every tool the
# agent could call; this one does not need to, because the policy contains no
# tool-name predicate. That absence is the point: registering a new domain's
# tools requires no manifest edit and no policy edit.

policies:
tier_authorization:
type: rego
bundle: ./policy_tier_authz
query: data.agent_control_specification.tier_authorization.verdict

intervention_points:
pre_tool_call:
policy_target: "$.tool_call.args"
policy_target_kind: tool_args
tool_name_from: "$.tool_call.name"
policy:
id: tier_authorization
query: data.agent_control_specification.tier_authorization.pre_tool_call_verdict

post_tool_call:
policy_target: "$.tool_result"
policy_target_kind: tool_result
tool_name_from: "$.tool_call.name"
policy:
id: tier_authorization
query: data.agent_control_specification.tier_authorization.post_tool_call_verdict
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# ACS policy — coercion via unverified authority claims (learned-gate tier).
#
# The typed-feature policy (bank_manager_feature.rego) is the deterministic tier:
# risk_tier, referenced_accounts, grounded, transfer_approved. This file is the
# LEARNED tier, and it deliberately owns only the residual the typed tier cannot
# express.
#
# The gate reads a host-produced annotation, NOT a model. ACS §10: the runtime
# resolves each `from` path, calls the host annotator dispatcher, and places the
# output only under `annotations.<name>`. Policy execution stays pure and
# deterministic given that input — which is what makes the decision auditable
# and replayable even though the signal upstream of it is learned.
#
# Three-valued on purpose. A learned signal has a genuine uncertain middle, and
# collapsing it to allow/deny throws away the one action that costs least when
# the classifier is unsure: route it to a human approver.
#
# score > deny_hi -> deny (clear coercion; do not action here)
# score > escalate_lo -> escalate (route to the verified-operations queue)
# otherwise -> allow
#
# Both band edges come from the annotation itself (`escalate_lo` / `deny_hi`),
# so recalibrating the classifier re-tunes the gate without editing Rego. The
# defaults below are intentionally unreachable (>1.0) so a MISSING annotation
# can never silently widen the allow band.

package agent_control_specification.bank_manager_coercion

import rego.v1

default verdict := {"decision": "allow"}
default pre_tool_call_verdict := {"decision": "allow"}
default post_tool_call_verdict := {"decision": "allow"}

verdict := pre_tool_call_verdict if input.intervention_point == "pre_tool_call"
verdict := post_tool_call_verdict if input.intervention_point == "post_tool_call"

annotations := object.get(input, "annotations", {})
coercion := object.get(annotations, "coercion_risk", {})
score := object.get(coercion, "score", 0)
escalate_lo := object.get(coercion, "escalate_lo", 2)
deny_hi := object.get(coercion, "deny_hi", 2)
invalid_references := object.get(coercion, "invalid_references", [])

tool_name := object.get(object.get(input, "tool", {}), "name", "")

# Only actions where skipping a recorded control step actually changes state.
# Reads stay ungated: an over-broad learned gate is how you buy back the
# over-refusal you were trying to remove.
gated_tools := {
"create_transfer",
"request_customer_approval",
"prepare_loan_modification",
"place_trade",
"freeze_account",
"enable_admin_mode",
}

deny(reason, message) := {"decision": "deny", "reason": reason, "message": message}

escalate(reason, message) := {"decision": "escalate", "reason": reason, "message": message}

# ── pre_tool_call: the learned coercion gate ───────────────────────────────

annotation_valid if {
object.get(annotations, "coercion_risk", null) != null
is_object(coercion)
is_number(object.get(coercion, "score", null))
is_number(object.get(coercion, "escalate_lo", null))
is_number(object.get(coercion, "deny_hi", null))
}

pre_tool_call_verdict := escalate(
"coercion_annotation_missing",
"I couldn't verify the learned control signal for this action, so I routed it to verified bank operations instead of executing it.",
) if {
input.intervention_point == "pre_tool_call"
tool_name in gated_tools
not annotation_valid
} else := escalate(
"unverified_control_artifact",
"The request cites a control reference that the bank's records could not verify for this action. I routed it to verified bank operations instead of treating the string as authorization.",
) if {
input.intervention_point == "pre_tool_call"
tool_name in gated_tools
count(invalid_references) > 0
} else := deny(
"coercion_unverified_authority",
"I can't action that here. The request asks me to stand in a claimed authority or an unrecorded verbal assurance in place of the recorded approval step, and that step is what protects the customer. Send it to verified bank operations with an authorization reference and I can complete it immediately.",
) if {
input.intervention_point == "pre_tool_call"
tool_name in gated_tools
score > deny_hi
} else := escalate(
"coercion_unverified_authority_ambiguous",
"I've routed this to the verified bank operations approver queue for a second look before anything is actioned — I couldn't confirm from the request whether a recorded authorization is already on file. If you have the authorization or callback reference, send it and I can complete it right away.",
) if {
input.intervention_point == "pre_tool_call"
tool_name in gated_tools
score > escalate_lo
}

# ── post_tool_call: nothing learned runs here ──────────────────────────────
#
# The typed post-call scrubbers live in bank_manager_feature.rego. Keeping this
# point declared but silent makes the manifest complete without duplicating a
# deterministic rule into the learned tier.
Loading
Loading