From dc7f1557c606e0742198f2a1e9bd5b6f2a411a44 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 18:56:20 -0400 Subject: [PATCH] feat(#768): pre-populate settings.json permissions.allow/deny for Claude Code (#819) * feat(#768): pre-populate settings.json permissions.allow/deny for Claude Code Adds mergeClaudePermissions() to bin/install.js which non-destructively appends GSD's known-safe tool-call patterns to permissions.allow and defense-in-depth credential-file patterns to permissions.deny during Claude Code installs. Merge is idempotent (no duplicates on reinstall) and additive (existing user entries preserved). Uninstall removes only the exact GSD-owned entries. Co-Authored-By: Claude Opus 4.8 * chore: update changeset pr number to 819 --------- Co-authored-by: Claude Opus 4.8 --- .../768-claude-permissions-prepopulate.md | 5 + CONTEXT.md | 2 +- bin/install.js | 106 +++++++ docs/USER-GUIDE.md | 29 ++ tests/install-regressions.test.cjs | 297 +++++++++++++++++- 5 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 .changeset/768-claude-permissions-prepopulate.md diff --git a/.changeset/768-claude-permissions-prepopulate.md b/.changeset/768-claude-permissions-prepopulate.md new file mode 100644 index 000000000..9aab9eaac --- /dev/null +++ b/.changeset/768-claude-permissions-prepopulate.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 819 +--- +**Installer pre-populates `permissions.allow`/`deny` for Claude Code** — fresh Claude Code installs now receive GSD's known-safe tool-call patterns (`Bash(npx gsd-core *)`, `Read(.planning/*)`, `Write(.planning/*)`, `Read(STATE.md)`, `Write(STATE.md)`) in `settings.json` out of the box, eliminating first-run approval prompts. A `deny` block for credential files (`Read(.env)`, `Read(.env.*)`, `Read(.secrets)`) is also added for defense-in-depth. The merge is additive and idempotent; existing user-set entries are preserved. Uninstall removes only GSD-owned entries. (#768) diff --git a/CONTEXT.md b/CONTEXT.md index 7ae30f170..ae909288b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -104,7 +104,7 @@ Module owning runtime identity normalization at runtime-selection seams. Canonic Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply. ### Installer Module -Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. +Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module. ### Package Identity Module [Planned] Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. diff --git a/bin/install.js b/bin/install.js index 2d039002c..8e83c2eba 100755 --- a/bin/install.js +++ b/bin/install.js @@ -97,6 +97,69 @@ function isCodexHooksFeatureKey(key) { return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key); } +// #768 \u2014 Claude Code permissions.allow / permissions.deny entries. +// Pre-populated during Claude installs to eliminate first-run approval friction +// for gsd-core's own known-safe tool calls, and to add defense-in-depth deny +// entries for common credential files. +// +// Format: each string uses Claude Code's documented permission rule syntax \u2014 +// "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)" +// "Tool" (bare tool name, no pattern) +// +// Merge policy: additive, non-destructive \u2014 existing user entries are preserved; +// GSD entries are appended only when not already present (idempotent). +const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([ + 'Bash(npx gsd-core *)', + 'Read(.planning/*)', + 'Write(.planning/*)', + 'Read(STATE.md)', + 'Write(STATE.md)', +]); +const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([ + 'Read(.env)', + 'Read(.env.*)', + 'Read(.secrets)', +]); + +/** + * Merge GSD-owned permission entries into a Claude Code settings object. + * + * Additive and idempotent: existing allow/deny entries are preserved; GSD + * entries are appended only if not already present. No other permission sub-keys + * (ask, disableBypassPermissionsMode, etc.) are touched. + * + * Defensive: if settings is not a plain object, returns immediately without + * throwing. If permissions.allow / permissions.deny exist but are not arrays + * (malformed settings), they are replaced with valid arrays. + * + * @param {object} settings - The parsed settings.json object to mutate in-place. + */ +function mergeClaudePermissions(settings) { + if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return; + + if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) { + settings.permissions = {}; + } + + if (!Array.isArray(settings.permissions.allow)) { + settings.permissions.allow = []; + } + if (!Array.isArray(settings.permissions.deny)) { + settings.permissions.deny = []; + } + + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + if (!settings.permissions.allow.includes(entry)) { + settings.permissions.allow.push(entry); + } + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + if (!settings.permissions.deny.includes(entry)) { + settings.permissions.deny.push(entry); + } + } +} + // Copilot instructions marker constants const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; @@ -7801,6 +7864,37 @@ function uninstall(isGlobal, runtime = 'claude') { delete settings.hooks; } + // #768 — Remove GSD-owned Claude permissions from settings.json. + // Applies only to Claude uninstalls. Filter only the exact GSD-owned entries + // to preserve any user-added allow/deny entries. + // Uses a local flag to avoid the shared `settingsModified` producing a false + // "Removed GSD permissions" message when only hooks/statusline changed. + if (runtime === 'claude' && settings.permissions) { + let permissionsModified = false; + if (Array.isArray(settings.permissions.allow)) { + const before = settings.permissions.allow.length; + settings.permissions.allow = settings.permissions.allow.filter( + (e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e) + ); + if (settings.permissions.allow.length !== before) { + permissionsModified = true; + } + } + if (Array.isArray(settings.permissions.deny)) { + const before = settings.permissions.deny.length; + settings.permissions.deny = settings.permissions.deny.filter( + (e) => !GSD_CLAUDE_DENY_PERMISSIONS.includes(e) + ); + if (settings.permissions.deny.length !== before) { + permissionsModified = true; + } + } + if (permissionsModified) { + settingsModified = true; + console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`); + } + } + if (settingsModified) { writeSettings(settingsPath, settings); removedCount++; @@ -10803,6 +10897,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS } } + // #768 — Pre-populate permissions.allow/deny for Claude Code installs. + // Merges GSD-owned entries non-destructively (preserves existing user permissions). + // Scoped to Claude only: gemini/antigravity/qwen/hermes/codebuddy also write + // settings.json but use different runtimes and do not use these permission strings. + if (runtime === 'claude') { + mergeClaudePermissions(settings); + } + // Write settings when runtime supports settings.json. // #3002 CR: defense-in-depth — re-run validateHookFields right before // serialization. The push-site guards above already skip null-command @@ -11574,6 +11676,10 @@ module.exports = { convertClaudeCommandToKiloSkill, configureOpencodePermissions, neutralizeAgentReferences, + // #768 — Claude Code permissions pre-population + mergeClaudePermissions, + GSD_CLAUDE_ALLOW_PERMISSIONS, + GSD_CLAUDE_DENY_PERMISSIONS, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, getDirName, diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 1e3eef13d..64f9e928c 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -817,6 +817,35 @@ See [docs/manual-update.md](manual-update.md) for a step-by-step manual update p When a workflow fails in a non-obvious way, run `/gsd-forensics` to generate a diagnostic report covering git history anomalies, artifact integrity, and state inconsistencies. Output goes to `.planning/forensics/`. +### Pre-populated Permissions (Claude Code) + +Since v1.3.1, the installer pre-populates `~/.claude/settings.json` (or +`settings.local.json` for local installs) with the core permissions GSD needs: + +```json +{ + "permissions": { + "allow": [ + "Bash(npx gsd-core *)", + "Read(.planning/*)", + "Write(.planning/*)", + "Read(STATE.md)", + "Write(STATE.md)" + ], + "deny": [ + "Read(.env)", + "Read(.env.*)", + "Read(.secrets)" + ] + } +} +``` + +These entries eliminate first-run approval prompts for GSD's own tool calls. The +merge is non-destructive — your existing permissions are preserved and GSD entries +are only appended. Uninstalling GSD removes exactly these entries and preserves +any others. + ### Executor Subagent Gets "Permission denied" on Bash Commands Add the required patterns to `~/.claude/settings.json`. Core patterns needed for all stacks: diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs index 6d6239fdd..f7fb9213f 100644 --- a/tests/install-regressions.test.cjs +++ b/tests/install-regressions.test.cjs @@ -37,7 +37,7 @@ try { else process.env.GSD_TEST_MODE = savedTestMode; } -const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {}; +const { installRuntimeArtifacts, uninstallRuntimeArtifacts, mergeClaudePermissions, GSD_CLAUDE_ALLOW_PERMISSIONS, GSD_CLAUDE_DENY_PERMISSIONS } = installExports || {}; const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); @@ -304,3 +304,298 @@ describe('U3 (#2973): uninstallRuntimeArtifacts hermes migrates dev-preferences assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my hermes prefs\n'); }); }); + +// ─── #768 — mergeClaudePermissions: pre-populate permissions.allow/deny ────── + +describe('mergeClaudePermissions (#768): exports and permission constants', () => { + test('mergeClaudePermissions is exported', () => { + assert.strictEqual(typeof mergeClaudePermissions, 'function', + 'mergeClaudePermissions must be exported from bin/install.js'); + }); + + test('GSD_CLAUDE_ALLOW_PERMISSIONS is a non-empty array of strings', () => { + assert.ok(Array.isArray(GSD_CLAUDE_ALLOW_PERMISSIONS), + 'GSD_CLAUDE_ALLOW_PERMISSIONS must be an array'); + assert.ok(GSD_CLAUDE_ALLOW_PERMISSIONS.length > 0, + 'GSD_CLAUDE_ALLOW_PERMISSIONS must not be empty'); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + assert.strictEqual(typeof entry, 'string', `allow entry must be a string, got: ${JSON.stringify(entry)}`); + } + }); + + test('GSD_CLAUDE_DENY_PERMISSIONS is a non-empty array of strings', () => { + assert.ok(Array.isArray(GSD_CLAUDE_DENY_PERMISSIONS), + 'GSD_CLAUDE_DENY_PERMISSIONS must be an array'); + assert.ok(GSD_CLAUDE_DENY_PERMISSIONS.length > 0, + 'GSD_CLAUDE_DENY_PERMISSIONS must not be empty'); + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + assert.strictEqual(typeof entry, 'string', `deny entry must be a string, got: ${JSON.stringify(entry)}`); + } + }); +}); + +describe('mergeClaudePermissions (#768): fresh settings object', () => { + test('populates permissions.allow and permissions.deny on empty settings', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(Array.isArray(settings.permissions?.allow), 'permissions.allow must be an array'); + assert.ok(Array.isArray(settings.permissions?.deny), 'permissions.deny must be an array'); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + assert.ok(settings.permissions.allow.includes(entry), + `permissions.allow must contain "${entry}"`); + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + assert.ok(settings.permissions.deny.includes(entry), + `permissions.deny must contain "${entry}"`); + } + }); + + test('includes Bash(npx gsd-core *) in allow', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'), + 'permissions.allow must contain Bash(npx gsd-core *)'); + }); + + test('includes planning path entries in allow', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.allow.includes('Read(.planning/*)'), + 'permissions.allow must contain Read(.planning/*)'); + assert.ok(settings.permissions.allow.includes('Write(.planning/*)'), + 'permissions.allow must contain Write(.planning/*)'); + }); + + test('includes STATE.md entries in allow', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.allow.includes('Read(STATE.md)'), + 'permissions.allow must contain Read(STATE.md)'); + assert.ok(settings.permissions.allow.includes('Write(STATE.md)'), + 'permissions.allow must contain Write(STATE.md)'); + }); + + test('includes .env denial entries in deny', () => { + const settings = {}; + mergeClaudePermissions(settings); + assert.ok(settings.permissions.deny.includes('Read(.env)'), + 'permissions.deny must contain Read(.env)'); + assert.ok(settings.permissions.deny.includes('Read(.env.*)'), + 'permissions.deny must contain Read(.env.*)'); + assert.ok(settings.permissions.deny.includes('Read(.secrets)'), + 'permissions.deny must contain Read(.secrets)'); + }); +}); + +describe('mergeClaudePermissions (#768): non-destructive merge', () => { + test('appends to existing allow/deny arrays without overwriting user entries', () => { + const settings = { + permissions: { + allow: ['Bash(git *)'], + deny: ['WebSearch'], + }, + }; + mergeClaudePermissions(settings); + // User entries must be preserved + assert.ok(settings.permissions.allow.includes('Bash(git *)'), + 'existing allow entries must be preserved'); + assert.ok(settings.permissions.deny.includes('WebSearch'), + 'existing deny entries must be preserved'); + // GSD entries must be added + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'), + 'GSD allow entry must be added'); + assert.ok(settings.permissions.deny.includes('Read(.env)'), + 'GSD deny entry must be added'); + }); + + test('does not duplicate entries on repeated calls (idempotent)', () => { + const settings = {}; + mergeClaudePermissions(settings); + mergeClaudePermissions(settings); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + const count = settings.permissions.allow.filter((e) => e === entry).length; + assert.strictEqual(count, 1, `allow entry "${entry}" must appear exactly once after two merges`); + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + const count = settings.permissions.deny.filter((e) => e === entry).length; + assert.strictEqual(count, 1, `deny entry "${entry}" must appear exactly once after two merges`); + } + }); + + test('preserves other permission sub-keys (ask, disableBypassPermissionsMode)', () => { + const settings = { + permissions: { + ask: ['Bash'], + disableBypassPermissionsMode: 'disable', + allow: [], + deny: [], + }, + }; + mergeClaudePermissions(settings); + assert.deepStrictEqual(settings.permissions.ask, ['Bash'], + 'permissions.ask must be preserved'); + assert.strictEqual(settings.permissions.disableBypassPermissionsMode, 'disable', + 'permissions.disableBypassPermissionsMode must be preserved'); + }); + + test('handles permissions with non-array allow/deny gracefully (replaces with array)', () => { + // If allow/deny exist but are not arrays (malformed settings), must not crash + // and must result in valid arrays. + const settings = { permissions: { allow: null, deny: null } }; + mergeClaudePermissions(settings); + assert.ok(Array.isArray(settings.permissions.allow)); + assert.ok(Array.isArray(settings.permissions.deny)); + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)')); + }); + + test('handles settings that are not plain objects (returns unchanged)', () => { + // Guard: if settings is not a plain object, do nothing + const badInputs = [null, undefined, [], 'string', 42]; + for (const bad of badInputs) { + // Must not throw + assert.doesNotThrow(() => mergeClaudePermissions(bad), + `mergeClaudePermissions must not throw on: ${JSON.stringify(bad)}`); + } + }); +}); + +describe('mergeClaudePermissions (#768): end-to-end install writes permissions to settings.json', () => { + test('--claude --global install writes GSD allow/deny entries to settings.json', (t) => { + const root = createTempDir('gsd-claude-perm-install-'); + t.after(() => cleanup(root)); + + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root], + { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + ); + + assert.strictEqual(result.status, 0, + `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + + const settingsPath = path.join(root, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), 'settings.json must exist after claude install'); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(Array.isArray(settings.permissions?.allow), + 'settings.json must have permissions.allow array'); + assert.ok(Array.isArray(settings.permissions?.deny), + 'settings.json must have permissions.deny array'); + + assert.ok(settings.permissions.allow.includes('Bash(npx gsd-core *)'), + 'settings.json permissions.allow must include Bash(npx gsd-core *)'); + assert.ok(settings.permissions.allow.includes('Read(.planning/*)'), + 'settings.json permissions.allow must include Read(.planning/*)'); + assert.ok(settings.permissions.deny.includes('Read(.env)'), + 'settings.json permissions.deny must include Read(.env)'); + }); + + test('non-claude runtime (gemini) does NOT write GSD allow/deny permissions to settings.json', (t) => { + const root = createTempDir('gsd-gemini-perm-install-'); + t.after(() => cleanup(root)); + + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--gemini', '--global', '--config-dir', root], + { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + ); + + assert.strictEqual(result.status, 0, + `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + + const settingsPath = path.join(root, 'settings.json'); + // If settings.json doesn't exist, permissions are definitely not written — pass. + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const allow = settings.permissions?.allow ?? []; + assert.ok(!allow.includes('Bash(npx gsd-core *)'), + 'Gemini settings.json must NOT include Bash(npx gsd-core *) in permissions.allow'); + } + }); + + test('--claude --global reinstall is idempotent (no duplicate permission entries)', (t) => { + const root = createTempDir('gsd-claude-perm-idempotent-'); + t.after(() => cleanup(root)); + + const spawnOpts = { + encoding: 'utf8', + env: { ...process.env, HOME: root, USERPROFILE: root }, + }; + const args = [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root]; + + // First install + const r1 = spawnSync(process.execPath, args, spawnOpts); + assert.strictEqual(r1.status, 0, `first install failed: ${r1.stderr}`); + + // Second install (reinstall) + const r2 = spawnSync(process.execPath, args, spawnOpts); + assert.strictEqual(r2.status, 0, `reinstall failed: ${r2.stderr}`); + + const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8')); + for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { + const count = (settings.permissions?.allow ?? []).filter((e) => e === entry).length; + assert.strictEqual(count, 1, + `allow entry "${entry}" must appear exactly once after two installs`); + } + for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) { + const count = (settings.permissions?.deny ?? []).filter((e) => e === entry).length; + assert.strictEqual(count, 1, + `deny entry "${entry}" must appear exactly once after two installs`); + } + }); + + test('--claude --global uninstall removes GSD permission entries from settings.json', (t) => { + const root = createTempDir('gsd-claude-perm-uninstall-'); + t.after(() => cleanup(root)); + + const spawnOpts = { + encoding: 'utf8', + env: { ...process.env, HOME: root, USERPROFILE: root }, + }; + + // Install first + const r1 = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root], + spawnOpts, + ); + assert.strictEqual(r1.status, 0, `install failed: ${r1.stderr}`); + + // Verify permissions were written + const settingsPath = path.join(root, 'settings.json'); + const afterInstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok((afterInstall.permissions?.allow ?? []).includes('Bash(npx gsd-core *)'), + 'permissions.allow must contain GSD entry after install'); + + // Now add a user permission to make sure we don't nuke it + afterInstall.permissions.allow.push('Bash(git *)'); + afterInstall.permissions.deny.push('WebSearch'); + fs.writeFileSync(settingsPath, JSON.stringify(afterInstall, null, 2) + '\n'); + + // Uninstall + const r2 = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--uninstall'], + spawnOpts, + ); + assert.strictEqual(r2.status, 0, `uninstall failed: ${r2.stderr}`); + + const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const allow = afterUninstall.permissions?.allow ?? []; + const deny = afterUninstall.permissions?.deny ?? []; + + // GSD entries must be removed + assert.ok(!allow.includes('Bash(npx gsd-core *)'), + 'GSD Bash allow entry must be removed by uninstall'); + assert.ok(!allow.includes('Read(.planning/*)'), + 'GSD Read(.planning/*) allow entry must be removed by uninstall'); + assert.ok(!deny.includes('Read(.env)'), + 'GSD Read(.env) deny entry must be removed by uninstall'); + + // User entries must survive + assert.ok(allow.includes('Bash(git *)'), + 'user Bash(git *) allow entry must survive uninstall'); + assert.ok(deny.includes('WebSearch'), + 'user WebSearch deny entry must survive uninstall'); + }); +});