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>
11 KiB
v1.13.17-cross-repo-reads — on-demand read access to another repo (draft, 2026-05-22)
BooChat sessions are scoped to one project root. When the agent needs context from another repo (e.g. /opt/forks/codecontext to investigate a dependency), pathGuard rejects every read tool and the agent has no recovery path.
This batch adds a reactive ask_user_input-style flow that the agent triggers on PathScopeError. User approves once per session per project root; subsequent reads under that root succeed without further prompting.
Trigger flow
- Model emits
view_file("/opt/forks/codecontext/go.mod")while session is scoped to/opt/boocode. pathGuardthrowsPathScopeError. Existing tool wrapper catches it and returns the error to the model. The error message now ends with a hint:"Use request_read_access(path, reason) to ask the user for permission."- Model self-issues
request_read_access("/opt/forks/codecontext/go.mod", "investigating codecontext fork to write design doc")on the next turn. - The new tool emits a pending tool-call frame (same pause mechanism as
ask_user_input); inference loop pauses. - Frontend renders approve/deny chips with the path + reason.
- User picks Allow → append the grant root to
session.allowed_read_paths, resume inference, tool returns"granted: /opt/forks/codecontext". Model retries the originalview_fileon the next turn. - User picks Deny → tool returns
"denied"without mutating session state; model decides what to do next.
Decisions (draft — override in dispatch if different)
D1. Grant unit = nearest registered project root, then nearest path-whitelist ancestor, then refuse
When user approves access to /opt/forks/codecontext/go.mod:
- If a row in
projects.pathis an ancestor of the requested path → grant the project's root path. - Else if
PROJECT_ROOT_WHITELISTenv (default/opt) is an ancestor and the immediate child dir of the whitelist looks like a repo root (.git/,package.json,go.mod, orCargo.tomlpresent) → grant that immediate child dir (e.g./opt/forks/codecontext). - Else → refuse without prompting. Tool returns
"denied: path outside permitted scope". No user prompt fires.
Why: granting the literal path is too narrow (next file in the same repo re-prompts). Granting an arbitrary parent dir over-scopes. The nearest repo-shaped directory is the natural unit.
D2. Persistence = per-session, no expiry
sessions.allowed_read_paths is the source of truth. Grants stick until the session is archived. A new session in the same project re-prompts on the first cross-repo read.
Why: per-chat is too granular for the typical workflow (Sam investigates the same fork across multiple chats in one investigation session). Per-project is too broad (different sessions in the same project might have different scope needs). Per-session is the natural unit and matches session.web_search_enabled's scope.
D3. Secret-file deny list applies across all grant roots
is_secret_path in secret_guard.ts filters filenames (.env, *.pem, credentials.json, etc.) regardless of which root they're under. The check is post-pathGuard, so it already runs on the resolved path. No change needed.
D4. Revocation UI = chat-settings panel + automatic clear on archive
- Settings panel under the session-info popover: lists current
allowed_read_pathswith a per-row delete button. - Session archive deletes the row (no need to clear allowed_read_paths separately — the row goes).
- No expiry timer.
Optional v1.13.18 follow-up if Sam wants it: a /clear_grants slash command for power users. Out of scope for v1.13.17.
Schema
-- v1.13.17: session-scoped cross-repo read grants. Populated via the
-- request_read_access tool's approve path; never written by other code.
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS allowed_read_paths text[] NOT NULL DEFAULT ARRAY[]::text[];
No CHECK constraint — values are absolute paths validated at write time against the projects table + whitelist heuristic.
New tool: request_read_access
// apps/server/src/services/request_read_access.ts (new)
export const requestReadAccessInput = z.object({
path: z.string().min(1),
reason: z.string().min(1).max(500),
});
export const requestReadAccess: ToolDef<...> = {
name: 'request_read_access',
description:
'Ask the user for read-only access to a path outside the current ' +
'session\'s project scope. Use when pathGuard rejected a read ' +
'attempt and the path is plausibly under another known repo. ' +
'Returns "granted: <root>" or "denied".',
inputSchema: requestReadAccessInput,
jsonSchema: { ... },
category: 'read_only',
async execute(input, projectRoot) {
// Validate path: must be absolute, must be under PROJECT_ROOT_WHITELIST
// (default /opt), must NOT already be under the session's primary
// projectRoot (silly to ask for what's already in scope).
// Validation failures return sentinel without prompting the user.
// Emit pending-grant tool result (parallel of ask_user_input's pause
// sentinel). Inference loop pauses on this kind=pending_grant marker.
// User picks Allow/Deny via a new POST /api/messages/:id/grant endpoint.
// On Allow: derive grant root per D1 + UPDATE sessions SET
// allowed_read_paths = array_append(allowed_read_paths, <root>);
// resume inference; tool returns "granted: <root>".
// On Deny: resume immediately; tool returns "denied".
},
};
Registered in ALL_TOOLS + READ_ONLY_TOOL_NAMES. Available to all agents by default (no agent's tools whitelist needs to be updated to grant access — the tool registry's filter is per-agent).
pathGuard extension
// apps/server/src/services/path_guard.ts — current signature:
// pathGuard(projectRoot, requestedPath): Promise<string>
//
// Extended:
// pathGuard(projectRoot, requestedPath, extraRoots?: string[]): Promise<string>
//
// Tries primary projectRoot first; on PathScopeError, walks extraRoots and
// returns the first one that resolves the requestedPath inside its tree.
// Throws PathScopeError if no root accepts.
Every tool that calls pathGuard (currently view_file, list_dir, grep, find_files, view_truncated_output) threads session.allowed_read_paths through executeToolCall. The Session interface already flows through TurnArgs; tool-phase just needs to forward session.allowed_read_paths as the third arg.
Pause/resume infrastructure reuse
The pending-grant pause uses the same mechanism as ask_user_input:
- Tool insert with
payload.output = null+payload.kind = 'pending_grant'. pausingForUserInputbranch intool-phase.tsis widened to also catch pending grants.chat_statusflips towaiting_for_inputper the v1.12.1 5-state model.
New endpoint POST /api/messages/:tool_msg_id/grant (parallel of the existing /answer):
- Body:
{ decision: 'allow' | 'deny' }. - Resolves grant root per D1 if Allow. UPDATEs
sessions.allowed_read_paths. UPDATEs tool message with output. Resumes inference via existing enqueue path.
Frontend changes (in scope; small)
MessageBubble.tsx: renderpending_granttool messages with Allow/Deny chips + the path + reason text. Wires toapi.messages.grant(toolMsgId, decision).- New API client method
api.messages.grant. - Settings popover:
allowed_read_pathslist with per-row delete (callsPATCH /api/sessions/:idwith the modified array).
Hard rules
- No git commit, no git push, no git pull during dispatch. Sam commits manually.
- Backup every file before edit per the standard convention.
- TS strict, no
any. - No new deps.
- Schema migration is additive only (ADD COLUMN IF NOT EXISTS), idempotent on re-run.
- Tool is read-only — no path under
allowed_read_pathscan ever be written by BooChat (no write tools registered today; this is a structural guarantee). - Secret-file deny list still runs unconditionally on resolved paths.
Stop checkpoints
- After recon (read existing path_guard + ask_user_input + answer endpoint patterns): stop, hand back the recon report.
- After code edits, before schema migration applies: stop, hand back the diff.
- After schema migration applies in dev: stop, run smoke plan, report.
Smoke plan
- Approve flow. Send a chat in a
/opt/boocodesession asking the agent to investigate/opt/forks/codecontext/go.mod. Confirm:pathGuardthrows on the first attempt; tool result includes therequest_read_accesshint.- Agent calls
request_read_access; tool-call frame lands; chat status flips towaiting_for_input. - Frontend renders Allow/Deny chips with the path + reason.
- Pick Allow → grant root resolves to
/opt/forks/codecontext(per D1);sessions.allowed_read_pathsshows the entry; agent retriesview_filesuccessfully on the next turn.
- Deny flow. Same setup; pick Deny. Confirm session state unchanged, tool returns
"denied", agent gives up or asks differently. - Persistence. In the same session, a second
view_fileagainst a different file under/opt/forks/codecontext/succeeds without re-prompting. - Cross-session isolation. Open a fresh session in the boocode project, try the same path — re-prompts (allowed_read_paths is empty on the new session).
- Secret-file deny still fires. Approve access to a repo that contains a
.envfile. Tryview_file('/opt/forks/some-repo/.env'). Confirm refused viais_secret_path, not via pathGuard scope. - Out-of-scope refusal. Try
request_read_access('/etc/passwd', 'system file'). Tool validates against the whitelist + repo-shape heuristic, returns"denied: path outside permitted scope"without prompting the user.
Done when
- New
request_read_accesstool +POST /api/messages/:id/grantendpoint shipped. path_guard.tsextended; all read tools forwardallowed_read_paths.MessageBubble.tsxrenders pending-grant bubbles; settings popover lists + clears grants.- Schema migration applied (sessions.allowed_read_paths).
- Smoke plan green.
- v1.13.17-cross-repo-reads tag + CHANGELOG entry + roadmap retrospective bullet.
Files expected to touch
apps/server/src/schema.sql— new columnapps/server/src/services/request_read_access.ts— NEWapps/server/src/services/path_guard.ts— extra-roots param + helpful PathScopeError messageapps/server/src/services/tools.ts— register the new tool, update view_file / list_dir / grep / find_files / view_truncated_output to thread allowed_read_pathsapps/server/src/services/inference/tool-phase.ts— pause-on-pending-grant branch (alongside ask_user_input)apps/server/src/routes/messages.ts— new/grantendpointapps/server/src/types/api.ts—Session.allowed_read_pathsapps/web/src/api/client.ts—api.messages.grantapps/web/src/api/types.ts—Session.allowed_read_pathsapps/web/src/components/MessageBubble.tsx— render pending_grant chipsapps/web/src/components/— settings-popover grants list (file TBD during impl)
Estimate: ~120 LoC across backend + frontend + schema. Single batch.
Open questions for dispatch
The four design decisions above are my recommendations. Override any of them in the dispatch and I'll update the proposal before recon. Most likely-overridable: D1 (grant unit — you may want exact-path-only for tighter scoping, accepting the re-prompt cost) and D4 (revocation UI — you may want it deferred entirely).