diff --git a/README.md b/README.md index 8233848d6..8f191f445 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 74cd5af5d..2687f7d73 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 1de10a6d7..c2d307d35 100644 --- a/implementation-notes.html +++ b/implementation-notes.html @@ -14,6 +14,7 @@
  • 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 a17fb6938..e3101a3ec 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', () => {