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:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Archive, Maximize2, Minimize2, X } from 'lucide-react';
|
||||
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project, Session } from '@/api/types';
|
||||
@@ -269,6 +269,8 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AllowedReadPathsSection session={session} />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
@@ -337,6 +339,76 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
||||
);
|
||||
}
|
||||
|
||||
// v1.13.17-cross-repo-reads: revoke UI for session.allowed_read_paths.
|
||||
// Append happens through the inline request_read_access pause flow; this
|
||||
// section only shrinks the list. PATCH /api/sessions/:id replaces the
|
||||
// whole array, so we send the original list minus the deleted entry.
|
||||
function AllowedReadPathsSection({ session }: { session: Session }) {
|
||||
const [paths, setPaths] = useState<string[]>(session.allowed_read_paths);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
|
||||
// Re-sync on session prop change (e.g. WS session_updated after a new
|
||||
// grant lands). Without this, a grant approved in this same chat wouldn't
|
||||
// appear in the list until the user closes and reopens settings.
|
||||
useEffect(() => {
|
||||
setPaths(session.allowed_read_paths);
|
||||
}, [session.id, session.allowed_read_paths]);
|
||||
|
||||
async function remove(path: string) {
|
||||
if (pendingDelete) return;
|
||||
setPendingDelete(path);
|
||||
const next = paths.filter((p) => p !== path);
|
||||
try {
|
||||
const updated = await api.sessions.update(session.id, { allowed_read_paths: next });
|
||||
setPaths(updated.allowed_read_paths);
|
||||
toast.success('Grant revoked');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to revoke');
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Cross-repo read grants
|
||||
</label>
|
||||
{paths.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
The agent has no access outside this project. Grants are created when
|
||||
the agent asks for them inline.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{paths.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center gap-2 rounded border bg-background/60 px-2 py-1.5"
|
||||
>
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-mono text-xs flex-1 min-w-0 break-all">{p}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove(p)}
|
||||
disabled={pendingDelete !== null}
|
||||
aria-label={`Revoke ${p}`}
|
||||
title="Revoke"
|
||||
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Grants are session-scoped. Archiving the session clears them.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSection({ project }: { project: Project }) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
|
||||
|
||||
Reference in New Issue
Block a user