@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
@@ -78,6 +78,19 @@
78
78
  // scans a `<( )` operand; and `find … | xargs cat` infers the upstream
79
79
  // segment's names as the sub-command's read operands.
80
80
  //
81
+ // Project exemptions (#4432): `hooks.secret_read_exempt` in the root project's
82
+ // .planning/config.json lists repo-relative files that stay readable. An entry
83
+ // takes effect only for a file that is a regular file on disk AND a regular
84
+ // blob (100644/100755) in the commit HEAD names — read from `.git` directly,
85
+ // no subprocess, no timer (hooks/lib/git-head-tree.js). Read and Grep honor an
86
+ // absolute or cwd-relative path; Bash honors the absolute path only, and only
87
+ // when EVERY command on the line is one of cat/head/tail/grep/egrep/fgrep/rg/
88
+ // wc/diff/cmp/sort/uniq/cut/nl/od (after wrapper resolution) with no `$( )`/
89
+ // backtick/`<( )` body, heredoc or file-writing redirect or option; any other
90
+ // command (mv, ln, cp, git, sh, awk, sed, …) withdraws it for the whole line. A Grep
91
+ // `glob` is never widened, and a colon-joined word (`HEAD:.env.production`)
92
+ // never matches. An unreadable config or repository means no exemption.
93
+ //
81
94
  // Grep globs are judged per brace alternative (never on the whole glob, so
82
95
  // `{.env.local,zzz.ts}` cannot hide behind a benign sibling): a pure-wildcard
83
96
  // alternative (`*`, `**`) is allowed — Grep already skips gitignored files,
@@ -107,7 +120,16 @@
107
120
  // destination exemption can move a link prepared under a non-secret name
108
121
  // onto a secret name (`ln -s .env.example l && mv l .env`), so a later
109
122
  // edit of `.env` lands in the link target — a write redirection, the
110
- // out-of-scope write class, not a read.)
123
+ // out-of-scope write class, not a read. #4432 adds three stated limits to
124
+ // the project exemptions: (1) an agent with Bash can rewrite local git state
125
+ // (force-add + commit, `update-ref`) to make a file committed; the
126
+ // committed-file rule stops the one-step "edit the config, then read" path
127
+ // and is the same class as the interpreter one-liner gap above — a
128
+ // guardrail, not a sandbox. (2) The guard vouches for the path, not for
129
+ // working-tree bytes that differ from HEAD; a content-hash check would block
130
+ // re-reading the template after each edit, the flow the issue asks for.
131
+ // (3) A file inside a git submodule cannot be exempted: the submodule's
132
+ // `.git` is a file the reader does not follow.)
111
133
  //
112
134
  // A second, unrelated contract is enforced on the Bash leg because this is the
113
135
  // one hook that already parses EVERY Bash command (#5214): a Bash command that
@@ -148,6 +170,95 @@ const MAX_NESTING_DEPTH = 3;
148
170
  // Brace-alternative budget for a Grep glob before it is denied as too complex.
149
171
  const MAX_GLOB_ALTERNATIVES = 64;
150
172
 
173
+ // #4432: project exemptions (`hooks.secret_read_exempt` in the ROOT project's
174
+ // .planning/config.json). `root`/`allowRelative` are set once per invocation by
175
+ // the stdin handler; `checker` is built lazily on the first secret-classified
176
+ // token. Any failure while building or calling it means NOT exempt — never an
177
+ // allow, and never a throw into the fail-open crash path.
178
+ const exemption = { root: null, allowRelative: false, checker: null, configured: false, bashLine: null, bashReadOnly: null };
179
+
180
+ // F3: a Bash exemption holds only when every command on the line is one of
181
+ // these read-only readers; any other command anywhere on the line withdraws it
182
+ // for every token (`mv a b && cat <exempt>` re-anchors or launders the name).
183
+ const READ_ONLY_COMMANDS = new Set([
184
+ 'cat', 'head', 'tail', 'grep', 'egrep', 'fgrep', 'rg', 'wc', 'diff', 'cmp', 'sort', 'uniq', 'cut', 'nl', 'od',
185
+ ]);
186
+
187
+ // Per-command options that write a file or run a program, so the command is
188
+ // not read-only: `sort -o f`, `sort --compress-program`, `rg --pre`.
189
+ function readOnlyOptionsOnly(base, operands) {
190
+ let positional = 0;
191
+ for (const w of operands) {
192
+ const t = w.text;
193
+ if (base === 'sort' && (/^-[A-Za-z]*o/.test(t) || /^--(output|compress-program|files0-from)/.test(t))) return false;
194
+ if (base === 'rg' && /^--(pre|pre-glob|hostname-bin)(=|$)/.test(t)) return false;
195
+ if (base === 'uniq' && !t.startsWith('-')) positional++;
196
+ }
197
+ return base !== 'uniq' || positional <= 1; // `uniq in out` writes `out`
198
+ }
199
+
200
+ // Fail closed: a substitution body, heredoc, process substitution, file
201
+ // redirect out, unresolved or unlisted command word, or any exception is false.
202
+ function lineIsReadOnly(command) {
203
+ try {
204
+ const { tokens, nested } = tokenize(command);
205
+ if (nested.length > 0) return false;
206
+ const bySeg = new Map();
207
+ for (const t of tokens) {
208
+ if (t.kind === 'heredoc') return false;
209
+ if (t.kind === 'sep') continue;
210
+ if (t.kind === 'word' && t.procsub !== undefined) return false;
211
+ if (!bySeg.has(t.seg)) bySeg.set(t.seg, []);
212
+ bySeg.get(t.seg).push(t);
213
+ }
214
+ for (const segTokens of bySeg.values()) {
215
+ const words = [];
216
+ for (let k = 0; k < segTokens.length; k++) {
217
+ const t = segTokens[k];
218
+ if (t.kind === 'op') {
219
+ if (t.dup) continue;
220
+ const target = segTokens[k + 1];
221
+ if (!target || target.kind !== 'word') return false;
222
+ k++;
223
+ if (!t.read && target.text !== '/dev/null') return false; // output to a file
224
+ if (t.text.endsWith('<<<')) return false;
225
+ continue;
226
+ }
227
+ words.push(t);
228
+ }
229
+ if (words.length === 0) return false;
230
+ const cmd = resolveCommand(words);
231
+ if (!cmd || !READ_ONLY_COMMANDS.has(cmd.base)) return false;
232
+ if (!readOnlyOptionsOnly(cmd.base, cmd.operands)) return false;
233
+ }
234
+ return true;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ function isExemptSecret(tok) {
241
+ if (exemption.root === null) return false;
242
+ if (exemption.bashLine !== null) {
243
+ if (exemption.bashReadOnly === null) exemption.bashReadOnly = lineIsReadOnly(exemption.bashLine);
244
+ if (exemption.bashReadOnly !== true) return false;
245
+ }
246
+ try {
247
+ if (exemption.checker === null) {
248
+ const lib = require('./lib/secret-read-exemptions.js');
249
+ const entries = lib.readExemptEntries(exemption.root);
250
+ exemption.configured = entries.length > 0;
251
+ exemption.checker = entries.length === 0
252
+ ? () => false
253
+ : lib.createExemptionChecker({ root: exemption.root, entries, allowRelative: exemption.allowRelative });
254
+ }
255
+ return exemption.checker(tok) === true;
256
+ } catch {
257
+ exemption.checker = () => false;
258
+ return false;
259
+ }
260
+ }
261
+
151
262
  // `.env.<suffix>` names that are templates, not secrets (case-insensitive).
152
263
  const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
153
264
 
@@ -163,8 +274,11 @@ const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist'
163
274
  // stdbuf(1), setsid(1) and bash's `command`/`exec`. A wrong entry fails open
164
275
  // (the option's value becomes the command word), so keep it to the man pages.
165
276
  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' }],
277
+ ['sudo', { short: 'aCcDghpRrTtUu', long: ['--auth-type', '--close-from', '--login-class', '--chdir', '--group', '--host', '--prompt', '--chroot', '--role', '--command-timeout', '--type', '--other-user', '--user'],
278
+ // No-value long options, so an exact one never prefix-matches a longer
279
+ // valued one (`--login` vs `--login-class`).
280
+ flags: ['--askpass', '--background', '--bell', '--edit', '--help', '--list', '--login', '--non-interactive', '--preserve-env', '--preserve-groups', '--remove-timestamp', '--reset-timestamp', '--set-home', '--shell', '--stdin', '--validate', '--version'] }],
281
+ ['doas', { short: 'aCu' }],
168
282
  ['env', { short: 'uCSaP', long: ['--unset', '--chdir', '--split-string', '--argv0'], assigns: true }],
169
283
  ['nice', { short: 'n', long: ['--adjustment'] }],
170
284
  ['nohup', {}],
@@ -309,7 +423,7 @@ function isSecretBasename(name) {
309
423
  // lower case once at the top so `.ENV` / `.Secrets` match on the
310
424
  // case-insensitive filesystems (macOS, Windows) where they ARE the secret file
311
425
  // — the same stance as the write guard's `/i` patterns.
312
- function namesSecret(tok) {
426
+ function namesSecretByName(tok) {
313
427
  if (typeof tok !== 'string' || tok === '') return false;
314
428
  const lower = tok.toLowerCase();
315
429
  if (isSecretBasename(lastSegment(lower))) return true;
@@ -317,6 +431,15 @@ function namesSecret(tok) {
317
431
  return colon !== -1 && isSecretBasename(lastSegment(lower.slice(colon + 1)));
318
432
  }
319
433
 
434
+ // A secret-by-name token is still allowed when the project listed that exact
435
+ // committed file under `hooks.secret_read_exempt` (#4432). The exemption is
436
+ // consulted only on the secret-classified branch, so an ordinary token costs
437
+ // no I/O.
438
+ function namesSecret(tok) {
439
+ if (!namesSecretByName(tok)) return false;
440
+ return !isExemptSecret(tok);
441
+ }
442
+
320
443
  // ---------------------------------------------------------------------------
321
444
  // Grep glob analysis
322
445
  // ---------------------------------------------------------------------------
@@ -886,7 +1009,9 @@ const GROUPING_SEPS = new Set(['(', ')', '{', '}']);
886
1009
  function wrapperOptionWidth(word, spec) {
887
1010
  if (word.startsWith('--')) {
888
1011
  if (word.includes('=')) return 1;
889
- // getopt_long accepts any unambiguous prefix (`--adj 5`).
1012
+ // getopt_long takes an exact match over any longer option it prefixes
1013
+ // (`sudo --login` is not `--login-class`), else any unambiguous prefix (`--adj 5`).
1014
+ if ((spec.flags || []).includes(word)) return 1;
890
1015
  return word.length > 2 && (spec.long || []).some((l) => l.startsWith(word)) ? 2 : 1;
891
1016
  }
892
1017
  const valued = spec.short || '';
@@ -1410,7 +1535,10 @@ function reasonFor(code, tool, target) {
1410
1535
  return `Secret read guard: ${tool} would read '${target}', which matches a protected secret-file ` +
1411
1536
  `pattern (${PATTERN_TEXT}). Secret values must not be read into the conversation. ` +
1412
1537
  'If you need a specific value, ask the user for it; if you need the variable NAMES, ' +
1413
- 'read the non-secret template (.env.example) instead.';
1538
+ 'read the non-secret template (.env.example) instead.' +
1539
+ (tool === 'Bash' && exemption.configured
1540
+ ? ' Files the project lists under hooks.secret_read_exempt are readable from Bash only by their absolute path.'
1541
+ : '');
1414
1542
  }
1415
1543
 
1416
1544
  // stdout gets the typed JSON block; stderr gets the plain reason string
@@ -1532,6 +1660,12 @@ process.stdin.on('end', () => {
1532
1660
  allow(undefined);
1533
1661
  }
1534
1662
 
1663
+ // #4432: exemptions resolve against the payload cwd (as the context
1664
+ // monitor does), else the process cwd. Bash honors absolute paths only: a
1665
+ // relative word there is re-anchored by cd/find/xargs/git -C.
1666
+ exemption.root = typeof data.cwd === 'string' && data.cwd !== '' ? data.cwd : process.cwd();
1667
+ exemption.allowRelative = tool === 'Read' || tool === 'Grep';
1668
+
1535
1669
  // Every payload field is read TYPED in a single statement (#2547 class):
1536
1670
  // `[]`/`{}` are truthy and a non-string degrades to '' here.
1537
1671
  if (tool === 'Read') {
@@ -1556,6 +1690,7 @@ process.stdin.on('end', () => {
1556
1690
  const command = typeof data.tool_input.command === 'string' ? data.tool_input.command : '';
1557
1691
  if (command === '') allow(undefined);
1558
1692
  if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
1693
+ exemption.bashLine = command;
1559
1694
  const hit = findSecretRead(command, 0) || findSecretReadUnderOtherReadings(command);
1560
1695
  if (hit !== null) emitBlock('secret-read', tool, hit);
1561
1696
  const spawn = findDebuggerCliSpawn(command, 0);
@@ -18,6 +18,7 @@ const os = require('os');
18
18
  // monkeypatching childProcess.execFileSync.
19
19
  const childProcess = require('child_process');
20
20
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
21
+ const { readConfigFileBounded } = require('./lib/config-read.js');
21
22
  const { gitEnv } = require('./lib/git-probe.js');
22
23
 
23
24
  // This hook's build-seam outer catch (require.main guard just below) has
@@ -81,7 +82,7 @@ function readGsdConfig(dir) {
81
82
  const candidate = path.join(current, '.planning', 'config.json');
82
83
  if (fs.existsSync(candidate)) {
83
84
  try {
84
- return JSON.parse(fs.readFileSync(candidate, 'utf8')) || {};
85
+ return JSON.parse(readConfigFileBounded(candidate, { required: true })) || {};
85
86
  } catch (e) {
86
87
  return {};
87
88
  }
@@ -254,7 +255,7 @@ const SETTINGS_FILE_MAX_BYTES = 1024 * 1024;
254
255
  function readSettingsFile(file) {
255
256
  let fd;
256
257
  try {
257
- fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
258
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOCTTY || 0));
258
259
  const st = fs.fstatSync(fd);
259
260
  if (!st.isFile() || st.size > SETTINGS_FILE_MAX_BYTES) return null;
260
261
  // One byte past the checked size, so growth after the check shows as a longer read.
@@ -22,6 +22,7 @@ const path = require('path');
22
22
  const { spawnSync } = require('child_process');
23
23
  const { tokenize, skipToSubcommand } = require('./lib/git-cmd.js');
24
24
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
25
+ const { readConfigFileBounded } = require('./lib/config-read.js');
25
26
  const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
26
27
 
27
28
  // This guard is almost entirely advisory (fail open — a broken advisory must
@@ -179,7 +180,7 @@ function workflowGuardEnabled(cwd) {
179
180
  const configPath = path.join(cwd, '.planning', 'config.json');
180
181
  if (!fs.existsSync(configPath)) return false;
181
182
  try {
182
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
183
+ const config = JSON.parse(readConfigFileBounded(configPath, { required: true }));
183
184
  return Boolean(config.hooks?.workflow_guard);
184
185
  } catch (e) {
185
186
  return false;
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+ // hooks/lib/config-read.js — the one bounded reader for a project config.json used by every
3
+ // hook (#5759). Hooks are standalone files staged at install, so this mirrors the built
4
+ // gsd-core/bin/lib/config-file-read.cjs (a test pins that the two caps agree) instead of
5
+ // requiring it.
6
+ //
7
+ // A config.json planted as a FIFO or device blocks a plain readFileSync forever, and a hook
8
+ // runs inside the user's session on every tool call. The file is opened non-blocking and
9
+ // checked through its own descriptor (O_NONBLOCK is absent on Windows, hence `|| 0`).
10
+ const fs = require('node:fs');
11
+
12
+ const CONFIG_MAX_BYTES = 1024 * 1024;
13
+
14
+ function coded(message, code) {
15
+ return Object.assign(new Error(message), { code });
16
+ }
17
+
18
+ /**
19
+ * The UTF-8 text of `file`. A file that is absent returns null, or — with `{ required: true }` —
20
+ * throws an error with code ENOENT, so a caller that already treats any throw as "no usable
21
+ * config" keeps its exact semantics. A FIFO/device (ENXIO), directory (EISDIR), file over
22
+ * CONFIG_MAX_BYTES (EFBIG) or any other failure always throws with `.code` set.
23
+ */
24
+ function readConfigFileBounded(file, options) {
25
+ let fd;
26
+ try {
27
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOCTTY || 0));
28
+ } catch (err) {
29
+ if (err && err.code === 'ENOENT' && !(options && options.required)) return null;
30
+ throw err;
31
+ }
32
+ try {
33
+ const st = fs.fstatSync(fd);
34
+ if (!st.isFile()) throw coded(`${file} is not a regular file`, st.isDirectory() ? 'EISDIR' : 'ENXIO');
35
+ if (st.size > CONFIG_MAX_BYTES) throw coded(`${file} exceeds ${CONFIG_MAX_BYTES} bytes`, 'EFBIG');
36
+ const buf = Buffer.allocUnsafe(st.size);
37
+ let off = 0;
38
+ while (off < st.size) {
39
+ const n = fs.readSync(fd, buf, off, st.size - off, off);
40
+ if (n <= 0) break;
41
+ off += n;
42
+ }
43
+ return buf.toString('utf8', 0, off);
44
+ } finally {
45
+ try { fs.closeSync(fd); } catch (e) { /* already closed */ }
46
+ }
47
+ }
48
+
49
+ module.exports = { CONFIG_MAX_BYTES, readConfigFileBounded };
@@ -49,6 +49,8 @@ const ARGUMENT_TAKING_FLAGS = new Set([
49
49
  '--man-path',
50
50
  '--info-path',
51
51
  '--list-cmds',
52
+ '--config-env', // config from an env var: `--config-env=k=V` or `--config-env k=V` (#5821)
53
+ '--attr-source', // tree-ish for attributes: `--attr-source=T` or `--attr-source T` (#5821)
52
54
  ]);
53
55
 
54
56
  /**
@@ -59,7 +61,7 @@ const BOOLEAN_FLAGS = new Set([
59
61
  '--no-replace-objects', '--bare',
60
62
  '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs',
61
63
  '--icase-pathspecs', '--no-optional-locks',
62
- '-P', '--no-lazy-fetch',
64
+ '-P', '--no-lazy-fetch', '--no-advice',
63
65
  '--version', '--help',
64
66
  ]);
65
67