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

@@ -13,6 +13,7 @@ const {
hookCommandNeedsPowerShellCallOperator,
formatHookCommandForRuntime,
isManagedHookBasename,
isManagedHookCommand,
projectLocalHookPrefix,
projectLegacySettingsHookCommand,
projectPortableHookBaseDir,
@@ -141,4 +142,48 @@ describe('bug #3439: shell projection module owns managed-hook policy and legacy
'/opt/custom/.claude',
);
});
test('isManagedHookCommand classifies managed settings hooks and leaves user commands untouched', () => {
assert.equal(
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.claude/hooks/gsd-statusline.js"', {
surface: 'settings-json',
}),
true,
);
assert.equal(
isManagedHookCommand('"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.claude/hooks/gsd-session-state.sh"', {
surface: 'settings-json',
}),
true,
);
assert.equal(
isManagedHookCommand('bash /Users/me/.claude/hooks/custom-lint.sh', {
surface: 'settings-json',
}),
false,
);
});
test('isManagedHookCommand supports codex surfaces and optional legacy alias matching', () => {
const command = '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"';
assert.equal(
isManagedHookCommand(command, {
surface: 'codex-toml',
}),
true,
);
assert.equal(
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', {
surface: 'codex-toml',
}),
false,
);
assert.equal(
isManagedHookCommand('"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"', {
surface: 'codex-toml',
includeLegacyAliases: true,
}),
true,
);
});
});

View File

@@ -0,0 +1,58 @@
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const migration = require(path.join(
__dirname,
'..',
'get-shit-done',
'bin',
'lib',
'installer-migrations',
'002-codex-legacy-hooks-json.cjs',
));
describe('bug #3442: codex legacy hooks.json migration consumes shared managed-hook policy', () => {
test('plan prunes managed codex hook commands including legacy alias', () => {
const configDir = '/Users/me/.codex';
const hooksJson = {
hooks: [
{ command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-check-update.js"' },
{ command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/gsd-update-check.js"' },
{ command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/custom-hook.js"' },
],
};
const actions = migration.plan({
configDir,
readJson: () => ({ exists: true, error: null, value: hooksJson }),
});
assert.equal(actions.length, 1);
assert.equal(actions[0].type, 'rewrite-json');
assert.equal(actions[0].relPath, 'hooks.json');
assert.deepEqual(actions[0].value, {
hooks: [
{ command: '"/usr/local/bin/node" "/Users/me/.codex/hooks/custom-hook.js"' },
],
});
});
test('plan preserves similarly named commands outside the managed hooks directory', () => {
const configDir = '/Users/me/.codex';
const hooksJson = {
hooks: [
{ command: '"/usr/local/bin/node" "/tmp/other/hooks/gsd-check-update.js"' },
],
};
const actions = migration.plan({
configDir,
readJson: () => ({ exists: true, error: null, value: hooksJson }),
});
assert.deepEqual(actions, []);
});
});

View File

@@ -17,6 +17,14 @@ const os = require('os');
const path = require('path');
const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js');
const PROJECTION_PATH = path.join(
__dirname,
'..',
'get-shit-done',
'bin',
'lib',
'shell-command-projection.cjs',
);
function loadInstaller() {
process.env.GSD_TEST_MODE = '1';
@@ -34,8 +42,10 @@ function cleanup(dir) {
describe('installer HOME-relative PATH detection (#2620)', () => {
let installer;
let projection;
before(() => {
installer = loadInstaller();
projection = require(PROJECTION_PATH);
});
test('homePathCoveredByRc is exported', () => {
@@ -257,10 +267,23 @@ describe('installer HOME-relative PATH detection (#2620)', () => {
}
const joined = logs.join('\n');
assert.ok(
/echo 'export PATH=/.test(joined),
`installer should emit absolute export suggestion when rc does not cover globalBin; got:\n${joined}`,
assert.equal(
typeof projection.projectPersistentPathExportActions,
'function',
'shell command projection module must export projectPersistentPathExportActions',
);
const projected = projection.projectPersistentPathExportActions({
targetDir: globalBin,
platform: process.platform,
});
assert.ok(Array.isArray(projected.shellActions), 'projected.shellActions must be an array');
assert.ok(projected.shellActions.length >= 2, 'expected at least zsh/bash projected actions');
for (const action of projected.shellActions) {
assert.ok(
joined.includes(action.command),
`installer should render projected command "${action.command}". Output:\n${joined}`,
);
}
} finally {
cleanup(home);
}