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:
Michael GrundbergandClaude Opus 5.5 committed 2026-11-06 20:33:14 +01:00
1 parent c9b79097b0
commit 8d33f53fd0
28 files changed
+856 -198

No files matched your search

+34 -19
View File
@@ -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.
+1 -1
View File
@@ -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;