@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,850 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mutative verb detector for shell commands.
|
|
3
|
+
|
|
4
|
+
Simplified three-category pipeline:
|
|
5
|
+
blocked_commands.py -> BLOCKED (exit 2, permanently denied)
|
|
6
|
+
mutative_verbs.py -> MUTATIVE (needs user approval via nonce)
|
|
7
|
+
everything else -> SAFE (auto-approved by elimination)
|
|
8
|
+
|
|
9
|
+
This module detects MUTATIVE commands by scanning tokens for known verb patterns,
|
|
10
|
+
dangerous flags, and command aliases. If a command is not blocked and not mutative,
|
|
11
|
+
it is safe by elimination -- no allowlist needed.
|
|
12
|
+
|
|
13
|
+
Categories retained internally for verb classification:
|
|
14
|
+
- MUTATIVE: ALL state-modifying verbs (approvable via nonce workflow)
|
|
15
|
+
- SIMULATION: plan, diff, preview, template, validate, lint, etc.
|
|
16
|
+
- READ_ONLY: get, list, describe, show, logs, status, etc.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Dict, FrozenSet, List, Tuple
|
|
23
|
+
|
|
24
|
+
from .approval_messages import build_t3_approval_instructions
|
|
25
|
+
from .command_semantics import analyze_command
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from .blocked_commands import is_blocked_command as _is_blocked_command
|
|
29
|
+
except ImportError:
|
|
30
|
+
_is_blocked_command = None
|
|
31
|
+
logging.getLogger(__name__).warning(
|
|
32
|
+
"blocked_commands.is_blocked_command not importable; "
|
|
33
|
+
"inline code Layer 1 (shell extraction) disabled"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ============================================================================
|
|
40
|
+
# Category Constants
|
|
41
|
+
# ============================================================================
|
|
42
|
+
|
|
43
|
+
CATEGORY_MUTATIVE = "MUTATIVE"
|
|
44
|
+
CATEGORY_SIMULATION = "SIMULATION"
|
|
45
|
+
CATEGORY_READ_ONLY = "READ_ONLY"
|
|
46
|
+
CATEGORY_UNKNOWN = "UNKNOWN"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ============================================================================
|
|
50
|
+
# MutativeResult
|
|
51
|
+
# ============================================================================
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class MutativeResult:
|
|
55
|
+
"""Structured result of mutative verb detection.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
is_mutative: Whether the command is classified as mutative (T3).
|
|
59
|
+
category: Verb category: CATEGORY_MUTATIVE, CATEGORY_SIMULATION,
|
|
60
|
+
CATEGORY_READ_ONLY, or CATEGORY_UNKNOWN.
|
|
61
|
+
verb: The extracted verb (e.g., "delete", "apply", "get").
|
|
62
|
+
dangerous_flags: Tuple of flags that escalate the danger level.
|
|
63
|
+
cli_family: Lightweight CLI family hint (e.g., "k8s", "cloud", "git").
|
|
64
|
+
confidence: Confidence level: "high", "medium", or "low".
|
|
65
|
+
reason: Human-readable explanation of the classification.
|
|
66
|
+
"""
|
|
67
|
+
is_mutative: bool = False
|
|
68
|
+
category: str = CATEGORY_UNKNOWN
|
|
69
|
+
verb: str = ""
|
|
70
|
+
dangerous_flags: Tuple[str, ...] = ()
|
|
71
|
+
cli_family: str = "unknown"
|
|
72
|
+
confidence: str = "low"
|
|
73
|
+
reason: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ============================================================================
|
|
78
|
+
# Verb Taxonomy Constants
|
|
79
|
+
# ============================================================================
|
|
80
|
+
|
|
81
|
+
MUTATIVE_VERBS: FrozenSet[str] = frozenset({
|
|
82
|
+
# Creation / addition
|
|
83
|
+
# NOTE: "add" removed -- safe by elimination (e.g., git add is local-only)
|
|
84
|
+
"apply", "create", "put", "insert", "register",
|
|
85
|
+
# Modification
|
|
86
|
+
"update", "patch", "set", "modify", "edit", "configure",
|
|
87
|
+
"replace", "overwrite", "write",
|
|
88
|
+
# Deployment / packaging
|
|
89
|
+
"deploy", "install", "upgrade", "downgrade", "publish", "release", "promote",
|
|
90
|
+
# Scaling
|
|
91
|
+
"scale", "resize", "autoscale",
|
|
92
|
+
# Lifecycle
|
|
93
|
+
"start", "restart", "reboot", "reload", "refresh", "resume",
|
|
94
|
+
"uncordon", "unsuspend", "enable", "disable", "suspend", "pause",
|
|
95
|
+
"stop", "shutdown", "halt", "abort",
|
|
96
|
+
# Movement / transfer
|
|
97
|
+
"move", "rename", "copy", "sync",
|
|
98
|
+
"import", "export", "migrate", "transfer",
|
|
99
|
+
# Attachment
|
|
100
|
+
# NOTE: "link" removed -- false positive in shell variable names (e.g., "for link in ...").
|
|
101
|
+
# The `ln` command is already covered as a COMMAND_ALIAS.
|
|
102
|
+
"attach", "bind", "connect", "mount",
|
|
103
|
+
# Execution
|
|
104
|
+
# NOTE: "run" removed -- safe by elimination (e.g., docker run is common dev workflow)
|
|
105
|
+
"exec", "execute", "invoke", "trigger", "send",
|
|
106
|
+
# Git operations
|
|
107
|
+
# NOTE: "stash" removed -- safe by elimination (local-only operation)
|
|
108
|
+
"commit", "push", "merge", "rebase", "cherry-pick",
|
|
109
|
+
"revert", "rollback",
|
|
110
|
+
# Access control
|
|
111
|
+
"grant", "assign", "revoke",
|
|
112
|
+
# Reconciliation
|
|
113
|
+
"reconcile", "rsync",
|
|
114
|
+
# Deletion / removal (approvable via nonce -- blocked_commands.py catches
|
|
115
|
+
# the truly destructive patterns like "delete namespace", "delete-vpc", etc.)
|
|
116
|
+
"delete", "destroy", "remove", "drop", "purge", "wipe", "clean",
|
|
117
|
+
"truncate", "kill", "terminate", "uninstall", "unpublish",
|
|
118
|
+
"drain", "evict", "cordon", "deregister", "detach",
|
|
119
|
+
"disconnect", "unbind", "reset", "force-delete", "force-remove", "erase",
|
|
120
|
+
# Collaboration (GitHub/GitLab CLI)
|
|
121
|
+
"comment", "label", "annotate", "approve", "close", "reopen", "tag",
|
|
122
|
+
# Helm-specific
|
|
123
|
+
"uninstall",
|
|
124
|
+
# HTTP methods (e.g., glab api -X POST, gh api -X DELETE)
|
|
125
|
+
"post", "put", "patch",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
SIMULATION_VERBS: FrozenSet[str] = frozenset({
|
|
129
|
+
"plan", "diff", "preview", "template", "render", "simulate",
|
|
130
|
+
"test", "check", "verify", "lint", "validate", "fmt", "format", "audit",
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
READ_ONLY_VERBS: FrozenSet[str] = frozenset({
|
|
134
|
+
"get", "list", "describe", "show", "read", "view", "inspect",
|
|
135
|
+
"info", "status", "log", "logs", "tail", "head",
|
|
136
|
+
"search", "find", "query", "scan", "fetch", "download",
|
|
137
|
+
"version", "help", "whoami", "which", "explain",
|
|
138
|
+
"top", "stat", "history", "blame", "tree", "shortlog", "reflog",
|
|
139
|
+
"env", "auth", "config", "cluster-info", "api-resources", "ls",
|
|
140
|
+
# Compound subcommands that look mutative after hyphen-split but are read-only
|
|
141
|
+
"merge-base",
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ============================================================================
|
|
146
|
+
# Compound Read-Only Subcommands
|
|
147
|
+
# ============================================================================
|
|
148
|
+
# Full subcommand tokens that must be matched BEFORE the hyphen-split logic.
|
|
149
|
+
# Without this, "merge-base" would be split to "merge" and flagged as MUTATIVE.
|
|
150
|
+
|
|
151
|
+
COMPOUND_READ_ONLY_SUBCOMMANDS: FrozenSet[str] = frozenset({
|
|
152
|
+
"merge-base",
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ============================================================================
|
|
157
|
+
# Verb+Flag Overrides (mutative verb downgraded to READ_ONLY by a flag)
|
|
158
|
+
# ============================================================================
|
|
159
|
+
# Map of (cli_family, verb) -> frozenset of flag tokens that override to READ_ONLY.
|
|
160
|
+
# Checked AFTER a mutative verb is found but BEFORE returning the MUTATIVE result.
|
|
161
|
+
|
|
162
|
+
VERB_FLAG_READ_ONLY_OVERRIDES: Dict[Tuple[str, str], FrozenSet[str]] = {
|
|
163
|
+
# "git tag -l" / "git tag --list" is listing, not creating/deleting
|
|
164
|
+
("git", "tag"): frozenset({"-l", "--list"}),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ============================================================================
|
|
169
|
+
# Inline Code Detection — Language-Agnostic 3-Layer Approach
|
|
170
|
+
# ============================================================================
|
|
171
|
+
# When the base command is a runtime interpreter with an inline code flag
|
|
172
|
+
# (e.g., python3 -c, node -e, ruby -e, perl -e), scan the code string
|
|
173
|
+
# using three layers instead of verb-matching tokens:
|
|
174
|
+
# Layer 1: Extract string literals → check against blocked_commands
|
|
175
|
+
# Layer 2: Universal dangerous API keyword patterns
|
|
176
|
+
# Layer 3: Heuristic safety classification (length, paths, encoding)
|
|
177
|
+
import re as _re
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# CLI → inline-code flag mapping (Step 1a)
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
_INLINE_CODE_MAP: Dict[str, FrozenSet[str]] = {
|
|
183
|
+
"python": frozenset({"-c"}),
|
|
184
|
+
"python3": frozenset({"-c"}),
|
|
185
|
+
"python3.10": frozenset({"-c"}),
|
|
186
|
+
"python3.11": frozenset({"-c"}),
|
|
187
|
+
"python3.12": frozenset({"-c"}),
|
|
188
|
+
"python3.13": frozenset({"-c"}),
|
|
189
|
+
"node": frozenset({"-e", "--eval"}),
|
|
190
|
+
"ruby": frozenset({"-e"}),
|
|
191
|
+
"perl": frozenset({"-e", "-E"}),
|
|
192
|
+
"php": frozenset({"-r"}),
|
|
193
|
+
"lua": frozenset({"-e"}),
|
|
194
|
+
"rscript": frozenset({"-e"}),
|
|
195
|
+
}
|
|
196
|
+
_INLINE_CODE_CLIS: FrozenSet[str] = frozenset(_INLINE_CODE_MAP.keys())
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Layer 1: Shell command extraction from string literals
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
_STRING_LITERAL_RE = _re.compile(r"""(?:['"])((?:[^'"\\\n]|\\.){3,})(?:['"])""")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _extract_embedded_shell_commands(code: str) -> List[str]:
|
|
205
|
+
"""Extract string literals from inline code that may contain shell commands."""
|
|
206
|
+
return [m.group(1) for m in _STRING_LITERAL_RE.finditer(code)]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Layer 2: Universal dangerous API keyword patterns (category-based)
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
_UNIVERSAL_DANGEROUS_PATTERNS: Tuple[Tuple[_re.Pattern, str, str], ...] = (
|
|
213
|
+
# Category: Process Execution
|
|
214
|
+
(_re.compile(r"\b(child_process|subprocess)\b"), "process-module", "PROCESS_EXECUTION"),
|
|
215
|
+
(_re.compile(r"\b(execSync|execFile|execFileSync)\s*\("), "exec-sync", "PROCESS_EXECUTION"),
|
|
216
|
+
(_re.compile(r"\bos\.(system|popen|exec[lv]?[pe]?)\s*\("), "os-exec", "PROCESS_EXECUTION"),
|
|
217
|
+
(_re.compile(r"\b(system|exec)\s*\("), "system-call", "PROCESS_EXECUTION"),
|
|
218
|
+
(_re.compile(r"\bspawn(Sync)?\s*\("), "spawn-call", "PROCESS_EXECUTION"),
|
|
219
|
+
(_re.compile(r"\bPopen\s*\("), "popen-call", "PROCESS_EXECUTION"),
|
|
220
|
+
(_re.compile(r"`[^`]{3,}`"), "backtick-exec", "PROCESS_EXECUTION"),
|
|
221
|
+
|
|
222
|
+
# Category: File Deletion
|
|
223
|
+
(_re.compile(r"\b(os\.remove|os\.unlink|os\.rmdir)\s*\("), "os-delete", "FILE_DELETION"),
|
|
224
|
+
(_re.compile(r"\b(shutil\.rmtree|shutil\.move)\s*\("), "shutil-delete", "FILE_DELETION"),
|
|
225
|
+
(_re.compile(r"\bfs\.(unlink|rmdir|rm)(Sync)?\s*\("), "fs-delete", "FILE_DELETION"),
|
|
226
|
+
# Also match .unlinkSync( / .rmSync( / .rmdirSync( as method calls (e.g., require('fs').unlinkSync())
|
|
227
|
+
(_re.compile(r"\.(unlink|rmdir|rm)(Sync)?\s*\("), "fs-delete", "FILE_DELETION"),
|
|
228
|
+
(_re.compile(r"\bFile\.(delete|unlink)\s*\("), "file-delete", "FILE_DELETION"),
|
|
229
|
+
(_re.compile(r"\bunlink\s*\("), "unlink-call", "FILE_DELETION"),
|
|
230
|
+
(_re.compile(r"\brmtree\s*\("), "rmtree-call", "FILE_DELETION"),
|
|
231
|
+
(_re.compile(r"\bFileUtils\.rm"), "fileutils-rm", "FILE_DELETION"),
|
|
232
|
+
(_re.compile(r"pathlib\.Path\([^)]*\)\.(unlink|rmdir)"), "pathlib-delete", "FILE_DELETION"),
|
|
233
|
+
|
|
234
|
+
# Category: File Write
|
|
235
|
+
(_re.compile(r"open\s*\([^)]*['\"][wWaA]"), "file-write-open", "FILE_WRITE"),
|
|
236
|
+
(_re.compile(r"\bfs\.writeFile(Sync)?\s*\("), "fs-write", "FILE_WRITE"),
|
|
237
|
+
# Also match .writeFileSync( / .appendFileSync( as method calls
|
|
238
|
+
(_re.compile(r"\.writeFile(Sync)?\s*\("), "fs-write", "FILE_WRITE"),
|
|
239
|
+
(_re.compile(r"\bfs\.appendFile(Sync)?\s*\("), "fs-append", "FILE_WRITE"),
|
|
240
|
+
(_re.compile(r"\.appendFile(Sync)?\s*\("), "fs-append", "FILE_WRITE"),
|
|
241
|
+
(_re.compile(r"\bFile\.(write|open)\b.*['\"][wWaA]"), "file-write-ruby", "FILE_WRITE"),
|
|
242
|
+
(_re.compile(r"\.write\s*\("), "file-write", "FILE_WRITE"),
|
|
243
|
+
(_re.compile(r"pathlib\.Path\([^)]*\)\.(rename|write_)"), "pathlib-write", "FILE_WRITE"),
|
|
244
|
+
|
|
245
|
+
# Category: File System Mutation (os.rename, os.makedirs, shutil.copy)
|
|
246
|
+
(_re.compile(r"\bos\.rename\s*\("), "os-rename", "FILE_MUTATION"),
|
|
247
|
+
(_re.compile(r"\bos\.makedirs?\s*\("), "os-makedirs", "FILE_MUTATION"),
|
|
248
|
+
(_re.compile(r"\bshutil\.copy\s*\("), "shutil-copy", "FILE_MUTATION"),
|
|
249
|
+
|
|
250
|
+
# Category: Network
|
|
251
|
+
(_re.compile(r"\bhttps?://\S+"), "url-literal", "NETWORK"),
|
|
252
|
+
(_re.compile(r"\b(fetch|axios|requests\.get|urllib)\s*\("), "http-call", "NETWORK"),
|
|
253
|
+
(_re.compile(r"\bNet::HTTP\b"), "net-http", "NETWORK"),
|
|
254
|
+
|
|
255
|
+
# Category: Permission Modification
|
|
256
|
+
(_re.compile(r"\bos\.chmod\s*\("), "os-chmod", "PERMISSION_MOD"),
|
|
257
|
+
(_re.compile(r"\bfs\.chmod(Sync)?\s*\("), "fs-chmod", "PERMISSION_MOD"),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Layer 3: Heuristic safety classification
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
_SUSPICIOUS_HEURISTICS: Tuple[Tuple[_re.Pattern, str], ...] = (
|
|
264
|
+
(_re.compile(r"(/etc/|/home/|~/\.ssh|/var/|/usr/|/root/)"), "sensitive-path"),
|
|
265
|
+
(_re.compile(r"\b(base64|b64encode|b64decode|atob|btoa)\b"), "encoding"),
|
|
266
|
+
(_re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"), "ip-address"),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
MAX_SAFE_INLINE_LENGTH = 150
|
|
270
|
+
MAX_NORMAL_INLINE_LENGTH = 500
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ============================================================================
|
|
274
|
+
# Command Aliases (single-token commands that map to a category)
|
|
275
|
+
# ============================================================================
|
|
276
|
+
|
|
277
|
+
# All command aliases are MUTATIVE (approvable via nonce).
|
|
278
|
+
# The truly destructive patterns (rm -rf /, dd of=/dev/sda, mkfs, fdisk) are
|
|
279
|
+
# permanently blocked by blocked_commands.py before the verb detector runs.
|
|
280
|
+
COMMAND_ALIASES: Dict[str, str] = {
|
|
281
|
+
"rm": CATEGORY_MUTATIVE,
|
|
282
|
+
"rmdir": CATEGORY_MUTATIVE,
|
|
283
|
+
"mv": CATEGORY_MUTATIVE,
|
|
284
|
+
"cp": CATEGORY_MUTATIVE,
|
|
285
|
+
"ln": CATEGORY_MUTATIVE,
|
|
286
|
+
"dd": CATEGORY_MUTATIVE,
|
|
287
|
+
"mkfs": CATEGORY_MUTATIVE,
|
|
288
|
+
"fdisk": CATEGORY_MUTATIVE,
|
|
289
|
+
"chmod": CATEGORY_MUTATIVE,
|
|
290
|
+
"chown": CATEGORY_MUTATIVE,
|
|
291
|
+
"chgrp": CATEGORY_MUTATIVE,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ============================================================================
|
|
296
|
+
# Simulation Flags (--dry-run and equivalents)
|
|
297
|
+
# ============================================================================
|
|
298
|
+
|
|
299
|
+
SIMULATION_FLAGS: FrozenSet[str] = frozenset({
|
|
300
|
+
"--dry-run",
|
|
301
|
+
"--dryrun",
|
|
302
|
+
"--dry-run=client",
|
|
303
|
+
"--dry-run=server",
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ============================================================================
|
|
308
|
+
# Dangerous Flags (context-sensitive)
|
|
309
|
+
# ============================================================================
|
|
310
|
+
|
|
311
|
+
DANGEROUS_FLAGS: Dict[str, str] = {
|
|
312
|
+
"--force": "ALWAYS",
|
|
313
|
+
"--no-preserve-root": "ALWAYS",
|
|
314
|
+
"--force-with-lease": "ALWAYS",
|
|
315
|
+
"--prune": "ALWAYS",
|
|
316
|
+
"--cascade": "ALWAYS",
|
|
317
|
+
"--grace-period=0": "ALWAYS",
|
|
318
|
+
"--now": "ALWAYS",
|
|
319
|
+
"-f": "CONTEXT",
|
|
320
|
+
"-r": "CONTEXT",
|
|
321
|
+
"-R": "CONTEXT",
|
|
322
|
+
"-D": "CONTEXT",
|
|
323
|
+
"-M": "CONTEXT",
|
|
324
|
+
"--recursive": "CONTEXT",
|
|
325
|
+
"--delete": "CONTEXT",
|
|
326
|
+
"-rf": "ALWAYS",
|
|
327
|
+
"-fr": "ALWAYS",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# CLIs where -f means --force (not --file or --format)
|
|
331
|
+
F_FLAG_MEANS_FORCE: FrozenSet[str] = frozenset({
|
|
332
|
+
"rm", "cp", "mv", "ln", "docker", "podman",
|
|
333
|
+
"kubectl", "helm", "apt-get", "brew",
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
# CLIs where -r means recursive delete (not --region or --role)
|
|
337
|
+
R_FLAG_MEANS_RECURSIVE_DELETE: FrozenSet[str] = frozenset({
|
|
338
|
+
"rm", "cp", "chmod", "chown", "chgrp", "find",
|
|
339
|
+
"gsutil",
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
# CLIs where -D means force-delete (not -D for other meanings)
|
|
343
|
+
D_FLAG_MEANS_FORCE_DELETE: FrozenSet[str] = frozenset({
|
|
344
|
+
"git",
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
# CLIs where -M means force-move/rename (not -M for other meanings)
|
|
348
|
+
M_FLAG_MEANS_FORCE_MOVE: FrozenSet[str] = frozenset({
|
|
349
|
+
"git",
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
# CLIs where --delete is a destructive flag (not a query filter)
|
|
353
|
+
DELETE_FLAG_IS_DESTRUCTIVE: FrozenSet[str] = frozenset({
|
|
354
|
+
"git", "rsync",
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ============================================================================
|
|
359
|
+
# Lightweight CLI Family Lookup (metadata only, not routing)
|
|
360
|
+
# ============================================================================
|
|
361
|
+
|
|
362
|
+
CLI_FAMILY_LOOKUP: Dict[str, str] = {
|
|
363
|
+
"kubectl": "k8s", "helm": "k8s", "flux": "k8s", "kustomize": "k8s",
|
|
364
|
+
"k9s": "k8s", "kubectx": "k8s", "kubens": "k8s", "stern": "k8s",
|
|
365
|
+
"terraform": "iac", "terragrunt": "iac", "pulumi": "iac", "cdktf": "iac",
|
|
366
|
+
"git": "git",
|
|
367
|
+
"docker": "docker", "podman": "docker",
|
|
368
|
+
"docker-compose": "docker", "podman-compose": "docker",
|
|
369
|
+
"aws": "cloud", "gcloud": "cloud", "gsutil": "cloud", "az": "cloud",
|
|
370
|
+
"eksctl": "cloud", "gh": "cloud", "glab": "cloud",
|
|
371
|
+
"vercel": "cloud", "netlify": "cloud",
|
|
372
|
+
"fly": "cloud", "flyctl": "cloud", "heroku": "cloud",
|
|
373
|
+
"npm": "package", "npx": "package", "pnpm": "package",
|
|
374
|
+
"yarn": "package", "bun": "package", "deno": "package",
|
|
375
|
+
"pip": "package", "pip3": "package", "poetry": "package",
|
|
376
|
+
"pipenv": "package", "uv": "package",
|
|
377
|
+
"apt": "package", "apt-get": "package", "brew": "package",
|
|
378
|
+
"cargo": "package", "go": "package",
|
|
379
|
+
"make": "build", "cmake": "build", "bazel": "build",
|
|
380
|
+
"gradle": "build", "mvn": "build",
|
|
381
|
+
"node": "runtime", "python": "runtime", "python3": "runtime",
|
|
382
|
+
"tsx": "runtime", "ts-node": "runtime",
|
|
383
|
+
"pytest": "linter", "mypy": "linter", "black": "linter",
|
|
384
|
+
"ruff": "linter", "flake8": "linter", "pylint": "linter",
|
|
385
|
+
"systemctl": "system", "service": "system", "supervisorctl": "system",
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ============================================================================
|
|
390
|
+
# Dangerous Flag Scanning
|
|
391
|
+
# ============================================================================
|
|
392
|
+
|
|
393
|
+
def _scan_dangerous_flags(
|
|
394
|
+
tokens: List[str] | tuple,
|
|
395
|
+
cli: str,
|
|
396
|
+
) -> Tuple[str, ...]:
|
|
397
|
+
"""Scan tokens for dangerous flags with context sensitivity.
|
|
398
|
+
|
|
399
|
+
Context rules:
|
|
400
|
+
- "-f" is only dangerous if cli is in F_FLAG_MEANS_FORCE
|
|
401
|
+
- "-r"/"-R" is only dangerous if cli is in R_FLAG_MEANS_RECURSIVE_DELETE
|
|
402
|
+
- "-D" is only dangerous if cli is in D_FLAG_MEANS_FORCE_DELETE
|
|
403
|
+
- "-M" is only dangerous if cli is in M_FLAG_MEANS_FORCE_MOVE
|
|
404
|
+
- "--delete" is only dangerous if cli is in DELETE_FLAG_IS_DESTRUCTIVE
|
|
405
|
+
- Compound flags like "-rf" are always dangerous
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
tokens: Tokenized command.
|
|
409
|
+
cli: CLI tool name.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Tuple of dangerous flag strings found.
|
|
413
|
+
"""
|
|
414
|
+
found: List[str] = []
|
|
415
|
+
|
|
416
|
+
for token in tokens:
|
|
417
|
+
if not token.startswith("-"):
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
# Check exact matches in DANGEROUS_FLAGS
|
|
421
|
+
if token in DANGEROUS_FLAGS:
|
|
422
|
+
flag_type = DANGEROUS_FLAGS[token]
|
|
423
|
+
|
|
424
|
+
if flag_type == "ALWAYS":
|
|
425
|
+
found.append(token)
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
# CONTEXT-sensitive flags
|
|
429
|
+
if token == "-f":
|
|
430
|
+
if cli in F_FLAG_MEANS_FORCE:
|
|
431
|
+
found.append(token)
|
|
432
|
+
elif token in ("-r", "-R"):
|
|
433
|
+
if cli in R_FLAG_MEANS_RECURSIVE_DELETE:
|
|
434
|
+
found.append(token)
|
|
435
|
+
elif token == "-D":
|
|
436
|
+
if cli in D_FLAG_MEANS_FORCE_DELETE:
|
|
437
|
+
found.append(token)
|
|
438
|
+
elif token == "-M":
|
|
439
|
+
if cli in M_FLAG_MEANS_FORCE_MOVE:
|
|
440
|
+
found.append(token)
|
|
441
|
+
elif token == "--delete":
|
|
442
|
+
if cli in DELETE_FLAG_IS_DESTRUCTIVE:
|
|
443
|
+
found.append(token)
|
|
444
|
+
elif token == "--recursive":
|
|
445
|
+
if cli in R_FLAG_MEANS_RECURSIVE_DELETE:
|
|
446
|
+
found.append(token)
|
|
447
|
+
|
|
448
|
+
# Check for compound short flags containing dangerous combos
|
|
449
|
+
# e.g., "-rfi" contains both -r and -f
|
|
450
|
+
elif len(token) > 2 and token[0] == "-" and token[1] != "-":
|
|
451
|
+
flag_chars = token[1:]
|
|
452
|
+
if "r" in flag_chars and "f" in flag_chars:
|
|
453
|
+
found.append(token)
|
|
454
|
+
elif "f" in flag_chars and cli in F_FLAG_MEANS_FORCE:
|
|
455
|
+
found.append(token)
|
|
456
|
+
elif "r" in flag_chars and cli in R_FLAG_MEANS_RECURSIVE_DELETE:
|
|
457
|
+
found.append(token)
|
|
458
|
+
|
|
459
|
+
return tuple(found)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ============================================================================
|
|
463
|
+
# Main Detection Function
|
|
464
|
+
# ============================================================================
|
|
465
|
+
|
|
466
|
+
@functools.lru_cache(maxsize=128)
|
|
467
|
+
def detect_mutative_command(command: str) -> MutativeResult:
|
|
468
|
+
"""Analyze a shell command and return a structured mutative assessment.
|
|
469
|
+
|
|
470
|
+
Simplified algorithm (CLI-agnostic):
|
|
471
|
+
1. Tokenize the command.
|
|
472
|
+
2. COMMAND_ALIASES fast-path.
|
|
473
|
+
3. Simulation flag override: --dry-run anywhere = not mutative.
|
|
474
|
+
4. Scan the first semantic non-flag tokens after the base CLI.
|
|
475
|
+
5. Scan for dangerous flags.
|
|
476
|
+
6. No match: not mutative (safe by elimination).
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
command: Raw shell command string.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
MutativeResult with full classification details.
|
|
483
|
+
"""
|
|
484
|
+
# --- Edge case: empty command ---
|
|
485
|
+
if not command or not command.strip():
|
|
486
|
+
return MutativeResult(
|
|
487
|
+
is_mutative=False,
|
|
488
|
+
category=CATEGORY_UNKNOWN,
|
|
489
|
+
reason="Empty command",
|
|
490
|
+
confidence="high",
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
semantics = analyze_command(command)
|
|
494
|
+
tokens = list(semantics.tokens)
|
|
495
|
+
if not tokens:
|
|
496
|
+
return MutativeResult(
|
|
497
|
+
is_mutative=False,
|
|
498
|
+
category=CATEGORY_UNKNOWN,
|
|
499
|
+
reason="No tokens after parsing",
|
|
500
|
+
confidence="high",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
base_cmd = semantics.base_cmd
|
|
504
|
+
family = CLI_FAMILY_LOOKUP.get(base_cmd, "unknown")
|
|
505
|
+
|
|
506
|
+
# --- Step 1: Command alias fast-path ---
|
|
507
|
+
if base_cmd in COMMAND_ALIASES:
|
|
508
|
+
alias_category = COMMAND_ALIASES[base_cmd]
|
|
509
|
+
dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
|
|
510
|
+
return MutativeResult(
|
|
511
|
+
is_mutative=True,
|
|
512
|
+
category=alias_category,
|
|
513
|
+
verb=base_cmd,
|
|
514
|
+
dangerous_flags=dangerous_flags,
|
|
515
|
+
cli_family=family if family != "unknown" else "system",
|
|
516
|
+
confidence="high",
|
|
517
|
+
reason=f"Command alias '{base_cmd}' is {alias_category.lower()}",
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# --- Step 2: Single-token command (no verb to extract) ---
|
|
521
|
+
if len(tokens) == 1:
|
|
522
|
+
return MutativeResult(
|
|
523
|
+
is_mutative=False,
|
|
524
|
+
category=CATEGORY_UNKNOWN,
|
|
525
|
+
verb=base_cmd,
|
|
526
|
+
cli_family=family,
|
|
527
|
+
confidence="low",
|
|
528
|
+
reason=f"Single-token command '{base_cmd}' with no verb",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# --- Step 3: Simulation flag override ---
|
|
532
|
+
if any(t.lower() in SIMULATION_FLAGS for t in tokens):
|
|
533
|
+
# Find the first non-flag token after base_cmd for the verb
|
|
534
|
+
verb, _ = _find_first_non_flag(semantics.semantic_head_tokens)
|
|
535
|
+
return MutativeResult(
|
|
536
|
+
is_mutative=False,
|
|
537
|
+
category=CATEGORY_SIMULATION,
|
|
538
|
+
verb=verb,
|
|
539
|
+
cli_family=family,
|
|
540
|
+
confidence="high",
|
|
541
|
+
reason=f"Simulation flag detected (command has --dry-run or equivalent)",
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# --- Step 3b: Inline code safety check (python3 -c, node -e, etc.) ---
|
|
545
|
+
# For runtime interpreters with inline code flags, scan the code string
|
|
546
|
+
# using the 3-layer approach instead of verb-matching tokens (which would
|
|
547
|
+
# false-positive on generic keywords like "import", "create", etc.).
|
|
548
|
+
cli_flags = _INLINE_CODE_MAP.get(base_cmd, frozenset())
|
|
549
|
+
if base_cmd in _INLINE_CODE_CLIS and cli_flags & set(semantics.flag_tokens):
|
|
550
|
+
return _check_inline_code(command, base_cmd, family)
|
|
551
|
+
|
|
552
|
+
# --- Step 4: Scan semantic non-flag tokens near the command head ---
|
|
553
|
+
# Priority order: SIMULATION > MUTATIVE > READ_ONLY > ALIASES
|
|
554
|
+
for semantic_index, token in enumerate(semantics.semantic_head_tokens[1:], start=1):
|
|
555
|
+
# Check compound read-only subcommands BEFORE hyphen-split.
|
|
556
|
+
# Without this, "merge-base" would be split to "merge" -> MUTATIVE.
|
|
557
|
+
if token in COMPOUND_READ_ONLY_SUBCOMMANDS:
|
|
558
|
+
return MutativeResult(
|
|
559
|
+
is_mutative=False,
|
|
560
|
+
category=CATEGORY_READ_ONLY,
|
|
561
|
+
verb=token,
|
|
562
|
+
cli_family=family,
|
|
563
|
+
confidence="high",
|
|
564
|
+
reason=f"Compound read-only subcommand '{token}'",
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# Split hyphenated tokens: "delete-stack" -> check "delete"
|
|
568
|
+
candidate = token.split("-", 1)[0] if "-" in token else token
|
|
569
|
+
|
|
570
|
+
# Also check full token for exact matches (e.g., "force-delete")
|
|
571
|
+
full_lower = token
|
|
572
|
+
|
|
573
|
+
# Determine confidence from position
|
|
574
|
+
confidence = "high" if semantic_index <= 2 else "medium"
|
|
575
|
+
|
|
576
|
+
# Check verb taxonomy in priority order
|
|
577
|
+
if candidate in SIMULATION_VERBS or full_lower in SIMULATION_VERBS:
|
|
578
|
+
verb = candidate if candidate in SIMULATION_VERBS else full_lower
|
|
579
|
+
return MutativeResult(
|
|
580
|
+
is_mutative=False,
|
|
581
|
+
category=CATEGORY_SIMULATION,
|
|
582
|
+
verb=verb,
|
|
583
|
+
cli_family=family,
|
|
584
|
+
confidence=confidence,
|
|
585
|
+
reason=f"Simulation verb '{verb}'",
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
if candidate in MUTATIVE_VERBS or full_lower in MUTATIVE_VERBS:
|
|
589
|
+
verb = candidate if candidate in MUTATIVE_VERBS else full_lower
|
|
590
|
+
|
|
591
|
+
# Check verb+flag overrides: some verbs become READ_ONLY with
|
|
592
|
+
# specific flags (e.g., "git tag -l" is listing, not creating).
|
|
593
|
+
override_key = (family, verb)
|
|
594
|
+
if override_key in VERB_FLAG_READ_ONLY_OVERRIDES:
|
|
595
|
+
override_flags = VERB_FLAG_READ_ONLY_OVERRIDES[override_key]
|
|
596
|
+
if override_flags & frozenset(semantics.flag_tokens):
|
|
597
|
+
return MutativeResult(
|
|
598
|
+
is_mutative=False,
|
|
599
|
+
category=CATEGORY_READ_ONLY,
|
|
600
|
+
verb=verb,
|
|
601
|
+
cli_family=family,
|
|
602
|
+
confidence="high",
|
|
603
|
+
reason=f"Verb '{verb}' overridden to read-only by flag",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
|
|
607
|
+
flag_detail = (
|
|
608
|
+
f" with dangerous flags {dangerous_flags}"
|
|
609
|
+
if dangerous_flags else ""
|
|
610
|
+
)
|
|
611
|
+
return MutativeResult(
|
|
612
|
+
is_mutative=True,
|
|
613
|
+
category=CATEGORY_MUTATIVE,
|
|
614
|
+
verb=verb,
|
|
615
|
+
dangerous_flags=dangerous_flags,
|
|
616
|
+
cli_family=family,
|
|
617
|
+
confidence=confidence,
|
|
618
|
+
reason=f"Mutative verb '{verb}'{flag_detail}",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
if candidate in READ_ONLY_VERBS or full_lower in READ_ONLY_VERBS:
|
|
622
|
+
verb = candidate if candidate in READ_ONLY_VERBS else full_lower
|
|
623
|
+
return MutativeResult(
|
|
624
|
+
is_mutative=False,
|
|
625
|
+
category=CATEGORY_READ_ONLY,
|
|
626
|
+
verb=verb,
|
|
627
|
+
cli_family=family,
|
|
628
|
+
confidence=confidence,
|
|
629
|
+
reason=f"Read-only verb '{verb}'",
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Check command aliases as verb (e.g., "docker rm" -> rm is alias)
|
|
633
|
+
if candidate in COMMAND_ALIASES:
|
|
634
|
+
alias_cat = COMMAND_ALIASES[candidate]
|
|
635
|
+
dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
|
|
636
|
+
return MutativeResult(
|
|
637
|
+
is_mutative=True,
|
|
638
|
+
category=alias_cat,
|
|
639
|
+
verb=candidate,
|
|
640
|
+
dangerous_flags=dangerous_flags,
|
|
641
|
+
cli_family=family,
|
|
642
|
+
confidence=confidence,
|
|
643
|
+
reason=f"Verb alias '{candidate}' is {alias_cat.lower()}",
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# --- Step 4b: API subcommand with no explicit mutative HTTP method ---
|
|
647
|
+
# CLIs like `gh api` and `glab api` default to GET when no -X flag is
|
|
648
|
+
# specified. If the semantic scan found no verb and the subcommand is
|
|
649
|
+
# "api", treat the command as read-only.
|
|
650
|
+
if (
|
|
651
|
+
not any(
|
|
652
|
+
t in MUTATIVE_VERBS
|
|
653
|
+
for t in semantics.semantic_head_tokens[1:]
|
|
654
|
+
)
|
|
655
|
+
and len(semantics.semantic_head_tokens) > 1
|
|
656
|
+
and semantics.semantic_head_tokens[1] == "api"
|
|
657
|
+
):
|
|
658
|
+
return MutativeResult(
|
|
659
|
+
is_mutative=False,
|
|
660
|
+
category=CATEGORY_READ_ONLY,
|
|
661
|
+
verb="api",
|
|
662
|
+
cli_family=family,
|
|
663
|
+
confidence="high",
|
|
664
|
+
reason="API call with implicit GET method",
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# --- Step 5: Scan for dangerous flags (no verb found) ---
|
|
668
|
+
dangerous_flags = _scan_dangerous_flags(tokens, base_cmd)
|
|
669
|
+
if dangerous_flags:
|
|
670
|
+
# Find first non-flag token as the "verb" for reporting
|
|
671
|
+
verb, _ = _find_first_non_flag(semantics.semantic_head_tokens)
|
|
672
|
+
return MutativeResult(
|
|
673
|
+
is_mutative=True,
|
|
674
|
+
category=CATEGORY_UNKNOWN,
|
|
675
|
+
verb=verb,
|
|
676
|
+
dangerous_flags=dangerous_flags,
|
|
677
|
+
cli_family=family,
|
|
678
|
+
confidence="low",
|
|
679
|
+
reason=f"Unknown verb '{verb}' with dangerous flags {dangerous_flags}",
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# --- Step 6: No match -- not mutative (safe by elimination) ---
|
|
683
|
+
verb, _ = _find_first_non_flag(semantics.semantic_head_tokens)
|
|
684
|
+
return MutativeResult(
|
|
685
|
+
is_mutative=False,
|
|
686
|
+
category=CATEGORY_UNKNOWN,
|
|
687
|
+
verb=verb,
|
|
688
|
+
cli_family=family,
|
|
689
|
+
confidence="low",
|
|
690
|
+
reason=f"Unknown verb '{verb}' with no dangerous flags",
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# ============================================================================
|
|
695
|
+
# Helpers
|
|
696
|
+
# ============================================================================
|
|
697
|
+
|
|
698
|
+
def _check_inline_code(command: str, base_cmd: str, family: str) -> MutativeResult:
|
|
699
|
+
"""Check inline code for dangerous patterns using a 3-layer approach.
|
|
700
|
+
|
|
701
|
+
Layer 1: Extract string literals from inline code and check them against
|
|
702
|
+
blocked_commands (catches embedded shell commands like 'rm -rf /').
|
|
703
|
+
Layer 2: Scan for universal dangerous API keywords (language-agnostic).
|
|
704
|
+
Layer 3: Heuristic safety classification (length, sensitive paths, encoding).
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
command: Full raw command string.
|
|
708
|
+
base_cmd: The interpreter (e.g., "python3", "node", "ruby").
|
|
709
|
+
family: CLI family hint.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
MutativeResult -- MUTATIVE if any layer triggers, else safe.
|
|
713
|
+
"""
|
|
714
|
+
# ---- Layer 1: Extract string literals → check against blocked_commands ----
|
|
715
|
+
if _is_blocked_command is not None:
|
|
716
|
+
embedded_strings = _extract_embedded_shell_commands(command)
|
|
717
|
+
for literal in embedded_strings:
|
|
718
|
+
blocked = _is_blocked_command(literal)
|
|
719
|
+
if blocked.is_blocked:
|
|
720
|
+
return MutativeResult(
|
|
721
|
+
is_mutative=True,
|
|
722
|
+
category=CATEGORY_MUTATIVE,
|
|
723
|
+
verb="embedded-blocked-cmd",
|
|
724
|
+
cli_family=family,
|
|
725
|
+
confidence="high",
|
|
726
|
+
reason=(
|
|
727
|
+
f"Inline code contains blocked shell command in "
|
|
728
|
+
f"string literal: {blocked.category}"
|
|
729
|
+
),
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# ---- Layer 2: Universal dangerous API keyword patterns ----
|
|
733
|
+
for pattern, label, category in _UNIVERSAL_DANGEROUS_PATTERNS:
|
|
734
|
+
if pattern.search(command):
|
|
735
|
+
return MutativeResult(
|
|
736
|
+
is_mutative=True,
|
|
737
|
+
category=CATEGORY_MUTATIVE,
|
|
738
|
+
verb=label,
|
|
739
|
+
cli_family=family,
|
|
740
|
+
confidence="medium",
|
|
741
|
+
reason=f"Inline code contains dangerous pattern: {label} ({category})",
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# ---- Layer 3: Heuristic safety classification ----
|
|
745
|
+
# 3a: Check for suspicious indicators (sensitive paths, encoding, IPs)
|
|
746
|
+
for pattern, label in _SUSPICIOUS_HEURISTICS:
|
|
747
|
+
if pattern.search(command):
|
|
748
|
+
return MutativeResult(
|
|
749
|
+
is_mutative=True,
|
|
750
|
+
category=CATEGORY_MUTATIVE,
|
|
751
|
+
verb=f"heuristic-{label}",
|
|
752
|
+
cli_family=family,
|
|
753
|
+
confidence="low",
|
|
754
|
+
reason=f"Inline code flagged by heuristic: {label}",
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
# 3b: Unusually long inline code is suspicious
|
|
758
|
+
# Extract the code portion after the inline flag for length check.
|
|
759
|
+
# Use a rough extraction: everything after the first inline flag.
|
|
760
|
+
code_portion = command
|
|
761
|
+
cli_flag_tokens = _INLINE_CODE_MAP.get(base_cmd, frozenset())
|
|
762
|
+
for flag in cli_flag_tokens:
|
|
763
|
+
idx = command.find(f" {flag} ")
|
|
764
|
+
if idx != -1:
|
|
765
|
+
code_portion = command[idx + len(flag) + 2:]
|
|
766
|
+
break
|
|
767
|
+
|
|
768
|
+
if len(code_portion) > MAX_NORMAL_INLINE_LENGTH:
|
|
769
|
+
return MutativeResult(
|
|
770
|
+
is_mutative=True,
|
|
771
|
+
category=CATEGORY_MUTATIVE,
|
|
772
|
+
verb="heuristic-long-code",
|
|
773
|
+
cli_family=family,
|
|
774
|
+
confidence="low",
|
|
775
|
+
reason=(
|
|
776
|
+
f"Inline code is unusually long ({len(code_portion)} chars > "
|
|
777
|
+
f"{MAX_NORMAL_INLINE_LENGTH} limit)"
|
|
778
|
+
),
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
# ---- No layers triggered -- safe inline code ----
|
|
782
|
+
return MutativeResult(
|
|
783
|
+
is_mutative=False,
|
|
784
|
+
category=CATEGORY_READ_ONLY,
|
|
785
|
+
verb="inline-code",
|
|
786
|
+
cli_family=family,
|
|
787
|
+
confidence="medium",
|
|
788
|
+
reason=f"Inline code ({base_cmd}) with no dangerous patterns",
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _find_first_non_flag(tokens: List[str] | tuple) -> tuple:
|
|
793
|
+
"""Find the first semantic token after tokens[0].
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
(verb, position) tuple. ("", -1) if no non-flag token found.
|
|
797
|
+
"""
|
|
798
|
+
for i in range(1, len(tokens)):
|
|
799
|
+
if tokens[i]:
|
|
800
|
+
return tokens[i], i
|
|
801
|
+
return "", -1
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# ============================================================================
|
|
805
|
+
# Hook Response Builder
|
|
806
|
+
# ============================================================================
|
|
807
|
+
|
|
808
|
+
def build_t3_block_response(
|
|
809
|
+
command: str,
|
|
810
|
+
danger: MutativeResult,
|
|
811
|
+
nonce: str = "",
|
|
812
|
+
) -> dict:
|
|
813
|
+
"""Build an internal block response dict for T3 commands.
|
|
814
|
+
|
|
815
|
+
Returns an internal dict consumed by bash_validator, which wraps the
|
|
816
|
+
'message' field into a hookSpecificOutput with permissionDecision: "deny".
|
|
817
|
+
The 'decision' key is internal only and never sent to Claude Code.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
command: The original shell command.
|
|
821
|
+
danger: MutativeResult from detect_mutative_command.
|
|
822
|
+
nonce: Cryptographic nonce for this pending approval. When provided,
|
|
823
|
+
the block message includes the approval code that the agent must
|
|
824
|
+
present to the user.
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
Dict with 'decision' (internal) and 'message' (forwarded to agent) keys.
|
|
828
|
+
"""
|
|
829
|
+
flag_warning = ""
|
|
830
|
+
if danger.dangerous_flags:
|
|
831
|
+
flag_warning = (
|
|
832
|
+
f"\nDangerous flags detected: {', '.join(danger.dangerous_flags)}"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
message = (
|
|
836
|
+
f"[T3_APPROVAL_REQUIRED] {danger.category} operation detected.\n"
|
|
837
|
+
f"Command: {command}\n"
|
|
838
|
+
f"Verb: '{danger.verb}' (CLI family: {danger.cli_family})\n"
|
|
839
|
+
f"Confidence: {danger.confidence}\n"
|
|
840
|
+
f"Reason: {danger.reason}{flag_warning}\n"
|
|
841
|
+
f"\n"
|
|
842
|
+
f"{build_t3_approval_instructions(nonce)}"
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
"decision": "block",
|
|
847
|
+
"message": message,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|