from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from typing import Any from .config import RuntimeConfig from .llm import OpenAICompatibleLLM, make_pydantic_agent, make_pydantic_openai_model, parse_fix_candidate from .policy import build_rule_hints_md, enrich_findings from .schemas import ( ApprovalDecision, ComplianceReport, EnrichedFindings, Finding, FixCandidate, IngestRequest, ScanBundle, ValidationResult, ) @dataclass(slots=True) class PydanticAgentBundle: ingest: Any | None = None policy_context: Any | None = None patch: Any | None = None validation: Any | None = None compliance: Any | None = None approval: Any | None = None @property def available(self) -> bool: return any( agent is not None for agent in [ self.ingest, self.policy_context, self.patch, self.validation, self.compliance, self.approval, ] ) def create_pydantic_agent_bundle(config: RuntimeConfig) -> PydanticAgentBundle: """Build PydanticAI agents when the installed API supports it.""" model = make_pydantic_openai_model(config) return PydanticAgentBundle( ingest=make_pydantic_agent( model, ScanBundle, "Identify file type and return scanner bundle. Deterministic scanner tools supply findings.", ), policy_context=make_pydantic_agent( model, EnrichedFindings, "Map scanner findings to policy descriptions. Do not invent policy IDs.", ), patch=make_pydantic_agent(model, FixCandidate, "Return only a valid FixCandidate JSON object."), validation=make_pydantic_agent(model, ValidationResult, "Summarize deterministic validation results only."), compliance=make_pydantic_agent(model, ComplianceReport, "Compute compliance summary only from scan summaries."), approval=make_pydantic_agent( model, ApprovalDecision, "Return needs_human_review unless deterministic validation says accept and auto approval is enabled.", ), ) def shard_for_context(file_content: str, findings: list[Finding], max_chars: int) -> str: if len(file_content) <= max_chars: return file_content lines = file_content.splitlines() selected: list[str] = ["# Non-targeted blocks omitted for context budget."] used_ranges: list[tuple[int, int]] = [] for finding in findings: start = max((finding.line_start or 1) - 20, 1) end = min((finding.line_end or start) + 20, len(lines)) if any(not (end < old_start or start > old_end) for old_start, old_end in used_ranges): continue used_ranges.append((start, end)) selected.append(f"\n# Context lines {start}-{end}") selected.extend(lines[start - 1 : end]) if len("\n".join(selected)) >= max_chars: break return "\n".join(selected)[:max_chars] def fix_candidate_schema_text() -> str: return """ Return exactly one JSON object: { "patch": "unified diff string", "fixed_file": "complete corrected file content", "resolved_policy_ids": ["CKV_..."], "explanation": "short summary", "verification_commands": ["checkov -f -o json"], "risk_notes": ["human-review note if needed"], "requires_human_approval": true } No markdown fences. No prose outside JSON. """.strip() def build_patch_prompt( enriched: EnrichedFindings, retry_feedback: str | None = None, max_context_chars: int = 18000, max_finding_records: int = 24, ) -> str: iac_type = enriched.file.iac_type lang = "hcl" if iac_type == "terraform" else "yaml" findings_for_json = enriched.findings[:max_finding_records] omitted = max(0, len(enriched.findings) - len(findings_for_json)) scanner_report_json = json.dumps( [finding.model_dump(mode="json") for finding in findings_for_json], ensure_ascii=False, indent=2, ) rule_ids = [finding.rule_id for finding in enriched.findings] original_context = shard_for_context(enriched.file.file_content, list(enriched.findings), max_context_chars) prompt = f""" Original file type: {iac_type} Original file language: {lang} {original_context} {json.dumps(sorted(set(rule_ids)))} {scanner_report_json} {omitted} {build_rule_hints_md(rule_ids)} Task: Create the smallest safe in-file remediation that resolves as many targeted rule_id values as possible. Hard requirements: - Start from the ORIGINAL file. Add or modify only attributes directly needed by the matching fix templates. - fixed_file must contain the complete corrected file, including unchanged lines. - Do not delete, rename, or reorder resources, modules, providers, containers, or attributes unless the rule requires it. - Do not invent placeholder ARNs, account IDs, bucket names, topics, roles, regions, KMS keys, CIDRs, or image tags. - If a rule requires an external dependency or unknown business decision, leave it unresolved and set requires_human_approval=true. - Do not introduce CRITICAL or HIGH findings. - Do not add checkov:skip, nosec, tfsec:ignore, or scanner suppression comments. - Before returning, list only rule IDs that your fixed_file actually resolves in resolved_policy_ids. {fix_candidate_schema_text()} """.strip() if retry_feedback: prompt += f"\n\n\n{retry_feedback[:4000]}\n" return prompt def compress_feedback(result: ValidationResult, attempt: int, max_retries: int) -> str: payload = { "attempt": attempt + 1, "max_retries": max_retries, "retry_reason": result.retry_reason, "targeted_still_unresolved": result.targeted_unresolved, "new_critical_high_count": result.new_critical_high_count, "changed_lines_ratio": round(result.changed_lines_ratio, 3), "post_scan_summary": result.post_summary, } feedback = json.dumps(payload, ensure_ascii=False) if result.targeted_unresolved: feedback += "\n\nUnresolved-rule fix templates:\n" feedback += build_rule_hints_md(result.targeted_unresolved) feedback += ( "\n\nRetry instructions:\n" "- Start again from the original file, not the previous attempt.\n" "- Apply the smallest safe edit for the unresolved rules.\n" "- Remove rule IDs from resolved_policy_ids when validation did not resolve them.\n" ) return feedback[:4000] class IngestAgent: def run(self, request: IngestRequest, tmp_dir: Path | None = None) -> ScanBundle: from .scanners import unified_scan return unified_scan(request, tmp_dir=tmp_dir) class PolicyContextAgent: def run(self, bundle: ScanBundle) -> EnrichedFindings: return enrich_findings(bundle) class PatchAgent: def __init__(self, config: RuntimeConfig, log_dir: Path): self.llm = OpenAICompatibleLLM(config) self.log_dir = log_dir self.log_dir.mkdir(parents=True, exist_ok=True) def run( self, enriched: EnrichedFindings, retry_feedback: str | None = None, max_context_chars: int = 18000, ) -> tuple[FixCandidate | None, bool, str]: prompt = build_patch_prompt(enriched, retry_feedback, max_context_chars=max_context_chars) messages = [ { "role": "system", "content": ( "You are IaC-SecFix. Return only valid JSON matching the FixCandidate schema. " "Do not include markdown, prose, analysis, or code fences." ), }, {"role": "user", "content": prompt}, ] result = self.llm.chat(messages, json_mode=True) self.log_dir.joinpath("last_patch_raw.txt").write_text(result.text[:20000], errors="ignore") candidate, schema_valid = parse_fix_candidate(result.text) return candidate, schema_valid, result.text class ValidationAgent: def run( self, request: IngestRequest, candidate: FixCandidate, targeted: list[str], tmp_dir: Path | None, max_changed_lines_ratio: float, ) -> ValidationResult: from .scanners import validate_candidate return validate_candidate( request, candidate, targeted, tmp_dir=tmp_dir, max_changed_lines_ratio=max_changed_lines_ratio, ) class ComplianceAgent: def run(self, pre_summary: dict[str, object], result: ValidationResult) -> ComplianceReport: before_failed = max(int(pre_summary.get("failed", 0) or 0), len(result.targeted_resolved) + len(result.targeted_unresolved), 1) after_failed = int(result.post_summary.get("failed", 0) or 0) targeted_total = max(len(result.targeted_resolved) + len(result.targeted_unresolved), 1) return ComplianceReport( cis_pass_rate_before=max(0.0, 1.0 - before_failed / max(before_failed, 1)), cis_pass_rate_after=max(0.0, 1.0 - after_failed / max(before_failed, 1)), network_exposure_reduction=1.0 if result.targeted_resolved else 0.0, iam_privilege_reduction=1.0 if result.targeted_resolved else 0.0, targeted_resolution_rate=len(result.targeted_resolved) / targeted_total, scanner_clean=after_failed == 0, ) class HumanApprovalGate: def __init__(self, auto_approve_validated: bool = False): self.auto_approve_validated = auto_approve_validated def run( self, patch: FixCandidate, result: ValidationResult, compliance: ComplianceReport, n_retries: int, ) -> ApprovalDecision: can_auto_approve = ( self.auto_approve_validated and result.verdict == "accept" and not patch.requires_human_approval ) if can_auto_approve: return ApprovalDecision( action="approved", reason="Deterministic validation accepted the patch.", patch=patch, validation=result, compliance=compliance, n_retries=n_retries, ) return ApprovalDecision( action="needs_human_review", reason="Validated candidate requires human approval before export.", patch=patch, validation=result, compliance=compliance, n_retries=n_retries, )