raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/runners/cli.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from ..core.identity import short_runtime
|
|
5
|
+
|
|
6
|
+
RUNTIMES = ("codex", "claude", "gemini", "shell")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _setup(p):
|
|
10
|
+
p.add_argument("card", help="card id")
|
|
11
|
+
p.add_argument(
|
|
12
|
+
"--to",
|
|
13
|
+
required=True,
|
|
14
|
+
type=short_runtime,
|
|
15
|
+
choices=RUNTIMES,
|
|
16
|
+
help="which agent CLI runs the card: codex, claude, gemini (experimental) or shell; codex-cli, claude-code "
|
|
17
|
+
"and gemini-cli also work",
|
|
18
|
+
)
|
|
19
|
+
p.add_argument(
|
|
20
|
+
"--as",
|
|
21
|
+
dest="role",
|
|
22
|
+
default="worker",
|
|
23
|
+
choices=("worker", "reviewer"),
|
|
24
|
+
help="worker changes files and hands back; reviewer reads and records a verdict",
|
|
25
|
+
)
|
|
26
|
+
p.add_argument(
|
|
27
|
+
"--sandbox",
|
|
28
|
+
default=None,
|
|
29
|
+
choices=("read-only", "workspace-write"),
|
|
30
|
+
help="default: workspace-write for workers, read-only for reviewers",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument("--ref", "--base", default=None, help="branch or commit to start the clone from")
|
|
33
|
+
p.add_argument("--model", default=None, help="model id passed to the CLI")
|
|
34
|
+
p.add_argument("--cmd", default=None, help="command to run for --to shell (prompt on stdin, cwd is the clone)")
|
|
35
|
+
p.add_argument(
|
|
36
|
+
"--tools", default=None, help='claude only: comma-separated allowed tools, e.g. "Read,Grep,Edit,Bash(pytest *)"'
|
|
37
|
+
)
|
|
38
|
+
p.add_argument(
|
|
39
|
+
"--unsafe-verify",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="run the card's verify command even when no sandbox is available (scrubbed env only)",
|
|
42
|
+
)
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--raw-capture",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="keep raw logs (0600) and secret-bearing rejected artifacts in a private quarantine (0700)",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--timeout", type=float, default=3600.0, help="stop a hung worker after this many seconds (default: 3600)"
|
|
50
|
+
)
|
|
51
|
+
p.add_argument("--heartbeat", type=float, default=60.0, help=argparse.SUPPRESS)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _short(path):
|
|
55
|
+
home = os.path.expanduser("~")
|
|
56
|
+
return "~" + path[len(home) :] if path and path.startswith(home + os.sep) else path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _text(r):
|
|
60
|
+
cid = r["card"]
|
|
61
|
+
if r.get("role") == "reviewer":
|
|
62
|
+
if r.get("status") == "failed":
|
|
63
|
+
return f"{r['agent']} could not review {cid}: {r['reason']}\nNext: less {_short(r['run_dir'])}/stderr.log"
|
|
64
|
+
nxt = f"raidlock done {cid}" if r["verdict"] == "approve" else f"raidlock show {cid}"
|
|
65
|
+
return f"{r['agent']} reviewed {cid}: {r['verdict']}. {r['summary'][:200]}\nNext: {nxt}"
|
|
66
|
+
if r.get("status") == "lost":
|
|
67
|
+
return f"{r['agent']} stopped on {cid}: {r['reason']}\nNext: raidlock show {cid}"
|
|
68
|
+
if r["lane"] == "blocked":
|
|
69
|
+
return f"{r['agent']} blocked {cid}: {r['reason']}\nNext: raidlock show {cid}"
|
|
70
|
+
files = len(r["paths_changed"])
|
|
71
|
+
verify = (
|
|
72
|
+
f", verify {'passed' if r['verify']['passed'] else 'failed'}"
|
|
73
|
+
if r.get("verify")
|
|
74
|
+
else (", verify not run: no sandbox" if r.get("verify_skipped") else "")
|
|
75
|
+
)
|
|
76
|
+
patch = f" Patch: {_short(r['patch'])}" if r.get("patch") else ""
|
|
77
|
+
return f"{r['agent']} handed {cid} to review ({files} file(s) changed{verify}).{patch}\nNext: raidlock show {cid}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _handler(ctx, args):
|
|
81
|
+
from .runner import delegate
|
|
82
|
+
|
|
83
|
+
board = ctx.board
|
|
84
|
+
result = delegate(
|
|
85
|
+
board,
|
|
86
|
+
ctx.caller(),
|
|
87
|
+
args.card,
|
|
88
|
+
args.to,
|
|
89
|
+
role=args.role,
|
|
90
|
+
sandbox=args.sandbox,
|
|
91
|
+
model=args.model,
|
|
92
|
+
cmd=args.cmd,
|
|
93
|
+
timeout=args.timeout,
|
|
94
|
+
heartbeat=args.heartbeat,
|
|
95
|
+
tools=args.tools,
|
|
96
|
+
unsafe_verify=args.unsafe_verify,
|
|
97
|
+
raw=args.raw_capture,
|
|
98
|
+
ref=args.ref,
|
|
99
|
+
)
|
|
100
|
+
return result, _text(result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def register_cli(add_command):
|
|
104
|
+
add_command(
|
|
105
|
+
"delegate",
|
|
106
|
+
"run a card with codex, claude, gemini or a shell command in its own clone",
|
|
107
|
+
_setup,
|
|
108
|
+
_handler,
|
|
109
|
+
mutating=True,
|
|
110
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from ..core import Usage
|
|
6
|
+
from .base import Adapter, short
|
|
7
|
+
|
|
8
|
+
CONFIG_KEYS = ("model", "model_reasoning_effort")
|
|
9
|
+
_LINE = re.compile(r'^\s*([A-Za-z_]+)\s*=\s*"([^"\n]*)"\s*$')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def user_home(env):
|
|
13
|
+
return env.get("CODEX_HOME") or os.path.join(env.get("HOME") or os.path.expanduser("~"), ".codex")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def top_level(config_text):
|
|
17
|
+
out = {}
|
|
18
|
+
for line in config_text.splitlines():
|
|
19
|
+
if line.strip().startswith("["):
|
|
20
|
+
break
|
|
21
|
+
m = _LINE.match(line)
|
|
22
|
+
if m and m.group(1) in CONFIG_KEYS:
|
|
23
|
+
out[m.group(1)] = m.group(2)
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Codex(Adapter):
|
|
28
|
+
name = "codex"
|
|
29
|
+
runtime = "codex-cli"
|
|
30
|
+
provider = "openai"
|
|
31
|
+
binary = "codex"
|
|
32
|
+
env_keys = ("CODEX_HOME",)
|
|
33
|
+
api_keys = ("OPENAI_API_KEY", "CODEX_API_KEY")
|
|
34
|
+
|
|
35
|
+
def prepare(self, run):
|
|
36
|
+
"""An ephemeral CODEX_HOME: a link to the user's auth.json and a config.toml with only model and effort,
|
|
37
|
+
so the user's skills, rules, MCP servers and other config never reach the run."""
|
|
38
|
+
source = user_home(run.parent_env)
|
|
39
|
+
home = os.path.join(run.run_dir, "codex-home")
|
|
40
|
+
os.makedirs(home, mode=0o700, exist_ok=True)
|
|
41
|
+
auth = os.path.join(source, "auth.json")
|
|
42
|
+
if os.path.exists(auth):
|
|
43
|
+
try:
|
|
44
|
+
os.symlink(os.path.realpath(auth), os.path.join(home, "auth.json"))
|
|
45
|
+
except OSError:
|
|
46
|
+
shutil.copy2(auth, os.path.join(home, "auth.json"))
|
|
47
|
+
try:
|
|
48
|
+
with open(os.path.join(source, "config.toml"), encoding="utf-8") as f:
|
|
49
|
+
settings = top_level(f.read())
|
|
50
|
+
except OSError:
|
|
51
|
+
settings = {}
|
|
52
|
+
if run.model:
|
|
53
|
+
settings["model"] = run.model
|
|
54
|
+
with open(os.path.join(home, "config.toml"), "w", encoding="utf-8") as f:
|
|
55
|
+
f.write("".join(f'{k} = "{v}"\n' for k, v in sorted(settings.items())))
|
|
56
|
+
run.codex_config = settings
|
|
57
|
+
return {"CODEX_HOME": home}
|
|
58
|
+
|
|
59
|
+
def command(self, run):
|
|
60
|
+
argv = self.executable(run.parent_env) + [
|
|
61
|
+
"exec",
|
|
62
|
+
"-C",
|
|
63
|
+
run.clone,
|
|
64
|
+
"-s",
|
|
65
|
+
run.sandbox,
|
|
66
|
+
"--skip-git-repo-check",
|
|
67
|
+
"--ephemeral",
|
|
68
|
+
"--ignore-rules",
|
|
69
|
+
"--json",
|
|
70
|
+
"--color",
|
|
71
|
+
"never",
|
|
72
|
+
"--output-schema",
|
|
73
|
+
run.schema_path,
|
|
74
|
+
"-o",
|
|
75
|
+
run.last_path,
|
|
76
|
+
]
|
|
77
|
+
if run.model:
|
|
78
|
+
argv += ["-m", run.model]
|
|
79
|
+
return argv + ["-"]
|
|
80
|
+
|
|
81
|
+
def parse(self, obj):
|
|
82
|
+
kind = obj.get("type", "")
|
|
83
|
+
item = obj.get("item") or {}
|
|
84
|
+
if kind == "item.completed":
|
|
85
|
+
itype = item.get("type")
|
|
86
|
+
if itype == "agent_message":
|
|
87
|
+
return [{"kind": "message", "text": item.get("text", "")}]
|
|
88
|
+
if itype == "command_execution":
|
|
89
|
+
return [
|
|
90
|
+
{"kind": "command", "command": short(item.get("command", ""), 300), "exit": item.get("exit_code")}
|
|
91
|
+
]
|
|
92
|
+
if itype == "file_change":
|
|
93
|
+
return [{"kind": "files", "paths": [c.get("path", "") for c in item.get("changes", [])]}]
|
|
94
|
+
if itype == "error":
|
|
95
|
+
return [{"kind": "error", "text": item.get("message", "")}]
|
|
96
|
+
return []
|
|
97
|
+
if kind == "turn.completed":
|
|
98
|
+
u = obj.get("usage") or {}
|
|
99
|
+
usage = Usage.from_codex(u)
|
|
100
|
+
usage.cache_write = int(u.get("cache_write_input_tokens", 0) or 0)
|
|
101
|
+
return [{"kind": "usage", "usage": usage}]
|
|
102
|
+
if kind == "turn.failed":
|
|
103
|
+
return [{"kind": "error", "text": (obj.get("error") or {}).get("message", "turn failed")}]
|
|
104
|
+
if kind == "error":
|
|
105
|
+
return [{"kind": "error", "text": obj.get("message", "error")}]
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
def final(self, run, collected):
|
|
109
|
+
try:
|
|
110
|
+
with open(run.last_path, encoding="utf-8") as f:
|
|
111
|
+
text = f.read()
|
|
112
|
+
except OSError:
|
|
113
|
+
text = ""
|
|
114
|
+
return text.strip() or collected.final
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from ..core import Usage
|
|
2
|
+
from .base import Adapter, short
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Gemini(Adapter):
|
|
6
|
+
"""Experimental: flags and stream-json event shapes are not verified against a real gemini binary."""
|
|
7
|
+
|
|
8
|
+
name = "gemini"
|
|
9
|
+
runtime = "gemini-cli"
|
|
10
|
+
provider = "google"
|
|
11
|
+
binary = "gemini"
|
|
12
|
+
env_keys = ("GEMINI_CONFIG_DIR", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_GENAI_USE_VERTEXAI")
|
|
13
|
+
api_keys = ("GEMINI_API_KEY", "GOOGLE_API_KEY")
|
|
14
|
+
|
|
15
|
+
def command(self, run):
|
|
16
|
+
mode = "auto_edit" if run.sandbox == "workspace-write" else "default"
|
|
17
|
+
argv = self.executable(run.parent_env) + [
|
|
18
|
+
"--output-format",
|
|
19
|
+
"stream-json",
|
|
20
|
+
"--approval-mode",
|
|
21
|
+
mode,
|
|
22
|
+
"-p",
|
|
23
|
+
"Follow the instructions given on stdin.",
|
|
24
|
+
]
|
|
25
|
+
if run.model:
|
|
26
|
+
argv += ["-m", run.model]
|
|
27
|
+
return argv
|
|
28
|
+
|
|
29
|
+
def parse(self, obj):
|
|
30
|
+
kind = obj.get("type", "")
|
|
31
|
+
if kind == "init":
|
|
32
|
+
return [{"kind": "model", "model": obj.get("model", "")}]
|
|
33
|
+
if kind == "message" and obj.get("role") == "assistant" and obj.get("content"):
|
|
34
|
+
return [{"kind": "message", "text": obj["content"], "delta": bool(obj.get("delta"))}]
|
|
35
|
+
if kind == "tool_use":
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
"kind": "command",
|
|
39
|
+
"command": short(f"{obj.get('tool_name', 'tool')} {obj.get('parameters', '')}", 300),
|
|
40
|
+
"exit": None,
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
if kind == "error":
|
|
44
|
+
return [{"kind": "error", "text": obj.get("message", "error")}]
|
|
45
|
+
if kind == "result":
|
|
46
|
+
stats = obj.get("stats") or {}
|
|
47
|
+
cached = int(stats.get("cached", 0) or 0)
|
|
48
|
+
out = [
|
|
49
|
+
{
|
|
50
|
+
"kind": "usage",
|
|
51
|
+
"usage": Usage(
|
|
52
|
+
input=max(int(stats.get("input_tokens", 0) or 0) - cached, 0),
|
|
53
|
+
cached_input=cached,
|
|
54
|
+
output=int(stats.get("output_tokens", 0) or 0),
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
if obj.get("status") not in (None, "success"):
|
|
59
|
+
out.append({"kind": "result", "error": short(obj.get("error") or obj.get("status"), 300)})
|
|
60
|
+
return out
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
def final(self, run, collected):
|
|
64
|
+
return "".join(collected.messages) if collected.messages else collected.final
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from ..core import lead
|
|
10
|
+
from ..core.portable import remove_tree
|
|
11
|
+
|
|
12
|
+
ENV_KEEP = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "SYSTEMROOT")
|
|
13
|
+
SANDBOX_EXEC = "/usr/bin/sandbox-exec"
|
|
14
|
+
TAIL_LINES = 40
|
|
15
|
+
TAIL_BYTES = 4000
|
|
16
|
+
HOME_SECRETS = (
|
|
17
|
+
".ssh",
|
|
18
|
+
".aws",
|
|
19
|
+
".azure",
|
|
20
|
+
".kube",
|
|
21
|
+
".docker",
|
|
22
|
+
".gnupg",
|
|
23
|
+
".netrc",
|
|
24
|
+
".git-credentials",
|
|
25
|
+
".npmrc",
|
|
26
|
+
".pypirc",
|
|
27
|
+
".codex",
|
|
28
|
+
".claude",
|
|
29
|
+
".claude.json",
|
|
30
|
+
".gemini",
|
|
31
|
+
".raidlock",
|
|
32
|
+
".config/gcloud",
|
|
33
|
+
".config/gh",
|
|
34
|
+
".config/raidlock",
|
|
35
|
+
".config/git",
|
|
36
|
+
".gitconfig",
|
|
37
|
+
"Library/Keychains",
|
|
38
|
+
)
|
|
39
|
+
ENV_DIRS = (
|
|
40
|
+
"RAIDLOCK_HOME",
|
|
41
|
+
"TEAM_BOARD_HOME",
|
|
42
|
+
"CODEX_HOME",
|
|
43
|
+
"CLAUDE_CONFIG_DIR",
|
|
44
|
+
"GEMINI_CONFIG_DIR",
|
|
45
|
+
"AWS_CONFIG_FILE",
|
|
46
|
+
"AWS_SHARED_CREDENTIALS_FILE",
|
|
47
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
48
|
+
"KUBECONFIG",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
CONTAINER_MARKERS = ("/.dockerenv", "/run/.containerenv")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mode(platform=None, which=shutil.which, env=None, exists=os.path.exists):
|
|
56
|
+
"""The sandbox this host offers for verify: "bwrap", "bwrap-container", "sandbox-exec" or None."""
|
|
57
|
+
platform = platform or sys.platform
|
|
58
|
+
env = os.environ if env is None else env
|
|
59
|
+
sandbox = env.get("RAIDLOCK_SANDBOX", "")
|
|
60
|
+
if sandbox:
|
|
61
|
+
in_container = any(exists(m) for m in CONTAINER_MARKERS)
|
|
62
|
+
if sandbox == "container" and platform.startswith("linux") and which("bwrap") and in_container:
|
|
63
|
+
return "bwrap-container"
|
|
64
|
+
return None
|
|
65
|
+
if platform.startswith("linux"):
|
|
66
|
+
return "bwrap" if which("bwrap") else None
|
|
67
|
+
if platform == "darwin":
|
|
68
|
+
return "sandbox-exec" if os.path.exists(SANDBOX_EXEC) else None
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scrub_env(parent_env, home, agent=""):
|
|
73
|
+
env = {k: parent_env[k] for k in ENV_KEEP if parent_env.get(k)}
|
|
74
|
+
env.setdefault("PATH", os.defpath)
|
|
75
|
+
env.update(HOME=home, TMPDIR=home, TMP=home, TEMP=home, NO_COLOR="1", CI="1")
|
|
76
|
+
if agent:
|
|
77
|
+
env["RAIDLOCK_AGENT_ID"] = agent
|
|
78
|
+
return env
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def hidden(parent_env, extra=()):
|
|
82
|
+
"""Paths the verify command must not read: the board's state, agent CLI homes and credential stores."""
|
|
83
|
+
home = parent_env.get("HOME") or os.path.expanduser("~")
|
|
84
|
+
out = [home] + [os.path.join(home, rel) for rel in HOME_SECRETS]
|
|
85
|
+
out += [parent_env[k] for k in ENV_DIRS if parent_env.get(k)]
|
|
86
|
+
out += [p for p in extra if p]
|
|
87
|
+
seen, result = set(), []
|
|
88
|
+
for p in out:
|
|
89
|
+
real = os.path.realpath(os.path.abspath(os.path.expanduser(p)))
|
|
90
|
+
if real not in seen:
|
|
91
|
+
seen.add(real)
|
|
92
|
+
result.append(real)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sb(path):
|
|
97
|
+
return '"' + path.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ancestors(path):
|
|
101
|
+
out = []
|
|
102
|
+
cur = os.path.dirname(path)
|
|
103
|
+
while cur and cur != os.path.dirname(cur):
|
|
104
|
+
out.append(cur)
|
|
105
|
+
cur = os.path.dirname(cur)
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def profile(writable, deny_read, readable=()):
|
|
110
|
+
writable = [os.path.realpath(p) for p in writable]
|
|
111
|
+
readable = [os.path.realpath(p) for p in readable]
|
|
112
|
+
rules = [
|
|
113
|
+
"(version 1)",
|
|
114
|
+
"(allow default)",
|
|
115
|
+
"(deny network*)",
|
|
116
|
+
"(deny process-info*)",
|
|
117
|
+
"(allow process-info* (target same-sandbox))",
|
|
118
|
+
"(deny signal)",
|
|
119
|
+
"(allow signal (target same-sandbox))",
|
|
120
|
+
"(deny mach-lookup)",
|
|
121
|
+
"(deny file-write*)",
|
|
122
|
+
"(allow file-write* "
|
|
123
|
+
+ " ".join(f"(subpath {_sb(p)})" for p in writable)
|
|
124
|
+
+ ' (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper")'
|
|
125
|
+
+ ' (regex #"^/dev/tty") (regex #"^/dev/fd/"))',
|
|
126
|
+
]
|
|
127
|
+
if deny_read:
|
|
128
|
+
rules.append("(deny file-read* " + " ".join(f"(subpath {_sb(p)})" for p in deny_read) + ")")
|
|
129
|
+
rules.append("(allow file-read* " + " ".join(f"(subpath {_sb(p)})" for p in writable + readable) + ")")
|
|
130
|
+
parents = sorted({a for p in writable + readable for a in _ancestors(p)})
|
|
131
|
+
if parents:
|
|
132
|
+
rules.append("(allow file-read-metadata " + " ".join(f"(literal {_sb(a)})" for a in parents) + ")")
|
|
133
|
+
return "".join(rules)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def wrap(kind, argv, cwd, home, deny_read, readable=(), writable=()):
|
|
137
|
+
cwd, home = os.path.realpath(cwd), os.path.realpath(home)
|
|
138
|
+
readable = [os.path.realpath(p) for p in readable]
|
|
139
|
+
if kind == "sandbox-exec":
|
|
140
|
+
return [SANDBOX_EXEC, "-p", profile([cwd, home, *writable], deny_read, readable), *argv]
|
|
141
|
+
if kind in ("bwrap", "bwrap-container"):
|
|
142
|
+
proc = ["--unshare-user", "--tmpfs", "/proc"] if kind == "bwrap-container" else ["--proc", "/proc"]
|
|
143
|
+
out = [
|
|
144
|
+
"bwrap",
|
|
145
|
+
"--ro-bind",
|
|
146
|
+
"/",
|
|
147
|
+
"/",
|
|
148
|
+
"--dev",
|
|
149
|
+
"/dev",
|
|
150
|
+
*proc,
|
|
151
|
+
"--tmpfs",
|
|
152
|
+
os.path.join(os.sep, "tmp"),
|
|
153
|
+
"--tmpfs",
|
|
154
|
+
os.path.join(os.sep, "run"),
|
|
155
|
+
"--cap-drop",
|
|
156
|
+
"ALL",
|
|
157
|
+
]
|
|
158
|
+
for p in deny_read:
|
|
159
|
+
if os.path.isdir(p):
|
|
160
|
+
out += ["--tmpfs", p]
|
|
161
|
+
elif os.path.lexists(p):
|
|
162
|
+
out += ["--ro-bind", os.devnull, p]
|
|
163
|
+
for p in readable:
|
|
164
|
+
out += ["--ro-bind", p, p]
|
|
165
|
+
out += [
|
|
166
|
+
"--bind",
|
|
167
|
+
cwd,
|
|
168
|
+
cwd,
|
|
169
|
+
"--bind",
|
|
170
|
+
home,
|
|
171
|
+
home,
|
|
172
|
+
"--chdir",
|
|
173
|
+
cwd,
|
|
174
|
+
"--unshare-net",
|
|
175
|
+
"--unshare-pid",
|
|
176
|
+
"--unshare-ipc",
|
|
177
|
+
"--unshare-uts",
|
|
178
|
+
"--die-with-parent",
|
|
179
|
+
"--new-session",
|
|
180
|
+
"--",
|
|
181
|
+
*argv,
|
|
182
|
+
]
|
|
183
|
+
return out
|
|
184
|
+
return list(argv)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def uv_paths(parent_env):
|
|
188
|
+
home = parent_env.get("HOME") or os.path.expanduser("~")
|
|
189
|
+
cache_home = parent_env.get("XDG_CACHE_HOME") or os.path.join(home, ".cache")
|
|
190
|
+
data_home = parent_env.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share")
|
|
191
|
+
cache = parent_env.get("UV_CACHE_DIR") or os.path.join(cache_home, "uv")
|
|
192
|
+
python = parent_env.get("UV_PYTHON_INSTALL_DIR") or os.path.join(data_home, "uv", "python")
|
|
193
|
+
executable = shutil.which("uv", path=parent_env.get("PATH", os.defpath))
|
|
194
|
+
readable = [python, os.path.join(home, ".local", "bin")]
|
|
195
|
+
if executable:
|
|
196
|
+
readable.append(os.path.dirname(os.path.realpath(executable)))
|
|
197
|
+
return os.path.realpath(cache), [os.path.realpath(p) for p in readable]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def failure_reason(code, kind):
|
|
201
|
+
if not isinstance(code, int) or code == 0:
|
|
202
|
+
return ""
|
|
203
|
+
if code == 127:
|
|
204
|
+
return (
|
|
205
|
+
"verify command not found (exit 127): install it or give its full path with "
|
|
206
|
+
'raid edit CARD --verify-cmd "..."'
|
|
207
|
+
)
|
|
208
|
+
number = -code if code < 0 else code - 128
|
|
209
|
+
try:
|
|
210
|
+
name = signal.Signals(number).name
|
|
211
|
+
except ValueError:
|
|
212
|
+
return ""
|
|
213
|
+
if kind:
|
|
214
|
+
return (
|
|
215
|
+
f"verify terminated by {name} (exit {code}); likely sandbox denial or process crash; "
|
|
216
|
+
"for uv, allow UV_CACHE_DIR and Python install paths, or rerun with --unsafe-verify"
|
|
217
|
+
)
|
|
218
|
+
return f"verify terminated by {name} (exit {code}); inspect the command output and retry"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _kill(proc):
|
|
222
|
+
try:
|
|
223
|
+
if os.name == "posix":
|
|
224
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
225
|
+
else:
|
|
226
|
+
proc.kill()
|
|
227
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
CLEANUP_TIMEOUT = 1.0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _process_rows(home):
|
|
235
|
+
rows = {}
|
|
236
|
+
if sys.platform == "darwin":
|
|
237
|
+
import ctypes
|
|
238
|
+
|
|
239
|
+
lib = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
|
|
240
|
+
lib.proc_listpids.argtypes = (ctypes.c_uint, ctypes.c_uint, ctypes.c_void_p, ctypes.c_int)
|
|
241
|
+
lib.proc_listpids.restype = ctypes.c_int
|
|
242
|
+
pids = (ctypes.c_int * 65536)()
|
|
243
|
+
count = lib.proc_listpids(1, 0, pids, ctypes.sizeof(pids))
|
|
244
|
+
buf = ctypes.create_string_buffer(1024 * 1024)
|
|
245
|
+
marker = f"HOME={home}".encode()
|
|
246
|
+
for pid in pids[: max(0, count) // ctypes.sizeof(ctypes.c_int)]:
|
|
247
|
+
try:
|
|
248
|
+
row = lead._sysctl_row(pid)
|
|
249
|
+
if not row:
|
|
250
|
+
continue
|
|
251
|
+
group = os.getpgid(pid)
|
|
252
|
+
mib = (ctypes.c_int * 3)(lead.CTL_KERN, 49, pid)
|
|
253
|
+
size = ctypes.c_size_t(len(buf))
|
|
254
|
+
tagged = lead._libc().sysctl(mib, 3, buf, ctypes.byref(size), None, 0) == 0 and marker in buf.raw[
|
|
255
|
+
: size.value
|
|
256
|
+
].split(b"\0")
|
|
257
|
+
rows[pid] = (row[0], group, tagged)
|
|
258
|
+
except (OSError, ValueError):
|
|
259
|
+
continue
|
|
260
|
+
elif os.path.isdir("/proc/self"):
|
|
261
|
+
for name in os.listdir("/proc"):
|
|
262
|
+
if not name.isdigit():
|
|
263
|
+
continue
|
|
264
|
+
pid = int(name)
|
|
265
|
+
try:
|
|
266
|
+
row = lead._proc_row(pid)
|
|
267
|
+
if not row:
|
|
268
|
+
continue
|
|
269
|
+
with open(f"/proc/{pid}/environ", "rb") as stream:
|
|
270
|
+
tagged = f"HOME={home}".encode() in stream.read().split(b"\0")
|
|
271
|
+
rows[pid] = (row[0], os.getpgid(pid), tagged)
|
|
272
|
+
except (OSError, ValueError):
|
|
273
|
+
continue
|
|
274
|
+
return rows
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _descendants(proc, home, known):
|
|
278
|
+
if os.name != "posix":
|
|
279
|
+
return {}
|
|
280
|
+
rows = _process_rows(home)
|
|
281
|
+
found = {proc.pid}
|
|
282
|
+
while True:
|
|
283
|
+
more = {pid for pid, (parent, group, tagged) in rows.items() if parent in found or group == proc.pid or tagged}
|
|
284
|
+
if more <= found:
|
|
285
|
+
break
|
|
286
|
+
found.update(more)
|
|
287
|
+
found.update(known)
|
|
288
|
+
lookup = lead.row_lookup()
|
|
289
|
+
live = {}
|
|
290
|
+
for pid in found - {proc.pid, os.getpid()}:
|
|
291
|
+
try:
|
|
292
|
+
row = lookup(pid)
|
|
293
|
+
except (OSError, ValueError):
|
|
294
|
+
continue
|
|
295
|
+
if row and (pid not in known or row[1] == known[pid]):
|
|
296
|
+
live[pid] = row[1]
|
|
297
|
+
return live
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _cleanup(proc, home, known):
|
|
301
|
+
stray = _descendants(proc, home, known)
|
|
302
|
+
_kill(proc)
|
|
303
|
+
lookup = lead.row_lookup()
|
|
304
|
+
for pid, start in stray.items():
|
|
305
|
+
try:
|
|
306
|
+
row = lookup(pid)
|
|
307
|
+
if row and row[1] == start:
|
|
308
|
+
os.kill(pid, signal.SIGKILL)
|
|
309
|
+
except (OSError, ValueError):
|
|
310
|
+
pass
|
|
311
|
+
return bool(stray)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _tail(output):
|
|
315
|
+
return "\n".join(output.splitlines()[-TAIL_LINES:])[-TAIL_BYTES:]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def run(cmd, cwd, parent_env, timeout, kind=None, agent="", deny_read=(), redact=None, readable=()):
|
|
319
|
+
"""Run `cmd` with /bin/sh in `cwd` with a scrubbed env, a throwaway HOME and, when `kind` is set, a sandbox
|
|
320
|
+
that only writes to `cwd` and that HOME and cannot read `deny_read`, except the read-only `readable` paths.
|
|
321
|
+
Returns (exit or None, tail, ms)."""
|
|
322
|
+
home = tempfile.mkdtemp(prefix="raidlock-verify-")
|
|
323
|
+
start = time.monotonic()
|
|
324
|
+
try:
|
|
325
|
+
env = scrub_env(parent_env, home, agent)
|
|
326
|
+
writable = []
|
|
327
|
+
if kind == "sandbox-exec":
|
|
328
|
+
cache, uv_readable = uv_paths(parent_env)
|
|
329
|
+
env.update(UV_CACHE_DIR=cache, UV_PYTHON_INSTALL_DIR=uv_readable[0], UV_OFFLINE="1")
|
|
330
|
+
writable.append(cache)
|
|
331
|
+
readable = [*readable, *uv_readable]
|
|
332
|
+
if os.name == "posix":
|
|
333
|
+
shell = ["/bin/sh", "-c", cmd]
|
|
334
|
+
argv = wrap(kind, shell, cwd, home, list(deny_read), list(readable), writable) if kind else shell
|
|
335
|
+
else:
|
|
336
|
+
argv = f'"{os.environ.get("COMSPEC", "cmd.exe")}" /d /s /c "{cmd}"'
|
|
337
|
+
try:
|
|
338
|
+
proc = subprocess.Popen(
|
|
339
|
+
argv,
|
|
340
|
+
cwd=cwd,
|
|
341
|
+
env=env,
|
|
342
|
+
stdin=subprocess.DEVNULL,
|
|
343
|
+
stdout=subprocess.PIPE,
|
|
344
|
+
stderr=subprocess.STDOUT,
|
|
345
|
+
start_new_session=os.name == "posix",
|
|
346
|
+
)
|
|
347
|
+
except OSError as e:
|
|
348
|
+
return None, f"could not start verify: {e}", int((time.monotonic() - start) * 1000)
|
|
349
|
+
known = {}
|
|
350
|
+
deadline = time.monotonic() + timeout
|
|
351
|
+
expired = False
|
|
352
|
+
out = b""
|
|
353
|
+
try:
|
|
354
|
+
while True:
|
|
355
|
+
remaining = deadline - time.monotonic()
|
|
356
|
+
if remaining <= 0:
|
|
357
|
+
expired = True
|
|
358
|
+
break
|
|
359
|
+
try:
|
|
360
|
+
out, _ = proc.communicate(timeout=min(0.1, remaining))
|
|
361
|
+
break
|
|
362
|
+
except subprocess.TimeoutExpired as exc:
|
|
363
|
+
out = exc.output or b""
|
|
364
|
+
known.update(_descendants(proc, home, known))
|
|
365
|
+
finally:
|
|
366
|
+
stray = _cleanup(proc, home, known)
|
|
367
|
+
try:
|
|
368
|
+
out, _ = proc.communicate(timeout=CLEANUP_TIMEOUT)
|
|
369
|
+
except subprocess.TimeoutExpired as exc:
|
|
370
|
+
stray = True
|
|
371
|
+
out = exc.output or out
|
|
372
|
+
_cleanup(proc, home, known)
|
|
373
|
+
if proc.stdout:
|
|
374
|
+
proc.stdout.close()
|
|
375
|
+
try:
|
|
376
|
+
proc.wait(timeout=CLEANUP_TIMEOUT)
|
|
377
|
+
except subprocess.TimeoutExpired:
|
|
378
|
+
_kill(proc)
|
|
379
|
+
code = None if expired or stray else proc.returncode
|
|
380
|
+
text = out.decode("utf-8", "replace")
|
|
381
|
+
if expired:
|
|
382
|
+
text += f"\n[timed out after {timeout:g}s]"
|
|
383
|
+
if stray:
|
|
384
|
+
text += "\n[stray verification processes detected; receipt failed]"
|
|
385
|
+
reason = failure_reason(code, kind)
|
|
386
|
+
if reason:
|
|
387
|
+
text += "\n" + reason
|
|
388
|
+
tail = _tail(redact(text) if redact else text)
|
|
389
|
+
return code, tail, int((time.monotonic() - start) * 1000)
|
|
390
|
+
finally:
|
|
391
|
+
remove_tree(home, ignore_errors=True)
|