Skip to content

Commit 3cff98f

Browse files
committed
fix(security): scope the filesystem path picker per user in multi-user mode
Both picker endpoints are a second file-serving surface, and they inherited neither the attachment guard's confinement nor its ownership scoping. Two separate holes: 1. `sessionId` contributes that session's workingDir as a browse root, but it was resolved straight off ctx.sessions/ctx.store with no owner check, unlike the nine other session-scoped handlers in this file. A non-admin could pin ANOTHER user's working directory as a root just by passing their session id, then list and preview underneath it. Now runs canAccessOwned and reports 404, which also avoids confirming that a session id exists. 2. `Home` and `CASES_DIR` were unconditional roots for every caller. Per-user spaces live at <USER_SPACES_DIR>/<username>, which is INSIDE homedir(), so the Home root alone exposed every other user's workspace. A multi-user non-admin now gets only their own userSpacePath plus anything explicitly listed in CODEMAN_FILE_PICKER_ROOTS. /mnt/d is dropped as well: a broad host mount should be an explicit operator decision in a multi-user deployment, and operators who want it can name it in that env var. Admins and single-user mode keep the host-wide roots, so behavior is unchanged unless CODEMAN_MULTIUSER is on (opt-in, off by default). All three discriminating tests were verified to fail against the previous code: browse and preview both returned 200 instead of 404, and the roots came back as [Home, Codeman Cases, ...] instead of [My Space]. Full suite green, 3784 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bc232e5 commit 3cff98f

6 files changed

Lines changed: 183 additions & 19 deletions

File tree

.changeset/553c3037.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'aicodeman': patch
3+
---
4+
5+
Fix two multi-user scoping holes in the new filesystem path picker. `GET /api/filesystem/browse` and `GET /api/filesystem/preview` accept an optional `sessionId` that contributes the session's working directory as a browse root, but they resolved it straight off the session map without an ownership check, unlike the nine other session-scoped handlers in the same route file. A non-admin could therefore pin another user's working directory as a root simply by passing their session id, then list and preview files under it. Both endpoints now run `canAccessOwned` and report 404, which also avoids confirming that a session id exists.
6+
7+
Separately, `Home` and `CASES_DIR` were unconditional browse roots for every caller. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is inside `homedir()`, so the `Home` root alone exposed every other user's workspace to any authenticated user. In multi-user mode a non-admin now gets only their own space plus anything explicitly listed in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is no longer offered by default, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins keep the host-wide roots, and single-user mode is unchanged.
8+
9+
Both holes are regression-guarded in `test/routes/file-routes.test.ts`, verified to fail against the previous code. Multi-user mode is opt-in and off by default, so single-user installs were never affected.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
211211
212212
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
213213
214-
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and does not inherit the attachment confinement** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
214+
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
215215
216216
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
217217

docs/architecture-invariants.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
8080

8181
⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.
8282

83+
⚠️ **Ownership scoping is also not inherited, and both endpoints must do it themselves.** Two separate holes shipped in the original version and are now regression-guarded in `test/routes/file-routes.test.ts`:
84+
85+
1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check has to be written out by hand. Without it a multi-user caller pins **another user's** working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists.
86+
2. `Home` and `CASES_DIR` were unconditional roots. Per-user spaces live at `<USER_SPACES_DIR>/<username>`, which is **inside `homedir()`**, so a `Home` root alone let any authenticated user browse and preview every other user's workspace. In multi-user mode a non-admin now gets only `My Space` (their own `userSpacePath`) plus anything in `CODEMAN_FILE_PICKER_ROOTS`; `/mnt/d` is dropped too, since a broad host mount should be an explicit operator decision in a multi-user deployment. Admins and single-user mode keep the host-wide set unchanged.
87+
88+
The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`.
89+
8390
### Ultracode and workflow-run visualization
8491

8592
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.

src/web/routes/file-routes.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
3030
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
3131
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
3232
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
33+
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
3334
import {
3435
CASES_DIR,
3536
canAccessOwned,
@@ -323,33 +324,55 @@ function isBlockedPickerPath(path: string, blockedTrees: readonly string[], dire
323324
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
324325
}
325326

326-
function configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> {
327-
const candidates: Array<{ label: string; path: string }> = [
327+
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
328+
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
329+
if (!extraRoots) return [];
330+
return extraRoots
331+
.split(',')
332+
.map((value) => value.trim())
333+
.filter(Boolean)
334+
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
335+
}
336+
337+
/**
338+
* Browse roots for the requesting identity.
339+
*
340+
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
341+
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
342+
* which is *inside* `homedir()`, so handing out a `Home` root would let any
343+
* authenticated user browse and preview every other user's workspace. The
344+
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
345+
* that a multi-user deployment should not expose by default. Operators who
346+
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
347+
* which stays an explicit opt-in in both modes.
348+
*/
349+
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
350+
const user = getAuthUser(req);
351+
if (isMultiUserMode() && user.role !== 'admin') {
352+
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
353+
}
354+
return [
328355
{ label: 'Home', path: homedir() },
329356
{ label: 'Codeman Cases', path: CASES_DIR },
330357
{ label: 'WSL D:', path: '/mnt/d' },
358+
...extraConfiguredPickerRoots(),
331359
];
332-
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
333-
if (extraRoots) {
334-
for (const [index, path] of extraRoots
335-
.split(',')
336-
.map((value) => value.trim())
337-
.filter(Boolean)
338-
.entries()) {
339-
candidates.push({ label: `Configured ${index + 1}`, path });
340-
}
341-
}
342-
return candidates;
343360
}
344361

345362
async function resolveFilesystemPickerRoots(
346363
ctx: SessionPort & ConfigPort,
364+
req: FastifyRequest,
347365
sessionId?: string
348366
): Promise<FilesystemBrowseRoot[]> {
349-
const candidates = configuredFilesystemPickerRoots();
367+
const candidates = configuredFilesystemPickerRoots(req);
350368
if (sessionId) {
351369
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
352-
if (!session) {
370+
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
371+
// the other session-scoped handlers in this file. Without it a multi-user
372+
// caller could pin ANOTHER user's `workingDir` as a browse root just by
373+
// passing their sessionId. Report not-found rather than forbidden so the
374+
// endpoint does not confirm that a session id exists.
375+
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
353376
throw Object.assign(new Error(`Session ${sessionId} not found`), {
354377
statusCode: 404,
355378
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
@@ -394,10 +417,11 @@ function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, mess
394417

395418
async function resolveFilesystemPickerPath(
396419
ctx: SessionPort & ConfigPort,
420+
req: FastifyRequest,
397421
requestedPath: string | undefined,
398422
sessionId?: string
399423
): Promise<ResolvedFilesystemPickerPath> {
400-
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
424+
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
401425
if (roots.length === 0) {
402426
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
403427
}
@@ -545,6 +569,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
545569
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
546570
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
547571
ctx,
572+
req,
548573
requestedPath,
549574
sessionId
550575
);
@@ -665,6 +690,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
665690
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
666691
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
667692
ctx,
693+
req,
668694
requestedPath,
669695
sessionId
670696
);

test/routes/_route-test-utils.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,28 @@ export interface RouteTestHarness {
2020
* @param registerFn - The route registration function (e.g., registerSessionRoutes).
2121
* Uses `any` for ctx parameter because route functions expect typed port intersections
2222
* that MockRouteContext satisfies structurally but not nominally.
23-
* @param ctxOptions - Optional overrides for the mock context
23+
* @param ctxOptions - Optional overrides for the mock context. `authUser` stands
24+
* in for what the auth middleware would attach in multi-user mode; without it
25+
* `getAuthUser()` falls back to a synthetic admin, which passes every
26+
* ownership check and would make a scoping test pass vacuously.
2427
*/
2528
export async function createRouteTestHarness(
2629
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2730
registerFn: (app: FastifyInstance, ctx: any) => void,
28-
ctxOptions?: { sessionId?: string }
31+
ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } }
2932
): Promise<RouteTestHarness> {
3033
const app = Fastify({ logger: false });
3134

3235
// Register cookie plugin — some routes access req.cookies
3336
await app.register(fastifyCookie);
3437

38+
if (ctxOptions?.authUser) {
39+
const authUser = ctxOptions.authUser;
40+
app.addHook('onRequest', async (req) => {
41+
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
42+
});
43+
}
44+
3545
const ctx = createMockRouteContext(ctxOptions);
3646

3747
registerFn(app, ctx);

0 commit comments

Comments
 (0)