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.
Files changed (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. 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
+