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,1012 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent base class and shared result types for Patchi.
|
|
3
|
+
|
|
4
|
+
Every agent in the system — scanner, fix, test, security, guard — inherits
|
|
5
|
+
from BaseAgent and returns an AgentResult. No agent ever:
|
|
6
|
+
- Writes files directly (only fix agents propose patches; the queue applies them)
|
|
7
|
+
- Calls AI when static analysis is sufficient
|
|
8
|
+
- Runs outside its declared scope
|
|
9
|
+
|
|
10
|
+
Agent contract:
|
|
11
|
+
1. Receive AgentInput (project root, target scope, brain data, config)
|
|
12
|
+
2. Do work (static analysis / AST parse / subprocess / http request)
|
|
13
|
+
3. Return AgentResult (findings, metadata, duration, errors)
|
|
14
|
+
4. Done. Never mutate state. Never touch the queue directly.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
import fnmatch
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
import traceback
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from enum import StrEnum
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from patchi.core.brain.file_corpus import FileCorpus
|
|
31
|
+
from patchi.core.brain.languages import DEFAULT_IGNORE_DIRS, is_minified_asset
|
|
32
|
+
|
|
33
|
+
# ponytail: rglob traverses node_modules (30k+ files). Shared skip sets.
|
|
34
|
+
_SKIP_DIRS = frozenset(DEFAULT_IGNORE_DIRS)
|
|
35
|
+
_SKIP_FILES = frozenset(
|
|
36
|
+
{"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock", "Gemfile.lock"}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _skip_asset(fname: str, skip_files: frozenset[str]) -> bool:
|
|
41
|
+
"""True when a filename is user-skipped or a minified/bundled asset."""
|
|
42
|
+
return fname in skip_files or is_minified_asset(fname)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scope_allows(inp: AgentInput, rel_path: str) -> bool:
|
|
46
|
+
"""True when `rel_path` is inside the agent's target scope.
|
|
47
|
+
|
|
48
|
+
Empty scope means whole-tree (unchanged behavior). Entries match as
|
|
49
|
+
exact files or directory prefixes, so per-file sharding and --since
|
|
50
|
+
scoping work without touching every agent's walk logic.
|
|
51
|
+
"""
|
|
52
|
+
scope = inp.scope or []
|
|
53
|
+
if not scope:
|
|
54
|
+
return True
|
|
55
|
+
rel = rel_path.replace("\\", "/").lstrip("./")
|
|
56
|
+
for entry in scope:
|
|
57
|
+
e = str(entry).replace("\\", "/").lstrip("./")
|
|
58
|
+
if rel == e or rel.startswith(e.rstrip("/") + "/"):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_log = logging.getLogger("patchi.agents.base")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def safe_rglob(
|
|
67
|
+
root: Path,
|
|
68
|
+
pattern: str,
|
|
69
|
+
skip_files: frozenset[str] = frozenset(),
|
|
70
|
+
corpus: FileCorpus | None = None,
|
|
71
|
+
):
|
|
72
|
+
"""Yield Path objects matching `pattern` without entering node_modules etc.
|
|
73
|
+
|
|
74
|
+
When `corpus` is provided, uses the pre-built FileCorpus index instead of
|
|
75
|
+
walking the filesystem — avoids redundant directory traversal across
|
|
76
|
+
the 60+ callers that each independently walk the tree.
|
|
77
|
+
"""
|
|
78
|
+
if corpus is not None:
|
|
79
|
+
for entry in corpus.by_glob(pattern):
|
|
80
|
+
if _skip_asset(entry.path, skip_files) or _skip_asset(
|
|
81
|
+
Path(entry.path).name, skip_files
|
|
82
|
+
):
|
|
83
|
+
continue
|
|
84
|
+
yield root / entry.path
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if "/" not in pattern and "**" not in pattern:
|
|
88
|
+
# Simple pattern like "*.py" — walk with pruning
|
|
89
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
90
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
91
|
+
for fname in filenames:
|
|
92
|
+
if _skip_asset(fname, skip_files):
|
|
93
|
+
continue
|
|
94
|
+
if fnmatch.fnmatch(fname, pattern):
|
|
95
|
+
yield Path(dirpath) / fname
|
|
96
|
+
elif "**" in pattern:
|
|
97
|
+
# Compound pattern like "**/routes/**" — split and match
|
|
98
|
+
parts = pattern.split("/")
|
|
99
|
+
mid = parts[1] if len(parts) > 2 else None
|
|
100
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
101
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
102
|
+
rel = Path(dirpath).relative_to(root).as_posix()
|
|
103
|
+
if mid and mid not in rel.split("/"):
|
|
104
|
+
continue
|
|
105
|
+
for fname in filenames:
|
|
106
|
+
if _skip_asset(fname, skip_files):
|
|
107
|
+
continue
|
|
108
|
+
if fnmatch.fnmatch(fname, parts[-1]):
|
|
109
|
+
yield Path(dirpath) / fname
|
|
110
|
+
else:
|
|
111
|
+
# Compound pattern like ".github/workflows/*" — walk, match dir+filename
|
|
112
|
+
parts = pattern.rsplit("/", 1)
|
|
113
|
+
dir_part = parts[0] if len(parts) > 1 else ""
|
|
114
|
+
file_part = parts[-1]
|
|
115
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
116
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
117
|
+
rel = Path(dirpath).relative_to(root).as_posix()
|
|
118
|
+
if dir_part and dir_part not in rel:
|
|
119
|
+
continue
|
|
120
|
+
for fname in filenames:
|
|
121
|
+
if _skip_asset(fname, skip_files):
|
|
122
|
+
continue
|
|
123
|
+
if fnmatch.fnmatch(fname, file_part):
|
|
124
|
+
yield Path(dirpath) / fname
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Agent groups ───────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AgentGroup(StrEnum):
|
|
131
|
+
SCANNER = "scanner"
|
|
132
|
+
FIX = "fix"
|
|
133
|
+
TEST = "test"
|
|
134
|
+
SECURITY = "security"
|
|
135
|
+
GUARD = "guard"
|
|
136
|
+
|
|
137
|
+
def label(self) -> str:
|
|
138
|
+
labels = {
|
|
139
|
+
"scanner": "Scanner Agents",
|
|
140
|
+
"fix": "Fix Agents",
|
|
141
|
+
"test": "Test Agents",
|
|
142
|
+
"security": "Security Agents",
|
|
143
|
+
"guard": "Guard Agents",
|
|
144
|
+
}
|
|
145
|
+
return labels[self.value]
|
|
146
|
+
|
|
147
|
+
def ant_type(self) -> str:
|
|
148
|
+
"""Which ant type represents this group in the web UI colony."""
|
|
149
|
+
return {
|
|
150
|
+
"scanner": "minor_worker",
|
|
151
|
+
"fix": "minor_worker",
|
|
152
|
+
"test": "minor_worker",
|
|
153
|
+
"security": "soldier",
|
|
154
|
+
"guard": "soldier",
|
|
155
|
+
}[self.value]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class AgentDomain(StrEnum):
|
|
159
|
+
"""User-facing domain categories for organizing agents by purpose."""
|
|
160
|
+
|
|
161
|
+
TESTING = "testing" # Does my code work?
|
|
162
|
+
CODE_QUALITY = "code_quality" # Is my code well-structured?
|
|
163
|
+
INTEGRATION = "integration" # Is everything wired?
|
|
164
|
+
PERFORMANCE = "performance" # Does it perform?
|
|
165
|
+
SECURITY = "security" # Are there security holes?
|
|
166
|
+
VISUAL = "visual" # Does it look right?
|
|
167
|
+
INFRASTRUCTURE = "infrastructure" # Is the project set up?
|
|
168
|
+
|
|
169
|
+
def label(self) -> str:
|
|
170
|
+
return {
|
|
171
|
+
"testing": "Testing",
|
|
172
|
+
"code_quality": "Code Quality",
|
|
173
|
+
"integration": "Integration",
|
|
174
|
+
"performance": "Performance",
|
|
175
|
+
"security": "Security",
|
|
176
|
+
"visual": "Visual",
|
|
177
|
+
"infrastructure": "Infrastructure",
|
|
178
|
+
}[self.value]
|
|
179
|
+
|
|
180
|
+
def description(self) -> str:
|
|
181
|
+
return {
|
|
182
|
+
"testing": "Does my code work?",
|
|
183
|
+
"code_quality": "Is my code well-structured?",
|
|
184
|
+
"integration": "Is everything wired?",
|
|
185
|
+
"performance": "Does it perform?",
|
|
186
|
+
"security": "Are there security holes?",
|
|
187
|
+
"visual": "Does it look right?",
|
|
188
|
+
"infrastructure": "Is the project set up?",
|
|
189
|
+
}[self.value]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── Agent status ───────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class AgentStatus(StrEnum):
|
|
196
|
+
IDLE = "idle"
|
|
197
|
+
RUNNING = "running"
|
|
198
|
+
DONE = "done"
|
|
199
|
+
SUCCEEDED = "done"
|
|
200
|
+
FAILED = "failed"
|
|
201
|
+
SKIPPED = "skipped"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Severity levels (used across all finding types) ────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Severity(StrEnum):
|
|
208
|
+
CRITICAL = "critical" # level 1 alert
|
|
209
|
+
HIGH = "high" # level 2 alert
|
|
210
|
+
MEDIUM = "medium" # level 3 alert
|
|
211
|
+
LOW = "low" # level 4, digest only
|
|
212
|
+
INFO = "info" # never alerts, informational only
|
|
213
|
+
|
|
214
|
+
def color(self) -> str:
|
|
215
|
+
return {
|
|
216
|
+
"critical": "#FF4D6D",
|
|
217
|
+
"high": "#FF8C42",
|
|
218
|
+
"medium": "#FACC15",
|
|
219
|
+
"low": "#4ADE80",
|
|
220
|
+
"info": "#B8A898",
|
|
221
|
+
}[self.value]
|
|
222
|
+
|
|
223
|
+
def sort_key(self) -> int:
|
|
224
|
+
return {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}[self.value]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Finding — universal result item ───────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class Finding:
|
|
232
|
+
"""
|
|
233
|
+
A single finding from any agent.
|
|
234
|
+
Used by scanner, security, and guard agents.
|
|
235
|
+
Fix agents produce Patch objects , not findings.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
agent: str # agent name that produced this
|
|
239
|
+
type: str # e.g. "dead_code", "missing_type", "todo_comment"
|
|
240
|
+
severity: Severity
|
|
241
|
+
file: str # relative path to project root
|
|
242
|
+
line: int = 0 # line number (0 = file-level finding)
|
|
243
|
+
column: int = 0
|
|
244
|
+
message: str = "" # one sentence, human readable
|
|
245
|
+
detail: str = "" # full context, multi-line
|
|
246
|
+
code_snippet: str = "" # the relevant code, never secret values
|
|
247
|
+
suggestion: str = "" # what to do about it (no code — that's fix agents)
|
|
248
|
+
fix_agent: str | None = None # which fix agent handles this
|
|
249
|
+
cwe: str = "" # CWE reference if applicable
|
|
250
|
+
extra: dict = field(default_factory=dict)
|
|
251
|
+
|
|
252
|
+
def to_dict(self) -> dict:
|
|
253
|
+
return {
|
|
254
|
+
"agent": self.agent,
|
|
255
|
+
"type": self.type,
|
|
256
|
+
"severity": self.severity.value,
|
|
257
|
+
"file": self.file,
|
|
258
|
+
"line": self.line,
|
|
259
|
+
"column": self.column,
|
|
260
|
+
"message": self.message,
|
|
261
|
+
"detail": self.detail,
|
|
262
|
+
"code_snippet": self.code_snippet,
|
|
263
|
+
"suggestion": self.suggestion,
|
|
264
|
+
"fix_agent": self.fix_agent,
|
|
265
|
+
"cwe": self.cwe,
|
|
266
|
+
**self.extra,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def from_dict(cls, d: dict) -> Finding:
|
|
271
|
+
extra_keys = {
|
|
272
|
+
"agent",
|
|
273
|
+
"type",
|
|
274
|
+
"severity",
|
|
275
|
+
"file",
|
|
276
|
+
"line",
|
|
277
|
+
"column",
|
|
278
|
+
"message",
|
|
279
|
+
"detail",
|
|
280
|
+
"code_snippet",
|
|
281
|
+
"suggestion",
|
|
282
|
+
"fix_agent",
|
|
283
|
+
"cwe",
|
|
284
|
+
}
|
|
285
|
+
extra = {k: v for k, v in d.items() if k not in extra_keys}
|
|
286
|
+
return cls(
|
|
287
|
+
agent=d.get("agent", ""),
|
|
288
|
+
type=d.get("type", ""),
|
|
289
|
+
severity=Severity(d.get("severity", "medium")),
|
|
290
|
+
file=d.get("file", ""),
|
|
291
|
+
line=d.get("line", 0),
|
|
292
|
+
column=d.get("column", 0),
|
|
293
|
+
message=d.get("message", ""),
|
|
294
|
+
detail=d.get("detail", ""),
|
|
295
|
+
code_snippet=d.get("code_snippet", ""),
|
|
296
|
+
suggestion=d.get("suggestion", ""),
|
|
297
|
+
fix_agent=d.get("fix_agent"),
|
|
298
|
+
cwe=d.get("cwe", ""),
|
|
299
|
+
extra=extra,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def title(self) -> str:
|
|
304
|
+
"""Legacy alias for finding message/title compatibility."""
|
|
305
|
+
return self.message
|
|
306
|
+
|
|
307
|
+
@title.setter
|
|
308
|
+
def title(self, value: str) -> None:
|
|
309
|
+
self.message = value
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ── Agent input ────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class AgentInput:
|
|
317
|
+
"""
|
|
318
|
+
Standard input passed to every agent's run() method.
|
|
319
|
+
|
|
320
|
+
Agents must not read config or brain directly — everything
|
|
321
|
+
they need is injected here. This keeps agents testable in isolation.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
root: Path # project root
|
|
325
|
+
scope: list[str] # relative file paths to analyse (empty = all)
|
|
326
|
+
brain: dict # current brain state (from memory.get_brain())
|
|
327
|
+
config: dict # current config (from config.load())
|
|
328
|
+
extra: dict = field(default_factory=dict) # agent-specific params
|
|
329
|
+
purpose: str = "" # project purpose (from brain scan, e.g. "A AI CLI for code scanning")
|
|
330
|
+
domain: str = "" # project domain (e.g. "dev-tool", "web-app", "library")
|
|
331
|
+
context: dict = field(default_factory=dict) # rich project context from brain context phase
|
|
332
|
+
active_domains: list[str] = field(default_factory=list) # activated security domain IDs
|
|
333
|
+
on_message: callable | None = (
|
|
334
|
+
None # callback for live progress streaming: fn(agent_name, message, style)
|
|
335
|
+
)
|
|
336
|
+
on_ai_progress: callable | None = (
|
|
337
|
+
None # callback for AI call progress: fn(message)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ── Agent result ───────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class AgentResult:
|
|
346
|
+
"""
|
|
347
|
+
Standard output from every agent's run() method.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
agent_name: str = ""
|
|
351
|
+
agent_group: AgentGroup = AgentGroup.SCANNER
|
|
352
|
+
status: AgentStatus = AgentStatus.IDLE
|
|
353
|
+
started_at: str = ""
|
|
354
|
+
completed_at: str = ""
|
|
355
|
+
duration_ms: int = 0
|
|
356
|
+
metadata: dict | None = None
|
|
357
|
+
|
|
358
|
+
findings: list[Finding] = field(default_factory=list)
|
|
359
|
+
data: dict = field(default_factory=dict) # structured output beyond findings
|
|
360
|
+
errors: list[str] = field(default_factory=list)
|
|
361
|
+
files_scanned: int = 0
|
|
362
|
+
ai_calls_made: int = 0 # track AI usage for cost awareness
|
|
363
|
+
|
|
364
|
+
def __post_init__(self) -> None:
|
|
365
|
+
if self.metadata is not None:
|
|
366
|
+
self.data.update(self.metadata)
|
|
367
|
+
|
|
368
|
+
def add_finding(self, finding: Finding) -> None:
|
|
369
|
+
self.findings.append(finding)
|
|
370
|
+
|
|
371
|
+
def add_error(self, error: str) -> None:
|
|
372
|
+
self.errors.append(error)
|
|
373
|
+
self.status = AgentStatus.FAILED
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def finding_count(self) -> int:
|
|
377
|
+
return len(self.findings)
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def has_critical(self) -> bool:
|
|
381
|
+
return any(f.severity == Severity.CRITICAL for f in self.findings)
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def by_severity(self) -> dict[str, list[Finding]]:
|
|
385
|
+
result: dict[str, list[Finding]] = {s.value: [] for s in Severity}
|
|
386
|
+
for f in self.findings:
|
|
387
|
+
result[f.severity.value].append(f)
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
def to_dict(self) -> dict:
|
|
391
|
+
return {
|
|
392
|
+
"agent": self.agent_name,
|
|
393
|
+
"group": self.agent_group.value,
|
|
394
|
+
"status": self.status.value,
|
|
395
|
+
"started_at": self.started_at,
|
|
396
|
+
"completed_at": self.completed_at,
|
|
397
|
+
"duration_ms": self.duration_ms,
|
|
398
|
+
"files_scanned": self.files_scanned,
|
|
399
|
+
"ai_calls_made": self.ai_calls_made,
|
|
400
|
+
"finding_count": self.finding_count,
|
|
401
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
402
|
+
"data": self.data,
|
|
403
|
+
"errors": self.errors,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def from_dict(cls, d: dict) -> AgentResult:
|
|
408
|
+
return cls(
|
|
409
|
+
agent_name=d.get("agent", ""),
|
|
410
|
+
agent_group=AgentGroup(d.get("group", "scanner")),
|
|
411
|
+
status=AgentStatus(d.get("status", "idle")),
|
|
412
|
+
started_at=d.get("started_at", ""),
|
|
413
|
+
completed_at=d.get("completed_at", ""),
|
|
414
|
+
duration_ms=d.get("duration_ms", 0),
|
|
415
|
+
files_scanned=d.get("files_scanned", 0),
|
|
416
|
+
ai_calls_made=d.get("ai_calls_made", 0),
|
|
417
|
+
findings=[Finding.from_dict(f) for f in d.get("findings", [])],
|
|
418
|
+
data=d.get("data", {}),
|
|
419
|
+
errors=d.get("errors", []),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def summary_line(self) -> str:
|
|
423
|
+
status_icon = {
|
|
424
|
+
AgentStatus.DONE: "✓",
|
|
425
|
+
AgentStatus.FAILED: "✗",
|
|
426
|
+
AgentStatus.SKIPPED: "○",
|
|
427
|
+
AgentStatus.RUNNING: "↻",
|
|
428
|
+
AgentStatus.IDLE: "·",
|
|
429
|
+
}[self.status]
|
|
430
|
+
findings_str = f"{self.finding_count} findings" if self.finding_count else "no findings"
|
|
431
|
+
return f"{status_icon} {self.agent_name:<24} {findings_str:<18} {self.duration_ms}ms"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ── Skills directory (companion .skill.md files for security/review agents) ─────
|
|
435
|
+
|
|
436
|
+
_SKILLS_DIR: Path | None = None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _get_skills_dir() -> Path | None:
|
|
440
|
+
global _SKILLS_DIR
|
|
441
|
+
if _SKILLS_DIR is not None:
|
|
442
|
+
return _SKILLS_DIR
|
|
443
|
+
candidate = Path(__file__).resolve().parent.parent / "security" / "skills"
|
|
444
|
+
_SKILLS_DIR = candidate if candidate.is_dir() else None
|
|
445
|
+
return _SKILLS_DIR
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
_SKILL_CACHE: dict[str, dict[str, str]] | None = None
|
|
449
|
+
"""Global cache: agent_name -> {skill_stem -> skill_content}. Populated once."""
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _build_skill_map() -> dict[str, dict[str, str]]:
|
|
453
|
+
"""Scan skills_dir, parse target_agents frontmatter, return reverse map."""
|
|
454
|
+
global _SKILL_CACHE
|
|
455
|
+
if _SKILL_CACHE is not None:
|
|
456
|
+
return _SKILL_CACHE
|
|
457
|
+
skills_dir = _get_skills_dir()
|
|
458
|
+
if not skills_dir:
|
|
459
|
+
_SKILL_CACHE = {}
|
|
460
|
+
return _SKILL_CACHE
|
|
461
|
+
|
|
462
|
+
import re
|
|
463
|
+
|
|
464
|
+
agent_skills: dict[str, dict[str, str]] = {}
|
|
465
|
+
for f in sorted(skills_dir.glob("*.skill.md")):
|
|
466
|
+
content = f.read_text(encoding="utf-8")
|
|
467
|
+
stem = f.stem
|
|
468
|
+
# Extract target_agents from YAML frontmatter (between --- markers)
|
|
469
|
+
m = re.search(r"(?m)^target_agents:\s*\[([^\]]+)\]", content)
|
|
470
|
+
if not m:
|
|
471
|
+
m = re.search(r"(?m)^target_agents:\s*(.+?)$", content)
|
|
472
|
+
if m:
|
|
473
|
+
raw = m.group(1)
|
|
474
|
+
names = [n.strip().strip("'\"") for n in raw.split(",") if n.strip()]
|
|
475
|
+
for name in names:
|
|
476
|
+
agent_skills.setdefault(name, {})[stem] = content
|
|
477
|
+
_SKILL_CACHE = agent_skills
|
|
478
|
+
return agent_skills
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ── Base agent ─────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class BaseAgent(ABC):
|
|
485
|
+
"""
|
|
486
|
+
Abstract base for all Patchi agents.
|
|
487
|
+
|
|
488
|
+
Subclasses implement _run() with their analysis logic.
|
|
489
|
+
run() wraps _run() with timing, error handling, and result finalization.
|
|
490
|
+
|
|
491
|
+
Agents must:
|
|
492
|
+
- Be stateless between calls (all state lives in AgentInput/AgentResult)
|
|
493
|
+
- Return within their declared timeout
|
|
494
|
+
- Never write to disk
|
|
495
|
+
- Never modify the queue or brain directly
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
# Override these in subclasses
|
|
499
|
+
name: str = "BaseAgent"
|
|
500
|
+
group: AgentGroup = AgentGroup.SCANNER
|
|
501
|
+
domain: AgentDomain = AgentDomain.INFRASTRUCTURE
|
|
502
|
+
timeout: int = 60 # seconds — coordinator enforces this
|
|
503
|
+
|
|
504
|
+
def run(self, inp: AgentInput) -> AgentResult:
|
|
505
|
+
"""
|
|
506
|
+
Public entry point. Wraps _run() with timing, error handling,
|
|
507
|
+
and companion skill loading.
|
|
508
|
+
|
|
509
|
+
Companion skill files (.skill.md) from patchi/core/security/skills/
|
|
510
|
+
are auto-discovered based on self.name appearing in the file's
|
|
511
|
+
target_agents frontmatter. Loaded content is available as:
|
|
512
|
+
|
|
513
|
+
inp.extra.get("skill_context", {}) — dict of {skill_stem: content}
|
|
514
|
+
getattr(self, "_skill_context", {}) — same, on the agent instance
|
|
515
|
+
|
|
516
|
+
Never override this — override _run() instead.
|
|
517
|
+
"""
|
|
518
|
+
# Gate Rule — Testing/Live/Attack must confirm P-Check READY_TO_SERVE
|
|
519
|
+
if self.group in (AgentGroup.TEST, AgentGroup.SECURITY) and self.name not in ("PreCheckAgent",):
|
|
520
|
+
# Only gate live/test/attack agents, not pure static scanners
|
|
521
|
+
needs_gate = self.group == AgentGroup.TEST or self.name in (
|
|
522
|
+
"RedTeamAgent",
|
|
523
|
+
"RedTeamEngineAgent",
|
|
524
|
+
"DastAgent",
|
|
525
|
+
"BrowserTestAgent",
|
|
526
|
+
"UIButtonAgent",
|
|
527
|
+
"UILayoutAgent",
|
|
528
|
+
"UIAccessibilityAgent",
|
|
529
|
+
"VisualRegressionAgent",
|
|
530
|
+
"E2EFlowAgent",
|
|
531
|
+
"ApiFuzzerAgent",
|
|
532
|
+
"StressTestAgent",
|
|
533
|
+
)
|
|
534
|
+
if needs_gate:
|
|
535
|
+
try:
|
|
536
|
+
from patchi.core.testing.gate import require_ready
|
|
537
|
+
|
|
538
|
+
ready, url, st = require_ready(inp.root)
|
|
539
|
+
if not ready:
|
|
540
|
+
from patchi.core.testing.gate import gate_message
|
|
541
|
+
|
|
542
|
+
msg = gate_message(st)
|
|
543
|
+
# Stay idle — do not run, request P-Check
|
|
544
|
+
result = AgentResult(
|
|
545
|
+
agent_name=self.name,
|
|
546
|
+
agent_group=self.group,
|
|
547
|
+
status=AgentStatus.SKIPPED,
|
|
548
|
+
)
|
|
549
|
+
result.data["gate_blocked"] = True
|
|
550
|
+
result.data["gate_reason"] = msg
|
|
551
|
+
result.data["gate_status"] = st
|
|
552
|
+
result.errors.append(msg)
|
|
553
|
+
return result
|
|
554
|
+
# Inject url for agents that need it
|
|
555
|
+
if url and not inp.extra.get("base_url"):
|
|
556
|
+
safe_extra = dict(inp.extra)
|
|
557
|
+
safe_extra["base_url"] = url
|
|
558
|
+
safe_extra["live_probe"] = True
|
|
559
|
+
except Exception as e:
|
|
560
|
+
_log.debug("gate check failed: %s", e)
|
|
561
|
+
|
|
562
|
+
# Load companion skills for this agent name
|
|
563
|
+
skill_map = _build_skill_map()
|
|
564
|
+
self._skill_context = skill_map.get(self.name, {})
|
|
565
|
+
# Inject into inp.extra with a unique key to avoid cross-agent interference
|
|
566
|
+
# when multiple agents share the same AgentInput object in parallel runs.
|
|
567
|
+
safe_extra = dict(inp.extra)
|
|
568
|
+
safe_extra["skill_context"] = self._skill_context
|
|
569
|
+
inp = AgentInput(
|
|
570
|
+
root=inp.root,
|
|
571
|
+
scope=inp.scope,
|
|
572
|
+
brain=inp.brain,
|
|
573
|
+
config=inp.config,
|
|
574
|
+
extra=safe_extra,
|
|
575
|
+
on_message=getattr(inp, "on_message", None),
|
|
576
|
+
domain=getattr(inp, "domain", ""),
|
|
577
|
+
purpose=getattr(inp, "purpose", ""),
|
|
578
|
+
context=getattr(inp, "context", {}),
|
|
579
|
+
active_domains=getattr(inp, "active_domains", None),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
result = AgentResult(
|
|
583
|
+
agent_name=self.name,
|
|
584
|
+
agent_group=self.group,
|
|
585
|
+
status=AgentStatus.RUNNING,
|
|
586
|
+
started_at=_now(),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
t0 = time.monotonic()
|
|
590
|
+
try:
|
|
591
|
+
# Every registered agent implements _run(inp, result) -> None
|
|
592
|
+
# (enforced by register()/validate_agent_registry()); the old
|
|
593
|
+
# inspect.signature shim for _run(inp) -> AgentResult is gone.
|
|
594
|
+
self._run(inp, result)
|
|
595
|
+
if result.status == AgentStatus.RUNNING:
|
|
596
|
+
result.status = AgentStatus.DONE
|
|
597
|
+
except Exception as e:
|
|
598
|
+
result.errors.append(f"{type(e).__name__}: {e}")
|
|
599
|
+
result.errors.append(traceback.format_exc())
|
|
600
|
+
result.status = AgentStatus.FAILED
|
|
601
|
+
|
|
602
|
+
result.completed_at = _now()
|
|
603
|
+
result.duration_ms = int((time.monotonic() - t0) * 1000)
|
|
604
|
+
return result
|
|
605
|
+
|
|
606
|
+
@abstractmethod
|
|
607
|
+
def _run(self, inp: AgentInput, result: AgentResult) -> None:
|
|
608
|
+
"""
|
|
609
|
+
Implement analysis logic here.
|
|
610
|
+
Append findings to result.findings.
|
|
611
|
+
Write structured data to result.data.
|
|
612
|
+
Call result.add_error() on non-fatal errors.
|
|
613
|
+
Raise exceptions for fatal errors (they are caught by run()).
|
|
614
|
+
"""
|
|
615
|
+
...
|
|
616
|
+
|
|
617
|
+
def skip(self, result: AgentResult, reason: str) -> None:
|
|
618
|
+
"""Mark agent as skipped with a reason."""
|
|
619
|
+
result.status = AgentStatus.SKIPPED
|
|
620
|
+
result.data["skip_reason"] = reason
|
|
621
|
+
|
|
622
|
+
@classmethod
|
|
623
|
+
def describe(cls) -> dict:
|
|
624
|
+
"""Return metadata for `p agents list`."""
|
|
625
|
+
return {
|
|
626
|
+
"name": cls.name,
|
|
627
|
+
"group": cls.group.value,
|
|
628
|
+
"timeout": cls.timeout,
|
|
629
|
+
} # ── Registry ───────────────────────────────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
_REGISTRY: dict[str, type[BaseAgent]] = {}
|
|
633
|
+
|
|
634
|
+
# Packages walked by discover_agent_modules() so every @register side-effect
|
|
635
|
+
# fires. Mirrors where agents live across the codebase; a module added here
|
|
636
|
+
# without being imported by an aggregator (scanners.py, security_agents.py,
|
|
637
|
+
# test_agents.py, fix/__init__.py) still gets discovered.
|
|
638
|
+
AGENT_PACKAGES: tuple[str, ...] = (
|
|
639
|
+
"patchi.core.agents",
|
|
640
|
+
"patchi.core.security",
|
|
641
|
+
"patchi.core.testing",
|
|
642
|
+
"patchi.core.fix",
|
|
643
|
+
"patchi.core.detector",
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def discover_agent_modules(packages: tuple[str, ...] = AGENT_PACKAGES) -> list[str]:
|
|
648
|
+
"""Import every module under the agent packages so @register fires.
|
|
649
|
+
|
|
650
|
+
Replaces hand-maintained aggregator import lists (the sweep previously
|
|
651
|
+
enumerated 10 modules by hand; a new agent module nobody imported was
|
|
652
|
+
silently invisible). Walks each package with pkgutil and imports every
|
|
653
|
+
submodule, tolerating import failures: returns formatted failure strings
|
|
654
|
+
("module -> ErrorType: message") for every module that FAILED to import
|
|
655
|
+
(empty = everything imported cleanly).
|
|
656
|
+
|
|
657
|
+
NOTE: modules with heavy optional deps (playwright, torch, pyre) are
|
|
658
|
+
imported lazily by their agent bodies, so discovery stays cheap.
|
|
659
|
+
"""
|
|
660
|
+
import importlib
|
|
661
|
+
import pkgutil
|
|
662
|
+
|
|
663
|
+
failures: list[str] = []
|
|
664
|
+
for pkg_name in packages:
|
|
665
|
+
try:
|
|
666
|
+
pkg = importlib.import_module(pkg_name)
|
|
667
|
+
except Exception as e: # noqa: BLE001 — report and continue
|
|
668
|
+
failures.append(f"{pkg_name} -> {type(e).__name__}: {e}")
|
|
669
|
+
continue
|
|
670
|
+
pkg_path = getattr(pkg, "__path__", [])
|
|
671
|
+
for mod_info in pkgutil.iter_modules(pkg_path, prefix=pkg_name + "."):
|
|
672
|
+
if mod_info.ispkg:
|
|
673
|
+
# Import sub-packages (e.g. security sub-folders) too.
|
|
674
|
+
for sub_fail in discover_agent_modules((mod_info.name,)):
|
|
675
|
+
failures.append(sub_fail)
|
|
676
|
+
continue
|
|
677
|
+
try:
|
|
678
|
+
importlib.import_module(mod_info.name)
|
|
679
|
+
except Exception as e: # noqa: BLE001 — report and continue
|
|
680
|
+
failures.append(f"{mod_info.name} -> {type(e).__name__}: {e}")
|
|
681
|
+
return failures
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def validate_agent_registry() -> list[str]:
|
|
685
|
+
"""Startup contract check over every registered agent.
|
|
686
|
+
|
|
687
|
+
Returns a list of violation strings (empty = all agents satisfy the
|
|
688
|
+
contract). Catches the audit's registration bug classes AT IMPORT TIME
|
|
689
|
+
instead of in the sweep or mid-pipeline:
|
|
690
|
+
|
|
691
|
+
* a @register-ed plain class that is not a BaseAgent subclass
|
|
692
|
+
(PysaAgent/CodeqlAgent had no run() and no AgentResult)
|
|
693
|
+
* a class whose body raised while reading AgentGroup (e.g. referencing
|
|
694
|
+
a nonexistent AgentGroup member, so it silently never registered)
|
|
695
|
+
* an agent without a name, or with a group outside AgentGroup
|
|
696
|
+
|
|
697
|
+
Call after discover_agent_modules() (or after any aggregator import).
|
|
698
|
+
"""
|
|
699
|
+
# Note: this is defense-in-depth — strict register() already guarantees the
|
|
700
|
+
# contract for anything added through the decorator, so this only fires for
|
|
701
|
+
# entries injected directly into _REGISTRY. Kept as the startup audit the
|
|
702
|
+
# sweep runs, per the deep-audit requirement.
|
|
703
|
+
violations: list[str] = []
|
|
704
|
+
for name, cls in _REGISTRY.items():
|
|
705
|
+
if not (isinstance(cls, type) and issubclass(cls, BaseAgent)):
|
|
706
|
+
violations.append(
|
|
707
|
+
f"{name}: registered class is not a BaseAgent subclass "
|
|
708
|
+
f"(no run()/AgentResult contract)"
|
|
709
|
+
)
|
|
710
|
+
continue
|
|
711
|
+
if not callable(getattr(cls, "run", None)):
|
|
712
|
+
violations.append(f"{name}: registered agent has no callable run()")
|
|
713
|
+
agent_name = getattr(cls, "name", "")
|
|
714
|
+
if not agent_name or not isinstance(agent_name, str) or agent_name == "BaseAgent":
|
|
715
|
+
violations.append(f"{name}: agent has no usable .name (inherited BaseAgent default)")
|
|
716
|
+
group = getattr(cls, "group", None)
|
|
717
|
+
if not isinstance(group, AgentGroup):
|
|
718
|
+
violations.append(f"{name}: group {group!r} is not an AgentGroup member")
|
|
719
|
+
try:
|
|
720
|
+
_check_run_signature(cls)
|
|
721
|
+
except TypeError as e:
|
|
722
|
+
violations.append(str(e))
|
|
723
|
+
# _run(inp, result) mutates the passed-in result; any rebinding of the
|
|
724
|
+
# `result` name (for-loop target, comprehension target, plain assign)
|
|
725
|
+
# that is followed by a LATER use of `result` silently discards the
|
|
726
|
+
# AgentResult and crashes (AttributeError: 'dict' object has no
|
|
727
|
+
# attribute 'status' — the accessibility/api-contract shadow bugs).
|
|
728
|
+
for lineno, kind in _find_result_shadowing(cls):
|
|
729
|
+
violations.append(
|
|
730
|
+
f"{name}: _run rebinds `result` at line {lineno} "
|
|
731
|
+
f"({kind}) and uses it afterwards — rename the loop variable"
|
|
732
|
+
)
|
|
733
|
+
return violations
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _iter_target_names(node) -> list[str]:
|
|
737
|
+
"""All assigned names under an assign/for/comprehension target, recursing
|
|
738
|
+
through tuple/list/starred unpacking."""
|
|
739
|
+
if isinstance(node, ast.Name):
|
|
740
|
+
return [node.id]
|
|
741
|
+
if isinstance(node, (ast.Tuple, ast.List)):
|
|
742
|
+
out: list[str] = []
|
|
743
|
+
for elt in node.elts:
|
|
744
|
+
out.extend(_iter_target_names(elt))
|
|
745
|
+
return out
|
|
746
|
+
if isinstance(node, ast.Starred):
|
|
747
|
+
return _iter_target_names(node.value)
|
|
748
|
+
return []
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _find_result_shadowing(cls: type) -> list[tuple[int, str]]:
|
|
752
|
+
"""Return [(lineno, kind)] where `_run` rebinds `result` and then uses it
|
|
753
|
+
again later — the passed-in AgentResult would be lost.
|
|
754
|
+
|
|
755
|
+
A rebind with NO later use is intentionally not flagged (harmless, e.g. a
|
|
756
|
+
trailing loop that iterates dicts and returns). Rebinds are detected at
|
|
757
|
+
the statement level; a load on the SAME line (e.g. `result.get(...)`
|
|
758
|
+
inside the comprehension that rebinds it) refers to the new binding and is
|
|
759
|
+
correct, so only loads on strictly later lines trigger a violation.
|
|
760
|
+
"""
|
|
761
|
+
# Read source directly from the file to avoid inspect.getsource()
|
|
762
|
+
# deadlocks on Windows when pytest-timeout threads conflict.
|
|
763
|
+
try:
|
|
764
|
+
import sys as _sys
|
|
765
|
+
mod_name = cls.__module__
|
|
766
|
+
mod = _sys.modules.get(mod_name)
|
|
767
|
+
if mod and hasattr(mod, '__file__') and mod.__file__:
|
|
768
|
+
src_path = Path(mod.__file__)
|
|
769
|
+
if src_path.suffix == '.pyc' and src_path.with_suffix('.py').exists():
|
|
770
|
+
src_path = src_path.with_suffix('.py')
|
|
771
|
+
if src_path.exists():
|
|
772
|
+
src = src_path.read_text(encoding='utf-8', errors='replace')
|
|
773
|
+
else:
|
|
774
|
+
return []
|
|
775
|
+
else:
|
|
776
|
+
return []
|
|
777
|
+
except Exception:
|
|
778
|
+
return []
|
|
779
|
+
try:
|
|
780
|
+
tree = ast.parse(src)
|
|
781
|
+
except (SyntaxError, IndentationError):
|
|
782
|
+
return []
|
|
783
|
+
# Find the class node matching cls.__name__, then its _run method
|
|
784
|
+
run_fn = None
|
|
785
|
+
for node in ast.walk(tree):
|
|
786
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef)):
|
|
787
|
+
if isinstance(node, ast.ClassDef) and node.name == cls.__name__:
|
|
788
|
+
for item in node.body:
|
|
789
|
+
if isinstance(item, ast.FunctionDef) and item.name == "_run":
|
|
790
|
+
if "result" in [a.arg for a in item.args.args]:
|
|
791
|
+
run_fn = item
|
|
792
|
+
break
|
|
793
|
+
if run_fn:
|
|
794
|
+
break
|
|
795
|
+
if run_fn is None:
|
|
796
|
+
return []
|
|
797
|
+
|
|
798
|
+
rebinds: dict[int, str] = {} # lineno -> kind
|
|
799
|
+
load_lines: set[int] = set()
|
|
800
|
+
|
|
801
|
+
for node in ast.walk(run_fn):
|
|
802
|
+
if isinstance(node, ast.For):
|
|
803
|
+
for n in _iter_target_names(node.target):
|
|
804
|
+
if n == "result":
|
|
805
|
+
rebinds[node.lineno] = "for-loop target"
|
|
806
|
+
elif isinstance(node, ast.comprehension):
|
|
807
|
+
for n in _iter_target_names(node.target):
|
|
808
|
+
if n == "result":
|
|
809
|
+
rebinds[node.target.lineno] = "comprehension target"
|
|
810
|
+
elif isinstance(node, (ast.Assign, ast.AugAssign, ast.NamedExpr)):
|
|
811
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
812
|
+
for t in targets:
|
|
813
|
+
for n in _iter_target_names(t):
|
|
814
|
+
if n == "result":
|
|
815
|
+
rebinds[node.lineno] = "plain assignment"
|
|
816
|
+
elif isinstance(node, ast.Name) and node.id == "result" and isinstance(node.ctx, ast.Load):
|
|
817
|
+
load_lines.add(node.lineno)
|
|
818
|
+
|
|
819
|
+
return sorted(
|
|
820
|
+
(line, kind) for line, kind in rebinds.items() if any(load > line for load in load_lines)
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def register(agent_cls: type[BaseAgent]) -> type[BaseAgent]:
|
|
825
|
+
"""Decorator to register an agent class by name.
|
|
826
|
+
|
|
827
|
+
Enforces the agent contract at registration time: the decorated object
|
|
828
|
+
must be a BaseAgent subclass with a name and a real AgentGroup. A plain
|
|
829
|
+
class decorated with @register (the PysaAgent/CodeqlAgent bug) raises
|
|
830
|
+
immediately instead of registering a broken entry.
|
|
831
|
+
"""
|
|
832
|
+
if not (isinstance(agent_cls, type) and issubclass(agent_cls, BaseAgent)):
|
|
833
|
+
raise TypeError(
|
|
834
|
+
f"register() requires a BaseAgent subclass, got {agent_cls!r} — "
|
|
835
|
+
"agents must inherit BaseAgent to expose run()/AgentResult"
|
|
836
|
+
)
|
|
837
|
+
if not callable(getattr(agent_cls, "run", None)):
|
|
838
|
+
raise TypeError(f"register() requires {agent_cls.__name__} to expose a callable run()")
|
|
839
|
+
# An agent that never sets .name inherits "BaseAgent" from the base class
|
|
840
|
+
# and would silently OVERWRITE the base entry in the registry — reject the
|
|
841
|
+
# inherited default, not just the empty string.
|
|
842
|
+
agent_name = getattr(agent_cls, "name", "") or ""
|
|
843
|
+
if not agent_name or not isinstance(agent_name, str) or agent_name == "BaseAgent":
|
|
844
|
+
raise TypeError(
|
|
845
|
+
f"register() requires agent {agent_cls.__name__} to define its own .name "
|
|
846
|
+
f"(inherited the BaseAgent default)"
|
|
847
|
+
)
|
|
848
|
+
if not isinstance(getattr(agent_cls, "group", None), AgentGroup):
|
|
849
|
+
raise TypeError(
|
|
850
|
+
f"register() requires {agent_cls.__name__}.group to be an AgentGroup member"
|
|
851
|
+
)
|
|
852
|
+
# The _run contract: every agent must implement _run(self, inp, result)
|
|
853
|
+
# -> None. The old per-call inspect.signature shim in run() was removed, so
|
|
854
|
+
# a wrong-shaped _run would only explode mid-pipeline — catch it here at
|
|
855
|
+
# registration time instead (enforced, not probed).
|
|
856
|
+
_check_run_signature(agent_cls)
|
|
857
|
+
_REGISTRY[agent_cls.name] = agent_cls
|
|
858
|
+
return agent_cls
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _check_run_signature(agent_cls: type[BaseAgent]) -> None:
|
|
862
|
+
"""Raise TypeError unless _run takes exactly (self, inp, result)."""
|
|
863
|
+
import inspect
|
|
864
|
+
|
|
865
|
+
run_impl = getattr(agent_cls, "_run", None)
|
|
866
|
+
if not callable(run_impl):
|
|
867
|
+
raise TypeError(f"register() requires {agent_cls.__name__} to implement _run()")
|
|
868
|
+
try:
|
|
869
|
+
sig = inspect.signature(run_impl)
|
|
870
|
+
except (TypeError, ValueError) as e: # noqa: BLE001 — signature probe failure
|
|
871
|
+
raise TypeError(f"register() could not inspect {agent_cls.__name__}._run: {e}") from e
|
|
872
|
+
params = list(sig.parameters.values())
|
|
873
|
+
positional = [p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
|
|
874
|
+
if len(positional) < 3 or positional[1].name != "inp" or positional[2].name != "result":
|
|
875
|
+
raise TypeError(
|
|
876
|
+
f"register() requires {agent_cls.__name__}._run(self, inp, result) — "
|
|
877
|
+
f"got {sig}. The old _run(inp) -> AgentResult shape is removed."
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def deregister(agent_cls: type[BaseAgent]) -> None:
|
|
882
|
+
"""Remove an agent class from the registry (for test cleanup)."""
|
|
883
|
+
_REGISTRY.pop(agent_cls.name, None)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def get_agent(name: str) -> type[BaseAgent] | None:
|
|
887
|
+
return _REGISTRY.get(name)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def list_agents(group: AgentGroup | None = None) -> list[type[BaseAgent]]:
|
|
891
|
+
agents = list(_REGISTRY.values())
|
|
892
|
+
if group:
|
|
893
|
+
agents = [a for a in agents if a.group == group]
|
|
894
|
+
return sorted(agents, key=lambda a: (a.group.value, a.name))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
# ── Utilities ──────────────────────────────────────────────────────────────────
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _now() -> str:
|
|
901
|
+
return datetime.now(UTC).isoformat()
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _infer_agent_name() -> str:
|
|
905
|
+
"""Try to infer the calling agent name for legacy make_finding calls."""
|
|
906
|
+
import inspect
|
|
907
|
+
|
|
908
|
+
frame = inspect.currentframe()
|
|
909
|
+
if not frame:
|
|
910
|
+
return "Unknown"
|
|
911
|
+
|
|
912
|
+
caller = frame.f_back
|
|
913
|
+
while caller:
|
|
914
|
+
self_obj = caller.f_locals.get("self")
|
|
915
|
+
if self_obj is not None:
|
|
916
|
+
agent_name = getattr(self_obj, "name", None)
|
|
917
|
+
if isinstance(agent_name, str) and agent_name:
|
|
918
|
+
return agent_name
|
|
919
|
+
caller = caller.f_back
|
|
920
|
+
|
|
921
|
+
return "Unknown"
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def make_finding(
|
|
925
|
+
*args,
|
|
926
|
+
**kwargs,
|
|
927
|
+
) -> Finding:
|
|
928
|
+
"""Create a Finding with backwards compatibility for old call signatures."""
|
|
929
|
+
# Legacy positional form: (Severity, file, line_start, title, description, evidence)
|
|
930
|
+
if args and isinstance(args[0], Severity):
|
|
931
|
+
severity = args[0]
|
|
932
|
+
file = args[1] if len(args) > 1 else ""
|
|
933
|
+
line = args[2] if len(args) > 2 else 0
|
|
934
|
+
title = args[3] if len(args) > 3 else ""
|
|
935
|
+
description = args[4] if len(args) > 4 else ""
|
|
936
|
+
evidence = args[5] if len(args) > 5 else ""
|
|
937
|
+
finding_type = kwargs.pop("finding_type", None) or title.lower().replace(" ", "_").replace(
|
|
938
|
+
":", ""
|
|
939
|
+
).replace("'", "").replace("-", "_")
|
|
940
|
+
agent = kwargs.pop("agent", _infer_agent_name())
|
|
941
|
+
message = kwargs.pop("message", "") or title or description
|
|
942
|
+
detail = kwargs.pop("detail", description or evidence)
|
|
943
|
+
return Finding(
|
|
944
|
+
agent=agent,
|
|
945
|
+
type=finding_type,
|
|
946
|
+
severity=severity,
|
|
947
|
+
file=file,
|
|
948
|
+
line=line,
|
|
949
|
+
message=message,
|
|
950
|
+
detail=detail,
|
|
951
|
+
code_snippet=kwargs.pop("code_snippet", ""),
|
|
952
|
+
suggestion=kwargs.pop("suggestion", ""),
|
|
953
|
+
fix_agent=kwargs.pop("fix_agent", None),
|
|
954
|
+
cwe=kwargs.pop("cwe", ""),
|
|
955
|
+
extra={**kwargs},
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# Legacy keyword form: severity=..., file=..., title=..., description=..., evidence=...
|
|
959
|
+
if "severity" in kwargs and ("file" in kwargs or "file_path" in kwargs):
|
|
960
|
+
severity = kwargs.pop("severity")
|
|
961
|
+
file = kwargs.pop("file", kwargs.pop("file_path", ""))
|
|
962
|
+
line = kwargs.pop("line_start", kwargs.pop("line", 0))
|
|
963
|
+
title = kwargs.pop("title", "")
|
|
964
|
+
description = kwargs.pop("description", "")
|
|
965
|
+
message = kwargs.pop("message", "") or title or description
|
|
966
|
+
evidence = kwargs.pop("evidence", "")
|
|
967
|
+
finding_type = kwargs.pop("finding_type", None) or title.lower().replace(" ", "_").replace(
|
|
968
|
+
":", ""
|
|
969
|
+
).replace("'", "").replace("-", "_")
|
|
970
|
+
agent = kwargs.pop("agent", _infer_agent_name())
|
|
971
|
+
detail = kwargs.pop("detail", description or evidence)
|
|
972
|
+
return Finding(
|
|
973
|
+
agent=agent,
|
|
974
|
+
type=finding_type,
|
|
975
|
+
severity=severity,
|
|
976
|
+
file=file,
|
|
977
|
+
line=line,
|
|
978
|
+
message=message,
|
|
979
|
+
detail=detail,
|
|
980
|
+
code_snippet=kwargs.pop("code_snippet", ""),
|
|
981
|
+
suggestion=kwargs.pop("suggestion", ""),
|
|
982
|
+
fix_agent=kwargs.pop("fix_agent", None),
|
|
983
|
+
cwe=kwargs.pop("cwe", ""),
|
|
984
|
+
extra={**kwargs},
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
# Standard constructor: (agent, finding_type, severity, file, message, ...)
|
|
988
|
+
if len(args) >= 5 and isinstance(args[2], Severity):
|
|
989
|
+
agent = args[0]
|
|
990
|
+
finding_type = args[1]
|
|
991
|
+
severity = args[2]
|
|
992
|
+
file = args[3]
|
|
993
|
+
message = args[4]
|
|
994
|
+
line = kwargs.pop("line", kwargs.pop("line_start", 0))
|
|
995
|
+
evidence = kwargs.pop("evidence", "") # Extract evidence if passed
|
|
996
|
+
detail = kwargs.pop("detail", evidence) # Map evidence to detail
|
|
997
|
+
return Finding(
|
|
998
|
+
agent=agent,
|
|
999
|
+
type=finding_type,
|
|
1000
|
+
severity=severity,
|
|
1001
|
+
file=file,
|
|
1002
|
+
line=line,
|
|
1003
|
+
message=message,
|
|
1004
|
+
detail=detail,
|
|
1005
|
+
code_snippet=kwargs.pop("code_snippet", ""),
|
|
1006
|
+
suggestion=kwargs.pop("suggestion", ""),
|
|
1007
|
+
fix_agent=kwargs.pop("fix_agent", None),
|
|
1008
|
+
cwe=kwargs.pop("cwe", ""),
|
|
1009
|
+
extra={**kwargs},
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
raise TypeError("Invalid make_finding signature")
|