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,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Channel configuration models and Apprise URL builders for Patchi notifications.
|
|
3
|
+
|
|
4
|
+
Each channel type stores its config in the project's config.json under the
|
|
5
|
+
`notifications` list as a plain dict. This module converts those dicts to
|
|
6
|
+
validated models and builds the Apprise URL strings required to send alerts.
|
|
7
|
+
|
|
8
|
+
Supported types: email, slack, discord, webhook, telegram
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChannelType(StrEnum):
|
|
20
|
+
EMAIL = "email"
|
|
21
|
+
SLACK = "slack"
|
|
22
|
+
DISCORD = "discord"
|
|
23
|
+
WEBHOOK = "webhook"
|
|
24
|
+
TELEGRAM = "telegram"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Severity levels that each channel is willing to receive.
|
|
28
|
+
# Channels with min_severity="high" skip MEDIUM/LOW alerts.
|
|
29
|
+
_SEVERITY_RANK: dict[str, int] = {
|
|
30
|
+
"critical": 0,
|
|
31
|
+
"high": 1,
|
|
32
|
+
"medium": 2,
|
|
33
|
+
"low": 3,
|
|
34
|
+
"info": 4,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ChannelConfig:
|
|
40
|
+
"""Parsed, validated representation of one entry in config `notifications`."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
channel_type: ChannelType
|
|
44
|
+
apprise_url: str # ready-to-use Apprise URL
|
|
45
|
+
min_severity: str = "medium" # alerts below this rank are skipped
|
|
46
|
+
in_digest: bool = False # if True, always batch regardless of severity
|
|
47
|
+
|
|
48
|
+
def accepts_severity(self, severity: str) -> bool:
|
|
49
|
+
"""Return True if this channel should receive an alert at the given severity."""
|
|
50
|
+
channel_rank = _SEVERITY_RANK.get(self.min_severity, 2)
|
|
51
|
+
alert_rank = _SEVERITY_RANK.get(severity, 4)
|
|
52
|
+
return alert_rank <= channel_rank
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Apprise URL builders ───────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_email_url(cfg: dict) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Build an Apprise mailto:// URL from email config dict.
|
|
61
|
+
Required keys: user, password, host
|
|
62
|
+
Optional: port, to, from_addr, secure (bool)
|
|
63
|
+
"""
|
|
64
|
+
user = cfg["user"]
|
|
65
|
+
password = quote(cfg["password"], safe="")
|
|
66
|
+
host = cfg["host"]
|
|
67
|
+
port = cfg.get("port", "")
|
|
68
|
+
to = cfg.get("to", user)
|
|
69
|
+
secure = cfg.get("secure", True)
|
|
70
|
+
schema = "mailtos" if secure else "mailto"
|
|
71
|
+
port_str = f":{port}" if port else ""
|
|
72
|
+
return f"{schema}://{user}:{password}@{host}{port_str}?to={quote(to, safe='@.')}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_slack_url(cfg: dict) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Build an Apprise slack:// URL.
|
|
78
|
+
Accepts either a raw webhook URL (webhook_url key) or
|
|
79
|
+
tokenA/tokenB/tokenC format (token_a, token_b, token_c keys).
|
|
80
|
+
"""
|
|
81
|
+
if "webhook_url" in cfg:
|
|
82
|
+
# Slack incoming webhook — extract path tokens from URL
|
|
83
|
+
# Format: https://hooks.slack.com/services/T.../B.../xxx
|
|
84
|
+
path = cfg["webhook_url"].split("hooks.slack.com/services/")[-1]
|
|
85
|
+
parts = path.strip("/").split("/")
|
|
86
|
+
if len(parts) == 3:
|
|
87
|
+
return f"slack://{parts[0]}/{parts[1]}/{parts[2]}"
|
|
88
|
+
# Non-standard — pass raw URL through Apprise's https:// handler
|
|
89
|
+
return cfg["webhook_url"]
|
|
90
|
+
return "slack://{token_a}/{token_b}/{token_c}".format(**cfg)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_discord_url(cfg: dict) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Build an Apprise discord:// URL.
|
|
96
|
+
Required: webhook_id, webhook_token
|
|
97
|
+
(Discord webhook URL format: .../webhooks/{id}/{token})
|
|
98
|
+
"""
|
|
99
|
+
if "webhook_url" in cfg:
|
|
100
|
+
parts = cfg["webhook_url"].rstrip("/").split("/")
|
|
101
|
+
# Last two segments are id and token
|
|
102
|
+
return f"discord://{parts[-2]}/{parts[-1]}"
|
|
103
|
+
return "discord://{webhook_id}/{webhook_token}".format(**cfg)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_webhook_url(cfg: dict) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Custom HTTP webhook — just pass the URL through directly.
|
|
109
|
+
Apprise supports json:// and xml:// schemas; plain https:// also works.
|
|
110
|
+
"""
|
|
111
|
+
url = cfg["url"]
|
|
112
|
+
# If already an apprise-style schema, use as-is
|
|
113
|
+
if any(url.startswith(s) for s in ("json://", "xml://", "form://")):
|
|
114
|
+
return url
|
|
115
|
+
# Convert plain https:// to json:// so Apprise sends JSON body
|
|
116
|
+
return url.replace("https://", "json://", 1).replace("http://", "json://", 1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_telegram_url(cfg: dict) -> str:
|
|
120
|
+
"""
|
|
121
|
+
Build an Apprise tgram:// URL.
|
|
122
|
+
Required: bot_token, chat_id
|
|
123
|
+
"""
|
|
124
|
+
return "tgram://{bot_token}/{chat_id}".format(**cfg)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_URL_BUILDERS = {
|
|
128
|
+
ChannelType.EMAIL: _build_email_url,
|
|
129
|
+
ChannelType.SLACK: _build_slack_url,
|
|
130
|
+
ChannelType.DISCORD: _build_discord_url,
|
|
131
|
+
ChannelType.WEBHOOK: _build_webhook_url,
|
|
132
|
+
ChannelType.TELEGRAM: _build_telegram_url,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── Public API ─────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def from_config(raw: dict) -> ChannelConfig:
|
|
140
|
+
"""
|
|
141
|
+
Parse one raw dict from config `notifications` list into a ChannelConfig.
|
|
142
|
+
Raises ValueError for missing required fields or unknown channel type.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
channel_type = ChannelType(raw["type"])
|
|
146
|
+
except (KeyError, ValueError) as exc:
|
|
147
|
+
valid = ", ".join(t.value for t in ChannelType)
|
|
148
|
+
raise ValueError(f"Channel type must be one of: {valid}") from exc
|
|
149
|
+
|
|
150
|
+
if "name" not in raw:
|
|
151
|
+
raise ValueError("Channel config missing required key: name")
|
|
152
|
+
|
|
153
|
+
builder = _URL_BUILDERS[channel_type]
|
|
154
|
+
try:
|
|
155
|
+
apprise_url = builder(raw.get("credentials", raw))
|
|
156
|
+
except KeyError as exc:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Channel '{raw['name']}' ({channel_type.value}) missing required field: {exc}"
|
|
159
|
+
) from exc
|
|
160
|
+
|
|
161
|
+
return ChannelConfig(
|
|
162
|
+
name=raw["name"],
|
|
163
|
+
channel_type=channel_type,
|
|
164
|
+
apprise_url=apprise_url,
|
|
165
|
+
min_severity=raw.get("min_severity", "medium"),
|
|
166
|
+
in_digest=raw.get("in_digest", False),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_channels(config: dict) -> list[ChannelConfig]:
|
|
171
|
+
"""
|
|
172
|
+
Load and validate all channels from a config dict.
|
|
173
|
+
Skips entries that fail validation (logs name for debugging).
|
|
174
|
+
"""
|
|
175
|
+
channels: list[ChannelConfig] = []
|
|
176
|
+
for raw in config.get("notifications", []):
|
|
177
|
+
try:
|
|
178
|
+
channels.append(from_config(raw))
|
|
179
|
+
except ValueError:
|
|
180
|
+
# Corrupt/incomplete entry — skip silently in runtime, surface in `p notify list`
|
|
181
|
+
pass
|
|
182
|
+
return channels
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def channel_to_dict(ch: ChannelConfig) -> dict[str, Any]:
|
|
186
|
+
"""Serialise a ChannelConfig back for display (never exposes the full apprise_url)."""
|
|
187
|
+
return {
|
|
188
|
+
"name": ch.name,
|
|
189
|
+
"type": ch.channel_type.value,
|
|
190
|
+
"min_severity": ch.min_severity,
|
|
191
|
+
"in_digest": ch.in_digest,
|
|
192
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Digest queue for Patchi notifications.
|
|
3
|
+
|
|
4
|
+
MEDIUM, LOW, and INFO alerts are batched here instead of firing immediately.
|
|
5
|
+
The queue persists to .patchi/digest.json so alerts survive restarts.
|
|
6
|
+
Flushing sends all queued alerts as a single digest notification per channel.
|
|
7
|
+
|
|
8
|
+
Flush is triggered either:
|
|
9
|
+
- Manually via `p notify flush`
|
|
10
|
+
- Automatically when Notifier.send() is called and the digest window has elapsed
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time as _time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# Severities that feed the digest queue (not immediate)
|
|
21
|
+
DIGEST_SEVERITIES: frozenset[str] = frozenset({"medium", "low", "info"})
|
|
22
|
+
|
|
23
|
+
# Frequency → seconds mapping
|
|
24
|
+
_FREQUENCY_SECONDS: dict[str, int] = {
|
|
25
|
+
"hourly": 3600,
|
|
26
|
+
"6h": 21600,
|
|
27
|
+
"daily": 86400,
|
|
28
|
+
"weekly": 604800,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_DIGEST_FILE = ".patchi/digest.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_queue(root: Path) -> dict:
|
|
35
|
+
path = root / _DIGEST_FILE
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return {"items": [], "last_flushed": 0.0}
|
|
38
|
+
try:
|
|
39
|
+
with path.open("r", encoding="utf-8") as f:
|
|
40
|
+
return json.load(f)
|
|
41
|
+
except (json.JSONDecodeError, OSError):
|
|
42
|
+
return {"items": [], "last_flushed": 0.0}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _save_queue(root: Path, data: dict) -> None:
|
|
46
|
+
path = root / _DIGEST_FILE
|
|
47
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
with path.open("w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(data, f, indent=2)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _frequency_to_seconds(frequency: str) -> int:
|
|
53
|
+
return _FREQUENCY_SECONDS.get(frequency, _FREQUENCY_SECONDS["daily"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DigestQueue:
|
|
57
|
+
"""
|
|
58
|
+
Persistent queue for batched (MEDIUM/LOW/INFO) notifications.
|
|
59
|
+
|
|
60
|
+
Usage:
|
|
61
|
+
dq = DigestQueue(project_root)
|
|
62
|
+
dq.enqueue("medium", "Title", "Body text")
|
|
63
|
+
if dq.should_flush(digest_frequency):
|
|
64
|
+
items = dq.flush(channels) # returns list of (channel, message) tuples
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, root: Path) -> None:
|
|
68
|
+
self._root = root
|
|
69
|
+
|
|
70
|
+
def enqueue(self, severity: str, title: str, body: str, data: dict | None = None) -> None:
|
|
71
|
+
"""Add one alert to the persistent queue."""
|
|
72
|
+
queue = _load_queue(self._root)
|
|
73
|
+
queue["items"].append(
|
|
74
|
+
{
|
|
75
|
+
"severity": severity,
|
|
76
|
+
"title": title,
|
|
77
|
+
"body": body,
|
|
78
|
+
"data": data or {},
|
|
79
|
+
"queued_at": _time.time(),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
_save_queue(self._root, queue)
|
|
83
|
+
|
|
84
|
+
def should_flush(self, frequency: str) -> bool:
|
|
85
|
+
"""Return True if enough time has elapsed since the last flush."""
|
|
86
|
+
queue = _load_queue(self._root)
|
|
87
|
+
elapsed = _time.time() - queue.get("last_flushed", 0.0)
|
|
88
|
+
interval = _frequency_to_seconds(frequency)
|
|
89
|
+
return elapsed >= interval
|
|
90
|
+
|
|
91
|
+
def flush(self) -> list[dict[str, Any]]:
|
|
92
|
+
"""
|
|
93
|
+
Drain the queue and return all pending items.
|
|
94
|
+
Resets the queue and updates last_flushed timestamp.
|
|
95
|
+
Returns an empty list if nothing was queued.
|
|
96
|
+
"""
|
|
97
|
+
queue = _load_queue(self._root)
|
|
98
|
+
items = queue.get("items", [])
|
|
99
|
+
if not items:
|
|
100
|
+
return []
|
|
101
|
+
_save_queue(self._root, {"items": [], "last_flushed": _time.time()})
|
|
102
|
+
return items
|
|
103
|
+
|
|
104
|
+
def peek(self) -> list[dict[str, Any]]:
|
|
105
|
+
"""Return queued items without draining."""
|
|
106
|
+
return _load_queue(self._root).get("items", [])
|
|
107
|
+
|
|
108
|
+
def clear(self) -> int:
|
|
109
|
+
"""Remove all queued items without sending. Returns count cleared."""
|
|
110
|
+
queue = _load_queue(self._root)
|
|
111
|
+
count = len(queue.get("items", []))
|
|
112
|
+
_save_queue(self._root, {"items": [], "last_flushed": _time.time()})
|
|
113
|
+
return count
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def build_digest_body(items: list[dict]) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Render a human-readable digest body from a list of queued items.
|
|
119
|
+
Groups by severity, most critical first.
|
|
120
|
+
"""
|
|
121
|
+
if not items:
|
|
122
|
+
return "No alerts."
|
|
123
|
+
|
|
124
|
+
order = ["medium", "low", "info"]
|
|
125
|
+
grouped: dict[str, list[dict]] = {s: [] for s in order}
|
|
126
|
+
for item in items:
|
|
127
|
+
sev = item.get("severity", "info")
|
|
128
|
+
if sev in grouped:
|
|
129
|
+
grouped[sev].append(item)
|
|
130
|
+
|
|
131
|
+
lines: list[str] = [f"Patchi digest — {len(items)} alert(s)\n"]
|
|
132
|
+
labels = {"medium": "⚠ Medium", "low": "✓ Low", "info": "ℹ Info"}
|
|
133
|
+
for sev in order:
|
|
134
|
+
group = grouped[sev]
|
|
135
|
+
if not group:
|
|
136
|
+
continue
|
|
137
|
+
lines.append(f"\n{labels[sev]} ({len(group)})")
|
|
138
|
+
for item in group:
|
|
139
|
+
lines.append(f" • {item['title']}: {item['body']}")
|
|
140
|
+
|
|
141
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Escalation tracking for CRITICAL and HIGH Patchi alerts.
|
|
3
|
+
|
|
4
|
+
When an alert fires, it is registered here with a unique ID.
|
|
5
|
+
If the user does not acknowledge it within the configured window,
|
|
6
|
+
escalation fires to the next channel in the notification list.
|
|
7
|
+
|
|
8
|
+
Ack store persists to .patchi/escalation.json.
|
|
9
|
+
`p notify ack [id]` calls EscalationTracker.acknowledge(id).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
import time as _time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# Severities that require acknowledgement tracking
|
|
21
|
+
ESCALATION_SEVERITIES: frozenset[str] = frozenset({"critical", "high"})
|
|
22
|
+
|
|
23
|
+
_ESCALATION_FILE = ".patchi/escalation.json"
|
|
24
|
+
_DEFAULT_ESCALATION_MINS = 30 # escalate after 30 min if unacknowledged
|
|
25
|
+
_POLL_INTERVAL_SECS = 60 # check every 60 seconds
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_log = logging.getLogger("patchi.core.escalation")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_store(root: Path) -> dict:
|
|
32
|
+
path = root / _ESCALATION_FILE
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return {"alerts": {}}
|
|
35
|
+
try:
|
|
36
|
+
with path.open("r", encoding="utf-8") as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
except (json.JSONDecodeError, OSError):
|
|
39
|
+
return {"alerts": {}}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _save_store(root: Path, data: dict) -> None:
|
|
43
|
+
path = root / _ESCALATION_FILE
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
with path.open("w", encoding="utf-8") as f:
|
|
46
|
+
json.dump(data, f, indent=2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EscalationTracker:
|
|
50
|
+
"""
|
|
51
|
+
Tracks unacknowledged CRITICAL/HIGH alerts and escalates them.
|
|
52
|
+
|
|
53
|
+
The background thread polls every 60 seconds and fires escalation
|
|
54
|
+
callbacks for overdue unacknowledged alerts.
|
|
55
|
+
|
|
56
|
+
escalation_fn signature: (alert_id: str, alert: dict, channels: list) -> None
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
root: Path,
|
|
62
|
+
escalation_fn: Any,
|
|
63
|
+
escalation_minutes: int = _DEFAULT_ESCALATION_MINS,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._root = root
|
|
66
|
+
self._escalation_fn = escalation_fn
|
|
67
|
+
self._escalation_secs = escalation_minutes * 60
|
|
68
|
+
self._stop_event = threading.Event()
|
|
69
|
+
self._thread: threading.Thread | None = None
|
|
70
|
+
|
|
71
|
+
def register(self, alert_id: str, severity: str, title: str, body: str) -> None:
|
|
72
|
+
"""Register a new alert as requiring acknowledgement."""
|
|
73
|
+
if severity not in ESCALATION_SEVERITIES:
|
|
74
|
+
return
|
|
75
|
+
store = _load_store(self._root)
|
|
76
|
+
store["alerts"][alert_id] = {
|
|
77
|
+
"severity": severity,
|
|
78
|
+
"title": title,
|
|
79
|
+
"body": body,
|
|
80
|
+
"fired_at": _time.time(),
|
|
81
|
+
"acked": False,
|
|
82
|
+
"escalated": False,
|
|
83
|
+
}
|
|
84
|
+
_save_store(self._root, store)
|
|
85
|
+
|
|
86
|
+
def acknowledge(self, alert_id: str) -> bool:
|
|
87
|
+
"""Mark an alert as acknowledged. Returns True if the ID was found."""
|
|
88
|
+
store = _load_store(self._root)
|
|
89
|
+
if alert_id not in store["alerts"]:
|
|
90
|
+
return False
|
|
91
|
+
store["alerts"][alert_id]["acked"] = True
|
|
92
|
+
_save_store(self._root, store)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def pending(self) -> list[dict]:
|
|
96
|
+
"""Return all alerts that are unacknowledged and not yet escalated."""
|
|
97
|
+
store = _load_store(self._root)
|
|
98
|
+
return [
|
|
99
|
+
{"id": aid, **alert}
|
|
100
|
+
for aid, alert in store["alerts"].items()
|
|
101
|
+
if not alert.get("acked") and not alert.get("escalated")
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def list_all(self) -> list[dict]:
|
|
105
|
+
"""Return all tracked alerts (acked and unacked)."""
|
|
106
|
+
store = _load_store(self._root)
|
|
107
|
+
return [{"id": aid, **alert} for aid, alert in store["alerts"].items()]
|
|
108
|
+
|
|
109
|
+
def start(self) -> None:
|
|
110
|
+
"""Start the background escalation polling thread."""
|
|
111
|
+
if self._thread and self._thread.is_alive():
|
|
112
|
+
return
|
|
113
|
+
self._stop_event.clear()
|
|
114
|
+
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
115
|
+
self._thread.start()
|
|
116
|
+
|
|
117
|
+
def stop(self) -> None:
|
|
118
|
+
"""Stop the background polling thread."""
|
|
119
|
+
self._stop_event.set()
|
|
120
|
+
|
|
121
|
+
def _poll_loop(self) -> None:
|
|
122
|
+
while not self._stop_event.is_set():
|
|
123
|
+
self._check_overdue()
|
|
124
|
+
self._stop_event.wait(timeout=_POLL_INTERVAL_SECS)
|
|
125
|
+
|
|
126
|
+
def _check_overdue(self) -> None:
|
|
127
|
+
"""Fire escalation for any alert past the window."""
|
|
128
|
+
store = _load_store(self._root)
|
|
129
|
+
now = _time.time()
|
|
130
|
+
changed = False
|
|
131
|
+
|
|
132
|
+
for alert_id, alert in store["alerts"].items():
|
|
133
|
+
if alert.get("acked") or alert.get("escalated"):
|
|
134
|
+
continue
|
|
135
|
+
age = now - alert.get("fired_at", now)
|
|
136
|
+
if age >= self._escalation_secs:
|
|
137
|
+
alert["escalated"] = True
|
|
138
|
+
changed = True
|
|
139
|
+
try:
|
|
140
|
+
self._escalation_fn(alert_id, alert)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
_log.warning("EscalationTracker._check_overdue failed: %s", e)
|
|
143
|
+
|
|
144
|
+
if changed:
|
|
145
|
+
_save_store(self._root, store)
|
|
146
|
+
|
|
147
|
+
def purge_old(self, max_age_hours: int = 72) -> int:
|
|
148
|
+
"""Remove acked/escalated alerts older than max_age_hours. Returns count removed."""
|
|
149
|
+
store = _load_store(self._root)
|
|
150
|
+
cutoff = _time.time() - (max_age_hours * 3600)
|
|
151
|
+
before = len(store["alerts"])
|
|
152
|
+
store["alerts"] = {
|
|
153
|
+
aid: a
|
|
154
|
+
for aid, a in store["alerts"].items()
|
|
155
|
+
if not (a.get("acked") or a.get("escalated")) or a.get("fired_at", 0) >= cutoff
|
|
156
|
+
}
|
|
157
|
+
_save_store(self._root, store)
|
|
158
|
+
return before - len(store["alerts"])
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Central notification dispatcher for Patchi.
|
|
3
|
+
|
|
4
|
+
Single entry point: Notifier.send(severity, title, body, data)
|
|
5
|
+
|
|
6
|
+
Routing rules:
|
|
7
|
+
CRITICAL → immediate, all channels, bypass quiet hours, start escalation
|
|
8
|
+
HIGH → immediate, bypass quiet hours, start escalation
|
|
9
|
+
MEDIUM → digest queue unless quiet hours bypassed, respects quiet hours
|
|
10
|
+
LOW/INFO → digest queue only, respects quiet hours
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
notifier = Notifier(project_root)
|
|
14
|
+
notifier.send("critical", "SQL injection found", "in routes/user.py:44")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import apprise
|
|
24
|
+
from apprise import NotifyType
|
|
25
|
+
|
|
26
|
+
from patchi.core.notifications.channels import ChannelConfig, load_channels
|
|
27
|
+
from patchi.core.notifications.digest import DIGEST_SEVERITIES, DigestQueue
|
|
28
|
+
from patchi.core.notifications.escalation import ESCALATION_SEVERITIES, EscalationTracker
|
|
29
|
+
from patchi.core.notifications.quiet_hours import should_suppress
|
|
30
|
+
|
|
31
|
+
# Severity → Apprise NotifyType mapping
|
|
32
|
+
_APPRISE_TYPE: dict[str, str] = {
|
|
33
|
+
"critical": NotifyType.FAILURE,
|
|
34
|
+
"high": NotifyType.WARNING,
|
|
35
|
+
"medium": NotifyType.INFO,
|
|
36
|
+
"low": NotifyType.SUCCESS,
|
|
37
|
+
"info": NotifyType.SUCCESS,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Notifier:
|
|
42
|
+
"""
|
|
43
|
+
Dispatch Patchi alerts to configured channels.
|
|
44
|
+
|
|
45
|
+
Instantiate once per command invocation or keep alive in watch mode.
|
|
46
|
+
Thread-safe for the escalation background thread.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, root: Path, config: dict) -> None:
|
|
50
|
+
self._root = root
|
|
51
|
+
self._config = config
|
|
52
|
+
self._channels = load_channels(config)
|
|
53
|
+
self._digest = DigestQueue(root)
|
|
54
|
+
self._tracker = EscalationTracker(
|
|
55
|
+
root,
|
|
56
|
+
escalation_fn=self._escalate,
|
|
57
|
+
escalation_minutes=config.get("escalation_minutes", 30),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def send(
|
|
61
|
+
self,
|
|
62
|
+
severity: str,
|
|
63
|
+
title: str,
|
|
64
|
+
body: str,
|
|
65
|
+
data: dict[str, Any] | None = None,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""
|
|
68
|
+
Route one alert by severity.
|
|
69
|
+
Returns the number of channels that received the alert immediately.
|
|
70
|
+
"""
|
|
71
|
+
data = data or {}
|
|
72
|
+
channels = self._eligible_channels(severity)
|
|
73
|
+
|
|
74
|
+
if not channels:
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
in_digest_mode = severity in DIGEST_SEVERITIES
|
|
78
|
+
suppressed = should_suppress(severity, self._config.get("quiet_hours", {}))
|
|
79
|
+
|
|
80
|
+
if suppressed or in_digest_mode:
|
|
81
|
+
self._digest.enqueue(severity, title, body, data)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
# Immediate fire
|
|
85
|
+
sent = self._fire(channels, severity, title, body)
|
|
86
|
+
|
|
87
|
+
# Register for escalation if CRITICAL or HIGH
|
|
88
|
+
if severity in ESCALATION_SEVERITIES and sent > 0:
|
|
89
|
+
alert_id = str(uuid.uuid4())[:8]
|
|
90
|
+
self._tracker.register(alert_id, severity, title, body)
|
|
91
|
+
|
|
92
|
+
# Auto-flush digest if window has elapsed
|
|
93
|
+
freq = self._config.get("digest_frequency", "daily")
|
|
94
|
+
if self._digest.should_flush(freq):
|
|
95
|
+
self.flush_digest()
|
|
96
|
+
|
|
97
|
+
return sent
|
|
98
|
+
|
|
99
|
+
def flush_digest(self) -> int:
|
|
100
|
+
"""
|
|
101
|
+
Flush the digest queue to all channels configured with in_digest=True
|
|
102
|
+
or that accept MEDIUM severity. Returns number of channels notified.
|
|
103
|
+
"""
|
|
104
|
+
items = self._digest.flush()
|
|
105
|
+
if not items:
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
body = DigestQueue.build_digest_body(items)
|
|
109
|
+
title = f"Patchi digest — {len(items)} alert(s)"
|
|
110
|
+
channels = [ch for ch in self._channels if ch.accepts_severity("medium")]
|
|
111
|
+
return self._fire(channels, "medium", title, body)
|
|
112
|
+
|
|
113
|
+
def test(self, channel_name: str | None = None) -> list[tuple[str, bool]]:
|
|
114
|
+
"""
|
|
115
|
+
Send a test alert to one or all channels.
|
|
116
|
+
Returns list of (channel_name, success) tuples.
|
|
117
|
+
"""
|
|
118
|
+
targets = (
|
|
119
|
+
[ch for ch in self._channels if ch.name == channel_name]
|
|
120
|
+
if channel_name
|
|
121
|
+
else self._channels
|
|
122
|
+
)
|
|
123
|
+
results: list[tuple[str, bool]] = []
|
|
124
|
+
for ch in targets:
|
|
125
|
+
ok = _apprise_send(
|
|
126
|
+
ch.apprise_url,
|
|
127
|
+
"info",
|
|
128
|
+
"Patchi test alert",
|
|
129
|
+
"If you see this, the channel is working.",
|
|
130
|
+
)
|
|
131
|
+
results.append((ch.name, ok))
|
|
132
|
+
return results
|
|
133
|
+
|
|
134
|
+
def acknowledge(self, alert_id: str) -> bool:
|
|
135
|
+
"""Acknowledge a CRITICAL/HIGH alert by its short ID."""
|
|
136
|
+
return self._tracker.acknowledge(alert_id)
|
|
137
|
+
|
|
138
|
+
def pending_escalations(self) -> list[dict]:
|
|
139
|
+
"""Return all unacknowledged, unescalated alerts."""
|
|
140
|
+
return self._tracker.pending()
|
|
141
|
+
|
|
142
|
+
# ── Internal helpers ───────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def _eligible_channels(self, severity: str) -> list[ChannelConfig]:
|
|
145
|
+
return [ch for ch in self._channels if ch.accepts_severity(severity)]
|
|
146
|
+
|
|
147
|
+
def _fire(
|
|
148
|
+
self,
|
|
149
|
+
channels: list[ChannelConfig],
|
|
150
|
+
severity: str,
|
|
151
|
+
title: str,
|
|
152
|
+
body: str,
|
|
153
|
+
) -> int:
|
|
154
|
+
notify_type = _APPRISE_TYPE.get(severity, NotifyType.INFO)
|
|
155
|
+
sent = 0
|
|
156
|
+
for ch in channels:
|
|
157
|
+
if _apprise_send(ch.apprise_url, notify_type, title, body):
|
|
158
|
+
sent += 1
|
|
159
|
+
return sent
|
|
160
|
+
|
|
161
|
+
def _escalate(self, alert_id: str, alert: dict) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Escalation callback — fires remaining channels that haven't received the alert.
|
|
164
|
+
Called by EscalationTracker on the background thread.
|
|
165
|
+
"""
|
|
166
|
+
channels = self._eligible_channels(alert.get("severity", "high"))
|
|
167
|
+
# Skip first channel (already fired) — escalate to the rest
|
|
168
|
+
escalation_targets = channels[1:] if len(channels) > 1 else channels
|
|
169
|
+
if escalation_targets:
|
|
170
|
+
self._fire(
|
|
171
|
+
escalation_targets,
|
|
172
|
+
alert.get("severity", "high"),
|
|
173
|
+
f"[ESCALATED] {alert.get('title', 'Alert')}",
|
|
174
|
+
alert.get("body", ""),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _apprise_send(url: str, notify_type: str, title: str, body: str) -> bool:
|
|
179
|
+
"""Send one notification via Apprise. Returns True on success."""
|
|
180
|
+
ap = apprise.Apprise()
|
|
181
|
+
ap.add(url)
|
|
182
|
+
return bool(ap.notify(title=title, body=body, notify_type=notify_type))
|