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