mirror of https://github.com/garrytan/gstack.git
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:
parent
e81dc23d37
commit
472f1b3df5
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue