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>
94 lines
3.7 KiB
TypeScript
94 lines
3.7 KiB
TypeScript
// 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'));
|
|
});
|
|
});
|