Merge #780 feat(files): follow the agent into a worktree outside the session folder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-08 04:05:56 +01:00
commit 2f9cc7aabc
28 files changed
+1781 -133

No files matched your search

+23 -1
View File
@@ -27,7 +27,7 @@
* @module config/attachment-guard
*/
import { sep } from 'node:path';
import { join, sep } from 'node:path';
import { isSensitivePath } from '../web/sensitive-path.js';
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
@@ -131,3 +131,25 @@ export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly
if (isSensitivePath(absPath)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}
/**
* Like `isBlockedAttachmentPath`, for a directory. The sensitive patterns describe file locations
* such as ~/.ssh/<key>, so a child probe is what refuses a folder like ~/.ssh itself.
*/
export function isBlockedAttachmentDirectory(dir: string, blockedTrees: readonly string[]): boolean {
return (
isBlockedAttachmentPath(dir, blockedTrees) ||
isBlockedAttachmentPath(join(dir, '__codeman_blocked_dir_probe__'), blockedTrees)
);
}
/**
* True when the attachment guard refuses `root` as the root of a worktree outside the session
* folder: workspace confinement is on, the folder is blocked or sensitive, or it surrounds a
* blocked tree. `root` must be absolute and symlink-resolved.
*/
export function isOutsideWorktreeRootRefused(root: string, guard: AttachmentGuardConfig): boolean {
if (guard.confineToWorkspace) return true;
if (isBlockedAttachmentDirectory(root, guard.blockedTrees)) return true;
return guard.blockedTrees.some((tree) => isUnderTree(tree, root));
}
+220 -31
View File
@@ -13,17 +13,27 @@
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
* changes, and a file read starts no process, no repository config and no filter.
*
* Only a folder strictly below `workingDir` is ever an answer: the file routes already serve
* A folder strictly below `workingDir` is the plain answer: the file routes already serve
* everything under it, so following a pane there widens nothing, and a planted `.git` file can
* only move where the listing STARTS inside that same folder. A worktree outside it
* (`git worktree add ../feature`) stays unfollowed, since listing it would need a second
* confinement root.
* only move where the listing STARTS inside that same folder.
*
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep
* judging every path against `workingDir`.
* A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree that a
* `WorktreeCreate` hook places beside the repository) is a second confinement root, so it is
* answered only when the caller passes {@link OutsideWorktreeOptions}, and only when the session's
* own repository vouches for it ({@link findOutsideWorktree}). The answer then carries
* `outside: true`, and the file routes serve absolute paths inside it.
*
* For a worktree inside `workingDir` the answer is lexical and only as fresh as the last time the
* pane moved: a symlink can take the folder's place afterwards without moving the pane. So
* nothing there is a confinement decision. `GET /api/sessions/:id/files` resolves the folder on
* every request and walks it only while it really lies inside `workingDir`
* (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep judging every path against
* `workingDir`.
*
* For an outside worktree {@link findOutsideWorktree} IS the confinement decision for the second
* root. Its vouching, home, Docker and `allowRoot` rules run only when the pane moves. The routes
* re-check on each request only that the folder still realpaths to the recorded path
* (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`).
*
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
* `unknown` answer stops the walk rather than being read as "no `.git` here".
@@ -32,15 +42,44 @@
*/
import { constants, promises as fs } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
/** A linked worktree a session's pane is in. */
export interface LiveWorktree {
/** The worktree folder's name, for display. */
name: string;
/** Absolute path, spelled like the session's `workingDir`. */
/**
* Absolute path. Spelled like the session's `workingDir` for a worktree inside it; the real
* path (no symlink in any component) for one outside it.
*/
path: string;
/**
* The worktree lies outside `workingDir`, so it is a second root the file routes serve
* ABSOLUTE paths from. Absent for a worktree inside `workingDir`.
*/
outside?: true;
}
/** What {@link findLiveWorktree} needs to answer a worktree outside `workingDir`. */
export interface OutsideWorktreeOptions {
/** Overrides `os.homedir()` (tests). */
home?: string;
/** Host paths of every Docker case workspace: a container can write there, so none is followed. */
dockerWorkspaces?: () => Promise<string[]>;
/**
* The caller's last word on which worktree may be followed. The server passes its attachment
* guard rules (workspace confinement, blocked trees, sensitive folders) in every mode, plus the
* owner's folder rule (`folderAllowedForUser`) in multi-user mode, so a regular user's session
* cannot list another user's checkout. A rejection counts as a refusal.
*/
allowRoot?: (root: string) => boolean | Promise<boolean>;
}
/** The text after `gitdir:` in a `.git` file, as written (possibly relative). */
function rawGitDir(text: string): string | undefined {
return /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
}
/** More than any real `.git` file. It is read only to recognise the first line. */
@@ -52,12 +91,33 @@ const MAX_DOT_GIT_FILE_BYTES = 4096;
* The path may be relative: `git worktree add --relative-paths` writes it that way.
*/
export function isLinkedWorktreeGitFile(text: string): boolean {
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
const gitDir = rawGitDir(text);
if (!gitDir) return false;
const segments = gitDir.split('/').filter(Boolean);
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
}
/**
* The text of a small file, opened so that nothing blocks or escapes. The probe a caller ran
* follows symlinks and the file can change after it, so the open itself refuses: O_NOFOLLOW a
* symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and hold a threadpool
* worker for good. Whatever it did open is then judged on the handle. Null for anything but a
* regular file it could read.
*/
async function readOpenedSmallFile(path: string): Promise<string | null> {
let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return null;
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return buffer.toString('utf8', 0, bytesRead);
} catch {
return null;
} finally {
await handle?.close().catch(() => {});
}
}
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
const dotGit = join(dir, '.git');
@@ -65,32 +125,28 @@ async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'
if (kind === 'absent') return 'none';
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
if (kind !== 'file') return 'stop';
let handle: fs.FileHandle | undefined;
try {
// The probe follows symlinks and the file can change after it, so the open itself refuses:
// O_NOFOLLOW a symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and
// hold a threadpool worker for good. Whatever it did open is then judged on the handle.
handle = await fs.open(dotGit, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return 'stop';
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
} catch {
return 'stop';
} finally {
await handle?.close().catch(() => {});
}
// `null` is a symlink, a FIFO, a non-regular file or an unreadable one: all stop the walk.
const text = await readOpenedSmallFile(dotGit);
return text !== null && isLinkedWorktreeGitFile(text) ? 'worktree' : 'stop';
}
/**
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
* read. `workingDir` itself is never an answer: a session started in a worktree is already
* listing it. Never throws.
* The linked worktree that holds `cwd`, if any. Walks up from `cwd` and stops at `workingDir`,
* at a repository or submodule of its own, or at anything it cannot read. `workingDir` itself is
* never an answer: a session started in a worktree is already listing it. When `cwd` lies outside
* `workingDir` and `outside` is given, {@link findOutsideWorktree} answers instead. Never throws.
*/
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
export async function findLiveWorktree(
workingDir: string,
cwd: string | undefined,
outside?: OutsideWorktreeOptions
): Promise<LiveWorktree | undefined> {
if (!cwd?.startsWith('/')) return undefined;
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
const under = root === '/' ? '/' : `${root}/`;
if (cwd !== root && !cwd.startsWith(under)) {
return outside ? findOutsideWorktree(root, cwd, outside).catch(() => undefined) : undefined;
}
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
const found = await classifyDotGit(dir);
if (found === 'worktree') return { name: basename(dir), path: dir };
@@ -98,3 +154,136 @@ export async function findLiveWorktree(workingDir: string, cwd: string | undefin
}
return undefined;
}
/** True when `child` is `parent` or lies below it. Both must already be real paths. */
const isSameOrUnder = (child: string, parent: string): boolean =>
child === parent || child.startsWith(parent === sep ? parent : parent + sep);
/** The real path of `p` once a bounded probe has answered for it, else null. */
async function realPathBounded(p: string): Promise<string | null> {
const kind = await probePathKind(p);
if (kind !== 'directory' && kind !== 'file') return null;
try {
return await fs.realpath(p);
} catch {
return null;
}
}
/** The text of a small regular file that a bounded probe found, via {@link readOpenedSmallFile}. */
async function readSmallFile(path: string): Promise<string | null> {
return (await probePathKind(path)) === 'file' ? readOpenedSmallFile(path) : null;
}
/** The `gitdir:` value of a `.git` file's text, resolved against the folder that holds it. */
function gitDirOf(text: string, dir: string): string | null {
const raw = rawGitDir(text);
return raw ? resolve(dir, raw) : null;
}
/**
* The real path of the git common dir of the repository whose top level is `workingDir`: its
* `.git` folder, or, for a linked worktree, the folder its `commondir` file names. Null when
* `workingDir` is not the top level of a repository or worktree: a session in a subfolder
* follows no worktree outside it.
*/
async function sessionCommonDir(workingDir: string): Promise<string | null> {
const dotGit = join(workingDir, '.git');
const kind = await probePathKind(dotGit);
if (kind === 'directory') return realPathBounded(dotGit);
if (kind !== 'file') return null;
const text = await readSmallFile(dotGit);
const gitDir = text ? gitDirOf(text, workingDir) : null;
if (!gitDir) return null;
// A linked worktree's git dir names the common dir in `commondir`; a submodule's git dir
// has none and is its own common dir.
const commonDir = await readSmallFile(join(gitDir, 'commondir'));
return realPathBounded(commonDir ? resolve(gitDir, commonDir.trim()) : gitDir);
}
/**
* The worktree outside `workingDir` that holds `cwd`, when the session's own repository vouches
* for it. `cwd` comes from the pane, which a prompt-injected agent controls, so every rule below
* keeps it from widening what the file routes serve beyond a worktree of the session's repository:
*
* - The walk up from `cwd` stops at the first `.git`. That folder is a candidate only when its
* `.git` FILE names `<common dir>/worktrees/<id>` of the session's repository AND that
* entry's own `gitdir` file names the candidate back. A `.git` FOLDER is a candidate only when
* it IS the session's common dir (the main worktree, for a session started in a linked one).
* These are the files `git worktree list` reads, read here without starting git: this runs
* from the pane watcher on every directory change.
* - `/`, the home folder and anything above it, and any folder holding `workingDir` are
* refused, as is a folder at, inside or around a Docker case workspace.
* - `allowRoot`, when given, has the last word (the server's attachment-guard and multi-user rules).
*
* Never rejects: an `allowRoot` that rejects refuses the worktree.
*
* Every path is compared as a real path, and the answer is the real path. The file routes check
* on each request that the folder still resolves to itself, so a symlink swapped in later is
* refused there. A forged registry entry needs write access to the session's own `.git`, which
* an agent running as this user already has, along with read access to everything it could list.
*/
export async function findOutsideWorktree(
workingDir: string,
cwd: string,
opts: OutsideWorktreeOptions
): Promise<LiveWorktree | undefined> {
// The nearest folder at or above `cwd` that holds a `.git`, and what that `.git` is.
let dir = cwd;
let kind: 'directory' | 'file';
for (;;) {
const probed = await probePathKind(join(dir, '.git'));
if (probed === 'unknown') return undefined;
if (probed === 'directory' || probed === 'file') {
kind = probed;
break;
}
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
const commonDir = await sessionCommonDir(workingDir);
if (!commonDir) return undefined;
const real = await realPathBounded(dir);
if (!real) return undefined;
if (kind === 'directory') {
// The main worktree: its `.git` folder is the session's common dir itself. The folder must
// really hold it: a `.git` symlink resolves elsewhere, so it fails this comparison.
if (join(real, '.git') !== commonDir) return undefined;
} else {
const text = await readSmallFile(join(dir, '.git'));
const gitDir = text && isLinkedWorktreeGitFile(text) ? gitDirOf(text, dir) : null;
const realGitDir = gitDir ? await realPathBounded(gitDir) : null;
if (!realGitDir || dirname(realGitDir) !== join(commonDir, 'worktrees')) return undefined;
// The registry entry must name this folder back, as `git worktree list` would report it.
const back = await readSmallFile(join(realGitDir, 'gitdir'));
const backDir = back ? await realPathBounded(dirname(resolve(realGitDir, back.trim()))) : null;
if (backDir !== real) return undefined;
}
const realWorkingDir = await realPathBounded(workingDir);
if (!realWorkingDir || isSameOrUnder(realWorkingDir, real) || isSameOrUnder(real, realWorkingDir)) return undefined;
const home = (await realPathBounded(opts.home ?? homedir())) ?? resolve(opts.home ?? homedir());
if (real === sep || isSameOrUnder(home, real)) return undefined;
if (opts.dockerWorkspaces) {
const workspaces = await opts.dockerWorkspaces().catch(() => null);
// An unreadable list could hide a container's workspace, so it fails closed.
if (!workspaces) return undefined;
for (const workspace of workspaces) {
const realWorkspace = (await realPathBounded(workspace)) ?? resolve(workspace);
if (isSameOrUnder(real, realWorkspace) || isSameOrUnder(realWorkspace, real)) return undefined;
}
}
if (opts.allowRoot) {
let allowed = false;
try {
allowed = await opts.allowRoot(real);
} catch {
// A callback that throws or rejects refuses the worktree.
}
if (!allowed) return undefined;
}
return { name: basename(real), path: real, outside: true };
}
+10 -5
View File
@@ -1639,10 +1639,13 @@ export class Session extends EventEmitter {
}
/**
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
* never a confinement root, like `paneCwd`: it is judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request.
* The git worktree that {@link paneCwd} is in, when there is one (`live-worktree.ts`): a linked
* worktree inside `workingDir`, or, for an outside answer, a linked worktree or the repository's
* main worktree (from a session started in a linked one).
* The Files panel lists it instead of the main checkout. Runtime-only, like `paneCwd`. One inside
* `workingDir` is never a confinement root: it is judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request. One outside it (`outside: true`) is
* a second root for the file routes, re-checked on every request the same way.
*/
get liveWorktree(): LiveWorktree | undefined {
return this._liveWorktree;
@@ -2418,7 +2421,9 @@ export class Session extends EventEmitter {
status: this._status,
workingDir: this.workingDir,
paneCwd: this._paneCwd,
liveWorktree: this._liveWorktree,
// The wire keeps `liveWorktree` inside `workingDir` only; an outside answer rides its own field.
liveWorktree: this._liveWorktree?.outside ? undefined : this._liveWorktree,
outsideWorktree: this._liveWorktree?.outside ? this._liveWorktree : undefined,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
+17 -6
View File
@@ -934,20 +934,31 @@ export interface SessionState {
* from disk, so a restart starts blank. Absent for remote and docker sessions
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
* rediscovered sessions. NOT a confinement root: the file routes stay confined
* to `workingDir`, and a path resolved outside it goes through the attachment
* routes like any other out-of-workspace path.
* to `workingDir`, except for an absolute path inside an
* {@link outsideWorktree}. Any other path resolved outside `workingDir` goes
* through the attachment routes like any other out-of-workspace path.
*/
paneCwd?: string;
/**
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
* only starts its listing here, after `GET .../files` has checked that the real folder still
* lies inside `workingDir`, since a symlink can replace it without moving the pane.
* runtime-only and absent wherever `paneCwd` is, and always a folder strictly below
* `workingDir`: a worktree elsewhere is reported in {@link outsideWorktree}, never here. NOT a
* confinement root; the Files panel only starts its listing here, after `GET .../files` has
* checked that the real folder still lies inside `workingDir`, since a symlink can replace it
* without moving the pane.
*/
liveWorktree?: LiveWorktree;
/**
* The worktree of the session's repository outside `workingDir` that the pane is in NOW, when
* there is one: a linked worktree, or the repository's main worktree for a session started in a
* linked one. It carries `outside: true`, and its `path` is a real path that the repository
* itself vouched for. The file routes serve absolute paths inside it as a second root while it
* still resolves to that same path. Runtime-only, like {@link liveWorktree}; at most one of
* the two fields is set.
*/
outsideWorktree?: LiveWorktree;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
+53 -14
View File
@@ -3293,6 +3293,8 @@ Object.assign(CodemanApp.prototype, {
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
// The person asked for the session folder while the agent is in a worktree (#765).
sessionRootPinned: false,
// The worktree on screen when the pin was set: a pinned listing omits an outside worktree.
pinnedWorktree: null,
};
}
return this._fileBrowserState;
@@ -3316,6 +3318,7 @@ Object.assign(CodemanApp.prototype, {
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
state.pinnedWorktree = null;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3360,6 +3363,7 @@ Object.assign(CodemanApp.prototype, {
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
state.pinnedWorktree = null;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3473,49 +3477,58 @@ Object.assign(CodemanApp.prototype, {
_renderFileBrowserRoot(data) {
const strip = this.$?.('fileBrowserRoot');
if (!strip) return;
const worktree = data?.worktree;
const worktree =
data?.worktree ?? (this._fileBrowserState?.sessionRootPinned ? this._fileBrowserState.pinnedWorktree : null);
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
strip.hidden = true;
return;
}
const inWorktree = data.root === worktree.path;
const inWorktree = data?.root === worktree.path;
const label = this.$?.('fileBrowserRootLabel');
const name = this.$?.('fileBrowserRootName');
const button = this.$?.('fileBrowserRootSwitch');
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
if (name) name.textContent = inWorktree ? worktree.name : '';
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
strip.title = typeof data.root === 'string' ? data.root : '';
strip.title = typeof data?.root === 'string' ? data.root : '';
strip.hidden = false;
},
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
// a worktree, so the panel always asks unless the person chose the session folder.
_fileBrowserRootParam() {
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live-any';
},
toggleFileBrowserRoot() {
const state = this._ensureFileBrowserState();
state.sessionRootPinned = !state.sessionRootPinned;
const shown = this.fileBrowserData?.worktree;
state.pinnedWorktree =
state.sessionRootPinned && typeof shown?.path === 'string' && typeof shown?.name === 'string'
? { name: shown.name, path: shown.path }
: null;
return this._reloadFileBrowserKeepingQuery();
},
// Called on every session update of the active session. Compares with what the listing on
// screen reported rather than with the previous update, so a load already in flight when the
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
// pane moved is corrected by the next update, and an answer that matches ends it. A listing
// reports the session's worktree whichever folder it walked, except that a listing without
// root=live-any omits an outside worktree. While the person has pinned the session folder, the
// panel compares with the worktree it pinned away from, so this cannot loop.
_followFileBrowserWorktree(session) {
const state = this._fileBrowserState;
if (!state || state.ownerSessionId !== session.id) return;
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
const normal = state.normalState;
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
const live = session.liveWorktree?.path ?? null;
const listed = normal.data?.worktree?.path ?? null;
const live = (session.liveWorktree ?? session.outsideWorktree)?.path ?? null;
const listed = (normal.data?.worktree ?? (state.sessionRootPinned ? state.pinnedWorktree : null))?.path ?? null;
if (live === listed) return;
// The pin was about the worktree the agent has just left.
state.sessionRootPinned = false;
state.pinnedWorktree = null;
this._reloadFileBrowserKeepingQuery();
},
@@ -4256,10 +4269,12 @@ Object.assign(CodemanApp.prototype, {
/**
* Whether a path is absolute and provably OUTSIDE this session's workspace.
*
* `file-content` / `file-raw` resolve every path against `workingDir` and
* refuse anything that escapes it, so an absolute path elsewhere on the host
* (an agent's `/tmp` scratchpad capture, a screenshot, another checkout) can
* only ever 404 there — it has to go through the attachment routes instead.
* `file-content` / `file-raw` serve paths inside `workingDir` and, as a second
* root, an absolute path inside a followed worktree outside it
* (`outsideWorktree`). They refuse every other path that escapes
* `workingDir`, so an absolute path elsewhere on the host (an agent's `/tmp`
* scratchpad capture, a screenshot, another checkout) can only ever 404 there
* — it has to go through the attachment routes instead.
*
* A string compare is enough for ROUTING; the real containment decision stays
* server-side (realpath + guard) on whichever route the request lands on. An
@@ -4273,7 +4288,24 @@ Object.assign(CodemanApp.prototype, {
const workingDir = this.sessions.get(sessionId)?.workingDir;
if (!workingDir) return false;
const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`;
return filePath !== workingDir && !filePath.startsWith(root);
if (filePath === workingDir || filePath.startsWith(root)) return false;
// A worktree outside `workingDir` that the agent is in (#765) is a second root the file
// routes serve, so a path inside it previews (and edits) like a workspace path.
return this._isOutsideWorktreePath(filePath, sessionId) !== true;
},
/**
* Whether an absolute path lies inside the worktree OUTSIDE `workingDir` that the session's agent
* is in (`outsideWorktree`, #765). The file routes serve it as a second root, while the log
* viewer's `tail-file` stays confined to `workingDir`, so the terminal opens such a path in the
* file preview.
*/
_isOutsideWorktreePath(filePath, sessionId) {
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
const worktree = this.sessions.get(sessionId)?.outsideWorktree;
if (worktree?.outside !== true || typeof worktree.path !== 'string' || !worktree.path) return false;
const root = worktree.path.endsWith('/') ? worktree.path : `${worktree.path}/`;
return filePath === worktree.path || filePath.startsWith(root);
},
/**
@@ -5206,6 +5238,13 @@ Object.assign(CodemanApp.prototype, {
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
const unresolvable = !!attachmentId && !filePath.startsWith('/');
// A root-relative ref (`/docs/x.png`) resolves from the workspace root, as on GitHub, or from
// the top of the worktree outside the workspace that the agent is in (#765).
const worktree = this.sessions.get(sessionId)?.outsideWorktree;
const rootPrefix =
worktree?.outside === true && this._isOutsideWorktreePath(filePath, sessionId)
? `${worktree.path.replace(/\/+$/, '')}/`
: '';
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
const isWorkspaceRef = (ref) =>
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
@@ -5225,7 +5264,7 @@ Object.assign(CodemanApp.prototype, {
/* malformed escape: keep the ref as written */
}
const parts = [];
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
for (const seg of (rel.startsWith('/') ? rootPrefix + rel.slice(1) : dir + rel).split('/')) {
if (seg === '.' || (seg === '' && parts.length)) continue;
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
else parts.push(seg);
+7 -1
View File
@@ -2367,7 +2367,13 @@ Object.assign(CodemanApp.prototype, {
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) {
// The log viewer's tail-file stays confined to workingDir, so a path in a worktree outside it previews.
if (
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId) ||
self._isOutsideWorktreePath(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
}
+41
View File
@@ -16,6 +16,7 @@ import type { WaitCapacityError } from './session-wait-registry.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
import { isSensitivePath } from './sensitive-path.js';
import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js';
@@ -101,6 +102,46 @@ export function validateSessionFilePath(
return confineToRoot(resolvedWorkingDir, resolvedPath);
}
/**
* The outside worktree (#765) a LOCAL session's file routes may serve `filePath` from, else null:
* a recorded worktree with `outside` set, an absolute `filePath` lexically inside it, and a
* folder that still resolves to the very path that was recorded. `findOutsideWorktree()` records
* the real path, so a symlink that took the folder's place (or a parent's) since then fails the
* last check and the path is judged against `workingDir` alone.
*/
export function outsideWorktreeRootFor(
liveWorktree: { path: string; outside?: true } | undefined,
filePath: string
): string | null {
if (!liveWorktree?.outside || !isAbsolute(filePath)) return null;
const root = liveWorktree.path;
const rel = relative(root, resolve(filePath));
if (rel.startsWith('..') || isAbsolute(rel)) return null;
try {
return realpathSync(root) === root ? root : null;
} catch {
return null;
}
}
/**
* {@link validateSessionFilePath} for a LOCAL session that may have a worktree outside its folder
* (the session's outside worktree, `outside: true`): an absolute path inside that worktree is confined to it, and, when
* that refuses it (a worktree file that is a symlink into the session's own folder), to
* `workingDir`, which is served anyway. A file there that `isSensitivePath()` blocklists (`.env`,
* `.aws/credentials`, `users.json`, ...) is never served from the worktree root. Every other
* path is judged against `workingDir` alone.
*/
export function validateLocalSessionFilePath(
scope: { workingDir: string; liveWorktree?: { path: string; outside?: true } },
filePath: string
): { resolvedPath: string; relativePath: string } | null {
const root = outsideWorktreeRootFor(scope.liveWorktree, filePath);
const viaRoot = root ? validateSessionFilePath(root, filePath) : null;
if (viaRoot && !isSensitivePath(viaRoot.resolvedPath)) return viaRoot;
return validateSessionFilePath(scope.workingDir, filePath);
}
/**
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
* WITHOUT touching the filesystem.
+93 -21
View File
@@ -49,7 +49,13 @@ import {
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import {
isBlockedAttachmentDirectory,
isBlockedAttachmentPath,
isOutsideWorktreeRootRefused,
isUnderTree,
loadAttachmentGuardConfig,
} from '../../config/attachment-guard.js';
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
import {
CASES_DIR,
@@ -58,11 +64,13 @@ import {
getAuthUser,
parseBody,
resolveCasesDir,
validateLocalSessionFilePath,
validateSessionFilePath,
validateSessionFilePathLexical,
} from '../route-helpers.js';
import type { FastifyRequest } from 'fastify';
import type { SessionAttachmentHistoryItem, SessionRemote, SessionState } from '../../types/session.js';
import type { LiveWorktree } from '../../live-worktree.js';
import {
RemoteFileAccessError,
remoteCreateReadStream,
@@ -574,6 +582,11 @@ function getKnownSessionWorkingDir(
/** Where a session's files live: this host, or a host reachable over ssh. */
interface SessionFileScope {
workingDir: string;
/**
* A live local session's worktree (#765). Only one with `outside` set changes anything here: it
* is a second root that ABSOLUTE paths inside it are confined to (`validateLocalSessionFilePath`).
*/
liveWorktree?: LiveWorktree;
remote?: SessionRemote;
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
docker?: boolean;
@@ -606,7 +619,12 @@ function getKnownSessionFileScope(
const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId);
if (liveSession && canAccessOwned(user, liveSession.owner)) {
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
return {
workingDir: liveSession.workingDir,
liveWorktree: liveSession.liveWorktree,
remote: liveSession.remote,
docker: !!liveSession.docker,
};
}
const stored = ctx.store.getSession(sessionId);
@@ -648,6 +666,19 @@ type FileTargetResolution =
message: string;
};
/**
* The scope with an outside worktree (#765) dropped when the attachment guard refuses its root
* right now. `findOutsideWorktree()` judged the guard when the pane moved, and an admin can turn
* on workspace confinement or block the folder afterwards, so every request judges it again. A
* scope without an outside worktree comes back unchanged and reads no settings.
*/
async function withGuardedOutsideWorktree<T extends { liveWorktree?: LiveWorktree }>(scope: T): Promise<T> {
const worktree = scope.liveWorktree;
if (!worktree?.outside) return scope;
if (!isOutsideWorktreeRootRefused(worktree.path, await loadAttachmentGuardConfig())) return scope;
return { ...scope, liveWorktree: undefined };
}
/**
* Resolve a request's `?path=` against the session's file scope.
*
@@ -657,7 +688,7 @@ type FileTargetResolution =
*/
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
if (!scope.remote) {
const validated = validateSessionFilePath(scope.workingDir, filePath);
const validated = validateLocalSessionFilePath(await withGuardedOutsideWorktree(scope), filePath);
if (!validated) {
return {
ok: false,
@@ -876,6 +907,22 @@ async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Pr
}
}
/**
* Whether a `root=live-any` listing may start at a worktree OUTSIDE `workingDir` (`outside: true`,
* published as `outsideWorktree`).
* `findOutsideWorktree()` recorded its real path, and a symlink can take the folder's place, or a
* parent's, without moving the pane, so the folder must still resolve to exactly that path. Probed
* first (#516).
*/
async function canWalkOutsideWorktree(worktreePath: string): Promise<boolean> {
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
try {
return (await fs.realpath(worktreePath)) === worktreePath;
} catch {
return false;
}
}
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
return roots
.filter((root) => isPathWithinRoot(root.path, candidate))
@@ -944,11 +991,7 @@ function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>):
}
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
// The shared sensitive-path matcher describes file locations such as
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
// opened and used to enumerate those filenames.
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
return directory ? isBlockedAttachmentDirectory(path, blockedTrees) : isBlockedAttachmentPath(path, blockedTrees);
}
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
@@ -1708,11 +1751,28 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
// paths against the same root as any other. Only the search predicate reads a path relative
// to the listing, since that is the tree the person is looking at.
const liveWorktree = session.liveWorktree;
const walkRoot =
root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
? liveWorktree.path
: workingDir;
// A worktree OUTSIDE `workingDir` (the session's `outsideWorktree` on the wire) is a second
// root, and walking it is opt-in: only `root=live-any` does. `root=live` never walks it (every
// `path` stays relative to `workingDir`), so with an outside worktree it walks `workingDir`. The outside
// walk happens only while the folder still resolves to the real path recorded for it, and it
// lists ABSOLUTE `path`s, since a relative one resolves against `workingDir` on every other
// route. Those routes confine an absolute path inside it to it (`validateLocalSessionFilePath`).
const liveWorktree = (await withGuardedOutsideWorktree({ liveWorktree: session.liveWorktree })).liveWorktree;
const walksOutside =
root === 'live-any' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
const walksInside =
(root === 'live' || root === 'live-any') &&
!!liveWorktree &&
!liveWorktree.outside &&
(await canWalkLiveWorktree(workingDir, liveWorktree.path));
const walkRoot = liveWorktree && (walksOutside || walksInside) ? liveWorktree.path : workingDir;
// Only `root=live-any` reports an outside worktree: it is the client that opted in to it.
// A listing keeps reporting a worktree the guard refuses to walk, or the panel would reload on
// every session update.
const sessionWorktree = session.liveWorktree;
const reportedWorktree =
sessionWorktree && (!sessionWorktree.outside || root === 'live-any') ? sessionWorktree : undefined;
const nodePath = (fullPath: string): string => (walksOutside ? fullPath : fullPath.slice(workingDir.length + 1));
// null for an empty/whitespace query, which is what keeps the default
// tree response byte-identical when no search is requested.
const matcher = compileFileQuery(q ?? '');
@@ -1802,7 +1862,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
const entry = cursor.entries[cursor.index++];
const fullPath = join(cursor.dirPath, entry.name);
const relativePath = relative(workingDir, fullPath);
const relativePath = walksOutside ? fullPath : relative(workingDir, fullPath);
const listedPath = relative(walkRoot, fullPath);
if (entry.isDirectory()) {
@@ -1840,7 +1900,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
success: true,
data: {
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
...(reportedWorktree && { worktree: reportedWorktree }),
tree: [],
matches,
totalFiles,
@@ -1893,7 +1953,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) {
const entry = cursor.entries[cursor.index++];
const fullPath = join(cursor.dirPath, entry.name);
const relativePath = fullPath.slice(workingDir.length + 1);
const relativePath = nodePath(fullPath);
if (entry.isDirectory()) {
totalDirectories++;
const node: FileTreeNode = {
@@ -1931,7 +1991,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
success: true,
data: {
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
...(reportedWorktree && { worktree: reportedWorktree }),
tree,
totalFiles,
totalDirectories,
@@ -1958,7 +2018,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
// For a remote (SSH) case the same boundary is resolved on the remote host (#415),
// where the path actually lives.
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
const resolution = await resolveFileTarget(
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
filePath
);
if (!resolution.ok) {
// An unreachable remote is thrown so the shared handler answers a real 502 (and
// the test can assert it); a refusal keeps this route's historical returned
@@ -2230,7 +2293,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
}
const validated = validateSessionFilePath(session.workingDir, body.path);
const validated = validateLocalSessionFilePath(
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
body.path
);
if (!validated) {
// Covers missing files, traversal, and symlink escapes alike — a write
// target that fails confinement is reported identically to a missing
@@ -2333,7 +2399,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
const resolution = await resolveFileTarget(
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
filePath
);
if (!resolution.ok) {
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
return;
@@ -2972,7 +3041,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const session = findSessionOrFail(ctx, sessionId, req);
const validated = validateSessionFilePath(session.workingDir, filePath);
const validated = validateLocalSessionFilePath(
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
filePath
);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
+19 -3
View File
@@ -33,7 +33,14 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import {
CASES_DIR,
findSessionOrFail,
ownerFor,
parseBody,
validateLocalSessionFilePath,
validateSessionFilePath,
} from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
@@ -175,9 +182,15 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
// Inside the workspace: the same check every file route uses. Outside it: the
// attachment guard, exactly as an external file preview is admitted.
let resolvedPath: string;
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
const inWorkspace = validateLocalSessionFilePath(session, requestedPath);
// A path the live-worktree root admitted (an outside worktree, a second served root) is
// inside that root but not inside `workingDir`: it still answers to the attachment guard.
let viaOutsideWorktree = false;
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
if (!validateSessionFilePath(session.workingDir, requestedPath)) {
viaOutsideWorktree = true;
}
} else {
if (!isAbsolute(requestedPath)) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
@@ -189,6 +202,9 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
}
// One gate for every page outside `workingDir`: an external file and one in an outside worktree.
if (!inWorkspace || viaOutsideWorktree) {
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
@@ -233,7 +249,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
return;
}
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace || viaOutsideWorktree);
return {
success: true,
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
+37 -4
View File
@@ -35,7 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { findLiveWorktree } from '../live-worktree.js';
import { findLiveWorktree, type OutsideWorktreeOptions } from '../live-worktree.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
@@ -80,7 +80,8 @@ import {
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { folderGrants } from '../folder-listing.js';
import { folderAllowedForUser, folderGrants } from '../folder-listing.js';
import { isOutsideWorktreeRootRefused, loadAttachmentGuardConfig } from '../config/attachment-guard.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js';
@@ -213,7 +214,7 @@ import {
sendLostWebviewFramePage,
} from './middleware/auth.js';
import { isMultiUserMode } from '../config/multiuser.js';
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { bootstrapInitialAdmin, findUser, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { installEmptyApiPostParser } from './request-body-parser.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
@@ -3021,11 +3022,43 @@ export class WebServer extends EventEmitter {
*/
private async refreshLiveWorktree(session: Session): Promise<void> {
const cwd = session.paneCwd;
const worktree = await findLiveWorktree(session.workingDir, cwd);
const worktree = await findLiveWorktree(session.workingDir, cwd, this.outsideWorktreeOptions(session));
if (session.paneCwd !== cwd) return;
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
}
/**
* The rules a worktree OUTSIDE the session's folder must pass to be followed (#765 follow-up):
*
* - none in a Docker case workspace (the list is read only when a candidate needs it, and an
* unreadable list follows nothing);
* - none while attachment workspace confinement is on (`attachmentConfineToWorkspace` /
* `CODEMAN_ATTACHMENT_CONFINE`), since an outside worktree is a second served root;
* - none at, inside or around an attachment blocked tree (`attachmentBlockedPaths` /
* `CODEMAN_ATTACHMENT_BLOCKED_PATHS`, plus the defaults), or at a sensitive path;
* - in multi-user mode, only inside the owner's space or `workingDir`.
*
* Codeman judges these rules when the pane moves. The file routes judge the two guard rules
* again on every request (`isOutsideWorktreeRootRefused()`), so a setting changed later applies
* at once. The Docker and multi-user rules are judged only when the pane moves. Undefined for a remote or Docker session,
* whose pane directory is not a path the agent works in on this host (it has no `paneCwd`
* either, so this is belt and braces).
*/
private outsideWorktreeOptions(session: Session): OutsideWorktreeOptions | undefined {
if (session.remote || session.docker) return undefined;
return {
dockerWorkspaces: defaultDockerWorkspaces,
allowRoot: async (root: string): Promise<boolean> => {
if (isOutsideWorktreeRootRefused(root, await loadAttachmentGuardConfig())) return false;
if (!isMultiUserMode()) return true;
const owner = session.owner ? await findUser(session.owner) : undefined;
return folderAllowedForUser(root, owner ? { username: owner.username, role: owner.role } : undefined, {
workingDir: session.workingDir,
});
},
};
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session