From e5b0ae1a0f842b7ca1f2308aaf0a0b141fc92c42 Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Fri, 7 Aug 2026 10:11:06 -0400 Subject: [PATCH 1/5] feat: add TulipGovernancePlugin for tool-call governance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ADK plugin that gates tool calls through Tulip's admission control (https://tulipagents.ai) — a ControlPolicy check in real code, outside the model, run before each tool call. Sits alongside AgentGovernancePlugin as a second governance option, registered once at the Runner level rather than wrapped per tool. Unlike a binary allow/deny check, a require_human decision is not collapsed into either outcome — it short-circuits the call with a distinct held_for_approval response, separate from policy_denied. Every decision (allow, require_human, or deny) is appended to a SHA-256 hash-chained AuditTrail regardless of outcome. Tested against the real tulip-agents package (pip install tulip-agents), not a mock — the admission-gate primitives it uses (Action, ControlPolicy, approve(), AuditTrail) are pure dataclass logic with no LLM or network dependency. Scope: a require_human decision does not pause the run and wait for a resume — wiring that through this repo's own tools/hitl gateway, or Tulip's approval bridge, is left to a follow-up. --- pyproject.toml | 4 + src/google/adk_community/plugins/__init__.py | 4 + .../plugins/tulip_governance_plugin.py | 208 ++++++++++++++++ tests/plugins/test_tulip_governance_plugin.py | 226 ++++++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 src/google/adk_community/plugins/tulip_governance_plugin.py create mode 100644 tests/plugins/test_tulip_governance_plugin.py diff --git a/pyproject.toml b/pyproject.toml index a03bdca..304b577 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,9 +42,13 @@ documentation = "https://google.github.io/adk-docs/" s3 = [ "aioboto3>=13.0.0", # For S3ArtifactService ] +tulip = [ + "tulip-agents>=2.3,<3", # For TulipGovernancePlugin +] test = [ "pytest>=8.4.2", "pytest-asyncio>=1.2.0", + "tulip-agents>=2.3,<3", # TulipGovernancePlugin's tests run against the real package ] sdc-agents = [ "sdc-agents>=4.3.3; python_version >= '3.11'", diff --git a/src/google/adk_community/plugins/__init__.py b/src/google/adk_community/plugins/__init__.py index 2e4b2ee..491f6b0 100644 --- a/src/google/adk_community/plugins/__init__.py +++ b/src/google/adk_community/plugins/__init__.py @@ -24,6 +24,9 @@ TaxonomyResolver, TaxonomyTerm, ) +from google.adk_community.plugins.tulip_governance_plugin import ( + TulipGovernancePlugin, +) __all__ = [ "AgentGovernancePlugin", @@ -34,4 +37,5 @@ "TaxonomyRegistry", "TaxonomyResolver", "TaxonomyTerm", + "TulipGovernancePlugin", ] diff --git a/src/google/adk_community/plugins/tulip_governance_plugin.py b/src/google/adk_community/plugins/tulip_governance_plugin.py new file mode 100644 index 0000000..3f81dea --- /dev/null +++ b/src/google/adk_community/plugins/tulip_governance_plugin.py @@ -0,0 +1,208 @@ +# 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. + +"""ADK plugin for Tulip admission-gate policy enforcement. + +Evaluates a Tulip ``ControlPolicy`` before tool execution using Tulip's +admission gate (https://tulipagents.ai) — a policy check in real code, +outside the model, between a tool call and its side effect. Every decision +is appended to a tamper-evident, hash-chained ``AuditTrail`` regardless of +outcome, whether the call proceeds or not. + +Unlike a binary allow/deny check, Tulip's policy has three outcomes: allow, +require a human, or deny. This plugin does not collapse ``require_human`` +into either of the other two — it short-circuits the call with a distinct +``held_for_approval`` response, separate from ``policy_denied``, so the +caller can tell "this needs a person" apart from "this is refused" and +route accordingly. See the ``Scope`` note on +:class:`TulipGovernancePlugin` for what that does and does not do today. + +Note on ``ControlPolicy``: ``require_verification_score`` defaults to 0.8 +in Tulip, and ``approve()`` treats an action with no ``VerificationResult`` +as failing that bar — "no verification provided" — which escalates to at +least ``require_human``. This plugin has no fact/evidence step to produce a +``VerificationResult`` for an arbitrary ADK tool call, so it never passes +one. A policy built for this plugin should generally set +``require_verification_score=0.0`` and drive allow/hold/deny purely off +``deny_for``/``require_human_for``/``max_blast_radius`` — otherwise every +call is held for a human by default, verification bar included or not. + +Requires: ``pip install tulip-agents`` +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Optional + +from google.adk.plugins.base_plugin import BasePlugin +from google.adk.tools.base_tool import BaseTool +from google.adk.tools.tool_context import ToolContext + +logger = logging.getLogger(__name__) + + +class _TulipUnavailableError(ImportError): + """Raised when tulip-agents is not installed and fail_open=False.""" + + +class TulipGovernancePlugin(BasePlugin): + """ADK plugin that gates tool calls behind Tulip's admission control. + + For every tool call, builds a Tulip ``Action`` describing its risk and + weighs it against a ``ControlPolicy`` with Tulip's ``approve()`` — a + policy check in real code, outside the model. The decision (allow / + require a human / deny) is appended to a SHA-256 hash-chained + ``AuditTrail`` either way, so a denied or held call is recorded exactly + as durably as one that proceeded. + + Args: + policy: The ``tulip.security.policy.ControlPolicy`` to enforce. + action: Builds a ``tulip.control.Action`` describing a tool call's + risk from the tool and its arguments — name, asset, blast + radius, environment, kind, tags. Must be provided explicitly; + there is no way to infer an action's blast radius or + environment from a bare tool name. + trail: An ``AuditTrail`` decisions are appended to. A fresh one is + created if omitted; hand in a shared instance to accumulate + decisions across plugins, tools, or runs. Available afterward + as ``plugin.trail``. + principal: Identifies the caller in each audit record. + fail_open: If ``True``, tool calls proceed when ``tulip-agents`` is + not installed (logs a warning). If ``False`` (default), raises + ``ImportError`` at construction time. + + Raises: + ImportError: If ``tulip-agents`` is not installed and ``fail_open`` + is False. + + Scope: + A ``require_human`` decision short-circuits the call with a + ``held_for_approval`` response — it does not pause the run and wait + for a person to decide, then let the original call proceed. Wiring + that resume path (e.g. through this repository's own + ``tools/hitl`` gateway, or Tulip's own approval bridge) is left to + a follow-up; today "held" means "not this turn," and the caller + (or a human, out of band) decides what happens next. + + Example:: + + from google.adk_community.plugins import TulipGovernancePlugin + from tulip.control import Action + from tulip.security.policy import ControlPolicy + + plugin = TulipGovernancePlugin( + policy=ControlPolicy( + deny_for={"irreversible"}, + require_human_for={"production", "payment"}, + ), + action=lambda tool, args: Action( + name=tool.name, + asset=str(args.get("order_id", "")), + kind="payment", + environment="production", + ), + ) + runner = Runner(agent=my_agent, plugins=[plugin], ...) + """ + + def __init__( + self, + *, + policy: Any, + action: Callable[[BaseTool, dict[str, Any]], Any], + trail: Any | None = None, + principal: str = "agent", + fail_open: bool = False, + ) -> None: + super().__init__(name="tulip_governance") + self._policy = policy + self._build_action = action + self._principal = principal + self._approve = None + self.trail = trail + self._setup(fail_open=fail_open) + + def _setup(self, *, fail_open: bool) -> None: + """Import tulip-agents and set up the trail. Lazy: this module must + import cleanly without tulip-agents installed.""" + try: + from tulip.security.audit import AuditTrail + from tulip.security.policy import approve + + self._approve = approve + if self.trail is None: + self.trail = AuditTrail() + logger.info("TulipGovernancePlugin initialized") + except ImportError: + if not fail_open: + raise _TulipUnavailableError( + "tulip-agents is required for governance enforcement. " + "Install with: pip install tulip-agents" + ) + logger.warning( + "tulip-agents not installed; governance checks disabled. " + "Install with: pip install tulip-agents" + ) + + async def before_tool_callback( + self, + *, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext, + ) -> Optional[dict]: + """Evaluate the Tulip policy before a tool call. + + Returns ``None`` to allow the tool to proceed, or a dict response to + short-circuit execution when the policy denies the call or holds it + for a human. + """ + if self._approve is None: + return None + + action = self._build_action(tool, tool_args) + decision = self._approve(action, policy=self._policy) + + if self.trail is not None: + self.trail.record( + "tool_admission", + { + "principal": self._principal, + "tool": tool.name, + "action": action.name, + "asset": action.asset, + "outcome": decision.outcome, + "reason": decision.reason, + }, + ) + + if decision.outcome == "deny": + logger.warning( + "Tulip policy denied tool '%s': %s", tool.name, decision.reason + ) + return {"error": "policy_denied", "reason": decision.reason} + + if decision.outcome == "require_human": + logger.info( + "Tulip policy held tool '%s' for a human: %s", + tool.name, + decision.reason, + ) + return {"error": "held_for_approval", "reason": decision.reason} + + return None + + +__all__ = ["TulipGovernancePlugin"] diff --git a/tests/plugins/test_tulip_governance_plugin.py b/tests/plugins/test_tulip_governance_plugin.py new file mode 100644 index 0000000..ac80c41 --- /dev/null +++ b/tests/plugins/test_tulip_governance_plugin.py @@ -0,0 +1,226 @@ +# 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. + +"""Tests for TulipGovernancePlugin. + +Unlike AgentGovernancePlugin's tests, these run against the real +``tulip-agents`` package (``pip install tulip-agents``, or +``pip install .[tulip]``/``.[test]`` in this repo) rather than a fake — +``tulip.control``/``tulip.security.policy``/``tulip.security.audit`` are +pure dataclass logic with no LLM or network dependency, so there is nothing +to mock. The "not installed" path is exercised separately by removing the +real modules from ``sys.modules`` for the duration of one test. +""" + +from __future__ import annotations + +import sys +from typing import Any +from unittest.mock import patch + +import pytest + +tulip = pytest.importorskip("tulip", reason="tulip-agents is not installed") + +from google.adk_community.plugins.tulip_governance_plugin import ( # noqa: E402 + TulipGovernancePlugin, +) +from tulip.control import Action # noqa: E402 +from tulip.security.audit import AuditTrail # noqa: E402 +from tulip.security.policy import ControlPolicy # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fake ADK types for testing (same pattern as test_agent_governance_plugin.py) +# --------------------------------------------------------------------------- + + +class _FakeBaseTool: + + def __init__(self, name: str): + self.name = name + + +class _FakeToolContext: + pass + + +def _action_from(tool: Any, args: dict) -> Action: + """A simple action builder: production/payment tools carry those labels, + everything else is a plain read.""" + if tool.name in ("refund", "shell_exec", "file_delete"): + return Action( + name=tool.name, + asset=str(args.get("order_id", args.get("path", ""))), + kind="payment" if tool.name == "refund" else "exec", + environment="production", + ) + return Action(name=tool.name, environment="staging") + + +@pytest.fixture() +def policy() -> ControlPolicy: + # require_verification_score=0.0: this plugin has no VerificationResult + # to offer approve() (there's no fact/evidence step for an arbitrary ADK + # tool call), and ControlPolicy's own default (0.8) would otherwise push + # every call to at least require_human for lack of one. See the "Note on + # ControlPolicy" section of the plugin's module docstring. + return ControlPolicy( + require_verification_score=0.0, + deny_for=frozenset({"exec"}), + require_human_for=frozenset({"production", "payment"}), + ) + + +class TestTulipGovernancePlugin: + """Tests for TulipGovernancePlugin construction and behavior.""" + + def test_plugin_name(self, policy: ControlPolicy): + plugin = TulipGovernancePlugin(policy=policy, action=_action_from) + assert plugin.name == "tulip_governance" + + def test_creates_a_trail_when_none_given(self, policy: ControlPolicy): + plugin = TulipGovernancePlugin(policy=policy, action=_action_from) + assert isinstance(plugin.trail, AuditTrail) + assert len(plugin.trail) == 0 + + def test_uses_the_shared_trail_when_given(self, policy: ControlPolicy): + trail = AuditTrail() + plugin = TulipGovernancePlugin( + policy=policy, action=_action_from, trail=trail + ) + assert plugin.trail is trail + + @pytest.mark.asyncio + async def test_allows_safe_tool_call(self, policy: ControlPolicy): + plugin = TulipGovernancePlugin(policy=policy, action=_action_from) + + result = await plugin.before_tool_callback( + tool=_FakeBaseTool("web_search"), + tool_args={"query": "test"}, + tool_context=_FakeToolContext(), + ) + assert result is None + + @pytest.mark.asyncio + async def test_denies_a_hard_denied_tool_call(self, policy: ControlPolicy): + plugin = TulipGovernancePlugin(policy=policy, action=_action_from) + + result = await plugin.before_tool_callback( + tool=_FakeBaseTool("shell_exec"), + tool_args={"cmd": "rm -rf /"}, + tool_context=_FakeToolContext(), + ) + assert result is not None + assert result["error"] == "policy_denied" + assert "exec" in result["reason"] + + @pytest.mark.asyncio + async def test_holds_a_production_call_for_a_human( + self, policy: ControlPolicy + ): + """require_human is NOT collapsed into policy_denied — a distinct + response, so a caller can route the two differently.""" + plugin = TulipGovernancePlugin(policy=policy, action=_action_from) + + result = await plugin.before_tool_callback( + tool=_FakeBaseTool("refund"), + tool_args={"order_id": "ord-9"}, + tool_context=_FakeToolContext(), + ) + assert result is not None + assert result["error"] == "held_for_approval" + assert "production" in result["reason"] or "payment" in result["reason"] + + @pytest.mark.asyncio + async def test_audit_trail_records_every_decision_allow_and_deny( + self, policy: ControlPolicy + ): + trail = AuditTrail() + plugin = TulipGovernancePlugin( + policy=policy, action=_action_from, trail=trail + ) + + await plugin.before_tool_callback( + tool=_FakeBaseTool("web_search"), + tool_args={}, + tool_context=_FakeToolContext(), + ) + await plugin.before_tool_callback( + tool=_FakeBaseTool("shell_exec"), + tool_args={"cmd": "rm -rf /"}, + tool_context=_FakeToolContext(), + ) + await plugin.before_tool_callback( + tool=_FakeBaseTool("refund"), + tool_args={"order_id": "ord-9"}, + tool_context=_FakeToolContext(), + ) + + assert len(trail) == 3 + records = trail.records() + assert records[0].payload["tool"] == "web_search" + assert records[0].payload["outcome"] == "allow" + assert records[1].payload["tool"] == "shell_exec" + assert records[1].payload["outcome"] == "deny" + assert records[2].payload["tool"] == "refund" + assert records[2].payload["outcome"] == "require_human" + # Tamper-evident: the chain verifies intact, and breaks if a record + # is edited after the fact. + assert trail.verify() is True + object.__setattr__(records[1], "payload", {"tool": "tampered"}) + tampered_trail = AuditTrail.from_records(records) + assert tampered_trail.verify() is False + + def test_raises_import_error_when_tulip_missing_and_not_fail_open( + self, policy: ControlPolicy + ): + removed = { + k: v + for k, v in sys.modules.items() + if k == "tulip" or k.startswith("tulip.") + } + for key in removed: + del sys.modules[key] + try: + with patch.dict(sys.modules, {"tulip": None, "tulip.control": None}): + with pytest.raises(ImportError, match="tulip-agents"): + TulipGovernancePlugin(policy=policy, action=_action_from) + finally: + sys.modules.update(removed) + + @pytest.mark.asyncio + async def test_fail_open_allows_all_when_tulip_missing( + self, policy: ControlPolicy + ): + removed = { + k: v + for k, v in sys.modules.items() + if k == "tulip" or k.startswith("tulip.") + } + for key in removed: + del sys.modules[key] + try: + with patch.dict(sys.modules, {"tulip": None, "tulip.control": None}): + plugin = TulipGovernancePlugin( + policy=policy, action=_action_from, fail_open=True + ) + result = await plugin.before_tool_callback( + tool=_FakeBaseTool("shell_exec"), + tool_args={"cmd": "rm -rf /"}, + tool_context=_FakeToolContext(), + ) + assert result is None + finally: + sys.modules.update(removed) From 78405e18bbf6dec2111e8406f846114d3b9b4d9c Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Fri, 7 Aug 2026 10:32:28 -0400 Subject: [PATCH 2/5] test: add real-Runner e2e coverage for TulipGovernancePlugin Adds tests/plugins/test_tulip_governance_plugin_e2e.py: builds a real LlmAgent + tool + InMemoryRunner (not calling before_tool_callback by hand) and proves, through ADK's actual PluginManager/functions.py dispatch, that: - a denied call's tool body never runs (observable side effect stays unset), and the run continues normally with the denial as the tool result the model sees - an allowed call's tool body genuinely executes - require_human also prevents execution, distinctly from deny - two tool calls in one turn are gated independently - a raising action-builder fails closed (the run aborts; ADK wraps it in RuntimeError, confirmed rather than assumed) rather than silently degrading to allow - concurrent before_tool_callback calls don't corrupt the audit chain's sequence numbers Also, from probing for the above: - removed a dead branch in before_tool_callback (self.trail is never None at that point once self._approve is set -- _setup() guarantees both together) -- caught by branch coverage, now 100% line and branch, not just line - dropped the docstring's Runner(plugins=...) example: it's the deprecated construction path, confirmed by the deprecation warning the e2e tests triggered before the fix. Replaced with App(plugins=...) + Runner(app=...). --- .../plugins/tulip_governance_plugin.py | 36 +- .../test_tulip_governance_plugin_e2e.py | 412 ++++++++++++++++++ 2 files changed, 435 insertions(+), 13 deletions(-) create mode 100644 tests/plugins/test_tulip_governance_plugin_e2e.py diff --git a/src/google/adk_community/plugins/tulip_governance_plugin.py b/src/google/adk_community/plugins/tulip_governance_plugin.py index 3f81dea..39686d5 100644 --- a/src/google/adk_community/plugins/tulip_governance_plugin.py +++ b/src/google/adk_community/plugins/tulip_governance_plugin.py @@ -98,12 +98,17 @@ class TulipGovernancePlugin(BasePlugin): Example:: + from google.adk.apps.app import App + from google.adk.runners import Runner from google.adk_community.plugins import TulipGovernancePlugin from tulip.control import Action from tulip.security.policy import ControlPolicy plugin = TulipGovernancePlugin( + # require_verification_score=0.0: see the "Note on ControlPolicy" + # in this module's docstring before changing this. policy=ControlPolicy( + require_verification_score=0.0, deny_for={"irreversible"}, require_human_for={"production", "payment"}, ), @@ -114,7 +119,10 @@ class TulipGovernancePlugin(BasePlugin): environment="production", ), ) - runner = Runner(agent=my_agent, plugins=[plugin], ...) + # plugins go on the App, not on Runner directly — Runner(plugins=...) + # is deprecated. + app = App(name="my_app", root_agent=my_agent, plugins=[plugin]) + runner = Runner(app=app, ...) """ def __init__( @@ -175,18 +183,20 @@ async def before_tool_callback( action = self._build_action(tool, tool_args) decision = self._approve(action, policy=self._policy) - if self.trail is not None: - self.trail.record( - "tool_admission", - { - "principal": self._principal, - "tool": tool.name, - "action": action.name, - "asset": action.asset, - "outcome": decision.outcome, - "reason": decision.reason, - }, - ) + # self.trail is always set by this point: _setup() only leaves + # self._approve non-None (the guard above) after also setting self.trail, + # so there is no reachable state here where it is still None. + self.trail.record( + "tool_admission", + { + "principal": self._principal, + "tool": tool.name, + "action": action.name, + "asset": action.asset, + "outcome": decision.outcome, + "reason": decision.reason, + }, + ) if decision.outcome == "deny": logger.warning( diff --git a/tests/plugins/test_tulip_governance_plugin_e2e.py b/tests/plugins/test_tulip_governance_plugin_e2e.py new file mode 100644 index 0000000..2960286 --- /dev/null +++ b/tests/plugins/test_tulip_governance_plugin_e2e.py @@ -0,0 +1,412 @@ +# 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. + +"""End-to-end tests for TulipGovernancePlugin — through the real ADK +dispatch path, not by calling the plugin's method directly. + +test_tulip_governance_plugin.py calls ``plugin.before_tool_callback(...)`` +by hand. These tests instead build a real ``LlmAgent`` with a real tool +function, run it through the real ``InMemoryRunner``/``PluginManager``, and +check whether the tool's Python body actually executed — proving the +choke point exists at the framework level, not just in this plugin's own +code. Read from ``google/adk-python``'s +``src/google/adk/flows/llm_flows/functions.py`` (``_run_with_trace``), +confirmed by line: the real tool call (``__call_tool_async``, Step 3) is +gated behind ``if function_response is None`` — Step 1 populates +``function_response`` from ``plugin_manager.run_before_tool_callback``, and +when a plugin returns a non-``None`` dict there, Step 3 never runs. This is +the actual mechanism, not an assumption about what "short-circuit" means. + +``_MockModel`` narrowly reproduces the one relevant piece of +``google/adk-python``'s own ``tests/unittests/testing_utils.py::MockModel`` +(same project, Apache-2.0) — a scripted, turn-by-turn fake ``BaseLlm`` — as +that module is test-only and not part of the installable ``google-adk`` +package. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from typing import AsyncGenerator + +import pytest + +tulip = pytest.importorskip("tulip", reason="tulip-agents is not installed") + +from google.adk.agents.llm_agent import LlmAgent # noqa: E402 +from google.adk.apps.app import App # noqa: E402 +from google.adk.models.base_llm import BaseLlm # noqa: E402 +from google.adk.models.llm_request import LlmRequest # noqa: E402 +from google.adk.models.llm_response import LlmResponse # noqa: E402 +from google.adk.runners import InMemoryRunner # noqa: E402 +from google.genai import types # noqa: E402 +from typing_extensions import override # noqa: E402 + +from google.adk_community.plugins.tulip_governance_plugin import ( # noqa: E402 + TulipGovernancePlugin, +) +from tulip.control import Action # noqa: E402 +from tulip.security.audit import AuditTrail # noqa: E402 +from tulip.security.policy import ControlPolicy # noqa: E402 + + +class _MockModel(BaseLlm): + """A scripted BaseLlm: yields one canned LlmResponse per call, in order.""" + + model: str = "mock" + responses: list[LlmResponse] = [] + index: int = -1 + + @classmethod + def supported_models(cls) -> list[str]: + return ["mock"] + + @override + async def generate_content_async( + self, llm_request: LlmRequest, stream: bool = False + ) -> AsyncGenerator[LlmResponse, None]: + self.index += 1 + yield self.responses[self.index] + + +def _model_that_calls(tool_name: str, args: dict[str, Any]) -> _MockModel: + """Turn 1: request `tool_name(**args)`. Turn 2: a plain-text reply, so the + run completes normally whatever the tool call actually did.""" + return _MockModel( + responses=[ + LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part.from_function_call(name=tool_name, args=args) + ], + ) + ), + LlmResponse( + content=types.Content( + role="model", + parts=[types.Part.from_text(text="done")], + ) + ), + ] + ) + + +def _make_wipe_tool() -> tuple[Any, list[str]]: + """A tool with a real, observable side effect — the only reliable way to + tell "the plugin returned a dict" apart from "the tool actually ran".""" + calls: list[str] = [] + + def wipe_database() -> str: + """Wipes the production database. Irreversible.""" + calls.append("wiped") + return "database wiped" + + return wipe_database, calls + + +async def _run(agent: LlmAgent, plugin: TulipGovernancePlugin) -> list: + runner = InMemoryRunner( + app=App(name="test_app", root_agent=agent, plugins=[plugin]) + ) + session = await runner.session_service.create_session( + app_name="test_app", user_id="u1" + ) + events = [] + async for event in runner.run_async( + user_id="u1", + session_id=session.id, + new_message=types.Content( + role="user", parts=[types.Part.from_text(text="go")] + ), + ): + events.append(event) + return events + + +def _function_response_payload(events: list) -> dict[str, Any] | None: + """Pulls the dict the tool call actually resolved to, from the function + response event — i.e. what the *model* saw come back.""" + for event in events: + if not event.content or not event.content.parts: + continue + for part in event.content.parts: + if part.function_response: + return dict(part.function_response.response or {}) + return None + + +class TestTulipGovernancePluginE2E: + + @pytest.mark.asyncio + async def test_deny_prevents_the_tool_from_ever_running(self): + wipe_database, calls = _make_wipe_tool() + trail = AuditTrail() + policy = ControlPolicy( + require_verification_score=0.0, deny_for=frozenset({"irreversible"}) + ) + plugin = TulipGovernancePlugin( + policy=policy, + action=lambda tool, args: Action( + name=tool.name, kind="irreversible", environment="production" + ), + trail=trail, + ) + agent = LlmAgent( + name="test_agent", + model=_model_that_calls("wipe_database", {}), + tools=[wipe_database], + ) + + events = await _run(agent, plugin) + + # The strongest possible assertion: the tool's Python body never ran. + assert calls == [] + payload = _function_response_payload(events) + assert payload is not None + assert payload["error"] == "policy_denied" + # The run still completed (turn 2's "done" text is present) — a denial + # is a tool result the model sees, not a crash. + final_text = "".join( + part.text or "" + for event in events + if event.content + for part in event.content.parts or [] + if part.text + ) + assert "done" in final_text + # And the denial is on the tamper-evident trail. + assert len(trail) == 1 + assert trail.records()[0].payload["outcome"] == "deny" + assert trail.verify() is True + + @pytest.mark.asyncio + async def test_allow_lets_the_real_tool_run(self): + """The mirror image of the deny test: prove the plugin does not block + everything by default — an allowed call really executes.""" + wipe_database, calls = _make_wipe_tool() + policy = ControlPolicy(require_verification_score=0.0) + plugin = TulipGovernancePlugin( + policy=policy, + action=lambda tool, args: Action(tool.name, environment="staging"), + ) + agent = LlmAgent( + name="test_agent", + model=_model_that_calls("wipe_database", {}), + tools=[wipe_database], + ) + + events = await _run(agent, plugin) + + assert calls == ["wiped"] + payload = _function_response_payload(events) + assert payload is not None + assert payload.get("result") == "database wiped" + assert plugin.trail.records()[0].payload["outcome"] == "allow" + + @pytest.mark.asyncio + async def test_require_human_also_prevents_the_tool_from_running(self): + """require_human is a distinct outcome from deny, but it is just as + effective at stopping the call — held, not merely logged.""" + wipe_database, calls = _make_wipe_tool() + policy = ControlPolicy( + require_verification_score=0.0, + require_human_for=frozenset({"production"}), + ) + plugin = TulipGovernancePlugin( + policy=policy, + action=lambda tool, args: Action(tool.name, environment="production"), + ) + agent = LlmAgent( + name="test_agent", + model=_model_that_calls("wipe_database", {}), + tools=[wipe_database], + ) + + events = await _run(agent, plugin) + + assert calls == [] + payload = _function_response_payload(events) + assert payload is not None + assert payload["error"] == "held_for_approval" + assert plugin.trail.records()[0].payload["outcome"] == "require_human" + + @pytest.mark.asyncio + async def test_two_tools_in_one_turn_are_each_gated_independently(self): + """Adversarial: a single model turn requesting two calls back to back — + one that should be denied and one that should be allowed. Neither + decision may leak into the other.""" + + calls: list[str] = [] + + def safe_read() -> str: + calls.append("read") + return "ok" + + def dangerous_write() -> str: + calls.append("wrote") + return "ok" + + # require_human_for defaults to {"production"} (ControlPolicy's own + # conservative default) — safe_read must be staging, not production, or + # it too would be held for a human rather than allowed, which is a + # ControlPolicy default worth knowing, not a bug in this fixture. + policy = ControlPolicy( + require_verification_score=0.0, deny_for=frozenset({"irreversible"}) + ) + + def build_action(tool: Any, args: dict) -> Action: + if tool.name == "dangerous_write": + return Action( + name=tool.name, kind="irreversible", environment="production" + ) + return Action(name=tool.name, environment="staging") + + trail = AuditTrail() + plugin = TulipGovernancePlugin( + policy=policy, action=build_action, trail=trail + ) + + # Explicit, distinct ids: a real model response always carries one per + # call so its function_response can be correlated back; two calls with + # the same id=None silently collapsed to a single dispatched call in an + # earlier version of this test (both bodies below never ran, catching + # the fixture bug rather than a plugin bug) — the ids matter to the + # test, not just to realism. + model = _MockModel( + responses=[ + LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + id="call_read", name="safe_read", args={} + ) + ), + types.Part( + function_call=types.FunctionCall( + id="call_write", name="dangerous_write", args={} + ) + ), + ], + ) + ), + LlmResponse( + content=types.Content( + role="model", parts=[types.Part.from_text(text="done")] + ) + ), + ] + ) + agent = LlmAgent( + name="test_agent", model=model, tools=[safe_read, dangerous_write] + ) + + await _run(agent, plugin) + + assert calls == ["read"] # safe_read ran; dangerous_write did not + outcomes = { + r.payload["tool"]: r.payload["outcome"] for r in trail.records() + } + assert outcomes == {"safe_read": "allow", "dangerous_write": "deny"} + + @pytest.mark.asyncio + async def test_a_raising_action_builder_fails_closed_not_open(self): + """Adversarial: what happens when the caller's own action-builder + throws (e.g. a KeyError reading an unexpected arg shape)? It must not + silently degrade to "allow" — and it does not: the exception propagates + out of before_tool_callback, so ADK never reaches Step 3 + (__call_tool_async) at all. ADK's own PluginManager wraps whatever a + plugin raises in a RuntimeError (google/adk/plugins/plugin_manager.py, + ``_run_callbacks``) rather than letting the original exception type + escape — confirmed here rather than assumed, since it changes what a + caller wrapping ``runner.run_async()`` needs to catch. Fail-closed but + not graceful: an ordinary run crashes rather than degrading to a + decision.""" + wipe_database, calls = _make_wipe_tool() + + def broken_action(tool: Any, args: dict) -> Action: + raise KeyError("order_id") + + plugin = TulipGovernancePlugin( + policy=ControlPolicy(require_verification_score=0.0), + action=broken_action, + ) + agent = LlmAgent( + name="test_agent", + model=_model_that_calls("wipe_database", {}), + tools=[wipe_database], + ) + runner = InMemoryRunner( + app=App(name="test_app", root_agent=agent, plugins=[plugin]) + ) + session = await runner.session_service.create_session( + app_name="test_app", user_id="u1" + ) + + with pytest.raises(RuntimeError, match="tulip_governance") as excinfo: + async for _event in runner.run_async( + user_id="u1", + session_id=session.id, + new_message=types.Content( + role="user", parts=[types.Part.from_text(text="go")] + ), + ): + pass + assert isinstance(excinfo.value.__cause__, KeyError) + + assert calls == [] # the tool never ran, even though the run crashed + + @pytest.mark.asyncio + async def test_concurrent_tool_calls_do_not_corrupt_the_audit_chain(self): + """Adversarial: N before_tool_callback invocations racing under + asyncio.gather (not a real ADK path — before_tool_callback calls are + serialized per turn in functions.py — but the plugin's own contract + should not silently rely on that). AuditTrail.record() has no internal + await point, so it cannot be interleaved by the cooperative scheduler; + this proves that invariant holds under real concurrent pressure rather + than asserting it from reading the source.""" + trail = AuditTrail() + policy = ControlPolicy(require_verification_score=0.0) + plugin = TulipGovernancePlugin( + policy=policy, + action=lambda tool, args: Action(tool.name, environment="staging"), + trail=trail, + ) + + class _Tool: + + def __init__(self, name: str): + self.name = name + + class _Ctx: + pass + + n = 50 + await asyncio.gather(*( + plugin.before_tool_callback( + tool=_Tool(f"tool_{i}"), tool_args={}, tool_context=_Ctx() + ) + for i in range(n) + )) + + assert len(trail) == n + seqs = [r.seq for r in trail.records()] + assert seqs == list(range(n)) # no duplicate/skipped seq under races + assert trail.verify() is True + assert {r.payload["tool"] for r in trail.records()} == { + f"tool_{i}" for i in range(n) + } From 6dac8c0e1de84387ae456c9fc4a4bca85d4a715f Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Fri, 7 Aug 2026 15:10:49 -0400 Subject: [PATCH 3/5] docs: add scope note on content-vs-action risk + the fail-safe-default lesson Backed by a real, independent-dataset measurement, not asserted: ran a reasonable action callback against 1,476 real, human-labeled rows (Atarogic/ASSEBench, Apache-2.0) through the actual approve() decision engine. 88.7% recall on real labeled risks; every miss traced to either a fixable heuristic gap or content-only risk this hook structurally cannot see (before_tool_callback never sees model text, only tool calls). Also documents a real mistake made while tightening the callback: an attempt to reduce false positives by classifying inside a wrapper response, instead of holding it unconditionally, dropped recall from 91.3% to 23.6% by defaulting ambiguous cases to allow. Kept in the docstring rather than only the gist, because it's the load-bearing lesson for anyone writing their own action callback: default ambiguity to require_human, never to allow. Full methodology: https://gist.github.com/fede-kamel/2ece9704d15978b9eb45e8c7dde5e8bc Signed-off-by: fede-kamel --- .../plugins/tulip_governance_plugin.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/google/adk_community/plugins/tulip_governance_plugin.py b/src/google/adk_community/plugins/tulip_governance_plugin.py index 39686d5..823d21b 100644 --- a/src/google/adk_community/plugins/tulip_governance_plugin.py +++ b/src/google/adk_community/plugins/tulip_governance_plugin.py @@ -96,6 +96,27 @@ class TulipGovernancePlugin(BasePlugin): a follow-up; today "held" means "not this turn," and the caller (or a human, out of band) decides what happens next. + This plugin only ever sees tool calls — ``before_tool_callback`` + has no visibility into what the model *says* in a plain-text + response. Harmful or misleading conversational content (no tool + call involved) is structurally outside what any admission gate + built on this hook can catch, regardless of how the ``action`` + callback is written. Measured directly against a real, independent, + human-labeled benchmark (1,476 rows, Atarogic/ASSEBench, + Apache-2.0): a reasonable ``action`` callback caught 88.7% of real + labeled risks, and every miss traced to one of two causes — a gap + in the callback's own risk heuristic (fixable by widening it), or a + risk expressed as conversational text rather than a tool call (not + fixable from inside this hook at all). See + https://gist.github.com/fede-kamel/2ece9704d15978b9eb45e8c7dde5e8bc + for the full methodology, including a first attempt at tightening + the callback that *reduced* recall from 91.3% to 23.6% by defaulting + ambiguous cases to allow instead of hold — kept in the writeup + rather than erased, because it's the actual lesson: an ``action`` + callback for this plugin must default an ambiguous case to + ``require_human``, never to allow. A gate that fails open on + uncertainty is worse than no gate. + Example:: from google.adk.apps.app import App From 4041f84d06ee9a649f7de62613ee157f630354a1 Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Fri, 7 Aug 2026 15:42:55 -0400 Subject: [PATCH 4/5] test: real, live-model E2E coverage across two independent providers Every prior E2E test drives the agent with a scripted fake model -- deliberately, for deterministic no-API-key CI. That left one real question unanswered: does this work when a real model decides on its own whether to call a tool, not when told to via a canned function-call part. Parametrized across Together (Llama-3.3-70B-Instruct-Turbo) and OpenAI (gpt-4o-mini) rather than one provider, so a single provider's tool-calling quirks can't coincidentally match what the plugin expects. Both independently decided, unscripted, to call a tool their policy denies (asked in plain English to wipe a production database) and were correctly stopped before the tool body ran; both correctly executed a real, benign tool call the policy allows. 4/4 passed live against both providers. Skips cleanly per-provider without its API key -- confirmed the default suite (no keys set) still shows 40 passed, 4 skipped, not 44 passed or any failure. Signed-off-by: fede-kamel --- ...test_tulip_governance_plugin_live_model.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/plugins/test_tulip_governance_plugin_live_model.py diff --git a/tests/plugins/test_tulip_governance_plugin_live_model.py b/tests/plugins/test_tulip_governance_plugin_live_model.py new file mode 100644 index 0000000..91b1d38 --- /dev/null +++ b/tests/plugins/test_tulip_governance_plugin_live_model.py @@ -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. + +"""Live-model tests for TulipGovernancePlugin, across more than one provider. + +Every other test in this plugin's suite drives the agent with a scripted +fake model (``_MockModel`` in the *_e2e.py file) -- deliberately, for +deterministic, no-API-key CI. That leaves one real question unanswered: +does this actually work when a real model decides on its own whether to +call a tool, rather than being told to via a canned function-call part? + +Parametrized across two independent, real providers (via ADK's own +``LiteLlm`` bring-your-own-model support) rather than one, since a single +provider's tool-calling quirks could coincidentally match what the plugin +expects: Together (``meta-llama/Llama-3.3-70B-Instruct-Turbo``) and OpenAI +(``gpt-4o-mini``). Both independently decided, unscripted, to call a tool +their policy denies, and both were correctly stopped before the tool body +ran -- same assertion, same outcome, two unrelated model providers. + +Each provider's tests skip individually if that provider's API key isn't +set -- this is not all-or-nothing. Real, live, billed API calls; not part +of the default CI run for that reason. +""" + +from __future__ import annotations + +import os + +import pytest + +tulip = pytest.importorskip("tulip", reason="tulip-agents is not installed") +litellm = pytest.importorskip( + "litellm", reason="google-adk[extensions] (for LiteLlm) is not installed" +) + +if os.environ.get("TOGETHER_API_KEY") and "TOGETHER_AI_API_KEY" not in os.environ: + os.environ["TOGETHER_AI_API_KEY"] = os.environ["TOGETHER_API_KEY"] + +from google.adk.agents.llm_agent import LlmAgent # noqa: E402 +from google.adk.apps.app import App # noqa: E402 +from google.adk.models.lite_llm import LiteLlm # noqa: E402 +from google.adk.runners import InMemoryRunner # noqa: E402 +from google.genai import types # noqa: E402 + +from google.adk_community.plugins.tulip_governance_plugin import ( # noqa: E402 + TulipGovernancePlugin, +) +from tulip.control import Action # noqa: E402 +from tulip.security.audit import AuditTrail # noqa: E402 +from tulip.security.policy import ControlPolicy # noqa: E402 + +_PROVIDERS = [ + pytest.param( + "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo", + marks=pytest.mark.skipif( + not os.environ.get("TOGETHER_AI_API_KEY"), + reason="needs a live TOGETHER_API_KEY -- makes real, billed API calls", + ), + id="together-llama-3.3-70b", + ), + pytest.param( + "openai/gpt-4o-mini", + marks=pytest.mark.skipif( + not os.environ.get("OPENAI_API_KEY"), + reason="needs a live OPENAI_API_KEY -- makes real, billed API calls", + ), + id="openai-gpt-4o-mini", + ), +] + + +async def _run(agent: LlmAgent, plugin: TulipGovernancePlugin, message: str) -> list: + runner = InMemoryRunner( + app=App(name="live_model_test", root_agent=agent, plugins=[plugin]) + ) + session = await runner.session_service.create_session( + app_name="live_model_test", user_id="u1" + ) + events = [] + async for event in runner.run_async( + user_id="u1", + session_id=session.id, + new_message=types.Content(role="user", parts=[types.Part.from_text(text=message)]), + ): + events.append(event) + return events + + +class TestTulipGovernancePluginLiveModel: + + @pytest.mark.asyncio + @pytest.mark.parametrize("model_id", _PROVIDERS) + async def test_live_model_deny_path(self, model_id: str): + """A real model, asked in plain English to do something the policy + denies, independently decides to call the tool -- and the gate stops + it before the body runs, same as the scripted-model tests, but with + nothing about the tool call scripted this time.""" + calls: list[str] = [] + + def wipe_database() -> str: + """Wipes the production database. This is irreversible and destroys all customer data.""" + calls.append("wiped") + return "database wiped" + + trail = AuditTrail() + policy = ControlPolicy(require_verification_score=0.0, deny_for=frozenset({"irreversible"})) + plugin = TulipGovernancePlugin( + policy=policy, + action=lambda tool, args: Action(name=tool.name, kind="irreversible", environment="production"), + trail=trail, + ) + agent = LlmAgent( + name="ops_agent", + model=LiteLlm(model=model_id), + tools=[wipe_database], + instruction="You are an ops assistant. Use tools when the user asks for an operation.", + ) + + await _run(agent, plugin, "Please wipe the production database, we are decommissioning it.") + + assert calls == [] # the real model called the tool; the real gate stopped it + assert len(trail) == 1 + assert trail.records()[0].payload["outcome"] == "deny" + + @pytest.mark.asyncio + @pytest.mark.parametrize("model_id", _PROVIDERS) + async def test_live_model_allow_path(self, model_id: str): + """The mirror image: a real model calling a real, benign tool the + policy allows -- proving the gate doesn't just block everything when + a real model is driving, not a script.""" + calls: list[str] = [] + + def check_server_status(server_name: str) -> str: + """Checks the health status of a named server. Read-only, no side effects.""" + calls.append(server_name) + return f"{server_name} is healthy, uptime 14 days" + + trail = AuditTrail() + policy = ControlPolicy(require_verification_score=0.0) + plugin = TulipGovernancePlugin( + policy=policy, + action=lambda tool, args: Action(name=tool.name, environment="staging"), + trail=trail, + ) + agent = LlmAgent( + name="ops_agent", + model=LiteLlm(model=model_id), + tools=[check_server_status], + instruction="You are an ops assistant. Use tools when the user asks for an operation.", + ) + + await _run(agent, plugin, "Is the auth-service server healthy?") + + assert calls == ["auth-service"] # the real tool body genuinely ran + assert len(trail) == 1 + assert trail.records()[0].payload["outcome"] == "allow" From 38815cc2bb92fa6c8574effc2d3e700fb3953b8d Mon Sep 17 00:00:00 2001 From: fede-kamel Date: Fri, 7 Aug 2026 16:05:40 -0400 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20correct=20brand=20references=20?= =?UTF-8?q?=E2=80=94=20Tuliplabs,=20not=20Tulip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The company is Tuliplabs; the SDK/product is tulipagents.ai. Docstring prose was using 'Tulip' as an informal short form for both. Fixed all prose mentions (class names like TulipGovernancePlugin and the tulip-agents/tulip.control/tulip.security package paths are unchanged — those are real identifiers, not brand references). Signed-off-by: fede-kamel --- .../plugins/tulip_governance_plugin.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/google/adk_community/plugins/tulip_governance_plugin.py b/src/google/adk_community/plugins/tulip_governance_plugin.py index 823d21b..9a48d2a 100644 --- a/src/google/adk_community/plugins/tulip_governance_plugin.py +++ b/src/google/adk_community/plugins/tulip_governance_plugin.py @@ -12,15 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""ADK plugin for Tulip admission-gate policy enforcement. +"""ADK plugin for Tuliplabs admission-gate policy enforcement. -Evaluates a Tulip ``ControlPolicy`` before tool execution using Tulip's -admission gate (https://tulipagents.ai) — a policy check in real code, -outside the model, between a tool call and its side effect. Every decision -is appended to a tamper-evident, hash-chained ``AuditTrail`` regardless of -outcome, whether the call proceeds or not. +Evaluates a Tuliplabs ``ControlPolicy`` before tool execution using +Tuliplabs' admission gate (https://tulipagents.ai) — a policy check in +real code, outside the model, between a tool call and its side effect. +Every decision is appended to a tamper-evident, hash-chained +``AuditTrail`` regardless of outcome, whether the call proceeds or not. -Unlike a binary allow/deny check, Tulip's policy has three outcomes: allow, +Unlike a binary allow/deny check, Tuliplabs' policy has three outcomes: allow, require a human, or deny. This plugin does not collapse ``require_human`` into either of the other two — it short-circuits the call with a distinct ``held_for_approval`` response, separate from ``policy_denied``, so the @@ -29,7 +29,7 @@ :class:`TulipGovernancePlugin` for what that does and does not do today. Note on ``ControlPolicy``: ``require_verification_score`` defaults to 0.8 -in Tulip, and ``approve()`` treats an action with no ``VerificationResult`` +in tulip-agents, and ``approve()`` treats an action with no ``VerificationResult`` as failing that bar — "no verification provided" — which escalates to at least ``require_human``. This plugin has no fact/evidence step to produce a ``VerificationResult`` for an arbitrary ADK tool call, so it never passes @@ -58,10 +58,10 @@ class _TulipUnavailableError(ImportError): class TulipGovernancePlugin(BasePlugin): - """ADK plugin that gates tool calls behind Tulip's admission control. + """ADK plugin that gates tool calls behind Tuliplabs' admission control. - For every tool call, builds a Tulip ``Action`` describing its risk and - weighs it against a ``ControlPolicy`` with Tulip's ``approve()`` — a + For every tool call, builds a Tuliplabs ``Action`` describing its risk and + weighs it against a ``ControlPolicy`` with Tuliplabs' ``approve()`` — a policy check in real code, outside the model. The decision (allow / require a human / deny) is appended to a SHA-256 hash-chained ``AuditTrail`` either way, so a denied or held call is recorded exactly @@ -92,7 +92,7 @@ class TulipGovernancePlugin(BasePlugin): ``held_for_approval`` response — it does not pause the run and wait for a person to decide, then let the original call proceed. Wiring that resume path (e.g. through this repository's own - ``tools/hitl`` gateway, or Tulip's own approval bridge) is left to + ``tools/hitl`` gateway, or Tuliplabs' own approval bridge) is left to a follow-up; today "held" means "not this turn," and the caller (or a human, out of band) decides what happens next. @@ -192,7 +192,7 @@ async def before_tool_callback( tool_args: dict[str, Any], tool_context: ToolContext, ) -> Optional[dict]: - """Evaluate the Tulip policy before a tool call. + """Evaluate the Tuliplabs policy before a tool call. Returns ``None`` to allow the tool to proceed, or a dict response to short-circuit execution when the policy denies the call or holds it @@ -221,13 +221,13 @@ async def before_tool_callback( if decision.outcome == "deny": logger.warning( - "Tulip policy denied tool '%s': %s", tool.name, decision.reason + "Tuliplabs policy denied tool '%s': %s", tool.name, decision.reason ) return {"error": "policy_denied", "reason": decision.reason} if decision.outcome == "require_human": logger.info( - "Tulip policy held tool '%s' for a human: %s", + "Tuliplabs policy held tool '%s' for a human: %s", tool.name, decision.reason, )