Merge pull request #205 from morluto/agent/fix-cache-hit-permissions
fix(permission): defer cache write grants
This commit is contained in:
commit
6a3fd5b4ce
|
|
@ -59,6 +59,7 @@ export interface CrossVersionInvestigationExecution {
|
|||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
readonly integrityContinueEnabled?: boolean;
|
||||
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** 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<Result<null, AnalysisError>>)
|
||||
| undefined;
|
||||
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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<Result<null, AnalysisError>> => {
|
||||
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<Result<null, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** 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<Result<DeferredFileWriteAuthorization, AnalysisError>> => {
|
||||
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<Result<null, AnalysisError>> | 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,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
|
@ -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<JsonValue> =>
|
||||
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<Record<string, JsonValue>>,
|
||||
snapshotPath: string | undefined,
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
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<typeof loadConfiguredPermissionAuthority> =>
|
||||
supplied === undefined
|
||||
? loadConfiguredPermissionAuthority(config)
|
||||
: Promise.resolve(ok(supplied));
|
||||
|
||||
const authorizeSnapshotAccess = async (
|
||||
authority: PermissionAuthority,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
snapshotPath: string | undefined,
|
||||
): Promise<
|
||||
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
|
||||
> =>
|
||||
snapshotPath === undefined
|
||||
? ok(undefined)
|
||||
: authorizeFileReadWithDeferredWrite(authority, {
|
||||
path: snapshotPath,
|
||||
readCapability: "snapshot_read",
|
||||
writeCapability: "snapshot_write",
|
||||
operation: tool,
|
||||
});
|
||||
|
||||
const authorizeDeferredWrite = (
|
||||
authorization: DeferredFileWriteAuthorization | undefined,
|
||||
): Promise<Result<null, AnalysisError>> =>
|
||||
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<Record<string, JsonValue>>;
|
||||
readonly snapshotPath: string | undefined;
|
||||
}): Promise<
|
||||
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
|
||||
> => {
|
||||
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<JsonValue> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<PermissionAuthority> => {
|
||||
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,
|
||||
});
|
||||
|
|
@ -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<PermissionAuthority> =>
|
||||
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<ReturnType<typeof createServer>>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue