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.
- tools/__init__.py +2 -0
- tools/zcode_control/__init__.py +16 -0
- tools/zcode_control/browser_scripts.mjs +106 -0
- tools/zcode_control/provider_errors.mjs +135 -0
- tools/zcode_control/zcodectl.mjs +2097 -0
- tools/zcode_eval/__init__.py +2 -0
- tools/zcode_eval/duel_import.py +304 -0
- tools/zcode_eval/zcode_eval.py +687 -0
- tools/zcode_eval/zcode_release.py +221 -0
- tools/zcode_supervisor/__init__.py +2 -0
- tools/zcode_supervisor/auto_route.py +393 -0
- tools/zcode_supervisor/repo_setup.py +439 -0
- tools/zcode_supervisor/zcode_supervisor.py +696 -0
- zcode_supervisor-0.0.1.dist-info/METADATA +928 -0
- zcode_supervisor-0.0.1.dist-info/RECORD +19 -0
- zcode_supervisor-0.0.1.dist-info/WHEEL +5 -0
- zcode_supervisor-0.0.1.dist-info/entry_points.txt +7 -0
- zcode_supervisor-0.0.1.dist-info/licenses/LICENSE +21 -0
- zcode_supervisor-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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())
|