From 7a7a83b73e6272da20c531c3902b1b8b0e5691ac Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 15:35:04 -0700 Subject: [PATCH] fix(team-init): required-mode hook blocks with nested schema + exit 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated check-gstack.sh emitted a flat permissionDecision payload and exited 0, which Claude Code ignores — required mode enforced nothing. The generated hook now nests the deny under hookSpecificOutput and exits 2 so the block holds even if the JSON schema drifts again. Adds a temp-repo regression test that runs the generated hook under both installed and missing-gstack homes. Fixes #2413, #2296. Contributed by @Masashi-Ono0611 (PR #2423). Co-Authored-By: Claude Fable 5 --- bin/gstack-team-init | 4 +- test/gstack-team-init-hook-schema.test.ts | 81 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 test/gstack-team-init-hook-schema.test.ts diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 256735f8b..fd6c1b7d9 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -127,8 +127,8 @@ Install it: Then restart your AI coding tool. MSG - echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}' - exit 0 + echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"gstack is required but not installed. See stderr for install instructions."}}' + exit 2 fi echo '{}' diff --git a/test/gstack-team-init-hook-schema.test.ts b/test/gstack-team-init-hook-schema.test.ts new file mode 100644 index 000000000..9ce2f0cfc --- /dev/null +++ b/test/gstack-team-init-hook-schema.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { execSync, execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Regression guard for #2413: gstack-team-init required generated a +// PreToolUse hook that emitted a flat {"permissionDecision":...} payload and +// exited 0. Claude Code only nests decisions under hookSpecificOutput, and +// only exit code 2 reliably blocks a PreToolUse call. The old shape was +// silently ignored as a non-blocking error, so `required` mode enforced +// nothing — the BLOCKED text printed to stderr but the tool call proceeded. + +const ROOT = path.resolve(import.meta.dir, '..'); +const TEAM_INIT = path.join(ROOT, 'bin', 'gstack-team-init'); + +let repoDir: string; +let fakeHomeAbsent: string; +let fakeHomePresent: string; + +describe('gstack-team-init required: PreToolUse hook schema (#2413)', () => { + beforeEach(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-repo-')); + execFileSync('git', ['init', '-q'], { cwd: repoDir }); + execFileSync(TEAM_INIT, ['required'], { cwd: repoDir, encoding: 'utf-8' }); + + fakeHomeAbsent = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-home-absent-')); + + fakeHomePresent = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-init-home-present-')); + fs.mkdirSync(path.join(fakeHomePresent, '.claude', 'skills', 'gstack', 'bin'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(fakeHomeAbsent, { recursive: true, force: true }); + fs.rmSync(fakeHomePresent, { recursive: true, force: true }); + }); + + function runHook(home: string): { status: number; stdout: string; stderr: string } { + const hookPath = path.join(repoDir, '.claude', 'hooks', 'check-gstack.sh'); + try { + const stdout = execSync(`bash "${hookPath}"`, { + env: { ...process.env, HOME: home }, + encoding: 'utf-8', + }); + return { status: 0, stdout, stderr: '' }; + } catch (err) { + const e = err as { status: number; stdout: string; stderr: string }; + return { status: e.status, stdout: e.stdout, stderr: e.stderr }; + } + } + + test('generates the hook and registers it in settings.json', () => { + expect(fs.existsSync(path.join(repoDir, '.claude', 'hooks', 'check-gstack.sh'))).toBe(true); + const settings = JSON.parse( + fs.readFileSync(path.join(repoDir, '.claude', 'settings.json'), 'utf-8'), + ); + expect(JSON.stringify(settings)).toContain('check-gstack.sh'); + }); + + test('gstack absent: exits 2 (blocking) with schema-valid deny JSON', () => { + const { status, stdout, stderr } = runHook(fakeHomeAbsent); + expect(status).toBe(2); + expect(stderr).toContain('BLOCKED: gstack is not installed globally.'); + + const payload = JSON.parse(stdout.trim()); + expect(payload.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(payload.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(typeof payload.hookSpecificOutput.permissionDecisionReason).toBe('string'); + // The old top-level shape must be gone, not just supplemented. + expect(payload.permissionDecision).toBeUndefined(); + expect(payload.message).toBeUndefined(); + }); + + test('gstack present: exits 0 with an empty (no-opinion) payload', () => { + const { status, stdout, stderr } = runHook(fakeHomePresent); + expect(status).toBe(0); + expect(stderr).toBe(''); + expect(JSON.parse(stdout.trim())).toEqual({}); + }); +});