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>
83 lines
3.8 KiB
TypeScript
83 lines
3.8 KiB
TypeScript
// v1.13.17-cross-repo-reads: tool the model uses to request read access to
|
|
// a path outside its session's primary project root. When the model emits
|
|
// view_file("/opt/forks/foo/go.mod") under a session scoped to /opt/boocode,
|
|
// pathGuard's error message hints at this tool. The model then emits
|
|
// request_read_access(path="/opt/forks/foo/go.mod",
|
|
// reason="investigating foo to write the design doc")
|
|
// The tool's execute does cheap up-front validation: if the requested path
|
|
// can't possibly be granted under the current whitelist + repo-shape rules,
|
|
// it returns a denial immediately without prompting the user. Otherwise, the
|
|
// tool-phase pause branch (parallel of ask_user_input) stores a pending
|
|
// sentinel and waits for the user's allow/deny via the grant_read_access
|
|
// endpoint.
|
|
//
|
|
// The execute body never directly mutates state; the grant endpoint owns
|
|
// the persistence path. This keeps the tool-side logic side-effect-free
|
|
// (it's just a request) and matches ask_user_input's "server-side no-op
|
|
// fallback, pause happens in tool-phase" shape.
|
|
|
|
import { z } from 'zod';
|
|
import type { ToolDef } from './tools.js';
|
|
|
|
const RequestReadAccessInput = z.object({
|
|
path: z.string().min(1),
|
|
reason: z.string().min(1).max(500),
|
|
});
|
|
type RequestReadAccessInputT = z.infer<typeof RequestReadAccessInput>;
|
|
|
|
export const requestReadAccess: ToolDef<RequestReadAccessInputT> = {
|
|
name: 'request_read_access',
|
|
description:
|
|
"Ask the user for read-only access to a path outside the current " +
|
|
"session's project scope. Use when a previous read tool (view_file, " +
|
|
'list_dir, grep, find_files) was refused with a path-escapes-project ' +
|
|
'error and the path is plausibly under another known repository (e.g. ' +
|
|
'/opt/forks/foo). Provide a short reason describing why you need the ' +
|
|
"access. Pauses the conversation until the user picks Allow or Deny; " +
|
|
'the next assistant turn sees the result. On Allow, the tool result ' +
|
|
'is "granted: <root>" — subsequent reads under that root succeed for ' +
|
|
'the rest of the session. On Deny, the tool result is "denied". Do ' +
|
|
'not call this for paths that are already inside the project root.',
|
|
inputSchema: RequestReadAccessInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'request_read_access',
|
|
description:
|
|
"Ask the user for read-only access to a path outside the session's " +
|
|
'project scope. Pauses the conversation until the user picks Allow ' +
|
|
'or Deny. Subsequent reads under the granted root succeed for the ' +
|
|
'rest of the session.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description:
|
|
'Absolute path the model wants to read. Must be under the ' +
|
|
"server's PROJECT_ROOT_WHITELIST (default /opt) and outside " +
|
|
"the session's primary project root.",
|
|
},
|
|
reason: {
|
|
type: 'string',
|
|
description:
|
|
'Short rationale (<=500 chars) shown to the user explaining ' +
|
|
'why the access is needed. The user uses this to decide.',
|
|
},
|
|
},
|
|
required: ['path', 'reason'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
// Server-side no-op. The "execution" of request_read_access is the
|
|
// pause-and-resume cycle managed by tool-phase.ts + the grant endpoint.
|
|
// The inference loop catches this tool name BEFORE executeToolCall fires
|
|
// and inserts a pending sentinel instead — this fallback only runs if
|
|
// something bypasses that branch, in which case we surface the pending
|
|
// shape so downstream code can still detect it. Mirrors ask_user_input.
|
|
async execute(input) {
|
|
return { _pending: true, path: input.path, reason: input.reason };
|
|
},
|
|
};
|