tollgate 1.0.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.
- tollgate/__init__.py +66 -0
- tollgate/approvals.py +227 -0
- tollgate/audit.py +47 -0
- tollgate/exceptions.py +28 -0
- tollgate/helpers.py +72 -0
- tollgate/integrations/__init__.py +0 -0
- tollgate/integrations/mcp.py +58 -0
- tollgate/integrations/strands.py +89 -0
- tollgate/interceptors/__init__.py +12 -0
- tollgate/interceptors/base.py +41 -0
- tollgate/interceptors/langchain.py +91 -0
- tollgate/interceptors/openai.py +87 -0
- tollgate/policy.py +152 -0
- tollgate/registry.py +58 -0
- tollgate/tower.py +224 -0
- tollgate/types.py +124 -0
- tollgate-1.0.0.dist-info/METADATA +98 -0
- tollgate-1.0.0.dist-info/RECORD +20 -0
- tollgate-1.0.0.dist-info/WHEEL +4 -0
- tollgate-1.0.0.dist-info/licenses/LICENSE +176 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from ..registry import ToolRegistry
|
|
4
|
+
from ..tower import ControlTower
|
|
5
|
+
from ..types import NormalizedToolCall, ToolRequest
|
|
6
|
+
from .base import TollgateInterceptor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LangChainAdapter:
|
|
10
|
+
"""Adapter for LangChain tools."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, registry: ToolRegistry):
|
|
13
|
+
self.registry = registry
|
|
14
|
+
|
|
15
|
+
def normalize(self, tool_call: Any) -> NormalizedToolCall:
|
|
16
|
+
# tool_call is expected to be (tool, tool_input) or (tool, tool_input, kwargs)
|
|
17
|
+
if len(tool_call) == 3:
|
|
18
|
+
tool, tool_input, kwargs = tool_call
|
|
19
|
+
else:
|
|
20
|
+
tool, tool_input = tool_call
|
|
21
|
+
kwargs = {}
|
|
22
|
+
|
|
23
|
+
tool_name = getattr(tool, "name", str(tool))
|
|
24
|
+
registry_key = f"langchain:{tool_name}"
|
|
25
|
+
|
|
26
|
+
effect, resource_type, manifest_version = self.registry.resolve_tool(
|
|
27
|
+
registry_key
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
metadata = kwargs.get("metadata", {})
|
|
31
|
+
|
|
32
|
+
params = tool_input if isinstance(tool_input, dict) else {"input": tool_input}
|
|
33
|
+
|
|
34
|
+
request = ToolRequest(
|
|
35
|
+
tool="langchain",
|
|
36
|
+
action=tool_name,
|
|
37
|
+
resource_type=resource_type,
|
|
38
|
+
effect=effect,
|
|
39
|
+
params=params,
|
|
40
|
+
metadata=metadata,
|
|
41
|
+
manifest_version=manifest_version,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def _exec_async():
|
|
45
|
+
if hasattr(tool, "ainvoke"):
|
|
46
|
+
return await tool.ainvoke(tool_input)
|
|
47
|
+
return await tool.arun(tool_input)
|
|
48
|
+
|
|
49
|
+
def _exec_sync():
|
|
50
|
+
if hasattr(tool, "invoke"):
|
|
51
|
+
return tool.invoke(tool_input)
|
|
52
|
+
return tool.run(tool_input)
|
|
53
|
+
|
|
54
|
+
return NormalizedToolCall(
|
|
55
|
+
request=request, exec_async=_exec_async, exec_sync=_exec_sync
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GuardedLangChainTool:
|
|
60
|
+
"""A wrapper for LangChain tools that enforces Tollgate gating."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, tool: Any, interceptor: TollgateInterceptor):
|
|
63
|
+
self.tool = tool
|
|
64
|
+
self.interceptor = interceptor
|
|
65
|
+
self.name = tool.name
|
|
66
|
+
self.description = tool.description
|
|
67
|
+
|
|
68
|
+
async def ainvoke(self, tool_input, agent_ctx=None, intent=None, **kwargs):
|
|
69
|
+
if agent_ctx is None or intent is None:
|
|
70
|
+
return await self.tool.ainvoke(tool_input, **kwargs)
|
|
71
|
+
|
|
72
|
+
return await self.interceptor.intercept_async(
|
|
73
|
+
agent_ctx, intent, (self.tool, tool_input, kwargs)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def invoke(self, tool_input, agent_ctx=None, intent=None, **kwargs):
|
|
77
|
+
if agent_ctx is None or intent is None:
|
|
78
|
+
return self.tool.invoke(tool_input, **kwargs)
|
|
79
|
+
|
|
80
|
+
return self.interceptor.intercept(
|
|
81
|
+
agent_ctx, intent, (self.tool, tool_input, kwargs)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def guard_tools(
|
|
86
|
+
tools: list[Any], tower: ControlTower, registry: ToolRegistry
|
|
87
|
+
) -> list[Any]:
|
|
88
|
+
"""Wrap a list of LangChain tools with Tollgate."""
|
|
89
|
+
adapter = LangChainAdapter(registry)
|
|
90
|
+
interceptor = TollgateInterceptor(tower, adapter)
|
|
91
|
+
return [GuardedLangChainTool(t, interceptor) for t in tools]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..registry import ToolRegistry
|
|
7
|
+
from ..tower import ControlTower
|
|
8
|
+
from ..types import AgentContext, Intent, NormalizedToolCall, ToolRequest
|
|
9
|
+
from .base import TollgateInterceptor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAIAdapter:
|
|
13
|
+
"""Adapter for OpenAI function/tool calls."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, registry: ToolRegistry, tool_map: dict[str, Callable]):
|
|
16
|
+
self.registry = registry
|
|
17
|
+
self.tool_map = tool_map
|
|
18
|
+
|
|
19
|
+
def normalize(self, tool_call: Any) -> NormalizedToolCall:
|
|
20
|
+
# tool_call is expected to be a dict with 'name' and 'arguments'
|
|
21
|
+
# OR (tool_call_dict, kwargs)
|
|
22
|
+
if isinstance(tool_call, tuple) and len(tool_call) == 2:
|
|
23
|
+
tc_dict, kwargs = tool_call
|
|
24
|
+
else:
|
|
25
|
+
tc_dict = tool_call
|
|
26
|
+
kwargs = {}
|
|
27
|
+
|
|
28
|
+
tool_name = tc_dict.get("function", {}).get("name") or tc_dict.get("name")
|
|
29
|
+
args_str = tc_dict.get("function", {}).get("arguments") or tc_dict.get(
|
|
30
|
+
"arguments"
|
|
31
|
+
)
|
|
32
|
+
args = json.loads(args_str) if isinstance(args_str, str) else args_str
|
|
33
|
+
|
|
34
|
+
registry_key = f"openai:{tool_name}"
|
|
35
|
+
effect, resource_type, manifest_version = self.registry.resolve_tool(
|
|
36
|
+
registry_key
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
metadata = kwargs.get("metadata", {})
|
|
40
|
+
|
|
41
|
+
request = ToolRequest(
|
|
42
|
+
tool="openai",
|
|
43
|
+
action=tool_name,
|
|
44
|
+
resource_type=resource_type,
|
|
45
|
+
effect=effect,
|
|
46
|
+
params=args,
|
|
47
|
+
metadata=metadata,
|
|
48
|
+
manifest_version=manifest_version,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
func = self.tool_map[tool_name]
|
|
52
|
+
|
|
53
|
+
async def _exec_async():
|
|
54
|
+
if asyncio.iscoroutinefunction(func):
|
|
55
|
+
return await func(**args)
|
|
56
|
+
return func(**args)
|
|
57
|
+
|
|
58
|
+
def _exec_sync():
|
|
59
|
+
return func(**args)
|
|
60
|
+
|
|
61
|
+
return NormalizedToolCall(
|
|
62
|
+
request=request, exec_async=_exec_async, exec_sync=_exec_sync
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OpenAIToolRunner:
|
|
67
|
+
"""Helper to run OpenAI tool calls through Tollgate."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, tower: ControlTower, registry: ToolRegistry):
|
|
70
|
+
self.tower = tower
|
|
71
|
+
self.registry = registry
|
|
72
|
+
|
|
73
|
+
async def run_async(
|
|
74
|
+
self,
|
|
75
|
+
tool_calls: list[Any],
|
|
76
|
+
tool_map: dict[str, Callable],
|
|
77
|
+
agent_ctx: AgentContext,
|
|
78
|
+
intent: Intent,
|
|
79
|
+
) -> list[Any]:
|
|
80
|
+
adapter = OpenAIAdapter(self.registry, tool_map)
|
|
81
|
+
interceptor = TollgateInterceptor(self.tower, adapter)
|
|
82
|
+
|
|
83
|
+
results = []
|
|
84
|
+
for tc in tool_calls:
|
|
85
|
+
result = await interceptor.intercept_async(agent_ctx, intent, tc)
|
|
86
|
+
results.append(result)
|
|
87
|
+
return results
|
tollgate/policy.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from .types import AgentContext, Decision, DecisionType, Effect, Intent, ToolRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PolicyEvaluator(Protocol):
|
|
11
|
+
"""Protocol for policy evaluation."""
|
|
12
|
+
|
|
13
|
+
def evaluate(
|
|
14
|
+
self, agent_ctx: AgentContext, intent: Intent, tool_request: ToolRequest
|
|
15
|
+
) -> Decision:
|
|
16
|
+
"""Evaluate a tool request against the policy."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class YamlPolicyEvaluator:
|
|
21
|
+
"""YAML-based policy evaluator with safe defaults."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
policy_path: str | Path,
|
|
26
|
+
default_if_unknown: DecisionType = DecisionType.DENY,
|
|
27
|
+
):
|
|
28
|
+
"""Load and validate policy from YAML file."""
|
|
29
|
+
self.path = Path(policy_path)
|
|
30
|
+
with self.path.open("r") as f:
|
|
31
|
+
content = f.read()
|
|
32
|
+
self.data = yaml.safe_load(content)
|
|
33
|
+
self.version = self.data.get(
|
|
34
|
+
"version", hashlib.sha256(content.encode()).hexdigest()[:8]
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.rules = self.data.get("rules", [])
|
|
38
|
+
self.default_if_unknown = default_if_unknown
|
|
39
|
+
self._validate_rules()
|
|
40
|
+
|
|
41
|
+
def _validate_rules(self):
|
|
42
|
+
"""Basic validation of rule structure."""
|
|
43
|
+
for i, rule in enumerate(self.rules):
|
|
44
|
+
if "decision" not in rule:
|
|
45
|
+
raise ValueError(f"Rule at index {i} is missing 'decision' key")
|
|
46
|
+
try:
|
|
47
|
+
DecisionType(rule["decision"])
|
|
48
|
+
except ValueError:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Invalid decision '{rule['decision']}' in rule at index {i}"
|
|
51
|
+
) from None
|
|
52
|
+
|
|
53
|
+
def evaluate(
|
|
54
|
+
self, agent_ctx: AgentContext, intent: Intent, tool_request: ToolRequest
|
|
55
|
+
) -> Decision:
|
|
56
|
+
"""Evaluate a tool request against loaded rules."""
|
|
57
|
+
# Principle 2: Safe Defaults for unknown effect/resource
|
|
58
|
+
if tool_request.effect == Effect.UNKNOWN:
|
|
59
|
+
return Decision(
|
|
60
|
+
decision=self.default_if_unknown,
|
|
61
|
+
reason="Unknown tool effect. Safe default applied.",
|
|
62
|
+
policy_version=self.version,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
for rule in self.rules:
|
|
66
|
+
if self._matches(rule, agent_ctx, intent, tool_request):
|
|
67
|
+
# Principle 3: Trusted Attributes
|
|
68
|
+
# If ALLOW, ensure effect and resource_type are from registry (trusted)
|
|
69
|
+
decision = DecisionType(rule["decision"])
|
|
70
|
+
if decision == DecisionType.ALLOW and not tool_request.manifest_version:
|
|
71
|
+
return Decision(
|
|
72
|
+
decision=DecisionType.ASK,
|
|
73
|
+
reason=(
|
|
74
|
+
"ALLOW decision requires trusted tool metadata "
|
|
75
|
+
"from registry."
|
|
76
|
+
),
|
|
77
|
+
policy_version=self.version,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return Decision(
|
|
81
|
+
decision=decision,
|
|
82
|
+
reason=rule.get("reason", "Rule matched"),
|
|
83
|
+
policy_id=rule.get("id"),
|
|
84
|
+
policy_version=self.version,
|
|
85
|
+
metadata=rule.get("metadata", {}),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return Decision(
|
|
89
|
+
decision=DecisionType.DENY,
|
|
90
|
+
reason="No matching policy rule found. Defaulting to DENY.",
|
|
91
|
+
policy_version=self.version,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _matches(
|
|
95
|
+
self,
|
|
96
|
+
rule: dict,
|
|
97
|
+
agent_ctx: AgentContext,
|
|
98
|
+
intent: Intent,
|
|
99
|
+
req: ToolRequest,
|
|
100
|
+
) -> bool:
|
|
101
|
+
# Match tool, action, resource_type, effect
|
|
102
|
+
if "tool" in rule and rule["tool"] != req.tool:
|
|
103
|
+
return False
|
|
104
|
+
if "action" in rule and rule["action"] != req.action:
|
|
105
|
+
return False
|
|
106
|
+
if "resource_type" in rule and rule["resource_type"] != req.resource_type:
|
|
107
|
+
return False
|
|
108
|
+
if "effect" in rule and rule["effect"] != req.effect.value:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Match Agent Context
|
|
112
|
+
if "agent" in rule:
|
|
113
|
+
for key, expected_val in rule["agent"].items():
|
|
114
|
+
if getattr(agent_ctx, key, None) != expected_val:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Match Intent
|
|
118
|
+
if "intent" in rule:
|
|
119
|
+
for key, expected_val in rule["intent"].items():
|
|
120
|
+
if getattr(intent, key, None) != expected_val:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
# Match metadata conditions (Untrusted)
|
|
124
|
+
if "when" in rule:
|
|
125
|
+
for key, condition in rule["when"].items():
|
|
126
|
+
val = req.metadata.get(key)
|
|
127
|
+
if not self._check_condition(val, condition):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
def _check_condition(self, val: Any, condition: Any) -> bool:
|
|
133
|
+
"""Check a single condition against a value."""
|
|
134
|
+
if isinstance(condition, dict):
|
|
135
|
+
for op, target in condition.items():
|
|
136
|
+
if val is None and op in (">", ">=", "<", "<="):
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
if op == "==" and val != target:
|
|
140
|
+
return False
|
|
141
|
+
if op == "!=" and val == target:
|
|
142
|
+
return False
|
|
143
|
+
if op == ">" and not (val > target):
|
|
144
|
+
return False
|
|
145
|
+
if op == ">=" and not (val >= target):
|
|
146
|
+
return False
|
|
147
|
+
if op == "<" and not (val < target):
|
|
148
|
+
return False
|
|
149
|
+
if op == "<=" and not (val <= target):
|
|
150
|
+
return False
|
|
151
|
+
return True
|
|
152
|
+
return val == condition
|
tollgate/registry.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from .types import Effect
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolRegistry:
|
|
10
|
+
"""A registry of trusted tool metadata."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, manifest_path: str | Path):
|
|
13
|
+
self.path = Path(manifest_path)
|
|
14
|
+
if not self.path.exists():
|
|
15
|
+
raise FileNotFoundError(f"Manifest file not found: {manifest_path}")
|
|
16
|
+
|
|
17
|
+
with self.path.open("r") as f:
|
|
18
|
+
content = f.read()
|
|
19
|
+
self.data = yaml.safe_load(content)
|
|
20
|
+
if not self.data:
|
|
21
|
+
self.data = {}
|
|
22
|
+
# Use content hash as manifest version if not provided
|
|
23
|
+
self.version = str(
|
|
24
|
+
self.data.get(
|
|
25
|
+
"version", hashlib.sha256(content.encode()).hexdigest()[:8]
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
self.tools = self.data.get("tools", {})
|
|
29
|
+
self._validate_manifest()
|
|
30
|
+
|
|
31
|
+
def _validate_manifest(self):
|
|
32
|
+
"""Basic validation of manifest structure."""
|
|
33
|
+
if not isinstance(self.tools, dict):
|
|
34
|
+
raise ValueError("Manifest 'tools' must be a dictionary.")
|
|
35
|
+
|
|
36
|
+
for key, entry in self.tools.items():
|
|
37
|
+
if not isinstance(entry, dict):
|
|
38
|
+
raise ValueError(f"Tool entry '{key}' must be a dictionary.")
|
|
39
|
+
if "effect" in entry:
|
|
40
|
+
try:
|
|
41
|
+
Effect(entry["effect"])
|
|
42
|
+
except ValueError as e:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Invalid effect '{entry['effect']}' for tool '{key}'."
|
|
45
|
+
) from e
|
|
46
|
+
|
|
47
|
+
def resolve_tool(self, tool_key: str) -> tuple[Effect, str, str | None]:
|
|
48
|
+
"""
|
|
49
|
+
Resolve tool key to (effect, resource_type, manifest_version).
|
|
50
|
+
Returns (UNKNOWN, "unknown", None) if not found.
|
|
51
|
+
"""
|
|
52
|
+
meta = self.tools.get(tool_key)
|
|
53
|
+
if not meta:
|
|
54
|
+
return Effect.UNKNOWN, "unknown", None
|
|
55
|
+
|
|
56
|
+
effect = Effect(meta.get("effect", "unknown"))
|
|
57
|
+
resource_type = meta.get("resource_type", "unknown")
|
|
58
|
+
return effect, resource_type, self.version
|
tollgate/tower.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import uuid
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .approvals import Approver, compute_request_hash
|
|
8
|
+
from .audit import AuditSink
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
TollgateApprovalDenied,
|
|
11
|
+
TollgateDeferred,
|
|
12
|
+
TollgateDenied,
|
|
13
|
+
)
|
|
14
|
+
from .policy import PolicyEvaluator
|
|
15
|
+
from .types import (
|
|
16
|
+
AgentContext,
|
|
17
|
+
ApprovalOutcome,
|
|
18
|
+
AuditEvent,
|
|
19
|
+
Decision,
|
|
20
|
+
DecisionType,
|
|
21
|
+
Intent,
|
|
22
|
+
Outcome,
|
|
23
|
+
ToolRequest,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ControlTower:
|
|
28
|
+
"""Async-first control tower for tool execution enforcement."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
policy: PolicyEvaluator,
|
|
33
|
+
approver: Approver,
|
|
34
|
+
audit: AuditSink,
|
|
35
|
+
redact_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
|
|
36
|
+
):
|
|
37
|
+
self.policy = policy
|
|
38
|
+
self.approver = approver
|
|
39
|
+
self.audit = audit
|
|
40
|
+
self.redact_fn = redact_fn or self._default_redact
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _default_redact(params: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
"""Redact sensitive keys by default."""
|
|
45
|
+
sensitive_keys = {
|
|
46
|
+
"password",
|
|
47
|
+
"token",
|
|
48
|
+
"secret",
|
|
49
|
+
"authorization",
|
|
50
|
+
"api_key",
|
|
51
|
+
"key",
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
k: ("[REDACTED]" if k.lower() in sensitive_keys else v)
|
|
55
|
+
for k, v in params.items()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async def execute_async(
|
|
59
|
+
self,
|
|
60
|
+
agent_ctx: AgentContext,
|
|
61
|
+
intent: Intent,
|
|
62
|
+
tool_request: ToolRequest,
|
|
63
|
+
exec_async: Callable[[], Awaitable[Any]],
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""
|
|
66
|
+
Evaluate and execute a tool call asynchronously.
|
|
67
|
+
"""
|
|
68
|
+
correlation_id = str(uuid.uuid4())
|
|
69
|
+
request_hash = compute_request_hash(agent_ctx, intent, tool_request)
|
|
70
|
+
|
|
71
|
+
# 1. Evaluate Policy
|
|
72
|
+
decision = self.policy.evaluate(agent_ctx, intent, tool_request)
|
|
73
|
+
|
|
74
|
+
# 2. Handle DENY
|
|
75
|
+
if decision.decision == DecisionType.DENY:
|
|
76
|
+
self._log(
|
|
77
|
+
correlation_id,
|
|
78
|
+
request_hash,
|
|
79
|
+
agent_ctx,
|
|
80
|
+
intent,
|
|
81
|
+
tool_request,
|
|
82
|
+
decision,
|
|
83
|
+
Outcome.BLOCKED,
|
|
84
|
+
)
|
|
85
|
+
raise TollgateDenied(decision.reason)
|
|
86
|
+
|
|
87
|
+
# 3. Handle ASK
|
|
88
|
+
if decision.decision == DecisionType.ASK:
|
|
89
|
+
outcome = await self.approver.request_approval_async(
|
|
90
|
+
agent_ctx, intent, tool_request, request_hash, decision.reason
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if outcome == ApprovalOutcome.DEFERRED:
|
|
94
|
+
# Audit the deferral
|
|
95
|
+
self._log(
|
|
96
|
+
correlation_id,
|
|
97
|
+
request_hash,
|
|
98
|
+
agent_ctx,
|
|
99
|
+
intent,
|
|
100
|
+
tool_request,
|
|
101
|
+
decision,
|
|
102
|
+
Outcome.BLOCKED, # Deferral is a temporary block
|
|
103
|
+
)
|
|
104
|
+
raise TollgateDeferred("pending")
|
|
105
|
+
|
|
106
|
+
if outcome != ApprovalOutcome.APPROVED:
|
|
107
|
+
final_outcome = (
|
|
108
|
+
Outcome.TIMEOUT
|
|
109
|
+
if outcome == ApprovalOutcome.TIMEOUT
|
|
110
|
+
else Outcome.APPROVAL_DENIED
|
|
111
|
+
)
|
|
112
|
+
self._log(
|
|
113
|
+
correlation_id,
|
|
114
|
+
request_hash,
|
|
115
|
+
agent_ctx,
|
|
116
|
+
intent,
|
|
117
|
+
tool_request,
|
|
118
|
+
decision,
|
|
119
|
+
final_outcome,
|
|
120
|
+
)
|
|
121
|
+
raise TollgateApprovalDenied(f"Approval failed: {outcome.value}")
|
|
122
|
+
|
|
123
|
+
# 4. Execute tool
|
|
124
|
+
result = None
|
|
125
|
+
outcome = Outcome.EXECUTED
|
|
126
|
+
try:
|
|
127
|
+
result = await exec_async()
|
|
128
|
+
except Exception as e:
|
|
129
|
+
outcome = Outcome.FAILED
|
|
130
|
+
result_summary = f"{type(e).__name__}: {str(e)}"
|
|
131
|
+
self._log(
|
|
132
|
+
correlation_id,
|
|
133
|
+
request_hash,
|
|
134
|
+
agent_ctx,
|
|
135
|
+
intent,
|
|
136
|
+
tool_request,
|
|
137
|
+
decision,
|
|
138
|
+
outcome,
|
|
139
|
+
result_summary=result_summary,
|
|
140
|
+
)
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
# 5. Final Audit
|
|
144
|
+
result_summary = self._truncate_result(result)
|
|
145
|
+
self._log(
|
|
146
|
+
correlation_id,
|
|
147
|
+
request_hash,
|
|
148
|
+
agent_ctx,
|
|
149
|
+
intent,
|
|
150
|
+
tool_request,
|
|
151
|
+
decision,
|
|
152
|
+
outcome,
|
|
153
|
+
result_summary=result_summary,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def execute(
|
|
159
|
+
self,
|
|
160
|
+
agent_ctx: AgentContext,
|
|
161
|
+
intent: Intent,
|
|
162
|
+
tool_request: ToolRequest,
|
|
163
|
+
exec_sync: Callable[[], Any],
|
|
164
|
+
) -> Any:
|
|
165
|
+
"""Sync wrapper for execute_async. Safe only if no event loop is running."""
|
|
166
|
+
try:
|
|
167
|
+
loop = asyncio.get_running_loop()
|
|
168
|
+
if loop.is_running():
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"execute() called from within a running event loop. "
|
|
171
|
+
"Use execute_async() instead."
|
|
172
|
+
)
|
|
173
|
+
except RuntimeError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
async def _exec():
|
|
177
|
+
return exec_sync()
|
|
178
|
+
|
|
179
|
+
return asyncio.run(self.execute_async(agent_ctx, intent, tool_request, _exec))
|
|
180
|
+
|
|
181
|
+
def _log(
|
|
182
|
+
self,
|
|
183
|
+
correlation_id: str,
|
|
184
|
+
request_hash: str,
|
|
185
|
+
agent: AgentContext,
|
|
186
|
+
intent: Intent,
|
|
187
|
+
req: ToolRequest,
|
|
188
|
+
decision: Decision,
|
|
189
|
+
outcome: Outcome,
|
|
190
|
+
approval_id: str | None = None,
|
|
191
|
+
result_summary: str | None = None,
|
|
192
|
+
):
|
|
193
|
+
# Redact params before logging
|
|
194
|
+
redacted_req = ToolRequest(
|
|
195
|
+
tool=req.tool,
|
|
196
|
+
action=req.action,
|
|
197
|
+
resource_type=req.resource_type,
|
|
198
|
+
effect=req.effect,
|
|
199
|
+
params=self.redact_fn(req.params),
|
|
200
|
+
metadata=req.metadata,
|
|
201
|
+
manifest_version=req.manifest_version,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
event = AuditEvent(
|
|
205
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
206
|
+
correlation_id=correlation_id,
|
|
207
|
+
request_hash=request_hash,
|
|
208
|
+
agent=agent,
|
|
209
|
+
intent=intent,
|
|
210
|
+
tool_request=redacted_req,
|
|
211
|
+
decision=decision,
|
|
212
|
+
outcome=outcome,
|
|
213
|
+
approval_id=approval_id,
|
|
214
|
+
result_summary=result_summary,
|
|
215
|
+
policy_version=decision.policy_version,
|
|
216
|
+
manifest_version=req.manifest_version,
|
|
217
|
+
)
|
|
218
|
+
self.audit.emit(event)
|
|
219
|
+
|
|
220
|
+
def _truncate_result(self, result: Any, max_chars: int = 200) -> str | None:
|
|
221
|
+
if result is None:
|
|
222
|
+
return None
|
|
223
|
+
s = str(result)
|
|
224
|
+
return s[:max_chars] + "..." if len(s) > max_chars else s
|