toolgovern-cli 0.1.1__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.
- toolgovern/__init__.py +222 -0
- toolgovern/approval/__init__.py +25 -0
- toolgovern/approval/pending_registry.py +379 -0
- toolgovern/classifier/__init__.py +19 -0
- toolgovern/classifier/credential_access.py +180 -0
- toolgovern/classifier/cross_agent_inheritance.py +216 -0
- toolgovern/classifier/filesystem_scope.py +202 -0
- toolgovern/classifier/index.py +80 -0
- toolgovern/classifier/information_flow.py +154 -0
- toolgovern/classifier/network_egress.py +289 -0
- toolgovern/classifier/shell_risk.py +357 -0
- toolgovern/classifier/util.py +259 -0
- toolgovern/cli.py +298 -0
- toolgovern/mcp_trust/__init__.py +342 -0
- toolgovern/middleware/__init__.py +30 -0
- toolgovern/middleware/idempotency_cache.py +117 -0
- toolgovern/middleware/on_tool_call.py +538 -0
- toolgovern/policy/__init__.py +10 -0
- toolgovern/policy/load_policy.py +41 -0
- toolgovern/policy/validate_policy.py +106 -0
- toolgovern/py.typed +0 -0
- toolgovern/scoping/__init__.py +13 -0
- toolgovern/scoping/inheritance_enforcer.py +145 -0
- toolgovern/scoping/scope_declaration.py +84 -0
- toolgovern/shared/__init__.py +0 -0
- toolgovern/shared/paths.py +266 -0
- toolgovern/trace/__init__.py +33 -0
- toolgovern/trace/canonical_json.py +27 -0
- toolgovern/trace/trace_reader.py +206 -0
- toolgovern/trace/trace_writer.py +247 -0
- toolgovern/types.py +247 -0
- toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
- toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
- toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
- toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
- toolgovern_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""``IdempotencyCache`` -- the primitive behind ``govern_tool()``'s optional ``idempotency``
|
|
2
|
+
option.
|
|
3
|
+
|
|
4
|
+
Ported from ``packages/toolgovern/src/middleware/idempotency-cache.ts``.
|
|
5
|
+
|
|
6
|
+
SCOPE AND LIMITATIONS (read before relying on this for anything beyond a single-process first
|
|
7
|
+
pass):
|
|
8
|
+
|
|
9
|
+
- This is a plain in-process dict. It does NOT survive a process restart, and it is NOT shared
|
|
10
|
+
across multiple processes or replicas (e.g. horizontally-scaled workers behind a load
|
|
11
|
+
balancer, or a serverless function that spins up a fresh instance per invocation). Two
|
|
12
|
+
replicas each hold their own independent cache, so a retry that happens to land on a
|
|
13
|
+
different replica than the original call will NOT be deduplicated.
|
|
14
|
+
- This is intentionally scoped as a first pass for the common single-process case (a
|
|
15
|
+
long-running agent runtime, a single server instance, a CLI run). Cross-process /
|
|
16
|
+
distributed idempotency (e.g. backed by Redis, or a database row with a unique constraint)
|
|
17
|
+
is future work and is explicitly out of scope for this cache.
|
|
18
|
+
- A new cache is created per ``govern_tool()`` call, so it is scoped to that one gated tool
|
|
19
|
+
instance -- it is never shared globally across every gate in a process.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any, Callable, Dict, Mapping, Optional
|
|
28
|
+
|
|
29
|
+
from ..trace.canonical_json import canonical_json
|
|
30
|
+
|
|
31
|
+
_DEFAULT_IDEMPOTENCY_TTL_MS = 60_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class IdempotencyOptions:
|
|
36
|
+
"""Options for ``govern_tool()``'s optional idempotency dedup. Omitting this option
|
|
37
|
+
entirely (the default) leaves ``govern_tool()``'s behavior completely unchanged -- every
|
|
38
|
+
call executes independently, exactly as before this option existed."""
|
|
39
|
+
|
|
40
|
+
enabled: bool = False
|
|
41
|
+
ttl_ms: int = _DEFAULT_IDEMPOTENCY_TTL_MS
|
|
42
|
+
"""How long a completed result stays eligible to be replayed for an identical retry, in
|
|
43
|
+
milliseconds."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _CacheEntry:
|
|
47
|
+
__slots__ = ("result", "error", "settled", "expires_at", "event")
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self.result: Any = None
|
|
51
|
+
self.error: Optional[BaseException] = None
|
|
52
|
+
self.settled: bool = False
|
|
53
|
+
# None while the entry is still pending -- a pending claim never expires out from
|
|
54
|
+
# under a call that is still running. Set to a concrete timestamp once the entry
|
|
55
|
+
# settles, so the TTL clock starts counting from completion rather than from when the
|
|
56
|
+
# call was first issued.
|
|
57
|
+
self.expires_at: Optional[float] = None
|
|
58
|
+
self.event = threading.Event()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IdempotencyCache:
|
|
62
|
+
"""A scoped, in-memory claim-if-absent cache keyed on a stable serialization of tool name +
|
|
63
|
+
arguments. See the module docstring for what this does and does not cover."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, ttl_ms: int = _DEFAULT_IDEMPOTENCY_TTL_MS) -> None:
|
|
66
|
+
self._entries: Dict[str, _CacheEntry] = {}
|
|
67
|
+
self._ttl_ms = ttl_ms
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def key_for(tool: str, args: Mapping[str, Any]) -> str:
|
|
72
|
+
"""Stable key for one tool+args combination. ``canonical_json`` sorts object keys
|
|
73
|
+
recursively, so ``{"a":1,"b":2}`` and ``{"b":2,"a":1}`` hash identically -- argument
|
|
74
|
+
insertion order must never cause two logically-identical calls to miss each other."""
|
|
75
|
+
return f"{tool}:{canonical_json(dict(args))}"
|
|
76
|
+
|
|
77
|
+
def claim_if_absent(self, key: str, run: Callable[[], Any]) -> Any:
|
|
78
|
+
"""Returns the cached result for ``key`` if a live (non-expired) entry exists,
|
|
79
|
+
executing ``run`` only when there is no live claim. A failed execution is evicted
|
|
80
|
+
immediately -- it is never cached as if it had succeeded, so a transient failure
|
|
81
|
+
remains retryable. Only a completed result is eligible to be replayed for a retry
|
|
82
|
+
within the TTL window."""
|
|
83
|
+
now = time.monotonic() * 1000
|
|
84
|
+
with self._lock:
|
|
85
|
+
existing = self._entries.get(key)
|
|
86
|
+
if existing and (existing.expires_at is None or existing.expires_at > now):
|
|
87
|
+
claimed = existing
|
|
88
|
+
is_new_claim = False
|
|
89
|
+
else:
|
|
90
|
+
if existing:
|
|
91
|
+
# Expired -- prune it so it doesn't linger.
|
|
92
|
+
del self._entries[key]
|
|
93
|
+
claimed = _CacheEntry()
|
|
94
|
+
self._entries[key] = claimed
|
|
95
|
+
is_new_claim = True
|
|
96
|
+
|
|
97
|
+
if not is_new_claim:
|
|
98
|
+
claimed.event.wait()
|
|
99
|
+
if claimed.error is not None:
|
|
100
|
+
raise claimed.error
|
|
101
|
+
return claimed.result
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
result = run()
|
|
105
|
+
claimed.result = result
|
|
106
|
+
claimed.settled = True
|
|
107
|
+
with self._lock:
|
|
108
|
+
claimed.expires_at = time.monotonic() * 1000 + self._ttl_ms
|
|
109
|
+
claimed.event.set()
|
|
110
|
+
return result
|
|
111
|
+
except BaseException as error: # noqa: BLE001 -- re-raised immediately below
|
|
112
|
+
claimed.error = error
|
|
113
|
+
claimed.settled = True
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._entries.pop(key, None)
|
|
116
|
+
claimed.event.set()
|
|
117
|
+
raise
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""``govern_tool()`` -- the core hook.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/middleware/onToolCall.ts``.
|
|
4
|
+
|
|
5
|
+
Wraps any tool definition a framework already has and returns a version that evaluates every
|
|
6
|
+
invocation through the classifier before the underlying tool executes. No framework fork
|
|
7
|
+
required: this is designed to slot into a single call site (e.g. a tool-executor wrapper) with
|
|
8
|
+
one wrapping call.
|
|
9
|
+
|
|
10
|
+
A gated call never reaches the real tool implementation until the classifier's decision resolves
|
|
11
|
+
to ``allow``. ``deny`` raises ``ToolGovernDenialError`` without executing the tool at all.
|
|
12
|
+
``require-approval`` calls ``on_approval_required`` if one was provided; with no handler, or if
|
|
13
|
+
the handler times out, the call fails closed (denied) -- an unanswered approval request is never
|
|
14
|
+
treated as a yes.
|
|
15
|
+
|
|
16
|
+
This Python port is synchronous (matching ``load_policy()``'s "runs once, no event loop
|
|
17
|
+
required" design and the rest of this package), unlike the TS original's async/await
|
|
18
|
+
middleware -- the classifier itself is pure and synchronous in both languages; only the I/O
|
|
19
|
+
around it (trace writes, an approval handler) differs. Approval timeouts are implemented with a
|
|
20
|
+
worker thread joined with a timeout, the synchronous equivalent of ``Promise.race`` against a
|
|
21
|
+
``setTimeout``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Union
|
|
29
|
+
|
|
30
|
+
from ..approval.pending_registry import PendingApprovalRegistry, ResolvePendingInput
|
|
31
|
+
from ..classifier import ClassifyOptions, classify
|
|
32
|
+
from ..scoping.inheritance_enforcer import ScopeRegistry, SpawnSubAgentParams
|
|
33
|
+
from ..scoping.scope_declaration import is_valid_agent_id
|
|
34
|
+
from ..trace.trace_writer import TraceWriter
|
|
35
|
+
from ..types import (
|
|
36
|
+
AgentIdSource,
|
|
37
|
+
Decision,
|
|
38
|
+
Policy,
|
|
39
|
+
RuleContext,
|
|
40
|
+
RuleMatch,
|
|
41
|
+
RuleOverrides,
|
|
42
|
+
ScopeDeclaration,
|
|
43
|
+
TraceEntryInput,
|
|
44
|
+
)
|
|
45
|
+
from .idempotency_cache import IdempotencyCache, IdempotencyOptions
|
|
46
|
+
|
|
47
|
+
_DEFAULT_APPROVAL_TIMEOUT_MS = 30_000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ToolDefinition:
|
|
52
|
+
"""A gateable tool: a name and a callable that executes it. ``execute`` takes the call's
|
|
53
|
+
``args`` mapping and returns (or raises) a result."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
execute: Callable[[Mapping[str, Any]], Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class GateDecisionInfo:
|
|
61
|
+
"""Everything surfaced to ``on_approval_required`` and ``on_decision`` about one gate
|
|
62
|
+
decision."""
|
|
63
|
+
|
|
64
|
+
agent_id: str
|
|
65
|
+
session_id: str
|
|
66
|
+
tool: str
|
|
67
|
+
args: Mapping[str, Any]
|
|
68
|
+
decision: Decision
|
|
69
|
+
fired_rules: Sequence[RuleMatch]
|
|
70
|
+
scope: ScopeDeclaration
|
|
71
|
+
coordinator_id: Optional[str] = None
|
|
72
|
+
pending_id: Optional[str] = None
|
|
73
|
+
"""The durable PendingApprovalRegistry id for this decision, when options.pending_approvals
|
|
74
|
+
was supplied and this decision was require-approval. Lets an on_decision listener correlate
|
|
75
|
+
the in-process (synchronous) outcome with the durable record a webhook/CLI/review queue can
|
|
76
|
+
later resolve via resolve_pending(). Absent for every other decision, and absent entirely when
|
|
77
|
+
no pending_approvals registry was configured."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class ApprovalOutcome:
|
|
82
|
+
"""What an ``ApprovalHandler`` resolves to when it wants to record who made the call, not
|
|
83
|
+
just whether it was approved. A handler may still just return a plain bool --
|
|
84
|
+
``approved_by`` is optional identity metadata, never required to resolve an approval."""
|
|
85
|
+
|
|
86
|
+
approved: bool
|
|
87
|
+
approved_by: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# A handler may return a bool or an ApprovalOutcome.
|
|
91
|
+
ApprovalHandlerResult = Union[bool, ApprovalOutcome]
|
|
92
|
+
ApprovalHandler = Callable[[GateDecisionInfo], ApprovalHandlerResult]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ToolGovernDenialError(Exception):
|
|
96
|
+
def __init__(self, decision_info: GateDecisionInfo) -> None:
|
|
97
|
+
self.decision_info = decision_info
|
|
98
|
+
rule_ids = ", ".join(r.rule_id for r in decision_info.fired_rules) or "policy default"
|
|
99
|
+
super().__init__(
|
|
100
|
+
f'toolgovern denied tool call "{decision_info.tool}" '
|
|
101
|
+
f'(agent "{decision_info.agent_id}"): {rule_ids}'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class InvalidAgentIdError(Exception):
|
|
106
|
+
"""Raised by ``govern_tool()`` when an explicitly-supplied ``agent_id`` fails the format
|
|
107
|
+
check in ``is_valid_agent_id()`` (empty, excessively long, or containing
|
|
108
|
+
control/injection-style characters). This is a format rejection, not an
|
|
109
|
+
identity-verification failure -- toolgovern cannot tell a malformed agent_id apart from a
|
|
110
|
+
well-formed one that is still a lie; it can only refuse to treat obviously-malformed input
|
|
111
|
+
as an identity at all. See docs/security-model.md, "Agent identity is caller-asserted, not
|
|
112
|
+
cryptographically verified."
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, raw_agent_id: str) -> None:
|
|
116
|
+
self.raw_agent_id = raw_agent_id
|
|
117
|
+
super().__init__(
|
|
118
|
+
f"toolgovern rejected a malformed agent_id: {raw_agent_id!r}. It must be a "
|
|
119
|
+
"non-empty string, no longer than 256 characters, with no control characters. "
|
|
120
|
+
"This is a format check only -- it does not verify the caller actually is the "
|
|
121
|
+
"agent it claims to be."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class GovernToolOptions:
|
|
127
|
+
"""``options`` is a ``Policy`` (whether hand-written inline or returned by
|
|
128
|
+
``load_policy()``) plus optional runtime wiring (``scope_registry``, ``trace``, approval
|
|
129
|
+
handling)."""
|
|
130
|
+
|
|
131
|
+
scope: ScopeDeclaration
|
|
132
|
+
policy: Optional[str] = None
|
|
133
|
+
name: Optional[str] = None
|
|
134
|
+
rules: Optional[RuleOverrides] = None
|
|
135
|
+
default_decision: Decision = "allow"
|
|
136
|
+
agent_id: Optional[str] = None
|
|
137
|
+
session_id: Optional[str] = None
|
|
138
|
+
coordinator_id: Optional[str] = None
|
|
139
|
+
|
|
140
|
+
scope_registry: Optional[ScopeRegistry] = None
|
|
141
|
+
trace: Optional[TraceWriter] = None
|
|
142
|
+
on_approval_required: Optional[ApprovalHandler] = None
|
|
143
|
+
"""Called only for require-approval decisions. Return True to allow the call through,
|
|
144
|
+
False to deny it -- or an ApprovalOutcome to also record who decided. Omitted entirely
|
|
145
|
+
means every require-approval decision is denied (fail-closed) -- there is no such thing as
|
|
146
|
+
an implicit approval."""
|
|
147
|
+
approval_timeout_ms: int = _DEFAULT_APPROVAL_TIMEOUT_MS
|
|
148
|
+
pending_approvals: Optional[PendingApprovalRegistry] = None
|
|
149
|
+
"""Optional durable registry for require-approval decisions. When supplied, every
|
|
150
|
+
require-approval decision is persisted here (via register_pending()) BEFORE
|
|
151
|
+
on_approval_required (if any) is invoked -- so a caller who is not the in-process handler (a
|
|
152
|
+
webhook, a CLI command, a human review queue) can resolve the same decision later via
|
|
153
|
+
resolve_pending(), independent of this call's own synchronous window. Once the synchronous
|
|
154
|
+
path genuinely answers (a real handler decision, not a fail-closed default), that outcome is
|
|
155
|
+
reflected back into the registry so the entry reads 'resolved' and a later out-of-band
|
|
156
|
+
resolve_pending() call correctly gets 'already-resolved' rather than re-deciding (or, for a
|
|
157
|
+
side-effecting tool, re-executing) the same call twice. A fail-closed default (no handler, a
|
|
158
|
+
timeout, or a throwing handler) deliberately does NOT close out the registry entry -- see
|
|
159
|
+
execute()'s wiring below and resume_pending_approval(). Omitted entirely -- the default --
|
|
160
|
+
leaves govern_tool()'s behavior completely unchanged."""
|
|
161
|
+
on_decision: Optional[Callable[[GateDecisionInfo], None]] = None
|
|
162
|
+
"""Fires after every gate decision, allow/deny/require-approval alike, after the trace
|
|
163
|
+
entry (if any) has been written."""
|
|
164
|
+
on_tool_result: Optional[Callable[[Any, RuleContext], Any]] = None
|
|
165
|
+
"""Optional post-execution hook. Once a call is allowed and tool.execute() has run (or
|
|
166
|
+
raised), the raw result -- or the raised exception, if execute() raised -- is passed
|
|
167
|
+
through this function before anything is returned to the caller."""
|
|
168
|
+
idempotency: Optional[IdempotencyOptions] = None
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def from_policy(cls, policy: Policy, **overrides: Any) -> "GovernToolOptions":
|
|
172
|
+
return cls(
|
|
173
|
+
scope=policy.scope,
|
|
174
|
+
policy=policy.policy,
|
|
175
|
+
name=policy.name,
|
|
176
|
+
rules=policy.rules,
|
|
177
|
+
default_decision=policy.default_decision,
|
|
178
|
+
agent_id=policy.agent_id,
|
|
179
|
+
session_id=policy.session_id,
|
|
180
|
+
coordinator_id=policy.coordinator_id,
|
|
181
|
+
**overrides,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _resolve_effective_scope(options: GovernToolOptions, agent_id: str, session_id: str) -> ScopeDeclaration:
|
|
186
|
+
if not options.scope_registry:
|
|
187
|
+
return options.scope
|
|
188
|
+
|
|
189
|
+
existing = options.scope_registry.get_record(agent_id)
|
|
190
|
+
if existing:
|
|
191
|
+
return existing.granted_scope
|
|
192
|
+
|
|
193
|
+
if options.coordinator_id:
|
|
194
|
+
return options.scope_registry.spawn_sub_agent(
|
|
195
|
+
SpawnSubAgentParams(
|
|
196
|
+
coordinator_id=options.coordinator_id,
|
|
197
|
+
sub_agent_id=agent_id,
|
|
198
|
+
session_id=session_id,
|
|
199
|
+
requested_scope=options.scope,
|
|
200
|
+
)
|
|
201
|
+
).granted_scope
|
|
202
|
+
return options.scope_registry.register_root_agent(agent_id, session_id, options.scope).granted_scope
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _normalize_approval_result(result: ApprovalHandlerResult) -> ApprovalOutcome:
|
|
206
|
+
if isinstance(result, bool):
|
|
207
|
+
return ApprovalOutcome(approved=result)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class _ApprovalResolution:
|
|
213
|
+
"""What actually happened when govern_tool() tried to resolve a require-approval decision
|
|
214
|
+
through the synchronous in-process path. ``answered=True`` means ``handler`` itself genuinely
|
|
215
|
+
produced a result before the timeout -- a real decision, whether allow or deny. ``answered=
|
|
216
|
+
False`` covers every case where nothing genuine came back: no handler was provided, the
|
|
217
|
+
handler raised, or it simply didn't return before ``timeout_ms``. This distinction is what
|
|
218
|
+
lets ``execute()`` decide whether a ``pending_approvals`` registry entry should be closed out
|
|
219
|
+
as terminally resolved (a real decision was made) or left 'pending' for a later out-of-band
|
|
220
|
+
resolve_pending()/resume_pending_approval() call to actually resolve. Either way, THIS
|
|
221
|
+
execute() invocation still fails closed (denies) when answered is False, exactly as before
|
|
222
|
+
this distinction existed -- only the registry's own bookkeeping changes."""
|
|
223
|
+
|
|
224
|
+
outcome: ApprovalOutcome
|
|
225
|
+
answered: bool
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_approval(
|
|
229
|
+
handler: Optional[ApprovalHandler], info: GateDecisionInfo, timeout_ms: int
|
|
230
|
+
) -> _ApprovalResolution:
|
|
231
|
+
if not handler:
|
|
232
|
+
return _ApprovalResolution(outcome=ApprovalOutcome(approved=False), answered=False)
|
|
233
|
+
|
|
234
|
+
# A handler that raises must fail closed exactly like "no handler" or "timed out" -- it
|
|
235
|
+
# must NOT propagate out of govern_tool(), because that would skip the trace-append call
|
|
236
|
+
# below and surface a raw, unrelated error instead of ToolGovernDenialError. An
|
|
237
|
+
# unanswerable approval request is a denial, not an application crash.
|
|
238
|
+
result_box: Dict[str, _ApprovalResolution] = {}
|
|
239
|
+
|
|
240
|
+
def _run() -> None:
|
|
241
|
+
try:
|
|
242
|
+
result = handler(info)
|
|
243
|
+
result_box["resolution"] = _ApprovalResolution(
|
|
244
|
+
outcome=_normalize_approval_result(result), answered=True
|
|
245
|
+
)
|
|
246
|
+
except Exception:
|
|
247
|
+
result_box["resolution"] = _ApprovalResolution(
|
|
248
|
+
outcome=ApprovalOutcome(approved=False), answered=False
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
thread = threading.Thread(target=_run, daemon=True)
|
|
252
|
+
thread.start()
|
|
253
|
+
thread.join(timeout_ms / 1000)
|
|
254
|
+
if thread.is_alive():
|
|
255
|
+
# Timed out -- fail closed. The handler thread is left to finish in the background
|
|
256
|
+
# (daemon=True), but its eventual result is discarded.
|
|
257
|
+
return _ApprovalResolution(outcome=ApprovalOutcome(approved=False), answered=False)
|
|
258
|
+
return result_box.get(
|
|
259
|
+
"resolution", _ApprovalResolution(outcome=ApprovalOutcome(approved=False), answered=False)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def govern_tool(tool: ToolDefinition, options: GovernToolOptions) -> ToolDefinition:
|
|
264
|
+
"""Wraps ``tool`` so every call is evaluated by the classifier before it executes."""
|
|
265
|
+
# agent_id is a caller-asserted string, never cryptographically verified (see
|
|
266
|
+
# docs/security-model.md). What we CAN do here is reject a malformed one outright, and
|
|
267
|
+
# record whether this call's agent_id was explicitly supplied or fell back to the default.
|
|
268
|
+
if options.agent_id is not None and not is_valid_agent_id(options.agent_id):
|
|
269
|
+
raise InvalidAgentIdError(options.agent_id)
|
|
270
|
+
agent_id_source: AgentIdSource = "explicit" if options.agent_id is not None else "fallback"
|
|
271
|
+
agent_id = options.agent_id if options.agent_id is not None else "default-agent"
|
|
272
|
+
session_id = options.session_id if options.session_id is not None else "default-session"
|
|
273
|
+
coordinator_id = options.coordinator_id
|
|
274
|
+
disabled_rules = list(options.rules.disable) if options.rules else []
|
|
275
|
+
downgrade_to_approval = list(options.rules.require_approval) if options.rules else []
|
|
276
|
+
default_decision = options.default_decision or "allow"
|
|
277
|
+
approval_timeout_ms = options.approval_timeout_ms
|
|
278
|
+
# Scoped to this one gated tool instance -- never shared globally across every gate in a
|
|
279
|
+
# process.
|
|
280
|
+
idempotency_cache: Optional[IdempotencyCache] = (
|
|
281
|
+
IdempotencyCache(options.idempotency.ttl_ms) if options.idempotency and options.idempotency.enabled else None
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def execute(args: Mapping[str, Any]) -> Any:
|
|
285
|
+
effective_scope = _resolve_effective_scope(options, agent_id, session_id)
|
|
286
|
+
|
|
287
|
+
rule_context = RuleContext(
|
|
288
|
+
agent_id=agent_id,
|
|
289
|
+
session_id=session_id,
|
|
290
|
+
coordinator_id=coordinator_id,
|
|
291
|
+
tool=tool.name,
|
|
292
|
+
args=args,
|
|
293
|
+
scope=effective_scope,
|
|
294
|
+
scope_registry=options.scope_registry,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
classifier_result = classify(
|
|
298
|
+
rule_context,
|
|
299
|
+
ClassifyOptions(disabled_rules=disabled_rules, downgrade_to_approval=downgrade_to_approval),
|
|
300
|
+
)
|
|
301
|
+
decision: Decision = classifier_result.decision
|
|
302
|
+
fired_rules = classifier_result.fired_rules
|
|
303
|
+
|
|
304
|
+
# A default_decision other than "allow" only applies when the classifier found nothing
|
|
305
|
+
# to flag -- it never overrides an explicit rule verdict.
|
|
306
|
+
if len(fired_rules) == 0 and default_decision != "allow":
|
|
307
|
+
decision = default_decision
|
|
308
|
+
|
|
309
|
+
# Registered BEFORE on_approval_required is invoked (or even looked at), so a durable
|
|
310
|
+
# record of this decision exists regardless of whether the synchronous handler answers,
|
|
311
|
+
# times out, raises, or was never provided at all -- see pending_approvals above.
|
|
312
|
+
pending_id: Optional[str] = None
|
|
313
|
+
if decision == "require-approval" and options.pending_approvals:
|
|
314
|
+
pending_id = options.pending_approvals.register_pending(
|
|
315
|
+
agent_id=agent_id,
|
|
316
|
+
session_id=session_id,
|
|
317
|
+
coordinator_id=coordinator_id,
|
|
318
|
+
tool=tool.name,
|
|
319
|
+
args=args,
|
|
320
|
+
scope=effective_scope,
|
|
321
|
+
fired_rules=fired_rules,
|
|
322
|
+
agent_id_source=agent_id_source,
|
|
323
|
+
disabled_rules=disabled_rules,
|
|
324
|
+
downgrade_to_approval=downgrade_to_approval,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
info = GateDecisionInfo(
|
|
328
|
+
agent_id=agent_id,
|
|
329
|
+
session_id=session_id,
|
|
330
|
+
coordinator_id=coordinator_id,
|
|
331
|
+
tool=tool.name,
|
|
332
|
+
args=args,
|
|
333
|
+
decision=decision,
|
|
334
|
+
fired_rules=fired_rules,
|
|
335
|
+
scope=effective_scope,
|
|
336
|
+
pending_id=pending_id,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
final_decision: Decision = decision
|
|
340
|
+
approved_by: Optional[str] = None
|
|
341
|
+
if decision == "require-approval":
|
|
342
|
+
resolution = _resolve_approval(options.on_approval_required, info, approval_timeout_ms)
|
|
343
|
+
final_decision = "allow" if resolution.outcome.approved else "deny"
|
|
344
|
+
approved_by = resolution.outcome.approved_by
|
|
345
|
+
|
|
346
|
+
# Only reflect this outcome back into the durable registry when the synchronous
|
|
347
|
+
# handler actually, genuinely answered -- a real decision, allow or deny, is terminal:
|
|
348
|
+
# a later resolve_pending()/resume_pending_approval() call must get
|
|
349
|
+
# 'already-resolved', never a chance to re-decide or (for a side-effecting tool)
|
|
350
|
+
# re-execute a call this process already finished with.
|
|
351
|
+
#
|
|
352
|
+
# When NOTHING genuinely answered (no handler, a raising handler, or a timeout), THIS
|
|
353
|
+
# execute() call still fails closed exactly as before -- but the registry entry is
|
|
354
|
+
# deliberately left 'pending', so the real approval can still arrive later, out of
|
|
355
|
+
# band, via resolve_pending()/resume_pending_approval(). Reflecting a fail-closed
|
|
356
|
+
# default back as if it were a real decision would make that async path permanently
|
|
357
|
+
# unreachable.
|
|
358
|
+
if pending_id and options.pending_approvals and resolution.answered:
|
|
359
|
+
options.pending_approvals.resolve_pending(
|
|
360
|
+
pending_id,
|
|
361
|
+
ResolvePendingInput(decision=final_decision, approved_by=approved_by),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if options.trace:
|
|
365
|
+
if fired_rules:
|
|
366
|
+
rule_fired_ids = [r.rule_id for r in fired_rules]
|
|
367
|
+
elif decision != "allow":
|
|
368
|
+
rule_fired_ids = ["policy-default-decision"]
|
|
369
|
+
else:
|
|
370
|
+
rule_fired_ids = []
|
|
371
|
+
|
|
372
|
+
options.trace.append(
|
|
373
|
+
TraceEntryInput(
|
|
374
|
+
session_id=session_id,
|
|
375
|
+
agent_id=agent_id,
|
|
376
|
+
tool=tool.name,
|
|
377
|
+
args=args,
|
|
378
|
+
decision=final_decision,
|
|
379
|
+
rule_fired=rule_fired_ids,
|
|
380
|
+
declared_scope=effective_scope,
|
|
381
|
+
approved_by=approved_by,
|
|
382
|
+
agent_id_source=agent_id_source,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if options.on_decision:
|
|
387
|
+
options.on_decision(info)
|
|
388
|
+
|
|
389
|
+
if final_decision == "deny":
|
|
390
|
+
raise ToolGovernDenialError(info)
|
|
391
|
+
|
|
392
|
+
# A raised execute() (whether run directly or via the idempotency cache below) is
|
|
393
|
+
# caught here rather than left to propagate directly, so on_tool_result (when
|
|
394
|
+
# provided) gets a chance to see it before anything reaches the caller. With no
|
|
395
|
+
# on_tool_result, behavior is unchanged: a caught error is simply re-raised.
|
|
396
|
+
try:
|
|
397
|
+
if idempotency_cache:
|
|
398
|
+
result = idempotency_cache.claim_if_absent(
|
|
399
|
+
IdempotencyCache.key_for(tool.name, args), lambda: tool.execute(args)
|
|
400
|
+
)
|
|
401
|
+
else:
|
|
402
|
+
result = tool.execute(args)
|
|
403
|
+
return options.on_tool_result(result, rule_context) if options.on_tool_result else result
|
|
404
|
+
except Exception as error:
|
|
405
|
+
if options.on_tool_result:
|
|
406
|
+
return options.on_tool_result(error, rule_context)
|
|
407
|
+
raise
|
|
408
|
+
|
|
409
|
+
return ToolDefinition(name=tool.name, execute=execute)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class PendingApprovalNotResolvableError(Exception):
|
|
413
|
+
"""Raised by ``resume_pending_approval()`` when the ``pending_id`` it was given cannot be
|
|
414
|
+
resolved to a fresh, actionable decision -- either because it (or its alias) is unrecognized,
|
|
415
|
+
because it was already resolved by an earlier call (the synchronous path answering, or a
|
|
416
|
+
previous resume), or because it expired. This is deliberately a different error from
|
|
417
|
+
``ToolGovernDenialError``: a denial is a real classifier/human verdict on the call; this is
|
|
418
|
+
"there was nothing here left to resolve," which callers (a webhook handler, say) generally
|
|
419
|
+
need to handle differently (e.g. respond 409/404 rather than "your request was denied")."""
|
|
420
|
+
|
|
421
|
+
def __init__(self, pending_id: str, status: str) -> None:
|
|
422
|
+
self.pending_id = pending_id
|
|
423
|
+
self.status = status
|
|
424
|
+
super().__init__(
|
|
425
|
+
f"toolgovern: pending approval {pending_id!r} could not be resolved ({status})."
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass
|
|
430
|
+
class ResumePendingApprovalOptions:
|
|
431
|
+
"""Optional wiring resume_pending_approval() accepts -- deliberately the same shape of
|
|
432
|
+
trace/on_decision/on_tool_result options govern_tool() itself accepts, so a caller resuming a
|
|
433
|
+
pending approval gets the same trace/observability behavior as the original synchronous call
|
|
434
|
+
would have."""
|
|
435
|
+
|
|
436
|
+
trace: Optional[TraceWriter] = None
|
|
437
|
+
on_decision: Optional[Callable[[GateDecisionInfo], None]] = None
|
|
438
|
+
on_tool_result: Optional[Callable[[Any, RuleContext], Any]] = None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def resume_pending_approval(
|
|
442
|
+
tool: ToolDefinition,
|
|
443
|
+
registry: PendingApprovalRegistry,
|
|
444
|
+
pending_id: str,
|
|
445
|
+
resolution: ResolvePendingInput,
|
|
446
|
+
options: Optional[ResumePendingApprovalOptions] = None,
|
|
447
|
+
) -> Any:
|
|
448
|
+
"""Closes the loop ``pending_approvals`` opens: given the SAME ``tool`` definition
|
|
449
|
+
``govern_tool()`` was originally wrapping, a ``PendingApprovalRegistry`` that call registered
|
|
450
|
+
its require-approval decision in, the ``pending_id`` it was given back, and a resolution
|
|
451
|
+
(allow/deny, optionally with ``edited_args``), this resolves the pending approval and -- if
|
|
452
|
+
and only if the resolution (after any edited-args re-classification) comes back allow --
|
|
453
|
+
actually invokes ``tool.execute()`` with the effective arguments, appends one trace entry with
|
|
454
|
+
``approved_by`` populated exactly as the synchronous path does, and returns the tool's result.
|
|
455
|
+
|
|
456
|
+
This is the piece of the "durable, resumable approval" story that runs OUTSIDE the original
|
|
457
|
+
``govern_tool(...).execute()`` call -- from a webhook handler, a CLI command, or a long-running
|
|
458
|
+
human review queue's worker loop, any time after that original call already returned (denied,
|
|
459
|
+
most likely, if nothing answered its synchronous window). It does not, and cannot, resume that
|
|
460
|
+
ORIGINAL execute() call itself -- that call already returned. What it does is perform the
|
|
461
|
+
actual, real, gated execution the human's later decision authorizes, through the identical
|
|
462
|
+
classify -> gate -> execute -> trace pipeline, so an edited/approved call still cannot skip
|
|
463
|
+
re-classification and still produces a real audit trail.
|
|
464
|
+
|
|
465
|
+
Raises ``PendingApprovalNotResolvableError`` if the pending approval is unrecognized, already
|
|
466
|
+
resolved, or expired -- never silently does nothing. Raises ``ToolGovernDenialError`` if the
|
|
467
|
+
resolution (or its edited-args re-classification) is a deny -- ``tool.execute()`` is never
|
|
468
|
+
called in that case, exactly like a live ``govern_tool()`` denial.
|
|
469
|
+
"""
|
|
470
|
+
options = options or ResumePendingApprovalOptions()
|
|
471
|
+
pending = registry.get(pending_id)
|
|
472
|
+
outcome = registry.resolve_pending(pending_id, resolution)
|
|
473
|
+
|
|
474
|
+
if outcome.status != "resolved":
|
|
475
|
+
raise PendingApprovalNotResolvableError(pending_id, outcome.status)
|
|
476
|
+
|
|
477
|
+
effective_args = outcome.args if outcome.args is not None else (pending.args if pending else {})
|
|
478
|
+
fired_rules = outcome.fired_rules if outcome.fired_rules is not None else (
|
|
479
|
+
pending.fired_rules if pending else []
|
|
480
|
+
)
|
|
481
|
+
scope = pending.scope if pending else ScopeDeclaration()
|
|
482
|
+
final_decision: Decision = "allow" if outcome.final_decision == "allow" else "deny"
|
|
483
|
+
|
|
484
|
+
info = GateDecisionInfo(
|
|
485
|
+
agent_id=pending.agent_id if pending else "default-agent",
|
|
486
|
+
session_id=pending.session_id if pending else "default-session",
|
|
487
|
+
coordinator_id=pending.coordinator_id if pending else None,
|
|
488
|
+
tool=pending.tool if pending else tool.name,
|
|
489
|
+
args=effective_args,
|
|
490
|
+
decision=final_decision,
|
|
491
|
+
fired_rules=fired_rules,
|
|
492
|
+
scope=scope,
|
|
493
|
+
pending_id=pending_id,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if options.trace:
|
|
497
|
+
if fired_rules:
|
|
498
|
+
rule_fired_ids = [r.rule_id for r in fired_rules]
|
|
499
|
+
elif final_decision != "allow":
|
|
500
|
+
rule_fired_ids = ["policy-default-decision"]
|
|
501
|
+
else:
|
|
502
|
+
rule_fired_ids = []
|
|
503
|
+
options.trace.append(
|
|
504
|
+
TraceEntryInput(
|
|
505
|
+
session_id=info.session_id,
|
|
506
|
+
agent_id=info.agent_id,
|
|
507
|
+
tool=info.tool,
|
|
508
|
+
args=effective_args,
|
|
509
|
+
decision=final_decision,
|
|
510
|
+
rule_fired=rule_fired_ids,
|
|
511
|
+
declared_scope=scope,
|
|
512
|
+
approved_by=outcome.approved_by,
|
|
513
|
+
agent_id_source=pending.agent_id_source if pending else None,
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
if options.on_decision:
|
|
518
|
+
options.on_decision(info)
|
|
519
|
+
|
|
520
|
+
if final_decision == "deny":
|
|
521
|
+
raise ToolGovernDenialError(info)
|
|
522
|
+
|
|
523
|
+
rule_context = RuleContext(
|
|
524
|
+
agent_id=info.agent_id,
|
|
525
|
+
session_id=info.session_id,
|
|
526
|
+
coordinator_id=info.coordinator_id,
|
|
527
|
+
tool=info.tool,
|
|
528
|
+
args=effective_args,
|
|
529
|
+
scope=scope,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
result = tool.execute(effective_args)
|
|
534
|
+
return options.on_tool_result(result, rule_context) if options.on_tool_result else result
|
|
535
|
+
except Exception as error:
|
|
536
|
+
if options.on_tool_result:
|
|
537
|
+
return options.on_tool_result(error, rule_context)
|
|
538
|
+
raise
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .load_policy import PolicyValidationError, load_policy
|
|
2
|
+
from .validate_policy import PolicyValidationResult, as_policy, validate_policy
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"load_policy",
|
|
6
|
+
"PolicyValidationError",
|
|
7
|
+
"validate_policy",
|
|
8
|
+
"as_policy",
|
|
9
|
+
"PolicyValidationResult",
|
|
10
|
+
]
|