diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8687b8a..3cfbb80 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,11 +1,13 @@ import { ipcMain, dialog, shell, type BrowserWindow } from 'electron'; import { homedir } from 'node:os'; -import { join, resolve, sep } from 'node:path'; +import { join } from 'node:path'; +import { stat } from 'node:fs/promises'; import type { Store } from './store'; -import { scanRepos } from './scanner'; +import { scanFolders, isRepo } from './scanner'; import { getGitInfo } from './gitInfo'; import { getProvider, availableAgents } from './agents'; -import type { AgentId } from '../shared/types'; +import type { AgentId, Folder } from '../shared/types'; +import { isAllowedPath } from '../shared/pathGuard'; import { buildProjectList } from './projects'; import { openProjects, openInEditor } from './launcher'; import type { WtTab } from '../shared/wtArgs'; @@ -22,15 +24,14 @@ export interface IpcConfig { defaultLanguage: string; } -function isUnderBase(base: string, incoming: string): boolean { - const r = resolve(incoming); - const b = resolve(base); - return r === b || r.startsWith(b + sep); -} - export function registerIpc(cfg: IpcConfig): void { + // Legacy single-base value, retained only for the settings:get response (back-compat); not used for scanning or the security guard. const effBaseDir = () => cfg.store.getBaseDir() ?? cfg.defaultBaseDir; const effThresholds = () => cfg.store.getThresholds() ?? DEFAULT_THRESHOLDS; + const effFolders = (): Folder[] => { + const f = cfg.store.getFolders(); + return f.length ? f : [{ path: cfg.defaultBaseDir, kind: 'root' }]; + }; const activeAgent = (): AgentId => { const a = cfg.store.getAgent(); @@ -40,10 +41,9 @@ export function registerIpc(cfg: IpcConfig): void { ipcMain.handle('projects:list', async () => { return buildProjectList({ - baseDir: effBaseDir(), nowMs: Date.now(), thresholds: effThresholds(), - scan: (base) => scanRepos(base), + scan: () => scanFolders(effFolders()), git: (dir) => getGitInfo(dir), sessions: (p) => agent().listSessions(p), resumeCue: (p, sessionId) => agent().lastUserMessage(p, sessionId), @@ -63,7 +63,7 @@ export function registerIpc(cfg: IpcConfig): void { ipcMain.handle('usage:report', async (_e, sinceMs: number) => { const ms = (Number.isFinite(sinceMs) || sinceMs === Infinity) ? sinceMs : 0; - const repos = await scanRepos(effBaseDir()); + const repos = await scanFolders(effFolders()); return scanUsage(repos, CLAUDE_PROJECTS, ms); }); ipcMain.handle('settings:getLanguage', () => cfg.store.getLanguage() ?? cfg.defaultLanguage); @@ -78,6 +78,21 @@ export function registerIpc(cfg: IpcConfig): void { baseDir: effBaseDir(), thresholds: effThresholds(), language: cfg.store.getLanguage() ?? cfg.defaultLanguage, })); ipcMain.handle('settings:setBaseDir', (_e, dir: string) => cfg.store.setBaseDir(String(dir).slice(0, 2000))); + ipcMain.handle('settings:getFolders', () => effFolders()); + ipcMain.handle('settings:addFolder', async (_e, p: string) => { + const path = String(p).trim().slice(0, 2000); + let isDir = false; + try { isDir = (await stat(path)).isDirectory(); } catch { isDir = false; } + if (isDir) { + const kind: Folder['kind'] = (await isRepo(path)) ? 'repo' : 'root'; + cfg.store.addFolder({ path, kind }); + } + return effFolders(); + }); + ipcMain.handle('settings:removeFolder', (_e, p: string) => { + cfg.store.removeFolder(String(p).slice(0, 2000)); + return effFolders(); + }); ipcMain.handle('settings:setThresholds', (_e, t: { freshDays: number; warnDays: number; neglectedDays: number }) => { const { freshDays, warnDays, neglectedDays } = t ?? {}; if ( @@ -93,12 +108,12 @@ export function registerIpc(cfg: IpcConfig): void { }); ipcMain.handle('projects:open', (_e, items: { path: string; sessionId: string | null }[]) => { - const base = effBaseDir(); const now = new Date().toISOString(); + const folders = effFolders(); const tabs: WtTab[] = []; for (const it of items) { - if (!isUnderBase(base, it.path)) { - cfg.sendError(`Path outside base dir: ${it.path}`); + if (!isAllowedPath(folders, it.path)) { + cfg.sendError(`Path outside allowed folders: ${it.path}`); continue; } const a = agent(); @@ -119,8 +134,8 @@ export function registerIpc(cfg: IpcConfig): void { // Open the project folder in the OS file manager. ipcMain.handle('project:openFolder', async (_e, p: string) => { - if (!isUnderBase(effBaseDir(), p)) { - cfg.sendError(`Path outside base dir: ${p}`); + if (!isAllowedPath(effFolders(), p)) { + cfg.sendError(`Path outside allowed folders: ${p}`); return; } const err = await shell.openPath(p); @@ -129,8 +144,8 @@ export function registerIpc(cfg: IpcConfig): void { // Open the project in VS Code (`code `). ipcMain.handle('project:openEditor', (_e, p: string) => { - if (!isUnderBase(effBaseDir(), p)) { - cfg.sendError(`Path outside base dir: ${p}`); + if (!isAllowedPath(effFolders(), p)) { + cfg.sendError(`Path outside allowed folders: ${p}`); return; } openInEditor(p, { onError: cfg.sendError }); diff --git a/src/main/projects.test.ts b/src/main/projects.test.ts index bd7f6ef..b19c536 100644 --- a/src/main/projects.test.ts +++ b/src/main/projects.test.ts @@ -8,7 +8,6 @@ const DAY = 86_400_000; function deps(over: Partial): BuildDeps { return { - baseDir: 'C:\\g', nowMs: NOW, thresholds: DEFAULT_THRESHOLDS, scan: async () => [ diff --git a/src/main/projects.ts b/src/main/projects.ts index 1d2637c..3bb6649 100644 --- a/src/main/projects.ts +++ b/src/main/projects.ts @@ -3,10 +3,9 @@ import type { RawProject } from './scanner'; import { classifyStaleness } from '../shared/staleness'; export interface BuildDeps { - baseDir: string; nowMs: number; thresholds: StaleThresholds; - scan: (baseDir: string) => Promise; + scan: () => Promise; git: (dir: string) => Promise; sessions: (projectPath: string) => SessionMeta[]; resumeCue: (projectPath: string, sessionId: string) => string | null; @@ -20,7 +19,7 @@ function maxMs(a: number | null, b: number | null): number | null { } export async function buildProjectList(deps: BuildDeps): Promise { - const raw = await deps.scan(deps.baseDir); + const raw = await deps.scan(); const models = await Promise.all( raw.map(async (r): Promise => { const git = await deps.git(r.path); diff --git a/src/main/scanner.test.ts b/src/main/scanner.test.ts index 520f01a..f6c2d0b 100644 --- a/src/main/scanner.test.ts +++ b/src/main/scanner.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { scanRepos } from './scanner'; +import { join, basename } from 'node:path'; +import { scanRepos, scanFolders } from './scanner'; let base: string; beforeEach(() => { @@ -40,3 +40,39 @@ describe('scanRepos', () => { expect(repos).toEqual(['projA', 'projB']); }); }); + +describe('scanFolders', () => { + it('merges multiple roots, includes a direct repo, and dedupes by path', async () => { + // second root + const base2 = mkdtempSync(join(tmpdir(), 'devdeck-scan2-')); + mkdirSync(join(base2, 'projC', '.git'), { recursive: true }); + // a standalone repo dir to add directly + const repoDir = mkdtempSync(join(tmpdir(), 'devdeck-repo-')); + mkdirSync(join(repoDir, '.git'), { recursive: true }); + try { + const out = await scanFolders([ + { path: base, kind: 'root' }, // projA, projB (from beforeEach) + { path: base2, kind: 'root' }, // projC + { path: repoDir, kind: 'repo' }, // the standalone repo itself + { path: join(base, 'projA'), kind: 'repo' }, // duplicate of projA -> deduped + ]); + expect(out.map((p) => p.name).sort()).toEqual(['projA', 'projB', 'projC', basename(repoDir)].sort()); + } finally { + rmSync(base2, { recursive: true, force: true }); + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('skips a repo entry that has no .git, and a non-existent root', async () => { + const noGit = mkdtempSync(join(tmpdir(), 'devdeck-nogit-')); + try { + const out = await scanFolders([ + { path: noGit, kind: 'repo' }, // no .git -> skipped + { path: join(noGit, 'does-not-exist'), kind: 'root' }, // missing -> skipped + ]); + expect(out).toEqual([]); + } finally { + rmSync(noGit, { recursive: true, force: true }); + } + }); +}); diff --git a/src/main/scanner.ts b/src/main/scanner.ts index e11a840..84bd2ac 100644 --- a/src/main/scanner.ts +++ b/src/main/scanner.ts @@ -1,5 +1,6 @@ import { readdir, access } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, basename, resolve } from 'node:path'; +import type { Folder } from '../shared/types'; const IGNORE = new Set(['__pycache__', '.pytest_cache', '.claude', '.playwright-mcp', 'node_modules']); @@ -8,7 +9,7 @@ export interface RawProject { name: string; } -async function isRepo(dir: string): Promise { +export async function isRepo(dir: string): Promise { try { await access(join(dir, '.git')); return true; @@ -44,3 +45,27 @@ export async function scanRepos(baseDir: string, maxDepth = 2): Promise(); + const out: RawProject[] = []; + for (const it of items) { + const key = resolve(it.path); + if (seen.has(key)) continue; + seen.add(key); + out.push(it); + } + return out; +} + +/** Scan every configured folder: roots walked depth-2, repos included directly; deduped by resolved path. */ +export async function scanFolders(folders: Folder[]): Promise { + const chunks = await Promise.all( + folders.map((f) => + f.kind === 'repo' + ? isRepo(f.path).then((ok) => (ok ? [{ path: f.path, name: basename(f.path) }] : [])) + : scanRepos(f.path), + ), + ); + return dedupeByResolvedPath(chunks.flat()); +} diff --git a/src/main/store.test.ts b/src/main/store.test.ts index 2d77368..a662ad1 100644 --- a/src/main/store.test.ts +++ b/src/main/store.test.ts @@ -65,4 +65,38 @@ describe('Store', () => { const re = new Store(file); expect(re.getAgent()).toBe('codex'); }); + + it('round-trips folders: add, dedupe, remove', () => { + const s = new Store(file); + expect(s.getFolders()).toEqual([]); + s.addFolder({ path: 'C:\\work', kind: 'root' }); + s.addFolder({ path: 'C:\\work', kind: 'root' }); // duplicate -> ignored + s.addFolder({ path: 'E:\\spike', kind: 'repo' }); + const re = new Store(file); + expect(re.getFolders()).toEqual([ + { path: 'C:\\work', kind: 'root' }, + { path: 'E:\\spike', kind: 'repo' }, + ]); + re.removeFolder('C:\\work'); + expect(new Store(file).getFolders()).toEqual([{ path: 'E:\\spike', kind: 'repo' }]); + }); + + it('migrates a legacy baseDir into a single root folder', () => { + const s = new Store(file); + s.setBaseDir('C:\\repos'); + expect(s.getFolders()).toEqual([{ path: 'C:\\repos', kind: 'root' }]); + // first explicit add persists the migrated entry plus the new one + s.addFolder({ path: 'D:\\more', kind: 'root' }); + expect(new Store(file).getFolders()).toEqual([ + { path: 'C:\\repos', kind: 'root' }, + { path: 'D:\\more', kind: 'root' }, + ]); + }); + + it('keeps a migrated baseDir folder removed after reload', () => { + const s = new Store(file); + s.setBaseDir('C:\\repos'); // legacy single base, no explicit folders + s.removeFolder('C:\\repos'); // remove the migrated entry + expect(new Store(file).getFolders()).toEqual([]); // must stay empty after reload + }); }); diff --git a/src/main/store.ts b/src/main/store.ts index 800ff4c..6df9a8d 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -1,9 +1,10 @@ import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs'; -import type { StoreEntry } from '../shared/types'; +import { resolve } from 'node:path'; +import type { StoreEntry, Folder } from '../shared/types'; interface StateFile { projects: Record; - settings?: { language?: string; baseDir?: string; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string }; + settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string }; } const EMPTY: StoreEntry = { @@ -56,6 +57,27 @@ export class Store { getBaseDir(): string | null { return this.state.settings?.baseDir ?? null; } setBaseDir(baseDir: string): void { this.state.settings = { ...(this.state.settings ?? {}), baseDir }; this.save(); } + + getFolders(): Folder[] { + const f = this.state.settings?.folders; + if (f !== undefined) return [...f]; + const b = this.state.settings?.baseDir; + return b ? [{ path: b, kind: 'root' }] : []; + } + addFolder(folder: Folder): void { + const cur = this.state.settings?.folders ?? this.getFolders(); + const exists = cur.some((x) => resolve(x.path) === resolve(folder.path)); + const folders = exists ? cur : [...cur, folder]; + this.state.settings = { ...(this.state.settings ?? {}), folders }; + this.save(); + } + removeFolder(path: string): void { + const cur = this.state.settings?.folders ?? this.getFolders(); + const folders = cur.filter((x) => resolve(x.path) !== resolve(path)); + this.state.settings = { ...(this.state.settings ?? {}), folders }; + this.save(); + } + getThresholds(): { freshDays: number; warnDays: number; neglectedDays: number } | null { return this.state.settings?.thresholds ?? null; } setThresholds(thresholds: { freshDays: number; warnDays: number; neglectedDays: number }): void { this.state.settings = { ...(this.state.settings ?? {}), thresholds }; this.save(); } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 0a14549..0511f9c 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -16,6 +16,9 @@ contextBridge.exposeInMainWorld('devdeck', { availableAgents: () => ipcRenderer.invoke('settings:availableAgents'), getSettings: () => ipcRenderer.invoke('settings:get'), setBaseDir: (dir: string) => ipcRenderer.invoke('settings:setBaseDir', dir), + getFolders: () => ipcRenderer.invoke('settings:getFolders'), + addFolder: (path: string) => ipcRenderer.invoke('settings:addFolder', path), + removeFolder: (path: string) => ipcRenderer.invoke('settings:removeFolder', path), setThresholds: (t: { freshDays: number; warnDays: number; neglectedDays: number }) => ipcRenderer.invoke('settings:setThresholds', t), pickFolder: () => ipcRenderer.invoke('settings:pickFolder'), openFolder: (path: string) => ipcRenderer.invoke('project:openFolder', path), diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 29d691f..7725a6f 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -17,6 +17,9 @@ declare global { availableAgents(): Promise; getSettings(): Promise<{ baseDir: string; thresholds: { freshDays: number; warnDays: number; neglectedDays: number }; language: string }>; setBaseDir(dir: string): Promise; + getFolders(): Promise; + addFolder(path: string): Promise; + removeFolder(path: string): Promise; setThresholds(t: { freshDays: number; warnDays: number; neglectedDays: number }): Promise; pickFolder(): Promise; openFolder(path: string): Promise; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 8d7cc93..9c6c522 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -48,8 +48,11 @@ "usage.col_project": "Project", "usage.col_cost": "Est. cost", "usage.disclaimer": "* Some models have no price card — excluded from cost. Cost is a public-rate API-equivalent estimate and may differ from your (subscription) bill.", - "set.scan_dir": "Scan folder", - "set.browse": "Browse…", + "set.scan_locations": "Scan locations", + "set.kind_root": "scans for repos", + "set.kind_repo": "single repo", + "set.add_folder": "+ Add folder…", + "set.remove_folder": "Remove", "set.thresholds": "Staleness thresholds (days)", "set.fresh": "Fresh", "set.warn": "Warn", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index 943d2b7..ebe51e3 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -48,8 +48,11 @@ "usage.col_project": "プロジェクト", "usage.col_cost": "推定コスト", "usage.disclaimer": "* 一部のモデルは単価未登録 — コスト推定から除外。コストは公開単価ベースのAPI換算推定値であり、実際の請求(サブスクリプション)と異なる場合があります。", - "set.scan_dir": "スキャンフォルダ", - "set.browse": "参照…", + "set.scan_locations": "スキャン場所", + "set.kind_root": "リポジトリを検索", + "set.kind_repo": "単一リポジトリ", + "set.add_folder": "+ フォルダを追加…", + "set.remove_folder": "削除", "set.thresholds": "放置しきい値(日)", "set.fresh": "新鮮", "set.warn": "注意", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 3f9d0c6..5272fb8 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -48,8 +48,11 @@ "usage.col_project": "프로젝트", "usage.col_cost": "추정비용", "usage.disclaimer": "* 일부 모델은 단가 미등록 — 비용 추정에서 제외됨. 비용은 공개 단가 기반 API 환산 추정치이며 실제 청구(구독)와 다를 수 있습니다.", - "set.scan_dir": "스캔 폴더", - "set.browse": "찾아보기…", + "set.scan_locations": "스캔 위치", + "set.kind_root": "폴더 스캔", + "set.kind_repo": "단일 저장소", + "set.add_folder": "+ 폴더 추가…", + "set.remove_folder": "제거", "set.thresholds": "방치 기준(일)", "set.fresh": "신선", "set.warn": "주의", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 594e241..dc88c96 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -48,8 +48,11 @@ "usage.col_project": "项目", "usage.col_cost": "预估费用", "usage.disclaimer": "* 部分模型无单价 — 已从费用中排除。费用是基于公开单价的 API 等效估算,可能与实际账单(订阅)不同。", - "set.scan_dir": "扫描文件夹", - "set.browse": "浏览…", + "set.scan_locations": "扫描位置", + "set.kind_root": "扫描仓库", + "set.kind_repo": "单个仓库", + "set.add_folder": "+ 添加文件夹…", + "set.remove_folder": "移除", "set.thresholds": "闲置阈值(天)", "set.fresh": "新鲜", "set.warn": "注意", diff --git a/src/renderer/settingsView.ts b/src/renderer/settingsView.ts index 8599295..27b623d 100644 --- a/src/renderer/settingsView.ts +++ b/src/renderer/settingsView.ts @@ -1,4 +1,5 @@ import { tr, SUPPORTED, setLanguage as setRuntimeLang } from './i18n-runtime'; +import type { Folder } from '../shared/types'; let host: HTMLElement; let onChangedCb: () => void = () => {}; @@ -17,12 +18,26 @@ async function render(): Promise { const title = document.createElement('h2'); title.className = 'set-title'; title.textContent = tr('nav.settings'); host.appendChild(title); - const dir = document.createElement('input'); dir.className = 'set-input'; dir.type = 'text'; dir.value = s.baseDir; - const browse = document.createElement('button'); browse.className = 'chip'; browse.textContent = tr('set.browse'); - browse.addEventListener('click', async () => { const p = await window.devdeck.pickFolder(); if (p) { dir.value = p; await window.devdeck.setBaseDir(p); onChangedCb(); } }); - dir.addEventListener('change', async () => { await window.devdeck.setBaseDir(dir.value.trim()); onChangedCb(); }); - const dirWrap = document.createElement('div'); dirWrap.className = 'set-inline'; dirWrap.append(dir, browse); - host.appendChild(field('set.scan_dir', dirWrap, dir)); + const folders = await window.devdeck.getFolders(); + const list = document.createElement('div'); list.className = 'folder-list'; + const renderRow = (f: Folder) => { + const row = document.createElement('div'); row.className = 'folder-row'; + const path = document.createElement('span'); path.className = 'folder-path'; path.textContent = f.path; + const kind = document.createElement('span'); kind.className = 'folder-kind'; + kind.textContent = tr(f.kind === 'repo' ? 'set.kind_repo' : 'set.kind_root'); + const rm = document.createElement('button'); rm.className = 'folder-rm'; rm.textContent = '✕'; + rm.setAttribute('aria-label', tr('set.remove_folder')); + rm.addEventListener('click', async () => { await window.devdeck.removeFolder(f.path); render(); onChangedCb(); }); + row.append(path, kind, rm); return row; + }; + for (const f of folders) list.appendChild(renderRow(f)); + const add = document.createElement('button'); add.className = 'chip'; add.textContent = tr('set.add_folder'); + add.addEventListener('click', async () => { + const p = await window.devdeck.pickFolder(); + if (p) { await window.devdeck.addFolder(p); render(); onChangedCb(); } + }); + const listWrap = document.createElement('div'); listWrap.append(list, add); + host.appendChild(field('set.scan_locations', listWrap)); const mk = (v: number) => { const n = document.createElement('input'); n.type = 'number'; n.min = '1'; n.className = 'set-num'; n.value = String(v); return n; }; const f = mk(s.thresholds.freshDays), w = mk(s.thresholds.warnDays), g = mk(s.thresholds.neglectedDays); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index ee86fe6..764177c 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -191,4 +191,11 @@ button.primary:hover { background: var(--accent-hover); } #proj-search { width: 160px; } #proj-search:focus, #proj-sort:focus { border-color: var(--accent); -} \ No newline at end of file +} + +.folder-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; } +.folder-row { display: flex; align-items: center; gap: 8px; } +.folder-path { flex: 1; font-family: var(--mono, monospace); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.folder-kind { opacity: 0.6; font-size: 11px; white-space: nowrap; } +.folder-rm { background: none; border: none; cursor: pointer; color: inherit; opacity: 0.6; flex-shrink: 0; } +.folder-rm:hover { opacity: 1; } \ No newline at end of file diff --git a/src/shared/pathGuard.test.ts b/src/shared/pathGuard.test.ts new file mode 100644 index 0000000..4d97d5a --- /dev/null +++ b/src/shared/pathGuard.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { join, resolve, sep } from 'node:path'; +import { isAllowedPath } from './pathGuard'; + +// Build OS-native absolute paths so the test exercises the same separator/`resolve` +// semantics the guard uses on whatever platform runs it (CI runs ubuntu/macos/windows). +const root = resolve(sep, 'work'); +const repo = resolve(sep, 'spike'); +const folders = [ + { path: root, kind: 'root' as const }, + { path: repo, kind: 'repo' as const }, +]; + +describe('isAllowedPath', () => { + it('allows a path under a root', () => { + expect(isAllowedPath(folders, join(root, 'projA'))).toBe(true); + expect(isAllowedPath(folders, root)).toBe(true); + }); + it('rejects a sibling that merely shares a prefix', () => { + expect(isAllowedPath(folders, resolve(sep, 'work2', 'projA'))).toBe(false); + }); + it('matches a repo entry only by exact path, not its children', () => { + expect(isAllowedPath(folders, repo)).toBe(true); + expect(isAllowedPath(folders, join(repo, 'sub'))).toBe(false); + }); + it('rejects anything when no folders are configured', () => { + expect(isAllowedPath([], join(root, 'projA'))).toBe(false); + }); +}); diff --git a/src/shared/pathGuard.ts b/src/shared/pathGuard.ts new file mode 100644 index 0000000..6d6aa3d --- /dev/null +++ b/src/shared/pathGuard.ts @@ -0,0 +1,13 @@ +import { resolve, sep } from 'node:path'; +import type { Folder } from './types'; + +/** A renderer-supplied path is safe to act on only if it is the exact path of, or lives under, a configured folder. */ +export function isAllowedPath(folders: Folder[], incoming: string): boolean { + const r = resolve(incoming); + return folders.some((f) => { + const base = resolve(f.path); + return f.kind === 'repo' + ? r === base + : r === base || r.startsWith(base + sep); + }); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index d3482da..b085d41 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -4,6 +4,11 @@ export type AgentId = 'claude' | 'codex'; export type Language = 'ko' | 'en' | 'ja' | 'zh'; +export interface Folder { + path: string; + kind: 'root' | 'repo'; +} + export interface ModelUsage { model: string; totals: UsageTotals; costEstimate: number | null; } export interface ProjectUsage { path: string; name: string; sessions: number;