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,419 @@
1
+ """Hardened adapter for an authenticated Claude Code subscription CLI."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Final
10
+
11
+ from .cli_identity import (
12
+ CliIdentityPrimitiveError,
13
+ canonical_executable,
14
+ executable_sha256,
15
+ parse_semantic_version_output,
16
+ )
17
+ from .contracts import CliIdentity, Effort, PermissionMode, ProviderId
18
+ from .process_runner import (
19
+ CliProcessError,
20
+ CliProcessFailureDiagnostics,
21
+ CliProcessResult,
22
+ decode_cli_output,
23
+ run_cli_process,
24
+ )
25
+
26
+ ADAPTER_PROTOCOL_VERSION: Final = "1.0.0"
27
+ PREFLIGHT_TIMEOUT_SECONDS: Final = 15.0
28
+ EXECUTION_TIMEOUT_SECONDS: Final = 1_800.0
29
+ # Read-only execution runs in plan permission mode, whose system guidance
30
+ # pushes the model toward the non-allowlisted ExitPlanMode tool. Without a
31
+ # turn bound, a denial-retry loop can only end at the process timeout, which
32
+ # is indistinguishable from slow analysis. A fixed cap converts that loop
33
+ # into a distinguishable max-turns failure while leaving room for bounded
34
+ # Read exploration. Workspace-write argv is intentionally unbounded here to
35
+ # preserve the live-proven edit contract.
36
+ READ_ONLY_EXECUTION_MAX_TURNS: Final = 8
37
+ MAX_MESSAGE_LENGTH: Final = 1_048_576
38
+ MAX_TOKEN_COUNT: Final = 10_000_000
39
+ PROFILE_VERIFICATION_MARKER: Final = "GRAPHITE_PROFILE_OK"
40
+ MAX_EVENT_COUNT: Final = 10_000
41
+ _VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*) \(Claude Code\)\r?\n?$")
42
+ _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$")
43
+ _TRANSPORT_ERRORS: Final = {
44
+ "timeout": "timeout",
45
+ "cancelled": "cancelled",
46
+ "response_limit": "response_limit",
47
+ "process_containment_unavailable": "containment",
48
+ "process_containment_failed": "containment",
49
+ }
50
+
51
+
52
+ class AdapterError(RuntimeError):
53
+ """Stable adapter failure that contains no provider diagnostics."""
54
+
55
+ def __init__(
56
+ self,
57
+ code: str,
58
+ *,
59
+ process_diagnostics: CliProcessFailureDiagnostics | None = None,
60
+ ) -> None:
61
+ self.code = code
62
+ self.process_diagnostics = (
63
+ process_diagnostics
64
+ if isinstance(process_diagnostics, CliProcessFailureDiagnostics)
65
+ else None
66
+ )
67
+ super().__init__(code)
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class ClaudeExecutionResult:
72
+ effective_model: str
73
+ message: str = field(repr=False)
74
+ input_tokens: int | None
75
+ output_tokens: int | None
76
+ duration_seconds: float
77
+ input_sha256: str
78
+ stdout_sha256: str
79
+ stderr_sha256: str
80
+
81
+
82
+ Transport = Callable[..., CliProcessResult]
83
+
84
+
85
+ def _canonical_file(path: Path, *, workspace: Path | None = None) -> Path:
86
+ try:
87
+ return canonical_executable(path, workspace=workspace)
88
+ except CliIdentityPrimitiveError:
89
+ raise AdapterError("executable_invalid") from None
90
+
91
+
92
+ def _file_sha256(path: Path) -> str:
93
+ try:
94
+ return executable_sha256(path)
95
+ except CliIdentityPrimitiveError:
96
+ raise AdapterError("executable_invalid") from None
97
+
98
+
99
+ def _invoke(transport: Transport, **kwargs: object) -> CliProcessResult:
100
+ try:
101
+ result = transport(**kwargs)
102
+ except CliProcessError as exc:
103
+ raise AdapterError(
104
+ _TRANSPORT_ERRORS.get(exc.code, "unavailable"),
105
+ process_diagnostics=exc.diagnostics,
106
+ ) from None
107
+ except AdapterError:
108
+ raise
109
+ except Exception:
110
+ raise AdapterError("unavailable") from None
111
+ if not isinstance(result, CliProcessResult) or result.returncode != 0:
112
+ raise AdapterError("unavailable")
113
+ return result
114
+
115
+
116
+ def _common_call(
117
+ *, executable: Path, workspace: Path, credential_home: Path | None
118
+ ) -> dict[str, object]:
119
+ return {
120
+ "cwd": workspace,
121
+ "provider": ProviderId.CLAUDE_CODE,
122
+ "credential_home": credential_home,
123
+ "timeout_seconds": PREFLIGHT_TIMEOUT_SECONDS,
124
+ }
125
+
126
+
127
+ def preflight_claude(
128
+ *,
129
+ executable: Path,
130
+ workspace: Path,
131
+ credential_home: Path | None,
132
+ transport: Transport = run_cli_process,
133
+ ) -> CliIdentity:
134
+ """Verify executable identity and Claude subscription authentication."""
135
+ resolved = _canonical_file(executable, workspace=workspace)
136
+ executable_digest = _file_sha256(resolved)
137
+ common = _common_call(
138
+ executable=resolved, workspace=workspace, credential_home=credential_home
139
+ )
140
+ version_result = _invoke(
141
+ transport, argv=(str(resolved), "--version"), stdin=b"", **common
142
+ )
143
+ try:
144
+ version_text = decode_cli_output(version_result.stdout)
145
+ except CliProcessError:
146
+ raise AdapterError("version") from None
147
+ try:
148
+ version = parse_semantic_version_output(version_text, _VERSION)
149
+ except CliIdentityPrimitiveError:
150
+ raise AdapterError("version")
151
+ auth_result = _invoke(
152
+ transport,
153
+ argv=(str(resolved), "auth", "status", "--json"),
154
+ stdin=b"",
155
+ **common,
156
+ )
157
+ try:
158
+ auth = json.loads(decode_cli_output(auth_result.stdout))
159
+ except (json.JSONDecodeError, CliProcessError):
160
+ raise AdapterError("auth_required") from None
161
+ if (
162
+ not isinstance(auth, dict)
163
+ or auth.get("loggedIn") is not True
164
+ or auth.get("authMethod") != "claude.ai"
165
+ or auth.get("apiProvider") != "firstParty"
166
+ ):
167
+ raise AdapterError("auth_required")
168
+ if _file_sha256(resolved) != executable_digest:
169
+ raise AdapterError("version")
170
+ return CliIdentity(
171
+ ProviderId.CLAUDE_CODE,
172
+ executable_digest,
173
+ version,
174
+ ADAPTER_PROTOCOL_VERSION,
175
+ )
176
+
177
+
178
+ def _identifier(value: str) -> str:
179
+ if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
180
+ raise AdapterError("request_invalid")
181
+ return value
182
+
183
+
184
+ def _token(value: object) -> int:
185
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= MAX_TOKEN_COUNT:
186
+ raise AdapterError("protocol")
187
+ return value
188
+
189
+
190
+ def _parse_result(
191
+ stdout: bytes,
192
+ expected_model: str,
193
+ expected_structured_output: dict[str, object] | None = None,
194
+ *,
195
+ structured_output_required: bool = False,
196
+ ) -> tuple[str, str, int, int]:
197
+ try:
198
+ lines = decode_cli_output(stdout).splitlines()
199
+ except CliProcessError:
200
+ raise AdapterError("protocol") from None
201
+ if not lines or len(lines) > MAX_EVENT_COUNT or any(not line.strip() for line in lines):
202
+ raise AdapterError("protocol")
203
+ events: list[dict[str, object]] = []
204
+ for line in lines:
205
+ try:
206
+ event = json.loads(line)
207
+ except json.JSONDecodeError:
208
+ raise AdapterError("protocol") from None
209
+ if not isinstance(event, dict) or event.get("type") not in {
210
+ "system", "assistant", "user", "result", "rate_limit_event"
211
+ }:
212
+ raise AdapterError("protocol")
213
+ events.append(event)
214
+ payload = events[-1]
215
+ if payload.get("type") != "result" or any(
216
+ event.get("type") == "result" for event in events[:-1]
217
+ ):
218
+ raise AdapterError("protocol")
219
+ if payload.get("subtype") != "success" or payload.get("is_error") is not False:
220
+ subtype = str(payload.get("subtype", "")).lower()
221
+ if "auth" in subtype or "login" in subtype:
222
+ raise AdapterError("auth_required")
223
+ if "quota" in subtype or "rate" in subtype or "limit" in subtype:
224
+ raise AdapterError("quota")
225
+ raise AdapterError("unavailable")
226
+ if expected_structured_output is not None:
227
+ if payload.get("structured_output") != expected_structured_output:
228
+ raise AdapterError("protocol")
229
+ message = PROFILE_VERIFICATION_MARKER
230
+ elif structured_output_required:
231
+ structured = payload.get("structured_output")
232
+ if not isinstance(structured, dict):
233
+ raise AdapterError("protocol")
234
+ try:
235
+ message = json.dumps(structured, sort_keys=True, separators=(",", ":"))
236
+ except (TypeError, ValueError):
237
+ raise AdapterError("protocol") from None
238
+ if not message or len(message) > MAX_MESSAGE_LENGTH:
239
+ raise AdapterError("protocol")
240
+ else:
241
+ message = payload.get("result")
242
+ if not isinstance(message, str) or not message or len(message) > MAX_MESSAGE_LENGTH:
243
+ raise AdapterError("protocol")
244
+ assistant_models: list[str] = []
245
+ for event in events[:-1]:
246
+ if event.get("type") != "assistant":
247
+ continue
248
+ assistant = event.get("message")
249
+ model = assistant.get("model") if isinstance(assistant, dict) else None
250
+ if not isinstance(model, str) or _IDENTIFIER.fullmatch(model) is None:
251
+ raise AdapterError("model_identity_unverified")
252
+ assistant_models.append(model)
253
+ if not assistant_models:
254
+ raise AdapterError("model_identity_unverified")
255
+ if any(model != expected_model for model in assistant_models):
256
+ raise AdapterError("model_mismatch")
257
+ usage = payload.get("usage")
258
+ if not isinstance(usage, dict):
259
+ raise AdapterError("protocol")
260
+ return (
261
+ message,
262
+ expected_model,
263
+ _token(usage.get("input_tokens")),
264
+ _token(usage.get("output_tokens")),
265
+ )
266
+
267
+
268
+ def execute_claude(
269
+ *,
270
+ executable: Path,
271
+ workspace: Path,
272
+ credential_home: Path | None,
273
+ prompt: bytes,
274
+ requested_model: str,
275
+ expected_effective_model: str,
276
+ effort: Effort,
277
+ permission_mode: PermissionMode,
278
+ output_schema: dict[str, object] | None = None,
279
+ transport: Transport = run_cli_process,
280
+ timeout_seconds: float = EXECUTION_TIMEOUT_SECONDS,
281
+ _verification_marker: str | None = None,
282
+ ) -> ClaudeExecutionResult:
283
+ """Execute exactly one noninteractive, non-resumable Claude task."""
284
+ resolved = _canonical_file(executable, workspace=workspace)
285
+ requested = _identifier(requested_model)
286
+ expected = _identifier(expected_effective_model)
287
+ try:
288
+ normalized_effort = Effort(effort)
289
+ permission = PermissionMode(permission_mode)
290
+ except (TypeError, ValueError):
291
+ raise AdapterError("request_invalid") from None
292
+ if normalized_effort is Effort.DEFAULT:
293
+ raise AdapterError("request_invalid")
294
+ permission_value = "acceptEdits" if permission is PermissionMode.WORKSPACE_WRITE else "plan"
295
+ allowed_tools = "Read,Edit,Write" if permission is PermissionMode.WORKSPACE_WRITE else "Read"
296
+ structured_output: dict[str, object] | None = None
297
+ structured_args: tuple[str, ...] = ()
298
+ if _verification_marker is not None:
299
+ if (
300
+ _verification_marker != PROFILE_VERIFICATION_MARKER
301
+ or permission is not PermissionMode.READ_ONLY
302
+ ):
303
+ raise AdapterError("request_invalid")
304
+ structured_output = {"verification": PROFILE_VERIFICATION_MARKER}
305
+ schema = {
306
+ "additionalProperties": False,
307
+ "properties": {
308
+ "verification": {
309
+ "const": PROFILE_VERIFICATION_MARKER,
310
+ "type": "string",
311
+ }
312
+ },
313
+ "required": ["verification"],
314
+ "type": "object",
315
+ }
316
+ structured_args = (
317
+ "--max-turns",
318
+ "1",
319
+ "--json-schema",
320
+ json.dumps(schema, sort_keys=True, separators=(",", ":")),
321
+ )
322
+ if output_schema is not None:
323
+ # CLI-validated structured output is the only reliable bare-JSON
324
+ # response contract; free text cannot guarantee parseability.
325
+ if (
326
+ _verification_marker is not None
327
+ or permission is not PermissionMode.READ_ONLY
328
+ or not isinstance(output_schema, dict)
329
+ or not output_schema
330
+ ):
331
+ raise AdapterError("request_invalid")
332
+ try:
333
+ schema_argument = json.dumps(
334
+ output_schema, sort_keys=True, separators=(",", ":")
335
+ )
336
+ except (TypeError, ValueError):
337
+ raise AdapterError("request_invalid") from None
338
+ structured_args = ("--json-schema", schema_argument)
339
+ turn_args: tuple[str, ...] = ()
340
+ if _verification_marker is None and permission is PermissionMode.READ_ONLY:
341
+ turn_args = ("--max-turns", str(READ_ONLY_EXECUTION_MAX_TURNS))
342
+ argv = (
343
+ str(resolved),
344
+ "--safe-mode",
345
+ "--no-chrome",
346
+ "--disable-slash-commands",
347
+ "--strict-mcp-config",
348
+ "--permission-mode",
349
+ permission_value,
350
+ "--allowedTools",
351
+ allowed_tools,
352
+ "--model",
353
+ requested,
354
+ "--effort",
355
+ normalized_effort.value,
356
+ *structured_args,
357
+ *turn_args,
358
+ "--no-session-persistence",
359
+ "--output-format",
360
+ "stream-json",
361
+ "--verbose",
362
+ "--input-format",
363
+ "text",
364
+ "--print",
365
+ )
366
+ result = _invoke(
367
+ transport,
368
+ argv=argv,
369
+ cwd=workspace,
370
+ stdin=prompt,
371
+ provider=ProviderId.CLAUDE_CODE,
372
+ credential_home=credential_home,
373
+ timeout_seconds=timeout_seconds,
374
+ )
375
+ message, effective_model, input_tokens, output_tokens = _parse_result(
376
+ result.stdout,
377
+ expected,
378
+ structured_output,
379
+ structured_output_required=output_schema is not None,
380
+ )
381
+ return ClaudeExecutionResult(
382
+ effective_model,
383
+ message,
384
+ input_tokens,
385
+ output_tokens,
386
+ result.duration_seconds,
387
+ result.input_sha256,
388
+ result.stdout_sha256,
389
+ result.stderr_sha256,
390
+ )
391
+
392
+
393
+ def execute_claude_profile_verification(
394
+ *,
395
+ executable: Path,
396
+ workspace: Path,
397
+ credential_home: Path | None,
398
+ prompt: bytes,
399
+ requested_model: str,
400
+ expected_effective_model: str,
401
+ effort: Effort,
402
+ permission_mode: PermissionMode,
403
+ transport: Transport = run_cli_process,
404
+ timeout_seconds: float = EXECUTION_TIMEOUT_SECONDS,
405
+ ) -> ClaudeExecutionResult:
406
+ """Run one read-only, schema-constrained capability verification turn."""
407
+ return execute_claude(
408
+ executable=executable,
409
+ workspace=workspace,
410
+ credential_home=credential_home,
411
+ prompt=prompt,
412
+ requested_model=requested_model,
413
+ expected_effective_model=expected_effective_model,
414
+ effort=effort,
415
+ permission_mode=permission_mode,
416
+ transport=transport,
417
+ timeout_seconds=timeout_seconds,
418
+ _verification_marker=PROFILE_VERIFICATION_MARKER,
419
+ )
@@ -0,0 +1,102 @@
1
+ """Bounded, non-inference Claude Code lifecycle observation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ import re
7
+ import time
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import Final
11
+
12
+ from .cli_identity import CliIdentityPrimitiveError, canonical_executable, executable_sha256, parse_semantic_version_output
13
+ from .lifecycle import LifecycleProviderId, ProviderRuntimeIdentity, RuntimeKind
14
+ from .probe_runner import ProviderProbeError, run_process_probe
15
+ from .process_runner import CliProcessError, CliProcessResult, decode_cli_output
16
+
17
+ _VERSION: Final = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*) \(Claude Code\)\r?\n?$")
18
+ # Claude documents that --help is intentionally incomplete. Runtime-only controls
19
+ # such as --allowedTools and --max-turns are enforced by the verification argv and
20
+ # its fail-closed process result rather than inferred from help text.
21
+ _HELP_DISCOVERABLE_FLAGS: Final = frozenset(
22
+ {
23
+ "--disable-slash-commands",
24
+ "--effort",
25
+ "--input-format",
26
+ "--json-schema",
27
+ "--model",
28
+ "--no-chrome",
29
+ "--no-session-persistence",
30
+ "--output-format",
31
+ "--permission-mode",
32
+ "--print",
33
+ "--safe-mode",
34
+ "--strict-mcp-config",
35
+ "--verbose",
36
+ }
37
+ )
38
+ _FLAG_PATTERNS: Final = tuple(
39
+ re.compile(rf"(?<![A-Za-z0-9_-]){re.escape(flag)}(?![A-Za-z0-9_-])")
40
+ for flag in sorted(_HELP_DISCOVERABLE_FLAGS)
41
+ )
42
+ ProcessProbe = Callable[..., CliProcessResult]
43
+
44
+
45
+ def _text(result: CliProcessResult, code: str) -> str:
46
+ if not isinstance(result, CliProcessResult) or result.returncode != 0:
47
+ raise ProviderProbeError(code)
48
+ try:
49
+ return decode_cli_output(result.stdout)
50
+ except CliProcessError:
51
+ raise ProviderProbeError(code) from None
52
+
53
+
54
+ def observe_claude(
55
+ *, executable: Path, workspace: Path, credential_home: Path | None,
56
+ observed_at: int, policy_version: str, timeout_seconds: float = 15.0,
57
+ transport: ProcessProbe = run_process_probe,
58
+ clock: Callable[[], float] = time.monotonic,
59
+ ) -> ProviderRuntimeIdentity:
60
+ """Observe one Claude CLI through fixed local metadata commands only."""
61
+ if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)) or not math.isfinite(timeout_seconds) or not 0.1 <= timeout_seconds <= 30:
62
+ raise ProviderProbeError("probe_request_invalid")
63
+ try:
64
+ resolved = canonical_executable(executable, workspace=workspace)
65
+ runtime_digest = executable_sha256(resolved)
66
+ except CliIdentityPrimitiveError:
67
+ raise ProviderProbeError("probe_executable_invalid") from None
68
+ deadline = clock() + float(timeout_seconds)
69
+
70
+ def call(*args: str) -> CliProcessResult:
71
+ remaining = deadline - clock()
72
+ if remaining <= 0:
73
+ raise ProviderProbeError("probe_timeout")
74
+ try:
75
+ return transport(argv=(str(resolved), *args), cwd=workspace, provider=LifecycleProviderId.CLAUDE_CODE, credential_home=credential_home, timeout_seconds=remaining)
76
+ except ProviderProbeError:
77
+ raise
78
+ except Exception:
79
+ raise ProviderProbeError("probe_failed") from None
80
+
81
+ try:
82
+ version = parse_semantic_version_output(_text(call("--version"), "probe_version_invalid"), _VERSION)
83
+ except CliIdentityPrimitiveError:
84
+ raise ProviderProbeError("probe_version_invalid") from None
85
+ help_text = _text(call("--help"), "probe_capability_missing")
86
+ if not all(pattern.search(help_text) is not None for pattern in _FLAG_PATTERNS):
87
+ raise ProviderProbeError("probe_capability_missing")
88
+ try:
89
+ auth = json.loads(_text(call("auth", "status", "--json"), "probe_auth_unhealthy"))
90
+ except (json.JSONDecodeError, RecursionError):
91
+ raise ProviderProbeError("probe_auth_unhealthy") from None
92
+ if not isinstance(auth, dict) or auth.get("loggedIn") is not True or auth.get("authMethod") != "claude.ai" or auth.get("apiProvider") != "firstParty":
93
+ raise ProviderProbeError("probe_auth_unhealthy")
94
+ try:
95
+ if executable_sha256(resolved) != runtime_digest:
96
+ raise ProviderProbeError("probe_identity_changed")
97
+ except CliIdentityPrimitiveError:
98
+ raise ProviderProbeError("probe_identity_changed") from None
99
+ try:
100
+ return ProviderRuntimeIdentity(LifecycleProviderId.CLAUDE_CODE, RuntimeKind.LOCAL_CLI, version, runtime_digest, None, None, ("credential_health", "structured_output", "version"), policy_version, observed_at)
101
+ except ValueError:
102
+ raise ProviderProbeError("probe_request_invalid") from None
@@ -0,0 +1,84 @@
1
+ """Shared, non-executing CLI identity primitives."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import re
6
+ import stat
7
+ from pathlib import Path
8
+ from typing import Final, Pattern
9
+
10
+ MAX_EXECUTABLE_BYTES: Final = 512 * 1024 * 1024
11
+ _REPARSE_POINT: Final = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
12
+ _SEMANTIC_VERSION: Final = re.compile(
13
+ r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"
14
+ )
15
+
16
+
17
+ class CliIdentityPrimitiveError(RuntimeError):
18
+ """Stable local identity failure."""
19
+
20
+
21
+ def _is_reparse(metadata: object) -> bool:
22
+ return bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT)
23
+
24
+
25
+ def canonical_executable(path: Path, *, workspace: Path | None = None) -> Path:
26
+ """Return an absolute regular executable outside the workspace trust boundary."""
27
+ if not isinstance(path, Path) or not path.is_absolute():
28
+ raise CliIdentityPrimitiveError("executable_invalid")
29
+ try:
30
+ metadata = path.lstat()
31
+ resolved = path.resolve(strict=True)
32
+ resolved_metadata = resolved.stat()
33
+ workspace_root = workspace.resolve(strict=True) if workspace is not None else None
34
+ except (OSError, RuntimeError):
35
+ raise CliIdentityPrimitiveError("executable_invalid") from None
36
+ if (
37
+ not stat.S_ISREG(metadata.st_mode)
38
+ or stat.S_ISLNK(metadata.st_mode)
39
+ or _is_reparse(metadata)
40
+ or not stat.S_ISREG(resolved_metadata.st_mode)
41
+ or _is_reparse(resolved_metadata)
42
+ or metadata.st_size <= 0
43
+ or metadata.st_size > MAX_EXECUTABLE_BYTES
44
+ or workspace_root is not None
45
+ and resolved.is_relative_to(workspace_root)
46
+ ):
47
+ raise CliIdentityPrimitiveError("executable_invalid")
48
+ return resolved
49
+
50
+
51
+ def executable_sha256(path: Path) -> str:
52
+ """Hash a bounded regular file and reject concurrent identity drift."""
53
+ digest = hashlib.sha256()
54
+ try:
55
+ before = path.stat()
56
+ if not stat.S_ISREG(before.st_mode) or not 0 < before.st_size <= MAX_EXECUTABLE_BYTES:
57
+ raise OSError
58
+ with path.open("rb") as handle:
59
+ while chunk := handle.read(1024 * 1024):
60
+ digest.update(chunk)
61
+ after = path.stat()
62
+ except OSError:
63
+ raise CliIdentityPrimitiveError("executable_invalid") from None
64
+ if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (
65
+ after.st_dev,
66
+ after.st_ino,
67
+ after.st_size,
68
+ after.st_mtime_ns,
69
+ ):
70
+ raise CliIdentityPrimitiveError("executable_changed")
71
+ return digest.hexdigest()
72
+
73
+
74
+ def parse_semantic_version_output(text: str, pattern: Pattern[str]) -> str:
75
+ """Extract and revalidate one exact semantic version match."""
76
+ if not isinstance(text, str) or not isinstance(pattern, Pattern):
77
+ raise CliIdentityPrimitiveError("version_invalid")
78
+ match = pattern.fullmatch(text)
79
+ if match is None or len(match.groups()) != 3:
80
+ raise CliIdentityPrimitiveError("version_invalid")
81
+ version = ".".join(match.groups())
82
+ if _SEMANTIC_VERSION.fullmatch(version) is None:
83
+ raise CliIdentityPrimitiveError("version_invalid")
84
+ return version