toolpass 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolpass/__init__.py +102 -0
- toolpass/_api.py +568 -0
- toolpass/_approval.py +250 -0
- toolpass/_audit.py +75 -0
- toolpass/_checks.py +258 -0
- toolpass/_default.py +39 -0
- toolpass/_permissions.py +53 -0
- toolpass/_rules.py +224 -0
- toolpass/_session.py +120 -0
- toolpass/_toolkit.py +813 -0
- toolpass/_untrusted.py +77 -0
- toolpass/_version.py +1 -0
- toolpass/_walk.py +48 -0
- toolpass/authx/__init__.py +6 -0
- toolpass/authx/awscreds.py +295 -0
- toolpass/authx/awsquery.py +225 -0
- toolpass/authx/google.py +89 -0
- toolpass/authx/jwt.py +283 -0
- toolpass/authx/oauth2.py +180 -0
- toolpass/authx/sigv4.py +271 -0
- toolpass/authx/sts.py +177 -0
- toolpass/authx/token.py +137 -0
- toolpass/authx/util.py +446 -0
- toolpass/authx/xmlutil.py +92 -0
- toolpass/claude_agent_sdk.py +187 -0
- toolpass/cli.py +612 -0
- toolpass/core/__init__.py +0 -0
- toolpass/core/cache.py +413 -0
- toolpass/core/catalog.py +237 -0
- toolpass/core/config.py +680 -0
- toolpass/core/context.py +233 -0
- toolpass/core/decision.py +204 -0
- toolpass/core/declog.py +113 -0
- toolpass/core/duration.py +144 -0
- toolpass/core/engine.py +408 -0
- toolpass/core/errors.py +185 -0
- toolpass/core/evidence.py +324 -0
- toolpass/core/integration.py +497 -0
- toolpass/core/jsonx.py +135 -0
- toolpass/core/log.py +188 -0
- toolpass/core/secret.py +146 -0
- toolpass/core/template.py +126 -0
- toolpass/crewai.py +208 -0
- toolpass/google_adk.py +229 -0
- toolpass/integrations/__init__.py +46 -0
- toolpass/integrations/argocd/__init__.py +341 -0
- toolpass/integrations/argocd/actions.py +160 -0
- toolpass/integrations/argocd/rbac/__init__.py +452 -0
- toolpass/integrations/argocd/rbac/builtin.py +67 -0
- toolpass/integrations/argocd/rbac/glob.py +213 -0
- toolpass/integrations/argocd/rbac/re2.py +595 -0
- toolpass/integrations/aws/__init__.py +9 -0
- toolpass/integrations/aws/actions.py +138 -0
- toolpass/integrations/aws/aws.py +675 -0
- toolpass/integrations/aws/identity.py +484 -0
- toolpass/integrations/azure/__init__.py +9 -0
- toolpass/integrations/azure/actions.py +230 -0
- toolpass/integrations/azure/azure.py +735 -0
- toolpass/integrations/bitbucket/__init__.py +162 -0
- toolpass/integrations/bitbucket/actions.py +259 -0
- toolpass/integrations/bitbucket/cloud.py +429 -0
- toolpass/integrations/bitbucket/common.py +106 -0
- toolpass/integrations/bitbucket/datacenter.py +465 -0
- toolpass/integrations/confluence/__init__.py +11 -0
- toolpass/integrations/confluence/actions.py +97 -0
- toolpass/integrations/confluence/confluence.py +407 -0
- toolpass/integrations/databricks/__init__.py +9 -0
- toolpass/integrations/databricks/actions.py +381 -0
- toolpass/integrations/databricks/databricks.py +651 -0
- toolpass/integrations/datadog/__init__.py +9 -0
- toolpass/integrations/datadog/actions.py +167 -0
- toolpass/integrations/datadog/datadog.py +465 -0
- toolpass/integrations/fake.py +103 -0
- toolpass/integrations/github/__init__.py +10 -0
- toolpass/integrations/github/actions.py +210 -0
- toolpass/integrations/github/github.py +965 -0
- toolpass/integrations/github/identity.py +448 -0
- toolpass/integrations/gitlab/__init__.py +916 -0
- toolpass/integrations/gitlab/actions.py +222 -0
- toolpass/integrations/googlecloud/__init__.py +10 -0
- toolpass/integrations/googlecloud/actions.py +307 -0
- toolpass/integrations/googlecloud/googlecloud.py +526 -0
- toolpass/integrations/googleworkspace/__init__.py +10 -0
- toolpass/integrations/googleworkspace/actions.py +84 -0
- toolpass/integrations/googleworkspace/googleworkspace.py +790 -0
- toolpass/integrations/jira/__init__.py +27 -0
- toolpass/integrations/jira/actions.py +114 -0
- toolpass/integrations/jira/jira.py +446 -0
- toolpass/integrations/jira/site.py +219 -0
- toolpass/integrations/kubernetes/__init__.py +346 -0
- toolpass/integrations/kubernetes/actions.py +197 -0
- toolpass/integrations/linear/__init__.py +10 -0
- toolpass/integrations/linear/actions.py +116 -0
- toolpass/integrations/linear/linear.py +655 -0
- toolpass/integrations/microsoft365/__init__.py +10 -0
- toolpass/integrations/microsoft365/actions.py +136 -0
- toolpass/integrations/microsoft365/microsoft365.py +992 -0
- toolpass/integrations/pagerduty/__init__.py +9 -0
- toolpass/integrations/pagerduty/actions.py +140 -0
- toolpass/integrations/pagerduty/pagerduty.py +435 -0
- toolpass/integrations/salesforce/__init__.py +13 -0
- toolpass/integrations/salesforce/actions.py +158 -0
- toolpass/integrations/salesforce/salesforce.py +1304 -0
- toolpass/integrations/salesforce/soql.py +493 -0
- toolpass/integrations/slack/__init__.py +10 -0
- toolpass/integrations/slack/actions.py +84 -0
- toolpass/integrations/slack/slack.py +961 -0
- toolpass/integrations/snowflake/__init__.py +12 -0
- toolpass/integrations/snowflake/actions.py +172 -0
- toolpass/integrations/snowflake/snowflake.py +676 -0
- toolpass/integrations/snowflake/sql.py +101 -0
- toolpass/integrations/vault/__init__.py +9 -0
- toolpass/integrations/vault/actions.py +122 -0
- toolpass/integrations/vault/policy.py +816 -0
- toolpass/integrations/vault/vault.py +799 -0
- toolpass/integrations/zendesk/__init__.py +9 -0
- toolpass/integrations/zendesk/actions.py +90 -0
- toolpass/integrations/zendesk/zendesk.py +789 -0
- toolpass/langchain.py +237 -0
- toolpass/llamaindex.py +186 -0
- toolpass/mcp.py +237 -0
- toolpass/net/__init__.py +0 -0
- toolpass/net/httpx.py +1404 -0
- toolpass/openai_agents.py +198 -0
- toolpass/py.typed +0 -0
- toolpass/pydantic_ai.py +202 -0
- toolpass/server.py +517 -0
- toolpass/strands.py +148 -0
- toolpass-0.7.0.dist-info/METADATA +466 -0
- toolpass-0.7.0.dist-info/RECORD +133 -0
- toolpass-0.7.0.dist-info/WHEEL +4 -0
- toolpass-0.7.0.dist-info/entry_points.txt +2 -0
- toolpass-0.7.0.dist-info/licenses/LICENSE +202 -0
toolpass/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""toolpass: a pass for every tool call. Secure-by-default tools for AI agents.
|
|
2
|
+
|
|
3
|
+
import toolpass
|
|
4
|
+
from toolpass import ApprovalQueue, Session, Toolpass, permission_check, secured_tool
|
|
5
|
+
|
|
6
|
+
tp = Toolpass.from_config("toolpass.yaml") # permission checks, live
|
|
7
|
+
toolpass.configure(approver=ApprovalQueue(), credentials={"github-bot": token})
|
|
8
|
+
|
|
9
|
+
@secured_tool(effect="write", scope={"repo": "acme/gitops-*"}, approve=True, credential="github-bot",
|
|
10
|
+
authorize=permission_check(tp, "github-acme", "pull_request.create", "repo:{repo}"))
|
|
11
|
+
def open_pr(repo: str, title: str, *, credential: str) -> str: ...
|
|
12
|
+
|
|
13
|
+
with Session("dana@example.com").active():
|
|
14
|
+
agent.run(prompt)
|
|
15
|
+
|
|
16
|
+
``secured_tool`` declares a tool and runs the checks around every call;
|
|
17
|
+
``Toolkit`` holds a separate set of settings when one program needs two.
|
|
18
|
+
``Toolpass`` answers "may this user do this?" by asking the system that owns
|
|
19
|
+
the resource, in-process or against a toolpass server; ``guarded`` and the
|
|
20
|
+
framework adapters use it on their own.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from toolpass._api import (
|
|
24
|
+
ALLOW,
|
|
25
|
+
DENY,
|
|
26
|
+
UNKNOWN,
|
|
27
|
+
Decision,
|
|
28
|
+
GroupsSource,
|
|
29
|
+
PermissionDenied,
|
|
30
|
+
Toolpass,
|
|
31
|
+
UserSource,
|
|
32
|
+
current,
|
|
33
|
+
guarded,
|
|
34
|
+
)
|
|
35
|
+
from toolpass._approval import ApprovalQueue, ApprovalRequest, Approver, approve_all
|
|
36
|
+
from toolpass._audit import AuditEvent, AuditSink, log_audit
|
|
37
|
+
from toolpass._checks import ScopeRule, Validator
|
|
38
|
+
from toolpass._default import configure, default_toolkit, secured_tool
|
|
39
|
+
from toolpass._permissions import permission_check
|
|
40
|
+
from toolpass._session import Session, current_session
|
|
41
|
+
from toolpass._toolkit import (
|
|
42
|
+
EFFECTS,
|
|
43
|
+
ApprovalPending,
|
|
44
|
+
AuthDecision,
|
|
45
|
+
Authorizer,
|
|
46
|
+
Call,
|
|
47
|
+
CredentialSource,
|
|
48
|
+
Effect,
|
|
49
|
+
ToolError,
|
|
50
|
+
Toolkit,
|
|
51
|
+
ToolRefused,
|
|
52
|
+
ToolSpec,
|
|
53
|
+
fence,
|
|
54
|
+
spec_of,
|
|
55
|
+
)
|
|
56
|
+
from toolpass._version import __version__
|
|
57
|
+
from toolpass.core.secret import env, file, literal
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"ALLOW",
|
|
61
|
+
"DENY",
|
|
62
|
+
"EFFECTS",
|
|
63
|
+
"UNKNOWN",
|
|
64
|
+
"ApprovalPending",
|
|
65
|
+
"ApprovalQueue",
|
|
66
|
+
"ApprovalRequest",
|
|
67
|
+
"Approver",
|
|
68
|
+
"AuditEvent",
|
|
69
|
+
"AuditSink",
|
|
70
|
+
"AuthDecision",
|
|
71
|
+
"Authorizer",
|
|
72
|
+
"Call",
|
|
73
|
+
"CredentialSource",
|
|
74
|
+
"Decision",
|
|
75
|
+
"Effect",
|
|
76
|
+
"GroupsSource",
|
|
77
|
+
"PermissionDenied",
|
|
78
|
+
"ScopeRule",
|
|
79
|
+
"Session",
|
|
80
|
+
"ToolError",
|
|
81
|
+
"ToolRefused",
|
|
82
|
+
"ToolSpec",
|
|
83
|
+
"Toolkit",
|
|
84
|
+
"Toolpass",
|
|
85
|
+
"UserSource",
|
|
86
|
+
"Validator",
|
|
87
|
+
"__version__",
|
|
88
|
+
"approve_all",
|
|
89
|
+
"configure",
|
|
90
|
+
"current",
|
|
91
|
+
"current_session",
|
|
92
|
+
"default_toolkit",
|
|
93
|
+
"env",
|
|
94
|
+
"fence",
|
|
95
|
+
"file",
|
|
96
|
+
"guarded",
|
|
97
|
+
"literal",
|
|
98
|
+
"log_audit",
|
|
99
|
+
"permission_check",
|
|
100
|
+
"secured_tool",
|
|
101
|
+
"spec_of",
|
|
102
|
+
]
|
toolpass/_api.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""The public Python API: ``Toolpass`` (in-process or remote), ``guarded``.
|
|
2
|
+
|
|
3
|
+
from toolpass import Toolpass, guarded
|
|
4
|
+
|
|
5
|
+
tp = Toolpass.from_config("toolpass.yaml") # in-process engine
|
|
6
|
+
tp = Toolpass(connections=[{"id": "github-main", "integration": "github", ...}])
|
|
7
|
+
tp = Toolpass.remote("https://toolpass.internal") # a toolpass server
|
|
8
|
+
|
|
9
|
+
The one rule an agent has to follow: perform an action on behalf of a user
|
|
10
|
+
only when toolpass answered ``allow``. ``deny`` and ``unknown`` both mean
|
|
11
|
+
"do not act", and so does any failure to get an answer at all.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextvars
|
|
18
|
+
import datetime
|
|
19
|
+
import functools
|
|
20
|
+
import http.client
|
|
21
|
+
import inspect
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.parse
|
|
28
|
+
import urllib.request
|
|
29
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, TypeVar, Union
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ALLOW",
|
|
35
|
+
"DENY",
|
|
36
|
+
"UNKNOWN",
|
|
37
|
+
"Decision",
|
|
38
|
+
"GroupsSource",
|
|
39
|
+
"PermissionDenied",
|
|
40
|
+
"Toolpass",
|
|
41
|
+
"UserSource",
|
|
42
|
+
"current",
|
|
43
|
+
"guarded",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
ALLOW = "allow"
|
|
47
|
+
DENY = "deny"
|
|
48
|
+
UNKNOWN = "unknown"
|
|
49
|
+
|
|
50
|
+
# Where guarded reports each write it let through. toolpass never sees the
|
|
51
|
+
# write itself, so this is the record that ties the action to the check.
|
|
52
|
+
log = logging.getLogger("toolpass")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Decision:
|
|
57
|
+
"""One answer.
|
|
58
|
+
|
|
59
|
+
``decision`` is ``allow``, ``deny`` or ``unknown``. ``reason`` is
|
|
60
|
+
``"<code>: <text>"``, or ``"client_error: ..."`` when a remote request
|
|
61
|
+
never produced a usable answer. ``status`` is the HTTP status the server
|
|
62
|
+
used (the in-process engine reports the one it would have used), 0 when
|
|
63
|
+
no response arrived.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
decision: str
|
|
67
|
+
reason: str
|
|
68
|
+
status: int = 200
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def allowed(self) -> bool:
|
|
72
|
+
"""True only for a positive ``allow``. ``unknown`` is not allowed."""
|
|
73
|
+
return self.decision == ALLOW
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def code(self) -> str:
|
|
77
|
+
"""The machine-readable code in front of the colon, e.g. ``user_not_found``."""
|
|
78
|
+
return self.reason.split(":", 1)[0].strip()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PermissionDenied(Exception):
|
|
82
|
+
"""Raised by ``Toolpass.require`` when the answer was not ``allow``."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, decision: Decision, user: str, connection: str, action: str, resource: str):
|
|
85
|
+
self.decision = decision
|
|
86
|
+
self.user, self.connection, self.action, self.resource = user, connection, action, resource
|
|
87
|
+
super().__init__(f"{user} may not {action} on {resource} in {connection}: {decision.decision} ({decision.reason})")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Toolpass:
|
|
91
|
+
"""Permission checks for one set of connections, answered live.
|
|
92
|
+
|
|
93
|
+
Three ways to make one, all with the same methods:
|
|
94
|
+
|
|
95
|
+
- ``Toolpass.from_config(path)``: the engine runs in this process, from
|
|
96
|
+
a toolpass YAML file. No service to deploy.
|
|
97
|
+
- ``Toolpass(connections=[...])``: the same, with the connections given
|
|
98
|
+
in code as mappings with exactly the keys of the file. Secret keys
|
|
99
|
+
take ``toolpass.env("NAME")``, ``toolpass.file("/path")`` or
|
|
100
|
+
``toolpass.literal(value)``, or the file's ``"env:NAME"`` strings.
|
|
101
|
+
- ``Toolpass.remote(url, api_key)``: ask a running toolpass server.
|
|
102
|
+
|
|
103
|
+
``check`` never raises for a failed lookup: every failure is an
|
|
104
|
+
``unknown`` decision. ``require`` raises ``PermissionDenied`` for
|
|
105
|
+
anything but ``allow``.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
connections: Sequence[Mapping[str, Any]] | None = None,
|
|
111
|
+
*,
|
|
112
|
+
decision_cache_seconds: int = 30,
|
|
113
|
+
identity_cache_seconds: int = 900,
|
|
114
|
+
decision_log: str = "none",
|
|
115
|
+
logger: Any = None,
|
|
116
|
+
):
|
|
117
|
+
if connections is None:
|
|
118
|
+
raise TypeError("Toolpass needs connections: Toolpass(connections=[...]), Toolpass.from_config(path) or Toolpass.remote(url, api_key)")
|
|
119
|
+
from toolpass.core.config import from_mapping
|
|
120
|
+
from toolpass.integrations import registry
|
|
121
|
+
|
|
122
|
+
doc: dict[str, Any] = {
|
|
123
|
+
"decision_log": decision_log,
|
|
124
|
+
"decision_cache_seconds": decision_cache_seconds,
|
|
125
|
+
"identity_cache_seconds": identity_cache_seconds,
|
|
126
|
+
"connections": list(connections),
|
|
127
|
+
}
|
|
128
|
+
self._backend: _Backend = _Local(from_mapping(doc, registry(), "<connections>", require_api_key=False), logger)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_config(cls, path: str | os.PathLike[str], *, logger: Any = None) -> Toolpass:
|
|
132
|
+
"""The in-process engine from a toolpass YAML file. The file's
|
|
133
|
+
caches and decision log apply; ``listen`` and ``api_key`` are for
|
|
134
|
+
the server and are not needed here."""
|
|
135
|
+
from toolpass.core.config import load
|
|
136
|
+
from toolpass.integrations import registry
|
|
137
|
+
|
|
138
|
+
self = cls.__new__(cls)
|
|
139
|
+
self._backend = _Local(load(os.fspath(path), registry(), require_api_key=False), logger)
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def remote(cls, url: str | None = None, api_key: str | None = None, timeout: float = 10.0) -> Toolpass:
|
|
144
|
+
"""A client for a running toolpass server. ``url`` defaults to
|
|
145
|
+
``$TOOLPASS_URL`` or ``http://localhost:8080``, ``api_key`` to
|
|
146
|
+
``$TOOLPASS_API_KEY``."""
|
|
147
|
+
self = cls.__new__(cls)
|
|
148
|
+
self._backend = _Remote(url, api_key, timeout)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def local(self) -> bool:
|
|
153
|
+
"""True when the engine runs in this process."""
|
|
154
|
+
return isinstance(self._backend, _Local)
|
|
155
|
+
|
|
156
|
+
def check(
|
|
157
|
+
self,
|
|
158
|
+
user: str,
|
|
159
|
+
connection: str,
|
|
160
|
+
action: str,
|
|
161
|
+
resource: str,
|
|
162
|
+
groups: Iterable[str] | None = None,
|
|
163
|
+
*,
|
|
164
|
+
fresh: bool = False,
|
|
165
|
+
) -> Decision:
|
|
166
|
+
"""Ask. Never raises for a failed lookup: every failure becomes an
|
|
167
|
+
``unknown`` decision.
|
|
168
|
+
|
|
169
|
+
``fresh=True`` asks for an answer straight from the upstream system,
|
|
170
|
+
skipping the caches. It narrows the window between the check and the
|
|
171
|
+
action to the time between the two; it does not close it.
|
|
172
|
+
"""
|
|
173
|
+
grp = None if groups is None else _group_list(groups)
|
|
174
|
+
return self._backend.check(user, connection, action, resource, grp, fresh)
|
|
175
|
+
|
|
176
|
+
def allowed(self, user: str, connection: str, action: str, resource: str, groups=None, *, fresh: bool = False) -> bool:
|
|
177
|
+
"""True only when the answer was ``allow``."""
|
|
178
|
+
return self.check(user, connection, action, resource, groups, fresh=fresh).allowed
|
|
179
|
+
|
|
180
|
+
def require(self, user: str, connection: str, action: str, resource: str, groups=None, *, fresh: bool = False) -> Decision:
|
|
181
|
+
"""Return the decision when it is ``allow``; raise ``PermissionDenied`` otherwise."""
|
|
182
|
+
d = self.check(user, connection, action, resource, groups, fresh=fresh)
|
|
183
|
+
if not d.allowed:
|
|
184
|
+
raise PermissionDenied(d, user, connection, action, resource)
|
|
185
|
+
return d
|
|
186
|
+
|
|
187
|
+
async def acheck(self, user: str, connection: str, action: str, resource: str, groups=None, *, fresh: bool = False) -> Decision:
|
|
188
|
+
"""``check`` for async code; the lookup runs in a worker thread."""
|
|
189
|
+
return await asyncio.to_thread(self.check, user, connection, action, resource, groups, fresh=fresh)
|
|
190
|
+
|
|
191
|
+
async def arequire(self, user: str, connection: str, action: str, resource: str, groups=None, *, fresh: bool = False) -> Decision:
|
|
192
|
+
"""``require`` for async code."""
|
|
193
|
+
return await asyncio.to_thread(self.require, user, connection, action, resource, groups, fresh=fresh)
|
|
194
|
+
|
|
195
|
+
def probe(self) -> list[tuple[str, bool, str]]:
|
|
196
|
+
"""Verify every connection's credential (in-process only): a list
|
|
197
|
+
of (connection id, ok, summary or error)."""
|
|
198
|
+
if not isinstance(self._backend, _Local):
|
|
199
|
+
raise TypeError("probe runs only in-process; use `toolpass probe` on the server")
|
|
200
|
+
return self._backend.probe()
|
|
201
|
+
|
|
202
|
+
def connections(self) -> list[str]:
|
|
203
|
+
"""The configured connection ids (in-process only)."""
|
|
204
|
+
if not isinstance(self._backend, _Local):
|
|
205
|
+
raise TypeError("connections are known only in-process")
|
|
206
|
+
return self._backend.engine.connections()
|
|
207
|
+
|
|
208
|
+
def __repr__(self) -> str:
|
|
209
|
+
return f"Toolpass({self._backend!r})"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _Backend:
|
|
213
|
+
def check(self, user: str, connection: str, action: str, resource: str, groups: list[str] | None, fresh: bool) -> Decision:
|
|
214
|
+
raise NotImplementedError
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class _Local(_Backend):
|
|
218
|
+
def __init__(self, cfg: Any, logger: Any) -> None:
|
|
219
|
+
from toolpass.core.context import background
|
|
220
|
+
from toolpass.core.declog import open_log
|
|
221
|
+
from toolpass.core.engine import Options, build
|
|
222
|
+
from toolpass.core.log import Logger, StdlibHandler
|
|
223
|
+
|
|
224
|
+
if logger is None:
|
|
225
|
+
lg = Logger(StdlibHandler())
|
|
226
|
+
elif isinstance(logger, logging.Logger):
|
|
227
|
+
lg = Logger(StdlibHandler(logger))
|
|
228
|
+
else:
|
|
229
|
+
lg = logger
|
|
230
|
+
self.engine = build(
|
|
231
|
+
background(),
|
|
232
|
+
cfg,
|
|
233
|
+
Options(
|
|
234
|
+
logger=lg,
|
|
235
|
+
decision_log=open_log(cfg.decision_log),
|
|
236
|
+
decision_cache=cfg.decision_cache,
|
|
237
|
+
identity_cache=cfg.identity_cache,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def check(self, user: str, connection: str, action: str, resource: str, groups: list[str] | None, fresh: bool) -> Decision:
|
|
242
|
+
from toolpass.core.engine import Request
|
|
243
|
+
|
|
244
|
+
for name, v in (("user", user), ("connection", connection), ("action", action), ("resource", resource)):
|
|
245
|
+
if not isinstance(v, str):
|
|
246
|
+
raise TypeError(f"{name} must be a string")
|
|
247
|
+
res = self.engine.check(
|
|
248
|
+
None, Request(user=user, connection=connection, action=action, resource=resource, groups=groups, fresh=fresh, remote="in-process")
|
|
249
|
+
)
|
|
250
|
+
return Decision(res.decision.outcome.value, res.decision.reason(), res.status)
|
|
251
|
+
|
|
252
|
+
def probe(self) -> list[tuple[str, bool, str]]:
|
|
253
|
+
out = []
|
|
254
|
+
for r in self.engine.probe():
|
|
255
|
+
if r.err is not None:
|
|
256
|
+
out.append((r.id, False, str(r.err)))
|
|
257
|
+
else:
|
|
258
|
+
out.append((r.id, True, r.result.summary))
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
def __repr__(self) -> str:
|
|
262
|
+
return f"in-process, connections={self.engine.connections()!r}"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class _Remote(_Backend):
|
|
266
|
+
"""Client for one toolpass service.
|
|
267
|
+
|
|
268
|
+
The URL must be ``https://``; plain ``http://`` is accepted only for
|
|
269
|
+
``localhost`` or a loopback address, because the API key travels in a
|
|
270
|
+
header. ``timeout`` is the seconds the client waits for the connection
|
|
271
|
+
and then for each read; toolpass waits up to the connection's
|
|
272
|
+
``timeout`` (8 s by default) for the upstream, so keep this a little
|
|
273
|
+
above that. Redirects are not followed: a redirect would re-send the API
|
|
274
|
+
key to whatever host the ``Location`` header names.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(self, url: str | None, api_key: str | None, timeout: float):
|
|
278
|
+
self.url = _validate_url(url or os.environ.get("TOOLPASS_URL") or "http://localhost:8080")
|
|
279
|
+
self.api_key = api_key if api_key is not None else os.environ.get("TOOLPASS_API_KEY", "")
|
|
280
|
+
if not self.api_key:
|
|
281
|
+
raise ValueError("toolpass API key missing: pass api_key or set TOOLPASS_API_KEY")
|
|
282
|
+
self.timeout = timeout
|
|
283
|
+
handlers: list[urllib.request.BaseHandler] = [_NoRedirect()]
|
|
284
|
+
from toolpass.core.integration import is_loopback_host
|
|
285
|
+
|
|
286
|
+
host = urllib.parse.urlsplit(self.url).hostname or ""
|
|
287
|
+
if is_loopback_host(host):
|
|
288
|
+
# Never through a proxy: the API key would travel to it, and Go's
|
|
289
|
+
# proxy selection skips loopback too.
|
|
290
|
+
handlers.append(urllib.request.ProxyHandler({}))
|
|
291
|
+
self._opener = urllib.request.build_opener(*handlers)
|
|
292
|
+
|
|
293
|
+
def check(self, user: str, connection: str, action: str, resource: str, groups: list[str] | None, fresh: bool) -> Decision:
|
|
294
|
+
body: dict[str, Any] = {"user": user, "connection": connection, "action": action, "resource": resource}
|
|
295
|
+
if groups is not None:
|
|
296
|
+
body["groups"] = groups
|
|
297
|
+
if fresh:
|
|
298
|
+
body["fresh"] = True
|
|
299
|
+
try:
|
|
300
|
+
req = urllib.request.Request(
|
|
301
|
+
self.url + "/check",
|
|
302
|
+
data=json.dumps(body).encode(),
|
|
303
|
+
method="POST",
|
|
304
|
+
headers={
|
|
305
|
+
"Authorization": "Bearer " + self.api_key,
|
|
306
|
+
"Content-Type": "application/json",
|
|
307
|
+
"Accept": "application/json",
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
311
|
+
return _parse(resp.status, resp.read(_MAX_RESPONSE + 1))
|
|
312
|
+
except urllib.error.HTTPError as e:
|
|
313
|
+
# 400 and 401 still carry a decision body; anything else is unusable.
|
|
314
|
+
try:
|
|
315
|
+
raw = e.read(_MAX_RESPONSE + 1)
|
|
316
|
+
except (OSError, http.client.HTTPException):
|
|
317
|
+
raw = b""
|
|
318
|
+
return _parse(e.code, raw)
|
|
319
|
+
except (urllib.error.URLError, OSError, http.client.HTTPException, ValueError) as e:
|
|
320
|
+
return Decision(UNKNOWN, f"client_error: toolpass unreachable: {e}", 0)
|
|
321
|
+
|
|
322
|
+
def __repr__(self) -> str:
|
|
323
|
+
return f"remote, url={self.url!r}"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _validate_url(url: str) -> str:
|
|
327
|
+
"""The rule toolpass applies to its own upstream URLs."""
|
|
328
|
+
if any(c in url for c in " \t\r\n?#"):
|
|
329
|
+
raise ValueError(f"toolpass url {url!r} must not contain whitespace, '?' or '#'")
|
|
330
|
+
u = urllib.parse.urlsplit(url)
|
|
331
|
+
if u.username is not None or u.password is not None:
|
|
332
|
+
raise ValueError(f"toolpass url {url!r} must not contain userinfo")
|
|
333
|
+
if u.scheme == "https" and u.hostname:
|
|
334
|
+
return url.rstrip("/")
|
|
335
|
+
if u.scheme == "http" and u.hostname:
|
|
336
|
+
from toolpass.core.integration import is_loopback_host
|
|
337
|
+
|
|
338
|
+
if is_loopback_host(u.hostname):
|
|
339
|
+
return url.rstrip("/")
|
|
340
|
+
raise ValueError(f"toolpass url {url!r} must start with https:// (http:// only for localhost)")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _group_list(groups: Iterable[str]) -> list[str]:
|
|
344
|
+
if isinstance(groups, bool):
|
|
345
|
+
raise TypeError("groups must be a list of strings; fresh is keyword-only (fresh=True)")
|
|
346
|
+
if isinstance(groups, str):
|
|
347
|
+
raise TypeError("groups must be a list of strings, not a string")
|
|
348
|
+
out = list(groups)
|
|
349
|
+
if not all(isinstance(g, str) for g in out):
|
|
350
|
+
raise TypeError("groups must be a list of strings")
|
|
351
|
+
return out
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
355
|
+
"""Turn every redirect into an HTTPError instead of following it."""
|
|
356
|
+
|
|
357
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# A decision is a few hundred bytes; a body past this is not one.
|
|
362
|
+
_MAX_RESPONSE = 1 << 20
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _parse(status: int, raw: bytes) -> Decision:
|
|
366
|
+
if len(raw) > _MAX_RESPONSE:
|
|
367
|
+
return Decision(UNKNOWN, f"client_error: response larger than {_MAX_RESPONSE} bytes (HTTP {status})", status)
|
|
368
|
+
try:
|
|
369
|
+
data = json.loads(raw)
|
|
370
|
+
decision, reason = data["decision"], str(data.get("reason", ""))
|
|
371
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
372
|
+
return Decision(UNKNOWN, f"client_error: unexpected response (HTTP {status})", status)
|
|
373
|
+
if decision not in (ALLOW, DENY, UNKNOWN):
|
|
374
|
+
return Decision(UNKNOWN, f"client_error: unexpected decision {decision!r}", status)
|
|
375
|
+
if decision == ALLOW and status != 200:
|
|
376
|
+
# toolpass never allows with a non-200 status; do not trust a proxy that does.
|
|
377
|
+
return Decision(UNKNOWN, f"client_error: allow with HTTP {status}", status)
|
|
378
|
+
return Decision(decision, reason, status)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _log_write(
|
|
382
|
+
who: str,
|
|
383
|
+
outcome: str,
|
|
384
|
+
connection: str,
|
|
385
|
+
action: str,
|
|
386
|
+
resource: str,
|
|
387
|
+
d: Decision,
|
|
388
|
+
checked_at: datetime.datetime,
|
|
389
|
+
fresh: bool,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""The line logged after a checked write ran, from ``guarded`` and the Strands handler."""
|
|
392
|
+
log.info(
|
|
393
|
+
"unconditional write: %s %s %s on %s in %s; toolpass said %s (%s) at %s, fresh=%s; "
|
|
394
|
+
"the write was not conditioned on the state toolpass saw (no If-Match), "
|
|
395
|
+
"so check and write were not atomic",
|
|
396
|
+
who,
|
|
397
|
+
outcome,
|
|
398
|
+
action,
|
|
399
|
+
resource,
|
|
400
|
+
connection,
|
|
401
|
+
d.decision,
|
|
402
|
+
d.reason,
|
|
403
|
+
checked_at.isoformat(timespec="milliseconds"),
|
|
404
|
+
fresh,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
409
|
+
|
|
410
|
+
# Where the acting user (or their groups) comes from: a fixed value, a
|
|
411
|
+
# zero-argument callable, or a contextvars.ContextVar the application sets
|
|
412
|
+
# for the current session or request.
|
|
413
|
+
UserSource = Union[str, Callable[[], str], "contextvars.ContextVar[str]"]
|
|
414
|
+
GroupsSource = Union[Iterable[str], Callable[[], Iterable[str]], "contextvars.ContextVar[Iterable[str]]"]
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def current(source: Any, what: str = "user") -> Any:
|
|
418
|
+
"""Resolve a user or groups source now.
|
|
419
|
+
|
|
420
|
+
A ``ContextVar`` with nothing set for this session raises ``RuntimeError``
|
|
421
|
+
naming it, rather than a bare ``LookupError`` from inside a framework.
|
|
422
|
+
"""
|
|
423
|
+
if isinstance(source, contextvars.ContextVar):
|
|
424
|
+
try:
|
|
425
|
+
return source.get()
|
|
426
|
+
except LookupError:
|
|
427
|
+
raise RuntimeError(f"no {what} set for this session in ContextVar {source.name!r}") from None
|
|
428
|
+
if callable(source):
|
|
429
|
+
return source()
|
|
430
|
+
return source
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _takes_one_dict(sig: inspect.Signature) -> bool:
|
|
434
|
+
"""True for the Claude Agent SDK handler shape: one positional parameter
|
|
435
|
+
annotated as a dict or Mapping, which receives all the arguments."""
|
|
436
|
+
params = list(sig.parameters.values())
|
|
437
|
+
if len(params) != 1 or params[0].kind not in (params[0].POSITIONAL_ONLY, params[0].POSITIONAL_OR_KEYWORD):
|
|
438
|
+
return False
|
|
439
|
+
ann = params[0].annotation
|
|
440
|
+
if ann is inspect.Parameter.empty:
|
|
441
|
+
return False
|
|
442
|
+
name = ann if isinstance(ann, str) else getattr(ann, "__name__", None) or str(ann)
|
|
443
|
+
origin = getattr(ann, "__origin__", None)
|
|
444
|
+
if isinstance(origin, type) and issubclass(origin, Mapping):
|
|
445
|
+
return True
|
|
446
|
+
if isinstance(ann, type) and issubclass(ann, Mapping):
|
|
447
|
+
return True
|
|
448
|
+
return bool(re.match(r"(typing\.)?(dict|Dict|Mapping|MutableMapping)\b", name))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def guarded(
|
|
452
|
+
tp: Toolpass,
|
|
453
|
+
connection: str,
|
|
454
|
+
action: str,
|
|
455
|
+
resource: str,
|
|
456
|
+
*,
|
|
457
|
+
user: UserSource,
|
|
458
|
+
groups: GroupsSource | None = None,
|
|
459
|
+
deny: Callable[[PermissionDenied], Any] | None = None,
|
|
460
|
+
fresh: bool = False,
|
|
461
|
+
) -> Callable[[F], F]:
|
|
462
|
+
"""Decorate a function so it runs only after toolpass allowed it.
|
|
463
|
+
|
|
464
|
+
The user the check is for comes from ``user``: a string, a zero-argument
|
|
465
|
+
callable, or a ``contextvars.ContextVar`` the application sets for the
|
|
466
|
+
current session. It is never read from the call's arguments, so a
|
|
467
|
+
framework's ``@tool`` can sit directly on top and the model cannot pick
|
|
468
|
+
who it acts as; the decorated function's signature is exactly the
|
|
469
|
+
original's. ``groups`` works the same way and must yield a list.
|
|
470
|
+
|
|
471
|
+
``resource`` is a format string over the call's arguments, e.g.
|
|
472
|
+
``"issue:{key}"``; a parameter left at its default is available too. A
|
|
473
|
+
function with ordinary parameters is called with keyword arguments
|
|
474
|
+
(LangChain, Strands, MCP); a function whose only parameter is a dict
|
|
475
|
+
(the Claude Agent SDK handler shape, ``async def f(args: dict)``)
|
|
476
|
+
receives all the arguments in that dict, and a ``user`` key in it is
|
|
477
|
+
ignored, never honoured. ``async def`` is supported and the check then
|
|
478
|
+
runs in a worker thread.
|
|
479
|
+
|
|
480
|
+
Any answer other than ``allow`` raises ``PermissionDenied`` before the
|
|
481
|
+
body runs. With ``deny`` given, its return value is returned instead of
|
|
482
|
+
raising; use that where the framework would hide the exception's text
|
|
483
|
+
from the model (MCPServer does).
|
|
484
|
+
|
|
485
|
+
``fresh=True`` makes every check skip toolpass's caches and ask the
|
|
486
|
+
upstream system now. Use it for destructive actions.
|
|
487
|
+
|
|
488
|
+
After the body has run, ``guarded`` logs one line on the ``toolpass``
|
|
489
|
+
logger (INFO): the decision, when the check was made, whether it was
|
|
490
|
+
fresh, and that the write was unconditional. toolpass only checked;
|
|
491
|
+
the write itself ran afterwards with no ``If-Match`` on the state
|
|
492
|
+
toolpass saw, so nobody reading the logs later should take check and
|
|
493
|
+
write for one atomic step.
|
|
494
|
+
|
|
495
|
+
@tool
|
|
496
|
+
@guarded(tp, "jira-main", "DELETE_ISSUES", "issue:{key}", user=current_user, fresh=True)
|
|
497
|
+
def delete_issue(key: str) -> str: ...
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
def wrap(fn: F) -> F:
|
|
501
|
+
sig = inspect.signature(fn)
|
|
502
|
+
one_dict = _takes_one_dict(sig)
|
|
503
|
+
|
|
504
|
+
def prepare(args: tuple[Any, ...], kwargs: dict[str, Any]):
|
|
505
|
+
if one_dict:
|
|
506
|
+
if len(args) != 1 or kwargs or not isinstance(args[0], Mapping):
|
|
507
|
+
raise TypeError(f"{fn.__name__} takes one dict of arguments")
|
|
508
|
+
fields, call = args[0], functools.partial(fn, args[0])
|
|
509
|
+
else:
|
|
510
|
+
if args:
|
|
511
|
+
raise TypeError(f"{fn.__name__} takes keyword arguments only")
|
|
512
|
+
bound = sig.bind(**kwargs) # a stray or missing argument fails here, before any check
|
|
513
|
+
bound.apply_defaults()
|
|
514
|
+
fields, call = bound.arguments, functools.partial(fn, **kwargs)
|
|
515
|
+
who = current(user, "user")
|
|
516
|
+
if not isinstance(who, str) or not who:
|
|
517
|
+
raise RuntimeError("guarded: user must be a non-empty string")
|
|
518
|
+
grp = None if groups is None else current(groups, "groups")
|
|
519
|
+
return who, grp, resource.format(**fields), call
|
|
520
|
+
|
|
521
|
+
def refused(e: PermissionDenied):
|
|
522
|
+
if deny is None:
|
|
523
|
+
raise e
|
|
524
|
+
return deny(e)
|
|
525
|
+
|
|
526
|
+
def ran(who: str, res: str, d: Decision, checked_at: datetime.datetime, outcome: str) -> None:
|
|
527
|
+
_log_write(who, outcome, connection, action, res, d, checked_at, fresh)
|
|
528
|
+
|
|
529
|
+
if inspect.iscoroutinefunction(fn):
|
|
530
|
+
|
|
531
|
+
@functools.wraps(fn)
|
|
532
|
+
async def inner(*args, **kwargs):
|
|
533
|
+
who, grp, res, call = prepare(args, kwargs)
|
|
534
|
+
checked_at = datetime.datetime.now(datetime.timezone.utc)
|
|
535
|
+
try:
|
|
536
|
+
d = await asyncio.to_thread(tp.require, who, connection, action, res, grp, fresh=fresh)
|
|
537
|
+
except PermissionDenied as e:
|
|
538
|
+
return refused(e)
|
|
539
|
+
try:
|
|
540
|
+
result = await call()
|
|
541
|
+
except BaseException as e:
|
|
542
|
+
ran(who, res, d, checked_at, f"raised {type(e).__name__} from")
|
|
543
|
+
raise
|
|
544
|
+
ran(who, res, d, checked_at, "ran")
|
|
545
|
+
return result
|
|
546
|
+
|
|
547
|
+
else:
|
|
548
|
+
|
|
549
|
+
@functools.wraps(fn)
|
|
550
|
+
def inner(*args, **kwargs):
|
|
551
|
+
who, grp, res, call = prepare(args, kwargs)
|
|
552
|
+
checked_at = datetime.datetime.now(datetime.timezone.utc)
|
|
553
|
+
try:
|
|
554
|
+
d = tp.require(who, connection, action, res, grp, fresh=fresh)
|
|
555
|
+
except PermissionDenied as e:
|
|
556
|
+
return refused(e)
|
|
557
|
+
try:
|
|
558
|
+
result = call()
|
|
559
|
+
except BaseException as e:
|
|
560
|
+
ran(who, res, d, checked_at, f"raised {type(e).__name__} from")
|
|
561
|
+
raise
|
|
562
|
+
ran(who, res, d, checked_at, "ran")
|
|
563
|
+
return result
|
|
564
|
+
|
|
565
|
+
inner.__signature__ = sig # type: ignore[attr-defined]
|
|
566
|
+
return inner # type: ignore[return-value]
|
|
567
|
+
|
|
568
|
+
return wrap
|