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

+1 -1
View File
@@ -241,7 +241,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; dotenv is the one sensitive family it opens (`allowDotenv`, because the plain preview already serves it), so every other credential pattern, a dotenv file inside a blocked tree and the data dir's own `.env` still refuse; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Files panel delete** (salvaged from #529; `DELETE /api/sessions/:id/files?path=`, the × on a file row of the Files panel, tree and search results): removes ONE regular file and is the **only file surface that deletes**. It takes the edit route's confinement and guards whole (ownership via `findSessionOrFail`, `validateLocalSessionFilePath` behind `withGuardedOutsideWorktree`, so an absolute path in a followed outside worktree obeys the same rules, sensitive/blocked/`.git` 403), and both spellings are checked, the realpath and the path as named. ⚠️ **A symbolic link is refused (400), never followed**: the confinement check returns the REALPATH, i.e. the link's target, so an `unlink` of it deleted `CLAUDE.md` for `AGENTS.md -> CLAUDE.md` and left the link dangling. The final component is `lstat`ed on the path AS NAMED, only a plain file goes on, the inode is compared again before `unlink`, and `unlink` never follows a final link. A dangling link fails realpath and reads as 404. ⚠️ Remote sessions are refused before any path check (a same-named local folder would take the delete), and so are adopted ones (the `/tmp` file-surface exception is not copied to a new surface); Docker workspaces are allowed like the edit route. The workspace (and the outside worktree root) is probed bounded first (#516, `422` on a dead mount). ⚠️ The button is bound from the row's escaped `data-path` in the `.file-tree-item` loops, NEVER an inline `onclick` (`encodeURIComponent` leaves `'` alone, so `x'-alert(1)-'.txt` ran script); it asks with `confirm()`, reloads through `_reloadFileBrowserKeepingQuery()` so a search re-runs, stays visible under `@media (hover: none)`, and is not drawn for remote or adopted sessions. Tests: `test/routes/file-delete-routes.test.ts`, `test/file-delete-ui.test.ts`. → [architecture-invariants#files-panel-delete](docs/architecture-invariants.md#files-panel-delete)
+1 -1
View File
@@ -482,7 +482,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes file contents** (the Files panel's delete, [below](#files-panel-delete), removes whole files and borrows these confinement rules), so it carries every rule the read surfaces have plus its own:
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded from `EDITABLE_EXTENSIONS`). ⚠️ **Dotenv is the one sensitive family the editor opens**: both the gate (`assertEditableTarget`) and the `editable` flag on the plain preview call `isBlockedAttachmentPath(..., { allowDotenv: true })`, which skips only `DOTENV_PATTERNS` in `sensitive-path.ts`, and `isEditableFileName` accepts `.env`, `.env.*` and `*.env` by NAME. ⚠️ Never add `env` to `EDITABLE_EXTENSIONS`: it is also `TEXT_ATTACHMENT_EXTENSIONS`, so it would let cross-workspace attachments read any `prod.env` on the host, which no sensitive pattern matches. The reason is that the plain preview and `file-raw` never consult the blocklist (realpath containment is their control), so refusing the edit protected nothing the preview had not already shown. Every other credential pattern still refuses, so does a dotenv file inside a blocked tree (`.ssh/.env`, a config backup), and the data dir's own `.env` (the `codeman attach` login fallback) has its own entry in `SENSITIVE_PATTERNS` so a session whose workspace is `$HOME` cannot open it. Downloads, attachments and the path picker keep the full list. Keep the two call sites in step. Accepted limits: `/\.env\./` also matches a FOLDER named `.env.<x>`, so files under one become editable too (the preview already shows them); and like every `.codeman[^/]*` entry, the data-dir entry misses a data dir moved by `CODEMAN_DATA_DIR` to a folder not named `.codeman*`, or reached through a symlinked `~/.codeman`, which already left that dir's `users.json`/`settings.json` editable when it sits inside a workspace. ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
- **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick.
- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content.
+5 -2
View File
@@ -109,7 +109,9 @@ Decisions baked in:
- `svg` and `env` are deliberately marked with `?` above: `svg` is served as an untrusted octet-stream on
the read side (`file-routes.ts:118`) so allowing an edit is defensible, but I recommend **excluding
both** in v1. `.env` files are matched by `isSensitivePath()` anyway and would be rejected downstream;
excluding them at the allowlist keeps a single obvious refusal.
excluding them at the allowlist keeps a single obvious refusal. Superseded for dotenv: the plain
preview serves a workspace `.env` in full, so the editor now opens dotenv files (`allowDotenv`, see
`docs/architecture-invariants.md#file-viewer-edit-mode`); `svg` stays excluded.
- `isDeniedEditRelativePath` blocks the `.git/` subtree: `.git/hooks/*` is code execution and a corrupt
index is unrecoverable-looking to a user who only wanted to fix a typo. Other dotfiles stay allowed but
are not reachable from the tree UI anyway (`showHidden=false`).
@@ -363,7 +365,8 @@ Route cases to assert explicitly:
4. symlink pointing inside is written through to the target
5. non-allowlisted extension gives 400
6. `.git/config` gives 403
7. a `.env` in the workspace gives 403 (sensitive-path)
7. a `.env` in the workspace gives 403 (sensitive-path); superseded: it is now editable, while `.ssh/.env`,
the data dir's `.env` and the other credential files still give 403
8. a file with a NUL byte gives 400
9. a latin-1 file that fails the UTF-8 round-trip gives 400
10. stale `baseHash` gives 409 (`CONFLICT` envelope, no data); `force:true` then succeeds
+4
View File
@@ -76,6 +76,10 @@ convenient:
- **`.git/` is denied outright.** Hooks are executable code, and a corrupted index looks
unrecoverable to someone who wanted to fix a typo.
- **Non-UTF-8 content is refused**, verified by a round-trip comparison.
- **Dotenv files are editable, other credential files are not.** `.env`, `.env.local` and
`prod.env` open in the editor like any config file, and a save keeps the file's permissions
(a `0600` file stays `0600`). `.npmrc`, `credentials.json`, anything under `.ssh/` and
Codeman's own `~/.codeman/.env` stay read-only here.
## Attachments
+8 -2
View File
@@ -126,9 +126,15 @@ export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig
*
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
* behavior is NOT changed — only attachment registration/serving uses this.
* `opts` passes through to `isSensitivePath` (the File Viewer editor's
* `allowDotenv`).
*/
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
if (isSensitivePath(absPath)) return true;
export function isBlockedAttachmentPath(
absPath: string,
blockedTrees: readonly string[],
opts: { allowDotenv?: boolean } = {}
): boolean {
if (isSensitivePath(absPath, opts)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}
+11 -3
View File
@@ -8,9 +8,13 @@
*
* Design (docs/file-viewer-edit-plan.md):
* - ALLOWLIST of text extensions/basenames, not a blocklist — matching the
* attachment-guard precedent. `svg` and `env` are deliberately absent: svg is
* treated as untrusted on the read side, and `.env` is sensitive-path blocked
* anyway; excluding them here keeps a single obvious refusal.
* attachment-guard precedent. `svg` and `env` are deliberately absent from
* the extension list: svg is treated as untrusted on the read side, and the
* list doubles as the attachment list, which must not gain `*.env`. Dotenv
* files (`.env`, `.env.*`, `*.env`) are editable by NAME in
* isEditableFileName instead: the plain preview already shows them, and the
* routes pass `allowDotenv` to the sensitive-path check, which keeps every
* other credential pattern.
* - The `.git/` subtree is denied outright: `.git/hooks/*` is code execution and
* a corrupted index looks unrecoverable to a user who wanted to fix a typo.
* - EOL helpers exist because a browser <textarea> normalizes to LF; the server
@@ -116,6 +120,10 @@ export const EDITABLE_BASENAMES: ReadonlySet<string> = new Set([
export function isEditableFileName(fileName: string): boolean {
const lower = fileName.toLowerCase();
if (EDITABLE_BASENAMES.has(lower)) return true;
// Dotenv in every spelling, by name: `.env.local`'s extension is `local`, and
// `env` must stay off EDITABLE_EXTENSIONS, which is also the cross-workspace
// attachment list (TEXT_ATTACHMENT_EXTENSIONS) that never accepted `*.env`.
if (lower.endsWith('.env') || lower.startsWith('.env.')) return true;
const dot = lower.lastIndexOf('.');
// No extension (or a bare dotfile like `.bashrc`): only the basename list applies.
if (dot <= 0) return false;
+7 -4
View File
@@ -1200,10 +1200,14 @@ function throwRouteError(statusCode: number, code: ApiErrorCode, message: string
* Gate a resolved workspace file for edit-mode read/write. Throws a structured
* error when the file may not be edited; returns void when it may. Order
* matters for the message a user sees: confinement (the caller's 404) →
* sensitive/blocked (403) → .git (403) → extension allowlist (400).
* sensitive/blocked (403) → .git (403) → extension allowlist (400). Dotenv is
* the one sensitive family the editor may open (`allowDotenv`): the plain
* preview already serves it to the same user. Keep this and the `editable`
* advertisement in the read route in step.
*/
function assertEditableTarget(resolvedPath: string, relativePath: string, blockedTrees: readonly string[]): void {
if (isSensitivePath(resolvedPath) || isBlockedAttachmentPath(resolvedPath, blockedTrees)) {
// isBlockedAttachmentPath includes the sensitive-path check.
if (isBlockedAttachmentPath(resolvedPath, blockedTrees, { allowDotenv: true })) {
throwFileEditError(403, ApiErrorCode.FORBIDDEN, 'Editing this file is blocked');
}
if (isDeniedEditRelativePath(relativePath)) {
@@ -2332,8 +2336,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
editable =
isEditableFileName(pathBasename(target.resolvedPath)) &&
!isDeniedEditRelativePath(relativePath) &&
!isSensitivePath(target.resolvedPath) &&
!isBlockedAttachmentPath(target.resolvedPath, guard.blockedTrees) &&
!isBlockedAttachmentPath(target.resolvedPath, guard.blockedTrees, { allowDotenv: true }) &&
stat.size <= MAX_EDITABLE_BYTES &&
Buffer.from(content, 'utf8').equals(buf);
}
+20 -5
View File
@@ -4,6 +4,9 @@
* A small defense-in-depth blocklist of absolute paths that must never be
* served to the browser regardless of how the path was obtained (workspace
* download, cross-workspace attachment registration, raw/preview serving).
* The workspace preview routes (`file-content`, `file-raw`) do not consult it,
* since realpath containment is their control, and the File Viewer editor
* skips only the dotenv entries (`allowDotenv`).
*
* This is intentionally a BLOCKLIST, not a workspace-confinement check:
* cross-workspace attachment is a supported feature (codeman-publish skill +
@@ -45,10 +48,6 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.ssh\//,
/\/\.gnupg\//,
// Dotenv, in every conventional spelling (.env, .env.local, .env.production).
/\/\.env$/,
/\/\.env\./,
// Generic credential files, plus the per-vendor spellings that do not match it.
/\/credentials(\.json|\.yml|\.yaml|\.xml|\.toml|\.db)?$/i,
/\/\.aws\/(credentials|config)$/,
@@ -83,6 +82,10 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.claude\/\.credentials\.json$/,
/\/\.codeman[^/]*\/hook-secret$/,
/\/\.codeman[^/]*\/users\.json$/,
// The data dir's `.env` is the CODEMAN_USERNAME/PASSWORD fallback `codeman attach`
// reads. Named here as well as in DOTENV_PATTERNS so `allowDotenv` (the File
// Viewer editor) cannot reach it from a session whose workspace is $HOME.
/\/\.codeman[^/]*\/\.env$/,
// Codeman's own state files. Named once `.json` became previewable outside
// the workspace: `SessionState.envOverrides` persists whatever the user set
// for a session, and the env allowlist admits key-shaped names
@@ -124,6 +127,15 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.codeman[^/]*\/backups\//,
];
/**
* Dotenv, in every conventional spelling (.env, .env.local, .env.production).
* Kept apart from SENSITIVE_PATTERNS so the File Viewer editor can skip them
* (`allowDotenv`): the plain preview already shows a workspace dotenv file to
* the same user, so refusing the edit protected nothing. Every other surface
* (download, attachments, path picker) still blocks them.
*/
const DOTENV_PATTERNS: RegExp[] = [/\/\.env$/, /\/\.env\./];
/**
* Claude config members that are credential-bearing ONLY under the user's real
* home directory: `~/.claude/settings.json` can hold `env.ANTHROPIC_API_KEY`
@@ -141,8 +153,11 @@ const HOME_SENSITIVE_MEMBERS = ['.claude.json', '.claude/settings.json', '.claud
/**
* Returns true if the given ABSOLUTE, symlink-resolved path matches the
* sensitive-file blocklist and must not be served to the browser.
* `allowDotenv` skips only DOTENV_PATTERNS: a dotenv file inside a blocked tree
* (`.ssh/.env`, a config backup) still matches its tree's pattern.
*/
export function isSensitivePath(absPath: string): boolean {
export function isSensitivePath(absPath: string, opts: { allowDotenv?: boolean } = {}): boolean {
if (!opts.allowDotenv && DOTENV_PATTERNS.some((pattern) => pattern.test(absPath))) return true;
if (SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath))) return true;
const home = homedir();
return HOME_SENSITIVE_MEMBERS.some((member) => absPath === join(home, member));
+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.