From 639307946866c38f32d2ad4038ff94dde41c3ed2 Mon Sep 17 00:00:00 2001 From: Erick Heslan Date: Thu, 11 Jun 2026 07:50:51 -0300 Subject: [PATCH] fix(team-init): emit PreToolUse deny in hookSpecificOutput shape The check-gstack.sh hook generated by gstack-team-init printed permissionDecision at the top level. Claude Code's PreToolUse decision control only reads hookSpecificOutput.permissionDecision (with hookEventName), so the deny was silently ignored and the required-mode gate never actually blocked skill usage when gstack was missing. Use the same hookSpecificOutput shape adopted for careful/freeze in #1509, and add a test that executes the generated hook in both scenarios (gstack missing -> structured deny; installed -> {}). --- bin/gstack-team-init | 2 +- test/team-mode.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 256735f8b..2a08c1638 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -127,7 +127,7 @@ Install it: Then restart your AI coding tool. MSG - echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}' + echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"gstack is required but not installed. See stderr for install instructions."}}' exit 0 fi diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index ce8c1d610..677e3680e 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -240,6 +240,29 @@ describe('gstack-team-init', () => { expect(stat.mode & 0o111).toBeGreaterThan(0); }); + test('required: hook emits PreToolUse deny in hookSpecificOutput shape when gstack is missing', () => { + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.sh'); + + // HOME without gstack → must deny via hookSpecificOutput (top-level + // permissionDecision is ignored by Claude Code's PreToolUse schema) + const fakeHome = path.join(tmpDir, 'fake-home'); + fs.mkdirSync(fakeHome, { recursive: true }); + const denied = run(`bash ${hookPath}`, { env: { HOME: fakeHome } }); + expect(denied.exitCode).toBe(0); + const deniedJson = JSON.parse(denied.stdout.trim()); + expect(deniedJson.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(deniedJson.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(deniedJson.hookSpecificOutput.permissionDecisionReason).toContain('gstack is required'); + + // HOME with gstack installed → no-op {} + const homeWithGstack = path.join(tmpDir, 'home-with-gstack'); + fs.mkdirSync(path.join(homeWithGstack, '.claude', 'skills', 'gstack', 'bin'), { recursive: true }); + const allowed = run(`bash ${hookPath}`, { env: { HOME: homeWithGstack } }); + expect(allowed.exitCode).toBe(0); + expect(JSON.parse(allowed.stdout.trim())).toEqual({}); + }); + test('required: creates project settings.json with PreToolUse hook', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); const settingsPath = path.join(tmpDir, '.claude', 'settings.json');