Skip to content

Commit 74a245e

Browse files
committed
Inline @-mentioned files outside the workspace instead of blocking them
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.
1 parent e143db0 commit 74a245e

3 files changed

Lines changed: 82 additions & 47 deletions

File tree

src/permission/path-restriction.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,15 @@ test("workspace-relative paths are unrestricted", () => {
4747
expect(r.isRestricted("src/index.ts", false)).toBe(false);
4848
expect(r.isRestricted("src/index.ts", true)).toBe(false);
4949
});
50+
51+
test("a directory sharing a string prefix with the workspace root is still restricted", async () => {
52+
// "<cwd>baz" shares a string prefix with cwd but is a distinct sibling
53+
// directory outside the workspace — the boundary check must not leak into
54+
// it via naive string prefixing.
55+
const prefixSibling = `${cwd}baz`;
56+
await mkdir(prefixSibling, { recursive: true });
57+
const r = createPathRestriction(cwd, () => [], home);
58+
59+
expect(r.isRestricted(prefixSibling, false)).toBe(true);
60+
expect(r.isRestricted(join(prefixSibling, "file.txt"), false)).toBe(true);
61+
});

src/tui/mention-resolution.ts

Lines changed: 22 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,13 @@
11
import { readFile, opendir, realpath, stat } from "node:fs/promises";
22
import { resolve, isAbsolute } from "node:path";
33
import { isSensitivePath } from "../plugins/secret-guard-plugin.js";
4-
import { createPathRestriction, type PathRestriction } from "../permission/path-restriction.js";
5-
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
64

75
const MAX_MENTION_FILE_BYTES = 200_000;
86
const MAX_MENTION_TOTAL_BYTES = 400_000;
97
const MAX_MENTION_COUNT = 5;
108
const MAX_DIRECTORY_SUMMARY_ENTRIES = 200;
119
const MAX_DIRECTORY_NAMES = 20;
1210

13-
async function resolveMentionPath(
14-
cwd: string,
15-
path: string,
16-
pathRestriction: PathRestriction,
17-
): Promise<{ ok: true; abs: string } | { ok: false; reason: string }> {
18-
if (path === "~" || path.startsWith("~/")) {
19-
return { ok: false, reason: "home-relative paths are not supported" };
20-
}
21-
22-
let abs: string;
23-
try {
24-
abs = await realpath(isAbsolute(path) ? path : resolve(cwd, path));
25-
} catch {
26-
return { ok: false, reason: "not found" };
27-
}
28-
29-
if (pathRestriction.isRestricted(abs, false)) {
30-
return { ok: false, reason: "outside workspace" };
31-
}
32-
33-
return { ok: true, abs };
34-
}
35-
3611
async function summarizeDir(abs: string): Promise<string> {
3712
let scanned = 0;
3813
let files = 0;
@@ -58,6 +33,13 @@ async function summarizeDir(abs: string): Promise<string> {
5833
return parts.length > 0 ? parts.join(", ") : "empty directory";
5934
}
6035

36+
// An @mention is the operator directly asking the agent to read one path, once,
37+
// right now — the same consent that already lets the agent read any workspace
38+
// file. There is no workspace-boundary check here: mentioning a path outside
39+
// the workspace inlines it exactly like a workspace path would, gated only by
40+
// the sensitive-path and size checks below. Nothing here authorizes a *later*
41+
// read of the same path — that still goes through the permission gate on its
42+
// own terms, and an @mention grants it no standing there.
6143
export async function resolveAtMentions(message: string, cwd: string): Promise<string> {
6244
const pattern = /@("([^"]+)"|(\S+))/g;
6345
const mentions: Array<{ full: string; path: string }> = [];
@@ -68,11 +50,6 @@ export async function resolveAtMentions(message: string, cwd: string): Promise<s
6850
}
6951
if (mentions.length === 0) return message;
7052

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

@@ -81,23 +58,30 @@ export async function resolveAtMentions(message: string, cwd: string): Promise<s
8158
replacements.push({ full, replacement: `${full} (blocked: too many @mentions; max ${MAX_MENTION_COUNT})` });
8259
continue;
8360
}
61+
if (path === "~" || path.startsWith("~/")) {
62+
replacements.push({ full, replacement: `${full} (blocked: home-relative paths are not supported)` });
63+
continue;
64+
}
8465
if (isSensitivePath(path)) {
8566
replacements.push({ full, replacement: `${full} (blocked: sensitive path)` });
8667
continue;
8768
}
88-
const resolved = await resolveMentionPath(cwd, path, pathRestriction);
89-
if (!resolved.ok) {
90-
replacements.push({ full, replacement: `${full} (blocked: ${resolved.reason})` });
69+
let abs: string;
70+
try {
71+
abs = await realpath(isAbsolute(path) ? path : resolve(cwd, path));
72+
} catch {
73+
replacements.push({ full, replacement: `${full} (not found)` });
9174
continue;
9275
}
93-
if (isSensitivePath(resolved.abs)) {
76+
if (isSensitivePath(abs)) {
9477
replacements.push({ full, replacement: `${full} (blocked: sensitive path)` });
9578
continue;
9679
}
80+
9781
try {
98-
const info = await stat(resolved.abs);
82+
const info = await stat(abs);
9983
if (info.isDirectory()) {
100-
const summary = await summarizeDir(resolved.abs);
84+
const summary = await summarizeDir(abs);
10185
replacements.push({ full, replacement: `\`${path}\` (directory - ${summary})` });
10286
continue;
10387
}
@@ -109,9 +93,9 @@ export async function resolveAtMentions(message: string, cwd: string): Promise<s
10993
replacements.push({ full, replacement: `${full} (blocked: total @mention content is too large; max ${MAX_MENTION_TOTAL_BYTES} bytes)` });
11094
continue;
11195
}
112-
const content = await readFile(resolved.abs, "utf-8");
96+
const content = await readFile(abs, "utf-8");
11397
totalBytes += info.size;
114-
const ext = resolved.abs.split(".").pop() ?? "";
98+
const ext = abs.split(".").pop() ?? "";
11599
replacements.push({ full, replacement: `\`${path}\`:\n\`\`\`${ext}\n${content}\n\`\`\`` });
116100
} catch {
117101
replacements.push({ full, replacement: `${full} (not found)` });

tests/unit/tui/at-mention-resolution.test.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,54 +110,93 @@ describe("resolveAtMentions", () => {
110110
}
111111
});
112112

113-
test("blocks symlinks that resolve outside the workspace", async () => {
113+
test("inlines symlinked outside-workspace files", async () => {
114114
const dir = await fixture();
115115
const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-"));
116116
try {
117117
await writeFile(join(outside, "outside.txt"), "outside content\n");
118118
await symlink(outside, join(dir, "escape"));
119119

120120
const resolved = await resolveAtMentions("read @escape/outside.txt", dir);
121-
expect(resolved).toContain("@escape/outside.txt (blocked: outside workspace)");
122-
expect(resolved).not.toContain("outside content");
121+
expect(resolved).toContain("outside content");
123122
} finally {
124123
await rm(dir, { recursive: true, force: true });
125124
await rm(outside, { recursive: true, force: true });
126125
}
127126
});
128127

129-
test("blocks absolute paths outside the workspace", async () => {
128+
test("inlines absolute outside-workspace paths", async () => {
130129
const dir = await fixture();
131130
const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-"));
132131
try {
133132
const outsideFile = join(outside, "outside.txt");
134133
await writeFile(outsideFile, "outside content\n");
135134

136135
const resolved = await resolveAtMentions(`read @${outsideFile}`, dir);
137-
expect(resolved).toContain(`@${outsideFile} (blocked: outside workspace)`);
138-
expect(resolved).not.toContain("outside content");
136+
expect(resolved).toContain(`\`${outsideFile}\`:`);
137+
expect(resolved).toContain("outside content");
139138
} finally {
140139
await rm(dir, { recursive: true, force: true });
141140
await rm(outside, { recursive: true, force: true });
142141
}
143142
});
144143

145-
test("blocks parent-traversal paths that escape the workspace", async () => {
144+
test("inlines parent-traversal outside-workspace paths", async () => {
146145
const dir = await fixture();
147146
const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-"));
148147
try {
149148
await writeFile(join(outside, "outside.txt"), "outside content\n");
150149
const traversal = `../../${outside.split("/").pop() ?? ""}/outside.txt`;
151150

152151
const resolved = await resolveAtMentions(`read @${traversal}`, join(dir, "src"));
153-
expect(resolved).toContain(`@${traversal} (blocked: outside workspace)`);
154-
expect(resolved).not.toContain("outside content");
152+
expect(resolved).toContain("outside content");
155153
} finally {
156154
await rm(dir, { recursive: true, force: true });
157155
await rm(outside, { recursive: true, force: true });
158156
}
159157
});
160158

159+
test("summarizes outside-workspace directories", async () => {
160+
const dir = await fixture();
161+
const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-"));
162+
try {
163+
await writeFile(join(outside, "a.txt"), "a\n");
164+
await mkdir(join(outside, "sub"));
165+
166+
const resolved = await resolveAtMentions(`read @${outside}`, dir);
167+
expect(resolved).toContain("directory - ");
168+
} finally {
169+
await rm(dir, { recursive: true, force: true });
170+
await rm(outside, { recursive: true, force: true });
171+
}
172+
});
173+
174+
test("still blocks sensitive outside-workspace paths", async () => {
175+
const dir = await fixture();
176+
const outside = await mkdtemp(join(tmpdir(), "at-mention-resolution-outside-"));
177+
try {
178+
const outsideEnv = join(outside, ".env");
179+
await writeFile(outsideEnv, "API_KEY=secret\n");
180+
181+
const resolved = await resolveAtMentions(`read @${outsideEnv}`, dir);
182+
expect(resolved).toContain("(blocked: sensitive path)");
183+
expect(resolved).not.toContain("API_KEY=secret");
184+
} finally {
185+
await rm(dir, { recursive: true, force: true });
186+
await rm(outside, { recursive: true, force: true });
187+
}
188+
});
189+
190+
test("blocks home-relative paths", async () => {
191+
const dir = await fixture();
192+
try {
193+
const resolved = await resolveAtMentions("read @~/some-file.txt", dir);
194+
expect(resolved).toContain("(blocked: home-relative paths are not supported)");
195+
} finally {
196+
await rm(dir, { recursive: true, force: true });
197+
}
198+
});
199+
161200
test("inlines mentions into a sibling git worktree of the same session", async () => {
162201
const repo = await mkdtemp(join(tmpdir(), "at-mention-resolution-repo-"));
163202
const worktree = await mkdtemp(join(tmpdir(), "at-mention-resolution-worktree-"));

0 commit comments

Comments
 (0)