From b52c5df705db82c4db298eb2f97817cea2ef9e50 Mon Sep 17 00:00:00 2001
From: indifferentketchup
Date: Fri, 22 May 2026 21:45:52 +0000
Subject: [PATCH] v1.13.17-cross-repo-reads: on-demand read access to paths
outside the project root
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
CHANGELOG.md | 4 +
apps/server/src/index.ts | 2 +-
.../src/routes/__tests__/sessions.test.ts | 70 +++++
apps/server/src/routes/messages.ts | 253 ++++++++++++++++++
apps/server/src/routes/sessions.ts | 97 ++++++-
apps/server/src/schema.sql | 10 +
.../services/__tests__/grant_resolver.test.ts | 199 ++++++++++++++
.../src/services/__tests__/path_guard.test.ts | 93 +++++++
apps/server/src/services/file_ops.ts | 24 +-
apps/server/src/services/grant_resolver.ts | 161 +++++++++++
.../src/services/inference/tool-phase.ts | 75 +++++-
apps/server/src/services/path_guard.ts | 28 +-
.../src/services/request_read_access.ts | 82 ++++++
apps/server/src/services/tools.ts | 47 +++-
apps/server/src/types/api.ts | 6 +
apps/web/src/api/client.ts | 28 +-
apps/web/src/api/types.ts | 5 +
apps/web/src/components/MessageList.tsx | 15 +-
.../src/components/RequestReadAccessCard.tsx | 193 +++++++++++++
.../web/src/components/panes/SettingsPane.tsx | 74 ++++-
.../v1.13.17-cross-repo-reads/proposal.md | 185 +++++++++++++
21 files changed, 1610 insertions(+), 41 deletions(-)
create mode 100644 apps/server/src/routes/__tests__/sessions.test.ts
create mode 100644 apps/server/src/services/__tests__/grant_resolver.test.ts
create mode 100644 apps/server/src/services/__tests__/path_guard.test.ts
create mode 100644 apps/server/src/services/grant_resolver.ts
create mode 100644 apps/server/src/services/request_read_access.ts
create mode 100644 apps/web/src/components/RequestReadAccessCard.tsx
create mode 100644 openspec/changes/v1.13.17-cross-repo-reads/proposal.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d44e367..df317b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
+## v1.13.17-cross-repo-reads — 2026-05-22
+
+On-demand read access to paths outside the session's primary project root. Closes the dead-end where `pathGuard` rejected every cross-repo read with no recovery path. New `request_read_access(path, reason)` tool emits an `ask_user_input`-style pause; user picks Allow/Deny via inline chips in `RequestReadAccessCard.tsx`; on Allow, the new `POST /api/chats/:id/grant_read_access` endpoint re-resolves the grant root and appends to `sessions.allowed_read_paths` (new `TEXT[]` column, default empty). Grant unit per design D1 = nearest registered `projects.path` ancestor → else nearest repo-shaped ancestor (`.git/` / `package.json` / `go.mod` / `Cargo.toml`) under `PROJECT_ROOT_WHITELIST` → else refuse without prompting. `pathGuard` extended with an optional `extraRoots` argument threaded from `session.allowed_read_paths` through `executeToolCall` to the four filesystem tools (view_file, list_dir, grep, find_files); `view_file` re-anchors the secret-guard check on `basename(real)` whenever the path resolved via a grant root so `.env` / `id_rsa*` deny still fires across grants. `grant_resolver.ts`'s ancestor walk checks the whitelist invariant on every iteration (not just final parent) so a symlinked input can't escape mid-walk. PATCH `/api/sessions/:id` exposes `allowed_read_paths` only for revocation: zod refines paths to absolute + no traversal markers, and a runtime subset guard (`findUnauthorizedAdditions`) rejects any entry not already present in the row, so a malicious `curl -X PATCH -d '{"allowed_read_paths":["/etc"]}'` 400s instead of bypassing the grant flow. Settings pane gains a per-session revoke list; archiving the session clears grants implicitly. 11 grant_resolver tests pin the symlink-escape-mid-walk guard (Sam's checkpoint-1 ask) and the nearest-project disambiguation; 8 path_guard tests cover extraRoots traversal; 8 sessions PATCH tests cover the subset guard including the `/etc` bypass attempt. Pairs with `v1.13.16-xml-parser` (model now both self-recovers from a wrong tool name AND from a refused path).
+
## v1.13.16-xml-parser — 2026-05-22
Two-part fix for the model-emitted XML drift the v1.13.15 investigation surfaced. **Parser extension:** `xml-parser.ts` now recognizes the Anthropic `…` shape alongside the existing Qwen/Hermes `…` shape. qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted as an Architect-style agent (Claude Code documentation in its pre-training corpus). Both formats route through the same synthetic-id `xml_call_${idx}` ToolCall path. The existing Qwen parser was tightened to tolerate whitespace around `=` (`` shape) so a stray space doesn't get absorbed into the function name. **Unknown-tool recovery hint:** new `tool-suggestions.ts` exports `levenshtein()` + `suggestToolName()` + `formatUnknownToolError()`. When the dispatcher (`tool-phase.ts:executeToolCall`) receives an unknown tool name, the error returned to the model includes a "Did you mean: X?" hint based on Levenshtein distance ≤3 or substring match against `Object.keys(TOOLS_BY_NAME)`. Targets the qwen3.6 drift to `read_file` → suggest `view_file`. Test coverage in `xml-parser.test.ts` (46 tests, all green) covers both parsers, the partial-opener detector for both flavors, the unified extraction helper, and the new error formatter.
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 57c499e..dca70b6 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -115,7 +115,7 @@ async function main() {
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
}
);
- registerMessageRoutes(app, sql, {
+ registerMessageRoutes(app, sql, config, broker, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);
},
diff --git a/apps/server/src/routes/__tests__/sessions.test.ts b/apps/server/src/routes/__tests__/sessions.test.ts
new file mode 100644
index 0000000..5f128f0
--- /dev/null
+++ b/apps/server/src/routes/__tests__/sessions.test.ts
@@ -0,0 +1,70 @@
+// v1.13.17-cross-repo-reads: PATCH /api/sessions/:id allowed_read_paths
+// subset enforcement. Sam flagged in the compliance review that without a
+// runtime subset check, a malicious client could POST
+// {"allowed_read_paths":["/etc"]}
+// and bypass the user-consent grant flow entirely. The findUnauthorizedAdditions
+// helper is the guard; tests pin its behavior so a regression in the helper
+// or its callsite (PATCH handler in sessions.ts) trips CI before prod.
+
+import { describe, it, expect } from 'vitest';
+import { findUnauthorizedAdditions } from '../sessions.js';
+
+describe('findUnauthorizedAdditions — PATCH allowed_read_paths subset guard', () => {
+ it('returns no extras when requested is empty (full revoke)', () => {
+ expect(findUnauthorizedAdditions(['/opt/forks/foo'], [])).toEqual([]);
+ });
+
+ it('returns no extras when requested is a strict subset (single revoke)', () => {
+ expect(
+ findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], ['/opt/forks/foo']),
+ ).toEqual([]);
+ });
+
+ it('returns no extras when requested equals prior (no-op PATCH)', () => {
+ expect(
+ findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
+ '/opt/forks/foo',
+ '/opt/forks/bar',
+ ]),
+ ).toEqual([]);
+ });
+
+ it('flags an unauthorized addition when prior is empty', () => {
+ // The /etc bypass attempt — Sam's specific concern from the compliance
+ // review. Without this guard, the PATCH would have written /etc directly.
+ expect(findUnauthorizedAdditions([], ['/etc'])).toEqual(['/etc']);
+ });
+
+ it('flags a single unauthorized addition mixed in with valid revokes', () => {
+ // The attacker still tries to be sneaky: keep one legit entry, drop
+ // another, slip in a new one. The guard catches the addition regardless
+ // of how the rest of the array shrinks.
+ expect(
+ findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
+ '/opt/forks/foo',
+ '/var/secrets',
+ ]),
+ ).toEqual(['/var/secrets']);
+ });
+
+ it('flags every unauthorized addition when there are multiple', () => {
+ expect(
+ findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/etc', '/root']),
+ ).toEqual(['/etc', '/root']);
+ });
+
+ it('treats requested duplicates correctly (each occurrence checked)', () => {
+ // If the requested array has duplicates of an unauthorized entry, the
+ // guard surfaces each one. (A frontend would never send duplicates, but
+ // the guard's contract shouldn't assume that.)
+ expect(findUnauthorizedAdditions([], ['/etc', '/etc'])).toEqual(['/etc', '/etc']);
+ });
+
+ it('does not flag entries present in prior even if requested has duplicates', () => {
+ // Duplicate of an authorized entry passes — the membership check is by
+ // value, not by index. Settled by Set.has semantics.
+ expect(
+ findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/opt/forks/foo']),
+ ).toEqual([]);
+ });
+});
diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts
index 4274a0e..86cca2a 100644
--- a/apps/server/src/routes/messages.ts
+++ b/apps/server/src/routes/messages.ts
@@ -1,7 +1,13 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
+import type { Config } from '../config.js';
+import type { Broker } from '../services/broker.js';
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
+// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
+// decision time (not at request time) so concurrent project changes don't
+// stale-bind the resolution.
+import { resolveGrantRoot } from '../services/grant_resolver.js';
const SendBody = z.object({
content: z.string().min(1).max(64_000),
@@ -47,6 +53,21 @@ const AskUserInputArgs = z.object({
.max(3),
});
+// v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the
+// model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary.
+const GrantReadAccessBody = z.object({
+ tool_call_id: z.string().min(1),
+ decision: z.enum(['allow', 'deny']),
+});
+
+// Same shape as services/request_read_access.ts RequestReadAccessInput.
+// Re-derived to avoid the services/tools.ts import (matches the
+// AskUserInputArgs pattern above).
+const RequestReadAccessArgs = z.object({
+ path: z.string().min(1),
+ reason: z.string().min(1).max(500),
+});
+
interface MessageHandlers {
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
// v1.11: returns a promise that resolves after compaction.process finishes
@@ -76,6 +97,8 @@ interface MessageHandlers {
export function registerMessageRoutes(
app: FastifyInstance,
sql: Sql,
+ config: Config,
+ broker: Broker,
handlers: MessageHandlers
): void {
app.get<{ Params: { id: string } }>(
@@ -626,4 +649,234 @@ export function registerMessageRoutes(
return result;
},
);
+
+ // v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape
+ // of /answer_user_input (validate, look up via message_parts, UPDATE,
+ // publish, enqueue). Differences vs /answer_user_input:
+ // - On 'allow', re-resolves the grant root via grant_resolver (state
+ // may have changed since the prompt fired — concurrent project add,
+ // etc.). Resolution failure auto-falls to a denial with reason text
+ // rather than 500ing.
+ // - On 'allow' with a valid root, appends to sessions.allowed_read_paths
+ // (deduplicated) inside the same transaction.
+ // - On success, also publishes session_updated so an open SettingsPane
+ // refetches the new grant list.
+ // Error codes match /answer:
+ // 400 invalid_body / mismatched_answer_shape (bad args on the tool_call)
+ // 404 chat_not_found / unknown_tool_call_id
+ // 409 tool_call_already_answered
+ app.post<{ Params: { id: string } }>(
+ '/api/chats/:id/grant_read_access',
+ async (req, reply) => {
+ const parsed = GrantReadAccessBody.safeParse(req.body);
+ if (!parsed.success) {
+ reply.code(400);
+ return { error: 'invalid_body', details: parsed.error.flatten() };
+ }
+ const { tool_call_id, decision } = parsed.data;
+
+ const chatRows = await sql`
+ SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
+ `;
+ if (chatRows.length === 0) {
+ reply.code(404);
+ return { error: 'chat_not_found' };
+ }
+ const chat = chatRows[0]!;
+ const sessionId = chat.session_id;
+
+ // Mirror the /answer lookup: assistant tool_call by id via message_parts.
+ const callerRows = await sql<{
+ message_id: string;
+ payload: { id: string; name: string; args: Record };
+ }[]>`
+ SELECT p.message_id, p.payload
+ FROM message_parts p
+ JOIN messages m ON m.id = p.message_id
+ WHERE m.chat_id = ${chat.id}
+ AND m.role = 'assistant'
+ AND p.kind = 'tool_call'
+ AND p.payload->>'id' = ${tool_call_id}
+ ORDER BY m.created_at DESC
+ LIMIT 1
+ `;
+ const callerRow = callerRows[0];
+ if (!callerRow) {
+ reply.code(404);
+ return { error: 'unknown_tool_call_id' };
+ }
+ const foundCall: ToolCall = {
+ id: callerRow.payload.id,
+ name: callerRow.payload.name,
+ args: callerRow.payload.args,
+ };
+ if (foundCall.name !== 'request_read_access') {
+ reply.code(400);
+ return { error: 'tool_call_not_request_read_access' };
+ }
+ const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
+ if (!argsParsed.success) {
+ reply.code(400);
+ return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
+ }
+ const requestedPath = argsParsed.data.path;
+
+ // Find the pending tool row.
+ const toolRows = await sql<{
+ message_id: string;
+ payload: { tool_call_id: string; output: unknown };
+ }[]>`
+ SELECT p.message_id, p.payload
+ FROM message_parts p
+ JOIN messages m ON m.id = p.message_id
+ WHERE m.chat_id = ${chat.id}
+ AND m.role = 'tool'
+ AND p.kind = 'tool_result'
+ AND p.payload->>'tool_call_id' = ${tool_call_id}
+ ORDER BY m.created_at DESC
+ LIMIT 1
+ `;
+ const toolRow = toolRows[0];
+ if (!toolRow) {
+ reply.code(404);
+ return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
+ }
+ if (toolRow.payload && toolRow.payload.output !== null) {
+ reply.code(409);
+ return { error: 'tool_call_already_answered' };
+ }
+
+ // Look up session + project so we can re-resolve the grant root and
+ // append to allowed_read_paths atomically. We don't need agent or
+ // history here — just the project path for the resolver.
+ const sessionRows = await sql<{
+ id: string;
+ project_id: string;
+ allowed_read_paths: string[];
+ project_path: string;
+ }[]>`
+ SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path
+ FROM sessions s
+ JOIN projects p ON p.id = s.project_id
+ WHERE s.id = ${sessionId}
+ `;
+ const sessionRow = sessionRows[0];
+ if (!sessionRow) {
+ reply.code(404);
+ return { error: 'session_not_found' };
+ }
+
+ // Decision branch. 'deny' is the easy path: nothing to resolve or
+ // persist. 'allow' resolves the grant root; if resolution fails (e.g.
+ // path was deleted, project removed since prompt) the tool gets a
+ // denial with the resolver's reason text instead of a 500.
+ let resultOutput: string;
+ let grantRoot: string | null = null;
+ if (decision === 'allow') {
+ const resolution = await resolveGrantRoot(
+ sql,
+ requestedPath,
+ sessionRow.project_path,
+ config.PROJECT_ROOT_WHITELIST,
+ );
+ if (!resolution.ok) {
+ resultOutput = `denied: ${resolution.reason}`;
+ } else {
+ grantRoot = resolution.root;
+ resultOutput = `granted: ${grantRoot}`;
+ }
+ } else {
+ resultOutput = 'denied';
+ }
+
+ const newToolResults = {
+ tool_call_id,
+ output: resultOutput,
+ truncated: false,
+ };
+ const toolMessageId = toolRow.message_id;
+ const dbResult = await sql.begin(async (tx) => {
+ await tx`
+ UPDATE messages
+ SET tool_results = ${tx.json(newToolResults as never)}
+ WHERE id = ${toolMessageId}
+ `;
+ // Same delete+insert dance as /answer — UNIQUE (message_id, sequence)
+ // blocks plain UPDATE on append-style parts.
+ await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
+ await tx`
+ INSERT INTO message_parts (message_id, sequence, kind, payload)
+ VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
+ `;
+ // Persist the grant if we have one. ARRAY-level dedup — append only
+ // when the root isn't already present. The session row gets
+ // touched (updated_at) so the post-update publish below has a
+ // fresh timestamp.
+ let allowedRootsAfter = sessionRow.allowed_read_paths;
+ if (grantRoot !== null) {
+ if (!sessionRow.allowed_read_paths.includes(grantRoot)) {
+ const updated = await tx<{ allowed_read_paths: string[] }[]>`
+ UPDATE sessions
+ SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}),
+ updated_at = clock_timestamp()
+ WHERE id = ${sessionId}
+ RETURNING allowed_read_paths
+ `;
+ allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths;
+ } else {
+ // Already present — touch updated_at so any open settings
+ // panel still picks up the no-op via session_updated.
+ await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
+ }
+ }
+ const [assistantMsg] = await tx<{ id: string }[]>`
+ INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
+ VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
+ RETURNING id
+ `;
+ await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
+ return {
+ tool_message_id: toolMessageId,
+ assistant_message_id: assistantMsg!.id,
+ allowed_roots_after: allowedRootsAfter,
+ };
+ });
+
+ // Publish the deferred tool_result frame so the pending card flips to
+ // its answered view without a refetch.
+ handlers.publishSessionFrame(sessionId, {
+ type: 'tool_result',
+ tool_message_id: dbResult.tool_message_id,
+ tool_call_id,
+ chat_id: chat.id,
+ output: resultOutput,
+ truncated: false,
+ });
+ // session_updated nudge so any open SettingsPane refetches and sees
+ // the new allowed_read_paths. We publish on the user channel to match
+ // the existing PATCH /api/sessions/:id behavior — frontend refetches
+ // via api.sessions.get on receipt.
+ const nowIso = new Date().toISOString();
+ broker.publishUserFrame('default', {
+ type: 'session_updated',
+ session_id: sessionId,
+ project_id: sessionRow.project_id,
+ // session name doesn't change on grant; we look it up fresh to
+ // avoid carrying stale state if a rename raced us.
+ name:
+ (
+ await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}`
+ )[0]?.name ?? '',
+ updated_at: nowIso,
+ });
+ handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default');
+
+ reply.code(202);
+ return {
+ tool_message_id: dbResult.tool_message_id,
+ assistant_message_id: dbResult.assistant_message_id,
+ allowed_read_paths: dbResult.allowed_roots_after,
+ };
+ },
+ );
}
diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts
index c5161a0..acabdad 100644
--- a/apps/server/src/routes/sessions.ts
+++ b/apps/server/src/routes/sessions.ts
@@ -32,6 +32,29 @@ const PatchBody = z.object({
agent_id: z.string().min(1).max(200).nullable().optional(),
// v1.9: null = inherit from project default; true/false = explicit override.
web_search_enabled: z.boolean().nullable().optional(),
+ // v1.13.17-cross-repo-reads: revocation pathway. PATCH with a shortened
+ // list deletes entries; the grant flow itself APPENDS via the separate
+ // grant_read_access endpoint, never via this PATCH. Frontend treats this
+ // as "send the new whole array". Per-entry shape validation: must be
+ // absolute, no NUL, no `/..` traversal segment. Server doesn't re-validate
+ // whitelist membership on PATCH — entries already in the array were
+ // placed there by the grant endpoint after a full whitelist+repo-shape
+ // check. THE SUBSET CHECK (every entry must already be in the current
+ // array) is enforced at runtime in the PATCH handler below, NOT in this
+ // zod refinement, because the refinement has no access to the existing
+ // session row.
+ allowed_read_paths: z
+ .array(
+ z
+ .string()
+ .min(1)
+ .max(1024)
+ .refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('/..'), {
+ message: 'must be an absolute path without traversal markers',
+ }),
+ )
+ .max(64)
+ .optional(),
});
async function resolveDefaultModel(sql: Sql, config: Config): Promise {
@@ -40,6 +63,19 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise {
return config.DEFAULT_MODEL;
}
+// v1.13.17-cross-repo-reads: subset enforcement for PATCH allowed_read_paths.
+// The PATCH route can only SHRINK the array; growth happens exclusively via
+// POST /api/chats/:id/grant_read_access (which requires user consent).
+// Returns the list of disallowed-additions; an empty list means the request
+// is a valid shrink-or-no-op. Exported for the unit test.
+export function findUnauthorizedAdditions(
+ prior: readonly string[],
+ requested: readonly string[],
+): string[] {
+ const priorSet = new Set(prior);
+ return requested.filter((p) => !priorSet.has(p));
+}
+
export function registerSessionRoutes(
app: FastifyInstance,
sql: Sql,
@@ -56,7 +92,7 @@ export function registerSessionRoutes(
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql`
- SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
+ SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
@@ -124,7 +160,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql`
- SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
+ SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -150,15 +186,53 @@ export function registerSessionRoutes(
const newAgentId = parsed.data.agent_id ?? null;
const wseProvided = parsed.data.web_search_enabled !== undefined;
const newWse = parsed.data.web_search_enabled ?? null;
- // Read the prior name so the post-update publish can skip no-op renames
- // (PATCH { name: "Foo" } where the session is already "Foo"). The window
- // between SELECT and UPDATE is sub-millisecond in the same request handler;
- // a concurrent rename in that gap would just mean one stale publish, which
- // existing clients dedup by id.
- const before = await sql<{ name: string }[]>`
- SELECT name FROM sessions WHERE id = ${req.params.id}
+ // v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
+ // change, [] = clear). Frontend currently uses this PATCH only for
+ // revocation (delete a single entry from the existing array, send
+ // shortened result). Append-style grants go through the dedicated
+ // grant_read_access endpoint inside the inference loop.
+ const arpProvided = parsed.data.allowed_read_paths !== undefined;
+ const newArp = parsed.data.allowed_read_paths ?? [];
+ // Read the prior name + grants so the post-update publish can skip no-op
+ // renames (PATCH { name: "Foo" } where the session is already "Foo") AND
+ // so the subset check below has the current grant list to compare against.
+ // The window between SELECT and UPDATE is sub-millisecond in the same
+ // request handler; a concurrent rename in that gap would just mean one
+ // stale publish, which existing clients dedup by id.
+ const before = await sql<{ name: string; allowed_read_paths: string[] }[]>`
+ SELECT name, allowed_read_paths FROM sessions WHERE id = ${req.params.id}
`;
const priorName = before[0]?.name;
+ const priorArp = before[0]?.allowed_read_paths ?? [];
+
+ // v1.13.17-cross-repo-reads: subset enforcement. The grant flow is the
+ // ONLY path that can add entries to allowed_read_paths — PATCH can only
+ // shrink the array, never grow it. Without this guard, a malicious
+ // client could POST {"allowed_read_paths":["/etc"]} and bypass the
+ // user-consent prompt entirely. Sam flagged this in the v1.13.17
+ // compliance review (2026-05-22).
+ // Race note: a concurrent grant landing between this SELECT and the
+ // UPDATE below would briefly make a "shouldn't-have-been-valid" PATCH
+ // succeed (the newly-granted root sneaks in). Inverse race — a
+ // legitimate revoke happening alongside a concurrent grant — could
+ // briefly reject the revoke; the user retries. Both are acceptable
+ // given the single-user threat model + sub-millisecond window.
+ if (arpProvided) {
+ const extras = findUnauthorizedAdditions(priorArp, newArp);
+ if (extras.length > 0) {
+ reply.code(400);
+ return {
+ error: 'invalid body',
+ details: {
+ fieldErrors: {
+ allowed_read_paths: [
+ `entries must already be granted; cannot add via PATCH: ${extras.join(', ')}`,
+ ],
+ },
+ },
+ };
+ }
+ }
const rows = await sql`
UPDATE sessions
SET
@@ -167,10 +241,11 @@ export function registerSessionRoutes(
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
+ allowed_read_paths = CASE WHEN ${arpProvided} THEN ${sql.array(newArp, 25)} ELSE allowed_read_paths END,
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
- agent_id, web_search_enabled, workspace_panes
+ agent_id, web_search_enabled, workspace_panes, allowed_read_paths
`;
if (rows.length === 0) {
reply.code(404);
@@ -213,7 +288,7 @@ export function registerSessionRoutes(
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
- agent_id, web_search_enabled, workspace_panes
+ agent_id, web_search_enabled, workspace_panes, allowed_read_paths
`;
if (rows.length === 0) {
reply.code(404);
diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql
index c5597e8..1013e03 100644
--- a/apps/server/src/schema.sql
+++ b/apps/server/src/schema.sql
@@ -330,6 +330,16 @@ END $$;
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
+-- v1.13.17-cross-repo-reads: session-scoped read grants for paths outside the
+-- session's primary project root. Populated only by the request_read_access
+-- tool's approve branch; revoked via PATCH /api/sessions/:id. Values are
+-- absolute paths to project roots OR repo-shaped dirs under
+-- PROJECT_ROOT_WHITELIST (default /opt). No CHECK constraint — validation
+-- happens at write time in services/grant_resolver.ts. Cleared automatically
+-- when the session row is deleted (no cascade needed; the column goes with it).
+ALTER TABLE sessions
+ ADD COLUMN IF NOT EXISTS allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
-- reasons. JSONB so future kinds can extend without further schema churn.
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
diff --git a/apps/server/src/services/__tests__/grant_resolver.test.ts b/apps/server/src/services/__tests__/grant_resolver.test.ts
new file mode 100644
index 0000000..c259c5a
--- /dev/null
+++ b/apps/server/src/services/__tests__/grant_resolver.test.ts
@@ -0,0 +1,199 @@
+// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree.
+//
+// Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor
+// walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
+// filesystem root — check on every iteration, not just final parent.
+// Symlinked input must not be able to escape the whitelist during the
+// walk." The symlink-escape-mid-walk test below pins that invariant —
+// without the per-iteration whitelist check, this case would walk OUTSIDE
+// the whitelist root and return a phantom grant.
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { realpath } from 'node:fs/promises';
+import { resolveGrantRoot } from '../grant_resolver.js';
+import type { Sql } from '../../db.js';
+
+let tmp: string;
+let whitelist: string;
+let project: string;
+let fork: string;
+let outside: string;
+
+// Fake sql tag — returns the projects rows we want without touching a real
+// database. The resolver only ever does a single SELECT, so a single-shot
+// mock that returns the prepared rows on every invocation is enough.
+function makeSql(rows: Array<{ path: string }>): Sql {
+ const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql;
+ return tag;
+}
+
+beforeAll(async () => {
+ tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-')));
+ whitelist = join(tmp, 'whitelist');
+ project = join(whitelist, 'boocode');
+ fork = join(whitelist, 'forks', 'codecontext');
+ outside = join(tmp, 'outside');
+ await mkdir(project, { recursive: true });
+ await mkdir(fork, { recursive: true });
+ await mkdir(outside, { recursive: true });
+ // Mark project as a repo (.git directory).
+ await mkdir(join(project, '.git'));
+ await writeFile(join(project, 'README.md'), 'project readme');
+ // Mark fork as a repo via go.mod (matches the proposal's example).
+ await writeFile(join(fork, 'go.mod'), 'module example.com/foo');
+ await writeFile(join(fork, 'main.go'), 'package main');
+ await writeFile(join(outside, 'secret.txt'), 'forbidden');
+});
+
+afterAll(async () => {
+ await rm(tmp, { recursive: true, force: true });
+});
+
+describe('resolveGrantRoot — happy paths', () => {
+ it('refuses when the requested path is already under projectRoot', async () => {
+ const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toMatch(/already accessible/);
+ });
+
+ it('returns the project root when the path falls under a registered project', async () => {
+ // Register `fork` as a known project. Resolver should return the project
+ // ancestor (LONGEST match wins) rather than the repo-shape fallback.
+ const result = await resolveGrantRoot(
+ makeSql([{ path: fork }]),
+ join(fork, 'main.go'),
+ project,
+ whitelist,
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.root).toBe(fork);
+ expect(result.source).toBe('project');
+ }
+ });
+
+ it('falls back to the nearest repo-shaped ancestor when no project matches', async () => {
+ const result = await resolveGrantRoot(
+ makeSql([]),
+ join(fork, 'main.go'),
+ project,
+ whitelist,
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.root).toBe(fork);
+ expect(result.source).toBe('whitelist');
+ }
+ });
+});
+
+describe('resolveGrantRoot — refusals', () => {
+ it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => {
+ const result = await resolveGrantRoot(
+ makeSql([]),
+ join(outside, 'secret.txt'),
+ project,
+ whitelist,
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
+ });
+
+ it('refuses non-absolute paths', async () => {
+ const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toMatch(/absolute/);
+ });
+
+ it('refuses missing paths without prompting', async () => {
+ const result = await resolveGrantRoot(
+ makeSql([]),
+ join(whitelist, 'nope'),
+ project,
+ whitelist,
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toMatch(/does not exist/);
+ });
+
+ it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => {
+ // Build a directory tree under the whitelist that has NO repo markers
+ // all the way up to the whitelist root.
+ const plain = join(whitelist, 'plain-dir', 'nested');
+ await mkdir(plain, { recursive: true });
+ await writeFile(join(plain, 'just-a-file.txt'), 'x');
+ const result = await resolveGrantRoot(
+ makeSql([]),
+ join(plain, 'just-a-file.txt'),
+ project,
+ whitelist,
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
+ });
+
+ it('does not grant the whitelist root itself as a fallback', async () => {
+ // Even if .git existed at the whitelist root (it doesn't), we'd refuse.
+ // Easier to assert: a path directly under whitelist with no repo marker.
+ const direct = join(whitelist, 'lone-file.txt');
+ await writeFile(direct, 'x');
+ const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist);
+ expect(result.ok).toBe(false);
+ });
+});
+
+describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => {
+ it('refuses a symlinked input whose realpath sits outside the whitelist', async () => {
+ // The symlink lives nominally inside the whitelist, but its target
+ // (realpath) is outside. The guard's first realpath() call normalizes
+ // and the up-front whitelist check refuses immediately.
+ const link = join(whitelist, 'escape-link');
+ try {
+ await symlink(outside, link);
+ const result = await resolveGrantRoot(
+ makeSql([]),
+ join(link, 'secret.txt'),
+ project,
+ whitelist,
+ );
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
+ } finally {
+ await rm(link, { force: true });
+ }
+ });
+
+ it('walk loop terminates at the whitelist root, not at filesystem /', async () => {
+ // Construct a deep tree with NO repo markers anywhere. Without a bound,
+ // the walk would chase parents up to "/". The bound flips the loop into
+ // a refusal once the cursor equals the realpath'd whitelist root.
+ const deep = join(whitelist, 'a', 'b', 'c', 'd');
+ await mkdir(deep, { recursive: true });
+ await writeFile(join(deep, 'leaf.txt'), 'x');
+ const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist);
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
+ });
+});
+
+describe('resolveGrantRoot — nearest-project disambiguation', () => {
+ it('prefers the longest matching project path over a shorter ancestor', async () => {
+ const outer = whitelist;
+ const inner = fork; // /whitelist/forks/codecontext, deeper than outer
+ const result = await resolveGrantRoot(
+ makeSql([{ path: outer }, { path: inner }]),
+ join(fork, 'main.go'),
+ project,
+ whitelist,
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.root).toBe(inner);
+ });
+});
+
+// Belt-and-suspenders: silence a known dynamic-import warning that vitest
+// occasionally emits on transient fs operations in CI but never in dev.
+vi.spyOn(console, 'warn').mockImplementation(() => {});
diff --git a/apps/server/src/services/__tests__/path_guard.test.ts b/apps/server/src/services/__tests__/path_guard.test.ts
new file mode 100644
index 0000000..12135b2
--- /dev/null
+++ b/apps/server/src/services/__tests__/path_guard.test.ts
@@ -0,0 +1,93 @@
+// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
+// list. Validates the primary-root path stays the source of truth and that
+// extra roots are consulted when (and only when) the primary rejects.
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { realpath } from 'node:fs/promises';
+import { pathGuard, PathScopeError } from '../path_guard.js';
+
+let tmp: string;
+let projectRoot: string;
+let altRoot: string;
+let outsideDir: string;
+
+beforeAll(async () => {
+ tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-pg-')));
+ projectRoot = join(tmp, 'project');
+ altRoot = join(tmp, 'alt');
+ outsideDir = join(tmp, 'outside');
+ await mkdir(projectRoot, { recursive: true });
+ await mkdir(altRoot, { recursive: true });
+ await mkdir(outsideDir, { recursive: true });
+ await writeFile(join(projectRoot, 'inside.txt'), 'p');
+ await writeFile(join(altRoot, 'cross.txt'), 'a');
+ await writeFile(join(outsideDir, 'forbidden.txt'), 'x');
+});
+
+afterAll(async () => {
+ await rm(tmp, { recursive: true, force: true });
+});
+
+describe('pathGuard (v1.13.17 extraRoots)', () => {
+ it('accepts paths inside the primary projectRoot', async () => {
+ const real = await pathGuard(projectRoot, 'inside.txt');
+ expect(real).toBe(join(projectRoot, 'inside.txt'));
+ });
+
+ it('rejects paths outside the primary root when no extra roots given', async () => {
+ await expect(pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'))).rejects.toBeInstanceOf(
+ PathScopeError,
+ );
+ });
+
+ it('accepts cross-root paths when the matching extra root is provided', async () => {
+ const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [altRoot]);
+ expect(real).toBe(join(altRoot, 'cross.txt'));
+ });
+
+ it('rejects cross-root paths even with extra roots when no root matches', async () => {
+ await expect(
+ pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'), [altRoot]),
+ ).rejects.toBeInstanceOf(PathScopeError);
+ });
+
+ it('ignores empty-string extra roots silently', async () => {
+ const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), ['', altRoot]);
+ expect(real).toBe(join(altRoot, 'cross.txt'));
+ });
+
+ it('error message contains the request_read_access hint when scope rejects', async () => {
+ try {
+ await pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'));
+ throw new Error('should have thrown');
+ } catch (err) {
+ expect(err).toBeInstanceOf(PathScopeError);
+ expect((err as Error).message).toContain('request_read_access');
+ }
+ });
+
+ it('still resolves symlinks before the scope check', async () => {
+ const linkPath = join(projectRoot, 'link-to-outside');
+ await symlink(join(outsideDir, 'forbidden.txt'), linkPath);
+ // Symlink target escapes both primary and the single extra root, so
+ // even though the surface path "looks" inside projectRoot, the real
+ // path resolves outside and the guard rejects.
+ await expect(pathGuard(projectRoot, linkPath, [altRoot])).rejects.toBeInstanceOf(
+ PathScopeError,
+ );
+ // But adding outsideDir as an extra root accepts (realpath inside it).
+ const real = await pathGuard(projectRoot, linkPath, [altRoot, outsideDir]);
+ expect(real).toBe(join(outsideDir, 'forbidden.txt'));
+ });
+
+ it('tries extra roots in order until one accepts', async () => {
+ const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [
+ outsideDir, // rejects
+ altRoot, // accepts
+ ]);
+ expect(real).toBe(join(altRoot, 'cross.txt'));
+ });
+});
diff --git a/apps/server/src/services/file_ops.ts b/apps/server/src/services/file_ops.ts
index 2ec0790..9f75231 100644
--- a/apps/server/src/services/file_ops.ts
+++ b/apps/server/src/services/file_ops.ts
@@ -47,8 +47,12 @@ export interface FindFilesResult {
truncated: boolean;
}
-export async function listDir(projectRoot: string, relPath: string): Promise {
- const real = await pathGuard(projectRoot, relPath);
+export async function listDir(
+ projectRoot: string,
+ relPath: string,
+ opts?: { extra_roots?: readonly string[] },
+): Promise {
+ const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
const s = await stat(real);
if (!s.isDirectory()) {
throw new PathScopeError(`not a directory: ${relPath}`);
@@ -82,8 +86,12 @@ export async function listDir(projectRoot: string, relPath: string): Promise {
- const real = await pathGuard(projectRoot, relPath);
+export async function viewFile(
+ projectRoot: string,
+ relPath: string,
+ opts?: { extra_roots?: readonly string[] },
+): Promise {
+ const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
const s = await stat(real);
if (!s.isFile()) {
throw new PathScopeError(`not a file: ${relPath}`);
@@ -119,10 +127,10 @@ interface RipgrepMatch {
export async function grep(
projectRoot: string,
pattern: string,
- opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean }
+ opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean; extra_roots?: readonly string[] }
): Promise {
const targetPath = opts?.path ?? projectRoot;
- const target = await pathGuard(projectRoot, targetPath);
+ const target = await pathGuard(projectRoot, targetPath, opts?.extra_roots);
const limit = Math.min(
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
MAX_GREP_RESULTS
@@ -192,14 +200,14 @@ export async function grep(
export async function findFiles(
projectRoot: string,
pattern?: string,
- opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string }
+ opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string; extra_roots?: readonly string[] }
): Promise {
const limit = Math.min(
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
MAX_FIND_RESULTS
);
const target = opts?.path != null
- ? await pathGuard(projectRoot, opts.path)
+ ? await pathGuard(projectRoot, opts.path, opts?.extra_roots)
: projectRoot;
const args = ['--files'];
if (pattern) args.push('--glob', pattern);
diff --git a/apps/server/src/services/grant_resolver.ts b/apps/server/src/services/grant_resolver.ts
new file mode 100644
index 0000000..08d53a8
--- /dev/null
+++ b/apps/server/src/services/grant_resolver.ts
@@ -0,0 +1,161 @@
+// v1.13.17-cross-repo-reads: derives the grant root for a path the user is
+// being asked to approve cross-repo read access to.
+//
+// Per design decision D1: grant unit = nearest registered project root,
+// then nearest path-whitelist ancestor that looks like a repo root, then
+// refuse. Granting the literal file path is too narrow (next file in the
+// same repo re-prompts). Granting an arbitrary parent dir over-scopes.
+//
+// The resolver runs in two contexts:
+// 1. request_read_access.execute — pre-prompt validation (cheap; bails
+// early if the path can't plausibly be granted so the user is never
+// asked about /etc/passwd)
+// 2. POST /api/chats/:id/grant_read_access — at decision time, re-derives
+// the root and persists it on sessions.allowed_read_paths
+//
+// Sam (2026-05-22 dispatch confirmation): "in the project-root resolver
+// ancestor walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
+// filesystem root — check on every iteration, not just final parent.
+// Symlinked input must not be able to escape the whitelist during the
+// walk." Hence the loop here checks both the walk bound AND the still-
+// inside-whitelist invariant every step.
+
+import { access, realpath } from 'node:fs/promises';
+import { constants } from 'node:fs';
+import { dirname, isAbsolute, sep } from 'node:path';
+import type { Sql } from '../db.js';
+
+// Files whose presence in a directory marks it as a repo root for grant
+// purposes. Kept narrow on purpose; broader heuristics (e.g. ".project",
+// "pyproject.toml") can be added with measured intent. Each entry is a
+// literal basename — no globs.
+const REPO_MARKERS: ReadonlyArray = [
+ '.git',
+ 'package.json',
+ 'go.mod',
+ 'Cargo.toml',
+];
+
+export type GrantResolution =
+ | { ok: true; root: string; source: 'project' | 'whitelist' }
+ | { ok: false; reason: string };
+
+function isUnder(child: string, parent: string): boolean {
+ return child === parent || child.startsWith(parent + sep);
+}
+
+async function exists(path: string): Promise {
+ try {
+ await access(path, constants.F_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function isRepoShaped(dir: string): Promise {
+ for (const marker of REPO_MARKERS) {
+ if (await exists(`${dir}${sep}${marker}`)) return true;
+ }
+ return false;
+}
+
+// Resolves an absolute path to its grant root or refuses with a reason
+// string suitable for surfacing to the model. Pure helper — no DB writes,
+// no broker publishes. Caller persists the root on session.allowed_read_paths
+// if it wants the grant to stick.
+//
+// Arguments:
+// sql — used only to read projects.path (no writes)
+// requestedPath — absolute path the model wants to read
+// projectRoot — the session's primary project root (already
+// realpath'd by caller). Used to short-circuit
+// "already in scope".
+// whitelistRoot — PROJECT_ROOT_WHITELIST from config (default /opt).
+// Walk bound for the repo-shape fallback.
+//
+// Returns { ok: true, root, source } on success; { ok: false, reason } else.
+export async function resolveGrantRoot(
+ sql: Sql,
+ requestedPath: string,
+ projectRoot: string,
+ whitelistRoot: string,
+): Promise {
+ if (typeof requestedPath !== 'string' || requestedPath.length === 0) {
+ return { ok: false, reason: 'path is required' };
+ }
+ if (!isAbsolute(requestedPath)) {
+ return { ok: false, reason: 'path must be absolute' };
+ }
+
+ // Resolve symlinks so subsequent ancestor checks compare apples-to-apples
+ // with realpath'd projectRoot. If the path doesn't exist at all, bail
+ // before bothering the user — the model is asking about a phantom.
+ let real: string;
+ try {
+ real = await realpath(requestedPath);
+ } catch {
+ return { ok: false, reason: `path does not exist: ${requestedPath}` };
+ }
+
+ // Whitelist guard. Symlinked inputs can resolve outside the whitelist
+ // even when the surface-form path looks inside it; that's why we test
+ // the *real* path here, not the requested one.
+ let realWhitelist: string;
+ try {
+ realWhitelist = await realpath(whitelistRoot);
+ } catch {
+ return { ok: false, reason: `whitelist root does not exist: ${whitelistRoot}` };
+ }
+ if (!isUnder(real, realWhitelist)) {
+ return { ok: false, reason: 'path outside permitted scope' };
+ }
+
+ // Already in scope? No prompt needed; the tool's caller should retry.
+ if (isUnder(real, projectRoot)) {
+ return { ok: false, reason: 'path already accessible without a grant' };
+ }
+
+ // Look for a registered project whose root is an ancestor of the
+ // requested path. Pick the LONGEST match (nearest ancestor wins) so
+ // sub-projects don't get over-broadened.
+ const projectRows = await sql<{ path: string }[]>`
+ SELECT path FROM projects WHERE status = 'open'
+ `;
+ let bestProject: string | null = null;
+ for (const row of projectRows) {
+ if (!row.path) continue;
+ if (!isUnder(real, row.path)) continue;
+ if (bestProject === null || row.path.length > bestProject.length) {
+ bestProject = row.path;
+ }
+ }
+ if (bestProject !== null) {
+ return { ok: true, root: bestProject, source: 'project' };
+ }
+
+ // Repo-shape fallback. Walk from the requested path upward toward the
+ // whitelist root. At every iteration: confirm we're still inside the
+ // whitelist (so a symlinked component can't slip the bound mid-walk)
+ // and confirm we haven't hit the filesystem root. The first dir with a
+ // REPO_MARKER child is the grant root.
+ let cursor = real;
+ while (true) {
+ // Don't grant the whitelist root itself — that would be far too broad.
+ if (cursor === realWhitelist) {
+ return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
+ }
+ if (!isUnder(cursor, realWhitelist)) {
+ return { ok: false, reason: 'path outside permitted scope' };
+ }
+ const parent = dirname(cursor);
+ if (parent === cursor) {
+ // Hit filesystem root without finding a repo marker.
+ return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
+ }
+ if (await isRepoShaped(cursor)) {
+ return { ok: true, root: cursor, source: 'whitelist' };
+ }
+ cursor = parent;
+ }
+}
diff --git a/apps/server/src/services/inference/tool-phase.ts b/apps/server/src/services/inference/tool-phase.ts
index 2c9d91c..de2ef19 100644
--- a/apps/server/src/services/inference/tool-phase.ts
+++ b/apps/server/src/services/inference/tool-phase.ts
@@ -10,6 +10,10 @@ import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './
// dispatch layer we no longer know which format produced the call, and the
// extra signal is harmless for Qwen-derived calls.
import { formatUnknownToolError } from './tool-suggestions.js';
+// v1.13.17-cross-repo-reads: pre-prompt validation for request_read_access.
+// Resolves the grant root before pausing the loop so the user is never
+// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
+import { resolveGrantRoot } from '../grant_resolver.js';
import type {
InferenceContext,
StreamResult,
@@ -28,7 +32,8 @@ import { SYNTHESIS_TOOLS, runSynthesisPass } from '../synthesisPipeline.js';
async function executeToolCall(
projectRoot: string,
- toolCall: ToolCall
+ toolCall: ToolCall,
+ extraRoots: readonly string[],
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
const tool = TOOLS_BY_NAME[toolCall.name];
if (!tool) {
@@ -63,7 +68,7 @@ async function executeToolCall(
};
}
try {
- const output = await tool.execute(parsed.data, projectRoot);
+ const output = await tool.execute(parsed.data, projectRoot, extraRoots);
const truncated =
typeof output === 'object' && output !== null && 'truncated' in output
? Boolean((output as { truncated: unknown }).truncated)
@@ -206,7 +211,71 @@ export async function executeToolPhase(
);
return;
}
- const tres = await executeToolCall(projectRoot, tc);
+ // v1.13.17-cross-repo-reads: request_read_access pauses identically to
+ // ask_user_input EXCEPT for an up-front validation pass — if the path
+ // can't be granted under the whitelist / repo-shape rules, surface an
+ // immediate denial without prompting the user. Per design D1, we never
+ // ask the user about /etc/passwd or paths outside PROJECT_ROOT_WHITELIST.
+ if (tc.name === 'request_read_access') {
+ const tcArgs = tc.args as { path?: unknown; reason?: unknown };
+ const requested =
+ typeof tcArgs.path === 'string' ? tcArgs.path : '';
+ const resolution = await resolveGrantRoot(
+ ctx.sql,
+ requested,
+ projectRoot,
+ ctx.config.PROJECT_ROOT_WHITELIST,
+ );
+ if (!resolution.ok) {
+ // Auto-deny without pausing. The model sees the reason on its
+ // next turn and decides what to do.
+ const stored = {
+ tool_call_id: tc.id,
+ output: `denied: ${resolution.reason}`,
+ truncated: false,
+ };
+ await ctx.sql`
+ UPDATE messages
+ SET tool_results = ${ctx.sql.json(stored as never)}
+ WHERE id = ${toolMessageId}
+ `;
+ await insertParts(
+ ctx.sql,
+ partsFromToolMessage({ tool_results: stored }).map((p) => ({
+ ...p,
+ message_id: toolMessageId,
+ })),
+ );
+ ctx.publish(sessionId, {
+ type: 'tool_result',
+ tool_message_id: toolMessageId,
+ chat_id: chatId,
+ tool_call_id: tc.id,
+ output: stored.output,
+ truncated: false,
+ });
+ return;
+ }
+ // Path is plausibly grantable — install the pending sentinel and
+ // pause. The grant endpoint re-derives the root at decision time
+ // (state may have changed in the meantime) so we don't stash it here.
+ pausingForUserInput = true;
+ const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
+ await ctx.sql`
+ UPDATE messages
+ SET tool_results = ${ctx.sql.json(sentinel as never)}
+ WHERE id = ${toolMessageId}
+ `;
+ await insertParts(
+ ctx.sql,
+ partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
+ ...p,
+ message_id: toolMessageId,
+ })),
+ );
+ return;
+ }
+ const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
if (SYNTHESIS_TOOLS.has(tc.name)) {
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
}
diff --git a/apps/server/src/services/path_guard.ts b/apps/server/src/services/path_guard.ts
index c2e4c8f..6f8f6af 100644
--- a/apps/server/src/services/path_guard.ts
+++ b/apps/server/src/services/path_guard.ts
@@ -16,9 +16,22 @@ export async function resolveProjectRoot(projectPath: string): Promise {
}
}
+function isUnder(real: string, root: string): boolean {
+ return real === root || real.startsWith(root + sep);
+}
+
+// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
+// list (typically session.allowed_read_paths). The primary projectRoot is
+// tried first; if the resolved path doesn't sit under it, each extraRoot is
+// tried in turn. Throws PathScopeError if no root accepts. The error message
+// includes a hint pointing the model at the request_read_access tool so it
+// can self-correct on the next turn — extraRoots IS the persistence
+// mechanism for those grants, so we only suggest it when there's a missing
+// grant to ask for (i.e. the path isn't already under any allowed root).
export async function pathGuard(
projectRoot: string,
- requested: string
+ requested: string,
+ extraRoots: readonly string[] = [],
): Promise {
if (typeof requested !== 'string' || requested.length === 0) {
throw new PathScopeError('path is required');
@@ -30,10 +43,13 @@ export async function pathGuard(
} catch {
throw new PathScopeError(`path does not exist: ${requested}`);
}
- if (real !== projectRoot && !real.startsWith(projectRoot + sep)) {
- throw new PathScopeError(
- `path escapes project root: ${requested} -> ${real}`
- );
+ if (isUnder(real, projectRoot)) return real;
+ for (const extra of extraRoots) {
+ if (extra.length === 0) continue;
+ if (isUnder(real, extra)) return real;
}
- return real;
+ throw new PathScopeError(
+ `path escapes project root: ${requested} -> ${real}. ` +
+ `Use request_read_access(path, reason) to ask the user for permission.`,
+ );
}
diff --git a/apps/server/src/services/request_read_access.ts b/apps/server/src/services/request_read_access.ts
new file mode 100644
index 0000000..046723c
--- /dev/null
+++ b/apps/server/src/services/request_read_access.ts
@@ -0,0 +1,82 @@
+// 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;
+
+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 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: " — 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 };
+ },
+};
diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts
index d69f0da..918b99c 100644
--- a/apps/server/src/services/tools.ts
+++ b/apps/server/src/services/tools.ts
@@ -22,6 +22,10 @@ import {
getSemanticNeighborhoods,
getFrameworkAnalysis,
} from './tools/codecontext/index.js';
+// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
+// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
+// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
+import { requestReadAccess } from './request_read_access.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200;
@@ -45,7 +49,13 @@ export interface ToolDef {
description: string;
inputSchema: z.ZodType;
jsonSchema: ToolJsonSchema;
- execute(input: TInput, projectRoot: string): Promise;
+ // v1.13.17-cross-repo-reads: extraRoots is the session's
+ // allowed_read_paths, threaded through executeToolCall in tool-phase.ts.
+ // Only the filesystem tools (view_file, list_dir, grep, find_files,
+ // view_truncated_output) forward it to pathGuard; other tools accept the
+ // arg and ignore it. The execute signature stays compatible with
+ // pre-v1.13.17 callsites because the parameter is optional.
+ execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise;
}
const ViewFileInput = z.object({
@@ -78,14 +88,19 @@ export const viewFile: ToolDef = {
},
},
},
- async execute(input, projectRoot) {
- const real = await pathGuard(projectRoot, input.path);
+ async execute(input, projectRoot, extraRoots) {
+ const real = await pathGuard(projectRoot, input.path, extraRoots);
// v1.11.7: secret-file deny check. Test the project-relative path
// (matches the form continue.dev's patterns expect: basenames + dir
// segments). Throw a typed error so executeToolCall in inference.ts
// surfaces a clear "blocked" message to the LLM instead of silently
// returning content the user wanted hidden.
- const relPath = relative(projectRoot, real) || basename(real);
+ // v1.13.17: when the resolved path is outside the primary projectRoot
+ // (i.e. via an allowed_read_paths grant), `relative()` returns "../…"
+ // which won't match secret-file basename patterns. Re-anchor on the
+ // file's basename so the secret deny still fires across all grant roots.
+ const rel = relative(projectRoot, real);
+ const relPath = rel && !rel.startsWith('..') ? rel : basename(real);
if (isSecretPath(relPath)) {
throw new SecretBlockedError(relPath);
}
@@ -157,8 +172,8 @@ export const listDir: ToolDef = {
},
},
},
- async execute(input, projectRoot) {
- const real = await pathGuard(projectRoot, input.path);
+ async execute(input, projectRoot, extraRoots) {
+ const real = await pathGuard(projectRoot, input.path, extraRoots);
const s = await stat(real);
if (!s.isDirectory()) {
throw new PathScopeError(`not a directory: ${input.path}`);
@@ -264,7 +279,7 @@ export const grep: ToolDef = {
},
},
},
- async execute(input, projectRoot) {
+ async execute(input, projectRoot, extraRoots) {
const limit = Math.min(
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
MAX_GREP_RESULTS
@@ -276,6 +291,7 @@ export const grep: ToolDef = {
max_matches: limit,
case_sensitive: input.case_sensitive,
hidden: input.hidden,
+ extra_roots: extraRoots,
});
const reshaped = result.matches.map((m) => ({
path: m.path,
@@ -325,7 +341,7 @@ export const findFiles: ToolDef = {
},
},
},
- async execute(input, projectRoot) {
+ async execute(input, projectRoot, extraRoots) {
const limit = Math.min(
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
MAX_FIND_RESULTS
@@ -335,6 +351,7 @@ export const findFiles: ToolDef = {
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
path: input.path,
max_results: limit,
+ extra_roots: extraRoots,
});
// v1.11.7: drop paths matching secret patterns. The original `total`
// from file_ops includes pre-truncation count; we report the visible
@@ -383,7 +400,10 @@ export const viewTruncatedOutput: ToolDef = {
},
},
},
- async execute(input, _projectRoot) {
+ // view_truncated_output doesn't touch the filesystem — it pulls from tmpfs
+ // by opaque id. extraRoots is irrelevant here; declared for signature parity
+ // with the v1.13.17 ToolDef contract.
+ async execute(input, _projectRoot, _extraRoots) {
const content = await readTruncation(input.id);
if (content === null) {
return {
@@ -658,6 +678,11 @@ export const ALL_TOOLS: ReadonlyArray> = [
watchChanges as ToolDef,
getSemanticNeighborhoods as ToolDef,
getFrameworkAnalysis as ToolDef,
+ // v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
+ // branch in tool-phase.ts. Read-only — only ever READS files; the only
+ // state change is appending to sessions.allowed_read_paths via the
+ // grant endpoint, gated by user consent.
+ requestReadAccess as ToolDef,
].sort((a, b) => a.name.localeCompare(b.name));
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
@@ -694,6 +719,10 @@ export const READ_ONLY_TOOL_NAMES = [
'watch_changes',
'get_semantic_neighborhoods',
'get_framework_analysis',
+ // v1.13.17-cross-repo-reads: pauses execution but doesn't mutate project
+ // state directly (the grant endpoint appends to sessions.allowed_read_paths
+ // only with user consent). Belongs in the read-only budget tier.
+ 'request_read_access',
] as const;
export const TOOLS_BY_NAME: Record> = Object.fromEntries(
diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts
index de5bf9c..9b08666 100644
--- a/apps/server/src/types/api.ts
+++ b/apps/server/src/types/api.ts
@@ -42,6 +42,12 @@ export interface Session {
// v1.12.1: server-side workspace pane layout. Replaces per-device
// localStorage so all devices viewing the session see the same panes.
workspace_panes: WorkspacePane[];
+ // v1.13.17: absolute paths the agent has been granted read access to via
+ // the request_read_access tool. Empty by default; populated only by the
+ // grant_read_access endpoint's allow branch. Revoked via PATCH session.
+ // path_guard's extraRoots check consults this list before refusing reads
+ // outside the primary project root.
+ allowed_read_paths: string[];
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 3b82e6d..8a1caef 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -123,7 +123,20 @@ export const api = {
get: (id: string) => request(`/api/sessions/${id}`),
update: (
id: string,
- body: Partial>
+ 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(`/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: {
diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts
index 9fa6378..92ae8f0 100644
--- a/apps/web/src/api/types.ts
+++ b/apps/web/src/api/types.ts
@@ -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
diff --git a/apps/web/src/components/MessageList.tsx b/apps/web/src/components/MessageList.tsx
index 154fa35..6335ff6 100644
--- a/apps/web/src/components/MessageList.tsx
+++ b/apps/web/src/components/MessageList.tsx
@@ -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 (
+
+ );
+ }
return ;
}
return ;
diff --git a/apps/web/src/components/RequestReadAccessCard.tsx b/apps/web/src/components/RequestReadAccessCard.tsx
new file mode 100644
index 0000000..4eaebb6
--- /dev/null
+++ b/apps/web/src/components/RequestReadAccessCard.tsx
@@ -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: " or "denied" or "denied: ").
+// 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 (
+