loki-mode 7.129.5 → 8.0.1
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.
- package/README.md +121 -9
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +326 -92
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +169 -105
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +141 -0
- package/autonomy/loki +196 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4587 -671
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- package/skills/providers.md +42 -0
- package/skills/quality-gates.md +22 -0
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Durable supervisor for dashboard-started workspace builds."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import ctypes
|
|
8
|
+
import fcntl
|
|
9
|
+
import hashlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import signal
|
|
15
|
+
import socket
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Iterator
|
|
25
|
+
|
|
26
|
+
from autonomy.lib import deadline as process_deadline
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SCHEMA_VERSION = 1
|
|
30
|
+
_DARWIN_LIBPROC: Any = None
|
|
31
|
+
_PROOF_VERIFIER: Any = None
|
|
32
|
+
|
|
33
|
+
_CHILD_ENV_NAMES = {
|
|
34
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
|
35
|
+
"DISABLE_AUTOUPDATER",
|
|
36
|
+
"DISABLE_TELEMETRY",
|
|
37
|
+
"HOME",
|
|
38
|
+
"LANG",
|
|
39
|
+
"LC_ALL",
|
|
40
|
+
"LC_CTYPE",
|
|
41
|
+
"LOGNAME",
|
|
42
|
+
"NO_COLOR",
|
|
43
|
+
"SHELL",
|
|
44
|
+
"TERM",
|
|
45
|
+
"USER",
|
|
46
|
+
}
|
|
47
|
+
_CHILD_LOKI_ENV_NAMES = {
|
|
48
|
+
"LOKI_ADVISOR_MODEL",
|
|
49
|
+
"LOKI_APP_RUNNER",
|
|
50
|
+
"LOKI_AUTONOMY_OVERRIDE",
|
|
51
|
+
"LOKI_BUILD_PROFILE",
|
|
52
|
+
"LOKI_CLAUDE_EFFORT",
|
|
53
|
+
"LOKI_CLAUDE_MODEL_DEVELOPMENT",
|
|
54
|
+
"LOKI_CLAUDE_MODEL_FAST",
|
|
55
|
+
"LOKI_CLAUDE_MODEL_PLANNING",
|
|
56
|
+
"LOKI_CLAUDE_TASK_BUDGET",
|
|
57
|
+
"LOKI_CODEX_MODEL",
|
|
58
|
+
"LOKI_CODEX_REASONING_EFFORT",
|
|
59
|
+
"LOKI_CODEX_WEB_SEARCH",
|
|
60
|
+
"LOKI_COMPLEXITY",
|
|
61
|
+
"LOKI_COUNCIL_ENABLED",
|
|
62
|
+
"LOKI_COUNCIL_SIZE",
|
|
63
|
+
"LOKI_COUNCIL_TIMEOUT_MS",
|
|
64
|
+
"LOKI_ENTERPRISE_AUTH",
|
|
65
|
+
"LOKI_FIRST_PASS_EXCELLENCE",
|
|
66
|
+
"LOKI_GEMINI_MODEL",
|
|
67
|
+
"LOKI_HOST_GUARD",
|
|
68
|
+
"LOKI_HOST_GUARD_SETTINGS_JSON",
|
|
69
|
+
"LOKI_LEGACY_BASH",
|
|
70
|
+
"LOKI_MAX_ITERATIONS",
|
|
71
|
+
"LOKI_NO_SESSION_PERSIST",
|
|
72
|
+
"LOKI_PARTIAL_MESSAGES",
|
|
73
|
+
"LOKI_PROMPT_INJECTION",
|
|
74
|
+
"LOKI_SDK_LOOP",
|
|
75
|
+
"LOKI_SDK_MODE",
|
|
76
|
+
"LOKI_SESSION_MODEL",
|
|
77
|
+
"LOKI_SETTING_SOURCES",
|
|
78
|
+
"LOKI_SKILL_DIR",
|
|
79
|
+
"LOKI_SPEC_SHA256",
|
|
80
|
+
"LOKI_SPEC_CONTRADICTION_FASTFAIL",
|
|
81
|
+
"LOKI_WORKSPACE_ROOTS",
|
|
82
|
+
}
|
|
83
|
+
_SYSTEM_RUNTIME_READ_PATHS = (
|
|
84
|
+
"/System",
|
|
85
|
+
"/usr",
|
|
86
|
+
"/bin",
|
|
87
|
+
"/sbin",
|
|
88
|
+
"/Library/Apple",
|
|
89
|
+
"/private/etc",
|
|
90
|
+
"/private/var/db/timezone",
|
|
91
|
+
"/private/var/select",
|
|
92
|
+
)
|
|
93
|
+
_SYSTEM_CHILD_PATHS = ("/usr/bin", "/bin", "/usr/sbin", "/sbin")
|
|
94
|
+
_PROVIDER_AUTH_COMMANDS = {
|
|
95
|
+
"claude": ("claude", "auth", "status"),
|
|
96
|
+
"codex": ("codex", "login", "status"),
|
|
97
|
+
"cline": ("cline", "auth", "status"),
|
|
98
|
+
}
|
|
99
|
+
_PROVIDER_READINESS_COMMANDS = {
|
|
100
|
+
"claude": (
|
|
101
|
+
"claude",
|
|
102
|
+
"--dangerously-skip-permissions",
|
|
103
|
+
"--model",
|
|
104
|
+
"haiku",
|
|
105
|
+
"--setting-sources",
|
|
106
|
+
"",
|
|
107
|
+
"--disallowedTools",
|
|
108
|
+
"Task",
|
|
109
|
+
"--tools",
|
|
110
|
+
"Bash",
|
|
111
|
+
"--effort",
|
|
112
|
+
"low",
|
|
113
|
+
"-p",
|
|
114
|
+
"Use the Bash tool to run printf ready. After it succeeds, reply with exactly OK.",
|
|
115
|
+
"--output-format",
|
|
116
|
+
"json",
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
_PROVIDER_AUTH_ACTIONS = {
|
|
120
|
+
"claude": "Run 'claude login', unlock the macOS login keychain, then retry.",
|
|
121
|
+
"codex": "Run 'codex login', then retry.",
|
|
122
|
+
"cline": "Run 'cline auth', then retry.",
|
|
123
|
+
"aider": "Configure a provider with a non-interactive auth status command.",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _DarwinProcBsdInfo(ctypes.Structure):
|
|
128
|
+
_fields_ = [
|
|
129
|
+
("pbi_flags", ctypes.c_uint32),
|
|
130
|
+
("pbi_status", ctypes.c_uint32),
|
|
131
|
+
("pbi_xstatus", ctypes.c_uint32),
|
|
132
|
+
("pbi_pid", ctypes.c_uint32),
|
|
133
|
+
("pbi_ppid", ctypes.c_uint32),
|
|
134
|
+
("pbi_uid", ctypes.c_uint32),
|
|
135
|
+
("pbi_gid", ctypes.c_uint32),
|
|
136
|
+
("pbi_ruid", ctypes.c_uint32),
|
|
137
|
+
("pbi_rgid", ctypes.c_uint32),
|
|
138
|
+
("pbi_svuid", ctypes.c_uint32),
|
|
139
|
+
("pbi_svgid", ctypes.c_uint32),
|
|
140
|
+
("rfu_1", ctypes.c_uint32),
|
|
141
|
+
("pbi_comm", ctypes.c_char * 16),
|
|
142
|
+
("pbi_name", ctypes.c_char * 32),
|
|
143
|
+
("pbi_nfiles", ctypes.c_uint32),
|
|
144
|
+
("pbi_pgid", ctypes.c_uint32),
|
|
145
|
+
("pbi_pjobc", ctypes.c_uint32),
|
|
146
|
+
("e_tdev", ctypes.c_uint32),
|
|
147
|
+
("e_tpgid", ctypes.c_uint32),
|
|
148
|
+
("pbi_nice", ctypes.c_int32),
|
|
149
|
+
("pbi_start_tvsec", ctypes.c_uint64),
|
|
150
|
+
("pbi_start_tvusec", ctypes.c_uint64),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def utc_now() -> str:
|
|
155
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate_execution_id(raw: str) -> str:
|
|
159
|
+
"""Return the canonical UUID used as the durable execution identity."""
|
|
160
|
+
try:
|
|
161
|
+
return str(uuid.UUID(str(raw)))
|
|
162
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
163
|
+
raise ValueError("request_id must be a valid UUID") from exc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def executions_root() -> Path:
|
|
167
|
+
base = Path(os.environ.get("LOKI_DATA_DIR", "~/.loki")).expanduser()
|
|
168
|
+
return base.resolve() / "dashboard" / "builds"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def execution_dir(execution_id: str) -> Path:
|
|
172
|
+
return executions_root() / validate_execution_id(execution_id)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def state_path(execution_id: str) -> Path:
|
|
176
|
+
return execution_dir(execution_id) / "state.json"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _ensure_execution_dir(execution_id: str) -> Path:
|
|
180
|
+
path = execution_dir(execution_id)
|
|
181
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
182
|
+
return path
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@contextmanager
|
|
186
|
+
def execution_lock(execution_id: str) -> Iterator[None]:
|
|
187
|
+
"""Serialize state transitions and idempotent start decisions."""
|
|
188
|
+
directory = _ensure_execution_dir(execution_id)
|
|
189
|
+
lock_fd = os.open(str(directory / ".lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
|
190
|
+
try:
|
|
191
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
192
|
+
yield
|
|
193
|
+
finally:
|
|
194
|
+
try:
|
|
195
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
196
|
+
finally:
|
|
197
|
+
os.close(lock_fd)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def read_state_unlocked(execution_id: str) -> dict[str, Any] | None:
|
|
201
|
+
try:
|
|
202
|
+
value = json.loads(state_path(execution_id).read_text(encoding="utf-8"))
|
|
203
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
204
|
+
return None
|
|
205
|
+
return value if isinstance(value, dict) else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def read_state(execution_id: str) -> dict[str, Any] | None:
|
|
209
|
+
"""Read one complete state snapshot. Atomic writers prevent torn JSON."""
|
|
210
|
+
return read_state_unlocked(execution_id)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _fsync_directory(path: Path) -> None:
|
|
214
|
+
try:
|
|
215
|
+
fd = os.open(str(path), os.O_RDONLY)
|
|
216
|
+
except OSError:
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
os.fsync(fd)
|
|
220
|
+
except OSError:
|
|
221
|
+
pass
|
|
222
|
+
finally:
|
|
223
|
+
os.close(fd)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _atomic_write(path: Path, payload: bytes, mode: int = 0o600) -> None:
|
|
227
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
228
|
+
fd, temporary = tempfile.mkstemp(
|
|
229
|
+
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
|
230
|
+
)
|
|
231
|
+
try:
|
|
232
|
+
os.fchmod(fd, mode)
|
|
233
|
+
with os.fdopen(fd, "wb") as handle:
|
|
234
|
+
handle.write(payload)
|
|
235
|
+
handle.flush()
|
|
236
|
+
os.fsync(handle.fileno())
|
|
237
|
+
os.replace(temporary, path)
|
|
238
|
+
_fsync_directory(path.parent)
|
|
239
|
+
except BaseException:
|
|
240
|
+
try:
|
|
241
|
+
os.unlink(temporary)
|
|
242
|
+
except OSError:
|
|
243
|
+
pass
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def write_state_unlocked(execution_id: str, state: dict[str, Any]) -> None:
|
|
248
|
+
encoded = json.dumps(
|
|
249
|
+
state, sort_keys=True, indent=2, ensure_ascii=True
|
|
250
|
+
).encode("utf-8") + b"\n"
|
|
251
|
+
_atomic_write(state_path(execution_id), encoded)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def update_state(execution_id: str, **updates: Any) -> dict[str, Any]:
|
|
255
|
+
with execution_lock(execution_id):
|
|
256
|
+
state = read_state_unlocked(execution_id) or {
|
|
257
|
+
"schema_version": SCHEMA_VERSION,
|
|
258
|
+
"execution_id": validate_execution_id(execution_id),
|
|
259
|
+
}
|
|
260
|
+
state.update(updates)
|
|
261
|
+
write_state_unlocked(execution_id, state)
|
|
262
|
+
return state
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def write_spec_snapshot_unlocked(execution_id: str, content: bytes) -> Path:
|
|
266
|
+
path = execution_dir(execution_id) / "spec.md"
|
|
267
|
+
_atomic_write(path, content)
|
|
268
|
+
return path
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def open_private_log(path: Path) -> Any:
|
|
272
|
+
"""Open an append-only execution log with owner-only permissions."""
|
|
273
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
274
|
+
fd = os.open(str(path), os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
|
|
275
|
+
try:
|
|
276
|
+
os.fchmod(fd, 0o600)
|
|
277
|
+
return os.fdopen(fd, "ab", buffering=0)
|
|
278
|
+
except BaseException:
|
|
279
|
+
os.close(fd)
|
|
280
|
+
raise
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def sha256_bytes(content: bytes) -> str:
|
|
284
|
+
return hashlib.sha256(content).hexdigest()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _darwin_process_birth_token(pid: int) -> str:
|
|
288
|
+
"""Read Darwin's microsecond process start time through libproc."""
|
|
289
|
+
if sys.platform != "darwin":
|
|
290
|
+
return ""
|
|
291
|
+
global _DARWIN_LIBPROC
|
|
292
|
+
try:
|
|
293
|
+
if _DARWIN_LIBPROC is None:
|
|
294
|
+
libproc = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
|
|
295
|
+
libproc.proc_pidinfo.argtypes = [
|
|
296
|
+
ctypes.c_int,
|
|
297
|
+
ctypes.c_int,
|
|
298
|
+
ctypes.c_uint64,
|
|
299
|
+
ctypes.c_void_p,
|
|
300
|
+
ctypes.c_int,
|
|
301
|
+
]
|
|
302
|
+
libproc.proc_pidinfo.restype = ctypes.c_int
|
|
303
|
+
_DARWIN_LIBPROC = libproc
|
|
304
|
+
info = _DarwinProcBsdInfo()
|
|
305
|
+
size = ctypes.sizeof(info)
|
|
306
|
+
read = _DARWIN_LIBPROC.proc_pidinfo(
|
|
307
|
+
int(pid), 3, 0, ctypes.byref(info), size
|
|
308
|
+
)
|
|
309
|
+
except (OSError, TypeError, ValueError, AttributeError):
|
|
310
|
+
return ""
|
|
311
|
+
if read != size or info.pbi_pid != int(pid):
|
|
312
|
+
return ""
|
|
313
|
+
return f"darwin:{info.pbi_start_tvsec}:{info.pbi_start_tvusec}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def process_birth_token(pid: int) -> str:
|
|
317
|
+
"""Return an OS process birth token suitable for PID reuse checks."""
|
|
318
|
+
try:
|
|
319
|
+
numeric_pid = int(pid)
|
|
320
|
+
except (TypeError, ValueError):
|
|
321
|
+
return ""
|
|
322
|
+
if numeric_pid <= 0:
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
proc_stat = Path(f"/proc/{numeric_pid}/stat")
|
|
326
|
+
try:
|
|
327
|
+
raw = proc_stat.read_text(encoding="utf-8", errors="replace")
|
|
328
|
+
fields = raw.rsplit(")", 1)[1].strip().split()
|
|
329
|
+
start_ticks = fields[19]
|
|
330
|
+
try:
|
|
331
|
+
boot_id = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
332
|
+
except OSError:
|
|
333
|
+
boot_id = ""
|
|
334
|
+
return f"proc:{boot_id}:{start_ticks}"
|
|
335
|
+
except (OSError, IndexError):
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
darwin_token = _darwin_process_birth_token(numeric_pid)
|
|
339
|
+
if darwin_token:
|
|
340
|
+
return darwin_token
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
result = subprocess.run(
|
|
344
|
+
["ps", "-o", "lstart=", "-p", str(numeric_pid)],
|
|
345
|
+
capture_output=True,
|
|
346
|
+
text=True,
|
|
347
|
+
timeout=5,
|
|
348
|
+
)
|
|
349
|
+
except (OSError, subprocess.SubprocessError):
|
|
350
|
+
return ""
|
|
351
|
+
started = (result.stdout or "").strip()
|
|
352
|
+
if result.returncode != 0 or not started:
|
|
353
|
+
return ""
|
|
354
|
+
return f"ps:{started}"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _signal_safe_birth_token(token: Any) -> bool:
|
|
358
|
+
"""True only for OS birth tokens precise enough to authorize a signal."""
|
|
359
|
+
if not isinstance(token, str):
|
|
360
|
+
return False
|
|
361
|
+
parts = token.split(":")
|
|
362
|
+
if parts[0] == "proc":
|
|
363
|
+
return len(parts) == 3 and bool(parts[1]) and parts[2].isdigit()
|
|
364
|
+
if parts[0] == "darwin":
|
|
365
|
+
return len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit()
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def process_state(pid: int) -> str:
|
|
370
|
+
"""Return the single-letter process state when the OS exposes it."""
|
|
371
|
+
try:
|
|
372
|
+
raw = Path(f"/proc/{int(pid)}/stat").read_text(
|
|
373
|
+
encoding="utf-8", errors="replace"
|
|
374
|
+
)
|
|
375
|
+
return raw.rsplit(")", 1)[1].strip().split()[0]
|
|
376
|
+
except (OSError, ValueError, IndexError):
|
|
377
|
+
pass
|
|
378
|
+
try:
|
|
379
|
+
result = subprocess.run(
|
|
380
|
+
["ps", "-o", "state=", "-p", str(int(pid))],
|
|
381
|
+
capture_output=True,
|
|
382
|
+
text=True,
|
|
383
|
+
timeout=5,
|
|
384
|
+
)
|
|
385
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
386
|
+
return ""
|
|
387
|
+
return (result.stdout or "").strip()[:1]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def process_identity_matches(pid: Any, birth_token: Any) -> bool:
|
|
391
|
+
"""Fail closed unless the live PID has the recorded birth token."""
|
|
392
|
+
if not isinstance(birth_token, str) or not birth_token:
|
|
393
|
+
return False
|
|
394
|
+
try:
|
|
395
|
+
numeric_pid = int(pid)
|
|
396
|
+
os.kill(numeric_pid, 0)
|
|
397
|
+
except (TypeError, ValueError, OSError):
|
|
398
|
+
return False
|
|
399
|
+
if process_state(numeric_pid) == "Z":
|
|
400
|
+
return False
|
|
401
|
+
return process_birth_token(numeric_pid) == birth_token
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _owned_runner_group_matches(
|
|
405
|
+
runner_pid: Any,
|
|
406
|
+
runner_pgid: Any,
|
|
407
|
+
runner_sid: Any,
|
|
408
|
+
runner_birth_token: Any,
|
|
409
|
+
) -> bool:
|
|
410
|
+
"""Verify the exact session leader before signaling its whole group."""
|
|
411
|
+
try:
|
|
412
|
+
pid = int(runner_pid)
|
|
413
|
+
pgid = int(runner_pgid)
|
|
414
|
+
sid = int(runner_sid)
|
|
415
|
+
except (TypeError, ValueError):
|
|
416
|
+
return False
|
|
417
|
+
if pid <= 1 or pid != pgid or pid != sid:
|
|
418
|
+
return False
|
|
419
|
+
if not _signal_safe_birth_token(runner_birth_token):
|
|
420
|
+
return False
|
|
421
|
+
if not process_identity_matches(pid, runner_birth_token):
|
|
422
|
+
return False
|
|
423
|
+
try:
|
|
424
|
+
return os.getpgid(pid) == pgid and os.getsid(pid) == sid
|
|
425
|
+
except OSError:
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _signal_owned_runner_group(
|
|
430
|
+
runner_pid: Any,
|
|
431
|
+
runner_pgid: Any,
|
|
432
|
+
runner_sid: Any,
|
|
433
|
+
runner_birth_token: Any,
|
|
434
|
+
signum: int,
|
|
435
|
+
) -> str:
|
|
436
|
+
"""Signal the runner group or return a fail-closed error string."""
|
|
437
|
+
if not _owned_runner_group_matches(
|
|
438
|
+
runner_pid, runner_pgid, runner_sid, runner_birth_token
|
|
439
|
+
):
|
|
440
|
+
return "Runner group identity changed or is imprecise; signal refused"
|
|
441
|
+
try:
|
|
442
|
+
os.killpg(int(runner_pgid), signum)
|
|
443
|
+
except ProcessLookupError:
|
|
444
|
+
return ""
|
|
445
|
+
except OSError as exc:
|
|
446
|
+
return f"Runner group signal failed: {type(exc).__name__}"
|
|
447
|
+
return ""
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def empty_descendant_outcome() -> dict[str, Any]:
|
|
451
|
+
return {
|
|
452
|
+
"checked": False,
|
|
453
|
+
"detected": False,
|
|
454
|
+
"quiescent": False,
|
|
455
|
+
"term_sent": [],
|
|
456
|
+
"kill_sent": [],
|
|
457
|
+
"remaining_pids": [],
|
|
458
|
+
"error": "",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _load_tree_digest() -> Any:
|
|
463
|
+
lib_dir = Path(__file__).resolve().parents[1] / "autonomy" / "lib"
|
|
464
|
+
if str(lib_dir) not in sys.path:
|
|
465
|
+
sys.path.insert(0, str(lib_dir))
|
|
466
|
+
from tree_digest import compute_tree_digest
|
|
467
|
+
|
|
468
|
+
return compute_tree_digest
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _load_proof_verifier() -> Any:
|
|
472
|
+
"""Load the canonical verifier despite its hyphenated filename."""
|
|
473
|
+
global _PROOF_VERIFIER
|
|
474
|
+
if _PROOF_VERIFIER is not None:
|
|
475
|
+
return _PROOF_VERIFIER
|
|
476
|
+
path = (
|
|
477
|
+
Path(__file__).resolve().parents[1]
|
|
478
|
+
/ "autonomy"
|
|
479
|
+
/ "lib"
|
|
480
|
+
/ "proof-verify.py"
|
|
481
|
+
)
|
|
482
|
+
spec = importlib.util.spec_from_file_location("loki_proof_verify", path)
|
|
483
|
+
if spec is None or spec.loader is None:
|
|
484
|
+
raise ImportError("canonical proof verifier could not be loaded")
|
|
485
|
+
module = importlib.util.module_from_spec(spec)
|
|
486
|
+
spec.loader.exec_module(module)
|
|
487
|
+
_PROOF_VERIFIER = module
|
|
488
|
+
return module
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def empty_integrity_result(reason: str = "proof integrity not checked") -> dict[str, Any]:
|
|
492
|
+
return {
|
|
493
|
+
"hash_ok": False,
|
|
494
|
+
"gpg_ok": "n/a",
|
|
495
|
+
"generator_trusted": True,
|
|
496
|
+
"headline_consistent": None,
|
|
497
|
+
"degraded": [],
|
|
498
|
+
"reason": reason,
|
|
499
|
+
"ok": False,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def empty_proof_binding(
|
|
504
|
+
execution_id: str, reason: str = "proof not present"
|
|
505
|
+
) -> dict[str, Any]:
|
|
506
|
+
return {
|
|
507
|
+
"present": False,
|
|
508
|
+
"run_id": "",
|
|
509
|
+
"headline": "",
|
|
510
|
+
"tree_sha256": "",
|
|
511
|
+
"document_sha256": "",
|
|
512
|
+
"proof_path": "",
|
|
513
|
+
"proof_url": f"/api/control/builds/{execution_id}/proof",
|
|
514
|
+
"snapshotted": False,
|
|
515
|
+
"snapshot_error": "",
|
|
516
|
+
"tree_fields_agree": False,
|
|
517
|
+
"execution": {},
|
|
518
|
+
"failed_quality_gates": [],
|
|
519
|
+
"integrity": empty_integrity_result(reason),
|
|
520
|
+
"bound": False,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _read_run_id(loki_dir: Path) -> str:
|
|
525
|
+
try:
|
|
526
|
+
value = (loki_dir / "state" / "trust-run-id").read_text(
|
|
527
|
+
encoding="utf-8"
|
|
528
|
+
).strip()
|
|
529
|
+
except OSError:
|
|
530
|
+
return ""
|
|
531
|
+
if not value.startswith("run-") or "/" in value or "\\" in value:
|
|
532
|
+
return ""
|
|
533
|
+
return value
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _proof_binding(
|
|
537
|
+
execution_id: str, loki_dir: Path, run_id: str, tree_sha256: str
|
|
538
|
+
) -> dict[str, Any]:
|
|
539
|
+
"""Snapshot and bind the exact final proof outside the tenant workspace."""
|
|
540
|
+
result = empty_proof_binding(execution_id)
|
|
541
|
+
if not run_id:
|
|
542
|
+
return result
|
|
543
|
+
proof_path = loki_dir / "proofs" / run_id / "proof.json"
|
|
544
|
+
try:
|
|
545
|
+
raw = proof_path.read_bytes()
|
|
546
|
+
except OSError:
|
|
547
|
+
return result
|
|
548
|
+
snapshot_path = execution_dir(execution_id) / "proof.json"
|
|
549
|
+
result["document_sha256"] = sha256_bytes(raw)
|
|
550
|
+
try:
|
|
551
|
+
_atomic_write(snapshot_path, raw, mode=0o400)
|
|
552
|
+
except OSError as exc:
|
|
553
|
+
result["snapshot_error"] = f"{type(exc).__name__}: snapshot write failed"
|
|
554
|
+
else:
|
|
555
|
+
result["proof_path"] = str(snapshot_path)
|
|
556
|
+
result["snapshotted"] = True
|
|
557
|
+
try:
|
|
558
|
+
proof = json.loads(raw.decode("utf-8"))
|
|
559
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
560
|
+
return result
|
|
561
|
+
if not isinstance(proof, dict):
|
|
562
|
+
return result
|
|
563
|
+
try:
|
|
564
|
+
integrity = _load_proof_verifier().verify_integrity(proof)
|
|
565
|
+
except Exception as exc:
|
|
566
|
+
integrity = empty_integrity_result(
|
|
567
|
+
f"Canonical integrity verifier failed: {type(exc).__name__}"
|
|
568
|
+
)
|
|
569
|
+
if not isinstance(integrity, dict):
|
|
570
|
+
integrity = empty_integrity_result(
|
|
571
|
+
"Canonical integrity verifier returned an invalid result"
|
|
572
|
+
)
|
|
573
|
+
facts = proof.get("facts") if isinstance(proof.get("facts"), dict) else {}
|
|
574
|
+
git = facts.get("git") if isinstance(facts.get("git"), dict) else {}
|
|
575
|
+
honesty = (
|
|
576
|
+
proof.get("honesty") if isinstance(proof.get("honesty"), dict) else {}
|
|
577
|
+
)
|
|
578
|
+
execution = (
|
|
579
|
+
facts.get("execution")
|
|
580
|
+
if isinstance(facts.get("execution"), dict)
|
|
581
|
+
else {}
|
|
582
|
+
)
|
|
583
|
+
quality_gates = (
|
|
584
|
+
facts.get("quality_gates")
|
|
585
|
+
if isinstance(facts.get("quality_gates"), list)
|
|
586
|
+
else []
|
|
587
|
+
)
|
|
588
|
+
failed_quality_gates = [
|
|
589
|
+
str(gate.get("name") or "")
|
|
590
|
+
for gate in quality_gates
|
|
591
|
+
if isinstance(gate, dict) and gate.get("status") == "failed"
|
|
592
|
+
]
|
|
593
|
+
proof_run_id = str(proof.get("run_id") or "")
|
|
594
|
+
facts_tree = str(git.get("tree_sha256") or "")
|
|
595
|
+
top_tree = str(proof.get("tree_sha256") or "")
|
|
596
|
+
proof_tree = top_tree or facts_tree
|
|
597
|
+
tree_fields_agree = not (top_tree and facts_tree) or top_tree == facts_tree
|
|
598
|
+
headline = str(proof.get("headline") or honesty.get("headline") or "")
|
|
599
|
+
result.update(
|
|
600
|
+
present=True,
|
|
601
|
+
run_id=proof_run_id,
|
|
602
|
+
headline=headline,
|
|
603
|
+
tree_sha256=proof_tree,
|
|
604
|
+
tree_fields_agree=tree_fields_agree,
|
|
605
|
+
execution=execution,
|
|
606
|
+
failed_quality_gates=failed_quality_gates,
|
|
607
|
+
integrity=integrity,
|
|
608
|
+
bound=bool(
|
|
609
|
+
proof_run_id == run_id
|
|
610
|
+
and tree_sha256
|
|
611
|
+
and proof_tree == tree_sha256
|
|
612
|
+
and tree_fields_agree
|
|
613
|
+
and result["snapshotted"]
|
|
614
|
+
and integrity.get("ok") is True
|
|
615
|
+
and integrity.get("headline_consistent") is True
|
|
616
|
+
),
|
|
617
|
+
)
|
|
618
|
+
return result
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _positive_float_env(name: str, default: float) -> float:
|
|
622
|
+
try:
|
|
623
|
+
value = float(os.environ.get(name, str(default)))
|
|
624
|
+
except (TypeError, ValueError):
|
|
625
|
+
return default
|
|
626
|
+
return value if value > 0 else default
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _enabled_env(name: str) -> bool:
|
|
630
|
+
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _path_is_within(path: Path, root: Path) -> bool:
|
|
634
|
+
try:
|
|
635
|
+
path.relative_to(root)
|
|
636
|
+
except ValueError:
|
|
637
|
+
return False
|
|
638
|
+
return True
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _required_path(name: str, raw: str, *, directory: bool) -> Path:
|
|
642
|
+
value = raw.strip()
|
|
643
|
+
if not value or any(character in value for character in "\n\r\t"):
|
|
644
|
+
raise ValueError(f"{name} contains an invalid path")
|
|
645
|
+
candidate = Path(value).expanduser()
|
|
646
|
+
if not candidate.is_absolute():
|
|
647
|
+
raise ValueError(f"{name} paths must be absolute")
|
|
648
|
+
try:
|
|
649
|
+
resolved = candidate.resolve(strict=True)
|
|
650
|
+
except OSError as exc:
|
|
651
|
+
raise ValueError(f"{name} path does not exist") from exc
|
|
652
|
+
if resolved == Path(resolved.anchor):
|
|
653
|
+
raise ValueError(f"{name} must not include the filesystem root")
|
|
654
|
+
if directory and not resolved.is_dir():
|
|
655
|
+
raise ValueError(f"{name} paths must be directories")
|
|
656
|
+
return resolved
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _required_path_list(name: str, *, directories: bool) -> tuple[Path, ...]:
|
|
660
|
+
raw = os.environ.get(name, "")
|
|
661
|
+
values = raw.split(":")
|
|
662
|
+
if not raw.strip() or any(not value.strip() for value in values):
|
|
663
|
+
raise ValueError(f"{name} must be a non-empty path list")
|
|
664
|
+
paths = tuple(
|
|
665
|
+
_required_path(name, value, directory=directories) for value in values
|
|
666
|
+
)
|
|
667
|
+
# De-duplicate rather than reject. _required_path RESOLVES symlinks, so two
|
|
668
|
+
# genuinely different entries can legitimately collapse to one: on most
|
|
669
|
+
# Linux distributions /bin is a symlink to /usr/bin (and /sbin to /usr/sbin),
|
|
670
|
+
# while on macOS they are distinct directories. A caller listing the
|
|
671
|
+
# conventional four system bin paths is correct on both platforms, and
|
|
672
|
+
# rejecting it on Linux made this a platform-dependent failure that passes
|
|
673
|
+
# on a developer Mac and fails in CI.
|
|
674
|
+
#
|
|
675
|
+
# Order is preserved (dict.fromkeys, not set) because these become sandbox
|
|
676
|
+
# profile entries and a stable order keeps the generated profile
|
|
677
|
+
# byte-reproducible.
|
|
678
|
+
deduped = tuple(dict.fromkeys(paths))
|
|
679
|
+
return deduped
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _reserved_ports() -> tuple[int, ...]:
|
|
683
|
+
raw = os.environ.get("LOKI_HOST_RESERVED_PORTS", "")
|
|
684
|
+
values = raw.split(",")
|
|
685
|
+
if not raw.strip() or any(not value.strip() for value in values):
|
|
686
|
+
raise ValueError("LOKI_HOST_RESERVED_PORTS must be a non-empty port list")
|
|
687
|
+
ports: list[int] = []
|
|
688
|
+
for raw_port in values:
|
|
689
|
+
value = raw_port.strip()
|
|
690
|
+
if not value.isascii() or not value.isdigit() or not 1 <= int(value) <= 65535:
|
|
691
|
+
raise ValueError("LOKI_HOST_RESERVED_PORTS contains an invalid port")
|
|
692
|
+
ports.append(int(value))
|
|
693
|
+
if len(set(ports)) != len(ports):
|
|
694
|
+
raise ValueError("LOKI_HOST_RESERVED_PORTS contains duplicate ports")
|
|
695
|
+
return tuple(ports)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _host_boundary_config(run_sh: Path) -> dict[str, Any]:
|
|
699
|
+
"""Normalize and validate every configured host boundary."""
|
|
700
|
+
run_sh = _required_path("run_sh", str(run_sh), directory=False)
|
|
701
|
+
engine_source = run_sh.parent.parent
|
|
702
|
+
protected_root = _required_path(
|
|
703
|
+
"LOKI_HOST_PROTECTED_ROOT",
|
|
704
|
+
os.environ.get("LOKI_HOST_PROTECTED_ROOT", ""),
|
|
705
|
+
directory=True,
|
|
706
|
+
)
|
|
707
|
+
workspace_roots = _required_path_list(
|
|
708
|
+
"LOKI_WORKSPACE_ROOTS", directories=True
|
|
709
|
+
)
|
|
710
|
+
runtime_read_paths = _required_path_list(
|
|
711
|
+
"LOKI_HOST_RUNTIME_READ_PATHS", directories=False
|
|
712
|
+
)
|
|
713
|
+
runtime_write_paths = _required_path_list(
|
|
714
|
+
"LOKI_HOST_RUNTIME_WRITE_PATHS", directories=True
|
|
715
|
+
)
|
|
716
|
+
child_path = _required_path_list("LOKI_HOST_CHILD_PATH", directories=True)
|
|
717
|
+
|
|
718
|
+
required_child_paths = tuple(
|
|
719
|
+
Path(value).resolve(strict=True)
|
|
720
|
+
for value in _SYSTEM_CHILD_PATHS
|
|
721
|
+
if Path(value).is_dir()
|
|
722
|
+
)
|
|
723
|
+
missing_child_paths = tuple(
|
|
724
|
+
path for path in required_child_paths if path not in child_path
|
|
725
|
+
)
|
|
726
|
+
if missing_child_paths:
|
|
727
|
+
missing = ":".join(str(path) for path in missing_child_paths)
|
|
728
|
+
raise ValueError(
|
|
729
|
+
"LOKI_HOST_CHILD_PATH must include the system command paths: "
|
|
730
|
+
f"{missing}"
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
for root in workspace_roots:
|
|
734
|
+
if root == protected_root or not _path_is_within(root, protected_root):
|
|
735
|
+
raise ValueError(
|
|
736
|
+
"LOKI_WORKSPACE_ROOTS must be strictly contained under "
|
|
737
|
+
"LOKI_HOST_PROTECTED_ROOT"
|
|
738
|
+
)
|
|
739
|
+
for path in (*runtime_read_paths, *runtime_write_paths):
|
|
740
|
+
if _path_is_within(path, protected_root) or _path_is_within(
|
|
741
|
+
protected_root, path
|
|
742
|
+
):
|
|
743
|
+
raise ValueError(
|
|
744
|
+
"runtime paths must not overlap LOKI_HOST_PROTECTED_ROOT"
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
system_read_paths = tuple(
|
|
748
|
+
Path(value).resolve(strict=True)
|
|
749
|
+
for value in _SYSTEM_RUNTIME_READ_PATHS
|
|
750
|
+
if Path(value).exists()
|
|
751
|
+
)
|
|
752
|
+
executable_roots = (*system_read_paths, *runtime_read_paths)
|
|
753
|
+
for path in child_path:
|
|
754
|
+
if not any(_path_is_within(path, root) for root in executable_roots):
|
|
755
|
+
raise ValueError(
|
|
756
|
+
"LOKI_HOST_CHILD_PATH entries must be under an allowed read path"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
"workspace_roots": workspace_roots,
|
|
761
|
+
"engine_source": engine_source,
|
|
762
|
+
"protected_root": protected_root,
|
|
763
|
+
"runtime_read_paths": runtime_read_paths,
|
|
764
|
+
"runtime_write_paths": runtime_write_paths,
|
|
765
|
+
"system_read_paths": system_read_paths,
|
|
766
|
+
"child_path": child_path,
|
|
767
|
+
"ports": _reserved_ports(),
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def host_confinement_identity(workspace_root: Path, run_sh: Path) -> dict[str, str]:
|
|
772
|
+
"""Return the canonical config identity used by the engine manifest."""
|
|
773
|
+
config = _host_boundary_config(run_sh)
|
|
774
|
+
workspace_root = _required_path(
|
|
775
|
+
"workspace_root", str(workspace_root), directory=True
|
|
776
|
+
)
|
|
777
|
+
if workspace_root not in config["workspace_roots"]:
|
|
778
|
+
raise ValueError("workspace_root must exactly match LOKI_WORKSPACE_ROOTS")
|
|
779
|
+
payload = {
|
|
780
|
+
"workspace_roots": [str(path) for path in config["workspace_roots"]],
|
|
781
|
+
"protected_root": str(config["protected_root"]),
|
|
782
|
+
"runtime_read_paths": [str(path) for path in config["runtime_read_paths"]],
|
|
783
|
+
"runtime_write_paths": [str(path) for path in config["runtime_write_paths"]],
|
|
784
|
+
"child_path": [str(path) for path in config["child_path"]],
|
|
785
|
+
"reserved_ports": list(config["ports"]),
|
|
786
|
+
}
|
|
787
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
788
|
+
return {
|
|
789
|
+
"workspace_root": str(workspace_root),
|
|
790
|
+
"protected_root": payload["protected_root"],
|
|
791
|
+
"runtime_read_paths": ":".join(payload["runtime_read_paths"]),
|
|
792
|
+
"runtime_write_paths": ":".join(payload["runtime_write_paths"]),
|
|
793
|
+
"child_path": ":".join(payload["child_path"]),
|
|
794
|
+
"reserved_ports": ",".join(str(port) for port in config["ports"]),
|
|
795
|
+
"config_sha256": sha256_bytes(encoded),
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _host_confinement_config(
|
|
800
|
+
workspace: Path,
|
|
801
|
+
run_sh: Path,
|
|
802
|
+
spec: Path,
|
|
803
|
+
) -> dict[str, Any]:
|
|
804
|
+
config = _host_boundary_config(run_sh)
|
|
805
|
+
workspace = _required_path("workspace", str(workspace), directory=True)
|
|
806
|
+
spec = _required_path("spec", str(spec), directory=False)
|
|
807
|
+
if not any(
|
|
808
|
+
workspace != root and _path_is_within(workspace, root)
|
|
809
|
+
for root in config["workspace_roots"]
|
|
810
|
+
):
|
|
811
|
+
raise ValueError(
|
|
812
|
+
"LOKI host Seatbelt requires the workspace strictly under "
|
|
813
|
+
"LOKI_WORKSPACE_ROOTS"
|
|
814
|
+
)
|
|
815
|
+
engine_source = config["engine_source"]
|
|
816
|
+
if _path_is_within(workspace, engine_source) or _path_is_within(
|
|
817
|
+
engine_source, workspace
|
|
818
|
+
):
|
|
819
|
+
raise ValueError("engine source and workspace boundaries must not overlap")
|
|
820
|
+
return {**config, "workspace": workspace, "spec": spec}
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _seatbelt_path_filter(path: Path) -> str:
|
|
824
|
+
kind = "subpath" if path.is_dir() else "literal"
|
|
825
|
+
return f"({kind} {json.dumps(str(path), ensure_ascii=True)})"
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _host_seatbelt_profile(
|
|
829
|
+
workspace: Path,
|
|
830
|
+
run_sh: Path,
|
|
831
|
+
spec: Path,
|
|
832
|
+
) -> bytes:
|
|
833
|
+
"""Build the macOS local-acceptance sandbox profile for one workspace."""
|
|
834
|
+
config = _host_confinement_config(workspace, run_sh, spec)
|
|
835
|
+
quote = lambda value: json.dumps(str(value), ensure_ascii=True)
|
|
836
|
+
lines = [
|
|
837
|
+
"(version 1)",
|
|
838
|
+
"(deny default)",
|
|
839
|
+
"(allow process-fork process-exec*)",
|
|
840
|
+
"(allow signal (target same-sandbox))",
|
|
841
|
+
"(allow process-info* (target same-sandbox))",
|
|
842
|
+
"(allow sysctl-read)",
|
|
843
|
+
"(allow mach-lookup)",
|
|
844
|
+
"(allow ipc-posix-shm ipc-posix-sem)",
|
|
845
|
+
"(allow network-outbound (remote ip))",
|
|
846
|
+
"(allow network-bind (local ip))",
|
|
847
|
+
"(deny network-outbound (remote unix-socket))",
|
|
848
|
+
'(allow network-outbound (literal "/private/var/run/mDNSResponder"))',
|
|
849
|
+
";; Preexisting cross-boundary hardlinks share one kernel inode.",
|
|
850
|
+
";; This path policy cannot prove which directory originally created it.",
|
|
851
|
+
]
|
|
852
|
+
read_paths = {
|
|
853
|
+
*config["system_read_paths"],
|
|
854
|
+
*config["runtime_read_paths"],
|
|
855
|
+
config["engine_source"],
|
|
856
|
+
config["workspace"],
|
|
857
|
+
config["spec"],
|
|
858
|
+
}
|
|
859
|
+
metadata_paths = {Path("/"), Path("/var"), Path("/tmp"), Path("/etc"), Path("/dev")}
|
|
860
|
+
for path in (*read_paths, *config["runtime_write_paths"]):
|
|
861
|
+
metadata_paths.update(path.parents)
|
|
862
|
+
lines.append('(allow file-read-metadata file-read-data (literal "/"))')
|
|
863
|
+
for path in sorted(metadata_paths - {Path("/")}, key=str):
|
|
864
|
+
lines.append(
|
|
865
|
+
f"(allow file-read-metadata (literal {quote(path)}))"
|
|
866
|
+
)
|
|
867
|
+
for path in sorted(read_paths, key=str):
|
|
868
|
+
lines.append(
|
|
869
|
+
f"(allow file-read* file-map-executable {_seatbelt_path_filter(path)})"
|
|
870
|
+
)
|
|
871
|
+
write_paths = {*config["runtime_write_paths"], config["workspace"]}
|
|
872
|
+
for path in sorted(write_paths, key=str):
|
|
873
|
+
lines.append(
|
|
874
|
+
f"(allow file-read* file-write* file-map-executable "
|
|
875
|
+
f"{_seatbelt_path_filter(path)})"
|
|
876
|
+
)
|
|
877
|
+
for device in ("/dev/null", "/dev/random", "/dev/urandom", "/dev/zero"):
|
|
878
|
+
if Path(device).exists():
|
|
879
|
+
lines.append(
|
|
880
|
+
f"(allow file-read* file-write* (literal {quote(device)}))"
|
|
881
|
+
)
|
|
882
|
+
if Path("/dev/fd").exists():
|
|
883
|
+
lines.append('(allow file-read* file-write* (subpath "/dev/fd"))')
|
|
884
|
+
for port in config["ports"]:
|
|
885
|
+
lines.append(
|
|
886
|
+
f'(deny network-outbound (remote tcp "*:{port}"))'
|
|
887
|
+
)
|
|
888
|
+
lines.append(f'(deny network-bind (local tcp "*:{port}"))')
|
|
889
|
+
return ("\n".join(lines) + "\n").encode("utf-8")
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _child_environment(workspace: Path, loki_dir: Path) -> dict[str, str]:
|
|
893
|
+
"""Pass only non-secret runtime configuration into the tenant process."""
|
|
894
|
+
child_env = {
|
|
895
|
+
name: os.environ[name]
|
|
896
|
+
for name in (_CHILD_ENV_NAMES | _CHILD_LOKI_ENV_NAMES)
|
|
897
|
+
if name in os.environ
|
|
898
|
+
}
|
|
899
|
+
child_env["PATH"] = os.environ.get(
|
|
900
|
+
"LOKI_HOST_CHILD_PATH", "/usr/bin:/bin:/usr/sbin:/sbin"
|
|
901
|
+
)
|
|
902
|
+
child_env["LOKI_OWN_SESSION"] = "1"
|
|
903
|
+
child_env["LOKI_TARGET_DIR"] = str(workspace)
|
|
904
|
+
child_env["LOKI_DIR"] = str(loki_dir)
|
|
905
|
+
child_env["LOKI_NO_SESSION_PERSIST"] = "1"
|
|
906
|
+
child_env["LOKI_SUPERVISED_BUILD"] = "1"
|
|
907
|
+
# Supervised builds are durable jobs. This activates run.sh's existing
|
|
908
|
+
# terminal exit contract so max-iteration and policy failures return 20
|
|
909
|
+
# instead of looking like successful process exits.
|
|
910
|
+
child_env["LOKI_DURABLE_STATE"] = "1"
|
|
911
|
+
child_env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
|
|
912
|
+
child_env["DISABLE_AUTOUPDATER"] = "1"
|
|
913
|
+
child_env["DISABLE_TELEMETRY"] = "1"
|
|
914
|
+
runtime_root = loki_dir / "host-runtime"
|
|
915
|
+
for name in (
|
|
916
|
+
"tmp",
|
|
917
|
+
"cache",
|
|
918
|
+
"config",
|
|
919
|
+
"data",
|
|
920
|
+
"state",
|
|
921
|
+
"npm",
|
|
922
|
+
"bun",
|
|
923
|
+
"claude",
|
|
924
|
+
):
|
|
925
|
+
(runtime_root / name).mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
926
|
+
child_env["TMPDIR"] = str(runtime_root / "tmp")
|
|
927
|
+
child_env["XDG_CACHE_HOME"] = str(runtime_root / "cache")
|
|
928
|
+
child_env["XDG_CONFIG_HOME"] = str(runtime_root / "config")
|
|
929
|
+
child_env["XDG_DATA_HOME"] = str(runtime_root / "data")
|
|
930
|
+
child_env["XDG_STATE_HOME"] = str(runtime_root / "state")
|
|
931
|
+
child_env["npm_config_cache"] = str(runtime_root / "npm")
|
|
932
|
+
child_env["BUN_INSTALL_CACHE_DIR"] = str(runtime_root / "bun")
|
|
933
|
+
git_config = runtime_root / "gitconfig"
|
|
934
|
+
_atomic_write(git_config, b"[commit]\n\tgpgsign = false\n")
|
|
935
|
+
child_env["GIT_CONFIG_GLOBAL"] = str(git_config)
|
|
936
|
+
child_env["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
937
|
+
return child_env
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _provider_auth_succeeded(
|
|
941
|
+
provider: str,
|
|
942
|
+
result: subprocess.CompletedProcess[str],
|
|
943
|
+
) -> tuple[bool, str]:
|
|
944
|
+
"""Classify auth status without returning provider output."""
|
|
945
|
+
if provider != "claude":
|
|
946
|
+
available = result.returncode == 0
|
|
947
|
+
return (
|
|
948
|
+
available,
|
|
949
|
+
"available"
|
|
950
|
+
if available
|
|
951
|
+
else "provider_auth_unavailable_in_confinement",
|
|
952
|
+
)
|
|
953
|
+
try:
|
|
954
|
+
payload = json.loads(result.stdout)
|
|
955
|
+
except (json.JSONDecodeError, TypeError):
|
|
956
|
+
return False, "provider_auth_status_invalid"
|
|
957
|
+
available = result.returncode == 0 and payload.get("loggedIn") is True
|
|
958
|
+
return (
|
|
959
|
+
available,
|
|
960
|
+
"available" if available else "provider_auth_unavailable_in_confinement",
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _provider_readiness_succeeded(
|
|
965
|
+
provider: str,
|
|
966
|
+
result: subprocess.CompletedProcess[str],
|
|
967
|
+
) -> bool:
|
|
968
|
+
"""Accept only a real, non-error provider response without returning it."""
|
|
969
|
+
if result.returncode != 0:
|
|
970
|
+
return False
|
|
971
|
+
if provider != "claude":
|
|
972
|
+
return True
|
|
973
|
+
try:
|
|
974
|
+
payload = json.loads(result.stdout)
|
|
975
|
+
except (json.JSONDecodeError, TypeError):
|
|
976
|
+
return False
|
|
977
|
+
response = str(payload.get("result", "")).strip().rstrip(".")
|
|
978
|
+
return payload.get("is_error") is False and response == "OK"
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def confined_provider_auth_status(
|
|
982
|
+
provider: str,
|
|
983
|
+
workspace: Path,
|
|
984
|
+
run_sh: Path,
|
|
985
|
+
spec: Path,
|
|
986
|
+
) -> dict[str, str | bool]:
|
|
987
|
+
"""Prove provider auth and live inference at the exact child boundary."""
|
|
988
|
+
action = _PROVIDER_AUTH_ACTIONS.get(
|
|
989
|
+
provider,
|
|
990
|
+
"Authenticate the selected provider, then retry.",
|
|
991
|
+
)
|
|
992
|
+
if not _enabled_env("LOKI_HOST_SEATBELT"):
|
|
993
|
+
return {
|
|
994
|
+
"provider": provider,
|
|
995
|
+
"available": True,
|
|
996
|
+
"classification": "seatbelt_not_enabled",
|
|
997
|
+
"action": action,
|
|
998
|
+
}
|
|
999
|
+
command = _PROVIDER_AUTH_COMMANDS.get(provider)
|
|
1000
|
+
if command is None:
|
|
1001
|
+
return {
|
|
1002
|
+
"provider": provider,
|
|
1003
|
+
"available": False,
|
|
1004
|
+
"classification": "provider_auth_probe_unsupported",
|
|
1005
|
+
"action": action,
|
|
1006
|
+
}
|
|
1007
|
+
readiness_command = _PROVIDER_READINESS_COMMANDS.get(provider)
|
|
1008
|
+
if readiness_command is None:
|
|
1009
|
+
return {
|
|
1010
|
+
"provider": provider,
|
|
1011
|
+
"available": False,
|
|
1012
|
+
"classification": "provider_readiness_probe_unsupported",
|
|
1013
|
+
"action": action,
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
workspace = workspace.resolve(strict=True)
|
|
1017
|
+
run_sh = run_sh.resolve(strict=True)
|
|
1018
|
+
spec = spec.resolve(strict=True)
|
|
1019
|
+
sandbox_exec = Path("/usr/bin/sandbox-exec")
|
|
1020
|
+
if sys.platform != "darwin" or not sandbox_exec.is_file():
|
|
1021
|
+
return {
|
|
1022
|
+
"provider": provider,
|
|
1023
|
+
"available": False,
|
|
1024
|
+
"classification": "provider_auth_confinement_unavailable",
|
|
1025
|
+
"action": action,
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
loki_dir = workspace / ".loki"
|
|
1029
|
+
child_env = _child_environment(workspace, loki_dir)
|
|
1030
|
+
profile = _host_seatbelt_profile(workspace, run_sh, spec)
|
|
1031
|
+
profile_path = (
|
|
1032
|
+
loki_dir
|
|
1033
|
+
/ "host-runtime"
|
|
1034
|
+
/ f"provider-auth-{uuid.uuid4().hex}.sb"
|
|
1035
|
+
)
|
|
1036
|
+
_atomic_write(profile_path, profile)
|
|
1037
|
+
try:
|
|
1038
|
+
auth_result = subprocess.run(
|
|
1039
|
+
[str(sandbox_exec), "-f", str(profile_path), *command],
|
|
1040
|
+
cwd=str(workspace),
|
|
1041
|
+
env=child_env,
|
|
1042
|
+
capture_output=True,
|
|
1043
|
+
text=True,
|
|
1044
|
+
timeout=10,
|
|
1045
|
+
)
|
|
1046
|
+
except subprocess.TimeoutExpired:
|
|
1047
|
+
return {
|
|
1048
|
+
"provider": provider,
|
|
1049
|
+
"available": False,
|
|
1050
|
+
"classification": "provider_auth_probe_timeout",
|
|
1051
|
+
"action": action,
|
|
1052
|
+
}
|
|
1053
|
+
except OSError:
|
|
1054
|
+
return {
|
|
1055
|
+
"provider": provider,
|
|
1056
|
+
"available": False,
|
|
1057
|
+
"classification": "provider_cli_unavailable",
|
|
1058
|
+
"action": action,
|
|
1059
|
+
}
|
|
1060
|
+
finally:
|
|
1061
|
+
profile_path.unlink(missing_ok=True)
|
|
1062
|
+
|
|
1063
|
+
available, classification = _provider_auth_succeeded(provider, auth_result)
|
|
1064
|
+
if not available:
|
|
1065
|
+
return {
|
|
1066
|
+
"provider": provider,
|
|
1067
|
+
"available": False,
|
|
1068
|
+
"classification": classification,
|
|
1069
|
+
"action": action,
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
profile_path = (
|
|
1073
|
+
loki_dir
|
|
1074
|
+
/ "host-runtime"
|
|
1075
|
+
/ f"provider-ready-{uuid.uuid4().hex}.sb"
|
|
1076
|
+
)
|
|
1077
|
+
_atomic_write(profile_path, profile)
|
|
1078
|
+
try:
|
|
1079
|
+
readiness_result = subprocess.run(
|
|
1080
|
+
[str(sandbox_exec), "-f", str(profile_path), *readiness_command],
|
|
1081
|
+
cwd=str(workspace),
|
|
1082
|
+
env=child_env,
|
|
1083
|
+
capture_output=True,
|
|
1084
|
+
text=True,
|
|
1085
|
+
timeout=20,
|
|
1086
|
+
)
|
|
1087
|
+
except subprocess.TimeoutExpired:
|
|
1088
|
+
return {
|
|
1089
|
+
"provider": provider,
|
|
1090
|
+
"available": False,
|
|
1091
|
+
"classification": "provider_readiness_probe_timeout",
|
|
1092
|
+
"action": action,
|
|
1093
|
+
}
|
|
1094
|
+
except OSError:
|
|
1095
|
+
return {
|
|
1096
|
+
"provider": provider,
|
|
1097
|
+
"available": False,
|
|
1098
|
+
"classification": "provider_cli_unavailable",
|
|
1099
|
+
"action": action,
|
|
1100
|
+
}
|
|
1101
|
+
finally:
|
|
1102
|
+
profile_path.unlink(missing_ok=True)
|
|
1103
|
+
|
|
1104
|
+
if not _provider_readiness_succeeded(provider, readiness_result):
|
|
1105
|
+
return {
|
|
1106
|
+
"provider": provider,
|
|
1107
|
+
"available": False,
|
|
1108
|
+
"classification": "provider_live_inference_unavailable",
|
|
1109
|
+
"action": action,
|
|
1110
|
+
}
|
|
1111
|
+
return {
|
|
1112
|
+
"provider": provider,
|
|
1113
|
+
"available": True,
|
|
1114
|
+
"classification": "available",
|
|
1115
|
+
"action": action,
|
|
1116
|
+
"live_inference_passed": True,
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def smoke_probe_confined_provider_auth(
|
|
1121
|
+
workspace_root: Path,
|
|
1122
|
+
run_sh: Path,
|
|
1123
|
+
provider: str,
|
|
1124
|
+
) -> dict[str, str | bool]:
|
|
1125
|
+
"""Probe auth and live inference before advertising engine readiness."""
|
|
1126
|
+
workspace_root = _required_path(
|
|
1127
|
+
"workspace_root", str(workspace_root), directory=True
|
|
1128
|
+
)
|
|
1129
|
+
with tempfile.TemporaryDirectory(
|
|
1130
|
+
prefix=".provider-auth-probe-", dir=workspace_root
|
|
1131
|
+
) as workspace_raw:
|
|
1132
|
+
workspace = Path(workspace_raw).resolve()
|
|
1133
|
+
spec = workspace / "spec.md"
|
|
1134
|
+
spec.write_text("provider auth capability probe", encoding="utf-8")
|
|
1135
|
+
return confined_provider_auth_status(provider, workspace, run_sh, spec)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def smoke_probe_host_seatbelt(workspace_root: Path, run_sh: Path) -> dict[str, str]:
|
|
1139
|
+
"""Prove the configured kernel profile before advertising confinement."""
|
|
1140
|
+
if sys.platform != "darwin":
|
|
1141
|
+
raise RuntimeError("LOKI host Seatbelt is available only on macOS")
|
|
1142
|
+
sandbox_exec = Path("/usr/bin/sandbox-exec")
|
|
1143
|
+
if not sandbox_exec.is_file() or not os.access(sandbox_exec, os.X_OK):
|
|
1144
|
+
raise RuntimeError("LOKI host Seatbelt requires /usr/bin/sandbox-exec")
|
|
1145
|
+
|
|
1146
|
+
workspace_root = _required_path(
|
|
1147
|
+
"workspace_root", str(workspace_root), directory=True
|
|
1148
|
+
)
|
|
1149
|
+
with tempfile.TemporaryDirectory(
|
|
1150
|
+
prefix=".seatbelt-probe-workspace-", dir=workspace_root
|
|
1151
|
+
) as workspace_raw, tempfile.TemporaryDirectory(
|
|
1152
|
+
prefix=".seatbelt-probe-sibling-", dir=workspace_root
|
|
1153
|
+
) as sibling_raw:
|
|
1154
|
+
workspace = Path(workspace_raw).resolve()
|
|
1155
|
+
sibling = Path(sibling_raw).resolve()
|
|
1156
|
+
spec = workspace / "spec.md"
|
|
1157
|
+
spec.write_text("seatbelt probe", encoding="utf-8")
|
|
1158
|
+
secret = sibling / "secret.txt"
|
|
1159
|
+
secret.write_text("must remain unreadable", encoding="utf-8")
|
|
1160
|
+
unix_socket_path = workspace / "probe.sock"
|
|
1161
|
+
|
|
1162
|
+
config = _host_confinement_config(workspace, run_sh, spec)
|
|
1163
|
+
probe_port = 0
|
|
1164
|
+
for port in config["ports"]:
|
|
1165
|
+
with socket.socket() as candidate:
|
|
1166
|
+
try:
|
|
1167
|
+
candidate.bind(("127.0.0.1", port))
|
|
1168
|
+
except OSError:
|
|
1169
|
+
continue
|
|
1170
|
+
probe_port = port
|
|
1171
|
+
break
|
|
1172
|
+
if not probe_port:
|
|
1173
|
+
raise RuntimeError("no reserved port was free for the Seatbelt probe")
|
|
1174
|
+
|
|
1175
|
+
profile = _host_seatbelt_profile(workspace, run_sh, spec)
|
|
1176
|
+
profile_path = workspace / "probe.sb"
|
|
1177
|
+
profile_path.write_bytes(profile)
|
|
1178
|
+
output_path = workspace / "probe.txt"
|
|
1179
|
+
probe_code = """
|
|
1180
|
+
import errno
|
|
1181
|
+
import os
|
|
1182
|
+
import signal
|
|
1183
|
+
import socket
|
|
1184
|
+
import subprocess
|
|
1185
|
+
import sys
|
|
1186
|
+
|
|
1187
|
+
output, sibling, unix_path, port = sys.argv[1:]
|
|
1188
|
+
open(output, "w", encoding="utf-8").write("confined")
|
|
1189
|
+
open("/etc/hosts", encoding="utf-8").read(1)
|
|
1190
|
+
child = subprocess.Popen(
|
|
1191
|
+
["/bin/sh", "-c", "/bin/sleep 5 & wait"],
|
|
1192
|
+
stdin=subprocess.DEVNULL,
|
|
1193
|
+
stdout=subprocess.DEVNULL,
|
|
1194
|
+
stderr=subprocess.DEVNULL,
|
|
1195
|
+
preexec_fn=os.setpgrp,
|
|
1196
|
+
)
|
|
1197
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
1198
|
+
child.wait(timeout=2)
|
|
1199
|
+
try:
|
|
1200
|
+
open(sibling, encoding="utf-8").read(1)
|
|
1201
|
+
except OSError:
|
|
1202
|
+
pass
|
|
1203
|
+
else:
|
|
1204
|
+
raise SystemExit(41)
|
|
1205
|
+
with socket.socket(socket.AF_UNIX) as client:
|
|
1206
|
+
try:
|
|
1207
|
+
client.connect(unix_path)
|
|
1208
|
+
except OSError:
|
|
1209
|
+
pass
|
|
1210
|
+
else:
|
|
1211
|
+
raise SystemExit(42)
|
|
1212
|
+
with socket.socket() as listener:
|
|
1213
|
+
try:
|
|
1214
|
+
listener.bind(("127.0.0.1", int(port)))
|
|
1215
|
+
except OSError as exc:
|
|
1216
|
+
if exc.errno not in (errno.EACCES, errno.EPERM):
|
|
1217
|
+
raise
|
|
1218
|
+
else:
|
|
1219
|
+
raise SystemExit(43)
|
|
1220
|
+
"""
|
|
1221
|
+
with socket.socket(socket.AF_UNIX) as unix_server:
|
|
1222
|
+
unix_server.bind(str(unix_socket_path))
|
|
1223
|
+
unix_server.listen(1)
|
|
1224
|
+
try:
|
|
1225
|
+
result = subprocess.run(
|
|
1226
|
+
[
|
|
1227
|
+
str(sandbox_exec),
|
|
1228
|
+
"-f",
|
|
1229
|
+
str(profile_path),
|
|
1230
|
+
"/usr/bin/python3",
|
|
1231
|
+
"-c",
|
|
1232
|
+
probe_code,
|
|
1233
|
+
str(output_path),
|
|
1234
|
+
str(secret),
|
|
1235
|
+
str(unix_socket_path),
|
|
1236
|
+
str(probe_port),
|
|
1237
|
+
],
|
|
1238
|
+
cwd=str(workspace),
|
|
1239
|
+
env={
|
|
1240
|
+
"HOME": os.environ.get("HOME", "/var/empty"),
|
|
1241
|
+
"LANG": os.environ.get("LANG", "C"),
|
|
1242
|
+
"PATH": os.environ.get(
|
|
1243
|
+
"LOKI_HOST_CHILD_PATH", "/usr/bin:/bin:/usr/sbin:/sbin"
|
|
1244
|
+
),
|
|
1245
|
+
"TMPDIR": str(workspace),
|
|
1246
|
+
},
|
|
1247
|
+
capture_output=True,
|
|
1248
|
+
text=True,
|
|
1249
|
+
timeout=15,
|
|
1250
|
+
)
|
|
1251
|
+
except subprocess.SubprocessError as exc:
|
|
1252
|
+
raise RuntimeError("Seatbelt kernel smoke probe did not finish") from exc
|
|
1253
|
+
if result.returncode != 0 or output_path.read_text(encoding="utf-8") != "confined":
|
|
1254
|
+
raise RuntimeError(
|
|
1255
|
+
f"Seatbelt kernel smoke probe failed with exit {result.returncode}"
|
|
1256
|
+
)
|
|
1257
|
+
return {
|
|
1258
|
+
**host_confinement_identity(workspace_root, run_sh),
|
|
1259
|
+
"probe_sha256": sha256_bytes(profile),
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _host_confined_args(
|
|
1264
|
+
execution_id: str,
|
|
1265
|
+
args: list[str],
|
|
1266
|
+
workspace: Path,
|
|
1267
|
+
run_sh: Path,
|
|
1268
|
+
spec: Path,
|
|
1269
|
+
) -> tuple[list[str], dict[str, Any] | None]:
|
|
1270
|
+
"""Wrap a controlled local build in macOS Seatbelt when explicitly armed."""
|
|
1271
|
+
if not _enabled_env("LOKI_HOST_SEATBELT"):
|
|
1272
|
+
return args, None
|
|
1273
|
+
if sys.platform != "darwin":
|
|
1274
|
+
raise RuntimeError("LOKI host Seatbelt is available only on macOS")
|
|
1275
|
+
sandbox_exec = Path("/usr/bin/sandbox-exec")
|
|
1276
|
+
if not sandbox_exec.is_file() or not os.access(sandbox_exec, os.X_OK):
|
|
1277
|
+
raise RuntimeError("LOKI host Seatbelt requires /usr/bin/sandbox-exec")
|
|
1278
|
+
|
|
1279
|
+
probe_sha256 = os.environ.get("LOKI_HOST_SEATBELT_PROBE_SHA256", "").strip()
|
|
1280
|
+
if not re.fullmatch(r"[0-9a-f]{64}", probe_sha256):
|
|
1281
|
+
raise RuntimeError("LOKI host Seatbelt kernel probe is missing or invalid")
|
|
1282
|
+
profile = _host_seatbelt_profile(workspace, run_sh, spec)
|
|
1283
|
+
profile_path = execution_dir(execution_id) / "host-seatbelt.sb"
|
|
1284
|
+
_atomic_write(profile_path, profile)
|
|
1285
|
+
confinement = {
|
|
1286
|
+
"kind": "macos-seatbelt",
|
|
1287
|
+
"profile_sha256": sha256_bytes(profile),
|
|
1288
|
+
"kernel_probe_sha256": probe_sha256,
|
|
1289
|
+
"known_limitations": [
|
|
1290
|
+
"preexisting-cross-boundary-hardlinks",
|
|
1291
|
+
"unrestricted-outbound-network",
|
|
1292
|
+
"provider-credential-store-visible-to-confined-build",
|
|
1293
|
+
"deprecated-macos-sandbox-exec",
|
|
1294
|
+
],
|
|
1295
|
+
}
|
|
1296
|
+
return [str(sandbox_exec), "-f", str(profile_path), *args], confinement
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def run_supervisor(
|
|
1300
|
+
execution_id: str,
|
|
1301
|
+
run_sh: Path,
|
|
1302
|
+
workspace: Path,
|
|
1303
|
+
loki_dir: Path,
|
|
1304
|
+
spec: Path,
|
|
1305
|
+
provider: str,
|
|
1306
|
+
parallel: bool = False,
|
|
1307
|
+
) -> int:
|
|
1308
|
+
"""Run one foreground engine child and persist its exact terminal result."""
|
|
1309
|
+
execution_id = validate_execution_id(execution_id)
|
|
1310
|
+
run_sh = run_sh.resolve(strict=True)
|
|
1311
|
+
workspace = workspace.resolve(strict=True)
|
|
1312
|
+
loki_dir = loki_dir.resolve(strict=True)
|
|
1313
|
+
spec = spec.resolve(strict=True)
|
|
1314
|
+
supervisor_pid = os.getpid()
|
|
1315
|
+
update_state(
|
|
1316
|
+
execution_id,
|
|
1317
|
+
supervisor_pid=supervisor_pid,
|
|
1318
|
+
supervisor_birth_token=process_birth_token(supervisor_pid),
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
args = [str(run_sh), "--provider", provider]
|
|
1322
|
+
if parallel:
|
|
1323
|
+
args.append("--parallel")
|
|
1324
|
+
args.append(str(spec))
|
|
1325
|
+
|
|
1326
|
+
child_env = _child_environment(workspace, loki_dir)
|
|
1327
|
+
# A reused brownfield workspace may already contain an older run identity.
|
|
1328
|
+
# Only a value minted after this execution starts may bind its receipt.
|
|
1329
|
+
baseline_run_id = _read_run_id(loki_dir)
|
|
1330
|
+
|
|
1331
|
+
runner_log = execution_dir(execution_id) / "runner.log"
|
|
1332
|
+
try:
|
|
1333
|
+
args, confinement = _host_confined_args(
|
|
1334
|
+
execution_id,
|
|
1335
|
+
args,
|
|
1336
|
+
workspace,
|
|
1337
|
+
run_sh,
|
|
1338
|
+
spec,
|
|
1339
|
+
)
|
|
1340
|
+
if confinement is not None:
|
|
1341
|
+
update_state(execution_id, confinement=confinement)
|
|
1342
|
+
log_handle = open_private_log(runner_log)
|
|
1343
|
+
process, lineage_tracker = process_deadline.spawn_tracked(
|
|
1344
|
+
args,
|
|
1345
|
+
cwd=str(workspace),
|
|
1346
|
+
env=child_env,
|
|
1347
|
+
stdout=log_handle,
|
|
1348
|
+
stderr=subprocess.STDOUT,
|
|
1349
|
+
)
|
|
1350
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
1351
|
+
try:
|
|
1352
|
+
log_handle.close()
|
|
1353
|
+
except (NameError, OSError):
|
|
1354
|
+
pass
|
|
1355
|
+
finished_at = utc_now()
|
|
1356
|
+
update_state(
|
|
1357
|
+
execution_id,
|
|
1358
|
+
state="exited",
|
|
1359
|
+
exited_at=finished_at,
|
|
1360
|
+
finished_at=finished_at,
|
|
1361
|
+
returncode=None,
|
|
1362
|
+
exit_code=None,
|
|
1363
|
+
signal=None,
|
|
1364
|
+
termination_reason="launch_failed",
|
|
1365
|
+
launch_error=str(exc),
|
|
1366
|
+
proof=empty_proof_binding(execution_id, "runner launch failed"),
|
|
1367
|
+
)
|
|
1368
|
+
return 127
|
|
1369
|
+
|
|
1370
|
+
runner_pid = process.pid
|
|
1371
|
+
# start_new_session=True makes the child both session and process-group
|
|
1372
|
+
# leader before exec. These IDs remain authoritative after the leader exits.
|
|
1373
|
+
runner_pgid = runner_pid
|
|
1374
|
+
runner_sid = runner_pid
|
|
1375
|
+
runner_birth_token = process_birth_token(runner_pid)
|
|
1376
|
+
update_state(
|
|
1377
|
+
execution_id,
|
|
1378
|
+
state="running",
|
|
1379
|
+
started_at=utc_now(),
|
|
1380
|
+
runner_pid=runner_pid,
|
|
1381
|
+
runner_pgid=runner_pgid,
|
|
1382
|
+
runner_sid=runner_sid,
|
|
1383
|
+
runner_birth_token=runner_birth_token,
|
|
1384
|
+
runner_log=str(runner_log),
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
received_signal = 0
|
|
1388
|
+
|
|
1389
|
+
def _request_stop(signum: int, _frame: Any) -> None:
|
|
1390
|
+
nonlocal received_signal
|
|
1391
|
+
received_signal = signum
|
|
1392
|
+
|
|
1393
|
+
previous_handlers: dict[int, Any] = {}
|
|
1394
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
1395
|
+
previous_handlers[signum] = signal.signal(signum, _request_stop)
|
|
1396
|
+
|
|
1397
|
+
stop_sent_at: float | None = None
|
|
1398
|
+
stop_refused = False
|
|
1399
|
+
kill_sent = False
|
|
1400
|
+
stop_grace = _positive_float_env("LOKI_BUILD_STOP_GRACE_SECONDS", 10.0)
|
|
1401
|
+
poll_interval = _positive_float_env("LOKI_BUILD_SUPERVISOR_POLL_SECONDS", 0.2)
|
|
1402
|
+
cached_run_id = ""
|
|
1403
|
+
lineage_error = ""
|
|
1404
|
+
try:
|
|
1405
|
+
while process.poll() is None:
|
|
1406
|
+
if not lineage_error:
|
|
1407
|
+
try:
|
|
1408
|
+
lineage_tracker.refresh()
|
|
1409
|
+
except (OSError, PermissionError, RuntimeError) as exc:
|
|
1410
|
+
lineage_error = (
|
|
1411
|
+
"Runner lineage tracking failed: " + type(exc).__name__
|
|
1412
|
+
)
|
|
1413
|
+
update_state(execution_id, stop_error=lineage_error)
|
|
1414
|
+
state = read_state(execution_id) or {}
|
|
1415
|
+
if not cached_run_id:
|
|
1416
|
+
candidate_run_id = _read_run_id(loki_dir)
|
|
1417
|
+
if candidate_run_id and candidate_run_id != baseline_run_id:
|
|
1418
|
+
cached_run_id = candidate_run_id
|
|
1419
|
+
update_state(execution_id, run_id=cached_run_id)
|
|
1420
|
+
stop_requested = bool(
|
|
1421
|
+
state.get("stop_requested_at") or received_signal or lineage_error
|
|
1422
|
+
)
|
|
1423
|
+
if stop_requested and stop_sent_at is None and not stop_refused:
|
|
1424
|
+
signal_error = _signal_owned_runner_group(
|
|
1425
|
+
runner_pid,
|
|
1426
|
+
runner_pgid,
|
|
1427
|
+
runner_sid,
|
|
1428
|
+
runner_birth_token,
|
|
1429
|
+
signal.SIGTERM,
|
|
1430
|
+
)
|
|
1431
|
+
if signal_error:
|
|
1432
|
+
update_state(
|
|
1433
|
+
execution_id,
|
|
1434
|
+
stop_error=signal_error,
|
|
1435
|
+
)
|
|
1436
|
+
stop_refused = True
|
|
1437
|
+
else:
|
|
1438
|
+
stop_sent_at = time.monotonic()
|
|
1439
|
+
update_state(execution_id, stop_signal_sent_at=utc_now())
|
|
1440
|
+
elif (
|
|
1441
|
+
stop_sent_at is not None
|
|
1442
|
+
and time.monotonic() - stop_sent_at >= stop_grace
|
|
1443
|
+
and not kill_sent
|
|
1444
|
+
):
|
|
1445
|
+
signal_error = _signal_owned_runner_group(
|
|
1446
|
+
runner_pid,
|
|
1447
|
+
runner_pgid,
|
|
1448
|
+
runner_sid,
|
|
1449
|
+
runner_birth_token,
|
|
1450
|
+
signal.SIGKILL,
|
|
1451
|
+
)
|
|
1452
|
+
if signal_error:
|
|
1453
|
+
update_state(
|
|
1454
|
+
execution_id,
|
|
1455
|
+
stop_error=signal_error,
|
|
1456
|
+
)
|
|
1457
|
+
kill_sent = True
|
|
1458
|
+
time.sleep(poll_interval)
|
|
1459
|
+
returncode = process.wait()
|
|
1460
|
+
finally:
|
|
1461
|
+
log_handle.close()
|
|
1462
|
+
for signum, handler in previous_handlers.items():
|
|
1463
|
+
signal.signal(signum, handler)
|
|
1464
|
+
|
|
1465
|
+
descendant_grace = _positive_float_env(
|
|
1466
|
+
"LOKI_BUILD_DESCENDANT_GRACE_SECONDS", 2.0
|
|
1467
|
+
)
|
|
1468
|
+
if lineage_error:
|
|
1469
|
+
descendants = empty_descendant_outcome()
|
|
1470
|
+
descendants["error"] = lineage_error
|
|
1471
|
+
else:
|
|
1472
|
+
try:
|
|
1473
|
+
descendants = process_deadline.reconcile_lineage(
|
|
1474
|
+
process, lineage_tracker, descendant_grace
|
|
1475
|
+
)
|
|
1476
|
+
except (OSError, PermissionError, RuntimeError) as exc:
|
|
1477
|
+
descendants = empty_descendant_outcome()
|
|
1478
|
+
descendants["error"] = (
|
|
1479
|
+
"Runner lineage reconciliation failed: " + type(exc).__name__
|
|
1480
|
+
)
|
|
1481
|
+
lineage_tracker.close()
|
|
1482
|
+
abnormal_descendants = bool(descendants.get("detected") or descendants.get("error"))
|
|
1483
|
+
if abnormal_descendants:
|
|
1484
|
+
run_id = ""
|
|
1485
|
+
tree_sha256 = ""
|
|
1486
|
+
proof = empty_proof_binding(
|
|
1487
|
+
execution_id,
|
|
1488
|
+
"proof not captured because runner descendants survived direct exit",
|
|
1489
|
+
)
|
|
1490
|
+
else:
|
|
1491
|
+
final_candidate_run_id = _read_run_id(loki_dir)
|
|
1492
|
+
run_id = cached_run_id or (
|
|
1493
|
+
final_candidate_run_id
|
|
1494
|
+
if final_candidate_run_id and final_candidate_run_id != baseline_run_id
|
|
1495
|
+
else ""
|
|
1496
|
+
)
|
|
1497
|
+
# Snapshot the exact proof first, then derive the returned source tree.
|
|
1498
|
+
proof = _proof_binding(execution_id, loki_dir, run_id, "")
|
|
1499
|
+
tree_sha256 = _load_tree_digest()(workspace)
|
|
1500
|
+
integrity = (
|
|
1501
|
+
proof.get("integrity")
|
|
1502
|
+
if isinstance(proof.get("integrity"), dict)
|
|
1503
|
+
else {}
|
|
1504
|
+
)
|
|
1505
|
+
proof["bound"] = bool(
|
|
1506
|
+
proof.get("present")
|
|
1507
|
+
and proof.get("snapshotted")
|
|
1508
|
+
and proof.get("run_id") == run_id
|
|
1509
|
+
and tree_sha256
|
|
1510
|
+
and proof.get("tree_sha256") == tree_sha256
|
|
1511
|
+
and proof.get("tree_fields_agree")
|
|
1512
|
+
and integrity.get("ok") is True
|
|
1513
|
+
and integrity.get("headline_consistent") is True
|
|
1514
|
+
)
|
|
1515
|
+
stopped = bool(stop_sent_at is not None)
|
|
1516
|
+
if descendants.get("detected"):
|
|
1517
|
+
reason = (
|
|
1518
|
+
"descendants_terminated"
|
|
1519
|
+
if descendants.get("quiescent")
|
|
1520
|
+
else "descendants_uncontained"
|
|
1521
|
+
)
|
|
1522
|
+
elif descendants.get("error"):
|
|
1523
|
+
reason = "descendant_check_failed"
|
|
1524
|
+
elif stopped:
|
|
1525
|
+
reason = "stopped"
|
|
1526
|
+
elif (
|
|
1527
|
+
returncode == 0
|
|
1528
|
+
and proof.get("bound") is True
|
|
1529
|
+
and proof.get("headline") == "VERIFIED"
|
|
1530
|
+
and (proof.get("execution") or {}).get("exit_code") == 0
|
|
1531
|
+
and (proof.get("execution") or {}).get("run_status") in {
|
|
1532
|
+
"deterministic_gates_passed",
|
|
1533
|
+
"council_approved",
|
|
1534
|
+
"council_force_approved",
|
|
1535
|
+
"completion_promise_fulfilled",
|
|
1536
|
+
"reuse_already_satisfied",
|
|
1537
|
+
}
|
|
1538
|
+
and not proof.get("failed_quality_gates")
|
|
1539
|
+
):
|
|
1540
|
+
reason = "completed"
|
|
1541
|
+
elif returncode in (0, 20):
|
|
1542
|
+
reason = "verification_failed"
|
|
1543
|
+
else:
|
|
1544
|
+
reason = "crashed"
|
|
1545
|
+
exit_code = returncode if returncode >= 0 else None
|
|
1546
|
+
exit_signal = -returncode if returncode < 0 else None
|
|
1547
|
+
finished_at = utc_now()
|
|
1548
|
+
update_state(
|
|
1549
|
+
execution_id,
|
|
1550
|
+
state="exited",
|
|
1551
|
+
exited_at=finished_at,
|
|
1552
|
+
finished_at=finished_at,
|
|
1553
|
+
returncode=returncode,
|
|
1554
|
+
exit_code=exit_code,
|
|
1555
|
+
signal=exit_signal,
|
|
1556
|
+
termination_reason=reason,
|
|
1557
|
+
run_id=run_id,
|
|
1558
|
+
final_tree_sha256=tree_sha256,
|
|
1559
|
+
descendants=descendants,
|
|
1560
|
+
proof=proof,
|
|
1561
|
+
)
|
|
1562
|
+
return returncode
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1566
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1567
|
+
parser.add_argument("--execution-id", required=True)
|
|
1568
|
+
parser.add_argument("--run-sh", required=True)
|
|
1569
|
+
parser.add_argument("--workspace", required=True)
|
|
1570
|
+
parser.add_argument("--loki-dir", required=True)
|
|
1571
|
+
parser.add_argument("--spec", required=True)
|
|
1572
|
+
parser.add_argument("--provider", required=True)
|
|
1573
|
+
parser.add_argument("--parallel", action="store_true")
|
|
1574
|
+
args = parser.parse_args(argv)
|
|
1575
|
+
result = run_supervisor(
|
|
1576
|
+
execution_id=args.execution_id,
|
|
1577
|
+
run_sh=Path(args.run_sh),
|
|
1578
|
+
workspace=Path(args.workspace),
|
|
1579
|
+
loki_dir=Path(args.loki_dir),
|
|
1580
|
+
spec=Path(args.spec),
|
|
1581
|
+
provider=args.provider,
|
|
1582
|
+
parallel=args.parallel,
|
|
1583
|
+
)
|
|
1584
|
+
if result < 0:
|
|
1585
|
+
return 128 + (-result)
|
|
1586
|
+
return min(result, 255)
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
if __name__ == "__main__":
|
|
1590
|
+
raise SystemExit(main())
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
__all__ = [
|
|
1594
|
+
"SCHEMA_VERSION",
|
|
1595
|
+
"confined_provider_auth_status",
|
|
1596
|
+
"empty_descendant_outcome",
|
|
1597
|
+
"empty_integrity_result",
|
|
1598
|
+
"empty_proof_binding",
|
|
1599
|
+
"execution_dir",
|
|
1600
|
+
"execution_lock",
|
|
1601
|
+
"executions_root",
|
|
1602
|
+
"host_confinement_identity",
|
|
1603
|
+
"open_private_log",
|
|
1604
|
+
"process_birth_token",
|
|
1605
|
+
"process_identity_matches",
|
|
1606
|
+
"process_state",
|
|
1607
|
+
"read_state",
|
|
1608
|
+
"read_state_unlocked",
|
|
1609
|
+
"run_supervisor",
|
|
1610
|
+
"sha256_bytes",
|
|
1611
|
+
"smoke_probe_confined_provider_auth",
|
|
1612
|
+
"smoke_probe_host_seatbelt",
|
|
1613
|
+
"state_path",
|
|
1614
|
+
"update_state",
|
|
1615
|
+
"utc_now",
|
|
1616
|
+
"validate_execution_id",
|
|
1617
|
+
"write_spec_snapshot_unlocked",
|
|
1618
|
+
"write_state_unlocked",
|
|
1619
|
+
]
|