From 2c74724f7052784ea8b9a5b34bcd7c9d15417e34 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 01:14:36 -0700 Subject: [PATCH 1/2] Inline @-mentioned files outside the workspace instead of blocking them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An @-mention is the operator asking the agent to read one path, once, right now — the same consent that already covers workspace files. Outside-workspace mentions now resolve and inline like any other mention, gated only by the sensitive-path and size checks that already applied. Nothing about the mention persists past that one read: no grant is minted, nothing is written to settings, and later reads of the same path still go through the permission gate on its own terms. --- src/permission/path-restriction.test.ts | 12 ++++ src/tui/mention-resolution.ts | 60 +++++++------------- tests/unit/tui/at-mention-resolution.test.ts | 57 ++++++++++++++++--- 3 files changed, 82 insertions(+), 47 deletions(-) diff --git a/src/permission/path-restriction.test.ts b/src/permission/path-restriction.test.ts index 6bd0f075f..8d677dab5 100644 --- a/src/permission/path-restriction.test.ts +++ b/src/permission/path-restriction.test.ts @@ -47,3 +47,15 @@ test("workspace-relative paths are unrestricted", () => { expect(r.isRestricted("src/index.ts", false)).toBe(false); expect(r.isRestricted("src/index.ts", true)).toBe(false); }); + +test("a directory sharing a string prefix with the workspace root is still restricted", async () => { + // "baz" shares a string prefix with cwd but is a distinct sibling + // directory outside the workspace — the boundary check must not leak into + // it via naive string prefixing. + const prefixSibling = `${cwd}baz`; + await mkdir(prefixSibling, { recursive: true }); + const r = createPathRestriction(cwd, () => [], home); + + expect(r.isRestricted(prefixSibling, false)).toBe(true); + expect(r.isRestricted(join(prefixSibling, "file.txt"), false)).toBe(true); +}); diff --git a/src/tui/mention-resolution.ts b/src/tui/mention-resolution.ts index 901d0af08..271c47900 100644 --- a/src/tui/mention-resolution.ts +++ b/src/tui/mention-resolution.ts @@ -1,8 +1,6 @@ import { readFile, opendir, realpath, stat } from "node:fs/promises"; import { resolve, isAbsolute } from "node:path"; import { isSensitivePath } from "../plugins/secret-guard-plugin.js"; -import { createPathRestriction, type PathRestriction } from "../permission/path-restriction.js"; -import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; const MAX_MENTION_FILE_BYTES = 200_000; const MAX_MENTION_TOTAL_BYTES = 400_000; @@ -10,29 +8,6 @@ const MAX_MENTION_COUNT = 5; const MAX_DIRECTORY_SUMMARY_ENTRIES = 200; const MAX_DIRECTORY_NAMES = 20; -async function resolveMentionPath( - cwd: string, - path: string, - pathRestriction: PathRestriction, -): Promise<{ ok: true; abs: string } | { ok: false; reason: string }> { - if (path === "~" || path.startsWith("~/")) { - return { ok: false, reason: "home-relative paths are not supported" }; - } - - let abs: string; - try { - abs = await realpath(isAbsolute(path) ? path : resolve(cwd, path)); - } catch { - return { ok: false, reason: "not found" }; - } - - if (pathRestriction.isRestricted(abs, false)) { - return { ok: false, reason: "outside workspace" }; - } - - return { ok: true, abs }; -} - async function summarizeDir(abs: string): Promise { let scanned = 0; let files = 0; @@ -58,6 +33,13 @@ async function summarizeDir(abs: string): Promise { return parts.length > 0 ? parts.join(", ") : "empty directory"; } +// An @mention is the operator directly asking the agent to read one path, once, +// right now — the same consent that already lets the agent read any workspace +// file. There is no workspace-boundary check here: mentioning a path outside +// the workspace inlines it exactly like a workspace path would, gated only by +// the sensitive-path and size checks below. Nothing here authorizes a *later* +// read of the same path — that still goes through the permission gate on its +// own terms, and an @mention grants it no standing there. export async function resolveAtMentions(message: string, cwd: string): Promise { const pattern = /@("([^"]+)"|(\S+))/g; const mentions: Array<{ full: string; path: string }> = []; @@ -68,11 +50,6 @@ export async function resolveAtMentions(message: string, cwd: string): Promise = []; let totalBytes = 0; @@ -81,23 +58,30 @@ export async function resolveAtMentions(message: string, cwd: string): Promise { } }); - test("blocks symlinks that resolve outside the workspace", async () => { + test("inlines symlinked outside-workspace files", async () => { const dir = await fixture(); const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); try { @@ -118,15 +118,14 @@ describe("resolveAtMentions", () => { await symlink(outside, join(dir, "escape")); const resolved = await resolveAtMentions("read @escape/outside.txt", dir); - expect(resolved).toContain("@escape/outside.txt (blocked: outside workspace)"); - expect(resolved).not.toContain("outside content"); + expect(resolved).toContain("outside content"); } finally { await rm(dir, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } }); - test("blocks absolute paths outside the workspace", async () => { + test("inlines absolute outside-workspace paths", async () => { const dir = await fixture(); const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); try { @@ -134,15 +133,15 @@ describe("resolveAtMentions", () => { await writeFile(outsideFile, "outside content\n"); const resolved = await resolveAtMentions(`read @${outsideFile}`, dir); - expect(resolved).toContain(`@${outsideFile} (blocked: outside workspace)`); - expect(resolved).not.toContain("outside content"); + expect(resolved).toContain(`\`${outsideFile}\`:`); + expect(resolved).toContain("outside content"); } finally { await rm(dir, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } }); - test("blocks parent-traversal paths that escape the workspace", async () => { + test("inlines parent-traversal outside-workspace paths", async () => { const dir = await fixture(); const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); try { @@ -150,14 +149,54 @@ describe("resolveAtMentions", () => { const traversal = `../../${outside.split("/").pop() ?? ""}/outside.txt`; const resolved = await resolveAtMentions(`read @${traversal}`, join(dir, "src")); - expect(resolved).toContain(`@${traversal} (blocked: outside workspace)`); - expect(resolved).not.toContain("outside content"); + expect(resolved).toContain("outside content"); } finally { await rm(dir, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } }); + test("summarizes outside-workspace directories", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); + try { + await writeFile(join(outside, "a.txt"), "a\n"); + await mkdir(join(outside, "sub")); + + const resolved = await resolveAtMentions(`read @${outside}`, dir); + expect(resolved).toContain("directory - "); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + test("still blocks sensitive outside-workspace paths", async () => { + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); + try { + const outsideEnv = join(outside, ".env"); + await writeFile(outsideEnv, "API_KEY=secret\n"); + + const resolved = await resolveAtMentions(`read @${outsideEnv}`, dir); + expect(resolved).toContain("(blocked: sensitive path)"); + expect(resolved).not.toContain("API_KEY=secret"); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + test("blocks home-relative paths", async () => { + const dir = await fixture(); + try { + const resolved = await resolveAtMentions("read @~/some-file.txt", dir); + expect(resolved).toContain("(blocked: home-relative paths are not supported)"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + test("inlines mentions into a sibling git worktree of the same session", async () => { const repo = await mkdtemp(join(tmpdir(), "at-mention-resolution-repo-")); const worktree = await mkdtemp(join(tmpdir(), "at-mention-resolution-worktree-")); From 7cc2d24c9f21c0bd12bd209d953e8e0ba79236f5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 01:28:04 -0700 Subject: [PATCH 2/2] Extend the sensitive-path denylist for reachable outside-workspace files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @-mentioning a file outside the workspace now inlines it, so the workspace boundary no longer stands between an outside path and the sensitivity check — that check has to carry the full weight alone. Add shell histories, /etc/shadow and /etc/sudoers, macOS Keychain databases, browser cookie jars and saved-login stores, and cloud credential files beyond AWS (gcloud config dir, Azure CLI cache). --- src/plugins/secret-guard-plugin.test.ts | 33 ++++++++++++++++++++ src/plugins/secret-guard-plugin.ts | 32 +++++++++++++++++++ tests/unit/tui/at-mention-resolution.test.ts | 22 +++++++++++++ 3 files changed, 87 insertions(+) diff --git a/src/plugins/secret-guard-plugin.test.ts b/src/plugins/secret-guard-plugin.test.ts index dce2868a9..6b7f4330b 100644 --- a/src/plugins/secret-guard-plugin.test.ts +++ b/src/plugins/secret-guard-plugin.test.ts @@ -45,6 +45,29 @@ describe("isSensitivePath", () => { "my-project_service_account-key.json", ".corbits/settings.json", "/Users/me/.corbits/settings.json", + // Shell histories. + "/home/me/.bash_history", + ".zsh_history", + ".sh_history", + "/home/me/.local/share/fish/fish_history", + // System account and privilege files. + "/etc/shadow", + "/etc/sudoers", + "/etc/sudoers.d/90-cloud-init-users", + // macOS Keychain. + "/Users/me/Library/Keychains/login.keychain-db", + "backup.keychain", + // Browser cookie jars and saved-login stores. + "/Users/me/Library/Application Support/Google/Chrome/Default/Cookies", + "/Users/me/Library/Application Support/Google/Chrome/Default/Login Data", + "/home/me/.mozilla/firefox/abc123.default/cookies.sqlite", + "/home/me/.mozilla/firefox/abc123.default/logins.json", + "/home/me/.mozilla/firefox/abc123.default/key4.db", + // Cloud credentials beyond AWS. + "/home/me/.config/gcloud/legacy_credentials/me@example.com/adc.json", + "/home/me/.config/gcloud/credentials.db", + "/home/me/.azure/accessTokens.json", + "/home/me/.azure/azureProfile.json", ]; for (const p of sensitive) { test(`flags ${p}`, () => expect(isSensitivePath(p)).toBe(true)); @@ -62,6 +85,16 @@ describe("isSensitivePath", () => { "keystore.md", "account.json", "src/keyboard.ts", + // Near-misses for the new patterns: plausible legitimate filenames that + // share a word or extension with a sensitive pattern but aren't the + // sensitive file itself. + "docs/bash_history_format.md", + "src/keychain-helper.ts", + "test/fixtures/cookies.json", + "src/etc/shadow-dom.ts", + "docs/sudoers-explained.md", + "src/gcloud-deploy.ts", + "src/azure-profile-view.tsx", ]; for (const p of ok) { test(`allows ${p}`, () => expect(isSensitivePath(p)).toBe(false)); diff --git a/src/plugins/secret-guard-plugin.ts b/src/plugins/secret-guard-plugin.ts index 38f4a9081..24f2eec11 100644 --- a/src/plugins/secret-guard-plugin.ts +++ b/src/plugins/secret-guard-plugin.ts @@ -42,6 +42,38 @@ const SENSITIVE_PATTERNS: RegExp[] = [ /\.tfstate(\.backup)?$/, // GCP service-account key files, e.g. service-account.json, my-service_account-key.json. /service[-_]account[^/]*\.json$/, + // Shell history files. Operators paste secrets into interactive shells + // constantly (export TOKEN=…, curl -H "Authorization: …", psql with an + // inline password); the history file is a durable log of that. Previously + // the workspace boundary kept ~/.bash_history etc. out of reach even though + // this list didn't cover it. Now that @mentions can reach outside the + // workspace, the list has to carry that weight itself. + /(^|\/)\.bash_history$/, + /(^|\/)\.zsh_history$/, + /(^|\/)\.sh_history$/, + /(^|\/)fish_history$/, + // System account and privilege files. Not "secrets" in the API-key sense, + // but /etc/shadow is password hashes and /etc/sudoers is the privilege + // escalation policy — both are direct system-compromise material. + /(^|\/)etc\/shadow$/, + /(^|\/)etc\/sudoers(\.d\/.*)?$/, + // macOS Keychain databases — every saved Wi-Fi password, website login, and + // app credential on the machine lives here. + /(^|\/)Library\/Keychains\//, + /\.keychain(-db)?$/, + // Browser cookie jars and saved-login stores. A cookie store alone is often + // enough to hijack an authenticated session without ever seeing a password. + /(^|\/)Cookies$/, // Chrome/Chromium/Edge profile cookie DB (no extension) + /(^|\/)Login Data$/, // Chrome/Chromium/Edge saved passwords DB (no extension) + /(^|\/)cookies\.sqlite$/, // Firefox + /(^|\/)logins\.json$/, // Firefox saved logins + /(^|\/)key4\.db$/, // Firefox's key store for the above + // Broaden the existing single-file gcloud pattern to the whole config + // directory — legacy_credentials/, credentials.db, and access_tokens.db + // all live alongside application_default_credentials.json there. + /(^|\/)\.config\/gcloud\//, + // Azure CLI's credential cache — the equivalent of ~/.aws/credentials. + /(^|\/)\.azure\/(accessTokens|azureProfile)\.json$/, ]; export function isSensitivePath(value: string): boolean { diff --git a/tests/unit/tui/at-mention-resolution.test.ts b/tests/unit/tui/at-mention-resolution.test.ts index 54e1917f0..aefc17076 100644 --- a/tests/unit/tui/at-mention-resolution.test.ts +++ b/tests/unit/tui/at-mention-resolution.test.ts @@ -187,6 +187,28 @@ describe("resolveAtMentions", () => { } }); + test("blocks a symlink that points outside the workspace at a sensitive file", async () => { + // Combines the two cases the other tests exercise separately: the symlink + // test above targets a sensitive file *inside* the workspace, and the + // outside-workspace sensitivity test above uses a direct path. Realpath + // must resolve the symlink before the sensitivity check runs regardless + // of which boundary (workspace, sensitivity) the target crosses. + const dir = await fixture(); + const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-")); + try { + const outsideKey = join(outside, "id_rsa"); + await writeFile(outsideKey, "-----BEGIN OPENSSH PRIVATE KEY-----\n"); + await symlink(outsideKey, join(dir, "looks-like-a-normal-file")); + + const resolved = await resolveAtMentions("read @looks-like-a-normal-file", dir); + expect(resolved).toContain("(blocked: sensitive path)"); + expect(resolved).not.toContain("BEGIN OPENSSH PRIVATE KEY"); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + test("blocks home-relative paths", async () => { const dir = await fixture(); try {