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>
56 lines
2.0 KiB
TypeScript
56 lines
2.0 KiB
TypeScript
import { realpath } from 'node:fs/promises';
|
|
import { isAbsolute, resolve, sep } from 'node:path';
|
|
|
|
export class PathScopeError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'PathScopeError';
|
|
}
|
|
}
|
|
|
|
export async function resolveProjectRoot(projectPath: string): Promise<string> {
|
|
try {
|
|
return await realpath(projectPath);
|
|
} catch {
|
|
throw new PathScopeError(`project path does not exist: ${projectPath}`);
|
|
}
|
|
}
|
|
|
|
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,
|
|
extraRoots: readonly string[] = [],
|
|
): Promise<string> {
|
|
if (typeof requested !== 'string' || requested.length === 0) {
|
|
throw new PathScopeError('path is required');
|
|
}
|
|
const candidate = isAbsolute(requested) ? requested : resolve(projectRoot, requested);
|
|
let real: string;
|
|
try {
|
|
real = await realpath(candidate);
|
|
} catch {
|
|
throw new PathScopeError(`path does not exist: ${requested}`);
|
|
}
|
|
if (isUnder(real, projectRoot)) return real;
|
|
for (const extra of extraRoots) {
|
|
if (extra.length === 0) continue;
|
|
if (isUnder(real, extra)) return real;
|
|
}
|
|
throw new PathScopeError(
|
|
`path escapes project root: ${requested} -> ${real}. ` +
|
|
`Use request_read_access(path, reason) to ask the user for permission.`,
|
|
);
|
|
}
|