zcode-supervisor 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,696 @@
1
+ #!/usr/bin/env python3
2
+ """Safety and quality gate for Codex-managed ZCode work."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import shlex
11
+ import struct
12
+ import subprocess
13
+ import sys
14
+ import zlib
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ try:
21
+ from .auto_route import add_auto_route_parser
22
+ from .repo_setup import install_repo_command
23
+ except ImportError: # pragma: no cover - direct script execution
24
+ from auto_route import add_auto_route_parser
25
+ from repo_setup import install_repo_command
26
+
27
+ VERSION = 1
28
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", "dist", "build", "coverage"}
29
+ SECRET_PATH_NEEDLES = (".env", "id_rsa", "id_ed25519", ".ssh", "credential", "credentials")
30
+ TASK_CLASSES = (
31
+ "small-fix",
32
+ "long-horizon",
33
+ "architecture",
34
+ "root-cause",
35
+ "production-gate",
36
+ "mobile-debug",
37
+ "research",
38
+ )
39
+ WORKSPACE_KINDS = ("regular", "worktree", "disposable", "fixture")
40
+ FULL_ACCESS_SAFE_WORKSPACE_KINDS = {"worktree", "disposable", "fixture"}
41
+ RISK_BUDGETS = ("low", "medium", "high")
42
+ DEFAULT_CONTEXT_POLICY = (
43
+ "Use targeted reads and file references first. Do not paste or request the "
44
+ "whole repository unless the task class requires project-level inventory."
45
+ )
46
+ SECRET_PATTERNS = (
47
+ re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[^'\"\s]{12,}"),
48
+ re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
49
+ )
50
+ PROVIDER_OVERLOAD_PATTERN = re.compile(r"temporarily overloaded|try again later|overloaded_error", re.I)
51
+ MAX_HASH_BYTES = 1_000_000
52
+ MAX_COLOR_SAMPLE_BYTES = 50_000_000
53
+ MAX_COLOR_SAMPLE_DECOMPRESSED_BYTES = 50_000_000
54
+ DEFAULT_VISION_SERVICE = "zai-mcp-server"
55
+ IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
56
+ COLOR_SAMPLE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Snapshot:
61
+ workspace: Path
62
+ files: dict[str, dict[str, Any]]
63
+ skipped_secret_files: list[str]
64
+ skipped_large_files: list[str]
65
+
66
+
67
+ def utc_now() -> str:
68
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
69
+
70
+
71
+ def emit_json(payload: dict[str, Any]) -> None:
72
+ sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + "\n")
73
+
74
+
75
+ def fail(message: str) -> int:
76
+ emit_json({"ok": False, "error": message})
77
+ return 1
78
+
79
+
80
+ def inside_workspace(workspace: Path, path: Path) -> bool:
81
+ try:
82
+ path.resolve().relative_to(workspace.resolve())
83
+ return True
84
+ except ValueError:
85
+ return False
86
+
87
+
88
+ def is_secret_path(path: Path) -> bool:
89
+ lowered = str(path).lower()
90
+ return any(needle in lowered for needle in SECRET_PATH_NEEDLES)
91
+
92
+
93
+ def normalize_rel_path(workspace: Path, raw: str) -> str:
94
+ candidate = Path(raw)
95
+ absolute = candidate if candidate.is_absolute() else workspace / candidate
96
+ absolute = absolute.resolve()
97
+ if not inside_workspace(workspace, absolute):
98
+ raise ValueError(f"path escapes workspace: {raw}")
99
+ rel = absolute.relative_to(workspace.resolve()).as_posix()
100
+ if is_secret_path(Path(rel)):
101
+ raise ValueError(f"secret-like path is not allowed: {raw}")
102
+ return rel
103
+
104
+
105
+ def normalize_vision_image(workspace: Path, raw: str) -> str:
106
+ rel = normalize_rel_path(workspace, raw)
107
+ path = workspace / rel
108
+ if not path.is_file():
109
+ raise ValueError(f"vision image does not exist: {raw}")
110
+ if path.suffix.lower() not in IMAGE_EXTENSIONS:
111
+ raise ValueError(f"vision image must be an image file: {raw}")
112
+ return rel
113
+
114
+
115
+ def parse_color_sample(workspace: Path, raw: str) -> dict[str, Any]:
116
+ if "=" not in raw or "@" not in raw:
117
+ raise ValueError("vision color sample must use name=image.png@x,y")
118
+ name, target = raw.split("=", 1)
119
+ image_raw, coords = target.rsplit("@", 1)
120
+ name = name.strip()
121
+ if not COLOR_SAMPLE_NAME_PATTERN.match(name):
122
+ raise ValueError(f"invalid vision color sample name: {name}")
123
+ rel = normalize_vision_image(workspace, image_raw.strip())
124
+ try:
125
+ x_raw, y_raw = coords.split(",", 1)
126
+ x = int(x_raw)
127
+ y = int(y_raw)
128
+ except ValueError as exc:
129
+ raise ValueError(f"invalid vision color sample coordinates: {raw}") from exc
130
+ if x < 0 or y < 0:
131
+ raise ValueError(f"vision color sample coordinates must be non-negative: {raw}")
132
+ rgba = read_png_pixel(workspace / rel, x, y)
133
+ return {
134
+ "name": name,
135
+ "image": rel,
136
+ "x": x,
137
+ "y": y,
138
+ "hex": f"#{rgba[0]:02X}{rgba[1]:02X}{rgba[2]:02X}",
139
+ "rgba": list(rgba),
140
+ }
141
+
142
+
143
+ def read_png_pixel(path: Path, x: int, y: int) -> tuple[int, int, int, int]:
144
+ if path.stat().st_size > MAX_COLOR_SAMPLE_BYTES:
145
+ raise ValueError(f"vision color sample image is too large: {path.name}")
146
+ data = path.read_bytes()
147
+ if not data.startswith(b"\x89PNG\r\n\x1a\n"):
148
+ raise ValueError(f"vision color samples currently require PNG files: {path.name}")
149
+ offset = 8
150
+ width = height = bit_depth = color_type = interlace = None
151
+ idat_parts: list[bytes] = []
152
+ while offset < len(data):
153
+ if offset + 8 > len(data):
154
+ raise ValueError(f"invalid PNG chunk header: {path.name}")
155
+ length = struct.unpack(">I", data[offset:offset + 4])[0]
156
+ kind = data[offset + 4:offset + 8]
157
+ chunk_end = offset + 12 + length
158
+ if chunk_end > len(data):
159
+ raise ValueError(f"truncated PNG chunk: {path.name}")
160
+ chunk = data[offset + 8:offset + 8 + length]
161
+ offset = chunk_end
162
+ if kind == b"IHDR":
163
+ if length != 13:
164
+ raise ValueError(f"invalid PNG IHDR chunk: {path.name}")
165
+ width, height, bit_depth, color_type, _compression, _filter, interlace = struct.unpack(">IIBBBBB", chunk)
166
+ elif kind == b"IDAT":
167
+ idat_parts.append(chunk)
168
+ elif kind == b"IEND":
169
+ break
170
+ if None in {width, height, bit_depth, color_type, interlace} or not idat_parts:
171
+ raise ValueError(f"invalid PNG data: {path.name}")
172
+ if width <= 0 or height <= 0:
173
+ raise ValueError(f"invalid PNG dimensions: {path.name}")
174
+ if bit_depth != 8 or color_type not in {2, 6} or interlace != 0:
175
+ raise ValueError("vision color samples support non-interlaced 8-bit RGB/RGBA PNG files only")
176
+ if x >= width or y >= height:
177
+ raise ValueError(f"vision color sample outside image bounds: {path.name}@{x},{y}")
178
+ channels = 4 if color_type == 6 else 3
179
+ row_size = width * channels
180
+ expected_size = (row_size + 1) * height
181
+ if expected_size > MAX_COLOR_SAMPLE_DECOMPRESSED_BYTES:
182
+ raise ValueError(f"vision color sample image is too large after decompression: {path.name}")
183
+ decompressor = zlib.decompressobj()
184
+ try:
185
+ raw = decompressor.decompress(b"".join(idat_parts), expected_size)
186
+ except zlib.error as exc:
187
+ raise ValueError(f"invalid PNG image data: {path.name}") from exc
188
+ if decompressor.unconsumed_tail:
189
+ raise ValueError(f"vision color sample image exceeds decompression limit: {path.name}")
190
+ if not decompressor.eof:
191
+ raise ValueError(f"truncated PNG image data: {path.name}")
192
+ if len(raw) < expected_size:
193
+ raise ValueError(f"truncated PNG image data: {path.name}")
194
+ rows: list[bytearray] = []
195
+ cursor = 0
196
+ for _row_index in range(height):
197
+ filter_type = raw[cursor]
198
+ cursor += 1
199
+ row = bytearray(raw[cursor:cursor + row_size])
200
+ cursor += row_size
201
+ previous = rows[-1] if rows else bytearray(row_size)
202
+ unfilter_png_row(row, previous, filter_type, channels)
203
+ rows.append(row)
204
+ index = x * channels
205
+ row = rows[y]
206
+ alpha = row[index + 3] if channels == 4 else 255
207
+ return row[index], row[index + 1], row[index + 2], alpha
208
+
209
+
210
+ def unfilter_png_row(row: bytearray, previous: bytearray, filter_type: int, channels: int) -> None:
211
+ if filter_type == 0:
212
+ return
213
+ for index, value in enumerate(row):
214
+ left = row[index - channels] if index >= channels else 0
215
+ up = previous[index]
216
+ up_left = previous[index - channels] if index >= channels else 0
217
+ if filter_type == 1:
218
+ row[index] = (value + left) & 0xFF
219
+ elif filter_type == 2:
220
+ row[index] = (value + up) & 0xFF
221
+ elif filter_type == 3:
222
+ row[index] = (value + ((left + up) // 2)) & 0xFF
223
+ elif filter_type == 4:
224
+ row[index] = (value + paeth(left, up, up_left)) & 0xFF
225
+ else:
226
+ raise ValueError(f"unsupported PNG filter type: {filter_type}")
227
+
228
+
229
+ def paeth(left: int, up: int, up_left: int) -> int:
230
+ estimate = left + up - up_left
231
+ left_distance = abs(estimate - left)
232
+ up_distance = abs(estimate - up)
233
+ up_left_distance = abs(estimate - up_left)
234
+ if left_distance <= up_distance and left_distance <= up_left_distance:
235
+ return left
236
+ if up_distance <= up_left_distance:
237
+ return up
238
+ return up_left
239
+
240
+
241
+ def read_json(path: Path) -> dict[str, Any]:
242
+ return json.loads(path.read_text(encoding="utf-8"))
243
+
244
+
245
+ def write_json(path: Path, payload: dict[str, Any]) -> None:
246
+ path.parent.mkdir(parents=True, exist_ok=True)
247
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
248
+
249
+
250
+ def approximate_tokens(text: str) -> int:
251
+ return (len(text) + 3) // 4
252
+
253
+
254
+ def non_negative_int(raw: str) -> int:
255
+ value = int(raw)
256
+ if value < 0:
257
+ raise argparse.ArgumentTypeError("value must be non-negative")
258
+ return value
259
+
260
+
261
+ def validation_danger_reason(command: str) -> str | None:
262
+ try:
263
+ argv = shlex.split(command)
264
+ except ValueError as exc:
265
+ return f"invalid validation command: {exc}"
266
+ if not argv:
267
+ return "empty validation command"
268
+ executable = Path(argv[0]).name.lower()
269
+ lowered = " ".join(argv).lower()
270
+ if executable in {"sudo", "su"}:
271
+ return f"unsafe validation command: {argv[0]}"
272
+ if executable == "rm" and any(item.startswith("-") and "r" in item.lower() for item in argv[1:]):
273
+ return "unsafe validation command: rm recursive delete"
274
+ if executable == "git" and len(argv) > 1 and argv[1] in {"clean", "reset", "checkout", "restore"}:
275
+ return f"unsafe validation command: git {argv[1]}"
276
+ if executable == "chmod" and any("777" in item for item in argv[1:]):
277
+ return "unsafe validation command: chmod 777"
278
+ if executable in {"bash", "sh", "zsh"} and any(item in {"-c", "-lc"} for item in argv[1:]):
279
+ destructive_shell = r"\brm\s+-[^\s]*r|\bgit\s+(clean|reset|checkout|restore)\b|\bchmod\s+777\b"
280
+ if re.search(destructive_shell, lowered):
281
+ return "unsafe validation shell command"
282
+ return None
283
+
284
+
285
+ def first_regex_group(text: str, patterns: tuple[str, ...]) -> str | None:
286
+ for pattern in patterns:
287
+ match = re.search(pattern, text)
288
+ if match:
289
+ return match.group(1)
290
+ return None
291
+
292
+
293
+ def classify_provider_error(*, stdout: str = "", stderr: str = "", exit_code: int | None = None) -> dict[str, Any]:
294
+ text = f"{stderr}\n{stdout}"
295
+ provider_code = first_regex_group(
296
+ text,
297
+ (
298
+ r"providerCode:\s*['\"]?(\d+)['\"]?",
299
+ r'"providerCode"\s*:\s*"(\d+)"',
300
+ r"\[(\d{3,})\]\[",
301
+ r"\bcode:\s*['\"]?(\d{3,})['\"]?",
302
+ r'"code"\s*:\s*"(\d{3,})"',
303
+ ),
304
+ )
305
+ temporary = bool(PROVIDER_OVERLOAD_PATTERN.search(text)) or provider_code == "1305"
306
+ exit_143 = exit_code == 143
307
+ provider_error = bool(
308
+ re.search(r"ProviderBusinessError|PROVIDER_BUSINESS_ERROR|isProviderBusinessError:\s*true", text)
309
+ or temporary
310
+ or provider_code == "1305"
311
+ or exit_143
312
+ )
313
+ provider_message = first_regex_group(
314
+ text,
315
+ (
316
+ r"providerMessage:\s*'([^']+)'",
317
+ r'providerMessage:\s*"([^"]+)"',
318
+ r'"providerMessage"\s*:\s*"([^"]+)"',
319
+ r"ProviderBusinessError:\s*([^\n]+)",
320
+ ),
321
+ )
322
+ if provider_message is None and exit_143:
323
+ provider_message = "ZCode CLI exited with code 143"
324
+ return {
325
+ "provider_error": provider_error,
326
+ "provider_code": provider_code,
327
+ "provider_message": provider_message,
328
+ "provider_id": first_regex_group(text, (r"providerId:\s*'([^']+)'", r'providerId:\s*"([^"]+)"', r'"providerId"\s*:\s*"([^"]+)"')),
329
+ "provider_kind": first_regex_group(text, (r"providerKind:\s*'([^']+)'", r'providerKind:\s*"([^"]+)"', r'"providerKind"\s*:\s*"([^"]+)"')),
330
+ "provider_error_temporary": temporary,
331
+ "retryable_provider_error": provider_error and (temporary or exit_143),
332
+ }
333
+
334
+
335
+ def classify_provider_run_state(provider: dict[str, Any], audit: dict[str, Any] | None) -> dict[str, Any]:
336
+ if not provider.get("provider_error"):
337
+ return {"supervisor_state": "cli_error", "partial_artifacts_possible": False, "safe_to_retry_later": False}
338
+ changed_count = audit.get("changed_count") if audit else None
339
+ if changed_count == 0 and provider.get("retryable_provider_error"):
340
+ return {"supervisor_state": "retryable_provider_error", "partial_artifacts_possible": False, "safe_to_retry_later": True}
341
+ if isinstance(changed_count, int) and changed_count > 0 and audit and audit.get("ok") is True:
342
+ return {"supervisor_state": "partial_success", "partial_artifacts_possible": True, "safe_to_retry_later": False}
343
+ partial_possible = changed_count is None or not isinstance(changed_count, int) or changed_count > 0
344
+ return {"supervisor_state": "unsafe_partial", "partial_artifacts_possible": partial_possible, "safe_to_retry_later": False}
345
+
346
+
347
+ def make_prompt(packet: dict[str, Any]) -> str:
348
+ allowed = ", ".join(packet["allowed_files"]) or "NONE"
349
+ forbidden = ", ".join(packet["forbidden_files"]) or "secrets, .env*, credentials, files outside workspace"
350
+ max_changed = packet["max_changed_files"] or "not set"
351
+ prefix = "/goal " if packet["goal"] else ""
352
+ vision = packet.get("vision") if isinstance(packet.get("vision"), dict) else {}
353
+ vision_block = ""
354
+ if vision.get("required"):
355
+ images = ", ".join(vision.get("image_files") or []) or "runtime screenshots or attached images"
356
+ service = vision.get("service") or DEFAULT_VISION_SERVICE
357
+ samples = vision.get("color_samples") or []
358
+ sample_lines = ""
359
+ if samples:
360
+ rendered = "\n".join(
361
+ f"- {sample['name']}: {sample['image']}@{sample['x']},{sample['y']} = {sample['hex']}"
362
+ for sample in samples
363
+ )
364
+ sample_lines = (
365
+ "Deterministic color samples, use these exact values instead of estimating sampled colors:\n"
366
+ f"{rendered}\n"
367
+ )
368
+ vision_block = (
369
+ "Vision/image policy:\n"
370
+ "- GLM-5.2 is text-only; do not guess from image filenames or surrounding text.\n"
371
+ f"- Use ZCode's built-in image service/MCP before relying on image details. Preferred service: {service}.\n"
372
+ f"- Required image context: {images}.\n"
373
+ f"{sample_lines}"
374
+ "- If reporting colors as hex, normalize them as uppercase #RRGGBB unless the task says otherwise.\n"
375
+ "- If image understanding is unavailable, stop and report vision_service_unavailable.\n\n"
376
+ )
377
+ return (
378
+ f"{prefix}You are a ZCode worker under Codex audit.\n"
379
+ f"Workspace: {packet['workspace']}\n"
380
+ f"Workspace kind: {packet['workspace_kind']}\n"
381
+ f"Objective: {packet['objective']}\n"
382
+ f"GLM-5.2 task class: {packet['task_class']}\n"
383
+ f"GLM-5.2 effort: {packet['effort']}\n"
384
+ f"Risk budget: {packet['risk_budget']}\n"
385
+ f"Max changed files: {max_changed}\n"
386
+ f"Context policy: {packet['context_policy']}\n"
387
+ f"Allowed files: {allowed}\n"
388
+ f"Forbidden files: {forbidden}\n"
389
+ f"Validation: {packet['validation']} (run by Codex supervisor after the ZCode turn)\n\n"
390
+ f"{vision_block}"
391
+ "Rules:\n"
392
+ "- Read only the minimum files needed.\n"
393
+ "- Use project-wide context only to preserve architecture, call chains, "
394
+ "interfaces, or standards that affect the task.\n"
395
+ "- For root-cause tasks, analyze the call chain and regression surface before editing.\n"
396
+ "- For production-gate tasks, enforce style, dependency, test, and commit-boundary constraints.\n"
397
+ "- If the task needs broader risk than the packet allows, stop and report.\n"
398
+ "- Do not inspect or edit secrets, credentials, .env*, or files outside the workspace.\n"
399
+ "- Do not edit tests unless they are explicitly in Allowed files.\n"
400
+ "- Prefer the smallest fix that satisfies the objective.\n"
401
+ "- Do not run the validation command yourself; Codex supervisor runs it after your response.\n\n"
402
+ "Final report: changed files, validation result, remaining risks, accept/inspect/reject recommendation."
403
+ )
404
+
405
+
406
+ def packet_command(args: argparse.Namespace) -> int:
407
+ workspace = args.workspace.resolve()
408
+ if not workspace.is_dir():
409
+ return fail(f"workspace does not exist: {workspace}")
410
+ try:
411
+ allowed = [normalize_rel_path(workspace, item) for item in args.allowed]
412
+ forbidden = [normalize_rel_path(workspace, item) for item in args.forbidden]
413
+ vision_images = [normalize_vision_image(workspace, item) for item in args.vision_image]
414
+ color_samples = [parse_color_sample(workspace, item) for item in args.vision_color_sample]
415
+ except ValueError as exc:
416
+ return fail(str(exc))
417
+ if (
418
+ args.mode == "Full Access"
419
+ and args.workspace_kind not in FULL_ACCESS_SAFE_WORKSPACE_KINDS
420
+ and not args.allow_regular_full_access
421
+ ):
422
+ return fail("Full Access requires --workspace-kind worktree, disposable, or fixture")
423
+ danger = validation_danger_reason(args.validation)
424
+ if danger:
425
+ return fail(danger)
426
+ vision_service = args.vision_service.strip() or DEFAULT_VISION_SERVICE
427
+ sampled_images = [sample["image"] for sample in color_samples]
428
+ image_files = sorted(set(vision_images + sampled_images))
429
+
430
+ packet = {
431
+ "version": VERSION,
432
+ "created_at": utc_now(),
433
+ "workspace": str(workspace),
434
+ "workspace_kind": args.workspace_kind,
435
+ "objective": args.objective.strip(),
436
+ "allowed_files": sorted(set(allowed)),
437
+ "forbidden_files": sorted(set(forbidden)),
438
+ "validation": args.validation.strip(),
439
+ "mode": args.mode,
440
+ "effort": args.effort,
441
+ "task_class": args.task_class,
442
+ "risk_budget": args.risk_budget,
443
+ "max_changed_files": args.max_changed_files,
444
+ "context_policy": args.context_policy.strip(),
445
+ "goal": args.goal,
446
+ "vision": {
447
+ "required": bool(args.vision_required or image_files or color_samples),
448
+ "service": vision_service,
449
+ "image_files": image_files,
450
+ "color_samples": color_samples,
451
+ "model_limit": "GLM-5.2 is text-only; use ZCode image service for visual understanding.",
452
+ },
453
+ }
454
+ prompt = make_prompt(packet)
455
+ packet["prompt"] = prompt
456
+ packet["prompt_chars"] = len(prompt)
457
+ packet["approx_prompt_tokens"] = approximate_tokens(prompt)
458
+ if packet["prompt_chars"] > args.max_prompt_chars:
459
+ return fail(f"prompt exceeds max chars: {packet['prompt_chars']} > {args.max_prompt_chars}")
460
+ write_json(args.out, packet)
461
+ if args.prompt_out:
462
+ args.prompt_out.parent.mkdir(parents=True, exist_ok=True)
463
+ args.prompt_out.write_text(prompt, encoding="utf-8")
464
+ emit_json(packet)
465
+ return 0
466
+
467
+
468
+ def should_skip(path: Path) -> bool:
469
+ return any(part in SKIP_DIRS for part in path.parts)
470
+
471
+
472
+ def hash_file(path: Path) -> str:
473
+ digest = hashlib.sha256()
474
+ with path.open("rb") as handle:
475
+ for chunk in iter(lambda: handle.read(65536), b""):
476
+ digest.update(chunk)
477
+ return digest.hexdigest()
478
+
479
+
480
+ def build_snapshot(workspace: Path) -> Snapshot:
481
+ files: dict[str, dict[str, Any]] = {}
482
+ skipped_secret_files: list[str] = []
483
+ skipped_large_files: list[str] = []
484
+ for path in sorted(workspace.rglob("*")):
485
+ if not path.is_file() or should_skip(path.relative_to(workspace)):
486
+ continue
487
+ rel = path.relative_to(workspace).as_posix()
488
+ if is_secret_path(Path(rel)):
489
+ skipped_secret_files.append(rel)
490
+ continue
491
+ size = path.stat().st_size
492
+ if size > MAX_HASH_BYTES:
493
+ skipped_large_files.append(rel)
494
+ continue
495
+ files[rel] = {"sha256": hash_file(path), "size": size}
496
+ return Snapshot(workspace, files, skipped_secret_files, skipped_large_files)
497
+
498
+
499
+ def snapshot_command(args: argparse.Namespace) -> int:
500
+ workspace = args.workspace.resolve()
501
+ if not workspace.is_dir():
502
+ return fail(f"workspace does not exist: {workspace}")
503
+ snapshot = build_snapshot(workspace)
504
+ payload = {
505
+ "version": VERSION,
506
+ "created_at": utc_now(),
507
+ "workspace": str(snapshot.workspace),
508
+ "files": snapshot.files,
509
+ "skipped_secret_files": snapshot.skipped_secret_files,
510
+ "skipped_large_files": snapshot.skipped_large_files,
511
+ }
512
+ write_json(args.out, payload)
513
+ emit_json(payload)
514
+ return 0
515
+
516
+
517
+ def compare_files(before: dict[str, Any], after: dict[str, Any]) -> dict[str, list[str]]:
518
+ before_keys = set(before)
519
+ after_keys = set(after)
520
+ modified = sorted(rel for rel in before_keys & after_keys if before[rel]["sha256"] != after[rel]["sha256"])
521
+ return {
522
+ "added": sorted(after_keys - before_keys),
523
+ "deleted": sorted(before_keys - after_keys),
524
+ "modified": modified,
525
+ }
526
+
527
+
528
+ def scan_changed_files(workspace: Path, changed: list[str]) -> list[dict[str, str]]:
529
+ findings: list[dict[str, str]] = []
530
+ for rel in changed:
531
+ path = workspace / rel
532
+ if not path.exists() or is_secret_path(Path(rel)) or path.stat().st_size > MAX_HASH_BYTES:
533
+ continue
534
+ text = path.read_text(encoding="utf-8", errors="ignore")
535
+ for pattern in SECRET_PATTERNS:
536
+ if pattern.search(text):
537
+ findings.append({"file": rel, "type": "secret_pattern"})
538
+ break
539
+ return findings
540
+
541
+
542
+ def run_validation(workspace: Path, command: str, timeout: int) -> dict[str, Any]:
543
+ try:
544
+ argv = shlex.split(command)
545
+ except ValueError as exc:
546
+ return {"ok": False, "returncode": 127, "error": f"invalid validation command: {exc}"}
547
+ if not argv:
548
+ return {"ok": False, "returncode": 127, "error": "empty validation command"}
549
+ try:
550
+ result = subprocess.run(
551
+ argv,
552
+ cwd=workspace,
553
+ text=True,
554
+ capture_output=True,
555
+ timeout=timeout,
556
+ check=False,
557
+ )
558
+ except FileNotFoundError:
559
+ return {"ok": False, "returncode": 127, "error": f"command not found: {argv[0]}"}
560
+ except subprocess.TimeoutExpired as exc:
561
+ return {"ok": False, "returncode": 124, "error": f"validation timed out after {timeout}s", "stdout": exc.stdout or "", "stderr": exc.stderr or ""}
562
+ return {
563
+ "ok": result.returncode == 0,
564
+ "returncode": result.returncode,
565
+ "stdout_tail": result.stdout[-4000:],
566
+ "stderr_tail": result.stderr[-4000:],
567
+ }
568
+
569
+
570
+ def audit_command(args: argparse.Namespace) -> int:
571
+ workspace = args.workspace.resolve()
572
+ packet = read_json(args.packet)
573
+ before = read_json(args.snapshot)
574
+ after_snapshot = build_snapshot(workspace)
575
+ changes = compare_files(before["files"], after_snapshot.files)
576
+ changed = sorted(changes["added"] + changes["deleted"] + changes["modified"])
577
+ allowed = set(packet.get("allowed_files", []))
578
+ forbidden = set(packet.get("forbidden_files", []))
579
+ violations: list[dict[str, Any]] = []
580
+
581
+ if str(workspace) != str(Path(packet.get("workspace", "")).resolve()):
582
+ violations.append({"type": "packet_workspace_mismatch", "packet_workspace": packet.get("workspace")})
583
+ if str(workspace) != str(Path(before.get("workspace", "")).resolve()):
584
+ violations.append({"type": "snapshot_workspace_mismatch", "snapshot_workspace": before.get("workspace")})
585
+
586
+ if not allowed:
587
+ violations.append({"type": "missing_allowed_files", "message": "packet must declare allowed files"})
588
+ outside_allowed = [rel for rel in changed if rel not in allowed]
589
+ if outside_allowed:
590
+ violations.append({"type": "outside_allowed_files", "files": outside_allowed})
591
+ max_changed_files = packet.get("max_changed_files", 0)
592
+ if (
593
+ isinstance(max_changed_files, int)
594
+ and max_changed_files > 0
595
+ and len(changed) > max_changed_files
596
+ ):
597
+ violations.append(
598
+ {"type": "max_changed_files_exceeded", "limit": max_changed_files, "actual": len(changed)}
599
+ )
600
+ forbidden_changed = [rel for rel in changed if rel in forbidden]
601
+ if forbidden_changed:
602
+ violations.append({"type": "forbidden_files_changed", "files": forbidden_changed})
603
+ secret_findings = scan_changed_files(workspace, changed)
604
+ if secret_findings:
605
+ violations.append({"type": "secret_pattern", "findings": secret_findings})
606
+
607
+ validation_command = packet.get("validation", "")
608
+ danger = validation_danger_reason(validation_command)
609
+ if danger:
610
+ validation = {"ok": False, "returncode": 127, "error": danger}
611
+ violations.append({"type": "unsafe_validation_command", "message": danger})
612
+ else:
613
+ validation = run_validation(workspace, validation_command, args.validation_timeout)
614
+ if not validation["ok"]:
615
+ violations.append({"type": "validation_failed", "returncode": validation.get("returncode")})
616
+
617
+ payload = {
618
+ "ok": not violations,
619
+ "workspace": str(workspace),
620
+ "packet": str(args.packet),
621
+ "changed_files": changes,
622
+ "changed_count": len(changed),
623
+ "validation": validation,
624
+ "violations": violations,
625
+ "skipped_secret_files": after_snapshot.skipped_secret_files,
626
+ "skipped_large_files": after_snapshot.skipped_large_files,
627
+ }
628
+ emit_json(payload)
629
+ return 0 if payload["ok"] else 1
630
+
631
+
632
+ def build_parser() -> argparse.ArgumentParser:
633
+ parser = argparse.ArgumentParser(description="Supervise Codex-managed ZCode work.")
634
+ subparsers = parser.add_subparsers(dest="command", required=True)
635
+
636
+ packet = subparsers.add_parser("packet", help="Create a compact ZCode task packet.")
637
+ packet.add_argument("--workspace", type=Path, required=True)
638
+ packet.add_argument("--objective", required=True)
639
+ packet.add_argument("--allowed", action="append", default=[])
640
+ packet.add_argument("--forbidden", action="append", default=[])
641
+ packet.add_argument("--validation", required=True)
642
+ packet.add_argument("--mode", choices=("Plan", "Auto Edit", "Full Access", "Confirm Before Changes"), default="Auto Edit")
643
+ packet.add_argument("--workspace-kind", choices=WORKSPACE_KINDS, default="regular")
644
+ packet.add_argument("--allow-regular-full-access", action="store_true")
645
+ packet.add_argument("--effort", choices=("high", "max"), default="max")
646
+ packet.add_argument("--task-class", choices=TASK_CLASSES, default="small-fix")
647
+ packet.add_argument("--risk-budget", choices=RISK_BUDGETS, default="low")
648
+ packet.add_argument("--max-changed-files", type=non_negative_int, default=0)
649
+ packet.add_argument("--context-policy", default=DEFAULT_CONTEXT_POLICY)
650
+ packet.add_argument("--vision-image", action="append", default=[])
651
+ packet.add_argument("--vision-color-sample", action="append", default=[])
652
+ packet.add_argument("--vision-required", action="store_true")
653
+ packet.add_argument("--vision-service", default=DEFAULT_VISION_SERVICE)
654
+ packet.add_argument("--goal", action="store_true")
655
+ packet.add_argument("--max-prompt-chars", type=int, default=5000)
656
+ packet.add_argument("--out", type=Path, required=True)
657
+ packet.add_argument("--prompt-out", type=Path)
658
+ packet.set_defaults(func=packet_command)
659
+
660
+ snapshot = subparsers.add_parser("snapshot", help="Snapshot a workspace before ZCode edits.")
661
+ snapshot.add_argument("--workspace", type=Path, required=True)
662
+ snapshot.add_argument("--out", type=Path, required=True)
663
+ snapshot.set_defaults(func=snapshot_command)
664
+
665
+ audit = subparsers.add_parser("audit", help="Audit ZCode edits against packet and snapshot.")
666
+ audit.add_argument("--workspace", type=Path, required=True)
667
+ audit.add_argument("--snapshot", type=Path, required=True)
668
+ audit.add_argument("--packet", type=Path, required=True)
669
+ audit.add_argument("--validation-timeout", type=int, default=60)
670
+ audit.set_defaults(func=audit_command)
671
+
672
+ add_auto_route_parser(subparsers)
673
+
674
+ install = subparsers.add_parser("install-repo", help="Install repo-local Codex-to-ZCode routing hints.")
675
+ install.add_argument("--repo", type=Path, required=True)
676
+ install.add_argument("--write-agents", action="store_true")
677
+ install.add_argument("--skip-vision-mcp", action="store_true")
678
+ install.add_argument("--vision-mcp-server", default=DEFAULT_VISION_SERVICE)
679
+ install.add_argument("--vision-mcp-package", default="@z_ai/mcp-server")
680
+ install.add_argument("--force", action="store_true")
681
+ install.set_defaults(func=install_repo_command)
682
+
683
+ return parser
684
+
685
+
686
+ def main(argv: list[str] | None = None) -> int:
687
+ parser = build_parser()
688
+ args = parser.parse_args(argv)
689
+ try:
690
+ return args.func(args)
691
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
692
+ return fail(str(exc))
693
+
694
+
695
+ if __name__ == "__main__":
696
+ sys.exit(main())