darwin-agentic-cloud 0.1.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.
- darwin/__init__.py +10 -0
- darwin/agenticcloud/__init__.py +7 -0
- darwin/agenticcloud/attestation.py +77 -0
- darwin/agenticcloud/cli.py +502 -0
- darwin/agenticcloud/cost.py +76 -0
- darwin/agenticcloud/hashing.py +32 -0
- darwin/agenticcloud/mcp_server.py +303 -0
- darwin/agenticcloud/runtime.py +109 -0
- darwin/agenticcloud/sandbox.py +235 -0
- darwin/agenticcloud/server.py +230 -0
- darwin/agenticcloud/signing.py +100 -0
- darwin/agenticcloud/storage.py +230 -0
- darwin/agenticcloud/templates/docs.html +528 -0
- darwin/agenticcloud/types.py +57 -0
- darwin_agentic_cloud-0.1.0.dist-info/METADATA +444 -0
- darwin_agentic_cloud-0.1.0.dist-info/RECORD +19 -0
- darwin_agentic_cloud-0.1.0.dist-info/WHEEL +4 -0
- darwin_agentic_cloud-0.1.0.dist-info/entry_points.txt +2 -0
- darwin_agentic_cloud-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Deterministic hashing for DAC attestations.
|
|
2
|
+
|
|
3
|
+
Canonical JSON (sorted keys, no whitespace, UTF-8) + SHA-256 so the same
|
|
4
|
+
logical content always produces the same hash across implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def canonical_json(obj: Any) -> bytes:
|
|
15
|
+
"""Serialize obj to canonical JSON bytes (sorted keys, no whitespace, UTF-8)."""
|
|
16
|
+
return json.dumps(
|
|
17
|
+
obj,
|
|
18
|
+
ensure_ascii=False,
|
|
19
|
+
sort_keys=True,
|
|
20
|
+
separators=(",", ":"),
|
|
21
|
+
allow_nan=False,
|
|
22
|
+
).encode("utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_hex(data: bytes) -> str:
|
|
26
|
+
"""Return the hex-encoded SHA-256 of data."""
|
|
27
|
+
return hashlib.sha256(data).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def content_hash(obj: Any) -> str:
|
|
31
|
+
"""Hex SHA-256 of obj serialized as canonical JSON."""
|
|
32
|
+
return sha256_hex(canonical_json(obj))
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""DAC MCP server.
|
|
2
|
+
|
|
3
|
+
Exposes DAC as an MCP server over stdio so Claude Desktop, Cursor,
|
|
4
|
+
and other MCP clients can call DAC as a tool.
|
|
5
|
+
|
|
6
|
+
Tools:
|
|
7
|
+
dac_run_python — execute Python code, return signed attestation
|
|
8
|
+
dac_run_node — execute Node.js code, return signed attestation
|
|
9
|
+
dac_verify_attestation — verify a signed attestation
|
|
10
|
+
dac_identity — show this DAC instance's identity
|
|
11
|
+
dac_history_recent — list recent attestations
|
|
12
|
+
dac_history_stats — aggregate stats across stored attestations
|
|
13
|
+
dac_history_get — fetch a specific attestation by ID
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from mcp.server import Server
|
|
23
|
+
from mcp.server.stdio import stdio_server
|
|
24
|
+
from mcp.types import TextContent, Tool
|
|
25
|
+
|
|
26
|
+
from darwin import agenticcloud as dac
|
|
27
|
+
from darwin.agenticcloud.attestation import verify_attestation
|
|
28
|
+
from darwin.agenticcloud.runtime import Runtime
|
|
29
|
+
from darwin.agenticcloud.sandbox import SUBSTRATE_ID
|
|
30
|
+
|
|
31
|
+
# TODO(v0.2): per-role signing keys (cli vs http vs mcp) via HKDF.
|
|
32
|
+
_runtime = Runtime()
|
|
33
|
+
_server = Server("darwin-agenticcloud")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@_server.list_tools()
|
|
37
|
+
async def list_tools() -> list[Tool]:
|
|
38
|
+
return [
|
|
39
|
+
Tool(
|
|
40
|
+
name="dac_run_python",
|
|
41
|
+
description=(
|
|
42
|
+
"Execute Python code in an isolated Docker sandbox and return a "
|
|
43
|
+
"cryptographically signed attestation of the execution. The attestation "
|
|
44
|
+
"binds the source code, the output, the substrate identity, the cost, "
|
|
45
|
+
"and the signer's identity. Any tampering with any field breaks "
|
|
46
|
+
"verification. Workloads whose maximum possible cost exceeds "
|
|
47
|
+
"cost_cap_usd are rejected before the sandbox is launched; the "
|
|
48
|
+
"rejection itself is signed."
|
|
49
|
+
),
|
|
50
|
+
inputSchema={
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"code": {"type": "string", "description": "Python source code."},
|
|
54
|
+
"timeout_sec": {"type": "integer", "minimum": 1, "maximum": 600, "default": 30},
|
|
55
|
+
"memory_mb": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"minimum": 64,
|
|
58
|
+
"maximum": 8192,
|
|
59
|
+
"default": 512,
|
|
60
|
+
},
|
|
61
|
+
"cost_cap_usd": {
|
|
62
|
+
"type": "number",
|
|
63
|
+
"minimum": 0.0001,
|
|
64
|
+
"default": 0.01,
|
|
65
|
+
"description": "Maximum cost the workload may incur. Pre-flight enforced.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"required": ["code"],
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
Tool(
|
|
72
|
+
name="dac_run_node",
|
|
73
|
+
description=(
|
|
74
|
+
"Execute Node.js code in an isolated Docker sandbox and return a "
|
|
75
|
+
"cryptographically signed attestation."
|
|
76
|
+
),
|
|
77
|
+
inputSchema={
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"code": {"type": "string", "description": "JS/Node source code."},
|
|
81
|
+
"timeout_sec": {"type": "integer", "minimum": 1, "maximum": 600, "default": 30},
|
|
82
|
+
"memory_mb": {
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"minimum": 64,
|
|
85
|
+
"maximum": 8192,
|
|
86
|
+
"default": 512,
|
|
87
|
+
},
|
|
88
|
+
"cost_cap_usd": {"type": "number", "minimum": 0.0001, "default": 0.01},
|
|
89
|
+
},
|
|
90
|
+
"required": ["code"],
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
Tool(
|
|
94
|
+
name="dac_verify_attestation",
|
|
95
|
+
description=(
|
|
96
|
+
"Verify a signed DAC attestation. Returns whether the signature is "
|
|
97
|
+
"valid for the attestation payload under the embedded public key."
|
|
98
|
+
),
|
|
99
|
+
inputSchema={
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"attestation": {"type": "object", "description": "Attestation payload."},
|
|
103
|
+
"signature_b64": {"type": "string", "description": "Base64 Ed25519 signature."},
|
|
104
|
+
"public_key_b64": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"description": "Base64 Ed25519 public key.",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
"required": ["attestation", "signature_b64", "public_key_b64"],
|
|
110
|
+
},
|
|
111
|
+
),
|
|
112
|
+
Tool(
|
|
113
|
+
name="dac_identity",
|
|
114
|
+
description="Return this DAC instance's identity.",
|
|
115
|
+
inputSchema={"type": "object", "properties": {}},
|
|
116
|
+
),
|
|
117
|
+
Tool(
|
|
118
|
+
name="dac_history_recent",
|
|
119
|
+
description=(
|
|
120
|
+
"List the most recent attestations stored by this DAC instance. "
|
|
121
|
+
"Useful for an agent to audit its own past execution. Returns a "
|
|
122
|
+
"summary per attestation (id, workload_id, status, cost, timing, "
|
|
123
|
+
"substrate)."
|
|
124
|
+
),
|
|
125
|
+
inputSchema={
|
|
126
|
+
"type": "object",
|
|
127
|
+
"properties": {
|
|
128
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20},
|
|
129
|
+
"status": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"description": "Filter by status (ok, error, timeout, oom, cost_exceeded).",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
),
|
|
136
|
+
Tool(
|
|
137
|
+
name="dac_history_stats",
|
|
138
|
+
description=(
|
|
139
|
+
"Return aggregate stats across all attestations stored by this DAC "
|
|
140
|
+
"instance: total count, total cost USD, count by status."
|
|
141
|
+
),
|
|
142
|
+
inputSchema={"type": "object", "properties": {}},
|
|
143
|
+
),
|
|
144
|
+
Tool(
|
|
145
|
+
name="dac_history_get",
|
|
146
|
+
description=(
|
|
147
|
+
"Fetch the full signed attestation for a given attestation ID. "
|
|
148
|
+
"Accepts the full UUID. Returns the complete signed attestation "
|
|
149
|
+
"(attestation, signature_b64, public_key_b64) which can be passed "
|
|
150
|
+
"to dac_verify_attestation."
|
|
151
|
+
),
|
|
152
|
+
inputSchema={
|
|
153
|
+
"type": "object",
|
|
154
|
+
"properties": {
|
|
155
|
+
"attestation_id": {"type": "string", "description": "Full attestation UUID."},
|
|
156
|
+
},
|
|
157
|
+
"required": ["attestation_id"],
|
|
158
|
+
},
|
|
159
|
+
),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@_server.call_tool()
|
|
164
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
165
|
+
if name == "dac_run_python":
|
|
166
|
+
return await _handle_run(arguments, language="python")
|
|
167
|
+
if name == "dac_run_node":
|
|
168
|
+
return await _handle_run(arguments, language="node")
|
|
169
|
+
if name == "dac_verify_attestation":
|
|
170
|
+
return _handle_verify(arguments)
|
|
171
|
+
if name == "dac_identity":
|
|
172
|
+
return _handle_identity()
|
|
173
|
+
if name == "dac_history_recent":
|
|
174
|
+
return _handle_history_recent(arguments)
|
|
175
|
+
if name == "dac_history_stats":
|
|
176
|
+
return _handle_history_stats()
|
|
177
|
+
if name == "dac_history_get":
|
|
178
|
+
return _handle_history_get(arguments)
|
|
179
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def _handle_run(arguments: dict[str, Any], language: str) -> list[TextContent]:
|
|
183
|
+
from darwin.agenticcloud.types import WorkloadSpec
|
|
184
|
+
|
|
185
|
+
spec = WorkloadSpec(
|
|
186
|
+
code=arguments["code"],
|
|
187
|
+
language=language,
|
|
188
|
+
timeout_sec=int(arguments.get("timeout_sec", 30)),
|
|
189
|
+
memory_mb=int(arguments.get("memory_mb", 512)),
|
|
190
|
+
cost_cap_usd=float(arguments.get("cost_cap_usd", 0.01)),
|
|
191
|
+
)
|
|
192
|
+
signed = await asyncio.to_thread(_runtime.run, spec)
|
|
193
|
+
payload = {
|
|
194
|
+
"attestation": signed.attestation,
|
|
195
|
+
"signature_b64": signed.signature_b64,
|
|
196
|
+
"public_key_b64": signed.public_key_b64,
|
|
197
|
+
}
|
|
198
|
+
return [TextContent(type="text", text=json.dumps(payload, indent=2))]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _handle_verify(arguments: dict[str, Any]) -> list[TextContent]:
|
|
202
|
+
try:
|
|
203
|
+
ok = verify_attestation(
|
|
204
|
+
{
|
|
205
|
+
"attestation": arguments["attestation"],
|
|
206
|
+
"signature_b64": arguments["signature_b64"],
|
|
207
|
+
"public_key_b64": arguments["public_key_b64"],
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
result = {
|
|
211
|
+
"verified": ok,
|
|
212
|
+
"attestation_id": arguments["attestation"].get("attestation_id"),
|
|
213
|
+
"signer_key_id": arguments["attestation"].get("signer_key_id"),
|
|
214
|
+
}
|
|
215
|
+
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
|
216
|
+
except Exception as e:
|
|
217
|
+
return [
|
|
218
|
+
TextContent(
|
|
219
|
+
type="text", text=json.dumps({"verified": False, "error": str(e)}, indent=2)
|
|
220
|
+
)
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _handle_identity() -> list[TextContent]:
|
|
225
|
+
signer = _runtime.signer
|
|
226
|
+
payload = {
|
|
227
|
+
"key_id": signer.key_id(),
|
|
228
|
+
"public_key_b64": signer.public_key_b64(),
|
|
229
|
+
"substrate_id": SUBSTRATE_ID,
|
|
230
|
+
"schema": dac.ATTESTATION_SCHEMA,
|
|
231
|
+
"version": dac.__version__,
|
|
232
|
+
}
|
|
233
|
+
return [TextContent(type="text", text=json.dumps(payload, indent=2))]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _handle_history_recent(arguments: dict[str, Any]) -> list[TextContent]:
|
|
237
|
+
limit = int(arguments.get("limit", 20))
|
|
238
|
+
status = arguments.get("status")
|
|
239
|
+
store = _runtime.store
|
|
240
|
+
rows = store.list_by_status(status, limit=limit) if status else store.list_recent(limit=limit)
|
|
241
|
+
summaries = [
|
|
242
|
+
{
|
|
243
|
+
"attestation_id": r.attestation_id,
|
|
244
|
+
"workload_id": r.workload_id,
|
|
245
|
+
"signer_key_id": r.signer_key_id,
|
|
246
|
+
"substrate_id": r.substrate_id,
|
|
247
|
+
"status": r.status,
|
|
248
|
+
"issued_at": r.issued_at,
|
|
249
|
+
"cost_usd": r.cost_usd,
|
|
250
|
+
"wall_time_sec": r.wall_time_sec,
|
|
251
|
+
"schema_version": r.schema_version,
|
|
252
|
+
}
|
|
253
|
+
for r in rows
|
|
254
|
+
]
|
|
255
|
+
return [
|
|
256
|
+
TextContent(
|
|
257
|
+
type="text",
|
|
258
|
+
text=json.dumps({"count": len(summaries), "attestations": summaries}, indent=2),
|
|
259
|
+
)
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _handle_history_stats() -> list[TextContent]:
|
|
264
|
+
store = _runtime.store
|
|
265
|
+
payload = {
|
|
266
|
+
"total_count": store.count(),
|
|
267
|
+
"total_cost_usd": store.total_cost_usd(),
|
|
268
|
+
"by_status": {
|
|
269
|
+
"ok": len(store.list_by_status("ok", limit=10**9)),
|
|
270
|
+
"error": len(store.list_by_status("error", limit=10**9)),
|
|
271
|
+
"timeout": len(store.list_by_status("timeout", limit=10**9)),
|
|
272
|
+
"oom": len(store.list_by_status("oom", limit=10**9)),
|
|
273
|
+
"cost_exceeded": len(store.list_by_status("cost_exceeded", limit=10**9)),
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
return [TextContent(type="text", text=json.dumps(payload, indent=2))]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _handle_history_get(arguments: dict[str, Any]) -> list[TextContent]:
|
|
280
|
+
store = _runtime.store
|
|
281
|
+
attestation_id = arguments["attestation_id"]
|
|
282
|
+
fetched = store.get(attestation_id)
|
|
283
|
+
if fetched is None:
|
|
284
|
+
return [
|
|
285
|
+
TextContent(
|
|
286
|
+
type="text",
|
|
287
|
+
text=json.dumps({"error": "not_found", "attestation_id": attestation_id}, indent=2),
|
|
288
|
+
)
|
|
289
|
+
]
|
|
290
|
+
return [TextContent(type="text", text=json.dumps(fetched.signed_attestation, indent=2))]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def main() -> None:
|
|
294
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
295
|
+
await _server.run(read_stream, write_stream, _server.create_initialization_options())
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run() -> None:
|
|
299
|
+
asyncio.run(main())
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
run()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""DAC runtime orchestrator.
|
|
2
|
+
|
|
3
|
+
The single entry point that turns a WorkloadSpec into a SignedAttestation:
|
|
4
|
+
|
|
5
|
+
spec → budget check → sandbox.execute → ExecutionResult → signed attestation → store
|
|
6
|
+
|
|
7
|
+
Every signed attestation (including budget rejections) is persisted to
|
|
8
|
+
the AttestationStore for audit and history.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from darwin.agenticcloud.attestation import build_signed_attestation
|
|
17
|
+
from darwin.agenticcloud.cost import BudgetExceeded, check_budget, cost_for_seconds
|
|
18
|
+
from darwin.agenticcloud.hashing import sha256_hex
|
|
19
|
+
from darwin.agenticcloud.sandbox import SUBSTRATE_ID, DockerSandbox, SandboxResult
|
|
20
|
+
from darwin.agenticcloud.signing import Signer
|
|
21
|
+
from darwin.agenticcloud.storage import AttestationStore
|
|
22
|
+
from darwin.agenticcloud.types import ExecutionResult, SignedAttestation, WorkloadSpec
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Runtime:
|
|
26
|
+
"""The DAC execution runtime."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
sandbox: DockerSandbox | None = None,
|
|
31
|
+
signer: Signer | None = None,
|
|
32
|
+
store: AttestationStore | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._sandbox = sandbox or DockerSandbox()
|
|
35
|
+
self._signer = signer or Signer()
|
|
36
|
+
self._store = store if store is not None else AttestationStore()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def signer(self) -> Signer:
|
|
40
|
+
return self._signer
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def store(self) -> AttestationStore:
|
|
44
|
+
return self._store
|
|
45
|
+
|
|
46
|
+
def run(self, spec: WorkloadSpec) -> SignedAttestation:
|
|
47
|
+
"""Execute a workload spec and return a signed attestation.
|
|
48
|
+
|
|
49
|
+
Every signed attestation (success, error, timeout, cost_exceeded)
|
|
50
|
+
is persisted before returning.
|
|
51
|
+
"""
|
|
52
|
+
workload_id = f"wl-{uuid.uuid4().hex[:12]}"
|
|
53
|
+
|
|
54
|
+
# Pre-flight budget check (no sandbox launch on rejection)
|
|
55
|
+
try:
|
|
56
|
+
check_budget(spec, SUBSTRATE_ID)
|
|
57
|
+
except BudgetExceeded as e:
|
|
58
|
+
signed = self._build_rejection_attestation(spec, workload_id, str(e))
|
|
59
|
+
self._store.save(signed)
|
|
60
|
+
return signed
|
|
61
|
+
|
|
62
|
+
# Execute in the sandbox
|
|
63
|
+
sandbox_result: SandboxResult = self._sandbox.execute(
|
|
64
|
+
code=spec.code,
|
|
65
|
+
language=spec.language,
|
|
66
|
+
timeout_sec=spec.timeout_sec,
|
|
67
|
+
memory_mb=spec.memory_mb,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
cost_usd = cost_for_seconds(sandbox_result.wall_time_sec, sandbox_result.substrate_id)
|
|
71
|
+
|
|
72
|
+
execution_result = ExecutionResult(
|
|
73
|
+
workload_id=workload_id,
|
|
74
|
+
status=sandbox_result.status,
|
|
75
|
+
stdout=sandbox_result.stdout,
|
|
76
|
+
stderr=sandbox_result.stderr,
|
|
77
|
+
exit_code=sandbox_result.exit_code,
|
|
78
|
+
started_at=sandbox_result.started_at,
|
|
79
|
+
ended_at=sandbox_result.ended_at,
|
|
80
|
+
wall_time_sec=sandbox_result.wall_time_sec,
|
|
81
|
+
cost_usd=cost_usd,
|
|
82
|
+
substrate_id=sandbox_result.substrate_id,
|
|
83
|
+
output_hash=sandbox_result.output_hash,
|
|
84
|
+
error=sandbox_result.error,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
signed = build_signed_attestation(spec, execution_result, self._signer)
|
|
88
|
+
self._store.save(signed)
|
|
89
|
+
return signed
|
|
90
|
+
|
|
91
|
+
def _build_rejection_attestation(
|
|
92
|
+
self, spec: WorkloadSpec, workload_id: str, error: str
|
|
93
|
+
) -> SignedAttestation:
|
|
94
|
+
now = time.time()
|
|
95
|
+
execution_result = ExecutionResult(
|
|
96
|
+
workload_id=workload_id,
|
|
97
|
+
status="cost_exceeded",
|
|
98
|
+
stdout="",
|
|
99
|
+
stderr="",
|
|
100
|
+
exit_code=None,
|
|
101
|
+
started_at=now,
|
|
102
|
+
ended_at=now,
|
|
103
|
+
wall_time_sec=0.0,
|
|
104
|
+
cost_usd=0.0,
|
|
105
|
+
substrate_id=SUBSTRATE_ID,
|
|
106
|
+
output_hash=sha256_hex(b""),
|
|
107
|
+
error=error,
|
|
108
|
+
)
|
|
109
|
+
return build_signed_attestation(spec, execution_result, self._signer)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Docker-based sandbox for executing workloads.
|
|
2
|
+
|
|
3
|
+
Runs code in an isolated container with:
|
|
4
|
+
- Memory and CPU limits
|
|
5
|
+
- Wall-time timeout
|
|
6
|
+
- No network access (default)
|
|
7
|
+
- Non-root user
|
|
8
|
+
- Read-only root filesystem with writable /tmp
|
|
9
|
+
- Dropped capabilities
|
|
10
|
+
- No /proc, no /sys mounts beyond defaults
|
|
11
|
+
|
|
12
|
+
This is the v0 sandbox. Production will swap for Firecracker microVMs
|
|
13
|
+
or Kata Containers with the same interface.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import io
|
|
20
|
+
import tarfile
|
|
21
|
+
import time
|
|
22
|
+
import uuid
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
import docker
|
|
26
|
+
from docker.errors import APIError, ContainerError, ImageNotFound
|
|
27
|
+
from docker.models.containers import Container
|
|
28
|
+
|
|
29
|
+
from darwin.agenticcloud.hashing import sha256_hex
|
|
30
|
+
|
|
31
|
+
# Base images we trust. Pin by digest in production.
|
|
32
|
+
IMAGE_PYTHON = "python:3.11-slim"
|
|
33
|
+
IMAGE_NODE = "node:20-slim"
|
|
34
|
+
|
|
35
|
+
LANGUAGE_IMAGES = {
|
|
36
|
+
"python": IMAGE_PYTHON,
|
|
37
|
+
"node": IMAGE_NODE,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
LANGUAGE_COMMANDS = {
|
|
41
|
+
"python": ["python", "/workload/main.py"],
|
|
42
|
+
"node": ["node", "/workload/main.js"],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
LANGUAGE_FILENAMES = {
|
|
46
|
+
"python": "main.py",
|
|
47
|
+
"node": "main.js",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
SUBSTRATE_ID = "local-docker-v0"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SandboxResult:
|
|
55
|
+
"""Raw execution data from the sandbox."""
|
|
56
|
+
|
|
57
|
+
status: str # "ok" | "error" | "timeout" | "oom"
|
|
58
|
+
stdout: str
|
|
59
|
+
stderr: str
|
|
60
|
+
exit_code: int | None
|
|
61
|
+
started_at: float
|
|
62
|
+
ended_at: float
|
|
63
|
+
wall_time_sec: float
|
|
64
|
+
substrate_id: str
|
|
65
|
+
output_hash: str
|
|
66
|
+
error: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DockerSandbox:
|
|
70
|
+
"""Execute code in a Docker container with strict isolation."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, client: docker.DockerClient | None = None) -> None:
|
|
73
|
+
# Lazy: don't connect until execute() is first called. Lets the server
|
|
74
|
+
# start in environments without a local Docker daemon (e.g. hosted
|
|
75
|
+
# read-only demos). Calling execute() without a reachable daemon
|
|
76
|
+
# still raises a clear error, just later instead of at import time.
|
|
77
|
+
self._client_arg = client
|
|
78
|
+
self._client: docker.DockerClient | None = None
|
|
79
|
+
|
|
80
|
+
def _ensure_client(self) -> docker.DockerClient:
|
|
81
|
+
if self._client is None:
|
|
82
|
+
self._client = self._client_arg or docker.from_env()
|
|
83
|
+
self._verify_daemon()
|
|
84
|
+
return self._client
|
|
85
|
+
|
|
86
|
+
def _verify_daemon(self) -> None:
|
|
87
|
+
"""Fail fast if Docker isn't reachable."""
|
|
88
|
+
try:
|
|
89
|
+
self._ensure_client().ping()
|
|
90
|
+
except Exception as e:
|
|
91
|
+
raise RuntimeError("Docker daemon is not reachable. Is Docker Desktop running?") from e
|
|
92
|
+
|
|
93
|
+
def execute(
|
|
94
|
+
self,
|
|
95
|
+
code: str,
|
|
96
|
+
language: str = "python",
|
|
97
|
+
timeout_sec: int = 30,
|
|
98
|
+
memory_mb: int = 512,
|
|
99
|
+
cpu_quota: float = 1.0,
|
|
100
|
+
) -> SandboxResult:
|
|
101
|
+
"""Run code in a sandboxed container and return the result."""
|
|
102
|
+
if language not in LANGUAGE_IMAGES:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Unsupported language: {language!r}. Supported: {sorted(LANGUAGE_IMAGES)}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
image = LANGUAGE_IMAGES[language]
|
|
108
|
+
command = LANGUAGE_COMMANDS[language]
|
|
109
|
+
filename = LANGUAGE_FILENAMES[language]
|
|
110
|
+
|
|
111
|
+
self._ensure_image(image)
|
|
112
|
+
|
|
113
|
+
container_name = f"dac-{uuid.uuid4().hex[:12]}"
|
|
114
|
+
started_at = time.time()
|
|
115
|
+
|
|
116
|
+
container: Container | None = None
|
|
117
|
+
try:
|
|
118
|
+
container = self._ensure_client().containers.create(
|
|
119
|
+
image=image,
|
|
120
|
+
command=command,
|
|
121
|
+
name=container_name,
|
|
122
|
+
# Resource limits
|
|
123
|
+
mem_limit=f"{memory_mb}m",
|
|
124
|
+
memswap_limit=f"{memory_mb}m", # no swap
|
|
125
|
+
nano_cpus=int(cpu_quota * 1_000_000_000),
|
|
126
|
+
pids_limit=128,
|
|
127
|
+
# Isolation
|
|
128
|
+
network_disabled=True,
|
|
129
|
+
read_only=False,
|
|
130
|
+
tmpfs={"/tmp": "size=64m,mode=1777"},
|
|
131
|
+
cap_drop=["ALL"],
|
|
132
|
+
security_opt=["no-new-privileges"],
|
|
133
|
+
user="65534:65534", # nobody:nogroup
|
|
134
|
+
working_dir="/workload",
|
|
135
|
+
# Cleanup
|
|
136
|
+
auto_remove=False,
|
|
137
|
+
detach=True,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self._copy_code_into_container(container, filename, code)
|
|
141
|
+
container.start()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
exit_status = container.wait(timeout=timeout_sec)
|
|
145
|
+
exit_code = exit_status.get("StatusCode")
|
|
146
|
+
timed_out = False
|
|
147
|
+
except Exception:
|
|
148
|
+
# docker-py raises requests.exceptions.ReadTimeout on wait timeout
|
|
149
|
+
container.kill()
|
|
150
|
+
exit_code = None
|
|
151
|
+
timed_out = True
|
|
152
|
+
|
|
153
|
+
ended_at = time.time()
|
|
154
|
+
|
|
155
|
+
stdout = self._safe_logs(container, stdout=True, stderr=False)
|
|
156
|
+
stderr = self._safe_logs(container, stdout=False, stderr=True)
|
|
157
|
+
|
|
158
|
+
if timed_out:
|
|
159
|
+
status = "timeout"
|
|
160
|
+
elif self._was_oom_killed(container):
|
|
161
|
+
status = "oom"
|
|
162
|
+
elif exit_code == 0:
|
|
163
|
+
status = "ok"
|
|
164
|
+
else:
|
|
165
|
+
status = "error"
|
|
166
|
+
|
|
167
|
+
return SandboxResult(
|
|
168
|
+
status=status,
|
|
169
|
+
stdout=stdout,
|
|
170
|
+
stderr=stderr,
|
|
171
|
+
exit_code=exit_code,
|
|
172
|
+
started_at=started_at,
|
|
173
|
+
ended_at=ended_at,
|
|
174
|
+
wall_time_sec=ended_at - started_at,
|
|
175
|
+
substrate_id=SUBSTRATE_ID,
|
|
176
|
+
output_hash=sha256_hex(stdout.encode("utf-8")),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
except (ContainerError, APIError) as e:
|
|
180
|
+
ended_at = time.time()
|
|
181
|
+
return SandboxResult(
|
|
182
|
+
status="error",
|
|
183
|
+
stdout="",
|
|
184
|
+
stderr="",
|
|
185
|
+
exit_code=None,
|
|
186
|
+
started_at=started_at,
|
|
187
|
+
ended_at=ended_at,
|
|
188
|
+
wall_time_sec=ended_at - started_at,
|
|
189
|
+
substrate_id=SUBSTRATE_ID,
|
|
190
|
+
output_hash=sha256_hex(b""),
|
|
191
|
+
error=f"Sandbox error: {type(e).__name__}: {e}",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
finally:
|
|
195
|
+
if container is not None:
|
|
196
|
+
with contextlib.suppress(Exception):
|
|
197
|
+
container.remove(force=True)
|
|
198
|
+
|
|
199
|
+
def _ensure_image(self, image: str) -> None:
|
|
200
|
+
"""Pull the image if it's not present locally."""
|
|
201
|
+
try:
|
|
202
|
+
self._ensure_client().images.get(image)
|
|
203
|
+
except ImageNotFound:
|
|
204
|
+
self._ensure_client().images.pull(image)
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _copy_code_into_container(container: Container, filename: str, code: str) -> None:
|
|
208
|
+
"""Stream code into the container as a tar archive at /workload/<filename>."""
|
|
209
|
+
tar_stream = io.BytesIO()
|
|
210
|
+
with tarfile.open(fileobj=tar_stream, mode="w") as tar:
|
|
211
|
+
data = code.encode("utf-8")
|
|
212
|
+
info = tarfile.TarInfo(name=filename)
|
|
213
|
+
info.size = len(data)
|
|
214
|
+
info.mode = 0o644
|
|
215
|
+
tar.addfile(info, io.BytesIO(data))
|
|
216
|
+
tar_stream.seek(0)
|
|
217
|
+
container.put_archive("/workload", tar_stream.read())
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _safe_logs(container: Container, *, stdout: bool, stderr: bool) -> str:
|
|
221
|
+
try:
|
|
222
|
+
raw = container.logs(stdout=stdout, stderr=stderr)
|
|
223
|
+
if isinstance(raw, bytes):
|
|
224
|
+
return raw.decode("utf-8", errors="replace")
|
|
225
|
+
return str(raw)
|
|
226
|
+
except Exception:
|
|
227
|
+
return ""
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _was_oom_killed(container: Container) -> bool:
|
|
231
|
+
try:
|
|
232
|
+
container.reload()
|
|
233
|
+
return bool(container.attrs.get("State", {}).get("OOMKilled", False))
|
|
234
|
+
except Exception:
|
|
235
|
+
return False
|