Skip to content

Commit 41ceebe

Browse files
authored
fix(ci+installers): harden permission checks and guard completion/profile writes (#1247)
* fix: harden permission checks in root CI * fix: close permission guard review gaps * test: address coderabbit installer comments
1 parent a0decbe commit 41ceebe

12 files changed

Lines changed: 213 additions & 36 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ jobs:
6060
- name: Setup Node.js
6161
uses: actions/setup-node@v4
6262
with:
63-
node-version: '20'
63+
node-version: '20.19.0'
6464
cache: 'pnpm'
6565

6666
- name: Install dependencies
@@ -116,7 +116,7 @@ jobs:
116116
- name: Setup Node.js
117117
uses: actions/setup-node@v4
118118
with:
119-
node-version: '20'
119+
node-version: '20.19.0'
120120
cache: 'pnpm'
121121

122122
- name: Print environment diagnostics
@@ -155,7 +155,7 @@ jobs:
155155
- name: Setup Node.js
156156
uses: actions/setup-node@v4
157157
with:
158-
node-version: '20'
158+
node-version: '20.19.0'
159159
cache: 'pnpm'
160160

161161
- name: Install dependencies
@@ -277,7 +277,7 @@ jobs:
277277
if: steps.changed-changesets.outputs.has_changesets == 'true'
278278
uses: actions/setup-node@v4
279279
with:
280-
node-version: '20'
280+
node-version: '20.19.0'
281281
cache: 'pnpm'
282282

283283
- name: Install dependencies

src/core/completions/installers/bash-installer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ export class BashInstaller {
240240
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
241241
}
242242

243+
if (!(await FileSystemUtils.canWriteFile(targetPath))) {
244+
throw new Error(`Path is not writable: ${targetPath}`);
245+
}
246+
243247
// Ensure the directory exists
244248
const targetDir = path.dirname(targetPath);
245249
await fs.mkdir(targetDir, { recursive: true });

src/core/completions/installers/fish-installer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { promises as fs } from 'fs';
22
import path from 'path';
33
import os from 'os';
4+
import { FileSystemUtils } from '../../../utils/file-system.js';
45
import { InstallationResult } from '../factory.js';
56

67
/**
@@ -76,6 +77,10 @@ export class FishInstaller {
7677
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
7778
}
7879

80+
if (!(await FileSystemUtils.canWriteFile(targetPath))) {
81+
throw new Error(`Path is not writable: ${targetPath}`);
82+
}
83+
7984
// Ensure the directory exists
8085
const targetDir = path.dirname(targetPath);
8186
await fs.mkdir(targetDir, { recursive: true });
@@ -135,6 +140,11 @@ export class FishInstaller {
135140
};
136141
}
137142

143+
const targetDir = path.dirname(targetPath);
144+
if (!(await FileSystemUtils.canWriteFile(targetDir))) {
145+
throw new Error(`Path is not writable: ${targetDir}`);
146+
}
147+
138148
// Remove the completion script
139149
await fs.unlink(targetPath);
140150

src/core/completions/installers/powershell-installer.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,23 @@ export class PowerShellInstaller {
170170

171171
for (const profilePath of profilePaths) {
172172
try {
173-
// Create profile file if it doesn't exist
174173
const profileDir = path.dirname(profilePath);
175-
await fs.mkdir(profileDir, { recursive: true });
174+
let profileExists = false;
175+
try {
176+
await fs.access(profilePath);
177+
profileExists = true;
178+
} catch (err: any) {
179+
if (err?.code !== 'ENOENT') {
180+
throw err;
181+
}
182+
}
183+
184+
if (!profileExists) {
185+
if (!(await FileSystemUtils.canWriteFile(profilePath))) {
186+
throw new Error(`Path is not writable: ${profilePath}`);
187+
}
188+
await fs.mkdir(profileDir, { recursive: true });
189+
}
176190

177191
let profileContent = '';
178192
let fileEncoding: BufferEncoding = 'utf-8';
@@ -209,6 +223,9 @@ export class PowerShellInstaller {
209223
].join('\n');
210224

211225
const newContent = profileContent + openspecBlock;
226+
if (!(await FileSystemUtils.canWriteFile(profilePath))) {
227+
throw new Error(`Path is not writable: ${profilePath}`);
228+
}
212229
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
213230
anyConfigured = true;
214231
} catch (error) {
@@ -271,6 +288,9 @@ export class PowerShellInstaller {
271288
// Clean up extra newlines
272289
const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n';
273290

291+
if (!(await FileSystemUtils.canWriteFile(profilePath))) {
292+
throw new Error(`Path is not writable: ${profilePath}`);
293+
}
274294
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
275295
anyRemoved = true;
276296
} catch (error) {
@@ -314,6 +334,10 @@ export class PowerShellInstaller {
314334
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
315335
}
316336

337+
if (!(await FileSystemUtils.canWriteFile(targetPath))) {
338+
throw new Error(`Path is not writable: ${targetPath}`);
339+
}
340+
317341
// Ensure the directory exists
318342
const targetDir = path.dirname(targetPath);
319343
await fs.mkdir(targetDir, { recursive: true });
@@ -402,6 +426,11 @@ export class PowerShellInstaller {
402426
};
403427
}
404428

429+
const targetDir = path.dirname(targetPath);
430+
if (!(await FileSystemUtils.canWriteFile(targetDir))) {
431+
throw new Error(`Path is not writable: ${targetDir}`);
432+
}
433+
405434
// Remove the completion script
406435
await fs.unlink(targetPath);
407436

src/core/completions/installers/zsh-installer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ export class ZshInstaller {
256256
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
257257
}
258258

259+
if (!(await FileSystemUtils.canWriteFile(targetPath))) {
260+
throw new Error(`Path is not writable: ${targetPath}`);
261+
}
262+
259263
// Ensure the directory exists
260264
const targetDir = path.dirname(targetPath);
261265
await fs.mkdir(targetDir, { recursive: true });

src/core/file-state.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ export async function acquireFileLock(
120120
options: FileLockOptions
121121
): Promise<nodeFs.promises.FileHandle> {
122122
const { lockPath, errorFor } = options;
123-
await FileSystemUtils.createDirectory(path.dirname(lockPath));
123+
const lockDir = path.dirname(lockPath);
124+
await FileSystemUtils.createDirectory(lockDir);
125+
if (!(await FileSystemUtils.canWriteFile(lockDir))) {
126+
throw errorFor('create-failed', { lockPath, cause: 'EACCES' });
127+
}
124128
const deadline = Date.now() + LOCK_DEADLINE_MS;
125129

126130
while (true) {

src/utils/file-system.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,40 @@ import path from 'path';
44
const fs = nodeFs.promises;
55
const { constants: fsConstants } = nodeFs;
66

7+
function hasOwnerGroupOrOtherWriteBit(stats: nodeFs.Stats): boolean {
8+
return (stats.mode & 0o222) !== 0;
9+
}
10+
11+
function hasOwnerGroupOrOtherExecuteBit(stats: nodeFs.Stats): boolean {
12+
return (stats.mode & 0o111) !== 0;
13+
}
14+
15+
async function hasWritableModeAndAccess(targetPath: string): Promise<boolean> {
16+
try {
17+
const stats = await fs.stat(targetPath);
18+
19+
// POSIX root can often write despite mode bits, but OpenSpec should respect
20+
// explicit read-only file/directory modes when deciding whether an install
21+
// path is user-writable. This also keeps permission checks deterministic in
22+
// root-run CI containers. On Windows, chmod mode bits are not authoritative,
23+
// so rely on fs.access below.
24+
if (process.platform !== 'win32' && !hasOwnerGroupOrOtherWriteBit(stats)) {
25+
return false;
26+
}
27+
if (process.platform !== 'win32' && stats.isDirectory() && !hasOwnerGroupOrOtherExecuteBit(stats)) {
28+
return false;
29+
}
30+
31+
const accessMode = stats.isDirectory()
32+
? fsConstants.W_OK | fsConstants.X_OK
33+
: fsConstants.W_OK;
34+
await fs.access(targetPath, accessMode);
35+
return true;
36+
} catch {
37+
return false;
38+
}
39+
}
40+
741
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
842
let leftIndex = markerIndex - 1;
943
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
@@ -152,18 +186,15 @@ export class FileSystemUtils {
152186
try {
153187
const stats = await fs.stat(filePath);
154188

155-
if (!stats.isFile()) {
156-
return true;
189+
if (stats.isDirectory()) {
190+
return hasWritableModeAndAccess(filePath);
157191
}
158192

159-
// On Windows, stats.mode doesn't reliably indicate write permissions.
160-
// Use fs.access with W_OK to check actual write permissions cross-platform.
161-
try {
162-
await fs.access(filePath, fsConstants.W_OK);
193+
if (!stats.isFile()) {
163194
return true;
164-
} catch {
165-
return false;
166195
}
196+
197+
return hasWritableModeAndAccess(filePath);
167198
} catch (error: any) {
168199
if (error.code === 'ENOENT') {
169200
// File doesn't exist - find first existing parent directory and check its permissions
@@ -175,13 +206,8 @@ export class FileSystemUtils {
175206
return false;
176207
}
177208

178-
// Check if the existing parent directory is writable
179-
try {
180-
await fs.access(existingDir, fsConstants.W_OK);
181-
return true;
182-
} catch {
183-
return false;
184-
}
209+
// Check if the existing parent directory is writable.
210+
return hasWritableModeAndAccess(existingDir);
185211
}
186212

187213
console.debug(`Unable to determine write permissions for ${filePath}: ${error.message}`);

test/core/completions/installers/bash-installer.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,24 @@ describe('BashInstaller', () => {
151151
expect(result.message).toContain('Failed to install');
152152
});
153153

154+
it.skipIf(process.platform === 'win32')('should return failure when completion directory is not writable', async () => {
155+
const targetPath = await installer.getInstallationPath();
156+
const targetDir = path.dirname(targetPath);
157+
await fs.mkdir(targetDir, { recursive: true });
158+
await fs.chmod(targetDir, 0o555);
159+
160+
let result: Awaited<ReturnType<BashInstaller['install']>> | undefined;
161+
try {
162+
result = await installer.install(testScript);
163+
} finally {
164+
await fs.chmod(targetDir, 0o755);
165+
}
166+
167+
expect(result?.success).toBe(false);
168+
expect(result?.message).toContain('Failed to install');
169+
expect(result?.message).toContain(`Path is not writable: ${targetPath}`);
170+
});
171+
154172
it('should detect already-installed completion with identical content', async () => {
155173
// First installation
156174
const firstResult = await installer.install(testScript);

test/core/completions/installers/fish-installer.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,23 @@ complete -c openspec -a 'init'
286286
expect(result.message).toBe('Completion script uninstalled successfully');
287287
});
288288

289+
it.skipIf(process.platform === 'win32')('should uninstall read-only file when parent directory is writable', async () => {
290+
await installer.install(mockCompletionScript);
291+
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
292+
await fs.chmod(targetPath, 0o444);
293+
294+
let result: Awaited<ReturnType<FishInstaller['uninstall']>> | undefined;
295+
try {
296+
result = await installer.uninstall();
297+
} finally {
298+
await fs.chmod(targetPath, 0o644).catch(() => undefined);
299+
}
300+
301+
const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);
302+
expect(result?.success).toBe(true);
303+
expect(fileExists).toBe(false);
304+
});
305+
289306
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
290307
// Windows uses ACLs which Node.js chmod doesn't control
291308
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {

test/core/completions/installers/powershell-installer.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ describe('PowerShellInstaller', () => {
1111
let originalPlatform: NodeJS.Platform;
1212
let originalEnv: NodeJS.ProcessEnv;
1313

14+
const restoreEnvValue = (key: string, value: string | undefined): void => {
15+
if (value === undefined) {
16+
delete process.env[key];
17+
} else {
18+
process.env[key] = value;
19+
}
20+
};
21+
1422
beforeEach(async () => {
1523
testHomeDir = path.join(os.tmpdir(), `openspec-powershell-test-${randomUUID()}`);
1624
await fs.mkdir(testHomeDir, { recursive: true });
@@ -257,6 +265,28 @@ describe('PowerShellInstaller', () => {
257265

258266
expect(result).toBe(false);
259267
});
268+
269+
it.skipIf(process.platform === 'win32')('should not create profile directory when parent is not writable', async () => {
270+
const originalNoAutoConfig = process.env.OPENSPEC_NO_AUTO_CONFIG;
271+
const restrictedHome = path.join(testHomeDir, 'restricted-home');
272+
await fs.mkdir(restrictedHome);
273+
await fs.chmod(restrictedHome, 0o555);
274+
const restrictedInstaller = new PowerShellInstaller(restrictedHome);
275+
const profileDir = path.dirname(restrictedInstaller.getProfilePath());
276+
277+
let result = true;
278+
try {
279+
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
280+
result = await restrictedInstaller.configureProfile(mockScriptPath);
281+
} finally {
282+
restoreEnvValue('OPENSPEC_NO_AUTO_CONFIG', originalNoAutoConfig);
283+
await fs.chmod(restrictedHome, 0o755);
284+
}
285+
286+
const profileDirExists = await fs.access(profileDir).then(() => true).catch(() => false);
287+
expect(result).toBe(false);
288+
expect(profileDirExists).toBe(false);
289+
});
260290
});
261291

262292
describe('removeProfileConfig', () => {
@@ -767,6 +797,26 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
767797
expect(result.message).toBe('Completion script uninstalled successfully');
768798
});
769799

800+
it.skipIf(process.platform === 'win32')('should uninstall read-only completion script when parent directory is writable', async () => {
801+
const originalNoAutoConfig = process.env.OPENSPEC_NO_AUTO_CONFIG;
802+
const targetPath = installer.getInstallationPath();
803+
let result: Awaited<ReturnType<PowerShellInstaller['uninstall']>> | undefined;
804+
805+
try {
806+
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
807+
await installer.install(mockCompletionScript);
808+
await fs.chmod(targetPath, 0o444);
809+
result = await installer.uninstall();
810+
} finally {
811+
restoreEnvValue('OPENSPEC_NO_AUTO_CONFIG', originalNoAutoConfig);
812+
await fs.chmod(targetPath, 0o644).catch(() => undefined);
813+
}
814+
815+
const scriptExists = await fs.access(targetPath).then(() => true).catch(() => false);
816+
expect(result?.success).toBe(true);
817+
expect(scriptExists).toBe(false);
818+
});
819+
770820
it('should handle both script and config removal', async () => {
771821
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
772822
await installer.install(mockCompletionScript);

0 commit comments

Comments
 (0)