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