@opengsd/gsd-core 1.20.1 → 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 (135) 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 +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -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) {
@@ -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;
@@ -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
  }
@@ -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