mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(files): harden the outside-worktree root after review
Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the session folder, and an outside worktree is published as outsideWorktree and walked only with root=live-any. Refuse a symlinked .git folder as the main worktree, sensitive files inside a followed worktree, and any outside worktree while attachment confinement is on or around a blocked tree. The file routes re-check the guard settings on every request, and a throwing rule check refuses. The panel keeps its Back to worktree button while pinned to the session folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9b79097b0
commit
8d33f53fd0
28 files changed
+856
-198
No files matched your search
@@ -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));
|
||||
}
|
||||
+63
-44
@@ -23,11 +23,17 @@
|
||||
* own repository vouches for it ({@link findOutsideWorktree}). The answer then carries
|
||||
* `outside: true`, and the file routes serve absolute paths inside it.
|
||||
*
|
||||
* 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`.
|
||||
* 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".
|
||||
@@ -63,12 +69,19 @@ export interface OutsideWorktreeOptions {
|
||||
/** Host paths of every Docker case workspace: a container can write there, so none is followed. */
|
||||
dockerWorkspaces?: () => Promise<string[]>;
|
||||
/**
|
||||
* Narrows which worktree may be followed. Multi-user mode passes the owner's folder rule
|
||||
* (`folderAllowedForUser`), so a regular user's session cannot list another user's checkout.
|
||||
* 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. */
|
||||
const MAX_DOT_GIT_FILE_BYTES = 4096;
|
||||
|
||||
@@ -78,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');
|
||||
@@ -91,20 +125,9 @@ 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';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,29 +170,14 @@ async function realPathBounded(p: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of a small regular file, refusing a symlink as the last component and a FIFO
|
||||
* (O_NOFOLLOW, O_NONBLOCK, then judged on the handle), like {@link classifyDotGit}. Null for
|
||||
* anything else.
|
||||
*/
|
||||
/** The text of a small regular file that a bounded probe found, via {@link readOpenedSmallFile}. */
|
||||
async function readSmallFile(path: string): Promise<string | null> {
|
||||
if ((await probePathKind(path)) !== 'file') return 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(() => {});
|
||||
}
|
||||
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 = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
|
||||
const raw = rawGitDir(text);
|
||||
return raw ? resolve(dir, raw) : null;
|
||||
}
|
||||
|
||||
@@ -206,7 +214,9 @@ async function sessionCommonDir(workingDir: string): Promise<string | null> {
|
||||
* 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 (multi-user mode).
|
||||
* - `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
|
||||
@@ -239,8 +249,9 @@ export async function findOutsideWorktree(
|
||||
if (!real) return undefined;
|
||||
|
||||
if (kind === 'directory') {
|
||||
// The main worktree: its `.git` folder is the session's common dir itself.
|
||||
if ((await realPathBounded(join(dir, '.git'))) !== commonDir) return undefined;
|
||||
// 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;
|
||||
@@ -265,6 +276,14 @@ export async function findOutsideWorktree(
|
||||
if (isSameOrUnder(real, realWorkspace) || isSameOrUnder(realWorkspace, real)) return undefined;
|
||||
}
|
||||
}
|
||||
if (opts.allowRoot && !(await opts.allowRoot(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 };
|
||||
}
|
||||
+6
-2
@@ -1639,7 +1639,9 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked git worktree that {@link paneCwd} is in, when there is one (`live-worktree.ts`).
|
||||
* 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
|
||||
@@ -2419,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,
|
||||
|
||||
+20
-13
@@ -934,24 +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 that the pane is in NOW, when there is one. Derived from
|
||||
* {@link paneCwd} by `live-worktree.ts`, so it is runtime-only and absent wherever `paneCwd` is.
|
||||
*
|
||||
* Usually it lies inside `workingDir` (Claude Code's `.claude/worktrees/<name>`), and then it is
|
||||
* 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.
|
||||
*
|
||||
* With `outside: true` it is a worktree of the session's repository outside `workingDir`, which
|
||||
* the repository itself vouched for, and its `path` is a real path. The file routes then serve
|
||||
* absolute paths inside it as a second root, while it still resolves to that same path.
|
||||
* 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 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 */
|
||||
|
||||
+34
-19
@@ -3284,6 +3284,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;
|
||||
@@ -3307,6 +3309,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
state.pinnedWorktree = null;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3351,6 +3354,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
state.pinnedWorktree = null;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3464,49 +3468,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();
|
||||
},
|
||||
|
||||
@@ -4247,10 +4260,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
|
||||
@@ -4267,18 +4282,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
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;
|
||||
return this._isOutsideWorktreePath(filePath, sessionId) !== true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether an absolute path lies inside the worktree OUTSIDE `workingDir` that the session's agent
|
||||
* is in (`liveWorktree.outside`, #765). The file routes serve it as a second root, while the log
|
||||
* 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)?.liveWorktree;
|
||||
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);
|
||||
@@ -5214,11 +5229,11 @@ 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 top of the checkout the file is in, as
|
||||
// on GitHub: the workspace, or the worktree outside it that the agent is in (#765).
|
||||
const worktree = this.sessions?.get?.(sessionId)?.liveWorktree;
|
||||
// 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?.outside === true && this._isOutsideWorktreePath(filePath, sessionId)
|
||||
? `${worktree.path.replace(/\/+$/, '')}/`
|
||||
: '';
|
||||
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
|
||||
|
||||
@@ -2372,7 +2372,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
previewsInFileViewer(target) ||
|
||||
rendersAsPage(target) ||
|
||||
self._isExternalPreviewPath(target, sessionId) ||
|
||||
self._isOutsideWorktreePath?.(target, sessionId)
|
||||
self._isOutsideWorktreePath(target, sessionId)
|
||||
) {
|
||||
self.openFilePreview(target, sessionId);
|
||||
return;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -125,16 +126,20 @@ export function outsideWorktreeRootFor(
|
||||
|
||||
/**
|
||||
* {@link validateSessionFilePath} for a LOCAL session that may have a worktree outside its folder
|
||||
* (`liveWorktree.outside`): an absolute path inside that worktree is confined to it, and, when
|
||||
* (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. Every other path is judged against `workingDir` alone.
|
||||
* `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);
|
||||
return (root && validateSessionFilePath(root, filePath)) || validateSessionFilePath(scope.workingDir, filePath);
|
||||
const viaRoot = root ? validateSessionFilePath(root, filePath) : null;
|
||||
if (viaRoot && !isSensitivePath(viaRoot.resolvedPath)) return viaRoot;
|
||||
return validateSessionFilePath(scope.workingDir, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
@@ -660,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.
|
||||
*
|
||||
@@ -669,7 +688,7 @@ type FileTargetResolution =
|
||||
*/
|
||||
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
|
||||
if (!scope.remote) {
|
||||
const validated = validateLocalSessionFilePath(scope, filePath);
|
||||
const validated = validateLocalSessionFilePath(await withGuardedOutsideWorktree(scope), filePath);
|
||||
if (!validated) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -889,7 +908,8 @@ async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Pr
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `root=live` listing may start at a worktree OUTSIDE `workingDir` (`liveWorktree.outside`).
|
||||
* 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).
|
||||
@@ -971,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 }> {
|
||||
@@ -1735,19 +1751,27 @@ 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.
|
||||
// A worktree OUTSIDE `workingDir` (`liveWorktree.outside`) is a second root: walked only while
|
||||
// it still resolves to the real path recorded for it, and listed with 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 = session.liveWorktree;
|
||||
// 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' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
|
||||
root === 'live-any' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
|
||||
const walksInside =
|
||||
root === 'live' &&
|
||||
(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.
|
||||
@@ -1876,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,
|
||||
@@ -1967,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,
|
||||
@@ -2269,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 = validateLocalSessionFilePath(session, 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
|
||||
@@ -3014,7 +3041,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, sessionId, req);
|
||||
const validated = validateLocalSessionFilePath(session, 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;
|
||||
|
||||
@@ -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, validateLocalSessionFilePath } 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';
|
||||
@@ -176,8 +183,14 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// attachment guard, exactly as an external file preview is admitted.
|
||||
let resolvedPath: string;
|
||||
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))}` },
|
||||
|
||||
+23
-12
@@ -81,6 +81,7 @@ import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.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';
|
||||
@@ -3028,23 +3029,33 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
/**
|
||||
* 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), and in multi-user mode only inside the owner's space or
|
||||
* `workingDir`. 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).
|
||||
*
|
||||
* - 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,
|
||||
...(isMultiUserMode() && {
|
||||
allowRoot: async (root: string): Promise<boolean> => {
|
||||
const owner = session.owner ? await findUser(session.owner) : undefined;
|
||||
return folderAllowedForUser(root, owner ? { username: owner.username, role: owner.role } : undefined, {
|
||||
workingDir: session.workingDir,
|
||||
});
|
||||
},
|
||||
}),
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user