[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
755
electron/plugins/permissionEngine.cjs
Normal file
755
electron/plugins/permissionEngine.cjs
Normal file
@@ -0,0 +1,755 @@
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user