prismor-cli 1.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.
prismor/cli_config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Local CLI config (~/.prismor/cli-config.json).
2
+
3
+ Today this stores the active organization for users who belong to more than one
4
+ org — the `prismor org switch` target. The CLI sends it as org_id on scan/fix;
5
+ the control plane only honors it if the user is a member (else it falls back to
6
+ the key's default org), so this is a convenience, not an auth boundary.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+
16
+ def _config_dir() -> Path:
17
+ return Path(os.environ.get("PRISMOR_HOME", str(Path.home() / ".prismor")))
18
+
19
+
20
+ def _config_path() -> Path:
21
+ return _config_dir() / "cli-config.json"
22
+
23
+
24
+ def load() -> Dict[str, Any]:
25
+ try:
26
+ data = json.loads(_config_path().read_text(encoding="utf-8"))
27
+ return data if isinstance(data, dict) else {}
28
+ except (OSError, ValueError):
29
+ return {}
30
+
31
+
32
+ def save(cfg: Dict[str, Any]) -> None:
33
+ d = _config_dir()
34
+ d.mkdir(parents=True, exist_ok=True)
35
+ _config_path().write_text(json.dumps(cfg, indent=2), encoding="utf-8")
36
+
37
+
38
+ def active_org() -> Optional[Dict[str, Any]]:
39
+ """The active org dict ({id, slug, name}) or None."""
40
+ org = load().get("active_org")
41
+ return org if isinstance(org, dict) and org.get("id") else None
42
+
43
+
44
+ def active_org_id() -> Optional[str]:
45
+ org = active_org()
46
+ return org.get("id") if org else None
47
+
48
+
49
+ def set_active_org(org: Optional[Dict[str, Any]]) -> None:
50
+ cfg = load()
51
+ if org:
52
+ cfg["active_org"] = {"id": org.get("id"), "slug": org.get("slug"), "name": org.get("name")}
53
+ else:
54
+ cfg.pop("active_org", None)
55
+ save(cfg)
prismor/local_fix.py ADDED
@@ -0,0 +1,338 @@
1
+ """Local AI auto-fix: apply security fixes on the user's own machine.
2
+
3
+ Scanning stays in the Prismor cloud; this path takes the resulting findings and
4
+ hands them to a coding agent the user already has installed (Claude Code, Codex,
5
+ ...). The agent edits the user's local checkout using the user's own LLM
6
+ credentials. Nothing sensitive — source code, GitHub token — leaves the machine,
7
+ and Prismor's hosted fix agent is never invoked.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ import json
13
+ import shlex
14
+ import shutil
15
+ import subprocess
16
+ from typing import List, Optional, Tuple
17
+
18
+ import click
19
+
20
+ from .sanitize import build_fix_prompt, count_findings
21
+ from .api import PrismorClient, PrismorAPIError
22
+
23
+
24
+ class AgentNotFound(Exception):
25
+ """Raised when no usable local coding agent can be resolved."""
26
+
27
+
28
+ def _claude_argv(prompt: str) -> List[str]:
29
+ # Headless print mode; auto-accept file edits but still gate shell commands.
30
+ return ["claude", "-p", prompt, "--permission-mode", "acceptEdits"]
31
+
32
+
33
+ def _codex_argv(prompt: str) -> List[str]:
34
+ return ["codex", "exec", prompt]
35
+
36
+
37
+ # Coding agents we know how to invoke headlessly. Order = auto-detect priority.
38
+ AGENT_SPECS = {
39
+ "claude": {"label": "Claude Code", "bin": "claude", "argv": _claude_argv},
40
+ "codex": {"label": "OpenAI Codex CLI", "bin": "codex", "argv": _codex_argv},
41
+ }
42
+ AGENT_PRIORITY = ["claude", "codex"]
43
+
44
+ # Env var to point Prismor at any other agent. Must contain a {prompt}
45
+ # placeholder; the substituted command is run through the shell, e.g.:
46
+ # export PRISMOR_LOCAL_AGENT_CMD='aider --yes --message {prompt}'
47
+ CUSTOM_AGENT_ENV = "PRISMOR_LOCAL_AGENT_CMD"
48
+
49
+
50
+ def build_agent_command(prompt: str, preferred: str = "auto") -> Tuple[str, object, bool]:
51
+ """Resolve which agent to run and build its invocation.
52
+
53
+ Args:
54
+ prompt: The fix prompt to hand to the agent.
55
+ preferred: ``"auto"`` to detect from PATH, or a specific agent key.
56
+
57
+ Returns:
58
+ ``(label, command, use_shell)`` where ``command`` is an argv list when
59
+ ``use_shell`` is False, or a shell string when True.
60
+
61
+ Raises:
62
+ AgentNotFound: with message ``"no-agent"`` when nothing is installed,
63
+ or a descriptive message for other resolution failures.
64
+ """
65
+ custom = os.environ.get(CUSTOM_AGENT_ENV)
66
+ if custom:
67
+ if "{prompt}" not in custom:
68
+ raise AgentNotFound(
69
+ f"{CUSTOM_AGENT_ENV} must contain a {{prompt}} placeholder."
70
+ )
71
+ return ("custom agent", custom.replace("{prompt}", shlex.quote(prompt)), True)
72
+
73
+ if preferred and preferred != "auto":
74
+ spec = AGENT_SPECS.get(preferred)
75
+ if spec and shutil.which(spec["bin"]):
76
+ return (spec["label"], spec["argv"](prompt), False)
77
+ binary = spec["bin"] if spec else preferred
78
+ raise AgentNotFound(
79
+ f"Requested agent '{preferred}' is not installed "
80
+ f"(couldn't find '{binary}' on your PATH)."
81
+ )
82
+
83
+ for kind in AGENT_PRIORITY:
84
+ spec = AGENT_SPECS[kind]
85
+ if shutil.which(spec["bin"]):
86
+ return (spec["label"], spec["argv"](prompt), False)
87
+ raise AgentNotFound("no-agent")
88
+
89
+
90
+ def run_agent_command(command, use_shell: bool, cwd: str) -> int:
91
+ """Run the agent in ``cwd`` streaming its output live. Returns the exit code.
92
+
93
+ The whole fix prompt is passed as an argument, so the agent never needs to
94
+ read stdin. We redirect stdin from /dev/null so headless agents (e.g.
95
+ ``claude -p``) don't stall waiting for piped input when launched
96
+ non-interactively.
97
+ """
98
+ try:
99
+ proc = subprocess.run(command, cwd=cwd, shell=use_shell, stdin=subprocess.DEVNULL)
100
+ return proc.returncode
101
+ except FileNotFoundError:
102
+ return 127
103
+ except KeyboardInterrupt:
104
+ return 130
105
+
106
+
107
+ def load_findings(results_file: Optional[str], from_scan: Optional[str]) -> dict:
108
+ """Load scan findings from a results file/stdin or a completed cloud scan job.
109
+
110
+ Raises PrismorAPIError("__no_source__") when neither source is provided so the
111
+ caller can print tailored guidance.
112
+ """
113
+ if results_file:
114
+ if results_file == "-":
115
+ raw = sys.stdin.read()
116
+ else:
117
+ with open(results_file, "r", encoding="utf-8") as f:
118
+ raw = f.read()
119
+ data = json.loads(raw)
120
+ # Accept either the bare results dict or a status payload wrapping it.
121
+ if isinstance(data, dict) and isinstance(data.get("results"), dict):
122
+ return data["results"]
123
+ return data
124
+
125
+ if from_scan:
126
+ client = PrismorClient()
127
+ status = client.check_scan_status(from_scan)
128
+ state = status.get("status")
129
+ if state not in {"completed", "success"}:
130
+ raise PrismorAPIError(
131
+ f"Scan job '{from_scan}' is not finished (status: {state}). "
132
+ f"Wait for it with: prismor scan-status {from_scan} --watch"
133
+ )
134
+ return status.get("results", status)
135
+
136
+ raise PrismorAPIError("__no_source__")
137
+
138
+
139
+ def _is_git_repo(path: str) -> bool:
140
+ try:
141
+ r = subprocess.run(
142
+ ["git", "-C", path, "rev-parse", "--is-inside-work-tree"],
143
+ capture_output=True, text=True, timeout=5,
144
+ )
145
+ return r.returncode == 0 and r.stdout.strip() == "true"
146
+ except Exception:
147
+ return False
148
+
149
+
150
+ def _create_branch(path: str, name: str) -> Tuple[bool, str]:
151
+ try:
152
+ r = subprocess.run(
153
+ ["git", "-C", path, "checkout", "-b", name],
154
+ capture_output=True, text=True, timeout=15,
155
+ )
156
+ if r.returncode == 0:
157
+ return True, ""
158
+ return False, (r.stderr or r.stdout).strip()
159
+ except Exception as e:
160
+ return False, str(e)
161
+
162
+
163
+ def _print_diff_stat(path: str) -> None:
164
+ try:
165
+ r = subprocess.run(
166
+ ["git", "-C", path, "--no-pager", "diff", "--stat"],
167
+ capture_output=True, text=True, timeout=15,
168
+ )
169
+ out = (r.stdout or "").strip()
170
+ if out:
171
+ click.secho("\nLocal changes:", fg="yellow", bold=True)
172
+ click.echo(out)
173
+ else:
174
+ click.secho("\nNo file changes detected in the working tree.", fg="yellow")
175
+ except Exception:
176
+ pass
177
+
178
+
179
+ @click.command("fix-local")
180
+ @click.argument("path", type=click.Path(exists=True, file_okay=False), default=".")
181
+ @click.option("--results", "results_file", type=str, metavar="FILE",
182
+ help="Scan results JSON from `prismor --scan -o FILE`. Use '-' for stdin.")
183
+ @click.option("--from-scan", "from_scan", type=str, metavar="JOB_ID",
184
+ help="Pull findings from a completed cloud scan job (needs PRISMOR_API_KEY).")
185
+ @click.option("--agent", type=click.Choice(["auto", "claude", "codex"]), default="auto",
186
+ help="Coding agent to drive (default: auto-detect). Override fully with PRISMOR_LOCAL_AGENT_CMD.")
187
+ @click.option("--instruction", type=str, help="Extra instruction appended to the fix prompt.")
188
+ @click.option("--branch", "new_branch", type=str, metavar="NAME",
189
+ help="Create & switch to this local git branch before fixing.")
190
+ @click.option("--dry-run", is_flag=True,
191
+ help="Print the resolved agent and prompt, then exit without editing.")
192
+ @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Skip the confirmation prompt.")
193
+ def fix_local(path: str, results_file: Optional[str], from_scan: Optional[str], agent: str,
194
+ instruction: Optional[str], new_branch: Optional[str], dry_run: bool,
195
+ assume_yes: bool):
196
+ """Apply AI security fixes locally using your own coding agent.
197
+
198
+ Scanning still runs in the Prismor cloud; this command takes those findings
199
+ and fixes them on YOUR machine with YOUR installed coding agent (Claude Code,
200
+ Codex, ...) and YOUR own LLM credits. Your source code and GitHub token never
201
+ leave your machine, and Prismor's hosted fix agent is not used.
202
+
203
+ PATH is the local checkout to fix (default: current directory).
204
+
205
+ Examples:
206
+ prismor --repo me/app --scan -o findings.json # 1. scan in cloud
207
+ prismor fix-local --results findings.json # 2. fix locally
208
+
209
+ prismor fix-local . --from-scan <job_id>
210
+ prismor fix-local ~/code/app --agent claude --branch security-fixes
211
+ prismor fix-local --results findings.json --dry-run
212
+ """
213
+ # Deferred import: cli.py imports this module at load time, so importing its
214
+ # console helpers here (rather than at module top) avoids a circular import.
215
+ from .cli import print_info, print_success, print_error, print_warning, should_show_spinner
216
+
217
+ target = os.path.abspath(path)
218
+
219
+ # 1. Load findings (scan stays in the cloud; we only consume its output).
220
+ try:
221
+ results = load_findings(results_file, from_scan)
222
+ except PrismorAPIError as e:
223
+ if str(e) == "__no_source__":
224
+ print_error("No findings source given.")
225
+ click.echo("Provide one of:")
226
+ click.echo(" --results FILE output of: prismor --repo <repo> --scan -o FILE")
227
+ click.echo(" --from-scan JOB_ID a completed cloud scan job")
228
+ sys.exit(1)
229
+ print_error(str(e))
230
+ sys.exit(1)
231
+ except FileNotFoundError:
232
+ print_error(f"Results file not found: {results_file}")
233
+ sys.exit(1)
234
+ except (OSError, json.JSONDecodeError) as e:
235
+ print_error(f"Could not read findings JSON: {e}")
236
+ sys.exit(1)
237
+
238
+ if not isinstance(results, dict):
239
+ print_error("Findings JSON is not in the expected object format.")
240
+ sys.exit(1)
241
+
242
+ counts = count_findings(results)
243
+ total = counts["vulnerabilities"] + counts["secrets"]
244
+ if total == 0:
245
+ print_success("No findings to fix — nothing to do.")
246
+ return
247
+
248
+ # 2. Build the apply-mode prompt for a local agent.
249
+ prompt = build_fix_prompt(results, label=target, mode="apply", extra_instruction=instruction)
250
+
251
+ # 3. Resolve the agent.
252
+ try:
253
+ label, command, use_shell = build_agent_command(prompt, agent)
254
+ except AgentNotFound as e:
255
+ if str(e) == "no-agent":
256
+ print_error("No supported coding agent found on your PATH.")
257
+ click.echo("Install one of:")
258
+ click.echo(" • Claude Code: https://docs.claude.com/claude-code")
259
+ click.echo(" • Codex CLI: https://github.com/openai/codex")
260
+ click.echo("\nOr point Prismor at any agent via PRISMOR_LOCAL_AGENT_CMD, e.g.:")
261
+ click.secho(" export PRISMOR_LOCAL_AGENT_CMD='aider --yes --message {prompt}'", fg="cyan")
262
+ else:
263
+ print_error(str(e))
264
+ sys.exit(1)
265
+
266
+ # 4. Dry run: show the plan and prompt, change nothing.
267
+ if dry_run:
268
+ click.echo("\n" + "=" * 60)
269
+ click.secho(" fix-local (dry run)", fg="cyan", bold=True)
270
+ click.echo("=" * 60 + "\n")
271
+ click.secho("Agent:", fg="yellow", bold=True)
272
+ click.echo(f" {label}")
273
+ click.secho("Target directory:", fg="yellow", bold=True)
274
+ click.echo(f" {target}")
275
+ click.secho("Findings:", fg="yellow", bold=True)
276
+ click.echo(f" {counts['vulnerabilities']} vulnerabilities, {counts['secrets']} secrets")
277
+ click.echo("\n" + "-" * 60)
278
+ click.secho("Prompt that would be sent to the agent:\n", fg="yellow", bold=True)
279
+ click.echo(prompt)
280
+ return
281
+
282
+ # 5. Confirm before editing the working tree.
283
+ click.echo("\n" + "=" * 60)
284
+ click.secho(" Local AI Auto-Fix", fg="cyan", bold=True)
285
+ click.echo("=" * 60 + "\n")
286
+ print_info(f"Agent: {label}")
287
+ print_info(f"Target: {target}")
288
+ print_info(f"Findings: {counts['vulnerabilities']} vulnerabilities, {counts['secrets']} secrets")
289
+ print_warning("The agent will edit files in the target directory using your own LLM credits.")
290
+
291
+ if not assume_yes:
292
+ if not should_show_spinner():
293
+ print_error("Refusing to edit files non-interactively. Re-run with --yes to proceed.")
294
+ sys.exit(1)
295
+ if not click.confirm("Proceed?", default=False):
296
+ print_warning("Aborted. No files were changed.")
297
+ return
298
+
299
+ # 6. Optional local branch.
300
+ if new_branch:
301
+ if not _is_git_repo(target):
302
+ print_error(f"--branch requires a git repository; {target} is not one.")
303
+ sys.exit(1)
304
+ ok, err = _create_branch(target, new_branch)
305
+ if ok:
306
+ print_success(f"Created and switched to branch '{new_branch}'.")
307
+ else:
308
+ print_error(f"Could not create branch '{new_branch}': {err}")
309
+ sys.exit(1)
310
+
311
+ # 7. Hand off to the agent (output streams live).
312
+ click.echo()
313
+ print_info(f"Handing off to {label}…")
314
+ click.echo("-" * 60)
315
+ rc = run_agent_command(command, use_shell, target)
316
+ click.echo("-" * 60)
317
+
318
+ # 8. Report outcome.
319
+ if rc == 130:
320
+ print_warning("Interrupted. The working tree may contain partial changes.")
321
+ sys.exit(130)
322
+ if rc == 127:
323
+ print_error(f"{label} could not be launched (command not found).")
324
+ sys.exit(1)
325
+ if rc != 0:
326
+ print_error(f"{label} exited with code {rc}. Review any partial changes before committing.")
327
+ if _is_git_repo(target):
328
+ _print_diff_stat(target)
329
+ sys.exit(rc)
330
+
331
+ print_success("Local auto-fix finished.")
332
+ if _is_git_repo(target):
333
+ _print_diff_stat(target)
334
+ click.secho("\nNext steps:", fg="cyan", bold=True)
335
+ click.echo(" 1. Review the changes: git diff")
336
+ click.echo(" 2. Run your tests.")
337
+ click.echo(" 3. Commit & push with your own credentials, then open a PR.")
338
+ click.echo()
prismor/sanitize.py ADDED
@@ -0,0 +1,179 @@
1
+ """Shared helpers for sanitizing scan results and building fix prompts.
2
+
3
+ These live in their own module so both the CLI output path (`prismor/cli.py`)
4
+ and the local-fix path (`prismor/local_fix.py`) use one source of truth and
5
+ avoid a circular import.
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from typing import Optional
11
+
12
+ # Keys whose values are short-lived credentials / signed URLs that must never
13
+ # be written to a file, printed, or handed to a local coding agent.
14
+ _SENSITIVE_KEYS = {"presigned_url", "AWSAccessKeyId", "Signature", "x-amz-security-token"}
15
+
16
+ # Server-side scratch paths (the backend clones into /tmp). Replace with a
17
+ # placeholder so output doesn't leak Prismor's internal filesystem layout.
18
+ _INTERNAL_PATH_RE = re.compile(r"^/(?:tmp|var/folders|private/tmp)/[^/\s]+")
19
+
20
+
21
+ def _redact_ai_insight(insight: dict) -> dict:
22
+ """Replace raw upstream LLM errors in ai_insights with a generic notice.
23
+
24
+ The server leaves OpenAI 429s / billing URLs in the response; don't surface those.
25
+ """
26
+ if not isinstance(insight, dict):
27
+ return insight
28
+ err = insight.get("error")
29
+ if isinstance(err, str) and err.strip():
30
+ cleaned = dict(insight)
31
+ cleaned["error"] = "AI insight unavailable (upstream model error)"
32
+ if isinstance(cleaned.get("summary"), str) and "Error generating" in cleaned["summary"]:
33
+ cleaned["summary"] = "AI insight unavailable for this CVE."
34
+ return cleaned
35
+ return insight
36
+
37
+
38
+ def strip_sensitive(obj):
39
+ """Recursively drop presigned URLs, redact internal temp paths, and sanitize
40
+ raw upstream-LLM error blobs from output."""
41
+ if isinstance(obj, dict):
42
+ cleaned = {}
43
+ for k, v in obj.items():
44
+ if k in _SENSITIVE_KEYS:
45
+ continue
46
+ if k == "ArtifactName" and isinstance(v, str) and _INTERNAL_PATH_RE.match(v):
47
+ cleaned[k] = "<repository>"
48
+ continue
49
+ if k == "insight":
50
+ cleaned[k] = _redact_ai_insight(v) if isinstance(v, dict) else v
51
+ continue
52
+ cleaned[k] = strip_sensitive(v)
53
+ return cleaned
54
+ if isinstance(obj, list):
55
+ return [strip_sensitive(v) for v in obj]
56
+ return obj
57
+
58
+
59
+ def extract_vulnerabilities(results: dict) -> list:
60
+ """Pull a flat list of vulnerability dicts out of a scan-results payload.
61
+
62
+ Handles both the legacy flat ``vulnerabilities`` list and the Trivy
63
+ ``Results[].Vulnerabilities[]`` shape.
64
+ """
65
+ scans = (results or {}).get("scans") or {}
66
+ vuln_scan = scans.get("vulnerability") or {}
67
+ scan_data = vuln_scan.get("scan_results") or {}
68
+ if not isinstance(scan_data, dict):
69
+ return []
70
+
71
+ flat = scan_data.get("vulnerabilities")
72
+ if isinstance(flat, list) and flat and isinstance(flat[0], dict) and "Severity" in flat[0]:
73
+ return flat
74
+
75
+ vulns = []
76
+ for target in scan_data.get("Results", []) or []:
77
+ if isinstance(target, dict):
78
+ vulns.extend(target.get("Vulnerabilities") or [])
79
+ if not vulns:
80
+ top = scan_data.get("Results", [])
81
+ if isinstance(top, list) and top and isinstance(top[0], dict) and "Severity" in top[0]:
82
+ vulns = top
83
+ return vulns
84
+
85
+
86
+ def count_findings(results: dict) -> dict:
87
+ """Return {'vulnerabilities': int, 'secrets': int} for a scan-results payload."""
88
+ vulns = len(extract_vulnerabilities(results))
89
+
90
+ secrets = 0
91
+ secret_scan = ((results or {}).get("scans") or {}).get("secret") or {}
92
+ summary = secret_scan.get("summary")
93
+ if isinstance(summary, dict):
94
+ ai_triage = summary.get("ai_triage")
95
+ if isinstance(ai_triage, dict) and isinstance(ai_triage.get("real_secrets"), int):
96
+ secrets = ai_triage["real_secrets"]
97
+ elif isinstance(summary.get("total"), int):
98
+ secrets = summary["total"]
99
+
100
+ return {"vulnerabilities": vulns, "secrets": secrets}
101
+
102
+
103
+ def _prompt_scan_data(results: dict) -> dict:
104
+ """Build the sanitized, brevity-trimmed scan-data block embedded in a prompt."""
105
+ prompt_data = {k: v for k, v in results.items() if k != "scans"}
106
+ if "scans" in results:
107
+ prompt_data["scans"] = {}
108
+ for s_type, s_data in results["scans"].items():
109
+ if s_type == "sbom":
110
+ prompt_data["scans"][s_type] = {
111
+ "status": (s_data or {}).get("status"),
112
+ "message": "SBOM data omitted for brevity",
113
+ }
114
+ else:
115
+ prompt_data["scans"][s_type] = s_data
116
+ return strip_sensitive(prompt_data)
117
+
118
+
119
+ _ADVISE_INSTRUCTIONS = """## Instructions for AI Agent:
120
+ 1. Review the vulnerabilities and secrets detected above.
121
+ 2. For each finding, locate the vulnerable code in the repository.
122
+ 3. Provide the necessary code changes to fix the security issues.
123
+ 4. If applicable, explain the fix and how it resolves the vulnerability."""
124
+
125
+ _APPLY_INSTRUCTIONS = """## Your task
126
+
127
+ You are running on the user's own machine inside their checkout of this
128
+ repository. Apply the security fixes directly to the files in the current
129
+ working directory.
130
+
131
+ Guidelines:
132
+ 1. Fix the findings above, prioritising CRITICAL and HIGH severity first.
133
+ 2. For dependency CVEs, bump the affected package to the listed fixed version
134
+ in the appropriate manifest/lockfile. Do not upgrade unrelated packages.
135
+ 3. For detected secrets, remove the secret from source and replace it with an
136
+ environment variable or secret-manager reference. Never invent a real value.
137
+ 4. Make the smallest change that resolves each finding. Do not refactor
138
+ unrelated code, reformat files, or change behaviour beyond the fix.
139
+ 5. If a finding cannot be safely auto-fixed, leave the code unchanged and note
140
+ why in your summary instead of guessing.
141
+ 6. Do not commit, push, or open a pull request unless explicitly asked — just
142
+ edit the working tree and leave the changes staged for the user to review.
143
+
144
+ When done, print a concise summary of every file you changed and which finding
145
+ each change addresses."""
146
+
147
+
148
+ def build_fix_prompt(
149
+ results: dict,
150
+ label: str,
151
+ mode: str = "advise",
152
+ extra_instruction: Optional[str] = None,
153
+ ) -> str:
154
+ """Build the markdown fix prompt fed to an LLM or local coding agent.
155
+
156
+ Args:
157
+ results: Scan-results payload (already may contain sensitive fields;
158
+ they are stripped here).
159
+ label: Human-readable repo/path label for the heading.
160
+ mode: ``"advise"`` (suggest changes only — legacy ``--prompt`` output) or
161
+ ``"apply"`` (instruct a local agent to edit files in place).
162
+ extra_instruction: Optional caller-supplied instruction appended to the end.
163
+
164
+ Returns:
165
+ The prompt as a string.
166
+ """
167
+ prompt_data = _prompt_scan_data(results or {})
168
+
169
+ parts = [
170
+ f"# Prismor Security Scan Results for {label}\n",
171
+ "## Scan Data\n",
172
+ "```json",
173
+ json.dumps(prompt_data, indent=2),
174
+ "```\n",
175
+ _APPLY_INSTRUCTIONS if mode == "apply" else _ADVISE_INSTRUCTIONS,
176
+ ]
177
+ if extra_instruction:
178
+ parts.append(f"\n## Additional instruction from the user\n{extra_instruction}")
179
+ return "\n".join(parts)