feat(files): preview text files from outside the workspace, and stop routing them at a viewer that cannot read them

A .json/.log/.yaml/code path outside the session workspace was refused as an
unsupported type, and clicking one in the terminal made it worse: text goes to
the log viewer, which spawns `tail -f` and allows only the workspace, /var/log
and ~/logs, so it answered "Path must be within working directory or allowed
log directories" while the same path clicked in the response viewer previewed
fine. Two surfaces, two answers, for a file the session can already cat.

- TEXT_ATTACHMENT_EXTENSIONS IS EDITABLE_EXTENSIONS (config/file-editing.ts),
  not a second curated list that would drift from it. The rule reads: if the
  viewer would open a file for editing inside the workspace, the same file
  outside it can be read. The suffix was never the confidentiality gate here,
  the path guard is (sensitive-file blocklist, /root and /etc trees, realpath
  before the check), and it still runs on every registration.
- Widening what can be READ must not widen what can RUN. html/htm join svg in
  serveRawFile's download-only branch, so markup is never served with a
  renderable type on our own origin; other text goes out as inert
  text/plain; charset=utf-8 with nosniff, matching what the path picker does.
  The preview reads through fetch(), which ignores the disposition, so a
  clicked .html still shows its source.
- ~/.codeman*/state.json joins isSensitivePath. It persists
  SessionState.envOverrides and the env allowlist admits key-shaped names
  (GEMINI_API_KEY, CLAUDE_CODE_*), so it can hold a live credential. Same
  treatment as hook-secret and users.json, and the rest of the tree stays
  attachable.
- The terminal sends an out-of-workspace path to the preview instead of the log
  viewer. In-workspace text keeps the tail viewer, which is the point of it, and
  file-stream-manager's allowlist is untouched: no `tail -f` on arbitrary host
  paths.
- The by-id text preview is bounded like the workspace one: a Range request for
  the first 512KB (a real partial read, not a discarded 50MB download) plus a
  500-line cap, with the footer saying so.

Verified on an isolated instance: a 1.1MB external log opens in ~1.8s showing
500 lines with "showing first 500 lines" in the footer; json, yaml and code
preview; an .html carrying a script tag renders as source and does not execute;
.svg is still refused; a terminal click on an external .yaml opens the preview
with no log viewer and no attachment card; an in-workspace .log still opens the
streaming tail viewer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 18:12:00 +02:00
parent cbc54fc98d
commit da999b130e
9 changed files with 210 additions and 9 deletions
+22 -3
View File
@@ -15,6 +15,11 @@
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
// Bounds for the by-id text preview, mirroring what the workspace text preview
// already does server-side (500 lines). The byte cap rides a Range request, so
// a huge log is a partial read rather than a download the viewer throws away.
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
const TEXT_PREVIEW_MAX_LINES = 500;
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -3284,7 +3289,7 @@ Object.assign(CodemanApp.prototype, {
if (/unsupported/i.test(reason)) {
const ext = (filePath.split('.').pop() || '').toLowerCase();
return {
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents, Markdown and text only).`,
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
};
}
return { error: reason };
@@ -3363,10 +3368,24 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else {
try {
const res = await fetch(`${base}/raw`);
// Bounded like the workspace text preview: a Range for the first
// chunk (the route is range-aware, so this is a real partial read,
// not a 50MB download thrown away) and a line cap on top. An agent's
// log can be enormous, and rendering all of it into one <pre> is how
// you lock up the tab on the file you wanted to glance at.
const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text();
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
const lines = text.split('\n');
const clippedByLines = lines.length > TEXT_PREVIEW_MAX_LINES;
const shown = clippedByLines ? lines.slice(0, TEXT_PREVIEW_MAX_LINES).join('\n') : text;
bodyEl.innerHTML = `<pre><code>${escapeHtml(shown)}</code></pre>`;
this.filePreviewContent = shown;
if (clippedByLines || clippedByBytes) {
const note = clippedByLines ? `showing first ${TEXT_PREVIEW_MAX_LINES} lines` : 'showing the start of the file';
footerEl.textContent = `${footerEl.textContent} (${note})`;
}
} catch (err) {
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
}
+10 -1
View File
@@ -1457,7 +1457,16 @@ Object.assign(CodemanApp.prototype, {
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
if (previewsInFileViewer(text)) {
//
// Text goes to the log viewer, which follows a file that is still
// being written — but ONLY where it can actually read: it spawns
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
// out-of-workspace path there answered "Path must be within
// working directory or allowed log directories" while the SAME
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
self.openFilePreview(text, self.activeSessionId);
return;
}
+20 -2
View File
@@ -30,6 +30,7 @@ import {
buildFileThumbnailRoute,
isSupportedAttachmentExtension,
registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord,
} from '../../attachment-registry.js';
@@ -193,10 +194,16 @@ async function serveRawFile(
);
return;
}
if (download || extension === 'svg') {
// Markup is download-only: served with a renderable type on our own origin it
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
// family is servable, so widening what can be READ never widened what can RUN.
// The preview overlay reads these through `fetch()`, which ignores the
// disposition, so a clicked .html still shows its source.
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
if (download || markupOnly) {
reply.header(
'Content-Type',
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
markupOnly ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
@@ -204,6 +211,17 @@ async function serveRawFile(
return;
}
// Plain text with no dedicated MIME entry (code, config, logs, csv, xml) goes
// out as inert text/plain rather than the octet-stream fallback, matching what
// the path picker already does. Never a type the browser would execute.
if (!MIME_TYPES[extension] && TEXT_ATTACHMENT_EXTENSIONS.has(extension)) {
reply.header('Content-Type', 'text/plain; charset=utf-8');
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
return;
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('X-Content-Type-Options', 'nosniff');
+6
View File
@@ -80,6 +80,12 @@ const SENSITIVE_PATTERNS: RegExp[] = [
/\/\.claude\/\.credentials\.json$/,
/\/\.codeman[^/]*\/hook-secret$/,
/\/\.codeman[^/]*\/users\.json$/,
// Codeman's own state file. Named once `.json` became previewable outside the
// workspace: `SessionState.envOverrides` persists whatever the user set for a
// session, and the env allowlist admits key-shaped names (`GEMINI_API_KEY`,
// `CLAUDE_CODE_*`), so this file can hold a live credential. Same reasoning
// as the two entries above, and it leaves the rest of ~/.codeman attachable.
/\/\.codeman[^/]*\/state\.json$/,
];
/**