Skip to content

Commit c3b1718

Browse files
author
laoyh
committed
fix: preview explicit files outside workspace
1 parent f59ffd0 commit c3b1718

8 files changed

Lines changed: 35 additions & 25 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
277277

278278
- 🌿 **「文件变动」统一 tab**#475):Git 视角(真 diff / 历史 / 暂存·提交·还原 / worktree·子仓库选择)与本轮文件视角(模型读/写/编辑实时追踪)双视角合一;统一 diff 渲染(改蓝配对 + 行内字符级高亮 + 语法着色 + 上下文折叠)、底部可拖拽预览面板、一键展开独立 diff tab
279279
- 💬 **侧边对话渲染升级**#486):主对话级 Blocks 结构、turn 用量尾标、断线重连横幅
280-
- ⚙️ **工作区路径围栏开关**#458):新增 `workspaceFence` 声明式设置键,工作区外路径 403 时错误面一键关闭并指引
280+
- ⚙️ **工作区路径围栏与外部预览**`workspaceFence` 继续约束文件树、保存、重命名、删除和上传;明确打开的绝对文件允许在工作区外只读预览(文本、媒体和HTML),不存在或不可读时返回明确的文件系统错误
281281

282282
**⚡ 性能**
283283

README_EN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ All changes since v0.17.1 (the two intermediate version numbers v0.18.1-alpha.0
281281

282282
- 🌿 **Unified "Changes" tab** (#475): one tab, two lenses — Git (real diff / history / stage·commit·revert / worktree & child-repo selection) and This Session (live tracking of every file the model reads / writes / edits); a unified diff renderer (mod pairing + intra-line character highlights + syntax coloring + context folding), a draggable bottom preview pane, and one-click expansion into a dedicated diff tab
283283
- 💬 **Side Chat rendering upgrade** (#486): main-conversation-grade Blocks structure, per-turn usage tails, reconnect banner
284-
- ⚙️ **Workspace path fence toggle** (#458): a new `workspaceFence` declarative settings key with a one-click off affordance and guidance on 403 error surfaces
284+
- ⚙️ **Workspace fence and external previews**: `workspaceFence` still protects tree browsing, save, rename, delete, and upload operations; explicitly opened absolute files may be previewed read-only outside the workspace (text, media, and HTML), with clear filesystem errors for missing or unreadable files
285285

286286
**⚡ Performance**
287287

dsh.plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"id": "dsh-external/dsh-better-sidebar",
3-
"version": "0.18.0",
3+
"version": "0.18.1",
44
"main": "./lib/index.js",
55
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
66
"engines": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dsh-better-sidebar",
3-
"version": "0.18.0",
3+
"version": "0.18.1",
44
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
55
"type": "module",
66
"repository": {

src/client/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
465465
* The plugin version this service instance reports. Keep in lockstep with
466466
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
467467
*/
468-
export const SIDEBAR_SERVICE_VERSION = '0.18.0'
468+
export const SIDEBAR_SERVICE_VERSION = '0.18.1'
469469

470470
/**
471471
* Monotonic capability list consumers use to gate new API usage (features

src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
3333
import { resolveSessionPath } from './session-path.ts'
3434
import { writeWorkspaceUpload } from './fs-operations.ts'
35-
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
35+
import { ensurePreviewPath, ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
3636
import { searchFiles } from './fs-search.ts'
3737
import { decodeHtmlUrl } from './html-route.ts'
3838
import { extractFrameAncestors } from './browser-probe.ts'
@@ -349,7 +349,7 @@ function buildApi(
349349
// child-repo path is relative to the selected repoRoot, not the session
350350
// cwd; thread it so the path resolves inside the authorized workspace.
351351
const selected = selectedRepoOf(payload)
352-
const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected), fenceEnabledOf(getSettings))
352+
const path = await ensurePreviewPath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected))
353353
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
354354
if (binary) return { kind: 'binary', size, truncated, head }
355355
return { kind: 'text', content, truncated }
@@ -929,7 +929,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
929929
const raw = url.searchParams.get('path')
930930
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
931931
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
932-
const path = await ensureWorkspacePath(cwd, raw, fenceEnabledOf(() => settingsFace))
932+
const path = await ensurePreviewPath(cwd, raw)
933933
const info = await stat(path)
934934
if (!info.isFile() || info.size > resolved.mediaLimit) {
935935
throw new SidebarError('fs-error', 'not a file or too large', 400)
@@ -988,7 +988,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
988988
// real-path guard, with the same semantics as the media route's
989989
// fallback.
990990
const cwd = await sessionCwdOf(ctx, sessionId)
991-
const absolute = await ensureWorkspacePath(cwd, path, fenceEnabledOf(() => settingsFace))
991+
const absolute = await ensurePreviewPath(cwd, path)
992992
const info = await stat(absolute)
993993
if (!info.isFile() || info.size > resolved.mediaLimit) {
994994
throw new SidebarError('fs-error', 'not a file or too large', 400)

src/path-security.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ export async function ensureWorkspacePath(cwd: string, target: string, fence = t
4242
return realTarget
4343
}
4444

45+
/**
46+
* Resolve one existing file selected for read-only preview. Explicit absolute
47+
* files may live outside the session workspace; canonicalization and readable
48+
* filesystem errors remain identical to workspace-scoped reads.
49+
*/
50+
export async function ensurePreviewPath(cwd: string, target: string): Promise<string> {
51+
return ensureWorkspacePath(cwd, target, false)
52+
}
53+
4554
/**
4655
* Validate a write destination, including destinations that do not exist yet.
4756
* Existing targets are resolved to catch symlinks; missing targets are checked

tests/smoke.spec.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -665,14 +665,14 @@ describe('session cwd resolution over the API route', () => {
665665
expect(value.value?.content).toContain('runGit')
666666
})
667667

668-
it('rejects repo-root-relative fs.read paths outside a nested session workspace', async () => {
668+
it('previews repo-root-relative fs.read paths outside a nested session workspace', async () => {
669669
const route = mount({
670670
sessions: {
671671
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
672672
},
673673
})
674674
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'package.json' })
675-
expect(result).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
675+
expect(result).toMatchObject({ ok: true, value: { kind: 'text' } })
676676
})
677677

678678
it('rejects fs.tree paths outside the session workspace', async () => {
@@ -692,7 +692,7 @@ describe('session cwd resolution over the API route', () => {
692692
}
693693
})
694694

695-
it('rejects fs.read paths outside the session workspace', async () => {
695+
it('reads explicit files outside the session workspace for preview', async () => {
696696
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-'))
697697
const workspace = join(root, 'workspace')
698698
const outside = join(root, 'outside')
@@ -703,7 +703,7 @@ describe('session cwd resolution over the API route', () => {
703703
try {
704704
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
705705
const read = await invoke(route, 'fs.read', { sessionId: 'security', path: outsideFile })
706-
expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
706+
expect(read).toMatchObject({ ok: true, value: { kind: 'text', content: 'secret' } })
707707
} finally {
708708
rmSync(root, { recursive: true, force: true })
709709
}
@@ -724,7 +724,7 @@ describe('session cwd resolution over the API route', () => {
724724
}
725725
})
726726

727-
it('rejects media and HTML reads through a workspace symlink', async () => {
727+
it('serves explicit media and HTML previews through an external symlink', async () => {
728728
if (!canCreateSymlink) return
729729
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-route-symlink-security-'))
730730
const workspace = join(root, 'workspace')
@@ -745,10 +745,8 @@ describe('session cwd resolution over the API route', () => {
745745
// platform (a Windows drive path needs the leading slash separator
746746
// that a naive join-without-separator drops).
747747
const htmlResult = await invokeGet(html, encodeHtmlUrl('security', join(workspace, 'link', 'secret.html')))
748-
expect(mediaResult).toMatchObject({ status: 403 })
749-
expect(JSON.parse(mediaResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } })
750-
expect(htmlResult).toMatchObject({ status: 403 })
751-
expect(JSON.parse(htmlResult.body)).toMatchObject({ ok: false, error: { code: 'forbidden' } })
748+
expect(mediaResult).toMatchObject({ status: 200, body: 'not an image' })
749+
expect(htmlResult).toMatchObject({ status: 200, body: '<p>secret</p>' })
752750
} finally {
753751
rmSync(root, { recursive: true, force: true })
754752
}
@@ -784,7 +782,7 @@ describe('session cwd resolution over the API route', () => {
784782
}
785783
})
786784

787-
it.skipIf(!canCreateSymlink)('rejects fs.read through a workspace symlink', async () => {
785+
it.skipIf(!canCreateSymlink)('previews fs.read through a workspace symlink', async () => {
788786
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-symlink-security-'))
789787
const workspace = join(root, 'workspace')
790788
const outside = join(root, 'outside')
@@ -795,7 +793,7 @@ describe('session cwd resolution over the API route', () => {
795793
symlinkSync(outside, join(workspace, 'link'))
796794
const route = mount({ sessions: { get: () => ({ header: { cwd: workspace } }) } })
797795
const read = await invoke(route, 'fs.read', { sessionId: 'security', path: join(workspace, 'link', 'secret.txt') })
798-
expect(read).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } })
796+
expect(read).toMatchObject({ ok: true, value: { kind: 'text', content: 'secret' } })
799797
} finally {
800798
rmSync(root, { recursive: true, force: true })
801799
}
@@ -989,11 +987,14 @@ describe('side card settings routes', () => {
989987
writeFileSync(join(outside, 'secret.txt'), 'global instructions')
990988
try {
991989
const route = mountWithSettings(createFakeSettings())
992-
// Default (fence on): the outside read is refused as usual…
993-
const refused = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
994-
expect(refused).toMatchObject({ ok: false, error: { code: 'forbidden' } })
995-
// …then the settings-page switch (or the fence notice's one-click off)
996-
// disarms every fs route for paths outside the workspace.
990+
// Read-only previews accept explicit external files while tree and write
991+
// operations remain fenced until the existing global switch is off.
992+
const preview = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })
993+
expect(preview).toMatchObject({ ok: true, value: { kind: 'text', content: 'global instructions' } })
994+
const fencedTree = await invoke(route, 'fs.tree', { sessionId: 'fence', cwd: workspace, path: outside })
995+
expect(fencedTree).toMatchObject({ ok: false, error: { code: 'forbidden' } })
996+
const fencedWrite = await invoke(route, 'fs.write', { sessionId: 'fence', cwd: workspace, path: join(outside, 'written.txt'), content: 'blocked' })
997+
expect(fencedWrite).toMatchObject({ ok: false, error: { code: 'forbidden' } })
997998
const off = await invoke(route, 'settings.update', { patch: { workspaceFence: false } })
998999
expect(off.ok).toBe(true)
9991000
const read = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') })

0 commit comments

Comments
 (0)