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.
Import directly:
Quick Start#
TealTigerMiddleware is a middleware factory β pass the instance straight into an agent's middleware list.
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:
Policy Types#
Tool Allowlist#
Restrict which tools the agent can call. Patterns are matched with fnmatch, so the full glob syntax (*, ?, [seq]) applies:
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:
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:
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:
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:
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:
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:
| 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.
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:
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:
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:
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:
Confidence threshold β tune sensitivity to reduce false positives:
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:
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:
Freezing matches on the exact agent name. There is no wildcard β to freeze several agents, freeze each by 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:
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:
| 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:
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 Complete Example: Governed Research Agent#
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:
TealTigerMiddlewareis the long-lived factory holding shared state (decisions, receipts, frozen agents, cumulative cost)- Calling the factory creates a
_TealTigerPerTurninstance for the turn, holding a reference back to the factory on_turnhandles kill switch enforcement at the turn levelon_tool_executionhandles 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:
Links#
- TealTiger AG2 integration guide β advanced capabilities beyond the bundled extension
- TealTiger on GitHub (Apache 2.0)
- TealTiger governance platform on PyPI
Maintainer: @nagasatish007 / TealTiger.