mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Merge pull request #119 from aakhter/pr/cod-37-attachments
Add server-side attachment pipeline (registry, magic-link, path guard) Review fixes (2767e80): force-confine the terminal magic-link scan path to the session workspace (closes a prompt-injectable arbitrary host-file read primitive that broadcast over SSE); keep PNG on the image-popup path (the attachment UI consumer is out of scope, so rerouting it broke the screenshot popup); serve the re-resolved path (TOCTOU); 50MB raw cap; per-session registry cap; CLI .env via dataPath(). Documented in security-architecture.md.
This commit is contained in:
@@ -320,7 +320,37 @@ injected from API JSON (`innerHTML`), not via `file-raw`, so they are unaffected
|
|||||||
`/api/download` additionally refuses a blocklist of sensitive paths
|
`/api/download` additionally refuses a blocklist of sensitive paths
|
||||||
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
|
(`/etc/shadow`, `~/.ssh/`, `.env`, `*credentials*`, `.aws/credentials`, …). This
|
||||||
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
|
is **defense‑in‑depth, not the primary boundary** — the realpath containment is
|
||||||
the control.
|
the control. The blocklist patterns are shared (`src/web/sensitive-path.ts`) with
|
||||||
|
the attachment guard below.
|
||||||
|
|
||||||
|
### External attachments (registry) & the magic‑link trust boundary
|
||||||
|
|
||||||
|
Live external attachments (`src/attachment-registry.ts`) mint an `att_<uuid>` id
|
||||||
|
for a host file so browser requests carry the id, never an absolute path. Serving
|
||||||
|
is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, 50 MB cap,
|
||||||
|
`nosniff`) and re‑resolves the symlink + re‑checks the **attachment guard**
|
||||||
|
(`src/config/attachment-guard.ts`: the shared sensitive‑path blocklist **plus**
|
||||||
|
the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` /
|
||||||
|
`CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file
|
||||||
|
routes, attachments are intentionally **cross‑workspace** — so the effective gate
|
||||||
|
is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not
|
||||||
|
realpath containment.
|
||||||
|
|
||||||
|
Two registration paths, with **different trust**:
|
||||||
|
|
||||||
|
- **Explicit `POST /api/sessions/:id/attachments`** (and `codeman attach`, which
|
||||||
|
POSTs directly inside a managed session) — a deliberate, Origin‑guarded HTTP
|
||||||
|
request. Allowed cross‑workspace (subject to the guard). This is the supported
|
||||||
|
path for codeman‑publish and the `~/.codeman` review‑card loop.
|
||||||
|
- **Terminal `codeman://attach?path=…` magic links** — scanned passively from
|
||||||
|
session output. Terminal output is **attacker‑influenceable** (a prompt‑injected
|
||||||
|
session can print an arbitrary path), and registration here is server‑side with
|
||||||
|
no Origin gate and broadcasts the `rawUrl` over SSE to all clients. This path is
|
||||||
|
therefore **force‑confined to the session workspace** (`forceWorkspaceConfinement`
|
||||||
|
in `registerExternalAttachment`, wired in `WebServer.registerAttachment`),
|
||||||
|
regardless of the global confine setting — a passive magic link cannot expose a
|
||||||
|
file outside the session's own workspace. Cross‑workspace attach must go through
|
||||||
|
the explicit POST path above.
|
||||||
|
|
||||||
### SSE log‑tail route — intentional extra read roots
|
### SSE log‑tail route — intentional extra read roots
|
||||||
|
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
|
||||||
|
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, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-session attachment cap. Bounds memory against a client (or a
|
||||||
|
* prompt-injected magic-link flood) registering unbounded distinct paths. */
|
||||||
|
const MAX_ATTACHMENTS_PER_SESSION = 200;
|
||||||
|
|
||||||
|
class AttachmentRegistry {
|
||||||
|
private recordsBySession = new Map<string, Map<string, AttachmentRecord>>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
// Evict oldest (insertion-order) entries beyond the cap.
|
||||||
|
while (records.size > MAX_ATTACHMENTS_PER_SESSION) {
|
||||||
|
const oldest = records.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
records.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 to enforce workspace
|
||||||
|
* confinement — either when the global mode is enabled
|
||||||
|
* (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) or when
|
||||||
|
* {@link forceWorkspaceConfinement} is set for this call.
|
||||||
|
*/
|
||||||
|
sessionWorkingDir?: string;
|
||||||
|
/**
|
||||||
|
* Force workspace confinement for THIS registration regardless of the global
|
||||||
|
* setting. Used by the terminal-output `codeman://attach` magic-link scanner:
|
||||||
|
* terminal output is attacker-influenceable (a prompt-injected session can
|
||||||
|
* print an arbitrary path), so passive magic links may only reference files
|
||||||
|
* inside the session workspace. Deliberate cross-workspace attachment still
|
||||||
|
* works through the explicit, Origin-guarded `POST /attachments` route and the
|
||||||
|
* `codeman attach` CLI (which POSTs directly when a session id is known).
|
||||||
|
*/
|
||||||
|
forceWorkspaceConfinement?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerExternalAttachment(
|
||||||
|
sessionId: string,
|
||||||
|
requestedPath: string,
|
||||||
|
options: RegisterExternalAttachmentOptions = {}
|
||||||
|
): Promise<AttachmentRegistrationResult> {
|
||||||
|
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 || options.forceWorkspaceConfinement) {
|
||||||
|
// Workspace-confined: the file MUST resolve inside the session's workspace.
|
||||||
|
// Applies when the global strict mode is on (opt-in, default OFF) OR when
|
||||||
|
// the caller forces it for this registration (the magic-link scanner — see
|
||||||
|
// forceWorkspaceConfinement). Strictly more restrictive than the blocklist.
|
||||||
|
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);
|
||||||
|
}
|
||||||
+93
@@ -10,11 +10,17 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { isAbsolute } from 'node:path';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
import { getSessionManager } from './session-manager.js';
|
import { getSessionManager } from './session-manager.js';
|
||||||
import { getTaskQueue } from './task-queue.js';
|
import { getTaskQueue } from './task-queue.js';
|
||||||
import { getRalphLoop } from './ralph-loop.js';
|
import { getRalphLoop } from './ralph-loop.js';
|
||||||
import { getStore } from './state-store.js';
|
import { getStore } from './state-store.js';
|
||||||
import { getErrorMessage } from './types.js';
|
import { getErrorMessage } from './types.js';
|
||||||
|
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const pkg = require('../package.json') as { version: string };
|
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);
|
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<string, string> {
|
||||||
|
const envPath = dataPath('.env');
|
||||||
|
try {
|
||||||
|
const text = readFileSync(envPath, 'utf-8');
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
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<boolean> {
|
||||||
|
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<string, string | number> = {
|
||||||
|
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 <path>')
|
||||||
|
.description('Show an attachment card for a local file')
|
||||||
|
.option('-s, --session <id>', 'Codeman session ID (defaults to CODEMAN_SESSION_ID)')
|
||||||
|
.option('--url <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 ============
|
// ============ Session Commands ============
|
||||||
|
|
||||||
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions');
|
||||||
|
|||||||
@@ -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<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));
|
||||||
|
}
|
||||||
+55
-8
@@ -12,7 +12,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { watch, type FSWatcher } from 'chokidar';
|
import { watch, type FSWatcher } from 'chokidar';
|
||||||
import { basename, extname, relative } from 'node:path';
|
import { basename, extname, relative } from 'node:path';
|
||||||
import { statSync } from 'node:fs';
|
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';
|
import { KeyedDebouncer } from './utils/index.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
@@ -20,7 +20,13 @@ import { KeyedDebouncer } from './utils/index.js';
|
|||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
/** Supported image file extensions (lowercase) */
|
/** Supported image file extensions (lowercase) */
|
||||||
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
// PNG stays on the image-popup path: it's the dominant screenshot format and the
|
||||||
|
// frontend only wires the `image:detected` popup today. The attachment-card UI
|
||||||
|
// that would consume `attachment:detected` for images is out of scope for this
|
||||||
|
// PR, so routing PNG to it would silently break the dropped-screenshot popup.
|
||||||
|
const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||||
|
const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']);
|
||||||
|
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
||||||
|
|
||||||
/** Time to wait for file writes to stabilize (ms) */
|
/** Time to wait for file writes to stabilize (ms) */
|
||||||
const STABILITY_THRESHOLD_MS = 500;
|
const STABILITY_THRESHOLD_MS = 500;
|
||||||
@@ -166,8 +172,8 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const ext = extname(path).toLowerCase();
|
const ext = extname(path).toLowerCase();
|
||||||
// Don't ignore directories (needed for watching to work)
|
// Don't ignore directories (needed for watching to work)
|
||||||
// Ignore files that aren't images
|
// Ignore files that aren't previewable images/documents
|
||||||
return ext !== '' && !IMAGE_EXTENSIONS.has(ext);
|
return ext !== '' && !DETECTED_FILE_EXTENSIONS.has(ext);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,15 +235,16 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a new file being detected.
|
* 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 {
|
private handleNewFile(sessionId: string, filePath: string): void {
|
||||||
const ext = extname(filePath).toLowerCase();
|
const ext = extname(filePath).toLowerCase();
|
||||||
|
|
||||||
// Double-check it's an image extension
|
// Double-check it's a supported extension
|
||||||
if (!IMAGE_EXTENSIONS.has(ext)) {
|
if (!DETECTED_FILE_EXTENSIONS.has(ext)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isAttachment = ATTACHMENT_EXTENSIONS.has(ext);
|
||||||
|
|
||||||
// Burst limit: skip if too many images detected for this session in a short window
|
// Burst limit: skip if too many images detected for this session in a short window
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -259,7 +266,11 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
||||||
this.fileDeb.schedule(filePath, () => {
|
this.fileDeb.schedule(filePath, () => {
|
||||||
this.fileToSession.delete(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)
|
// Increment burst count on actual emission (not on detection)
|
||||||
const b = this.burstTrackers.get(sessionId);
|
const b = this.burstTrackers.get(sessionId);
|
||||||
if (b) b.count++;
|
if (b) b.count++;
|
||||||
@@ -294,6 +305,42 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId);
|
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
|
// Export singleton instance for convenience
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
import { SessionAutoOps } from './session-auto-ops.js';
|
import { SessionAutoOps } from './session-auto-ops.js';
|
||||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||||
import { SessionTaskCache } from './session-task-cache.js';
|
import { SessionTaskCache } from './session-task-cache.js';
|
||||||
|
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||||
|
|
||||||
export type { BackgroundTask } from './task-tracker.js';
|
export type { BackgroundTask } from './task-tracker.js';
|
||||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||||
@@ -311,6 +312,9 @@ export class Session extends EventEmitter {
|
|||||||
private _parentAgentId: string | null = null;
|
private _parentAgentId: string | null = null;
|
||||||
private _childAgentIds: string[] = [];
|
private _childAgentIds: string[] = [];
|
||||||
|
|
||||||
|
// Bounded dedup set for terminal attachment magic-links already requested.
|
||||||
|
private _attachmentMagicSeen = new Set<string>();
|
||||||
|
|
||||||
// Nice prioritying configuration
|
// Nice prioritying configuration
|
||||||
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
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, '');
|
.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
|
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||||
this._terminalBuffer.append(data);
|
this._terminalBuffer.append(data);
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
|
|||||||
+37
-1
@@ -7,13 +7,14 @@
|
|||||||
* - ActiveBashTool — a live bash command with extracted file paths and status
|
* - ActiveBashTool — a live bash command with extracted file paths and status
|
||||||
* - ActiveBashToolStatus — 'running' | 'completed'
|
* - ActiveBashToolStatus — 'running' | 'completed'
|
||||||
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
|
* - ImageDetectedEvent — screenshot/image file detection trigger for UI popup
|
||||||
|
* - AttachmentDetectedEvent — document/image file detection trigger for attachment cards
|
||||||
*
|
*
|
||||||
* Cross-domain relationships:
|
* Cross-domain relationships:
|
||||||
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
|
* - ActiveBashTool.sessionId links to SessionState.id (session domain)
|
||||||
* - ImageDetectedEvent.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
|
* 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`).
|
* (`src/bash-tool-parser.ts`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -61,3 +62,38 @@ export interface ImageDetectedEvent {
|
|||||||
/** File size in bytes */
|
/** File size in bytes */
|
||||||
size: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ const SSE_EVENTS = {
|
|||||||
|
|
||||||
// Images
|
// Images
|
||||||
IMAGE_DETECTED: 'image:detected',
|
IMAGE_DETECTED: 'image:detected',
|
||||||
|
ATTACHMENT_DETECTED: 'attachment:detected',
|
||||||
|
|
||||||
// Tunnel
|
// Tunnel
|
||||||
TUNNEL_STARTED: 'tunnel:started',
|
TUNNEL_STARTED: 'tunnel:started',
|
||||||
|
|||||||
+206
-22
@@ -3,16 +3,163 @@
|
|||||||
* Provides directory listing, file content preview, raw file serving, and tail streaming.
|
* 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 { 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 fs from 'node:fs/promises';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.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 { 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<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
const stat = await fs.stat(resolvedPath);
|
||||||
|
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
||||||
|
if (stat.size > MAX_RAW_ATTACHMENT_SIZE) {
|
||||||
|
reply
|
||||||
|
.code(413)
|
||||||
|
.send(
|
||||||
|
createErrorResponse(
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 resolveServableAttachmentPath(
|
||||||
|
reply: FastifyReply,
|
||||||
|
record: AttachmentRecord,
|
||||||
|
sessionWorkingDir?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
let pathToCheck = record.filePath;
|
||||||
|
let resolved = false;
|
||||||
|
try {
|
||||||
|
pathToCheck = realpathSync(record.filePath);
|
||||||
|
resolved = true;
|
||||||
|
} 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 null;
|
||||||
|
}
|
||||||
|
// Serve the freshly-resolved path, not the stored one: if a path component
|
||||||
|
// became a symlink after registration, the guard checked the resolved target
|
||||||
|
// but streaming record.filePath would follow the symlink to a swapped file.
|
||||||
|
return resolved ? pathToCheck : record.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||||
// File tree listing
|
// File tree listing
|
||||||
app.get('/api/sessions/:id/files', async (req) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
@@ -315,6 +462,59 @@ 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;
|
||||||
|
const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir);
|
||||||
|
if (!servePath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serveRawFile(reply, servePath, 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)
|
// Stream file content via tail -f (SSE endpoint)
|
||||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
@@ -387,24 +587,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
|
|||||||
});
|
});
|
||||||
// Session-scoped file download.
|
// Session-scoped file download.
|
||||||
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
// Uses the same realpath-based workspace boundary as file preview/raw routes;
|
||||||
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
|
// the shared sensitive-path blocklist (../sensitive-path.js, also used by the
|
||||||
const SENSITIVE_PATTERNS: RegExp[] = [
|
// attachment guard) remains defense-in-depth, not the primary boundary.
|
||||||
/^\/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));
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/api/download', async (req, reply) => {
|
app.get('/api/download', async (req, reply) => {
|
||||||
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
type SubagentToolResult,
|
type SubagentToolResult,
|
||||||
} from '../subagent-watcher.js';
|
} from '../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../image-watcher.js';
|
import { imageWatcher } from '../image-watcher.js';
|
||||||
|
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
||||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
import { TeamWatcher } from '../team-watcher.js';
|
import { TeamWatcher } from '../team-watcher.js';
|
||||||
import { TunnelManager } from '../tunnel-manager.js';
|
import { TunnelManager } from '../tunnel-manager.js';
|
||||||
@@ -110,6 +111,7 @@ import {
|
|||||||
type PersistedRespawnConfig,
|
type PersistedRespawnConfig,
|
||||||
type NiceConfig,
|
type NiceConfig,
|
||||||
type ImageDetectedEvent,
|
type ImageDetectedEvent,
|
||||||
|
type AttachmentDetectedEvent,
|
||||||
DEFAULT_NICE_CONFIG,
|
DEFAULT_NICE_CONFIG,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import {
|
import {
|
||||||
@@ -248,6 +250,7 @@ export class WebServer extends EventEmitter {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
private imageWatcherHandlers: {
|
private imageWatcherHandlers: {
|
||||||
detected: (event: ImageDetectedEvent) => void;
|
detected: (event: ImageDetectedEvent) => void;
|
||||||
|
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
||||||
error: (error: Error, sessionId?: string) => void;
|
error: (error: Error, sessionId?: string) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private tunnelManager: TunnelManager = new TunnelManager();
|
private tunnelManager: TunnelManager = new TunnelManager();
|
||||||
@@ -436,12 +439,15 @@ export class WebServer extends EventEmitter {
|
|||||||
// Store handlers for cleanup on shutdown
|
// Store handlers for cleanup on shutdown
|
||||||
this.imageWatcherHandlers = {
|
this.imageWatcherHandlers = {
|
||||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
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) => {
|
error: (error: Error, sessionId?: string) => {
|
||||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
imageWatcher.on('image:detected', this.imageWatcherHandlers.detected);
|
||||||
|
imageWatcher.on('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
|
||||||
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
imageWatcher.on('image:error', this.imageWatcherHandlers.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +457,7 @@ export class WebServer extends EventEmitter {
|
|||||||
private cleanupImageWatcherListeners(): void {
|
private cleanupImageWatcherListeners(): void {
|
||||||
if (this.imageWatcherHandlers) {
|
if (this.imageWatcherHandlers) {
|
||||||
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
imageWatcher.off('image:detected', this.imageWatcherHandlers.detected);
|
||||||
|
imageWatcher.off('attachment:detected', this.imageWatcherHandlers.attachmentDetected);
|
||||||
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
imageWatcher.off('image:error', this.imageWatcherHandlers.error);
|
||||||
this.imageWatcherHandlers = null;
|
this.imageWatcherHandlers = null;
|
||||||
}
|
}
|
||||||
@@ -1067,6 +1074,8 @@ export class WebServer extends EventEmitter {
|
|||||||
session.removeAllListeners();
|
session.removeAllListeners();
|
||||||
// Close any active file streams for this session
|
// Close any active file streams for this session
|
||||||
fileStreamManager.closeSessionStreams(sessionId);
|
fileStreamManager.closeSessionStreams(sessionId);
|
||||||
|
// Drop live external attachment registrations for this session
|
||||||
|
attachmentRegistry.clearSession(sessionId);
|
||||||
// Stop watching for images in this session's directory
|
// Stop watching for images in this session's directory
|
||||||
imageWatcher.unwatchSession(sessionId);
|
imageWatcher.unwatchSession(sessionId);
|
||||||
// Clean up pasted images directory for this session
|
// Clean up pasted images directory for this session
|
||||||
@@ -1252,9 +1261,32 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getStore: () => this.store,
|
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). Because terminal output is
|
||||||
|
* attacker-influenceable (a prompt-injected session can print an arbitrary
|
||||||
|
* `codeman://attach?path=` link), the scanned path is FORCE-confined to the
|
||||||
|
* session workspace — passive magic links can't expose arbitrary host files.
|
||||||
|
* Deliberate cross-workspace attachment goes through the explicit,
|
||||||
|
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
|
||||||
|
* directly inside a managed session). Registration also enforces the COD-53
|
||||||
|
* blocklist as defense-in-depth.
|
||||||
|
*/
|
||||||
|
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const event = await registerExternalAttachment(sessionId, filePath, {
|
||||||
|
sessionWorkingDir: session.workingDir,
|
||||||
|
forceWorkspaceConfinement: true,
|
||||||
|
});
|
||||||
|
this.broadcast(SseEvent.AttachmentDetected, event);
|
||||||
|
}
|
||||||
|
|
||||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||||
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
|
wireRespawnListeners(sessionId, controller, this.buildRespawnWiringDeps());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface SessionListenerRefs {
|
|||||||
bashToolStart: (tool: ActiveBashTool) => void;
|
bashToolStart: (tool: ActiveBashTool) => void;
|
||||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||||
|
attachmentRequested: (event: { path: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||||
@@ -77,10 +78,11 @@ interface SessionListenerDeps {
|
|||||||
removeSessionListenerRefs(sessionId: string): void;
|
removeSessionListenerRefs(sessionId: string): void;
|
||||||
cleanupRespawnOnExit(sessionId: string): void;
|
cleanupRespawnOnExit(sessionId: string): void;
|
||||||
getStore(): import('../state-store.js').StateStore;
|
getStore(): import('../state-store.js').StateStore;
|
||||||
|
registerAttachment(sessionId: string, filePath: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* Call `attachSessionListeners()` after to wire them to the session.
|
||||||
*/
|
*/
|
||||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||||
@@ -355,6 +357,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
bashToolsUpdate: (tools: ActiveBashTool[]) => {
|
||||||
deps.broadcast(SseEvent.SessionBashToolsUpdate, { sessionId: session.id, tools });
|
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('bashToolStart', refs.bashToolStart);
|
||||||
session.on('bashToolEnd', refs.bashToolEnd);
|
session.on('bashToolEnd', refs.bashToolEnd);
|
||||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
|
session.on('attachmentRequested', refs.attachmentRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
/** 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('bashToolStart', refs.bashToolStart);
|
||||||
session.off('bashToolEnd', refs.bashToolEnd);
|
session.off('bashToolEnd', refs.bashToolEnd);
|
||||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
|
session.off('attachmentRequested', refs.attachmentRequested);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ export const TunnelQrAuthUsed = 'tunnel:qrAuthUsed' as const;
|
|||||||
|
|
||||||
/** New image file detected (e.g. screenshot upload). */
|
/** New image file detected (e.g. screenshot upload). */
|
||||||
export const ImageDetected = 'image:detected' as const;
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -479,6 +481,7 @@ export const SseEvent = {
|
|||||||
|
|
||||||
// Image
|
// Image
|
||||||
ImageDetected,
|
ImageDetected,
|
||||||
|
AttachmentDetected,
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
HookIdlePrompt,
|
HookIdlePrompt,
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+42
-12
@@ -145,7 +145,7 @@ describe('ImageWatcher', () => {
|
|||||||
// ========== Image Detection ==========
|
// ========== Image Detection ==========
|
||||||
|
|
||||||
describe('image detection', () => {
|
describe('image detection', () => {
|
||||||
it('should emit image:detected for .png files', () => {
|
it('should emit image:detected (popup) for .png files', () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
watcher.on('image:detected', handler);
|
watcher.on('image:detected', handler);
|
||||||
|
|
||||||
@@ -161,7 +161,37 @@ describe('ImageWatcher', () => {
|
|||||||
expect(event.fileName).toBe('screenshot.png');
|
expect(event.fileName).toBe('screenshot.png');
|
||||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||||
expect(event.relativePath).toBe('screenshot.png');
|
expect(event.relativePath).toBe('screenshot.png');
|
||||||
expect(event.size).toBe(2048);
|
});
|
||||||
|
|
||||||
|
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
|
||||||
|
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/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', () => {
|
it('should emit for .jpg files', () => {
|
||||||
@@ -250,10 +280,10 @@ describe('ImageWatcher', () => {
|
|||||||
watcher.on('image:detected', handler);
|
watcher.on('image:detected', handler);
|
||||||
|
|
||||||
watcher.watchSession('session-1', '/home/user/project');
|
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);
|
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 +298,11 @@ describe('ImageWatcher', () => {
|
|||||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||||
|
|
||||||
// Rapid adds of the same file
|
// 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
|
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);
|
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
|
vi.advanceTimersByTime(300); // now past debounce from last emit
|
||||||
|
|
||||||
// Should only emit once (the last debounced one)
|
// Should only emit once (the last debounced one)
|
||||||
@@ -286,8 +316,8 @@ describe('ImageWatcher', () => {
|
|||||||
watcher.watchSession('session-1', '/home/user/project');
|
watcher.watchSession('session-1', '/home/user/project');
|
||||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||||
|
|
||||||
chokidarWatcher.emit('add', '/home/user/project/a.png');
|
chokidarWatcher.emit('add', '/home/user/project/a.jpg');
|
||||||
chokidarWatcher.emit('add', '/home/user/project/b.png');
|
chokidarWatcher.emit('add', '/home/user/project/b.jpg');
|
||||||
vi.advanceTimersByTime(300);
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(2);
|
expect(handler).toHaveBeenCalledTimes(2);
|
||||||
@@ -306,7 +336,7 @@ describe('ImageWatcher', () => {
|
|||||||
|
|
||||||
// Emit 25 unique images in quick succession
|
// Emit 25 unique images in quick succession
|
||||||
for (let i = 0; i < 25; i++) {
|
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
|
vi.advanceTimersByTime(250); // past debounce, within burst window
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +353,7 @@ describe('ImageWatcher', () => {
|
|||||||
|
|
||||||
// Fill up burst limit
|
// Fill up burst limit
|
||||||
for (let i = 0; i < 20; i++) {
|
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);
|
vi.advanceTimersByTime(250);
|
||||||
}
|
}
|
||||||
expect(handler).toHaveBeenCalledTimes(20);
|
expect(handler).toHaveBeenCalledTimes(20);
|
||||||
@@ -332,7 +362,7 @@ describe('ImageWatcher', () => {
|
|||||||
vi.advanceTimersByTime(11_000);
|
vi.advanceTimersByTime(11_000);
|
||||||
|
|
||||||
// Should accept new images
|
// Should accept new images
|
||||||
chokidarWatcher.emit('add', '/home/user/project/new.png');
|
chokidarWatcher.emit('add', '/home/user/project/new.jpg');
|
||||||
vi.advanceTimersByTime(300);
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledTimes(21);
|
expect(handler).toHaveBeenCalledTimes(21);
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* @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<typeof import('node:fs')>();
|
||||||
|
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,
|
||||||
|
registerExternalAttachment,
|
||||||
|
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.<ext>`.
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Magic-link scan path: FORCED workspace confinement =====
|
||||||
|
// The terminal-output `codeman://attach` scanner registers with
|
||||||
|
// forceWorkspaceConfinement: true so a prompt-injected session printing an
|
||||||
|
// arbitrary path can't expose a host file, even though global confine is OFF.
|
||||||
|
describe('forced workspace confinement (magic-link scan path)', () => {
|
||||||
|
it('rejects an out-of-workspace path even when global confinement is OFF', async () => {
|
||||||
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
|
||||||
|
await expect(
|
||||||
|
registerExternalAttachment('test-session-mlc', '/home/someone/secret/report.pdf', {
|
||||||
|
sessionWorkingDir: '/tmp/test-workdir',
|
||||||
|
forceWorkspaceConfinement: true,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
attachmentRegistry.clearSession('test-session-mlc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an in-workspace path on the forced path', async () => {
|
||||||
|
const inside = '/tmp/test-workdir/sub/report.pdf';
|
||||||
|
mockedRealpathSync.mockReturnValue(inside as never);
|
||||||
|
mockedStat.mockResolvedValue({ size: 10, isFile: () => true, mtimeMs: 1 } as never);
|
||||||
|
const event = await registerExternalAttachment('test-session-mlc', inside, {
|
||||||
|
sessionWorkingDir: '/tmp/test-workdir',
|
||||||
|
forceWorkspaceConfinement: true,
|
||||||
|
});
|
||||||
|
expect(event.fileName).toBe('report.pdf');
|
||||||
|
attachmentRegistry.clearSession('test-session-mlc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user