@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,708 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bash command validator.
|
|
3
|
+
|
|
4
|
+
Primary security gate for all Bash tool invocations. With Bash(*) in the
|
|
5
|
+
settings.json allow list, ALL commands reach this hook -- it is the sole
|
|
6
|
+
enforcement layer for dangerous command detection.
|
|
7
|
+
|
|
8
|
+
Pipeline (ordered by priority):
|
|
9
|
+
0. Indirect execution detection -- bash -c, eval, python -c etc. (T2 approval)
|
|
10
|
+
1. blocked_commands FIRST -- permanently denied patterns (exit 2)
|
|
11
|
+
2. Claude footer stripping -- transparent cleanup via updatedInput
|
|
12
|
+
3. Commit message validation -- conventional commits format
|
|
13
|
+
4. Cloud pipe/redirect/chain check -- corrective deny (exit 0)
|
|
14
|
+
5. Mutative verb detection -- MUTATIVE -> nonce-based deny (exit 0)
|
|
15
|
+
6. Everything else -> SAFE (auto-approved by elimination)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Dict, Any, Optional, List
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from ..security.tiers import SecurityTier
|
|
25
|
+
from ..security.blocked_commands import is_blocked_command
|
|
26
|
+
from ..security.gitops_validator import validate_gitops_workflow
|
|
27
|
+
from ..security.mutative_verbs import (
|
|
28
|
+
detect_mutative_command,
|
|
29
|
+
build_t3_block_response,
|
|
30
|
+
)
|
|
31
|
+
from ..security.approval_grants import (
|
|
32
|
+
check_approval_grant,
|
|
33
|
+
confirm_grant,
|
|
34
|
+
find_pending_for_command,
|
|
35
|
+
generate_nonce,
|
|
36
|
+
last_check_found_expired,
|
|
37
|
+
write_pending_approval,
|
|
38
|
+
)
|
|
39
|
+
from ..security.approval_messages import (
|
|
40
|
+
build_pending_approval_unavailable_message,
|
|
41
|
+
build_t3_approval_instructions,
|
|
42
|
+
)
|
|
43
|
+
from .shell_parser import get_shell_parser
|
|
44
|
+
from .cloud_pipe_validator import validate_cloud_pipe
|
|
45
|
+
from .hook_response import build_hook_permission_response
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class BashValidationResult:
|
|
52
|
+
"""Result of Bash command validation."""
|
|
53
|
+
allowed: bool
|
|
54
|
+
tier: SecurityTier
|
|
55
|
+
reason: str
|
|
56
|
+
suggestions: List[str] = None
|
|
57
|
+
modified_input: Optional[Dict[str, Any]] = None
|
|
58
|
+
# When set, the caller should return this dict (exit 0) instead of a
|
|
59
|
+
# plain error string (exit 2). Used for structured block responses that
|
|
60
|
+
# should correct the agent rather than terminate execution.
|
|
61
|
+
block_response: Optional[Dict[str, Any]] = None
|
|
62
|
+
|
|
63
|
+
def __post_init__(self):
|
|
64
|
+
if self.suggestions is None:
|
|
65
|
+
self.suggestions = []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Patterns for AI tool attribution footers (auto-stripped from commits).
|
|
69
|
+
# Covers Claude Code, GitHub Copilot, Aider, Windsurf, and any future
|
|
70
|
+
# tool using the Co-authored-by git trailer convention.
|
|
71
|
+
FORBIDDEN_FOOTER_PATTERNS = [
|
|
72
|
+
r"Generated with\s+Claude Code",
|
|
73
|
+
r"Generated with\s+\[?Claude Code\]?",
|
|
74
|
+
r"Co-Authored-By:\s+Claude\b",
|
|
75
|
+
r"Co-authored-by:\s+GitHub Copilot\b",
|
|
76
|
+
r"Co-authored-by:\s+aider\b",
|
|
77
|
+
r"Co-authored-by:\s+Windsurf\b",
|
|
78
|
+
r"Co-authored-by:\s+Cursor\b",
|
|
79
|
+
r"Co-authored-by:\s+Codex\b",
|
|
80
|
+
r"Co-authored-by:\s+Gemini\b",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Indirect execution wrappers — commands that execute arbitrary strings.
|
|
85
|
+
# These bypass regex-based command blocking because the real command is
|
|
86
|
+
# hidden inside a string argument. Classified as T2 (requires approval)
|
|
87
|
+
# so the user sees what will actually run.
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
INDIRECT_EXEC_PATTERNS = [
|
|
90
|
+
re.compile(r"^bash\s+-c\s+", re.IGNORECASE),
|
|
91
|
+
re.compile(r"^sh\s+-c\s+", re.IGNORECASE),
|
|
92
|
+
re.compile(r"^zsh\s+-c\s+", re.IGNORECASE),
|
|
93
|
+
re.compile(r"^dash\s+-c\s+", re.IGNORECASE),
|
|
94
|
+
re.compile(r"^\s*eval\s+", re.IGNORECASE),
|
|
95
|
+
re.compile(r"^python3?\s+-c\s+", re.IGNORECASE),
|
|
96
|
+
re.compile(r"^node\s+-e\s+", re.IGNORECASE),
|
|
97
|
+
re.compile(r"^perl\s+-e\s+", re.IGNORECASE),
|
|
98
|
+
re.compile(r"^ruby\s+-e\s+", re.IGNORECASE),
|
|
99
|
+
# Process substitution and heredoc piped to shell
|
|
100
|
+
re.compile(r"^bash\s+<\(", re.IGNORECASE),
|
|
101
|
+
re.compile(r"^sh\s+<\(", re.IGNORECASE),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
class BashValidator:
|
|
105
|
+
"""Validator for Bash tool invocations."""
|
|
106
|
+
|
|
107
|
+
def __init__(self):
|
|
108
|
+
"""Initialize validator."""
|
|
109
|
+
self.shell_parser = get_shell_parser()
|
|
110
|
+
|
|
111
|
+
def _detect_indirect_execution(self, command: str) -> Optional[BashValidationResult]:
|
|
112
|
+
"""Detect indirect execution wrappers that can bypass regex blocking.
|
|
113
|
+
|
|
114
|
+
Commands like 'bash -c "az group delete"' hide the real command inside
|
|
115
|
+
a string. We classify these as T2 (mutative) so they require user
|
|
116
|
+
approval via the nonce workflow, giving the human a chance to inspect
|
|
117
|
+
what will actually run.
|
|
118
|
+
|
|
119
|
+
Returns BashValidationResult if indirect execution detected, else None.
|
|
120
|
+
"""
|
|
121
|
+
for pattern in INDIRECT_EXEC_PATTERNS:
|
|
122
|
+
if pattern.search(command):
|
|
123
|
+
# Also check if the inner payload contains a blocked command.
|
|
124
|
+
# Extract the string argument after the wrapper.
|
|
125
|
+
inner = self._extract_inner_command(command)
|
|
126
|
+
if inner:
|
|
127
|
+
blocked = is_blocked_command(inner)
|
|
128
|
+
if blocked.is_blocked:
|
|
129
|
+
return BashValidationResult(
|
|
130
|
+
allowed=False,
|
|
131
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
132
|
+
reason=(
|
|
133
|
+
f"Indirect execution of blocked command detected: "
|
|
134
|
+
f"{blocked.category} (via wrapper)"
|
|
135
|
+
),
|
|
136
|
+
suggestions=[
|
|
137
|
+
blocked.suggestion or "Run the command directly instead of via a shell wrapper.",
|
|
138
|
+
],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Not blocked but still indirect — route through approval
|
|
142
|
+
logger.info("Indirect execution detected: %s", command[:80])
|
|
143
|
+
result = detect_mutative_command(command)
|
|
144
|
+
if result.is_mutative:
|
|
145
|
+
return None # Already mutative, will be caught by mutative_verbs
|
|
146
|
+
|
|
147
|
+
# For interpreters with inline code analysis (python3 -c),
|
|
148
|
+
# mutative_verbs.py has dedicated pattern scanning that
|
|
149
|
+
# distinguishes safe code (json.dumps, sys.version) from
|
|
150
|
+
# dangerous code (os.system, subprocess.run). If it classified
|
|
151
|
+
# the inline code as safe, trust that analysis and allow it
|
|
152
|
+
# through without forcing an "ask" dialog.
|
|
153
|
+
from ..security.mutative_verbs import _INLINE_CODE_CLIS
|
|
154
|
+
base_cmd = command.strip().split()[0].rsplit("/", 1)[-1].lower()
|
|
155
|
+
if base_cmd in _INLINE_CODE_CLIS:
|
|
156
|
+
logger.info(
|
|
157
|
+
"Inline code classified as safe by pattern scanner: %s",
|
|
158
|
+
command[:80],
|
|
159
|
+
)
|
|
160
|
+
return None # Safe inline code, proceed to normal validation
|
|
161
|
+
|
|
162
|
+
# Shell wrappers (bash -c, eval, etc.) hide the real command
|
|
163
|
+
# in a string — no dedicated scanner exists. Force "ask" so
|
|
164
|
+
# the user can inspect what will actually run.
|
|
165
|
+
hook_block = build_hook_permission_response(
|
|
166
|
+
"ask",
|
|
167
|
+
(
|
|
168
|
+
"Indirect execution detected. The command uses a shell "
|
|
169
|
+
"wrapper (bash -c, eval, etc.) that can bypass "
|
|
170
|
+
"security checks. Please confirm you want to run this."
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
return BashValidationResult(
|
|
174
|
+
allowed=False,
|
|
175
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
176
|
+
reason="Indirect execution wrapper detected — requires confirmation",
|
|
177
|
+
block_response=hook_block,
|
|
178
|
+
)
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def _extract_inner_command(self, command: str) -> Optional[str]:
|
|
182
|
+
"""Extract the inner command from an indirect execution wrapper.
|
|
183
|
+
|
|
184
|
+
E.g., 'bash -c "az group delete --name foo"' → 'az group delete --name foo'
|
|
185
|
+
"""
|
|
186
|
+
# Match: shell -c "..." or shell -c '...'
|
|
187
|
+
match = re.search(r"""-[ce]\s+(['"])(.*?)\1""", command, re.DOTALL)
|
|
188
|
+
if match:
|
|
189
|
+
return match.group(2).strip()
|
|
190
|
+
# Match: shell -c ... (unquoted, take rest of line)
|
|
191
|
+
match = re.search(r"-[ce]\s+(\S+.*)", command)
|
|
192
|
+
if match:
|
|
193
|
+
return match.group(1).strip()
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _has_operators(self, command: str) -> bool:
|
|
197
|
+
"""Quick check if command has operators (before parsing)."""
|
|
198
|
+
# Fast check for common operators outside quotes
|
|
199
|
+
# This avoids expensive parsing for 70% of commands
|
|
200
|
+
if not any(op in command for op in ['|', '&&', '||', ';', '\n']):
|
|
201
|
+
return False
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
def validate(
|
|
205
|
+
self,
|
|
206
|
+
command: str,
|
|
207
|
+
is_subagent: bool = False,
|
|
208
|
+
session_id: str = "",
|
|
209
|
+
) -> BashValidationResult:
|
|
210
|
+
"""
|
|
211
|
+
Validate a Bash command.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
command: Command string to validate
|
|
215
|
+
is_subagent: True when running in subagent context
|
|
216
|
+
session_id: Session ID for approval scoping
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
BashValidationResult with validation details
|
|
220
|
+
"""
|
|
221
|
+
if not command or not command.strip():
|
|
222
|
+
return BashValidationResult(
|
|
223
|
+
allowed=False,
|
|
224
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
225
|
+
reason="Empty command not allowed",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
command = command.strip()
|
|
229
|
+
|
|
230
|
+
# ================================================================
|
|
231
|
+
# EARLY NORMALIZATION: Strip AI attribution footers before any
|
|
232
|
+
# other processing. This ensures the same normalized command
|
|
233
|
+
# string is used for blocked-command checks, compound parsing,
|
|
234
|
+
# mutative verb detection, pending approval writes, AND pending
|
|
235
|
+
# approval lookups. Without this, write_pending_approval() and
|
|
236
|
+
# find_pending_for_command() could see different strings on the
|
|
237
|
+
# first attempt vs. retry, causing nonce mismatch loops.
|
|
238
|
+
# ================================================================
|
|
239
|
+
command_was_modified = False
|
|
240
|
+
if self._detect_claude_footers(command):
|
|
241
|
+
command = self._strip_claude_footers(command)
|
|
242
|
+
command_was_modified = True
|
|
243
|
+
logger.info("Auto-stripped Claude Code footer from commit command")
|
|
244
|
+
|
|
245
|
+
# ================================================================
|
|
246
|
+
# PRIORITY 0: Indirect execution detection.
|
|
247
|
+
# Commands like "bash -c '...'" or "eval '...'" can hide blocked
|
|
248
|
+
# commands inside string arguments, bypassing regex patterns.
|
|
249
|
+
# Detected wrappers are routed to approval or blocked if the inner
|
|
250
|
+
# payload matches a blocked command.
|
|
251
|
+
# ================================================================
|
|
252
|
+
indirect_result = self._detect_indirect_execution(command)
|
|
253
|
+
if indirect_result is not None:
|
|
254
|
+
return indirect_result
|
|
255
|
+
|
|
256
|
+
# ================================================================
|
|
257
|
+
# PRIORITY 1: Blocked commands check on FULL command (exit 2).
|
|
258
|
+
# This MUST run before any other validator to ensure permanently
|
|
259
|
+
# blocked commands (kubectl delete namespace, etc.) are caught
|
|
260
|
+
# with a reliable exit 2 — even if the command also triggers
|
|
261
|
+
# cloud_pipe_validator or has compound operators.
|
|
262
|
+
# ================================================================
|
|
263
|
+
blocked_result = is_blocked_command(command)
|
|
264
|
+
if blocked_result.is_blocked:
|
|
265
|
+
return BashValidationResult(
|
|
266
|
+
allowed=False,
|
|
267
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
268
|
+
reason=f"Command blocked by security policy: {blocked_result.category}",
|
|
269
|
+
suggestions=[blocked_result.suggestion] if blocked_result.suggestion else [],
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Parse compound commands once (reused for blocked-command check and validation dispatch).
|
|
273
|
+
# Runs AFTER footer stripping so components also use the normalized command.
|
|
274
|
+
has_operators = self._has_operators(command)
|
|
275
|
+
parsed_components = None
|
|
276
|
+
if has_operators:
|
|
277
|
+
parsed_components = self.shell_parser.parse(command)
|
|
278
|
+
# Check each component of compound commands against the deny list.
|
|
279
|
+
# This catches "ls && kubectl delete namespace prod" early.
|
|
280
|
+
for component in parsed_components:
|
|
281
|
+
comp_blocked = is_blocked_command(component.strip())
|
|
282
|
+
if comp_blocked.is_blocked:
|
|
283
|
+
return BashValidationResult(
|
|
284
|
+
allowed=False,
|
|
285
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
286
|
+
reason=f"Command blocked by security policy: {comp_blocked.category}",
|
|
287
|
+
suggestions=[comp_blocked.suggestion] if comp_blocked.suggestion else [],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Validate git commit messages (on the potentially cleaned command)
|
|
291
|
+
if "git commit" in command and "-m" in command:
|
|
292
|
+
commit_validation = self._validate_commit_message(command)
|
|
293
|
+
if not commit_validation.allowed:
|
|
294
|
+
return commit_validation
|
|
295
|
+
|
|
296
|
+
# Cloud pipe/redirect/chaining check -- runs AFTER blocked commands.
|
|
297
|
+
# Returns a structured block response dict if a violation is found.
|
|
298
|
+
# block_response is set so the caller emits JSON and exits 0 (corrective),
|
|
299
|
+
# not a plain string with exit 2 (which would terminate the agent).
|
|
300
|
+
pipe_block = validate_cloud_pipe(command)
|
|
301
|
+
if pipe_block is not None:
|
|
302
|
+
return BashValidationResult(
|
|
303
|
+
allowed=False,
|
|
304
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
305
|
+
reason=pipe_block["hookSpecificOutput"]["permissionDecisionReason"],
|
|
306
|
+
suggestions=[],
|
|
307
|
+
modified_input=None,
|
|
308
|
+
block_response=pipe_block,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Dispatch to single or compound validation using already-parsed components
|
|
312
|
+
if not has_operators:
|
|
313
|
+
result = self._validate_single_command(
|
|
314
|
+
command, is_subagent=is_subagent, session_id=session_id,
|
|
315
|
+
)
|
|
316
|
+
elif parsed_components is not None and len(parsed_components) > 1:
|
|
317
|
+
result = self._validate_compound_command(
|
|
318
|
+
parsed_components, is_subagent=is_subagent, session_id=session_id,
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
result = self._validate_single_command(
|
|
322
|
+
command, is_subagent=is_subagent, session_id=session_id,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Attach cleaned command for hook to emit via updatedInput.
|
|
326
|
+
# Set regardless of result.allowed so the ask path can include it too.
|
|
327
|
+
if command_was_modified:
|
|
328
|
+
result.modified_input = {"command": command}
|
|
329
|
+
# If the result is an "ask" block_response, inject updatedInput
|
|
330
|
+
# so the modification survives the native permission dialog.
|
|
331
|
+
if (
|
|
332
|
+
result.block_response is not None
|
|
333
|
+
and result.block_response.get("hookSpecificOutput", {}).get(
|
|
334
|
+
"permissionDecision"
|
|
335
|
+
) == "ask"
|
|
336
|
+
):
|
|
337
|
+
result.block_response["hookSpecificOutput"]["updatedInput"] = {
|
|
338
|
+
"command": command
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
def _validate_single_command(
|
|
344
|
+
self,
|
|
345
|
+
command: str,
|
|
346
|
+
is_subagent: bool = False,
|
|
347
|
+
session_id: str = "",
|
|
348
|
+
) -> BashValidationResult:
|
|
349
|
+
"""Validate a single command (no operators).
|
|
350
|
+
|
|
351
|
+
Simplified pipeline:
|
|
352
|
+
0. Indirect execution detection (for compound command components)
|
|
353
|
+
1. Mutative verb detection -> block with nonce or allow with grant
|
|
354
|
+
2. GitOps policy validation (for kubectl/helm/flux)
|
|
355
|
+
3. Everything else -> SAFE by elimination
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
command: The command to validate.
|
|
359
|
+
is_subagent: True when running in subagent context (generates
|
|
360
|
+
approval_id + deny). False for orchestrator (returns ask).
|
|
361
|
+
session_id: Session ID for pending approval scoping.
|
|
362
|
+
|
|
363
|
+
Note: is_blocked_command() is NOT called here because validate()
|
|
364
|
+
already checks the full command AND each compound component against
|
|
365
|
+
the deny list before dispatching to this method.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
# Indirect execution check for compound command components.
|
|
369
|
+
# When validate() splits "cd /tmp && python3 -c '...'" into parts,
|
|
370
|
+
# the python3 -c component needs the same indirect execution gate
|
|
371
|
+
# that the full command gets in validate().
|
|
372
|
+
indirect_result = self._detect_indirect_execution(command)
|
|
373
|
+
if indirect_result is not None:
|
|
374
|
+
return indirect_result
|
|
375
|
+
|
|
376
|
+
# Mutative verb detection
|
|
377
|
+
result = detect_mutative_command(command)
|
|
378
|
+
if result.is_mutative:
|
|
379
|
+
# Check for an active approval grant before blocking.
|
|
380
|
+
grant = check_approval_grant(command, session_id=session_id)
|
|
381
|
+
if grant is not None:
|
|
382
|
+
if grant.confirmed:
|
|
383
|
+
# Already confirmed and consumed -- should not reach
|
|
384
|
+
# here (single-use). But if it does, allow through.
|
|
385
|
+
logger.info(
|
|
386
|
+
"T3 command allowed via confirmed grant: %s (scope='%s')",
|
|
387
|
+
command[:80], grant.approved_scope,
|
|
388
|
+
)
|
|
389
|
+
return BashValidationResult(
|
|
390
|
+
allowed=True,
|
|
391
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
392
|
+
reason="Grant confirmed",
|
|
393
|
+
)
|
|
394
|
+
else:
|
|
395
|
+
# Grant exists, not yet confirmed -- GAIA approved,
|
|
396
|
+
# let it through. PostToolUse will confirm and consume
|
|
397
|
+
# the grant after successful execution.
|
|
398
|
+
logger.info(
|
|
399
|
+
"T3 command passthrough via active grant: %s (scope='%s')",
|
|
400
|
+
command[:80], grant.approved_scope,
|
|
401
|
+
)
|
|
402
|
+
return BashValidationResult(
|
|
403
|
+
allowed=True,
|
|
404
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
405
|
+
reason="Grant active, pending confirmation",
|
|
406
|
+
)
|
|
407
|
+
else:
|
|
408
|
+
if is_subagent:
|
|
409
|
+
# Subagent context: check for an existing pending
|
|
410
|
+
# approval first (retry scenario). If found, reuse
|
|
411
|
+
# the same nonce to prevent infinite approval_id
|
|
412
|
+
# generation loops while the user reviews.
|
|
413
|
+
existing_nonce = find_pending_for_command(
|
|
414
|
+
session_id or "", command,
|
|
415
|
+
)
|
|
416
|
+
if existing_nonce:
|
|
417
|
+
approval_id = existing_nonce
|
|
418
|
+
logger.info(
|
|
419
|
+
"Reusing pending approval_id=%s for retry: %s",
|
|
420
|
+
approval_id, command[:80],
|
|
421
|
+
)
|
|
422
|
+
reason = (
|
|
423
|
+
f"[T3_BLOCKED] This command requires user approval.\n"
|
|
424
|
+
f"Do NOT retry this command. Report REVIEW with this approval_id in your json:contract.\n"
|
|
425
|
+
f"Command: {command}\n"
|
|
426
|
+
f"Verb: '{result.verb}' ({result.category})\n"
|
|
427
|
+
f"approval_id: {approval_id}"
|
|
428
|
+
)
|
|
429
|
+
hook_deny = build_hook_permission_response("deny", reason)
|
|
430
|
+
return BashValidationResult(
|
|
431
|
+
allowed=False,
|
|
432
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
433
|
+
reason=f"T3 {result.category.lower()} command: {result.reason}",
|
|
434
|
+
block_response=hook_deny,
|
|
435
|
+
)
|
|
436
|
+
# No existing pending -- generate a new nonce.
|
|
437
|
+
# The ElicitationResult hook will activate the
|
|
438
|
+
# grant when the user approves via AskUserQuestion.
|
|
439
|
+
approval_id = generate_nonce()
|
|
440
|
+
pending_path = write_pending_approval(
|
|
441
|
+
nonce=approval_id,
|
|
442
|
+
command=command,
|
|
443
|
+
danger_verb=result.verb,
|
|
444
|
+
danger_category=result.category,
|
|
445
|
+
session_id=session_id or None,
|
|
446
|
+
)
|
|
447
|
+
if pending_path is None:
|
|
448
|
+
# Persistence failure — fall back to ask
|
|
449
|
+
logger.warning(
|
|
450
|
+
"Failed to persist pending approval for subagent; "
|
|
451
|
+
"falling back to ask: %s",
|
|
452
|
+
command[:80],
|
|
453
|
+
)
|
|
454
|
+
reason = build_pending_approval_unavailable_message()
|
|
455
|
+
hook_ask = build_hook_permission_response("ask", reason)
|
|
456
|
+
return BashValidationResult(
|
|
457
|
+
allowed=False,
|
|
458
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
459
|
+
reason="Pending approval persistence failed",
|
|
460
|
+
block_response=hook_ask,
|
|
461
|
+
)
|
|
462
|
+
reason = (
|
|
463
|
+
f"[T3_BLOCKED] This command requires user approval.\n"
|
|
464
|
+
f"Do NOT retry this command. Report REVIEW with this approval_id in your json:contract.\n"
|
|
465
|
+
f"Command: {command}\n"
|
|
466
|
+
f"Verb: '{result.verb}' ({result.category})\n"
|
|
467
|
+
f"approval_id: {approval_id}"
|
|
468
|
+
)
|
|
469
|
+
hook_deny = build_hook_permission_response("deny", reason)
|
|
470
|
+
return BashValidationResult(
|
|
471
|
+
allowed=False,
|
|
472
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
473
|
+
reason=f"T3 {result.category.lower()} command: {result.reason}",
|
|
474
|
+
block_response=hook_deny,
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
# Orchestrator context: route through native 'ask' dialog.
|
|
478
|
+
# The user sees the native permission prompt and approves
|
|
479
|
+
# directly. No approval_id is generated.
|
|
480
|
+
reason = (
|
|
481
|
+
f"[T3_APPROVAL_REQUIRED] {result.category} operation detected.\n"
|
|
482
|
+
f"Command: {command}\n"
|
|
483
|
+
f"Verb: '{result.verb}' ({result.category})\n"
|
|
484
|
+
f"Reason: {result.reason}"
|
|
485
|
+
)
|
|
486
|
+
hook_ask = build_hook_permission_response("ask", reason)
|
|
487
|
+
return BashValidationResult(
|
|
488
|
+
allowed=False,
|
|
489
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
490
|
+
reason=f"Dangerous {result.category.lower()} command: {result.reason}",
|
|
491
|
+
block_response=hook_ask,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Check GitOps policy for kubectl/helm/flux commands
|
|
495
|
+
if any(keyword in command for keyword in ("kubectl", "helm", "flux")):
|
|
496
|
+
gitops_result = validate_gitops_workflow(command)
|
|
497
|
+
if not gitops_result.allowed:
|
|
498
|
+
return BashValidationResult(
|
|
499
|
+
allowed=False,
|
|
500
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
501
|
+
reason=f"GitOps policy violation: {gitops_result.reason}",
|
|
502
|
+
suggestions=gitops_result.suggestions,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Not blocked, not mutative -> SAFE by elimination
|
|
506
|
+
return BashValidationResult(
|
|
507
|
+
allowed=True,
|
|
508
|
+
tier=SecurityTier.T0_READ_ONLY,
|
|
509
|
+
reason="Safe by elimination (not blocked, not mutative)",
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def _validate_compound_command(
|
|
513
|
+
self,
|
|
514
|
+
components: List[str],
|
|
515
|
+
is_subagent: bool = False,
|
|
516
|
+
session_id: str = "",
|
|
517
|
+
) -> BashValidationResult:
|
|
518
|
+
"""Validate a compound command (multiple components)."""
|
|
519
|
+
logger.info(f"Compound command detected with {len(components)} components")
|
|
520
|
+
|
|
521
|
+
component_results: List[BashValidationResult] = []
|
|
522
|
+
for i, component in enumerate(components, 1):
|
|
523
|
+
result = self._validate_single_command(
|
|
524
|
+
component, is_subagent=is_subagent, session_id=session_id,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if not result.allowed:
|
|
528
|
+
return BashValidationResult(
|
|
529
|
+
allowed=False,
|
|
530
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
531
|
+
reason=(
|
|
532
|
+
f"Compound command blocked: component {i}/{len(components)} "
|
|
533
|
+
f"'{component[:50]}' is not allowed\n"
|
|
534
|
+
f"Reason: {result.reason}"
|
|
535
|
+
),
|
|
536
|
+
suggestions=result.suggestions,
|
|
537
|
+
block_response=result.block_response,
|
|
538
|
+
)
|
|
539
|
+
component_results.append(result)
|
|
540
|
+
|
|
541
|
+
# All components validated -- derive highest tier from results already
|
|
542
|
+
# computed by _validate_single_command (avoids redundant classification).
|
|
543
|
+
tier_order = ["T0", "T1", "T2", "T3"]
|
|
544
|
+
highest_tier = max(
|
|
545
|
+
(r.tier for r in component_results),
|
|
546
|
+
key=lambda t: tier_order.index(t.value),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
return BashValidationResult(
|
|
550
|
+
allowed=True,
|
|
551
|
+
tier=highest_tier,
|
|
552
|
+
reason=f"All {len(components)} components validated",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
def _detect_claude_footers(self, command: str) -> bool:
|
|
556
|
+
"""Detect Claude Code attribution footers in command."""
|
|
557
|
+
for pattern in FORBIDDEN_FOOTER_PATTERNS:
|
|
558
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
559
|
+
return True
|
|
560
|
+
return False
|
|
561
|
+
|
|
562
|
+
def _strip_claude_footers(self, command: str) -> str:
|
|
563
|
+
"""
|
|
564
|
+
Strip Claude Code attribution footers from a command.
|
|
565
|
+
|
|
566
|
+
Removes full lines matching forbidden footer patterns.
|
|
567
|
+
Works on raw command string regardless of quoting/HEREDOC format.
|
|
568
|
+
Preserves trailing quote/paren characters that close the commit
|
|
569
|
+
message (e.g., the closing " in -m "...footer").
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
command: Raw command string
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Command with footer lines removed
|
|
576
|
+
"""
|
|
577
|
+
# Remove full lines that contain AI attribution patterns.
|
|
578
|
+
# Each pattern matches the newline + footer content, then uses a
|
|
579
|
+
# lookahead to stop before any trailing quote/paren/bracket
|
|
580
|
+
# sequence that closes the command structure. The captured group
|
|
581
|
+
# is replaced with empty string, leaving the closing chars intact.
|
|
582
|
+
footer_line_patterns = [
|
|
583
|
+
r'\n\s*Co-[Aa]uthored-[Bb]y:\s+(?:Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini)[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
584
|
+
r'\n\s*Generated with\s+\[?Claude Code\]?[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
585
|
+
r'\n\s*🤖\s*Generated with[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
586
|
+
]
|
|
587
|
+
for pattern in footer_line_patterns:
|
|
588
|
+
command = re.sub(pattern, '', command, flags=re.IGNORECASE)
|
|
589
|
+
|
|
590
|
+
# Clean up trailing whitespace inside quotes/heredoc
|
|
591
|
+
# Collapse 3+ consecutive newlines to 2
|
|
592
|
+
command = re.sub(r'\n{3,}', '\n\n', command)
|
|
593
|
+
|
|
594
|
+
return command
|
|
595
|
+
|
|
596
|
+
def _validate_commit_message(self, command: str) -> BashValidationResult:
|
|
597
|
+
"""
|
|
598
|
+
Validate git commit message using commit_validator.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
command: Git commit command to validate
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
BashValidationResult with validation status
|
|
605
|
+
"""
|
|
606
|
+
# Extract commit message from command
|
|
607
|
+
# Handles both: git commit -m "message" and git commit -m "$(cat <<'EOF'...)"
|
|
608
|
+
message = self._extract_commit_message(command)
|
|
609
|
+
|
|
610
|
+
if not message:
|
|
611
|
+
# Could not extract message - let it pass, git will handle it
|
|
612
|
+
return BashValidationResult(
|
|
613
|
+
allowed=True,
|
|
614
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
615
|
+
reason="Could not extract commit message for validation"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# Import validator (lazy import to avoid startup cost)
|
|
619
|
+
try:
|
|
620
|
+
import sys
|
|
621
|
+
from pathlib import Path
|
|
622
|
+
|
|
623
|
+
# Import from sibling module (hooks/modules/validation)
|
|
624
|
+
from ..validation.commit_validator import validate_commit_message
|
|
625
|
+
|
|
626
|
+
# Validate message
|
|
627
|
+
validation = validate_commit_message(message)
|
|
628
|
+
|
|
629
|
+
if not validation.valid:
|
|
630
|
+
# Build suggestions from errors
|
|
631
|
+
suggestions = []
|
|
632
|
+
for error in validation.errors:
|
|
633
|
+
suggestions.append(f"{error['type']}: {error['fix']}")
|
|
634
|
+
|
|
635
|
+
return BashValidationResult(
|
|
636
|
+
allowed=False,
|
|
637
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
638
|
+
reason=f"Commit message validation failed: {validation.errors[0]['message']}",
|
|
639
|
+
suggestions=suggestions[:3] # Limit to 3 suggestions
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
return BashValidationResult(
|
|
643
|
+
allowed=True,
|
|
644
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
645
|
+
reason="Commit message validated successfully"
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
except Exception as e:
|
|
649
|
+
logger.warning(f"Failed to validate commit message: {e}")
|
|
650
|
+
# If validation fails, allow the command (don't block on validator failure)
|
|
651
|
+
return BashValidationResult(
|
|
652
|
+
allowed=True,
|
|
653
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
654
|
+
reason=f"Commit validation skipped (validator error: {e})"
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
def _extract_commit_message(self, command: str) -> Optional[str]:
|
|
658
|
+
"""
|
|
659
|
+
Extract commit message from git commit command.
|
|
660
|
+
|
|
661
|
+
Handles formats:
|
|
662
|
+
- git commit -m "message"
|
|
663
|
+
- git commit -m 'message'
|
|
664
|
+
- git commit -m "$(cat <<'EOF'\nmessage\nEOF\n)"
|
|
665
|
+
- git commit -m "$(cat <<EOF\nmessage\nEOF\n)"
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
Extracted message or None if cannot extract
|
|
669
|
+
"""
|
|
670
|
+
# Level 1: HEREDOC pattern (most common in Claude Code)
|
|
671
|
+
# Handles: <<'EOF', <<EOF, <<"EOF" with flexible whitespace
|
|
672
|
+
if "<<" in command:
|
|
673
|
+
heredoc_match = re.search(
|
|
674
|
+
r"<<['\"]?EOF['\"]?\s*\n(.*?)\n\s*EOF",
|
|
675
|
+
command, re.DOTALL
|
|
676
|
+
)
|
|
677
|
+
if heredoc_match:
|
|
678
|
+
return heredoc_match.group(1).strip()
|
|
679
|
+
|
|
680
|
+
# Level 2: Simple -m "message" or -m 'message' (non-heredoc)
|
|
681
|
+
match = re.search(r'-m\s+(["\'])(.*?)\1', command, re.DOTALL)
|
|
682
|
+
if match:
|
|
683
|
+
msg = match.group(2)
|
|
684
|
+
# Skip if it's a $(cat... wrapper — heredoc parse failed above
|
|
685
|
+
if msg.lstrip().startswith("$(cat"):
|
|
686
|
+
return None
|
|
687
|
+
return msg.strip()
|
|
688
|
+
|
|
689
|
+
return None
|
|
690
|
+
|
|
691
|
+
def validate_bash_command(
|
|
692
|
+
command: str,
|
|
693
|
+
is_subagent: bool = False,
|
|
694
|
+
session_id: str = "",
|
|
695
|
+
) -> BashValidationResult:
|
|
696
|
+
"""
|
|
697
|
+
Validate a Bash command (convenience function).
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
command: Command to validate
|
|
701
|
+
is_subagent: True when running in subagent context
|
|
702
|
+
session_id: Session ID for approval scoping
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
BashValidationResult
|
|
706
|
+
"""
|
|
707
|
+
validator = BashValidator()
|
|
708
|
+
return validator.validate(command, is_subagent=is_subagent, session_id=session_id)
|