@opengsd/gsd-path 1.4.0 → 1.5.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 +325 -344
- package/DOCS.md +12 -2
- package/FULL.md +3 -2
- package/HOOKS.md +95 -8
- package/QUICK.md +1 -1
- package/README.md +78 -110
- package/RUNTIME.md +1 -1
- package/UPDATE.md +4 -3
- package/WORKFLOW.md +22 -1
- package/package.json +5 -2
- package/platforms/shared-agents/dispatch.md +13 -0
- package/scripts/_common.py +149 -1
- package/scripts/archive_milestone.py +14 -13
- package/scripts/build_recovery.py +96 -31
- package/scripts/build_state.py +79 -3
- package/scripts/check_docs_audit.py +77 -4
- package/scripts/check_handoffs.py +546 -30
- package/scripts/check_task_briefs.py +279 -23
- package/scripts/detect_project.py +243 -18
- package/scripts/discussion_validate.py +86 -29
- package/scripts/dispatch_driver.py +392 -22
- package/scripts/git_guard.py +3 -3
- package/scripts/guard_hook.py +1416 -165
- package/scripts/install.mjs +23 -8
- package/scripts/install.py +49 -9
- package/scripts/integration.py +146 -1
- package/scripts/isolation.py +392 -60
- package/scripts/loop_run.py +16 -4
- package/scripts/members.py +11 -9
- package/scripts/path_config.py +3 -3
- package/scripts/pipeline_git.py +63 -12
- package/scripts/pipeline_state.py +111 -33
- package/scripts/pipeline_undo.py +101 -8
- package/scripts/runtime_store.py +20 -2
- package/scripts/skill-resources.json +9 -2
- package/scripts/state_checkpoint.py +9 -27
- package/scripts/status_runtime.py +22 -4
- package/scripts/task_context.py +12 -3
- package/scripts/wizard.mjs +1 -0
- package/scripts/workflow_run.py +5 -2
- package/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path/DEFINE.md +28 -3
- package/skills/gsd-path/PLAN.md +11 -3
- package/skills/gsd-path/RESEARCH.md +11 -0
- package/skills/gsd-path/SHIP.md +6 -1
- package/skills/gsd-path/references/build-native.md +16 -1
- package/skills/gsd-path/references/dispatch.md +13 -0
- package/skills/gsd-path/references/docs-auditor.md +5 -0
- package/skills/gsd-path/references/planner.md +14 -4
- package/skills/gsd-path/references/reviewer-final.md +20 -2
- package/skills/gsd-path/references/reviewer-wave.md +15 -4
- package/skills/gsd-path/references/reviewer.md +3 -1
- package/skills/gsd-path/references/spec-probes.md +150 -0
- package/skills/gsd-path/scripts/_common.py +149 -1
- package/skills/gsd-path/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path/scripts/build_recovery.py +96 -31
- package/skills/gsd-path/scripts/build_state.py +79 -3
- package/skills/gsd-path/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path/scripts/detect_project.py +243 -18
- package/skills/gsd-path/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path/scripts/dispatch_driver.py +392 -22
- package/skills/gsd-path/scripts/git_guard.py +3 -3
- package/skills/gsd-path/scripts/integration.py +146 -1
- package/skills/gsd-path/scripts/isolation.py +392 -60
- package/skills/gsd-path/scripts/loop_run.py +16 -4
- package/skills/gsd-path/scripts/members.py +11 -9
- package/skills/gsd-path/scripts/path_config.py +3 -3
- package/skills/gsd-path/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path/scripts/state_checkpoint.py +9 -27
- package/skills/gsd-path/scripts/status_runtime.py +22 -4
- package/skills/gsd-path/scripts/task_context.py +12 -3
- package/skills/gsd-path/scripts/workflow_run.py +5 -2
- 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 +1 -1
- package/skills/gsd-path/templates/wave-review.md +16 -6
- package/skills/gsd-path-build/references/build-native.md +16 -1
- package/skills/gsd-path-build/references/dispatch.md +13 -0
- package/skills/gsd-path-build/references/reviewer-final.md +20 -2
- package/skills/gsd-path-build/references/reviewer-wave.md +15 -4
- package/skills/gsd-path-build/references/reviewer.md +3 -1
- package/skills/gsd-path-build/references/spec-probes.md +150 -0
- package/skills/gsd-path-build/scripts/_common.py +149 -1
- package/skills/gsd-path-build/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-build/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-build/scripts/build_state.py +79 -3
- package/skills/gsd-path-build/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-build/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-build/scripts/detect_project.py +243 -18
- package/skills/gsd-path-build/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-build/scripts/dispatch_driver.py +392 -22
- package/skills/gsd-path-build/scripts/integration.py +146 -1
- package/skills/gsd-path-build/scripts/isolation.py +392 -60
- package/skills/gsd-path-build/scripts/loop_run.py +16 -4
- package/skills/gsd-path-build/scripts/members.py +11 -9
- package/skills/gsd-path-build/scripts/path_config.py +3 -3
- package/skills/gsd-path-build/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-build/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-build/scripts/state_checkpoint.py +9 -27
- package/skills/gsd-path-build/scripts/task_context.py +12 -3
- package/skills/gsd-path-build/scripts/workflow_run.py +5 -2
- package/skills/gsd-path-build/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-build/templates/wave-review.md +16 -6
- package/skills/gsd-path-decide/references/dispatch.md +13 -0
- package/skills/gsd-path-decide/scripts/_common.py +149 -1
- package/skills/gsd-path-decide/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-decide/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-decide/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-decide/scripts/detect_project.py +243 -18
- package/skills/gsd-path-decide/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-decide/scripts/integration.py +146 -1
- package/skills/gsd-path-decide/scripts/isolation.py +392 -60
- package/skills/gsd-path-decide/scripts/loop_run.py +16 -4
- package/skills/gsd-path-decide/scripts/members.py +11 -9
- package/skills/gsd-path-decide/scripts/path_config.py +3 -3
- package/skills/gsd-path-decide/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-decide/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +9 -27
- package/skills/gsd-path-decide/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-define/SKILL.md +28 -3
- package/skills/gsd-path-define/references/spec-probes.md +150 -0
- package/skills/gsd-path-define/scripts/_common.py +149 -1
- package/skills/gsd-path-define/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-define/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-define/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-define/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-define/scripts/detect_project.py +243 -18
- package/skills/gsd-path-define/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-define/scripts/integration.py +146 -1
- package/skills/gsd-path-define/scripts/isolation.py +392 -60
- package/skills/gsd-path-define/scripts/loop_run.py +16 -4
- package/skills/gsd-path-define/scripts/members.py +11 -9
- package/skills/gsd-path-define/scripts/path_config.py +3 -3
- package/skills/gsd-path-define/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-define/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-define/scripts/state_checkpoint.py +9 -27
- 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 +1 -1
- package/skills/gsd-path-discuss/scripts/_common.py +149 -1
- package/skills/gsd-path-discuss/scripts/loop_run.py +16 -4
- 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/scripts/_common.py +149 -1
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +243 -18
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-docs-audit/scripts/integration.py +146 -1
- package/skills/gsd-path-docs-audit/scripts/isolation.py +392 -60
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +16 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +11 -9
- package/skills/gsd-path-docs-audit/scripts/path_config.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +9 -27
- 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/scripts/_common.py +149 -1
- package/skills/gsd-path-forensics/scripts/loop_run.py +16 -4
- package/skills/gsd-path-forensics/scripts/path_config.py +3 -3
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +101 -8
- 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/scripts/_common.py +149 -1
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-inspect/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-inspect/scripts/detect_project.py +243 -18
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-inspect/scripts/integration.py +146 -1
- package/skills/gsd-path-inspect/scripts/isolation.py +392 -60
- package/skills/gsd-path-inspect/scripts/loop_run.py +16 -4
- package/skills/gsd-path-inspect/scripts/members.py +11 -9
- package/skills/gsd-path-inspect/scripts/path_config.py +3 -3
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +9 -27
- package/skills/gsd-path-inspect/scripts/workflow_run.py +5 -2
- 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 +1 -1
- package/skills/gsd-path-loop/scripts/_common.py +149 -1
- package/skills/gsd-path-loop/scripts/loop_run.py +16 -4
- 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/scripts/_common.py +149 -1
- package/skills/gsd-path-migrate/scripts/loop_run.py +16 -4
- package/skills/gsd-path-migrate/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/SKILL.md +11 -3
- package/skills/gsd-path-plan/references/dispatch.md +13 -0
- package/skills/gsd-path-plan/references/planner.md +14 -4
- package/skills/gsd-path-plan/references/spec-probes.md +150 -0
- package/skills/gsd-path-plan/scripts/_common.py +149 -1
- package/skills/gsd-path-plan/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-plan/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-plan/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-plan/scripts/detect_project.py +243 -18
- package/skills/gsd-path-plan/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-plan/scripts/integration.py +146 -1
- package/skills/gsd-path-plan/scripts/isolation.py +392 -60
- package/skills/gsd-path-plan/scripts/loop_run.py +16 -4
- package/skills/gsd-path-plan/scripts/members.py +11 -9
- package/skills/gsd-path-plan/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-plan/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +9 -27
- package/skills/gsd-path-plan/scripts/workflow_run.py +5 -2
- 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-research/SKILL.md +11 -0
- package/skills/gsd-path-research/references/dispatch.md +13 -0
- package/skills/gsd-path-research/scripts/_common.py +149 -1
- package/skills/gsd-path-research/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-research/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-research/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-research/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-research/scripts/detect_project.py +243 -18
- package/skills/gsd-path-research/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-research/scripts/integration.py +146 -1
- package/skills/gsd-path-research/scripts/isolation.py +392 -60
- package/skills/gsd-path-research/scripts/loop_run.py +16 -4
- package/skills/gsd-path-research/scripts/members.py +11 -9
- package/skills/gsd-path-research/scripts/path_config.py +3 -3
- package/skills/gsd-path-research/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-research/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-research/scripts/state_checkpoint.py +9 -27
- 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/references/dispatch.md +13 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +149 -1
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-roadmap/scripts/detect_project.py +243 -18
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-roadmap/scripts/integration.py +146 -1
- package/skills/gsd-path-roadmap/scripts/isolation.py +392 -60
- package/skills/gsd-path-roadmap/scripts/loop_run.py +16 -4
- package/skills/gsd-path-roadmap/scripts/members.py +11 -9
- package/skills/gsd-path-roadmap/scripts/path_config.py +3 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +9 -27
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-ship/SKILL.md +6 -1
- package/skills/gsd-path-ship/references/dispatch.md +13 -0
- package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
- package/skills/gsd-path-ship/references/reviewer-wave.md +15 -4
- package/skills/gsd-path-ship/references/reviewer.md +3 -1
- package/skills/gsd-path-ship/references/spec-probes.md +150 -0
- package/skills/gsd-path-ship/scripts/_common.py +149 -1
- package/skills/gsd-path-ship/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-ship/scripts/build_recovery.py +96 -31
- package/skills/gsd-path-ship/scripts/build_state.py +79 -3
- package/skills/gsd-path-ship/scripts/check_handoffs.py +546 -30
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +279 -23
- package/skills/gsd-path-ship/scripts/detect_project.py +243 -18
- package/skills/gsd-path-ship/scripts/discussion_validate.py +86 -29
- package/skills/gsd-path-ship/scripts/integration.py +146 -1
- package/skills/gsd-path-ship/scripts/isolation.py +392 -60
- package/skills/gsd-path-ship/scripts/loop_run.py +16 -4
- package/skills/gsd-path-ship/scripts/members.py +11 -9
- package/skills/gsd-path-ship/scripts/path_config.py +3 -3
- package/skills/gsd-path-ship/scripts/pipeline_git.py +63 -12
- package/skills/gsd-path-ship/scripts/pipeline_state.py +111 -33
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +9 -27
- package/skills/gsd-path-ship/scripts/workflow_run.py +5 -2
- 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 +16 -6
- package/skills/gsd-path-undo/scripts/_common.py +149 -1
- package/skills/gsd-path-undo/scripts/loop_run.py +16 -4
- package/skills/gsd-path-undo/scripts/path_config.py +3 -3
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +101 -8
- package/skills/gsd-path-undo/scripts/worktree_paths.py +54 -2
- package/skills/path/DEFINE.md +28 -3
- package/skills/path/PLAN.md +11 -3
- package/skills/path/RESEARCH.md +11 -0
- package/skills/path/SHIP.md +6 -1
- package/skills/path/references/build-native.md +16 -1
- package/skills/path/references/dispatch.md +13 -0
- package/skills/path/references/docs-auditor.md +5 -0
- package/skills/path/references/planner.md +14 -4
- package/skills/path/references/reviewer-final.md +20 -2
- package/skills/path/references/reviewer-wave.md +15 -4
- package/skills/path/references/reviewer.md +3 -1
- package/skills/path/references/spec-probes.md +150 -0
- package/skills/path/scripts/_common.py +149 -1
- package/skills/path/scripts/archive_milestone.py +14 -13
- package/skills/path/scripts/build_recovery.py +96 -31
- package/skills/path/scripts/build_state.py +79 -3
- package/skills/path/scripts/check_docs_audit.py +77 -4
- package/skills/path/scripts/check_handoffs.py +546 -30
- package/skills/path/scripts/check_task_briefs.py +279 -23
- package/skills/path/scripts/detect_project.py +243 -18
- package/skills/path/scripts/discussion_validate.py +86 -29
- package/skills/path/scripts/dispatch_driver.py +392 -22
- package/skills/path/scripts/git_guard.py +3 -3
- package/skills/path/scripts/integration.py +146 -1
- package/skills/path/scripts/isolation.py +392 -60
- package/skills/path/scripts/loop_run.py +16 -4
- package/skills/path/scripts/members.py +11 -9
- package/skills/path/scripts/path_config.py +3 -3
- package/skills/path/scripts/pipeline_git.py +63 -12
- package/skills/path/scripts/pipeline_state.py +111 -33
- package/skills/path/scripts/state_checkpoint.py +9 -27
- package/skills/path/scripts/status_runtime.py +22 -4
- package/skills/path/scripts/task_context.py +12 -3
- package/skills/path/scripts/workflow_run.py +5 -2
- 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 +1 -1
- package/skills/path/templates/wave-review.md +16 -6
- 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
|
)
|
|
@@ -306,6 +451,8 @@ MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
|
|
|
306
451
|
UNVALIDATED_EXECUTION_REASONS = {
|
|
307
452
|
".": SCRIPT_FILE_REASON,
|
|
308
453
|
"source": SCRIPT_FILE_REASON,
|
|
454
|
+
"readonly": "readonly changes shell assignment behavior the guard cannot validate; "
|
|
455
|
+
"avoid readonly declarations",
|
|
309
456
|
"start": "start launches a detached process the guard cannot inspect; run the program directly",
|
|
310
457
|
"setenv": ENV_BUILTIN_REASON,
|
|
311
458
|
"unsetenv": ENV_BUILTIN_REASON,
|
|
@@ -370,7 +517,64 @@ SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
|
|
|
370
517
|
}
|
|
371
518
|
)
|
|
372
519
|
IN_PLACE_EDITORS = frozenset({"perl", "sed"})
|
|
520
|
+
SED_SAFE_OPTIONS = frozenset(
|
|
521
|
+
{
|
|
522
|
+
"--debug",
|
|
523
|
+
"--help",
|
|
524
|
+
"--null-data",
|
|
525
|
+
"--posix",
|
|
526
|
+
"--quiet",
|
|
527
|
+
"--regexp-extended",
|
|
528
|
+
"--sandbox",
|
|
529
|
+
"--separate",
|
|
530
|
+
"--silent",
|
|
531
|
+
"--unbuffered",
|
|
532
|
+
"--version",
|
|
533
|
+
"-E",
|
|
534
|
+
"-n",
|
|
535
|
+
"-r",
|
|
536
|
+
"-s",
|
|
537
|
+
"-u",
|
|
538
|
+
}
|
|
539
|
+
)
|
|
373
540
|
FIND_EXECUTION_ACTIONS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
|
541
|
+
# Programs that run the command in their arguments, with the options of each
|
|
542
|
+
# that take a separate value. The guard checks the command they run.
|
|
543
|
+
EXEC_PREFIX_VALUE_OPTIONS = {
|
|
544
|
+
"nice": frozenset({"-n", "--adjustment"}),
|
|
545
|
+
"nohup": frozenset(),
|
|
546
|
+
"setsid": frozenset(),
|
|
547
|
+
"stdbuf": frozenset({"-i", "-o", "-e", "--input", "--output", "--error"}),
|
|
548
|
+
"sudo": frozenset({
|
|
549
|
+
"-u", "-g", "-h", "-p", "-C", "-T", "-U", "-r", "-t",
|
|
550
|
+
"--user", "--group", "--host", "--prompt", "--close-from",
|
|
551
|
+
"--command-timeout", "--other-user", "--role", "--type",
|
|
552
|
+
}),
|
|
553
|
+
"time": frozenset({"-f", "--format"}),
|
|
554
|
+
"timeout": frozenset({"-s", "-k", "--signal", "--kill-after"}),
|
|
555
|
+
"watch": frozenset({"-n", "-q", "--interval", "--equexit"}),
|
|
556
|
+
}
|
|
557
|
+
# Runner options that write, edit or enter a file or directory by themselves.
|
|
558
|
+
EXEC_PREFIX_FILE_OPTIONS = {
|
|
559
|
+
"sudo": frozenset({"-e", "-D", "-R", "--edit", "--chdir", "--chroot"}),
|
|
560
|
+
"time": frozenset({"-o", "--output"}),
|
|
561
|
+
"watch": frozenset({"-s", "--shotsdir"}),
|
|
562
|
+
}
|
|
563
|
+
# The short option letters of each runner that take no value.
|
|
564
|
+
EXEC_PREFIX_FLAG_LETTERS = {
|
|
565
|
+
"nice": "",
|
|
566
|
+
"nohup": "",
|
|
567
|
+
"setsid": "cfw",
|
|
568
|
+
"stdbuf": "",
|
|
569
|
+
"sudo": "AbBEHiKklnNPSsVv",
|
|
570
|
+
"time": "ahlpqvV",
|
|
571
|
+
"timeout": "fpv",
|
|
572
|
+
"watch": "bcCdegprtwx",
|
|
573
|
+
}
|
|
574
|
+
EXEC_PREFIX_INTERVAL_OPTIONS = frozenset({"-n", "--interval"})
|
|
575
|
+
RUNNER_DURATION = re.compile(
|
|
576
|
+
r"(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?)[smhd]?", re.IGNORECASE
|
|
577
|
+
)
|
|
374
578
|
SHELL_CONTROL_WORDS = frozenset(
|
|
375
579
|
{
|
|
376
580
|
"!",
|
|
@@ -925,30 +1129,29 @@ def closed_target_reason(targets):
|
|
|
925
1129
|
return None
|
|
926
1130
|
|
|
927
1131
|
|
|
928
|
-
def
|
|
929
|
-
|
|
930
|
-
if reason:
|
|
931
|
-
deny(reason)
|
|
932
|
-
classified = pipeline_target_kinds((path, working_directories) for path in paths)
|
|
1132
|
+
def pipeline_product_phase_reason(classified):
|
|
1133
|
+
"""Why product writes are denied outside the routed build phase, or None when allowed."""
|
|
933
1134
|
if classified is None:
|
|
934
|
-
return
|
|
1135
|
+
return None
|
|
935
1136
|
repo, state, kinds = classified
|
|
936
1137
|
for path, kind in kinds:
|
|
937
1138
|
if kind == "protected":
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
if all(kind == "external" for kind in
|
|
941
|
-
return
|
|
1139
|
+
return f"{CONTROL_FILE_REASON} {path}"
|
|
1140
|
+
kind_values = [kind for _, kind in kinds]
|
|
1141
|
+
if all(kind == "external" for kind in kind_values):
|
|
1142
|
+
return None
|
|
1143
|
+
if not any(kind == "product" for kind in kind_values):
|
|
1144
|
+
return None
|
|
942
1145
|
try:
|
|
943
1146
|
status = project_status(repo)
|
|
944
1147
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
945
|
-
|
|
1148
|
+
return REENTRY_FAILURE_REASON
|
|
946
1149
|
route = status.get("route")
|
|
947
1150
|
state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
|
|
948
1151
|
completion = status.get("completion")
|
|
949
1152
|
if (state_data.get("phase") == "shipped"
|
|
950
1153
|
and isinstance(completion, dict) and completion.get("status") == "verified"):
|
|
951
|
-
return
|
|
1154
|
+
return None
|
|
952
1155
|
routed_build = (
|
|
953
1156
|
state_data.get("phase") == "build"
|
|
954
1157
|
and isinstance(route, dict)
|
|
@@ -956,20 +1159,31 @@ def enforce_pipeline_reentry(paths, working_directories):
|
|
|
956
1159
|
and route.get("phase") == "build"
|
|
957
1160
|
)
|
|
958
1161
|
if routed_build:
|
|
959
|
-
return
|
|
960
|
-
if all(kind in {"external", "artifact"} for kind in
|
|
961
|
-
return
|
|
1162
|
+
return None
|
|
1163
|
+
if all(kind in {"external", "artifact"} for kind in kind_values):
|
|
1164
|
+
return None
|
|
962
1165
|
phase = state_data.get("phase", "unknown")
|
|
963
1166
|
if isinstance(route, dict) and route.get("action") != "run-phase":
|
|
964
1167
|
next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
|
|
965
1168
|
else:
|
|
966
1169
|
next_step = status.get("next_skill") or "gsd-path"
|
|
967
|
-
|
|
1170
|
+
return (
|
|
968
1171
|
f"GSD Path is {phase}; direct product-file changes require the routed "
|
|
969
1172
|
f"build phase. Review {status.get('path', state)}; next: {next_step}"
|
|
970
1173
|
)
|
|
971
1174
|
|
|
972
1175
|
|
|
1176
|
+
def enforce_pipeline_reentry(paths, working_directories):
|
|
1177
|
+
reason = closed_target_reason((path, working_directories) for path in paths)
|
|
1178
|
+
if reason:
|
|
1179
|
+
deny(reason)
|
|
1180
|
+
reason = pipeline_product_phase_reason(
|
|
1181
|
+
pipeline_target_kinds((path, working_directories) for path in paths)
|
|
1182
|
+
)
|
|
1183
|
+
if reason:
|
|
1184
|
+
deny(reason)
|
|
1185
|
+
|
|
1186
|
+
|
|
973
1187
|
def in_archive(path):
|
|
974
1188
|
# Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
|
|
975
1189
|
normalized = normalize_posix(path).casefold()
|
|
@@ -1147,10 +1361,10 @@ def directory_change_target(arguments):
|
|
|
1147
1361
|
return operands[-1]
|
|
1148
1362
|
|
|
1149
1363
|
|
|
1150
|
-
def segment_directories(tokens, working_directories):
|
|
1364
|
+
def segment_directories(tokens, working_directories, contexts=None):
|
|
1151
1365
|
"""Yield each command segment with the working directories in effect for it."""
|
|
1152
1366
|
current_directories = list(working_directories)
|
|
1153
|
-
for segment in command_segments(tokens):
|
|
1367
|
+
for position, segment in enumerate(command_segments(tokens)):
|
|
1154
1368
|
yield segment, current_directories
|
|
1155
1369
|
invocation = command_invocation(segment)
|
|
1156
1370
|
if invocation is None:
|
|
@@ -1163,16 +1377,31 @@ def segment_directories(tokens, working_directories):
|
|
|
1163
1377
|
if command not in DIRECTORY_CHANGE_COMMANDS:
|
|
1164
1378
|
continue
|
|
1165
1379
|
target = directory_change_target(arguments)
|
|
1166
|
-
|
|
1380
|
+
tracked = {}
|
|
1381
|
+
if contexts is not None and position < len(contexts):
|
|
1382
|
+
tracked = {
|
|
1383
|
+
name: value
|
|
1384
|
+
for name, value in contexts[position].items()
|
|
1385
|
+
if name not in SHELL_DIRECTORY_VARIABLES
|
|
1386
|
+
}
|
|
1387
|
+
expanded = expand_environment_parameters(target, tracked, tracked_only=True)
|
|
1388
|
+
if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(expanded):
|
|
1167
1389
|
raise ValueError(
|
|
1168
1390
|
f"cd target {target} cannot be resolved by the guard; "
|
|
1169
1391
|
"run that command first and cd to the literal result"
|
|
1170
1392
|
)
|
|
1171
|
-
if
|
|
1172
|
-
|
|
1393
|
+
if (
|
|
1394
|
+
(SHELL_PARAMETER_SYNTAX.search(target) or CMD_PARAMETER_SYNTAX.search(target))
|
|
1395
|
+
and any(character.isspace() or character in "*?[]" for character in expanded)
|
|
1396
|
+
):
|
|
1397
|
+
raise ValueError(
|
|
1398
|
+
f"cd target {target} may split or glob; pass a literal path"
|
|
1399
|
+
)
|
|
1400
|
+
if is_absolute_path(expanded):
|
|
1401
|
+
current_directories = [expanded]
|
|
1173
1402
|
else:
|
|
1174
1403
|
bases = current_directories or ["."]
|
|
1175
|
-
current_directories = [f"{base}/{
|
|
1404
|
+
current_directories = [f"{base}/{expanded}" for base in bases]
|
|
1176
1405
|
|
|
1177
1406
|
|
|
1178
1407
|
def command_can_destroy_files(invocation):
|
|
@@ -1188,9 +1417,11 @@ def command_can_destroy_files(invocation):
|
|
|
1188
1417
|
def literal_path(operand):
|
|
1189
1418
|
if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
|
|
1190
1419
|
operand = operand[1:-1]
|
|
1191
|
-
|
|
1420
|
+
# A space is safe here: the operand is one shlex unit, so it cannot carry a
|
|
1421
|
+
# second word; injection-relevant characters stay excluded.
|
|
1422
|
+
pattern = r"[A-Za-z0-9._/ -]+"
|
|
1192
1423
|
if os.name == "nt":
|
|
1193
|
-
pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._
|
|
1424
|
+
pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\ -]+"
|
|
1194
1425
|
return operand if re.fullmatch(pattern, operand) is not None else None
|
|
1195
1426
|
|
|
1196
1427
|
|
|
@@ -1203,19 +1434,27 @@ def literal_destructive_operand(operand):
|
|
|
1203
1434
|
return literal
|
|
1204
1435
|
|
|
1205
1436
|
|
|
1206
|
-
def
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1437
|
+
def segment_references_archive(segment, directories, assignments=None):
|
|
1438
|
+
invocation = command_invocation(segment)
|
|
1439
|
+
ancestors = command_can_destroy_files(invocation)
|
|
1440
|
+
# A here-document body survives tokenization only inside a wrapped payload
|
|
1441
|
+
# (bash -c '...'), where it is data, not commands; keep its text from
|
|
1442
|
+
# flipping the segment into archive context.
|
|
1443
|
+
def searchable(token):
|
|
1444
|
+
return strip_heredoc_bodies(token) if "\n" in token else token
|
|
1445
|
+
|
|
1446
|
+
operands = [
|
|
1447
|
+
expand_environment_parameters(searchable(token), assignments)
|
|
1448
|
+
for token in segment
|
|
1449
|
+
]
|
|
1450
|
+
if ancestors:
|
|
1451
|
+
resolved = command_invocation(operands)
|
|
1452
|
+
operands = [literal_destructive_operand(operand) for operand in resolved[1]]
|
|
1453
|
+
if any(path_in_archive(operand, directories, ancestors) for operand in operands):
|
|
1454
|
+
return True
|
|
1455
|
+
return unresolved_archive_expansion(
|
|
1456
|
+
" ".join(searchable(token) for token in segment), assignments
|
|
1457
|
+
)
|
|
1219
1458
|
|
|
1220
1459
|
|
|
1221
1460
|
def copy_destinations(arguments, directories, assignments=None):
|
|
@@ -1286,43 +1525,258 @@ def copy_destinations(arguments, directories, assignments=None):
|
|
|
1286
1525
|
yield str(output / entry.relative_to(source_path))
|
|
1287
1526
|
|
|
1288
1527
|
|
|
1528
|
+
def sed_delimited_end(script, index, delimiter):
|
|
1529
|
+
while index < len(script):
|
|
1530
|
+
if script[index] == "\\":
|
|
1531
|
+
index += 2
|
|
1532
|
+
elif script[index] == delimiter:
|
|
1533
|
+
return index + 1
|
|
1534
|
+
else:
|
|
1535
|
+
index += 1
|
|
1536
|
+
return None
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def sed_address_end(script, index):
|
|
1540
|
+
if index >= len(script):
|
|
1541
|
+
return index
|
|
1542
|
+
if script[index].isdigit():
|
|
1543
|
+
while index < len(script) and script[index].isdigit():
|
|
1544
|
+
index += 1
|
|
1545
|
+
if index < len(script) and script[index] == "~":
|
|
1546
|
+
index += 1
|
|
1547
|
+
while index < len(script) and script[index].isdigit():
|
|
1548
|
+
index += 1
|
|
1549
|
+
elif script[index] == "$":
|
|
1550
|
+
index += 1
|
|
1551
|
+
elif script[index] == "/":
|
|
1552
|
+
end = sed_delimited_end(script, index + 1, "/")
|
|
1553
|
+
return len(script) if end is None else end
|
|
1554
|
+
elif script[index] == "\\" and index + 1 < len(script):
|
|
1555
|
+
end = sed_delimited_end(script, index + 2, script[index + 1])
|
|
1556
|
+
return len(script) if end is None else end
|
|
1557
|
+
return index
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def sed_script_is_read_only(script):
|
|
1561
|
+
"""Accept only sed scripts whose commands cannot write or execute."""
|
|
1562
|
+
# Sed blocks require recursively validating their nested command grammar;
|
|
1563
|
+
# reject them until that grammar is modeled explicitly.
|
|
1564
|
+
safe_commands = frozenset("aAcCdDgGhHiIlLnNpPqQrstTxv=:")
|
|
1565
|
+
index = 0
|
|
1566
|
+
while index < len(script):
|
|
1567
|
+
while index < len(script) and (script[index].isspace() or script[index] == ";"):
|
|
1568
|
+
index += 1
|
|
1569
|
+
if index >= len(script):
|
|
1570
|
+
break
|
|
1571
|
+
address_start = index
|
|
1572
|
+
index = sed_address_end(script, index)
|
|
1573
|
+
if index < len(script) and script[index] == ",":
|
|
1574
|
+
index = sed_address_end(script, index + 1)
|
|
1575
|
+
if index > address_start and index < len(script) and script[index] == "!":
|
|
1576
|
+
index += 1
|
|
1577
|
+
while index < len(script) and script[index].isspace():
|
|
1578
|
+
index += 1
|
|
1579
|
+
if index >= len(script):
|
|
1580
|
+
break
|
|
1581
|
+
command = script[index]
|
|
1582
|
+
index += 1
|
|
1583
|
+
if command in {"w", "W", "e"}:
|
|
1584
|
+
return False
|
|
1585
|
+
if command == "#":
|
|
1586
|
+
newline = script.find("\n", index)
|
|
1587
|
+
index = len(script) if newline < 0 else newline + 1
|
|
1588
|
+
continue
|
|
1589
|
+
if command == "s":
|
|
1590
|
+
if index >= len(script):
|
|
1591
|
+
return False
|
|
1592
|
+
delimiter = script[index]
|
|
1593
|
+
index = sed_delimited_end(script, index + 1, delimiter)
|
|
1594
|
+
if index is None:
|
|
1595
|
+
return False
|
|
1596
|
+
index = sed_delimited_end(script, index, delimiter)
|
|
1597
|
+
if index is None:
|
|
1598
|
+
return False
|
|
1599
|
+
end = index
|
|
1600
|
+
while end < len(script) and script[end] not in ";\n":
|
|
1601
|
+
end += 1
|
|
1602
|
+
if any(flag in "wWe" for flag in script[index:end]):
|
|
1603
|
+
return False
|
|
1604
|
+
index = end
|
|
1605
|
+
continue
|
|
1606
|
+
if command == "y":
|
|
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
|
+
continue
|
|
1617
|
+
if command not in safe_commands:
|
|
1618
|
+
return False
|
|
1619
|
+
while index < len(script) and script[index] not in ";\n":
|
|
1620
|
+
index += 1
|
|
1621
|
+
return True
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def sed_arguments(arguments):
|
|
1625
|
+
"""Return (in_place, input_files, script_is_read_only) for sed arguments."""
|
|
1626
|
+
in_place = False
|
|
1627
|
+
scripts = []
|
|
1628
|
+
inputs = []
|
|
1629
|
+
script_seen = False
|
|
1630
|
+
scripts_are_literal = True
|
|
1631
|
+
index = 0
|
|
1632
|
+
while index < len(arguments):
|
|
1633
|
+
argument = arguments[index]
|
|
1634
|
+
index += 1
|
|
1635
|
+
if argument == "--":
|
|
1636
|
+
positional = arguments[index:]
|
|
1637
|
+
if not script_seen and positional:
|
|
1638
|
+
scripts.append(positional[0])
|
|
1639
|
+
positional = positional[1:]
|
|
1640
|
+
script_seen = True
|
|
1641
|
+
inputs.extend(positional)
|
|
1642
|
+
break
|
|
1643
|
+
if argument in {"-e", "--expression"}:
|
|
1644
|
+
if index >= len(arguments):
|
|
1645
|
+
return in_place, inputs, False
|
|
1646
|
+
scripts.append(arguments[index])
|
|
1647
|
+
script_seen = True
|
|
1648
|
+
index += 1
|
|
1649
|
+
continue
|
|
1650
|
+
if argument == "-f" or argument == "--file":
|
|
1651
|
+
if index >= len(arguments):
|
|
1652
|
+
return in_place, inputs, False
|
|
1653
|
+
script_seen = True
|
|
1654
|
+
scripts_are_literal = False
|
|
1655
|
+
index += 1
|
|
1656
|
+
continue
|
|
1657
|
+
if argument.startswith("--expression="):
|
|
1658
|
+
scripts.append(argument.split("=", 1)[1])
|
|
1659
|
+
script_seen = True
|
|
1660
|
+
continue
|
|
1661
|
+
if argument.startswith("--file="):
|
|
1662
|
+
script_seen = True
|
|
1663
|
+
scripts_are_literal = False
|
|
1664
|
+
continue
|
|
1665
|
+
if argument == "--in-place" or argument.startswith("--in-place="):
|
|
1666
|
+
in_place = True
|
|
1667
|
+
continue
|
|
1668
|
+
if argument.startswith("--"):
|
|
1669
|
+
if argument not in SED_SAFE_OPTIONS:
|
|
1670
|
+
scripts_are_literal = False
|
|
1671
|
+
continue
|
|
1672
|
+
if argument.startswith("-") and argument != "-":
|
|
1673
|
+
options = argument[1:]
|
|
1674
|
+
cursor = 0
|
|
1675
|
+
while cursor < len(options):
|
|
1676
|
+
option = options[cursor]
|
|
1677
|
+
cursor += 1
|
|
1678
|
+
if option == "i":
|
|
1679
|
+
in_place = True
|
|
1680
|
+
break
|
|
1681
|
+
if option == "e":
|
|
1682
|
+
if cursor < len(options):
|
|
1683
|
+
scripts.append(options[cursor:])
|
|
1684
|
+
script_seen = True
|
|
1685
|
+
elif index < len(arguments):
|
|
1686
|
+
scripts.append(arguments[index])
|
|
1687
|
+
script_seen = True
|
|
1688
|
+
index += 1
|
|
1689
|
+
else:
|
|
1690
|
+
return in_place, inputs, False
|
|
1691
|
+
break
|
|
1692
|
+
if option not in "nErsu":
|
|
1693
|
+
scripts_are_literal = False
|
|
1694
|
+
continue
|
|
1695
|
+
if not script_seen:
|
|
1696
|
+
scripts.append(argument)
|
|
1697
|
+
script_seen = True
|
|
1698
|
+
else:
|
|
1699
|
+
inputs.append(argument)
|
|
1700
|
+
if not scripts or not scripts_are_literal:
|
|
1701
|
+
return in_place, inputs, False
|
|
1702
|
+
return in_place, inputs, all(sed_script_is_read_only(script) for script in scripts)
|
|
1703
|
+
|
|
1704
|
+
|
|
1289
1705
|
def shell_write_targets(tokens, working_directories, assignments=None):
|
|
1290
|
-
"""Yield (target, directories) for every path a shell command may write."""
|
|
1291
|
-
|
|
1292
|
-
|
|
1706
|
+
"""Yield (target, directories, variables) for every path a shell command may write."""
|
|
1707
|
+
for segment, directories, segment_assignments, _ in shell_segment_contexts(
|
|
1708
|
+
tokens, working_directories, assignments
|
|
1709
|
+
):
|
|
1293
1710
|
for index, token in enumerate(segment[:-1]):
|
|
1294
1711
|
if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
|
|
1295
1712
|
continue
|
|
1296
1713
|
target = segment[index + 1]
|
|
1297
1714
|
if token.endswith("&") and (target.isdigit() or target == "-"):
|
|
1298
1715
|
continue
|
|
1299
|
-
yield target, directories
|
|
1716
|
+
yield target, directories, segment_assignments
|
|
1300
1717
|
wrapped = wrapped_command_tokens(segment)
|
|
1301
1718
|
if wrapped is not None:
|
|
1302
|
-
yield from shell_write_targets(wrapped, directories,
|
|
1719
|
+
yield from shell_write_targets(wrapped, directories, segment_assignments)
|
|
1303
1720
|
continue
|
|
1304
1721
|
invocation = command_invocation(segment)
|
|
1305
1722
|
if invocation is None:
|
|
1306
1723
|
continue
|
|
1307
1724
|
command, arguments = invocation
|
|
1725
|
+
command = command.removesuffix(".exe")
|
|
1726
|
+
if command == "sed":
|
|
1727
|
+
in_place, inputs, _ = sed_arguments(arguments)
|
|
1728
|
+
if in_place:
|
|
1729
|
+
for target in inputs:
|
|
1730
|
+
yield target, directories, segment_assignments
|
|
1731
|
+
continue
|
|
1308
1732
|
in_place = command in IN_PLACE_EDITORS and (
|
|
1309
1733
|
has_short_option(arguments, "i")
|
|
1310
1734
|
or any(argument.startswith("--in-place") for argument in arguments)
|
|
1311
1735
|
)
|
|
1312
|
-
if command
|
|
1313
|
-
for destination in copy_destinations(arguments, directories,
|
|
1314
|
-
yield destination, directories
|
|
1736
|
+
if command == "cp":
|
|
1737
|
+
for destination in copy_destinations(arguments, directories, segment_assignments):
|
|
1738
|
+
yield destination, directories, segment_assignments
|
|
1315
1739
|
continue
|
|
1316
|
-
if command
|
|
1740
|
+
if command in SHELL_WRITE_COMMANDS or in_place:
|
|
1317
1741
|
for argument in arguments:
|
|
1318
1742
|
if argument and not argument.startswith("-"):
|
|
1319
|
-
yield argument, directories
|
|
1743
|
+
yield argument, directories, segment_assignments
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def shell_write_needs_build_phase_gate(targets):
|
|
1747
|
+
"""Phase-gate shell product writes only on bound milestone branches in-tree."""
|
|
1748
|
+
repo = repository_root()
|
|
1749
|
+
branch = subprocess.run(
|
|
1750
|
+
["git", "-C", str(repo), "branch", "--show-current"],
|
|
1751
|
+
capture_output=True,
|
|
1752
|
+
encoding="utf-8",
|
|
1753
|
+
errors="replace",
|
|
1754
|
+
)
|
|
1755
|
+
if branch.returncode or not STATUS_BRANCH.fullmatch(branch.stdout.strip()):
|
|
1756
|
+
return False
|
|
1757
|
+
for path, working_directories in targets:
|
|
1758
|
+
for target in dict.fromkeys(target_paths(path, working_directories, repo)):
|
|
1759
|
+
directory = (
|
|
1760
|
+
target
|
|
1761
|
+
if target.is_dir() and not is_link_like(target)
|
|
1762
|
+
else target.parent
|
|
1763
|
+
)
|
|
1764
|
+
while not directory.exists() and directory != directory.parent:
|
|
1765
|
+
directory = directory.parent
|
|
1766
|
+
root = subprocess.run(
|
|
1767
|
+
["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
|
|
1768
|
+
capture_output=True,
|
|
1769
|
+
encoding="utf-8",
|
|
1770
|
+
errors="replace",
|
|
1771
|
+
)
|
|
1772
|
+
if root.returncode != 0 or Path(root.stdout.strip()) != repo:
|
|
1773
|
+
return False
|
|
1774
|
+
return True
|
|
1320
1775
|
|
|
1321
1776
|
|
|
1322
1777
|
def protected_shell_write_reason(tokens, working_directories):
|
|
1323
|
-
assignments = shell_assignment_values(tokens)
|
|
1324
1778
|
targets = []
|
|
1325
|
-
for target, directories in shell_write_targets(tokens, working_directories):
|
|
1779
|
+
for target, directories, assignments in shell_write_targets(tokens, working_directories):
|
|
1326
1780
|
expanded = expand_environment_parameters(target, assignments)
|
|
1327
1781
|
if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
|
|
1328
1782
|
expanded
|
|
@@ -1342,44 +1796,141 @@ def protected_shell_write_reason(tokens, working_directories):
|
|
|
1342
1796
|
for target, kind in classified[2]:
|
|
1343
1797
|
if kind == "protected":
|
|
1344
1798
|
return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
|
|
1799
|
+
if shell_write_needs_build_phase_gate(targets):
|
|
1800
|
+
return pipeline_product_phase_reason(classified)
|
|
1345
1801
|
return None
|
|
1346
1802
|
|
|
1347
1803
|
|
|
1348
|
-
def environment_parameter_value(match, assignments):
|
|
1804
|
+
def environment_parameter_value(match, assignments, tracked_only=False):
|
|
1349
1805
|
name = match.group(1) or match.group(2)
|
|
1350
1806
|
if name.startswith(SUBSTITUTION_PLACEHOLDER):
|
|
1351
1807
|
return match.group(0) # command substitution output is never resolved
|
|
1352
|
-
|
|
1808
|
+
if name in assignments:
|
|
1809
|
+
return assignments[name]
|
|
1810
|
+
if tracked_only:
|
|
1811
|
+
return match.group(0)
|
|
1812
|
+
return os.environ.get(name, match.group(0))
|
|
1353
1813
|
|
|
1354
1814
|
|
|
1355
|
-
def expand_environment_parameters(command, assignments=None):
|
|
1815
|
+
def expand_environment_parameters(command, assignments=None, tracked_only=False):
|
|
1356
1816
|
values = assignments or {}
|
|
1357
1817
|
|
|
1358
1818
|
def substitute(match):
|
|
1359
|
-
return environment_parameter_value(match, values)
|
|
1819
|
+
return environment_parameter_value(match, values, tracked_only)
|
|
1360
1820
|
|
|
1361
1821
|
expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
|
|
1362
1822
|
return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
|
|
1363
1823
|
|
|
1364
1824
|
|
|
1365
|
-
def
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1825
|
+
def shell_segment_flow(tokens):
|
|
1826
|
+
"""Return whether each segment is conditional or runs in a subshell."""
|
|
1827
|
+
flows = []
|
|
1828
|
+
segment = []
|
|
1829
|
+
conditional = False
|
|
1830
|
+
subshell_depth = 0
|
|
1831
|
+
control_depth = 0
|
|
1832
|
+
separators = set("|;&()\n\r")
|
|
1833
|
+
for token in tokens:
|
|
1834
|
+
if token and set(token) <= separators:
|
|
1835
|
+
if segment:
|
|
1836
|
+
flows.append(
|
|
1837
|
+
(conditional or control_depth > 0, subshell_depth > 0 or token in {"|", "&"})
|
|
1838
|
+
)
|
|
1839
|
+
segment = []
|
|
1840
|
+
if token == "(":
|
|
1841
|
+
subshell_depth += 1
|
|
1842
|
+
conditional = True
|
|
1843
|
+
elif token == ")":
|
|
1844
|
+
subshell_depth = max(0, subshell_depth - 1)
|
|
1845
|
+
conditional = False
|
|
1846
|
+
elif token in {"&&", "||", "|", "&"}:
|
|
1847
|
+
conditional = True
|
|
1848
|
+
else:
|
|
1849
|
+
conditional = False
|
|
1850
|
+
continue
|
|
1851
|
+
word = token.casefold()
|
|
1852
|
+
if not segment and word in SHELL_CONTROL_WORDS:
|
|
1853
|
+
if word in {"if", "while", "until", "for", "select", "case", "coproc"}:
|
|
1854
|
+
control_depth += 1
|
|
1855
|
+
elif word in {"fi", "done", "esac"}:
|
|
1856
|
+
control_depth = max(0, control_depth - 1)
|
|
1857
|
+
if word in {"then", "else", "elif", "do", "in"}:
|
|
1858
|
+
conditional = True
|
|
1859
|
+
continue
|
|
1860
|
+
segment.append(token)
|
|
1861
|
+
if segment:
|
|
1862
|
+
flows.append((conditional or control_depth > 0, subshell_depth > 0))
|
|
1863
|
+
return flows
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def shell_assignment_changes(segment, values):
|
|
1867
|
+
"""Return variables changed by a persistent assignment or variable builtin."""
|
|
1868
|
+
index = 0
|
|
1869
|
+
prefix = []
|
|
1870
|
+
while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
|
|
1871
|
+
prefix.append(segment[index])
|
|
1872
|
+
index += 1
|
|
1873
|
+
executable = segment[index].casefold() if index < len(segment) else None
|
|
1874
|
+
updates = {}
|
|
1875
|
+
removes = set()
|
|
1876
|
+
if executable is None:
|
|
1877
|
+
assignments = prefix
|
|
1878
|
+
elif executable in {"export", "declare", "typeset"}:
|
|
1879
|
+
assignments = [*prefix, *segment[index + 1 :]]
|
|
1880
|
+
else:
|
|
1881
|
+
assignments = []
|
|
1882
|
+
for assignment in assignments:
|
|
1883
|
+
if SHELL_ASSIGNMENT_PATTERN.match(assignment):
|
|
1884
|
+
name, value = assignment.split("=", 1)
|
|
1885
|
+
updates[name] = expand_environment_parameters(value, {**values, **updates})
|
|
1886
|
+
if executable == "unset":
|
|
1887
|
+
removes = {
|
|
1888
|
+
name for name in segment[index + 1 :] if not name.startswith("-")
|
|
1889
|
+
}
|
|
1890
|
+
return updates, removes
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
def shell_assignment_contexts(tokens, initial=None):
|
|
1894
|
+
"""Return the persistent shell variables visible before each segment."""
|
|
1895
|
+
values = dict(initial or {})
|
|
1896
|
+
contexts = []
|
|
1897
|
+
segments = list(command_segments(tokens))
|
|
1898
|
+
flows = shell_segment_flow(tokens)
|
|
1899
|
+
if len(segments) != len(flows):
|
|
1900
|
+
raise ValueError("shell command segments cannot be aligned")
|
|
1901
|
+
for segment, (conditional, subshell) in zip(segments, flows):
|
|
1902
|
+
contexts.append(dict(values))
|
|
1903
|
+
updates, removes = shell_assignment_changes(segment, values)
|
|
1904
|
+
if subshell:
|
|
1905
|
+
continue
|
|
1906
|
+
for name in removes:
|
|
1907
|
+
if conditional:
|
|
1908
|
+
values[name] = f"${name}"
|
|
1909
|
+
else:
|
|
1910
|
+
values.pop(name, None)
|
|
1911
|
+
for name, value in updates.items():
|
|
1912
|
+
values[name] = f"${name}" if conditional else value
|
|
1913
|
+
return contexts, values
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
def shell_segment_contexts(tokens, working_directories, initial=None):
|
|
1917
|
+
"""Yield each segment with its working directory and prior shell variables."""
|
|
1918
|
+
contexts, _ = shell_assignment_contexts(tokens, initial)
|
|
1919
|
+
segments = list(segment_directories(tokens, working_directories, contexts))
|
|
1920
|
+
if len(segments) != len(contexts):
|
|
1921
|
+
raise ValueError("shell command segments cannot be aligned")
|
|
1922
|
+
for (segment, directories), assignments in zip(segments, contexts):
|
|
1923
|
+
prefix = {}
|
|
1369
1924
|
for token in segment:
|
|
1370
1925
|
if not SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
1371
|
-
if variable_command:
|
|
1372
|
-
continue
|
|
1373
1926
|
break
|
|
1374
1927
|
name, value = token.split("=", 1)
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
values[name] = expand_environment_parameters(value, values)
|
|
1378
|
-
return values
|
|
1928
|
+
prefix[name] = expand_environment_parameters(value, assignments)
|
|
1929
|
+
yield segment, directories, assignments, prefix
|
|
1379
1930
|
|
|
1380
1931
|
|
|
1381
|
-
def unresolved_archive_expansion(command,
|
|
1382
|
-
expanded = expand_environment_parameters(command,
|
|
1932
|
+
def unresolved_archive_expansion(command, assignments=None):
|
|
1933
|
+
expanded = expand_environment_parameters(command, assignments)
|
|
1383
1934
|
return ARCHIVE_REFERENCE.search(expanded) is not None
|
|
1384
1935
|
|
|
1385
1936
|
|
|
@@ -1408,32 +1959,194 @@ def substitution_end(command, start):
|
|
|
1408
1959
|
raise ValueError("command substitution $( is not closed")
|
|
1409
1960
|
|
|
1410
1961
|
|
|
1962
|
+
def heredoc_requests(line):
|
|
1963
|
+
"""Find literal here-document delimiters, excluding here-strings and quotes."""
|
|
1964
|
+
requests = []
|
|
1965
|
+
quote = None
|
|
1966
|
+
index = 0
|
|
1967
|
+
while index < len(line):
|
|
1968
|
+
character = line[index]
|
|
1969
|
+
if quote:
|
|
1970
|
+
if character == "\\" and quote == '"':
|
|
1971
|
+
index += 2
|
|
1972
|
+
continue
|
|
1973
|
+
if character == quote:
|
|
1974
|
+
quote = None
|
|
1975
|
+
index += 1
|
|
1976
|
+
continue
|
|
1977
|
+
if character == "\\":
|
|
1978
|
+
index += 2
|
|
1979
|
+
continue
|
|
1980
|
+
if character == "#" and (
|
|
1981
|
+
index == 0
|
|
1982
|
+
or line[index - 1].isspace()
|
|
1983
|
+
or line[index - 1] in ";|&()<>"
|
|
1984
|
+
):
|
|
1985
|
+
break
|
|
1986
|
+
if character in "'\"":
|
|
1987
|
+
quote = character
|
|
1988
|
+
index += 1
|
|
1989
|
+
continue
|
|
1990
|
+
if line.startswith("$(", index):
|
|
1991
|
+
index = substitution_end(line, index + 2) + 1
|
|
1992
|
+
continue
|
|
1993
|
+
if character == "`":
|
|
1994
|
+
end = index + 1
|
|
1995
|
+
while end < len(line) and line[end] != "`":
|
|
1996
|
+
end += 2 if line[end] == "\\" else 1
|
|
1997
|
+
index = min(end + 1, len(line))
|
|
1998
|
+
continue
|
|
1999
|
+
if line.startswith("<<<", index):
|
|
2000
|
+
index += 3
|
|
2001
|
+
continue
|
|
2002
|
+
if not line.startswith("<<", index):
|
|
2003
|
+
index += 1
|
|
2004
|
+
continue
|
|
2005
|
+
cursor = index + 2
|
|
2006
|
+
strip_tabs = cursor < len(line) and line[cursor] == "-"
|
|
2007
|
+
if strip_tabs:
|
|
2008
|
+
cursor += 1
|
|
2009
|
+
while cursor < len(line) and line[cursor] in " \t":
|
|
2010
|
+
cursor += 1
|
|
2011
|
+
delimiter = []
|
|
2012
|
+
delimiter_quote = None
|
|
2013
|
+
quoted = False
|
|
2014
|
+
while cursor < len(line):
|
|
2015
|
+
value = line[cursor]
|
|
2016
|
+
if delimiter_quote:
|
|
2017
|
+
if value == delimiter_quote:
|
|
2018
|
+
delimiter_quote = None
|
|
2019
|
+
else:
|
|
2020
|
+
delimiter.append(value)
|
|
2021
|
+
cursor += 1
|
|
2022
|
+
continue
|
|
2023
|
+
if value in "'\"":
|
|
2024
|
+
delimiter_quote = value
|
|
2025
|
+
quoted = True
|
|
2026
|
+
cursor += 1
|
|
2027
|
+
continue
|
|
2028
|
+
if value == "\\" and cursor + 1 < len(line):
|
|
2029
|
+
quoted = True
|
|
2030
|
+
delimiter.append(line[cursor + 1])
|
|
2031
|
+
cursor += 2
|
|
2032
|
+
continue
|
|
2033
|
+
if value.isspace() or value in ";|&()<>":
|
|
2034
|
+
break
|
|
2035
|
+
delimiter.append(value)
|
|
2036
|
+
cursor += 1
|
|
2037
|
+
if not delimiter:
|
|
2038
|
+
raise ValueError("here-document delimiter cannot be validated")
|
|
2039
|
+
requests.append(("".join(delimiter), strip_tabs, quoted))
|
|
2040
|
+
index = cursor
|
|
2041
|
+
return requests
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def heredoc_modes(command):
|
|
2045
|
+
"""Mark here-document bodies as data or shell-expanded input."""
|
|
2046
|
+
lines = command.splitlines(keepends=True)
|
|
2047
|
+
modes = ["shell"] * len(command)
|
|
2048
|
+
offsets = []
|
|
2049
|
+
offset = 0
|
|
2050
|
+
for line in lines:
|
|
2051
|
+
offsets.append(offset)
|
|
2052
|
+
offset += len(line)
|
|
2053
|
+
line_index = 0
|
|
2054
|
+
while line_index < len(lines):
|
|
2055
|
+
requests = heredoc_requests(lines[line_index].rstrip("\r\n"))
|
|
2056
|
+
line_index += 1
|
|
2057
|
+
for delimiter, strip_tabs, quoted in requests:
|
|
2058
|
+
found = False
|
|
2059
|
+
while line_index < len(lines):
|
|
2060
|
+
line = lines[line_index]
|
|
2061
|
+
content = line.rstrip("\r\n")
|
|
2062
|
+
if strip_tabs:
|
|
2063
|
+
content = content.lstrip("\t")
|
|
2064
|
+
start = offsets[line_index]
|
|
2065
|
+
stop = start + len(line)
|
|
2066
|
+
mode = "literal" if quoted or content == delimiter else "heredoc"
|
|
2067
|
+
modes[start:stop] = [mode] * (stop - start)
|
|
2068
|
+
line_index += 1
|
|
2069
|
+
if content == delimiter:
|
|
2070
|
+
found = True
|
|
2071
|
+
break
|
|
2072
|
+
if not found:
|
|
2073
|
+
raise ValueError(f"here-document {delimiter} is not terminated")
|
|
2074
|
+
return modes
|
|
2075
|
+
|
|
2076
|
+
|
|
1411
2077
|
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
|
|
2078
|
+
"""Replace executable substitutions, leaving quoted text as shell data."""
|
|
2079
|
+
modes = heredoc_modes(command)
|
|
2080
|
+
outer, inner, index, quote = [], [], 0, None
|
|
1420
2081
|
while index < len(command):
|
|
2082
|
+
mode = modes[index]
|
|
2083
|
+
character = command[index]
|
|
2084
|
+
if mode == "literal":
|
|
2085
|
+
outer.append(character)
|
|
2086
|
+
index += 1
|
|
2087
|
+
continue
|
|
2088
|
+
if mode == "heredoc":
|
|
2089
|
+
if character == "\\" and index + 1 < len(command) and command[index + 1] in "\\$`":
|
|
2090
|
+
outer.append(command[index : index + 2])
|
|
2091
|
+
index += 2
|
|
2092
|
+
continue
|
|
2093
|
+
if command.startswith("$(", index):
|
|
2094
|
+
end = substitution_end(command, index + 2)
|
|
2095
|
+
inner.append(command[index + 2 : end])
|
|
2096
|
+
elif character == "`":
|
|
2097
|
+
end = index + 1
|
|
2098
|
+
while end < len(command) and command[end] != "`":
|
|
2099
|
+
end += 2 if command[end] == "\\" else 1
|
|
2100
|
+
if end >= len(command):
|
|
2101
|
+
raise ValueError("backtick command substitution is not closed")
|
|
2102
|
+
inner.append(command[index + 1 : end])
|
|
2103
|
+
elif command.startswith("@(", index):
|
|
2104
|
+
raise ValueError(
|
|
2105
|
+
"@( expansion cannot be validated; write the list as literal arguments"
|
|
2106
|
+
)
|
|
2107
|
+
else:
|
|
2108
|
+
outer.append(character)
|
|
2109
|
+
index += 1
|
|
2110
|
+
continue
|
|
2111
|
+
outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
|
|
2112
|
+
index = end + 1
|
|
2113
|
+
continue
|
|
2114
|
+
if quote == "'":
|
|
2115
|
+
outer.append(character)
|
|
2116
|
+
if character == "'":
|
|
2117
|
+
quote = None
|
|
2118
|
+
index += 1
|
|
2119
|
+
continue
|
|
2120
|
+
if character == "\\":
|
|
2121
|
+
outer.append(command[index : index + 2])
|
|
2122
|
+
index += 2
|
|
2123
|
+
continue
|
|
2124
|
+
if quote is None and character == "'":
|
|
2125
|
+
quote = character
|
|
2126
|
+
outer.append(character)
|
|
2127
|
+
index += 1
|
|
2128
|
+
continue
|
|
2129
|
+
if character == '"':
|
|
2130
|
+
quote = None if quote == '"' else '"'
|
|
2131
|
+
outer.append(character)
|
|
2132
|
+
index += 1
|
|
2133
|
+
continue
|
|
1421
2134
|
if command.startswith("$(", index):
|
|
1422
2135
|
end = substitution_end(command, index + 2)
|
|
1423
2136
|
inner.append(command[index + 2 : end])
|
|
1424
|
-
elif
|
|
2137
|
+
elif character == "`":
|
|
1425
2138
|
end = index + 1
|
|
1426
2139
|
while end < len(command) and command[end] != "`":
|
|
1427
2140
|
end += 2 if command[end] == "\\" else 1
|
|
1428
2141
|
if end >= len(command):
|
|
1429
2142
|
raise ValueError("backtick command substitution is not closed")
|
|
1430
2143
|
inner.append(command[index + 1 : end])
|
|
1431
|
-
elif command
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2144
|
+
elif command.startswith("@(", index):
|
|
2145
|
+
raise ValueError(
|
|
2146
|
+
"@( expansion cannot be validated; write the list as literal arguments"
|
|
2147
|
+
)
|
|
1435
2148
|
else:
|
|
1436
|
-
outer.append(
|
|
2149
|
+
outer.append(character)
|
|
1437
2150
|
index += 1
|
|
1438
2151
|
continue
|
|
1439
2152
|
outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
|
|
@@ -1454,11 +2167,8 @@ def strip_heredoc_bodies(command):
|
|
|
1454
2167
|
line = lines[index]
|
|
1455
2168
|
kept.append(line)
|
|
1456
2169
|
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"
|
|
2170
|
+
for delimiter, strip_tabs, _ in heredoc_requests(line):
|
|
2171
|
+
strip = "\t\r" if strip_tabs else "\r"
|
|
1462
2172
|
while index < len(lines) and lines[index].strip(strip) != delimiter:
|
|
1463
2173
|
index += 1
|
|
1464
2174
|
if index >= len(lines):
|
|
@@ -1497,14 +2207,222 @@ def decode_patch_path(raw_path, strip_prefix):
|
|
|
1497
2207
|
return path
|
|
1498
2208
|
|
|
1499
2209
|
|
|
1500
|
-
|
|
1501
|
-
|
|
2210
|
+
PARAMETER_MARKERS = frozenset("$%!")
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
class ShellToken(str):
|
|
2214
|
+
"""A token that keeps, per parameter marker character, whether double quotes enclosed it.
|
|
2215
|
+
|
|
2216
|
+
`redirection` is true for an unquoted redirection operator and for one
|
|
2217
|
+
ASCII file descriptor digit written against one (the 2 of 2>file, not of
|
|
2218
|
+
2 >file). zsh passes a longer or non-ASCII digit word on as an argument.
|
|
2219
|
+
`literal` is "bare" for a word written with no quote or escape, "quoted"
|
|
2220
|
+
for a word inside one pair of plain quotes, "mixed" for plain quoted and
|
|
2221
|
+
unquoted parts where no unquoted part can expand, and None when the shell
|
|
2222
|
+
may build the word in another way (escapes, an expanding unquoted part, a
|
|
2223
|
+
line continuation in the word or one that joins it to the next word).
|
|
2224
|
+
"""
|
|
2225
|
+
|
|
2226
|
+
double_quoted = ()
|
|
2227
|
+
redirection = False
|
|
2228
|
+
literal = None
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
def double_quoted_markers(command):
|
|
2232
|
+
"""Return, per parameter marker in order, whether plain double quotes enclose it.
|
|
2233
|
+
|
|
2234
|
+
None means the command uses quoting outside the plain constructs this scan
|
|
2235
|
+
models (plain single quotes, plain double quotes, unquoted text), so no
|
|
2236
|
+
marker in it can be proven quoted.
|
|
2237
|
+
"""
|
|
2238
|
+
flags = []
|
|
2239
|
+
quote = None
|
|
2240
|
+
comment = False
|
|
2241
|
+
braces = 0
|
|
2242
|
+
previous = " "
|
|
2243
|
+
index = 0
|
|
2244
|
+
while index < len(command):
|
|
2245
|
+
character = command[index]
|
|
2246
|
+
following = command[index + 1 : index + 2]
|
|
2247
|
+
if comment:
|
|
2248
|
+
if character in "\r\n":
|
|
2249
|
+
comment = False
|
|
2250
|
+
elif character in "'\"\\`":
|
|
2251
|
+
return None
|
|
2252
|
+
elif quote == "'":
|
|
2253
|
+
if character == "'":
|
|
2254
|
+
quote = None
|
|
2255
|
+
elif character == "\\":
|
|
2256
|
+
if following and following in PARAMETER_MARKERS:
|
|
2257
|
+
# The shell executes the escaped marker as literal text; the
|
|
2258
|
+
# token keeps the bare marker, so the pair is never provable.
|
|
2259
|
+
flags.append(False)
|
|
2260
|
+
previous = character
|
|
2261
|
+
index += 2
|
|
2262
|
+
continue
|
|
2263
|
+
elif character == "`" or (character == "$" and following == "("):
|
|
2264
|
+
return None
|
|
2265
|
+
elif character == "$" and following == "{":
|
|
2266
|
+
braces += 1
|
|
2267
|
+
elif character == "}" and braces:
|
|
2268
|
+
braces -= 1
|
|
2269
|
+
elif character in "'\"" and braces:
|
|
2270
|
+
return None
|
|
2271
|
+
elif character == '"':
|
|
2272
|
+
quote = None if quote else '"'
|
|
2273
|
+
elif quote is None:
|
|
2274
|
+
if character == "'":
|
|
2275
|
+
quote = "'"
|
|
2276
|
+
elif character == "$" and following and following in "'\"":
|
|
2277
|
+
return None
|
|
2278
|
+
elif character == "<" and following == "<":
|
|
2279
|
+
return None
|
|
2280
|
+
elif character == "#" and previous in " \t\r\n;&|()<>":
|
|
2281
|
+
comment = True
|
|
2282
|
+
if character in PARAMETER_MARKERS:
|
|
2283
|
+
flags.append(quote == '"' and not comment)
|
|
2284
|
+
previous = character
|
|
2285
|
+
index += 1
|
|
2286
|
+
return flags if quote is None and not braces else None
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def zsh_expansion_flag(text):
|
|
2290
|
+
"""Whether the text holds an unbraced zsh expansion flag ($=x, $~x, $^x, $+x).
|
|
2291
|
+
|
|
2292
|
+
These match no parameter syntax, so without this check they never enter
|
|
2293
|
+
the parameter proof at all — while zsh word-splits them exactly like the
|
|
2294
|
+
forms the proof denies. Any other unmatched $ is inert literal text.
|
|
2295
|
+
"""
|
|
2296
|
+
return ZSH_EXPANSION_FLAG_SYNTAX.search(text)
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
def parameters_double_quoted(token):
|
|
2300
|
+
"""Whether every parameter in the token expands inside double quotes to one word.
|
|
2301
|
+
|
|
2302
|
+
Only plain $name and ${name} qualify: flags, operators, subscripts,
|
|
2303
|
+
positional and special parameters can split even inside double quotes.
|
|
2304
|
+
A backslash-escaped marker stays one flag short of proven: the shell
|
|
2305
|
+
executes the literal text while the guard's token still shows the bare
|
|
2306
|
+
marker, so nothing about its expansion is provable.
|
|
2307
|
+
"""
|
|
2308
|
+
flags = getattr(token, "double_quoted", ())
|
|
2309
|
+
positions = [
|
|
2310
|
+
index for index, character in enumerate(token) if character in PARAMETER_MARKERS
|
|
2311
|
+
]
|
|
2312
|
+
if len(flags) != len(positions):
|
|
2313
|
+
return False
|
|
2314
|
+
quoted = dict(zip(positions, flags))
|
|
2315
|
+
plain = {
|
|
2316
|
+
match.start(): match.end() for match in NAMED_SHELL_PARAMETER_SYNTAX.finditer(token)
|
|
2317
|
+
}
|
|
2318
|
+
if any(
|
|
2319
|
+
character == "$" and index not in plain for index, character in enumerate(token)
|
|
2320
|
+
) or any(token[end : end + 1] == "[" for end in plain.values()):
|
|
2321
|
+
return False
|
|
2322
|
+
return all(
|
|
2323
|
+
quoted[match.start()] and quoted.get(match.end() - 1, True)
|
|
2324
|
+
for syntax in (SHELL_PARAMETER_SYNTAX, CMD_PARAMETER_SYNTAX)
|
|
2325
|
+
for match in syntax.finditer(token)
|
|
2326
|
+
)
|
|
2327
|
+
|
|
2328
|
+
|
|
2329
|
+
def shell_tokens(command, direct=True):
|
|
2330
|
+
"""Tokenize a command; `direct` is False for a string that a second shell parses.
|
|
2331
|
+
|
|
2332
|
+
Only a directly parsed command keeps quote flags: an outer shell expands
|
|
2333
|
+
parameters before the inner parse, so inner quotes prove nothing.
|
|
2334
|
+
"""
|
|
2335
|
+
breaks = set()
|
|
2336
|
+
if LINE_CONTINUATION.search(command):
|
|
2337
|
+
mark = next(
|
|
2338
|
+
character for character in map(chr, range(0xE000, 0xF900))
|
|
2339
|
+
if character not in command
|
|
2340
|
+
)
|
|
2341
|
+
marked = strip_heredoc_bodies(
|
|
2342
|
+
LINE_CONTINUATION.sub(lambda match: match.group(1) + mark, command)
|
|
2343
|
+
)
|
|
2344
|
+
command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
|
|
2345
|
+
breaks = (
|
|
2346
|
+
{index for index, character in enumerate(marked) if character == mark}
|
|
2347
|
+
if marked.replace(mark, " ") == command
|
|
2348
|
+
else None
|
|
2349
|
+
)
|
|
2350
|
+
else:
|
|
2351
|
+
command = strip_heredoc_bodies(command)
|
|
2352
|
+
joins = set()
|
|
2353
|
+
for index in breaks or ():
|
|
2354
|
+
left, right = index - 1, index + 1
|
|
2355
|
+
while left >= 0 and marked[left] == mark:
|
|
2356
|
+
left -= 1
|
|
2357
|
+
while right < len(marked) and marked[right] == mark:
|
|
2358
|
+
right += 1
|
|
2359
|
+
if left >= 0 and marked[left].strip() and marked[right:right + 1].strip():
|
|
2360
|
+
joins.add(index)
|
|
1502
2361
|
lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
|
|
1503
2362
|
lexer.whitespace = " \t"
|
|
1504
2363
|
lexer.whitespace_split = True
|
|
1505
2364
|
lexer.commenters = ""
|
|
2365
|
+
tokens = []
|
|
2366
|
+
previous_end = 0
|
|
1506
2367
|
try:
|
|
1507
|
-
|
|
2368
|
+
for token in lexer:
|
|
2369
|
+
end = lexer.instream.tell() - len(lexer._pushback_chars)
|
|
2370
|
+
if command[end - 1:end] in (" ", "\t"):
|
|
2371
|
+
end -= 1
|
|
2372
|
+
word_start = previous_end
|
|
2373
|
+
while word_start < end and command[word_start] in " \t":
|
|
2374
|
+
word_start += 1
|
|
2375
|
+
previous_end = end
|
|
2376
|
+
word = command[word_start:end]
|
|
2377
|
+
literal = None
|
|
2378
|
+
if (
|
|
2379
|
+
breaks is not None
|
|
2380
|
+
and "\\" not in word
|
|
2381
|
+
and not ANSI_QUOTE_START.search(word)
|
|
2382
|
+
and breaks.isdisjoint(range(word_start, end))
|
|
2383
|
+
and end not in joins
|
|
2384
|
+
and word_start - 1 not in joins
|
|
2385
|
+
and PLAIN_QUOTED_SEGMENT.sub(lambda match: match.group(0)[1:-1], word) == token
|
|
2386
|
+
):
|
|
2387
|
+
unquoted = PLAIN_QUOTED_SEGMENT.sub("", word)
|
|
2388
|
+
if (
|
|
2389
|
+
"'" not in unquoted
|
|
2390
|
+
and '"' not in unquoted
|
|
2391
|
+
and not GIT_CONFIG_EXPANDING_WORD.search(unquoted)
|
|
2392
|
+
):
|
|
2393
|
+
literal = "mixed"
|
|
2394
|
+
for quote in ("", "'", '"'):
|
|
2395
|
+
start = end - len(token) - 2 * len(quote)
|
|
2396
|
+
if (
|
|
2397
|
+
breaks is not None
|
|
2398
|
+
and start >= 0
|
|
2399
|
+
and command[start:end] == quote + token + quote
|
|
2400
|
+
and (start == 0 or command[start - 1] in " \t|;&()<>\n\r")
|
|
2401
|
+
and breaks.isdisjoint(range(start, end))
|
|
2402
|
+
and end not in joins
|
|
2403
|
+
and start - 1 not in joins
|
|
2404
|
+
):
|
|
2405
|
+
literal = "quoted" if quote else "bare"
|
|
2406
|
+
break
|
|
2407
|
+
start = end - len(token)
|
|
2408
|
+
before = command[start - 1:start] if start > 0 else ""
|
|
2409
|
+
redirection = command[start:end] == token and (
|
|
2410
|
+
(
|
|
2411
|
+
set(token) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
2412
|
+
and ("<" in token or ">" in token)
|
|
2413
|
+
and before != "\\"
|
|
2414
|
+
)
|
|
2415
|
+
or (
|
|
2416
|
+
len(token) == 1
|
|
2417
|
+
and token in "0123456789"
|
|
2418
|
+
and command[end:end + 1] in ("<", ">")
|
|
2419
|
+
and (not before or before in " \t|;&()<>\n\r")
|
|
2420
|
+
)
|
|
2421
|
+
)
|
|
2422
|
+
token = ShellToken(token)
|
|
2423
|
+
token.redirection = redirection
|
|
2424
|
+
token.literal = literal
|
|
2425
|
+
tokens.append(token)
|
|
1508
2426
|
except ValueError as error:
|
|
1509
2427
|
raise ValueError(
|
|
1510
2428
|
f"shell command cannot be tokenized ({error}); balance the quotes "
|
|
@@ -1512,7 +2430,20 @@ def shell_tokens(command):
|
|
|
1512
2430
|
) from None
|
|
1513
2431
|
if not tokens:
|
|
1514
2432
|
raise ValueError("shell command is empty")
|
|
1515
|
-
|
|
2433
|
+
flags = double_quoted_markers(command) if direct else None
|
|
2434
|
+
if flags is None or len(flags) != sum(
|
|
2435
|
+
character in PARAMETER_MARKERS for token in tokens for character in token
|
|
2436
|
+
):
|
|
2437
|
+
return tokens
|
|
2438
|
+
marked = []
|
|
2439
|
+
for token in tokens:
|
|
2440
|
+
count = sum(character in PARAMETER_MARKERS for character in token)
|
|
2441
|
+
redirection, literal = token.redirection, token.literal
|
|
2442
|
+
token = ShellToken(token)
|
|
2443
|
+
token.redirection, token.literal = redirection, literal
|
|
2444
|
+
token.double_quoted, flags = tuple(flags[:count]), flags[count:]
|
|
2445
|
+
marked.append(token)
|
|
2446
|
+
return marked
|
|
1516
2447
|
|
|
1517
2448
|
|
|
1518
2449
|
def command_segments(tokens):
|
|
@@ -1572,56 +2503,163 @@ def xargs_command(arguments):
|
|
|
1572
2503
|
|
|
1573
2504
|
|
|
1574
2505
|
def require_read_command(wrapper, wrapped):
|
|
1575
|
-
|
|
2506
|
+
segments = list(command_segments(wrapped))
|
|
2507
|
+
if wrapped and (
|
|
2508
|
+
not segments
|
|
2509
|
+
or not all(
|
|
2510
|
+
archive_command_is_read_only(shlex.join(segment), segment, True)
|
|
2511
|
+
for segment in segments
|
|
2512
|
+
)
|
|
2513
|
+
):
|
|
1576
2514
|
raise ValueError(
|
|
1577
2515
|
f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
|
|
1578
2516
|
f"only read commands may follow {wrapper}, or pass the paths as literals"
|
|
1579
2517
|
)
|
|
1580
2518
|
|
|
1581
2519
|
|
|
1582
|
-
def
|
|
2520
|
+
def runner_value_option(prefix, token):
|
|
2521
|
+
"""The option in a runner option word whose value is the next word, if any.
|
|
2522
|
+
|
|
2523
|
+
Raises for an option that writes, edits or enters a file or directory. A
|
|
2524
|
+
long option matches by each prefix, as getopt accepts it. A short word is
|
|
2525
|
+
read as getopt reads it: flag letters, then the first value option letter,
|
|
2526
|
+
whose value is the rest of the word or, with no rest, the next word.
|
|
2527
|
+
"""
|
|
2528
|
+
value_options = EXEC_PREFIX_VALUE_OPTIONS[prefix]
|
|
2529
|
+
file_options = EXEC_PREFIX_FILE_OPTIONS.get(prefix, frozenset())
|
|
2530
|
+
denial = ValueError(
|
|
2531
|
+
f"{prefix} {token} writes, edits or enters a file or directory that the "
|
|
2532
|
+
"guard cannot check; run the command without this option and redirect "
|
|
2533
|
+
"its output instead"
|
|
2534
|
+
)
|
|
2535
|
+
if token.startswith("--"):
|
|
2536
|
+
name = token.split("=", 1)[0]
|
|
2537
|
+
if any(option.startswith(name) for option in file_options):
|
|
2538
|
+
raise denial
|
|
2539
|
+
option = next((option for option in value_options if option.startswith(name)), None)
|
|
2540
|
+
return option if "=" not in token else None
|
|
2541
|
+
for position, letter in enumerate(token[1:], start=1):
|
|
2542
|
+
option = f"-{letter}"
|
|
2543
|
+
if option in file_options:
|
|
2544
|
+
raise denial
|
|
2545
|
+
if option in value_options:
|
|
2546
|
+
return option if position == len(token) - 1 else None
|
|
2547
|
+
if letter not in EXEC_PREFIX_FLAG_LETTERS[prefix]:
|
|
2548
|
+
return None
|
|
2549
|
+
return None
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
def nonliteral_runner_option(token):
|
|
2553
|
+
literal = getattr(token, "literal", "bare")
|
|
2554
|
+
return (
|
|
2555
|
+
SHELL_PARAMETER_SYNTAX.search(token)
|
|
2556
|
+
or CMD_PARAMETER_SYNTAX.search(token)
|
|
2557
|
+
or zsh_expansion_flag(token)
|
|
2558
|
+
or literal is None
|
|
2559
|
+
or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(token))
|
|
2560
|
+
)
|
|
2561
|
+
|
|
2562
|
+
|
|
2563
|
+
def unresolved_runner_word(token):
|
|
2564
|
+
return (
|
|
2565
|
+
SHELL_PARAMETER_SYNTAX.search(token)
|
|
2566
|
+
or CMD_PARAMETER_SYNTAX.search(token)
|
|
2567
|
+
or zsh_expansion_flag(token)
|
|
2568
|
+
) and not parameters_double_quoted(token)
|
|
2569
|
+
|
|
2570
|
+
|
|
2571
|
+
def command_invocation(segment, unwrap_runners=True):
|
|
2572
|
+
"""`unwrap_runners` is False where a command runner itself must be judged."""
|
|
1583
2573
|
index = 0
|
|
1584
|
-
|
|
1585
|
-
|
|
2574
|
+
# The shell word `time` is already removed; its only option can remain.
|
|
2575
|
+
while unwrap_runners and index < len(segment) and segment[index] == "-p":
|
|
1586
2576
|
index += 1
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
index
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
2577
|
+
while True:
|
|
2578
|
+
while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
|
|
2579
|
+
validate_shell_assignment(segment[index])
|
|
2580
|
+
index += 1
|
|
2581
|
+
if index >= len(segment):
|
|
2582
|
+
return None
|
|
2583
|
+
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
2584
|
+
prefix = executable.removesuffix(".exe")
|
|
2585
|
+
if executable == "env":
|
|
2586
|
+
index += 1
|
|
2587
|
+
while index < len(segment):
|
|
2588
|
+
token = segment[index]
|
|
2589
|
+
if token == "--":
|
|
2590
|
+
index += 1
|
|
2591
|
+
break
|
|
2592
|
+
if SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
2593
|
+
validate_shell_assignment(token)
|
|
2594
|
+
index += 1
|
|
2595
|
+
continue
|
|
2596
|
+
option = token.split("=", 1)[0]
|
|
2597
|
+
if option in ENV_OPTIONS_WITHOUT_VALUES:
|
|
2598
|
+
index += 1
|
|
2599
|
+
continue
|
|
2600
|
+
if option in ENV_OPTIONS_WITH_VALUES:
|
|
2601
|
+
if option in {"-C", "--chdir"}:
|
|
2602
|
+
raise ValueError(
|
|
2603
|
+
f"env {option} changes the working directory the guard "
|
|
2604
|
+
"tracks; use cd with a literal path instead"
|
|
2605
|
+
)
|
|
2606
|
+
index += 1
|
|
2607
|
+
if "=" not in token:
|
|
2608
|
+
if index >= len(segment):
|
|
2609
|
+
raise ValueError(f"env {option} lacks a value")
|
|
2610
|
+
index += 1
|
|
2611
|
+
continue
|
|
2612
|
+
if token.startswith("-"):
|
|
2613
|
+
raise ValueError(
|
|
2614
|
+
f"env {token} cannot be validated; use env NAME=VALUE <command>"
|
|
2615
|
+
)
|
|
2616
|
+
if SHELL_PARAMETER_SYNTAX.search(token) or zsh_expansion_flag(token):
|
|
2617
|
+
raise ValueError(
|
|
2618
|
+
f"env word {token} cannot be resolved by the guard; env "
|
|
2619
|
+
"assignments must be literal NAME=VALUE words and the "
|
|
2620
|
+
"program a literal name"
|
|
2621
|
+
)
|
|
1596
2622
|
break
|
|
1597
|
-
|
|
1598
|
-
|
|
2623
|
+
elif unwrap_runners and prefix in EXEC_PREFIX_VALUE_OPTIONS:
|
|
2624
|
+
index += 1
|
|
2625
|
+
while index < len(segment):
|
|
2626
|
+
token = segment[index]
|
|
2627
|
+
if token == "--":
|
|
2628
|
+
index += 1
|
|
2629
|
+
break
|
|
2630
|
+
if not token.startswith("-") or token == "-":
|
|
2631
|
+
break
|
|
1599
2632
|
index += 1
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
2633
|
+
if nonliteral_runner_option(token):
|
|
2634
|
+
raise ValueError(
|
|
2635
|
+
f"{prefix} option {token} cannot be resolved by the guard; "
|
|
2636
|
+
f"{prefix} options must be literal words"
|
|
2637
|
+
)
|
|
2638
|
+
option = runner_value_option(prefix, token)
|
|
2639
|
+
if option is None or index >= len(segment):
|
|
2640
|
+
continue
|
|
2641
|
+
value = segment[index]
|
|
1603
2642
|
index += 1
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
2643
|
+
if prefix == "watch" and option in EXEC_PREFIX_INTERVAL_OPTIONS:
|
|
2644
|
+
if not RUNNER_DURATION.fullmatch(value):
|
|
2645
|
+
raise ValueError(
|
|
2646
|
+
f"watch interval {value} cannot be resolved by the guard; "
|
|
2647
|
+
"the interval must be a literal number"
|
|
2648
|
+
)
|
|
2649
|
+
elif unresolved_runner_word(value):
|
|
2650
|
+
raise ValueError(
|
|
2651
|
+
f"{prefix} option value {value} cannot be resolved by the "
|
|
2652
|
+
f"guard; {prefix} option values must be literal words"
|
|
2653
|
+
)
|
|
2654
|
+
if prefix == "timeout" and index < len(segment):
|
|
2655
|
+
if not RUNNER_DURATION.fullmatch(segment[index]):
|
|
1607
2656
|
raise ValueError(
|
|
1608
|
-
f"
|
|
1609
|
-
"
|
|
2657
|
+
f"timeout duration {segment[index]} cannot be resolved by the "
|
|
2658
|
+
"guard; the duration must be a literal number"
|
|
1610
2659
|
)
|
|
1611
2660
|
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
|
-
)
|
|
2661
|
+
else:
|
|
1621
2662
|
break
|
|
1622
|
-
if index >= len(segment):
|
|
1623
|
-
return None
|
|
1624
|
-
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1625
2663
|
arguments = segment[index + 1 :]
|
|
1626
2664
|
if SHELL_PARAMETER_SYNTAX.search(executable):
|
|
1627
2665
|
raise ValueError(
|
|
@@ -1645,19 +2683,103 @@ def command_invocation(segment):
|
|
|
1645
2683
|
return executable, arguments
|
|
1646
2684
|
|
|
1647
2685
|
|
|
1648
|
-
def
|
|
2686
|
+
def require_resolved_git_word(word, mixed=True):
|
|
2687
|
+
"""Deny a git word the shell can turn into other or several arguments."""
|
|
2688
|
+
literal = getattr(word, "literal", "bare")
|
|
2689
|
+
if (
|
|
2690
|
+
literal is None
|
|
2691
|
+
or (literal == "mixed" and not mixed)
|
|
2692
|
+
or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(word))
|
|
2693
|
+
):
|
|
2694
|
+
raise ValueError(
|
|
2695
|
+
f"git argument {word} cannot be resolved by the guard; "
|
|
2696
|
+
"pass a literal value"
|
|
2697
|
+
)
|
|
2698
|
+
|
|
2699
|
+
|
|
2700
|
+
def git_command(segment, assignments=None):
|
|
1649
2701
|
invocation = command_invocation(segment)
|
|
1650
2702
|
if invocation is None:
|
|
1651
2703
|
return None
|
|
1652
2704
|
executable, arguments = invocation
|
|
1653
2705
|
if executable not in {"git", "git.exe"}:
|
|
1654
2706
|
return None
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
2707
|
+
resolved_arguments = []
|
|
2708
|
+
parameter_arguments = []
|
|
2709
|
+
unproven_parameters = []
|
|
2710
|
+
argument_index = 0
|
|
2711
|
+
while argument_index < len(arguments):
|
|
2712
|
+
argument = arguments[argument_index]
|
|
2713
|
+
attached_directory = argument.startswith("-C") and len(argument) > 2
|
|
2714
|
+
if argument == "-C" and argument_index + 1 < len(arguments):
|
|
2715
|
+
argument_index += 1
|
|
2716
|
+
directory = arguments[argument_index]
|
|
2717
|
+
require_resolved_git_word(directory)
|
|
2718
|
+
expanded = expand_environment_parameters(directory, assignments)
|
|
2719
|
+
if (
|
|
2720
|
+
SHELL_PARAMETER_SYNTAX.search(expanded)
|
|
2721
|
+
or CMD_PARAMETER_SYNTAX.search(expanded)
|
|
2722
|
+
or zsh_expansion_flag(expanded)
|
|
2723
|
+
):
|
|
2724
|
+
raise ValueError(
|
|
2725
|
+
f"git argument {directory} cannot be resolved by the guard; "
|
|
2726
|
+
"pass a literal path"
|
|
2727
|
+
)
|
|
2728
|
+
if (
|
|
2729
|
+
(SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
|
|
2730
|
+
and any(character.isspace() or character in "*?[]" for character in expanded)
|
|
2731
|
+
):
|
|
2732
|
+
raise ValueError(
|
|
2733
|
+
f"git -C path {directory} may split or glob; pass a literal path"
|
|
2734
|
+
)
|
|
2735
|
+
resolved_arguments.extend((argument, expanded))
|
|
2736
|
+
elif attached_directory:
|
|
2737
|
+
require_resolved_git_word(argument)
|
|
2738
|
+
directory = argument[2:]
|
|
2739
|
+
expanded = expand_environment_parameters(directory, assignments)
|
|
2740
|
+
if (
|
|
2741
|
+
SHELL_PARAMETER_SYNTAX.search(expanded)
|
|
2742
|
+
or CMD_PARAMETER_SYNTAX.search(expanded)
|
|
2743
|
+
or zsh_expansion_flag(expanded)
|
|
2744
|
+
):
|
|
2745
|
+
raise ValueError(
|
|
2746
|
+
f"git argument {directory} cannot be resolved by the guard; "
|
|
2747
|
+
"pass a literal path"
|
|
2748
|
+
)
|
|
2749
|
+
if (
|
|
2750
|
+
(SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
|
|
2751
|
+
and any(character.isspace() or character in "*?[]" for character in expanded)
|
|
2752
|
+
):
|
|
2753
|
+
raise ValueError(
|
|
2754
|
+
f"git -C path {directory} may split or glob; pass a literal path"
|
|
2755
|
+
)
|
|
2756
|
+
resolved_arguments.append(f"-C{expanded}")
|
|
2757
|
+
else:
|
|
2758
|
+
parameter = (
|
|
2759
|
+
SHELL_PARAMETER_SYNTAX.search(argument)
|
|
2760
|
+
or CMD_PARAMETER_SYNTAX.search(argument)
|
|
2761
|
+
or zsh_expansion_flag(argument)
|
|
1660
2762
|
)
|
|
2763
|
+
if parameter:
|
|
2764
|
+
parameter_arguments.append(argument)
|
|
2765
|
+
literal = re.split(r"[$%!]", argument, maxsplit=1)[0]
|
|
2766
|
+
expanded = expand_environment_parameters(argument, assignments)
|
|
2767
|
+
if (
|
|
2768
|
+
not literal
|
|
2769
|
+
or (literal.startswith("-") and "=" not in literal)
|
|
2770
|
+
or git_option_in(literal, GIT_PARAMETER_DENIED_OPTIONS)
|
|
2771
|
+
or git_option_in(arguments[argument_index - 1], GIT_PARAMETER_DENIED_OPTIONS)
|
|
2772
|
+
or (literal.startswith("-c") and literal != "-c")
|
|
2773
|
+
or "::" in literal
|
|
2774
|
+
or not parameters_double_quoted(argument)
|
|
2775
|
+
or SUBSTITUTION_PLACEHOLDER in argument
|
|
2776
|
+
or SUBSTITUTION_PLACEHOLDER in expanded
|
|
2777
|
+
):
|
|
2778
|
+
unproven_parameters.append(argument)
|
|
2779
|
+
expanded = expand_environment_parameters(argument, assignments)
|
|
2780
|
+
resolved_arguments.append(argument if expanded == argument else expanded)
|
|
2781
|
+
argument_index += 1
|
|
2782
|
+
arguments = resolved_arguments
|
|
1661
2783
|
index = 0
|
|
1662
2784
|
git_options = []
|
|
1663
2785
|
while index < len(arguments) and arguments[index].startswith("-"):
|
|
@@ -1668,18 +2790,32 @@ def git_command(segment):
|
|
|
1668
2790
|
option = token.split("=", 1)[0]
|
|
1669
2791
|
value = token.split("=", 1)[1] if "=" in token else None
|
|
1670
2792
|
index += 1
|
|
2793
|
+
if option != "-C":
|
|
2794
|
+
require_resolved_git_word(token)
|
|
1671
2795
|
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
|
|
1672
2796
|
if index >= len(arguments):
|
|
1673
2797
|
raise ValueError(f"git option {option} lacks a value")
|
|
1674
2798
|
value = arguments[index]
|
|
2799
|
+
if option != "-C":
|
|
2800
|
+
require_resolved_git_word(value)
|
|
1675
2801
|
index += 1
|
|
1676
2802
|
if option in {"-c", "--config-env"}:
|
|
1677
|
-
config_key = str(value).
|
|
2803
|
+
config_key, _, config_value = str(value).partition("=")
|
|
2804
|
+
config_key = config_key.casefold()
|
|
1678
2805
|
if config_key.startswith("alias."):
|
|
1679
2806
|
raise ValueError(
|
|
1680
2807
|
f"git -c {config_key} defines an alias the guard cannot inspect; "
|
|
1681
2808
|
"run the underlying git command directly"
|
|
1682
2809
|
)
|
|
2810
|
+
if executed_config_key(config_key) and not (
|
|
2811
|
+
option == "-c"
|
|
2812
|
+
and not loaded_config_key(config_key)
|
|
2813
|
+
and config_value.casefold() in GIT_CONFIG_DISABLING_VALUES
|
|
2814
|
+
):
|
|
2815
|
+
raise ValueError(
|
|
2816
|
+
f"git -c {config_key} configures a program git executes; "
|
|
2817
|
+
"the guard cannot allow it"
|
|
2818
|
+
)
|
|
1683
2819
|
if config_key == "clean.requireforce":
|
|
1684
2820
|
raise ValueError(
|
|
1685
2821
|
"git -c clean.requireForce disables the git clean safety check; "
|
|
@@ -1688,8 +2824,47 @@ def git_command(segment):
|
|
|
1688
2824
|
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
|
|
1689
2825
|
git_options.extend((option, str(value)))
|
|
1690
2826
|
if index >= len(arguments):
|
|
2827
|
+
if parameter_arguments:
|
|
2828
|
+
raise ValueError(
|
|
2829
|
+
f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
|
|
2830
|
+
"pass a literal value (for commit messages use -F <file>)"
|
|
2831
|
+
)
|
|
1691
2832
|
return None
|
|
1692
|
-
|
|
2833
|
+
require_resolved_git_word(arguments[index], mixed=False)
|
|
2834
|
+
subcommand = arguments[index].casefold()
|
|
2835
|
+
if subcommand == "config":
|
|
2836
|
+
for argument in arguments[index + 1:]:
|
|
2837
|
+
literal = getattr(argument, "literal", None)
|
|
2838
|
+
if not getattr(argument, "redirection", False) and (
|
|
2839
|
+
literal in (None, "mixed")
|
|
2840
|
+
or (
|
|
2841
|
+
literal == "bare"
|
|
2842
|
+
and (argument.startswith("#") or GIT_CONFIG_EXPANDING_WORD.search(argument))
|
|
2843
|
+
)
|
|
2844
|
+
):
|
|
2845
|
+
raise ValueError(
|
|
2846
|
+
f"git argument {argument} cannot be resolved by the guard; "
|
|
2847
|
+
"pass a literal value"
|
|
2848
|
+
)
|
|
2849
|
+
denied = denied_git_config_argument(arguments[index + 1:])
|
|
2850
|
+
if denied is not None:
|
|
2851
|
+
raise ValueError(
|
|
2852
|
+
f"git config {denied} persists a program git executes; "
|
|
2853
|
+
"the guard cannot allow it"
|
|
2854
|
+
)
|
|
2855
|
+
read_only_subcommands = ARCHIVE_READ_GIT_COMMANDS | CLOSED_READ_GIT_COMMANDS
|
|
2856
|
+
if subcommand in read_only_subcommands:
|
|
2857
|
+
# Read-only subcommands still write through --output. A parameter
|
|
2858
|
+
# passes only inside plain double quotes, where its expansion stays
|
|
2859
|
+
# one word; an unquoted one can word-split into an option, and
|
|
2860
|
+
# command substitution output is never known.
|
|
2861
|
+
parameter_arguments = unproven_parameters
|
|
2862
|
+
if parameter_arguments:
|
|
2863
|
+
raise ValueError(
|
|
2864
|
+
f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
|
|
2865
|
+
"pass a literal value (for commit messages use -F <file>)"
|
|
2866
|
+
)
|
|
2867
|
+
return subcommand, arguments[index + 1:], git_options
|
|
1693
2868
|
|
|
1694
2869
|
|
|
1695
2870
|
def wrapped_command_tokens(segment, expand_parameters=True):
|
|
@@ -1698,7 +2873,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1698
2873
|
return None
|
|
1699
2874
|
executable, arguments = invocation
|
|
1700
2875
|
if executable == "eval":
|
|
1701
|
-
return shell_tokens(" ".join(arguments)) if arguments else None
|
|
2876
|
+
return shell_tokens(" ".join(arguments), direct=False) if arguments else None
|
|
1702
2877
|
if executable in COMMAND_WRAPPERS:
|
|
1703
2878
|
if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
|
|
1704
2879
|
return None
|
|
@@ -1723,7 +2898,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1723
2898
|
executable=executable, option=argument
|
|
1724
2899
|
)
|
|
1725
2900
|
)
|
|
1726
|
-
return shell_tokens(arguments[index + 1])
|
|
2901
|
+
return shell_tokens(arguments[index + 1], direct=False)
|
|
1727
2902
|
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1728
2903
|
if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
|
|
1729
2904
|
switches = {"-c", "-command", "/c", "/k"}
|
|
@@ -1742,8 +2917,8 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1742
2917
|
for argument in payload
|
|
1743
2918
|
]
|
|
1744
2919
|
if len(payload) == 1:
|
|
1745
|
-
return shell_tokens(payload[0])
|
|
1746
|
-
return payload
|
|
2920
|
+
return shell_tokens(payload[0], direct=False)
|
|
2921
|
+
return [str(argument) for argument in payload]
|
|
1747
2922
|
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1748
2923
|
return None
|
|
1749
2924
|
|
|
@@ -1798,14 +2973,18 @@ def update_ref_deletes(arguments):
|
|
|
1798
2973
|
)
|
|
1799
2974
|
|
|
1800
2975
|
|
|
1801
|
-
def destructive_git_reason(
|
|
1802
|
-
|
|
2976
|
+
def destructive_git_reason(
|
|
2977
|
+
tokens, resolved_aliases=frozenset(), initial_assignments=None
|
|
2978
|
+
):
|
|
2979
|
+
segments = list(command_segments(tokens))
|
|
2980
|
+
contexts, _ = shell_assignment_contexts(tokens, initial_assignments)
|
|
2981
|
+
for segment, assignments in zip(segments, contexts):
|
|
1803
2982
|
wrapped = wrapped_command_tokens(segment)
|
|
1804
2983
|
if wrapped is not None:
|
|
1805
|
-
reason = destructive_git_reason(wrapped, resolved_aliases)
|
|
2984
|
+
reason = destructive_git_reason(wrapped, resolved_aliases, assignments)
|
|
1806
2985
|
if reason is not None:
|
|
1807
2986
|
return reason
|
|
1808
|
-
invocation = git_command(segment)
|
|
2987
|
+
invocation = git_command(segment, assignments)
|
|
1809
2988
|
if invocation is None:
|
|
1810
2989
|
continue
|
|
1811
2990
|
command, arguments, git_options = invocation
|
|
@@ -1828,8 +3007,9 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
|
|
|
1828
3007
|
"git command directly"
|
|
1829
3008
|
)
|
|
1830
3009
|
reason = destructive_git_reason(
|
|
1831
|
-
["git", *git_options, *shell_tokens(alias), *arguments],
|
|
3010
|
+
["git", *git_options, *shell_tokens(alias, direct=False), *arguments],
|
|
1832
3011
|
resolved_aliases | {command},
|
|
3012
|
+
assignments,
|
|
1833
3013
|
)
|
|
1834
3014
|
if reason is not None:
|
|
1835
3015
|
return reason
|
|
@@ -1983,28 +3163,76 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
|
|
|
1983
3163
|
return False
|
|
1984
3164
|
|
|
1985
3165
|
|
|
1986
|
-
def archive_command_is_read_only(
|
|
3166
|
+
def archive_command_is_read_only(
|
|
3167
|
+
command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS,
|
|
3168
|
+
source=None,
|
|
3169
|
+
):
|
|
3170
|
+
"""`source` is the unexpanded (segment, assignments) whose tokens keep their quote flags."""
|
|
1987
3171
|
if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
|
|
1988
3172
|
return False
|
|
1989
3173
|
if not archive_context:
|
|
1990
3174
|
return True
|
|
1991
|
-
|
|
3175
|
+
invocation = command_invocation(tokens, unwrap_runners=False)
|
|
3176
|
+
if invocation is None:
|
|
3177
|
+
return True
|
|
3178
|
+
executable, arguments = invocation
|
|
3179
|
+
executable = executable.removesuffix(".exe")
|
|
3180
|
+
if executable in DIRECTORY_CHANGE_COMMANDS:
|
|
3181
|
+
return True
|
|
3182
|
+
if executable in {"export", "declare", "typeset", "unset"}:
|
|
3183
|
+
return True
|
|
1992
3184
|
denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
|
|
1993
3185
|
if any(
|
|
1994
3186
|
token == option or token.startswith(f"{option}=")
|
|
1995
|
-
for token in
|
|
3187
|
+
for token in arguments
|
|
1996
3188
|
for option in denied_options
|
|
1997
3189
|
):
|
|
1998
3190
|
return False
|
|
3191
|
+
if executable == "sed":
|
|
3192
|
+
in_place, _inputs, script_is_read_only = sed_arguments(arguments)
|
|
3193
|
+
return not in_place and script_is_read_only
|
|
1999
3194
|
if executable in ARCHIVE_READ_COMMANDS:
|
|
2000
3195
|
return True
|
|
2001
|
-
if executable != "git"
|
|
3196
|
+
if executable != "git":
|
|
3197
|
+
return False
|
|
3198
|
+
git = git_command(*(source or (tokens,)))
|
|
3199
|
+
if git is None:
|
|
2002
3200
|
return False
|
|
2003
|
-
|
|
3201
|
+
subcommand, git_arguments, _options = git
|
|
3202
|
+
if subcommand not in git_commands:
|
|
2004
3203
|
return False
|
|
2005
3204
|
return not any(
|
|
2006
|
-
token
|
|
2007
|
-
|
|
3205
|
+
git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
|
|
3206
|
+
)
|
|
3207
|
+
|
|
3208
|
+
|
|
3209
|
+
def archive_write_attempt(segment, source=None):
|
|
3210
|
+
"""Whether a segment denied in archive context attempts a write there."""
|
|
3211
|
+
if any(
|
|
3212
|
+
token
|
|
3213
|
+
and (set(token) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
3214
|
+
or re.fullmatch(r"\d*[<>]+&?", token))
|
|
3215
|
+
for token in segment
|
|
3216
|
+
):
|
|
3217
|
+
return True
|
|
3218
|
+
invocation = command_invocation(segment)
|
|
3219
|
+
if invocation is None:
|
|
3220
|
+
return False
|
|
3221
|
+
executable, arguments = invocation
|
|
3222
|
+
executable = executable.removesuffix(".exe")
|
|
3223
|
+
if executable in SHELL_WRITE_COMMANDS:
|
|
3224
|
+
return True
|
|
3225
|
+
if executable in IN_PLACE_EDITORS and (
|
|
3226
|
+
has_short_option(arguments, "i")
|
|
3227
|
+
or any(argument.startswith("--in-place") for argument in arguments)
|
|
3228
|
+
):
|
|
3229
|
+
return True
|
|
3230
|
+
git = git_command(*(source or (segment,)))
|
|
3231
|
+
if git is None:
|
|
3232
|
+
return False
|
|
3233
|
+
subcommand, git_arguments, _options = git
|
|
3234
|
+
return subcommand not in ARCHIVE_READ_GIT_COMMANDS or any(
|
|
3235
|
+
git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
|
|
2008
3236
|
)
|
|
2009
3237
|
|
|
2010
3238
|
|
|
@@ -2126,22 +3354,28 @@ def closed_git_listing(tokens):
|
|
|
2126
3354
|
return not positionals
|
|
2127
3355
|
|
|
2128
3356
|
|
|
2129
|
-
def closed_shell_execution_reason(
|
|
3357
|
+
def closed_shell_execution_reason(
|
|
3358
|
+
command, tokens, working_directories, initial_assignments=None
|
|
3359
|
+
):
|
|
2130
3360
|
helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
|
|
2131
3361
|
rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
|
|
2132
3362
|
if rest[:1] == ["pending"]:
|
|
2133
3363
|
helpers = helpers | {"discussion_records.py"}
|
|
2134
3364
|
if bundled_helper_invocation(command, tokens, working_directories, helpers):
|
|
2135
3365
|
return None
|
|
2136
|
-
for segment, directories in
|
|
3366
|
+
for segment, directories, assignments, _prefix in shell_segment_contexts(
|
|
3367
|
+
tokens, working_directories, initial_assignments
|
|
3368
|
+
):
|
|
2137
3369
|
wrapped = wrapped_command_tokens(segment)
|
|
2138
3370
|
if wrapped is not None:
|
|
2139
|
-
reason = closed_shell_execution_reason(
|
|
3371
|
+
reason = closed_shell_execution_reason(
|
|
3372
|
+
shlex.join(wrapped), wrapped, directories, assignments
|
|
3373
|
+
)
|
|
2140
3374
|
if reason:
|
|
2141
3375
|
return reason
|
|
2142
3376
|
continue
|
|
2143
3377
|
metadata_closed = None
|
|
2144
|
-
git = git_command(segment)
|
|
3378
|
+
git = git_command(segment, assignments)
|
|
2145
3379
|
checked_segment = segment
|
|
2146
3380
|
if git is not None:
|
|
2147
3381
|
subcommand, arguments, options = git
|
|
@@ -2180,6 +3414,7 @@ def closed_shell_execution_reason(command, tokens, working_directories):
|
|
|
2180
3414
|
def command_denial(command, working_directories, allow_destructive=True):
|
|
2181
3415
|
"""Return why a shell command is denied, or None when it may run."""
|
|
2182
3416
|
working_directories = working_directories or [os.getcwd()]
|
|
3417
|
+
command = re.sub(r"(?i:(\.project)\\(archive))", r"\1/\2", command)
|
|
2183
3418
|
outer, substitutions = split_command_substitutions(command)
|
|
2184
3419
|
try:
|
|
2185
3420
|
tokens = shell_tokens(outer)
|
|
@@ -2188,6 +3423,7 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
2188
3423
|
for interpreter in ("python3", "python", "py -3")
|
|
2189
3424
|
):
|
|
2190
3425
|
return None
|
|
3426
|
+
bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
|
|
2191
3427
|
destructive = list(destructive_shell_invocations(tokens))
|
|
2192
3428
|
if destructive and (
|
|
2193
3429
|
not allow_destructive
|
|
@@ -2208,16 +3444,31 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
2208
3444
|
reason = command_denial(inner, working_directories, allow_destructive=False)
|
|
2209
3445
|
if reason is not None:
|
|
2210
3446
|
return reason
|
|
2211
|
-
|
|
2212
|
-
|
|
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)
|
|
3447
|
+
for segment, directories, assignments, prefix in shell_segment_contexts(
|
|
3448
|
+
tokens, working_directories
|
|
2219
3449
|
):
|
|
2220
|
-
|
|
3450
|
+
resolved_segment = [
|
|
3451
|
+
expand_environment_parameters(token, assignments) for token in segment
|
|
3452
|
+
]
|
|
3453
|
+
archive_context = (
|
|
3454
|
+
any(working_directory_in_archive(path) for path in directories)
|
|
3455
|
+
or segment_references_archive(segment, directories, assignments)
|
|
3456
|
+
or any(
|
|
3457
|
+
ARCHIVE_REFERENCE.search(value)
|
|
3458
|
+
or path_in_archive(value, directories, ancestors=False)
|
|
3459
|
+
for value in prefix.values()
|
|
3460
|
+
)
|
|
3461
|
+
)
|
|
3462
|
+
if not archive_command_is_read_only(
|
|
3463
|
+
shlex.join(resolved_segment), resolved_segment, archive_context,
|
|
3464
|
+
source=(segment, assignments),
|
|
3465
|
+
) and not (
|
|
3466
|
+
archive_context
|
|
3467
|
+
and bundled_helper
|
|
3468
|
+
):
|
|
3469
|
+
if archive_write_attempt(resolved_segment, (segment, assignments)):
|
|
3470
|
+
return ARCHIVE_REASON
|
|
3471
|
+
return ARCHIVE_UNPROVEN_READ_REASON
|
|
2221
3472
|
reason = destructive_git_reason(tokens) or protected_shell_write_reason(
|
|
2222
3473
|
tokens, working_directories
|
|
2223
3474
|
)
|