128 lines
12 KiB
TypeScript
128 lines
12 KiB
TypeScript
import { normalizeProjectAssetPath } from "./asset-path";
|
|
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
|
import type { ErrorCode } from "./error";
|
|
|
|
export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
|
|
export const SCRIPT_SOURCE_SCHEMA = 1 as const;
|
|
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536 } as const;
|
|
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
|
|
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
|
|
|
|
export interface ScriptDependencyIR { id: string; sourceSha256: string; sourcePath: string }
|
|
export interface ScriptManifestIR {
|
|
id: string;
|
|
name: string;
|
|
entryPath: string;
|
|
sourceSha256: string;
|
|
publisher: string;
|
|
signature: string;
|
|
keyId: string;
|
|
permissions: ScriptPermission[];
|
|
dependencies: ScriptDependencyIR[];
|
|
cpuMs: number;
|
|
memoryBytes: number;
|
|
wallMs: number;
|
|
network: false;
|
|
autorun: false;
|
|
driverExpressions: false;
|
|
addonInstall: false;
|
|
}
|
|
export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] }
|
|
export interface ScriptSourceIR {
|
|
id: string;
|
|
name: string;
|
|
source: string;
|
|
sourceSha256: string;
|
|
byteLength: number;
|
|
lineCount: number;
|
|
sourcePath?: string;
|
|
internal: boolean;
|
|
moduleAutorunRequested: boolean;
|
|
readOnly: true;
|
|
executionStatus: "BLOCKED";
|
|
errorCode: "SCRIPT_POLICY_DENIED" | "SCRIPT_SANDBOX_UNAVAILABLE";
|
|
}
|
|
export interface ScriptSourceInventoryIR { schemaVersion: typeof SCRIPT_SOURCE_SCHEMA; sources: ScriptSourceIR[] }
|
|
export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" }
|
|
|
|
export class ScriptingPlatformValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "ScriptingPlatformValidationError"; this.code = code; }
|
|
}
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/; const HEX_SIGNATURE = /^[a-f0-9]{128}$/;
|
|
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`); return value; }
|
|
function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} must be a lowercase SHA-256 digest`); return value; }
|
|
function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } }
|
|
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; }
|
|
|
|
export function parseScriptSourceInventory(value: unknown): ScriptSourceInventoryIR {
|
|
if (!record(value) || value.schemaVersion !== SCRIPT_SOURCE_SCHEMA || !Array.isArray(value.sources)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script source inventory schema");
|
|
if (value.sources.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script source count exceeds the budget");
|
|
const ids = new Set<string>(); let totalBytes = 0;
|
|
const sources = value.sources.map((item, index): ScriptSourceIR => {
|
|
const name = `sources[${index}]`; if (!record(item) || typeof item.source !== "string") throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
|
const id = text(item.id, `${name}.id`); if (!id.startsWith("text:") || ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.id is invalid`); ids.add(id);
|
|
const byteLength = integer(item.byteLength, `${name}.byteLength`, 0, SCRIPTING_BUDGET.maxSourceBytes); totalBytes += byteLength;
|
|
if (totalBytes > SCRIPTING_BUDGET.maxSourceBytes || new TextEncoder().encode(item.source).byteLength !== byteLength) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.source exceeds or disagrees with the byte budget`);
|
|
const moduleAutorunRequested = item.moduleAutorunRequested === true;
|
|
if (item.readOnly !== true || item.executionStatus !== "BLOCKED" || item.internal !== (item.sourcePath === undefined) ||
|
|
item.errorCode !== (moduleAutorunRequested ? "SCRIPT_POLICY_DENIED" : "SCRIPT_SANDBOX_UNAVAILABLE")) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name} execution policy is invalid`);
|
|
const sourcePath = item.sourcePath === undefined ? undefined : path(item.sourcePath, `${name}.sourcePath`);
|
|
const lineCount = integer(item.lineCount, `${name}.lineCount`, 1, SCRIPTING_BUDGET.maxSourceLines);
|
|
if (item.source.split("\n").length !== lineCount) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.lineCount disagrees with source`);
|
|
return { id, name: text(item.name, `${name}.name`), source: item.source, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), byteLength, lineCount, sourcePath, internal: item.internal as boolean, moduleAutorunRequested, readOnly: true, executionStatus: "BLOCKED", errorCode: item.errorCode as ScriptSourceIR["errorCode"] };
|
|
});
|
|
return { schemaVersion: SCRIPT_SOURCE_SCHEMA, sources };
|
|
}
|
|
|
|
export async function verifyScriptSource(source: ScriptSourceIR): Promise<boolean> {
|
|
const digestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source.source));
|
|
return [...new Uint8Array(digestBytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("") === source.sourceSha256;
|
|
}
|
|
|
|
export function parseScriptingManifest(value: unknown): ScriptingManifestIR {
|
|
if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema");
|
|
if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget");
|
|
const ids = new Set<string>();
|
|
const scripts = value.scripts.map((item, index): ScriptManifestIR => {
|
|
const name = `scripts[${index}]`; if (!record(item) || !Array.isArray(item.permissions) || !Array.isArray(item.dependencies)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
|
|
const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Duplicate script ${id}`); ids.add(id);
|
|
if (item.permissions.length > SCRIPTING_BUDGET.maxPermissions || item.permissions.some((permission) => !SCRIPT_PERMISSIONS.includes(permission as ScriptPermission)) || new Set(item.permissions).size !== item.permissions.length) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name}.permissions are invalid or exceed the allowlist`);
|
|
if (item.dependencies.length > SCRIPTING_BUDGET.maxDependencies) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.dependencies exceed the budget`);
|
|
const dependencies = item.dependencies.map((dependency, dependencyIndex): ScriptDependencyIR => { const dependencyName = `${name}.dependencies[${dependencyIndex}]`; if (!record(dependency)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${dependencyName} is invalid`); return { id: text(dependency.id, `${dependencyName}.id`), sourceSha256: digest(dependency.sourceSha256, `${dependencyName}.sourceSha256`), sourcePath: path(dependency.sourcePath, `${dependencyName}.sourcePath`) }; });
|
|
if (item.network !== false || item.autorun !== false || item.driverExpressions !== false || item.addonInstall !== false) throw new ScriptingPlatformValidationError(item.driverExpressions === true ? "DRIVER_EXECUTION_BLOCKED" : item.addonInstall === true ? "ADDON_INSTALL_BLOCKED" : "SCRIPT_POLICY_DENIED", `${name} requests a denied execution policy`);
|
|
if (typeof item.signature !== "string" || !HEX_SIGNATURE.test(item.signature)) throw new ScriptingPlatformValidationError("SCRIPT_SIGNATURE_INVALID", `${name}.signature is invalid`);
|
|
return { id, name: text(item.name, `${name}.name`), entryPath: path(item.entryPath, `${name}.entryPath`), sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), publisher: text(item.publisher, `${name}.publisher`), signature: item.signature, keyId: text(item.keyId, `${name}.keyId`, 128), permissions: [...item.permissions] as ScriptPermission[], dependencies, cpuMs: integer(item.cpuMs, `${name}.cpuMs`, 1, SCRIPTING_BUDGET.maxCpuMs), memoryBytes: integer(item.memoryBytes, `${name}.memoryBytes`, 1, SCRIPTING_BUDGET.maxMemoryBytes), wallMs: integer(item.wallMs, `${name}.wallMs`, 1, SCRIPTING_BUDGET.maxWallMs), network: false, autorun: false, driverExpressions: false, addonInstall: false };
|
|
});
|
|
const scriptIds = new Set(scripts.map((script) => script.id));
|
|
const active = new Set<string>(); const complete = new Set<string>(); const visit = (id: string): void => { if (active.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Script dependency cycle includes ${id}`); if (complete.has(id)) return; const script = scripts.find((item) => item.id === id); if (!script) return; active.add(id); for (const dependency of script.dependencies) { if (!scriptIds.has(dependency.id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${id} references missing script ${dependency.id}`); visit(dependency.id); } active.delete(id); complete.add(id); }; scripts.forEach((script) => visit(script.id));
|
|
return { schemaVersion: SCRIPTING_PLATFORM_SCHEMA, scripts };
|
|
}
|
|
|
|
export function gateScriptExecution(manifest: unknown, scriptId: string, approvedKeyIds: ReadonlySet<string>): CapabilityGateResult {
|
|
const parsed = parseScriptingManifest(manifest); const script = parsed.scripts.find((item) => item.id === scriptId); if (!script) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `Unknown script ${scriptId}`);
|
|
if (!approvedKeyIds.has(script.keyId)) return blockedGate("N-025", `SCRIPT_${script.id}`, [capabilityIssue("SCRIPT_SIGNATURE_INVALID", `Script ${script.id} is not signed by an approved key`)]);
|
|
return blockedGate("N-025", `SCRIPT_${script.id}`, [capabilityIssue("SCRIPT_SANDBOX_UNAVAILABLE", "Local Python/Native execution requires an isolated sandbox")]);
|
|
}
|
|
|
|
export function gateServerScriptJob(value: unknown, manifest: unknown, inputBlendSha256: string): CapabilityGateResult {
|
|
const parsed = parseScriptingManifest(manifest); if (!record(value)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", "Server script job is invalid"); const script = parsed.scripts.find((item) => item.id === value.scriptId); if (!script || script.sourceSha256 !== value.sourceSha256 || !SHA256.test(inputBlendSha256)) throw new ScriptingPlatformValidationError("ASSET_SOURCE_HASH_MISMATCH", "Server script job source hash is invalid");
|
|
return blockedGate("N-025", `SERVER_SCRIPT_${script.id}`, [capabilityIssue("SERVER_JOB_UNAVAILABLE", "Server Blender job endpoint is not configured")]);
|
|
}
|
|
|
|
export function platformCapabilities(scope: typeof globalThis = globalThis): Record<string, "AVAILABLE" | "PROBE_REQUIRED" | "BLOCKED" | "UNAVAILABLE"> {
|
|
return {
|
|
worker: typeof scope.Worker === "function" ? "AVAILABLE" : "UNAVAILABLE",
|
|
webgpu: "gpu" in scope.navigator ? "PROBE_REQUIRED" : "UNAVAILABLE",
|
|
offscreenCanvas: "OffscreenCanvas" in scope ? "AVAILABLE" : "UNAVAILABLE",
|
|
opfs: scope.navigator.storage && typeof (scope.navigator.storage as StorageManager & { getDirectory?: unknown }).getDirectory === "function" ? "PROBE_REQUIRED" : "UNAVAILABLE",
|
|
nativeWindow: "BLOCKED",
|
|
cuda: "BLOCKED",
|
|
metal: "BLOCKED",
|
|
hip: "BLOCKED",
|
|
optix: "BLOCKED",
|
|
};
|
|
}
|