v1.13.17-cross-repo-reads: on-demand read access to paths outside the project root
When the agent needed context from another repo, pathGuard rejected every read
with no recovery path. This batch adds a reactive request_read_access flow:
pathGuard's error now hints at the tool, the model emits a structured request,
the inference loop pauses (same mechanism as ask_user_input), the user picks
Allow/Deny via inline chips, and subsequent reads under the granted root succeed
for the rest of the session.
Schema: sessions.allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]
(idempotent ADD COLUMN IF NOT EXISTS).
Grant unit (design D1): nearest registered projects.path ancestor →
nearest repo-shaped ancestor (.git/ / package.json / go.mod / Cargo.toml)
under PROJECT_ROOT_WHITELIST → else refuse. grant_resolver.ts walks
ancestors with a per-iteration whitelist invariant check so symlinked
input can't escape the whitelist mid-walk (Sam's checkpoint-1 ask).
Path-guard: optional extraRoots arg threaded from session.allowed_read_paths
through executeToolCall to view_file / list_dir / grep / find_files. The
ToolDef.execute signature gets an optional third param; non-FS tools
ignore it. view_file re-anchors the secret-guard check on basename(real)
whenever a relative path starts with "../" so .env / id_rsa* etc. still
deny across grant roots.
Endpoint: POST /api/chats/:id/grant_read_access mirrors /answer_user_input.
On 'allow' it re-resolves the grant root (state may have changed since
prompt — auto-falls to denial reason text on failure, not 500), array_appends
to sessions.allowed_read_paths with in-memory dedup, then publishes
tool_result + session_updated frames and enqueues the next assistant turn.
PATCH /api/sessions/:id allowed_read_paths supports revocation only. Zod
refines absolute + no traversal markers; runtime findUnauthorizedAdditions
guard rejects any entry not already present in the row, so a malicious
curl -X PATCH -d '{"allowed_read_paths":["/etc"]}' returns 400 instead of
bypassing the grant flow (Sam's compliance-review action item).
Frontend: RequestReadAccessCard renders pending (path + reason + Allow/Deny)
and answered (granted/denied summary with the resolved root) variants;
MessageList.flatten/group special-cases the tool name; SettingsPane adds a
per-session grants list with per-row revoke that PATCHes the shortened
array.
Tests: 11 grant_resolver, 8 path_guard, 8 sessions PATCH subset, including
explicit cases for symlink escape mid-walk, walk-bound termination at
whitelist root, /etc bypass attempt via PATCH, and nearest-project
disambiguation. 292 total server tests green.
Pairs with v1.13.16-xml-parser — the model now self-recovers from both
a wrong tool name AND from a refused path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Chat[]>`
|
||||
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<string, 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 = '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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user