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

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