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.
Files changed (133) hide show
  1. toolpass/__init__.py +102 -0
  2. toolpass/_api.py +568 -0
  3. toolpass/_approval.py +250 -0
  4. toolpass/_audit.py +75 -0
  5. toolpass/_checks.py +258 -0
  6. toolpass/_default.py +39 -0
  7. toolpass/_permissions.py +53 -0
  8. toolpass/_rules.py +224 -0
  9. toolpass/_session.py +120 -0
  10. toolpass/_toolkit.py +813 -0
  11. toolpass/_untrusted.py +77 -0
  12. toolpass/_version.py +1 -0
  13. toolpass/_walk.py +48 -0
  14. toolpass/authx/__init__.py +6 -0
  15. toolpass/authx/awscreds.py +295 -0
  16. toolpass/authx/awsquery.py +225 -0
  17. toolpass/authx/google.py +89 -0
  18. toolpass/authx/jwt.py +283 -0
  19. toolpass/authx/oauth2.py +180 -0
  20. toolpass/authx/sigv4.py +271 -0
  21. toolpass/authx/sts.py +177 -0
  22. toolpass/authx/token.py +137 -0
  23. toolpass/authx/util.py +446 -0
  24. toolpass/authx/xmlutil.py +92 -0
  25. toolpass/claude_agent_sdk.py +187 -0
  26. toolpass/cli.py +612 -0
  27. toolpass/core/__init__.py +0 -0
  28. toolpass/core/cache.py +413 -0
  29. toolpass/core/catalog.py +237 -0
  30. toolpass/core/config.py +680 -0
  31. toolpass/core/context.py +233 -0
  32. toolpass/core/decision.py +204 -0
  33. toolpass/core/declog.py +113 -0
  34. toolpass/core/duration.py +144 -0
  35. toolpass/core/engine.py +408 -0
  36. toolpass/core/errors.py +185 -0
  37. toolpass/core/evidence.py +324 -0
  38. toolpass/core/integration.py +497 -0
  39. toolpass/core/jsonx.py +135 -0
  40. toolpass/core/log.py +188 -0
  41. toolpass/core/secret.py +146 -0
  42. toolpass/core/template.py +126 -0
  43. toolpass/crewai.py +208 -0
  44. toolpass/google_adk.py +229 -0
  45. toolpass/integrations/__init__.py +46 -0
  46. toolpass/integrations/argocd/__init__.py +341 -0
  47. toolpass/integrations/argocd/actions.py +160 -0
  48. toolpass/integrations/argocd/rbac/__init__.py +452 -0
  49. toolpass/integrations/argocd/rbac/builtin.py +67 -0
  50. toolpass/integrations/argocd/rbac/glob.py +213 -0
  51. toolpass/integrations/argocd/rbac/re2.py +595 -0
  52. toolpass/integrations/aws/__init__.py +9 -0
  53. toolpass/integrations/aws/actions.py +138 -0
  54. toolpass/integrations/aws/aws.py +675 -0
  55. toolpass/integrations/aws/identity.py +484 -0
  56. toolpass/integrations/azure/__init__.py +9 -0
  57. toolpass/integrations/azure/actions.py +230 -0
  58. toolpass/integrations/azure/azure.py +735 -0
  59. toolpass/integrations/bitbucket/__init__.py +162 -0
  60. toolpass/integrations/bitbucket/actions.py +259 -0
  61. toolpass/integrations/bitbucket/cloud.py +429 -0
  62. toolpass/integrations/bitbucket/common.py +106 -0
  63. toolpass/integrations/bitbucket/datacenter.py +465 -0
  64. toolpass/integrations/confluence/__init__.py +11 -0
  65. toolpass/integrations/confluence/actions.py +97 -0
  66. toolpass/integrations/confluence/confluence.py +407 -0
  67. toolpass/integrations/databricks/__init__.py +9 -0
  68. toolpass/integrations/databricks/actions.py +381 -0
  69. toolpass/integrations/databricks/databricks.py +651 -0
  70. toolpass/integrations/datadog/__init__.py +9 -0
  71. toolpass/integrations/datadog/actions.py +167 -0
  72. toolpass/integrations/datadog/datadog.py +465 -0
  73. toolpass/integrations/fake.py +103 -0
  74. toolpass/integrations/github/__init__.py +10 -0
  75. toolpass/integrations/github/actions.py +210 -0
  76. toolpass/integrations/github/github.py +965 -0
  77. toolpass/integrations/github/identity.py +448 -0
  78. toolpass/integrations/gitlab/__init__.py +916 -0
  79. toolpass/integrations/gitlab/actions.py +222 -0
  80. toolpass/integrations/googlecloud/__init__.py +10 -0
  81. toolpass/integrations/googlecloud/actions.py +307 -0
  82. toolpass/integrations/googlecloud/googlecloud.py +526 -0
  83. toolpass/integrations/googleworkspace/__init__.py +10 -0
  84. toolpass/integrations/googleworkspace/actions.py +84 -0
  85. toolpass/integrations/googleworkspace/googleworkspace.py +790 -0
  86. toolpass/integrations/jira/__init__.py +27 -0
  87. toolpass/integrations/jira/actions.py +114 -0
  88. toolpass/integrations/jira/jira.py +446 -0
  89. toolpass/integrations/jira/site.py +219 -0
  90. toolpass/integrations/kubernetes/__init__.py +346 -0
  91. toolpass/integrations/kubernetes/actions.py +197 -0
  92. toolpass/integrations/linear/__init__.py +10 -0
  93. toolpass/integrations/linear/actions.py +116 -0
  94. toolpass/integrations/linear/linear.py +655 -0
  95. toolpass/integrations/microsoft365/__init__.py +10 -0
  96. toolpass/integrations/microsoft365/actions.py +136 -0
  97. toolpass/integrations/microsoft365/microsoft365.py +992 -0
  98. toolpass/integrations/pagerduty/__init__.py +9 -0
  99. toolpass/integrations/pagerduty/actions.py +140 -0
  100. toolpass/integrations/pagerduty/pagerduty.py +435 -0
  101. toolpass/integrations/salesforce/__init__.py +13 -0
  102. toolpass/integrations/salesforce/actions.py +158 -0
  103. toolpass/integrations/salesforce/salesforce.py +1304 -0
  104. toolpass/integrations/salesforce/soql.py +493 -0
  105. toolpass/integrations/slack/__init__.py +10 -0
  106. toolpass/integrations/slack/actions.py +84 -0
  107. toolpass/integrations/slack/slack.py +961 -0
  108. toolpass/integrations/snowflake/__init__.py +12 -0
  109. toolpass/integrations/snowflake/actions.py +172 -0
  110. toolpass/integrations/snowflake/snowflake.py +676 -0
  111. toolpass/integrations/snowflake/sql.py +101 -0
  112. toolpass/integrations/vault/__init__.py +9 -0
  113. toolpass/integrations/vault/actions.py +122 -0
  114. toolpass/integrations/vault/policy.py +816 -0
  115. toolpass/integrations/vault/vault.py +799 -0
  116. toolpass/integrations/zendesk/__init__.py +9 -0
  117. toolpass/integrations/zendesk/actions.py +90 -0
  118. toolpass/integrations/zendesk/zendesk.py +789 -0
  119. toolpass/langchain.py +237 -0
  120. toolpass/llamaindex.py +186 -0
  121. toolpass/mcp.py +237 -0
  122. toolpass/net/__init__.py +0 -0
  123. toolpass/net/httpx.py +1404 -0
  124. toolpass/openai_agents.py +198 -0
  125. toolpass/py.typed +0 -0
  126. toolpass/pydantic_ai.py +202 -0
  127. toolpass/server.py +517 -0
  128. toolpass/strands.py +148 -0
  129. toolpass-0.7.0.dist-info/METADATA +466 -0
  130. toolpass-0.7.0.dist-info/RECORD +133 -0
  131. toolpass-0.7.0.dist-info/WHEEL +4 -0
  132. toolpass-0.7.0.dist-info/entry_points.txt +2 -0
  133. 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