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,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST utilities — source-to-sink taint tracking.
|
|
3
|
+
|
|
4
|
+
Follows data flow from untrusted SOURCES (user input, request objects, env
|
|
5
|
+
vars, etc.) into dangerous SINKS (SQL execution, command execution, eval,
|
|
6
|
+
HTML rendering, ...) and reports a tainted call only when the value reaches a
|
|
7
|
+
sink without being sanitized.
|
|
8
|
+
|
|
9
|
+
The analysis is intra-procedural and best-effort: it tracks simple local
|
|
10
|
+
aliasing (`x = src; sink(x)`) and string concatenation of tainted values.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from patchi.core.brain.languages import Lang, get_parser
|
|
18
|
+
|
|
19
|
+
from .assignments import find_assignments
|
|
20
|
+
from .config import CALL_NODE_TYPES
|
|
21
|
+
from .helpers import child_by_field, children, node_text
|
|
22
|
+
|
|
23
|
+
# Untrusted data origins
|
|
24
|
+
SOURCES = {
|
|
25
|
+
"request.args",
|
|
26
|
+
"request.form",
|
|
27
|
+
"request.json",
|
|
28
|
+
"request.data",
|
|
29
|
+
"request.GET",
|
|
30
|
+
"request.POST",
|
|
31
|
+
"request.body",
|
|
32
|
+
"request.headers",
|
|
33
|
+
"request.query_params",
|
|
34
|
+
"request.path_params",
|
|
35
|
+
"request.cookies",
|
|
36
|
+
"req.query",
|
|
37
|
+
"req.body",
|
|
38
|
+
"req.params",
|
|
39
|
+
"req.headers",
|
|
40
|
+
"input",
|
|
41
|
+
"sys.argv",
|
|
42
|
+
"os.environ",
|
|
43
|
+
"os.getenv",
|
|
44
|
+
"self.request",
|
|
45
|
+
"self.params",
|
|
46
|
+
"params",
|
|
47
|
+
"query",
|
|
48
|
+
"payload",
|
|
49
|
+
"event.target",
|
|
50
|
+
"event.data",
|
|
51
|
+
"document.location",
|
|
52
|
+
"location.search",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Sanitizers that neutralize common taint categories
|
|
56
|
+
SANITIZERS = {
|
|
57
|
+
"escape",
|
|
58
|
+
"sanitize",
|
|
59
|
+
"html.escape",
|
|
60
|
+
"markupsafe.escape",
|
|
61
|
+
"bleach.clean",
|
|
62
|
+
"django.utils.html.escape",
|
|
63
|
+
"parameterized",
|
|
64
|
+
"prepare",
|
|
65
|
+
"quote",
|
|
66
|
+
"urlencode",
|
|
67
|
+
"encode_for_sql",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_log = logging.getLogger("patchi.brain.taint")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_source(text: str) -> bool:
|
|
75
|
+
t = text.strip()
|
|
76
|
+
return (
|
|
77
|
+
t in SOURCES
|
|
78
|
+
or any(t.startswith(s + ".") for s in SOURCES)
|
|
79
|
+
or t
|
|
80
|
+
in (
|
|
81
|
+
"request",
|
|
82
|
+
"req",
|
|
83
|
+
"input()",
|
|
84
|
+
"event",
|
|
85
|
+
"location",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_sanitized(text: str) -> bool:
|
|
91
|
+
return any(s in text for s in SANITIZERS)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def track_taint(content: str, lang: Lang, sinks: set[str]) -> list[dict]:
|
|
95
|
+
"""
|
|
96
|
+
Track tainted data from SOURCES into `sinks`.
|
|
97
|
+
|
|
98
|
+
Returns list of dicts with keys: name, line, col, full_text, tainted_via.
|
|
99
|
+
A sink call is reported when at least one of its arguments is either a
|
|
100
|
+
direct source or an alias of a source.
|
|
101
|
+
"""
|
|
102
|
+
if lang == Lang.PYTHON:
|
|
103
|
+
return _track_python(content, sinks)
|
|
104
|
+
parser = get_parser(lang)
|
|
105
|
+
if not parser:
|
|
106
|
+
return []
|
|
107
|
+
try:
|
|
108
|
+
tree = parser.parse(content.encode("utf-8"))
|
|
109
|
+
except Exception as e:
|
|
110
|
+
_log.warning("track_taint failed: %s", e)
|
|
111
|
+
return []
|
|
112
|
+
call_types = CALL_NODE_TYPES.get(lang, set())
|
|
113
|
+
results: list[dict] = []
|
|
114
|
+
aliases = _collect_aliases(content, lang)
|
|
115
|
+
_walk_taint(tree.root_node, call_types, sinks, aliases, results)
|
|
116
|
+
return results
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _collect_aliases(content: str, lang: Lang) -> dict[str, str]:
|
|
120
|
+
"""Map variable name -> source text it was assigned from (one hop)."""
|
|
121
|
+
aliases: dict[str, str] = {}
|
|
122
|
+
for asg in find_assignments(content, lang):
|
|
123
|
+
target = asg.get("target", "")
|
|
124
|
+
value = asg.get("value", "")
|
|
125
|
+
if target and value and _is_source(value):
|
|
126
|
+
aliases[target.split(".")[0]] = value
|
|
127
|
+
return aliases
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _argument_texts(call_node: object) -> list[str]:
|
|
131
|
+
arg_nodes = (
|
|
132
|
+
child_by_field(call_node, "arguments")
|
|
133
|
+
or child_by_field(call_node, "argument_list")
|
|
134
|
+
or child_by_field(call_node, "value_arguments")
|
|
135
|
+
or child_by_field(call_node, "argument_part")
|
|
136
|
+
)
|
|
137
|
+
if arg_nodes is None:
|
|
138
|
+
return []
|
|
139
|
+
out = []
|
|
140
|
+
for c in children(arg_nodes):
|
|
141
|
+
if c.type in ("(", ")", ",", "[", "]", "{", "}"):
|
|
142
|
+
continue
|
|
143
|
+
out.append(node_text(c))
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _walk_taint(
|
|
148
|
+
node: object,
|
|
149
|
+
call_types: set[str],
|
|
150
|
+
sinks: set[str],
|
|
151
|
+
aliases: dict[str, str],
|
|
152
|
+
results: list[dict],
|
|
153
|
+
) -> None:
|
|
154
|
+
try:
|
|
155
|
+
ntype = node.type if hasattr(node, "type") else ""
|
|
156
|
+
except Exception as e:
|
|
157
|
+
_log.warning("_walk_taint failed: %s", e)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if ntype in call_types:
|
|
161
|
+
fn = _call_name(node)
|
|
162
|
+
leaf = fn.split(".")[-1] if "." in fn else fn
|
|
163
|
+
if fn in sinks or leaf in sinks:
|
|
164
|
+
arg_texts = _argument_texts(node)
|
|
165
|
+
tainted = []
|
|
166
|
+
for arg in arg_texts:
|
|
167
|
+
base = arg.split(".")[0].split("[")[0].strip()
|
|
168
|
+
if (
|
|
169
|
+
_is_source(arg)
|
|
170
|
+
or base in aliases
|
|
171
|
+
or (any(s in arg for s in aliases) and not _is_sanitized(arg))
|
|
172
|
+
):
|
|
173
|
+
tainted.append(arg)
|
|
174
|
+
if tainted:
|
|
175
|
+
try:
|
|
176
|
+
line = node.start_point[0] + 1 if node.start_point else 0
|
|
177
|
+
col = node.start_point[1] if node.start_point else 0
|
|
178
|
+
except Exception as e:
|
|
179
|
+
_log.warning("_walk_taint failed: %s", e)
|
|
180
|
+
line, col = 0, 0
|
|
181
|
+
results.append(
|
|
182
|
+
{
|
|
183
|
+
"name": fn,
|
|
184
|
+
"line": line,
|
|
185
|
+
"col": col,
|
|
186
|
+
"full_text": node_text(node),
|
|
187
|
+
"tainted_via": tainted,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
for child in children(node):
|
|
192
|
+
_walk_taint(child, call_types, sinks, aliases, results)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _call_name(node: object) -> str:
|
|
196
|
+
fn = child_by_field(node, "function") or child_by_field(node, "name")
|
|
197
|
+
if fn is not None:
|
|
198
|
+
return node_text(fn).split("(")[0].strip()
|
|
199
|
+
parts = []
|
|
200
|
+
for child in children(node):
|
|
201
|
+
if child.type in ("argument_list", "arguments", "value_arguments", "argument_part"):
|
|
202
|
+
break
|
|
203
|
+
txt = node_text(child)
|
|
204
|
+
if txt and txt not in ("(", ")", ".", "::"):
|
|
205
|
+
parts.append(txt)
|
|
206
|
+
return ".".join(parts) if parts else ""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _track_python(content: str, sinks: set[str]) -> list[dict]:
|
|
210
|
+
import ast
|
|
211
|
+
|
|
212
|
+
results: list[dict] = []
|
|
213
|
+
try:
|
|
214
|
+
tree = ast.parse(content)
|
|
215
|
+
except SyntaxError:
|
|
216
|
+
return results
|
|
217
|
+
|
|
218
|
+
aliases: dict[str, str] = {}
|
|
219
|
+
for node in ast.walk(tree):
|
|
220
|
+
if isinstance(node, ast.Assign):
|
|
221
|
+
for t in node.targets:
|
|
222
|
+
if isinstance(t, ast.Name) and isinstance(node.value, ast.Call):
|
|
223
|
+
call = node.value
|
|
224
|
+
if isinstance(call.func, ast.Name) and _is_source(_py_src_expr(call.func.id)):
|
|
225
|
+
aliases[t.id] = call.func.id
|
|
226
|
+
elif isinstance(call.func, ast.Attribute) and _is_source(
|
|
227
|
+
_py_src_expr(_py_attr_str(call.func))
|
|
228
|
+
):
|
|
229
|
+
aliases[t.id] = _py_attr_str(call.func)
|
|
230
|
+
|
|
231
|
+
for node in ast.walk(tree):
|
|
232
|
+
if isinstance(node, ast.Call):
|
|
233
|
+
fn_str = _py_call_str(node.func)
|
|
234
|
+
leaf = fn_str.split(".")[-1]
|
|
235
|
+
if fn_str in sinks or leaf in sinks:
|
|
236
|
+
tainted = []
|
|
237
|
+
for arg in node.args:
|
|
238
|
+
arg_str = _py_arg_str(arg)
|
|
239
|
+
base = arg_str.split(".")[0].split("[")[0].strip()
|
|
240
|
+
if (
|
|
241
|
+
_is_source(arg_str)
|
|
242
|
+
or base in aliases
|
|
243
|
+
or (
|
|
244
|
+
any(a in arg_str for a in aliases.values())
|
|
245
|
+
and not _is_sanitized(arg_str)
|
|
246
|
+
)
|
|
247
|
+
):
|
|
248
|
+
tainted.append(arg_str)
|
|
249
|
+
if tainted:
|
|
250
|
+
results.append(
|
|
251
|
+
{
|
|
252
|
+
"name": fn_str,
|
|
253
|
+
"line": getattr(node, "lineno", 0),
|
|
254
|
+
"col": getattr(node, "col_offset", 0),
|
|
255
|
+
"full_text": _ast_text(node, content),
|
|
256
|
+
"tainted_via": tainted,
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
return results
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── Python helpers ─────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _py_src_expr(s: str) -> str:
|
|
266
|
+
return s
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _py_attr_str(node: ast.AST) -> str:
|
|
270
|
+
parts = []
|
|
271
|
+
cur = node
|
|
272
|
+
while isinstance(cur, ast.Attribute):
|
|
273
|
+
parts.append(cur.attr)
|
|
274
|
+
cur = cur.value
|
|
275
|
+
if isinstance(cur, ast.Name):
|
|
276
|
+
parts.append(cur.id)
|
|
277
|
+
return ".".join(reversed(parts))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _py_call_str(node: ast.AST) -> str:
|
|
281
|
+
if isinstance(node, ast.Name):
|
|
282
|
+
return node.id
|
|
283
|
+
if isinstance(node, ast.Attribute):
|
|
284
|
+
return _py_attr_str(node)
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _py_arg_str(node: ast.AST) -> str:
|
|
289
|
+
if isinstance(node, ast.Call):
|
|
290
|
+
return _py_call_str(node.func)
|
|
291
|
+
if isinstance(node, ast.Attribute):
|
|
292
|
+
return _py_attr_str(node)
|
|
293
|
+
if isinstance(node, ast.Name):
|
|
294
|
+
return node.id
|
|
295
|
+
if isinstance(node, ast.Constant):
|
|
296
|
+
return repr(node.value)
|
|
297
|
+
if isinstance(node, ast.Subscript):
|
|
298
|
+
return _py_arg_str(node.value)
|
|
299
|
+
return _py_call_str(node) or _py_attr_str(node)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _ast_text(node: ast.AST, source: str) -> str:
|
|
303
|
+
try:
|
|
304
|
+
lines = source.splitlines()
|
|
305
|
+
start = getattr(node, "lineno", 1) - 1
|
|
306
|
+
end = getattr(node, "end_lineno", start + 1) - 1
|
|
307
|
+
return "\n".join(lines[start : end + 1])
|
|
308
|
+
except Exception as e:
|
|
309
|
+
_log.debug("_ast_text failed: %s", e)
|
|
310
|
+
return ""
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drift Detector + Plan-vs-Built Report Card (Dream Assistant spec, priority #2).
|
|
3
|
+
|
|
4
|
+
The agent (and the developer) declare *intent* — a Plan: which layers exist,
|
|
5
|
+
what the charter is, what the codebase currently looks like. Later, `p audit`
|
|
6
|
+
re-scans the *actual* built state and reports the gap between Plan and Built.
|
|
7
|
+
|
|
8
|
+
This is deliberately additive: it snapshots the current memory (layers, charter,
|
|
9
|
+
scan results) as a Plan and diffs it against the live state.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
p audit save [--intent "..."] — snapshot current state as the agreed Plan
|
|
13
|
+
p audit [--no-scan] — Plan-vs-Built report card + drift
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
_PLAN_FILE = ".patchi/plan.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_log = logging.getLogger("patchi.brain.audit")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _snapshot_scope(root: Path) -> dict:
|
|
30
|
+
"""
|
|
31
|
+
Capture the REAL scope of the codebase: for every source file its language,
|
|
32
|
+
exported symbols (functions/classes/exports), and a content hash. This is
|
|
33
|
+
what makes the Drift Detector a true scope diff, not just a finding-count
|
|
34
|
+
proxy.
|
|
35
|
+
"""
|
|
36
|
+
from patchi.core.brain.scanner import FileScanner
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
fis = FileScanner(root).scan()
|
|
40
|
+
except Exception as e:
|
|
41
|
+
_log.warning("_snapshot_scope failed: %s", e)
|
|
42
|
+
return {}
|
|
43
|
+
scope: dict[str, dict] = {}
|
|
44
|
+
for fi in fis:
|
|
45
|
+
if fi.error:
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
data = (Path(root) / fi.path).read_bytes()
|
|
49
|
+
h = hashlib.sha256(data).hexdigest()[:16]
|
|
50
|
+
except Exception as e:
|
|
51
|
+
_log.warning("_snapshot_scope failed: %s", e)
|
|
52
|
+
h = ""
|
|
53
|
+
syms = sorted(
|
|
54
|
+
{f.name for f in fi.functions} | {c.name for c in fi.classes} | set(fi.exports)
|
|
55
|
+
)
|
|
56
|
+
scope[fi.path] = {"lang": fi.language.value, "symbols": syms, "hash": h}
|
|
57
|
+
return scope
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _diff_scope(plan_scope: dict, cur_scope: dict) -> dict:
|
|
61
|
+
"""Real file/symbol scope diff between the agreed Plan and the built state."""
|
|
62
|
+
plan_files = set(plan_scope)
|
|
63
|
+
cur_files = set(cur_scope)
|
|
64
|
+
added = sorted(cur_files - plan_files)
|
|
65
|
+
removed = sorted(plan_files - cur_files)
|
|
66
|
+
modified = []
|
|
67
|
+
for f in sorted(plan_files & cur_files):
|
|
68
|
+
p = plan_scope[f]
|
|
69
|
+
c = cur_scope[f]
|
|
70
|
+
if p.get("hash") == c.get("hash"):
|
|
71
|
+
continue
|
|
72
|
+
added_sym = sorted(set(c.get("symbols", [])) - set(p.get("symbols", [])))
|
|
73
|
+
removed_sym = sorted(set(p.get("symbols", [])) - set(c.get("symbols", [])))
|
|
74
|
+
modified.append(
|
|
75
|
+
{
|
|
76
|
+
"file": f,
|
|
77
|
+
"symbols_added": added_sym,
|
|
78
|
+
"symbols_removed": removed_sym,
|
|
79
|
+
"symbols_unchanged": sorted(set(p.get("symbols", [])) & set(c.get("symbols", []))),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return {
|
|
83
|
+
"added_files": added,
|
|
84
|
+
"removed_files": removed,
|
|
85
|
+
"modified_files": modified,
|
|
86
|
+
"added_count": len(added),
|
|
87
|
+
"removed_count": len(removed),
|
|
88
|
+
"modified_count": len(modified),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _now() -> str:
|
|
93
|
+
return datetime.now(UTC).isoformat()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _count_findings(result: dict) -> int:
|
|
97
|
+
"""Recursively count concrete findings inside one scanner result."""
|
|
98
|
+
if not isinstance(result, dict):
|
|
99
|
+
return 0
|
|
100
|
+
total = 0
|
|
101
|
+
for key, val in result.items():
|
|
102
|
+
if key in ("timestamp", "scanned_at", "layer", "name"):
|
|
103
|
+
continue
|
|
104
|
+
if key == "findings" and isinstance(val, list):
|
|
105
|
+
total += len([f for f in val if f])
|
|
106
|
+
elif isinstance(val, list):
|
|
107
|
+
for item in val:
|
|
108
|
+
if isinstance(item, dict) and (item.get("findings") or item.get("issues")):
|
|
109
|
+
total += len(item.get("findings") or item.get("issues") or [])
|
|
110
|
+
elif isinstance(item, dict):
|
|
111
|
+
total += _count_findings(item)
|
|
112
|
+
elif isinstance(val, dict):
|
|
113
|
+
total += _count_findings(val)
|
|
114
|
+
return total
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _charter_violations(scans: dict) -> int:
|
|
118
|
+
cg = scans.get("CharterGuard", {})
|
|
119
|
+
if isinstance(cg, dict):
|
|
120
|
+
return len(cg.get("findings", []) or [])
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _snapshot_state(root: Path) -> dict:
|
|
125
|
+
from patchi.core import memory as mem
|
|
126
|
+
|
|
127
|
+
charter = mem.get_charter(root) or {}
|
|
128
|
+
layers = mem.get_layers(root) or {}
|
|
129
|
+
scans = mem.get_scan_results(root) or {}
|
|
130
|
+
scan_summary = {
|
|
131
|
+
name: {
|
|
132
|
+
"finding_count": _count_findings(res),
|
|
133
|
+
"timestamp": (res.get("timestamp") if isinstance(res, dict) else None),
|
|
134
|
+
}
|
|
135
|
+
for name, res in scans.items()
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
"layers": layers,
|
|
139
|
+
"charter": charter,
|
|
140
|
+
"scan_summary": scan_summary,
|
|
141
|
+
"charter_violations": _charter_violations(scans),
|
|
142
|
+
"total_findings": sum(v["finding_count"] for v in scan_summary.values()),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def save_plan(root: Path, intent: str | None = None) -> dict:
|
|
147
|
+
"""Snapshot the current state (including real scope) as the agreed Plan."""
|
|
148
|
+
root = Path(root)
|
|
149
|
+
state = _snapshot_state(root)
|
|
150
|
+
plan = {
|
|
151
|
+
"saved_at": _now(),
|
|
152
|
+
"intent": intent or "",
|
|
153
|
+
**state,
|
|
154
|
+
"scope": _snapshot_scope(root),
|
|
155
|
+
}
|
|
156
|
+
p = root / _PLAN_FILE
|
|
157
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
tmp = p.with_suffix(".tmp")
|
|
159
|
+
tmp.write_text(json.dumps(plan, indent=2, default=str), encoding="utf-8")
|
|
160
|
+
tmp.replace(p)
|
|
161
|
+
return plan
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_plan(root: Path) -> dict | None:
|
|
165
|
+
p = Path(root) / _PLAN_FILE
|
|
166
|
+
if not p.exists():
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
170
|
+
except Exception as e:
|
|
171
|
+
_log.warning("load_plan failed: %s", e)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compute_drift(root: Path, scans: dict | None = None) -> dict:
|
|
176
|
+
"""
|
|
177
|
+
Diff the agreed Plan against the live built state.
|
|
178
|
+
|
|
179
|
+
scans: optional injectable current scan results (for tests).
|
|
180
|
+
"""
|
|
181
|
+
root = Path(root)
|
|
182
|
+
plan = load_plan(root)
|
|
183
|
+
if not plan:
|
|
184
|
+
return {"has_plan": False}
|
|
185
|
+
|
|
186
|
+
current = _snapshot_state(root) if scans is None else _summarize(scans)
|
|
187
|
+
cur_scope = _snapshot_scope(root)
|
|
188
|
+
|
|
189
|
+
plan_layers = set((plan.get("layers") or {}).keys())
|
|
190
|
+
cur_layers = set((current.get("layers") or {}).keys())
|
|
191
|
+
added = sorted(cur_layers - plan_layers)
|
|
192
|
+
removed = sorted(plan_layers - cur_layers)
|
|
193
|
+
unchanged = sorted(plan_layers & cur_layers)
|
|
194
|
+
|
|
195
|
+
scope_diff = _diff_scope(plan.get("scope", {}) or {}, cur_scope)
|
|
196
|
+
|
|
197
|
+
drift = {
|
|
198
|
+
"has_plan": True,
|
|
199
|
+
"saved_at": plan.get("saved_at"),
|
|
200
|
+
"intent": plan.get("intent", ""),
|
|
201
|
+
"layers_added": added,
|
|
202
|
+
"layers_removed": removed,
|
|
203
|
+
"layers_unchanged": unchanged,
|
|
204
|
+
"plan_total_findings": plan.get("total_findings", 0),
|
|
205
|
+
"built_total_findings": current.get("total_findings", 0),
|
|
206
|
+
"new_findings": max(0, current.get("total_findings", 0) - plan.get("total_findings", 0)),
|
|
207
|
+
"plan_charter_violations": plan.get("charter_violations", 0),
|
|
208
|
+
"built_charter_violations": current.get("charter_violations", 0),
|
|
209
|
+
"new_charter_violations": max(
|
|
210
|
+
0, current.get("charter_violations", 0) - plan.get("charter_violations", 0)
|
|
211
|
+
),
|
|
212
|
+
"per_scanner": _diff_scanners(
|
|
213
|
+
plan.get("scan_summary", {}), current.get("scan_summary", {})
|
|
214
|
+
),
|
|
215
|
+
"scope_diff": scope_diff,
|
|
216
|
+
}
|
|
217
|
+
drift["clean"] = (
|
|
218
|
+
not added
|
|
219
|
+
and not removed
|
|
220
|
+
and drift["new_findings"] == 0
|
|
221
|
+
and drift["new_charter_violations"] == 0
|
|
222
|
+
and scope_diff["added_count"] == 0
|
|
223
|
+
and scope_diff["removed_count"] == 0
|
|
224
|
+
and scope_diff["modified_count"] == 0
|
|
225
|
+
)
|
|
226
|
+
return drift
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _summarize(scans: dict) -> dict:
|
|
230
|
+
scan_summary = {
|
|
231
|
+
name: {"finding_count": _count_findings(res), "timestamp": None}
|
|
232
|
+
for name, res in scans.items()
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
"layers": {},
|
|
236
|
+
"charter": {},
|
|
237
|
+
"scan_summary": scan_summary,
|
|
238
|
+
"charter_violations": _charter_violations(scans),
|
|
239
|
+
"total_findings": sum(v["finding_count"] for v in scan_summary.values()),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _diff_scanners(plan_summary: dict, cur_summary: dict) -> list[dict]:
|
|
244
|
+
rows = []
|
|
245
|
+
all_names = sorted(set(plan_summary) | set(cur_summary))
|
|
246
|
+
for name in all_names:
|
|
247
|
+
plan_c = plan_summary.get(name, {}).get("finding_count", 0)
|
|
248
|
+
cur_c = cur_summary.get(name, {}).get("finding_count", 0)
|
|
249
|
+
delta = cur_c - plan_c
|
|
250
|
+
rows.append({"scanner": name, "plan": plan_c, "built": cur_c, "delta": delta})
|
|
251
|
+
return rows
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def report_card(root: Path, run_scan: bool = True) -> dict:
|
|
255
|
+
"""
|
|
256
|
+
Plan-vs-Built Report Card. Re-scans (unless --no-scan) then computes drift
|
|
257
|
+
and a plain-language summary.
|
|
258
|
+
"""
|
|
259
|
+
root = Path(root)
|
|
260
|
+
if run_scan:
|
|
261
|
+
try:
|
|
262
|
+
from patchi.core.brain.brain import Brain
|
|
263
|
+
|
|
264
|
+
Brain(root).scan()
|
|
265
|
+
except Exception as e:
|
|
266
|
+
_log.warning("report_card failed: %s", e)
|
|
267
|
+
drift = compute_drift(root)
|
|
268
|
+
if not drift.get("has_plan"):
|
|
269
|
+
return {"has_plan": False, "summary": "No Plan saved. Run `p audit save` first."}
|
|
270
|
+
|
|
271
|
+
lines = [f"Plan saved at {drift['saved_at']}"]
|
|
272
|
+
if drift.get("intent"):
|
|
273
|
+
lines.append(f"Intent: {drift['intent']}")
|
|
274
|
+
if drift["layers_added"]:
|
|
275
|
+
lines.append(f" + Layers built beyond plan: {', '.join(drift['layers_added'])}")
|
|
276
|
+
if drift["layers_removed"]:
|
|
277
|
+
lines.append(f" - Layers in plan but missing: {', '.join(drift['layers_removed'])}")
|
|
278
|
+
sd = drift.get("scope_diff", {}) or {}
|
|
279
|
+
if sd.get("added_files"):
|
|
280
|
+
lines.append(
|
|
281
|
+
f" + Files added beyond plan ({sd['added_count']}): "
|
|
282
|
+
f"{', '.join(f for f in sd['added_files'][:8])}"
|
|
283
|
+
+ (" …" if sd["added_count"] > 8 else "")
|
|
284
|
+
)
|
|
285
|
+
if sd.get("removed_files"):
|
|
286
|
+
lines.append(
|
|
287
|
+
f" - Files removed vs plan ({sd['removed_count']}): "
|
|
288
|
+
f"{', '.join(f for f in sd['removed_files'][:8])}"
|
|
289
|
+
+ (" …" if sd["removed_count"] > 8 else "")
|
|
290
|
+
)
|
|
291
|
+
if sd.get("modified_files"):
|
|
292
|
+
lines.append(f" ~ Files changed vs plan ({sd['modified_count']}):")
|
|
293
|
+
for m in sd["modified_files"][:8]:
|
|
294
|
+
bits = []
|
|
295
|
+
if m["symbols_added"]:
|
|
296
|
+
bits.append(f"+{len(m['symbols_added'])} sym")
|
|
297
|
+
if m["symbols_removed"]:
|
|
298
|
+
bits.append(f"-{len(m['symbols_removed'])} sym")
|
|
299
|
+
if not bits:
|
|
300
|
+
bits.append("content changed")
|
|
301
|
+
lines.append(f" • {m['file']} ({', '.join(bits)})")
|
|
302
|
+
lines.append(
|
|
303
|
+
f" Findings: plan {drift['plan_total_findings']} -> built {drift['built_total_findings']} "
|
|
304
|
+
f"({drift['new_findings']} new)"
|
|
305
|
+
)
|
|
306
|
+
if drift["new_charter_violations"]:
|
|
307
|
+
lines.append(f" Charter violations: {drift['new_charter_violations']} new")
|
|
308
|
+
for row in drift["per_scanner"]:
|
|
309
|
+
if row["delta"]:
|
|
310
|
+
lines.append(
|
|
311
|
+
f" • {row['scanner']}: {row['plan']} -> {row['built']} ({row['delta']:+d})"
|
|
312
|
+
)
|
|
313
|
+
status = "ON PLAN ✅" if drift["clean"] else "DRIFT DETECTED ⚠️"
|
|
314
|
+
lines.insert(0, f"Plan-vs-Built: {status}")
|
|
315
|
+
drift["summary"] = "\n".join(lines)
|
|
316
|
+
return drift
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def drift_vs_plan_file(root: Path, plan_file: str | Path) -> dict:
|
|
320
|
+
"""
|
|
321
|
+
Audit the built state against an externally supplied plan/spec file
|
|
322
|
+
(the `p audit --plan-file` path). The file is treated as the agreed Plan
|
|
323
|
+
and diffed against the live state.
|
|
324
|
+
"""
|
|
325
|
+
root = Path(root)
|
|
326
|
+
path = Path(plan_file)
|
|
327
|
+
if not path.exists():
|
|
328
|
+
return {"has_plan": False, "error": f"Plan file not found: {path}"}
|
|
329
|
+
try:
|
|
330
|
+
plan = json.loads(path.read_text(encoding="utf-8"))
|
|
331
|
+
except Exception as e:
|
|
332
|
+
return {"has_plan": False, "error": f"Could not parse plan file: {e}"}
|
|
333
|
+
|
|
334
|
+
current = _snapshot_state(root)
|
|
335
|
+
cur_scope = _snapshot_scope(root)
|
|
336
|
+
plan_layers = set((plan.get("layers") or {}).keys())
|
|
337
|
+
cur_layers = set((current.get("layers") or {}).keys())
|
|
338
|
+
plan_total = plan.get("total_findings", 0)
|
|
339
|
+
cur_total = current.get("total_findings", 0)
|
|
340
|
+
plan_charter = plan.get("charter_violations", 0)
|
|
341
|
+
cur_charter = current.get("charter_violations", 0)
|
|
342
|
+
|
|
343
|
+
plan_summary = plan.get("scan_summary", {})
|
|
344
|
+
cur_summary = current.get("scan_summary", {})
|
|
345
|
+
|
|
346
|
+
drift = {
|
|
347
|
+
"has_plan": True,
|
|
348
|
+
"from_file": str(path),
|
|
349
|
+
"intent": plan.get("intent", ""),
|
|
350
|
+
"layers_added": sorted(cur_layers - plan_layers),
|
|
351
|
+
"layers_removed": sorted(plan_layers - cur_layers),
|
|
352
|
+
"plan_total_findings": plan_total,
|
|
353
|
+
"built_total_findings": cur_total,
|
|
354
|
+
"new_findings": max(0, cur_total - plan_total),
|
|
355
|
+
"new_charter_violations": max(0, cur_charter - plan_charter),
|
|
356
|
+
"per_scanner": _diff_scanners(plan_summary, cur_summary),
|
|
357
|
+
"scope_diff": _diff_scope(plan.get("scope", {}) or {}, cur_scope),
|
|
358
|
+
}
|
|
359
|
+
drift["clean"] = (
|
|
360
|
+
not drift["layers_added"]
|
|
361
|
+
and not drift["layers_removed"]
|
|
362
|
+
and drift["new_findings"] == 0
|
|
363
|
+
and drift["new_charter_violations"] == 0
|
|
364
|
+
)
|
|
365
|
+
status = "ON PLAN ✅" if drift["clean"] else "DRIFT DETECTED ⚠️"
|
|
366
|
+
lines = [f"Plan-vs-Built (vs {path.name}): {status}"]
|
|
367
|
+
if drift["intent"]:
|
|
368
|
+
lines.append(f"Intent: {drift['intent']}")
|
|
369
|
+
if drift["layers_added"]:
|
|
370
|
+
lines.append(f" + Layers beyond plan: {', '.join(drift['layers_added'])}")
|
|
371
|
+
if drift["layers_removed"]:
|
|
372
|
+
lines.append(f" - Layers missing vs plan: {', '.join(drift['layers_removed'])}")
|
|
373
|
+
lines.append(
|
|
374
|
+
f" Findings: plan {plan_total} -> built {cur_total} ({drift['new_findings']} new)"
|
|
375
|
+
)
|
|
376
|
+
if drift["new_charter_violations"]:
|
|
377
|
+
lines.append(f" Charter violations: {drift['new_charter_violations']} new")
|
|
378
|
+
drift["summary"] = "\n".join(lines)
|
|
379
|
+
return drift
|