loki-mode 11.56.5 → 11.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/lib/os_sandbox.py +600 -0
  4. package/autonomy/lib/secret-paths.json +9 -0
  5. package/dashboard/__init__.py +1 -1
  6. package/dashboard/build_supervisor.py +17 -204
  7. package/docs/CLI-REFERENCE.md +1 -1
  8. package/docs/INSTALLATION.md +1 -1
  9. package/loki-ts/dist/loki.js +2 -2
  10. package/mcp/__init__.py +1 -1
  11. package/package.json +2 -2
  12. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  13. package/src/policies/claude_permissions.js +3 -1
  14. package/web-app/dist/assets/{AdminPage-Dimx1yBh.js → AdminPage-DBiUblcD.js} +1 -1
  15. package/web-app/dist/assets/{Avatar-Dc3bWhBo.js → Avatar-EdMAS9fc.js} +1 -1
  16. package/web-app/dist/assets/{Badge-b2X7zLeT.js → Badge-DaAeyY6D.js} +1 -1
  17. package/web-app/dist/assets/{Button-WmENJAsd.js → Button-BuKkqCgI.js} +1 -1
  18. package/web-app/dist/assets/{CockpitPage-8RQI3bm-.js → CockpitPage-CLtGh4DQ.js} +1 -1
  19. package/web-app/dist/assets/{ComparePage-CifTx_ce.js → ComparePage-B_ndBuIY.js} +1 -1
  20. package/web-app/dist/assets/{ErrorBoundary-BWf3qZVi.js → ErrorBoundary-BUTTQEvv.js} +1 -1
  21. package/web-app/dist/assets/{EvidenceReceiptPanel-COEJUdCv.js → EvidenceReceiptPanel-CUHWRxGp.js} +1 -1
  22. package/web-app/dist/assets/{GitHubIssuesPanel-BuoEObIg.js → GitHubIssuesPanel-AaRFMsoo.js} +1 -1
  23. package/web-app/dist/assets/{GitHubPRsPanel-k338Akq5.js → GitHubPRsPanel-C_2LI83h.js} +1 -1
  24. package/web-app/dist/assets/{HomePage-Dw89TygE.js → HomePage-6ztnYohP.js} +2 -2
  25. package/web-app/dist/assets/{LoginPage-Go7X1UUz.js → LoginPage-DO6u5aeL.js} +1 -1
  26. package/web-app/dist/assets/{MagicPage-l9yI0QuR.js → MagicPage-Dv3_fZqR.js} +1 -1
  27. package/web-app/dist/assets/{MetricsPage-CqYbIdOq.js → MetricsPage-pw-RkgcH.js} +1 -1
  28. package/web-app/dist/assets/{NotFoundPage-DDY07nmT.js → NotFoundPage-CZA8cYxO.js} +1 -1
  29. package/web-app/dist/assets/{ProjectPage-BI3YXMvn.js → ProjectPage-CCS_3vgk.js} +3 -3
  30. package/web-app/dist/assets/{ProjectsPage-B-e-jZCX.js → ProjectsPage-CP5J7CV8.js} +1 -1
  31. package/web-app/dist/assets/{SettingsPage-D5yeF0Ib.js → SettingsPage-DHttQEO6.js} +1 -1
  32. package/web-app/dist/assets/{ShowcasePage-Cp4lhM2Q.js → ShowcasePage-CNwwQO24.js} +1 -1
  33. package/web-app/dist/assets/{SystemSettingsPage-CGgunfcK.js → SystemSettingsPage-Cx6e8tDF.js} +1 -1
  34. package/web-app/dist/assets/{TeamsPage-CH4-Ec82.js → TeamsPage-B8r_0194.js} +1 -1
  35. package/web-app/dist/assets/{TemplatesPage-DphuBvfB.js → TemplatesPage-Cn7dEkNR.js} +1 -1
  36. package/web-app/dist/assets/{TerminalOutput-BppH3g5s.js → TerminalOutput-CA11igTw.js} +1 -1
  37. package/web-app/dist/assets/{activity-BWs18kHl.js → activity-B15LD09k.js} +1 -1
  38. package/web-app/dist/assets/{bell-CJcKaBBF.js → bell-Ca5Ldx8p.js} +1 -1
  39. package/web-app/dist/assets/{bot-DzNrwUlE.js → bot-CAaO6PrO.js} +1 -1
  40. package/web-app/dist/assets/{check-BwXjvRFD.js → check-Cp1n0u4X.js} +1 -1
  41. package/web-app/dist/assets/{chevron-left-CtR6I6_X.js → chevron-left-Bj46ZgUD.js} +1 -1
  42. package/web-app/dist/assets/{circle-alert-Buh7j2ty.js → circle-alert-CkKaFsnu.js} +1 -1
  43. package/web-app/dist/assets/{clock-s2f9N3Vx.js → clock-CtloyNk1.js} +1 -1
  44. package/web-app/dist/assets/{cloud-DEOd_pzL.js → cloud-B2hYe0sR.js} +1 -1
  45. package/web-app/dist/assets/{code-xml-C4xFPVwf.js → code-xml-CDOS05Y1.js} +1 -1
  46. package/web-app/dist/assets/{database-BnoHUFDF.js → database-L38ilUtw.js} +1 -1
  47. package/web-app/dist/assets/{dollar-sign-DtvGSyex.js → dollar-sign-B2257oWE.js} +1 -1
  48. package/web-app/dist/assets/{file-code-corner-DzzG_aWp.js → file-code-corner-CV8o-4g0.js} +1 -1
  49. package/web-app/dist/assets/{file-plus-D44fKKeH.js → file-plus-B7MPR3fA.js} +1 -1
  50. package/web-app/dist/assets/{globe-BmRQoo61.js → globe-BLCn_Cog.js} +1 -1
  51. package/web-app/dist/assets/{hammer-DlnGJRRm.js → hammer-BJ8yNwTJ.js} +1 -1
  52. package/web-app/dist/assets/{index-yNuJC1nz.js → index-CGzqU7rU.js} +3 -3
  53. package/web-app/dist/assets/{layers-Mp7J961W.js → layers-BfWLlNeT.js} +1 -1
  54. package/web-app/dist/assets/{loader-circle-BXbrKzAM.js → loader-circle-Cv8_-xvl.js} +1 -1
  55. package/web-app/dist/assets/{lock--G0xB_sg.js → lock-Dn-oYVZ3.js} +1 -1
  56. package/web-app/dist/assets/{package-ffXQaNr5.js → package-DpH_ZGA2.js} +1 -1
  57. package/web-app/dist/assets/{plus-D-SjJwMh.js → plus-CeKCAWlJ.js} +1 -1
  58. package/web-app/dist/assets/{refresh-cw-YZez-e-J.js → refresh-cw-D9GUiPrl.js} +1 -1
  59. package/web-app/dist/assets/{rotate-ccw-DJeBzW7L.js → rotate-ccw-D4U0EMfj.js} +1 -1
  60. package/web-app/dist/assets/{scroll-text-BczZXyxh.js → scroll-text-CRkW-BOl.js} +1 -1
  61. package/web-app/dist/assets/{server-Cgf89eCI.js → server-C-BYNYwp.js} +1 -1
  62. package/web-app/dist/assets/{shield-alert-CfnIh_vZ.js → shield-alert-D5rem0gR.js} +1 -1
  63. package/web-app/dist/assets/{thumbs-up-DVWVUs2p.js → thumbs-up-DT0YnPRK.js} +1 -1
  64. package/web-app/dist/assets/{trash-2-C7y11wmM.js → trash-2-BGWE-LNI.js} +1 -1
  65. package/web-app/dist/assets/{trending-down-bTE7raGc.js → trending-down-CiQmvJrr.js} +1 -1
  66. package/web-app/dist/assets/{trending-up-CWUE8let.js → trending-up-ZFFlnhHp.js} +1 -1
  67. package/web-app/dist/assets/{upload-DV61LMnd.js → upload-CwJmMS8s.js} +1 -1
  68. package/web-app/dist/assets/{usePolling-CsmEvZkA.js → usePolling-BsDsq3La.js} +1 -1
  69. package/web-app/dist/assets/{user-JuKWf6JG.js → user-x9UMwtb0.js} +1 -1
  70. package/web-app/dist/index.html +1 -1
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Runs an autonomous software factory that knows what it is supposed to deliver and proves it did, with a signed receipt. Use when the user says Loki Mode or asks to build, fix or verify software autonomously.
4
4
  ---
5
5
 
6
- # Loki Mode v11.56.5
6
+ # Loki Mode v11.57.0
7
7
 
8
8
  **Current entry points (Loki 10 engine):** `loki "<task>"`, `loki owner/repo#N` and `loki quick "<task>"` run the Loki 10 engine and end with a signed receipt (`loki verify`). `loki backlog owner/repo --all` runs many issues in parallel. Guide: `docs/v10/GUIDE.md`. The skill instructions below, and `loki start`, describe the previous bash loop. PRD-file starts (`loki start ./prd.md`) and the opencode provider still run on it until FC38-SWEEP ports them; the legacy-engine command and the LOKI_ENGINE switch remain removed. Prefer `loki owner/repo#N` for issues.
9
9
 
@@ -476,4 +476,4 @@ Implemented, planned and deprecated items, with the version each landed in: `ski
476
476
 
477
477
  ---
478
478
 
479
- **v11.56.5 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
479
+ **v11.57.0 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 11.56.5
1
+ 11.57.0
@@ -0,0 +1,600 @@
1
+ #!/usr/bin/env python3
2
+ """One OS confinement module: macOS Seatbelt and Linux bubblewrap.
3
+
4
+ Owns the Seatbelt profile builder and kernel smoke probe (moved out of
5
+ dashboard/build_supervisor.py) plus a provider-agnostic argv wrapper.
6
+ Secret paths come from autonomy/lib/secret-paths.json, the same list
7
+ src/policies/claude_permissions.js compiles into Claude read-deny rules.
8
+
9
+ CLI:
10
+ python3 -m autonomy.lib.os_sandbox argv --workspace W --run-dir R --provider P
11
+ prints {kind, argv_prefix, profile_sha256, kernel_probe_sha256, known_limitations}.
12
+ LOKI_OS_SANDBOX=off|auto|on (default off). Under on, an unavailable sandbox or a
13
+ failed kernel probe exits 2. auto falls back to kind none with a reason.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import shutil
23
+ import socket
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from pathlib import Path
28
+ from typing import Any, Iterable
29
+
30
+ SANDBOX_EXEC = Path("/usr/bin/sandbox-exec")
31
+ SECRET_PATHS_FILE = Path(__file__).resolve().parent / "secret-paths.json"
32
+ PROBE_TIMEOUT_SECONDS = 15
33
+ # Scratch space every provider CLI needs. A workspace inside one of these roots
34
+ # cannot prove "write outside the workspace is denied" by sibling probe.
35
+ TMP_ROOTS = {
36
+ "darwin": (Path("/private/tmp"), Path("/private/var/folders")),
37
+ "linux": (Path("/tmp"),),
38
+ }
39
+ WALK_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "__pycache__"})
40
+ WALK_MAX_ENTRIES = 200000
41
+
42
+ # Provider CLI state the confined process must be able to write. Each entry was
43
+ # checked against a machine with the CLI installed (~/.claude, ~/.claude.json,
44
+ # ~/.codex, ~/.config/opencode, ~/.local/share/opencode, ~/.cache/opencode,
45
+ # ~/.cline, ~/.aider*). A trailing * is a prefix glob inside HOME.
46
+ PROVIDER_WRITE_PATHS: dict[str, tuple[str, ...]] = {
47
+ "claude": ("~/.claude", "~/.claude.json"),
48
+ "codex": ("~/.codex",),
49
+ "opencode": (
50
+ "~/.config/opencode",
51
+ "~/.local/share/opencode",
52
+ "~/.cache/opencode",
53
+ ),
54
+ "cline": ("~/.cline",),
55
+ "aider": ("~/.aider*",),
56
+ }
57
+
58
+ INSTALL_HINT = {
59
+ "darwin": "sandbox-exec ships with macOS at /usr/bin/sandbox-exec",
60
+ "linux": "install bubblewrap (apt install bubblewrap, dnf install bubblewrap)",
61
+ }
62
+
63
+
64
+ class SandboxUnavailable(RuntimeError):
65
+ """Confinement was requested but cannot be provided."""
66
+
67
+
68
+ def sha256_bytes(content: bytes) -> str:
69
+ return hashlib.sha256(content).hexdigest()
70
+
71
+
72
+ # ---------------------------------------------------------------- secret list
73
+
74
+ def load_secret_rules(path: Path = SECRET_PATHS_FILE) -> list[dict[str, str]]:
75
+ data = json.loads(path.read_text(encoding="utf-8"))
76
+ rules = data["rules"]
77
+ for rule in rules:
78
+ if rule["kind"] == "file" and rule.get("glob"):
79
+ continue
80
+ if rule["kind"] == "dir" and rule.get("name"):
81
+ continue
82
+ raise ValueError(f"invalid secret-paths rule: {rule!r}")
83
+ return rules
84
+
85
+
86
+ def _glob_to_regex(glob: str) -> str:
87
+ return "".join("[^/]*" if ch == "*" else re.escape(ch) for ch in glob)
88
+
89
+
90
+ def secret_path_regexes(rules: Iterable[dict[str, str]]) -> list[str]:
91
+ """Regexes (POSIX ERE subset) matching a secret path anywhere on disk."""
92
+ out = []
93
+ for rule in rules:
94
+ if rule["kind"] == "file":
95
+ out.append(f"(^|/){_glob_to_regex(rule['glob'])}$")
96
+ else:
97
+ out.append(f"(^|/){_glob_to_regex(rule['name'])}(/|$)")
98
+ return out
99
+
100
+
101
+ def _seatbelt_regex(regex: str) -> str:
102
+ # Seatbelt regex literal #"..." passes backslashes through to the regex
103
+ # engine; a double quote cannot occur in the rule alphabet.
104
+ return '(regex #"' + regex.replace('"', '\\"') + '")'
105
+
106
+
107
+ # ------------------------------------------------------------------- Seatbelt
108
+
109
+ def seatbelt_path_filter(path: Path) -> str:
110
+ kind = "subpath" if path.is_dir() else "literal"
111
+ return f"({kind} {json.dumps(str(path), ensure_ascii=True)})"
112
+
113
+
114
+ def seatbelt_profile(
115
+ config: dict[str, Any],
116
+ secret_regexes: Iterable[str] = (),
117
+ ) -> bytes:
118
+ """Build the macOS local-acceptance sandbox profile for one workspace.
119
+
120
+ config keys: system_read_paths, runtime_read_paths, runtime_write_paths,
121
+ engine_source, workspace, spec, ports. secret_regexes appends kernel read
122
+ denies; the default (none) keeps the dashboard profile bytes unchanged.
123
+ """
124
+ quote = lambda value: json.dumps(str(value), ensure_ascii=True)
125
+ lines = [
126
+ "(version 1)",
127
+ "(deny default)",
128
+ "(allow process-fork process-exec*)",
129
+ "(allow signal (target same-sandbox))",
130
+ "(allow process-info* (target same-sandbox))",
131
+ "(allow sysctl-read)",
132
+ "(allow mach-lookup)",
133
+ "(allow ipc-posix-shm ipc-posix-sem)",
134
+ "(allow network-outbound (remote ip))",
135
+ "(allow network-bind (local ip))",
136
+ "(deny network-outbound (remote unix-socket))",
137
+ '(allow network-outbound (literal "/private/var/run/mDNSResponder"))',
138
+ ";; Preexisting cross-boundary hardlinks share one kernel inode.",
139
+ ";; This path policy cannot prove which directory originally created it.",
140
+ ]
141
+ read_paths = {
142
+ *config["system_read_paths"],
143
+ *config["runtime_read_paths"],
144
+ config["engine_source"],
145
+ config["workspace"],
146
+ config["spec"],
147
+ }
148
+ metadata_paths = {Path("/"), Path("/var"), Path("/tmp"), Path("/etc"), Path("/dev")}
149
+ for path in (*read_paths, *config["runtime_write_paths"]):
150
+ metadata_paths.update(path.parents)
151
+ lines.append('(allow file-read-metadata file-read-data (literal "/"))')
152
+ for path in sorted(metadata_paths - {Path("/")}, key=str):
153
+ lines.append(
154
+ f"(allow file-read-metadata (literal {quote(path)}))"
155
+ )
156
+ for path in sorted(read_paths, key=str):
157
+ lines.append(
158
+ f"(allow file-read* file-map-executable {seatbelt_path_filter(path)})"
159
+ )
160
+ write_paths = {*config["runtime_write_paths"], config["workspace"]}
161
+ for path in sorted(write_paths, key=str):
162
+ lines.append(
163
+ f"(allow file-read* file-write* file-map-executable "
164
+ f"{seatbelt_path_filter(path)})"
165
+ )
166
+ for device in ("/dev/null", "/dev/random", "/dev/urandom", "/dev/zero"):
167
+ if Path(device).exists():
168
+ lines.append(
169
+ f"(allow file-read* file-write* (literal {quote(device)}))"
170
+ )
171
+ if Path("/dev/fd").exists():
172
+ lines.append('(allow file-read* file-write* (subpath "/dev/fd"))')
173
+ for port in config["ports"]:
174
+ lines.append(
175
+ f'(deny network-outbound (remote tcp "*:{port}"))'
176
+ )
177
+ # network-bind uses "tcp4", not "tcp": confirmed on macOS 27.0 (26A428)
178
+ # that sandbox-exec's (local tcp "*:PORT") filter on network-bind never
179
+ # matches an actual bind (a bind to 127.0.0.1 or 0.0.0.0 on the denied
180
+ # port succeeds), while (local tcp4 "*:PORT") correctly denies it with
181
+ # a clean PermissionError. network-outbound's "tcp" filter above is
182
+ # unaffected (verified separately: an outbound connect to a denied
183
+ # port is still refused), so only this line changes.
184
+ #
185
+ # Known residual gap, same platform bug family, found while fixing
186
+ # the above (not introduced by it -- reproduced identically against
187
+ # the un-patched "tcp" rule too): on this same macOS 27.0 build, EVERY
188
+ # port-qualified network-bind filter for IPv6 ("tcp6 \"*:PORT\"" and
189
+ # "ip6 \"*:PORT\"") also fails to match, so an IPv6 bind to the
190
+ # reserved port (e.g. "::1"/"::") still succeeds even with a "tcp6"
191
+ # deny added. Only a port-unqualified "(deny network-bind (local
192
+ # ip6))" was confirmed to actually block IPv6 binds, which would deny
193
+ # ALL IPv6 binding (not just the reserved ports) -- a materially wider
194
+ # behavior change than this port-scoping fix, and nothing in this
195
+ # codebase binds IPv6 today (grepped: no AF_INET6/IPv6 usage). Left
196
+ # unfixed here to keep this change scoped to the failing test; the
197
+ # reserved ports remain enforced for IPv4 (the only family in active
198
+ # use), and a confined build could still self-expose a reserved port
199
+ # over IPv6 loopback/wildcard. Tracked as a follow-up, not silently
200
+ # closed.
201
+ lines.append(f'(deny network-bind (local tcp4 "*:{port}"))')
202
+ for regex in secret_regexes:
203
+ # Deny write as well: a rename or hardlink of a secret would otherwise
204
+ # move it to a name the read-deny regex does not match.
205
+ lines.append(f"(deny file-read-data {_seatbelt_regex(regex)})")
206
+ lines.append(f"(deny file-write* {_seatbelt_regex(regex)})")
207
+ return ("\n".join(lines) + "\n").encode("utf-8")
208
+
209
+
210
+ def provider_write_paths(provider: str, home: Path | None = None) -> list[Path]:
211
+ """Existing provider config and credential paths the CLI must write."""
212
+ home = home or Path(os.path.expanduser("~"))
213
+ found: list[Path] = []
214
+ for entry in PROVIDER_WRITE_PATHS.get(provider, ()):
215
+ rel = entry[2:] if entry.startswith("~/") else entry
216
+ if rel.endswith("*"):
217
+ found.extend(sorted(home.glob(rel)))
218
+ elif (home / rel).exists():
219
+ found.append(home / rel)
220
+ return [p.resolve() for p in found]
221
+
222
+
223
+ def seatbelt_provider_profile(
224
+ workspace: Path,
225
+ run_dir: Path,
226
+ provider: str,
227
+ secret_regexes: Iterable[str],
228
+ home: Path | None = None,
229
+ ) -> bytes:
230
+ """Provider-CLI profile: reads open, writes confined, secrets denied."""
231
+ quote = lambda value: json.dumps(str(value), ensure_ascii=True)
232
+ writable = [workspace, run_dir, *provider_write_paths(provider, home)]
233
+ lines = [
234
+ "(version 1)",
235
+ "(deny default)",
236
+ "(allow process-fork process-exec*)",
237
+ "(allow signal (target same-sandbox))",
238
+ "(allow process-info* (target same-sandbox))",
239
+ "(allow sysctl-read sysctl-write)",
240
+ "(allow mach-lookup)",
241
+ "(allow ipc-posix-shm ipc-posix-sem)",
242
+ "(allow network*)",
243
+ "(allow user-preference-read)",
244
+ "(allow file-read*)",
245
+ "(allow file-map-executable)",
246
+ ]
247
+ for path in sorted({*writable}, key=str):
248
+ lines.append(f"(allow file-write* {seatbelt_path_filter(path)})")
249
+ for tmp in TMP_ROOTS["darwin"]:
250
+ if tmp.exists():
251
+ lines.append(f"(allow file-write* (subpath {quote(tmp)}))")
252
+ lines.append(
253
+ '(allow file-write* (literal "/dev/null") (literal "/dev/tty") '
254
+ '(regex #"^/dev/ttys[0-9]+$"))'
255
+ )
256
+ for regex in secret_regexes:
257
+ # Deny write as well: a rename or hardlink of a secret would otherwise
258
+ # move it to a name the read-deny regex does not match.
259
+ lines.append(f"(deny file-read-data {_seatbelt_regex(regex)})")
260
+ lines.append(f"(deny file-write* {_seatbelt_regex(regex)})")
261
+ return ("\n".join(lines) + "\n").encode("utf-8")
262
+
263
+
264
+ def seatbelt_smoke_probe(
265
+ workspace_root: Path,
266
+ profile_for: Any,
267
+ ports: Iterable[int],
268
+ child_path: str,
269
+ ) -> str:
270
+ """Prove the kernel profile before advertising confinement.
271
+
272
+ profile_for(workspace, spec) returns the profile bytes for a scratch
273
+ workspace created under workspace_root. Returns the profile sha256.
274
+ """
275
+ if sys.platform != "darwin":
276
+ raise RuntimeError("LOKI host Seatbelt is available only on macOS")
277
+ if not SANDBOX_EXEC.is_file() or not os.access(SANDBOX_EXEC, os.X_OK):
278
+ raise RuntimeError("LOKI host Seatbelt requires /usr/bin/sandbox-exec")
279
+
280
+ with tempfile.TemporaryDirectory(
281
+ prefix=".seatbelt-probe-workspace-", dir=workspace_root
282
+ ) as workspace_raw, tempfile.TemporaryDirectory(
283
+ prefix=".seatbelt-probe-sibling-", dir=workspace_root
284
+ ) as sibling_raw:
285
+ workspace = Path(workspace_raw).resolve()
286
+ sibling = Path(sibling_raw).resolve()
287
+ spec = workspace / "spec.md"
288
+ spec.write_text("seatbelt probe", encoding="utf-8")
289
+ secret = sibling / "secret.txt"
290
+ secret.write_text("must remain unreadable", encoding="utf-8")
291
+ unix_socket_path = workspace / "probe.sock"
292
+
293
+ probe_port = 0
294
+ for port in ports:
295
+ with socket.socket() as candidate:
296
+ try:
297
+ candidate.bind(("127.0.0.1", port))
298
+ except OSError:
299
+ continue
300
+ probe_port = port
301
+ break
302
+ if not probe_port:
303
+ raise RuntimeError("no reserved port was free for the Seatbelt probe")
304
+
305
+ profile = profile_for(workspace, spec)
306
+ profile_path = workspace / "probe.sb"
307
+ profile_path.write_bytes(profile)
308
+ output_path = workspace / "probe.txt"
309
+ probe_code = """
310
+ import errno
311
+ import os
312
+ import signal
313
+ import socket
314
+ import subprocess
315
+ import sys
316
+
317
+ output, sibling, unix_path, port = sys.argv[1:]
318
+ open(output, "w", encoding="utf-8").write("confined")
319
+ open("/etc/hosts", encoding="utf-8").read(1)
320
+ child = subprocess.Popen(
321
+ ["/bin/sh", "-c", "/bin/sleep 5 & wait"],
322
+ stdin=subprocess.DEVNULL,
323
+ stdout=subprocess.DEVNULL,
324
+ stderr=subprocess.DEVNULL,
325
+ preexec_fn=os.setpgrp,
326
+ )
327
+ os.killpg(child.pid, signal.SIGKILL)
328
+ child.wait(timeout=2)
329
+ try:
330
+ open(sibling, encoding="utf-8").read(1)
331
+ except OSError:
332
+ pass
333
+ else:
334
+ raise SystemExit(41)
335
+ with socket.socket(socket.AF_UNIX) as client:
336
+ try:
337
+ client.connect(unix_path)
338
+ except OSError:
339
+ pass
340
+ else:
341
+ raise SystemExit(42)
342
+ with socket.socket() as listener:
343
+ try:
344
+ listener.bind(("127.0.0.1", int(port)))
345
+ except OSError as exc:
346
+ if exc.errno not in (errno.EACCES, errno.EPERM):
347
+ raise
348
+ else:
349
+ raise SystemExit(43)
350
+ """
351
+ with socket.socket(socket.AF_UNIX) as unix_server:
352
+ unix_server.bind(str(unix_socket_path))
353
+ unix_server.listen(1)
354
+ try:
355
+ result = subprocess.run(
356
+ [
357
+ str(SANDBOX_EXEC),
358
+ "-f",
359
+ str(profile_path),
360
+ "/usr/bin/python3",
361
+ "-c",
362
+ probe_code,
363
+ str(output_path),
364
+ str(secret),
365
+ str(unix_socket_path),
366
+ str(probe_port),
367
+ ],
368
+ cwd=str(workspace),
369
+ env={
370
+ "HOME": os.environ.get("HOME", "/var/empty"),
371
+ "LANG": os.environ.get("LANG", "C"),
372
+ "PATH": child_path,
373
+ "TMPDIR": str(workspace),
374
+ },
375
+ capture_output=True,
376
+ text=True,
377
+ timeout=PROBE_TIMEOUT_SECONDS,
378
+ )
379
+ except subprocess.SubprocessError as exc:
380
+ raise RuntimeError("Seatbelt kernel smoke probe did not finish") from exc
381
+ if result.returncode != 0 or output_path.read_text(encoding="utf-8") != "confined":
382
+ raise RuntimeError(
383
+ f"Seatbelt kernel smoke probe failed with exit {result.returncode}"
384
+ )
385
+ return sha256_bytes(profile)
386
+
387
+
388
+ # --------------------------------------------------------------------- bwrap
389
+
390
+ def _find_secret_files(roots: Iterable[Path], rules: list[dict[str, str]]) -> list[Path]:
391
+ """Existing secret files and dirs under roots, bounded, skipping vendored trees."""
392
+ regexes = [re.compile(r) for r in secret_path_regexes(rules)]
393
+ hits: list[Path] = []
394
+ seen = 0
395
+ for root in roots:
396
+ for dirpath, dirnames, filenames in os.walk(root):
397
+ dirnames[:] = [d for d in dirnames if d not in WALK_SKIP_DIRS]
398
+ for name in [*dirnames, *filenames]:
399
+ seen += 1
400
+ full = os.path.join(dirpath, name)
401
+ if any(r.search(full) for r in regexes):
402
+ hits.append(Path(full))
403
+ if name in dirnames:
404
+ dirnames.remove(name)
405
+ if seen > WALK_MAX_ENTRIES:
406
+ break
407
+ return hits
408
+
409
+
410
+ def bwrap_argv_prefix(
411
+ workspace: Path,
412
+ run_dir: Path,
413
+ provider: str,
414
+ rules: list[dict[str, str]],
415
+ home: Path | None = None,
416
+ bwrap: str = "bwrap",
417
+ ) -> list[str]:
418
+ home = home or Path(os.path.expanduser("~"))
419
+ argv = [bwrap, "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc",
420
+ "--tmpfs", "/tmp"]
421
+ writable = [workspace, run_dir, *provider_write_paths(provider, home)]
422
+ seen: set[str] = set()
423
+ for path in writable:
424
+ if str(path) not in seen:
425
+ seen.add(str(path))
426
+ argv += ["--bind", str(path), str(path)]
427
+ masked: set[str] = set()
428
+ candidates = [
429
+ *(home / r["name"] for r in rules if r["kind"] == "dir"),
430
+ *_find_secret_files([workspace, run_dir], rules),
431
+ ]
432
+ for path in candidates:
433
+ if str(path) in masked or not path.exists():
434
+ continue
435
+ masked.add(str(path))
436
+ if path.is_dir():
437
+ argv += ["--tmpfs", str(path)]
438
+ else:
439
+ argv += ["--ro-bind", "/dev/null", str(path)]
440
+ argv += ["--die-with-parent", "--"]
441
+ return argv
442
+
443
+
444
+ # ------------------------------------------------------------- kernel probes
445
+
446
+ _PROBE_SCRIPT = (
447
+ 'cat "$1/.env" >/dev/null 2>&1 && exit 41; '
448
+ 'cat "$1/plain.txt" >/dev/null 2>&1 || exit 42; '
449
+ ': > "$1/w.txt" 2>/dev/null || exit 43; '
450
+ '[ -z "$2" ] || { : > "$2" 2>/dev/null && exit 44; }; '
451
+ 'exit 0'
452
+ )
453
+
454
+
455
+ def _outside_probe_dir(workspace: Path, platform: str) -> Path | None:
456
+ """A writable-by-user dir outside the workspace that the sandbox must refuse."""
457
+ roots = TMP_ROOTS.get(platform, ())
458
+ for candidate in (workspace.parent, Path(os.path.expanduser("~"))):
459
+ if candidate.is_dir() and os.access(candidate, os.W_OK) and not any(
460
+ candidate == r or r in candidate.parents for r in roots
461
+ ):
462
+ return candidate
463
+ return None
464
+
465
+
466
+ def kernel_probe(argv_builder: Any, workspace: Path, run_dir: Path,
467
+ platform: str | None = None) -> tuple[bool, str]:
468
+ """Run the four probes (secret read denied, normal read, write in, write out)."""
469
+ probe_dir = Path(tempfile.mkdtemp(prefix=".os-sandbox-probe-", dir=run_dir))
470
+ outside_parent = _outside_probe_dir(workspace, platform or sys.platform)
471
+ outside = (
472
+ Path(tempfile.mkdtemp(prefix=".os-sandbox-probe-out-", dir=outside_parent)) / "escape.txt"
473
+ if outside_parent else None
474
+ )
475
+ try:
476
+ (probe_dir / ".env").write_text("SECRET=1\n", encoding="utf-8")
477
+ (probe_dir / "plain.txt").write_text("ok\n", encoding="utf-8")
478
+ prefix = argv_builder()
479
+ result = subprocess.run(
480
+ [*prefix, "/bin/sh", "-c", _PROBE_SCRIPT, "probe", str(probe_dir), str(outside or "")],
481
+ capture_output=True, text=True, timeout=PROBE_TIMEOUT_SECONDS,
482
+ )
483
+ detail = {
484
+ 0: "ok",
485
+ 41: "secret .env was readable",
486
+ 42: "a normal file was not readable",
487
+ 43: "a write inside the run dir failed",
488
+ 44: "a write outside the workspace succeeded",
489
+ }.get(result.returncode, f"probe exited {result.returncode}: {result.stderr.strip()[:200]}")
490
+ return result.returncode == 0, detail
491
+ except (OSError, subprocess.SubprocessError) as exc:
492
+ return False, f"probe did not run: {exc}"
493
+ finally:
494
+ shutil.rmtree(probe_dir, ignore_errors=True)
495
+ if outside:
496
+ shutil.rmtree(outside.parent, ignore_errors=True)
497
+
498
+
499
+ # ----------------------------------------------------------------- front door
500
+
501
+ def resolve_mode(raw: str | None = None) -> str:
502
+ mode = (raw if raw is not None else os.environ.get("LOKI_OS_SANDBOX", "off")).strip().lower()
503
+ if mode not in {"off", "auto", "on"}:
504
+ raise ValueError(f"LOKI_OS_SANDBOX must be off, auto or on (got {mode!r})")
505
+ return mode
506
+
507
+
508
+ def build_confinement(
509
+ workspace: Path,
510
+ run_dir: Path,
511
+ provider: str,
512
+ mode: str | None = None,
513
+ home: Path | None = None,
514
+ platform: str | None = None,
515
+ ) -> dict[str, Any]:
516
+ """Return the typed confinement status for wrapping a provider CLI argv.
517
+
518
+ Raises SandboxUnavailable only under mode on (fail closed).
519
+ """
520
+ mode = resolve_mode(mode)
521
+ platform = platform or sys.platform
522
+ none = lambda reason: {
523
+ "kind": "none", "reason": reason, "argv_prefix": [],
524
+ "profile_sha256": None, "kernel_probe_sha256": None,
525
+ "known_limitations": ["unconfined"],
526
+ }
527
+ if mode == "off":
528
+ return none("LOKI_OS_SANDBOX=off")
529
+ workspace = workspace.resolve(strict=True)
530
+ run_dir = run_dir.resolve(strict=True)
531
+ rules = load_secret_rules()
532
+ regexes = secret_path_regexes(rules)
533
+ limitations = ["unrestricted-outbound-network",
534
+ "reads-outside-workspace-allowed-except-secret-paths"]
535
+
536
+ def unavailable(reason: str) -> dict[str, Any]:
537
+ if mode == "on":
538
+ raise SandboxUnavailable(reason)
539
+ return none(reason)
540
+
541
+ if platform == "darwin":
542
+ if not SANDBOX_EXEC.is_file() or not os.access(SANDBOX_EXEC, os.X_OK):
543
+ return unavailable(f"/usr/bin/sandbox-exec missing; {INSTALL_HINT['darwin']}")
544
+ profile = seatbelt_provider_profile(workspace, run_dir, provider, regexes, home)
545
+ profile_path = run_dir / "os-sandbox.sb"
546
+ profile_path.write_bytes(profile)
547
+ prefix = [str(SANDBOX_EXEC), "-f", str(profile_path)]
548
+ kind = "macos-seatbelt"
549
+ limitations.append("deprecated-macos-sandbox-exec")
550
+ builder = lambda: prefix
551
+ elif platform.startswith("linux"):
552
+ bwrap = shutil.which("bwrap")
553
+ if not bwrap:
554
+ return unavailable(f"bwrap not found; {INSTALL_HINT['linux']}")
555
+ kind = "linux-bwrap"
556
+ limitations.append("secret-files-created-after-launch-not-masked")
557
+ builder = lambda: bwrap_argv_prefix(workspace, run_dir, provider, rules, home, bwrap)
558
+ profile = " ".join(builder()).encode("utf-8")
559
+ prefix = None
560
+ else:
561
+ return unavailable(f"no OS sandbox for platform {platform}")
562
+
563
+ ok, detail = kernel_probe(builder, workspace, run_dir)
564
+ if not ok:
565
+ return unavailable(f"kernel probe failed: {detail}")
566
+ if prefix is None:
567
+ prefix = builder()
568
+ profile = " ".join(prefix).encode("utf-8")
569
+ sha = sha256_bytes(profile)
570
+ return {
571
+ "kind": kind, "argv_prefix": prefix, "profile_sha256": sha,
572
+ "kernel_probe_sha256": sha, "known_limitations": limitations,
573
+ }
574
+
575
+
576
+ def main(argv: list[str] | None = None) -> int:
577
+ parser = argparse.ArgumentParser(prog="python3 -m autonomy.lib.os_sandbox")
578
+ sub = parser.add_subparsers(dest="command", required=True)
579
+ p = sub.add_parser("argv")
580
+ p.add_argument("--workspace", required=True)
581
+ p.add_argument("--run-dir", required=True)
582
+ p.add_argument("--provider", required=True)
583
+ args = parser.parse_args(argv)
584
+ try:
585
+ status = build_confinement(Path(args.workspace), Path(args.run_dir), args.provider)
586
+ except SandboxUnavailable as exc:
587
+ print(f"os_sandbox: LOKI_OS_SANDBOX=on but confinement is unavailable: {exc}",
588
+ file=sys.stderr)
589
+ return 2
590
+ except (ValueError, OSError) as exc:
591
+ print(f"os_sandbox: {exc}", file=sys.stderr)
592
+ return 2
593
+ if status["kind"] == "none" and status.get("reason") != "LOKI_OS_SANDBOX=off":
594
+ print(f"os_sandbox: running unconfined: {status['reason']}", file=sys.stderr)
595
+ print(json.dumps(status, sort_keys=True))
596
+ return 0
597
+
598
+
599
+ if __name__ == "__main__":
600
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": 1,
3
+ "rules": [
4
+ {"kind": "file", "glob": ".env*"},
5
+ {"kind": "dir", "name": ".aws"},
6
+ {"kind": "dir", "name": ".ssh"},
7
+ {"kind": "file", "glob": "*.pem"}
8
+ ]
9
+ }
@@ -7,6 +7,6 @@ Modules:
7
7
  control: Session control helpers (status, atomic writes, origin check)
8
8
  """
9
9
 
10
- __version__ = "11.56.5"
10
+ __version__ = "11.57.0"
11
11
 
12
12
  __all__ = ["__version__"]