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.
@@ -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
- proc = subprocess.run(
38
- ["git", "-C", str(root), *args],
39
- text=True,
40
- stdout=subprocess.PIPE,
41
- stderr=subprocess.DEVNULL,
42
- errors="surrogateescape",
43
- check=False,
44
- )
45
- return proc.returncode, proc.stdout.rstrip("\n")
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 = ("--", ".", f":(exclude){MANIFEST}", f":(exclude){HUMAN}")
97
+ exclusions = (
98
+ "--", ".", f":(exclude){MANIFEST}", f":(exclude){HUMAN}",
99
+ f":(exclude){RECEIPT}",
100
+ )
62
101
  if head_rc == 0:
63
- _, raw_modified = git(repo, "diff", "--name-only", "-z", "HEAD", *exclusions)
64
- _, diff = git(repo, "diff", "--binary", "HEAD", *exclusions)
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
- _, raw_staged = git(repo, "diff", "--cached", "--name-only", "-z", *exclusions)
67
- _, raw_unstaged = git(repo, "diff", "--name-only", "-z", *exclusions)
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
- _, staged_diff = git(repo, "diff", "--cached", "--binary", "--root", *exclusions)
70
- _, unstaged_diff = git(repo, "diff", "--binary", *exclusions)
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
- _, raw_untracked = git(repo, "ls-files", "--others", "--exclude-standard", "-z")
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") if item and item not in (MANIFEST, HUMAN)
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
- _, remote_refs = git(
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 draft(root: Path, recipient: str) -> dict[str, Any]:
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": 2,
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 load_manifest(root: Path) -> dict[str, Any]:
148
- path = root / MANIFEST
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"{MANIFEST} must not be a symlink")
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"{MANIFEST} does not exist") from exc
155
- except (OSError, json.JSONDecodeError) as exc:
156
- raise ValueError(f"cannot read {MANIFEST}: {exc}") from exc
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"{MANIFEST} must contain a JSON object")
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
- if isinstance(value, str):
168
- yield value
169
- elif isinstance(value, dict):
170
- for nested in value.values():
171
- yield from string_values(nested)
172
- elif isinstance(value, list):
173
- for nested in value:
174
- yield from string_values(nested)
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
- rc, _ = git(root, "cat-file", "-e", f"{head}:{path}")
231
- if rc != 0:
232
- errors.append(
233
- f"knowledge.read_first[{index}] path is not present in the pushed relay commit: {path}"
234
- )
235
- continue
236
- _, entry = git(root, "ls-tree", str(head), "--", path)
237
- if entry.startswith("120000 "):
238
- errors.append(
239
- f"knowledge.read_first[{index}] must not use a symlink for cross-machine delivery: {path}"
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 2")
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 == 2:
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 == 2 and not isinstance(inline, list):
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 == 2:
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 recipient == "cross-machine":
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 not repository.get("known_remote_refs"):
390
- errors.append(
391
- "cross-machine relay HEAD is not reachable from a locally known remote branch; push it before routing"
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") == 2:
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") == 2:
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") == 2:
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(root: Path, data: dict[str, Any], check_human: bool = False) -> dict[str, Any]:
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
- if human.is_symlink():
522
- errors.append(f"{HUMAN} must not be a symlink")
523
- elif not human.is_file():
524
- errors.append(f"{HUMAN} is missing; render it from the canonical manifest")
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
- try:
527
- actual = human.read_text(encoding="utf-8")
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
- if not current.get("known_remote_refs"):
541
- errors.append("current cross-machine HEAD is not reachable from a locally known remote branch")
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
- for name in ("render", "validate", "inspect"):
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
- print(json.dumps(draft(root, args.recipient), ensure_ascii=False, indent=2))
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
- result = inspect(root, data, check_human=args.command in ("inspect", "consume"))
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
- return 0 if result["valid"] else 1
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
- if not result["valid"]:
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