feat(permissions): add scoped process capture elicitation
This commit is contained in:
parent
37f4ac6d70
commit
e6cfce3902
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
119
src/main.ts
119
src/main.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue