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"
|
(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.
|
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) settings.hooks = {};
|
||||||
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
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;
|
let found = false;
|
||||||
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => {
|
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => {
|
||||||
if (entry.matcher !== 'Skill' || !entry.hooks) return true;
|
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>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 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>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 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>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>
|
<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 SETTINGS_HOOK = bashCommand(path.join(ROOT, 'bin', 'gstack-settings-hook'));
|
||||||
const SESSION_UPDATE = bashCommand(path.join(ROOT, 'bin', 'gstack-session-update'));
|
const SESSION_UPDATE = bashCommand(path.join(ROOT, 'bin', 'gstack-session-update'));
|
||||||
const TEAM_INIT = bashCommand(path.join(ROOT, 'bin', 'gstack-team-init'));
|
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 {
|
function mkTmpDir(): string {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-test-'));
|
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', () => {
|
describe('gstack-settings-hook', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let settingsFile: string;
|
let settingsFile: string;
|
||||||
|
|
@ -312,7 +332,7 @@ describe('gstack-team-init', () => {
|
||||||
expect(entry.hooks).toHaveLength(1);
|
expect(entry.hooks).toHaveLength(1);
|
||||||
expect(entry.hooks[0]).not.toHaveProperty('shell');
|
expect(entry.hooks[0]).not.toHaveProperty('shell');
|
||||||
expect(entry.hooks[0].command).toBe(
|
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(
|
expect(entry.hooks[0].command).not.toMatch(
|
||||||
/\$CLAUDE_PROJECT_DIR|\$env:CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/,
|
/\$CLAUDE_PROJECT_DIR|\$env:CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/,
|
||||||
|
|
@ -359,16 +379,7 @@ describe('gstack-team-init', () => {
|
||||||
env: { CLAUDE_PROJECT_DIR: '' },
|
env: { CLAUDE_PROJECT_DIR: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.exitCode).toBe(0);
|
expectStructuredDeny(result, MISSING_PROJECT_REASON);
|
||||||
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.',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('required: missing project env denies through PowerShell', () => {
|
test('required: missing project env denies through PowerShell', () => {
|
||||||
|
|
@ -385,16 +396,55 @@ describe('gstack-team-init', () => {
|
||||||
env: { CLAUDE_PROJECT_DIR: '' },
|
env: { CLAUDE_PROJECT_DIR: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.exitCode).toBe(0);
|
expectStructuredDeny(result, MISSING_PROJECT_REASON);
|
||||||
expect(result.stderr).toContain('BLOCKED: CLAUDE_PROJECT_DIR is unavailable');
|
});
|
||||||
expect(JSON.parse(result.stdout)).toEqual({
|
|
||||||
hookSpecificOutput: {
|
test('required: stale project paths deny through the platform default shell', () => {
|
||||||
hookEventName: 'PreToolUse',
|
run(`${TEAM_INIT} required`, { cwd: tmpDir });
|
||||||
permissionDecision: 'deny',
|
const settings = JSON.parse(
|
||||||
permissionDecisionReason:
|
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
|
||||||
'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.',
|
);
|
||||||
},
|
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', () => {
|
test('required: hook runs in PowerShell with USERPROFILE home fallback', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue