fix: deny stale team hook paths safely

Catch project hook resolution and load failures in the cross-shell Node launcher, returning a sanitized structured denial instead of a module-loader error.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
neallee 2026-07-10 20:07:32 +08:00
parent e81dc23d37
commit 472f1b3df5
4 changed files with 74 additions and 23 deletions

View File

@ -58,7 +58,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
```
No vendored gstack install in your repo, no version drift, no manual upgrades. Required mode commits one Node 18+ CommonJS enforcement hook, so the same project hook works from POSIX and Windows hook runners without duplicate shell-specific handlers. If the hook runner omits its project directory, enforcement fails closed with an intentional denial instead of a hook error. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent).
No vendored gstack install in your repo, no version drift, no manual upgrades. Required mode commits one Node 18+ CommonJS enforcement hook, so the same project hook works from POSIX and Windows hook runners without duplicate shell-specific handlers. If the hook runner omits its project directory or provides a stale path, enforcement fails closed with an intentional denial instead of a hook error. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent).
Swap `required` for `optional` if you'd rather nudge teammates than block them.

View File

@ -179,7 +179,7 @@ HOOK_EOF
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
const hookCommand = \"node -e \\\"const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){const reason='BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))}else{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}\\\"\";
const hookCommand = \"node -e \\\"const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}\\\"\";
let found = false;
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => {
if (entry.matcher !== 'Skill' || !entry.hooks) return true;

View File

@ -14,6 +14,7 @@
<li>Required mode generates <code>.claude/hooks/check-gstack.cjs</code>. The explicit CommonJS extension is unaffected by a consumer repository's <code>package.json</code> module type.</li>
<li>The one registered command launches Node 18+ and resolves <code>CLAUDE_PROJECT_DIR</code> inside JavaScript through <code>process.env</code>. It contains no Bash, PowerShell, or cmd environment expansion.</li>
<li>The launcher validates <code>CLAUDE_PROJECT_DIR</code> before calling <code>path.join</code>. Missing or empty project context emits a structured <code>PreToolUse</code> denial, writes a clear reason to stderr, and exits zero instead of surfacing a hook execution error.</li>
<li>Path resolution and module loading run inside the same <code>try/catch</code>. A nonexistent project, an existing project without the generated hook, or any other load failure returns the same concise denial without exposing a stack trace or the full project path.</li>
<li>The generated hook resolves the user home in <code>HOME</code>, <code>USERPROFILE</code>, then <code>os.homedir()</code> order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.</li>
<li>The hook contract follows <code>docs/spikes/claude-code-hook-mutation.md</code>: pass-through writes <code>{}</code>; denial writes a <code>hookSpecificOutput</code> object for <code>PreToolUse</code> with <code>permissionDecision: "deny"</code>.</li>
<li>Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and <code>permissionDecisionReason</code>, so the runner does not report a hook execution error.</li>

View File

@ -13,6 +13,10 @@ function bashCommand(filePath: string): string {
const SETTINGS_HOOK = bashCommand(path.join(ROOT, 'bin', 'gstack-settings-hook'));
const SESSION_UPDATE = bashCommand(path.join(ROOT, 'bin', 'gstack-session-update'));
const TEAM_INIT = bashCommand(path.join(ROOT, 'bin', 'gstack-team-init'));
const MISSING_PROJECT_REASON =
'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';
const HOOK_LOAD_REASON =
'BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.';
function mkTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-test-'));
@ -77,6 +81,22 @@ function runHookInPowerShell(
};
}
function expectStructuredDeny(
result: { stdout: string; stderr: string; exitCode: number },
reason: string,
): void {
expect(result.exitCode).toBe(0);
expect(result.stderr.trim()).toBe(reason);
expect(result.stderr).not.toContain('MODULE_NOT_FOUND');
expect(JSON.parse(result.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason,
},
});
}
describe('gstack-settings-hook', () => {
let tmpDir: string;
let settingsFile: string;
@ -312,7 +332,7 @@ describe('gstack-team-init', () => {
expect(entry.hooks).toHaveLength(1);
expect(entry.hooks[0]).not.toHaveProperty('shell');
expect(entry.hooks[0].command).toBe(
`node -e "const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){const reason='BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))}else{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}"`,
`node -e "const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}"`,
);
expect(entry.hooks[0].command).not.toMatch(
/\$CLAUDE_PROJECT_DIR|\$env:CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/,
@ -359,16 +379,7 @@ describe('gstack-team-init', () => {
env: { CLAUDE_PROJECT_DIR: '' },
});
expect(result.exitCode).toBe(0);
expect(result.stderr).toContain('BLOCKED: CLAUDE_PROJECT_DIR is unavailable');
expect(JSON.parse(result.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.',
},
});
expectStructuredDeny(result, MISSING_PROJECT_REASON);
});
test('required: missing project env denies through PowerShell', () => {
@ -385,16 +396,55 @@ describe('gstack-team-init', () => {
env: { CLAUDE_PROJECT_DIR: '' },
});
expect(result.exitCode).toBe(0);
expect(result.stderr).toContain('BLOCKED: CLAUDE_PROJECT_DIR is unavailable');
expect(JSON.parse(result.stdout)).toEqual({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.',
},
});
expectStructuredDeny(result, MISSING_PROJECT_REASON);
});
test('required: stale project paths deny through the platform default shell', () => {
run(`${TEAM_INIT} required`, { cwd: tmpDir });
const settings = JSON.parse(
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
);
const command = settings.hooks.PreToolUse[0].hooks[0].command;
const existingWithoutHook = path.join(tmpDir, 'existing-without-hook');
fs.mkdirSync(existingWithoutHook);
for (const projectDir of [
path.join(tmpDir, 'nonexistent-project'),
existingWithoutHook,
]) {
const result = runHook(command, {
cwd: tmpDir,
env: { CLAUDE_PROJECT_DIR: projectDir },
});
expectStructuredDeny(result, HOOK_LOAD_REASON);
expect(result.stderr).not.toContain(projectDir);
}
});
test('required: stale project paths deny through PowerShell', () => {
if (process.platform !== 'win32') return;
run(`${TEAM_INIT} required`, { cwd: tmpDir });
const settings = JSON.parse(
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
);
const command = settings.hooks.PreToolUse[0].hooks[0].command;
const existingWithoutHook = path.join(tmpDir, 'powershell-without-hook');
fs.mkdirSync(existingWithoutHook);
for (const projectDir of [
path.join(tmpDir, 'powershell-nonexistent'),
existingWithoutHook,
]) {
const result = runHookInPowerShell(command, {
cwd: tmpDir,
env: { CLAUDE_PROJECT_DIR: projectDir },
});
expectStructuredDeny(result, HOOK_LOAD_REASON);
expect(result.stderr).not.toContain(projectDir);
}
});
test('required: hook runs in PowerShell with USERPROFILE home fallback', () => {