@opengsd/gsd-core 1.20.0 → 1.21.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.
Files changed (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -29,6 +29,12 @@
29
29
  // of the same repo are separate directories on disk, so each gets its own
30
30
  // sentinel with no cross-worktree collision, no lock file needed.
31
31
  //
32
+ // #5682: the single slot above is last-writer-wins, so two orchestrators sharing one checkout
33
+ // overwrote each other's decision and the guard discarded the foreign record. The writer now ALSO
34
+ // records each plan-scoped decision at `<cwd>/.gsd/dispatch-isolation/<phase>--<plan>.json`
35
+ // (same payload, same cwd-derived location and forgery posture), and `readSentinel` consults the
36
+ // record keyed by the dispatch's own phase/plan first, falling back to the single slot.
37
+ //
32
38
  // #3045 SECURITY F3 (LOW, accepted risk — documented, not redesigned for):
33
39
  // the sentinel lives at a fixed, predictable, gitignored path inside the
34
40
  // PRIMARY checkout. An agent already running inside a legitimately isolated
@@ -64,6 +70,35 @@ const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'n
64
70
 
65
71
  const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.json');
66
72
 
73
+ // #5682: per-dispatch records, one file per `<phase>--<plan>` under this directory.
74
+ const KEYED_SENTINEL_DIR = path.join('.gsd', 'dispatch-isolation');
75
+ const SAFE_SENTINEL_KEY_RE = /^[A-Za-z0-9._-]{1,100}$/;
76
+
77
+ // #5758: a real sentinel payload is under 300 bytes. Anything larger is not one, and the cap bounds
78
+ // the read so a hostile or runaway record cannot be pulled into the hook.
79
+ const SENTINEL_MAX_BYTES = 4096;
80
+
81
+ /** #5682: strict allow-list for a phase/plan string that becomes a path segment. */
82
+ function isSafeSentinelKey(s) {
83
+ return typeof s === 'string' && SAFE_SENTINEL_KEY_RE.test(s) && s !== '.' && s !== '..';
84
+ }
85
+
86
+ /** #5682: keyed record path for a dispatch, or null unless BOTH phase and plan are safe keys. */
87
+ function keyedSentinelPath(root, phase, plan) {
88
+ if (!isSafeSentinelKey(phase) || !isSafeSentinelKey(plan)) return null;
89
+ return path.join(root, KEYED_SENTINEL_DIR, `${phase}--${plan}.json`);
90
+ }
91
+
92
+ /**
93
+ * #5682: phase-level keyed record path (`<phase>.json`, written when a decision carries no plan),
94
+ * or null unless the phase is a safe key. Two orchestrators recording plan-less decisions for
95
+ * different phases each keep their own file.
96
+ */
97
+ function phaseSentinelPath(root, phase) {
98
+ if (!isSafeSentinelKey(phase)) return null;
99
+ return path.join(root, KEYED_SENTINEL_DIR, `${phase}.json`);
100
+ }
101
+
67
102
  // #3045 SECURITY F2 fix: how long a written sentinel is trusted as "this
68
103
  // dispatch's decision" before a reader falls back to the conservative
69
104
  // registry+config check.
@@ -103,10 +138,10 @@ function sentinelPath(cwd) {
103
138
  /**
104
139
  * Resolve the project root a sentinel should be read from/written to, using
105
140
  * the SAME derivation gsd-tools.cjs's dispatcher applies to every `--cwd`
106
- * before invoking a route handler: `findProjectRoot(resolveMainWorktreeCwd(cwd))`
107
- * (gsd-core/bin/gsd-tools.cjs main(), :3506/:3603 — `record-dispatch-isolation`
108
- * and `dispatch-isolation` are not in SKIP_ROOT_RESOLUTION, so every write
109
- * goes through both steps).
141
+ * before invoking a route handler: `resolveProjectContext(cwd).projectRoot`
142
+ * (gsd-core/bin/gsd-tools.cjs main() — `record-dispatch-isolation` and
143
+ * `dispatch-isolation` are not in SKIP_ROOT_RESOLUTION, so every write goes
144
+ * through it).
110
145
  *
111
146
  * #3045 MINOR fix: the guard hooks previously read the sentinel from the raw
112
147
  * `data.cwd` / `workspace_roots[i]` the harness reports, with NO equivalent
@@ -119,7 +154,7 @@ function sentinelPath(cwd) {
119
154
  * written where it looked. Deriving both sides through this one function
120
155
  * closes that divergence.
121
156
  *
122
- * `findProjectRoot`/`resolveWorktreeRoot` are read from the sibling
157
+ * `resolveProjectContext` is read from the sibling
123
158
  * `gsd-core/bin/lib/*.cjs` modules staged alongside these hooks at install
124
159
  * time (same pattern the guard hooks already use for
125
160
  * capability-registry.cjs/runtime-name-policy.cjs) — two directories up from
@@ -145,10 +180,10 @@ function resolveSentinelRoot(cwd) {
145
180
  // cold-tree encounter.
146
181
  const { ensureRuntimeBuild } = require('../../gsd-core/bin/ensure-runtime-build.cjs');
147
182
  ensureRuntimeBuild();
148
- const { resolveWorktreeRoot } = require('../../gsd-core/bin/lib/worktree-safety.cjs');
149
- const { root } = resolveWorktreeRoot(cwd);
150
- const { findProjectRoot } = require('../../gsd-core/bin/lib/project-root.cjs');
151
- return findProjectRoot(root);
183
+ // #6043 (ADR-4908): the dispatcher resolves non-skip commands through the
184
+ // same seam, so the reader looks where the writer wrote.
185
+ const { resolveProjectContext } = require('../../gsd-core/bin/lib/project-context.cjs');
186
+ return resolveProjectContext(cwd).projectRoot;
152
187
  } catch {
153
188
  return cwd;
154
189
  }
@@ -174,14 +209,129 @@ function resolveSentinelRoot(cwd) {
174
209
  * per the repo's clock-seam convention, so staleness is testable without
175
210
  * asserting on wall-clock time.
176
211
  */
177
- function readSentinel(cwd, { clock = Date } = {}) {
212
+ function readSentinel(cwd, { clock = Date, dispatchIds = null } = {}) {
178
213
  const root = resolveSentinelRoot(cwd);
179
- let raw;
214
+
215
+ // #5682: candidates are the record keyed by this dispatch's own phase/plan, the phase-level
216
+ // record for its phase, and the single legacy slot. A keyed candidate counts only when it is
217
+ // well-formed and its contents agree with its own filename key; the legacy slot only when it
218
+ // applies to this dispatch. Among FRESH candidates the latest `writtenAt` wins, so a newer
219
+ // phase-level decision is not shadowed by an older plan-scoped one (and vice versa).
220
+ const gsdDir = path.join(root, '.gsd');
221
+ const keyedDirs = [gsdDir, path.join(root, KEYED_SENTINEL_DIR)];
222
+ const keyedCandidates = [];
223
+ if (dispatchIds) {
224
+ const planFile = keyedSentinelPath(root, dispatchIds.phase, dispatchIds.plan);
225
+ if (planFile) {
226
+ const c = parseSentinelFile(planFile, clock, keyedDirs);
227
+ if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === dispatchIds.plan) keyedCandidates.push(c);
228
+ }
229
+ const phaseFile = phaseSentinelPath(root, dispatchIds.phase);
230
+ if (phaseFile) {
231
+ const c = parseSentinelFile(phaseFile, clock, keyedDirs);
232
+ if (c.present && !c.malformed && c.phase === dispatchIds.phase && c.plan === null) keyedCandidates.push(c);
233
+ }
234
+ }
235
+
236
+ const legacy = parseSentinelFile(sentinelPath(root), clock, [gsdDir]);
237
+ const legacyApplies = legacy.present && !legacy.malformed && sentinelAppliesToDispatch(legacy, dispatchIds);
238
+
239
+ const fresh = keyedCandidates.filter((c) => !c.stale);
240
+ if (legacyApplies && !legacy.stale) fresh.push(legacy);
241
+ if (fresh.length > 0) return latestWritten(fresh);
242
+
243
+ // Nothing fresh: a stale keyed record still feeds the aged-none path; otherwise the legacy
244
+ // slot is returned exactly as before.
245
+ if (keyedCandidates.length > 0) return latestWritten(keyedCandidates);
246
+ return legacy;
247
+ }
248
+
249
+ /** The candidate with the greatest `writtenAt` (ties keep the earlier-listed, i.e. most specific). */
250
+ function latestWritten(candidates) {
251
+ return candidates.reduce((best, c) => (c.writtenAt > best.writtenAt ? c : best));
252
+ }
253
+
254
+ /**
255
+ * lstat of `dir` when it is a real directory, not a link (lstat does not follow); otherwise null.
256
+ * Never throws.
257
+ */
258
+ function lstatRealDirectory(dir) {
180
259
  try {
181
- raw = fs.readFileSync(sentinelPath(root), 'utf-8');
260
+ // bigint: an NTFS file id is 64 bits and a JS number keeps 53, so ids are compared exactly.
261
+ const st = fs.lstatSync(dir, { bigint: true });
262
+ return st.isDirectory() && !st.isSymbolicLink() ? st : null;
182
263
  } catch {
183
- return { present: false };
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /** Same filesystem object: equal dev and ino. A zero ino (some Windows volumes) cannot be compared. */
269
+ function sameObject(a, b) {
270
+ return a.ino === 0n || b.ino === 0n || (a.dev === b.dev && a.ino === b.ino);
271
+ }
272
+
273
+ /**
274
+ * #5758: read one sentinel file as a bounded, non-blocking, link-refusing read of a regular file.
275
+ * Returns the text, or null for EVERY refusal (missing, link, FIFO, device, directory, oversized,
276
+ * short read, a hard link to another file, a parent directory that is a link), so a hostile record
277
+ * is exactly as absent as a missing one. `readFileSync` would block forever on a FIFO with no
278
+ * writer, follow a link to an endless device or to a file outside the project, and read without
279
+ * bound. The writers only ever produce a fresh temp file renamed into place (link count 1). A
280
+ * sentinel renamed over between the open and the fstat reads as nlink 0 and is refused for that one
281
+ * dispatch, which is the conservative no-sentinel decision.
282
+ *
283
+ * Parent directories are lstat-checked before the open and re-checked (same dev/ino, still a real
284
+ * directory) after it, and the opened file must be the object the pre-open lstat saw. Node has no
285
+ * openat, so this NARROWS the window for a parent swapped to a link between check and open; it does
286
+ * not close it.
287
+ */
288
+ function readSentinelText(file, parentDirs) {
289
+ const parents = [];
290
+ for (const dir of parentDirs) {
291
+ const st = lstatRealDirectory(dir);
292
+ if (!st) return null;
293
+ parents.push({ dir, st });
184
294
  }
295
+ let fd;
296
+ try {
297
+ const before = fs.lstatSync(file, { bigint: true });
298
+ if (!before.isFile() || before.size > BigInt(SENTINEL_MAX_BYTES)) return null;
299
+ // O_NONBLOCK makes an open of a FIFO swapped in after the lstat return at once; O_NOFOLLOW
300
+ // refuses a link swapped in. Windows lacks both flags (`|| 0`); lstat/fstat still guard.
301
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
302
+ const st = fs.fstatSync(fd, { bigint: true });
303
+ if (!st.isFile() || st.nlink !== 1n || st.size > BigInt(SENTINEL_MAX_BYTES)) return null;
304
+ if (!sameObject(before, st)) return null;
305
+ const size = Number(st.size);
306
+ for (const p of parents) {
307
+ const after = lstatRealDirectory(p.dir);
308
+ if (!after || !sameObject(p.st, after)) return null;
309
+ }
310
+ const buf = Buffer.alloc(size);
311
+ let offset = 0;
312
+ while (offset < size) {
313
+ const n = fs.readSync(fd, buf, offset, size - offset, offset);
314
+ if (n === 0) return null;
315
+ offset += n;
316
+ }
317
+ return buf.toString('utf-8');
318
+ } catch {
319
+ return null;
320
+ } finally {
321
+ if (fd !== undefined) {
322
+ try { fs.closeSync(fd); } catch { /* nothing left to release */ }
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Parse and validate one sentinel file. Never throws. Shape: see `readSentinel`.
329
+ * `parentDirs` are the directories between the project root and the file that must be real
330
+ * directories (not links); a refusal anywhere reads as `{ present: false }`.
331
+ */
332
+ function parseSentinelFile(file, clock, parentDirs = []) {
333
+ const raw = readSentinelText(file, parentDirs);
334
+ if (raw === null) return { present: false };
185
335
 
186
336
  let parsed;
187
337
  try {
@@ -348,7 +498,12 @@ function buildSentinelDiscard(sentinel, dispatchIds) {
348
498
  module.exports = {
349
499
  VALID_ISOLATION,
350
500
  SENTINEL_RELATIVE_PATH,
501
+ KEYED_SENTINEL_DIR,
502
+ isSafeSentinelKey,
503
+ keyedSentinelPath,
504
+ phaseSentinelPath,
351
505
  SENTINEL_STALE_MS,
506
+ SENTINEL_MAX_BYTES,
352
507
  AGED_NONE_MAX_MS,
353
508
  sentinelPath,
354
509
  resolveSentinelRoot,
@@ -0,0 +1,161 @@
1
+ // gsd-hook-version: {{GSD_VERSION}}
2
+ // Project exemptions for the secret-read guard (#4432).
3
+ //
4
+ // A project lists repo-relative files under `hooks.secret_read_exempt` in the
5
+ // ROOT `.planning/config.json`. A listed file stays readable only when it is
6
+ // BOTH a regular file in the working tree (lstat, so a symlink never counts)
7
+ // AND a regular blob (100644/100755) in the commit HEAD names. Config alone is
8
+ // not the key: an agent that can write the config must not be able to unlock a
9
+ // real secret that was never committed.
10
+ //
11
+ // The committed-file check reads `.git` directly (./git-head-tree.js): no
12
+ // subprocess and no timer, so the verdict depends only on the repository's
13
+ // bytes. Every failure means "not exempt"; nothing in this module throws.
14
+ //
15
+ // Standalone on purpose: hooks are installed as loose files and cannot require
16
+ // `src/`. The entry rules are duplicated in `src/config.cts`
17
+ // (`normalizeSecretReadExemptEntry`, used by config-set); tests/
18
+ // secret-read-exemptions.test.cjs drives one corpus through both so they
19
+ // cannot diverge.
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const { headTreeEntry } = require('./git-head-tree.js');
26
+
27
+ const REGULAR_BLOB_MODES = new Set(['100644', '100755']);
28
+
29
+ /**
30
+ * Normalize one config entry to a POSIX repo-relative file path, or null.
31
+ * `\` becomes `/` unconditionally; nothing is trimmed (whitespace is part of a
32
+ * name). Rejected: non-strings, absolute and UNC paths, drive prefixes, NUL, any
33
+ * `..` segment, a directory (trailing `/`), and `.`.
34
+ * @param {unknown} entry
35
+ * @returns {string | null}
36
+ */
37
+ function normalizeExemptEntry(entry) {
38
+ if (typeof entry !== 'string') return null;
39
+ let s = entry.replace(/\\/g, '/');
40
+ while (s.startsWith('./')) s = s.slice(2);
41
+ if (s === '' || s === '.') return null;
42
+ if (s.startsWith('/') || s.endsWith('/')) return null;
43
+ if (/^[A-Za-z]:/.test(s)) return null;
44
+ if (s.includes('\0')) return null;
45
+ if (s.split('/').includes('..')) return null;
46
+ return s;
47
+ }
48
+
49
+ /**
50
+ * The normalized, de-duplicated `hooks.secret_read_exempt` entries of
51
+ * `<root>/.planning/config.json`. Anything unreadable or malformed yields [].
52
+ * @param {string} root
53
+ * @param {{ readFile?: (file: string, encoding: string) => string }} [deps]
54
+ * @returns {string[]}
55
+ */
56
+ function readExemptEntries(root, { readFile = fs.readFileSync } = {}) {
57
+ let config;
58
+ try {
59
+ config = JSON.parse(readFile(path.join(root, '.planning', 'config.json'), 'utf8'));
60
+ } catch {
61
+ return [];
62
+ }
63
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) return [];
64
+ const hooks = config.hooks;
65
+ if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) return [];
66
+ const list = hooks.secret_read_exempt;
67
+ if (!Array.isArray(list)) return [];
68
+ const out = [];
69
+ for (const raw of list) {
70
+ const entry = normalizeExemptEntry(raw);
71
+ if (entry !== null && !out.includes(entry)) out.push(entry);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * True when HEAD holds `entry` (relative to `root`) as a regular blob.
78
+ * @param {string} root
79
+ * @param {string} entry
80
+ * @returns {boolean}
81
+ */
82
+ function isCommittedBlob(root, entry) {
83
+ const found = headTreeEntry(root, entry);
84
+ return found !== null && REGULAR_BLOB_MODES.has(found.mode);
85
+ }
86
+
87
+ /**
88
+ * True when `abs` is a regular file (lstat: a symlink is not). When `root` and
89
+ * `entry` are given, the real path of `abs` must also equal the real root joined
90
+ * with `entry`, so a symlinked PARENT directory (which lstat of the leaf does
91
+ * not see) is never exempt. A realpath failure means "not a match".
92
+ * @param {string} abs
93
+ * @param {string} [root]
94
+ * @param {string} [entry]
95
+ * @returns {boolean}
96
+ */
97
+ function isRegularFile(abs, root, entry) {
98
+ try {
99
+ if (!fs.lstatSync(abs).isFile()) return false;
100
+ if (typeof root !== 'string' || typeof entry !== 'string') return true;
101
+ const fold = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
102
+ // .native: canonical long form on Windows (see sameRealPath in git-head-tree.js).
103
+ return fold(fs.realpathSync.native(abs)) === fold(path.join(fs.realpathSync.native(root), entry));
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Build the per-invocation checker. Pure apart from the two injected probes,
111
+ * which are memoized per entry. A probe that throws means "not exempt".
112
+ * @returns {(token: unknown) => boolean}
113
+ */
114
+ function createExemptionChecker({
115
+ root,
116
+ entries,
117
+ allowRelative = false,
118
+ platform = process.platform,
119
+ isCommittedBlob: committed = isCommittedBlob,
120
+ isRegularFile: regular = isRegularFile,
121
+ }) {
122
+ const fold = platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
123
+ const targets = [];
124
+ for (const entry of Array.isArray(entries) ? entries : []) {
125
+ if (typeof entry === 'string' && entry !== '') targets.push({ entry, target: fold(path.resolve(root, entry)) });
126
+ }
127
+ const verdicts = new Map();
128
+
129
+ function verdictFor(entry) {
130
+ if (verdicts.has(entry)) return verdicts.get(entry);
131
+ let ok = false;
132
+ try {
133
+ ok = regular(path.resolve(root, entry), root, entry) === true && committed(root, entry) === true;
134
+ } catch {
135
+ ok = false;
136
+ }
137
+ verdicts.set(entry, ok);
138
+ return ok;
139
+ }
140
+
141
+ return function isExempt(token) {
142
+ if (typeof token !== 'string' || token === '' || targets.length === 0) return false;
143
+ if (!allowRelative && !path.isAbsolute(token)) return false;
144
+ // path.resolve would collapse `<symlinkdir>/../x` lexically while the OS
145
+ // reads through the link, so a `..` segment is refused before resolving.
146
+ if (token.split(/[\\/]/).includes('..')) return false;
147
+ const resolved = fold(path.resolve(root, token));
148
+ for (const { entry, target } of targets) {
149
+ if (resolved === target) return verdictFor(entry);
150
+ }
151
+ return false;
152
+ };
153
+ }
154
+
155
+ module.exports = {
156
+ normalizeExemptEntry,
157
+ readExemptEntries,
158
+ createExemptionChecker,
159
+ isCommittedBlob,
160
+ isRegularFile,
161
+ };
@@ -74,6 +74,7 @@ const os = require('os');
74
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
+ const { readConfigFileBounded } = require('./lib/config-read.js');
77
78
 
78
79
  // Required at module top, alongside the other ./lib requires — NOT behind
79
80
  // ensureRuntimeBuild() below. Terminating on a parse/timeout failure must
@@ -208,7 +209,7 @@ function resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidate
208
209
  // A throw here (corrupt JSON, EISDIR, permission error) propagates to the
209
210
  // caller's catch as a resolution failure — this function only decides
210
211
  // "confident vs not", never resolution failure.
211
- const raw = fs.readFileSync(configPath, 'utf-8');
212
+ const raw = readConfigFileBounded(configPath, { required: true });
212
213
  const parsed = JSON.parse(raw);
213
214
  if (parsed && typeof parsed === 'object' && 'runtime' in parsed) {
214
215
  const configRuntime = resolveRuntimeNameFromCandidates(parsed.runtime);
@@ -331,7 +332,7 @@ function resolveRegistryIsolation(cwd, configPath) {
331
332
  }
332
333
  if (!ladderAnswered) {
333
334
  try {
334
- const raw = fs.readFileSync(configPath, 'utf-8');
335
+ const raw = readConfigFileBounded(configPath, { required: true });
335
336
  const parsedCfg = JSON.parse(raw);
336
337
  if (parsedCfg && typeof parsedCfg === 'object' && parsedCfg.workflow &&
337
338
  typeof parsedCfg.workflow === 'object' && parsedCfg.workflow.use_worktrees === false) {
@@ -422,7 +423,7 @@ function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) {
422
423
  return { gsdProject: false, isolation: null, harnessFlag: null, error: null, sentinelDiscarded: null };
423
424
  }
424
425
 
425
- const sentinel = readSentinel(cwd, { clock });
426
+ const sentinel = readSentinel(cwd, { clock, dispatchIds });
426
427
  // Hoisted so the "sentinel was present/fresh but did not apply" case below
427
428
  // (#4594 row 15 — Postel's-Law finding) can distinguish itself from
428
429
  // "absent"/"stale" without re-deriving applicability.
@@ -18,9 +18,9 @@
18
18
  // Enabled for all Claude Code installs. This hook is always-on — it is a
19
19
  // no-op when .planning/config.json is absent (ENOENT → exit 0).
20
20
 
21
- const fs = require('fs');
22
21
  const path = require('path');
23
22
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
23
+ const { readConfigFileBounded } = require('./lib/config-read.js');
24
24
 
25
25
  // This hook only injects an advisory config-reload summary; a crash mid-parse
26
26
  // must not block the session or the FileChanged event that triggered it — the
@@ -70,7 +70,7 @@ process.stdin.on('end', () => {
70
70
  // Read the updated config file
71
71
  let config;
72
72
  try {
73
- const raw = fs.readFileSync(filePath, 'utf8');
73
+ const raw = readConfigFileBounded(filePath, { required: true });
74
74
  config = JSON.parse(raw);
75
75
  } catch (e) {
76
76
  if (e && e.code === 'ENOENT') allow(undefined);
@@ -28,6 +28,7 @@ const os = require('os');
28
28
  const path = require('path');
29
29
  const { spawn } = require('child_process');
30
30
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
31
+ const { readConfigFileBounded } = require('./lib/config-read.js');
31
32
 
32
33
  // This hook only injects an advisory context-usage warning; it never blocks
33
34
  // the tool call it rides in on. A crash here (e.g. a malformed bridge file)
@@ -334,7 +335,7 @@ const handleStdinEnd = () => {
334
335
  let thresholds = { warning: WARNING_THRESHOLD, critical: CRITICAL_THRESHOLD };
335
336
  try {
336
337
  const configPath = path.join(cwd, '.planning', 'config.json');
337
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
338
+ const config = JSON.parse(readConfigFileBounded(configPath, { required: true }));
338
339
  if (config.hooks?.context_warnings === false) {
339
340
  allow(undefined);
340
341
  }
@@ -65,6 +65,7 @@ const fs = require('fs');
65
65
  const path = require('path');
66
66
  const os = require('os');
67
67
  const { allow } = require('./lib/hook-exit.js');
68
+ const { readConfigFileBounded } = require('./lib/config-read.js');
68
69
 
69
70
  // Workspace resolution is shared across the Cursor hooks (#2587) — see
70
71
  // hooks/lib/cursor-workspace.js. Staged next to these scripts by
@@ -133,12 +134,12 @@ function realpathOrNull(p, realpathFn) {
133
134
  * i.e. `~/.cursor/worktrees` by default — `getGlobalConfigDir('cursor')`
134
135
  * honors the `CURSOR_CONFIG_DIR` env override and `~` expansion for free).
135
136
  * That is made NECESSARY AND SUFFICIENT below. Do NOT reinstate
136
- * `resolveWorktreeLinkage`'s `linked_worktree_root` mode as an alternative
137
+ * a linked-worktree result of `resolveProjectContext` as an alternative
137
138
  * OR'd proof of isolation — that is precisely the bypass finding 3 closed;
138
139
  * a future "simplification" that merges it back in re-opens unconsented
139
140
  * writes to the human's active checkout.
140
141
  *
141
- * `resolveWorktreeLinkage` is still called, but ONLY as a diagnostic: it
142
+ * `resolveProjectContext` is still called, but ONLY as a diagnostic: its provenance
142
143
  * distinguishes "confidently not isolated" from "git could not answer
143
144
  * (timeout) — cannot determine" so the eventual deny reason stays
144
145
  * actionable. Its result never flips `isolated`.
@@ -195,8 +196,10 @@ function resolveIsolationEvidence(root, { realpath = fs.realpathSync } = {}) {
195
196
  let linkageReason = null;
196
197
  try {
197
198
  // Sibling data/policy module, staged alongside this hook at install time.
198
- const { resolveWorktreeLinkage } = require('../gsd-core/bin/lib/worktree-safety.cjs');
199
- linkageReason = resolveWorktreeLinkage(root).reason;
199
+ const { resolveProjectContext } = require('../gsd-core/bin/lib/project-context.cjs');
200
+ const gitProvenance = resolveProjectContext(root).provenance;
201
+ // A degraded context (git timed out or could not run) is "cannot determine".
202
+ linkageReason = gitProvenance.degraded ? 'git_timed_out' : gitProvenance.reason;
200
203
  } catch {
201
204
  linkageReason = null;
202
205
  }
@@ -421,7 +424,7 @@ function resolveFallbackIsolation(root, configPath) {
421
424
  const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
422
425
 
423
426
  let runtimeId = resolveRuntimeNameFromCandidates(process.env.GSD_RUNTIME);
424
- const rawConfig = fs.readFileSync(configPath, 'utf-8');
427
+ const rawConfig = readConfigFileBounded(configPath, { required: true });
425
428
  const parsedConfig = JSON.parse(rawConfig);
426
429
  if (!runtimeId && parsedConfig && typeof parsedConfig === 'object' && 'runtime' in parsedConfig) {
427
430
  runtimeId = resolveRuntimeNameFromCandidates(parsedConfig.runtime) || null;
@@ -512,7 +515,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
512
515
  // Hoisted (readSentinel never throws) so the "present, fresh, but did not
513
516
  // apply" case (#4594 row 15) can be reported on every deny path below
514
517
  // instead of silently discarded.
515
- const sentinel = readSentinel(root, { clock });
518
+ const sentinel = readSentinel(root, { clock, dispatchIds });
516
519
  const applies = sentinelAppliesToDispatch(sentinel, dispatchIds);
517
520
  const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds);
518
521
 
@@ -22,8 +22,8 @@
22
22
  // hook source files — these legitimately contain injection-like strings.
23
23
 
24
24
  const path = require('path');
25
- const fs = require('fs');
26
25
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
26
+ const { readConfigFileBounded } = require('./lib/config-read.js');
27
27
 
28
28
  // This is a PostToolUse advisory scanner over content the tool call already
29
29
  // returned; a crash while scanning must not retroactively block that Read/
@@ -343,7 +343,7 @@ process.stdin.on('end', () => {
343
343
  try {
344
344
  const cfgBase = data.cwd || process.cwd();
345
345
  const cfgPath = path.join(cfgBase, '.planning', 'config.json');
346
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
346
+ const cfg = JSON.parse(readConfigFileBounded(cfgPath, { required: true }));
347
347
  blocking = cfg.security?.injection_blocking === true;
348
348
  } catch { /* no config ⇒ advisory */ }
349
349
  }