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,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])