claude-dev-env 4.3.0 → 7.1.0
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/.agents/agents/pr-description-writer.md +2 -2
- package/.agents/agents/test_agent_frontmatter.py +2 -2
- package/.agents/agents/test_pr_description_writer_style.py +1 -3
- package/.agents/skills/e-code-review/SKILL.md +12 -12
- package/.agents/skills/e-code-review/preflight-proposal.contract.test.mjs +20 -0
- package/.agents/skills/e-code-review/reference/fix.md +15 -17
- package/.agents/skills/e-code-review/reference/loop.md +35 -39
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +2 -1
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +44 -9
- package/.agents/skills/e-code-review/reference/runner-selection.md +13 -14
- package/.agents/skills/issue-tracker/SKILL.md +5 -4
- package/.agents/skills/pull-request/SKILL.md +11 -13
- package/.agents/skills/pull-request/reference/publication-tasks.md +3 -3
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +9 -1
- package/.agents/skills/pull-request/scripts/pull_request.py +35 -44
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +9 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +35 -3
- package/.agents/skills/session-continuity/README.md +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
- package/.agents/skills/skill-builder/SKILL.md +3 -196
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +6 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +64 -19
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +59 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +78 -0
- package/bin/install-session-continuity.test.mjs +42 -0
- package/bin/install.agents-home.test.mjs +15 -13
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +506 -63
- package/bin/install.policy-lint.test.mjs +130 -0
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +286 -37
- package/bin/install.pstack-plugin.test.mjs +114 -0
- package/bin/install.test.mjs +537 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/bin/refresh-pstack-plugin-skills.test.mjs +211 -0
- package/docs/CODE_RULES.md +1 -1
- package/hooks/advisory/pr_done_reminder.py +342 -0
- package/hooks/advisory/test_pr_done_reminder.py +249 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +53 -6
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/code_rules_paired_test.py +22 -15
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_post_call_dispatcher.py +65 -4
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +36 -29
- package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +4 -4
- package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +5 -5
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +43 -342
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +20 -51
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/staged_policy_lint.py +12 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -49
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +208 -0
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +120 -0
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +38 -148
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +1 -120
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +14 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pr_done_reminder_constants.py +98 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -93
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +19 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pr_done_reminder_constants.py +40 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +46 -201
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +2 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/durable-post-artifacts.md +2 -1
- package/rules/git-workflow.md +18 -6
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/check.ps1 +0 -1
- package/{.agents/skills/run-claude-dev-env/driver.mjs → scripts/ci/installer-lifecycle.mjs} +3 -28
- package/scripts/ci/installer-lifecycle.test.mjs +26 -0
- package/scripts/ci/windows-installer-lifecycle.ps1 +1 -11
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +4 -5
- package/{.agents/skills/_shared/pr-loop/scripts → scripts}/durable_post_lint.py +35 -43
- package/{.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py → scripts/durable_post_lint_config/config/constants.py} +10 -10
- package/{.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py → scripts/durable_post_lint_config/config/test_constants.py} +14 -1
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_detectors.py +13 -6
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/adapters.py +7 -4
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/policy_lint/config/constants.py +5 -0
- package/scripts/policy_lint/engine.py +48 -4
- package/scripts/policy_lint/model.py +1 -0
- package/scripts/policy_lint/registry.py +1 -1
- package/scripts/policy_lint/selection.py +2 -1
- package/scripts/policy_lint/selection_git.py +2 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/refresh_pstack_plugin_skills.mjs +200 -0
- package/scripts/shared_tree_paths.py +32 -3
- package/{.agents/skills/_shared/pr-loop/scripts → scripts}/test_durable_post_lint.py +97 -0
- package/scripts/test_shared_tree_paths.py +33 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +120 -0
- package/scripts/tests/test_policy_lint_rules_engine.py +25 -4
- package/scripts/tests/test_policy_lint_rules_installed_loader.py +29 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +227 -13
- package/scripts/tests/test_policy_lint_selection.py +25 -2
- package/scripts/tests/test_policy_lint_selection_native_index.py +30 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_rules.py +3 -7
- package/scripts/tests/test_verification_notice.py +347 -0
- package/.agents/skills/_shared/pr-loop/.claude/CLAUDE.md +0 -1
- package/.agents/skills/_shared/pr-loop/audit-contract.md +0 -5
- package/.agents/skills/_shared/pr-loop/audit-reply-template.md +0 -5
- package/.agents/skills/_shared/pr-loop/code-rules-gate.md +0 -5
- package/.agents/skills/_shared/pr-loop/fix-protocol.md +0 -5
- package/.agents/skills/_shared/pr-loop/gh-payloads.md +0 -5
- package/.agents/skills/_shared/pr-loop/portable-driver.md +0 -149
- package/.agents/skills/_shared/pr-loop/post-audit-thread-contract.md +0 -5
- package/.agents/skills/_shared/pr-loop/precatch-rubric.md +0 -5
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +0 -119
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +0 -5
- package/.agents/skills/_shared/pr-loop/prompts/.claude/CLAUDE.md +0 -1
- package/.agents/skills/_shared/pr-loop/prompts/AGENTS.md +0 -1
- package/.agents/skills/_shared/pr-loop/prompts/pr-consistency-audit.xml +0 -324
- package/.agents/skills/_shared/pr-loop/scripts/.claude/CLAUDE.md +0 -1
- package/.agents/skills/_shared/pr-loop/scripts/AGENTS.md +0 -1
- package/.agents/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +0 -29
- package/.agents/skills/_shared/pr-loop/scripts/_cli_utils.py +0 -22
- package/.agents/skills/_shared/pr-loop/scripts/_path_resolver.py +0 -186
- package/.agents/skills/_shared/pr-loop/scripts/_xml_utils.py +0 -20
- package/.agents/skills/_shared/pr-loop/scripts/audit_category_schema.py +0 -355
- package/.agents/skills/_shared/pr-loop/scripts/build_audit_prompt.py +0 -289
- package/.agents/skills/_shared/pr-loop/scripts/build_converge_task_list.py +0 -310
- package/.agents/skills/_shared/pr-loop/scripts/build_fix_prompt.py +0 -292
- package/.agents/skills/_shared/pr-loop/scripts/init_loop_state.py +0 -134
- package/.agents/skills/_shared/pr-loop/scripts/portable_converge_driver.py +0 -1646
- package/.agents/skills/_shared/pr-loop/scripts/preflight_worktree.py +0 -392
- package/.agents/skills/_shared/pr-loop/scripts/select_converge_pacer.py +0 -215
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/.claude/CLAUDE.md +0 -1
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/AGENTS.md +0 -1
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/__init__.py +0 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +0 -37
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +0 -56
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +0 -45
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +0 -47
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +0 -157
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +0 -177
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +0 -70
- package/.agents/skills/_shared/pr-loop/scripts/teardown_worktrees.py +0 -172
- package/.agents/skills/_shared/pr-loop/scripts/test__path_resolver.py +0 -106
- package/.agents/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +0 -94
- package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +0 -499
- package/.agents/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +0 -140
- package/.agents/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +0 -262
- package/.agents/skills/_shared/pr-loop/scripts/test_init_loop_state.py +0 -71
- package/.agents/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +0 -1119
- package/.agents/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +0 -287
- package/.agents/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +0 -207
- package/.agents/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +0 -59
- package/.agents/skills/_shared/pr-loop/scripts/test_write_handoff.py +0 -201
- package/.agents/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +0 -182
- package/.agents/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +0 -206
- package/.agents/skills/_shared/pr-loop/scripts/write_handoff.py +0 -309
- package/.agents/skills/_shared/pr-loop/state-schema.md +0 -5
- package/.agents/skills/_shared/pr-loop/worker-spawn.md +0 -5
- package/.agents/skills/autoconverge/.claude/CLAUDE.md +0 -1
- package/.agents/skills/autoconverge/AGENTS.md +0 -1
- package/.agents/skills/autoconverge/SKILL.md +0 -431
- package/.agents/skills/autoconverge/reference/.claude/CLAUDE.md +0 -1
- package/.agents/skills/autoconverge/reference/AGENTS.md +0 -1
- package/.agents/skills/autoconverge/reference/closing-report.md +0 -93
- package/.agents/skills/autoconverge/reference/convergence-gates.md +0 -31
- package/.agents/skills/autoconverge/reference/convergence.md +0 -219
- package/.agents/skills/autoconverge/reference/copilot-findings.md +0 -51
- package/.agents/skills/autoconverge/reference/gotchas.md +0 -66
- package/.agents/skills/autoconverge/reference/headless-safety.md +0 -44
- package/.agents/skills/autoconverge/reference/multi-pr.md +0 -128
- package/.agents/skills/autoconverge/reference/self-closing-loop.md +0 -84
- package/.agents/skills/autoconverge/reference/stop-conditions.md +0 -127
- package/.agents/skills/autoconverge/test_portable_pacer_gate.py +0 -54
- package/.agents/skills/autoconverge/workflow/.claude/CLAUDE.md +0 -1
- package/.agents/skills/autoconverge/workflow/AGENTS.md +0 -1
- package/.agents/skills/autoconverge/workflow/aggregate_runs.py +0 -371
- package/.agents/skills/autoconverge/workflow/autoconverge_report_constants/.claude/CLAUDE.md +0 -1
- package/.agents/skills/autoconverge/workflow/autoconverge_report_constants/AGENTS.md +0 -1
- package/.agents/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/.agents/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +0 -250
- package/.agents/skills/autoconverge/workflow/converge.clean-audit.test.mjs +0 -665
- package/.agents/skills/autoconverge/workflow/converge.codex-gate.test.mjs +0 -472
- package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +0 -64
- package/.agents/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +0 -1009
- package/.agents/skills/autoconverge/workflow/converge.fix-progress.test.mjs +0 -107
- package/.agents/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +0 -33
- package/.agents/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +0 -15
- package/.agents/skills/autoconverge/workflow/converge.mjs +0 -3132
- package/.agents/skills/autoconverge/workflow/converge.p2-advance.test.mjs +0 -202
- package/.agents/skills/autoconverge/workflow/converge.path-aware.test.mjs +0 -47
- package/.agents/skills/autoconverge/workflow/converge.precatch.test.mjs +0 -152
- package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +0 -116
- package/.agents/skills/autoconverge/workflow/converge_multi.mjs +0 -169
- package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +0 -173
- package/.agents/skills/autoconverge/workflow/convergence_summary.py +0 -110
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +0 -2
- package/.agents/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +0 -266
- package/.agents/skills/autoconverge/workflow/render_report.py +0 -1024
- package/.agents/skills/autoconverge/workflow/test_aggregate_runs.py +0 -134
- package/.agents/skills/autoconverge/workflow/test_convergence_summary.py +0 -149
- package/.agents/skills/autoconverge/workflow/test_render_report.py +0 -812
- package/.agents/skills/hitl/SKILL.md +0 -45
- package/.agents/skills/pr-cleanup/SKILL.md +0 -140
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +0 -17
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +0 -22
- package/.agents/skills/pr-name-by-capability/SKILL.md +0 -73
- package/.agents/skills/pr-name-by-capability/reference/fetch-commands.md +0 -29
- package/.agents/skills/pr-name-by-capability/reference/offense-examples.md +0 -32
- package/.agents/skills/pr-name-by-capability/reference/report-template.md +0 -29
- package/.agents/skills/pr-name-by-capability/reference/rule-checklist.md +0 -38
- package/.agents/skills/pr-name-by-capability/reference/task-seeds.md +0 -10
- package/.agents/skills/pr-plain-language-cleanup/SKILL.md +0 -93
- package/.agents/skills/pr-refinement/SKILL.md +0 -31
- package/.agents/skills/pr-shared-extraction/SKILL.md +0 -177
- package/.agents/skills/pr-shared-extraction/reference/examples.md +0 -46
- package/.agents/skills/pr-shared-extraction/reference/offense-taxonomy.md +0 -100
- package/.agents/skills/pr-small-cl/SKILL.md +0 -52
- package/.agents/skills/pr-title-description/SKILL.md +0 -96
- package/.agents/skills/prototype/SKILL.md +0 -85
- package/.agents/skills/prototype/reference/honest-limitations.md +0 -23
- package/.agents/skills/prototype/reference/promotion-tasks.md +0 -23
- package/.agents/skills/prototype/scripts/build_sandbox_settings.py +0 -249
- package/.agents/skills/prototype/scripts/conftest.py +0 -15
- package/.agents/skills/prototype/scripts/launch_sandbox.py +0 -205
- package/.agents/skills/prototype/scripts/probe_sandbox_safety.py +0 -311
- package/.agents/skills/prototype/scripts/prototype_scripts_constants/__init__.py +0 -1
- package/.agents/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/.agents/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +0 -41
- package/.agents/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +0 -23
- package/.agents/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +0 -45
- package/.agents/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +0 -10
- package/.agents/skills/prototype/scripts/test_build_sandbox_settings.py +0 -275
- package/.agents/skills/prototype/scripts/test_launch_sandbox.py +0 -303
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +0 -312
- package/.agents/skills/prototype/workflows/promotion.md +0 -25
- package/.agents/skills/prototype/workflows/sandbox.md +0 -35
- package/.agents/skills/rebase/.claude/CLAUDE.md +0 -1
- package/.agents/skills/rebase/AGENTS.md +0 -1
- package/.agents/skills/rebase/SKILL.md +0 -178
- package/.agents/skills/review-router/SKILL.md +0 -51
- package/.agents/skills/review-router/reference/route-policy.json +0 -7
- package/.agents/skills/review-router/reference/task-seeds.md +0 -1
- package/.agents/skills/review-router/scripts/conftest.py +0 -4
- package/.agents/skills/review-router/scripts/review_router.py +0 -50
- package/.agents/skills/review-router/scripts/review_router_cli.py +0 -211
- package/.agents/skills/review-router/scripts/review_router_constants/__init__.py +0 -1
- package/.agents/skills/review-router/scripts/review_router_constants/config/__init__.py +0 -0
- package/.agents/skills/review-router/scripts/review_router_constants/config/constants.py +0 -13
- package/.agents/skills/review-router/scripts/test_review_router.py +0 -23
- package/.agents/skills/review-router/scripts/test_review_router_cli.py +0 -47
- package/.agents/skills/review-tier/SKILL.md +0 -56
- package/.agents/skills/review-tier/reference/task-seeds.md +0 -1
- package/.agents/skills/review-tier/reference/tier-policy.json +0 -9
- package/.agents/skills/review-tier/scripts/conftest.py +0 -4
- package/.agents/skills/review-tier/scripts/review_tier.py +0 -359
- package/.agents/skills/review-tier/scripts/review_tier_constants/__init__.py +0 -1
- package/.agents/skills/review-tier/scripts/review_tier_constants/config/__init__.py +0 -0
- package/.agents/skills/review-tier/scripts/review_tier_constants/config/constants.py +0 -29
- package/.agents/skills/review-tier/scripts/test_generation.py +0 -159
- package/.agents/skills/review-tier/scripts/test_review_tier.py +0 -33
- package/.agents/skills/run-claude-dev-env/SKILL.md +0 -84
- package/.agents/skills/session-log/.claude/CLAUDE.md +0 -1
- package/.agents/skills/session-log/AGENTS.md +0 -1
- package/.agents/skills/session-log/SKILL.md +0 -170
- package/.agents/skills/session-log/templates/frontmatter.md +0 -40
- package/.agents/skills/session-tidy/.claude/CLAUDE.md +0 -1
- package/.agents/skills/session-tidy/AGENTS.md +0 -1
- package/.agents/skills/session-tidy/SKILL.md +0 -191
- package/.agents/skills/skill-builder/references/.claude/CLAUDE.md +0 -1
- package/.agents/skills/skill-builder/references/AGENTS.md +0 -1
- package/.agents/skills/skill-builder/references/delegation-map.md +0 -130
- package/.agents/skills/skill-builder/references/description-field.md +0 -111
- package/.agents/skills/skill-builder/references/deterministic-elements.md +0 -218
- package/.agents/skills/skill-builder/references/progressive-disclosure.md +0 -122
- package/.agents/skills/skill-builder/references/self-audit-checklist.md +0 -126
- package/.agents/skills/skill-builder/references/skill-modularity.md +0 -111
- package/.agents/skills/skill-builder/references/skill-types.md +0 -232
- package/.agents/skills/skill-builder/references/thariq-x-post-skills.json +0 -33
- package/.agents/skills/skill-builder/templates/.claude/CLAUDE.md +0 -1
- package/.agents/skills/skill-builder/templates/AGENTS.md +0 -1
- package/.agents/skills/skill-builder/templates/gap-analysis.md +0 -87
- package/.agents/skills/skill-builder/workflows/.claude/CLAUDE.md +0 -1
- package/.agents/skills/skill-builder/workflows/AGENTS.md +0 -1
- package/.agents/skills/skill-builder/workflows/improve-skill.md +0 -136
- package/.agents/skills/skill-builder/workflows/new-skill.md +0 -179
- package/.agents/skills/skill-builder/workflows/polish-skill.md +0 -139
- package/.agents/skills/source-command-sr-loop/SKILL.md +0 -82
- package/.agents/skills/update/.claude/CLAUDE.md +0 -1
- package/.agents/skills/update/AGENTS.md +0 -1
- package/.agents/skills/update/SKILL.md +0 -190
- package/_shared/pr-loop/AGENTS.md +0 -1
- package/_shared/pr-loop/audit-contract.md +0 -183
- package/_shared/pr-loop/audit-reply-template.md +0 -151
- package/_shared/pr-loop/code-rules-gate.md +0 -65
- package/_shared/pr-loop/fix-protocol.md +0 -60
- package/_shared/pr-loop/gh-payloads.md +0 -75
- package/_shared/pr-loop/post-audit-thread-contract.md +0 -51
- package/_shared/pr-loop/precatch-rubric.md +0 -66
- package/_shared/pr-loop/preflight-proposal.md +0 -56
- package/_shared/pr-loop/scripts/post_audit_thread.py +0 -1242
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/post_audit_thread_constants.py +0 -199
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +0 -1115
- package/_shared/pr-loop/scripts/tests/test_post_audit_thread_constants.py +0 -129
- package/_shared/pr-loop/state-schema.md +0 -45
- package/_shared/pr-loop/worker-spawn.md +0 -189
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
- /package/.agents/skills/{_shared/pr-loop → session-continuity}/AGENTS.md +0 -0
- /package/{_shared/pr-loop → .agents/skills/session-continuity}/CLAUDE.md +0 -0
|
@@ -1,2073 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import datetime
|
|
3
|
-
import enum
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
import shlex
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
import tempfile
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
14
|
-
if _hooks_dir not in sys.path:
|
|
15
|
-
sys.path.insert(0, _hooks_dir)
|
|
16
|
-
|
|
17
|
-
from hooks_constants.convergence_branch_constants import ( # noqa: E402
|
|
18
|
-
ALL_CONVERGENCE_BRANCH_PREFIXES,
|
|
19
|
-
CONVERGENCE_BRANCH_SUFFIX_PATTERN,
|
|
20
|
-
CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
|
|
21
|
-
)
|
|
22
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
23
|
-
from hooks_constants.destructive_command_environment_constants import ( # noqa: E402
|
|
24
|
-
ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES,
|
|
25
|
-
ALL_TRUTHY_ENV_VALUES,
|
|
26
|
-
DESTRUCTIVE_DENY_MODE_ENV_VAR,
|
|
27
|
-
EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR,
|
|
28
|
-
GH_REDIRECT_ACTIVE_ENV_VAR,
|
|
29
|
-
)
|
|
30
|
-
from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
|
|
31
|
-
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
|
|
32
|
-
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
|
|
33
|
-
ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM,
|
|
34
|
-
ALL_FIND_EXEC_ACTION_FLAGS,
|
|
35
|
-
ALL_FIND_EXEC_ACTION_TERMINATORS,
|
|
36
|
-
ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE,
|
|
37
|
-
ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE,
|
|
38
|
-
FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX,
|
|
39
|
-
ALL_GH_API_GLUED_REQUEST_BODY_FIELD_FLAG_PREFIXES,
|
|
40
|
-
ALL_GH_API_REQUEST_BODY_FIELD_FLAGS,
|
|
41
|
-
ALL_GH_HTTP_WRITE_METHOD_FLAGS,
|
|
42
|
-
ALL_GH_HTTP_WRITE_METHODS,
|
|
43
|
-
ALL_GIT_CONFIG_READ_ONLY_FLAGS,
|
|
44
|
-
ALL_GIT_FETCH_FORCE_FLAGS,
|
|
45
|
-
ALL_GIT_REMOTE_READ_ONLY_VERBS,
|
|
46
|
-
ALL_INTERPRETER_AND_WRAPPER_COMMANDS,
|
|
47
|
-
ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE,
|
|
48
|
-
ALL_LAUNCHERS_REQUIRING_A_POSITIONAL_VALUE,
|
|
49
|
-
ALL_READ_ONLY_SUBCOMMANDS_BY_DISPATCHING_PROGRAM,
|
|
50
|
-
ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS,
|
|
51
|
-
ALL_SHELL_CONTROL_OPERATOR_TOKENS,
|
|
52
|
-
ALL_STRING_ARGUMENT_EXECUTION_FLAGS,
|
|
53
|
-
ALL_SUBSHELL_GROUPING_CHARACTERS,
|
|
54
|
-
FIND_PROGRAM_NAME,
|
|
55
|
-
GH_HTTP_READ_ONLY_METHOD,
|
|
56
|
-
GH_LONG_METHOD_FLAG_EQUALS_PREFIX,
|
|
57
|
-
GH_SHORT_METHOD_FLAG_PREFIX,
|
|
58
|
-
ALL_READ_ONLY_SUBCOMMAND_POSITION_DEPTHS_BY_DISPATCHING_PROGRAM,
|
|
59
|
-
LAUNCHER_POSITIONAL_VALUE_SHAPE_PATTERN,
|
|
60
|
-
OUTPUT_REDIRECTION_OPERATOR_PATTERN,
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def gh_redirect_is_active() -> bool:
|
|
67
|
-
env_var_value = os.environ.get(GH_REDIRECT_ACTIVE_ENV_VAR, "").strip().lower()
|
|
68
|
-
return env_var_value in ALL_TRUTHY_ENV_VALUES
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def deny_mode_is_active() -> bool:
|
|
72
|
-
env_var_value = os.environ.get(DESTRUCTIVE_DENY_MODE_ENV_VAR, "").strip().lower()
|
|
73
|
-
return env_var_value in ALL_TRUTHY_ENV_VALUES
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def directory_is_ephemeral(directory_path: str) -> bool:
|
|
77
|
-
"""Return True when a directory belongs to the ephemeral auto-allow namespace.
|
|
78
|
-
|
|
79
|
-
A directory is ephemeral when the environment override has not disabled the
|
|
80
|
-
auto-allow and the path matches one of these sources, in order: a path
|
|
81
|
-
containing a ``/worktrees/`` or ``/worktree/`` segment; a path rooted at ``/tmp``
|
|
82
|
-
or ``/temp`` (drive-letter tolerant); a path under the OS temporary root; a path
|
|
83
|
-
git reports inside a worktree admin directory. Returns False when the
|
|
84
|
-
``CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW`` override is truthy and when no
|
|
85
|
-
source matches.
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
directory_path: The filesystem path to classify.
|
|
89
|
-
|
|
90
|
-
Returns:
|
|
91
|
-
True when the directory belongs to the ephemeral auto-allow namespace.
|
|
92
|
-
"""
|
|
93
|
-
if os.environ.get(EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR, "").strip().lower() in ALL_TRUTHY_ENV_VALUES:
|
|
94
|
-
return False
|
|
95
|
-
forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
|
|
96
|
-
all_worktree_path_segments = ("/worktrees/", "/worktree/")
|
|
97
|
-
for each_worktree_segment in all_worktree_path_segments:
|
|
98
|
-
if each_worktree_segment in forward_slash_normalized_directory_path + "/":
|
|
99
|
-
return True
|
|
100
|
-
drive_letter_stripped_path = re.sub(r"^[a-z]:", "", forward_slash_normalized_directory_path)
|
|
101
|
-
all_root_anchored_temporary_directories = ("/tmp", "/temp")
|
|
102
|
-
for each_temporary_root in all_root_anchored_temporary_directories:
|
|
103
|
-
if drive_letter_stripped_path == each_temporary_root or drive_letter_stripped_path.startswith(each_temporary_root + "/"):
|
|
104
|
-
return True
|
|
105
|
-
system_temporary_root = os.path.normpath(tempfile.gettempdir()).replace("\\", "/").lower()
|
|
106
|
-
if forward_slash_normalized_directory_path.startswith(system_temporary_root + "/") or forward_slash_normalized_directory_path == system_temporary_root:
|
|
107
|
-
return True
|
|
108
|
-
try:
|
|
109
|
-
git_rev_parse_completion = subprocess.run(
|
|
110
|
-
["git", "rev-parse", "--git-dir"],
|
|
111
|
-
cwd=directory_path,
|
|
112
|
-
capture_output=True,
|
|
113
|
-
text=True,
|
|
114
|
-
check=False,
|
|
115
|
-
)
|
|
116
|
-
except (OSError, subprocess.SubprocessError):
|
|
117
|
-
return False
|
|
118
|
-
if git_rev_parse_completion.returncode != 0:
|
|
119
|
-
return False
|
|
120
|
-
git_directory_path_normalized = git_rev_parse_completion.stdout.strip().replace("\\", "/").lower()
|
|
121
|
-
return "/.git/worktrees/" in git_directory_path_normalized or "/worktrees/" in git_directory_path_normalized
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def load_allow_git_reset_hard_projects() -> list[str]:
|
|
125
|
-
allow_git_reset_hard_settings_key = "allowGitResetHardProjects"
|
|
126
|
-
settings_path = Path(CLAUDE_DIRECTORY_PATH) / "settings.json"
|
|
127
|
-
try:
|
|
128
|
-
raw_settings_text = settings_path.read_text(encoding="utf-8")
|
|
129
|
-
except OSError:
|
|
130
|
-
return []
|
|
131
|
-
try:
|
|
132
|
-
parsed_settings = json.loads(raw_settings_text)
|
|
133
|
-
except json.JSONDecodeError:
|
|
134
|
-
return []
|
|
135
|
-
if not isinstance(parsed_settings, dict):
|
|
136
|
-
return []
|
|
137
|
-
hooks_section = parsed_settings.get("hooks")
|
|
138
|
-
if not isinstance(hooks_section, dict):
|
|
139
|
-
return []
|
|
140
|
-
raw_allow_list = hooks_section.get(allow_git_reset_hard_settings_key)
|
|
141
|
-
if not isinstance(raw_allow_list, list):
|
|
142
|
-
return []
|
|
143
|
-
return [
|
|
144
|
-
each_project_path
|
|
145
|
-
for each_project_path in raw_allow_list
|
|
146
|
-
if isinstance(each_project_path, str)
|
|
147
|
-
]
|
|
148
|
-
|
|
149
|
-
DESTRUCTIVE_BASH_PATTERNS = [
|
|
150
|
-
(re.compile(r'\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r', re.IGNORECASE), "rm -rf (destructive recursive forced delete)"),
|
|
151
|
-
(re.compile(r'\brm\s+--recursive\b.*--force\b|\brm\s+--force\b.*--recursive\b', re.IGNORECASE), "rm --recursive --force (destructive recursive forced delete)"),
|
|
152
|
-
(re.compile(r'\brm\s+-r\s+([/~]|\.(?:\s|$)|\$HOME)', re.IGNORECASE), "rm -r on broad path (/, ~, $HOME, .)"),
|
|
153
|
-
(re.compile(r'\bmkfs\b', re.IGNORECASE), "mkfs (format filesystem)"),
|
|
154
|
-
(re.compile(r'\bdd\s+.*\bif=.*\bof=/dev/', re.IGNORECASE), "dd raw disk write"),
|
|
155
|
-
(re.compile(r'\bgit\s+reset\s+--hard\b', re.IGNORECASE), "git reset --hard (discards uncommitted work)"),
|
|
156
|
-
(re.compile(r'\bgit\s+push\s+--force(?!-with-lease)\b', re.IGNORECASE), "git push --force (rewrites remote history)"),
|
|
157
|
-
(re.compile(r'\bgit\s+push\s+-f\b', re.IGNORECASE), "git push -f (rewrites remote history)"),
|
|
158
|
-
(re.compile(r'\bgit\s+clean\s+(-fd|-df)\b', re.IGNORECASE), "git clean -fd (deletes untracked files and dirs)"),
|
|
159
|
-
(re.compile(r'\bgit\s+clean\s+-f\b', re.IGNORECASE), "git clean -f (deletes untracked files)"),
|
|
160
|
-
(re.compile(r'\bDROP\s+TABLE\b', re.IGNORECASE), "DROP TABLE (destroys database table)"),
|
|
161
|
-
(re.compile(r'\bDROP\s+DATABASE\b', re.IGNORECASE), "DROP DATABASE (destroys entire database)"),
|
|
162
|
-
(re.compile(r'\bTRUNCATE\s+TABLE\b', re.IGNORECASE), "TRUNCATE TABLE (removes all table rows)"),
|
|
163
|
-
(re.compile(r'\bgit\s+(?:[^\s]+\s+)*--no-verify\b', re.IGNORECASE), "git --no-verify (skips pre-commit / pre-push hooks; NON-NEGOTIABLE per git-workflow.md)"),
|
|
164
|
-
(re.compile(r'\bgit\s+(?:[^\s]+\s+)*--no-gpg-sign\b', re.IGNORECASE), "git --no-gpg-sign (bypasses commit signing; NON-NEGOTIABLE per git-workflow.md)"),
|
|
165
|
-
(re.compile(r"\bgit\s+-c\s+['\"]?commit\.gpgsign=['\"]?false['\"]?(?!\w)", re.IGNORECASE), "git -c commit.gpgsign=false (bypasses commit signing; NON-NEGOTIABLE per git-workflow.md)"),
|
|
166
|
-
]
|
|
167
|
-
|
|
168
|
-
def find_destructive_pattern(command: str) -> str | None:
|
|
169
|
-
for pattern_regex, pattern_description in DESTRUCTIVE_BASH_PATTERNS:
|
|
170
|
-
if pattern_regex.search(command):
|
|
171
|
-
return pattern_description
|
|
172
|
-
return None
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def find_redirected_gh_pattern(command: str) -> str | None:
|
|
176
|
-
redirected_gh_bash_patterns = [
|
|
177
|
-
(re.compile(r'\bgh\s+api\b.*/(comments|reviews)\b.*-X\s+POST', re.IGNORECASE), "gh api comment/review POST"),
|
|
178
|
-
(re.compile(r'\bgh\s+pr\s+comment\b', re.IGNORECASE), "gh pr comment"),
|
|
179
|
-
(re.compile(r'\bgh\s+pr\s+review\b', re.IGNORECASE), "gh pr review"),
|
|
180
|
-
(re.compile(r'\bgh\s+issue\s+comment\b', re.IGNORECASE), "gh issue comment"),
|
|
181
|
-
]
|
|
182
|
-
for pattern_regex, pattern_description in redirected_gh_bash_patterns:
|
|
183
|
-
if pattern_regex.search(command):
|
|
184
|
-
return pattern_description
|
|
185
|
-
return None
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def _append_destructive_gate_log_entry(brief_label: str, full_reason: str) -> None:
|
|
189
|
-
destructive_gate_log_path = Path.home() / ".claude" / "logs" / "destructive-gate.log"
|
|
190
|
-
try:
|
|
191
|
-
destructive_gate_log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
-
timestamp_iso = datetime.datetime.now().isoformat()
|
|
193
|
-
log_entry = f"{timestamp_iso}\t{brief_label}\t{full_reason}\n"
|
|
194
|
-
with destructive_gate_log_path.open("a", encoding="utf-8") as log_handle:
|
|
195
|
-
log_handle.write(log_entry)
|
|
196
|
-
except OSError:
|
|
197
|
-
pass
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _build_silent_gh_deny_response(matched_description: str) -> dict:
|
|
201
|
-
gh_gate_user_facing_prefix = "[gh-gate]"
|
|
202
|
-
brief_label = f"blocked redirected {matched_description}"
|
|
203
|
-
full_reason = (
|
|
204
|
-
f"GH-REDIRECT GATE: {matched_description} already executed by "
|
|
205
|
-
"gh-wsl-to-windows-redirect.py via PowerShell. Denying the original "
|
|
206
|
-
"Bash call prevents duplicate execution."
|
|
207
|
-
)
|
|
208
|
-
_append_destructive_gate_log_entry(brief_label, full_reason)
|
|
209
|
-
log_hook_block(
|
|
210
|
-
calling_hook_name="destructive_command_blocker.py",
|
|
211
|
-
hook_event="PreToolUse",
|
|
212
|
-
block_reason=full_reason,
|
|
213
|
-
tool_name="Bash",
|
|
214
|
-
)
|
|
215
|
-
return {
|
|
216
|
-
"hookSpecificOutput": {
|
|
217
|
-
"hookEventName": "PreToolUse",
|
|
218
|
-
"permissionDecision": "deny",
|
|
219
|
-
"permissionDecisionReason": full_reason,
|
|
220
|
-
},
|
|
221
|
-
"suppressOutput": True,
|
|
222
|
-
"systemMessage": f"{gh_gate_user_facing_prefix} {brief_label}",
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def _path_is_bare_ephemeral_root(resolved_path: str) -> bool:
|
|
227
|
-
leading_repeated_slash_pattern = re.compile(r"^/{2,}")
|
|
228
|
-
forward_slash_normalized_path = leading_repeated_slash_pattern.sub(
|
|
229
|
-
"/",
|
|
230
|
-
resolved_path.replace("\\", "/").lower().rstrip("/"),
|
|
231
|
-
)
|
|
232
|
-
system_temporary_root = leading_repeated_slash_pattern.sub(
|
|
233
|
-
"/",
|
|
234
|
-
os.path.normpath(tempfile.gettempdir()).replace("\\", "/").lower().rstrip("/"),
|
|
235
|
-
)
|
|
236
|
-
forbidden_bare_ephemeral_roots = {"/tmp", "/temp", "/worktrees", "/worktree", system_temporary_root}
|
|
237
|
-
return forward_slash_normalized_path in forbidden_bare_ephemeral_roots
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _path_is_bare_named_worktrees_container(resolved_path: str) -> bool:
|
|
241
|
-
return Path(resolved_path).name.lower() in ("worktrees", "worktree")
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _path_basename_is_shell_glob_wildcard(resolved_path: str) -> bool:
|
|
245
|
-
bracket_class_empty_length = len("[]")
|
|
246
|
-
basename = Path(resolved_path).name
|
|
247
|
-
if not basename:
|
|
248
|
-
return False
|
|
249
|
-
if basename in ("*", "?"):
|
|
250
|
-
return True
|
|
251
|
-
if basename.startswith("[") and basename.endswith("]") and len(basename) > bracket_class_empty_length:
|
|
252
|
-
return True
|
|
253
|
-
if "*" in basename or "?" in basename:
|
|
254
|
-
return True
|
|
255
|
-
return False
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _command_contains_windows_style_path(command: str) -> bool:
|
|
259
|
-
windows_drive_path_pattern = re.compile(r"(?<![A-Za-z0-9_])[A-Za-z]:\\")
|
|
260
|
-
windows_unc_path_pattern = re.compile(r"(?<!\S)\\\\[^\s\\]+\\[^\s\\]+")
|
|
261
|
-
return bool(
|
|
262
|
-
windows_drive_path_pattern.search(command)
|
|
263
|
-
or windows_unc_path_pattern.search(command)
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def _split_command_preserving_windows_backslashes(command: str) -> list[str]:
|
|
268
|
-
"""Tokenize a command, normalizing Windows backslashes while preserving the find terminator.
|
|
269
|
-
|
|
270
|
-
Plain POSIX ``shlex.split`` unescapes a ``\\;`` find action terminator to a
|
|
271
|
-
standalone ``;`` token. When the command carries Windows backslashes, the global
|
|
272
|
-
backslash-to-forward-slash normalization would otherwise rewrite that ``\\;`` to
|
|
273
|
-
``/;`` and bury the terminator inside a data token. The find action terminator is
|
|
274
|
-
restored to a standalone ``;`` token before the normalization so action slicing and
|
|
275
|
-
target collection see the same terminator on both platforms.
|
|
276
|
-
|
|
277
|
-
Args:
|
|
278
|
-
command: The raw Bash command string from the tool input.
|
|
279
|
-
|
|
280
|
-
Returns:
|
|
281
|
-
The shlex tokens of the command.
|
|
282
|
-
"""
|
|
283
|
-
if "\\" in command and (
|
|
284
|
-
os.name == "nt" or _command_contains_windows_style_path(command)
|
|
285
|
-
):
|
|
286
|
-
find_terminator_preserved_command = re.sub(r"\\;", " ; ", command)
|
|
287
|
-
forward_slash_normalized_command = find_terminator_preserved_command.replace("\\", "/")
|
|
288
|
-
return shlex.split(forward_slash_normalized_command)
|
|
289
|
-
return shlex.split(command)
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def _rm_flags_before_double_dash_are_unsafe(tokens_after_rm: list[str]) -> bool:
|
|
293
|
-
safe_long_options = frozenset({
|
|
294
|
-
"--recursive",
|
|
295
|
-
"--force",
|
|
296
|
-
"--verbose",
|
|
297
|
-
"--interactive",
|
|
298
|
-
"--dir",
|
|
299
|
-
})
|
|
300
|
-
for each_token in tokens_after_rm:
|
|
301
|
-
if each_token == "--":
|
|
302
|
-
return False
|
|
303
|
-
if not each_token.startswith("-"):
|
|
304
|
-
continue
|
|
305
|
-
if "=" in each_token:
|
|
306
|
-
return True
|
|
307
|
-
if each_token.startswith("--"):
|
|
308
|
-
if each_token not in safe_long_options:
|
|
309
|
-
return True
|
|
310
|
-
continue
|
|
311
|
-
short_rest = each_token[1:]
|
|
312
|
-
if not short_rest or not all(c in "rfRvidI" for c in short_rest):
|
|
313
|
-
return True
|
|
314
|
-
return False
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def _collect_rm_target_tokens(tokens_after_rm: list[str]) -> list[str]:
|
|
318
|
-
targets: list[str] = []
|
|
319
|
-
has_seen_end_of_options = False
|
|
320
|
-
for each_token in tokens_after_rm:
|
|
321
|
-
if not has_seen_end_of_options and each_token == "--":
|
|
322
|
-
has_seen_end_of_options = True
|
|
323
|
-
continue
|
|
324
|
-
if not has_seen_end_of_options and each_token.startswith("-"):
|
|
325
|
-
continue
|
|
326
|
-
targets.append(each_token)
|
|
327
|
-
return targets
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def rm_targets_only_ephemeral_paths(command: str) -> bool:
|
|
331
|
-
"""Return True when command is a single rm invocation whose every target is inside an ephemeral directory.
|
|
332
|
-
|
|
333
|
-
Refuses compound commands so operators like && / || / ; / | / backticks /
|
|
334
|
-
$(...) cannot piggy-back non-rm work on the ephemeral auto-allow. Refuses an
|
|
335
|
-
output redirection (``rm -rf /tmp/x>/etc/passwd`` truncates ``/etc/passwd``
|
|
336
|
-
even though the deletion targets an ephemeral path; shlex keeps the ``>`` glued
|
|
337
|
-
to the target token when no whitespace separates them). Rejects bare ephemeral
|
|
338
|
-
roots (/tmp, system temp dir) and bare directories named worktrees/worktree so
|
|
339
|
-
we never auto-approve wiping those roots. Only allows common short flags and a
|
|
340
|
-
small set of long options before ``--``; tokens with ``=`` or unknown long
|
|
341
|
-
options disable auto-allow.
|
|
342
|
-
"""
|
|
343
|
-
compound_shell_operator_pattern = re.compile(r'(?:&&|\|\||;|\||`|\$\()')
|
|
344
|
-
if compound_shell_operator_pattern.search(command):
|
|
345
|
-
return False
|
|
346
|
-
try:
|
|
347
|
-
all_command_tokens = _split_command_preserving_windows_backslashes(command)
|
|
348
|
-
except ValueError:
|
|
349
|
-
return False
|
|
350
|
-
if _segment_redirects_output_to_a_file(all_command_tokens):
|
|
351
|
-
return False
|
|
352
|
-
if len(all_command_tokens) < 2 or all_command_tokens[0] != "rm":
|
|
353
|
-
return False
|
|
354
|
-
tokens_after_rm = all_command_tokens[1:]
|
|
355
|
-
if _rm_flags_before_double_dash_are_unsafe(tokens_after_rm):
|
|
356
|
-
return False
|
|
357
|
-
all_target_tokens = _collect_rm_target_tokens(tokens_after_rm)
|
|
358
|
-
if not all_target_tokens:
|
|
359
|
-
return False
|
|
360
|
-
for each_target_token in all_target_tokens:
|
|
361
|
-
each_resolved_path = os.path.normpath(os.path.expanduser(each_target_token))
|
|
362
|
-
if _path_basename_is_shell_glob_wildcard(each_resolved_path):
|
|
363
|
-
return False
|
|
364
|
-
if _path_is_bare_ephemeral_root(each_resolved_path):
|
|
365
|
-
return False
|
|
366
|
-
if _path_is_bare_named_worktrees_container(each_resolved_path):
|
|
367
|
-
return False
|
|
368
|
-
if not directory_is_ephemeral(each_resolved_path):
|
|
369
|
-
return False
|
|
370
|
-
return True
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def _destructive_match_is_rm_family(matched_description: str) -> bool:
|
|
374
|
-
"""Return True when the matched destructive pattern is one of the rm-family deletes.
|
|
375
|
-
|
|
376
|
-
The rm-family descriptions all begin with the same prefix; the compound
|
|
377
|
-
ephemeral auto-allow and the quoted-mention guard act only on these, never on
|
|
378
|
-
git, database, or device patterns.
|
|
379
|
-
|
|
380
|
-
Args:
|
|
381
|
-
matched_description: A description from DESTRUCTIVE_BASH_PATTERNS.
|
|
382
|
-
|
|
383
|
-
Returns:
|
|
384
|
-
True when the description names an rm deletion.
|
|
385
|
-
"""
|
|
386
|
-
rm_family_description_prefix = "rm "
|
|
387
|
-
return matched_description.startswith(rm_family_description_prefix)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def _command_contains_shell_expansion(command: str) -> bool:
|
|
391
|
-
"""Return True when the command contains shell parameter or command expansion.
|
|
392
|
-
|
|
393
|
-
Any ``$`` (variable reference or ``$(...)`` command substitution) or backtick
|
|
394
|
-
subshell means a token could expand at runtime to ``rm`` or to an arbitrary
|
|
395
|
-
destructive command that the hook cannot resolve statically. The quoted-mention
|
|
396
|
-
guard and the compound ephemeral auto-allow both fail closed on this so they
|
|
397
|
-
never grant on a command whose effective program list is unknown until the
|
|
398
|
-
shell runs.
|
|
399
|
-
|
|
400
|
-
Args:
|
|
401
|
-
command: The raw Bash command string from the tool input.
|
|
402
|
-
|
|
403
|
-
Returns:
|
|
404
|
-
True when the command contains a ``$`` or backtick expansion character.
|
|
405
|
-
"""
|
|
406
|
-
return "$" in command or "`" in command
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def _split_tokens_into_shell_segments(all_command_tokens: list[str]) -> list[list[str]]:
|
|
410
|
-
"""Split a shlex token list into simple-command segments on control operators.
|
|
411
|
-
|
|
412
|
-
Segments are delimited by ``&&``, ``||``, ``;``, ``|&``, ``|`` and ``&`` tokens,
|
|
413
|
-
so each returned segment is one simple command. Operators that are not whitespace
|
|
414
|
-
separated stay inside one shlex token and therefore inside one segment; that
|
|
415
|
-
segment fails the absolute-ephemeral target check and the command falls through
|
|
416
|
-
to the prompt.
|
|
417
|
-
|
|
418
|
-
Args:
|
|
419
|
-
all_command_tokens: Tokens produced by shlex tokenization.
|
|
420
|
-
|
|
421
|
-
Returns:
|
|
422
|
-
A list of segments, each a list of tokens with operators removed.
|
|
423
|
-
"""
|
|
424
|
-
all_segments: list[list[str]] = []
|
|
425
|
-
current_segment: list[str] = []
|
|
426
|
-
for each_token in all_command_tokens:
|
|
427
|
-
if each_token in ALL_SHELL_CONTROL_OPERATOR_TOKENS:
|
|
428
|
-
all_segments.append(current_segment)
|
|
429
|
-
current_segment = []
|
|
430
|
-
continue
|
|
431
|
-
current_segment.append(each_token)
|
|
432
|
-
all_segments.append(current_segment)
|
|
433
|
-
return all_segments
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
def _leading_command_token(all_command_tokens: list[str]) -> str | None:
|
|
437
|
-
"""Return the program token that leads the command, skipping VAR=value prefixes.
|
|
438
|
-
|
|
439
|
-
A shell command may begin with one or more ``NAME=value`` environment
|
|
440
|
-
assignments (``FOO=bar rm -rf x``); the first token that is not such an
|
|
441
|
-
assignment is the program the shell executes. Returns None when every token is
|
|
442
|
-
an assignment or the list is empty.
|
|
443
|
-
|
|
444
|
-
Args:
|
|
445
|
-
all_command_tokens: Tokens produced by shlex tokenization.
|
|
446
|
-
|
|
447
|
-
Returns:
|
|
448
|
-
The leading program token, or None when there is no program token.
|
|
449
|
-
"""
|
|
450
|
-
leading_assignment_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
451
|
-
for each_token in all_command_tokens:
|
|
452
|
-
if leading_assignment_pattern.match(each_token):
|
|
453
|
-
continue
|
|
454
|
-
return each_token
|
|
455
|
-
return None
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
def _strip_leading_launcher_wrapper(all_command_tokens: list[str]) -> list[str] | None:
|
|
459
|
-
"""Return the tokens after a leading command-launcher wrapper, or None when absent.
|
|
460
|
-
|
|
461
|
-
A pure launcher wrapper (``timeout``, ``nohup``, ``nice``, ``ionice``,
|
|
462
|
-
``stdbuf``, ``time``, ``setsid``, ``chrt``, ``taskset``) forwards a trailing
|
|
463
|
-
command line to another program without itself executing a quoted string. To
|
|
464
|
-
find that real program, the launcher token and its own option tokens are
|
|
465
|
-
dropped: leading ``VAR=value`` assignments are skipped, the launcher token is
|
|
466
|
-
consumed, then option tokens are consumed until the first token that names a
|
|
467
|
-
program. A launcher option that takes a SEPARATE argument value
|
|
468
|
-
(``timeout -s SIGNAL`` / ``--signal SIGNAL``, ``timeout -k DURATION`` /
|
|
469
|
-
``--kill-after DURATION``, ``nice -n PRIORITY``) consumes both the flag and the
|
|
470
|
-
following value token, so a signal name such as ``KILL`` is never mistaken for
|
|
471
|
-
the wrapped program. Every dash-prefixed flag is consumed as well.
|
|
472
|
-
|
|
473
|
-
The first positional token after the launcher and its flags is its required
|
|
474
|
-
value for the launchers that take one (``timeout`` duration, ``chrt`` priority,
|
|
475
|
-
``taskset`` CPU mask or CPU range) and is consumed before the wrapped program. A
|
|
476
|
-
value matching the known shapes (decimal with optional unit suffix, hexadecimal
|
|
477
|
-
mask, CPU range/list) is consumed for any launcher. A launcher in
|
|
478
|
-
ALL_LAUNCHERS_REQUIRING_A_POSITIONAL_VALUE consumes its first positional even when
|
|
479
|
-
that value's shape is unrecognized (``timeout inf``, ``timeout 100ms``), so an
|
|
480
|
-
unrecognized duration never masks the wrapped program by being returned as the
|
|
481
|
-
program itself. A launcher that takes no positional value (``nohup``, ``time``,
|
|
482
|
-
``setsid``, ``ionice``, ``nice``, ``stdbuf``) returns its first positional as the
|
|
483
|
-
wrapped program. Returns None when the leading program is not a launcher wrapper.
|
|
484
|
-
|
|
485
|
-
A leading subshell or brace grouping character glued to the launcher token is
|
|
486
|
-
stripped before the launcher is identified, so ``(timeout N bash -c '...')``
|
|
487
|
-
resolves to its wrapped program.
|
|
488
|
-
|
|
489
|
-
Args:
|
|
490
|
-
all_command_tokens: Tokens of one shell segment.
|
|
491
|
-
|
|
492
|
-
Returns:
|
|
493
|
-
The tokens beginning at the wrapped program, an empty list when no program
|
|
494
|
-
follows the launcher value, or None when no launcher leads.
|
|
495
|
-
"""
|
|
496
|
-
leading_assignment_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
497
|
-
launcher_positional_value_pattern = re.compile(LAUNCHER_POSITIONAL_VALUE_SHAPE_PATTERN)
|
|
498
|
-
first_program_index = next(
|
|
499
|
-
(
|
|
500
|
-
index
|
|
501
|
-
for index, token in enumerate(all_command_tokens)
|
|
502
|
-
if not leading_assignment_pattern.match(token)
|
|
503
|
-
),
|
|
504
|
-
None,
|
|
505
|
-
)
|
|
506
|
-
if first_program_index is None:
|
|
507
|
-
return None
|
|
508
|
-
leading_command_basename = Path(_strip_leading_subshell_grouping_characters(all_command_tokens[first_program_index])).name.lower()
|
|
509
|
-
if leading_command_basename not in ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS:
|
|
510
|
-
return None
|
|
511
|
-
launcher_requires_a_positional_value = (
|
|
512
|
-
leading_command_basename in ALL_LAUNCHERS_REQUIRING_A_POSITIONAL_VALUE
|
|
513
|
-
)
|
|
514
|
-
each_index = first_program_index + 1
|
|
515
|
-
has_consumed_required_positional_value = False
|
|
516
|
-
skip_next_token_as_option_value = False
|
|
517
|
-
while each_index < len(all_command_tokens):
|
|
518
|
-
each_token = all_command_tokens[each_index]
|
|
519
|
-
if skip_next_token_as_option_value:
|
|
520
|
-
skip_next_token_as_option_value = False
|
|
521
|
-
each_index += 1
|
|
522
|
-
continue
|
|
523
|
-
if each_token in ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE:
|
|
524
|
-
skip_next_token_as_option_value = True
|
|
525
|
-
each_index += 1
|
|
526
|
-
continue
|
|
527
|
-
if each_token.startswith("-"):
|
|
528
|
-
each_index += 1
|
|
529
|
-
continue
|
|
530
|
-
each_basename = Path(each_token).name.lower()
|
|
531
|
-
if launcher_positional_value_pattern.match(each_basename):
|
|
532
|
-
has_consumed_required_positional_value = True
|
|
533
|
-
each_index += 1
|
|
534
|
-
continue
|
|
535
|
-
if launcher_requires_a_positional_value and not has_consumed_required_positional_value:
|
|
536
|
-
has_consumed_required_positional_value = True
|
|
537
|
-
each_index += 1
|
|
538
|
-
continue
|
|
539
|
-
return all_command_tokens[each_index:]
|
|
540
|
-
return []
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def _find_exec_action_program_token_lists(all_segment_tokens: list[str]) -> list[list[str]]:
|
|
544
|
-
"""Return the program-token list of each ``find`` ``-exec``/``-execdir`` action.
|
|
545
|
-
|
|
546
|
-
A ``find ... -exec <program> <args...> ;`` (or ``-execdir``, or a ``+``
|
|
547
|
-
terminator) action runs ``<program> <args...>`` against the matched files, so the
|
|
548
|
-
program tokens are every token after the action flag up to the next ``;`` or ``+``
|
|
549
|
-
terminator. One ``find`` may carry several such actions, so every action's program
|
|
550
|
-
tokens are collected. An action flag with no following program tokens before its
|
|
551
|
-
terminator (or before the token list ends) contributes nothing.
|
|
552
|
-
|
|
553
|
-
Args:
|
|
554
|
-
all_segment_tokens: The tokens of a single shell segment.
|
|
555
|
-
|
|
556
|
-
Returns:
|
|
557
|
-
One program-token list per ``-exec``/``-execdir`` action that has program
|
|
558
|
-
tokens, in the order the actions appear.
|
|
559
|
-
"""
|
|
560
|
-
all_action_program_token_lists: list[list[str]] = []
|
|
561
|
-
each_token_index = 0
|
|
562
|
-
while each_token_index < len(all_segment_tokens):
|
|
563
|
-
if all_segment_tokens[each_token_index] not in ALL_FIND_EXEC_ACTION_FLAGS:
|
|
564
|
-
each_token_index += 1
|
|
565
|
-
continue
|
|
566
|
-
each_program_token_index = each_token_index + 1
|
|
567
|
-
current_action_program_tokens: list[str] = []
|
|
568
|
-
while (
|
|
569
|
-
each_program_token_index < len(all_segment_tokens)
|
|
570
|
-
and all_segment_tokens[each_program_token_index] not in ALL_FIND_EXEC_ACTION_TERMINATORS
|
|
571
|
-
):
|
|
572
|
-
current_action_program_tokens.append(all_segment_tokens[each_program_token_index])
|
|
573
|
-
each_program_token_index += 1
|
|
574
|
-
if current_action_program_tokens:
|
|
575
|
-
all_action_program_token_lists.append(current_action_program_tokens)
|
|
576
|
-
each_token_index = each_program_token_index + 1
|
|
577
|
-
return all_action_program_token_lists
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
def _command_executes_a_string_argument(all_command_tokens: list[str]) -> bool:
|
|
581
|
-
"""Return True when the command's leading program runs a string argument as code.
|
|
582
|
-
|
|
583
|
-
Shell interpreters and wrappers (``bash``, ``sh``, ``eval``, ``sudo``,
|
|
584
|
-
``xargs`` and the rest of ALL_INTERPRETER_AND_WRAPPER_COMMANDS) and remote
|
|
585
|
-
runners such as ``ssh`` execute a following quoted token as a command line, so
|
|
586
|
-
``bash -c 'rm -rf /etc'`` and ``ssh host 'rm -rf /etc'`` run ``rm`` even though
|
|
587
|
-
no token's basename is ``rm``. Language interpreters (``python``, ``perl``,
|
|
588
|
-
``ruby``, ``node`` and the rest of ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS) run
|
|
589
|
-
a string only with a ``-c`` or ``-e`` flag, so those qualify only when such a
|
|
590
|
-
flag is present.
|
|
591
|
-
|
|
592
|
-
A pure command-launcher wrapper (``timeout``, ``nohup``, ``nice`` and the rest
|
|
593
|
-
of ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS) does not run a quoted string itself but
|
|
594
|
-
forwards argv to a following program, so a ``timeout`` in front of
|
|
595
|
-
``bash -c 'rm -rf /etc'`` runs ``rm`` through the wrapped ``bash``. The wrapper
|
|
596
|
-
and its own flags are stripped and the wrapped program is re-evaluated,
|
|
597
|
-
recursively through stacked wrappers, so a launcher in front of an interpreter is
|
|
598
|
-
caught while a launcher in front of a plain program (a ``timeout`` in front of
|
|
599
|
-
``rm -rf /tmp/scratch``) still reports False and reaches the legitimate-mention
|
|
600
|
-
path.
|
|
601
|
-
|
|
602
|
-
A leading subshell ``(`` or brace ``{`` grouping character glued to the program
|
|
603
|
-
token is stripped before the program is identified, so ``(bash -c 'rm -rf /etc')``
|
|
604
|
-
and ``(timeout N bash -c 'rm -rf /etc')`` are caught.
|
|
605
|
-
|
|
606
|
-
A leading ``find`` runs each ``-exec``/``-execdir`` action's program against the
|
|
607
|
-
matched files, so each action's program tokens are re-evaluated through this same
|
|
608
|
-
detection: ``find . -exec bash -c 'rm -rf /etc' ;`` and
|
|
609
|
-
``find . -exec python -c '...' ;`` are caught because the action runs an
|
|
610
|
-
interpreter on a quoted string, while ``find . -exec rm -rf {} +`` reports False
|
|
611
|
-
because the action's program ``rm`` executes no quoted string.
|
|
612
|
-
|
|
613
|
-
Args:
|
|
614
|
-
all_command_tokens: Tokens produced by shlex tokenization.
|
|
615
|
-
|
|
616
|
-
Returns:
|
|
617
|
-
True when the leading program executes a quoted string argument as code.
|
|
618
|
-
"""
|
|
619
|
-
leading_command_token = _leading_command_token(all_command_tokens)
|
|
620
|
-
if leading_command_token is None:
|
|
621
|
-
return False
|
|
622
|
-
leading_command_basename = Path(_strip_leading_subshell_grouping_characters(leading_command_token)).name.lower()
|
|
623
|
-
if leading_command_basename in ALL_INTERPRETER_AND_WRAPPER_COMMANDS:
|
|
624
|
-
return True
|
|
625
|
-
if leading_command_basename in ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS:
|
|
626
|
-
wrapped_program_tokens = _strip_leading_launcher_wrapper(all_command_tokens)
|
|
627
|
-
if not wrapped_program_tokens:
|
|
628
|
-
return False
|
|
629
|
-
return _command_executes_a_string_argument(wrapped_program_tokens)
|
|
630
|
-
if leading_command_basename == FIND_PROGRAM_NAME:
|
|
631
|
-
return any(
|
|
632
|
-
_command_executes_a_string_argument(each_action_program_tokens)
|
|
633
|
-
for each_action_program_tokens in _find_exec_action_program_token_lists(all_command_tokens)
|
|
634
|
-
)
|
|
635
|
-
if leading_command_basename not in ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS:
|
|
636
|
-
return False
|
|
637
|
-
if leading_command_basename == "ssh":
|
|
638
|
-
return True
|
|
639
|
-
return any(
|
|
640
|
-
each_token in ALL_STRING_ARGUMENT_EXECUTION_FLAGS for each_token in all_command_tokens
|
|
641
|
-
)
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def _explode_glued_shell_control_operators(all_command_tokens: list[str]) -> list[str]:
|
|
645
|
-
"""Split control operators off shlex tokens that glue them to a program name.
|
|
646
|
-
|
|
647
|
-
shlex keeps a control operator joined to an adjacent program when no whitespace
|
|
648
|
-
separates them, so ``true; eval 'x'`` tokenizes to ``['true;', 'eval', 'x']``
|
|
649
|
-
with the ``;`` hidden inside ``true;``. This re-splits each token on the
|
|
650
|
-
unquoted control operators ``&&`` / ``||`` / ``;`` / ``|&`` / ``|`` / ``&`` and
|
|
651
|
-
on the POSIX command terminators newline and carriage return, so the operator
|
|
652
|
-
becomes its own token and segment boundaries are visible. The ``|&`` pipe (stdout
|
|
653
|
-
and stderr both into the next command) is matched before the single ``|`` so a
|
|
654
|
-
glued ``cat foo|&tee x`` splits on ``|&`` rather than leaving ``&tee`` joined. The
|
|
655
|
-
lone background ``&`` is split only when it neighbors no ``>`` redirection
|
|
656
|
-
character, so a file-descriptor duplication such as a stderr-to-stdout redirect
|
|
657
|
-
stays one token and is left for the redirection guard rather than torn into a
|
|
658
|
-
dangling redirect fragment that would misread as a hidden segment boundary. shlex
|
|
659
|
-
has already removed quoting, so any operator character still present in a token
|
|
660
|
-
came from unquoted shell source and is a real boundary.
|
|
661
|
-
|
|
662
|
-
Args:
|
|
663
|
-
all_command_tokens: Tokens produced by shlex tokenization.
|
|
664
|
-
|
|
665
|
-
Returns:
|
|
666
|
-
Tokens with glued control operators separated into standalone tokens.
|
|
667
|
-
"""
|
|
668
|
-
control_operator_split_pattern = re.compile(r"(&&|\|\||;|\|&|\||(?<!>)&(?!>)|\n|\r)")
|
|
669
|
-
all_exploded_tokens: list[str] = []
|
|
670
|
-
for each_token in all_command_tokens:
|
|
671
|
-
for each_fragment in control_operator_split_pattern.split(each_token):
|
|
672
|
-
if each_fragment:
|
|
673
|
-
all_exploded_tokens.append(each_fragment)
|
|
674
|
-
return all_exploded_tokens
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
def _strip_leading_subshell_grouping_characters(token: str) -> str:
|
|
678
|
-
"""Return a token with leading subshell-grouping characters removed.
|
|
679
|
-
|
|
680
|
-
shlex keeps a subshell ``(`` or brace-group ``{`` joined to an adjacent program
|
|
681
|
-
when no whitespace separates them, so ``(rm -rf /etc)`` tokenizes to
|
|
682
|
-
``['(rm', '-rf', '/etc)']`` with the ``(`` hidden inside ``(rm``. Stripping the
|
|
683
|
-
leading grouping characters exposes the real program name (``rm``) so the
|
|
684
|
-
rm-detection check sees it. shlex has already removed quoting, so any grouping
|
|
685
|
-
character still present came from unquoted shell source.
|
|
686
|
-
|
|
687
|
-
Args:
|
|
688
|
-
token: One token produced by shlex tokenization.
|
|
689
|
-
|
|
690
|
-
Returns:
|
|
691
|
-
The token with leading ``(`` and ``{`` characters removed.
|
|
692
|
-
"""
|
|
693
|
-
return token.lstrip(ALL_SUBSHELL_GROUPING_CHARACTERS)
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
def _any_shell_segment_executes_a_string_argument(all_command_tokens: list[str]) -> bool:
|
|
697
|
-
"""Return True when any shell segment's leading program runs a string as code.
|
|
698
|
-
|
|
699
|
-
A ``find`` ``\\;`` action terminator tokenizes to a bare ``;``, which the
|
|
700
|
-
segment splitter treats as a command separator, so a second
|
|
701
|
-
``-exec <interpreter> -c '...'`` action becomes its own segment whose leader is
|
|
702
|
-
``-exec`` — a leader no detector recognizes. Each ``find`` ``-exec``/``-execdir``
|
|
703
|
-
action's program tokens are therefore scanned first on the full pre-split token
|
|
704
|
-
list (where the ``;``/``+`` terminators are still standalone), so a buried
|
|
705
|
-
``find . -exec touch {} ; -exec bash -c 'rm -rf /etc' ;`` action is caught before
|
|
706
|
-
the per-segment loop runs.
|
|
707
|
-
|
|
708
|
-
Splits the command into simple-command segments on ``&&`` / ``||`` / ``;`` /
|
|
709
|
-
``|`` / ``&`` and applies the leading-program string-execution check to each.
|
|
710
|
-
A benign program leading the whole command (``echo hi && bash -c 'rm -rf /etc'``,
|
|
711
|
-
``true; eval 'rm -rf /etc'``) must not mask an interpreter that runs the
|
|
712
|
-
destructive ``rm`` inside a later segment, so every segment is inspected rather
|
|
713
|
-
than only the command's first program. Control operators glued to a program by
|
|
714
|
-
missing whitespace are separated first so those segment boundaries are seen.
|
|
715
|
-
|
|
716
|
-
Args:
|
|
717
|
-
all_command_tokens: Tokens produced by shlex tokenization.
|
|
718
|
-
|
|
719
|
-
Returns:
|
|
720
|
-
True when at least one segment's leading program executes a quoted string
|
|
721
|
-
argument as code.
|
|
722
|
-
"""
|
|
723
|
-
if any(
|
|
724
|
-
_command_executes_a_string_argument(each_action_program_tokens)
|
|
725
|
-
for each_action_program_tokens in _find_exec_action_program_token_lists(all_command_tokens)
|
|
726
|
-
):
|
|
727
|
-
return True
|
|
728
|
-
all_exploded_tokens = _explode_glued_shell_control_operators(all_command_tokens)
|
|
729
|
-
for each_segment in _split_tokens_into_shell_segments(all_exploded_tokens):
|
|
730
|
-
if each_segment and _command_executes_a_string_argument(each_segment):
|
|
731
|
-
return True
|
|
732
|
-
return False
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
def _command_executes_a_string_in_any_segment(command: str) -> bool:
|
|
736
|
-
"""Return True when any segment of any physical line runs a quoted string as code.
|
|
737
|
-
|
|
738
|
-
Splits the command on the POSIX newline and carriage-return terminators,
|
|
739
|
-
tokenizes each line preserving Windows paths, and reports whether any shell
|
|
740
|
-
segment's leading program executes a quoted string argument as code
|
|
741
|
-
(``bash -c '...'``, ``eval '...'``, ``ssh host '...'``, ``python -c '...'``, or a
|
|
742
|
-
launcher wrapping any of these). The broad ephemeral-cwd auto-allow declines such
|
|
743
|
-
a command because the executed string can delete a path outside the ephemeral
|
|
744
|
-
working directory that no plain token names. Fails closed (returns True) when a
|
|
745
|
-
line cannot be tokenized.
|
|
746
|
-
|
|
747
|
-
Args:
|
|
748
|
-
command: The raw Bash command string from the tool input.
|
|
749
|
-
|
|
750
|
-
Returns:
|
|
751
|
-
True when at least one segment executes a quoted string argument as code.
|
|
752
|
-
"""
|
|
753
|
-
for each_command_line in re.split(r"[\n\r]+", command):
|
|
754
|
-
try:
|
|
755
|
-
all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
|
|
756
|
-
except ValueError:
|
|
757
|
-
return True
|
|
758
|
-
if _any_shell_segment_executes_a_string_argument(all_command_tokens):
|
|
759
|
-
return True
|
|
760
|
-
return False
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
def command_has_no_real_rm_invocation(command: str) -> bool:
|
|
764
|
-
"""Return True when no shell token in the command actually invokes ``rm``.
|
|
765
|
-
|
|
766
|
-
Distinguishes a destructive-pattern match that lands inside a quoted string
|
|
767
|
-
argument (``grep 'rm -rf foo' log``, ``echo "rm -rf x"``,
|
|
768
|
-
``git commit -m "rm -rf cleanup"``) from a command that runs ``rm``. A quoted
|
|
769
|
-
mention tokenizes to a single token whose path basename is not ``rm``, so it is
|
|
770
|
-
reported as having no real invocation and the spurious ``rm`` prompt is
|
|
771
|
-
suppressed.
|
|
772
|
-
|
|
773
|
-
Fails closed (returns False, meaning "treat as a real invocation, keep
|
|
774
|
-
prompting") when the command contains shell expansion (``$`` or backtick),
|
|
775
|
-
where a token such as ``$RM`` could expand to ``rm``; when tokenization fails on
|
|
776
|
-
unbalanced quotes; or when any shell segment's leading program executes a quoted
|
|
777
|
-
string argument as code (``bash -c 'rm -rf /etc'``, ``eval 'rm -rf /etc'``,
|
|
778
|
-
``ssh host 'rm -rf /etc'``, ``awk 'BEGIN{system("rm -rf /etc")}'``,
|
|
779
|
-
``echo hi && bash -c 'rm -rf /etc'``, ``timeout bash -c 'rm -rf /etc'``), where
|
|
780
|
-
the destructive ``rm`` rides inside an executed string rather than a passive
|
|
781
|
-
mention. The command is split on the POSIX newline and carriage-return command
|
|
782
|
-
terminators before tokenizing, because shlex treats those as whitespace and would
|
|
783
|
-
otherwise merge a later-line interpreter (``echo safe`` newline
|
|
784
|
-
``bash -c 'rm -rf /etc'``) into the benign leading segment. The per-segment check
|
|
785
|
-
means a benign leader on a line does not mask an interpreter later on that line.
|
|
786
|
-
``/bin/rm``, ``sudo rm`` and ``\\rm`` each tokenize to a token whose basename is
|
|
787
|
-
``rm`` and are correctly reported as real. Before the rm-detection scan, each
|
|
788
|
-
token is split on glued control operators and stripped of leading subshell- and
|
|
789
|
-
brace-grouping characters, so ``(rm -rf /etc)``, ``;rm -rf /etc`` and
|
|
790
|
-
``echo|rm -rf /etc`` expose ``rm`` as a real invocation rather than a passive
|
|
791
|
-
mention.
|
|
792
|
-
|
|
793
|
-
Args:
|
|
794
|
-
command: The raw Bash command string from the tool input.
|
|
795
|
-
|
|
796
|
-
Returns:
|
|
797
|
-
True when the command contains no real ``rm`` invocation.
|
|
798
|
-
"""
|
|
799
|
-
if _command_contains_shell_expansion(command):
|
|
800
|
-
return False
|
|
801
|
-
all_physical_command_lines = re.split(r"[\n\r]+", command)
|
|
802
|
-
for each_command_line in all_physical_command_lines:
|
|
803
|
-
try:
|
|
804
|
-
all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
|
|
805
|
-
except ValueError:
|
|
806
|
-
return False
|
|
807
|
-
if _any_shell_segment_executes_a_string_argument(all_command_tokens):
|
|
808
|
-
return False
|
|
809
|
-
all_operator_split_tokens = _explode_glued_shell_control_operators(all_command_tokens)
|
|
810
|
-
for each_token in all_operator_split_tokens:
|
|
811
|
-
each_program_token = _strip_leading_subshell_grouping_characters(each_token)
|
|
812
|
-
if Path(each_program_token).name == "rm":
|
|
813
|
-
return False
|
|
814
|
-
return True
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
def _find_non_rm_destructive_pattern(command: str) -> str | None:
|
|
818
|
-
"""Return the first non-rm-family destructive pattern description, or None.
|
|
819
|
-
|
|
820
|
-
Applied after the quoted-mention guard finds a matched rm-family pattern to be
|
|
821
|
-
a false positive: the command is rescanned for any other destructive pattern
|
|
822
|
-
(force push, git clean, mkfs, dd, DROP/TRUNCATE, signing bypass) so a real
|
|
823
|
-
non-rm hazard riding alongside the quoted mention
|
|
824
|
-
(``grep 'rm -rf' f && git push --force origin main``) still prompts.
|
|
825
|
-
|
|
826
|
-
Args:
|
|
827
|
-
command: The raw Bash command string from the tool input.
|
|
828
|
-
|
|
829
|
-
Returns:
|
|
830
|
-
The description of the first matching non-rm-family pattern, or None.
|
|
831
|
-
"""
|
|
832
|
-
for each_pattern_regex, each_pattern_description in DESTRUCTIVE_BASH_PATTERNS:
|
|
833
|
-
if _destructive_match_is_rm_family(each_pattern_description):
|
|
834
|
-
continue
|
|
835
|
-
if each_pattern_regex.search(command):
|
|
836
|
-
return each_pattern_description
|
|
837
|
-
return None
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
def _find_non_force_push_destructive_hazard(command: str) -> str | None:
|
|
841
|
-
"""Return a destructive hazard riding alongside a convergence force-push, or None.
|
|
842
|
-
|
|
843
|
-
Applied when a force-push to a convergence branch is being auto-allowed: the
|
|
844
|
-
command is rescanned for any destructive pattern other than the force-push itself
|
|
845
|
-
so a real co-resident hazard (``git push --force origin claude/x && git reset
|
|
846
|
-
--hard``) still prompts. The force-push patterns are skipped because they are the
|
|
847
|
-
very thing the convergence exemption grants. An rm-family pattern is skipped when
|
|
848
|
-
it is only a quoted mention (``echo "rm -rf foo" && git push --force origin
|
|
849
|
-
claude/x``), so a passive ``rm`` string does not re-block a legitimate push.
|
|
850
|
-
|
|
851
|
-
Args:
|
|
852
|
-
command: The raw Bash command string from the tool input.
|
|
853
|
-
|
|
854
|
-
Returns:
|
|
855
|
-
The description of the first co-resident destructive hazard, or None.
|
|
856
|
-
"""
|
|
857
|
-
for each_pattern_regex, each_pattern_description in DESTRUCTIVE_BASH_PATTERNS:
|
|
858
|
-
if "git push" in each_pattern_description and (
|
|
859
|
-
"force" in each_pattern_description or "-f" in each_pattern_description
|
|
860
|
-
):
|
|
861
|
-
continue
|
|
862
|
-
if not each_pattern_regex.search(command):
|
|
863
|
-
continue
|
|
864
|
-
if _destructive_match_is_rm_family(
|
|
865
|
-
each_pattern_description
|
|
866
|
-
) and command_has_no_real_rm_invocation(command):
|
|
867
|
-
continue
|
|
868
|
-
return each_pattern_description
|
|
869
|
-
return None
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
def _command_contains_non_rm_family_destructive_pattern(command: str) -> bool:
|
|
873
|
-
"""Return True when any destructive pattern in the command is not rm-family.
|
|
874
|
-
|
|
875
|
-
The compound ephemeral auto-allow grants only when every destructive pattern
|
|
876
|
-
present is an rm deletion. A git reset --hard, force push, git clean, mkfs, dd,
|
|
877
|
-
or DROP/TRUNCATE riding inside the chain is not bounded by the ephemeral rm
|
|
878
|
-
targets, so its presence declines the whole auto-allow.
|
|
879
|
-
|
|
880
|
-
Args:
|
|
881
|
-
command: The raw Bash command string from the tool input.
|
|
882
|
-
|
|
883
|
-
Returns:
|
|
884
|
-
True when at least one matching destructive pattern is not rm-family.
|
|
885
|
-
"""
|
|
886
|
-
for each_pattern_regex, each_pattern_description in DESTRUCTIVE_BASH_PATTERNS:
|
|
887
|
-
if each_pattern_regex.search(command) and not _destructive_match_is_rm_family(
|
|
888
|
-
each_pattern_description
|
|
889
|
-
):
|
|
890
|
-
return True
|
|
891
|
-
return False
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
def _rm_segment_targets_only_absolute_ephemeral_paths(all_rm_segment_tokens: list[str]) -> bool:
|
|
895
|
-
"""Return True when an ``rm`` segment's every target is an absolute ephemeral path.
|
|
896
|
-
|
|
897
|
-
``all_rm_segment_tokens`` is one shell segment beginning at its ``rm`` command
|
|
898
|
-
token. Rejects the segment (returns False) when the segment carries an output
|
|
899
|
-
redirection (``rm -rf /tmp/x>/etc/passwd`` truncates ``/etc/passwd`` even though
|
|
900
|
-
the deletion targets an ephemeral path; shlex keeps the ``>`` glued to the target
|
|
901
|
-
token when no whitespace separates them), when an unsafe flag precedes ``--``,
|
|
902
|
-
when there are no targets, when a target is relative (the compound auto-allow
|
|
903
|
-
refuses to resolve relative targets without a trusted working directory), when
|
|
904
|
-
a target basename is a glob wildcard, when a target is a bare ephemeral root or
|
|
905
|
-
a bare worktrees container, or when a target is not inside an ephemeral
|
|
906
|
-
directory.
|
|
907
|
-
|
|
908
|
-
Args:
|
|
909
|
-
all_rm_segment_tokens: Shlex tokens of a single ``rm`` segment, the first
|
|
910
|
-
token being the ``rm`` command.
|
|
911
|
-
|
|
912
|
-
Returns:
|
|
913
|
-
True when every target is an absolute ephemeral path safe to auto-allow.
|
|
914
|
-
"""
|
|
915
|
-
if _segment_redirects_output_to_a_file(all_rm_segment_tokens):
|
|
916
|
-
return False
|
|
917
|
-
tokens_after_rm = all_rm_segment_tokens[1:]
|
|
918
|
-
if _rm_flags_before_double_dash_are_unsafe(tokens_after_rm):
|
|
919
|
-
return False
|
|
920
|
-
all_target_tokens = _collect_rm_target_tokens(tokens_after_rm)
|
|
921
|
-
if not all_target_tokens:
|
|
922
|
-
return False
|
|
923
|
-
for each_target_token in all_target_tokens:
|
|
924
|
-
each_expanded_target = os.path.expanduser(each_target_token)
|
|
925
|
-
each_is_absolute = (
|
|
926
|
-
os.path.isabs(each_expanded_target)
|
|
927
|
-
or each_expanded_target.replace("\\", "/").startswith("/")
|
|
928
|
-
)
|
|
929
|
-
if not each_is_absolute:
|
|
930
|
-
return False
|
|
931
|
-
each_resolved_target = os.path.normpath(each_expanded_target)
|
|
932
|
-
if _path_basename_is_shell_glob_wildcard(each_resolved_target):
|
|
933
|
-
return False
|
|
934
|
-
if _path_is_bare_ephemeral_root(each_resolved_target):
|
|
935
|
-
return False
|
|
936
|
-
if _path_is_bare_named_worktrees_container(each_resolved_target):
|
|
937
|
-
return False
|
|
938
|
-
if not directory_is_ephemeral(each_resolved_target):
|
|
939
|
-
return False
|
|
940
|
-
return True
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
def _path_is_the_null_device(path_token: str) -> bool:
|
|
944
|
-
"""Return True when a path token names the null device (``/dev/null`` or ``nul``).
|
|
945
|
-
|
|
946
|
-
Args:
|
|
947
|
-
path_token: A redirect-target path token.
|
|
948
|
-
|
|
949
|
-
Returns:
|
|
950
|
-
True when the token names the null device.
|
|
951
|
-
"""
|
|
952
|
-
return path_token.replace("\\", "/").rstrip("/").lower() in ("/dev/null", "nul")
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
def _segment_redirects_output_to_a_file(all_segment_tokens: list[str]) -> bool:
|
|
956
|
-
"""Return True when a segment writes its output to a file via shell redirection.
|
|
957
|
-
|
|
958
|
-
An output redirection (a plain, appending, clobbering, or combined operator, with
|
|
959
|
-
or without a leading file-descriptor number) truncates or rewrites the redirect
|
|
960
|
-
target, so ``cat /dev/null > /etc/important.conf`` destroys the target file even
|
|
961
|
-
though ``cat`` itself is read-only. A redirect whose target is the null device
|
|
962
|
-
(``/dev/null`` or ``nul``) writes nothing and stays read-only, so it does not count;
|
|
963
|
-
a redirect to any other file counts. A file-descriptor duplication that names another
|
|
964
|
-
descriptor as its target writes no file and stays read-only. shlex keeps a
|
|
965
|
-
redirect operator glued to an adjacent program or target token when no whitespace
|
|
966
|
-
separates them (``echo pwned>/etc/passwd``, ``cat secret>/etc/x``), so each token is
|
|
967
|
-
scanned for a redirect operator anywhere within it rather than tested for exact
|
|
968
|
-
equality; the target is read from the same token after the operator, or from the next
|
|
969
|
-
token when the operator ends its own token. The benign-segment check declines any
|
|
970
|
-
segment carrying a redirect to a non-null file so a benign program that overwrites a
|
|
971
|
-
non-ephemeral file does not ride the ephemeral ``rm`` auto-allow.
|
|
972
|
-
|
|
973
|
-
Args:
|
|
974
|
-
all_segment_tokens: Shlex tokens of one shell segment.
|
|
975
|
-
|
|
976
|
-
Returns:
|
|
977
|
-
True when any token contains a redirect to a file other than the null device.
|
|
978
|
-
"""
|
|
979
|
-
output_redirection_pattern = re.compile(OUTPUT_REDIRECTION_OPERATOR_PATTERN)
|
|
980
|
-
for each_index, each_token in enumerate(all_segment_tokens):
|
|
981
|
-
operator_match = output_redirection_pattern.search(each_token)
|
|
982
|
-
if operator_match is None:
|
|
983
|
-
continue
|
|
984
|
-
glued_redirect_target = each_token[operator_match.end():]
|
|
985
|
-
if glued_redirect_target:
|
|
986
|
-
redirect_target = glued_redirect_target
|
|
987
|
-
elif each_index + 1 < len(all_segment_tokens):
|
|
988
|
-
redirect_target = all_segment_tokens[each_index + 1]
|
|
989
|
-
else:
|
|
990
|
-
return True
|
|
991
|
-
if _path_is_the_null_device(redirect_target):
|
|
992
|
-
continue
|
|
993
|
-
return True
|
|
994
|
-
return False
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
def _all_positional_tokens_after_leader(all_segment_tokens: list[str]) -> list[str]:
|
|
998
|
-
"""Return the non-flag tokens that follow a segment's leading program.
|
|
999
|
-
|
|
1000
|
-
Skips leading ``VAR=value`` assignments, the program token itself, every
|
|
1001
|
-
dash-prefixed flag, and any ``key=value`` flag value, leaving the positional
|
|
1002
|
-
words that name a subcommand chain (``repo``, ``delete`` in ``gh repo delete``;
|
|
1003
|
-
``stash``, ``drop`` in ``git stash drop``).
|
|
1004
|
-
|
|
1005
|
-
Args:
|
|
1006
|
-
all_segment_tokens: Shlex tokens of one shell segment.
|
|
1007
|
-
|
|
1008
|
-
Returns:
|
|
1009
|
-
The positional tokens after the leading program, in order.
|
|
1010
|
-
"""
|
|
1011
|
-
leading_assignment_pattern = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
1012
|
-
first_program_index = next(
|
|
1013
|
-
(
|
|
1014
|
-
index
|
|
1015
|
-
for index, token in enumerate(all_segment_tokens)
|
|
1016
|
-
if not leading_assignment_pattern.match(token)
|
|
1017
|
-
),
|
|
1018
|
-
None,
|
|
1019
|
-
)
|
|
1020
|
-
if first_program_index is None:
|
|
1021
|
-
return []
|
|
1022
|
-
return [
|
|
1023
|
-
each_token
|
|
1024
|
-
for each_token in all_segment_tokens[first_program_index + 1:]
|
|
1025
|
-
if not each_token.startswith("-") and "=" not in each_token
|
|
1026
|
-
]
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
def _gh_segment_names_an_explicit_method(
|
|
1030
|
-
all_segment_tokens: list[str], target_method: str
|
|
1031
|
-
) -> bool:
|
|
1032
|
-
"""Return True when a ``gh`` segment explicitly names ``target_method``.
|
|
1033
|
-
|
|
1034
|
-
Recognizes both ``gh`` flag spellings: the space-separated form where the flag
|
|
1035
|
-
(``-X``/``--method``) is its own token and the next token names the method
|
|
1036
|
-
(``-X GET``), and the glued forms where the method is inside the flag token
|
|
1037
|
-
(``-XGET``, ``--method=GET``). The match is case-insensitive against the
|
|
1038
|
-
already-uppercased ``target_method``.
|
|
1039
|
-
|
|
1040
|
-
Args:
|
|
1041
|
-
all_segment_tokens: Shlex tokens of one shell segment.
|
|
1042
|
-
target_method: The HTTP method name to match, uppercased.
|
|
1043
|
-
|
|
1044
|
-
Returns:
|
|
1045
|
-
True when an ``-X``/``--method`` flag names ``target_method``.
|
|
1046
|
-
"""
|
|
1047
|
-
for each_index, each_token in enumerate(all_segment_tokens):
|
|
1048
|
-
if each_token.startswith(GH_SHORT_METHOD_FLAG_PREFIX):
|
|
1049
|
-
inline_method = each_token[len(GH_SHORT_METHOD_FLAG_PREFIX) :]
|
|
1050
|
-
elif each_token.startswith(GH_LONG_METHOD_FLAG_EQUALS_PREFIX):
|
|
1051
|
-
inline_method = each_token[len(GH_LONG_METHOD_FLAG_EQUALS_PREFIX) :]
|
|
1052
|
-
else:
|
|
1053
|
-
inline_method = ""
|
|
1054
|
-
if inline_method.upper() == target_method:
|
|
1055
|
-
return True
|
|
1056
|
-
if each_token not in ALL_GH_HTTP_WRITE_METHOD_FLAGS:
|
|
1057
|
-
continue
|
|
1058
|
-
each_next_index = each_index + 1
|
|
1059
|
-
if each_next_index < len(all_segment_tokens) and (
|
|
1060
|
-
all_segment_tokens[each_next_index].upper() == target_method
|
|
1061
|
-
):
|
|
1062
|
-
return True
|
|
1063
|
-
return False
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
def _gh_segment_carries_a_request_body_field(all_segment_tokens: list[str]) -> bool:
|
|
1067
|
-
"""Return True when a ``gh api`` segment adds a request-body field.
|
|
1068
|
-
|
|
1069
|
-
``gh api`` adds a parameter to the request body through ``-f``/``--raw-field``,
|
|
1070
|
-
``-F``/``--field``, or ``--input``, each accepted as its own token (``-f title=x``,
|
|
1071
|
-
``--field a=b``) or glued to its value (``-ftitle=x``, ``--field=a=b``,
|
|
1072
|
-
``--input=body.json``).
|
|
1073
|
-
|
|
1074
|
-
Args:
|
|
1075
|
-
all_segment_tokens: Shlex tokens of one shell segment.
|
|
1076
|
-
|
|
1077
|
-
Returns:
|
|
1078
|
-
True when any token is a request-body field flag.
|
|
1079
|
-
"""
|
|
1080
|
-
for each_token in all_segment_tokens:
|
|
1081
|
-
if each_token in ALL_GH_API_REQUEST_BODY_FIELD_FLAGS:
|
|
1082
|
-
return True
|
|
1083
|
-
if each_token.startswith(ALL_GH_API_GLUED_REQUEST_BODY_FIELD_FLAG_PREFIXES):
|
|
1084
|
-
return True
|
|
1085
|
-
return False
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
def _gh_segment_runs_an_http_write_method(all_segment_tokens: list[str]) -> bool:
|
|
1089
|
-
"""Return True when a ``gh`` segment performs an HTTP write through ``gh api``.
|
|
1090
|
-
|
|
1091
|
-
``gh api`` reaches the GitHub API with whatever HTTP method an ``-X``/``--method``
|
|
1092
|
-
flag names. A GET is read-only, but POST, PUT, PATCH and DELETE mutate server
|
|
1093
|
-
state (``gh api repos/foo -X DELETE``). Both flag spellings are recognized: the
|
|
1094
|
-
space-separated form where the method is its own token (``-X DELETE``) and the
|
|
1095
|
-
glued forms where the method is inside the flag token (``-XDELETE``,
|
|
1096
|
-
``--method=DELETE``). The method flag is dash-prefixed and so is dropped from the
|
|
1097
|
-
positional-token list the read-only check inspects, so the raw segment tokens are
|
|
1098
|
-
scanned here: when a write-method flag names a write method, the segment is
|
|
1099
|
-
reported as a write rather than a read.
|
|
1100
|
-
|
|
1101
|
-
``gh api`` also defaults the method to POST when any request-body field flag
|
|
1102
|
-
(``-f``/``--raw-field``, ``-F``/``--field``, ``--input``) is present and no explicit
|
|
1103
|
-
method is given, so a field-carrying segment that does not explicitly name GET
|
|
1104
|
-
(``gh api repos/foo -f title=x``) is an implicit-POST write. An explicit ``-X
|
|
1105
|
-
GET``/``--method GET`` keeps such a segment read-only.
|
|
1106
|
-
|
|
1107
|
-
Args:
|
|
1108
|
-
all_segment_tokens: Shlex tokens of one shell segment.
|
|
1109
|
-
|
|
1110
|
-
Returns:
|
|
1111
|
-
True when the segment names an HTTP write method via ``gh api``.
|
|
1112
|
-
"""
|
|
1113
|
-
if any(
|
|
1114
|
-
_gh_segment_names_an_explicit_method(all_segment_tokens, each_write_method)
|
|
1115
|
-
for each_write_method in ALL_GH_HTTP_WRITE_METHODS
|
|
1116
|
-
):
|
|
1117
|
-
return True
|
|
1118
|
-
if _gh_segment_carries_a_request_body_field(all_segment_tokens):
|
|
1119
|
-
return not _gh_segment_names_an_explicit_method(
|
|
1120
|
-
all_segment_tokens, GH_HTTP_READ_ONLY_METHOD
|
|
1121
|
-
)
|
|
1122
|
-
return False
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
def _git_fetch_segment_forces_a_local_ref_update(
|
|
1126
|
-
all_positional_tokens: list[str], all_segment_tokens: list[str]
|
|
1127
|
-
) -> bool:
|
|
1128
|
-
"""Return True when a ``git fetch`` segment force-updates a local ref.
|
|
1129
|
-
|
|
1130
|
-
``git fetch`` is read-only in normal use, but two spellings force-update the local
|
|
1131
|
-
destination ref even when the update is not a fast-forward, overwriting a local
|
|
1132
|
-
branch and discarding local commits. A ``+``-prefixed refspec forces only the refs
|
|
1133
|
-
it names: ``git fetch origin +refs/heads/main:refs/heads/main`` is detected from a
|
|
1134
|
-
positional refspec that begins with ``+`` or names a ``+refs/`` source. The ``-f``/
|
|
1135
|
-
``--force`` flag forces every named refspec at once: ``git fetch --force origin
|
|
1136
|
-
refs/heads/main:refs/heads/main`` discards local ``main`` commits with no ``+`` in
|
|
1137
|
-
sight. The force flag is dash-prefixed and so is dropped from the positional-token
|
|
1138
|
-
list, so the raw segment tokens are scanned for it, mirroring how the ``gh api``
|
|
1139
|
-
write-method check scans raw tokens for ``-X``.
|
|
1140
|
-
|
|
1141
|
-
Args:
|
|
1142
|
-
all_positional_tokens: The non-flag tokens after the leading ``git`` program.
|
|
1143
|
-
all_segment_tokens: Shlex tokens of the whole ``git`` segment.
|
|
1144
|
-
|
|
1145
|
-
Returns:
|
|
1146
|
-
True when any positional refspec or a force flag force-updates a local ref.
|
|
1147
|
-
"""
|
|
1148
|
-
if any(each_token in ALL_GIT_FETCH_FORCE_FLAGS for each_token in all_segment_tokens):
|
|
1149
|
-
return True
|
|
1150
|
-
return any(
|
|
1151
|
-
each_token.startswith("+") or "+refs/" in each_token
|
|
1152
|
-
for each_token in all_positional_tokens
|
|
1153
|
-
)
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
def _git_config_segment_runs_a_read_only_mode(all_segment_tokens: list[str]) -> bool:
|
|
1157
|
-
"""Return True only when a ``git config`` segment carries a read-only mode flag.
|
|
1158
|
-
|
|
1159
|
-
``git config`` parses a read-only mode flag (``--get``/``--list``/``-l`` and the
|
|
1160
|
-
rest of ALL_GIT_CONFIG_READ_ONLY_FLAGS) only while it sits before the first
|
|
1161
|
-
positional key. Once a key positional appears (``git config core.editor ...``),
|
|
1162
|
-
every following dash-prefixed token is the value being set, so ``git config
|
|
1163
|
-
core.editor --get`` stores the literal string ``--get`` rather than querying.
|
|
1164
|
-
Reading the mode from the flags that precede the first key positional, rather
|
|
1165
|
-
than scanning the whole segment, keeps a value that happens to equal a read-only
|
|
1166
|
-
flag string from masking the write.
|
|
1167
|
-
|
|
1168
|
-
Args:
|
|
1169
|
-
all_segment_tokens: Shlex tokens of the whole ``git config`` segment.
|
|
1170
|
-
|
|
1171
|
-
Returns:
|
|
1172
|
-
True when a read-only flag precedes the first ``config`` key positional.
|
|
1173
|
-
"""
|
|
1174
|
-
config_token_index = next(
|
|
1175
|
-
(
|
|
1176
|
-
each_index
|
|
1177
|
-
for each_index, each_token in enumerate(all_segment_tokens)
|
|
1178
|
-
if each_token.lower() == "config"
|
|
1179
|
-
),
|
|
1180
|
-
None,
|
|
1181
|
-
)
|
|
1182
|
-
if config_token_index is None:
|
|
1183
|
-
return False
|
|
1184
|
-
for each_token in all_segment_tokens[config_token_index + 1:]:
|
|
1185
|
-
if not each_token.startswith("-"):
|
|
1186
|
-
return False
|
|
1187
|
-
if each_token in ALL_GIT_CONFIG_READ_ONLY_FLAGS:
|
|
1188
|
-
return True
|
|
1189
|
-
return False
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
def _git_segment_runs_a_mutating_mode(all_positional_tokens: list[str], all_segment_tokens: list[str]) -> bool:
|
|
1193
|
-
"""Return True when a ``git config``, ``git remote`` or ``git fetch`` segment mutates state.
|
|
1194
|
-
|
|
1195
|
-
``config``, ``remote`` and ``fetch`` appear in the git read-only allowlist for their
|
|
1196
|
-
query modes (``git config --list``, ``git remote -v``, plain ``git fetch``) but each
|
|
1197
|
-
carries a write mode: ``git config key value`` and ``git config --global key value``
|
|
1198
|
-
set a value, ``git remote add|remove|rm|set-url`` change the remote table, and
|
|
1199
|
-
``git fetch origin +refs/heads/main:refs/heads/main`` force-updates a local ref. A
|
|
1200
|
-
``config`` segment mutates unless a read-only flag (``--get``/``--list`` and the rest
|
|
1201
|
-
of ALL_GIT_CONFIG_READ_ONLY_FLAGS) precedes the first positional key; a ``remote``
|
|
1202
|
-
segment mutates unless the first positional verb after ``remote`` is a read-only one
|
|
1203
|
-
(``show``, ``get-url``); a ``fetch`` segment mutates when it carries a ``+``-prefixed
|
|
1204
|
-
force refspec or a ``-f``/``--force`` flag. Both the config mode and the remote verb
|
|
1205
|
-
are read positionally rather
|
|
1206
|
-
than by scanning the whole segment for any read-only token, because a value that
|
|
1207
|
-
follows the key positional (``git config core.editor --get`` stores ``--get``) and a
|
|
1208
|
-
global ``-v``/``--verbose`` before a write verb (``git remote -v add evil url``) would
|
|
1209
|
-
otherwise let a read-only token anywhere mask the write.
|
|
1210
|
-
|
|
1211
|
-
Args:
|
|
1212
|
-
all_positional_tokens: The non-flag tokens after the leading ``git`` program.
|
|
1213
|
-
all_segment_tokens: Shlex tokens of the whole ``git`` segment.
|
|
1214
|
-
|
|
1215
|
-
Returns:
|
|
1216
|
-
True when the segment runs a mutating ``config``, ``remote`` or ``fetch`` mode.
|
|
1217
|
-
"""
|
|
1218
|
-
all_lowercased_positional_tokens = [each_token.lower() for each_token in all_positional_tokens]
|
|
1219
|
-
if "config" in all_lowercased_positional_tokens:
|
|
1220
|
-
return not _git_config_segment_runs_a_read_only_mode(all_segment_tokens)
|
|
1221
|
-
if "remote" in all_lowercased_positional_tokens:
|
|
1222
|
-
remote_verb_index = all_lowercased_positional_tokens.index("remote") + 1
|
|
1223
|
-
all_remote_verbs = all_lowercased_positional_tokens[remote_verb_index:]
|
|
1224
|
-
if not all_remote_verbs:
|
|
1225
|
-
return False
|
|
1226
|
-
return all_remote_verbs[0] not in ALL_GIT_REMOTE_READ_ONLY_VERBS
|
|
1227
|
-
if "fetch" in all_lowercased_positional_tokens:
|
|
1228
|
-
return _git_fetch_segment_forces_a_local_ref_update(
|
|
1229
|
-
all_positional_tokens, all_segment_tokens
|
|
1230
|
-
)
|
|
1231
|
-
return False
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
def _subcommand_dispatching_segment_is_read_only(
|
|
1235
|
-
leading_program_basename: str,
|
|
1236
|
-
all_read_only_subcommands: frozenset[str],
|
|
1237
|
-
all_segment_tokens: list[str],
|
|
1238
|
-
) -> bool:
|
|
1239
|
-
"""Return True only when a subcommand-dispatching segment runs a read-only verb.
|
|
1240
|
-
|
|
1241
|
-
``git`` and ``gh`` dispatch destructive operations through subcommands the
|
|
1242
|
-
DESTRUCTIVE_BASH_PATTERNS table does not separately enumerate (``gh repo delete``,
|
|
1243
|
-
``git checkout -- .``, ``git stash drop``, ``git branch -D``), so a chained
|
|
1244
|
-
destructive subcommand would otherwise ride the ephemeral ``rm`` auto-allow. The
|
|
1245
|
-
check fails closed: a segment is benign only when a read-only subcommand verb sits
|
|
1246
|
-
in the program's leading subcommand window and the segment runs no known mutating
|
|
1247
|
-
mode. The window spans the first positional for ``git`` (``git status``) and the
|
|
1248
|
-
first two positionals for ``gh`` (``gh api``, ``gh pr view``), matching how each
|
|
1249
|
-
program names its subcommand. Bounding the search to that window keeps a read-only
|
|
1250
|
-
verb used as a deeper argument to a destructive subcommand (``gh repo delete
|
|
1251
|
-
status``, ``git push origin log``, ``git branch -D log``) from satisfying the check.
|
|
1252
|
-
``git config`` and ``git remote`` sit in the read-only allowlist for their query
|
|
1253
|
-
modes yet carry write modes (``git config --global key value``, ``git remote add
|
|
1254
|
-
evil url``), and ``gh api`` performs an HTTP write when an ``-X``/``--method`` flag
|
|
1255
|
-
names POST, PUT, PATCH or DELETE; each such mutating mode declines the segment.
|
|
1256
|
-
|
|
1257
|
-
Args:
|
|
1258
|
-
leading_program_basename: The dispatching program (``git`` or ``gh``).
|
|
1259
|
-
all_read_only_subcommands: The read-only subcommand verbs for the dispatching
|
|
1260
|
-
program.
|
|
1261
|
-
all_segment_tokens: Shlex tokens of one shell segment.
|
|
1262
|
-
|
|
1263
|
-
Returns:
|
|
1264
|
-
True when the segment's subcommand is on the read-only allowlist and the
|
|
1265
|
-
segment runs no mutating mode.
|
|
1266
|
-
"""
|
|
1267
|
-
all_positional_tokens = _all_positional_tokens_after_leader(all_segment_tokens)
|
|
1268
|
-
subcommand_window_depth = (
|
|
1269
|
-
ALL_READ_ONLY_SUBCOMMAND_POSITION_DEPTHS_BY_DISPATCHING_PROGRAM[
|
|
1270
|
-
leading_program_basename
|
|
1271
|
-
]
|
|
1272
|
-
)
|
|
1273
|
-
all_leading_subcommand_tokens = all_positional_tokens[:subcommand_window_depth]
|
|
1274
|
-
runs_a_read_only_subcommand = any(
|
|
1275
|
-
each_token.lower() in all_read_only_subcommands
|
|
1276
|
-
for each_token in all_leading_subcommand_tokens
|
|
1277
|
-
)
|
|
1278
|
-
if not runs_a_read_only_subcommand:
|
|
1279
|
-
return False
|
|
1280
|
-
if leading_program_basename == "git" and _git_segment_runs_a_mutating_mode(
|
|
1281
|
-
all_positional_tokens, all_segment_tokens
|
|
1282
|
-
):
|
|
1283
|
-
return False
|
|
1284
|
-
if leading_program_basename == "gh" and _gh_segment_runs_an_http_write_method(
|
|
1285
|
-
all_segment_tokens
|
|
1286
|
-
):
|
|
1287
|
-
return False
|
|
1288
|
-
return True
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
def _benign_program_writes_a_file_via_output_flag(
|
|
1292
|
-
leading_program_basename: str, all_segment_tokens: list[str]
|
|
1293
|
-
) -> bool:
|
|
1294
|
-
"""Return True when a benign program writes a file through its own output flag.
|
|
1295
|
-
|
|
1296
|
-
Some allowlisted reporting commands overwrite an arbitrary file without a shell
|
|
1297
|
-
redirection: ``sort -o FILE`` truncates and rewrites ``FILE`` the same way
|
|
1298
|
-
``cat ... > FILE`` does, so ``sort -o /etc/important.conf /etc/passwd`` destroys a
|
|
1299
|
-
non-ephemeral file even though ``sort`` is read-only by default. A segment whose
|
|
1300
|
-
leading program is in ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM and carries
|
|
1301
|
-
one of that program's file-writing output flags is reported as a write so it
|
|
1302
|
-
declines the ephemeral ``rm`` auto-allow.
|
|
1303
|
-
|
|
1304
|
-
Args:
|
|
1305
|
-
leading_program_basename: The segment's leading program basename, lowercased.
|
|
1306
|
-
all_segment_tokens: Shlex tokens of one shell segment.
|
|
1307
|
-
|
|
1308
|
-
Returns:
|
|
1309
|
-
True when the segment writes a file through the program's output flag.
|
|
1310
|
-
"""
|
|
1311
|
-
all_file_writing_output_flags = ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM.get(
|
|
1312
|
-
leading_program_basename
|
|
1313
|
-
)
|
|
1314
|
-
if all_file_writing_output_flags is None:
|
|
1315
|
-
return False
|
|
1316
|
-
return any(
|
|
1317
|
-
each_token in all_file_writing_output_flags
|
|
1318
|
-
or each_token.split("=", 1)[0] in all_file_writing_output_flags
|
|
1319
|
-
for each_token in all_segment_tokens
|
|
1320
|
-
)
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
def _segment_leading_program_is_benign(all_segment_tokens: list[str]) -> bool:
|
|
1324
|
-
"""Return True when a non-rm segment's leading program is a benign reporting command.
|
|
1325
|
-
|
|
1326
|
-
A compound chain auto-allow requires every segment that is not an ``rm`` deletion
|
|
1327
|
-
to be a recognized read-only or reporting command (``echo``, ``gh``, ``head``,
|
|
1328
|
-
``cat``, ``grep`` and the rest of ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS). A segment
|
|
1329
|
-
leading with any other program — ``shred``, ``truncate``, ``find ... -delete``,
|
|
1330
|
-
``chmod -R``, ``mv`` and every other destructive command absent from the
|
|
1331
|
-
DESTRUCTIVE_BASH_PATTERNS table — is treated as non-benign so the chain falls
|
|
1332
|
-
through to the prompt rather than riding the ephemeral ``rm`` auto-allow.
|
|
1333
|
-
|
|
1334
|
-
Three further constraints fail the segment closed even when its leading program is
|
|
1335
|
-
allowlisted: an output redirection (``cat /dev/null > /etc/important.conf``)
|
|
1336
|
-
truncates the redirect target; a benign program that writes a file through its own
|
|
1337
|
-
output flag (``sort -o /etc/important.conf``) overwrites the named file without a
|
|
1338
|
-
shell redirection; and a ``git`` or ``gh`` segment must run a read-only subcommand
|
|
1339
|
-
in a read-only mode (``git status``, ``gh pr view``, ``git config --list``) rather
|
|
1340
|
-
than a destructive subcommand (``gh repo delete``, ``git checkout -- .``,
|
|
1341
|
-
``git stash drop``) or a mutating mode of an otherwise-read-only subcommand
|
|
1342
|
-
(``git config --global key value``, ``git remote add evil url``, ``gh api -X
|
|
1343
|
-
DELETE``).
|
|
1344
|
-
|
|
1345
|
-
Args:
|
|
1346
|
-
all_segment_tokens: Shlex tokens of one shell segment, possibly led by
|
|
1347
|
-
``VAR=value`` assignments before the program token.
|
|
1348
|
-
|
|
1349
|
-
Returns:
|
|
1350
|
-
True when the segment's leading program is in the benign allowlist.
|
|
1351
|
-
"""
|
|
1352
|
-
leading_command_token = _leading_command_token(all_segment_tokens)
|
|
1353
|
-
if leading_command_token is None:
|
|
1354
|
-
return False
|
|
1355
|
-
leading_program_basename = Path(leading_command_token).name.lower()
|
|
1356
|
-
if leading_program_basename not in ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS:
|
|
1357
|
-
return False
|
|
1358
|
-
if _segment_redirects_output_to_a_file(all_segment_tokens):
|
|
1359
|
-
return False
|
|
1360
|
-
if _benign_program_writes_a_file_via_output_flag(leading_program_basename, all_segment_tokens):
|
|
1361
|
-
return False
|
|
1362
|
-
all_read_only_subcommands = ALL_READ_ONLY_SUBCOMMANDS_BY_DISPATCHING_PROGRAM.get(
|
|
1363
|
-
leading_program_basename
|
|
1364
|
-
)
|
|
1365
|
-
if all_read_only_subcommands is not None:
|
|
1366
|
-
return _subcommand_dispatching_segment_is_read_only(
|
|
1367
|
-
leading_program_basename, all_read_only_subcommands, all_segment_tokens
|
|
1368
|
-
)
|
|
1369
|
-
return True
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
class CompoundSegmentVerdict(enum.Enum):
|
|
1373
|
-
"""Auto-allow classification of one segment in a compound ``rm`` chain."""
|
|
1374
|
-
|
|
1375
|
-
DECLINES_AUTO_ALLOW = enum.auto()
|
|
1376
|
-
IS_EPHEMERAL_RM = enum.auto()
|
|
1377
|
-
IS_BENIGN = enum.auto()
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
def _compound_segment_auto_allow_verdict(
|
|
1381
|
-
all_segment_tokens: list[str],
|
|
1382
|
-
) -> CompoundSegmentVerdict:
|
|
1383
|
-
"""Classify one compound-chain segment for the ephemeral ``rm`` auto-allow.
|
|
1384
|
-
|
|
1385
|
-
Returns DECLINES_AUTO_ALLOW when the segment's leading program executes a quoted
|
|
1386
|
-
string argument as code, when an ``rm`` segment targets a non-ephemeral path, or
|
|
1387
|
-
when a non-``rm`` segment is not a benign reporting command. Returns
|
|
1388
|
-
IS_EPHEMERAL_RM when the segment is an ``rm`` deletion whose every target is an
|
|
1389
|
-
absolute ephemeral path. Returns IS_BENIGN for an empty segment or a benign
|
|
1390
|
-
non-``rm`` segment.
|
|
1391
|
-
|
|
1392
|
-
Args:
|
|
1393
|
-
all_segment_tokens: Shlex tokens of one shell segment with control operators
|
|
1394
|
-
removed.
|
|
1395
|
-
|
|
1396
|
-
Returns:
|
|
1397
|
-
The CompoundSegmentVerdict for the segment.
|
|
1398
|
-
"""
|
|
1399
|
-
if not all_segment_tokens:
|
|
1400
|
-
return CompoundSegmentVerdict.IS_BENIGN
|
|
1401
|
-
if _command_executes_a_string_argument(all_segment_tokens):
|
|
1402
|
-
return CompoundSegmentVerdict.DECLINES_AUTO_ALLOW
|
|
1403
|
-
each_rm_token_index = next(
|
|
1404
|
-
(
|
|
1405
|
-
index
|
|
1406
|
-
for index, token in enumerate(all_segment_tokens)
|
|
1407
|
-
if Path(_strip_leading_subshell_grouping_characters(token)).name == "rm"
|
|
1408
|
-
),
|
|
1409
|
-
None,
|
|
1410
|
-
)
|
|
1411
|
-
if each_rm_token_index is None:
|
|
1412
|
-
if _segment_leading_program_is_benign(all_segment_tokens):
|
|
1413
|
-
return CompoundSegmentVerdict.IS_BENIGN
|
|
1414
|
-
return CompoundSegmentVerdict.DECLINES_AUTO_ALLOW
|
|
1415
|
-
if _rm_segment_targets_only_absolute_ephemeral_paths(
|
|
1416
|
-
all_segment_tokens[each_rm_token_index:]
|
|
1417
|
-
):
|
|
1418
|
-
return CompoundSegmentVerdict.IS_EPHEMERAL_RM
|
|
1419
|
-
return CompoundSegmentVerdict.DECLINES_AUTO_ALLOW
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
def rm_compound_targets_only_absolute_ephemeral_paths(command: str) -> bool:
|
|
1423
|
-
"""Return True when a compound command's every ``rm`` segment is safe to auto-allow.
|
|
1424
|
-
|
|
1425
|
-
Handles destructive cleanup chains that declare no ephemeral working directory,
|
|
1426
|
-
such as ``rm -rf /tmp/pr136 /tmp/difftest && echo 'cleaned'``. Splits the
|
|
1427
|
-
command into shell segments and requires all of: at least one segment runs
|
|
1428
|
-
``rm``; every ``rm`` segment targets only absolute ephemeral paths; every
|
|
1429
|
-
non-``rm`` segment leads with a benign reporting command from
|
|
1430
|
-
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS, so a ``shred``, ``truncate``,
|
|
1431
|
-
``find ... -delete``, ``chmod -R`` or ``mv`` segment that destroys
|
|
1432
|
-
non-ephemeral data declines the auto-allow; no segment's leading program
|
|
1433
|
-
executes a quoted string argument as code — a shell interpreter, ``eval``,
|
|
1434
|
-
``exec``, ``source``, a privilege or argument wrapper (``sudo``, ``su``,
|
|
1435
|
-
``env``, ``xargs``), or a command-launcher wrapper that forwards argv to such a
|
|
1436
|
-
program (``timeout bash -c '...'``); no segment matches a destructive pattern
|
|
1437
|
-
that is not rm-family (force push, git clean, git reset --hard, mkfs, dd,
|
|
1438
|
-
DROP/TRUNCATE, signing bypass); and the command contains no shell expansion.
|
|
1439
|
-
|
|
1440
|
-
Fails closed (returns False) on shell expansion (``$`` or backtick), on a
|
|
1441
|
-
tokenization error, and whenever any ``rm`` segment fails the absolute-ephemeral
|
|
1442
|
-
target check, so the compound auto-allow grants only on chains it can fully
|
|
1443
|
-
bound.
|
|
1444
|
-
|
|
1445
|
-
Args:
|
|
1446
|
-
command: The raw Bash command string from the tool input.
|
|
1447
|
-
|
|
1448
|
-
Returns:
|
|
1449
|
-
True when every ``rm`` segment targets only absolute ephemeral paths and no
|
|
1450
|
-
other hazard is present.
|
|
1451
|
-
"""
|
|
1452
|
-
if _command_contains_shell_expansion(command):
|
|
1453
|
-
return False
|
|
1454
|
-
if _command_contains_non_rm_family_destructive_pattern(command):
|
|
1455
|
-
return False
|
|
1456
|
-
has_seen_rm_segment = False
|
|
1457
|
-
for each_command_line in re.split(r"[\n\r]+", command):
|
|
1458
|
-
try:
|
|
1459
|
-
all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
|
|
1460
|
-
except ValueError:
|
|
1461
|
-
return False
|
|
1462
|
-
all_operator_split_tokens = _explode_glued_shell_control_operators(all_command_tokens)
|
|
1463
|
-
for each_segment in _split_tokens_into_shell_segments(all_operator_split_tokens):
|
|
1464
|
-
each_verdict = _compound_segment_auto_allow_verdict(each_segment)
|
|
1465
|
-
if each_verdict == CompoundSegmentVerdict.DECLINES_AUTO_ALLOW:
|
|
1466
|
-
return False
|
|
1467
|
-
if each_verdict == CompoundSegmentVerdict.IS_EPHEMERAL_RM:
|
|
1468
|
-
has_seen_rm_segment = True
|
|
1469
|
-
return has_seen_rm_segment
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
def targets_only_claude_directory(command: str) -> bool:
|
|
1473
|
-
"""Check if rm command targets only paths under ~/.claude/."""
|
|
1474
|
-
all_rm_target_paths = re.findall(
|
|
1475
|
-
r'(?:rm\s+(?:-\w+\s+)*)("[^"]+"|\'[^\']+\'|\S+)',
|
|
1476
|
-
command,
|
|
1477
|
-
)
|
|
1478
|
-
if not all_rm_target_paths:
|
|
1479
|
-
return False
|
|
1480
|
-
|
|
1481
|
-
for each_raw_path in all_rm_target_paths:
|
|
1482
|
-
each_stripped_path = each_raw_path.strip("\"'")
|
|
1483
|
-
each_cleaned_path = re.split(r'[;&|]', each_stripped_path)[0]
|
|
1484
|
-
if each_cleaned_path != each_stripped_path:
|
|
1485
|
-
return False
|
|
1486
|
-
each_resolved_path = os.path.normpath(os.path.expanduser(each_cleaned_path))
|
|
1487
|
-
if not each_resolved_path.startswith(CLAUDE_DIRECTORY_PATH):
|
|
1488
|
-
return False
|
|
1489
|
-
|
|
1490
|
-
return True
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
def _ephemeral_recursive_rm_auto_allow_granted(command: str, matched_description: str) -> bool:
|
|
1494
|
-
return matched_description.startswith(("rm -rf", "rm --recursive")) and rm_targets_only_ephemeral_paths(command)
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
def _extract_leading_cd_target(command: str) -> str | None:
|
|
1498
|
-
"""Return the target of a ``cd`` that starts the command, or None if absent.
|
|
1499
|
-
|
|
1500
|
-
Uses ``shlex.split`` with POSIX rules to tokenize the command so adjacent
|
|
1501
|
-
quoted string concatenation (``"/tmp/a""/../../etc"``) resolves to the
|
|
1502
|
-
same path the shell would cd to (``/tmp/a/../../etc``). A regex-based
|
|
1503
|
-
extractor cannot see past the first quoted segment and would
|
|
1504
|
-
misclassify the cd target as ephemeral while the shell ends up
|
|
1505
|
-
somewhere else entirely.
|
|
1506
|
-
|
|
1507
|
-
Returns None when the command does not start with ``cd``, when tokenization
|
|
1508
|
-
fails (unbalanced quotes), when the cd target is missing, or when the
|
|
1509
|
-
target contains any shell-expansion character (``$`` for variable /
|
|
1510
|
-
command substitution, `` ` `` for backtick subshell) that the shell
|
|
1511
|
-
would resolve *before* cd runs. Hook authors cannot safely know what
|
|
1512
|
-
``$(rm -rf ~)`` expands to, so the conservative answer is "don't
|
|
1513
|
-
auto-allow".
|
|
1514
|
-
"""
|
|
1515
|
-
shell_expansion_characters_that_execute_code = ("$", "`")
|
|
1516
|
-
try:
|
|
1517
|
-
all_command_tokens = shlex.split(command, posix=True)
|
|
1518
|
-
except ValueError:
|
|
1519
|
-
return None
|
|
1520
|
-
if len(all_command_tokens) < 2 or all_command_tokens[0] != "cd":
|
|
1521
|
-
return None
|
|
1522
|
-
cd_target_token = all_command_tokens[1]
|
|
1523
|
-
for each_shell_expansion_character in shell_expansion_characters_that_execute_code:
|
|
1524
|
-
if each_shell_expansion_character in cd_target_token:
|
|
1525
|
-
return None
|
|
1526
|
-
return cd_target_token
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
def _resolve_declared_effective_working_directory(command: str, tool_input: dict) -> str | None:
|
|
1530
|
-
"""Return the declared cwd for the command, or None when none is declared.
|
|
1531
|
-
|
|
1532
|
-
Precedence: leading ``cd "X"`` in the command, then the
|
|
1533
|
-
``tool_input['cwd']`` field passed in by the Bash tool call. Returns
|
|
1534
|
-
None when neither source is present so the broad auto-allow gate never
|
|
1535
|
-
depends on the hook process's own ``os.getcwd()`` (which can itself be
|
|
1536
|
-
ephemeral when Claude Code runs inside a worktree, and would otherwise
|
|
1537
|
-
auto-allow every destructive command). Paths are user-expanded and
|
|
1538
|
-
normalized so downstream ``directory_is_ephemeral`` comparisons see a
|
|
1539
|
-
canonical form on both POSIX and Windows.
|
|
1540
|
-
"""
|
|
1541
|
-
leading_cd_target = _extract_leading_cd_target(command)
|
|
1542
|
-
if leading_cd_target is not None:
|
|
1543
|
-
return os.path.normpath(os.path.expanduser(leading_cd_target))
|
|
1544
|
-
tool_input_cwd_value = tool_input.get("cwd") if isinstance(tool_input, dict) else None
|
|
1545
|
-
if isinstance(tool_input_cwd_value, str) and tool_input_cwd_value.strip():
|
|
1546
|
-
return os.path.normpath(os.path.expanduser(tool_input_cwd_value))
|
|
1547
|
-
return None
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
def _effective_working_directory_is_ephemeral(command: str, tool_input: dict) -> bool:
|
|
1551
|
-
"""Return True when the command's declared effective cwd is a specific ephemeral directory.
|
|
1552
|
-
|
|
1553
|
-
Auto-allow trust model: if the destructive command declares (via leading
|
|
1554
|
-
``cd`` or ``tool_input['cwd']``) that it will execute inside a concrete
|
|
1555
|
-
ephemeral directory (a temp-dir subfolder, a git worktrees directory, or
|
|
1556
|
-
a subfolder of the OS temp root), treat that directory as a disposable
|
|
1557
|
-
trust boundary and skip the destructive-action prompt. Rejects bare
|
|
1558
|
-
ephemeral roots (``/tmp``, ``/temp``, the OS temp root, ``/worktrees``,
|
|
1559
|
-
``/worktree``) so auto-allow only triggers inside a named scratch area,
|
|
1560
|
-
not at the root of a shared scratch namespace. Returns False when no
|
|
1561
|
-
cwd is declared; the narrower target-based auto-allow still applies in
|
|
1562
|
-
that case.
|
|
1563
|
-
"""
|
|
1564
|
-
declared_effective_cwd = _resolve_declared_effective_working_directory(command, tool_input)
|
|
1565
|
-
if declared_effective_cwd is None:
|
|
1566
|
-
return False
|
|
1567
|
-
if _path_is_bare_ephemeral_root(declared_effective_cwd):
|
|
1568
|
-
return False
|
|
1569
|
-
return directory_is_ephemeral(declared_effective_cwd)
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
CWD_SCOPED_DESTRUCTIVE_DESCRIPTIONS_ELIGIBLE_FOR_BROAD_EPHEMERAL_AUTO_ALLOW = (
|
|
1573
|
-
"rm -rf",
|
|
1574
|
-
"rm --recursive",
|
|
1575
|
-
"git reset --hard",
|
|
1576
|
-
)
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
def _destructive_match_is_cwd_scoped(matched_description: str) -> bool:
|
|
1580
|
-
"""Return True when the matched destructive pattern's blast radius is bounded by cwd.
|
|
1581
|
-
|
|
1582
|
-
``rm -rf``, ``rm --recursive``, and ``git reset --hard`` only affect
|
|
1583
|
-
files inside the working directory (or paths resolved relative to it
|
|
1584
|
-
when the rm target is relative). Patterns whose blast radius is NOT
|
|
1585
|
-
bounded by cwd — ``git push --force`` / ``git push -f`` (remote
|
|
1586
|
-
history rewrite), ``git clean`` variants (untracked deletion outside
|
|
1587
|
-
what the user can audit at the current prompt), ``mkfs`` / ``dd``
|
|
1588
|
-
(raw device), ``DROP TABLE`` / ``DROP DATABASE`` / ``TRUNCATE TABLE``
|
|
1589
|
-
(database) — must still prompt even when the command runs from an
|
|
1590
|
-
ephemeral worktree. Being in a scratch directory is not a trust zone
|
|
1591
|
-
for remote or out-of-band effects.
|
|
1592
|
-
"""
|
|
1593
|
-
return matched_description.startswith(
|
|
1594
|
-
CWD_SCOPED_DESTRUCTIVE_DESCRIPTIONS_ELIGIBLE_FOR_BROAD_EPHEMERAL_AUTO_ALLOW
|
|
1595
|
-
)
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
def _command_contains_any_non_cwd_scoped_destructive_pattern(command: str) -> bool:
|
|
1599
|
-
"""Return True when the command matches any destructive pattern outside the cwd-scoped whitelist.
|
|
1600
|
-
|
|
1601
|
-
``find_destructive_pattern`` returns the *first* match in the
|
|
1602
|
-
``DESTRUCTIVE_BASH_PATTERNS`` table, where ``rm -rf`` sits at the
|
|
1603
|
-
very front. That means a compound like ``cd /tmp/scratch && rm -rf
|
|
1604
|
-
cache && git push --force`` reports ``rm -rf`` to the main gate,
|
|
1605
|
-
passes the cwd-scoped whitelist, and ends up auto-allowing the
|
|
1606
|
-
remote force-push even though the whitelist docstring says
|
|
1607
|
-
non-cwd-scoped patterns must still prompt. This helper scans *every*
|
|
1608
|
-
destructive pattern and returns True the moment it finds one that
|
|
1609
|
-
is not in the cwd-scoped whitelist, so the broad auto-allow can
|
|
1610
|
-
decline the whole command rather than trust the first-match report.
|
|
1611
|
-
"""
|
|
1612
|
-
for each_pattern_regex, each_pattern_description in DESTRUCTIVE_BASH_PATTERNS:
|
|
1613
|
-
if each_pattern_regex.search(command) and not each_pattern_description.startswith(
|
|
1614
|
-
CWD_SCOPED_DESTRUCTIVE_DESCRIPTIONS_ELIGIBLE_FOR_BROAD_EPHEMERAL_AUTO_ALLOW
|
|
1615
|
-
):
|
|
1616
|
-
return True
|
|
1617
|
-
return False
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
def _rm_target_resolves_outside_ephemeral_namespace(target_token: str, declared_effective_cwd: str | None) -> bool:
|
|
1621
|
-
"""Return True when an ``rm`` target token resolves outside the ephemeral namespace.
|
|
1622
|
-
|
|
1623
|
-
A token carrying command substitution (``$(...)``) or a backtick subshell is
|
|
1624
|
-
unsafe because the shell resolves it before ``rm`` runs and the hook cannot
|
|
1625
|
-
statically bound the deletion target. A token carrying a brace group with a
|
|
1626
|
-
comma list or ``..`` range is unsafe because the shell expands it into multiple
|
|
1627
|
-
targets the hook cannot bound; a bare ``{}`` placeholder (no comma, no range)
|
|
1628
|
-
does not match and stays bounded. A token referencing an environment variable
|
|
1629
|
-
other than a known temporary one (``TEMP``, ``TMP``, ``TMPDIR``,
|
|
1630
|
-
``CLAUDE_JOB_DIR``) is unsafe; a known temporary variable spliced after an
|
|
1631
|
-
absolute (or ``/``-rooted) literal prefix is unsafe because the literal prefix,
|
|
1632
|
-
not the variable, roots the path. A token referencing only known temporary
|
|
1633
|
-
variables with an empty or ``~`` literal prefix resolves with each reference
|
|
1634
|
-
rewritten to the system temporary root. A ``~``-prefixed or absolute token
|
|
1635
|
-
resolves as written; a relative token resolves against ``declared_effective_cwd``
|
|
1636
|
-
(``None`` is unsafe because the target cannot be bounded). The resolved path is
|
|
1637
|
-
unsafe when its basename is a glob wildcard, when it is a bare ephemeral root,
|
|
1638
|
-
when it is a bare named-worktrees container, or when it is not ephemeral.
|
|
1639
|
-
|
|
1640
|
-
Args:
|
|
1641
|
-
target_token: A single ``rm`` target token from the segment.
|
|
1642
|
-
declared_effective_cwd: The declared effective working directory, or ``None``
|
|
1643
|
-
when the command declares none.
|
|
1644
|
-
|
|
1645
|
-
Returns:
|
|
1646
|
-
True when the target resolves outside the ephemeral namespace.
|
|
1647
|
-
"""
|
|
1648
|
-
if "$(" in target_token or "`" in target_token:
|
|
1649
|
-
return True
|
|
1650
|
-
if re.search(r"\{[^{}]*(?:,|\.\.)[^{}]*\}", target_token):
|
|
1651
|
-
return True
|
|
1652
|
-
known_temporary_environment_variable_names = ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES
|
|
1653
|
-
environment_variable_reference_pattern = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?|%([A-Za-z_][A-Za-z0-9_]*)%")
|
|
1654
|
-
all_referenced_variable_names = [
|
|
1655
|
-
next(each_group for each_group in each_match.groups() if each_group is not None)
|
|
1656
|
-
for each_match in environment_variable_reference_pattern.finditer(target_token)
|
|
1657
|
-
]
|
|
1658
|
-
resolved_token = target_token
|
|
1659
|
-
if all_referenced_variable_names:
|
|
1660
|
-
for each_variable_name in all_referenced_variable_names:
|
|
1661
|
-
if each_variable_name not in known_temporary_environment_variable_names:
|
|
1662
|
-
return True
|
|
1663
|
-
first_variable_reference = environment_variable_reference_pattern.search(target_token)
|
|
1664
|
-
assert first_variable_reference is not None
|
|
1665
|
-
literal_prefix_before_first_variable = target_token[: first_variable_reference.start()]
|
|
1666
|
-
if literal_prefix_before_first_variable not in ("", "~") and (
|
|
1667
|
-
os.path.isabs(literal_prefix_before_first_variable)
|
|
1668
|
-
or literal_prefix_before_first_variable.replace("\\", "/").startswith("/")
|
|
1669
|
-
):
|
|
1670
|
-
return True
|
|
1671
|
-
system_temporary_root = os.path.normpath(tempfile.gettempdir()).replace("\\", "/")
|
|
1672
|
-
resolved_token = environment_variable_reference_pattern.sub(lambda each_match: system_temporary_root, target_token)
|
|
1673
|
-
each_expanded_target = os.path.expanduser(resolved_token)
|
|
1674
|
-
each_is_absolute = (
|
|
1675
|
-
os.path.isabs(each_expanded_target)
|
|
1676
|
-
or each_expanded_target.replace("\\", "/").startswith("/")
|
|
1677
|
-
)
|
|
1678
|
-
if each_is_absolute:
|
|
1679
|
-
each_resolved_target = os.path.normpath(each_expanded_target)
|
|
1680
|
-
elif declared_effective_cwd is None:
|
|
1681
|
-
return True
|
|
1682
|
-
else:
|
|
1683
|
-
each_resolved_target = os.path.normpath(os.path.join(declared_effective_cwd, each_expanded_target))
|
|
1684
|
-
if _path_basename_is_shell_glob_wildcard(each_resolved_target):
|
|
1685
|
-
return True
|
|
1686
|
-
if _path_is_bare_ephemeral_root(each_resolved_target):
|
|
1687
|
-
return True
|
|
1688
|
-
if _path_is_bare_named_worktrees_container(each_resolved_target):
|
|
1689
|
-
return True
|
|
1690
|
-
return not directory_is_ephemeral(each_resolved_target)
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
def _rm_segment_targets_escape_ephemeral_cwd(all_rm_segment_tokens: list[str], declared_effective_cwd: str | None) -> bool:
|
|
1694
|
-
"""Return True when an ``rm`` segment's redirect, flags, or targets escape the ephemeral cwd.
|
|
1695
|
-
|
|
1696
|
-
The segment tokens begin at the ``rm`` program token. An output redirection
|
|
1697
|
-
escapes (``rm -rf /tmp/x>/etc/passwd`` truncates ``/etc/passwd`` even when the
|
|
1698
|
-
deletion target is ephemeral; shlex keeps the ``>`` glued to the target token when
|
|
1699
|
-
no whitespace separates them). Unsafe ``rm`` flags before ``--`` (as enforced by
|
|
1700
|
-
``_rm_flags_before_double_dash_are_unsafe``) escape; otherwise any collected target
|
|
1701
|
-
token that resolves outside the ephemeral namespace escapes.
|
|
1702
|
-
|
|
1703
|
-
Args:
|
|
1704
|
-
all_rm_segment_tokens: The segment tokens starting at the ``rm`` token.
|
|
1705
|
-
declared_effective_cwd: The declared effective working directory, or ``None``
|
|
1706
|
-
when the command declares none.
|
|
1707
|
-
|
|
1708
|
-
Returns:
|
|
1709
|
-
True when the segment's redirect, flags, or any target escape the ephemeral cwd.
|
|
1710
|
-
"""
|
|
1711
|
-
if _segment_redirects_output_to_a_file(all_rm_segment_tokens):
|
|
1712
|
-
return True
|
|
1713
|
-
tokens_after_rm = all_rm_segment_tokens[1:]
|
|
1714
|
-
if _rm_flags_before_double_dash_are_unsafe(tokens_after_rm):
|
|
1715
|
-
return True
|
|
1716
|
-
return any(
|
|
1717
|
-
_rm_target_resolves_outside_ephemeral_namespace(each_target_token, declared_effective_cwd)
|
|
1718
|
-
for each_target_token in _collect_rm_target_tokens(tokens_after_rm)
|
|
1719
|
-
)
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
def _collect_find_search_root_tokens(all_tokens_after_find: list[str]) -> list[str]:
|
|
1723
|
-
"""Return the path-operand search roots that follow ``find``, skipping global options.
|
|
1724
|
-
|
|
1725
|
-
GNU ``find`` accepts global options before the path operands: the flag-only
|
|
1726
|
-
options ``-H``/``-L``/``-P`` (each its own token, possibly in sequence), the
|
|
1727
|
-
value-taking option ``-D`` (which consumes the following debug-options token), and
|
|
1728
|
-
an optimization-level option ``-O<level>`` whose level is glued to the flag
|
|
1729
|
-
(``-O3``), so the ``-O``-prefixed token is skipped as a single token and a
|
|
1730
|
-
following path operand is collected as a search root rather than swallowed as a
|
|
1731
|
-
level. The leading run of such global options
|
|
1732
|
-
is skipped first, then the path operands are collected: every non-dash token up to
|
|
1733
|
-
the first ``-``-prefixed expression primary (``-name``, ``-type``, ``-exec``). A
|
|
1734
|
-
``find`` whose first post-option token is already an expression primary declares no
|
|
1735
|
-
path operand and returns an empty list, so it defaults to the ephemeral cwd.
|
|
1736
|
-
|
|
1737
|
-
Args:
|
|
1738
|
-
all_tokens_after_find: The segment tokens that follow the ``find`` program token.
|
|
1739
|
-
|
|
1740
|
-
Returns:
|
|
1741
|
-
The path-operand search-root tokens, in order; empty when ``find`` declares none.
|
|
1742
|
-
"""
|
|
1743
|
-
each_token_index = 0
|
|
1744
|
-
while each_token_index < len(all_tokens_after_find):
|
|
1745
|
-
each_token = all_tokens_after_find[each_token_index]
|
|
1746
|
-
if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE:
|
|
1747
|
-
each_token_index += 1
|
|
1748
|
-
continue
|
|
1749
|
-
if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE:
|
|
1750
|
-
each_token_index += 1
|
|
1751
|
-
if each_token_index < len(all_tokens_after_find):
|
|
1752
|
-
each_token_index += 1
|
|
1753
|
-
continue
|
|
1754
|
-
if each_token.startswith(FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX):
|
|
1755
|
-
each_token_index += 1
|
|
1756
|
-
continue
|
|
1757
|
-
break
|
|
1758
|
-
all_search_root_tokens: list[str] = []
|
|
1759
|
-
for each_token in all_tokens_after_find[each_token_index:]:
|
|
1760
|
-
if each_token.startswith("-"):
|
|
1761
|
-
break
|
|
1762
|
-
all_search_root_tokens.append(each_token)
|
|
1763
|
-
return all_search_root_tokens
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
def _find_exec_rm_search_root_escapes_ephemeral_cwd(
|
|
1767
|
-
all_segment_tokens: list[str], declared_effective_cwd: str | None
|
|
1768
|
-
) -> bool:
|
|
1769
|
-
"""Return True when a ``find ... -exec rm`` segment's search root escapes the ephemeral namespace.
|
|
1770
|
-
|
|
1771
|
-
A ``find <roots...> ... -exec rm ...`` (or ``-execdir``) segment deletes whatever
|
|
1772
|
-
``find`` matches under its leading search-root arguments; the ``rm``'s own ``{}``
|
|
1773
|
-
and ``+`` placeholders name no deletion target, ``find``'s roots do. The leading run
|
|
1774
|
-
of ``find`` global options (``-H``/``-L``/``-P``, ``-D debugopts``, ``-Olevel``) is
|
|
1775
|
-
skipped before the path operands are read, so a global option before the roots does
|
|
1776
|
-
not hide them. The segment is unsafe when any path-operand search root resolves
|
|
1777
|
-
outside the ephemeral namespace. Returns False when the segment contains no ``find``
|
|
1778
|
-
token, when it has no ``-exec`` or ``-execdir`` action after ``find``, or when
|
|
1779
|
-
``find`` declares no path operand (``find`` then defaults to the ephemeral cwd).
|
|
1780
|
-
|
|
1781
|
-
Args:
|
|
1782
|
-
all_segment_tokens: The tokens of a single shell segment.
|
|
1783
|
-
declared_effective_cwd: The declared effective working directory, or ``None``
|
|
1784
|
-
when the command declares none.
|
|
1785
|
-
|
|
1786
|
-
Returns:
|
|
1787
|
-
True when any of ``find``'s search roots escapes the ephemeral namespace.
|
|
1788
|
-
"""
|
|
1789
|
-
find_token_index = next(
|
|
1790
|
-
(
|
|
1791
|
-
index
|
|
1792
|
-
for index, token in enumerate(all_segment_tokens)
|
|
1793
|
-
if Path(_strip_leading_subshell_grouping_characters(token)).name == FIND_PROGRAM_NAME
|
|
1794
|
-
),
|
|
1795
|
-
None,
|
|
1796
|
-
)
|
|
1797
|
-
if find_token_index is None:
|
|
1798
|
-
return False
|
|
1799
|
-
if not any(each_token in ALL_FIND_EXEC_ACTION_FLAGS for each_token in all_segment_tokens[find_token_index:]):
|
|
1800
|
-
return False
|
|
1801
|
-
all_search_root_tokens = _collect_find_search_root_tokens(all_segment_tokens[find_token_index + 1 :])
|
|
1802
|
-
return any(
|
|
1803
|
-
_rm_target_resolves_outside_ephemeral_namespace(each_search_root_token, declared_effective_cwd)
|
|
1804
|
-
for each_search_root_token in all_search_root_tokens
|
|
1805
|
-
)
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
def _command_changes_directory_beyond_leading_cd(command: str) -> bool:
|
|
1809
|
-
"""Return True when a directory change beyond a single leading ``cd`` runs.
|
|
1810
|
-
|
|
1811
|
-
The broad ephemeral auto-allow resolves a relative ``rm`` target against the
|
|
1812
|
-
declared effective working directory, which is established only by the
|
|
1813
|
-
command's single leading ``cd``. Any further directory change moves the base a
|
|
1814
|
-
later relative target resolves against, so the declared cwd no longer bounds
|
|
1815
|
-
the deletion: a second top-level ``cd`` (``cd /tmp/x && cd / && rm -rf etc``),
|
|
1816
|
-
a ``cd`` inside a subshell group (``(cd /; rm -rf etc)``), or a ``pushd`` /
|
|
1817
|
-
``popd`` anywhere. The caller nulls the declared cwd when this returns True, so
|
|
1818
|
-
every relative target fails closed while absolute targets — which no directory
|
|
1819
|
-
change can redirect — still resolve.
|
|
1820
|
-
|
|
1821
|
-
A leading ``cd`` is the first simple-command segment of the first physical line
|
|
1822
|
-
whose leading program, after leading ``VAR=value`` assignments and
|
|
1823
|
-
subshell-grouping characters are stripped, is ``cd``. Counts every segment whose
|
|
1824
|
-
leading program is ``cd``, ``pushd`` or ``popd`` and returns True when more such
|
|
1825
|
-
segments exist than the single leading ``cd``. Fails closed (returns True) when a
|
|
1826
|
-
physical line cannot be tokenized.
|
|
1827
|
-
|
|
1828
|
-
Args:
|
|
1829
|
-
command: The raw Bash command string from the tool input.
|
|
1830
|
-
|
|
1831
|
-
Returns:
|
|
1832
|
-
True when a directory change beyond a single leading ``cd`` is present.
|
|
1833
|
-
"""
|
|
1834
|
-
all_directory_changing_program_names = ("cd", "pushd", "popd")
|
|
1835
|
-
leading_cd_segment_count = 0
|
|
1836
|
-
directory_changing_segment_count = 0
|
|
1837
|
-
for each_line_index, each_command_line in enumerate(re.split(r"[\n\r]+", command)):
|
|
1838
|
-
try:
|
|
1839
|
-
all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
|
|
1840
|
-
except ValueError:
|
|
1841
|
-
return True
|
|
1842
|
-
all_operator_split_tokens = _explode_glued_shell_control_operators(all_command_tokens)
|
|
1843
|
-
for each_segment_index, each_segment in enumerate(_split_tokens_into_shell_segments(all_operator_split_tokens)):
|
|
1844
|
-
leading_program_token = _leading_command_token(each_segment)
|
|
1845
|
-
if leading_program_token is None:
|
|
1846
|
-
continue
|
|
1847
|
-
is_subshell_wrapped = leading_program_token[:1] in ALL_SUBSHELL_GROUPING_CHARACTERS
|
|
1848
|
-
stripped_program_name = Path(_strip_leading_subshell_grouping_characters(leading_program_token)).name
|
|
1849
|
-
if stripped_program_name not in all_directory_changing_program_names:
|
|
1850
|
-
continue
|
|
1851
|
-
directory_changing_segment_count += 1
|
|
1852
|
-
if (
|
|
1853
|
-
stripped_program_name == "cd"
|
|
1854
|
-
and each_line_index == 0
|
|
1855
|
-
and each_segment_index == 0
|
|
1856
|
-
and not is_subshell_wrapped
|
|
1857
|
-
):
|
|
1858
|
-
leading_cd_segment_count += 1
|
|
1859
|
-
return directory_changing_segment_count > leading_cd_segment_count
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
def _command_rm_targets_include_unsafe_path(command: str, tool_input: dict) -> bool:
|
|
1863
|
-
"""Return True when an ``rm`` in any segment targets a path outside the ephemeral cwd.
|
|
1864
|
-
|
|
1865
|
-
Tokenizes each physical line, explodes glued shell control operators, and splits
|
|
1866
|
-
into shell segments. A ``find ... -exec rm`` segment escapes when any of ``find``'s
|
|
1867
|
-
leading search roots resolves outside the ephemeral namespace, because those roots,
|
|
1868
|
-
not the ``rm``'s ``{}``/``+`` placeholders, name what gets deleted; that segment is
|
|
1869
|
-
then also checked as an ordinary ``rm`` segment, where the placeholder ``{}``/``+``
|
|
1870
|
-
targets resolve under the ephemeral cwd and a trailing redirect to the null device is
|
|
1871
|
-
benign while a redirect to any other file escapes. Within each segment, the first
|
|
1872
|
-
token whose basename is ``rm`` begins an ``rm`` invocation;
|
|
1873
|
-
its flags and targets are checked in isolation, so a sibling segment's flags
|
|
1874
|
-
(``mkdir -p``) or absolute paths (a ``python`` interpreter path) never count as this
|
|
1875
|
-
``rm``'s targets. A segment escapes when an unsafe flag precedes ``--`` or a target
|
|
1876
|
-
resolves outside the ephemeral namespace: an absolute non-ephemeral path, a relative
|
|
1877
|
-
path that resolves outside (or with no declared cwd to resolve against), a reference
|
|
1878
|
-
to a non-temporary environment variable, a bare ephemeral root, a bare
|
|
1879
|
-
named-worktrees container, or a glob wildcard basename.
|
|
1880
|
-
|
|
1881
|
-
A relative target fails closed when the command changes directory beyond a single
|
|
1882
|
-
leading ``cd`` (a second top-level ``cd``, a ``cd`` inside a subshell group, or a
|
|
1883
|
-
``pushd`` / ``popd``): the declared cwd is nulled so the leading ``cd`` no longer
|
|
1884
|
-
bounds the deletion, while absolute targets — which no directory change can
|
|
1885
|
-
redirect — still resolve.
|
|
1886
|
-
|
|
1887
|
-
Fails closed: returns True on parse failure (``ValueError`` from unbalanced quotes).
|
|
1888
|
-
The broad auto-allow must decline rather than grant on input the hook cannot
|
|
1889
|
-
conclusively bound.
|
|
1890
|
-
|
|
1891
|
-
Args:
|
|
1892
|
-
command: The raw Bash command string from the tool input.
|
|
1893
|
-
tool_input: The Bash tool input mapping, used to resolve the declared effective
|
|
1894
|
-
working directory.
|
|
1895
|
-
|
|
1896
|
-
Returns:
|
|
1897
|
-
True when any ``rm`` segment's flags or targets escape the ephemeral cwd.
|
|
1898
|
-
"""
|
|
1899
|
-
declared_effective_cwd = _resolve_declared_effective_working_directory(command, tool_input)
|
|
1900
|
-
if _command_changes_directory_beyond_leading_cd(command):
|
|
1901
|
-
declared_effective_cwd = None
|
|
1902
|
-
for each_command_line in re.split(r"[\n\r]+", command):
|
|
1903
|
-
try:
|
|
1904
|
-
all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
|
|
1905
|
-
except ValueError:
|
|
1906
|
-
return True
|
|
1907
|
-
all_operator_split_tokens = _explode_glued_shell_control_operators(all_command_tokens)
|
|
1908
|
-
for each_segment in _split_tokens_into_shell_segments(all_operator_split_tokens):
|
|
1909
|
-
if _find_exec_rm_search_root_escapes_ephemeral_cwd(each_segment, declared_effective_cwd):
|
|
1910
|
-
return True
|
|
1911
|
-
each_rm_token_index = next(
|
|
1912
|
-
(
|
|
1913
|
-
index
|
|
1914
|
-
for index, token in enumerate(each_segment)
|
|
1915
|
-
if Path(_strip_leading_subshell_grouping_characters(token)).name == "rm"
|
|
1916
|
-
),
|
|
1917
|
-
None,
|
|
1918
|
-
)
|
|
1919
|
-
if each_rm_token_index is None:
|
|
1920
|
-
continue
|
|
1921
|
-
if _rm_segment_targets_escape_ephemeral_cwd(each_segment[each_rm_token_index:], declared_effective_cwd):
|
|
1922
|
-
return True
|
|
1923
|
-
return False
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
def _git_reset_hard_allowed_for_command(command: str, current_working_directory: str) -> bool:
|
|
1927
|
-
if directory_is_ephemeral(current_working_directory):
|
|
1928
|
-
return True
|
|
1929
|
-
current_working_directory_lowercased = os.path.normpath(current_working_directory).lower()
|
|
1930
|
-
for allowed_project in load_allow_git_reset_hard_projects():
|
|
1931
|
-
allowed_project_lowercased = os.path.normpath(allowed_project).lower()
|
|
1932
|
-
if current_working_directory_lowercased.startswith(allowed_project_lowercased):
|
|
1933
|
-
return True
|
|
1934
|
-
for path_match in re.findall(r'cd\s+"([^"]+)"', command):
|
|
1935
|
-
if os.path.normpath(path_match).lower().startswith(allowed_project_lowercased):
|
|
1936
|
-
return True
|
|
1937
|
-
return False
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
def _is_convergence_branch(branch: str) -> bool:
|
|
1941
|
-
all_convergence_branch_prefixes = ALL_CONVERGENCE_BRANCH_PREFIXES
|
|
1942
|
-
for each_prefix in all_convergence_branch_prefixes:
|
|
1943
|
-
if branch.startswith(each_prefix):
|
|
1944
|
-
return True
|
|
1945
|
-
return bool(re.match(CONVERGENCE_BRANCH_SUFFIX_PATTERN, branch))
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
def _all_refspecs_are_convergence_branches(post_remote_text: str) -> bool:
|
|
1949
|
-
if not post_remote_text.strip():
|
|
1950
|
-
return False
|
|
1951
|
-
is_any_refspec_checked = False
|
|
1952
|
-
for each_token in post_remote_text.split():
|
|
1953
|
-
if each_token.startswith("-"):
|
|
1954
|
-
continue
|
|
1955
|
-
is_any_refspec_checked = True
|
|
1956
|
-
destination_branch = each_token.split(":")[-1]
|
|
1957
|
-
if not _is_convergence_branch(destination_branch):
|
|
1958
|
-
return False
|
|
1959
|
-
return is_any_refspec_checked
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
def _force_push_targets_convergence_branch(command: str) -> bool:
|
|
1963
|
-
convergence_force_push_detection_pattern = (
|
|
1964
|
-
CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN
|
|
1965
|
-
)
|
|
1966
|
-
is_force_push_found = False
|
|
1967
|
-
for each_match in re.finditer(
|
|
1968
|
-
convergence_force_push_detection_pattern, command, re.IGNORECASE
|
|
1969
|
-
):
|
|
1970
|
-
is_force_push_found = True
|
|
1971
|
-
post_push_text = each_match.group(1).strip()
|
|
1972
|
-
all_tokens = post_push_text.split()
|
|
1973
|
-
remote_index = 1 if all_tokens and all_tokens[0] in ("--force", "-f") else 0
|
|
1974
|
-
all_refspec_tokens = [
|
|
1975
|
-
token for token in all_tokens[remote_index + 1 :]
|
|
1976
|
-
if token not in ("--force", "-f")
|
|
1977
|
-
]
|
|
1978
|
-
post_remote_text = " ".join(all_refspec_tokens)
|
|
1979
|
-
if not post_remote_text:
|
|
1980
|
-
return False
|
|
1981
|
-
if not _all_refspecs_are_convergence_branches(post_remote_text):
|
|
1982
|
-
return False
|
|
1983
|
-
return is_force_push_found
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
def main() -> None:
|
|
1987
|
-
try:
|
|
1988
|
-
hook_input = json.load(sys.stdin)
|
|
1989
|
-
except json.JSONDecodeError:
|
|
1990
|
-
sys.exit(0)
|
|
1991
|
-
|
|
1992
|
-
tool_name = hook_input.get("tool_name", "")
|
|
1993
|
-
tool_input = hook_input.get("tool_input", {})
|
|
1994
|
-
|
|
1995
|
-
if tool_name != "Bash":
|
|
1996
|
-
sys.exit(0)
|
|
1997
|
-
|
|
1998
|
-
command = tool_input.get("command", "")
|
|
1999
|
-
|
|
2000
|
-
if gh_redirect_is_active():
|
|
2001
|
-
redirected_gh_description = find_redirected_gh_pattern(command)
|
|
2002
|
-
if redirected_gh_description is not None:
|
|
2003
|
-
print(json.dumps(_build_silent_gh_deny_response(redirected_gh_description)))
|
|
2004
|
-
sys.exit(0)
|
|
2005
|
-
|
|
2006
|
-
matched_description = find_destructive_pattern(command)
|
|
2007
|
-
|
|
2008
|
-
if (
|
|
2009
|
-
matched_description is not None
|
|
2010
|
-
and _destructive_match_is_rm_family(matched_description)
|
|
2011
|
-
and command_has_no_real_rm_invocation(command)
|
|
2012
|
-
):
|
|
2013
|
-
matched_description = _find_non_rm_destructive_pattern(command)
|
|
2014
|
-
|
|
2015
|
-
if matched_description is not None and targets_only_claude_directory(command):
|
|
2016
|
-
sys.exit(0)
|
|
2017
|
-
|
|
2018
|
-
if (
|
|
2019
|
-
matched_description is not None
|
|
2020
|
-
and _destructive_match_is_cwd_scoped(matched_description)
|
|
2021
|
-
and _effective_working_directory_is_ephemeral(command, tool_input)
|
|
2022
|
-
and not _command_executes_a_string_in_any_segment(command)
|
|
2023
|
-
and not _command_rm_targets_include_unsafe_path(command, tool_input)
|
|
2024
|
-
and not _command_contains_any_non_cwd_scoped_destructive_pattern(command)
|
|
2025
|
-
):
|
|
2026
|
-
sys.exit(0)
|
|
2027
|
-
|
|
2028
|
-
if matched_description is not None and _ephemeral_recursive_rm_auto_allow_granted(command, matched_description):
|
|
2029
|
-
sys.exit(0)
|
|
2030
|
-
|
|
2031
|
-
if (
|
|
2032
|
-
matched_description is not None
|
|
2033
|
-
and _destructive_match_is_rm_family(matched_description)
|
|
2034
|
-
and rm_compound_targets_only_absolute_ephemeral_paths(command)
|
|
2035
|
-
):
|
|
2036
|
-
sys.exit(0)
|
|
2037
|
-
|
|
2038
|
-
if matched_description is not None and "git reset --hard" in matched_description:
|
|
2039
|
-
if _git_reset_hard_allowed_for_command(command, os.getcwd()):
|
|
2040
|
-
sys.exit(0)
|
|
2041
|
-
|
|
2042
|
-
if (
|
|
2043
|
-
matched_description is not None
|
|
2044
|
-
and "git push" in matched_description
|
|
2045
|
-
and ("force" in matched_description or "-f" in matched_description)
|
|
2046
|
-
and _force_push_targets_convergence_branch(command)
|
|
2047
|
-
):
|
|
2048
|
-
co_resident_hazard_description = _find_non_force_push_destructive_hazard(command)
|
|
2049
|
-
if co_resident_hazard_description is None:
|
|
2050
|
-
sys.exit(0)
|
|
2051
|
-
matched_description = co_resident_hazard_description
|
|
2052
|
-
|
|
2053
|
-
if matched_description is not None:
|
|
2054
|
-
if deny_mode_is_active():
|
|
2055
|
-
terminal_decision = "deny"
|
|
2056
|
-
terminal_reason = f"DESTRUCTIVE: {matched_description}. Blocked in deny mode."
|
|
2057
|
-
else:
|
|
2058
|
-
terminal_decision = "ask"
|
|
2059
|
-
terminal_reason = f"DESTRUCTIVE: {matched_description}. Requires explicit user approval."
|
|
2060
|
-
terminal_response = {
|
|
2061
|
-
"hookSpecificOutput": {
|
|
2062
|
-
"hookEventName": "PreToolUse",
|
|
2063
|
-
"permissionDecision": terminal_decision,
|
|
2064
|
-
"permissionDecisionReason": terminal_reason
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
print(json.dumps(terminal_response))
|
|
2068
|
-
|
|
2069
|
-
sys.exit(0)
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
if __name__ == "__main__":
|
|
2073
|
-
main()
|