From 4b8e1b14ef0544cc6e9b1aad8b195b60cb40f785 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sun, 31 May 2026 21:36:45 +0200 Subject: [PATCH] feat(core): allow injected ACL scope matching --- packages/core/src/acl.test.ts | 100 ++++++++++++++++++++++++++++++++++ packages/core/src/acl.ts | 25 ++++++++- packages/core/src/export.ts | 14 ++++- packages/core/src/query.ts | 10 +++- packages/core/src/tree.ts | 7 +++ 5 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/acl.test.ts diff --git a/packages/core/src/acl.test.ts b/packages/core/src/acl.test.ts new file mode 100644 index 00000000..b7ddd3ba --- /dev/null +++ b/packages/core/src/acl.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { exportWorkspaceJson } from "./export.js"; +import { queryFiles } from "./query.js"; +import type { FileRow, StorageAdapter } from "./storage.js"; +import { listTree } from "./tree.js"; +import { filePermissionAllows, type TokenClaims } from "./acl.js"; + +function claimsWith(scopes: string[]): TokenClaims { + return { + workspaceId: "ws_test", + agentName: "agent_test", + scopes: new Set(scopes), + }; +} + +function file(path: string, permissions: string[] = []): FileRow { + return { + path, + revision: "rev_1", + contentType: "text/markdown", + content: "body", + encoding: "utf-8", + provider: "github", + lastEditedAt: "2026-05-31T00:00:00.000Z", + semantics: { + permissions, + }, + }; +} + +function storage(files: FileRow[]): StorageAdapter { + const byPath = new Map(files.map((entry) => [entry.path, entry])); + return { + getFile: (path) => byPath.get(path) ?? null, + listFiles: () => files, + putFile: () => undefined, + deleteFile: () => undefined, + appendEvent: () => undefined, + listEvents: () => ({ items: [], nextCursor: null }), + getRecentEvents: () => [], + getOperation: () => null, + putOperation: () => undefined, + listOperations: () => ({ items: [], nextCursor: null }), + nextRevision: () => "rev_2", + nextOperationId: () => "op_1", + nextEventId: () => "evt_1", + enqueueWriteback: () => undefined, + getPendingWritebacks: () => [], + getWorkspaceId: () => "ws_test", + }; +} + +describe("ACL scope matching", () => { + it("keeps exact scope matching as the default", () => { + const claims = claimsWith(["relayfile:fs:read:/github/*"]); + + expect( + filePermissionAllows(["scope:relayfile:fs:read:/github/LAYOUT.md"], "ws_test", claims), + ).toBe(false); + expect( + filePermissionAllows(["scope:relayfile:fs:read:/github/*"], "ws_test", claims), + ).toBe(true); + }); + + it("lets callers inject path-aware scope matching for tree, query, and export", () => { + const claims = claimsWith(["relayfile:fs:read:/github/*"]); + const rows = [ + file("/.relayfile.acl", ["scope:relayfile:fs:read:/github/LAYOUT.md"]), + file("/github/LAYOUT.md"), + ]; + const repo = storage(rows); + const scopeMatches = vi.fn( + (scope: string, tokenClaims: TokenClaims | null, context) => + scope === "relayfile:fs:read:/github/LAYOUT.md" && + tokenClaims?.scopes.has("relayfile:fs:read:/github/*") === true && + context.action === "read" && + context.requestedPath === "/github/LAYOUT.md", + ); + + expect(listTree(repo, { path: "/github", depth: 1 }, claims).entries).toEqual( + [], + ); + + const aclOptions = { scopeMatches }; + expect( + listTree(repo, { path: "/github", depth: 1 }, claims, aclOptions).entries.map( + (entry) => entry.path, + ), + ).toEqual(["/github/LAYOUT.md"]); + expect( + queryFiles(repo, { path: "/github" }, claims, aclOptions).items.map( + (entry) => entry.path, + ), + ).toEqual(["/github/LAYOUT.md"]); + expect(exportWorkspaceJson(repo, claims, aclOptions).map((entry) => entry.path)).toEqual([ + "/github/LAYOUT.md", + ]); + expect(scopeMatches).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/acl.ts b/packages/core/src/acl.ts index 2927d8cd..02a920c4 100644 --- a/packages/core/src/acl.ts +++ b/packages/core/src/acl.ts @@ -31,6 +31,22 @@ export interface ParsedPermissionRule { value: string; } +export interface ScopeMatchContext { + workspaceId: string; + requestedPath?: string; + action?: "read" | "write" | "manage"; +} + +export interface PermissionEvaluationOptions { + scopeMatches?: ( + scope: string, + claims: TokenClaims | null, + context: ScopeMatchContext, + ) => boolean; + requestedPath?: string; + action?: "read" | "write" | "manage"; +} + // --------------------------------------------------------------------------- // Functions — agent-3: extract from workspace.ts // --------------------------------------------------------------------------- @@ -87,6 +103,7 @@ export function filePermissionAllows( permissions: string[] | undefined, workspaceId: string, claims: TokenClaims | null, + options: PermissionEvaluationOptions = {}, ): boolean { if (!permissions || permissions.length === 0) { return true; @@ -107,7 +124,13 @@ export function filePermissionAllows( match = true; break; case "scope": - match = claims?.scopes.has(rule.value) ?? false; + match = options.scopeMatches + ? options.scopeMatches(rule.value, claims, { + workspaceId, + requestedPath: options.requestedPath, + action: options.action, + }) + : (claims?.scopes.has(rule.value) ?? false); break; case "agent": match = claims?.agentName === rule.value; diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index 68c8fe4d..adecfb16 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -8,7 +8,7 @@ */ import type { StorageAdapter, FileRow } from "./storage.js"; -import type { TokenClaims } from "./acl.js"; +import type { PermissionEvaluationOptions, TokenClaims } from "./acl.js"; import { filePermissionAllows, resolveFilePermissions } from "./acl.js"; export type ExportFormat = "json" | "tar" | "patch"; @@ -16,6 +16,7 @@ export type ExportFormat = "json" | "tar" | "patch"; export function exportWorkspaceJson( storage: StorageAdapter, claims: TokenClaims | null, + aclOptions: PermissionEvaluationOptions = {}, ): FileRow[] { const workspaceId = storage.getWorkspaceId(); @@ -28,6 +29,11 @@ export function exportWorkspaceJson( resolveFilePermissions(storage, row.path, true), workspaceId, claims, + { + ...aclOptions, + action: aclOptions.action ?? "read", + requestedPath: row.path, + }, ), ) .map((row) => materializeFile(storage, row)); @@ -36,15 +42,17 @@ export function exportWorkspaceJson( export function exportWorkspacePatch( storage: StorageAdapter, claims: TokenClaims | null, + aclOptions: PermissionEvaluationOptions = {}, ): string { - return buildUnifiedPatch(exportWorkspaceJson(storage, claims)); + return buildUnifiedPatch(exportWorkspaceJson(storage, claims, aclOptions)); } export async function exportWorkspaceTarGzip( storage: StorageAdapter, claims: TokenClaims | null, + aclOptions: PermissionEvaluationOptions = {}, ): Promise { - return buildTarGzip(exportWorkspaceJson(storage, claims)); + return buildTarGzip(exportWorkspaceJson(storage, claims, aclOptions)); } export function buildUnifiedPatch(files: FileRow[]): string { diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 112f40f5..fca1d83c 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -11,6 +11,7 @@ import type { StorageAdapter, FileRow, Paginated, PaginationOptions } from "./st import { filePermissionAllows, resolveFilePermissions, + type PermissionEvaluationOptions, type TokenClaims, } from "./acl.js"; @@ -39,6 +40,7 @@ export function queryFiles( storage: StorageAdapter, options: QueryOptions, claims: TokenClaims | null, + aclOptions: PermissionEvaluationOptions = {}, ): Paginated { const base = normalizePath(options.path ?? "/"); const provider = normalizeProvider(options.provider); @@ -84,7 +86,13 @@ export function queryFiles( if (!propertiesMatch(semantics.properties, expectedProperties)) { continue; } - if (!filePermissionAllows(effectivePermissions, workspaceId, claims)) { + if ( + !filePermissionAllows(effectivePermissions, workspaceId, claims, { + ...aclOptions, + action: aclOptions.action ?? "read", + requestedPath: row.path, + }) + ) { continue; } diff --git a/packages/core/src/tree.ts b/packages/core/src/tree.ts index b6d8d47d..9b61d6ec 100644 --- a/packages/core/src/tree.ts +++ b/packages/core/src/tree.ts @@ -13,6 +13,7 @@ import type { StorageAdapter, PaginationOptions } from "./storage.js"; import { filePermissionAllows, resolveFilePermissions, + type PermissionEvaluationOptions, type TokenClaims, } from "./acl.js"; @@ -43,6 +44,7 @@ export function listTree( storage: StorageAdapter, options: ListTreeOptions, claims: TokenClaims | null, + aclOptions: PermissionEvaluationOptions = {}, ): TreeResult { const base = normalizePath(options.path ?? "/"); const maxDepth = options.depth && options.depth > 0 ? options.depth : 1; @@ -59,6 +61,11 @@ export function listTree( resolveFilePermissions(storage, filePath, true), workspaceId, claims, + { + ...aclOptions, + action: aclOptions.action ?? "read", + requestedPath: filePath, + }, ) ) { continue;