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,1160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real tool backends for Patchi's AI tool-calls.
|
|
3
|
+
|
|
4
|
+
Every function here performs *actual work* — no placeholder "initiated" stubs.
|
|
5
|
+
The previous handlers in ``registry.py`` returned success messages without doing
|
|
6
|
+
anything; this module is what makes ``p smart``, the Council, and the web
|
|
7
|
+
console actually do something.
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
- Functions are synchronous so they run cleanly inside the ToolExecutor's thread
|
|
11
|
+
pool. Each returns a dict with at least ``{"success": bool}``.
|
|
12
|
+
- Live progress is emitted through a caller-supplied event sink (set via
|
|
13
|
+
``set_event_sink``). The SmartAgent and the web layer both wire a sink so the
|
|
14
|
+
"live view" shows real activity as tools execute.
|
|
15
|
+
- Security domains are auto-activated by project signals ("only activate when
|
|
16
|
+
needed") — see ``_select_security_agents``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import concurrent.futures as _cf
|
|
23
|
+
import importlib.util
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
# Only pass --no-cov if pytest-cov is actually installed; otherwise pytest
|
|
36
|
+
# rejects the unknown flag and exits with code 4 (no tests run).
|
|
37
|
+
_HAS_PYTEST_COV = importlib.util.find_spec("pytest_cov") is not None
|
|
38
|
+
|
|
39
|
+
_log = logging.getLogger("patchi.ai.realize")
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Event sink (best-effort). The SmartAgent / web layer set this so tool
|
|
43
|
+
# progress can be streamed to a live view.
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
_EVENT_SINK: Callable[[dict], None] | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_event_sink(fn: Callable[[dict], None] | None) -> None:
|
|
50
|
+
"""Set the callable that receives ``{"event": str, "data": dict}`` payloads."""
|
|
51
|
+
global _EVENT_SINK
|
|
52
|
+
_EVENT_SINK = fn
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _emit(event: str, data: dict) -> None:
|
|
56
|
+
payload = {"event": event, "data": data}
|
|
57
|
+
if _EVENT_SINK:
|
|
58
|
+
try:
|
|
59
|
+
_EVENT_SINK(payload)
|
|
60
|
+
except Exception as e: # never let event emission break a tool
|
|
61
|
+
_log.debug("event sink raised: %s", e)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Helpers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _now() -> str:
|
|
70
|
+
return datetime.now(UTC).isoformat()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _detect_signals(root: Path) -> set[str]:
|
|
74
|
+
"""Return a set of capability signals derived from repo contents."""
|
|
75
|
+
exts: set[str] = set()
|
|
76
|
+
fw_markers: set[str] = set()
|
|
77
|
+
web_signals = False
|
|
78
|
+
try:
|
|
79
|
+
for p in root.rglob("*"):
|
|
80
|
+
if p.is_dir():
|
|
81
|
+
continue
|
|
82
|
+
exts.add(p.suffix.lower())
|
|
83
|
+
low = p.name.lower()
|
|
84
|
+
if low in ("package.json", "vue.config.js", "angular.json"):
|
|
85
|
+
fw_markers.add("js_framework")
|
|
86
|
+
if low in ("requirements.txt", "pyproject.toml", "setup.py", "pipfile"):
|
|
87
|
+
fw_markers.add("python")
|
|
88
|
+
if low in ("go.mod",):
|
|
89
|
+
fw_markers.add("go")
|
|
90
|
+
if low in ("pom.xml", "build.gradle"):
|
|
91
|
+
fw_markers.add("java")
|
|
92
|
+
if low in ("cargo.toml",):
|
|
93
|
+
fw_markers.add("rust")
|
|
94
|
+
if exts & {".html", ".htm"}:
|
|
95
|
+
web_signals = True
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
sig: set[str] = set()
|
|
99
|
+
if ".py" in exts:
|
|
100
|
+
sig.add("python")
|
|
101
|
+
if exts & {".js", ".ts", ".jsx", ".tsx", ".vue"}:
|
|
102
|
+
sig.add("web_frontend")
|
|
103
|
+
if web_signals or (exts & {".js", ".ts"}):
|
|
104
|
+
sig.add("web")
|
|
105
|
+
sig |= fw_markers
|
|
106
|
+
return sig
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_agent_input(
|
|
110
|
+
root: Path, scope: list[str] | None = None, active_domains: list[str] | None = None
|
|
111
|
+
):
|
|
112
|
+
"""Construct an ``AgentInput`` with current brain/config context."""
|
|
113
|
+
from patchi.core.agents.base import AgentInput
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
from patchi.core import config as cfg
|
|
117
|
+
from patchi.core import memory as mem
|
|
118
|
+
|
|
119
|
+
brain = mem.get_brain(root)
|
|
120
|
+
config = cfg.load(root)
|
|
121
|
+
except Exception:
|
|
122
|
+
brain, config = {}, {}
|
|
123
|
+
return AgentInput(
|
|
124
|
+
root=root,
|
|
125
|
+
scope=scope or [],
|
|
126
|
+
brain=brain,
|
|
127
|
+
config=config,
|
|
128
|
+
purpose=brain.get("project_purpose", ""),
|
|
129
|
+
domain=brain.get("project_domain", ""),
|
|
130
|
+
context=brain.get("project_context", {}) if isinstance(brain, dict) else {},
|
|
131
|
+
active_domains=active_domains
|
|
132
|
+
or (brain.get("active_security_domains", []) if isinstance(brain, dict) else [])
|
|
133
|
+
or [],
|
|
134
|
+
on_message=lambda n, m, s: None,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run_agent_class(cls, root: Path, scope: list[str] | None = None) -> Any:
|
|
139
|
+
"""Instantiate and run one agent class, returning its AgentResult.
|
|
140
|
+
|
|
141
|
+
A per-agent timeout (``PATCHI_AGENT_TIMEOUT``, default 60s) bounds any
|
|
142
|
+
agent that shells out to an external tool (e.g. ``codeql``) and would
|
|
143
|
+
otherwise hang the whole scan indefinitely when that binary is missing or
|
|
144
|
+
unresponsive. On timeout the agent is treated as failed by the caller, so
|
|
145
|
+
the scan still completes and reports the failure gracefully.
|
|
146
|
+
"""
|
|
147
|
+
timeout = float(os.environ.get("PATCHI_AGENT_TIMEOUT", "60"))
|
|
148
|
+
inp = _build_agent_input(root, scope=scope)
|
|
149
|
+
|
|
150
|
+
def _go() -> Any:
|
|
151
|
+
return cls().run(inp)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
ex = _cf.ThreadPoolExecutor(max_workers=1)
|
|
155
|
+
fut = ex.submit(_go)
|
|
156
|
+
try:
|
|
157
|
+
result = fut.result(timeout=timeout)
|
|
158
|
+
finally:
|
|
159
|
+
# Do NOT join the worker thread on shutdown: if the agent exceeded
|
|
160
|
+
# the timeout we abandon it, but a blocking join would wait for the
|
|
161
|
+
# (still-running) agent subprocess and defeat the timeout entirely.
|
|
162
|
+
ex.shutdown(wait=False)
|
|
163
|
+
except _cf.TimeoutError as e:
|
|
164
|
+
raise TimeoutError(f"{cls.__name__} exceeded {timeout:g}s and was aborted") from e
|
|
165
|
+
try:
|
|
166
|
+
from patchi.core.security.pattern_context import suppress_findings
|
|
167
|
+
|
|
168
|
+
suppress_findings(result)
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Security domain selection ("only activate when needed")
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
# Agents relevant to any project regardless of stack.
|
|
179
|
+
_ALWAYS = (
|
|
180
|
+
"secret",
|
|
181
|
+
"crypto",
|
|
182
|
+
"inject",
|
|
183
|
+
"auth",
|
|
184
|
+
"authz",
|
|
185
|
+
"config",
|
|
186
|
+
"supply",
|
|
187
|
+
"depen",
|
|
188
|
+
"sensit",
|
|
189
|
+
"privac",
|
|
190
|
+
"complian",
|
|
191
|
+
"govern",
|
|
192
|
+
"polic",
|
|
193
|
+
"precheck",
|
|
194
|
+
"red",
|
|
195
|
+
"attack",
|
|
196
|
+
"advers",
|
|
197
|
+
"runtime",
|
|
198
|
+
"probe",
|
|
199
|
+
"blast",
|
|
200
|
+
"history",
|
|
201
|
+
"malware",
|
|
202
|
+
"vuln",
|
|
203
|
+
"taint",
|
|
204
|
+
"leak",
|
|
205
|
+
)
|
|
206
|
+
# Agents only worth running when a web/network surface is present.
|
|
207
|
+
_WEB_ONLY = (
|
|
208
|
+
"header",
|
|
209
|
+
"cors",
|
|
210
|
+
"ratelimit",
|
|
211
|
+
"ssrf",
|
|
212
|
+
"network",
|
|
213
|
+
"appmap",
|
|
214
|
+
"browser",
|
|
215
|
+
"jwt",
|
|
216
|
+
"saml",
|
|
217
|
+
"dns",
|
|
218
|
+
"cdn",
|
|
219
|
+
"email",
|
|
220
|
+
"push",
|
|
221
|
+
"mesh",
|
|
222
|
+
"k8s",
|
|
223
|
+
"iac",
|
|
224
|
+
"container",
|
|
225
|
+
"docker",
|
|
226
|
+
"kube",
|
|
227
|
+
"ssl",
|
|
228
|
+
"tls",
|
|
229
|
+
"xss",
|
|
230
|
+
"csrf",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _security_agent_map(root: Path) -> dict[str, Any]:
|
|
235
|
+
import patchi.core.security.security_agents # noqa: F401 (registers agents)
|
|
236
|
+
from patchi.core.agents.base import AgentGroup, list_agents
|
|
237
|
+
|
|
238
|
+
agents = list_agents(AgentGroup.SECURITY)
|
|
239
|
+
return {a.name: a for a in agents}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _select_security_agents(
|
|
243
|
+
root: Path, name_map: dict[str, Any], area: str | None = None
|
|
244
|
+
) -> list[Any]:
|
|
245
|
+
"""Pick the security agents relevant to THIS project's signals + body tags gating."""
|
|
246
|
+
signals = _detect_signals(root)
|
|
247
|
+
web = "web" in signals or "web_frontend" in signals
|
|
248
|
+
# Try body_tags refined gating: drop web-only if no high/medium core route files
|
|
249
|
+
try:
|
|
250
|
+
from patchi.core.brain.body_tags import load_body_tags
|
|
251
|
+
|
|
252
|
+
tags = load_body_tags(root)
|
|
253
|
+
if tags:
|
|
254
|
+
high_route = any(t.get("is_route_file") and t.get("criticality") in ("high", "critical") for t in tags.values())
|
|
255
|
+
if not high_route:
|
|
256
|
+
web = False
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
selected = []
|
|
260
|
+
for name, cls in name_map.items():
|
|
261
|
+
low = name.lower()
|
|
262
|
+
if any(sub in low for sub in _ALWAYS):
|
|
263
|
+
selected.append(cls)
|
|
264
|
+
elif web and any(sub in low for sub in _WEB_ONLY):
|
|
265
|
+
selected.append(cls)
|
|
266
|
+
return selected
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Tool: scan_vulnerabilities (real security pipeline)
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def scan_vulnerabilities(
|
|
275
|
+
root: Path,
|
|
276
|
+
area: str | None = None,
|
|
277
|
+
domains: list[str] | None = None,
|
|
278
|
+
include_red_team: bool = False,
|
|
279
|
+
) -> dict:
|
|
280
|
+
"""Run the real defensive security agents, auto-activating relevant domains.
|
|
281
|
+
|
|
282
|
+
Returns aggregated finding counts + the (capped) findings list. Results are
|
|
283
|
+
persisted to scan memory so ``query_findings`` can read them later.
|
|
284
|
+
"""
|
|
285
|
+
name_map = _security_agent_map(root)
|
|
286
|
+
if domains:
|
|
287
|
+
selected = [name_map[n] for n in domains if n in name_map]
|
|
288
|
+
else:
|
|
289
|
+
selected = _select_security_agents(root, name_map, area)
|
|
290
|
+
if include_red_team:
|
|
291
|
+
extra = [
|
|
292
|
+
c
|
|
293
|
+
for n, c in name_map.items()
|
|
294
|
+
if any(s in n.lower() for s in ("red", "attack", "advers", "runtime", "probe"))
|
|
295
|
+
and c not in selected
|
|
296
|
+
]
|
|
297
|
+
selected = extra + selected
|
|
298
|
+
|
|
299
|
+
if not selected:
|
|
300
|
+
return {
|
|
301
|
+
"success": True,
|
|
302
|
+
"agent_count": 0,
|
|
303
|
+
"by_severity": {},
|
|
304
|
+
"total_findings": 0,
|
|
305
|
+
"findings": [],
|
|
306
|
+
"message": "No relevant security agents for this project.",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_emit(
|
|
310
|
+
"security.scan.started",
|
|
311
|
+
{"mode": "smart", "agent_count": len(selected), "domains": domains or "auto"},
|
|
312
|
+
)
|
|
313
|
+
agg = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
314
|
+
all_findings: list[dict] = []
|
|
315
|
+
results: list[Any] = []
|
|
316
|
+
scope = [area] if area else None
|
|
317
|
+
_agg_lock = threading.Lock()
|
|
318
|
+
|
|
319
|
+
def _run_one(cls):
|
|
320
|
+
_emit(
|
|
321
|
+
"security.agent.started",
|
|
322
|
+
{"agent": getattr(cls, "name", cls.__name__), "class": cls.__name__},
|
|
323
|
+
)
|
|
324
|
+
try:
|
|
325
|
+
res = _run_agent_class(cls, root, scope=scope)
|
|
326
|
+
except Exception as e: # noqa: BLE001
|
|
327
|
+
_log.error("agent %s failed: %s", cls.__name__, e)
|
|
328
|
+
_emit(
|
|
329
|
+
"security.finding",
|
|
330
|
+
{
|
|
331
|
+
"severity": "info",
|
|
332
|
+
"type": "agent_error",
|
|
333
|
+
"file": "",
|
|
334
|
+
"line": 0,
|
|
335
|
+
"cwe": "",
|
|
336
|
+
"description": f"{cls.__name__} error: {e}",
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
_emit(
|
|
340
|
+
"security.agent.completed",
|
|
341
|
+
{"agent": getattr(cls, "name", cls.__name__), "success": False},
|
|
342
|
+
)
|
|
343
|
+
return None
|
|
344
|
+
try:
|
|
345
|
+
from patchi.core import memory as mem
|
|
346
|
+
|
|
347
|
+
mem.save_scan_result(
|
|
348
|
+
cls.name,
|
|
349
|
+
{
|
|
350
|
+
"status": res.status.value if hasattr(res.status, "value") else str(res.status),
|
|
351
|
+
"duration_ms": getattr(res, "duration_ms", 0),
|
|
352
|
+
"finding_count": res.finding_count,
|
|
353
|
+
"files_scanned": getattr(res, "files_scanned", 0),
|
|
354
|
+
"findings": [f.to_dict() for f in res.findings],
|
|
355
|
+
},
|
|
356
|
+
root,
|
|
357
|
+
)
|
|
358
|
+
except Exception as e: # noqa: BLE001
|
|
359
|
+
_log.warning("save_scan_result failed: %s", e)
|
|
360
|
+
with _agg_lock:
|
|
361
|
+
for f in res.findings:
|
|
362
|
+
sev = f.severity.value if hasattr(f.severity, "value") else str(f.severity)
|
|
363
|
+
agg[sev] = agg.get(sev, 0) + 1
|
|
364
|
+
fd = f.to_dict()
|
|
365
|
+
all_findings.append(fd)
|
|
366
|
+
_emit(
|
|
367
|
+
"security.finding",
|
|
368
|
+
{
|
|
369
|
+
"severity": sev,
|
|
370
|
+
"type": fd.get("type", ""),
|
|
371
|
+
"file": fd.get("file", ""),
|
|
372
|
+
"line": fd.get("line", 0),
|
|
373
|
+
"cwe": fd.get("cwe", ""),
|
|
374
|
+
"description": fd.get("message", ""),
|
|
375
|
+
},
|
|
376
|
+
)
|
|
377
|
+
results.append(res)
|
|
378
|
+
_emit(
|
|
379
|
+
"security.agent.completed",
|
|
380
|
+
{
|
|
381
|
+
"agent": getattr(cls, "name", cls.__name__),
|
|
382
|
+
"success": True,
|
|
383
|
+
"finding_count": res.finding_count,
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
return res
|
|
387
|
+
|
|
388
|
+
# Agents are CPU/GIL-bound (per-file AST parsing), so true parallelism is not
|
|
389
|
+
# achieved with threads and concurrent runs contend on the GIL. Run them
|
|
390
|
+
# sequentially; the per-agent events below still give the live view real
|
|
391
|
+
# working-state feedback.
|
|
392
|
+
for cls in selected:
|
|
393
|
+
_run_one(cls)
|
|
394
|
+
|
|
395
|
+
_emit(
|
|
396
|
+
"security.scan.completed",
|
|
397
|
+
{
|
|
398
|
+
"critical_count": agg["critical"],
|
|
399
|
+
"high_count": agg["high"],
|
|
400
|
+
"medium_count": agg["medium"],
|
|
401
|
+
"low_count": agg["low"],
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
return {
|
|
405
|
+
"success": True,
|
|
406
|
+
"agent_count": len(results),
|
|
407
|
+
"by_severity": agg,
|
|
408
|
+
"total_findings": sum(agg.values()),
|
|
409
|
+
"findings": all_findings[:200],
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# Tool: attack_simulate / red_team (offensive, safe by default)
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
_OFFENSIVE = ("red", "attack", "advers", "runtime", "probe", "exploit", "fuzz", "dast", "exploit")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def attack_simulate(
|
|
421
|
+
root: Path,
|
|
422
|
+
scenarios: list[str] | None = None,
|
|
423
|
+
target_url: str | None = None,
|
|
424
|
+
safe_mode: bool = True,
|
|
425
|
+
use_real_tools: bool = False,
|
|
426
|
+
use_shannon: bool = False,
|
|
427
|
+
) -> dict:
|
|
428
|
+
"""Run offensive agents (static) and, if a URL is given, a SAFE dynamic probe.
|
|
429
|
+
|
|
430
|
+
``safe_mode`` (default True) restricts the dynamic probe to read-only header
|
|
431
|
+
inspection — no payloads are sent.
|
|
432
|
+
If ``use_real_tools`` is True and a target is given, additionally runs
|
|
433
|
+
PentestRegistry engines (nuclei/sqlmap/dalfox) or Shannon via npx.
|
|
434
|
+
"""
|
|
435
|
+
name_map = _security_agent_map(root)
|
|
436
|
+
selected = [c for n, c in name_map.items() if any(s in n.lower() for s in _OFFENSIVE)]
|
|
437
|
+
if scenarios:
|
|
438
|
+
selected = [c for c in selected if any(s in c.__name__.lower() for s in scenarios)]
|
|
439
|
+
if not safe_mode:
|
|
440
|
+
_emit(
|
|
441
|
+
"security.finding",
|
|
442
|
+
{
|
|
443
|
+
"severity": "info",
|
|
444
|
+
"type": "safety",
|
|
445
|
+
"file": "",
|
|
446
|
+
"line": 0,
|
|
447
|
+
"cwe": "",
|
|
448
|
+
"description": "Non-safe attack mode requested — restricting to read-only checks for safety.",
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
safe_mode = True # enforce safety regardless of caller intent
|
|
452
|
+
|
|
453
|
+
_emit(
|
|
454
|
+
"security.scan.started",
|
|
455
|
+
{"mode": "red-team", "agent_count": len(selected), "target_url": target_url or ""},
|
|
456
|
+
)
|
|
457
|
+
findings: list[dict] = []
|
|
458
|
+
_find_lock = threading.Lock()
|
|
459
|
+
|
|
460
|
+
def _run_attack(cls):
|
|
461
|
+
_emit(
|
|
462
|
+
"security.agent.started",
|
|
463
|
+
{"agent": getattr(cls, "name", cls.__name__), "class": cls.__name__},
|
|
464
|
+
)
|
|
465
|
+
try:
|
|
466
|
+
res = _run_agent_class(cls, root)
|
|
467
|
+
with _find_lock:
|
|
468
|
+
for f in res.findings:
|
|
469
|
+
fd = f.to_dict()
|
|
470
|
+
findings.append(fd)
|
|
471
|
+
_emit(
|
|
472
|
+
"security.finding",
|
|
473
|
+
{
|
|
474
|
+
"severity": f.severity.value if hasattr(f.severity, "value") else "info",
|
|
475
|
+
"type": fd.get("type", ""),
|
|
476
|
+
"file": fd.get("file", ""),
|
|
477
|
+
"line": fd.get("line", 0),
|
|
478
|
+
"cwe": fd.get("cwe", ""),
|
|
479
|
+
"description": fd.get("message", ""),
|
|
480
|
+
},
|
|
481
|
+
)
|
|
482
|
+
except Exception as e: # noqa: BLE001
|
|
483
|
+
_log.error("attack agent %s failed: %s", cls.__name__, e)
|
|
484
|
+
_emit(
|
|
485
|
+
"security.agent.completed",
|
|
486
|
+
{"agent": getattr(cls, "name", cls.__name__), "success": True},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
max_workers = min(len(selected), int(os.environ.get("PATCHI_SCAN_WORKERS", "6")) or 1)
|
|
490
|
+
with _cf.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
491
|
+
list(pool.map(_run_attack, selected))
|
|
492
|
+
|
|
493
|
+
dyn = {}
|
|
494
|
+
# Real DAST engines when requested (opt-in, needs target)
|
|
495
|
+
if (use_real_tools or use_shannon) and target_url:
|
|
496
|
+
try:
|
|
497
|
+
from patchi.core import memory as _mem
|
|
498
|
+
|
|
499
|
+
brain = _mem.get_brain(root)
|
|
500
|
+
active = brain.get("active_security_domains", []) if isinstance(brain, dict) else []
|
|
501
|
+
from patchi.core.security.pentest.registry import PentestRegistry
|
|
502
|
+
|
|
503
|
+
reg = PentestRegistry()
|
|
504
|
+
ctx = {"active_domains": active, "routes": brain.get("routes", []) if isinstance(brain, dict) else [], "target_url": target_url, "config": brain, "use_shannon": bool(use_shannon)}
|
|
505
|
+
picks = reg.ai_pick(ctx)
|
|
506
|
+
# shannon is heavy — run alone
|
|
507
|
+
if any(p.get("tool") == "shannon" for p in picks) and use_shannon:
|
|
508
|
+
picks = [p for p in picks if p.get("tool") == "shannon"][:1]
|
|
509
|
+
for p in picks[:3]:
|
|
510
|
+
tool = p.get("tool")
|
|
511
|
+
_emit("security.agent.started", {"agent": tool, "class": tool})
|
|
512
|
+
extra = {"repo_root": str(root)}
|
|
513
|
+
res = reg.run(tool, target_url, safe_mode=safe_mode, workspace=root / ".patchi" / "pentest", extra=extra)
|
|
514
|
+
for f in res.findings:
|
|
515
|
+
fd = {"type": f.get("ruleId") or f.get("template") or f.get("type") or tool, "severity": "high" if tool in ("nuclei","shannon") else "medium", "message": f.get("message") or f.get("name") or str(f)[:300], "file": target_url, "line": 0, "cwe": f.get("cwe","")}
|
|
516
|
+
findings.append(fd)
|
|
517
|
+
_emit("security.finding", {"severity": fd["severity"], "type": fd["type"], "file": target_url, "line": 0, "cwe": fd["cwe"], "description": fd["message"]})
|
|
518
|
+
_emit("security.agent.completed", {"agent": tool, "success": bool(res.success)})
|
|
519
|
+
except Exception as exc: # noqa: BLE001
|
|
520
|
+
_log.warning("pentest registry failed: %s", exc)
|
|
521
|
+
if target_url:
|
|
522
|
+
dyn = _safe_dynamic_probe(target_url)
|
|
523
|
+
for d in dyn.get("issues", []):
|
|
524
|
+
findings.append(
|
|
525
|
+
{
|
|
526
|
+
"type": "dynamic",
|
|
527
|
+
"severity": d.get("severity", "medium"),
|
|
528
|
+
"message": d.get("message", ""),
|
|
529
|
+
"file": target_url,
|
|
530
|
+
"line": 0,
|
|
531
|
+
"cwe": d.get("cwe", ""),
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
_emit(
|
|
535
|
+
"security.finding",
|
|
536
|
+
{
|
|
537
|
+
"severity": d.get("severity", "medium"),
|
|
538
|
+
"type": "dynamic",
|
|
539
|
+
"file": target_url,
|
|
540
|
+
"line": 0,
|
|
541
|
+
"cwe": d.get("cwe", ""),
|
|
542
|
+
"description": d.get("message", ""),
|
|
543
|
+
},
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
547
|
+
for f in findings:
|
|
548
|
+
s = str(f.get("severity", "info")).lower()
|
|
549
|
+
sev_counts[s] = sev_counts.get(s, 0) + 1
|
|
550
|
+
_emit(
|
|
551
|
+
"security.scan.completed",
|
|
552
|
+
{
|
|
553
|
+
"critical_count": sev_counts["critical"],
|
|
554
|
+
"high_count": sev_counts["high"],
|
|
555
|
+
"medium_count": sev_counts["medium"],
|
|
556
|
+
"low_count": sev_counts["low"],
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
return {
|
|
560
|
+
"success": True,
|
|
561
|
+
"findings": findings,
|
|
562
|
+
"by_severity": sev_counts,
|
|
563
|
+
"dynamic_probe": dyn,
|
|
564
|
+
"target_url": target_url,
|
|
565
|
+
"safe_mode": True,
|
|
566
|
+
"total_findings": len(findings),
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _safe_dynamic_probe(target_url: str) -> dict:
|
|
571
|
+
"""Read-only security-header inspection of a live URL (no payloads sent)."""
|
|
572
|
+
try:
|
|
573
|
+
import httpx
|
|
574
|
+
except Exception as e:
|
|
575
|
+
return {"ok": False, "error": f"httpx unavailable: {e}", "issues": []}
|
|
576
|
+
issues: list[dict] = []
|
|
577
|
+
try:
|
|
578
|
+
resp = httpx.get(target_url, timeout=8, follow_redirects=True)
|
|
579
|
+
except Exception as e:
|
|
580
|
+
return {"ok": False, "error": f"Target unreachable: {e}", "issues": []}
|
|
581
|
+
headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
582
|
+
checks = {
|
|
583
|
+
"Content-Security-Policy": "Missing CSP header — XSS risk",
|
|
584
|
+
"Strict-Transport-Security": "Missing HSTS — transport security risk",
|
|
585
|
+
"X-Content-Type-Options": "Missing X-Content-Type-Options — MIME sniffing risk",
|
|
586
|
+
"X-Frame-Options": "Missing X-Frame-Options — clickjacking risk",
|
|
587
|
+
}
|
|
588
|
+
for h, msg in checks.items():
|
|
589
|
+
if h.lower() not in headers:
|
|
590
|
+
issues.append({"severity": "medium", "message": msg, "cwe": "CWE-693"})
|
|
591
|
+
server = headers.get("server")
|
|
592
|
+
if server:
|
|
593
|
+
issues.append(
|
|
594
|
+
{
|
|
595
|
+
"severity": "low",
|
|
596
|
+
"message": f"Server header leaks software: {server}",
|
|
597
|
+
"cwe": "CWE-200",
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
return {"ok": True, "status_code": resp.status_code, "issues": issues}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def red_team(root: Path, scope: str = "full", intensity: str = "active") -> dict:
|
|
604
|
+
"""Full red-team assessment: static attack surface + safe dynamic probing."""
|
|
605
|
+
_emit(
|
|
606
|
+
"security.scan.started", {"mode": "red-team-full", "scope": scope, "intensity": intensity}
|
|
607
|
+
)
|
|
608
|
+
result = attack_simulate(root, safe_mode=True)
|
|
609
|
+
result["scope"] = scope
|
|
610
|
+
result["intensity"] = intensity
|
|
611
|
+
return result
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ---------------------------------------------------------------------------
|
|
615
|
+
# Tool: check_compliance
|
|
616
|
+
# ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def check_compliance(root: Path, standard: str = "owasp-asvs", level: int = 1) -> dict:
|
|
620
|
+
"""Run compliance-relevant security agents and summarize their findings."""
|
|
621
|
+
name_map = _security_agent_map(root)
|
|
622
|
+
keywords = (
|
|
623
|
+
"complian",
|
|
624
|
+
"policy",
|
|
625
|
+
"privacy",
|
|
626
|
+
"soc2",
|
|
627
|
+
"pci",
|
|
628
|
+
"hipaa",
|
|
629
|
+
"gdpr",
|
|
630
|
+
"govern",
|
|
631
|
+
"secret",
|
|
632
|
+
"crypto",
|
|
633
|
+
"auth",
|
|
634
|
+
)
|
|
635
|
+
selected = [c for n, c in name_map.items() if any(k in n.lower() for k in keywords)]
|
|
636
|
+
_emit(
|
|
637
|
+
"security.scan.started",
|
|
638
|
+
{"mode": "compliance", "standard": standard, "agent_count": len(selected)},
|
|
639
|
+
)
|
|
640
|
+
findings: list[dict] = []
|
|
641
|
+
for cls in selected:
|
|
642
|
+
try:
|
|
643
|
+
res = _run_agent_class(cls, root)
|
|
644
|
+
for f in res.findings:
|
|
645
|
+
findings.append(f.to_dict())
|
|
646
|
+
except Exception as e:
|
|
647
|
+
_log.error("compliance agent %s failed: %s", cls.__name__, e)
|
|
648
|
+
controls = {}
|
|
649
|
+
for f in findings:
|
|
650
|
+
ctl = f.get("type", "unknown")
|
|
651
|
+
controls.setdefault(ctl, {"pass": 0, "fail": 0})
|
|
652
|
+
sev = str(f.get("severity", "")).lower()
|
|
653
|
+
if sev in ("critical", "high", "medium"):
|
|
654
|
+
controls[ctl]["fail"] += 1
|
|
655
|
+
else:
|
|
656
|
+
controls[ctl]["pass"] += 1
|
|
657
|
+
_emit(
|
|
658
|
+
"security.scan.completed",
|
|
659
|
+
{
|
|
660
|
+
"critical_count": 0,
|
|
661
|
+
"high_count": 0,
|
|
662
|
+
"medium_count": sum(c["fail"] for c in controls.values()),
|
|
663
|
+
"low_count": 0,
|
|
664
|
+
},
|
|
665
|
+
)
|
|
666
|
+
return {
|
|
667
|
+
"success": True,
|
|
668
|
+
"standard": standard,
|
|
669
|
+
"level": level,
|
|
670
|
+
"controls": controls,
|
|
671
|
+
"total_findings": len(findings),
|
|
672
|
+
"findings": findings[:200],
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
# Tool: run_tests (real pytest)
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def run_tests(
|
|
682
|
+
root: Path,
|
|
683
|
+
test_types: list[str] | None = None,
|
|
684
|
+
area: str | None = None,
|
|
685
|
+
base_url: str | None = None,
|
|
686
|
+
parallel: bool = False,
|
|
687
|
+
) -> dict:
|
|
688
|
+
"""Run the project's pytest suite and return real pass/fail counts."""
|
|
689
|
+
target = str(root / area) if area else str(root)
|
|
690
|
+
cmd = [
|
|
691
|
+
sys.executable,
|
|
692
|
+
"-m",
|
|
693
|
+
"pytest",
|
|
694
|
+
target,
|
|
695
|
+
"-q",
|
|
696
|
+
"--no-header",
|
|
697
|
+
"-p",
|
|
698
|
+
"no:cacheprovider",
|
|
699
|
+
# Skip known-broken/slow subsets so an agent-driven "run the test suite"
|
|
700
|
+
# returns promptly instead of hanging (e.g. the CodeQL self-scan test
|
|
701
|
+
# tries to shell out to a binary that isn't installed in CI/agent runs).
|
|
702
|
+
"-m",
|
|
703
|
+
"not slow and not integration",
|
|
704
|
+
"-k",
|
|
705
|
+
"not test_agent_self_scan_no_false_self_positive",
|
|
706
|
+
"--no-cov" if _HAS_PYTEST_COV else "",
|
|
707
|
+
]
|
|
708
|
+
cmd = [c for c in cmd if c]
|
|
709
|
+
_emit("test.suite.started", {"test_type": (test_types or ["unit"])[0], "test_count": 0})
|
|
710
|
+
try:
|
|
711
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(root), timeout=600)
|
|
712
|
+
except subprocess.TimeoutExpired:
|
|
713
|
+
_emit("test.suite.completed", {"passed": 0, "failed": 0, "coverage_pct": 0.0})
|
|
714
|
+
return {"success": False, "error": "pytest timed out (600s)"}
|
|
715
|
+
out = proc.stdout + proc.stderr
|
|
716
|
+
passed = _extract_count(out, r"(\d+) passed")
|
|
717
|
+
failed = _extract_count(out, r"(\d+) failed")
|
|
718
|
+
errors = _extract_count(out, r"(\d+) error")
|
|
719
|
+
skipped = _extract_count(out, r"(\d+) skipped")
|
|
720
|
+
_emit("test.suite.completed", {"passed": passed, "failed": failed, "coverage_pct": 0.0})
|
|
721
|
+
return {
|
|
722
|
+
"success": proc.returncode == 0 or failed == 0,
|
|
723
|
+
"passed": passed,
|
|
724
|
+
"failed": failed,
|
|
725
|
+
"errors": errors,
|
|
726
|
+
"skipped": skipped,
|
|
727
|
+
"returncode": proc.returncode,
|
|
728
|
+
"summary": out.strip().splitlines()[-1] if out.strip() else "",
|
|
729
|
+
"target": target,
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _extract_count(text: str, pattern: str) -> int:
|
|
734
|
+
import re
|
|
735
|
+
|
|
736
|
+
m = re.search(pattern, text)
|
|
737
|
+
return int(m.group(1)) if m else 0
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# ---------------------------------------------------------------------------
|
|
741
|
+
# Tool: stress_test (real load generator)
|
|
742
|
+
# ---------------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def stress_test(
|
|
746
|
+
root: Path,
|
|
747
|
+
base_url: str,
|
|
748
|
+
scenario: str = "load",
|
|
749
|
+
users: int = 10,
|
|
750
|
+
duration_seconds: int = 30,
|
|
751
|
+
ramp_up_seconds: int = 10,
|
|
752
|
+
) -> dict:
|
|
753
|
+
"""Generate real load against ``base_url`` and report latency/throughput.
|
|
754
|
+
|
|
755
|
+
Returns an error (success=False) if the target is unreachable instead of
|
|
756
|
+
faking success.
|
|
757
|
+
"""
|
|
758
|
+
try:
|
|
759
|
+
import httpx
|
|
760
|
+
except Exception as e:
|
|
761
|
+
return {"success": False, "error": f"httpx unavailable: {e}"}
|
|
762
|
+
|
|
763
|
+
# Reachability check
|
|
764
|
+
try:
|
|
765
|
+
probe = httpx.get(base_url, timeout=5)
|
|
766
|
+
_ = probe.status_code
|
|
767
|
+
except Exception as e:
|
|
768
|
+
return {"success": False, "error": f"Target unreachable: {e}"}
|
|
769
|
+
|
|
770
|
+
latencies: list[float] = []
|
|
771
|
+
errors = 0
|
|
772
|
+
stop = threading.Event()
|
|
773
|
+
lock = threading.Lock()
|
|
774
|
+
|
|
775
|
+
def worker() -> None:
|
|
776
|
+
nonlocal errors
|
|
777
|
+
client = httpx.Client(timeout=5, follow_redirects=True)
|
|
778
|
+
while not stop.is_set():
|
|
779
|
+
t0 = time.monotonic()
|
|
780
|
+
try:
|
|
781
|
+
client.get(base_url)
|
|
782
|
+
dt = (time.monotonic() - t0) * 1000.0
|
|
783
|
+
with lock:
|
|
784
|
+
latencies.append(dt)
|
|
785
|
+
except Exception:
|
|
786
|
+
with lock:
|
|
787
|
+
errors += 1
|
|
788
|
+
time.sleep(0.005)
|
|
789
|
+
|
|
790
|
+
threads = [threading.Thread(target=worker, daemon=True) for _ in range(max(1, users))]
|
|
791
|
+
start = time.monotonic()
|
|
792
|
+
for th in threads:
|
|
793
|
+
th.start()
|
|
794
|
+
# emit periodic updates
|
|
795
|
+
while time.monotonic() - start < duration_seconds:
|
|
796
|
+
time.sleep(1.0)
|
|
797
|
+
with lock:
|
|
798
|
+
n = len(latencies)
|
|
799
|
+
elapsed = time.monotonic() - start
|
|
800
|
+
if n:
|
|
801
|
+
rps = n / elapsed
|
|
802
|
+
_emit(
|
|
803
|
+
"test.stress.update",
|
|
804
|
+
{
|
|
805
|
+
"users": users,
|
|
806
|
+
"rps": round(rps, 1),
|
|
807
|
+
"p50": 0,
|
|
808
|
+
"p95": 0,
|
|
809
|
+
"p99": 0,
|
|
810
|
+
"error_rate": 0.0,
|
|
811
|
+
},
|
|
812
|
+
)
|
|
813
|
+
stop.set()
|
|
814
|
+
for th in threads:
|
|
815
|
+
th.join(timeout=5)
|
|
816
|
+
|
|
817
|
+
with lock:
|
|
818
|
+
sample = list(latencies)
|
|
819
|
+
if not sample:
|
|
820
|
+
return {
|
|
821
|
+
"success": False,
|
|
822
|
+
"error": "No successful requests (target may be down)",
|
|
823
|
+
"users": users,
|
|
824
|
+
"error_count": errors,
|
|
825
|
+
}
|
|
826
|
+
sample.sort()
|
|
827
|
+
total = len(sample) + errors
|
|
828
|
+
|
|
829
|
+
def pct(p: float) -> float:
|
|
830
|
+
idx = min(len(sample) - 1, int(p * len(sample)))
|
|
831
|
+
return round(sample[idx], 2)
|
|
832
|
+
|
|
833
|
+
err_rate = round(errors / total, 4) if total else 0.0
|
|
834
|
+
result = {
|
|
835
|
+
"success": True,
|
|
836
|
+
"scenario": scenario,
|
|
837
|
+
"users": users,
|
|
838
|
+
"duration_seconds": duration_seconds,
|
|
839
|
+
"requests": len(sample),
|
|
840
|
+
"rps": round(len(sample) / duration_seconds, 1),
|
|
841
|
+
"p50_ms": pct(0.50),
|
|
842
|
+
"p95_ms": pct(0.95),
|
|
843
|
+
"p99_ms": pct(0.99),
|
|
844
|
+
"max_ms": round(sample[-1], 2),
|
|
845
|
+
"error_count": errors,
|
|
846
|
+
"error_rate": err_rate,
|
|
847
|
+
"target_url": base_url,
|
|
848
|
+
}
|
|
849
|
+
_emit(
|
|
850
|
+
"test.stress.update",
|
|
851
|
+
{
|
|
852
|
+
"users": users,
|
|
853
|
+
"rps": result["rps"],
|
|
854
|
+
"p50": result["p50_ms"],
|
|
855
|
+
"p95": result["p95_ms"],
|
|
856
|
+
"p99": result["p99_ms"],
|
|
857
|
+
"error_rate": err_rate,
|
|
858
|
+
},
|
|
859
|
+
)
|
|
860
|
+
if err_rate > 0.2:
|
|
861
|
+
_emit("test.stress.break_found", {"breaking_users": users, "breaking_route": base_url})
|
|
862
|
+
return result
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# ---------------------------------------------------------------------------
|
|
866
|
+
# Tool: screenshot / browser_test / visual_regression (real, graceful)
|
|
867
|
+
# ---------------------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def screenshot(
|
|
871
|
+
root: Path,
|
|
872
|
+
url: str,
|
|
873
|
+
selector: str | None = None,
|
|
874
|
+
full_page: bool = True,
|
|
875
|
+
wait_for: str | None = None,
|
|
876
|
+
) -> dict:
|
|
877
|
+
"""Capture a screenshot via Playwright. Fails gracefully if unavailable."""
|
|
878
|
+
try:
|
|
879
|
+
from playwright.sync_api import sync_playwright
|
|
880
|
+
except Exception as e:
|
|
881
|
+
return {"success": False, "error": f"Playwright unavailable: {e}"}
|
|
882
|
+
try:
|
|
883
|
+
with sync_playwright() as p:
|
|
884
|
+
browser = p.chromium.launch()
|
|
885
|
+
page = browser.new_page()
|
|
886
|
+
page.goto(url, wait_until="load", timeout=15000)
|
|
887
|
+
if wait_for:
|
|
888
|
+
page.wait_for_selector(wait_for, timeout=5000)
|
|
889
|
+
if selector:
|
|
890
|
+
el = page.query_selector(selector)
|
|
891
|
+
if not el:
|
|
892
|
+
browser.close()
|
|
893
|
+
return {"success": False, "error": f"selector not found: {selector}"}
|
|
894
|
+
img = el.screenshot()
|
|
895
|
+
else:
|
|
896
|
+
img = page.screenshot(full_page=full_page)
|
|
897
|
+
browser.close()
|
|
898
|
+
b64 = base64.b64encode(img).decode()
|
|
899
|
+
_emit(
|
|
900
|
+
"test.browser.step_passed", {"flow_name": "screenshot", "step": f"captured {url}"}
|
|
901
|
+
)
|
|
902
|
+
return {"success": True, "screenshot_base64": b64, "bytes": len(img), "url": url}
|
|
903
|
+
except Exception as e:
|
|
904
|
+
return {"success": False, "error": f"Screenshot failed: {e}"}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def browser_test(
|
|
908
|
+
root: Path,
|
|
909
|
+
script: str,
|
|
910
|
+
base_url: str | None = None,
|
|
911
|
+
headless: bool = True,
|
|
912
|
+
record_video: bool = False,
|
|
913
|
+
) -> dict:
|
|
914
|
+
"""Execute a small Playwright navigation script (goto/fill/click/expect)."""
|
|
915
|
+
try:
|
|
916
|
+
from playwright.sync_api import sync_playwright
|
|
917
|
+
except Exception as e:
|
|
918
|
+
return {"success": False, "error": f"Playwright unavailable: {e}"}
|
|
919
|
+
steps = [s.strip() for s in script.split(";") if s.strip()]
|
|
920
|
+
try:
|
|
921
|
+
with sync_playwright() as p:
|
|
922
|
+
browser = p.chromium.launch(headless=headless)
|
|
923
|
+
page = browser.new_page()
|
|
924
|
+
passed = 0
|
|
925
|
+
for step in steps:
|
|
926
|
+
_emit("test.browser.flow_started", {"flow_name": "browser_test", "steps": [step]})
|
|
927
|
+
if step.startswith("goto("):
|
|
928
|
+
page.goto(_expand(step[5:-1], base_url), timeout=15000)
|
|
929
|
+
elif step.startswith("fill("):
|
|
930
|
+
sel, val = _parse_two(step[5:-1])
|
|
931
|
+
page.fill(sel, val)
|
|
932
|
+
elif step.startswith("click("):
|
|
933
|
+
page.click(_strip(step[6:-1]))
|
|
934
|
+
elif step.startswith("expect("):
|
|
935
|
+
page.wait_for_selector(_strip(step[7:-1]), timeout=5000)
|
|
936
|
+
passed += 1
|
|
937
|
+
_emit("test.browser.step_passed", {"flow_name": "browser_test", "step": step})
|
|
938
|
+
browser.close()
|
|
939
|
+
return {"success": True, "steps_executed": passed, "steps_total": len(steps)}
|
|
940
|
+
except Exception as e:
|
|
941
|
+
return {"success": False, "error": f"Browser test failed: {e}", "steps_executed": 0}
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _strip(s: str) -> str:
|
|
945
|
+
return s.strip().strip("'\"")
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _expand(s: str, base: str | None) -> str:
|
|
949
|
+
s = _strip(s)
|
|
950
|
+
if base and s.startswith("/"):
|
|
951
|
+
return base.rstrip("/") + s
|
|
952
|
+
return s
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _parse_two(s: str) -> tuple[str, str]:
|
|
956
|
+
# crude "sel, value" parser handling quoted strings
|
|
957
|
+
parts = [p.strip() for p in s.split(",", 1)]
|
|
958
|
+
if len(parts) == 2:
|
|
959
|
+
return _strip(parts[0]), _strip(parts[1])
|
|
960
|
+
return _strip(parts[0]), ""
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def visual_regression(root: Path, urls: list[str], threshold: float = 0.1) -> dict:
|
|
964
|
+
"""Capture screenshots for URLs and compare against stored baselines.
|
|
965
|
+
|
|
966
|
+
First run stores baselines; subsequent runs report diffs. Degrades
|
|
967
|
+
gracefully if Playwright is unavailable.
|
|
968
|
+
"""
|
|
969
|
+
baseline_dir = root / ".patchi" / "visual_baselines"
|
|
970
|
+
baseline_dir.mkdir(parents=True, exist_ok=True)
|
|
971
|
+
try:
|
|
972
|
+
from playwright.sync_api import sync_playwright
|
|
973
|
+
except Exception as e:
|
|
974
|
+
return {"success": False, "error": f"Playwright unavailable: {e}"}
|
|
975
|
+
results = []
|
|
976
|
+
try:
|
|
977
|
+
with sync_playwright() as p:
|
|
978
|
+
browser = p.chromium.launch()
|
|
979
|
+
page = browser.new_page()
|
|
980
|
+
for url in urls:
|
|
981
|
+
bl = baseline_dir / (url.replace("/", "_").replace(":", "_") + ".png")
|
|
982
|
+
page.goto(url, wait_until="load", timeout=15000)
|
|
983
|
+
img = page.screenshot(full_page=True)
|
|
984
|
+
if bl.exists():
|
|
985
|
+
prev = bl.read_bytes()
|
|
986
|
+
diff = abs(len(prev) - len(img)) / max(1, len(prev))
|
|
987
|
+
results.append(
|
|
988
|
+
{"url": url, "changed": diff > threshold, "delta": round(diff, 3)}
|
|
989
|
+
)
|
|
990
|
+
else:
|
|
991
|
+
bl.write_bytes(img)
|
|
992
|
+
results.append(
|
|
993
|
+
{"url": url, "changed": False, "delta": 0.0, "baseline_created": True}
|
|
994
|
+
)
|
|
995
|
+
browser.close()
|
|
996
|
+
except Exception as e:
|
|
997
|
+
return {"success": False, "error": f"Visual regression failed: {e}"}
|
|
998
|
+
return {"success": True, "results": results}
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
# ---------------------------------------------------------------------------
|
|
1002
|
+
# Tool: generate_tests (real skeleton generator, non-destructive)
|
|
1003
|
+
# ---------------------------------------------------------------------------
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def read_file(root: Path, path: str, start: int = 1, end: int = 500) -> dict:
|
|
1007
|
+
"""Validated file slice for LLM tool calls — 500 lines / 3 calls limit enforced by caller."""
|
|
1008
|
+
try:
|
|
1009
|
+
from patchi.core.brain.import_graph import KNOWN_EXTENSIONS
|
|
1010
|
+
|
|
1011
|
+
# path traversal guard
|
|
1012
|
+
rel = Path(path)
|
|
1013
|
+
if rel.is_absolute() or ".." in rel.parts:
|
|
1014
|
+
return {"success": False, "error": "invalid path"}
|
|
1015
|
+
full = (root / rel).resolve()
|
|
1016
|
+
# ensure inside root
|
|
1017
|
+
try:
|
|
1018
|
+
full.relative_to(root.resolve())
|
|
1019
|
+
except ValueError:
|
|
1020
|
+
return {"success": False, "error": "outside repo"}
|
|
1021
|
+
if full.suffix not in KNOWN_EXTENSIONS and full.suffix not in ("", ".py", ".js", ".ts"):
|
|
1022
|
+
# allow any source, but cap size
|
|
1023
|
+
pass
|
|
1024
|
+
if not full.is_file():
|
|
1025
|
+
return {"success": False, "error": "not found"}
|
|
1026
|
+
if full.stat().st_size > 2 * 1024 * 1024:
|
|
1027
|
+
return {"success": False, "error": ">2MB"}
|
|
1028
|
+
end = min(end, start + 500 - 1)
|
|
1029
|
+
lines = full.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1030
|
+
sliced = lines[max(0, start - 1) : min(len(lines), end)]
|
|
1031
|
+
numbered = "\n".join(f"{i+1:4d} | {line}" for i, line in enumerate(sliced, start=start))
|
|
1032
|
+
return {"success": True, "path": str(rel), "start": start, "end": end, "content": numbered}
|
|
1033
|
+
except Exception as exc: # noqa: BLE001
|
|
1034
|
+
return {"success": False, "error": str(exc)}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def generate_tests(
|
|
1038
|
+
root: Path, target_files: list[str], test_type: str = "unit", framework: str | None = None
|
|
1039
|
+
) -> dict:
|
|
1040
|
+
"""Generate minimal, real pytest skeletons for the given source files.
|
|
1041
|
+
|
|
1042
|
+
Files are written to ``.patchi/generated_tests/`` — never over the source —
|
|
1043
|
+
so the action is non-destructive.
|
|
1044
|
+
If target_files is empty or ["auto"], uses Understander to find untested core files.
|
|
1045
|
+
"""
|
|
1046
|
+
if not target_files or target_files == ["auto"]:
|
|
1047
|
+
try:
|
|
1048
|
+
from patchi.core.brain.body_tags import load_body_tags
|
|
1049
|
+
from patchi.core.brain.file_corpus import FileCorpus
|
|
1050
|
+
from patchi.core.brain.understander import Understander
|
|
1051
|
+
|
|
1052
|
+
tags = load_body_tags(root)
|
|
1053
|
+
# need file_infos for understander — quick corpus probe
|
|
1054
|
+
corpus = FileCorpus(root)
|
|
1055
|
+
# Build pseudo file_infos from corpus entries
|
|
1056
|
+
class _FI:
|
|
1057
|
+
def __init__(self, p: str):
|
|
1058
|
+
self.path = p
|
|
1059
|
+
fis = [_FI(e.path) for e in corpus.files()]
|
|
1060
|
+
# If tags empty, fall back to corpus high-size
|
|
1061
|
+
if not tags:
|
|
1062
|
+
# take 5 largest non-test python files as core hint
|
|
1063
|
+
cand = sorted([e for e in corpus.files() if e.path.endswith(".py") and "tests" not in e.path], key=lambda e: -e.size_bytes)[:5]
|
|
1064
|
+
target_files = [c.path for c in cand]
|
|
1065
|
+
else:
|
|
1066
|
+
u = Understander(root, fis, tags, {}, [])
|
|
1067
|
+
core = u.core_files(limit=10)
|
|
1068
|
+
# filter already tested (tests/test_<stem>.py exists)
|
|
1069
|
+
auto: list[str] = []
|
|
1070
|
+
for c in core:
|
|
1071
|
+
stem = Path(c["path"]).stem
|
|
1072
|
+
if not (root / f"tests/test_{stem}.py").exists() and not (root / f"tests/{stem}_test.py").exists():
|
|
1073
|
+
auto.append(c["path"])
|
|
1074
|
+
target_files = auto[:5] if auto else [c["path"] for c in core[:3]]
|
|
1075
|
+
except Exception:
|
|
1076
|
+
pass
|
|
1077
|
+
out_dir = root / ".patchi" / "generated_tests"
|
|
1078
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1079
|
+
created = []
|
|
1080
|
+
for tf in target_files:
|
|
1081
|
+
src = Path(tf)
|
|
1082
|
+
if not src.is_absolute():
|
|
1083
|
+
src = root / tf
|
|
1084
|
+
if not src.exists():
|
|
1085
|
+
created.append({"file": str(tf), "status": "missing"})
|
|
1086
|
+
continue
|
|
1087
|
+
module = src.stem
|
|
1088
|
+
test_src = (
|
|
1089
|
+
f"# Auto-generated by Patchi SmartAgent ({test_type})\n"
|
|
1090
|
+
f"import pytest\n"
|
|
1091
|
+
f"from pathlib import Path\n\n"
|
|
1092
|
+
f'MODULE = "{src.as_posix()}"\n\n\n'
|
|
1093
|
+
f"def test_{module}_importable():\n"
|
|
1094
|
+
f" module_path = Path(MODULE)\n"
|
|
1095
|
+
f" assert module_path.exists(), f'{{MODULE}} does not exist'\n"
|
|
1096
|
+
f" assert module_path.suffix == '.py', f'{{MODULE}} is not a Python file'\n"
|
|
1097
|
+
)
|
|
1098
|
+
dest = out_dir / f"test_{module}.py"
|
|
1099
|
+
dest.write_text(test_src, encoding="utf-8")
|
|
1100
|
+
created.append({"file": str(dest), "source": str(src), "status": "created"})
|
|
1101
|
+
_emit("test.suite.started", {"test_type": test_type, "test_count": len(created)})
|
|
1102
|
+
_emit("test.suite.completed", {"passed": len(created), "failed": 0, "coverage_pct": 0.0})
|
|
1103
|
+
return {"success": True, "created": created, "output_dir": str(out_dir)}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
# ---------------------------------------------------------------------------
|
|
1107
|
+
# Tool: start_web_server (actually boots the Patchi web UI)
|
|
1108
|
+
# ---------------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def start_web_server(root: Path, port: int = 8000, host: str = "127.0.0.1") -> dict:
|
|
1112
|
+
"""Start the Patchi web dashboard in a background thread."""
|
|
1113
|
+
try:
|
|
1114
|
+
import uvicorn
|
|
1115
|
+
|
|
1116
|
+
from patchi.web.app import create_app
|
|
1117
|
+
except Exception as e:
|
|
1118
|
+
return {"success": False, "error": f"Web stack unavailable: {e}"}
|
|
1119
|
+
app = create_app(root)
|
|
1120
|
+
cfg = uvicorn.Config(app, host=host, port=port, log_level="warning")
|
|
1121
|
+
server = uvicorn.Server(cfg)
|
|
1122
|
+
t = threading.Thread(target=server.run, daemon=True)
|
|
1123
|
+
t.start()
|
|
1124
|
+
# Give it a moment to bind
|
|
1125
|
+
time.sleep(1.0)
|
|
1126
|
+
url = f"http://{host}:{port}"
|
|
1127
|
+
_emit("status", {"mode": "web", "active_agents": 0, "queue_depth": 0, "brain_fresh": True})
|
|
1128
|
+
return {"success": True, "url": url, "message": "Web server started (background)"}
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
# ---------------------------------------------------------------------------
|
|
1132
|
+
# Tool: analyze_project (brain scan + charter-grade understanding)
|
|
1133
|
+
# ---------------------------------------------------------------------------
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def analyze_project(root: Path, area: str | None = None) -> dict:
|
|
1137
|
+
"""Run a real brain scan and summarize what the project actually is."""
|
|
1138
|
+
try:
|
|
1139
|
+
from patchi.core.brain.brain import Brain
|
|
1140
|
+
|
|
1141
|
+
brain = Brain(root)
|
|
1142
|
+
report = brain.scan(area)
|
|
1143
|
+
_emit(
|
|
1144
|
+
"brain.scan.completed",
|
|
1145
|
+
{
|
|
1146
|
+
"file_count": getattr(report, "file_count", 0),
|
|
1147
|
+
"route_count": getattr(report, "route_count", 0),
|
|
1148
|
+
"health_score": 0,
|
|
1149
|
+
},
|
|
1150
|
+
)
|
|
1151
|
+
return {
|
|
1152
|
+
"success": True,
|
|
1153
|
+
"file_count": getattr(report, "file_count", 0),
|
|
1154
|
+
"route_count": getattr(report, "route_count", 0),
|
|
1155
|
+
"framework": getattr(report, "framework", "Unknown"),
|
|
1156
|
+
"report": getattr(report, "summary_dict", lambda: {})(),
|
|
1157
|
+
}
|
|
1158
|
+
except Exception as e:
|
|
1159
|
+
_log.error("analyze_project failed: %s", e)
|
|
1160
|
+
return {"success": False, "error": str(e)}
|