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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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
|