@opengsd/gsd-core 1.18.0 → 1.19.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -1
- package/.opencode/plugins/gsd-core.js +2 -3
- package/agents/gsd-code-fixer.compact.md +2 -2
- package/agents/gsd-code-fixer.md +4 -3
- package/agents/gsd-verifier.md +1 -1
- package/bin/install.js +422 -103
- package/gsd-core/bin/gsd-tools.cjs +106 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
- package/gsd-core/bin/lib/args-terminator.cjs +114 -0
- package/gsd-core/bin/lib/broken-windows.cjs +59 -1
- package/gsd-core/bin/lib/capability-lock.cjs +13 -2
- package/gsd-core/bin/lib/capability-registry.cjs +156 -74
- package/gsd-core/bin/lib/capability-state.cjs +14 -8
- package/gsd-core/bin/lib/capability-validator.cjs +17 -0
- package/gsd-core/bin/lib/config-loader.cjs +10 -0
- package/gsd-core/bin/lib/config.cjs +256 -58
- package/gsd-core/bin/lib/core-utils.cjs +34 -0
- package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +8 -3
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
- package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
- package/gsd-core/bin/lib/git-info-exclude.cjs +57 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +18 -3
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
- package/gsd-core/bin/lib/host-integration.cjs +13 -6
- package/gsd-core/bin/lib/init.cjs +65 -1
- package/gsd-core/bin/lib/install-engine.cjs +246 -32
- package/gsd-core/bin/lib/install-profiles.cjs +9 -4
- package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
- package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +113 -0
- package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
- package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
- package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
- package/gsd-core/bin/lib/milestone.cjs +43 -1
- package/gsd-core/bin/lib/model-catalog.cjs +8 -0
- package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
- package/gsd-core/bin/lib/phase-id.cjs +21 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +37 -4
- package/gsd-core/bin/lib/phase.cjs +115 -26
- package/gsd-core/bin/lib/planning-snapshot.cjs +3 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +18 -0
- package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +58 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +35 -2
- package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +12 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +39 -2
- package/gsd-core/bin/lib/roadmap.cjs +33 -25
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
- package/gsd-core/bin/lib/shell-command-projection.cjs +121 -16
- package/gsd-core/bin/lib/smart-entry.cjs +13 -0
- package/gsd-core/bin/lib/state-contract.cjs +29 -9
- package/gsd-core/bin/lib/state-document.cjs +12 -4
- package/gsd-core/bin/lib/state-transition.cjs +119 -24
- package/gsd-core/bin/lib/state.cjs +50 -28
- package/gsd-core/bin/lib/surface.cjs +9 -0
- package/gsd-core/bin/lib/task-command-router.cjs +5 -25
- package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
- package/gsd-core/bin/lib/verification.cjs +96 -9
- package/gsd-core/bin/lib/verify.cjs +2 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/git-integration.md +11 -0
- package/gsd-core/references/loop-hook-dispatch.md +13 -6
- package/gsd-core/references/planning-config.md +2 -0
- package/gsd-core/references/tdd.md +2 -2
- package/gsd-core/templates/DEBUG.md +16 -0
- package/gsd-core/templates/config.json +2 -1
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/audit-fix.md +2 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
- package/gsd-core/workflows/code-review.md +61 -7
- package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
- package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
- package/gsd-core/workflows/complete-milestone.md +37 -150
- package/gsd-core/workflows/diagnose-issues.md +29 -5
- package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
- package/gsd-core/workflows/discuss-phase-power.md +19 -2
- package/gsd-core/workflows/discuss-phase.md +6 -10
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
- package/gsd-core/workflows/execute-phase.md +9 -5
- package/gsd-core/workflows/health.md +2 -1
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
- package/gsd-core/workflows/plan-phase.md +13 -13
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
- package/gsd-core/workflows/quick.md +1 -1
- package/gsd-core/workflows/review.md +16 -13
- package/gsd-core/workflows/section-manifest.json +5 -0
- package/gsd-core/workflows/settings.md +1 -0
- package/gsd-core/workflows/ship.md +3 -4
- package/gsd-core/workflows/spec-phase.md +3 -6
- package/gsd-core/workflows/verify-work.md +4 -3
- package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-path-export.js +95 -0
- package/hooks/dist/gsd-secret-read-guard.js +172 -39
- package/hooks/dist/gsd-statusline.js +35 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +28 -1
- package/hooks/dist/gsd-workflow-guard.js +1 -1
- package/hooks/dist/gsd-worktree-path-guard.js +28 -1
- package/hooks/dist/lib/isolation-sentinel.js +44 -0
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-agent-isolation-guard.js +11 -1
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-path-export.js +95 -0
- package/hooks/gsd-secret-read-guard.js +172 -39
- package/hooks/gsd-statusline.js +35 -2
- package/hooks/gsd-windsurf-pre-write.js +28 -1
- package/hooks/gsd-workflow-guard.js +1 -1
- package/hooks/gsd-worktree-path-guard.js +28 -1
- package/hooks/hooks.json +1 -0
- package/hooks/lib/isolation-sentinel.js +44 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +13 -6
- package/pi/gsd.cjs +1047 -10
- package/pi/package-agents/gsd-advisor-researcher.md +113 -0
- package/pi/package-agents/gsd-ai-researcher.md +111 -0
- package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
- package/pi/package-agents/gsd-code-fixer.md +769 -0
- package/pi/package-agents/gsd-code-reviewer.md +402 -0
- package/pi/package-agents/gsd-codebase-mapper.md +850 -0
- package/pi/package-agents/gsd-debug-session-manager.md +416 -0
- package/pi/package-agents/gsd-debugger.md +1274 -0
- package/pi/package-agents/gsd-doc-classifier.md +269 -0
- package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
- package/pi/package-agents/gsd-doc-verifier.md +212 -0
- package/pi/package-agents/gsd-doc-writer.md +626 -0
- package/pi/package-agents/gsd-dom-verifier.md +164 -0
- package/pi/package-agents/gsd-domain-researcher.md +144 -0
- package/pi/package-agents/gsd-eval-auditor.md +185 -0
- package/pi/package-agents/gsd-eval-planner.md +149 -0
- package/pi/package-agents/gsd-executor.md +894 -0
- package/pi/package-agents/gsd-framework-selector.md +159 -0
- package/pi/package-agents/gsd-integration-checker.md +476 -0
- package/pi/package-agents/gsd-intel-updater.md +338 -0
- package/pi/package-agents/gsd-mempalace-curator.md +49 -0
- package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
- package/pi/package-agents/gsd-pattern-mapper.md +340 -0
- package/pi/package-agents/gsd-phase-researcher.md +897 -0
- package/pi/package-agents/gsd-plan-checker.md +1077 -0
- package/pi/package-agents/gsd-planner.md +1003 -0
- package/pi/package-agents/gsd-project-researcher.md +611 -0
- package/pi/package-agents/gsd-research-synthesizer.md +259 -0
- package/pi/package-agents/gsd-roadmapper.md +779 -0
- package/pi/package-agents/gsd-security-auditor.md +171 -0
- package/pi/package-agents/gsd-ui-auditor.md +581 -0
- package/pi/package-agents/gsd-ui-checker.md +417 -0
- package/pi/package-agents/gsd-ui-researcher.md +469 -0
- package/pi/package-agents/gsd-user-profiler.md +174 -0
- package/pi/package-agents/gsd-verifier.md +963 -0
- package/pi/package-agents/manifest.json +39 -0
- package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
- package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
- package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
- package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
- package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
- package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
- package/pi/package-skills/gsd-capture/SKILL.md +68 -0
- package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
- package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
- package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
- package/pi/package-skills/gsd-config/SKILL.md +60 -0
- package/pi/package-skills/gsd-debug/SKILL.md +64 -0
- package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
- package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
- package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
- package/pi/package-skills/gsd-explore/SKILL.md +26 -0
- package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
- package/pi/package-skills/gsd-fast/SKILL.md +33 -0
- package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
- package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
- package/pi/package-skills/gsd-health/SKILL.md +36 -0
- package/pi/package-skills/gsd-help/SKILL.md +37 -0
- package/pi/package-skills/gsd-import/SKILL.md +46 -0
- package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
- package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
- package/pi/package-skills/gsd-manager/SKILL.md +46 -0
- package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
- package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
- package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
- package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
- package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
- package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
- package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
- package/pi/package-skills/gsd-next/SKILL.md +34 -0
- package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
- package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
- package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
- package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
- package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
- package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
- package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
- package/pi/package-skills/gsd-phase/SKILL.md +61 -0
- package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
- package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
- package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
- package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
- package/pi/package-skills/gsd-progress/SKILL.md +51 -0
- package/pi/package-skills/gsd-quick/SKILL.md +179 -0
- package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
- package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
- package/pi/package-skills/gsd-review/SKILL.md +50 -0
- package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
- package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
- package/pi/package-skills/gsd-settings/SKILL.md +30 -0
- package/pi/package-skills/gsd-ship/SKILL.md +27 -0
- package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
- package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
- package/pi/package-skills/gsd-spike/SKILL.md +53 -0
- package/pi/package-skills/gsd-stats/SKILL.md +22 -0
- package/pi/package-skills/gsd-surface/SKILL.md +177 -0
- package/pi/package-skills/gsd-thread/SKILL.md +29 -0
- package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
- package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
- package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
- package/pi/package-skills/gsd-undo/SKILL.md +38 -0
- package/pi/package-skills/gsd-update/SKILL.md +51 -0
- package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
- package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
- package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
- package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
- package/scripts/build-hooks.js +4 -0
- package/scripts/command-contract-helpers.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +20 -7
- package/scripts/gen-health-docs.cjs +13 -1
- package/scripts/gen-pi-package-skills.cjs +217 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +11 -1
- package/scripts/lib/platform-conformance-tier.generated.cjs +26 -1
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
- package/scripts/lint-retired-runtime-name.cjs +5 -0
- package/scripts/npm-audit-baseline.cjs +41 -7
- package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
- package/scripts/select-docs-guards.cjs +1 -1
- package/scripts/self-test-report-policy.cjs +350 -0
- package/vscode/package.json +1 -1
- package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
- package/scripts/diagnose-validate-commit-phases.cjs +0 -348
- package/scripts/xtrace-phase-timing.cjs +0 -205
|
@@ -61,7 +61,7 @@ INIT=$(gsd_run init phase-op "${PHASE}")
|
|
|
61
61
|
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Parse JSON for: `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `state_path`, `requirements_path`, `roadmap_path`, `planning_path`, `response_language`, `commit_docs`.
|
|
64
|
+
Parse JSON for: `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `state_path`, `requirements_path`, `roadmap_path`, `planning_path`, `has_spec`, `spec_path`, `response_language`, `commit_docs`.
|
|
65
65
|
|
|
66
66
|
**If `response_language` is set:** All user-facing text in this workflow — narration between tool calls, status updates, progress notes, findings, questions, and report prose — MUST be in `{response_language}`. Technical terms, code, and file paths stay in English.
|
|
67
67
|
|
|
@@ -72,12 +72,9 @@ Use /gsd:progress to see available phases.
|
|
|
72
72
|
```
|
|
73
73
|
Exit.
|
|
74
74
|
|
|
75
|
-
**Check for existing SPEC.md:**
|
|
76
|
-
```bash
|
|
77
|
-
ls ${phase_dir}/*-SPEC.md 2>/dev/null | grep -v AI-SPEC | head -1 || true
|
|
78
|
-
```
|
|
75
|
+
**Check for existing SPEC.md:** use `has_spec` and `spec_path` from init (one shared definition of the phase SPEC: a plain `*-SPEC.md`, never `*-AI-SPEC.md` or `*-UI-SPEC.md`). Do not glob for it.
|
|
79
76
|
|
|
80
|
-
If
|
|
77
|
+
If `has_spec` is true:
|
|
81
78
|
|
|
82
79
|
**If `--auto`:** Auto-select "Skip" — leave the existing SPEC.md unchanged and exit with the same
|
|
83
80
|
message the interactive "Skip" prints. Log: `[auto] SPEC.md exists — reusing as-is.` An unattended
|
|
@@ -75,6 +75,7 @@ registry, not hardcoded here.
|
|
|
75
75
|
```bash
|
|
76
76
|
VERIFY_PRE_HOOKS_JSON=$(gsd_run loop render-hooks verify:pre --raw)
|
|
77
77
|
PHASE_DIR=$(printf '%s' "$INIT" | jq -r '.phase_dir // empty')
|
|
78
|
+
PHASE_NUMBER=$(printf '%s' "$INIT" | jq -r '.phase_number // empty')
|
|
78
79
|
```
|
|
79
80
|
|
|
80
81
|
Read the `activeHooks` array from `VERIFY_PRE_HOOKS_JSON` in-context (do NOT pipe through a shell parser).
|
|
@@ -87,12 +88,12 @@ Read the `activeHooks` array from `VERIFY_PRE_HOOKS_JSON` in-context (do NOT pip
|
|
|
87
88
|
|
|
88
89
|
Record the union of `produces` artefact names declared by the active step entries as `VERIFY_PRE_PRODUCED` — `extract_tests` consumes it below. An entry declaring `produces: []` contributes nothing, which is the normal case.
|
|
89
90
|
|
|
90
|
-
⚠ **Validate `check` before shell use** (third-party manifest input) — `loop-hook-dispatch.md` § `gate`.
|
|
91
|
+
⚠ **Validate `check` before shell use** (third-party manifest input; wherever `${hook.check.query}` appears, write the dot-normalized query, each `.` as `-`, and validate and pass that same value) — `loop-hook-dispatch.md` § `gate`.
|
|
91
92
|
|
|
92
93
|
Resolve active gate hooks from `VERIFY_PRE_HOOKS_JSON` where `kind == "gate"`.
|
|
93
94
|
For each active gate hook, run its declared check (a `check.query` gate runs
|
|
94
95
|
`gsd_run check ${hook.check.query} "${PHASE_DIR}" --raw`; a `predicate` gate
|
|
95
|
-
runs `gsd_run check predicate --predicate '<hook.check.predicate as JSON>' --phase-dir "${PHASE_DIR}" --raw`):
|
|
96
|
+
runs `gsd_run check predicate --predicate '<hook.check.predicate as JSON>' --phase-dir "${PHASE_DIR}" --phase-number "${PHASE_NUMBER}" --raw`):
|
|
96
97
|
|
|
97
98
|
```bash
|
|
98
99
|
GATE_RESULT=$(gsd_run check "${hook_check_query}" "${PHASE_DIR}" --raw) && CHECK_EXIT=0 || CHECK_EXIT=$?
|
|
@@ -799,7 +800,7 @@ Spawn gsd-plan-checker against the fix plans (iteration_count starts at 1), mode
|
|
|
799
800
|
> **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
|
|
800
801
|
|
|
801
802
|
On return:
|
|
802
|
-
- **VERIFICATION PASSED:**
|
|
803
|
+
- **VERIFICATION PASSED:** If the return lists an entry whose severity is BLOCKER or WARNING, in the YAML issues block or listed in prose (a mention such as "no warnings" or a zero count is not an entry), handle it as ISSUES FOUND below, counting the prose entries as BLOCKER/WARNING entries; the sentinel never decides alone, and an entry with a missing or unrecognized severity counts as a BLOCKER (#5578). Otherwise proceed to `present_ready`
|
|
803
804
|
- **ISSUES FOUND:** Count BLOCKER + WARNING entries in the YAML issues block; an entry whose severity is missing or unrecognized counts as a BLOCKER (fail closed). If zero — every entry is explicitly INFO — display `ℹ advisory — {dimension}: {description}` per entry and proceed to `present_ready`; INFO is advisory and never enters the loop (#3724). Otherwise proceed to `revision_loop`
|
|
804
805
|
|
|
805
806
|
Exact Agent() prompt fields: `gsd-core/workflows/verify-work/detail/elaboration.md` § 2.
|
|
@@ -71,7 +71,7 @@ if (require.main === module && !process.env.NODE_V8_COVERAGE) {
|
|
|
71
71
|
const fs = require('fs');
|
|
72
72
|
const path = require('path');
|
|
73
73
|
const os = require('os');
|
|
74
|
-
const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
|
|
74
|
+
const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, agedNoneGovernsDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
|
|
75
75
|
const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
|
|
76
76
|
const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
|
|
77
77
|
|
|
@@ -465,6 +465,16 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
|
|
|
465
465
|
};
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
// #5479: a recorded sequential degrade (`none`) for THIS dispatch's phase keeps governing it after the
|
|
469
|
+
// trust window. Without this, an executor that ran longer than SENTINEL_STALE_MS made the next
|
|
470
|
+
// flag-less dispatch of the same phase fall back to the registry capability ("this host CAN
|
|
471
|
+
// isolate") and be denied for a degrade the workflow legitimately recorded. The predicate only
|
|
472
|
+
// loosens toward `none`, only for the phase the dispatch itself names, never for a future-dated
|
|
473
|
+
// record (see agedNoneGovernsDispatch).
|
|
474
|
+
if (sentinel.present && sentinel.stale && agedNoneGovernsDispatch(sentinel, dispatchIds, { clock })) {
|
|
475
|
+
return { gsdProject: true, isolation: 'none', harnessFlag: null, error: null, sentinelDiscarded: null };
|
|
476
|
+
}
|
|
477
|
+
|
|
468
478
|
// Sentinel absent, stale, or does not apply to this dispatch (F2 mismatch):
|
|
469
479
|
// conservative fallback (#3045 finding — must still cover fail-closed case
|
|
470
480
|
// (a): a project that opted out of worktrees entirely via
|
|
@@ -70,7 +70,7 @@ const { allow } = require('./lib/hook-exit.js');
|
|
|
70
70
|
// hooks/lib/cursor-workspace.js. Staged next to these scripts by
|
|
71
71
|
// writeCursorHooksJson so the require always resolves post-install.
|
|
72
72
|
const { resolveStatePath } = require('./lib/cursor-workspace.js');
|
|
73
|
-
const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
|
|
73
|
+
const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch, agedNoneGovernsDispatch, buildSentinelDiscard } = require('./lib/isolation-sentinel.js');
|
|
74
74
|
const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js');
|
|
75
75
|
// #3582: gsd-core/bin/lib/*.cjs (runtime-homes.cjs, worktree-safety.cjs,
|
|
76
76
|
// runtime-name-policy.cjs, capability-registry.cjs — required below, inside
|
|
@@ -518,9 +518,13 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
|
|
|
518
518
|
|
|
519
519
|
let declaredIsolation;
|
|
520
520
|
try {
|
|
521
|
+
// #5479: an aged recorded `none` for this dispatch's phase keeps governing it — the same predicate,
|
|
522
|
+
// and so the same verdicts, as the Claude guard (hooks/gsd-agent-isolation-guard.js).
|
|
521
523
|
declaredIsolation = (sentinel.present && !sentinel.stale && applies)
|
|
522
524
|
? sentinel.isolation
|
|
523
|
-
:
|
|
525
|
+
: (sentinel.present && sentinel.stale && agedNoneGovernsDispatch(sentinel, dispatchIds, { clock }))
|
|
526
|
+
? 'none'
|
|
527
|
+
: resolveFallbackIsolation(root, configPath);
|
|
524
528
|
} catch {
|
|
525
529
|
return {
|
|
526
530
|
action: 'deny',
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
// SessionStart hook: put the installed gsd-core `bin/` directory (which holds `gsd_run`) on the
|
|
4
|
+
// PATH of every later Bash tool call in this Claude Code session (#5465).
|
|
5
|
+
//
|
|
6
|
+
// Why a hook: the resolver snippet (gsd-core/workflows/_runtime-launcher.snippet.sh) persists its
|
|
7
|
+
// PATH export through $CLAUDE_ENV_FILE, but Claude Code provides that variable only to SessionStart,
|
|
8
|
+
// Setup, CwdChanged and FileChanged hooks, never to a workflow Bash block. So the write never fired
|
|
9
|
+
// and a later block that called `gsd_run` without re-including the resolver got "command not found"
|
|
10
|
+
// (hidden by `2>/dev/null || echo <fallback>`). This hook writes the same line from the one place
|
|
11
|
+
// the variable exists. The snippet's in-block write stays for users who export the variable
|
|
12
|
+
// themselves.
|
|
13
|
+
//
|
|
14
|
+
// Fail-open and side-effect-minimal by design: it only ever appends one line to the file Claude Code
|
|
15
|
+
// hands to SessionStart hooks, only for a directory that contains an installed `gsd_run`, and any
|
|
16
|
+
// problem (no variable, no gsd_run, unwritable file) exits 0 without output.
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { allow } = require('./lib/hook-exit.js');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `bin/` directory next to this hook's install root: hooks live in `<root>/hooks/` and the
|
|
26
|
+
* runtime in `<root>/gsd-core/` for global, project-local and plugin layouts alike. Returns the
|
|
27
|
+
* real path, or null when `gsd_run` is not there.
|
|
28
|
+
*/
|
|
29
|
+
function resolveBinDir(hookDir) {
|
|
30
|
+
try {
|
|
31
|
+
const bin = fs.realpathSync(path.join(hookDir, '..', 'gsd-core', 'bin'));
|
|
32
|
+
return fs.statSync(path.join(bin, 'gsd_run')).isFile() ? bin : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The directory as the bash that sources $CLAUDE_ENV_FILE spells it. On Windows the env file is
|
|
40
|
+
* sourced by Git Bash, where `:` separates PATH entries and a drive letter is `/c/...`, which is
|
|
41
|
+
* also what the resolver snippet writes (it derives the directory from bash's `$GSD_TOOLS`). A
|
|
42
|
+
* native `C:\Users\x\...` would split at the colon and never resolve.
|
|
43
|
+
*/
|
|
44
|
+
function shellPath(dir, platform = process.platform) {
|
|
45
|
+
if (platform !== 'win32') return dir;
|
|
46
|
+
const m = /^([A-Za-z]):[\\/](.*)$/.exec(dir);
|
|
47
|
+
const rest = (m ? m[2] : dir.replace(/^[\\/]+/, '')).replace(/\\/g, '/');
|
|
48
|
+
return m ? `/${m[1].toLowerCase()}/${rest}` : dir.replace(/\\/g, '/');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The exact line the resolver snippet writes: `export PATH='<dir>':"$PATH"` with `'` escaped. */
|
|
52
|
+
function exportLine(binDir, platform = process.platform) {
|
|
53
|
+
return `export PATH='${shellPath(binDir, platform).replace(/'/g, "'\\''")}':"$PATH"`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Append the export to `envFile` unless it is already there. Returns true when it wrote.
|
|
58
|
+
* Never throws.
|
|
59
|
+
*/
|
|
60
|
+
function persistPath(envFile, binDir) {
|
|
61
|
+
try {
|
|
62
|
+
const line = exportLine(binDir);
|
|
63
|
+
let existing = '';
|
|
64
|
+
try {
|
|
65
|
+
existing = fs.readFileSync(envFile, 'utf8');
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e && e.code !== 'ENOENT') return false;
|
|
68
|
+
}
|
|
69
|
+
if (existing.split(/\r?\n/).includes(line)) return false;
|
|
70
|
+
const prefix = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
71
|
+
fs.appendFileSync(envFile, `${prefix}${line}\n`, 'utf8');
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function main() {
|
|
79
|
+
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
80
|
+
if (typeof envFile !== 'string' || envFile.trim() === '') return;
|
|
81
|
+
const binDir = resolveBinDir(__dirname);
|
|
82
|
+
if (binDir === null) return;
|
|
83
|
+
persistPath(envFile, binDir);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (require.main === module) {
|
|
87
|
+
try {
|
|
88
|
+
main();
|
|
89
|
+
} catch {
|
|
90
|
+
// A PATH convenience must never fail session start.
|
|
91
|
+
}
|
|
92
|
+
allow();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { resolveBinDir, shellPath, exportLine, persistPath };
|
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
// EOF)"` — is never operand-checked, so prose mentioning `.env` is safe.
|
|
57
57
|
// pass 2 groups tokens by segment and evaluates each on its own:
|
|
58
58
|
// input redirect targets (`<`, `N<`) are always checked; the command word is
|
|
59
|
-
// located past
|
|
59
|
+
// located past wrapper prefixes AND their own options (`sudo -u U`,
|
|
60
|
+
// `env -i VAR=x`, `timeout -s KILL 5`, `nice -n 5`, `stdbuf -oL`, `exec`;
|
|
61
|
+
// see WRAPPERS, #5627); a closed set of
|
|
60
62
|
// NON-READING commands (test/[/ls/stat/rm/touch/echo/…) exempts that
|
|
61
63
|
// segment's operands — `[ -f .env ]` and `ls .env*` are existence checks
|
|
62
64
|
// GSD's own agents run — while `cp`/`mv`/`ln`/`git` are deliberately NOT
|
|
@@ -90,7 +92,9 @@
|
|
|
90
92
|
// (`cat .e*`), interpreter one-liners (`python -c "open('.env')"`), a piped
|
|
91
93
|
// script from a non-echo source (`cat gen.sh | bash`, `curl … | sh`), reads
|
|
92
94
|
// inside scripts the agent executes, and `glob: '*'` reaching a
|
|
93
|
-
// NON-gitignored `.env
|
|
95
|
+
// NON-gitignored `.env`, and a wrapper (or wrapper option taking a
|
|
96
|
+
// separate value) the WRAPPERS table does not list, or `env -S 'script'` (the split string is skipped as
|
|
97
|
+
// the option's value). The promise is "no looser than the retired rules
|
|
94
98
|
// on plain commands, without arming the compound-`cd` prompt". Writes to
|
|
95
99
|
// secret files are out of scope (Write/Edit were never gated). Commands
|
|
96
100
|
// over 1 MiB are denied outright (`command-too-large`) rather than
|
|
@@ -148,8 +152,29 @@ const MAX_GLOB_ALTERNATIVES = 64;
|
|
|
148
152
|
const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
|
|
149
153
|
|
|
150
154
|
// Command-prefix wrappers to look through when locating the command word at
|
|
151
|
-
// the head of a segment (
|
|
152
|
-
|
|
155
|
+
// the head of a segment (#5627). Each wrapper runs the words that follow its
|
|
156
|
+
// own options, so the table records what resolveCommand must step over:
|
|
157
|
+
// short letters of short options that take a value (`-n 5`, `-n5`)
|
|
158
|
+
// long long options that take a SEPARATE value word (`--adjustment 5`);
|
|
159
|
+
// `--opt=value` is one word and needs no entry
|
|
160
|
+
// assigns leading `VAR=value` words belong to the wrapper (env)
|
|
161
|
+
// operands positional words after the options (timeout's DURATION)
|
|
162
|
+
// Taken from sudo(8), doas(1), env(1), nice(1), time(1), timeout(1),
|
|
163
|
+
// stdbuf(1), setsid(1) and bash's `command`/`exec`. A wrong entry fails open
|
|
164
|
+
// (the option's value becomes the command word), so keep it to the man pages.
|
|
165
|
+
const WRAPPERS = new Map([
|
|
166
|
+
['sudo', { short: 'CDghpRrTtUu', long: ['--close-from', '--chdir', '--group', '--host', '--prompt', '--chroot', '--role', '--command-timeout', '--type', '--other-user', '--user'] }],
|
|
167
|
+
['doas', { short: 'Cu' }],
|
|
168
|
+
['env', { short: 'uCSaP', long: ['--unset', '--chdir', '--split-string', '--argv0'], assigns: true }],
|
|
169
|
+
['nice', { short: 'n', long: ['--adjustment'] }],
|
|
170
|
+
['nohup', {}],
|
|
171
|
+
['command', {}],
|
|
172
|
+
['time', { short: 'fo', long: ['--format', '--output'] }],
|
|
173
|
+
['timeout', { short: 'sk', long: ['--signal', '--kill-after'], operands: 1 }],
|
|
174
|
+
['stdbuf', { short: 'ioe', long: ['--input', '--output', '--error'] }],
|
|
175
|
+
['setsid', {}],
|
|
176
|
+
['exec', { short: 'a' }],
|
|
177
|
+
]);
|
|
153
178
|
|
|
154
179
|
// Commands whose ordinary operands are file NAMES, never file CONTENTS. A
|
|
155
180
|
// closed set on purpose: anything not listed is assumed to read.
|
|
@@ -405,6 +430,20 @@ function classifyGrepGlob(glob) {
|
|
|
405
430
|
// Bash command scan — pass 1: tokenizer
|
|
406
431
|
// ---------------------------------------------------------------------------
|
|
407
432
|
|
|
433
|
+
// Which reading of a heredoc the scan is making (#5513). The guard scans a command that
|
|
434
|
+
// opens a heredoc under every reading below and denies if any of them finds a secret read
|
|
435
|
+
// (see findSecretReadUnderOtherReadings), so a change of reading can add a denial but
|
|
436
|
+
// never remove one.
|
|
437
|
+
// - heredocBodiesSkippedInSpans: bash 5 skips the bodies of heredocs opened inside a
|
|
438
|
+
// `$( )` span when it looks for the closing `)`; bash 3.2.57 (still `/bin/bash` on
|
|
439
|
+
// macOS) does not, so a `)` on a body line closes the substitution and the rest of
|
|
440
|
+
// that line runs as the OUTER command.
|
|
441
|
+
// - heredocTagsAsWords: an unquoted tag is a bash word (a CR and a substitution belong
|
|
442
|
+
// to it). false is the earlier reading, where the tag stopped at any whitespace and at
|
|
443
|
+
// `(`: kept as a reading so nothing the guard denied before is allowed now.
|
|
444
|
+
let heredocBodiesSkippedInSpans = true;
|
|
445
|
+
let heredocTagsAsWords = true;
|
|
446
|
+
|
|
408
447
|
// Index of the `)` closing a `$(` / `<(` / `>(` opened just before `i`, or
|
|
409
448
|
// str.length when unterminated. Quote- and heredoc-aware so a `)` inside a
|
|
410
449
|
// quoted string or a heredoc body never closes the span early.
|
|
@@ -439,7 +478,7 @@ function findParenClose(str, i) {
|
|
|
439
478
|
i = j === -1 ? str.length : j + 1;
|
|
440
479
|
continue;
|
|
441
480
|
}
|
|
442
|
-
if (ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
|
|
481
|
+
if (heredocBodiesSkippedInSpans && ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
|
|
443
482
|
const tag = readHeredocTag(str, i + 2);
|
|
444
483
|
heredocTags.push(tag);
|
|
445
484
|
i = tag.end;
|
|
@@ -475,7 +514,34 @@ function readHeredocTag(str, i) {
|
|
|
475
514
|
i = j === -1 ? str.length : j + 1;
|
|
476
515
|
} else {
|
|
477
516
|
if (str[i] === '\\') { quoted = true; i++; }
|
|
478
|
-
|
|
517
|
+
// #5513: an unquoted tag is a bash WORD. It ends only at a metacharacter (space, tab,
|
|
518
|
+
// newline, `;&|<>()`): a carriage return is not one, so `EOF` + CR is the tag and the
|
|
519
|
+
// CRLF terminator line matches it; and a substitution inside the word belongs to it,
|
|
520
|
+
// taken literally (`E$(x)F` waits for the line `E$(x)F`).
|
|
521
|
+
while (i < str.length && !(heredocTagsAsWords ? /[ \t\n;&|<>()]/ : /[\s;&|<>()]/).test(str[i])) {
|
|
522
|
+
if (!heredocTagsAsWords) { tag += str[i++]; continue; }
|
|
523
|
+
if (str[i] === '$' && str[i + 1] === '(') {
|
|
524
|
+
const e = findParenClose(str, i + 2);
|
|
525
|
+
tag += str.slice(i, e + 1);
|
|
526
|
+
i = e + 1;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (str[i] === '$' && str[i + 1] === '{') {
|
|
530
|
+
const j = str.indexOf('}', i);
|
|
531
|
+
const e = j === -1 ? str.length - 1 : j;
|
|
532
|
+
tag += str.slice(i, e + 1);
|
|
533
|
+
i = e + 1;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (str[i] === '`') {
|
|
537
|
+
const j = str.indexOf('`', i + 1);
|
|
538
|
+
const e = j === -1 ? str.length - 1 : j;
|
|
539
|
+
tag += str.slice(i, e + 1);
|
|
540
|
+
i = e + 1;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
tag += str[i++];
|
|
544
|
+
}
|
|
479
545
|
}
|
|
480
546
|
return { tag, quoted, stripTabs, end: i };
|
|
481
547
|
}
|
|
@@ -621,6 +687,7 @@ function tokenize(str) {
|
|
|
621
687
|
hasWord = true;
|
|
622
688
|
const e = findParenClose(str, i + 2);
|
|
623
689
|
nested.push(str.slice(i + 2, e));
|
|
690
|
+
if (expectTag && heredocTagsAsWords) buf += str.slice(i, e + 1); // #5513: the tag is taken literally
|
|
624
691
|
i = e + 1;
|
|
625
692
|
continue;
|
|
626
693
|
}
|
|
@@ -639,6 +706,7 @@ function tokenize(str) {
|
|
|
639
706
|
const j = str.indexOf('`', i + 1);
|
|
640
707
|
const e = j === -1 ? str.length : j;
|
|
641
708
|
nested.push(str.slice(i + 1, e));
|
|
709
|
+
if (expectTag && heredocTagsAsWords) buf += str.slice(i, e + 1); // #5513: the tag is taken literally
|
|
642
710
|
i = e + 1;
|
|
643
711
|
continue;
|
|
644
712
|
}
|
|
@@ -673,6 +741,14 @@ function tokenize(str) {
|
|
|
673
741
|
continue;
|
|
674
742
|
}
|
|
675
743
|
|
|
744
|
+
// #5513: a CR is not a bash metacharacter, so inside a heredoc tag it is part of the word
|
|
745
|
+
// (a CRLF command's terminator line is `EOF` + CR).
|
|
746
|
+
if (ch === '\r' && expectTag && hasWord && heredocTagsAsWords) {
|
|
747
|
+
buf += ch;
|
|
748
|
+
i++;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
|
|
676
752
|
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
677
753
|
flush();
|
|
678
754
|
i++;
|
|
@@ -769,17 +845,33 @@ function tokenize(str) {
|
|
|
769
845
|
// Bash command scan — pass 2: per-segment evaluation
|
|
770
846
|
// ---------------------------------------------------------------------------
|
|
771
847
|
|
|
772
|
-
//
|
|
773
|
-
|
|
848
|
+
// Option characters of a bundled short cluster (`-2nif`).
|
|
849
|
+
const CLUSTER_FLAG_CHAR_RE = /[A-Za-z0-9]/;
|
|
850
|
+
|
|
851
|
+
// True when an operand word names a secret file. `@file` (curl -d) and
|
|
852
|
+
// `--flag=value` are stripped to the value. In a single-dash word
|
|
853
|
+
// (`/^-[^-]./`, so `-2if.env` counts too) the value of a bundled cluster
|
|
854
|
+
// starts right after its run of option characters (#5046): `-if.env` holds
|
|
855
|
+
// `.env`, `-nf=.env` holds `.env` after the `=`. That value is checked as
|
|
856
|
+
// well as the historical two-character strip, so the verdict is never looser
|
|
857
|
+
// than base and costs a fixed number of `namesSecret` calls (at most three,
|
|
858
|
+
// whatever the word's length). The option run also swallows a value's leading
|
|
859
|
+
// letters and digits, so `-fapp.env` is denied although `cat app.env` is not:
|
|
860
|
+
// a fail-safe false positive, pinned in tests.
|
|
861
|
+
function operandNamesSecret(text) {
|
|
774
862
|
let v = text;
|
|
775
863
|
if (v.startsWith('@')) v = v.slice(1);
|
|
776
864
|
if (v.startsWith('--')) {
|
|
777
865
|
const eq = v.indexOf('=');
|
|
778
866
|
if (eq !== -1) v = v.slice(eq + 1);
|
|
779
|
-
|
|
780
|
-
v = v.slice(2);
|
|
867
|
+
return namesSecret(v);
|
|
781
868
|
}
|
|
782
|
-
return v;
|
|
869
|
+
if (!/^-[^-]./.test(v)) return namesSecret(v);
|
|
870
|
+
if (namesSecret(v.slice(2))) return true;
|
|
871
|
+
let end = 1;
|
|
872
|
+
while (end < v.length && CLUSTER_FLAG_CHAR_RE.test(v[end])) end++;
|
|
873
|
+
if (end > 2 && namesSecret(v.slice(end))) return true;
|
|
874
|
+
return v[end] === '=' && namesSecret(v.slice(end + 1));
|
|
783
875
|
}
|
|
784
876
|
|
|
785
877
|
const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
@@ -789,18 +881,55 @@ const DASH_C_RE = /^-[A-Za-z]*c$/;
|
|
|
789
881
|
|
|
790
882
|
const GROUPING_SEPS = new Set(['(', ')', '{', '}']);
|
|
791
883
|
|
|
884
|
+
// How many words the option `word` occupies for wrapper `spec`: 2 when it
|
|
885
|
+
// takes a separate value word, else 1.
|
|
886
|
+
function wrapperOptionWidth(word, spec) {
|
|
887
|
+
if (word.startsWith('--')) {
|
|
888
|
+
if (word.includes('=')) return 1;
|
|
889
|
+
// getopt_long accepts any unambiguous prefix (`--adj 5`).
|
|
890
|
+
return word.length > 2 && (spec.long || []).some((l) => l.startsWith(word)) ? 2 : 1;
|
|
891
|
+
}
|
|
892
|
+
const valued = spec.short || '';
|
|
893
|
+
for (let i = 1; i < word.length; i++) {
|
|
894
|
+
if (valued.includes(word[i])) return i === word.length - 1 ? 2 : 1; // `-n5` carries its value
|
|
895
|
+
}
|
|
896
|
+
return 1;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Index of the first word after a wrapper's own options, assignments and
|
|
900
|
+
// positional operands (`-n 5`, `-i A=1`, `-s KILL 5`, `--`).
|
|
901
|
+
function skipWrapperArgs(words, start, spec) {
|
|
902
|
+
let idx = start;
|
|
903
|
+
let optionsEnded = false;
|
|
904
|
+
while (idx < words.length) {
|
|
905
|
+
const text = words[idx].text;
|
|
906
|
+
if (spec.assigns && ASSIGNMENT_RE.test(text)) {
|
|
907
|
+
idx++;
|
|
908
|
+
} else if (!optionsEnded && text === '--') {
|
|
909
|
+
optionsEnded = true;
|
|
910
|
+
idx++;
|
|
911
|
+
} else if (!optionsEnded && text.length > 1 && text.startsWith('-')) {
|
|
912
|
+
idx += wrapperOptionWidth(text, spec);
|
|
913
|
+
} else {
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (!spec.operands) return idx;
|
|
918
|
+
idx = Math.min(idx + spec.operands, words.length);
|
|
919
|
+
// `timeout 5 -- cmd`: some getopt builds still accept `--` after DURATION.
|
|
920
|
+
return idx < words.length && words[idx].text === '--' ? idx + 1 : idx;
|
|
921
|
+
}
|
|
922
|
+
|
|
792
923
|
// Command base + operands after leading `VAR=val` assignments and prefix
|
|
793
|
-
// wrappers (`sudo`, `env VAR=x`,
|
|
924
|
+
// wrappers with their own options (`sudo -u root`, `env -i VAR=x`,
|
|
925
|
+
// `timeout -s KILL 5`, `nice -n 5`, …), or null when nothing but prefixes remain.
|
|
794
926
|
function resolveCommand(words) {
|
|
795
927
|
let idx = 0;
|
|
796
928
|
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
|
|
797
929
|
while (idx < words.length) {
|
|
798
|
-
const
|
|
799
|
-
if (!
|
|
800
|
-
idx
|
|
801
|
-
if (base === 'env') {
|
|
802
|
-
while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
|
|
803
|
-
}
|
|
930
|
+
const spec = WRAPPERS.get(lastSegment(words[idx].text).toLowerCase());
|
|
931
|
+
if (!spec) break;
|
|
932
|
+
idx = skipWrapperArgs(words, idx + 1, spec);
|
|
804
933
|
}
|
|
805
934
|
if (idx >= words.length) return null;
|
|
806
935
|
return { base: lastSegment(words[idx].text).toLowerCase(), operands: words.slice(idx + 1) };
|
|
@@ -962,6 +1091,26 @@ function precedingOp(s, bySeg, sepAfter) {
|
|
|
962
1091
|
return { op, prevSeg: p };
|
|
963
1092
|
}
|
|
964
1093
|
|
|
1094
|
+
// #5513: the same scan under the other readings of a heredoc: bash 3.2.57's `$( )` span
|
|
1095
|
+
// (bodies not skipped) and the earlier tag reading (tag ends at any whitespace). Only a
|
|
1096
|
+
// command that opens a heredoc can read differently, so the extra passes are skipped for
|
|
1097
|
+
// every other command.
|
|
1098
|
+
function findSecretReadUnderOtherReadings(command) {
|
|
1099
|
+
if (!command.includes('<<')) return null;
|
|
1100
|
+
for (const [bodiesSkipped, tagsAsWords] of [[false, true], [true, false]]) {
|
|
1101
|
+
heredocBodiesSkippedInSpans = bodiesSkipped;
|
|
1102
|
+
heredocTagsAsWords = tagsAsWords;
|
|
1103
|
+
try {
|
|
1104
|
+
const hit = findSecretRead(command, 0);
|
|
1105
|
+
if (hit) return hit;
|
|
1106
|
+
} finally {
|
|
1107
|
+
heredocBodiesSkippedInSpans = true;
|
|
1108
|
+
heredocTagsAsWords = true;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
965
1114
|
// Returns the offending token text, or null.
|
|
966
1115
|
function findSecretRead(command, depth) {
|
|
967
1116
|
const { tokens, nested } = tokenize(command);
|
|
@@ -1040,7 +1189,7 @@ function findSecretRead(command, depth) {
|
|
|
1040
1189
|
const hit = findSecretRead(src, depth + 1);
|
|
1041
1190
|
if (hit) return hit;
|
|
1042
1191
|
}
|
|
1043
|
-
} else if (
|
|
1192
|
+
} else if (operandNamesSecret(w.text)) return w.text;
|
|
1044
1193
|
}
|
|
1045
1194
|
continue;
|
|
1046
1195
|
}
|
|
@@ -1070,7 +1219,7 @@ function findSecretRead(command, depth) {
|
|
|
1070
1219
|
const nameOnly = nameOnlyOperandIndices(base, operands);
|
|
1071
1220
|
for (let k = 0; k < operands.length; k++) {
|
|
1072
1221
|
if (nameOnly.has(k)) continue;
|
|
1073
|
-
if (
|
|
1222
|
+
if (operandNamesSecret(operands[k].text)) return operands[k].text;
|
|
1074
1223
|
}
|
|
1075
1224
|
}
|
|
1076
1225
|
return null;
|
|
@@ -1088,21 +1237,6 @@ function isDebuggerAgent(value) {
|
|
|
1088
1237
|
return value.slice(value.lastIndexOf(':') + 1) === DEBUGGER_AGENT_NAME;
|
|
1089
1238
|
}
|
|
1090
1239
|
|
|
1091
|
-
// `timeout [flags] DURATION cmd …` is not in CMD_PREFIXES because its first
|
|
1092
|
-
// operand is a duration, not the command. Returns the command that follows it.
|
|
1093
|
-
function unwrapTimeout(cmd) {
|
|
1094
|
-
let { base, operands } = cmd;
|
|
1095
|
-
while (base === 'timeout') {
|
|
1096
|
-
let k = 0;
|
|
1097
|
-
while (k < operands.length && operands[k].text.startsWith('-')) k++;
|
|
1098
|
-
k++; // the DURATION operand
|
|
1099
|
-
const rest = resolveCommand(operands.slice(k));
|
|
1100
|
-
if (!rest) return null;
|
|
1101
|
-
({ base, operands } = rest);
|
|
1102
|
-
}
|
|
1103
|
-
return { base, operands };
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
1240
|
// Returns the `--agent` value text of a debugger launch, or null.
|
|
1107
1241
|
function findDebuggerCliSpawn(command, depth) {
|
|
1108
1242
|
const { tokens, nested } = tokenize(command);
|
|
@@ -1134,8 +1268,7 @@ function findDebuggerCliSpawn(command, depth) {
|
|
|
1134
1268
|
}
|
|
1135
1269
|
if (!words.length) continue;
|
|
1136
1270
|
|
|
1137
|
-
const
|
|
1138
|
-
const cmd = first && unwrapTimeout(first);
|
|
1271
|
+
const cmd = resolveCommand(words);
|
|
1139
1272
|
if (!cmd) continue;
|
|
1140
1273
|
const { base, operands } = cmd;
|
|
1141
1274
|
|
|
@@ -1248,7 +1381,7 @@ function scanXargsPipe(operands, s, bySeg, sepAfter, depth) {
|
|
|
1248
1381
|
const prevCmd = resolveCommand((bySeg.get(prevSeg) || []).filter((t) => t.kind === 'word'));
|
|
1249
1382
|
if (!prevCmd) return null;
|
|
1250
1383
|
for (const w of prevCmd.operands) {
|
|
1251
|
-
if (
|
|
1384
|
+
if (operandNamesSecret(w.text)) return w.text;
|
|
1252
1385
|
}
|
|
1253
1386
|
return null;
|
|
1254
1387
|
}
|
|
@@ -1423,7 +1556,7 @@ process.stdin.on('end', () => {
|
|
|
1423
1556
|
const command = typeof data.tool_input.command === 'string' ? data.tool_input.command : '';
|
|
1424
1557
|
if (command === '') allow(undefined);
|
|
1425
1558
|
if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
|
|
1426
|
-
const hit = findSecretRead(command, 0);
|
|
1559
|
+
const hit = findSecretRead(command, 0) || findSecretReadUnderOtherReadings(command);
|
|
1427
1560
|
if (hit !== null) emitBlock('secret-read', tool, hit);
|
|
1428
1561
|
const spawn = findDebuggerCliSpawn(command, 0);
|
|
1429
1562
|
if (spawn !== null) emitBlock('debugger-cli-spawn', tool, spawn);
|
|
@@ -889,8 +889,40 @@ function compactModelName(name) {
|
|
|
889
889
|
// are a fixed array, and the workspace dir is passed via -C. Fails silently
|
|
890
890
|
// (segment absent) outside a repo, without git, or on timeout.
|
|
891
891
|
|
|
892
|
+
// #5048: bound for the `git status` spawn below. With GIT_OPTIONAL_LOCKS=0 the
|
|
893
|
+
// spawn no longer writes the refreshed index back, so what is left is reading
|
|
894
|
+
// the index and stat-ing the tracked paths. The bound keeps a hung network
|
|
895
|
+
// filesystem from stalling a render for long while leaving room for a cold stat
|
|
896
|
+
// cache on a large repo. The figure was chosen, not measured: time
|
|
897
|
+
// `git status --porcelain=v2 --branch` on a large tree with a cold cache before
|
|
898
|
+
// changing it. Exported so the test can pin the bound instead of restating the
|
|
899
|
+
// literal (which is how the constant and its assertion drifted before).
|
|
892
900
|
const GIT_STATUS_TIMEOUT_MS = 1500;
|
|
893
901
|
|
|
902
|
+
/**
|
|
903
|
+
* #5048: env for the read-only git spawn in this file.
|
|
904
|
+
*
|
|
905
|
+
* By default `git status` refreshes the index and takes an *optional*
|
|
906
|
+
* `.git/index.lock` to write the refreshed copy back. That is a write the
|
|
907
|
+
* caller never asked for, and it turns a pure read into a contender for the
|
|
908
|
+
* lock a real `git add` / `git commit` needs — a read-only statusline render
|
|
909
|
+
* can then make a concurrent commit fail with
|
|
910
|
+
* `Unable to create '.git/index.lock': File exists`. The statusline spawns
|
|
911
|
+
* git on every render, so the lock is re-created continuously.
|
|
912
|
+
*
|
|
913
|
+
* The env block itself is owned by src/read-only-git-env.cts and shared with
|
|
914
|
+
* every other read-only seam (`execGit` in src/shell-command-projection.cts,
|
|
915
|
+
* `readGitSignals` in src/smart-entry.cts, `gitExec` in
|
|
916
|
+
* src/pristine-baseline.cts, the two blocking guards). This hook reads it per
|
|
917
|
+
* call rather than snapshotting at load, so a later `process.env` mutation is
|
|
918
|
+
* visible and the seams behave identically.
|
|
919
|
+
*
|
|
920
|
+
* Deliberately NOT applied to the `git rev-list` spawn in
|
|
921
|
+
* readStateHeadCommits(): that walks the commit graph and never opens the
|
|
922
|
+
* index, so the variable would be a no-op there.
|
|
923
|
+
*/
|
|
924
|
+
const { readOnlyGitEnv } = require('../gsd-core/bin/lib/read-only-git-env.cjs');
|
|
925
|
+
|
|
894
926
|
/**
|
|
895
927
|
* Run `git status --porcelain=v2 --branch` in dir.
|
|
896
928
|
* Returns raw stdout, or null when git is missing, dir isn't a repo, or the
|
|
@@ -902,7 +934,7 @@ function readGitStatus(dir) {
|
|
|
902
934
|
// or untracked files; overflow still degrades safely to segment-absent via
|
|
903
935
|
// the catch below.
|
|
904
936
|
return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
|
|
905
|
-
{ encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
937
|
+
{ encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env: readOnlyGitEnv() });
|
|
906
938
|
} catch (e) {
|
|
907
939
|
return null;
|
|
908
940
|
}
|
|
@@ -995,7 +1027,7 @@ function isValidStateHeadStamp(raw) {
|
|
|
995
1027
|
*/
|
|
996
1028
|
function readStateHeadCommits(root, stamp) {
|
|
997
1029
|
try {
|
|
998
|
-
return childProcess.execFileSync('git',
|
|
1030
|
+
return childProcess.execFileSync('git', // allow-index-refresh: rev-list walks the commit graph and never opens the index
|
|
999
1031
|
['-C', root, 'rev-list', '--left-right', '--count', `${stamp}...HEAD`],
|
|
1000
1032
|
{ encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
|
|
1001
1033
|
stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
@@ -1396,6 +1428,7 @@ module.exports = {
|
|
|
1396
1428
|
isAutoCompactDisabled, AUTO_COMPACT_DISABLE_ENV_KEYS, AUTO_COMPACT_ENV_TRUTHY,
|
|
1397
1429
|
resolveAutoCompactThreshold, contextMeter, SETTINGS_FILE_MAX_BYTES,
|
|
1398
1430
|
parseAutoCompactWindowEnv, canonicalModelKey, isAutoCompactWindowValue,
|
|
1431
|
+
GIT_STATUS_TIMEOUT_MS,
|
|
1399
1432
|
};
|
|
1400
1433
|
|
|
1401
1434
|
/**
|
|
@@ -49,8 +49,35 @@ const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib
|
|
|
49
49
|
// timeout does. The guard keeps its fail-open posture either way.
|
|
50
50
|
const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: BLOCKING_GUARD_PROBE_TIMEOUT_MS, windowsHide: true };
|
|
51
51
|
|
|
52
|
+
// #5048: GIT_OPTIONAL_LOCKS=0 on every git call from this hook. `git rev-parse`
|
|
53
|
+
// never opens the index, so this is inert for today's callers; it is set anyway
|
|
54
|
+
// so the read-only invariant has no holes if a `status` / `diff` caller is ever
|
|
55
|
+
// added here. git refreshes the index and takes an *optional*
|
|
56
|
+
// `.git/index.lock` on some reads, and this hook runs on every tool call, so a
|
|
57
|
+
// read here can otherwise fail a concurrent `git add` / `git commit` with
|
|
58
|
+
// `Unable to create '.git/index.lock': File exists`.
|
|
59
|
+
//
|
|
60
|
+
// The env block is owned by src/read-only-git-env.cts and shared with every
|
|
61
|
+
// other read-only seam (built artifact, not a sibling hooks/-tree file — same
|
|
62
|
+
// rule hooks/lib/git-cmd.js follows for token-scanner). It is read per spawn,
|
|
63
|
+
// not snapshotted at load, so the two guards behave identically.
|
|
64
|
+
// Parity: tests/git-optional-locks-parity.test.cjs.
|
|
65
|
+
// A raw plugin-marketplace or git-clone install never ran `npm run build:lib`
|
|
66
|
+
// (#3582), so the compiled owner can be absent. This guard only runs
|
|
67
|
+
// `git rev-parse`, which never opens the index, so the unmodified environment
|
|
68
|
+
// loses nothing; requiring the artifact unguarded would throw at load and the
|
|
69
|
+
// containment guard would silently stop running.
|
|
70
|
+
let readOnlyGitEnv = () => process.env;
|
|
71
|
+
try {
|
|
72
|
+
const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
|
|
73
|
+
ensureRuntimeBuild();
|
|
74
|
+
({ readOnlyGitEnv } = require('../gsd-core/bin/lib/read-only-git-env.cjs'));
|
|
75
|
+
} catch {
|
|
76
|
+
// artifact absent and not buildable: keep the inert fallback above
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
function git(args, cwd) {
|
|
53
|
-
return spawnSync('git', args, { ...SPAWNOPT, cwd });
|
|
80
|
+
return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv() });
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
// Walk up from `start` to find the nearest existing DIRECTORY (not merely an
|