luciazero 2.3.0 → 2.4.2
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/LICENSE +1 -1
- package/README.md +35 -31
- package/agents/reviewer.md +34 -26
- package/claude/agents/reviewer.md +34 -26
- package/claude/hooks/luciazero-verify.sh +6 -14
- package/install-codex.sh +5 -1
- package/install.sh +3 -2
- package/package.json +10 -5
- package/skills/bisect/SKILL.md +11 -8
- package/skills/debug/SKILL.md +29 -23
- package/skills/discipline-report/SKILL.md +16 -6
- package/skills/done/SKILL.md +36 -30
- package/skills/experiment/SKILL.md +20 -17
- package/skills/imouto-mode/SKILL.md +29 -22
- package/skills/lucia-relay/SKILL.md +46 -32
- package/skills/lucia-relay/scripts/relay.py +599 -85
- package/skills/plan/SKILL.md +14 -10
- package/skills/ready/SKILL.md +82 -66
- package/skills/retro/SKILL.md +46 -39
- package/skills/show/SKILL.md +45 -96
- package/CHANGELOG.md +0 -712
- package/README.th.md +0 -277
|
@@ -11,18 +11,41 @@ import json
|
|
|
11
11
|
import os
|
|
12
12
|
from pathlib import Path, PurePosixPath
|
|
13
13
|
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import stat
|
|
14
16
|
import subprocess
|
|
15
17
|
import sys
|
|
18
|
+
import tempfile
|
|
16
19
|
from typing import Any, Optional
|
|
20
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
MANIFEST = "LUCIA_RELAY.json"
|
|
20
24
|
HUMAN = "LUCIA_RELAY.md"
|
|
25
|
+
RECEIPT = "LUCIA_RELAY_RECEIPT.json"
|
|
26
|
+
MAX_MANIFEST_BYTES = 1024 * 1024
|
|
27
|
+
MAX_HUMAN_BYTES = 2 * 1024 * 1024
|
|
28
|
+
MAX_DEPTH = 20
|
|
29
|
+
MAX_NODES = 8192
|
|
30
|
+
MAX_STRING_BYTES = 64 * 1024
|
|
31
|
+
GIT_TIMEOUT_SECONDS = 30
|
|
32
|
+
MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
33
|
+
MAX_UNTRACKED_BYTES = 64 * 1024 * 1024
|
|
34
|
+
SHA_RE = re.compile(r"\A[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
|
|
21
35
|
SECRET_PATTERNS = (
|
|
22
36
|
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
23
37
|
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
|
|
24
38
|
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
|
|
25
39
|
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
40
|
+
re.compile(r"\bnpm_[A-Za-z0-9]{30,}\b"),
|
|
41
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"),
|
|
42
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b"),
|
|
43
|
+
re.compile(r"\bglpat-[0-9A-Za-z_-]{20,}\b"),
|
|
44
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
|
|
45
|
+
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{20,}\b"),
|
|
46
|
+
re.compile(r"(?i)\b(?:aws_secret_access_key|secretaccesskey)\s*[:=]\s*[A-Za-z0-9/+=]{40}\b"),
|
|
47
|
+
re.compile(r"(?i)\b(?:https?|ssh|postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://[^\s/@:]+:[^\s/@]+@"),
|
|
48
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
|
|
26
49
|
)
|
|
27
50
|
POSIX_ABSOLUTE_PATH = re.compile(
|
|
28
51
|
r"(?<![A-Za-z0-9+.\-:/~$%])/(?!/)[^\s\"'`<>]+"
|
|
@@ -34,15 +57,27 @@ FILE_URI_PATH = re.compile(r"\bfile:[^\s\"'`<>]+", re.IGNORECASE)
|
|
|
34
57
|
|
|
35
58
|
|
|
36
59
|
def git(root: Path, *args: str) -> tuple[int, str]:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
git_env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
|
61
|
+
git_env["GIT_TERMINAL_PROMPT"] = "0"
|
|
62
|
+
with tempfile.TemporaryFile() as output:
|
|
63
|
+
try:
|
|
64
|
+
proc = subprocess.run(
|
|
65
|
+
["git", "-C", str(root), *args],
|
|
66
|
+
stdin=subprocess.DEVNULL,
|
|
67
|
+
stdout=output,
|
|
68
|
+
stderr=subprocess.DEVNULL,
|
|
69
|
+
check=False,
|
|
70
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
71
|
+
env=git_env,
|
|
72
|
+
)
|
|
73
|
+
except subprocess.TimeoutExpired:
|
|
74
|
+
return 124, ""
|
|
75
|
+
size = output.tell()
|
|
76
|
+
if size > MAX_GIT_OUTPUT_BYTES:
|
|
77
|
+
return 125, ""
|
|
78
|
+
output.seek(0)
|
|
79
|
+
text = output.read().decode("utf-8", errors="surrogateescape").rstrip("\n")
|
|
80
|
+
return proc.returncode, text
|
|
46
81
|
|
|
47
82
|
|
|
48
83
|
def repository_snapshot(root: Path) -> dict[str, Any]:
|
|
@@ -56,34 +91,52 @@ def repository_snapshot(root: Path) -> dict[str, Any]:
|
|
|
56
91
|
"known_remote_refs": [],
|
|
57
92
|
}
|
|
58
93
|
repo = Path(top)
|
|
94
|
+
snapshot_errors: list[str] = []
|
|
59
95
|
head_rc, head = git(repo, "rev-parse", "HEAD")
|
|
60
96
|
_, branch = git(repo, "branch", "--show-current")
|
|
61
|
-
exclusions = (
|
|
97
|
+
exclusions = (
|
|
98
|
+
"--", ".", f":(exclude){MANIFEST}", f":(exclude){HUMAN}",
|
|
99
|
+
f":(exclude){RECEIPT}",
|
|
100
|
+
)
|
|
62
101
|
if head_rc == 0:
|
|
63
|
-
|
|
64
|
-
|
|
102
|
+
modified_rc, raw_modified = git(repo, "diff", "--name-only", "-z", "HEAD", *exclusions)
|
|
103
|
+
diff_rc, diff = git(repo, "diff", "--binary", "HEAD", *exclusions)
|
|
104
|
+
if modified_rc != 0 or diff_rc != 0:
|
|
105
|
+
snapshot_errors.append("Git diff failed, timed out, or exceeded its output budget")
|
|
65
106
|
else:
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
staged_names_rc, raw_staged = git(repo, "diff", "--cached", "--name-only", "-z", *exclusions)
|
|
108
|
+
unstaged_names_rc, raw_unstaged = git(repo, "diff", "--name-only", "-z", *exclusions)
|
|
68
109
|
raw_modified = raw_staged + raw_unstaged
|
|
69
|
-
|
|
70
|
-
|
|
110
|
+
staged_diff_rc, staged_diff = git(repo, "diff", "--cached", "--binary", "--root", *exclusions)
|
|
111
|
+
unstaged_diff_rc, unstaged_diff = git(repo, "diff", "--binary", *exclusions)
|
|
112
|
+
if any(rc != 0 for rc in (staged_names_rc, unstaged_names_rc, staged_diff_rc, unstaged_diff_rc)):
|
|
113
|
+
snapshot_errors.append("Git diff failed, timed out, or exceeded its output budget")
|
|
71
114
|
diff = staged_diff + "\n" + unstaged_diff
|
|
72
|
-
|
|
115
|
+
untracked_rc, raw_untracked = git(repo, "ls-files", "--others", "--exclude-standard", "-z")
|
|
116
|
+
if untracked_rc != 0:
|
|
117
|
+
snapshot_errors.append("Git untracked-file scan failed, timed out, or exceeded its output budget")
|
|
73
118
|
modified = sorted(set(item for item in raw_modified.split("\0") if item))
|
|
74
119
|
untracked = [
|
|
75
|
-
item for item in raw_untracked.split("\0")
|
|
120
|
+
item for item in raw_untracked.split("\0")
|
|
121
|
+
if item and item not in (MANIFEST, HUMAN, RECEIPT)
|
|
76
122
|
]
|
|
77
123
|
digest = hashlib.sha256(diff.encode("utf-8", errors="surrogateescape"))
|
|
124
|
+
untracked_bytes = 0
|
|
125
|
+
untracked_budget_exhausted = False
|
|
78
126
|
for relative in sorted(untracked):
|
|
79
127
|
digest.update(b"\0untracked\0" + relative.encode("utf-8", errors="surrogateescape") + b"\0")
|
|
80
128
|
candidate = repo / relative
|
|
81
129
|
try:
|
|
82
130
|
if candidate.is_symlink():
|
|
83
131
|
digest.update(b"symlink\0" + os.readlink(candidate).encode("utf-8", errors="surrogateescape"))
|
|
84
|
-
elif candidate.is_file():
|
|
132
|
+
elif candidate.is_file() and not untracked_budget_exhausted:
|
|
85
133
|
with candidate.open("rb") as handle:
|
|
86
134
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
135
|
+
untracked_bytes += len(chunk)
|
|
136
|
+
if untracked_bytes > MAX_UNTRACKED_BYTES:
|
|
137
|
+
snapshot_errors.append("untracked files exceed the fingerprint byte budget")
|
|
138
|
+
untracked_budget_exhausted = True
|
|
139
|
+
break
|
|
87
140
|
digest.update(chunk)
|
|
88
141
|
else:
|
|
89
142
|
# FIFOs, sockets, and devices must affect the fingerprint but
|
|
@@ -96,13 +149,15 @@ def repository_snapshot(root: Path) -> dict[str, Any]:
|
|
|
96
149
|
fingerprint = digest.hexdigest()
|
|
97
150
|
known_remote_refs: list[str] = []
|
|
98
151
|
if head_rc == 0:
|
|
99
|
-
|
|
152
|
+
remote_refs_rc, remote_refs = git(
|
|
100
153
|
repo,
|
|
101
154
|
"for-each-ref",
|
|
102
155
|
"--contains=HEAD",
|
|
103
156
|
"--format=%(refname)",
|
|
104
157
|
"refs/remotes",
|
|
105
158
|
)
|
|
159
|
+
if remote_refs_rc != 0:
|
|
160
|
+
snapshot_errors.append("Git remote-ref scan failed, timed out, or exceeded its output budget")
|
|
106
161
|
known_remote_refs = sorted(
|
|
107
162
|
ref[len("refs/remotes/") :]
|
|
108
163
|
for ref in remote_refs.splitlines()
|
|
@@ -115,14 +170,188 @@ def repository_snapshot(root: Path) -> dict[str, Any]:
|
|
|
115
170
|
"diff_sha256": fingerprint,
|
|
116
171
|
"known_remote_refs": known_remote_refs,
|
|
117
172
|
"files": {"modified": modified, "untracked": untracked},
|
|
173
|
+
"_errors": sorted(set(snapshot_errors)),
|
|
118
174
|
}
|
|
119
175
|
|
|
120
176
|
|
|
121
|
-
def
|
|
177
|
+
def sanitize_remote_url(value: str) -> Optional[str]:
|
|
178
|
+
value = value.strip()
|
|
179
|
+
if not value or value.startswith(("/", "./", "../", "~", "file:")):
|
|
180
|
+
return None
|
|
181
|
+
if re.fullmatch(r"[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._/+~-]+", value):
|
|
182
|
+
return value
|
|
183
|
+
parsed = urlsplit(value)
|
|
184
|
+
if parsed.scheme not in ("https", "ssh") or not parsed.hostname:
|
|
185
|
+
return None
|
|
186
|
+
user = parsed.username if parsed.scheme == "ssh" else None
|
|
187
|
+
host = parsed.hostname
|
|
188
|
+
try:
|
|
189
|
+
port = parsed.port
|
|
190
|
+
except ValueError:
|
|
191
|
+
return None
|
|
192
|
+
if port:
|
|
193
|
+
host = f"{host}:{port}"
|
|
194
|
+
netloc = f"{user}@{host}" if user else host
|
|
195
|
+
return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def git_url_rewrite_error(root: Path, remote_url: str) -> Optional[str]:
|
|
199
|
+
rc, rewrites = git(root, "config", "--get-regexp", r"^url\..*\.(insteadOf|pushInsteadOf)$")
|
|
200
|
+
if rc not in (0, 1):
|
|
201
|
+
return "cannot safely inspect Git URL rewrite rules"
|
|
202
|
+
if rc == 0:
|
|
203
|
+
for line in rewrites.splitlines():
|
|
204
|
+
parts = line.split(None, 1)
|
|
205
|
+
if len(parts) == 2 and remote_url.startswith(parts[1]):
|
|
206
|
+
return "Git URL rewrite rules apply to the relay repository"
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def git_transport_override_error(root: Path, remote_name: str, remote_url: str) -> Optional[str]:
|
|
211
|
+
rewrite_error = git_url_rewrite_error(root, remote_url)
|
|
212
|
+
if rewrite_error:
|
|
213
|
+
return rewrite_error
|
|
214
|
+
for key in (
|
|
215
|
+
"core.sshCommand",
|
|
216
|
+
f"remote.{remote_name}.uploadpack",
|
|
217
|
+
f"remote.{remote_name}.receivepack",
|
|
218
|
+
):
|
|
219
|
+
rc, value = git(root, "config", "--get-all", key)
|
|
220
|
+
if rc not in (0, 1):
|
|
221
|
+
return f"cannot safely inspect Git transport override {key}"
|
|
222
|
+
if rc == 0 and value:
|
|
223
|
+
return f"Git transport override {key} applies to the relay repository"
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def remote_snapshot(root: Path, head: str, base: str) -> dict[str, Any]:
|
|
228
|
+
rc, upstream = git(root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
|
|
229
|
+
if rc != 0 or "/" not in upstream:
|
|
230
|
+
raise ValueError("cross-machine draft requires a configured upstream branch")
|
|
231
|
+
remote_name, branch = upstream.split("/", 1)
|
|
232
|
+
remote_ref = f"refs/heads/{branch}"
|
|
233
|
+
rc, remote_urls = git(root, "config", "--get-all", f"remote.{remote_name}.url")
|
|
234
|
+
configured_urls = [value for value in remote_urls.splitlines() if value]
|
|
235
|
+
if rc != 0 or len(configured_urls) != 1:
|
|
236
|
+
raise ValueError("cross-machine draft requires exactly one upstream remote URL")
|
|
237
|
+
remote_url = configured_urls[0]
|
|
238
|
+
sanitized_url = sanitize_remote_url(remote_url)
|
|
239
|
+
if sanitized_url is None:
|
|
240
|
+
raise ValueError("cross-machine draft requires a portable HTTPS or SSH upstream URL")
|
|
241
|
+
rc, push_urls = git(root, "config", "--get-all", f"remote.{remote_name}.pushurl")
|
|
242
|
+
if rc not in (0, 1):
|
|
243
|
+
raise ValueError("cross-machine draft cannot safely inspect remote push URLs")
|
|
244
|
+
if rc == 0 and push_urls:
|
|
245
|
+
raise ValueError("cross-machine draft rejects a separate remote push URL")
|
|
246
|
+
transport_error = git_transport_override_error(root, remote_name, remote_url)
|
|
247
|
+
if transport_error:
|
|
248
|
+
raise ValueError(f"cross-machine draft rejects its upstream: {transport_error}")
|
|
249
|
+
rc, remote_lines = git(root, "ls-remote", "--exit-code", remote_name, remote_ref)
|
|
250
|
+
if rc != 0:
|
|
251
|
+
raise ValueError("cross-machine draft cannot verify the upstream ref; push it first")
|
|
252
|
+
remote_oid = next(
|
|
253
|
+
(line.split("\t", 1)[0] for line in remote_lines.splitlines() if line.endswith("\t" + remote_ref)),
|
|
254
|
+
"",
|
|
255
|
+
)
|
|
256
|
+
if remote_oid != head:
|
|
257
|
+
raise ValueError("cross-machine draft HEAD is not the current remote ref; push it first")
|
|
258
|
+
transfer_ref = f"refs/tags/lucia-relay-{head}"
|
|
259
|
+
rc, transfer_lines = git(root, "ls-remote", remote_name, transfer_ref)
|
|
260
|
+
if rc != 0:
|
|
261
|
+
raise ValueError("cross-machine draft cannot safely inspect its transfer tag")
|
|
262
|
+
transfer_oid = next(
|
|
263
|
+
(line.split("\t", 1)[0] for line in transfer_lines.splitlines() if line.endswith("\t" + transfer_ref)),
|
|
264
|
+
"",
|
|
265
|
+
)
|
|
266
|
+
if transfer_oid and transfer_oid != head:
|
|
267
|
+
raise ValueError("cross-machine commit-named transfer tag points at another commit")
|
|
268
|
+
if not transfer_oid:
|
|
269
|
+
rc, _ = git(root, "push", "--porcelain", remote_name, f"{head}:{transfer_ref}")
|
|
270
|
+
if rc != 0:
|
|
271
|
+
raise ValueError("cross-machine draft could not publish its transfer tag")
|
|
272
|
+
rc, transfer_lines = git(root, "ls-remote", "--exit-code", remote_name, transfer_ref)
|
|
273
|
+
transfer_oid = next(
|
|
274
|
+
(line.split("\t", 1)[0] for line in transfer_lines.splitlines() if line.endswith("\t" + transfer_ref)),
|
|
275
|
+
"",
|
|
276
|
+
) if rc == 0 else ""
|
|
277
|
+
if transfer_oid != head:
|
|
278
|
+
raise ValueError("cross-machine transfer ref was not published at HEAD")
|
|
279
|
+
rc, base_oid = git(root, "rev-parse", f"{base}^{{commit}}")
|
|
280
|
+
if rc != 0 or not SHA_RE.fullmatch(base_oid):
|
|
281
|
+
raise ValueError("cross-machine --base must resolve to a commit")
|
|
282
|
+
rc, _ = git(root, "merge-base", "--is-ancestor", base_oid, head)
|
|
283
|
+
if rc != 0:
|
|
284
|
+
raise ValueError("cross-machine --base must be an ancestor of HEAD")
|
|
285
|
+
rc, changed = git(root, "diff", "--name-only", "-z", "--no-renames", base_oid, head, "--")
|
|
286
|
+
if rc != 0:
|
|
287
|
+
raise ValueError("cross-machine draft could not list committed task files")
|
|
288
|
+
changed_files = sorted(item for item in changed.split("\0") if item)
|
|
289
|
+
return {
|
|
290
|
+
"url": sanitized_url,
|
|
291
|
+
"name": remote_name,
|
|
292
|
+
"ref": transfer_ref,
|
|
293
|
+
"oid": transfer_oid,
|
|
294
|
+
"source_ref": remote_ref,
|
|
295
|
+
"base": base_oid,
|
|
296
|
+
"changed_files": changed_files,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def envelope_remote_errors(root: Path, data: dict[str, Any]) -> list[str]:
|
|
301
|
+
repository = data.get("repository") if isinstance(data.get("repository"), dict) else {}
|
|
302
|
+
remote = repository.get("remote") if isinstance(repository.get("remote"), dict) else {}
|
|
303
|
+
name, expected_url, ref, oid = (
|
|
304
|
+
remote.get("name"), remote.get("url"), remote.get("ref"), remote.get("oid")
|
|
305
|
+
)
|
|
306
|
+
if not all(nonempty(value) for value in (name, expected_url, ref, oid)):
|
|
307
|
+
return ["trusted envelope lacks complete remote metadata"]
|
|
308
|
+
rc, configured_urls = git(root, "config", "--get-all", f"remote.{name}.url")
|
|
309
|
+
urls = [value for value in configured_urls.splitlines() if value]
|
|
310
|
+
if rc != 0 or len(urls) != 1 or sanitize_remote_url(urls[0]) != expected_url:
|
|
311
|
+
return ["trusted envelope repository URL no longer matches configured upstream"]
|
|
312
|
+
configured_url = urls[0]
|
|
313
|
+
transport_error = git_transport_override_error(root, str(name), configured_url)
|
|
314
|
+
if transport_error:
|
|
315
|
+
return [f"trusted envelope rejects repository transport: {transport_error}"]
|
|
316
|
+
rc, push_urls = git(root, "config", "--get-all", f"remote.{name}.pushurl")
|
|
317
|
+
if rc not in (0, 1):
|
|
318
|
+
return ["trusted envelope cannot safely inspect remote push URLs"]
|
|
319
|
+
if rc == 0 and push_urls:
|
|
320
|
+
return ["trusted envelope rejects a separate remote push URL"]
|
|
321
|
+
rc, remote_lines = git(root, "ls-remote", "--exit-code", str(name), str(ref))
|
|
322
|
+
if rc != 0:
|
|
323
|
+
return ["trusted envelope cannot reach its recorded remote ref"]
|
|
324
|
+
live_oid = next(
|
|
325
|
+
(line.split("\t", 1)[0] for line in remote_lines.splitlines() if line.endswith("\t" + str(ref))),
|
|
326
|
+
"",
|
|
327
|
+
)
|
|
328
|
+
if live_oid != oid or oid != repository.get("head"):
|
|
329
|
+
return ["trusted envelope remote ref no longer resolves to its recorded HEAD"]
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def draft(root: Path, recipient: str, base: Optional[str] = None) -> dict[str, Any]:
|
|
122
334
|
snap = repository_snapshot(root)
|
|
335
|
+
snapshot_errors = snap.pop("_errors", [])
|
|
123
336
|
files = snap.pop("files", {"modified": [], "untracked": []})
|
|
337
|
+
schema = 2
|
|
338
|
+
if recipient == "cross-machine":
|
|
339
|
+
if not base:
|
|
340
|
+
raise ValueError("cross-machine draft requires --base <task-base-revision>")
|
|
341
|
+
if snapshot_errors:
|
|
342
|
+
raise ValueError("; ".join(snapshot_errors))
|
|
343
|
+
if snap.get("head") is None or snap.get("dirty") is not False:
|
|
344
|
+
raise ValueError("cross-machine draft requires a clean committed worktree")
|
|
345
|
+
remote = remote_snapshot(root, str(snap["head"]), base)
|
|
346
|
+
snap["remote"] = {
|
|
347
|
+
key: remote[key] for key in ("url", "name", "ref", "oid", "source_ref")
|
|
348
|
+
}
|
|
349
|
+
snap["base"] = remote["base"]
|
|
350
|
+
snap["changed_files"] = remote["changed_files"]
|
|
351
|
+
files = {"modified": [], "untracked": []}
|
|
352
|
+
schema = 3
|
|
124
353
|
return {
|
|
125
|
-
"schema":
|
|
354
|
+
"schema": schema,
|
|
126
355
|
"kind": "luciazero-relay",
|
|
127
356
|
"created_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
|
|
128
357
|
"route": {"recipient": recipient},
|
|
@@ -144,34 +373,90 @@ def draft(root: Path, recipient: str) -> dict[str, Any]:
|
|
|
144
373
|
}
|
|
145
374
|
|
|
146
375
|
|
|
147
|
-
def
|
|
148
|
-
|
|
376
|
+
def structure_errors(value: Any) -> list[str]:
|
|
377
|
+
errors: list[str] = []
|
|
378
|
+
stack = [(value, 0)]
|
|
379
|
+
nodes = 0
|
|
380
|
+
while stack:
|
|
381
|
+
item, depth = stack.pop()
|
|
382
|
+
nodes += 1
|
|
383
|
+
if nodes > MAX_NODES:
|
|
384
|
+
return [f"artifact exceeds {MAX_NODES} values"]
|
|
385
|
+
if depth > MAX_DEPTH:
|
|
386
|
+
return [f"artifact exceeds nesting depth {MAX_DEPTH}"]
|
|
387
|
+
if isinstance(item, str):
|
|
388
|
+
try:
|
|
389
|
+
encoded = item.encode("utf-8")
|
|
390
|
+
except UnicodeEncodeError:
|
|
391
|
+
errors.append("artifact contains invalid Unicode surrogate data")
|
|
392
|
+
continue
|
|
393
|
+
if len(encoded) > MAX_STRING_BYTES:
|
|
394
|
+
errors.append(f"artifact string exceeds {MAX_STRING_BYTES} bytes")
|
|
395
|
+
elif isinstance(item, dict):
|
|
396
|
+
stack.extend((key, depth + 1) for key in item.keys())
|
|
397
|
+
stack.extend((nested, depth + 1) for nested in item.values())
|
|
398
|
+
elif isinstance(item, list):
|
|
399
|
+
stack.extend((nested, depth + 1) for nested in item)
|
|
400
|
+
return errors
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def load_json_object(path: Path, label: str, max_bytes: int = MAX_MANIFEST_BYTES) -> dict[str, Any]:
|
|
149
404
|
if path.is_symlink():
|
|
150
|
-
raise ValueError(f"{
|
|
405
|
+
raise ValueError(f"{label} must not be a symlink")
|
|
151
406
|
try:
|
|
407
|
+
info = path.stat()
|
|
408
|
+
if not stat.S_ISREG(info.st_mode):
|
|
409
|
+
raise ValueError(f"{label} must be a regular file")
|
|
410
|
+
if info.st_size > max_bytes:
|
|
411
|
+
raise ValueError(f"{label} exceeds {max_bytes} bytes")
|
|
152
412
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
153
413
|
except FileNotFoundError as exc:
|
|
154
|
-
raise ValueError(f"{
|
|
155
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
156
|
-
raise ValueError(f"cannot read {
|
|
414
|
+
raise ValueError(f"{label} does not exist") from exc
|
|
415
|
+
except (OSError, json.JSONDecodeError, RecursionError) as exc:
|
|
416
|
+
raise ValueError(f"cannot read {label}: {exc}") from exc
|
|
157
417
|
if not isinstance(value, dict):
|
|
158
|
-
raise ValueError(f"{
|
|
418
|
+
raise ValueError(f"{label} must contain a JSON object")
|
|
419
|
+
limits = structure_errors(value)
|
|
420
|
+
if limits:
|
|
421
|
+
raise ValueError(f"{label} {limits[0]}")
|
|
159
422
|
return value
|
|
160
423
|
|
|
161
424
|
|
|
425
|
+
def load_manifest(root: Path) -> dict[str, Any]:
|
|
426
|
+
path = root / MANIFEST
|
|
427
|
+
return load_json_object(path, MANIFEST)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def read_bounded_text(path: Path, label: str, max_bytes: int) -> str:
|
|
431
|
+
if path.is_symlink():
|
|
432
|
+
raise ValueError(f"{label} must not be a symlink")
|
|
433
|
+
try:
|
|
434
|
+
info = path.stat()
|
|
435
|
+
if not stat.S_ISREG(info.st_mode):
|
|
436
|
+
raise ValueError(f"{label} must be a regular file")
|
|
437
|
+
if info.st_size > max_bytes:
|
|
438
|
+
raise ValueError(f"{label} exceeds {max_bytes} bytes")
|
|
439
|
+
return path.read_text(encoding="utf-8")
|
|
440
|
+
except FileNotFoundError as exc:
|
|
441
|
+
raise ValueError(f"{label} does not exist") from exc
|
|
442
|
+
except (OSError, UnicodeError) as exc:
|
|
443
|
+
raise ValueError(f"cannot read {label}: {exc}") from exc
|
|
444
|
+
|
|
445
|
+
|
|
162
446
|
def nonempty(value: Any) -> bool:
|
|
163
447
|
return isinstance(value, str) and bool(value.strip())
|
|
164
448
|
|
|
165
449
|
|
|
166
450
|
def string_values(value: Any):
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
yield
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
451
|
+
stack = [value]
|
|
452
|
+
while stack:
|
|
453
|
+
item = stack.pop()
|
|
454
|
+
if isinstance(item, str):
|
|
455
|
+
yield item
|
|
456
|
+
elif isinstance(item, dict):
|
|
457
|
+
stack.extend(item.values())
|
|
458
|
+
elif isinstance(item, list):
|
|
459
|
+
stack.extend(item)
|
|
175
460
|
|
|
176
461
|
|
|
177
462
|
def machine_paths(data: dict[str, Any]) -> list[str]:
|
|
@@ -206,12 +491,56 @@ def repo_pointer(value: Any) -> Optional[str]:
|
|
|
206
491
|
return candidate
|
|
207
492
|
|
|
208
493
|
|
|
494
|
+
def safe_command_argv(value: Any) -> Optional[list[str]]:
|
|
495
|
+
if not nonempty(value):
|
|
496
|
+
return None
|
|
497
|
+
try:
|
|
498
|
+
argv = shlex.split(str(value))
|
|
499
|
+
except ValueError:
|
|
500
|
+
return None
|
|
501
|
+
shell_tokens = {"|", "||", "&&", ";", ">", ">>", "<", "2>", "&"}
|
|
502
|
+
if not argv or any(token in shell_tokens for token in argv):
|
|
503
|
+
return None
|
|
504
|
+
executable = PurePosixPath(argv[0].replace("\\", "/")).name.casefold()
|
|
505
|
+
if executable in {"sh", "bash", "zsh", "fish", "cmd", "cmd.exe", "powershell", "pwsh", "env", "xargs"}:
|
|
506
|
+
return None
|
|
507
|
+
if executable == "git" and any(token == "-c" or token.startswith("--config-env=") for token in argv[1:]):
|
|
508
|
+
return None
|
|
509
|
+
if executable in {"python", "python3", "node", "ruby", "perl"} and any(
|
|
510
|
+
token in ("-c", "-e", "--eval") for token in argv[1:]
|
|
511
|
+
):
|
|
512
|
+
return None
|
|
513
|
+
return argv
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def repository_path_error(root: Path, head: str, path: str, base: Optional[str] = None) -> Optional[str]:
|
|
517
|
+
if repo_pointer(path) != path:
|
|
518
|
+
return f"must be one contained repo-relative path: {path}"
|
|
519
|
+
if not SHA_RE.fullmatch(head) or (base is not None and not SHA_RE.fullmatch(base)):
|
|
520
|
+
return "cannot be checked without valid immutable repository OIDs"
|
|
521
|
+
refs = [head] + ([base] if nonempty(base) else [])
|
|
522
|
+
saw_entry = False
|
|
523
|
+
for ref in refs:
|
|
524
|
+
rc, entry = git(root, "ls-tree", str(ref), "--", path)
|
|
525
|
+
if rc != 0 or not entry:
|
|
526
|
+
continue
|
|
527
|
+
saw_entry = True
|
|
528
|
+
metadata = entry.split("\t", 1)[0].split()
|
|
529
|
+
if len(metadata) < 2 or metadata[1] != "blob" or metadata[0] == "120000":
|
|
530
|
+
return f"must resolve to a regular tracked blob: {path}"
|
|
531
|
+
return None
|
|
532
|
+
if not saw_entry:
|
|
533
|
+
return f"is not present in the relay base or HEAD: {path}"
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
|
|
209
537
|
def cross_machine_repository_errors(root: Path, data: dict[str, Any]) -> list[str]:
|
|
210
538
|
route = data.get("route") if isinstance(data.get("route"), dict) else {}
|
|
211
539
|
if route.get("recipient") != "cross-machine":
|
|
212
540
|
return []
|
|
213
541
|
repository = data.get("repository") if isinstance(data.get("repository"), dict) else {}
|
|
214
542
|
head = repository.get("head")
|
|
543
|
+
base = repository.get("base")
|
|
215
544
|
knowledge = data.get("knowledge") if isinstance(data.get("knowledge"), dict) else {}
|
|
216
545
|
read_first = knowledge.get("read_first")
|
|
217
546
|
if not isinstance(read_first, list):
|
|
@@ -227,31 +556,51 @@ def cross_machine_repository_errors(root: Path, data: dict[str, Any]) -> list[st
|
|
|
227
556
|
if not nonempty(head):
|
|
228
557
|
errors.append(f"knowledge.read_first[{index}] cannot be checked without repository.head")
|
|
229
558
|
continue
|
|
230
|
-
|
|
231
|
-
if
|
|
232
|
-
errors.append(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
559
|
+
problem = repository_path_error(root, str(head), path)
|
|
560
|
+
if problem:
|
|
561
|
+
errors.append(f"knowledge.read_first[{index}] {problem}")
|
|
562
|
+
changed = repository.get("changed_files")
|
|
563
|
+
if isinstance(changed, list):
|
|
564
|
+
for index, item in enumerate(changed):
|
|
565
|
+
if not isinstance(item, str):
|
|
566
|
+
continue
|
|
567
|
+
problem = repository_path_error(root, str(head), item, str(base) if nonempty(base) else None)
|
|
568
|
+
if problem:
|
|
569
|
+
errors.append(f"repository.changed_files[{index}] {problem}")
|
|
241
570
|
return errors
|
|
242
571
|
|
|
243
572
|
|
|
573
|
+
def receiver_repository_url_error(root: Path, trusted_url: str) -> Optional[str]:
|
|
574
|
+
rc, names = git(root, "remote")
|
|
575
|
+
if rc != 0:
|
|
576
|
+
return "receiver cannot inspect configured Git remotes"
|
|
577
|
+
for name in names.splitlines():
|
|
578
|
+
rc, configured_values = git(root, "config", "--get-all", f"remote.{name}.url")
|
|
579
|
+
configured = [value for value in configured_values.splitlines() if value]
|
|
580
|
+
if rc == 0 and configured == [trusted_url]:
|
|
581
|
+
transport_error = git_transport_override_error(root, name, trusted_url)
|
|
582
|
+
if transport_error:
|
|
583
|
+
return f"receiver rejects trusted repository transport: {transport_error}"
|
|
584
|
+
push_rc, push_urls = git(root, "config", "--get-all", f"remote.{name}.pushurl")
|
|
585
|
+
if push_rc not in (0, 1):
|
|
586
|
+
return "receiver cannot safely inspect trusted remote push URLs"
|
|
587
|
+
if push_rc == 0 and push_urls:
|
|
588
|
+
return "receiver trusted Git remote has a separate push URL"
|
|
589
|
+
return None
|
|
590
|
+
return "receiver clone has no Git remote matching the trusted repository URL"
|
|
591
|
+
|
|
592
|
+
|
|
244
593
|
def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
245
594
|
errors: list[str] = []
|
|
246
595
|
warnings: list[str] = []
|
|
247
596
|
schema = data.get("schema")
|
|
248
|
-
if schema not in (1, 2):
|
|
249
|
-
errors.append("schema must equal 1 or
|
|
597
|
+
if type(schema) is not int or schema not in (1, 2, 3):
|
|
598
|
+
errors.append("schema must equal 1, 2, or 3")
|
|
250
599
|
if data.get("kind") != "luciazero-relay":
|
|
251
600
|
errors.append("kind must equal luciazero-relay")
|
|
252
601
|
recipient = None
|
|
253
602
|
route = data.get("route")
|
|
254
|
-
if schema
|
|
603
|
+
if schema in (2, 3):
|
|
255
604
|
if not isinstance(route, dict):
|
|
256
605
|
errors.append("route must be an object")
|
|
257
606
|
else:
|
|
@@ -301,6 +650,8 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
301
650
|
continue
|
|
302
651
|
if not nonempty(item.get("command")):
|
|
303
652
|
errors.append(f"{prefix}.command is required")
|
|
653
|
+
elif schema == 3 and recipient == "cross-machine" and safe_command_argv(item.get("command")) is None:
|
|
654
|
+
errors.append(f"{prefix}.command must be one argv-safe command without shell operators")
|
|
304
655
|
if not isinstance(item.get("exit_code"), int):
|
|
305
656
|
errors.append(f"{prefix}.exit_code must be an integer")
|
|
306
657
|
if not nonempty(item.get("decisive_line")):
|
|
@@ -323,7 +674,7 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
323
674
|
if not isinstance(knowledge.get(key), list):
|
|
324
675
|
errors.append(f"knowledge.{key} must be an array")
|
|
325
676
|
inline = knowledge.get("inline")
|
|
326
|
-
if schema
|
|
677
|
+
if schema in (2, 3) and not isinstance(inline, list):
|
|
327
678
|
errors.append("knowledge.inline must be an array")
|
|
328
679
|
elif isinstance(inline, list):
|
|
329
680
|
for index, item in enumerate(inline):
|
|
@@ -355,12 +706,38 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
355
706
|
for key in ("head", "branch", "dirty", "diff_sha256"):
|
|
356
707
|
if key not in repository:
|
|
357
708
|
errors.append(f"repository.{key} is required")
|
|
358
|
-
if schema
|
|
709
|
+
if schema in (2, 3):
|
|
359
710
|
known_remote_refs = repository.get("known_remote_refs")
|
|
360
711
|
if not isinstance(known_remote_refs, list):
|
|
361
712
|
errors.append("repository.known_remote_refs must be an array")
|
|
362
713
|
elif not all(nonempty(ref) for ref in known_remote_refs):
|
|
363
714
|
errors.append("repository.known_remote_refs entries must be non-empty strings")
|
|
715
|
+
if schema == 3:
|
|
716
|
+
base = repository.get("base")
|
|
717
|
+
changed_files = repository.get("changed_files")
|
|
718
|
+
remote = repository.get("remote")
|
|
719
|
+
if not nonempty(base) or not SHA_RE.fullmatch(str(base)):
|
|
720
|
+
errors.append("repository.base must be an immutable commit OID")
|
|
721
|
+
if not isinstance(changed_files, list) or not all(
|
|
722
|
+
isinstance(path, str) and repo_pointer(path) == path for path in changed_files
|
|
723
|
+
):
|
|
724
|
+
errors.append("repository.changed_files must contain repo-relative paths")
|
|
725
|
+
if not isinstance(remote, dict):
|
|
726
|
+
errors.append("repository.remote must be an object")
|
|
727
|
+
else:
|
|
728
|
+
url = remote.get("url")
|
|
729
|
+
if not nonempty(url) or sanitize_remote_url(str(url)) != url:
|
|
730
|
+
errors.append("repository.remote.url must be a sanitized portable HTTPS or SSH URL")
|
|
731
|
+
if not nonempty(remote.get("name")):
|
|
732
|
+
errors.append("repository.remote.name is required")
|
|
733
|
+
if not nonempty(remote.get("ref")) or not str(remote.get("ref")).startswith(("refs/heads/", "refs/tags/")):
|
|
734
|
+
errors.append("repository.remote.ref must be a full heads/tags ref")
|
|
735
|
+
if not nonempty(remote.get("source_ref")) or not str(remote.get("source_ref")).startswith("refs/heads/"):
|
|
736
|
+
errors.append("repository.remote.source_ref must be a full branch ref")
|
|
737
|
+
if not nonempty(remote.get("oid")) or not SHA_RE.fullmatch(str(remote.get("oid"))):
|
|
738
|
+
errors.append("repository.remote.oid must be an immutable commit OID")
|
|
739
|
+
if remote.get("oid") != repository.get("head"):
|
|
740
|
+
errors.append("repository.remote.oid must equal repository.head")
|
|
364
741
|
files = data.get("files")
|
|
365
742
|
if not isinstance(files, dict):
|
|
366
743
|
errors.append("files must be an object")
|
|
@@ -368,12 +745,18 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
368
745
|
for key in ("modified", "untracked"):
|
|
369
746
|
if not isinstance(files.get(key), list):
|
|
370
747
|
errors.append(f"files.{key} must be an array")
|
|
748
|
+
elif not all(isinstance(path, str) and repo_pointer(path) == path for path in files.get(key)):
|
|
749
|
+
errors.append(f"files.{key} must contain repo-relative paths")
|
|
371
750
|
|
|
372
751
|
serialized = json.dumps(data, ensure_ascii=False)
|
|
373
752
|
if any(pattern.search(serialized) for pattern in SECRET_PATTERNS):
|
|
374
753
|
errors.append("possible secret or private key detected; remove it before routing")
|
|
375
754
|
local_paths = machine_paths(data)
|
|
376
|
-
if
|
|
755
|
+
if schema == 3 and recipient != "cross-machine":
|
|
756
|
+
errors.append("schema 3 route.recipient must be cross-machine")
|
|
757
|
+
if recipient == "cross-machine" or schema == 3:
|
|
758
|
+
if schema != 3:
|
|
759
|
+
errors.append("cross-machine relay requires schema 3; re-draft it after push")
|
|
377
760
|
if local_paths:
|
|
378
761
|
errors.append(
|
|
379
762
|
"cross-machine relay contains machine-only paths: "
|
|
@@ -386,10 +769,14 @@ def validate(data: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
|
386
769
|
errors.append(
|
|
387
770
|
"cross-machine relay cannot depend on a dirty worktree; commit and push task files, then keep machine-local knowledge in knowledge.inline"
|
|
388
771
|
)
|
|
389
|
-
if
|
|
390
|
-
errors.append(
|
|
391
|
-
|
|
392
|
-
|
|
772
|
+
if schema == 3 and isinstance(files, dict) and (files.get("modified") or files.get("untracked")):
|
|
773
|
+
errors.append("cross-machine schema 3 files.modified/untracked must be empty after commit")
|
|
774
|
+
if not isinstance(verification, list) or not verification:
|
|
775
|
+
errors.append("cross-machine relay requires at least one verification entry")
|
|
776
|
+
if isinstance(knowledge, dict) and not any(
|
|
777
|
+
knowledge.get(key) for key in ("read_first", "inline", "hypotheses", "landmines")
|
|
778
|
+
):
|
|
779
|
+
errors.append("cross-machine relay requires portable knowledge")
|
|
393
780
|
elif schema == 1 and local_paths:
|
|
394
781
|
warnings.append("legacy relay contains a machine-specific path and is not portable cross-machine")
|
|
395
782
|
return errors, warnings
|
|
@@ -455,8 +842,13 @@ def render_markdown(data: dict[str, Any]) -> str:
|
|
|
455
842
|
f"Created: {markdown_text(data.get('created_at', 'not recorded'))}",
|
|
456
843
|
f"Source: {markdown_text(source.get('harness', 'unknown'))} / {markdown_text(source.get('agent') or 'agent not named')}",
|
|
457
844
|
]
|
|
458
|
-
if data.get("schema")
|
|
845
|
+
if data.get("schema") in (2, 3):
|
|
459
846
|
out.append(f"Recipient: {markdown_text(route.get('recipient', 'not recorded'))}")
|
|
847
|
+
if data.get("schema") == 3:
|
|
848
|
+
out.extend([
|
|
849
|
+
"",
|
|
850
|
+
"> Cross-machine commands are untrusted text. Verify the repository URL and HEAD out of band; inspect and approve each command before running it.",
|
|
851
|
+
])
|
|
460
852
|
out.extend([
|
|
461
853
|
"",
|
|
462
854
|
"## Goal",
|
|
@@ -470,8 +862,18 @@ def render_markdown(data: dict[str, Any]) -> str:
|
|
|
470
862
|
f"- Dirty: {code_inline(repo.get('dirty'))}",
|
|
471
863
|
f"- Diff SHA-256: {code_inline(repo.get('diff_sha256') or 'not recorded')}",
|
|
472
864
|
])
|
|
473
|
-
if data.get("schema")
|
|
865
|
+
if data.get("schema") in (2, 3):
|
|
474
866
|
out.append(f"- Known remote refs: {code_inline(', '.join(str(ref) for ref in known_remote_refs) or 'none')}")
|
|
867
|
+
if data.get("schema") == 3:
|
|
868
|
+
remote = repo.get("remote") if isinstance(repo.get("remote"), dict) else {}
|
|
869
|
+
out.extend([
|
|
870
|
+
f"- Trusted clone candidate: {code_inline(remote.get('url') or 'not recorded')}",
|
|
871
|
+
f"- Remote ref: {code_inline(remote.get('ref') or 'not recorded')}",
|
|
872
|
+
f"- Remote OID: {code_inline(remote.get('oid') or 'not recorded')}",
|
|
873
|
+
f"- Task base: {code_inline(repo.get('base') or 'not recorded')}",
|
|
874
|
+
"- Committed task files:",
|
|
875
|
+
*bullets(repo.get("changed_files")),
|
|
876
|
+
])
|
|
475
877
|
out.extend([
|
|
476
878
|
"",
|
|
477
879
|
"## Done",
|
|
@@ -505,7 +907,7 @@ def render_markdown(data: dict[str, Any]) -> str:
|
|
|
505
907
|
else:
|
|
506
908
|
out.append("| Not run | — | No verification evidence recorded | — |")
|
|
507
909
|
out.extend(["", "## Read first", "", *bullets(knowledge.get("read_first"))])
|
|
508
|
-
if data.get("schema")
|
|
910
|
+
if data.get("schema") in (2, 3):
|
|
509
911
|
out.extend(["", "## Inline knowledge", "", *inline_knowledge(knowledge.get("inline"))])
|
|
510
912
|
out.extend(["", "## Hypotheses", "", *bullets(knowledge.get("hypotheses"))])
|
|
511
913
|
out.extend(["", "## Landmines", "", *bullets(knowledge.get("landmines"))])
|
|
@@ -514,32 +916,78 @@ def render_markdown(data: dict[str, Any]) -> str:
|
|
|
514
916
|
return "\n".join(out)
|
|
515
917
|
|
|
516
918
|
|
|
517
|
-
def inspect(
|
|
919
|
+
def inspect(
|
|
920
|
+
root: Path,
|
|
921
|
+
data: dict[str, Any],
|
|
922
|
+
check_human: bool = False,
|
|
923
|
+
expected_recipient: Optional[str] = None,
|
|
924
|
+
trusted_head: Optional[str] = None,
|
|
925
|
+
trusted_manifest_sha256: Optional[str] = None,
|
|
926
|
+
trusted_repository_url: Optional[str] = None,
|
|
927
|
+
receiver_context: bool = False,
|
|
928
|
+
) -> dict[str, Any]:
|
|
518
929
|
errors, warnings = validate(data)
|
|
519
930
|
if check_human:
|
|
520
931
|
human = root / HUMAN
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
errors.append(
|
|
932
|
+
try:
|
|
933
|
+
actual = read_bounded_text(human, HUMAN, MAX_HUMAN_BYTES)
|
|
934
|
+
except ValueError as exc:
|
|
935
|
+
errors.append(str(exc))
|
|
525
936
|
else:
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
except OSError as exc:
|
|
529
|
-
errors.append(f"cannot read {HUMAN}: {exc}")
|
|
530
|
-
else:
|
|
531
|
-
if actual != render_markdown(data):
|
|
532
|
-
errors.append(f"{HUMAN} does not match {MANIFEST}; regenerate it before trusting or consuming the relay")
|
|
937
|
+
if actual != render_markdown(data):
|
|
938
|
+
errors.append(f"{HUMAN} does not match {MANIFEST}; regenerate it before trusting or consuming the relay")
|
|
533
939
|
current = repository_snapshot(root)
|
|
940
|
+
for snapshot_error in current.pop("_errors", []):
|
|
941
|
+
errors.append(snapshot_error)
|
|
534
942
|
errors.extend(cross_machine_repository_errors(root, data))
|
|
535
943
|
recorded = data.get("repository") if isinstance(data.get("repository"), dict) else {}
|
|
536
944
|
route = data.get("route") if isinstance(data.get("route"), dict) else {}
|
|
945
|
+
recipient = route.get("recipient")
|
|
946
|
+
trust_required = False
|
|
947
|
+
cross_receiver = receiver_context and (data.get("schema") == 3 or recipient == "cross-machine")
|
|
948
|
+
if cross_receiver and expected_recipient is None:
|
|
949
|
+
errors.append("receiver must declare --expected-recipient cross-machine")
|
|
950
|
+
trust_required = True
|
|
951
|
+
if expected_recipient is not None and recipient != expected_recipient:
|
|
952
|
+
errors.append(
|
|
953
|
+
f"receiver expected recipient {expected_recipient}, artifact declares {recipient or 'none'}"
|
|
954
|
+
)
|
|
955
|
+
if receiver_context and (expected_recipient == "cross-machine" or data.get("schema") == 3):
|
|
956
|
+
if data.get("schema") != 3:
|
|
957
|
+
errors.append("receiver refuses legacy schema for cross-machine transfer")
|
|
958
|
+
if not nonempty(trusted_head) or not SHA_RE.fullmatch(str(trusted_head)):
|
|
959
|
+
errors.append("receiver must supply --trusted-head from a trusted channel")
|
|
960
|
+
trust_required = True
|
|
961
|
+
elif recorded.get("head") != trusted_head:
|
|
962
|
+
errors.append("artifact HEAD does not match receiver-supplied trusted HEAD")
|
|
963
|
+
if not nonempty(trusted_manifest_sha256) or not re.fullmatch(
|
|
964
|
+
r"[0-9a-f]{64}", str(trusted_manifest_sha256)
|
|
965
|
+
):
|
|
966
|
+
errors.append("receiver must supply --trusted-manifest-sha256 from a trusted channel")
|
|
967
|
+
trust_required = True
|
|
968
|
+
else:
|
|
969
|
+
try:
|
|
970
|
+
actual_digest = manifest_sha256(root)
|
|
971
|
+
except ValueError as exc:
|
|
972
|
+
errors.append(str(exc))
|
|
973
|
+
else:
|
|
974
|
+
if actual_digest != trusted_manifest_sha256:
|
|
975
|
+
errors.append("artifact does not match receiver-supplied trusted manifest digest")
|
|
976
|
+
recorded_remote = recorded.get("remote") if isinstance(recorded.get("remote"), dict) else {}
|
|
977
|
+
if not nonempty(trusted_repository_url) or sanitize_remote_url(str(trusted_repository_url)) != trusted_repository_url:
|
|
978
|
+
errors.append("receiver must supply --trusted-repository-url from a trusted channel")
|
|
979
|
+
trust_required = True
|
|
980
|
+
elif recorded_remote.get("url") != trusted_repository_url:
|
|
981
|
+
errors.append("artifact repository URL does not match receiver-supplied trusted URL")
|
|
982
|
+
else:
|
|
983
|
+
remote_error = receiver_repository_url_error(root, str(trusted_repository_url))
|
|
984
|
+
if remote_error:
|
|
985
|
+
errors.append(remote_error)
|
|
537
986
|
if route.get("recipient") == "cross-machine":
|
|
538
987
|
if current.get("dirty") is True:
|
|
539
988
|
errors.append("current cross-machine worktree is dirty; create a fresh relay after committing")
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
compared = ("head", "branch", "dirty", "diff_sha256")
|
|
989
|
+
compared = ("head", "dirty", "diff_sha256") if data.get("schema") == 3 and recipient == "cross-machine" \
|
|
990
|
+
else ("head", "branch", "dirty", "diff_sha256")
|
|
543
991
|
drift_fields = [key for key in compared if recorded.get(key) != current.get(key)]
|
|
544
992
|
if route.get("recipient") == "cross-machine" and drift_fields:
|
|
545
993
|
errors.append(
|
|
@@ -562,6 +1010,7 @@ def inspect(root: Path, data: dict[str, Any], check_human: bool = False) -> dict
|
|
|
562
1010
|
"recorded_repository": recorded,
|
|
563
1011
|
"current_repository": current,
|
|
564
1012
|
"next_step": state.get("next_step"),
|
|
1013
|
+
"trust_required": trust_required,
|
|
565
1014
|
"recipient": (
|
|
566
1015
|
data.get("route", {}).get("recipient")
|
|
567
1016
|
if isinstance(data.get("route"), dict)
|
|
@@ -570,6 +1019,24 @@ def inspect(root: Path, data: dict[str, Any], check_human: bool = False) -> dict
|
|
|
570
1019
|
}
|
|
571
1020
|
|
|
572
1021
|
|
|
1022
|
+
def manifest_sha256(root: Path) -> str:
|
|
1023
|
+
path = root / MANIFEST
|
|
1024
|
+
if path.is_symlink():
|
|
1025
|
+
raise ValueError(f"{MANIFEST} must not be a symlink")
|
|
1026
|
+
try:
|
|
1027
|
+
info = path.stat()
|
|
1028
|
+
if not stat.S_ISREG(info.st_mode):
|
|
1029
|
+
raise ValueError(f"{MANIFEST} must be a regular file")
|
|
1030
|
+
if info.st_size > MAX_MANIFEST_BYTES:
|
|
1031
|
+
raise ValueError(f"{MANIFEST} exceeds {MAX_MANIFEST_BYTES} bytes")
|
|
1032
|
+
payload = path.read_bytes()
|
|
1033
|
+
except FileNotFoundError as exc:
|
|
1034
|
+
raise ValueError(f"{MANIFEST} does not exist") from exc
|
|
1035
|
+
except OSError as exc:
|
|
1036
|
+
raise ValueError(f"cannot read {MANIFEST}: {exc}") from exc
|
|
1037
|
+
return hashlib.sha256(payload).hexdigest()
|
|
1038
|
+
|
|
1039
|
+
|
|
573
1040
|
def parser() -> argparse.ArgumentParser:
|
|
574
1041
|
top = argparse.ArgumentParser(description=__doc__)
|
|
575
1042
|
sub = top.add_subparsers(dest="command", required=True)
|
|
@@ -581,14 +1048,23 @@ def parser() -> argparse.ArgumentParser:
|
|
|
581
1048
|
choices=("same-machine", "cross-machine"),
|
|
582
1049
|
help="where the receiver will consume this relay (default: same-machine for legacy callers)",
|
|
583
1050
|
)
|
|
584
|
-
|
|
1051
|
+
draft_command.add_argument("--base", help="task base revision; required for cross-machine")
|
|
1052
|
+
for name in ("render", "validate", "envelope", "inspect"):
|
|
585
1053
|
command = sub.add_parser(name)
|
|
586
1054
|
command.add_argument("--root", default=".")
|
|
587
1055
|
if name == "inspect":
|
|
588
1056
|
command.add_argument("--json", action="store_true")
|
|
1057
|
+
command.add_argument("--expected-recipient", choices=("same-machine", "cross-machine"))
|
|
1058
|
+
command.add_argument("--trusted-head")
|
|
1059
|
+
command.add_argument("--trusted-manifest-sha256")
|
|
1060
|
+
command.add_argument("--trusted-repository-url")
|
|
589
1061
|
consume = sub.add_parser("consume")
|
|
590
1062
|
consume.add_argument("--root", default=".")
|
|
591
1063
|
consume.add_argument("--verified", action="store_true")
|
|
1064
|
+
consume.add_argument("--expected-recipient", choices=("same-machine", "cross-machine"))
|
|
1065
|
+
consume.add_argument("--trusted-head")
|
|
1066
|
+
consume.add_argument("--trusted-manifest-sha256")
|
|
1067
|
+
consume.add_argument("--trusted-repository-url")
|
|
592
1068
|
return top
|
|
593
1069
|
|
|
594
1070
|
|
|
@@ -596,14 +1072,33 @@ def main() -> int:
|
|
|
596
1072
|
args = parser().parse_args()
|
|
597
1073
|
root = Path(args.root).resolve()
|
|
598
1074
|
if args.command == "draft":
|
|
599
|
-
|
|
1075
|
+
try:
|
|
1076
|
+
data = draft(root, args.recipient, args.base)
|
|
1077
|
+
except ValueError as exc:
|
|
1078
|
+
print(f"relay: {exc}", file=sys.stderr)
|
|
1079
|
+
return 1
|
|
1080
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
600
1081
|
return 0
|
|
601
1082
|
try:
|
|
602
1083
|
data = load_manifest(root)
|
|
603
1084
|
except ValueError as exc:
|
|
604
1085
|
print(f"relay: {exc}", file=sys.stderr)
|
|
605
1086
|
return 1
|
|
606
|
-
|
|
1087
|
+
receiver_context = args.command in ("inspect", "consume")
|
|
1088
|
+
expected_recipient = getattr(args, "expected_recipient", None)
|
|
1089
|
+
trusted_head = getattr(args, "trusted_head", None)
|
|
1090
|
+
trusted_manifest_sha256 = getattr(args, "trusted_manifest_sha256", None)
|
|
1091
|
+
trusted_repository_url = getattr(args, "trusted_repository_url", None)
|
|
1092
|
+
result = inspect(
|
|
1093
|
+
root,
|
|
1094
|
+
data,
|
|
1095
|
+
check_human=receiver_context or args.command == "envelope",
|
|
1096
|
+
expected_recipient=expected_recipient,
|
|
1097
|
+
trusted_head=trusted_head,
|
|
1098
|
+
trusted_manifest_sha256=trusted_manifest_sha256,
|
|
1099
|
+
trusted_repository_url=trusted_repository_url,
|
|
1100
|
+
receiver_context=receiver_context,
|
|
1101
|
+
)
|
|
607
1102
|
if args.command == "validate":
|
|
608
1103
|
for message in result["errors"]:
|
|
609
1104
|
print(f"ERROR {message}", file=sys.stderr)
|
|
@@ -626,6 +1121,23 @@ def main() -> int:
|
|
|
626
1121
|
print(f"WARN {message}", file=sys.stderr)
|
|
627
1122
|
print(f"WROTE {root / HUMAN}")
|
|
628
1123
|
return 0
|
|
1124
|
+
if args.command == "envelope":
|
|
1125
|
+
repository = data.get("repository") if isinstance(data.get("repository"), dict) else {}
|
|
1126
|
+
remote = repository.get("remote") if isinstance(repository.get("remote"), dict) else {}
|
|
1127
|
+
result["errors"].extend(envelope_remote_errors(root, data))
|
|
1128
|
+
result["valid"] = not result["errors"]
|
|
1129
|
+
if not result["valid"] or data.get("schema") != 3 or result["recipient"] != "cross-machine":
|
|
1130
|
+
for message in result["errors"]:
|
|
1131
|
+
print(f"ERROR {message}", file=sys.stderr)
|
|
1132
|
+
print("relay: trusted envelope requires a valid rendered cross-machine schema 3 relay", file=sys.stderr)
|
|
1133
|
+
return 1
|
|
1134
|
+
print(json.dumps({
|
|
1135
|
+
"repository_url": remote.get("url"),
|
|
1136
|
+
"remote_ref": remote.get("ref"),
|
|
1137
|
+
"trusted_head": repository.get("head"),
|
|
1138
|
+
"trusted_manifest_sha256": manifest_sha256(root),
|
|
1139
|
+
}, ensure_ascii=False, indent=2))
|
|
1140
|
+
return 0
|
|
629
1141
|
if args.command == "inspect":
|
|
630
1142
|
if args.json:
|
|
631
1143
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
@@ -640,16 +1152,18 @@ def main() -> int:
|
|
|
640
1152
|
print(f"WARN {message}")
|
|
641
1153
|
for message in result["errors"]:
|
|
642
1154
|
print(f"ERROR {message}")
|
|
643
|
-
|
|
1155
|
+
if result["valid"]:
|
|
1156
|
+
return 0
|
|
1157
|
+
return 2 if result["trust_required"] else 1
|
|
1158
|
+
if not result["valid"]:
|
|
1159
|
+
print("relay: refuse to consume an invalid relay", file=sys.stderr)
|
|
1160
|
+
return 2 if result["trust_required"] else 1
|
|
644
1161
|
if not args.verified:
|
|
645
1162
|
print("relay: refuse to consume without --verified after re-running evidence", file=sys.stderr)
|
|
646
1163
|
return 2
|
|
647
|
-
|
|
648
|
-
print("relay: refuse to consume an invalid relay", file=sys.stderr)
|
|
649
|
-
return 1
|
|
650
|
-
for name in (HUMAN, MANIFEST):
|
|
1164
|
+
for name in (RECEIPT, HUMAN, MANIFEST):
|
|
651
1165
|
path = root / name
|
|
652
|
-
if path.exists():
|
|
1166
|
+
if path.exists() or path.is_symlink():
|
|
653
1167
|
path.unlink()
|
|
654
1168
|
print(f"CONSUMED {path}")
|
|
655
1169
|
return 0
|