Files
boocode/apps/web/src/components/RequestReadAccessCard.tsx
indifferentketchup b52c5df705 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>
2026-05-22 21:45:52 +00:00

194 lines
7.0 KiB
TypeScript

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>
);
}