Skip to content

TealTiger

The ag2.extensions.tealtiger module adds deterministic governance guardrails to AG2 agents. TealTigerMiddleware enforces tool allowlists and blocklists, validates tool arguments, detects PII and secrets in tool arguments and in tool results, blocks prompt injection attempts, tracks per-session cost, provides per-agent kill switches, and produces structured TEEC audit receipts β€” with no LLM in the governance path.

Installation#

Note

The extension ships with AG2. No additional Python package and no API key are required β€” all governance evaluation runs inline on the standard library.

pip install ag2

Import directly:

1
2
3
4
5
6
7
8
from ag2.extensions.tealtiger import (
    GovernanceDecision,
    GovernanceMode,
    GovernancePolicy,
    OutputAction,
    TEECReceipt,
    TealTigerMiddleware,
)

Quick Start#

TealTigerMiddleware is a middleware factory β€” pass the instance straight into an agent's middleware list.

import asyncio

from ag2 import Agent
from ag2.config import AnthropicConfig
from ag2.extensions.tealtiger import GovernanceMode, GovernancePolicy, TealTigerMiddleware

governance = TealTigerMiddleware(
    mode=GovernanceMode.ENFORCE,
    policies=[
        GovernancePolicy.prompt_injection_block(),
        GovernancePolicy.tool_allowlist(["search", "read_*"]),
        GovernancePolicy.pii_block(["ssn", "credit_card", "email", "phone"]),
        GovernancePolicy.secret_detection(),
        GovernancePolicy.cost_limit(max_per_session=5.0),
    ],
)

async def main() -> None:
    agent = Agent(
        "assistant",
        config=AnthropicConfig(model="claude-sonnet-5"),
        middleware=[governance],
    )
    reply = await agent.ask("Summarize the AG2 middleware guide.")
    print(await reply.content())
    print(governance.decisions)
    print(f"Cost: ${governance.total_cost:.4f}")

if __name__ == "__main__":
    asyncio.run(main())

Every tool call now flows through deterministic governance evaluation before execution.

Multi-Stage Defense#

TealTiger governs at three stages of the AG2 agent lifecycle:

flowchart LR
    A[πŸ‘€ User Message] --> B{πŸ›‘οΈ Stage 1<br/>on_turn}
    B -->|Kill Switch<br/>Check| C[πŸ€– Agent<br/>Processing]
    C --> D[πŸ”§ Tool Call]
    D --> E{πŸ›‘οΈ Stage 2<br/>pre-tool}
    E -->|"βœ… ALLOW"| F[⚑ Execute Tool]
    E -->|"❌ DENY"| G[🚫 ToolErrorEvent]
    F --> I{πŸ›‘οΈ Stage 3<br/>output_scan}
    I -->|"βœ… clean or redacted"| H[πŸ“ TEEC Receipt]
    I -->|"❌ BLOCK"| G
    G --> H

    style B fill:#f0fdfa,stroke:#14b8a6,stroke-width:2px
    style E fill:#f0fdfa,stroke:#14b8a6,stroke-width:2px
    style I fill:#f0fdfa,stroke:#14b8a6,stroke-width:2px
    style H fill:#f0fdfa,stroke:#14b8a6,stroke-width:2px
    style F fill:#f0fdf4,stroke:#16a34a
    style G fill:#fef2f2,stroke:#dc2626
Hook Stage What It Does
on_turn Turn defense Kill switch enforcement β€” frozen agents cannot take turns
on_tool_execution Pre-tool defense Tool allowlist/blocklist, argument validation, PII scan, secret scan, cost limit check
on_tool_execution Post-tool defense Output scan β€” PII and secrets in the tool's result, before it re-enters the model's context

All three stages activate from a single middleware instance. The middleware factory pattern keeps state (decisions, receipts, cost, frozen agents) alive across turns.

Receipts come from the tool stage

A TEECReceipt is emitted for every tool evaluation, whether the call was executed or blocked. Turn-level kill switch denials record a GovernanceDecision but do not emit a receipt.

Governance Modes#

Mode Behavior Use Case
ENFORCE Evaluates all policies. Blocks violations with ToolErrorEvent. Production
MONITOR Evaluates all policies, records decisions, but never blocks. Staging / shadow testing
OBSERVE Skips policy evaluation. Passes through, still tracks cost and records an OBSERVE_PASSTHROUGH decision. Initial rollout / lowest overhead

mode accepts either a GovernanceMode member or its string name, and defaults to GovernanceMode.ENFORCE. Start with MONITOR in staging to see what would be blocked, then switch to ENFORCE in production:

# Shadow mode β€” see what governance would deny without blocking anything
governance = TealTigerMiddleware(
    mode=GovernanceMode.MONITOR,
    policies=[
        GovernancePolicy.tool_allowlist(["search", "read_*"]),
        GovernancePolicy.pii_block(["ssn", "credit_card"]),
    ],
)

# After validation, promote to ENFORCE
governance.mode = GovernanceMode.ENFORCE

Policy Types#

Tool Allowlist#

Restrict which tools the agent can call. Patterns are matched with fnmatch, so the full glob syntax (*, ?, [seq]) applies:

GovernancePolicy.tool_allowlist(["search", "read_*", "github_*"])

Any tool not matching the patterns is denied with reason code TOOL_NOT_ALLOWED and risk score 80.

Tool Blocklist#

The complement of the allowlist: allow every tool except the ones you name. Reach for this when an agent has many safe tools and only a few dangerous ones, so enumerating every safe tool would be tedious. Patterns are matched with fnmatch, so the full glob syntax (*, ?, [seq]) applies:

GovernancePolicy.tool_blocklist(["delete_*", "shell", "drop_table"])

Any tool matching a pattern is denied with reason code TOOL_BLOCKED and risk score 80.

An empty blocklist raises ValueError at policy construction, so a policy that blocks nothing cannot be created by mistake.

You can combine an allowlist with a blocklist β€” for example, permit everything under read_* but still deny read_secrets. When both are configured, every policy is evaluated until one denies, so either one denying results in a DENY; the policy that denies is the one reported, whatever its position in the list:

1
2
3
4
5
6
7
8
from ag2.extensions.tealtiger import GovernancePolicy, TealTigerMiddleware

governance = TealTigerMiddleware(
    policies=[
        GovernancePolicy.tool_allowlist(["read_*"]),
        GovernancePolicy.tool_blocklist(["read_secrets"]),
    ],
)

Here read_file passes both policies, while read_secrets clears the allowlist and is denied by the blocklist β€” so the reason code is TOOL_BLOCKED, from the second entry.

Pattern matching is case-sensitive on Linux and macOS

fnmatch normalizes case the way the host filesystem does, so ["shell"] blocks shell but not Shell on Linux/macOS, while on Windows it blocks both. For an allowlist that asymmetry denies too much; for a blocklist it lets a call through. If your tool names are not under your control, list the casings you need to block explicitly, or normalize tool names before registering them.

Argument Validation#

Where the allowlist and blocklist govern which tools run, arg_validation governs what a tool is called with β€” a defense against dangerous argument values such as SQL injection, path traversal, or oversized payloads. It applies to any tool whose name matches tool via fnmatch, and checks the named arguments against per-argument constraints:

# Reject long queries and dangerous SQL verbs
GovernancePolicy.arg_validation(
    "sql_query",
    {"query": {"max_length": 500, "blocked_terms": ["DROP", "DELETE", ";--"]}},
)

# Reject path traversal in a file tool
GovernancePolicy.arg_validation(
    "read_file",
    {"path": {"blocked_patterns": [r"\.\.[\\/]"]}},  # ../ and ..\
)

Each argument's constraint spec may combine any of these checks:

Check Denies when
max_length len(str(value)) exceeds the limit
min_length len(str(value)) is below the minimum
type The value is not of this type β€” one of "str", "int", "float", "bool", "list", "dict"
blocked_terms Any term appears in the value (case-insensitive substring)
blocked_patterns Any regex matches the value
allowed_values The value is not one of these

A violation is denied with reason code ARG_VALIDATION:{argument}:{check} (for example ARG_VALIDATION:query:max_length) and risk score 85. Only the arguments named in constraints are checked β€” any others pass through β€” and a constrained argument that is absent from the call is skipped.

Type note: bool is not accepted for int

Because bool is a subclass of int in Python, {"type": "int"} explicitly rejects a boolean value, so a True/False cannot slip through where a real integer is required.

The policy validates its own spec at construction, checking each check's value and not just its name. An empty tool or constraints, a non-dict spec, an unknown check name, an unsupported type, a negative or non-integer length bound, a max_length below the min_length, an empty term or value list, or an uncompilable regex all raise ValueError. A typo therefore surfaces where you wrote it β€” rather than leaving a policy that silently validates nothing, or one that raises from inside the governance path on the first call it evaluates.

Patterns are compiled during that same validation, so evaluation never pays for compilation:

GovernancePolicy.arg_validation("read_file", {"path": {"blocked_patterns": ["[unclosed"]}})
# ValueError: Invalid regex in `blocked_patterns` for argument 'path': '[unclosed' β€” ...

Named checks need mapping-style arguments

Length, type, and allowed_values checks rely on reading an argument by name. A model usually sends a JSON object, but it can send an array or a bare value instead, leaving no names to read. The policy then stays fail-closed: every constrained argument's blocked_terms and blocked_patterns run over the whole serialized call, and the value-specific checks are skipped. A denial from that path is reported as ARG_VALIDATION:*:{check} β€” the * marking that the hit could not be attributed to one argument, and that a term found anywhere in the call, including in an argument the policy does not constrain, is enough to deny.

PII Detection#

Block tool calls containing sensitive data in their arguments:

GovernancePolicy.pii_block(["ssn", "credit_card", "email", "phone"])

Passing no argument applies all four categories:

Category Pattern Example Match
ssn \b\d{3}-\d{2}-\d{4}\b 123-45-6789
credit_card \b(?:\d{4}[-\s]?){3}\d{4}\b 4111-1111-1111-1111
email Standard email regex user@example.com
phone US phone with optional +1 +1-555-123-4567

Denied with risk score 90. One reason code is appended per matched category, in the form PII_DETECTED:ssn, PII_DETECTED:email, and so on.

Secret Detection#

Block tool calls containing API keys, tokens, or credentials:

GovernancePolicy.secret_detection()
Pattern What It Catches
sk-… (20+ chars) OpenAI-style API keys
ghp_… (36+ chars) GitHub personal access tokens
AKIA… (16 chars) AWS access key IDs
xox[bpors]-… Slack tokens
api_key=… / apikey:… (20+ chars) Generic API keys
PEM private key headers RSA, EC, DSA private keys

Denied with reason code SECRET_DETECTED and risk score 95 β€” the highest of the policy checks.

Output Scanning#

Every policy above inspects a tool's arguments before it runs. output_scan inspects what a tool returns β€” the data-leakage direction. A tool that reads a database, a file, or an external API can hand back an SSN or a leaked credential that would otherwise land straight in the model's context on the next turn. This scans the result first and redacts, blocks, or flags it.

1
2
3
4
5
6
# Redact PII, block secrets (the defaults)
GovernancePolicy.output_scan()

# Block on any PII too, and only scan the categories you care about.
# Actions take an OutputAction member or its string name, as `mode` takes GovernanceMode.
GovernancePolicy.output_scan(pii_action=OutputAction.BLOCK, categories=["ssn", "credit_card"])

Each detector carries its own action:

Action Effect
REDACT Replace matched values in the result with [REDACTED:{type}]; the sanitized result flows through to the model.
BLOCK Withhold the whole result and fail the turn with a governance error (ENFORCE only). In MONITOR it degrades to REDACT so the value still never leaks.
FLAG Record the finding in the decision and receipt trail; pass the result through unchanged.

The defaults mirror the sensible split: PII is redacted (the agent usually still needs the surrounding result), secrets are blocked (a leaked credential should not reach the model at all).

The two detectors act independently: output_scan(pii_action="FLAG", secret_action="REDACT") records the PII it finds and rewrites only the credential. Where they overlap β€” a single result carrying both, or several output_scan policies disagreeing about the same detector β€” the more restrictive action wins.

Results are scanned whatever shape they arrive in. A tool returning a dict or a list leaks just as readily as one returning a sentence, so structured results are scanned and redacted through their strings:

1
2
3
4
5
6
# A tool whose result is a dict, not a sentence
def lookup_customer(name: str) -> dict[str, str]:
    return {"name": name, "ssn": "123-45-6789"}

# With GovernancePolicy.output_scan() configured, the model receives:
#   {"name": "Ada", "ssn": "[REDACTED:ssn]"}

Findings are recorded with reason codes OUTPUT_PII_DETECTED:{category} and OUTPUT_SECRET_DETECTED, plus OUTPUT_REDACTED, OUTPUT_REDACTION_INCOMPLETE, or OUTPUT_BLOCKED describing what was done. Risk score is 90 when a secret is present, 60 for PII only.

Error results are scanned too. A tool that raises with an SSN or credential in its exception message leaks it into the model's context just as a returned value would, so output_scan scans a ToolErrorEvent's message and traceback on the same terms. An error stays an error: its content is redacted in place, and a BLOCK in ENFORCE replaces it with a sanitized governance error rather than turning the failure into a success.

A value spanning two result parts is withheld, not half-redacted

Detection reads a result's parts together, while redaction rewrites each part on its own β€” so a value split across a part boundary can be found but not cut out. Rather than pass on a result it could not fully sanitize, TealTiger withholds it and adds the reason code OUTPUT_REDACTION_INCOMPLETE. Outside ENFORCE the affected parts are replaced with [REDACTED:unredactable].

output_scan validates its own configuration: scanning nothing (scan_pii=False, scan_secrets=False), an invalid action, an unknown PII category, or scan_pii=True with an empty categories list all raise ValueError at construction, so a policy that would silently scan nothing cannot be created by mistake.

Runs after the tool, in MONITOR and ENFORCE

Output scanning evaluates the tool's result β€” successful or error β€” so it necessarily runs after the tool executes; it governs what re-enters the model's context, not whether the tool runs. In OBSERVE mode results pass through unmodified, consistent with OBSERVE never altering a call.

Cost Limit#

Enforce per-session cost ceilings:

GovernancePolicy.cost_limit(max_per_session=5.0)

Cumulative cost is tracked across all tool calls using cost_per_call (configurable, defaults to 0.002). Once the limit is reached, further calls are denied with BUDGET_EXCEEDED and risk score 70.

Prompt Injection Detection#

Block tool calls containing adversarial prompt injection patterns in their arguments:

GovernancePolicy.prompt_injection_block()

Detects 5 technique categories with 20 regex patterns:

Technique Patterns What It Catches Confidence
instruction_override 5 "Ignore previous instructions", system prompt override, context resets 0.80–0.95
role_manipulation 5 DAN jailbreak, developer mode, persona switching, "jailbreak mode" 0.85–0.95
context_manipulation 4 Delimiter injection, XML/chat-template tag injection, markdown fence injection, fake system messages 0.80–0.90
encoding_evasion 4 Base64 payloads, hex escapes, unicode escapes, ROT13 references 0.65–0.75
multi_turn_assembly 2 Payload splitting, continuation attacks 0.80–0.85

Denied with reason code PROMPT_INJECTION:{technique}/{pattern_name} and risk score 95.

Patterns match on injection framing, not on keywords alone β€” DAN counts only alongside a jailbreak cue and is matched case-sensitively, a <system> tag counts only when instruction-like content follows it. Without that, ordinary arguments (a colleague named Dan, <system> in an XML payload, "add the new rules to the linter config") would be denied at risk score 95.

Selective techniques β€” enable only specific categories:

1
2
3
GovernancePolicy.prompt_injection_block(
    techniques=["instruction_override", "role_manipulation"],
)

Confidence threshold β€” tune sensitivity to reduce false positives:

1
2
3
GovernancePolicy.prompt_injection_block(
    confidence_threshold=0.85,  # Skip lower-confidence patterns
)

Each pattern carries a fixed confidence score (0.0–1.0). The threshold selects which patterns run β€” it is not a score computed per match β€” and only patterns at or above it are evaluated. Default threshold is 0.7, which leaves rot13_reference (0.65) inactive; lower the threshold to enable it.

An unknown technique name, an empty techniques list, or a threshold outside 0.0–1.0 raises ValueError at policy construction, so a typo cannot silently leave the policy detecting nothing.

Policies are evaluated in list order

Policies are evaluated in the order you pass them, and evaluation stops at the first denial. Put prompt_injection_block() before tool_allowlist(...) if you want injection attempts reported as PROMPT_INJECTION:… rather than TOOL_NOT_ALLOWED.

Factory-Level Budget#

In addition to the policy-level cost_limit, you can set a factory-level budget that is always enforced, with no policy needed:

1
2
3
4
5
6
7
governance = TealTigerMiddleware(
    budget_limit=10.0,      # Hard ceiling β€” always enforced
    cost_per_call=0.002,    # Estimated cost per tool call
    policies=[
        GovernancePolicy.tool_allowlist(["search", "read_*"]),
    ],
)

budget_limit defaults to infinity. It is checked before any policy runs, providing a spending cap that no policy ordering can bypass.

Kill Switch#

Freeze an agent immediately β€” this blocks its turns and all of its tool calls, regardless of policies:

1
2
3
governance.freeze("assistant")      # Block everything for this agent
governance.is_frozen("assistant")   # True
governance.unfreeze("assistant")    # Restore normal governance

Freezing matches on the exact agent name. There is no wildcard β€” to freeze several agents, freeze each by name:

for name in ("researcher", "analyst", "writer"):
    governance.freeze(name)

The kill switch is enforced at both the turn level (on_turn) and the tool level (on_tool_execution):

  • ENFORCE mode: returns ToolErrorEvent, blocking the entire turn or tool call
  • MONITOR mode: records a DENY decision but allows the action through
  • OBSERVE mode: not evaluated (passthrough)

Kill switch decisions carry reason code AGENT_FROZEN and risk score 100 (maximum).

Audit Trail#

Every governance decision produces a structured GovernanceDecision:

1
2
3
4
5
6
7
8
9
for decision in governance.decisions:
    print(
        f"[{decision.action}] tool={decision.tool_name} "
        f"agent={decision.agent_name} "
        f"reason_codes={decision.reason_codes} "
        f"risk={decision.risk_score} "
        f"latency={decision.evaluation_time_ms:.2f}ms "
        f"cost=${decision.cumulative_cost:.4f}"
    )

Decision Contract Fields#

Field Type Description
decision_id str UUID4 string, unique per decision, for correlation
action str Governance verdict: ALLOW or DENY
mode str Mode the decision was made under: OBSERVE, MONITOR, or ENFORCE
agent_name str Agent that made the call, or unknown
tool_name str Tool that was evaluated (* for turn-level denials)
reason_codes list[str] Machine-readable codes: TOOL_NOT_ALLOWED, TOOL_BLOCKED, ARG_VALIDATION:{argument}:{check} ({argument} is * when the call's arguments were not a mapping), PROMPT_INJECTION:{technique}/{pattern}, PII_DETECTED:{category}, SECRET_DETECTED, OUTPUT_PII_DETECTED:{category}, OUTPUT_SECRET_DETECTED, OUTPUT_REDACTED, OUTPUT_REDACTION_INCOMPLETE, OUTPUT_BLOCKED, BUDGET_EXCEEDED, AGENT_FROZEN, POLICY_ALLOW, OBSERVE_PASSTHROUGH
risk_score int 0 (safe) to 100 (critical)
evaluation_time_ms float Governance latency for this decision
cost_tracked float Cost attributed to this call in USD
cumulative_cost float Running session cost in USD
timestamp_ms float Unix timestamp in milliseconds

TEEC Receipts#

Every tool evaluation β€” executed or blocked β€” produces a TEECReceipt:

1
2
3
4
5
for receipt in governance.receipts:
    print(
        f"{receipt.execution_outcome} | {receipt.tool_name} | "
        f"agent={receipt.agent_name} | decision={receipt.decision_id}"
    )
Field Description
receipt_id Unique receipt UUID string
decision_id Links back to the governance decision
agent_name Agent that triggered the evaluation
tool_name Tool that was called
action ALLOW or DENY
execution_outcome executed, blocked, or error
reason_codes Copied from the originating decision
risk_score Copied from the originating decision
policy_digest Short SHA-256 digest of the active policy set
timestamp_ms Unix timestamp in milliseconds

Factory State API#

The factory exposes the accumulated state directly:

Member Description
decisions All GovernanceDecision records across all turns
receipts All TEECReceipt records across all turns
total_cost Cumulative tracked cost in USD
deny_count Number of decisions with action == "DENY"
is_frozen(name) Whether that agent is currently frozen
reset() Clear decisions, receipts, cost, and frozen agents

Callbacks#

React to governance decisions in real time:

def on_deny(decision):
    if decision.action == "DENY":
        alert_ops_team(decision.tool_name, decision.reason_codes)

def on_receipt(receipt):
    audit_log.append(receipt)

governance = TealTigerMiddleware(
    policies=[GovernancePolicy.tool_allowlist(["search"])],
    on_decision=on_deny,       # Called for every decision (ALLOW and DENY)
    on_receipt=on_receipt,     # Called for every TEEC receipt
)

Use Across Multiple Agents#

One governance instance can be attached to several agents β€” decisions, receipts, cost, and frozen agents are shared across all of them. Here the coordinator delegates to two subagents via Agent.as_tool(), and all three run under the same policy set:

flowchart TB
    GOV["πŸ›‘οΈ TealTigerMiddleware β€” Shared Instance<br/>(decisions, receipts, cost, frozen_agents)"]
    GOV -->|evaluates| R["πŸ”¬ Researcher<br/>tools: search, read_*"]
    GOV -->|evaluates| AN["πŸ“Š Analyst<br/>tools: calculator"]
    GOV -->|"🧊 FROZEN"| W["✍️ Coordinator"]

    style GOV fill:#f0fdfa,stroke:#14b8a6,stroke-width:2px
    style R fill:#eff6ff,stroke:#93c5fd
    style AN fill:#eff6ff,stroke:#93c5fd
    style W fill:#fef2f2,stroke:#fca5a5
import asyncio

from ag2 import Agent
from ag2.config import AnthropicConfig
from ag2.extensions.tealtiger import GovernanceMode, GovernancePolicy, TealTigerMiddleware

# Single governance instance shared across all agents
governance = TealTigerMiddleware(
    mode=GovernanceMode.ENFORCE,
    policies=[
        GovernancePolicy.tool_allowlist(["search", "read_*", "calculator"]),
        GovernancePolicy.pii_block(["ssn", "credit_card", "email"]),
        GovernancePolicy.secret_detection(),
        GovernancePolicy.cost_limit(max_per_session=10.0),
    ],
)
config = AnthropicConfig(model="claude-sonnet-5")

async def main() -> None:
    researcher = Agent("researcher", config=config, middleware=[governance])
    analyst = Agent("analyst", config=config, middleware=[governance])
    coordinator = Agent(
        "coordinator",
        config=config,
        middleware=[governance],
        tools=[
            researcher.as_tool(description="Research a topic and return findings."),
            analyst.as_tool(description="Analyze findings and return a summary."),
        ],
    )
    reply = await coordinator.ask("Research AI governance trends, then analyze them.")
    print(await reply.content())
    # Cost, decisions, and receipts are shared across all three agents
    print(f"Total decisions: {len(governance.decisions)}")
    print(f"Denied: {governance.deny_count}")
    print(f"Total cost: ${governance.total_cost:.4f}")
    # Freeze one agent β€” it can no longer take turns or call tools
    governance.freeze("researcher")

if __name__ == "__main__":
    asyncio.run(main())

Complete Example: Governed Research Agent#

import asyncio

from ag2 import Agent
from ag2.config import AnthropicConfig
from ag2.extensions.tealtiger import GovernanceMode, GovernancePolicy, TealTigerMiddleware

governance = TealTigerMiddleware(
    mode=GovernanceMode.ENFORCE,
    budget_limit=10.0,
    cost_per_call=0.003,
    policies=[
        # Tool governance β€” only safe tools
        GovernancePolicy.tool_allowlist(["web_search", "calculator", "read_file"]),
        # Data protection β€” block PII leakage
        GovernancePolicy.pii_block(["ssn", "credit_card", "email", "phone"]),
        # Credential protection β€” block secret leakage
        GovernancePolicy.secret_detection(),
        # Cost governance β€” hard per-session limit
        GovernancePolicy.cost_limit(max_per_session=5.0),
    ],
    on_decision=lambda d: print(f"  [{d.action}] {d.tool_name} β€” {d.reason_codes}"),
)

async def main() -> None:
    agent = Agent(
        "research_assistant",
        config=AnthropicConfig(model="claude-sonnet-5"),
        middleware=[governance],
    )
    reply = await agent.ask("Research AI governance frameworks for enterprise deployment.")
    print(await reply.content())
    # Post-run analysis
    allowed = [d for d in governance.decisions if d.action == "ALLOW"]
    denied = [d for d in governance.decisions if d.action == "DENY"]
    print("\n--- Governance Summary ---")
    print(f"Total evaluations: {len(governance.decisions)}")
    print(f"Allowed: {len(allowed)}")
    print(f"Denied: {len(denied)}")
    print(f"Session cost: ${governance.total_cost:.4f}")
    print(f"TEEC receipts: {len(governance.receipts)}")
    for d in denied:
        print(f"  β€’ {d.tool_name}: {d.reason_codes} (risk={d.risk_score})")

if __name__ == "__main__":
    asyncio.run(main())

Evaluation Order#

Two checks always run first, in this order. Everything after them is your policy list, walked in the order you declared it β€” the first DENY wins and short-circuits the rest. Once the tool has run, output_scan gets the last word on its result:

flowchart TD
    START([Tool Call Received]) --> S1
    S1{{"1️⃣ Kill Switch<br/>(risk: 100)"}} -->|frozen| DENY[❌ DENY]
    S1 -->|not frozen| S2
    S2{{"2️⃣ Factory Budget<br/>(risk: 70)"}} -->|exceeded| DENY
    S2 -->|within budget| S3
    S3{{"3️⃣ Your policies, in list order<br/>first DENY wins"}} -->|violation| DENY
    S3 -->|all clean| RUN[/Tool executes/]
    RUN --> S4
    S4{{"4️⃣ Output scan, on the result<br/>(risk: 90 secret / 60 PII)"}} -->|BLOCK| DENY
    S4 -->|REDACT| SANITIZED[βœ… ALLOW<br/>sanitized result]
    S4 -->|FLAG or clean| ALLOW[βœ… ALLOW]

    style S1 fill:#fef2f2,stroke:#fca5a5
    style S2 fill:#fef9c3,stroke:#fde047
    style S3 fill:#eff6ff,stroke:#93c5fd
    style S4 fill:#faf5ff,stroke:#c4b5fd
    style DENY fill:#fef2f2,stroke:#dc2626,stroke-width:2px
    style ALLOW fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
    style SANITIZED fill:#f0fdf4,stroke:#16a34a,stroke-width:2px

Steps 1–3 govern whether the tool runs; step 4 governs what its result is allowed to carry back. Only step 4 runs after execution, and it is skipped entirely in OBSERVE mode.

Each policy type carries its own risk score when it denies:

Policy Reason Code Risk Score
Kill switch AGENT_FROZEN 100
Prompt injection PROMPT_INJECTION:{technique}/{pattern} 95
Secret detection SECRET_DETECTED 95
PII detection PII_DETECTED:{category} 90
Argument validation ARG_VALIDATION:{argument}:{check} 85
Tool allowlist TOOL_NOT_ALLOWED 80
Tool blocklist TOOL_BLOCKED 80
Cost limit / factory budget BUDGET_EXCEEDED 70
Output scan (post-tool, on the result) OUTPUT_SECRET_DETECTED / OUTPUT_PII_DETECTED:{category} 90 / 60

Policy order is yours to choose

Because policies are evaluated in the order you pass them, the reason code on a denial depends on that order. If a call violates two policies, only the first one in your list is reported. Put the checks you most want attributed earliest in the list.

If all checks pass, the decision is ALLOW with reason code POLICY_ALLOW and risk score 0.

Architecture#

flowchart TB
    subgraph Factory["TealTigerMiddleware (Long-lived factory)"]
        STATE["_decisions | _receipts<br/>_frozen_agents | _cumulative_cost"]
        API["freeze() | unfreeze() | is_frozen()<br/>.decisions | .receipts | .total_cost"]
    end

    Factory -->|"creates per turn"| PerTurn

    subgraph PerTurn["_TealTigerPerTurn (Per-turn instance)"]
        TURN["on_turn()<br/>Kill switch + mode gating"]
        TOOL["on_tool_execution()<br/>Policy evaluation + receipts"]
    end

    PerTurn -->|"writes back"| STATE

    style Factory fill:#f0fdfa,stroke:#14b8a6,stroke-width:2px
    style PerTurn fill:#eff6ff,stroke:#3b82f6,stroke-width:2px
ag2/extensions/tealtiger/
β”œβ”€β”€ __init__.py       # Public API: TealTigerMiddleware, GovernanceMode, GovernancePolicy, GovernanceDecision, TEECReceipt + injection detection types
β”œβ”€β”€ types.py          # GovernanceMode, GovernancePolicy, GovernanceDecision, TEECReceipt + injection detection types (InjectionPattern, InjectionFinding, INJECTION_PATTERNS, INJECTION_TECHNIQUES, DEFAULT_INJECTION_CONFIDENCE_THRESHOLD)
└── middleware.py     # TealTigerMiddleware (factory) + _TealTigerPerTurn (per-turn) + _PII_PATTERNS / _SECRET_PATTERNS / _detect_prompt_injection

The middleware follows AG2's middleware factory pattern:

  • TealTigerMiddleware is the long-lived factory holding shared state (decisions, receipts, frozen agents, cumulative cost)
  • Calling the factory creates a _TealTigerPerTurn instance for the turn, holding a reference back to the factory
  • on_turn handles kill switch enforcement at the turn level
  • on_tool_execution handles all policy evaluation at the tool level
  • Kill switch, budget, and audit state persist across turns and agents

Performance#

Governance runs entirely in-process: no network calls, no LLM inference, and no external service dependencies. Every check is a fnmatch glob, a precompiled re pattern, or a plain comparison over the tool arguments β€” every regex the middleware evaluates, arg_validation's blocked_patterns included, is compiled before the first call reaches it. Evaluation cost therefore scales with argument size rather than with model latency.

Each decision records its own measured cost in evaluation_time_ms, so you can profile governance overhead against your own workload:

slowest = max(governance.decisions, key=lambda d: d.evaluation_time_ms)
print(f"{slowest.tool_name}: {slowest.evaluation_time_ms:.3f}ms")

Maintainer: @nagasatish007 / TealTiger.