@opengsd/gsd-path 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/DOCS.md +27 -1
- package/FULL.md +2 -1
- package/HOOKS.md +38 -4
- package/QUICK.md +1 -1
- package/README.md +20 -29
- package/RUNTIME.md +5 -3
- package/WORKFLOW.md +9 -2
- package/package.json +1 -1
- package/scripts/_common.py +76 -2
- package/scripts/archive_milestone.py +129 -7
- package/scripts/build_recovery.py +25 -2
- package/scripts/build_state.py +333 -8
- package/scripts/check_handoffs.py +437 -30
- package/scripts/check_task_briefs.py +135 -11
- package/scripts/discussion_validate.py +46 -10
- package/scripts/dispatch_driver.py +28 -3
- package/scripts/git_guard.py +44 -4
- package/scripts/guard_hook.py +456 -9
- package/scripts/install.py +34 -4
- package/scripts/integration.py +196 -39
- package/scripts/isolation.py +1015 -37
- package/scripts/lean_verification.py +33 -14
- package/scripts/loop_run.py +9 -4
- package/scripts/members.py +17 -3
- package/scripts/pipeline_diagnose.py +104 -1
- package/scripts/pipeline_state.py +10 -2
- package/scripts/runtime_store.py +88 -3
- package/scripts/state_checkpoint.py +15 -0
- package/scripts/status_runtime.py +73 -2
- package/scripts/workflow_run.py +10 -3
- package/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/BUILD.md +5 -1
- package/skills/gsd-path/DEFINE.md +2 -1
- package/skills/gsd-path/PLAN.md +10 -0
- package/skills/gsd-path/SHIP.md +43 -9
- package/skills/gsd-path/SKILL.md +16 -1
- package/skills/gsd-path/references/build-native.md +20 -3
- package/skills/gsd-path/references/coder.md +6 -3
- package/skills/gsd-path/references/reviewer-final.md +10 -0
- package/skills/gsd-path/references/reviewer-wave.md +61 -22
- package/skills/gsd-path/references/reviewer.md +8 -6
- package/skills/gsd-path/references/runtime-selection.md +36 -0
- package/skills/gsd-path/references/spec-probes.md +19 -1
- package/skills/gsd-path/scripts/_common.py +76 -2
- package/skills/gsd-path/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path/scripts/build_recovery.py +25 -2
- package/skills/gsd-path/scripts/build_state.py +333 -8
- package/skills/gsd-path/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path/scripts/dispatch_driver.py +28 -3
- package/skills/gsd-path/scripts/git_guard.py +44 -4
- package/skills/gsd-path/scripts/integration.py +196 -39
- package/skills/gsd-path/scripts/isolation.py +1015 -37
- package/skills/gsd-path/scripts/lean_verification.py +33 -14
- package/skills/gsd-path/scripts/loop_run.py +9 -4
- package/skills/gsd-path/scripts/members.py +17 -3
- package/skills/gsd-path/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path/scripts/status_runtime.py +73 -2
- package/skills/gsd-path/scripts/workflow_run.py +10 -3
- package/skills/gsd-path/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/templates/intent.md +5 -3
- package/skills/gsd-path/templates/task.md +8 -1
- package/skills/gsd-path/templates/wave-review.md +24 -8
- package/skills/gsd-path-build/SKILL.md +5 -1
- package/skills/gsd-path-build/references/build-native.md +20 -3
- package/skills/gsd-path-build/references/coder.md +6 -3
- package/skills/gsd-path-build/references/reviewer-final.md +10 -0
- package/skills/gsd-path-build/references/reviewer-wave.md +61 -22
- package/skills/gsd-path-build/references/reviewer.md +8 -6
- package/skills/gsd-path-build/references/runtime-selection.md +36 -0
- package/skills/gsd-path-build/references/spec-probes.md +19 -1
- package/skills/gsd-path-build/scripts/_common.py +76 -2
- package/skills/gsd-path-build/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-build/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-build/scripts/build_state.py +333 -8
- package/skills/gsd-path-build/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-build/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-build/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-build/scripts/dispatch_driver.py +28 -3
- package/skills/gsd-path-build/scripts/integration.py +196 -39
- package/skills/gsd-path-build/scripts/isolation.py +1015 -37
- package/skills/gsd-path-build/scripts/loop_run.py +9 -4
- package/skills/gsd-path-build/scripts/members.py +17 -3
- package/skills/gsd-path-build/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-build/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-build/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-build/templates/task.md +8 -1
- package/skills/gsd-path-build/templates/wave-review.md +24 -8
- package/skills/gsd-path-decide/references/runtime-selection.md +36 -0
- package/skills/gsd-path-decide/scripts/_common.py +76 -2
- package/skills/gsd-path-decide/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-decide/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-decide/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-decide/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-decide/scripts/integration.py +196 -39
- package/skills/gsd-path-decide/scripts/isolation.py +1015 -37
- package/skills/gsd-path-decide/scripts/loop_run.py +9 -4
- package/skills/gsd-path-decide/scripts/members.py +17 -3
- package/skills/gsd-path-decide/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-decide/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/SKILL.md +2 -1
- package/skills/gsd-path-define/references/runtime-selection.md +36 -0
- package/skills/gsd-path-define/references/spec-probes.md +19 -1
- package/skills/gsd-path-define/scripts/_common.py +76 -2
- package/skills/gsd-path-define/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-define/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-define/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-define/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-define/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-define/scripts/integration.py +196 -39
- package/skills/gsd-path-define/scripts/isolation.py +1015 -37
- package/skills/gsd-path-define/scripts/loop_run.py +9 -4
- package/skills/gsd-path-define/scripts/members.py +17 -3
- package/skills/gsd-path-define/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-define/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-define/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/templates/intent.md +5 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +36 -0
- package/skills/gsd-path-discuss/scripts/_common.py +76 -2
- package/skills/gsd-path-discuss/scripts/loop_run.py +9 -4
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +36 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +76 -2
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-docs-audit/scripts/integration.py +196 -39
- package/skills/gsd-path-docs-audit/scripts/isolation.py +1015 -37
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +9 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +17 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-forensics/references/runtime-selection.md +36 -0
- package/skills/gsd-path-forensics/scripts/_common.py +76 -2
- package/skills/gsd-path-forensics/scripts/loop_run.py +9 -4
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +104 -1
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-inspect/references/runtime-selection.md +36 -0
- package/skills/gsd-path-inspect/scripts/_common.py +76 -2
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-inspect/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-inspect/scripts/integration.py +196 -39
- package/skills/gsd-path-inspect/scripts/isolation.py +1015 -37
- package/skills/gsd-path-inspect/scripts/loop_run.py +9 -4
- package/skills/gsd-path-inspect/scripts/members.py +17 -3
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-inspect/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-loop/references/runtime-selection.md +36 -0
- package/skills/gsd-path-loop/scripts/_common.py +76 -2
- package/skills/gsd-path-loop/scripts/loop_run.py +9 -4
- package/skills/gsd-path-loop/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-migrate/references/runtime-selection.md +36 -0
- package/skills/gsd-path-migrate/scripts/_common.py +76 -2
- package/skills/gsd-path-migrate/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/SKILL.md +10 -0
- package/skills/gsd-path-plan/references/runtime-selection.md +36 -0
- package/skills/gsd-path-plan/references/spec-probes.md +19 -1
- package/skills/gsd-path-plan/scripts/_common.py +76 -2
- package/skills/gsd-path-plan/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-plan/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-plan/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-plan/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-plan/scripts/integration.py +196 -39
- package/skills/gsd-path-plan/scripts/isolation.py +1015 -37
- package/skills/gsd-path-plan/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/scripts/members.py +17 -3
- package/skills/gsd-path-plan/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-plan/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-plan/templates/task.md +8 -1
- package/skills/gsd-path-research/references/runtime-selection.md +36 -0
- package/skills/gsd-path-research/scripts/_common.py +76 -2
- package/skills/gsd-path-research/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-research/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-research/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-research/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-research/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-research/scripts/integration.py +196 -39
- package/skills/gsd-path-research/scripts/isolation.py +1015 -37
- package/skills/gsd-path-research/scripts/loop_run.py +9 -4
- package/skills/gsd-path-research/scripts/members.py +17 -3
- package/skills/gsd-path-research/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-research/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-research/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-roadmap/references/runtime-selection.md +36 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +76 -2
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-roadmap/scripts/integration.py +196 -39
- package/skills/gsd-path-roadmap/scripts/isolation.py +1015 -37
- package/skills/gsd-path-roadmap/scripts/loop_run.py +9 -4
- package/skills/gsd-path-roadmap/scripts/members.py +17 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/SKILL.md +43 -9
- package/skills/gsd-path-ship/references/reviewer-final.md +10 -0
- package/skills/gsd-path-ship/references/reviewer-wave.md +61 -22
- package/skills/gsd-path-ship/references/reviewer.md +8 -6
- package/skills/gsd-path-ship/references/runtime-selection.md +36 -0
- package/skills/gsd-path-ship/references/spec-probes.md +19 -1
- package/skills/gsd-path-ship/scripts/_common.py +76 -2
- package/skills/gsd-path-ship/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-ship/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-ship/scripts/build_state.py +333 -8
- package/skills/gsd-path-ship/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-ship/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-ship/scripts/integration.py +196 -39
- package/skills/gsd-path-ship/scripts/isolation.py +1015 -37
- package/skills/gsd-path-ship/scripts/lean_verification.py +33 -14
- package/skills/gsd-path-ship/scripts/loop_run.py +9 -4
- package/skills/gsd-path-ship/scripts/members.py +17 -3
- package/skills/gsd-path-ship/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-ship/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/templates/wave-review.md +24 -8
- package/skills/gsd-path-undo/references/runtime-selection.md +36 -0
- package/skills/gsd-path-undo/scripts/_common.py +76 -2
- package/skills/gsd-path-undo/scripts/loop_run.py +9 -4
- package/skills/gsd-path-undo/scripts/worktree_paths.py +45 -0
- package/skills/path/BUILD.md +5 -1
- package/skills/path/DEFINE.md +2 -1
- package/skills/path/PLAN.md +10 -0
- package/skills/path/SHIP.md +43 -9
- package/skills/path/SKILL.md +16 -1
- package/skills/path/references/build-native.md +20 -3
- package/skills/path/references/coder.md +6 -3
- package/skills/path/references/reviewer-final.md +10 -0
- package/skills/path/references/reviewer-wave.md +61 -22
- package/skills/path/references/reviewer.md +8 -6
- package/skills/path/references/runtime-selection.md +36 -0
- package/skills/path/references/spec-probes.md +19 -1
- package/skills/path/scripts/_common.py +76 -2
- package/skills/path/scripts/archive_milestone.py +129 -7
- package/skills/path/scripts/build_recovery.py +25 -2
- package/skills/path/scripts/build_state.py +333 -8
- package/skills/path/scripts/check_handoffs.py +437 -30
- package/skills/path/scripts/check_task_briefs.py +135 -11
- package/skills/path/scripts/discussion_validate.py +46 -10
- package/skills/path/scripts/dispatch_driver.py +28 -3
- package/skills/path/scripts/git_guard.py +44 -4
- package/skills/path/scripts/integration.py +196 -39
- package/skills/path/scripts/isolation.py +1015 -37
- package/skills/path/scripts/lean_verification.py +33 -14
- package/skills/path/scripts/loop_run.py +9 -4
- package/skills/path/scripts/members.py +17 -3
- package/skills/path/scripts/pipeline_state.py +10 -2
- package/skills/path/scripts/state_checkpoint.py +15 -0
- package/skills/path/scripts/status_runtime.py +73 -2
- package/skills/path/scripts/workflow_run.py +10 -3
- package/skills/path/scripts/worktree_paths.py +45 -0
- package/skills/path/templates/intent.md +5 -3
- package/skills/path/templates/task.md +8 -1
- package/skills/path/templates/wave-review.md +24 -8
package/scripts/guard_hook.py
CHANGED
|
@@ -424,6 +424,10 @@ GIT_REASONS = {
|
|
|
424
424
|
"(STATE.branch); create a new branch instead"
|
|
425
425
|
),
|
|
426
426
|
"update-ref": "git update-ref deletion destroys refs the recovery protocol inspects",
|
|
427
|
+
"owned-refspec": (
|
|
428
|
+
"git commands naming refs/gsd-path/ or runtime-hotfix are denied: that "
|
|
429
|
+
"namespace holds orchestrator-owned authorizations an agent must not create or move"
|
|
430
|
+
),
|
|
427
431
|
"worktree": (
|
|
428
432
|
"git worktree remove --force discards uncommitted task work the recovery "
|
|
429
433
|
"protocol needs; commit or stash it, then remove without --force"
|
|
@@ -1431,6 +1435,133 @@ def command_can_destroy_files(invocation):
|
|
|
1431
1435
|
)
|
|
1432
1436
|
|
|
1433
1437
|
|
|
1438
|
+
def _deletion_overlaps(candidate, root):
|
|
1439
|
+
"""Whether a recursive delete of `candidate` could remove `root`.
|
|
1440
|
+
|
|
1441
|
+
A candidate inside the root deletes its contents, and a candidate that
|
|
1442
|
+
CONTAINS the root deletes it wholesale (`rm -r` of a parent directory), so
|
|
1443
|
+
both directions deny. Existing-path identity covers case variants that a
|
|
1444
|
+
lexical comparison misses on case-insensitive filesystems.
|
|
1445
|
+
"""
|
|
1446
|
+
try:
|
|
1447
|
+
candidate.relative_to(root)
|
|
1448
|
+
return True
|
|
1449
|
+
except ValueError:
|
|
1450
|
+
pass
|
|
1451
|
+
try:
|
|
1452
|
+
root.relative_to(candidate)
|
|
1453
|
+
return True
|
|
1454
|
+
except ValueError:
|
|
1455
|
+
pass
|
|
1456
|
+
return _within_existing_root(candidate, root) or _within_existing_root(root, candidate)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _strictly_inside(candidate, root):
|
|
1460
|
+
if candidate == root:
|
|
1461
|
+
return False
|
|
1462
|
+
try:
|
|
1463
|
+
candidate.relative_to(root)
|
|
1464
|
+
return True
|
|
1465
|
+
except ValueError:
|
|
1466
|
+
return any(_same_existing_path(ancestor, root) for ancestor in candidate.parents)
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def destructive_state_root_reason(operands, working_directories):
|
|
1470
|
+
"""Why a destructive operand is pipeline state, or None when outside it.
|
|
1471
|
+
|
|
1472
|
+
Delete restrictions cover the pipeline's state directories, not only the
|
|
1473
|
+
repository (#468): the guard's home tree, the configured worktree root,
|
|
1474
|
+
and every pinned workspace root sit outside any repository, so the path
|
|
1475
|
+
kinds alone would call them external. Unresolvable workspace state denies
|
|
1476
|
+
rather than guesses.
|
|
1477
|
+
"""
|
|
1478
|
+
directories = working_directories or [os.getcwd()]
|
|
1479
|
+
commons = set()
|
|
1480
|
+
checkouts = set()
|
|
1481
|
+
for directory in directories:
|
|
1482
|
+
result = subprocess.run(
|
|
1483
|
+
["git", "-C", str(directory), "rev-parse", "--show-toplevel", "--git-common-dir"],
|
|
1484
|
+
capture_output=True, encoding="utf-8", errors="replace", check=False,
|
|
1485
|
+
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
|
1486
|
+
)
|
|
1487
|
+
lines = result.stdout.splitlines()
|
|
1488
|
+
if result.returncode == 0 and len(lines) == 2:
|
|
1489
|
+
checkouts.add(Path(lines[0]).resolve(strict=False))
|
|
1490
|
+
common = Path(lines[1])
|
|
1491
|
+
if not common.is_absolute():
|
|
1492
|
+
common = Path(directory) / common
|
|
1493
|
+
commons.add(common.resolve(strict=False))
|
|
1494
|
+
try:
|
|
1495
|
+
roots = _workspace_roots_for_commons(commons)
|
|
1496
|
+
except ValueError as error:
|
|
1497
|
+
return f"pipeline workspace state cannot be resolved: {error}"
|
|
1498
|
+
for operand in operands:
|
|
1499
|
+
for candidate in target_paths(operand, directories, Path(os.getcwd())):
|
|
1500
|
+
for root in roots:
|
|
1501
|
+
# A managed worktree under a state root is the agent's own
|
|
1502
|
+
# checkout; deletes inside it are ordinary product work.
|
|
1503
|
+
if _deletion_overlaps(candidate, root) and not any(
|
|
1504
|
+
_strictly_inside(candidate, checkout) and _strictly_inside(checkout, root)
|
|
1505
|
+
for checkout in checkouts
|
|
1506
|
+
):
|
|
1507
|
+
return f"destructive target is pipeline state: {operand}"
|
|
1508
|
+
return None
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _closed_repo_root(working_directories):
|
|
1512
|
+
"""The closed-milestone repository for these working directories, if any."""
|
|
1513
|
+
for directory in working_directories or [os.getcwd()]:
|
|
1514
|
+
root = subprocess.run(
|
|
1515
|
+
["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
|
|
1516
|
+
capture_output=True, encoding="utf-8", errors="replace", check=False,
|
|
1517
|
+
)
|
|
1518
|
+
if root.returncode != 0:
|
|
1519
|
+
continue
|
|
1520
|
+
repo = Path(root.stdout.strip())
|
|
1521
|
+
if closed_milestone_reason(repo):
|
|
1522
|
+
return repo
|
|
1523
|
+
return None
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def closed_destructive_scope_reason(arguments, working_directories):
|
|
1527
|
+
"""Why a destructive command stays inside pipeline scope, or None outside it.
|
|
1528
|
+
|
|
1529
|
+
A closed milestone accepts no new work, but a scratch file entirely outside
|
|
1530
|
+
the repository and every state root is no work (#468). The allowance needs
|
|
1531
|
+
every operand provably external to the closed repository — the path kinds
|
|
1532
|
+
check the lexical path, the symlink-resolved path, control aliases, and
|
|
1533
|
+
member checkouts — and the state-root check covers the directories that
|
|
1534
|
+
live outside the repository.
|
|
1535
|
+
"""
|
|
1536
|
+
end = arguments.index("--") if "--" in arguments else len(arguments)
|
|
1537
|
+
operands = [
|
|
1538
|
+
argument for argument in arguments[:end] if argument and not argument.startswith("-")
|
|
1539
|
+
] + [argument for argument in arguments[end + 1:] if argument]
|
|
1540
|
+
if not operands:
|
|
1541
|
+
return "destructive command names no target"
|
|
1542
|
+
repo = _closed_repo_root(working_directories)
|
|
1543
|
+
if repo is None:
|
|
1544
|
+
return "pipeline scope cannot be classified"
|
|
1545
|
+
control_roots = repository_control_roots(repo)
|
|
1546
|
+
try:
|
|
1547
|
+
import members # the guard's runtime directory
|
|
1548
|
+
member_roots = [Path(member["checkout"]) for member in members.read_members(repo)]
|
|
1549
|
+
except Exception as error:
|
|
1550
|
+
return f"member scope cannot be resolved: {error}"
|
|
1551
|
+
for operand in operands:
|
|
1552
|
+
if target_kind(operand, working_directories, repo, control_roots) != "external":
|
|
1553
|
+
return f"destructive target stays inside the repository: {operand}"
|
|
1554
|
+
# A recursive delete of an ancestor removes the repository (and any
|
|
1555
|
+
# member checkout) even though the operand itself classifies external.
|
|
1556
|
+
for candidate in target_paths(operand, working_directories, repo):
|
|
1557
|
+
if _deletion_overlaps(candidate, repo.resolve()):
|
|
1558
|
+
return f"destructive target contains the repository: {operand}"
|
|
1559
|
+
for member_root in member_roots:
|
|
1560
|
+
if _deletion_overlaps(candidate, member_root.resolve(strict=False)):
|
|
1561
|
+
return f"destructive target contains a member checkout: {operand}"
|
|
1562
|
+
return destructive_state_root_reason(operands, working_directories)
|
|
1563
|
+
|
|
1564
|
+
|
|
1434
1565
|
def literal_path(operand):
|
|
1435
1566
|
if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
|
|
1436
1567
|
operand = operand[1:-1]
|
|
@@ -2687,6 +2818,114 @@ def unresolved_runner_word(token):
|
|
|
2687
2818
|
) and not parameters_double_quoted(token)
|
|
2688
2819
|
|
|
2689
2820
|
|
|
2821
|
+
POWERSHELL_ASSIGNMENT_TARGET = re.compile(r"^\$(?:env:)?[A-Za-z_][A-Za-z0-9_]*$")
|
|
2822
|
+
POWERSHELL_LITERAL_VALUE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.:/\\ +,-]*")
|
|
2823
|
+
POWERSHELL_STATIC_CALL = re.compile(r"\[(?P<type>[^\]()]+)\]::")
|
|
2824
|
+
POWERSHELL_PURE_TYPES = frozenset(
|
|
2825
|
+
{
|
|
2826
|
+
"math", "string", "int", "int32", "long", "int64", "double", "bool",
|
|
2827
|
+
"byte", "char", "decimal", "single", "guid", "datetime", "timespan",
|
|
2828
|
+
"version",
|
|
2829
|
+
"system.math", "system.string", "system.int32", "system.int64",
|
|
2830
|
+
"system.double", "system.boolean", "system.guid", "system.datetime",
|
|
2831
|
+
"system.timespan", "system.version",
|
|
2832
|
+
}
|
|
2833
|
+
)
|
|
2834
|
+
POWERSHELL_DYNAMIC_COMMANDS = frozenset(
|
|
2835
|
+
{
|
|
2836
|
+
"invoke-expression", "iex", "invoke-command", "icm", "add-type",
|
|
2837
|
+
"start-process", "saps", "sap", "start-job", "new-object",
|
|
2838
|
+
}
|
|
2839
|
+
)
|
|
2840
|
+
|
|
2841
|
+
|
|
2842
|
+
def powershell_assignment_invocation(segment, unwrap_runners=True):
|
|
2843
|
+
"""The invocation a validated PowerShell assignment runs, or None.
|
|
2844
|
+
|
|
2845
|
+
``$name = literal`` and ``$env:NAME = literal`` set a process-local
|
|
2846
|
+
variable or environment entry. PowerShell runs a bare-word value as a
|
|
2847
|
+
pipeline, so the value is judged as the command it would run. The value
|
|
2848
|
+
must be plain literals; anything richer — expansions, substitutions,
|
|
2849
|
+
member access — keeps the unresolvable denial rather than guessing at
|
|
2850
|
+
expression semantics. An environment name that changes where Git reads
|
|
2851
|
+
its configuration is refused exactly like the shell form (#468).
|
|
2852
|
+
"""
|
|
2853
|
+
if len(segment) < 3 or segment[1] != "=":
|
|
2854
|
+
return None
|
|
2855
|
+
if POWERSHELL_ASSIGNMENT_TARGET.fullmatch(segment[0]) is None:
|
|
2856
|
+
return None
|
|
2857
|
+
name = segment[0].removeprefix("$")
|
|
2858
|
+
folded = name.casefold()
|
|
2859
|
+
if ":" in name:
|
|
2860
|
+
variable, _, scope = name.partition(":")
|
|
2861
|
+
if variable != "env":
|
|
2862
|
+
return None
|
|
2863
|
+
if scope.casefold() in {"home", "xdg_config_home"} or scope.casefold().startswith("git_"):
|
|
2864
|
+
raise ValueError(
|
|
2865
|
+
f"{name} changes where Git reads its configuration, which the guard "
|
|
2866
|
+
"cannot validate; run git without it"
|
|
2867
|
+
)
|
|
2868
|
+
elif folded in {"home", "xdg_config_home"} or folded.startswith("git_"):
|
|
2869
|
+
return None # shell-style names keep their shell semantics, not PowerShell's
|
|
2870
|
+
if any(POWERSHELL_LITERAL_VALUE.fullmatch(token) is None for token in segment[2:]):
|
|
2871
|
+
return None
|
|
2872
|
+
# A value naming a write or dynamic command runs it; deny without resolving paths.
|
|
2873
|
+
command_names = SHELL_WRITE_COMMANDS | POWERSHELL_DYNAMIC_COMMANDS
|
|
2874
|
+
if any(token.casefold().removesuffix(".exe") in command_names for token in segment[2:]):
|
|
2875
|
+
return None
|
|
2876
|
+
return command_invocation(segment[2:], unwrap_runners)
|
|
2877
|
+
|
|
2878
|
+
|
|
2879
|
+
def powershell_expression_reason(tokens, depth=0):
|
|
2880
|
+
"""Why a PowerShell expression form stays denied, or None when safe.
|
|
2881
|
+
|
|
2882
|
+
PowerShell expression statements are not executables: assignments are
|
|
2883
|
+
process-local, while .NET static member calls run arbitrary code
|
|
2884
|
+
(``[System.IO.File]::WriteAllText`` writes a file the write-target
|
|
2885
|
+
classification never sees). The guard allows the provably pure type
|
|
2886
|
+
members and denies every other static call — wherever the token appears,
|
|
2887
|
+
including inside a script block or a wrapped ``-Command`` payload — plus
|
|
2888
|
+
every dynamic-execution command (aliases included) inside a block or as
|
|
2889
|
+
the executable, and the call operator (#468).
|
|
2890
|
+
"""
|
|
2891
|
+
previous = None
|
|
2892
|
+
for token in tokens:
|
|
2893
|
+
if token == "&" and (
|
|
2894
|
+
previous is None
|
|
2895
|
+
or previous.endswith("{")
|
|
2896
|
+
or set(previous) <= set("|;&()\n\r")
|
|
2897
|
+
):
|
|
2898
|
+
return "the PowerShell call operator target cannot be resolved"
|
|
2899
|
+
previous = token
|
|
2900
|
+
for segment in command_segments(tokens):
|
|
2901
|
+
wrapped = wrapped_command_tokens(segment, expand_parameters=False)
|
|
2902
|
+
if wrapped is not None:
|
|
2903
|
+
if depth >= 4:
|
|
2904
|
+
return "nested command payloads exceed the guard's inspection depth"
|
|
2905
|
+
reason = powershell_expression_reason(wrapped, depth + 1)
|
|
2906
|
+
if reason:
|
|
2907
|
+
return reason
|
|
2908
|
+
continue
|
|
2909
|
+
invocation = command_invocation(list(segment))
|
|
2910
|
+
if invocation is None:
|
|
2911
|
+
continue
|
|
2912
|
+
executable = invocation[0]
|
|
2913
|
+
if executable in POWERSHELL_DYNAMIC_COMMANDS:
|
|
2914
|
+
return f"dynamic execution cannot be inspected by the guard: {executable}"
|
|
2915
|
+
block_depth = 0
|
|
2916
|
+
for token in segment:
|
|
2917
|
+
match = POWERSHELL_STATIC_CALL.search(token)
|
|
2918
|
+
if match is not None and match.group("type").strip().casefold() not in POWERSHELL_PURE_TYPES:
|
|
2919
|
+
return (f"static member call is not on the guard's read-only type "
|
|
2920
|
+
f"whitelist: {token}")
|
|
2921
|
+
word = token.lstrip("{")
|
|
2922
|
+
block_depth += len(token) - len(word)
|
|
2923
|
+
if block_depth and word.rstrip("}").casefold() in POWERSHELL_DYNAMIC_COMMANDS:
|
|
2924
|
+
return f"dynamic execution cannot be inspected by the guard: {token}"
|
|
2925
|
+
block_depth = max(0, block_depth - (len(word) - len(word.rstrip("}"))))
|
|
2926
|
+
return None
|
|
2927
|
+
|
|
2928
|
+
|
|
2690
2929
|
def command_invocation(segment, unwrap_runners=True):
|
|
2691
2930
|
"""`unwrap_runners` is False where a command runner itself must be judged."""
|
|
2692
2931
|
index = 0
|
|
@@ -2781,6 +3020,9 @@ def command_invocation(segment, unwrap_runners=True):
|
|
|
2781
3020
|
break
|
|
2782
3021
|
arguments = segment[index + 1 :]
|
|
2783
3022
|
if SHELL_PARAMETER_SYNTAX.search(executable):
|
|
3023
|
+
assignment = powershell_assignment_invocation(list(segment[index:]), unwrap_runners)
|
|
3024
|
+
if assignment is not None:
|
|
3025
|
+
return assignment
|
|
2784
3026
|
raise ValueError(
|
|
2785
3027
|
f"executable {segment[index]} cannot be resolved by the guard; "
|
|
2786
3028
|
"name the program literally"
|
|
@@ -2918,6 +3160,16 @@ def git_command(segment, assignments=None):
|
|
|
2918
3160
|
if option != "-C":
|
|
2919
3161
|
require_resolved_git_word(value)
|
|
2920
3162
|
index += 1
|
|
3163
|
+
if option not in {"-C", "-c", "--config-env"} and git_option_in(
|
|
3164
|
+
option, GIT_PARAMETER_DENIED_OPTIONS
|
|
3165
|
+
):
|
|
3166
|
+
# A denied global option executes a program git runs
|
|
3167
|
+
# (--upload-pack/--exec/...); it must never slip past as a
|
|
3168
|
+
# pre-subcommand token the subcommand checks never see (#468).
|
|
3169
|
+
# -c/--config-env keep their dedicated config-key checks below.
|
|
3170
|
+
raise ValueError(
|
|
3171
|
+
f"git option {option} cannot be verified by the guard"
|
|
3172
|
+
)
|
|
2921
3173
|
if option in {"-c", "--config-env"}:
|
|
2922
3174
|
config_key, _, config_value = str(value).partition("=")
|
|
2923
3175
|
config_key = config_key.casefold()
|
|
@@ -3589,6 +3841,10 @@ def _update_ref_names(target, refs, context, no_deref):
|
|
|
3589
3841
|
|
|
3590
3842
|
|
|
3591
3843
|
def _workspace_roots(contexts):
|
|
3844
|
+
return _workspace_roots_for_commons({context["common"] for context in contexts})
|
|
3845
|
+
|
|
3846
|
+
|
|
3847
|
+
def _workspace_roots_for_commons(commons):
|
|
3592
3848
|
roots = [(Path.home() / ".gsd-path").resolve()]
|
|
3593
3849
|
configured = os.environ.get("GSD_PATH_WORKTREE_ROOT")
|
|
3594
3850
|
if configured:
|
|
@@ -3596,7 +3852,7 @@ def _workspace_roots(contexts):
|
|
|
3596
3852
|
if not configured_root.is_absolute():
|
|
3597
3853
|
raise ValueError("GSD_PATH_WORKTREE_ROOT must be absolute")
|
|
3598
3854
|
roots.append(configured_root.resolve())
|
|
3599
|
-
for common in
|
|
3855
|
+
for common in commons:
|
|
3600
3856
|
receipts = common / "gsd-path" / "workspaces"
|
|
3601
3857
|
if not os.path.lexists(receipts):
|
|
3602
3858
|
continue
|
|
@@ -4089,6 +4345,12 @@ def destructive_git_reason(
|
|
|
4089
4345
|
if invocation is None:
|
|
4090
4346
|
continue
|
|
4091
4347
|
command, arguments, git_options = invocation
|
|
4348
|
+
if any(
|
|
4349
|
+
protected in word.casefold()
|
|
4350
|
+
for word in words
|
|
4351
|
+
for protected in ("refs/gsd-path/", "runtime-hotfix")
|
|
4352
|
+
):
|
|
4353
|
+
return GIT_REASONS["owned-refspec"]
|
|
4092
4354
|
# Git matches its own commands by exact case, but alias names in any case.
|
|
4093
4355
|
built_in = (
|
|
4094
4356
|
command_invocation(words)[1][-len(arguments) - 1] in GIT_BUILT_IN_COMMANDS
|
|
@@ -4307,20 +4569,48 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
|
|
|
4307
4569
|
runtime = here / "runtime"
|
|
4308
4570
|
if (here / "runtime.json").exists():
|
|
4309
4571
|
try:
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4572
|
+
# Option (i) on #468: acceptance is the pinned runtime plus the
|
|
4573
|
+
# validated successors its own hashed manifest names. The walk
|
|
4574
|
+
# comes from the PINNED runtime's own bytes — never the project
|
|
4575
|
+
# launcher's preloaded module, which may be stale or divergent —
|
|
4576
|
+
# and exactly the bytes whose sha256 the pinned manifest names:
|
|
4577
|
+
# read once, hash against the digest-proven manifest, execute
|
|
4578
|
+
# the verified buffer, so no swap between check and exec can
|
|
4579
|
+
# run unproven code.
|
|
4580
|
+
import hashlib
|
|
4581
|
+
import types
|
|
4582
|
+
|
|
4583
|
+
from status_runtime import (
|
|
4584
|
+
declaration, manifest_digest, runtime_home, validate_runtime,
|
|
4585
|
+
)
|
|
4586
|
+
pin = declaration(here.parent)
|
|
4587
|
+
validate_runtime(pin)
|
|
4588
|
+
root = runtime_home() / pin["digest"]
|
|
4589
|
+
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
|
4590
|
+
if (not isinstance(manifest, dict)
|
|
4591
|
+
or manifest_digest(manifest) != pin["digest"]):
|
|
4592
|
+
return False
|
|
4593
|
+
expected = manifest.get("files", {}).get("status_runtime.py")
|
|
4594
|
+
data = (root / "status_runtime.py").read_bytes()
|
|
4595
|
+
if (not isinstance(expected, str)
|
|
4596
|
+
or hashlib.sha256(data).hexdigest() != expected):
|
|
4597
|
+
return False
|
|
4598
|
+
module = types.ModuleType("gsd_path_pinned_status_runtime")
|
|
4599
|
+
exec(compile(data, str(root / "status_runtime.py"), "exec"), module.__dict__)
|
|
4600
|
+
runtimes = module.accepted_runtime_dirs(here.parent)
|
|
4601
|
+
except (OSError, ValueError, TypeError, ImportError, AttributeError):
|
|
4313
4602
|
return False
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4603
|
+
else:
|
|
4604
|
+
if not runtime.exists():
|
|
4605
|
+
runtime = here
|
|
4606
|
+
runtimes = {runtime.resolve()}
|
|
4317
4607
|
for base in working_directories or [os.getcwd()]:
|
|
4318
4608
|
try:
|
|
4319
4609
|
# A Git Bash /c/... operand names the C: drive, not a folder named c.
|
|
4320
4610
|
script = (Path(base) / native_path_text(operand)).resolve()
|
|
4321
4611
|
if (
|
|
4322
4612
|
script.name in helpers
|
|
4323
|
-
and (script.is_relative_to(
|
|
4613
|
+
and (any(script.is_relative_to(runtime_dir) for runtime_dir in runtimes) or (
|
|
4324
4614
|
script.name == "status_runtime.py" and script == here / "status_runtime.py"
|
|
4325
4615
|
))
|
|
4326
4616
|
and script.is_file()
|
|
@@ -4522,6 +4812,106 @@ def closed_git_listing(tokens):
|
|
|
4522
4812
|
return not positionals
|
|
4523
4813
|
|
|
4524
4814
|
|
|
4815
|
+
REMOTE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
4816
|
+
FETCH_DESTINATION = re.compile(r"^refs/remotes/[^\s]+/")
|
|
4817
|
+
|
|
4818
|
+
|
|
4819
|
+
FETCH_BEHAVIOR_KEYS = (
|
|
4820
|
+
"remote.{remote}.uploadpack", "fetch.uploadpack",
|
|
4821
|
+
"remote.{remote}.prune", "fetch.prune",
|
|
4822
|
+
"remote.{remote}.pruneTags", "fetch.pruneTags",
|
|
4823
|
+
"remote.{remote}.mirror", "remote.{remote}.tagopt",
|
|
4824
|
+
)
|
|
4825
|
+
|
|
4826
|
+
|
|
4827
|
+
def _configured_fetch_read_only(repo, remote) -> bool:
|
|
4828
|
+
"""The configured remote fetches into remote-tracking refs only.
|
|
4829
|
+
|
|
4830
|
+
Repo config can change what a bare `git fetch REMOTE` does, so the
|
|
4831
|
+
guard proves it instead of assuming it: the remote has a URL (an
|
|
4832
|
+
unconfigured name makes git fetch from a local directory of that
|
|
4833
|
+
name); none of uploadpack (runs a program), prune/pruneTags (delete
|
|
4834
|
+
refs), mirror (rewrites every ref) or tagopt (forces tag fetches) is
|
|
4835
|
+
set at any scope; and every `remote.<name>.fetch` refspec writes only
|
|
4836
|
+
under refs/remotes/ (a hand-added `+refs/heads/main:refs/heads/retired`
|
|
4837
|
+
moves a local branch). Default tag auto-follow still creates missing
|
|
4838
|
+
tags that point at fetched commits; it never moves an existing ref.
|
|
4839
|
+
"""
|
|
4840
|
+
def config(*arguments):
|
|
4841
|
+
try:
|
|
4842
|
+
return subprocess.run(
|
|
4843
|
+
["git", "config", *arguments], cwd=repo, capture_output=True,
|
|
4844
|
+
encoding="utf-8", errors="replace", check=False,
|
|
4845
|
+
)
|
|
4846
|
+
except OSError:
|
|
4847
|
+
return None
|
|
4848
|
+
|
|
4849
|
+
url = config("--get", f"remote.{remote}.url")
|
|
4850
|
+
if url is None or url.returncode != 0 or not url.stdout.strip():
|
|
4851
|
+
return False
|
|
4852
|
+
for key in FETCH_BEHAVIOR_KEYS:
|
|
4853
|
+
result = config("--get", key.format(remote=remote))
|
|
4854
|
+
if result is None or result.returncode != 1:
|
|
4855
|
+
return False
|
|
4856
|
+
refspecs = config("--get-all", f"remote.{remote}.fetch")
|
|
4857
|
+
if refspecs is None or refspecs.returncode not in (0, 1):
|
|
4858
|
+
return False
|
|
4859
|
+
for line in refspecs.stdout.splitlines():
|
|
4860
|
+
destination = line.rsplit(":", 1)[-1].strip()
|
|
4861
|
+
if not FETCH_DESTINATION.match(destination):
|
|
4862
|
+
return False
|
|
4863
|
+
return True
|
|
4864
|
+
|
|
4865
|
+
|
|
4866
|
+
def _closed_fetch_allowed(git, segment, directories) -> bool:
|
|
4867
|
+
"""A read-only fetch on a closed milestone (#468).
|
|
4868
|
+
|
|
4869
|
+
`git fetch [REMOTE]` is allowed only once these properties are PROVEN,
|
|
4870
|
+
never assumed:
|
|
4871
|
+
(1) the raw segment is `git (-C DIR)* fetch`: no other global option
|
|
4872
|
+
precedes the subcommand (walked on raw tokens, consuming each -C with
|
|
4873
|
+
its value, so neither valueless flags like -p nor a -C value named
|
|
4874
|
+
fetch can slip past; --upload-pack/--exec additionally raise in
|
|
4875
|
+
git_command);
|
|
4876
|
+
(2) the remote's configuration fetches read-only (see
|
|
4877
|
+
_configured_fetch_read_only).
|
|
4878
|
+
Any refspec or option on the command line stays denied.
|
|
4879
|
+
"""
|
|
4880
|
+
if git is None:
|
|
4881
|
+
return False
|
|
4882
|
+
subcommand, arguments, _options = git
|
|
4883
|
+
if subcommand != "fetch":
|
|
4884
|
+
return False
|
|
4885
|
+
words = [str(token) for token in segment]
|
|
4886
|
+
index = 1
|
|
4887
|
+
while words[index:index + 1] == ["-C"]:
|
|
4888
|
+
index += 2
|
|
4889
|
+
if words[index:index + 1] != ["fetch"]:
|
|
4890
|
+
return False
|
|
4891
|
+
remote = arguments[0] if arguments else None
|
|
4892
|
+
if remote is not None and not REMOTE_NAME.fullmatch(remote):
|
|
4893
|
+
return False
|
|
4894
|
+
if len(arguments) > 1:
|
|
4895
|
+
return False
|
|
4896
|
+
if remote is None:
|
|
4897
|
+
# No argument fetches the current branch's remote, else origin.
|
|
4898
|
+
remote = "origin"
|
|
4899
|
+
if directories:
|
|
4900
|
+
branch = _git_output([], directories[0], "branch", "--show-current")
|
|
4901
|
+
if branch:
|
|
4902
|
+
configured_branch_remote = subprocess.run(
|
|
4903
|
+
["git", "config", "--get", f"branch.{branch}.remote"],
|
|
4904
|
+
cwd=directories[0], capture_output=True, encoding="utf-8",
|
|
4905
|
+
errors="replace", check=False,
|
|
4906
|
+
).stdout.strip()
|
|
4907
|
+
if configured_branch_remote:
|
|
4908
|
+
remote = configured_branch_remote
|
|
4909
|
+
return all(
|
|
4910
|
+
_configured_fetch_read_only(Path(directory), remote)
|
|
4911
|
+
for directory in directories or [Path.cwd()]
|
|
4912
|
+
)
|
|
4913
|
+
|
|
4914
|
+
|
|
4525
4915
|
def closed_shell_execution_reason(
|
|
4526
4916
|
command, tokens, working_directories, initial_assignments=None
|
|
4527
4917
|
):
|
|
@@ -4568,17 +4958,54 @@ def closed_shell_execution_reason(
|
|
|
4568
4958
|
if invocation is None:
|
|
4569
4959
|
continue
|
|
4570
4960
|
executable, _ = invocation
|
|
4571
|
-
if executable in DIRECTORY_CHANGE_COMMANDS or
|
|
4961
|
+
if executable in DIRECTORY_CHANGE_COMMANDS or _closed_fetch_allowed(
|
|
4962
|
+
git, segment, directories
|
|
4963
|
+
):
|
|
4572
4964
|
continue
|
|
4573
4965
|
plain = checked_segment[:next((i for i, token in enumerate(checked_segment) if is_redirection(token)), len(checked_segment))]
|
|
4574
4966
|
if executable in {"echo", "printf"} or closed_git_listing(plain) or archive_command_is_read_only(
|
|
4575
4967
|
shlex.join(plain), plain, True, CLOSED_READ_GIT_COMMANDS
|
|
4576
4968
|
):
|
|
4577
4969
|
continue
|
|
4970
|
+
if command_can_destroy_files(invocation) and closed_destructive_scope_reason(
|
|
4971
|
+
list(invocation[1]), directories
|
|
4972
|
+
) is None:
|
|
4973
|
+
# A literal delete entirely outside the repository and every state
|
|
4974
|
+
# root is scratch cleanup, not new work on the closed milestone (#468).
|
|
4975
|
+
continue
|
|
4578
4976
|
return closed
|
|
4579
4977
|
return None
|
|
4580
4978
|
|
|
4581
4979
|
|
|
4980
|
+
def runtime_hotfix_invocation(tokens):
|
|
4981
|
+
"""True when any simple command, prefixed or wrapped, names isolation.py then runtime-hotfix."""
|
|
4982
|
+
for segment in command_segments(tokens):
|
|
4983
|
+
for index, (token, following) in enumerate(zip(segment, [*segment[1:], ""])):
|
|
4984
|
+
if following != "runtime-hotfix":
|
|
4985
|
+
continue
|
|
4986
|
+
clustered = re.fullmatch(r"-[A-Za-ln-z]*m(.+)", token)
|
|
4987
|
+
if clustered:
|
|
4988
|
+
module = clustered.group(1)
|
|
4989
|
+
elif any(
|
|
4990
|
+
earlier.startswith("-") and not earlier.startswith("--") and "m" in earlier
|
|
4991
|
+
for earlier in segment[:index]
|
|
4992
|
+
):
|
|
4993
|
+
module = token
|
|
4994
|
+
else:
|
|
4995
|
+
module = None
|
|
4996
|
+
if token == "-" or token.replace("\\", "/").rsplit("/", 1)[-1] == "isolation.py" or (
|
|
4997
|
+
module is not None and module.rsplit(".", 1)[-1] == "isolation"
|
|
4998
|
+
):
|
|
4999
|
+
return True
|
|
5000
|
+
try:
|
|
5001
|
+
wrapped = wrapped_command_tokens(segment, expand_parameters=False)
|
|
5002
|
+
except ValueError: # the shape checks below refuse what cannot be unwrapped
|
|
5003
|
+
continue
|
|
5004
|
+
if wrapped is not None and runtime_hotfix_invocation(wrapped):
|
|
5005
|
+
return True
|
|
5006
|
+
return False
|
|
5007
|
+
|
|
5008
|
+
|
|
4582
5009
|
def command_denial(command, working_directories, allow_destructive=True):
|
|
4583
5010
|
"""Return why a shell command is denied, or None when it may run."""
|
|
4584
5011
|
working_directories = working_directories or [os.getcwd()]
|
|
@@ -4592,6 +5019,16 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
4592
5019
|
):
|
|
4593
5020
|
return None
|
|
4594
5021
|
bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
|
|
5022
|
+
# runtime-hotfix is an operator-only recovery (#480): its receipt is
|
|
5023
|
+
# the owner authorization a landing later trusts, so an agent running
|
|
5024
|
+
# the command self-authorizes. Deny by subcommand name regardless of
|
|
5025
|
+
# the script's path.
|
|
5026
|
+
if runtime_hotfix_invocation(tokens):
|
|
5027
|
+
return (
|
|
5028
|
+
"isolation.py runtime-hotfix records the owner authorization a "
|
|
5029
|
+
"landing trusts; it is operator-only — request the hotfix from "
|
|
5030
|
+
"the orchestrator instead of running it"
|
|
5031
|
+
)
|
|
4595
5032
|
destructive = list(destructive_shell_invocations(tokens))
|
|
4596
5033
|
if destructive and (
|
|
4597
5034
|
not allow_destructive
|
|
@@ -4605,6 +5042,16 @@ def command_denial(command, working_directories, allow_destructive=True):
|
|
|
4605
5042
|
)
|
|
4606
5043
|
):
|
|
4607
5044
|
return DESTRUCTIVE_SHAPE_REASON
|
|
5045
|
+
if destructive:
|
|
5046
|
+
state_reason = destructive_state_root_reason(
|
|
5047
|
+
[operand for _, operands in destructive for operand in operands],
|
|
5048
|
+
working_directories,
|
|
5049
|
+
)
|
|
5050
|
+
if state_reason:
|
|
5051
|
+
return state_reason
|
|
5052
|
+
expression_reason = powershell_expression_reason(tokens)
|
|
5053
|
+
if expression_reason:
|
|
5054
|
+
return expression_reason
|
|
4608
5055
|
reason = closed_shell_execution_reason(command, tokens, working_directories)
|
|
4609
5056
|
if reason:
|
|
4610
5057
|
return reason
|
package/scripts/install.py
CHANGED
|
@@ -345,6 +345,9 @@ def install_member_hooks(coordinator: Path, member: Path, dry_run: bool = False)
|
|
|
345
345
|
plan = []
|
|
346
346
|
for name in GIT_HOOK_NAMES:
|
|
347
347
|
hook, chained = hooks_dir / name, hooks_dir / f"{name}{CHAINED_HOOK_SUFFIX}"
|
|
348
|
+
if _hook_is_disabled(hooks_dir, name):
|
|
349
|
+
plan.append((name, hook, chained, False, True))
|
|
350
|
+
continue
|
|
348
351
|
if hook.is_symlink() or chained.is_symlink():
|
|
349
352
|
raise InstallerError(f"refusing a symlinked git hook: {hook}")
|
|
350
353
|
if _lexists(hook) and not hook.is_file():
|
|
@@ -357,17 +360,21 @@ def install_member_hooks(coordinator: Path, member: Path, dry_run: bool = False)
|
|
|
357
360
|
)
|
|
358
361
|
if chain and _lexists(chained):
|
|
359
362
|
raise InstallerError(f"cannot chain {hook}: {chained} already exists")
|
|
360
|
-
plan.append((name, hook, chained, chain))
|
|
363
|
+
plan.append((name, hook, chained, chain, False))
|
|
361
364
|
lines = []
|
|
362
|
-
for name, hook, chained, chain in plan:
|
|
365
|
+
for name, hook, chained, chain, disabled in plan:
|
|
363
366
|
note = " (keeps the existing hook as a chained hook)" if chain else ""
|
|
367
|
+
if disabled:
|
|
368
|
+
note = " (left disabled)"
|
|
364
369
|
lines.append(f"member hook: {hook}{note}" + (" (dry run)" if dry_run else ""))
|
|
365
370
|
if dry_run:
|
|
366
371
|
return lines
|
|
367
372
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
368
373
|
staged = []
|
|
369
374
|
try:
|
|
370
|
-
for name, _, _, _ in plan:
|
|
375
|
+
for name, _, _, _, disabled in plan:
|
|
376
|
+
if disabled:
|
|
377
|
+
continue
|
|
371
378
|
temporary = hooks_dir / f".{name}.gsd-path-tmp"
|
|
372
379
|
# O_EXCL already refuses any existing name; O_NOFOLLOW (absent on
|
|
373
380
|
# Windows) adds defense in depth where it exists.
|
|
@@ -379,7 +386,9 @@ def install_member_hooks(coordinator: Path, member: Path, dry_run: bool = False)
|
|
|
379
386
|
if hasattr(os, "fchmod"): # Windows before 3.13; Git for Windows needs no exec bit.
|
|
380
387
|
os.fchmod(output.fileno(), 0o755)
|
|
381
388
|
output.write(member_hook(interpreter, name).encode("utf-8"))
|
|
382
|
-
for name, hook, chained, chain in plan:
|
|
389
|
+
for name, hook, chained, chain, disabled in plan:
|
|
390
|
+
if disabled:
|
|
391
|
+
continue
|
|
383
392
|
if chain:
|
|
384
393
|
os.replace(hook, chained)
|
|
385
394
|
os.replace(hooks_dir / f".{name}.gsd-path-tmp", hook)
|
|
@@ -1308,11 +1317,25 @@ def _project_destinations(
|
|
|
1308
1317
|
if hooks_dir is not None:
|
|
1309
1318
|
for hook_name, generator in GIT_HOOKS:
|
|
1310
1319
|
hook = hooks_dir / hook_name
|
|
1320
|
+
if _hook_is_disabled(hooks_dir, hook_name):
|
|
1321
|
+
continue
|
|
1311
1322
|
chained = _lexists(_chained_hook_path(hook)) or _foreign_git_hook(hook)
|
|
1312
1323
|
destinations.append((hook, None, generator(interpreter, chained), True))
|
|
1313
1324
|
return destinations
|
|
1314
1325
|
|
|
1315
1326
|
|
|
1327
|
+
def _hook_is_disabled(hooks_dir: Path, hook_name: str) -> bool:
|
|
1328
|
+
"""Whether the user deliberately disabled this hook (#469).
|
|
1329
|
+
|
|
1330
|
+
A hook renamed to `<name>.disabled*` stays disabled: reinstalling beside
|
|
1331
|
+
the rename would silently re-enable it. The rename only disables while
|
|
1332
|
+
the active hook is absent — a leftover active hook next to a rename is
|
|
1333
|
+
inspected and refreshed normally, never hidden. The convention matches
|
|
1334
|
+
the installer's own `disabled-gsd-skills-<n>` rename style.
|
|
1335
|
+
"""
|
|
1336
|
+
return not _lexists(hooks_dir / hook_name) and any(hooks_dir.glob(f"{hook_name}.disabled*"))
|
|
1337
|
+
|
|
1338
|
+
|
|
1316
1339
|
def _native_settings_mergers(
|
|
1317
1340
|
project: Path, selected: Sequence[str], hooks: bool
|
|
1318
1341
|
) -> Mapping[Path, Callable[[Path, str], str]]:
|
|
@@ -3050,6 +3073,13 @@ def doctor(
|
|
|
3050
3073
|
label = _describe_project_path(project, hook_path)
|
|
3051
3074
|
if hook_path in unreadable_git_hooks:
|
|
3052
3075
|
continue
|
|
3076
|
+
if _hook_is_disabled(hooks_dir, hook_name):
|
|
3077
|
+
push(
|
|
3078
|
+
"ok",
|
|
3079
|
+
f"hooks: {hook_name} is deliberately disabled "
|
|
3080
|
+
f"({hook_name}.disabled* present in {hooks_dir})",
|
|
3081
|
+
)
|
|
3082
|
+
continue
|
|
3053
3083
|
if not _lexists(hook_path):
|
|
3054
3084
|
push(
|
|
3055
3085
|
"fail",
|