patchi 0.7.5__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.
- patchi/__init__.py +1 -0
- patchi/__main__.py +13 -0
- patchi/cli/__init__.py +0 -0
- patchi/cli/command_families.py +235 -0
- patchi/cli/commands/__init__.py +0 -0
- patchi/cli/commands/access_cmd.py +65 -0
- patchi/cli/commands/agent_stats_cmd.py +273 -0
- patchi/cli/commands/agents_cmd.py +187 -0
- patchi/cli/commands/ai_cmd.py +308 -0
- patchi/cli/commands/assure_cmd.py +538 -0
- patchi/cli/commands/audit_cmd.py +286 -0
- patchi/cli/commands/auto_cmd.py +110 -0
- patchi/cli/commands/blame_cmd.py +53 -0
- patchi/cli/commands/chains_cmd.py +327 -0
- patchi/cli/commands/charter_cmd.py +173 -0
- patchi/cli/commands/chat_cmd.py +597 -0
- patchi/cli/commands/check_cmd.py +232 -0
- patchi/cli/commands/cleanup_cmd.py +328 -0
- patchi/cli/commands/cockpit_cmd.py +208 -0
- patchi/cli/commands/commands_cmd.py +160 -0
- patchi/cli/commands/cross_repo_cmd.py +113 -0
- patchi/cli/commands/deps_cmd.py +110 -0
- patchi/cli/commands/dev_check_cmd.py +342 -0
- patchi/cli/commands/dev_cmd.py +445 -0
- patchi/cli/commands/doctor_cmd.py +457 -0
- patchi/cli/commands/findings_cmd.py +187 -0
- patchi/cli/commands/fix_cmd.py +648 -0
- patchi/cli/commands/fix_review_cmd.py +273 -0
- patchi/cli/commands/heatmap_cmd.py +62 -0
- patchi/cli/commands/help_cmd.py +159 -0
- patchi/cli/commands/hosted_cmd.py +980 -0
- patchi/cli/commands/init.py +456 -0
- patchi/cli/commands/key_cmd.py +571 -0
- patchi/cli/commands/learn_cmd.py +140 -0
- patchi/cli/commands/link_cmd.py +104 -0
- patchi/cli/commands/log_cmd.py +57 -0
- patchi/cli/commands/memory_cmd.py +215 -0
- patchi/cli/commands/model_cmd.py +207 -0
- patchi/cli/commands/notify_cmd.py +213 -0
- patchi/cli/commands/notify_prompts.py +29 -0
- patchi/cli/commands/patch_cmd.py +231 -0
- patchi/cli/commands/plan_cmd.py +128 -0
- patchi/cli/commands/plugins_cmd.py +307 -0
- patchi/cli/commands/queue_cmd.py +152 -0
- patchi/cli/commands/quick_cmd.py +159 -0
- patchi/cli/commands/ready_cmd.py +292 -0
- patchi/cli/commands/report_cmd.py +416 -0
- patchi/cli/commands/restrict_cmd.py +127 -0
- patchi/cli/commands/review_cmd.py +225 -0
- patchi/cli/commands/rules_cmd.py +233 -0
- patchi/cli/commands/scan_cmd.py +1908 -0
- patchi/cli/commands/settings_cmd.py +45 -0
- patchi/cli/commands/status_cmd.py +679 -0
- patchi/cli/commands/test_cmd.py +1052 -0
- patchi/cli/commands/trend_cmd.py +296 -0
- patchi/cli/commands/undo_cmd.py +235 -0
- patchi/cli/commands/update_cmd.py +345 -0
- patchi/cli/commands/verify_cmd.py +56 -0
- patchi/cli/commands/vr_cmd.py +440 -0
- patchi/cli/commands/watch_cmd.py +304 -0
- patchi/cli/commands/web_cmd.py +114 -0
- patchi/cli/console.py +101 -0
- patchi/cli/display/__init__.py +1 -0
- patchi/cli/display/live_audit.py +269 -0
- patchi/cli/display/live_progress.py +169 -0
- patchi/cli/framework.py +241 -0
- patchi/cli/logo.py +128 -0
- patchi/cli/main.py +222 -0
- patchi/cli/registry.py +1171 -0
- patchi/cli/style.py +68 -0
- patchi/cli/themes.py +187 -0
- patchi/cli/ux.py +444 -0
- patchi/core/__init__.py +0 -0
- patchi/core/agents/__init__.py +0 -0
- patchi/core/agents/api_fuzzer_agent.py +141 -0
- patchi/core/agents/attack_agent.py +242 -0
- patchi/core/agents/base.py +1012 -0
- patchi/core/agents/bug_predictor_agent.py +58 -0
- patchi/core/agents/build_tool_validator.py +150 -0
- patchi/core/agents/cache.py +192 -0
- patchi/core/agents/chaos_agent.py +133 -0
- patchi/core/agents/cicd_generator.py +240 -0
- patchi/core/agents/comment_scanner.py +280 -0
- patchi/core/agents/console_logging_agent.py +156 -0
- patchi/core/agents/coordinator.py +858 -0
- patchi/core/agents/core_scanner.py +269 -0
- patchi/core/agents/coverage_prioritizer.py +181 -0
- patchi/core/agents/dead_code_hygiene.py +296 -0
- patchi/core/agents/dead_code_scanner.py +692 -0
- patchi/core/agents/dependency_scanner.py +725 -0
- patchi/core/agents/doc_claim_agent.py +573 -0
- patchi/core/agents/duplicate_scanner.py +568 -0
- patchi/core/agents/env_scanner.py +410 -0
- patchi/core/agents/feature_flag_agent.py +89 -0
- patchi/core/agents/frontend_framework_agent.py +133 -0
- patchi/core/agents/goroutine_agent.py +82 -0
- patchi/core/agents/governor.py +1972 -0
- patchi/core/agents/i18n_agent.py +95 -0
- patchi/core/agents/license_compliance.py +247 -0
- patchi/core/agents/linking_agent.py +251 -0
- patchi/core/agents/memory_profiler_agent.py +102 -0
- patchi/core/agents/modernization_agent.py +84 -0
- patchi/core/agents/mutation_agent.py +174 -0
- patchi/core/agents/nl_query_agent.py +74 -0
- patchi/core/agents/promise_rejection_agent.py +100 -0
- patchi/core/agents/refactoring_agent.py +286 -0
- patchi/core/agents/resource_leak_agent.py +91 -0
- patchi/core/agents/route_graph_scanner.py +951 -0
- patchi/core/agents/run_agent.py +106 -0
- patchi/core/agents/rust_unwrap_agent.py +100 -0
- patchi/core/agents/sbom_generator.py +284 -0
- patchi/core/agents/scanners.py +67 -0
- patchi/core/agents/side/__init__.py +1 -0
- patchi/core/agents/side/build_agent.py +65 -0
- patchi/core/agents/side/format_agent.py +50 -0
- patchi/core/agents/side/install_agent.py +63 -0
- patchi/core/agents/side_file_scanner.py +549 -0
- patchi/core/agents/snapshot_drift_detector.py +146 -0
- patchi/core/agents/spa_route_inventory.py +203 -0
- patchi/core/agents/test_scanner.py +442 -0
- patchi/core/agents/tool_health.py +109 -0
- patchi/core/agents/tool_runner.py +162 -0
- patchi/core/agents/type_scanner.py +168 -0
- patchi/core/agents/ui_scanner.py +558 -0
- patchi/core/ai/__init__.py +20 -0
- patchi/core/ai/agent_audit.py +153 -0
- patchi/core/ai/agent_profiler.py +277 -0
- patchi/core/ai/client.py +499 -0
- patchi/core/ai/cost_tracker.py +107 -0
- patchi/core/ai/model_router.py +318 -0
- patchi/core/ai/orchestrator.py +625 -0
- patchi/core/ai/prompts.py +681 -0
- patchi/core/ai/smart.py +302 -0
- patchi/core/ai/tool_executor.py +24 -0
- patchi/core/ai/tools/__init__.py +10 -0
- patchi/core/ai/tools/executor.py +621 -0
- patchi/core/ai/tools/realize.py +1160 -0
- patchi/core/ai/tools/registry.py +1577 -0
- patchi/core/assurance/__init__.py +33 -0
- patchi/core/assurance/builder.py +211 -0
- patchi/core/assurance/graph.py +342 -0
- patchi/core/assurance/invariants.py +168 -0
- patchi/core/atomic.py +61 -0
- patchi/core/attackers/__init__.py +17 -0
- patchi/core/attackers/api_abuse.py +89 -0
- patchi/core/attackers/auth_bypass.py +73 -0
- patchi/core/attackers/base.py +91 -0
- patchi/core/attackers/business_logic.py +88 -0
- patchi/core/attackers/planner.py +108 -0
- patchi/core/attackers/priv_escalation.py +88 -0
- patchi/core/attackers/recon.py +87 -0
- patchi/core/brain/__init__.py +0 -0
- patchi/core/brain/ast_utils/__init__.py +81 -0
- patchi/core/brain/ast_utils/assignments.py +219 -0
- patchi/core/brain/ast_utils/calls.py +139 -0
- patchi/core/brain/ast_utils/config.py +132 -0
- patchi/core/brain/ast_utils/control_flow.py +74 -0
- patchi/core/brain/ast_utils/dead_symbols.py +205 -0
- patchi/core/brain/ast_utils/decorators.py +108 -0
- patchi/core/brain/ast_utils/helpers.py +144 -0
- patchi/core/brain/ast_utils/imports.py +169 -0
- patchi/core/brain/ast_utils/scan.py +143 -0
- patchi/core/brain/ast_utils/taint.py +310 -0
- patchi/core/brain/audit.py +379 -0
- patchi/core/brain/baseline.py +195 -0
- patchi/core/brain/blast_radius.py +312 -0
- patchi/core/brain/body_tags.py +144 -0
- patchi/core/brain/brain.py +1161 -0
- patchi/core/brain/brain_context.py +393 -0
- patchi/core/brain/brain_watcher.py +340 -0
- patchi/core/brain/charter.py +473 -0
- patchi/core/brain/classifier.py +214 -0
- patchi/core/brain/cockpit.py +350 -0
- patchi/core/brain/code_query.py +710 -0
- patchi/core/brain/contract.py +889 -0
- patchi/core/brain/contract_diff.py +155 -0
- patchi/core/brain/council.py +800 -0
- patchi/core/brain/cross_repo.py +104 -0
- patchi/core/brain/dead_code_tools.py +157 -0
- patchi/core/brain/doc_validator.py +197 -0
- patchi/core/brain/domain_activator.py +2183 -0
- patchi/core/brain/enriched_context.py +220 -0
- patchi/core/brain/file_corpus.py +220 -0
- patchi/core/brain/framework.py +953 -0
- patchi/core/brain/freshness.py +273 -0
- patchi/core/brain/git_aware.py +206 -0
- patchi/core/brain/heatmap.py +104 -0
- patchi/core/brain/ignore_parser.py +153 -0
- patchi/core/brain/import_graph.py +486 -0
- patchi/core/brain/languages.py +397 -0
- patchi/core/brain/layered_brain.py +419 -0
- patchi/core/brain/learning.py +229 -0
- patchi/core/brain/orphaned_endpoints.py +313 -0
- patchi/core/brain/personas/__init__.py +648 -0
- patchi/core/brain/personas/bad_user.py +165 -0
- patchi/core/brain/personas/base.py +349 -0
- patchi/core/brain/proactive.py +807 -0
- patchi/core/brain/project_context.py +121 -0
- patchi/core/brain/project_reader.py +521 -0
- patchi/core/brain/reasoning.py +269 -0
- patchi/core/brain/route_detector/__init__.py +15 -0
- patchi/core/brain/route_detector/base.py +31 -0
- patchi/core/brain/route_detector/csharp.py +209 -0
- patchi/core/brain/route_detector/file_based.py +81 -0
- patchi/core/brain/route_detector/go.py +152 -0
- patchi/core/brain/route_detector/java.py +195 -0
- patchi/core/brain/route_detector/javascript.py +197 -0
- patchi/core/brain/route_detector/php.py +158 -0
- patchi/core/brain/route_detector/python.py +193 -0
- patchi/core/brain/route_detector/registry.py +25 -0
- patchi/core/brain/route_detector/ruby.py +134 -0
- patchi/core/brain/route_detector/rust.py +216 -0
- patchi/core/brain/route_detector/swift.py +118 -0
- patchi/core/brain/route_mapper.py +265 -0
- patchi/core/brain/scanner.py +2136 -0
- patchi/core/brain/secrets.py +280 -0
- patchi/core/brain/symbol_graph.py +1955 -0
- patchi/core/brain/team.py +28 -0
- patchi/core/brain/trace_log.py +132 -0
- patchi/core/brain/type_checker/__init__.py +65 -0
- patchi/core/brain/type_checker/base.py +60 -0
- patchi/core/brain/type_checker/csharp.py +59 -0
- patchi/core/brain/type_checker/dart.py +59 -0
- patchi/core/brain/type_checker/go.py +81 -0
- patchi/core/brain/type_checker/java.py +75 -0
- patchi/core/brain/type_checker/kotlin.py +75 -0
- patchi/core/brain/type_checker/php.py +88 -0
- patchi/core/brain/type_checker/python.py +87 -0
- patchi/core/brain/type_checker/rust.py +81 -0
- patchi/core/brain/type_checker/swift.py +59 -0
- patchi/core/brain/type_checker/typescript.py +298 -0
- patchi/core/brain/understander.py +130 -0
- patchi/core/brain/verify.py +379 -0
- patchi/core/campaigns/__init__.py +14 -0
- patchi/core/campaigns/base.py +62 -0
- patchi/core/campaigns/data_flow_abuse.py +153 -0
- patchi/core/campaigns/orchestrator.py +140 -0
- patchi/core/campaigns/state_transitions.py +178 -0
- patchi/core/ci_bundle.py +104 -0
- patchi/core/config.py +311 -0
- patchi/core/constants.py +245 -0
- patchi/core/debug/__init__.py +25 -0
- patchi/core/debug/adapters/__init__.py +11 -0
- patchi/core/debug/adapters/codelldb.py +379 -0
- patchi/core/debug/adapters/node.py +222 -0
- patchi/core/debug/adapters/powershell.py +210 -0
- patchi/core/debug/adapters/python.py +244 -0
- patchi/core/debug/capture.py +210 -0
- patchi/core/debug/dap_client.py +268 -0
- patchi/core/detector/__init__.py +0 -0
- patchi/core/detector/audit.py +194 -0
- patchi/core/detector/bus.py +226 -0
- patchi/core/detector/dispatcher.py +275 -0
- patchi/core/detector/event.py +254 -0
- patchi/core/detector/sigma_engine.py +378 -0
- patchi/core/detector/triage.py +289 -0
- patchi/core/export/sarif.py +113 -0
- patchi/core/fix/__init__.py +17 -0
- patchi/core/fix/applier.py +574 -0
- patchi/core/fix/base.py +510 -0
- patchi/core/fix/code_fixer.py +169 -0
- patchi/core/fix/dead_code_remover.py +131 -0
- patchi/core/fix/fix_agents.py +775 -0
- patchi/core/fix/patch.py +444 -0
- patchi/core/fix/risk_gate.py +383 -0
- patchi/core/fix/security_fixer.py +93 -0
- patchi/core/fix/verify_loop.py +204 -0
- patchi/core/fuzz/__init__.py +8 -0
- patchi/core/fuzz/corpus.py +137 -0
- patchi/core/fuzz/input_fuzzer.py +256 -0
- patchi/core/fuzz/sequence_fuzzer.py +162 -0
- patchi/core/fuzz/state_fuzzer.py +236 -0
- patchi/core/health.py +407 -0
- patchi/core/hosted/__init__.py +1 -0
- patchi/core/hosted/anomaly.py +298 -0
- patchi/core/hosted/audit_log.py +102 -0
- patchi/core/hosted/compliance_report.py +148 -0
- patchi/core/hosted/ip_reputation.py +169 -0
- patchi/core/hosted/log_parsers.py +251 -0
- patchi/core/hosted/tokens.py +127 -0
- patchi/core/hosted/watchlist.py +164 -0
- patchi/core/hosted/webhooks.py +156 -0
- patchi/core/memory.py +437 -0
- patchi/core/notifications/__init__.py +18 -0
- patchi/core/notifications/channels.py +192 -0
- patchi/core/notifications/digest.py +141 -0
- patchi/core/notifications/escalation.py +158 -0
- patchi/core/notifications/notifier.py +182 -0
- patchi/core/notifications/quiet_hours.py +69 -0
- patchi/core/plugins/__init__.py +48 -0
- patchi/core/plugins/analyzer.py +326 -0
- patchi/core/plugins/analyzers/__init__.py +6 -0
- patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
- patchi/core/plugins/analyzers/secret_scanner.py +158 -0
- patchi/core/plugins/analyzers/todo_scanner.py +115 -0
- patchi/core/plugins/registry.py +335 -0
- patchi/core/queue.py +375 -0
- patchi/core/reliability/__init__.py +13 -0
- patchi/core/reliability/chaos.py +163 -0
- patchi/core/reliability/fault_injector.py +172 -0
- patchi/core/reliability/idempotency.py +128 -0
- patchi/core/reliability/recovery.py +147 -0
- patchi/core/runtime/__init__.py +5 -0
- patchi/core/runtime/tracer.py +230 -0
- patchi/core/scan_bus.py +156 -0
- patchi/core/security/__init__.py +1 -0
- patchi/core/security/ai_validator.py +462 -0
- patchi/core/security/app_mapper.py +216 -0
- patchi/core/security/app_profile.py +464 -0
- patchi/core/security/attack_feedback.py +141 -0
- patchi/core/security/attack_scenarios/__init__.py +1 -0
- patchi/core/security/attack_tree.py +176 -0
- patchi/core/security/auth_audit_agent.py +344 -0
- patchi/core/security/authz_agent.py +602 -0
- patchi/core/security/auto_fix_proactive.py +620 -0
- patchi/core/security/auto_fixer.py +660 -0
- patchi/core/security/auto_ticket.py +53 -0
- patchi/core/security/bandit_agent.py +152 -0
- patchi/core/security/blast_radius.py +226 -0
- patchi/core/security/browser_tester.py +333 -0
- patchi/core/security/business_logic_agent.py +263 -0
- patchi/core/security/catch_block_auditor.py +395 -0
- patchi/core/security/cdn_cache_agent.py +357 -0
- patchi/core/security/chain_analyzer.py +321 -0
- patchi/core/security/chain_to_assurance.py +168 -0
- patchi/core/security/charter.py +510 -0
- patchi/core/security/cloud_waf_detector.py +295 -0
- patchi/core/security/codeql_agent.py +233 -0
- patchi/core/security/compliance_agent.py +348 -0
- patchi/core/security/confidence_gate.py +423 -0
- patchi/core/security/container_scanner.py +282 -0
- patchi/core/security/crypto_agent.py +378 -0
- patchi/core/security/cve_monitor.py +262 -0
- patchi/core/security/dast_agent.py +865 -0
- patchi/core/security/defectdojo.py +238 -0
- patchi/core/security/defenders/__init__.py +75 -0
- patchi/core/security/defenders/auth_middleware.py +115 -0
- patchi/core/security/defenders/base.py +97 -0
- patchi/core/security/defenders/block_ip.py +71 -0
- patchi/core/security/defenders/block_ws_origin.py +36 -0
- patchi/core/security/defenders/crypto_fix.py +118 -0
- patchi/core/security/defenders/enforce_rate_limit.py +108 -0
- patchi/core/security/defenders/fix_code.py +93 -0
- patchi/core/security/defenders/invalidate_session.py +40 -0
- patchi/core/security/defenders/patch_config.py +70 -0
- patchi/core/security/defenders/rotate_secret.py +54 -0
- patchi/core/security/defenders/suspend_account.py +41 -0
- patchi/core/security/defenders/update_dependency.py +160 -0
- patchi/core/security/defense_layer.py +217 -0
- patchi/core/security/dependency_vulnerability_agent.py +616 -0
- patchi/core/security/detection_pipeline.py +289 -0
- patchi/core/security/dns_security_agent.py +497 -0
- patchi/core/security/domain_activator_v2.py +913 -0
- patchi/core/security/domain_loader.py +1004 -0
- patchi/core/security/domains/__init__.py +0 -0
- patchi/core/security/email_authentication_agent.py +417 -0
- patchi/core/security/env_var_validator.py +304 -0
- patchi/core/security/evidence.py +100 -0
- patchi/core/security/falco_runtime_agent.py +387 -0
- patchi/core/security/fix-playbooks/__init__.py +0 -0
- patchi/core/security/gated_finding.py +85 -0
- patchi/core/security/git_diff_activator.py +367 -0
- patchi/core/security/governance.py +208 -0
- patchi/core/security/gradual_gate.py +34 -0
- patchi/core/security/history.py +315 -0
- patchi/core/security/iac_scanner.py +250 -0
- patchi/core/security/ignore_expiry.py +111 -0
- patchi/core/security/ignore_learner.py +416 -0
- patchi/core/security/injection_agent.py +319 -0
- patchi/core/security/insecure_randomness_agent.py +288 -0
- patchi/core/security/intent_analyzer.py +326 -0
- patchi/core/security/jwt_agent.py +453 -0
- patchi/core/security/kubernetes_agent.py +638 -0
- patchi/core/security/layer2_orchestrator.py +341 -0
- patchi/core/security/llm_security_agent.py +252 -0
- patchi/core/security/misconfig_agent.py +600 -0
- patchi/core/security/mobile_security_agent.py +474 -0
- patchi/core/security/network_agent.py +376 -0
- patchi/core/security/noise_filter.py +274 -0
- patchi/core/security/orchestrator.py +313 -0
- patchi/core/security/pattern_context.py +125 -0
- patchi/core/security/pattern_loader.py +101 -0
- patchi/core/security/pentest/__init__.py +4 -0
- patchi/core/security/pentest/base.py +58 -0
- patchi/core/security/pentest/dalfox_adapter.py +40 -0
- patchi/core/security/pentest/ffuf_adapter.py +48 -0
- patchi/core/security/pentest/nuclei_adapter.py +50 -0
- patchi/core/security/pentest/registry.py +80 -0
- patchi/core/security/pentest/shannon_adapter.py +107 -0
- patchi/core/security/pentest/sqlmap_adapter.py +56 -0
- patchi/core/security/pentest/zap_adapter.py +52 -0
- patchi/core/security/plan_auditor.py +276 -0
- patchi/core/security/policy_engine.py +272 -0
- patchi/core/security/prechecks.py +172 -0
- patchi/core/security/precommit_hook.py +247 -0
- patchi/core/security/privacy_agent.py +361 -0
- patchi/core/security/push_notification_agent.py +417 -0
- patchi/core/security/pysa_agent.py +143 -0
- patchi/core/security/reasoning.py +442 -0
- patchi/core/security/red_team_agent.py +184 -0
- patchi/core/security/red_team_engine.py +1142 -0
- patchi/core/security/remediation.py +360 -0
- patchi/core/security/request_interceptor.py +294 -0
- patchi/core/security/runtime_validator.py +245 -0
- patchi/core/security/saml_sso_agent.py +401 -0
- patchi/core/security/sast_agent.py +213 -0
- patchi/core/security/sast_gate.py +95 -0
- patchi/core/security/scheduler.py +287 -0
- patchi/core/security/secrets_runtime_agent.py +476 -0
- patchi/core/security/security_agents.py +175 -0
- patchi/core/security/security_config.py +551 -0
- patchi/core/security/security_probe.py +762 -0
- patchi/core/security/security_taint.py +1005 -0
- patchi/core/security/sensitive_data_agent.py +271 -0
- patchi/core/security/service_mesh_agent.py +412 -0
- patchi/core/security/session_management_agent.py +245 -0
- patchi/core/security/ssrf_agent.py +422 -0
- patchi/core/security/supply_chain.py +449 -0
- patchi/core/security/threat_model_generator.py +378 -0
- patchi/core/security/threat_model_updater.py +196 -0
- patchi/core/security/tool_adapters.py +135 -0
- patchi/core/security/tool_verify.py +173 -0
- patchi/core/security/websocket_security_agent.py +188 -0
- patchi/core/snapshot.py +242 -0
- patchi/core/tenant.py +386 -0
- patchi/core/testing/__init__.py +17 -0
- patchi/core/testing/_browser.py +147 -0
- patchi/core/testing/ads_agent.py +141 -0
- patchi/core/testing/api_contract_agent.py +450 -0
- patchi/core/testing/app_discovery_agent.py +641 -0
- patchi/core/testing/app_launcher.py +184 -0
- patchi/core/testing/browser_test_agent.py +516 -0
- patchi/core/testing/e2e_flow_agent.py +354 -0
- patchi/core/testing/flake_detector_agent.py +292 -0
- patchi/core/testing/gate.py +31 -0
- patchi/core/testing/live_test_runner.py +304 -0
- patchi/core/testing/live_v2/__init__.py +1 -0
- patchi/core/testing/live_v2/browser_pool.py +349 -0
- patchi/core/testing/live_v2/browser_test_runner.py +313 -0
- patchi/core/testing/live_v2/runner.py +626 -0
- patchi/core/testing/live_v2/screenshot_manager.py +474 -0
- patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
- patchi/core/testing/live_v2/video_recorder.py +303 -0
- patchi/core/testing/regression_agent.py +302 -0
- patchi/core/testing/security_test_agent.py +253 -0
- patchi/core/testing/stress_test_agent.py +203 -0
- patchi/core/testing/test_agents.py +50 -0
- patchi/core/testing/test_config.py +194 -0
- patchi/core/testing/ui_accessibility_agent.py +324 -0
- patchi/core/testing/ui_button_agent.py +294 -0
- patchi/core/testing/ui_layout_agent.py +330 -0
- patchi/core/testing/unit_test_agent.py +1149 -0
- patchi/core/testing/visual_regression_agent.py +260 -0
- patchi/web/__init__.py +1 -0
- patchi/web/api/__init__.py +1 -0
- patchi/web/api/brain_map.py +114 -0
- patchi/web/api/charts.py +47 -0
- patchi/web/api/chat.py +224 -0
- patchi/web/api/cicd.py +576 -0
- patchi/web/api/dast_evidence.py +49 -0
- patchi/web/api/dev_check.py +320 -0
- patchi/web/api/fix.py +134 -0
- patchi/web/api/guard.py +65 -0
- patchi/web/api/hosted.py +128 -0
- patchi/web/api/hosted_v2.py +234 -0
- patchi/web/api/live_testing.py +699 -0
- patchi/web/api/scan.py +582 -0
- patchi/web/api/smart.py +111 -0
- patchi/web/api/tenant.py +117 -0
- patchi/web/api/tools.py +85 -0
- patchi/web/api_legacy.py +2401 -0
- patchi/web/app.py +310 -0
- patchi/web/events.py +483 -0
- patchi/web/routes/__init__.py +1 -0
- patchi/web/routes/assurance.py +541 -0
- patchi/web/routes/brain.py +36 -0
- patchi/web/routes/charter.py +227 -0
- patchi/web/routes/chat.py +23 -0
- patchi/web/routes/dashboard.py +558 -0
- patchi/web/routes/dashboard_v2.py +402 -0
- patchi/web/routes/findings.py +330 -0
- patchi/web/routes/guard.py +35 -0
- patchi/web/routes/history.py +126 -0
- patchi/web/routes/landing.py +52 -0
- patchi/web/routes/live_testing.py +24 -0
- patchi/web/routes/review.py +57 -0
- patchi/web/routes/self_improvement.py +110 -0
- patchi/web/routes/settings.py +49 -0
- patchi/web/routes/tokens.py +29 -0
- patchi/web/spawn.py +139 -0
- patchi/web/ws.py +141 -0
- patchi-0.7.5.dist-info/METADATA +315 -0
- patchi-0.7.5.dist-info/RECORD +496 -0
- patchi-0.7.5.dist-info/WHEEL +5 -0
- patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
- patchi-0.7.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Governor — Pipeline state machine wrapping Coordinator.
|
|
3
|
+
|
|
4
|
+
Phases (v2, per files-5 Testing Strategy §3):
|
|
5
|
+
SCAN — Structural pass: rules, dangling edges, CVEs, secrets
|
|
6
|
+
GRAPH_UPDATE — Incremental SymbolGraph patch from scan diff
|
|
7
|
+
TEST_GENERATION — Graph-scoped test generation using neighborhood context
|
|
8
|
+
TEST_EXECUTION — Run generated tests, results become oracle for fix verify
|
|
9
|
+
FIX_GENERATION — Multiple candidates with deterministic autofix first, LLM fallback
|
|
10
|
+
SANDBOX_REVERIFY — Re-run scoped tests + loop-back scan on each candidate
|
|
11
|
+
SCORE_SELECT — Composite scoring → select winner or escalate to human
|
|
12
|
+
|
|
13
|
+
Design:
|
|
14
|
+
- Additive — wraps Coordinator. Coordinator still works standalone.
|
|
15
|
+
- State is stored in SQLite (.patchi/pipeline_state.db) for crash recovery.
|
|
16
|
+
- Phase transitions gated by acceptance criteria (max errors, findings, etc).
|
|
17
|
+
- Structured finding format enforced across all ants.
|
|
18
|
+
- Ambiguous findings → explicit escalation rule, never model guessing.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
gov = Governor(project_root)
|
|
22
|
+
gov.run_full_pipeline_v2() # Full 7-step pipeline
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sqlite3
|
|
28
|
+
import time
|
|
29
|
+
from collections import deque
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from enum import StrEnum
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import yaml
|
|
38
|
+
from loguru import logger
|
|
39
|
+
|
|
40
|
+
import patchi.core.agents.scanners # noqa: F401 — trigger scanner registration
|
|
41
|
+
|
|
42
|
+
# Trigger security agent registration. Security agents register lazily inside
|
|
43
|
+
# security_agents.py, so the module MUST be imported or Coordinator.
|
|
44
|
+
# run_group(AgentGroup.SECURITY) would resolve zero agents.
|
|
45
|
+
try:
|
|
46
|
+
import patchi.core.security.security_agents # noqa: F401
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.debug(f"security_agents not available: {e}")
|
|
49
|
+
|
|
50
|
+
# Trigger test agent registration (pulled in via test_agents.py)
|
|
51
|
+
try:
|
|
52
|
+
import patchi.core.testing.test_agents # noqa: F401
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.debug(f"test_agents not available: {e}")
|
|
55
|
+
|
|
56
|
+
# Trigger fix agent registration. fix/__init__ imports code_fixer,
|
|
57
|
+
# dead_code_remover, and security_fixer; code_fixer imports fix_agents, so all
|
|
58
|
+
# 8 FIX agents register. Without this, run_group(AgentGroup.FIX) resolves ZERO
|
|
59
|
+
# agents and the pipeline's FIX phase silently runs nothing (caught by the
|
|
60
|
+
# smoke-sweep --pipeline orchestration gate).
|
|
61
|
+
try:
|
|
62
|
+
import patchi.core.fix # noqa: F401
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.debug(f"fix agents not available: {e}")
|
|
65
|
+
|
|
66
|
+
# ── Incident state machine types ────────────────────────────────────────────────
|
|
67
|
+
import logging
|
|
68
|
+
|
|
69
|
+
from patchi.core import memory as mem
|
|
70
|
+
from patchi.core.agents.base import (
|
|
71
|
+
AgentGroup,
|
|
72
|
+
AgentResult,
|
|
73
|
+
AgentStatus,
|
|
74
|
+
)
|
|
75
|
+
from patchi.core.agents.coordinator import Coordinator, CoordinatorProgress
|
|
76
|
+
|
|
77
|
+
_log = logging.getLogger("patchi.agents.governor")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class IncidentState(StrEnum):
|
|
81
|
+
DETECTED = "detected"
|
|
82
|
+
CLASSIFIED = "classified"
|
|
83
|
+
TEST_SCOPED = "test_scoped"
|
|
84
|
+
TEST_RUNNING = "test_running"
|
|
85
|
+
TEST_COMPLETE = "test_complete"
|
|
86
|
+
FIX_CANDIDATE_GENERATION = "fix_candidate_generation"
|
|
87
|
+
FIX_CANDIDATE_SCORING = "fix_candidate_scoring"
|
|
88
|
+
AUTO_APPLIED = "auto_applied"
|
|
89
|
+
ESCALATED_TO_HUMAN = "escalated_to_human"
|
|
90
|
+
REJECTED_NO_VIABLE_FIX = "rejected_no_viable_fix"
|
|
91
|
+
VERIFIED_RESOLVED = "verified_resolved"
|
|
92
|
+
AWAITING_HUMAN_DECISION = "awaiting_human_decision"
|
|
93
|
+
FLAGGED_OPEN = "flagged_open"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def is_terminal(self) -> bool:
|
|
97
|
+
return self in (
|
|
98
|
+
IncidentState.VERIFIED_RESOLVED,
|
|
99
|
+
IncidentState.AWAITING_HUMAN_DECISION,
|
|
100
|
+
IncidentState.FLAGGED_OPEN,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class AuditEntry:
|
|
106
|
+
prior_state: IncidentState | None
|
|
107
|
+
new_state: IncidentState
|
|
108
|
+
rule_id: str | None
|
|
109
|
+
timestamp: str
|
|
110
|
+
metadata: dict = field(default_factory=dict)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Condition:
|
|
115
|
+
field: str
|
|
116
|
+
operator: str
|
|
117
|
+
value: Any
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class Action:
|
|
122
|
+
type: str
|
|
123
|
+
target: str | None = None
|
|
124
|
+
next_state: str | None = None
|
|
125
|
+
reason: str | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class DispatchRule:
|
|
130
|
+
rule_id: str
|
|
131
|
+
applies_at_state: str
|
|
132
|
+
priority: int
|
|
133
|
+
conditions: list[Condition]
|
|
134
|
+
action: Action
|
|
135
|
+
fallback_if_no_match: bool = False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class Incident:
|
|
140
|
+
id: str
|
|
141
|
+
state: IncidentState
|
|
142
|
+
control_id: str | None
|
|
143
|
+
symbol_id: str | None
|
|
144
|
+
technique_id: str | None
|
|
145
|
+
confidence: float
|
|
146
|
+
criticality: str | None = None
|
|
147
|
+
check_method: str | None = None
|
|
148
|
+
bug_class: str | None = None
|
|
149
|
+
domain_activation_state: str | None = None
|
|
150
|
+
fix_retries: int = 0
|
|
151
|
+
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
152
|
+
updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
153
|
+
audit_trail: list[AuditEntry] = field(default_factory=list)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── Pipeline phase enum ─────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PipelinePhase(StrEnum):
|
|
160
|
+
IDLE = "idle"
|
|
161
|
+
SCAN = "scan"
|
|
162
|
+
GRAPH_UPDATE = "graph_update"
|
|
163
|
+
TEST_GENERATION = "test_generation"
|
|
164
|
+
TEST_EXECUTION = "test_execution"
|
|
165
|
+
FIX_GENERATION = "fix_generation"
|
|
166
|
+
SANDBOX_REVERIFY = "sandbox_reverify"
|
|
167
|
+
SCORE_SELECT = "score_select"
|
|
168
|
+
COMPLETE = "complete"
|
|
169
|
+
FAILED = "failed"
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def order(self) -> int:
|
|
173
|
+
return _PHASE_ORDER[self]
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def next_phase(self) -> PipelinePhase | None:
|
|
177
|
+
if self == PipelinePhase.FAILED:
|
|
178
|
+
return None
|
|
179
|
+
if self == PipelinePhase.COMPLETE:
|
|
180
|
+
return None
|
|
181
|
+
phases = list(PipelinePhase)
|
|
182
|
+
idx = phases.index(self)
|
|
183
|
+
if idx + 1 < len(phases):
|
|
184
|
+
n = phases[idx + 1]
|
|
185
|
+
return n if n != PipelinePhase.FAILED else None
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def is_valid_transition(cls, current: PipelinePhase, target: PipelinePhase) -> bool:
|
|
190
|
+
if current == PipelinePhase.IDLE:
|
|
191
|
+
return target == PipelinePhase.SCAN
|
|
192
|
+
if current == PipelinePhase.COMPLETE:
|
|
193
|
+
return target == PipelinePhase.SCAN
|
|
194
|
+
return target.order == current.order + 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_PHASE_ORDER = {
|
|
198
|
+
PipelinePhase.IDLE: 0,
|
|
199
|
+
PipelinePhase.SCAN: 1,
|
|
200
|
+
PipelinePhase.GRAPH_UPDATE: 2,
|
|
201
|
+
PipelinePhase.TEST_GENERATION: 3,
|
|
202
|
+
PipelinePhase.TEST_EXECUTION: 4,
|
|
203
|
+
PipelinePhase.FIX_GENERATION: 5,
|
|
204
|
+
PipelinePhase.SANDBOX_REVERIFY: 6,
|
|
205
|
+
PipelinePhase.SCORE_SELECT: 7,
|
|
206
|
+
PipelinePhase.COMPLETE: 8,
|
|
207
|
+
PipelinePhase.FAILED: -1,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── Acceptance criteria ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class PhaseCriteria:
|
|
216
|
+
max_errors: int = 0
|
|
217
|
+
max_critical_findings: int = 0
|
|
218
|
+
max_high_findings: int = 10
|
|
219
|
+
min_agents_run: int = 1
|
|
220
|
+
require_zero_errors: bool = True
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
DEFAULT_CRITERIA: dict[PipelinePhase, PhaseCriteria] = {
|
|
224
|
+
PipelinePhase.SCAN: PhaseCriteria(
|
|
225
|
+
max_errors=5,
|
|
226
|
+
max_critical_findings=200,
|
|
227
|
+
max_high_findings=1000,
|
|
228
|
+
min_agents_run=1,
|
|
229
|
+
),
|
|
230
|
+
PipelinePhase.GRAPH_UPDATE: PhaseCriteria(
|
|
231
|
+
max_errors=1,
|
|
232
|
+
min_agents_run=0,
|
|
233
|
+
),
|
|
234
|
+
PipelinePhase.TEST_GENERATION: PhaseCriteria(
|
|
235
|
+
max_errors=3,
|
|
236
|
+
min_agents_run=0,
|
|
237
|
+
),
|
|
238
|
+
PipelinePhase.TEST_EXECUTION: PhaseCriteria(
|
|
239
|
+
max_errors=5,
|
|
240
|
+
max_critical_findings=200,
|
|
241
|
+
max_high_findings=1000,
|
|
242
|
+
min_agents_run=0,
|
|
243
|
+
),
|
|
244
|
+
PipelinePhase.FIX_GENERATION: PhaseCriteria(
|
|
245
|
+
max_errors=3,
|
|
246
|
+
max_critical_findings=50,
|
|
247
|
+
max_high_findings=200,
|
|
248
|
+
min_agents_run=1,
|
|
249
|
+
),
|
|
250
|
+
PipelinePhase.SANDBOX_REVERIFY: PhaseCriteria(
|
|
251
|
+
max_errors=3,
|
|
252
|
+
max_critical_findings=50,
|
|
253
|
+
max_high_findings=200,
|
|
254
|
+
min_agents_run=0,
|
|
255
|
+
),
|
|
256
|
+
PipelinePhase.SCORE_SELECT: PhaseCriteria(
|
|
257
|
+
max_errors=1,
|
|
258
|
+
min_agents_run=0,
|
|
259
|
+
),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class PhaseResult:
|
|
265
|
+
phase: PipelinePhase
|
|
266
|
+
status: AgentStatus
|
|
267
|
+
results: list[AgentResult] = field(default_factory=list)
|
|
268
|
+
errors: list[str] = field(default_factory=list)
|
|
269
|
+
duration_ms: int = 0
|
|
270
|
+
findings_count: int = 0
|
|
271
|
+
agents_run: int = 0
|
|
272
|
+
# Structured per-phase detail (e.g. verify-loop outcomes under data["verify"])
|
|
273
|
+
data: dict = field(default_factory=dict)
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def passed(self) -> bool:
|
|
277
|
+
return self.status == AgentStatus.DONE
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── Governor ────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class Governor:
|
|
284
|
+
"""Pipeline state machine wrapping Coordinator."""
|
|
285
|
+
|
|
286
|
+
def __init__(
|
|
287
|
+
self,
|
|
288
|
+
root: Path,
|
|
289
|
+
on_progress: Callable[[CoordinatorProgress], None] | None = None,
|
|
290
|
+
criteria: dict[PipelinePhase, PhaseCriteria] | None = None,
|
|
291
|
+
):
|
|
292
|
+
self.root = root
|
|
293
|
+
self.coordinator = Coordinator(root, on_progress=on_progress)
|
|
294
|
+
self.criteria = criteria or DEFAULT_CRITERIA.copy()
|
|
295
|
+
self._on_progress = on_progress or (lambda _: None)
|
|
296
|
+
self._db_path = root / ".patchi" / "pipeline_state.db"
|
|
297
|
+
self._conn: sqlite3.Connection | None = None
|
|
298
|
+
self._init_db()
|
|
299
|
+
|
|
300
|
+
def _init_db(self) -> None:
|
|
301
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
conn = self._get_conn()
|
|
303
|
+
conn.executescript("""
|
|
304
|
+
CREATE TABLE IF NOT EXISTS pipeline_state (
|
|
305
|
+
key TEXT PRIMARY KEY,
|
|
306
|
+
value TEXT
|
|
307
|
+
);
|
|
308
|
+
CREATE TABLE IF NOT EXISTS phase_history (
|
|
309
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
310
|
+
phase TEXT NOT NULL,
|
|
311
|
+
status TEXT NOT NULL,
|
|
312
|
+
timestamp TEXT NOT NULL,
|
|
313
|
+
duration_ms INTEGER DEFAULT 0,
|
|
314
|
+
findings_count INTEGER DEFAULT 0,
|
|
315
|
+
agents_run INTEGER DEFAULT 0,
|
|
316
|
+
errors TEXT DEFAULT ''
|
|
317
|
+
);
|
|
318
|
+
INSERT OR IGNORE INTO pipeline_state (key, value)
|
|
319
|
+
VALUES ('current_phase', 'idle');
|
|
320
|
+
""")
|
|
321
|
+
|
|
322
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
323
|
+
if self._conn is None:
|
|
324
|
+
self._conn = sqlite3.connect(str(self._db_path))
|
|
325
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
326
|
+
return self._conn
|
|
327
|
+
|
|
328
|
+
def close(self) -> None:
|
|
329
|
+
"""Release all SQLite resources so the db file can be deleted (Windows)."""
|
|
330
|
+
db_path = self._db_path
|
|
331
|
+
if self._conn is not None:
|
|
332
|
+
try:
|
|
333
|
+
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
334
|
+
self._conn.execute("PRAGMA journal_mode=DELETE")
|
|
335
|
+
except Exception as e:
|
|
336
|
+
_log.warning("Governor.close checkpoint failed: %s", e)
|
|
337
|
+
try:
|
|
338
|
+
self._conn.close()
|
|
339
|
+
except Exception as _exc:
|
|
340
|
+
_log.warning('close failed: %s', _exc)
|
|
341
|
+
self._conn = None
|
|
342
|
+
for suffix in (".db-wal", ".db-shm"):
|
|
343
|
+
try:
|
|
344
|
+
Path(f"{db_path}{suffix}").unlink(missing_ok=True)
|
|
345
|
+
except OSError:
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
# ── Phase management ───────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def current_phase(self) -> PipelinePhase:
|
|
352
|
+
conn = self._get_conn()
|
|
353
|
+
cur = conn.execute("SELECT value FROM pipeline_state WHERE key = 'current_phase'")
|
|
354
|
+
row = cur.fetchone()
|
|
355
|
+
return PipelinePhase(row[0]) if row else PipelinePhase.IDLE
|
|
356
|
+
|
|
357
|
+
@current_phase.setter
|
|
358
|
+
def current_phase(self, phase: PipelinePhase) -> None:
|
|
359
|
+
conn = self._get_conn()
|
|
360
|
+
conn.execute(
|
|
361
|
+
"INSERT OR REPLACE INTO pipeline_state (key, value) VALUES ('current_phase', ?)",
|
|
362
|
+
(phase.value,),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def get_history(self, limit: int = 20) -> list[dict]:
|
|
366
|
+
conn = self._get_conn()
|
|
367
|
+
cur = conn.execute("SELECT * FROM phase_history ORDER BY id DESC LIMIT ?", (limit,))
|
|
368
|
+
rows = [
|
|
369
|
+
{
|
|
370
|
+
"id": r[0],
|
|
371
|
+
"phase": r[1],
|
|
372
|
+
"status": r[2],
|
|
373
|
+
"timestamp": r[3],
|
|
374
|
+
"duration_ms": r[4],
|
|
375
|
+
"findings_count": r[5],
|
|
376
|
+
"agents_run": r[6],
|
|
377
|
+
"errors": r[7].split(";") if r[7] else [],
|
|
378
|
+
}
|
|
379
|
+
for r in cur.fetchall()
|
|
380
|
+
]
|
|
381
|
+
return rows
|
|
382
|
+
|
|
383
|
+
def _record_phase(self, result: PhaseResult) -> None:
|
|
384
|
+
conn = self._get_conn()
|
|
385
|
+
conn.execute(
|
|
386
|
+
"""INSERT INTO phase_history (phase, status, timestamp, duration_ms, findings_count, agents_run, errors)
|
|
387
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
388
|
+
(
|
|
389
|
+
result.phase.value,
|
|
390
|
+
result.status.value,
|
|
391
|
+
datetime.now(UTC).isoformat(),
|
|
392
|
+
result.duration_ms,
|
|
393
|
+
result.findings_count,
|
|
394
|
+
result.agents_run,
|
|
395
|
+
";".join(result.errors[:5]),
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _check_criteria(self, phase: PipelinePhase, results: list[AgentResult]) -> list[str]:
|
|
400
|
+
"""Check acceptance criteria for a phase. Returns list of violations."""
|
|
401
|
+
violations: list[str] = []
|
|
402
|
+
crit = self.criteria.get(phase, DEFAULT_CRITERIA[PipelinePhase.SCAN])
|
|
403
|
+
|
|
404
|
+
errors = [r for r in results if r.status == AgentStatus.FAILED]
|
|
405
|
+
if len(errors) > crit.max_errors:
|
|
406
|
+
violations.append(f"Too many agent errors: {len(errors)} > {crit.max_errors}")
|
|
407
|
+
|
|
408
|
+
if crit.require_zero_errors and errors:
|
|
409
|
+
violations.append(f"Agents failed: {', '.join(r.agent_name for r in errors)}")
|
|
410
|
+
|
|
411
|
+
critical = sum(1 for r in results for f in r.findings if f.severity.name == "CRITICAL")
|
|
412
|
+
if critical > crit.max_critical_findings:
|
|
413
|
+
violations.append(
|
|
414
|
+
f"Too many CRITICAL findings: {critical} > {crit.max_critical_findings}"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
high = sum(1 for r in results for f in r.findings if f.severity.name == "HIGH")
|
|
418
|
+
if high > crit.max_high_findings:
|
|
419
|
+
violations.append(f"Too many HIGH findings: {high} > {crit.max_high_findings}")
|
|
420
|
+
|
|
421
|
+
if len(results) < crit.min_agents_run:
|
|
422
|
+
violations.append(f"Not enough agents ran: {len(results)} < {crit.min_agents_run}")
|
|
423
|
+
|
|
424
|
+
return violations
|
|
425
|
+
|
|
426
|
+
def _transition_to(self, target: PipelinePhase, results: list[AgentResult]) -> PhaseResult:
|
|
427
|
+
"""Attempt to transition to the target phase. Checks criteria."""
|
|
428
|
+
current = self.current_phase
|
|
429
|
+
if current == PipelinePhase.FAILED:
|
|
430
|
+
logger.warning("Pipeline is in FAILED state. Reset with reset_pipeline() to continue.")
|
|
431
|
+
|
|
432
|
+
if not PipelinePhase.is_valid_transition(current, target):
|
|
433
|
+
logger.warning(
|
|
434
|
+
f"Invalid phase transition: {current.value} → {target.value}. "
|
|
435
|
+
f"Skipping ordering check."
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
violations = self._check_criteria(target, results)
|
|
439
|
+
duration_ms = sum(r.duration_ms for r in results)
|
|
440
|
+
findings_count = sum(r.finding_count for r in results)
|
|
441
|
+
|
|
442
|
+
if violations:
|
|
443
|
+
phase_result = PhaseResult(
|
|
444
|
+
phase=target,
|
|
445
|
+
status=AgentStatus.FAILED,
|
|
446
|
+
results=results,
|
|
447
|
+
errors=violations,
|
|
448
|
+
duration_ms=duration_ms,
|
|
449
|
+
findings_count=findings_count,
|
|
450
|
+
agents_run=len(results),
|
|
451
|
+
)
|
|
452
|
+
self.current_phase = PipelinePhase.FAILED
|
|
453
|
+
self._record_phase(phase_result)
|
|
454
|
+
return phase_result
|
|
455
|
+
|
|
456
|
+
phase_result = PhaseResult(
|
|
457
|
+
phase=target,
|
|
458
|
+
status=AgentStatus.DONE,
|
|
459
|
+
results=results,
|
|
460
|
+
duration_ms=duration_ms,
|
|
461
|
+
findings_count=findings_count,
|
|
462
|
+
agents_run=len(results),
|
|
463
|
+
)
|
|
464
|
+
self.current_phase = target
|
|
465
|
+
self._record_phase(phase_result)
|
|
466
|
+
return phase_result
|
|
467
|
+
|
|
468
|
+
# ── Graph neighborhood context (files-5 Testing Strategy §4) ──────────
|
|
469
|
+
|
|
470
|
+
def _build_graph_neighborhood(self, symbol_names: list[str], radius: int = 1) -> list[dict]:
|
|
471
|
+
"""Build scoped context for a set of symbols.
|
|
472
|
+
|
|
473
|
+
Returns list of symbol summaries with immediate callers/callees.
|
|
474
|
+
This is the graph-scoped context that replaces whole-file context
|
|
475
|
+
for AI test generation and fix candidate generation.
|
|
476
|
+
"""
|
|
477
|
+
neighborhood: list[dict] = []
|
|
478
|
+
try:
|
|
479
|
+
from patchi.core.brain.symbol_graph import SymbolGraph
|
|
480
|
+
|
|
481
|
+
with SymbolGraph(self.root) as sym_graph:
|
|
482
|
+
sym_graph.ensure_built()
|
|
483
|
+
for sym_name in symbol_names:
|
|
484
|
+
sym = sym_graph.get_symbol(sym_name)
|
|
485
|
+
if not sym:
|
|
486
|
+
continue
|
|
487
|
+
entry = {
|
|
488
|
+
"name": sym.name,
|
|
489
|
+
"kind": sym.kind,
|
|
490
|
+
"file": sym.file,
|
|
491
|
+
"line": sym.line,
|
|
492
|
+
"is_exported": sym.is_exported,
|
|
493
|
+
"params": sym.params,
|
|
494
|
+
"docstring": sym.docstring[:120] if sym.docstring else "",
|
|
495
|
+
"callers": [],
|
|
496
|
+
"callees": [],
|
|
497
|
+
}
|
|
498
|
+
if radius >= 1:
|
|
499
|
+
for dep in sym_graph.get_dependents(sym.name, sym.file):
|
|
500
|
+
entry["callers"].append(
|
|
501
|
+
{"name": dep.name, "file": dep.file, "kind": dep.kind}
|
|
502
|
+
)
|
|
503
|
+
for dep in sym_graph.get_dependencies(sym.id):
|
|
504
|
+
entry["callees"].append(
|
|
505
|
+
{"name": dep.name, "file": dep.file, "kind": dep.kind}
|
|
506
|
+
)
|
|
507
|
+
neighborhood.append(entry)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.warning(f"build_graph_neighborhood error: {e}")
|
|
510
|
+
return neighborhood
|
|
511
|
+
|
|
512
|
+
# ── Pipeline execution ────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
def run_scan(self, scope: list[str] | None = None, side: bool = True) -> PhaseResult:
|
|
515
|
+
"""Phase 1: Run scanner agents."""
|
|
516
|
+
logger.info(f"Pipeline phase: SCAN (scope={len(scope or [])} files, side={side})")
|
|
517
|
+
results = self.coordinator.run_all_scanners(scope=scope, side=side)
|
|
518
|
+
return self._transition_to(PipelinePhase.SCAN, results)
|
|
519
|
+
|
|
520
|
+
def run_scan_deep(self, scope: list[str] | None = None) -> PhaseResult:
|
|
521
|
+
"""Phase 1b: Deep scan with AI analysis."""
|
|
522
|
+
logger.info(f"Pipeline phase: SCAN --deep (scope={len(scope or [])} files)")
|
|
523
|
+
results = self.coordinator.run_group(AgentGroup.SCANNER, scope=scope)
|
|
524
|
+
# Deep analysis via coordinator's _build_llm
|
|
525
|
+
llm = self.coordinator._build_llm()
|
|
526
|
+
if llm:
|
|
527
|
+
try:
|
|
528
|
+
from patchi.core.ai.client import call_ai
|
|
529
|
+
|
|
530
|
+
for r in results:
|
|
531
|
+
if r.findings:
|
|
532
|
+
prompt = (
|
|
533
|
+
f"Analyze these findings for file context. "
|
|
534
|
+
f"Findings: {[f.to_dict() for f in r.findings[:5]]}"
|
|
535
|
+
)
|
|
536
|
+
analysis = call_ai(
|
|
537
|
+
self.coordinator._config,
|
|
538
|
+
"You are a security analysis assistant.",
|
|
539
|
+
prompt,
|
|
540
|
+
max_tokens=300,
|
|
541
|
+
)
|
|
542
|
+
if analysis:
|
|
543
|
+
r.data["deep_analysis"] = analysis
|
|
544
|
+
except Exception as e:
|
|
545
|
+
_log.warning("Governor.run_scan_deep failed: %s", e)
|
|
546
|
+
return self._transition_to(PipelinePhase.SCAN, results)
|
|
547
|
+
|
|
548
|
+
def run_test(self, scope: list[str] | None = None) -> PhaseResult:
|
|
549
|
+
"""Phase 2: Run test agents."""
|
|
550
|
+
logger.info(f"Pipeline phase: TEST (scope={len(scope or [])} files)")
|
|
551
|
+
results = self.coordinator.run_group(AgentGroup.TEST, scope=scope)
|
|
552
|
+
return self._transition_to(PipelinePhase.TEST_EXECUTION, results)
|
|
553
|
+
|
|
554
|
+
def run_test_security(self) -> PhaseResult:
|
|
555
|
+
"""Phase 2b: Security-specific tests."""
|
|
556
|
+
logger.info("Pipeline phase: TEST --security")
|
|
557
|
+
from patchi.core.agents.base import get_agent
|
|
558
|
+
|
|
559
|
+
agent = get_agent("SecurityTestAgent")
|
|
560
|
+
if agent:
|
|
561
|
+
results = self.coordinator.run_agents(["SecurityTestAgent"])
|
|
562
|
+
else:
|
|
563
|
+
results = []
|
|
564
|
+
return self._transition_to(PipelinePhase.TEST_EXECUTION, results)
|
|
565
|
+
|
|
566
|
+
def run_security(self, scope: list[str] | None = None) -> PhaseResult:
|
|
567
|
+
"""Run security agents (part of scan phase or standalone)."""
|
|
568
|
+
logger.info(f"Pipeline phase: SCAN --security (scope={len(scope or [])} files)")
|
|
569
|
+
results = self.coordinator.run_group(AgentGroup.SECURITY, scope=scope)
|
|
570
|
+
return PhaseResult(
|
|
571
|
+
phase=PipelinePhase.SCAN,
|
|
572
|
+
status=AgentStatus.DONE,
|
|
573
|
+
results=results,
|
|
574
|
+
duration_ms=sum(r.duration_ms for r in results),
|
|
575
|
+
findings_count=sum(r.finding_count for r in results),
|
|
576
|
+
agents_run=len(results),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
def run_fix(self, dry_run: bool = False) -> PhaseResult:
|
|
580
|
+
"""Phase: Run fix agents, then route every produced patch through the
|
|
581
|
+
fix → verify → retry loop (verify_loop).
|
|
582
|
+
|
|
583
|
+
Reuses the exact wiring ``p fix`` has: the applier is injected, each
|
|
584
|
+
patch is applied and its SPECIFIC failing test re-run (retries up to
|
|
585
|
+
2 with fresh failure feedback), and test-weakening patches (only test
|
|
586
|
+
files changed) are flagged requires_review — never auto-applied.
|
|
587
|
+
|
|
588
|
+
NOTE: this phase now MUTATES the working tree (applies patches) —
|
|
589
|
+
previously FIX was candidate-generation only and nothing applied.
|
|
590
|
+
``dry_run=True`` is the only non-mutating path.
|
|
591
|
+
|
|
592
|
+
Outcomes land in ``result.data["verify"]`` as lists of patch ids:
|
|
593
|
+
verified / applied_unverified / review_required / rolled_back
|
|
594
|
+
(``skipped`` holds error strings, not patch ids).
|
|
595
|
+
"""
|
|
596
|
+
logger.info(f"Pipeline phase: FIX-GENERATION (dry_run={dry_run})")
|
|
597
|
+
results = self.coordinator.run_group(AgentGroup.FIX, extra={"dry_run": dry_run})
|
|
598
|
+
verify = self._verify_fix_patches(results, dry_run=dry_run)
|
|
599
|
+
result = self._transition_to(PipelinePhase.FIX_GENERATION, results)
|
|
600
|
+
result.data["verify"] = verify
|
|
601
|
+
return result
|
|
602
|
+
|
|
603
|
+
def _verify_fix_patches(self, results: list[AgentResult], dry_run: bool = False) -> dict:
|
|
604
|
+
"""Route produced fix patches through verify_loop (fix → verify → retry).
|
|
605
|
+
|
|
606
|
+
Mirrors ``fix_cmd``: test-weakening guard runs first (a fix that only
|
|
607
|
+
edits test files goes to human review, never auto-apply), then each
|
|
608
|
+
patch is applied via ``run_verify_loop`` with a real ``PatchApplier``
|
|
609
|
+
injected, which re-runs the exact failing test and retries up to 2
|
|
610
|
+
times with fresh failure output. Returns a per-patch summary:
|
|
611
|
+
|
|
612
|
+
{"verified": [ids], "applied_unverified": [ids],
|
|
613
|
+
"review_required": [ids], "rolled_back": [ids], "skipped": [ids]}
|
|
614
|
+
|
|
615
|
+
Also records ``self._last_applied_patches`` — (Patch, failing test
|
|
616
|
+
file) pairs — so the REVERIFY phase can re-run those exact tests.
|
|
617
|
+
"""
|
|
618
|
+
from patchi.core.fix.applier import PatchApplier
|
|
619
|
+
from patchi.core.fix.patch import Patch
|
|
620
|
+
from patchi.core.fix.verify_loop import run_verify_loop, should_flag_for_review
|
|
621
|
+
|
|
622
|
+
summary: dict = {
|
|
623
|
+
"verified": [],
|
|
624
|
+
"applied_unverified": [],
|
|
625
|
+
"review_required": [],
|
|
626
|
+
"rolled_back": [],
|
|
627
|
+
"skipped": [],
|
|
628
|
+
}
|
|
629
|
+
self._last_applied_patches: list[tuple[Patch, str]] = []
|
|
630
|
+
if dry_run:
|
|
631
|
+
return summary
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
applier = PatchApplier(self.root)
|
|
635
|
+
except Exception as e:
|
|
636
|
+
logger.warning(f"FIX verify: PatchApplier unavailable, skipping apply: {e}")
|
|
637
|
+
summary["skipped"].append(f"PatchApplier: {e}")
|
|
638
|
+
return summary
|
|
639
|
+
config = self.config or {}
|
|
640
|
+
brain = self.brain or {}
|
|
641
|
+
|
|
642
|
+
for r in results:
|
|
643
|
+
for patch_dict in r.data.get("patches", []):
|
|
644
|
+
try:
|
|
645
|
+
patch = Patch.from_dict(patch_dict)
|
|
646
|
+
except Exception as e:
|
|
647
|
+
summary["skipped"].append(str(e))
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
# Test-weakening guard — flag BEFORE the loop so AUTOPILOT can't
|
|
651
|
+
# slip a test-only fix past the apply path.
|
|
652
|
+
if should_flag_for_review(patch):
|
|
653
|
+
patch.requires_review = True
|
|
654
|
+
summary["review_required"].append(patch.id)
|
|
655
|
+
self._last_applied_patches.append((patch, ""))
|
|
656
|
+
continue
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
outcome = run_verify_loop(
|
|
660
|
+
patch,
|
|
661
|
+
root=self.root,
|
|
662
|
+
config=config,
|
|
663
|
+
brain=brain,
|
|
664
|
+
applier=applier,
|
|
665
|
+
)
|
|
666
|
+
except Exception as e:
|
|
667
|
+
summary["rolled_back"].append(patch.id)
|
|
668
|
+
self._last_applied_patches.append((patch, ""))
|
|
669
|
+
logger.warning(f"verify loop failed for {patch.id}: {e}")
|
|
670
|
+
continue
|
|
671
|
+
|
|
672
|
+
done = outcome.patch or patch
|
|
673
|
+
test_file = (done.source_finding or {}).get("file", "")
|
|
674
|
+
if outcome.review_required:
|
|
675
|
+
summary["review_required"].append(done.id)
|
|
676
|
+
elif outcome.applied and outcome.verified:
|
|
677
|
+
summary["verified"].append(done.id)
|
|
678
|
+
elif outcome.applied:
|
|
679
|
+
summary["applied_unverified"].append(done.id)
|
|
680
|
+
else:
|
|
681
|
+
summary["rolled_back"].append(done.id)
|
|
682
|
+
self._last_applied_patches.append((done, test_file))
|
|
683
|
+
|
|
684
|
+
logger.info(
|
|
685
|
+
f"FIX verify: {len(summary['verified'])} verified, "
|
|
686
|
+
f"{len(summary['applied_unverified'])} applied-unverified, "
|
|
687
|
+
f"{len(summary['review_required'])} review-required, "
|
|
688
|
+
f"{len(summary['rolled_back'])} rolled back"
|
|
689
|
+
)
|
|
690
|
+
return summary
|
|
691
|
+
|
|
692
|
+
def _recheck_applied_patches(self) -> dict:
|
|
693
|
+
"""REVERIFY: re-run the SPECIFIC failing test of each patch that the
|
|
694
|
+
FIX phase applied/verified — not the whole test group.
|
|
695
|
+
|
|
696
|
+
Returns {"rechecked": n, "passed": [ids], "regressed": [ids],
|
|
697
|
+
"unrunnable": [ids]} where regressed means a previously-verified test
|
|
698
|
+
now fails after later phases touched the tree.
|
|
699
|
+
"""
|
|
700
|
+
from patchi.core.fix.verify_loop import recheck_test_file
|
|
701
|
+
|
|
702
|
+
report: dict = {"rechecked": 0, "passed": [], "regressed": [], "unrunnable": []}
|
|
703
|
+
for patch, test_file in getattr(self, "_last_applied_patches", []):
|
|
704
|
+
if not test_file:
|
|
705
|
+
continue
|
|
706
|
+
check = recheck_test_file(self.root, test_file)
|
|
707
|
+
report["rechecked"] += 1
|
|
708
|
+
if check.get("passed") is True:
|
|
709
|
+
report["passed"].append(patch.id)
|
|
710
|
+
elif check.get("passed") is False:
|
|
711
|
+
report["regressed"].append(patch.id)
|
|
712
|
+
else:
|
|
713
|
+
report["unrunnable"].append(patch.id)
|
|
714
|
+
return report
|
|
715
|
+
|
|
716
|
+
def run_fix_security(self) -> PhaseResult:
|
|
717
|
+
"""Phase: Security-specific fixes."""
|
|
718
|
+
logger.info("Pipeline phase: FIX-GENERATION --security")
|
|
719
|
+
from patchi.core.agents.base import get_agent
|
|
720
|
+
|
|
721
|
+
agent = get_agent("SecurityFixer")
|
|
722
|
+
if agent:
|
|
723
|
+
results = self.coordinator.run_agents(["SecurityFixer"])
|
|
724
|
+
else:
|
|
725
|
+
results = []
|
|
726
|
+
return self._transition_to(PipelinePhase.FIX_GENERATION, results)
|
|
727
|
+
|
|
728
|
+
def run_reverify(self) -> PhaseResult:
|
|
729
|
+
"""Phase 4: Re-verify by re-running scan agents and checking fixes.
|
|
730
|
+
|
|
731
|
+
On top of the loop-back scan, re-runs the SPECIFIC failing tests of
|
|
732
|
+
patches applied in the FIX phase (via verify_loop.recheck_test_file) so
|
|
733
|
+
a regression in an applied fix fails the phase, not just "some findings
|
|
734
|
+
remain". Recheck results land in ``result.data["reverify"]``.
|
|
735
|
+
"""
|
|
736
|
+
logger.info("Pipeline phase: REVERIFY")
|
|
737
|
+
scan_results = self.coordinator.run_all_scanners()
|
|
738
|
+
security_results = self.coordinator.run_group(AgentGroup.SECURITY)
|
|
739
|
+
|
|
740
|
+
all_results = scan_results + security_results
|
|
741
|
+
total_findings = sum(r.finding_count for r in all_results)
|
|
742
|
+
reverify = self._recheck_applied_patches()
|
|
743
|
+
|
|
744
|
+
crit = self.criteria.get(
|
|
745
|
+
PipelinePhase.SANDBOX_REVERIFY, DEFAULT_CRITERIA[PipelinePhase.SANDBOX_REVERIFY]
|
|
746
|
+
)
|
|
747
|
+
violations: list[str] = []
|
|
748
|
+
if total_findings > crit.max_critical_findings:
|
|
749
|
+
violations.append(f"Re-verify failed: {total_findings} findings remain")
|
|
750
|
+
if reverify["regressed"]:
|
|
751
|
+
violations.append(
|
|
752
|
+
f"Re-verify failed: {len(reverify['regressed'])} previously-verified "
|
|
753
|
+
f"test(s) regressed: {', '.join(reverify['regressed'])}"
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
if violations:
|
|
757
|
+
phase_result = PhaseResult(
|
|
758
|
+
phase=PipelinePhase.SANDBOX_REVERIFY,
|
|
759
|
+
status=AgentStatus.FAILED,
|
|
760
|
+
results=all_results,
|
|
761
|
+
errors=violations,
|
|
762
|
+
duration_ms=sum(r.duration_ms for r in all_results),
|
|
763
|
+
findings_count=total_findings,
|
|
764
|
+
agents_run=len(all_results),
|
|
765
|
+
data={"reverify": reverify},
|
|
766
|
+
)
|
|
767
|
+
self._record_phase(phase_result)
|
|
768
|
+
return phase_result
|
|
769
|
+
|
|
770
|
+
phase_result = PhaseResult(
|
|
771
|
+
phase=PipelinePhase.SANDBOX_REVERIFY,
|
|
772
|
+
status=AgentStatus.DONE,
|
|
773
|
+
results=all_results,
|
|
774
|
+
duration_ms=sum(r.duration_ms for r in all_results),
|
|
775
|
+
findings_count=total_findings,
|
|
776
|
+
agents_run=len(all_results),
|
|
777
|
+
data={"reverify": reverify},
|
|
778
|
+
)
|
|
779
|
+
self._record_phase(phase_result)
|
|
780
|
+
self.current_phase = PipelinePhase.COMPLETE
|
|
781
|
+
return phase_result
|
|
782
|
+
|
|
783
|
+
# ── New v2 phases ──────────────────────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
def run_graph_update(self) -> PhaseResult:
|
|
786
|
+
"""Phase 2: Build or incrementally update the SymbolGraph.
|
|
787
|
+
|
|
788
|
+
The full graph is built HERE — during the SCAN/GRAPH_UPDATE phase — so
|
|
789
|
+
later FIX/TEST phases never pay a full tree-sitter parse latency spike
|
|
790
|
+
through the lazy ensure_built() query path.
|
|
791
|
+
|
|
792
|
+
Uses the graph_diff from BrainReport to identify changed files and
|
|
793
|
+
apply SymbolGraph.patch() incrementally; when no diff exists (fresh
|
|
794
|
+
project, no prior scan), it falls back to a full build_from_root().
|
|
795
|
+
"""
|
|
796
|
+
start = time.monotonic()
|
|
797
|
+
logger.info("Pipeline phase: GRAPH_UPDATE")
|
|
798
|
+
errors: list[str] = []
|
|
799
|
+
changed_paths: list[Path] = []
|
|
800
|
+
built = False
|
|
801
|
+
|
|
802
|
+
try:
|
|
803
|
+
brain_mem = mem.get_brain(self.root)
|
|
804
|
+
graph_diff = brain_mem.get("graph_diff", {})
|
|
805
|
+
|
|
806
|
+
for f in graph_diff.get("new_files", []):
|
|
807
|
+
p = self.root / f
|
|
808
|
+
if p.exists():
|
|
809
|
+
changed_paths.append(p)
|
|
810
|
+
for f in graph_diff.get("removed_files", []):
|
|
811
|
+
p = self.root / f
|
|
812
|
+
if p.exists():
|
|
813
|
+
changed_paths.append(p)
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
from patchi.core.brain.symbol_graph import SymbolGraph
|
|
817
|
+
|
|
818
|
+
with SymbolGraph(self.root) as sym_graph:
|
|
819
|
+
if changed_paths:
|
|
820
|
+
# Build a base graph if the DB is empty, then apply
|
|
821
|
+
# the incremental patch from the scan diff.
|
|
822
|
+
sym_graph.ensure_built()
|
|
823
|
+
diff = sym_graph.patch(changed_paths)
|
|
824
|
+
built = True
|
|
825
|
+
logger.info(f"Graph update: patched {diff.summary()}")
|
|
826
|
+
else:
|
|
827
|
+
# No diff — ensure the full graph exists now so
|
|
828
|
+
# FIX/TEST phases never trigger the parse spike later.
|
|
829
|
+
# ensure_built() does a full build_from_root when the
|
|
830
|
+
# DB is empty (fresh project) and is a cheap no-op on
|
|
831
|
+
# re-runs where the graph is already populated.
|
|
832
|
+
count = sym_graph.ensure_built()
|
|
833
|
+
built = True
|
|
834
|
+
logger.info(f"Graph update: graph ensured ({count} symbols)")
|
|
835
|
+
except Exception as e:
|
|
836
|
+
errors.append(f"SymbolGraph build/patch failed: {e}")
|
|
837
|
+
except Exception as e:
|
|
838
|
+
errors.append(f"Graph update failed: {e}")
|
|
839
|
+
|
|
840
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
841
|
+
result = PhaseResult(
|
|
842
|
+
phase=PipelinePhase.GRAPH_UPDATE,
|
|
843
|
+
status=AgentStatus.DONE if not errors else AgentStatus.FAILED,
|
|
844
|
+
errors=errors,
|
|
845
|
+
duration_ms=duration_ms,
|
|
846
|
+
agents_run=1 if built else 0,
|
|
847
|
+
)
|
|
848
|
+
self._record_phase(result)
|
|
849
|
+
self.current_phase = PipelinePhase.GRAPH_UPDATE
|
|
850
|
+
return result
|
|
851
|
+
|
|
852
|
+
def _affected_symbols_from_scan(self) -> list[str]:
|
|
853
|
+
"""Extract affected symbol names from the last scan's graph diff."""
|
|
854
|
+
symbols: list[str] = []
|
|
855
|
+
try:
|
|
856
|
+
brain_mem = mem.get_brain(self.root)
|
|
857
|
+
graph_diff = brain_mem.get("graph_diff", {})
|
|
858
|
+
|
|
859
|
+
for f in graph_diff.get("new_files", []):
|
|
860
|
+
try:
|
|
861
|
+
from patchi.core.brain.symbol_graph import SymbolGraph
|
|
862
|
+
|
|
863
|
+
with SymbolGraph(self.root) as sym_graph:
|
|
864
|
+
sym_graph.ensure_built()
|
|
865
|
+
for sym in sym_graph.get_symbols_in_file(f):
|
|
866
|
+
symbols.append(sym.name)
|
|
867
|
+
except Exception as e:
|
|
868
|
+
logger.debug(f"SymbolGraph error for {f}: {e}")
|
|
869
|
+
except Exception as e:
|
|
870
|
+
logger.debug(f"get_symbols_in_file error: {e}")
|
|
871
|
+
return symbols
|
|
872
|
+
|
|
873
|
+
def run_test_generation(self) -> PhaseResult:
|
|
874
|
+
"""Phase 3: Graph-scoped test generation.
|
|
875
|
+
|
|
876
|
+
Uses symbol neighborhood context instead of whole-file context.
|
|
877
|
+
This directly addresses the 11-vs-1 hallucination reduction finding
|
|
878
|
+
from Testing Strategy §4.
|
|
879
|
+
"""
|
|
880
|
+
start = time.monotonic()
|
|
881
|
+
logger.info("Pipeline phase: TEST_GENERATION")
|
|
882
|
+
errors: list[str] = []
|
|
883
|
+
|
|
884
|
+
affected_symbols = self._affected_symbols_from_scan()
|
|
885
|
+
if not affected_symbols:
|
|
886
|
+
logger.info("Test generation: no affected symbols, skipping")
|
|
887
|
+
result = PhaseResult(
|
|
888
|
+
phase=PipelinePhase.TEST_GENERATION,
|
|
889
|
+
status=AgentStatus.DONE,
|
|
890
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
891
|
+
)
|
|
892
|
+
self._record_phase(result)
|
|
893
|
+
return result
|
|
894
|
+
|
|
895
|
+
# Build graph-scoped context
|
|
896
|
+
neighborhood = self._build_graph_neighborhood(affected_symbols, radius=1)
|
|
897
|
+
if not neighborhood:
|
|
898
|
+
logger.info("Test generation: no graph context, skipping")
|
|
899
|
+
result = PhaseResult(
|
|
900
|
+
phase=PipelinePhase.TEST_GENERATION,
|
|
901
|
+
status=AgentStatus.DONE,
|
|
902
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
903
|
+
)
|
|
904
|
+
self._record_phase(result)
|
|
905
|
+
return result
|
|
906
|
+
|
|
907
|
+
# Generate tests scoped to affected symbols
|
|
908
|
+
try:
|
|
909
|
+
from patchi.core.agents.base import get_agent
|
|
910
|
+
|
|
911
|
+
agent = get_agent("SecurityTestAgent")
|
|
912
|
+
if agent:
|
|
913
|
+
extra = {
|
|
914
|
+
"graph_neighborhood": neighborhood,
|
|
915
|
+
"affected_symbols": affected_symbols,
|
|
916
|
+
"generation_mode": "scoped",
|
|
917
|
+
}
|
|
918
|
+
agent_results = self.coordinator.run_agents(["SecurityTestAgent"], extra=extra)
|
|
919
|
+
else:
|
|
920
|
+
# Fallback: use test agent if available
|
|
921
|
+
agent_results = self.coordinator.run_group(
|
|
922
|
+
AgentGroup.TEST,
|
|
923
|
+
extra={"graph_neighborhood": neighborhood, "generation_mode": "scoped"},
|
|
924
|
+
)
|
|
925
|
+
except Exception as e:
|
|
926
|
+
errors.append(f"Test generation failed: {e}")
|
|
927
|
+
agent_results = []
|
|
928
|
+
|
|
929
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
930
|
+
findings_count = sum(r.finding_count for r in agent_results)
|
|
931
|
+
|
|
932
|
+
result = PhaseResult(
|
|
933
|
+
phase=PipelinePhase.TEST_GENERATION,
|
|
934
|
+
status=AgentStatus.DONE if not errors else AgentStatus.FAILED,
|
|
935
|
+
results=agent_results,
|
|
936
|
+
errors=errors,
|
|
937
|
+
duration_ms=duration_ms,
|
|
938
|
+
findings_count=findings_count,
|
|
939
|
+
agents_run=len(agent_results),
|
|
940
|
+
)
|
|
941
|
+
self._record_phase(result)
|
|
942
|
+
self.current_phase = PipelinePhase.TEST_GENERATION
|
|
943
|
+
return result
|
|
944
|
+
|
|
945
|
+
def run_fix_generation(self, dry_run: bool = False) -> PhaseResult:
|
|
946
|
+
"""Phase 5: Fix candidate generation with multiple candidates.
|
|
947
|
+
|
|
948
|
+
Deterministic autofix first (ESLint --fix, Semgrep autofix, etc.).
|
|
949
|
+
LLM candidates for residual cases. Multiple candidates generated
|
|
950
|
+
and scored against the composite (tests pass, blast-radius delta,
|
|
951
|
+
no new scan findings).
|
|
952
|
+
"""
|
|
953
|
+
start = time.monotonic()
|
|
954
|
+
logger.info(f"Pipeline phase: FIX_GENERATION (dry_run={dry_run})")
|
|
955
|
+
|
|
956
|
+
# Build graph-scoped context for fix agents
|
|
957
|
+
affected_symbols = self._affected_symbols_from_scan()
|
|
958
|
+
extra: dict = {"dry_run": dry_run}
|
|
959
|
+
if affected_symbols:
|
|
960
|
+
neighborhood = self._build_graph_neighborhood(affected_symbols, radius=1)
|
|
961
|
+
extra["graph_neighborhood"] = neighborhood
|
|
962
|
+
extra["affected_symbols"] = affected_symbols
|
|
963
|
+
|
|
964
|
+
# Run fix agents with graph-scoped context
|
|
965
|
+
results = self.coordinator.run_group(AgentGroup.FIX, extra=extra)
|
|
966
|
+
|
|
967
|
+
# fix → verify → retry loop over every produced patch (never weaken
|
|
968
|
+
# tests): apply with the applier injected, re-run the specific failing
|
|
969
|
+
# test, retry up to 2 with fresh failure feedback, and flag test-only
|
|
970
|
+
# patches for human review. Outcomes land in result.data["verify"].
|
|
971
|
+
verify = self._verify_fix_patches(results, dry_run=dry_run)
|
|
972
|
+
|
|
973
|
+
# Score candidates (store for SCORE_SELECT phase)
|
|
974
|
+
scored_candidates = []
|
|
975
|
+
for r in results:
|
|
976
|
+
candidate = {
|
|
977
|
+
"agent_name": r.agent_name,
|
|
978
|
+
"status": r.status.value,
|
|
979
|
+
"findings": r.finding_count,
|
|
980
|
+
"duration_ms": r.duration_ms,
|
|
981
|
+
"score": self._score_fix_candidate(r),
|
|
982
|
+
}
|
|
983
|
+
scored_candidates.append(candidate)
|
|
984
|
+
self._last_candidates = scored_candidates
|
|
985
|
+
self._last_fix_results = results
|
|
986
|
+
|
|
987
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
988
|
+
result = self._transition_to(PipelinePhase.FIX_GENERATION, results)
|
|
989
|
+
result.data["verify"] = verify
|
|
990
|
+
result.duration_ms = duration_ms
|
|
991
|
+
return result
|
|
992
|
+
|
|
993
|
+
def _score_fix_candidate(self, result: AgentResult) -> float:
|
|
994
|
+
"""Score a fix candidate on 0-1 scale.
|
|
995
|
+
|
|
996
|
+
Factors:
|
|
997
|
+
- Agent completed without errors (0.4)
|
|
998
|
+
- Finding count (lower = better, 0.3)
|
|
999
|
+
- Has fix data (0.2)
|
|
1000
|
+
- Fast runtime (0.1)
|
|
1001
|
+
"""
|
|
1002
|
+
score = 0.0
|
|
1003
|
+
if result.status == AgentStatus.DONE:
|
|
1004
|
+
score += 0.4
|
|
1005
|
+
score += max(0, 0.3 - (result.finding_count * 0.02))
|
|
1006
|
+
if result.data:
|
|
1007
|
+
score += 0.2
|
|
1008
|
+
score += max(0, 0.1 - (result.duration_ms * 0.00001))
|
|
1009
|
+
return min(1.0, max(0.0, score))
|
|
1010
|
+
|
|
1011
|
+
def run_sandbox_reverify(self) -> PhaseResult:
|
|
1012
|
+
"""Phase 6: Sandbox reverification with loop-back scan + test re-run.
|
|
1013
|
+
|
|
1014
|
+
For each fix candidate:
|
|
1015
|
+
1. Re-run the scoped test set from TEST_EXECUTION
|
|
1016
|
+
2. Re-run the scanner on the candidate diff
|
|
1017
|
+
3. Check for new findings introduced by the fix
|
|
1018
|
+
|
|
1019
|
+
Uses git worktree or temp directory as sandbox.
|
|
1020
|
+
"""
|
|
1021
|
+
start = time.monotonic()
|
|
1022
|
+
logger.info("Pipeline phase: SANDBOX_REVERIFY")
|
|
1023
|
+
errors: list[str] = []
|
|
1024
|
+
all_results: list[AgentResult] = []
|
|
1025
|
+
|
|
1026
|
+
candidates = getattr(self, "_last_candidates", [])
|
|
1027
|
+
fix_results = getattr(self, "_last_fix_results", [])
|
|
1028
|
+
|
|
1029
|
+
if not fix_results:
|
|
1030
|
+
logger.info("Sandbox reverify: no fix candidates to verify")
|
|
1031
|
+
result = PhaseResult(
|
|
1032
|
+
phase=PipelinePhase.SANDBOX_REVERIFY,
|
|
1033
|
+
status=AgentStatus.DONE,
|
|
1034
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
1035
|
+
)
|
|
1036
|
+
self._record_phase(result)
|
|
1037
|
+
return result
|
|
1038
|
+
|
|
1039
|
+
for i, (candidate, _fix_res) in enumerate(zip(candidates, fix_results, strict=False)):
|
|
1040
|
+
logger.info(
|
|
1041
|
+
f"Reverifying candidate {i + 1}/{len(candidates)}: {candidate['agent_name']}"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# Re-run scanner agents on the working tree
|
|
1045
|
+
try:
|
|
1046
|
+
scan_results = self.coordinator.run_all_scanners()
|
|
1047
|
+
all_results.extend(scan_results)
|
|
1048
|
+
except Exception as e:
|
|
1049
|
+
errors.append(f"Re-scan for {candidate['agent_name']} failed: {e}")
|
|
1050
|
+
|
|
1051
|
+
# Re-run test agents
|
|
1052
|
+
try:
|
|
1053
|
+
test_results = self.coordinator.run_group(AgentGroup.TEST)
|
|
1054
|
+
all_results.extend(test_results)
|
|
1055
|
+
except Exception as e:
|
|
1056
|
+
errors.append(f"Re-test for {candidate['agent_name']} failed: {e}")
|
|
1057
|
+
|
|
1058
|
+
# Check for newly introduced findings
|
|
1059
|
+
total_findings = sum(r.finding_count for r in all_results)
|
|
1060
|
+
if total_findings > 0:
|
|
1061
|
+
logger.warning(f"Sandbox reverify: {total_findings} findings remain after fix")
|
|
1062
|
+
|
|
1063
|
+
# Re-run the SPECIFIC failing tests of patches applied in FIX_GENERATION
|
|
1064
|
+
# (verify_loop.recheck_test_file) — a regression there fails the phase.
|
|
1065
|
+
reverify = self._recheck_applied_patches()
|
|
1066
|
+
if reverify["regressed"]:
|
|
1067
|
+
errors.append(
|
|
1068
|
+
f"Sandbox reverify: {len(reverify['regressed'])} previously-verified "
|
|
1069
|
+
f"test(s) regressed: {', '.join(reverify['regressed'])}"
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
1073
|
+
result = PhaseResult(
|
|
1074
|
+
phase=PipelinePhase.SANDBOX_REVERIFY,
|
|
1075
|
+
status=AgentStatus.DONE,
|
|
1076
|
+
results=all_results,
|
|
1077
|
+
errors=errors,
|
|
1078
|
+
duration_ms=duration_ms,
|
|
1079
|
+
findings_count=total_findings,
|
|
1080
|
+
agents_run=len(all_results),
|
|
1081
|
+
data={"reverify": reverify},
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
violations = self._check_criteria(PipelinePhase.SANDBOX_REVERIFY, all_results)
|
|
1085
|
+
if violations:
|
|
1086
|
+
result.status = AgentStatus.FAILED
|
|
1087
|
+
result.errors.extend(violations)
|
|
1088
|
+
|
|
1089
|
+
self._record_phase(result)
|
|
1090
|
+
if result.status == AgentStatus.DONE:
|
|
1091
|
+
self.current_phase = PipelinePhase.SANDBOX_REVERIFY
|
|
1092
|
+
return result
|
|
1093
|
+
|
|
1094
|
+
def run_score_select(self) -> PhaseResult:
|
|
1095
|
+
"""Phase 7: Composite scoring → select winner or escalate to human.
|
|
1096
|
+
|
|
1097
|
+
Compares all fix candidates, selects the best-scoring one,
|
|
1098
|
+
logs the decision with all candidate scores for audit trail.
|
|
1099
|
+
If no candidate passes all gates, escalates to human review.
|
|
1100
|
+
"""
|
|
1101
|
+
start = time.monotonic()
|
|
1102
|
+
logger.info("Pipeline phase: SCORE_SELECT")
|
|
1103
|
+
errors: list[str] = []
|
|
1104
|
+
|
|
1105
|
+
candidates = getattr(self, "_last_candidates", [])
|
|
1106
|
+
fix_results = getattr(self, "_last_fix_results", [])
|
|
1107
|
+
|
|
1108
|
+
if not candidates:
|
|
1109
|
+
errors.append("No fix candidates to score")
|
|
1110
|
+
result = PhaseResult(
|
|
1111
|
+
phase=PipelinePhase.SCORE_SELECT,
|
|
1112
|
+
status=AgentStatus.FAILED,
|
|
1113
|
+
errors=errors,
|
|
1114
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
1115
|
+
)
|
|
1116
|
+
self._record_phase(result)
|
|
1117
|
+
return result
|
|
1118
|
+
|
|
1119
|
+
# Sort by score descending
|
|
1120
|
+
ranked = sorted(
|
|
1121
|
+
zip(candidates, fix_results, strict=False),
|
|
1122
|
+
key=lambda x: x[0]["score"],
|
|
1123
|
+
reverse=True,
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
winners: list[dict] = []
|
|
1127
|
+
for cand, res in ranked:
|
|
1128
|
+
decision = self._select_candidate(cand, res)
|
|
1129
|
+
entry = {
|
|
1130
|
+
"candidate": cand,
|
|
1131
|
+
"decision": decision["action"],
|
|
1132
|
+
"reason": decision["reason"],
|
|
1133
|
+
}
|
|
1134
|
+
winners.append(entry)
|
|
1135
|
+
|
|
1136
|
+
# Log all candidates and decision
|
|
1137
|
+
logger.info(f"Score-select: {len(candidates)} candidates evaluated")
|
|
1138
|
+
for w in winners:
|
|
1139
|
+
logger.info(
|
|
1140
|
+
f" {w['candidate']['agent_name']}: score={w['candidate']['score']:.2f} → {w['decision']}"
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
self._last_selection = winners
|
|
1144
|
+
|
|
1145
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
1146
|
+
|
|
1147
|
+
# If any candidate passes, phase passes
|
|
1148
|
+
any_pass = any(w["decision"] == "auto_apply" for w in winners)
|
|
1149
|
+
result = PhaseResult(
|
|
1150
|
+
phase=PipelinePhase.SCORE_SELECT,
|
|
1151
|
+
status=AgentStatus.DONE if any_pass or not errors else AgentStatus.FAILED,
|
|
1152
|
+
errors=errors,
|
|
1153
|
+
duration_ms=duration_ms,
|
|
1154
|
+
agents_run=len(candidates),
|
|
1155
|
+
)
|
|
1156
|
+
if any_pass:
|
|
1157
|
+
self.current_phase = PipelinePhase.COMPLETE
|
|
1158
|
+
self._record_phase(result)
|
|
1159
|
+
return result
|
|
1160
|
+
|
|
1161
|
+
def _select_candidate(self, candidate: dict, result: AgentResult) -> dict:
|
|
1162
|
+
"""Decide whether a candidate is auto-applied or escalated.
|
|
1163
|
+
|
|
1164
|
+
Returns {"action": "auto_apply"|"escalate"|"discard", "reason": str}.
|
|
1165
|
+
"""
|
|
1166
|
+
score = candidate["score"]
|
|
1167
|
+
|
|
1168
|
+
# Gates
|
|
1169
|
+
if result.status != AgentStatus.DONE:
|
|
1170
|
+
return {"action": "discard", "reason": f"Agent failed: {result.status.value}"}
|
|
1171
|
+
|
|
1172
|
+
if candidate["findings"] > 0:
|
|
1173
|
+
return {"action": "escalate", "reason": f"{candidate['findings']} findings remain"}
|
|
1174
|
+
|
|
1175
|
+
# Score thresholds
|
|
1176
|
+
if score >= 0.8:
|
|
1177
|
+
return {"action": "auto_apply", "reason": f"Score {score:.2f} ≥ 0.8, all gates passed"}
|
|
1178
|
+
if score >= 0.5:
|
|
1179
|
+
return {
|
|
1180
|
+
"action": "escalate",
|
|
1181
|
+
"reason": f"Score {score:.2f} moderate, needs human review",
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return {"action": "discard", "reason": f"Score {score:.2f} too low"}
|
|
1185
|
+
|
|
1186
|
+
# ── Full pipeline runners ─────────────────────────────────────────────
|
|
1187
|
+
|
|
1188
|
+
def run_full_pipeline(
|
|
1189
|
+
self,
|
|
1190
|
+
scope: list[str] | None = None,
|
|
1191
|
+
dry_run: bool = False,
|
|
1192
|
+
) -> list[PhaseResult]:
|
|
1193
|
+
"""Run the full SCAN → TEST → FIX → REVERIFY pipeline sequentially."""
|
|
1194
|
+
phases: list[PhaseResult] = []
|
|
1195
|
+
|
|
1196
|
+
scan_result = self.run_scan(scope=scope)
|
|
1197
|
+
phases.append(scan_result)
|
|
1198
|
+
if not scan_result.passed:
|
|
1199
|
+
logger.error("SCAN phase failed, aborting pipeline")
|
|
1200
|
+
return phases
|
|
1201
|
+
|
|
1202
|
+
test_result = self.run_test()
|
|
1203
|
+
phases.append(test_result)
|
|
1204
|
+
if not test_result.passed:
|
|
1205
|
+
logger.warning("TEST phase has issues, proceeding with caution")
|
|
1206
|
+
|
|
1207
|
+
fix_result = self.run_fix(dry_run=dry_run)
|
|
1208
|
+
phases.append(fix_result)
|
|
1209
|
+
if not fix_result.passed:
|
|
1210
|
+
logger.warning("FIX phase has issues")
|
|
1211
|
+
|
|
1212
|
+
reverify_result = self.run_reverify()
|
|
1213
|
+
phases.append(reverify_result)
|
|
1214
|
+
|
|
1215
|
+
return phases
|
|
1216
|
+
|
|
1217
|
+
def run_full_pipeline_v2(
|
|
1218
|
+
self,
|
|
1219
|
+
scope: list[str] | None = None,
|
|
1220
|
+
dry_run: bool = False,
|
|
1221
|
+
) -> list[PhaseResult]:
|
|
1222
|
+
"""Run the full 7-step pipeline.
|
|
1223
|
+
|
|
1224
|
+
SCAN → GRAPH_UPDATE → TEST_GENERATION → TEST_EXECUTION
|
|
1225
|
+
→ FIX_GENERATION → SANDBOX_REVERIFY → SCORE_SELECT
|
|
1226
|
+
"""
|
|
1227
|
+
phases: list[PhaseResult] = []
|
|
1228
|
+
|
|
1229
|
+
# 1. SCAN
|
|
1230
|
+
scan_result = self.run_scan(scope=scope)
|
|
1231
|
+
phases.append(scan_result)
|
|
1232
|
+
if not scan_result.passed:
|
|
1233
|
+
logger.error("SCAN failed, aborting v2 pipeline")
|
|
1234
|
+
self.current_phase = PipelinePhase.FAILED
|
|
1235
|
+
return phases
|
|
1236
|
+
|
|
1237
|
+
# 2. GRAPH_UPDATE — incremental SymbolGraph patch
|
|
1238
|
+
graph_result = self.run_graph_update()
|
|
1239
|
+
phases.append(graph_result)
|
|
1240
|
+
|
|
1241
|
+
# 3. TEST_GENERATION — graph-scoped
|
|
1242
|
+
test_gen_result = self.run_test_generation()
|
|
1243
|
+
phases.append(test_gen_result)
|
|
1244
|
+
|
|
1245
|
+
# 4. TEST_EXECUTION — run test agents (existing)
|
|
1246
|
+
test_exec_result = self.run_test()
|
|
1247
|
+
phases.append(test_exec_result)
|
|
1248
|
+
if not test_exec_result.passed:
|
|
1249
|
+
logger.warning("TEST_EXECUTION has issues, proceeding with caution")
|
|
1250
|
+
|
|
1251
|
+
# 5. FIX_GENERATION — multiple candidates, deterministic first
|
|
1252
|
+
fix_gen_result = self.run_fix_generation(dry_run=dry_run)
|
|
1253
|
+
phases.append(fix_gen_result)
|
|
1254
|
+
if not fix_gen_result.passed:
|
|
1255
|
+
logger.warning("FIX_GENERATION has issues")
|
|
1256
|
+
|
|
1257
|
+
# 6. SANDBOX_REVERIFY — loop-back scan + test re-run
|
|
1258
|
+
reverify_result = self.run_sandbox_reverify()
|
|
1259
|
+
phases.append(reverify_result)
|
|
1260
|
+
|
|
1261
|
+
# 7. SCORE_SELECT — composite score → select or escalate
|
|
1262
|
+
select_result = self.run_score_select()
|
|
1263
|
+
phases.append(select_result)
|
|
1264
|
+
|
|
1265
|
+
return phases
|
|
1266
|
+
|
|
1267
|
+
# ── State management ──────────────────────────────────────────────────
|
|
1268
|
+
|
|
1269
|
+
def reset_pipeline(self) -> None:
|
|
1270
|
+
"""Reset pipeline state back to IDLE."""
|
|
1271
|
+
self.current_phase = PipelinePhase.IDLE
|
|
1272
|
+
logger.info("Pipeline reset to IDLE")
|
|
1273
|
+
|
|
1274
|
+
def status(self) -> dict:
|
|
1275
|
+
"""Return current pipeline status as a dict."""
|
|
1276
|
+
phase = self.current_phase
|
|
1277
|
+
history = self.get_history(5)
|
|
1278
|
+
return {
|
|
1279
|
+
"current_phase": phase.value,
|
|
1280
|
+
"phase_order": phase.order,
|
|
1281
|
+
"next_phase": phase.next_phase.value if phase.next_phase else None,
|
|
1282
|
+
"is_running": phase
|
|
1283
|
+
not in (PipelinePhase.IDLE, PipelinePhase.COMPLETE, PipelinePhase.FAILED),
|
|
1284
|
+
"recent_history": history,
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
# ── Coordinator passthrough ───────────────────────────────────────────
|
|
1288
|
+
|
|
1289
|
+
@property
|
|
1290
|
+
def config(self) -> dict:
|
|
1291
|
+
return getattr(self.coordinator, "_config", {})
|
|
1292
|
+
|
|
1293
|
+
@property
|
|
1294
|
+
def brain(self) -> dict:
|
|
1295
|
+
return getattr(self.coordinator, "_brain", {})
|
|
1296
|
+
|
|
1297
|
+
def run_agents(
|
|
1298
|
+
self, agent_names: list[str], scope: list[str] | None = None
|
|
1299
|
+
) -> list[AgentResult]:
|
|
1300
|
+
"""Passthrough to Coordinator.run_agents."""
|
|
1301
|
+
return self.coordinator.run_agents(agent_names, scope=scope)
|
|
1302
|
+
|
|
1303
|
+
def run_group(self, group: AgentGroup, scope: list[str] | None = None) -> list[AgentResult]:
|
|
1304
|
+
"""Passthrough to Coordinator.run_group."""
|
|
1305
|
+
return self.coordinator.run_group(group, scope=scope)
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
# ── GovernorEngine ──────────────────────────────────────────────────────────────
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
class GovernorEngine:
|
|
1312
|
+
"""Incident-based state machine wrapping Governor.
|
|
1313
|
+
|
|
1314
|
+
Manages incidents (create, transition, query), loads dispatch rules,
|
|
1315
|
+
evaluates escalation rules (spec §3), tracks fix retries and agent dispatch rate.
|
|
1316
|
+
|
|
1317
|
+
Additive — wraps Governor, does not replace it. The existing Governor
|
|
1318
|
+
and run_full_pipeline_v2() continue working unchanged.
|
|
1319
|
+
"""
|
|
1320
|
+
|
|
1321
|
+
def __init__(
|
|
1322
|
+
self,
|
|
1323
|
+
root: Path,
|
|
1324
|
+
on_progress: Callable[[CoordinatorProgress], None] | None = None,
|
|
1325
|
+
criteria: dict[PipelinePhase, PhaseCriteria] | None = None,
|
|
1326
|
+
rules_dir: Path | str | None = None,
|
|
1327
|
+
):
|
|
1328
|
+
self.root = Path(root)
|
|
1329
|
+
self.governor = Governor(root, on_progress=on_progress, criteria=criteria)
|
|
1330
|
+
self._on_progress = on_progress or (lambda _: None)
|
|
1331
|
+
self._rules_dir = Path(rules_dir) if rules_dir else self.root / ".patchi" / "rules"
|
|
1332
|
+
self._incidents: dict[str, Incident] = {}
|
|
1333
|
+
self._rules: list[DispatchRule] = []
|
|
1334
|
+
self._dispatch_timestamps: deque[float] = deque()
|
|
1335
|
+
|
|
1336
|
+
# Configurable thresholds (spec §4 reference table)
|
|
1337
|
+
self._conf_threshold: float = 0.4
|
|
1338
|
+
self._auto_apply_threshold: float = 0.75
|
|
1339
|
+
self._max_fix_retries: int = 3
|
|
1340
|
+
self._max_dispatches_per_window: int = 50
|
|
1341
|
+
self._dispatch_window_seconds: int = 60
|
|
1342
|
+
|
|
1343
|
+
self._load_rules()
|
|
1344
|
+
self._init_db()
|
|
1345
|
+
|
|
1346
|
+
# ── Database schema extension ─────────────────────────────────────────
|
|
1347
|
+
|
|
1348
|
+
def _init_db(self) -> None:
|
|
1349
|
+
"""Extend the existing pipeline_state.db with incident tables."""
|
|
1350
|
+
try:
|
|
1351
|
+
conn = self.governor._get_conn()
|
|
1352
|
+
conn.executescript("""
|
|
1353
|
+
CREATE TABLE IF NOT EXISTS incidents (
|
|
1354
|
+
id TEXT PRIMARY KEY,
|
|
1355
|
+
state TEXT NOT NULL,
|
|
1356
|
+
control_id TEXT,
|
|
1357
|
+
symbol_id TEXT,
|
|
1358
|
+
technique_id TEXT,
|
|
1359
|
+
confidence REAL DEFAULT 0.0,
|
|
1360
|
+
criticality TEXT,
|
|
1361
|
+
check_method TEXT,
|
|
1362
|
+
bug_class TEXT,
|
|
1363
|
+
domain_activation_state TEXT,
|
|
1364
|
+
fix_retries INTEGER DEFAULT 0,
|
|
1365
|
+
created_at TEXT NOT NULL,
|
|
1366
|
+
updated_at TEXT NOT NULL
|
|
1367
|
+
);
|
|
1368
|
+
CREATE TABLE IF NOT EXISTS incident_audit (
|
|
1369
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1370
|
+
incident_id TEXT NOT NULL,
|
|
1371
|
+
prior_state TEXT,
|
|
1372
|
+
new_state TEXT NOT NULL,
|
|
1373
|
+
rule_id TEXT,
|
|
1374
|
+
timestamp TEXT NOT NULL,
|
|
1375
|
+
metadata TEXT DEFAULT '{}',
|
|
1376
|
+
FOREIGN KEY (incident_id) REFERENCES incidents(id)
|
|
1377
|
+
);
|
|
1378
|
+
CREATE TABLE IF NOT EXISTS dispatch_log (
|
|
1379
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1380
|
+
incident_id TEXT,
|
|
1381
|
+
rule_id TEXT,
|
|
1382
|
+
target_agent TEXT,
|
|
1383
|
+
timestamp TEXT NOT NULL
|
|
1384
|
+
);
|
|
1385
|
+
""")
|
|
1386
|
+
except Exception as e:
|
|
1387
|
+
logger.warning(f"Could not extend schema for incidents: {e}")
|
|
1388
|
+
|
|
1389
|
+
# ── Rule loading (file-based YAML, spec §2) ───────────────────────────
|
|
1390
|
+
|
|
1391
|
+
def _load_rules(self) -> None:
|
|
1392
|
+
"""Load dispatch rules from YAML files in .patchi/rules/."""
|
|
1393
|
+
if not self._rules_dir.exists():
|
|
1394
|
+
logger.info(f"No rules directory at {self._rules_dir}, skipping")
|
|
1395
|
+
return
|
|
1396
|
+
|
|
1397
|
+
loaded = 0
|
|
1398
|
+
for yaml_path in sorted(self._rules_dir.glob("**/*.yaml")):
|
|
1399
|
+
try:
|
|
1400
|
+
with open(yaml_path) as f:
|
|
1401
|
+
data = yaml.safe_load(f)
|
|
1402
|
+
if not data:
|
|
1403
|
+
continue
|
|
1404
|
+
|
|
1405
|
+
conditions = [
|
|
1406
|
+
Condition(field=c["field"], operator=c["operator"], value=c["value"])
|
|
1407
|
+
for c in data.get("conditions", [])
|
|
1408
|
+
]
|
|
1409
|
+
|
|
1410
|
+
action_raw = data.get("action", {})
|
|
1411
|
+
action = Action(
|
|
1412
|
+
type=action_raw.get("type", "transition"),
|
|
1413
|
+
target=action_raw.get("target"),
|
|
1414
|
+
next_state=action_raw.get("next_state"),
|
|
1415
|
+
reason=action_raw.get("reason"),
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
rule = DispatchRule(
|
|
1419
|
+
rule_id=data.get("rule_id", yaml_path.stem),
|
|
1420
|
+
applies_at_state=data.get("applies_at_state", "DETECTED"),
|
|
1421
|
+
priority=data.get("priority", 100),
|
|
1422
|
+
conditions=conditions,
|
|
1423
|
+
action=action,
|
|
1424
|
+
fallback_if_no_match=data.get("fallback_if_no_match", False),
|
|
1425
|
+
)
|
|
1426
|
+
self._rules.append(rule)
|
|
1427
|
+
loaded += 1
|
|
1428
|
+
except Exception as e:
|
|
1429
|
+
logger.error(f"Failed to load rule {yaml_path}: {e}")
|
|
1430
|
+
|
|
1431
|
+
self._rules.sort(key=lambda r: r.priority)
|
|
1432
|
+
logger.info(f"Loaded {loaded} dispatch rules from {self._rules_dir}")
|
|
1433
|
+
|
|
1434
|
+
# ── Incident CRUD ─────────────────────────────────────────────────────
|
|
1435
|
+
|
|
1436
|
+
def create_incident(
|
|
1437
|
+
self,
|
|
1438
|
+
control_id: str | None = None,
|
|
1439
|
+
symbol_id: str | None = None,
|
|
1440
|
+
technique_id: str | None = None,
|
|
1441
|
+
confidence: float = 0.0,
|
|
1442
|
+
criticality: str | None = None,
|
|
1443
|
+
check_method: str | None = None,
|
|
1444
|
+
bug_class: str | None = None,
|
|
1445
|
+
domain_activation_state: str | None = None,
|
|
1446
|
+
finding_dict: dict | None = None,
|
|
1447
|
+
) -> Incident:
|
|
1448
|
+
"""Create a new incident, optionally from a finding dict."""
|
|
1449
|
+
if finding_dict:
|
|
1450
|
+
control_id = control_id or finding_dict.get("control_id")
|
|
1451
|
+
symbol_id = (
|
|
1452
|
+
symbol_id
|
|
1453
|
+
or finding_dict.get("symbol_id")
|
|
1454
|
+
or finding_dict.get("affected_node", {}).get("symbol")
|
|
1455
|
+
)
|
|
1456
|
+
technique_id = technique_id or finding_dict.get("technique_id")
|
|
1457
|
+
confidence = confidence if confidence else finding_dict.get("confidence", 0.0)
|
|
1458
|
+
criticality = (
|
|
1459
|
+
criticality
|
|
1460
|
+
or finding_dict.get("affected_node", {}).get("criticality")
|
|
1461
|
+
or finding_dict.get("criticality")
|
|
1462
|
+
)
|
|
1463
|
+
check_method = check_method or finding_dict.get("check_method")
|
|
1464
|
+
bug_class = bug_class or finding_dict.get("bug_class")
|
|
1465
|
+
domain_activation_state = domain_activation_state or finding_dict.get(
|
|
1466
|
+
"domain_activation_state"
|
|
1467
|
+
)
|
|
1468
|
+
|
|
1469
|
+
incident_id = f"INC-{int(time.time() * 1000)}-{len(self._incidents) + 1}"
|
|
1470
|
+
now = datetime.now(UTC).isoformat()
|
|
1471
|
+
|
|
1472
|
+
incident = Incident(
|
|
1473
|
+
id=incident_id,
|
|
1474
|
+
state=IncidentState.DETECTED,
|
|
1475
|
+
control_id=control_id,
|
|
1476
|
+
symbol_id=symbol_id,
|
|
1477
|
+
technique_id=technique_id,
|
|
1478
|
+
confidence=confidence,
|
|
1479
|
+
criticality=criticality,
|
|
1480
|
+
check_method=check_method,
|
|
1481
|
+
bug_class=bug_class,
|
|
1482
|
+
domain_activation_state=domain_activation_state,
|
|
1483
|
+
created_at=now,
|
|
1484
|
+
updated_at=now,
|
|
1485
|
+
audit_trail=[
|
|
1486
|
+
AuditEntry(
|
|
1487
|
+
prior_state=None,
|
|
1488
|
+
new_state=IncidentState.DETECTED,
|
|
1489
|
+
rule_id="system",
|
|
1490
|
+
timestamp=now,
|
|
1491
|
+
metadata={"source": "finding_ingested"},
|
|
1492
|
+
)
|
|
1493
|
+
],
|
|
1494
|
+
)
|
|
1495
|
+
self._incidents[incident_id] = incident
|
|
1496
|
+
self._persist_incident(incident)
|
|
1497
|
+
logger.info(
|
|
1498
|
+
f"Created incident {incident_id} (technique={technique_id}, confidence={confidence})"
|
|
1499
|
+
)
|
|
1500
|
+
return incident
|
|
1501
|
+
|
|
1502
|
+
def get_incident(self, incident_id: str) -> Incident | None:
|
|
1503
|
+
return self._incidents.get(incident_id)
|
|
1504
|
+
|
|
1505
|
+
def get_incidents(self, state: IncidentState | None = None) -> list[Incident]:
|
|
1506
|
+
if state is None:
|
|
1507
|
+
return list(self._incidents.values())
|
|
1508
|
+
return [i for i in self._incidents.values() if i.state == state]
|
|
1509
|
+
|
|
1510
|
+
def _persist_incident(self, incident: Incident) -> None:
|
|
1511
|
+
try:
|
|
1512
|
+
conn = self.governor._get_conn()
|
|
1513
|
+
conn.execute(
|
|
1514
|
+
"""INSERT OR REPLACE INTO incidents
|
|
1515
|
+
(id, state, control_id, symbol_id, technique_id, confidence,
|
|
1516
|
+
criticality, check_method, bug_class, domain_activation_state,
|
|
1517
|
+
fix_retries, created_at, updated_at)
|
|
1518
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
1519
|
+
(
|
|
1520
|
+
incident.id,
|
|
1521
|
+
incident.state.value,
|
|
1522
|
+
incident.control_id,
|
|
1523
|
+
incident.symbol_id,
|
|
1524
|
+
incident.technique_id,
|
|
1525
|
+
incident.confidence,
|
|
1526
|
+
incident.criticality,
|
|
1527
|
+
incident.check_method,
|
|
1528
|
+
incident.bug_class,
|
|
1529
|
+
incident.domain_activation_state,
|
|
1530
|
+
incident.fix_retries,
|
|
1531
|
+
incident.created_at,
|
|
1532
|
+
incident.updated_at,
|
|
1533
|
+
),
|
|
1534
|
+
)
|
|
1535
|
+
except Exception as e:
|
|
1536
|
+
logger.warning(f"Failed to persist incident {incident.id}: {e}")
|
|
1537
|
+
|
|
1538
|
+
def _persist_audit_entry(self, incident_id: str, entry: AuditEntry) -> None:
|
|
1539
|
+
try:
|
|
1540
|
+
conn = self.governor._get_conn()
|
|
1541
|
+
conn.execute(
|
|
1542
|
+
"""INSERT INTO incident_audit (incident_id, prior_state, new_state, rule_id, timestamp, metadata)
|
|
1543
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
1544
|
+
(
|
|
1545
|
+
incident_id,
|
|
1546
|
+
entry.prior_state.value if entry.prior_state else None,
|
|
1547
|
+
entry.new_state.value,
|
|
1548
|
+
entry.rule_id,
|
|
1549
|
+
entry.timestamp,
|
|
1550
|
+
str(entry.metadata),
|
|
1551
|
+
),
|
|
1552
|
+
)
|
|
1553
|
+
except Exception as e:
|
|
1554
|
+
logger.warning(f"Failed to persist audit entry: {e}")
|
|
1555
|
+
|
|
1556
|
+
# ── State transitions ─────────────────────────────────────────────────
|
|
1557
|
+
|
|
1558
|
+
def transition_incident(
|
|
1559
|
+
self,
|
|
1560
|
+
incident_id: str,
|
|
1561
|
+
new_state: IncidentState,
|
|
1562
|
+
rule_id: str | None = None,
|
|
1563
|
+
metadata: dict | None = None,
|
|
1564
|
+
) -> Incident | None:
|
|
1565
|
+
"""Transition an incident to a new state. Logs the transition to the audit trail."""
|
|
1566
|
+
incident = self._incidents.get(incident_id)
|
|
1567
|
+
if not incident:
|
|
1568
|
+
logger.warning(f"Incident {incident_id} not found")
|
|
1569
|
+
return None
|
|
1570
|
+
|
|
1571
|
+
prior_state = incident.state
|
|
1572
|
+
now = datetime.now(UTC).isoformat()
|
|
1573
|
+
|
|
1574
|
+
entry = AuditEntry(
|
|
1575
|
+
prior_state=prior_state,
|
|
1576
|
+
new_state=new_state,
|
|
1577
|
+
rule_id=rule_id,
|
|
1578
|
+
timestamp=now,
|
|
1579
|
+
metadata=metadata or {},
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
incident.state = new_state
|
|
1583
|
+
incident.updated_at = now
|
|
1584
|
+
incident.audit_trail.append(entry)
|
|
1585
|
+
|
|
1586
|
+
self._persist_incident(incident)
|
|
1587
|
+
self._persist_audit_entry(incident_id, entry)
|
|
1588
|
+
|
|
1589
|
+
logger.debug(
|
|
1590
|
+
f"Incident {incident_id}: {prior_state.value} → {new_state.value} (rule={rule_id})"
|
|
1591
|
+
)
|
|
1592
|
+
return incident
|
|
1593
|
+
|
|
1594
|
+
# ── Dispatch rule evaluation (spec §2, first-match-wins) ──────────────
|
|
1595
|
+
|
|
1596
|
+
def _resolve_field(self, incident: Incident, field_path: str) -> Any:
|
|
1597
|
+
"""Resolve a dotted field path against an incident (e.g. finding.technique_id)."""
|
|
1598
|
+
if field_path.startswith("finding."):
|
|
1599
|
+
key = field_path[len("finding.") :]
|
|
1600
|
+
mapping = {
|
|
1601
|
+
"technique_id": incident.technique_id,
|
|
1602
|
+
"confidence": incident.confidence,
|
|
1603
|
+
"criticality": incident.criticality,
|
|
1604
|
+
"check_method": incident.check_method,
|
|
1605
|
+
"bug_class": incident.bug_class,
|
|
1606
|
+
"domain_activation_state": incident.domain_activation_state,
|
|
1607
|
+
"control_id": incident.control_id,
|
|
1608
|
+
"symbol_id": incident.symbol_id,
|
|
1609
|
+
}
|
|
1610
|
+
return mapping.get(key)
|
|
1611
|
+
|
|
1612
|
+
if field_path.startswith("best_candidate."):
|
|
1613
|
+
key = field_path[len("best_candidate.") :]
|
|
1614
|
+
return getattr(self, f"_candidate_{key}", None)
|
|
1615
|
+
|
|
1616
|
+
return getattr(incident, field_path, None)
|
|
1617
|
+
|
|
1618
|
+
def _evaluate_conditions(self, incident: Incident, conditions: list[Condition]) -> bool:
|
|
1619
|
+
for cond in conditions:
|
|
1620
|
+
value = self._resolve_field(incident, cond.field)
|
|
1621
|
+
|
|
1622
|
+
if cond.operator == "in":
|
|
1623
|
+
if isinstance(value, list):
|
|
1624
|
+
if not any(v in cond.value for v in value):
|
|
1625
|
+
return False
|
|
1626
|
+
elif value not in cond.value:
|
|
1627
|
+
return False
|
|
1628
|
+
elif cond.operator == ">=":
|
|
1629
|
+
if not (value is not None and value >= cond.value):
|
|
1630
|
+
return False
|
|
1631
|
+
elif cond.operator == "<=":
|
|
1632
|
+
if not (value is not None and value <= cond.value):
|
|
1633
|
+
return False
|
|
1634
|
+
elif cond.operator == ">":
|
|
1635
|
+
if not (value is not None and value > cond.value):
|
|
1636
|
+
return False
|
|
1637
|
+
elif cond.operator == "<":
|
|
1638
|
+
if not (value is not None and value < cond.value):
|
|
1639
|
+
return False
|
|
1640
|
+
elif cond.operator == "==":
|
|
1641
|
+
if value != cond.value:
|
|
1642
|
+
return False
|
|
1643
|
+
elif cond.operator == "!=":
|
|
1644
|
+
if value == cond.value:
|
|
1645
|
+
return False
|
|
1646
|
+
else:
|
|
1647
|
+
logger.warning(f"Unknown condition operator: {cond.operator}")
|
|
1648
|
+
return False
|
|
1649
|
+
return True
|
|
1650
|
+
|
|
1651
|
+
def evaluate_dispatch_rules(
|
|
1652
|
+
self, incident: Incident, at_state: IncidentState | None = None
|
|
1653
|
+
) -> DispatchRule | None:
|
|
1654
|
+
"""First-match-wins rule evaluation. Returns the first matching rule or None."""
|
|
1655
|
+
state = at_state or incident.state
|
|
1656
|
+
for rule in self._rules:
|
|
1657
|
+
if rule.applies_at_state != state.value:
|
|
1658
|
+
continue
|
|
1659
|
+
if self._evaluate_conditions(incident, rule.conditions):
|
|
1660
|
+
logger.info(f"Rule {rule.rule_id} matched incident {incident.id}")
|
|
1661
|
+
return rule
|
|
1662
|
+
return None
|
|
1663
|
+
|
|
1664
|
+
# ── Escalation rules: §3.1 Hard triggers ──────────────────────────────
|
|
1665
|
+
|
|
1666
|
+
def check_hard_escalation_triggers(self, incident: Incident) -> str | None:
|
|
1667
|
+
"""§3.1 — unconditional, score-independent triggers."""
|
|
1668
|
+
# criticality in [auth, secrets, payment, data-write] at FIX_CANDIDATE_SCORING
|
|
1669
|
+
if incident.state == IncidentState.FIX_CANDIDATE_SCORING:
|
|
1670
|
+
if incident.criticality in ("auth", "secrets", "payment", "data-write"):
|
|
1671
|
+
return "hard-trigger-criticality"
|
|
1672
|
+
|
|
1673
|
+
# check_method == "manual-review" at CLASSIFIED
|
|
1674
|
+
if incident.state == IncidentState.CLASSIFIED:
|
|
1675
|
+
if incident.check_method == "manual-review":
|
|
1676
|
+
return "hard-trigger-manual-review"
|
|
1677
|
+
|
|
1678
|
+
# bug_class == "semantic-mismatch" at TEST_COMPLETE
|
|
1679
|
+
if incident.state == IncidentState.TEST_COMPLETE:
|
|
1680
|
+
if incident.bug_class == "semantic-mismatch":
|
|
1681
|
+
return "hard-trigger-semantic-mismatch"
|
|
1682
|
+
|
|
1683
|
+
# domain_activation_state == "unclear" at CLASSIFIED
|
|
1684
|
+
if incident.state == IncidentState.CLASSIFIED:
|
|
1685
|
+
if incident.domain_activation_state == "unclear":
|
|
1686
|
+
return "hard-trigger-unclear-domain"
|
|
1687
|
+
|
|
1688
|
+
return None
|
|
1689
|
+
|
|
1690
|
+
# ── Escalation rules: §3.2 Score-based ────────────────────────────────
|
|
1691
|
+
|
|
1692
|
+
def check_score_escalation(
|
|
1693
|
+
self, incident: Incident, best_score: float | None = None
|
|
1694
|
+
) -> str | None:
|
|
1695
|
+
"""§3.2 — composite score below configurable threshold."""
|
|
1696
|
+
if incident.state != IncidentState.FIX_CANDIDATE_SCORING:
|
|
1697
|
+
return None
|
|
1698
|
+
if best_score is not None and best_score < self._auto_apply_threshold:
|
|
1699
|
+
return f"score-below-threshold-{best_score:.2f}"
|
|
1700
|
+
return None
|
|
1701
|
+
|
|
1702
|
+
# ── Escalation rules: §3.3 Rate-limiting / loop prevention ────────────
|
|
1703
|
+
|
|
1704
|
+
def check_rate_limiting(self, incident: Incident) -> str | None:
|
|
1705
|
+
"""§3.3 — fix-retry loops and dispatch-rate ceilings."""
|
|
1706
|
+
if incident.state in (
|
|
1707
|
+
IncidentState.FIX_CANDIDATE_GENERATION,
|
|
1708
|
+
IncidentState.FIX_CANDIDATE_SCORING,
|
|
1709
|
+
):
|
|
1710
|
+
if incident.fix_retries >= self._max_fix_retries:
|
|
1711
|
+
return "max-fix-retries-exceeded"
|
|
1712
|
+
|
|
1713
|
+
now = time.time()
|
|
1714
|
+
while (
|
|
1715
|
+
self._dispatch_timestamps
|
|
1716
|
+
and self._dispatch_timestamps[0] < now - self._dispatch_window_seconds
|
|
1717
|
+
):
|
|
1718
|
+
self._dispatch_timestamps.popleft()
|
|
1719
|
+
|
|
1720
|
+
if len(self._dispatch_timestamps) >= self._max_dispatches_per_window:
|
|
1721
|
+
return "dispatch-rate-exceeded"
|
|
1722
|
+
|
|
1723
|
+
return None
|
|
1724
|
+
|
|
1725
|
+
# ── Combined escalation check ─────────────────────────────────────────
|
|
1726
|
+
|
|
1727
|
+
def check_escalation(self, incident: Incident, best_score: float | None = None) -> str | None:
|
|
1728
|
+
"""Evaluate all escalation rules in order. Returns the first reason or None."""
|
|
1729
|
+
hard = self.check_hard_escalation_triggers(incident)
|
|
1730
|
+
if hard:
|
|
1731
|
+
return hard
|
|
1732
|
+
|
|
1733
|
+
score = self.check_score_escalation(incident, best_score)
|
|
1734
|
+
if score:
|
|
1735
|
+
return score
|
|
1736
|
+
|
|
1737
|
+
rate = self.check_rate_limiting(incident)
|
|
1738
|
+
if rate:
|
|
1739
|
+
return rate
|
|
1740
|
+
|
|
1741
|
+
return None
|
|
1742
|
+
|
|
1743
|
+
# ── Dispatch rate tracking ────────────────────────────────────────────
|
|
1744
|
+
|
|
1745
|
+
def record_dispatch(self, incident_id: str | None = None, rule_id: str | None = None) -> None:
|
|
1746
|
+
now = time.time()
|
|
1747
|
+
self._dispatch_timestamps.append(now)
|
|
1748
|
+
try:
|
|
1749
|
+
conn = self.governor._get_conn()
|
|
1750
|
+
conn.execute(
|
|
1751
|
+
"""INSERT INTO dispatch_log (incident_id, rule_id, target_agent, timestamp)
|
|
1752
|
+
VALUES (?, ?, ?, ?)""",
|
|
1753
|
+
(incident_id, rule_id, None, datetime.now(UTC).isoformat()),
|
|
1754
|
+
)
|
|
1755
|
+
except Exception as e:
|
|
1756
|
+
logger.warning(f"Failed to log dispatch for {incident_id}: {e}")
|
|
1757
|
+
|
|
1758
|
+
def can_dispatch(self) -> bool:
|
|
1759
|
+
"""Check dispatch rate — True if under the ceiling."""
|
|
1760
|
+
now = time.time()
|
|
1761
|
+
while (
|
|
1762
|
+
self._dispatch_timestamps
|
|
1763
|
+
and self._dispatch_timestamps[0] < now - self._dispatch_window_seconds
|
|
1764
|
+
):
|
|
1765
|
+
self._dispatch_timestamps.popleft()
|
|
1766
|
+
return len(self._dispatch_timestamps) < self._max_dispatches_per_window
|
|
1767
|
+
|
|
1768
|
+
# ── Pipeline integration ──────────────────────────────────────────────
|
|
1769
|
+
|
|
1770
|
+
@staticmethod
|
|
1771
|
+
def _map_phase_to_incident_state(phase: PipelinePhase) -> IncidentState | None:
|
|
1772
|
+
mapping = {
|
|
1773
|
+
PipelinePhase.SCAN: IncidentState.CLASSIFIED,
|
|
1774
|
+
PipelinePhase.GRAPH_UPDATE: IncidentState.CLASSIFIED,
|
|
1775
|
+
PipelinePhase.TEST_GENERATION: IncidentState.TEST_SCOPED,
|
|
1776
|
+
PipelinePhase.TEST_EXECUTION: IncidentState.TEST_COMPLETE,
|
|
1777
|
+
PipelinePhase.FIX_GENERATION: IncidentState.FIX_CANDIDATE_GENERATION,
|
|
1778
|
+
PipelinePhase.SANDBOX_REVERIFY: None,
|
|
1779
|
+
PipelinePhase.SCORE_SELECT: IncidentState.FIX_CANDIDATE_SCORING,
|
|
1780
|
+
PipelinePhase.COMPLETE: None,
|
|
1781
|
+
PipelinePhase.FAILED: None,
|
|
1782
|
+
}
|
|
1783
|
+
return mapping.get(phase)
|
|
1784
|
+
|
|
1785
|
+
def run_pipeline_with_incident(
|
|
1786
|
+
self,
|
|
1787
|
+
scope: list[str] | None = None,
|
|
1788
|
+
dry_run: bool = False,
|
|
1789
|
+
) -> dict:
|
|
1790
|
+
"""Run the full v2 pipeline wrapped with incident tracking and escalation.
|
|
1791
|
+
|
|
1792
|
+
Returns a dict with keys:
|
|
1793
|
+
- phases: list[PhaseResult] from the underlying pipeline
|
|
1794
|
+
- incidents: list[Incident] tracked during this run
|
|
1795
|
+
- escalated: list[str] — incident IDs that were escalated to human
|
|
1796
|
+
- queued: int — 1 if pipeline was queued due to rate limit, else 0
|
|
1797
|
+
"""
|
|
1798
|
+
if not self.can_dispatch():
|
|
1799
|
+
logger.warning("Dispatch rate exceeded — pipeline queued")
|
|
1800
|
+
return {
|
|
1801
|
+
"phases": [],
|
|
1802
|
+
"incidents": [],
|
|
1803
|
+
"escalated": [],
|
|
1804
|
+
"queued": 1,
|
|
1805
|
+
"reason": "dispatch_rate_exceeded",
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
phases = self.governor.run_full_pipeline_v2(scope=scope, dry_run=dry_run)
|
|
1809
|
+
|
|
1810
|
+
incidents: list[Incident] = []
|
|
1811
|
+
escalated: list[str] = []
|
|
1812
|
+
|
|
1813
|
+
for phase_result in phases:
|
|
1814
|
+
incident_state = self._map_phase_to_incident_state(phase_result.phase)
|
|
1815
|
+
if incident_state is None:
|
|
1816
|
+
continue
|
|
1817
|
+
|
|
1818
|
+
for agent_result in phase_result.results:
|
|
1819
|
+
for finding in getattr(agent_result, "findings", []):
|
|
1820
|
+
try:
|
|
1821
|
+
finding_dict = finding.to_dict()
|
|
1822
|
+
except Exception as e:
|
|
1823
|
+
logger.warning(f"Failed to convert finding to dict: {e}")
|
|
1824
|
+
continue
|
|
1825
|
+
|
|
1826
|
+
confidence = finding_dict.get("confidence", 0.0)
|
|
1827
|
+
if confidence < self._conf_threshold:
|
|
1828
|
+
continue
|
|
1829
|
+
|
|
1830
|
+
control_id = finding_dict.get("control_id")
|
|
1831
|
+
symbol_id = finding_dict.get("symbol_id") or finding_dict.get(
|
|
1832
|
+
"affected_node", {}
|
|
1833
|
+
).get("symbol")
|
|
1834
|
+
technique_id = finding_dict.get("technique_id")
|
|
1835
|
+
|
|
1836
|
+
existing: Incident | None = None
|
|
1837
|
+
for inc in self._incidents.values():
|
|
1838
|
+
if (
|
|
1839
|
+
inc.control_id == control_id
|
|
1840
|
+
and inc.symbol_id == symbol_id
|
|
1841
|
+
and not inc.state.is_terminal
|
|
1842
|
+
):
|
|
1843
|
+
existing = inc
|
|
1844
|
+
break
|
|
1845
|
+
|
|
1846
|
+
if existing:
|
|
1847
|
+
incident = existing
|
|
1848
|
+
self.transition_incident(
|
|
1849
|
+
incident.id,
|
|
1850
|
+
incident_state,
|
|
1851
|
+
rule_id="pipeline-phase-auto",
|
|
1852
|
+
metadata={"phase": phase_result.phase.value},
|
|
1853
|
+
)
|
|
1854
|
+
else:
|
|
1855
|
+
incident = self.create_incident(
|
|
1856
|
+
finding_dict=finding_dict,
|
|
1857
|
+
control_id=control_id,
|
|
1858
|
+
symbol_id=symbol_id,
|
|
1859
|
+
technique_id=technique_id,
|
|
1860
|
+
confidence=confidence,
|
|
1861
|
+
)
|
|
1862
|
+
if incident_state != IncidentState.DETECTED:
|
|
1863
|
+
self.transition_incident(
|
|
1864
|
+
incident.id,
|
|
1865
|
+
incident_state,
|
|
1866
|
+
rule_id="dispatch-rule-auto",
|
|
1867
|
+
metadata={"phase": phase_result.phase.value},
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
incidents.append(incident)
|
|
1871
|
+
self.record_dispatch(incident.id)
|
|
1872
|
+
|
|
1873
|
+
escalation_reason = self.check_escalation(incident)
|
|
1874
|
+
if escalation_reason:
|
|
1875
|
+
self.transition_incident(
|
|
1876
|
+
incident.id,
|
|
1877
|
+
IncidentState.ESCALATED_TO_HUMAN,
|
|
1878
|
+
rule_id=escalation_reason,
|
|
1879
|
+
metadata={"escalation_reason": escalation_reason},
|
|
1880
|
+
)
|
|
1881
|
+
escalated.append(incident.id)
|
|
1882
|
+
|
|
1883
|
+
for incident in self._incidents.values():
|
|
1884
|
+
if incident.state.is_terminal:
|
|
1885
|
+
continue
|
|
1886
|
+
escalation_reason = self.check_escalation(incident)
|
|
1887
|
+
if escalation_reason and incident.id not in escalated:
|
|
1888
|
+
self.transition_incident(
|
|
1889
|
+
incident.id,
|
|
1890
|
+
IncidentState.ESCALATED_TO_HUMAN,
|
|
1891
|
+
rule_id=escalation_reason,
|
|
1892
|
+
metadata={"escalation_reason": escalation_reason, "phase": "final"},
|
|
1893
|
+
)
|
|
1894
|
+
escalated.append(incident.id)
|
|
1895
|
+
|
|
1896
|
+
if (
|
|
1897
|
+
phases
|
|
1898
|
+
and phases[-1].status == AgentStatus.DONE
|
|
1899
|
+
and phases[-1].phase in (PipelinePhase.COMPLETE, PipelinePhase.SCORE_SELECT)
|
|
1900
|
+
):
|
|
1901
|
+
for incident in self._incidents.values():
|
|
1902
|
+
if not incident.state.is_terminal and incident.id not in escalated:
|
|
1903
|
+
self.transition_incident(
|
|
1904
|
+
incident.id,
|
|
1905
|
+
IncidentState.VERIFIED_RESOLVED,
|
|
1906
|
+
rule_id="pipeline-complete",
|
|
1907
|
+
metadata={"phase": "complete"},
|
|
1908
|
+
)
|
|
1909
|
+
|
|
1910
|
+
return {
|
|
1911
|
+
"phases": phases,
|
|
1912
|
+
"incidents": list(self._incidents.values()),
|
|
1913
|
+
"escalated": escalated,
|
|
1914
|
+
"queued": 0,
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
# ── Config accessors (spec §4 — all thresholds are configurable) ──────
|
|
1918
|
+
|
|
1919
|
+
@property
|
|
1920
|
+
def auto_apply_threshold(self) -> float:
|
|
1921
|
+
return self._auto_apply_threshold
|
|
1922
|
+
|
|
1923
|
+
@auto_apply_threshold.setter
|
|
1924
|
+
def auto_apply_threshold(self, value: float) -> None:
|
|
1925
|
+
self._auto_apply_threshold = value
|
|
1926
|
+
|
|
1927
|
+
@property
|
|
1928
|
+
def max_fix_retries(self) -> int:
|
|
1929
|
+
return self._max_fix_retries
|
|
1930
|
+
|
|
1931
|
+
@max_fix_retries.setter
|
|
1932
|
+
def max_fix_retries(self, value: int) -> None:
|
|
1933
|
+
self._max_fix_retries = value
|
|
1934
|
+
|
|
1935
|
+
@property
|
|
1936
|
+
def max_dispatches_per_window(self) -> int:
|
|
1937
|
+
return self._max_dispatches_per_window
|
|
1938
|
+
|
|
1939
|
+
@max_dispatches_per_window.setter
|
|
1940
|
+
def max_dispatches_per_window(self, value: int) -> None:
|
|
1941
|
+
self._max_dispatches_per_window = value
|
|
1942
|
+
|
|
1943
|
+
@property
|
|
1944
|
+
def confidence_threshold(self) -> float:
|
|
1945
|
+
return self._conf_threshold
|
|
1946
|
+
|
|
1947
|
+
@confidence_threshold.setter
|
|
1948
|
+
def confidence_threshold(self, value: float) -> None:
|
|
1949
|
+
self._conf_threshold = value
|
|
1950
|
+
|
|
1951
|
+
# ── Status / passthrough ──────────────────────────────────────────────
|
|
1952
|
+
|
|
1953
|
+
@property
|
|
1954
|
+
def config(self) -> dict:
|
|
1955
|
+
return self.governor.config
|
|
1956
|
+
|
|
1957
|
+
def status(self) -> dict:
|
|
1958
|
+
base = self.governor.status()
|
|
1959
|
+
base["incident_count"] = len(self._incidents)
|
|
1960
|
+
base["incidents_by_state"] = {
|
|
1961
|
+
s.value: sum(1 for i in self._incidents.values() if i.state == s) for s in IncidentState
|
|
1962
|
+
}
|
|
1963
|
+
base["rules_loaded"] = len(self._rules)
|
|
1964
|
+
base["dispatches_in_window"] = len(self._dispatch_timestamps)
|
|
1965
|
+
return base
|
|
1966
|
+
|
|
1967
|
+
def reset(self) -> None:
|
|
1968
|
+
"""Reset pipeline and incident state."""
|
|
1969
|
+
self.governor.reset_pipeline()
|
|
1970
|
+
self._incidents.clear()
|
|
1971
|
+
self._dispatch_timestamps.clear()
|
|
1972
|
+
logger.info("GovernorEngine reset")
|