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