From e81dc23d375f501a4d2af1c84769b8cf1bf841f5 Mon Sep 17 00:00:00 2001 From: neallee Date: Fri, 10 Jul 2026 19:56:31 +0800 Subject: [PATCH 1/6] fix: make team hook launcher cross-platform Generate one Node CommonJS enforcement hook, fail closed when project context or the global install cannot be verified, and cover cmd and PowerShell execution paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .claude/dispatch/inbox.md | 3 + README.md | 2 +- bin/gstack-team-init | 106 ++++++++++----- implementation-notes.html | 36 +++++ test/team-mode.test.ts | 274 +++++++++++++++++++++++++++++++++++--- 5 files changed, 373 insertions(+), 48 deletions(-) create mode 100644 .claude/dispatch/inbox.md create mode 100644 implementation-notes.html diff --git a/.claude/dispatch/inbox.md b/.claude/dispatch/inbox.md new file mode 100644 index 0000000000..c2d5e4f561 --- /dev/null +++ b/.claude/dispatch/inbox.md @@ -0,0 +1,3 @@ +# Dispatch inbox + +- Investigate the curated Windows-safe suite prerequisites and filtering. `bun run test:windows` currently includes `test/readme-throughput.test.ts`, whose four script subprocess tests return status `-1` on this Windows environment, and `browse/test/tab-isolation.test.ts`, which aborts when the generated `browse/src/server-node.mjs` bundle is absent. Receipt: reproduced identically on detached `origin/main` SHA `7c9df1c568a9ea745508f679a329332b2c338063` after `bun install --frozen-lockfile`, using `bun run test:windows` with Git Bash prepended to `PATH`; result was 27 pass, 5 fail, 1 error, exit 1. Full base log: `C:\Users\kunle\.copilot\session-state\8631d6e3-fb62-412d-8bdc-dc6a8f3a46be\files\main-test-windows.log`. The temporary worktree was removed afterward. This is outside the cross-platform team-hook fix; `test/team-mode.test.ts` passes in full. diff --git a/README.md b/README.md index 4bb177c3a7..8233848d68 100644 --- a/README.md +++ b/README.md @@ -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 files in your repo, no version drift, no manual upgrades. 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, 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. diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 256735f8b4..74cd5af5d7 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -65,12 +65,13 @@ cd ~/.claude/skills/gstack && ./setup --team Skills like /qa, /ship, /review, /investigate, and /browse become available after install. Use /browse for all web browsing. Use ~/.claude/skills/gstack/... for gstack file paths.' else - SNIPPET='## gstack (REQUIRED — global install) + SNIPPET=$(cat <<'SNIPPET_EOF' +## gstack (REQUIRED — global install) **Before doing ANY work, verify gstack is installed:** -```bash -test -d ~/.claude/skills/gstack/bin && echo "GSTACK_OK" || echo "GSTACK_MISSING" +``` +node -e "const fs=require('node:fs'),os=require('node:os'),p=require('node:path'); console.log(fs.existsSync(p.join(os.homedir(),'.claude','skills','gstack','bin')) ? 'GSTACK_OK' : 'GSTACK_MISSING')" ``` If GSTACK_MISSING: STOP. Do not proceed. Tell the user: @@ -87,7 +88,9 @@ Do not skip skills, ignore gstack errors, or work around missing gstack. Using gstack skills: After install, skills like /qa, /ship, /review, /investigate, and /browse are available. Use /browse for all web browsing. -Use ~/.claude/skills/gstack/... for gstack file paths (the global path).' +Use ~/.claude/skills/gstack/... for gstack file paths (the global path). +SNIPPET_EOF +) fi # Check if CLAUDE.md already has a gstack section @@ -109,33 +112,60 @@ if [ "$MODE" = "required" ]; then HOOKS_DIR="$REPO_ROOT/.claude/hooks" SETTINGS="$REPO_ROOT/.claude/settings.json" - # Create enforcement hook script + # Create a CommonJS hook so consumer package.json module settings cannot change + # how Node 18+ loads it. mkdir -p "$HOOKS_DIR" - cat > "$HOOKS_DIR/check-gstack.sh" << 'HOOK_EOF' -#!/bin/bash -# Block skill usage when gstack is not installed globally. - -if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then - cat >&2 <<'MSG' -BLOCKED: gstack is not installed globally. - -gstack is required for AI-assisted work in this repo. + cat > "$HOOKS_DIR/check-gstack.cjs" << 'HOOK_EOF' +'use strict'; + +// Block skill usage when gstack is not installed globally. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir(); +const gstackBin = path.join(homeDir, '.claude', 'skills', 'gstack', 'bin'); +let verificationError = null; +let installed = false; + +try { + installed = fs.statSync(gstackBin).isDirectory(); +} catch (error) { + if (error && error.code !== 'ENOENT') { + verificationError = error; + } +} + +if (installed) { + process.stdout.write('{}\n'); +} else { + const projectDir = process.env.CLAUDE_PROJECT_DIR || '(unknown project)'; + const heading = verificationError + ? 'BLOCKED: the global gstack install could not be verified.' + : 'BLOCKED: gstack is not installed globally.'; + const instructions = `${heading} + +gstack is required for AI-assisted work in ${projectDir}. Install it: git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack cd ~/.claude/skills/gstack && ./setup --team Then restart your AI coding tool. -MSG - echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}' - exit 0 -fi - -echo '{}' +`; + + process.stderr.write(instructions); + process.stdout.write(`${JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: instructions, + }, + })}\n`); +} HOOK_EOF - chmod +x "$HOOKS_DIR/check-gstack.sh" - GENERATED+=(".claude/hooks/check-gstack.sh") - echo " + .claude/hooks/check-gstack.sh — enforcement hook" + GENERATED+=(".claude/hooks/check-gstack.cjs") + echo " + .claude/hooks/check-gstack.cjs — cross-platform enforcement hook" # Add hook to project-level settings.json if command -v bun >/dev/null 2>&1; then @@ -149,18 +179,32 @@ HOOK_EOF if (!settings.hooks) settings.hooks = {}; if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []; - // Dedup - const exists = settings.hooks.PreToolUse.some(entry => - entry.matcher === 'Skill' && - entry.hooks && entry.hooks.some(h => h.command && h.command.includes('check-gstack')) - ); - - if (!exists) { + 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'))}\\\"\"; + let found = false; + settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => { + if (entry.matcher !== 'Skill' || !entry.hooks) return true; + + const matchingHooks = entry.hooks.filter( + h => h.command && h.command.includes('check-gstack') + ); + if (matchingHooks.length === 0) return true; + + entry.hooks = entry.hooks.filter( + h => !h.command || !h.command.includes('check-gstack') + ); + if (!found) { + entry.hooks.push({ type: 'command', command: hookCommand }); + found = true; + } + return entry.hooks.length > 0; + }); + + if (!found) { settings.hooks.PreToolUse.push({ matcher: 'Skill', hooks: [{ type: 'command', - command: '\"\$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\"' + command: hookCommand }] }); } diff --git a/implementation-notes.html b/implementation-notes.html new file mode 100644 index 0000000000..1de10a6d7b --- /dev/null +++ b/implementation-notes.html @@ -0,0 +1,36 @@ + + + + + + Cross-platform gstack enforcement hook + + +
+

Cross-platform gstack enforcement hook

+ +

Design decisions

+ + +

Deviations

+

None. The implementation uses the requested Node launcher, one hook handler, and the repository's documented hook output schema.

+ +

Tradeoffs

+ + +

Open questions

+

None.

+
+ + diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index ce8c1d6107..a17fb6938f 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -2,12 +2,17 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); -const SETTINGS_HOOK = path.join(ROOT, 'bin', 'gstack-settings-hook'); -const SESSION_UPDATE = path.join(ROOT, 'bin', 'gstack-session-update'); -const TEAM_INIT = path.join(ROOT, 'bin', 'gstack-team-init'); + +function bashCommand(filePath: string): string { + return `bash "${filePath.replace(/\\/g, '/')}"`; +} + +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')); function mkTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-test-')); @@ -17,7 +22,11 @@ function run(cmd: string, opts: { cwd?: string; env?: Record } = try { const stdout = execSync(cmd, { cwd: opts.cwd, - env: { ...process.env, ...opts.env }, + env: { + ...process.env, + ...(process.platform === 'win32' ? { MSYS_NO_PATHCONV: '1' } : {}), + ...opts.env, + }, encoding: 'utf-8', timeout: 10000, }); @@ -27,6 +36,47 @@ function run(cmd: string, opts: { cwd?: string; env?: Record } = } } +function runHook( + command: string, + opts: { cwd: string; env: Record }, +): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync(command, { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + encoding: 'utf-8', + shell: true, + timeout: 10000, + }); + + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + +function runHookInPowerShell( + command: string, + opts: { cwd: string; env: Record }, +): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', command], + { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + encoding: 'utf-8', + timeout: 10000, + }, + ); + + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: result.status ?? 1, + }; +} + describe('gstack-settings-hook', () => { let tmpDir: string; let settingsFile: string; @@ -227,27 +277,216 @@ describe('gstack-team-init', () => { const claude = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); expect(claude).toContain('## gstack (REQUIRED'); expect(claude).toContain('GSTACK_MISSING'); + expect(claude).toContain("require('node:os')"); + expect(claude).not.toContain('test -d ~/.claude/skills/gstack/bin'); }); test('required: creates enforcement hook', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); - const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.sh'); + const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.cjs'); expect(fs.existsSync(hookPath)).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.claude', 'hooks', 'check-gstack.sh')), + ).toBe(false); const hook = fs.readFileSync(hookPath, 'utf-8'); + expect(hook).toContain("'use strict'"); + expect(hook).toContain("require('node:fs')"); + expect(hook).toContain( + 'process.env.HOME || process.env.USERPROFILE || os.homedir()', + ); + expect(hook).toContain('process.env.CLAUDE_PROJECT_DIR'); + expect(hook).toContain("hookEventName: 'PreToolUse'"); + expect(hook).toContain("permissionDecision: 'deny'"); expect(hook).toContain('BLOCKED: gstack is not installed'); - // Should be executable - const stat = fs.statSync(hookPath); - expect(stat.mode & 0o111).toBeGreaterThan(0); + expect(hook).not.toContain('#!/bin/bash'); }); - test('required: creates project settings.json with PreToolUse hook', () => { + test('required: registers one shell-neutral project hook', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); const settingsPath = path.join(tmpDir, '.claude', 'settings.json'); expect(fs.existsSync(settingsPath)).toBe(true); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); expect(settings.hooks.PreToolUse).toHaveLength(1); - expect(settings.hooks.PreToolUse[0].matcher).toBe('Skill'); - expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain('check-gstack'); + const entry = settings.hooks.PreToolUse[0]; + expect(entry.matcher).toBe('Skill'); + 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'))}"`, + ); + expect(entry.hooks[0].command).not.toMatch( + /\$CLAUDE_PROJECT_DIR|\$env:CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/, + ); + expect(entry.hooks[0].command).not.toContain("permissionDecision:'allow'"); + }); + + test('required: hook allows with valid empty JSON when gstack is installed', () => { + 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 fakeHome = path.join(tmpDir, 'home'); + const nestedCwd = path.join(tmpDir, 'nested', 'working', 'directory'); + fs.mkdirSync(path.join(fakeHome, '.claude', 'skills', 'gstack', 'bin'), { + recursive: true, + }); + fs.mkdirSync(nestedCwd, { recursive: true }); + + const result = runHook(command, { + cwd: nestedCwd, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: fakeHome, + USERPROFILE: path.join(tmpDir, 'unused-userprofile'), + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({}); + }); + + test('required: missing project env denies 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 result = runHook(command, { + cwd: tmpDir, + 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.', + }, + }); + }); + + test('required: missing project env denies 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 result = runHookInPowerShell(command, { + cwd: tmpDir, + 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.', + }, + }); + }); + + test('required: hook runs in PowerShell with USERPROFILE home fallback', () => { + 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 fakeHome = path.join(tmpDir, 'powershell-home'); + fs.mkdirSync(path.join(fakeHome, '.claude', 'skills', 'gstack', 'bin'), { + recursive: true, + }); + + const result = runHookInPowerShell(command, { + cwd: tmpDir, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: '', + USERPROFILE: fakeHome, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({}); + }); + + test('required: missing gstack returns an intentional deny, not a hook error', () => { + 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 fakeHome = path.join(tmpDir, 'home-without-gstack'); + fs.mkdirSync(fakeHome, { recursive: true }); + + const result = runHook(command, { + cwd: tmpDir, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: fakeHome, + USERPROFILE: fakeHome, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('BLOCKED: gstack is not installed globally.'); + expect(result.stderr).toContain('git clone --depth 1'); + const decision = JSON.parse(result.stdout); + expect(decision).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining( + 'Then restart your AI coding tool.', + ), + }, + }); + }); + + test('required: rerun upgrades a legacy Bash hook without duplicates', () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'check-gstack.sh'), '#!/bin/bash\n'); + fs.writeFileSync( + path.join(tmpDir, '.claude', 'settings.json'), + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: 'Skill', + hooks: [{ + type: 'command', + command: '"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh"', + }], + }, + ], + }, + }), + ); + + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const settings = JSON.parse( + fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), + ); + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1); + expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain( + 'check-gstack.cjs', + ); }); test('idempotent: running twice does not duplicate CLAUDE.md section', () => { @@ -291,7 +530,11 @@ describe('gstack-team-init', () => { fs.mkdirSync(skillsDir, { recursive: true }); const targetDir = mkTmpDir(); fs.writeFileSync(path.join(targetDir, 'VERSION'), '0.14.0.0'); - fs.symlinkSync(targetDir, path.join(skillsDir, 'gstack')); + fs.symlinkSync( + targetDir, + path.join(skillsDir, 'gstack'), + process.platform === 'win32' ? 'junction' : 'dir', + ); const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); expect(result.exitCode).toBe(0); @@ -329,7 +572,7 @@ describe('setup --team / --no-team / -q', () => { test( 'setup -q produces no stdout', () => { - const result = run(`${path.join(ROOT, 'setup')} -q`, { cwd: ROOT }); + const result = run(`${bashCommand(path.join(ROOT, 'setup'))} -q`, { cwd: ROOT }); // -q should suppress informational output (may still have some output from build) // The key test is that the "Skill naming:" prompt and "gstack ready" messages are suppressed expect(result.stdout).not.toContain('Skill naming:'); @@ -341,8 +584,7 @@ describe('setup --team / --no-team / -q', () => { test( 'setup --local prints deprecation warning', () => { - // stderr capture: run via bash redirect so we can capture stderr - const result = run(`bash -c '${path.join(ROOT, 'setup')} --local -q 2>&1'`, { cwd: ROOT }); + const result = run(`${bashCommand(path.join(ROOT, 'setup'))} --local -q 2>&1`, { cwd: ROOT }); expect(result.stdout).toContain('deprecated'); }, 180_000, From 472f1b3df5d0850a0cd04cb4ca26a04ee8032d81 Mon Sep 17 00:00:00 2001 From: neallee Date: Fri, 10 Jul 2026 20:07:32 +0800 Subject: [PATCH 2/6] 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> --- README.md | 2 +- bin/gstack-team-init | 2 +- implementation-notes.html | 1 + test/team-mode.test.ts | 92 ++++++++++++++++++++++++++++++--------- 4 files changed, 74 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 8233848d68..8f191f445e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 74cd5af5d7..2687f7d73c 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -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; diff --git a/implementation-notes.html b/implementation-notes.html index 1de10a6d7b..c2d307d35f 100644 --- a/implementation-notes.html +++ b/implementation-notes.html @@ -14,6 +14,7 @@

Design decisions

  • Required mode generates .claude/hooks/check-gstack.cjs. The explicit CommonJS extension is unaffected by a consumer repository's package.json module type.
  • The one registered command launches Node 18+ and resolves CLAUDE_PROJECT_DIR inside JavaScript through process.env. It contains no Bash, PowerShell, or cmd environment expansion.
  • The launcher validates CLAUDE_PROJECT_DIR before calling path.join. Missing or empty project context emits a structured PreToolUse denial, writes a clear reason to stderr, and exits zero instead of surfacing a hook execution error.
  • +
  • Path resolution and module loading run inside the same try/catch. 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.
  • The generated hook resolves the user home in HOME, USERPROFILE, then os.homedir() order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.
  • The hook contract follows docs/spikes/claude-code-hook-mutation.md: pass-through writes {}; denial writes a hookSpecificOutput object for PreToolUse with permissionDecision: "deny".
  • Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and permissionDecisionReason, so the runner does not report a hook execution error.
  • diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index a17fb6938f..e3101a3ec9 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -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', () => { From 5f1e896f908f02068793a21fc88008b78cf353f4 Mon Sep 17 00:00:00 2001 From: neallee Date: Fri, 10 Jul 2026 20:59:35 +0800 Subject: [PATCH 3/6] fix: remove legacy team hook on migration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bin/gstack-team-init | 7 +++++++ implementation-notes.html | 2 +- test/team-mode.test.ts | 30 ++++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 2687f7d73c..a416d40fa2 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -215,6 +215,13 @@ HOOK_EOF " 2>/dev/null GENERATED+=(".claude/settings.json") echo " + .claude/settings.json — PreToolUse hook registered" + + # Remove the obsolete Bash hook only after its replacement is generated and + # registered successfully. Other project hooks are left untouched. + if [ -e "$HOOKS_DIR/check-gstack.sh" ] || [ -L "$HOOKS_DIR/check-gstack.sh" ]; then + rm "$HOOKS_DIR/check-gstack.sh" + echo " - .claude/hooks/check-gstack.sh — removed legacy enforcement hook" + fi else echo " ! bun not found — manually add the PreToolUse hook to .claude/settings.json" fi diff --git a/implementation-notes.html b/implementation-notes.html index c2d307d35f..d4e2fad0ff 100644 --- a/implementation-notes.html +++ b/implementation-notes.html @@ -18,7 +18,7 @@

    Design decisions

  • The generated hook resolves the user home in HOME, USERPROFILE, then os.homedir() order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.
  • The hook contract follows docs/spikes/claude-code-hook-mutation.md: pass-through writes {}; denial writes a hookSpecificOutput object for PreToolUse with permissionDecision: "deny".
  • Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and permissionDecisionReason, so the runner does not report a hook execution error.
  • -
  • Rerunning required mode replaces a matching legacy check-gstack.sh registration and removes duplicate matching registrations.
  • +
  • Rerunning required mode replaces a matching legacy check-gstack.sh registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. Unrelated project hooks remain unchanged.
  • Deviations

    diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index e3101a3ec9..b555f471cb 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -397,7 +397,7 @@ describe('gstack-team-init', () => { }); expectStructuredDeny(result, MISSING_PROJECT_REASON); - }); + }, 30_000); test('required: stale project paths deny through the platform default shell', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); @@ -445,7 +445,7 @@ describe('gstack-team-init', () => { expectStructuredDeny(result, HOOK_LOAD_REASON); expect(result.stderr).not.toContain(projectDir); } - }); + }, 30_000); test('required: hook runs in PowerShell with USERPROFILE home fallback', () => { if (process.platform !== 'win32') return; @@ -472,7 +472,7 @@ describe('gstack-team-init', () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe(''); expect(JSON.parse(result.stdout)).toEqual({}); - }); + }, 30_000); test('required: missing gstack returns an intentional deny, not a hook error', () => { run(`${TEAM_INIT} required`, { cwd: tmpDir }); @@ -507,10 +507,14 @@ describe('gstack-team-init', () => { }); }); - test('required: rerun upgrades a legacy Bash hook without duplicates', () => { + test('required: rerun removes a legacy Bash hook without touching other hooks', () => { const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + const legacyHook = path.join(hooksDir, 'check-gstack.sh'); + const unrelatedHook = path.join(hooksDir, 'check-project.cjs'); + const unrelatedHookContents = "console.log('project hook');\n"; fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'check-gstack.sh'), '#!/bin/bash\n'); + fs.writeFileSync(legacyHook, '#!/bin/bash\n'); + fs.writeFileSync(unrelatedHook, unrelatedHookContents); fs.writeFileSync( path.join(tmpDir, '.claude', 'settings.json'), JSON.stringify({ @@ -527,17 +531,31 @@ describe('gstack-team-init', () => { }, }), ); + execSync('git add .claude', { cwd: tmpDir }); + execSync('git commit -m "add legacy hook"', { cwd: tmpDir }); + + run(`${TEAM_INIT} required`, { cwd: tmpDir }); + expect(fs.existsSync(legacyHook)).toBe(false); + expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); run(`${TEAM_INIT} required`, { cwd: tmpDir }); const settings = JSON.parse( fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), ); + expect(fs.existsSync(legacyHook)).toBe(false); + expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); + expect( + execSync('git status --short -- .claude/hooks/check-gstack.sh', { + cwd: tmpDir, + encoding: 'utf-8', + }), + ).toBe(' D .claude/hooks/check-gstack.sh\n'); expect(settings.hooks.PreToolUse).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain( 'check-gstack.cjs', ); - }); + }, 30_000); test('idempotent: running twice does not duplicate CLAUDE.md section', () => { run(`${TEAM_INIT} optional`, { cwd: tmpDir }); From 7233eb0d6003973f456f328fbcdd66c860257f2a Mon Sep 17 00:00:00 2001 From: neallee Date: Fri, 10 Jul 2026 21:25:58 +0800 Subject: [PATCH 4/6] fix: stage legacy team hook deletion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- bin/gstack-team-init | 3 +++ implementation-notes.html | 2 +- test/team-mode.test.ts | 40 +++++++++++++++++++++++++++++++-------- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/bin/gstack-team-init b/bin/gstack-team-init index a416d40fa2..bf5a074283 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -220,6 +220,9 @@ HOOK_EOF # registered successfully. Other project hooks are left untouched. if [ -e "$HOOKS_DIR/check-gstack.sh" ] || [ -L "$HOOKS_DIR/check-gstack.sh" ]; then rm "$HOOKS_DIR/check-gstack.sh" + if (cd "$REPO_ROOT" && git ls-files --error-unmatch -- .claude/hooks/check-gstack.sh >/dev/null 2>&1); then + GENERATED+=(".claude/hooks/check-gstack.sh") + fi echo " - .claude/hooks/check-gstack.sh — removed legacy enforcement hook" fi else diff --git a/implementation-notes.html b/implementation-notes.html index d4e2fad0ff..c740af189c 100644 --- a/implementation-notes.html +++ b/implementation-notes.html @@ -18,7 +18,7 @@

    Design decisions

  • The generated hook resolves the user home in HOME, USERPROFILE, then os.homedir() order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.
  • The hook contract follows docs/spikes/claude-code-hook-mutation.md: pass-through writes {}; denial writes a hookSpecificOutput object for PreToolUse with permissionDecision: "deny".
  • Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and permissionDecisionReason, so the runner does not report a hook execution error.
  • -
  • Rerunning required mode replaces a matching legacy check-gstack.sh registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. Unrelated project hooks remain unchanged.
  • +
  • Rerunning required mode replaces a matching legacy check-gstack.sh registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. If Git tracks the deleted hook, the printed git add command includes its path so the suggested migration commit stages the deletion; untracked removed hooks are omitted because they have no index change. Unrelated project hooks remain unchanged.
  • Deviations

    diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index b555f471cb..17c815160b 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -302,7 +302,7 @@ describe('gstack-team-init', () => { }); test('required: creates enforcement hook', () => { - run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const result = run(`${TEAM_INIT} required`, { cwd: tmpDir }); const hookPath = path.join(tmpDir, '.claude', 'hooks', 'check-gstack.cjs'); expect(fs.existsSync(hookPath)).toBe(true); expect( @@ -319,6 +319,7 @@ describe('gstack-team-init', () => { expect(hook).toContain("permissionDecision: 'deny'"); expect(hook).toContain('BLOCKED: gstack is not installed'); expect(hook).not.toContain('#!/bin/bash'); + expect(result.stdout).not.toMatch(/git add .*check-gstack\.sh/); }); test('required: registers one shell-neutral project hook', () => { @@ -534,22 +535,45 @@ describe('gstack-team-init', () => { execSync('git add .claude', { cwd: tmpDir }); execSync('git commit -m "add legacy hook"', { cwd: tmpDir }); - run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const migration = run(`${TEAM_INIT} required`, { cwd: tmpDir }); expect(fs.existsSync(legacyHook)).toBe(false); expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); + const suggestedGitAdd = migration.stdout + .split('\n') + .find(line => line.startsWith(' git add ')) + ?.trim(); + if (!suggestedGitAdd) { + throw new Error('gstack-team-init did not print a git add command'); + } + expect(suggestedGitAdd.split(/\s+/)).toContain( + '.claude/hooks/check-gstack.sh', + ); + execSync(suggestedGitAdd, { cwd: tmpDir }); + execSync('git commit -m "migrate required hook"', { cwd: tmpDir }); + expect( + execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }), + ).toBe(''); - run(`${TEAM_INIT} required`, { cwd: tmpDir }); + const rerun = run(`${TEAM_INIT} required`, { cwd: tmpDir }); const settings = JSON.parse( fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'), ); expect(fs.existsSync(legacyHook)).toBe(false); expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents); + const rerunGitAdd = rerun.stdout + .split('\n') + .find(line => line.startsWith(' git add ')) + ?.trim(); + if (!rerunGitAdd) { + throw new Error('gstack-team-init rerun did not print a git add command'); + } + expect(rerunGitAdd.split(/\s+/)).not.toContain( + '.claude/hooks/check-gstack.sh', + ); + execSync(rerunGitAdd, { cwd: tmpDir }); expect( - execSync('git status --short -- .claude/hooks/check-gstack.sh', { - cwd: tmpDir, - encoding: 'utf-8', - }), - ).toBe(' D .claude/hooks/check-gstack.sh\n'); + execSync('git status --short', { cwd: tmpDir, encoding: 'utf-8' }), + ).toBe(''); expect(settings.hooks.PreToolUse).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain( From 3623783b8a96c389bbc49e8bd2727dd1a0d4deb0 Mon Sep 17 00:00:00 2001 From: neallee Date: Fri, 10 Jul 2026 21:46:20 +0800 Subject: [PATCH 5/6] docs: sanitize Windows test dispatch note Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .claude/dispatch/inbox.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/dispatch/inbox.md b/.claude/dispatch/inbox.md index c2d5e4f561..41069cfa88 100644 --- a/.claude/dispatch/inbox.md +++ b/.claude/dispatch/inbox.md @@ -1,3 +1,3 @@ # Dispatch inbox -- Investigate the curated Windows-safe suite prerequisites and filtering. `bun run test:windows` currently includes `test/readme-throughput.test.ts`, whose four script subprocess tests return status `-1` on this Windows environment, and `browse/test/tab-isolation.test.ts`, which aborts when the generated `browse/src/server-node.mjs` bundle is absent. Receipt: reproduced identically on detached `origin/main` SHA `7c9df1c568a9ea745508f679a329332b2c338063` after `bun install --frozen-lockfile`, using `bun run test:windows` with Git Bash prepended to `PATH`; result was 27 pass, 5 fail, 1 error, exit 1. Full base log: `C:\Users\kunle\.copilot\session-state\8631d6e3-fb62-412d-8bdc-dc6a8f3a46be\files\main-test-windows.log`. The temporary worktree was removed afterward. This is outside the cross-platform team-hook fix; `test/team-mode.test.ts` passes in full. +- [ ] Investigate Windows-safe suite filtering and prerequisites for `test/readme-throughput.test.ts` and `browse/test/tab-isolation.test.ts`; `origin/main` reproduced the failures. (files: package.json, test/readme-throughput.test.ts, browse/test/tab-isolation.test.ts) — noticed 2026-07-10 From d1423cd1a96a1dbadcc648bf7fbd775cff6d1db5 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 10 Jul 2026 14:30:09 -0700 Subject: [PATCH 6/6] fix: fail closed across hook decision schemas --- .claude/dispatch/inbox.md | 3 -- README.md | 2 +- bin/gstack-team-init | 16 ++++++---- implementation-notes.html | 37 ----------------------- test/team-mode.test.ts | 63 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 73 insertions(+), 48 deletions(-) delete mode 100644 .claude/dispatch/inbox.md delete mode 100644 implementation-notes.html diff --git a/.claude/dispatch/inbox.md b/.claude/dispatch/inbox.md deleted file mode 100644 index 41069cfa88..0000000000 --- a/.claude/dispatch/inbox.md +++ /dev/null @@ -1,3 +0,0 @@ -# Dispatch inbox - -- [ ] Investigate Windows-safe suite filtering and prerequisites for `test/readme-throughput.test.ts` and `browse/test/tab-isolation.test.ts`; `origin/main` reproduced the failures. (files: package.json, test/readme-throughput.test.ts, browse/test/tab-isolation.test.ts) — noticed 2026-07-10 diff --git a/README.md b/README.md index 8f191f445e..121f42099c 100644 --- a/README.md +++ b/README.md @@ -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 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). +Your project stays small, and everyone gets the same up-to-date version of gstack. If gstack is missing or its safety check cannot run, Claude Code and Copilot stop and explain what to do. If everything is ready, the check stays out of the way and your usual permission questions still appear. Updates happen quietly when a Claude Code session starts, at most once an hour. Swap `required` for `optional` if you'd rather nudge teammates than block them. diff --git a/bin/gstack-team-init b/bin/gstack-team-init index bf5a074283..6157bad9c6 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -155,11 +155,15 @@ Then restart your AI coding tool. `; process.stderr.write(instructions); + const decision = { + permissionDecision: 'deny', + permissionDecisionReason: instructions, + }; process.stdout.write(`${JSON.stringify({ + ...decision, hookSpecificOutput: { hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: instructions, + ...decision, }, })}\n`); } @@ -179,10 +183,11 @@ HOOK_EOF if (!settings.hooks) settings.hooks = {}; if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []; - 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.')}}\\\"\"; + const hookMatcher = 'Skill|skill'; + const hookCommand = \"node -e \\\"const deny=reason=>{const decision={permissionDecision:'deny',permissionDecisionReason:reason};console.error(reason);console.log(JSON.stringify({...decision,hookSpecificOutput:{hookEventName:'PreToolUse',...decision}}))};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; + if (!['Skill', hookMatcher].includes(entry.matcher) || !entry.hooks) return true; const matchingHooks = entry.hooks.filter( h => h.command && h.command.includes('check-gstack') @@ -193,6 +198,7 @@ HOOK_EOF h => !h.command || !h.command.includes('check-gstack') ); if (!found) { + entry.matcher = hookMatcher; entry.hooks.push({ type: 'command', command: hookCommand }); found = true; } @@ -201,7 +207,7 @@ HOOK_EOF if (!found) { settings.hooks.PreToolUse.push({ - matcher: 'Skill', + matcher: hookMatcher, hooks: [{ type: 'command', command: hookCommand diff --git a/implementation-notes.html b/implementation-notes.html deleted file mode 100644 index c740af189c..0000000000 --- a/implementation-notes.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - Cross-platform gstack enforcement hook - - -
    -

    Cross-platform gstack enforcement hook

    - -

    Design decisions

    -
      -
    • Required mode generates .claude/hooks/check-gstack.cjs. The explicit CommonJS extension is unaffected by a consumer repository's package.json module type.
    • -
    • The one registered command launches Node 18+ and resolves CLAUDE_PROJECT_DIR inside JavaScript through process.env. It contains no Bash, PowerShell, or cmd environment expansion.
    • -
    • The launcher validates CLAUDE_PROJECT_DIR before calling path.join. Missing or empty project context emits a structured PreToolUse denial, writes a clear reason to stderr, and exits zero instead of surfacing a hook execution error.
    • -
    • Path resolution and module loading run inside the same try/catch. 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.
    • -
    • The generated hook resolves the user home in HOME, USERPROFILE, then os.homedir() order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.
    • -
    • The hook contract follows docs/spikes/claude-code-hook-mutation.md: pass-through writes {}; denial writes a hookSpecificOutput object for PreToolUse with permissionDecision: "deny".
    • -
    • Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and permissionDecisionReason, so the runner does not report a hook execution error.
    • -
    • Rerunning required mode replaces a matching legacy check-gstack.sh registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. If Git tracks the deleted hook, the printed git add command includes its path so the suggested migration commit stages the deletion; untracked removed hooks are omitted because they have no index change. Unrelated project hooks remain unchanged.
    • -
    - -

    Deviations

    -

    None. The implementation uses the requested Node launcher, one hook handler, and the repository's documented hook output schema.

    - -

    Tradeoffs

    -
      -
    • The generated project now requires Node 18+ at hook execution time. This removes shell selection ambiguity and matches the requested runtime floor.
    • -
    • The generated hook is invoked through Node rather than as an executable file, so POSIX executable mode bits are no longer part of the contract.
    • -
    - -

    Open questions

    -

    None.

    -
    - - diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index 17c815160b..5a4715e2e4 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -89,6 +89,8 @@ function expectStructuredDeny( expect(result.stderr.trim()).toBe(reason); expect(result.stderr).not.toContain('MODULE_NOT_FOUND'); expect(JSON.parse(result.stdout)).toEqual({ + permissionDecision: 'deny', + permissionDecisionReason: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', @@ -329,11 +331,11 @@ describe('gstack-team-init', () => { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); expect(settings.hooks.PreToolUse).toHaveLength(1); const entry = settings.hooks.PreToolUse[0]; - expect(entry.matcher).toBe('Skill'); + expect(entry.matcher).toBe('Skill|skill'); expect(entry.hooks).toHaveLength(1); expect(entry.hooks[0]).not.toHaveProperty('shell'); expect(entry.hooks[0].command).toBe( - `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.')}}"`, + `node -e "const deny=reason=>{const decision={permissionDecision:'deny',permissionDecisionReason:reason};console.error(reason);console.log(JSON.stringify({...decision,hookSpecificOutput:{hookEventName:'PreToolUse',...decision}}))};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%/, @@ -498,6 +500,10 @@ describe('gstack-team-init', () => { expect(result.stderr).toContain('git clone --depth 1'); const decision = JSON.parse(result.stdout); expect(decision).toEqual({ + permissionDecision: 'deny', + permissionDecisionReason: expect.stringContaining( + 'Then restart your AI coding tool.', + ), hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', @@ -508,6 +514,58 @@ describe('gstack-team-init', () => { }); }); + test('required: install verification errors return a structured deny', () => { + 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 fakeHome = path.join(tmpDir, 'unreadable-home'); + const preload = path.join(tmpDir, 'fail-gstack-stat.cjs'); + fs.mkdirSync(fakeHome, { recursive: true }); + fs.writeFileSync( + preload, + `'use strict'; +const fs = require('node:fs'); +const original = fs.statSync; +fs.statSync = function (target, ...args) { + if (String(target).replace(/\\\\/g, '/').includes('skills/gstack/bin')) { + const error = new Error('injected verification failure'); + error.code = 'EACCES'; + throw error; + } + return original.call(this, target, ...args); +}; +`, + ); + + const result = runHook(command, { + cwd: tmpDir, + env: { + CLAUDE_PROJECT_DIR: tmpDir, + HOME: fakeHome, + USERPROFILE: fakeHome, + NODE_OPTIONS: `--require=${preload}`, + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + 'BLOCKED: the global gstack install could not be verified.', + ); + expect(result.stderr).not.toContain('injected verification failure'); + const decision = JSON.parse(result.stdout); + expect(decision.permissionDecision).toBe('deny'); + expect(decision.permissionDecisionReason).toContain( + 'BLOCKED: the global gstack install could not be verified.', + ); + expect(decision.hookSpecificOutput).toEqual({ + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: decision.permissionDecisionReason, + }); + }); + test('required: rerun removes a legacy Bash hook without touching other hooks', () => { const hooksDir = path.join(tmpDir, '.claude', 'hooks'); const legacyHook = path.join(hooksDir, 'check-gstack.sh'); @@ -576,6 +634,7 @@ describe('gstack-team-init', () => { ).toBe(''); expect(settings.hooks.PreToolUse).toHaveLength(1); expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1); + expect(settings.hooks.PreToolUse[0].matcher).toBe('Skill|skill'); expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain( 'check-gstack.cjs', );