v1.13.17-cross-repo-reads: on-demand read access to paths outside the project root
When the agent needed context from another repo, pathGuard rejected every read
with no recovery path. This batch adds a reactive request_read_access flow:
pathGuard's error now hints at the tool, the model emits a structured request,
the inference loop pauses (same mechanism as ask_user_input), the user picks
Allow/Deny via inline chips, and subsequent reads under the granted root succeed
for the rest of the session.
Schema: sessions.allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]
(idempotent ADD COLUMN IF NOT EXISTS).
Grant unit (design D1): nearest registered projects.path ancestor →
nearest repo-shaped ancestor (.git/ / package.json / go.mod / Cargo.toml)
under PROJECT_ROOT_WHITELIST → else refuse. grant_resolver.ts walks
ancestors with a per-iteration whitelist invariant check so symlinked
input can't escape the whitelist mid-walk (Sam's checkpoint-1 ask).
Path-guard: optional extraRoots arg threaded from session.allowed_read_paths
through executeToolCall to view_file / list_dir / grep / find_files. The
ToolDef.execute signature gets an optional third param; non-FS tools
ignore it. view_file re-anchors the secret-guard check on basename(real)
whenever a relative path starts with "../" so .env / id_rsa* etc. still
deny across grant roots.
Endpoint: POST /api/chats/:id/grant_read_access mirrors /answer_user_input.
On 'allow' it re-resolves the grant root (state may have changed since
prompt — auto-falls to denial reason text on failure, not 500), array_appends
to sessions.allowed_read_paths with in-memory dedup, then publishes
tool_result + session_updated frames and enqueues the next assistant turn.
PATCH /api/sessions/:id allowed_read_paths supports revocation only. Zod
refines absolute + no traversal markers; runtime findUnauthorizedAdditions
guard rejects any entry not already present in the row, so a malicious
curl -X PATCH -d '{"allowed_read_paths":["/etc"]}' returns 400 instead of
bypassing the grant flow (Sam's compliance-review action item).
Frontend: RequestReadAccessCard renders pending (path + reason + Allow/Deny)
and answered (granted/denied summary with the resolved root) variants;
MessageList.flatten/group special-cases the tool name; SettingsPane adds a
per-session grants list with per-row revoke that PATCHes the shortened
array.
Tests: 11 grant_resolver, 8 path_guard, 8 sessions PATCH subset, including
explicit cases for symlink escape mid-walk, walk-bound termination at
whitelist root, /etc bypass attempt via PATCH, and nearest-project
disambiguation. 292 total server tests green.
Pairs with v1.13.16-xml-parser — the model now self-recovers from both
a wrong tool name AND from a refused path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree.
|
||||
//
|
||||
// Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor
|
||||
// walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
|
||||
// filesystem root — check on every iteration, not just final parent.
|
||||
// Symlinked input must not be able to escape the whitelist during the
|
||||
// walk." The symlink-escape-mid-walk test below pins that invariant —
|
||||
// without the per-iteration whitelist check, this case would walk OUTSIDE
|
||||
// the whitelist root and return a phantom grant.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
let tmp: string;
|
||||
let whitelist: string;
|
||||
let project: string;
|
||||
let fork: string;
|
||||
let outside: string;
|
||||
|
||||
// Fake sql tag — returns the projects rows we want without touching a real
|
||||
// database. The resolver only ever does a single SELECT, so a single-shot
|
||||
// mock that returns the prepared rows on every invocation is enough.
|
||||
function makeSql(rows: Array<{ path: string }>): Sql {
|
||||
const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql;
|
||||
return tag;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-')));
|
||||
whitelist = join(tmp, 'whitelist');
|
||||
project = join(whitelist, 'boocode');
|
||||
fork = join(whitelist, 'forks', 'codecontext');
|
||||
outside = join(tmp, 'outside');
|
||||
await mkdir(project, { recursive: true });
|
||||
await mkdir(fork, { recursive: true });
|
||||
await mkdir(outside, { recursive: true });
|
||||
// Mark project as a repo (.git directory).
|
||||
await mkdir(join(project, '.git'));
|
||||
await writeFile(join(project, 'README.md'), 'project readme');
|
||||
// Mark fork as a repo via go.mod (matches the proposal's example).
|
||||
await writeFile(join(fork, 'go.mod'), 'module example.com/foo');
|
||||
await writeFile(join(fork, 'main.go'), 'package main');
|
||||
await writeFile(join(outside, 'secret.txt'), 'forbidden');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — happy paths', () => {
|
||||
it('refuses when the requested path is already under projectRoot', async () => {
|
||||
const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/already accessible/);
|
||||
});
|
||||
|
||||
it('returns the project root when the path falls under a registered project', async () => {
|
||||
// Register `fork` as a known project. Resolver should return the project
|
||||
// ancestor (LONGEST match wins) rather than the repo-shape fallback.
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([{ path: fork }]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.root).toBe(fork);
|
||||
expect(result.source).toBe('project');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the nearest repo-shaped ancestor when no project matches', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.root).toBe(fork);
|
||||
expect(result.source).toBe('whitelist');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — refusals', () => {
|
||||
it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(outside, 'secret.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||
});
|
||||
|
||||
it('refuses non-absolute paths', async () => {
|
||||
const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/absolute/);
|
||||
});
|
||||
|
||||
it('refuses missing paths without prompting', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(whitelist, 'nope'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/does not exist/);
|
||||
});
|
||||
|
||||
it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => {
|
||||
// Build a directory tree under the whitelist that has NO repo markers
|
||||
// all the way up to the whitelist root.
|
||||
const plain = join(whitelist, 'plain-dir', 'nested');
|
||||
await mkdir(plain, { recursive: true });
|
||||
await writeFile(join(plain, 'just-a-file.txt'), 'x');
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(plain, 'just-a-file.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||
});
|
||||
|
||||
it('does not grant the whitelist root itself as a fallback', async () => {
|
||||
// Even if .git existed at the whitelist root (it doesn't), we'd refuse.
|
||||
// Easier to assert: a path directly under whitelist with no repo marker.
|
||||
const direct = join(whitelist, 'lone-file.txt');
|
||||
await writeFile(direct, 'x');
|
||||
const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => {
|
||||
it('refuses a symlinked input whose realpath sits outside the whitelist', async () => {
|
||||
// The symlink lives nominally inside the whitelist, but its target
|
||||
// (realpath) is outside. The guard's first realpath() call normalizes
|
||||
// and the up-front whitelist check refuses immediately.
|
||||
const link = join(whitelist, 'escape-link');
|
||||
try {
|
||||
await symlink(outside, link);
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(link, 'secret.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||
} finally {
|
||||
await rm(link, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('walk loop terminates at the whitelist root, not at filesystem /', async () => {
|
||||
// Construct a deep tree with NO repo markers anywhere. Without a bound,
|
||||
// the walk would chase parents up to "/". The bound flips the loop into
|
||||
// a refusal once the cursor equals the realpath'd whitelist root.
|
||||
const deep = join(whitelist, 'a', 'b', 'c', 'd');
|
||||
await mkdir(deep, { recursive: true });
|
||||
await writeFile(join(deep, 'leaf.txt'), 'x');
|
||||
const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — nearest-project disambiguation', () => {
|
||||
it('prefers the longest matching project path over a shorter ancestor', async () => {
|
||||
const outer = whitelist;
|
||||
const inner = fork; // /whitelist/forks/codecontext, deeper than outer
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([{ path: outer }, { path: inner }]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.root).toBe(inner);
|
||||
});
|
||||
});
|
||||
|
||||
// Belt-and-suspenders: silence a known dynamic-import warning that vitest
|
||||
// occasionally emits on transient fs operations in CI but never in dev.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
|
||||
// list. Validates the primary-root path stays the source of truth and that
|
||||
// extra roots are consulted when (and only when) the primary rejects.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { pathGuard, PathScopeError } from '../path_guard.js';
|
||||
|
||||
let tmp: string;
|
||||
let projectRoot: string;
|
||||
let altRoot: string;
|
||||
let outsideDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-pg-')));
|
||||
projectRoot = join(tmp, 'project');
|
||||
altRoot = join(tmp, 'alt');
|
||||
outsideDir = join(tmp, 'outside');
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
await mkdir(altRoot, { recursive: true });
|
||||
await mkdir(outsideDir, { recursive: true });
|
||||
await writeFile(join(projectRoot, 'inside.txt'), 'p');
|
||||
await writeFile(join(altRoot, 'cross.txt'), 'a');
|
||||
await writeFile(join(outsideDir, 'forbidden.txt'), 'x');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('pathGuard (v1.13.17 extraRoots)', () => {
|
||||
it('accepts paths inside the primary projectRoot', async () => {
|
||||
const real = await pathGuard(projectRoot, 'inside.txt');
|
||||
expect(real).toBe(join(projectRoot, 'inside.txt'));
|
||||
});
|
||||
|
||||
it('rejects paths outside the primary root when no extra roots given', async () => {
|
||||
await expect(pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'))).rejects.toBeInstanceOf(
|
||||
PathScopeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts cross-root paths when the matching extra root is provided', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [altRoot]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
|
||||
it('rejects cross-root paths even with extra roots when no root matches', async () => {
|
||||
await expect(
|
||||
pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'), [altRoot]),
|
||||
).rejects.toBeInstanceOf(PathScopeError);
|
||||
});
|
||||
|
||||
it('ignores empty-string extra roots silently', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), ['', altRoot]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
|
||||
it('error message contains the request_read_access hint when scope rejects', async () => {
|
||||
try {
|
||||
await pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'));
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PathScopeError);
|
||||
expect((err as Error).message).toContain('request_read_access');
|
||||
}
|
||||
});
|
||||
|
||||
it('still resolves symlinks before the scope check', async () => {
|
||||
const linkPath = join(projectRoot, 'link-to-outside');
|
||||
await symlink(join(outsideDir, 'forbidden.txt'), linkPath);
|
||||
// Symlink target escapes both primary and the single extra root, so
|
||||
// even though the surface path "looks" inside projectRoot, the real
|
||||
// path resolves outside and the guard rejects.
|
||||
await expect(pathGuard(projectRoot, linkPath, [altRoot])).rejects.toBeInstanceOf(
|
||||
PathScopeError,
|
||||
);
|
||||
// But adding outsideDir as an extra root accepts (realpath inside it).
|
||||
const real = await pathGuard(projectRoot, linkPath, [altRoot, outsideDir]);
|
||||
expect(real).toBe(join(outsideDir, 'forbidden.txt'));
|
||||
});
|
||||
|
||||
it('tries extra roots in order until one accepts', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [
|
||||
outsideDir, // rejects
|
||||
altRoot, // accepts
|
||||
]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
});
|
||||
@@ -47,8 +47,12 @@ export interface FindFilesResult {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export async function listDir(projectRoot: string, relPath: string): Promise<ListDirResult> {
|
||||
const real = await pathGuard(projectRoot, relPath);
|
||||
export async function listDir(
|
||||
projectRoot: string,
|
||||
relPath: string,
|
||||
opts?: { extra_roots?: readonly string[] },
|
||||
): Promise<ListDirResult> {
|
||||
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
|
||||
const s = await stat(real);
|
||||
if (!s.isDirectory()) {
|
||||
throw new PathScopeError(`not a directory: ${relPath}`);
|
||||
@@ -82,8 +86,12 @@ export async function listDir(projectRoot: string, relPath: string): Promise<Lis
|
||||
};
|
||||
}
|
||||
|
||||
export async function viewFile(projectRoot: string, relPath: string): Promise<ViewFileResult> {
|
||||
const real = await pathGuard(projectRoot, relPath);
|
||||
export async function viewFile(
|
||||
projectRoot: string,
|
||||
relPath: string,
|
||||
opts?: { extra_roots?: readonly string[] },
|
||||
): Promise<ViewFileResult> {
|
||||
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
|
||||
const s = await stat(real);
|
||||
if (!s.isFile()) {
|
||||
throw new PathScopeError(`not a file: ${relPath}`);
|
||||
@@ -119,10 +127,10 @@ interface RipgrepMatch {
|
||||
export async function grep(
|
||||
projectRoot: string,
|
||||
pattern: string,
|
||||
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean }
|
||||
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean; extra_roots?: readonly string[] }
|
||||
): Promise<GrepResult> {
|
||||
const targetPath = opts?.path ?? projectRoot;
|
||||
const target = await pathGuard(projectRoot, targetPath);
|
||||
const target = await pathGuard(projectRoot, targetPath, opts?.extra_roots);
|
||||
const limit = Math.min(
|
||||
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
|
||||
MAX_GREP_RESULTS
|
||||
@@ -192,14 +200,14 @@ export async function grep(
|
||||
export async function findFiles(
|
||||
projectRoot: string,
|
||||
pattern?: string,
|
||||
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string }
|
||||
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string; extra_roots?: readonly string[] }
|
||||
): Promise<FindFilesResult> {
|
||||
const limit = Math.min(
|
||||
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||
MAX_FIND_RESULTS
|
||||
);
|
||||
const target = opts?.path != null
|
||||
? await pathGuard(projectRoot, opts.path)
|
||||
? await pathGuard(projectRoot, opts.path, opts?.extra_roots)
|
||||
: projectRoot;
|
||||
const args = ['--files'];
|
||||
if (pattern) args.push('--glob', pattern);
|
||||
|
||||
161
apps/server/src/services/grant_resolver.ts
Normal file
161
apps/server/src/services/grant_resolver.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// v1.13.17-cross-repo-reads: derives the grant root for a path the user is
|
||||
// being asked to approve cross-repo read access to.
|
||||
//
|
||||
// Per design decision D1: grant unit = nearest registered project root,
|
||||
// then nearest path-whitelist ancestor that looks like a repo root, then
|
||||
// refuse. Granting the literal file path is too narrow (next file in the
|
||||
// same repo re-prompts). Granting an arbitrary parent dir over-scopes.
|
||||
//
|
||||
// The resolver runs in two contexts:
|
||||
// 1. request_read_access.execute — pre-prompt validation (cheap; bails
|
||||
// early if the path can't plausibly be granted so the user is never
|
||||
// asked about /etc/passwd)
|
||||
// 2. POST /api/chats/:id/grant_read_access — at decision time, re-derives
|
||||
// the root and persists it on sessions.allowed_read_paths
|
||||
//
|
||||
// Sam (2026-05-22 dispatch confirmation): "in the project-root resolver
|
||||
// ancestor walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
|
||||
// filesystem root — check on every iteration, not just final parent.
|
||||
// Symlinked input must not be able to escape the whitelist during the
|
||||
// walk." Hence the loop here checks both the walk bound AND the still-
|
||||
// inside-whitelist invariant every step.
|
||||
|
||||
import { access, realpath } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { dirname, isAbsolute, sep } from 'node:path';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
// Files whose presence in a directory marks it as a repo root for grant
|
||||
// purposes. Kept narrow on purpose; broader heuristics (e.g. ".project",
|
||||
// "pyproject.toml") can be added with measured intent. Each entry is a
|
||||
// literal basename — no globs.
|
||||
const REPO_MARKERS: ReadonlyArray<string> = [
|
||||
'.git',
|
||||
'package.json',
|
||||
'go.mod',
|
||||
'Cargo.toml',
|
||||
];
|
||||
|
||||
export type GrantResolution =
|
||||
| { ok: true; root: string; source: 'project' | 'whitelist' }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function isUnder(child: string, parent: string): boolean {
|
||||
return child === parent || child.startsWith(parent + sep);
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isRepoShaped(dir: string): Promise<boolean> {
|
||||
for (const marker of REPO_MARKERS) {
|
||||
if (await exists(`${dir}${sep}${marker}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves an absolute path to its grant root or refuses with a reason
|
||||
// string suitable for surfacing to the model. Pure helper — no DB writes,
|
||||
// no broker publishes. Caller persists the root on session.allowed_read_paths
|
||||
// if it wants the grant to stick.
|
||||
//
|
||||
// Arguments:
|
||||
// sql — used only to read projects.path (no writes)
|
||||
// requestedPath — absolute path the model wants to read
|
||||
// projectRoot — the session's primary project root (already
|
||||
// realpath'd by caller). Used to short-circuit
|
||||
// "already in scope".
|
||||
// whitelistRoot — PROJECT_ROOT_WHITELIST from config (default /opt).
|
||||
// Walk bound for the repo-shape fallback.
|
||||
//
|
||||
// Returns { ok: true, root, source } on success; { ok: false, reason } else.
|
||||
export async function resolveGrantRoot(
|
||||
sql: Sql,
|
||||
requestedPath: string,
|
||||
projectRoot: string,
|
||||
whitelistRoot: string,
|
||||
): Promise<GrantResolution> {
|
||||
if (typeof requestedPath !== 'string' || requestedPath.length === 0) {
|
||||
return { ok: false, reason: 'path is required' };
|
||||
}
|
||||
if (!isAbsolute(requestedPath)) {
|
||||
return { ok: false, reason: 'path must be absolute' };
|
||||
}
|
||||
|
||||
// Resolve symlinks so subsequent ancestor checks compare apples-to-apples
|
||||
// with realpath'd projectRoot. If the path doesn't exist at all, bail
|
||||
// before bothering the user — the model is asking about a phantom.
|
||||
let real: string;
|
||||
try {
|
||||
real = await realpath(requestedPath);
|
||||
} catch {
|
||||
return { ok: false, reason: `path does not exist: ${requestedPath}` };
|
||||
}
|
||||
|
||||
// Whitelist guard. Symlinked inputs can resolve outside the whitelist
|
||||
// even when the surface-form path looks inside it; that's why we test
|
||||
// the *real* path here, not the requested one.
|
||||
let realWhitelist: string;
|
||||
try {
|
||||
realWhitelist = await realpath(whitelistRoot);
|
||||
} catch {
|
||||
return { ok: false, reason: `whitelist root does not exist: ${whitelistRoot}` };
|
||||
}
|
||||
if (!isUnder(real, realWhitelist)) {
|
||||
return { ok: false, reason: 'path outside permitted scope' };
|
||||
}
|
||||
|
||||
// Already in scope? No prompt needed; the tool's caller should retry.
|
||||
if (isUnder(real, projectRoot)) {
|
||||
return { ok: false, reason: 'path already accessible without a grant' };
|
||||
}
|
||||
|
||||
// Look for a registered project whose root is an ancestor of the
|
||||
// requested path. Pick the LONGEST match (nearest ancestor wins) so
|
||||
// sub-projects don't get over-broadened.
|
||||
const projectRows = await sql<{ path: string }[]>`
|
||||
SELECT path FROM projects WHERE status = 'open'
|
||||
`;
|
||||
let bestProject: string | null = null;
|
||||
for (const row of projectRows) {
|
||||
if (!row.path) continue;
|
||||
if (!isUnder(real, row.path)) continue;
|
||||
if (bestProject === null || row.path.length > bestProject.length) {
|
||||
bestProject = row.path;
|
||||
}
|
||||
}
|
||||
if (bestProject !== null) {
|
||||
return { ok: true, root: bestProject, source: 'project' };
|
||||
}
|
||||
|
||||
// Repo-shape fallback. Walk from the requested path upward toward the
|
||||
// whitelist root. At every iteration: confirm we're still inside the
|
||||
// whitelist (so a symlinked component can't slip the bound mid-walk)
|
||||
// and confirm we haven't hit the filesystem root. The first dir with a
|
||||
// REPO_MARKER child is the grant root.
|
||||
let cursor = real;
|
||||
while (true) {
|
||||
// Don't grant the whitelist root itself — that would be far too broad.
|
||||
if (cursor === realWhitelist) {
|
||||
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
|
||||
}
|
||||
if (!isUnder(cursor, realWhitelist)) {
|
||||
return { ok: false, reason: 'path outside permitted scope' };
|
||||
}
|
||||
const parent = dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
// Hit filesystem root without finding a repo marker.
|
||||
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
|
||||
}
|
||||
if (await isRepoShaped(cursor)) {
|
||||
return { ok: true, root: cursor, source: 'whitelist' };
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './
|
||||
// dispatch layer we no longer know which format produced the call, and the
|
||||
// extra signal is harmless for Qwen-derived calls.
|
||||
import { formatUnknownToolError } from './tool-suggestions.js';
|
||||
// v1.13.17-cross-repo-reads: pre-prompt validation for request_read_access.
|
||||
// Resolves the grant root before pausing the loop so the user is never
|
||||
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||
import type {
|
||||
InferenceContext,
|
||||
StreamResult,
|
||||
@@ -28,7 +32,8 @@ import { SYNTHESIS_TOOLS, runSynthesisPass } from '../synthesisPipeline.js';
|
||||
|
||||
async function executeToolCall(
|
||||
projectRoot: string,
|
||||
toolCall: ToolCall
|
||||
toolCall: ToolCall,
|
||||
extraRoots: readonly string[],
|
||||
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
||||
const tool = TOOLS_BY_NAME[toolCall.name];
|
||||
if (!tool) {
|
||||
@@ -63,7 +68,7 @@ async function executeToolCall(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const output = await tool.execute(parsed.data, projectRoot);
|
||||
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
|
||||
const truncated =
|
||||
typeof output === 'object' && output !== null && 'truncated' in output
|
||||
? Boolean((output as { truncated: unknown }).truncated)
|
||||
@@ -206,7 +211,71 @@ export async function executeToolPhase(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tres = await executeToolCall(projectRoot, tc);
|
||||
// v1.13.17-cross-repo-reads: request_read_access pauses identically to
|
||||
// ask_user_input EXCEPT for an up-front validation pass — if the path
|
||||
// can't be granted under the whitelist / repo-shape rules, surface an
|
||||
// immediate denial without prompting the user. Per design D1, we never
|
||||
// ask the user about /etc/passwd or paths outside PROJECT_ROOT_WHITELIST.
|
||||
if (tc.name === 'request_read_access') {
|
||||
const tcArgs = tc.args as { path?: unknown; reason?: unknown };
|
||||
const requested =
|
||||
typeof tcArgs.path === 'string' ? tcArgs.path : '';
|
||||
const resolution = await resolveGrantRoot(
|
||||
ctx.sql,
|
||||
requested,
|
||||
projectRoot,
|
||||
ctx.config.PROJECT_ROOT_WHITELIST,
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
// Auto-deny without pausing. The model sees the reason on its
|
||||
// next turn and decides what to do.
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
output: `denied: ${resolution.reason}`,
|
||||
truncated: false,
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||
...p,
|
||||
message_id: toolMessageId,
|
||||
})),
|
||||
);
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: toolMessageId,
|
||||
chat_id: chatId,
|
||||
tool_call_id: tc.id,
|
||||
output: stored.output,
|
||||
truncated: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Path is plausibly grantable — install the pending sentinel and
|
||||
// pause. The grant endpoint re-derives the root at decision time
|
||||
// (state may have changed in the meantime) so we don't stash it here.
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||
...p,
|
||||
message_id: toolMessageId,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||
}
|
||||
|
||||
@@ -16,9 +16,22 @@ export async function resolveProjectRoot(projectPath: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function isUnder(real: string, root: string): boolean {
|
||||
return real === root || real.startsWith(root + sep);
|
||||
}
|
||||
|
||||
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
|
||||
// list (typically session.allowed_read_paths). The primary projectRoot is
|
||||
// tried first; if the resolved path doesn't sit under it, each extraRoot is
|
||||
// tried in turn. Throws PathScopeError if no root accepts. The error message
|
||||
// includes a hint pointing the model at the request_read_access tool so it
|
||||
// can self-correct on the next turn — extraRoots IS the persistence
|
||||
// mechanism for those grants, so we only suggest it when there's a missing
|
||||
// grant to ask for (i.e. the path isn't already under any allowed root).
|
||||
export async function pathGuard(
|
||||
projectRoot: string,
|
||||
requested: string
|
||||
requested: string,
|
||||
extraRoots: readonly string[] = [],
|
||||
): Promise<string> {
|
||||
if (typeof requested !== 'string' || requested.length === 0) {
|
||||
throw new PathScopeError('path is required');
|
||||
@@ -30,10 +43,13 @@ export async function pathGuard(
|
||||
} catch {
|
||||
throw new PathScopeError(`path does not exist: ${requested}`);
|
||||
}
|
||||
if (real !== projectRoot && !real.startsWith(projectRoot + sep)) {
|
||||
throw new PathScopeError(
|
||||
`path escapes project root: ${requested} -> ${real}`
|
||||
);
|
||||
if (isUnder(real, projectRoot)) return real;
|
||||
for (const extra of extraRoots) {
|
||||
if (extra.length === 0) continue;
|
||||
if (isUnder(real, extra)) return real;
|
||||
}
|
||||
return real;
|
||||
throw new PathScopeError(
|
||||
`path escapes project root: ${requested} -> ${real}. ` +
|
||||
`Use request_read_access(path, reason) to ask the user for permission.`,
|
||||
);
|
||||
}
|
||||
|
||||
82
apps/server/src/services/request_read_access.ts
Normal file
82
apps/server/src/services/request_read_access.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// v1.13.17-cross-repo-reads: tool the model uses to request read access to
|
||||
// a path outside its session's primary project root. When the model emits
|
||||
// view_file("/opt/forks/foo/go.mod") under a session scoped to /opt/boocode,
|
||||
// pathGuard's error message hints at this tool. The model then emits
|
||||
// request_read_access(path="/opt/forks/foo/go.mod",
|
||||
// reason="investigating foo to write the design doc")
|
||||
// The tool's execute does cheap up-front validation: if the requested path
|
||||
// can't possibly be granted under the current whitelist + repo-shape rules,
|
||||
// it returns a denial immediately without prompting the user. Otherwise, the
|
||||
// tool-phase pause branch (parallel of ask_user_input) stores a pending
|
||||
// sentinel and waits for the user's allow/deny via the grant_read_access
|
||||
// endpoint.
|
||||
//
|
||||
// The execute body never directly mutates state; the grant endpoint owns
|
||||
// the persistence path. This keeps the tool-side logic side-effect-free
|
||||
// (it's just a request) and matches ask_user_input's "server-side no-op
|
||||
// fallback, pause happens in tool-phase" shape.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from './tools.js';
|
||||
|
||||
const RequestReadAccessInput = z.object({
|
||||
path: z.string().min(1),
|
||||
reason: z.string().min(1).max(500),
|
||||
});
|
||||
type RequestReadAccessInputT = z.infer<typeof RequestReadAccessInput>;
|
||||
|
||||
export const requestReadAccess: ToolDef<RequestReadAccessInputT> = {
|
||||
name: 'request_read_access',
|
||||
description:
|
||||
"Ask the user for read-only access to a path outside the current " +
|
||||
"session's project scope. Use when a previous read tool (view_file, " +
|
||||
'list_dir, grep, find_files) was refused with a path-escapes-project ' +
|
||||
'error and the path is plausibly under another known repository (e.g. ' +
|
||||
'/opt/forks/foo). Provide a short reason describing why you need the ' +
|
||||
"access. Pauses the conversation until the user picks Allow or Deny; " +
|
||||
'the next assistant turn sees the result. On Allow, the tool result ' +
|
||||
'is "granted: <root>" — subsequent reads under that root succeed for ' +
|
||||
'the rest of the session. On Deny, the tool result is "denied". Do ' +
|
||||
'not call this for paths that are already inside the project root.',
|
||||
inputSchema: RequestReadAccessInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'request_read_access',
|
||||
description:
|
||||
"Ask the user for read-only access to a path outside the session's " +
|
||||
'project scope. Pauses the conversation until the user picks Allow ' +
|
||||
'or Deny. Subsequent reads under the granted root succeed for the ' +
|
||||
'rest of the session.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Absolute path the model wants to read. Must be under the ' +
|
||||
"server's PROJECT_ROOT_WHITELIST (default /opt) and outside " +
|
||||
"the session's primary project root.",
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Short rationale (<=500 chars) shown to the user explaining ' +
|
||||
'why the access is needed. The user uses this to decide.',
|
||||
},
|
||||
},
|
||||
required: ['path', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Server-side no-op. The "execution" of request_read_access is the
|
||||
// pause-and-resume cycle managed by tool-phase.ts + the grant endpoint.
|
||||
// The inference loop catches this tool name BEFORE executeToolCall fires
|
||||
// and inserts a pending sentinel instead — this fallback only runs if
|
||||
// something bypasses that branch, in which case we surface the pending
|
||||
// shape so downstream code can still detect it. Mirrors ask_user_input.
|
||||
async execute(input) {
|
||||
return { _pending: true, path: input.path, reason: input.reason };
|
||||
},
|
||||
};
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
getSemanticNeighborhoods,
|
||||
getFrameworkAnalysis,
|
||||
} from './tools/codecontext/index.js';
|
||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||
import { requestReadAccess } from './request_read_access.js';
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_VIEW_LINES = 200;
|
||||
@@ -45,7 +49,13 @@ export interface ToolDef<TInput> {
|
||||
description: string;
|
||||
inputSchema: z.ZodType<TInput>;
|
||||
jsonSchema: ToolJsonSchema;
|
||||
execute(input: TInput, projectRoot: string): Promise<unknown>;
|
||||
// v1.13.17-cross-repo-reads: extraRoots is the session's
|
||||
// allowed_read_paths, threaded through executeToolCall in tool-phase.ts.
|
||||
// Only the filesystem tools (view_file, list_dir, grep, find_files,
|
||||
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||
// arg and ignore it. The execute signature stays compatible with
|
||||
// pre-v1.13.17 callsites because the parameter is optional.
|
||||
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>;
|
||||
}
|
||||
|
||||
const ViewFileInput = z.object({
|
||||
@@ -78,14 +88,19 @@ export const viewFile: ToolDef<ViewFileInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
const real = await pathGuard(projectRoot, input.path);
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||
// v1.11.7: secret-file deny check. Test the project-relative path
|
||||
// (matches the form continue.dev's patterns expect: basenames + dir
|
||||
// segments). Throw a typed error so executeToolCall in inference.ts
|
||||
// surfaces a clear "blocked" message to the LLM instead of silently
|
||||
// returning content the user wanted hidden.
|
||||
const relPath = relative(projectRoot, real) || basename(real);
|
||||
// v1.13.17: when the resolved path is outside the primary projectRoot
|
||||
// (i.e. via an allowed_read_paths grant), `relative()` returns "../…"
|
||||
// which won't match secret-file basename patterns. Re-anchor on the
|
||||
// file's basename so the secret deny still fires across all grant roots.
|
||||
const rel = relative(projectRoot, real);
|
||||
const relPath = rel && !rel.startsWith('..') ? rel : basename(real);
|
||||
if (isSecretPath(relPath)) {
|
||||
throw new SecretBlockedError(relPath);
|
||||
}
|
||||
@@ -157,8 +172,8 @@ export const listDir: ToolDef<ListDirInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
const real = await pathGuard(projectRoot, input.path);
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||
const s = await stat(real);
|
||||
if (!s.isDirectory()) {
|
||||
throw new PathScopeError(`not a directory: ${input.path}`);
|
||||
@@ -264,7 +279,7 @@ export const grep: ToolDef<GrepInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const limit = Math.min(
|
||||
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
||||
MAX_GREP_RESULTS
|
||||
@@ -276,6 +291,7 @@ export const grep: ToolDef<GrepInputT> = {
|
||||
max_matches: limit,
|
||||
case_sensitive: input.case_sensitive,
|
||||
hidden: input.hidden,
|
||||
extra_roots: extraRoots,
|
||||
});
|
||||
const reshaped = result.matches.map((m) => ({
|
||||
path: m.path,
|
||||
@@ -325,7 +341,7 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const limit = Math.min(
|
||||
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||
MAX_FIND_RESULTS
|
||||
@@ -335,6 +351,7 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
||||
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
|
||||
path: input.path,
|
||||
max_results: limit,
|
||||
extra_roots: extraRoots,
|
||||
});
|
||||
// v1.11.7: drop paths matching secret patterns. The original `total`
|
||||
// from file_ops includes pre-truncation count; we report the visible
|
||||
@@ -383,7 +400,10 @@ export const viewTruncatedOutput: ToolDef<ViewTruncatedOutputInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, _projectRoot) {
|
||||
// view_truncated_output doesn't touch the filesystem — it pulls from tmpfs
|
||||
// by opaque id. extraRoots is irrelevant here; declared for signature parity
|
||||
// with the v1.13.17 ToolDef contract.
|
||||
async execute(input, _projectRoot, _extraRoots) {
|
||||
const content = await readTruncation(input.id);
|
||||
if (content === null) {
|
||||
return {
|
||||
@@ -658,6 +678,11 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||
watchChanges as ToolDef<unknown>,
|
||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||
getFrameworkAnalysis as ToolDef<unknown>,
|
||||
// v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
|
||||
// branch in tool-phase.ts. Read-only — only ever READS files; the only
|
||||
// state change is appending to sessions.allowed_read_paths via the
|
||||
// grant endpoint, gated by user consent.
|
||||
requestReadAccess as ToolDef<unknown>,
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||
@@ -694,6 +719,10 @@ export const READ_ONLY_TOOL_NAMES = [
|
||||
'watch_changes',
|
||||
'get_semantic_neighborhoods',
|
||||
'get_framework_analysis',
|
||||
// v1.13.17-cross-repo-reads: pauses execution but doesn't mutate project
|
||||
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
||||
// only with user consent). Belongs in the read-only budget tier.
|
||||
'request_read_access',
|
||||
] as const;
|
||||
|
||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user