Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/permission/path-restriction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
// "<cwd>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);
});
33 changes: 33 additions & 0 deletions src/plugins/secret-guard-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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));
Expand Down
32 changes: 32 additions & 0 deletions src/plugins/secret-guard-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
60 changes: 22 additions & 38 deletions src/tui/mention-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,13 @@
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;
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<string> {
let scanned = 0;
let files = 0;
Expand All @@ -58,6 +33,13 @@ async function summarizeDir(abs: string): Promise<string> {
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<string> {
const pattern = /@("([^"]+)"|(\S+))/g;
const mentions: Array<{ full: string; path: string }> = [];
Expand All @@ -68,11 +50,6 @@ export async function resolveAtMentions(message: string, cwd: string): Promise<s
}
if (mentions.length === 0) return message;

// Mirrors the permission gate's own containment check (see gate.ts): the
// gate resolves paths against cwd plus every registered git worktree of
// this session, so an @mention into a sibling worktree must resolve the
// same way rather than being wrongly rejected as an escape.
const pathRestriction = createPathRestriction(cwd, createWorktreeRootsProvider(cwd));
const replacements: Array<{ full: string; replacement: string }> = [];
let totalBytes = 0;

Expand All @@ -81,23 +58,30 @@ export async function resolveAtMentions(message: string, cwd: string): Promise<s
replacements.push({ full, replacement: `${full} (blocked: too many @mentions; max ${MAX_MENTION_COUNT})` });
continue;
}
if (path === "~" || path.startsWith("~/")) {
replacements.push({ full, replacement: `${full} (blocked: home-relative paths are not supported)` });
continue;
}
if (isSensitivePath(path)) {
replacements.push({ full, replacement: `${full} (blocked: sensitive path)` });
continue;
}
const resolved = await resolveMentionPath(cwd, path, pathRestriction);
if (!resolved.ok) {
replacements.push({ full, replacement: `${full} (blocked: ${resolved.reason})` });
let abs: string;
try {
abs = await realpath(isAbsolute(path) ? path : resolve(cwd, path));
} catch {
replacements.push({ full, replacement: `${full} (not found)` });
continue;
}
if (isSensitivePath(resolved.abs)) {
if (isSensitivePath(abs)) {
replacements.push({ full, replacement: `${full} (blocked: sensitive path)` });
continue;
}

try {
const info = await stat(resolved.abs);
const info = await stat(abs);
if (info.isDirectory()) {
const summary = await summarizeDir(resolved.abs);
const summary = await summarizeDir(abs);
replacements.push({ full, replacement: `\`${path}\` (directory - ${summary})` });
continue;
}
Expand All @@ -109,9 +93,9 @@ export async function resolveAtMentions(message: string, cwd: string): Promise<s
replacements.push({ full, replacement: `${full} (blocked: total @mention content is too large; max ${MAX_MENTION_TOTAL_BYTES} bytes)` });
continue;
}
const content = await readFile(resolved.abs, "utf-8");
const content = await readFile(abs, "utf-8");
totalBytes += info.size;
const ext = resolved.abs.split(".").pop() ?? "";
const ext = abs.split(".").pop() ?? "";
replacements.push({ full, replacement: `\`${path}\`:\n\`\`\`${ext}\n${content}\n\`\`\`` });
} catch {
replacements.push({ full, replacement: `${full} (not found)` });
Expand Down
79 changes: 70 additions & 9 deletions tests/unit/tui/at-mention-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,54 +110,115 @@ describe("resolveAtMentions", () => {
}
});

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 {
await writeFile(join(outside, "outside.txt"), "outside content\n");
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 {
const outsideFile = join(outside, "outside.txt");
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 {
await writeFile(join(outside, "outside.txt"), "outside content\n");
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 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 {
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-"));
Expand Down
Loading