mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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>
606 lines
25 KiB
TypeScript
606 lines
25 KiB
TypeScript
/**
|
|
* @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';
|
|
import { SseEvent } from '../../src/web/sse-events.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');
|
|
});
|
|
});
|
|
|
|
// ===== Media (click-to-preview parity with the workspace preview) =====
|
|
// A video an agent writes inside the workspace plays with a working scrub
|
|
// bar; the same file in /tmp used to be refused as an unsupported type. Both
|
|
// now go through the same extension sets, and the raw route has to answer
|
|
// with a real media Content-Type and a range, or the player renders and then
|
|
// does nothing.
|
|
describe('media attachments', () => {
|
|
it('registers a video and serves it as seekable video/mp4', async () => {
|
|
const content = Buffer.from('MP4DATA-0123456789');
|
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
|
mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(4, 10)]) as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/tmp/captures/demo.mp4', notify: false },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.data.attachmentType).toBe('video');
|
|
|
|
const rawRes = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
|
|
headers: { range: 'bytes=4-9' },
|
|
});
|
|
expect(rawRes.statusCode).toBe(206);
|
|
expect(rawRes.headers['content-type']).toBe('video/mp4');
|
|
expect(rawRes.headers['content-range']).toBe(`bytes 4-9/${content.length}`);
|
|
expect(rawRes.headers['accept-ranges']).toBe('bytes');
|
|
});
|
|
|
|
it('registers audio with an audio type and its real MIME', async () => {
|
|
const content = Buffer.from('ID3AUDIO');
|
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/tmp/captures/take.mp3', notify: false },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.data.attachmentType).toBe('audio');
|
|
|
|
const rawRes = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
|
|
});
|
|
expect(rawRes.statusCode).toBe(200);
|
|
expect(rawRes.headers['content-type']).toBe('audio/mpeg');
|
|
});
|
|
|
|
it('answers no thumbnail for media instead of spawning a converter', async () => {
|
|
// generateFirstPageThumbnail has no media branch; the card falls back to
|
|
// its type label. This pins that the route reports that cleanly.
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/tmp/captures/clip.webm', notify: false },
|
|
});
|
|
const { attachmentId } = JSON.parse(res.body).data;
|
|
|
|
const thumbRes = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/thumbnail`,
|
|
});
|
|
expect(thumbRes.statusCode).toBe(204);
|
|
});
|
|
|
|
it('still refuses media in a blocked tree', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/root/private/recording.mp4', notify: false },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ===== Text family (code, config and logs outside the workspace) =====
|
|
// The agent in the session can already `cat` these, so refusing the click
|
|
// bought no confidentiality. The gate that matters is the path guard, which
|
|
// still runs, and markup must not become executable just because it is now
|
|
// readable.
|
|
describe('text attachments', () => {
|
|
it.each([
|
|
['/tmp/run.log', 'log'],
|
|
['/tmp/data.json', 'json'],
|
|
['/tmp/conf/app.yaml', 'yaml'],
|
|
['/tmp/src/index.ts', 'ts'],
|
|
['/tmp/export.csv', 'csv'],
|
|
])('registers %s as a text attachment', async (path, extension) => {
|
|
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path, notify: false },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.data.extension).toBe(extension);
|
|
expect(body.data.attachmentType).toBe('text');
|
|
});
|
|
|
|
it('serves a text file with no dedicated MIME as inert text/plain', async () => {
|
|
const content = Buffer.from('boot ok\nstarted\n');
|
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
|
|
|
const reg = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/tmp/run.log', notify: false },
|
|
});
|
|
const rawRes = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
|
|
});
|
|
|
|
expect(rawRes.statusCode).toBe(200);
|
|
expect(rawRes.headers['content-type']).toBe('text/plain; charset=utf-8');
|
|
expect(rawRes.headers['x-content-type-options']).toBe('nosniff');
|
|
});
|
|
|
|
it('keeps HTML download-only so readable never means executable', async () => {
|
|
// Serving markup with a renderable type on our own origin is stored XSS.
|
|
// The preview reads it through fetch(), which ignores the disposition, so
|
|
// a clicked .html still shows its source.
|
|
const content = Buffer.from('<script>alert(1)</script>');
|
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
|
|
|
const reg = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/tmp/report.html', notify: false },
|
|
});
|
|
const rawRes = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
|
|
});
|
|
|
|
expect(rawRes.headers['content-type']).toBe('application/octet-stream');
|
|
expect(String(rawRes.headers['content-disposition'])).toContain('attachment');
|
|
});
|
|
|
|
it('answers a byte range for text so a huge log is a partial read', async () => {
|
|
const content = Buffer.from('0123456789abcdef');
|
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
|
mockedCreateReadStream.mockReturnValue(Readable.from([content.subarray(0, 8)]) as never);
|
|
|
|
const reg = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/tmp/big.log', notify: false },
|
|
});
|
|
const rawRes = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${JSON.parse(reg.body).data.attachmentId}/raw`,
|
|
headers: { range: 'bytes=0-7' },
|
|
});
|
|
|
|
expect(rawRes.statusCode).toBe(206);
|
|
expect(rawRes.headers['content-range']).toBe(`bytes 0-7/${content.length}`);
|
|
});
|
|
|
|
it.each([
|
|
['/home/someone/.config/gh/hosts.yml', 'forge token'],
|
|
['/home/someone/project/.env.json', 'dotenv'],
|
|
['/home/someone/.codeman/state.json', 'codeman state (can hold envOverrides secrets)'],
|
|
['/home/someone/deploy/credentials.yaml', 'generic credentials'],
|
|
['/etc/codeman/dump.log', 'blocked tree'],
|
|
])('still refuses %s (%s) now that text is servable', async (path) => {
|
|
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path, notify: false },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it('still refuses a type outside the family', async () => {
|
|
mockedStat.mockResolvedValue({ size: 40, isFile: () => true, mtimeMs: 5 } as never);
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: '/tmp/drawing.svg', notify: false },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
expect(JSON.parse(res.body).error).toMatch(/unsupported/i);
|
|
});
|
|
});
|
|
|
|
// ===== Quiet registration (click-to-preview) =====
|
|
// The file-preview overlay registers a clicked out-of-workspace path to mint
|
|
// an id it can render by. It is already putting the file on screen, so the
|
|
// usual attachment card + unread badge would announce what the user is
|
|
// looking at. `notify: false` suppresses ONLY the broadcast — the guard, the
|
|
// registry entry and the by-id routes are identical either way.
|
|
describe('quiet registration', () => {
|
|
const outside = '/tmp/claude-1000/scratchpad/probe-run-native.png';
|
|
|
|
it('broadcasts by default, so the CLI and publish paths keep their card', async () => {
|
|
mockedStat.mockResolvedValue({ size: 128, isFile: () => true, mtimeMs: 5 } as never);
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: outside },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith(SseEvent.AttachmentDetected, expect.anything());
|
|
});
|
|
|
|
it('registers and serves a clicked path without broadcasting when notify is false', async () => {
|
|
const content = Buffer.from('PNGDATA');
|
|
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true, mtimeMs: 5 } as never);
|
|
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
|
|
payload: { path: outside, notify: false },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.data.fileName).toBe('probe-run-native.png');
|
|
expect(harness.ctx.broadcast).not.toHaveBeenCalled();
|
|
|
|
// The preview renders from this route, so the id has to be live.
|
|
const rawRes = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/attachments/${body.data.attachmentId}/raw`,
|
|
});
|
|
expect(rawRes.statusCode).toBe(200);
|
|
expect(rawRes.headers['content-type']).toBe('image/png');
|
|
});
|
|
});
|
|
});
|