mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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).
134 lines
5.7 KiB
TypeScript
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));
|
|
}
|