@opengsd/gsd-path 1.3.1 → 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/AGENTS.md +2 -207
- package/CHANGELOG.md +331 -69
- package/DOCS.md +25 -9
- package/FULL.md +12 -5
- package/GUIDE.md +1 -1
- package/HOOKS.md +104 -14
- package/QUICK.md +5 -2
- package/README.md +131 -111
- package/RUNTIME.md +2 -2
- package/UPDATE.md +61 -11
- package/WORKFLOW.md +44 -7
- package/package.json +13 -9
- package/platforms/shared-agents/dispatch.md +13 -0
- package/scripts/_common.py +619 -18
- package/scripts/archive_milestone.py +160 -86
- package/scripts/bootstrap_repository.py +8 -5
- package/scripts/build_recovery.py +100 -32
- package/scripts/build_state.py +143 -24
- package/scripts/check_docs_audit.py +91 -7
- package/scripts/check_handoffs.py +662 -50
- package/scripts/check_task_briefs.py +338 -30
- package/scripts/check_update.py +2 -2
- package/scripts/core_hook_gate.py +10 -1
- package/scripts/core_hook_settings.py +11 -4
- package/scripts/detect_project.py +587 -32
- package/scripts/discussion_records.py +19 -13
- package/scripts/discussion_validate.py +152 -50
- package/scripts/dispatch_driver.py +501 -46
- package/scripts/git_guard.py +118 -31
- package/scripts/guard_hook.py +1567 -194
- package/scripts/install.mjs +77 -46
- package/scripts/install.py +611 -50
- package/scripts/integration.py +580 -25
- package/scripts/isolation.py +1438 -57
- package/scripts/lean_verification.py +99 -19
- package/scripts/loop_run.py +31 -29
- package/scripts/members.py +706 -0
- package/scripts/migrate_core.py +2 -2
- package/scripts/path_config.py +5 -5
- package/scripts/pipeline_diagnose.py +82 -31
- package/scripts/pipeline_git.py +124 -34
- package/scripts/pipeline_state.py +287 -87
- package/scripts/pipeline_undo.py +218 -30
- package/scripts/promote_lookahead.py +1 -1
- package/scripts/review_panel.py +1 -1
- package/scripts/runtime_store.py +55 -11
- package/scripts/skill-resources.json +37 -5
- package/scripts/state_checkpoint.py +135 -44
- package/scripts/state_promote.py +2 -0
- package/scripts/status_runtime.py +58 -10
- package/scripts/sync_skill_resources.py +8 -7
- package/scripts/task_context.py +12 -3
- package/scripts/token_budget.py +12 -8
- package/scripts/wizard.mjs +1 -0
- package/scripts/workflow_run.py +107 -30
- package/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path/BUILD.md +7 -1
- package/skills/gsd-path/DECIDE.md +11 -4
- package/skills/gsd-path/DEFINE.md +36 -4
- package/skills/gsd-path/DOCS-AUDIT.md +6 -4
- package/skills/gsd-path/INSPECT.md +5 -4
- package/skills/gsd-path/PLAN.md +18 -4
- package/skills/gsd-path/RESEARCH.md +13 -1
- package/skills/gsd-path/ROADMAP.md +2 -1
- package/skills/gsd-path/SHIP.md +33 -11
- package/skills/gsd-path/SKILL.md +45 -1
- package/skills/gsd-path/references/build-native.md +63 -15
- package/skills/gsd-path/references/codebase-mapper.md +2 -1
- package/skills/gsd-path/references/dispatch.md +13 -0
- package/skills/gsd-path/references/docs-auditor.md +7 -1
- package/skills/gsd-path/references/operating-rules.md +214 -0
- package/skills/gsd-path/references/planner.md +22 -4
- package/skills/gsd-path/references/reviewer-final.md +20 -2
- package/skills/gsd-path/references/reviewer-wave.md +18 -5
- 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 +619 -18
- package/skills/gsd-path/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path/scripts/bootstrap_repository.py +8 -5
- package/skills/gsd-path/scripts/build_recovery.py +100 -32
- package/skills/gsd-path/scripts/build_state.py +143 -24
- package/skills/gsd-path/scripts/check_docs_audit.py +91 -7
- package/skills/gsd-path/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path/scripts/check_update.py +2 -2
- package/skills/gsd-path/scripts/detect_project.py +587 -32
- package/skills/gsd-path/scripts/discussion_records.py +19 -13
- package/skills/gsd-path/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path/scripts/dispatch_driver.py +501 -46
- package/skills/gsd-path/scripts/git_guard.py +118 -31
- package/skills/gsd-path/scripts/integration.py +580 -25
- package/skills/gsd-path/scripts/isolation.py +1438 -57
- package/skills/gsd-path/scripts/lean_verification.py +99 -19
- package/skills/gsd-path/scripts/loop_run.py +31 -29
- package/skills/gsd-path/scripts/members.py +706 -0
- package/skills/gsd-path/scripts/path_config.py +5 -5
- package/skills/gsd-path/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path/scripts/review_panel.py +1 -1
- package/skills/gsd-path/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path/scripts/state_promote.py +2 -0
- package/skills/gsd-path/scripts/status_runtime.py +58 -10
- package/skills/gsd-path/scripts/task_context.py +12 -3
- package/skills/gsd-path/scripts/token_budget.py +12 -8
- package/skills/gsd-path/scripts/workflow_run.py +107 -30
- package/skills/gsd-path/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path/templates/codebase.md +1 -1
- package/skills/gsd-path/templates/docs-audit.md +6 -2
- package/skills/gsd-path/templates/final-review.md +16 -4
- package/skills/gsd-path/templates/gap-review.md +2 -1
- 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/task.md +1 -0
- package/skills/gsd-path/templates/wave-review.md +16 -4
- package/skills/gsd-path-build/SKILL.md +7 -1
- package/skills/gsd-path-build/references/build-native.md +63 -15
- package/skills/gsd-path-build/references/dispatch.md +13 -0
- package/skills/gsd-path-build/references/operating-rules.md +214 -0
- package/skills/gsd-path-build/references/reviewer-final.md +20 -2
- package/skills/gsd-path-build/references/reviewer-wave.md +18 -5
- 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 +619 -18
- package/skills/gsd-path-build/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-build/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-build/scripts/build_state.py +143 -24
- package/skills/gsd-path-build/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-build/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-build/scripts/detect_project.py +587 -32
- package/skills/gsd-path-build/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-build/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-build/scripts/dispatch_driver.py +501 -46
- package/skills/gsd-path-build/scripts/integration.py +580 -25
- package/skills/gsd-path-build/scripts/isolation.py +1438 -57
- package/skills/gsd-path-build/scripts/loop_run.py +31 -29
- package/skills/gsd-path-build/scripts/members.py +706 -0
- package/skills/gsd-path-build/scripts/path_config.py +5 -5
- package/skills/gsd-path-build/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-build/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-build/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-build/scripts/review_panel.py +1 -1
- package/skills/gsd-path-build/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-build/scripts/state_promote.py +2 -0
- package/skills/gsd-path-build/scripts/task_context.py +12 -3
- package/skills/gsd-path-build/scripts/token_budget.py +12 -8
- package/skills/gsd-path-build/scripts/workflow_run.py +107 -30
- package/skills/gsd-path-build/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-build/templates/task.md +1 -0
- package/skills/gsd-path-build/templates/wave-review.md +16 -4
- package/skills/gsd-path-decide/SKILL.md +11 -4
- package/skills/gsd-path-decide/references/dispatch.md +13 -0
- package/skills/gsd-path-decide/references/operating-rules.md +214 -0
- package/skills/gsd-path-decide/scripts/_common.py +619 -18
- package/skills/gsd-path-decide/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-decide/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-decide/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-decide/scripts/detect_project.py +587 -32
- package/skills/gsd-path-decide/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-decide/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-decide/scripts/integration.py +580 -25
- package/skills/gsd-path-decide/scripts/isolation.py +1438 -57
- package/skills/gsd-path-decide/scripts/loop_run.py +31 -29
- package/skills/gsd-path-decide/scripts/members.py +706 -0
- package/skills/gsd-path-decide/scripts/path_config.py +5 -5
- package/skills/gsd-path-decide/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-decide/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-decide/scripts/review_panel.py +1 -1
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-decide/scripts/state_promote.py +2 -0
- package/skills/gsd-path-decide/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-define/SKILL.md +36 -4
- package/skills/gsd-path-define/references/operating-rules.md +214 -0
- package/skills/gsd-path-define/references/spec-probes.md +150 -0
- package/skills/gsd-path-define/scripts/_common.py +619 -18
- package/skills/gsd-path-define/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-define/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-define/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-define/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-define/scripts/detect_project.py +587 -32
- package/skills/gsd-path-define/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-define/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-define/scripts/integration.py +580 -25
- package/skills/gsd-path-define/scripts/isolation.py +1438 -57
- package/skills/gsd-path-define/scripts/loop_run.py +31 -29
- package/skills/gsd-path-define/scripts/members.py +706 -0
- package/skills/gsd-path-define/scripts/path_config.py +5 -5
- package/skills/gsd-path-define/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-define/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-define/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-define/scripts/review_panel.py +1 -1
- package/skills/gsd-path-define/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-define/scripts/state_promote.py +2 -0
- package/skills/gsd-path-define/scripts/worktree_paths.py +72 -10
- 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/SKILL.md +2 -1
- package/skills/gsd-path-discuss/references/operating-rules.md +214 -0
- package/skills/gsd-path-discuss/scripts/_common.py +619 -18
- package/skills/gsd-path-discuss/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-discuss/scripts/loop_run.py +31 -29
- package/skills/gsd-path-discuss/scripts/path_config.py +5 -5
- package/skills/gsd-path-discuss/scripts/review_panel.py +1 -1
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-docs-audit/SKILL.md +6 -4
- package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +7 -1
- package/skills/gsd-path-docs-audit/references/operating-rules.md +214 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +619 -18
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +91 -7
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +587 -32
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-docs-audit/scripts/integration.py +580 -25
- package/skills/gsd-path-docs-audit/scripts/isolation.py +1438 -57
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +31 -29
- package/skills/gsd-path-docs-audit/scripts/members.py +706 -0
- package/skills/gsd-path-docs-audit/scripts/path_config.py +5 -5
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-docs-audit/scripts/review_panel.py +1 -1
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +2 -0
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +6 -2
- package/skills/gsd-path-forensics/SKILL.md +2 -1
- package/skills/gsd-path-forensics/references/operating-rules.md +214 -0
- package/skills/gsd-path-forensics/scripts/_common.py +619 -18
- package/skills/gsd-path-forensics/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-forensics/scripts/loop_run.py +31 -29
- package/skills/gsd-path-forensics/scripts/path_config.py +5 -5
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +82 -31
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +218 -30
- package/skills/gsd-path-forensics/scripts/review_panel.py +1 -1
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-inspect/SKILL.md +5 -4
- package/skills/gsd-path-inspect/references/codebase-mapper.md +2 -1
- package/skills/gsd-path-inspect/references/dispatch.md +13 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +7 -1
- package/skills/gsd-path-inspect/references/operating-rules.md +214 -0
- package/skills/gsd-path-inspect/scripts/_common.py +619 -18
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-inspect/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +91 -7
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-inspect/scripts/detect_project.py +587 -32
- package/skills/gsd-path-inspect/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-inspect/scripts/integration.py +580 -25
- package/skills/gsd-path-inspect/scripts/isolation.py +1438 -57
- package/skills/gsd-path-inspect/scripts/loop_run.py +31 -29
- package/skills/gsd-path-inspect/scripts/members.py +706 -0
- package/skills/gsd-path-inspect/scripts/path_config.py +5 -5
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-inspect/scripts/review_panel.py +1 -1
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-inspect/scripts/state_promote.py +2 -0
- package/skills/gsd-path-inspect/scripts/workflow_run.py +107 -30
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-inspect/templates/codebase.md +1 -1
- package/skills/gsd-path-inspect/templates/docs-audit.md +6 -2
- package/skills/gsd-path-inspect/templates/state.md +1 -1
- package/skills/gsd-path-loop/SKILL.md +2 -1
- package/skills/gsd-path-loop/references/operating-rules.md +214 -0
- package/skills/gsd-path-loop/scripts/_common.py +619 -18
- package/skills/gsd-path-loop/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-loop/scripts/loop_run.py +31 -29
- package/skills/gsd-path-loop/scripts/path_config.py +5 -5
- package/skills/gsd-path-loop/scripts/review_panel.py +1 -1
- package/skills/gsd-path-loop/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-migrate/SKILL.md +2 -1
- package/skills/gsd-path-migrate/references/operating-rules.md +214 -0
- package/skills/gsd-path-migrate/scripts/_common.py +619 -18
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +10 -1
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +11 -4
- package/skills/gsd-path-migrate/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-migrate/scripts/loop_run.py +31 -29
- package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -2
- package/skills/gsd-path-migrate/scripts/path_config.py +5 -5
- package/skills/gsd-path-migrate/scripts/review_panel.py +1 -1
- package/skills/gsd-path-plan/SKILL.md +18 -4
- package/skills/gsd-path-plan/references/dispatch.md +13 -0
- package/skills/gsd-path-plan/references/operating-rules.md +214 -0
- package/skills/gsd-path-plan/references/planner.md +22 -4
- package/skills/gsd-path-plan/references/spec-probes.md +150 -0
- package/skills/gsd-path-plan/scripts/_common.py +619 -18
- package/skills/gsd-path-plan/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-plan/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-plan/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-plan/scripts/detect_project.py +587 -32
- package/skills/gsd-path-plan/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-plan/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-plan/scripts/integration.py +580 -25
- package/skills/gsd-path-plan/scripts/isolation.py +1438 -57
- package/skills/gsd-path-plan/scripts/loop_run.py +31 -29
- package/skills/gsd-path-plan/scripts/members.py +706 -0
- package/skills/gsd-path-plan/scripts/path_config.py +5 -5
- package/skills/gsd-path-plan/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-plan/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-plan/scripts/review_panel.py +1 -1
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-plan/scripts/state_promote.py +2 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +107 -30
- package/skills/gsd-path-plan/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
- package/skills/gsd-path-plan/templates/plan.md +17 -2
- package/skills/gsd-path-plan/templates/task.md +1 -0
- package/skills/gsd-path-research/SKILL.md +13 -1
- package/skills/gsd-path-research/references/dispatch.md +13 -0
- package/skills/gsd-path-research/references/operating-rules.md +214 -0
- package/skills/gsd-path-research/scripts/_common.py +619 -18
- package/skills/gsd-path-research/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-research/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-research/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-research/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-research/scripts/detect_project.py +587 -32
- package/skills/gsd-path-research/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-research/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-research/scripts/integration.py +580 -25
- package/skills/gsd-path-research/scripts/isolation.py +1438 -57
- package/skills/gsd-path-research/scripts/loop_run.py +31 -29
- package/skills/gsd-path-research/scripts/members.py +706 -0
- package/skills/gsd-path-research/scripts/path_config.py +5 -5
- package/skills/gsd-path-research/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-research/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-research/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-research/scripts/review_panel.py +1 -1
- package/skills/gsd-path-research/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-research/scripts/state_promote.py +2 -0
- package/skills/gsd-path-research/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-research/templates/research-handoff.md +14 -3
- package/skills/gsd-path-roadmap/SKILL.md +2 -1
- package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
- package/skills/gsd-path-roadmap/references/operating-rules.md +214 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +619 -18
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-roadmap/scripts/detect_project.py +587 -32
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-roadmap/scripts/integration.py +580 -25
- package/skills/gsd-path-roadmap/scripts/isolation.py +1438 -57
- package/skills/gsd-path-roadmap/scripts/loop_run.py +31 -29
- package/skills/gsd-path-roadmap/scripts/members.py +706 -0
- package/skills/gsd-path-roadmap/scripts/path_config.py +5 -5
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-roadmap/scripts/review_panel.py +1 -1
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-roadmap/scripts/state_promote.py +2 -0
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-ship/SKILL.md +33 -11
- package/skills/gsd-path-ship/references/dispatch.md +13 -0
- package/skills/gsd-path-ship/references/operating-rules.md +214 -0
- package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
- package/skills/gsd-path-ship/references/reviewer-wave.md +18 -5
- 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 +619 -18
- package/skills/gsd-path-ship/scripts/archive_milestone.py +160 -86
- package/skills/gsd-path-ship/scripts/build_recovery.py +100 -32
- package/skills/gsd-path-ship/scripts/build_state.py +143 -24
- package/skills/gsd-path-ship/scripts/check_handoffs.py +662 -50
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +338 -30
- package/skills/gsd-path-ship/scripts/detect_project.py +587 -32
- package/skills/gsd-path-ship/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-ship/scripts/discussion_validate.py +152 -50
- package/skills/gsd-path-ship/scripts/integration.py +580 -25
- package/skills/gsd-path-ship/scripts/isolation.py +1438 -57
- package/skills/gsd-path-ship/scripts/lean_verification.py +99 -19
- package/skills/gsd-path-ship/scripts/loop_run.py +31 -29
- package/skills/gsd-path-ship/scripts/members.py +706 -0
- package/skills/gsd-path-ship/scripts/path_config.py +5 -5
- package/skills/gsd-path-ship/scripts/pipeline_git.py +124 -34
- package/skills/gsd-path-ship/scripts/pipeline_state.py +287 -87
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +1 -1
- package/skills/gsd-path-ship/scripts/review_panel.py +1 -1
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +135 -44
- package/skills/gsd-path-ship/scripts/state_promote.py +2 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +107 -30
- package/skills/gsd-path-ship/scripts/worktree_paths.py +72 -10
- package/skills/gsd-path-ship/templates/final-review.md +16 -4
- package/skills/gsd-path-ship/templates/gap-review.md +2 -1
- package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
- package/skills/gsd-path-ship/templates/wave-review.md +16 -4
- package/skills/gsd-path-undo/SKILL.md +2 -1
- package/skills/gsd-path-undo/references/operating-rules.md +214 -0
- package/skills/gsd-path-undo/scripts/_common.py +619 -18
- package/skills/gsd-path-undo/scripts/discussion_records.py +19 -13
- package/skills/gsd-path-undo/scripts/loop_run.py +31 -29
- package/skills/gsd-path-undo/scripts/path_config.py +5 -5
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +218 -30
- package/skills/gsd-path-undo/scripts/review_panel.py +1 -1
- package/skills/gsd-path-undo/scripts/worktree_paths.py +72 -10
- package/skills/path/BUILD.md +7 -1
- package/skills/path/DECIDE.md +11 -4
- package/skills/path/DEFINE.md +36 -4
- package/skills/path/DOCS-AUDIT.md +6 -4
- package/skills/path/INSPECT.md +5 -4
- package/skills/path/PLAN.md +18 -4
- package/skills/path/RESEARCH.md +13 -1
- package/skills/path/ROADMAP.md +2 -1
- package/skills/path/SHIP.md +33 -11
- package/skills/path/SKILL.md +45 -1
- package/skills/path/references/build-native.md +63 -15
- package/skills/path/references/codebase-mapper.md +2 -1
- package/skills/path/references/dispatch.md +13 -0
- package/skills/path/references/docs-auditor.md +7 -1
- package/skills/path/references/operating-rules.md +214 -0
- package/skills/path/references/planner.md +22 -4
- package/skills/path/references/reviewer-final.md +20 -2
- package/skills/path/references/reviewer-wave.md +18 -5
- package/skills/path/references/reviewer.md +3 -1
- package/skills/path/references/spec-probes.md +150 -0
- package/skills/path/scripts/_common.py +619 -18
- package/skills/path/scripts/archive_milestone.py +160 -86
- package/skills/path/scripts/bootstrap_repository.py +8 -5
- package/skills/path/scripts/build_recovery.py +100 -32
- package/skills/path/scripts/build_state.py +143 -24
- package/skills/path/scripts/check_docs_audit.py +91 -7
- package/skills/path/scripts/check_handoffs.py +662 -50
- package/skills/path/scripts/check_task_briefs.py +338 -30
- package/skills/path/scripts/check_update.py +2 -2
- package/skills/path/scripts/detect_project.py +587 -32
- package/skills/path/scripts/discussion_records.py +19 -13
- package/skills/path/scripts/discussion_validate.py +152 -50
- package/skills/path/scripts/dispatch_driver.py +501 -46
- package/skills/path/scripts/git_guard.py +118 -31
- package/skills/path/scripts/integration.py +580 -25
- package/skills/path/scripts/isolation.py +1438 -57
- package/skills/path/scripts/lean_verification.py +99 -19
- package/skills/path/scripts/loop_run.py +31 -29
- package/skills/path/scripts/members.py +706 -0
- package/skills/path/scripts/path_config.py +5 -5
- package/skills/path/scripts/pipeline_git.py +124 -34
- package/skills/path/scripts/pipeline_state.py +287 -87
- package/skills/path/scripts/promote_lookahead.py +1 -1
- package/skills/path/scripts/review_panel.py +1 -1
- package/skills/path/scripts/state_checkpoint.py +135 -44
- package/skills/path/scripts/state_promote.py +2 -0
- package/skills/path/scripts/status_runtime.py +58 -10
- package/skills/path/scripts/task_context.py +12 -3
- package/skills/path/scripts/token_budget.py +12 -8
- package/skills/path/scripts/workflow_run.py +107 -30
- package/skills/path/scripts/worktree_paths.py +72 -10
- package/skills/path/templates/codebase.md +1 -1
- package/skills/path/templates/docs-audit.md +6 -2
- package/skills/path/templates/final-review.md +16 -4
- package/skills/path/templates/gap-review.md +2 -1
- 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/task.md +1 -0
- package/skills/path/templates/wave-review.md +16 -4
- 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
|
@@ -25,6 +25,7 @@ import json
|
|
|
25
25
|
import os
|
|
26
26
|
import re
|
|
27
27
|
import shlex
|
|
28
|
+
import stat
|
|
28
29
|
import subprocess
|
|
29
30
|
import sys
|
|
30
31
|
from pathlib import Path, PurePosixPath
|
|
@@ -120,6 +121,10 @@ ARCHIVE_REASON = (
|
|
|
120
121
|
"committed GSD Path archives under .project/archive/ are read-only; "
|
|
121
122
|
"only the bundled pipeline helpers may write there during a ship transaction"
|
|
122
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
|
+
)
|
|
123
128
|
DESTRUCTIVE_SHAPE_REASON = (
|
|
124
129
|
"destructive commands must run alone with literal paths; split compound, "
|
|
125
130
|
"substituted, or expanded destructive commands into single commands"
|
|
@@ -181,7 +186,7 @@ STATUS_STATE_FIELDS = frozenset(
|
|
|
181
186
|
"integration_source",
|
|
182
187
|
}
|
|
183
188
|
)
|
|
184
|
-
STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
|
|
189
|
+
STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request", "external-landing"})
|
|
185
190
|
STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
|
|
186
191
|
STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
187
192
|
STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
|
|
@@ -205,6 +210,7 @@ ARCHIVE_READ_COMMANDS = frozenset(
|
|
|
205
210
|
"tail",
|
|
206
211
|
"grep",
|
|
207
212
|
"rg",
|
|
213
|
+
"sed",
|
|
208
214
|
"ls",
|
|
209
215
|
"stat",
|
|
210
216
|
"wc",
|
|
@@ -232,6 +238,147 @@ CLOSED_LISTING_GIT_OPTIONS = {
|
|
|
232
238
|
"symbolic-ref": frozenset({"-q", "--quiet", "--short"}),
|
|
233
239
|
}
|
|
234
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
|
+
|
|
235
382
|
ARCHIVE_READ_EXECUTION_OPTIONS = {"rg": frozenset({"--pre"})}
|
|
236
383
|
AMBIGUOUS_SHELL_SYNTAX = re.compile(r"[\r\n|;&<>`]|\$\(|@\(")
|
|
237
384
|
SUBSTITUTION_PLACEHOLDER = "COMMAND_SUBSTITUTION_"
|
|
@@ -239,10 +386,6 @@ SUBSTITUTION_PLACEHOLDER_SYNTAX = re.compile(
|
|
|
239
386
|
r"\$\{" + SUBSTITUTION_PLACEHOLDER + r"(\d+)\}"
|
|
240
387
|
)
|
|
241
388
|
LINE_CONTINUATION = re.compile(r"(?<!\\)((?:\\\\)*)\\\r?\n")
|
|
242
|
-
HEREDOC_PATTERN = re.compile(
|
|
243
|
-
r"<<(?P<dash>-?)\s*(?:'(?P<single>[^']+)'|\"(?P<double>[^\"]+)\""
|
|
244
|
-
r"|\\?(?P<bare>[A-Za-z_][A-Za-z0-9_]*))"
|
|
245
|
-
)
|
|
246
389
|
SHELL_WRITE_REDIRECTION_CHARS = frozenset("<>&|")
|
|
247
390
|
SHELL_PARAMETER_SYNTAX = re.compile(
|
|
248
391
|
r"\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[-*@#?$!]|\{[^}\r\n]+\})"
|
|
@@ -250,9 +393,12 @@ SHELL_PARAMETER_SYNTAX = re.compile(
|
|
|
250
393
|
NAMED_SHELL_PARAMETER_SYNTAX = re.compile(
|
|
251
394
|
r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})"
|
|
252
395
|
)
|
|
396
|
+
ZSH_EXPANSION_FLAG_SYNTAX = re.compile(r"\$[=~^+]")
|
|
253
397
|
CMD_PARAMETER_SYNTAX = re.compile(
|
|
254
398
|
r"%([A-Za-z_][A-Za-z0-9_]*)%|!([A-Za-z_][A-Za-z0-9_]*)!"
|
|
255
399
|
)
|
|
400
|
+
# The shell reassigns these on every cd, so a tracked value is stale.
|
|
401
|
+
SHELL_DIRECTORY_VARIABLES = frozenset({"PWD", "OLDPWD"})
|
|
256
402
|
DIRECTORY_CHANGE_COMMANDS = frozenset(
|
|
257
403
|
{"cd", "chdir", "pushd", "set-location", "sl"}
|
|
258
404
|
)
|
|
@@ -305,6 +451,8 @@ MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
|
|
|
305
451
|
UNVALIDATED_EXECUTION_REASONS = {
|
|
306
452
|
".": SCRIPT_FILE_REASON,
|
|
307
453
|
"source": SCRIPT_FILE_REASON,
|
|
454
|
+
"readonly": "readonly changes shell assignment behavior the guard cannot validate; "
|
|
455
|
+
"avoid readonly declarations",
|
|
308
456
|
"start": "start launches a detached process the guard cannot inspect; run the program directly",
|
|
309
457
|
"setenv": ENV_BUILTIN_REASON,
|
|
310
458
|
"unsetenv": ENV_BUILTIN_REASON,
|
|
@@ -369,7 +517,64 @@ SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
|
|
|
369
517
|
}
|
|
370
518
|
)
|
|
371
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
|
+
)
|
|
372
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
|
+
)
|
|
373
578
|
SHELL_CONTROL_WORDS = frozenset(
|
|
374
579
|
{
|
|
375
580
|
"!",
|
|
@@ -474,6 +679,20 @@ def collect(
|
|
|
474
679
|
)
|
|
475
680
|
|
|
476
681
|
|
|
682
|
+
def is_link_like(path):
|
|
683
|
+
"""A symlink, or on Windows any name-surrogate reparse point such as a junction.
|
|
684
|
+
|
|
685
|
+
Path.is_symlink() is false for a junction, which still redirects the path.
|
|
686
|
+
"""
|
|
687
|
+
try:
|
|
688
|
+
status = os.lstat(path)
|
|
689
|
+
except OSError:
|
|
690
|
+
return False
|
|
691
|
+
return stat.S_ISLNK(status.st_mode) or bool(
|
|
692
|
+
getattr(status, "st_reparse_tag", 0) & 0x20000000 # IO_REPARSE_TAG name surrogate
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
477
696
|
def normalize_posix(path):
|
|
478
697
|
text = path.replace("\\", "/").strip()
|
|
479
698
|
if not text:
|
|
@@ -536,7 +755,7 @@ def repository_root():
|
|
|
536
755
|
result = subprocess.run(
|
|
537
756
|
["git", "rev-parse", "--show-toplevel"],
|
|
538
757
|
cwd=candidate,
|
|
539
|
-
|
|
758
|
+
encoding="utf-8", errors="replace",
|
|
540
759
|
capture_output=True,
|
|
541
760
|
check=False,
|
|
542
761
|
)
|
|
@@ -544,7 +763,7 @@ def repository_root():
|
|
|
544
763
|
state = candidate / ".project" / "STATE.md"
|
|
545
764
|
if (
|
|
546
765
|
candidate.is_dir()
|
|
547
|
-
and not candidate
|
|
766
|
+
and not is_link_like(candidate)
|
|
548
767
|
and os.path.lexists(state)
|
|
549
768
|
):
|
|
550
769
|
return candidate
|
|
@@ -560,7 +779,7 @@ def project_status(repo):
|
|
|
560
779
|
result = subprocess.run(
|
|
561
780
|
[sys.executable, "-B", str(launcher), "--repo", str(repo)],
|
|
562
781
|
cwd=repo,
|
|
563
|
-
|
|
782
|
+
encoding="utf-8", errors="replace",
|
|
564
783
|
capture_output=True,
|
|
565
784
|
check=False,
|
|
566
785
|
)
|
|
@@ -697,13 +916,29 @@ def valid_status_branch(value):
|
|
|
697
916
|
|
|
698
917
|
def target_paths(path, working_directories, repo):
|
|
699
918
|
repo = repo.resolve()
|
|
700
|
-
candidate = Path(path)
|
|
919
|
+
candidate = Path(native_path_text(path))
|
|
701
920
|
if not candidate.is_absolute():
|
|
702
921
|
base = Path(working_directories[0]) if working_directories else repo
|
|
703
922
|
if not base.is_absolute():
|
|
704
923
|
base = repo / base
|
|
705
924
|
candidate = base / candidate
|
|
706
|
-
return Path(os.path.abspath(candidate)), candidate
|
|
925
|
+
return Path(os.path.abspath(candidate)), resolve_lenient(candidate)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def resolve_lenient(path):
|
|
929
|
+
"""Path.resolve(strict=False) that still follows links on Windows.
|
|
930
|
+
|
|
931
|
+
Python 3.9 raises OSError on Windows for names it cannot open (rep*) and
|
|
932
|
+
for some links to missing files; the archive checks must not skip those
|
|
933
|
+
(a symlinked archive path would be allowed), so fall back to
|
|
934
|
+
os.path.realpath, which resolves what it can and keeps the remainder.
|
|
935
|
+
"""
|
|
936
|
+
try:
|
|
937
|
+
return path.resolve(strict=False)
|
|
938
|
+
except OSError:
|
|
939
|
+
if os.name != "nt":
|
|
940
|
+
raise
|
|
941
|
+
return Path(os.path.realpath(path))
|
|
707
942
|
|
|
708
943
|
|
|
709
944
|
@lru_cache(maxsize=None)
|
|
@@ -712,7 +947,7 @@ def repository_control_roots(repo):
|
|
|
712
947
|
result = subprocess.run(
|
|
713
948
|
["git", "rev-parse", "--git-common-dir"],
|
|
714
949
|
cwd=repo,
|
|
715
|
-
|
|
950
|
+
encoding="utf-8", errors="replace",
|
|
716
951
|
capture_output=True,
|
|
717
952
|
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
718
953
|
check=False,
|
|
@@ -789,6 +1024,51 @@ def path_kind(candidate, repo, control_roots=()):
|
|
|
789
1024
|
return "product"
|
|
790
1025
|
|
|
791
1026
|
|
|
1027
|
+
def member_target_kind(candidate, repo):
|
|
1028
|
+
"""'product' when the path lies in a verified member of this coordinator.
|
|
1029
|
+
|
|
1030
|
+
A member follows its coordinator's phase. A stale member of this
|
|
1031
|
+
coordinator raises MembersError, so the caller denies the write.
|
|
1032
|
+
"""
|
|
1033
|
+
directory = candidate if candidate.is_dir() else candidate.parent
|
|
1034
|
+
while not directory.is_dir() and directory != directory.parent:
|
|
1035
|
+
directory = directory.parent
|
|
1036
|
+
found = subprocess.run(
|
|
1037
|
+
["git", "-C", str(directory), "rev-parse", "--path-format=absolute",
|
|
1038
|
+
"--show-toplevel", "--git-common-dir"],
|
|
1039
|
+
encoding="utf-8", errors="replace", capture_output=True, check=False,
|
|
1040
|
+
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
1041
|
+
)
|
|
1042
|
+
lines = found.stdout.splitlines()
|
|
1043
|
+
if found.returncode != 0:
|
|
1044
|
+
if found.stderr.strip().startswith("fatal: not a git repository"):
|
|
1045
|
+
return None
|
|
1046
|
+
raise RuntimeError(found.stderr.strip() or "Git inspection failed")
|
|
1047
|
+
if len(lines) != 2:
|
|
1048
|
+
raise RuntimeError("Git inspection returned an unexpected result")
|
|
1049
|
+
toplevel, common = Path(lines[0]), Path(lines[1])
|
|
1050
|
+
if not os.path.lexists(common / "gsd-path" / "member.json"):
|
|
1051
|
+
return None
|
|
1052
|
+
import members # the guard's runtime directory; only paths in a member need it
|
|
1053
|
+
coordinator = members.marker_coordinator(toplevel)
|
|
1054
|
+
if coordinator is None:
|
|
1055
|
+
return None
|
|
1056
|
+
if coordinator.resolve() != repo.resolve():
|
|
1057
|
+
for member in members.read_members(repo):
|
|
1058
|
+
try:
|
|
1059
|
+
listed_common = members._common_dir(Path(member["checkout"]))
|
|
1060
|
+
except (members.MembersError, OSError):
|
|
1061
|
+
continue
|
|
1062
|
+
if listed_common == common.resolve():
|
|
1063
|
+
raise members.MembersError(
|
|
1064
|
+
"member marker names an old coordinator; "
|
|
1065
|
+
"run members.py repair --repo <coordinator>"
|
|
1066
|
+
)
|
|
1067
|
+
return None
|
|
1068
|
+
members.member_role(toplevel)
|
|
1069
|
+
return "product"
|
|
1070
|
+
|
|
1071
|
+
|
|
792
1072
|
def target_kind(path, working_directories, repo, control_roots=None):
|
|
793
1073
|
lexical, resolved = target_paths(path, working_directories, repo)
|
|
794
1074
|
repo = repo.resolve()
|
|
@@ -798,10 +1078,21 @@ def target_kind(path, working_directories, repo, control_roots=None):
|
|
|
798
1078
|
_canonical_control_alias(lexical, repo, control_roots),
|
|
799
1079
|
_canonical_control_alias(resolved, repo, control_roots),
|
|
800
1080
|
)
|
|
801
|
-
|
|
1081
|
+
resolved_kind = path_kind(resolved, repo, control_roots)
|
|
1082
|
+
kinds = {resolved_kind} | {
|
|
802
1083
|
path_kind(candidate, repo, control_roots)
|
|
803
|
-
for candidate in (lexical,
|
|
1084
|
+
for candidate in (lexical, *aliases)
|
|
804
1085
|
}
|
|
1086
|
+
if resolved_kind == "external" and "protected" not in kinds:
|
|
1087
|
+
try:
|
|
1088
|
+
member_kind = member_target_kind(resolved, repo)
|
|
1089
|
+
except Exception as error:
|
|
1090
|
+
deny(
|
|
1091
|
+
f"member write refused: {error}; "
|
|
1092
|
+
"run members.py repair --repo <coordinator>"
|
|
1093
|
+
)
|
|
1094
|
+
if member_kind:
|
|
1095
|
+
kinds.add(member_kind)
|
|
805
1096
|
for kind in ("protected", "product", "artifact", "external"):
|
|
806
1097
|
if kind in kinds:
|
|
807
1098
|
return kind
|
|
@@ -824,12 +1115,12 @@ def pipeline_target_kinds(targets):
|
|
|
824
1115
|
def closed_target_reason(targets):
|
|
825
1116
|
for path, working_directories in targets:
|
|
826
1117
|
for target in dict.fromkeys(target_paths(path, working_directories, repository_root())):
|
|
827
|
-
directory = target if target.is_dir() and not target
|
|
1118
|
+
directory = target if target.is_dir() and not is_link_like(target) else target.parent
|
|
828
1119
|
while not directory.exists() and directory != directory.parent:
|
|
829
1120
|
directory = directory.parent
|
|
830
1121
|
root = subprocess.run(
|
|
831
1122
|
["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
|
|
832
|
-
capture_output=True,
|
|
1123
|
+
capture_output=True, encoding="utf-8", errors="replace",
|
|
833
1124
|
)
|
|
834
1125
|
if root.returncode == 0:
|
|
835
1126
|
reason = closed_milestone_reason(Path(root.stdout.strip()))
|
|
@@ -838,30 +1129,29 @@ def closed_target_reason(targets):
|
|
|
838
1129
|
return None
|
|
839
1130
|
|
|
840
1131
|
|
|
841
|
-
def
|
|
842
|
-
|
|
843
|
-
if reason:
|
|
844
|
-
deny(reason)
|
|
845
|
-
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."""
|
|
846
1134
|
if classified is None:
|
|
847
|
-
return
|
|
1135
|
+
return None
|
|
848
1136
|
repo, state, kinds = classified
|
|
849
1137
|
for path, kind in kinds:
|
|
850
1138
|
if kind == "protected":
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if all(kind == "external" for kind in
|
|
854
|
-
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
|
|
855
1145
|
try:
|
|
856
1146
|
status = project_status(repo)
|
|
857
1147
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
858
|
-
|
|
1148
|
+
return REENTRY_FAILURE_REASON
|
|
859
1149
|
route = status.get("route")
|
|
860
1150
|
state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
|
|
861
1151
|
completion = status.get("completion")
|
|
862
1152
|
if (state_data.get("phase") == "shipped"
|
|
863
1153
|
and isinstance(completion, dict) and completion.get("status") == "verified"):
|
|
864
|
-
return
|
|
1154
|
+
return None
|
|
865
1155
|
routed_build = (
|
|
866
1156
|
state_data.get("phase") == "build"
|
|
867
1157
|
and isinstance(route, dict)
|
|
@@ -869,20 +1159,31 @@ def enforce_pipeline_reentry(paths, working_directories):
|
|
|
869
1159
|
and route.get("phase") == "build"
|
|
870
1160
|
)
|
|
871
1161
|
if routed_build:
|
|
872
|
-
return
|
|
873
|
-
if all(kind in {"external", "artifact"} for kind in
|
|
874
|
-
return
|
|
1162
|
+
return None
|
|
1163
|
+
if all(kind in {"external", "artifact"} for kind in kind_values):
|
|
1164
|
+
return None
|
|
875
1165
|
phase = state_data.get("phase", "unknown")
|
|
876
1166
|
if isinstance(route, dict) and route.get("action") != "run-phase":
|
|
877
1167
|
next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
|
|
878
1168
|
else:
|
|
879
1169
|
next_step = status.get("next_skill") or "gsd-path"
|
|
880
|
-
|
|
1170
|
+
return (
|
|
881
1171
|
f"GSD Path is {phase}; direct product-file changes require the routed "
|
|
882
1172
|
f"build phase. Review {status.get('path', state)}; next: {next_step}"
|
|
883
1173
|
)
|
|
884
1174
|
|
|
885
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
|
+
|
|
886
1187
|
def in_archive(path):
|
|
887
1188
|
# Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
|
|
888
1189
|
normalized = normalize_posix(path).casefold()
|
|
@@ -919,10 +1220,35 @@ def expansion_can_match_archive(path):
|
|
|
919
1220
|
)
|
|
920
1221
|
|
|
921
1222
|
|
|
1223
|
+
# Git Bash (Claude Code's shell on Windows) names drive C: as /c or /cygdrive/c.
|
|
1224
|
+
MSYS_DRIVE_PATH = re.compile(r"^/(?:cygdrive/)?([A-Za-z])(?=/|$)")
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def native_path_text(value):
|
|
1228
|
+
"""On Windows, the drive path that a Git Bash path such as /c/repo names.
|
|
1229
|
+
|
|
1230
|
+
Python reads /c/repo as a folder named c at the root of the current drive,
|
|
1231
|
+
which would hide an archive from the containment checks.
|
|
1232
|
+
"""
|
|
1233
|
+
if os.name != "nt":
|
|
1234
|
+
return value
|
|
1235
|
+
text = value.replace("\\", "/")
|
|
1236
|
+
match = MSYS_DRIVE_PATH.match(text)
|
|
1237
|
+
if match is None:
|
|
1238
|
+
return value
|
|
1239
|
+
return f"{match.group(1).upper()}:{text[match.end():] or '/'}"
|
|
1240
|
+
|
|
1241
|
+
|
|
922
1242
|
def path_values(value):
|
|
923
|
-
|
|
1243
|
+
values = [value]
|
|
924
1244
|
if value.startswith("-") and "=" in value:
|
|
925
|
-
|
|
1245
|
+
values.append(value.split("=", 1)[1])
|
|
1246
|
+
for candidate in values:
|
|
1247
|
+
yield candidate
|
|
1248
|
+
# Deny when either reading of an ambiguous Windows path reaches an archive.
|
|
1249
|
+
native = native_path_text(candidate)
|
|
1250
|
+
if native != candidate:
|
|
1251
|
+
yield native
|
|
926
1252
|
|
|
927
1253
|
|
|
928
1254
|
def is_absolute_path(path):
|
|
@@ -932,7 +1258,7 @@ def is_absolute_path(path):
|
|
|
932
1258
|
|
|
933
1259
|
def contains_existing_archive(path, working_directories):
|
|
934
1260
|
try:
|
|
935
|
-
target = Path(path)
|
|
1261
|
+
target = resolve_lenient(Path(path))
|
|
936
1262
|
except (OSError, RuntimeError):
|
|
937
1263
|
return False
|
|
938
1264
|
search_paths = [target, Path.cwd(), *(Path(value) for value in working_directories)]
|
|
@@ -940,7 +1266,7 @@ def contains_existing_archive(path, working_directories):
|
|
|
940
1266
|
if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", str(search_path)):
|
|
941
1267
|
continue
|
|
942
1268
|
try:
|
|
943
|
-
resolved = search_path
|
|
1269
|
+
resolved = resolve_lenient(search_path)
|
|
944
1270
|
except (OSError, RuntimeError):
|
|
945
1271
|
continue
|
|
946
1272
|
for parent in (resolved, *resolved.parents):
|
|
@@ -973,7 +1299,7 @@ def path_in_archive(path, working_directories=(), ancestors=True):
|
|
|
973
1299
|
if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", combined):
|
|
974
1300
|
continue
|
|
975
1301
|
try:
|
|
976
|
-
resolved = Path(combined)
|
|
1302
|
+
resolved = resolve_lenient(Path(combined))
|
|
977
1303
|
except (OSError, RuntimeError):
|
|
978
1304
|
continue
|
|
979
1305
|
if in_archive(resolved.as_posix()) or (
|
|
@@ -997,7 +1323,7 @@ def working_directory_in_archive(path):
|
|
|
997
1323
|
if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", value):
|
|
998
1324
|
continue
|
|
999
1325
|
try:
|
|
1000
|
-
resolved = Path(value)
|
|
1326
|
+
resolved = resolve_lenient(Path(value))
|
|
1001
1327
|
except (OSError, RuntimeError):
|
|
1002
1328
|
continue
|
|
1003
1329
|
if in_archive(resolved.as_posix()):
|
|
@@ -1035,10 +1361,10 @@ def directory_change_target(arguments):
|
|
|
1035
1361
|
return operands[-1]
|
|
1036
1362
|
|
|
1037
1363
|
|
|
1038
|
-
def segment_directories(tokens, working_directories):
|
|
1364
|
+
def segment_directories(tokens, working_directories, contexts=None):
|
|
1039
1365
|
"""Yield each command segment with the working directories in effect for it."""
|
|
1040
1366
|
current_directories = list(working_directories)
|
|
1041
|
-
for segment in command_segments(tokens):
|
|
1367
|
+
for position, segment in enumerate(command_segments(tokens)):
|
|
1042
1368
|
yield segment, current_directories
|
|
1043
1369
|
invocation = command_invocation(segment)
|
|
1044
1370
|
if invocation is None:
|
|
@@ -1051,16 +1377,31 @@ def segment_directories(tokens, working_directories):
|
|
|
1051
1377
|
if command not in DIRECTORY_CHANGE_COMMANDS:
|
|
1052
1378
|
continue
|
|
1053
1379
|
target = directory_change_target(arguments)
|
|
1054
|
-
|
|
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):
|
|
1055
1389
|
raise ValueError(
|
|
1056
1390
|
f"cd target {target} cannot be resolved by the guard; "
|
|
1057
1391
|
"run that command first and cd to the literal result"
|
|
1058
1392
|
)
|
|
1059
|
-
if
|
|
1060
|
-
|
|
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]
|
|
1061
1402
|
else:
|
|
1062
1403
|
bases = current_directories or ["."]
|
|
1063
|
-
current_directories = [f"{base}/{
|
|
1404
|
+
current_directories = [f"{base}/{expanded}" for base in bases]
|
|
1064
1405
|
|
|
1065
1406
|
|
|
1066
1407
|
def command_can_destroy_files(invocation):
|
|
@@ -1076,9 +1417,11 @@ def command_can_destroy_files(invocation):
|
|
|
1076
1417
|
def literal_path(operand):
|
|
1077
1418
|
if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
|
|
1078
1419
|
operand = operand[1:-1]
|
|
1079
|
-
|
|
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._/ -]+"
|
|
1080
1423
|
if os.name == "nt":
|
|
1081
|
-
pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._
|
|
1424
|
+
pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\ -]+"
|
|
1082
1425
|
return operand if re.fullmatch(pattern, operand) is not None else None
|
|
1083
1426
|
|
|
1084
1427
|
|
|
@@ -1091,19 +1434,27 @@ def literal_destructive_operand(operand):
|
|
|
1091
1434
|
return literal
|
|
1092
1435
|
|
|
1093
1436
|
|
|
1094
|
-
def
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
+
)
|
|
1107
1458
|
|
|
1108
1459
|
|
|
1109
1460
|
def copy_destinations(arguments, directories, assignments=None):
|
|
@@ -1159,7 +1510,7 @@ def copy_destinations(arguments, directories, assignments=None):
|
|
|
1159
1510
|
lexical, source_path = target_paths(source, directories, repository_root())
|
|
1160
1511
|
if source_path.is_dir():
|
|
1161
1512
|
# ponytail: reject ambiguous directory links instead of emulating cp flags.
|
|
1162
|
-
if lexical
|
|
1513
|
+
if is_link_like(lexical):
|
|
1163
1514
|
raise ValueError("cp directory source is a symlink; pass a literal directory")
|
|
1164
1515
|
|
|
1165
1516
|
def unreadable(error):
|
|
@@ -1168,49 +1519,264 @@ def copy_destinations(arguments, directories, assignments=None):
|
|
|
1168
1519
|
for root, folders, files in os.walk(source_path, onerror=unreadable):
|
|
1169
1520
|
for name in folders + files:
|
|
1170
1521
|
entry = Path(root) / name
|
|
1171
|
-
if entry
|
|
1522
|
+
if is_link_like(entry) and entry.is_dir():
|
|
1172
1523
|
raise ValueError("cp source contains a directory symlink; copy it separately")
|
|
1173
1524
|
for output in outputs:
|
|
1174
1525
|
yield str(output / entry.relative_to(source_path))
|
|
1175
1526
|
|
|
1176
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
|
+
|
|
1177
1705
|
def shell_write_targets(tokens, working_directories, assignments=None):
|
|
1178
|
-
"""Yield (target, directories) for every path a shell command may write."""
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
+
):
|
|
1181
1710
|
for index, token in enumerate(segment[:-1]):
|
|
1182
1711
|
if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
|
|
1183
1712
|
continue
|
|
1184
1713
|
target = segment[index + 1]
|
|
1185
1714
|
if token.endswith("&") and (target.isdigit() or target == "-"):
|
|
1186
1715
|
continue
|
|
1187
|
-
yield target, directories
|
|
1716
|
+
yield target, directories, segment_assignments
|
|
1188
1717
|
wrapped = wrapped_command_tokens(segment)
|
|
1189
1718
|
if wrapped is not None:
|
|
1190
|
-
yield from shell_write_targets(wrapped, directories,
|
|
1719
|
+
yield from shell_write_targets(wrapped, directories, segment_assignments)
|
|
1191
1720
|
continue
|
|
1192
1721
|
invocation = command_invocation(segment)
|
|
1193
1722
|
if invocation is None:
|
|
1194
1723
|
continue
|
|
1195
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
|
|
1196
1732
|
in_place = command in IN_PLACE_EDITORS and (
|
|
1197
1733
|
has_short_option(arguments, "i")
|
|
1198
1734
|
or any(argument.startswith("--in-place") for argument in arguments)
|
|
1199
1735
|
)
|
|
1200
|
-
if command
|
|
1201
|
-
for destination in copy_destinations(arguments, directories,
|
|
1202
|
-
yield destination, directories
|
|
1736
|
+
if command == "cp":
|
|
1737
|
+
for destination in copy_destinations(arguments, directories, segment_assignments):
|
|
1738
|
+
yield destination, directories, segment_assignments
|
|
1203
1739
|
continue
|
|
1204
|
-
if command
|
|
1740
|
+
if command in SHELL_WRITE_COMMANDS or in_place:
|
|
1205
1741
|
for argument in arguments:
|
|
1206
1742
|
if argument and not argument.startswith("-"):
|
|
1207
|
-
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
|
|
1208
1775
|
|
|
1209
1776
|
|
|
1210
1777
|
def protected_shell_write_reason(tokens, working_directories):
|
|
1211
|
-
assignments = shell_assignment_values(tokens)
|
|
1212
1778
|
targets = []
|
|
1213
|
-
for target, directories in shell_write_targets(tokens, working_directories):
|
|
1779
|
+
for target, directories, assignments in shell_write_targets(tokens, working_directories):
|
|
1214
1780
|
expanded = expand_environment_parameters(target, assignments)
|
|
1215
1781
|
if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
|
|
1216
1782
|
expanded
|
|
@@ -1230,44 +1796,141 @@ def protected_shell_write_reason(tokens, working_directories):
|
|
|
1230
1796
|
for target, kind in classified[2]:
|
|
1231
1797
|
if kind == "protected":
|
|
1232
1798
|
return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
|
|
1799
|
+
if shell_write_needs_build_phase_gate(targets):
|
|
1800
|
+
return pipeline_product_phase_reason(classified)
|
|
1233
1801
|
return None
|
|
1234
1802
|
|
|
1235
1803
|
|
|
1236
|
-
def environment_parameter_value(match, assignments):
|
|
1804
|
+
def environment_parameter_value(match, assignments, tracked_only=False):
|
|
1237
1805
|
name = match.group(1) or match.group(2)
|
|
1238
1806
|
if name.startswith(SUBSTITUTION_PLACEHOLDER):
|
|
1239
1807
|
return match.group(0) # command substitution output is never resolved
|
|
1240
|
-
|
|
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))
|
|
1241
1813
|
|
|
1242
1814
|
|
|
1243
|
-
def expand_environment_parameters(command, assignments=None):
|
|
1815
|
+
def expand_environment_parameters(command, assignments=None, tracked_only=False):
|
|
1244
1816
|
values = assignments or {}
|
|
1245
1817
|
|
|
1246
1818
|
def substitute(match):
|
|
1247
|
-
return environment_parameter_value(match, values)
|
|
1819
|
+
return environment_parameter_value(match, values, tracked_only)
|
|
1248
1820
|
|
|
1249
1821
|
expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
|
|
1250
1822
|
return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
|
|
1251
1823
|
|
|
1252
1824
|
|
|
1253
|
-
def
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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 = {}
|
|
1257
1924
|
for token in segment:
|
|
1258
1925
|
if not SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
1259
|
-
if variable_command:
|
|
1260
|
-
continue
|
|
1261
1926
|
break
|
|
1262
1927
|
name, value = token.split("=", 1)
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
values[name] = expand_environment_parameters(value, values)
|
|
1266
|
-
return values
|
|
1928
|
+
prefix[name] = expand_environment_parameters(value, assignments)
|
|
1929
|
+
yield segment, directories, assignments, prefix
|
|
1267
1930
|
|
|
1268
1931
|
|
|
1269
|
-
def unresolved_archive_expansion(command,
|
|
1270
|
-
expanded = expand_environment_parameters(command,
|
|
1932
|
+
def unresolved_archive_expansion(command, assignments=None):
|
|
1933
|
+
expanded = expand_environment_parameters(command, assignments)
|
|
1271
1934
|
return ARCHIVE_REFERENCE.search(expanded) is not None
|
|
1272
1935
|
|
|
1273
1936
|
|
|
@@ -1296,32 +1959,194 @@ def substitution_end(command, start):
|
|
|
1296
1959
|
raise ValueError("command substitution $( is not closed")
|
|
1297
1960
|
|
|
1298
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
|
+
|
|
1299
2077
|
def split_command_substitutions(command):
|
|
1300
|
-
"""Replace
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
"@( expansion cannot be validated; write the list as literal arguments"
|
|
1304
|
-
)
|
|
1305
|
-
if "$(" not in command and "`" not in command:
|
|
1306
|
-
return command, []
|
|
1307
|
-
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
|
|
1308
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
|
|
1309
2134
|
if command.startswith("$(", index):
|
|
1310
2135
|
end = substitution_end(command, index + 2)
|
|
1311
2136
|
inner.append(command[index + 2 : end])
|
|
1312
|
-
elif
|
|
2137
|
+
elif character == "`":
|
|
1313
2138
|
end = index + 1
|
|
1314
2139
|
while end < len(command) and command[end] != "`":
|
|
1315
2140
|
end += 2 if command[end] == "\\" else 1
|
|
1316
2141
|
if end >= len(command):
|
|
1317
2142
|
raise ValueError("backtick command substitution is not closed")
|
|
1318
2143
|
inner.append(command[index + 1 : end])
|
|
1319
|
-
elif command
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
2144
|
+
elif command.startswith("@(", index):
|
|
2145
|
+
raise ValueError(
|
|
2146
|
+
"@( expansion cannot be validated; write the list as literal arguments"
|
|
2147
|
+
)
|
|
1323
2148
|
else:
|
|
1324
|
-
outer.append(
|
|
2149
|
+
outer.append(character)
|
|
1325
2150
|
index += 1
|
|
1326
2151
|
continue
|
|
1327
2152
|
outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
|
|
@@ -1342,11 +2167,8 @@ def strip_heredoc_bodies(command):
|
|
|
1342
2167
|
line = lines[index]
|
|
1343
2168
|
kept.append(line)
|
|
1344
2169
|
index += 1
|
|
1345
|
-
for
|
|
1346
|
-
|
|
1347
|
-
match.group("single") or match.group("double") or match.group("bare")
|
|
1348
|
-
)
|
|
1349
|
-
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"
|
|
1350
2172
|
while index < len(lines) and lines[index].strip(strip) != delimiter:
|
|
1351
2173
|
index += 1
|
|
1352
2174
|
if index >= len(lines):
|
|
@@ -1385,14 +2207,222 @@ def decode_patch_path(raw_path, strip_prefix):
|
|
|
1385
2207
|
return path
|
|
1386
2208
|
|
|
1387
2209
|
|
|
1388
|
-
|
|
1389
|
-
|
|
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)
|
|
1390
2361
|
lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
|
|
1391
2362
|
lexer.whitespace = " \t"
|
|
1392
2363
|
lexer.whitespace_split = True
|
|
1393
2364
|
lexer.commenters = ""
|
|
2365
|
+
tokens = []
|
|
2366
|
+
previous_end = 0
|
|
1394
2367
|
try:
|
|
1395
|
-
|
|
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)
|
|
1396
2426
|
except ValueError as error:
|
|
1397
2427
|
raise ValueError(
|
|
1398
2428
|
f"shell command cannot be tokenized ({error}); balance the quotes "
|
|
@@ -1400,7 +2430,20 @@ def shell_tokens(command):
|
|
|
1400
2430
|
) from None
|
|
1401
2431
|
if not tokens:
|
|
1402
2432
|
raise ValueError("shell command is empty")
|
|
1403
|
-
|
|
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
|
|
1404
2447
|
|
|
1405
2448
|
|
|
1406
2449
|
def command_segments(tokens):
|
|
@@ -1460,56 +2503,163 @@ def xargs_command(arguments):
|
|
|
1460
2503
|
|
|
1461
2504
|
|
|
1462
2505
|
def require_read_command(wrapper, wrapped):
|
|
1463
|
-
|
|
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
|
+
):
|
|
1464
2514
|
raise ValueError(
|
|
1465
2515
|
f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
|
|
1466
2516
|
f"only read commands may follow {wrapper}, or pass the paths as literals"
|
|
1467
2517
|
)
|
|
1468
2518
|
|
|
1469
2519
|
|
|
1470
|
-
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."""
|
|
1471
2573
|
index = 0
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
index += 1
|
|
1475
|
-
if index >= len(segment):
|
|
1476
|
-
return None
|
|
1477
|
-
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1478
|
-
if executable == "env":
|
|
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":
|
|
1479
2576
|
index += 1
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
+
)
|
|
1484
2622
|
break
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
|
1487
2632
|
index += 1
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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]
|
|
1491
2642
|
index += 1
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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]):
|
|
1495
2656
|
raise ValueError(
|
|
1496
|
-
f"
|
|
1497
|
-
"
|
|
2657
|
+
f"timeout duration {segment[index]} cannot be resolved by the "
|
|
2658
|
+
"guard; the duration must be a literal number"
|
|
1498
2659
|
)
|
|
1499
2660
|
index += 1
|
|
1500
|
-
|
|
1501
|
-
if index >= len(segment):
|
|
1502
|
-
raise ValueError(f"env {option} lacks a value")
|
|
1503
|
-
index += 1
|
|
1504
|
-
continue
|
|
1505
|
-
if token.startswith("-"):
|
|
1506
|
-
raise ValueError(
|
|
1507
|
-
f"env {token} cannot be validated; use env NAME=VALUE <command>"
|
|
1508
|
-
)
|
|
2661
|
+
else:
|
|
1509
2662
|
break
|
|
1510
|
-
if index >= len(segment):
|
|
1511
|
-
return None
|
|
1512
|
-
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1513
2663
|
arguments = segment[index + 1 :]
|
|
1514
2664
|
if SHELL_PARAMETER_SYNTAX.search(executable):
|
|
1515
2665
|
raise ValueError(
|
|
@@ -1533,19 +2683,103 @@ def command_invocation(segment):
|
|
|
1533
2683
|
return executable, arguments
|
|
1534
2684
|
|
|
1535
2685
|
|
|
1536
|
-
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):
|
|
1537
2701
|
invocation = command_invocation(segment)
|
|
1538
2702
|
if invocation is None:
|
|
1539
2703
|
return None
|
|
1540
2704
|
executable, arguments = invocation
|
|
1541
2705
|
if executable not in {"git", "git.exe"}:
|
|
1542
2706
|
return None
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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)
|
|
1548
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
|
|
1549
2783
|
index = 0
|
|
1550
2784
|
git_options = []
|
|
1551
2785
|
while index < len(arguments) and arguments[index].startswith("-"):
|
|
@@ -1556,18 +2790,32 @@ def git_command(segment):
|
|
|
1556
2790
|
option = token.split("=", 1)[0]
|
|
1557
2791
|
value = token.split("=", 1)[1] if "=" in token else None
|
|
1558
2792
|
index += 1
|
|
2793
|
+
if option != "-C":
|
|
2794
|
+
require_resolved_git_word(token)
|
|
1559
2795
|
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
|
|
1560
2796
|
if index >= len(arguments):
|
|
1561
2797
|
raise ValueError(f"git option {option} lacks a value")
|
|
1562
2798
|
value = arguments[index]
|
|
2799
|
+
if option != "-C":
|
|
2800
|
+
require_resolved_git_word(value)
|
|
1563
2801
|
index += 1
|
|
1564
2802
|
if option in {"-c", "--config-env"}:
|
|
1565
|
-
config_key = str(value).
|
|
2803
|
+
config_key, _, config_value = str(value).partition("=")
|
|
2804
|
+
config_key = config_key.casefold()
|
|
1566
2805
|
if config_key.startswith("alias."):
|
|
1567
2806
|
raise ValueError(
|
|
1568
2807
|
f"git -c {config_key} defines an alias the guard cannot inspect; "
|
|
1569
2808
|
"run the underlying git command directly"
|
|
1570
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
|
+
)
|
|
1571
2819
|
if config_key == "clean.requireforce":
|
|
1572
2820
|
raise ValueError(
|
|
1573
2821
|
"git -c clean.requireForce disables the git clean safety check; "
|
|
@@ -1576,8 +2824,47 @@ def git_command(segment):
|
|
|
1576
2824
|
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
|
|
1577
2825
|
git_options.extend((option, str(value)))
|
|
1578
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
|
+
)
|
|
1579
2832
|
return None
|
|
1580
|
-
|
|
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
|
|
1581
2868
|
|
|
1582
2869
|
|
|
1583
2870
|
def wrapped_command_tokens(segment, expand_parameters=True):
|
|
@@ -1586,7 +2873,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1586
2873
|
return None
|
|
1587
2874
|
executable, arguments = invocation
|
|
1588
2875
|
if executable == "eval":
|
|
1589
|
-
return shell_tokens(" ".join(arguments)) if arguments else None
|
|
2876
|
+
return shell_tokens(" ".join(arguments), direct=False) if arguments else None
|
|
1590
2877
|
if executable in COMMAND_WRAPPERS:
|
|
1591
2878
|
if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
|
|
1592
2879
|
return None
|
|
@@ -1611,7 +2898,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1611
2898
|
executable=executable, option=argument
|
|
1612
2899
|
)
|
|
1613
2900
|
)
|
|
1614
|
-
return shell_tokens(arguments[index + 1])
|
|
2901
|
+
return shell_tokens(arguments[index + 1], direct=False)
|
|
1615
2902
|
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1616
2903
|
if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
|
|
1617
2904
|
switches = {"-c", "-command", "/c", "/k"}
|
|
@@ -1630,8 +2917,8 @@ def wrapped_command_tokens(segment, expand_parameters=True):
|
|
|
1630
2917
|
for argument in payload
|
|
1631
2918
|
]
|
|
1632
2919
|
if len(payload) == 1:
|
|
1633
|
-
return shell_tokens(payload[0])
|
|
1634
|
-
return payload
|
|
2920
|
+
return shell_tokens(payload[0], direct=False)
|
|
2921
|
+
return [str(argument) for argument in payload]
|
|
1635
2922
|
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1636
2923
|
return None
|
|
1637
2924
|
|
|
@@ -1648,7 +2935,7 @@ def has_short_option(arguments, option):
|
|
|
1648
2935
|
def git_alias(command, git_options):
|
|
1649
2936
|
result = subprocess.run(
|
|
1650
2937
|
["git", *git_options, "config", "--get", f"alias.{command}"],
|
|
1651
|
-
|
|
2938
|
+
encoding="utf-8", errors="replace",
|
|
1652
2939
|
capture_output=True,
|
|
1653
2940
|
check=False,
|
|
1654
2941
|
)
|
|
@@ -1686,14 +2973,18 @@ def update_ref_deletes(arguments):
|
|
|
1686
2973
|
)
|
|
1687
2974
|
|
|
1688
2975
|
|
|
1689
|
-
def destructive_git_reason(
|
|
1690
|
-
|
|
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):
|
|
1691
2982
|
wrapped = wrapped_command_tokens(segment)
|
|
1692
2983
|
if wrapped is not None:
|
|
1693
|
-
reason = destructive_git_reason(wrapped, resolved_aliases)
|
|
2984
|
+
reason = destructive_git_reason(wrapped, resolved_aliases, assignments)
|
|
1694
2985
|
if reason is not None:
|
|
1695
2986
|
return reason
|
|
1696
|
-
invocation = git_command(segment)
|
|
2987
|
+
invocation = git_command(segment, assignments)
|
|
1697
2988
|
if invocation is None:
|
|
1698
2989
|
continue
|
|
1699
2990
|
command, arguments, git_options = invocation
|
|
@@ -1716,8 +3007,9 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
|
|
|
1716
3007
|
"git command directly"
|
|
1717
3008
|
)
|
|
1718
3009
|
reason = destructive_git_reason(
|
|
1719
|
-
["git", *git_options, *shell_tokens(alias), *arguments],
|
|
3010
|
+
["git", *git_options, *shell_tokens(alias, direct=False), *arguments],
|
|
1720
3011
|
resolved_aliases | {command},
|
|
3012
|
+
assignments,
|
|
1721
3013
|
)
|
|
1722
3014
|
if reason is not None:
|
|
1723
3015
|
return reason
|
|
@@ -1791,13 +3083,13 @@ def closed_milestone_reason(repo=None):
|
|
|
1791
3083
|
repo = repo or repository_root()
|
|
1792
3084
|
branch = subprocess.run(
|
|
1793
3085
|
["git", "-C", str(repo), "branch", "--show-current"],
|
|
1794
|
-
capture_output=True,
|
|
3086
|
+
capture_output=True, encoding="utf-8", errors="replace",
|
|
1795
3087
|
)
|
|
1796
3088
|
if not re.fullmatch(r"gsd-path/M\d{3,}", branch.stdout.strip()):
|
|
1797
3089
|
return None
|
|
1798
3090
|
result = subprocess.run(
|
|
1799
3091
|
[sys.executable, "-B", str(Path(__file__).with_name("git_guard.py")),
|
|
1800
|
-
"closed-milestone"], cwd=repo, capture_output=True,
|
|
3092
|
+
"closed-milestone"], cwd=repo, capture_output=True, encoding="utf-8", errors="replace",
|
|
1801
3093
|
)
|
|
1802
3094
|
if result.returncode:
|
|
1803
3095
|
return result.stderr.strip() or "closed milestone inspection failed"
|
|
@@ -1807,7 +3099,7 @@ def closed_milestone_reason(repo=None):
|
|
|
1807
3099
|
def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPELINE_HELPERS):
|
|
1808
3100
|
"""Allow one plain ``python[3] [-B] <script>`` from the guard-owned runtime.
|
|
1809
3101
|
|
|
1810
|
-
The interpreter
|
|
3102
|
+
The interpreter must be exactly python, python3, or py -3, and the script
|
|
1811
3103
|
operand is the exact parsed token, with expansions and parent traversal refused.
|
|
1812
3104
|
The resolved helper must be a regular file inside runtime/ beside this guard,
|
|
1813
3105
|
or beside the guard itself in the repository layout. Resolve only in supplied
|
|
@@ -1826,9 +3118,12 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
|
|
|
1826
3118
|
_, substitutions = split_command_substitutions(command)
|
|
1827
3119
|
if substitutions or wrapped_command_tokens(segments[0]) is not None:
|
|
1828
3120
|
return False
|
|
1829
|
-
if tokens[
|
|
3121
|
+
if tokens[:2] == ["py", "-3"]:
|
|
3122
|
+
rest = tokens[2:]
|
|
3123
|
+
elif tokens[0] in ("python", "python3"):
|
|
3124
|
+
rest = tokens[1:]
|
|
3125
|
+
else:
|
|
1830
3126
|
return False
|
|
1831
|
-
rest = tokens[1:]
|
|
1832
3127
|
if rest[:1] == ["-B"]:
|
|
1833
3128
|
rest = rest[1:]
|
|
1834
3129
|
if not rest:
|
|
@@ -1853,7 +3148,8 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
|
|
|
1853
3148
|
runtime = runtime.resolve()
|
|
1854
3149
|
for base in working_directories or [os.getcwd()]:
|
|
1855
3150
|
try:
|
|
1856
|
-
|
|
3151
|
+
# A Git Bash /c/... operand names the C: drive, not a folder named c.
|
|
3152
|
+
script = (Path(base) / native_path_text(operand)).resolve()
|
|
1857
3153
|
if (
|
|
1858
3154
|
script.name in helpers
|
|
1859
3155
|
and (script.is_relative_to(runtime) or (
|
|
@@ -1867,28 +3163,76 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
|
|
|
1867
3163
|
return False
|
|
1868
3164
|
|
|
1869
3165
|
|
|
1870
|
-
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."""
|
|
1871
3171
|
if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
|
|
1872
3172
|
return False
|
|
1873
3173
|
if not archive_context:
|
|
1874
3174
|
return True
|
|
1875
|
-
|
|
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
|
|
1876
3184
|
denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
|
|
1877
3185
|
if any(
|
|
1878
3186
|
token == option or token.startswith(f"{option}=")
|
|
1879
|
-
for token in
|
|
3187
|
+
for token in arguments
|
|
1880
3188
|
for option in denied_options
|
|
1881
3189
|
):
|
|
1882
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
|
|
1883
3194
|
if executable in ARCHIVE_READ_COMMANDS:
|
|
1884
3195
|
return True
|
|
1885
|
-
if executable != "git"
|
|
3196
|
+
if executable != "git":
|
|
1886
3197
|
return False
|
|
1887
|
-
|
|
3198
|
+
git = git_command(*(source or (tokens,)))
|
|
3199
|
+
if git is None:
|
|
3200
|
+
return False
|
|
3201
|
+
subcommand, git_arguments, _options = git
|
|
3202
|
+
if subcommand not in git_commands:
|
|
1888
3203
|
return False
|
|
1889
3204
|
return not any(
|
|
1890
|
-
token
|
|
1891
|
-
|
|
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
|
|
1892
3236
|
)
|
|
1893
3237
|
|
|
1894
3238
|
|
|
@@ -2010,22 +3354,28 @@ def closed_git_listing(tokens):
|
|
|
2010
3354
|
return not positionals
|
|
2011
3355
|
|
|
2012
3356
|
|
|
2013
|
-
def closed_shell_execution_reason(
|
|
3357
|
+
def closed_shell_execution_reason(
|
|
3358
|
+
command, tokens, working_directories, initial_assignments=None
|
|
3359
|
+
):
|
|
2014
3360
|
helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
|
|
2015
3361
|
rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
|
|
2016
3362
|
if rest[:1] == ["pending"]:
|
|
2017
3363
|
helpers = helpers | {"discussion_records.py"}
|
|
2018
3364
|
if bundled_helper_invocation(command, tokens, working_directories, helpers):
|
|
2019
3365
|
return None
|
|
2020
|
-
for segment, directories in
|
|
3366
|
+
for segment, directories, assignments, _prefix in shell_segment_contexts(
|
|
3367
|
+
tokens, working_directories, initial_assignments
|
|
3368
|
+
):
|
|
2021
3369
|
wrapped = wrapped_command_tokens(segment)
|
|
2022
3370
|
if wrapped is not None:
|
|
2023
|
-
reason = closed_shell_execution_reason(
|
|
3371
|
+
reason = closed_shell_execution_reason(
|
|
3372
|
+
shlex.join(wrapped), wrapped, directories, assignments
|
|
3373
|
+
)
|
|
2024
3374
|
if reason:
|
|
2025
3375
|
return reason
|
|
2026
3376
|
continue
|
|
2027
3377
|
metadata_closed = None
|
|
2028
|
-
git = git_command(segment)
|
|
3378
|
+
git = git_command(segment, assignments)
|
|
2029
3379
|
checked_segment = segment
|
|
2030
3380
|
if git is not None:
|
|
2031
3381
|
subcommand, arguments, options = git
|
|
@@ -2035,7 +3385,7 @@ def closed_shell_execution_reason(command, tokens, working_directories):
|
|
|
2035
3385
|
for directory in directories:
|
|
2036
3386
|
result = subprocess.run(
|
|
2037
3387
|
["git", *options, "rev-parse", "--show-toplevel", "--absolute-git-dir"],
|
|
2038
|
-
cwd=directory, capture_output=True,
|
|
3388
|
+
cwd=directory, capture_output=True, encoding="utf-8", errors="replace",
|
|
2039
3389
|
)
|
|
2040
3390
|
if result.returncode:
|
|
2041
3391
|
raise ValueError("git target repository cannot be resolved")
|
|
@@ -2064,14 +3414,16 @@ def closed_shell_execution_reason(command, tokens, working_directories):
|
|
|
2064
3414
|
def command_denial(command, working_directories, allow_destructive=True):
|
|
2065
3415
|
"""Return why a shell command is denied, or None when it may run."""
|
|
2066
3416
|
working_directories = working_directories or [os.getcwd()]
|
|
3417
|
+
command = re.sub(r"(?i:(\.project)\\(archive))", r"\1/\2", command)
|
|
2067
3418
|
outer, substitutions = split_command_substitutions(command)
|
|
2068
3419
|
try:
|
|
2069
3420
|
tokens = shell_tokens(outer)
|
|
2070
3421
|
if tokens in (
|
|
2071
|
-
[interpreter, "-B", "-c", "import sys; raise SystemExit(sys.version_info < (3, 9))"]
|
|
2072
|
-
for interpreter in ("python3", "python")
|
|
3422
|
+
[*interpreter.split(), "-B", "-c", "import sys; raise SystemExit(sys.version_info < (3, 9))"]
|
|
3423
|
+
for interpreter in ("python3", "python", "py -3")
|
|
2073
3424
|
):
|
|
2074
3425
|
return None
|
|
3426
|
+
bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
|
|
2075
3427
|
destructive = list(destructive_shell_invocations(tokens))
|
|
2076
3428
|
if destructive and (
|
|
2077
3429
|
not allow_destructive
|
|
@@ -2092,16 +3444,31 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
2092
3444
|
reason = command_denial(inner, working_directories, allow_destructive=False)
|
|
2093
3445
|
if reason is not None:
|
|
2094
3446
|
return reason
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
or bool(ARCHIVE_REFERENCE.search(outer))
|
|
2098
|
-
or command_references_archive(tokens, working_directories)
|
|
2099
|
-
or unresolved_archive_expansion(outer, tokens, working_directories)
|
|
2100
|
-
)
|
|
2101
|
-
if not archive_command_is_read_only(command, tokens, archive_context) and not (
|
|
2102
|
-
archive_context and bundled_helper_invocation(command, tokens, working_directories)
|
|
3447
|
+
for segment, directories, assignments, prefix in shell_segment_contexts(
|
|
3448
|
+
tokens, working_directories
|
|
2103
3449
|
):
|
|
2104
|
-
|
|
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
|
|
2105
3472
|
reason = destructive_git_reason(tokens) or protected_shell_write_reason(
|
|
2106
3473
|
tokens, working_directories
|
|
2107
3474
|
)
|
|
@@ -2116,8 +3483,14 @@ def allow(event):
|
|
|
2116
3483
|
|
|
2117
3484
|
|
|
2118
3485
|
def main():
|
|
3486
|
+
# Hosts exchange hook input and output as UTF-8; a Windows pipe would
|
|
3487
|
+
# default to cp1252. In-process callers may substitute text streams.
|
|
3488
|
+
for stream in (sys.stdout, sys.stderr):
|
|
3489
|
+
if hasattr(stream, "reconfigure"):
|
|
3490
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
2119
3491
|
try:
|
|
2120
|
-
|
|
3492
|
+
stdin = getattr(sys.stdin, "buffer", None)
|
|
3493
|
+
event = json.loads(stdin.read().decode("utf-8") if stdin is not None else sys.stdin.read())
|
|
2121
3494
|
if not isinstance(event, dict):
|
|
2122
3495
|
raise ValueError("hook event must be an object")
|
|
2123
3496
|
evaluate(event)
|