diff --git a/src/attachment-magic.ts b/src/attachment-magic.ts new file mode 100644 index 00000000..f4d6d763 --- /dev/null +++ b/src/attachment-magic.ts @@ -0,0 +1,35 @@ +/** + * @fileoverview Parses terminal magic links that request attachment cards. + */ + +import { isAbsolute } from 'node:path'; +import { isSupportedAttachmentExtension } from './attachment-registry.js'; + +const MAGIC_LINK_RE = /codeman:\/\/attach\?([^\s<>"']+)/g; + +export function parseAttachmentMagicLinks(data: string): string[] { + const results: string[] = []; + const seen = new Set(); + + for (const match of data.matchAll(MAGIC_LINK_RE)) { + const query = trimTrailingPunctuation(match[1] || ''); + try { + const params = new URLSearchParams(query); + const filePath = params.get('path'); + if (!filePath || !isAbsolute(filePath)) continue; + const extension = filePath.split('.').pop()?.toLowerCase() || ''; + if (!isSupportedAttachmentExtension(extension)) continue; + if (seen.has(filePath)) continue; + seen.add(filePath); + results.push(filePath); + } catch { + // Ignore malformed terminal text. Magic links are advisory. + } + } + + return results; +} + +function trimTrailingPunctuation(value: string): string { + return value.replace(/[),.;:]+$/g, ''); +} diff --git a/src/attachment-registry.ts b/src/attachment-registry.ts new file mode 100644 index 00000000..fe7b64cf --- /dev/null +++ b/src/attachment-registry.ts @@ -0,0 +1,216 @@ +/** + * @fileoverview In-memory attachment registry for live external document references. + * + * Session-local files keep using the existing workspace-scoped file routes. This + * registry is only for explicit, live external attachments that need a stable ID + * so browser requests never contain arbitrary absolute paths. + */ + +import { randomUUID } from 'node:crypto'; +import { realpathSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { basename, extname, isAbsolute } from 'node:path'; +import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from './config/attachment-guard.js'; +import { validateSessionFilePath } from './web/route-helpers.js'; +import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js'; + +const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set(['png', 'pdf', 'docx', 'pptx', 'md', 'txt']); + +export type AttachmentSource = 'detected' | 'external'; + +export interface AttachmentRecord { + attachmentId: string; + sessionId: string; + filePath: string; + fileName: string; + extension: string; + attachmentType: AttachmentDetectedType; + size: number; + mtimeMs: number; + timestamp: number; + source: AttachmentSource; +} + +export interface AttachmentRegistrationResult extends AttachmentDetectedEvent { + attachmentId: string; + source: AttachmentSource; + rawUrl: string; + previewUrl: string; + thumbnailUrl: string; +} + +export class AttachmentRegistrationError extends Error { + constructor( + message: string, + readonly statusCode: number = 400 + ) { + super(message); + } +} + +class AttachmentRegistry { + private recordsBySession = new Map>(); + + register(record: AttachmentRecord): void { + let records = this.recordsBySession.get(record.sessionId); + if (!records) { + records = new Map(); + this.recordsBySession.set(record.sessionId, records); + } + records.set(record.attachmentId, record); + } + + get(sessionId: string, attachmentId: string): AttachmentRecord | undefined { + return this.recordsBySession.get(sessionId)?.get(attachmentId); + } + + findByFilePath(sessionId: string, filePath: string): AttachmentRecord | undefined { + const records = this.recordsBySession.get(sessionId); + if (!records) return undefined; + for (const record of records.values()) { + if (record.filePath === filePath) return record; + } + return undefined; + } + + clearSession(sessionId: string): void { + this.recordsBySession.delete(sessionId); + } +} + +export const attachmentRegistry = new AttachmentRegistry(); + +export function isSupportedAttachmentExtension(extension: string): boolean { + return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, '')); +} + +export function getAttachmentType(extension: string): AttachmentDetectedType { + const normalized = extension.toLowerCase().replace(/^\./, ''); + if (normalized === 'png') return 'image'; + if (normalized === 'pdf') return 'pdf'; + if (normalized === 'pptx') return 'presentation'; + if (normalized === 'md') return 'markdown'; + if (normalized === 'txt') return 'text'; + return 'document'; +} + +export function buildAttachmentRoutes( + sessionId: string, + attachmentId: string +): { + rawUrl: string; + previewUrl: string; + thumbnailUrl: string; +} { + const encodedId = encodeURIComponent(attachmentId); + return { + rawUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/raw`, + previewUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/preview`, + thumbnailUrl: `/api/sessions/${sessionId}/attachments/${encodedId}/thumbnail`, + }; +} + +export function buildFileThumbnailRoute(sessionId: string, relativePath: string): string { + return `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(relativePath)}`; +} + +export function attachmentRecordToEvent(record: AttachmentRecord): AttachmentRegistrationResult { + const routes = buildAttachmentRoutes(record.sessionId, record.attachmentId); + return { + sessionId: record.sessionId, + filePath: record.fileName, + relativePath: '', + fileName: record.fileName, + extension: record.extension, + attachmentType: record.attachmentType, + timestamp: record.timestamp, + size: record.size, + attachmentId: record.attachmentId, + source: record.source, + ...routes, + }; +} + +/** Options for {@link registerExternalAttachment}. */ +export interface RegisterExternalAttachmentOptions { + /** + * The registering session's working directory. Required only to enforce + * workspace confinement when that mode is enabled + * (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`); ignored in + * the default blocklist mode. + */ + sessionWorkingDir?: string; +} + +export async function registerExternalAttachment( + sessionId: string, + requestedPath: string, + options: RegisterExternalAttachmentOptions = {} +): Promise { + if (!requestedPath || !isAbsolute(requestedPath)) { + throw new AttachmentRegistrationError('Attachment path must be an absolute local path'); + } + + let resolvedPath: string; + try { + resolvedPath = realpathSync(requestedPath); + } catch { + throw new AttachmentRegistrationError('Attachment file not found', 404); + } + + // COD-53: enforce the active attachment-guard policy on the symlink-resolved + // path before doing anything else. + const guard = await loadAttachmentGuardConfig(); + + if (guard.confineToWorkspace) { + // Strict mode (opt-in, default OFF): the file MUST resolve inside the + // session's workspace. Strictly more restrictive than the blocklist — + // breaks cross-workspace attachment, which is why it is off by default. + const workingDir = options.sessionWorkingDir; + if (!workingDir || !validateSessionFilePath(workingDir, resolvedPath)) { + throw new AttachmentRegistrationError('Access to this file is blocked', 403); + } + } + + // Blocklist (DEFAULT, also applied alongside confinement as defense in + // depth): pre-populated secret locations + the /root and /etc trees + any + // operator-configured extra trees. Symlinks are already resolved above. + // Cross-workspace attachment of non-blocked files stays allowed, so + // codeman-publish and the ~/.codeman review loop keep working. + if (isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) { + throw new AttachmentRegistrationError('Access to this file is blocked', 403); + } + + const extension = extname(resolvedPath).toLowerCase().replace(/^\./, ''); + if (!isSupportedAttachmentExtension(extension)) { + throw new AttachmentRegistrationError('Unsupported attachment type'); + } + + const stat = await fs.stat(resolvedPath); + if (typeof stat.isFile === 'function' && !stat.isFile()) { + throw new AttachmentRegistrationError('Attachment path is not a file'); + } + + const existing = attachmentRegistry.findByFilePath(sessionId, resolvedPath); + if (existing) { + existing.size = stat.size; + existing.mtimeMs = stat.mtimeMs ?? 0; + existing.timestamp = Date.now(); + return attachmentRecordToEvent(existing); + } + + const record: AttachmentRecord = { + attachmentId: `att_${randomUUID()}`, + sessionId, + filePath: resolvedPath, + fileName: basename(resolvedPath), + extension, + attachmentType: getAttachmentType(extension), + size: stat.size, + mtimeMs: stat.mtimeMs ?? 0, + timestamp: Date.now(), + source: 'external', + }; + attachmentRegistry.register(record); + return attachmentRecordToEvent(record); +} diff --git a/src/cli.ts b/src/cli.ts index ea6536ac..ba5be45f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,11 +10,17 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { createRequire } from 'module'; +import http from 'node:http'; +import https from 'node:https'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; import { getSessionManager } from './session-manager.js'; import { getTaskQueue } from './task-queue.js'; import { getRalphLoop } from './ralph-loop.js'; import { getStore } from './state-store.js'; import { getErrorMessage } from './types.js'; +import { isSupportedAttachmentExtension } from './attachment-registry.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json') as { version: string }; @@ -23,6 +29,93 @@ const program = new Command(); program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version); +function makeAttachmentMagicLink(filePath: string): string { + return `codeman://attach?path=${encodeURIComponent(filePath)}`; +} + +function readCodemanEnv(): Record { + const envPath = join(homedir(), '.codeman', '.env'); + try { + const text = readFileSync(envPath, 'utf-8'); + const result: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + result[match[1]] = value; + } + return result; + } catch { + return {}; + } +} + +async function postAttachment(apiUrl: string, sessionId: string, filePath: string): Promise { + const envFile = readCodemanEnv(); + const username = process.env.CODEMAN_USERNAME || envFile.CODEMAN_USERNAME || 'admin'; + const password = process.env.CODEMAN_PASSWORD || envFile.CODEMAN_PASSWORD; + const url = new URL(`/api/sessions/${encodeURIComponent(sessionId)}/attachments`, apiUrl); + const body = JSON.stringify({ path: filePath }); + const transport = url.protocol === 'https:' ? https : http; + + return new Promise((resolve) => { + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }; + if (password) { + headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + } + + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method: 'POST', + path: `${url.pathname}${url.search}`, + rejectUnauthorized: false, + headers, + }, + (res) => { + res.resume(); + res.on('end', () => resolve(Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300))); + } + ); + req.on('error', () => resolve(false)); + req.write(body); + req.end(); + }); +} + +program + .command('attach ') + .description('Show an attachment card for a local file') + .option('-s, --session ', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)') + .option('--url ', 'Codeman API URL (defaults to CODEMAN_API_URL or https://127.0.0.1:3000)') + .action(async (filePath, options) => { + const extension = String(filePath).split('.').pop()?.toLowerCase() || ''; + if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) { + console.error(chalk.red('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file')); + process.exit(1); + } + + const sessionId = options.session || process.env.CODEMAN_SESSION_ID; + const apiUrl = options.url || process.env.CODEMAN_API_URL || 'https://127.0.0.1:3000'; + if (sessionId && (await postAttachment(apiUrl, sessionId, filePath))) { + console.log(chalk.green('✓ Attachment card requested')); + return; + } + + console.log(makeAttachmentMagicLink(filePath)); + }); + // ============ Session Commands ============ const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions'); diff --git a/src/config/attachment-guard.ts b/src/config/attachment-guard.ts new file mode 100644 index 00000000..86744b47 --- /dev/null +++ b/src/config/attachment-guard.ts @@ -0,0 +1,133 @@ +/** + * @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 { + const settings = await readJsonConfig>(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)); +} diff --git a/src/image-watcher.ts b/src/image-watcher.ts index 35454909..e0f8c7ae 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events'; import { watch, type FSWatcher } from 'chokidar'; import { basename, extname, relative } from 'node:path'; import { statSync } from 'node:fs'; -import type { ImageDetectedEvent } from './types.js'; +import type { AttachmentDetectedEvent, AttachmentDetectedType, ImageDetectedEvent } from './types.js'; import { KeyedDebouncer } from './utils/index.js'; // ========== Types ========== @@ -20,7 +20,9 @@ import { KeyedDebouncer } from './utils/index.js'; // ========== Constants ========== /** Supported image file extensions (lowercase) */ -const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']); +const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']); +const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']); +const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]); /** Time to wait for file writes to stabilize (ms) */ const STABILITY_THRESHOLD_MS = 500; @@ -166,8 +168,8 @@ export class ImageWatcher extends EventEmitter { } const ext = extname(path).toLowerCase(); // Don't ignore directories (needed for watching to work) - // Ignore files that aren't images - return ext !== '' && !IMAGE_EXTENSIONS.has(ext); + // Ignore files that aren't previewable images/documents + return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext); }, }); @@ -229,15 +231,16 @@ export class ImageWatcher extends EventEmitter { /** * Handle a new file being detected. - * Verifies it's an image and emits the detection event. + * Verifies it's a previewable image/document and emits the detection event. */ private handleNewFile(sessionId: string, filePath: string): void { const ext = extname(filePath).toLowerCase(); - // Double-check it's an image extension - if (!IMAGE_EXTENSIONS.has(ext)) { + // Double-check it's a supported extension + if (!DETECTED_FILE_EXTENSIONS.has(ext)) { return; } + const isAttachment = ATTACHMENT_EXTENSIONS.has(ext); // Burst limit: skip if too many images detected for this session in a short window const now = Date.now(); @@ -259,7 +262,11 @@ export class ImageWatcher extends EventEmitter { // Debounce rapid file creation (e.g., multiple screenshots quickly) this.fileDeb.schedule(filePath, () => { this.fileToSession.delete(filePath); - this.emitImageDetected(sessionId, filePath); + if (isAttachment) { + this.emitAttachmentDetected(sessionId, filePath); + } else { + this.emitImageDetected(sessionId, filePath); + } // Increment burst count on actual emission (not on detection) const b = this.burstTrackers.get(sessionId); if (b) b.count++; @@ -294,6 +301,42 @@ export class ImageWatcher extends EventEmitter { this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId); } } + + /** + * Emit the attachment:detected event with file metadata. + */ + private emitAttachmentDetected(sessionId: string, filePath: string): void { + try { + const stat = statSync(filePath); + const fileName = basename(filePath); + const workingDir = this.sessionDirs.get(sessionId); + const relativePath = workingDir ? relative(workingDir, filePath) : fileName; + const extension = extname(fileName).toLowerCase().replace(/^\./, ''); + + const event: AttachmentDetectedEvent = { + sessionId, + filePath, + relativePath, + fileName, + extension, + attachmentType: this.getAttachmentType(extension), + timestamp: Date.now(), + size: stat.size, + }; + + this.emit('attachment:detected', event); + } catch (error) { + this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId); + } + } + + private getAttachmentType(extension: string): AttachmentDetectedType { + if (extension === 'png') return 'image'; + if (extension === 'pdf') return 'pdf'; + if (extension === 'docx') return 'document'; + if (extension === 'pptx') return 'presentation'; + return 'document'; + } } // Export singleton instance for convenience diff --git a/src/session.ts b/src/session.ts index f5b6c60f..1bd5d5f7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -80,6 +80,7 @@ import { import { SessionAutoOps } from './session-auto-ops.js'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; import { SessionTaskCache } from './session-task-cache.js'; +import { parseAttachmentMagicLinks } from './attachment-magic.js'; export type { BackgroundTask } from './task-tracker.js'; export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; @@ -311,6 +312,9 @@ export class Session extends EventEmitter { private _parentAgentId: string | null = null; private _childAgentIds: string[] = []; + // Bounded dedup set for terminal attachment magic-links already requested. + private _attachmentMagicSeen = new Set(); + // Nice prioritying configuration private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG }; @@ -1135,6 +1139,20 @@ export class Session extends EventEmitter { .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, ''); } + // Scan terminal output for `codeman://attach?path=...` magic links and emit + // an attachmentRequested event for each newly-seen absolute path. The web + // server turns these into registered attachment cards. + const attachmentPaths = parseAttachmentMagicLinks(data); + for (const attachmentPath of attachmentPaths) { + if (this._attachmentMagicSeen.has(attachmentPath)) continue; + this._attachmentMagicSeen.add(attachmentPath); + if (this._attachmentMagicSeen.size > 200) { + const oldest = this._attachmentMagicSeen.values().next().value; + if (oldest) this._attachmentMagicSeen.delete(oldest); + } + this.emit('attachmentRequested', { sessionId: this.id, path: attachmentPath, timestamp: Date.now() }); + } + // BufferAccumulator handles auto-trimming when max size exceeded this._terminalBuffer.append(data); this._lastActivityAt = Date.now(); diff --git a/src/types/tools.ts b/src/types/tools.ts index 1e3de918..233c0205 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -7,13 +7,14 @@ * - ActiveBashTool — a live bash command with extracted file paths and status * - ActiveBashToolStatus — 'running' | 'completed' * - ImageDetectedEvent — screenshot/image file detection trigger for UI popup + * - AttachmentDetectedEvent — document/image file detection trigger for attachment cards * * Cross-domain relationships: * - ActiveBashTool.sessionId links to SessionState.id (session domain) * - ImageDetectedEvent.sessionId links to SessionState.id (session domain) * * Both types are in-memory only (not persisted). Broadcast via SSE events - * `subagent:tool_call` and `image:detected`. Parsed by BashToolParser + * `subagent:tool_call`, `image:detected`, and `attachment:detected`. Parsed by BashToolParser * (`src/bash-tool-parser.ts`). */ @@ -61,3 +62,38 @@ export interface ImageDetectedEvent { /** File size in bytes */ size: number; } + +export type AttachmentDetectedType = 'image' | 'pdf' | 'document' | 'presentation' | 'markdown' | 'text'; + +/** + * Event emitted when a new previewable attachment file is detected in a session's + * working directory. Used to render a compact attachment card in the web UI. + */ +export interface AttachmentDetectedEvent { + /** Codeman session ID where the attachment was detected */ + sessionId: string; + /** Full path to the detected attachment file */ + filePath: string; + /** Path relative to the session's working directory (for file-raw/file-preview endpoints) */ + relativePath: string; + /** Attachment file name (basename) */ + fileName: string; + /** Lowercase extension without a leading dot */ + extension: string; + /** Viewer category used by the web UI */ + attachmentType: AttachmentDetectedType; + /** Timestamp when the attachment was detected */ + timestamp: number; + /** File size in bytes */ + size: number; + /** Registered attachment id for explicit live external attachments */ + attachmentId?: string; + /** Source of the attachment card request */ + source?: 'detected' | 'external'; + /** Raw file route for explicit attachments */ + rawUrl?: string; + /** Inline preview route for explicit attachments */ + previewUrl?: string; + /** First-page thumbnail route for card previews */ + thumbnailUrl?: string; +} diff --git a/src/web/public/constants.js b/src/web/public/constants.js index c3b22714..1dc86bc1 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -336,6 +336,7 @@ const SSE_EVENTS = { // Images IMAGE_DETECTED: 'image:detected', + ATTACHMENT_DETECTED: 'attachment:detected', // Tunnel TUNNEL_STARTED: 'tunnel:started', diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 2b8051df..8f40febd 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -3,16 +3,146 @@ * Provides directory listing, file content preview, raw file serving, and tail streaming. */ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, type FastifyReply } from 'fastify'; import { basename as pathBasename, join } from 'node:path'; -import { homedir } from 'node:os'; +import { createReadStream, realpathSync, type ReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; +import { + AttachmentRegistrationError, + attachmentRegistry, + registerExternalAttachment, + type AttachmentRecord, +} from '../../attachment-registry.js'; +import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js'; -import type { SessionPort } from '../ports/index.js'; +import { isSensitivePath } from '../sensitive-path.js'; +import { SseEvent } from '../sse-events.js'; +import type { EventPort, SessionPort } from '../ports/index.js'; -export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void { +const MIME_TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + ico: 'image/x-icon', + bmp: 'image/bmp', + pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + json: 'application/json', + md: 'text/markdown', + txt: 'text/plain', +}; + +function sanitizeDownloadName(fileName: string): string { + return fileName.replace(/["\\\r\n]/g, '_'); +} + +function sendRawStream(reply: FastifyReply, content: ReadStream): void { + const headers = reply.getHeaders(); + reply.hijack(); + + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + reply.raw.setHeader(name, value); + } + } + + content.on('error', (err) => { + if (reply.raw.headersSent) { + reply.raw.destroy(err); + return; + } + + reply.raw.statusCode = 500; + reply.raw.end('Failed to read file'); + }); + content.pipe(reply.raw); +} + +async function serveRawFile( + reply: FastifyReply, + resolvedPath: string, + fileName: string, + extension: string, + download?: boolean +): Promise { + const stat = await fs.stat(resolvedPath); + const content = createReadStream(resolvedPath); + const safeName = sanitizeDownloadName(fileName); + if (download || extension === 'svg') { + reply.header( + 'Content-Type', + extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream' + ); + reply.header('Content-Disposition', `attachment; filename="${safeName}"`); + reply.header('Content-Length', stat.size); + reply.header('X-Content-Type-Options', 'nosniff'); + sendRawStream(reply, content); + return; + } + + reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream'); + reply.header('Content-Disposition', `inline; filename="${safeName}"`); + reply.header('Content-Length', stat.size); + reply.header('X-Content-Type-Options', 'nosniff'); + sendRawStream(reply, content); +} + +function getAttachmentOr404( + reply: FastifyReply, + sessionId: string, + attachmentId: string +): AttachmentRecord | undefined { + const record = attachmentRegistry.get(sessionId, attachmentId); + if (!record) { + reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found')); + return undefined; + } + return record; +} + +/** + * COD-53 defense-in-depth: refuse to stream a record whose underlying path is + * blocked by the active attachment-guard policy, even though registration + * already blocks them. Guards against records that predate the guard or were + * crafted to point at a sensitive file. Resolves symlinks before the check so a + * record pointing at a symlink that now resolves to a sensitive target is also + * caught; if the path can't be resolved (deleted/unreadable) the check still + * runs on the stored path. When workspace confinement is enabled it additionally + * rejects any record outside the session workspace. Returns true (and sends a + * 403) when blocked. + */ +async function rejectIfSensitiveRecord( + reply: FastifyReply, + record: AttachmentRecord, + sessionWorkingDir?: string +): Promise { + let pathToCheck = record.filePath; + try { + pathToCheck = realpathSync(record.filePath); + } catch { + // Fall back to the stored (already realpath-resolved at registration) path. + } + + const guard = await loadAttachmentGuardConfig(); + + const blocked = + isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) || + isBlockedAttachmentPath(record.filePath, guard.blockedTrees) || + (guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck))); + + if (blocked) { + reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked')); + return true; + } + return false; +} + +export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void { // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; @@ -315,6 +445,58 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void } }); + // ===== Live external attachments ===== + // Register an explicit, live external file (absolute host path) as an + // attachment with a stable id so browser requests never carry arbitrary + // paths. Registration enforces the COD-53 attachment-guard policy. Serving is + // by id via the /raw route below; document previews/thumbnails and the + // attachment-history list are layered on separately. + app.post('/api/sessions/:id/attachments', async (req, reply) => { + const { id } = req.params as { id: string }; + const session = findSessionOrFail(ctx, id); + const body = (req.body || {}) as { path?: string }; + + if (!body.path || typeof body.path !== 'string') { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path')); + return; + } + + try { + const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir }); + ctx.broadcast(SseEvent.AttachmentDetected, event); + return { success: true, data: event }; + } catch (err) { + if (err instanceof AttachmentRegistrationError) { + reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message)); + return; + } + return reply + .code(500) + .send( + createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`) + ); + } + }); + + // Serve the raw bytes of a registered attachment by id. Re-checks the + // attachment-guard policy on every request (defense-in-depth) before streaming. + app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => { + const { id, attachmentId } = req.params as { id: string; attachmentId: string }; + const { download } = req.query as { download?: string }; + const session = findSessionOrFail(ctx, id); + const record = getAttachmentOr404(reply, id, attachmentId); + if (!record) return; + if (await rejectIfSensitiveRecord(reply, record, session.workingDir)) return; + + try { + await serveRawFile(reply, record.filePath, record.fileName, record.extension, download === 'true'); + } catch (err) { + reply + .code(500) + .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); + } + }); + // Stream file content via tail -f (SSE endpoint) app.get('/api/sessions/:id/tail-file', async (req, reply) => { const { id } = req.params as { id: string }; @@ -387,24 +569,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void }); // Session-scoped file download. // Uses the same realpath-based workspace boundary as file preview/raw routes; - // the sensitive-path blocklist remains defense-in-depth, not the primary boundary. - const SENSITIVE_PATTERNS: RegExp[] = [ - /^\/etc\/shadow$/, - /^\/etc\/gshadow$/, - /^\/etc\/master\.passwd$/, - new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`), - /\/\.env$/, - /\/\.env\./, - /\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i, - /\/\.aws\/credentials$/, - /\/\.gcloud\/credentials\.db$/, - /\/\.docker\/config\.json$/, - ]; - - function isSensitivePath(absPath: string): boolean { - return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath)); - } - + // the shared sensitive-path blocklist (../sensitive-path.js, also used by the + // attachment guard) remains defense-in-depth, not the primary boundary. app.get('/api/download', async (req, reply) => { const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string }; diff --git a/src/web/sensitive-path.ts b/src/web/sensitive-path.ts new file mode 100644 index 00000000..cb3e8624 --- /dev/null +++ b/src/web/sensitive-path.ts @@ -0,0 +1,41 @@ +/** + * @fileoverview Shared sensitive-path blocklist. + * + * 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). + * + * This is intentionally a BLOCKLIST, not a workspace-confinement check: + * cross-workspace attachment is a supported feature (codeman-publish skill + + * the automated review-card loop attaching files under ~/.codeman/), so a + * strict session-workspace boundary would break legitimate use. The blocklist + * rejects well-known secret locations (system password files, SSH keys, cloud + * credentials, dotenv files) while leaving ordinary cross-workspace files + * attachable. + * + * Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a + * symlink pointing at a sensitive target is also caught. + */ + +import { homedir } from 'node:os'; + +const SENSITIVE_PATTERNS: RegExp[] = [ + /^\/etc\/shadow$/, + /^\/etc\/gshadow$/, + /^\/etc\/master\.passwd$/, + new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`), + /\/\.env$/, + /\/\.env\./, + /\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i, + /\/\.aws\/credentials$/, + /\/\.gcloud\/credentials\.db$/, + /\/\.docker\/config\.json$/, +]; + +/** + * Returns true if the given ABSOLUTE, symlink-resolved path matches the + * sensitive-file blocklist and must not be served to the browser. + */ +export function isSensitivePath(absPath: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath)); +} diff --git a/src/web/server.ts b/src/web/server.ts index aeb57a14..ae812568 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -60,6 +60,7 @@ import { type SubagentToolResult, } from '../subagent-watcher.js'; import { imageWatcher } from '../image-watcher.js'; +import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js'; import { TranscriptWatcher } from '../transcript-watcher.js'; import { TeamWatcher } from '../team-watcher.js'; import { TunnelManager } from '../tunnel-manager.js'; @@ -110,6 +111,7 @@ import { type PersistedRespawnConfig, type NiceConfig, type ImageDetectedEvent, + type AttachmentDetectedEvent, DEFAULT_NICE_CONFIG, } from '../types.js'; import { @@ -248,6 +250,7 @@ export class WebServer extends EventEmitter { } | null = null; private imageWatcherHandlers: { detected: (event: ImageDetectedEvent) => void; + attachmentDetected: (event: AttachmentDetectedEvent) => void; error: (error: Error, sessionId?: string) => void; } | null = null; private tunnelManager: TunnelManager = new TunnelManager(); @@ -436,12 +439,15 @@ export class WebServer extends EventEmitter { // Store handlers for cleanup on shutdown this.imageWatcherHandlers = { detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event), + attachmentDetected: (event: AttachmentDetectedEvent) => + this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }), error: (error: Error, sessionId?: string) => { console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message); }, }; imageWatcher.on('image:detected', this.imageWatcherHandlers.detected); + imageWatcher.on('attachment:detected', this.imageWatcherHandlers.attachmentDetected); imageWatcher.on('image:error', this.imageWatcherHandlers.error); } @@ -451,6 +457,7 @@ export class WebServer extends EventEmitter { private cleanupImageWatcherListeners(): void { if (this.imageWatcherHandlers) { imageWatcher.off('image:detected', this.imageWatcherHandlers.detected); + imageWatcher.off('attachment:detected', this.imageWatcherHandlers.attachmentDetected); imageWatcher.off('image:error', this.imageWatcherHandlers.error); this.imageWatcherHandlers = null; } @@ -1067,6 +1074,8 @@ export class WebServer extends EventEmitter { session.removeAllListeners(); // Close any active file streams for this session fileStreamManager.closeSessionStreams(sessionId); + // Drop live external attachment registrations for this session + attachmentRegistry.clearSession(sessionId); // Stop watching for images in this session's directory imageWatcher.unwatchSession(sessionId); // Clean up pasted images directory for this session @@ -1252,9 +1261,23 @@ export class WebServer extends EventEmitter { } }, getStore: () => this.store, + registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath), }; } + /** + * Register a terminal-requested external file as a live attachment and + * broadcast it. Triggered by the session's `attachmentRequested` event + * (codeman://attach magic links). Registration enforces the COD-53 + * attachment-guard policy. + */ + private async registerAttachment(sessionId: string, filePath: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + const event = await registerExternalAttachment(sessionId, filePath, { sessionWorkingDir: session.workingDir }); + this.broadcast(SseEvent.AttachmentDetected, event); + } + private setupRespawnListeners(sessionId: string, controller: RespawnController): void { wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps()); } diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 40817f83..5a0f9a4f 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -58,6 +58,7 @@ export interface SessionListenerRefs { bashToolStart: (tool: ActiveBashTool) => void; bashToolEnd: (tool: ActiveBashTool) => void; bashToolsUpdate: (tools: ActiveBashTool[]) => void; + attachmentRequested: (event: { path: string }) => void; } /** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */ @@ -77,10 +78,11 @@ interface SessionListenerDeps { removeSessionListenerRefs(sessionId: string): void; cleanupRespawnOnExit(sessionId: string): void; getStore(): import('../state-store.js').StateStore; + registerAttachment(sessionId: string, filePath: string): Promise; } /** - * Creates all 25 session listener handlers, capturing dependencies via closure. + * Creates all 26 session listener handlers, capturing dependencies via closure. * Call `attachSessionListeners()` after to wire them to the session. */ export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs { @@ -355,6 +357,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe bashToolsUpdate: (tools: ActiveBashTool[]) => { deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools }); }, + + /** Registers an explicit attachment card requested by terminal magic text. */ + attachmentRequested: (event: { path: string }) => { + deps.registerAttachment(session.id, event.path).catch((err) => { + console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err); + }); + }, }; } @@ -388,6 +397,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe session.on('bashToolStart', refs.bashToolStart); session.on('bashToolEnd', refs.bashToolEnd); session.on('bashToolsUpdate', refs.bashToolsUpdate); + session.on('attachmentRequested', refs.attachmentRequested); } /** Detach all listeners from a session (prevents memory leaks from closure references). */ @@ -420,4 +430,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe session.off('bashToolStart', refs.bashToolStart); session.off('bashToolEnd', refs.bashToolEnd); session.off('bashToolsUpdate', refs.bashToolsUpdate); + session.off('attachmentRequested', refs.attachmentRequested); } diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 98e98d92..e851dfd6 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -284,6 +284,8 @@ export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const; /** New image file detected (e.g. screenshot upload). */ export const ImageDetected = 'image:detected' as const; +/** New document/image attachment detected in a session working directory. */ +export const AttachmentDetected = 'attachment:detected' as const; // ─── Hooks ─────────────────────────────────────────────────────────────────── @@ -479,6 +481,7 @@ export const SseEvent = { // Image ImageDetected, + AttachmentDetected, // Hooks HookIdlePrompt, diff --git a/test/attachment-magic.test.ts b/test/attachment-magic.test.ts new file mode 100644 index 00000000..0acbae2d --- /dev/null +++ b/test/attachment-magic.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { Session } from '../src/session.js'; +import { parseAttachmentMagicLinks } from '../src/attachment-magic.js'; + +describe('attachment magic links', () => { + it('extracts absolute paths from codeman attach magic URLs', () => { + const links = parseAttachmentMagicLinks( + 'Preview this: codeman://attach?path=%2Fmnt%2Fc%2FDecks%2FBoard%20Update.pptx' + ); + + expect(links).toEqual(['/mnt/c/Decks/Board Update.pptx']); + }); + + it('ignores duplicate links in one terminal chunk', () => { + const links = parseAttachmentMagicLinks( + [ + 'codeman://attach?path=/tmp/report.pdf', + 'codeman://attach?path=/tmp/report.pdf', + 'codeman://attach?path=/tmp/brief.docx', + ].join('\n') + ); + + expect(links).toEqual(['/tmp/report.pdf', '/tmp/brief.docx']); + }); + + it('accepts markdown and plain-text magic paths', () => { + const links = parseAttachmentMagicLinks( + ['codeman://attach?path=/tmp/notes.md', 'codeman://attach?path=/tmp/run.txt'].join('\n') + ); + + expect(links).toEqual(['/tmp/notes.md', '/tmp/run.txt']); + }); + + it('rejects relative or unsupported magic paths', () => { + const links = parseAttachmentMagicLinks( + [ + 'codeman://attach?path=relative.pdf', + 'codeman://attach?path=/tmp/archive.zip', + 'codeman://attach?path=/tmp/deck.pptx', + ].join('\n') + ); + + expect(links).toEqual(['/tmp/deck.pptx']); + }); + + it('emits attachmentRequested from raw terminal output', () => { + const session = new Session({ id: 'session-attach-test', workingDir: '/tmp', mode: 'codex' }); + const requested: string[] = []; + session.on('attachmentRequested', (event: { path: string }) => requested.push(event.path)); + + (session as unknown as { _handleTerminalOutput(data: string): void })._handleTerminalOutput( + 'codeman://attach?path=%2Ftmp%2Fdeck.pptx' + ); + + expect(requested).toEqual(['/tmp/deck.pptx']); + }); +}); diff --git a/test/image-watcher.test.ts b/test/image-watcher.test.ts index 19832091..c7e97955 100644 --- a/test/image-watcher.test.ts +++ b/test/image-watcher.test.ts @@ -145,9 +145,9 @@ describe('ImageWatcher', () => { // ========== Image Detection ========== describe('image detection', () => { - it('should emit image:detected for .png files', () => { + it('should emit attachment:detected for .png files', () => { const handler = vi.fn(); - watcher.on('image:detected', handler); + watcher.on('attachment:detected', handler); watcher.watchSession('session-1', '/home/user/project'); const chokidarWatcher = mockWatchers.get('/home/user/project')!; @@ -161,9 +161,42 @@ describe('ImageWatcher', () => { expect(event.fileName).toBe('screenshot.png'); expect(event.filePath).toBe('/home/user/project/screenshot.png'); expect(event.relativePath).toBe('screenshot.png'); + expect(event.extension).toBe('png'); + expect(event.attachmentType).toBe('image'); expect(event.size).toBe(2048); }); + it('should not emit legacy image:detected for .png attachment cards', () => { + const handler = vi.fn(); + watcher.on('image:detected', handler); + + watcher.watchSession('session-1', '/home/user/project'); + mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png'); + vi.advanceTimersByTime(300); + + expect(handler).not.toHaveBeenCalled(); + }); + + it.each([ + ['report.pdf', 'pdf'], + ['brief.docx', 'document'], + ['deck.pptx', 'presentation'], + ])('should emit attachment:detected for %s files', (fileName, attachmentType) => { + const handler = vi.fn(); + watcher.on('attachment:detected', handler); + + watcher.watchSession('session-1', '/home/user/project'); + mockWatchers.get('/home/user/project')!.emit('add', `/home/user/project/${fileName}`); + vi.advanceTimersByTime(300); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toMatchObject({ + sessionId: 'session-1', + fileName, + attachmentType, + }); + }); + it('should emit for .jpg files', () => { const handler = vi.fn(); watcher.on('image:detected', handler); @@ -250,10 +283,10 @@ describe('ImageWatcher', () => { watcher.on('image:detected', handler); watcher.watchSession('session-1', '/home/user/project'); - mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.png'); + mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.jpg'); vi.advanceTimersByTime(300); - expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.png'); + expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.jpg'); }); }); @@ -268,11 +301,11 @@ describe('ImageWatcher', () => { const chokidarWatcher = mockWatchers.get('/home/user/project')!; // Rapid adds of the same file - chokidarWatcher.emit('add', '/home/user/project/screenshot.png'); + chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg'); vi.advanceTimersByTime(100); // not yet past debounce - chokidarWatcher.emit('add', '/home/user/project/screenshot.png'); + chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg'); vi.advanceTimersByTime(100); - chokidarWatcher.emit('add', '/home/user/project/screenshot.png'); + chokidarWatcher.emit('add', '/home/user/project/screenshot.jpg'); vi.advanceTimersByTime(300); // now past debounce from last emit // Should only emit once (the last debounced one) @@ -286,8 +319,8 @@ describe('ImageWatcher', () => { watcher.watchSession('session-1', '/home/user/project'); const chokidarWatcher = mockWatchers.get('/home/user/project')!; - chokidarWatcher.emit('add', '/home/user/project/a.png'); - chokidarWatcher.emit('add', '/home/user/project/b.png'); + chokidarWatcher.emit('add', '/home/user/project/a.jpg'); + chokidarWatcher.emit('add', '/home/user/project/b.jpg'); vi.advanceTimersByTime(300); expect(handler).toHaveBeenCalledTimes(2); @@ -306,7 +339,7 @@ describe('ImageWatcher', () => { // Emit 25 unique images in quick succession for (let i = 0; i < 25; i++) { - chokidarWatcher.emit('add', `/home/user/project/img${i}.png`); + chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`); vi.advanceTimersByTime(250); // past debounce, within burst window } @@ -323,7 +356,7 @@ describe('ImageWatcher', () => { // Fill up burst limit for (let i = 0; i < 20; i++) { - chokidarWatcher.emit('add', `/home/user/project/img${i}.png`); + chokidarWatcher.emit('add', `/home/user/project/img${i}.jpg`); vi.advanceTimersByTime(250); } expect(handler).toHaveBeenCalledTimes(20); @@ -332,7 +365,7 @@ describe('ImageWatcher', () => { vi.advanceTimersByTime(11_000); // Should accept new images - chokidarWatcher.emit('add', '/home/user/project/new.png'); + chokidarWatcher.emit('add', '/home/user/project/new.jpg'); vi.advanceTimersByTime(300); expect(handler).toHaveBeenCalledTimes(21); diff --git a/test/routes/file-routes-attachment-path-guard.test.ts b/test/routes/file-routes-attachment-path-guard.test.ts new file mode 100644 index 00000000..258335b9 --- /dev/null +++ b/test/routes/file-routes-attachment-path-guard.test.ts @@ -0,0 +1,324 @@ +/** + * @fileoverview COD-53 — attachment path-traversal / sensitive-file guard. + * + * Verifies the sensitive-path blocklist is enforced at: + * - attachment registration (POST /api/sessions/:id/attachments) + * - raw / preview / thumbnail serving (defense-in-depth against a record that + * was crafted or registered before the guard existed) + * while still allowing legitimate cross-workspace attachment (codeman-publish + * skill + the ~/.codeman review-card loop) to succeed. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Readable } from 'node:stream'; +import { homedir } from 'node:os'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; + +// Mock fs/promises for file operations +vi.mock('node:fs/promises', () => ({ + default: { + readdir: vi.fn(async () => []), + readFile: vi.fn(async () => 'file content'), + writeFile: vi.fn(async () => undefined), + stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })), + mkdir: vi.fn(async () => undefined), + mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'), + rename: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), + }, +})); + +// Mock realpathSync for symlink resolution (identity by default) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpathSync: vi.fn((p: string) => p), + createReadStream: vi.fn(() => Readable.from([Buffer.from('file content')])), + }; +}); + +vi.mock('../../src/file-stream-manager.js', () => ({ + fileStreamManager: { + createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })), + closeStream: vi.fn(() => true), + }, +})); + +import fs from 'node:fs/promises'; +import { createReadStream, realpathSync } from 'node:fs'; +import { attachmentRegistry, type AttachmentRecord } from '../../src/attachment-registry.js'; + +const mockedStat = vi.mocked(fs.stat); +const mockedRealpathSync = vi.mocked(realpathSync); +const mockedCreateReadStream = vi.mocked(createReadStream); + +describe('file-routes attachment path guard (COD-53)', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerFileRoutes); + vi.clearAllMocks(); + attachmentRegistry.clearSession('test-session-1'); + mockedRealpathSync.mockImplementation((p: string) => p as never); + mockedStat.mockResolvedValue({ size: 100, isFile: () => true, mtimeMs: 1 } as never); + mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('file content')]) as never); + }); + + afterEach(async () => { + await harness.app.close(); + attachmentRegistry.clearSession(harness.ctx._sessionId); + // Reset attachment-guard env knobs so one test can't leak into the next. + delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS; + delete process.env.CODEMAN_ATTACHMENT_CONFINE; + }); + + // ===== BLOCK: registration rejects a sensitive path ===== + + it('rejects registering a .env file that carries a supported extension', async () => { + // A dotenv-style secret file named with a supported extension still leaks + // secrets; the blocklist's /\.env\./ pattern catches `.env.`. + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/home/someone/project/.env.txt' }, + }); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects registering an SSH key path even with a supported extension', async () => { + const sshTxt = `${homedir()}/.ssh/id_rsa.txt`; + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: sshTxt }, + }); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects registering a sensitive path that a symlink resolves to', async () => { + // The requested path looks innocent (.md) but realpath resolves it to an SSH key dir. + mockedRealpathSync.mockReturnValue(`${homedir()}/.ssh/known_hosts.md` as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/home/someone/project/innocent.md' }, + }); + + expect(res.statusCode).toBe(403); + }); + + // ===== BLOCK (defense-in-depth): raw serving rejects a sensitive record ===== + + it('refuses to serve raw bytes for a record whose path is sensitive', async () => { + // Simulate a record that was registered before the guard existed (or crafted). + const record: AttachmentRecord = { + attachmentId: 'att_sensitive', + sessionId: harness.ctx._sessionId, + filePath: `${homedir()}/.ssh/id_rsa.txt`, + fileName: 'id_rsa.txt', + extension: 'txt', + attachmentType: 'text', + size: 100, + mtimeMs: 1, + timestamp: Date.now(), + source: 'external', + }; + attachmentRegistry.register(record); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/att_sensitive/raw`, + }); + + expect(res.statusCode).toBe(403); + expect(mockedCreateReadStream).not.toHaveBeenCalled(); + }); + + // ===== PRESERVE: legitimate cross-workspace attachment still works ===== + + it('still registers a normal cross-workspace file (codeman-publish / loop review card)', async () => { + mockedStat.mockResolvedValue({ size: 512, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.fileName).toBe('jira-autoloop-questions.md'); + expect(body.data.extension).toBe('md'); + }); + + it('still registers an arbitrary project-dir file (WSL path)', async () => { + mockedStat.mockResolvedValue({ size: 4096, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/mnt/c/decks/board-update.pdf' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.fileName).toBe('board-update.pdf'); + }); + + it('still serves raw bytes for a legitimately registered cross-workspace file', async () => { + const content = Buffer.from('# notes'); + mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never); + + const registerRes = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: `${homedir()}/.codeman/review-card.md` }, + }); + const attachmentId = JSON.parse(registerRes.body).data.attachmentId; + + const rawRes = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`, + }); + + expect(rawRes.statusCode).toBe(200); + expect(rawRes.headers['content-type']).toBe('text/markdown'); + }); + + // ===== BLOCK (broadened defaults): /root and /etc trees ===== + + it('rejects registering a file anywhere under /root by default', async () => { + // /root is the root account home — blocked as a whole tree by default, + // even for an ordinary-looking note with a supported extension. + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/root/secret-notes.md' }, + }); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects registering a file anywhere under /etc by default', async () => { + // The whole /etc tree is blocked by default (not just /etc/shadow). + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/etc/codeman/config-dump.txt' }, + }); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('does not block a lookalike sibling dir like /etcetera (separator-aware)', async () => { + // The /etc tree block must be path-separator-aware so an unrelated + // /etcetera/... path is NOT caught by accident. + mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/etcetera/notes.md' }, + }); + + expect(res.statusCode).toBe(200); + }); + + // ===== CONFIG: extend the blocked set via env ===== + + it('rejects a path added via the extra-blocked-paths config', async () => { + process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private'; + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/srv/secrets/keys.pdf' }, + }); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('still allows a normal path NOT in the configured blocked set', async () => { + process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = '/srv/secrets,/data/private'; + mockedStat.mockResolvedValue({ size: 20, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/srv/public/report.pdf' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.fileName).toBe('report.pdf'); + }); + + // ===== CONFINEMENT MODE ON (opt-in) ===== + + it('confinement ON: rejects a file OUTSIDE the session workspace', async () => { + process.env.CODEMAN_ATTACHMENT_CONFINE = '1'; + // Mock session workspace is /tmp/test-workdir; this file resolves elsewhere. + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: '/home/someone/elsewhere/report.pdf' }, + }); + + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('confinement ON: allows a file INSIDE the session workspace', async () => { + process.env.CODEMAN_ATTACHMENT_CONFINE = '1'; + // Mock session workspace is /tmp/test-workdir (see MockSession). + const insidePath = '/tmp/test-workdir/docs/report.pdf'; + mockedRealpathSync.mockReturnValue(insidePath as never); + mockedStat.mockResolvedValue({ size: 30, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: insidePath }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.fileName).toBe('report.pdf'); + }); + + // ===== CONFINEMENT OFF (default) regression: legit cross-workspace attach ===== + + it('confinement OFF (default): legit cross-workspace attach still succeeds', async () => { + // No CODEMAN_ATTACHMENT_CONFINE set → default OFF. A ~/.codeman review-card + // file lives OUTSIDE the /tmp/test-workdir session workspace and must still + // attach (protects codeman-publish + the loop's review-card channel). + mockedStat.mockResolvedValue({ size: 64, isFile: () => true, mtimeMs: 5 } as never); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/attachments`, + payload: { path: `${homedir()}/.codeman/jira-autoloop-questions.md` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.fileName).toBe('jira-autoloop-questions.md'); + }); +});