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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)