* test(#3023): failing-first guard — pi must not stage hooks in its reserved dir pi reserves <configDir>/hooks as its deprecated extension location and warns on every startup when it exists. Assert a pi install stages the shared hook bundle under gsd-hooks/ instead, manifests it there, and never creates hooks/. Also adds pi to the local-scope dir table in install-shared.cjs: pi was in RUNTIME_META but not LOCAL_DIR_NAME, so scope:'local' resolved path.join(root, undefined) and no local pi install could be exercised. Fails before the fix. Verified via the remote runner. * fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ dir pi reserves <configDir>/hooks as its now-deprecated extension location and warns on every startup when that directory merely exists — checkDeprecatedExtensionDirs() guards the warning with a bare existsSync(), unlike its tools/ sibling. GSD staged its shared hook bundle exactly there, and pi's advised remediation (move it to extensions/) would break the adapter's paths and expose GSD's .js helpers to pi's extension auto-discovery. The bundle directory name is now runtime-descriptor-driven: hostBehaviors .sharedHooksDirName, defaulting to 'hooks' so all 18 other runtimes are byte-identical. pi sets 'gsd-hooks'. The name is validated as a single path segment — separators, dot-only segments, trailing dots, absolute paths, NUL, and Windows reserved device names all fall back to the default, because the value is joined onto a user's config root and written to. Renamed in place rather than relocated: hook scripts resolve siblings via __dirname/.., so a depth change would silently break them. - install / uninstall / manifest sites all read the resolved name - pi/gsd.cjs probes gsd-hooks then hooks, so dev checkouts and half-upgraded trees still resolve; the never-throws contract is preserved - new migration 009 retires the legacy pi hooks/ dir on upgrade, using a new non-recursive remove-empty-dir engine primitive (rmdirSync only, symlink-refusing, containment-guarded); ADR-0008 amended accordingly - fixes two latent name-dependencies the rename exposed: the stale-hook scan and the injection scanner's self-exclusion both hardcoded 'hooks' Verified on the remote runner. Closes #3023 * fix(#3023): close review findings and align emitted provenance with the rename Adversarial review found two defects, and the remote runner found four failure clusters. All fixed here. Review BLOCKER — detect-custom-files was blind to the renamed bundle. GSD_PREFIX_MANAGED_DIRS in gsd-tools.cjs hardcoded 'hooks', so for pi the whole gsd-hooks/ tree was invisible to the custom-file scan and user-added files there were never backed up before the next update's clean-install wipe. The dir set now resolves via the .gsd-runtime marker plus the shipped capability registry (never bin/install.js, which is not shipped into installed trees), and falls back to scanning every known candidate when the runtime cannot be determined — over-scanning is safe, under-scanning is the data loss. Review MAJOR — the pi adapter bound to an empty bundle. resolveSharedHooksDir accepted any directory, so an interrupted install left gsd-hooks/ winning over a fully-staged legacy hooks/ and every hook silently no-opped. A candidate now qualifies only if it is non-empty. Remote-runner clusters: - emitted-provenance had no rule for the gsd-hooks/ family; added two pi-scoped rules pointing at the same sources the existing hooks/ rules use. The table is total, so an unattributed family is a hard failure by design. - pi tests in install-minimal-hooks and the install integration suite asserted the old layout; updated to derive the dir name from the descriptor rather than hardcoding either name. - 19 unrelated-looking failures on node22 only were a leaked fs mock: t.after() runs in registration order, cleanup was registered before mock.restoreAll(), and node22's JS rimraf calls the public fs.rmdirSync while node24's native path does not — so the EACCES stub leaked process-wide on one lane. Restore now runs first. Verified on the remote runner. * fix(#3023): honor PI_CODING_AGENT_DIR, ack the rename ripple, fix expandTilde pi resolves its agent dir as PI_CODING_AGENT_DIR ?? ~/<CONFIG_DIR_NAME>/agent (packages/coding-agent/src/config.ts). GSD's pi descriptor declared an empty configHome.env, so a user with that variable set had GSD installed where pi never looks. Added the env name; the dot-home-nested resolver already handled the override, so no resolver logic changed. Also fixes expandTilde in the shared runtime-homes resolver, found while adding that: it hardcoded os.homedir() and ignored the opts.home every caller threads, so EVERY runtime's tilde-valued env override (claude, antigravity, windsurf, pi) silently resolved against the real home. That is a correctness bug and a test-escape hazard — a sandboxed test asserting on a tilde override reached the developer's actual home directory. Now threaded through every branch; behavior with no injected home is unchanged. Adds the emitted-drift ack fragment for the 58 pi paths whose emitted location moved with the rename. The provenance rules satisfy the totality gate; the differential gate needs the ack because the hook sources are byte-unchanged — only the installer's target directory moved. The two hook files this branch genuinely edits stay attributed and are not double-acked. Note on piConfig.configDir: it is read from pi's OWN installed package.json (getPackageDir walks up from pi's __dirname), alongside piConfig.name — a white-label setting for a redistributed pi fork, not a per-project user setting. Documented accordingly rather than treated as an unsupported override. Verified on the remote runner. * fix(#3023): reject blank env overrides, pin adapter/descriptor parity Three review findings, all fixed. A whitespace-only config-dir override was accepted verbatim: the guard was `if (val)`, falsy only for the empty string, so PI_CODING_AGENT_DIR=' ' resolved to a literal three-space directory name instead of falling back to the descriptor default. Fixed across every env-consuming branch — dot-home, dot-home-nested, all three xdg steps, and generic-agents-root — not just pi's. Non-blank values are still never trimmed, so '~/My Agent Dir' keeps working. pi/gsd.cjs's probe list and the descriptor were two independent sources of truth for the bundle directory name; a future rename would have desynced them silently and left every pi hook quiet with no error. The probe list stays deliberate — it must resolve in a dev checkout and a half-upgraded tree, where the registry's answer would be wrong — so this adds the parity assertion the repo's generative-fix-divergence rule calls for: the descriptor value must be the FIRST candidate, and the default must remain present. Changeset body rewritten to cover the two later user-facing fixes it had not caught up with. Verified on the remote runner. * chore(#3023): backfill changeset PR number * fix(#3023): anchor injection-scan patterns and fix a macOS detection hole CI's security job flagged CONTEXT.md:124 — pre-existing prose reading 'not the same fact as a genuinely empty or absent one'. The match was the 'act as a' INSIDE 'f-act as a': the pattern had no left word boundary, so any word ending in act tripped it (fact, impact, contract, artifact, interact, redact, abstract). My four-line CONTEXT.md edit dragged the latent false positive into this PR because the scan is diff-scoped by file but reads whole files. Anchored with (^|[^[:alnum:]]) rather than rewording maintainer-owned prose, which would have left the class alive for the next PR touching any file saying 'fact as a'. Auditing the rest of the list for the same class surfaced a real detection hole: the eval/exec/Function patterns matched a quote via \x27, a GNU-grep-only hex escape. BSD/macOS grep reads it as four literal characters, so single-quoted eval('...')/exec('...') payloads were NEVER detected there while passing on GNU-grep CI. Replaced with a literal apostrophe class. Boundaries were added only where a real word-suffix collision exists; exec, jailbreak, developer mode and the role-manipulation family were audited and deliberately left unanchored. 22 new cases cover both directions — the false positives now scan clean, and every real payload still fires, including the quote/punctuation/start-of-line boundary forms. Also builds this branch's injection test fixture at runtime instead of carrying the literal phrase, so the payload keeps its teeth without tripping the scan. Verified on the remote runner. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -345,6 +345,68 @@ const GSD_WINDSURF_HOOK_SCRIPTS = [
|
||||
// that does receive hooks/lib.
|
||||
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh', 'cursor-workspace.js'];
|
||||
|
||||
/**
|
||||
* Directory name GSD stages its shared hook bundle under, inside a runtime's
|
||||
* install root. Defaults to 'hooks' — the name every runtime used before #3023.
|
||||
*
|
||||
* pi (pi.dev) reserves `hooks/` as its own now-deprecated extension location and
|
||||
* prints a migration warning on every startup when one exists, so pi overrides
|
||||
* this via hostBehaviors.sharedHooksDirName. Following pi's advised remediation
|
||||
* (move it into extensions/) would break the adapter's path resolution AND expose
|
||||
* GSD's .js helpers to pi's extension auto-discovery, so the bundle is renamed in
|
||||
* place instead — same depth, so every `__dirname/..`-relative resolution inside
|
||||
* the bundle (e.g. hooks/gsd-context-monitor.js reaching ../gsd-core/bin/) keeps
|
||||
* working.
|
||||
*/
|
||||
const SHARED_HOOKS_DIR_DEFAULT = 'hooks';
|
||||
|
||||
/**
|
||||
* Resolve a runtime's shared-hooks directory name from its descriptor.
|
||||
*
|
||||
* The value is a single path SEGMENT. This string is joined onto a user's config
|
||||
* root and then written to and recursively read, so anything that is not a plain,
|
||||
* non-empty, separator-free, non-dot segment is rejected back to the default —
|
||||
* a descriptor typo must never let the installer write outside the install root.
|
||||
*
|
||||
* The "non-dot" part of that contract is enforced beyond the literal '.' / '..'
|
||||
* segments: an all-dot (or dot-and-whitespace-only) segment is rejected as a
|
||||
* meaningless name, a segment with a trailing dot or space is rejected because
|
||||
* Windows silently strips it at directory-creation time (which would split the
|
||||
* name the installer creates from the name callers probe for), and a Windows
|
||||
* reserved device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9, with or without an
|
||||
* extension) is rejected because it cannot exist as a directory on Windows at
|
||||
* all. These checks are unconditional on every platform: the descriptor is
|
||||
* authored once and shipped everywhere, so a value invalid on Windows must be
|
||||
* rejected identically on Linux/macOS, or the install and its fixtures disagree
|
||||
* cross-platform.
|
||||
*
|
||||
* @param {string} runtime
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveSharedHooksDirName(runtime) {
|
||||
const raw = _hostBehaviors(runtime).sharedHooksDirName;
|
||||
if (typeof raw !== 'string') return SHARED_HOOKS_DIR_DEFAULT;
|
||||
const name = raw.trim();
|
||||
if (name === '') return SHARED_HOOKS_DIR_DEFAULT;
|
||||
if (name === '.' || name === '..') return SHARED_HOOKS_DIR_DEFAULT;
|
||||
// All-dot or dot+whitespace segments ('...', '. .') are not meaningful
|
||||
// directory names and are almost certainly a descriptor typo.
|
||||
if (name.replace(/[.\s]/g, '') === '') return SHARED_HOOKS_DIR_DEFAULT;
|
||||
// Windows silently strips a trailing dot or space at creation time, so the
|
||||
// directory the installer creates would not match the name the adapter
|
||||
// probes for — a split-brain that only reproduces off-Linux.
|
||||
if (/[. ]$/.test(name)) return SHARED_HOOKS_DIR_DEFAULT;
|
||||
// Windows reserved device names cannot exist as directories.
|
||||
if (/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i.test(name)) return SHARED_HOOKS_DIR_DEFAULT;
|
||||
if (name.includes('/') || name.includes('\\')) return SHARED_HOOKS_DIR_DEFAULT;
|
||||
// Belt-and-braces: reject anything path.basename() would reduce, and any
|
||||
// Windows drive/UNC-flavoured value.
|
||||
if (path.basename(name) !== name) return SHARED_HOOKS_DIR_DEFAULT;
|
||||
if (path.isAbsolute(name)) return SHARED_HOOKS_DIR_DEFAULT;
|
||||
if (name.includes('\0')) return SHARED_HOOKS_DIR_DEFAULT;
|
||||
return name;
|
||||
}
|
||||
|
||||
const CODEX_AGENT_SANDBOX = {
|
||||
'gsd-executor': 'workspace-write',
|
||||
'gsd-planner': 'workspace-write',
|
||||
@@ -8469,7 +8531,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
|
||||
}
|
||||
|
||||
// 4. Remove GSD hooks
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
// #3023: mirror the install site's descriptor-driven bundle dir name.
|
||||
const hooksDir = path.join(targetDir, resolveSharedHooksDirName(runtime));
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
let hookCount = 0;
|
||||
for (const hook of GSD_UNINSTALL_HOOKS) {
|
||||
@@ -9568,7 +9631,10 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares
|
||||
// skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed.
|
||||
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
|
||||
const hooksDir = path.join(configDir, 'hooks');
|
||||
// #3023: manifest keys must track the bundle wherever the descriptor put it,
|
||||
// or uninstall/saveLocalPatches silently orphan the tree.
|
||||
const sharedHooksDirName = resolveSharedHooksDirName(runtime);
|
||||
const hooksDir = path.join(configDir, sharedHooksDirName);
|
||||
if (fs.existsSync(hooksDir)) {
|
||||
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
|
||||
// scripts/build-hooks.js) rather than a prefix/extension regex, so the
|
||||
@@ -9580,7 +9646,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
for (const hook of INSTALLED_HOOK_FILES) {
|
||||
const hookPath = path.join(hooksDir, hook);
|
||||
if (fs.existsSync(hookPath)) {
|
||||
manifest.files['hooks/' + hook] = fileHash(hookPath);
|
||||
manifest.files[sharedHooksDirName + '/' + hook] = fileHash(hookPath);
|
||||
}
|
||||
}
|
||||
// Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
|
||||
@@ -9589,7 +9655,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
if (fs.existsSync(hooksLibDir)) {
|
||||
for (const file of fs.readdirSync(hooksLibDir)) {
|
||||
if (GSD_HOOK_LIB_FILES.includes(file)) {
|
||||
manifest.files['hooks/lib/' + file] = fileHash(path.join(hooksLibDir, file));
|
||||
manifest.files[sharedHooksDirName + '/lib/' + file] = fileHash(path.join(hooksLibDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11106,6 +11172,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// a safe no-op when the dir is already present.
|
||||
fs.mkdirSync(destRootDir, { recursive: true });
|
||||
|
||||
// #3023: the bundle's directory NAME is descriptor-driven — a host that
|
||||
// reserves `hooks/` (pi) must be able to opt out. Resolved once here so the
|
||||
// stage / lib / marker sites can never disagree about where the bundle is.
|
||||
const sharedHooksDirName = resolveSharedHooksDirName(runtime);
|
||||
|
||||
// #2544: the CommonJS marker is NOT written here (destRootDir is the
|
||||
// runtime's shared config root — user-writable territory on OpenCode and
|
||||
// Kilo, where it is the documented place to declare local-plugin npm
|
||||
@@ -11121,7 +11192,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// Template paths for the target runtime (replaces '.claude' with correct config dir)
|
||||
const hooksSrc = path.join(src, 'hooks', 'dist');
|
||||
if (fs.existsSync(hooksSrc)) {
|
||||
const hooksDest = path.join(destRootDir, 'hooks');
|
||||
const hooksDest = path.join(destRootDir, sharedHooksDirName);
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
const hookEntries = fs.readdirSync(hooksSrc);
|
||||
if (hookEntries.some((e) => fs.statSync(path.join(hooksSrc, e)).isFile())) stagedHooks = true;
|
||||
@@ -11187,7 +11258,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
}
|
||||
}
|
||||
if (verifyInstalled(hooksDest, 'hooks')) {
|
||||
console.log(` ${green}✓${reset} Installed hooks (bundled)`);
|
||||
console.log(` ${green}✓${reset} Installed ${sharedHooksDirName} (bundled)`);
|
||||
// Warn if expected community .sh hooks are missing (non-fatal)
|
||||
const expectedShHooks = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh', 'gsd-graphify-update.sh'];
|
||||
for (const sh of expectedShHooks) {
|
||||
@@ -11216,11 +11287,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// below; this helper itself only checks source presence.)
|
||||
const hooksLibSrc = path.join(src, 'hooks', 'lib');
|
||||
if (fs.existsSync(hooksLibSrc)) {
|
||||
const hooksLibDest = path.join(destRootDir, 'hooks', 'lib');
|
||||
const hooksLibDest = path.join(destRootDir, sharedHooksDirName, 'lib');
|
||||
fs.mkdirSync(hooksLibDest, { recursive: true });
|
||||
copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
|
||||
if (GSD_HOOK_LIB_FILES.some((f) => fs.existsSync(path.join(hooksLibDest, f)))) stagedHooks = true;
|
||||
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
|
||||
console.log(` ${green}✓${reset} Installed ${sharedHooksDirName}/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
|
||||
}
|
||||
|
||||
// #2544: pin the staged hook scripts to CommonJS from inside hooks/ — the
|
||||
@@ -11241,19 +11312,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
// populate as CommonJS claims an ownership the install did not earn — the
|
||||
// two flags answer different questions ("did we intend to fill it" vs "is
|
||||
// it actually filled"), and the marker needs both.
|
||||
const hooksMarkerDir = path.join(destRootDir, 'hooks');
|
||||
const hooksMarkerDir = path.join(destRootDir, sharedHooksDirName);
|
||||
if (stagedHooks && hooksOk) {
|
||||
switch (ensureCommonJsMarker(hooksMarkerDir)) {
|
||||
case 'written':
|
||||
console.log(` ${green}✓${reset} Wrote hooks/package.json (CommonJS mode)`);
|
||||
console.log(` ${green}✓${reset} Wrote ${sharedHooksDirName}/package.json (CommonJS mode)`);
|
||||
break;
|
||||
case 'preserved-foreign':
|
||||
console.warn(` ${yellow}⚠${reset} Left existing hooks/package.json untouched (not GSD's marker) — GSD hooks may not resolve as CommonJS`);
|
||||
console.warn(` ${yellow}⚠${reset} Left existing ${sharedHooksDirName}/package.json untouched (not GSD's marker) — GSD hooks may not resolve as CommonJS`);
|
||||
break;
|
||||
case 'failed':
|
||||
// Best-effort: a read-only or full config dir must not abort the
|
||||
// install with a raw stack trace. The hooks themselves are staged.
|
||||
console.warn(` ${yellow}⚠${reset} Could not write hooks/package.json (CommonJS mode) — install continued; GSD hooks may not resolve as CommonJS`);
|
||||
console.warn(` ${yellow}⚠${reset} Could not write ${sharedHooksDirName}/package.json (CommonJS mode) — install continued; GSD hooks may not resolve as CommonJS`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -13407,6 +13478,9 @@ module.exports = {
|
||||
// #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
|
||||
_resolveHostBehaviors,
|
||||
FALLBACK_HOST_BEHAVIORS,
|
||||
// #3023 — shared hook bundle directory name, descriptor-driven
|
||||
SHARED_HOOKS_DIR_DEFAULT,
|
||||
resolveSharedHooksDirName,
|
||||
convertSlashCommandsToCodexSkillMentions,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
convertClaudeCommandToKimiSkill,
|
||||
|
||||
Reference in New Issue
Block a user