diff --git a/.changeset/quick-deer-squeak.md b/.changeset/quick-deer-squeak.md new file mode 100644 index 000000000..680cde678 --- /dev/null +++ b/.changeset/quick-deer-squeak.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3448 +--- +**Installer shim/wrapper drift guard now enforces projection seam ownership** — inline shim text builders in installer-owned serialized shell-command surfaces are rejected unless routed through the Shell Command Projection Module. diff --git a/scripts/lint-shell-command-projection-drift.cjs b/scripts/lint-shell-command-projection-drift.cjs new file mode 100644 index 000000000..24888486a --- /dev/null +++ b/scripts/lint-shell-command-projection-drift.cjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Focused drift guard for issue #3442: + * prevent installer-owned inline shim/wrapper text builders from bypassing the + * Shell Command Projection Module seam. + * + * Scope intentionally excludes subprocess execution helpers (spawnSync / + * execFileSync) because those are safe internal execution primitives, not + * serialized shell-command rendering. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const targetArg = process.argv[2] || path.join(ROOT, 'bin', 'install.js'); +const target = path.resolve(targetArg); +const rel = path.relative(ROOT, target); + +let content; +try { + content = fs.readFileSync(target, 'utf8'); +} catch (error) { + process.stderr.write(`lint-shell-command-projection-drift: failed to read ${target}: ${error.message}\n`); + process.exit(1); +} + +const forbidden = [ + { + label: 'inline cmd shim builder', + pattern: /@ECHO OFF\\r\\n@SETLOCAL\\r\\n@node /, + }, + { + label: 'inline pwsh shim builder', + pattern: /#!\/usr\/bin\/env pwsh\\n& node /, + }, + { + label: 'inline sh shim builder', + pattern: /#!\/usr\/bin\/env sh\\nexec node /, + }, +]; + +const matches = forbidden.filter((rule) => rule.pattern.test(content)); +if (matches.length === 0) { + process.stdout.write(`ok shell-projection-drift: ${rel}\n`); + process.exit(0); +} + +process.stderr.write(`ERROR shell-projection-drift: inline serialized shim builders found in ${rel}\n`); +for (const match of matches) { + process.stderr.write(` - ${match.label}\n`); +} +process.stderr.write('Route shim/wrapper rendering through get-shit-done/bin/lib/shell-command-projection.cjs\n'); +process.stderr.write('Safe subprocess execution via spawnSync/execFileSync is intentionally allowed.\n'); +process.exit(1); diff --git a/tests/bug-3442-shim-projection-drift-guard.test.cjs b/tests/bug-3442-shim-projection-drift-guard.test.cjs new file mode 100644 index 000000000..731f1fa4c --- /dev/null +++ b/tests/bug-3442-shim-projection-drift-guard.test.cjs @@ -0,0 +1,83 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); +const PROJECTION = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs')); +const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs'); + +function runLint(targetFile) { + return spawnSync(process.execPath, [DRIFT_LINT, targetFile], { + cwd: ROOT, + encoding: 'utf8', + }); +} + +describe('bug #3442: shim/wrapper projection seam', () => { + test('buildWindowsShimTriple matches shared projection output', () => { + const shimSrc = path.join(ROOT, 'bin', 'gsd-sdk.js'); + const fromInstall = INSTALL.buildWindowsShimTriple(shimSrc); + const fromProjection = PROJECTION.buildWindowsShimTriple(shimSrc); + assert.deepEqual(fromInstall.invocation, fromProjection.invocation); + assert.deepEqual(fromInstall.eol, fromProjection.eol); + assert.deepEqual(fromInstall.fileNames, fromProjection.fileNames); + assert.equal(fromInstall.render.cmd(), fromProjection.render.cmd()); + assert.equal(fromInstall.render.ps1(), fromProjection.render.ps1()); + assert.equal(fromInstall.render.sh(), fromProjection.render.sh()); + }); +}); + +describe('bug #3442: shim/wrapper serialized-command drift guard', () => { + test('drift guard passes for current install.js', () => { + const result = runLint(path.join(ROOT, 'bin', 'install.js')); + assert.equal(result.status, 0, `expected lint pass, got:\n${result.stderr || result.stdout}`); + }); + + test('drift guard fails when install-owned inline shim text builder is present', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-')); + try { + const fixture = path.join(tmp, 'install-inline-builder.js'); + fs.writeFileSync( + fixture, + [ + 'function badBuilder() {', + " return '@ECHO OFF\\r\\n@SETLOCAL\\r\\n@node \"C:/shim.js\" %*\\r\\n';", + '}', + '', + ].join('\n'), + ); + const result = runLint(fixture); + assert.notEqual(result.status, 0, 'inline shim renderer should be rejected by the drift guard'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('drift guard does not block safe subprocess execution patterns', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3442-')); + try { + const fixture = path.join(tmp, 'install-subprocess-safe.js'); + fs.writeFileSync( + fixture, + [ + "const cp = require('node:child_process');", + "cp.spawnSync('cmd.exe', ['/c', 'echo ok']);", + "cp.execFileSync('bash', ['-lc', 'printf %s \"$PATH\"']);", + '', + ].join('\n'), + ); + const result = runLint(fixture); + assert.equal(result.status, 0, `spawnSync/execFileSync should remain allowed:\n${result.stderr || result.stdout}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +});