feat(permissions): add scoped process capture elicitation

This commit is contained in:
morluto 2026-07-18 00:27:33 +00:00
parent 37f4ac6d70
commit e6cfce3902
21 changed files with 1260 additions and 118 deletions

View File

@ -601,6 +601,10 @@ environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY
adapter uses host networking, it also requires
`REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`.
Set `REA_PROCESS_CAPTURE_AUTO_GRANT=false` to configure those process-capture
limits as a ceiling without implicitly granting them. This mode remains
fail-closed until a narrower grant is established.
Capture a scenario or compare two saved Process Capture v4 Evidence records:
```bash

View File

@ -99,9 +99,15 @@ used by MCP. Optional project grants require both
`REA_PERMISSION_PROJECT_ROOT` and `REA_PERMISSION_PROJECT_STORE`. The store is
atomically written with mode `0600`, bound to the canonical project root, and is
never enabled by default. Send `SIGHUP` to the REA MCP process after a trusted
local policy change; ceilings, administrator grants, and project grants reload
without restarting. Revocation affects future operations; an already-running
operation retains the decision made at its preflight boundary.
local policy change;
ceilings, administrator grants, and project grants reload without restarting.
Revocation affects future operations; an already-running operation retains the
decision made at its preflight boundary.
Once and session grants are connection-local overlays. They cannot authorize a
second MCP connection, be consumed by another connection, or be cleared when a
different connection closes. Live connections evaluate those overlays against
the current process-wide ceilings and persisted grants, including successful
SIGHUP reloads.
`rea policy revoke <grant-id>` displays the exact grant and requires interactive
confirmation; automation must pass `--yes` (or `-y`) explicitly.

View File

@ -31,6 +31,26 @@ export REA_PROCESS_ALLOWED_ENV_JSON='["PATH"]'
export REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true
```
By default, enabling process capture also installs an administrator-lifetime
grant for the configured boundary. Set
`REA_PROCESS_CAPTURE_AUTO_GRANT=false` to install the same boundary only as an
administrator ceiling. Captures then fail with a structured permission-required
result until a narrower grant is established; this is the prerequisite mode for
connection-scoped MCP elicitation. It does not itself authorize a capture.
Each MCP connection owns its elicited once and session grants. A grant accepted
on one connection is not visible to another connection, and disconnect cleanup
removes only the grants owned by the connection that closed. Reloaded
administrator ceilings and persisted grants still apply immediately to every
live connection.
REA offers the once-or-session grant form only after the connection negotiates
an MCP revision with multi-round tool results and advertises form elicitation.
Older connections keep returning the structured
permission-required result and never wait for an unsupported elicitation
response. The MCP SDK runtime currently pinned by REA negotiates the older
protocol path, so the interactive flow remains fail-closed until its runtime
protocol support is available.
Every scenario must also contain `"approved": true`. Executables, working
directories, filesystem roots, and requested environment variables are checked
against operator policy before launch. Process Capture is an observation tool,

View File

@ -1,10 +1,10 @@
---
name: reverse-engineer-anything
description: Reverse engineer native, Electron/JavaScript, and web applications with REA. Connect static artifacts to passive runtime evidence, explain how features work, and build a version tailored to the user's project.
description: Reverse engineer native, Electron/JavaScript, and web application artifacts or runtimes with REA. Connect static artifacts to passive runtime evidence, explain how features work, and build a version tailored to the user's project. Skip REA setup for ordinary source-repository architecture analysis.
metadata:
version: "21"
version: "22"
tool_count: 95
catalog_digest: "b6840d7c79ffdfe8a2ef65fb66c1694f3631bacbc22a0456abbe12aca2d53969"
catalog_digest: "266ea209ddc79eaf85d7ad160e8d3c8ce55cfda6d4b2ca1abb2e4554a914cfd1"
---
# REA
@ -95,12 +95,25 @@ literal approval and `evidence_write` authority after sandbox cleanup.
Identify the app and what the user wants to understand or build. Do not ask for information they already provided.
First decide whether REA evidence is needed. When the complete source repository
is available and the request is ordinary source-level architecture, code-flow,
or implementation analysis, use normal repository tools and skip REA readiness,
setup, and binary-provider commands. Use REA only when the request needs evidence
from a binary, package, built artifact, decompilation, passive application
runtime, controlled replay, or comparison against behavior not established by
the available source. If the source is available but the requested claim still
depends on built or runtime behavior, explain that distinction and continue with
the relevant REA workflow.
If the app is missing, ask which app they want to reverse engineer. If the app is known but the goal is unclear, ask what they want to understand or build, and offer to start with an overview. Never require the user to supply a program path, address, architecture, or reverse-engineering terminology.
Notes is only a documentation example. Never select an app unless the user names it or confirms it.
## Ensure REA is ready
Run this section only after the request-routing decision establishes that REA
evidence is needed.
1. Run `npx -y rea-agents@latest doctor`.
2. If setup is needed, tell the user REA needs to install its local binary-analysis tools. Do not lead with implementation details or assume the user knows reverse-engineering products.
3. Before installing external software, obtain approval and identify what will be installed. If deeper analysis needs Hopper, describe it as REA's local analysis engine and note that it is a separate Mac app with its own license. Then run `npx -y rea-agents@latest setup --yes`.

View File

@ -110,9 +110,22 @@ const errorReason = (error: unknown): PermissionPathError["reason"] =>
/** Stateful owner for permission evaluation, consumption, reload, and revoke. */
export class PermissionAuthority {
private policy: PermissionPolicy;
private readonly configuredAuthority: PermissionAuthority | undefined;
constructor(policy: PermissionPolicy) {
constructor(
policy: PermissionPolicy,
configuredAuthority?: PermissionAuthority,
) {
this.policy = policy;
this.configuredAuthority = configuredAuthority;
}
/** Create a connection-owned overlay for once and session grants. */
createConnectionAuthority(): PermissionAuthority {
return new PermissionAuthority(
createPermissionPolicy([]),
this.configuredAuthority ?? this,
);
}
/** Canonicalize and authorize one exact operation before its side effect. */
@ -132,11 +145,13 @@ export class PermissionAuthority {
PermissionPathError | PermissionRequiredError
>
> {
const evaluated = await this.explain(request, access, options);
const canonical = await canonicalizePermissionRequest(request, access);
if (!canonical.ok) return canonical;
const evaluated = this.evaluateCanonical(canonical.value, options);
if (!evaluated.ok) return evaluated;
const decision = evaluated.value;
this.policy = consumePermission(this.policy, decision);
return ok(decision);
const { decision, owner } = evaluated.value;
owner.policy = consumePermission(owner.policy, decision);
return ok({ ...decision, request: canonical.value });
}
/** Dry-run the exact shared evaluation without consuming a once grant. */
@ -158,23 +173,22 @@ export class PermissionAuthority {
> {
const canonical = await canonicalizePermissionRequest(request, access);
if (!canonical.ok) return canonical;
const decision = evaluatePermission(this.policy, canonical.value);
if (!decision.allowed)
return err(
new PermissionRequiredError(
canonical.value,
decision.missing,
ceilingFor(this.policy, canonical.value.capability),
options.elicitationSupported ?? false,
options.restartRequired ?? false,
),
);
return ok({ ...decision, request: canonical.value });
const evaluated = this.evaluateCanonical(canonical.value, options);
return evaluated.ok
? ok({ ...evaluated.value.decision, request: canonical.value })
: evaluated;
}
/** Add authority beneath the administrator ceiling. */
grant(grant: PermissionGrant): ReturnType<typeof grantPermission> {
const result = grantPermission(this.policy, grant);
const policy =
this.configuredAuthority === undefined
? this.policy
: {
...this.policy,
ceilings: this.configuredAuthority.effectivePolicy().ceilings,
};
const result = grantPermission(policy, grant);
if (result.ok) this.policy = result.value;
return result;
}
@ -189,6 +203,69 @@ export class PermissionAuthority {
this.policy = clearSessionPermissions(this.policy);
}
private effectivePolicy(): PermissionPolicy {
if (this.configuredAuthority === undefined) return this.policy;
const configured = this.configuredAuthority.effectivePolicy();
return {
ceilings: configured.ceilings,
grants: [...configured.grants, ...this.policy.grants],
revoked_grant_ids: new Set([
...configured.revoked_grant_ids,
...this.policy.revoked_grant_ids,
]),
consumed_grant_ids: new Set([
...configured.consumed_grant_ids,
...this.policy.consumed_grant_ids,
]),
};
}
private evaluateCanonical(
request: PermissionRequest,
options: {
readonly elicitationSupported?: boolean;
readonly restartRequired?: boolean;
},
): Result<
{
readonly decision: Extract<
PermissionDecision,
{ readonly allowed: true }
>;
readonly owner: PermissionAuthority;
},
PermissionRequiredError
> {
const configured = this.configuredAuthority;
if (configured !== undefined) {
const configuredDecision = evaluatePermission(
configured.effectivePolicy(),
request,
);
if (configuredDecision.allowed)
return ok({ decision: configuredDecision, owner: configured });
}
const localPolicy =
configured === undefined
? this.policy
: { ...this.policy, ceilings: configured.effectivePolicy().ceilings };
const localDecision = evaluatePermission(localPolicy, request);
if (localDecision.allowed)
return ok({ decision: localDecision, owner: this });
const effective = this.effectivePolicy();
const denied = evaluatePermission(effective, request);
if (denied.allowed) return ok({ decision: denied, owner: this });
return err(
new PermissionRequiredError(
request,
denied.missing,
ceilingFor(effective, request.capability),
options.elicitationSupported ?? false,
options.restartRequired ?? false,
),
);
}
/** Atomically replace optional persisted project grants. */
async replaceProjectGrants(
grants: readonly PermissionGrant[],

View File

@ -0,0 +1,18 @@
import type { PermissionRequest } from "../domain/permissionPolicy.js";
import type { ProcessScenario } from "../domain/processCapture.js";
/** Build the exact permission request shared by process-capture adapters. */
export const processCapturePermissionRequest = (
scenario: ProcessScenario,
): PermissionRequest => ({
capability: "process_capture",
roots: [scenario.working_directory, ...scenario.filesystem_roots],
executables: [scenario.executable],
environment_names: [
...Object.keys(scenario.environment),
...scenario.inherit_environment,
],
network: scenario.network_access === "host" ? "external" : "none",
mount: false,
operation_identity: `capture_process_scenario:${scenario.executable}`,
});

View File

@ -1,11 +1,11 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import {
lstat,
mkdir,
open,
readFile,
realpath,
stat,
unlink,
type FileHandle,
} from "node:fs/promises";
@ -35,6 +35,7 @@ const grantSchema = z.object({
"native_mount",
"reference_read",
"javascript_replay",
"managed_runtime",
]),
roots: z.array(z.string()),
executables: z.array(z.string()),
@ -102,11 +103,15 @@ export const readProjectPermissionStore = async (
> => {
const project = await identifyPermissionProject(projectRoot);
if (!project.ok) return project;
if (process.getuid === undefined)
return err(new ProjectPermissionStoreError("not_owner_only"));
let handle: FileHandle | undefined;
try {
const metadata = await stat(path);
if ((metadata.mode & 0o077) !== 0)
return err(new ProjectPermissionStoreError("not_owner_only"));
const encoded = await readFile(path, "utf8");
const opened = await openPrivateStore(path);
if (!opened.ok) return opened;
if (opened.value === null) return ok(null);
handle = opened.value;
const encoded = await handle.readFile("utf8");
let decoded: unknown;
try {
decoded = JSON.parse(encoded);
@ -124,6 +129,8 @@ export const readProjectPermissionStore = async (
} catch (cause: unknown) {
if (isNotFound(cause)) return ok(null);
return err(new ProjectPermissionStoreError("io", { cause }));
} finally {
await handle?.close().catch(() => undefined);
}
};
@ -145,6 +152,8 @@ export const writeProjectPermissionStore = async (
return err(
new ProjectPermissionStoreError("invalid", { cause: candidate.error }),
);
if (process.getuid === undefined)
return err(new ProjectPermissionStoreError("not_owner_only"));
try {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFileAtomic(
@ -155,10 +164,13 @@ export const writeProjectPermissionStore = async (
mode: 0o600,
},
);
const metadata = await stat(path);
if ((metadata.mode & 0o077) !== 0) {
return err(new ProjectPermissionStoreError("not_owner_only"));
}
const verified = await readProjectPermissionStore(path, projectRoot);
if (!verified.ok) return verified;
if (
verified.value === null ||
JSON.stringify(verified.value) !== JSON.stringify(candidate.data)
)
return err(new ProjectPermissionStoreError("invalid"));
return ok(candidate.data);
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("io", { cause }));
@ -269,6 +281,39 @@ const releasePermissionStoreLock = async (
const isNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT";
const openPrivateStore = async (
path: string,
): Promise<Result<FileHandle | null, ProjectPermissionStoreError>> => {
let handle: FileHandle | undefined;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const metadata = await handle.stat();
if (!privateRegularFile(metadata)) {
await handle.close();
return err(new ProjectPermissionStoreError("not_owner_only"));
}
return ok(handle);
} catch (cause: unknown) {
await handle?.close().catch(() => undefined);
if (isNotFound(cause)) return ok(null);
return err(
new ProjectPermissionStoreError(
isSymlinkRefusal(cause) ? "not_owner_only" : "io",
{ cause },
),
);
}
};
const privateRegularFile = (
metadata: Awaited<ReturnType<typeof lstat>>,
): boolean =>
metadata.isFile() &&
!metadata.isSymbolicLink() &&
(Number(metadata.mode) & 0o077) === 0 &&
process.getuid !== undefined &&
metadata.uid === process.getuid();
const isAlreadyExists = (cause: unknown): boolean =>
errorCode(cause) === "EEXIST";
@ -276,3 +321,6 @@ const errorCode = (cause: unknown): unknown =>
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;
const isSymlinkRefusal = (cause: unknown): boolean =>
errorCode(cause) === "ELOOP" || errorCode(cause) === "EMLINK";

View File

@ -100,6 +100,7 @@ const environmentSchema = z
.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
.default("info"),
REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"),
REA_PROCESS_CAPTURE_AUTO_GRANT: z.enum(["true", "false"]).default("true"),
REA_ARTIFACT_NATIVE_MOUNT_ENABLED: z
.enum(["true", "false"])
.default("false"),
@ -546,7 +547,13 @@ export const parseConfig = (
parsedEnvironment.data.REA_MANAGED_RUNTIME_EXECUTABLE_PATH,
},
permissionCeilings,
administratorPermissionGrants: administratorGrants(permissionCeilings),
administratorPermissionGrants: administratorGrants(
parsedEnvironment.data.REA_PROCESS_CAPTURE_AUTO_GRANT === "true"
? permissionCeilings
: permissionCeilings.filter(
({ capability }) => capability !== "process_capture",
),
),
permissionProjectRoot: parsedEnvironment.data.REA_PERMISSION_PROJECT_ROOT,
permissionProjectStore: parsedEnvironment.data.REA_PERMISSION_PROJECT_STORE,
});

View File

@ -259,12 +259,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"search_procedures",
"Search analyzed procedure names using literal matching by default or regex opt-in. Ghidra bounds literal work and rejects regex constructs, paths, candidates, or cumulative work outside its finite budgets. Results are deterministic and offset-paginated.",
"Search analyzed procedure names using literal matching by default or regex opt-in. Providers bound search work and reject regex constructs, paths, candidates, or cumulative work outside their finite budgets. Results are deterministic and offset-paginated.",
z.object(searchInput),
),
official(
"search_strings",
"Search analyzed strings using literal matching by default or regex opt-in. Ghidra bounds literal work and rejects regex constructs, paths, candidates, or cumulative work outside its finite budgets. Results are deterministic, offset-paginated, and explicitly truncated.",
"Search analyzed strings using literal matching by default or regex opt-in. Providers bound search work and reject regex constructs, paths, candidates, or cumulative work outside their finite budgets. Results are deterministic, offset-paginated, and explicitly truncated.",
z.object(searchInput),
),
official(

View File

@ -175,6 +175,13 @@ export const evaluatePermission = (
};
};
/** Test whether one exact request is wholly contained by one ceiling. */
export const isPermissionRequestWithinCeiling = (
request: PermissionRequest,
ceiling: PermissionCeiling,
): boolean =>
!hasMissing(missingFromScopes(normalizeRequest(request), [ceiling]));
/** Consume an allowed one-shot decision; longer-lived grants are unchanged. */
export const consumePermission = (
policy: PermissionPolicy,

View File

@ -25,6 +25,8 @@ const MCP_CONNECTION_START_FAILED =
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.";
const MCP_SHUTDOWN_FAILED =
"REA could not close cleanly. End the REA process before starting it again.";
const MCP_PERMISSION_RELOAD_FAILED =
"Reloaded permission policy failed unexpectedly";
const SERVER_START_FAILED =
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
@ -199,63 +201,70 @@ export const run = async (
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 (
dependencies.readProjectPermissionStore ??
readProjectPermissionStore
)(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
if (!project.ok) {
serverLogger.error("Reloaded project grants could not be read");
reloadQueue = reloadQueue
.then(async () => {
let projectGrants: readonly PermissionGrant[] = [];
if (
refreshed.value.permissionProjectRoot !== undefined &&
refreshed.value.permissionProjectStore !== undefined
) {
const project = await (
dependencies.readProjectPermissionStore ??
readProjectPermissionStore
)(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
if (!project.ok) {
serverLogger.error("Reloaded project grants could not be read");
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;
}
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(
runtimeProcessPolicy,
refreshed.value.processExecutionPolicy,
);
Object.assign(
runtimeEvidencePolicy,
refreshed.value.evidenceFilePolicy,
);
Object.assign(
runtimeSnapshotPolicy,
refreshed.value.analysisSnapshotFilePolicy,
);
runtimeInvestigationRoots.splice(
0,
runtimeInvestigationRoots.length,
...refreshed.value.investigationInputRoots,
);
Object.assign(
runtimeJavascriptReplayPolicy,
refreshed.value.javascriptReplayPolicy,
);
Object.assign(
runtimeManagedRuntimePolicy,
refreshed.value.managedRuntimePolicy,
);
for (const server of liveServers) server.sendToolListChanged();
});
currentConfig = refreshed.value;
Object.assign(
runtimeProcessPolicy,
refreshed.value.processExecutionPolicy,
);
Object.assign(
runtimeEvidencePolicy,
refreshed.value.evidenceFilePolicy,
);
Object.assign(
runtimeSnapshotPolicy,
refreshed.value.analysisSnapshotFilePolicy,
);
runtimeInvestigationRoots.splice(
0,
runtimeInvestigationRoots.length,
...refreshed.value.investigationInputRoots,
);
Object.assign(
runtimeJavascriptReplayPolicy,
refreshed.value.javascriptReplayPolicy,
);
Object.assign(
runtimeManagedRuntimePolicy,
refreshed.value.managedRuntimePolicy,
);
for (const server of liveServers) server.sendToolListChanged();
})
.catch(() => {
serverLogger.error(MCP_PERMISSION_RELOAD_FAILED);
});
}) ?? (() => undefined);
let shutdownPromise: Promise<void> | undefined;

View File

@ -0,0 +1,230 @@
import { randomUUID } from "node:crypto";
import {
acceptedContent,
inputRequired,
inputResponse,
type InputRequiredResult,
type RequestStateCodec,
} from "@modelcontextprotocol/server";
import { z } from "zod";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import { PermissionRequiredError } from "../domain/errors.js";
import {
isPermissionRequestWithinCeiling,
type PermissionGrant,
type PermissionRequest,
} from "../domain/permissionPolicy.js";
const RESPONSE_KEY = "process_capture_grant";
const STATE_VERSION = 1;
const STATE_TTL_MS = 600_000;
const MAX_CONSUMED_NONCES = 4_096;
const grantResponseSchema = z.object({
lifetime: z.enum(["once", "session"]).default("session"),
});
const grantResponseJsonSchema = {
type: "object" as const,
properties: {
lifetime: {
type: "string" as const,
enum: ["once", "session"],
default: "session",
},
},
required: ["lifetime"],
};
const stateSchema = z.object({
version: z.literal(STATE_VERSION),
nonce: z.string().uuid(),
request: z.object({
capability: z.literal("process_capture"),
roots: z.array(z.string()),
executables: z.array(z.string()),
environment_names: z.array(z.string()),
origins: z.array(z.string()).optional(),
network: z.enum(["none", "loopback", "external"]),
mount: z.boolean(),
operation_identity: z.string(),
}),
});
export type ProcessCaptureElicitationState = z.infer<typeof stateSchema>;
/** Connection-owned SDK state used for process-capture grant elicitation. */
export interface ProcessCaptureElicitation {
readonly stateCodec: RequestStateCodec<ProcessCaptureElicitationState>;
readonly supported: () => boolean;
readonly modern: () => boolean;
readonly consumedNonces: Map<string, number>;
}
interface ProcessCaptureElicitationContext {
readonly mcpReq: {
readonly inputResponses?: Record<string, unknown>;
readonly requestState: () => unknown;
};
}
type PermissionResult = Awaited<ReturnType<PermissionAuthority["authorize"]>>;
/** Authorize capture or return a signed MCP input-required continuation. */
export const authorizeProcessCaptureWithElicitation = async (
authority: PermissionAuthority,
request: PermissionRequest,
context: ProcessCaptureElicitationContext,
elicitation: ProcessCaptureElicitation,
): Promise<PermissionResult | InputRequiredResult> => {
const supported = elicitation.supported() && elicitation.modern();
const initial = await authority.authorize(request, "read", {
elicitationSupported: supported,
});
if (initial.ok || !(initial.error instanceof PermissionRequiredError))
return initial;
const denied = initial.error;
if (
!supported ||
denied.requested.capability !== "process_capture" ||
denied.ceiling === null ||
!isPermissionRequestWithinCeiling(denied.requested, denied.ceiling)
)
return initial;
const encodedState = context.mcpReq.requestState();
if (encodedState !== undefined) {
const state = stateSchema.safeParse(encodedState);
if (!state.success || !sameRequest(state.data.request, denied.requested))
return initial;
const response = inputResponse(context.mcpReq.inputResponses, RESPONSE_KEY);
if (response.kind === "elicit") {
if (!consumeNonce(elicitation.consumedNonces, state.data.nonce))
return initial;
}
if (response.kind === "elicit" && response.action === "accept") {
const content = acceptedContent(
context.mcpReq.inputResponses,
RESPONSE_KEY,
grantResponseSchema,
);
if (content === undefined) return initial;
return grantAndAuthorize(authority, state.data.request, content.lifetime);
}
if (
response.kind === "elicit" &&
(response.action === "decline" || response.action === "cancel")
)
return initial;
}
const state = await elicitation.stateCodec.mint({
version: STATE_VERSION,
nonce: randomUUID(),
request: {
capability: "process_capture",
roots: [...denied.requested.roots],
executables: [...denied.requested.executables],
environment_names: [...denied.requested.environment_names],
network: denied.requested.network,
mount: denied.requested.mount,
operation_identity: denied.requested.operation_identity,
...(denied.requested.origins === undefined
? {}
: { origins: [...denied.requested.origins] }),
},
});
return inputRequired({
inputRequests: {
[RESPONSE_KEY]: inputRequired.elicit({
message: permissionMessage(denied.requested),
requestedSchema: grantResponseJsonSchema,
}),
},
requestState: state,
});
};
const grantAndAuthorize = async (
authority: PermissionAuthority,
request: PermissionRequest,
lifetime: "once" | "session",
): Promise<PermissionResult> => {
const grant: PermissionGrant = {
...request,
grant_id: `elicited:${randomUUID()}`,
lifetime,
operation_identity: lifetime === "once" ? request.operation_identity : null,
expires_at: null,
};
const granted = authority.grant(grant);
if (!granted.ok)
return authority.authorize(request, "read", {
elicitationSupported: true,
});
let authorized: PermissionResult;
try {
authorized = await authority.authorize(request, "read", {
elicitationSupported: true,
});
} catch (cause: unknown) {
authority.revoke(grant.grant_id);
throw cause;
}
if (!authorized.ok) authority.revoke(grant.grant_id);
return authorized;
};
const sameRequest = (
left: PermissionRequest,
right: PermissionRequest,
): boolean =>
left.capability === right.capability &&
left.network === right.network &&
left.mount === right.mount &&
left.operation_identity === right.operation_identity &&
sameOptionalStrings(left.origins, right.origins) &&
sameStrings(left.roots, right.roots) &&
sameStrings(left.executables, right.executables) &&
sameStrings(left.environment_names, right.environment_names);
const sameStrings = (
left: readonly string[],
right: readonly string[],
): boolean =>
left.length === right.length &&
left.every((value, index) => value === right[index]);
const sameOptionalStrings = (
left: readonly string[] | undefined,
right: readonly string[] | undefined,
): boolean =>
left === undefined
? right === undefined
: right !== undefined && sameStrings(left, right);
const consumeNonce = (
consumed: Map<string, number>,
nonce: string,
): boolean => {
const now = Date.now();
for (const [candidate, expiresAt] of consumed)
if (expiresAt <= now) consumed.delete(candidate);
if (consumed.has(nonce) || consumed.size >= MAX_CONSUMED_NONCES) return false;
consumed.set(nonce, now + STATE_TTL_MS);
return true;
};
const permissionMessage = (request: PermissionRequest): string =>
[
"Allow REA to run this exact process-capture scope?",
`Executable: ${request.executables.join(", ") || "none"}`,
`Filesystem roots: ${request.roots.join(", ") || "none"}`,
`Environment names: ${request.environment_names.join(", ") || "none"}`,
`Origins: ${request.origins?.join(", ") || "none"}`,
`Network: ${request.network}`,
`Mount access: ${request.mount ? "yes" : "no"}`,
"Choose whether the grant applies once or for this MCP session.",
].join("\n");

View File

@ -1,4 +1,9 @@
import { McpServer } from "@modelcontextprotocol/server";
import { randomBytes } from "node:crypto";
import {
createRequestStateCodec,
McpServer,
} from "@modelcontextprotocol/server";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
@ -30,6 +35,7 @@ import type {
import type { ManagedRuntimePolicy } from "../application/ManagedRuntimeCorrelationService.js";
import { LinuxJavaScriptReplayRunner } from "../replay/LinuxJavaScriptReplayRunner.js";
import { SystemJavaScriptReplayHost } from "../replay/SystemJavaScriptReplayHost.js";
import type { ProcessCaptureElicitationState } from "./ProcessCaptureElicitation.js";
export interface CreateServerOptions {
readonly logger?: Logger;
@ -67,6 +73,13 @@ export const createServer = (
): McpServer => {
const startedAt = new Date().toISOString();
const logger = options.logger ?? silentLogger;
const permissionAuthority =
options.permissionAuthority?.createConnectionAuthority();
const processCaptureStateCodec =
createRequestStateCodec<ProcessCaptureElicitationState>({
key: randomBytes(32),
ttlSeconds: 600,
});
const server = new McpServer(
{
name: PRODUCT_IDENTITY.mcpServerKey,
@ -77,12 +90,17 @@ export const createServer = (
tools: { listChanged: true },
resources: { listChanged: true },
},
inputRequired: { maxRounds: 3, roundTimeoutMs: 600_000 },
requestState: { verify: processCaptureStateCodec.verify },
instructions:
session === undefined
? "Reverse-engineering tools for an active analysis target. Start with binary_overview."
: "Reverse-engineering tools for configured analysis providers. Open a target with open_binary, then start with binary_overview.",
},
);
server.server.onclose = () => {
permissionAuthority?.clearSessionGrants();
};
session?.onAvailabilityChanged?.(() => server.sendToolListChanged());
server.registerResource(
"server-identity",
@ -170,9 +188,7 @@ export const createServer = (
logger: toolLogger,
activeTarget,
recordEvidence,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
});
registerManagedTools(server, analysis, {
logger: toolLogger,
@ -191,19 +207,19 @@ export const createServer = (
roots: [],
executablePath: "/usr/bin/dotnet",
},
authority: options.permissionAuthority,
authority: permissionAuthority,
},
});
registerBrowserTools(server, {
logger: toolLogger,
browser: options.browserObservation,
permissionAuthority: options.permissionAuthority,
permissionAuthority,
recordEvidence,
});
registerElectronTools(server, {
logger: toolLogger,
electron: options.electronObservation,
permissionAuthority: options.permissionAuthority,
permissionAuthority,
recordEvidence,
});
registerApplicationTools(server, {
@ -217,7 +233,7 @@ export const createServer = (
maxStringLength: 1,
maxNodes: 1,
},
permissionAuthority: options.permissionAuthority,
permissionAuthority,
retainCoverageWorkspace:
session === undefined
? undefined
@ -240,7 +256,7 @@ export const createServer = (
host: options.javascriptReplayHost ?? new SystemJavaScriptReplayHost(),
runner:
options.javascriptReplayRunner ?? new LinuxJavaScriptReplayRunner(),
authority: options.permissionAuthority,
authority: permissionAuthority,
},
});
registerGuidedPrompts(server, analysis, session);
@ -248,7 +264,18 @@ export const createServer = (
registerEvidenceResources(server, session);
registerSessionTools(server, session, toolLogger, {
...options,
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
startedAt,
processCaptureElicitation: {
stateCodec: processCaptureStateCodec,
supported: () =>
server.server.getClientCapabilities()?.elicitation?.form !==
undefined,
modern: () =>
server.server.getNegotiatedProtocolVersion()?.startsWith("2026-") ===
true,
consumedNonces: new Map(),
},
});
}
return server;

View File

@ -20,6 +20,7 @@ import type {
} from "../domain/processCapture.js";
import { processScenarioSchema } from "../domain/processCapture.js";
import { captureProcessScenario } from "../application/ProcessHarness.js";
import { processCapturePermissionRequest } from "../application/ProcessCapturePermission.js";
import type { Evidence } from "../domain/evidence.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import {
@ -60,6 +61,11 @@ import {
import { registerSessionStatusTool } from "./registerSessionStatusTool.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import {
authorizeProcessCaptureWithElicitation,
type ProcessCaptureElicitation,
} from "./ProcessCaptureElicitation.js";
import { isInputRequiredResult } from "@modelcontextprotocol/server";
const permissionFailure = (
failure: Awaited<ReturnType<PermissionAuthority["authorize"]>>,
@ -121,6 +127,7 @@ interface ProcessToolRegistration {
readonly processPolicy: ProcessExecutionPolicy;
readonly captureContract: (typeof SESSION_TOOL_CONTRACTS)[5];
readonly permissionAuthority?: PermissionAuthority;
readonly processCaptureElicitation?: ProcessCaptureElicitation;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
@ -134,6 +141,7 @@ const registerProcessTools = ({
processPolicy,
captureContract,
permissionAuthority,
processCaptureElicitation,
}: ProcessToolRegistration): void => {
server.registerTool(
captureContract.name,
@ -148,21 +156,17 @@ const registerProcessTools = ({
return toCallToolResult(parsedInput, captureContract);
const scenario = parsedInput.value;
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "process_capture",
roots: [scenario.working_directory, ...scenario.filesystem_roots],
executables: [scenario.executable],
environment_names: [
...Object.keys(scenario.environment),
...scenario.inherit_environment,
],
network: scenario.network_access === "host" ? "external" : "none",
mount: false,
operation_identity: `capture_process_scenario:${scenario.executable}`,
},
"read",
);
const request = processCapturePermissionRequest(scenario);
const authorized =
processCaptureElicitation === undefined
? await permissionAuthority.authorize(request, "read")
: await authorizeProcessCaptureWithElicitation(
permissionAuthority,
request,
context,
processCaptureElicitation,
);
if (isInputRequiredResult(authorized)) return authorized;
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
@ -595,6 +599,7 @@ export interface SessionToolOptions {
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
readonly startedAt?: string;
readonly permissionAuthority?: PermissionAuthority;
readonly processCaptureElicitation?: ProcessCaptureElicitation;
readonly artifactIntegrityContinueEnabled?: () => boolean;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
@ -677,6 +682,9 @@ export const registerSessionTools = (
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
...(options.processCaptureElicitation === undefined
? {}
: { processCaptureElicitation: options.processCaptureElicitation }),
});
registerProcessComparisonTool(server, session, compareContract);
registerArtifactComparisonTool(server, session, compareArtifactsContract);

View File

@ -112,6 +112,37 @@ describe("runtime configuration", () => {
});
});
it("can configure a process-capture ceiling without an implicit grant", () => {
const result = parseConfig({
REA_PROCESS_CAPTURE_ENABLED: "true",
REA_PROCESS_CAPTURE_AUTO_GRANT: "false",
REA_PROCESS_EXECUTABLE_ROOTS_JSON: '["/opt/tools"]',
REA_PROCESS_WORKING_ROOTS_JSON: '["/tmp/work"]',
});
if (!result.ok) throw result.error;
expect(result.value.permissionCeilings).toContainEqual(
expect.objectContaining({ capability: "process_capture" }),
);
expect(result.value.administratorPermissionGrants).not.toContainEqual(
expect.objectContaining({ capability: "process_capture" }),
);
});
it("preserves the configured process-capture administrator grant by default", () => {
const result = parseConfig({
REA_PROCESS_CAPTURE_ENABLED: "true",
REA_PROCESS_EXECUTABLE_ROOTS_JSON: '["/opt/tools"]',
REA_PROCESS_WORKING_ROOTS_JSON: '["/tmp/work"]',
});
if (!result.ok) throw result.error;
expect(result.value.administratorPermissionGrants).toContainEqual(
expect.objectContaining({
capability: "process_capture",
lifetime: "administrator",
}),
);
});
it("builds a no-network JavaScript replay ceiling only when enabled", () => {
const result = parseConfig({
REA_JAVASCRIPT_REPLAY_ENABLED: "true",

View File

@ -112,6 +112,30 @@ describe("runtime permission reload", () => {
expect(await canRead(runtime.authority, fixture.newRoot)).toBe(false);
expect(await canRead(runtime.authority, fixture.latestRoot)).toBe(true);
});
it("continues with later reloads after an unexpected reload rejection", async () => {
const fixture = await reloadFixture();
let reads = 0;
const runtime = await startRuntime(fixture.env, async () => {
reads += 1;
if (reads === 1) throw new Error("synthetic unexpected read failure");
return ok(projectStore(fixture.projectRoot, []));
});
configure(fixture.env, fixture.newRoot, true);
runtime.reload();
runtime.reload();
await runtime.notificationCount(1);
expect(reads).toBe(2);
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);
});
});
const reloadFixture = async () => {

View File

@ -147,6 +147,131 @@ describe("permission authority", () => {
expect(await canRead(authority.value, oldRoot)).toBe(false);
expect(await canRead(authority.value, newRoot)).toBe(true);
});
it("isolates transient grants and consumption between live connections", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "rea-permission-session-"));
const scope = evidenceScope(sandbox);
const configured = await createPermissionAuthority([scope]);
expect(configured.ok).toBe(true);
if (!configured.ok) return;
const connectionA = configured.value.createConnectionAuthority();
const connectionB = configured.value.createConnectionAuthority();
const request = {
...scope,
operation_identity: "read:connection-owned",
};
expect(
connectionA.grant({
...request,
grant_id: "session:a",
lifetime: "session",
operation_identity: null,
expires_at: null,
}),
).toMatchObject({ ok: true });
expect(await connectionA.authorize(request, "read")).toMatchObject({
ok: true,
});
expect(await connectionB.authorize(request, "read")).toMatchObject({
ok: false,
});
connectionB.clearSessionGrants();
expect(await connectionA.authorize(request, "read")).toMatchObject({
ok: true,
});
connectionA.clearSessionGrants();
const onceRequest = {
...request,
operation_identity: "read:once-on-a",
};
expect(
connectionA.grant({
...onceRequest,
grant_id: "once:a",
lifetime: "once",
expires_at: null,
}),
).toMatchObject({ ok: true });
expect(
connectionB.grant({
...onceRequest,
grant_id: "once:a",
lifetime: "once",
expires_at: null,
}),
).toMatchObject({ ok: true });
const concurrent = await Promise.all([
connectionA.authorize(onceRequest, "read"),
connectionA.authorize(onceRequest, "read"),
]);
expect(concurrent.filter(({ ok }) => ok)).toHaveLength(1);
expect(await connectionB.authorize(onceRequest, "read")).toMatchObject({
ok: true,
});
});
it("applies configured policy reloads to existing connections", async () => {
const sandbox = await mkdtemp(
join(tmpdir(), "rea-permission-live-reload-"),
);
const allowedRoot = join(sandbox, "allowed");
const replacementRoot = join(sandbox, "replacement");
await Promise.all([mkdir(allowedRoot), mkdir(replacementRoot)]);
const configured = await createPermissionAuthority([
evidenceScope(allowedRoot),
]);
expect(configured.ok).toBe(true);
if (!configured.ok) return;
const connection = configured.value.createConnectionAuthority();
expect(
connection.grant({
...evidenceScope(allowedRoot),
grant_id: "session:before-reload",
lifetime: "session",
operation_identity: null,
expires_at: null,
}),
).toMatchObject({ ok: true });
expect(await canRead(connection, allowedRoot)).toBe(true);
const collisionRequest = {
...evidenceScope(allowedRoot),
operation_identity: "read:reload-collision",
};
expect(
connection.grant({
...collisionRequest,
grant_id: "shared-id",
lifetime: "once",
expires_at: null,
}),
).toMatchObject({ ok: true });
expect(await connection.authorize(collisionRequest, "read")).toMatchObject({
ok: true,
});
expect(
await configured.value.replaceAdministratorGrants([
{
...evidenceScope(allowedRoot),
grant_id: "shared-id",
lifetime: "administrator",
operation_identity: null,
expires_at: null,
},
]),
).toEqual({ ok: true, value: null });
expect(await connection.authorize(collisionRequest, "read")).toMatchObject({
ok: true,
});
expect(
await configured.value.reload([evidenceScope(replacementRoot)]),
).toEqual({ ok: true, value: null });
expect(await canRead(connection, allowedRoot)).toBe(false);
});
});
const evidenceScope = (root: string): PermissionScope => ({

View File

@ -0,0 +1,414 @@
import { isInputRequiredResult } from "@modelcontextprotocol/server";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { PermissionAuthority } from "../src/application/PermissionAuthority.js";
import { createPermissionPolicy } from "../src/domain/permissionPolicy.js";
import {
authorizeProcessCaptureWithElicitation,
type ProcessCaptureElicitationState,
} from "../src/server/ProcessCaptureElicitation.js";
const request = {
capability: "process_capture" as const,
roots: ["/tmp"],
executables: [process.execPath],
environment_names: ["PATH"],
network: "external" as const,
mount: false,
operation_identity: "capture:test",
};
describe("process-capture MCP elicitation", () => {
it("grants and reuses a signed session scope on the modern protocol", async () => {
const authority = ceilingOnlyAuthority();
let state: ProcessCaptureElicitationState | undefined;
const elicitation = {
stateCodec: {
mint: (value: ProcessCaptureElicitationState) => {
state = value;
return Promise.resolve("signed-state");
},
verify: () => verifiedState(state),
},
supported: () => true,
modern: () => true,
consumedNonces: new Map<string, number>(),
};
const first = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(),
elicitation,
);
expect(isInputRequiredResult(first)).toBe(true);
expect(first).toMatchObject({ requestState: "signed-state" });
expect(first).toMatchObject({
inputRequests: {
process_capture_grant: {
params: {
message: expect.stringContaining("Mount access: no"),
},
},
},
});
expect(state?.request).toEqual(request);
const accepted = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(state, {
action: "accept",
content: { lifetime: "session" },
}),
elicitation,
);
expect(isInputRequiredResult(accepted)).toBe(false);
if (isInputRequiredResult(accepted)) return;
expect(accepted.ok).toBe(true);
const reused = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(),
elicitation,
);
expect(isInputRequiredResult(reused)).toBe(false);
if (!isInputRequiredResult(reused)) expect(reused.ok).toBe(true);
});
it("fails closed for legacy clients and altered continuations", async () => {
const authority = ceilingOnlyAuthority();
let state: ProcessCaptureElicitationState | undefined;
const elicitation = {
stateCodec: {
mint: (value: ProcessCaptureElicitationState) => {
state = value;
return Promise.resolve("signed-state");
},
verify: () => verifiedState(state),
},
supported: () => true,
modern: () => false,
consumedNonces: new Map<string, number>(),
};
const legacy = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(),
elicitation,
);
expect(isInputRequiredResult(legacy)).toBe(false);
if (!isInputRequiredResult(legacy)) expect(legacy.ok).toBe(false);
const modern = { ...elicitation, modern: () => true };
await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(),
modern,
);
const altered = await authorizeProcessCaptureWithElicitation(
authority,
{ ...request, operation_identity: "capture:altered" },
requestContext(state, {
action: "accept",
content: { lifetime: "session" },
}),
modern,
);
expect(isInputRequiredResult(altered)).toBe(false);
if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false);
});
it("binds signed continuation state to exact origins", async () => {
const authority = ceilingOnlyAuthority([
"https://example.test",
"https://other.test",
]);
let state: ProcessCaptureElicitationState | undefined;
const elicitation = {
stateCodec: {
mint: (value: ProcessCaptureElicitationState) => {
state = value;
return Promise.resolve("signed-state");
},
verify: () => verifiedState(state),
},
supported: () => true,
modern: () => true,
consumedNonces: new Map<string, number>(),
};
const scoped = { ...request, origins: ["https://example.test"] };
const prompted = await authorizeProcessCaptureWithElicitation(
authority,
scoped,
requestContext(),
elicitation,
);
expect(prompted).toMatchObject({
inputRequests: {
process_capture_grant: {
params: {
message: expect.stringContaining("Origins: https://example.test"),
},
},
},
});
const altered = await authorizeProcessCaptureWithElicitation(
authority,
{ ...scoped, origins: ["https://other.test"] },
requestContext(state, {
action: "accept",
content: { lifetime: "session" },
}),
elicitation,
);
expect(isInputRequiredResult(altered)).toBe(false);
if (!isInputRequiredResult(altered)) expect(altered.ok).toBe(false);
});
it("consumes once grants and rejects continuation replay", async () => {
const authority = ceilingOnlyAuthority();
let state: ProcessCaptureElicitationState | undefined;
const elicitation = {
stateCodec: {
mint: (value: ProcessCaptureElicitationState) => {
state = value;
return Promise.resolve("signed-state");
},
verify: () => verifiedState(state),
},
supported: () => true,
modern: () => true,
consumedNonces: new Map<string, number>(),
};
await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(),
elicitation,
);
const response = {
action: "accept",
content: { lifetime: "once" },
};
const accepted = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(state, response),
elicitation,
);
if (isInputRequiredResult(accepted)) throw new Error("grant was not used");
expect(accepted.ok).toBe(true);
const replayed = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(state, response),
elicitation,
);
expect(isInputRequiredResult(replayed)).toBe(false);
if (!isInputRequiredResult(replayed)) expect(replayed.ok).toBe(false);
const next = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(),
elicitation,
);
expect(isInputRequiredResult(next)).toBe(true);
const declined = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(state, { action: "decline" }),
elicitation,
);
expect(isInputRequiredResult(declined)).toBe(false);
const acceptedAfterDecline = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(state, {
action: "accept",
content: { lifetime: "session" },
}),
elicitation,
);
expect(isInputRequiredResult(acceptedAfterDecline)).toBe(false);
if (!isInputRequiredResult(acceptedAfterDecline))
expect(acceptedAfterDecline.ok).toBe(false);
});
it("bounds replay state and prunes entries at the signed-state TTL", async () => {
const authority = ceilingOnlyAuthority();
let state: ProcessCaptureElicitationState | undefined;
const consumedNonces = new Map<string, number>();
const elicitation = {
stateCodec: {
mint: (value: ProcessCaptureElicitationState) => {
state = value;
return Promise.resolve("signed-state");
},
verify: () => verifiedState(state),
},
supported: () => true,
modern: () => true,
consumedNonces,
};
await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(),
elicitation,
);
const future = Date.now() + 600_000;
for (let index = 0; index < 4_096; index += 1)
consumedNonces.set(`occupied-${String(index)}`, future);
const rejectedAtCapacity = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(state, {
action: "accept",
content: { lifetime: "session" },
}),
elicitation,
);
expect(isInputRequiredResult(rejectedAtCapacity)).toBe(false);
if (!isInputRequiredResult(rejectedAtCapacity))
expect(rejectedAtCapacity.ok).toBe(false);
consumedNonces.set("occupied-0", Date.now() - 1);
const acceptedAfterPrune = await authorizeProcessCaptureWithElicitation(
authority,
request,
requestContext(state, {
action: "accept",
content: { lifetime: "session" },
}),
elicitation,
);
expect(isInputRequiredResult(acceptedAfterPrune)).toBe(false);
if (!isInputRequiredResult(acceptedAfterPrune))
expect(acceptedAfterPrune.ok).toBe(true);
expect(consumedNonces.has("occupied-0")).toBe(false);
expect(consumedNonces.size).toBe(4_096);
});
it.each(["result", "throw"] as const)(
"revokes an elicited grant when final authorization ends with %s",
async (failure) => {
const root = await mkdtemp(join(tmpdir(), "rea-elicit-rollback-"));
const scoped = { ...request, roots: [root] };
const policy = createPermissionPolicy([
{
capability: "process_capture",
roots: scoped.roots,
executables: scoped.executables,
environment_names: scoped.environment_names,
network: scoped.network,
mount: scoped.mount,
},
]);
class FailingFinalAuthority extends PermissionAuthority {
private authorizationCount = 0;
override async authorize(
...arguments_: Parameters<PermissionAuthority["authorize"]>
): ReturnType<PermissionAuthority["authorize"]> {
this.authorizationCount += 1;
const result = await super.authorize(...arguments_);
if (failure === "result" && this.authorizationCount === 2)
await rm(root, { recursive: true, force: true });
if (failure === "throw" && this.authorizationCount === 3)
throw new Error("synthetic final authorization rejection");
return result;
}
}
const authority = new FailingFinalAuthority(policy);
let state: ProcessCaptureElicitationState | undefined;
const elicitation = {
stateCodec: {
mint: (value: ProcessCaptureElicitationState) => {
state = value;
return Promise.resolve("signed-state");
},
verify: () => verifiedState(state),
},
supported: () => true,
modern: () => true,
consumedNonces: new Map<string, number>(),
};
await authorizeProcessCaptureWithElicitation(
authority,
scoped,
requestContext(),
elicitation,
);
const finalAuthorization = authorizeProcessCaptureWithElicitation(
authority,
scoped,
requestContext(state, {
action: "accept",
content: { lifetime: "session" },
}),
elicitation,
);
if (failure === "throw")
await expect(finalAuthorization).rejects.toThrow(
"synthetic final authorization rejection",
);
else {
const failed = await finalAuthorization;
expect(isInputRequiredResult(failed)).toBe(false);
if (!isInputRequiredResult(failed)) expect(failed.ok).toBe(false);
await mkdir(root);
}
const retry = await authority.authorize(scoped, "read", {
elicitationSupported: true,
});
expect(retry.ok).toBe(false);
},
);
});
const ceilingOnlyAuthority = (
origins?: readonly string[],
): PermissionAuthority =>
new PermissionAuthority(
createPermissionPolicy([
{
capability: "process_capture",
roots: request.roots,
executables: request.executables,
environment_names: request.environment_names,
...(origins === undefined ? {} : { origins }),
network: request.network,
mount: request.mount,
},
]),
);
const requestContext = (
state?: ProcessCaptureElicitationState,
response?: Record<string, unknown>,
) => ({
mcpReq: {
requestState: () => state,
...(response === undefined
? {}
: { inputResponses: { process_capture_grant: response } }),
},
});
const verifiedState = (
state: ProcessCaptureElicitationState | undefined,
): Promise<ProcessCaptureElicitationState> => {
if (state === undefined) throw new Error("state was not minted");
return Promise.resolve(state);
};

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { processCapturePermissionRequest } from "../src/application/ProcessCapturePermission.js";
import { parseProcessScenario } from "../src/domain/processCapture.js";
describe("process capture permission request", () => {
it("projects the complete adapter-neutral authority scope", () => {
const scenario = parseProcessScenario({
approved: true,
executable: "/bin/tool",
arguments: ["--probe"],
working_directory: "/workspace",
environment: { FIXTURE_TOKEN: "secret" },
inherit_environment: ["PATH"],
filesystem_roots: ["/workspace/output"],
network_access: "host",
events: [],
});
expect(processCapturePermissionRequest(scenario)).toEqual({
capability: "process_capture",
roots: ["/workspace", "/workspace/output"],
executables: ["/bin/tool"],
environment_names: ["FIXTURE_TOKEN", "PATH"],
network: "external",
mount: false,
operation_identity: "capture_process_scenario:/bin/tool",
});
});
});

View File

@ -1,4 +1,4 @@
import { chmod, mkdtemp, stat, writeFile } from "node:fs/promises";
import { chmod, mkdtemp, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -62,6 +62,46 @@ describe("project permission store", () => {
});
});
it("rejects a symlink even when its target is owner-only", async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-symlink-"));
const target = join(project, "target.json");
const path = join(project, "permissions.json");
expect((await writeProjectPermissionStore(target, project, [])).ok).toBe(
true,
);
await symlink(target, path);
expect(await readProjectPermissionStore(path, project)).toMatchObject({
ok: false,
error: { reason: "not_owner_only" },
});
});
it("round-trips managed runtime project grants", async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-managed-"));
const path = join(project, "permissions.json");
const grant = {
grant_id: "project:managed-runtime",
capability: "managed_runtime" as const,
roots: [project],
executables: [process.execPath],
environment_names: [],
network: "none" as const,
mount: false,
lifetime: "project" as const,
operation_identity: null,
expires_at: null,
};
expect(
await writeProjectPermissionStore(path, project, [grant]),
).toMatchObject({ ok: true, value: { grants: [grant] } });
expect(await readProjectPermissionStore(path, project)).toMatchObject({
ok: true,
value: { grants: [grant] },
});
});
it("classifies malformed JSON as an invalid store", async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-invalid-"));
const path = join(project, "permissions.json");

View File

@ -34,8 +34,12 @@ describe("canonical skill transaction", () => {
expect(await readFile(`${destination}.rea.backup`, "utf8")).toBe(
"stale managed skill\n",
);
expect(await readFile(destination, "utf8")).toContain('version: "21"');
expect(await readFile(destination, "utf8")).toContain(
const installedSkill = await readFile(destination, "utf8");
expect(installedSkill).toContain('version: "22"');
expect(installedSkill).toContain(
"use normal repository tools and skip REA readiness",
);
expect(installedSkill).toContain(
`tool_count: ${String(TOOL_CONTRACTS.length)}`,
);
expect(await readFile(sibling, "utf8")).toBe("unrelated skill\n");