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>
194 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|