fix(runtime): apply permission reloads atomically

This commit is contained in:
morluto committed 2026-07-14 22:22:51 +00:00
commit 49e843e8ff
4 files changed
+428 -66

No files matched your search

+40
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[],
+56 -66
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.";
@@ -35,6 +36,8 @@ interface RuntimeDependencies {
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => void;
readonly registerReload?: (handler: () => void) => () => void;
readonly createServer?: typeof createServer;
readonly readProjectPermissionStore?: typeof readProjectPermissionStore;
}
const runtimeDependencies = (): RuntimeDependencies => ({
@@ -123,30 +126,35 @@ export const run = async (
try {
handle = dependencies.serve(
() => {
const server = createServer(session, session, {
logger,
processPolicy: runtimeProcessPolicy,
evidenceFilePolicy: runtimeEvidencePolicy,
investigationInputRoots: runtimeInvestigationRoots,
analysisSnapshotFilePolicy: runtimeSnapshotPolicy,
permissionAuthority: permissionAuthority.value,
browserObservation,
electronObservation,
artifactIntegrityContinueEnabled: () =>
currentConfig.artifactIntegrityContinueEnabled,
availabilityPolicy: () => ({
processCaptureEnabled: currentConfig.processExecutionPolicy.enabled,
evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length,
browserObservationEnabled:
currentConfig.browserObservationEnabled &&
currentConfig.browserCdpEndpoints.length > 0 &&
currentConfig.browserAllowedOrigins.length > 0,
electronObservationEnabled:
currentConfig.electronObservationEnabled &&
currentConfig.electronCdpEndpoints.length > 0 &&
currentConfig.electronFileRoots.length > 0,
}),
});
const server = (dependencies.createServer ?? createServer)(
session,
session,
{
logger,
processPolicy: runtimeProcessPolicy,
evidenceFilePolicy: runtimeEvidencePolicy,
investigationInputRoots: runtimeInvestigationRoots,
analysisSnapshotFilePolicy: runtimeSnapshotPolicy,
permissionAuthority: permissionAuthority.value,
browserObservation,
electronObservation,
artifactIntegrityContinueEnabled: () =>
currentConfig.artifactIntegrityContinueEnabled,
availabilityPolicy: () => ({
processCaptureEnabled:
currentConfig.processExecutionPolicy.enabled,
evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length,
browserObservationEnabled:
currentConfig.browserObservationEnabled &&
currentConfig.browserCdpEndpoints.length > 0 &&
currentConfig.browserAllowedOrigins.length > 0,
electronObservationEnabled:
currentConfig.electronObservationEnabled &&
currentConfig.electronCdpEndpoints.length > 0 &&
currentConfig.electronFileRoots.length > 0,
}),
},
);
liveServers.add(server);
return server;
},
@@ -164,38 +172,24 @@ export const run = async (
return 1;
}
let reloadQueue = Promise.resolve();
const unregisterReload =
dependencies.registerReload?.(() => {
void (async () => {
const refreshed = parseConfig(dependencies.env);
if (!refreshed.ok) {
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;
}
const refreshed = parseConfig(dependencies.env);
if (!refreshed.ok) {
serverLogger.error("Reloaded permission policy is invalid");
return;
}
reloadQueue = reloadQueue.then(async () => {
let projectGrants: readonly PermissionGrant[] = [];
if (
refreshed.value.permissionProjectRoot !== undefined &&
refreshed.value.permissionProjectStore !== undefined
) {
const project = await readProjectPermissionStore(
const project = await (
dependencies.readProjectPermissionStore ??
readProjectPermissionStore
)(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
@@ -203,21 +197,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(
@@ -238,7 +228,7 @@ export const run = async (
...refreshed.value.investigationInputRoots,
);
for (const server of liveServers) server.sendToolListChanged();
})();
});
}) ?? (() => undefined);
let shutdownPromise: Promise<void> | undefined;
+253
View File
@@ -0,0 +1,253 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PermissionAuthority } from "../src/application/PermissionAuthority.js";
import type { ProjectPermissionStore } from "../src/application/ProjectPermissionStore.js";
import type { PermissionGrant } from "../src/domain/permissionPolicy.js";
import { ok } from "../src/domain/result.js";
import { run } from "../src/main.js";
import {
createServer,
type CreateServerOptions,
} from "../src/server/createServer.js";
type RuntimeDependencies = NonNullable<Parameters<typeof run>[0]>;
const directories: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(
directories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
describe("runtime permission reload", () => {
it("keeps live state unchanged after a late grant failure, then commits together", async () => {
const fixture = await reloadFixture();
const invalid = projectStore(fixture.projectRoot, [
projectGrant("invalid", fixture.outsideRoot),
]);
const valid = projectStore(fixture.projectRoot, [
projectGrant("valid", fixture.newRoot),
]);
const validReadStarted = deferred<void>();
const releaseValidRead = deferred<void>();
let reads = 0;
const runtime = await startRuntime(fixture.env, async () => {
reads += 1;
if (reads === 1) return ok(invalid);
validReadStarted.resolve();
await releaseValidRead.promise;
return ok(valid);
});
configure(fixture.env, fixture.newRoot, true);
runtime.reload();
runtime.reload();
await validReadStarted.promise;
expect(runtime.notifications()).toBe(0);
expect(runtime.options.processPolicy?.enabled).toBe(false);
expect(runtime.options.evidenceFilePolicy?.roots).toEqual([
fixture.oldRoot,
]);
expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false);
expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(true);
expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false);
releaseValidRead.resolve();
await runtime.notificationCount(1);
expect(runtime.options.processPolicy?.enabled).toBe(true);
expect(runtime.options.evidenceFilePolicy?.roots).toEqual([
fixture.newRoot,
]);
expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(true);
expect(await canRead(runtime.authority, fixture.oldRoot)).toBe(false);
expect(await canRead(runtime.authority, fixture.newRoot)).toBe(true);
});
it("serializes overlapping reloads so an older read cannot commit last", async () => {
const fixture = await reloadFixture();
const firstReadStarted = deferred<void>();
const releaseFirstRead = deferred<void>();
const secondReadStarted = deferred<void>();
let secondStarted = false;
let reads = 0;
const runtime = await startRuntime(fixture.env, async () => {
reads += 1;
if (reads === 1) {
firstReadStarted.resolve();
await releaseFirstRead.promise;
return ok(projectStore(fixture.projectRoot, []));
}
secondStarted = true;
secondReadStarted.resolve();
return ok(projectStore(fixture.projectRoot, []));
});
configure(fixture.env, fixture.newRoot, true);
runtime.reload();
await firstReadStarted.promise;
configure(fixture.env, fixture.latestRoot, false);
runtime.reload();
expect(secondStarted).toBe(false);
releaseFirstRead.resolve();
await secondReadStarted.promise;
await runtime.notificationCount(2);
expect(runtime.options.processPolicy?.enabled).toBe(false);
expect(runtime.options.evidenceFilePolicy?.roots).toEqual([
fixture.latestRoot,
]);
expect(runtime.options.artifactIntegrityContinueEnabled?.()).toBe(false);
expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false);
expect(await canRead(runtime.authority, fixture.latestRoot)).toBe(true);
});
});
const reloadFixture = async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-main-reload-"));
directories.push(directory);
const oldRoot = join(directory, "old");
const newRoot = join(directory, "new");
const latestRoot = join(directory, "latest");
const outsideRoot = join(directory, "outside");
const projectRoot = join(directory, "project");
await Promise.all(
[oldRoot, newRoot, latestRoot, outsideRoot, projectRoot].map((path) =>
mkdir(path),
),
);
const env: NodeJS.ProcessEnv = {
REA_EVIDENCE_ROOTS_JSON: JSON.stringify([oldRoot]),
REA_PERMISSION_PROJECT_ROOT: projectRoot,
REA_PERMISSION_PROJECT_STORE: join(directory, "permissions.json"),
};
return { env, oldRoot, newRoot, latestRoot, outsideRoot, projectRoot };
};
const configure = (
env: NodeJS.ProcessEnv,
root: string,
enabled: boolean,
): void => {
env.REA_EVIDENCE_ROOTS_JSON = JSON.stringify([root]);
env.REA_PROCESS_CAPTURE_ENABLED = String(enabled);
env.REA_PROCESS_EXECUTABLE_ROOTS_JSON = JSON.stringify([root]);
env.REA_PROCESS_WORKING_ROOTS_JSON = JSON.stringify([root]);
env.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED = String(enabled);
};
const startRuntime = async (
env: NodeJS.ProcessEnv,
readStore: NonNullable<RuntimeDependencies["readProjectPermissionStore"]>,
) => {
let reload: (() => void) | undefined;
let options: CreateServerOptions | undefined;
let notificationCount = 0;
let notification = deferred<void>();
const waiters = new Map<number, ReturnType<typeof deferred<void>>>();
const result = await run({
env,
serve: (factory) => {
void factory({ era: "legacy" });
return { close: () => Promise.resolve() };
},
writeStderr: () => undefined,
setExitCode: () => undefined,
registerShutdown: () => undefined,
registerReload: (handler) => {
reload = handler;
return () => undefined;
},
readProjectPermissionStore: readStore,
createServer: (analysis, session, received) => {
options = received;
const server = createServer(analysis, session, received);
vi.spyOn(server, "sendToolListChanged").mockImplementation(() => {
notificationCount += 1;
notification.resolve(undefined);
notification = deferred<void>();
waiters.get(notificationCount)?.resolve(undefined);
});
return server;
},
});
expect(result).toBe(0);
const authority = options?.permissionAuthority;
if (reload === undefined || options === undefined || authority === undefined)
throw new Error("Runtime reload seam was not initialized");
const capturedOptions = options;
return {
reload,
options: capturedOptions,
authority,
notifications: () => notificationCount,
notificationCount: (count: number) => {
if (notificationCount >= count) return Promise.resolve();
const waiter = deferred<void>();
waiters.set(count, waiter);
return waiter.promise;
},
};
};
const projectStore = (
projectRoot: string,
grants: readonly ReturnType<typeof projectGrant>[],
): ProjectPermissionStore => ({
schema_version: 1,
project_id: `project_${createHash("sha256").update(projectRoot).digest("hex")}`,
project_root: projectRoot,
grants: [...grants],
});
const projectGrant = (id: string, root: string) =>
({
grant_id: `project:${id}`,
capability: "evidence_read",
roots: [root],
executables: [],
environment_names: [],
network: "none",
mount: false,
lifetime: "project",
operation_identity: null,
expires_at: null,
}) satisfies PermissionGrant;
const canRead = async (
authority: PermissionAuthority,
root: string,
): Promise<boolean> =>
(
await authority.explain(
{
capability: "evidence_read",
roots: [root],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `read:${root}`,
},
"read",
)
).ok;
const deferred = <T>() => {
let resolvePromise!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
};
+79
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;