loki-mode 11.56.5 → 11.57.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/os_sandbox.py +600 -0
- package/autonomy/lib/secret-paths.json +9 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/build_supervisor.py +17 -204
- package/docs/CLI-REFERENCE.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/FAILURE-CLASSES.md +4 -1
- package/docs/v10/PROGRESS.md +9 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/src/policies/claude_permissions.js +3 -1
- package/web-app/dist/assets/{AdminPage-Dimx1yBh.js → AdminPage-BdXC7R8h.js} +1 -1
- package/web-app/dist/assets/{Avatar-Dc3bWhBo.js → Avatar-CODU48Je.js} +1 -1
- package/web-app/dist/assets/{Badge-b2X7zLeT.js → Badge-DZE8yEMw.js} +1 -1
- package/web-app/dist/assets/{Button-WmENJAsd.js → Button--S_2rEVE.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-8RQI3bm-.js → CockpitPage-CtB-8Nlu.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CifTx_ce.js → ComparePage-Dy3RDGLA.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BWf3qZVi.js → ErrorBoundary-S_BL6WG6.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-COEJUdCv.js → EvidenceReceiptPanel-BgMrHNQg.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-BuoEObIg.js → GitHubIssuesPanel-D6C_vIxF.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-k338Akq5.js → GitHubPRsPanel-BXZnJQVR.js} +1 -1
- package/web-app/dist/assets/{HomePage-Dw89TygE.js → HomePage-Dskf8roM.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Go7X1UUz.js → LoginPage-DX0Fyjt4.js} +1 -1
- package/web-app/dist/assets/{MagicPage-l9yI0QuR.js → MagicPage-jaBIYBr_.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-CqYbIdOq.js → MetricsPage-3oJ92FcY.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-DDY07nmT.js → NotFoundPage-n1YqSjoo.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BI3YXMvn.js → ProjectPage-Chwc-9aM.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-B-e-jZCX.js → ProjectsPage-CoOhk5aW.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-D5yeF0Ib.js → SettingsPage-BCU-XapP.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Cp4lhM2Q.js → ShowcasePage-fNLEQOhc.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CGgunfcK.js → SystemSettingsPage-Bt3PzbqL.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-CH4-Ec82.js → TeamsPage-Ds26rBSS.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-DphuBvfB.js → TemplatesPage-DZo92LGJ.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BppH3g5s.js → TerminalOutput-1wnKnqaX.js} +1 -1
- package/web-app/dist/assets/{activity-BWs18kHl.js → activity-DqpbPJ8F.js} +1 -1
- package/web-app/dist/assets/{bell-CJcKaBBF.js → bell-9AK7Jw2Y.js} +1 -1
- package/web-app/dist/assets/{bot-DzNrwUlE.js → bot-Cr73CI7v.js} +1 -1
- package/web-app/dist/assets/{check-BwXjvRFD.js → check-DWWc7ugy.js} +1 -1
- package/web-app/dist/assets/{chevron-left-CtR6I6_X.js → chevron-left-D1v_bvlW.js} +1 -1
- package/web-app/dist/assets/{circle-alert-Buh7j2ty.js → circle-alert-CIp3gtHi.js} +1 -1
- package/web-app/dist/assets/{clock-s2f9N3Vx.js → clock-DEx7rYki.js} +1 -1
- package/web-app/dist/assets/{cloud-DEOd_pzL.js → cloud-Bycn90xL.js} +1 -1
- package/web-app/dist/assets/{code-xml-C4xFPVwf.js → code-xml-DOcXrNJU.js} +1 -1
- package/web-app/dist/assets/{database-BnoHUFDF.js → database-Bot4zvdk.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-DtvGSyex.js → dollar-sign-CVKwI0OJ.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-DzzG_aWp.js → file-code-corner-BKPI_yF0.js} +1 -1
- package/web-app/dist/assets/{file-plus-D44fKKeH.js → file-plus-_SUeeSwB.js} +1 -1
- package/web-app/dist/assets/{globe-BmRQoo61.js → globe-C4SNVyp2.js} +1 -1
- package/web-app/dist/assets/{hammer-DlnGJRRm.js → hammer-D0KhRJjv.js} +1 -1
- package/web-app/dist/assets/{index-yNuJC1nz.js → index-HJdspB0z.js} +3 -3
- package/web-app/dist/assets/{layers-Mp7J961W.js → layers-th1FZl4L.js} +1 -1
- package/web-app/dist/assets/{loader-circle-BXbrKzAM.js → loader-circle-DGNwlM9d.js} +1 -1
- package/web-app/dist/assets/{lock--G0xB_sg.js → lock-CmxRlOjc.js} +1 -1
- package/web-app/dist/assets/{package-ffXQaNr5.js → package-CCFqDhcx.js} +1 -1
- package/web-app/dist/assets/{plus-D-SjJwMh.js → plus-DMvUUGHR.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-YZez-e-J.js → refresh-cw-jk9yDNHG.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DJeBzW7L.js → rotate-ccw-B4qtnPb5.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BczZXyxh.js → scroll-text-Cc6YtNmC.js} +1 -1
- package/web-app/dist/assets/{server-Cgf89eCI.js → server-BYpNb-G1.js} +1 -1
- package/web-app/dist/assets/{shield-alert-CfnIh_vZ.js → shield-alert-DzVX8vEK.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-DVWVUs2p.js → thumbs-up-nzGDRPjQ.js} +1 -1
- package/web-app/dist/assets/{trash-2-C7y11wmM.js → trash-2-B7g04h8R.js} +1 -1
- package/web-app/dist/assets/{trending-down-bTE7raGc.js → trending-down-ayXC-U0B.js} +1 -1
- package/web-app/dist/assets/{trending-up-CWUE8let.js → trending-up-DWZsi8PX.js} +1 -1
- package/web-app/dist/assets/{upload-DV61LMnd.js → upload-BPhRYbnh.js} +1 -1
- package/web-app/dist/assets/{usePolling-CsmEvZkA.js → usePolling-B6qLkExQ.js} +1 -1
- package/web-app/dist/assets/{user-JuKWf6JG.js → user-MAp5FNR4.js} +1 -1
- 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.
|
|
6
|
+
# Loki Mode v11.57.1
|
|
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.
|
|
479
|
+
**v11.57.1 | [Autonomi](https://www.autonomi.dev/) flagship product | core skill**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
11.
|
|
1
|
+
11.57.1
|
|
@@ -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())
|