From 12f513e34bbf435dd546e93766fcebe71ab563a3 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 23 Aug 2026 11:13:25 +0700 Subject: [PATCH 1/2] fix: never crawl the home directory for a file index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS the desktop app asked for access to Downloads, Photos, Desktop and the Music library. The file-search index walks everything under its location, and two paths rooted that location in the home directory: the project picker's bare-word search runs against its `~` base, and requests naming no directory fall back to process.cwd(), which the sidecar inherited as `~` from the main process. The walk opens files inside every protected folder, and macOS prompts for each (confirmed in the TCC database; reproduced against a decoy home, where one directory-less find request read sentinels inside Downloads and the photos library bundle). Three changes close it off: - core: unindexableDirectory (home per Global.Path.home, or a filesystem root) routes the search service to a new shallowLayer — find answers from one readdir of the location itself, glob/grep keep their on-demand ripgrep behaviour (extracted into a shared ripgrepGlobGrep; ripgrepLayer is unchanged in behaviour). - app: the picker's bare-word search matches direct children via the existing file.list path when its base is home or a root, instead of a recursive find.files — same answers against older servers too. - desktop: chdir to an empty app-owned folder (userData/cwd) instead of homedir(), so the cwd fallback has nothing to crawl. Still not `/`, which is what the original chdir was avoiding for ripgrep. With the guard in place the decoy-home request returns home's direct children and no sentinel is touched. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 +++ DIVERGENCES.md | 2 + .../directory-picker-domain.test.ts | 59 ++++++ .../src/components/directory-picker-domain.ts | 13 ++ packages/core/src/filesystem/search.ts | 192 +++++++++++++----- packages/core/test/filesystem/search.test.ts | 65 ++++++ packages/desktop/src/main/index.ts | 20 +- 7 files changed, 315 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4fb04287..35421243db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ file is the human-readable summary of what changed and why. ## Unreleased +### Privacy + +- **CoDev no longer asks for access to your Downloads, Photos, Desktop, or + Music library on macOS** — the app's file search indexed whatever folder a + request was rooted in, and two paths rooted it in your home directory: + typing a bare name into the project picker (its browser starts at `~`), and + any internal request that named no folder, which fell back to the app's + working directory — also `~`. Building that index walks everything under it, + and a walk of your home directory opens files inside Downloads, Desktop, and + the photo and music libraries, each of which macOS answers with a permission + prompt. None of it had anything to do with your session: the crawl was rooted + somewhere you never chose. Three changes close it off. The server now refuses + to index the home directory or a filesystem root — searches there answer + with the folder's own entries, read without stepping inside any of them; the + picker matches a bare name against the folders directly under `~` instead of + searching the whole disk; and the app parks its working directory in an empty + folder of its own instead of `~`, so directory-less requests have nothing to + crawl. Browsing into a protected folder yourself still asks, once, as it + should — that prompt is about a folder you chose. Permissions you already + granted can be revoked in System Settings → Privacy & Security; CoDev no + longer needs any of them. + ([#PR](https://github.com/quickbeard/codev-code/pull/PR)) + ### Install - **Installing downloads about half as much** — the published platform packages diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 267f88b633..39bcb9720a 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -300,6 +300,8 @@ from the squash message first, or use a plain merge. | `packages/app/src/pages/layout-new.tsx` (update prompt) | One import and one element: mounts the fork-only `src/components/dialog-update.tsx`, whose `` renders nothing and opens the update dialog the first time the desktop updater reports `available` or `ready`. | The main process has always checked for updates at launch (`updater.start()`), but on the new layout the only thing that ever said so was a small Update button in the titlebar, and only once the download had finished. Users went weeks without noticing a waiting update. The legacy layout's `UpdateAvailableToast` in `pages/layout.tsx` is unreachable — `newLayoutDesigns` is always true. | Keep the import and the element; take upstream's other changes. If upstream reworks this file's layout, the element can sit anywhere inside the shell: it renders nothing. | +| Home-directory crawl guard: `packages/core/src/filesystem/search.ts`, `packages/app/src/components/directory-picker-domain.ts`, `packages/desktop/src/main/index.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/app/src/components/directory-picker-domain.test.ts` | search.ts: new exported `unindexableDirectory(directory)` (true for `Global.Path.home` — honoring `CODEV_TEST_HOME` — and filesystem roots) and a new `shallowLayer`; the layer chooser becomes `Layer.unwrap(Effect.gen(...))` reading `Location.Service` and picks `shallowLayer` for unindexable directories, fff/ripgrep otherwise. `shallowLayer.find` answers from one `readDirectoryEntries` of the location itself (direct children only, symlinks/specials dropped, same fuzzysort/empty-query/limit semantics as the index layers); its glob/grep are the on-demand ripgrep implementations, extracted from `ripgrepLayer` into a shared `ripgrepGlobGrep` effect (ripgrepLayer now spreads it — no behavior change there). directory-picker-domain.ts: `createDirectorySearch`'s bare-word branch (no `/`, `~`, or drive root in the input) matches direct children via the existing `file.list`-based `match()` when the base is the picker's home or a filesystem root, instead of `find.files({directory: base})`. desktop index.ts: the startup `process.chdir(homedir())` becomes `process.chdir(join(app.getPath("userData"), "cwd"))` (mkdir first, after `app.setPath("userData", ...)` so it lands under the channel's own folder; still inside try/catch, `homedir` import dropped). | Materializing the search service walks everything under its location — fff at creation, ripgrep as an eagerly forked scan. Rooted at `~` that walk opens files inside Downloads, Desktop, Documents and the photo/music library bundles, so macOS raised a permission prompt for each (confirmed in the TCC database: Desktop, Media Library, Downloads granted; Photos denied — the last two one second apart, one walk). The desktop app reached a home-rooted location two ways: the picker's bare-word search runs `find.files` against its `~` base, and any server request naming no directory falls back to `process.cwd()` (workspace-routing), which the sidecar inherited as `~` from the main process's chdir. Reproduced end to end against a decoy home: one directoryless `find/file` request read sentinel files inside `Downloads/` and `Photos Library.photoslibrary/`; with the guard the same request returns home's direct children and no sentinel is touched. | Keep all three pieces and take upstream's surrounding changes. If upstream reworks the search layers, preserve the invariant: no eager index (fff create or ripgrep scan) is ever built for the home directory or a filesystem root — `find` there must answer from a single readdir of the location itself. If upstream moves the desktop chdir, keep it pointed at an app-owned empty folder, never `homedir()` (and never leave it in `/`, which breaks ripgrep). The picker guard is belt-and-braces for older/remote servers; do not "simplify" it away because the server also guards. | + ## Known rebrand gaps - **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 5746410610..b42819e3e9 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -223,3 +223,62 @@ test("returns absolute directories and relative files", () => { expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts") expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined() }) + +test("a bare word over the home directory matches its children instead of searching the disk", async () => { + const finds: string[] = [] + const listed: string[] = [] + const sdk = { + client: { + find: { + files: (input: { directory: string }) => { + finds.push(input.directory) + return Promise.resolve({ data: [] }) + }, + }, + file: { + list: (input: { directory: string }) => { + listed.push(input.directory) + return Promise.resolve({ + data: [ + { name: "works", type: "directory", absolute: "/home/luke/works" }, + { name: "Downloads", type: "directory", absolute: "/home/luke/Downloads" }, + { name: "note.md", type: "file", absolute: "/home/luke/note.md" }, + ], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + + const results = await search("wor") + + // The recursive server-side search would index everything under `~`; the + // shallow listing of `~` itself is the whole point of the guard. + expect(finds).toEqual([]) + expect(listed).toEqual(["/home/luke"]) + expect(results).toEqual(["/home/luke/works"]) +}) + +test("a bare word over a filesystem root also stays shallow", async () => { + const finds: string[] = [] + const sdk = { + client: { + find: { + files: (input: { directory: string }) => { + finds.push(input.directory) + return Promise.resolve({ data: [] }) + }, + }, + file: { + list: () => Promise.resolve({ data: [{ name: "Users", type: "directory", absolute: "/Users" }] }), + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/" }) + + const results = await search("use") + + expect(finds).toEqual([]) + expect(results).toEqual(["/Users"]) +}) diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9900265962..046666a10a 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -371,6 +371,19 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { + // A recursive directory search rooted at the home directory (the + // picker's usual base) makes the server index every file under it — on + // macOS that walk reads inside Downloads and the photo library, and the + // OS answers with a permission prompt for each. The server refuses to + // index such roots (FileSystemSearch.unindexableDirectory); match its + // shallow answer here so a bare word typed over `~` completes against + // its direct children on old servers too, instead of the whole disk. + const base = trimPickerPath(input.directory) + if (base === trimPickerPath(args.home()) || base === pickerRoot(base)) { + const results = await match(base, query, 50) + if (!active()) return [] + return results + } const results = await args.sdk.client.find .files({ directory: input.directory, query, type: "directory", limit: 50 }) .then((result) => result.data ?? []) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index f186835975..799e7b2bc4 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -11,6 +11,7 @@ import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { Flag } from "../flag/flag" +import { Global } from "../global" export interface Interface { readonly find: (input: FileSystem.FindInput) => Effect.Effect @@ -20,13 +21,89 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/FileSystem/Search") {} +// A directory whose file index must never be built: the home directory and +// filesystem roots. Materializing the search service walks everything under +// its location — fff at creation, ripgrep as an eagerly forked scan — and a +// walk of the home directory opens files inside Downloads, Desktop, Documents +// and the photo/music library bundles, which macOS answers with a permission +// prompt for each. The desktop app hits this whenever a search lands on a +// location it never deliberately chose (the picker's `~` base, or the +// cwd fallback for requests that name no directory). +export function unindexableDirectory(directory: string) { + const resolved = path.resolve(directory) + if (resolved === path.parse(resolved).root) return true + return resolved === path.resolve(Global.Path.home) +} + +// glob and grep run ripgrep on demand against the requested path; only find +// answers from an eager index. Shared between ripgrepLayer and the shallow +// layer for unindexable directories, which keeps these and drops the index. +const ripgrepGlobGrep = Effect.gen(function* () { + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const ripgrep = yield* Ripgrep.Service + return { + glob: (input: FileSystem.GlobInput) => + Effect.gen(function* () { + const target = path.resolve(location.directory, input.path ?? ".") + const info = yield* fs.stat(target).pipe(Effect.orDie) + const cwd = info.type === "File" ? path.dirname(target) : target + return yield* ripgrep + .glob({ + cwd, + pattern: input.pattern, + limit: input.limit ?? Number.MAX_SAFE_INTEGER, + }) + .pipe( + Effect.map((result) => + result.map((entry) => + FileSystem.Entry.make({ + ...entry, + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), + }), + ), + ), + Effect.orDie, + ) + }), + grep: (input: FileSystem.GrepInput) => + Effect.gen(function* () { + const target = path.resolve(location.directory, input.path ?? ".") + const info = yield* fs.stat(target).pipe(Effect.orDie) + const cwd = info.type === "File" ? path.dirname(target) : target + return yield* ripgrep + .grep({ + cwd, + pattern: input.pattern, + file: info.type === "File" ? path.basename(target) : undefined, + include: input.include, + limit: input.limit ?? Number.MAX_SAFE_INTEGER, + }) + .pipe( + Effect.map((result) => + result.map((match) => + FileSystem.Match.make({ + ...match, + entry: FileSystem.Entry.make({ + ...match.entry, + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))), + }), + }), + ), + ), + Effect.orDie, + ) + }), + } +}) + export const ripgrepLayer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service const location = yield* Location.Service const ripgrep = yield* Ripgrep.Service const scope = yield* Scope.Scope + const base = yield* ripgrepGlobGrep const state = { files: [] as string[], directories: [] as string[], @@ -60,57 +137,7 @@ export const ripgrepLayer = Layer.effect( Effect.forkIn(scope), ) return Service.of({ - glob: (input) => - Effect.gen(function* () { - const target = path.resolve(location.directory, input.path ?? ".") - const info = yield* fs.stat(target).pipe(Effect.orDie) - const cwd = info.type === "File" ? path.dirname(target) : target - return yield* ripgrep - .glob({ - cwd, - pattern: input.pattern, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, - }) - .pipe( - Effect.map((result) => - result.map((entry) => - FileSystem.Entry.make({ - ...entry, - path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), - }), - ), - ), - Effect.orDie, - ) - }), - grep: (input) => - Effect.gen(function* () { - const target = path.resolve(location.directory, input.path ?? ".") - const info = yield* fs.stat(target).pipe(Effect.orDie) - const cwd = info.type === "File" ? path.dirname(target) : target - return yield* ripgrep - .grep({ - cwd, - pattern: input.pattern, - file: info.type === "File" ? path.basename(target) : undefined, - include: input.include, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, - }) - .pipe( - Effect.map((result) => - result.map((match) => - FileSystem.Match.make({ - ...match, - entry: FileSystem.Entry.make({ - ...match.entry, - path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))), - }), - }), - ), - ), - Effect.orDie, - ) - }), + ...base, find: (input) => Effect.gen(function* () { const items = @@ -259,7 +286,66 @@ export const fffLayer = Layer.effect( }), ) -const layer = Layer.unwrap(Effect.sync(() => (Flag.CODEV_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) +// What an unindexable directory gets instead of an index: find matches the +// location's direct children only, read with a single readdir of the location +// itself — which touches no protected folder. Deeper navigation stays with the +// callers (the pickers list level by level as the user drills in), and glob +// and grep keep their on-demand ripgrep behaviour: they run only when a caller +// explicitly asks for that path, never as a side effect of materializing the +// service. +export const shallowLayer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const base = yield* ripgrepGlobGrep + return Service.of({ + ...base, + find: (input) => + Effect.gen(function* () { + // Symlinks and specials are dropped rather than resolved: telling a + // directory link from a file link takes a stat of the target, which + // for a link out of `~` could itself land in a protected folder. + const children = yield* fs + .readDirectoryEntries(location.directory) + .pipe(Effect.catch(() => Effect.succeed([]))) + const items = children + .filter((child) => + input.type === "file" + ? child.type === "file" + : input.type === "directory" + ? child.type === "directory" + : child.type === "file" || child.type === "directory", + ) + .map((child) => (child.type === "directory" ? child.name + "/" : child.name)) + const entry = (relative: string) => + FileSystem.Entry.make({ + path: RelativePath.make(relative), + type: relative.endsWith("/") ? "directory" : "file", + }) + // Same empty-query behaviour as the indexed layers: top entries. + if (!input.query) return items.slice(0, input.limit ?? 50).map(entry) + const query = input.query + .split("\\") + .join("/") + .replace(/\/{2,}/g, "/") + return fuzzysort.go(query, items, { limit: input.limit ?? 50 }).map((item) => entry(item.target)) + }), + }) + }), +) + +const layer = Layer.unwrap( + Effect.gen(function* () { + const location = yield* Location.Service + // The home directory and filesystem roots never get an index — building + // one walks everything under them, which on macOS opens files inside + // Downloads and the photo/music libraries and triggers a permission + // prompt for each. See unindexableDirectory above. + if (unindexableDirectory(location.directory)) return shallowLayer + return Flag.CODEV_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer + }), +) export const locationLayer = layer diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 4a8e11f2d6..a2aafd0d14 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -178,3 +178,68 @@ describe("FileSystemSearch ripgrep fallback", () => { }), ) }) + +const shallowSearchLayer = (directory: AbsolutePath) => + FileSystemSearch.shallowLayer.pipe( + Layer.provide( + Layer.mergeAll( + LayerNode.compile(FSUtil.node), + LayerNode.compile(Ripgrep.node), + Layer.succeed(Location.Service, Location.Service.of(location({ directory }))), + ), + ), + ) + +describe("FileSystemSearch home guard", () => { + it.live("refuses to index the home directory and filesystem roots", () => + withTmp((cwd) => + Effect.sync(() => { + const previous = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = cwd + try { + expect(FileSystemSearch.unindexableDirectory(cwd)).toBe(true) + // Trailing slashes and dot segments must not defeat the comparison. + expect(FileSystemSearch.unindexableDirectory(path.join(cwd, "."))).toBe(true) + expect(FileSystemSearch.unindexableDirectory(path.join(cwd, "sub"))).toBe(false) + expect(FileSystemSearch.unindexableDirectory(path.parse(path.resolve("/")).root)).toBe(true) + } finally { + if (previous === undefined) delete process.env.CODEV_TEST_HOME + else process.env.CODEV_TEST_HOME = previous + } + }), + ), + ) + + it.live("find answers with direct children only, without walking deeper", () => + withTmp((cwd) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(cwd, "Downloads"))) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "Downloads", "secret.txt"), "x\n")) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "note.md"), "x\n")) + const search = yield* FileSystemSearch.Service + const empty = yield* search.find(FileSystem.FindInput.make({ query: "" })) + expect(empty.map((entry) => entry.path).sort()).toEqual([ + RelativePath.make("Downloads/"), + RelativePath.make("note.md"), + ]) + // The nested file is invisible: nothing ever descended to see it. + const nested = yield* search.find(FileSystem.FindInput.make({ query: "secret" })) + expect(nested).toHaveLength(0) + const directories = yield* search.find(FileSystem.FindInput.make({ query: "down", type: "directory" })) + expect(directories.map((entry) => entry.path)).toEqual([RelativePath.make("Downloads/")]) + }).pipe(Effect.provide(shallowSearchLayer(cwd))), + ), + ) + + it.live("glob still answers on demand for an explicitly requested path", () => + withTmp((cwd) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(cwd, "sub"))) + yield* Effect.promise(() => fs.writeFile(path.join(cwd, "sub", "match.ts"), "x\n")) + const search = yield* FileSystemSearch.Service + const found = yield* search.glob(FileSystem.GlobInput.make({ pattern: "**/*.ts" })) + expect(found.map((entry) => entry.path)).toEqual([RelativePath.make("sub/match.ts")]) + }).pipe(Effect.provide(shallowSearchLayer(cwd))), + ), + ) +}) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 6f4d1eb1ab..c356b7f44c 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto" import { mkdirSync, rmSync } from "node:fs" import * as http from "node:http" import { createServer } from "node:net" -import { homedir, tmpdir } from "node:os" +import { tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" @@ -119,11 +119,6 @@ function ensureLoopbackNoProxy() { const main = Effect.gen(function* () { contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) - // on macOS apps run in `/` which can cause issues with ripgrep - try { - process.chdir(homedir()) - } catch {} - process.env.CODEV_DISABLE_EMBEDDED_WEB_UI = "true" const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.codev.desktop.dev" @@ -150,6 +145,19 @@ const main = Effect.gen(function* () { ) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) initializeOldLayoutEligibility(app.getPath("userData")) + // Out of `/` (where macOS launches apps, which breaks ripgrep) but not into + // the home directory: the sidecar inherits this cwd, and server requests + // that name no directory fall back to it — a cwd of `~` had those requests + // boot an instance over the whole home directory, whose file indexing then + // read inside Downloads and the photo library and made macOS ask for + // permission to each. An empty folder of our own gives that fallback + // nothing to crawl. After setPath so it lands under the channel's own + // userData, not the default Electron one. + try { + const fallbackCwd = join(app.getPath("userData"), "cwd") + mkdirSync(fallbackCwd, { recursive: true }) + process.chdir(fallbackCwd) + } catch {} logger = initLogging() initCrashReporter() From 7dfb40d69888ce2053927b71048bf87ef03c87d6 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 23 Aug 2026 11:14:09 +0700 Subject: [PATCH 2/2] docs: link the changelog entry to #306 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35421243db..de113c6ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ file is the human-readable summary of what changed and why. should — that prompt is about a folder you chose. Permissions you already granted can be revoked in System Settings → Privacy & Security; CoDev no longer needs any of them. - ([#PR](https://github.com/quickbeard/codev-code/pull/PR)) + ([#306](https://github.com/quickbeard/codev-code/pull/306)) ### Install