graphite-code 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,523 @@
1
+ """Provider-neutral, bounded subprocess policy for authenticated development CLIs."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import math
7
+ import os
8
+ import select
9
+ import stat
10
+ import sys
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Final, Protocol
15
+
16
+ from graphite.probe_process import (
17
+ ProbeProcessError,
18
+ ProbeProcessResult,
19
+ run_bounded_process,
20
+ )
21
+
22
+ from .contracts import ProviderId
23
+
24
+ MAX_ARG_COUNT: Final = 128
25
+ MAX_ARG_LENGTH: Final = 8_192
26
+ MAX_CLI_INPUT_BYTES: Final = 4 * 1024 * 1024
27
+ MAX_CLI_OUTPUT_BYTES: Final = 4 * 1024 * 1024
28
+ _REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
29
+ _OS_ENVIRONMENT = (
30
+ "SYSTEMROOT",
31
+ "WINDIR",
32
+ "COMSPEC",
33
+ "PATHEXT",
34
+ "TEMP",
35
+ "TMP",
36
+ "HOME",
37
+ "USERPROFILE",
38
+ "LOCALAPPDATA",
39
+ "APPDATA",
40
+ )
41
+ _ERROR_MAP: Final = {
42
+ "timeout": "timeout",
43
+ "cancelled": "cancelled",
44
+ "output_limit": "response_limit",
45
+ "nonzero": "process_nonzero",
46
+ "launch_failed": "process_launch_failed",
47
+ "cleanup_failed": "process_containment_failed",
48
+ "input_limit": "request_limit",
49
+ "input_failed": "process_io_failed",
50
+ "io_failed": "process_io_failed",
51
+ "invalid_timeout": "request_invalid",
52
+ "invalid_environment": "environment_invalid",
53
+ }
54
+ _EXIT_CLASSIFICATIONS: Final = frozenset({"nonzero_exit", "signal_exit"})
55
+ _FAILURE_CATEGORIES: Final = frozenset(
56
+ {"provider_process_failure", "capacity_unavailable"}
57
+ )
58
+ _CAPACITY_DIAGNOSTICS: Final = {
59
+ ProviderId.CLAUDE_CODE: (
60
+ b"selected model is at capacity. please try a different model.",
61
+ ),
62
+ ProviderId.CODEX: (
63
+ b"selected model is at capacity. please try a different model.",
64
+ ),
65
+ }
66
+ QUOTA_MARKERS: Final = ("quota", "rate_limit", "rate limit", "usage_limit", "usage limit")
67
+ _CLAUDE_SUBTYPE_MARKERS: Final = ("quota", "rate", "limit")
68
+ _CLI_PROVIDERS: Final = frozenset({ProviderId.CLAUDE_CODE, ProviderId.CODEX})
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class CliProcessFailureDiagnostics:
73
+ """Allowlisted nonzero-exit evidence that cannot contain provider output."""
74
+
75
+ exit_classification: str
76
+ exit_code: int
77
+ duration_seconds: float
78
+ stdout_sha256: str
79
+ stderr_sha256: str
80
+ failure_category: str
81
+
82
+ def __post_init__(self) -> None:
83
+ if (
84
+ not isinstance(self.exit_classification, str)
85
+ or self.exit_classification not in _EXIT_CLASSIFICATIONS
86
+ or isinstance(self.exit_code, bool)
87
+ or not isinstance(self.exit_code, int)
88
+ or self.exit_code == 0
89
+ or not -(2**31) <= self.exit_code <= 2**32 - 1
90
+ or isinstance(self.duration_seconds, bool)
91
+ or not isinstance(self.duration_seconds, (int, float))
92
+ or not math.isfinite(self.duration_seconds)
93
+ or self.duration_seconds < 0
94
+ or not isinstance(self.stdout_sha256, str)
95
+ or len(self.stdout_sha256) != 64
96
+ or any(character not in "0123456789abcdef" for character in self.stdout_sha256)
97
+ or not isinstance(self.stderr_sha256, str)
98
+ or len(self.stderr_sha256) != 64
99
+ or any(character not in "0123456789abcdef" for character in self.stderr_sha256)
100
+ or not isinstance(self.failure_category, str)
101
+ or self.failure_category not in _FAILURE_CATEGORIES
102
+ ):
103
+ raise ValueError("process_failure_diagnostics_invalid")
104
+
105
+ def to_dict(self) -> dict[str, int | float | str]:
106
+ return {
107
+ "exit_classification": self.exit_classification,
108
+ "exit_code": self.exit_code,
109
+ "duration_seconds": self.duration_seconds,
110
+ "stdout_sha256": self.stdout_sha256,
111
+ "stderr_sha256": self.stderr_sha256,
112
+ "failure_category": self.failure_category,
113
+ }
114
+
115
+
116
+ class CliProcessError(RuntimeError):
117
+ """Stable, path-free CLI transport failure."""
118
+
119
+ def __init__(
120
+ self,
121
+ code: str,
122
+ *,
123
+ diagnostics: CliProcessFailureDiagnostics | None = None,
124
+ ) -> None:
125
+ self.code = code
126
+ self.diagnostics = (
127
+ diagnostics
128
+ if isinstance(diagnostics, CliProcessFailureDiagnostics)
129
+ else None
130
+ )
131
+ super().__init__(code)
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class CliProcessResult:
136
+ returncode: int
137
+ stdout: bytes = field(repr=False)
138
+ stderr: bytes = field(repr=False)
139
+ duration_seconds: float
140
+ input_sha256: str
141
+ stdout_sha256: str
142
+ stderr_sha256: str
143
+
144
+
145
+ class ProcessRunner(Protocol):
146
+ def __call__(self, argv: list[str], **kwargs: object) -> ProbeProcessResult: ...
147
+
148
+
149
+ def require_process_containment() -> None:
150
+ """Fail before authority consumption when native descendant containment is absent."""
151
+ if os.name == "nt":
152
+ try:
153
+ from graphite.windows_job import launch
154
+ except (ImportError, OSError):
155
+ raise CliProcessError("process_containment_unavailable") from None
156
+ if not callable(launch):
157
+ raise CliProcessError("process_containment_unavailable")
158
+ return
159
+ waitid_available = callable(getattr(os, "waitid", None))
160
+ kqueue_available = sys.platform == "darwin" and hasattr(select, "kqueue")
161
+ if not waitid_available and not kqueue_available:
162
+ raise CliProcessError("process_containment_unavailable")
163
+
164
+
165
+ def _is_reparse(metadata: os.stat_result) -> bool:
166
+ return bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
167
+
168
+
169
+ # Both canonicalisers RESOLVE FIRST and then judge the resolved target.
170
+ #
171
+ # They used to `lstat()` and reject anything that was a symlink at all. That
172
+ # reads like defence in depth and is actually a platform assumption: on POSIX
173
+ # essentially every interpreter is a symlink -- `/usr/bin/python3` ->
174
+ # `python3.12`, a venv's `bin/python` -> the system binary -- so the rule
175
+ # rejected `sys.executable` itself and graphite could not launch any CLI
176
+ # provider on Linux or macOS. `_canonical_directory` was hit too, worst on
177
+ # macOS, where `/tmp` -> `/private/tmp` and `/var` -> `/private/var` put a
178
+ # symlink in the workspace path before the executable is even looked at.
179
+ #
180
+ # The security property here is NOT "no symlinks". It is "never execute
181
+ # anything inside the agent-controlled workspace", enforced by the
182
+ # `relative_to` containment check below -- which already operates on the
183
+ # RESOLVED path, so a symlink aimed into the workspace is caught by it.
184
+ # Dropping the ban therefore costs no containment. Pinned by
185
+ # `test_a_symlink_pointing_into_the_workspace_is_still_rejected`, and that test
186
+ # was mutation-checked: with the containment check removed it fails, so it is
187
+ # testing the guard rather than the ban.
188
+ #
189
+ # What the ban DID carry, preserved deliberately:
190
+ # - "is a regular file" / "is a directory" -- now checked on
191
+ # `resolved.stat()`. Checking nothing would let a symlink to a directory or
192
+ # a fifo through.
193
+ # - the Windows reparse-point rule, still applied to the resolved metadata.
194
+ # `st_file_attributes` is Windows-only, so it is inert on POSIX and was
195
+ # never what failed there.
196
+ #
197
+ # Residual, accepted knowingly: resolving then executing leaves a TOCTOU window
198
+ # in which a symlink can be re-pointed. The ban narrowed that window; it did not
199
+ # close it (a plain file can be swapped too), and closing it properly needs an
200
+ # fd-based exec, not a path check. Banning symlinks to buy a narrower race, at
201
+ # the price of the tool not running on two of three platforms, is the wrong
202
+ # trade.
203
+ def _canonical_directory(path: Path, code: str) -> Path:
204
+ try:
205
+ resolved = path.resolve(strict=True)
206
+ metadata = resolved.stat()
207
+ except OSError as exc:
208
+ raise CliProcessError(code) from exc
209
+ if not stat.S_ISDIR(metadata.st_mode) or _is_reparse(metadata):
210
+ raise CliProcessError(code)
211
+ return resolved
212
+
213
+
214
+ def _canonical_executable(path: Path, workspace: Path) -> Path:
215
+ if not path.is_absolute():
216
+ raise CliProcessError("executable_invalid")
217
+ try:
218
+ resolved = path.resolve(strict=True)
219
+ metadata = resolved.stat()
220
+ except OSError as exc:
221
+ raise CliProcessError("executable_invalid") from exc
222
+ if not stat.S_ISREG(metadata.st_mode) or _is_reparse(metadata):
223
+ raise CliProcessError("executable_invalid")
224
+ # `workspace` arrives already resolved (`_canonical_directory`), so both
225
+ # sides of this comparison are canonical and containment cannot be
226
+ # sidestepped by spelling either path differently.
227
+ try:
228
+ resolved.relative_to(workspace)
229
+ except ValueError:
230
+ # Judge the RESOLVED target, launch the path we were GIVEN.
231
+ #
232
+ # Returning `resolved` here broke every virtual environment on POSIX,
233
+ # where `.venv/bin/python` is a symlink to the base interpreter. Python
234
+ # locates a venv from the executable it was invoked as, so resolving the
235
+ # link launches the base interpreter with `sys.prefix` pointing at the
236
+ # installation rather than the venv -- measured on Linux, the resolved
237
+ # interpreter cannot `import graphite` at all. Windows hides this
238
+ # completely: venv interpreters there are copies, not symlinks, so
239
+ # resolved and unresolved are the same file.
240
+ #
241
+ # The containment guarantee is untouched, and this is the load-bearing
242
+ # sentence: the rejection below still tests `resolved`, so a symlink
243
+ # sitting outside the workspace that points at a repo-controlled binary
244
+ # is still refused. Only which spelling of the same target gets executed
245
+ # changes. Do not "restore" the resolved return; it buys no containment
246
+ # and costs venv support on two of three platforms.
247
+ #
248
+ # It does widen the residual TOCTOU noted above, and the widening is
249
+ # named here rather than left for a reviewer to find: the executed
250
+ # spelling is now the caller's path, so the race went from "swap the
251
+ # file at the canonical path" to "re-point the symlink" -- strictly
252
+ # easier. `str(executable)` reaches `argv[0]` directly (`_prepare`), and
253
+ # the second validation on the `build_cli_environment` path returns this
254
+ # same unpinned spelling, so neither check pins what actually execs.
255
+ #
256
+ # Left accepted, on the reasoning already given above: no path check
257
+ # closes this, only an fd-based exec does. The obvious cheap narrowing
258
+ # -- refuse a launcher whose parent directory is group- or
259
+ # world-writable -- was considered and rejected. Homebrew's
260
+ # `/usr/local/bin` is group-writable by design, so it would reject
261
+ # working installs on exactly the platforms this branch exists to
262
+ # support, while the realistic attacker here is same-UID and can write
263
+ # the user's own non-group-writable directories anyway. It would cost
264
+ # real installs and buy nothing against the actual threat.
265
+ return path
266
+ raise CliProcessError("executable_invalid")
267
+
268
+
269
+ def build_cli_environment(
270
+ *,
271
+ provider: ProviderId | str,
272
+ executable: Path,
273
+ workspace: Path,
274
+ credential_home: Path | None,
275
+ source: Mapping[str, str] | None = None,
276
+ ) -> dict[str, str]:
277
+ """Build the complete child environment without copying ambient credentials."""
278
+ try:
279
+ normalized_provider = ProviderId(provider)
280
+ except (TypeError, ValueError) as exc:
281
+ raise CliProcessError("provider_invalid") from exc
282
+ if normalized_provider not in _CLI_PROVIDERS:
283
+ raise CliProcessError("provider_invalid")
284
+ canonical_workspace = _canonical_directory(workspace, "workspace_invalid")
285
+ canonical_executable = _canonical_executable(executable, canonical_workspace)
286
+ ambient = dict(os.environ)
287
+ if source is not None:
288
+ for name in _OS_ENVIRONMENT:
289
+ if name in source:
290
+ ambient[name] = source[name]
291
+ elif name in ambient and name in {"TEMP", "TMP", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"}:
292
+ ambient.pop(name)
293
+ environment = {
294
+ name: ambient[name]
295
+ for name in _OS_ENVIRONMENT
296
+ if name in ambient and isinstance(ambient[name], str) and "\x00" not in ambient[name]
297
+ }
298
+ trusted_paths = [canonical_executable.parent]
299
+ system_root = environment.get("SYSTEMROOT") or environment.get("WINDIR")
300
+ if os.name == "nt" and system_root:
301
+ trusted_paths.append(Path(system_root) / "System32")
302
+ elif os.name != "nt":
303
+ trusted_paths.extend((Path("/usr/bin"), Path("/bin")))
304
+ environment["PATH"] = os.pathsep.join(dict.fromkeys(str(path) for path in trusted_paths))
305
+ environment.update(
306
+ {
307
+ "NO_COLOR": "1",
308
+ "TERM": "dumb",
309
+ "CI": "1",
310
+ "PYTHONIOENCODING": "utf-8",
311
+ "PYTHONUTF8": "1",
312
+ }
313
+ )
314
+ if credential_home is not None:
315
+ canonical_credentials = _canonical_directory(credential_home, "credential_home_invalid")
316
+ try:
317
+ canonical_credentials.relative_to(canonical_workspace)
318
+ except ValueError:
319
+ pass
320
+ else:
321
+ raise CliProcessError("credential_home_invalid")
322
+ key = "CODEX_HOME" if normalized_provider is ProviderId.CODEX else "CLAUDE_CONFIG_DIR"
323
+ environment[key] = str(canonical_credentials)
324
+ return environment
325
+
326
+
327
+ def _validate_argv(argv: tuple[str, ...], cwd: Path) -> tuple[list[str], Path]:
328
+ if not isinstance(argv, tuple) or not argv or len(argv) > MAX_ARG_COUNT:
329
+ raise CliProcessError("argv_invalid")
330
+ if any(
331
+ not isinstance(value, str)
332
+ or not value
333
+ or "\x00" in value
334
+ or len(value) > MAX_ARG_LENGTH
335
+ for value in argv
336
+ ):
337
+ raise CliProcessError("argv_invalid")
338
+ workspace = _canonical_directory(cwd, "workspace_invalid")
339
+ executable = _canonical_executable(Path(argv[0]), workspace)
340
+ return [str(executable), *argv[1:]], workspace
341
+
342
+
343
+ def run_cli_process(
344
+ *,
345
+ argv: tuple[str, ...],
346
+ cwd: Path,
347
+ stdin: bytes,
348
+ provider: ProviderId | str,
349
+ credential_home: Path | None,
350
+ timeout_seconds: float,
351
+ max_input_bytes: int = MAX_CLI_INPUT_BYTES,
352
+ max_output_bytes: int = MAX_CLI_OUTPUT_BYTES,
353
+ cancelled: Callable[[], bool] | None = None,
354
+ runner: ProcessRunner = run_bounded_process,
355
+ source_environment: Mapping[str, str] | None = None,
356
+ ) -> CliProcessResult:
357
+ """Run exactly one fixed CLI command inside the shared contained transport."""
358
+ command, workspace = _validate_argv(argv, cwd)
359
+ try:
360
+ normalized_provider = ProviderId(provider)
361
+ except (TypeError, ValueError) as exc:
362
+ raise CliProcessError("provider_invalid") from exc
363
+ if normalized_provider not in _CLI_PROVIDERS:
364
+ raise CliProcessError("provider_invalid")
365
+ if (
366
+ not isinstance(stdin, bytes)
367
+ or not math.isfinite(timeout_seconds)
368
+ or timeout_seconds <= 0
369
+ or isinstance(max_input_bytes, bool)
370
+ or not isinstance(max_input_bytes, int)
371
+ or not 1 <= max_input_bytes <= MAX_CLI_INPUT_BYTES
372
+ or isinstance(max_output_bytes, bool)
373
+ or not isinstance(max_output_bytes, int)
374
+ or not 1 <= max_output_bytes <= MAX_CLI_OUTPUT_BYTES
375
+ ):
376
+ raise CliProcessError("request_invalid")
377
+ cancellation = cancelled or (lambda: False)
378
+ try:
379
+ if cancellation():
380
+ raise CliProcessError("cancelled")
381
+ except CliProcessError:
382
+ raise
383
+ except Exception:
384
+ raise CliProcessError("cancelled") from None
385
+ require_process_containment()
386
+ environment = build_cli_environment(
387
+ provider=normalized_provider,
388
+ executable=Path(command[0]),
389
+ workspace=workspace,
390
+ credential_home=credential_home,
391
+ source=source_environment,
392
+ )
393
+ try:
394
+ result = runner(
395
+ command,
396
+ cwd=workspace,
397
+ stdin=stdin,
398
+ timeout_seconds=timeout_seconds,
399
+ max_output_bytes=max_output_bytes,
400
+ max_input_bytes=max_input_bytes,
401
+ check=False,
402
+ environment=environment,
403
+ cancelled=cancellation,
404
+ )
405
+ except ProbeProcessError as exc:
406
+ raise CliProcessError(_ERROR_MAP.get(exc.code, "process_failed")) from None
407
+ except Exception:
408
+ raise CliProcessError("process_failed") from None
409
+ if (
410
+ not isinstance(result, ProbeProcessResult)
411
+ or isinstance(result.returncode, bool)
412
+ or not isinstance(result.returncode, int)
413
+ or not isinstance(result.stdout, bytes)
414
+ or not isinstance(result.stderr, bytes)
415
+ or len(result.stdout) > max_output_bytes
416
+ or len(result.stderr) > max_output_bytes
417
+ or isinstance(result.duration_seconds, bool)
418
+ or not isinstance(result.duration_seconds, (int, float))
419
+ or not math.isfinite(result.duration_seconds)
420
+ or result.duration_seconds < 0
421
+ ):
422
+ raise CliProcessError("process_failed")
423
+ stdout_sha256 = hashlib.sha256(result.stdout).hexdigest()
424
+ stderr_sha256 = hashlib.sha256(result.stderr).hexdigest()
425
+ if result.returncode != 0:
426
+ classification = (
427
+ "signal_exit" if os.name != "nt" and result.returncode < 0 else "nonzero_exit"
428
+ )
429
+ raise CliProcessError(
430
+ "process_nonzero",
431
+ diagnostics=CliProcessFailureDiagnostics(
432
+ exit_classification=classification,
433
+ exit_code=result.returncode,
434
+ duration_seconds=float(result.duration_seconds),
435
+ stdout_sha256=stdout_sha256,
436
+ stderr_sha256=stderr_sha256,
437
+ failure_category=_classify_nonzero_failure(
438
+ normalized_provider,
439
+ result.stdout,
440
+ result.stderr,
441
+ ),
442
+ ),
443
+ )
444
+ return CliProcessResult(
445
+ returncode=result.returncode,
446
+ stdout=result.stdout,
447
+ stderr=result.stderr,
448
+ duration_seconds=result.duration_seconds,
449
+ input_sha256=hashlib.sha256(stdin).hexdigest(),
450
+ stdout_sha256=stdout_sha256,
451
+ stderr_sha256=stderr_sha256,
452
+ )
453
+
454
+
455
+ def decode_cli_output(value: bytes) -> str:
456
+ """Decode one bounded provider output without reflecting malformed bytes."""
457
+ if not isinstance(value, bytes):
458
+ raise CliProcessError("provider_protocol")
459
+ try:
460
+ decoded = value.decode("utf-8")
461
+ except UnicodeDecodeError:
462
+ raise CliProcessError("provider_protocol") from None
463
+ if "\x00" in decoded:
464
+ raise CliProcessError("provider_protocol")
465
+ return decoded
466
+
467
+
468
+ def _classify_nonzero_failure(
469
+ provider: ProviderId,
470
+ stdout: bytes,
471
+ stderr: bytes,
472
+ ) -> str:
473
+ """Classify one nonzero exit; decodes transiently, returns only an allowlisted category."""
474
+ patterns = _CAPACITY_DIAGNOSTICS[provider]
475
+ for output in (stdout, stderr):
476
+ if any(line.strip() in patterns for line in output.lower().splitlines()):
477
+ return "capacity_unavailable"
478
+ if _stdout_reports_quota(provider, stdout):
479
+ return "capacity_unavailable"
480
+ return "provider_process_failure"
481
+
482
+
483
+ def _stdout_reports_quota(provider: ProviderId, stdout: bytes) -> bool:
484
+ """Detect provider-authored quota/rate-limit error events; retains nothing."""
485
+ try:
486
+ text = stdout.decode("utf-8")
487
+ except UnicodeDecodeError:
488
+ return False
489
+ for line in text.splitlines():
490
+ if not line.strip():
491
+ continue
492
+ try:
493
+ event = json.loads(line)
494
+ except (ValueError, RecursionError):
495
+ continue
496
+ if not isinstance(event, dict):
497
+ continue
498
+ # Codex error events are provider-authored, so the whole event is
499
+ # matched; claude error results can embed model text in "result", so
500
+ # only the provider-authored subtype is matched. Unlike the exit-0
501
+ # parser there is no auth-first precedence here: recall wins, and a
502
+ # false positive only reaches the second pre-approved candidate.
503
+ if provider is ProviderId.CODEX:
504
+ if event.get("type") not in {"error", "turn.failed"}:
505
+ continue
506
+ try:
507
+ serialized = json.dumps(
508
+ event, ensure_ascii=True, separators=(",", ":")
509
+ ).lower()
510
+ except (ValueError, RecursionError):
511
+ continue
512
+ if any(marker in serialized for marker in QUOTA_MARKERS):
513
+ return True
514
+ elif provider is ProviderId.CLAUDE_CODE:
515
+ if event.get("type") != "result" or (
516
+ event.get("subtype") == "success"
517
+ and event.get("is_error") is False
518
+ ):
519
+ continue
520
+ subtype = str(event.get("subtype", "")).lower()
521
+ if any(marker in subtype for marker in _CLAUDE_SUBTYPE_MARKERS):
522
+ return True
523
+ return False