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,468 @@
|
|
|
1
|
+
"""Fail-closed inspection of untrusted model-generated worktree changes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from graphite.git import GitError, GitOutputLimitError, GitRunner, GitTimeoutError
|
|
13
|
+
|
|
14
|
+
from .worktree import TaskWorktree
|
|
15
|
+
|
|
16
|
+
_COMMIT: Final = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
|
|
17
|
+
_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
18
|
+
_SENSITIVE_COMPONENTS: Final = frozenset({"secret", "secrets", "credential", "credentials"})
|
|
19
|
+
_SENSITIVE_NAMES: Final = frozenset(
|
|
20
|
+
{
|
|
21
|
+
".env",
|
|
22
|
+
".npmrc",
|
|
23
|
+
".pypirc",
|
|
24
|
+
".netrc",
|
|
25
|
+
"id_rsa",
|
|
26
|
+
"id_ed25519",
|
|
27
|
+
"service-account.json",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
_SENSITIVE_SUFFIXES: Final = (".pem", ".key", ".p12", ".pfx")
|
|
31
|
+
_REJECTED_CI_PREFIXES: Final = (".github/workflows/", ".gitlab/")
|
|
32
|
+
_REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
33
|
+
GIT_TIMEOUT_SECONDS: Final = 15.0
|
|
34
|
+
MAX_METADATA_BYTES: Final = 8 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DiffPolicyError(RuntimeError):
|
|
38
|
+
"""Stable unsafe-diff failure with no changed path in its text.
|
|
39
|
+
|
|
40
|
+
`cause` is a diagnostic that rides in the MESSAGE only; `code` stays exactly
|
|
41
|
+
what the caller passed. The routing service re-raises `code` verbatim as its
|
|
42
|
+
own failure taxonomy, so widening it would be a breaking change wearing a
|
|
43
|
+
diagnostic's clothes.
|
|
44
|
+
|
|
45
|
+
Only ever set it to an exception CLASS NAME. Git's own text routinely embeds
|
|
46
|
+
a path, which is the entire reason these are raised `from None`.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, code: str, cause: str | None = None) -> None:
|
|
50
|
+
self.code = code
|
|
51
|
+
self.cause = cause
|
|
52
|
+
super().__init__(f"{code} ({cause})" if cause else code)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class DiffEvidence:
|
|
57
|
+
baseline_commit: str
|
|
58
|
+
changed_files: int
|
|
59
|
+
changed_bytes: int
|
|
60
|
+
diff_sha256: str
|
|
61
|
+
changed_paths: tuple[str, ...] = field(repr=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_reparse(metadata: os.stat_result) -> bool:
|
|
65
|
+
return bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _records(output: bytes) -> list[bytes]:
|
|
69
|
+
if not isinstance(output, bytes) or (output and not output.endswith(b"\0")):
|
|
70
|
+
raise DiffPolicyError("git_protocol")
|
|
71
|
+
return output[:-1].split(b"\0") if output else []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _path(raw: bytes) -> str:
|
|
75
|
+
try:
|
|
76
|
+
value = raw.decode("utf-8")
|
|
77
|
+
except UnicodeDecodeError:
|
|
78
|
+
raise DiffPolicyError("path_invalid") from None
|
|
79
|
+
candidate = PurePosixPath(value)
|
|
80
|
+
if (
|
|
81
|
+
not value
|
|
82
|
+
or "\\" in value
|
|
83
|
+
or candidate.is_absolute()
|
|
84
|
+
or value.startswith("/")
|
|
85
|
+
or any(part in {"", ".", ".."} for part in candidate.parts)
|
|
86
|
+
):
|
|
87
|
+
raise DiffPolicyError("path_invalid")
|
|
88
|
+
folded = value.casefold()
|
|
89
|
+
parts = tuple(part.casefold() for part in candidate.parts)
|
|
90
|
+
name = parts[-1]
|
|
91
|
+
if parts[0] == ".git" or name == ".git" or folded.startswith(".git/"):
|
|
92
|
+
raise DiffPolicyError("git_control_path")
|
|
93
|
+
if (
|
|
94
|
+
name in _SENSITIVE_NAMES
|
|
95
|
+
or name.startswith(".env.")
|
|
96
|
+
or name.startswith("secret.")
|
|
97
|
+
or name.startswith("secrets.")
|
|
98
|
+
or name.startswith("credential.")
|
|
99
|
+
or name.startswith("credentials.")
|
|
100
|
+
or name.endswith(_SENSITIVE_SUFFIXES)
|
|
101
|
+
or any(part in _SENSITIVE_COMPONENTS for part in parts)
|
|
102
|
+
or any(folded.startswith(prefix) for prefix in _REJECTED_CI_PREFIXES)
|
|
103
|
+
):
|
|
104
|
+
raise DiffPolicyError("sensitive_path")
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _status_paths(output: bytes) -> set[str]:
|
|
109
|
+
records = _records(output)
|
|
110
|
+
paths: set[str] = set()
|
|
111
|
+
index = 0
|
|
112
|
+
while index < len(records):
|
|
113
|
+
record = records[index]
|
|
114
|
+
if len(record) < 4 or record[2:3] != b" " or record[:2] == b" ":
|
|
115
|
+
raise DiffPolicyError("git_protocol")
|
|
116
|
+
if b"U" in record[:2]:
|
|
117
|
+
raise DiffPolicyError("unmerged_change")
|
|
118
|
+
paths.add(_path(record[3:]))
|
|
119
|
+
if b"R" in record[:2] or b"C" in record[:2]:
|
|
120
|
+
index += 1
|
|
121
|
+
if index >= len(records):
|
|
122
|
+
raise DiffPolicyError("git_protocol")
|
|
123
|
+
paths.add(_path(records[index]))
|
|
124
|
+
index += 1
|
|
125
|
+
return paths
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _name_status_paths(output: bytes) -> set[str]:
|
|
129
|
+
records = _records(output)
|
|
130
|
+
paths: set[str] = set()
|
|
131
|
+
index = 0
|
|
132
|
+
while index < len(records):
|
|
133
|
+
status = records[index]
|
|
134
|
+
index += 1
|
|
135
|
+
if not status or status[:1] not in b"ACDMRTUXB":
|
|
136
|
+
raise DiffPolicyError("git_protocol")
|
|
137
|
+
count = 2 if status[:1] in {b"R", b"C"} else 1
|
|
138
|
+
if index + count > len(records):
|
|
139
|
+
raise DiffPolicyError("git_protocol")
|
|
140
|
+
for raw in records[index : index + count]:
|
|
141
|
+
paths.add(_path(raw))
|
|
142
|
+
index += count
|
|
143
|
+
return paths
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _numstat(output: bytes) -> tuple[set[str], int]:
|
|
147
|
+
paths: set[str] = set()
|
|
148
|
+
churn = 0
|
|
149
|
+
records = _records(output)
|
|
150
|
+
index = 0
|
|
151
|
+
while index < len(records):
|
|
152
|
+
record = records[index]
|
|
153
|
+
index += 1
|
|
154
|
+
try:
|
|
155
|
+
added, deleted, raw_path = record.split(b"\t", 2)
|
|
156
|
+
except ValueError:
|
|
157
|
+
raise DiffPolicyError("git_protocol") from None
|
|
158
|
+
if added == b"-" or deleted == b"-":
|
|
159
|
+
raise DiffPolicyError("binary_change")
|
|
160
|
+
try:
|
|
161
|
+
additions = int(added)
|
|
162
|
+
deletions = int(deleted)
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise DiffPolicyError("git_protocol") from None
|
|
165
|
+
if additions < 0 or deletions < 0:
|
|
166
|
+
raise DiffPolicyError("git_protocol")
|
|
167
|
+
churn += additions + deletions
|
|
168
|
+
if raw_path:
|
|
169
|
+
paths.add(_path(raw_path))
|
|
170
|
+
else:
|
|
171
|
+
if index + 2 > len(records):
|
|
172
|
+
raise DiffPolicyError("git_protocol")
|
|
173
|
+
paths.add(_path(records[index]))
|
|
174
|
+
paths.add(_path(records[index + 1]))
|
|
175
|
+
index += 2
|
|
176
|
+
return paths, churn
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _raw_paths_and_modes(output: bytes) -> set[str]:
|
|
180
|
+
records = _records(output)
|
|
181
|
+
paths: set[str] = set()
|
|
182
|
+
index = 0
|
|
183
|
+
while index < len(records):
|
|
184
|
+
header = records[index]
|
|
185
|
+
index += 1
|
|
186
|
+
match = re.fullmatch(
|
|
187
|
+
rb":([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) ([A-Z][0-9]{0,3})",
|
|
188
|
+
header,
|
|
189
|
+
)
|
|
190
|
+
if match is None:
|
|
191
|
+
raise DiffPolicyError("git_protocol")
|
|
192
|
+
old_mode, new_mode, _old_hash, _new_hash, status = match.groups()
|
|
193
|
+
if status[:1] == b"U":
|
|
194
|
+
raise DiffPolicyError("unmerged_change")
|
|
195
|
+
if b"160000" in {old_mode, new_mode}:
|
|
196
|
+
raise DiffPolicyError("submodule_change")
|
|
197
|
+
if b"120000" in {old_mode, new_mode}:
|
|
198
|
+
raise DiffPolicyError("special_file_change")
|
|
199
|
+
if b"100755" in {old_mode, new_mode}:
|
|
200
|
+
raise DiffPolicyError("executable_change")
|
|
201
|
+
count = 2 if status[:1] in {b"R", b"C"} else 1
|
|
202
|
+
if index + count > len(records):
|
|
203
|
+
raise DiffPolicyError("git_protocol")
|
|
204
|
+
for raw in records[index : index + count]:
|
|
205
|
+
paths.add(_path(raw))
|
|
206
|
+
index += count
|
|
207
|
+
return paths
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _validate_filesystem(
|
|
211
|
+
root: Path, paths: set[str], untracked_paths: set[str], *, max_bytes: int
|
|
212
|
+
) -> tuple[int, bytes]:
|
|
213
|
+
untracked_bytes = 0
|
|
214
|
+
manifest = hashlib.sha256()
|
|
215
|
+
for relative in paths:
|
|
216
|
+
candidate = root.joinpath(*PurePosixPath(relative).parts)
|
|
217
|
+
parent = candidate.parent
|
|
218
|
+
while parent != root:
|
|
219
|
+
try:
|
|
220
|
+
metadata = parent.lstat()
|
|
221
|
+
except OSError:
|
|
222
|
+
raise DiffPolicyError("containment_uncertain") from None
|
|
223
|
+
if stat.S_ISLNK(metadata.st_mode) or bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE):
|
|
224
|
+
raise DiffPolicyError("containment_uncertain")
|
|
225
|
+
parent = parent.parent
|
|
226
|
+
try:
|
|
227
|
+
metadata = candidate.lstat()
|
|
228
|
+
except FileNotFoundError:
|
|
229
|
+
continue
|
|
230
|
+
except OSError:
|
|
231
|
+
raise DiffPolicyError("containment_uncertain") from None
|
|
232
|
+
if (
|
|
233
|
+
stat.S_ISLNK(metadata.st_mode)
|
|
234
|
+
or bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
|
|
235
|
+
or not stat.S_ISREG(metadata.st_mode)
|
|
236
|
+
):
|
|
237
|
+
raise DiffPolicyError("special_file_change")
|
|
238
|
+
try:
|
|
239
|
+
resolved = candidate.resolve(strict=True)
|
|
240
|
+
resolved.relative_to(root)
|
|
241
|
+
except (OSError, ValueError):
|
|
242
|
+
raise DiffPolicyError("containment_uncertain") from None
|
|
243
|
+
if relative in untracked_paths:
|
|
244
|
+
if metadata.st_size > max_bytes - untracked_bytes:
|
|
245
|
+
raise DiffPolicyError("byte_limit")
|
|
246
|
+
content_digest = hashlib.sha256()
|
|
247
|
+
try:
|
|
248
|
+
with candidate.open("rb") as handle:
|
|
249
|
+
while chunk := handle.read(64 * 1024):
|
|
250
|
+
content_digest.update(chunk)
|
|
251
|
+
after = candidate.lstat()
|
|
252
|
+
except OSError:
|
|
253
|
+
raise DiffPolicyError("containment_uncertain") from None
|
|
254
|
+
if (
|
|
255
|
+
after.st_size != metadata.st_size
|
|
256
|
+
or after.st_mtime_ns != metadata.st_mtime_ns
|
|
257
|
+
or getattr(after, "st_ino", None) != getattr(metadata, "st_ino", None)
|
|
258
|
+
):
|
|
259
|
+
raise DiffPolicyError("concurrent_mutation")
|
|
260
|
+
encoded_path = relative.encode("utf-8")
|
|
261
|
+
manifest.update(len(encoded_path).to_bytes(4, "big"))
|
|
262
|
+
manifest.update(encoded_path)
|
|
263
|
+
manifest.update(metadata.st_size.to_bytes(8, "big"))
|
|
264
|
+
manifest.update(content_digest.digest())
|
|
265
|
+
untracked_bytes += metadata.st_size
|
|
266
|
+
return untracked_bytes, manifest.digest()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def inspect_diff_evidence(
|
|
270
|
+
*,
|
|
271
|
+
worktree_root: Path,
|
|
272
|
+
baseline_commit: str,
|
|
273
|
+
status_output: bytes,
|
|
274
|
+
name_status_output: bytes,
|
|
275
|
+
numstat_output: bytes,
|
|
276
|
+
raw_output: bytes,
|
|
277
|
+
patch_output: bytes,
|
|
278
|
+
max_files: int,
|
|
279
|
+
max_bytes: int,
|
|
280
|
+
) -> DiffEvidence:
|
|
281
|
+
"""Validate bounded machine-readable Git evidence and return only a digest."""
|
|
282
|
+
if not isinstance(baseline_commit, str) or _COMMIT.fullmatch(baseline_commit) is None:
|
|
283
|
+
raise DiffPolicyError("baseline_invalid")
|
|
284
|
+
if (
|
|
285
|
+
isinstance(max_files, bool)
|
|
286
|
+
or not isinstance(max_files, int)
|
|
287
|
+
or not 1 <= max_files <= 10_000
|
|
288
|
+
or isinstance(max_bytes, bool)
|
|
289
|
+
or not isinstance(max_bytes, int)
|
|
290
|
+
or not 1 <= max_bytes <= 100 * 1024 * 1024
|
|
291
|
+
or not isinstance(patch_output, bytes)
|
|
292
|
+
):
|
|
293
|
+
raise DiffPolicyError("policy_invalid")
|
|
294
|
+
try:
|
|
295
|
+
root_metadata = worktree_root.lstat()
|
|
296
|
+
root = worktree_root.resolve(strict=True)
|
|
297
|
+
except OSError:
|
|
298
|
+
raise DiffPolicyError("worktree_invalid") from None
|
|
299
|
+
if stat.S_ISLNK(root_metadata.st_mode) or _is_reparse(root_metadata) or not stat.S_ISDIR(root_metadata.st_mode):
|
|
300
|
+
raise DiffPolicyError("worktree_invalid")
|
|
301
|
+
status_paths = _status_paths(status_output)
|
|
302
|
+
tracked_paths = _name_status_paths(name_status_output)
|
|
303
|
+
numstat_paths, churn = _numstat(numstat_output)
|
|
304
|
+
raw_paths = _raw_paths_and_modes(raw_output)
|
|
305
|
+
if tracked_paths != numstat_paths or tracked_paths != raw_paths:
|
|
306
|
+
if tracked_paths or numstat_paths or raw_paths:
|
|
307
|
+
raise DiffPolicyError("git_evidence_mismatch")
|
|
308
|
+
if not tracked_paths <= status_paths:
|
|
309
|
+
raise DiffPolicyError("git_evidence_mismatch")
|
|
310
|
+
folded: dict[str, str] = {}
|
|
311
|
+
for path in status_paths:
|
|
312
|
+
key = path.casefold()
|
|
313
|
+
if key in folded and folded[key] != path:
|
|
314
|
+
raise DiffPolicyError("path_collision")
|
|
315
|
+
folded[key] = path
|
|
316
|
+
if len(status_paths) > max_files:
|
|
317
|
+
raise DiffPolicyError("file_limit")
|
|
318
|
+
if b"GIT binary patch" in patch_output or b"Binary files " in patch_output:
|
|
319
|
+
raise DiffPolicyError("binary_change")
|
|
320
|
+
untracked_paths = status_paths - tracked_paths
|
|
321
|
+
filesystem_bytes, untracked_digest = _validate_filesystem(
|
|
322
|
+
root, status_paths, untracked_paths, max_bytes=max_bytes
|
|
323
|
+
)
|
|
324
|
+
changed_bytes = len(patch_output) + filesystem_bytes
|
|
325
|
+
if changed_bytes > max_bytes or churn > max_bytes:
|
|
326
|
+
raise DiffPolicyError("byte_limit")
|
|
327
|
+
digest = hashlib.sha256()
|
|
328
|
+
digest.update(b"graphite-diff-v1\0")
|
|
329
|
+
digest.update(baseline_commit.encode("ascii"))
|
|
330
|
+
for output in (status_output, name_status_output, numstat_output, raw_output, patch_output):
|
|
331
|
+
digest.update(len(output).to_bytes(8, "big"))
|
|
332
|
+
digest.update(output)
|
|
333
|
+
digest.update(untracked_digest)
|
|
334
|
+
return DiffEvidence(
|
|
335
|
+
baseline_commit,
|
|
336
|
+
len(status_paths),
|
|
337
|
+
changed_bytes,
|
|
338
|
+
digest.hexdigest(),
|
|
339
|
+
tuple(sorted(status_paths)),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _run_git(
|
|
344
|
+
runner: GitRunner, arguments: list[str], *, maximum: int
|
|
345
|
+
) -> bytes:
|
|
346
|
+
try:
|
|
347
|
+
result = runner.run(
|
|
348
|
+
arguments,
|
|
349
|
+
timeout_seconds=GIT_TIMEOUT_SECONDS,
|
|
350
|
+
max_stdout_bytes=maximum,
|
|
351
|
+
)
|
|
352
|
+
except GitTimeoutError:
|
|
353
|
+
raise DiffPolicyError("git_timeout") from None
|
|
354
|
+
except GitOutputLimitError:
|
|
355
|
+
raise DiffPolicyError("byte_limit") from None
|
|
356
|
+
except GitError as exc:
|
|
357
|
+
# `git_unavailable` buckets GitUnavailableError (no executable found),
|
|
358
|
+
# GitLaunchError (found, would not start) and GitUnsupportedVersionError
|
|
359
|
+
# (started, protected config unreadable). Different causes, different
|
|
360
|
+
# fixes, and `from None` then discards the one fact that separates them
|
|
361
|
+
# -- graphite#37's single CI sighting could not be taken any further
|
|
362
|
+
# than the string.
|
|
363
|
+
#
|
|
364
|
+
# `diagnostic()` rather than `type(exc).__name__`: the class name alone
|
|
365
|
+
# still leaves seven `GitLaunchError` sites indistinguishable, and says
|
|
366
|
+
# nothing about what the OS refused. It stays path-free -- a fixed step
|
|
367
|
+
# token and an integer errno, never `filename`, argv or Git's own text.
|
|
368
|
+
raise DiffPolicyError("git_unavailable", exc.diagnostic()) from None
|
|
369
|
+
if result.returncode != 0:
|
|
370
|
+
raise DiffPolicyError("git_failed")
|
|
371
|
+
return result.stdout
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _decode_git_path(root: Path, output: bytes) -> Path:
|
|
375
|
+
try:
|
|
376
|
+
raw = output.decode("utf-8").rstrip("\r\n")
|
|
377
|
+
except UnicodeDecodeError:
|
|
378
|
+
raise DiffPolicyError("git_protocol") from None
|
|
379
|
+
if not raw or "\x00" in raw or "\n" in raw or "\r" in raw:
|
|
380
|
+
raise DiffPolicyError("git_protocol")
|
|
381
|
+
candidate = Path(raw)
|
|
382
|
+
if not candidate.is_absolute():
|
|
383
|
+
candidate = root / candidate
|
|
384
|
+
try:
|
|
385
|
+
return candidate.resolve(strict=True)
|
|
386
|
+
except OSError:
|
|
387
|
+
raise DiffPolicyError("containment_uncertain") from None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def collect_diff_evidence(
|
|
391
|
+
worktree: TaskWorktree,
|
|
392
|
+
*,
|
|
393
|
+
max_files: int,
|
|
394
|
+
max_bytes: int,
|
|
395
|
+
) -> DiffEvidence:
|
|
396
|
+
"""Collect bounded Git evidence twice and reject concurrent or identity drift."""
|
|
397
|
+
if not isinstance(worktree, TaskWorktree) or worktree.status != "prepared":
|
|
398
|
+
raise DiffPolicyError("worktree_invalid")
|
|
399
|
+
try:
|
|
400
|
+
root = worktree.root.resolve(strict=True)
|
|
401
|
+
except OSError:
|
|
402
|
+
raise DiffPolicyError("worktree_invalid") from None
|
|
403
|
+
if root != worktree.root:
|
|
404
|
+
raise DiffPolicyError("worktree_invalid")
|
|
405
|
+
runner = GitRunner(root)
|
|
406
|
+
head = _run_git(runner, ["rev-parse", "HEAD"], maximum=256)
|
|
407
|
+
try:
|
|
408
|
+
current_head = head.decode("ascii").strip()
|
|
409
|
+
except UnicodeDecodeError:
|
|
410
|
+
raise DiffPolicyError("git_protocol") from None
|
|
411
|
+
if current_head != worktree.baseline_commit:
|
|
412
|
+
raise DiffPolicyError("commit_drift")
|
|
413
|
+
common = _run_git(runner, ["rev-parse", "--git-common-dir"], maximum=4_096)
|
|
414
|
+
if _decode_git_path(root, common) != worktree.git_common_dir:
|
|
415
|
+
raise DiffPolicyError("worktree_identity_drift")
|
|
416
|
+
status_arguments = [
|
|
417
|
+
"status",
|
|
418
|
+
"--porcelain=v1",
|
|
419
|
+
"-z",
|
|
420
|
+
"--untracked-files=all",
|
|
421
|
+
"--ignored=matching",
|
|
422
|
+
]
|
|
423
|
+
first_status = _run_git(runner, status_arguments, maximum=MAX_METADATA_BYTES)
|
|
424
|
+
common_diff = ["diff", "--no-ext-diff", "--no-renames", worktree.baseline_commit, "--"]
|
|
425
|
+
names = _run_git(
|
|
426
|
+
runner,
|
|
427
|
+
["diff", "--name-status", "-z", "--no-ext-diff", "--no-renames", worktree.baseline_commit, "--"],
|
|
428
|
+
maximum=MAX_METADATA_BYTES,
|
|
429
|
+
)
|
|
430
|
+
numstat = _run_git(
|
|
431
|
+
runner,
|
|
432
|
+
["diff", "--numstat", "-z", "--no-ext-diff", "--no-renames", worktree.baseline_commit, "--"],
|
|
433
|
+
maximum=MAX_METADATA_BYTES,
|
|
434
|
+
)
|
|
435
|
+
raw = _run_git(
|
|
436
|
+
runner,
|
|
437
|
+
[
|
|
438
|
+
"diff",
|
|
439
|
+
"--raw",
|
|
440
|
+
"-z",
|
|
441
|
+
"--abbrev=64",
|
|
442
|
+
"--no-ext-diff",
|
|
443
|
+
"--no-renames",
|
|
444
|
+
worktree.baseline_commit,
|
|
445
|
+
"--",
|
|
446
|
+
],
|
|
447
|
+
maximum=MAX_METADATA_BYTES,
|
|
448
|
+
)
|
|
449
|
+
patch = _run_git(
|
|
450
|
+
runner,
|
|
451
|
+
[*common_diff[:2], "--no-color", "--binary", "--full-index", *common_diff[2:]],
|
|
452
|
+
maximum=max_bytes + 1,
|
|
453
|
+
)
|
|
454
|
+
evidence = inspect_diff_evidence(
|
|
455
|
+
worktree_root=root,
|
|
456
|
+
baseline_commit=worktree.baseline_commit,
|
|
457
|
+
status_output=first_status,
|
|
458
|
+
name_status_output=names,
|
|
459
|
+
numstat_output=numstat,
|
|
460
|
+
raw_output=raw,
|
|
461
|
+
patch_output=patch,
|
|
462
|
+
max_files=max_files,
|
|
463
|
+
max_bytes=max_bytes,
|
|
464
|
+
)
|
|
465
|
+
second_status = _run_git(runner, status_arguments, maximum=MAX_METADATA_BYTES)
|
|
466
|
+
if second_status != first_status:
|
|
467
|
+
raise DiffPolicyError("concurrent_mutation")
|
|
468
|
+
return evidence
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Provider-agnostic, hardened whole-file edit apply engine.
|
|
2
|
+
|
|
3
|
+
The single authority on edit-payload safety: path-traversal rejection,
|
|
4
|
+
symlink/reparse-point rejection, per-file and total byte caps, and atomic
|
|
5
|
+
replace with full rollback on any mid-set failure. Shared by every provider
|
|
6
|
+
executor (OpenRouter, z.ai); it consumes a validated payload and is agnostic
|
|
7
|
+
to how that payload was produced (JSON vs plain-text marker parse)."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
from .claude_executor import AdapterError
|
|
16
|
+
|
|
17
|
+
MAX_EDIT_FILE_BYTES: Final = 1_048_576
|
|
18
|
+
MAX_EDIT_TOTAL_BYTES: Final = 1_073_741_824
|
|
19
|
+
MAX_EDIT_PATH_LENGTH: Final = 512
|
|
20
|
+
MAX_EDIT_SCOPE_FILES: Final = 1_000
|
|
21
|
+
EDIT_RESULT_MARKER: Final = "GRAPHITE_EDIT_OK"
|
|
22
|
+
_TEMP_SUFFIX: Final = ".graphite-tmp"
|
|
23
|
+
_REPARSE_POINT: Final = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_reparse_point(path: Path) -> bool:
|
|
27
|
+
try:
|
|
28
|
+
attributes = path.stat(follow_symlinks=False).st_file_attributes
|
|
29
|
+
except (OSError, AttributeError):
|
|
30
|
+
return False
|
|
31
|
+
return bool(attributes & _REPARSE_POINT)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_edit_path(value: object) -> tuple[str, ...]:
|
|
35
|
+
if (
|
|
36
|
+
not isinstance(value, str)
|
|
37
|
+
or not value
|
|
38
|
+
or len(value) > MAX_EDIT_PATH_LENGTH
|
|
39
|
+
or "\\" in value
|
|
40
|
+
or ":" in value
|
|
41
|
+
or "\x00" in value
|
|
42
|
+
or value.startswith("/")
|
|
43
|
+
or value.endswith(_TEMP_SUFFIX)
|
|
44
|
+
):
|
|
45
|
+
raise AdapterError("edit_scope_violation")
|
|
46
|
+
segments = tuple(value.split("/"))
|
|
47
|
+
if any(segment in ("", ".", "..") for segment in segments):
|
|
48
|
+
raise AdapterError("edit_scope_violation")
|
|
49
|
+
return segments
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cleanup_temps(temps: list[tuple[Path, Path]]) -> None:
|
|
53
|
+
for temp, _target in temps:
|
|
54
|
+
try:
|
|
55
|
+
temp.unlink(missing_ok=True)
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def apply_whole_file_edit(
|
|
61
|
+
*,
|
|
62
|
+
workspace: Path,
|
|
63
|
+
payload: object,
|
|
64
|
+
edit_scope: tuple[str, ...],
|
|
65
|
+
max_total_bytes: int,
|
|
66
|
+
) -> tuple[str, ...]:
|
|
67
|
+
"""Validate a whole-file payload completely, then apply it atomically.
|
|
68
|
+
|
|
69
|
+
A validation failure leaves the workspace byte-identical; a mid-set
|
|
70
|
+
replace failure restores every already-replaced file from held originals.
|
|
71
|
+
"""
|
|
72
|
+
if not isinstance(workspace, Path):
|
|
73
|
+
raise AdapterError("edit_scope_violation")
|
|
74
|
+
try:
|
|
75
|
+
workspace_root = workspace.resolve(strict=True)
|
|
76
|
+
except OSError:
|
|
77
|
+
raise AdapterError("edit_scope_violation") from None
|
|
78
|
+
if not workspace_root.is_dir() or workspace_root.is_symlink():
|
|
79
|
+
raise AdapterError("edit_scope_violation")
|
|
80
|
+
if (
|
|
81
|
+
not isinstance(edit_scope, tuple)
|
|
82
|
+
or not edit_scope
|
|
83
|
+
or len(edit_scope) > MAX_EDIT_SCOPE_FILES
|
|
84
|
+
or len(set(edit_scope)) != len(edit_scope)
|
|
85
|
+
):
|
|
86
|
+
raise AdapterError("edit_scope_violation")
|
|
87
|
+
if (
|
|
88
|
+
isinstance(max_total_bytes, bool)
|
|
89
|
+
or not isinstance(max_total_bytes, int)
|
|
90
|
+
or not 1 <= max_total_bytes <= MAX_EDIT_TOTAL_BYTES
|
|
91
|
+
):
|
|
92
|
+
raise AdapterError("edit_scope_violation")
|
|
93
|
+
if not isinstance(payload, dict) or set(payload) != {"files", "result"}:
|
|
94
|
+
raise AdapterError("edit_scope_violation")
|
|
95
|
+
if payload["result"] != EDIT_RESULT_MARKER:
|
|
96
|
+
raise AdapterError("edit_scope_violation")
|
|
97
|
+
files = payload["files"]
|
|
98
|
+
if not isinstance(files, list) or len(files) != len(edit_scope):
|
|
99
|
+
raise AdapterError("edit_scope_violation")
|
|
100
|
+
planned: dict[str, tuple[Path, tuple[str, ...], bytes]] = {}
|
|
101
|
+
total = 0
|
|
102
|
+
for item in files:
|
|
103
|
+
if not isinstance(item, dict) or set(item) != {"content", "path"}:
|
|
104
|
+
raise AdapterError("edit_scope_violation")
|
|
105
|
+
raw_path = item["path"]
|
|
106
|
+
segments = _validate_edit_path(raw_path)
|
|
107
|
+
if raw_path in planned:
|
|
108
|
+
raise AdapterError("edit_scope_violation")
|
|
109
|
+
content = item["content"]
|
|
110
|
+
if not isinstance(content, str):
|
|
111
|
+
raise AdapterError("edit_scope_violation")
|
|
112
|
+
try:
|
|
113
|
+
encoded = content.encode("utf-8")
|
|
114
|
+
except UnicodeEncodeError:
|
|
115
|
+
raise AdapterError("edit_scope_violation") from None
|
|
116
|
+
if len(encoded) > MAX_EDIT_FILE_BYTES:
|
|
117
|
+
raise AdapterError("edit_scope_violation")
|
|
118
|
+
total += len(encoded)
|
|
119
|
+
target = workspace_root
|
|
120
|
+
for segment in segments:
|
|
121
|
+
target = target / segment
|
|
122
|
+
planned[raw_path] = (target, segments, encoded)
|
|
123
|
+
if total > max_total_bytes or set(planned) != set(edit_scope):
|
|
124
|
+
raise AdapterError("edit_scope_violation")
|
|
125
|
+
originals: dict[Path, bytes] = {}
|
|
126
|
+
for raw_path in sorted(planned):
|
|
127
|
+
target, segments, _encoded = planned[raw_path]
|
|
128
|
+
component = workspace_root
|
|
129
|
+
for segment in segments:
|
|
130
|
+
component = component / segment
|
|
131
|
+
if component.is_symlink() or _is_reparse_point(component):
|
|
132
|
+
raise AdapterError("edit_scope_violation")
|
|
133
|
+
if not component.exists():
|
|
134
|
+
raise AdapterError("edit_scope_violation")
|
|
135
|
+
if not target.is_file():
|
|
136
|
+
raise AdapterError("edit_scope_violation")
|
|
137
|
+
if target.with_name(target.name + _TEMP_SUFFIX).exists():
|
|
138
|
+
raise AdapterError("edit_scope_violation")
|
|
139
|
+
try:
|
|
140
|
+
originals[target] = target.read_bytes()
|
|
141
|
+
except OSError:
|
|
142
|
+
raise AdapterError("edit_apply_failed") from None
|
|
143
|
+
temps: list[tuple[Path, Path]] = []
|
|
144
|
+
try:
|
|
145
|
+
for raw_path in sorted(planned):
|
|
146
|
+
target, _segments, encoded = planned[raw_path]
|
|
147
|
+
temp = target.with_name(target.name + _TEMP_SUFFIX)
|
|
148
|
+
temps.append((temp, target))
|
|
149
|
+
temp.write_bytes(encoded)
|
|
150
|
+
except OSError:
|
|
151
|
+
_cleanup_temps(temps)
|
|
152
|
+
raise AdapterError("edit_apply_failed") from None
|
|
153
|
+
replaced: list[Path] = []
|
|
154
|
+
try:
|
|
155
|
+
for temp, target in temps:
|
|
156
|
+
os.replace(temp, target)
|
|
157
|
+
replaced.append(target)
|
|
158
|
+
except OSError:
|
|
159
|
+
for target in replaced:
|
|
160
|
+
try:
|
|
161
|
+
target.write_bytes(originals[target])
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
_cleanup_temps(temps)
|
|
165
|
+
raise AdapterError("edit_apply_failed") from None
|
|
166
|
+
return tuple(sorted(planned))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Verified normalized effort mappings for exact Ollama model identities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any, Final, Mapping
|
|
6
|
+
|
|
7
|
+
from .contracts import Effort
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EffortMappingError(ValueError):
|
|
11
|
+
"""A fixed unsupported model or effort failure."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# No model-specific thinking level is enabled until its exact payload has been
|
|
15
|
+
# evaluated. Omitting `think` preserves the provider's documented default.
|
|
16
|
+
EFFORT_PAYLOADS: Final[Mapping[str, Mapping[Effort, Mapping[str, Any]]]] = MappingProxyType(
|
|
17
|
+
{
|
|
18
|
+
"kimi-k2.7-code:cloud": MappingProxyType(
|
|
19
|
+
{Effort.DEFAULT: MappingProxyType({})}
|
|
20
|
+
),
|
|
21
|
+
"minimax-m2.7:cloud": MappingProxyType(
|
|
22
|
+
{Effort.DEFAULT: MappingProxyType({})}
|
|
23
|
+
),
|
|
24
|
+
"nemotron-3-super:cloud": MappingProxyType(
|
|
25
|
+
{Effort.DEFAULT: MappingProxyType({})}
|
|
26
|
+
),
|
|
27
|
+
"minimax-m3:cloud": MappingProxyType(
|
|
28
|
+
{Effort.DEFAULT: MappingProxyType({})}
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def effort_payload(model_id: str, effort: Effort | str) -> dict[str, Any]:
|
|
35
|
+
"""Return a copy of the tested request fragment for an exact model/effort."""
|
|
36
|
+
try:
|
|
37
|
+
normalized = Effort(effort)
|
|
38
|
+
except (TypeError, ValueError) as exc:
|
|
39
|
+
raise EffortMappingError("effort_unsupported") from exc
|
|
40
|
+
mapping = EFFORT_PAYLOADS.get(model_id)
|
|
41
|
+
if mapping is None or normalized not in mapping:
|
|
42
|
+
raise EffortMappingError("effort_unsupported")
|
|
43
|
+
return dict(mapping[normalized])
|