diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index 9ed402b07..7c5b2ee7f 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -59,6 +59,7 @@ export interface CrossVersionInvestigationExecution { readonly signal?: AbortSignal; readonly progress?: ProgressReporter; readonly integrityContinueEnabled?: boolean; + readonly authorizeWorkspaceWrite?: () => Promise>; } /** Run or resume a deterministic cross-version artifact investigation. */ @@ -111,6 +112,7 @@ export const runCrossVersionInvestigation = async ( session: execution.session, signal: execution.signal, progress: execution.progress, + authorizeWorkspaceWrite: execution.authorizeWorkspaceWrite, }); }; @@ -127,6 +129,9 @@ const continueInvestigation = async (context: { readonly session: BinarySessionPort | undefined; readonly signal: AbortSignal | undefined; readonly progress: ProgressReporter | undefined; + readonly authorizeWorkspaceWrite: + | (() => Promise>) + | undefined; }): Promise> => { const left = targetFor(context.snapshots.left); const right = targetFor(context.snapshots.right); @@ -155,6 +160,10 @@ const continueInvestigation = async (context: { ); if (context.initial !== null && existing?.status === "complete") return completeOutcome(context.initial, existing, true, context.session); + if (context.authorizeWorkspaceWrite !== undefined) { + const authorized = await context.authorizeWorkspaceWrite(); + if (!authorized.ok) return authorized; + } const pages = createInventoryEvidencePages(context.input, context.snapshots); if (!pages.ok) return pages; if (isCancelled(context.signal)) diff --git a/src/application/DeferredFileAuthorization.ts b/src/application/DeferredFileAuthorization.ts new file mode 100644 index 000000000..1d428ae91 --- /dev/null +++ b/src/application/DeferredFileAuthorization.ts @@ -0,0 +1,105 @@ +import { + AnalysisProtocolError, + PermissionRequiredError, + type AnalysisError, +} from "../domain/errors.js"; +import type { PermissionCapability } from "../domain/permissionPolicy.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + PermissionPathError, + type PermissionAuthority, +} from "./PermissionAuthority.js"; + +interface RootPermissionRequest { + readonly capability: PermissionCapability; + readonly roots: readonly string[]; + readonly access: "read" | "write"; + readonly operation: string; +} + +/** Authorize one root-scoped operation and project policy path failures. */ +export const authorizeRootPermission = async ( + authority: PermissionAuthority, + request: RootPermissionRequest, +): Promise> => { + const authorized = await authority.authorize( + { + capability: request.capability, + roots: request.roots, + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `${request.operation}:${request.capability}:${request.roots.join(":")}`, + }, + request.access, + ); + return authorized.ok + ? ok(null) + : err( + authorized.error instanceof PermissionRequiredError + ? authorized.error + : new AnalysisProtocolError(authorized.error.message, { + cause: authorized.error, + }), + ); +}; + +export interface DeferredFileWriteAuthorization { + readonly authorizeWrite: () => Promise>; +} + +/** Authorize an existing file read and defer write authority until requested. */ +export const authorizeFileReadWithDeferredWrite = async ( + authority: PermissionAuthority, + input: { + readonly path: string; + readonly readCapability: PermissionCapability; + readonly writeCapability: PermissionCapability; + readonly operation: string; + }, +): Promise> => { + const read = await authority.authorize( + { + capability: input.readCapability, + roots: [input.path], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `${input.operation}:${input.readCapability}:${input.path}`, + }, + "read", + ); + let writeAuthorization: Promise> | undefined; + if (!read.ok) { + if ( + !(read.error instanceof PermissionPathError) || + read.error.reason !== "not_found" + ) + return err( + read.error instanceof PermissionRequiredError + ? read.error + : new AnalysisProtocolError(read.error.message, { + cause: read.error, + }), + ); + writeAuthorization = authorizeRootPermission(authority, { + capability: input.writeCapability, + roots: [input.path], + access: "write", + operation: input.operation, + }); + const authorized = await writeAuthorization; + if (!authorized.ok) return authorized; + } + return ok({ + authorizeWrite: () => + (writeAuthorization ??= authorizeRootPermission(authority, { + capability: input.writeCapability, + roots: [input.path], + access: "write", + operation: input.operation, + })), + }); +}; diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 2247371b8..260df0e78 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -1,4 +1,4 @@ -import { parseConfig } from "../config.js"; +import { parseConfig, type AppConfig } from "../config.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; import { createServerIdentity } from "../serverIdentity.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -30,6 +30,10 @@ import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js"; import type { PermissionAuthority } from "./PermissionAuthority.js"; +import { + authorizeFileReadWithDeferredWrite, + type DeferredFileWriteAuthorization, +} from "./DeferredFileAuthorization.js"; const WORKFLOW_PROVIDER = { id: "rea-workflow", @@ -59,6 +63,7 @@ export const runDirectAnalysis = async ( readonly logger?: Logger; readonly snapshotPath?: string | undefined; readonly signal?: AbortSignal; + readonly permissionAuthority?: PermissionAuthority; } = {}, ): Promise => withProcessCancellation(options.signal, (signal) => @@ -66,6 +71,9 @@ export const runDirectAnalysis = async ( logger: options.logger ?? silentLogger, snapshotPath: options.snapshotPath, signal, + ...(options.permissionAuthority === undefined + ? {} + : { permissionAuthority: options.permissionAuthority }), }), ); @@ -108,23 +116,8 @@ const authorizeAnalysis = async ( authority: PermissionAuthority, tool: NativeToolName | ArtifactToolName | DirectAnalysisTool, arguments_: Readonly>, - snapshotPath: string | undefined, ): Promise> => { const requests = []; - if (snapshotPath !== undefined) { - requests.push( - { - capability: "snapshot_read" as const, - path: snapshotPath, - access: "write" as const, - }, - { - capability: "snapshot_write" as const, - path: snapshotPath, - access: "write" as const, - }, - ); - } if (tool === "extract_artifact" && typeof arguments_.output_root === "string") requests.push({ capability: "artifact_extract" as const, @@ -181,6 +174,62 @@ const authorizeAnalysis = async ( return ok(null); }; +const permissionAuthorityFor = ( + config: AppConfig, + supplied: PermissionAuthority | undefined, +): ReturnType => + supplied === undefined + ? loadConfiguredPermissionAuthority(config) + : Promise.resolve(ok(supplied)); + +const authorizeSnapshotAccess = async ( + authority: PermissionAuthority, + tool: NativeToolName | ArtifactToolName | DirectAnalysisTool, + snapshotPath: string | undefined, +): Promise< + Result +> => + snapshotPath === undefined + ? ok(undefined) + : authorizeFileReadWithDeferredWrite(authority, { + path: snapshotPath, + readCapability: "snapshot_read", + writeCapability: "snapshot_write", + operation: tool, + }); + +const authorizeDeferredWrite = ( + authorization: DeferredFileWriteAuthorization | undefined, +): Promise> => + authorization?.authorizeWrite() ?? Promise.resolve(ok(null)); + +const authorizeAnalysisRun = async (input: { + readonly config: AppConfig; + readonly suppliedAuthority: PermissionAuthority | undefined; + readonly tool: NativeToolName | ArtifactToolName | DirectAnalysisTool; + readonly arguments: Readonly>; + readonly snapshotPath: string | undefined; +}): Promise< + Result +> => { + const authority = await permissionAuthorityFor( + input.config, + input.suppliedAuthority, + ); + if (!authority.ok) return authority; + const operation = await authorizeAnalysis( + authority.value, + input.tool, + input.arguments, + ); + if (!operation.ok) return operation; + return authorizeSnapshotAccess( + authority.value, + input.tool, + input.snapshotPath, + ); +}; + const runAnalysis = async ( path: string, tool: NativeToolName | ArtifactToolName | DirectAnalysisTool, @@ -189,22 +238,20 @@ const runAnalysis = async ( readonly logger: Logger; readonly snapshotPath: string | undefined; readonly signal: AbortSignal; + readonly permissionAuthority?: PermissionAuthority; }, ): Promise => { const { logger, signal, snapshotPath } = options; const config = parseConfig(process.env); if (!config.ok) return cliError(config.error); - const permissionAuthority = await loadConfiguredPermissionAuthority( - config.value, - ); - if (!permissionAuthority.ok) return cliError(permissionAuthority.error); - const authorized = await authorizeAnalysis( - permissionAuthority.value, + const authorization = await authorizeAnalysisRun({ + config: config.value, + suppliedAuthority: options.permissionAuthority, tool, - arguments_, + arguments: arguments_, snapshotPath, - ); - if (!authorized.ok) return cliError(authorized.error); + }); + if (!authorization.ok) return cliError(authorization.error); const unavailable = snapshotUnavailable( snapshotPath, config.value.hopperLoaderArgs, @@ -222,6 +269,8 @@ const runAnalysis = async ( }); if (!prepared.ok) return cliError(prepared.error); if (prepared.value.evidence !== undefined) return prepared.value.evidence; + const writable = await authorizeDeferredWrite(authorization.value); + if (!writable.ok) return cliError(writable.error); const { snapshot } = prepared.value; const opened = await session.open( path, diff --git a/src/cliInvestigationCommands.ts b/src/cliInvestigationCommands.ts index f0bb01508..89adc3d2d 100644 --- a/src/cliInvestigationCommands.ts +++ b/src/cliInvestigationCommands.ts @@ -3,14 +3,13 @@ import { Cli, z } from "incur"; import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js"; import { logCliCommand } from "./cliLogging.js"; import { parseConfig } from "./config.js"; -import { - AnalysisProtocolError, - PermissionRequiredError, - projectAnalysisError, - type AnalysisError, -} from "./domain/errors.js"; +import { projectAnalysisError, type AnalysisError } from "./domain/errors.js"; import type { Logger } from "./logger.js"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; +import { + authorizeFileReadWithDeferredWrite, + authorizeRootPermission, +} from "./application/DeferredFileAuthorization.js"; const investigationOptionsSchema = z.object({ yes: z @@ -90,44 +89,23 @@ const runInvestigation = async ( if (!config.ok) return cliError(config.error); const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return cliError(authority.error); - for (const scope of [ + const workspaceAuthorization = await authorizeFileReadWithDeferredWrite( + authority.value, { - capability: "investigation_workspace_read" as const, - roots: [args.workspacePath], - access: "write" as const, + path: args.workspacePath, + readCapability: "investigation_workspace_read", + writeCapability: "investigation_workspace_write", + operation: "investigate_versions", }, - { - capability: "investigation_workspace_write" as const, - roots: [args.workspacePath], - access: "write" as const, - }, - { - capability: "investigation_input" as const, - roots: [args.leftPath, args.rightPath], - access: "read" as const, - }, - ]) { - const authorized = await authority.value.authorize( - { - capability: scope.capability, - roots: scope.roots, - executables: [], - environment_names: [], - network: "none", - mount: false, - operation_identity: `investigate_versions:${scope.capability}:${scope.roots.join(":")}`, - }, - scope.access, - ); - if (!authorized.ok) - return cliError( - authorized.error instanceof PermissionRequiredError - ? authorized.error - : new AnalysisProtocolError(authorized.error.message, { - cause: authorized.error, - }), - ); - } + ); + if (!workspaceAuthorization.ok) return cliError(workspaceAuthorization.error); + const inputAuthorization = await authorizeRootPermission(authority.value, { + capability: "investigation_input", + roots: [args.leftPath, args.rightPath], + access: "read", + operation: "investigate_versions", + }); + if (!inputAuthorization.ok) return cliError(inputAuthorization.error); const investigated = await runCrossVersionInvestigation( { approved: true, @@ -156,6 +134,7 @@ const runInvestigation = async ( { inputRoots: config.value.investigationInputRoots, integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled, + authorizeWorkspaceWrite: workspaceAuthorization.value.authorizeWrite, }, ); return investigated.ok diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index 152480e73..c60bc2c74 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -31,13 +31,14 @@ import { import { toCallToolResult } from "./toolResult.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; import type { PermissionAuthority } from "../application/PermissionAuthority.js"; -import { - AnalysisProtocolError, - PermissionRequiredError, -} from "../domain/errors.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { mcpProgressReporter } from "./mcpProgress.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; +import { + authorizeFileReadWithDeferredWrite, + authorizeRootPermission, + type DeferredFileWriteAuthorization, +} from "../application/DeferredFileAuthorization.js"; /** Register Evidence-composed differential investigation workflows. */ export const registerInvestigationTools = ( @@ -94,59 +95,53 @@ const registerChangedBehavior = ( const parsed = changedBehaviorInputSchema.parse(input); if (parsed.investigation_run !== undefined) { const progress = mcpProgressReporter(context); + let workspaceAuthorization: DeferredFileWriteAuthorization | undefined; if (policies.permissionAuthority !== undefined) { - const scopes = [ + const authorizedWorkspace = await authorizeFileReadWithDeferredWrite( + policies.permissionAuthority, { - capability: "investigation_workspace_read" as const, - roots: [parsed.investigation_run.workspace_path], + path: parsed.investigation_run.workspace_path, + readCapability: "investigation_workspace_read", + writeCapability: "investigation_workspace_write", + operation: contract.name, }, + ); + if (!authorizedWorkspace.ok) + return toCallToolResult(authorizedWorkspace, contract); + workspaceAuthorization = authorizedWorkspace.value; + const authorizedInput = await authorizeRootPermission( + policies.permissionAuthority, { - capability: "investigation_workspace_write" as const, - roots: [parsed.investigation_run.workspace_path], - }, - { - capability: "investigation_input" as const, + capability: "investigation_input", roots: [ parsed.investigation_run.left_path, parsed.investigation_run.right_path, ], + access: "read", + operation: contract.name, }, - ]; - for (const scope of scopes) { - const authorized = await policies.permissionAuthority.authorize( - { - ...scope, - executables: [], - environment_names: [], - network: "none", - mount: false, - operation_identity: `${contract.name}:${scope.capability}:${scope.roots.join(":")}`, - }, - scope.capability === "investigation_input" ? "read" : "write", - ); - if (!authorized.ok) - return toCallToolResult( - err( - authorized.error instanceof PermissionRequiredError - ? authorized.error - : new AnalysisProtocolError(authorized.error.message, { - cause: authorized.error, - }), - ), - contract, - ); - } + ); + if (!authorizedInput.ok) + return toCallToolResult(authorizedInput, contract); } const investigated = await runCrossVersionInvestigation( parsed.investigation_run, policies.evidenceFiles, - investigationExecution( - session, - context.mcpReq.signal, - policies.inputRoots, - policies.integrityContinueEnabled?.() ?? false, - progress, - ), + { + ...investigationExecution( + session, + context.mcpReq.signal, + policies.inputRoots, + policies.integrityContinueEnabled?.() ?? false, + progress, + ), + ...(workspaceAuthorization === undefined + ? {} + : { + authorizeWorkspaceWrite: + workspaceAuthorization.authorizeWrite, + }), + }, ); if (!investigated.ok) return toCallToolResult(investigated, contract); const workspace = investigated.value.workspace; diff --git a/tests/directAnalysisPermissions.test.ts b/tests/directAnalysisPermissions.test.ts new file mode 100644 index 000000000..e37cccf61 --- /dev/null +++ b/tests/directAnalysisPermissions.test.ts @@ -0,0 +1,80 @@ +import { 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 { writeAnalysisSnapshot } from "../src/application/AnalysisSnapshotFiles.js"; +import { runDirectAnalysis } from "../src/application/DirectAnalysis.js"; +import type { AnalysisSnapshot } from "../src/domain/analysisSnapshot.js"; +import { parseBinaryTarget } from "../src/domain/binaryTarget.js"; +import { createEvidence } from "../src/domain/evidence.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; + +let directory: string | undefined; + +afterEach(async () => { + vi.unstubAllEnvs(); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describe("direct analysis snapshot permissions", () => { + it("replays a cache hit with snapshot read authority only", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-direct-snapshot-")); + const snapshotPath = join(directory, "analysis.json"); + const target = await parseBinaryTarget(process.execPath); + if (!target.ok) throw target.error; + const provider = { + id: "rea-workflow", + name: "REA composed investigation workflow", + version: "1", + } as const; + const evidence = createEvidence(target.value, provider, { + operation: "binary_overview", + parameters: {}, + result: { cached: true }, + }); + const snapshot: AnalysisSnapshot = { + snapshot_version: 1, + target: { + sha256: target.value.sha256, + format: target.value.format, + architecture: target.value.architecture ?? null, + loader_args: [...target.value.loaderArgs], + }, + entries: [], + evidence_bundle: createEvidenceBundle([evidence]), + }; + const policy = { + roots: [directory], + maxBytes: 1024 * 1024, + maxDepth: 64, + maxStringLength: 1024, + maxNodes: 10_000, + }; + expect( + (await writeAnalysisSnapshot(snapshot, snapshotPath, false, policy)).ok, + ).toBe(true); + const authority = await permissionAuthorityForRoot( + directory, + ["snapshot_read", "snapshot_write"], + ["snapshot_read"], + ); + vi.stubEnv("REA_ANALYSIS_SNAPSHOT_ROOTS_JSON", JSON.stringify([directory])); + + await expect( + runDirectAnalysis( + process.execPath, + "binary_overview", + {}, + { + snapshotPath, + permissionAuthority: authority, + }, + ), + ).resolves.toEqual(evidence); + }); +}); diff --git a/tests/fixtures/permissionAuthority.ts b/tests/fixtures/permissionAuthority.ts new file mode 100644 index 000000000..533b35537 --- /dev/null +++ b/tests/fixtures/permissionAuthority.ts @@ -0,0 +1,44 @@ +import { + createPermissionAuthority, + type PermissionAuthority, +} from "../../src/application/PermissionAuthority.js"; +import type { + PermissionCapability, + PermissionCeiling, + PermissionGrant, +} from "../../src/domain/permissionPolicy.js"; + +/** Build a canonical root-scoped authority with selected administrator grants. */ +export const permissionAuthorityForRoot = async ( + root: string, + capabilities: readonly PermissionCapability[], + grantedCapabilities: readonly PermissionCapability[], +): Promise => { + const ceilings = capabilities.map((capability) => scope(capability, root)); + const grants = ceilings + .filter(({ capability }) => grantedCapabilities.includes(capability)) + .map(administratorGrant); + const authority = await createPermissionAuthority(ceilings, grants); + if (!authority.ok) throw authority.error; + return authority.value; +}; + +const scope = ( + capability: PermissionCapability, + root: string, +): PermissionCeiling => ({ + capability, + roots: [root], + executables: [], + environment_names: [], + network: "none", + mount: false, +}); + +const administratorGrant = (ceiling: PermissionCeiling): PermissionGrant => ({ + ...ceiling, + grant_id: `administrator:${ceiling.capability}`, + lifetime: "administrator", + operation_identity: null, + expires_at: null, +}); diff --git a/tests/investigationMcp.test.ts b/tests/investigationMcp.test.ts index 46a57d2db..4004afb83 100644 --- a/tests/investigationMcp.test.ts +++ b/tests/investigationMcp.test.ts @@ -20,6 +20,8 @@ import { createServer } from "../src/server/createServer.js"; import { observed } from "./fixtures/analysisExecution.js"; import { readInvestigationWorkspace } from "../src/application/InvestigationWorkspaceStore.js"; import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js"; +import type { PermissionAuthority } from "../src/application/PermissionAuthority.js"; +import { permissionAuthorityForRoot } from "./fixtures/permissionAuthority.js"; describe("investigation MCP workflows", () => { it("never retains derived evidence after request cancellation", async () => { @@ -147,6 +149,71 @@ describe("investigation MCP workflows", () => { } }); + it("replays a complete workspace without write authority", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-investigation-read-")); + const left = join(directory, "left"); + const right = join(directory, "right"); + const workspace = join(directory, "workspace.json"); + await Promise.all([mkdir(left), mkdir(right)]); + await Promise.all([ + writeFile(join(left, "feature.txt"), "old\n"), + writeFile(join(right, "feature.txt"), "new\n"), + ]); + const filePolicy = evidencePolicy(directory); + const arguments_ = { + investigation_run: { + approved: true, + workspace_path: workspace, + workspace_name: "read-only-replay", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }, + }; + const writer = await investigationAuthority(directory, true); + const initial = await connected(filePolicy, [directory], writer); + try { + const created = await initial.client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(created.isError, JSON.stringify(created)).not.toBe(true); + } finally { + await close(initial.session, initial.server, initial.client); + } + + const reader = await investigationAuthority(directory, false); + const replay = await connected(filePolicy, [directory], reader); + try { + const cached = await replay.client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(cached.isError, JSON.stringify(cached)).not.toBe(true); + expect( + await readInvestigationWorkspace(workspace, filePolicy), + ).toMatchObject({ ok: true, value: { revision: 3 } }); + + await writeFile(join(right, "feature.txt"), "changed again\n"); + const denied = await replay.client.callTool({ + name: "find_changed_behavior", + arguments: arguments_, + }); + expect(denied).toMatchObject({ + isError: true, + structuredContent: { + error: { + code: "permission_required", + details: { capability: "investigation_workspace_write" }, + }, + }, + }); + } finally { + await close(replay.session, replay.server, replay.client); + await rm(directory, { recursive: true, force: true }); + } + }); + it("refuses automatic artifact reads outside operator-approved roots", async () => { const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-")); const approvedInputs = join(directory, "approved-inputs"); @@ -492,22 +559,22 @@ describe("investigation MCP workflows", () => { const connected = async ( evidenceFilePolicy?: EvidenceFilePolicy, investigationInputRoots: readonly string[] = evidenceFilePolicy?.roots ?? [], + permissionAuthority?: PermissionAuthority, ) => { const session = new BinarySession(() => ({ health: () => Promise.resolve(), execute: () => Promise.resolve(observed(null)), close: () => Promise.resolve(), })); - const server = createServer( - session, - session, - evidenceFilePolicy === undefined + const server = createServer(session, session, { + ...(evidenceFilePolicy === undefined ? {} : { evidenceFilePolicy, investigationInputRoots, - }, - ); + }), + ...(permissionAuthority === undefined ? {} : { permissionAuthority }), + }); const client = new Client({ name: "investigation-test", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -524,6 +591,24 @@ const evidencePolicy = (root: string): EvidenceFilePolicy => ({ maxNodes: 1_000_000, }); +const investigationAuthority = async ( + root: string, + includeWrite: boolean, +): Promise => + permissionAuthorityForRoot( + root, + [ + "investigation_workspace_read", + "investigation_workspace_write", + "investigation_input", + ], + [ + "investigation_workspace_read", + "investigation_input", + ...(includeWrite ? (["investigation_workspace_write"] as const) : []), + ], + ); + const close = async ( session: BinarySession, server: Awaited>,