@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,875 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Infrastructure Scanner
|
|
3
|
+
|
|
4
|
+
Detects cloud providers, IaC tools, container tooling, CI/CD platforms,
|
|
5
|
+
application services, and infrastructure-related directory paths. Only
|
|
6
|
+
produces the 'infrastructure' section when at least one indicator is found;
|
|
7
|
+
returns empty dict for projects with no infrastructure files.
|
|
8
|
+
|
|
9
|
+
Schema: data-model.md section 2.7
|
|
10
|
+
Contract: contracts/scanner-interface.md
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional, Set
|
|
19
|
+
|
|
20
|
+
from tools.scan.scanners.base import BaseScanner, ScanResult
|
|
21
|
+
from tools.scan.walk import walk_project, walk_project_named
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Terraform provider patterns
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
_TF_PROVIDER_PATTERNS: Dict[str, str] = {
|
|
29
|
+
"gcp": r'provider\s+"google"',
|
|
30
|
+
"aws": r'provider\s+"aws"',
|
|
31
|
+
"azure": r'provider\s+"azurerm"',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Cloud-related environment variable prefixes (non-secret only)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
_CLOUD_ENV_VARS: Dict[str, List[str]] = {
|
|
38
|
+
"gcp": [
|
|
39
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
40
|
+
"GCLOUD_PROJECT",
|
|
41
|
+
"GCP_PROJECT",
|
|
42
|
+
"CLOUDSDK_CORE_PROJECT",
|
|
43
|
+
],
|
|
44
|
+
"aws": [
|
|
45
|
+
"AWS_DEFAULT_REGION",
|
|
46
|
+
"AWS_REGION",
|
|
47
|
+
],
|
|
48
|
+
"azure": [
|
|
49
|
+
"AZURE_SUBSCRIPTION_ID",
|
|
50
|
+
"ARM_SUBSCRIPTION_ID",
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# IaC tool markers
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
_IAC_MARKERS: List[Dict[str, str]] = [
|
|
58
|
+
{"tool": "terraform", "rglob": "*.tf"},
|
|
59
|
+
{"tool": "terragrunt", "rglob": "terragrunt.hcl"},
|
|
60
|
+
{"tool": "pulumi", "rglob": "Pulumi.yaml"},
|
|
61
|
+
{"tool": "cdk", "rglob": "cdk.json"},
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Container markers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
_CONTAINER_GLOBS: List[Dict[str, Any]] = [
|
|
68
|
+
{"tool": "docker", "rglob_patterns": ["Dockerfile", "Dockerfile.*"]},
|
|
69
|
+
{"tool": "docker-compose", "rglob_patterns": ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# CI/CD platform markers
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
_CICD_MARKERS: List[Dict[str, Any]] = [
|
|
76
|
+
{"platform": "github-actions", "type": "dir", "path": ".github/workflows"},
|
|
77
|
+
{"platform": "gitlab-ci", "type": "file", "path": ".gitlab-ci.yml"},
|
|
78
|
+
{"platform": "jenkins", "type": "file", "path": "Jenkinsfile"},
|
|
79
|
+
{"platform": "circleci", "type": "dir", "path": ".circleci"},
|
|
80
|
+
{"platform": "bitbucket-pipelines", "type": "file", "path": "bitbucket-pipelines.yml"},
|
|
81
|
+
{"platform": "cloud-build", "type": "file", "path": "cloudbuild.yaml"},
|
|
82
|
+
{"platform": "cloud-build", "type": "file", "path": "cloudbuild.json"},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Known infrastructure directory names
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
_INFRA_DIR_NAMES: Dict[str, List[str]] = {
|
|
89
|
+
"gitops": ["gitops", "flux", "argocd", "deploy", "k8s"],
|
|
90
|
+
"terraform": ["terraform", "terragrunt", "infra", "infrastructure"],
|
|
91
|
+
"app_services": ["app_services", "app-services", "services", "apps"],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InfrastructureScanner(BaseScanner):
|
|
96
|
+
"""Detects cloud providers, IaC, containers, CI/CD, and infra paths.
|
|
97
|
+
|
|
98
|
+
Pure function contract:
|
|
99
|
+
- No file writes
|
|
100
|
+
- No state modification
|
|
101
|
+
- No network calls
|
|
102
|
+
- Only reads: filesystem paths, file contents, environment variables
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def SCANNER_NAME(self) -> str:
|
|
107
|
+
return "infrastructure"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def SCANNER_VERSION(self) -> str:
|
|
111
|
+
return "1.0.0"
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def OWNED_SECTIONS(self) -> List[str]:
|
|
115
|
+
return ["infrastructure", "application_services"]
|
|
116
|
+
|
|
117
|
+
def scan(self, root: Path) -> ScanResult:
|
|
118
|
+
"""Scan for infrastructure indicators.
|
|
119
|
+
|
|
120
|
+
In multi-repo mode, scans each repo subdirectory and tags IaC
|
|
121
|
+
entries with their containing repo name. In single-repo mode,
|
|
122
|
+
behaves as before.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
root: Absolute path to the project root directory.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
ScanResult with 'infrastructure' section if indicators found,
|
|
129
|
+
or empty sections dict if none detected.
|
|
130
|
+
"""
|
|
131
|
+
start = time.monotonic()
|
|
132
|
+
warnings: List[str] = []
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
cloud_providers = self._detect_cloud_providers(root, warnings)
|
|
136
|
+
iac = self._detect_iac(root, warnings)
|
|
137
|
+
containers = self._detect_containers(root, warnings)
|
|
138
|
+
ci_cd = self._detect_cicd(root, warnings)
|
|
139
|
+
paths = self._detect_paths(root, warnings)
|
|
140
|
+
app_services = self._detect_application_services(root, warnings)
|
|
141
|
+
|
|
142
|
+
# Multi-repo mode: also scan each repo subdirectory and tag results
|
|
143
|
+
if self.workspace_info and self.workspace_info.is_multi_repo:
|
|
144
|
+
self._enrich_multi_repo(
|
|
145
|
+
root, iac, containers, ci_cd, warnings
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Only produce section when at least one indicator is found
|
|
149
|
+
has_indicators = (
|
|
150
|
+
cloud_providers
|
|
151
|
+
or iac
|
|
152
|
+
or containers
|
|
153
|
+
or ci_cd
|
|
154
|
+
or paths.get("gitops") is not None
|
|
155
|
+
or paths.get("terraform") is not None
|
|
156
|
+
or paths.get("app_services") is not None
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if not has_indicators and not app_services:
|
|
160
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
161
|
+
return self.make_result(sections={}, warnings=warnings, duration_ms=duration_ms)
|
|
162
|
+
|
|
163
|
+
sections: Dict[str, Any] = {}
|
|
164
|
+
|
|
165
|
+
if has_indicators:
|
|
166
|
+
sections["infrastructure"] = {
|
|
167
|
+
"cloud_providers": cloud_providers,
|
|
168
|
+
"iac": iac,
|
|
169
|
+
"containers": containers,
|
|
170
|
+
"ci_cd": ci_cd,
|
|
171
|
+
"paths": paths,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if app_services:
|
|
175
|
+
sections["application_services"] = {
|
|
176
|
+
"services": app_services,
|
|
177
|
+
"base_path": self._common_base_path(
|
|
178
|
+
[s["path"] for s in app_services]
|
|
179
|
+
),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
183
|
+
return self.make_result(sections=sections, warnings=warnings, duration_ms=duration_ms)
|
|
184
|
+
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
logger.warning("Infrastructure scanner failed: %s", exc)
|
|
187
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
188
|
+
return self.make_result(sections={}, warnings=[str(exc)], duration_ms=duration_ms)
|
|
189
|
+
|
|
190
|
+
def _enrich_multi_repo(
|
|
191
|
+
self,
|
|
192
|
+
root: Path,
|
|
193
|
+
iac: List[Dict[str, Any]],
|
|
194
|
+
containers: List[Dict[str, Any]],
|
|
195
|
+
ci_cd: List[Dict[str, Any]],
|
|
196
|
+
warnings: List[str],
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Tag IaC, container, and CI/CD entries with their containing repo.
|
|
199
|
+
|
|
200
|
+
For each entry whose base_path or config_path starts with a known
|
|
201
|
+
repo directory, adds a 'repo' field with the repo name. This helps
|
|
202
|
+
agents understand which repo owns each infrastructure component.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
root: Workspace root path.
|
|
206
|
+
iac: IaC entries list (mutated in place).
|
|
207
|
+
containers: Container entries list (mutated in place).
|
|
208
|
+
ci_cd: CI/CD entries list (mutated in place).
|
|
209
|
+
warnings: Warning accumulator.
|
|
210
|
+
"""
|
|
211
|
+
repo_names = {
|
|
212
|
+
str(rd.relative_to(root)): rd.name
|
|
213
|
+
for rd in self.workspace_info.repo_dirs
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def _tag_repo(entry: Dict[str, Any], path_key: str) -> None:
|
|
217
|
+
"""Add 'repo' field if path matches a known repo directory."""
|
|
218
|
+
path_val = entry.get(path_key, "")
|
|
219
|
+
if not path_val:
|
|
220
|
+
return
|
|
221
|
+
for repo_path, repo_name in repo_names.items():
|
|
222
|
+
if path_val == repo_path or path_val.startswith(repo_path + "/"):
|
|
223
|
+
entry["repo"] = repo_name
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
for entry in iac:
|
|
227
|
+
_tag_repo(entry, "base_path")
|
|
228
|
+
|
|
229
|
+
for entry in containers:
|
|
230
|
+
# Containers use 'files' list; tag based on first file's directory
|
|
231
|
+
files = entry.get("files", [])
|
|
232
|
+
if files:
|
|
233
|
+
first_file = files[0]
|
|
234
|
+
for repo_path, repo_name in repo_names.items():
|
|
235
|
+
if first_file.startswith(repo_path + "/") or first_file.startswith(repo_path):
|
|
236
|
+
entry["repo"] = repo_name
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
for entry in ci_cd:
|
|
240
|
+
_tag_repo(entry, "config_path")
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Cloud provider detection
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def _detect_cloud_providers(
|
|
247
|
+
self, root: Path, warnings: List[str]
|
|
248
|
+
) -> List[Dict[str, Any]]:
|
|
249
|
+
"""Detect cloud providers from Terraform, CLI configs, and env vars."""
|
|
250
|
+
providers: Dict[str, Dict[str, Any]] = {}
|
|
251
|
+
|
|
252
|
+
# 1. Terraform provider blocks
|
|
253
|
+
self._detect_providers_from_terraform(root, providers, warnings)
|
|
254
|
+
|
|
255
|
+
# 2. CLI configs
|
|
256
|
+
self._detect_providers_from_cli_configs(providers)
|
|
257
|
+
|
|
258
|
+
# 3. Environment variables (non-secret only)
|
|
259
|
+
self._detect_providers_from_env_vars(providers)
|
|
260
|
+
|
|
261
|
+
return list(providers.values())
|
|
262
|
+
|
|
263
|
+
def _detect_providers_from_terraform(
|
|
264
|
+
self,
|
|
265
|
+
root: Path,
|
|
266
|
+
providers: Dict[str, Dict[str, Any]],
|
|
267
|
+
warnings: List[str],
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Scan .tf files for provider blocks."""
|
|
270
|
+
for tf_file in walk_project(root, [".tf"]):
|
|
271
|
+
try:
|
|
272
|
+
content = tf_file.read_text(encoding="utf-8", errors="replace")
|
|
273
|
+
for cloud_name, pattern in _TF_PROVIDER_PATTERNS.items():
|
|
274
|
+
if re.search(pattern, content):
|
|
275
|
+
if cloud_name not in providers:
|
|
276
|
+
providers[cloud_name] = {
|
|
277
|
+
"name": cloud_name,
|
|
278
|
+
"detected_by": "terraform_provider",
|
|
279
|
+
}
|
|
280
|
+
except OSError as exc:
|
|
281
|
+
warnings.append(f"Could not read {tf_file}: {exc}")
|
|
282
|
+
|
|
283
|
+
def _detect_providers_from_cli_configs(
|
|
284
|
+
self, providers: Dict[str, Dict[str, Any]]
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Check for cloud CLI config files."""
|
|
287
|
+
home = Path.home()
|
|
288
|
+
|
|
289
|
+
# GCP: gcloud CLI config
|
|
290
|
+
gcloud_config = home / ".config" / "gcloud" / "properties"
|
|
291
|
+
if gcloud_config.is_file() and "gcp" not in providers:
|
|
292
|
+
providers["gcp"] = {
|
|
293
|
+
"name": "gcp",
|
|
294
|
+
"detected_by": "cli_config",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# AWS: aws CLI config
|
|
298
|
+
aws_config = home / ".aws" / "config"
|
|
299
|
+
if aws_config.is_file() and "aws" not in providers:
|
|
300
|
+
providers["aws"] = {
|
|
301
|
+
"name": "aws",
|
|
302
|
+
"detected_by": "cli_config",
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# Azure: az CLI config
|
|
306
|
+
azure_config = home / ".azure" / "azureProfile.json"
|
|
307
|
+
if azure_config.is_file() and "azure" not in providers:
|
|
308
|
+
providers["azure"] = {
|
|
309
|
+
"name": "azure",
|
|
310
|
+
"detected_by": "cli_config",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
def _detect_providers_from_env_vars(
|
|
314
|
+
self, providers: Dict[str, Dict[str, Any]]
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Detect cloud providers from non-secret environment variables."""
|
|
317
|
+
for cloud_name, env_vars in _CLOUD_ENV_VARS.items():
|
|
318
|
+
if cloud_name in providers:
|
|
319
|
+
continue
|
|
320
|
+
for var in env_vars:
|
|
321
|
+
if os.environ.get(var):
|
|
322
|
+
providers[cloud_name] = {
|
|
323
|
+
"name": cloud_name,
|
|
324
|
+
"detected_by": "env_var",
|
|
325
|
+
}
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# IaC tool detection
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def _detect_iac(
|
|
333
|
+
self, root: Path, warnings: List[str]
|
|
334
|
+
) -> List[Dict[str, Any]]:
|
|
335
|
+
"""Detect IaC tools from file presence.
|
|
336
|
+
|
|
337
|
+
Groups detected files into distinct IaC roots. For example, if both
|
|
338
|
+
``terraform/`` (shared modules) and ``features/infra/`` contain .tf
|
|
339
|
+
files, they become separate entries. Validates that all reported file
|
|
340
|
+
paths actually exist on disk (Fix 3: ghost references).
|
|
341
|
+
"""
|
|
342
|
+
results: List[Dict[str, Any]] = []
|
|
343
|
+
|
|
344
|
+
for marker in _IAC_MARKERS:
|
|
345
|
+
try:
|
|
346
|
+
rglob_pattern = marker["rglob"]
|
|
347
|
+
if rglob_pattern.startswith("*."):
|
|
348
|
+
ext = rglob_pattern[1:]
|
|
349
|
+
found_files = sorted(walk_project(root, [ext]))
|
|
350
|
+
else:
|
|
351
|
+
found_files = sorted(walk_project_named(root, [rglob_pattern]))
|
|
352
|
+
|
|
353
|
+
if not found_files:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
# Fix 3: filter out files whose paths no longer exist
|
|
357
|
+
found_files = [f for f in found_files if f.exists()]
|
|
358
|
+
|
|
359
|
+
if not found_files:
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
relative_files = [
|
|
363
|
+
str(f.relative_to(root)) for f in found_files
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
# Group files into distinct IaC roots. A "root" is the
|
|
367
|
+
# top-level directory under `root` that contains the file
|
|
368
|
+
# (depth-1 or depth-2 for monorepo subdirs).
|
|
369
|
+
iac_roots = self._group_iac_roots(root, found_files)
|
|
370
|
+
|
|
371
|
+
for iac_root_path, root_files in sorted(iac_roots.items()):
|
|
372
|
+
root_relative_files = [
|
|
373
|
+
str(f.relative_to(root)) for f in root_files[:10]
|
|
374
|
+
]
|
|
375
|
+
results.append(
|
|
376
|
+
{
|
|
377
|
+
"tool": marker["tool"],
|
|
378
|
+
"base_path": iac_root_path,
|
|
379
|
+
"detected_files": root_relative_files,
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
except OSError as exc:
|
|
383
|
+
warnings.append(f"IaC detection error for {marker['tool']}: {exc}")
|
|
384
|
+
|
|
385
|
+
return results
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _group_iac_roots(
|
|
389
|
+
root: Path, files: List[Path]
|
|
390
|
+
) -> Dict[str, List[Path]]:
|
|
391
|
+
"""Group IaC files by their top-level infrastructure root directory.
|
|
392
|
+
|
|
393
|
+
Identifies distinct IaC roots by looking for well-known directory
|
|
394
|
+
names (``terraform/``, ``infra/``) in the file path. Files that
|
|
395
|
+
share a common IaC root are grouped together.
|
|
396
|
+
"""
|
|
397
|
+
# Known IaC root directory names
|
|
398
|
+
iac_dir_names = {"terraform", "terragrunt", "infra", "infrastructure", "iac"}
|
|
399
|
+
|
|
400
|
+
groups: Dict[str, List[Path]] = {}
|
|
401
|
+
|
|
402
|
+
for f in files:
|
|
403
|
+
rel = f.relative_to(root)
|
|
404
|
+
parts = rel.parts
|
|
405
|
+
|
|
406
|
+
# Find the deepest IaC-root-named directory in the path
|
|
407
|
+
iac_root = None
|
|
408
|
+
for i, part in enumerate(parts[:-1]): # exclude filename
|
|
409
|
+
if part.lower() in iac_dir_names:
|
|
410
|
+
# Use path up to and including this directory
|
|
411
|
+
iac_root = str(Path(*parts[: i + 1]))
|
|
412
|
+
break
|
|
413
|
+
|
|
414
|
+
if iac_root is None:
|
|
415
|
+
# No known IaC directory found; use common base path logic
|
|
416
|
+
iac_root = str(rel.parent) if len(parts) > 1 else "."
|
|
417
|
+
|
|
418
|
+
if iac_root not in groups:
|
|
419
|
+
groups[iac_root] = []
|
|
420
|
+
groups[iac_root].append(f)
|
|
421
|
+
|
|
422
|
+
return groups
|
|
423
|
+
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
# Container detection
|
|
426
|
+
# ------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
def _detect_containers(
|
|
429
|
+
self, root: Path, warnings: List[str]
|
|
430
|
+
) -> List[Dict[str, Any]]:
|
|
431
|
+
"""Detect container tooling from file presence.
|
|
432
|
+
|
|
433
|
+
Validates that all reported paths actually exist (Fix 3: ghost refs).
|
|
434
|
+
"""
|
|
435
|
+
results: List[Dict[str, Any]] = []
|
|
436
|
+
|
|
437
|
+
for container_def in _CONTAINER_GLOBS:
|
|
438
|
+
found: List[str] = []
|
|
439
|
+
try:
|
|
440
|
+
# Separate exact names from prefix patterns (e.g., "Dockerfile.*")
|
|
441
|
+
exact_names = []
|
|
442
|
+
prefixes = []
|
|
443
|
+
for pattern in container_def["rglob_patterns"]:
|
|
444
|
+
if "*" in pattern:
|
|
445
|
+
# "Dockerfile.*" -> prefix "Dockerfile."
|
|
446
|
+
prefixes.append(pattern.replace("*", ""))
|
|
447
|
+
else:
|
|
448
|
+
exact_names.append(pattern)
|
|
449
|
+
|
|
450
|
+
for match in walk_project_named(root, exact_names):
|
|
451
|
+
if match.exists():
|
|
452
|
+
found.append(str(match.relative_to(root)))
|
|
453
|
+
|
|
454
|
+
# Handle prefix patterns (e.g., "Dockerfile.*") via walk
|
|
455
|
+
if prefixes:
|
|
456
|
+
from tools.scan.walk import walk_project_prefix
|
|
457
|
+
for match in walk_project_prefix(root, prefixes):
|
|
458
|
+
if match.exists():
|
|
459
|
+
found.append(str(match.relative_to(root)))
|
|
460
|
+
except OSError as exc:
|
|
461
|
+
warnings.append(
|
|
462
|
+
f"Container detection error for {container_def['tool']}: {exc}"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
if found:
|
|
466
|
+
results.append(
|
|
467
|
+
{
|
|
468
|
+
"tool": container_def["tool"],
|
|
469
|
+
"files": sorted(set(found)),
|
|
470
|
+
}
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
return results
|
|
474
|
+
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
# CI/CD detection
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def _detect_cicd(
|
|
480
|
+
self, root: Path, warnings: List[str]
|
|
481
|
+
) -> List[Dict[str, Any]]:
|
|
482
|
+
"""Detect CI/CD platforms from config files/directories.
|
|
483
|
+
|
|
484
|
+
Checks root-level marker files first, then scans subdirectories
|
|
485
|
+
for CI/CD configuration files and manifests (e.g., gitlab-runner
|
|
486
|
+
kustomize files).
|
|
487
|
+
"""
|
|
488
|
+
results: List[Dict[str, Any]] = []
|
|
489
|
+
detected_platforms: set = set()
|
|
490
|
+
|
|
491
|
+
# Check root-level markers
|
|
492
|
+
for marker in _CICD_MARKERS:
|
|
493
|
+
target = root / marker["path"]
|
|
494
|
+
detected = False
|
|
495
|
+
|
|
496
|
+
if marker["type"] == "dir":
|
|
497
|
+
detected = target.is_dir()
|
|
498
|
+
elif marker["type"] == "file":
|
|
499
|
+
detected = target.is_file()
|
|
500
|
+
|
|
501
|
+
if detected:
|
|
502
|
+
entry: Dict[str, Any] = {
|
|
503
|
+
"platform": marker["platform"],
|
|
504
|
+
"config_path": marker["path"],
|
|
505
|
+
}
|
|
506
|
+
# Enrich GitLab CI entries with related files and stages
|
|
507
|
+
if marker["platform"] == "gitlab-ci":
|
|
508
|
+
self._enrich_gitlab_ci(root, entry, warnings)
|
|
509
|
+
results.append(entry)
|
|
510
|
+
detected_platforms.add(marker["platform"])
|
|
511
|
+
|
|
512
|
+
# Check subdirectories for CI/CD config files (handles monorepo
|
|
513
|
+
# and nested project structures)
|
|
514
|
+
if not results:
|
|
515
|
+
self._detect_cicd_in_subdirs(root, results, detected_platforms, warnings)
|
|
516
|
+
|
|
517
|
+
# Detect CI/CD from manifest content (e.g., gitlab-runner in
|
|
518
|
+
# kustomize manifests)
|
|
519
|
+
if "gitlab-ci" not in detected_platforms:
|
|
520
|
+
self._detect_cicd_from_manifests(root, results, detected_platforms, warnings)
|
|
521
|
+
|
|
522
|
+
return results
|
|
523
|
+
|
|
524
|
+
def _detect_cicd_in_subdirs(
|
|
525
|
+
self,
|
|
526
|
+
root: Path,
|
|
527
|
+
results: List[Dict[str, Any]],
|
|
528
|
+
detected_platforms: set,
|
|
529
|
+
warnings: List[str],
|
|
530
|
+
) -> None:
|
|
531
|
+
"""Check immediate subdirectories for CI/CD config files."""
|
|
532
|
+
try:
|
|
533
|
+
for entry in sorted(root.iterdir()):
|
|
534
|
+
if not entry.is_dir() or entry.name.startswith("."):
|
|
535
|
+
continue
|
|
536
|
+
if entry.name in ("node_modules", "vendor", "__pycache__"):
|
|
537
|
+
continue
|
|
538
|
+
for marker in _CICD_MARKERS:
|
|
539
|
+
if marker["platform"] in detected_platforms:
|
|
540
|
+
continue
|
|
541
|
+
target = entry / marker["path"]
|
|
542
|
+
detected = False
|
|
543
|
+
if marker["type"] == "dir":
|
|
544
|
+
detected = target.is_dir()
|
|
545
|
+
elif marker["type"] == "file":
|
|
546
|
+
detected = target.is_file()
|
|
547
|
+
if detected:
|
|
548
|
+
rel_path = str(target.relative_to(root))
|
|
549
|
+
cicd_entry: Dict[str, Any] = {
|
|
550
|
+
"platform": marker["platform"],
|
|
551
|
+
"config_path": rel_path,
|
|
552
|
+
}
|
|
553
|
+
# Enrich GitLab CI entries found in subdirectories
|
|
554
|
+
if marker["platform"] == "gitlab-ci":
|
|
555
|
+
self._enrich_gitlab_ci(entry, cicd_entry, warnings)
|
|
556
|
+
results.append(cicd_entry)
|
|
557
|
+
detected_platforms.add(marker["platform"])
|
|
558
|
+
except OSError as exc:
|
|
559
|
+
warnings.append(f"CI/CD subdirectory scan error: {exc}")
|
|
560
|
+
|
|
561
|
+
def _detect_cicd_from_manifests(
|
|
562
|
+
self,
|
|
563
|
+
root: Path,
|
|
564
|
+
results: List[Dict[str, Any]],
|
|
565
|
+
detected_platforms: set,
|
|
566
|
+
warnings: List[str],
|
|
567
|
+
) -> None:
|
|
568
|
+
"""Detect CI/CD platforms from Kubernetes manifest content.
|
|
569
|
+
|
|
570
|
+
Looks for CI/CD-related resources like gitlab-runner deployments
|
|
571
|
+
in kustomize/Kubernetes manifests.
|
|
572
|
+
"""
|
|
573
|
+
# CI/CD-related directory or file name patterns
|
|
574
|
+
cicd_manifest_indicators = {
|
|
575
|
+
"gitlab-runner": "gitlab-ci",
|
|
576
|
+
"github-actions-runner": "github-actions",
|
|
577
|
+
"jenkins": "jenkins",
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
for dirpath, dirnames, filenames in os.walk(str(root)):
|
|
582
|
+
dirnames[:] = [
|
|
583
|
+
d for d in dirnames
|
|
584
|
+
if d not in ("node_modules", ".git", "__pycache__",
|
|
585
|
+
".terraform", "vendor", "dist", "build",
|
|
586
|
+
".venv", "venv")
|
|
587
|
+
and not d.startswith(".")
|
|
588
|
+
]
|
|
589
|
+
dir_name = os.path.basename(dirpath).lower()
|
|
590
|
+
for indicator, platform in cicd_manifest_indicators.items():
|
|
591
|
+
if platform in detected_platforms:
|
|
592
|
+
continue
|
|
593
|
+
if indicator in dir_name:
|
|
594
|
+
rel_path = str(Path(dirpath).relative_to(root))
|
|
595
|
+
results.append(
|
|
596
|
+
{
|
|
597
|
+
"platform": platform,
|
|
598
|
+
"config_path": rel_path,
|
|
599
|
+
}
|
|
600
|
+
)
|
|
601
|
+
detected_platforms.add(platform)
|
|
602
|
+
except OSError as exc:
|
|
603
|
+
warnings.append(f"CI/CD manifest scan error: {exc}")
|
|
604
|
+
|
|
605
|
+
# ------------------------------------------------------------------
|
|
606
|
+
# GitLab CI enrichment
|
|
607
|
+
# ------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
def _enrich_gitlab_ci(
|
|
610
|
+
self,
|
|
611
|
+
root: Path,
|
|
612
|
+
entry: Dict[str, Any],
|
|
613
|
+
warnings: List[str],
|
|
614
|
+
) -> None:
|
|
615
|
+
"""Enrich a GitLab CI entry with related files and stage names.
|
|
616
|
+
|
|
617
|
+
Looks for:
|
|
618
|
+
- .gitlab/ci/ directory (reusable CI components/templates)
|
|
619
|
+
- Additional CI yml files (e.g., .gitlab-ci-builder.yml)
|
|
620
|
+
- .ci-local/ directory (local CI testing)
|
|
621
|
+
- Stage names extracted from the main .gitlab-ci.yml
|
|
622
|
+
"""
|
|
623
|
+
related_files: List[str] = []
|
|
624
|
+
|
|
625
|
+
try:
|
|
626
|
+
# Check for .gitlab/ci/ directory
|
|
627
|
+
gitlab_ci_dir = root / ".gitlab" / "ci"
|
|
628
|
+
if gitlab_ci_dir.is_dir():
|
|
629
|
+
related_files.append(".gitlab/ci/")
|
|
630
|
+
|
|
631
|
+
# Check for additional CI yml files at root
|
|
632
|
+
for candidate in sorted(root.iterdir()):
|
|
633
|
+
if not candidate.is_file():
|
|
634
|
+
continue
|
|
635
|
+
name = candidate.name
|
|
636
|
+
if (
|
|
637
|
+
name != ".gitlab-ci.yml"
|
|
638
|
+
and name.startswith(".gitlab-ci")
|
|
639
|
+
and name.endswith((".yml", ".yaml"))
|
|
640
|
+
):
|
|
641
|
+
related_files.append(name)
|
|
642
|
+
|
|
643
|
+
# Check for .ci-local/ directory
|
|
644
|
+
ci_local_dir = root / ".ci-local"
|
|
645
|
+
if ci_local_dir.is_dir():
|
|
646
|
+
related_files.append(".ci-local/")
|
|
647
|
+
|
|
648
|
+
except OSError as exc:
|
|
649
|
+
warnings.append(f"GitLab CI enrichment error (related files): {exc}")
|
|
650
|
+
|
|
651
|
+
if related_files:
|
|
652
|
+
entry["related_files"] = related_files
|
|
653
|
+
|
|
654
|
+
# Extract stage names from the main .gitlab-ci.yml
|
|
655
|
+
try:
|
|
656
|
+
ci_file = root / ".gitlab-ci.yml"
|
|
657
|
+
if ci_file.is_file():
|
|
658
|
+
content = ci_file.read_text(encoding="utf-8", errors="replace")
|
|
659
|
+
stages = self._extract_yaml_stages(content)
|
|
660
|
+
if stages:
|
|
661
|
+
entry["stages"] = stages
|
|
662
|
+
except OSError as exc:
|
|
663
|
+
warnings.append(f"GitLab CI enrichment error (stages): {exc}")
|
|
664
|
+
|
|
665
|
+
@staticmethod
|
|
666
|
+
def _extract_yaml_stages(content: str) -> List[str]:
|
|
667
|
+
"""Extract stage names from a YAML string using simple line parsing.
|
|
668
|
+
|
|
669
|
+
Looks for a top-level ``stages:`` key followed by ``- name`` lines.
|
|
670
|
+
Handles the common format without requiring a YAML library.
|
|
671
|
+
"""
|
|
672
|
+
stages: List[str] = []
|
|
673
|
+
in_stages = False
|
|
674
|
+
|
|
675
|
+
for line in content.splitlines():
|
|
676
|
+
stripped = line.strip()
|
|
677
|
+
|
|
678
|
+
# Detect the start of the stages block (must be top-level, no leading spaces)
|
|
679
|
+
if line.startswith("stages:") and not line[0].isspace():
|
|
680
|
+
in_stages = True
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
if in_stages:
|
|
684
|
+
# A list item under stages
|
|
685
|
+
if stripped.startswith("- "):
|
|
686
|
+
stage_name = stripped[2:].strip().strip("'\"")
|
|
687
|
+
if stage_name:
|
|
688
|
+
stages.append(stage_name)
|
|
689
|
+
elif stripped == "" or stripped.startswith("#"):
|
|
690
|
+
# Blank lines and comments are OK inside the block
|
|
691
|
+
continue
|
|
692
|
+
else:
|
|
693
|
+
# Any other non-list content ends the stages block
|
|
694
|
+
break
|
|
695
|
+
|
|
696
|
+
return stages
|
|
697
|
+
|
|
698
|
+
# ------------------------------------------------------------------
|
|
699
|
+
# Infrastructure path detection
|
|
700
|
+
# ------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
def _detect_paths(
|
|
703
|
+
self, root: Path, warnings: List[str]
|
|
704
|
+
) -> Dict[str, Optional[str]]:
|
|
705
|
+
"""Detect infrastructure-related directories.
|
|
706
|
+
|
|
707
|
+
Searches depth=1 and depth=2 to handle monorepo structures where
|
|
708
|
+
infra directories live inside a workspace subdirectory (e.g.,
|
|
709
|
+
``qxo-monorepo/terraform/``).
|
|
710
|
+
"""
|
|
711
|
+
detected: Dict[str, Optional[str]] = {
|
|
712
|
+
"gitops": None,
|
|
713
|
+
"terraform": None,
|
|
714
|
+
"app_services": None,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
skip = {"node_modules", ".git", "__pycache__", ".terraform", "vendor",
|
|
718
|
+
"dist", "build", ".venv", "venv"}
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
# Depth=1: immediate subdirectories of root
|
|
722
|
+
subdirs = [
|
|
723
|
+
d for d in root.iterdir()
|
|
724
|
+
if d.is_dir() and not d.name.startswith(".") and d.name not in skip
|
|
725
|
+
]
|
|
726
|
+
except OSError as exc:
|
|
727
|
+
warnings.append(f"Path detection error: {exc}")
|
|
728
|
+
return detected
|
|
729
|
+
|
|
730
|
+
for subdir in subdirs:
|
|
731
|
+
dir_name = subdir.name.lower()
|
|
732
|
+
for path_key, candidates in _INFRA_DIR_NAMES.items():
|
|
733
|
+
if detected[path_key] is None and dir_name in candidates:
|
|
734
|
+
detected[path_key] = str(subdir.relative_to(root))
|
|
735
|
+
|
|
736
|
+
# Depth=2: check inside each depth-1 subdirectory for infra dirs
|
|
737
|
+
# This handles monorepo layouts like qxo-monorepo/terraform/
|
|
738
|
+
for subdir in subdirs:
|
|
739
|
+
try:
|
|
740
|
+
for child in subdir.iterdir():
|
|
741
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
742
|
+
continue
|
|
743
|
+
if child.name in skip:
|
|
744
|
+
continue
|
|
745
|
+
child_name = child.name.lower()
|
|
746
|
+
for path_key, candidates in _INFRA_DIR_NAMES.items():
|
|
747
|
+
if detected[path_key] is None and child_name in candidates:
|
|
748
|
+
detected[path_key] = str(child.relative_to(root))
|
|
749
|
+
except OSError:
|
|
750
|
+
continue
|
|
751
|
+
|
|
752
|
+
return detected
|
|
753
|
+
|
|
754
|
+
# ------------------------------------------------------------------
|
|
755
|
+
# Application service detection
|
|
756
|
+
# ------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
def _detect_application_services(
|
|
759
|
+
self, root: Path, warnings: List[str]
|
|
760
|
+
) -> List[Dict[str, Any]]:
|
|
761
|
+
"""Detect microservices from directory conventions.
|
|
762
|
+
|
|
763
|
+
Scans for directories containing ``service-runtime/`` subdirs or
|
|
764
|
+
``Dockerfile`` files, following common monorepo patterns like
|
|
765
|
+
``features/*-feature/*-service/service-runtime/``.
|
|
766
|
+
|
|
767
|
+
Returns a list of service descriptors (scanner-owned fields only).
|
|
768
|
+
"""
|
|
769
|
+
services: List[Dict[str, Any]] = []
|
|
770
|
+
seen_names: Set[str] = set()
|
|
771
|
+
|
|
772
|
+
skip = {"node_modules", ".git", "__pycache__", ".terraform",
|
|
773
|
+
".terragrunt-cache", "vendor", "dist", "build",
|
|
774
|
+
".venv", "venv", "charts", "infra"}
|
|
775
|
+
|
|
776
|
+
# Search up to depth=4 for service-runtime dirs and Dockerfiles
|
|
777
|
+
# that indicate a service boundary
|
|
778
|
+
self._find_services_recursive(
|
|
779
|
+
root, root, services, seen_names, skip, warnings, depth=0, max_depth=4
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
return services
|
|
783
|
+
|
|
784
|
+
def _find_services_recursive(
|
|
785
|
+
self,
|
|
786
|
+
root: Path,
|
|
787
|
+
current: Path,
|
|
788
|
+
services: List[Dict[str, Any]],
|
|
789
|
+
seen_names: Set[str],
|
|
790
|
+
skip: Set[str],
|
|
791
|
+
warnings: List[str],
|
|
792
|
+
depth: int,
|
|
793
|
+
max_depth: int,
|
|
794
|
+
) -> None:
|
|
795
|
+
"""Recursively find service directories."""
|
|
796
|
+
if depth >= max_depth:
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
try:
|
|
800
|
+
entries = sorted(current.iterdir())
|
|
801
|
+
except OSError:
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
for entry in entries:
|
|
805
|
+
if not entry.is_dir() or entry.name.startswith("."):
|
|
806
|
+
continue
|
|
807
|
+
if entry.name in skip:
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
# Check if this directory looks like a service
|
|
811
|
+
has_service_runtime = (entry / "service-runtime").is_dir()
|
|
812
|
+
has_dockerfile = (entry / "Dockerfile").is_file()
|
|
813
|
+
has_docker_compose = (
|
|
814
|
+
(entry / "docker-compose.yml").is_file()
|
|
815
|
+
or (entry / "docker-compose.yaml").is_file()
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
if has_service_runtime or has_dockerfile:
|
|
819
|
+
service_name = entry.name
|
|
820
|
+
if service_name not in seen_names:
|
|
821
|
+
seen_names.add(service_name)
|
|
822
|
+
services.append({
|
|
823
|
+
"name": service_name,
|
|
824
|
+
"path": str(entry.relative_to(root)),
|
|
825
|
+
"has_dockerfile": has_dockerfile,
|
|
826
|
+
"has_docker_compose": has_docker_compose,
|
|
827
|
+
"has_service_runtime": has_service_runtime,
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
# Continue recursing into subdirectories
|
|
831
|
+
self._find_services_recursive(
|
|
832
|
+
root, entry, services, seen_names, skip, warnings,
|
|
833
|
+
depth + 1, max_depth,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
# ------------------------------------------------------------------
|
|
837
|
+
# Helpers
|
|
838
|
+
# ------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
@staticmethod
|
|
841
|
+
def _common_base_path(paths: List[str]) -> str:
|
|
842
|
+
"""Find the common base directory from a list of relative paths.
|
|
843
|
+
|
|
844
|
+
Returns '.' if paths are in the root directory.
|
|
845
|
+
"""
|
|
846
|
+
if not paths:
|
|
847
|
+
return "."
|
|
848
|
+
|
|
849
|
+
parts_list = [Path(p).parent.parts for p in paths]
|
|
850
|
+
if not parts_list:
|
|
851
|
+
return "."
|
|
852
|
+
|
|
853
|
+
common: List[str] = []
|
|
854
|
+
for segments in zip(*parts_list):
|
|
855
|
+
if len(set(segments)) == 1:
|
|
856
|
+
common.append(segments[0])
|
|
857
|
+
else:
|
|
858
|
+
break
|
|
859
|
+
|
|
860
|
+
return str(Path(*common)) if common else "."
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# Module-level convenience for verify commands
|
|
864
|
+
def scan(root: Path) -> Dict[str, Any]:
|
|
865
|
+
"""Module-level convenience function for infrastructure scanning.
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
root: Absolute path to the project root directory.
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
Dict mapping section names to section data.
|
|
872
|
+
"""
|
|
873
|
+
scanner = InfrastructureScanner()
|
|
874
|
+
result = scanner.scan(root)
|
|
875
|
+
return result.sections
|