readyagentsdev 0.8.2__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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Expose ReadyAgents builtin tools (and run-workflow) as an MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from readyagents.config import MAX_HTTP_BODY_BYTES, get_settings
|
|
10
|
+
from readyagents.errors import MCPError
|
|
11
|
+
from readyagents.mcp.builtin import builtin_tools
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mcp_available() -> bool:
|
|
15
|
+
try:
|
|
16
|
+
import mcp # noqa: F401
|
|
17
|
+
|
|
18
|
+
return True
|
|
19
|
+
except ImportError:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _create_server(name: str) -> Any:
|
|
24
|
+
try:
|
|
25
|
+
from mcp.server import MCPServer
|
|
26
|
+
|
|
27
|
+
return MCPServer(name)
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
try:
|
|
31
|
+
from mcp.server.fastmcp import FastMCP
|
|
32
|
+
|
|
33
|
+
return FastMCP(name)
|
|
34
|
+
except ImportError as exc:
|
|
35
|
+
raise MCPError(
|
|
36
|
+
"This version of the mcp package does not provide MCPServer or FastMCP."
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def construct_server(*, allow_http: bool | None = None, workspace: Path | None = None) -> Any:
|
|
41
|
+
"""Build an MCP server that exposes builtin tools. Does not start a transport."""
|
|
42
|
+
if not mcp_available():
|
|
43
|
+
raise MCPError('MCP extra is not installed. Run: pip install -e ".[mcp]"')
|
|
44
|
+
|
|
45
|
+
settings = get_settings()
|
|
46
|
+
allow = settings.allow_http if allow_http is None else allow_http
|
|
47
|
+
root = workspace or settings.workspace_path()
|
|
48
|
+
tools = {t.name: t for t in builtin_tools(allow_http=allow, workspace=root)}
|
|
49
|
+
server = _create_server("readyagents")
|
|
50
|
+
_register_server_tools(server, tools, workspace=Path(root))
|
|
51
|
+
return server
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def serve_stdio(*, allow_http: bool | None = None, workspace: Path | None = None) -> None:
|
|
55
|
+
"""Run a stdio MCP server exposing builtin tools."""
|
|
56
|
+
construct_server(allow_http=allow_http, workspace=workspace).run(transport="stdio")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def streamable_http_app(
|
|
60
|
+
server: Any,
|
|
61
|
+
*,
|
|
62
|
+
host: str,
|
|
63
|
+
port: int,
|
|
64
|
+
max_body_bytes: int = MAX_HTTP_BODY_BYTES,
|
|
65
|
+
) -> Any:
|
|
66
|
+
"""Return the SDK Streamable HTTP Starlette app mounted at /mcp. Feature-detect."""
|
|
67
|
+
method = getattr(server, "streamable_http_app", None)
|
|
68
|
+
if method is None:
|
|
69
|
+
raise MCPError(
|
|
70
|
+
"This mcp package does not support Streamable HTTP. "
|
|
71
|
+
"Upgrade with: pip install 'mcp>=2' or pip install -e '.[mcp]'"
|
|
72
|
+
)
|
|
73
|
+
kwargs: dict[str, Any] = {
|
|
74
|
+
"streamable_http_path": "/mcp",
|
|
75
|
+
"host": host,
|
|
76
|
+
"max_request_body_size": max_body_bytes,
|
|
77
|
+
}
|
|
78
|
+
security = _transport_security_settings(host, port)
|
|
79
|
+
if security is not None:
|
|
80
|
+
kwargs["transport_security"] = security
|
|
81
|
+
# Keep SDK default response mode so Accept can negotiate JSON vs SSE.
|
|
82
|
+
kwargs.pop("json_response", None)
|
|
83
|
+
if not callable(method):
|
|
84
|
+
return method
|
|
85
|
+
return _call_supported_kwargs(method, kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _transport_security_settings(host: str, port: int) -> Any | None:
|
|
89
|
+
try:
|
|
90
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
91
|
+
except ImportError:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return TransportSecuritySettings(
|
|
95
|
+
enable_dns_rebinding_protection=True,
|
|
96
|
+
allowed_hosts=_dns_allowed_hosts(host, port),
|
|
97
|
+
allowed_origins=_dns_allowed_origins(host, port),
|
|
98
|
+
)
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _dns_bind_names(host: str) -> list[str]:
|
|
104
|
+
names: list[str] = []
|
|
105
|
+
for item in (host, "127.0.0.1", "localhost", "::1"):
|
|
106
|
+
if item and item not in names:
|
|
107
|
+
names.append(item)
|
|
108
|
+
return names
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _host_header_variants(name: str, port: int) -> list[str]:
|
|
112
|
+
variants: list[str] = []
|
|
113
|
+
|
|
114
|
+
def add(value: str) -> None:
|
|
115
|
+
if value not in variants:
|
|
116
|
+
variants.append(value)
|
|
117
|
+
|
|
118
|
+
ipv6 = ":" in name and not name.startswith("[")
|
|
119
|
+
bracketed = f"[{name}]" if ipv6 else name
|
|
120
|
+
add(name)
|
|
121
|
+
add(bracketed)
|
|
122
|
+
add(f"{bracketed}:{port}")
|
|
123
|
+
if not ipv6:
|
|
124
|
+
add(f"{name}:{port}")
|
|
125
|
+
add(f"{name}:*")
|
|
126
|
+
add(f"{bracketed}:*")
|
|
127
|
+
return variants
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _dns_allowed_hosts(host: str, port: int) -> list[str]:
|
|
131
|
+
hosts: list[str] = []
|
|
132
|
+
for name in _dns_bind_names(host):
|
|
133
|
+
for variant in _host_header_variants(name, port):
|
|
134
|
+
if variant not in hosts:
|
|
135
|
+
hosts.append(variant)
|
|
136
|
+
return hosts
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _dns_allowed_origins(host: str, port: int) -> list[str]:
|
|
140
|
+
origins: list[str] = []
|
|
141
|
+
for name in _dns_bind_names(host):
|
|
142
|
+
ipv6 = ":" in name and not name.startswith("[")
|
|
143
|
+
shown = f"[{name}]" if ipv6 else name
|
|
144
|
+
for value in (f"http://{shown}:{port}", f"http://{shown}:*"):
|
|
145
|
+
if value not in origins:
|
|
146
|
+
origins.append(value)
|
|
147
|
+
return origins
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _call_supported_kwargs(method: Any, kwargs: dict[str, Any]) -> Any:
|
|
151
|
+
try:
|
|
152
|
+
signature = inspect.signature(method)
|
|
153
|
+
params = signature.parameters
|
|
154
|
+
except (TypeError, ValueError):
|
|
155
|
+
return method()
|
|
156
|
+
if any(param.kind is inspect.Parameter.VAR_KEYWORD for param in params.values()):
|
|
157
|
+
accepted = dict(kwargs)
|
|
158
|
+
else:
|
|
159
|
+
accepted = {key: value for key, value in kwargs.items() if key in params}
|
|
160
|
+
accepted.pop("json_response", None)
|
|
161
|
+
try:
|
|
162
|
+
return method(**accepted) if accepted else method()
|
|
163
|
+
except TypeError:
|
|
164
|
+
return method()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _register_server_tools(server: Any, tools: dict[str, Any], *, workspace: Path) -> None:
|
|
168
|
+
root = Path(workspace).resolve()
|
|
169
|
+
|
|
170
|
+
@server.tool(name="now", description=tools["now"].description)
|
|
171
|
+
def now() -> str:
|
|
172
|
+
return str(tools["now"].run())
|
|
173
|
+
|
|
174
|
+
@server.tool(name="calc", description=tools["calc"].description)
|
|
175
|
+
def calc(expression: str) -> str:
|
|
176
|
+
return str(tools["calc"].run(expression=expression))
|
|
177
|
+
|
|
178
|
+
@server.tool(name="json_get", description=tools["json_get"].description)
|
|
179
|
+
def json_get(data: str, path: str) -> str:
|
|
180
|
+
import json
|
|
181
|
+
|
|
182
|
+
result = tools["json_get"].run(data=data, path=path)
|
|
183
|
+
if isinstance(result, (dict, list)):
|
|
184
|
+
return json.dumps(result)
|
|
185
|
+
return str(result)
|
|
186
|
+
|
|
187
|
+
@server.tool(name="json_set", description=tools["json_set"].description)
|
|
188
|
+
def json_set(data: str, path: str, value: str) -> str:
|
|
189
|
+
import json
|
|
190
|
+
|
|
191
|
+
result = tools["json_set"].run(data=data, path=path, value=value)
|
|
192
|
+
if isinstance(result, (dict, list)):
|
|
193
|
+
return json.dumps(result)
|
|
194
|
+
return str(result)
|
|
195
|
+
|
|
196
|
+
@server.tool(name="json_merge", description=tools["json_merge"].description)
|
|
197
|
+
def json_merge(data: str, path: str, value: str) -> str:
|
|
198
|
+
import json
|
|
199
|
+
|
|
200
|
+
result = tools["json_merge"].run(data=data, path=path, value=value)
|
|
201
|
+
if isinstance(result, (dict, list)):
|
|
202
|
+
return json.dumps(result)
|
|
203
|
+
return str(result)
|
|
204
|
+
|
|
205
|
+
@server.tool(name="http_get", description=tools["http_get"].description)
|
|
206
|
+
def http_get(url: str) -> str:
|
|
207
|
+
return str(tools["http_get"].run(url=url))
|
|
208
|
+
|
|
209
|
+
@server.tool(name="list_dir", description=tools["list_dir"].description)
|
|
210
|
+
def list_dir(path: str = ".", include_hidden: bool = False, max_entries: int = 200) -> str:
|
|
211
|
+
import json
|
|
212
|
+
|
|
213
|
+
result = tools["list_dir"].run(
|
|
214
|
+
path=path, include_hidden=include_hidden, max_entries=max_entries
|
|
215
|
+
)
|
|
216
|
+
if isinstance(result, (dict, list)):
|
|
217
|
+
return json.dumps(result)
|
|
218
|
+
return str(result)
|
|
219
|
+
|
|
220
|
+
@server.tool(name="read_file", description=tools["read_file"].description)
|
|
221
|
+
def read_file(path: str) -> str:
|
|
222
|
+
return str(tools["read_file"].run(path=path))
|
|
223
|
+
|
|
224
|
+
@server.tool(name="write_file", description=tools["write_file"].description)
|
|
225
|
+
def write_file(path: str, content: str) -> str:
|
|
226
|
+
return str(tools["write_file"].run(path=path, content=content))
|
|
227
|
+
|
|
228
|
+
@server.tool()
|
|
229
|
+
def run_workflow(path: str, inputs_json: str = "{}") -> str:
|
|
230
|
+
"""Run a ReadyAgents workflow file under the server workspace."""
|
|
231
|
+
import json
|
|
232
|
+
|
|
233
|
+
from readyagents.config import get_settings
|
|
234
|
+
from readyagents.errors import ConfigError
|
|
235
|
+
from readyagents.workflow.runner import confine_under, run_workflow_file
|
|
236
|
+
|
|
237
|
+
data = json.loads(inputs_json) if inputs_json else {}
|
|
238
|
+
if not isinstance(data, dict):
|
|
239
|
+
raise ValueError("inputs_json must be a JSON object")
|
|
240
|
+
try:
|
|
241
|
+
wf_path = confine_under(path, root, what="workflow")
|
|
242
|
+
except ConfigError as exc:
|
|
243
|
+
raise MCPError(str(exc)) from exc
|
|
244
|
+
bound = get_settings().model_copy(update={"workspace": Path(root)})
|
|
245
|
+
state = run_workflow_file(wf_path, inputs=data, settings=bound)
|
|
246
|
+
return json.dumps(state.to_record(), ensure_ascii=False)
|
readyagents/notify.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Outbound pause notify. Core never listens; packs may receive the webhook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
from readyagents import __version__
|
|
10
|
+
from readyagents.mcp.builtin import (
|
|
11
|
+
_MAX_HTTP_REDIRECTS,
|
|
12
|
+
_assert_public_http_url,
|
|
13
|
+
_http_exchange,
|
|
14
|
+
_resolve_public_ips,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_KIND = "notify"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def post_json(url: str, payload: dict[str, Any], *, timeout: float = 5.0) -> None:
|
|
21
|
+
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
22
|
+
current = url.strip() if isinstance(url, str) else ""
|
|
23
|
+
headers = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"User-Agent": f"readyagents/{__version__}",
|
|
26
|
+
}
|
|
27
|
+
for _ in range(_MAX_HTTP_REDIRECTS + 1):
|
|
28
|
+
parsed = _assert_public_http_url(current, kind=_KIND)
|
|
29
|
+
assert parsed.hostname is not None
|
|
30
|
+
ips = _resolve_public_ips(parsed.hostname, kind=_KIND)
|
|
31
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
32
|
+
path = parsed.path or "/"
|
|
33
|
+
if parsed.query:
|
|
34
|
+
path = f"{path}?{parsed.query}"
|
|
35
|
+
last_err: Exception | None = None
|
|
36
|
+
status = 0
|
|
37
|
+
location: str | None = None
|
|
38
|
+
for ip in ips:
|
|
39
|
+
try:
|
|
40
|
+
status, _body, location = _http_exchange(
|
|
41
|
+
parsed.scheme,
|
|
42
|
+
parsed.hostname,
|
|
43
|
+
ip,
|
|
44
|
+
port,
|
|
45
|
+
path,
|
|
46
|
+
method="POST",
|
|
47
|
+
body=data,
|
|
48
|
+
headers=headers,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
)
|
|
51
|
+
last_err = None
|
|
52
|
+
break
|
|
53
|
+
except (TimeoutError, OSError) as exc:
|
|
54
|
+
last_err = exc
|
|
55
|
+
if last_err is not None:
|
|
56
|
+
raise last_err
|
|
57
|
+
if status in {301, 302, 303, 307, 308} and location:
|
|
58
|
+
current = urljoin(current, location)
|
|
59
|
+
continue
|
|
60
|
+
if status >= 400:
|
|
61
|
+
raise OSError(f"HTTP Error {status}")
|
|
62
|
+
return
|
|
63
|
+
raise OSError("too many redirects")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from readyagents.packs.loader import (
|
|
2
|
+
collect_pack_authorizers,
|
|
3
|
+
collect_pack_nodes,
|
|
4
|
+
collect_pack_secrets,
|
|
5
|
+
collect_pack_specs,
|
|
6
|
+
collect_pack_tools,
|
|
7
|
+
confine_pack_path,
|
|
8
|
+
discover_packs,
|
|
9
|
+
load_local_packs,
|
|
10
|
+
load_pack_file,
|
|
11
|
+
)
|
|
12
|
+
from readyagents.packs.protocol import BasePack, Pack
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BasePack",
|
|
16
|
+
"Pack",
|
|
17
|
+
"collect_pack_authorizers",
|
|
18
|
+
"collect_pack_nodes",
|
|
19
|
+
"collect_pack_secrets",
|
|
20
|
+
"collect_pack_specs",
|
|
21
|
+
"collect_pack_tools",
|
|
22
|
+
"confine_pack_path",
|
|
23
|
+
"discover_packs",
|
|
24
|
+
"load_local_packs",
|
|
25
|
+
"load_pack_file",
|
|
26
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Load installed packs via importlib.metadata entry points and local files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from importlib.metadata import entry_points
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from readyagents.errors import ConfigError
|
|
15
|
+
from readyagents.logging import get_logger
|
|
16
|
+
from readyagents.packs.protocol import Pack
|
|
17
|
+
from readyagents.tools import ToolRegistry
|
|
18
|
+
|
|
19
|
+
log = get_logger("packs")
|
|
20
|
+
|
|
21
|
+
ENTRY_POINT_GROUP = "readyagents.packs"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def discover_packs() -> list[Pack]:
|
|
25
|
+
"""Load every installed pack. Core runs fine with an empty list."""
|
|
26
|
+
packs: list[Pack] = []
|
|
27
|
+
selected = entry_points().select(group=ENTRY_POINT_GROUP)
|
|
28
|
+
for ep in selected:
|
|
29
|
+
try:
|
|
30
|
+
loaded = ep.load()
|
|
31
|
+
pack = loaded() if callable(loaded) and not _is_pack_instance(loaded) else loaded
|
|
32
|
+
if not _is_pack_instance(pack):
|
|
33
|
+
raise ConfigError(
|
|
34
|
+
f"Entry point '{ep.name}' did not return a Pack (name/version/register_*)"
|
|
35
|
+
)
|
|
36
|
+
packs.append(pack)
|
|
37
|
+
log.debug("Loaded pack %s %s", pack.name, pack.version)
|
|
38
|
+
except ConfigError:
|
|
39
|
+
raise
|
|
40
|
+
except Exception as exc: # noqa: BLE001
|
|
41
|
+
raise ConfigError(f"Failed to load pack '{ep.name}': {exc}") from exc
|
|
42
|
+
return packs
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_pack_instance(obj: Any) -> bool:
|
|
46
|
+
return (
|
|
47
|
+
hasattr(obj, "name")
|
|
48
|
+
and hasattr(obj, "version")
|
|
49
|
+
and callable(getattr(obj, "register_nodes", None))
|
|
50
|
+
and callable(getattr(obj, "register_tools", None))
|
|
51
|
+
and callable(getattr(obj, "register_workflows", None))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def collect_pack_tools(packs: list[Pack] | None = None) -> ToolRegistry:
|
|
56
|
+
registry = ToolRegistry()
|
|
57
|
+
for pack in packs if packs is not None else discover_packs():
|
|
58
|
+
for tool in pack.register_tools() or []:
|
|
59
|
+
registry.register(tool)
|
|
60
|
+
return registry
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def collect_pack_nodes(packs: list[Pack] | None = None) -> dict[str, Any]:
|
|
64
|
+
handlers: dict[str, Any] = {}
|
|
65
|
+
for pack in packs if packs is not None else discover_packs():
|
|
66
|
+
for type_name, handler in (pack.register_nodes() or {}).items():
|
|
67
|
+
handlers[type_name] = handler
|
|
68
|
+
return handlers
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def collect_pack_secrets(packs: list[Pack] | None = None) -> list[Any]:
|
|
72
|
+
backends: list[Any] = []
|
|
73
|
+
for pack in packs if packs is not None else discover_packs():
|
|
74
|
+
fn = getattr(pack, "register_secrets", None)
|
|
75
|
+
if not callable(fn):
|
|
76
|
+
continue
|
|
77
|
+
backends.extend(list(fn() or []))
|
|
78
|
+
return backends
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def collect_pack_authorizers(packs: list[Pack] | None = None) -> list[Any]:
|
|
82
|
+
authorizers: list[Any] = []
|
|
83
|
+
for pack in packs if packs is not None else discover_packs():
|
|
84
|
+
fn = getattr(pack, "register_authorizers", None)
|
|
85
|
+
if not callable(fn):
|
|
86
|
+
continue
|
|
87
|
+
authorizers.extend(list(fn() or []))
|
|
88
|
+
return authorizers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def collect_pack_specs(flags: Sequence[str] | None = None, *, env: str | None = None) -> list[str]:
|
|
92
|
+
"""Combine READYAGENTS_PACK (pathsep or comma) with repeatable --pack flags."""
|
|
93
|
+
out: list[str] = []
|
|
94
|
+
raw_env = os.environ.get("READYAGENTS_PACK") if env is None else env
|
|
95
|
+
if raw_env:
|
|
96
|
+
normalized = raw_env.replace(",", os.pathsep)
|
|
97
|
+
for part in normalized.split(os.pathsep):
|
|
98
|
+
piece = part.strip()
|
|
99
|
+
if piece:
|
|
100
|
+
out.append(piece)
|
|
101
|
+
for flag in flags or ():
|
|
102
|
+
text = str(flag).strip()
|
|
103
|
+
if text:
|
|
104
|
+
out.append(text)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def confine_pack_path(raw: str | Path, root: Path) -> Path:
|
|
109
|
+
"""Resolve ``raw`` and refuse anything outside ``root`` (symlink-aware)."""
|
|
110
|
+
root = Path(root).resolve()
|
|
111
|
+
text = str(raw).strip()
|
|
112
|
+
if not text or "\x00" in text:
|
|
113
|
+
raise ConfigError(f"Pack path must be a Python file under {root}")
|
|
114
|
+
candidate = Path(text)
|
|
115
|
+
if not candidate.is_absolute():
|
|
116
|
+
candidate = root / candidate
|
|
117
|
+
resolved = candidate.resolve()
|
|
118
|
+
if not resolved.is_relative_to(root):
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
f"Pack path is outside the workspace: {raw} "
|
|
121
|
+
f"(resolved to {resolved}, must stay under {root})"
|
|
122
|
+
)
|
|
123
|
+
if not resolved.is_file():
|
|
124
|
+
raise ConfigError(f"Pack file not found: {raw}")
|
|
125
|
+
if resolved.suffix.lower() != ".py":
|
|
126
|
+
raise ConfigError(f"Pack path must be a Python file: {raw}")
|
|
127
|
+
return resolved
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_pack_file(raw: str | Path, *, root: Path) -> Pack:
|
|
131
|
+
"""Import a local pack module confined under ``root``."""
|
|
132
|
+
path = confine_pack_path(raw, root)
|
|
133
|
+
mod_name = f"_readyagents_pack_{path.stem}_{uuid4().hex[:8]}"
|
|
134
|
+
spec = importlib.util.spec_from_file_location(mod_name, path)
|
|
135
|
+
if spec is None or spec.loader is None:
|
|
136
|
+
raise ConfigError(f"Could not load pack {path}")
|
|
137
|
+
module = importlib.util.module_from_spec(spec)
|
|
138
|
+
sys.modules[mod_name] = module
|
|
139
|
+
try:
|
|
140
|
+
spec.loader.exec_module(module)
|
|
141
|
+
except Exception as extra:
|
|
142
|
+
sys.modules.pop(mod_name, None)
|
|
143
|
+
raise ConfigError(f"Failed to load pack '{path}': {extra}") from extra
|
|
144
|
+
getter = getattr(module, "get_pack", None)
|
|
145
|
+
if callable(getter):
|
|
146
|
+
loaded = getter()
|
|
147
|
+
if _is_pack_instance(loaded):
|
|
148
|
+
log.debug("Loaded local pack %s %s from %s", loaded.name, loaded.version, path)
|
|
149
|
+
return loaded
|
|
150
|
+
raise ConfigError(f"get_pack() in {path} did not return a Pack")
|
|
151
|
+
if _is_pack_instance(module):
|
|
152
|
+
return module # type: ignore[return-value]
|
|
153
|
+
raise ConfigError(f"Pack {path} needs get_pack() or a Pack instance")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load_local_packs(specs: Sequence[str], *, root: Path) -> list[Pack]:
|
|
157
|
+
return [load_pack_file(spec, root=root) for spec in specs]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Pack protocol — commercial / extra capability layers sit on top of core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from readyagents.tools import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class NodeHandler(Protocol):
|
|
13
|
+
"""Optional custom node type: `execute(node, state, context) -> output`."""
|
|
14
|
+
|
|
15
|
+
type_name: str
|
|
16
|
+
|
|
17
|
+
def execute(self, node: Any, state: Any, context: Any) -> Any: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Pack(Protocol):
|
|
22
|
+
"""A ReadyAgents pack discovered via entry points `readyagents.packs`."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
version: str
|
|
26
|
+
|
|
27
|
+
def register_nodes(self) -> Mapping[str, NodeHandler]: ...
|
|
28
|
+
|
|
29
|
+
def register_tools(self) -> Sequence[Tool]: ...
|
|
30
|
+
|
|
31
|
+
def register_workflows(self) -> Sequence[Any]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BasePack:
|
|
35
|
+
"""Convenient base class for packs (not required)."""
|
|
36
|
+
|
|
37
|
+
name = "unnamed"
|
|
38
|
+
version = "0.0.0"
|
|
39
|
+
|
|
40
|
+
def register_nodes(self) -> Mapping[str, NodeHandler]:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
def register_tools(self) -> Sequence[Tool]:
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
def register_workflows(self) -> Sequence[Any]:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
def register_secrets(self) -> Sequence[Any]:
|
|
50
|
+
"""Optional secrets-manager backends (Vault/AWS live in packs, not core)."""
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
def register_authorizers(self) -> Sequence[Any]:
|
|
54
|
+
"""Optional RBAC hooks. Default in core is allow-all."""
|
|
55
|
+
return []
|
readyagents/policy.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""RBAC hooks and PII redaction. No control plane — local policy only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from readyagents.errors import AuthorizationError
|
|
10
|
+
|
|
11
|
+
_EMAIL = re.compile(r"(?i)\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b")
|
|
12
|
+
_SK_KEY = re.compile(r"sk-[A-Za-z0-9]{8,}")
|
|
13
|
+
_ASSIGNED_SECRET = re.compile(r"(?i)\b(api[_-]?key|secret|token|password|passwd)\s*[=:]\s*\S+")
|
|
14
|
+
|
|
15
|
+
DEFAULT_REDACT_PATTERNS: tuple[re.Pattern[str], ...] = (_EMAIL, _SK_KEY, _ASSIGNED_SECRET)
|
|
16
|
+
REDACTED = "[redacted]"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Authorizer(Protocol):
|
|
21
|
+
"""Allow or deny an action. Raise ``AuthorizationError`` to deny."""
|
|
22
|
+
|
|
23
|
+
def check(self, actor: str | None, action: str, resource: str) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AllowAll:
|
|
27
|
+
"""Default authorizer — core stays open unless a pack/hook is installed."""
|
|
28
|
+
|
|
29
|
+
def check(self, actor: str | None, action: str, resource: str) -> None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CallbackAuthorizer:
|
|
34
|
+
"""Wrap a ``(actor, action, resource) -> bool`` callback."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
allowed: Callable[[str | None, str, str], bool],
|
|
39
|
+
*,
|
|
40
|
+
name: str = "callback",
|
|
41
|
+
) -> None:
|
|
42
|
+
self._allowed = allowed
|
|
43
|
+
self.name = name
|
|
44
|
+
|
|
45
|
+
def check(self, actor: str | None, action: str, resource: str) -> None:
|
|
46
|
+
if not self._allowed(actor, action, resource):
|
|
47
|
+
raise AuthorizationError(actor, action, resource)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CompositeAuthorizer:
|
|
51
|
+
"""Every registered authorizer must allow (AND)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, parts: Sequence[Authorizer]) -> None:
|
|
54
|
+
self.parts = [p for p in parts if p is not None]
|
|
55
|
+
|
|
56
|
+
def check(self, actor: str | None, action: str, resource: str) -> None:
|
|
57
|
+
for part in self.parts:
|
|
58
|
+
part.check(actor, action, resource)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_authorizer(raw: Any) -> Authorizer:
|
|
62
|
+
if raw is None:
|
|
63
|
+
return AllowAll()
|
|
64
|
+
if isinstance(raw, CompositeAuthorizer):
|
|
65
|
+
return raw
|
|
66
|
+
if hasattr(raw, "check") and callable(raw.check):
|
|
67
|
+
return raw
|
|
68
|
+
if isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)):
|
|
69
|
+
return CompositeAuthorizer(list(raw))
|
|
70
|
+
return AllowAll()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Redactor:
|
|
74
|
+
"""Mask emails, vendor-style keys, assignment secrets, and extra literals."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
patterns: Sequence[str] | None = None,
|
|
80
|
+
literals: Sequence[str] | None = None,
|
|
81
|
+
replacement: str = REDACTED,
|
|
82
|
+
) -> None:
|
|
83
|
+
compiled: list[re.Pattern[str]] = list(DEFAULT_REDACT_PATTERNS)
|
|
84
|
+
for raw in patterns or []:
|
|
85
|
+
text = str(raw).strip()
|
|
86
|
+
if not text:
|
|
87
|
+
continue
|
|
88
|
+
compiled.append(re.compile(text))
|
|
89
|
+
self._patterns = compiled
|
|
90
|
+
self._literals = [str(item) for item in (literals or []) if str(item)]
|
|
91
|
+
self.replacement = replacement
|
|
92
|
+
|
|
93
|
+
def redact_text(self, text: str) -> str:
|
|
94
|
+
out = text
|
|
95
|
+
for lit in self._literals:
|
|
96
|
+
if lit:
|
|
97
|
+
out = out.replace(lit, self.replacement)
|
|
98
|
+
for pat in self._patterns:
|
|
99
|
+
out = pat.sub(self.replacement, out)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
def redact(self, value: Any) -> Any:
|
|
103
|
+
if value is None or isinstance(value, (int, float, bool)):
|
|
104
|
+
return value
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return self.redact_text(value)
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return {str(k): self.redact(v) for k, v in value.items()}
|
|
109
|
+
if isinstance(value, (list, tuple)):
|
|
110
|
+
return [self.redact(v) for v in value]
|
|
111
|
+
if isinstance(value, bytes):
|
|
112
|
+
try:
|
|
113
|
+
return self.redact_text(value.decode("utf-8"))
|
|
114
|
+
except UnicodeDecodeError:
|
|
115
|
+
return self.replacement
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def redactor_from_settings(
|
|
120
|
+
*,
|
|
121
|
+
enabled: bool,
|
|
122
|
+
patterns: Sequence[str] | None = None,
|
|
123
|
+
literals: Sequence[str] | None = None,
|
|
124
|
+
) -> Redactor | None:
|
|
125
|
+
if not enabled:
|
|
126
|
+
return None
|
|
127
|
+
return Redactor(patterns=patterns, literals=literals)
|
readyagents/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|