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
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
|
+
]
|