attackmap 0.6.4__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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/llm_review.py
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""Anthropic-powered narrative review generator.
|
|
2
|
+
|
|
3
|
+
Wraps the existing prompt scaffolding (`review_prompts.render_review_prompts`)
|
|
4
|
+
in a real Claude call so users can produce a story-bearing defensive review
|
|
5
|
+
without copy-pasting JSON into a chat client.
|
|
6
|
+
|
|
7
|
+
Auth resolution (in order):
|
|
8
|
+
1. Explicit `client=` (tests / programmatic override)
|
|
9
|
+
2. `ANTHROPIC_API_KEY` env → SDK with api_key (per-token API billing)
|
|
10
|
+
3. `ANTHROPIC_AUTH_TOKEN` env → SDK with auth_token (OAuth bearer)
|
|
11
|
+
4. `claude` CLI on PATH → shell out to `claude -p --output-format=json`
|
|
12
|
+
(uses whatever auth `claude login` configured —
|
|
13
|
+
typically the user's Pro/Max subscription)
|
|
14
|
+
|
|
15
|
+
The user can force a backend via the `backend` argument or the CLI's
|
|
16
|
+
`--llm-backend` flag; "auto" walks the order above and picks the first one
|
|
17
|
+
that resolves.
|
|
18
|
+
|
|
19
|
+
Optional dependency: `pip install "attackmap[llm] @ git+https://github.com/mlaify/AttackMap.git"` installs the anthropic SDK.
|
|
20
|
+
The CLI backend only requires the `claude` binary on PATH (no SDK install).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
from collections.abc import Iterator
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from functools import lru_cache
|
|
35
|
+
from typing import Any, Literal
|
|
36
|
+
|
|
37
|
+
from .models import AttackPath, AttackSurface, Finding, ScanResult
|
|
38
|
+
from .review_prompts import (
|
|
39
|
+
render_critic_prompts,
|
|
40
|
+
render_hunt_generate_prompts,
|
|
41
|
+
render_hunt_prompts,
|
|
42
|
+
render_hunt_verify_prompts,
|
|
43
|
+
render_remediation_prompts,
|
|
44
|
+
render_review_prompts,
|
|
45
|
+
render_skeptic_prompts,
|
|
46
|
+
render_triage_prompts,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_LLM_INSTALL = 'pip install "attackmap[llm] @ git+https://github.com/mlaify/AttackMap.git"'
|
|
50
|
+
|
|
51
|
+
DEFAULT_MODEL = "claude-opus-4-8"
|
|
52
|
+
DEFAULT_EFFORT: Literal["low", "medium", "high", "xhigh", "max"] = "high"
|
|
53
|
+
DEFAULT_MAX_TOKENS = 32000
|
|
54
|
+
|
|
55
|
+
# Fast mode (2.5x output speed, premium price) is a research-preview beta and
|
|
56
|
+
# only runs on the Opus 4.8 / 4.7 tiers via the API backend — the `claude` CLI
|
|
57
|
+
# backend and other models silently fall back to standard speed.
|
|
58
|
+
FAST_MODE_BETA = "fast-mode-2026-02-01"
|
|
59
|
+
FAST_CAPABLE_MODELS = frozenset({"claude-opus-4-8", "claude-opus-4-7"})
|
|
60
|
+
CLAUDE_CLI_TIMEOUT_SECONDS = 600
|
|
61
|
+
|
|
62
|
+
# OpenAI / Codex provider. `gpt-5-codex` is the code-optimized GPT-5 (Responses
|
|
63
|
+
# API only) and the safe default; any other model ID is passed through verbatim
|
|
64
|
+
# so we never have to chase OpenAI's point-release churn. Reasoning effort maps
|
|
65
|
+
# onto the Responses API's `reasoning.effort` — OpenAI exposes low/medium/high,
|
|
66
|
+
# so our extended tiers (xhigh/max) clamp down to "high".
|
|
67
|
+
OPENAI_DEFAULT_MODEL = "gpt-5-codex"
|
|
68
|
+
OPENAI_API_TIMEOUT_SECONDS = 900
|
|
69
|
+
CODEX_CLI_TIMEOUT_SECONDS = 900
|
|
70
|
+
OPENAI_EFFORT_MAP = {
|
|
71
|
+
"low": "low",
|
|
72
|
+
"medium": "medium",
|
|
73
|
+
"high": "high",
|
|
74
|
+
"xhigh": "high",
|
|
75
|
+
"max": "high",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
LlmProvider = Literal["claude", "openai"]
|
|
79
|
+
LlmBackend = Literal["auto", "api", "cli"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LlmReviewError(RuntimeError):
|
|
83
|
+
"""Raised when the LLM-backed review cannot be produced."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class LlmReviewResult:
|
|
88
|
+
markdown: str
|
|
89
|
+
model: str
|
|
90
|
+
stop_reason: str | None
|
|
91
|
+
usage: dict[str, int]
|
|
92
|
+
backend: Literal["api", "cli"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------- Auth + SDK backend ----------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_sdk_client(api_key: str | None, client: Any | None) -> tuple[Any, str]:
|
|
99
|
+
"""Return (anthropic client, auth_kind). auth_kind ∈ {'api_key', 'auth_token'}."""
|
|
100
|
+
if client is not None:
|
|
101
|
+
return client, "explicit"
|
|
102
|
+
try:
|
|
103
|
+
import anthropic
|
|
104
|
+
except ImportError as exc:
|
|
105
|
+
raise LlmReviewError(
|
|
106
|
+
"The anthropic SDK is not installed. Install with "
|
|
107
|
+
f"`{_LLM_INSTALL}` "
|
|
108
|
+
"to use the API backend, or ensure the `claude` CLI is on PATH for the CLI backend."
|
|
109
|
+
) from exc
|
|
110
|
+
|
|
111
|
+
resolved_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
112
|
+
if resolved_key:
|
|
113
|
+
return anthropic.Anthropic(api_key=resolved_key), "api_key"
|
|
114
|
+
|
|
115
|
+
auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN")
|
|
116
|
+
if auth_token:
|
|
117
|
+
return anthropic.Anthropic(auth_token=auth_token), "auth_token"
|
|
118
|
+
|
|
119
|
+
raise LlmReviewError(
|
|
120
|
+
"No API credentials found. Set ANTHROPIC_API_KEY (per-token API billing) or "
|
|
121
|
+
"ANTHROPIC_AUTH_TOKEN (OAuth bearer), or install the `claude` CLI and run "
|
|
122
|
+
"`claude login` to use your Claude subscription."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _extract_text(blocks: list[Any]) -> str:
|
|
127
|
+
parts: list[str] = []
|
|
128
|
+
for block in blocks:
|
|
129
|
+
if getattr(block, "type", None) == "text":
|
|
130
|
+
text = getattr(block, "text", "")
|
|
131
|
+
if text:
|
|
132
|
+
parts.append(text)
|
|
133
|
+
return "\n".join(parts).strip()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _usage_dict(usage: Any) -> dict[str, int]:
|
|
137
|
+
out: dict[str, int] = {}
|
|
138
|
+
for field in (
|
|
139
|
+
"input_tokens",
|
|
140
|
+
"output_tokens",
|
|
141
|
+
"cache_creation_input_tokens",
|
|
142
|
+
"cache_read_input_tokens",
|
|
143
|
+
):
|
|
144
|
+
value = getattr(usage, field, None)
|
|
145
|
+
if isinstance(value, int):
|
|
146
|
+
out[field] = value
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run_via_sdk(
|
|
151
|
+
rendered_system: str,
|
|
152
|
+
rendered_user: str,
|
|
153
|
+
*,
|
|
154
|
+
model: str,
|
|
155
|
+
effort: str,
|
|
156
|
+
max_tokens: int,
|
|
157
|
+
api_key: str | None,
|
|
158
|
+
client: Any | None,
|
|
159
|
+
speed: str = "standard",
|
|
160
|
+
) -> LlmReviewResult:
|
|
161
|
+
sdk_client, _ = _resolve_sdk_client(api_key, client)
|
|
162
|
+
|
|
163
|
+
request_kwargs: dict[str, Any] = {
|
|
164
|
+
"model": model,
|
|
165
|
+
"max_tokens": max_tokens,
|
|
166
|
+
"system": [
|
|
167
|
+
{"type": "text", "text": rendered_system, "cache_control": {"type": "ephemeral"}}
|
|
168
|
+
],
|
|
169
|
+
"messages": [{"role": "user", "content": rendered_user}],
|
|
170
|
+
"thinking": {"type": "adaptive"},
|
|
171
|
+
"output_config": {"effort": effort},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Fast mode needs the beta endpoint + flag + top-level speed, and only on
|
|
175
|
+
# the Opus 4.8/4.7 tiers. Anything else runs at standard speed.
|
|
176
|
+
fast = speed == "fast" and model in FAST_CAPABLE_MODELS
|
|
177
|
+
messages_api = sdk_client.messages
|
|
178
|
+
if fast:
|
|
179
|
+
request_kwargs["speed"] = "fast"
|
|
180
|
+
request_kwargs["betas"] = [FAST_MODE_BETA]
|
|
181
|
+
messages_api = sdk_client.beta.messages
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
with messages_api.stream(**request_kwargs) as stream:
|
|
185
|
+
final_message = stream.get_final_message()
|
|
186
|
+
except LlmReviewError:
|
|
187
|
+
raise
|
|
188
|
+
except Exception as exc: # pragma: no cover - surface SDK errors verbatim
|
|
189
|
+
raise LlmReviewError(f"Claude API call failed: {exc}") from exc
|
|
190
|
+
|
|
191
|
+
markdown = _extract_text(getattr(final_message, "content", []) or [])
|
|
192
|
+
if not markdown:
|
|
193
|
+
raise LlmReviewError("Claude returned no text content for the review.")
|
|
194
|
+
|
|
195
|
+
stop_reason = getattr(final_message, "stop_reason", None)
|
|
196
|
+
return LlmReviewResult(
|
|
197
|
+
markdown=markdown,
|
|
198
|
+
model=model,
|
|
199
|
+
stop_reason=stop_reason if isinstance(stop_reason, str) else None,
|
|
200
|
+
usage=_usage_dict(getattr(final_message, "usage", None)),
|
|
201
|
+
backend="api",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------- CLI backend isolation (#232) ----------
|
|
206
|
+
#
|
|
207
|
+
# The CLI backends hand an *agent* the evidence pack, which is derived from an
|
|
208
|
+
# untrusted repository and may carry prompt injection. They are spawned so that
|
|
209
|
+
# nothing from that repository can configure the agent and the agent has as
|
|
210
|
+
# little reach as its CLI allows:
|
|
211
|
+
# * cwd is a fresh empty temp dir, never the scan root or the caller's cwd,
|
|
212
|
+
# so project-scoped `.claude/settings.json` (hooks, permissions),
|
|
213
|
+
# `.mcp.json`, `CLAUDE.md` and `AGENTS.md` are never discovered;
|
|
214
|
+
# * claude: no built-in tools, user-level settings only, no MCP servers, no
|
|
215
|
+
# session persistence;
|
|
216
|
+
# * codex: read-only sandbox rooted at the temp dir, no execpolicy rules,
|
|
217
|
+
# ephemeral session. Codex has no tool-less mode, so its model can still
|
|
218
|
+
# run read-only commands as the user; `--llm-backend api` is the only fully
|
|
219
|
+
# tool-less path for OpenAI (documented in the README);
|
|
220
|
+
# * the environment is scrubbed of credentials unrelated to the provider.
|
|
221
|
+
# If the installed CLI lacks the hardening flags we refuse to run it rather
|
|
222
|
+
# than fall back to an unhardened invocation.
|
|
223
|
+
|
|
224
|
+
CLAUDE_CLI_HARDENING_ARGS = (
|
|
225
|
+
"--tools",
|
|
226
|
+
"",
|
|
227
|
+
"--setting-sources",
|
|
228
|
+
"user",
|
|
229
|
+
"--strict-mcp-config",
|
|
230
|
+
"--mcp-config",
|
|
231
|
+
'{"mcpServers":{}}',
|
|
232
|
+
"--no-session-persistence",
|
|
233
|
+
)
|
|
234
|
+
_CLAUDE_REQUIRED_FLAGS = ("--tools", "--setting-sources", "--strict-mcp-config", "--no-session-persistence")
|
|
235
|
+
_CODEX_REQUIRED_FLAGS = ("--cd", "--ephemeral", "--ignore-rules", "--sandbox")
|
|
236
|
+
|
|
237
|
+
# Environment variables whose names look like credentials are not forwarded to
|
|
238
|
+
# the agent, except the ones the selected provider needs to authenticate.
|
|
239
|
+
_SENSITIVE_ENV_RE = re.compile(
|
|
240
|
+
r"(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|API_KEY|ACCESS_KEY|SESSION_KEY|AUTH)",
|
|
241
|
+
re.IGNORECASE,
|
|
242
|
+
)
|
|
243
|
+
_CLOUD_ENV_PREFIXES = ("AWS_", "AZURE_", "GOOGLE_", "GCP_", "GCLOUD_", "CLOUDSDK_", "ANTHROPIC_VERTEX_")
|
|
244
|
+
_PROVIDER_ENV_ALLOW = {
|
|
245
|
+
"claude": frozenset(
|
|
246
|
+
{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"}
|
|
247
|
+
),
|
|
248
|
+
"openai": frozenset({"OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_API_KEY"}),
|
|
249
|
+
}
|
|
250
|
+
# Claude Code on Bedrock / Vertex / Foundry authenticates with cloud
|
|
251
|
+
# credentials, so those are kept when the user has opted into a 3P provider.
|
|
252
|
+
_CLAUDE_3P_SWITCHES = ("CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def scrub_cli_env(env: dict[str, str], provider: LlmProvider) -> dict[str, str]:
|
|
256
|
+
"""Return a copy of ``env`` without credentials the agent doesn't need."""
|
|
257
|
+
allow = _PROVIDER_ENV_ALLOW[provider]
|
|
258
|
+
keep_cloud = provider == "claude" and any(env.get(k) for k in _CLAUDE_3P_SWITCHES)
|
|
259
|
+
scrubbed: dict[str, str] = {}
|
|
260
|
+
for name, value in env.items():
|
|
261
|
+
upper = name.upper()
|
|
262
|
+
if upper in allow:
|
|
263
|
+
scrubbed[name] = value
|
|
264
|
+
continue
|
|
265
|
+
if upper.startswith(_CLOUD_ENV_PREFIXES):
|
|
266
|
+
if keep_cloud:
|
|
267
|
+
scrubbed[name] = value
|
|
268
|
+
continue
|
|
269
|
+
if _SENSITIVE_ENV_RE.search(upper):
|
|
270
|
+
continue
|
|
271
|
+
scrubbed[name] = value
|
|
272
|
+
return scrubbed
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@contextmanager
|
|
276
|
+
def isolated_cli_workdir() -> Iterator[str]:
|
|
277
|
+
"""A throwaway empty working directory for an agent CLI subprocess."""
|
|
278
|
+
with tempfile.TemporaryDirectory(prefix="attackmap-llm-") as tmp:
|
|
279
|
+
yield tmp
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@lru_cache(maxsize=None)
|
|
283
|
+
def _cli_help_text(argv: tuple[str, ...]) -> str:
|
|
284
|
+
try:
|
|
285
|
+
with isolated_cli_workdir() as cwd:
|
|
286
|
+
completed = subprocess.run(
|
|
287
|
+
list(argv), capture_output=True, text=True, check=False, timeout=30, cwd=cwd
|
|
288
|
+
)
|
|
289
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
290
|
+
return ""
|
|
291
|
+
return (completed.stdout or "") + (completed.stderr or "")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _require_hardening_flags(name: str, help_argv: tuple[str, ...], flags: tuple[str, ...]) -> None:
|
|
295
|
+
help_text = _cli_help_text(help_argv)
|
|
296
|
+
missing = [flag for flag in flags if flag not in help_text]
|
|
297
|
+
if missing:
|
|
298
|
+
raise LlmReviewError(
|
|
299
|
+
f"The installed `{name}` CLI does not support {', '.join(missing)}, which AttackMap "
|
|
300
|
+
f"needs to run it isolated from the scanned repository. Upgrade `{name}`, or use "
|
|
301
|
+
"`--llm-backend api`."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------- Claude CLI backend ----------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _claude_cli_available() -> bool:
|
|
309
|
+
return shutil.which("claude") is not None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _anthropic_sdk_available() -> bool:
|
|
313
|
+
try:
|
|
314
|
+
import anthropic # noqa: F401
|
|
315
|
+
except ImportError:
|
|
316
|
+
return False
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _run_via_claude_cli(
|
|
321
|
+
rendered_system: str,
|
|
322
|
+
rendered_user: str,
|
|
323
|
+
*,
|
|
324
|
+
model: str,
|
|
325
|
+
runner: Any | None = None,
|
|
326
|
+
) -> LlmReviewResult:
|
|
327
|
+
"""Invoke `claude -p --output-format=json` and return the result.
|
|
328
|
+
|
|
329
|
+
The `runner` argument is only used by tests to inject a fake subprocess
|
|
330
|
+
runner; in production we go straight to subprocess.run.
|
|
331
|
+
"""
|
|
332
|
+
if runner is None and not _claude_cli_available():
|
|
333
|
+
raise LlmReviewError(
|
|
334
|
+
"`claude` CLI was not found on PATH. Install Claude Code (https://claude.com/claude-code) "
|
|
335
|
+
"and run `claude login`, or set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN to use the SDK backend."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
cmd = [
|
|
339
|
+
"claude",
|
|
340
|
+
"-p",
|
|
341
|
+
"--output-format=json",
|
|
342
|
+
*CLAUDE_CLI_HARDENING_ARGS,
|
|
343
|
+
"--model",
|
|
344
|
+
model,
|
|
345
|
+
"--system-prompt",
|
|
346
|
+
rendered_system,
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
if runner is None:
|
|
351
|
+
_require_hardening_flags("claude", ("claude", "--help"), _CLAUDE_REQUIRED_FLAGS)
|
|
352
|
+
with isolated_cli_workdir() as cwd:
|
|
353
|
+
completed = subprocess.run(
|
|
354
|
+
cmd,
|
|
355
|
+
input=rendered_user,
|
|
356
|
+
text=True,
|
|
357
|
+
capture_output=True,
|
|
358
|
+
check=False,
|
|
359
|
+
timeout=CLAUDE_CLI_TIMEOUT_SECONDS,
|
|
360
|
+
cwd=cwd,
|
|
361
|
+
env=scrub_cli_env(dict(os.environ), "claude"),
|
|
362
|
+
)
|
|
363
|
+
else:
|
|
364
|
+
completed = runner(cmd, rendered_user)
|
|
365
|
+
except FileNotFoundError as exc:
|
|
366
|
+
raise LlmReviewError("`claude` CLI was not found on PATH.") from exc
|
|
367
|
+
except subprocess.TimeoutExpired as exc:
|
|
368
|
+
raise LlmReviewError(
|
|
369
|
+
f"`claude` CLI did not respond within {CLAUDE_CLI_TIMEOUT_SECONDS}s."
|
|
370
|
+
) from exc
|
|
371
|
+
|
|
372
|
+
stdout = (completed.stdout or "").strip()
|
|
373
|
+
stderr = (completed.stderr or "").strip()
|
|
374
|
+
if completed.returncode != 0 and not stdout:
|
|
375
|
+
raise LlmReviewError(
|
|
376
|
+
f"`claude` CLI exited with status {completed.returncode}: {stderr or '<no stderr>'}"
|
|
377
|
+
)
|
|
378
|
+
if not stdout:
|
|
379
|
+
raise LlmReviewError("`claude` CLI produced no output.")
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
payload = json.loads(stdout)
|
|
383
|
+
except json.JSONDecodeError as exc:
|
|
384
|
+
raise LlmReviewError(
|
|
385
|
+
f"`claude` CLI returned non-JSON output: {stdout[:200]}..."
|
|
386
|
+
) from exc
|
|
387
|
+
|
|
388
|
+
if payload.get("is_error"):
|
|
389
|
+
# The CLI returns an unhelpful mix here:
|
|
390
|
+
# - `subtype` is often the literal "success" even when is_error=true
|
|
391
|
+
# - `result` carries the human-readable message ("Not logged in · Please run /login")
|
|
392
|
+
# - `errors` is sometimes absent
|
|
393
|
+
# Prefer `result` when it looks like a message; fall back to `errors`,
|
|
394
|
+
# then to `subtype`. Never show "success" — it just confuses users.
|
|
395
|
+
detail: str | None = None
|
|
396
|
+
result_text = payload.get("result")
|
|
397
|
+
if isinstance(result_text, str) and result_text.strip():
|
|
398
|
+
detail = result_text.strip()
|
|
399
|
+
elif payload.get("errors"):
|
|
400
|
+
detail = "; ".join(str(e) for e in payload["errors"])
|
|
401
|
+
else:
|
|
402
|
+
subtype = payload.get("subtype")
|
|
403
|
+
if isinstance(subtype, str) and subtype and subtype != "success":
|
|
404
|
+
detail = subtype
|
|
405
|
+
detail = detail or "unknown error"
|
|
406
|
+
hint = ""
|
|
407
|
+
low = detail.lower()
|
|
408
|
+
if "not logged in" in low or "/login" in low:
|
|
409
|
+
hint = (
|
|
410
|
+
" Run `claude login` (or `claude /login` inside the CLI), "
|
|
411
|
+
"or use --llm-backend api with ANTHROPIC_API_KEY set."
|
|
412
|
+
)
|
|
413
|
+
raise LlmReviewError(f"`claude` CLI reported an error: {detail}.{hint}")
|
|
414
|
+
|
|
415
|
+
markdown = payload.get("result")
|
|
416
|
+
if not isinstance(markdown, str) or not markdown.strip():
|
|
417
|
+
raise LlmReviewError("`claude` CLI returned no `result` text.")
|
|
418
|
+
|
|
419
|
+
usage_payload = payload.get("usage") or {}
|
|
420
|
+
usage: dict[str, int] = {}
|
|
421
|
+
for field in (
|
|
422
|
+
"input_tokens",
|
|
423
|
+
"output_tokens",
|
|
424
|
+
"cache_creation_input_tokens",
|
|
425
|
+
"cache_read_input_tokens",
|
|
426
|
+
):
|
|
427
|
+
value = usage_payload.get(field)
|
|
428
|
+
if isinstance(value, int):
|
|
429
|
+
usage[field] = value
|
|
430
|
+
|
|
431
|
+
cli_model = model
|
|
432
|
+
model_usage = payload.get("modelUsage")
|
|
433
|
+
if isinstance(model_usage, dict) and model_usage:
|
|
434
|
+
cli_model = next(iter(model_usage.keys()), model)
|
|
435
|
+
|
|
436
|
+
stop_reason = payload.get("stop_reason")
|
|
437
|
+
return LlmReviewResult(
|
|
438
|
+
markdown=markdown.strip(),
|
|
439
|
+
model=cli_model,
|
|
440
|
+
stop_reason=stop_reason if isinstance(stop_reason, str) else None,
|
|
441
|
+
usage=usage,
|
|
442
|
+
backend="cli",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------- OpenAI SDK backend (Responses API) ----------
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _resolve_openai_client(api_key: str | None, client: Any | None) -> Any:
|
|
450
|
+
if client is not None:
|
|
451
|
+
return client
|
|
452
|
+
try:
|
|
453
|
+
import openai
|
|
454
|
+
except ImportError as exc:
|
|
455
|
+
raise LlmReviewError(
|
|
456
|
+
"The openai SDK is not installed. Install with "
|
|
457
|
+
f"`{_LLM_INSTALL}` "
|
|
458
|
+
"to use the OpenAI API backend, or install the `codex` CLI for the CLI backend."
|
|
459
|
+
) from exc
|
|
460
|
+
|
|
461
|
+
resolved_key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
462
|
+
if not resolved_key:
|
|
463
|
+
raise LlmReviewError(
|
|
464
|
+
"No OpenAI API credentials found. Set OPENAI_API_KEY, or install the `codex` "
|
|
465
|
+
"CLI and run `codex login` to use --llm-backend cli."
|
|
466
|
+
)
|
|
467
|
+
return openai.OpenAI(api_key=resolved_key, timeout=OPENAI_API_TIMEOUT_SECONDS)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _openai_output_text(response: Any) -> str:
|
|
471
|
+
text = getattr(response, "output_text", None)
|
|
472
|
+
if isinstance(text, str) and text.strip():
|
|
473
|
+
return text.strip()
|
|
474
|
+
# Fallback: walk output items → content blocks → text, for SDK/response
|
|
475
|
+
# shapes where the `output_text` convenience isn't populated.
|
|
476
|
+
parts: list[str] = []
|
|
477
|
+
for item in getattr(response, "output", None) or []:
|
|
478
|
+
for block in getattr(item, "content", None) or []:
|
|
479
|
+
value = getattr(block, "text", None)
|
|
480
|
+
if isinstance(value, str) and value:
|
|
481
|
+
parts.append(value)
|
|
482
|
+
return "\n".join(parts).strip()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _openai_usage(usage: Any) -> dict[str, int]:
|
|
486
|
+
out: dict[str, int] = {}
|
|
487
|
+
for field in ("input_tokens", "output_tokens"):
|
|
488
|
+
value = getattr(usage, field, None)
|
|
489
|
+
if isinstance(value, int):
|
|
490
|
+
out[field] = value
|
|
491
|
+
return out
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _run_via_openai_sdk(
|
|
495
|
+
rendered_system: str,
|
|
496
|
+
rendered_user: str,
|
|
497
|
+
*,
|
|
498
|
+
model: str,
|
|
499
|
+
effort: str,
|
|
500
|
+
max_tokens: int,
|
|
501
|
+
api_key: str | None,
|
|
502
|
+
client: Any | None,
|
|
503
|
+
) -> LlmReviewResult:
|
|
504
|
+
oi = _resolve_openai_client(api_key, client)
|
|
505
|
+
reasoning_effort = OPENAI_EFFORT_MAP.get(effort, "high")
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
response = oi.responses.create(
|
|
509
|
+
model=model,
|
|
510
|
+
instructions=rendered_system,
|
|
511
|
+
input=rendered_user,
|
|
512
|
+
reasoning={"effort": reasoning_effort},
|
|
513
|
+
max_output_tokens=max_tokens,
|
|
514
|
+
)
|
|
515
|
+
except LlmReviewError:
|
|
516
|
+
raise
|
|
517
|
+
except Exception as exc: # pragma: no cover - surface SDK errors verbatim
|
|
518
|
+
raise LlmReviewError(f"OpenAI API call failed: {exc}") from exc
|
|
519
|
+
|
|
520
|
+
markdown = _openai_output_text(response)
|
|
521
|
+
if not markdown:
|
|
522
|
+
raise LlmReviewError("OpenAI returned no text content for the review.")
|
|
523
|
+
|
|
524
|
+
status = getattr(response, "status", None)
|
|
525
|
+
return LlmReviewResult(
|
|
526
|
+
markdown=markdown,
|
|
527
|
+
model=str(getattr(response, "model", None) or model),
|
|
528
|
+
stop_reason=status if isinstance(status, str) else None,
|
|
529
|
+
usage=_openai_usage(getattr(response, "usage", None)),
|
|
530
|
+
backend="api",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ---------- Codex CLI backend ----------
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _codex_cli_available() -> bool:
|
|
538
|
+
return shutil.which("codex") is not None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _openai_sdk_available() -> bool:
|
|
542
|
+
try:
|
|
543
|
+
import openai # noqa: F401
|
|
544
|
+
except ImportError:
|
|
545
|
+
return False
|
|
546
|
+
return True
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _run_via_codex_cli(
|
|
550
|
+
rendered_system: str,
|
|
551
|
+
rendered_user: str,
|
|
552
|
+
*,
|
|
553
|
+
model: str,
|
|
554
|
+
effort: str,
|
|
555
|
+
runner: Any | None = None,
|
|
556
|
+
) -> LlmReviewResult:
|
|
557
|
+
"""Invoke `codex exec` non-interactively and return the final message.
|
|
558
|
+
|
|
559
|
+
`codex exec` treats the positional prompt as the instruction and piped
|
|
560
|
+
stdin as additional context, so we pass the rendered system prompt as the
|
|
561
|
+
argument and stream the (large) evidence pack in on stdin. It prints only
|
|
562
|
+
the final agent message to stdout. Runs read-only so it can never mutate the
|
|
563
|
+
working tree, and `--skip-git-repo-check` lets it run outside a git repo.
|
|
564
|
+
|
|
565
|
+
The `runner` argument is only used by tests to inject a fake subprocess
|
|
566
|
+
runner; in production we go straight to subprocess.run.
|
|
567
|
+
"""
|
|
568
|
+
if runner is None and not _codex_cli_available():
|
|
569
|
+
raise LlmReviewError(
|
|
570
|
+
"`codex` CLI was not found on PATH. Install it (npm install -g @openai/codex) "
|
|
571
|
+
"and run `codex login`, or set OPENAI_API_KEY to use --llm-backend api."
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
reasoning_effort = OPENAI_EFFORT_MAP.get(effort, "high")
|
|
575
|
+
|
|
576
|
+
def build_cmd(cwd: str) -> list[str]:
|
|
577
|
+
return [
|
|
578
|
+
"codex",
|
|
579
|
+
"exec",
|
|
580
|
+
"--model",
|
|
581
|
+
model,
|
|
582
|
+
"-c",
|
|
583
|
+
f'model_reasoning_effort="{reasoning_effort}"',
|
|
584
|
+
"--sandbox",
|
|
585
|
+
"read-only",
|
|
586
|
+
"--cd",
|
|
587
|
+
cwd,
|
|
588
|
+
"--ephemeral",
|
|
589
|
+
"--ignore-rules",
|
|
590
|
+
"--skip-git-repo-check",
|
|
591
|
+
rendered_system,
|
|
592
|
+
]
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
with isolated_cli_workdir() as cwd:
|
|
596
|
+
cmd = build_cmd(cwd)
|
|
597
|
+
if runner is None:
|
|
598
|
+
_require_hardening_flags("codex", ("codex", "exec", "--help"), _CODEX_REQUIRED_FLAGS)
|
|
599
|
+
completed = subprocess.run(
|
|
600
|
+
cmd,
|
|
601
|
+
input=rendered_user,
|
|
602
|
+
text=True,
|
|
603
|
+
capture_output=True,
|
|
604
|
+
check=False,
|
|
605
|
+
timeout=CODEX_CLI_TIMEOUT_SECONDS,
|
|
606
|
+
cwd=cwd,
|
|
607
|
+
env=scrub_cli_env(dict(os.environ), "openai"),
|
|
608
|
+
)
|
|
609
|
+
else:
|
|
610
|
+
completed = runner(cmd, rendered_user)
|
|
611
|
+
except FileNotFoundError as exc:
|
|
612
|
+
raise LlmReviewError("`codex` CLI was not found on PATH.") from exc
|
|
613
|
+
except subprocess.TimeoutExpired as exc:
|
|
614
|
+
raise LlmReviewError(
|
|
615
|
+
f"`codex` CLI did not respond within {CODEX_CLI_TIMEOUT_SECONDS}s."
|
|
616
|
+
) from exc
|
|
617
|
+
|
|
618
|
+
stdout = (completed.stdout or "").strip()
|
|
619
|
+
stderr = (completed.stderr or "").strip()
|
|
620
|
+
if completed.returncode != 0:
|
|
621
|
+
hint = ""
|
|
622
|
+
low = stderr.lower()
|
|
623
|
+
if "not logged in" in low or "login" in low or "auth" in low:
|
|
624
|
+
hint = (
|
|
625
|
+
" Run `codex login`, or use --llm-backend api with OPENAI_API_KEY set."
|
|
626
|
+
)
|
|
627
|
+
raise LlmReviewError(
|
|
628
|
+
f"`codex` CLI exited with status {completed.returncode}: "
|
|
629
|
+
f"{stderr or '<no stderr>'}.{hint}"
|
|
630
|
+
)
|
|
631
|
+
if not stdout:
|
|
632
|
+
raise LlmReviewError("`codex` CLI produced no output.")
|
|
633
|
+
|
|
634
|
+
return LlmReviewResult(
|
|
635
|
+
markdown=stdout,
|
|
636
|
+
model=model,
|
|
637
|
+
stop_reason=None,
|
|
638
|
+
usage={},
|
|
639
|
+
backend="cli",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ---------- Public entry point ----------
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _resolve_openai_backend(
|
|
647
|
+
backend: LlmBackend,
|
|
648
|
+
*,
|
|
649
|
+
api_key: str | None,
|
|
650
|
+
client: Any | None,
|
|
651
|
+
codex_runner: Any | None,
|
|
652
|
+
) -> Literal["api", "cli"]:
|
|
653
|
+
if backend == "api":
|
|
654
|
+
return "api"
|
|
655
|
+
if backend == "cli":
|
|
656
|
+
if codex_runner is None and not _codex_cli_available():
|
|
657
|
+
raise LlmReviewError(
|
|
658
|
+
"`--llm-backend cli` was requested but the `codex` CLI is not on PATH."
|
|
659
|
+
)
|
|
660
|
+
return "cli"
|
|
661
|
+
# auto
|
|
662
|
+
if client is not None:
|
|
663
|
+
return "api"
|
|
664
|
+
has_key = bool(api_key or os.environ.get("OPENAI_API_KEY"))
|
|
665
|
+
# Prefer the API backend only when the openai SDK is importable; otherwise
|
|
666
|
+
# fall back to the `codex` CLI (needs no SDK) so a key set against an
|
|
667
|
+
# SDK-less install still works via subscription auth.
|
|
668
|
+
if has_key and _openai_sdk_available():
|
|
669
|
+
return "api"
|
|
670
|
+
if _codex_cli_available():
|
|
671
|
+
return "cli"
|
|
672
|
+
if has_key:
|
|
673
|
+
return "api" # no SDK and no CLI — surface the actionable SDK-missing error
|
|
674
|
+
raise LlmReviewError(
|
|
675
|
+
"No OpenAI backend available. Set OPENAI_API_KEY, or install the `codex` "
|
|
676
|
+
"CLI and run `codex login`."
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _resolve_backend(
|
|
681
|
+
backend: LlmBackend,
|
|
682
|
+
*,
|
|
683
|
+
api_key: str | None,
|
|
684
|
+
client: Any | None,
|
|
685
|
+
) -> Literal["api", "cli"]:
|
|
686
|
+
if backend == "api":
|
|
687
|
+
return "api"
|
|
688
|
+
if backend == "cli":
|
|
689
|
+
if not _claude_cli_available():
|
|
690
|
+
raise LlmReviewError(
|
|
691
|
+
"`--llm-backend cli` was requested but the `claude` CLI is not on PATH."
|
|
692
|
+
)
|
|
693
|
+
return "cli"
|
|
694
|
+
# auto
|
|
695
|
+
if client is not None:
|
|
696
|
+
return "api"
|
|
697
|
+
has_key = bool(
|
|
698
|
+
api_key
|
|
699
|
+
or os.environ.get("ANTHROPIC_API_KEY")
|
|
700
|
+
or os.environ.get("ANTHROPIC_AUTH_TOKEN")
|
|
701
|
+
)
|
|
702
|
+
# Prefer the API backend only when the SDK is actually importable. Otherwise
|
|
703
|
+
# fall back to the `claude` CLI (needs no SDK), so a key set against an
|
|
704
|
+
# SDK-less install (one without the `llm` extra)
|
|
705
|
+
# still works via subscription auth instead of erroring.
|
|
706
|
+
if has_key and _anthropic_sdk_available():
|
|
707
|
+
return "api"
|
|
708
|
+
if _claude_cli_available():
|
|
709
|
+
return "cli"
|
|
710
|
+
if has_key:
|
|
711
|
+
return "api" # no SDK and no CLI — surface the actionable SDK-missing error
|
|
712
|
+
raise LlmReviewError(
|
|
713
|
+
"No LLM backend available. Set ANTHROPIC_API_KEY, set ANTHROPIC_AUTH_TOKEN, "
|
|
714
|
+
"or install the `claude` CLI and run `claude login`."
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def generate_llm_review(
|
|
719
|
+
scan: ScanResult,
|
|
720
|
+
attack_surfaces: list[AttackSurface],
|
|
721
|
+
findings: list[Finding],
|
|
722
|
+
attack_paths: list[AttackPath],
|
|
723
|
+
*,
|
|
724
|
+
model: str | None = None,
|
|
725
|
+
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None,
|
|
726
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
727
|
+
api_key: str | None = None,
|
|
728
|
+
client: Any | None = None,
|
|
729
|
+
backend: LlmBackend = "auto",
|
|
730
|
+
cli_runner: Any | None = None,
|
|
731
|
+
mode: Literal[
|
|
732
|
+
"review", "hunt", "hunt_verify", "hunt_generate", "hunt_skeptic",
|
|
733
|
+
"hunt_critic", "remediate", "triage",
|
|
734
|
+
] = "review",
|
|
735
|
+
speed: Literal["standard", "fast"] = "standard",
|
|
736
|
+
provider: LlmProvider = "claude",
|
|
737
|
+
openai_client: Any | None = None,
|
|
738
|
+
codex_runner: Any | None = None,
|
|
739
|
+
hypotheses: list[dict] | None = None,
|
|
740
|
+
lens: str | None = None,
|
|
741
|
+
avoid_titles: list[str] | None = None,
|
|
742
|
+
critic_hint: str | None = None,
|
|
743
|
+
) -> LlmReviewResult:
|
|
744
|
+
"""Produce a narrative defensive review — or, with ``mode="hunt"``, ranked
|
|
745
|
+
vulnerability hypotheses (#80) — by calling an LLM.
|
|
746
|
+
|
|
747
|
+
``provider`` selects Claude (default) or OpenAI/Codex. For each provider,
|
|
748
|
+
the backend resolves to an API SDK or a subscription CLI based on available
|
|
749
|
+
auth, then runs the appropriate prompt pack through it. The Claude SDK path
|
|
750
|
+
streams so we never hit HTTP timeouts on long reviews; the `claude` CLI path
|
|
751
|
+
runs `claude -p --output-format=json`. The OpenAI path uses the Responses
|
|
752
|
+
API; the `codex` CLI path runs `codex exec`. All modes share the same
|
|
753
|
+
evidence grounding contract (every claim cites evidence IDs). ``speed`` (Fast
|
|
754
|
+
mode) is Claude/API-only and ignored for OpenAI.
|
|
755
|
+
"""
|
|
756
|
+
resolved_effort = effort or DEFAULT_EFFORT
|
|
757
|
+
|
|
758
|
+
if mode == "hunt_skeptic":
|
|
759
|
+
# The skeptic pass needs the fixed hypothesis list (#147a).
|
|
760
|
+
rendered = render_skeptic_prompts(
|
|
761
|
+
scan, attack_surfaces, findings, attack_paths, hypotheses or []
|
|
762
|
+
)
|
|
763
|
+
elif mode == "hunt_generate":
|
|
764
|
+
# Generation can be primed with a lens (#147b) and prior-round context (#147c).
|
|
765
|
+
rendered = render_hunt_generate_prompts(
|
|
766
|
+
scan, attack_surfaces, findings, attack_paths,
|
|
767
|
+
lens=lens, avoid_titles=avoid_titles, critic_hint=critic_hint,
|
|
768
|
+
)
|
|
769
|
+
elif mode == "hunt_critic":
|
|
770
|
+
# The completeness critic names untried angles from the leads so far (#147c).
|
|
771
|
+
rendered = render_critic_prompts(
|
|
772
|
+
scan, attack_surfaces, findings, attack_paths, hypotheses or []
|
|
773
|
+
)
|
|
774
|
+
else:
|
|
775
|
+
render = {
|
|
776
|
+
"hunt": render_hunt_prompts,
|
|
777
|
+
"hunt_verify": render_hunt_verify_prompts,
|
|
778
|
+
"remediate": render_remediation_prompts,
|
|
779
|
+
"triage": render_triage_prompts,
|
|
780
|
+
}.get(mode, render_review_prompts)
|
|
781
|
+
rendered = render(scan, attack_surfaces, findings, attack_paths)
|
|
782
|
+
|
|
783
|
+
if provider == "openai":
|
|
784
|
+
resolved_model = (
|
|
785
|
+
model or os.environ.get("ATTACKMAP_OPENAI_MODEL") or OPENAI_DEFAULT_MODEL
|
|
786
|
+
)
|
|
787
|
+
if backend == "cli" and codex_runner is not None:
|
|
788
|
+
chosen: Literal["api", "cli"] = "cli"
|
|
789
|
+
else:
|
|
790
|
+
chosen = _resolve_openai_backend(
|
|
791
|
+
backend, api_key=api_key, client=openai_client, codex_runner=codex_runner
|
|
792
|
+
)
|
|
793
|
+
if chosen == "api":
|
|
794
|
+
return _run_via_openai_sdk(
|
|
795
|
+
rendered.system,
|
|
796
|
+
rendered.user,
|
|
797
|
+
model=resolved_model,
|
|
798
|
+
effort=resolved_effort,
|
|
799
|
+
max_tokens=max_tokens,
|
|
800
|
+
api_key=api_key,
|
|
801
|
+
client=openai_client,
|
|
802
|
+
)
|
|
803
|
+
return _run_via_codex_cli(
|
|
804
|
+
rendered.system,
|
|
805
|
+
rendered.user,
|
|
806
|
+
model=resolved_model,
|
|
807
|
+
effort=resolved_effort,
|
|
808
|
+
runner=codex_runner,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
resolved_model = model or os.environ.get("ATTACKMAP_LLM_MODEL") or DEFAULT_MODEL
|
|
812
|
+
if backend == "cli" and cli_runner is not None:
|
|
813
|
+
chosen_backend: Literal["api", "cli"] = "cli"
|
|
814
|
+
else:
|
|
815
|
+
chosen_backend = _resolve_backend(backend, api_key=api_key, client=client)
|
|
816
|
+
|
|
817
|
+
if chosen_backend == "api":
|
|
818
|
+
return _run_via_sdk(
|
|
819
|
+
rendered.system,
|
|
820
|
+
rendered.user,
|
|
821
|
+
model=resolved_model,
|
|
822
|
+
effort=resolved_effort,
|
|
823
|
+
max_tokens=max_tokens,
|
|
824
|
+
api_key=api_key,
|
|
825
|
+
client=client,
|
|
826
|
+
speed=speed,
|
|
827
|
+
)
|
|
828
|
+
return _run_via_claude_cli(
|
|
829
|
+
rendered.system,
|
|
830
|
+
rendered.user,
|
|
831
|
+
model=resolved_model,
|
|
832
|
+
runner=cli_runner,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
__all__ = [
|
|
837
|
+
"generate_llm_review",
|
|
838
|
+
"LlmReviewError",
|
|
839
|
+
"LlmReviewResult",
|
|
840
|
+
"LlmBackend",
|
|
841
|
+
"LlmProvider",
|
|
842
|
+
"DEFAULT_MODEL",
|
|
843
|
+
"OPENAI_DEFAULT_MODEL",
|
|
844
|
+
"DEFAULT_EFFORT",
|
|
845
|
+
"DEFAULT_MAX_TOKENS",
|
|
846
|
+
]
|