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>
262 lines
7.1 KiB
TypeScript
262 lines
7.1 KiB
TypeScript
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import { resolve, relative } from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
|
|
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
const DEFAULT_VIEW_LINES = 200;
|
|
const MAX_GREP_RESULTS = 200;
|
|
const DEFAULT_GREP_RESULTS = 100;
|
|
const MAX_FIND_RESULTS = 200;
|
|
const DEFAULT_FIND_RESULTS = 100;
|
|
const MAX_DIR_ENTRIES = 500;
|
|
|
|
export interface FileEntry {
|
|
name: string;
|
|
kind: 'file' | 'dir';
|
|
size?: number;
|
|
}
|
|
|
|
export interface ListDirResult {
|
|
entries: FileEntry[];
|
|
truncated: boolean;
|
|
total: number;
|
|
}
|
|
|
|
export interface ViewFileResult {
|
|
content: string;
|
|
truncated: boolean;
|
|
total_bytes: number;
|
|
bytes_returned: number;
|
|
}
|
|
|
|
export interface GrepMatch {
|
|
path: string;
|
|
line: number;
|
|
text: string;
|
|
}
|
|
|
|
export interface GrepResult {
|
|
matches: GrepMatch[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
export interface FindFilesResult {
|
|
files: string[];
|
|
total: number;
|
|
truncated: boolean;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
const entries = await readdir(real, { withFileTypes: true });
|
|
const total = entries.length;
|
|
const slice = entries.slice(0, MAX_DIR_ENTRIES);
|
|
const out: FileEntry[] = await Promise.all(
|
|
slice.map(async (e) => {
|
|
const child = resolve(real, e.name);
|
|
let size: number | undefined;
|
|
if (e.isFile()) {
|
|
try {
|
|
const cs = await stat(child);
|
|
size = cs.size;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return {
|
|
name: e.name,
|
|
kind: e.isDirectory() ? ('dir' as const) : ('file' as const),
|
|
...(size != null ? { size } : {}),
|
|
};
|
|
})
|
|
);
|
|
return {
|
|
entries: out,
|
|
total,
|
|
truncated: total > MAX_DIR_ENTRIES,
|
|
};
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
if (s.size > MAX_FILE_BYTES) {
|
|
throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`);
|
|
}
|
|
const raw = await readFile(real, 'utf8');
|
|
const lines = raw.split('\n');
|
|
const total = lines.length;
|
|
const end = Math.min(total, DEFAULT_VIEW_LINES);
|
|
const slice = lines.slice(0, end);
|
|
const content = slice.join('\n');
|
|
const truncated = total > end;
|
|
const bytes_returned = Buffer.byteLength(content, 'utf8');
|
|
return {
|
|
content,
|
|
truncated,
|
|
total_bytes: s.size,
|
|
bytes_returned,
|
|
};
|
|
}
|
|
|
|
interface RipgrepMatch {
|
|
type: string;
|
|
data?: {
|
|
path?: { text?: string };
|
|
line_number?: number;
|
|
lines?: { text?: string };
|
|
};
|
|
}
|
|
|
|
export async function grep(
|
|
projectRoot: string,
|
|
pattern: string,
|
|
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, opts?.extra_roots);
|
|
const limit = Math.min(
|
|
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
|
|
MAX_GREP_RESULTS
|
|
);
|
|
const args = [
|
|
'--json',
|
|
'--max-count',
|
|
String(limit),
|
|
'--max-columns',
|
|
'300',
|
|
];
|
|
if (!opts?.case_sensitive) args.push('--ignore-case');
|
|
if (opts?.hidden) args.push('--hidden');
|
|
args.push('--', pattern, target);
|
|
|
|
return new Promise((resolveP, rejectP) => {
|
|
const child = spawn('rg', args, { cwd: projectRoot });
|
|
const matches: GrepMatch[] = [];
|
|
let buf = '';
|
|
let stderr = '';
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
child.stdout.on('data', (chunk: string) => {
|
|
buf += chunk;
|
|
let idx;
|
|
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
const line = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 1);
|
|
if (!line) continue;
|
|
if (matches.length >= limit) continue;
|
|
try {
|
|
const parsed = JSON.parse(line) as RipgrepMatch;
|
|
if (parsed.type !== 'match' || !parsed.data) continue;
|
|
const filePath = parsed.data.path?.text ?? '';
|
|
const lineNumber = parsed.data.line_number ?? 0;
|
|
const content = parsed.data.lines?.text ?? '';
|
|
matches.push({
|
|
path: relative(projectRoot, filePath) || filePath,
|
|
line: lineNumber,
|
|
text: content.replace(/\n$/, ''),
|
|
});
|
|
} catch {
|
|
/* ignore non-json */
|
|
}
|
|
}
|
|
if (matches.length >= limit) {
|
|
child.kill();
|
|
}
|
|
});
|
|
child.stderr.on('data', (chunk: string) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on('error', (err) => rejectP(err));
|
|
child.on('close', (code) => {
|
|
if (code === 2 && matches.length === 0) {
|
|
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
|
|
return;
|
|
}
|
|
resolveP({
|
|
matches,
|
|
truncated: matches.length >= limit,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function findFiles(
|
|
projectRoot: string,
|
|
pattern?: 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, opts?.extra_roots)
|
|
: projectRoot;
|
|
const args = ['--files'];
|
|
if (pattern) args.push('--glob', pattern);
|
|
args.push(target);
|
|
|
|
return new Promise((resolveP, rejectP) => {
|
|
const child = spawn('rg', args, { cwd: projectRoot });
|
|
const files: string[] = [];
|
|
let total = 0;
|
|
let buf = '';
|
|
let stderr = '';
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
child.stdout.on('data', (chunk: string) => {
|
|
buf += chunk;
|
|
let idx;
|
|
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
const line = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 1);
|
|
if (!line) continue;
|
|
// Keep counting after limit to report accurate `total` to the caller.
|
|
// grep kills early since the LLM doesn't need a total; this differs intentionally.
|
|
total++;
|
|
if (files.length < limit) {
|
|
files.push(relative(projectRoot, line) || line);
|
|
}
|
|
}
|
|
});
|
|
child.stderr.on('data', (chunk: string) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on('error', (err) => rejectP(err));
|
|
child.on('close', (code) => {
|
|
if (code === 2) {
|
|
rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`));
|
|
return;
|
|
}
|
|
if (buf.length > 0) {
|
|
total++;
|
|
if (files.length < limit) {
|
|
files.push(relative(projectRoot, buf) || buf);
|
|
}
|
|
}
|
|
resolveP({
|
|
files,
|
|
total,
|
|
truncated: total > files.length,
|
|
});
|
|
});
|
|
});
|
|
}
|