diff --git a/apps/agent-worker/src/materialize-env.ts b/apps/agent-worker/src/materialize-env.ts index bc2f902..6dd8106 100644 --- a/apps/agent-worker/src/materialize-env.ts +++ b/apps/agent-worker/src/materialize-env.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { assertContainedRealPath } from './path-containment'; @@ -24,6 +24,9 @@ export const writeMaterializedEnv = async ( }); await mkdir(path.dirname(envPath), { recursive: true }); await writeFile(envPath, formatMaterializedEnv(secrets), { mode: 0o600 }); + // writeFile's mode only applies on create; chmod unconditionally tightens an + // already-existing (possibly 0644) file so plaintext secrets never leak. + await chmod(envPath, 0o600); return envPath; }; diff --git a/apps/agent-worker/tests/unit/materialize-env.test.ts b/apps/agent-worker/tests/unit/materialize-env.test.ts index 31ffb8c..f4f15bd 100644 --- a/apps/agent-worker/tests/unit/materialize-env.test.ts +++ b/apps/agent-worker/tests/unit/materialize-env.test.ts @@ -1,4 +1,12 @@ -import { access, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { + access, + chmod, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; @@ -39,6 +47,26 @@ describe('materialize env', () => { await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n'); }); + test('tightens perms to 0600 and overwrites content when the env file pre-exists 0644', async () => { + const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-')); + tempDirs.push(repoDir); + + const preExisting = path.join(repoDir, '.env.local'); + // Pre-create the target with loose perms. umask may mask the writeFile + // mode, so chmod explicitly and assert the precondition really is 0644. + await writeFile(preExisting, 'OLD', { mode: 0o644 }); + await chmod(preExisting, 0o644); + await expect(mode(preExisting)).resolves.toBe(0o644); + + const envPath = await writeMaterializedEnv(repoDir, '.env.local', [ + { name: 'A', value: 'x' }, + ]); + + // Overwrote the plaintext content AND tightened perms unconditionally. + await expect(mode(envPath)).resolves.toBe(0o600); + await expect(readFile(envPath, 'utf8')).resolves.toBe('A="x"\n'); + }); + test('removeMaterializedEnv deletes an existing file and no-ops when absent', async () => { const repoDir = await mkdtemp(path.join(os.tmpdir(), 'spoon-env-')); tempDirs.push(repoDir);