runeward 0.2.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.
runeward/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """runeward — Python client and agent-framework adapters for the Runeward
2
+ agent governance harness.
3
+
4
+ The core :class:`RunewardClient` depends only on the standard library. The
5
+ framework helpers live in :mod:`runeward.langchain_tools`,
6
+ :mod:`runeward.crewai_tools`, :mod:`runeward.llamaindex_tools`,
7
+ :mod:`runeward.openai_agents_tools`, and :mod:`runeward.strands_tools`; each
8
+ imports its framework lazily, so importing this package never requires those
9
+ extras to be installed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .client import (
15
+ RunewardApprovalRequired,
16
+ RunewardClient,
17
+ RunewardDenied,
18
+ RunewardError,
19
+ )
20
+
21
+ __all__ = [
22
+ "RunewardClient",
23
+ "RunewardError",
24
+ "RunewardDenied",
25
+ "RunewardApprovalRequired",
26
+ ]
27
+
28
+ __version__ = "0.2.0"
runeward/client.py ADDED
@@ -0,0 +1,297 @@
1
+ """A dependency-light Python client for the runeward control plane.
2
+
3
+ Uses only the Python standard library (``urllib``) so it can be dropped into any
4
+ environment without pulling in ``requests`` or an async HTTP stack. The client
5
+ mirrors the runeward REST contract 1:1 and translates the two governance
6
+ outcomes into exceptions:
7
+
8
+ * HTTP ``403`` -> :class:`RunewardDenied`
9
+ * HTTP ``202`` -> :class:`RunewardApprovalRequired` (carries the ``approval_id``)
10
+
11
+ Everything else that isn't a 2xx becomes a :class:`RunewardError`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ import warnings
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ __all__ = [
25
+ "RunewardClient",
26
+ "RunewardError",
27
+ "RunewardDenied",
28
+ "RunewardApprovalRequired",
29
+ ]
30
+
31
+
32
+ class RunewardError(Exception):
33
+ """Base error for any non-success response from the control plane."""
34
+
35
+ def __init__(self, message: str, *, status: Optional[int] = None,
36
+ payload: Optional[Dict[str, Any]] = None) -> None:
37
+ super().__init__(message)
38
+ self.status = status
39
+ self.payload = payload or {}
40
+
41
+
42
+ class RunewardDenied(RunewardError):
43
+ """Raised when policy denies an action (HTTP 403).
44
+
45
+ ``reason`` explains *why* it was blocked. A denial is a policy decision, not
46
+ a transient failure: do not retry the identical action.
47
+ """
48
+
49
+ def __init__(self, reason: str, *, payload: Optional[Dict[str, Any]] = None) -> None:
50
+ super().__init__(f"runeward denied action: {reason}", status=403, payload=payload)
51
+ self.reason = reason
52
+
53
+
54
+ class RunewardApprovalRequired(RunewardError):
55
+ """Raised when an action needs human approval (HTTP 202).
56
+
57
+ ``approval_id`` identifies the pending request in the approvals inbox. The
58
+ caller should pause and surface this to a human rather than working around
59
+ the gate.
60
+ """
61
+
62
+ def __init__(self, approval_id: str, *, reason: str = "",
63
+ payload: Optional[Dict[str, Any]] = None) -> None:
64
+ super().__init__(
65
+ f"runeward requires approval (id={approval_id}): {reason}".rstrip(": "),
66
+ status=202,
67
+ payload=payload,
68
+ )
69
+ self.approval_id = approval_id
70
+ self.reason = reason
71
+
72
+
73
+ class RunewardClient:
74
+ """Thin, synchronous client over the runeward REST control plane.
75
+
76
+ Example
77
+ -------
78
+ >>> rw = RunewardClient("http://localhost:8080")
79
+ >>> sbx = rw.create_sandbox("dev")
80
+ >>> rw.shell(sbx["id"], ["echo", "hello"])["stdout"]
81
+ 'hello\\n'
82
+ >>> rw.kill_sandbox(sbx["id"])
83
+ """
84
+
85
+ def __init__(self, base_url: str = "http://localhost:8080", *,
86
+ timeout: float = 60.0, token: Optional[str] = None,
87
+ allow_insecure: bool = False) -> None:
88
+ # Normalize so we can safely join paths without doubling slashes.
89
+ self.base_url = self._normalize_base_url(base_url)
90
+ self._validate_transport(self.base_url, allow_insecure)
91
+ self.timeout = timeout
92
+ self.token = token
93
+
94
+ # -- low-level request plumbing ---------------------------------------
95
+
96
+ def _request(self, method: str, path: str,
97
+ body: Optional[Dict[str, Any]] = None) -> Any:
98
+ """Perform an HTTP request and decode the JSON response.
99
+
100
+ Maps governance status codes to typed exceptions before returning.
101
+ """
102
+ url = f"{self.base_url}{path}"
103
+ data = None
104
+ headers = {"Accept": "application/json"}
105
+ if body is not None:
106
+ data = json.dumps(body).encode("utf-8")
107
+ headers["Content-Type"] = "application/json"
108
+ if self.token:
109
+ headers["Authorization"] = f"Bearer {self.token}"
110
+
111
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
112
+ try:
113
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
114
+ return self._decode(resp.status, resp.read())
115
+ except urllib.error.HTTPError as exc: # 4xx / 5xx arrive here
116
+ payload = self._safe_json(exc.read())
117
+ self._raise_for_status(exc.code, payload)
118
+ # _raise_for_status always raises for non-2xx; keep the type checker happy.
119
+ raise
120
+ except urllib.error.URLError as exc: # connection refused, DNS, timeout
121
+ raise RunewardError(f"could not reach runeward at {url}: {exc.reason}") from exc
122
+
123
+ def _decode(self, status: int, raw: bytes) -> Any:
124
+ """Handle a response the stdlib treated as success (2xx)."""
125
+ payload = self._safe_json(raw)
126
+ # 202 is "success" to urllib but means an approval gate to runeward.
127
+ if status == 202:
128
+ self._raise_for_status(status, payload)
129
+ return payload
130
+
131
+ @staticmethod
132
+ def _safe_json(raw: bytes) -> Dict[str, Any]:
133
+ if not raw:
134
+ return {}
135
+ try:
136
+ return json.loads(raw.decode("utf-8"))
137
+ except (ValueError, UnicodeDecodeError):
138
+ return {"raw": raw.decode("utf-8", "replace")}
139
+
140
+ @staticmethod
141
+ def _normalize_base_url(base_url: str) -> str:
142
+ base_url = base_url.strip()
143
+ parsed = urllib.parse.urlsplit(base_url)
144
+ if not parsed.scheme:
145
+ base_url = f"https://{base_url}"
146
+ return base_url.rstrip("/")
147
+
148
+ @staticmethod
149
+ def _is_loopback_host(hostname: Optional[str]) -> bool:
150
+ if not hostname:
151
+ return False
152
+ host = hostname.lower().strip("[]")
153
+ return host in {"localhost", "127.0.0.1", "::1"}
154
+
155
+ @staticmethod
156
+ def _env_allows_insecure() -> bool:
157
+ value = os.environ.get("RUNEWARD_ALLOW_INSECURE_HTTP", "").strip().lower()
158
+ return value in {"1", "true", "yes", "on"}
159
+
160
+ @classmethod
161
+ def _validate_transport(cls, base_url: str, allow_insecure: bool) -> None:
162
+ parsed = urllib.parse.urlsplit(base_url)
163
+ if parsed.scheme != "http":
164
+ return
165
+ if cls._is_loopback_host(parsed.hostname):
166
+ return
167
+ if allow_insecure or cls._env_allows_insecure():
168
+ warnings.warn(
169
+ f"runeward client using insecure HTTP transport to non-loopback host: {base_url}",
170
+ RuntimeWarning,
171
+ stacklevel=3,
172
+ )
173
+ return
174
+ raise ValueError(
175
+ "refusing insecure http:// base URL to non-loopback host; "
176
+ "use https://, set allow_insecure=True, or set RUNEWARD_ALLOW_INSECURE_HTTP=1"
177
+ )
178
+
179
+ @staticmethod
180
+ def _segment(value: str) -> str:
181
+ return urllib.parse.quote(value, safe="")
182
+
183
+ @staticmethod
184
+ def _raise_for_status(status: int, payload: Dict[str, Any]) -> None:
185
+ if status == 403 or payload.get("verdict") == "deny":
186
+ raise RunewardDenied(payload.get("reason", "denied by policy"), payload=payload)
187
+ if status == 202 or payload.get("verdict") == "require-approval":
188
+ raise RunewardApprovalRequired(
189
+ payload.get("approval_id", ""),
190
+ reason=payload.get("reason", ""),
191
+ payload=payload,
192
+ )
193
+ raise RunewardError(
194
+ f"runeward returned HTTP {status}: {payload}", status=status, payload=payload
195
+ )
196
+
197
+ # -- health & discovery -----------------------------------------------
198
+
199
+ def healthz(self) -> Any:
200
+ """``GET /healthz`` — liveness check."""
201
+ return self._request("GET", "/healthz")
202
+
203
+ def list_profiles(self) -> List[Dict[str, Any]]:
204
+ """``GET /v1/charters`` — reachable profiles."""
205
+ return self._request("GET", "/v1/charters").get("profiles", [])
206
+
207
+ # -- sandbox lifecycle -------------------------------------------------
208
+
209
+ def create_sandbox(self, profile: str) -> Dict[str, Any]:
210
+ """``POST /v1/citadels`` — provision a sandbox from ``profile``."""
211
+ return self._request("POST", "/v1/citadels", {"profile": profile})
212
+
213
+ def list_sandboxes(self) -> List[Dict[str, Any]]:
214
+ """``GET /v1/citadels``."""
215
+ return self._request("GET", "/v1/citadels").get("sandboxes", [])
216
+
217
+ def get_sandbox(self, sandbox: str) -> Dict[str, Any]:
218
+ """``GET /v1/citadels/{id}``."""
219
+ return self._request("GET", f"/v1/citadels/{self._segment(sandbox)}")
220
+
221
+ def kill_sandbox(self, sandbox: str) -> Any:
222
+ """``DELETE /v1/citadels/{id}`` — tear the sandbox down."""
223
+ return self._request("DELETE", f"/v1/citadels/{self._segment(sandbox)}")
224
+
225
+ # -- execution ---------------------------------------------------------
226
+
227
+ def shell(self, sandbox: str, command: List[str], workdir: str = "") -> Dict[str, Any]:
228
+ """``POST .../shell/exec`` — run ``command`` (an argv list) in the sandbox.
229
+
230
+ Returns ``{"verdict","exit_code","stdout","stderr","duration_ms"}``. An
231
+ ``allow`` verdict with a non-zero ``exit_code`` is a normal program
232
+ error, not a policy denial.
233
+ """
234
+ return self._request(
235
+ "POST", f"/v1/citadels/{self._segment(sandbox)}/shell/exec",
236
+ {"command": list(command), "workdir": workdir},
237
+ )
238
+
239
+ def python(self, sandbox: str, code: str) -> Dict[str, Any]:
240
+ """``POST .../code/python`` — run a Python snippet in the sandbox."""
241
+ return self._request("POST", f"/v1/citadels/{self._segment(sandbox)}/code/python", {"code": code})
242
+
243
+ def node(self, sandbox: str, code: str) -> Dict[str, Any]:
244
+ """``POST .../code/node`` — run a Node.js snippet in the sandbox."""
245
+ return self._request("POST", f"/v1/citadels/{self._segment(sandbox)}/code/node", {"code": code})
246
+
247
+ # -- files -------------------------------------------------------------
248
+
249
+ def read_file(self, sandbox: str, path: str) -> str:
250
+ """``POST .../file/read`` — return the file's ``content``."""
251
+ return self._request(
252
+ "POST", f"/v1/citadels/{self._segment(sandbox)}/file/read", {"path": path}
253
+ ).get("content", "")
254
+
255
+ def write_file(self, sandbox: str, path: str, content: str) -> int:
256
+ """``POST .../file/write`` — write ``content``; return ``bytes`` written."""
257
+ return self._request(
258
+ "POST", f"/v1/citadels/{self._segment(sandbox)}/file/write",
259
+ {"path": path, "content": content},
260
+ ).get("bytes", 0)
261
+
262
+ def list_files(self, sandbox: str, path: str) -> str:
263
+ """``POST .../file/list`` — list a directory; return the raw ``output``."""
264
+ return self._request(
265
+ "POST", f"/v1/citadels/{self._segment(sandbox)}/file/list", {"path": path}
266
+ ).get("output", "")
267
+
268
+ def search_files(self, sandbox: str, query: str, path: str) -> str:
269
+ """``POST .../file/search`` — search for ``query`` under ``path``."""
270
+ return self._request(
271
+ "POST", f"/v1/citadels/{self._segment(sandbox)}/file/search",
272
+ {"query": query, "path": path},
273
+ ).get("output", "")
274
+
275
+ # -- audit -------------------------------------------------------------
276
+
277
+ def audit(self, sandbox: str) -> List[Dict[str, Any]]:
278
+ """``GET .../chronicle`` — this sandbox's ledger events."""
279
+ return self._request("GET", f"/v1/citadels/{self._segment(sandbox)}/chronicle").get("events", [])
280
+
281
+ def verify_audit(self) -> bool:
282
+ """``GET /v1/chronicle/verify`` — verify the ledger hash chain."""
283
+ return bool(self._request("GET", "/v1/chronicle/verify").get("ok", False))
284
+
285
+ # -- approvals ---------------------------------------------------------
286
+
287
+ def list_approvals(self) -> List[Dict[str, Any]]:
288
+ """``GET /v1/conclave`` — pending human-in-the-loop requests."""
289
+ return self._request("GET", "/v1/conclave").get("approvals", [])
290
+
291
+ def approve(self, approval_id: str) -> Any:
292
+ """``POST /v1/conclave/{id}/approve``."""
293
+ return self._request("POST", f"/v1/conclave/{self._segment(approval_id)}/approve")
294
+
295
+ def deny(self, approval_id: str) -> Any:
296
+ """``POST /v1/conclave/{id}/deny``."""
297
+ return self._request("POST", f"/v1/conclave/{self._segment(approval_id)}/deny")
@@ -0,0 +1,222 @@
1
+ """CrewAI tool wrappers around :class:`RunewardClient`.
2
+
3
+ CrewAI (and its ``pydantic`` dependency) are imported *lazily* inside
4
+ :func:`make_runeward_tools` so the base ``runeward`` client works without the
5
+ extra. Install with ``pip install runeward[crewai]``.
6
+
7
+ Each tool is a ``crewai.tools.BaseTool`` subclass with a typed pydantic
8
+ args-schema. Governance verdicts are surfaced as descriptive strings so the crew
9
+ can reason about a denial or an approval gate instead of raising.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, List
15
+
16
+ from .client import RunewardApprovalRequired, RunewardClient, RunewardDenied
17
+
18
+
19
+ def _format_denied(exc: RunewardDenied) -> str:
20
+ return (
21
+ f"DENIED by policy: {exc.reason}. Do not retry this action; choose a "
22
+ "different, allowed approach or report the block to the human."
23
+ )
24
+
25
+
26
+ def _format_approval(exc: RunewardApprovalRequired) -> str:
27
+ return (
28
+ f"APPROVAL REQUIRED (approval_id={exc.approval_id}): "
29
+ f"{exc.reason or 'a human must sign off before this runs'}. "
30
+ "Pause the task and ask the human to approve or deny."
31
+ )
32
+
33
+
34
+ def make_runeward_tools(client: RunewardClient) -> List[Any]:
35
+ """Build a list of CrewAI ``BaseTool`` instances bound to ``client``.
36
+
37
+ Returns one tool per runeward capability. Requires ``crewai`` to be
38
+ installed (``pip install runeward[crewai]``).
39
+ """
40
+ # Lazy import so crewai stays an optional extra.
41
+ try:
42
+ from crewai.tools import BaseTool
43
+ from pydantic import BaseModel, Field
44
+ except ImportError as exc: # pragma: no cover - depends on optional extra
45
+ raise ImportError(
46
+ "CrewAI is required for make_runeward_tools(). "
47
+ "Install it with: pip install runeward[crewai]"
48
+ ) from exc
49
+
50
+ # --- argument schemas -------------------------------------------------
51
+
52
+ class CreateCitadelArgs(BaseModel):
53
+ profile: str = Field(..., description="Charter name, e.g. 'dev' or 'governed'.")
54
+
55
+ class ShellArgs(BaseModel):
56
+ sandbox: str = Field(..., description="Citadel id from create_citadel.")
57
+ command: List[str] = Field(..., description="argv list, e.g. ['ls','-la'].")
58
+ workdir: str = Field("", description="Optional working directory.")
59
+
60
+ class CodeArgs(BaseModel):
61
+ sandbox: str = Field(..., description="Citadel id.")
62
+ code: str = Field(..., description="Source code to execute.")
63
+
64
+ class ReadArgs(BaseModel):
65
+ sandbox: str = Field(..., description="Citadel id.")
66
+ path: str = Field(..., description="File path to read.")
67
+
68
+ class WriteArgs(BaseModel):
69
+ sandbox: str = Field(..., description="Citadel id.")
70
+ path: str = Field(..., description="File path to write.")
71
+ content: str = Field(..., description="Content to write.")
72
+
73
+ class ListArgs(BaseModel):
74
+ sandbox: str = Field(..., description="Citadel id.")
75
+ path: str = Field(..., description="Directory path to list.")
76
+
77
+ class SearchArgs(BaseModel):
78
+ sandbox: str = Field(..., description="Citadel id.")
79
+ query: str = Field(..., description="Search query.")
80
+ path: str = Field(..., description="Path to search under.")
81
+
82
+ class CitadelArgs(BaseModel):
83
+ sandbox: str = Field(..., description="Citadel id.")
84
+
85
+ class NoArgs(BaseModel):
86
+ pass
87
+
88
+ # --- tool definitions -------------------------------------------------
89
+
90
+ class CreateCitadelTool(BaseTool):
91
+ name: str = "runeward_create_citadel"
92
+ description: str = "Provision a governed Citadel from a runeward Charter. Returns Citadel metadata including its id."
93
+ args_schema: type = CreateCitadelArgs
94
+
95
+ def _run(self, profile: str) -> str:
96
+ try:
97
+ return str(client.create_sandbox(profile))
98
+ except RunewardDenied as e:
99
+ return _format_denied(e)
100
+ except RunewardApprovalRequired as e:
101
+ return _format_approval(e)
102
+
103
+ class ShellTool(BaseTool):
104
+ name: str = "runeward_shell"
105
+ description: str = "Run a shell command (argv list) in a Citadel. Returns verdict, exit_code, stdout, stderr."
106
+ args_schema: type = ShellArgs
107
+
108
+ def _run(self, sandbox: str, command: List[str], workdir: str = "") -> str:
109
+ try:
110
+ return str(client.shell(sandbox, command, workdir))
111
+ except RunewardDenied as e:
112
+ return _format_denied(e)
113
+ except RunewardApprovalRequired as e:
114
+ return _format_approval(e)
115
+
116
+ class PythonTool(BaseTool):
117
+ name: str = "runeward_python"
118
+ description: str = "Run a Python code snippet inside the Citadel."
119
+ args_schema: type = CodeArgs
120
+
121
+ def _run(self, sandbox: str, code: str) -> str:
122
+ try:
123
+ return str(client.python(sandbox, code))
124
+ except RunewardDenied as e:
125
+ return _format_denied(e)
126
+ except RunewardApprovalRequired as e:
127
+ return _format_approval(e)
128
+
129
+ class NodeTool(BaseTool):
130
+ name: str = "runeward_node"
131
+ description: str = "Run a Node.js code snippet inside the Citadel."
132
+ args_schema: type = CodeArgs
133
+
134
+ def _run(self, sandbox: str, code: str) -> str:
135
+ try:
136
+ return str(client.node(sandbox, code))
137
+ except RunewardDenied as e:
138
+ return _format_denied(e)
139
+ except RunewardApprovalRequired as e:
140
+ return _format_approval(e)
141
+
142
+ class ReadFileTool(BaseTool):
143
+ name: str = "runeward_read_file"
144
+ description: str = "Read a file's contents from the Citadel."
145
+ args_schema: type = ReadArgs
146
+
147
+ def _run(self, sandbox: str, path: str) -> str:
148
+ try:
149
+ return client.read_file(sandbox, path)
150
+ except RunewardDenied as e:
151
+ return _format_denied(e)
152
+ except RunewardApprovalRequired as e:
153
+ return _format_approval(e)
154
+
155
+ class WriteFileTool(BaseTool):
156
+ name: str = "runeward_write_file"
157
+ description: str = "Write content to a file in the Citadel."
158
+ args_schema: type = WriteArgs
159
+
160
+ def _run(self, sandbox: str, path: str, content: str) -> str:
161
+ try:
162
+ return f"wrote {client.write_file(sandbox, path, content)} bytes to {path}"
163
+ except RunewardDenied as e:
164
+ return _format_denied(e)
165
+ except RunewardApprovalRequired as e:
166
+ return _format_approval(e)
167
+
168
+ class ListFilesTool(BaseTool):
169
+ name: str = "runeward_list_files"
170
+ description: str = "List a directory in the Citadel."
171
+ args_schema: type = ListArgs
172
+
173
+ def _run(self, sandbox: str, path: str) -> str:
174
+ try:
175
+ return client.list_files(sandbox, path)
176
+ except RunewardDenied as e:
177
+ return _format_denied(e)
178
+ except RunewardApprovalRequired as e:
179
+ return _format_approval(e)
180
+
181
+ class SearchFilesTool(BaseTool):
182
+ name: str = "runeward_search_files"
183
+ description: str = "Search for a query string under a path in the Citadel."
184
+ args_schema: type = SearchArgs
185
+
186
+ def _run(self, sandbox: str, query: str, path: str) -> str:
187
+ try:
188
+ return client.search_files(sandbox, query, path)
189
+ except RunewardDenied as e:
190
+ return _format_denied(e)
191
+ except RunewardApprovalRequired as e:
192
+ return _format_approval(e)
193
+
194
+ class ListConclaveTool(BaseTool):
195
+ name: str = "runeward_list_conclave"
196
+ description: str = "List pending human-in-the-loop Conclave requests."
197
+ args_schema: type = NoArgs
198
+
199
+ def _run(self) -> str:
200
+ return str(client.list_approvals())
201
+
202
+ class KillCitadelTool(BaseTool):
203
+ name: str = "runeward_kill_citadel"
204
+ description: str = "Tear down a Citadel when the task is finished."
205
+ args_schema: type = CitadelArgs
206
+
207
+ def _run(self, sandbox: str) -> str:
208
+ client.kill_sandbox(sandbox)
209
+ return f"Citadel {sandbox} terminated"
210
+
211
+ return [
212
+ CreateCitadelTool(),
213
+ ShellTool(),
214
+ PythonTool(),
215
+ NodeTool(),
216
+ ReadFileTool(),
217
+ WriteFileTool(),
218
+ ListFilesTool(),
219
+ SearchFilesTool(),
220
+ ListConclaveTool(),
221
+ KillCitadelTool(),
222
+ ]