Files
Codeman/src/config/attachment-guard.ts
T
Aamer Akhter f1c64994ad COD-37 add server-side attachment pipeline (registry, magic-link, path guard)
Adds the foundation for serving local files to the browser as live external
attachments with a stable id, so requests never carry arbitrary absolute paths.

- attachment-registry: in-memory, session-scoped registry. registerExternalAttachment
  validates an absolute path, resolves symlinks, enforces the path guard, and mints
  an `att_<uuid>` id; records are cleared when the session is removed.
- attachment path guard: a configurable blocklist (secret locations + /root,/etc
  trees, extendable via attachmentBlockedPaths / CODEMAN_ATTACHMENT_BLOCKED_PATHS)
  plus an optional, default-off workspace-confinement mode. Shares one
  sensitive-path blocklist (web/sensitive-path.ts) with /api/download, which is
  refactored to use the extracted module instead of an inline copy.
- terminal magic links: the session scans output for codeman://attach?path=... and
  emits `attachmentRequested`; the web server registers the file and broadcasts an
  `attachment:detected` SSE event. `codeman attach <path>` (CLI) prints the magic
  link or POSTs directly when a session id is known.
- image watcher: detects png/pdf/docx/pptx dropped into a session's working dir and
  emits `attachment:detected`.
- routes: POST /api/sessions/:id/attachments (register) and
  GET /api/sessions/:id/attachments/:attachmentId/raw (serve), both re-checking the
  guard before streaming.

Document previews/thumbnails and the attachment-history drawer build on this
foundation and land separately.

Verified: tsc --noEmit, lint, format, frontend-syntax, full test:ci (2846 passed),
and a server boot smoke (/api/status 200).
2026-06-11 10:27:09 +02:00

134 lines
5.7 KiB
TypeScript

/**
* @fileoverview Attachment path-guard configuration (COD-53).
*
* Governs which host files may be registered as cross-workspace attachments
* and served to the browser. Two operator-facing knobs, both with safe
* defaults:
*
* 1. **Blocked-path blocklist (DEFAULT, configurable).** Pre-populated with the
* shared secret-location blocklist (`isSensitivePath`) PLUS the directory
* trees `/root` and `/etc` (anything under them is blocked). The operator
* EXTENDS — never shrinks — this set with additional absolute directory
* trees via the settings key `attachmentBlockedPaths: string[]` and/or the
* env var `CODEMAN_ATTACHMENT_BLOCKED_PATHS` (comma-separated).
*
* 2. **Workspace confinement (OPTIONAL, default OFF).** When enabled, an
* attachment must resolve INSIDE the registering session's workingDir
* (reusing `validateSessionFilePath` containment semantics). This is
* strictly more restrictive than the blocklist and breaks intentional
* cross-workspace attachment (codeman-publish, the ~/.codeman review-card
* loop), so it is OFF by default. Toggle via settings
* `attachmentConfineToWorkspace: boolean` and/or env
* `CODEMAN_ATTACHMENT_CONFINE` (`1`/`true`).
*
* All paths passed to the predicates here MUST be absolute and symlink-resolved
* (realpath) by the caller, mirroring `isSensitivePath`'s contract.
*
* @module config/attachment-guard
*/
import { sep } from 'node:path';
import { isSensitivePath } from '../web/sensitive-path.js';
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
/**
* Directory trees blocked by default, IN ADDITION to the secret-location
* blocklist in `isSensitivePath`. Anything resolving under one of these trees
* is rejected. Pre-populated with the root account home and the system config
* tree (which already partially overlaps `isSensitivePath`'s `/etc/shadow`
* etc., but here we block the WHOLE tree).
*/
export const DEFAULT_BLOCKED_TREES: readonly string[] = ['/root', '/etc'];
/** Settings key carrying extra blocked directory trees (extends the defaults). */
export const ATTACHMENT_BLOCKED_PATHS_SETTING = 'attachmentBlockedPaths';
/** Settings key carrying the workspace-confinement toggle. */
export const ATTACHMENT_CONFINE_SETTING = 'attachmentConfineToWorkspace';
/** Resolved attachment-guard configuration. */
export interface AttachmentGuardConfig {
/** Pre-populated default trees PLUS any operator extras. */
blockedTrees: string[];
/** Whether attachments must resolve inside the session workspace. */
confineToWorkspace: boolean;
}
/** Normalizes a tree prefix: trim, drop trailing separators (but keep root). */
function normalizeTree(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
// Strip trailing slashes so '/etc/' and '/etc' behave the same; never reduce
// a bare separator to empty.
const stripped = trimmed.replace(/[/\\]+$/, '');
return stripped || trimmed[0];
}
/**
* Returns true if `absPath` (absolute, symlink-resolved) is the tree itself or
* lives under it. Uses path-separator-aware matching so `/etc` does NOT block
* an unrelated `/etcetera/notes.md`.
*/
export function isUnderTree(absPath: string, tree: string): boolean {
const t = normalizeTree(tree);
if (!t) return false;
if (absPath === t) return true;
return absPath.startsWith(t.endsWith(sep) ? t : t + sep);
}
/** Parses the comma-separated env override into a list of normalized trees. */
function parseEnvBlockedTrees(): string[] {
const raw = process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
if (!raw) return [];
return raw
.split(',')
.map(normalizeTree)
.filter((t) => t.length > 0);
}
/** Parses the env confinement toggle (`1`/`true`/`yes`/`on`, case-insensitive). */
function parseEnvConfine(): boolean | undefined {
const raw = process.env.CODEMAN_ATTACHMENT_CONFINE;
if (raw === undefined) return undefined;
return /^(1|true|yes|on)$/i.test(raw.trim());
}
/**
* Loads the effective attachment-guard config by merging the pre-populated
* defaults with settings.json and env overrides. Env wins over settings for the
* confinement toggle; blocked-tree extras from BOTH sources are unioned on top
* of the defaults (operators can only EXTEND, never shrink, the blocked set).
*/
export async function loadAttachmentGuardConfig(): Promise<AttachmentGuardConfig> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const settingsTrees = Array.isArray(settings[ATTACHMENT_BLOCKED_PATHS_SETTING])
? (settings[ATTACHMENT_BLOCKED_PATHS_SETTING] as unknown[])
.filter((v): v is string => typeof v === 'string')
.map(normalizeTree)
.filter((t) => t.length > 0)
: [];
const blockedTrees = Array.from(new Set([...DEFAULT_BLOCKED_TREES, ...settingsTrees, ...parseEnvBlockedTrees()]));
const envConfine = parseEnvConfine();
const settingsConfine = settings[ATTACHMENT_CONFINE_SETTING] === true;
const confineToWorkspace = envConfine ?? settingsConfine;
return { blockedTrees, confineToWorkspace };
}
/**
* Attachment-specific blocklist check. Builds on the shared `isSensitivePath`
* base (secret locations, shared with `/api/download`) and ADDS the configured
* directory trees (`/root`, `/etc`, plus operator extras). `absPath` must be
* absolute and symlink-resolved.
*
* NOTE: this is intentionally a SUPERSET of `isSensitivePath` so `/api/download`
* behavior is NOT changed — only attachment registration/serving uses this.
*/
export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly string[]): boolean {
if (isSensitivePath(absPath)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}