diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index 590a08714..1350e0f9c 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -66,7 +66,7 @@ "src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2", "src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6", "src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189", - "src/remote.js": "68f901411063fd515a52dad41ffd5c599765a53de34a080715232825eaf207b3", + "src/remote.js": "ea433cf719729963a50ebede9442136e72f1eff145c0b3c5e837c6c56fa1ccdf", "src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c", "src/sdk.d.ts": "d4c80689248c197b2ddb1ebf4cd7d8f2e55716722074d9e1cdb8029b2dfa19f7", "src/sdk.js": "1ac29fb389ed86e0f62ecfddbbd31614c58e832a8181495caa83fe5cf8b30bc4", @@ -75,7 +75,7 @@ "src/training.js": "bd8a6c12eba4f9d2f8990fa9692cd04c743ea247f9b13e10f8717ceefcbd09ac", "src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462" }, - "filesDigest": "f609508982d1fdc67fa7b90e1286e25945f920ba45e8fde95f76234848cf3be9", + "filesDigest": "edc6be28115ad21f18954066e989a6b4aaae2137b2f4e6d6571312beef610288", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index 6050050c5..054df24f9 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -4c69daba76af8460fe5101da07aeab58cd84376ca642280e038f52cf3c4676d6 manifest.json +0517e22827b8acc7d8550e1e560e4f13ca79397977c52c30cf23770dead2327a manifest.json diff --git a/harness/ruview/src/remote.js b/harness/ruview/src/remote.js index db0b7b659..b3c759ef9 100644 --- a/harness/ruview/src/remote.js +++ b/harness/ruview/src/remote.js @@ -60,10 +60,14 @@ const OWNER_ONLY_ACL = [ * inherited ACLs. Replace them with a single full-control grant for the owner. * Fails closed: an unrestricted hosts file is an error, not a warning. */ -export function restrictToOwner(path, { platform = process.platform, exec = spawnSync } = {}) { +export function restrictToOwner(path, { platform = process.platform, exec = spawnSync, env = process.env } = {}) { if (platform !== 'win32') return 'posix-0600'; + // pwsh -> node -> powershell.exe otherwise inherits PS7's incompatible + // module paths. Let Windows PowerShell construct its own defaults without + // changing the caller's environment or execution policy (about_PSModulePath). + const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) => key.toUpperCase() !== 'PSMODULEPATH')); const r = exec('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', OWNER_ONLY_ACL], { - input: JSON.stringify({ path }), encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 16_384, + input: JSON.stringify({ path }), env: childEnv, encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 16_384, }); if (r.error || r.status !== 0) { throw new Error(`could not restrict ${path} to the current user (ACL helper exit ${r.status ?? r.error?.code}); the hosts file was not made private`); diff --git a/harness/ruview/test/remote.test.mjs b/harness/ruview/test/remote.test.mjs index bfe8f1fb8..5486190c5 100644 --- a/harness/ruview/test/remote.test.mjs +++ b/harness/ruview/test/remote.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir, userInfo } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -131,15 +131,49 @@ test('Windows hosts-file restriction removes explicit grants and handles literal }); }); +test('Windows ACL helper isolates incompatible inherited PowerShell modules', { skip: process.platform !== 'win32' }, async () => { + await withHosts(async (path) => { + const modules = join(dirname(path), 'modules'); + const utility = join(modules, 'Microsoft.PowerShell.Utility'); + mkdirSync(utility, { recursive: true }); + // Reproduce pwsh7 -> node -> Windows PowerShell5 without installing pwsh: + // this higher-priority module advertises the cmdlet but requires PS7. + writeFileSync(join(utility, 'Microsoft.PowerShell.Utility.psd1'), + "@{ ModuleVersion='7.0.0'; PowerShellVersion='7.0'; CmdletsToExport=@('ConvertFrom-Json') }"); + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'PSMODULEPATH')); + inherited.PSModulePath = `${modules};${process.env.PSModulePath || ''}`; + const original = { ...inherited }; + const parentPath = process.env.PSModulePath; + const control = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "'{}' | ConvertFrom-Json"], { + env: inherited, encoding: 'utf8', windowsHide: true, timeout: 10_000, + }); + assert.equal(control.status, 1, control.stderr); + assert.match(control.stderr, /CouldNotAutoloadMatchingModule/); + const saved = saveHost({ name: 'pi', ssh: 'ruv@pi.local' }, path, { env: inherited }); + assert.equal(saved.access, 'windows-acl-owner-only'); + assert.ok(Object.keys(inherited).length === Object.keys(original).length + && Object.entries(original).every(([key, value]) => inherited[key] === value), + 'the caller environment is unchanged'); + assert.equal(process.env.PSModulePath, parentPath, 'the process environment is unchanged'); + const acl = spawnSync('icacls', [path], { encoding: 'utf8' }); + assert.equal(acl.status, 0, acl.stderr); + const grants = acl.stdout.split('\n').filter(line => /:\(/.test(line)); + assert.equal(grants.length, 1, acl.stdout); + }); +}); + test('Windows hosts-file restriction fails closed and keeps input out of PowerShell source', () => { const calls = []; const path = "C:/x/hosts $(throw 'injected').json"; - const ok = restrictToOwner(path, { platform: 'win32', exec: (cmd, args, options) => { calls.push({ cmd, args, options }); return { status: 0 }; } }); + const env = { SystemRoot: 'C:/Windows', pSmOdUlEpAtH: 'incompatible-PS7-modules', KEEP: 'unchanged' }; + const ok = restrictToOwner(path, { platform: 'win32', env, exec: (cmd, args, options) => { calls.push({ cmd, args, options }); return { status: 0 }; } }); assert.equal(ok, 'windows-acl-owner-only'); assert.equal(calls[0].cmd, 'powershell.exe'); assert.deepEqual(calls[0].args.slice(0, 4), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']); assert.ok(!calls[0].args.at(-1).includes(path)); assert.deepEqual(JSON.parse(calls[0].options.input), { path }); + assert.deepEqual(calls[0].options.env, { SystemRoot: 'C:/Windows', KEEP: 'unchanged' }); + assert.equal(env.pSmOdUlEpAtH, 'incompatible-PS7-modules'); for (const result of [{ status: 5 }, { status: null, error: { code: 'ENOENT' } }, { status: null, error: { code: 'ETIMEDOUT' } }]) { assert.throws(() => restrictToOwner(path, { platform: 'win32', exec: () => result }), /not made private/); }