@opengsd/gsd-path 1.4.0 → 1.6.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/CHANGELOG.md +460 -341
- package/DOCS.md +16 -5
- package/FULL.md +67 -6
- package/HOOKS.md +158 -10
- package/MIGRATE.md +12 -0
- package/QUICK.md +4 -2
- package/README.md +136 -116
- package/RUNTIME.md +24 -6
- package/UPDATE.md +39 -3
- package/WORKFLOW.md +80 -4
- package/package.json +5 -2
- package/platforms/shared-agents/dispatch.md +13 -0
- package/scripts/_common.py +256 -2
- package/scripts/archive_milestone.py +14 -13
- package/scripts/build_recovery.py +155 -58
- package/scripts/build_state.py +274 -8
- package/scripts/check_docs_audit.py +77 -4
- package/scripts/check_handoffs.py +635 -57
- package/scripts/check_task_briefs.py +293 -32
- package/scripts/detect_project.py +260 -20
- package/scripts/discussion_records.py +9 -1
- package/scripts/discussion_validate.py +93 -35
- package/scripts/dispatch_driver.py +549 -53
- package/scripts/git_guard.py +27 -8
- package/scripts/guard_hook.py +2630 -210
- package/scripts/install.mjs +99 -11
- package/scripts/install.py +231 -46
- package/scripts/integration.py +244 -65
- package/scripts/isolation.py +539 -70
- package/scripts/loop_run.py +16 -4
- package/scripts/members.py +77 -32
- package/scripts/migrate_core.py +2 -0
- package/scripts/model_policy.py +5 -1
- package/scripts/path_config.py +3 -3
- package/scripts/pipeline_diagnose.py +17 -2
- package/scripts/pipeline_git.py +131 -42
- package/scripts/pipeline_state.py +139 -39
- package/scripts/pipeline_undo.py +118 -16
- package/scripts/promote_lookahead.py +3 -3
- package/scripts/review_findings.py +180 -44
- package/scripts/roadmap.py +9 -1
- package/scripts/runtime_store.py +21 -2
- package/scripts/skill-resources.json +9 -2
- package/scripts/state_checkpoint.py +39 -36
- package/scripts/state_promote.py +15 -4
- package/scripts/status_runtime.py +22 -4
- package/scripts/task_context.py +14 -3
- package/scripts/wizard.mjs +1 -0
- package/scripts/workflow_run.py +14 -4
- package/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path/DEFINE.md +34 -5
- package/skills/gsd-path/PLAN.md +14 -3
- package/skills/gsd-path/RESEARCH.md +11 -0
- package/skills/gsd-path/ROADMAP.md +16 -6
- package/skills/gsd-path/SHIP.md +32 -5
- package/skills/gsd-path/SKILL.md +52 -24
- package/skills/gsd-path/references/build-native.md +112 -10
- package/skills/gsd-path/references/build-recovery.md +13 -0
- package/skills/gsd-path/references/coder.md +7 -3
- package/skills/gsd-path/references/dispatch.md +13 -0
- package/skills/gsd-path/references/docs-auditor.md +5 -0
- package/skills/gsd-path/references/operating-rules.md +11 -3
- package/skills/gsd-path/references/planner.md +26 -4
- package/skills/gsd-path/references/reviewer-final.md +20 -2
- package/skills/gsd-path/references/reviewer-wave.md +56 -12
- package/skills/gsd-path/references/reviewer.md +3 -1
- package/skills/gsd-path/references/runtime-selection.md +9 -0
- package/skills/gsd-path/references/spec-probes.md +150 -0
- package/skills/gsd-path/scripts/_common.py +256 -2
- package/skills/gsd-path/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path/scripts/build_recovery.py +155 -58
- package/skills/gsd-path/scripts/build_state.py +274 -8
- package/skills/gsd-path/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path/scripts/detect_project.py +260 -20
- package/skills/gsd-path/scripts/discussion_records.py +9 -1
- package/skills/gsd-path/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path/scripts/dispatch_driver.py +549 -53
- package/skills/gsd-path/scripts/git_guard.py +27 -8
- package/skills/gsd-path/scripts/integration.py +244 -65
- package/skills/gsd-path/scripts/isolation.py +539 -70
- package/skills/gsd-path/scripts/loop_run.py +16 -4
- package/skills/gsd-path/scripts/members.py +77 -32
- package/skills/gsd-path/scripts/model_policy.py +5 -1
- package/skills/gsd-path/scripts/path_config.py +3 -3
- package/skills/gsd-path/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path/scripts/review_findings.py +180 -44
- package/skills/gsd-path/scripts/roadmap.py +9 -1
- package/skills/gsd-path/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path/scripts/state_promote.py +15 -4
- package/skills/gsd-path/scripts/status_runtime.py +22 -4
- package/skills/gsd-path/scripts/task_context.py +14 -3
- package/skills/gsd-path/scripts/workflow_run.py +14 -4
- package/skills/gsd-path/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path/templates/docs-audit.md +5 -1
- package/skills/gsd-path/templates/final-review.md +11 -2
- package/skills/gsd-path/templates/intent.md +30 -0
- package/skills/gsd-path/templates/patch-findings.md +3 -3
- package/skills/gsd-path/templates/plan.md +17 -2
- package/skills/gsd-path/templates/research-handoff.md +14 -3
- package/skills/gsd-path/templates/state.md +2 -1
- package/skills/gsd-path/templates/task.md +1 -0
- package/skills/gsd-path/templates/wave-review.md +28 -9
- package/skills/gsd-path-build/references/build-native.md +112 -10
- package/skills/gsd-path-build/references/build-recovery.md +13 -0
- package/skills/gsd-path-build/references/coder.md +7 -3
- package/skills/gsd-path-build/references/dispatch.md +13 -0
- package/skills/gsd-path-build/references/operating-rules.md +11 -3
- package/skills/gsd-path-build/references/reviewer-final.md +20 -2
- package/skills/gsd-path-build/references/reviewer-wave.md +56 -12
- package/skills/gsd-path-build/references/reviewer.md +3 -1
- package/skills/gsd-path-build/references/runtime-selection.md +9 -0
- package/skills/gsd-path-build/references/spec-probes.md +150 -0
- package/skills/gsd-path-build/scripts/_common.py +256 -2
- package/skills/gsd-path-build/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-build/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-build/scripts/build_state.py +274 -8
- package/skills/gsd-path-build/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-build/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-build/scripts/detect_project.py +260 -20
- package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-build/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-build/scripts/dispatch_driver.py +549 -53
- package/skills/gsd-path-build/scripts/integration.py +244 -65
- package/skills/gsd-path-build/scripts/isolation.py +539 -70
- package/skills/gsd-path-build/scripts/loop_run.py +16 -4
- package/skills/gsd-path-build/scripts/members.py +77 -32
- package/skills/gsd-path-build/scripts/model_policy.py +5 -1
- package/skills/gsd-path-build/scripts/path_config.py +3 -3
- package/skills/gsd-path-build/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-build/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-build/scripts/review_findings.py +180 -44
- package/skills/gsd-path-build/scripts/roadmap.py +9 -1
- package/skills/gsd-path-build/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-build/scripts/state_promote.py +15 -4
- package/skills/gsd-path-build/scripts/task_context.py +14 -3
- package/skills/gsd-path-build/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-build/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-build/templates/task.md +1 -0
- package/skills/gsd-path-build/templates/wave-review.md +28 -9
- package/skills/gsd-path-decide/references/dispatch.md +13 -0
- package/skills/gsd-path-decide/references/operating-rules.md +11 -3
- package/skills/gsd-path-decide/references/runtime-selection.md +9 -0
- package/skills/gsd-path-decide/scripts/_common.py +256 -2
- package/skills/gsd-path-decide/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-decide/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-decide/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-decide/scripts/detect_project.py +260 -20
- package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-decide/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-decide/scripts/integration.py +244 -65
- package/skills/gsd-path-decide/scripts/isolation.py +539 -70
- package/skills/gsd-path-decide/scripts/loop_run.py +16 -4
- package/skills/gsd-path-decide/scripts/members.py +77 -32
- package/skills/gsd-path-decide/scripts/model_policy.py +5 -1
- package/skills/gsd-path-decide/scripts/path_config.py +3 -3
- package/skills/gsd-path-decide/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-decide/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-decide/scripts/state_promote.py +15 -4
- package/skills/gsd-path-decide/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-define/SKILL.md +34 -5
- package/skills/gsd-path-define/references/build-recovery.md +13 -0
- package/skills/gsd-path-define/references/operating-rules.md +11 -3
- package/skills/gsd-path-define/references/runtime-selection.md +9 -0
- package/skills/gsd-path-define/references/spec-probes.md +150 -0
- package/skills/gsd-path-define/scripts/_common.py +256 -2
- package/skills/gsd-path-define/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-define/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-define/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-define/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-define/scripts/detect_project.py +260 -20
- package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-define/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-define/scripts/integration.py +244 -65
- package/skills/gsd-path-define/scripts/isolation.py +539 -70
- package/skills/gsd-path-define/scripts/loop_run.py +16 -4
- package/skills/gsd-path-define/scripts/members.py +77 -32
- package/skills/gsd-path-define/scripts/model_policy.py +5 -1
- package/skills/gsd-path-define/scripts/path_config.py +3 -3
- package/skills/gsd-path-define/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-define/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-define/scripts/roadmap.py +9 -1
- package/skills/gsd-path-define/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-define/scripts/state_promote.py +15 -4
- package/skills/gsd-path-define/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-define/templates/intent.md +30 -0
- package/skills/gsd-path-define/templates/state.md +2 -1
- package/skills/gsd-path-discuss/references/operating-rules.md +11 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +9 -0
- package/skills/gsd-path-discuss/scripts/_common.py +256 -2
- package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-discuss/scripts/loop_run.py +16 -4
- package/skills/gsd-path-discuss/scripts/model_policy.py +5 -1
- package/skills/gsd-path-discuss/scripts/path_config.py +3 -3
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +5 -0
- package/skills/gsd-path-docs-audit/references/operating-rules.md +11 -3
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +9 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +256 -2
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +260 -20
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-docs-audit/scripts/integration.py +244 -65
- package/skills/gsd-path-docs-audit/scripts/isolation.py +539 -70
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +16 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +77 -32
- package/skills/gsd-path-docs-audit/scripts/model_policy.py +5 -1
- package/skills/gsd-path-docs-audit/scripts/path_config.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +15 -4
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +5 -1
- package/skills/gsd-path-forensics/references/operating-rules.md +11 -3
- package/skills/gsd-path-forensics/references/runtime-selection.md +9 -0
- package/skills/gsd-path-forensics/scripts/_common.py +256 -2
- package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-forensics/scripts/loop_run.py +16 -4
- package/skills/gsd-path-forensics/scripts/model_policy.py +5 -1
- package/skills/gsd-path-forensics/scripts/path_config.py +3 -3
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +118 -16
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-inspect/references/dispatch.md +13 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +5 -0
- package/skills/gsd-path-inspect/references/operating-rules.md +11 -3
- package/skills/gsd-path-inspect/references/runtime-selection.md +9 -0
- package/skills/gsd-path-inspect/scripts/_common.py +256 -2
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-inspect/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-inspect/scripts/detect_project.py +260 -20
- package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-inspect/scripts/integration.py +244 -65
- package/skills/gsd-path-inspect/scripts/isolation.py +539 -70
- package/skills/gsd-path-inspect/scripts/loop_run.py +16 -4
- package/skills/gsd-path-inspect/scripts/members.py +77 -32
- package/skills/gsd-path-inspect/scripts/model_policy.py +5 -1
- package/skills/gsd-path-inspect/scripts/path_config.py +3 -3
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-inspect/scripts/state_promote.py +15 -4
- package/skills/gsd-path-inspect/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-inspect/templates/docs-audit.md +5 -1
- package/skills/gsd-path-inspect/templates/state.md +2 -1
- package/skills/gsd-path-loop/references/operating-rules.md +11 -3
- package/skills/gsd-path-loop/references/runtime-selection.md +9 -0
- package/skills/gsd-path-loop/scripts/_common.py +256 -2
- package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-loop/scripts/loop_run.py +16 -4
- package/skills/gsd-path-loop/scripts/model_policy.py +5 -1
- package/skills/gsd-path-loop/scripts/path_config.py +3 -3
- package/skills/gsd-path-loop/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-migrate/SKILL.md +8 -0
- package/skills/gsd-path-migrate/references/operating-rules.md +11 -3
- package/skills/gsd-path-migrate/references/runtime-selection.md +9 -0
- package/skills/gsd-path-migrate/scripts/_common.py +256 -2
- package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-migrate/scripts/loop_run.py +16 -4
- package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
- package/skills/gsd-path-migrate/scripts/model_policy.py +5 -1
- package/skills/gsd-path-migrate/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/SKILL.md +14 -3
- package/skills/gsd-path-plan/references/build-recovery.md +13 -0
- package/skills/gsd-path-plan/references/dispatch.md +13 -0
- package/skills/gsd-path-plan/references/operating-rules.md +11 -3
- package/skills/gsd-path-plan/references/planner.md +26 -4
- package/skills/gsd-path-plan/references/runtime-selection.md +9 -0
- package/skills/gsd-path-plan/references/spec-probes.md +150 -0
- package/skills/gsd-path-plan/scripts/_common.py +256 -2
- package/skills/gsd-path-plan/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-plan/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-plan/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-plan/scripts/detect_project.py +260 -20
- package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-plan/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-plan/scripts/integration.py +244 -65
- package/skills/gsd-path-plan/scripts/isolation.py +539 -70
- package/skills/gsd-path-plan/scripts/loop_run.py +16 -4
- package/skills/gsd-path-plan/scripts/members.py +77 -32
- package/skills/gsd-path-plan/scripts/model_policy.py +5 -1
- package/skills/gsd-path-plan/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-plan/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-plan/scripts/state_promote.py +15 -4
- package/skills/gsd-path-plan/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-plan/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
- package/skills/gsd-path-plan/templates/plan.md +17 -2
- package/skills/gsd-path-plan/templates/task.md +1 -0
- package/skills/gsd-path-research/SKILL.md +11 -0
- package/skills/gsd-path-research/references/dispatch.md +13 -0
- package/skills/gsd-path-research/references/operating-rules.md +11 -3
- package/skills/gsd-path-research/references/runtime-selection.md +9 -0
- package/skills/gsd-path-research/scripts/_common.py +256 -2
- package/skills/gsd-path-research/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-research/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-research/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-research/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-research/scripts/detect_project.py +260 -20
- package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-research/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-research/scripts/integration.py +244 -65
- package/skills/gsd-path-research/scripts/isolation.py +539 -70
- package/skills/gsd-path-research/scripts/loop_run.py +16 -4
- package/skills/gsd-path-research/scripts/members.py +77 -32
- package/skills/gsd-path-research/scripts/model_policy.py +5 -1
- package/skills/gsd-path-research/scripts/path_config.py +3 -3
- package/skills/gsd-path-research/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-research/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-research/scripts/roadmap.py +9 -1
- package/skills/gsd-path-research/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-research/scripts/state_promote.py +15 -4
- package/skills/gsd-path-research/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-research/templates/research-handoff.md +14 -3
- package/skills/gsd-path-roadmap/SKILL.md +16 -6
- package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
- package/skills/gsd-path-roadmap/references/operating-rules.md +11 -3
- package/skills/gsd-path-roadmap/references/runtime-selection.md +9 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +256 -2
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-roadmap/scripts/detect_project.py +260 -20
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-roadmap/scripts/integration.py +244 -65
- package/skills/gsd-path-roadmap/scripts/isolation.py +539 -70
- package/skills/gsd-path-roadmap/scripts/loop_run.py +16 -4
- package/skills/gsd-path-roadmap/scripts/members.py +77 -32
- package/skills/gsd-path-roadmap/scripts/model_policy.py +5 -1
- package/skills/gsd-path-roadmap/scripts/path_config.py +3 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-roadmap/scripts/state_promote.py +15 -4
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-ship/SKILL.md +32 -5
- package/skills/gsd-path-ship/references/dispatch.md +13 -0
- package/skills/gsd-path-ship/references/operating-rules.md +11 -3
- package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
- package/skills/gsd-path-ship/references/reviewer-wave.md +56 -12
- package/skills/gsd-path-ship/references/reviewer.md +3 -1
- package/skills/gsd-path-ship/references/runtime-selection.md +9 -0
- package/skills/gsd-path-ship/references/spec-probes.md +150 -0
- package/skills/gsd-path-ship/scripts/_common.py +256 -2
- package/skills/gsd-path-ship/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-ship/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-ship/scripts/build_state.py +274 -8
- package/skills/gsd-path-ship/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-ship/scripts/detect_project.py +260 -20
- package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-ship/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-ship/scripts/integration.py +244 -65
- package/skills/gsd-path-ship/scripts/isolation.py +539 -70
- package/skills/gsd-path-ship/scripts/loop_run.py +16 -4
- package/skills/gsd-path-ship/scripts/members.py +77 -32
- package/skills/gsd-path-ship/scripts/model_policy.py +5 -1
- package/skills/gsd-path-ship/scripts/path_config.py +3 -3
- package/skills/gsd-path-ship/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-ship/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-ship/scripts/state_promote.py +15 -4
- package/skills/gsd-path-ship/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-ship/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-ship/templates/final-review.md +11 -2
- package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
- package/skills/gsd-path-ship/templates/wave-review.md +28 -9
- package/skills/gsd-path-undo/references/operating-rules.md +11 -3
- package/skills/gsd-path-undo/references/runtime-selection.md +9 -0
- package/skills/gsd-path-undo/scripts/_common.py +256 -2
- package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-undo/scripts/loop_run.py +16 -4
- package/skills/gsd-path-undo/scripts/model_policy.py +5 -1
- package/skills/gsd-path-undo/scripts/path_config.py +3 -3
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +118 -16
- package/skills/gsd-path-undo/scripts/worktree_paths.py +54 -2
- package/skills/path/DEFINE.md +34 -5
- package/skills/path/PLAN.md +14 -3
- package/skills/path/RESEARCH.md +11 -0
- package/skills/path/ROADMAP.md +16 -6
- package/skills/path/SHIP.md +32 -5
- package/skills/path/SKILL.md +52 -24
- package/skills/path/references/build-native.md +112 -10
- package/skills/path/references/build-recovery.md +13 -0
- package/skills/path/references/coder.md +7 -3
- package/skills/path/references/dispatch.md +13 -0
- package/skills/path/references/docs-auditor.md +5 -0
- package/skills/path/references/operating-rules.md +11 -3
- package/skills/path/references/planner.md +26 -4
- package/skills/path/references/reviewer-final.md +20 -2
- package/skills/path/references/reviewer-wave.md +56 -12
- package/skills/path/references/reviewer.md +3 -1
- package/skills/path/references/runtime-selection.md +9 -0
- package/skills/path/references/spec-probes.md +150 -0
- package/skills/path/scripts/_common.py +256 -2
- package/skills/path/scripts/archive_milestone.py +14 -13
- package/skills/path/scripts/build_recovery.py +155 -58
- package/skills/path/scripts/build_state.py +274 -8
- package/skills/path/scripts/check_docs_audit.py +77 -4
- package/skills/path/scripts/check_handoffs.py +635 -57
- package/skills/path/scripts/check_task_briefs.py +293 -32
- package/skills/path/scripts/detect_project.py +260 -20
- package/skills/path/scripts/discussion_records.py +9 -1
- package/skills/path/scripts/discussion_validate.py +93 -35
- package/skills/path/scripts/dispatch_driver.py +549 -53
- package/skills/path/scripts/git_guard.py +27 -8
- package/skills/path/scripts/integration.py +244 -65
- package/skills/path/scripts/isolation.py +539 -70
- package/skills/path/scripts/loop_run.py +16 -4
- package/skills/path/scripts/members.py +77 -32
- package/skills/path/scripts/model_policy.py +5 -1
- package/skills/path/scripts/path_config.py +3 -3
- package/skills/path/scripts/pipeline_git.py +131 -42
- package/skills/path/scripts/pipeline_state.py +139 -39
- package/skills/path/scripts/promote_lookahead.py +3 -3
- package/skills/path/scripts/review_findings.py +180 -44
- package/skills/path/scripts/roadmap.py +9 -1
- package/skills/path/scripts/state_checkpoint.py +39 -36
- package/skills/path/scripts/state_promote.py +15 -4
- package/skills/path/scripts/status_runtime.py +22 -4
- package/skills/path/scripts/task_context.py +14 -3
- package/skills/path/scripts/workflow_run.py +14 -4
- package/skills/path/scripts/worktree_paths.py +54 -2
- package/skills/path/templates/docs-audit.md +5 -1
- package/skills/path/templates/final-review.md +11 -2
- package/skills/path/templates/intent.md +30 -0
- package/skills/path/templates/patch-findings.md +3 -3
- package/skills/path/templates/plan.md +17 -2
- package/skills/path/templates/research-handoff.md +14 -3
- package/skills/path/templates/state.md +2 -1
- package/skills/path/templates/task.md +1 -0
- package/skills/path/templates/wave-review.md +28 -9
- package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
package/scripts/guard_hook.py
CHANGED
|
@@ -121,6 +121,10 @@ ARCHIVE_REASON = (
|
|
|
121
121
|
"committed GSD Path archives under .project/archive/ are read-only; "
|
|
122
122
|
"only the bundled pipeline helpers may write there during a ship transaction"
|
|
123
123
|
)
|
|
124
|
+
ARCHIVE_UNPROVEN_READ_REASON = (
|
|
125
|
+
"commands run in archive context must be provably read-only; only "
|
|
126
|
+
"recognized read commands and the bundled pipeline helpers may run there"
|
|
127
|
+
)
|
|
124
128
|
DESTRUCTIVE_SHAPE_REASON = (
|
|
125
129
|
"destructive commands must run alone with literal paths; split compound, "
|
|
126
130
|
"substituted, or expanded destructive commands into single commands"
|
|
@@ -182,7 +186,7 @@ STATUS_STATE_FIELDS = frozenset(
|
|
|
182
186
|
"integration_source",
|
|
183
187
|
}
|
|
184
188
|
)
|
|
185
|
-
STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
|
|
189
|
+
STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request", "external-landing"})
|
|
186
190
|
STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
|
|
187
191
|
STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
188
192
|
STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
|
|
@@ -206,6 +210,7 @@ ARCHIVE_READ_COMMANDS = frozenset(
|
|
|
206
210
|
"tail",
|
|
207
211
|
"grep",
|
|
208
212
|
"rg",
|
|
213
|
+
"sed",
|
|
209
214
|
"ls",
|
|
210
215
|
"stat",
|
|
211
216
|
"wc",
|
|
@@ -233,6 +238,147 @@ CLOSED_LISTING_GIT_OPTIONS = {
|
|
|
233
238
|
"symbolic-ref": frozenset({"-q", "--quiet", "--short"}),
|
|
234
239
|
}
|
|
235
240
|
GIT_READ_WRITE_OPTIONS = frozenset({"--output", "--ext-diff", "--textconv"})
|
|
241
|
+
# Options whose value git writes to or runs as a command: one unknown word is
|
|
242
|
+
# already enough, so a parameter in or after them is never accepted.
|
|
243
|
+
GIT_PARAMETER_DENIED_OPTIONS = GIT_READ_WRITE_OPTIONS | {
|
|
244
|
+
"--upload-pack", "--receive-pack", "--exec", "-c", "--config-env",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# -c/--config keys whose value git executes as a program. A literal value is
|
|
249
|
+
# as dangerous as a parameter here: git -c core.fsmonitor=<path> status runs
|
|
250
|
+
# the configured program while the subcommand stays nominally read-only.
|
|
251
|
+
GIT_EXECUTED_CONFIG_KEYS = frozenset({
|
|
252
|
+
"core.fsmonitor", "core.editor", "sequence.editor", "core.sshcommand",
|
|
253
|
+
"core.askpass", "core.pager", "core.gitproxy", "interactive.difffilter",
|
|
254
|
+
})
|
|
255
|
+
GIT_EXECUTED_CONFIG_SECTIONS = {
|
|
256
|
+
"filter": (".clean", ".smudge", ".process", ".command"),
|
|
257
|
+
"diff": (".command", ".textconv", ".external"),
|
|
258
|
+
"merge": (".driver",),
|
|
259
|
+
"remote": (".uploadpack", ".receivepack"),
|
|
260
|
+
"gpg": (".program",),
|
|
261
|
+
"credential": (".helper",),
|
|
262
|
+
"pager": ("",),
|
|
263
|
+
}
|
|
264
|
+
# Keys whose value is a path or URL git loads code through: an included file
|
|
265
|
+
# can set every key above, and a rewritten URL can retarget the transport.
|
|
266
|
+
# No value of these is a disabling form.
|
|
267
|
+
GIT_LOADED_CONFIG_KEYS = frozenset({"core.hookspath", "include.path"})
|
|
268
|
+
GIT_LOADED_CONFIG_SECTIONS = {
|
|
269
|
+
"includeif": (".path",),
|
|
270
|
+
"url": (".insteadof", ".pushinsteadof"),
|
|
271
|
+
}
|
|
272
|
+
GIT_CONFIG_DISABLING_VALUES = frozenset({"", "false"})
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def config_key_in(key, keys, sections):
|
|
276
|
+
if key in keys:
|
|
277
|
+
return True
|
|
278
|
+
section, _, remainder = key.partition(".")
|
|
279
|
+
return bool(remainder) and key.endswith(tuple(sections.get(section, ())))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def loaded_config_key(key):
|
|
283
|
+
"""Whether a config key's value is a path or URL git loads code through."""
|
|
284
|
+
return config_key_in(key, GIT_LOADED_CONFIG_KEYS, GIT_LOADED_CONFIG_SECTIONS)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def executed_config_key(key):
|
|
288
|
+
"""Whether a config key's value is a program git runs or code it loads."""
|
|
289
|
+
return loaded_config_key(key) or config_key_in(
|
|
290
|
+
key, GIT_EXECUTED_CONFIG_KEYS, GIT_EXECUTED_CONFIG_SECTIONS
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# Real read-only git options that are a strict prefix of a denied option name.
|
|
295
|
+
GIT_SAFE_PREFIX_OPTIONS = frozenset({"--text"})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def git_option_in(token, options):
|
|
299
|
+
"""Whether a token names one of the options, by full name or any long-option prefix.
|
|
300
|
+
|
|
301
|
+
git accepts each unique prefix of a long option; matching every prefix is
|
|
302
|
+
stricter than git and so only fails closed. A prefix that is itself the
|
|
303
|
+
exact name of a read-only option is that option, not an abbreviation.
|
|
304
|
+
"""
|
|
305
|
+
name = token.split("=", 1)[0]
|
|
306
|
+
if name in GIT_SAFE_PREFIX_OPTIONS and name not in options:
|
|
307
|
+
return False
|
|
308
|
+
if name.startswith("--") and len(name) > 2:
|
|
309
|
+
return any(option.startswith(name) for option in options)
|
|
310
|
+
return name in options
|
|
311
|
+
GIT_CONFIG_READ_MODES = frozenset({
|
|
312
|
+
"--get", "--get-all", "--get-regexp", "--get-urlmatch", "--get-color",
|
|
313
|
+
"--get-colorbool", "-l", "--list", "--unset", "--unset-all",
|
|
314
|
+
})
|
|
315
|
+
GIT_CONFIG_READ_SUBCOMMANDS = frozenset({"get", "unset", "list"})
|
|
316
|
+
GIT_CONFIG_DENIED_MODES = frozenset({"--rename-section", "--remove-section", "--edit"})
|
|
317
|
+
GIT_CONFIG_DENIED_SUBCOMMANDS = frozenset({"rename-section", "remove-section", "edit"})
|
|
318
|
+
GIT_CONFIG_VALUE_OPTIONS = frozenset({
|
|
319
|
+
"--file", "--blob", "--type", "--default", "--comment", "--value", "--url",
|
|
320
|
+
})
|
|
321
|
+
GIT_CONFIG_EDIT_SHORT_OPTION = re.compile(r"-[A-Za-z]*e[A-Za-z]*")
|
|
322
|
+
# An unquoted word the shell can turn into several arguments: a glob, or a
|
|
323
|
+
# brace list or range.
|
|
324
|
+
PLAIN_QUOTED_SEGMENT = re.compile(r"'[^']*'|\"[^\"]*\"")
|
|
325
|
+
ANSI_QUOTE_START = re.compile(r"\$['\"]")
|
|
326
|
+
GIT_CONFIG_EXPANDING_WORD = re.compile(r"[*?\[]|\{.*(?:,|\.\.)")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def denied_git_config_argument(arguments):
|
|
330
|
+
"""The git config argument that persists or runs a program git executes.
|
|
331
|
+
|
|
332
|
+
A section rename can carry a key into an executed section and --edit runs
|
|
333
|
+
the editor, so both are denied outright. Otherwise only a set denies: an
|
|
334
|
+
executed key with a value operand after it and no read or unset mode.
|
|
335
|
+
Only a trailing redirection is dropped: the operator, its target and a
|
|
336
|
+
file descriptor digit written against it. A separate digit (5 >file) and a
|
|
337
|
+
quoted operator are operands.
|
|
338
|
+
"""
|
|
339
|
+
arguments = list(arguments)
|
|
340
|
+
while (
|
|
341
|
+
len(arguments) >= 2
|
|
342
|
+
and getattr(arguments[-2], "redirection", False)
|
|
343
|
+
and not arguments[-2].isdigit()
|
|
344
|
+
):
|
|
345
|
+
del arguments[-2:]
|
|
346
|
+
if (
|
|
347
|
+
arguments
|
|
348
|
+
and getattr(arguments[-1], "redirection", False)
|
|
349
|
+
and arguments[-1].isdigit()
|
|
350
|
+
):
|
|
351
|
+
del arguments[-1]
|
|
352
|
+
separator = arguments.index("--") if "--" in arguments else len(arguments)
|
|
353
|
+
options = arguments[:separator]
|
|
354
|
+
for token in options:
|
|
355
|
+
if (
|
|
356
|
+
git_option_in(token, GIT_CONFIG_DENIED_MODES)
|
|
357
|
+
or GIT_CONFIG_EDIT_SHORT_OPTION.fullmatch(token)
|
|
358
|
+
or token.casefold() in GIT_CONFIG_DENIED_SUBCOMMANDS
|
|
359
|
+
):
|
|
360
|
+
return token
|
|
361
|
+
if options[:1] and options[0].casefold() in GIT_CONFIG_READ_SUBCOMMANDS:
|
|
362
|
+
return None
|
|
363
|
+
for position, token in enumerate(options):
|
|
364
|
+
# A mode word that is the value of the option before it is no mode.
|
|
365
|
+
if token in GIT_CONFIG_READ_MODES and not (
|
|
366
|
+
position
|
|
367
|
+
and (
|
|
368
|
+
options[position - 1] == "-f"
|
|
369
|
+
or git_option_in(options[position - 1], GIT_CONFIG_VALUE_OPTIONS)
|
|
370
|
+
)
|
|
371
|
+
):
|
|
372
|
+
return None
|
|
373
|
+
operands = [
|
|
374
|
+
token for token in options if not token.startswith("-")
|
|
375
|
+
] + arguments[separator + 1:]
|
|
376
|
+
for token in operands[:-1]:
|
|
377
|
+
if executed_config_key(token.casefold()):
|
|
378
|
+
return token
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
|
|
236
382
|
ARCHIVE_READ_EXECUTION_OPTIONS = {"rg": frozenset({"--pre"})}
|
|
237
383
|
AMBIGUOUS_SHELL_SYNTAX = re.compile(r"[\r\n|;&<>`]|\$\(|@\(")
|
|
238
384
|
SUBSTITUTION_PLACEHOLDER = "COMMAND_SUBSTITUTION_"
|
|
@@ -240,10 +386,6 @@ SUBSTITUTION_PLACEHOLDER_SYNTAX = re.compile(
|
|
|
240
386
|
r"\$\{" + SUBSTITUTION_PLACEHOLDER + r"(\d+)\}"
|
|
241
387
|
)
|
|
242
388
|
LINE_CONTINUATION = re.compile(r"(?<!\\)((?:\\\\)*)\\\r?\n")
|
|
243
|
-
HEREDOC_PATTERN = re.compile(
|
|
244
|
-
r"<<(?P<dash>-?)\s*(?:'(?P<single>[^']+)'|\"(?P<double>[^\"]+)\""
|
|
245
|
-
r"|\\?(?P<bare>[A-Za-z_][A-Za-z0-9_]*))"
|
|
246
|
-
)
|
|
247
389
|
SHELL_WRITE_REDIRECTION_CHARS = frozenset("<>&|")
|
|
248
390
|
SHELL_PARAMETER_SYNTAX = re.compile(
|
|
249
391
|
r"\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[-*@#?$!]|\{[^}\r\n]+\})"
|
|
@@ -251,9 +393,12 @@ SHELL_PARAMETER_SYNTAX = re.compile(
|
|
|
251
393
|
NAMED_SHELL_PARAMETER_SYNTAX = re.compile(
|
|
252
394
|
r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})"
|
|
253
395
|
)
|
|
396
|
+
ZSH_EXPANSION_FLAG_SYNTAX = re.compile(r"\$[=~^+]")
|
|
254
397
|
CMD_PARAMETER_SYNTAX = re.compile(
|
|
255
398
|
r"%([A-Za-z_][A-Za-z0-9_]*)%|!([A-Za-z_][A-Za-z0-9_]*)!"
|
|
256
399
|
)
|
|
400
|
+
# The shell reassigns these on every cd, so a tracked value is stale.
|
|
401
|
+
SHELL_DIRECTORY_VARIABLES = frozenset({"PWD", "OLDPWD"})
|
|
257
402
|
DIRECTORY_CHANGE_COMMANDS = frozenset(
|
|
258
403
|
{"cd", "chdir", "pushd", "set-location", "sl"}
|
|
259
404
|
)
|
|
@@ -262,6 +407,18 @@ GIT_REASONS = {
|
|
|
262
407
|
"clean": "git clean -f deletes untracked evidence and retained task worktrees",
|
|
263
408
|
"push": "force pushes rewrite build-branch history the pipeline resumes from",
|
|
264
409
|
"branch": "git branch -D destroys task branches the recovery protocol inspects",
|
|
410
|
+
"unresolved-target": (
|
|
411
|
+
"{action} needs a literal target the guard can resolve; name the branch, "
|
|
412
|
+
"ref, or registered worktree directly"
|
|
413
|
+
),
|
|
414
|
+
"git-environment": (
|
|
415
|
+
"env changes Git's repository or alias configuration context; run the Git "
|
|
416
|
+
"command without resetting or unsetting Git-related environment variables"
|
|
417
|
+
),
|
|
418
|
+
"update-ref-order": (
|
|
419
|
+
"git update-ref deletion after another Git command can follow a symbolic "
|
|
420
|
+
"ref that command changed; run it as its own command or add --no-deref"
|
|
421
|
+
),
|
|
265
422
|
"branch-move": (
|
|
266
423
|
"git branch -m renames a branch the pipeline resumes by name "
|
|
267
424
|
"(STATE.branch); create a new branch instead"
|
|
@@ -306,6 +463,8 @@ MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
|
|
|
306
463
|
UNVALIDATED_EXECUTION_REASONS = {
|
|
307
464
|
".": SCRIPT_FILE_REASON,
|
|
308
465
|
"source": SCRIPT_FILE_REASON,
|
|
466
|
+
"readonly": "readonly changes shell assignment behavior the guard cannot validate; "
|
|
467
|
+
"avoid readonly declarations",
|
|
309
468
|
"start": "start launches a detached process the guard cannot inspect; run the program directly",
|
|
310
469
|
"setenv": ENV_BUILTIN_REASON,
|
|
311
470
|
"unsetenv": ENV_BUILTIN_REASON,
|
|
@@ -370,7 +529,64 @@ SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
|
|
|
370
529
|
}
|
|
371
530
|
)
|
|
372
531
|
IN_PLACE_EDITORS = frozenset({"perl", "sed"})
|
|
532
|
+
SED_SAFE_OPTIONS = frozenset(
|
|
533
|
+
{
|
|
534
|
+
"--debug",
|
|
535
|
+
"--help",
|
|
536
|
+
"--null-data",
|
|
537
|
+
"--posix",
|
|
538
|
+
"--quiet",
|
|
539
|
+
"--regexp-extended",
|
|
540
|
+
"--sandbox",
|
|
541
|
+
"--separate",
|
|
542
|
+
"--silent",
|
|
543
|
+
"--unbuffered",
|
|
544
|
+
"--version",
|
|
545
|
+
"-E",
|
|
546
|
+
"-n",
|
|
547
|
+
"-r",
|
|
548
|
+
"-s",
|
|
549
|
+
"-u",
|
|
550
|
+
}
|
|
551
|
+
)
|
|
373
552
|
FIND_EXECUTION_ACTIONS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
|
553
|
+
# Programs that run the command in their arguments, with the options of each
|
|
554
|
+
# that take a separate value. The guard checks the command they run.
|
|
555
|
+
EXEC_PREFIX_VALUE_OPTIONS = {
|
|
556
|
+
"nice": frozenset({"-n", "--adjustment"}),
|
|
557
|
+
"nohup": frozenset(),
|
|
558
|
+
"setsid": frozenset(),
|
|
559
|
+
"stdbuf": frozenset({"-i", "-o", "-e", "--input", "--output", "--error"}),
|
|
560
|
+
"sudo": frozenset({
|
|
561
|
+
"-u", "-g", "-h", "-p", "-C", "-T", "-U", "-r", "-t",
|
|
562
|
+
"--user", "--group", "--host", "--prompt", "--close-from",
|
|
563
|
+
"--command-timeout", "--other-user", "--role", "--type",
|
|
564
|
+
}),
|
|
565
|
+
"time": frozenset({"-f", "--format"}),
|
|
566
|
+
"timeout": frozenset({"-s", "-k", "--signal", "--kill-after"}),
|
|
567
|
+
"watch": frozenset({"-n", "-q", "--interval", "--equexit"}),
|
|
568
|
+
}
|
|
569
|
+
# Runner options that write, edit or enter a file or directory by themselves.
|
|
570
|
+
EXEC_PREFIX_FILE_OPTIONS = {
|
|
571
|
+
"sudo": frozenset({"-e", "-D", "-R", "--edit", "--chdir", "--chroot"}),
|
|
572
|
+
"time": frozenset({"-o", "--output"}),
|
|
573
|
+
"watch": frozenset({"-s", "--shotsdir"}),
|
|
574
|
+
}
|
|
575
|
+
# The short option letters of each runner that take no value.
|
|
576
|
+
EXEC_PREFIX_FLAG_LETTERS = {
|
|
577
|
+
"nice": "",
|
|
578
|
+
"nohup": "",
|
|
579
|
+
"setsid": "cfw",
|
|
580
|
+
"stdbuf": "",
|
|
581
|
+
"sudo": "AbBEHiKklnNPSsVv",
|
|
582
|
+
"time": "ahlpqvV",
|
|
583
|
+
"timeout": "fpv",
|
|
584
|
+
"watch": "bcCdegprtwx",
|
|
585
|
+
}
|
|
586
|
+
EXEC_PREFIX_INTERVAL_OPTIONS = frozenset({"-n", "--interval"})
|
|
587
|
+
RUNNER_DURATION = re.compile(
|
|
588
|
+
r"(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?)[smhd]?", re.IGNORECASE
|
|
589
|
+
)
|
|
374
590
|
SHELL_CONTROL_WORDS = frozenset(
|
|
375
591
|
{
|
|
376
592
|
"!",
|
|
@@ -925,30 +1141,29 @@ def closed_target_reason(targets):
|
|
|
925
1141
|
return None
|
|
926
1142
|
|
|
927
1143
|
|
|
928
|
-
def
|
|
929
|
-
|
|
930
|
-
if reason:
|
|
931
|
-
deny(reason)
|
|
932
|
-
classified = pipeline_target_kinds((path, working_directories) for path in paths)
|
|
1144
|
+
def pipeline_product_phase_reason(classified):
|
|
1145
|
+
"""Why product writes are denied outside the routed build phase, or None when allowed."""
|
|
933
1146
|
if classified is None:
|
|
934
|
-
return
|
|
1147
|
+
return None
|
|
935
1148
|
repo, state, kinds = classified
|
|
936
1149
|
for path, kind in kinds:
|
|
937
1150
|
if kind == "protected":
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
if all(kind == "external" for kind in
|
|
941
|
-
return
|
|
1151
|
+
return f"{CONTROL_FILE_REASON} {path}"
|
|
1152
|
+
kind_values = [kind for _, kind in kinds]
|
|
1153
|
+
if all(kind == "external" for kind in kind_values):
|
|
1154
|
+
return None
|
|
1155
|
+
if not any(kind == "product" for kind in kind_values):
|
|
1156
|
+
return None
|
|
942
1157
|
try:
|
|
943
1158
|
status = project_status(repo)
|
|
944
1159
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
945
|
-
|
|
1160
|
+
return REENTRY_FAILURE_REASON
|
|
946
1161
|
route = status.get("route")
|
|
947
1162
|
state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
|
|
948
1163
|
completion = status.get("completion")
|
|
949
1164
|
if (state_data.get("phase") == "shipped"
|
|
950
1165
|
and isinstance(completion, dict) and completion.get("status") == "verified"):
|
|
951
|
-
return
|
|
1166
|
+
return None
|
|
952
1167
|
routed_build = (
|
|
953
1168
|
state_data.get("phase") == "build"
|
|
954
1169
|
and isinstance(route, dict)
|
|
@@ -956,20 +1171,31 @@ def enforce_pipeline_reentry(paths, working_directories):
|
|
|
956
1171
|
and route.get("phase") == "build"
|
|
957
1172
|
)
|
|
958
1173
|
if routed_build:
|
|
959
|
-
return
|
|
960
|
-
if all(kind in {"external", "artifact"} for kind in
|
|
961
|
-
return
|
|
1174
|
+
return None
|
|
1175
|
+
if all(kind in {"external", "artifact"} for kind in kind_values):
|
|
1176
|
+
return None
|
|
962
1177
|
phase = state_data.get("phase", "unknown")
|
|
963
1178
|
if isinstance(route, dict) and route.get("action") != "run-phase":
|
|
964
1179
|
next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
|
|
965
1180
|
else:
|
|
966
1181
|
next_step = status.get("next_skill") or "gsd-path"
|
|
967
|
-
|
|
1182
|
+
return (
|
|
968
1183
|
f"GSD Path is {phase}; direct product-file changes require the routed "
|
|
969
1184
|
f"build phase. Review {status.get('path', state)}; next: {next_step}"
|
|
970
1185
|
)
|
|
971
1186
|
|
|
972
1187
|
|
|
1188
|
+
def enforce_pipeline_reentry(paths, working_directories):
|
|
1189
|
+
reason = closed_target_reason((path, working_directories) for path in paths)
|
|
1190
|
+
if reason:
|
|
1191
|
+
deny(reason)
|
|
1192
|
+
reason = pipeline_product_phase_reason(
|
|
1193
|
+
pipeline_target_kinds((path, working_directories) for path in paths)
|
|
1194
|
+
)
|
|
1195
|
+
if reason:
|
|
1196
|
+
deny(reason)
|
|
1197
|
+
|
|
1198
|
+
|
|
973
1199
|
def in_archive(path):
|
|
974
1200
|
# Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
|
|
975
1201
|
normalized = normalize_posix(path).casefold()
|
|
@@ -1147,10 +1373,10 @@ def directory_change_target(arguments):
|
|
|
1147
1373
|
return operands[-1]
|
|
1148
1374
|
|
|
1149
1375
|
|
|
1150
|
-
def segment_directories(tokens, working_directories):
|
|
1376
|
+
def segment_directories(tokens, working_directories, contexts=None):
|
|
1151
1377
|
"""Yield each command segment with the working directories in effect for it."""
|
|
1152
1378
|
current_directories = list(working_directories)
|
|
1153
|
-
for segment in command_segments(tokens):
|
|
1379
|
+
for position, segment in enumerate(command_segments(tokens)):
|
|
1154
1380
|
yield segment, current_directories
|
|
1155
1381
|
invocation = command_invocation(segment)
|
|
1156
1382
|
if invocation is None:
|
|
@@ -1163,16 +1389,36 @@ def segment_directories(tokens, working_directories):
|
|
|
1163
1389
|
if command not in DIRECTORY_CHANGE_COMMANDS:
|
|
1164
1390
|
continue
|
|
1165
1391
|
target = directory_change_target(arguments)
|
|
1166
|
-
|
|
1392
|
+
tracked = {}
|
|
1393
|
+
if contexts is not None and position < len(contexts):
|
|
1394
|
+
tracked = {
|
|
1395
|
+
name: value
|
|
1396
|
+
for name, value in contexts[position].items()
|
|
1397
|
+
if name not in SHELL_DIRECTORY_VARIABLES
|
|
1398
|
+
}
|
|
1399
|
+
expanded = expand_environment_parameters(target, tracked, tracked_only=True)
|
|
1400
|
+
# An unquoted empty expansion leaves no word: the shell changes to HOME.
|
|
1401
|
+
if (
|
|
1402
|
+
(target and not expanded)
|
|
1403
|
+
or SHELL_PARAMETER_SYNTAX.search(expanded)
|
|
1404
|
+
or CMD_PARAMETER_SYNTAX.search(expanded)
|
|
1405
|
+
):
|
|
1167
1406
|
raise ValueError(
|
|
1168
1407
|
f"cd target {target} cannot be resolved by the guard; "
|
|
1169
1408
|
"run that command first and cd to the literal result"
|
|
1170
1409
|
)
|
|
1171
|
-
if
|
|
1172
|
-
|
|
1410
|
+
if (
|
|
1411
|
+
(SHELL_PARAMETER_SYNTAX.search(target) or CMD_PARAMETER_SYNTAX.search(target))
|
|
1412
|
+
and any(character.isspace() or character in "*?[]" for character in expanded)
|
|
1413
|
+
):
|
|
1414
|
+
raise ValueError(
|
|
1415
|
+
f"cd target {target} may split or glob; pass a literal path"
|
|
1416
|
+
)
|
|
1417
|
+
if is_absolute_path(expanded):
|
|
1418
|
+
current_directories = [expanded]
|
|
1173
1419
|
else:
|
|
1174
1420
|
bases = current_directories or ["."]
|
|
1175
|
-
current_directories = [f"{base}/{
|
|
1421
|
+
current_directories = [f"{base}/{expanded}" for base in bases]
|
|
1176
1422
|
|
|
1177
1423
|
|
|
1178
1424
|
def command_can_destroy_files(invocation):
|
|
@@ -1188,9 +1434,11 @@ def command_can_destroy_files(invocation):
|
|
|
1188
1434
|
def literal_path(operand):
|
|
1189
1435
|
if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
|
|
1190
1436
|
operand = operand[1:-1]
|
|
1191
|
-
|
|
1437
|
+
# A space is safe here: the operand is one shlex unit, so it cannot carry a
|
|
1438
|
+
# second word; injection-relevant characters stay excluded.
|
|
1439
|
+
pattern = r"[A-Za-z0-9._/ -]+"
|
|
1192
1440
|
if os.name == "nt":
|
|
1193
|
-
pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._
|
|
1441
|
+
pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\ -]+"
|
|
1194
1442
|
return operand if re.fullmatch(pattern, operand) is not None else None
|
|
1195
1443
|
|
|
1196
1444
|
|
|
@@ -1203,19 +1451,27 @@ def literal_destructive_operand(operand):
|
|
|
1203
1451
|
return literal
|
|
1204
1452
|
|
|
1205
1453
|
|
|
1206
|
-
def
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1454
|
+
def segment_references_archive(segment, directories, assignments=None):
|
|
1455
|
+
invocation = command_invocation(segment)
|
|
1456
|
+
ancestors = command_can_destroy_files(invocation)
|
|
1457
|
+
# A here-document body survives tokenization only inside a wrapped payload
|
|
1458
|
+
# (bash -c '...'), where it is data, not commands; keep its text from
|
|
1459
|
+
# flipping the segment into archive context.
|
|
1460
|
+
def searchable(token):
|
|
1461
|
+
return strip_heredoc_bodies(token) if "\n" in token else token
|
|
1462
|
+
|
|
1463
|
+
operands = [
|
|
1464
|
+
expand_environment_parameters(searchable(token), assignments)
|
|
1465
|
+
for token in segment
|
|
1466
|
+
]
|
|
1467
|
+
if ancestors:
|
|
1468
|
+
resolved = command_invocation(operands)
|
|
1469
|
+
operands = [literal_destructive_operand(operand) for operand in resolved[1]]
|
|
1470
|
+
if any(path_in_archive(operand, directories, ancestors) for operand in operands):
|
|
1471
|
+
return True
|
|
1472
|
+
return unresolved_archive_expansion(
|
|
1473
|
+
" ".join(searchable(token) for token in segment), assignments
|
|
1474
|
+
)
|
|
1219
1475
|
|
|
1220
1476
|
|
|
1221
1477
|
def copy_destinations(arguments, directories, assignments=None):
|
|
@@ -1286,43 +1542,258 @@ def copy_destinations(arguments, directories, assignments=None):
|
|
|
1286
1542
|
yield str(output / entry.relative_to(source_path))
|
|
1287
1543
|
|
|
1288
1544
|
|
|
1545
|
+
def sed_delimited_end(script, index, delimiter):
|
|
1546
|
+
while index < len(script):
|
|
1547
|
+
if script[index] == "\\":
|
|
1548
|
+
index += 2
|
|
1549
|
+
elif script[index] == delimiter:
|
|
1550
|
+
return index + 1
|
|
1551
|
+
else:
|
|
1552
|
+
index += 1
|
|
1553
|
+
return None
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def sed_address_end(script, index):
|
|
1557
|
+
if index >= len(script):
|
|
1558
|
+
return index
|
|
1559
|
+
if script[index].isdigit():
|
|
1560
|
+
while index < len(script) and script[index].isdigit():
|
|
1561
|
+
index += 1
|
|
1562
|
+
if index < len(script) and script[index] == "~":
|
|
1563
|
+
index += 1
|
|
1564
|
+
while index < len(script) and script[index].isdigit():
|
|
1565
|
+
index += 1
|
|
1566
|
+
elif script[index] == "$":
|
|
1567
|
+
index += 1
|
|
1568
|
+
elif script[index] == "/":
|
|
1569
|
+
end = sed_delimited_end(script, index + 1, "/")
|
|
1570
|
+
return len(script) if end is None else end
|
|
1571
|
+
elif script[index] == "\\" and index + 1 < len(script):
|
|
1572
|
+
end = sed_delimited_end(script, index + 2, script[index + 1])
|
|
1573
|
+
return len(script) if end is None else end
|
|
1574
|
+
return index
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def sed_script_is_read_only(script):
|
|
1578
|
+
"""Accept only sed scripts whose commands cannot write or execute."""
|
|
1579
|
+
# Sed blocks require recursively validating their nested command grammar;
|
|
1580
|
+
# reject them until that grammar is modeled explicitly.
|
|
1581
|
+
safe_commands = frozenset("aAcCdDgGhHiIlLnNpPqQrstTxv=:")
|
|
1582
|
+
index = 0
|
|
1583
|
+
while index < len(script):
|
|
1584
|
+
while index < len(script) and (script[index].isspace() or script[index] == ";"):
|
|
1585
|
+
index += 1
|
|
1586
|
+
if index >= len(script):
|
|
1587
|
+
break
|
|
1588
|
+
address_start = index
|
|
1589
|
+
index = sed_address_end(script, index)
|
|
1590
|
+
if index < len(script) and script[index] == ",":
|
|
1591
|
+
index = sed_address_end(script, index + 1)
|
|
1592
|
+
if index > address_start and index < len(script) and script[index] == "!":
|
|
1593
|
+
index += 1
|
|
1594
|
+
while index < len(script) and script[index].isspace():
|
|
1595
|
+
index += 1
|
|
1596
|
+
if index >= len(script):
|
|
1597
|
+
break
|
|
1598
|
+
command = script[index]
|
|
1599
|
+
index += 1
|
|
1600
|
+
if command in {"w", "W", "e"}:
|
|
1601
|
+
return False
|
|
1602
|
+
if command == "#":
|
|
1603
|
+
newline = script.find("\n", index)
|
|
1604
|
+
index = len(script) if newline < 0 else newline + 1
|
|
1605
|
+
continue
|
|
1606
|
+
if command == "s":
|
|
1607
|
+
if index >= len(script):
|
|
1608
|
+
return False
|
|
1609
|
+
delimiter = script[index]
|
|
1610
|
+
index = sed_delimited_end(script, index + 1, delimiter)
|
|
1611
|
+
if index is None:
|
|
1612
|
+
return False
|
|
1613
|
+
index = sed_delimited_end(script, index, delimiter)
|
|
1614
|
+
if index is None:
|
|
1615
|
+
return False
|
|
1616
|
+
end = index
|
|
1617
|
+
while end < len(script) and script[end] not in ";\n":
|
|
1618
|
+
end += 1
|
|
1619
|
+
if any(flag in "wWe" for flag in script[index:end]):
|
|
1620
|
+
return False
|
|
1621
|
+
index = end
|
|
1622
|
+
continue
|
|
1623
|
+
if command == "y":
|
|
1624
|
+
if index >= len(script):
|
|
1625
|
+
return False
|
|
1626
|
+
delimiter = script[index]
|
|
1627
|
+
index = sed_delimited_end(script, index + 1, delimiter)
|
|
1628
|
+
if index is None:
|
|
1629
|
+
return False
|
|
1630
|
+
index = sed_delimited_end(script, index, delimiter)
|
|
1631
|
+
if index is None:
|
|
1632
|
+
return False
|
|
1633
|
+
continue
|
|
1634
|
+
if command not in safe_commands:
|
|
1635
|
+
return False
|
|
1636
|
+
while index < len(script) and script[index] not in ";\n":
|
|
1637
|
+
index += 1
|
|
1638
|
+
return True
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def sed_arguments(arguments):
|
|
1642
|
+
"""Return (in_place, input_files, script_is_read_only) for sed arguments."""
|
|
1643
|
+
in_place = False
|
|
1644
|
+
scripts = []
|
|
1645
|
+
inputs = []
|
|
1646
|
+
script_seen = False
|
|
1647
|
+
scripts_are_literal = True
|
|
1648
|
+
index = 0
|
|
1649
|
+
while index < len(arguments):
|
|
1650
|
+
argument = arguments[index]
|
|
1651
|
+
index += 1
|
|
1652
|
+
if argument == "--":
|
|
1653
|
+
positional = arguments[index:]
|
|
1654
|
+
if not script_seen and positional:
|
|
1655
|
+
scripts.append(positional[0])
|
|
1656
|
+
positional = positional[1:]
|
|
1657
|
+
script_seen = True
|
|
1658
|
+
inputs.extend(positional)
|
|
1659
|
+
break
|
|
1660
|
+
if argument in {"-e", "--expression"}:
|
|
1661
|
+
if index >= len(arguments):
|
|
1662
|
+
return in_place, inputs, False
|
|
1663
|
+
scripts.append(arguments[index])
|
|
1664
|
+
script_seen = True
|
|
1665
|
+
index += 1
|
|
1666
|
+
continue
|
|
1667
|
+
if argument == "-f" or argument == "--file":
|
|
1668
|
+
if index >= len(arguments):
|
|
1669
|
+
return in_place, inputs, False
|
|
1670
|
+
script_seen = True
|
|
1671
|
+
scripts_are_literal = False
|
|
1672
|
+
index += 1
|
|
1673
|
+
continue
|
|
1674
|
+
if argument.startswith("--expression="):
|
|
1675
|
+
scripts.append(argument.split("=", 1)[1])
|
|
1676
|
+
script_seen = True
|
|
1677
|
+
continue
|
|
1678
|
+
if argument.startswith("--file="):
|
|
1679
|
+
script_seen = True
|
|
1680
|
+
scripts_are_literal = False
|
|
1681
|
+
continue
|
|
1682
|
+
if argument == "--in-place" or argument.startswith("--in-place="):
|
|
1683
|
+
in_place = True
|
|
1684
|
+
continue
|
|
1685
|
+
if argument.startswith("--"):
|
|
1686
|
+
if argument not in SED_SAFE_OPTIONS:
|
|
1687
|
+
scripts_are_literal = False
|
|
1688
|
+
continue
|
|
1689
|
+
if argument.startswith("-") and argument != "-":
|
|
1690
|
+
options = argument[1:]
|
|
1691
|
+
cursor = 0
|
|
1692
|
+
while cursor < len(options):
|
|
1693
|
+
option = options[cursor]
|
|
1694
|
+
cursor += 1
|
|
1695
|
+
if option == "i":
|
|
1696
|
+
in_place = True
|
|
1697
|
+
break
|
|
1698
|
+
if option == "e":
|
|
1699
|
+
if cursor < len(options):
|
|
1700
|
+
scripts.append(options[cursor:])
|
|
1701
|
+
script_seen = True
|
|
1702
|
+
elif index < len(arguments):
|
|
1703
|
+
scripts.append(arguments[index])
|
|
1704
|
+
script_seen = True
|
|
1705
|
+
index += 1
|
|
1706
|
+
else:
|
|
1707
|
+
return in_place, inputs, False
|
|
1708
|
+
break
|
|
1709
|
+
if option not in "nErsu":
|
|
1710
|
+
scripts_are_literal = False
|
|
1711
|
+
continue
|
|
1712
|
+
if not script_seen:
|
|
1713
|
+
scripts.append(argument)
|
|
1714
|
+
script_seen = True
|
|
1715
|
+
else:
|
|
1716
|
+
inputs.append(argument)
|
|
1717
|
+
if not scripts or not scripts_are_literal:
|
|
1718
|
+
return in_place, inputs, False
|
|
1719
|
+
return in_place, inputs, all(sed_script_is_read_only(script) for script in scripts)
|
|
1720
|
+
|
|
1721
|
+
|
|
1289
1722
|
def shell_write_targets(tokens, working_directories, assignments=None):
|
|
1290
|
-
"""Yield (target, directories) for every path a shell command may write."""
|
|
1291
|
-
|
|
1292
|
-
|
|
1723
|
+
"""Yield (target, directories, variables) for every path a shell command may write."""
|
|
1724
|
+
for segment, directories, segment_assignments, _ in shell_segment_contexts(
|
|
1725
|
+
tokens, working_directories, assignments
|
|
1726
|
+
):
|
|
1293
1727
|
for index, token in enumerate(segment[:-1]):
|
|
1294
1728
|
if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
|
|
1295
1729
|
continue
|
|
1296
1730
|
target = segment[index + 1]
|
|
1297
1731
|
if token.endswith("&") and (target.isdigit() or target == "-"):
|
|
1298
1732
|
continue
|
|
1299
|
-
yield target, directories
|
|
1733
|
+
yield target, directories, segment_assignments
|
|
1300
1734
|
wrapped = wrapped_command_tokens(segment)
|
|
1301
1735
|
if wrapped is not None:
|
|
1302
|
-
yield from shell_write_targets(wrapped, directories,
|
|
1736
|
+
yield from shell_write_targets(wrapped, directories, segment_assignments)
|
|
1303
1737
|
continue
|
|
1304
1738
|
invocation = command_invocation(segment)
|
|
1305
1739
|
if invocation is None:
|
|
1306
1740
|
continue
|
|
1307
1741
|
command, arguments = invocation
|
|
1742
|
+
command = command.removesuffix(".exe")
|
|
1743
|
+
if command == "sed":
|
|
1744
|
+
in_place, inputs, _ = sed_arguments(arguments)
|
|
1745
|
+
if in_place:
|
|
1746
|
+
for target in inputs:
|
|
1747
|
+
yield target, directories, segment_assignments
|
|
1748
|
+
continue
|
|
1308
1749
|
in_place = command in IN_PLACE_EDITORS and (
|
|
1309
1750
|
has_short_option(arguments, "i")
|
|
1310
1751
|
or any(argument.startswith("--in-place") for argument in arguments)
|
|
1311
1752
|
)
|
|
1312
|
-
if command
|
|
1313
|
-
for destination in copy_destinations(arguments, directories,
|
|
1314
|
-
yield destination, directories
|
|
1753
|
+
if command == "cp":
|
|
1754
|
+
for destination in copy_destinations(arguments, directories, segment_assignments):
|
|
1755
|
+
yield destination, directories, segment_assignments
|
|
1315
1756
|
continue
|
|
1316
|
-
if command
|
|
1757
|
+
if command in SHELL_WRITE_COMMANDS or in_place:
|
|
1317
1758
|
for argument in arguments:
|
|
1318
1759
|
if argument and not argument.startswith("-"):
|
|
1319
|
-
yield argument, directories
|
|
1760
|
+
yield argument, directories, segment_assignments
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
def shell_write_needs_build_phase_gate(targets):
|
|
1764
|
+
"""Phase-gate shell product writes only on bound milestone branches in-tree."""
|
|
1765
|
+
repo = repository_root()
|
|
1766
|
+
branch = subprocess.run(
|
|
1767
|
+
["git", "-C", str(repo), "branch", "--show-current"],
|
|
1768
|
+
capture_output=True,
|
|
1769
|
+
encoding="utf-8",
|
|
1770
|
+
errors="replace",
|
|
1771
|
+
)
|
|
1772
|
+
if branch.returncode or not STATUS_BRANCH.fullmatch(branch.stdout.strip()):
|
|
1773
|
+
return False
|
|
1774
|
+
for path, working_directories in targets:
|
|
1775
|
+
for target in dict.fromkeys(target_paths(path, working_directories, repo)):
|
|
1776
|
+
directory = (
|
|
1777
|
+
target
|
|
1778
|
+
if target.is_dir() and not is_link_like(target)
|
|
1779
|
+
else target.parent
|
|
1780
|
+
)
|
|
1781
|
+
while not directory.exists() and directory != directory.parent:
|
|
1782
|
+
directory = directory.parent
|
|
1783
|
+
root = subprocess.run(
|
|
1784
|
+
["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
|
|
1785
|
+
capture_output=True,
|
|
1786
|
+
encoding="utf-8",
|
|
1787
|
+
errors="replace",
|
|
1788
|
+
)
|
|
1789
|
+
if root.returncode != 0 or Path(root.stdout.strip()) != repo:
|
|
1790
|
+
return False
|
|
1791
|
+
return True
|
|
1320
1792
|
|
|
1321
1793
|
|
|
1322
1794
|
def protected_shell_write_reason(tokens, working_directories):
|
|
1323
|
-
assignments = shell_assignment_values(tokens)
|
|
1324
1795
|
targets = []
|
|
1325
|
-
for target, directories in shell_write_targets(tokens, working_directories):
|
|
1796
|
+
for target, directories, assignments in shell_write_targets(tokens, working_directories):
|
|
1326
1797
|
expanded = expand_environment_parameters(target, assignments)
|
|
1327
1798
|
if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
|
|
1328
1799
|
expanded
|
|
@@ -1342,44 +1813,171 @@ def protected_shell_write_reason(tokens, working_directories):
|
|
|
1342
1813
|
for target, kind in classified[2]:
|
|
1343
1814
|
if kind == "protected":
|
|
1344
1815
|
return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
|
|
1816
|
+
if shell_write_needs_build_phase_gate(targets):
|
|
1817
|
+
return pipeline_product_phase_reason(classified)
|
|
1345
1818
|
return None
|
|
1346
1819
|
|
|
1347
1820
|
|
|
1348
|
-
def environment_parameter_value(match, assignments):
|
|
1821
|
+
def environment_parameter_value(match, assignments, tracked_only=False):
|
|
1349
1822
|
name = match.group(1) or match.group(2)
|
|
1350
1823
|
if name.startswith(SUBSTITUTION_PLACEHOLDER):
|
|
1351
1824
|
return match.group(0) # command substitution output is never resolved
|
|
1352
|
-
|
|
1825
|
+
if name in assignments:
|
|
1826
|
+
return assignments[name]
|
|
1827
|
+
if tracked_only:
|
|
1828
|
+
return match.group(0)
|
|
1829
|
+
return os.environ.get(name, match.group(0))
|
|
1353
1830
|
|
|
1354
1831
|
|
|
1355
|
-
def expand_environment_parameters(command, assignments=None):
|
|
1832
|
+
def expand_environment_parameters(command, assignments=None, tracked_only=False):
|
|
1356
1833
|
values = assignments or {}
|
|
1357
1834
|
|
|
1358
1835
|
def substitute(match):
|
|
1359
|
-
return environment_parameter_value(match, values)
|
|
1836
|
+
return environment_parameter_value(match, values, tracked_only)
|
|
1360
1837
|
|
|
1361
1838
|
expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
|
|
1362
1839
|
return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
|
|
1363
1840
|
|
|
1364
1841
|
|
|
1365
|
-
def
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1842
|
+
def shell_segment_flow(tokens):
|
|
1843
|
+
"""Return whether each segment is conditional or runs in a subshell."""
|
|
1844
|
+
flows = []
|
|
1845
|
+
segment = []
|
|
1846
|
+
conditional = False
|
|
1847
|
+
subshell_depth = 0
|
|
1848
|
+
control_depth = 0
|
|
1849
|
+
separators = set("|;&()\n\r")
|
|
1850
|
+
for token in tokens:
|
|
1851
|
+
if token and set(token) <= separators:
|
|
1852
|
+
if segment:
|
|
1853
|
+
flows.append(
|
|
1854
|
+
(conditional or control_depth > 0, subshell_depth > 0 or token in {"|", "&"})
|
|
1855
|
+
)
|
|
1856
|
+
segment = []
|
|
1857
|
+
if token == "(":
|
|
1858
|
+
subshell_depth += 1
|
|
1859
|
+
conditional = True
|
|
1860
|
+
elif token == ")":
|
|
1861
|
+
subshell_depth = max(0, subshell_depth - 1)
|
|
1862
|
+
conditional = False
|
|
1863
|
+
elif token in {"&&", "||", "|", "&"}:
|
|
1864
|
+
conditional = True
|
|
1865
|
+
else:
|
|
1866
|
+
conditional = False
|
|
1867
|
+
continue
|
|
1868
|
+
word = token.casefold()
|
|
1869
|
+
if not segment and word in SHELL_CONTROL_WORDS:
|
|
1870
|
+
if word in {"if", "while", "until", "for", "select", "case", "coproc"}:
|
|
1871
|
+
control_depth += 1
|
|
1872
|
+
elif word in {"fi", "done", "esac"}:
|
|
1873
|
+
control_depth = max(0, control_depth - 1)
|
|
1874
|
+
if word in {"then", "else", "elif", "do", "in"}:
|
|
1875
|
+
conditional = True
|
|
1876
|
+
continue
|
|
1877
|
+
segment.append(token)
|
|
1878
|
+
if segment:
|
|
1879
|
+
flows.append((conditional or control_depth > 0, subshell_depth > 0))
|
|
1880
|
+
return flows
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def shell_assignment_changes(segment, values):
|
|
1884
|
+
"""Return variables changed by a persistent assignment or variable builtin."""
|
|
1885
|
+
index = 0
|
|
1886
|
+
prefix = []
|
|
1887
|
+
while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
|
|
1888
|
+
prefix.append(segment[index])
|
|
1889
|
+
index += 1
|
|
1890
|
+
executable = segment[index].casefold() if index < len(segment) else None
|
|
1891
|
+
updates = {}
|
|
1892
|
+
removes = set()
|
|
1893
|
+
if executable is None:
|
|
1894
|
+
assignments = prefix
|
|
1895
|
+
elif executable in {"export", "declare", "typeset"}:
|
|
1896
|
+
assignments = [*prefix, *segment[index + 1 :]]
|
|
1897
|
+
else:
|
|
1898
|
+
assignments = []
|
|
1899
|
+
for assignment in assignments:
|
|
1900
|
+
if SHELL_ASSIGNMENT_PATTERN.match(assignment):
|
|
1901
|
+
name, value = assignment.split("=", 1)
|
|
1902
|
+
updates[name] = expand_environment_parameters(value, {**values, **updates})
|
|
1903
|
+
if executable == "unset":
|
|
1904
|
+
removes = {
|
|
1905
|
+
name for name in segment[index + 1 :] if not name.startswith("-")
|
|
1906
|
+
}
|
|
1907
|
+
return updates, removes
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def shell_assignment_contexts(tokens, initial=None, working_directories=None):
|
|
1911
|
+
"""Return the persistent shell variables visible before each segment."""
|
|
1912
|
+
values = dict(initial or {})
|
|
1913
|
+
directories = list(working_directories or [os.getcwd()])
|
|
1914
|
+
if "PWD" not in values:
|
|
1915
|
+
values["PWD"] = (
|
|
1916
|
+
os.path.abspath(native_path_text(directories[0]))
|
|
1917
|
+
if len(directories) == 1 else "$PWD"
|
|
1918
|
+
)
|
|
1919
|
+
contexts = []
|
|
1920
|
+
segments = list(command_segments(tokens))
|
|
1921
|
+
flows = shell_segment_flow(tokens)
|
|
1922
|
+
if len(segments) != len(flows):
|
|
1923
|
+
raise ValueError("shell command segments cannot be aligned")
|
|
1924
|
+
for segment, (conditional, subshell) in zip(segments, flows):
|
|
1925
|
+
contexts.append(dict(values))
|
|
1926
|
+
updates, removes = shell_assignment_changes(segment, values)
|
|
1927
|
+
if subshell:
|
|
1928
|
+
continue
|
|
1929
|
+
for name in removes:
|
|
1930
|
+
if conditional:
|
|
1931
|
+
values[name] = f"${name}"
|
|
1932
|
+
else:
|
|
1933
|
+
# An unset variable expands to empty, never its inherited value.
|
|
1934
|
+
values[name] = ""
|
|
1935
|
+
for name, value in updates.items():
|
|
1936
|
+
values[name] = f"${name}" if conditional else value
|
|
1937
|
+
invocation = command_invocation(segment)
|
|
1938
|
+
if invocation is not None and invocation[0] in DIRECTORY_CHANGE_COMMANDS:
|
|
1939
|
+
# The same tracked expansion as segment_directories.
|
|
1940
|
+
target = expand_environment_parameters(
|
|
1941
|
+
directory_change_target(invocation[1]),
|
|
1942
|
+
{
|
|
1943
|
+
name: value
|
|
1944
|
+
for name, value in contexts[-1].items()
|
|
1945
|
+
if name not in SHELL_DIRECTORY_VARIABLES
|
|
1946
|
+
},
|
|
1947
|
+
tracked_only=True,
|
|
1948
|
+
)
|
|
1949
|
+
directories = (
|
|
1950
|
+
[target] if is_absolute_path(target)
|
|
1951
|
+
else [f"{directory}/{target}" for directory in directories]
|
|
1952
|
+
)
|
|
1953
|
+
values["OLDPWD"] = values.get("PWD", "$PWD")
|
|
1954
|
+
values["PWD"] = (
|
|
1955
|
+
os.path.abspath(native_path_text(directories[0]))
|
|
1956
|
+
if not conditional and len(directories) == 1 else "$PWD"
|
|
1957
|
+
)
|
|
1958
|
+
if conditional:
|
|
1959
|
+
values["OLDPWD"] = "$OLDPWD"
|
|
1960
|
+
return contexts, values
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def shell_segment_contexts(tokens, working_directories, initial=None):
|
|
1964
|
+
"""Yield each segment with its working directory and prior shell variables."""
|
|
1965
|
+
contexts, _ = shell_assignment_contexts(tokens, initial, working_directories)
|
|
1966
|
+
segments = list(segment_directories(tokens, working_directories, contexts))
|
|
1967
|
+
if len(segments) != len(contexts):
|
|
1968
|
+
raise ValueError("shell command segments cannot be aligned")
|
|
1969
|
+
for (segment, directories), assignments in zip(segments, contexts):
|
|
1970
|
+
prefix = {}
|
|
1369
1971
|
for token in segment:
|
|
1370
1972
|
if not SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
1371
|
-
if variable_command:
|
|
1372
|
-
continue
|
|
1373
1973
|
break
|
|
1374
1974
|
name, value = token.split("=", 1)
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
values[name] = expand_environment_parameters(value, values)
|
|
1378
|
-
return values
|
|
1975
|
+
prefix[name] = expand_environment_parameters(value, assignments)
|
|
1976
|
+
yield segment, directories, assignments, prefix
|
|
1379
1977
|
|
|
1380
1978
|
|
|
1381
|
-
def unresolved_archive_expansion(command,
|
|
1382
|
-
expanded = expand_environment_parameters(command,
|
|
1979
|
+
def unresolved_archive_expansion(command, assignments=None):
|
|
1980
|
+
expanded = expand_environment_parameters(command, assignments)
|
|
1383
1981
|
return ARCHIVE_REFERENCE.search(expanded) is not None
|
|
1384
1982
|
|
|
1385
1983
|
|
|
@@ -1408,32 +2006,194 @@ def substitution_end(command, start):
|
|
|
1408
2006
|
raise ValueError("command substitution $( is not closed")
|
|
1409
2007
|
|
|
1410
2008
|
|
|
2009
|
+
def heredoc_requests(line):
|
|
2010
|
+
"""Find literal here-document delimiters, excluding here-strings and quotes."""
|
|
2011
|
+
requests = []
|
|
2012
|
+
quote = None
|
|
2013
|
+
index = 0
|
|
2014
|
+
while index < len(line):
|
|
2015
|
+
character = line[index]
|
|
2016
|
+
if quote:
|
|
2017
|
+
if character == "\\" and quote == '"':
|
|
2018
|
+
index += 2
|
|
2019
|
+
continue
|
|
2020
|
+
if character == quote:
|
|
2021
|
+
quote = None
|
|
2022
|
+
index += 1
|
|
2023
|
+
continue
|
|
2024
|
+
if character == "\\":
|
|
2025
|
+
index += 2
|
|
2026
|
+
continue
|
|
2027
|
+
if character == "#" and (
|
|
2028
|
+
index == 0
|
|
2029
|
+
or line[index - 1].isspace()
|
|
2030
|
+
or line[index - 1] in ";|&()<>"
|
|
2031
|
+
):
|
|
2032
|
+
break
|
|
2033
|
+
if character in "'\"":
|
|
2034
|
+
quote = character
|
|
2035
|
+
index += 1
|
|
2036
|
+
continue
|
|
2037
|
+
if line.startswith("$(", index):
|
|
2038
|
+
index = substitution_end(line, index + 2) + 1
|
|
2039
|
+
continue
|
|
2040
|
+
if character == "`":
|
|
2041
|
+
end = index + 1
|
|
2042
|
+
while end < len(line) and line[end] != "`":
|
|
2043
|
+
end += 2 if line[end] == "\\" else 1
|
|
2044
|
+
index = min(end + 1, len(line))
|
|
2045
|
+
continue
|
|
2046
|
+
if line.startswith("<<<", index):
|
|
2047
|
+
index += 3
|
|
2048
|
+
continue
|
|
2049
|
+
if not line.startswith("<<", index):
|
|
2050
|
+
index += 1
|
|
2051
|
+
continue
|
|
2052
|
+
cursor = index + 2
|
|
2053
|
+
strip_tabs = cursor < len(line) and line[cursor] == "-"
|
|
2054
|
+
if strip_tabs:
|
|
2055
|
+
cursor += 1
|
|
2056
|
+
while cursor < len(line) and line[cursor] in " \t":
|
|
2057
|
+
cursor += 1
|
|
2058
|
+
delimiter = []
|
|
2059
|
+
delimiter_quote = None
|
|
2060
|
+
quoted = False
|
|
2061
|
+
while cursor < len(line):
|
|
2062
|
+
value = line[cursor]
|
|
2063
|
+
if delimiter_quote:
|
|
2064
|
+
if value == delimiter_quote:
|
|
2065
|
+
delimiter_quote = None
|
|
2066
|
+
else:
|
|
2067
|
+
delimiter.append(value)
|
|
2068
|
+
cursor += 1
|
|
2069
|
+
continue
|
|
2070
|
+
if value in "'\"":
|
|
2071
|
+
delimiter_quote = value
|
|
2072
|
+
quoted = True
|
|
2073
|
+
cursor += 1
|
|
2074
|
+
continue
|
|
2075
|
+
if value == "\\" and cursor + 1 < len(line):
|
|
2076
|
+
quoted = True
|
|
2077
|
+
delimiter.append(line[cursor + 1])
|
|
2078
|
+
cursor += 2
|
|
2079
|
+
continue
|
|
2080
|
+
if value.isspace() or value in ";|&()<>":
|
|
2081
|
+
break
|
|
2082
|
+
delimiter.append(value)
|
|
2083
|
+
cursor += 1
|
|
2084
|
+
if not delimiter:
|
|
2085
|
+
raise ValueError("here-document delimiter cannot be validated")
|
|
2086
|
+
requests.append(("".join(delimiter), strip_tabs, quoted))
|
|
2087
|
+
index = cursor
|
|
2088
|
+
return requests
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def heredoc_modes(command):
|
|
2092
|
+
"""Mark here-document bodies as data or shell-expanded input."""
|
|
2093
|
+
lines = command.splitlines(keepends=True)
|
|
2094
|
+
modes = ["shell"] * len(command)
|
|
2095
|
+
offsets = []
|
|
2096
|
+
offset = 0
|
|
2097
|
+
for line in lines:
|
|
2098
|
+
offsets.append(offset)
|
|
2099
|
+
offset += len(line)
|
|
2100
|
+
line_index = 0
|
|
2101
|
+
while line_index < len(lines):
|
|
2102
|
+
requests = heredoc_requests(lines[line_index].rstrip("\r\n"))
|
|
2103
|
+
line_index += 1
|
|
2104
|
+
for delimiter, strip_tabs, quoted in requests:
|
|
2105
|
+
found = False
|
|
2106
|
+
while line_index < len(lines):
|
|
2107
|
+
line = lines[line_index]
|
|
2108
|
+
content = line.rstrip("\r\n")
|
|
2109
|
+
if strip_tabs:
|
|
2110
|
+
content = content.lstrip("\t")
|
|
2111
|
+
start = offsets[line_index]
|
|
2112
|
+
stop = start + len(line)
|
|
2113
|
+
mode = "literal" if quoted or content == delimiter else "heredoc"
|
|
2114
|
+
modes[start:stop] = [mode] * (stop - start)
|
|
2115
|
+
line_index += 1
|
|
2116
|
+
if content == delimiter:
|
|
2117
|
+
found = True
|
|
2118
|
+
break
|
|
2119
|
+
if not found:
|
|
2120
|
+
raise ValueError(f"here-document {delimiter} is not terminated")
|
|
2121
|
+
return modes
|
|
2122
|
+
|
|
2123
|
+
|
|
1411
2124
|
def split_command_substitutions(command):
|
|
1412
|
-
"""Replace
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
"@( expansion cannot be validated; write the list as literal arguments"
|
|
1416
|
-
)
|
|
1417
|
-
if "$(" not in command and "`" not in command:
|
|
1418
|
-
return command, []
|
|
1419
|
-
outer, inner, index = [], [], 0
|
|
2125
|
+
"""Replace executable substitutions, leaving quoted text as shell data."""
|
|
2126
|
+
modes = heredoc_modes(command)
|
|
2127
|
+
outer, inner, index, quote = [], [], 0, None
|
|
1420
2128
|
while index < len(command):
|
|
2129
|
+
mode = modes[index]
|
|
2130
|
+
character = command[index]
|
|
2131
|
+
if mode == "literal":
|
|
2132
|
+
outer.append(character)
|
|
2133
|
+
index += 1
|
|
2134
|
+
continue
|
|
2135
|
+
if mode == "heredoc":
|
|
2136
|
+
if character == "\\" and index + 1 < len(command) and command[index + 1] in "\\$`":
|
|
2137
|
+
outer.append(command[index : index + 2])
|
|
2138
|
+
index += 2
|
|
2139
|
+
continue
|
|
2140
|
+
if command.startswith("$(", index):
|
|
2141
|
+
end = substitution_end(command, index + 2)
|
|
2142
|
+
inner.append(command[index + 2 : end])
|
|
2143
|
+
elif character == "`":
|
|
2144
|
+
end = index + 1
|
|
2145
|
+
while end < len(command) and command[end] != "`":
|
|
2146
|
+
end += 2 if command[end] == "\\" else 1
|
|
2147
|
+
if end >= len(command):
|
|
2148
|
+
raise ValueError("backtick command substitution is not closed")
|
|
2149
|
+
inner.append(command[index + 1 : end])
|
|
2150
|
+
elif command.startswith("@(", index):
|
|
2151
|
+
raise ValueError(
|
|
2152
|
+
"@( expansion cannot be validated; write the list as literal arguments"
|
|
2153
|
+
)
|
|
2154
|
+
else:
|
|
2155
|
+
outer.append(character)
|
|
2156
|
+
index += 1
|
|
2157
|
+
continue
|
|
2158
|
+
outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
|
|
2159
|
+
index = end + 1
|
|
2160
|
+
continue
|
|
2161
|
+
if quote == "'":
|
|
2162
|
+
outer.append(character)
|
|
2163
|
+
if character == "'":
|
|
2164
|
+
quote = None
|
|
2165
|
+
index += 1
|
|
2166
|
+
continue
|
|
2167
|
+
if character == "\\":
|
|
2168
|
+
outer.append(command[index : index + 2])
|
|
2169
|
+
index += 2
|
|
2170
|
+
continue
|
|
2171
|
+
if quote is None and character == "'":
|
|
2172
|
+
quote = character
|
|
2173
|
+
outer.append(character)
|
|
2174
|
+
index += 1
|
|
2175
|
+
continue
|
|
2176
|
+
if character == '"':
|
|
2177
|
+
quote = None if quote == '"' else '"'
|
|
2178
|
+
outer.append(character)
|
|
2179
|
+
index += 1
|
|
2180
|
+
continue
|
|
1421
2181
|
if command.startswith("$(", index):
|
|
1422
2182
|
end = substitution_end(command, index + 2)
|
|
1423
2183
|
inner.append(command[index + 2 : end])
|
|
1424
|
-
elif
|
|
2184
|
+
elif character == "`":
|
|
1425
2185
|
end = index + 1
|
|
1426
2186
|
while end < len(command) and command[end] != "`":
|
|
1427
2187
|
end += 2 if command[end] == "\\" else 1
|
|
1428
2188
|
if end >= len(command):
|
|
1429
2189
|
raise ValueError("backtick command substitution is not closed")
|
|
1430
2190
|
inner.append(command[index + 1 : end])
|
|
1431
|
-
elif command
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2191
|
+
elif command.startswith("@(", index):
|
|
2192
|
+
raise ValueError(
|
|
2193
|
+
"@( expansion cannot be validated; write the list as literal arguments"
|
|
2194
|
+
)
|
|
1435
2195
|
else:
|
|
1436
|
-
outer.append(
|
|
2196
|
+
outer.append(character)
|
|
1437
2197
|
index += 1
|
|
1438
2198
|
continue
|
|
1439
2199
|
outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
|
|
@@ -1454,11 +2214,8 @@ def strip_heredoc_bodies(command):
|
|
|
1454
2214
|
line = lines[index]
|
|
1455
2215
|
kept.append(line)
|
|
1456
2216
|
index += 1
|
|
1457
|
-
for
|
|
1458
|
-
|
|
1459
|
-
match.group("single") or match.group("double") or match.group("bare")
|
|
1460
|
-
)
|
|
1461
|
-
strip = "\t\r" if match.group("dash") else "\r"
|
|
2217
|
+
for delimiter, strip_tabs, _ in heredoc_requests(line):
|
|
2218
|
+
strip = "\t\r" if strip_tabs else "\r"
|
|
1462
2219
|
while index < len(lines) and lines[index].strip(strip) != delimiter:
|
|
1463
2220
|
index += 1
|
|
1464
2221
|
if index >= len(lines):
|
|
@@ -1497,30 +2254,323 @@ def decode_patch_path(raw_path, strip_prefix):
|
|
|
1497
2254
|
return path
|
|
1498
2255
|
|
|
1499
2256
|
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
lexer.commenters = ""
|
|
1506
|
-
try:
|
|
1507
|
-
tokens = list(lexer)
|
|
1508
|
-
except ValueError as error:
|
|
1509
|
-
raise ValueError(
|
|
1510
|
-
f"shell command cannot be tokenized ({error}); balance the quotes "
|
|
1511
|
-
"or write the content with an editor tool"
|
|
1512
|
-
) from None
|
|
1513
|
-
if not tokens:
|
|
1514
|
-
raise ValueError("shell command is empty")
|
|
1515
|
-
return tokens
|
|
2257
|
+
PARAMETER_MARKERS = frozenset("$%!")
|
|
2258
|
+
SHELL_DESCRIPTOR_WORD = re.compile(r"[0-9]{2,}|\{\w+\}", re.ASCII)
|
|
2259
|
+
SHELL_OPERATOR_PARTS = re.compile(
|
|
2260
|
+
r"[<>]\(|&>>?|<<<|<<|>>|>&|<&|<>|>\||[<>]|&&|\|\||\|&|[\s\S]"
|
|
2261
|
+
)
|
|
1516
2262
|
|
|
1517
2263
|
|
|
1518
|
-
|
|
1519
|
-
"""
|
|
1520
|
-
|
|
1521
|
-
for
|
|
1522
|
-
|
|
1523
|
-
|
|
2264
|
+
class ShellToken(str):
|
|
2265
|
+
"""A token that keeps, per parameter marker character, whether double quotes enclosed it.
|
|
2266
|
+
|
|
2267
|
+
`redirection` is true for an unquoted redirection operator and for one
|
|
2268
|
+
ASCII file descriptor digit written against one (the 2 of 2>file, not of
|
|
2269
|
+
2 >file). zsh passes a longer or non-ASCII digit word on as an argument.
|
|
2270
|
+
`descriptor` is true for a longer digit word or a {name} word written
|
|
2271
|
+
against a redirection operator: bash reads it as a file descriptor, zsh
|
|
2272
|
+
as an argument.
|
|
2273
|
+
`body` holds the tokens of a process substitution on its operator token,
|
|
2274
|
+
once destructive_git_reason has folded them.
|
|
2275
|
+
`literal` is "bare" for a word written with no quote or escape, "quoted"
|
|
2276
|
+
for a word inside one pair of plain quotes, "mixed" for plain quoted and
|
|
2277
|
+
unquoted parts where no unquoted part can expand, and None when the shell
|
|
2278
|
+
may build the word in another way (escapes, an expanding unquoted part, a
|
|
2279
|
+
line continuation in the word or one that joins it to the next word).
|
|
2280
|
+
"""
|
|
2281
|
+
|
|
2282
|
+
double_quoted = ()
|
|
2283
|
+
redirection = False
|
|
2284
|
+
descriptor = False
|
|
2285
|
+
literal = None
|
|
2286
|
+
body = None
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def double_quoted_markers(command):
|
|
2290
|
+
"""Return, per parameter marker in order, whether plain double quotes enclose it.
|
|
2291
|
+
|
|
2292
|
+
None means the command uses quoting outside the plain constructs this scan
|
|
2293
|
+
models (plain single quotes, plain double quotes, unquoted text), so no
|
|
2294
|
+
marker in it can be proven quoted.
|
|
2295
|
+
"""
|
|
2296
|
+
flags = []
|
|
2297
|
+
quote = None
|
|
2298
|
+
comment = False
|
|
2299
|
+
braces = 0
|
|
2300
|
+
previous = " "
|
|
2301
|
+
index = 0
|
|
2302
|
+
while index < len(command):
|
|
2303
|
+
character = command[index]
|
|
2304
|
+
following = command[index + 1 : index + 2]
|
|
2305
|
+
if comment:
|
|
2306
|
+
if character in "\r\n":
|
|
2307
|
+
comment = False
|
|
2308
|
+
elif character in "'\"\\`":
|
|
2309
|
+
return None
|
|
2310
|
+
elif quote == "'":
|
|
2311
|
+
if character == "'":
|
|
2312
|
+
quote = None
|
|
2313
|
+
elif character == "\\":
|
|
2314
|
+
if following and following in PARAMETER_MARKERS:
|
|
2315
|
+
# The shell executes the escaped marker as literal text; the
|
|
2316
|
+
# token keeps the bare marker, so the pair is never provable.
|
|
2317
|
+
flags.append(False)
|
|
2318
|
+
previous = character
|
|
2319
|
+
index += 2
|
|
2320
|
+
continue
|
|
2321
|
+
elif character == "`" or (character == "$" and following == "("):
|
|
2322
|
+
return None
|
|
2323
|
+
elif character == "$" and following == "{":
|
|
2324
|
+
braces += 1
|
|
2325
|
+
elif character == "}" and braces:
|
|
2326
|
+
braces -= 1
|
|
2327
|
+
elif character in "'\"" and braces:
|
|
2328
|
+
return None
|
|
2329
|
+
elif character == '"':
|
|
2330
|
+
quote = None if quote else '"'
|
|
2331
|
+
elif quote is None:
|
|
2332
|
+
if character == "'":
|
|
2333
|
+
quote = "'"
|
|
2334
|
+
elif character == "$" and following and following in "'\"":
|
|
2335
|
+
return None
|
|
2336
|
+
elif character == "<" and following == "<":
|
|
2337
|
+
return None
|
|
2338
|
+
elif character == "#" and previous in " \t\r\n;&|()<>":
|
|
2339
|
+
comment = True
|
|
2340
|
+
if character in PARAMETER_MARKERS:
|
|
2341
|
+
flags.append(quote == '"' and not comment)
|
|
2342
|
+
previous = character
|
|
2343
|
+
index += 1
|
|
2344
|
+
return flags if quote is None and not braces else None
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def zsh_expansion_flag(text):
|
|
2348
|
+
"""Whether the text holds an unbraced zsh expansion flag ($=x, $~x, $^x, $+x).
|
|
2349
|
+
|
|
2350
|
+
These match no parameter syntax, so without this check they never enter
|
|
2351
|
+
the parameter proof at all — while zsh word-splits them exactly like the
|
|
2352
|
+
forms the proof denies. Any other unmatched $ is inert literal text.
|
|
2353
|
+
"""
|
|
2354
|
+
return ZSH_EXPANSION_FLAG_SYNTAX.search(text)
|
|
2355
|
+
|
|
2356
|
+
|
|
2357
|
+
def parameters_double_quoted(token):
|
|
2358
|
+
"""Whether every parameter in the token expands inside double quotes to one word.
|
|
2359
|
+
|
|
2360
|
+
Only plain $name and ${name} qualify: flags, operators, subscripts,
|
|
2361
|
+
positional and special parameters can split even inside double quotes.
|
|
2362
|
+
A backslash-escaped marker stays one flag short of proven: the shell
|
|
2363
|
+
executes the literal text while the guard's token still shows the bare
|
|
2364
|
+
marker, so nothing about its expansion is provable.
|
|
2365
|
+
"""
|
|
2366
|
+
flags = getattr(token, "double_quoted", ())
|
|
2367
|
+
positions = [
|
|
2368
|
+
index for index, character in enumerate(token) if character in PARAMETER_MARKERS
|
|
2369
|
+
]
|
|
2370
|
+
if len(flags) != len(positions):
|
|
2371
|
+
return False
|
|
2372
|
+
quoted = dict(zip(positions, flags))
|
|
2373
|
+
plain = {
|
|
2374
|
+
match.start(): match.end() for match in NAMED_SHELL_PARAMETER_SYNTAX.finditer(token)
|
|
2375
|
+
}
|
|
2376
|
+
if any(
|
|
2377
|
+
character == "$" and index not in plain for index, character in enumerate(token)
|
|
2378
|
+
) or any(token[end : end + 1] == "[" for end in plain.values()):
|
|
2379
|
+
return False
|
|
2380
|
+
return all(
|
|
2381
|
+
quoted[match.start()] and quoted.get(match.end() - 1, True)
|
|
2382
|
+
for syntax in (SHELL_PARAMETER_SYNTAX, CMD_PARAMETER_SYNTAX)
|
|
2383
|
+
for match in syntax.finditer(token)
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2386
|
+
|
|
2387
|
+
def shell_tokens(command, direct=True):
|
|
2388
|
+
"""Tokenize a command; `direct` is False for a string that a second shell parses.
|
|
2389
|
+
|
|
2390
|
+
Only a directly parsed command keeps quote flags: an outer shell expands
|
|
2391
|
+
parameters before the inner parse, so inner quotes prove nothing.
|
|
2392
|
+
"""
|
|
2393
|
+
breaks = set()
|
|
2394
|
+
if LINE_CONTINUATION.search(command):
|
|
2395
|
+
mark = next(
|
|
2396
|
+
character for character in map(chr, range(0xE000, 0xF900))
|
|
2397
|
+
if character not in command
|
|
2398
|
+
)
|
|
2399
|
+
marked = strip_heredoc_bodies(
|
|
2400
|
+
LINE_CONTINUATION.sub(lambda match: match.group(1) + mark, command)
|
|
2401
|
+
)
|
|
2402
|
+
command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
|
|
2403
|
+
breaks = (
|
|
2404
|
+
{index for index, character in enumerate(marked) if character == mark}
|
|
2405
|
+
if marked.replace(mark, " ") == command
|
|
2406
|
+
else None
|
|
2407
|
+
)
|
|
2408
|
+
else:
|
|
2409
|
+
command = strip_heredoc_bodies(command)
|
|
2410
|
+
joins = set()
|
|
2411
|
+
for index in breaks or ():
|
|
2412
|
+
left, right = index - 1, index + 1
|
|
2413
|
+
while left >= 0 and marked[left] == mark:
|
|
2414
|
+
left -= 1
|
|
2415
|
+
while right < len(marked) and marked[right] == mark:
|
|
2416
|
+
right += 1
|
|
2417
|
+
if left >= 0 and marked[left].strip() and marked[right:right + 1].strip():
|
|
2418
|
+
joins.add(index)
|
|
2419
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
|
|
2420
|
+
lexer.whitespace = " \t"
|
|
2421
|
+
lexer.whitespace_split = True
|
|
2422
|
+
lexer.commenters = ""
|
|
2423
|
+
tokens = []
|
|
2424
|
+
previous_end = 0
|
|
2425
|
+
try:
|
|
2426
|
+
for token in lexer:
|
|
2427
|
+
end = lexer.instream.tell() - len(lexer._pushback_chars)
|
|
2428
|
+
if command[end - 1:end] in (" ", "\t"):
|
|
2429
|
+
end -= 1
|
|
2430
|
+
word_start = previous_end
|
|
2431
|
+
while word_start < end and command[word_start] in " \t":
|
|
2432
|
+
word_start += 1
|
|
2433
|
+
previous_end = end
|
|
2434
|
+
word = command[word_start:end]
|
|
2435
|
+
literal = None
|
|
2436
|
+
if (
|
|
2437
|
+
breaks is not None
|
|
2438
|
+
and "\\" not in word
|
|
2439
|
+
and not ANSI_QUOTE_START.search(word)
|
|
2440
|
+
and breaks.isdisjoint(range(word_start, end))
|
|
2441
|
+
and end not in joins
|
|
2442
|
+
and word_start - 1 not in joins
|
|
2443
|
+
and PLAIN_QUOTED_SEGMENT.sub(lambda match: match.group(0)[1:-1], word) == token
|
|
2444
|
+
):
|
|
2445
|
+
unquoted = PLAIN_QUOTED_SEGMENT.sub("", word)
|
|
2446
|
+
if (
|
|
2447
|
+
"'" not in unquoted
|
|
2448
|
+
and '"' not in unquoted
|
|
2449
|
+
and not GIT_CONFIG_EXPANDING_WORD.search(unquoted)
|
|
2450
|
+
):
|
|
2451
|
+
literal = "mixed"
|
|
2452
|
+
for quote in ("", "'", '"'):
|
|
2453
|
+
start = end - len(token) - 2 * len(quote)
|
|
2454
|
+
if (
|
|
2455
|
+
breaks is not None
|
|
2456
|
+
and start >= 0
|
|
2457
|
+
and command[start:end] == quote + token + quote
|
|
2458
|
+
and (start == 0 or command[start - 1] in " \t|;&()<>\n\r")
|
|
2459
|
+
and breaks.isdisjoint(range(start, end))
|
|
2460
|
+
and end not in joins
|
|
2461
|
+
and start - 1 not in joins
|
|
2462
|
+
):
|
|
2463
|
+
literal = "quoted" if quote else "bare"
|
|
2464
|
+
break
|
|
2465
|
+
start = end - len(token)
|
|
2466
|
+
before = command[start - 1:start] if start > 0 else ""
|
|
2467
|
+
escaped = (start - len(command[:start].rstrip("\\"))) % 2 == 1
|
|
2468
|
+
redirection = command[start:end] == token and (
|
|
2469
|
+
(
|
|
2470
|
+
set(token) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
2471
|
+
and ("<" in token or ">" in token)
|
|
2472
|
+
and not escaped
|
|
2473
|
+
)
|
|
2474
|
+
or (
|
|
2475
|
+
len(token) == 1
|
|
2476
|
+
and token in "0123456789"
|
|
2477
|
+
and command[end:end + 1] in ("<", ">")
|
|
2478
|
+
and (not before or before in " \t|;&()<>\n\r")
|
|
2479
|
+
)
|
|
2480
|
+
)
|
|
2481
|
+
descriptor = (
|
|
2482
|
+
command[start:end] == token
|
|
2483
|
+
and SHELL_DESCRIPTOR_WORD.fullmatch(token) is not None
|
|
2484
|
+
and command[end:end + 1] in ("<", ">")
|
|
2485
|
+
and (not before or before in " \t|;&()<>\n\r")
|
|
2486
|
+
)
|
|
2487
|
+
# The shell reads a control operator and a redirection operator
|
|
2488
|
+
# written against each other as two operators.
|
|
2489
|
+
parts = [token]
|
|
2490
|
+
if (
|
|
2491
|
+
command[start:end] == token
|
|
2492
|
+
and set(token) <= set("|;&()<>\n\r")
|
|
2493
|
+
and ("<" in token or ">" in token)
|
|
2494
|
+
and not escaped
|
|
2495
|
+
):
|
|
2496
|
+
parts = []
|
|
2497
|
+
for part in SHELL_OPERATOR_PARTS.findall(token):
|
|
2498
|
+
if "<" in part or ">" in part or not parts or (
|
|
2499
|
+
"<" in parts[-1] or ">" in parts[-1]
|
|
2500
|
+
):
|
|
2501
|
+
parts.append(part)
|
|
2502
|
+
else:
|
|
2503
|
+
parts[-1] += part
|
|
2504
|
+
for part in parts:
|
|
2505
|
+
part = ShellToken(part)
|
|
2506
|
+
part.redirection = redirection if len(parts) == 1 else (
|
|
2507
|
+
("<" in part or ">" in part)
|
|
2508
|
+
and set(part) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
2509
|
+
)
|
|
2510
|
+
part.descriptor = descriptor
|
|
2511
|
+
part.literal = literal
|
|
2512
|
+
tokens.append(part)
|
|
2513
|
+
except ValueError as error:
|
|
2514
|
+
raise ValueError(
|
|
2515
|
+
f"shell command cannot be tokenized ({error}); balance the quotes "
|
|
2516
|
+
"or write the content with an editor tool"
|
|
2517
|
+
) from None
|
|
2518
|
+
if not tokens:
|
|
2519
|
+
raise ValueError("shell command is empty")
|
|
2520
|
+
flags = double_quoted_markers(command) if direct else None
|
|
2521
|
+
if flags is None or len(flags) != sum(
|
|
2522
|
+
character in PARAMETER_MARKERS for token in tokens for character in token
|
|
2523
|
+
):
|
|
2524
|
+
return tokens
|
|
2525
|
+
marked = []
|
|
2526
|
+
for token in tokens:
|
|
2527
|
+
count = sum(character in PARAMETER_MARKERS for character in token)
|
|
2528
|
+
redirection, descriptor, literal = (
|
|
2529
|
+
token.redirection, token.descriptor, token.literal
|
|
2530
|
+
)
|
|
2531
|
+
token = ShellToken(token)
|
|
2532
|
+
token.redirection, token.descriptor, token.literal = (
|
|
2533
|
+
redirection, descriptor, literal
|
|
2534
|
+
)
|
|
2535
|
+
token.double_quoted, flags = tuple(flags[:count]), flags[count:]
|
|
2536
|
+
marked.append(token)
|
|
2537
|
+
return marked
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
def shell_parameter_quoting_uncertain(command):
|
|
2541
|
+
"""Detect literal parameters whose quoting is lost by shell_tokens."""
|
|
2542
|
+
command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
|
|
2543
|
+
# These forms expand in cmd, but are literal directory text in POSIX shells.
|
|
2544
|
+
if CMD_PARAMETER_SYNTAX.search(command):
|
|
2545
|
+
return True
|
|
2546
|
+
quote = None
|
|
2547
|
+
index = 0
|
|
2548
|
+
while index < len(command):
|
|
2549
|
+
character = command[index]
|
|
2550
|
+
if quote == "'":
|
|
2551
|
+
if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index):
|
|
2552
|
+
return True
|
|
2553
|
+
if character == "'":
|
|
2554
|
+
quote = None
|
|
2555
|
+
elif character == "\\":
|
|
2556
|
+
if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index + 1):
|
|
2557
|
+
return True
|
|
2558
|
+
index += 1
|
|
2559
|
+
elif character in "'\"":
|
|
2560
|
+
if quote is None:
|
|
2561
|
+
quote = character
|
|
2562
|
+
elif character == quote:
|
|
2563
|
+
quote = None
|
|
2564
|
+
index += 1
|
|
2565
|
+
return False
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def command_segments(tokens):
|
|
2569
|
+
"""Yield simple commands with separators and leading control words removed."""
|
|
2570
|
+
segment = []
|
|
2571
|
+
for token in tokens:
|
|
2572
|
+
if token and set(token) <= set("|;&()\n\r"):
|
|
2573
|
+
if segment:
|
|
1524
2574
|
yield segment
|
|
1525
2575
|
segment = []
|
|
1526
2576
|
elif segment or token.casefold() not in SHELL_CONTROL_WORDS:
|
|
@@ -1572,56 +2622,163 @@ def xargs_command(arguments):
|
|
|
1572
2622
|
|
|
1573
2623
|
|
|
1574
2624
|
def require_read_command(wrapper, wrapped):
|
|
1575
|
-
|
|
2625
|
+
segments = list(command_segments(wrapped))
|
|
2626
|
+
if wrapped and (
|
|
2627
|
+
not segments
|
|
2628
|
+
or not all(
|
|
2629
|
+
archive_command_is_read_only(shlex.join(segment), segment, True)
|
|
2630
|
+
for segment in segments
|
|
2631
|
+
)
|
|
2632
|
+
):
|
|
1576
2633
|
raise ValueError(
|
|
1577
2634
|
f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
|
|
1578
2635
|
f"only read commands may follow {wrapper}, or pass the paths as literals"
|
|
1579
2636
|
)
|
|
1580
2637
|
|
|
1581
2638
|
|
|
1582
|
-
def
|
|
2639
|
+
def runner_value_option(prefix, token):
|
|
2640
|
+
"""The option in a runner option word whose value is the next word, if any.
|
|
2641
|
+
|
|
2642
|
+
Raises for an option that writes, edits or enters a file or directory. A
|
|
2643
|
+
long option matches by each prefix, as getopt accepts it. A short word is
|
|
2644
|
+
read as getopt reads it: flag letters, then the first value option letter,
|
|
2645
|
+
whose value is the rest of the word or, with no rest, the next word.
|
|
2646
|
+
"""
|
|
2647
|
+
value_options = EXEC_PREFIX_VALUE_OPTIONS[prefix]
|
|
2648
|
+
file_options = EXEC_PREFIX_FILE_OPTIONS.get(prefix, frozenset())
|
|
2649
|
+
denial = ValueError(
|
|
2650
|
+
f"{prefix} {token} writes, edits or enters a file or directory that the "
|
|
2651
|
+
"guard cannot check; run the command without this option and redirect "
|
|
2652
|
+
"its output instead"
|
|
2653
|
+
)
|
|
2654
|
+
if token.startswith("--"):
|
|
2655
|
+
name = token.split("=", 1)[0]
|
|
2656
|
+
if any(option.startswith(name) for option in file_options):
|
|
2657
|
+
raise denial
|
|
2658
|
+
option = next((option for option in value_options if option.startswith(name)), None)
|
|
2659
|
+
return option if "=" not in token else None
|
|
2660
|
+
for position, letter in enumerate(token[1:], start=1):
|
|
2661
|
+
option = f"-{letter}"
|
|
2662
|
+
if option in file_options:
|
|
2663
|
+
raise denial
|
|
2664
|
+
if option in value_options:
|
|
2665
|
+
return option if position == len(token) - 1 else None
|
|
2666
|
+
if letter not in EXEC_PREFIX_FLAG_LETTERS[prefix]:
|
|
2667
|
+
return None
|
|
2668
|
+
return None
|
|
2669
|
+
|
|
2670
|
+
|
|
2671
|
+
def nonliteral_runner_option(token):
|
|
2672
|
+
literal = getattr(token, "literal", "bare")
|
|
2673
|
+
return (
|
|
2674
|
+
SHELL_PARAMETER_SYNTAX.search(token)
|
|
2675
|
+
or CMD_PARAMETER_SYNTAX.search(token)
|
|
2676
|
+
or zsh_expansion_flag(token)
|
|
2677
|
+
or literal is None
|
|
2678
|
+
or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(token))
|
|
2679
|
+
)
|
|
2680
|
+
|
|
2681
|
+
|
|
2682
|
+
def unresolved_runner_word(token):
|
|
2683
|
+
return (
|
|
2684
|
+
SHELL_PARAMETER_SYNTAX.search(token)
|
|
2685
|
+
or CMD_PARAMETER_SYNTAX.search(token)
|
|
2686
|
+
or zsh_expansion_flag(token)
|
|
2687
|
+
) and not parameters_double_quoted(token)
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
def command_invocation(segment, unwrap_runners=True):
|
|
2691
|
+
"""`unwrap_runners` is False where a command runner itself must be judged."""
|
|
1583
2692
|
index = 0
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
index += 1
|
|
1587
|
-
if index >= len(segment):
|
|
1588
|
-
return None
|
|
1589
|
-
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1590
|
-
if executable == "env":
|
|
2693
|
+
# The shell word `time` is already removed; its only option can remain.
|
|
2694
|
+
while unwrap_runners and index < len(segment) and segment[index] == "-p":
|
|
1591
2695
|
index += 1
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
2696
|
+
while True:
|
|
2697
|
+
while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
|
|
2698
|
+
validate_shell_assignment(segment[index])
|
|
2699
|
+
index += 1
|
|
2700
|
+
if index >= len(segment):
|
|
2701
|
+
return None
|
|
2702
|
+
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
2703
|
+
prefix = executable.removesuffix(".exe")
|
|
2704
|
+
if executable == "env":
|
|
2705
|
+
index += 1
|
|
2706
|
+
while index < len(segment):
|
|
2707
|
+
token = segment[index]
|
|
2708
|
+
if token == "--":
|
|
2709
|
+
index += 1
|
|
2710
|
+
break
|
|
2711
|
+
if SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
2712
|
+
validate_shell_assignment(token)
|
|
2713
|
+
index += 1
|
|
2714
|
+
continue
|
|
2715
|
+
option = token.split("=", 1)[0]
|
|
2716
|
+
if option in ENV_OPTIONS_WITHOUT_VALUES:
|
|
2717
|
+
index += 1
|
|
2718
|
+
continue
|
|
2719
|
+
if option in ENV_OPTIONS_WITH_VALUES:
|
|
2720
|
+
if option in {"-C", "--chdir"}:
|
|
2721
|
+
raise ValueError(
|
|
2722
|
+
f"env {option} changes the working directory the guard "
|
|
2723
|
+
"tracks; use cd with a literal path instead"
|
|
2724
|
+
)
|
|
2725
|
+
index += 1
|
|
2726
|
+
if "=" not in token:
|
|
2727
|
+
if index >= len(segment):
|
|
2728
|
+
raise ValueError(f"env {option} lacks a value")
|
|
2729
|
+
index += 1
|
|
2730
|
+
continue
|
|
2731
|
+
if token.startswith("-"):
|
|
2732
|
+
raise ValueError(
|
|
2733
|
+
f"env {token} cannot be validated; use env NAME=VALUE <command>"
|
|
2734
|
+
)
|
|
2735
|
+
if SHELL_PARAMETER_SYNTAX.search(token) or zsh_expansion_flag(token):
|
|
2736
|
+
raise ValueError(
|
|
2737
|
+
f"env word {token} cannot be resolved by the guard; env "
|
|
2738
|
+
"assignments must be literal NAME=VALUE words and the "
|
|
2739
|
+
"program a literal name"
|
|
2740
|
+
)
|
|
1596
2741
|
break
|
|
1597
|
-
|
|
1598
|
-
|
|
2742
|
+
elif unwrap_runners and prefix in EXEC_PREFIX_VALUE_OPTIONS:
|
|
2743
|
+
index += 1
|
|
2744
|
+
while index < len(segment):
|
|
2745
|
+
token = segment[index]
|
|
2746
|
+
if token == "--":
|
|
2747
|
+
index += 1
|
|
2748
|
+
break
|
|
2749
|
+
if not token.startswith("-") or token == "-":
|
|
2750
|
+
break
|
|
1599
2751
|
index += 1
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
2752
|
+
if nonliteral_runner_option(token):
|
|
2753
|
+
raise ValueError(
|
|
2754
|
+
f"{prefix} option {token} cannot be resolved by the guard; "
|
|
2755
|
+
f"{prefix} options must be literal words"
|
|
2756
|
+
)
|
|
2757
|
+
option = runner_value_option(prefix, token)
|
|
2758
|
+
if option is None or index >= len(segment):
|
|
2759
|
+
continue
|
|
2760
|
+
value = segment[index]
|
|
1603
2761
|
index += 1
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
2762
|
+
if prefix == "watch" and option in EXEC_PREFIX_INTERVAL_OPTIONS:
|
|
2763
|
+
if not RUNNER_DURATION.fullmatch(value):
|
|
2764
|
+
raise ValueError(
|
|
2765
|
+
f"watch interval {value} cannot be resolved by the guard; "
|
|
2766
|
+
"the interval must be a literal number"
|
|
2767
|
+
)
|
|
2768
|
+
elif unresolved_runner_word(value):
|
|
2769
|
+
raise ValueError(
|
|
2770
|
+
f"{prefix} option value {value} cannot be resolved by the "
|
|
2771
|
+
f"guard; {prefix} option values must be literal words"
|
|
2772
|
+
)
|
|
2773
|
+
if prefix == "timeout" and index < len(segment):
|
|
2774
|
+
if not RUNNER_DURATION.fullmatch(segment[index]):
|
|
1607
2775
|
raise ValueError(
|
|
1608
|
-
f"
|
|
1609
|
-
"
|
|
2776
|
+
f"timeout duration {segment[index]} cannot be resolved by the "
|
|
2777
|
+
"guard; the duration must be a literal number"
|
|
1610
2778
|
)
|
|
1611
2779
|
index += 1
|
|
1612
|
-
|
|
1613
|
-
if index >= len(segment):
|
|
1614
|
-
raise ValueError(f"env {option} lacks a value")
|
|
1615
|
-
index += 1
|
|
1616
|
-
continue
|
|
1617
|
-
if token.startswith("-"):
|
|
1618
|
-
raise ValueError(
|
|
1619
|
-
f"env {token} cannot be validated; use env NAME=VALUE <command>"
|
|
1620
|
-
)
|
|
2780
|
+
else:
|
|
1621
2781
|
break
|
|
1622
|
-
if index >= len(segment):
|
|
1623
|
-
return None
|
|
1624
|
-
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1625
2782
|
arguments = segment[index + 1 :]
|
|
1626
2783
|
if SHELL_PARAMETER_SYNTAX.search(executable):
|
|
1627
2784
|
raise ValueError(
|
|
@@ -1645,19 +2802,103 @@ def command_invocation(segment):
|
|
|
1645
2802
|
return executable, arguments
|
|
1646
2803
|
|
|
1647
2804
|
|
|
1648
|
-
def
|
|
2805
|
+
def require_resolved_git_word(word, mixed=True):
|
|
2806
|
+
"""Deny a git word the shell can turn into other or several arguments."""
|
|
2807
|
+
literal = getattr(word, "literal", "bare")
|
|
2808
|
+
if (
|
|
2809
|
+
literal is None
|
|
2810
|
+
or (literal == "mixed" and not mixed)
|
|
2811
|
+
or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(word))
|
|
2812
|
+
):
|
|
2813
|
+
raise ValueError(
|
|
2814
|
+
f"git argument {word} cannot be resolved by the guard; "
|
|
2815
|
+
"pass a literal value"
|
|
2816
|
+
)
|
|
2817
|
+
|
|
2818
|
+
|
|
2819
|
+
def git_command(segment, assignments=None):
|
|
1649
2820
|
invocation = command_invocation(segment)
|
|
1650
2821
|
if invocation is None:
|
|
1651
2822
|
return None
|
|
1652
2823
|
executable, arguments = invocation
|
|
1653
2824
|
if executable not in {"git", "git.exe"}:
|
|
1654
2825
|
return None
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
2826
|
+
resolved_arguments = []
|
|
2827
|
+
parameter_arguments = []
|
|
2828
|
+
unproven_parameters = []
|
|
2829
|
+
argument_index = 0
|
|
2830
|
+
while argument_index < len(arguments):
|
|
2831
|
+
argument = arguments[argument_index]
|
|
2832
|
+
attached_directory = argument.startswith("-C") and len(argument) > 2
|
|
2833
|
+
if argument == "-C" and argument_index + 1 < len(arguments):
|
|
2834
|
+
argument_index += 1
|
|
2835
|
+
directory = arguments[argument_index]
|
|
2836
|
+
require_resolved_git_word(directory)
|
|
2837
|
+
expanded = expand_environment_parameters(directory, assignments)
|
|
2838
|
+
if (
|
|
2839
|
+
SHELL_PARAMETER_SYNTAX.search(expanded)
|
|
2840
|
+
or CMD_PARAMETER_SYNTAX.search(expanded)
|
|
2841
|
+
or zsh_expansion_flag(expanded)
|
|
2842
|
+
):
|
|
2843
|
+
raise ValueError(
|
|
2844
|
+
f"git argument {directory} cannot be resolved by the guard; "
|
|
2845
|
+
"pass a literal path"
|
|
2846
|
+
)
|
|
2847
|
+
if (
|
|
2848
|
+
(SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
|
|
2849
|
+
and any(character.isspace() or character in "*?[]" for character in expanded)
|
|
2850
|
+
):
|
|
2851
|
+
raise ValueError(
|
|
2852
|
+
f"git -C path {directory} may split or glob; pass a literal path"
|
|
2853
|
+
)
|
|
2854
|
+
resolved_arguments.extend((argument, expanded))
|
|
2855
|
+
elif attached_directory:
|
|
2856
|
+
require_resolved_git_word(argument)
|
|
2857
|
+
directory = argument[2:]
|
|
2858
|
+
expanded = expand_environment_parameters(directory, assignments)
|
|
2859
|
+
if (
|
|
2860
|
+
SHELL_PARAMETER_SYNTAX.search(expanded)
|
|
2861
|
+
or CMD_PARAMETER_SYNTAX.search(expanded)
|
|
2862
|
+
or zsh_expansion_flag(expanded)
|
|
2863
|
+
):
|
|
2864
|
+
raise ValueError(
|
|
2865
|
+
f"git argument {directory} cannot be resolved by the guard; "
|
|
2866
|
+
"pass a literal path"
|
|
2867
|
+
)
|
|
2868
|
+
if (
|
|
2869
|
+
(SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
|
|
2870
|
+
and any(character.isspace() or character in "*?[]" for character in expanded)
|
|
2871
|
+
):
|
|
2872
|
+
raise ValueError(
|
|
2873
|
+
f"git -C path {directory} may split or glob; pass a literal path"
|
|
2874
|
+
)
|
|
2875
|
+
resolved_arguments.append(f"-C{expanded}")
|
|
2876
|
+
else:
|
|
2877
|
+
parameter = (
|
|
2878
|
+
SHELL_PARAMETER_SYNTAX.search(argument)
|
|
2879
|
+
or CMD_PARAMETER_SYNTAX.search(argument)
|
|
2880
|
+
or zsh_expansion_flag(argument)
|
|
1660
2881
|
)
|
|
2882
|
+
if parameter:
|
|
2883
|
+
parameter_arguments.append(argument)
|
|
2884
|
+
literal = re.split(r"[$%!]", argument, maxsplit=1)[0]
|
|
2885
|
+
expanded = expand_environment_parameters(argument, assignments)
|
|
2886
|
+
if (
|
|
2887
|
+
not literal
|
|
2888
|
+
or (literal.startswith("-") and "=" not in literal)
|
|
2889
|
+
or git_option_in(literal, GIT_PARAMETER_DENIED_OPTIONS)
|
|
2890
|
+
or git_option_in(arguments[argument_index - 1], GIT_PARAMETER_DENIED_OPTIONS)
|
|
2891
|
+
or (literal.startswith("-c") and literal != "-c")
|
|
2892
|
+
or "::" in literal
|
|
2893
|
+
or not parameters_double_quoted(argument)
|
|
2894
|
+
or SUBSTITUTION_PLACEHOLDER in argument
|
|
2895
|
+
or SUBSTITUTION_PLACEHOLDER in expanded
|
|
2896
|
+
):
|
|
2897
|
+
unproven_parameters.append(argument)
|
|
2898
|
+
expanded = expand_environment_parameters(argument, assignments)
|
|
2899
|
+
resolved_arguments.append(argument if expanded == argument else expanded)
|
|
2900
|
+
argument_index += 1
|
|
2901
|
+
arguments = resolved_arguments
|
|
1661
2902
|
index = 0
|
|
1662
2903
|
git_options = []
|
|
1663
2904
|
while index < len(arguments) and arguments[index].startswith("-"):
|
|
@@ -1668,18 +2909,32 @@ def git_command(segment):
|
|
|
1668
2909
|
option = token.split("=", 1)[0]
|
|
1669
2910
|
value = token.split("=", 1)[1] if "=" in token else None
|
|
1670
2911
|
index += 1
|
|
2912
|
+
if option != "-C":
|
|
2913
|
+
require_resolved_git_word(token)
|
|
1671
2914
|
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
|
|
1672
2915
|
if index >= len(arguments):
|
|
1673
2916
|
raise ValueError(f"git option {option} lacks a value")
|
|
1674
2917
|
value = arguments[index]
|
|
2918
|
+
if option != "-C":
|
|
2919
|
+
require_resolved_git_word(value)
|
|
1675
2920
|
index += 1
|
|
1676
2921
|
if option in {"-c", "--config-env"}:
|
|
1677
|
-
config_key = str(value).
|
|
2922
|
+
config_key, _, config_value = str(value).partition("=")
|
|
2923
|
+
config_key = config_key.casefold()
|
|
1678
2924
|
if config_key.startswith("alias."):
|
|
1679
2925
|
raise ValueError(
|
|
1680
2926
|
f"git -c {config_key} defines an alias the guard cannot inspect; "
|
|
1681
2927
|
"run the underlying git command directly"
|
|
1682
2928
|
)
|
|
2929
|
+
if executed_config_key(config_key) and not (
|
|
2930
|
+
option == "-c"
|
|
2931
|
+
and not loaded_config_key(config_key)
|
|
2932
|
+
and config_value.casefold() in GIT_CONFIG_DISABLING_VALUES
|
|
2933
|
+
):
|
|
2934
|
+
raise ValueError(
|
|
2935
|
+
f"git -c {config_key} configures a program git executes; "
|
|
2936
|
+
"the guard cannot allow it"
|
|
2937
|
+
)
|
|
1683
2938
|
if config_key == "clean.requireforce":
|
|
1684
2939
|
raise ValueError(
|
|
1685
2940
|
"git -c clean.requireForce disables the git clean safety check; "
|
|
@@ -1688,8 +2943,47 @@ def git_command(segment):
|
|
|
1688
2943
|
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
|
|
1689
2944
|
git_options.extend((option, str(value)))
|
|
1690
2945
|
if index >= len(arguments):
|
|
2946
|
+
if parameter_arguments:
|
|
2947
|
+
raise ValueError(
|
|
2948
|
+
f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
|
|
2949
|
+
"pass a literal value (for commit messages use -F <file>)"
|
|
2950
|
+
)
|
|
1691
2951
|
return None
|
|
1692
|
-
|
|
2952
|
+
require_resolved_git_word(arguments[index], mixed=False)
|
|
2953
|
+
subcommand = arguments[index].casefold()
|
|
2954
|
+
if subcommand == "config":
|
|
2955
|
+
for argument in arguments[index + 1:]:
|
|
2956
|
+
literal = getattr(argument, "literal", None)
|
|
2957
|
+
if not getattr(argument, "redirection", False) and (
|
|
2958
|
+
literal in (None, "mixed")
|
|
2959
|
+
or (
|
|
2960
|
+
literal == "bare"
|
|
2961
|
+
and (argument.startswith("#") or GIT_CONFIG_EXPANDING_WORD.search(argument))
|
|
2962
|
+
)
|
|
2963
|
+
):
|
|
2964
|
+
raise ValueError(
|
|
2965
|
+
f"git argument {argument} cannot be resolved by the guard; "
|
|
2966
|
+
"pass a literal value"
|
|
2967
|
+
)
|
|
2968
|
+
denied = denied_git_config_argument(arguments[index + 1:])
|
|
2969
|
+
if denied is not None:
|
|
2970
|
+
raise ValueError(
|
|
2971
|
+
f"git config {denied} persists a program git executes; "
|
|
2972
|
+
"the guard cannot allow it"
|
|
2973
|
+
)
|
|
2974
|
+
read_only_subcommands = ARCHIVE_READ_GIT_COMMANDS | CLOSED_READ_GIT_COMMANDS
|
|
2975
|
+
if subcommand in read_only_subcommands:
|
|
2976
|
+
# Read-only subcommands still write through --output. A parameter
|
|
2977
|
+
# passes only inside plain double quotes, where its expansion stays
|
|
2978
|
+
# one word; an unquoted one can word-split into an option, and
|
|
2979
|
+
# command substitution output is never known.
|
|
2980
|
+
parameter_arguments = unproven_parameters
|
|
2981
|
+
if parameter_arguments:
|
|
2982
|
+
raise ValueError(
|
|
2983
|
+
f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
|
|
2984
|
+
"pass a literal value (for commit messages use -F <file>)"
|
|
2985
|
+
)
|
|
2986
|
+
return subcommand, arguments[index + 1:], git_options
|
|
1693
2987
|
|
|
1694
2988
|
|
|
1695
2989
|
def wrapped_command_tokens(segment, expand_parameters=True):
|
|
@@ -1698,7 +2992,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1698
2992
|
return None
|
|
1699
2993
|
executable, arguments = invocation
|
|
1700
2994
|
if executable == "eval":
|
|
1701
|
-
return shell_tokens(" ".join(arguments)) if arguments else None
|
|
2995
|
+
return shell_tokens(" ".join(arguments), direct=False) if arguments else None
|
|
1702
2996
|
if executable in COMMAND_WRAPPERS:
|
|
1703
2997
|
if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
|
|
1704
2998
|
return None
|
|
@@ -1723,7 +3017,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1723
3017
|
executable=executable, option=argument
|
|
1724
3018
|
)
|
|
1725
3019
|
)
|
|
1726
|
-
return shell_tokens(arguments[index + 1])
|
|
3020
|
+
return shell_tokens(arguments[index + 1], direct=False)
|
|
1727
3021
|
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1728
3022
|
if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
|
|
1729
3023
|
switches = {"-c", "-command", "/c", "/k"}
|
|
@@ -1742,8 +3036,8 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1742
3036
|
for argument in payload
|
|
1743
3037
|
]
|
|
1744
3038
|
if len(payload) == 1:
|
|
1745
|
-
return shell_tokens(payload[0])
|
|
1746
|
-
return payload
|
|
3039
|
+
return shell_tokens(payload[0], direct=False)
|
|
3040
|
+
return [str(argument) for argument in payload]
|
|
1747
3041
|
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1748
3042
|
return None
|
|
1749
3043
|
|
|
@@ -1757,21 +3051,31 @@ def has_short_option(arguments, option):
|
|
|
1757
3051
|
)
|
|
1758
3052
|
|
|
1759
3053
|
|
|
1760
|
-
def git_alias(command, git_options):
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
3054
|
+
def git_alias(command, git_options, working_directories=None):
|
|
3055
|
+
aliases = set()
|
|
3056
|
+
for directory in working_directories or [os.getcwd()]:
|
|
3057
|
+
try:
|
|
3058
|
+
result = subprocess.run(
|
|
3059
|
+
["git", *git_options, "config", "--get", f"alias.{command}"],
|
|
3060
|
+
cwd=directory,
|
|
3061
|
+
encoding="utf-8", errors="replace",
|
|
3062
|
+
capture_output=True,
|
|
3063
|
+
check=False,
|
|
3064
|
+
)
|
|
3065
|
+
except OSError:
|
|
3066
|
+
raise ValueError("git alias configuration cannot be validated") from None
|
|
3067
|
+
if result.returncode == 1:
|
|
3068
|
+
aliases.add(None)
|
|
3069
|
+
continue
|
|
3070
|
+
if result.returncode != 0:
|
|
3071
|
+
raise ValueError("git alias configuration cannot be validated")
|
|
3072
|
+
alias = result.stdout.strip()
|
|
3073
|
+
if not alias or alias.startswith("!"):
|
|
3074
|
+
raise ValueError("git alias configuration cannot be validated")
|
|
3075
|
+
aliases.add(alias)
|
|
3076
|
+
if len(aliases) > 1:
|
|
3077
|
+
raise ValueError("git alias configuration is ambiguous across working directories")
|
|
3078
|
+
return next(iter(aliases), None)
|
|
1775
3079
|
|
|
1776
3080
|
|
|
1777
3081
|
def update_ref_deletes(arguments):
|
|
@@ -1791,24 +3095,1012 @@ def update_ref_deletes(arguments):
|
|
|
1791
3095
|
operands[1] == "" or not operands[1].strip("0")
|
|
1792
3096
|
)
|
|
1793
3097
|
return (
|
|
1794
|
-
"
|
|
3098
|
+
has_short_option(arguments, "d") # also a cluster such as -dm
|
|
1795
3099
|
or "--delete" in arguments
|
|
1796
|
-
or any(argument
|
|
3100
|
+
or any(git_option_in(argument, {"--stdin"}) for argument in arguments)
|
|
1797
3101
|
or zero_value
|
|
1798
3102
|
)
|
|
1799
3103
|
|
|
1800
3104
|
|
|
1801
|
-
|
|
3105
|
+
PATH_OWNED_REF_PREFIXES = (
|
|
3106
|
+
"gsd-path/",
|
|
3107
|
+
"gsd-path-task/",
|
|
3108
|
+
"gsd-path-verify/",
|
|
3109
|
+
"gsd-path-integrate/",
|
|
3110
|
+
)
|
|
3111
|
+
# Git ignores an alias that has the name of one of its own commands, so these
|
|
3112
|
+
# need no alias inspection. Each has been a Git command since Git 2.31, the
|
|
3113
|
+
# oldest Git that has the rev-parse --path-format option the pipeline uses.
|
|
3114
|
+
# Deprecated commands stay out: newer Git lets an alias replace them
|
|
3115
|
+
# (whatchanged, pack-redundant). A name that is absent only fails closed.
|
|
3116
|
+
GIT_BUILT_IN_COMMANDS = frozenset({
|
|
3117
|
+
"add", "am", "annotate", "apply", "archive", "bisect", "blame", "branch",
|
|
3118
|
+
"bugreport", "bundle", "cat-file", "check-attr", "check-ignore",
|
|
3119
|
+
"check-mailmap", "check-ref-format", "checkout", "checkout-index", "cherry",
|
|
3120
|
+
"cherry-pick", "clean", "clone", "column", "commit", "commit-graph",
|
|
3121
|
+
"commit-tree", "config", "count-objects", "credential", "credential-store",
|
|
3122
|
+
"describe", "diff", "diff-files", "diff-index", "diff-tree", "difftool",
|
|
3123
|
+
"fast-export", "fast-import", "fetch", "fetch-pack", "fmt-merge-msg",
|
|
3124
|
+
"for-each-ref", "for-each-repo", "format-patch", "fsck", "gc",
|
|
3125
|
+
"get-tar-commit-id", "grep", "hash-object", "help", "index-pack", "init",
|
|
3126
|
+
"interpret-trailers", "log", "ls-files", "ls-remote", "ls-tree", "mailinfo",
|
|
3127
|
+
"mailsplit", "maintenance", "merge", "merge-base", "merge-file",
|
|
3128
|
+
"merge-index", "merge-tree", "mergetool", "mktag", "mktree",
|
|
3129
|
+
"multi-pack-index", "mv", "name-rev", "notes", "pack-objects", "pack-refs",
|
|
3130
|
+
"patch-id", "prune", "prune-packed", "pull", "push", "range-diff",
|
|
3131
|
+
"read-tree", "rebase", "receive-pack", "reflog", "remote", "repack",
|
|
3132
|
+
"replace", "rerere", "reset", "restore", "rev-list", "rev-parse", "revert",
|
|
3133
|
+
"rm", "send-pack", "shortlog", "show", "show-branch", "show-index",
|
|
3134
|
+
"show-ref", "sparse-checkout", "stage", "stash", "status", "stripspace",
|
|
3135
|
+
"submodule", "switch", "symbolic-ref", "tag", "unpack-file",
|
|
3136
|
+
"unpack-objects", "update-index", "update-ref", "update-server-info",
|
|
3137
|
+
"upload-archive", "upload-pack", "var", "verify-commit", "verify-pack",
|
|
3138
|
+
"verify-tag", "version", "worktree", "write-tree",
|
|
3139
|
+
})
|
|
3140
|
+
|
|
3141
|
+
|
|
3142
|
+
def _ownership_scalar(raw, path, key):
|
|
3143
|
+
value = raw.strip()
|
|
3144
|
+
if not value:
|
|
3145
|
+
return None
|
|
3146
|
+
if value.startswith('"'):
|
|
3147
|
+
quoted = re.match(r'^("(?:\\.|[^"\\])*\")(?:[ \t]+#.*)?$', value)
|
|
3148
|
+
if quoted is None:
|
|
3149
|
+
raise ValueError(f"{path.name} has an unreadable {key} value")
|
|
3150
|
+
try:
|
|
3151
|
+
value = json.loads(quoted.group(1))
|
|
3152
|
+
except json.JSONDecodeError:
|
|
3153
|
+
raise ValueError(f"{path.name} has an unreadable {key} value") from None
|
|
3154
|
+
if not isinstance(value, str):
|
|
3155
|
+
raise ValueError(f"{path.name} has a non-string {key} value")
|
|
3156
|
+
return value or None
|
|
3157
|
+
if value.startswith("'"):
|
|
3158
|
+
quoted = re.fullmatch(r"'((?:[^']|'')*)'(?:[ \t]+#.*)?", value)
|
|
3159
|
+
if quoted is None:
|
|
3160
|
+
raise ValueError(f"{path.name} has an unreadable {key} value")
|
|
3161
|
+
value = quoted.group(1).replace("''", "'")
|
|
3162
|
+
return value or None
|
|
3163
|
+
value = re.sub(r"[ \t]+#.*$", "", value).strip()
|
|
3164
|
+
if not value or value.casefold() in {"null", "~", "none"}:
|
|
3165
|
+
return None
|
|
3166
|
+
if value.casefold() in {"true", "false", "yes", "no", "on", "off"} or re.fullmatch(
|
|
3167
|
+
r"[-+]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?", value
|
|
3168
|
+
):
|
|
3169
|
+
raise ValueError(f"{path.name} has a non-string {key} value")
|
|
3170
|
+
if value[0] in "[{|>&*!" or ": " in value:
|
|
3171
|
+
raise ValueError(f"{path.name} has a non-scalar {key} value")
|
|
3172
|
+
return value
|
|
3173
|
+
|
|
3174
|
+
|
|
3175
|
+
def ownership_frontmatter(path, keys, *, missing_ok=False):
|
|
3176
|
+
try:
|
|
3177
|
+
metadata = path.lstat()
|
|
3178
|
+
except FileNotFoundError:
|
|
3179
|
+
if missing_ok and not os.path.lexists(path):
|
|
3180
|
+
return {}
|
|
3181
|
+
raise ValueError(f"{path.name} ownership metadata cannot be read") from None
|
|
3182
|
+
except OSError as error:
|
|
3183
|
+
raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
|
|
3184
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
3185
|
+
raise ValueError(f"{path.name} ownership metadata is not a regular file")
|
|
3186
|
+
try:
|
|
3187
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
3188
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
3189
|
+
raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
|
|
3190
|
+
if not lines or lines[0] != "---":
|
|
3191
|
+
raise ValueError(f"{path.name} has no readable frontmatter")
|
|
3192
|
+
try:
|
|
3193
|
+
end = lines.index("---", 1)
|
|
3194
|
+
except ValueError:
|
|
3195
|
+
raise ValueError(f"{path.name} frontmatter is not terminated") from None
|
|
3196
|
+
|
|
3197
|
+
values = {}
|
|
3198
|
+
pending_scalar = None
|
|
3199
|
+
for line in lines[1:end]:
|
|
3200
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
3201
|
+
continue
|
|
3202
|
+
if line[0].isspace():
|
|
3203
|
+
if pending_scalar is not None:
|
|
3204
|
+
raise ValueError(
|
|
3205
|
+
f"{path.name} has a non-scalar {pending_scalar} value"
|
|
3206
|
+
)
|
|
3207
|
+
continue
|
|
3208
|
+
pending_scalar = None
|
|
3209
|
+
match = re.fullmatch(r"(?P<key>[A-Za-z_][A-Za-z0-9_-]*):(?P<value>.*)", line)
|
|
3210
|
+
if match is None:
|
|
3211
|
+
raise ValueError(f"{path.name} has unreadable frontmatter")
|
|
3212
|
+
key = match.group("key")
|
|
3213
|
+
if key not in keys:
|
|
3214
|
+
continue
|
|
3215
|
+
if key in values:
|
|
3216
|
+
raise ValueError(f"{path.name} repeats ownership field {key}")
|
|
3217
|
+
value = _ownership_scalar(match.group("value"), path, key)
|
|
3218
|
+
values[key] = value
|
|
3219
|
+
if value is None and not match.group("value").strip():
|
|
3220
|
+
pending_scalar = key
|
|
3221
|
+
return values
|
|
3222
|
+
|
|
3223
|
+
|
|
3224
|
+
def git_base_directories(git_options, working_directories):
|
|
3225
|
+
"""Resolve target paths from each shell cwd after Git's ordered ``-C`` options."""
|
|
3226
|
+
result = []
|
|
3227
|
+
for directory in working_directories or [os.getcwd()]:
|
|
3228
|
+
current = Path(directory or os.getcwd()).resolve()
|
|
3229
|
+
index = 0
|
|
3230
|
+
while index < len(git_options):
|
|
3231
|
+
option = git_options[index]
|
|
3232
|
+
value = git_options[index + 1]
|
|
3233
|
+
if option == "-C":
|
|
3234
|
+
target = Path(value)
|
|
3235
|
+
current = (target if target.is_absolute() else current / target).resolve()
|
|
3236
|
+
index += 2
|
|
3237
|
+
result.append(current)
|
|
3238
|
+
return result
|
|
3239
|
+
|
|
3240
|
+
|
|
3241
|
+
def _git_output(git_options, directory, *arguments):
|
|
3242
|
+
try:
|
|
3243
|
+
result = subprocess.run(
|
|
3244
|
+
["git", *git_options, *arguments],
|
|
3245
|
+
cwd=directory,
|
|
3246
|
+
capture_output=True,
|
|
3247
|
+
encoding="utf-8",
|
|
3248
|
+
errors="replace",
|
|
3249
|
+
check=False,
|
|
3250
|
+
)
|
|
3251
|
+
except OSError:
|
|
3252
|
+
raise ValueError("git target repository cannot be resolved") from None
|
|
3253
|
+
if result.returncode != 0:
|
|
3254
|
+
raise ValueError("git target repository cannot be resolved")
|
|
3255
|
+
return result.stdout
|
|
3256
|
+
|
|
3257
|
+
|
|
3258
|
+
def registered_worktree_records(output, separator="\0"):
|
|
3259
|
+
records = {}
|
|
3260
|
+
for record in output.split(separator * 2):
|
|
3261
|
+
fields = [field for field in record.split(separator) if field]
|
|
3262
|
+
if not fields:
|
|
3263
|
+
continue
|
|
3264
|
+
paths = [field[len("worktree "):] for field in fields if field.startswith("worktree ")]
|
|
3265
|
+
branches = [field[len("branch "):] for field in fields if field.startswith("branch ")]
|
|
3266
|
+
if len(paths) != 1 or len(branches) > 1:
|
|
3267
|
+
raise ValueError("registered worktree metadata cannot be resolved")
|
|
3268
|
+
path = Path(paths[0])
|
|
3269
|
+
if not path.is_absolute():
|
|
3270
|
+
raise ValueError("registered worktree path is not absolute")
|
|
3271
|
+
resolved = path.resolve()
|
|
3272
|
+
if resolved in records:
|
|
3273
|
+
raise ValueError("registered worktree metadata is ambiguous")
|
|
3274
|
+
records[resolved] = branches[0] if branches else None
|
|
3275
|
+
if not records:
|
|
3276
|
+
raise ValueError("registered worktrees cannot be resolved")
|
|
3277
|
+
return records
|
|
3278
|
+
|
|
3279
|
+
|
|
3280
|
+
def git_target_contexts(git_options, working_directories):
|
|
3281
|
+
contexts = []
|
|
3282
|
+
for directory, effective_directory in zip(
|
|
3283
|
+
working_directories or [os.getcwd()],
|
|
3284
|
+
git_base_directories(git_options, working_directories),
|
|
3285
|
+
):
|
|
3286
|
+
root = Path(
|
|
3287
|
+
_git_output(git_options, directory, "rev-parse", "--show-toplevel").strip()
|
|
3288
|
+
).resolve()
|
|
3289
|
+
listing = ("worktree", "list", "--porcelain")
|
|
3290
|
+
try:
|
|
3291
|
+
output, separator = _git_output(git_options, directory, *listing, "-z"), "\0"
|
|
3292
|
+
except ValueError:
|
|
3293
|
+
# Git before 2.36 has no -z here; pipeline_git.worktree_list_porcelain
|
|
3294
|
+
# retries the same way. A line then ends each field.
|
|
3295
|
+
output, separator = _git_output(git_options, directory, *listing), "\n"
|
|
3296
|
+
records = registered_worktree_records(output, separator)
|
|
3297
|
+
if root not in records:
|
|
3298
|
+
raise ValueError("git target repository worktree cannot be resolved")
|
|
3299
|
+
common = Path(
|
|
3300
|
+
_git_output(git_options, directory, "rev-parse", "--git-common-dir").strip()
|
|
3301
|
+
)
|
|
3302
|
+
if not common.is_absolute():
|
|
3303
|
+
common = effective_directory / common
|
|
3304
|
+
contexts.append(
|
|
3305
|
+
{
|
|
3306
|
+
"root": root,
|
|
3307
|
+
"directory": directory,
|
|
3308
|
+
"effective_directory": effective_directory,
|
|
3309
|
+
"common": common.resolve(),
|
|
3310
|
+
"git_options": list(git_options),
|
|
3311
|
+
"worktrees": records,
|
|
3312
|
+
}
|
|
3313
|
+
)
|
|
3314
|
+
return contexts
|
|
3315
|
+
|
|
3316
|
+
|
|
3317
|
+
def path_owned_records(roots):
|
|
3318
|
+
branches, worktrees = set(), set()
|
|
3319
|
+
for root in roots:
|
|
3320
|
+
project = root / ".project"
|
|
3321
|
+
if not os.path.lexists(project):
|
|
3322
|
+
continue
|
|
3323
|
+
if is_link_like(project) or not project.is_dir():
|
|
3324
|
+
raise ValueError(".project ownership metadata cannot be resolved")
|
|
3325
|
+
state = ownership_frontmatter(project / "STATE.md", {"branch"}, missing_ok=True)
|
|
3326
|
+
if state.get("branch"):
|
|
3327
|
+
branches.add(state["branch"])
|
|
3328
|
+
task_files = []
|
|
3329
|
+
|
|
3330
|
+
def raise_walk_error(error):
|
|
3331
|
+
raise error
|
|
3332
|
+
|
|
3333
|
+
try:
|
|
3334
|
+
for directory, child_directories, filenames in os.walk(
|
|
3335
|
+
project, onerror=raise_walk_error, followlinks=False
|
|
3336
|
+
):
|
|
3337
|
+
if any(is_link_like(Path(directory) / name) for name in child_directories):
|
|
3338
|
+
raise ValueError("task ownership metadata contains a linked directory")
|
|
3339
|
+
if Path(directory).name == "tasks":
|
|
3340
|
+
task_files.extend(
|
|
3341
|
+
Path(directory) / name
|
|
3342
|
+
for name in filenames
|
|
3343
|
+
if name.endswith(".md")
|
|
3344
|
+
)
|
|
3345
|
+
except OSError as error:
|
|
3346
|
+
raise ValueError(f"task ownership metadata cannot be listed: {error}") from None
|
|
3347
|
+
task_files.sort()
|
|
3348
|
+
for task in task_files:
|
|
3349
|
+
fields = ownership_frontmatter(task, {"task_branch", "worktree"})
|
|
3350
|
+
if fields.get("task_branch"):
|
|
3351
|
+
branches.add(fields["task_branch"])
|
|
3352
|
+
recorded = fields.get("worktree")
|
|
3353
|
+
if recorded:
|
|
3354
|
+
path = Path(recorded).expanduser()
|
|
3355
|
+
if not path.is_absolute():
|
|
3356
|
+
path = root / path
|
|
3357
|
+
try:
|
|
3358
|
+
worktrees.add(path.resolve())
|
|
3359
|
+
except (OSError, RuntimeError):
|
|
3360
|
+
raise ValueError("task worktree ownership path cannot be resolved") from None
|
|
3361
|
+
return branches, worktrees
|
|
3362
|
+
|
|
3363
|
+
|
|
3364
|
+
def ref_is_path_owned(name, owned_branches):
|
|
3365
|
+
lowered = name.casefold()
|
|
3366
|
+
# git stash drop and clear are denied too; refs/stash holds each stash.
|
|
3367
|
+
if lowered.startswith("refs/gsd-path/") or lowered == "refs/stash":
|
|
3368
|
+
return True
|
|
3369
|
+
if lowered.startswith("refs/remotes/"):
|
|
3370
|
+
# The remote-tracking ref of a Path branch can be the last ref to its
|
|
3371
|
+
# commits. A remote name can contain a slash, so try each split.
|
|
3372
|
+
parts = name.split("/")[2:]
|
|
3373
|
+
return any(
|
|
3374
|
+
ref_is_path_owned("refs/heads/" + "/".join(parts[index:]), owned_branches)
|
|
3375
|
+
for index in range(1, len(parts))
|
|
3376
|
+
)
|
|
3377
|
+
short = name
|
|
3378
|
+
if short.startswith("refs/heads/"):
|
|
3379
|
+
short = short[len("refs/heads/"):]
|
|
3380
|
+
elif short.startswith("heads/"):
|
|
3381
|
+
short = short[len("heads/"):]
|
|
3382
|
+
if short.casefold().startswith(PATH_OWNED_REF_PREFIXES):
|
|
3383
|
+
return True
|
|
3384
|
+
|
|
3385
|
+
def canonical(value):
|
|
3386
|
+
if value.startswith("refs/heads/"):
|
|
3387
|
+
return value[len("refs/heads/"):]
|
|
3388
|
+
if value.startswith("heads/"):
|
|
3389
|
+
return value[len("heads/"):]
|
|
3390
|
+
return value
|
|
3391
|
+
|
|
3392
|
+
# Loose refs that differ only in case are one file on macOS and Windows.
|
|
3393
|
+
return any(
|
|
3394
|
+
canonical(name).casefold() == canonical(branch).casefold()
|
|
3395
|
+
for branch in owned_branches
|
|
3396
|
+
)
|
|
3397
|
+
|
|
3398
|
+
|
|
3399
|
+
def _literal_target(value):
|
|
3400
|
+
return bool(value) and not any(character in value for character in "*?[]{}~")
|
|
3401
|
+
|
|
3402
|
+
|
|
3403
|
+
def _positional_targets(arguments):
|
|
3404
|
+
targets = []
|
|
3405
|
+
after_separator = False
|
|
3406
|
+
for argument in arguments:
|
|
3407
|
+
if after_separator:
|
|
3408
|
+
targets.append(argument)
|
|
3409
|
+
elif argument == "--":
|
|
3410
|
+
after_separator = True
|
|
3411
|
+
elif not argument.startswith("-"):
|
|
3412
|
+
targets.append(argument)
|
|
3413
|
+
return targets
|
|
3414
|
+
|
|
3415
|
+
|
|
3416
|
+
PLAIN_REDIRECTION_OPERATORS = frozenset({">", ">>", "<", ">&", "&>", "&>>"})
|
|
3417
|
+
|
|
3418
|
+
|
|
3419
|
+
def _opens_process_substitution(token):
|
|
3420
|
+
return token in ("<(", ">(") and getattr(token, "literal", None) != "quoted"
|
|
3421
|
+
|
|
3422
|
+
|
|
3423
|
+
def _fold_process_substitutions(tokens):
|
|
3424
|
+
"""The tokens with each process substitution body moved onto its operator."""
|
|
3425
|
+
folded = []
|
|
3426
|
+
body = None
|
|
3427
|
+
depth = 0
|
|
3428
|
+
for token in tokens:
|
|
3429
|
+
opens = _opens_process_substitution(token) and (
|
|
3430
|
+
getattr(token, "body", None) is None
|
|
3431
|
+
)
|
|
3432
|
+
if depth == 0:
|
|
3433
|
+
if opens:
|
|
3434
|
+
depth, body = 1, []
|
|
3435
|
+
operator = ShellToken(token)
|
|
3436
|
+
operator.literal = getattr(token, "literal", None)
|
|
3437
|
+
operator.body = body
|
|
3438
|
+
token = operator
|
|
3439
|
+
folded.append(token)
|
|
3440
|
+
continue
|
|
3441
|
+
if opens:
|
|
3442
|
+
depth += 1
|
|
3443
|
+
elif token and set(token) <= set("|;&()\n\r") and (
|
|
3444
|
+
getattr(token, "literal", None) != "quoted"
|
|
3445
|
+
):
|
|
3446
|
+
for index, character in enumerate(token):
|
|
3447
|
+
depth += (character == "(") - (character == ")")
|
|
3448
|
+
if depth == 0:
|
|
3449
|
+
if token[index + 1:]:
|
|
3450
|
+
folded.append(ShellToken(token[index + 1:]))
|
|
3451
|
+
if token[:index]:
|
|
3452
|
+
body.append(token[:index])
|
|
3453
|
+
break
|
|
3454
|
+
if depth == 0:
|
|
3455
|
+
continue
|
|
3456
|
+
body.append(token)
|
|
3457
|
+
return folded
|
|
3458
|
+
|
|
3459
|
+
|
|
3460
|
+
def _runs_git_or_wrapper(segment, assignments):
|
|
3461
|
+
words, _exact = _without_redirections(segment)
|
|
3462
|
+
return (
|
|
3463
|
+
wrapped_command_tokens(words if len(words) > 1 else segment) is not None
|
|
3464
|
+
or git_command(words, assignments) is not None
|
|
3465
|
+
)
|
|
3466
|
+
|
|
3467
|
+
|
|
3468
|
+
def _holds_git_command(tokens, assignments):
|
|
3469
|
+
"""Whether the tokens run Git or a wrapper, at each process substitution depth."""
|
|
1802
3470
|
for segment in command_segments(tokens):
|
|
1803
|
-
|
|
3471
|
+
words = []
|
|
3472
|
+
for token in [*segment, None]:
|
|
3473
|
+
if token is not None and not _opens_process_substitution(token):
|
|
3474
|
+
if not getattr(token, "descriptor", False):
|
|
3475
|
+
words.append(token)
|
|
3476
|
+
continue
|
|
3477
|
+
if words and _runs_git_or_wrapper(words, assignments):
|
|
3478
|
+
return True
|
|
3479
|
+
words = []
|
|
3480
|
+
if getattr(token, "body", None) and _holds_git_command(
|
|
3481
|
+
token.body, assignments
|
|
3482
|
+
):
|
|
3483
|
+
return True
|
|
3484
|
+
return False
|
|
3485
|
+
|
|
3486
|
+
|
|
3487
|
+
def _without_descriptor(token):
|
|
3488
|
+
word = ShellToken(token)
|
|
3489
|
+
word.redirection = getattr(token, "redirection", False)
|
|
3490
|
+
word.literal = getattr(token, "literal", None)
|
|
3491
|
+
return word
|
|
3492
|
+
|
|
3493
|
+
|
|
3494
|
+
def _without_redirections(words):
|
|
3495
|
+
"""The words of a simple command without the redirections the shell parsed.
|
|
3496
|
+
|
|
3497
|
+
Each redirection operator goes with the word after it and one file
|
|
3498
|
+
descriptor digit written against it. The second result is false when one
|
|
3499
|
+
of them is not plain: an operator outside PLAIN_REDIRECTION_OPERATORS or
|
|
3500
|
+
a target that is not one literal word. A process substitution operator is
|
|
3501
|
+
removed too and is never plain.
|
|
3502
|
+
"""
|
|
3503
|
+
kept = []
|
|
3504
|
+
exact = True
|
|
3505
|
+
index = 0
|
|
3506
|
+
while index < len(words):
|
|
3507
|
+
word = words[index]
|
|
3508
|
+
if _opens_process_substitution(word):
|
|
3509
|
+
exact = False
|
|
3510
|
+
index += 1
|
|
3511
|
+
continue
|
|
3512
|
+
if not getattr(word, "redirection", False):
|
|
3513
|
+
kept.append(word)
|
|
3514
|
+
index += 1
|
|
3515
|
+
continue
|
|
3516
|
+
if word.isdigit():
|
|
3517
|
+
index += 1
|
|
3518
|
+
word = words[index] if index < len(words) else ""
|
|
3519
|
+
target = words[index + 1] if index + 1 < len(words) else ""
|
|
3520
|
+
exact = exact and not (
|
|
3521
|
+
word not in PLAIN_REDIRECTION_OPERATORS
|
|
3522
|
+
or getattr(target, "redirection", False)
|
|
3523
|
+
or getattr(target, "literal", None) not in ("bare", "quoted")
|
|
3524
|
+
or not _literal_target(target)
|
|
3525
|
+
or any(character in target for character in "$`<>()&|#")
|
|
3526
|
+
)
|
|
3527
|
+
index += 2
|
|
3528
|
+
return kept, exact
|
|
3529
|
+
|
|
3530
|
+
|
|
3531
|
+
def _frontmatter_roots(contexts):
|
|
3532
|
+
return {
|
|
3533
|
+
path
|
|
3534
|
+
for context in contexts
|
|
3535
|
+
for path in context["worktrees"]
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
|
|
3539
|
+
def _git_refs(context):
|
|
3540
|
+
output = _git_output(
|
|
3541
|
+
context["git_options"],
|
|
3542
|
+
context["directory"],
|
|
3543
|
+
"for-each-ref",
|
|
3544
|
+
"--format=%(refname)",
|
|
3545
|
+
)
|
|
3546
|
+
return {line for line in output.splitlines() if line}
|
|
3547
|
+
|
|
3548
|
+
|
|
3549
|
+
def _symbolic_ref_target(context, target):
|
|
3550
|
+
try:
|
|
3551
|
+
result = subprocess.run(
|
|
3552
|
+
["git", *context["git_options"], "symbolic-ref", "--quiet", target],
|
|
3553
|
+
cwd=context["directory"],
|
|
3554
|
+
capture_output=True,
|
|
3555
|
+
encoding="utf-8",
|
|
3556
|
+
errors="replace",
|
|
3557
|
+
check=False,
|
|
3558
|
+
)
|
|
3559
|
+
except OSError:
|
|
3560
|
+
raise ValueError("git ref target cannot be resolved") from None
|
|
3561
|
+
if result.returncode == 1:
|
|
3562
|
+
return None
|
|
3563
|
+
if result.returncode != 0:
|
|
3564
|
+
raise ValueError("git ref target cannot be resolved")
|
|
3565
|
+
return result.stdout.strip()
|
|
3566
|
+
|
|
3567
|
+
|
|
3568
|
+
def _update_ref_names(target, refs, context, no_deref):
|
|
3569
|
+
"""The ref names a deletion of this operand touches, or None when unresolved.
|
|
3570
|
+
|
|
3571
|
+
Git reads an update-ref operand as a full name below the git directory,
|
|
3572
|
+
never as a branch short name. It follows each symbolic ref to the ref it
|
|
3573
|
+
deletes; with --no-deref it deletes the named symbolic ref itself. A name
|
|
3574
|
+
outside refs/ (HEAD, a symbolic ref at the top of the git directory) is
|
|
3575
|
+
unresolved: an ordinary command such as git switch changes its target.
|
|
3576
|
+
"""
|
|
3577
|
+
if not target.startswith("refs/"):
|
|
3578
|
+
return None
|
|
3579
|
+
names = [target]
|
|
3580
|
+
while True:
|
|
3581
|
+
symbolic = _symbolic_ref_target(context, names[-1])
|
|
3582
|
+
if symbolic is None:
|
|
3583
|
+
return names if names[-1] in refs else None
|
|
3584
|
+
if no_deref:
|
|
3585
|
+
return names
|
|
3586
|
+
if symbolic in names:
|
|
3587
|
+
return None
|
|
3588
|
+
names.append(symbolic)
|
|
3589
|
+
|
|
3590
|
+
|
|
3591
|
+
def _workspace_roots(contexts):
|
|
3592
|
+
roots = [(Path.home() / ".gsd-path").resolve()]
|
|
3593
|
+
configured = os.environ.get("GSD_PATH_WORKTREE_ROOT")
|
|
3594
|
+
if configured:
|
|
3595
|
+
configured_root = Path(configured).expanduser()
|
|
3596
|
+
if not configured_root.is_absolute():
|
|
3597
|
+
raise ValueError("GSD_PATH_WORKTREE_ROOT must be absolute")
|
|
3598
|
+
roots.append(configured_root.resolve())
|
|
3599
|
+
for common in {context["common"] for context in contexts}:
|
|
3600
|
+
receipts = common / "gsd-path" / "workspaces"
|
|
3601
|
+
if not os.path.lexists(receipts):
|
|
3602
|
+
continue
|
|
3603
|
+
if is_link_like(receipts) or not receipts.is_dir():
|
|
3604
|
+
raise ValueError("pinned workspace receipts cannot be resolved")
|
|
3605
|
+
try:
|
|
3606
|
+
with os.scandir(receipts) as entries:
|
|
3607
|
+
receipt_files = sorted(
|
|
3608
|
+
(Path(entry.path) for entry in entries if entry.name.endswith(".json")),
|
|
3609
|
+
key=lambda path: path.name,
|
|
3610
|
+
)
|
|
3611
|
+
except OSError as error:
|
|
3612
|
+
raise ValueError(f"pinned workspace receipts cannot be listed: {error}") from None
|
|
3613
|
+
for receipt in receipt_files:
|
|
3614
|
+
try:
|
|
3615
|
+
metadata = receipt.lstat()
|
|
3616
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
3617
|
+
raise ValueError("receipt is not a regular file")
|
|
3618
|
+
data = json.loads(receipt.read_text(encoding="utf-8"))
|
|
3619
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
3620
|
+
raise ValueError(
|
|
3621
|
+
f"pinned workspace receipt cannot be read: {receipt.name}"
|
|
3622
|
+
) from None
|
|
3623
|
+
if (
|
|
3624
|
+
not isinstance(data, dict)
|
|
3625
|
+
or not isinstance(data.get("primary"), str)
|
|
3626
|
+
or not isinstance(data.get("root"), str)
|
|
3627
|
+
or not Path(data["primary"]).is_absolute()
|
|
3628
|
+
or not Path(data["root"]).is_absolute()
|
|
3629
|
+
):
|
|
3630
|
+
raise ValueError(
|
|
3631
|
+
f"pinned workspace receipt is malformed: {receipt.name}"
|
|
3632
|
+
)
|
|
3633
|
+
roots.append(Path(data["root"]).resolve())
|
|
3634
|
+
return roots
|
|
3635
|
+
|
|
3636
|
+
|
|
3637
|
+
def owned_target_reason(command, arguments, git_options, working_directories):
|
|
3638
|
+
action = {
|
|
3639
|
+
"branch": "git branch -D",
|
|
3640
|
+
"update-ref": "git update-ref deletion",
|
|
3641
|
+
"worktree": "git worktree remove --force",
|
|
3642
|
+
}[command]
|
|
3643
|
+
unresolved = GIT_REASONS["unresolved-target"].format(action=action)
|
|
3644
|
+
# The shell expands a leading ~ and a glob in an option value such as the
|
|
3645
|
+
# -C directory; the guard has only the literal text.
|
|
3646
|
+
if any(
|
|
3647
|
+
value[:1] == "~" or any(character in value for character in "*?[]{}")
|
|
3648
|
+
for value in git_options[1::2]
|
|
3649
|
+
):
|
|
3650
|
+
return unresolved
|
|
3651
|
+
contexts = git_target_contexts(git_options, working_directories)
|
|
3652
|
+
branches, task_worktrees = path_owned_records(_frontmatter_roots(contexts))
|
|
3653
|
+
|
|
3654
|
+
if command == "branch":
|
|
3655
|
+
# Each word that is not an option is read as a branch name. Git does
|
|
3656
|
+
# the same after --color; after an option with a separate value
|
|
3657
|
+
# (--sort key) this only adds a name to check.
|
|
3658
|
+
targets = _positional_targets(arguments)
|
|
3659
|
+
if not targets or not all(_literal_target(target) for target in targets):
|
|
3660
|
+
return unresolved
|
|
3661
|
+
remote = has_short_option(arguments, "r") or any(
|
|
3662
|
+
git_option_in(argument, {"--remotes"}) for argument in arguments
|
|
3663
|
+
)
|
|
3664
|
+
for target in targets:
|
|
3665
|
+
# Git reads the name in exactly one namespace, with no other form.
|
|
3666
|
+
ref = f"refs/{'remotes' if remote else 'heads'}/{target}"
|
|
3667
|
+
if ref_is_path_owned(ref, branches) or ref_is_path_owned(
|
|
3668
|
+
f"refs/heads/{target}", branches
|
|
3669
|
+
):
|
|
3670
|
+
return GIT_REASONS["branch"]
|
|
3671
|
+
if any(ref not in _git_refs(context) for context in contexts):
|
|
3672
|
+
return unresolved
|
|
3673
|
+
return None
|
|
3674
|
+
|
|
3675
|
+
if command == "update-ref":
|
|
3676
|
+
operands, options = [], set()
|
|
3677
|
+
index = 0
|
|
3678
|
+
while index < len(arguments):
|
|
3679
|
+
argument = arguments[index]
|
|
3680
|
+
if argument == "-m": # its value is not an option, also when it is -d
|
|
3681
|
+
index += 2
|
|
3682
|
+
continue
|
|
3683
|
+
if argument.startswith("-"):
|
|
3684
|
+
options.add(argument)
|
|
3685
|
+
index += 1
|
|
3686
|
+
continue
|
|
3687
|
+
operands.append(argument)
|
|
3688
|
+
index += 1
|
|
3689
|
+
deletion_flag = "-d" in options
|
|
3690
|
+
expected_counts = {1, 2} if deletion_flag else {2, 3}
|
|
3691
|
+
if len(operands) not in expected_counts or not _literal_target(operands[0]):
|
|
3692
|
+
return unresolved
|
|
3693
|
+
if not deletion_flag and not (operands[1] == "" or not operands[1].strip("0")):
|
|
3694
|
+
return unresolved
|
|
3695
|
+
if deletion_flag and len(operands) == 2 and not _literal_target(operands[1]):
|
|
3696
|
+
return unresolved
|
|
3697
|
+
if not deletion_flag and len(operands) == 3 and not _literal_target(operands[2]):
|
|
3698
|
+
return unresolved
|
|
3699
|
+
# Dereferencing a symbolic ref must not erase ownership of its name.
|
|
3700
|
+
if ref_is_path_owned(operands[0], branches):
|
|
3701
|
+
return GIT_REASONS["update-ref"]
|
|
3702
|
+
for context in contexts:
|
|
3703
|
+
names = _update_ref_names(
|
|
3704
|
+
operands[0], _git_refs(context), context, "--no-deref" in options
|
|
3705
|
+
)
|
|
3706
|
+
if names is None:
|
|
3707
|
+
return unresolved
|
|
3708
|
+
if any(ref_is_path_owned(name, branches) for name in names):
|
|
3709
|
+
return GIT_REASONS["update-ref"]
|
|
3710
|
+
return None
|
|
3711
|
+
|
|
3712
|
+
targets = _positional_targets(arguments[1:])
|
|
3713
|
+
if len(targets) != 1 or not _literal_target(targets[0]):
|
|
3714
|
+
return unresolved
|
|
3715
|
+
recorded_paths = set(task_worktrees)
|
|
3716
|
+
managed_roots = _workspace_roots(contexts)
|
|
3717
|
+
for context in contexts:
|
|
3718
|
+
target = Path(targets[0])
|
|
3719
|
+
if not target.is_absolute():
|
|
3720
|
+
target = context["effective_directory"] / target
|
|
3721
|
+
resolved_target = None
|
|
3722
|
+
try:
|
|
3723
|
+
resolved_target = target.resolve(strict=True)
|
|
3724
|
+
except (OSError, RuntimeError):
|
|
3725
|
+
pass
|
|
3726
|
+
registered = context["worktrees"]
|
|
3727
|
+
# Git tries a unique path suffix first, then the path, and compares
|
|
3728
|
+
# without case on macOS and Windows. Both readings must agree.
|
|
3729
|
+
matches = {resolved_target} & set(registered)
|
|
3730
|
+
suffix = tuple(
|
|
3731
|
+
part.casefold() for part in Path(targets[0]).parts if part not in {"", "."}
|
|
3732
|
+
)
|
|
3733
|
+
if suffix and not Path(targets[0]).is_absolute() and ".." not in suffix:
|
|
3734
|
+
by_suffix = [
|
|
3735
|
+
path for path in registered
|
|
3736
|
+
if tuple(part.casefold() for part in path.parts[-len(suffix):]) == suffix
|
|
3737
|
+
]
|
|
3738
|
+
if len(by_suffix) == 1:
|
|
3739
|
+
matches.add(by_suffix[0])
|
|
3740
|
+
if len(matches) != 1:
|
|
3741
|
+
return unresolved
|
|
3742
|
+
resolved_target = matches.pop()
|
|
3743
|
+
if not resolved_target.is_dir():
|
|
3744
|
+
return unresolved
|
|
3745
|
+
branch = registered[resolved_target]
|
|
3746
|
+
primary = next(iter(registered))
|
|
3747
|
+
if (
|
|
3748
|
+
resolved_target in recorded_paths
|
|
3749
|
+
or (branch is not None and ref_is_path_owned(branch, branches))
|
|
3750
|
+
or any(
|
|
3751
|
+
resolved_target == root or resolved_target.is_relative_to(root)
|
|
3752
|
+
for root in (*managed_roots, primary.parent / f"{primary.name}.gsd-path")
|
|
3753
|
+
)
|
|
3754
|
+
or (
|
|
3755
|
+
resolved_target.parent == primary.parent
|
|
3756
|
+
and resolved_target.name.startswith(f".{primary.name}-gsd-path-integrate-")
|
|
3757
|
+
)
|
|
3758
|
+
):
|
|
3759
|
+
return GIT_REASONS["worktree"]
|
|
3760
|
+
return None
|
|
3761
|
+
|
|
3762
|
+
|
|
3763
|
+
def _path_has_symlink_component(value):
|
|
3764
|
+
path = Path(value)
|
|
3765
|
+
if not path.is_absolute():
|
|
3766
|
+
path = Path.cwd() / path
|
|
3767
|
+
current = Path(path.anchor)
|
|
3768
|
+
for component in path.parts[1:]:
|
|
3769
|
+
if component in {"", "."}:
|
|
3770
|
+
continue
|
|
3771
|
+
if component == "..":
|
|
3772
|
+
current = current.parent
|
|
3773
|
+
continue
|
|
3774
|
+
current = current / component
|
|
3775
|
+
try:
|
|
3776
|
+
if os.path.lexists(current) and is_link_like(current):
|
|
3777
|
+
return True
|
|
3778
|
+
except OSError:
|
|
3779
|
+
return True
|
|
3780
|
+
return False
|
|
3781
|
+
|
|
3782
|
+
|
|
3783
|
+
def env_wrapper_changes_git_context(segment):
|
|
3784
|
+
"""Whether an env wrapper makes ownership or alias lookup context uncertain."""
|
|
3785
|
+
index = 0
|
|
3786
|
+
while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
|
|
3787
|
+
index += 1
|
|
3788
|
+
if index >= len(segment):
|
|
3789
|
+
return False
|
|
3790
|
+
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
3791
|
+
if executable != "env":
|
|
3792
|
+
return False
|
|
3793
|
+
|
|
3794
|
+
def affects_git_context(name):
|
|
3795
|
+
folded = name.upper()
|
|
3796
|
+
return folded.startswith("GIT_") or folded in {"HOME", "XDG_CONFIG_HOME"}
|
|
3797
|
+
|
|
3798
|
+
index += 1
|
|
3799
|
+
while index < len(segment):
|
|
3800
|
+
token = segment[index]
|
|
3801
|
+
if token == "--":
|
|
3802
|
+
return False
|
|
3803
|
+
if token in {"-i", "--ignore-environment"}:
|
|
3804
|
+
return True
|
|
3805
|
+
if token.startswith("--unset="):
|
|
3806
|
+
if affects_git_context(token.split("=", 1)[1]):
|
|
3807
|
+
return True
|
|
3808
|
+
index += 1
|
|
3809
|
+
continue
|
|
3810
|
+
if token in {"-u", "--unset"}:
|
|
3811
|
+
if index + 1 >= len(segment):
|
|
3812
|
+
return False
|
|
3813
|
+
if affects_git_context(segment[index + 1]):
|
|
3814
|
+
return True
|
|
3815
|
+
index += 2
|
|
3816
|
+
continue
|
|
3817
|
+
if SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
3818
|
+
if affects_git_context(token.partition("=")[0]):
|
|
3819
|
+
return True
|
|
3820
|
+
index += 1
|
|
3821
|
+
continue
|
|
3822
|
+
option = token.split("=", 1)[0]
|
|
3823
|
+
if option in ENV_OPTIONS_WITH_VALUES:
|
|
3824
|
+
index += 1 if "=" in token else 2
|
|
3825
|
+
continue
|
|
3826
|
+
if option in ENV_OPTIONS_WITHOUT_VALUES:
|
|
3827
|
+
index += 1
|
|
3828
|
+
continue
|
|
3829
|
+
return False
|
|
3830
|
+
return False
|
|
3831
|
+
|
|
3832
|
+
|
|
3833
|
+
def ownership_cwd_is_uncertain(tokens, working_directories=None):
|
|
3834
|
+
"""Whether a scoped delete follows a cd whose shell control flow is unclear."""
|
|
3835
|
+
cwd_wrappers = COMMAND_WRAPPERS | {
|
|
3836
|
+
"eval", "source", ".",
|
|
3837
|
+
# These change what a later cd does, or run a cd later.
|
|
3838
|
+
"alias", "trap", "enable", "disable",
|
|
3839
|
+
}
|
|
3840
|
+
segments = []
|
|
3841
|
+
current = []
|
|
3842
|
+
separator = None
|
|
3843
|
+
depth = 0
|
|
3844
|
+
comment = False
|
|
3845
|
+
for token in tokens:
|
|
3846
|
+
# Most shells run nothing from a # word to the end of the line, but
|
|
3847
|
+
# the guard reads that text as commands. A cd there can run or not.
|
|
3848
|
+
if comment and "\n" in token:
|
|
3849
|
+
if not set(token) <= set("|;&()\n\r"):
|
|
3850
|
+
return True
|
|
3851
|
+
comment = False
|
|
3852
|
+
elif comment and token.casefold() in DIRECTORY_CHANGE_COMMANDS:
|
|
3853
|
+
return True
|
|
3854
|
+
if token and set(token) <= set("|;&()\n\r"):
|
|
3855
|
+
if "()" in token:
|
|
3856
|
+
return True # a function definition: its body runs only on a call
|
|
3857
|
+
if current:
|
|
3858
|
+
segments.append((current, separator, depth))
|
|
3859
|
+
current = []
|
|
3860
|
+
if "(" in token:
|
|
3861
|
+
depth += token.count("(")
|
|
3862
|
+
if ")" in token:
|
|
3863
|
+
depth = max(0, depth - token.count(")"))
|
|
3864
|
+
separator = token.strip()
|
|
3865
|
+
else:
|
|
3866
|
+
comment = comment or (
|
|
3867
|
+
token.startswith("#") and getattr(token, "literal", None) != "quoted"
|
|
3868
|
+
)
|
|
3869
|
+
current.append(token)
|
|
3870
|
+
if current:
|
|
3871
|
+
segments.append((current, separator, depth))
|
|
3872
|
+
|
|
3873
|
+
uncertain = False
|
|
3874
|
+
conditional = False
|
|
3875
|
+
current_directories = list(working_directories or [os.getcwd()])
|
|
3876
|
+
initial_symlink = any(
|
|
3877
|
+
_path_has_symlink_component(directory) for directory in current_directories
|
|
3878
|
+
)
|
|
3879
|
+
for index, (segment, previous_separator, group_depth) in enumerate(segments):
|
|
3880
|
+
plain, _exact = _without_redirections(segment)
|
|
3881
|
+
redirected = len(plain) != len(segment)
|
|
3882
|
+
segment = plain
|
|
3883
|
+
if not segment:
|
|
3884
|
+
continue
|
|
3885
|
+
if env_wrapper_changes_git_context(segment):
|
|
3886
|
+
return True
|
|
3887
|
+
if segment[0] == "function":
|
|
3888
|
+
return True
|
|
3889
|
+
if any(token.casefold() in SHELL_CONTROL_WORDS for token in segment):
|
|
3890
|
+
conditional = True
|
|
3891
|
+
while segment and segment[0].casefold() in SHELL_CONTROL_WORDS:
|
|
3892
|
+
segment = segment[1:]
|
|
3893
|
+
invocation = command_invocation(segment)
|
|
3894
|
+
if invocation is None:
|
|
3895
|
+
continue
|
|
3896
|
+
command, arguments = invocation
|
|
3897
|
+
next_separator = (
|
|
3898
|
+
segments[index + 1][1] if index + 1 < len(segments) else None
|
|
3899
|
+
)
|
|
3900
|
+
if command in DIRECTORY_CHANGE_COMMANDS:
|
|
3901
|
+
target = directory_change_target(arguments)
|
|
3902
|
+
absolute = is_absolute_path(target)
|
|
3903
|
+
dotted = target.replace("\\", "/")
|
|
3904
|
+
next_directories = (
|
|
3905
|
+
[target]
|
|
3906
|
+
if absolute
|
|
3907
|
+
else [f"{directory}/{target}" for directory in current_directories]
|
|
3908
|
+
)
|
|
3909
|
+
if (
|
|
3910
|
+
# Only a plain `cd <directory>` has the same effect in each shell.
|
|
3911
|
+
segment[0] != "cd"
|
|
3912
|
+
or redirected
|
|
3913
|
+
or arguments not in ([target], ["--", target])
|
|
3914
|
+
# A shell with CDPATH looks for a plain relative name in other
|
|
3915
|
+
# directories first; ./name and ../name are not looked up there.
|
|
3916
|
+
or not (
|
|
3917
|
+
absolute
|
|
3918
|
+
or dotted in {".", ".."}
|
|
3919
|
+
or dotted.startswith(("./", "../"))
|
|
3920
|
+
)
|
|
3921
|
+
or any(character in target for character in "*?[]{}$")
|
|
3922
|
+
or group_depth > 0
|
|
3923
|
+
or previous_separator in {"&&", "||", "&"}
|
|
3924
|
+
or next_separator in {"||", "&"}
|
|
3925
|
+
or (previous_separator is not None and "|" in previous_separator)
|
|
3926
|
+
or (next_separator is not None and "|" in next_separator)
|
|
3927
|
+
or conditional
|
|
3928
|
+
or initial_symlink
|
|
3929
|
+
or any(_path_has_symlink_component(path) for path in next_directories)
|
|
3930
|
+
):
|
|
3931
|
+
uncertain = True
|
|
3932
|
+
current_directories = next_directories
|
|
3933
|
+
initial_symlink = False
|
|
3934
|
+
if command in cwd_wrappers and index + 1 < len(segments):
|
|
3935
|
+
uncertain = True
|
|
3936
|
+
if uncertain and index + 1 < len(segments):
|
|
3937
|
+
return True
|
|
3938
|
+
if any(token.casefold() in {"fi", "done", "esac"} for token in segment):
|
|
3939
|
+
conditional = False
|
|
3940
|
+
return False
|
|
3941
|
+
|
|
3942
|
+
|
|
3943
|
+
def assignments_change_git_context(assignments):
|
|
3944
|
+
return any(
|
|
3945
|
+
name.upper().startswith("GIT_")
|
|
3946
|
+
or name.upper() in {"HOME", "XDG_CONFIG_HOME"}
|
|
3947
|
+
for name in assignments
|
|
3948
|
+
)
|
|
3949
|
+
|
|
3950
|
+
GIT_CONTEXT_NAME = re.compile(r"GIT_\w+|HOME|XDG_CONFIG_HOME")
|
|
3951
|
+
# export, declare, typeset, local, readonly and unset of these names are
|
|
3952
|
+
# denied where the guard reads assignments.
|
|
3953
|
+
VARIABLE_SETTING_COMMANDS = frozenset(
|
|
3954
|
+
{"read", "getopts", "mapfile", "readarray", "integer", "float", "vared"}
|
|
3955
|
+
)
|
|
3956
|
+
LOOP_VARIABLE_WORDS = frozenset({"for", "select"})
|
|
3957
|
+
|
|
3958
|
+
|
|
3959
|
+
def command_sets_git_context_name(tokens):
|
|
3960
|
+
"""Whether a command that the guard does not track sets a Git context variable."""
|
|
3961
|
+
words = []
|
|
3962
|
+
for token in [*tokens, ";"]:
|
|
3963
|
+
if not (token and set(token) <= set("|;&()\n\r")):
|
|
3964
|
+
words.append(token)
|
|
3965
|
+
continue
|
|
3966
|
+
words, _exact = _without_redirections(words)
|
|
3967
|
+
while words and (
|
|
3968
|
+
words[0].casefold() in SHELL_CONTROL_WORDS - LOOP_VARIABLE_WORDS
|
|
3969
|
+
or words[0] in COMMAND_WRAPPERS
|
|
3970
|
+
or SHELL_ASSIGNMENT_PATTERN.match(words[0])
|
|
3971
|
+
):
|
|
3972
|
+
del words[0]
|
|
3973
|
+
command, operands, words = "".join(words[:1]), words[1:], []
|
|
3974
|
+
if command in LOOP_VARIABLE_WORDS:
|
|
3975
|
+
operands = operands[:1]
|
|
3976
|
+
elif command in {"printf", "print"}:
|
|
3977
|
+
operands = [
|
|
3978
|
+
name for option, name in zip(operands, operands[1:]) if option == "-v"
|
|
3979
|
+
] + [option[2:] for option in operands if option.startswith("-v")]
|
|
3980
|
+
elif command not in VARIABLE_SETTING_COMMANDS:
|
|
3981
|
+
continue
|
|
3982
|
+
if any(GIT_CONTEXT_NAME.fullmatch(operand) for operand in operands):
|
|
3983
|
+
return True
|
|
3984
|
+
return False
|
|
3985
|
+
|
|
3986
|
+
|
|
3987
|
+
def destructive_git_reason(
|
|
3988
|
+
tokens,
|
|
3989
|
+
resolved_aliases=frozenset(),
|
|
3990
|
+
initial_assignments=None,
|
|
3991
|
+
working_directories=None,
|
|
3992
|
+
cwd_reliable=True,
|
|
3993
|
+
git_context_reliable=True,
|
|
3994
|
+
earlier_git=False,
|
|
3995
|
+
fold=True,
|
|
3996
|
+
):
|
|
3997
|
+
# A process substitution has two readings: the tokens as they stand, and
|
|
3998
|
+
# the tokens with each substitution body folded onto its operator.
|
|
3999
|
+
folded = _fold_process_substitutions(tokens) if fold else tokens
|
|
4000
|
+
if len(folded) != len(tokens):
|
|
4001
|
+
reason = destructive_git_reason(
|
|
4002
|
+
tokens,
|
|
4003
|
+
resolved_aliases,
|
|
4004
|
+
initial_assignments=initial_assignments,
|
|
4005
|
+
working_directories=working_directories,
|
|
4006
|
+
cwd_reliable=cwd_reliable,
|
|
4007
|
+
git_context_reliable=git_context_reliable,
|
|
4008
|
+
earlier_git=earlier_git,
|
|
4009
|
+
fold=False,
|
|
4010
|
+
)
|
|
4011
|
+
if reason is not None:
|
|
4012
|
+
return reason
|
|
4013
|
+
tokens = folded
|
|
4014
|
+
cwd_reliable = cwd_reliable and not ownership_cwd_is_uncertain(
|
|
4015
|
+
tokens, working_directories
|
|
4016
|
+
)
|
|
4017
|
+
git_context_reliable = (
|
|
4018
|
+
git_context_reliable and not command_sets_git_context_name(tokens)
|
|
4019
|
+
)
|
|
4020
|
+
this_git = False
|
|
4021
|
+
for segment, directories, assignments, _prefix in shell_segment_contexts(
|
|
4022
|
+
tokens, working_directories or [os.getcwd()], initial_assignments
|
|
4023
|
+
):
|
|
4024
|
+
# Whether a Git command or a wrapped command ran before this segment.
|
|
4025
|
+
earlier_git = earlier_git or this_git
|
|
4026
|
+
# A dollar expansion can introduce literal percent/bang text; the second
|
|
4027
|
+
# expansion pass must not silently reinterpret it as a different path.
|
|
4028
|
+
cwd_reliable = cwd_reliable and not any(
|
|
4029
|
+
CMD_PARAMETER_SYNTAX.search(NAMED_SHELL_PARAMETER_SYNTAX.sub(
|
|
4030
|
+
lambda match: environment_parameter_value(match, assignments), token
|
|
4031
|
+
))
|
|
4032
|
+
for token in segment
|
|
4033
|
+
)
|
|
4034
|
+
# A descriptor word is a file descriptor in bash and an argument in
|
|
4035
|
+
# zsh; a process substitution runs a second command. Each reading is
|
|
4036
|
+
# checked, and a scoped deletion in one of them is denied.
|
|
4037
|
+
descriptor = any(getattr(token, "descriptor", False) for token in segment)
|
|
4038
|
+
if descriptor:
|
|
4039
|
+
readings = [
|
|
4040
|
+
[token for token in segment if not getattr(token, "descriptor", False)],
|
|
4041
|
+
[
|
|
4042
|
+
_without_descriptor(token)
|
|
4043
|
+
if getattr(token, "descriptor", False) else token
|
|
4044
|
+
for token in segment
|
|
4045
|
+
],
|
|
4046
|
+
]
|
|
4047
|
+
else:
|
|
4048
|
+
readings = [token.body for token in segment if getattr(token, "body", None)]
|
|
4049
|
+
for reading in readings:
|
|
4050
|
+
reason = destructive_git_reason(
|
|
4051
|
+
reading,
|
|
4052
|
+
resolved_aliases,
|
|
4053
|
+
initial_assignments=assignments,
|
|
4054
|
+
working_directories=directories,
|
|
4055
|
+
cwd_reliable=False,
|
|
4056
|
+
git_context_reliable=git_context_reliable,
|
|
4057
|
+
earlier_git=earlier_git,
|
|
4058
|
+
)
|
|
4059
|
+
if reason is not None:
|
|
4060
|
+
return reason
|
|
4061
|
+
nested_git = (
|
|
4062
|
+
descriptor or any(_opens_process_substitution(token) for token in segment)
|
|
4063
|
+
) and _holds_git_command(segment, assignments)
|
|
4064
|
+
if descriptor:
|
|
4065
|
+
this_git = nested_git
|
|
4066
|
+
continue
|
|
4067
|
+
# A redirection can stand at each position of a simple command.
|
|
4068
|
+
words, exact = _without_redirections(segment)
|
|
4069
|
+
segment_git_context_reliable = (
|
|
4070
|
+
git_context_reliable
|
|
4071
|
+
and not env_wrapper_changes_git_context(words)
|
|
4072
|
+
and not assignments_change_git_context(assignments)
|
|
4073
|
+
)
|
|
4074
|
+
wrapped = wrapped_command_tokens(words if len(words) > 1 else segment)
|
|
1804
4075
|
if wrapped is not None:
|
|
1805
|
-
reason = destructive_git_reason(
|
|
4076
|
+
reason = destructive_git_reason(
|
|
4077
|
+
wrapped,
|
|
4078
|
+
resolved_aliases,
|
|
4079
|
+
initial_assignments=assignments,
|
|
4080
|
+
working_directories=directories,
|
|
4081
|
+
cwd_reliable=cwd_reliable and exact,
|
|
4082
|
+
git_context_reliable=segment_git_context_reliable,
|
|
4083
|
+
earlier_git=earlier_git,
|
|
4084
|
+
)
|
|
1806
4085
|
if reason is not None:
|
|
1807
4086
|
return reason
|
|
1808
|
-
invocation = git_command(
|
|
4087
|
+
invocation = git_command(words, assignments)
|
|
4088
|
+
this_git = nested_git or wrapped is not None or invocation is not None
|
|
1809
4089
|
if invocation is None:
|
|
1810
4090
|
continue
|
|
1811
4091
|
command, arguments, git_options = invocation
|
|
4092
|
+
# Git matches its own commands by exact case, but alias names in any case.
|
|
4093
|
+
built_in = (
|
|
4094
|
+
command_invocation(words)[1][-len(arguments) - 1] in GIT_BUILT_IN_COMMANDS
|
|
4095
|
+
)
|
|
4096
|
+
if not segment_git_context_reliable and not built_in:
|
|
4097
|
+
return GIT_REASONS["git-environment"]
|
|
4098
|
+
# A scoped deletion needs literal words: the shell can change a variable
|
|
4099
|
+
# in ways the guard does not track (read, +=, for, printf -v).
|
|
4100
|
+
resolvable = cwd_reliable and exact and not any(
|
|
4101
|
+
SHELL_PARAMETER_SYNTAX.search(token) for token in segment
|
|
4102
|
+
)
|
|
4103
|
+
directories = [native_path_text(directory) for directory in directories]
|
|
1812
4104
|
if command == "config":
|
|
1813
4105
|
alias_indexes = [
|
|
1814
4106
|
index
|
|
@@ -1820,16 +4112,32 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
|
|
|
1820
4112
|
"git config alias.* defines an alias the guard cannot inspect; "
|
|
1821
4113
|
"run the underlying git command directly"
|
|
1822
4114
|
)
|
|
1823
|
-
|
|
4115
|
+
# A directory that an earlier segment creates does not exist yet.
|
|
4116
|
+
alias = None
|
|
4117
|
+
if not built_in or all(os.path.isdir(directory) for directory in directories):
|
|
4118
|
+
alias = git_alias(command, git_options, directories)
|
|
1824
4119
|
if alias is not None:
|
|
1825
4120
|
if command in resolved_aliases:
|
|
1826
4121
|
raise ValueError(
|
|
1827
4122
|
f"git alias {command} refers to itself; run the underlying "
|
|
1828
4123
|
"git command directly"
|
|
1829
4124
|
)
|
|
4125
|
+
# Git passes each word of an alias on as an argument; it runs no shell.
|
|
4126
|
+
alias_words = shell_tokens(alias, direct=False)
|
|
4127
|
+
for word in alias_words:
|
|
4128
|
+
word.redirection = False
|
|
1830
4129
|
reason = destructive_git_reason(
|
|
1831
|
-
["git", *git_options, *
|
|
4130
|
+
["git", *git_options, *alias_words, *arguments],
|
|
1832
4131
|
resolved_aliases | {command},
|
|
4132
|
+
initial_assignments=assignments,
|
|
4133
|
+
working_directories=directories,
|
|
4134
|
+
# Non-shell Git aliases do not expand environment parameters.
|
|
4135
|
+
cwd_reliable=resolvable and not (
|
|
4136
|
+
NAMED_SHELL_PARAMETER_SYNTAX.search(alias)
|
|
4137
|
+
or CMD_PARAMETER_SYNTAX.search(alias)
|
|
4138
|
+
),
|
|
4139
|
+
git_context_reliable=segment_git_context_reliable,
|
|
4140
|
+
earlier_git=earlier_git,
|
|
1833
4141
|
)
|
|
1834
4142
|
if reason is not None:
|
|
1835
4143
|
return reason
|
|
@@ -1857,18 +4165,29 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
|
|
|
1857
4165
|
if force_option or force_refspec:
|
|
1858
4166
|
return GIT_REASONS[command]
|
|
1859
4167
|
if command == "branch":
|
|
4168
|
+
# Git accepts each unique prefix of a long option (--del, --forc).
|
|
1860
4169
|
deletes = (
|
|
1861
|
-
"--delete" in arguments
|
|
4170
|
+
any(git_option_in(argument, {"--delete"}) for argument in arguments)
|
|
1862
4171
|
or has_short_option(arguments, "d")
|
|
1863
4172
|
or has_short_option(arguments, "D")
|
|
1864
4173
|
)
|
|
1865
4174
|
forces = (
|
|
1866
|
-
"--force" in arguments
|
|
4175
|
+
any(git_option_in(argument, {"--force"}) for argument in arguments)
|
|
1867
4176
|
or has_short_option(arguments, "f")
|
|
1868
4177
|
or has_short_option(arguments, "D")
|
|
1869
4178
|
)
|
|
1870
4179
|
if deletes and forces:
|
|
1871
|
-
|
|
4180
|
+
if not segment_git_context_reliable:
|
|
4181
|
+
return GIT_REASONS["git-environment"]
|
|
4182
|
+
if not resolvable:
|
|
4183
|
+
return GIT_REASONS["unresolved-target"].format(
|
|
4184
|
+
action="git branch -D"
|
|
4185
|
+
)
|
|
4186
|
+
reason = owned_target_reason(
|
|
4187
|
+
command, arguments, git_options, directories
|
|
4188
|
+
)
|
|
4189
|
+
if reason is not None:
|
|
4190
|
+
return reason
|
|
1872
4191
|
if (
|
|
1873
4192
|
"--move" in arguments
|
|
1874
4193
|
or has_short_option(arguments, "m")
|
|
@@ -1877,10 +4196,39 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
|
|
|
1877
4196
|
return GIT_REASONS["branch-move"]
|
|
1878
4197
|
if command == "update-ref":
|
|
1879
4198
|
if update_ref_deletes(arguments):
|
|
1880
|
-
|
|
4199
|
+
if not segment_git_context_reliable:
|
|
4200
|
+
return GIT_REASONS["git-environment"]
|
|
4201
|
+
if not resolvable:
|
|
4202
|
+
return GIT_REASONS["unresolved-target"].format(
|
|
4203
|
+
action="git update-ref deletion"
|
|
4204
|
+
)
|
|
4205
|
+
# The guard reads each symbolic ref now; an earlier Git command
|
|
4206
|
+
# of this command line can point it at a Path-owned ref later.
|
|
4207
|
+
# (-m can take the text --no-deref as its value.)
|
|
4208
|
+
if earlier_git and (
|
|
4209
|
+
"--no-deref" not in arguments or "-m" in arguments
|
|
4210
|
+
):
|
|
4211
|
+
return GIT_REASONS["update-ref-order"]
|
|
4212
|
+
reason = owned_target_reason(
|
|
4213
|
+
command, arguments, git_options, directories
|
|
4214
|
+
)
|
|
4215
|
+
if reason is not None:
|
|
4216
|
+
return reason
|
|
1881
4217
|
if command == "worktree" and arguments[:1] == ["remove"]:
|
|
1882
|
-
if
|
|
1883
|
-
|
|
4218
|
+
if any(
|
|
4219
|
+
git_option_in(argument, {"--force"}) for argument in arguments
|
|
4220
|
+
) or has_short_option(arguments[1:], "f"):
|
|
4221
|
+
if not segment_git_context_reliable:
|
|
4222
|
+
return GIT_REASONS["git-environment"]
|
|
4223
|
+
if not resolvable:
|
|
4224
|
+
return GIT_REASONS["unresolved-target"].format(
|
|
4225
|
+
action="git worktree remove --force"
|
|
4226
|
+
)
|
|
4227
|
+
reason = owned_target_reason(
|
|
4228
|
+
"worktree", arguments, git_options, directories
|
|
4229
|
+
)
|
|
4230
|
+
if reason is not None:
|
|
4231
|
+
return reason
|
|
1884
4232
|
if command == "stash" and arguments[:1] in (["drop"], ["clear"]):
|
|
1885
4233
|
return GIT_REASONS[command]
|
|
1886
4234
|
if command in {"checkout", "restore"} and any(
|
|
@@ -1983,28 +4331,76 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
|
|
|
1983
4331
|
return False
|
|
1984
4332
|
|
|
1985
4333
|
|
|
1986
|
-
def archive_command_is_read_only(
|
|
4334
|
+
def archive_command_is_read_only(
|
|
4335
|
+
command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS,
|
|
4336
|
+
source=None,
|
|
4337
|
+
):
|
|
4338
|
+
"""`source` is the unexpanded (segment, assignments) whose tokens keep their quote flags."""
|
|
1987
4339
|
if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
|
|
1988
4340
|
return False
|
|
1989
4341
|
if not archive_context:
|
|
1990
4342
|
return True
|
|
1991
|
-
|
|
4343
|
+
invocation = command_invocation(tokens, unwrap_runners=False)
|
|
4344
|
+
if invocation is None:
|
|
4345
|
+
return True
|
|
4346
|
+
executable, arguments = invocation
|
|
4347
|
+
executable = executable.removesuffix(".exe")
|
|
4348
|
+
if executable in DIRECTORY_CHANGE_COMMANDS:
|
|
4349
|
+
return True
|
|
4350
|
+
if executable in {"export", "declare", "typeset", "unset"}:
|
|
4351
|
+
return True
|
|
1992
4352
|
denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
|
|
1993
4353
|
if any(
|
|
1994
4354
|
token == option or token.startswith(f"{option}=")
|
|
1995
|
-
for token in
|
|
4355
|
+
for token in arguments
|
|
1996
4356
|
for option in denied_options
|
|
1997
4357
|
):
|
|
1998
4358
|
return False
|
|
4359
|
+
if executable == "sed":
|
|
4360
|
+
in_place, _inputs, script_is_read_only = sed_arguments(arguments)
|
|
4361
|
+
return not in_place and script_is_read_only
|
|
1999
4362
|
if executable in ARCHIVE_READ_COMMANDS:
|
|
2000
4363
|
return True
|
|
2001
|
-
if executable != "git"
|
|
4364
|
+
if executable != "git":
|
|
4365
|
+
return False
|
|
4366
|
+
git = git_command(*(source or (tokens,)))
|
|
4367
|
+
if git is None:
|
|
2002
4368
|
return False
|
|
2003
|
-
|
|
4369
|
+
subcommand, git_arguments, _options = git
|
|
4370
|
+
if subcommand not in git_commands:
|
|
2004
4371
|
return False
|
|
2005
4372
|
return not any(
|
|
2006
|
-
token
|
|
2007
|
-
|
|
4373
|
+
git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
|
|
4374
|
+
)
|
|
4375
|
+
|
|
4376
|
+
|
|
4377
|
+
def archive_write_attempt(segment, source=None):
|
|
4378
|
+
"""Whether a segment denied in archive context attempts a write there."""
|
|
4379
|
+
if any(
|
|
4380
|
+
token
|
|
4381
|
+
and (set(token) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
4382
|
+
or re.fullmatch(r"\d*[<>]+&?", token))
|
|
4383
|
+
for token in segment
|
|
4384
|
+
):
|
|
4385
|
+
return True
|
|
4386
|
+
invocation = command_invocation(segment)
|
|
4387
|
+
if invocation is None:
|
|
4388
|
+
return False
|
|
4389
|
+
executable, arguments = invocation
|
|
4390
|
+
executable = executable.removesuffix(".exe")
|
|
4391
|
+
if executable in SHELL_WRITE_COMMANDS:
|
|
4392
|
+
return True
|
|
4393
|
+
if executable in IN_PLACE_EDITORS and (
|
|
4394
|
+
has_short_option(arguments, "i")
|
|
4395
|
+
or any(argument.startswith("--in-place") for argument in arguments)
|
|
4396
|
+
):
|
|
4397
|
+
return True
|
|
4398
|
+
git = git_command(*(source or (segment,)))
|
|
4399
|
+
if git is None:
|
|
4400
|
+
return False
|
|
4401
|
+
subcommand, git_arguments, _options = git
|
|
4402
|
+
return subcommand not in ARCHIVE_READ_GIT_COMMANDS or any(
|
|
4403
|
+
git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
|
|
2008
4404
|
)
|
|
2009
4405
|
|
|
2010
4406
|
|
|
@@ -2126,22 +4522,28 @@ def closed_git_listing(tokens):
|
|
|
2126
4522
|
return not positionals
|
|
2127
4523
|
|
|
2128
4524
|
|
|
2129
|
-
def closed_shell_execution_reason(
|
|
4525
|
+
def closed_shell_execution_reason(
|
|
4526
|
+
command, tokens, working_directories, initial_assignments=None
|
|
4527
|
+
):
|
|
2130
4528
|
helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
|
|
2131
4529
|
rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
|
|
2132
4530
|
if rest[:1] == ["pending"]:
|
|
2133
4531
|
helpers = helpers | {"discussion_records.py"}
|
|
2134
4532
|
if bundled_helper_invocation(command, tokens, working_directories, helpers):
|
|
2135
4533
|
return None
|
|
2136
|
-
for segment, directories in
|
|
4534
|
+
for segment, directories, assignments, _prefix in shell_segment_contexts(
|
|
4535
|
+
tokens, working_directories, initial_assignments
|
|
4536
|
+
):
|
|
2137
4537
|
wrapped = wrapped_command_tokens(segment)
|
|
2138
4538
|
if wrapped is not None:
|
|
2139
|
-
reason = closed_shell_execution_reason(
|
|
4539
|
+
reason = closed_shell_execution_reason(
|
|
4540
|
+
shlex.join(wrapped), wrapped, directories, assignments
|
|
4541
|
+
)
|
|
2140
4542
|
if reason:
|
|
2141
4543
|
return reason
|
|
2142
4544
|
continue
|
|
2143
4545
|
metadata_closed = None
|
|
2144
|
-
git = git_command(segment)
|
|
4546
|
+
git = git_command(segment, assignments)
|
|
2145
4547
|
checked_segment = segment
|
|
2146
4548
|
if git is not None:
|
|
2147
4549
|
subcommand, arguments, options = git
|
|
@@ -2180,6 +4582,7 @@ def closed_shell_execution_reason(command, tokens, working_directories):
|
|
|
2180
4582
|
def command_denial(command, working_directories, allow_destructive=True):
|
|
2181
4583
|
"""Return why a shell command is denied, or None when it may run."""
|
|
2182
4584
|
working_directories = working_directories or [os.getcwd()]
|
|
4585
|
+
command = re.sub(r"(?i:(\.project)\\(archive))", r"\1/\2", command)
|
|
2183
4586
|
outer, substitutions = split_command_substitutions(command)
|
|
2184
4587
|
try:
|
|
2185
4588
|
tokens = shell_tokens(outer)
|
|
@@ -2188,6 +4591,7 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
2188
4591
|
for interpreter in ("python3", "python", "py -3")
|
|
2189
4592
|
):
|
|
2190
4593
|
return None
|
|
4594
|
+
bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
|
|
2191
4595
|
destructive = list(destructive_shell_invocations(tokens))
|
|
2192
4596
|
if destructive and (
|
|
2193
4597
|
not allow_destructive
|
|
@@ -2208,19 +4612,35 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
2208
4612
|
reason = command_denial(inner, working_directories, allow_destructive=False)
|
|
2209
4613
|
if reason is not None:
|
|
2210
4614
|
return reason
|
|
2211
|
-
|
|
2212
|
-
any(working_directory_in_archive(path) for path in working_directories)
|
|
2213
|
-
or bool(ARCHIVE_REFERENCE.search(outer))
|
|
2214
|
-
or command_references_archive(tokens, working_directories)
|
|
2215
|
-
or unresolved_archive_expansion(outer, tokens, working_directories)
|
|
2216
|
-
)
|
|
2217
|
-
if not archive_command_is_read_only(command, tokens, archive_context) and not (
|
|
2218
|
-
archive_context and bundled_helper_invocation(command, tokens, working_directories)
|
|
2219
|
-
):
|
|
2220
|
-
return ARCHIVE_REASON
|
|
2221
|
-
reason = destructive_git_reason(tokens) or protected_shell_write_reason(
|
|
4615
|
+
for segment, directories, assignments, prefix in shell_segment_contexts(
|
|
2222
4616
|
tokens, working_directories
|
|
2223
|
-
)
|
|
4617
|
+
):
|
|
4618
|
+
resolved_segment = [
|
|
4619
|
+
expand_environment_parameters(token, assignments) for token in segment
|
|
4620
|
+
]
|
|
4621
|
+
archive_context = (
|
|
4622
|
+
any(working_directory_in_archive(path) for path in directories)
|
|
4623
|
+
or segment_references_archive(segment, directories, assignments)
|
|
4624
|
+
or any(
|
|
4625
|
+
ARCHIVE_REFERENCE.search(value)
|
|
4626
|
+
or path_in_archive(value, directories, ancestors=False)
|
|
4627
|
+
for value in prefix.values()
|
|
4628
|
+
)
|
|
4629
|
+
)
|
|
4630
|
+
if not archive_command_is_read_only(
|
|
4631
|
+
shlex.join(resolved_segment), resolved_segment, archive_context,
|
|
4632
|
+
source=(segment, assignments),
|
|
4633
|
+
) and not (
|
|
4634
|
+
archive_context
|
|
4635
|
+
and bundled_helper
|
|
4636
|
+
):
|
|
4637
|
+
if archive_write_attempt(resolved_segment, (segment, assignments)):
|
|
4638
|
+
return ARCHIVE_REASON
|
|
4639
|
+
return ARCHIVE_UNPROVEN_READ_REASON
|
|
4640
|
+
reason = destructive_git_reason(
|
|
4641
|
+
tokens, working_directories=working_directories,
|
|
4642
|
+
cwd_reliable=not shell_parameter_quoting_uncertain(outer),
|
|
4643
|
+
) or protected_shell_write_reason(tokens, working_directories)
|
|
2224
4644
|
except ValueError as error:
|
|
2225
4645
|
raise ValueError(describe_substitutions(str(error), substitutions)) from None
|
|
2226
4646
|
return reason and describe_substitutions(reason, substitutions)
|