diff --git a/tests/helpers-process-isolation.test.cjs b/tests/helpers-process-isolation.test.cjs index d6554813f..a7fa38430 100644 --- a/tests/helpers-process-isolation.test.cjs +++ b/tests/helpers-process-isolation.test.cjs @@ -213,6 +213,29 @@ describe('#2665: TEST_ENV_BASE config-location coverage', () => { } }); + test('write-escape PERMISSIONS are scrubbed (a fifth family — not a location var)', () => { + // #2665 round 5. GSD_ALLOW_SYMLINKED_DEST names no path, so every rung of the + // derivation above is structurally incapable of reaching it — it is not a + // registry configHome, not descriptor-shaped, not one of GSD's own location + // vars. It is still a #2665 leak vector: install-engine.cts reads it env-first + // and threads it into the symlink-escape guard that stops a write leaving the + // install root, so an ambient `=1` disarms that guard for the whole suite. + // + // Named literally rather than derived from the family constant on purpose: a + // test that reads WRITE_ESCAPE_PERMISSION_ENV_KEYS and asserts over it shrinks + // its own expectation when the family is emptied — the enumeration-relative + // failure this suite already documents, and the one that let the kimi-code + // descriptor go unwatched. Naming it is what makes removal fail loudly. + assert.strictEqual( + TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST, + '', + 'GSD_ALLOW_SYMLINKED_DEST must be blanked: ambient =1 disarms the symlink-escape guard', + ); + // Blanking must be fail-SAFE — '' is neither '1' nor 'true', so the guard gets + // stricter, never looser. This is what licenses scrubbing it wholesale. + assert.ok(!['1', 'true'].includes(TEST_ENV_BASE.GSD_ALLOW_SYMLINKED_DEST)); + }); + test('scrubConfigLocationEnv clears and restores the parent process env', () => { // The in-process half of the fix (Blocker 1): TEST_ENV_BASE only reaches // children, so a test calling install() in-process needs the PARENT's env diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 64161dee9..fdbd22381 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -65,6 +65,22 @@ const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [ 'GSD_WORKSTREAM', ]; +// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT +// folded into any of the four rungs below. +// +// #2665 round 5: GSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so it is +// not a config-location var by any honest reading. But install-engine.cts reads it +// env-first (`:214`) and threads it as `allowOptInFollow` into the symlink-escape +// guard at four call sites, each gating a write (`:361/:367`, `:416/:424`, +// `:785/:790`, `:927/:932`). That guard is what stops a write leaving the install +// root, so an ambient `=1` disarms it for the whole suite — the #2665 hazard +// exactly, arriving through a permission rather than a path. +// +// Blanking is fail-safe in the only direction that matters: '' is neither '1' nor +// 'true', so a blanked value makes the guard STRICTER, never looser. That asymmetry +// is why this can be scrubbed wholesale without reasoning about each call site. +const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['GSD_ALLOW_SYMLINKED_DEST']; + const CONFIG_LOCATION_ENV_KEYS = [ ...new Set([ // 1. Every runtime descriptor the capability registry carries — including @@ -89,6 +105,10 @@ const CONFIG_LOCATION_ENV_KEYS = [ ...GSD_LOCATION_ENV_KEYS, // 4. The residue that is neither registry-carried nor descriptor-shaped. ...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS, + // 5. Write-escape permissions — NOT locations. Same mechanism because the + // hazard is identical (ambient env lets a suite write outside the sandbox); + // named separately above so the list does not misdescribe what they are. + ...WRITE_ESCAPE_PERMISSION_ENV_KEYS, ]), ].sort();