@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
@@ -172,22 +172,66 @@ function resolveSchemaDefault(cwd, kp) {
172
172
  return { found: false, value: undefined };
173
173
  }
174
174
  /**
175
- * Emit a schema-resolved default (#2256), applying the same secret-masking
176
- * invariant the found-key path applies. getCapabilityConfigSchema is a
177
- * federated, third-party-extensible surface (ADR-1244) — a future key-name
178
- * collision with a secret key must not leak a declared default in plaintext.
179
- * Centralizing emission here means masking can't be missed at a call site.
175
+ * A decimal number literal as a person types it: optional sign, digits with an optional
176
+ * fraction (`5`, `5.`, `.5`, `5.0`, `007`), optional exponent. No surrounding whitespace and
177
+ * no `0x`/`0b`/`0o` prefix, so `' '`, `' 5'` and `'0x10'` stay strings (#6029, owner ruling
178
+ * 2026-11-08). `Number()` alone accepted all three.
180
179
  */
181
- function emitResolvedDefault(kp, value, raw) {
182
- const resolvedValue = kp === 'planner.stall_detection_enabled'
180
+ const DECIMAL_LITERAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
181
+ /**
182
+ * #6029 (ADR-1411 Decision 3): the single argv value parser at the config CLI
183
+ * boundary, shared by `config-set` and `config-get --default`. Type-directed
184
+ * only: per-key setter policy (project_code verbatim, validators) and the
185
+ * setter's `null` = unset action (#2046) stay in cmdConfigSet. The numeric
186
+ * branch is the DECIMAL_LITERAL rule above (the one deliberate change from the
187
+ * pre-#6029 setter parser).
188
+ */
189
+ function parseConfigArgValue(val) {
190
+ if (val === 'true')
191
+ return true;
192
+ if (val === 'false')
193
+ return false;
194
+ if (val === 'null')
195
+ return null;
196
+ // #1581: Number.isFinite (not !isNaN) so 'Infinity' and an overflowing literal such as
197
+ // '1e400' are NOT coerced to non-finite numbers that JSON.stringify later renders as
198
+ // `null` (disk=null while the CLI echoed 'Infinity'). They stay strings, and in
199
+ // config-set the per-key validators then reject them with a non-zero exit.
200
+ if (DECIMAL_LITERAL.test(val) && Number.isFinite(Number(val)))
201
+ return Number(val);
202
+ if (val.startsWith('[') || val.startsWith('{')) {
203
+ try {
204
+ return JSON.parse(val);
205
+ }
206
+ catch { /* keep as string */ }
207
+ }
208
+ return val;
209
+ }
210
+ /**
211
+ * #6029 (ADR-1411 Decisions 3-4): the single emission projection for every
212
+ * `cmdConfigGet` arm. Order: the #4570 planner-watchdog resolver, then secret
213
+ * masking (the encoder never sees plaintext; getCapabilityConfigSchema is a
214
+ * federated, third-party-extensible surface (ADR-1244), so centralizing the
215
+ * masking here means it cannot be missed at a call site), then encoding.
216
+ * `json` is what JSON mode emits; `text` is what `--raw` emits. Raw is frozen:
217
+ * it is `sourceText` (the verbatim argv of `--default`) when the value reached
218
+ * here untransformed, else `String(value)`.
219
+ */
220
+ function encodeConfigValue(kp, value, sourceText) {
221
+ const resolved = kp === 'planner.stall_detection_enabled'
183
222
  ? resolvePlannerStallDetectionEnabled(value)
184
223
  : value;
185
224
  if ((0, secrets_cjs_1.isSecretKey)(kp)) {
186
- const masked = (0, secrets_cjs_1.maskSecret)(resolvedValue);
187
- output(masked, raw, masked);
188
- return;
225
+ const masked = (0, secrets_cjs_1.maskSecret)(resolved);
226
+ return { json: masked, text: masked };
189
227
  }
190
- output(resolvedValue, raw, String(resolvedValue));
228
+ const transformed = resolved !== value;
229
+ const text = sourceText !== undefined && !transformed ? sourceText : String(resolved);
230
+ return { json: resolved, text };
231
+ }
232
+ function emitConfigValue(kp, value, raw, sourceText) {
233
+ const { json, text } = encodeConfigValue(kp, value, sourceText);
234
+ output(json, raw, text);
191
235
  }
192
236
  // ─── Validation helpers ───────────────────────────────────────────────────────
193
237
  function validateKnownConfigKeyPath(keyPath) {
@@ -812,8 +856,10 @@ function loadConfigJson(cwd) {
812
856
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
813
857
  let config = {};
814
858
  try {
815
- if (node_fs_1.default.existsSync(configPath)) {
816
- config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
859
+ // Bounded read (#5759): null is an absent file; a FIFO/device/oversized file throws.
860
+ const raw = configLoader.readConfigFileBounded(configPath);
861
+ if (raw !== null) {
862
+ config = JSON.parse(raw);
817
863
  }
818
864
  }
819
865
  catch (err) {
@@ -949,27 +995,8 @@ function cmdConfigSet(cwd, keyPath, value, raw, options = {}) {
949
995
  const kp = keyPath;
950
996
  const val = value;
951
997
  assertKnownConfigKey(cwd, kp);
952
- // Parse value (handle booleans, numbers, and JSON arrays/objects)
953
- let parsedValue = val;
954
- if (val === 'true')
955
- parsedValue = true;
956
- else if (val === 'false')
957
- parsedValue = false;
958
- else if (val === 'null')
959
- parsedValue = null;
960
- // #1581: Number.isFinite (not !isNaN) so 'Infinity'/'-Infinity' are NOT
961
- // coerced to non-finite numbers that JSON.stringify later renders as `null`
962
- // (disk=null while the CLI echoed 'Infinity'). They fall through to the
963
- // JSON branch (which rejects them) and stay strings, then per-key validators
964
- // reject them with a non-zero exit.
965
- else if (Number.isFinite(Number(val)) && val !== '')
966
- parsedValue = Number(val);
967
- else if (typeof val === 'string' && (val.startsWith('[') || val.startsWith('{'))) {
968
- try {
969
- parsedValue = JSON.parse(val);
970
- }
971
- catch { /* keep as string */ }
972
- }
998
+ // Parse value (handle booleans, numbers, and JSON arrays/objects) — #6029: shared with config-get --default.
999
+ let parsedValue = parseConfigArgValue(val);
973
1000
  // #2046: a bare `null` unsets (deletes) the key — the documented "Clear" action.
974
1001
  // Short-circuits before every typed per-key validator so clearing a typed key
975
1002
  // (enum/boolean/number) removes it rather than being rejected. Deleting (not
@@ -1040,6 +1067,33 @@ function cmdConfigSet(cwd, keyPath, value, raw, options = {}) {
1040
1067
  }
1041
1068
  output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
1042
1069
  }
1070
+ /**
1071
+ * #4432: one `hooks.secret_read_exempt` entry as a POSIX repo-relative file
1072
+ * path, or null when it is not one. The secret-read guard
1073
+ * (hooks/lib/secret-read-exemptions.js `normalizeExemptEntry`) applies the SAME
1074
+ * rules when it reads the list, and is standalone because an installed hook
1075
+ * cannot require `src/`. The duplication is deliberate and pinned: a parity test
1076
+ * (tests/secret-read-exemptions.test.cjs) drives one corpus and a seeded
1077
+ * property through both, so a value accepted here is a value the hook honors.
1078
+ */
1079
+ function normalizeSecretReadExemptEntry(entry) {
1080
+ if (typeof entry !== 'string')
1081
+ return null;
1082
+ let s = entry.replace(/\\/g, '/');
1083
+ while (s.startsWith('./'))
1084
+ s = s.slice(2);
1085
+ if (s === '' || s === '.')
1086
+ return null;
1087
+ if (s.startsWith('/') || s.endsWith('/'))
1088
+ return null;
1089
+ if (/^[A-Za-z]:/.test(s))
1090
+ return null;
1091
+ if (s.includes('\0'))
1092
+ return null;
1093
+ if (s.split('/').includes('..'))
1094
+ return null;
1095
+ return s;
1096
+ }
1043
1097
  /**
1044
1098
  * The per-key VALUE rules, shared by `config-set` and `config-new-project`
1045
1099
  * (#5268). They were inline in `cmdConfigSet`, so `config-new-project` had no
@@ -1220,6 +1274,15 @@ function validateConfigValue(cwd, kp, parsedValue, val) {
1220
1274
  + `35/25 defaults. Use a value below 100.`);
1221
1275
  }
1222
1276
  }
1277
+ // #4432: files the secret-read guard may let agents read. Every entry must be
1278
+ // a repo-relative file path; the guard drops anything else when it reads the
1279
+ // list, so storing it would be accept-then-discard.
1280
+ if (kp === 'hooks.secret_read_exempt') {
1281
+ if (!Array.isArray(parsedValue) || parsedValue.some((entry) => normalizeSecretReadExemptEntry(entry) === null)) {
1282
+ error(`Invalid hooks.secret_read_exempt '${val}'. Must be an array of repo-relative file paths `
1283
+ + `(no leading '/', drive prefix, '..' segment or trailing '/'), e.g. [".env.production"].`);
1284
+ }
1285
+ }
1223
1286
  // Fallow scope + profile enum validation (#3424)
1224
1287
  const VALID_FALLOW_SCOPES = ['phase', 'repo'];
1225
1288
  if (kp === 'code_quality.fallow.scope')
@@ -1310,38 +1373,44 @@ function validateConfigValue(cwd, kp, parsedValue, val) {
1310
1373
  }
1311
1374
  function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
1312
1375
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
1313
- const hasDefault = defaultValue !== undefined;
1314
1376
  if (!keyPath) {
1315
1377
  error('Usage: config-get <key.path> [--default <value>]');
1316
1378
  }
1317
1379
  // After the error() guard, keyPath is narrowed to string.
1318
1380
  const kp = keyPath;
1381
+ /**
1382
+ * #2702 / #1893 / #2256: an absent key resolves root config first (a present root key wins
1383
+ * over --default), then --default (parsed by the config-set parser, raw bytes kept), then the
1384
+ * schema default. With none of them, fail with the caller's reason.
1385
+ */
1386
+ const emitAbsentKey = (message, reason) => {
1387
+ const rootVal = resolveFromRootConfig(cwd, kp);
1388
+ if (rootVal.found) {
1389
+ emitConfigValue(kp, rootVal.value, raw);
1390
+ return;
1391
+ }
1392
+ if (defaultValue !== undefined) {
1393
+ emitConfigValue(kp, parseConfigArgValue(defaultValue), raw, defaultValue);
1394
+ return;
1395
+ }
1396
+ const sd = resolveSchemaDefault(cwd, kp);
1397
+ if (sd.found) {
1398
+ emitConfigValue(kp, sd.value, raw);
1399
+ return;
1400
+ }
1401
+ error(message, reason);
1402
+ };
1319
1403
  let config = {};
1320
1404
  try {
1321
- if (node_fs_1.default.existsSync(configPath)) {
1322
- config = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
1405
+ // Bounded read (#5759): null is an absent file; a FIFO/device/oversized file throws and
1406
+ // is reported as an unreadable config below, never waited on.
1407
+ const rawConfig = configLoader.readConfigFileBounded(configPath);
1408
+ if (rawConfig !== null) {
1409
+ config = JSON.parse(rawConfig);
1323
1410
  }
1324
1411
  else {
1325
- // #2702: when a workstream is active and has no config.json of its own, fall
1326
- // back to the project ROOT config first — a key the user configured at root is
1327
- // a real, present value and must inherit (per #1893: a present key wins over
1328
- // --default). Only when root also misses do --default / schema default apply.
1329
- // (When no workstream is active, resolveFromRootConfig is a no-op: same file.)
1330
- const rootVal = resolveFromRootConfig(cwd, kp);
1331
- if (rootVal.found) {
1332
- emitResolvedDefault(kp, rootVal.value, raw);
1333
- return;
1334
- }
1335
- if (hasDefault) {
1336
- emitResolvedDefault(kp, defaultValue, raw);
1337
- return;
1338
- }
1339
- const sd = resolveSchemaDefault(cwd, kp);
1340
- if (sd.found) {
1341
- emitResolvedDefault(kp, sd.value, raw);
1342
- return;
1343
- }
1344
- error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
1412
+ emitAbsentKey('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
1413
+ return;
1345
1414
  }
1346
1415
  }
1347
1416
  catch (err) {
@@ -1364,22 +1433,8 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
1364
1433
  let current = config;
1365
1434
  for (const key of keys) {
1366
1435
  if (current === undefined || current === null || typeof current !== 'object') {
1367
- // #2702: root-config inheritance before --default / schema default (see above).
1368
- const rootVal = resolveFromRootConfig(cwd, kp);
1369
- if (rootVal.found) {
1370
- emitResolvedDefault(kp, rootVal.value, raw);
1371
- return;
1372
- }
1373
- if (hasDefault) {
1374
- emitResolvedDefault(kp, defaultValue, raw);
1375
- return;
1376
- }
1377
- const sd = resolveSchemaDefault(cwd, kp);
1378
- if (sd.found) {
1379
- emitResolvedDefault(kp, sd.value, raw);
1380
- return;
1381
- }
1382
- error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
1436
+ emitAbsentKey(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
1437
+ return;
1383
1438
  }
1384
1439
  // Own-property gate: bracket access on a plain object walks the
1385
1440
  // prototype chain, so an unqualified `current[key]` would resolve
@@ -1395,34 +1450,12 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
1395
1450
  : undefined;
1396
1451
  }
1397
1452
  if (current === undefined) {
1398
- // #2702: root-config inheritance before --default / schema default (see above).
1399
- const rootVal = resolveFromRootConfig(cwd, kp);
1400
- if (rootVal.found) {
1401
- emitResolvedDefault(kp, rootVal.value, raw);
1402
- return;
1403
- }
1404
- if (hasDefault) {
1405
- emitResolvedDefault(kp, defaultValue, raw);
1406
- return;
1407
- }
1408
- const sd = resolveSchemaDefault(cwd, kp);
1409
- if (sd.found) {
1410
- emitResolvedDefault(kp, sd.value, raw);
1411
- return;
1412
- }
1413
- error(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
1414
- }
1415
- if (kp === 'planner.stall_detection_enabled') {
1416
- current = resolvePlannerStallDetectionEnabled(current);
1417
- }
1418
- // Never echo plaintext for sensitive keys via config-get. Plaintext lives
1419
- // in config.json on disk; the CLI surface always shows the masked form.
1420
- if ((0, secrets_cjs_1.isSecretKey)(kp)) {
1421
- const masked = (0, secrets_cjs_1.maskSecret)(current);
1422
- output(masked, raw, masked);
1453
+ emitAbsentKey(`Key not found: ${kp}`, ERROR_REASON.CONFIG_KEY_NOT_FOUND);
1423
1454
  return;
1424
1455
  }
1425
- output(current, raw, String(current));
1456
+ // #6029: the present-key arm uses the same projection as every other arm
1457
+ // (planner resolver and secret masking included).
1458
+ emitConfigValue(kp, current, raw);
1426
1459
  }
1427
1460
  /**
1428
1461
  * #2702: resolve a dot-notation key against the project ROOT config
@@ -1450,9 +1483,11 @@ function resolveFromRootConfig(cwd, kp) {
1450
1483
  const rootConfigPath = node_path_1.default.join(root, 'config.json');
1451
1484
  let rootConfig;
1452
1485
  try {
1453
- if (!node_fs_1.default.existsSync(rootConfigPath))
1486
+ // Bounded read (#5759): null is an absent root config; a FIFO/device/oversized one throws.
1487
+ const rawRoot = configLoader.readConfigFileBounded(rootConfigPath);
1488
+ if (rawRoot === null)
1454
1489
  return { found: false, value: undefined };
1455
- rootConfig = JSON.parse(node_fs_1.default.readFileSync(rootConfigPath, 'utf-8'));
1490
+ rootConfig = JSON.parse(rawRoot);
1456
1491
  }
1457
1492
  catch {
1458
1493
  // Unparseable root config → don't inherit (do not let a corrupt root file
@@ -1603,6 +1638,8 @@ module.exports = {
1603
1638
  cmdConfigEnsureSection,
1604
1639
  cmdConfigSet,
1605
1640
  cmdConfigGet,
1641
+ parseConfigArgValue,
1642
+ encodeConfigValue,
1606
1643
  cmdConfigSetModelProfile,
1607
1644
  cmdConfigNewProject,
1608
1645
  cmdConfigPath,
@@ -1611,4 +1648,5 @@ module.exports = {
1611
1648
  setConfigValue,
1612
1649
  setConfigValues,
1613
1650
  isValidProtectedBranches,
1651
+ normalizeSecretReadExemptEntry,
1614
1652
  };
@@ -240,6 +240,45 @@ function mergeDefaults(parsed) {
240
240
  const defaults = structuredClone(CONFIG_DEFAULTS);
241
241
  return deepMergeConfig(defaults, parsed);
242
242
  }
243
+ /**
244
+ * Largest config.json migrateOnDisk will read — the same 1 MiB bound as CONFIG_MAX_BYTES in
245
+ * config-file-read.cts (#5759). Inlined, not imported: this module may not import a sibling
246
+ * (the #3571 contract above). A test pins that the two bounds agree.
247
+ */
248
+ const CONFIG_READ_MAX_BYTES = 1024 * 1024;
249
+ /**
250
+ * Read a config.json as a bounded regular file (#5759): non-blocking open, fstat on the
251
+ * descriptor, regular files up to CONFIG_READ_MAX_BYTES only. A FIFO/device would otherwise
252
+ * block open(2) forever. Throws on anything unreadable (ENOENT included); migrateOnDisk
253
+ * treats every throw as "nothing to migrate".
254
+ */
255
+ function readConfigBounded(filePath) {
256
+ const fd = (0, node_fs_1.openSync)(filePath, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NONBLOCK ?? 0) | (node_fs_1.constants.O_NOCTTY ?? 0));
257
+ try {
258
+ const st = (0, node_fs_1.fstatSync)(fd);
259
+ if (!st.isFile()) {
260
+ throw Object.assign(new Error(`${filePath} is not a regular file`), { code: st.isDirectory() ? 'EISDIR' : 'ENXIO' });
261
+ }
262
+ if (st.size > CONFIG_READ_MAX_BYTES) {
263
+ throw Object.assign(new Error(`${filePath} exceeds ${CONFIG_READ_MAX_BYTES} bytes`), { code: 'EFBIG' });
264
+ }
265
+ const buf = Buffer.allocUnsafe(st.size);
266
+ let off = 0;
267
+ while (off < st.size) {
268
+ const n = (0, node_fs_1.readSync)(fd, buf, off, st.size - off, off);
269
+ if (n <= 0)
270
+ break;
271
+ off += n;
272
+ }
273
+ return buf.toString('utf-8', 0, off);
274
+ }
275
+ finally {
276
+ try {
277
+ (0, node_fs_1.closeSync)(fd);
278
+ }
279
+ catch { /* best-effort */ }
280
+ }
281
+ }
243
282
  function migrateOnDisk(cwd, workstream, configPathOverride) {
244
283
  // #3749: the caller (cmdMigrateConfig in config.cts) supplies the config
245
284
  // path resolved through planning-workspace's PROJECT-aware planningDir.
@@ -250,10 +289,10 @@ function migrateOnDisk(cwd, workstream, configPathOverride) {
250
289
  const configPath = configPathOverride ?? (0, node_path_1.join)(planningDir(cwd, workstream), 'config.json');
251
290
  let raw;
252
291
  try {
253
- raw = (0, node_fs_1.readFileSync)(configPath, 'utf-8');
292
+ raw = readConfigBounded(configPath);
254
293
  }
255
294
  catch {
256
- // File missing — nothing to migrate
295
+ // File missing, unreadable, non-regular or oversized — nothing to migrate
257
296
  return { migrated: false, normalizations: [], wrote: null, skipped: [] };
258
297
  }
259
298
  const trimmed = raw.trim();