@opengsd/gsd-path 1.0.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 +397 -0
- package/DOCS.md +341 -0
- package/FULL.md +475 -0
- package/GUIDE.md +23 -0
- package/HOOKS.md +273 -0
- package/LICENSE +21 -0
- package/MIGRATE.md +100 -0
- package/QUICK.md +146 -0
- package/README.md +280 -0
- package/RUNTIME.md +229 -0
- package/UPDATE.md +196 -0
- package/WORKFLOW.md +683 -0
- package/package.json +80 -0
- package/platforms/claude/dispatch.md +63 -0
- package/platforms/copilot/dispatch.md +61 -0
- package/platforms/cursor/agent.md +17 -0
- package/platforms/cursor/dispatch.md +63 -0
- package/platforms/grok/dispatch.md +61 -0
- package/platforms/kimi/dispatch.md +64 -0
- package/platforms/kiro/dispatch.md +58 -0
- package/platforms/opencode/dispatch.md +62 -0
- package/platforms/qwen/dispatch.md +60 -0
- package/platforms/shared-agents/dispatch.md +131 -0
- package/plugin.json +14 -0
- package/scripts/_common.py +180 -0
- package/scripts/archive_milestone.py +2265 -0
- package/scripts/bootstrap_repository.py +710 -0
- package/scripts/build_state.py +854 -0
- package/scripts/check_docs_audit.py +463 -0
- package/scripts/check_handoffs.py +1855 -0
- package/scripts/check_task_briefs.py +376 -0
- package/scripts/check_update.py +93 -0
- package/scripts/core_hook_gate.py +34 -0
- package/scripts/core_hook_settings.py +119 -0
- package/scripts/detect_project.py +1517 -0
- package/scripts/discussion_records.py +619 -0
- package/scripts/discussion_validate.py +1321 -0
- package/scripts/dispatch_driver.py +1812 -0
- package/scripts/git_guard.py +759 -0
- package/scripts/guard_hook.py +2124 -0
- package/scripts/install.mjs +3239 -0
- package/scripts/install.py +3008 -0
- package/scripts/integration.py +1433 -0
- package/scripts/isolation.py +3538 -0
- package/scripts/lean_verification.py +256 -0
- package/scripts/loop_run.py +837 -0
- package/scripts/migrate_core.py +169 -0
- package/scripts/pipeline_diagnose.py +715 -0
- package/scripts/pipeline_git.py +940 -0
- package/scripts/pipeline_state.py +2291 -0
- package/scripts/pipeline_undo.py +1039 -0
- package/scripts/promote_lookahead.py +415 -0
- package/scripts/review_findings.py +714 -0
- package/scripts/review_panel.py +553 -0
- package/scripts/skill-resources.json +340 -0
- package/scripts/state_checkpoint.py +1039 -0
- package/scripts/state_promote.py +743 -0
- package/scripts/status_runtime.py +122 -0
- package/scripts/sync_skill_resources.py +259 -0
- package/scripts/token_budget.py +146 -0
- package/scripts/wizard.mjs +179 -0
- package/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/BUILD.md +700 -0
- package/skills/gsd-path/DECIDE.md +115 -0
- package/skills/gsd-path/DEFINE.md +297 -0
- package/skills/gsd-path/DOCS-AUDIT.md +190 -0
- package/skills/gsd-path/INSPECT.md +167 -0
- package/skills/gsd-path/PLAN.md +411 -0
- package/skills/gsd-path/RESEARCH.md +159 -0
- package/skills/gsd-path/ROADMAP.md +215 -0
- package/skills/gsd-path/SHIP.md +412 -0
- package/skills/gsd-path/SKILL.md +471 -0
- package/skills/gsd-path/agents/openai.yaml +6 -0
- package/skills/gsd-path/references/codebase-mapper.md +52 -0
- package/skills/gsd-path/references/coder.md +90 -0
- package/skills/gsd-path/references/decider.md +36 -0
- package/skills/gsd-path/references/dispatch.md +131 -0
- package/skills/gsd-path/references/docs-auditor.md +60 -0
- package/skills/gsd-path/references/planner.md +104 -0
- package/skills/gsd-path/references/researcher.md +42 -0
- package/skills/gsd-path/references/reviewer.md +188 -0
- package/skills/gsd-path/references/roadmapper.md +48 -0
- package/skills/gsd-path/scripts/_common.py +180 -0
- package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
- package/skills/gsd-path/scripts/build_state.py +854 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path/scripts/check_update.py +93 -0
- package/skills/gsd-path/scripts/detect_project.py +1517 -0
- package/skills/gsd-path/scripts/discussion_records.py +619 -0
- package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path/scripts/integration.py +1433 -0
- package/skills/gsd-path/scripts/isolation.py +3538 -0
- package/skills/gsd-path/scripts/lean_verification.py +256 -0
- package/skills/gsd-path/scripts/loop_run.py +837 -0
- package/skills/gsd-path/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path/scripts/review_findings.py +714 -0
- package/skills/gsd-path/scripts/review_panel.py +553 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path/scripts/state_promote.py +743 -0
- package/skills/gsd-path/scripts/token_budget.py +146 -0
- package/skills/gsd-path/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/templates/answers.md +29 -0
- package/skills/gsd-path/templates/archive-manifest.md +37 -0
- package/skills/gsd-path/templates/charter.md +50 -0
- package/skills/gsd-path/templates/codebase.md +46 -0
- package/skills/gsd-path/templates/dialogue.md +21 -0
- package/skills/gsd-path/templates/docs-audit.md +69 -0
- package/skills/gsd-path/templates/evidence.md +24 -0
- package/skills/gsd-path/templates/final-review.md +27 -0
- package/skills/gsd-path/templates/gap-review.md +20 -0
- package/skills/gsd-path/templates/intent.md +98 -0
- package/skills/gsd-path/templates/loop.md +59 -0
- package/skills/gsd-path/templates/patch-findings.md +16 -0
- package/skills/gsd-path/templates/plan-panel.md +25 -0
- package/skills/gsd-path/templates/plan.md +89 -0
- package/skills/gsd-path/templates/repository.md +16 -0
- package/skills/gsd-path/templates/research-handoff.md +25 -0
- package/skills/gsd-path/templates/roadmap.md +38 -0
- package/skills/gsd-path/templates/skeptic.md +36 -0
- package/skills/gsd-path/templates/state.md +27 -0
- package/skills/gsd-path/templates/synthesis.md +36 -0
- package/skills/gsd-path/templates/task.md +74 -0
- package/skills/gsd-path/templates/wave-panel.md +25 -0
- package/skills/gsd-path/templates/wave-review.md +64 -0
- package/skills/gsd-path-build/SKILL.md +700 -0
- package/skills/gsd-path-build/agents/openai.yaml +6 -0
- package/skills/gsd-path-build/references/coder.md +90 -0
- package/skills/gsd-path-build/references/dispatch.md +131 -0
- package/skills/gsd-path-build/references/reviewer.md +188 -0
- package/skills/gsd-path-build/scripts/_common.py +180 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-build/scripts/build_state.py +854 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path-build/scripts/integration.py +1433 -0
- package/skills/gsd-path-build/scripts/isolation.py +3538 -0
- package/skills/gsd-path-build/scripts/loop_run.py +837 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-build/scripts/review_findings.py +714 -0
- package/skills/gsd-path-build/scripts/review_panel.py +553 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-build/scripts/state_promote.py +743 -0
- package/skills/gsd-path-build/scripts/token_budget.py +146 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-build/templates/skeptic.md +36 -0
- package/skills/gsd-path-build/templates/task.md +74 -0
- package/skills/gsd-path-build/templates/wave-panel.md +25 -0
- package/skills/gsd-path-build/templates/wave-review.md +64 -0
- package/skills/gsd-path-decide/SKILL.md +115 -0
- package/skills/gsd-path-decide/agents/openai.yaml +6 -0
- package/skills/gsd-path-decide/references/decider.md +36 -0
- package/skills/gsd-path-decide/references/dispatch.md +131 -0
- package/skills/gsd-path-decide/scripts/_common.py +180 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
- package/skills/gsd-path-decide/templates/synthesis.md +36 -0
- package/skills/gsd-path-define/SKILL.md +297 -0
- package/skills/gsd-path-define/agents/openai.yaml +6 -0
- package/skills/gsd-path-define/scripts/_common.py +180 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-define/scripts/isolation.py +3538 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-define/scripts/state_promote.py +743 -0
- package/skills/gsd-path-define/templates/charter.md +50 -0
- package/skills/gsd-path-define/templates/intent.md +98 -0
- package/skills/gsd-path-define/templates/state.md +27 -0
- package/skills/gsd-path-discuss/SKILL.md +162 -0
- package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
- package/skills/gsd-path-discuss/scripts/_common.py +180 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-discuss/templates/answers.md +29 -0
- package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
- package/skills/gsd-path-docs-audit/SKILL.md +190 -0
- package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
- package/skills/gsd-path-forensics/SKILL.md +39 -0
- package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
- package/skills/gsd-path-forensics/scripts/_common.py +180 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
- package/skills/gsd-path-inspect/SKILL.md +167 -0
- package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
- package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
- package/skills/gsd-path-inspect/references/dispatch.md +131 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
- package/skills/gsd-path-inspect/scripts/_common.py +180 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
- package/skills/gsd-path-inspect/templates/codebase.md +46 -0
- package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
- package/skills/gsd-path-inspect/templates/state.md +27 -0
- package/skills/gsd-path-loop/SKILL.md +98 -0
- package/skills/gsd-path-loop/agents/openai.yaml +6 -0
- package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
- package/skills/gsd-path-loop/scripts/_common.py +180 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
- package/skills/gsd-path-loop/templates/loop.md +59 -0
- package/skills/gsd-path-migrate/SKILL.md +134 -0
- package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
- package/skills/gsd-path-plan/SKILL.md +411 -0
- package/skills/gsd-path-plan/agents/openai.yaml +6 -0
- package/skills/gsd-path-plan/references/dispatch.md +131 -0
- package/skills/gsd-path-plan/references/planner.md +104 -0
- package/skills/gsd-path-plan/scripts/_common.py +180 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
- package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
- package/skills/gsd-path-plan/templates/plan.md +89 -0
- package/skills/gsd-path-plan/templates/task.md +74 -0
- package/skills/gsd-path-research/SKILL.md +159 -0
- package/skills/gsd-path-research/agents/openai.yaml +6 -0
- package/skills/gsd-path-research/references/dispatch.md +131 -0
- package/skills/gsd-path-research/references/researcher.md +42 -0
- package/skills/gsd-path-research/scripts/_common.py +180 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-research/scripts/isolation.py +3538 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-research/scripts/state_promote.py +743 -0
- package/skills/gsd-path-research/templates/evidence.md +24 -0
- package/skills/gsd-path-research/templates/research-handoff.md +25 -0
- package/skills/gsd-path-roadmap/SKILL.md +215 -0
- package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
- package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
- package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
- package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
- package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
- package/skills/gsd-path-ship/SKILL.md +412 -0
- package/skills/gsd-path-ship/agents/openai.yaml +6 -0
- package/skills/gsd-path-ship/references/dispatch.md +131 -0
- package/skills/gsd-path-ship/references/reviewer.md +188 -0
- package/skills/gsd-path-ship/scripts/_common.py +180 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-ship/scripts/build_state.py +854 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-ship/scripts/integration.py +1433 -0
- package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
- package/skills/gsd-path-ship/templates/final-review.md +27 -0
- package/skills/gsd-path-ship/templates/gap-review.md +20 -0
- package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
- package/skills/gsd-path-ship/templates/wave-review.md +64 -0
- package/skills/gsd-path-undo/SKILL.md +45 -0
- package/skills/gsd-path-undo/agents/openai.yaml +6 -0
- package/skills/gsd-path-undo/scripts/_common.py +180 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
- package/skills/path/BUILD.md +700 -0
- package/skills/path/DECIDE.md +115 -0
- package/skills/path/DEFINE.md +297 -0
- package/skills/path/DOCS-AUDIT.md +190 -0
- package/skills/path/INSPECT.md +167 -0
- package/skills/path/PLAN.md +411 -0
- package/skills/path/RESEARCH.md +159 -0
- package/skills/path/ROADMAP.md +215 -0
- package/skills/path/SHIP.md +412 -0
- package/skills/path/SKILL.md +471 -0
- package/skills/path/agents/openai.yaml +6 -0
- package/skills/path/references/codebase-mapper.md +52 -0
- package/skills/path/references/coder.md +90 -0
- package/skills/path/references/decider.md +36 -0
- package/skills/path/references/dispatch.md +131 -0
- package/skills/path/references/docs-auditor.md +60 -0
- package/skills/path/references/planner.md +104 -0
- package/skills/path/references/researcher.md +42 -0
- package/skills/path/references/reviewer.md +188 -0
- package/skills/path/references/roadmapper.md +48 -0
- package/skills/path/scripts/_common.py +180 -0
- package/skills/path/scripts/archive_milestone.py +2265 -0
- package/skills/path/scripts/bootstrap_repository.py +710 -0
- package/skills/path/scripts/build_state.py +854 -0
- package/skills/path/scripts/check_docs_audit.py +463 -0
- package/skills/path/scripts/check_handoffs.py +1855 -0
- package/skills/path/scripts/check_task_briefs.py +376 -0
- package/skills/path/scripts/check_update.py +93 -0
- package/skills/path/scripts/detect_project.py +1517 -0
- package/skills/path/scripts/discussion_records.py +619 -0
- package/skills/path/scripts/discussion_validate.py +1321 -0
- package/skills/path/scripts/dispatch_driver.py +1812 -0
- package/skills/path/scripts/integration.py +1433 -0
- package/skills/path/scripts/isolation.py +3538 -0
- package/skills/path/scripts/lean_verification.py +256 -0
- package/skills/path/scripts/loop_run.py +837 -0
- package/skills/path/scripts/pipeline_git.py +940 -0
- package/skills/path/scripts/pipeline_state.py +2291 -0
- package/skills/path/scripts/promote_lookahead.py +415 -0
- package/skills/path/scripts/review_findings.py +714 -0
- package/skills/path/scripts/review_panel.py +553 -0
- package/skills/path/scripts/state_checkpoint.py +1039 -0
- package/skills/path/scripts/state_promote.py +743 -0
- package/skills/path/scripts/token_budget.py +146 -0
- package/skills/path/scripts/workflow_run.py +117 -0
- package/skills/path/templates/answers.md +29 -0
- package/skills/path/templates/archive-manifest.md +37 -0
- package/skills/path/templates/charter.md +50 -0
- package/skills/path/templates/codebase.md +46 -0
- package/skills/path/templates/dialogue.md +21 -0
- package/skills/path/templates/docs-audit.md +69 -0
- package/skills/path/templates/evidence.md +24 -0
- package/skills/path/templates/final-review.md +27 -0
- package/skills/path/templates/gap-review.md +20 -0
- package/skills/path/templates/intent.md +98 -0
- package/skills/path/templates/loop.md +59 -0
- package/skills/path/templates/patch-findings.md +16 -0
- package/skills/path/templates/plan-panel.md +25 -0
- package/skills/path/templates/plan.md +89 -0
- package/skills/path/templates/repository.md +16 -0
- package/skills/path/templates/research-handoff.md +25 -0
- package/skills/path/templates/roadmap.md +38 -0
- package/skills/path/templates/skeptic.md +36 -0
- package/skills/path/templates/state.md +27 -0
- package/skills/path/templates/synthesis.md +36 -0
- package/skills/path/templates/task.md +74 -0
- package/skills/path/templates/wave-panel.md +25 -0
- package/skills/path/templates/wave-review.md +64 -0
|
@@ -0,0 +1,2124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-host pre-tool-use guard for GSD Path projects.
|
|
3
|
+
|
|
4
|
+
Reads one hook event as JSON on stdin and enforces the two pipeline
|
|
5
|
+
invariants a prompt contract cannot guarantee:
|
|
6
|
+
|
|
7
|
+
- committed archives under .project/archive/ are read-only (only the bundled
|
|
8
|
+
helpers pipeline_state.py and archive_milestone.py in the guard-owned runtime
|
|
9
|
+
directory may name that path in a single plain python or python3 command), and
|
|
10
|
+
- destructive git commands that break build recovery are refused.
|
|
11
|
+
|
|
12
|
+
Allow: exit 0 with no output, except Cursor (whose fail-closed hooks treat
|
|
13
|
+
empty stdout as a failure) also gets {"permission": "allow"} on stdout.
|
|
14
|
+
Deny: exit 2, a one-line reason on stderr,
|
|
15
|
+
and a denial JSON on stdout carrying every supported host's decision keys
|
|
16
|
+
(exit code 2 satisfies Claude Code, Codex, Qwen, Kimi, Grok, Cursor, and
|
|
17
|
+
Kiro; the JSON covers hosts that read a decision object instead).
|
|
18
|
+
Malformed input or an internal failure denies the tool call.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from fnmatch import fnmatchcase
|
|
23
|
+
from functools import lru_cache
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shlex
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path, PurePosixPath
|
|
31
|
+
|
|
32
|
+
PATH_KEYS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"file_path",
|
|
35
|
+
"filepath",
|
|
36
|
+
"path",
|
|
37
|
+
"file",
|
|
38
|
+
"notebook_path",
|
|
39
|
+
"target_file",
|
|
40
|
+
"relative_path",
|
|
41
|
+
"target_notebook",
|
|
42
|
+
"notebook",
|
|
43
|
+
"directory",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
MUTATION_OPERAND_KEYS = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"destination",
|
|
49
|
+
"destination_directory",
|
|
50
|
+
"destination_file",
|
|
51
|
+
"destination_path",
|
|
52
|
+
"from",
|
|
53
|
+
"from_file",
|
|
54
|
+
"from_path",
|
|
55
|
+
"old_file",
|
|
56
|
+
"old_path",
|
|
57
|
+
"source",
|
|
58
|
+
"source_directory",
|
|
59
|
+
"source_file",
|
|
60
|
+
"source_notebook",
|
|
61
|
+
"source_path",
|
|
62
|
+
"target_path",
|
|
63
|
+
"to",
|
|
64
|
+
"to_file",
|
|
65
|
+
"to_path",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
WORKING_DIRECTORY_KEYS = frozenset({"working_directory", "workdir", "cwd"})
|
|
69
|
+
COMMAND_KEYS = frozenset({"command", "cmd", "script"})
|
|
70
|
+
PATCH_KEYS = frozenset({"patch", "patch_body", "patch_text", "diff"})
|
|
71
|
+
PATCH_PATH_PATTERN = re.compile(
|
|
72
|
+
r"^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$",
|
|
73
|
+
re.MULTILINE,
|
|
74
|
+
)
|
|
75
|
+
UNIFIED_DIFF_PATH_PATTERN = re.compile(
|
|
76
|
+
r"^(?:---|\+\+\+) ([^\t\r\n]+)$", re.MULTILINE
|
|
77
|
+
)
|
|
78
|
+
GIT_DIFF_PATH_PATTERN = re.compile(
|
|
79
|
+
r'^diff --git ("(?:\\.|[^"])*"|\S+) ("(?:\\.|[^"])*"|\S+)$',
|
|
80
|
+
re.MULTILINE,
|
|
81
|
+
)
|
|
82
|
+
GIT_RENAME_PATH_PATTERN = re.compile(
|
|
83
|
+
r"^(?:rename from|rename to) (.+)$", re.MULTILINE
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# A tool skips path checks only when its name carries a read-only verb and
|
|
87
|
+
# no write-capable verb: `get_and_write` must still be path-checked.
|
|
88
|
+
READ_VERBS = frozenset(
|
|
89
|
+
{"read", "grep", "glob", "search", "view", "list", "get", "cat", "open"}
|
|
90
|
+
)
|
|
91
|
+
WRITE_VERBS = frozenset(
|
|
92
|
+
{
|
|
93
|
+
"write",
|
|
94
|
+
"save",
|
|
95
|
+
"copy",
|
|
96
|
+
"touch",
|
|
97
|
+
"edit",
|
|
98
|
+
"create",
|
|
99
|
+
"delete",
|
|
100
|
+
"remove",
|
|
101
|
+
"update",
|
|
102
|
+
"set",
|
|
103
|
+
"put",
|
|
104
|
+
"post",
|
|
105
|
+
"patch",
|
|
106
|
+
"move",
|
|
107
|
+
"rename",
|
|
108
|
+
"append",
|
|
109
|
+
"insert",
|
|
110
|
+
"replace",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
AMBIGUOUS_WRITE_VERBS = frozenset({"create", "update", "set", "put", "post"})
|
|
114
|
+
DIRECT_FILE_WRITE_VERBS = WRITE_VERBS - AMBIGUOUS_WRITE_VERBS
|
|
115
|
+
FILE_TARGET_TOKENS = frozenset({"file", "path", "notebook"})
|
|
116
|
+
TOOL_TOKEN_PATTERN = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+")
|
|
117
|
+
SHELL_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
118
|
+
|
|
119
|
+
ARCHIVE_REASON = (
|
|
120
|
+
"committed GSD Path archives under .project/archive/ are read-only; "
|
|
121
|
+
"only the bundled pipeline helpers may write there during a ship transaction"
|
|
122
|
+
)
|
|
123
|
+
DESTRUCTIVE_SHAPE_REASON = (
|
|
124
|
+
"destructive commands must run alone with literal paths; split compound, "
|
|
125
|
+
"substituted, or expanded destructive commands into single commands"
|
|
126
|
+
)
|
|
127
|
+
ARCHIVE_MARKER = ".project/archive"
|
|
128
|
+
INVALID_INPUT_REASON = "GSD Path guard could not validate the tool request"
|
|
129
|
+
CONTROL_PATHS = ".git, .project/STATE.md, .project/next, .gsd-path"
|
|
130
|
+
PROTECTED_SHELL_WRITE_REASON = (
|
|
131
|
+
f"GSD Path routing controls ({CONTROL_PATHS}) change only through the "
|
|
132
|
+
"pipeline helpers; the shell command writes"
|
|
133
|
+
)
|
|
134
|
+
REENTRY_FAILURE_REASON = (
|
|
135
|
+
"GSD Path status could not be verified; review .project/STATE.md and invoke "
|
|
136
|
+
"gsd-path-forensics before changing product files"
|
|
137
|
+
)
|
|
138
|
+
CONTROL_FILE_REASON = (
|
|
139
|
+
"GSD Path routing controls cannot be changed by direct write, edit, or patch "
|
|
140
|
+
"tools; use the pipeline helpers (pipeline_state.py) for"
|
|
141
|
+
)
|
|
142
|
+
STATUS_ACTIONS = frozenset(
|
|
143
|
+
{
|
|
144
|
+
"bind-initial",
|
|
145
|
+
"block",
|
|
146
|
+
"resume-checkpoint",
|
|
147
|
+
"resume-next-handoff",
|
|
148
|
+
"resume-promotion",
|
|
149
|
+
"resume-shipment",
|
|
150
|
+
"resume-undo",
|
|
151
|
+
"run-phase",
|
|
152
|
+
"validate-integrated",
|
|
153
|
+
"wait",
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
STATUS_PHASES = frozenset(
|
|
157
|
+
{
|
|
158
|
+
"inspect",
|
|
159
|
+
"define",
|
|
160
|
+
"research",
|
|
161
|
+
"decide",
|
|
162
|
+
"roadmap",
|
|
163
|
+
"plan",
|
|
164
|
+
"build",
|
|
165
|
+
"ship",
|
|
166
|
+
"shipped",
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
STATUS_VALUES = frozenset({"active", "done", "blocked"})
|
|
170
|
+
STATUS_STATE_FIELDS = frozenset(
|
|
171
|
+
{
|
|
172
|
+
"pipeline",
|
|
173
|
+
"project",
|
|
174
|
+
"milestone",
|
|
175
|
+
"phase",
|
|
176
|
+
"status",
|
|
177
|
+
"branch",
|
|
178
|
+
"archive",
|
|
179
|
+
"integration_default",
|
|
180
|
+
"integration",
|
|
181
|
+
"integration_source",
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
|
|
185
|
+
STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
|
|
186
|
+
STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
187
|
+
STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
|
|
188
|
+
STATUS_ARCHIVE = re.compile(r"^\.project/archive/(\d{3,})-([a-z0-9][a-z0-9-]*)/?$")
|
|
189
|
+
STATUS_TRANSITIONS = {
|
|
190
|
+
"inspect": frozenset({"inspect", "define"}),
|
|
191
|
+
"define": frozenset({"define", "research", "plan"}),
|
|
192
|
+
"research": frozenset({"research", "decide"}),
|
|
193
|
+
"decide": frozenset({"decide", "roadmap", "plan"}),
|
|
194
|
+
"roadmap": frozenset({"roadmap", "define"}),
|
|
195
|
+
"plan": frozenset({"plan", "build"}),
|
|
196
|
+
"build": frozenset({"build"}),
|
|
197
|
+
"ship": frozenset({"ship", "plan"}),
|
|
198
|
+
"shipped": frozenset({"ship"}),
|
|
199
|
+
}
|
|
200
|
+
ARCHIVE_REFERENCE = re.compile(r"(?i:\.project[\\/]archive)")
|
|
201
|
+
ARCHIVE_READ_COMMANDS = frozenset(
|
|
202
|
+
{
|
|
203
|
+
"cat",
|
|
204
|
+
"head",
|
|
205
|
+
"tail",
|
|
206
|
+
"grep",
|
|
207
|
+
"rg",
|
|
208
|
+
"ls",
|
|
209
|
+
"stat",
|
|
210
|
+
"wc",
|
|
211
|
+
"file",
|
|
212
|
+
"readlink",
|
|
213
|
+
"realpath",
|
|
214
|
+
"test",
|
|
215
|
+
"get-content",
|
|
216
|
+
"get-childitem",
|
|
217
|
+
"get-item",
|
|
218
|
+
"get-acl",
|
|
219
|
+
"select-string",
|
|
220
|
+
"test-path",
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
ARCHIVE_READ_GIT_COMMANDS = frozenset({"status", "diff", "log", "show", "ls-files"})
|
|
224
|
+
CLOSED_READ_GIT_COMMANDS = ARCHIVE_READ_GIT_COMMANDS | {
|
|
225
|
+
"rev-parse", "ls-remote", "ls-tree", "show-ref", "merge-base",
|
|
226
|
+
}
|
|
227
|
+
# Subcommands that also have write forms: only these listing options keep them read-only.
|
|
228
|
+
CLOSED_LISTING_GIT_OPTIONS = {
|
|
229
|
+
"cat-file": frozenset({"-p", "-t", "-s", "-e"}),
|
|
230
|
+
"branch": frozenset({"-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose", "-l", "--list", "--show-current", "--no-color"}),
|
|
231
|
+
"worktree list": frozenset({"--porcelain", "-v", "--verbose", "-z"}),
|
|
232
|
+
"symbolic-ref": frozenset({"-q", "--quiet", "--short"}),
|
|
233
|
+
}
|
|
234
|
+
GIT_READ_WRITE_OPTIONS = frozenset({"--output", "--ext-diff", "--textconv"})
|
|
235
|
+
ARCHIVE_READ_EXECUTION_OPTIONS = {"rg": frozenset({"--pre"})}
|
|
236
|
+
AMBIGUOUS_SHELL_SYNTAX = re.compile(r"[\r\n|;&<>`]|\$\(|@\(")
|
|
237
|
+
SUBSTITUTION_PLACEHOLDER = "COMMAND_SUBSTITUTION_"
|
|
238
|
+
SUBSTITUTION_PLACEHOLDER_SYNTAX = re.compile(
|
|
239
|
+
r"\$\{" + SUBSTITUTION_PLACEHOLDER + r"(\d+)\}"
|
|
240
|
+
)
|
|
241
|
+
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
|
+
SHELL_WRITE_REDIRECTION_CHARS = frozenset("<>&|")
|
|
247
|
+
SHELL_PARAMETER_SYNTAX = re.compile(
|
|
248
|
+
r"\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[-*@#?$!]|\{[^}\r\n]+\})"
|
|
249
|
+
)
|
|
250
|
+
NAMED_SHELL_PARAMETER_SYNTAX = re.compile(
|
|
251
|
+
r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})"
|
|
252
|
+
)
|
|
253
|
+
CMD_PARAMETER_SYNTAX = re.compile(
|
|
254
|
+
r"%([A-Za-z_][A-Za-z0-9_]*)%|!([A-Za-z_][A-Za-z0-9_]*)!"
|
|
255
|
+
)
|
|
256
|
+
DIRECTORY_CHANGE_COMMANDS = frozenset(
|
|
257
|
+
{"cd", "chdir", "pushd", "set-location", "sl"}
|
|
258
|
+
)
|
|
259
|
+
GIT_REASONS = {
|
|
260
|
+
"reset": "git reset --hard discards work the build recovery protocol needs",
|
|
261
|
+
"clean": "git clean -f deletes untracked evidence and retained task worktrees",
|
|
262
|
+
"push": "force pushes rewrite build-branch history the pipeline resumes from",
|
|
263
|
+
"branch": "git branch -D destroys task branches the recovery protocol inspects",
|
|
264
|
+
"branch-move": (
|
|
265
|
+
"git branch -m renames a branch the pipeline resumes by name "
|
|
266
|
+
"(STATE.branch); create a new branch instead"
|
|
267
|
+
),
|
|
268
|
+
"update-ref": "git update-ref deletion destroys refs the recovery protocol inspects",
|
|
269
|
+
"worktree": (
|
|
270
|
+
"git worktree remove --force discards uncommitted task work the recovery "
|
|
271
|
+
"protocol needs; commit or stash it, then remove without --force"
|
|
272
|
+
),
|
|
273
|
+
"stash": (
|
|
274
|
+
"git stash drop and git stash clear destroy stashed work the recovery "
|
|
275
|
+
"protocol may need; apply or keep the stash"
|
|
276
|
+
),
|
|
277
|
+
"checkout": (
|
|
278
|
+
"git checkout -- . and git restore . discard every uncommitted change; "
|
|
279
|
+
"name the files to restore instead"
|
|
280
|
+
),
|
|
281
|
+
}
|
|
282
|
+
WHOLE_TREE_PATHSPECS = frozenset({".", "./", ":/"})
|
|
283
|
+
GIT_GLOBAL_OPTIONS_WITH_VALUES = frozenset(
|
|
284
|
+
{"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"}
|
|
285
|
+
)
|
|
286
|
+
ENV_OPTIONS_WITH_VALUES = frozenset({"-C", "-u", "--chdir", "--unset"})
|
|
287
|
+
ENV_OPTIONS_WITHOUT_VALUES = frozenset(
|
|
288
|
+
{"-0", "-i", "-v", "--debug", "--ignore-environment", "--null"}
|
|
289
|
+
)
|
|
290
|
+
POSIX_SHELL_WRAPPERS = frozenset({"bash", "dash", "fish", "ksh", "sh", "zsh"})
|
|
291
|
+
POWERSHELL_WRAPPERS = frozenset(
|
|
292
|
+
{"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
|
|
293
|
+
)
|
|
294
|
+
COMMAND_WRAPPERS = frozenset({"builtin", "call", "command", "exec"})
|
|
295
|
+
SCRIPT_FILE_REASON = (
|
|
296
|
+
"{executable} runs the script file {argument}, which the guard cannot "
|
|
297
|
+
"inspect; run its commands directly"
|
|
298
|
+
)
|
|
299
|
+
ENV_BUILTIN_REASON = "{executable} cannot be validated; assign with NAME=VALUE instead"
|
|
300
|
+
SHELL_FILE_REASON = (
|
|
301
|
+
"{executable} reads commands from a file or stdin the guard cannot inspect; "
|
|
302
|
+
"run the commands directly or use {executable} -c '<commands>'"
|
|
303
|
+
)
|
|
304
|
+
MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
|
|
305
|
+
UNVALIDATED_EXECUTION_REASONS = {
|
|
306
|
+
".": SCRIPT_FILE_REASON,
|
|
307
|
+
"source": SCRIPT_FILE_REASON,
|
|
308
|
+
"start": "start launches a detached process the guard cannot inspect; run the program directly",
|
|
309
|
+
"setenv": ENV_BUILTIN_REASON,
|
|
310
|
+
"unsetenv": ENV_BUILTIN_REASON,
|
|
311
|
+
}
|
|
312
|
+
VARIABLE_COMMANDS = frozenset(
|
|
313
|
+
{"declare", "export", "local", "readonly", "set", "typeset", "unset"}
|
|
314
|
+
)
|
|
315
|
+
XARGS_COMMANDS = frozenset({"xargs", "xargs.exe"})
|
|
316
|
+
XARGS_OPTIONS_WITH_VALUES = frozenset(
|
|
317
|
+
{
|
|
318
|
+
"-I",
|
|
319
|
+
"-J",
|
|
320
|
+
"-L",
|
|
321
|
+
"-n",
|
|
322
|
+
"-P",
|
|
323
|
+
"-R",
|
|
324
|
+
"-S",
|
|
325
|
+
"-s",
|
|
326
|
+
"-E",
|
|
327
|
+
"-d",
|
|
328
|
+
"-a",
|
|
329
|
+
"-l",
|
|
330
|
+
"--replace",
|
|
331
|
+
"--max-args",
|
|
332
|
+
"--max-lines",
|
|
333
|
+
"--max-procs",
|
|
334
|
+
"--max-chars",
|
|
335
|
+
"--delimiter",
|
|
336
|
+
"--eof",
|
|
337
|
+
"--arg-file",
|
|
338
|
+
"--process-slot-var",
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
DESTRUCTIVE_SHELL_COMMANDS = frozenset(
|
|
342
|
+
{
|
|
343
|
+
"del",
|
|
344
|
+
"erase",
|
|
345
|
+
"mi",
|
|
346
|
+
"move",
|
|
347
|
+
"move-item",
|
|
348
|
+
"mv",
|
|
349
|
+
"rd",
|
|
350
|
+
"remove-item",
|
|
351
|
+
"ri",
|
|
352
|
+
"rm",
|
|
353
|
+
"rmdir",
|
|
354
|
+
"rsync",
|
|
355
|
+
"shred",
|
|
356
|
+
"trash",
|
|
357
|
+
"unlink",
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
|
|
361
|
+
{
|
|
362
|
+
"cp",
|
|
363
|
+
"install",
|
|
364
|
+
"ln",
|
|
365
|
+
"mkdir",
|
|
366
|
+
"tee",
|
|
367
|
+
"touch",
|
|
368
|
+
"truncate",
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
IN_PLACE_EDITORS = frozenset({"perl", "sed"})
|
|
372
|
+
FIND_EXECUTION_ACTIONS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
|
|
373
|
+
SHELL_CONTROL_WORDS = frozenset(
|
|
374
|
+
{
|
|
375
|
+
"!",
|
|
376
|
+
"case",
|
|
377
|
+
"coproc",
|
|
378
|
+
"do",
|
|
379
|
+
"done",
|
|
380
|
+
"elif",
|
|
381
|
+
"else",
|
|
382
|
+
"esac",
|
|
383
|
+
"fi",
|
|
384
|
+
"for",
|
|
385
|
+
"function",
|
|
386
|
+
"if",
|
|
387
|
+
"in",
|
|
388
|
+
"select",
|
|
389
|
+
"then",
|
|
390
|
+
"time",
|
|
391
|
+
"until",
|
|
392
|
+
"while",
|
|
393
|
+
"{",
|
|
394
|
+
"}",
|
|
395
|
+
}
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def collect(
|
|
400
|
+
node,
|
|
401
|
+
paths,
|
|
402
|
+
working_directories,
|
|
403
|
+
commands,
|
|
404
|
+
patch_payloads,
|
|
405
|
+
path_keys=PATH_KEYS,
|
|
406
|
+
inherited_working_directories=(),
|
|
407
|
+
):
|
|
408
|
+
if isinstance(node, dict):
|
|
409
|
+
local_working_directories = []
|
|
410
|
+
for key, value in node.items():
|
|
411
|
+
if key.lower() not in WORKING_DIRECTORY_KEYS:
|
|
412
|
+
continue
|
|
413
|
+
# Cursor sends "cwd": "" when it has no directory; treat that as absent.
|
|
414
|
+
if isinstance(value, str):
|
|
415
|
+
if value:
|
|
416
|
+
local_working_directories.append(value)
|
|
417
|
+
elif isinstance(value, list):
|
|
418
|
+
strings = [item for item in value if isinstance(item, str)]
|
|
419
|
+
if len(strings) != len(value):
|
|
420
|
+
raise ValueError("working directories cannot be validated")
|
|
421
|
+
local_working_directories.extend(item for item in strings if item)
|
|
422
|
+
if len(local_working_directories) > 1:
|
|
423
|
+
raise ValueError("working directory is ambiguous")
|
|
424
|
+
working_directories.extend(local_working_directories)
|
|
425
|
+
context = tuple(local_working_directories) or inherited_working_directories
|
|
426
|
+
for key, value in node.items():
|
|
427
|
+
lowered = key.lower()
|
|
428
|
+
if isinstance(value, str):
|
|
429
|
+
if lowered in path_keys:
|
|
430
|
+
paths.append((value, context))
|
|
431
|
+
elif lowered in COMMAND_KEYS:
|
|
432
|
+
commands.append((value, context))
|
|
433
|
+
elif lowered in PATCH_KEYS:
|
|
434
|
+
patch_payloads.append((value, context))
|
|
435
|
+
elif isinstance(value, list):
|
|
436
|
+
strings = [item for item in value if isinstance(item, str)]
|
|
437
|
+
if strings and lowered in path_keys:
|
|
438
|
+
paths.extend((item, context) for item in strings)
|
|
439
|
+
elif lowered in COMMAND_KEYS:
|
|
440
|
+
if not strings or len(strings) != len(value):
|
|
441
|
+
raise ValueError("command argv cannot be validated")
|
|
442
|
+
commands.append((shlex.join(strings), context))
|
|
443
|
+
elif strings and lowered in PATCH_KEYS:
|
|
444
|
+
patch_payloads.extend((item, context) for item in strings)
|
|
445
|
+
collect(
|
|
446
|
+
value,
|
|
447
|
+
paths,
|
|
448
|
+
working_directories,
|
|
449
|
+
commands,
|
|
450
|
+
patch_payloads,
|
|
451
|
+
path_keys,
|
|
452
|
+
context,
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
collect(
|
|
456
|
+
value,
|
|
457
|
+
paths,
|
|
458
|
+
working_directories,
|
|
459
|
+
commands,
|
|
460
|
+
patch_payloads,
|
|
461
|
+
path_keys,
|
|
462
|
+
context,
|
|
463
|
+
)
|
|
464
|
+
elif isinstance(node, list):
|
|
465
|
+
for value in node:
|
|
466
|
+
collect(
|
|
467
|
+
value,
|
|
468
|
+
paths,
|
|
469
|
+
working_directories,
|
|
470
|
+
commands,
|
|
471
|
+
patch_payloads,
|
|
472
|
+
path_keys,
|
|
473
|
+
inherited_working_directories,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def normalize_posix(path):
|
|
478
|
+
text = path.replace("\\", "/").strip()
|
|
479
|
+
if not text:
|
|
480
|
+
return "/"
|
|
481
|
+
parts = []
|
|
482
|
+
for part in PurePosixPath(text).parts:
|
|
483
|
+
if part == "..":
|
|
484
|
+
if parts:
|
|
485
|
+
parts.pop()
|
|
486
|
+
elif part != ".":
|
|
487
|
+
parts.append(part)
|
|
488
|
+
if not parts:
|
|
489
|
+
return "/"
|
|
490
|
+
return "/" + "/".join(parts)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def tool_tokens(tool):
|
|
494
|
+
return {token.casefold() for token in TOOL_TOKEN_PATTERN.findall(tool)}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def is_read_tool(tool):
|
|
498
|
+
tokens = tool_tokens(tool)
|
|
499
|
+
return bool(tokens & READ_VERBS) and not tokens & WRITE_VERBS
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def is_patch_tool(tool):
|
|
503
|
+
return "patch" in tool_tokens(tool)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def has_patch_headers(payload):
|
|
507
|
+
return bool(
|
|
508
|
+
PATCH_PATH_PATTERN.search(payload)
|
|
509
|
+
or UNIFIED_DIFF_PATH_PATTERN.search(payload)
|
|
510
|
+
or GIT_DIFF_PATH_PATTERN.search(payload)
|
|
511
|
+
or GIT_RENAME_PATH_PATTERN.search(payload)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def is_direct_write_tool(tool, has_file_targets=False):
|
|
516
|
+
if has_file_targets:
|
|
517
|
+
return not is_read_tool(tool)
|
|
518
|
+
tokens = tool_tokens(tool)
|
|
519
|
+
if tokens & DIRECT_FILE_WRITE_VERBS:
|
|
520
|
+
return (
|
|
521
|
+
len(tokens) == 1
|
|
522
|
+
or bool(tokens & FILE_TARGET_TOKENS)
|
|
523
|
+
or has_file_targets
|
|
524
|
+
)
|
|
525
|
+
if tokens == {"create"}:
|
|
526
|
+
return True
|
|
527
|
+
return bool(
|
|
528
|
+
(tokens & AMBIGUOUS_WRITE_VERBS)
|
|
529
|
+
and ((tokens & FILE_TARGET_TOKENS) or has_file_targets)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@lru_cache(maxsize=None)
|
|
534
|
+
def repository_root():
|
|
535
|
+
candidate = Path(__file__).resolve().parent.parent
|
|
536
|
+
result = subprocess.run(
|
|
537
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
538
|
+
cwd=candidate,
|
|
539
|
+
text=True,
|
|
540
|
+
capture_output=True,
|
|
541
|
+
check=False,
|
|
542
|
+
)
|
|
543
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
544
|
+
state = candidate / ".project" / "STATE.md"
|
|
545
|
+
if (
|
|
546
|
+
candidate.is_dir()
|
|
547
|
+
and not candidate.is_symlink()
|
|
548
|
+
and os.path.lexists(state)
|
|
549
|
+
):
|
|
550
|
+
return candidate
|
|
551
|
+
raise ValueError("repository root cannot be resolved")
|
|
552
|
+
return Path(result.stdout.strip()).resolve()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def project_status(repo):
|
|
556
|
+
script_root = Path(__file__).resolve().parent
|
|
557
|
+
launcher = script_root / "status_runtime.py"
|
|
558
|
+
if not launcher.is_file():
|
|
559
|
+
raise ValueError("project status runtime is unavailable")
|
|
560
|
+
result = subprocess.run(
|
|
561
|
+
[sys.executable, "-B", str(launcher), "--repo", str(repo)],
|
|
562
|
+
cwd=repo,
|
|
563
|
+
text=True,
|
|
564
|
+
capture_output=True,
|
|
565
|
+
check=False,
|
|
566
|
+
)
|
|
567
|
+
if result.returncode != 0:
|
|
568
|
+
raise ValueError("project status failed")
|
|
569
|
+
payload = json.loads(result.stdout)
|
|
570
|
+
if not valid_status_payload(payload, repo):
|
|
571
|
+
raise ValueError("project status returned an invalid payload")
|
|
572
|
+
return payload
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def valid_status_payload(payload, repo):
|
|
576
|
+
if not isinstance(payload, dict) or payload.get("schema") != "gsd-path/status/v1":
|
|
577
|
+
return False
|
|
578
|
+
state = payload.get("state")
|
|
579
|
+
route = payload.get("route")
|
|
580
|
+
status_path = payload.get("path")
|
|
581
|
+
next_skill = payload.get("next_skill")
|
|
582
|
+
if (
|
|
583
|
+
payload.get("advance") is not False
|
|
584
|
+
or not isinstance(state, dict)
|
|
585
|
+
or not valid_status_state(state)
|
|
586
|
+
or not isinstance(route, dict)
|
|
587
|
+
or route.get("action") not in STATUS_ACTIONS
|
|
588
|
+
or not isinstance(route.get("reason"), str)
|
|
589
|
+
or not route["reason"]
|
|
590
|
+
or not isinstance(status_path, str)
|
|
591
|
+
or not Path(status_path).is_absolute()
|
|
592
|
+
or Path(status_path).resolve(strict=False)
|
|
593
|
+
!= (repo / ".project" / "STATE.md").resolve(strict=False)
|
|
594
|
+
):
|
|
595
|
+
return False
|
|
596
|
+
action = route["action"]
|
|
597
|
+
if action == "run-phase":
|
|
598
|
+
phase = route.get("phase")
|
|
599
|
+
return (
|
|
600
|
+
isinstance(phase, str)
|
|
601
|
+
and phase in STATUS_TRANSITIONS[state["phase"]]
|
|
602
|
+
and state["branch"] is not None
|
|
603
|
+
and next_skill == f"gsd-path-{phase}"
|
|
604
|
+
)
|
|
605
|
+
if "phase" in route:
|
|
606
|
+
return False
|
|
607
|
+
if action == "bind-initial":
|
|
608
|
+
return (
|
|
609
|
+
state["branch"] is None
|
|
610
|
+
and isinstance(route.get("branch"), str)
|
|
611
|
+
and valid_status_branch(route["branch"]) is not None
|
|
612
|
+
and next_skill == "gsd-path"
|
|
613
|
+
)
|
|
614
|
+
if state["branch"] is None:
|
|
615
|
+
return False
|
|
616
|
+
if action == "validate-integrated" and not (
|
|
617
|
+
state["phase"] == "shipped" and state["status"] == "done"
|
|
618
|
+
):
|
|
619
|
+
return False
|
|
620
|
+
if action == "wait" and not (
|
|
621
|
+
state["phase"] == "plan" and state["status"] == "done"
|
|
622
|
+
):
|
|
623
|
+
return False
|
|
624
|
+
if action == "resume-undo":
|
|
625
|
+
expected = "gsd-path-undo"
|
|
626
|
+
elif action == "wait":
|
|
627
|
+
expected = None
|
|
628
|
+
else:
|
|
629
|
+
expected = "gsd-path"
|
|
630
|
+
return next_skill == expected
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def valid_status_state(state):
|
|
634
|
+
if set(state) != STATUS_STATE_FIELDS:
|
|
635
|
+
return False
|
|
636
|
+
milestone = state["milestone"]
|
|
637
|
+
branch = state["branch"]
|
|
638
|
+
archive = state["archive"]
|
|
639
|
+
integration_default = state["integration_default"]
|
|
640
|
+
integration = state["integration"]
|
|
641
|
+
integration_source = state["integration_source"]
|
|
642
|
+
archive_match = (
|
|
643
|
+
STATUS_ARCHIVE.fullmatch(archive or "")
|
|
644
|
+
if isinstance(archive, (str, type(None)))
|
|
645
|
+
else None
|
|
646
|
+
)
|
|
647
|
+
if (
|
|
648
|
+
state["pipeline"] != "gsd-path/v2"
|
|
649
|
+
or not isinstance(state["project"], str)
|
|
650
|
+
or STATUS_SLUG.fullmatch(state["project"]) is None
|
|
651
|
+
or not (
|
|
652
|
+
milestone is None
|
|
653
|
+
or isinstance(milestone, str)
|
|
654
|
+
and STATUS_SLUG.fullmatch(milestone) is not None
|
|
655
|
+
)
|
|
656
|
+
or not isinstance(state["phase"], str)
|
|
657
|
+
or state["phase"] not in STATUS_PHASES
|
|
658
|
+
or not isinstance(state["status"], str)
|
|
659
|
+
or state["status"] not in STATUS_VALUES
|
|
660
|
+
or integration_default not in STATUS_INTEGRATION_MODES
|
|
661
|
+
or integration not in STATUS_INTEGRATION_MODES
|
|
662
|
+
or integration_source not in STATUS_INTEGRATION_SOURCES
|
|
663
|
+
or (
|
|
664
|
+
integration_source == "default"
|
|
665
|
+
and integration != integration_default
|
|
666
|
+
)
|
|
667
|
+
or not (
|
|
668
|
+
branch is None
|
|
669
|
+
or isinstance(branch, str)
|
|
670
|
+
and valid_status_branch(branch) is not None
|
|
671
|
+
)
|
|
672
|
+
or not (archive is None or archive_match is not None)
|
|
673
|
+
):
|
|
674
|
+
return False
|
|
675
|
+
if state["phase"] == "shipped" and (
|
|
676
|
+
state["status"] != "done" or archive is None
|
|
677
|
+
):
|
|
678
|
+
return False
|
|
679
|
+
if archive is not None and state["phase"] not in {"build", "ship", "shipped"}:
|
|
680
|
+
return False
|
|
681
|
+
if state["phase"] in {"ship", "shipped"} and branch is None:
|
|
682
|
+
return False
|
|
683
|
+
if archive_match is not None:
|
|
684
|
+
branch_match = valid_status_branch(branch)
|
|
685
|
+
return (
|
|
686
|
+
milestone == archive_match.group(2)
|
|
687
|
+
and branch_match is not None
|
|
688
|
+
and int(branch_match.group(1)) == int(archive_match.group(1))
|
|
689
|
+
)
|
|
690
|
+
return True
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def valid_status_branch(value):
|
|
694
|
+
match = STATUS_BRANCH.fullmatch(value) if isinstance(value, str) else None
|
|
695
|
+
return match if match is not None and int(match.group(1)) >= 1 else None
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def target_paths(path, working_directories, repo):
|
|
699
|
+
repo = repo.resolve()
|
|
700
|
+
candidate = Path(path)
|
|
701
|
+
if not candidate.is_absolute():
|
|
702
|
+
base = Path(working_directories[0]) if working_directories else repo
|
|
703
|
+
if not base.is_absolute():
|
|
704
|
+
base = repo / base
|
|
705
|
+
candidate = base / candidate
|
|
706
|
+
return Path(os.path.abspath(candidate)), candidate.resolve(strict=False)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@lru_cache(maxsize=None)
|
|
710
|
+
def repository_control_roots(repo):
|
|
711
|
+
roots = [(repo / ".git").resolve(strict=False)]
|
|
712
|
+
result = subprocess.run(
|
|
713
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
714
|
+
cwd=repo,
|
|
715
|
+
text=True,
|
|
716
|
+
capture_output=True,
|
|
717
|
+
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
718
|
+
check=False,
|
|
719
|
+
)
|
|
720
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
721
|
+
common = Path(result.stdout.strip())
|
|
722
|
+
if not common.is_absolute():
|
|
723
|
+
common = repo / common
|
|
724
|
+
roots.append(common.resolve(strict=False))
|
|
725
|
+
return tuple(roots)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _same_existing_path(left, right):
|
|
729
|
+
try:
|
|
730
|
+
return os.path.samefile(left, right)
|
|
731
|
+
except OSError:
|
|
732
|
+
return False
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _within_existing_root(candidate, root):
|
|
736
|
+
return any(
|
|
737
|
+
_same_existing_path(ancestor, root)
|
|
738
|
+
for ancestor in (candidate, *candidate.parents)
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _canonical_control_alias(candidate, repo, control_roots):
|
|
743
|
+
roots = (*control_roots, repo / ".project", repo / ".gsd-path")
|
|
744
|
+
suffix = []
|
|
745
|
+
current = candidate
|
|
746
|
+
while current != current.parent:
|
|
747
|
+
for root in roots:
|
|
748
|
+
if _same_existing_path(current, root):
|
|
749
|
+
return root.joinpath(*reversed(suffix))
|
|
750
|
+
suffix.append(current.name)
|
|
751
|
+
current = current.parent
|
|
752
|
+
return candidate
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def path_kind(candidate, repo, control_roots=()):
|
|
756
|
+
if any(
|
|
757
|
+
candidate == root
|
|
758
|
+
or root in candidate.parents
|
|
759
|
+
or _within_existing_root(candidate, root)
|
|
760
|
+
for root in control_roots
|
|
761
|
+
):
|
|
762
|
+
return "protected"
|
|
763
|
+
if candidate != repo and repo not in candidate.parents:
|
|
764
|
+
return "external"
|
|
765
|
+
relative = candidate.relative_to(repo)
|
|
766
|
+
parts = relative.parts
|
|
767
|
+
if parts in {
|
|
768
|
+
(".project",),
|
|
769
|
+
(".project", "STATE.md"),
|
|
770
|
+
(".project", "next"),
|
|
771
|
+
(".project", "next", "STATE.md"),
|
|
772
|
+
}:
|
|
773
|
+
return "protected"
|
|
774
|
+
protected_paths = (
|
|
775
|
+
repo / ".project",
|
|
776
|
+
repo / ".project" / "STATE.md",
|
|
777
|
+
repo / ".project" / "next",
|
|
778
|
+
repo / ".project" / "next" / "STATE.md",
|
|
779
|
+
)
|
|
780
|
+
if any(_same_existing_path(candidate, path) for path in protected_paths):
|
|
781
|
+
return "protected"
|
|
782
|
+
managed_root = repo / ".gsd-path"
|
|
783
|
+
if (parts and parts[0] == ".gsd-path") or _within_existing_root(
|
|
784
|
+
candidate, managed_root
|
|
785
|
+
):
|
|
786
|
+
return "protected"
|
|
787
|
+
if parts and parts[0] == ".project":
|
|
788
|
+
return "artifact"
|
|
789
|
+
return "product"
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def target_kind(path, working_directories, repo, control_roots=None):
|
|
793
|
+
lexical, resolved = target_paths(path, working_directories, repo)
|
|
794
|
+
repo = repo.resolve()
|
|
795
|
+
if control_roots is None:
|
|
796
|
+
control_roots = repository_control_roots(repo)
|
|
797
|
+
aliases = (
|
|
798
|
+
_canonical_control_alias(lexical, repo, control_roots),
|
|
799
|
+
_canonical_control_alias(resolved, repo, control_roots),
|
|
800
|
+
)
|
|
801
|
+
kinds = {
|
|
802
|
+
path_kind(candidate, repo, control_roots)
|
|
803
|
+
for candidate in (lexical, resolved, *aliases)
|
|
804
|
+
}
|
|
805
|
+
for kind in ("protected", "product", "artifact", "external"):
|
|
806
|
+
if kind in kinds:
|
|
807
|
+
return kind
|
|
808
|
+
return "external"
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def pipeline_target_kinds(targets):
|
|
812
|
+
"""Classify (path, working_directories) pairs; None when no pipeline is owned."""
|
|
813
|
+
repo = repository_root()
|
|
814
|
+
state = repo / ".project" / "STATE.md"
|
|
815
|
+
if not os.path.lexists(state):
|
|
816
|
+
return None
|
|
817
|
+
control_roots = repository_control_roots(repo)
|
|
818
|
+
return repo, state, [
|
|
819
|
+
(path, target_kind(path, directories, repo, control_roots))
|
|
820
|
+
for path, directories in targets
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def closed_target_reason(targets):
|
|
825
|
+
for path, working_directories in targets:
|
|
826
|
+
for target in dict.fromkeys(target_paths(path, working_directories, repository_root())):
|
|
827
|
+
directory = target if target.is_dir() and not target.is_symlink() else target.parent
|
|
828
|
+
while not directory.exists() and directory != directory.parent:
|
|
829
|
+
directory = directory.parent
|
|
830
|
+
root = subprocess.run(
|
|
831
|
+
["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
|
|
832
|
+
capture_output=True, text=True,
|
|
833
|
+
)
|
|
834
|
+
if root.returncode == 0:
|
|
835
|
+
reason = closed_milestone_reason(Path(root.stdout.strip()))
|
|
836
|
+
if reason:
|
|
837
|
+
return reason
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def enforce_pipeline_reentry(paths, working_directories):
|
|
842
|
+
reason = closed_target_reason((path, working_directories) for path in paths)
|
|
843
|
+
if reason:
|
|
844
|
+
deny(reason)
|
|
845
|
+
classified = pipeline_target_kinds((path, working_directories) for path in paths)
|
|
846
|
+
if classified is None:
|
|
847
|
+
return
|
|
848
|
+
repo, state, kinds = classified
|
|
849
|
+
for path, kind in kinds:
|
|
850
|
+
if kind == "protected":
|
|
851
|
+
deny(f"{CONTROL_FILE_REASON} {path}")
|
|
852
|
+
kinds = [kind for _, kind in kinds]
|
|
853
|
+
if all(kind == "external" for kind in kinds):
|
|
854
|
+
return
|
|
855
|
+
try:
|
|
856
|
+
status = project_status(repo)
|
|
857
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
858
|
+
deny(REENTRY_FAILURE_REASON)
|
|
859
|
+
route = status.get("route")
|
|
860
|
+
state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
|
|
861
|
+
routed_build = (
|
|
862
|
+
state_data.get("phase") == "build"
|
|
863
|
+
and isinstance(route, dict)
|
|
864
|
+
and route.get("action") == "run-phase"
|
|
865
|
+
and route.get("phase") == "build"
|
|
866
|
+
)
|
|
867
|
+
if routed_build:
|
|
868
|
+
return
|
|
869
|
+
if all(kind in {"external", "artifact"} for kind in kinds):
|
|
870
|
+
return
|
|
871
|
+
phase = state_data.get("phase", "unknown")
|
|
872
|
+
if isinstance(route, dict) and route.get("action") != "run-phase":
|
|
873
|
+
next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
|
|
874
|
+
else:
|
|
875
|
+
next_step = status.get("next_skill") or "gsd-path"
|
|
876
|
+
deny(
|
|
877
|
+
f"GSD Path is {phase}; direct product-file changes require the routed "
|
|
878
|
+
f"build phase. Review {status.get('path', state)}; next: {next_step}"
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def in_archive(path):
|
|
883
|
+
# Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
|
|
884
|
+
normalized = normalize_posix(path).casefold()
|
|
885
|
+
marker = "/" + ARCHIVE_MARKER
|
|
886
|
+
if marker not in normalized:
|
|
887
|
+
return False
|
|
888
|
+
idx = normalized.index(marker)
|
|
889
|
+
suffix = normalized[idx + len(marker):]
|
|
890
|
+
return suffix == "" or suffix.startswith("/")
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def component_can_match(pattern, target):
|
|
894
|
+
match = re.search(r"\{([^{}]+)\}", pattern)
|
|
895
|
+
if match is not None:
|
|
896
|
+
return any(
|
|
897
|
+
component_can_match(
|
|
898
|
+
pattern[: match.start()] + option + pattern[match.end() :], target
|
|
899
|
+
)
|
|
900
|
+
for option in match.group(1).split(",")
|
|
901
|
+
)
|
|
902
|
+
return fnmatchcase(target, pattern)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def expansion_can_match_archive(path):
|
|
906
|
+
components = [
|
|
907
|
+
component.casefold()
|
|
908
|
+
for component in PurePosixPath(normalize_posix(path)).parts
|
|
909
|
+
if component != "/"
|
|
910
|
+
]
|
|
911
|
+
return any(
|
|
912
|
+
component_can_match(components[index], ".project")
|
|
913
|
+
and component_can_match(components[index + 1], "archive")
|
|
914
|
+
for index in range(len(components) - 1)
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def path_values(value):
|
|
919
|
+
yield value
|
|
920
|
+
if value.startswith("-") and "=" in value:
|
|
921
|
+
yield value.split("=", 1)[1]
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def is_absolute_path(path):
|
|
925
|
+
normalized = path.replace("\\", "/")
|
|
926
|
+
return normalized.startswith("/") or bool(re.match(r"^[A-Za-z]:/", normalized))
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def contains_existing_archive(path, working_directories):
|
|
930
|
+
try:
|
|
931
|
+
target = Path(path).resolve(strict=False)
|
|
932
|
+
except (OSError, RuntimeError):
|
|
933
|
+
return False
|
|
934
|
+
search_paths = [target, Path.cwd(), *(Path(value) for value in working_directories)]
|
|
935
|
+
for search_path in search_paths:
|
|
936
|
+
if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", str(search_path)):
|
|
937
|
+
continue
|
|
938
|
+
try:
|
|
939
|
+
resolved = search_path.resolve(strict=False)
|
|
940
|
+
except (OSError, RuntimeError):
|
|
941
|
+
continue
|
|
942
|
+
for parent in (resolved, *resolved.parents):
|
|
943
|
+
archive = parent / ".project" / "archive"
|
|
944
|
+
if not archive.is_dir():
|
|
945
|
+
continue
|
|
946
|
+
try:
|
|
947
|
+
archive = archive.resolve(strict=True)
|
|
948
|
+
except (OSError, RuntimeError):
|
|
949
|
+
continue
|
|
950
|
+
if target == archive or target in archive.parents:
|
|
951
|
+
return True
|
|
952
|
+
return False
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def path_in_archive(path, working_directories=(), ancestors=True):
|
|
956
|
+
"""True when path is inside an existing archive, or (ancestors=True) contains one."""
|
|
957
|
+
for value in path_values(path):
|
|
958
|
+
if in_archive(value) or expansion_can_match_archive(value):
|
|
959
|
+
return True
|
|
960
|
+
bases = [""] if is_absolute_path(value) else list(working_directories) or [""]
|
|
961
|
+
for working_directory in bases:
|
|
962
|
+
combined = (
|
|
963
|
+
value
|
|
964
|
+
if is_absolute_path(value) or not working_directory
|
|
965
|
+
else f"{working_directory}/{value}"
|
|
966
|
+
)
|
|
967
|
+
if in_archive(combined) or expansion_can_match_archive(combined):
|
|
968
|
+
return True
|
|
969
|
+
if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", combined):
|
|
970
|
+
continue
|
|
971
|
+
try:
|
|
972
|
+
resolved = Path(combined).resolve(strict=False)
|
|
973
|
+
except (OSError, RuntimeError):
|
|
974
|
+
continue
|
|
975
|
+
if in_archive(resolved.as_posix()) or (
|
|
976
|
+
ancestors and contains_existing_archive(resolved, working_directories)
|
|
977
|
+
):
|
|
978
|
+
return True
|
|
979
|
+
return False
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def working_directory_in_archive(path):
|
|
983
|
+
"""A working directory is archive context only when it is inside an archive.
|
|
984
|
+
|
|
985
|
+
The repository root merely contains .project/archive/ once a milestone has
|
|
986
|
+
shipped; treating it as archive context would deny every shell command in
|
|
987
|
+
the project. Ancestor containment stays with path_in_archive for operands,
|
|
988
|
+
so deleting the archive's parent is still refused.
|
|
989
|
+
"""
|
|
990
|
+
for value in path_values(path):
|
|
991
|
+
if in_archive(value) or expansion_can_match_archive(value):
|
|
992
|
+
return True
|
|
993
|
+
if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", value):
|
|
994
|
+
continue
|
|
995
|
+
try:
|
|
996
|
+
resolved = Path(value).resolve(strict=False)
|
|
997
|
+
except (OSError, RuntimeError):
|
|
998
|
+
continue
|
|
999
|
+
if in_archive(resolved.as_posix()):
|
|
1000
|
+
return True
|
|
1001
|
+
return False
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def is_redirection(token):
|
|
1005
|
+
return bool(token) and set(token) <= {"<", ">"}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def directory_change_target(arguments):
|
|
1009
|
+
operands = []
|
|
1010
|
+
for index, argument in enumerate(arguments):
|
|
1011
|
+
if is_redirection(argument):
|
|
1012
|
+
break
|
|
1013
|
+
if (
|
|
1014
|
+
argument.isdigit()
|
|
1015
|
+
and index + 1 < len(arguments)
|
|
1016
|
+
and is_redirection(arguments[index + 1])
|
|
1017
|
+
):
|
|
1018
|
+
break
|
|
1019
|
+
if (
|
|
1020
|
+
argument != "--"
|
|
1021
|
+
and not argument.startswith("-")
|
|
1022
|
+
and argument.casefold() != "/d"
|
|
1023
|
+
):
|
|
1024
|
+
operands.append(argument)
|
|
1025
|
+
if not operands:
|
|
1026
|
+
raise ValueError("cd without a literal directory cannot be tracked by the guard")
|
|
1027
|
+
if operands[-1] == "-":
|
|
1028
|
+
raise ValueError(
|
|
1029
|
+
"cd - returns to a directory the guard cannot track; cd to a literal path"
|
|
1030
|
+
)
|
|
1031
|
+
return operands[-1]
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def segment_directories(tokens, working_directories):
|
|
1035
|
+
"""Yield each command segment with the working directories in effect for it."""
|
|
1036
|
+
current_directories = list(working_directories)
|
|
1037
|
+
for segment in command_segments(tokens):
|
|
1038
|
+
yield segment, current_directories
|
|
1039
|
+
invocation = command_invocation(segment)
|
|
1040
|
+
if invocation is None:
|
|
1041
|
+
continue
|
|
1042
|
+
command, arguments = invocation
|
|
1043
|
+
if command == "popd":
|
|
1044
|
+
raise ValueError(
|
|
1045
|
+
"popd returns to a directory the guard cannot track; cd to a literal path"
|
|
1046
|
+
)
|
|
1047
|
+
if command not in DIRECTORY_CHANGE_COMMANDS:
|
|
1048
|
+
continue
|
|
1049
|
+
target = directory_change_target(arguments)
|
|
1050
|
+
if SHELL_PARAMETER_SYNTAX.search(target):
|
|
1051
|
+
raise ValueError(
|
|
1052
|
+
f"cd target {target} cannot be resolved by the guard; "
|
|
1053
|
+
"run that command first and cd to the literal result"
|
|
1054
|
+
)
|
|
1055
|
+
if is_absolute_path(target):
|
|
1056
|
+
current_directories = [target]
|
|
1057
|
+
else:
|
|
1058
|
+
bases = current_directories or ["."]
|
|
1059
|
+
current_directories = [f"{base}/{target}" for base in bases]
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def command_can_destroy_files(invocation):
|
|
1063
|
+
if invocation is None:
|
|
1064
|
+
return False
|
|
1065
|
+
executable, arguments = invocation
|
|
1066
|
+
executable = executable.removesuffix(".exe")
|
|
1067
|
+
return executable in DESTRUCTIVE_SHELL_COMMANDS or (
|
|
1068
|
+
executable == "find" and "-delete" in arguments
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def literal_path(operand):
|
|
1073
|
+
if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
|
|
1074
|
+
operand = operand[1:-1]
|
|
1075
|
+
pattern = r"[A-Za-z0-9._/-]+"
|
|
1076
|
+
if os.name == "nt":
|
|
1077
|
+
pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\-]+"
|
|
1078
|
+
return operand if re.fullmatch(pattern, operand) is not None else None
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def literal_destructive_operand(operand):
|
|
1082
|
+
literal = literal_path(operand)
|
|
1083
|
+
if literal is None:
|
|
1084
|
+
raise ValueError(
|
|
1085
|
+
f"destructive operand {operand} cannot be validated; pass a literal path"
|
|
1086
|
+
)
|
|
1087
|
+
return literal
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def command_references_archive(tokens, working_directories):
|
|
1091
|
+
for segment, directories in segment_directories(tokens, working_directories):
|
|
1092
|
+
invocation = command_invocation(segment)
|
|
1093
|
+
ancestors = command_can_destroy_files(invocation)
|
|
1094
|
+
operands = segment
|
|
1095
|
+
if ancestors:
|
|
1096
|
+
operands = [literal_destructive_operand(operand) for operand in invocation[1]]
|
|
1097
|
+
if any(path_in_archive(operand, directories, ancestors) for operand in operands):
|
|
1098
|
+
return True
|
|
1099
|
+
wrapped = wrapped_command_tokens(segment, expand_parameters=False)
|
|
1100
|
+
if wrapped is not None and command_references_archive(wrapped, directories):
|
|
1101
|
+
return True
|
|
1102
|
+
return False
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def copy_destinations(arguments, directories, assignments=None):
|
|
1106
|
+
operands, destination, no_target_directory = [], None, False
|
|
1107
|
+
arguments = iter(arguments)
|
|
1108
|
+
command_arguments = []
|
|
1109
|
+
for argument in arguments:
|
|
1110
|
+
if is_redirection(argument) or (
|
|
1111
|
+
">" in argument and set(argument) <= SHELL_WRITE_REDIRECTION_CHARS
|
|
1112
|
+
):
|
|
1113
|
+
if command_arguments and command_arguments[-1].isdigit():
|
|
1114
|
+
raise ValueError("cp numeric operand before redirection is ambiguous")
|
|
1115
|
+
if next(arguments, None) is None:
|
|
1116
|
+
raise ValueError("cp redirection lacks a target")
|
|
1117
|
+
else:
|
|
1118
|
+
command_arguments.append(argument)
|
|
1119
|
+
command_arguments = [expand_environment_parameters(argument, assignments) for argument in command_arguments]
|
|
1120
|
+
if any(SHELL_PARAMETER_SYNTAX.search(argument) or CMD_PARAMETER_SYNTAX.search(argument) for argument in command_arguments):
|
|
1121
|
+
raise ValueError("cp operand cannot be resolved by the guard; pass a literal path")
|
|
1122
|
+
arguments = iter(command_arguments)
|
|
1123
|
+
for argument in arguments:
|
|
1124
|
+
if argument == "--":
|
|
1125
|
+
operands.extend(arguments)
|
|
1126
|
+
break
|
|
1127
|
+
if argument in {"-t", "--target-directory", "-S", "--suffix"}:
|
|
1128
|
+
value = next(arguments, None)
|
|
1129
|
+
if value is None:
|
|
1130
|
+
raise ValueError(f"cp option {argument} lacks a value")
|
|
1131
|
+
if argument in {"-t", "--target-directory"}:
|
|
1132
|
+
destination = value
|
|
1133
|
+
elif argument.startswith("--target-directory="):
|
|
1134
|
+
destination = argument.split("=", 1)[1]
|
|
1135
|
+
elif argument.startswith("-t") and not argument.startswith("--"):
|
|
1136
|
+
destination = argument[2:]
|
|
1137
|
+
elif argument in {"-T", "--no-target-directory"}:
|
|
1138
|
+
no_target_directory = True
|
|
1139
|
+
elif not argument.startswith("-"):
|
|
1140
|
+
operands.append(argument)
|
|
1141
|
+
if destination is None:
|
|
1142
|
+
if len(operands) < 2:
|
|
1143
|
+
raise ValueError("cp source and destination cannot be resolved")
|
|
1144
|
+
destination = operands.pop()
|
|
1145
|
+
yield destination
|
|
1146
|
+
_, target = target_paths(destination, directories, repository_root())
|
|
1147
|
+
for source in operands:
|
|
1148
|
+
output = Path(destination)
|
|
1149
|
+
if not no_target_directory and target.is_dir():
|
|
1150
|
+
output /= os.path.basename(source.rstrip("/"))
|
|
1151
|
+
outputs = [output]
|
|
1152
|
+
if source.endswith("/") and output != Path(destination):
|
|
1153
|
+
outputs.append(Path(destination))
|
|
1154
|
+
yield from map(str, outputs)
|
|
1155
|
+
lexical, source_path = target_paths(source, directories, repository_root())
|
|
1156
|
+
if source_path.is_dir():
|
|
1157
|
+
# ponytail: reject ambiguous directory links instead of emulating cp flags.
|
|
1158
|
+
if lexical.is_symlink():
|
|
1159
|
+
raise ValueError("cp directory source is a symlink; pass a literal directory")
|
|
1160
|
+
|
|
1161
|
+
def unreadable(error):
|
|
1162
|
+
raise ValueError(f"cp source tree cannot be inspected: {error}")
|
|
1163
|
+
|
|
1164
|
+
for root, folders, files in os.walk(source_path, onerror=unreadable):
|
|
1165
|
+
for name in folders + files:
|
|
1166
|
+
entry = Path(root) / name
|
|
1167
|
+
if entry.is_symlink() and entry.is_dir():
|
|
1168
|
+
raise ValueError("cp source contains a directory symlink; copy it separately")
|
|
1169
|
+
for output in outputs:
|
|
1170
|
+
yield str(output / entry.relative_to(source_path))
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def shell_write_targets(tokens, working_directories, assignments=None):
|
|
1174
|
+
"""Yield (target, directories) for every path a shell command may write."""
|
|
1175
|
+
assignments = {**(assignments or {}), **shell_assignment_values(tokens)}
|
|
1176
|
+
for segment, directories in segment_directories(tokens, working_directories):
|
|
1177
|
+
for index, token in enumerate(segment[:-1]):
|
|
1178
|
+
if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
|
|
1179
|
+
continue
|
|
1180
|
+
target = segment[index + 1]
|
|
1181
|
+
if token.endswith("&") and (target.isdigit() or target == "-"):
|
|
1182
|
+
continue
|
|
1183
|
+
yield target, directories
|
|
1184
|
+
wrapped = wrapped_command_tokens(segment)
|
|
1185
|
+
if wrapped is not None:
|
|
1186
|
+
yield from shell_write_targets(wrapped, directories, assignments)
|
|
1187
|
+
continue
|
|
1188
|
+
invocation = command_invocation(segment)
|
|
1189
|
+
if invocation is None:
|
|
1190
|
+
continue
|
|
1191
|
+
command, arguments = invocation
|
|
1192
|
+
in_place = command in IN_PLACE_EDITORS and (
|
|
1193
|
+
has_short_option(arguments, "i")
|
|
1194
|
+
or any(argument.startswith("--in-place") for argument in arguments)
|
|
1195
|
+
)
|
|
1196
|
+
if command.removesuffix(".exe") == "cp":
|
|
1197
|
+
for destination in copy_destinations(arguments, directories, assignments):
|
|
1198
|
+
yield destination, directories
|
|
1199
|
+
continue
|
|
1200
|
+
if command.removesuffix(".exe") in SHELL_WRITE_COMMANDS or in_place:
|
|
1201
|
+
for argument in arguments:
|
|
1202
|
+
if argument and not argument.startswith("-"):
|
|
1203
|
+
yield argument, directories
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def protected_shell_write_reason(tokens, working_directories):
|
|
1207
|
+
assignments = shell_assignment_values(tokens)
|
|
1208
|
+
targets = []
|
|
1209
|
+
for target, directories in shell_write_targets(tokens, working_directories):
|
|
1210
|
+
expanded = expand_environment_parameters(target, assignments)
|
|
1211
|
+
if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
|
|
1212
|
+
expanded
|
|
1213
|
+
):
|
|
1214
|
+
raise ValueError(
|
|
1215
|
+
f"write target {target} cannot be resolved by the guard; "
|
|
1216
|
+
"pass a literal path"
|
|
1217
|
+
)
|
|
1218
|
+
if expanded and expanded not in {"/dev/null", "NUL"}:
|
|
1219
|
+
targets.append((expanded, directories))
|
|
1220
|
+
reason = closed_target_reason(targets)
|
|
1221
|
+
if reason:
|
|
1222
|
+
return reason
|
|
1223
|
+
classified = pipeline_target_kinds(targets) if targets else None
|
|
1224
|
+
if classified is None:
|
|
1225
|
+
return None
|
|
1226
|
+
for target, kind in classified[2]:
|
|
1227
|
+
if kind == "protected":
|
|
1228
|
+
return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
|
|
1229
|
+
return None
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def environment_parameter_value(match, assignments):
|
|
1233
|
+
name = match.group(1) or match.group(2)
|
|
1234
|
+
if name.startswith(SUBSTITUTION_PLACEHOLDER):
|
|
1235
|
+
return match.group(0) # command substitution output is never resolved
|
|
1236
|
+
return assignments.get(name, os.environ.get(name, ""))
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def expand_environment_parameters(command, assignments=None):
|
|
1240
|
+
values = assignments or {}
|
|
1241
|
+
|
|
1242
|
+
def substitute(match):
|
|
1243
|
+
return environment_parameter_value(match, values)
|
|
1244
|
+
|
|
1245
|
+
expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
|
|
1246
|
+
return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def shell_assignment_values(tokens):
|
|
1250
|
+
values = {}
|
|
1251
|
+
for segment in command_segments(tokens):
|
|
1252
|
+
variable_command = segment[0].casefold() in VARIABLE_COMMANDS
|
|
1253
|
+
for token in segment:
|
|
1254
|
+
if not SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
1255
|
+
if variable_command:
|
|
1256
|
+
continue
|
|
1257
|
+
break
|
|
1258
|
+
name, value = token.split("=", 1)
|
|
1259
|
+
if name in values:
|
|
1260
|
+
raise ValueError(f"shell variable {name} is reassigned; split the commands")
|
|
1261
|
+
values[name] = expand_environment_parameters(value, values)
|
|
1262
|
+
return values
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def unresolved_archive_expansion(command, tokens, working_directories):
|
|
1266
|
+
expanded = expand_environment_parameters(command, shell_assignment_values(tokens))
|
|
1267
|
+
return ARCHIVE_REFERENCE.search(expanded) is not None
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def substitution_end(command, start):
|
|
1271
|
+
depth, quote, index = 1, None, start
|
|
1272
|
+
while index < len(command):
|
|
1273
|
+
character = command[index]
|
|
1274
|
+
if quote:
|
|
1275
|
+
if character == "\\" and quote == '"':
|
|
1276
|
+
index += 2
|
|
1277
|
+
continue
|
|
1278
|
+
if character == quote:
|
|
1279
|
+
quote = None
|
|
1280
|
+
elif character == "\\":
|
|
1281
|
+
index += 2
|
|
1282
|
+
continue
|
|
1283
|
+
elif character in "'\"":
|
|
1284
|
+
quote = character
|
|
1285
|
+
elif character == "(":
|
|
1286
|
+
depth += 1
|
|
1287
|
+
elif character == ")":
|
|
1288
|
+
depth -= 1
|
|
1289
|
+
if depth == 0:
|
|
1290
|
+
return index
|
|
1291
|
+
index += 1
|
|
1292
|
+
raise ValueError("command substitution $( is not closed")
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def split_command_substitutions(command):
|
|
1296
|
+
"""Replace every $(...) and `...` with a placeholder; return (outer, inner)."""
|
|
1297
|
+
if "@(" in command:
|
|
1298
|
+
raise ValueError(
|
|
1299
|
+
"@( expansion cannot be validated; write the list as literal arguments"
|
|
1300
|
+
)
|
|
1301
|
+
if "$(" not in command and "`" not in command:
|
|
1302
|
+
return command, []
|
|
1303
|
+
outer, inner, index = [], [], 0
|
|
1304
|
+
while index < len(command):
|
|
1305
|
+
if command.startswith("$(", index):
|
|
1306
|
+
end = substitution_end(command, index + 2)
|
|
1307
|
+
inner.append(command[index + 2 : end])
|
|
1308
|
+
elif command[index] == "`":
|
|
1309
|
+
end = index + 1
|
|
1310
|
+
while end < len(command) and command[end] != "`":
|
|
1311
|
+
end += 2 if command[end] == "\\" else 1
|
|
1312
|
+
if end >= len(command):
|
|
1313
|
+
raise ValueError("backtick command substitution is not closed")
|
|
1314
|
+
inner.append(command[index + 1 : end])
|
|
1315
|
+
elif command[index] == "\\":
|
|
1316
|
+
outer.append(command[index : index + 2])
|
|
1317
|
+
index += 2
|
|
1318
|
+
continue
|
|
1319
|
+
else:
|
|
1320
|
+
outer.append(command[index])
|
|
1321
|
+
index += 1
|
|
1322
|
+
continue
|
|
1323
|
+
outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
|
|
1324
|
+
index = end + 1
|
|
1325
|
+
return "".join(outer), inner
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def describe_substitutions(text, substitutions):
|
|
1329
|
+
return SUBSTITUTION_PLACEHOLDER_SYNTAX.sub(
|
|
1330
|
+
lambda match: f"$({substitutions[int(match.group(1))]})", text
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def strip_heredoc_bodies(command):
|
|
1335
|
+
lines = command.split("\n")
|
|
1336
|
+
kept, index = [], 0
|
|
1337
|
+
while index < len(lines):
|
|
1338
|
+
line = lines[index]
|
|
1339
|
+
kept.append(line)
|
|
1340
|
+
index += 1
|
|
1341
|
+
for match in HEREDOC_PATTERN.finditer(line):
|
|
1342
|
+
delimiter = (
|
|
1343
|
+
match.group("single") or match.group("double") or match.group("bare")
|
|
1344
|
+
)
|
|
1345
|
+
strip = "\t\r" if match.group("dash") else "\r"
|
|
1346
|
+
while index < len(lines) and lines[index].strip(strip) != delimiter:
|
|
1347
|
+
index += 1
|
|
1348
|
+
if index >= len(lines):
|
|
1349
|
+
raise ValueError(f"here-document {delimiter} is not terminated")
|
|
1350
|
+
index += 1
|
|
1351
|
+
return "\n".join(kept)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def patch_paths(payload):
|
|
1355
|
+
for match in PATCH_PATH_PATTERN.finditer(payload):
|
|
1356
|
+
yield (match.group(1) or match.group(2)).strip()
|
|
1357
|
+
for match in UNIFIED_DIFF_PATH_PATTERN.finditer(payload):
|
|
1358
|
+
path = decode_patch_path(match.group(1), strip_prefix=True)
|
|
1359
|
+
if path != "/dev/null":
|
|
1360
|
+
yield path
|
|
1361
|
+
for match in GIT_DIFF_PATH_PATTERN.finditer(payload):
|
|
1362
|
+
for raw_path in match.groups():
|
|
1363
|
+
yield decode_patch_path(raw_path, strip_prefix=True)
|
|
1364
|
+
for match in GIT_RENAME_PATH_PATTERN.finditer(payload):
|
|
1365
|
+
yield decode_patch_path(match.group(1), strip_prefix=False)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def decode_patch_path(raw_path, strip_prefix):
|
|
1369
|
+
path = raw_path.strip()
|
|
1370
|
+
if path.startswith('"'):
|
|
1371
|
+
try:
|
|
1372
|
+
path = ast.literal_eval(path)
|
|
1373
|
+
except (SyntaxError, ValueError) as error:
|
|
1374
|
+
raise ValueError("quoted patch path cannot be validated") from error
|
|
1375
|
+
if not isinstance(path, str):
|
|
1376
|
+
raise ValueError("quoted patch path cannot be validated")
|
|
1377
|
+
elif '"' in path:
|
|
1378
|
+
raise ValueError("quoted patch path cannot be validated")
|
|
1379
|
+
if strip_prefix and path.startswith(("a/", "b/")):
|
|
1380
|
+
return path[2:]
|
|
1381
|
+
return path
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def shell_tokens(command):
|
|
1385
|
+
command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
|
|
1386
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
|
|
1387
|
+
lexer.whitespace = " \t"
|
|
1388
|
+
lexer.whitespace_split = True
|
|
1389
|
+
lexer.commenters = ""
|
|
1390
|
+
try:
|
|
1391
|
+
tokens = list(lexer)
|
|
1392
|
+
except ValueError as error:
|
|
1393
|
+
raise ValueError(
|
|
1394
|
+
f"shell command cannot be tokenized ({error}); balance the quotes "
|
|
1395
|
+
"or write the content with an editor tool"
|
|
1396
|
+
) from None
|
|
1397
|
+
if not tokens:
|
|
1398
|
+
raise ValueError("shell command is empty")
|
|
1399
|
+
return tokens
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def command_segments(tokens):
|
|
1403
|
+
"""Yield simple commands with separators and leading control words removed."""
|
|
1404
|
+
segment = []
|
|
1405
|
+
for token in tokens:
|
|
1406
|
+
if token and set(token) <= set("|;&()\n\r"):
|
|
1407
|
+
if segment:
|
|
1408
|
+
yield segment
|
|
1409
|
+
segment = []
|
|
1410
|
+
elif segment or token.casefold() not in SHELL_CONTROL_WORDS:
|
|
1411
|
+
segment.append(token)
|
|
1412
|
+
if segment:
|
|
1413
|
+
yield segment
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def validate_shell_assignment(assignment):
|
|
1417
|
+
name = assignment.partition("=")[0]
|
|
1418
|
+
folded = name.casefold()
|
|
1419
|
+
if folded in {"home", "xdg_config_home"} or folded.startswith("git_"):
|
|
1420
|
+
raise ValueError(
|
|
1421
|
+
f"{name} changes where Git reads its configuration, which the guard "
|
|
1422
|
+
"cannot validate; run git without it"
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def validate_variable_command(executable, arguments):
|
|
1427
|
+
for argument in arguments:
|
|
1428
|
+
if SHELL_ASSIGNMENT_PATTERN.match(argument):
|
|
1429
|
+
validate_shell_assignment(argument)
|
|
1430
|
+
elif executable == "set" or (executable == "unset" and argument.startswith("-")):
|
|
1431
|
+
continue
|
|
1432
|
+
elif argument.startswith(("-", "+")):
|
|
1433
|
+
raise ValueError(
|
|
1434
|
+
f"{executable} {argument}: variable options cannot be validated; "
|
|
1435
|
+
"assign with NAME=VALUE instead"
|
|
1436
|
+
)
|
|
1437
|
+
else:
|
|
1438
|
+
validate_shell_assignment(argument)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def first_argument(arguments):
|
|
1442
|
+
return arguments[0] if arguments else ""
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def xargs_command(arguments):
|
|
1446
|
+
index = 0
|
|
1447
|
+
while index < len(arguments):
|
|
1448
|
+
argument = arguments[index]
|
|
1449
|
+
if argument in XARGS_OPTIONS_WITH_VALUES:
|
|
1450
|
+
index += 2
|
|
1451
|
+
elif argument.startswith("-") and argument != "-":
|
|
1452
|
+
index += 1
|
|
1453
|
+
else:
|
|
1454
|
+
break
|
|
1455
|
+
return arguments[index:]
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def require_read_command(wrapper, wrapped):
|
|
1459
|
+
if wrapped and not archive_command_is_read_only(" ".join(wrapped), wrapped, True):
|
|
1460
|
+
raise ValueError(
|
|
1461
|
+
f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
|
|
1462
|
+
f"only read commands may follow {wrapper}, or pass the paths as literals"
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def command_invocation(segment):
|
|
1467
|
+
index = 0
|
|
1468
|
+
while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
|
|
1469
|
+
validate_shell_assignment(segment[index])
|
|
1470
|
+
index += 1
|
|
1471
|
+
if index >= len(segment):
|
|
1472
|
+
return None
|
|
1473
|
+
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1474
|
+
if executable == "env":
|
|
1475
|
+
index += 1
|
|
1476
|
+
while index < len(segment):
|
|
1477
|
+
token = segment[index]
|
|
1478
|
+
if token == "--":
|
|
1479
|
+
index += 1
|
|
1480
|
+
break
|
|
1481
|
+
if SHELL_ASSIGNMENT_PATTERN.match(token):
|
|
1482
|
+
validate_shell_assignment(token)
|
|
1483
|
+
index += 1
|
|
1484
|
+
continue
|
|
1485
|
+
option = token.split("=", 1)[0]
|
|
1486
|
+
if option in ENV_OPTIONS_WITHOUT_VALUES:
|
|
1487
|
+
index += 1
|
|
1488
|
+
continue
|
|
1489
|
+
if option in ENV_OPTIONS_WITH_VALUES:
|
|
1490
|
+
if option in {"-C", "--chdir"}:
|
|
1491
|
+
raise ValueError(
|
|
1492
|
+
f"env {option} changes the working directory the guard "
|
|
1493
|
+
"tracks; use cd with a literal path instead"
|
|
1494
|
+
)
|
|
1495
|
+
index += 1
|
|
1496
|
+
if "=" not in token:
|
|
1497
|
+
if index >= len(segment):
|
|
1498
|
+
raise ValueError(f"env {option} lacks a value")
|
|
1499
|
+
index += 1
|
|
1500
|
+
continue
|
|
1501
|
+
if token.startswith("-"):
|
|
1502
|
+
raise ValueError(
|
|
1503
|
+
f"env {token} cannot be validated; use env NAME=VALUE <command>"
|
|
1504
|
+
)
|
|
1505
|
+
break
|
|
1506
|
+
if index >= len(segment):
|
|
1507
|
+
return None
|
|
1508
|
+
executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1509
|
+
arguments = segment[index + 1 :]
|
|
1510
|
+
if SHELL_PARAMETER_SYNTAX.search(executable):
|
|
1511
|
+
raise ValueError(
|
|
1512
|
+
f"executable {segment[index]} cannot be resolved by the guard; "
|
|
1513
|
+
"name the program literally"
|
|
1514
|
+
)
|
|
1515
|
+
if executable in {"find", "find.exe"}:
|
|
1516
|
+
for position, argument in enumerate(arguments):
|
|
1517
|
+
if argument.casefold() in FIND_EXECUTION_ACTIONS:
|
|
1518
|
+
require_read_command(f"find {argument}", arguments[position + 1 :])
|
|
1519
|
+
if executable in XARGS_COMMANDS:
|
|
1520
|
+
require_read_command("xargs", xargs_command(arguments))
|
|
1521
|
+
if executable in VARIABLE_COMMANDS:
|
|
1522
|
+
validate_variable_command(executable, arguments)
|
|
1523
|
+
if executable in UNVALIDATED_EXECUTION_REASONS:
|
|
1524
|
+
raise ValueError(
|
|
1525
|
+
UNVALIDATED_EXECUTION_REASONS[executable].format(
|
|
1526
|
+
executable=segment[index], argument=first_argument(arguments)
|
|
1527
|
+
)
|
|
1528
|
+
)
|
|
1529
|
+
return executable, arguments
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def git_command(segment):
|
|
1533
|
+
invocation = command_invocation(segment)
|
|
1534
|
+
if invocation is None:
|
|
1535
|
+
return None
|
|
1536
|
+
executable, arguments = invocation
|
|
1537
|
+
if executable not in {"git", "git.exe"}:
|
|
1538
|
+
return None
|
|
1539
|
+
for argument in arguments:
|
|
1540
|
+
if SHELL_PARAMETER_SYNTAX.search(argument):
|
|
1541
|
+
raise ValueError(
|
|
1542
|
+
f"git argument {argument} cannot be resolved by the guard; pass a "
|
|
1543
|
+
"literal value (for commit messages use -F <file>)"
|
|
1544
|
+
)
|
|
1545
|
+
index = 0
|
|
1546
|
+
git_options = []
|
|
1547
|
+
while index < len(arguments) and arguments[index].startswith("-"):
|
|
1548
|
+
token = arguments[index]
|
|
1549
|
+
if token[:2] in {"-c", "-C"} and len(token) > 2 and not token.startswith("--"):
|
|
1550
|
+
option, value = token[:2], token[2:]
|
|
1551
|
+
else:
|
|
1552
|
+
option = token.split("=", 1)[0]
|
|
1553
|
+
value = token.split("=", 1)[1] if "=" in token else None
|
|
1554
|
+
index += 1
|
|
1555
|
+
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
|
|
1556
|
+
if index >= len(arguments):
|
|
1557
|
+
raise ValueError(f"git option {option} lacks a value")
|
|
1558
|
+
value = arguments[index]
|
|
1559
|
+
index += 1
|
|
1560
|
+
if option in {"-c", "--config-env"}:
|
|
1561
|
+
config_key = str(value).split("=", 1)[0].casefold()
|
|
1562
|
+
if config_key.startswith("alias."):
|
|
1563
|
+
raise ValueError(
|
|
1564
|
+
f"git -c {config_key} defines an alias the guard cannot inspect; "
|
|
1565
|
+
"run the underlying git command directly"
|
|
1566
|
+
)
|
|
1567
|
+
if config_key == "clean.requireforce":
|
|
1568
|
+
raise ValueError(
|
|
1569
|
+
"git -c clean.requireForce disables the git clean safety check; "
|
|
1570
|
+
"run git clean with explicit paths instead"
|
|
1571
|
+
)
|
|
1572
|
+
if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
|
|
1573
|
+
git_options.extend((option, str(value)))
|
|
1574
|
+
if index >= len(arguments):
|
|
1575
|
+
return None
|
|
1576
|
+
return arguments[index].casefold(), arguments[index + 1:], git_options
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def wrapped_command_tokens(segment, expand_parameters=True):
|
|
1580
|
+
invocation = command_invocation(segment)
|
|
1581
|
+
if invocation is None:
|
|
1582
|
+
return None
|
|
1583
|
+
executable, arguments = invocation
|
|
1584
|
+
if executable == "eval":
|
|
1585
|
+
return shell_tokens(" ".join(arguments)) if arguments else None
|
|
1586
|
+
if executable in COMMAND_WRAPPERS:
|
|
1587
|
+
if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
|
|
1588
|
+
return None
|
|
1589
|
+
if executable == "command" and arguments[:1] == ["-p"]:
|
|
1590
|
+
arguments = arguments[1:]
|
|
1591
|
+
if not arguments or arguments[0].startswith("-"):
|
|
1592
|
+
raise ValueError(
|
|
1593
|
+
f"{executable} {first_argument(arguments)}: only "
|
|
1594
|
+
f"`{executable} <program> [arguments]` can be validated"
|
|
1595
|
+
)
|
|
1596
|
+
return arguments
|
|
1597
|
+
if executable in POSIX_SHELL_WRAPPERS:
|
|
1598
|
+
for index, argument in enumerate(arguments):
|
|
1599
|
+
if (
|
|
1600
|
+
argument.startswith("-")
|
|
1601
|
+
and not argument.startswith("--")
|
|
1602
|
+
and "c" in argument[1:]
|
|
1603
|
+
):
|
|
1604
|
+
if index + 1 >= len(arguments):
|
|
1605
|
+
raise ValueError(
|
|
1606
|
+
MISSING_COMMAND_STRING_REASON.format(
|
|
1607
|
+
executable=executable, option=argument
|
|
1608
|
+
)
|
|
1609
|
+
)
|
|
1610
|
+
return shell_tokens(arguments[index + 1])
|
|
1611
|
+
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1612
|
+
if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
|
|
1613
|
+
switches = {"-c", "-command", "/c", "/k"}
|
|
1614
|
+
for index, argument in enumerate(arguments):
|
|
1615
|
+
if argument.casefold() in switches:
|
|
1616
|
+
if index + 1 >= len(arguments):
|
|
1617
|
+
raise ValueError(
|
|
1618
|
+
MISSING_COMMAND_STRING_REASON.format(
|
|
1619
|
+
executable=executable, option=argument
|
|
1620
|
+
)
|
|
1621
|
+
)
|
|
1622
|
+
payload = arguments[index + 1:]
|
|
1623
|
+
if executable in {"cmd", "cmd.exe"} and expand_parameters:
|
|
1624
|
+
payload = [
|
|
1625
|
+
expand_environment_parameters(argument)
|
|
1626
|
+
for argument in payload
|
|
1627
|
+
]
|
|
1628
|
+
if len(payload) == 1:
|
|
1629
|
+
return shell_tokens(payload[0])
|
|
1630
|
+
return payload
|
|
1631
|
+
raise ValueError(SHELL_FILE_REASON.format(executable=executable))
|
|
1632
|
+
return None
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def has_short_option(arguments, option):
|
|
1636
|
+
return any(
|
|
1637
|
+
argument.startswith("-")
|
|
1638
|
+
and not argument.startswith("--")
|
|
1639
|
+
and option in argument[1:]
|
|
1640
|
+
for argument in arguments
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def git_alias(command, git_options):
|
|
1645
|
+
result = subprocess.run(
|
|
1646
|
+
["git", *git_options, "config", "--get", f"alias.{command}"],
|
|
1647
|
+
text=True,
|
|
1648
|
+
capture_output=True,
|
|
1649
|
+
check=False,
|
|
1650
|
+
)
|
|
1651
|
+
if result.returncode == 1:
|
|
1652
|
+
return None
|
|
1653
|
+
if result.returncode != 0:
|
|
1654
|
+
raise ValueError("git alias configuration cannot be validated")
|
|
1655
|
+
alias = result.stdout.strip()
|
|
1656
|
+
if not alias or alias.startswith("!"):
|
|
1657
|
+
raise ValueError("git alias configuration cannot be validated")
|
|
1658
|
+
return alias
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def update_ref_deletes(arguments):
|
|
1662
|
+
operands = []
|
|
1663
|
+
index = 0
|
|
1664
|
+
while index < len(arguments):
|
|
1665
|
+
argument = arguments[index]
|
|
1666
|
+
if argument in {"-m", "--message"}:
|
|
1667
|
+
index += 2
|
|
1668
|
+
continue
|
|
1669
|
+
if argument.startswith("--message=") or argument.startswith("-"):
|
|
1670
|
+
index += 1
|
|
1671
|
+
continue
|
|
1672
|
+
operands.append(argument)
|
|
1673
|
+
index += 1
|
|
1674
|
+
zero_value = len(operands) >= 2 and (
|
|
1675
|
+
operands[1] == "" or not operands[1].strip("0")
|
|
1676
|
+
)
|
|
1677
|
+
return (
|
|
1678
|
+
"-d" in arguments
|
|
1679
|
+
or "--delete" in arguments
|
|
1680
|
+
or any(argument.startswith("--stdin") for argument in arguments)
|
|
1681
|
+
or zero_value
|
|
1682
|
+
)
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def destructive_git_reason(tokens, resolved_aliases=frozenset()):
|
|
1686
|
+
for segment in command_segments(tokens):
|
|
1687
|
+
wrapped = wrapped_command_tokens(segment)
|
|
1688
|
+
if wrapped is not None:
|
|
1689
|
+
reason = destructive_git_reason(wrapped, resolved_aliases)
|
|
1690
|
+
if reason is not None:
|
|
1691
|
+
return reason
|
|
1692
|
+
invocation = git_command(segment)
|
|
1693
|
+
if invocation is None:
|
|
1694
|
+
continue
|
|
1695
|
+
command, arguments, git_options = invocation
|
|
1696
|
+
if command == "config":
|
|
1697
|
+
alias_indexes = [
|
|
1698
|
+
index
|
|
1699
|
+
for index, argument in enumerate(arguments)
|
|
1700
|
+
if argument.casefold().startswith("alias.")
|
|
1701
|
+
]
|
|
1702
|
+
if any(index + 1 < len(arguments) for index in alias_indexes):
|
|
1703
|
+
raise ValueError(
|
|
1704
|
+
"git config alias.* defines an alias the guard cannot inspect; "
|
|
1705
|
+
"run the underlying git command directly"
|
|
1706
|
+
)
|
|
1707
|
+
alias = git_alias(command, git_options)
|
|
1708
|
+
if alias is not None:
|
|
1709
|
+
if command in resolved_aliases:
|
|
1710
|
+
raise ValueError(
|
|
1711
|
+
f"git alias {command} refers to itself; run the underlying "
|
|
1712
|
+
"git command directly"
|
|
1713
|
+
)
|
|
1714
|
+
reason = destructive_git_reason(
|
|
1715
|
+
["git", *git_options, *shell_tokens(alias), *arguments],
|
|
1716
|
+
resolved_aliases | {command},
|
|
1717
|
+
)
|
|
1718
|
+
if reason is not None:
|
|
1719
|
+
return reason
|
|
1720
|
+
continue
|
|
1721
|
+
if command == "reset" and "--hard" in arguments:
|
|
1722
|
+
return GIT_REASONS[command]
|
|
1723
|
+
if command == "clean":
|
|
1724
|
+
force = "--force" in arguments or has_short_option(arguments, "f")
|
|
1725
|
+
interactive = "--interactive" in arguments or has_short_option(
|
|
1726
|
+
arguments, "i"
|
|
1727
|
+
)
|
|
1728
|
+
if force or interactive:
|
|
1729
|
+
return GIT_REASONS[command]
|
|
1730
|
+
if command == "push":
|
|
1731
|
+
force_option = any(
|
|
1732
|
+
argument == "--force"
|
|
1733
|
+
or argument.startswith("--force-with-lease")
|
|
1734
|
+
or argument == "--mirror"
|
|
1735
|
+
for argument in arguments
|
|
1736
|
+
) or has_short_option(arguments, "f")
|
|
1737
|
+
force_refspec = any(
|
|
1738
|
+
argument.startswith("+") and len(argument) > 1
|
|
1739
|
+
for argument in arguments
|
|
1740
|
+
)
|
|
1741
|
+
if force_option or force_refspec:
|
|
1742
|
+
return GIT_REASONS[command]
|
|
1743
|
+
if command == "branch":
|
|
1744
|
+
deletes = (
|
|
1745
|
+
"--delete" in arguments
|
|
1746
|
+
or has_short_option(arguments, "d")
|
|
1747
|
+
or has_short_option(arguments, "D")
|
|
1748
|
+
)
|
|
1749
|
+
forces = (
|
|
1750
|
+
"--force" in arguments
|
|
1751
|
+
or has_short_option(arguments, "f")
|
|
1752
|
+
or has_short_option(arguments, "D")
|
|
1753
|
+
)
|
|
1754
|
+
if deletes and forces:
|
|
1755
|
+
return GIT_REASONS[command]
|
|
1756
|
+
if (
|
|
1757
|
+
"--move" in arguments
|
|
1758
|
+
or has_short_option(arguments, "m")
|
|
1759
|
+
or has_short_option(arguments, "M")
|
|
1760
|
+
):
|
|
1761
|
+
return GIT_REASONS["branch-move"]
|
|
1762
|
+
if command == "update-ref":
|
|
1763
|
+
if update_ref_deletes(arguments):
|
|
1764
|
+
return GIT_REASONS[command]
|
|
1765
|
+
if command == "worktree" and arguments[:1] == ["remove"]:
|
|
1766
|
+
if "--force" in arguments or has_short_option(arguments[1:], "f"):
|
|
1767
|
+
return GIT_REASONS[command]
|
|
1768
|
+
if command == "stash" and arguments[:1] in (["drop"], ["clear"]):
|
|
1769
|
+
return GIT_REASONS[command]
|
|
1770
|
+
if command in {"checkout", "restore"} and any(
|
|
1771
|
+
argument in WHOLE_TREE_PATHSPECS for argument in arguments
|
|
1772
|
+
):
|
|
1773
|
+
touches_worktree = command == "checkout" or (
|
|
1774
|
+
"--worktree" in arguments
|
|
1775
|
+
or has_short_option(arguments, "W")
|
|
1776
|
+
or not ("--staged" in arguments or has_short_option(arguments, "S"))
|
|
1777
|
+
)
|
|
1778
|
+
if touches_worktree:
|
|
1779
|
+
return GIT_REASONS["checkout"]
|
|
1780
|
+
return None
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
PIPELINE_HELPERS = frozenset({"pipeline_state.py", "archive_milestone.py"})
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def closed_milestone_reason(repo=None):
|
|
1787
|
+
repo = repo or repository_root()
|
|
1788
|
+
branch = subprocess.run(
|
|
1789
|
+
["git", "-C", str(repo), "branch", "--show-current"],
|
|
1790
|
+
capture_output=True, text=True,
|
|
1791
|
+
)
|
|
1792
|
+
if not re.fullmatch(r"gsd-path/M\d{3,}", branch.stdout.strip()):
|
|
1793
|
+
return None
|
|
1794
|
+
result = subprocess.run(
|
|
1795
|
+
[sys.executable, "-B", str(Path(__file__).with_name("git_guard.py")),
|
|
1796
|
+
"closed-milestone"], cwd=repo, capture_output=True, text=True,
|
|
1797
|
+
)
|
|
1798
|
+
if result.returncode:
|
|
1799
|
+
return result.stderr.strip() or "closed milestone inspection failed"
|
|
1800
|
+
return None
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPELINE_HELPERS):
|
|
1804
|
+
"""Allow one plain ``python[3] [-B] <script>`` from the guard-owned runtime.
|
|
1805
|
+
|
|
1806
|
+
The interpreter token must be exactly python or python3, and the script
|
|
1807
|
+
operand is the exact parsed token, with expansions and parent traversal refused.
|
|
1808
|
+
The resolved helper must be a regular file inside runtime/ beside this guard,
|
|
1809
|
+
or beside the guard itself in the repository layout. Resolve only in supplied
|
|
1810
|
+
tool working directories, falling back to cwd when none are supplied.
|
|
1811
|
+
"""
|
|
1812
|
+
if any(char in command for char in "\n\r\\"):
|
|
1813
|
+
return False
|
|
1814
|
+
segments = list(command_segments(tokens))
|
|
1815
|
+
if len(segments) != 1 or segments[0] != tokens:
|
|
1816
|
+
return False
|
|
1817
|
+
if any(
|
|
1818
|
+
re.fullmatch(r"[<>&|]*[<>][<>&|]*|[0-9]+(?:[<>]|&>).*", token)
|
|
1819
|
+
for token in tokens
|
|
1820
|
+
):
|
|
1821
|
+
return False
|
|
1822
|
+
_, substitutions = split_command_substitutions(command)
|
|
1823
|
+
if substitutions or wrapped_command_tokens(segments[0]) is not None:
|
|
1824
|
+
return False
|
|
1825
|
+
if tokens[0] not in ("python", "python3"):
|
|
1826
|
+
return False
|
|
1827
|
+
rest = tokens[1:]
|
|
1828
|
+
if rest[:1] == ["-B"]:
|
|
1829
|
+
rest = rest[1:]
|
|
1830
|
+
if not rest:
|
|
1831
|
+
return False
|
|
1832
|
+
operand = rest[0]
|
|
1833
|
+
if (
|
|
1834
|
+
operand.startswith("~")
|
|
1835
|
+
or any(char in operand for char in "$`*?[]{}")
|
|
1836
|
+
or ".." in operand.split("/")
|
|
1837
|
+
):
|
|
1838
|
+
return False
|
|
1839
|
+
here = Path(__file__).resolve().parent
|
|
1840
|
+
runtime = here / "runtime"
|
|
1841
|
+
if not runtime.exists():
|
|
1842
|
+
runtime = here
|
|
1843
|
+
runtime = runtime.resolve()
|
|
1844
|
+
for base in working_directories or [os.getcwd()]:
|
|
1845
|
+
try:
|
|
1846
|
+
script = (Path(base) / operand).resolve()
|
|
1847
|
+
if (
|
|
1848
|
+
script.name in helpers
|
|
1849
|
+
and (script.is_relative_to(runtime) or (
|
|
1850
|
+
script.name == "status_runtime.py" and script == here / "status_runtime.py"
|
|
1851
|
+
))
|
|
1852
|
+
and script.is_file()
|
|
1853
|
+
):
|
|
1854
|
+
return True
|
|
1855
|
+
except (OSError, RuntimeError):
|
|
1856
|
+
continue
|
|
1857
|
+
return False
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
def archive_command_is_read_only(command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS):
|
|
1861
|
+
if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
|
|
1862
|
+
return False
|
|
1863
|
+
if not archive_context:
|
|
1864
|
+
return True
|
|
1865
|
+
executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1].casefold()
|
|
1866
|
+
denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
|
|
1867
|
+
if any(
|
|
1868
|
+
token == option or token.startswith(f"{option}=")
|
|
1869
|
+
for token in tokens[1:]
|
|
1870
|
+
for option in denied_options
|
|
1871
|
+
):
|
|
1872
|
+
return False
|
|
1873
|
+
if executable in ARCHIVE_READ_COMMANDS:
|
|
1874
|
+
return True
|
|
1875
|
+
if executable != "git" or len(tokens) < 2:
|
|
1876
|
+
return False
|
|
1877
|
+
if tokens[1].casefold() not in git_commands:
|
|
1878
|
+
return False
|
|
1879
|
+
return not any(
|
|
1880
|
+
token in GIT_READ_WRITE_OPTIONS or token.startswith("--output=")
|
|
1881
|
+
for token in tokens[2:]
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
def deny(reason):
|
|
1886
|
+
# One denial object per documented host schema: decision/reason (Grok,
|
|
1887
|
+
# Antigravity), permission + user/agentMessage (Cursor), permissionDecision
|
|
1888
|
+
# (Copilot), hookSpecificOutput (Claude Code, Codex, Qwen).
|
|
1889
|
+
print(
|
|
1890
|
+
json.dumps(
|
|
1891
|
+
{
|
|
1892
|
+
"decision": "deny",
|
|
1893
|
+
"reason": reason,
|
|
1894
|
+
"permission": "deny",
|
|
1895
|
+
"userMessage": reason,
|
|
1896
|
+
"agentMessage": reason,
|
|
1897
|
+
"permissionDecision": "deny",
|
|
1898
|
+
"permissionDecisionReason": reason,
|
|
1899
|
+
"hookSpecificOutput": {
|
|
1900
|
+
"hookEventName": "PreToolUse",
|
|
1901
|
+
"permissionDecision": "deny",
|
|
1902
|
+
"permissionDecisionReason": reason,
|
|
1903
|
+
},
|
|
1904
|
+
}
|
|
1905
|
+
)
|
|
1906
|
+
)
|
|
1907
|
+
print(f"gsd-path guard: {reason}", file=sys.stderr)
|
|
1908
|
+
sys.exit(2)
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
def evaluate(event):
|
|
1912
|
+
tool = event.get("tool_name") or event.get("toolName") or event.get("tool")
|
|
1913
|
+
if not isinstance(tool, str) or not tool.strip():
|
|
1914
|
+
raise ValueError("hook event is missing its tool name")
|
|
1915
|
+
paths, working_directories, commands, patch_payloads = [], [], [], []
|
|
1916
|
+
path_keys = PATH_KEYS
|
|
1917
|
+
if is_direct_write_tool(tool):
|
|
1918
|
+
path_keys |= MUTATION_OPERAND_KEYS
|
|
1919
|
+
collect(
|
|
1920
|
+
event,
|
|
1921
|
+
paths,
|
|
1922
|
+
working_directories,
|
|
1923
|
+
commands,
|
|
1924
|
+
patch_payloads,
|
|
1925
|
+
path_keys,
|
|
1926
|
+
)
|
|
1927
|
+
raw_input = event.get("tool_input", event.get("toolInput"))
|
|
1928
|
+
raw_patch = isinstance(raw_input, str) and has_patch_headers(raw_input)
|
|
1929
|
+
if is_patch_tool(tool) or raw_patch:
|
|
1930
|
+
patch_payloads.extend(commands)
|
|
1931
|
+
if isinstance(raw_input, str):
|
|
1932
|
+
patch_payloads.append((raw_input, tuple(working_directories)))
|
|
1933
|
+
commands = []
|
|
1934
|
+
read_tool = is_read_tool(tool)
|
|
1935
|
+
extracted_patch_paths = []
|
|
1936
|
+
if not read_tool:
|
|
1937
|
+
for path, path_working_directories in paths:
|
|
1938
|
+
if path_in_archive(path, path_working_directories):
|
|
1939
|
+
deny(ARCHIVE_REASON)
|
|
1940
|
+
extracted_patch_paths = [
|
|
1941
|
+
(path, patch_working_directories)
|
|
1942
|
+
for payload, patch_working_directories in patch_payloads
|
|
1943
|
+
for path in patch_paths(payload)
|
|
1944
|
+
]
|
|
1945
|
+
if patch_payloads and not paths and not extracted_patch_paths:
|
|
1946
|
+
raise ValueError("patch targets cannot be validated")
|
|
1947
|
+
for path, path_working_directories in extracted_patch_paths:
|
|
1948
|
+
if path_in_archive(path, path_working_directories):
|
|
1949
|
+
deny(ARCHIVE_REASON)
|
|
1950
|
+
if is_direct_write_tool(tool, bool(paths or extracted_patch_paths)):
|
|
1951
|
+
write_paths = [*paths, *extracted_patch_paths]
|
|
1952
|
+
if not write_paths:
|
|
1953
|
+
raise ValueError("write targets cannot be validated")
|
|
1954
|
+
grouped_paths = {}
|
|
1955
|
+
for path, path_working_directories in write_paths:
|
|
1956
|
+
grouped_paths.setdefault(path_working_directories, []).append(path)
|
|
1957
|
+
for path_working_directories, targets in grouped_paths.items():
|
|
1958
|
+
enforce_pipeline_reentry(targets, path_working_directories)
|
|
1959
|
+
archive_working_directory = any(
|
|
1960
|
+
working_directory_in_archive(path) for path in working_directories
|
|
1961
|
+
)
|
|
1962
|
+
if archive_working_directory and not commands and not read_tool:
|
|
1963
|
+
deny(ARCHIVE_REASON)
|
|
1964
|
+
for command, command_working_directories in commands:
|
|
1965
|
+
reason = command_denial(command, command_working_directories)
|
|
1966
|
+
if reason is not None:
|
|
1967
|
+
deny(reason)
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def destructive_shell_invocations(tokens):
|
|
1971
|
+
for segment in command_segments(tokens):
|
|
1972
|
+
invocation = command_invocation(segment)
|
|
1973
|
+
if command_can_destroy_files(invocation):
|
|
1974
|
+
yield invocation
|
|
1975
|
+
wrapped = wrapped_command_tokens(segment, expand_parameters=False)
|
|
1976
|
+
if wrapped is not None:
|
|
1977
|
+
yield from destructive_shell_invocations(wrapped)
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def closed_git_listing(tokens):
|
|
1981
|
+
"""Whether a Git call uses only the allowed object-read or listing forms."""
|
|
1982
|
+
if len(tokens) < 2 or tokens[0] != "git":
|
|
1983
|
+
return False
|
|
1984
|
+
subcommand, arguments = tokens[1], tokens[2:]
|
|
1985
|
+
if subcommand == "worktree":
|
|
1986
|
+
if arguments[:1] != ["list"]:
|
|
1987
|
+
return False
|
|
1988
|
+
subcommand, arguments = "worktree list", arguments[1:]
|
|
1989
|
+
allowed = CLOSED_LISTING_GIT_OPTIONS.get(subcommand)
|
|
1990
|
+
if allowed is None:
|
|
1991
|
+
return False
|
|
1992
|
+
options = [argument for argument in arguments if argument.startswith("-")]
|
|
1993
|
+
positionals = len(arguments) - len(options)
|
|
1994
|
+
if any(option not in allowed for option in options):
|
|
1995
|
+
return False
|
|
1996
|
+
if subcommand == "branch":
|
|
1997
|
+
return not positionals or bool({"-l", "--list"} & set(options))
|
|
1998
|
+
if subcommand in {"cat-file", "symbolic-ref"}:
|
|
1999
|
+
return positionals == 1
|
|
2000
|
+
return not positionals
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def closed_shell_execution_reason(command, tokens, working_directories):
|
|
2004
|
+
helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
|
|
2005
|
+
rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
|
|
2006
|
+
if rest[:1] == ["pending"]:
|
|
2007
|
+
helpers = helpers | {"discussion_records.py"}
|
|
2008
|
+
if bundled_helper_invocation(command, tokens, working_directories, helpers):
|
|
2009
|
+
return None
|
|
2010
|
+
for segment, directories in segment_directories(tokens, working_directories):
|
|
2011
|
+
wrapped = wrapped_command_tokens(segment)
|
|
2012
|
+
if wrapped is not None:
|
|
2013
|
+
reason = closed_shell_execution_reason(shlex.join(wrapped), wrapped, directories)
|
|
2014
|
+
if reason:
|
|
2015
|
+
return reason
|
|
2016
|
+
continue
|
|
2017
|
+
metadata_closed = None
|
|
2018
|
+
git = git_command(segment)
|
|
2019
|
+
checked_segment = segment
|
|
2020
|
+
if git is not None:
|
|
2021
|
+
subcommand, arguments, options = git
|
|
2022
|
+
checked_segment = ["git", subcommand, *arguments]
|
|
2023
|
+
if options:
|
|
2024
|
+
roots = []
|
|
2025
|
+
for directory in directories:
|
|
2026
|
+
result = subprocess.run(
|
|
2027
|
+
["git", *options, "rev-parse", "--show-toplevel", "--absolute-git-dir"],
|
|
2028
|
+
cwd=directory, capture_output=True, text=True,
|
|
2029
|
+
)
|
|
2030
|
+
if result.returncode:
|
|
2031
|
+
raise ValueError("git target repository cannot be resolved")
|
|
2032
|
+
root, metadata = result.stdout.splitlines()
|
|
2033
|
+
roots.append(root)
|
|
2034
|
+
metadata_closed = metadata_closed or closed_milestone_reason(Path(metadata))
|
|
2035
|
+
directories = roots
|
|
2036
|
+
closed = metadata_closed or closed_target_reason([(".", directories)])
|
|
2037
|
+
if not closed:
|
|
2038
|
+
continue
|
|
2039
|
+
invocation = command_invocation(segment)
|
|
2040
|
+
if invocation is None:
|
|
2041
|
+
continue
|
|
2042
|
+
executable, _ = invocation
|
|
2043
|
+
if executable in DIRECTORY_CHANGE_COMMANDS or checked_segment == ["git", "fetch", "origin"]:
|
|
2044
|
+
continue
|
|
2045
|
+
plain = checked_segment[:next((i for i, token in enumerate(checked_segment) if is_redirection(token)), len(checked_segment))]
|
|
2046
|
+
if executable in {"echo", "printf"} or closed_git_listing(plain) or archive_command_is_read_only(
|
|
2047
|
+
shlex.join(plain), plain, True, CLOSED_READ_GIT_COMMANDS
|
|
2048
|
+
):
|
|
2049
|
+
continue
|
|
2050
|
+
return closed
|
|
2051
|
+
return None
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
def command_denial(command, working_directories, allow_destructive=True):
|
|
2055
|
+
"""Return why a shell command is denied, or None when it may run."""
|
|
2056
|
+
working_directories = working_directories or [os.getcwd()]
|
|
2057
|
+
outer, substitutions = split_command_substitutions(command)
|
|
2058
|
+
try:
|
|
2059
|
+
tokens = shell_tokens(outer)
|
|
2060
|
+
if tokens in (
|
|
2061
|
+
[interpreter, "-B", "-c", "import sys; raise SystemExit(sys.version_info < (3, 9))"]
|
|
2062
|
+
for interpreter in ("python3", "python")
|
|
2063
|
+
):
|
|
2064
|
+
return None
|
|
2065
|
+
destructive = list(destructive_shell_invocations(tokens))
|
|
2066
|
+
if destructive and (
|
|
2067
|
+
not allow_destructive
|
|
2068
|
+
or substitutions
|
|
2069
|
+
or any(char in command for char in ";&|()\n\r`")
|
|
2070
|
+
or len(list(command_segments(tokens))) != 1
|
|
2071
|
+
or any(
|
|
2072
|
+
literal_path(operand) is None
|
|
2073
|
+
for _, operands in destructive
|
|
2074
|
+
for operand in operands
|
|
2075
|
+
)
|
|
2076
|
+
):
|
|
2077
|
+
return DESTRUCTIVE_SHAPE_REASON
|
|
2078
|
+
reason = closed_shell_execution_reason(command, tokens, working_directories)
|
|
2079
|
+
if reason:
|
|
2080
|
+
return reason
|
|
2081
|
+
for inner in substitutions:
|
|
2082
|
+
reason = command_denial(inner, working_directories, allow_destructive=False)
|
|
2083
|
+
if reason is not None:
|
|
2084
|
+
return reason
|
|
2085
|
+
archive_context = (
|
|
2086
|
+
any(working_directory_in_archive(path) for path in working_directories)
|
|
2087
|
+
or bool(ARCHIVE_REFERENCE.search(outer))
|
|
2088
|
+
or command_references_archive(tokens, working_directories)
|
|
2089
|
+
or unresolved_archive_expansion(outer, tokens, working_directories)
|
|
2090
|
+
)
|
|
2091
|
+
if not archive_command_is_read_only(command, tokens, archive_context) and not (
|
|
2092
|
+
archive_context and bundled_helper_invocation(command, tokens, working_directories)
|
|
2093
|
+
):
|
|
2094
|
+
return ARCHIVE_REASON
|
|
2095
|
+
reason = destructive_git_reason(tokens) or protected_shell_write_reason(
|
|
2096
|
+
tokens, working_directories
|
|
2097
|
+
)
|
|
2098
|
+
except ValueError as error:
|
|
2099
|
+
raise ValueError(describe_substitutions(str(error), substitutions)) from None
|
|
2100
|
+
return reason and describe_substitutions(reason, substitutions)
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def allow(event):
|
|
2104
|
+
if "cursor_version" in event: # Cursor fails closed on empty stdout; see the module docstring
|
|
2105
|
+
print(json.dumps({"permission": "allow"}))
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
def main():
|
|
2109
|
+
try:
|
|
2110
|
+
event = json.load(sys.stdin)
|
|
2111
|
+
if not isinstance(event, dict):
|
|
2112
|
+
raise ValueError("hook event must be an object")
|
|
2113
|
+
evaluate(event)
|
|
2114
|
+
allow(event)
|
|
2115
|
+
except SystemExit:
|
|
2116
|
+
raise
|
|
2117
|
+
except ValueError as error:
|
|
2118
|
+
deny(f"{INVALID_INPUT_REASON}: {error}")
|
|
2119
|
+
except Exception:
|
|
2120
|
+
deny(INVALID_INPUT_REASON)
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
if __name__ == "__main__":
|
|
2124
|
+
main()
|