756 lines
27 KiB
JavaScript
756 lines
27 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const { createHash, randomUUID } = require("node:crypto");
|
||
|
|
|
||
|
|
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
||
|
|
const {
|
||
|
|
assertPluginPermission,
|
||
|
|
canonicalizePermissionResource,
|
||
|
|
declarationAllowsResource,
|
||
|
|
defaultSecurityPrincipal,
|
||
|
|
normalizePermissionDeclarations,
|
||
|
|
permissionDeclarationHash,
|
||
|
|
permissionResourceCovers,
|
||
|
|
} = require("./permissionResources.cjs");
|
||
|
|
|
||
|
|
const VALID_SCOPES = new Set(["once", "session", "application", "always"]);
|
||
|
|
const AUDITED_PERMISSION_USES = new Set([
|
||
|
|
"runtime.advanced",
|
||
|
|
"network",
|
||
|
|
"filesystem.read",
|
||
|
|
"filesystem.write",
|
||
|
|
"secrets",
|
||
|
|
"companion.execute",
|
||
|
|
"vault.credentials",
|
||
|
|
"terminal.input",
|
||
|
|
"terminal.intercept.input",
|
||
|
|
"terminal.intercept.output",
|
||
|
|
]);
|
||
|
|
const PROMPT_TIMEOUT_MS = 30_000;
|
||
|
|
const PERMISSION_GRANT_SCOPES = new Set(["once", "session", "application", "always"]);
|
||
|
|
const HOST_PERMISSION_REASONS = Object.freeze({
|
||
|
|
"terminal.intercept.input": "This plugin can inspect and rewrite Terminal input. Netcatty bypasses host-recognized credential input, but arbitrary no-echo input may not be detectable.",
|
||
|
|
});
|
||
|
|
|
||
|
|
function normalizePermissionOperationId(value) {
|
||
|
|
if (value === undefined || value === null) return undefined;
|
||
|
|
if (typeof value !== "string" || value.length < 1 || value.includes("\0")) {
|
||
|
|
throw permissionDenied("Plugin permission operation ID is invalid");
|
||
|
|
}
|
||
|
|
if (value.length <= 128) return value;
|
||
|
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePermissionReason(value, permission) {
|
||
|
|
const reason = typeof value === "string" && value.length > 0
|
||
|
|
? value
|
||
|
|
: `Allow ${permission}`;
|
||
|
|
if (reason.length <= 1_024) return reason;
|
||
|
|
const suffix = ` [sha256:${createHash("sha256").update(reason).digest("hex")}]`;
|
||
|
|
return `${reason.slice(0, 1_024 - suffix.length)}${suffix}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePermissionSessionId(value) {
|
||
|
|
if (value === undefined || value === null) return undefined;
|
||
|
|
if (typeof value !== "string" || value.length < 1 || value.length > 128 || value.includes("\0")) {
|
||
|
|
throw permissionDenied("Plugin permission session ID is invalid");
|
||
|
|
}
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePermissionAllowedScopes(value) {
|
||
|
|
if (value === undefined) return undefined;
|
||
|
|
if (!Array.isArray(value) || value.length === 0) {
|
||
|
|
throw permissionDenied("Plugin permission grant scopes are invalid");
|
||
|
|
}
|
||
|
|
const scopes = [...new Set(value)];
|
||
|
|
if (scopes.some((scope) => !PERMISSION_GRANT_SCOPES.has(scope))) {
|
||
|
|
throw permissionDenied("Plugin permission grant scopes are invalid");
|
||
|
|
}
|
||
|
|
return Object.freeze(scopes);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePermissionResources(permission, values, label = "Plugin permission resources") {
|
||
|
|
return normalizePermissionResourceDescriptors(permission, values, undefined, label)
|
||
|
|
.map(({ resource }) => resource);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePermissionResourceDescriptors(
|
||
|
|
permission,
|
||
|
|
values,
|
||
|
|
resourceKinds,
|
||
|
|
label = "Plugin permission resources",
|
||
|
|
) {
|
||
|
|
try {
|
||
|
|
if (
|
||
|
|
resourceKinds !== undefined
|
||
|
|
&& (!Array.isArray(resourceKinds) || resourceKinds.length !== values.length)
|
||
|
|
) throw new TypeError("Resource kinds must align with resources");
|
||
|
|
const descriptors = new Map();
|
||
|
|
for (const [index, value] of values.entries()) {
|
||
|
|
const resource = canonicalizePermissionResource(permission, value);
|
||
|
|
const resourceKind = resourceKinds?.[index] ?? "exact";
|
||
|
|
if (
|
||
|
|
(resourceKind !== "exact" && resourceKind !== "directory")
|
||
|
|
|| (resourceKind === "directory"
|
||
|
|
&& permission !== "filesystem.read"
|
||
|
|
&& permission !== "filesystem.write")
|
||
|
|
|| (resource === "*" && resourceKind !== "exact")
|
||
|
|
) throw new TypeError("Resource kind is invalid");
|
||
|
|
const previous = descriptors.get(resource);
|
||
|
|
if (previous && previous.resourceKind !== resourceKind) {
|
||
|
|
throw new TypeError("Duplicate resources cannot have conflicting kinds");
|
||
|
|
}
|
||
|
|
descriptors.set(resource, Object.freeze({ resource, resourceKind }));
|
||
|
|
}
|
||
|
|
return [...descriptors.values()].sort((left, right) => (
|
||
|
|
left.resource.localeCompare(right.resource, "en")
|
||
|
|
));
|
||
|
|
} catch {
|
||
|
|
throw permissionDenied(`${label} are invalid`, { permission });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function permissionDenied(message, details) {
|
||
|
|
return new PluginRpcError(RPC_ERRORS.permissionDenied, message, {
|
||
|
|
pluginCode: "permission_denied",
|
||
|
|
...(details === undefined ? {} : { details }),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeDecision(decision, requestId) {
|
||
|
|
if (!decision || typeof decision !== "object" || Array.isArray(decision)) {
|
||
|
|
throw permissionDenied("Plugin permission request was not approved");
|
||
|
|
}
|
||
|
|
if (decision.requestId !== requestId) {
|
||
|
|
throw permissionDenied("Plugin permission decision does not match the pending request");
|
||
|
|
}
|
||
|
|
if (decision.decision === "deny" || decision.decision === "cancel") {
|
||
|
|
if (Object.keys(decision).some((key) => key !== "requestId" && key !== "decision")) {
|
||
|
|
throw permissionDenied("Plugin permission decision is invalid");
|
||
|
|
}
|
||
|
|
return Object.freeze({ requestId, decision: decision.decision });
|
||
|
|
}
|
||
|
|
if (decision.decision !== "allow" || !VALID_SCOPES.has(decision.scope)) {
|
||
|
|
throw permissionDenied("Plugin permission decision is invalid");
|
||
|
|
}
|
||
|
|
if (Object.keys(decision).some((key) => (
|
||
|
|
key !== "requestId" && key !== "decision" && key !== "scope" && key !== "resources"
|
||
|
|
))) throw permissionDenied("Plugin permission decision is invalid");
|
||
|
|
if (
|
||
|
|
decision.resources !== undefined
|
||
|
|
&& (!Array.isArray(decision.resources) || decision.resources.length > 128)
|
||
|
|
) throw permissionDenied("Plugin permission decision resources are invalid");
|
||
|
|
return Object.freeze({
|
||
|
|
requestId,
|
||
|
|
decision: "allow",
|
||
|
|
scope: decision.scope,
|
||
|
|
...(decision.resources === undefined
|
||
|
|
? {}
|
||
|
|
: { resources: Object.freeze([...decision.resources]) }),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function immutableRequest(request) {
|
||
|
|
return Object.freeze({
|
||
|
|
...request,
|
||
|
|
resources: Object.freeze([...request.resources]),
|
||
|
|
resourceKinds: Object.freeze([...request.resourceKinds]),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
class PluginPermissionEngine {
|
||
|
|
constructor(options) {
|
||
|
|
this.database = options.database;
|
||
|
|
this.requestDecision = options.requestDecision ?? null;
|
||
|
|
if (this.requestDecision != null && typeof this.requestDecision !== "function") {
|
||
|
|
throw new TypeError("Plugin permission decision provider must be a function");
|
||
|
|
}
|
||
|
|
this.clock = options.clock ?? (() => Date.now());
|
||
|
|
this.promptTimeoutMs = options.promptTimeoutMs ?? PROMPT_TIMEOUT_MS;
|
||
|
|
this.applicationGrants = new Map();
|
||
|
|
this.sessionGrants = new Map();
|
||
|
|
this.pending = new Map();
|
||
|
|
this.sessionPromptControllers = new Map();
|
||
|
|
this.promptControllers = new Set();
|
||
|
|
this.revocationListeners = new Set();
|
||
|
|
this.closed = false;
|
||
|
|
this.shutdownController = new AbortController();
|
||
|
|
}
|
||
|
|
|
||
|
|
onDidRevoke(listener) {
|
||
|
|
if (typeof listener !== "function") throw new TypeError("Permission revocation listener is required");
|
||
|
|
this.revocationListeners.add(listener);
|
||
|
|
return Object.freeze({ dispose: () => this.revocationListeners.delete(listener) });
|
||
|
|
}
|
||
|
|
|
||
|
|
#emitRevocation(event) {
|
||
|
|
const frozen = Object.freeze({ ...event });
|
||
|
|
for (const listener of this.revocationListeners) {
|
||
|
|
try { listener(frozen); } catch {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#abortPrompts(pluginId, permission, resource) {
|
||
|
|
for (const entry of this.promptControllers) {
|
||
|
|
if (entry.pluginId !== pluginId) continue;
|
||
|
|
if (permission !== undefined && entry.permission !== permission) continue;
|
||
|
|
if (resource !== undefined && !entry.resources.includes(resource)) continue;
|
||
|
|
entry.controller.abort(permissionDenied("Plugin permission was revoked"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#grantKey(pluginId, permission, declarationHash, resource) {
|
||
|
|
return `${pluginId}\0${permission}\0${declarationHash}\0${resource}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
#sessionGrantKey(sessionId, grantKey) {
|
||
|
|
return `${sessionId}\0${grantKey}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
#createMemoryGrant(
|
||
|
|
context,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
scope,
|
||
|
|
sessionId,
|
||
|
|
) {
|
||
|
|
return Object.freeze({
|
||
|
|
pluginId: context.pluginId,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
scope,
|
||
|
|
sessionId: sessionId ?? null,
|
||
|
|
grantedAt: this.clock(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
#memoryGrantCovers(
|
||
|
|
grant,
|
||
|
|
context,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
sessionId,
|
||
|
|
) {
|
||
|
|
return grant.pluginId === context.pluginId
|
||
|
|
&& grant.permission === permission
|
||
|
|
&& grant.declarationHash === declarationHash
|
||
|
|
&& (grant.scope !== "session" || grant.sessionId === sessionId)
|
||
|
|
&& permissionResourceCovers(
|
||
|
|
permission,
|
||
|
|
grant.resource,
|
||
|
|
resource,
|
||
|
|
grant.resourceKind,
|
||
|
|
resourceKind,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#assertDeclared(context, permission, resources) {
|
||
|
|
const declarations = normalizePermissionDeclarations(context.manifest);
|
||
|
|
const declaration = declarations.get(permission);
|
||
|
|
if (!declaration) {
|
||
|
|
throw permissionDenied(`Plugin did not declare permission: ${permission}`, { permission });
|
||
|
|
}
|
||
|
|
for (const resource of resources) {
|
||
|
|
if (!declarationAllowsResource(declaration, resource)) {
|
||
|
|
throw permissionDenied(`Plugin did not declare permission resource: ${permission}`, {
|
||
|
|
permission,
|
||
|
|
resource,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const securityPrincipal = context.securityPrincipal ?? defaultSecurityPrincipal(context.manifest);
|
||
|
|
return {
|
||
|
|
declaration,
|
||
|
|
declarationHash: permissionDeclarationHash(declaration, securityPrincipal),
|
||
|
|
securityPrincipal,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
#hasGrant(context, permission, declarationHash, resource, resourceKind, sessionId) {
|
||
|
|
if ([...this.applicationGrants.values()].some((grant) => (
|
||
|
|
this.#memoryGrantCovers(
|
||
|
|
grant,
|
||
|
|
context,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
sessionId,
|
||
|
|
)
|
||
|
|
))) return true;
|
||
|
|
if (sessionId && [...this.sessionGrants.values()].some((grant) => (
|
||
|
|
this.#memoryGrantCovers(
|
||
|
|
grant,
|
||
|
|
context,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
sessionId,
|
||
|
|
)
|
||
|
|
))) return true;
|
||
|
|
return this.database.listPermissionGrants(context.pluginId).some((grant) => (
|
||
|
|
grant.permission === permission
|
||
|
|
&& grant.declarationHash === declarationHash
|
||
|
|
&& permissionResourceCovers(
|
||
|
|
permission,
|
||
|
|
grant.resource,
|
||
|
|
resource,
|
||
|
|
grant.resourceKind,
|
||
|
|
resourceKind,
|
||
|
|
)
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
#hasAllGrants(context, permission, declarationHash, resources, resourceKinds, sessionId) {
|
||
|
|
return resources.every((resource, index) => this.#hasGrant(
|
||
|
|
context,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKinds[index],
|
||
|
|
sessionId,
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
async authorize(context, descriptor) {
|
||
|
|
if (this.closed) throw permissionDenied("Plugin permission engine is unavailable");
|
||
|
|
context.signal?.throwIfAborted();
|
||
|
|
const permission = descriptor.permission;
|
||
|
|
if (
|
||
|
|
descriptor.resources !== undefined
|
||
|
|
&& (!Array.isArray(descriptor.resources) || descriptor.resources.length > 128)
|
||
|
|
) throw permissionDenied("Plugin permission resources are invalid");
|
||
|
|
if (
|
||
|
|
descriptor.resourceKinds !== undefined
|
||
|
|
&& (
|
||
|
|
!Array.isArray(descriptor.resourceKinds)
|
||
|
|
|| descriptor.resourceKinds.length > 128
|
||
|
|
|| !Array.isArray(descriptor.resources)
|
||
|
|
|| descriptor.resourceKinds.length !== descriptor.resources.length
|
||
|
|
)
|
||
|
|
) throw permissionDenied("Plugin permission resource kinds are invalid");
|
||
|
|
descriptor = Object.freeze({
|
||
|
|
...descriptor,
|
||
|
|
reason: normalizePermissionReason(
|
||
|
|
HOST_PERMISSION_REASONS[permission] ?? descriptor.reason,
|
||
|
|
permission,
|
||
|
|
),
|
||
|
|
operationId: normalizePermissionOperationId(descriptor.operationId),
|
||
|
|
sessionId: normalizePermissionSessionId(descriptor.sessionId),
|
||
|
|
allowedScopes: normalizePermissionAllowedScopes(descriptor.allowedScopes),
|
||
|
|
});
|
||
|
|
const resourceDescriptors = normalizePermissionResourceDescriptors(
|
||
|
|
permission,
|
||
|
|
descriptor.resources?.length ? descriptor.resources : ["*"],
|
||
|
|
descriptor.resources?.length ? descriptor.resourceKinds : undefined,
|
||
|
|
);
|
||
|
|
const resources = resourceDescriptors.map(({ resource }) => resource);
|
||
|
|
const resourceKinds = resourceDescriptors.map(({ resourceKind }) => resourceKind);
|
||
|
|
const { declaration, declarationHash } = this.#assertDeclared(context, permission, resources);
|
||
|
|
if (this.#hasAllGrants(
|
||
|
|
context,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resources,
|
||
|
|
resourceKinds,
|
||
|
|
descriptor.sessionId,
|
||
|
|
)) {
|
||
|
|
if (AUDITED_PERMISSION_USES.has(permission)) {
|
||
|
|
this.database.recordSecurityAudit(context.pluginId, "permission.used", {
|
||
|
|
permission,
|
||
|
|
resources,
|
||
|
|
resourceKinds,
|
||
|
|
runtimeId: context.runtimeId ?? null,
|
||
|
|
operationId: descriptor.operationId ?? null,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return Object.freeze({ declaration, resources, scope: "existing" });
|
||
|
|
}
|
||
|
|
if (descriptor.interactive === false || !this.requestDecision) {
|
||
|
|
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||
|
|
permission,
|
||
|
|
resources,
|
||
|
|
reason: "no-interactive-approver",
|
||
|
|
});
|
||
|
|
throw permissionDenied(`Plugin permission is not granted: ${permission}`, {
|
||
|
|
permission,
|
||
|
|
resources,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const pendingKey = JSON.stringify([
|
||
|
|
context.pluginId,
|
||
|
|
context.runtimeId ?? null,
|
||
|
|
permission,
|
||
|
|
declarationHash,
|
||
|
|
resources,
|
||
|
|
resourceKinds,
|
||
|
|
descriptor.operationId ?? null,
|
||
|
|
descriptor.sessionId ?? null,
|
||
|
|
descriptor.allowedScopes ?? null,
|
||
|
|
]);
|
||
|
|
let pending = this.pending.get(pendingKey);
|
||
|
|
let ownsPrompt = false;
|
||
|
|
if (!pending) {
|
||
|
|
ownsPrompt = true;
|
||
|
|
pending = this.#requestGrant(
|
||
|
|
context,
|
||
|
|
descriptor,
|
||
|
|
resources,
|
||
|
|
resourceKinds,
|
||
|
|
declaration,
|
||
|
|
declarationHash,
|
||
|
|
)
|
||
|
|
.finally(() => this.pending.delete(pendingKey));
|
||
|
|
this.pending.set(pendingKey, pending);
|
||
|
|
}
|
||
|
|
const grant = await pending;
|
||
|
|
context.signal?.throwIfAborted();
|
||
|
|
if (!ownsPrompt && grant.scope === "once") {
|
||
|
|
return this.authorize(context, descriptor);
|
||
|
|
}
|
||
|
|
return Object.freeze({ declaration, resources, scope: grant.scope });
|
||
|
|
}
|
||
|
|
|
||
|
|
async #requestGrant(
|
||
|
|
context,
|
||
|
|
descriptor,
|
||
|
|
resources,
|
||
|
|
resourceKinds,
|
||
|
|
declaration,
|
||
|
|
declarationHash,
|
||
|
|
) {
|
||
|
|
const requestId = randomUUID();
|
||
|
|
const request = immutableRequest({
|
||
|
|
requestId,
|
||
|
|
pluginId: context.pluginId,
|
||
|
|
...(context.pluginVersion === undefined ? {} : { pluginVersion: context.pluginVersion }),
|
||
|
|
...(context.manifest?.name === undefined ? {} : { pluginName: context.manifest.name }),
|
||
|
|
...(context.manifest?.publisher === undefined ? {} : { publisher: context.manifest.publisher }),
|
||
|
|
runtimeId: context.runtimeId ?? null,
|
||
|
|
runtimeKind: context.runtimeKind ?? null,
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resources,
|
||
|
|
resourceKinds,
|
||
|
|
reason: descriptor.reason,
|
||
|
|
...(descriptor.operationId === undefined ? {} : { operationId: descriptor.operationId }),
|
||
|
|
...(descriptor.sessionId === undefined ? {} : { sessionId: descriptor.sessionId }),
|
||
|
|
...(descriptor.allowedScopes === undefined ? {} : { allowedScopes: descriptor.allowedScopes }),
|
||
|
|
});
|
||
|
|
const controller = new AbortController();
|
||
|
|
const promptEntry = Object.freeze({
|
||
|
|
controller,
|
||
|
|
pluginId: context.pluginId,
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resources: Object.freeze([...resources]),
|
||
|
|
});
|
||
|
|
this.promptControllers.add(promptEntry);
|
||
|
|
if (descriptor.sessionId) {
|
||
|
|
const controllers = this.sessionPromptControllers.get(descriptor.sessionId) ?? new Set();
|
||
|
|
controllers.add(controller);
|
||
|
|
this.sessionPromptControllers.set(descriptor.sessionId, controllers);
|
||
|
|
}
|
||
|
|
const timer = setTimeout(() => controller.abort(permissionDenied(
|
||
|
|
"Plugin permission request timed out",
|
||
|
|
)), this.promptTimeoutMs);
|
||
|
|
timer.unref?.();
|
||
|
|
const onAbort = () => controller.abort(context.signal.reason);
|
||
|
|
const onShutdown = () => controller.abort(this.shutdownController.signal.reason);
|
||
|
|
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||
|
|
this.shutdownController.signal.addEventListener("abort", onShutdown, { once: true });
|
||
|
|
let rawDecision;
|
||
|
|
try {
|
||
|
|
rawDecision = await raceWithAbort(
|
||
|
|
Promise.resolve(this.requestDecision(request, { signal: controller.signal })),
|
||
|
|
controller.signal,
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resources,
|
||
|
|
reason: controller.signal.aborted ? "prompt-aborted" : "decision-provider-failed",
|
||
|
|
});
|
||
|
|
if (controller.signal.aborted) throw controller.signal.reason;
|
||
|
|
throw permissionDenied("Plugin permission decision provider failed", {
|
||
|
|
permission: descriptor.permission,
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
clearTimeout(timer);
|
||
|
|
this.promptControllers.delete(promptEntry);
|
||
|
|
context.signal?.removeEventListener("abort", onAbort);
|
||
|
|
this.shutdownController.signal.removeEventListener("abort", onShutdown);
|
||
|
|
if (descriptor.sessionId) {
|
||
|
|
const controllers = this.sessionPromptControllers.get(descriptor.sessionId);
|
||
|
|
controllers?.delete(controller);
|
||
|
|
if (controllers?.size === 0) this.sessionPromptControllers.delete(descriptor.sessionId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (this.closed) throw permissionDenied("Plugin permission engine is unavailable");
|
||
|
|
let decision;
|
||
|
|
try {
|
||
|
|
decision = normalizeDecision(rawDecision, requestId);
|
||
|
|
} catch (error) {
|
||
|
|
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resources,
|
||
|
|
reason: "invalid-decision",
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
if (decision.decision !== "allow") {
|
||
|
|
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resources,
|
||
|
|
decision: decision.decision,
|
||
|
|
});
|
||
|
|
const outcome = decision.decision === "deny" ? "denied" : "cancelled";
|
||
|
|
throw permissionDenied(`Plugin permission was ${outcome}: ${descriptor.permission}`);
|
||
|
|
}
|
||
|
|
if (descriptor.allowedScopes && !descriptor.allowedScopes.includes(decision.scope)) {
|
||
|
|
throw permissionDenied(`Plugin permission scope is not supported for this operation: ${decision.scope}`);
|
||
|
|
}
|
||
|
|
let decisionResources;
|
||
|
|
try {
|
||
|
|
decisionResources = normalizePermissionResources(
|
||
|
|
descriptor.permission,
|
||
|
|
decision.resources === undefined ? resources : decision.resources,
|
||
|
|
"Plugin permission decision resources",
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
this.database.recordSecurityAudit(context.pluginId, "permission.denied", {
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resources,
|
||
|
|
reason: "invalid-decision-resources",
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
if (!decisionResources.every((resource) => declarationAllowsResource(declaration, resource))) {
|
||
|
|
throw permissionDenied("Plugin permission decision exceeds the manifest declaration");
|
||
|
|
}
|
||
|
|
const decisionResourceDescriptors = decisionResources.map((resource) => {
|
||
|
|
const requestedIndex = resources.indexOf(resource);
|
||
|
|
return Object.freeze({
|
||
|
|
resource,
|
||
|
|
resourceKind: requestedIndex === -1 ? "exact" : resourceKinds[requestedIndex],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
if (!resources.every((requested) => decisionResourceDescriptors.some((granted) => (
|
||
|
|
permissionResourceCovers(
|
||
|
|
descriptor.permission,
|
||
|
|
granted.resource,
|
||
|
|
requested,
|
||
|
|
granted.resourceKind,
|
||
|
|
resourceKinds[resources.indexOf(requested)],
|
||
|
|
)
|
||
|
|
)))) {
|
||
|
|
throw permissionDenied("Plugin permission decision does not cover the requested resources");
|
||
|
|
}
|
||
|
|
if (decision.scope === "session" && !descriptor.sessionId) {
|
||
|
|
throw permissionDenied("Session-scoped plugin permission requires a host-owned session");
|
||
|
|
}
|
||
|
|
context.signal?.throwIfAborted();
|
||
|
|
descriptor.validateBeforeGrant?.();
|
||
|
|
for (const { resource, resourceKind } of decisionResourceDescriptors) {
|
||
|
|
const grantKey = this.#grantKey(
|
||
|
|
context.pluginId,
|
||
|
|
descriptor.permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
);
|
||
|
|
if (decision.scope === "application") {
|
||
|
|
this.applicationGrants.set(grantKey, this.#createMemoryGrant(
|
||
|
|
context,
|
||
|
|
descriptor.permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
decision.scope,
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if (decision.scope === "session") {
|
||
|
|
this.sessionGrants.set(
|
||
|
|
this.#sessionGrantKey(descriptor.sessionId, grantKey),
|
||
|
|
this.#createMemoryGrant(
|
||
|
|
context,
|
||
|
|
descriptor.permission,
|
||
|
|
declarationHash,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
decision.scope,
|
||
|
|
descriptor.sessionId,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (decision.scope === "always") {
|
||
|
|
this.database.upsertPermissionGrant({
|
||
|
|
pluginId: context.pluginId,
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resource,
|
||
|
|
resourceKind,
|
||
|
|
declarationHash,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.database.recordSecurityAudit(context.pluginId, "permission.granted", {
|
||
|
|
permission: descriptor.permission,
|
||
|
|
resources: decisionResources,
|
||
|
|
resourceKinds: decisionResourceDescriptors.map(({ resourceKind }) => resourceKind),
|
||
|
|
scope: decision.scope,
|
||
|
|
operationId: descriptor.operationId ?? null,
|
||
|
|
});
|
||
|
|
return decision;
|
||
|
|
}
|
||
|
|
|
||
|
|
async authorizeRequired(plugin, options = {}) {
|
||
|
|
const declarations = normalizePermissionDeclarations(plugin.manifest);
|
||
|
|
const skipPermissions = new Set(options.skipPermissions ?? []);
|
||
|
|
const context = {
|
||
|
|
pluginId: plugin.id,
|
||
|
|
pluginVersion: plugin.activeVersion,
|
||
|
|
runtimeId: options.runtimeId ?? null,
|
||
|
|
manifest: plugin.manifest,
|
||
|
|
securityPrincipal: options.securityPrincipal,
|
||
|
|
signal: options.signal,
|
||
|
|
};
|
||
|
|
for (const declaration of declarations.values()) {
|
||
|
|
if (!declaration.required || skipPermissions.has(declaration.permission)) continue;
|
||
|
|
await this.authorize(context, {
|
||
|
|
permission: declaration.permission,
|
||
|
|
resources: declaration.resources.length ? declaration.resources : ["*"],
|
||
|
|
...(declaration.resources.length
|
||
|
|
&& (declaration.permission === "filesystem.read"
|
||
|
|
|| declaration.permission === "filesystem.write")
|
||
|
|
? { resourceKinds: declaration.resources.map(() => "directory") }
|
||
|
|
: {}),
|
||
|
|
reason: declaration.reason || `Required by ${plugin.id}`,
|
||
|
|
operationId: `activation:${plugin.activeVersion}`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
createMiddleware() {
|
||
|
|
return async (context, next) => {
|
||
|
|
if (context.metadata.public === true) return next();
|
||
|
|
if (!context.authorization) {
|
||
|
|
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
|
||
|
|
}
|
||
|
|
await this.authorize(context, context.authorization);
|
||
|
|
return next();
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
listGrants(pluginId) {
|
||
|
|
return Object.freeze({
|
||
|
|
always: Object.freeze(this.database.listPermissionGrants(pluginId).map((grant) => (
|
||
|
|
Object.freeze({ ...grant, scope: "always", sessionId: null })
|
||
|
|
))),
|
||
|
|
application: Object.freeze([...this.applicationGrants.values()].filter((grant) => (
|
||
|
|
grant.pluginId === pluginId
|
||
|
|
))),
|
||
|
|
session: Object.freeze([...this.sessionGrants.values()].filter((grant) => (
|
||
|
|
grant.pluginId === pluginId
|
||
|
|
))),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
revokeAlways(pluginId, permission, resource) {
|
||
|
|
assertPluginPermission(permission);
|
||
|
|
const canonicalResource = canonicalizePermissionResource(permission, resource);
|
||
|
|
this.database.deletePermissionGrant(
|
||
|
|
pluginId,
|
||
|
|
permission,
|
||
|
|
canonicalResource,
|
||
|
|
);
|
||
|
|
this.database.recordSecurityAudit(pluginId, "permission.revoked", {
|
||
|
|
scope: "always",
|
||
|
|
permission,
|
||
|
|
resource: canonicalResource,
|
||
|
|
});
|
||
|
|
this.#abortPrompts(pluginId, permission, canonicalResource);
|
||
|
|
this.#emitRevocation({ pluginId, permission, resource: canonicalResource, scope: "always" });
|
||
|
|
}
|
||
|
|
|
||
|
|
revokeApplication(pluginId, permission, resource) {
|
||
|
|
if (permission !== undefined) assertPluginPermission(permission);
|
||
|
|
if (resource !== undefined && permission === undefined) {
|
||
|
|
throw new TypeError("Revoking a permission resource requires a permission name");
|
||
|
|
}
|
||
|
|
const canonicalResource = resource === undefined
|
||
|
|
? undefined
|
||
|
|
: canonicalizePermissionResource(permission, resource);
|
||
|
|
for (const [key, grant] of [...this.applicationGrants]) {
|
||
|
|
if (
|
||
|
|
grant.pluginId === pluginId
|
||
|
|
&& (permission === undefined || grant.permission === permission)
|
||
|
|
&& (canonicalResource === undefined || grant.resource === canonicalResource)
|
||
|
|
) this.applicationGrants.delete(key);
|
||
|
|
}
|
||
|
|
this.database.recordSecurityAudit(pluginId, "permission.revoked", {
|
||
|
|
scope: "application",
|
||
|
|
...(permission === undefined ? {} : { permission }),
|
||
|
|
...(canonicalResource === undefined ? {} : { resource: canonicalResource }),
|
||
|
|
});
|
||
|
|
this.#abortPrompts(pluginId, permission, canonicalResource);
|
||
|
|
this.#emitRevocation({
|
||
|
|
pluginId,
|
||
|
|
...(permission === undefined ? {} : { permission }),
|
||
|
|
...(canonicalResource === undefined ? {} : { resource: canonicalResource }),
|
||
|
|
scope: "application",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
revokeSession(sessionId) {
|
||
|
|
const controllers = this.sessionPromptControllers.get(sessionId);
|
||
|
|
this.sessionPromptControllers.delete(sessionId);
|
||
|
|
for (const controller of controllers ?? []) {
|
||
|
|
controller.abort(permissionDenied("Plugin permission request session ended"));
|
||
|
|
}
|
||
|
|
for (const key of [...this.sessionGrants.keys()]) {
|
||
|
|
if (key.startsWith(`${sessionId}\0`)) this.sessionGrants.delete(key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
revokeAll(pluginId) {
|
||
|
|
this.database.deleteAllPermissionGrants(pluginId);
|
||
|
|
for (const [key, grant] of [...this.applicationGrants]) {
|
||
|
|
if (grant.pluginId === pluginId) this.applicationGrants.delete(key);
|
||
|
|
}
|
||
|
|
for (const [key, grant] of [...this.sessionGrants]) {
|
||
|
|
if (grant.pluginId === pluginId) this.sessionGrants.delete(key);
|
||
|
|
}
|
||
|
|
this.database.recordSecurityAudit(pluginId, "permission.revoked", { scope: "all" });
|
||
|
|
this.#abortPrompts(pluginId);
|
||
|
|
this.#emitRevocation({ pluginId, scope: "all" });
|
||
|
|
}
|
||
|
|
|
||
|
|
shutdown() {
|
||
|
|
if (this.closed) return;
|
||
|
|
this.closed = true;
|
||
|
|
this.shutdownController.abort(permissionDenied("Plugin permission engine is unavailable"));
|
||
|
|
this.applicationGrants.clear();
|
||
|
|
this.sessionGrants.clear();
|
||
|
|
this.promptControllers.clear();
|
||
|
|
this.revocationListeners.clear();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
AUDITED_PERMISSION_USES,
|
||
|
|
PROMPT_TIMEOUT_MS,
|
||
|
|
PluginPermissionEngine,
|
||
|
|
normalizePermissionOperationId,
|
||
|
|
normalizePermissionReason,
|
||
|
|
normalizePermissionResourceDescriptors,
|
||
|
|
normalizePermissionResources,
|
||
|
|
normalizePermissionSessionId,
|
||
|
|
normalizeDecision,
|
||
|
|
permissionDenied,
|
||
|
|
};
|