Merge #797: the File Viewer edits workspace dotenv files

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:23:25 +01:00
commit 18b309fc02
13 files changed
+417 -34

No files matched your search

+41 -2
View File
@@ -6,7 +6,9 @@
*/
import { describe, it, expect } from 'vitest';
import { TEXT_ATTACHMENT_EXTENSIONS } from '../src/attachment-registry.js';
import {
EDITABLE_EXTENSIONS,
MAX_EDITABLE_BYTES,
applyEol,
detectEol,
@@ -15,6 +17,20 @@ import {
} from '../src/config/file-editing.js';
describe('file-editing policy', () => {
describe('shared text extensions', () => {
it('excludes env from TEXT_ATTACHMENT_EXTENSIONS', () => {
expect(TEXT_ATTACHMENT_EXTENSIONS.has('env')).toBe(false);
});
it('excludes env from EDITABLE_EXTENSIONS', () => {
expect(EDITABLE_EXTENSIONS.has('env')).toBe(false);
});
it('shares the same set between attachment and editor extensions', () => {
expect(TEXT_ATTACHMENT_EXTENSIONS).toBe(EDITABLE_EXTENSIONS);
});
});
describe('isEditableFileName', () => {
it('allows common text extensions', () => {
for (const name of ['a.ts', 'b.md', 'c.json', 'd.py', 'style.css', 'notes.txt', 'x.yml', 'Q.SQL']) {
@@ -34,11 +50,34 @@ describe('file-editing policy', () => {
}
});
it('rejects svg and env (deliberate v1 exclusions)', () => {
it('rejects svg (deliberate exclusion)', () => {
expect(isEditableFileName('image.svg')).toBe(false);
expect(isEditableFileName('config.env')).toBe(false);
});
it.each([
'.env',
'.env.local',
'.env.production',
'.env.development.local',
'.env.example',
'.env.template',
'.env.sample',
'.ENV',
'.Env.Local',
'config.env',
'prod.env',
'docker.ENV',
])('allows dotenv filename %s', (name) => {
expect(isEditableFileName(name)).toBe(true);
});
it.each(['.envrc', 'env', '.environment', 'environment', '.env-local', 'foo.envrc', 'envfile'])(
'rejects dotenv lookalike %s',
(name) => {
expect(isEditableFileName(name)).toBe(false);
}
);
it('rejects extensionless and unknown-dotfile names not on the basename list', () => {
expect(isEditableFileName('somebinary')).toBe(false);
expect(isEditableFileName('.bashrc')).toBe(false);
@@ -0,0 +1,83 @@
/**
* Cross-workspace attachment extensions with real files.
* Port: N/A (app.inject doesn't open ports)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { attachmentRegistry } from '../../src/attachment-registry.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
describe('cross-workspace attachment extensions (real fs)', () => {
let harness: RouteTestHarness;
let workDir: string;
let outsideDir: string;
beforeEach(async () => {
vi.stubEnv('CODEMAN_ATTACHMENT_CONFINE', undefined);
vi.stubEnv('CODEMAN_ATTACHMENT_BLOCKED_PATHS', undefined);
harness = await createRouteTestHarness(registerFileRoutes);
workDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-attachment-ws-')));
outsideDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-attachment-out-')));
harness.ctx._session.workingDir = workDir;
attachmentRegistry.clearSession(harness.ctx._sessionId);
});
afterEach(async () => {
await harness.app.close();
attachmentRegistry.clearSession(harness.ctx._sessionId);
rmSync(workDir, { recursive: true, force: true });
rmSync(outsideDir, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it.each(['prod.env', 'CONFIG.ENV', 'unsupported.xyz'])(
'refuses outside %s as an unsupported attachment without an id',
async (fileName) => {
const path = join(outsideDir, fileName);
writeFileSync(path, 'FIXTURE_ONLY=value\n');
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path },
});
const body = res.json();
expect.soft(res.statusCode).toBe(400);
expect.soft(body.success).toBe(false);
expect.soft(body.errorCode).toBe('INVALID_INPUT');
expect.soft(body.data?.attachmentId).toBe(undefined);
expect.soft(body.attachmentId).toBe(undefined);
}
);
it('registers outside notes.md with default attachment settings', async () => {
const path = join(outsideDir, 'notes.md');
const content = '# Outside notes\n';
writeFileSync(path, content);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path },
});
const body = res.json();
expect(res.statusCode).toBe(200);
expect(body.success).toBe(true);
expect(body.data.fileName).toBe('notes.md');
expect(body.data.extension).toBe('md');
expect(body.data.size).toBe(Buffer.byteLength(content));
const raw = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
});
expect(raw.statusCode).toBe(200);
expect(raw.body).toBe(content);
});
});
+21
View File
@@ -1125,5 +1125,26 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(403);
});
it('refuses a workspace dotenv download without reading or streaming its content', async () => {
const content = 'TEST_DOWNLOAD_VALUE=must-stay-private';
const path = `${harness.ctx._session.workingDir}/.env`;
mockedReadFile.mockImplementation(async (requestedPath) =>
String(requestedPath) === path ? (content as never) : ('{}' as never)
);
mockedCreateReadStream.mockImplementation(() => Readable.from([Buffer.from(content)]) as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`,
});
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
expect(res.json().data).toBeUndefined();
expect(res.headers['content-disposition']).toBeUndefined();
expect(mockedCreateReadStream.mock.calls).toEqual([]);
expect(mockedReadFile.mock.calls.filter(([requestedPath]) => String(requestedPath) === path)).toEqual([]);
});
});
});
+148 -8
View File
@@ -20,6 +20,8 @@ import {
symlinkSync,
chmodSync,
statSync,
lstatSync,
readlinkSync,
realpathSync,
readdirSync,
rmSync,
@@ -27,7 +29,7 @@ import {
} from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { MAX_EDITABLE_BYTES } from '../../src/config/file-editing.js';
@@ -209,13 +211,6 @@ describe('file viewer edit mode (real fs)', () => {
expect(res.statusCode).toBe(403);
});
it('rejects a .env file (allowlist first, sensitive-path as backstop)', async () => {
writeFileSync(join(workDir, '.env'), 'SECRET=1');
const res = await putFile('.env', { content: 'SECRET=2', baseHash: sha256('SECRET=1') });
expect([400, 403]).toContain(res.statusCode);
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('SECRET=1');
});
it('400s when the current file contains a NUL byte', async () => {
writeFileSync(join(workDir, 'weird.txt'), Buffer.from([0x61, 0x00, 0x62]));
const res = await putFile('weird.txt', { content: 'ab', baseHash: sha256(Buffer.from([0x61, 0x00, 0x62])) });
@@ -324,6 +319,151 @@ describe('file viewer edit mode (real fs)', () => {
});
});
describe('dotenv files', () => {
const original = 'TEST_VALUE=original\nTEST_LABEL=café';
const updated = 'TEST_VALUE=updated\nTEST_LABEL=été';
const editablePaths = ['.env', '.env.local', '.env.example', 'config.env', 'app/.env'];
const forbiddenPaths = ['.ssh/.env', '.codeman/.env', '.codeman-dev/.env', '.npmrc', 'credentials.json'];
const getPreview = (path: string) =>
harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(path)}`,
});
describe.each(editablePaths)('%s', (path) => {
beforeEach(() => {
mkdirSync(dirname(join(workDir, path)), { recursive: true });
writeFileSync(join(workDir, path), original);
});
it('plain preview returns exact content and advertises editable:true', async () => {
const res = await getPreview(path);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.content).toBe(original);
expect(body.data.editable).toBe(true);
});
it('edit=1 returns full content and the SHA-256 of the file bytes', async () => {
const res = await getEdit(path);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.content).toBe(original);
expect(body.data.hash).toBe(sha256(Buffer.from(original, 'utf8')));
expect(body.data.editable).toBe(true);
expect(body.data.truncated).toBe(false);
expect(body.data.totalLines).toBe(2);
expect(body.data.eol).toBe('lf');
});
it('PUT saves exact bytes and leaves no temp files', async () => {
const directory = dirname(join(workDir, path));
const before = readdirSync(directory).sort();
const res = await putFile(path, { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(200);
const body = res.json();
expect.soft(body.success).toBe(true);
expect.soft(body.data?.hash).toBe(sha256(updated));
expect.soft(body.data?.eol).toBe('lf');
expect.soft(body.data?.size).toBe(Buffer.byteLength(updated));
expect.soft(readFileSync(join(workDir, path))).toEqual(Buffer.from(updated));
expect(readdirSync(directory).sort()).toEqual(before);
});
});
it.each([
['0600', 0o600],
['0640', 0o640],
])('preserves dotenv mode %s across a save', async (_label, mode) => {
const path = join(workDir, '.env');
writeFileSync(path, original);
chmodSync(path, mode);
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(200);
expect.soft(res.json().success).toBe(true);
expect.soft(readFileSync(path)).toEqual(Buffer.from(updated));
expect(statSync(path).mode & 0o777).toBe(mode);
});
it('rejects a stale dotenv baseHash with 409 and leaves the bytes unchanged', async () => {
writeFileSync(join(workDir, '.env'), original);
const res = await putFile('.env', { content: updated, baseHash: sha256('TEST_VALUE=older') });
expect.soft(res.statusCode).toBe(409);
expect.soft(res.json().success).toBe(false);
expect.soft(res.json().errorCode).toBe('CONFLICT');
expect(readFileSync(join(workDir, '.env'))).toEqual(Buffer.from(original));
});
describe.each(forbiddenPaths)('protected %s', (path) => {
beforeEach(() => {
mkdirSync(dirname(join(workDir, path)), { recursive: true });
writeFileSync(join(workDir, path), original);
});
it('plain preview advertises editable:false', async () => {
const res = await getPreview(path);
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
expect(res.json().data.editable).toBe(false);
});
it('edit=1 refuses with 403', async () => {
const res = await getEdit(path);
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('FORBIDDEN');
});
it('PUT refuses with FORBIDDEN and leaves the bytes unchanged', async () => {
const res = await putFile(path, { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json().success).toBe(false);
expect.soft(res.json().errorCode).toBe('FORBIDDEN');
expect(readFileSync(join(workDir, path))).toEqual(Buffer.from(original));
});
});
it('refuses a dotenv symlink to credentials.json with 403 and preserves its target', async () => {
const target = join(workDir, 'credentials.json');
writeFileSync(target, original);
symlinkSync(target, join(workDir, '.env'));
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json().success).toBe(false);
expect.soft(res.json().errorCode).toBe('FORBIDDEN');
expect(readFileSync(target)).toEqual(Buffer.from(original));
});
it('refuses a dotenv symlink outside the workspace with 404 and preserves its target', async () => {
const target = join(outsideDir, 'outside.env');
writeFileSync(target, original);
symlinkSync(target, join(workDir, '.env'));
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(404);
expect.soft(res.json().success).toBe(false);
expect(readFileSync(target)).toEqual(Buffer.from(original));
});
it('writes through a dotenv symlink to real.env and preserves the symlink', async () => {
const target = join(workDir, 'real.env');
const link = join(workDir, '.env');
writeFileSync(target, original);
symlinkSync(target, link);
const before = readdirSync(workDir).sort();
const res = await putFile('.env', { content: updated, baseHash: sha256(original) });
expect.soft(res.statusCode).toBe(200);
expect.soft(res.json().success).toBe(true);
expect.soft(readFileSync(target)).toEqual(Buffer.from(updated));
expect.soft(readFileSync(link)).toEqual(Buffer.from(updated));
expect.soft(lstatSync(link).isSymbolicLink()).toBe(true);
expect.soft(readlinkSync(link)).toBe(target);
expect(readdirSync(workDir).sort()).toEqual(before);
});
});
// ========== multi-user scoping ==========
describe('multi-user ownership', () => {
+67 -6
View File
@@ -21,6 +21,13 @@ import { describe, expect, it } from 'vitest';
import { isSensitivePath } from '../src/web/sensitive-path.js';
const HOME = '/home/dev';
const dotenvPaths: Array<[string, string]> = [
['dotenv', '/srv/app/.env'],
['local dotenv', '/srv/app/.env.local'],
['suffixed dotenv', '/srv/app/.env.production'],
['a dotenv example', '/srv/app/.env.example'],
['nested dotenv', '/srv/app/sub/dir/.env'],
];
describe('isSensitivePath', () => {
describe('blocks', () => {
@@ -35,12 +42,7 @@ describe('isSensitivePath', () => {
['ssh keys anywhere', '/srv/deploy/.ssh/id_rsa'],
['gpg keyring', `${HOME}/.gnupg/private-keys-v1.d/key.key`],
['dotenv', '/srv/app/.env'],
['suffixed dotenv', '/srv/app/.env.production'],
// Pre-existing and deliberate: `.env.*` is blocked wholesale, so even a
// committed `.env.example` is refused rather than risking the one repo
// whose "example" holds a live key.
['a dotenv example', '/srv/app/.env.example'],
...dotenvPaths,
['generic credentials file', '/srv/app/credentials'],
['json credentials', '/srv/app/credentials.json'],
@@ -106,11 +108,59 @@ describe('isSensitivePath', () => {
it.each(blocked)('blocks the %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(true);
});
it.each(blocked.filter(([, path]) => !dotenvPaths.some(([, dotenv]) => dotenv === path)))(
'still blocks the %s with allowDotenv:true',
(_label, path) => {
expect(isSensitivePath(path, { allowDotenv: true })).toBe(true);
}
);
});
describe('dotenv opt-in', () => {
it.each(dotenvPaths)('blocks the %s with allowDotenv:false', (_label, path) => {
expect(isSensitivePath(path, { allowDotenv: false })).toBe(true);
});
it.each(dotenvPaths)('blocks the %s with empty options', (_label, path) => {
expect(isSensitivePath(path, {})).toBe(true);
});
it.each(dotenvPaths)('allows the %s with allowDotenv:true', (_label, path) => {
expect(isSensitivePath(path, { allowDotenv: true })).toBe(false);
});
it.each([
'/home/u/.ssh/.env',
'/home/u/.gnupg/.env.local',
'/home/u/.config/gh/.env',
'/home/u/.azure/.env',
'/home/u/.codeman/backups/cfg-20260101-000000/.env',
])('still blocks dotenv inside sensitive tree %s', (path) => {
expect(isSensitivePath(path, { allowDotenv: true })).toBe(true);
});
describe.each([
['default', undefined],
['empty options', {}],
['allowDotenv:false', { allowDotenv: false }],
['allowDotenv:true', { allowDotenv: true }],
])('Codeman login files with %s', (_label, options) => {
it.each([
'/home/u/.codeman/.env',
'/home/u/.codeman-dev/.env',
'/home/u/.codeman-beta/.env',
'/srv/x/.codeman/.env',
])('blocks %s', (path) => {
expect(isSensitivePath(path, options)).toBe(true);
});
});
});
describe('leaves ordinary files attachable', () => {
const allowed: Array<[string, string]> = [
['a source file', '/srv/app/src/index.ts'],
['a readme', '/srv/app/README.md'],
['a dotfile that carries no secret', '/srv/app/.gitignore'],
['a hidden CI directory', '/srv/app/.github/workflows/ci.yml'],
// The publish skill and the review-card loop attach from these trees, so
@@ -128,6 +178,10 @@ describe('isSensitivePath', () => {
it.each(allowed)('allows %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(false);
});
it.each(allowed)('still allows %s with allowDotenv:true', (_label, path) => {
expect(isSensitivePath(path, { allowDotenv: true })).toBe(false);
});
});
it('matches on the resolved path, so callers must realpath first', () => {
@@ -154,6 +208,13 @@ describe('isSensitivePath', () => {
expect(isSensitivePath(path)).toBe(true);
});
it.each(['.claude.json', '.claude/settings.json', '.claude/settings.local.json'])(
'still blocks home-anchored %s with allowDotenv:true',
(relativePath) => {
expect(isSensitivePath(`${homedir()}/${relativePath}`, { allowDotenv: true })).toBe(true);
}
);
// A blanket `/\.claude\/settings\.json$/` would also catch every CASE-level
// settings file, which users legitimately view and edit in the File Viewer
// (model override, hooks) — the home anchor is what keeps those servable.