fix(runtime): apply permission reloads atomically

This commit is contained in:
N0zoM1z0 2026-07-14 21:03:19 +00:00 committed by morluto
parent 072ce5aede
commit 3ae7109faa
3 changed files with 132 additions and 34 deletions

View File

@ -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<Result<null, PermissionPathError>> {
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[],

View File

@ -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(

View File

@ -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<boolean> =>
(
await authority.explain(
{
...evidenceScope(root),
operation_identity: `read:${root}`,
},
"read",
)
).ok;