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
toolgovern/__init__.py ADDED
@@ -0,0 +1,222 @@
1
+ """toolgovern -- runtime governance middleware for AI agent tool calls.
2
+
3
+ A gated call never reaches the wrapped tool's real implementation until the classifier returns
4
+ ``allow``. Every decision is traceable to a specific rule ID; there is no unexplained black-box
5
+ denial. ``govern_tool()`` evaluating a call as ``allow`` means the call was checked against the
6
+ current rule set -- it is not a guarantee the call is safe.
7
+
8
+ This is a genuine Python port of the ``toolgovern`` npm package
9
+ (https://github.com/RudrenduPaul/toolgovern), not a wrapper around the Node binary. It ships the
10
+ same 36-rule classifier (TG01-TG05, plus TG08 information-flow control), the same default-deny
11
+ scope-inheritance model, and the same signed local audit trail (unkeyed sha256 by default,
12
+ optional hmac-sha256 keyed signing).
13
+
14
+ One deliberate divergence from the TS original: TG03's DNS-resolution check
15
+ (``TG03-dns-resolves-private``) runs as an ordinary synchronous member of the one ``classify()``
16
+ rule registry here, because ``govern_tool()`` is synchronous end-to-end in this port and
17
+ ``socket.getaddrinfo()`` is itself a blocking call -- no separate async entry point was needed.
18
+ The TS package instead keeps this specific rule in a separate ``classifyAsync()``-only registry,
19
+ since Node's DNS resolution is Promise-based and the TS rule registry stays at its pre-existing
20
+ 34-rule count for that reason. Same check, same failure-closed behavior, different plumbing
21
+ dictated by each language's own concurrency model -- see ``docs/security-model.md`` for the full
22
+ writeup.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ from .approval import (
30
+ ApprovalResolutionDecision,
31
+ PendingApproval,
32
+ PendingApprovalAliasConflictError,
33
+ PendingApprovalRegistry,
34
+ PendingApprovalResolution,
35
+ PendingApprovalStatus,
36
+ ResolvePendingInput,
37
+ ResolvePendingOutcome,
38
+ ResolvePendingStatus,
39
+ UnknownPendingApprovalError,
40
+ )
41
+ from .classifier import (
42
+ ClassifyOptions,
43
+ classify,
44
+ credential_access_rules,
45
+ cross_agent_inheritance_rules,
46
+ filesystem_scope_rules,
47
+ information_flow_rules,
48
+ network_egress_rules,
49
+ rule_registry,
50
+ shell_risk_rules,
51
+ )
52
+ from .mcp_trust import (
53
+ Algorithm as McpTrustAlgorithm,
54
+ DEFAULT_MANIFEST_FETCH_TIMEOUT_SECONDS,
55
+ FetchImpl as McpManifestFetchImpl,
56
+ McpManifestEnvelope,
57
+ McpServerConnectionRequest,
58
+ McpTrustDecision,
59
+ McpTrustPolicy,
60
+ McpTrustVerdict,
61
+ PinnedPublicKey,
62
+ assert_mcp_server_trusted,
63
+ is_origin_allowed,
64
+ verify_mcp_server_manifest,
65
+ )
66
+ from .middleware import (
67
+ ApprovalHandler,
68
+ ApprovalOutcome,
69
+ GateDecisionInfo,
70
+ GovernToolOptions,
71
+ IdempotencyCache,
72
+ IdempotencyOptions,
73
+ InvalidAgentIdError,
74
+ PendingApprovalNotResolvableError,
75
+ ResumePendingApprovalOptions,
76
+ ToolDefinition,
77
+ ToolGovernDenialError,
78
+ govern_tool,
79
+ resume_pending_approval,
80
+ )
81
+ from .policy import PolicyValidationError, PolicyValidationResult, as_policy, load_policy, validate_policy
82
+ from .scoping import (
83
+ EMPTY_SCOPE,
84
+ ScopeRegistry,
85
+ SpawnSubAgentParams,
86
+ compute_inherited_scope,
87
+ has_zero_capability,
88
+ is_valid_agent_id,
89
+ is_valid_scope_declaration,
90
+ normalize_scope,
91
+ )
92
+ from .trace import (
93
+ ChainVerificationIssue,
94
+ ChainVerificationResult,
95
+ TraceQuery,
96
+ TraceWriter,
97
+ TraceWriterOptions,
98
+ VerifyChainOptions,
99
+ canonical_json,
100
+ compute_entry_content_hash,
101
+ compute_entry_signature,
102
+ filter_trace,
103
+ parse_since,
104
+ read_trace,
105
+ verify_chain,
106
+ )
107
+ from .types import (
108
+ AgentIdSource,
109
+ AgentScopeRecord,
110
+ ClassifierResult,
111
+ ConfidentialityLabel,
112
+ Decision,
113
+ IfcPolicy,
114
+ Policy,
115
+ Rule,
116
+ RuleCategory,
117
+ RuleContext,
118
+ RuleMatch,
119
+ RuleOverrides,
120
+ ScopeDeclaration,
121
+ ScopeRegistryReader,
122
+ TraceEntry,
123
+ TraceEntryInput,
124
+ )
125
+
126
+ __all__ = [
127
+ "__version__",
128
+ # types
129
+ "AgentIdSource",
130
+ "AgentScopeRecord",
131
+ "ClassifierResult",
132
+ "ConfidentialityLabel",
133
+ "Decision",
134
+ "IfcPolicy",
135
+ "Policy",
136
+ "Rule",
137
+ "RuleCategory",
138
+ "RuleContext",
139
+ "RuleMatch",
140
+ "RuleOverrides",
141
+ "ScopeDeclaration",
142
+ "ScopeRegistryReader",
143
+ "TraceEntry",
144
+ "TraceEntryInput",
145
+ # mcp_trust
146
+ "McpTrustAlgorithm",
147
+ "DEFAULT_MANIFEST_FETCH_TIMEOUT_SECONDS",
148
+ "McpManifestFetchImpl",
149
+ "McpManifestEnvelope",
150
+ "McpServerConnectionRequest",
151
+ "McpTrustDecision",
152
+ "McpTrustPolicy",
153
+ "McpTrustVerdict",
154
+ "PinnedPublicKey",
155
+ "assert_mcp_server_trusted",
156
+ "is_origin_allowed",
157
+ "verify_mcp_server_manifest",
158
+ # middleware
159
+ "ApprovalHandler",
160
+ "ApprovalOutcome",
161
+ "GateDecisionInfo",
162
+ "GovernToolOptions",
163
+ "IdempotencyCache",
164
+ "IdempotencyOptions",
165
+ "InvalidAgentIdError",
166
+ "PendingApprovalNotResolvableError",
167
+ "ResumePendingApprovalOptions",
168
+ "ToolDefinition",
169
+ "ToolGovernDenialError",
170
+ "govern_tool",
171
+ "resume_pending_approval",
172
+ # approval
173
+ "ApprovalResolutionDecision",
174
+ "PendingApproval",
175
+ "PendingApprovalAliasConflictError",
176
+ "PendingApprovalRegistry",
177
+ "PendingApprovalResolution",
178
+ "PendingApprovalStatus",
179
+ "ResolvePendingInput",
180
+ "ResolvePendingOutcome",
181
+ "ResolvePendingStatus",
182
+ "UnknownPendingApprovalError",
183
+ # classifier
184
+ "ClassifyOptions",
185
+ "classify",
186
+ "rule_registry",
187
+ "shell_risk_rules",
188
+ "filesystem_scope_rules",
189
+ "network_egress_rules",
190
+ "credential_access_rules",
191
+ "cross_agent_inheritance_rules",
192
+ "information_flow_rules",
193
+ # scoping
194
+ "EMPTY_SCOPE",
195
+ "ScopeRegistry",
196
+ "SpawnSubAgentParams",
197
+ "compute_inherited_scope",
198
+ "has_zero_capability",
199
+ "is_valid_agent_id",
200
+ "is_valid_scope_declaration",
201
+ "normalize_scope",
202
+ # trace
203
+ "TraceWriter",
204
+ "TraceWriterOptions",
205
+ "compute_entry_content_hash",
206
+ "compute_entry_signature",
207
+ "read_trace",
208
+ "filter_trace",
209
+ "parse_since",
210
+ "verify_chain",
211
+ "TraceQuery",
212
+ "ChainVerificationResult",
213
+ "ChainVerificationIssue",
214
+ "VerifyChainOptions",
215
+ "canonical_json",
216
+ # policy
217
+ "load_policy",
218
+ "PolicyValidationError",
219
+ "validate_policy",
220
+ "as_policy",
221
+ "PolicyValidationResult",
222
+ ]
@@ -0,0 +1,25 @@
1
+ from .pending_registry import (
2
+ ApprovalResolutionDecision,
3
+ PendingApproval,
4
+ PendingApprovalAliasConflictError,
5
+ PendingApprovalRegistry,
6
+ PendingApprovalResolution,
7
+ PendingApprovalStatus,
8
+ ResolvePendingInput,
9
+ ResolvePendingOutcome,
10
+ ResolvePendingStatus,
11
+ UnknownPendingApprovalError,
12
+ )
13
+
14
+ __all__ = [
15
+ "ApprovalResolutionDecision",
16
+ "PendingApproval",
17
+ "PendingApprovalAliasConflictError",
18
+ "PendingApprovalRegistry",
19
+ "PendingApprovalResolution",
20
+ "PendingApprovalStatus",
21
+ "ResolvePendingInput",
22
+ "ResolvePendingOutcome",
23
+ "ResolvePendingStatus",
24
+ "UnknownPendingApprovalError",
25
+ ]
@@ -0,0 +1,379 @@
1
+ """``PendingApprovalRegistry`` -- a durable, keyed record of ``require-approval`` gate decisions
2
+ that outlives a single in-process, 30-second ``on_approval_required`` callback.
3
+
4
+ Ported from ``packages/toolgovern/src/approval/pending-registry.ts``. Python's ``govern_tool()``
5
+ is synchronous end-to-end (see ``middleware/on_tool_call.py``'s module docstring), so this port is
6
+ synchronous throughout too -- no ``asyncio`` was needed, matching the TS-vs-Python split already
7
+ established for ``classify()``/``classifyAsync()``.
8
+
9
+ Today's ``govern_tool()`` treats a ``require-approval`` decision as something that must be
10
+ answered synchronously, in-process, before its own ``execute()`` call returns. That is a real,
11
+ useful default, and this registry does not remove it -- it adds a second, independent path: every
12
+ ``require-approval`` decision is also persisted here as a ``PendingApproval``, keyed by a
13
+ server-generated ``pending_id``, so a caller who is NOT the original in-process handler -- a
14
+ webhook receiving a Slack button click, a CLI command, a long-running human review queue polling
15
+ for pending items -- can look it up and resolve it later, on its own schedule, via
16
+ ``resolve_pending()``.
17
+
18
+ Three design decisions here are load-bearing, each anchored to a real shipped bug or finding:
19
+
20
+ 1. **``pending_id`` is always server-generated, never caller-supplied.** ``register_pending()``
21
+ mints the ID and hands it back; there is no way to register (or resolve) a pending approval
22
+ under an ID the caller chose. This directly closes the bypass Corridor's security bot found in
23
+ langchain-ai/langgraph#8169's ``human_approval()`` helper: that implementation read its resume
24
+ token (``resume_command_id``) out of the *untrusted resume payload* and, when the ID was
25
+ unrecognized, silently created a brand-new pending decision for it instead of failing closed --
26
+ so a caller who could resume an interrupted graph could mint a fresh ID and turn an
27
+ expired/cancelled/mismatched approval into an approvable one. ``resolve_pending()`` below never
28
+ creates an entry for an unrecognized ID; an unknown ``pending_id`` is ``"not-found"``, full
29
+ stop.
30
+
31
+ 2. **Alias tolerance for the same pending approval.** ``register_alias()`` lets a caller record
32
+ that some other identifier (a rewritten thread ID, a provider-issued conversation ID) now also
33
+ refers to an already-registered ``pending_id``; ``get()`` and ``resolve_pending()`` accept
34
+ either the original ID or any registered alias. This models the fix in
35
+ microsoft/agent-framework#6908 ("Python: Fix AG-UI approval thread aliases"): a stateful
36
+ provider (Foundry) streamed back a new conversation ID mid-thread, and the approval had been
37
+ registered only under the original client thread ID, so a client resuming with that original ID
38
+ could never find its own pending approval.
39
+
40
+ 3. **Edited arguments are re-classified, never smuggled through on the strength of the original
41
+ approval.** ``resolve_pending()`` accepts ``edited_args``; when supplied alongside an
42
+ ``"allow"`` decision, the edited arguments are run back through the classifier (the same
43
+ ``classify()`` used by ``govern_tool()``, with the same rule overrides active at registration
44
+ time) before the resolution is accepted. A re-classification that still comes back non-``allow``
45
+ overrides the human's ``"allow"`` -- approving a call is not a license to edit its arguments
46
+ into something riskier and have that edit wave through unchecked.
47
+
48
+ What this registry deliberately does NOT do: it does not itself execute a tool, and it does not
49
+ itself write to a ``TraceWriter``. It is a pure state machine over pending approvals.
50
+ ``resume_pending_approval()`` in ``middleware/on_tool_call.py`` is the piece that actually closes
51
+ the loop.
52
+
53
+ This is also, by construction, in-memory only: a plain dict, scoped to one process. A production
54
+ deployment that needs the pending-approval record to survive a process restart, or to be resolved
55
+ from a different process than the one that registered it (the webhook case this whole feature is
56
+ aimed at), must back this with real durable storage behind the same interface -- that persistence
57
+ layer is out of scope for this pass.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import threading
63
+ import time
64
+ import uuid
65
+ from dataclasses import dataclass, field
66
+ from typing import Any, Callable, Dict, Literal, Mapping, Optional, Sequence, Set
67
+
68
+ from ..classifier import ClassifyOptions, classify
69
+ from ..types import (
70
+ AgentIdSource,
71
+ ClassifierResult,
72
+ Decision,
73
+ RuleContext,
74
+ RuleMatch,
75
+ ScopeDeclaration,
76
+ )
77
+
78
+ # The two terminal decisions a pending approval can be resolved to. "require-approval" is never a
79
+ # valid resolution -- something either ends up allowed or denied.
80
+ ApprovalResolutionDecision = Literal["allow", "deny"]
81
+
82
+ PendingApprovalStatus = Literal["pending", "resolved", "expired"]
83
+ ResolvePendingStatus = Literal["resolved", "not-found", "already-resolved", "expired"]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class PendingApprovalResolution:
88
+ """What a resolved pending approval recorded about its own resolution."""
89
+
90
+ decision: ApprovalResolutionDecision
91
+ resolved_at: float
92
+ approved_by: Optional[str] = None
93
+ edited_args: Optional[Mapping[str, Any]] = None
94
+ # Present only when edited_args was supplied and actually re-classified.
95
+ reclassified: Optional[ClassifierResult] = None
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class PendingApproval:
100
+ """The public, read-only view of one registered pending approval, as returned by ``get()``."""
101
+
102
+ pending_id: str
103
+ agent_id: str
104
+ session_id: str
105
+ tool: str
106
+ args: Mapping[str, Any]
107
+ scope: ScopeDeclaration
108
+ fired_rules: Sequence[RuleMatch]
109
+ status: PendingApprovalStatus
110
+ created_at: float
111
+ aliases: Sequence[str]
112
+ coordinator_id: Optional[str] = None
113
+ agent_id_source: Optional[AgentIdSource] = None
114
+ expires_at: Optional[float] = None
115
+ resolution: Optional[PendingApprovalResolution] = None
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class ResolvePendingInput:
120
+ decision: ApprovalResolutionDecision
121
+ approved_by: Optional[str] = None
122
+ # Edited arguments to approve/deny instead of the originally registered args. When present
123
+ # together with decision="allow", the edited arguments are re-run through the classifier
124
+ # before the resolution is accepted -- see the module docstring, point 3.
125
+ edited_args: Optional[Mapping[str, Any]] = None
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ResolvePendingOutcome:
130
+ status: ResolvePendingStatus
131
+ # Echoes back whatever ID/alias the caller resolved with -- NOT necessarily the canonical
132
+ # pending_id, when status is "not-found".
133
+ pending_id: str
134
+ final_decision: Optional[ApprovalResolutionDecision] = None
135
+ approved_by: Optional[str] = None
136
+ args: Optional[Mapping[str, Any]] = None
137
+ fired_rules: Optional[Sequence[RuleMatch]] = None
138
+
139
+
140
+ class UnknownPendingApprovalError(Exception):
141
+ """Raised by ``register_alias()`` when asked to alias an ID/alias with no registered entry.
142
+ An alias must always point at a real, already-registered pending approval -- silently
143
+ accepting one for an unknown ID would let a caller plant a phantom entry that later resolves
144
+ as if it had gone through the classifier, which it never did."""
145
+
146
+ def __init__(self, pending_id: str) -> None:
147
+ self.pending_id = pending_id
148
+ super().__init__(f"toolgovern: no pending approval is registered under id/alias {pending_id!r}.")
149
+
150
+
151
+ class PendingApprovalAliasConflictError(Exception):
152
+ """Raised by ``register_alias()`` when ``alias`` already refers to a *different* pending
153
+ approval than the one being aliased -- silently repointing it would let a second, unrelated
154
+ call's resolution land on the first call's entry."""
155
+
156
+ def __init__(self, alias: str) -> None:
157
+ self.alias = alias
158
+ super().__init__(f"toolgovern: alias {alias!r} already refers to a different pending approval.")
159
+
160
+
161
+ @dataclass
162
+ class _PendingApprovalEntry:
163
+ pending_id: str
164
+ agent_id: str
165
+ session_id: str
166
+ tool: str
167
+ args: Mapping[str, Any]
168
+ scope: ScopeDeclaration
169
+ fired_rules: Sequence[RuleMatch]
170
+ disabled_rules: Sequence[str]
171
+ downgrade_to_approval: Sequence[str]
172
+ created_at: float
173
+ coordinator_id: Optional[str] = None
174
+ agent_id_source: Optional[AgentIdSource] = None
175
+ expires_at: Optional[float] = None
176
+ aliases: Set[str] = field(default_factory=set)
177
+ status: PendingApprovalStatus = "pending"
178
+ resolution: Optional[PendingApprovalResolution] = None
179
+
180
+
181
+ class PendingApprovalRegistry:
182
+ """A keyed, in-memory registry of pending ``require-approval`` gate decisions. See the module
183
+ docstring above for the three bug-shaped design decisions this embodies.
184
+
185
+ Thread-safe: a single lock guards every read/write, since Python's ``govern_tool()`` uses a
186
+ worker thread for its own approval-timeout handling and a durable registry shared across
187
+ threads (a web server's request-handling threads, say) must not race on registration or
188
+ resolution.
189
+ """
190
+
191
+ def __init__(
192
+ self,
193
+ now: Optional[Callable[[], float]] = None,
194
+ id_factory: Optional[Callable[[], str]] = None,
195
+ reclassify: Optional[Callable[[RuleContext, ClassifyOptions], ClassifierResult]] = None,
196
+ ) -> None:
197
+ self._entries: Dict[str, _PendingApprovalEntry] = {}
198
+ # alias -> canonical pending_id. A canonical pending_id is never itself a key in this map
199
+ # -- _resolve_canonical_id() checks _entries first, so a real ID always wins over any alias.
200
+ self._alias_to_canonical: Dict[str, str] = {}
201
+ self._lock = threading.Lock()
202
+ self._now = now or (lambda: time.time() * 1000)
203
+ self._id_factory = id_factory or (lambda: str(uuid.uuid4()))
204
+ self._reclassify = reclassify or classify
205
+
206
+ def register_pending(
207
+ self,
208
+ *,
209
+ agent_id: str,
210
+ session_id: str,
211
+ tool: str,
212
+ args: Mapping[str, Any],
213
+ scope: ScopeDeclaration,
214
+ fired_rules: Sequence[RuleMatch],
215
+ coordinator_id: Optional[str] = None,
216
+ agent_id_source: Optional[AgentIdSource] = None,
217
+ disabled_rules: Optional[Sequence[str]] = None,
218
+ downgrade_to_approval: Optional[Sequence[str]] = None,
219
+ ttl_ms: Optional[int] = None,
220
+ ) -> str:
221
+ """Persists one ``require-approval`` gate decision and returns its server-generated
222
+ ``pending_id``. The caller never supplies (and cannot influence) this ID -- see the
223
+ module docstring, point 1."""
224
+ with self._lock:
225
+ pending_id = self._id_factory()
226
+ created_at = self._now()
227
+ entry = _PendingApprovalEntry(
228
+ pending_id=pending_id,
229
+ agent_id=agent_id,
230
+ session_id=session_id,
231
+ tool=tool,
232
+ args=args,
233
+ scope=scope,
234
+ fired_rules=list(fired_rules),
235
+ disabled_rules=list(disabled_rules) if disabled_rules else [],
236
+ downgrade_to_approval=list(downgrade_to_approval) if downgrade_to_approval else [],
237
+ created_at=created_at,
238
+ coordinator_id=coordinator_id,
239
+ agent_id_source=agent_id_source,
240
+ expires_at=(created_at + ttl_ms) if ttl_ms is not None else None,
241
+ )
242
+ self._entries[pending_id] = entry
243
+ return pending_id
244
+
245
+ def register_alias(self, pending_id: str, alias: str) -> None:
246
+ """Records that ``alias`` now also refers to the pending approval registered under
247
+ ``pending_id`` (which may itself already be an alias). Resolving by ``pending_id`` OR
248
+ ``alias`` afterward reaches the same entry. See the module docstring, point 2
249
+ (microsoft/agent-framework#6908's thread-id-rewrite bug)."""
250
+ with self._lock:
251
+ canonical = self._resolve_canonical_id(pending_id)
252
+ if canonical is None:
253
+ raise UnknownPendingApprovalError(pending_id)
254
+ existing_target = self._resolve_canonical_id(alias)
255
+ if existing_target is not None and existing_target != canonical:
256
+ raise PendingApprovalAliasConflictError(alias)
257
+ self._entries[canonical].aliases.add(alias)
258
+ self._alias_to_canonical[alias] = canonical
259
+
260
+ def get(self, pending_id_or_alias: str) -> Optional[PendingApproval]:
261
+ """Looks up a pending approval by its ``pending_id`` OR any registered alias. Returns
262
+ ``None`` for anything unrecognized -- never fabricates an entry."""
263
+ with self._lock:
264
+ canonical = self._resolve_canonical_id(pending_id_or_alias)
265
+ if canonical is None:
266
+ return None
267
+ return self._to_public(self._entries[canonical])
268
+
269
+ def resolve_pending(
270
+ self, pending_id_or_alias: str, resolution: ResolvePendingInput
271
+ ) -> ResolvePendingOutcome:
272
+ """Resolves a pending approval, by ``pending_id`` or any registered alias, to a terminal
273
+ decision.
274
+
275
+ - An unrecognized ``pending_id_or_alias`` returns ``status="not-found"`` -- it is NEVER
276
+ treated as a fresh grant to be created on the spot. See the module docstring, point 1
277
+ (langchain-ai/langgraph#8169's resume-token bypass).
278
+ - An already-resolved entry returns ``status="already-resolved"`` with the *original*
279
+ resolution's outcome -- resolving twice can never flip a decision or re-trigger
280
+ execution.
281
+ - An expired entry (past ``ttl_ms``) returns ``status="expired"`` and is marked
282
+ ``"expired"``, never resolvable afterward.
283
+ - Otherwise, the entry is resolved. If ``edited_args`` is supplied together with
284
+ ``decision="allow"``, the edited arguments are re-run through the classifier (the same
285
+ rule overrides captured at registration time); any result other than ``"allow"``
286
+ overrides the human's ``"allow"`` down to ``"deny"``.
287
+ """
288
+ with self._lock:
289
+ canonical = self._resolve_canonical_id(pending_id_or_alias)
290
+ if canonical is None:
291
+ return ResolvePendingOutcome(status="not-found", pending_id=pending_id_or_alias)
292
+
293
+ entry = self._entries[canonical]
294
+
295
+ if entry.status == "expired" or (
296
+ entry.expires_at is not None and self._now() > entry.expires_at
297
+ ):
298
+ entry.status = "expired"
299
+ return ResolvePendingOutcome(status="expired", pending_id=canonical)
300
+
301
+ if entry.status == "resolved":
302
+ prior = entry.resolution
303
+ assert prior is not None
304
+ return ResolvePendingOutcome(
305
+ status="already-resolved",
306
+ pending_id=canonical,
307
+ final_decision=prior.decision,
308
+ approved_by=prior.approved_by,
309
+ args=prior.edited_args if prior.edited_args is not None else entry.args,
310
+ fired_rules=prior.reclassified.fired_rules if prior.reclassified else None,
311
+ )
312
+
313
+ effective_args = resolution.edited_args if resolution.edited_args is not None else entry.args
314
+ final_decision: ApprovalResolutionDecision = resolution.decision
315
+ reclassified: Optional[ClassifierResult] = None
316
+
317
+ if resolution.edited_args is not None and resolution.decision == "allow":
318
+ # Approving an edit is never itself a bypass -- the edited arguments must clear
319
+ # the same classifier a fresh call would. Anything other than a clean "allow" here
320
+ # (including a fresh "require-approval", which this single resolve step cannot
321
+ # itself re-adjudicate) overrides the human's decision down to "deny", fail-closed.
322
+ ctx = RuleContext(
323
+ agent_id=entry.agent_id,
324
+ session_id=entry.session_id,
325
+ coordinator_id=entry.coordinator_id,
326
+ tool=entry.tool,
327
+ args=resolution.edited_args,
328
+ scope=entry.scope,
329
+ )
330
+ reclassified = self._reclassify(
331
+ ctx,
332
+ ClassifyOptions(
333
+ disabled_rules=entry.disabled_rules,
334
+ downgrade_to_approval=entry.downgrade_to_approval,
335
+ ),
336
+ )
337
+ if reclassified.decision != "allow":
338
+ final_decision = "deny"
339
+
340
+ entry.status = "resolved"
341
+ entry.resolution = PendingApprovalResolution(
342
+ decision=final_decision,
343
+ approved_by=resolution.approved_by,
344
+ resolved_at=self._now(),
345
+ edited_args=resolution.edited_args,
346
+ reclassified=reclassified,
347
+ )
348
+
349
+ return ResolvePendingOutcome(
350
+ status="resolved",
351
+ pending_id=canonical,
352
+ final_decision=final_decision,
353
+ approved_by=resolution.approved_by,
354
+ args=effective_args,
355
+ fired_rules=reclassified.fired_rules if reclassified else None,
356
+ )
357
+
358
+ def _resolve_canonical_id(self, id_or_alias: str) -> Optional[str]:
359
+ if id_or_alias in self._entries:
360
+ return id_or_alias
361
+ return self._alias_to_canonical.get(id_or_alias)
362
+
363
+ def _to_public(self, entry: _PendingApprovalEntry) -> PendingApproval:
364
+ return PendingApproval(
365
+ pending_id=entry.pending_id,
366
+ agent_id=entry.agent_id,
367
+ session_id=entry.session_id,
368
+ tool=entry.tool,
369
+ args=entry.args,
370
+ scope=entry.scope,
371
+ fired_rules=list(entry.fired_rules),
372
+ status=entry.status,
373
+ created_at=entry.created_at,
374
+ aliases=list(entry.aliases),
375
+ coordinator_id=entry.coordinator_id,
376
+ agent_id_source=entry.agent_id_source,
377
+ expires_at=entry.expires_at,
378
+ resolution=entry.resolution,
379
+ )
@@ -0,0 +1,19 @@
1
+ from .credential_access import credential_access_rules
2
+ from .cross_agent_inheritance import cross_agent_inheritance_rules
3
+ from .filesystem_scope import filesystem_scope_rules
4
+ from .index import ClassifyOptions, classify, rule_registry
5
+ from .information_flow import information_flow_rules
6
+ from .network_egress import network_egress_rules
7
+ from .shell_risk import shell_risk_rules
8
+
9
+ __all__ = [
10
+ "ClassifyOptions",
11
+ "classify",
12
+ "rule_registry",
13
+ "shell_risk_rules",
14
+ "filesystem_scope_rules",
15
+ "network_egress_rules",
16
+ "credential_access_rules",
17
+ "cross_agent_inheritance_rules",
18
+ "information_flow_rules",
19
+ ]