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

+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));