@jaguilar87/gaia 5.0.0-rc.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 +33 -0
- package/.claude-plugin/plugin.json +26 -0
- package/ARCHITECTURE.md +335 -0
- package/CHANGELOG.md +1298 -0
- package/CODE_OF_CONDUCT.md +11 -0
- package/CONTRIBUTING.md +146 -0
- package/INSTALL.md +436 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/SECURITY.md +47 -0
- package/agents/README.md +78 -0
- package/agents/cloud-troubleshooter.md +73 -0
- package/agents/developer.md +65 -0
- package/agents/gaia-operator.md +64 -0
- package/agents/gaia-orchestrator.md +111 -0
- package/agents/gaia-planner.md +53 -0
- package/agents/gaia-system.md +71 -0
- package/agents/gitops-operator.md +61 -0
- package/agents/terraform-architect.md +63 -0
- package/bin/README.md +106 -0
- package/bin/cli/__init__.py +1 -0
- package/bin/cli/approvals.py +740 -0
- package/bin/cli/cleanup.py +562 -0
- package/bin/cli/context.py +283 -0
- package/bin/cli/doctor.py +651 -0
- package/bin/cli/history.py +305 -0
- package/bin/cli/memory.py +483 -0
- package/bin/cli/metrics.py +1068 -0
- package/bin/cli/plans.py +515 -0
- package/bin/cli/status.py +302 -0
- package/bin/cli/update.py +382 -0
- package/bin/gaia +112 -0
- package/bin/gaia-cleanup.js +531 -0
- package/bin/gaia-doctor.js +635 -0
- package/bin/gaia-evidence +126 -0
- package/bin/gaia-history.js +251 -0
- package/bin/gaia-metrics.js +1278 -0
- package/bin/gaia-review.js +269 -0
- package/bin/gaia-scan +44 -0
- package/bin/gaia-scan.py +589 -0
- package/bin/gaia-skills-diagnose.js +929 -0
- package/bin/gaia-status.js +278 -0
- package/bin/gaia-uninstall.js +111 -0
- package/bin/gaia-update.js +919 -0
- package/bin/pre-publish-validate.js +610 -0
- package/bin/python-detect.js +60 -0
- package/bin/validate-sandbox.sh +601 -0
- package/commands/README.md +64 -0
- package/commands/gaia.md +37 -0
- package/commands/scan-project.md +67 -0
- package/config/README.md +71 -0
- package/config/cloud/aws.json +134 -0
- package/config/cloud/gcp.json +139 -0
- package/config/context-contracts.json +158 -0
- package/config/crons-schema.md +81 -0
- package/config/git_standards.json +72 -0
- package/config/surface-routing.json +417 -0
- package/config/universal-rules.json +102 -0
- package/dist/gaia-ops/.claude-plugin/plugin.json +24 -0
- package/dist/gaia-ops/README.md +80 -0
- package/dist/gaia-ops/agents/cloud-troubleshooter.md +73 -0
- package/dist/gaia-ops/agents/developer.md +65 -0
- package/dist/gaia-ops/agents/gaia-operator.md +64 -0
- package/dist/gaia-ops/agents/gaia-orchestrator.md +111 -0
- package/dist/gaia-ops/agents/gaia-planner.md +53 -0
- package/dist/gaia-ops/agents/gaia-system.md +71 -0
- package/dist/gaia-ops/agents/gitops-operator.md +61 -0
- package/dist/gaia-ops/agents/terraform-architect.md +63 -0
- package/dist/gaia-ops/commands/gaia.md +37 -0
- package/dist/gaia-ops/config/README.md +71 -0
- package/dist/gaia-ops/config/cloud/aws.json +134 -0
- package/dist/gaia-ops/config/cloud/gcp.json +139 -0
- package/dist/gaia-ops/config/context-contracts.json +158 -0
- package/dist/gaia-ops/config/crons-schema.md +81 -0
- package/dist/gaia-ops/config/git_standards.json +72 -0
- package/dist/gaia-ops/config/surface-routing.json +417 -0
- package/dist/gaia-ops/config/universal-rules.json +102 -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 +1890 -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 +192 -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 +120 -0
- package/dist/gaia-ops/hooks/modules/agents/state_tracker.py +267 -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 +611 -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/agentic_loop_detector.py +165 -0
- package/dist/gaia-ops/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-ops/hooks/modules/context/compact_context_builder.py +218 -0
- package/dist/gaia-ops/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +558 -0
- package/dist/gaia-ops/hooks/modules/context/context_writer.py +530 -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 +577 -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/memory/__init__.py +8 -0
- package/dist/gaia-ops/hooks/modules/memory/episode_writer.py +216 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-ops/hooks/modules/orchestrator/delegate_mode.py +122 -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 +120 -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 +1638 -0
- package/dist/gaia-ops/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-ops/hooks/modules/security/approval_scopes.py +222 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_commands.py +595 -0
- package/dist/gaia-ops/hooks/modules/security/blocked_message_formatter.py +87 -0
- package/dist/gaia-ops/hooks/modules/security/command_semantics.py +181 -0
- package/dist/gaia-ops/hooks/modules/security/composition_rules.py +547 -0
- package/dist/gaia-ops/hooks/modules/security/flag_classifiers.py +873 -0
- package/dist/gaia-ops/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +1131 -0
- package/dist/gaia-ops/hooks/modules/security/network_hosts.py +481 -0
- package/dist/gaia-ops/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-ops/hooks/modules/security/shell_unwrapper.py +165 -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/pending_scanner.py +174 -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 +160 -0
- package/dist/gaia-ops/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-ops/hooks/modules/session/session_registry.py +333 -0
- package/dist/gaia-ops/hooks/modules/tools/__init__.py +29 -0
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +1008 -0
- package/dist/gaia-ops/hooks/modules/tools/cloud_pipe_validator.py +231 -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/stage_decomposer.py +315 -0
- package/dist/gaia-ops/hooks/modules/tools/task_validator.py +294 -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_compact.py +60 -0
- package/dist/gaia-ops/hooks/pre_tool_use.py +413 -0
- package/dist/gaia-ops/hooks/session_end_hook.py +77 -0
- package/dist/gaia-ops/hooks/session_start.py +81 -0
- package/dist/gaia-ops/hooks/stop_hook.py +70 -0
- package/dist/gaia-ops/hooks/subagent_start.py +71 -0
- package/dist/gaia-ops/hooks/subagent_stop.py +295 -0
- package/dist/gaia-ops/hooks/task_completed.py +70 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +246 -0
- package/dist/gaia-ops/settings.json +72 -0
- package/dist/gaia-ops/skills/README.md +158 -0
- package/dist/gaia-ops/skills/agent-creation/SKILL.md +87 -0
- package/dist/gaia-ops/skills/agent-creation/examples.md +170 -0
- package/dist/gaia-ops/skills/agent-creation/reference.md +191 -0
- package/dist/gaia-ops/skills/agent-protocol/SKILL.md +93 -0
- package/dist/gaia-ops/skills/agent-protocol/examples.md +223 -0
- package/dist/gaia-ops/skills/agent-response/SKILL.md +69 -0
- package/dist/gaia-ops/skills/agentic-loop/SKILL.md +80 -0
- package/dist/gaia-ops/skills/agentic-loop/reference.md +378 -0
- package/dist/gaia-ops/skills/blog-writing/SKILL.md +98 -0
- package/dist/gaia-ops/skills/blog-writing/reference.md +130 -0
- package/dist/gaia-ops/skills/brief-spec/SKILL.md +185 -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 +87 -0
- package/dist/gaia-ops/skills/context-updater/examples.md +71 -0
- package/dist/gaia-ops/skills/developer-patterns/SKILL.md +50 -0
- package/dist/gaia-ops/skills/developer-patterns/reference.md +112 -0
- package/dist/gaia-ops/skills/execution/SKILL.md +99 -0
- package/dist/gaia-ops/skills/fast-queries/SKILL.md +43 -0
- package/dist/gaia-ops/skills/gaia-compact/SKILL.md +74 -0
- package/dist/gaia-ops/skills/gaia-patterns/SKILL.md +108 -0
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +395 -0
- package/dist/gaia-ops/skills/gaia-planner/SKILL.md +37 -0
- package/dist/gaia-ops/skills/gaia-planner/reference.md +107 -0
- package/dist/gaia-ops/skills/gaia-release/SKILL.md +85 -0
- package/dist/gaia-ops/skills/gaia-release/reference.md +92 -0
- package/dist/gaia-ops/skills/gaia-self-check/SKILL.md +114 -0
- package/dist/gaia-ops/skills/gaia-self-check/reference.md +453 -0
- package/dist/gaia-ops/skills/gaia-verify/SKILL.md +77 -0
- package/dist/gaia-ops/skills/gaia-verify/reference.md +80 -0
- package/dist/gaia-ops/skills/git-conventions/SKILL.md +47 -0
- package/dist/gaia-ops/skills/gitops-patterns/SKILL.md +60 -0
- package/dist/gaia-ops/skills/gitops-patterns/reference.md +183 -0
- package/dist/gaia-ops/skills/gmail-policy/SKILL.md +200 -0
- package/dist/gaia-ops/skills/gmail-policy/reference.md +150 -0
- package/dist/gaia-ops/skills/gmail-triage/SKILL.md +100 -0
- package/dist/gaia-ops/skills/gws-setup/SKILL.md +99 -0
- package/dist/gaia-ops/skills/gws-setup/reference.md +73 -0
- package/dist/gaia-ops/skills/investigation/SKILL.md +100 -0
- package/dist/gaia-ops/skills/memory-curation/SKILL.md +83 -0
- package/dist/gaia-ops/skills/memory-search/SKILL.md +88 -0
- package/dist/gaia-ops/skills/orchestrator-approval/SKILL.md +160 -0
- package/dist/gaia-ops/skills/orchestrator-approval/reference.md +174 -0
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +72 -0
- package/dist/gaia-ops/skills/pending-approvals/reference.md +214 -0
- package/dist/gaia-ops/skills/readme-writing/SKILL.md +71 -0
- package/dist/gaia-ops/skills/readme-writing/reference.md +188 -0
- package/dist/gaia-ops/skills/reference.md +135 -0
- package/dist/gaia-ops/skills/request-approval/SKILL.md +140 -0
- package/dist/gaia-ops/skills/request-approval/examples.md +140 -0
- package/dist/gaia-ops/skills/request-approval/reference.md +57 -0
- package/dist/gaia-ops/skills/schedule-task/SKILL.md +64 -0
- package/dist/gaia-ops/skills/schedule-task/reference.md +233 -0
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +141 -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/session-reflection/SKILL.md +69 -0
- package/dist/gaia-ops/skills/skill-creation/SKILL.md +92 -0
- package/dist/gaia-ops/skills/skill-creation/reference.md +29 -0
- package/dist/gaia-ops/skills/terraform-patterns/SKILL.md +89 -0
- package/dist/gaia-ops/skills/terraform-patterns/reference.md +93 -0
- package/dist/gaia-ops/tools/__init__.py +9 -0
- package/dist/gaia-ops/tools/agentic-loop/decide-status.py +210 -0
- package/dist/gaia-ops/tools/agentic-loop/parse-metric.py +106 -0
- package/dist/gaia-ops/tools/agentic-loop/record-iteration.py +221 -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 +721 -0
- package/dist/gaia-ops/tools/context/context_section_reader.py +342 -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 +264 -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/backfill_fts5.py +107 -0
- package/dist/gaia-ops/tools/memory/conflict_detector.py +295 -0
- package/dist/gaia-ops/tools/memory/episodic.py +1210 -0
- package/dist/gaia-ops/tools/memory/git_invalidator.py +262 -0
- package/dist/gaia-ops/tools/memory/paths.py +102 -0
- package/dist/gaia-ops/tools/memory/scoring.py +193 -0
- package/dist/gaia-ops/tools/memory/search_store.py +375 -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 +349 -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 +686 -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 +270 -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 +24 -0
- package/dist/gaia-security/README.md +90 -0
- package/dist/gaia-security/config/universal-rules.json +102 -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 +1890 -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 +113 -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 +120 -0
- package/dist/gaia-security/hooks/modules/agents/state_tracker.py +267 -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 +611 -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/agentic_loop_detector.py +165 -0
- package/dist/gaia-security/hooks/modules/context/anchor_tracker.py +317 -0
- package/dist/gaia-security/hooks/modules/context/compact_context_builder.py +218 -0
- package/dist/gaia-security/hooks/modules/context/context_freshness.py +145 -0
- package/dist/gaia-security/hooks/modules/context/context_injector.py +558 -0
- package/dist/gaia-security/hooks/modules/context/context_writer.py +530 -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 +577 -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/memory/__init__.py +8 -0
- package/dist/gaia-security/hooks/modules/memory/episode_writer.py +216 -0
- package/dist/gaia-security/hooks/modules/orchestrator/__init__.py +1 -0
- package/dist/gaia-security/hooks/modules/orchestrator/delegate_mode.py +122 -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 +120 -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 +1638 -0
- package/dist/gaia-security/hooks/modules/security/approval_messages.py +71 -0
- package/dist/gaia-security/hooks/modules/security/approval_scopes.py +222 -0
- package/dist/gaia-security/hooks/modules/security/blocked_commands.py +595 -0
- package/dist/gaia-security/hooks/modules/security/blocked_message_formatter.py +87 -0
- package/dist/gaia-security/hooks/modules/security/command_semantics.py +181 -0
- package/dist/gaia-security/hooks/modules/security/composition_rules.py +547 -0
- package/dist/gaia-security/hooks/modules/security/flag_classifiers.py +873 -0
- package/dist/gaia-security/hooks/modules/security/gitops_validator.py +179 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +1131 -0
- package/dist/gaia-security/hooks/modules/security/network_hosts.py +481 -0
- package/dist/gaia-security/hooks/modules/security/prompt_validator.py +40 -0
- package/dist/gaia-security/hooks/modules/security/shell_unwrapper.py +165 -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/pending_scanner.py +174 -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 +160 -0
- package/dist/gaia-security/hooks/modules/session/session_manager.py +31 -0
- package/dist/gaia-security/hooks/modules/session/session_registry.py +333 -0
- package/dist/gaia-security/hooks/modules/tools/__init__.py +29 -0
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +1008 -0
- package/dist/gaia-security/hooks/modules/tools/cloud_pipe_validator.py +231 -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/stage_decomposer.py +315 -0
- package/dist/gaia-security/hooks/modules/tools/task_validator.py +294 -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 +413 -0
- package/dist/gaia-security/hooks/session_end_hook.py +77 -0
- package/dist/gaia-security/hooks/session_start.py +81 -0
- package/dist/gaia-security/hooks/stop_hook.py +70 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +246 -0
- package/dist/gaia-security/settings.json +58 -0
- package/git-hooks/commit-msg +41 -0
- package/hooks/README.md +100 -0
- package/hooks/adapters/__init__.py +52 -0
- package/hooks/adapters/base.py +219 -0
- package/hooks/adapters/channel.py +17 -0
- package/hooks/adapters/claude_code.py +1890 -0
- package/hooks/adapters/types.py +194 -0
- package/hooks/adapters/utils.py +25 -0
- package/hooks/elicitation_result.py +179 -0
- package/hooks/hooks.json +84 -0
- package/hooks/modules/README.md +189 -0
- package/hooks/modules/__init__.py +15 -0
- package/hooks/modules/agents/__init__.py +29 -0
- package/hooks/modules/agents/contract_validator.py +647 -0
- package/hooks/modules/agents/response_contract.py +496 -0
- package/hooks/modules/agents/skill_injection_verifier.py +120 -0
- package/hooks/modules/agents/state_tracker.py +267 -0
- package/hooks/modules/agents/task_info_builder.py +74 -0
- package/hooks/modules/agents/transcript_analyzer.py +458 -0
- package/hooks/modules/agents/transcript_reader.py +152 -0
- package/hooks/modules/audit/__init__.py +28 -0
- package/hooks/modules/audit/event_detector.py +168 -0
- package/hooks/modules/audit/logger.py +131 -0
- package/hooks/modules/audit/metrics.py +134 -0
- package/hooks/modules/audit/workflow_auditor.py +611 -0
- package/hooks/modules/audit/workflow_recorder.py +296 -0
- package/hooks/modules/context/__init__.py +11 -0
- package/hooks/modules/context/agentic_loop_detector.py +165 -0
- package/hooks/modules/context/anchor_tracker.py +317 -0
- package/hooks/modules/context/compact_context_builder.py +218 -0
- package/hooks/modules/context/context_freshness.py +145 -0
- package/hooks/modules/context/context_injector.py +558 -0
- package/hooks/modules/context/context_writer.py +530 -0
- package/hooks/modules/context/contracts_loader.py +161 -0
- package/hooks/modules/core/__init__.py +40 -0
- package/hooks/modules/core/hook_entry.py +78 -0
- package/hooks/modules/core/paths.py +160 -0
- package/hooks/modules/core/plugin_mode.py +149 -0
- package/hooks/modules/core/plugin_setup.py +577 -0
- package/hooks/modules/core/state.py +179 -0
- package/hooks/modules/core/stdin.py +24 -0
- package/hooks/modules/events/__init__.py +1 -0
- package/hooks/modules/events/event_writer.py +210 -0
- package/hooks/modules/evidence/__init__.py +34 -0
- package/hooks/modules/evidence/assertions.py +137 -0
- package/hooks/modules/evidence/index_writer.py +57 -0
- package/hooks/modules/evidence/loader.py +126 -0
- package/hooks/modules/evidence/runner.py +241 -0
- package/hooks/modules/memory/__init__.py +8 -0
- package/hooks/modules/memory/episode_writer.py +216 -0
- package/hooks/modules/orchestrator/__init__.py +1 -0
- package/hooks/modules/orchestrator/delegate_mode.py +122 -0
- package/hooks/modules/scanning/__init__.py +8 -0
- package/hooks/modules/scanning/scan_trigger.py +84 -0
- package/hooks/modules/security/__init__.py +120 -0
- package/hooks/modules/security/approval_cleanup.py +87 -0
- package/hooks/modules/security/approval_constants.py +23 -0
- package/hooks/modules/security/approval_grants.py +1638 -0
- package/hooks/modules/security/approval_messages.py +71 -0
- package/hooks/modules/security/approval_scopes.py +222 -0
- package/hooks/modules/security/blocked_commands.py +595 -0
- package/hooks/modules/security/blocked_message_formatter.py +87 -0
- package/hooks/modules/security/command_semantics.py +181 -0
- package/hooks/modules/security/composition_rules.py +547 -0
- package/hooks/modules/security/flag_classifiers.py +873 -0
- package/hooks/modules/security/gitops_validator.py +179 -0
- package/hooks/modules/security/mutative_verbs.py +1131 -0
- package/hooks/modules/security/network_hosts.py +481 -0
- package/hooks/modules/security/prompt_validator.py +40 -0
- package/hooks/modules/security/shell_unwrapper.py +165 -0
- package/hooks/modules/security/tiers.py +196 -0
- package/hooks/modules/session/__init__.py +10 -0
- package/hooks/modules/session/pending_scanner.py +174 -0
- package/hooks/modules/session/session_context_writer.py +100 -0
- package/hooks/modules/session/session_event_injector.py +160 -0
- package/hooks/modules/session/session_manager.py +31 -0
- package/hooks/modules/session/session_registry.py +333 -0
- package/hooks/modules/tools/__init__.py +29 -0
- package/hooks/modules/tools/bash_validator.py +1008 -0
- package/hooks/modules/tools/cloud_pipe_validator.py +231 -0
- package/hooks/modules/tools/hook_response.py +55 -0
- package/hooks/modules/tools/shell_parser.py +227 -0
- package/hooks/modules/tools/stage_decomposer.py +315 -0
- package/hooks/modules/tools/task_validator.py +294 -0
- package/hooks/modules/validation/__init__.py +23 -0
- package/hooks/modules/validation/commit_validator.py +380 -0
- package/hooks/post_compact.py +43 -0
- package/hooks/post_tool_use.py +54 -0
- package/hooks/pre_compact.py +60 -0
- package/hooks/pre_tool_use.py +413 -0
- package/hooks/session_end_hook.py +77 -0
- package/hooks/session_start.py +81 -0
- package/hooks/stop_hook.py +70 -0
- package/hooks/subagent_start.py +71 -0
- package/hooks/subagent_stop.py +295 -0
- package/hooks/task_completed.py +70 -0
- package/hooks/user_prompt_submit.py +246 -0
- package/index.js +83 -0
- package/package.json +103 -0
- package/pyproject.toml +32 -0
- package/skills/README.md +158 -0
- package/skills/agent-creation/SKILL.md +87 -0
- package/skills/agent-creation/examples.md +170 -0
- package/skills/agent-creation/reference.md +191 -0
- package/skills/agent-protocol/SKILL.md +93 -0
- package/skills/agent-protocol/examples.md +223 -0
- package/skills/agent-response/SKILL.md +69 -0
- package/skills/agentic-loop/SKILL.md +80 -0
- package/skills/agentic-loop/reference.md +378 -0
- package/skills/blog-writing/SKILL.md +98 -0
- package/skills/blog-writing/reference.md +130 -0
- package/skills/brief-spec/SKILL.md +185 -0
- package/skills/command-execution/SKILL.md +64 -0
- package/skills/command-execution/reference.md +83 -0
- package/skills/context-updater/SKILL.md +87 -0
- package/skills/context-updater/examples.md +71 -0
- package/skills/developer-patterns/SKILL.md +50 -0
- package/skills/developer-patterns/reference.md +112 -0
- package/skills/execution/SKILL.md +99 -0
- package/skills/fast-queries/SKILL.md +43 -0
- package/skills/gaia-compact/SKILL.md +74 -0
- package/skills/gaia-patterns/SKILL.md +108 -0
- package/skills/gaia-patterns/reference.md +395 -0
- package/skills/gaia-planner/SKILL.md +37 -0
- package/skills/gaia-planner/reference.md +107 -0
- package/skills/gaia-release/SKILL.md +85 -0
- package/skills/gaia-release/reference.md +92 -0
- package/skills/gaia-self-check/SKILL.md +114 -0
- package/skills/gaia-self-check/reference.md +453 -0
- package/skills/gaia-verify/SKILL.md +77 -0
- package/skills/gaia-verify/reference.md +80 -0
- package/skills/git-conventions/SKILL.md +47 -0
- package/skills/gitops-patterns/SKILL.md +60 -0
- package/skills/gitops-patterns/reference.md +183 -0
- package/skills/gmail-policy/SKILL.md +200 -0
- package/skills/gmail-policy/reference.md +150 -0
- package/skills/gmail-triage/SKILL.md +100 -0
- package/skills/gws-setup/SKILL.md +99 -0
- package/skills/gws-setup/reference.md +73 -0
- package/skills/investigation/SKILL.md +100 -0
- package/skills/memory-curation/SKILL.md +83 -0
- package/skills/memory-search/SKILL.md +88 -0
- package/skills/orchestrator-approval/SKILL.md +160 -0
- package/skills/orchestrator-approval/reference.md +174 -0
- package/skills/pending-approvals/SKILL.md +72 -0
- package/skills/pending-approvals/reference.md +214 -0
- package/skills/readme-writing/SKILL.md +71 -0
- package/skills/readme-writing/reference.md +188 -0
- package/skills/reference.md +135 -0
- package/skills/request-approval/SKILL.md +140 -0
- package/skills/request-approval/examples.md +140 -0
- package/skills/request-approval/reference.md +57 -0
- package/skills/schedule-task/SKILL.md +64 -0
- package/skills/schedule-task/reference.md +233 -0
- package/skills/security-tiers/SKILL.md +141 -0
- package/skills/security-tiers/destructive-commands-reference.md +623 -0
- package/skills/security-tiers/reference.md +39 -0
- package/skills/session-reflection/SKILL.md +69 -0
- package/skills/skill-creation/SKILL.md +92 -0
- package/skills/skill-creation/reference.md +29 -0
- package/skills/terraform-patterns/SKILL.md +89 -0
- package/skills/terraform-patterns/reference.md +93 -0
- package/templates/README.md +69 -0
- package/templates/managed-settings.template.json +43 -0
- package/tools/__init__.py +9 -0
- package/tools/agentic-loop/decide-status.py +210 -0
- package/tools/agentic-loop/parse-metric.py +106 -0
- package/tools/agentic-loop/record-iteration.py +221 -0
- package/tools/context/README.md +132 -0
- package/tools/context/__init__.py +42 -0
- package/tools/context/_paths.py +20 -0
- package/tools/context/context_provider.py +721 -0
- package/tools/context/context_section_reader.py +342 -0
- package/tools/context/deep_merge.py +159 -0
- package/tools/context/pending_updates.py +760 -0
- package/tools/context/surface_router.py +278 -0
- package/tools/fast-queries/README.md +65 -0
- package/tools/fast-queries/__init__.py +30 -0
- package/tools/fast-queries/appservices/quicktriage_devops_developer.sh +75 -0
- package/tools/fast-queries/cloud/aws/quicktriage_aws_troubleshooter.sh +32 -0
- package/tools/fast-queries/cloud/gcp/quicktriage_gcp_troubleshooter.sh +88 -0
- package/tools/fast-queries/gitops/quicktriage_gitops_operator.sh +48 -0
- package/tools/fast-queries/run_triage.sh +59 -0
- package/tools/fast-queries/terraform/quicktriage_terraform_architect.sh +80 -0
- package/tools/gaia_simulator/__init__.py +33 -0
- package/tools/gaia_simulator/cli.py +354 -0
- package/tools/gaia_simulator/extractor.py +457 -0
- package/tools/gaia_simulator/reporter.py +258 -0
- package/tools/gaia_simulator/routing_simulator.py +334 -0
- package/tools/gaia_simulator/runner.py +539 -0
- package/tools/gaia_simulator/skills_mapper.py +264 -0
- package/tools/memory/README.md +0 -0
- package/tools/memory/__init__.py +20 -0
- package/tools/memory/backfill_fts5.py +107 -0
- package/tools/memory/conflict_detector.py +295 -0
- package/tools/memory/episodic.py +1210 -0
- package/tools/memory/git_invalidator.py +262 -0
- package/tools/memory/paths.py +102 -0
- package/tools/memory/scoring.py +193 -0
- package/tools/memory/search_store.py +375 -0
- package/tools/persist_transcript_analysis.py +85 -0
- package/tools/review/__init__.py +1 -0
- package/tools/review/review_engine.py +157 -0
- package/tools/scan/__init__.py +35 -0
- package/tools/scan/config.py +247 -0
- package/tools/scan/merge.py +212 -0
- package/tools/scan/orchestrator.py +549 -0
- package/tools/scan/registry.py +127 -0
- package/tools/scan/scanners/__init__.py +18 -0
- package/tools/scan/scanners/base.py +137 -0
- package/tools/scan/scanners/environment.py +349 -0
- package/tools/scan/scanners/git.py +570 -0
- package/tools/scan/scanners/infrastructure.py +875 -0
- package/tools/scan/scanners/orchestration.py +600 -0
- package/tools/scan/scanners/stack.py +1085 -0
- package/tools/scan/scanners/tools.py +260 -0
- package/tools/scan/setup.py +686 -0
- package/tools/scan/tests/__init__.py +1 -0
- package/tools/scan/tests/conftest.py +796 -0
- package/tools/scan/tests/test_environment.py +323 -0
- package/tools/scan/tests/test_git.py +419 -0
- package/tools/scan/tests/test_infrastructure.py +382 -0
- package/tools/scan/tests/test_integration.py +920 -0
- package/tools/scan/tests/test_merge.py +269 -0
- package/tools/scan/tests/test_orchestration.py +304 -0
- package/tools/scan/tests/test_stack.py +604 -0
- package/tools/scan/tests/test_tools.py +349 -0
- package/tools/scan/ui.py +624 -0
- package/tools/scan/verify.py +270 -0
- package/tools/scan/walk.py +118 -0
- package/tools/scan/workspace.py +85 -0
- package/tools/validation/README.md +244 -0
- package/tools/validation/__init__.py +17 -0
- package/tools/validation/approval_gate.py +321 -0
- package/tools/validation/validate_skills.py +189 -0
|
@@ -0,0 +1,1008 @@
|
|
|
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
|
+
5-Phase Pipeline:
|
|
9
|
+
1. UNWRAP -- ShellUnwrapper strips wrapper shells (bash -c, sh -c, ...);
|
|
10
|
+
depth >= _OBFUSCATION_DEPTH_LIMIT = permanent block.
|
|
11
|
+
Existing _detect_indirect_execution() runs as fallback for
|
|
12
|
+
eval, python -c, node -e, etc.
|
|
13
|
+
2. DECOMPOSE -- StageDecomposer splits into operator-linked stages.
|
|
14
|
+
3. CLASSIFY -- blocked_commands + cloud_pipe_validator + mutative_verbs
|
|
15
|
+
per stage (existing logic, unchanged).
|
|
16
|
+
4. COMPOSITION -- cross-stage composition rules (exfiltration, RCE,
|
|
17
|
+
obfuscated exec via pipe analysis).
|
|
18
|
+
5. AGGREGATE -- combine stage results into final BashValidationResult.
|
|
19
|
+
|
|
20
|
+
Earlier flat-pipeline order preserved within phases for backward compat:
|
|
21
|
+
- Footer stripping runs before phase 1 (EARLY NORMALIZATION)
|
|
22
|
+
- Indirect execution detection is the first check in phase 1
|
|
23
|
+
- Blocked commands run before cloud_pipe and mutative_verbs in phase 3
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from typing import Dict, Any, Optional, List
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
from ..security.tiers import SecurityTier
|
|
34
|
+
from ..security.blocked_commands import is_blocked_command
|
|
35
|
+
from ..security.gitops_validator import validate_gitops_workflow
|
|
36
|
+
from ..security.mutative_verbs import (
|
|
37
|
+
detect_mutative_command,
|
|
38
|
+
build_t3_block_response,
|
|
39
|
+
)
|
|
40
|
+
from ..security.flag_classifiers import (
|
|
41
|
+
classify_by_flags,
|
|
42
|
+
OUTCOME_BLOCKED as FLAG_BLOCKED,
|
|
43
|
+
OUTCOME_MUTATIVE as FLAG_MUTATIVE,
|
|
44
|
+
)
|
|
45
|
+
from ..security.approval_grants import (
|
|
46
|
+
check_approval_grant,
|
|
47
|
+
confirm_grant,
|
|
48
|
+
find_pending_for_command,
|
|
49
|
+
generate_nonce,
|
|
50
|
+
last_check_found_expired,
|
|
51
|
+
write_pending_approval,
|
|
52
|
+
)
|
|
53
|
+
from ..security.approval_messages import (
|
|
54
|
+
build_pending_approval_unavailable_message,
|
|
55
|
+
build_t3_approval_instructions,
|
|
56
|
+
)
|
|
57
|
+
from ..security.shell_unwrapper import ShellUnwrapper
|
|
58
|
+
from ..security.composition_rules import (
|
|
59
|
+
build_composition_stages,
|
|
60
|
+
check_composition,
|
|
61
|
+
CompositionDecision,
|
|
62
|
+
)
|
|
63
|
+
from .shell_parser import get_shell_parser
|
|
64
|
+
from .cloud_pipe_validator import validate_cloud_pipe
|
|
65
|
+
from .hook_response import build_hook_permission_response
|
|
66
|
+
from .stage_decomposer import StageDecomposer, DecomposedCommand
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
# Maximum wrapper depth before treating as obfuscation (permanent block).
|
|
71
|
+
_OBFUSCATION_DEPTH_LIMIT = 5
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class BashValidationResult:
|
|
76
|
+
"""Result of Bash command validation."""
|
|
77
|
+
allowed: bool
|
|
78
|
+
tier: SecurityTier
|
|
79
|
+
reason: str
|
|
80
|
+
suggestions: List[str] = None
|
|
81
|
+
modified_input: Optional[Dict[str, Any]] = None
|
|
82
|
+
# When set, the caller should return this dict (exit 0) instead of a
|
|
83
|
+
# plain error string (exit 2). Used for structured block responses that
|
|
84
|
+
# should correct the agent rather than terminate execution.
|
|
85
|
+
block_response: Optional[Dict[str, Any]] = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self):
|
|
88
|
+
if self.suggestions is None:
|
|
89
|
+
self.suggestions = []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Patterns for AI tool attribution footers (auto-stripped from commits).
|
|
93
|
+
# Covers Claude Code, GitHub Copilot, Aider, Windsurf, and any future
|
|
94
|
+
# tool using the Co-authored-by git trailer convention.
|
|
95
|
+
FORBIDDEN_FOOTER_PATTERNS = [
|
|
96
|
+
r"Generated with\s+Claude Code",
|
|
97
|
+
r"Generated with\s+\[?Claude Code\]?",
|
|
98
|
+
r"Co-Authored-By:\s+Claude\b",
|
|
99
|
+
r"Co-authored-by:\s+GitHub Copilot\b",
|
|
100
|
+
r"Co-authored-by:\s+aider\b",
|
|
101
|
+
r"Co-authored-by:\s+Windsurf\b",
|
|
102
|
+
r"Co-authored-by:\s+Cursor\b",
|
|
103
|
+
r"Co-authored-by:\s+Codex\b",
|
|
104
|
+
r"Co-authored-by:\s+Gemini\b",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Indirect execution wrappers — commands that execute arbitrary strings.
|
|
109
|
+
# These bypass regex-based command blocking because the real command is
|
|
110
|
+
# hidden inside a string argument. Classified as T2 (requires approval)
|
|
111
|
+
# so the user sees what will actually run.
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Optional prefix commands that can wrap any shell invocation.
|
|
114
|
+
# nohup, sudo, env, nice, etc. — the regex allows zero or more of these
|
|
115
|
+
# before the real interpreter token so "nohup bash -c ..." is still caught.
|
|
116
|
+
_WRAPPER_PREFIX = r"(?:(?:nohup|sudo|env|nice|ionice|setsid|strace|ltrace|time)\s+)*"
|
|
117
|
+
|
|
118
|
+
INDIRECT_EXEC_PATTERNS = [
|
|
119
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"bash\s+-c\s+", re.IGNORECASE),
|
|
120
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"sh\s+-c\s+", re.IGNORECASE),
|
|
121
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"zsh\s+-c\s+", re.IGNORECASE),
|
|
122
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"dash\s+-c\s+", re.IGNORECASE),
|
|
123
|
+
re.compile(r"^\s*eval\s+", re.IGNORECASE),
|
|
124
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"python3?\s+-c\s+", re.IGNORECASE),
|
|
125
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"node\s+-e\s+", re.IGNORECASE),
|
|
126
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"perl\s+-e\s+", re.IGNORECASE),
|
|
127
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"ruby\s+-e\s+", re.IGNORECASE),
|
|
128
|
+
# Process substitution and heredoc piped to shell
|
|
129
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"bash\s+<\(", re.IGNORECASE),
|
|
130
|
+
re.compile(r"^" + _WRAPPER_PREFIX + r"sh\s+<\(", re.IGNORECASE),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
class BashValidator:
|
|
134
|
+
"""Validator for Bash tool invocations.
|
|
135
|
+
|
|
136
|
+
Implements a 5-phase pipeline: unwrap -> decompose -> classify ->
|
|
137
|
+
composition -> aggregate. See module docstring for phase details.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self):
|
|
141
|
+
"""Initialize validator with parser, unwrapper, and decomposer."""
|
|
142
|
+
self.shell_parser = get_shell_parser()
|
|
143
|
+
self._unwrapper = ShellUnwrapper()
|
|
144
|
+
self._decomposer = StageDecomposer()
|
|
145
|
+
|
|
146
|
+
def _detect_indirect_execution(self, command: str) -> Optional[BashValidationResult]:
|
|
147
|
+
"""Detect indirect execution wrappers that can bypass regex blocking.
|
|
148
|
+
|
|
149
|
+
Commands like 'bash -c "az group delete"' hide the real command inside
|
|
150
|
+
a string. We classify these as T2 (mutative) so they require user
|
|
151
|
+
approval via the nonce workflow, giving the human a chance to inspect
|
|
152
|
+
what will actually run.
|
|
153
|
+
|
|
154
|
+
Returns BashValidationResult if indirect execution detected, else None.
|
|
155
|
+
"""
|
|
156
|
+
for pattern in INDIRECT_EXEC_PATTERNS:
|
|
157
|
+
if pattern.search(command):
|
|
158
|
+
# Also check if the inner payload contains a blocked command.
|
|
159
|
+
# Extract the string argument after the wrapper.
|
|
160
|
+
inner = self._extract_inner_command(command)
|
|
161
|
+
if inner:
|
|
162
|
+
blocked = is_blocked_command(inner)
|
|
163
|
+
if blocked.is_blocked:
|
|
164
|
+
return BashValidationResult(
|
|
165
|
+
allowed=False,
|
|
166
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
167
|
+
reason=(
|
|
168
|
+
f"Indirect execution of blocked command detected: "
|
|
169
|
+
f"{blocked.category} (via wrapper)"
|
|
170
|
+
),
|
|
171
|
+
suggestions=[
|
|
172
|
+
blocked.suggestion or "Run the command directly instead of via a shell wrapper.",
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Not blocked but still indirect — route through approval
|
|
177
|
+
logger.info("Indirect execution detected: %s", command[:80])
|
|
178
|
+
result = detect_mutative_command(command)
|
|
179
|
+
if result.is_mutative:
|
|
180
|
+
return None # Already mutative, will be caught by mutative_verbs
|
|
181
|
+
|
|
182
|
+
# For interpreters with inline code analysis (python3 -c),
|
|
183
|
+
# mutative_verbs.py has dedicated pattern scanning that
|
|
184
|
+
# distinguishes safe code (json.dumps, sys.version) from
|
|
185
|
+
# dangerous code (os.system, subprocess.run). If it classified
|
|
186
|
+
# the inline code as safe, trust that analysis and allow it
|
|
187
|
+
# through without forcing an "ask" dialog.
|
|
188
|
+
from ..security.mutative_verbs import _INLINE_CODE_CLIS
|
|
189
|
+
base_cmd = command.strip().split()[0].rsplit("/", 1)[-1].lower()
|
|
190
|
+
if base_cmd in _INLINE_CODE_CLIS:
|
|
191
|
+
logger.info(
|
|
192
|
+
"Inline code classified as safe by pattern scanner: %s",
|
|
193
|
+
command[:80],
|
|
194
|
+
)
|
|
195
|
+
return None # Safe inline code, proceed to normal validation
|
|
196
|
+
|
|
197
|
+
# Shell wrappers (bash -c, eval, etc.) hide the real command
|
|
198
|
+
# in a string — no dedicated scanner exists. Force "ask" so
|
|
199
|
+
# the user can inspect what will actually run.
|
|
200
|
+
#
|
|
201
|
+
# Inspect the inner command to identify the mutative verb so
|
|
202
|
+
# the user sees a more informative message
|
|
203
|
+
# (e.g. "inner mutative verb 'mv'"). Falls back to generic
|
|
204
|
+
# message when inner has no mutative verb.
|
|
205
|
+
reason_msg = "Indirect execution wrapper detected — requires confirmation"
|
|
206
|
+
if inner:
|
|
207
|
+
inner_result = detect_mutative_command(inner)
|
|
208
|
+
if inner_result.is_mutative and inner_result.verb:
|
|
209
|
+
reason_msg = (
|
|
210
|
+
f"Indirect execution detected: inner mutative verb "
|
|
211
|
+
f"'{inner_result.verb}' — requires confirmation"
|
|
212
|
+
)
|
|
213
|
+
dialog_msg = (
|
|
214
|
+
"Indirect execution detected. The command uses a shell "
|
|
215
|
+
"wrapper (bash -c, eval, etc.) that can bypass "
|
|
216
|
+
"security checks. Please confirm you want to run this."
|
|
217
|
+
)
|
|
218
|
+
hook_block = build_hook_permission_response("ask", dialog_msg)
|
|
219
|
+
return BashValidationResult(
|
|
220
|
+
allowed=False,
|
|
221
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
222
|
+
reason=reason_msg,
|
|
223
|
+
block_response=hook_block,
|
|
224
|
+
)
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def _extract_inner_command(self, command: str) -> Optional[str]:
|
|
228
|
+
"""Extract the inner command from an indirect execution wrapper.
|
|
229
|
+
|
|
230
|
+
E.g., 'bash -c "az group delete --name foo"' → 'az group delete --name foo'
|
|
231
|
+
"""
|
|
232
|
+
# Match: shell -c "..." or shell -c '...'
|
|
233
|
+
match = re.search(r"""-[ce]\s+(['"])(.*?)\1""", command, re.DOTALL)
|
|
234
|
+
if match:
|
|
235
|
+
return match.group(2).strip()
|
|
236
|
+
# Match: shell -c ... (unquoted, take rest of line)
|
|
237
|
+
match = re.search(r"-[ce]\s+(\S+.*)", command)
|
|
238
|
+
if match:
|
|
239
|
+
return match.group(1).strip()
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
def _has_operators(self, command: str) -> bool:
|
|
243
|
+
"""Quick check if command has operators (before parsing).
|
|
244
|
+
|
|
245
|
+
Detects pipes, logical operators, semicolons, redirects, and
|
|
246
|
+
background operators. This is a fast pre-filter — the full
|
|
247
|
+
shell parser handles quote-aware splitting downstream.
|
|
248
|
+
|
|
249
|
+
Note: '>' and '&' are included so commands with redirects or
|
|
250
|
+
background operators reach the compound path, where the
|
|
251
|
+
sanitization layer can strip them.
|
|
252
|
+
"""
|
|
253
|
+
# Fast check for common operators outside quotes
|
|
254
|
+
# This avoids expensive parsing for 70% of commands
|
|
255
|
+
if not any(op in command for op in ['|', '&&', '||', ';', '\n', '>', '&']):
|
|
256
|
+
return False
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
# Regex patterns for operators that can be safely stripped from commands.
|
|
260
|
+
# Applied after quote-masking to avoid false positives.
|
|
261
|
+
_NOHUP_PREFIX_RE = re.compile(r"^\s*nohup\s+")
|
|
262
|
+
_TRAILING_BG_RE = re.compile(r"\s*&\s*$")
|
|
263
|
+
_REDIRECT_RE = re.compile(r"\s*>{1,2}\s*\S+\s*$")
|
|
264
|
+
# Fd duplication (2>&1) is harmless and should NOT be stripped.
|
|
265
|
+
_FD_DUP_RE = re.compile(r"\d+>&\d+")
|
|
266
|
+
|
|
267
|
+
def _try_sanitize_command(self, command: str) -> Optional[BashValidationResult]:
|
|
268
|
+
"""Attempt to strip dangerous operators and return a clean command.
|
|
269
|
+
|
|
270
|
+
Sanitizable patterns (can be stripped without changing semantics):
|
|
271
|
+
- nohup prefix: ``nohup cmd args`` -> ``cmd args``
|
|
272
|
+
- trailing &: ``cmd args &`` -> ``cmd args``
|
|
273
|
+
- trailing redirect: ``cmd args > file`` -> ``cmd args``
|
|
274
|
+
|
|
275
|
+
Non-sanitizable patterns (reject with guidance):
|
|
276
|
+
- Pipes (change data flow between commands)
|
|
277
|
+
- Chaining operators (&&, ||, ;) — use one-command-per-step
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
BashValidationResult with cleaned command via modified_input if
|
|
281
|
+
sanitization succeeded, or a block response if it cannot be cleaned.
|
|
282
|
+
None if no sanitization is needed (command has no dangerous operators).
|
|
283
|
+
"""
|
|
284
|
+
original = command
|
|
285
|
+
cleaned = command
|
|
286
|
+
stripped_parts = []
|
|
287
|
+
|
|
288
|
+
# Strip nohup prefix
|
|
289
|
+
if self._NOHUP_PREFIX_RE.match(cleaned):
|
|
290
|
+
cleaned = self._NOHUP_PREFIX_RE.sub("", cleaned).strip()
|
|
291
|
+
stripped_parts.append("nohup")
|
|
292
|
+
|
|
293
|
+
# Strip trailing & (background) but not && or >&
|
|
294
|
+
# Mask fd duplications first to avoid false matching
|
|
295
|
+
test_str = self._FD_DUP_RE.sub("", cleaned)
|
|
296
|
+
if self._TRAILING_BG_RE.search(test_str):
|
|
297
|
+
cleaned = self._TRAILING_BG_RE.sub("", cleaned).strip()
|
|
298
|
+
stripped_parts.append("&")
|
|
299
|
+
|
|
300
|
+
# Strip trailing redirect (> file or >> file)
|
|
301
|
+
# Only strip if it's at the end of the command
|
|
302
|
+
test_str = self._FD_DUP_RE.sub("", cleaned)
|
|
303
|
+
redirect_match = self._REDIRECT_RE.search(test_str)
|
|
304
|
+
if redirect_match:
|
|
305
|
+
# Find the position in the original cleaned string
|
|
306
|
+
# We need to remove from the redirect operator onward
|
|
307
|
+
pos = cleaned.rfind(">")
|
|
308
|
+
if pos > 0:
|
|
309
|
+
before_redirect = cleaned[:pos].rstrip()
|
|
310
|
+
# Only strip if the > is not inside a flag value like --output=>
|
|
311
|
+
if before_redirect and not before_redirect.endswith("="):
|
|
312
|
+
cleaned = before_redirect
|
|
313
|
+
stripped_parts.append("> redirect")
|
|
314
|
+
|
|
315
|
+
if not stripped_parts:
|
|
316
|
+
return None # Nothing to sanitize
|
|
317
|
+
|
|
318
|
+
if cleaned == original:
|
|
319
|
+
return None # Sanitization didn't change anything
|
|
320
|
+
|
|
321
|
+
logger.info(
|
|
322
|
+
"Command sanitized: stripped [%s] from: %s",
|
|
323
|
+
", ".join(stripped_parts),
|
|
324
|
+
original[:80],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Build the response with the cleaned command via updatedInput
|
|
328
|
+
reason = (
|
|
329
|
+
f"Command sanitized: stripped {', '.join(stripped_parts)}. "
|
|
330
|
+
f"Read the command-execution skill for proper patterns.\n"
|
|
331
|
+
f"Original: {original[:120]}\n"
|
|
332
|
+
f"Cleaned: {cleaned[:120]}"
|
|
333
|
+
)
|
|
334
|
+
hook_response = build_hook_permission_response(
|
|
335
|
+
"allow", reason, updated_input={"command": cleaned}
|
|
336
|
+
)
|
|
337
|
+
# Inject updatedInput into the response for the hook entry point
|
|
338
|
+
hook_response.setdefault("hookSpecificOutput", {})["updatedInput"] = {
|
|
339
|
+
"command": cleaned
|
|
340
|
+
}
|
|
341
|
+
return BashValidationResult(
|
|
342
|
+
allowed=True,
|
|
343
|
+
tier=SecurityTier.T0_READ_ONLY,
|
|
344
|
+
reason=reason,
|
|
345
|
+
modified_input={"command": cleaned},
|
|
346
|
+
block_response=hook_response,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def validate(
|
|
350
|
+
self,
|
|
351
|
+
command: str,
|
|
352
|
+
is_subagent: bool = False,
|
|
353
|
+
session_id: str = "",
|
|
354
|
+
) -> BashValidationResult:
|
|
355
|
+
"""
|
|
356
|
+
Validate a Bash command through the 5-phase pipeline.
|
|
357
|
+
|
|
358
|
+
Phases:
|
|
359
|
+
1. UNWRAP - strip shell wrappers, detect obfuscation
|
|
360
|
+
2. DECOMPOSE - split into operator-linked stages
|
|
361
|
+
3. CLASSIFY - blocked_commands + cloud_pipe + mutative_verbs
|
|
362
|
+
4. COMPOSITION - cross-stage pattern checks (stub for T4)
|
|
363
|
+
5. AGGREGATE - combine results into final verdict
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
command: Command string to validate
|
|
367
|
+
is_subagent: True when running in subagent context
|
|
368
|
+
session_id: Session ID for approval scoping
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
BashValidationResult with validation details
|
|
372
|
+
"""
|
|
373
|
+
if not command or not command.strip():
|
|
374
|
+
return BashValidationResult(
|
|
375
|
+
allowed=False,
|
|
376
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
377
|
+
reason="Empty command not allowed",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
command = command.strip()
|
|
381
|
+
|
|
382
|
+
# ================================================================
|
|
383
|
+
# EARLY NORMALIZATION: Strip AI attribution footers before any
|
|
384
|
+
# other processing. This ensures the same normalized command
|
|
385
|
+
# string is used for blocked-command checks, compound parsing,
|
|
386
|
+
# mutative verb detection, pending approval writes, AND pending
|
|
387
|
+
# approval lookups. Without this, write_pending_approval() and
|
|
388
|
+
# find_pending_for_command() could see different strings on the
|
|
389
|
+
# first attempt vs. retry, causing nonce mismatch loops.
|
|
390
|
+
# ================================================================
|
|
391
|
+
command_was_modified = False
|
|
392
|
+
if self._detect_claude_footers(command):
|
|
393
|
+
command = self._strip_claude_footers(command)
|
|
394
|
+
command_was_modified = True
|
|
395
|
+
logger.info("Auto-stripped Claude Code footer from commit command")
|
|
396
|
+
|
|
397
|
+
# ================================================================
|
|
398
|
+
# PHASE 1: UNWRAP
|
|
399
|
+
# Use ShellUnwrapper to detect and strip shell wrapper layers
|
|
400
|
+
# (bash -c, sh -c, env bash -c, etc.). If the wrapper nesting
|
|
401
|
+
# depth exceeds _OBFUSCATION_DEPTH_LIMIT, permanently block
|
|
402
|
+
# the command as obfuscated.
|
|
403
|
+
#
|
|
404
|
+
# After the unwrapper, _detect_indirect_execution() runs as a
|
|
405
|
+
# fallback for patterns the unwrapper does not cover: eval,
|
|
406
|
+
# python -c, node -e, perl -e, ruby -e, process substitution.
|
|
407
|
+
# ================================================================
|
|
408
|
+
unwrap_result = self._unwrapper.unwrap(command)
|
|
409
|
+
if unwrap_result.depth >= _OBFUSCATION_DEPTH_LIMIT:
|
|
410
|
+
return BashValidationResult(
|
|
411
|
+
allowed=False,
|
|
412
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
413
|
+
reason=(
|
|
414
|
+
f"Obfuscated shell nesting detected: {unwrap_result.depth} "
|
|
415
|
+
f"wrapper layers exceeds limit of {_OBFUSCATION_DEPTH_LIMIT}"
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
indirect_result = self._detect_indirect_execution(command)
|
|
420
|
+
if indirect_result is not None:
|
|
421
|
+
return indirect_result
|
|
422
|
+
|
|
423
|
+
# ================================================================
|
|
424
|
+
# PHASE 2: DECOMPOSE
|
|
425
|
+
# Split the command into operator-linked stages using
|
|
426
|
+
# StageDecomposer. The decomposed result is available for
|
|
427
|
+
# phase 4 (composition rules, T4) and provides operator context
|
|
428
|
+
# that ShellCommandParser.parse() discards.
|
|
429
|
+
# ================================================================
|
|
430
|
+
decomposed = self._decomposer.decompose(command)
|
|
431
|
+
|
|
432
|
+
# ================================================================
|
|
433
|
+
# PHASE 3: CLASSIFY STAGES
|
|
434
|
+
# Apply existing classification logic in priority order:
|
|
435
|
+
# 3a. blocked_commands on full command (exit 2)
|
|
436
|
+
# 3b. blocked_commands on each compound component (exit 2)
|
|
437
|
+
# 3c. Git commit message validation
|
|
438
|
+
# 3d. Smart sanitization (strip nohup, &, redirects)
|
|
439
|
+
# 3e. Cloud pipe/redirect/chain check (corrective deny)
|
|
440
|
+
# 3f. Dispatch to single/compound classification
|
|
441
|
+
# (mutative_verbs, gitops_validator, safe-by-elimination)
|
|
442
|
+
# ================================================================
|
|
443
|
+
|
|
444
|
+
# 3a. Blocked commands check on FULL command (exit 2).
|
|
445
|
+
# This MUST run before any other classifier to ensure permanently
|
|
446
|
+
# blocked commands (kubectl delete namespace, etc.) are caught
|
|
447
|
+
# with a reliable exit 2.
|
|
448
|
+
blocked_result = is_blocked_command(command)
|
|
449
|
+
if blocked_result.is_blocked:
|
|
450
|
+
return BashValidationResult(
|
|
451
|
+
allowed=False,
|
|
452
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
453
|
+
reason=f"Command blocked by security policy: {blocked_result.category}",
|
|
454
|
+
suggestions=[blocked_result.suggestion] if blocked_result.suggestion else [],
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# 3b. Parse compound commands and check each component against the
|
|
458
|
+
# deny list. Uses ShellCommandParser (not StageDecomposer) for
|
|
459
|
+
# backward compat — the decomposed stages are used in phase 4.
|
|
460
|
+
has_operators = self._has_operators(command)
|
|
461
|
+
parsed_components = None
|
|
462
|
+
if has_operators:
|
|
463
|
+
parsed_components = self.shell_parser.parse(command)
|
|
464
|
+
# Check each component against the deny list.
|
|
465
|
+
# This catches "ls && kubectl delete namespace prod" early.
|
|
466
|
+
for component in parsed_components:
|
|
467
|
+
comp_blocked = is_blocked_command(component.strip())
|
|
468
|
+
if comp_blocked.is_blocked:
|
|
469
|
+
return BashValidationResult(
|
|
470
|
+
allowed=False,
|
|
471
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
472
|
+
reason=f"Command blocked by security policy: {comp_blocked.category}",
|
|
473
|
+
suggestions=[comp_blocked.suggestion] if comp_blocked.suggestion else [],
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# 3c. Validate git commit messages (on the potentially cleaned command).
|
|
477
|
+
if "git commit" in command and "-m" in command:
|
|
478
|
+
commit_validation = self._validate_commit_message(command)
|
|
479
|
+
if not commit_validation.allowed:
|
|
480
|
+
return commit_validation
|
|
481
|
+
|
|
482
|
+
# 3d. Smart sanitization: strip nohup, trailing &, trailing redirect.
|
|
483
|
+
sanitized = self._try_sanitize_command(command)
|
|
484
|
+
if sanitized is not None:
|
|
485
|
+
if sanitized.allowed:
|
|
486
|
+
return sanitized
|
|
487
|
+
else:
|
|
488
|
+
return sanitized
|
|
489
|
+
|
|
490
|
+
# 3e. Cloud pipe/redirect/chaining check.
|
|
491
|
+
pipe_block = validate_cloud_pipe(command)
|
|
492
|
+
if pipe_block is not None:
|
|
493
|
+
return BashValidationResult(
|
|
494
|
+
allowed=False,
|
|
495
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
496
|
+
reason=pipe_block["hookSpecificOutput"]["permissionDecisionReason"],
|
|
497
|
+
suggestions=[],
|
|
498
|
+
modified_input=None,
|
|
499
|
+
block_response=pipe_block,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# ================================================================
|
|
503
|
+
# PHASE 4: CHECK COMPOSITION
|
|
504
|
+
# Cross-stage composition rules detect dangerous pipe patterns:
|
|
505
|
+
# - Exfiltration: sensitive_read | network_write
|
|
506
|
+
# - RCE: network_read | exec_sink
|
|
507
|
+
# - Obfuscated: decode | exec_sink
|
|
508
|
+
# - File-to-exec: file_read | exec_sink (escalate)
|
|
509
|
+
# Only pipe-connected stages are checked; &&/; are independent.
|
|
510
|
+
# ================================================================
|
|
511
|
+
_composition_result = self._phase4_check_composition(decomposed)
|
|
512
|
+
if _composition_result is not None:
|
|
513
|
+
return _composition_result
|
|
514
|
+
|
|
515
|
+
# ================================================================
|
|
516
|
+
# PHASE 5: AGGREGATE
|
|
517
|
+
# 3f. Dispatch to per-stage classifiers (single or compound)
|
|
518
|
+
# and combine into the final BashValidationResult.
|
|
519
|
+
# ================================================================
|
|
520
|
+
if not has_operators:
|
|
521
|
+
result = self._validate_single_command(
|
|
522
|
+
command, is_subagent=is_subagent, session_id=session_id,
|
|
523
|
+
)
|
|
524
|
+
elif parsed_components is not None and len(parsed_components) > 1:
|
|
525
|
+
result = self._validate_compound_command(
|
|
526
|
+
parsed_components, is_subagent=is_subagent, session_id=session_id,
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
result = self._validate_single_command(
|
|
530
|
+
command, is_subagent=is_subagent, session_id=session_id,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Attach cleaned command for hook to emit via updatedInput.
|
|
534
|
+
# Set regardless of result.allowed so the ask path can include it too.
|
|
535
|
+
if command_was_modified:
|
|
536
|
+
result.modified_input = {"command": command}
|
|
537
|
+
# If the result is an "ask" block_response, inject updatedInput
|
|
538
|
+
# so the modification survives the native permission dialog.
|
|
539
|
+
if (
|
|
540
|
+
result.block_response is not None
|
|
541
|
+
and result.block_response.get("hookSpecificOutput", {}).get(
|
|
542
|
+
"permissionDecision"
|
|
543
|
+
) == "ask"
|
|
544
|
+
):
|
|
545
|
+
result.block_response["hookSpecificOutput"]["updatedInput"] = {
|
|
546
|
+
"command": command
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return result
|
|
550
|
+
|
|
551
|
+
def _validate_single_command(
|
|
552
|
+
self,
|
|
553
|
+
command: str,
|
|
554
|
+
is_subagent: bool = False,
|
|
555
|
+
session_id: str = "",
|
|
556
|
+
) -> BashValidationResult:
|
|
557
|
+
"""Validate a single command (no operators).
|
|
558
|
+
|
|
559
|
+
Simplified pipeline:
|
|
560
|
+
0. Indirect execution detection (for compound command components)
|
|
561
|
+
1. Mutative verb detection -> block with nonce or allow with grant
|
|
562
|
+
2. GitOps policy validation (for kubectl/helm/flux)
|
|
563
|
+
3. Everything else -> SAFE by elimination
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
command: The command to validate.
|
|
567
|
+
is_subagent: True when running in subagent context (generates
|
|
568
|
+
approval_id + deny). False for orchestrator (returns ask).
|
|
569
|
+
session_id: Session ID for pending approval scoping.
|
|
570
|
+
|
|
571
|
+
Note: is_blocked_command() is NOT called here because validate()
|
|
572
|
+
already checks the full command AND each compound component against
|
|
573
|
+
the deny list before dispatching to this method.
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
# Indirect execution check for compound command components.
|
|
577
|
+
# When validate() splits "cd /tmp && python3 -c '...'" into parts,
|
|
578
|
+
# the python3 -c component needs the same indirect execution gate
|
|
579
|
+
# that the full command gets in validate().
|
|
580
|
+
indirect_result = self._detect_indirect_execution(command)
|
|
581
|
+
if indirect_result is not None:
|
|
582
|
+
return indirect_result
|
|
583
|
+
|
|
584
|
+
# Mutative verb detection
|
|
585
|
+
result = detect_mutative_command(command)
|
|
586
|
+
if result.is_mutative:
|
|
587
|
+
# Check for an active approval grant before blocking.
|
|
588
|
+
grant = check_approval_grant(command, session_id=session_id)
|
|
589
|
+
if grant is not None:
|
|
590
|
+
if grant.confirmed:
|
|
591
|
+
# Already confirmed and consumed -- should not reach
|
|
592
|
+
# here (single-use). But if it does, allow through.
|
|
593
|
+
logger.info(
|
|
594
|
+
"T3 command allowed via confirmed grant: %s (scope='%s')",
|
|
595
|
+
command[:80], grant.approved_scope,
|
|
596
|
+
)
|
|
597
|
+
return BashValidationResult(
|
|
598
|
+
allowed=True,
|
|
599
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
600
|
+
reason="Grant confirmed",
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
# Grant exists, not yet confirmed -- GAIA approved,
|
|
604
|
+
# let it through. PostToolUse will confirm and consume
|
|
605
|
+
# the grant after successful execution.
|
|
606
|
+
logger.info(
|
|
607
|
+
"T3 command passthrough via active grant: %s (scope='%s')",
|
|
608
|
+
command[:80], grant.approved_scope,
|
|
609
|
+
)
|
|
610
|
+
return BashValidationResult(
|
|
611
|
+
allowed=True,
|
|
612
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
613
|
+
reason="Grant active, pending confirmation",
|
|
614
|
+
)
|
|
615
|
+
else:
|
|
616
|
+
if is_subagent:
|
|
617
|
+
# Subagent context: check for an existing pending
|
|
618
|
+
# approval first (retry scenario). If found, reuse
|
|
619
|
+
# the same nonce to prevent infinite approval_id
|
|
620
|
+
# generation loops while the user reviews.
|
|
621
|
+
existing_nonce = find_pending_for_command(
|
|
622
|
+
session_id or "", command,
|
|
623
|
+
)
|
|
624
|
+
if existing_nonce:
|
|
625
|
+
approval_id = existing_nonce
|
|
626
|
+
logger.info(
|
|
627
|
+
"Reusing pending approval_id=%s for retry: %s",
|
|
628
|
+
approval_id, command[:80],
|
|
629
|
+
)
|
|
630
|
+
reason = (
|
|
631
|
+
f"[T3_BLOCKED] This command requires user approval.\n"
|
|
632
|
+
f"Do NOT retry this command. Report APPROVAL_REQUEST with this approval_id in your json:contract.\n"
|
|
633
|
+
f"Command: {command}\n"
|
|
634
|
+
f"Verb: '{result.verb}' ({result.category})\n"
|
|
635
|
+
f"approval_id: {approval_id}"
|
|
636
|
+
)
|
|
637
|
+
hook_deny = build_hook_permission_response("deny", reason)
|
|
638
|
+
return BashValidationResult(
|
|
639
|
+
allowed=False,
|
|
640
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
641
|
+
reason=f"T3 {result.category.lower()} command: {result.reason}",
|
|
642
|
+
block_response=hook_deny,
|
|
643
|
+
)
|
|
644
|
+
# No existing pending -- generate a new nonce.
|
|
645
|
+
# The ElicitationResult hook will activate the
|
|
646
|
+
# grant when the user approves via AskUserQuestion.
|
|
647
|
+
approval_id = generate_nonce()
|
|
648
|
+
pending_path = write_pending_approval(
|
|
649
|
+
nonce=approval_id,
|
|
650
|
+
command=command,
|
|
651
|
+
danger_verb=result.verb,
|
|
652
|
+
danger_category=result.category,
|
|
653
|
+
session_id=session_id or None,
|
|
654
|
+
cwd=os.getcwd(),
|
|
655
|
+
)
|
|
656
|
+
if pending_path is None:
|
|
657
|
+
# Persistence failure — fall back to ask
|
|
658
|
+
logger.warning(
|
|
659
|
+
"Failed to persist pending approval for subagent; "
|
|
660
|
+
"falling back to ask: %s",
|
|
661
|
+
command[:80],
|
|
662
|
+
)
|
|
663
|
+
reason = build_pending_approval_unavailable_message()
|
|
664
|
+
hook_ask = build_hook_permission_response("ask", reason)
|
|
665
|
+
return BashValidationResult(
|
|
666
|
+
allowed=False,
|
|
667
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
668
|
+
reason="Pending approval persistence failed",
|
|
669
|
+
block_response=hook_ask,
|
|
670
|
+
)
|
|
671
|
+
reason = (
|
|
672
|
+
f"[T3_BLOCKED] This command requires user approval.\n"
|
|
673
|
+
f"Do NOT retry this command. Report APPROVAL_REQUEST with this approval_id in your json:contract.\n"
|
|
674
|
+
f"Command: {command}\n"
|
|
675
|
+
f"Verb: '{result.verb}' ({result.category})\n"
|
|
676
|
+
f"approval_id: {approval_id}"
|
|
677
|
+
)
|
|
678
|
+
hook_deny = build_hook_permission_response("deny", reason)
|
|
679
|
+
return BashValidationResult(
|
|
680
|
+
allowed=False,
|
|
681
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
682
|
+
reason=f"T3 {result.category.lower()} command: {result.reason}",
|
|
683
|
+
block_response=hook_deny,
|
|
684
|
+
)
|
|
685
|
+
else:
|
|
686
|
+
# Orchestrator context: route through native 'ask' dialog.
|
|
687
|
+
# The user sees the native permission prompt and approves
|
|
688
|
+
# directly. No approval_id is generated.
|
|
689
|
+
reason = (
|
|
690
|
+
f"[T3_APPROVAL_REQUIRED] {result.category} operation detected.\n"
|
|
691
|
+
f"Command: {command}\n"
|
|
692
|
+
f"Verb: '{result.verb}' ({result.category})\n"
|
|
693
|
+
f"Reason: {result.reason}"
|
|
694
|
+
)
|
|
695
|
+
hook_ask = build_hook_permission_response("ask", reason)
|
|
696
|
+
return BashValidationResult(
|
|
697
|
+
allowed=False,
|
|
698
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
699
|
+
reason=f"Dangerous {result.category.lower()} command: {result.reason}",
|
|
700
|
+
block_response=hook_ask,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Check GitOps policy for kubectl/helm/flux commands
|
|
704
|
+
if any(keyword in command for keyword in ("kubectl", "helm", "flux")):
|
|
705
|
+
gitops_result = validate_gitops_workflow(command)
|
|
706
|
+
if not gitops_result.allowed:
|
|
707
|
+
return BashValidationResult(
|
|
708
|
+
allowed=False,
|
|
709
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
710
|
+
reason=f"GitOps policy violation: {gitops_result.reason}",
|
|
711
|
+
suggestions=gitops_result.suggestions,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
# Flag-dependent classification (sed -i, find -exec, tar -x, etc.)
|
|
715
|
+
# This supplements mutative_verbs -- it catches flag-dependent mutations
|
|
716
|
+
# that verb-based detection misses (e.g. "sed" has no mutative verb, but
|
|
717
|
+
# "sed -i" is mutative). Runs after blocked_commands and mutative_verbs
|
|
718
|
+
# to avoid double-classification.
|
|
719
|
+
#
|
|
720
|
+
# Git commands are EXCLUDED from the MUTATIVE path here because
|
|
721
|
+
# detect_mutative_command() already has deliberate git handling. If it
|
|
722
|
+
# chose not to block a git command, that decision should be respected.
|
|
723
|
+
# Git BLOCKED results still fire as a safety net (force push, etc.).
|
|
724
|
+
flag_result = classify_by_flags(command)
|
|
725
|
+
if flag_result is not None:
|
|
726
|
+
if flag_result.outcome == FLAG_BLOCKED:
|
|
727
|
+
return BashValidationResult(
|
|
728
|
+
allowed=False,
|
|
729
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
730
|
+
reason=f"Command blocked by flag classifier: {flag_result.reason}",
|
|
731
|
+
suggestions=[],
|
|
732
|
+
)
|
|
733
|
+
if flag_result.outcome == FLAG_MUTATIVE:
|
|
734
|
+
# Skip git commands -- mutative_verbs already handles them.
|
|
735
|
+
if flag_result.command_family.startswith("git_"):
|
|
736
|
+
pass # Fall through to safe-by-elimination
|
|
737
|
+
else:
|
|
738
|
+
reason = (
|
|
739
|
+
f"[T3_APPROVAL_REQUIRED] Flag-dependent mutation detected.\n"
|
|
740
|
+
f"Command: {command}\n"
|
|
741
|
+
f"Flag: {flag_result.matched_pattern} ({flag_result.command_family})\n"
|
|
742
|
+
f"Reason: {flag_result.reason}"
|
|
743
|
+
)
|
|
744
|
+
hook_ask = build_hook_permission_response("ask", reason)
|
|
745
|
+
return BashValidationResult(
|
|
746
|
+
allowed=False,
|
|
747
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
748
|
+
reason=f"Mutative flag detected: {flag_result.reason}",
|
|
749
|
+
block_response=hook_ask,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
# Not blocked, not mutative -> SAFE by elimination
|
|
753
|
+
return BashValidationResult(
|
|
754
|
+
allowed=True,
|
|
755
|
+
tier=SecurityTier.T0_READ_ONLY,
|
|
756
|
+
reason="Safe by elimination (not blocked, not mutative)",
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def _validate_compound_command(
|
|
760
|
+
self,
|
|
761
|
+
components: List[str],
|
|
762
|
+
is_subagent: bool = False,
|
|
763
|
+
session_id: str = "",
|
|
764
|
+
) -> BashValidationResult:
|
|
765
|
+
"""Validate a compound command (multiple components)."""
|
|
766
|
+
logger.info(f"Compound command detected with {len(components)} components")
|
|
767
|
+
|
|
768
|
+
component_results: List[BashValidationResult] = []
|
|
769
|
+
for i, component in enumerate(components, 1):
|
|
770
|
+
result = self._validate_single_command(
|
|
771
|
+
component, is_subagent=is_subagent, session_id=session_id,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
if not result.allowed:
|
|
775
|
+
return BashValidationResult(
|
|
776
|
+
allowed=False,
|
|
777
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
778
|
+
reason=(
|
|
779
|
+
f"Compound command blocked: component {i}/{len(components)} "
|
|
780
|
+
f"'{component[:50]}' is not allowed\n"
|
|
781
|
+
f"Reason: {result.reason}"
|
|
782
|
+
),
|
|
783
|
+
suggestions=result.suggestions,
|
|
784
|
+
block_response=result.block_response,
|
|
785
|
+
)
|
|
786
|
+
component_results.append(result)
|
|
787
|
+
|
|
788
|
+
# All components validated -- derive highest tier from results already
|
|
789
|
+
# computed by _validate_single_command (avoids redundant classification).
|
|
790
|
+
tier_order = ["T0", "T1", "T2", "T3"]
|
|
791
|
+
highest_tier = max(
|
|
792
|
+
(r.tier for r in component_results),
|
|
793
|
+
key=lambda t: tier_order.index(t.value),
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
return BashValidationResult(
|
|
797
|
+
allowed=True,
|
|
798
|
+
tier=highest_tier,
|
|
799
|
+
reason=f"All {len(components)} components validated",
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
def _phase4_check_composition(
|
|
803
|
+
self, decomposed: DecomposedCommand,
|
|
804
|
+
) -> Optional[BashValidationResult]:
|
|
805
|
+
"""Check cross-stage composition patterns (Phase 4).
|
|
806
|
+
|
|
807
|
+
Detects dangerous pipe compositions:
|
|
808
|
+
- Exfiltration: sensitive_read | network_write -> permanent block
|
|
809
|
+
- RCE: network_read | exec_sink -> permanent block
|
|
810
|
+
- Obfuscated exec: decode | exec_sink -> permanent block
|
|
811
|
+
- File-to-exec: file_read | exec_sink -> escalate (ask)
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
decomposed: Output from StageDecomposer.decompose().
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
BashValidationResult if a composition rule fires, else None.
|
|
818
|
+
"""
|
|
819
|
+
if not decomposed.stages or len(decomposed.stages) < 2:
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
# Check whether any stages are pipe-connected.
|
|
823
|
+
has_pipe = any(s.operator == "|" for s in decomposed.stages)
|
|
824
|
+
if not has_pipe:
|
|
825
|
+
return None
|
|
826
|
+
|
|
827
|
+
# Build classified composition stages and check rules.
|
|
828
|
+
comp_stages = build_composition_stages(decomposed.stages)
|
|
829
|
+
result = check_composition(comp_stages)
|
|
830
|
+
|
|
831
|
+
if result.decision == CompositionDecision.BLOCK:
|
|
832
|
+
return BashValidationResult(
|
|
833
|
+
allowed=False,
|
|
834
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
835
|
+
reason=f"Dangerous pipe composition blocked: {result.reason}",
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
if result.decision == CompositionDecision.ESCALATE:
|
|
839
|
+
reason = (
|
|
840
|
+
f"[T3_APPROVAL_REQUIRED] Potentially dangerous pipe composition.\n"
|
|
841
|
+
f"Pattern: {result.pattern}\n"
|
|
842
|
+
f"Reason: {result.reason}"
|
|
843
|
+
)
|
|
844
|
+
hook_ask = build_hook_permission_response("ask", reason)
|
|
845
|
+
return BashValidationResult(
|
|
846
|
+
allowed=False,
|
|
847
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
848
|
+
reason=f"Pipe composition requires approval: {result.reason}",
|
|
849
|
+
block_response=hook_ask,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# No composition rule fired — continue to Phase 5.
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
def _detect_claude_footers(self, command: str) -> bool:
|
|
856
|
+
"""Detect Claude Code attribution footers in command."""
|
|
857
|
+
for pattern in FORBIDDEN_FOOTER_PATTERNS:
|
|
858
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
859
|
+
return True
|
|
860
|
+
return False
|
|
861
|
+
|
|
862
|
+
def _strip_claude_footers(self, command: str) -> str:
|
|
863
|
+
"""
|
|
864
|
+
Strip Claude Code attribution footers from a command.
|
|
865
|
+
|
|
866
|
+
Removes full lines matching forbidden footer patterns.
|
|
867
|
+
Works on raw command string regardless of quoting/HEREDOC format.
|
|
868
|
+
Preserves trailing quote/paren characters that close the commit
|
|
869
|
+
message (e.g., the closing " in -m "...footer").
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
command: Raw command string
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
Command with footer lines removed
|
|
876
|
+
"""
|
|
877
|
+
# Remove full lines that contain AI attribution patterns.
|
|
878
|
+
# Each pattern matches the newline + footer content, then uses a
|
|
879
|
+
# lookahead to stop before any trailing quote/paren/bracket
|
|
880
|
+
# sequence that closes the command structure. The captured group
|
|
881
|
+
# is replaced with empty string, leaving the closing chars intact.
|
|
882
|
+
footer_line_patterns = [
|
|
883
|
+
r'\n\s*Co-[Aa]uthored-[Bb]y:\s+(?:Claude|GitHub Copilot|aider|Windsurf|Cursor|Codex|Gemini)[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
884
|
+
r'\n\s*Generated with\s+\[?Claude Code\]?[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
885
|
+
r'\n\s*🤖\s*Generated with[^\n]*?(?=["\')\]]*(?:\n|$))',
|
|
886
|
+
]
|
|
887
|
+
for pattern in footer_line_patterns:
|
|
888
|
+
command = re.sub(pattern, '', command, flags=re.IGNORECASE)
|
|
889
|
+
|
|
890
|
+
# Clean up trailing whitespace inside quotes/heredoc
|
|
891
|
+
# Collapse 3+ consecutive newlines to 2
|
|
892
|
+
command = re.sub(r'\n{3,}', '\n\n', command)
|
|
893
|
+
|
|
894
|
+
return command
|
|
895
|
+
|
|
896
|
+
def _validate_commit_message(self, command: str) -> BashValidationResult:
|
|
897
|
+
"""
|
|
898
|
+
Validate git commit message using commit_validator.
|
|
899
|
+
|
|
900
|
+
Args:
|
|
901
|
+
command: Git commit command to validate
|
|
902
|
+
|
|
903
|
+
Returns:
|
|
904
|
+
BashValidationResult with validation status
|
|
905
|
+
"""
|
|
906
|
+
# Extract commit message from command
|
|
907
|
+
# Handles both: git commit -m "message" and git commit -m "$(cat <<'EOF'...)"
|
|
908
|
+
message = self._extract_commit_message(command)
|
|
909
|
+
|
|
910
|
+
if not message:
|
|
911
|
+
# Could not extract message - let it pass, git will handle it
|
|
912
|
+
return BashValidationResult(
|
|
913
|
+
allowed=True,
|
|
914
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
915
|
+
reason="Could not extract commit message for validation"
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
# Import validator (lazy import to avoid startup cost)
|
|
919
|
+
try:
|
|
920
|
+
import sys
|
|
921
|
+
from pathlib import Path
|
|
922
|
+
|
|
923
|
+
# Import from sibling module (hooks/modules/validation)
|
|
924
|
+
from ..validation.commit_validator import validate_commit_message
|
|
925
|
+
|
|
926
|
+
# Validate message
|
|
927
|
+
validation = validate_commit_message(message)
|
|
928
|
+
|
|
929
|
+
if not validation.valid:
|
|
930
|
+
# Build suggestions from errors
|
|
931
|
+
suggestions = []
|
|
932
|
+
for error in validation.errors:
|
|
933
|
+
suggestions.append(f"{error['type']}: {error['fix']}")
|
|
934
|
+
|
|
935
|
+
return BashValidationResult(
|
|
936
|
+
allowed=False,
|
|
937
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
938
|
+
reason=f"Commit message validation failed: {validation.errors[0]['message']}",
|
|
939
|
+
suggestions=suggestions[:3] # Limit to 3 suggestions
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
return BashValidationResult(
|
|
943
|
+
allowed=True,
|
|
944
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
945
|
+
reason="Commit message validated successfully"
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
except Exception as e:
|
|
949
|
+
logger.warning(f"Failed to validate commit message: {e}")
|
|
950
|
+
# If validation fails, allow the command (don't block on validator failure)
|
|
951
|
+
return BashValidationResult(
|
|
952
|
+
allowed=True,
|
|
953
|
+
tier=SecurityTier.T2_DRY_RUN,
|
|
954
|
+
reason=f"Commit validation skipped (validator error: {e})"
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
def _extract_commit_message(self, command: str) -> Optional[str]:
|
|
958
|
+
"""
|
|
959
|
+
Extract commit message from git commit command.
|
|
960
|
+
|
|
961
|
+
Handles formats:
|
|
962
|
+
- git commit -m "message"
|
|
963
|
+
- git commit -m 'message'
|
|
964
|
+
- git commit -m "$(cat <<'EOF'\nmessage\nEOF\n)"
|
|
965
|
+
- git commit -m "$(cat <<EOF\nmessage\nEOF\n)"
|
|
966
|
+
|
|
967
|
+
Returns:
|
|
968
|
+
Extracted message or None if cannot extract
|
|
969
|
+
"""
|
|
970
|
+
# Level 1: HEREDOC pattern (most common in Claude Code)
|
|
971
|
+
# Handles: <<'EOF', <<EOF, <<"EOF" with flexible whitespace
|
|
972
|
+
if "<<" in command:
|
|
973
|
+
heredoc_match = re.search(
|
|
974
|
+
r"<<['\"]?EOF['\"]?\s*\n(.*?)\n\s*EOF",
|
|
975
|
+
command, re.DOTALL
|
|
976
|
+
)
|
|
977
|
+
if heredoc_match:
|
|
978
|
+
return heredoc_match.group(1).strip()
|
|
979
|
+
|
|
980
|
+
# Level 2: Simple -m "message" or -m 'message' (non-heredoc)
|
|
981
|
+
match = re.search(r'-m\s+(["\'])(.*?)\1', command, re.DOTALL)
|
|
982
|
+
if match:
|
|
983
|
+
msg = match.group(2)
|
|
984
|
+
# Skip if it's a $(cat... wrapper — heredoc parse failed above
|
|
985
|
+
if msg.lstrip().startswith("$(cat"):
|
|
986
|
+
return None
|
|
987
|
+
return msg.strip()
|
|
988
|
+
|
|
989
|
+
return None
|
|
990
|
+
|
|
991
|
+
def validate_bash_command(
|
|
992
|
+
command: str,
|
|
993
|
+
is_subagent: bool = False,
|
|
994
|
+
session_id: str = "",
|
|
995
|
+
) -> BashValidationResult:
|
|
996
|
+
"""
|
|
997
|
+
Validate a Bash command (convenience function).
|
|
998
|
+
|
|
999
|
+
Args:
|
|
1000
|
+
command: Command to validate
|
|
1001
|
+
is_subagent: True when running in subagent context
|
|
1002
|
+
session_id: Session ID for approval scoping
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
BashValidationResult
|
|
1006
|
+
"""
|
|
1007
|
+
validator = BashValidator()
|
|
1008
|
+
return validator.validate(command, is_subagent=is_subagent, session_id=session_id)
|