fix(remote): isolate Windows PowerShell module search paths

This commit is contained in:
ruv committed 2026-10-12 12:09:26 -04:00
1 parent 99d9851173
commit b265454b93
4 files changed
+45 -7

No files matched your search

+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -1 +1 @@
4c69daba76af8460fe5101da07aeab58cd84376ca642280e038f52cf3c4676d6 manifest.json
0517e22827b8acc7d8550e1e560e4f13ca79397977c52c30cf23770dead2327a manifest.json
+6 -2
View File
@@ -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`);
+36 -2
View File
@@ -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/);
}