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
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
349 changes: 225 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/coercion_annotator.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 `from` path is resolved against the preliminary policy input and
# snapshot; the host supplies the turn's user message on the snapshot.
coercion_risk:
from: "$.snapshot.user_message"

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,84 @@
# 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)

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 ───────────────────────────────

pre_tool_call_verdict := 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# ACS policy — sensitivity-tier authorization.
#
# THE ENTIRE POLICY IS ONE RULE. Read it and note what is NOT in it:
#
# * no tool names -> a new tool is covered the day it ships
# * no ID prefixes -> ACC-, LN-, BR-, CL-, and INS-/TR- domains that
# do not exist yet are all covered identically
# * no per-domain field -> it reads the platform-normalized `risk_tier`
# * no entity allow-list -> it keys on the PROPERTY, not on an ID
#
# That is the claim under test: written once, against a property, it holds for
# every domain the platform emits the property for — with zero new code.
#
# Contract with the host (agent_tier_authz.py):
# input.policy_target.value the tool RESULT (JSON string or object), carrying
# the platform sensitivity envelope
# {entity_id, risk_tier, ...}
# input.snapshot.authorized_entities
# entity IDs with a verified authorization record
# recorded this session
# input.snapshot.call_refs ID-shaped values in the tool ARGUMENTS (used by
# the pre-call gate for state-changing calls)
# input.snapshot.protected_refs
# of those, the ones the platform has already
# observed at a protected tier
#
# Fail-closed note: unlike the AIEWF demo policy, an unparseable result does NOT
# fall through to allow — see `unreadable_result` at the bottom.

package agent_control_specification.tier_authorization

import rego.v1

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

verdict := input_verdict if input.intervention_point == "input"

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

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

# ── the property ───────────────────────────────────────────────────────────

sensitive_tiers := {"high_net_worth", "vip", "restricted"}

snapshot := object.get(input, "snapshot", {})

authorized := {e | some e in object.get(snapshot, "authorized_entities", [])}

raw_result := object.get(object.get(input, "policy_target", {}), "value", {})

result_obj := json.unmarshal(raw_result) if is_string(raw_result)

result_obj := raw_result if is_object(raw_result)

subject := object.get(result_obj, "entity_id", "")

subject_tier := object.get(result_obj, "risk_tier", "standard")

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

# ── THE RULE ───────────────────────────────────────────────────────────────
# A result describing a protected entity may not reach the model unless a
# verified authorization record exists for that entity.

post_tool_call_verdict := deny(
"tier_authorization_required",
sprintf("Record %s is classified '%s'. Reads and actions on protected records require a verified customer authorization on file. Call verify_authorization('%s', <justification>) first, then retry — I can complete it immediately afterwards.", [subject, subject_tier, subject]),
) if {
input.intervention_point == "post_tool_call"
subject_tier in sensitive_tiers
not subject in authorized
}

# ── same rule, applied one step earlier ────────────────────────────────────
# A state-changing call whose arguments name an entity the platform has already
# observed at a protected tier is stopped BEFORE it executes, so the write never
# happens. Same predicate, same property, no tool names: "state-changing" is the
# host's own tool metadata, and `protected_refs` is derived by the platform from
# `risk_tier`, not from an ID list.

protected_refs := {e | some e in object.get(snapshot, "protected_refs", [])}

pre_tool_call_verdict := deny(
"tier_authorization_required_precall",
sprintf("That action targets a protected record (%v) with no verified authorization on file. Call verify_authorization first and I can action it right away.", [concat(", ", sort(protected_refs - authorized))]),
) if {
input.intervention_point == "pre_tool_call"
object.get(snapshot, "state_changing", false) == true
count(protected_refs - authorized) > 0
}

# ── fail closed ────────────────────────────────────────────────────────────
# The AIEWF policy fell through to `allow` when a tool result could not be
# parsed (documented there as a demo-only fail-open). Here an unreadable result
# is treated as unclassified and denied, because the classification is exactly
# what the rule depends on.

unreadable_result if {
is_string(raw_result)
not json.is_valid(raw_result)
}

post_tool_call_verdict := deny(
"unclassified_result",
"I couldn't confirm the sensitivity classification of that record, so I'm not going to disclose it. Please retry, or route the request to verified bank operations.",
) if {
input.intervention_point == "post_tool_call"
unreadable_result
}
Loading