From 3ae7109faa29175e84ef695b296ad4db14097a37 Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <1705004398@qq.com> Date: Tue, 14 Jul 2026 21:03:19 +0000 Subject: [PATCH] fix(runtime): apply permission reloads atomically --- src/application/PermissionAuthority.ts | 40 +++++++++++++ src/main.ts | 47 +++++---------- tests/permissionAuthority.test.ts | 79 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 34 deletions(-) diff --git a/src/application/PermissionAuthority.ts b/src/application/PermissionAuthority.ts index 81a4bd8a8..ab0e06579 100644 --- a/src/application/PermissionAuthority.ts +++ b/src/application/PermissionAuthority.ts @@ -232,6 +232,46 @@ export class PermissionAuthority { return ok(null); } + /** Atomically replace reloadable ceilings and persisted grants. */ + async replaceConfiguredPolicy(configuration: { + readonly ceilings: readonly PermissionCeiling[]; + readonly administratorGrants: readonly PermissionGrant[]; + readonly projectGrants: readonly PermissionGrant[]; + }): Promise> { + const ceilings = await canonicalizePermissionCeilings( + configuration.ceilings, + ); + if (!ceilings.ok) return ceilings; + const grants: PermissionGrant[] = []; + for (const grant of [ + ...configuration.administratorGrants, + ...configuration.projectGrants, + ]) { + const canonical = await canonicalizeGrant(grant); + if (!canonical.ok) return canonical; + grants.push(canonical.value); + } + let candidate: PermissionPolicy = { + ...reloadPermissionCeilings(this.policy, ceilings.value), + grants: this.policy.grants.filter( + ({ lifetime }) => + lifetime !== "administrator" && lifetime !== "project", + ), + }; + for (const grant of grants) { + const added = grantPermission(candidate, grant); + if (!added.ok) + return err( + new PermissionPathError(grant.grant_id, "io", { + cause: added.error, + }), + ); + candidate = added.value; + } + this.policy = candidate; + return ok(null); + } + /** Replace ceilings without restarting the MCP process. */ async reload( ceilings: readonly PermissionCeiling[], diff --git a/src/main.ts b/src/main.ts index 7fc4a2c7e..5ac7b4339 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import { readProjectPermissionStore } from "./application/ProjectPermissionStore import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; import { CdpElectronProvider } from "./browser/CdpElectronProvider.js"; +import type { PermissionGrant } from "./domain/permissionPolicy.js"; const MCP_CONNECTION_LOST = "REA lost its MCP client connection. Restart REA from your MCP client."; @@ -172,25 +173,7 @@ export const run = async ( serverLogger.error("Reloaded permission policy is invalid"); return; } - const reloaded = await permissionAuthority.value.reload( - refreshed.value.permissionCeilings, - ); - if (!reloaded.ok) { - serverLogger.error( - "Reloaded permission ceiling could not be applied", - ); - return; - } - const administrators = - await permissionAuthority.value.replaceAdministratorGrants( - refreshed.value.administratorPermissionGrants, - ); - if (!administrators.ok) { - serverLogger.error( - "Reloaded administrator grants could not be applied", - ); - return; - } + let projectGrants: readonly PermissionGrant[] = []; if ( refreshed.value.permissionProjectRoot !== undefined && refreshed.value.permissionProjectStore !== undefined @@ -203,21 +186,17 @@ export const run = async ( serverLogger.error("Reloaded project grants could not be read"); return; } - const projects = await permissionAuthority.value.replaceProjectGrants( - project.value?.grants ?? [], - ); - if (!projects.ok) { - serverLogger.error("Reloaded project grants could not be applied"); - return; - } - } else { - const projects = await permissionAuthority.value.replaceProjectGrants( - [], - ); - if (!projects.ok) { - serverLogger.error("Reloaded project grants could not be cleared"); - return; - } + projectGrants = project.value?.grants ?? []; + } + const reloaded = + await permissionAuthority.value.replaceConfiguredPolicy({ + ceilings: refreshed.value.permissionCeilings, + administratorGrants: refreshed.value.administratorPermissionGrants, + projectGrants, + }); + if (!reloaded.ok) { + serverLogger.error("Reloaded permission policy could not be applied"); + return; } currentConfig = refreshed.value; Object.assign( diff --git a/tests/permissionAuthority.test.ts b/tests/permissionAuthority.test.ts index 74f5e3fb0..775d4f927 100644 --- a/tests/permissionAuthority.test.ts +++ b/tests/permissionAuthority.test.ts @@ -7,10 +7,14 @@ import { describe, expect, it } from "vitest"; import { canonicalizePermissionCeilings, canonicalizePermissionRequest, + createPermissionAuthority, + type PermissionAuthority, } from "../src/application/PermissionAuthority.js"; import { createPermissionPolicy, evaluatePermission, + type PermissionGrant, + type PermissionScope, } from "../src/domain/permissionPolicy.js"; describe("permission authority", () => { @@ -99,4 +103,79 @@ describe("permission authority", () => { if (result.ok) expect(result.value.roots).toEqual([join(sandbox, "new.json")]); }); + + it("commits a configured policy only after every grant validates", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "rea-permission-reload-")); + const oldRoot = join(sandbox, "old"); + const newRoot = join(sandbox, "new"); + const outsideRoot = join(sandbox, "outside"); + await Promise.all([mkdir(oldRoot), mkdir(newRoot), mkdir(outsideRoot)]); + const oldScope = evidenceScope(oldRoot); + const newScope = evidenceScope(newRoot); + const authority = await createPermissionAuthority( + [oldScope], + [administratorGrant(oldScope)], + ); + expect(authority.ok).toBe(true); + if (!authority.ok) return; + + const reloaded = await authority.value.replaceConfiguredPolicy({ + ceilings: [newScope], + administratorGrants: [administratorGrant(newScope)], + projectGrants: [ + { + ...evidenceScope(outsideRoot), + grant_id: "project:outside-ceiling", + lifetime: "project", + operation_identity: null, + expires_at: null, + }, + ], + }); + + expect(reloaded.ok).toBe(false); + expect(await canRead(authority.value, oldRoot)).toBe(true); + expect(await canRead(authority.value, newRoot)).toBe(false); + + expect( + await authority.value.replaceConfiguredPolicy({ + ceilings: [newScope], + administratorGrants: [administratorGrant(newScope)], + projectGrants: [], + }), + ).toEqual({ ok: true, value: null }); + expect(await canRead(authority.value, oldRoot)).toBe(false); + expect(await canRead(authority.value, newRoot)).toBe(true); + }); }); + +const evidenceScope = (root: string): PermissionScope => ({ + capability: "evidence_read", + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, +}); + +const administratorGrant = (scope: PermissionScope): PermissionGrant => ({ + ...scope, + grant_id: "administrator:evidence_read", + lifetime: "administrator", + operation_identity: null, + expires_at: null, +}); + +const canRead = async ( + authority: PermissionAuthority, + root: string, +): Promise => + ( + await authority.explain( + { + ...evidenceScope(root), + operation_identity: `read:${root}`, + }, + "read", + ) + ).ok;