feat(shell-projection): centralize managed hook command policy (#3450)

* feat(shell-projection): route persistent PATH hints through projection seam

* refactor(shell-projection): unify managed hook command policy

* fix(install): guard malformed settings hooks during uninstall

* chore(changeset): add fragment for pr 3450

* fix(install): guard malformed settings hook entries
This commit is contained in:
Tom Boucher
2026-05-12 20:15:43 -04:00
committed by GitHub
parent 16f5009270
commit bf3c736029
10 changed files with 341 additions and 42 deletions

View File

@@ -1,6 +1,6 @@
'use strict';
const path = require('path');
const { isManagedHookCommand } = require('../shell-command-projection.cjs');
function isStructurallyEmpty(value) {
if (value === null || value === undefined) return true;
@@ -11,12 +11,11 @@ function isStructurallyEmpty(value) {
}
function isManagedCodexHookCommand(command, configDir) {
if (typeof command !== 'string') return false;
if (typeof configDir !== 'string' || configDir.length === 0) return false;
const normalizedCommand = command.replace(/\\/g, '/');
const managedHooksDir = `${path.join(configDir, 'hooks').replace(/\\/g, '/')}/`;
if (!normalizedCommand.includes(managedHooksDir)) return false;
return /(^|[\\/\s"'])(gsd-check-update\.js|gsd-update-check\.js)(?=$|[\s"'])/.test(normalizedCommand);
return isManagedHookCommand(command, {
surface: 'codex-hooks-json',
includeLegacyAliases: true,
configDir,
});
}
function pruneLegacyCodexHooksJsonValue(value, configDir) {

View File

@@ -1,5 +1,7 @@
'use strict';
const path = require('path');
/**
* Shell Command Projection Module
*
@@ -99,6 +101,37 @@ const MANAGED_HOOK_BASENAMES_BY_SURFACE = {
]),
};
const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE = {
'settings-json': new Set([
'gsd-check-update.js',
'gsd-statusline.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
]),
'codex-toml': new Set([
'gsd-check-update.js',
]),
'codex-hooks-json': new Set([
'gsd-check-update.js',
]),
};
const LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE = {
'codex-toml': new Set([
'gsd-update-check.js',
]),
'codex-hooks-json': new Set([
'gsd-update-check.js',
]),
};
function managedHookSurfaceSet(surface = 'settings-json') {
return MANAGED_HOOK_BASENAMES_BY_SURFACE[surface] || MANAGED_HOOK_BASENAMES_BY_SURFACE['settings-json'];
}
@@ -110,6 +143,36 @@ function isManagedHookBasename(scriptPathOrBasename, opts = {}) {
return managedHookSurfaceSet(surface).has(basename);
}
function managedHookCommandSurfaceSet(surface = 'settings-json', includeLegacyAliases = false) {
const base = MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE[surface]
|| MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE['settings-json'];
if (!includeLegacyAliases) return base;
const aliases = LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE[surface];
if (!aliases || aliases.size === 0) return base;
return new Set([...base, ...aliases]);
}
function isManagedHookCommand(commandText, opts = {}) {
if (typeof commandText !== 'string') return false;
const surface = opts.surface || 'settings-json';
const includeLegacyAliases = opts.includeLegacyAliases === true;
const managedBasenames = managedHookCommandSurfaceSet(surface, includeLegacyAliases);
if (!managedBasenames || managedBasenames.size === 0) return false;
const normalizedCommand = commandText.replace(/\\/g, '/');
if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
const normalizedHooksDir = `${path.join(opts.configDir, 'hooks').replace(/\\/g, '/')}/`;
if (!normalizedCommand.includes(normalizedHooksDir)) return false;
}
for (const basename of managedBasenames) {
const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(^|[\\\\/\\s"'` + '`' + `])${escapedBasename}(?=$|[\\s"'` + '`' + `])`);
if (pattern.test(normalizedCommand)) return true;
}
return false;
}
/**
* Projection helper for legacy settings.json hook rewrites.
*
@@ -158,11 +221,9 @@ function escapePowerShellSingleQuoted(value) {
function escapePosixDoubleQuoted(value) {
return String(value).replace(/[\\$"`]/g, '\\$&');
}
function escapeSingleQuotedShellLiteral(value) {
return String(value).replace(/'/g, "'\\''");
}
function renderShellActionLines(shellActions = []) {
return shellActions.map((action) => {
if (!action || !action.command) return '';
@@ -231,8 +292,16 @@ function projectPathActionProjection({
};
}
function projectPersistentPathExportActions({ targetDir, platform = process.platform }) {
const projected = projectPathActionProjection({
mode: 'persist',
targetDir,
platform,
});
return { shellActions: projected.shellActions };
}
function buildWindowsShimTriple(shimSrc) {
const path = require('path');
const shimAbs = path.resolve(shimSrc);
const shimQuoted = JSON.stringify(shimAbs);
const invocation = {
@@ -292,11 +361,13 @@ module.exports = {
projectShellCommandText,
projectManagedHookCommand,
isManagedHookBasename,
isManagedHookCommand,
projectLegacySettingsHookCommand,
escapeTomlDoubleQuotedString,
projectCodexHookTomlCommand,
projectPathActionProjection,
renderShellActionLines,
projectPersistentPathExportActions,
buildWindowsShimTriple,
formatSdkPathDiagnostic,
};