mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59: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
+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;
|
||||
|
||||
Reference in new issue
Block a user