@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +234 -126
- package/commands/gsd/autonomous.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +450 -753
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +80 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +13 -11
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +11 -19
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +107 -31
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +91 -41
- package/gsd-core/bin/lib/state.cjs +446 -61
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +97 -17
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +3 -2
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +8 -5
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +97 -17
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Config File Read — the single bounded reader for a project or workstream config.json (#5759)
|
|
4
|
+
*
|
|
5
|
+
* A config.json can be planted as a FIFO or device: a plain readFileSync opens it
|
|
6
|
+
* blocking and waits forever for a writer. Every read of a config.json in src/ goes through
|
|
7
|
+
* `readConfigFileBounded`, so the loader, the resolve-model omit check, and the many
|
|
8
|
+
* direct "read one key" helpers cannot diverge on how a non-regular or oversized file is treated.
|
|
9
|
+
*
|
|
10
|
+
* Leaf module: it imports only node:fs and ./capability-ledger.cjs (itself a node-builtins-only leaf), so
|
|
11
|
+
* planning-workspace, config-loader and every other reader can import it without a require cycle.
|
|
12
|
+
*
|
|
13
|
+
* Dependencies (leaf modules only):
|
|
14
|
+
* - ./capability-ledger.cjs (readSmallRegularFile — non-blocking open, fstat regular-file check, size cap)
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
|
+
const capabilityLedgerMod = require("./capability-ledger.cjs");
|
|
22
|
+
const { readSmallRegularFile } = capabilityLedgerMod;
|
|
23
|
+
/**
|
|
24
|
+
* Largest config.json any reader will read (#5759). Real configs are a few KB; 1 MiB
|
|
25
|
+
* is generous enough that no legitimate config is refused. A larger file is reported
|
|
26
|
+
* unusable (EFBIG) rather than read whole.
|
|
27
|
+
*/
|
|
28
|
+
const CONFIG_MAX_BYTES = 1024 * 1024;
|
|
29
|
+
/**
|
|
30
|
+
* Read a config.json as a bounded regular file (#5759). Opens non-blocking and fstats the
|
|
31
|
+
* fd, so a FIFO or device with no writer is refused instead of blocking open(2) forever.
|
|
32
|
+
* Returns the UTF-8 text, or null when the file is absent (ENOENT); throws a LedgerIOError
|
|
33
|
+
* carrying `.code` (ENXIO FIFO/device, EISDIR directory, EFBIG over CONFIG_MAX_BYTES, or the
|
|
34
|
+
* errno) for every other condition.
|
|
35
|
+
*/
|
|
36
|
+
function readConfigFileBounded(filePath) {
|
|
37
|
+
return readSmallRegularFile(filePath, CONFIG_MAX_BYTES);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Why `absPath` — looked up on the REAL file system — must not be read blindly: it exists but is
|
|
41
|
+
* not a regular file (FIFO, device, socket, directory) or is larger than CONFIG_MAX_BYTES.
|
|
42
|
+
* Returns null when it is a regular file within the cap, and also when it cannot be stat'ed
|
|
43
|
+
* (absent, or not on the real file system at all, as with an installer fake-fs adapter), so the
|
|
44
|
+
* caller's own read decides. stat(2) follows links and never opens, so it cannot block. This is
|
|
45
|
+
* the guard for readers that must go through an injected fs seam and so cannot use
|
|
46
|
+
* `readConfigFileBounded`; it is a check-then-read, not the descriptor-bound read. Residual
|
|
47
|
+
* race: the install-fs adapter exposes `openSync`/`readSync` with string flags (no O_NONBLOCK)
|
|
48
|
+
* and no `fstatSync`, so the check cannot be bound to the descriptor that is read; a path
|
|
49
|
+
* swapped for a FIFO between this stat and the adapter's read can still block. Closing that
|
|
50
|
+
* needs `fstatSync` and numeric open flags on the adapter.
|
|
51
|
+
*/
|
|
52
|
+
function unboundedConfigFileReason(absPath) {
|
|
53
|
+
try {
|
|
54
|
+
const st = node_fs_1.default.statSync(absPath);
|
|
55
|
+
if (!st.isFile())
|
|
56
|
+
return 'not a regular file';
|
|
57
|
+
if (st.size > CONFIG_MAX_BYTES)
|
|
58
|
+
return `larger than ${CONFIG_MAX_BYTES} bytes`;
|
|
59
|
+
}
|
|
60
|
+
catch { /* absent or not on the real fs — the caller's read decides */ }
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A path as printed to a terminal: C0 controls, DEL and C1 controls (general category Cc) become
|
|
65
|
+
* `\xNN`; every other format (Cf), line/paragraph separator (Zl, Zp) and lone-surrogate (Cs)
|
|
66
|
+
* character — zero-width and bidirectional controls, soft hyphen, tag characters — becomes
|
|
67
|
+
* `\uNNNN` (`\u{N...}` above the BMP). A directory
|
|
68
|
+
* name can carry ESC (or a C1 CSI), and a path written to stderr verbatim is a terminal-escape
|
|
69
|
+
* injection through that name.
|
|
70
|
+
*/
|
|
71
|
+
function printablePath(p) {
|
|
72
|
+
return p.replace(new RegExp('[\\p{Cc}\\p{Cf}\\p{Zl}\\p{Zp}\\p{Cs}]', 'gu'), (c) => {
|
|
73
|
+
const code = c.codePointAt(0);
|
|
74
|
+
const hex = code.toString(16).padStart(4, '0');
|
|
75
|
+
if (/\p{Cc}/u.test(c))
|
|
76
|
+
return `\\x${hex.slice(-2)}`; // C0, DEL, C1 (all <= 0xff)
|
|
77
|
+
return code > 0xffff ? `\\u{${hex}}` : `\\u${hex}`; // format, separator, lone surrogate
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
module.exports = {
|
|
81
|
+
CONFIG_MAX_BYTES,
|
|
82
|
+
readConfigFileBounded,
|
|
83
|
+
unboundedConfigFileReason,
|
|
84
|
+
printablePath,
|
|
85
|
+
};
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* - ./unusable-input.cjs (warnUnusableInput, UNUSABLE_REASON — #3760)
|
|
18
18
|
* - ./config-schema.cjs (VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS)
|
|
19
19
|
* - ./planning-workspace.cjs (planningDir, planningRoot)
|
|
20
|
-
* - ./shell-command-projection.cjs (execGit, platformWriteSync
|
|
20
|
+
* - ./shell-command-projection.cjs (execGit, platformWriteSync)
|
|
21
|
+
* - ./config-file-read.cjs (CONFIG_MAX_BYTES, readConfigFileBounded — bounded regular-file read, #5759)
|
|
21
22
|
* - ./core-utils.cjs (detectSubRepos)
|
|
22
23
|
* - ./model-catalog.cjs (KNOWN_RUNTIMES, KNOWN_PROVIDERS, ADAPTIVE_TIER_VALUES)
|
|
23
24
|
*/
|
|
@@ -27,6 +28,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
27
28
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
28
29
|
const node_path_1 = __importDefault(require("node:path"));
|
|
29
30
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
|
+
const configFileReadMod = require("./config-file-read.cjs");
|
|
33
|
+
const { CONFIG_MAX_BYTES, readConfigFileBounded, printablePath } = configFileReadMod;
|
|
30
34
|
// #4717: runtime-identity fill — env rung + per-install marker rung.
|
|
31
35
|
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
32
36
|
const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
|
|
@@ -533,15 +537,19 @@ const CONFIG_REASON = Object.freeze({
|
|
|
533
537
|
/** The file exists but could not be read (EACCES/EIO/…) — NOT applied. */
|
|
534
538
|
CONFIG_UNREADABLE: 'config_unreadable',
|
|
535
539
|
});
|
|
540
|
+
// CONFIG_MAX_BYTES and readConfigFileBounded live in ./config-file-read.cjs (#5759), the leaf
|
|
541
|
+
// every config.json reader shares; they are re-exported below for existing importers.
|
|
536
542
|
/**
|
|
537
543
|
* Read + JSON-parse a config file, keeping *absent* distinguishable from
|
|
538
|
-
* *unusable*. `
|
|
539
|
-
*
|
|
544
|
+
* *unusable*. `readConfigFileBounded` returns null on ENOENT and throws an error
|
|
545
|
+
* carrying `.code` for every other condition — a FIFO/device (ENXIO), a directory
|
|
546
|
+
* (EISDIR), an oversized file (EFBIG) or an errno — which is the seam that makes
|
|
547
|
+
* this separable at all.
|
|
540
548
|
*/
|
|
541
549
|
function _readConfigFile(filePath) {
|
|
542
550
|
let raw;
|
|
543
551
|
try {
|
|
544
|
-
raw = (
|
|
552
|
+
raw = readConfigFileBounded(filePath);
|
|
545
553
|
}
|
|
546
554
|
catch (err) {
|
|
547
555
|
const code = err.code ?? 'EUNKNOWN';
|
|
@@ -573,14 +581,7 @@ function _readConfigFile(filePath) {
|
|
|
573
581
|
* genuine second failure in a different file.
|
|
574
582
|
*/
|
|
575
583
|
const _warnedUnusableConfig = new Set();
|
|
576
|
-
|
|
577
|
-
* The wiring clause (ADR-1411 amendment). `reason` lives on `ConfigResolution`,
|
|
578
|
-
* but `loadConfig` — the wrapper roughly fifty call sites use — returns
|
|
579
|
-
* `.config` alone and would never surface it. Without this diagnostic the field
|
|
580
|
-
* is unreachable to almost every consumer, and the user whose config was
|
|
581
|
-
* silently discarded still gets no signal. That was the whole defect in #1880.
|
|
582
|
-
*/
|
|
583
|
-
function _warnUnusableConfig(fault) {
|
|
584
|
+
function _warnUnusableConfig(fault, fallback = 'defaults') {
|
|
584
585
|
// The NUL separators are load-bearing: without them `path`+`reason`+`code` is bare
|
|
585
586
|
// concatenation and two distinct faults can key alike. They are written as escapes rather
|
|
586
587
|
// than literal 0x00 bytes because a literal NUL makes the whole file binary to file(1) and
|
|
@@ -594,7 +595,10 @@ function _warnUnusableConfig(fault) {
|
|
|
594
595
|
const what = fault.reason === CONFIG_REASON.CONFIG_UNPARSEABLE
|
|
595
596
|
? 'is not valid JSON'
|
|
596
597
|
: `could not be read (${fault.code})`;
|
|
597
|
-
|
|
598
|
+
const instead = fallback === 'root' ? '; using the root config instead'
|
|
599
|
+
: fallback === 'defaults' ? '; using defaults instead'
|
|
600
|
+
: '';
|
|
601
|
+
process.stderr.write(`gsd-tools: warning: ${printablePath(fault.path)} ${what} — its settings were NOT applied${instead}\n`);
|
|
598
602
|
}
|
|
599
603
|
/**
|
|
600
604
|
* loadConfigResolved — provenance-aware config loading (#1415, ADR-1411 P2).
|
|
@@ -677,7 +681,8 @@ function loadConfigResolvedInternal(cwd, options = {}) {
|
|
|
677
681
|
const rootRead = _readConfigFile(rootConfigPath);
|
|
678
682
|
if (rootRead.kind === 'fault') {
|
|
679
683
|
configFault = rootRead.fault;
|
|
680
|
-
|
|
684
|
+
// The workstream config below may still apply, so no replacement is claimed.
|
|
685
|
+
_warnUnusableConfig(rootRead.fault, null);
|
|
681
686
|
}
|
|
682
687
|
if (rootRead.kind !== 'ok')
|
|
683
688
|
throw new Error('root config absent or unusable');
|
|
@@ -730,7 +735,8 @@ function loadConfigResolvedInternal(cwd, options = {}) {
|
|
|
730
735
|
// The workstream/root config that ACTUALLY governs this resolution is
|
|
731
736
|
// unusable. This outranks any earlier root-config fault for reporting.
|
|
732
737
|
configFault = read.fault;
|
|
733
|
-
|
|
738
|
+
// With a workstream active and a usable root config, the root config is what applies.
|
|
739
|
+
_warnUnusableConfig(read.fault, ws && rootParsed ? 'root' : 'defaults');
|
|
734
740
|
}
|
|
735
741
|
if (read.kind !== 'ok')
|
|
736
742
|
throw new Error('config absent or unusable');
|
|
@@ -1135,6 +1141,8 @@ module.exports = {
|
|
|
1135
1141
|
loadConfig,
|
|
1136
1142
|
loadConfigResolved,
|
|
1137
1143
|
CONFIG_REASON,
|
|
1144
|
+
CONFIG_MAX_BYTES,
|
|
1145
|
+
readConfigFileBounded,
|
|
1138
1146
|
_warnedUnusableConfig,
|
|
1139
1147
|
isGitIgnored,
|
|
1140
1148
|
CONFIG_DEFAULTS,
|
|
@@ -172,22 +172,66 @@ function resolveSchemaDefault(cwd, kp) {
|
|
|
172
172
|
return { found: false, value: undefined };
|
|
173
173
|
}
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
-
|
|
182
|
-
|
|
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)(
|
|
187
|
-
|
|
188
|
-
return;
|
|
225
|
+
const masked = (0, secrets_cjs_1.maskSecret)(resolved);
|
|
226
|
+
return { json: masked, text: masked };
|
|
189
227
|
}
|
|
190
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 = (
|
|
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();
|