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:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
||||
import { AskUserInputCard } from './AskUserInputCard';
|
||||
import { RequestReadAccessCard } from './RequestReadAccessCard';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
@@ -85,7 +86,9 @@ function group(items: RenderItem[]): RenderItem[] {
|
||||
continue;
|
||||
}
|
||||
const name = item.run.call.name;
|
||||
if (name === 'ask_user_input') {
|
||||
if (name === 'ask_user_input' || name === 'request_read_access') {
|
||||
// v1.13.17: same rationale as ask_user_input — grouping would collapse
|
||||
// the interactive pause card into a non-actionable ToolCallLine.
|
||||
out.push(item);
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -181,6 +184,16 @@ export function MessageList({ messages, sessionChats }: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.run.call.name === 'request_read_access') {
|
||||
return (
|
||||
<RequestReadAccessCard
|
||||
key={item.key}
|
||||
toolCall={item.run.call}
|
||||
toolResult={item.run.result}
|
||||
chatId={item.chatId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ToolCallLine key={item.key} run={item.run} />;
|
||||
}
|
||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||
|
||||
193
apps/web/src/components/RequestReadAccessCard.tsx
Normal file
193
apps/web/src/components/RequestReadAccessCard.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, FolderOpen, ShieldOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ToolCall, ToolResult } from '@/api/types';
|
||||
|
||||
// v1.13.17-cross-repo-reads. Renders an inline allow/deny picker for a
|
||||
// paused request_read_access tool call. Mirrors AskUserInputCard's pending
|
||||
// vs answered render dance:
|
||||
// - Pending: server pre-stamps a sentinel tool_result with output=null.
|
||||
// The card shows path + reason and lets the user pick Allow or Deny.
|
||||
// - Answered: the eventual WS tool_result frame carries the actual
|
||||
// decision string ("granted: <root>" or "denied" or "denied: <reason>").
|
||||
// The card flips to a read-only summary line.
|
||||
//
|
||||
// Tool name discrimination lives in MessageList.flatten/group — anything
|
||||
// with tc.name === 'request_read_access' bypasses grouping and renders this
|
||||
// card directly.
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCall;
|
||||
toolResult: ToolResult | null;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
interface ParsedArgs {
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function parseArgs(raw: unknown): ParsedArgs | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as { path?: unknown; reason?: unknown };
|
||||
if (typeof obj.path !== 'string' || obj.path.length === 0) return null;
|
||||
if (typeof obj.reason !== 'string' || obj.reason.length === 0) return null;
|
||||
return { path: obj.path, reason: obj.reason };
|
||||
}
|
||||
|
||||
function decisionVariant(output: unknown): 'granted' | 'denied' | 'unknown' {
|
||||
if (typeof output !== 'string') return 'unknown';
|
||||
if (output.startsWith('granted:')) return 'granted';
|
||||
if (output === 'denied' || output.startsWith('denied:')) return 'denied';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function RequestReadAccessCard({ toolCall, toolResult, chatId }: Props) {
|
||||
const args = parseArgs(toolCall.args);
|
||||
|
||||
if (!args) {
|
||||
return (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||
request_read_access: malformed tool args
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-null output means the WS tool_result frame arrived (or the row was
|
||||
// re-fetched from history).
|
||||
const answered = toolResult && toolResult.output !== null;
|
||||
if (answered) {
|
||||
return <AnsweredView args={args} output={toolResult!.output} />;
|
||||
}
|
||||
|
||||
return <PendingView args={args} toolCallId={toolCall.id} chatId={chatId} />;
|
||||
}
|
||||
|
||||
function PendingView({
|
||||
args,
|
||||
toolCallId,
|
||||
chatId,
|
||||
}: {
|
||||
args: ParsedArgs;
|
||||
toolCallId: string;
|
||||
chatId: string;
|
||||
}) {
|
||||
const [submitting, setSubmitting] = useState<'allow' | 'deny' | null>(null);
|
||||
|
||||
async function decide(decision: 'allow' | 'deny') {
|
||||
if (submitting) return;
|
||||
setSubmitting(decision);
|
||||
try {
|
||||
await api.chats.grantReadAccess(chatId, toolCallId, decision);
|
||||
// Card stays mounted; the incoming WS tool_result frame swaps it to
|
||||
// AnsweredView via the parent prop change.
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'request failed');
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 text-sm">
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
<ShieldOff className="size-3.5" />
|
||||
<span>Read-access request</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Path</div>
|
||||
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1">
|
||||
{args.path}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Reason</div>
|
||||
<div className="text-sm leading-snug whitespace-pre-wrap">{args.reason}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground pt-1">
|
||||
Allow grants the agent read access to the matching repository root for
|
||||
the rest of this session. Revoke any time from the session settings.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-amber-500/20 px-4 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={submitting !== null}
|
||||
onClick={() => void decide('deny')}
|
||||
>
|
||||
{submitting === 'deny' ? 'Denying…' : 'Deny'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={submitting !== null}
|
||||
onClick={() => void decide('allow')}
|
||||
>
|
||||
{submitting === 'allow' ? 'Allowing…' : 'Allow'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnsweredView({ args, output }: { args: ParsedArgs; output: unknown }) {
|
||||
const variant = decisionVariant(output);
|
||||
const text = typeof output === 'string' ? output : 'unknown';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
variant === 'granted'
|
||||
? 'rounded-lg border border-emerald-500/40 bg-emerald-500/5 text-sm'
|
||||
: variant === 'denied'
|
||||
? 'rounded-lg border bg-muted/20 text-sm'
|
||||
: 'rounded-lg border border-destructive/40 bg-destructive/5 text-sm'
|
||||
}
|
||||
>
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide">
|
||||
{variant === 'granted' ? (
|
||||
<>
|
||||
<Check className="size-3.5 text-emerald-600" />
|
||||
<span className="text-emerald-700 dark:text-emerald-300">Read access granted</span>
|
||||
</>
|
||||
) : variant === 'denied' ? (
|
||||
<>
|
||||
<ShieldOff className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Read access denied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldOff className="size-3.5 text-destructive" />
|
||||
<span className="text-destructive">Read access request — unknown result</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Path</div>
|
||||
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1">
|
||||
{args.path}
|
||||
</div>
|
||||
</div>
|
||||
{variant === 'granted' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Granted root</div>
|
||||
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1 flex items-center gap-1.5">
|
||||
<FolderOpen className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span>{text.replace(/^granted:\s*/, '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{variant === 'denied' && text !== 'denied' && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{text.replace(/^denied:\s*/, '')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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