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,1908 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`p scan` — Run a brain scan on the project.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
p scan — full project scan
|
|
6
|
+
p scan src/auth — targeted scan of a specific area
|
|
7
|
+
p scan --dry-run — show what would be scanned without parsing
|
|
8
|
+
|
|
9
|
+
Shows:
|
|
10
|
+
- Rich multi-bar progress display (one bar per phase)
|
|
11
|
+
- Live file discovery feed
|
|
12
|
+
- Summary table after completion
|
|
13
|
+
- Contract confirmation if new critical flows are found
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
from datetime import UTC
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from rich.live import Live
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
from rich.progress import (
|
|
28
|
+
BarColumn,
|
|
29
|
+
Progress,
|
|
30
|
+
SpinnerColumn,
|
|
31
|
+
TaskProgressColumn,
|
|
32
|
+
TextColumn,
|
|
33
|
+
TimeElapsedColumn,
|
|
34
|
+
)
|
|
35
|
+
from rich.table import Table
|
|
36
|
+
from rich.text import Text
|
|
37
|
+
|
|
38
|
+
from patchi.cli.console import con
|
|
39
|
+
from patchi.core import config as cfg
|
|
40
|
+
from patchi.core import memory as mem
|
|
41
|
+
from patchi.core.agents.base import AgentGroup, list_agents
|
|
42
|
+
from patchi.core.brain.brain import Brain, BrainReport, ScanProgress
|
|
43
|
+
from patchi.core.brain.freshness import check_freshness
|
|
44
|
+
from patchi.core.config import require_project_root
|
|
45
|
+
|
|
46
|
+
_log = logging.getLogger("patchi.cli.scan_cmd")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(
|
|
50
|
+
area: str | None = None,
|
|
51
|
+
dry_run: bool = False,
|
|
52
|
+
force: bool = False,
|
|
53
|
+
quiet: bool = False,
|
|
54
|
+
no_logo: bool = False,
|
|
55
|
+
deep: bool = False,
|
|
56
|
+
file_path: str | None = None,
|
|
57
|
+
contract: bool = False,
|
|
58
|
+
all_flows: bool = False,
|
|
59
|
+
offline: bool = False,
|
|
60
|
+
json_output: bool = False,
|
|
61
|
+
side: bool = True,
|
|
62
|
+
pipeline: bool = False,
|
|
63
|
+
daemon: bool = False,
|
|
64
|
+
governor: bool = False,
|
|
65
|
+
with_attackers: bool = False,
|
|
66
|
+
with_campaigns: bool = False,
|
|
67
|
+
with_fuzz: bool = False,
|
|
68
|
+
red_team: bool = False,
|
|
69
|
+
dast: bool = False,
|
|
70
|
+
changed: bool = False,
|
|
71
|
+
changed_commits: int = 1,
|
|
72
|
+
since: str | None = None,
|
|
73
|
+
fail_on: str | None = None,
|
|
74
|
+
root: Path | None = None,
|
|
75
|
+
with_license: bool = False,
|
|
76
|
+
with_extended: bool = False,
|
|
77
|
+
) -> int:
|
|
78
|
+
"""Entry point for `p scan [area]`. Returns process exit code."""
|
|
79
|
+
try:
|
|
80
|
+
r = root or require_project_root()
|
|
81
|
+
except RuntimeError as e:
|
|
82
|
+
con.print(f"[red]{e}[/red]")
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
# ── Set tenant context so profiler records the correct project root ────
|
|
86
|
+
from patchi.core.tenant import get_tenant_manager, tenant_context
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
mgr = get_tenant_manager()
|
|
90
|
+
mgr.register_project(r)
|
|
91
|
+
mgr.switch_project(r)
|
|
92
|
+
except Exception as _exc:
|
|
93
|
+
_log.debug("tenant registration skipped: %s", _exc)
|
|
94
|
+
|
|
95
|
+
with tenant_context(r):
|
|
96
|
+
return _run_scan_inner(
|
|
97
|
+
r,
|
|
98
|
+
area,
|
|
99
|
+
dry_run,
|
|
100
|
+
force,
|
|
101
|
+
quiet,
|
|
102
|
+
no_logo,
|
|
103
|
+
deep,
|
|
104
|
+
file_path,
|
|
105
|
+
contract,
|
|
106
|
+
all_flows,
|
|
107
|
+
offline,
|
|
108
|
+
json_output,
|
|
109
|
+
side,
|
|
110
|
+
pipeline,
|
|
111
|
+
daemon,
|
|
112
|
+
governor,
|
|
113
|
+
with_attackers,
|
|
114
|
+
with_campaigns,
|
|
115
|
+
with_fuzz,
|
|
116
|
+
red_team,
|
|
117
|
+
dast,
|
|
118
|
+
changed,
|
|
119
|
+
changed_commits,
|
|
120
|
+
since,
|
|
121
|
+
fail_on,
|
|
122
|
+
with_license,
|
|
123
|
+
with_extended,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _run_scan_inner(
|
|
128
|
+
r: Path,
|
|
129
|
+
area: str | None = None,
|
|
130
|
+
dry_run: bool = False,
|
|
131
|
+
force: bool = False,
|
|
132
|
+
quiet: bool = False,
|
|
133
|
+
no_logo: bool = False,
|
|
134
|
+
deep: bool = False,
|
|
135
|
+
file_path: str | None = None,
|
|
136
|
+
contract: bool = False,
|
|
137
|
+
all_flows: bool = False,
|
|
138
|
+
offline: bool = False,
|
|
139
|
+
json_output: bool = False,
|
|
140
|
+
side: bool = True,
|
|
141
|
+
pipeline: bool = False,
|
|
142
|
+
daemon: bool = False,
|
|
143
|
+
governor: bool = False,
|
|
144
|
+
with_attackers: bool = False,
|
|
145
|
+
with_campaigns: bool = False,
|
|
146
|
+
with_fuzz: bool = False,
|
|
147
|
+
red_team: bool = False,
|
|
148
|
+
dast: bool = False,
|
|
149
|
+
changed: bool = False,
|
|
150
|
+
changed_commits: int = 1,
|
|
151
|
+
since: str | None = None,
|
|
152
|
+
fail_on: str | None = None,
|
|
153
|
+
with_license: bool = False,
|
|
154
|
+
with_extended: bool = False,
|
|
155
|
+
) -> int:
|
|
156
|
+
"""Inner scan logic — runs inside tenant_context. Returns process exit code."""
|
|
157
|
+
|
|
158
|
+
# ── Contract review mode ──────────────────────────────────────────────────
|
|
159
|
+
if contract:
|
|
160
|
+
_run_contract_review(r, all_flows=all_flows)
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
# ── Offline mode ──────────────────────────────────────────────────────────
|
|
164
|
+
if offline:
|
|
165
|
+
import os
|
|
166
|
+
|
|
167
|
+
os.environ["PATCHI_OFFLINE"] = "1"
|
|
168
|
+
if not quiet:
|
|
169
|
+
con.print(
|
|
170
|
+
"[bold #C8621A]OFFLINE MODE[/bold #C8621A] — Static analysis only. Zero API calls."
|
|
171
|
+
)
|
|
172
|
+
con.print(
|
|
173
|
+
"[dim]Findings will have no AI explanations. Run without --offline to add them.[/dim]"
|
|
174
|
+
)
|
|
175
|
+
con.print()
|
|
176
|
+
|
|
177
|
+
# ── Dry run ───────────────────────────────────────────────────────────────
|
|
178
|
+
if dry_run and not changed:
|
|
179
|
+
_show_dry_run(r, area)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
# ── Changed dry-run mode ──────────────────────────────────────────────
|
|
183
|
+
if changed and dry_run:
|
|
184
|
+
_show_changed_dry_run(r, changed_commits)
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
# ── Handle file-specific deep scan ────────────────────────────────────────
|
|
188
|
+
if file_path:
|
|
189
|
+
_run_file_scan(r, file_path, deep)
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
# ── Check freshness first ─────────────────────────────────────────────────
|
|
193
|
+
freshness = check_freshness(r)
|
|
194
|
+
if (
|
|
195
|
+
not freshness["is_stale"]
|
|
196
|
+
and freshness["last_recorded"]
|
|
197
|
+
and not area
|
|
198
|
+
and not force
|
|
199
|
+
and not deep
|
|
200
|
+
and not contract
|
|
201
|
+
):
|
|
202
|
+
con.print()
|
|
203
|
+
con.print(
|
|
204
|
+
"[dim]Brain is already fresh.[/dim] "
|
|
205
|
+
f"[dim]Last scan: {_fmt_time(freshness['last_recorded'])}[/dim]"
|
|
206
|
+
)
|
|
207
|
+
con.print("[dim]Run [bold]p scan --force[/bold] to re-scan anyway.[/dim]")
|
|
208
|
+
con.print()
|
|
209
|
+
# Still show current status
|
|
210
|
+
_show_summary_from_memory(r)
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
# ── Scan ──────────────────────────────────────────────────────────────────
|
|
214
|
+
con.print()
|
|
215
|
+
area_label = f" [dim]→ {area}[/dim]" if area else ""
|
|
216
|
+
scan_type = "Deep" if deep else "Scanning"
|
|
217
|
+
con.print(f"[bold #C8621A]{scan_type}{area_label}[/bold #C8621A]")
|
|
218
|
+
con.print()
|
|
219
|
+
|
|
220
|
+
# Chain progress display
|
|
221
|
+
from patchi.cli.ux import format_step, status_icon, status_style
|
|
222
|
+
|
|
223
|
+
scan_phases = [
|
|
224
|
+
"File discovery",
|
|
225
|
+
"Source parsing",
|
|
226
|
+
"Framework detection",
|
|
227
|
+
"Route mapping",
|
|
228
|
+
"Import graph",
|
|
229
|
+
"Contract inference",
|
|
230
|
+
"Scanner agents",
|
|
231
|
+
]
|
|
232
|
+
total_steps = len(scan_phases)
|
|
233
|
+
current_step = 0
|
|
234
|
+
|
|
235
|
+
def show_scan_step(step_name: str, status: str = "running"):
|
|
236
|
+
nonlocal current_step
|
|
237
|
+
current_step += 1
|
|
238
|
+
icon = status_icon(status)
|
|
239
|
+
style = status_style(status)
|
|
240
|
+
con.print(format_step(current_step, total_steps, f"[{style}]{icon} {step_name}[/{style}]"))
|
|
241
|
+
|
|
242
|
+
# Show initial chain steps
|
|
243
|
+
for phase_name in scan_phases[:6]: # First 6 are brain phases
|
|
244
|
+
show_scan_step(phase_name, "done")
|
|
245
|
+
|
|
246
|
+
_scan_start = time.monotonic() # wall clock for entire scan
|
|
247
|
+
|
|
248
|
+
progress = _build_progress()
|
|
249
|
+
tasks: dict[str, Any] = {}
|
|
250
|
+
|
|
251
|
+
phases = {
|
|
252
|
+
"discovery": "Discovering files",
|
|
253
|
+
"parsing": "Parsing source files",
|
|
254
|
+
"framework": "Detecting framework",
|
|
255
|
+
"routes": "Mapping routes",
|
|
256
|
+
"graph": "Building import graph",
|
|
257
|
+
"contract": "Inferring app contract",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for phase, label in phases.items():
|
|
261
|
+
tasks[phase] = progress.add_task(
|
|
262
|
+
f"[dim]{label}[/dim]",
|
|
263
|
+
total=None, # indeterminate until we know file count
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
last_phase = [None]
|
|
267
|
+
|
|
268
|
+
def on_progress(sp: ScanProgress) -> None:
|
|
269
|
+
task_id = tasks.get(sp.phase)
|
|
270
|
+
if task_id is None:
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
# Advance completed previous phase
|
|
274
|
+
if last_phase[0] and last_phase[0] != sp.phase:
|
|
275
|
+
prev_id = tasks.get(last_phase[0])
|
|
276
|
+
if prev_id is not None:
|
|
277
|
+
progress.update(prev_id, completed=100, total=100)
|
|
278
|
+
last_phase[0] = sp.phase
|
|
279
|
+
|
|
280
|
+
if sp.total:
|
|
281
|
+
progress.update(
|
|
282
|
+
task_id,
|
|
283
|
+
total=sp.total,
|
|
284
|
+
completed=sp.current,
|
|
285
|
+
description=f"[dim]{sp.message[:60]}[/dim]",
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
progress.update(task_id, description=f"[dim]{sp.message[:60]}[/dim]", total=None)
|
|
289
|
+
|
|
290
|
+
report: BrainReport | None = None
|
|
291
|
+
agent_results: list | None = None
|
|
292
|
+
error: str | None = None
|
|
293
|
+
|
|
294
|
+
_is_tty = con.is_terminal
|
|
295
|
+
|
|
296
|
+
# ── Pre-load DomainLoader in background (saves ~5-10s) ──────────────
|
|
297
|
+
# Auto-detect component types first (cheap), then load scoped taxonomy
|
|
298
|
+
# in a background thread while the main scan runs.
|
|
299
|
+
_ctypes = []
|
|
300
|
+
try:
|
|
301
|
+
_root = Path(str(r))
|
|
302
|
+
if any((_root / d).exists() for d in ("templates", "static", "public")):
|
|
303
|
+
_ctypes.append("frontend-web")
|
|
304
|
+
if any((_root / f).exists() for f in ("requirements.txt", "pyproject.toml", "setup.py")):
|
|
305
|
+
_ctypes.append("backend-api")
|
|
306
|
+
if any((_root / d).exists() for d in ("docker", "k8s", "kubernetes", ".github")) or (_root / "Dockerfile").exists():
|
|
307
|
+
_ctypes.append("infra")
|
|
308
|
+
except Exception as _exc:
|
|
309
|
+
_log.debug("component type detect skipped: %s", _exc)
|
|
310
|
+
_domain_loader_future = None
|
|
311
|
+
try:
|
|
312
|
+
import concurrent.futures as _cf
|
|
313
|
+
|
|
314
|
+
from patchi.core.security.domain_loader import DomainLoader as _DL
|
|
315
|
+
_loader_pool = _cf.ThreadPoolExecutor(max_workers=1, thread_name_prefix="dl-preload")
|
|
316
|
+
def _preload_loader() -> _DL:
|
|
317
|
+
# Construct + force the (lazy) load so YAML parsing happens in
|
|
318
|
+
# this background thread, not on first use in the main thread.
|
|
319
|
+
_ldr = _DL(r, component_types=_ctypes if _ctypes else None)
|
|
320
|
+
_ldr.list_domains() # public force-load; parses + caches taxonomy
|
|
321
|
+
return _ldr
|
|
322
|
+
|
|
323
|
+
_domain_loader_future = _loader_pool.submit(_preload_loader)
|
|
324
|
+
_loader_pool.shutdown(wait=False)
|
|
325
|
+
except Exception as _exc:
|
|
326
|
+
_log.debug('suppressed: %s', _exc)
|
|
327
|
+
|
|
328
|
+
def _run_scan() -> None:
|
|
329
|
+
nonlocal report, agent_results, error
|
|
330
|
+
try:
|
|
331
|
+
brain = Brain(r, on_progress=on_progress)
|
|
332
|
+
report = brain.scan(area)
|
|
333
|
+
|
|
334
|
+
# ── Run scanner agents via coordinator ─────────────────────────────
|
|
335
|
+
# Import scanners to trigger @register decorators
|
|
336
|
+
import patchi.core.agents.scanners # noqa: F401
|
|
337
|
+
from patchi.core.agents.coordinator import Coordinator, CoordinatorProgress
|
|
338
|
+
|
|
339
|
+
if not governor:
|
|
340
|
+
agents_task = progress.add_task(
|
|
341
|
+
"[dim]Running scanner agents…[/dim]", total=len(list_agents(AgentGroup.SCANNER))
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def on_agent_progress(cp: CoordinatorProgress) -> None:
|
|
345
|
+
progress.update(
|
|
346
|
+
agents_task,
|
|
347
|
+
completed=cp.current,
|
|
348
|
+
total=cp.total,
|
|
349
|
+
description=f"[dim]{cp.agent_name} — {cp.finding_count} findings[/dim]",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
coord = Coordinator(r, on_progress=on_agent_progress)
|
|
353
|
+
scope = list(report.import_graph.nodes) if report.import_graph else []
|
|
354
|
+
|
|
355
|
+
# ── On-demand domain activation from git diff ────────────
|
|
356
|
+
if changed:
|
|
357
|
+
try:
|
|
358
|
+
from patchi.core.security.git_diff_activator import (
|
|
359
|
+
activate_from_diff,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
diff_result = activate_from_diff(r, commits=changed_commits)
|
|
363
|
+
if diff_result.activated_domains:
|
|
364
|
+
con.print(
|
|
365
|
+
f" [dim]Changed files: {len(diff_result.changed_files)}[/dim]"
|
|
366
|
+
)
|
|
367
|
+
dom_str = ", ".join(
|
|
368
|
+
f"{d} ({s:.1f})"
|
|
369
|
+
for d, s in list(diff_result.activated_domains.items())[:8]
|
|
370
|
+
)
|
|
371
|
+
con.print(f" [dim]Activated domains: {dom_str}[/dim]")
|
|
372
|
+
coord.set_active_domains(list(diff_result.activated_domains.keys()))
|
|
373
|
+
else:
|
|
374
|
+
con.print(
|
|
375
|
+
" [dim]No domain-relevant changes detected — running full scan[/dim]"
|
|
376
|
+
)
|
|
377
|
+
except Exception as e:
|
|
378
|
+
_log.debug("Git-diff activation failed: %s", e)
|
|
379
|
+
|
|
380
|
+
agent_results = coord.run_all_scanners(scope=scope if area else None, side=side)
|
|
381
|
+
|
|
382
|
+
# ── Noise trim: license & extended are opt-in ─────────────
|
|
383
|
+
# Main scan stays focused; heavy/noisy audits are separate
|
|
384
|
+
# runs: `p scan --with-license` and `p scan --with-extended`
|
|
385
|
+
_license_types = {"copyleft_license", "unknown_license", "missing_license"}
|
|
386
|
+
_license_suppressed = 0
|
|
387
|
+
_extended_suppressed = 0
|
|
388
|
+
if not with_license:
|
|
389
|
+
for ar in agent_results:
|
|
390
|
+
before = len(ar.findings)
|
|
391
|
+
ar.findings = [
|
|
392
|
+
f for f in ar.findings
|
|
393
|
+
if f.type not in _license_types and "license" not in f.type.lower()
|
|
394
|
+
]
|
|
395
|
+
_license_suppressed += before - len(ar.findings)
|
|
396
|
+
if not with_extended:
|
|
397
|
+
# Extended = duplicate/hygiene heavy hitters that drown signal
|
|
398
|
+
# For now we keep them but count; future: skip those agents
|
|
399
|
+
pass
|
|
400
|
+
if _license_suppressed and not quiet:
|
|
401
|
+
con.print(
|
|
402
|
+
f"[dim] license findings suppressed: {_license_suppressed} "
|
|
403
|
+
"(run [cyan]p scan --with-license[/cyan] for full audit)[/dim]"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# ── Noise filter: discard low-value findings from tests/fixtures/locks/generated/docs
|
|
407
|
+
# This is the user-reported "1000s when 74 are real" fix — NoiseFilter was defined
|
|
408
|
+
# but never wired into the scan pipeline. We apply it here before any reporting.
|
|
409
|
+
try:
|
|
410
|
+
from patchi.core.security.noise_filter import NoiseFilter
|
|
411
|
+
|
|
412
|
+
nf = NoiseFilter(root=r, config={"noise_filter": {"enabled": True, "mode": "discard", "skip_tests": True, "skip_locks": True, "skip_generated": True, "skip_docs": True}})
|
|
413
|
+
_noise_before = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
|
|
414
|
+
_noise_by_cat: dict[str, int] = {}
|
|
415
|
+
for ar in agent_results:
|
|
416
|
+
kept, rep = nf.apply(getattr(ar, "findings", []))
|
|
417
|
+
ar.findings = kept # type: ignore
|
|
418
|
+
for cat, cnt in rep.by_category.items():
|
|
419
|
+
_noise_by_cat[cat] = _noise_by_cat.get(cat, 0) + cnt
|
|
420
|
+
_noise_after = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
|
|
421
|
+
_noise_discarded = _noise_before - _noise_after
|
|
422
|
+
if _noise_discarded and not quiet:
|
|
423
|
+
cats = ", ".join(f"{k}={v}" for k, v in sorted(_noise_by_cat.items()))
|
|
424
|
+
con.print(f"[dim] noise filtered: {_noise_discarded} discarded ({cats}) — real findings kept[/dim]")
|
|
425
|
+
except Exception as _exc:
|
|
426
|
+
_log.debug("noise filter failed: %s", _exc)
|
|
427
|
+
|
|
428
|
+
# ── Confidence gate: demote low-confidence medium/low (trust fix)
|
|
429
|
+
try:
|
|
430
|
+
from patchi.core.agents.base import Severity as _Sev
|
|
431
|
+
from patchi.core.security.confidence_gate import ConfidenceGate
|
|
432
|
+
from patchi.core.security.orchestrator import CorrelatedFinding
|
|
433
|
+
|
|
434
|
+
# Build a pseudo report for gating — we reuse the gate's scoring without re-running AI
|
|
435
|
+
_gate = ConfidenceGate(root=r, config={"confidence_gate": {"ai_weight": 0.0, "min_agents_for_defend": 1, "fp_auto_discard": False}})
|
|
436
|
+
_gate_before = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
|
|
437
|
+
for ar in agent_results:
|
|
438
|
+
new_findings = []
|
|
439
|
+
for f in getattr(ar, "findings", []):
|
|
440
|
+
# Build minimal CorrelatedFinding for scoring
|
|
441
|
+
cf = CorrelatedFinding(finding=f, confirmed_by=[f.agent], composite_score=0.0)
|
|
442
|
+
score = _gate._compute_score(cf) # type: ignore
|
|
443
|
+
tier = _gate._assign_tier(score)
|
|
444
|
+
routing = _gate._assign_routing(tier, cf)
|
|
445
|
+
# Trust fix: keep critical/high always; medium/low only if high confidence
|
|
446
|
+
if routing == "discard" or (tier == "low" and f.severity in (_Sev.MEDIUM, _Sev.LOW, _Sev.INFO)):
|
|
447
|
+
continue
|
|
448
|
+
new_findings.append(f)
|
|
449
|
+
ar.findings = new_findings # type: ignore
|
|
450
|
+
_gate_after = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
|
|
451
|
+
_gate_discarded = _gate_before - _gate_after
|
|
452
|
+
if _gate_discarded and not quiet:
|
|
453
|
+
con.print(f"[dim] confidence gate: {_gate_discarded} low-trust medium/low discarded[/dim]")
|
|
454
|
+
except Exception as _exc:
|
|
455
|
+
_log.debug("confidence gate skipped: %s", _exc)
|
|
456
|
+
|
|
457
|
+
# ── --since: keep only findings in files changed since git ref
|
|
458
|
+
if since:
|
|
459
|
+
try:
|
|
460
|
+
from patchi.core import ci_bundle
|
|
461
|
+
|
|
462
|
+
_since_before = sum(len(getattr(ar, "findings", [])) for ar in agent_results)
|
|
463
|
+
for ar in agent_results:
|
|
464
|
+
_dicts = [f.to_dict() for f in getattr(ar, "findings", [])]
|
|
465
|
+
_kept = ci_bundle.filter_since(_dicts, since, r)
|
|
466
|
+
_keep = {(d.get("file"), d.get("line"), d.get("type")) for d in _kept}
|
|
467
|
+
ar.findings = [ # type: ignore
|
|
468
|
+
f for f in getattr(ar, "findings", [])
|
|
469
|
+
if (f.file, f.line, f.type) in _keep
|
|
470
|
+
]
|
|
471
|
+
_since_cut = _since_before - sum(len(getattr(ar, "findings", [])) for ar in agent_results)
|
|
472
|
+
if not quiet:
|
|
473
|
+
con.print(f"[dim] --since {since}: {_since_cut} finding(s) outside changed files hidden[/dim]")
|
|
474
|
+
except Exception as _exc:
|
|
475
|
+
_log.debug("--since filter skipped: %s", _exc)
|
|
476
|
+
|
|
477
|
+
# ── Blame annotation §10.3.2 — who introduced each error and when
|
|
478
|
+
try:
|
|
479
|
+
from patchi.core.brain.git_aware import annotate_findings_with_blame
|
|
480
|
+
|
|
481
|
+
_blame_n = 0
|
|
482
|
+
for ar in agent_results:
|
|
483
|
+
_blame_n += annotate_findings_with_blame(getattr(ar, "findings", []), r, max_workers=4)
|
|
484
|
+
if _blame_n and not quiet:
|
|
485
|
+
con.print(f"[dim] blame: { _blame_n} findings annotated with git author/date[/dim]")
|
|
486
|
+
except Exception as _exc:
|
|
487
|
+
_log.debug("blame annotate skipped: %s", _exc)
|
|
488
|
+
|
|
489
|
+
# ── Ignore expiry §10.2.3 — patchi-ignore with date, warn on expired
|
|
490
|
+
try:
|
|
491
|
+
from patchi.core.security.ignore_expiry import filter_ignores
|
|
492
|
+
|
|
493
|
+
_all_findings = [f for ar in agent_results for f in getattr(ar, "findings", [])]
|
|
494
|
+
_kept, _supp, _exp = filter_ignores(_all_findings, r)
|
|
495
|
+
# Apply kept back to agents (preserve per-agent buckets for reporting)
|
|
496
|
+
_kept_ids = {id(f) for f in _kept}
|
|
497
|
+
for ar in agent_results:
|
|
498
|
+
ar.findings = [f for f in getattr(ar, "findings", []) if id(f) in _kept_ids] # type: ignore
|
|
499
|
+
if _exp and not quiet:
|
|
500
|
+
for w in _exp[:3]:
|
|
501
|
+
con.print(f"[yellow]expired ignore[/yellow] {w['file']}:{w['line']} {w['rule']} was {w['message']}")
|
|
502
|
+
if len(_exp) > 3:
|
|
503
|
+
con.print(f"[dim] +{len(_exp)-3} more expired ignores[/dim]")
|
|
504
|
+
if _supp and not quiet:
|
|
505
|
+
con.print(f"[dim] ignore: {len(_supp)} findings suppressed by patchi-ignore[/dim]")
|
|
506
|
+
except Exception as _exc:
|
|
507
|
+
_log.debug("ignore expiry skipped: %s", _exc)
|
|
508
|
+
|
|
509
|
+
# ── Self-profiling: record per-agent latency/cost ──────────
|
|
510
|
+
try:
|
|
511
|
+
from patchi.core.agents.coordinator import merge_results as _pmr
|
|
512
|
+
from patchi.core.ai.agent_profiler import record_run
|
|
513
|
+
|
|
514
|
+
_pm = _pmr(agent_results)
|
|
515
|
+
for ar in agent_results or []:
|
|
516
|
+
aname = getattr(ar, "agent_name", type(ar).__name__)
|
|
517
|
+
acount = getattr(ar, "finding_count", 0)
|
|
518
|
+
with record_run(r, aname, files_scanned=acount) as run:
|
|
519
|
+
run.findings_produced = acount
|
|
520
|
+
except Exception as _exc:
|
|
521
|
+
_log.debug("profiling skipped: %s", _exc)
|
|
522
|
+
|
|
523
|
+
# ── Attack feedback loop: feed findings into learning ──────
|
|
524
|
+
try:
|
|
525
|
+
from patchi.core.agents.coordinator import merge_results as _fbr
|
|
526
|
+
from patchi.core.security.attack_feedback import (
|
|
527
|
+
record_confirmed_attack,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
_fb = _fbr(agent_results)
|
|
531
|
+
for f in _fb.get("findings", []):
|
|
532
|
+
if f.get("severity") in ("critical", "high"):
|
|
533
|
+
record_confirmed_attack(
|
|
534
|
+
r,
|
|
535
|
+
{
|
|
536
|
+
"tool": f.get("agent", "unknown"),
|
|
537
|
+
"payload": f.get("message", ""),
|
|
538
|
+
"endpoint": f.get("file", ""),
|
|
539
|
+
"severity": f.get("severity", "medium"),
|
|
540
|
+
"evidence": f.get("message", ""),
|
|
541
|
+
},
|
|
542
|
+
)
|
|
543
|
+
except Exception as _exc:
|
|
544
|
+
_log.debug("attack feedback skipped: %s", _exc)
|
|
545
|
+
|
|
546
|
+
# Mark all tasks complete
|
|
547
|
+
for tid in tasks.values():
|
|
548
|
+
progress.update(tid, completed=100, total=100)
|
|
549
|
+
progress.update(agents_task, completed=len(agent_results), total=len(agent_results))
|
|
550
|
+
|
|
551
|
+
except Exception as e:
|
|
552
|
+
import traceback
|
|
553
|
+
|
|
554
|
+
error = f"{e}\n{traceback.format_exc()}"
|
|
555
|
+
|
|
556
|
+
if _is_tty:
|
|
557
|
+
with Live(progress, console=con, refresh_per_second=10):
|
|
558
|
+
_run_scan()
|
|
559
|
+
else:
|
|
560
|
+
_run_scan()
|
|
561
|
+
|
|
562
|
+
if error:
|
|
563
|
+
con.print(f"\n[red]Scan failed:[/red] {error}")
|
|
564
|
+
return 2
|
|
565
|
+
|
|
566
|
+
# ── Deep scan processing ──────────────────────────────────────────────────
|
|
567
|
+
if deep:
|
|
568
|
+
_run_deep_scan_analysis(r, report, agent_results)
|
|
569
|
+
|
|
570
|
+
if report is None:
|
|
571
|
+
con.print("\n[red]Scan returned no results.[/red]")
|
|
572
|
+
return 2
|
|
573
|
+
|
|
574
|
+
# ── Results summary ───────────────────────────────────────────────────────
|
|
575
|
+
con.print()
|
|
576
|
+
_scan_elapsed = time.monotonic() - _scan_start
|
|
577
|
+
_show_report_summary(report, agent_results, wall_time=_scan_elapsed, root=r)
|
|
578
|
+
|
|
579
|
+
# ── Threat Model Generation (auto-updated from findings) ────────────────
|
|
580
|
+
try:
|
|
581
|
+
from patchi.core.agents.coordinator import merge_results as _mr
|
|
582
|
+
from patchi.core.security.threat_model_updater import update_threat_model
|
|
583
|
+
|
|
584
|
+
_findings_for_tm = _mr(agent_results).get("findings", []) if agent_results else []
|
|
585
|
+
threat_model = update_threat_model(r, _findings_for_tm)
|
|
586
|
+
if threat_model.applicable_scenarios > 0:
|
|
587
|
+
con.print()
|
|
588
|
+
con.print("[bold #C8621A]─ Threat Model ─[/bold #C8621A]")
|
|
589
|
+
con.print(
|
|
590
|
+
f" Scenarios: [bold]{threat_model.applicable_scenarios}[/bold] applicable "
|
|
591
|
+
f"out of {threat_model.total_scenarios} total"
|
|
592
|
+
)
|
|
593
|
+
if threat_model.by_severity:
|
|
594
|
+
sev_str = ", ".join(f"{k}={v}" for k, v in sorted(threat_model.by_severity.items()))
|
|
595
|
+
con.print(f" By severity: {sev_str}")
|
|
596
|
+
if threat_model.recommendations:
|
|
597
|
+
for rec in threat_model.recommendations[:3]:
|
|
598
|
+
con.print(f" [dim]• {rec}[/dim]")
|
|
599
|
+
# Persist for web UI and assurance
|
|
600
|
+
tm_path = r / ".patchi" / "threat_model.json"
|
|
601
|
+
tm_path.parent.mkdir(parents=True, exist_ok=True)
|
|
602
|
+
import json as _json
|
|
603
|
+
|
|
604
|
+
tm_path.write_text(_json.dumps(threat_model.to_dict(), indent=2), encoding="utf-8")
|
|
605
|
+
except Exception as e:
|
|
606
|
+
_log.debug("Threat model generation failed: %s", e)
|
|
607
|
+
|
|
608
|
+
# ── Chain & Intent Analysis ─────────────────────────────────────────────
|
|
609
|
+
try:
|
|
610
|
+
from patchi.core.agents.base import list_agents as _la
|
|
611
|
+
|
|
612
|
+
_sec_names = {a.name for a in _la(AgentGroup.SECURITY)}
|
|
613
|
+
_sec_agents = [
|
|
614
|
+
a for a in (agent_results or []) if getattr(a, "agent_name", "") in _sec_names
|
|
615
|
+
]
|
|
616
|
+
if _sec_agents:
|
|
617
|
+
from patchi.core.security.orchestrator import SecurityOrchestrator
|
|
618
|
+
|
|
619
|
+
_sec_report = SecurityOrchestrator().correlate(_sec_agents)
|
|
620
|
+
|
|
621
|
+
# ── Exploit Chains ──────────────────────────────────────
|
|
622
|
+
if _sec_report.chains:
|
|
623
|
+
con.print()
|
|
624
|
+
con.print("[bold #C8621A]─ Exploit Chains ─[/bold #C8621A]")
|
|
625
|
+
con.print(
|
|
626
|
+
f" [bold]{len(_sec_report.chains)}[/bold] cross-file attack paths discovered"
|
|
627
|
+
)
|
|
628
|
+
for chain in _sec_report.chains[:5]:
|
|
629
|
+
sev_color = {"critical": "red", "high": "red", "medium": "yellow"}.get(
|
|
630
|
+
chain.severity, "dim"
|
|
631
|
+
)
|
|
632
|
+
con.print(
|
|
633
|
+
f" [{sev_color}]●[{chain.severity}] score={chain.score:.0f} "
|
|
634
|
+
f"length={chain.length}[/{sev_color}]"
|
|
635
|
+
)
|
|
636
|
+
con.print(f" [dim]{chain.narrative[:100]}[/dim]")
|
|
637
|
+
if len(_sec_report.chains) > 5:
|
|
638
|
+
con.print(f" [dim]… and {len(_sec_report.chains) - 5} more[/dim]")
|
|
639
|
+
|
|
640
|
+
# ── Intent Gaps ─────────────────────────────────────────
|
|
641
|
+
intent = _sec_report.intent_report
|
|
642
|
+
if intent and intent.gap_count > 0:
|
|
643
|
+
con.print()
|
|
644
|
+
con.print("[bold #C8621A]─ Intent Gaps ─[/bold #C8621A]")
|
|
645
|
+
con.print(
|
|
646
|
+
f" [bold]{intent.gap_count}[/bold] logic gaps across "
|
|
647
|
+
f"[bold]{len(intent.routes)}[/bold] routes"
|
|
648
|
+
)
|
|
649
|
+
if intent.unauthenticated_state_changing:
|
|
650
|
+
con.print(
|
|
651
|
+
f" [red]● {len(intent.unauthenticated_state_changing)}[/red] "
|
|
652
|
+
f"state-changing routes without auth"
|
|
653
|
+
)
|
|
654
|
+
for r in intent.unauthenticated_state_changing[:3]:
|
|
655
|
+
con.print(f" [dim]{r.method} {r.path} @ {r.file}:{r.line}[/dim]")
|
|
656
|
+
if intent.admin_without_strict_guard:
|
|
657
|
+
con.print(
|
|
658
|
+
f" [yellow]● {len(intent.admin_without_strict_guard)}[/yellow] "
|
|
659
|
+
f"admin routes without strict guard"
|
|
660
|
+
)
|
|
661
|
+
for r in intent.admin_without_strict_guard[:3]:
|
|
662
|
+
con.print(f" [dim]{r.method} {r.path} @ {r.file}:{r.line}[/dim]")
|
|
663
|
+
if intent.unprotected_among_protected:
|
|
664
|
+
con.print(
|
|
665
|
+
f" [yellow]● {len(intent.unprotected_among_protected)}[/yellow] "
|
|
666
|
+
f"unprotected routes among protected peers"
|
|
667
|
+
)
|
|
668
|
+
for r in intent.unprotected_among_protected[:3]:
|
|
669
|
+
con.print(f" [dim]{r.method} {r.path} @ {r.file}:{r.line}[/dim]")
|
|
670
|
+
|
|
671
|
+
if _sec_report.charter_violations:
|
|
672
|
+
con.print(
|
|
673
|
+
f" [bold]{len(_sec_report.charter_violations)}[/bold] "
|
|
674
|
+
f"[yellow]charter violation(s)[/yellow]"
|
|
675
|
+
)
|
|
676
|
+
for cv in _sec_report.charter_violations[:5]:
|
|
677
|
+
sev = cv.get("severity", "medium")
|
|
678
|
+
con.print(
|
|
679
|
+
f" [yellow]● [{sev}] {cv.get('rule_id', '?')}[/yellow]: "
|
|
680
|
+
f"{cv.get('message', '')}"
|
|
681
|
+
)
|
|
682
|
+
if cv.get("suggestion"):
|
|
683
|
+
con.print(f" [dim]→ {cv['suggestion']}[/dim]")
|
|
684
|
+
if len(_sec_report.charter_violations) > 5:
|
|
685
|
+
con.print(
|
|
686
|
+
f" [dim]… and {len(_sec_report.charter_violations) - 5} more[/dim]"
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# Persist for web UI
|
|
690
|
+
import json as _cjson
|
|
691
|
+
|
|
692
|
+
_ci_path = r / ".patchi" / "chain_intent.json"
|
|
693
|
+
_ci_path.parent.mkdir(parents=True, exist_ok=True)
|
|
694
|
+
_ci_path.write_text(_cjson.dumps(_sec_report.to_dict(), indent=2), encoding="utf-8")
|
|
695
|
+
|
|
696
|
+
# ── Feed chains + intent into assurance graph ──────────────
|
|
697
|
+
try:
|
|
698
|
+
from patchi.core.security.chain_to_assurance import feed_chains_to_graph
|
|
699
|
+
|
|
700
|
+
_fed = feed_chains_to_graph(r)
|
|
701
|
+
if _fed:
|
|
702
|
+
con.print(f" [dim]Fed {_fed} evidence items into assurance graph[/dim]")
|
|
703
|
+
except Exception as e:
|
|
704
|
+
_log.debug("Chain-to-assurance bridge failed: %s", e)
|
|
705
|
+
|
|
706
|
+
except Exception as e:
|
|
707
|
+
_log.debug("Chain/intent analysis failed: %s", e)
|
|
708
|
+
|
|
709
|
+
# ── Assurance analysis (attackers, campaigns, fuzz) ─────────────────────
|
|
710
|
+
if with_attackers or with_campaigns or with_fuzz:
|
|
711
|
+
con.print()
|
|
712
|
+
con.print("[bold #C8621A]─ Assurance Analysis ─[/bold #C8621A]")
|
|
713
|
+
try:
|
|
714
|
+
from patchi.core.assurance.graph import AssuranceGraph
|
|
715
|
+
|
|
716
|
+
agraph = AssuranceGraph.load(r)
|
|
717
|
+
if not agraph.claims:
|
|
718
|
+
con.print(
|
|
719
|
+
" [dim]No assurance graph found — run 'p scan' first to build one.[/dim]"
|
|
720
|
+
)
|
|
721
|
+
else:
|
|
722
|
+
con.print(f" [dim]Loaded assurance graph: {len(agraph.claims)} claims[/dim]")
|
|
723
|
+
|
|
724
|
+
# ── Campaigns ────────────────────────────────────────────
|
|
725
|
+
if with_campaigns:
|
|
726
|
+
from patchi.core.campaigns import CampaignOrchestrator
|
|
727
|
+
|
|
728
|
+
orch = CampaignOrchestrator(agraph)
|
|
729
|
+
campaign_result = orch.run_all()
|
|
730
|
+
con.print(f" Campaigns: [bold]{len(campaign_result.campaigns)}[/bold] run")
|
|
731
|
+
for cr in campaign_result.campaigns:
|
|
732
|
+
status = (
|
|
733
|
+
"[green]PASS[/green]"
|
|
734
|
+
if cr.total_findings == 0
|
|
735
|
+
else f"[yellow]{cr.total_findings} findings[/yellow]"
|
|
736
|
+
)
|
|
737
|
+
con.print(f" {cr.name}: {status}")
|
|
738
|
+
for step in cr.steps:
|
|
739
|
+
if step.findings:
|
|
740
|
+
for f in step.findings:
|
|
741
|
+
sev = f.get("severity", "info")
|
|
742
|
+
con.print(f" [{sev}] {f.get('detail', '')[:80]}")
|
|
743
|
+
|
|
744
|
+
# ── Attackers ────────────────────────────────────────────
|
|
745
|
+
if with_attackers:
|
|
746
|
+
from patchi.core.attackers import AttackPlanner
|
|
747
|
+
|
|
748
|
+
planner = AttackPlanner(agraph)
|
|
749
|
+
attack_results = planner.run_all()
|
|
750
|
+
confirmed = [r for r in attack_results if r.confirmed]
|
|
751
|
+
con.print(
|
|
752
|
+
f" Attackers: [bold]{len(attack_results)}[/bold] hypotheses tested, "
|
|
753
|
+
f"[bold]{len(confirmed)}[/bold] confirmed"
|
|
754
|
+
)
|
|
755
|
+
for r in confirmed[:10]:
|
|
756
|
+
con.print(f" [red]●[/red] {r.hypothesis.attacker}: {r.evidence[:70]}")
|
|
757
|
+
|
|
758
|
+
# ── Fuzz ─────────────────────────────────────────────────
|
|
759
|
+
if with_fuzz:
|
|
760
|
+
from patchi.core.fuzz import InputFuzzer
|
|
761
|
+
|
|
762
|
+
fuzzer = InputFuzzer(seed=42)
|
|
763
|
+
# Fuzz route parameters
|
|
764
|
+
route_finds = 0
|
|
765
|
+
for claim in agraph.claims.values():
|
|
766
|
+
if "endpoint" in claim.domain:
|
|
767
|
+
route_finds += 1
|
|
768
|
+
con.print(f" Fuzz: [bold]{route_finds}[/bold] endpoints available for fuzzing")
|
|
769
|
+
if route_finds > 0:
|
|
770
|
+
sample = fuzzer.fuzz_string("test", count=5)
|
|
771
|
+
con.print(f" Generated {len(sample)} sample mutations")
|
|
772
|
+
|
|
773
|
+
except Exception as e:
|
|
774
|
+
import traceback
|
|
775
|
+
|
|
776
|
+
con.print(f" [red]Assurance analysis error: {e}[/red]")
|
|
777
|
+
con.print(traceback.format_exc())
|
|
778
|
+
|
|
779
|
+
# ── Red Team Engine (live attack simulation) ─────────────────────────────
|
|
780
|
+
if red_team:
|
|
781
|
+
con.print()
|
|
782
|
+
con.print("[bold #C8621A]─ Red Team Engine ─[/bold #C8621A]")
|
|
783
|
+
try:
|
|
784
|
+
from patchi.core.security.red_team_engine import RedTeamEngine
|
|
785
|
+
|
|
786
|
+
# Launcher-provided base_url first (config port, then dev ports)
|
|
787
|
+
target_url = None
|
|
788
|
+
try:
|
|
789
|
+
from patchi.core.testing._browser import find_server
|
|
790
|
+
|
|
791
|
+
target_url = find_server(r, {}, None)
|
|
792
|
+
except Exception as exc: # noqa: BLE001
|
|
793
|
+
_log.debug("red-team target resolve failed: %s", exc)
|
|
794
|
+
if target_url:
|
|
795
|
+
con.print(f" [dim]Target: {target_url} (detected running server)[/dim]")
|
|
796
|
+
else:
|
|
797
|
+
con.print(" [dim]No running web server detected — running code-only attacks[/dim]")
|
|
798
|
+
|
|
799
|
+
engine = RedTeamEngine(
|
|
800
|
+
root=r,
|
|
801
|
+
target_url=target_url,
|
|
802
|
+
safe_mode=True,
|
|
803
|
+
on_progress=lambda msg: con.print(f" [dim]{msg}[/dim]"),
|
|
804
|
+
)
|
|
805
|
+
import asyncio
|
|
806
|
+
|
|
807
|
+
report = asyncio.run(
|
|
808
|
+
engine.run_assessment(
|
|
809
|
+
scope="full",
|
|
810
|
+
intensity="standard",
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
con.print(f" Scenarios run: [bold]{len(report.scenarios_run)}[/bold]")
|
|
814
|
+
con.print(f" Findings: [bold]{report.total_findings}[/bold]")
|
|
815
|
+
if report.by_severity:
|
|
816
|
+
sev_str = ", ".join(f"{k}={v}" for k, v in sorted(report.by_severity.items()))
|
|
817
|
+
con.print(f" By severity: {sev_str}")
|
|
818
|
+
if report.remediation_playbooks:
|
|
819
|
+
con.print(f" Playbooks: [bold]{len(report.remediation_playbooks)}[/bold]")
|
|
820
|
+
|
|
821
|
+
# Auto-fix confirmed findings
|
|
822
|
+
if report.total_findings > 0:
|
|
823
|
+
con.print("\n [dim]Generating fixes for confirmed findings...[/dim]")
|
|
824
|
+
from patchi.core.security.auto_fixer import AutoFixer
|
|
825
|
+
|
|
826
|
+
fixer = AutoFixer(
|
|
827
|
+
r, cfg.load(r), on_progress=lambda msg: con.print(f" [dim]{msg}[/dim]")
|
|
828
|
+
)
|
|
829
|
+
for scenario in report.scenarios_run:
|
|
830
|
+
for finding in scenario.findings:
|
|
831
|
+
import asyncio
|
|
832
|
+
|
|
833
|
+
result = asyncio.run(
|
|
834
|
+
fixer.fix_finding(finding, strategy="auto", apply=False, verify=False)
|
|
835
|
+
)
|
|
836
|
+
if result.get("success"):
|
|
837
|
+
con.print(
|
|
838
|
+
f" [green]Fixed[/green] {finding.type} → patch {result['patch_id']}"
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
except Exception as e:
|
|
842
|
+
import traceback
|
|
843
|
+
|
|
844
|
+
con.print(f" [red]Red team error: {e}[/red]")
|
|
845
|
+
con.print(traceback.format_exc())
|
|
846
|
+
|
|
847
|
+
# ── DAST (Dynamic Application Security Testing) ──────────────────────────
|
|
848
|
+
if dast:
|
|
849
|
+
con.print()
|
|
850
|
+
con.print("[bold #C8621A]─ DAST Scanner ─[/bold #C8621A]")
|
|
851
|
+
try:
|
|
852
|
+
import asyncio
|
|
853
|
+
|
|
854
|
+
from patchi.core.security.dast_scanner import DastScanner
|
|
855
|
+
|
|
856
|
+
# Auto-detect target URL
|
|
857
|
+
target_url = None
|
|
858
|
+
try:
|
|
859
|
+
import urllib.request
|
|
860
|
+
|
|
861
|
+
urllib.request.urlopen("http://127.0.0.1:1612/api/health", timeout=2)
|
|
862
|
+
target_url = "http://127.0.0.1:1612"
|
|
863
|
+
con.print(f" [dim]Target: {target_url} (detected running server)[/dim]")
|
|
864
|
+
except Exception:
|
|
865
|
+
con.print(" [yellow]No running web server detected on :1612[/yellow]")
|
|
866
|
+
con.print(" [dim]Start the web server first: p web[/dim]")
|
|
867
|
+
|
|
868
|
+
if target_url:
|
|
869
|
+
scanner = DastScanner(
|
|
870
|
+
root=r,
|
|
871
|
+
target_url=target_url,
|
|
872
|
+
on_progress=lambda msg: con.print(f" [dim]{msg}[/dim]"),
|
|
873
|
+
)
|
|
874
|
+
report = asyncio.run(scanner.run())
|
|
875
|
+
|
|
876
|
+
con.print(f" Pages tested: [bold]{report.pages_tested}[/bold]")
|
|
877
|
+
con.print(f" Findings: [bold]{len(report.findings)}[/bold]")
|
|
878
|
+
con.print(f" Screenshots: [bold]{len(report.screenshots)}[/bold]")
|
|
879
|
+
|
|
880
|
+
if report.findings:
|
|
881
|
+
sev_counts = {}
|
|
882
|
+
for f in report.findings:
|
|
883
|
+
sev_counts[f.severity] = sev_counts.get(f.severity, 0) + 1
|
|
884
|
+
sev_str = ", ".join(f"{k}={v}" for k, v in sorted(sev_counts.items()))
|
|
885
|
+
con.print(f" By severity: {sev_str}")
|
|
886
|
+
con.print()
|
|
887
|
+
for f in report.findings[:10]:
|
|
888
|
+
sev_color = {
|
|
889
|
+
"critical": "red",
|
|
890
|
+
"high": "red",
|
|
891
|
+
"medium": "yellow",
|
|
892
|
+
"low": "dim",
|
|
893
|
+
}.get(f.severity, "dim")
|
|
894
|
+
con.print(
|
|
895
|
+
f" [{sev_color}] [{f.severity}] {f.test}: {f.evidence[:60]}[/{sev_color}]"
|
|
896
|
+
)
|
|
897
|
+
else:
|
|
898
|
+
con.print(" [green]No security issues found.[/green]")
|
|
899
|
+
|
|
900
|
+
if report.errors:
|
|
901
|
+
con.print(f" [dim]Errors: {len(report.errors)}[/dim]")
|
|
902
|
+
|
|
903
|
+
except Exception as e:
|
|
904
|
+
import traceback
|
|
905
|
+
|
|
906
|
+
con.print(f" [red]DAST error: {e}[/red]")
|
|
907
|
+
con.print(traceback.format_exc())
|
|
908
|
+
|
|
909
|
+
# ── Domain enrichment (always runs) ────────────────────────────────────
|
|
910
|
+
try:
|
|
911
|
+
from patchi.core.security.domain_loader import DomainLoader
|
|
912
|
+
# Use pre-loaded DomainLoader if available, else create new
|
|
913
|
+
_dl = None
|
|
914
|
+
if _domain_loader_future is not None:
|
|
915
|
+
try:
|
|
916
|
+
_dl = _domain_loader_future.result(timeout=0)
|
|
917
|
+
except Exception as _exc:
|
|
918
|
+
_log.debug('suppressed: %s', _exc)
|
|
919
|
+
if _dl is None:
|
|
920
|
+
_dl = DomainLoader(r, component_types=_ctypes if _ctypes else None)
|
|
921
|
+
_SEC_AGENTS = {
|
|
922
|
+
"EnvScanner", "SideFileScanner", "CoreScanner",
|
|
923
|
+
"DependencyScanner", "RouteGraphScanner", "SBOMGeneratorAgent",
|
|
924
|
+
"CommentScanner", "DeadCodeScanner", "DeadCodeHygieneAgent",
|
|
925
|
+
}
|
|
926
|
+
for _ar in (agent_results or []):
|
|
927
|
+
_aname = getattr(_ar, "agent_name", "") or ""
|
|
928
|
+
for _f in getattr(_ar, "findings", []):
|
|
929
|
+
# Skip enrichment for agents that never produce security findings
|
|
930
|
+
_fagent = getattr(_f, "agent", "") or _aname
|
|
931
|
+
if _fagent not in _SEC_AGENTS:
|
|
932
|
+
continue
|
|
933
|
+
_msg = getattr(_f, "message", "") or ""
|
|
934
|
+
_file = getattr(_f, "file", "") or ""
|
|
935
|
+
_type = getattr(_f, "type", "") or _fagent
|
|
936
|
+
_ctrls = _dl.match_finding_to_controls(_type, _file, _msg)
|
|
937
|
+
if _ctrls:
|
|
938
|
+
_f.extra["domain_controls"] = [
|
|
939
|
+
{"control_id": c.control_id, "name": c.name, "severity": c.severity}
|
|
940
|
+
for c in _ctrls[:5]
|
|
941
|
+
]
|
|
942
|
+
_pb = _dl.get_playbook(_ctrls[0].control_id)
|
|
943
|
+
if _pb:
|
|
944
|
+
_f.extra["playbook_ref"] = _pb.control_id
|
|
945
|
+
_f.extra["fix_strategy"] = _pb.fix_strategy
|
|
946
|
+
except Exception as _e:
|
|
947
|
+
import logging
|
|
948
|
+
logging.getLogger("patchi.scan").debug("Domain enrichment skipped: %s", _e)
|
|
949
|
+
|
|
950
|
+
# ── Pipeline / defense mode ───────────────────────────────────────────────
|
|
951
|
+
if pipeline:
|
|
952
|
+
con.print()
|
|
953
|
+
con.print("[bold #C8621A]─ Defense Pipeline ─[/bold #C8621A]")
|
|
954
|
+
try:
|
|
955
|
+
from patchi.core.security.defenders import ADAPTER_REGISTRY, DefenseAction, get_adapter
|
|
956
|
+
from patchi.core.security.defense_layer import DefenseLayer
|
|
957
|
+
from patchi.core.security.detection_pipeline import DetectionPipeline
|
|
958
|
+
from patchi.core.security.orchestrator import SecurityOrchestrator
|
|
959
|
+
|
|
960
|
+
con.print(f" [dim]Adapter registry: {len(ADAPTER_REGISTRY)} adapters loaded[/dim]")
|
|
961
|
+
|
|
962
|
+
sec_group_names = {a.name for a in list_agents(AgentGroup.SECURITY)}
|
|
963
|
+
sec_group_names.update(
|
|
964
|
+
{
|
|
965
|
+
"DependencyScanner",
|
|
966
|
+
"EnvScanner",
|
|
967
|
+
"SideFileScanner",
|
|
968
|
+
}
|
|
969
|
+
)
|
|
970
|
+
sec_agents = [
|
|
971
|
+
a for a in agent_results if getattr(a, "agent_name", "") in sec_group_names
|
|
972
|
+
]
|
|
973
|
+
report_sec = SecurityOrchestrator().correlate(sec_agents)
|
|
974
|
+
pipeline_inst = DetectionPipeline(r, cfg.load(r))
|
|
975
|
+
gated = pipeline_inst.process(report_sec)
|
|
976
|
+
con.print(
|
|
977
|
+
f" Findings gated: [bold]{len(gated.findings)}[/bold] "
|
|
978
|
+
f"(defend={len(gated.defend)}, "
|
|
979
|
+
f"ai_analyze={len(gated.ai_analyze)}, "
|
|
980
|
+
f"human_review={len(gated.human_review)}, "
|
|
981
|
+
f"discarded={len(gated.discarded)})"
|
|
982
|
+
)
|
|
983
|
+
noise_stats = gated.stats.get("noise")
|
|
984
|
+
if noise_stats:
|
|
985
|
+
cats = ", ".join(
|
|
986
|
+
f"{k}={v}" for k, v in sorted(noise_stats.get("by_category", {}).items())
|
|
987
|
+
)
|
|
988
|
+
con.print(
|
|
989
|
+
f" Noise muted: [bold]{noise_stats.get('capped', 0)}[/bold] capped, "
|
|
990
|
+
f"[bold]{noise_stats.get('discarded', 0)}[/bold] discarded"
|
|
991
|
+
+ (f" ({cats})" if cats else "")
|
|
992
|
+
)
|
|
993
|
+
if gated.defend:
|
|
994
|
+
# Use adapter registry directly for each finding
|
|
995
|
+
from patchi.core.fix.risk_gate import RiskGate
|
|
996
|
+
|
|
997
|
+
risk_gate = RiskGate(r)
|
|
998
|
+
results = []
|
|
999
|
+
action_counts = {"applied": 0, "queued": 0, "blocked": 0, "skipped": 0}
|
|
1000
|
+
for gf in gated.defend:
|
|
1001
|
+
f = gf.finding
|
|
1002
|
+
ftype = f.type.lower()
|
|
1003
|
+
# Map finding type to action type
|
|
1004
|
+
action_type = None
|
|
1005
|
+
for key, val in DefenseLayer._finding_to_action_map().items():
|
|
1006
|
+
if key in ftype or ftype in key:
|
|
1007
|
+
action_type = val
|
|
1008
|
+
break
|
|
1009
|
+
if action_type is None:
|
|
1010
|
+
action_type = "escalate"
|
|
1011
|
+
|
|
1012
|
+
target = f.file
|
|
1013
|
+
if action_type == "block_ip":
|
|
1014
|
+
target = (
|
|
1015
|
+
f.extra.get("ip", "")
|
|
1016
|
+
if hasattr(f, "extra") and isinstance(f.extra, dict)
|
|
1017
|
+
else ""
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
action = DefenseAction(
|
|
1021
|
+
type=action_type,
|
|
1022
|
+
target=target,
|
|
1023
|
+
finding=f,
|
|
1024
|
+
severity=f.severity.value
|
|
1025
|
+
if hasattr(f.severity, "value")
|
|
1026
|
+
else str(f.severity),
|
|
1027
|
+
fix_code=f.suggestion or "",
|
|
1028
|
+
)
|
|
1029
|
+
adapter = get_adapter(action_type, root=r, risk_gate=risk_gate)
|
|
1030
|
+
result = adapter.execute(action)
|
|
1031
|
+
results.append(result)
|
|
1032
|
+
action_counts[result.action] = action_counts.get(result.action, 0) + 1
|
|
1033
|
+
|
|
1034
|
+
con.print(
|
|
1035
|
+
f" Defense actions: [bold]{action_counts['applied']}[/bold] applied, "
|
|
1036
|
+
f"[bold]{action_counts['queued']}[/bold] queued, "
|
|
1037
|
+
f"[bold]{action_counts['blocked']}[/bold] blocked, "
|
|
1038
|
+
f"[bold]{action_counts['skipped']}[/bold] skipped"
|
|
1039
|
+
)
|
|
1040
|
+
for d in results:
|
|
1041
|
+
if d.action == "applied":
|
|
1042
|
+
adapter_name = type(d.defense_action).__name__ if d.defense_action else "?"
|
|
1043
|
+
con.print(
|
|
1044
|
+
f" [green]✓[/green] {d.defense_action.type} → {d.defense_action.target} (via {adapter_name})"
|
|
1045
|
+
)
|
|
1046
|
+
elif d.action == "queued":
|
|
1047
|
+
con.print(
|
|
1048
|
+
f" [yellow]⏳[/yellow] {d.defense_action.type} → queued for review"
|
|
1049
|
+
)
|
|
1050
|
+
else:
|
|
1051
|
+
con.print(" [dim]No actionable defense findings.[/dim]")
|
|
1052
|
+
except Exception as e:
|
|
1053
|
+
import traceback
|
|
1054
|
+
|
|
1055
|
+
con.print(f" [red]Pipeline error: {e}[/red]")
|
|
1056
|
+
con.print(traceback.format_exc())
|
|
1057
|
+
|
|
1058
|
+
# ── Daemon mode ───────────────────────────────────────────────────────────
|
|
1059
|
+
if daemon:
|
|
1060
|
+
con.print()
|
|
1061
|
+
con.print("[bold #C8621A]─ Scan Scheduler Daemon ─[/bold #C8621A]")
|
|
1062
|
+
try:
|
|
1063
|
+
from patchi.core.security.scheduler import ScanScheduler
|
|
1064
|
+
|
|
1065
|
+
scheduler = ScanScheduler(r, cfg.load(r), on_result=lambda res: None)
|
|
1066
|
+
scheduler.start()
|
|
1067
|
+
if scheduler.is_running:
|
|
1068
|
+
con.print(
|
|
1069
|
+
f" [green]✓[/green] Scheduler started with {len(scheduler._agents)} security agents"
|
|
1070
|
+
)
|
|
1071
|
+
con.print(
|
|
1072
|
+
f" [dim]Default interval: {cfg.load(r).get('pipeline', {}).get('scheduler', {}).get('intervals', {}).get('default', '1h')}[/dim]"
|
|
1073
|
+
)
|
|
1074
|
+
con.print(
|
|
1075
|
+
" [dim]Use [bold]p hosted daemon[/bold] for production daemon mode[/dim]"
|
|
1076
|
+
)
|
|
1077
|
+
else:
|
|
1078
|
+
con.print(
|
|
1079
|
+
" [yellow]Scheduler not enabled (pipeline.scheduler.enabled=false)[/yellow]"
|
|
1080
|
+
)
|
|
1081
|
+
except Exception as e:
|
|
1082
|
+
import traceback
|
|
1083
|
+
|
|
1084
|
+
con.print(f" [red]Daemon error: {e}[/red]")
|
|
1085
|
+
con.print(traceback.format_exc())
|
|
1086
|
+
|
|
1087
|
+
# ── Governor v2 pipeline ─────────────────────────────────────────────────
|
|
1088
|
+
if governor:
|
|
1089
|
+
from patchi.core.agents.governor import Governor
|
|
1090
|
+
|
|
1091
|
+
con.print()
|
|
1092
|
+
con.print("[bold #C8621A]─ Governor v2 Pipeline ─[/bold #C8621A]")
|
|
1093
|
+
gov = Governor(r)
|
|
1094
|
+
try:
|
|
1095
|
+
results = gov.run_full_pipeline_v2()
|
|
1096
|
+
for pr in results:
|
|
1097
|
+
status_style = "#4ADE80" if pr.passed else "#FF4D6D"
|
|
1098
|
+
con.print(
|
|
1099
|
+
f" {pr.phase.value}: [bold {status_style}]{pr.status.value}[/bold {status_style}]"
|
|
1100
|
+
f" [dim]{pr.duration_ms}ms {pr.findings_count} findings {pr.agents_run} agents[/dim]"
|
|
1101
|
+
)
|
|
1102
|
+
if pr.errors:
|
|
1103
|
+
for err in pr.errors[:3]:
|
|
1104
|
+
con.print(f" [dim] {err}[/dim]")
|
|
1105
|
+
con.print(
|
|
1106
|
+
f" [bold]Pipeline {'[#4ADE80]PASSED[/#4ADE80]' if any(r.passed for r in results) else '[#FF4D6D]FAILED[/#FF4D6D]'}[/bold]"
|
|
1107
|
+
)
|
|
1108
|
+
except Exception as e:
|
|
1109
|
+
import traceback
|
|
1110
|
+
|
|
1111
|
+
con.print(f" [red]Governor pipeline error: {e}[/red]")
|
|
1112
|
+
con.print(traceback.format_exc())
|
|
1113
|
+
finally:
|
|
1114
|
+
gov.close()
|
|
1115
|
+
|
|
1116
|
+
# ── Contract confirmation ─────────────────────────────────────────────────
|
|
1117
|
+
import sys
|
|
1118
|
+
|
|
1119
|
+
is_interactive = sys.stdin.isatty() if hasattr(sys.stdin, "isatty") else False
|
|
1120
|
+
|
|
1121
|
+
if contract and is_interactive and report.inferred_flows:
|
|
1122
|
+
_run_contract_confirmation(r, report, all_flows=all_flows)
|
|
1123
|
+
|
|
1124
|
+
elif report.inferred_flows:
|
|
1125
|
+
# New flows inferred that aren't yet confirmed
|
|
1126
|
+
new_flows = [
|
|
1127
|
+
f for f in report.inferred_flows if f.id not in {cf.id for cf in report.confirmed_flows}
|
|
1128
|
+
]
|
|
1129
|
+
if new_flows:
|
|
1130
|
+
con.print()
|
|
1131
|
+
con.print(
|
|
1132
|
+
f"[yellow]![/yellow] [dim]{len(new_flows)} new potential critical flow(s) found.[/dim]"
|
|
1133
|
+
)
|
|
1134
|
+
con.print("[dim]Run [bold]p scan --contract[/bold] to review and confirm.[/dim]")
|
|
1135
|
+
|
|
1136
|
+
# ── Doc validation ────────────────────────────────────────────────────────
|
|
1137
|
+
dv = report.doc_validation or {}
|
|
1138
|
+
if dv.get("total_claims", 0) > 0:
|
|
1139
|
+
stale = len(dv.get("stale_claims", []))
|
|
1140
|
+
validated = len(dv.get("validated_claims", []))
|
|
1141
|
+
total = dv["total_claims"]
|
|
1142
|
+
doc_files = len(dv.get("doc_files_found", []))
|
|
1143
|
+
if stale > 0:
|
|
1144
|
+
con.print(
|
|
1145
|
+
f"[yellow]![/yellow] [dim]{stale}/{total} doc claim(s) stale — "
|
|
1146
|
+
f"docs say it but code doesn't have it[/dim]"
|
|
1147
|
+
)
|
|
1148
|
+
else:
|
|
1149
|
+
con.print(
|
|
1150
|
+
f"[dim]{validated}/{total} doc claim(s) verified across {doc_files} file(s)[/dim]"
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
# ── Health score ──────────────────────────────────────────────────────────
|
|
1154
|
+
health_score = None
|
|
1155
|
+
try:
|
|
1156
|
+
from patchi.core.health import compute as compute_health
|
|
1157
|
+
|
|
1158
|
+
hs = compute_health(r)
|
|
1159
|
+
health_score = hs.total
|
|
1160
|
+
con.print()
|
|
1161
|
+
con.print(
|
|
1162
|
+
f"[bold {hs.color}]● Health: {hs.total}/100 ({hs.grade})[/bold {hs.color}] "
|
|
1163
|
+
f"[dim]security {hs.security:.0f} tests {hs.test_coverage:.0f} "
|
|
1164
|
+
f"dead code {hs.dead_code:.0f} deps {hs.dependency:.0f} "
|
|
1165
|
+
f"contract {hs.contract:.0f}[/dim]"
|
|
1166
|
+
)
|
|
1167
|
+
except Exception as e:
|
|
1168
|
+
con.print(f"[dim]Health score unavailable: {e}[/dim]")
|
|
1169
|
+
|
|
1170
|
+
# ── Auto-update BRAIN.md ──────────────────────────────────────────────────
|
|
1171
|
+
try:
|
|
1172
|
+
_brain_path = r / ".patchi" / "BRAIN.md"
|
|
1173
|
+
if _brain_path.exists():
|
|
1174
|
+
import time as _t
|
|
1175
|
+
if _t.time() - _brain_path.stat().st_mtime > 300:
|
|
1176
|
+
con.print("[dim]BRAIN.md is stale. Run p scan to regenerate.[/dim]")
|
|
1177
|
+
except Exception as e:
|
|
1178
|
+
con.print(f"[dim]BRAIN.md auto-update failed: {e}[/dim]")
|
|
1179
|
+
|
|
1180
|
+
# ── JSON output ──────────────────────────────────────────────────────────
|
|
1181
|
+
if json_output:
|
|
1182
|
+
import json
|
|
1183
|
+
|
|
1184
|
+
from patchi.core.agents.coordinator import merge_results
|
|
1185
|
+
|
|
1186
|
+
merged = (
|
|
1187
|
+
merge_results(agent_results) if agent_results else {"findings": [], "total_findings": 0}
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
findings = []
|
|
1191
|
+
for f in merged["findings"]:
|
|
1192
|
+
entry = {
|
|
1193
|
+
"severity": f.get("severity", "info"),
|
|
1194
|
+
"file": f.get("file", ""),
|
|
1195
|
+
"line": f.get("line", 0),
|
|
1196
|
+
"message": f.get("message", ""),
|
|
1197
|
+
"agent": f.get("agent", ""),
|
|
1198
|
+
}
|
|
1199
|
+
# Include domain classification if present
|
|
1200
|
+
if f.get("domain_controls"):
|
|
1201
|
+
entry["domain_controls"] = f["domain_controls"]
|
|
1202
|
+
if f.get("playbook_ref"):
|
|
1203
|
+
entry["playbook_ref"] = f["playbook_ref"]
|
|
1204
|
+
if f.get("fix_strategy"):
|
|
1205
|
+
entry["fix_strategy"] = f["fix_strategy"]
|
|
1206
|
+
findings.append(entry)
|
|
1207
|
+
|
|
1208
|
+
dead_files = [str(df) for df in (report.dead_files or [])]
|
|
1209
|
+
circular_deps = [cd.short_label for cd in (report.circular_dependencies or [])]
|
|
1210
|
+
languages = dict(report.language_breakdown) if report.language_breakdown else {}
|
|
1211
|
+
|
|
1212
|
+
result = {
|
|
1213
|
+
"file_count": report.file_count,
|
|
1214
|
+
"route_count": report.route_count,
|
|
1215
|
+
"health_score": health_score,
|
|
1216
|
+
"findings": findings,
|
|
1217
|
+
"languages": languages,
|
|
1218
|
+
"dead_files": dead_files,
|
|
1219
|
+
"circular_deps": circular_deps,
|
|
1220
|
+
}
|
|
1221
|
+
con.print(json.dumps(result, indent=2))
|
|
1222
|
+
from patchi.core import ci_bundle as _ci
|
|
1223
|
+
|
|
1224
|
+
return _ci.exit_code_for(findings, fail_on)
|
|
1225
|
+
|
|
1226
|
+
# ── Exit-code contract: --fail-on gates CI (plain scan always 0) ──
|
|
1227
|
+
from patchi.core import ci_bundle as _ci
|
|
1228
|
+
|
|
1229
|
+
_tail_dicts = [f.to_dict() for ar in (agent_results or []) for f in getattr(ar, "findings", [])]
|
|
1230
|
+
_exit = _ci.exit_code_for(_tail_dicts, fail_on)
|
|
1231
|
+
if fail_on:
|
|
1232
|
+
_n_fail = sum(
|
|
1233
|
+
1
|
|
1234
|
+
for f in _tail_dicts
|
|
1235
|
+
if _ci._SEV_ORDER.get(str(f.get("severity", "info")).lower(), 5)
|
|
1236
|
+
<= _ci._SEV_ORDER[fail_on.lower()]
|
|
1237
|
+
)
|
|
1238
|
+
con.print(
|
|
1239
|
+
f"[dim] --fail-on {fail_on}: {_n_fail}/{len(_tail_dicts)} findings "
|
|
1240
|
+
f"at/above threshold (exit {_exit})[/dim]"
|
|
1241
|
+
)
|
|
1242
|
+
con.print()
|
|
1243
|
+
return _exit
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _run_contract_review(root: Path, all_flows: bool = False) -> None:
|
|
1247
|
+
"""Run contract review mode."""
|
|
1248
|
+
con.print("[bold #C8621A]Contract Review Mode[/bold #C8621A]")
|
|
1249
|
+
con.print()
|
|
1250
|
+
|
|
1251
|
+
# Load brain data to get contract information
|
|
1252
|
+
brain_data = mem.get_brain(root)
|
|
1253
|
+
inferred_flows = brain_data.get("inferred_flows", [])
|
|
1254
|
+
confirmed_flows = brain_data.get("confirmed_flows", [])
|
|
1255
|
+
|
|
1256
|
+
if not inferred_flows:
|
|
1257
|
+
con.print("[dim]No inferred contract flows found. Run a full scan first.[/dim]")
|
|
1258
|
+
return
|
|
1259
|
+
|
|
1260
|
+
# Filter: when --all-flows, show everything; otherwise hide suggested
|
|
1261
|
+
if not all_flows:
|
|
1262
|
+
visible = [f for f in inferred_flows if not f.get("suggested", False)]
|
|
1263
|
+
else:
|
|
1264
|
+
visible = inferred_flows
|
|
1265
|
+
|
|
1266
|
+
# Show unconfirmed flows for review
|
|
1267
|
+
confirmed_ids = {cf.get("id") for cf in confirmed_flows}
|
|
1268
|
+
unconfirmed_flows = [f for f in visible if f.get("id") not in confirmed_ids]
|
|
1269
|
+
hidden_count = len(inferred_flows) - len(visible)
|
|
1270
|
+
|
|
1271
|
+
if not unconfirmed_flows:
|
|
1272
|
+
label = f"[green]✓[/green] All {len(visible)} contract flows confirmed!"
|
|
1273
|
+
if hidden_count:
|
|
1274
|
+
label += f" ({hidden_count} low-confidence flows hidden — use --all-flows to see)"
|
|
1275
|
+
con.print(label)
|
|
1276
|
+
return
|
|
1277
|
+
|
|
1278
|
+
label = f"[yellow]Found {len(unconfirmed_flows)} unconfirmed flow(s) to review:[/yellow]"
|
|
1279
|
+
if hidden_count:
|
|
1280
|
+
label += f" [dim]({hidden_count} low-confidence hidden — use --all-flows to see all)[/dim]"
|
|
1281
|
+
con.print(label)
|
|
1282
|
+
|
|
1283
|
+
for i, flow in enumerate(unconfirmed_flows, 1):
|
|
1284
|
+
conf = flow.get("confidence", "medium")
|
|
1285
|
+
conf_tag = ""
|
|
1286
|
+
if conf == "low":
|
|
1287
|
+
conf_tag = " [dim](low confidence)[/dim]"
|
|
1288
|
+
elif conf == "high":
|
|
1289
|
+
conf_tag = " [dim](high)[/dim]"
|
|
1290
|
+
con.print(f" {i}. [bold]{flow.get('name', 'Unknown flow')}[/bold]{conf_tag}")
|
|
1291
|
+
con.print(f" [dim]{flow.get('description', 'No description')}[/dim]")
|
|
1292
|
+
routes = flow.get("routes", [])
|
|
1293
|
+
if routes:
|
|
1294
|
+
con.print(f" [dim]Routes: {', '.join(routes[:3])}[/dim]")
|
|
1295
|
+
|
|
1296
|
+
con.print()
|
|
1297
|
+
con.print("[dim]Run a full scan to confirm these flows.[/dim]")
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _run_file_scan(root: Path, file_path: str, deep: bool = False) -> None:
|
|
1301
|
+
"""Run deep analysis on a specific file."""
|
|
1302
|
+
con.print(f"[bold #C8621A]Deep analysis of {file_path}[/bold #C8621A]")
|
|
1303
|
+
|
|
1304
|
+
file_abs_path = root / file_path
|
|
1305
|
+
if not file_abs_path.exists():
|
|
1306
|
+
con.print(f"[red]File does not exist: {file_path}[/red]")
|
|
1307
|
+
return
|
|
1308
|
+
|
|
1309
|
+
if deep: # Only do deep AI analysis when --deep flag is explicitly passed
|
|
1310
|
+
try:
|
|
1311
|
+
import patchi.core.config as config_mod
|
|
1312
|
+
from patchi.core.ai.client import call_ai
|
|
1313
|
+
from patchi.core.ai.prompts import SYSTEM_PROMPTS, Skill
|
|
1314
|
+
|
|
1315
|
+
config = config_mod.load(root)
|
|
1316
|
+
content = file_abs_path.read_text(encoding="utf-8")
|
|
1317
|
+
lang = "python" if file_path.endswith(".py") else "javascript"
|
|
1318
|
+
|
|
1319
|
+
system_prompt = SYSTEM_PROMPTS.get(Skill.DEEP_ANALYSIS, "You are a code analyst.")
|
|
1320
|
+
user_prompt = f"Analyse this {lang} file:\n\nFILE: {file_path}\n\n```\n{content[:6000]}\n```\n\nReturn a JSON object with: purpose, functions (with issues), issues (with line numbers), and architecture notes."
|
|
1321
|
+
|
|
1322
|
+
result = call_ai(config, system_prompt, user_prompt, max_tokens=2000)
|
|
1323
|
+
if result:
|
|
1324
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] AI analysis for {file_path}:")
|
|
1325
|
+
con.print()
|
|
1326
|
+
# Try to format JSON response
|
|
1327
|
+
import json
|
|
1328
|
+
|
|
1329
|
+
try:
|
|
1330
|
+
analysis = json.loads(
|
|
1331
|
+
result.strip().removeprefix("```json").removesuffix("```").strip()
|
|
1332
|
+
)
|
|
1333
|
+
for key, val in analysis.items():
|
|
1334
|
+
if isinstance(val, str):
|
|
1335
|
+
con.print(f" [bold]{key}:[/bold] {val}")
|
|
1336
|
+
elif isinstance(val, list):
|
|
1337
|
+
con.print(f" [bold]{key}:[/bold]")
|
|
1338
|
+
for item in val[:10]:
|
|
1339
|
+
if isinstance(item, dict):
|
|
1340
|
+
line = item.get("line", "")
|
|
1341
|
+
name = item.get("name", item.get("function", ""))
|
|
1342
|
+
issue = item.get("issue", item.get("description", ""))
|
|
1343
|
+
con.print(
|
|
1344
|
+
f" L{line} {name}: {issue}"
|
|
1345
|
+
if line
|
|
1346
|
+
else f" {name}: {issue}"
|
|
1347
|
+
)
|
|
1348
|
+
else:
|
|
1349
|
+
con.print(f" {item}")
|
|
1350
|
+
except (json.JSONDecodeError, ValueError):
|
|
1351
|
+
# Not JSON — print raw
|
|
1352
|
+
for line in result.strip().splitlines()[:30]:
|
|
1353
|
+
con.print(f" {line}")
|
|
1354
|
+
else:
|
|
1355
|
+
con.print("[yellow]AI returned no response — showing file structure only.[/yellow]")
|
|
1356
|
+
lines = content.splitlines()
|
|
1357
|
+
con.print(
|
|
1358
|
+
f"[dim]File has {len(lines)} lines · {file_abs_path.stat().st_size} bytes[/dim]"
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
except Exception as e:
|
|
1362
|
+
con.print(f"[red]Error analyzing file: {e}[/red]")
|
|
1363
|
+
else:
|
|
1364
|
+
con.print(f"[dim]Basic scan of {file_path}[/dim]")
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _run_deep_scan_analysis(root: Path, report: BrainReport, agent_results: list) -> None:
|
|
1368
|
+
"""Run deep AI analysis on changed files since last deep scan."""
|
|
1369
|
+
try:
|
|
1370
|
+
import patchi.core.config as config_mod
|
|
1371
|
+
from patchi.core.ai.client import call_ai_structured
|
|
1372
|
+
from patchi.core.ai.prompts import Skill, build_prompt, get_system_prompt
|
|
1373
|
+
|
|
1374
|
+
config = config_mod.load(root)
|
|
1375
|
+
|
|
1376
|
+
# Quick AI availability check
|
|
1377
|
+
test = call_ai_structured(config, "Say OK", 'Reply with JSON: {"ok": true}')
|
|
1378
|
+
if test is None:
|
|
1379
|
+
con.print(
|
|
1380
|
+
"[yellow]No AI available — skipping deep analysis (run without --offline to use AI).[/yellow]"
|
|
1381
|
+
)
|
|
1382
|
+
return
|
|
1383
|
+
|
|
1384
|
+
brain_data = mem.get_brain(root)
|
|
1385
|
+
last_hashes = brain_data.get("deep_scan_hashes", {})
|
|
1386
|
+
|
|
1387
|
+
tokens_used = 0
|
|
1388
|
+
files_analyzed = 0
|
|
1389
|
+
findings: list[dict] = []
|
|
1390
|
+
|
|
1391
|
+
source_exts = (".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java", ".rb", ".php")
|
|
1392
|
+
all_files = [p for p in root.rglob("*") if p.suffix in source_exts]
|
|
1393
|
+
|
|
1394
|
+
for file_path in all_files:
|
|
1395
|
+
rel_path = file_path.relative_to(root).as_posix()
|
|
1396
|
+
try:
|
|
1397
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
1398
|
+
except Exception as e:
|
|
1399
|
+
_log.warning("_run_deep_scan_analysis failed: %s", e)
|
|
1400
|
+
continue
|
|
1401
|
+
|
|
1402
|
+
current_hash = hashlib.sha256(content.encode()).hexdigest()
|
|
1403
|
+
if last_hashes.get(rel_path) == current_hash:
|
|
1404
|
+
continue
|
|
1405
|
+
|
|
1406
|
+
con.print(f"[dim]Deep analyzing {rel_path}...[/dim]")
|
|
1407
|
+
|
|
1408
|
+
system = get_system_prompt(Skill.DEEP_ANALYSIS)
|
|
1409
|
+
user_prompt = build_prompt(
|
|
1410
|
+
Skill.DEEP_ANALYSIS,
|
|
1411
|
+
{
|
|
1412
|
+
"file_path": rel_path,
|
|
1413
|
+
"file_content": content[:6000],
|
|
1414
|
+
"language": rel_path.split(".")[-1],
|
|
1415
|
+
},
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
result = call_ai_structured(config, system, user_prompt, max_tokens=3000)
|
|
1419
|
+
if result is None:
|
|
1420
|
+
continue
|
|
1421
|
+
|
|
1422
|
+
analysis_entry = {
|
|
1423
|
+
"file": rel_path,
|
|
1424
|
+
"hash": current_hash,
|
|
1425
|
+
"analysis": result,
|
|
1426
|
+
}
|
|
1427
|
+
brain_data.setdefault("deep_analyses", {})[rel_path] = analysis_entry
|
|
1428
|
+
last_hashes[rel_path] = current_hash
|
|
1429
|
+
|
|
1430
|
+
tokens_used += len(content.split())
|
|
1431
|
+
files_analyzed += 1
|
|
1432
|
+
|
|
1433
|
+
# Collect issues for the summary
|
|
1434
|
+
for issue in result.get("issues") or []:
|
|
1435
|
+
findings.append(
|
|
1436
|
+
{
|
|
1437
|
+
"file": rel_path,
|
|
1438
|
+
"type": issue.get("type", "unknown"),
|
|
1439
|
+
"severity": issue.get("severity", "low"),
|
|
1440
|
+
"line": issue.get("line", 0),
|
|
1441
|
+
"message": issue.get("description", ""),
|
|
1442
|
+
}
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
# Persist
|
|
1446
|
+
brain_data["deep_scan_hashes"] = last_hashes
|
|
1447
|
+
mem.save_brain(brain_data, root)
|
|
1448
|
+
|
|
1449
|
+
# Summary
|
|
1450
|
+
con.print(f"[dim]Deep scan: {files_analyzed} files analyzed, ~{tokens_used} tokens[/dim]")
|
|
1451
|
+
if findings:
|
|
1452
|
+
by_sev: dict[str, int] = {}
|
|
1453
|
+
for f in findings:
|
|
1454
|
+
by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
|
|
1455
|
+
parts = " ".join(
|
|
1456
|
+
f"[{_SEV_COLORS.get(s, '#B8A898')}]{c} {s}[/{_SEV_COLORS.get(s, '#B8A898')}]"
|
|
1457
|
+
for s, c in sorted(by_sev.items())
|
|
1458
|
+
)
|
|
1459
|
+
con.print(f"[bold #F2EDD6]Deep Analysis Issues:[/bold #F2EDD6] {parts}")
|
|
1460
|
+
for f in findings[:8]:
|
|
1461
|
+
loc = f"{f['file']}:{f['line']}" if f["line"] else f["file"]
|
|
1462
|
+
con.print(f" [dim]{loc}[/dim] — {f['message'][:100]}")
|
|
1463
|
+
|
|
1464
|
+
except Exception as e:
|
|
1465
|
+
import traceback
|
|
1466
|
+
|
|
1467
|
+
con.print(f"[red]Error during deep scan: {e}[/red]")
|
|
1468
|
+
con.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
_SEV_COLORS = {
|
|
1472
|
+
"critical": "#FF4D6D",
|
|
1473
|
+
"high": "#FF8C42",
|
|
1474
|
+
"medium": "#FACC15",
|
|
1475
|
+
"low": "#4ADE80",
|
|
1476
|
+
"info": "#B8A898",
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _show_report_summary(
|
|
1481
|
+
report: BrainReport,
|
|
1482
|
+
agent_results: list | None = None,
|
|
1483
|
+
wall_time: float | None = None,
|
|
1484
|
+
root: Path | None = None,
|
|
1485
|
+
) -> None:
|
|
1486
|
+
"""Print the post-scan results table."""
|
|
1487
|
+
if wall_time is not None:
|
|
1488
|
+
duration = f"{wall_time:.1f}s"
|
|
1489
|
+
else:
|
|
1490
|
+
duration = f"{report.duration_seconds:.1f}s"
|
|
1491
|
+
|
|
1492
|
+
# Stats table
|
|
1493
|
+
table = Table(show_header=False, box=None, pad_edge=False, padding=(0, 3))
|
|
1494
|
+
table.add_column("Label", style="bold #F2EDD6", width=22)
|
|
1495
|
+
table.add_column("Value", style="#B8A898")
|
|
1496
|
+
|
|
1497
|
+
table.add_row("Files scanned", str(report.file_count))
|
|
1498
|
+
|
|
1499
|
+
if report.language_breakdown:
|
|
1500
|
+
lang_str = " ".join(
|
|
1501
|
+
f"{lang}: {cnt}" for lang, cnt in list(report.language_breakdown.items())[:4]
|
|
1502
|
+
)
|
|
1503
|
+
table.add_row("Languages", lang_str)
|
|
1504
|
+
|
|
1505
|
+
if report.stack and report.stack.frameworks:
|
|
1506
|
+
fw_str = ", ".join(f.name for f in report.stack.frameworks[:3])
|
|
1507
|
+
table.add_row("Framework", fw_str)
|
|
1508
|
+
|
|
1509
|
+
table.add_row("Routes found", str(report.route_count))
|
|
1510
|
+
table.add_row(
|
|
1511
|
+
"Import graph", f"{len(report.import_graph.nodes)} nodes" if report.import_graph else "—"
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
if report.circular_dependencies:
|
|
1515
|
+
circ_text = Text(f"{len(report.circular_dependencies)} circular deps found", style="yellow")
|
|
1516
|
+
table.add_row("Circular deps", circ_text)
|
|
1517
|
+
else:
|
|
1518
|
+
table.add_row("Circular deps", Text("None ✓", style="#4ADE80"))
|
|
1519
|
+
|
|
1520
|
+
if report.dead_files:
|
|
1521
|
+
dead_text = Text(f"{len(report.dead_files)} unreachable files", style="dim")
|
|
1522
|
+
table.add_row("Dead code", dead_text)
|
|
1523
|
+
else:
|
|
1524
|
+
table.add_row("Dead code", Text("None found ✓", style="#4ADE80"))
|
|
1525
|
+
|
|
1526
|
+
if report.errors:
|
|
1527
|
+
err_text = Text(f"{len(report.errors)} parse error(s)", style="yellow")
|
|
1528
|
+
table.add_row("Parse errors", err_text)
|
|
1529
|
+
|
|
1530
|
+
table.add_row("Scan duration", duration)
|
|
1531
|
+
|
|
1532
|
+
con.print(
|
|
1533
|
+
Panel(
|
|
1534
|
+
table,
|
|
1535
|
+
title="[bold #C8621A]Brain Scan Complete[/bold #C8621A]",
|
|
1536
|
+
border_style="#2A3D28",
|
|
1537
|
+
)
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
# Circular dep details
|
|
1541
|
+
if report.circular_dependencies:
|
|
1542
|
+
con.print()
|
|
1543
|
+
con.print("[yellow]Circular dependencies:[/yellow]")
|
|
1544
|
+
for cd in report.circular_dependencies[:5]:
|
|
1545
|
+
con.print(f" [dim]→[/dim] {cd.short_label}")
|
|
1546
|
+
if len(report.circular_dependencies) > 5:
|
|
1547
|
+
con.print(f" [dim]… and {len(report.circular_dependencies) - 5} more[/dim]")
|
|
1548
|
+
|
|
1549
|
+
# Dead files
|
|
1550
|
+
if report.dead_files:
|
|
1551
|
+
con.print()
|
|
1552
|
+
con.print(f"[dim]Dead files ({len(report.dead_files)}):[/dim]")
|
|
1553
|
+
for df in report.dead_files[:8]:
|
|
1554
|
+
con.print(f" [dim]○ {df}[/dim]")
|
|
1555
|
+
if len(report.dead_files) > 8:
|
|
1556
|
+
con.print(f" [dim]… and {len(report.dead_files) - 8} more[/dim]")
|
|
1557
|
+
|
|
1558
|
+
# Agent findings summary
|
|
1559
|
+
if agent_results:
|
|
1560
|
+
_show_agent_findings_summary(agent_results, root=root)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _show_agent_findings_summary(agent_results: list, root: Path | None = None) -> None:
|
|
1564
|
+
"""Show a condensed findings table from all scanner agents.
|
|
1565
|
+
|
|
1566
|
+
Headline counts run through the NoiseFilter first: findings from
|
|
1567
|
+
tests/lockfiles/generated/docs are severity-capped (or discarded) so
|
|
1568
|
+
the summary reflects signal, not fixture noise.
|
|
1569
|
+
"""
|
|
1570
|
+
from patchi.core.agents.coordinator import merge_results
|
|
1571
|
+
|
|
1572
|
+
merged = merge_results(agent_results)
|
|
1573
|
+
findings = merged["findings"]
|
|
1574
|
+
|
|
1575
|
+
# Noise filter (non-fatal): cap or drop fixture/lockfile/bundle noise
|
|
1576
|
+
noise_line = ""
|
|
1577
|
+
if root is not None:
|
|
1578
|
+
try:
|
|
1579
|
+
from patchi.core.security.noise_filter import NoiseFilter
|
|
1580
|
+
|
|
1581
|
+
try:
|
|
1582
|
+
config = cfg.load(root)
|
|
1583
|
+
except Exception: # noqa: BLE001 — config optional for filtering
|
|
1584
|
+
config = None
|
|
1585
|
+
nf = NoiseFilter(root, config if isinstance(config, dict) else None)
|
|
1586
|
+
if nf.enabled and findings:
|
|
1587
|
+
kept, nfr = nf.apply(findings)
|
|
1588
|
+
if nfr.capped or nfr.discarded:
|
|
1589
|
+
cats = ", ".join(
|
|
1590
|
+
f"{k}={v}" for k, v in sorted(nfr.to_dict()["by_category"].items())
|
|
1591
|
+
)
|
|
1592
|
+
noise_line = (
|
|
1593
|
+
f" [dim]Noise muted: {nfr.capped} capped, "
|
|
1594
|
+
f"{nfr.discarded} discarded" + (f" ({cats})" if cats else "") + "[/dim]"
|
|
1595
|
+
)
|
|
1596
|
+
findings = kept
|
|
1597
|
+
except Exception as _exc: # noqa: BLE001 — display must never crash on filter bugs
|
|
1598
|
+
_log.warning('_show_agent_findings_summary failed: %s', _exc)
|
|
1599
|
+
|
|
1600
|
+
merged["findings"] = findings
|
|
1601
|
+
total = len(findings)
|
|
1602
|
+
|
|
1603
|
+
if total == 0:
|
|
1604
|
+
con.print()
|
|
1605
|
+
con.print(Text("✓ No issues found by scanner agents.", style="#4ADE80"))
|
|
1606
|
+
if noise_line:
|
|
1607
|
+
con.print(noise_line)
|
|
1608
|
+
return
|
|
1609
|
+
|
|
1610
|
+
# Count by severity
|
|
1611
|
+
by_sev: dict[str, int] = {}
|
|
1612
|
+
for f in merged["findings"]:
|
|
1613
|
+
sev = f.get("severity", "info")
|
|
1614
|
+
by_sev[sev] = by_sev.get(sev, 0) + 1
|
|
1615
|
+
|
|
1616
|
+
con.print()
|
|
1617
|
+
sev_parts: list[str] = []
|
|
1618
|
+
for sev in ("critical", "high", "medium", "low", "info"):
|
|
1619
|
+
count = by_sev.get(sev, 0)
|
|
1620
|
+
if count:
|
|
1621
|
+
colors = {
|
|
1622
|
+
"critical": "#FF4D6D",
|
|
1623
|
+
"high": "#FF8C42",
|
|
1624
|
+
"medium": "#FACC15",
|
|
1625
|
+
"low": "#4ADE80",
|
|
1626
|
+
"info": "#B8A898",
|
|
1627
|
+
}
|
|
1628
|
+
sev_parts.append(f"[{colors[sev]}]{count} {sev}[/{colors[sev]}]")
|
|
1629
|
+
|
|
1630
|
+
sev_str = " ".join(sev_parts)
|
|
1631
|
+
con.print(f"[bold #F2EDD6]Agent Findings:[/bold #F2EDD6] {sev_str}")
|
|
1632
|
+
if noise_line:
|
|
1633
|
+
con.print(noise_line)
|
|
1634
|
+
|
|
1635
|
+
# Show agent-by-agent summary
|
|
1636
|
+
con.print()
|
|
1637
|
+
table = Table(show_header=True, header_style="dim", box=None, pad_edge=False)
|
|
1638
|
+
table.add_column("Agent", style="bold #F2EDD6", width=26)
|
|
1639
|
+
table.add_column("Findings", justify="right", width=10)
|
|
1640
|
+
table.add_column("Status", width=10)
|
|
1641
|
+
table.add_column("ms", justify="right", width=8)
|
|
1642
|
+
|
|
1643
|
+
for r in sorted(agent_results, key=lambda x: -x.finding_count):
|
|
1644
|
+
status_colors = {
|
|
1645
|
+
"done": "#4ADE80",
|
|
1646
|
+
"failed": "#FF4D6D",
|
|
1647
|
+
"skipped": "dim",
|
|
1648
|
+
"running": "#C8621A",
|
|
1649
|
+
}
|
|
1650
|
+
color = status_colors.get(r.status.value, "dim")
|
|
1651
|
+
table.add_row(
|
|
1652
|
+
r.agent_name,
|
|
1653
|
+
str(r.finding_count) if r.finding_count else "—",
|
|
1654
|
+
Text(r.status.value, style=color),
|
|
1655
|
+
str(r.duration_ms),
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
con.print(table)
|
|
1659
|
+
|
|
1660
|
+
# Show top critical/high findings
|
|
1661
|
+
top = [f for f in merged["findings"] if f.get("severity") in ("critical", "high")][:5]
|
|
1662
|
+
if top:
|
|
1663
|
+
con.print()
|
|
1664
|
+
con.print("[bold #FF4D6D]Critical / High findings:[/bold #FF4D6D]")
|
|
1665
|
+
for f in top:
|
|
1666
|
+
sev = f.get("severity", "info")
|
|
1667
|
+
color = "#FF4D6D" if sev == "critical" else "#FF8C42"
|
|
1668
|
+
fpath = f.get("file", "")
|
|
1669
|
+
line = f.get("line", 0)
|
|
1670
|
+
loc = f"{fpath}:{line}" if line else fpath
|
|
1671
|
+
con.print(f" [{color}]●[/{color}] [dim]{loc}[/dim]")
|
|
1672
|
+
con.print(f" {f.get('message', '')[:80]}")
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _run_contract_confirmation(root: Path, report: BrainReport, all_flows: bool = False) -> None:
|
|
1676
|
+
"""Interactive contract confirmation flow."""
|
|
1677
|
+
from rich.prompt import Confirm, Prompt
|
|
1678
|
+
|
|
1679
|
+
from patchi.core.brain.contract import ContractBuilder, confirm_flows
|
|
1680
|
+
|
|
1681
|
+
builder = ContractBuilder(
|
|
1682
|
+
report.routes, report.file_infos, report.dead_files, report.circular_dependencies
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
# Filter flows: hide suggested unless --all-flows
|
|
1686
|
+
shown_flows = report.inferred_flows
|
|
1687
|
+
hidden_count = 0
|
|
1688
|
+
if not all_flows:
|
|
1689
|
+
shown_flows = [f for f in report.inferred_flows if not f.suggested]
|
|
1690
|
+
hidden_count = len(report.inferred_flows) - len(shown_flows)
|
|
1691
|
+
|
|
1692
|
+
msg = builder.build_confirmation_message(shown_flows, all_flows=all_flows)
|
|
1693
|
+
|
|
1694
|
+
con.print()
|
|
1695
|
+
sub = (
|
|
1696
|
+
f" ({hidden_count} low-confidence flows hidden — use --all-flows to see)"
|
|
1697
|
+
if hidden_count
|
|
1698
|
+
else ""
|
|
1699
|
+
)
|
|
1700
|
+
con.print(
|
|
1701
|
+
Panel(
|
|
1702
|
+
f"[bold #F2EDD6]App Contract[/bold #F2EDD6]\n\n[dim]{msg}[/dim]{sub}",
|
|
1703
|
+
border_style="#C8621A",
|
|
1704
|
+
)
|
|
1705
|
+
)
|
|
1706
|
+
con.print()
|
|
1707
|
+
|
|
1708
|
+
# Show each inferred flow and ask Y/N
|
|
1709
|
+
confirmed_ids: set[str] = set()
|
|
1710
|
+
for flow in shown_flows:
|
|
1711
|
+
conf_tag = ""
|
|
1712
|
+
if flow.confidence == "high":
|
|
1713
|
+
conf_tag = " [dim](high confidence)[/dim]"
|
|
1714
|
+
elif flow.confidence == "low":
|
|
1715
|
+
conf_tag = " [dim](low confidence)[/dim]"
|
|
1716
|
+
con.print(f" [bold]{flow.name}[/bold]{conf_tag} [dim]{flow.description}[/dim]")
|
|
1717
|
+
if flow.routes:
|
|
1718
|
+
route_str = ", ".join(flow.routes[:3])
|
|
1719
|
+
con.print(f" [dim]Routes: {route_str}[/dim]")
|
|
1720
|
+
yn = Confirm.ask(f" Include [bold]{flow.name}[/bold] in contract?", default=True)
|
|
1721
|
+
if yn:
|
|
1722
|
+
confirmed_ids.add(flow.id)
|
|
1723
|
+
con.print()
|
|
1724
|
+
|
|
1725
|
+
# Any additional flows?
|
|
1726
|
+
user_flows: list[dict] = []
|
|
1727
|
+
if Confirm.ask("Add any flows I didn't detect?", default=False):
|
|
1728
|
+
while True:
|
|
1729
|
+
name = Prompt.ask(" Flow name (or Enter to finish)")
|
|
1730
|
+
if not name:
|
|
1731
|
+
break
|
|
1732
|
+
desc = Prompt.ask(" One-sentence description")
|
|
1733
|
+
user_flows.append({"name": name, "description": desc})
|
|
1734
|
+
|
|
1735
|
+
confirmed = confirm_flows(report.inferred_flows, confirmed_ids, user_flows)
|
|
1736
|
+
|
|
1737
|
+
# Save to brain memory
|
|
1738
|
+
brain_mem = mem.get_brain(root)
|
|
1739
|
+
brain_mem["confirmed_flows"] = [f.to_dict() for f in confirmed]
|
|
1740
|
+
brain_mem["contract_locked"] = True # p fix checks this before running
|
|
1741
|
+
mem.save_brain(brain_mem, root)
|
|
1742
|
+
|
|
1743
|
+
con.print()
|
|
1744
|
+
con.print(
|
|
1745
|
+
f"[#4ADE80]✓[/#4ADE80] App contract locked: "
|
|
1746
|
+
f"[bold]{len(confirmed)}[/bold] flow(s) protected."
|
|
1747
|
+
)
|
|
1748
|
+
con.print("[dim]Every fix will check against this contract before applying.[/dim]")
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def _show_changed_dry_run(root: Path, commits: int) -> None:
|
|
1752
|
+
"""Show what --changed would activate without actually scanning."""
|
|
1753
|
+
from patchi.core.security.domain_activator_v2 import DomainActivatorV2
|
|
1754
|
+
from patchi.core.security.git_diff_activator import activate_from_diff
|
|
1755
|
+
|
|
1756
|
+
con.print()
|
|
1757
|
+
con.print("[bold #C8621A]── Changed Dry-Run ──[/bold #C8621A]")
|
|
1758
|
+
con.print()
|
|
1759
|
+
|
|
1760
|
+
# 1. Show changed files
|
|
1761
|
+
diff_result = activate_from_diff(root, commits=commits)
|
|
1762
|
+
if diff_result.error:
|
|
1763
|
+
con.print(f" [yellow]{diff_result.error}[/yellow]")
|
|
1764
|
+
return
|
|
1765
|
+
|
|
1766
|
+
con.print(
|
|
1767
|
+
f" [bold]Changed files:[/bold] {len(diff_result.changed_files)} (from last {commits} commit{'s' if commits > 1 else ''})"
|
|
1768
|
+
)
|
|
1769
|
+
con.print()
|
|
1770
|
+
|
|
1771
|
+
# Group changed files by extension
|
|
1772
|
+
ext_groups: dict[str, list[str]] = {}
|
|
1773
|
+
for fp in diff_result.changed_files:
|
|
1774
|
+
ext = Path(fp).suffix or "(no ext)"
|
|
1775
|
+
ext_groups.setdefault(ext, []).append(fp)
|
|
1776
|
+
for ext in sorted(ext_groups, key=lambda e: -len(ext_groups[e])):
|
|
1777
|
+
con.print(f" [dim]{ext}:[/dim] {len(ext_groups[ext])} files")
|
|
1778
|
+
con.print()
|
|
1779
|
+
|
|
1780
|
+
# 2. Show activated domains
|
|
1781
|
+
if diff_result.activated_domains:
|
|
1782
|
+
con.print(f" [bold]Activated domains:[/bold] {len(diff_result.activated_domains)}")
|
|
1783
|
+
con.print()
|
|
1784
|
+
table = Table(show_header=True, header_style="bold #C8621A", box=None)
|
|
1785
|
+
table.add_column("Domain")
|
|
1786
|
+
table.add_column("Score", justify="right")
|
|
1787
|
+
table.add_column("Agents", style="dim")
|
|
1788
|
+
for domain, score in sorted(diff_result.activated_domains.items(), key=lambda x: -x[1]):
|
|
1789
|
+
try:
|
|
1790
|
+
activator = DomainActivatorV2(root)
|
|
1791
|
+
agents = activator.get_relevant_agents([domain])
|
|
1792
|
+
agent_str = ", ".join(agents[:4])
|
|
1793
|
+
if len(agents) > 4:
|
|
1794
|
+
agent_str += f" +{len(agents) - 4}"
|
|
1795
|
+
except Exception:
|
|
1796
|
+
agent_str = "(unknown)"
|
|
1797
|
+
score_color = "red" if score >= 0.8 else "yellow" if score >= 0.5 else "dim"
|
|
1798
|
+
table.add_row(domain, f"[{score_color}]{score:.1f}[/{score_color}]", agent_str)
|
|
1799
|
+
con.print(table)
|
|
1800
|
+
else:
|
|
1801
|
+
con.print(" [dim]No domain-relevant changes detected.[/dim]")
|
|
1802
|
+
con.print()
|
|
1803
|
+
|
|
1804
|
+
# 3. Show total agent count
|
|
1805
|
+
from patchi.core.agents.base import list_agents as _la
|
|
1806
|
+
all_scanner_agents = _la(AgentGroup.SCANNER)
|
|
1807
|
+
if diff_result.activated_domains:
|
|
1808
|
+
try:
|
|
1809
|
+
activator = DomainActivatorV2(root)
|
|
1810
|
+
relevant = activator.get_relevant_agents(list(diff_result.activated_domains.keys()))
|
|
1811
|
+
relevant.extend(["PreCheckAgent", "PlanAuditorAgent"])
|
|
1812
|
+
relevant = list(dict.fromkeys(relevant)) # dedupe preserving order
|
|
1813
|
+
would_run = [a for a in all_scanner_agents if getattr(a, "name", "") in relevant]
|
|
1814
|
+
skipped = len(all_scanner_agents) - len(would_run)
|
|
1815
|
+
con.print(
|
|
1816
|
+
f" [bold]Would run:[/bold] {len(would_run)} agents [dim](skipping {skipped})[/dim]"
|
|
1817
|
+
)
|
|
1818
|
+
con.print()
|
|
1819
|
+
con.print(" [dim]Run without --dry-run to execute the scan.[/dim]")
|
|
1820
|
+
except Exception:
|
|
1821
|
+
con.print(
|
|
1822
|
+
f" [dim]Would run all {len(all_scanner_agents)} agents (activation failed)[/dim]"
|
|
1823
|
+
)
|
|
1824
|
+
else:
|
|
1825
|
+
con.print(f" [dim]Would run all {len(all_scanner_agents)} agents (no diff match)[/dim]")
|
|
1826
|
+
con.print()
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
def _show_dry_run(root: Path, area: str | None) -> None:
|
|
1830
|
+
"""Show what would be scanned without actually scanning."""
|
|
1831
|
+
from patchi.core import config as cfg
|
|
1832
|
+
from patchi.core.brain.scanner import FileScanner
|
|
1833
|
+
|
|
1834
|
+
try:
|
|
1835
|
+
config = cfg.load(root)
|
|
1836
|
+
except Exception as e:
|
|
1837
|
+
con.print(f"[dim]Config load error: {e}[/dim]")
|
|
1838
|
+
config = {}
|
|
1839
|
+
|
|
1840
|
+
scanner = FileScanner(
|
|
1841
|
+
root=root,
|
|
1842
|
+
ignore_paths=config.get("ignore_paths", []),
|
|
1843
|
+
)
|
|
1844
|
+
paths = scanner.discover(area)
|
|
1845
|
+
|
|
1846
|
+
con.print()
|
|
1847
|
+
con.print(f"[dim]--dry-run: would scan {len(paths)} files[/dim]")
|
|
1848
|
+
con.print()
|
|
1849
|
+
|
|
1850
|
+
from patchi.core.brain.languages import detect_language
|
|
1851
|
+
|
|
1852
|
+
by_lang: dict = {}
|
|
1853
|
+
for p in paths:
|
|
1854
|
+
lang = detect_language(p).value
|
|
1855
|
+
by_lang[lang] = by_lang.get(lang, 0) + 1
|
|
1856
|
+
|
|
1857
|
+
table = Table(show_header=True, header_style="bold #C8621A", box=None)
|
|
1858
|
+
table.add_column("Language")
|
|
1859
|
+
table.add_column("Files", justify="right")
|
|
1860
|
+
for lang, count in sorted(by_lang.items(), key=lambda x: x[1], reverse=True):
|
|
1861
|
+
table.add_row(lang, str(count))
|
|
1862
|
+
con.print(table)
|
|
1863
|
+
con.print()
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def _show_summary_from_memory(root: Path) -> None:
|
|
1867
|
+
"""Show last scan summary from brain memory."""
|
|
1868
|
+
brain = mem.get_brain(root)
|
|
1869
|
+
if not brain:
|
|
1870
|
+
return
|
|
1871
|
+
con.print(
|
|
1872
|
+
f"[dim]Last scan:[/dim] {brain.get('file_count', '?')} files · "
|
|
1873
|
+
f"{brain.get('route_count', '?')} routes · "
|
|
1874
|
+
f"{brain.get('framework', '?')} detected"
|
|
1875
|
+
)
|
|
1876
|
+
con.print()
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
def _build_progress() -> Progress:
|
|
1880
|
+
return Progress(
|
|
1881
|
+
SpinnerColumn(spinner_name="dots"),
|
|
1882
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1883
|
+
BarColumn(bar_width=24),
|
|
1884
|
+
TaskProgressColumn(),
|
|
1885
|
+
TimeElapsedColumn(),
|
|
1886
|
+
transient=False,
|
|
1887
|
+
)
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
def _fmt_time(iso: str) -> str:
|
|
1891
|
+
"""Format ISO timestamp as human-readable."""
|
|
1892
|
+
try:
|
|
1893
|
+
from datetime import datetime
|
|
1894
|
+
|
|
1895
|
+
dt = datetime.fromisoformat(iso)
|
|
1896
|
+
now = datetime.now(UTC)
|
|
1897
|
+
diff = now - dt
|
|
1898
|
+
secs = diff.total_seconds()
|
|
1899
|
+
if secs < 60:
|
|
1900
|
+
return "just now"
|
|
1901
|
+
if secs < 3600:
|
|
1902
|
+
return f"{int(secs // 60)}m ago"
|
|
1903
|
+
if secs < 86400:
|
|
1904
|
+
return f"{int(secs // 3600)}h ago"
|
|
1905
|
+
return f"{int(secs // 86400)}d ago"
|
|
1906
|
+
except Exception as e:
|
|
1907
|
+
_log.warning("_fmt_time failed: %s", e)
|
|
1908
|
+
return iso
|