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.
Files changed (36) hide show
  1. toolgovern/__init__.py +222 -0
  2. toolgovern/approval/__init__.py +25 -0
  3. toolgovern/approval/pending_registry.py +379 -0
  4. toolgovern/classifier/__init__.py +19 -0
  5. toolgovern/classifier/credential_access.py +180 -0
  6. toolgovern/classifier/cross_agent_inheritance.py +216 -0
  7. toolgovern/classifier/filesystem_scope.py +202 -0
  8. toolgovern/classifier/index.py +80 -0
  9. toolgovern/classifier/information_flow.py +154 -0
  10. toolgovern/classifier/network_egress.py +289 -0
  11. toolgovern/classifier/shell_risk.py +357 -0
  12. toolgovern/classifier/util.py +259 -0
  13. toolgovern/cli.py +298 -0
  14. toolgovern/mcp_trust/__init__.py +342 -0
  15. toolgovern/middleware/__init__.py +30 -0
  16. toolgovern/middleware/idempotency_cache.py +117 -0
  17. toolgovern/middleware/on_tool_call.py +538 -0
  18. toolgovern/policy/__init__.py +10 -0
  19. toolgovern/policy/load_policy.py +41 -0
  20. toolgovern/policy/validate_policy.py +106 -0
  21. toolgovern/py.typed +0 -0
  22. toolgovern/scoping/__init__.py +13 -0
  23. toolgovern/scoping/inheritance_enforcer.py +145 -0
  24. toolgovern/scoping/scope_declaration.py +84 -0
  25. toolgovern/shared/__init__.py +0 -0
  26. toolgovern/shared/paths.py +266 -0
  27. toolgovern/trace/__init__.py +33 -0
  28. toolgovern/trace/canonical_json.py +27 -0
  29. toolgovern/trace/trace_reader.py +206 -0
  30. toolgovern/trace/trace_writer.py +247 -0
  31. toolgovern/types.py +247 -0
  32. toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
  33. toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
  34. toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
  35. toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
  36. 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
+ ]