tulip-frameworks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tulip_frameworks/__init__.py +54 -0
- tulip_frameworks/_sync.py +37 -0
- tulip_frameworks/actions.py +84 -0
- tulip_frameworks/adk.py +85 -0
- tulip_frameworks/approval.py +56 -0
- tulip_frameworks/core.py +177 -0
- tulip_frameworks/crewai.py +88 -0
- tulip_frameworks/langchain.py +94 -0
- tulip_frameworks/langgraph.py +79 -0
- tulip_frameworks/llamaindex.py +85 -0
- tulip_frameworks/openai_agents.py +103 -0
- tulip_frameworks/policy_presets.py +51 -0
- tulip_frameworks/py.typed +0 -0
- tulip_frameworks/testing.py +55 -0
- tulip_frameworks-0.1.0.dist-info/METADATA +314 -0
- tulip_frameworks-0.1.0.dist-info/RECORD +18 -0
- tulip_frameworks-0.1.0.dist-info/WHEEL +4 -0
- tulip_frameworks-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Drop Tulip's control runtime into the agent framework you already use.
|
|
5
|
+
|
|
6
|
+
``import tulip_frameworks`` exposes only the framework-agnostic core — it pulls in
|
|
7
|
+
**no** third-party framework package. Import a bridge explicitly to use it::
|
|
8
|
+
|
|
9
|
+
from tulip_frameworks.langchain import gate_langchain_tool # needs [langchain]
|
|
10
|
+
from tulip_frameworks.openai_agents import gate_openai_tool # needs [openai-agents]
|
|
11
|
+
|
|
12
|
+
Each bridge wraps the same primitive, :func:`tulip_frameworks.core.gate_callable`, so
|
|
13
|
+
the action your agent already takes runs only after it clears the admission gate.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from tulip_frameworks.actions import (
|
|
19
|
+
ActionSpec,
|
|
20
|
+
asset_from_args,
|
|
21
|
+
default_action,
|
|
22
|
+
resolve_action,
|
|
23
|
+
)
|
|
24
|
+
from tulip_frameworks.approval import ApprovalBridge, gateway_bridge
|
|
25
|
+
from tulip_frameworks.core import (
|
|
26
|
+
DENIED,
|
|
27
|
+
HELD,
|
|
28
|
+
Mode,
|
|
29
|
+
VerificationResult,
|
|
30
|
+
gate_callable,
|
|
31
|
+
held_result,
|
|
32
|
+
is_held,
|
|
33
|
+
)
|
|
34
|
+
from tulip_frameworks.policy_presets import action_gate_policy
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DENIED",
|
|
40
|
+
"HELD",
|
|
41
|
+
"ActionSpec",
|
|
42
|
+
"ApprovalBridge",
|
|
43
|
+
"Mode",
|
|
44
|
+
"VerificationResult",
|
|
45
|
+
"__version__",
|
|
46
|
+
"action_gate_policy",
|
|
47
|
+
"asset_from_args",
|
|
48
|
+
"default_action",
|
|
49
|
+
"gate_callable",
|
|
50
|
+
"gateway_bridge",
|
|
51
|
+
"held_result",
|
|
52
|
+
"is_held",
|
|
53
|
+
"resolve_action",
|
|
54
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Run an async gated callable from a sync framework hook (CrewAI's ``_run``)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from collections.abc import Awaitable
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_sync(awaitable: Awaitable[T]) -> T:
|
|
16
|
+
"""Run ``awaitable`` to completion from sync code, loop-running or not.
|
|
17
|
+
|
|
18
|
+
CrewAI executes tools synchronously. If no event loop is running we use
|
|
19
|
+
:func:`asyncio.run`; if one is (rare for a sync tool body), we offload to a
|
|
20
|
+
worker thread so we don't try to nest loops.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
async def _drive() -> T:
|
|
24
|
+
return await awaitable
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
asyncio.get_running_loop()
|
|
28
|
+
except RuntimeError:
|
|
29
|
+
return asyncio.run(_drive())
|
|
30
|
+
|
|
31
|
+
import concurrent.futures
|
|
32
|
+
|
|
33
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
34
|
+
return pool.submit(asyncio.run, _drive()).result()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["run_sync"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Deriving a Tulip :class:`~tulip.security.Action` from a tool call.
|
|
5
|
+
|
|
6
|
+
The gate reasons over an :class:`Action` — its ``asset``, ``blast_radius``,
|
|
7
|
+
``environment``, ``kind`` and ``tags``. A bridge can supply that three ways:
|
|
8
|
+
|
|
9
|
+
1. a constant :class:`Action` (risk is the same regardless of arguments),
|
|
10
|
+
2. a callable ``(name, kwargs) -> Action`` (the recommended form — risk usually
|
|
11
|
+
varies with the call's arguments), or
|
|
12
|
+
3. nothing, in which case :func:`default_action` builds a conservative one.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from tulip.control import Action
|
|
21
|
+
|
|
22
|
+
#: An :class:`Action`, or a callable that derives one from ``(name, kwargs)``.
|
|
23
|
+
ActionSpec = Action | Callable[[str, Mapping[str, Any]], Action]
|
|
24
|
+
|
|
25
|
+
#: Argument names commonly naming the asset an action touches, in priority order.
|
|
26
|
+
_ASSET_KEYS = (
|
|
27
|
+
"asset",
|
|
28
|
+
"host",
|
|
29
|
+
"hostname",
|
|
30
|
+
"target",
|
|
31
|
+
"resource",
|
|
32
|
+
"resource_id",
|
|
33
|
+
"account",
|
|
34
|
+
"user",
|
|
35
|
+
"username",
|
|
36
|
+
"email",
|
|
37
|
+
"order_id",
|
|
38
|
+
"id",
|
|
39
|
+
"ip",
|
|
40
|
+
"url",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def asset_from_args(kwargs: Mapping[str, Any]) -> str:
|
|
45
|
+
"""Best-effort asset label from a tool call's arguments."""
|
|
46
|
+
for key in _ASSET_KEYS:
|
|
47
|
+
value = kwargs.get(key)
|
|
48
|
+
if value:
|
|
49
|
+
return str(value)
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_action(
|
|
54
|
+
name: str,
|
|
55
|
+
kwargs: Mapping[str, Any],
|
|
56
|
+
*,
|
|
57
|
+
environment: str = "unknown",
|
|
58
|
+
kind: str = "",
|
|
59
|
+
) -> Action:
|
|
60
|
+
"""A conservative :class:`Action` for ``name`` when none was supplied.
|
|
61
|
+
|
|
62
|
+
Fail-safe by construction: ``environment="unknown"`` plus the stock
|
|
63
|
+
:class:`~tulip.security.ControlPolicy` (which requires a verification score)
|
|
64
|
+
lands an un-verified call on ``require_human`` rather than auto-allowing it.
|
|
65
|
+
"""
|
|
66
|
+
return Action(
|
|
67
|
+
name=name,
|
|
68
|
+
asset=asset_from_args(kwargs),
|
|
69
|
+
blast_radius=1,
|
|
70
|
+
environment=environment,
|
|
71
|
+
kind=kind,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_action(spec: ActionSpec | None, name: str, kwargs: Mapping[str, Any]) -> Action:
|
|
76
|
+
"""Resolve an :class:`ActionSpec` (or ``None``) into a concrete :class:`Action`."""
|
|
77
|
+
if spec is None:
|
|
78
|
+
return default_action(name, kwargs)
|
|
79
|
+
if isinstance(spec, Action):
|
|
80
|
+
return spec
|
|
81
|
+
return spec(name, kwargs)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["ActionSpec", "asset_from_args", "default_action", "resolve_action"]
|
tulip_frameworks/adk.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Google ADK bridge — gate an Agent Development Kit tool behind Tulip's gate.
|
|
5
|
+
|
|
6
|
+
from google.adk.agents import Agent
|
|
7
|
+
from google.adk.tools import FunctionTool
|
|
8
|
+
from tulip_frameworks.adk import gate_adk_tool
|
|
9
|
+
from tulip_frameworks.policy_presets import action_gate_policy
|
|
10
|
+
from tulip.control import Action, AuditTrail
|
|
11
|
+
|
|
12
|
+
def disable_user(email: str) -> str:
|
|
13
|
+
"Disable an account."
|
|
14
|
+
return idp.disable(email)
|
|
15
|
+
|
|
16
|
+
trail = AuditTrail()
|
|
17
|
+
gated = gate_adk_tool(
|
|
18
|
+
FunctionTool(func=disable_user),
|
|
19
|
+
action=lambda n, a: Action(name=n, asset=a["email"],
|
|
20
|
+
environment="production", kind="identity"),
|
|
21
|
+
policy=action_gate_policy(), trail=trail)
|
|
22
|
+
# agent = Agent(model="gemini-2.0-flash", tools=[gated])
|
|
23
|
+
|
|
24
|
+
Accepts an ADK ``FunctionTool`` or a bare Python function. Needs the ``adk``
|
|
25
|
+
extra: ``pip install tulip-frameworks[adk]``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from tulip.control import AuditTrail, ControlPolicy
|
|
33
|
+
from tulip.security import Evidence
|
|
34
|
+
|
|
35
|
+
from tulip_frameworks.actions import ActionSpec
|
|
36
|
+
from tulip_frameworks.approval import ApprovalBridge
|
|
37
|
+
from tulip_frameworks.core import Mode, VerificationResult, gate_callable
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_adk() -> Any:
|
|
41
|
+
try:
|
|
42
|
+
from google.adk.tools import FunctionTool
|
|
43
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
44
|
+
raise ImportError("Google ADK support needs: pip install tulip-frameworks[adk]") from exc
|
|
45
|
+
return FunctionTool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def gate_adk_tool(
|
|
49
|
+
tool: Any,
|
|
50
|
+
*,
|
|
51
|
+
action: ActionSpec,
|
|
52
|
+
policy: ControlPolicy,
|
|
53
|
+
trail: AuditTrail | None = None,
|
|
54
|
+
mode: Mode = "soft",
|
|
55
|
+
finding: Evidence | None = None,
|
|
56
|
+
verdict: VerificationResult | None = None,
|
|
57
|
+
principal: str = "agent",
|
|
58
|
+
approval: ApprovalBridge | None = None,
|
|
59
|
+
) -> Any:
|
|
60
|
+
"""Return an ADK ``FunctionTool`` whose action is gated by :func:`admit`.
|
|
61
|
+
|
|
62
|
+
``tool`` may be an ADK ``FunctionTool`` or a plain function. The gated callable
|
|
63
|
+
keeps the original name, docstring, and signature (so ADK builds the right
|
|
64
|
+
function declaration), and runs the action only after it clears policy.
|
|
65
|
+
"""
|
|
66
|
+
function_tool = _require_adk()
|
|
67
|
+
inner = getattr(tool, "func", tool) # FunctionTool.func, or a bare function
|
|
68
|
+
name = str(getattr(tool, "name", None) or getattr(inner, "__name__", "tool"))
|
|
69
|
+
gated = gate_callable(
|
|
70
|
+
inner,
|
|
71
|
+
name=name,
|
|
72
|
+
action=action,
|
|
73
|
+
policy=policy,
|
|
74
|
+
trail=trail,
|
|
75
|
+
mode=mode,
|
|
76
|
+
finding=finding,
|
|
77
|
+
verdict=verdict,
|
|
78
|
+
principal=principal,
|
|
79
|
+
approval=approval,
|
|
80
|
+
serialize=True, # a held result must be a string for the model
|
|
81
|
+
)
|
|
82
|
+
return function_tool(func=gated)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["gate_adk_tool"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Out-of-band approval — a small Protocol the gateway's broker already satisfies.
|
|
5
|
+
|
|
6
|
+
When a tool is gated in ``mode="soft"`` and an action is held for a human, you can
|
|
7
|
+
hand :func:`tulip_frameworks.core.gate_callable` an :class:`ApprovalBridge`. The held
|
|
8
|
+
result then carries an ``approval_id`` the agent can poll, while a human approves or
|
|
9
|
+
denies on a side channel the agent has no access to.
|
|
10
|
+
|
|
11
|
+
This is a structural Protocol on purpose: it has **no import-time dependency** on
|
|
12
|
+
``tulip-gateway`` (which is not yet on PyPI). ``tulip_gateway.policy.approval``'s
|
|
13
|
+
``ApprovalBroker`` matches it in shape; :func:`gateway_bridge` adapts one when present.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from typing import Any, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class ApprovalBridge(Protocol):
|
|
24
|
+
"""Submit a held action for out-of-band approval and check its state."""
|
|
25
|
+
|
|
26
|
+
def submit(self, principal: str, tool: str, args: Mapping[str, Any]) -> str:
|
|
27
|
+
"""Record a pending approval; return an id the agent can poll."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def state(self, approval_id: str) -> str | None:
|
|
31
|
+
"""Current state for an id — e.g. ``"pending"`` / ``"approved"`` / ``"denied"``."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def gateway_bridge(broker: Any) -> ApprovalBridge:
|
|
36
|
+
"""Adapt a ``tulip_gateway`` ``ApprovalBroker`` to the :class:`ApprovalBridge` shape.
|
|
37
|
+
|
|
38
|
+
The broker's ``submit`` returns an ``ApprovalRecord``; this thin adapter returns
|
|
39
|
+
its ``approval_id`` instead, and maps ``get`` to :meth:`ApprovalBridge.state`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
class _Bridge:
|
|
43
|
+
def submit(self, principal: str, tool: str, args: Mapping[str, Any]) -> str:
|
|
44
|
+
record = broker.submit(principal, tool, dict(args))
|
|
45
|
+
return str(getattr(record, "approval_id", record))
|
|
46
|
+
|
|
47
|
+
def state(self, approval_id: str) -> str | None:
|
|
48
|
+
record = broker.get(approval_id)
|
|
49
|
+
if record is None:
|
|
50
|
+
return None
|
|
51
|
+
return str(getattr(record, "state", record))
|
|
52
|
+
|
|
53
|
+
return _Bridge()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["ApprovalBridge", "gateway_bridge"]
|
tulip_frameworks/core.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""The framework-agnostic gate — the only place that calls :func:`tulip.security.admit`.
|
|
5
|
+
|
|
6
|
+
Every per-framework bridge (``langchain``, ``langgraph``, ``openai_agents``, …) is a
|
|
7
|
+
thin re-wrapper around :func:`gate_callable`. Give it the callable that performs a
|
|
8
|
+
side effect plus the :class:`~tulip.security.Action` that describes it, and it returns
|
|
9
|
+
an async callable that runs the side effect **only** if the action clears policy —
|
|
10
|
+
holding for a human or denying otherwise, and recording the decision on a
|
|
11
|
+
tamper-evident :class:`~tulip.security.AuditTrail` either way.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import inspect
|
|
17
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from tulip.control import (
|
|
21
|
+
Action,
|
|
22
|
+
AdmissionError,
|
|
23
|
+
AuditTrail,
|
|
24
|
+
ControlPolicy,
|
|
25
|
+
admit,
|
|
26
|
+
)
|
|
27
|
+
from tulip.security import (
|
|
28
|
+
Evidence,
|
|
29
|
+
as_json,
|
|
30
|
+
)
|
|
31
|
+
from tulip.security import (
|
|
32
|
+
VerificationResult as VerificationResult,
|
|
33
|
+
# re-exported for callers that pass a verification result,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from tulip_frameworks.actions import ActionSpec, resolve_action
|
|
37
|
+
from tulip_frameworks.approval import ApprovalBridge
|
|
38
|
+
|
|
39
|
+
#: How a held/denied action surfaces. ``"soft"`` returns an LLM-readable result the
|
|
40
|
+
#: agent loop can react to; ``"raise"`` re-raises :class:`~tulip.security.AdmissionError`.
|
|
41
|
+
Mode = Literal["soft", "raise"]
|
|
42
|
+
|
|
43
|
+
#: ``status`` value on a soft-mode result when an action is held for a human.
|
|
44
|
+
HELD = "held_for_approval"
|
|
45
|
+
#: ``status`` value on a soft-mode result when an action is denied outright.
|
|
46
|
+
DENIED = "denied"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Awaitable[Any]]:
|
|
50
|
+
"""Adapt a sync-or-async callable into an awaitable one (called with kwargs)."""
|
|
51
|
+
if inspect.iscoroutinefunction(fn):
|
|
52
|
+
return fn
|
|
53
|
+
|
|
54
|
+
async def _wrapped(**kwargs: Any) -> Any:
|
|
55
|
+
result = fn(**kwargs)
|
|
56
|
+
if inspect.isawaitable(result):
|
|
57
|
+
return await result
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
return _wrapped
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def held_result(
|
|
64
|
+
decision: Any,
|
|
65
|
+
*,
|
|
66
|
+
kwargs: Mapping[str, Any],
|
|
67
|
+
principal: str,
|
|
68
|
+
approval: ApprovalBridge | None,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
"""Build the structured result returned in soft mode when an action doesn't admit.
|
|
71
|
+
|
|
72
|
+
On ``require_human`` and an ``approval`` bridge is given, an approval is submitted
|
|
73
|
+
out of band and its id is embedded so the agent can poll for the human decision.
|
|
74
|
+
"""
|
|
75
|
+
action: Action = decision.action
|
|
76
|
+
out: dict[str, Any] = {
|
|
77
|
+
"status": DENIED if decision.outcome == "deny" else HELD,
|
|
78
|
+
"outcome": decision.outcome,
|
|
79
|
+
"action": action.name,
|
|
80
|
+
"asset": action.asset,
|
|
81
|
+
"reason": decision.reason,
|
|
82
|
+
}
|
|
83
|
+
if decision.outcome != "deny" and approval is not None:
|
|
84
|
+
out["approval_id"] = approval.submit(principal, action.name, dict(kwargs))
|
|
85
|
+
out["next"] = "call approval_status(approval_id) once a human decides"
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def gate_callable(
|
|
90
|
+
fn: Callable[..., Any],
|
|
91
|
+
*,
|
|
92
|
+
name: str,
|
|
93
|
+
action: ActionSpec,
|
|
94
|
+
policy: ControlPolicy,
|
|
95
|
+
trail: AuditTrail | None = None,
|
|
96
|
+
mode: Mode = "soft",
|
|
97
|
+
finding: Evidence | None = None,
|
|
98
|
+
verdict: VerificationResult | None = None,
|
|
99
|
+
principal: str = "agent",
|
|
100
|
+
approval: ApprovalBridge | None = None,
|
|
101
|
+
serialize: bool = False,
|
|
102
|
+
) -> Callable[..., Awaitable[Any]]:
|
|
103
|
+
"""Wrap ``fn`` so it runs only after its :class:`Action` clears the admission gate.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
fn: The callable that performs the side effect (sync or async). Invoked with
|
|
107
|
+
the gated callable's keyword arguments.
|
|
108
|
+
name: A stable tool name used in the audit record and held result.
|
|
109
|
+
action: An :class:`~tulip.security.Action`, or a callable
|
|
110
|
+
``(name, kwargs) -> Action`` that derives one per call (the recommended
|
|
111
|
+
form — risk usually varies with the arguments).
|
|
112
|
+
policy: The governing :class:`~tulip.security.ControlPolicy`. For arbitrary
|
|
113
|
+
tools with no verification, prefer
|
|
114
|
+
:func:`tulip_frameworks.policy_presets.action_gate_policy`.
|
|
115
|
+
trail: An :class:`~tulip.security.AuditTrail` to record every decision on.
|
|
116
|
+
mode: ``"soft"`` (default) returns a held/denied result the LLM can read;
|
|
117
|
+
``"raise"`` re-raises :class:`~tulip.security.AdmissionError`.
|
|
118
|
+
finding / verdict: Optional grounded evidence + verification, forwarded to the
|
|
119
|
+
gate so a fully grounded caller gets the complete trust chain.
|
|
120
|
+
principal: Identity recorded against an out-of-band approval.
|
|
121
|
+
approval: Optional :class:`~tulip_frameworks.approval.ApprovalBridge` to submit
|
|
122
|
+
a ``require_human`` hold to (e.g. the gateway's broker).
|
|
123
|
+
serialize: If ``True``, the soft-mode held/denied result is returned as a JSON
|
|
124
|
+
string (what a string-typed tool result needs). If ``False`` (default), it
|
|
125
|
+
is returned as a ``dict``.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
An async callable. On allow it returns whatever ``fn`` returns; in soft mode
|
|
129
|
+
on hold/deny it returns the held result; in raise mode it raises.
|
|
130
|
+
"""
|
|
131
|
+
perform_async = _ensure_async(fn)
|
|
132
|
+
|
|
133
|
+
async def gated(**kwargs: Any) -> Any:
|
|
134
|
+
act = resolve_action(action, name, kwargs)
|
|
135
|
+
|
|
136
|
+
async def perform() -> Any:
|
|
137
|
+
return await perform_async(**kwargs)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
return await admit(
|
|
141
|
+
act, perform, policy=policy, finding=finding, verdict=verdict, trail=trail
|
|
142
|
+
)
|
|
143
|
+
except AdmissionError as exc:
|
|
144
|
+
if mode == "raise":
|
|
145
|
+
raise
|
|
146
|
+
result = held_result(
|
|
147
|
+
exc.decision, kwargs=kwargs, principal=principal, approval=approval
|
|
148
|
+
)
|
|
149
|
+
return as_json(result) if serialize else result
|
|
150
|
+
|
|
151
|
+
gated.__name__ = name
|
|
152
|
+
# Preserve the wrapped tool's signature + docstring so frameworks that build
|
|
153
|
+
# their arg schema by introspecting the callable (ADK, LlamaIndex, …) see the
|
|
154
|
+
# real parameters rather than the gated wrapper's ``**kwargs``.
|
|
155
|
+
try:
|
|
156
|
+
gated.__signature__ = inspect.signature(fn) # type: ignore[attr-defined]
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
pass
|
|
159
|
+
if getattr(fn, "__doc__", None):
|
|
160
|
+
gated.__doc__ = fn.__doc__
|
|
161
|
+
return gated
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def is_held(result: Any) -> bool:
|
|
165
|
+
"""Whether a soft-mode result represents a held/denied action (dict form)."""
|
|
166
|
+
return isinstance(result, Mapping) and result.get("status") in {HELD, DENIED}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"DENIED",
|
|
171
|
+
"HELD",
|
|
172
|
+
"Mode",
|
|
173
|
+
"VerificationResult",
|
|
174
|
+
"gate_callable",
|
|
175
|
+
"held_result",
|
|
176
|
+
"is_held",
|
|
177
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""CrewAI bridge — gate a CrewAI tool's action behind Tulip's admission gate.
|
|
5
|
+
|
|
6
|
+
from crewai.tools import BaseTool
|
|
7
|
+
from tulip_frameworks.crewai import gate_crewai_tool
|
|
8
|
+
from tulip_frameworks.policy_presets import action_gate_policy
|
|
9
|
+
from tulip.control import Action, AuditTrail
|
|
10
|
+
|
|
11
|
+
trail = AuditTrail()
|
|
12
|
+
safe_tool = gate_crewai_tool(
|
|
13
|
+
my_refund_tool,
|
|
14
|
+
action=lambda n, a: Action(name=n, asset=a["order_id"],
|
|
15
|
+
kind="payment", environment="production"),
|
|
16
|
+
policy=action_gate_policy(), trail=trail)
|
|
17
|
+
# crew = Crew(agents=[agent], tasks=[task]) # agent uses safe_tool
|
|
18
|
+
|
|
19
|
+
CrewAI runs tools synchronously, so the gated coroutine is driven via a sync bridge.
|
|
20
|
+
Needs the ``crewai`` extra: ``pip install tulip-frameworks[crewai]``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from tulip.control import AuditTrail, ControlPolicy
|
|
28
|
+
from tulip.security import Evidence
|
|
29
|
+
|
|
30
|
+
from tulip_frameworks._sync import run_sync
|
|
31
|
+
from tulip_frameworks.actions import ActionSpec
|
|
32
|
+
from tulip_frameworks.approval import ApprovalBridge
|
|
33
|
+
from tulip_frameworks.core import Mode, VerificationResult, gate_callable
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require_crewai() -> Any:
|
|
37
|
+
try:
|
|
38
|
+
from crewai.tools import BaseTool
|
|
39
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
40
|
+
raise ImportError("CrewAI support needs: pip install tulip-frameworks[crewai]") from exc
|
|
41
|
+
return BaseTool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gate_crewai_tool(
|
|
45
|
+
tool: Any,
|
|
46
|
+
*,
|
|
47
|
+
action: ActionSpec,
|
|
48
|
+
policy: ControlPolicy,
|
|
49
|
+
trail: AuditTrail | None = None,
|
|
50
|
+
mode: Mode = "soft",
|
|
51
|
+
finding: Evidence | None = None,
|
|
52
|
+
verdict: VerificationResult | None = None,
|
|
53
|
+
principal: str = "agent",
|
|
54
|
+
approval: ApprovalBridge | None = None,
|
|
55
|
+
) -> Any:
|
|
56
|
+
"""Return a CrewAI ``BaseTool`` whose action is gated by :func:`admit`.
|
|
57
|
+
|
|
58
|
+
Keeps the original ``name``, ``description`` and ``args_schema``. The wrapped
|
|
59
|
+
``_run`` drives the async gate synchronously (CrewAI's execution model).
|
|
60
|
+
"""
|
|
61
|
+
base_tool = _require_crewai()
|
|
62
|
+
inner = getattr(tool, "_run", None) or tool.run
|
|
63
|
+
gated = gate_callable(
|
|
64
|
+
inner,
|
|
65
|
+
name=tool.name,
|
|
66
|
+
action=action,
|
|
67
|
+
policy=policy,
|
|
68
|
+
trail=trail,
|
|
69
|
+
mode=mode,
|
|
70
|
+
finding=finding,
|
|
71
|
+
verdict=verdict,
|
|
72
|
+
principal=principal,
|
|
73
|
+
approval=approval,
|
|
74
|
+
serialize=True, # a held result must be a string for the LLM
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
class _GatedTool(base_tool): # type: ignore[misc, valid-type]
|
|
78
|
+
name: str = tool.name
|
|
79
|
+
description: str = tool.description
|
|
80
|
+
args_schema: Any = getattr(tool, "args_schema", None)
|
|
81
|
+
|
|
82
|
+
def _run(self, **kwargs: Any) -> Any:
|
|
83
|
+
return run_sync(gated(**kwargs))
|
|
84
|
+
|
|
85
|
+
return _GatedTool()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["gate_crewai_tool"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright 2026 Tulip Labs
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""LangChain bridge — gate a LangChain tool's action behind Tulip's admission gate.
|
|
5
|
+
|
|
6
|
+
from langchain_core.tools import tool
|
|
7
|
+
from tulip_frameworks.langchain import gate_langchain_tool
|
|
8
|
+
from tulip_frameworks.policy_presets import action_gate_policy
|
|
9
|
+
from tulip.control import Action, AuditTrail
|
|
10
|
+
|
|
11
|
+
@tool
|
|
12
|
+
async def refund(order_id: str, amount_usd: float) -> str:
|
|
13
|
+
"Issue a customer refund."
|
|
14
|
+
return payments.refund(order_id, amount_usd)
|
|
15
|
+
|
|
16
|
+
trail = AuditTrail()
|
|
17
|
+
safe_refund = gate_langchain_tool(
|
|
18
|
+
refund,
|
|
19
|
+
action=lambda name, a: Action(name=name, asset=a["order_id"],
|
|
20
|
+
blast_radius=1, kind="payment", environment="production"),
|
|
21
|
+
policy=action_gate_policy(), trail=trail)
|
|
22
|
+
# agent = create_react_agent(model, tools=[safe_refund])
|
|
23
|
+
|
|
24
|
+
Needs the ``langchain`` extra: ``pip install tulip-frameworks[langchain]``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from tulip.control import AuditTrail, ControlPolicy
|
|
32
|
+
from tulip.security import Evidence
|
|
33
|
+
|
|
34
|
+
from tulip_frameworks.actions import ActionSpec
|
|
35
|
+
from tulip_frameworks.approval import ApprovalBridge
|
|
36
|
+
from tulip_frameworks.core import Mode, VerificationResult, gate_callable
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from langchain_core.tools import StructuredTool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_langchain() -> Any:
|
|
43
|
+
try:
|
|
44
|
+
from langchain_core.tools import StructuredTool
|
|
45
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"LangChain support needs: pip install tulip-frameworks[langchain]"
|
|
48
|
+
) from exc
|
|
49
|
+
return StructuredTool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def gate_langchain_tool(
|
|
53
|
+
tool: Any,
|
|
54
|
+
*,
|
|
55
|
+
action: ActionSpec,
|
|
56
|
+
policy: ControlPolicy,
|
|
57
|
+
trail: AuditTrail | None = None,
|
|
58
|
+
mode: Mode = "soft",
|
|
59
|
+
finding: Evidence | None = None,
|
|
60
|
+
verdict: VerificationResult | None = None,
|
|
61
|
+
principal: str = "agent",
|
|
62
|
+
approval: ApprovalBridge | None = None,
|
|
63
|
+
) -> StructuredTool:
|
|
64
|
+
"""Return a LangChain ``StructuredTool`` whose action is gated by :func:`admit`.
|
|
65
|
+
|
|
66
|
+
The returned tool keeps the original ``name``, ``description`` and ``args_schema``,
|
|
67
|
+
so it is a drop-in replacement in any agent/graph that consumed the original. It is
|
|
68
|
+
async (``coroutine``); use it where the runtime awaits tools (e.g. LangGraph).
|
|
69
|
+
"""
|
|
70
|
+
structured_tool = _require_langchain()
|
|
71
|
+
# The underlying callable: prefer the async impl, else the sync one.
|
|
72
|
+
inner = getattr(tool, "coroutine", None) or tool.func
|
|
73
|
+
gated = gate_callable(
|
|
74
|
+
inner,
|
|
75
|
+
name=tool.name,
|
|
76
|
+
action=action,
|
|
77
|
+
policy=policy,
|
|
78
|
+
trail=trail,
|
|
79
|
+
mode=mode,
|
|
80
|
+
finding=finding,
|
|
81
|
+
verdict=verdict,
|
|
82
|
+
principal=principal,
|
|
83
|
+
approval=approval,
|
|
84
|
+
serialize=True, # a held result must be a string for the LLM's tool message
|
|
85
|
+
)
|
|
86
|
+
return structured_tool.from_function(
|
|
87
|
+
coroutine=gated,
|
|
88
|
+
name=tool.name,
|
|
89
|
+
description=tool.description,
|
|
90
|
+
args_schema=tool.args_schema,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = ["gate_langchain_tool"]
|