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:
@@ -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<string> {
|
||||
@@ -40,6 +63,19 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
||||
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<Session[]>`
|
||||
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<Session[]>`
|
||||
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<Session[]>`
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user