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
149 changes: 149 additions & 0 deletions contributing/samples/authority_routing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Authority routing plugin for Google ADK

An ADK plugin that decides an **authority posture** — ADVISE / EXECUTE / DEFER /
STOP — for every tool call *before* it runs. Tool schemas and permissions govern
*access*; this plugin governs *authorization*: whether the agent was allowed to
act at all.

It is the layer where two common agent failures live:

- **advice becomes action** — the user asked for a recommendation and the agent
executed anyway;
- **scope expansion** — one bounded edit ballooned into a dependent workflow;
- **missing approval** — an approval was clearly required and the agent proceeded.

## Install

```bash
pip install google-adk-community
```

No extra dependencies — the plugin is self-contained.

## The four postures

| Posture | Meaning |
| --------- | ------------------------------------------------------------- |
| `ADVISE` | Produce recommendations only; no tool side effects. |
| `EXECUTE` | Act, within an explicitly bounded scope. |
| `DEFER` | Pause and request authorization when scope/approval is unclear. |
| `STOP` | Refuse or block when the requested work is not allowed. |

## How it works

Two stages produce the final posture:

1. **Posture router** (optional, caller-supplied) — typically a model call that
classifies the request and returns a `PostureVerdict`. Its verdict is
*advisory*.
2. **Deterministic guard** — an irreversibility keyword tripwire (scanning the
tool name and argument values) plus an approval-state check on session
context. It composes with the router under **most-restrictive-wins**: plain
code can only make the posture *stricter*, never looser.

The plugin is **fail-closed**: a router that raises or returns an unparseable
verdict defaults to `DEFER` — an unreadable authorization is not an
authorization. With **no router configured**, the plugin runs guard-only: a
fully deterministic, dependency-free layer that `DEFER`s irreversible or
approval-pending tool calls and lets everything else `EXECUTE`.

When the final posture is anything other than `EXECUTE`, `before_tool_callback`
returns a block dict that short-circuits the tool (per the ADK plugin contract):

```json
{
"error": "authority_blocked",
"posture": "DEFER",
"reason": "deterministic guard: irreversibility tripwire: 'delete'",
"scope": ""
}
```

## Usage

```python
from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk_community.plugins import (
AuthorityRequest,
AuthorityRoutingPlugin,
Posture,
PostureVerdict,
)

# Guard-only: no model, fully deterministic.
plugin = AuthorityRoutingPlugin()

# Or with a model-backed router:
def route(req: AuthorityRequest) -> PostureVerdict:
# ...call your model, parse a structured verdict...
return PostureVerdict(posture=Posture.EXECUTE, scope="update one row")

plugin = AuthorityRoutingPlugin(posture_router=route)

runner = Runner(
agent=Agent(name="my_agent", model="gemini-2.0-flash"),
plugins=[plugin],
app_name="my-app",
session_service=InMemorySessionService(),
)
```

The standing context the guard scans for approval markers is read from session
state under `authority_context` (configurable via `context_state_key`).

## Run the sample

```bash
python contributing/samples/authority_routing/main.py
```

Live output (no API key, no network — the demo router is a deterministic stub;
tool call logs go to stderr):

```
Runner created with authority-routing plugin enabled.

CASE TOOL POSTURE OUTCOME
------------------------------------------------------------------------------------------------
authorized read — router EXECUTE, guard clear web_search EXECUTE tool runs
advice requested — agent must NOT execute update_ticket ADVISE blocked — user asked for a recommendation, not an action
irreversible call — guard overrides EXECUTE delete_records DEFER blocked — deterministic guard: irreversibility tripwire: 'delete'
approval pending — guard floors to DEFER send_email DEFER blocked — deterministic guard: approval-state marker in context: 'awaiting approval'
disallowed — STOP survives strictest-wins read_vault STOP blocked — blocked by standing policy
```

Row 3 is the key case: the router authorized `EXECUTE`, but the tool call is
irreversible, so the deterministic guard floored it to `DEFER`. Row 5 shows the
other direction — the router's `STOP` survives even though the guard raised no
concern of its own.

## Configuration

| Argument | Default | Purpose |
| -------------------------- | -------------------- | -------------------------------------------------- |
| `posture_router` | `None` (guard-only) | Callable classifying an `AuthorityRequest`. |
| `context_state_key` | `"authority_context"`| Session-state key scanned for approval markers. |
| `irreversible_keywords` | built-in list | Override the tripwire keywords. |
| `pending_approval_markers` | built-in list | Override the approval-state markers. |
| `fail_closed` | `True` | On bad router output: `True` → DEFER, `False` → ignore router. |

## Relationship to other governance plugins

This plugin is complementary to `AgentGovernancePlugin` (policy-as-code
allow/deny) and the HITL approval gateway. Those answer *"is this tool call
permitted by policy?"*; this one answers *"was the agent authorized to act at
all, and if so, within what scope?"* — and it degrades to a useful deterministic
default with no external dependency and no model.

## Provenance

The four-posture pattern, the most-restrictive-wins composition, and the
tripwire come from a live 24/7 multi-agent system and were first published as an
Anthropic cookbook notebook. Design and validation are the author's;
implementation was drafted with AI assistance, then reviewed and tested by hand.

## Links

- [ADK Plugin docs](https://google.github.io/adk-docs/plugins/)
167 changes: 167 additions & 0 deletions contributing/samples/authority_routing/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Example: Google ADK agent with authority-posture routing.

Demonstrates:
1. Wiring AuthorityRoutingPlugin into an ADK Runner.
2. How the deterministic guard (irreversibility tripwire + approval-state
check) alone, with no model, already blocks unauthorized tool calls.
3. How an optional posture router composes with the guard under
most-restrictive-wins.

Run it directly — it needs no API key and no network:

python contributing/samples/authority_routing/main.py
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass

from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService

from google.adk_community.plugins import AuthorityRequest
from google.adk_community.plugins import AuthorityRoutingPlugin
from google.adk_community.plugins import Posture
from google.adk_community.plugins import PostureVerdict


def demo_router(request: AuthorityRequest) -> PostureVerdict:
"""A tiny illustrative router (stands in for a model call).

Real deployments pass the request to an LLM and parse a structured verdict.
Here we key off markers the caller left in context so the demo is fully
deterministic:

* ``policy: denied`` -> STOP (the work is disallowed)
* ``wants: advice`` -> ADVISE (recommendation only, no side effects)
* otherwise -> EXECUTE (authorized to act)
"""
context = request.context.lower()
if "policy: denied" in context:
return PostureVerdict(
posture=Posture.STOP, rationale="blocked by standing policy"
)
if "wants: advice" in context:
return PostureVerdict(
posture=Posture.ADVISE,
rationale="user asked for a recommendation, not an action",
)
return PostureVerdict(
posture=Posture.EXECUTE, scope=f"call {request.action} as requested"
)


def create_governed_runner() -> Runner:
"""Create an ADK runner whose tool calls pass through authority routing."""
plugin = AuthorityRoutingPlugin(posture_router=demo_router)

agent = Agent(
name="authority_routed_agent",
model="gemini-2.0-flash",
instruction="You are an assistant with an authorization layer.",
)

return Runner(
agent=agent,
plugins=[plugin],
app_name="authority-routing-demo",
session_service=InMemorySessionService(),
)


# ---------------------------------------------------------------------------
# A live walkthrough of the decision layer (no model, no network).
# ---------------------------------------------------------------------------


@dataclass
class _Tool:
name: str


class _Ctx:

def __init__(self, context: str = ""):
self.state = {"authority_context": context}


@dataclass
class _Case:
label: str
tool: str
args: dict
context: str


CASES = [
_Case(
"authorized read — router EXECUTE, guard clear",
"web_search",
{"query": "weather in Paris tomorrow"},
context="wants: action",
),
_Case(
"advice requested — agent must NOT execute",
"update_ticket",
{"id": 42, "status": "closed"},
context="wants: advice on whether to close ticket 42",
),
_Case(
"irreversible call — guard overrides EXECUTE",
"delete_records",
{"table": "customers", "where": "all"},
context="wants: action",
),
_Case(
"approval pending — guard floors to DEFER",
"send_email",
{"to": "vip@corp.com", "body": "the numbers"},
context="wants: action; note: awaiting approval from finance",
),
_Case(
"disallowed — STOP survives strictest-wins",
"read_vault",
{"path": "prod credential store"},
context="wants: action; policy: denied for credential stores",
),
]


async def walkthrough() -> None:
plugin = AuthorityRoutingPlugin(posture_router=demo_router)
print(f"{'CASE':<46}{'TOOL':<17}{'POSTURE':<9}OUTCOME")
print("-" * 96)
for case in CASES:
decision = await plugin.before_tool_callback(
tool=_Tool(case.tool),
tool_args=case.args,
tool_context=_Ctx(case.context),
)
if decision is None:
posture, outcome = "EXECUTE", "tool runs"
else:
posture = decision["posture"]
outcome = f"blocked — {decision['reason']}"
print(f"{case.label:<46}{case.tool:<17}{posture:<9}{outcome}")


if __name__ == "__main__":
runner = create_governed_runner()
print("Runner created with authority-routing plugin enabled.\n")
asyncio.run(walkthrough())
30 changes: 18 additions & 12 deletions src/google/adk_community/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,27 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from google.adk_community.plugins.agent_governance_plugin import (
AgentGovernancePlugin,
)
from google.adk_community.plugins.taxonomy import (
DefaultSkillPolicy,
SkillPolicy,
TaxonomyPipeline,
TaxonomyPlugin,
TaxonomyRegistry,
TaxonomyResolver,
TaxonomyTerm,
)
from google.adk_community.plugins.agent_governance_plugin import AgentGovernancePlugin
from google.adk_community.plugins.authority_routing_plugin import AuthorityRequest
from google.adk_community.plugins.authority_routing_plugin import AuthorityRoutingPlugin
from google.adk_community.plugins.authority_routing_plugin import Posture
from google.adk_community.plugins.authority_routing_plugin import PostureVerdict
from google.adk_community.plugins.authority_routing_plugin import stricter
from google.adk_community.plugins.taxonomy import DefaultSkillPolicy
from google.adk_community.plugins.taxonomy import SkillPolicy
from google.adk_community.plugins.taxonomy import TaxonomyPipeline
from google.adk_community.plugins.taxonomy import TaxonomyPlugin
from google.adk_community.plugins.taxonomy import TaxonomyRegistry
from google.adk_community.plugins.taxonomy import TaxonomyResolver
from google.adk_community.plugins.taxonomy import TaxonomyTerm

__all__ = [
"AgentGovernancePlugin",
"AuthorityRequest",
"AuthorityRoutingPlugin",
"Posture",
"PostureVerdict",
"stricter",
"DefaultSkillPolicy",
"SkillPolicy",
"TaxonomyPipeline",
Expand Down
Loading