diff --git a/tests/check-gap-analysis-plan-post-e2e.test.cjs b/tests/check-gap-analysis-plan-post-e2e.test.cjs index 3a3044539..282c1501f 100644 --- a/tests/check-gap-analysis-plan-post-e2e.test.cjs +++ b/tests/check-gap-analysis-plan-post-e2e.test.cjs @@ -23,7 +23,7 @@ const path = require('path'); const os = require('os'); const { spawnSync } = require('child_process'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { runGsdTools, createTempProject, cleanup, installSpawnEnv, withAmbientCapabilityHome } = require('./helpers.cjs'); const { LOOP_HOOK_POINT_CLI_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); @@ -77,7 +77,7 @@ function spawnRenderHooks(point, cwd) { cwd, encoding: 'utf8', timeout: LOOP_HOOK_POINT_CLI_TIMEOUT_MS, - env: { ...process.env, GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '' }, + env: installSpawnEnv({ GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '' }), }); return { status: result.status, @@ -137,6 +137,15 @@ describe('render-hooks plan:post — gate discovery', () => { assert.ok(envelope.rendered.includes('gap-analysis.plan-post'), 'rendered must include check query'); }); + test('#4485: render-hooks ignores capabilities installed in ambient user locations', (t) => { + withAmbientCapabilityHome(t, 'gsd-ambient-plan-post-', 'ambient-plan-post', 'plan:post'); + + const result = spawnRenderHooks('plan:post', tmpDir); + assert.strictEqual(result.status, 0, `exit non-zero: ${result.stderr}`); + const activeHooks = JSON.parse(result.stdout).activeHooks; + assert.deepStrictEqual(activeHooks.map((hook) => [hook.capId, hook.check?.query]), [['gap-analysis', 'gap-analysis.plan-post']]); + }); + test('[negative] render-hooks plan:post returns empty activeHooks when workflow.post_planning_gaps=false (gate deactivated)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), diff --git a/tests/execute-wave-post-gate-pipeline-e2e.test.cjs b/tests/execute-wave-post-gate-pipeline-e2e.test.cjs index 1643d7fde..46920c61e 100644 --- a/tests/execute-wave-post-gate-pipeline-e2e.test.cjs +++ b/tests/execute-wave-post-gate-pipeline-e2e.test.cjs @@ -29,7 +29,7 @@ const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, installSpawnEnv, withAmbientCapabilityHome } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { LOOP_HOOK_POINT_CLI_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); @@ -61,14 +61,13 @@ function gitAddCommit(dir, message) { * When raw=true the tool emits JSON; parsed is set on success. */ function runTool(args, { cwd, env = {} } = {}) { - const childEnv = { - ...process.env, + const childEnv = installSpawnEnv({ GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '', CLAUDE_CODE_SSE_PORT: '', ...env, - }; + }); const r = spawnSync(process.execPath, [GSD_TOOLS, ...args], { cwd: cwd || os.tmpdir(), encoding: 'utf8', @@ -97,6 +96,18 @@ after(() => { for (const d of tmpDirs) { try { cleanup(d); } catch { /* best-eff describe('A. loop render-hooks execute:wave:post — resolution', () => { + test('#4485: render-hooks ignores capabilities installed in ambient user locations', (t) => { + withAmbientCapabilityHome(t, 'gsd-ambient-wave-post-', 'ambient-wave-post', 'execute:wave:post'); + + const dir = makeTmpDir(); + const result = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual( + result.parsed.activeHooks.map((hook) => [hook.capId, hook.check?.query]), + [['drift', 'verify.schema-drift'], ['drift', 'verify.codebase-drift'], ['ui', 'ui.safety-gate']], + ); + }); + test('[happy] full resolution: all 3 gates present with default config', () => { const dir = makeTmpDir(); fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index a55d96cd6..c03e47c50 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -1251,7 +1251,50 @@ function writePackageSourceMarkerFixture(configDir) { return configDir; } -module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, writePackageSourceMarkerFixture, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow, captureFdSync, suppressFdAsync }; +/** Write one valid third-party gate into a synthetic user capability home. */ +function writeAmbientCapabilityGate(home, id, point) { + const capDir = path.join(home, '.gsd', 'capabilities', id); + fs.mkdirSync(capDir, { recursive: true }); + fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify({ + id, + title: 'Ambient test capability', + version: '1.0.0', + role: 'feature', + tier: 'full', + description: 'Capability outside the test fixture that must remain invisible.', + engines: { gsd: '>=1.7.0' }, + requires: [], + runtimeCompat: { supported: ['claude'], unsupported: [] }, + skills: [], + agents: [], + config: {}, + steps: [], + contributions: [], + gates: [{ point, check: { query: 'ambient.check' }, blocking: false, onError: 'skip' }], + }), 'utf8'); +} + +/** + * Put a capability in the parent process's ambient home for one serial test. + * The child must still receive installSpawnEnv()'s different sandbox home. + */ +function withAmbientCapabilityHome(t, prefix, id, point) { + const home = createTempDir(prefix); + writeAmbientCapabilityGate(home, id, point); + const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; + process.env.HOME = home; + process.env.USERPROFILE = home; + t.after(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + cleanup(home); + }); + return home; +} + +module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, writePackageSourceMarkerFixture, writeAmbientCapabilityGate, withAmbientCapabilityHome, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow, captureFdSync, suppressFdAsync }; // Lazy, for the reason builtLib() is lazy: reading either of these is what // forces the built-lib require, so a test file that needs neither can still diff --git a/tests/plan-pre-hook-e2e.test.cjs b/tests/plan-pre-hook-e2e.test.cjs index a0411a316..9265324e8 100644 --- a/tests/plan-pre-hook-e2e.test.cjs +++ b/tests/plan-pre-hook-e2e.test.cjs @@ -29,7 +29,7 @@ const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, installSpawnEnv, withAmbientCapabilityHome } = require('./helpers.cjs'); const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); @@ -66,7 +66,7 @@ const CLEAN_ENV = { /** * Run gsd-tools via spawnSync. Returns { status, stdout, stderr }. - * Passes env overrides merged on top of process.env + CLEAN_ENV. + * Passes env overrides through the canonical sandboxed child environment. */ function runTools(args, cwd, envOverrides = {}) { return spawnSync( @@ -76,7 +76,7 @@ function runTools(args, cwd, envOverrides = {}) { cwd: cwd || process.cwd(), encoding: 'utf8', timeout: PLAN_PRE_HOOK_CLI_TIMEOUT_MS, - env: { ...process.env, ...CLEAN_ENV, ...envOverrides }, + env: installSpawnEnv({ ...CLEAN_ENV, ...envOverrides }), }, ); } @@ -278,11 +278,20 @@ describe('plan:pre all-off — empty resolution', () => { assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`); const envelope = parseEnvelope(result, 'all-off'); - assert.deepEqual(envelope.activeHooks, [], + assert.deepStrictEqual(envelope.activeHooks, [], `activeHooks must be empty when all flags false. Got: ${JSON.stringify(envelope.activeHooks.map(h=>h.capId))}`); assert.strictEqual(envelope.rendered, '_No active hooks at plan:pre._', 'rendered must be placeholder when no active hooks'); }); + + test('#4485: an ambient plan:pre capability cannot perturb the empty resolution', (t) => { + withAmbientCapabilityHome(t, 'gsd-ambient-plan-pre-', 'ambient-plan-pre', 'plan:pre'); + + const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir); + assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`); + const envelope = parseEnvelope(result, 'all-off-ambient'); + assert.deepStrictEqual(envelope.activeHooks, []); + }); }); // ─── 6. check ui.plan-gate: frontend + no-spec → block:true ──────────────────