@jaguilar87/gaia-ops 4.4.0 → 4.7.2
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +12 -3
- package/ARCHITECTURE.md +9 -8
- package/CHANGELOG.md +34 -0
- package/README.md +43 -11
- package/agents/terraform-architect.md +1 -1
- package/bin/README.md +2 -2
- package/bin/gaia-doctor.js +18 -5
- package/bin/gaia-history.js +0 -1
- package/bin/gaia-metrics.js +2 -2
- package/bin/gaia-scan.py +23 -1
- package/bin/gaia-update.js +346 -54
- package/bin/pre-publish-validate.js +33 -10
- package/commands/gaia.md +37 -0
- package/config/README.md +3 -9
- package/config/context-contracts.json +47 -15
- package/config/surface-routing.json +9 -1
- package/dist/gaia-ops/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
- package/dist/gaia-ops/agents/devops-developer.md +57 -0
- package/dist/gaia-ops/agents/gaia-system.md +58 -0
- package/dist/gaia-ops/agents/gitops-operator.md +60 -0
- package/dist/gaia-ops/agents/speckit-planner.md +71 -0
- package/dist/gaia-ops/agents/terraform-architect.md +60 -0
- package/dist/gaia-ops/commands/gaia.md +37 -0
- package/dist/gaia-ops/config/README.md +58 -0
- package/dist/gaia-ops/config/cloud/aws.json +140 -0
- package/dist/gaia-ops/config/cloud/gcp.json +145 -0
- package/dist/gaia-ops/config/context-contracts.json +131 -0
- package/dist/gaia-ops/config/git_standards.json +72 -0
- package/dist/gaia-ops/config/surface-routing.json +197 -0
- package/dist/gaia-ops/config/universal-rules.json +10 -0
- package/dist/gaia-ops/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-ops/hooks/adapters/base.py +219 -0
- package/dist/gaia-ops/hooks/adapters/channel.py +17 -0
- package/dist/gaia-ops/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-ops/hooks/adapters/types.py +194 -0
- package/dist/gaia-ops/hooks/adapters/utils.py +25 -0
- package/dist/gaia-ops/hooks/hooks.json +126 -0
- package/dist/gaia-ops/hooks/modules/__init__.py +15 -0
- package/dist/gaia-ops/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-ops/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-ops/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-ops/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-ops/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-ops/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-ops/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-ops/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-ops/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-ops/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-ops/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-ops/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-ops/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-ops/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-ops/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-ops/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-ops/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-ops/hooks/modules/core/state.py +179 -0
- package/dist/gaia-ops/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-ops/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-ops/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-ops/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-ops/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-ops/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-ops/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-ops/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-ops/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-ops/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-ops/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-ops/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-ops/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-ops/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-ops/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-ops/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-ops/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-ops/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-ops/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-ops/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-ops/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-ops/hooks/post_compact.py +43 -0
- package/dist/gaia-ops/hooks/post_tool_use.py +54 -0
- package/dist/gaia-ops/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-ops/hooks/session_start.py +69 -0
- package/dist/gaia-ops/hooks/stop_hook.py +69 -0
- package/dist/gaia-ops/hooks/subagent_start.py +71 -0
- package/dist/gaia-ops/hooks/subagent_stop.py +288 -0
- package/dist/gaia-ops/hooks/task_completed.py +70 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-ops/settings.json +72 -0
- package/dist/gaia-ops/skills/README.md +109 -0
- package/dist/gaia-ops/skills/agent-protocol/SKILL.md +105 -0
- package/dist/gaia-ops/skills/agent-protocol/examples.md +170 -0
- package/dist/gaia-ops/skills/agent-response/SKILL.md +53 -0
- package/dist/gaia-ops/skills/approval/SKILL.md +85 -0
- package/dist/gaia-ops/skills/approval/examples.md +140 -0
- package/dist/gaia-ops/skills/approval/reference.md +57 -0
- package/dist/gaia-ops/skills/command-execution/SKILL.md +64 -0
- package/dist/gaia-ops/skills/command-execution/reference.md +83 -0
- package/dist/gaia-ops/skills/context-updater/SKILL.md +76 -0
- package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
- package/dist/gaia-ops/skills/developer-patterns/SKILL.md +93 -0
- package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
- package/dist/gaia-ops/skills/execution/SKILL.md +66 -0
- package/dist/gaia-ops/skills/fast-queries/SKILL.md +47 -0
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +92 -0
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +22 -0
- package/dist/gaia-ops/skills/git-conventions/SKILL.md +48 -0
- package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +73 -0
- package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
- package/dist/gaia-ops/skills/investigation/SKILL.md +77 -0
- package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +64 -0
- package/dist/gaia-ops/skills/reference.md +134 -0
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +61 -0
- package/dist/gaia-ops/skills/security-tiers/destructive-commands-reference.md +623 -0
- package/dist/gaia-ops/skills/security-tiers/reference.md +39 -0
- package/dist/gaia-ops/skills/skill-creation/SKILL.md +119 -0
- package/dist/gaia-ops/skills/specification/SKILL.md +186 -0
- package/dist/gaia-ops/skills/speckit-workflow/SKILL.md +165 -0
- package/dist/gaia-ops/skills/speckit-workflow/reference.md +117 -0
- package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +63 -0
- package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
- package/dist/gaia-ops/speckit/README.md +516 -0
- package/dist/gaia-ops/speckit/scripts/.gitkeep +0 -0
- package/dist/gaia-ops/speckit/templates/adr-template.md +118 -0
- package/dist/gaia-ops/speckit/templates/agent-file-template.md +23 -0
- package/dist/gaia-ops/speckit/templates/plan-template.md +227 -0
- package/dist/gaia-ops/speckit/templates/spec-template.md +140 -0
- package/dist/gaia-ops/speckit/templates/tasks-template.md +257 -0
- package/dist/gaia-ops/tools/context/README.md +132 -0
- package/dist/gaia-ops/tools/context/__init__.py +42 -0
- package/dist/gaia-ops/tools/context/_paths.py +20 -0
- package/dist/gaia-ops/tools/context/context_provider.py +476 -0
- package/dist/gaia-ops/tools/context/context_section_reader.py +330 -0
- package/dist/gaia-ops/tools/context/deep_merge.py +159 -0
- package/dist/gaia-ops/tools/context/pending_updates.py +760 -0
- package/dist/gaia-ops/tools/context/surface_router.py +278 -0
- package/dist/gaia-ops/tools/fast-queries/README.md +65 -0
- package/dist/gaia-ops/tools/fast-queries/__init__.py +30 -0
- package/dist/gaia-ops/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
- package/dist/gaia-ops/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
- package/dist/gaia-ops/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
- package/dist/gaia-ops/tools/fast-queries/run_triage.sh +59 -0
- package/dist/gaia-ops/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
- package/dist/gaia-ops/tools/gaia_simulator/__init__.py +33 -0
- package/dist/gaia-ops/tools/gaia_simulator/cli.py +354 -0
- package/dist/gaia-ops/tools/gaia_simulator/extractor.py +457 -0
- package/dist/gaia-ops/tools/gaia_simulator/reporter.py +258 -0
- package/dist/gaia-ops/tools/gaia_simulator/routing_simulator.py +334 -0
- package/dist/gaia-ops/tools/gaia_simulator/runner.py +539 -0
- package/dist/gaia-ops/tools/gaia_simulator/skills_mapper.py +262 -0
- package/dist/gaia-ops/tools/memory/README.md +0 -0
- package/dist/gaia-ops/tools/memory/__init__.py +20 -0
- package/dist/gaia-ops/tools/memory/episodic.py +1196 -0
- package/dist/gaia-ops/tools/persist_transcript_analysis.py +85 -0
- package/dist/gaia-ops/tools/review/__init__.py +1 -0
- package/dist/gaia-ops/tools/review/review_engine.py +157 -0
- package/dist/gaia-ops/tools/scan/__init__.py +35 -0
- package/dist/gaia-ops/tools/scan/config.py +247 -0
- package/dist/gaia-ops/tools/scan/merge.py +212 -0
- package/dist/gaia-ops/tools/scan/orchestrator.py +549 -0
- package/dist/gaia-ops/tools/scan/registry.py +127 -0
- package/dist/gaia-ops/tools/scan/scanners/__init__.py +18 -0
- package/dist/gaia-ops/tools/scan/scanners/base.py +137 -0
- package/dist/gaia-ops/tools/scan/scanners/environment.py +324 -0
- package/dist/gaia-ops/tools/scan/scanners/git.py +570 -0
- package/dist/gaia-ops/tools/scan/scanners/infrastructure.py +875 -0
- package/dist/gaia-ops/tools/scan/scanners/orchestration.py +600 -0
- package/dist/gaia-ops/tools/scan/scanners/stack.py +1085 -0
- package/dist/gaia-ops/tools/scan/scanners/tools.py +260 -0
- package/dist/gaia-ops/tools/scan/setup.py +753 -0
- package/dist/gaia-ops/tools/scan/tests/__init__.py +1 -0
- package/dist/gaia-ops/tools/scan/tests/conftest.py +796 -0
- package/dist/gaia-ops/tools/scan/tests/test_environment.py +323 -0
- package/dist/gaia-ops/tools/scan/tests/test_git.py +419 -0
- package/dist/gaia-ops/tools/scan/tests/test_infrastructure.py +382 -0
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +920 -0
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +269 -0
- package/dist/gaia-ops/tools/scan/tests/test_orchestration.py +304 -0
- package/dist/gaia-ops/tools/scan/tests/test_stack.py +604 -0
- package/dist/gaia-ops/tools/scan/tests/test_tools.py +349 -0
- package/dist/gaia-ops/tools/scan/ui.py +624 -0
- package/dist/gaia-ops/tools/scan/verify.py +266 -0
- package/dist/gaia-ops/tools/scan/walk.py +118 -0
- package/dist/gaia-ops/tools/scan/workspace.py +85 -0
- package/dist/gaia-ops/tools/validation/README.md +244 -0
- package/dist/gaia-ops/tools/validation/__init__.py +17 -0
- package/dist/gaia-ops/tools/validation/approval_gate.py +321 -0
- package/dist/gaia-ops/tools/validation/validate_skills.py +189 -0
- package/dist/gaia-security/.claude-plugin/plugin.json +22 -0
- package/dist/gaia-security/config/universal-rules.json +10 -0
- package/dist/gaia-security/hooks/adapters/__init__.py +52 -0
- package/dist/gaia-security/hooks/adapters/base.py +219 -0
- package/dist/gaia-security/hooks/adapters/channel.py +17 -0
- package/dist/gaia-security/hooks/adapters/claude_code.py +1477 -0
- package/dist/gaia-security/hooks/adapters/types.py +194 -0
- package/dist/gaia-security/hooks/adapters/utils.py +25 -0
- package/dist/gaia-security/hooks/hooks.json +57 -0
- package/dist/gaia-security/hooks/modules/__init__.py +15 -0
- package/dist/gaia-security/hooks/modules/agents/__init__.py +29 -0
- package/dist/gaia-security/hooks/modules/agents/contract_validator.py +647 -0
- package/dist/gaia-security/hooks/modules/agents/response_contract.py +496 -0
- package/dist/gaia-security/hooks/modules/agents/skill_injection_verifier.py +124 -0
- package/dist/gaia-security/hooks/modules/agents/task_info_builder.py +74 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +152 -0
- package/dist/gaia-security/hooks/modules/audit/__init__.py +28 -0
- package/dist/gaia-security/hooks/modules/audit/event_detector.py +168 -0
- package/dist/gaia-security/hooks/modules/audit/logger.py +131 -0
- package/dist/gaia-security/hooks/modules/audit/metrics.py +134 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_auditor.py +576 -0
- package/dist/gaia-security/hooks/modules/audit/workflow_recorder.py +296 -0
- package/dist/gaia-security/hooks/modules/context/__init__.py +11 -0
- package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +215 -0
- package/dist/gaia-security/hooks/modules/context/context_cache.py +129 -0
- package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-security/hooks/modules/context/context_injector.py +427 -0
- package/dist/gaia-security/hooks/modules/context/context_writer.py +518 -0
- package/dist/gaia-security/hooks/modules/context/contracts_loader.py +161 -0
- package/dist/gaia-security/hooks/modules/core/__init__.py +40 -0
- package/dist/gaia-security/hooks/modules/core/hook_entry.py +78 -0
- package/dist/gaia-security/hooks/modules/core/paths.py +160 -0
- package/dist/gaia-security/hooks/modules/core/plugin_mode.py +149 -0
- package/dist/gaia-security/hooks/modules/core/plugin_setup.py +558 -0
- package/dist/gaia-security/hooks/modules/core/state.py +179 -0
- package/dist/gaia-security/hooks/modules/core/stdin.py +24 -0
- package/dist/gaia-security/hooks/modules/events/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/events/event_writer.py +210 -0
- package/dist/gaia-security/hooks/modules/identity/__init__.py +0 -0
- package/dist/gaia-security/hooks/modules/identity/identity_provider.py +21 -0
- package/dist/gaia-security/hooks/modules/identity/ops_identity.py +34 -0
- package/dist/gaia-security/hooks/modules/identity/security_identity.py +10 -0
- package/dist/gaia-security/hooks/modules/memory/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/memory/episode_writer.py +227 -0
- package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/dist/gaia-security/hooks/modules/scanning/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/scanning/scan_trigger.py +84 -0
- package/dist/gaia-security/hooks/modules/security/__init__.py +89 -0
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +87 -0
- package/dist/gaia-security/hooks/modules/security/approval_constants.py +23 -0
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +912 -0
- package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-security/hooks/modules/security/approval_scopes.py +153 -0
- package/dist/gaia-security/hooks/modules/security/blocked_commands.py +584 -0
- package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +86 -0
- package/dist/gaia-security/hooks/modules/security/command_semantics.py +130 -0
- package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +850 -0
- package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-security/hooks/modules/security/tiers.py +196 -0
- package/dist/gaia-security/hooks/modules/session/__init__.py +10 -0
- package/dist/gaia-security/hooks/modules/session/session_context_writer.py +100 -0
- package/dist/gaia-security/hooks/modules/session/session_event_injector.py +158 -0
- package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-security/hooks/modules/tools/__init__.py +25 -0
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +708 -0
- package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +181 -0
- package/dist/gaia-security/hooks/modules/tools/hook_response.py +55 -0
- package/dist/gaia-security/hooks/modules/tools/shell_parser.py +227 -0
- package/dist/gaia-security/hooks/modules/tools/task_validator.py +283 -0
- package/dist/gaia-security/hooks/modules/validation/__init__.py +23 -0
- package/dist/gaia-security/hooks/modules/validation/commit_validator.py +380 -0
- package/dist/gaia-security/hooks/post_tool_use.py +54 -0
- package/dist/gaia-security/hooks/pre_tool_use.py +383 -0
- package/dist/gaia-security/hooks/session_start.py +69 -0
- package/dist/gaia-security/hooks/stop_hook.py +69 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +177 -0
- package/dist/gaia-security/settings.json +58 -0
- package/git-hooks/commit-msg +41 -0
- package/hooks/README.md +8 -6
- package/hooks/adapters/channel.py +0 -25
- package/hooks/adapters/claude_code.py +364 -125
- package/hooks/elicitation_result.py +132 -0
- package/hooks/hooks.json +10 -1
- package/hooks/modules/README.md +3 -2
- package/hooks/modules/agents/contract_validator.py +3 -51
- package/hooks/modules/agents/response_contract.py +4 -8
- package/hooks/modules/agents/transcript_reader.py +4 -5
- package/hooks/modules/audit/__init__.py +4 -6
- package/hooks/modules/audit/event_detector.py +0 -2
- package/hooks/modules/audit/metrics.py +108 -187
- package/hooks/modules/audit/workflow_auditor.py +0 -4
- package/hooks/modules/audit/workflow_recorder.py +0 -5
- package/hooks/modules/context/compact_context_builder.py +1 -0
- package/hooks/modules/context/context_cache.py +129 -0
- package/hooks/modules/context/context_injector.py +18 -40
- package/hooks/modules/context/context_writer.py +1 -25
- package/hooks/modules/context/contracts_loader.py +7 -10
- package/hooks/modules/core/hook_entry.py +1 -0
- package/hooks/modules/core/paths.py +12 -13
- package/hooks/modules/core/plugin_mode.py +74 -4
- package/hooks/modules/core/plugin_setup.py +395 -23
- package/hooks/modules/events/__init__.py +1 -0
- package/hooks/modules/events/event_writer.py +210 -0
- package/hooks/modules/identity/ops_identity.py +18 -27
- package/hooks/modules/memory/episode_writer.py +1 -6
- package/hooks/modules/orchestrator/__init__.py +1 -0
- package/hooks/modules/orchestrator/delegate_mode.py +128 -0
- package/hooks/modules/security/__init__.py +2 -4
- package/hooks/modules/security/approval_constants.py +5 -1
- package/hooks/modules/security/approval_grants.py +189 -6
- package/hooks/modules/security/approval_messages.py +9 -21
- package/hooks/modules/security/blocked_commands.py +98 -34
- package/hooks/modules/security/command_semantics.py +0 -4
- package/hooks/modules/security/gitops_validator.py +1 -11
- package/hooks/modules/security/mutative_verbs.py +179 -38
- package/hooks/modules/security/tiers.py +1 -19
- package/hooks/modules/session/session_event_injector.py +1 -25
- package/hooks/modules/tools/bash_validator.py +310 -94
- package/hooks/modules/tools/shell_parser.py +0 -1
- package/hooks/modules/tools/task_validator.py +9 -29
- package/hooks/post_tool_use.py +0 -72
- package/hooks/pre_tool_use.py +42 -102
- package/hooks/session_start.py +4 -2
- package/hooks/subagent_start.py +6 -2
- package/hooks/subagent_stop.py +1 -13
- package/hooks/user_prompt_submit.py +119 -37
- package/index.js +1 -1
- package/package.json +5 -3
- package/skills/README.md +3 -5
- package/skills/agent-protocol/SKILL.md +17 -16
- package/skills/agent-protocol/examples.md +6 -6
- package/skills/agent-response/SKILL.md +11 -14
- package/skills/approval/SKILL.md +28 -13
- package/skills/approval/reference.md +2 -2
- package/skills/execution/SKILL.md +1 -1
- package/skills/gaia-patterns/SKILL.md +2 -3
- package/skills/orchestrator-approval/SKILL.md +22 -50
- package/skills/security-tiers/SKILL.md +1 -1
- package/templates/README.md +9 -9
- package/templates/managed-settings.template.json +43 -0
- package/tools/gaia_simulator/runner.py +34 -1
- package/tools/scan/orchestrator.py +13 -0
- package/tools/scan/scanners/base.py +8 -0
- package/tools/scan/scanners/git.py +78 -0
- package/tools/scan/scanners/infrastructure.py +65 -0
- package/tools/scan/scanners/stack.py +110 -0
- package/tools/scan/setup.py +120 -13
- package/tools/scan/workspace.py +85 -0
- package/config/context-contracts.aws.json +0 -42
- package/config/context-contracts.gcp.json +0 -39
- package/skills/project-dispatch/SKILL.md +0 -34
- package/templates/settings.template.json +0 -226
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scan Orchestrator
|
|
3
|
+
|
|
4
|
+
Runs all registered scanners in parallel, collects results, combines them
|
|
5
|
+
with existing project-context.json using the merge rules, updates metadata,
|
|
6
|
+
and performs an atomic write.
|
|
7
|
+
|
|
8
|
+
Pipeline:
|
|
9
|
+
1. Load existing project-context.json (if present)
|
|
10
|
+
2. Run all scanners in parallel (ThreadPoolExecutor)
|
|
11
|
+
3. Collect and combine scanner sections (handling environment sub-keys)
|
|
12
|
+
4. Merge with existing context (section ownership model)
|
|
13
|
+
5. Update metadata (last_updated, last_scan, scanner_version)
|
|
14
|
+
6. Atomic write to project-context.json
|
|
15
|
+
7. Return ScanOutput
|
|
16
|
+
|
|
17
|
+
Contract: specs/002-gaia-scan/data-model.md section 4
|
|
18
|
+
specs/002-gaia-scan/contracts/merge-behavior.md
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
from tools.scan import __version__ as scanner_package_version
|
|
32
|
+
from tools.scan.config import CONTRACT_CONFIG_PATH, ScanConfig
|
|
33
|
+
from tools.scan.merge import (
|
|
34
|
+
AGENT_ENRICHED_SECTIONS,
|
|
35
|
+
collect_scanner_sections,
|
|
36
|
+
merge_context,
|
|
37
|
+
)
|
|
38
|
+
from tools.scan.registry import ScannerRegistry
|
|
39
|
+
from tools.scan.scanners.base import BaseScanner, ScanResult
|
|
40
|
+
from tools.scan.workspace import WorkspaceInfo, detect_workspace_type
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ScanOutput:
|
|
47
|
+
"""Aggregated output from all scanners.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
context: Full merged project-context data (top-level with metadata,
|
|
51
|
+
paths, and sections).
|
|
52
|
+
sections_updated: Section names that were updated by scanners.
|
|
53
|
+
sections_preserved: Agent-enriched sections left untouched.
|
|
54
|
+
warnings: Aggregated warnings from all scanners.
|
|
55
|
+
errors: Aggregated errors from all scanners.
|
|
56
|
+
duration_ms: Total scan time in milliseconds.
|
|
57
|
+
scanner_results: Per-scanner ScanResult mapping.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
context: Dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
sections_updated: List[str] = field(default_factory=list)
|
|
62
|
+
sections_preserved: List[str] = field(default_factory=list)
|
|
63
|
+
warnings: List[str] = field(default_factory=list)
|
|
64
|
+
errors: List[str] = field(default_factory=list)
|
|
65
|
+
duration_ms: float = 0.0
|
|
66
|
+
scanner_results: Dict[str, ScanResult] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ScanOrchestrator:
|
|
70
|
+
"""Orchestrates parallel scanner execution with fault isolation.
|
|
71
|
+
|
|
72
|
+
Runs all scanners from a ScannerRegistry, collects their results,
|
|
73
|
+
merges sections with existing context, applies backward compatibility,
|
|
74
|
+
and returns a ScanOutput. Individual scanner failures are caught and
|
|
75
|
+
reported without aborting the scan.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
registry: ScannerRegistry with discovered scanners.
|
|
79
|
+
config: ScanConfig with orchestration settings.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
registry: Optional[ScannerRegistry] = None,
|
|
85
|
+
config: Optional[ScanConfig] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.registry = registry or ScannerRegistry()
|
|
88
|
+
self.config = config or ScanConfig()
|
|
89
|
+
|
|
90
|
+
def _run_scanner(
|
|
91
|
+
self,
|
|
92
|
+
scanner: BaseScanner,
|
|
93
|
+
project_root: Path,
|
|
94
|
+
) -> ScanResult:
|
|
95
|
+
"""Run a single scanner with fault isolation.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
scanner: Scanner instance to execute.
|
|
99
|
+
project_root: Project root path.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
ScanResult from the scanner, or an error result on failure.
|
|
103
|
+
"""
|
|
104
|
+
start_ms = time.monotonic() * 1000
|
|
105
|
+
try:
|
|
106
|
+
result = scanner.scan(project_root)
|
|
107
|
+
return result
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
elapsed_ms = (time.monotonic() * 1000) - start_ms
|
|
110
|
+
error_msg = (
|
|
111
|
+
f"Scanner '{scanner.SCANNER_NAME}' failed: "
|
|
112
|
+
f"{type(exc).__name__}: {exc}"
|
|
113
|
+
)
|
|
114
|
+
logger.warning(error_msg)
|
|
115
|
+
return ScanResult(
|
|
116
|
+
scanner=scanner.SCANNER_NAME,
|
|
117
|
+
sections={},
|
|
118
|
+
warnings=[error_msg],
|
|
119
|
+
duration_ms=elapsed_ms,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _load_existing_context(self, output_path: Path) -> Dict[str, Any]:
|
|
123
|
+
"""Load existing project-context.json if present.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
output_path: Path to project-context.json.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Parsed JSON dict, or empty structure if file does not exist.
|
|
130
|
+
"""
|
|
131
|
+
if not output_path.is_file():
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
with open(output_path, "r") as f:
|
|
136
|
+
return json.load(f)
|
|
137
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
138
|
+
logger.warning("Failed to load existing context: %s", exc)
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
def _resolve_output_path(self, project_root: Path) -> Path:
|
|
142
|
+
"""Resolve the output path for project-context.json.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
project_root: Project root path.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Absolute path to project-context.json.
|
|
149
|
+
"""
|
|
150
|
+
if self.config.output_path:
|
|
151
|
+
return self.config.output_path
|
|
152
|
+
return project_root / ".claude" / "project-context" / "project-context.json"
|
|
153
|
+
|
|
154
|
+
def _build_metadata(
|
|
155
|
+
self,
|
|
156
|
+
existing_metadata: Dict[str, Any],
|
|
157
|
+
project_root: Path,
|
|
158
|
+
) -> Dict[str, Any]:
|
|
159
|
+
"""Build updated metadata section (Rule 6: always update).
|
|
160
|
+
|
|
161
|
+
Preserves user-set fields (environment, cloud_provider, etc.) while
|
|
162
|
+
updating timestamps and scanner version.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
existing_metadata: Existing metadata from project-context.json.
|
|
166
|
+
project_root: Project root path.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Updated metadata dict.
|
|
170
|
+
"""
|
|
171
|
+
now_iso = datetime.now(timezone.utc).isoformat()
|
|
172
|
+
|
|
173
|
+
metadata = dict(existing_metadata) if existing_metadata else {}
|
|
174
|
+
metadata["version"] = metadata.get("version", "2.0")
|
|
175
|
+
metadata["last_updated"] = now_iso
|
|
176
|
+
|
|
177
|
+
# Read contract_version from context-contracts.json
|
|
178
|
+
contract_version = self._read_contract_version()
|
|
179
|
+
if contract_version:
|
|
180
|
+
metadata["contract_version"] = contract_version
|
|
181
|
+
|
|
182
|
+
# Ensure scan_config sub-section exists
|
|
183
|
+
scan_config = metadata.get("scan_config", {})
|
|
184
|
+
if not isinstance(scan_config, dict):
|
|
185
|
+
scan_config = {}
|
|
186
|
+
scan_config["last_scan"] = now_iso
|
|
187
|
+
scan_config["scanner_version"] = scanner_package_version
|
|
188
|
+
scan_config["staleness_hours"] = self.config.staleness_hours
|
|
189
|
+
metadata["scan_config"] = scan_config
|
|
190
|
+
|
|
191
|
+
return metadata
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _read_contract_version() -> Optional[str]:
|
|
195
|
+
"""Read the version field from config/context-contracts.json.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Version string (e.g. "3.0"), or None if file is missing or unreadable.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
if CONTRACT_CONFIG_PATH.is_file():
|
|
202
|
+
with open(CONTRACT_CONFIG_PATH, "r") as f:
|
|
203
|
+
data = json.load(f)
|
|
204
|
+
version = data.get("version")
|
|
205
|
+
if isinstance(version, str) and version:
|
|
206
|
+
return version
|
|
207
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
208
|
+
logger.debug("Failed to read contract version: %s", exc)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def _atomic_write(self, output_path: Path, data: Dict[str, Any]) -> None:
|
|
212
|
+
"""Atomically write data to JSON file.
|
|
213
|
+
|
|
214
|
+
Writes to a temp file in the same directory, then renames.
|
|
215
|
+
This prevents corruption from concurrent reads or crashes.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
output_path: Target file path.
|
|
219
|
+
data: Dict to serialize as JSON.
|
|
220
|
+
"""
|
|
221
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
tmp_path = output_path.with_suffix(".tmp")
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
with open(tmp_path, "w") as f:
|
|
226
|
+
json.dump(data, f, indent=2, sort_keys=False)
|
|
227
|
+
f.write("\n")
|
|
228
|
+
os.rename(str(tmp_path), str(output_path))
|
|
229
|
+
except OSError as exc:
|
|
230
|
+
logger.error("Atomic write failed: %s", exc)
|
|
231
|
+
# Clean up temp file if rename failed
|
|
232
|
+
if tmp_path.exists():
|
|
233
|
+
try:
|
|
234
|
+
tmp_path.unlink()
|
|
235
|
+
except OSError:
|
|
236
|
+
pass
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
def run(
|
|
240
|
+
self,
|
|
241
|
+
project_root: Optional[Path] = None,
|
|
242
|
+
write_output: bool = True,
|
|
243
|
+
) -> ScanOutput:
|
|
244
|
+
"""Run all registered scanners and return aggregated output.
|
|
245
|
+
|
|
246
|
+
Full pipeline:
|
|
247
|
+
1. Load existing project-context.json
|
|
248
|
+
2. Run scanners in parallel (or sequentially)
|
|
249
|
+
3. Collect and combine scanner sections
|
|
250
|
+
4. Merge with existing context using ownership rules
|
|
251
|
+
5. Update metadata
|
|
252
|
+
6. Atomic write to project-context.json (if write_output=True)
|
|
253
|
+
7. Return ScanOutput
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
project_root: Project root path. Falls back to config.project_root.
|
|
257
|
+
write_output: Whether to write the result to disk (default True).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
ScanOutput with merged sections, warnings, errors, and timing.
|
|
261
|
+
"""
|
|
262
|
+
root = project_root or self.config.project_root
|
|
263
|
+
start_ms = time.monotonic() * 1000
|
|
264
|
+
|
|
265
|
+
# Step 1: Load existing context
|
|
266
|
+
output_path = self._resolve_output_path(root)
|
|
267
|
+
existing_full = self._load_existing_context(output_path)
|
|
268
|
+
existing_sections = existing_full.get("sections", {})
|
|
269
|
+
existing_metadata = existing_full.get("metadata", {})
|
|
270
|
+
|
|
271
|
+
# Step 1.5: Detect workspace type BEFORE running scanners
|
|
272
|
+
workspace_info = detect_workspace_type(root)
|
|
273
|
+
if workspace_info.is_multi_repo:
|
|
274
|
+
logger.info(
|
|
275
|
+
"Multi-repo workspace: %d repos detected",
|
|
276
|
+
len(workspace_info.repo_dirs),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Step 2: Run all scanners
|
|
280
|
+
scanners = self.registry.get_all()
|
|
281
|
+
if self.config.scanners:
|
|
282
|
+
requested = set(self.config.scanners)
|
|
283
|
+
scanners = [s for s in scanners if s.SCANNER_NAME in requested]
|
|
284
|
+
|
|
285
|
+
# Pass workspace info to each scanner instance
|
|
286
|
+
for scanner in scanners:
|
|
287
|
+
scanner.workspace_info = workspace_info
|
|
288
|
+
|
|
289
|
+
scanner_results: Dict[str, ScanResult] = {}
|
|
290
|
+
all_warnings: List[str] = []
|
|
291
|
+
all_errors: List[str] = []
|
|
292
|
+
|
|
293
|
+
if scanners and self.config.parallel:
|
|
294
|
+
scanner_results, all_warnings, all_errors = self._run_parallel(
|
|
295
|
+
scanners, root
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
scanner_results, all_warnings, all_errors = self._run_sequential(
|
|
299
|
+
scanners, root
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Step 3: Collect and combine scanner sections
|
|
303
|
+
scan_sections = collect_scanner_sections(scanner_results)
|
|
304
|
+
|
|
305
|
+
# Step 4: Merge with existing context
|
|
306
|
+
section_owners = self.registry.get_section_owners()
|
|
307
|
+
|
|
308
|
+
# Merge (no backward-compat sections -- consumers read v2 directly)
|
|
309
|
+
merged_sections = merge_context(
|
|
310
|
+
existing=existing_sections,
|
|
311
|
+
scan_sections=scan_sections,
|
|
312
|
+
section_owners=section_owners,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Step 5: Build metadata
|
|
316
|
+
metadata = self._build_metadata(existing_metadata, root)
|
|
317
|
+
|
|
318
|
+
# Determine which sections were updated vs preserved
|
|
319
|
+
sections_updated = sorted(set(scan_sections.keys()))
|
|
320
|
+
sections_preserved = sorted(
|
|
321
|
+
name for name in existing_sections
|
|
322
|
+
if name in AGENT_ENRICHED_SECTIONS
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Ensure architecture_overview exists as empty dict so contract
|
|
326
|
+
# references are satisfied (it appears in ALL agent contracts).
|
|
327
|
+
# Other agent-enriched sections are only created when an agent
|
|
328
|
+
# populates them -- no empty {} placeholders.
|
|
329
|
+
if "architecture_overview" not in merged_sections:
|
|
330
|
+
merged_sections["architecture_overview"] = {}
|
|
331
|
+
|
|
332
|
+
# --- Derive infrastructure.paths from scanner data ---
|
|
333
|
+
self._derive_infrastructure_paths(merged_sections)
|
|
334
|
+
|
|
335
|
+
# --- Cross-populate git.monorepo.workspace_config ---
|
|
336
|
+
self._cross_populate_monorepo(merged_sections)
|
|
337
|
+
|
|
338
|
+
# --- Remove empty {} placeholders for agent-enriched and mixed sections ---
|
|
339
|
+
# These sections should only exist when they have actual data.
|
|
340
|
+
# architecture_overview is the exception -- always present (even empty).
|
|
341
|
+
from tools.scan.merge import MIXED_SECTION_SCANNER_FIELDS
|
|
342
|
+
remove_if_empty = (
|
|
343
|
+
AGENT_ENRICHED_SECTIONS
|
|
344
|
+
| frozenset(MIXED_SECTION_SCANNER_FIELDS.keys())
|
|
345
|
+
) - {"architecture_overview"}
|
|
346
|
+
for section_name in list(merged_sections.keys()):
|
|
347
|
+
if section_name in remove_if_empty:
|
|
348
|
+
if merged_sections[section_name] == {}:
|
|
349
|
+
del merged_sections[section_name]
|
|
350
|
+
|
|
351
|
+
# Build full output document (no top-level paths -- use
|
|
352
|
+
# infrastructure.paths as the single source of truth)
|
|
353
|
+
full_context: Dict[str, Any] = {
|
|
354
|
+
"metadata": metadata,
|
|
355
|
+
"sections": merged_sections,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Step 7: Atomic write
|
|
359
|
+
if write_output:
|
|
360
|
+
self._atomic_write(output_path, full_context)
|
|
361
|
+
|
|
362
|
+
elapsed_ms = (time.monotonic() * 1000) - start_ms
|
|
363
|
+
|
|
364
|
+
return ScanOutput(
|
|
365
|
+
context=full_context,
|
|
366
|
+
sections_updated=sections_updated,
|
|
367
|
+
sections_preserved=sections_preserved,
|
|
368
|
+
warnings=all_warnings,
|
|
369
|
+
errors=all_errors,
|
|
370
|
+
duration_ms=elapsed_ms,
|
|
371
|
+
scanner_results=scanner_results,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _derive_infrastructure_paths(
|
|
376
|
+
merged_sections: Dict[str, Any],
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Derive infrastructure.paths shortcuts from detected scanner data.
|
|
379
|
+
|
|
380
|
+
Populates infrastructure.paths.gitops, .terraform, and .app_services
|
|
381
|
+
from orchestration and infrastructure scanner results when the paths
|
|
382
|
+
are not already set.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
merged_sections: Merged sections dict (mutated in place).
|
|
386
|
+
"""
|
|
387
|
+
infra = merged_sections.get("infrastructure")
|
|
388
|
+
if not isinstance(infra, dict):
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
paths = infra.setdefault("paths", {})
|
|
392
|
+
|
|
393
|
+
# --- gitops: derive from orchestration.gitops.config_path ---
|
|
394
|
+
if not paths.get("gitops"):
|
|
395
|
+
orch = merged_sections.get("orchestration")
|
|
396
|
+
if isinstance(orch, dict):
|
|
397
|
+
gitops = orch.get("gitops", {})
|
|
398
|
+
if isinstance(gitops, dict) and gitops.get("config_path"):
|
|
399
|
+
paths["gitops"] = gitops["config_path"]
|
|
400
|
+
|
|
401
|
+
# --- terraform: derive from infrastructure.iac entries ---
|
|
402
|
+
if not paths.get("terraform"):
|
|
403
|
+
for iac_entry in infra.get("iac", []):
|
|
404
|
+
if isinstance(iac_entry, dict) and iac_entry.get("tool") in (
|
|
405
|
+
"terraform",
|
|
406
|
+
"terragrunt",
|
|
407
|
+
):
|
|
408
|
+
base_path = iac_entry.get("base_path")
|
|
409
|
+
if base_path and base_path != ".":
|
|
410
|
+
paths["terraform"] = base_path
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
# --- app_services: derive from Dockerfile paths common parent ---
|
|
414
|
+
if not paths.get("app_services"):
|
|
415
|
+
containers = infra.get("containers", [])
|
|
416
|
+
dockerfile_dirs: list = []
|
|
417
|
+
for container in containers:
|
|
418
|
+
if not isinstance(container, dict):
|
|
419
|
+
continue
|
|
420
|
+
if container.get("tool") != "docker":
|
|
421
|
+
continue
|
|
422
|
+
for fpath in container.get("files", []):
|
|
423
|
+
parent = str(Path(fpath).parent)
|
|
424
|
+
if parent != ".":
|
|
425
|
+
dockerfile_dirs.append(parent)
|
|
426
|
+
|
|
427
|
+
if dockerfile_dirs:
|
|
428
|
+
# Find common parent directory
|
|
429
|
+
from pathlib import PurePosixPath
|
|
430
|
+
|
|
431
|
+
parts_list = [PurePosixPath(d).parts for d in dockerfile_dirs]
|
|
432
|
+
common: list = []
|
|
433
|
+
for segments in zip(*parts_list):
|
|
434
|
+
if len(set(segments)) == 1:
|
|
435
|
+
common.append(segments[0])
|
|
436
|
+
else:
|
|
437
|
+
break
|
|
438
|
+
if common:
|
|
439
|
+
paths["app_services"] = str(PurePosixPath(*common))
|
|
440
|
+
|
|
441
|
+
# Clean up: remove None-valued path entries
|
|
442
|
+
for key in list(paths.keys()):
|
|
443
|
+
if paths[key] is None:
|
|
444
|
+
del paths[key]
|
|
445
|
+
|
|
446
|
+
@staticmethod
|
|
447
|
+
def _cross_populate_monorepo(
|
|
448
|
+
merged_sections: Dict[str, Any],
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Cross-populate git.monorepo.workspace_config from project_identity.
|
|
451
|
+
|
|
452
|
+
When the stack scanner detects a monorepo (project_identity.type ==
|
|
453
|
+
'monorepo' and project_identity.monorepo has data), propagate the
|
|
454
|
+
workspace_config to git.monorepo so both sections are consistent.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
merged_sections: Merged sections dict (mutated in place).
|
|
458
|
+
"""
|
|
459
|
+
identity = merged_sections.get("project_identity")
|
|
460
|
+
git = merged_sections.get("git")
|
|
461
|
+
if not isinstance(identity, dict) or not isinstance(git, dict):
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
monorepo_data = identity.get("monorepo", {})
|
|
465
|
+
if not isinstance(monorepo_data, dict):
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
# If project_identity detected a monorepo, populate git.monorepo
|
|
469
|
+
if monorepo_data.get("detected"):
|
|
470
|
+
git_monorepo = git.setdefault("monorepo", {})
|
|
471
|
+
if isinstance(git_monorepo, dict):
|
|
472
|
+
tool = monorepo_data.get("tool")
|
|
473
|
+
if tool and not git_monorepo.get("workspace_config"):
|
|
474
|
+
git_monorepo["workspace_config"] = tool
|
|
475
|
+
|
|
476
|
+
def _run_parallel(
|
|
477
|
+
self,
|
|
478
|
+
scanners: List[BaseScanner],
|
|
479
|
+
root: Path,
|
|
480
|
+
) -> tuple:
|
|
481
|
+
"""Run scanners in parallel using ThreadPoolExecutor.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
scanners: List of scanner instances to run.
|
|
485
|
+
root: Project root path.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Tuple of (scanner_results, all_warnings, all_errors).
|
|
489
|
+
"""
|
|
490
|
+
scanner_results: Dict[str, ScanResult] = {}
|
|
491
|
+
all_warnings: List[str] = []
|
|
492
|
+
all_errors: List[str] = []
|
|
493
|
+
|
|
494
|
+
with ThreadPoolExecutor(
|
|
495
|
+
max_workers=min(len(scanners), 8)
|
|
496
|
+
) as executor:
|
|
497
|
+
future_to_scanner = {
|
|
498
|
+
executor.submit(self._run_scanner, scanner, root): scanner
|
|
499
|
+
for scanner in scanners
|
|
500
|
+
}
|
|
501
|
+
for future in as_completed(future_to_scanner):
|
|
502
|
+
scanner = future_to_scanner[future]
|
|
503
|
+
try:
|
|
504
|
+
result = future.result(
|
|
505
|
+
timeout=self.config.timeout_per_scanner
|
|
506
|
+
)
|
|
507
|
+
except Exception as exc:
|
|
508
|
+
error_msg = (
|
|
509
|
+
f"Scanner '{scanner.SCANNER_NAME}' timed out or "
|
|
510
|
+
f"failed in executor: {type(exc).__name__}: {exc}"
|
|
511
|
+
)
|
|
512
|
+
logger.warning(error_msg)
|
|
513
|
+
result = ScanResult(
|
|
514
|
+
scanner=scanner.SCANNER_NAME,
|
|
515
|
+
sections={},
|
|
516
|
+
warnings=[error_msg],
|
|
517
|
+
duration_ms=0.0,
|
|
518
|
+
)
|
|
519
|
+
all_errors.append(error_msg)
|
|
520
|
+
|
|
521
|
+
scanner_results[scanner.SCANNER_NAME] = result
|
|
522
|
+
all_warnings.extend(result.warnings)
|
|
523
|
+
|
|
524
|
+
return scanner_results, all_warnings, all_errors
|
|
525
|
+
|
|
526
|
+
def _run_sequential(
|
|
527
|
+
self,
|
|
528
|
+
scanners: List[BaseScanner],
|
|
529
|
+
root: Path,
|
|
530
|
+
) -> tuple:
|
|
531
|
+
"""Run scanners sequentially.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
scanners: List of scanner instances to run.
|
|
535
|
+
root: Project root path.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Tuple of (scanner_results, all_warnings, all_errors).
|
|
539
|
+
"""
|
|
540
|
+
scanner_results: Dict[str, ScanResult] = {}
|
|
541
|
+
all_warnings: List[str] = []
|
|
542
|
+
all_errors: List[str] = []
|
|
543
|
+
|
|
544
|
+
for scanner in scanners:
|
|
545
|
+
result = self._run_scanner(scanner, root)
|
|
546
|
+
scanner_results[scanner.SCANNER_NAME] = result
|
|
547
|
+
all_warnings.extend(result.warnings)
|
|
548
|
+
|
|
549
|
+
return scanner_results, all_warnings, all_errors
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scanner Auto-Discovery Registry
|
|
3
|
+
|
|
4
|
+
Auto-discovers scanner modules from tools/scan/scanners/ directory.
|
|
5
|
+
Any .py file that contains a subclass of BaseScanner is registered
|
|
6
|
+
automatically. Validates no section ownership overlap at registration time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
import pkgutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from tools.scan.scanners.base import BaseScanner
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScannerRegistry:
|
|
21
|
+
"""Registry for auto-discovered scanner modules.
|
|
22
|
+
|
|
23
|
+
Auto-discovers all scanner modules in tools/scan/scanners/ by importing
|
|
24
|
+
them and finding BaseScanner subclasses. Validates section ownership
|
|
25
|
+
uniqueness at registration time.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._scanners: Dict[str, BaseScanner] = {}
|
|
30
|
+
self._section_owners: Dict[str, str] = {}
|
|
31
|
+
self._discover()
|
|
32
|
+
|
|
33
|
+
def _discover(self) -> None:
|
|
34
|
+
"""Auto-discover all scanner modules in the scanners package."""
|
|
35
|
+
scanners_dir = Path(__file__).parent / "scanners"
|
|
36
|
+
|
|
37
|
+
if not scanners_dir.is_dir():
|
|
38
|
+
logger.warning("Scanners directory not found: %s", scanners_dir)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
scanners_package = "tools.scan.scanners"
|
|
42
|
+
|
|
43
|
+
for module_info in pkgutil.iter_modules([str(scanners_dir)]):
|
|
44
|
+
if module_info.name.startswith("_") or module_info.name == "base":
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
module = importlib.import_module(
|
|
49
|
+
f"{scanners_package}.{module_info.name}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Find all BaseScanner subclasses in the module
|
|
53
|
+
for attr_name in dir(module):
|
|
54
|
+
attr = getattr(module, attr_name)
|
|
55
|
+
if (
|
|
56
|
+
isinstance(attr, type)
|
|
57
|
+
and issubclass(attr, BaseScanner)
|
|
58
|
+
and attr is not BaseScanner
|
|
59
|
+
):
|
|
60
|
+
try:
|
|
61
|
+
scanner_instance = attr()
|
|
62
|
+
self.register(scanner_instance)
|
|
63
|
+
except TypeError:
|
|
64
|
+
# Cannot instantiate (still abstract)
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
logger.warning(
|
|
69
|
+
"Failed to load scanner module '%s': %s",
|
|
70
|
+
module_info.name,
|
|
71
|
+
exc,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def register(self, scanner: BaseScanner) -> None:
|
|
75
|
+
"""Register a scanner, validating section ownership uniqueness.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
scanner: Scanner instance to register.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If scanner name is duplicate or section ownership overlaps.
|
|
82
|
+
"""
|
|
83
|
+
name = scanner.SCANNER_NAME
|
|
84
|
+
|
|
85
|
+
if name in self._scanners:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Duplicate scanner name: '{name}' is already registered"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Check section ownership overlap
|
|
91
|
+
for section in scanner.OWNED_SECTIONS:
|
|
92
|
+
if section in self._section_owners:
|
|
93
|
+
existing_owner = self._section_owners[section]
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Section ownership overlap: section '{section}' is owned by "
|
|
96
|
+
f"'{existing_owner}', cannot be claimed by '{name}'"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Register
|
|
100
|
+
self._scanners[name] = scanner
|
|
101
|
+
for section in scanner.OWNED_SECTIONS:
|
|
102
|
+
self._section_owners[section] = name
|
|
103
|
+
|
|
104
|
+
logger.debug("Registered scanner: %s (sections: %s)", name, scanner.OWNED_SECTIONS)
|
|
105
|
+
|
|
106
|
+
def get_all(self) -> List[BaseScanner]:
|
|
107
|
+
"""Return all registered scanners."""
|
|
108
|
+
return list(self._scanners.values())
|
|
109
|
+
|
|
110
|
+
def get_by_name(self, name: str) -> Optional[BaseScanner]:
|
|
111
|
+
"""Get a scanner by name.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
name: Scanner name to look up.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Scanner instance or None if not found.
|
|
118
|
+
"""
|
|
119
|
+
return self._scanners.get(name)
|
|
120
|
+
|
|
121
|
+
def get_section_owners(self) -> Dict[str, str]:
|
|
122
|
+
"""Return mapping of section name to owning scanner name."""
|
|
123
|
+
return dict(self._section_owners)
|
|
124
|
+
|
|
125
|
+
def list_names(self) -> List[str]:
|
|
126
|
+
"""Return list of all registered scanner names."""
|
|
127
|
+
return list(self._scanners.keys())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scanner modules package.
|
|
3
|
+
|
|
4
|
+
Scanner modules are auto-discovered from this directory. Any .py file that
|
|
5
|
+
exports SCANNER_NAME, SCANNER_VERSION, OWNED_SECTIONS, and scan() is
|
|
6
|
+
registered automatically by ScannerRegistry.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def __getattr__(name: str):
|
|
11
|
+
"""Lazy import to avoid circular dependency with tools.scan.registry."""
|
|
12
|
+
if name == "ScannerRegistry":
|
|
13
|
+
from tools.scan.registry import ScannerRegistry
|
|
14
|
+
return ScannerRegistry
|
|
15
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["ScannerRegistry"]
|