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:
2026-05-22 21:45:52 +00:00
parent 2e1a81de72
commit b52c5df705
21 changed files with 1610 additions and 41 deletions

View File

@@ -123,7 +123,20 @@ export const api = {
get: (id: string) => request<Session>(`/api/sessions/${id}`),
update: (
id: string,
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
body: Partial<
Pick<
Session,
| 'name'
| 'model'
| 'system_prompt'
| 'agent_id'
| 'web_search_enabled'
// v1.13.17: revocation path — frontend sends the shortened list
// when the user removes a grant. Grants are appended only via the
// separate grantReadAccess endpoint below.
| 'allowed_read_paths'
>
>
) =>
request<Session>(`/api/sessions/${id}`, {
method: 'PATCH',
@@ -228,6 +241,19 @@ export const api = {
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
},
),
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On
// 'allow' the server re-resolves the grant root and appends it to
// sessions.allowed_read_paths; the returned list reflects the post-
// grant state. On 'deny' the array is unchanged.
grantReadAccess: (chatId: string, toolCallId: string, decision: 'allow' | 'deny') =>
request<{
tool_message_id: string;
assistant_message_id: string;
allowed_read_paths: string[];
}>(`/api/chats/${chatId}/grant_read_access`, {
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, decision }),
}),
},
messages: {

View File

@@ -48,6 +48,11 @@ export interface Session {
web_search_enabled: boolean | null;
// v1.12.1: server-authoritative pane layout, replaces localStorage.
workspace_panes: WorkspacePane[];
// v1.13.17: paths the agent has been granted read access to via the
// request_read_access tool. Empty by default. Settings UI surfaces the
// list with per-row revoke; the grant flow itself appends through the
// dedicated POST /api/chats/:id/grant_read_access endpoint (not PATCH).
allowed_read_paths: string[];
}
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project