diff --git a/.chronus/changes/sandbox-permission-foundation-2026-6-24-13-48-0.md b/.chronus/changes/sandbox-permission-foundation-2026-6-24-13-48-0.md new file mode 100644 index 00000000000..0946f94b964 --- /dev/null +++ b/.chronus/changes/sandbox-permission-foundation-2026-6-24-13-48-0.md @@ -0,0 +1,29 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add the foundation for sandboxed, permissioned execution of libraries and emitters. Libraries/emitters can declare the system capabilities they need via a permission manifest, and users approve them per emitter in `tspconfig.yaml`. By default an emitter/library is granted no access to any system API. + +```ts +// In a library/emitter's `$lib` +export const $lib = createTypeSpecLibrary({ + name: "@typespec/openapi3", + diagnostics: {}, + permissions: [ + { permission: { kind: "fs-read", paths: ["./schemas"] }, reason: "Read shared JSON schemas" }, + { permission: { kind: "network", hosts: ["*.example.com"] }, reason: "Resolve remote refs" }, + ], +}); +``` + +```yaml +# tspconfig.yaml — the user authorizes what the emitter requested +permissions: + "@typespec/openapi3": + fs-read: + - ./schemas + network: + - "*.example.com" +``` diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 4ebaa9f1536..5048c6acab4 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -66,7 +66,9 @@ }, "browser": { "./dist/src/core/node-host.js": "./dist/src/core/node-host.browser.js", - "./dist/src/core/logger/console-sink.js": "./dist/src/core/logger/console-sink.browser.js" + "./dist/src/core/logger/console-sink.js": "./dist/src/core/logger/console-sink.browser.js", + "./dist/src/core/permissions/sandbox/runtime.js": "./dist/src/core/permissions/sandbox/runtime.browser.js", + "./dist/src/core/permissions/sandbox/emit-runner.js": "./dist/src/core/permissions/sandbox/emit-runner.browser.js" }, "engines": { "node": ">=22.0.0" diff --git a/packages/compiler/src/config/config-loader.ts b/packages/compiler/src/config/config-loader.ts index 5aebcb54aa3..70ad514652b 100644 --- a/packages/compiler/src/config/config-loader.ts +++ b/packages/compiler/src/config/config-loader.ts @@ -251,6 +251,7 @@ async function loadConfigFile( trace: typeof data.trace === "string" ? [data.trace] : data.trace, emit, options, + permissions: data.permissions, linter: data.linter, }); } diff --git a/packages/compiler/src/config/config-schema.ts b/packages/compiler/src/config/config-schema.ts index f0452d1bad1..a85bd8b9523 100644 --- a/packages/compiler/src/config/config-schema.ts +++ b/packages/compiler/src/config/config-schema.ts @@ -92,6 +92,25 @@ export const TypeSpecConfigJsonSchema: JSONSchemaType = { required: [], additionalProperties: emitterOptionsSchema, }, + permissions: { + type: "object", + nullable: true, + required: [], + additionalProperties: { + type: "object", + additionalProperties: false, + required: [], + properties: { + "fs-read": { type: "array", nullable: true, items: { type: "string" } }, + "fs-write": { type: "array", nullable: true, items: { type: "string" } }, + network: { type: "array", nullable: true, items: { type: "string" } }, + env: { type: "array", nullable: true, items: { type: "string" } }, + exec: { + oneOf: [{ type: "boolean" }, { type: "array", items: { type: "string" } }], + }, + }, + }, + } as any, // ajv optional property typing https://github.com/ajv-validator/ajv/issues/1664 linter: { type: "object", nullable: true, diff --git a/packages/compiler/src/config/types.ts b/packages/compiler/src/config/types.ts index c0f748fcca2..3088d89f6ac 100644 --- a/packages/compiler/src/config/types.ts +++ b/packages/compiler/src/config/types.ts @@ -85,6 +85,13 @@ export interface TypeSpecConfig { */ options?: Record; + /** + * Permissions granted to emitters/libraries when running in the sandbox. + * Keyed by the emitter/library name (matching its package name). By default + * an emitter/library is granted nothing and cannot access any system API. + */ + permissions?: Record; + linter?: LinterConfig; } @@ -122,9 +129,32 @@ export interface TypeSpecRawConfig { emit?: string[]; options?: Record; + permissions?: Record; + linter?: LinterConfig; } +/** + * Permissions a user grants to a specific emitter/library in `tspconfig.yaml`. + * Anything not listed here is denied. Path scopes should be absolute or relative + * to the config file; they are resolved during configuration loading. + */ +export interface ConfigPermissionGrant { + /** Directory/file scopes the emitter/library may read. */ + "fs-read"?: string[]; + /** + * Directory/file scopes the emitter/library may write, in addition to its own + * emitter output directory which is always granted. + */ + "fs-write"?: string[]; + /** Network host patterns the emitter/library may contact (supports `*` and `*.host`). */ + network?: string[]; + /** Environment variable names the emitter/library may read. */ + env?: string[]; + /** Allow spawning child processes: `true` for any command or a list of allowed commands. */ + exec?: boolean | string[]; +} + export interface ConfigEnvironmentVariable { default: string; } diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 3700b38b82a..a7d9a3416f0 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -811,6 +811,12 @@ const diagnostics = { default: paramMessage`Emitter '${"emitterName"}' requires '${"requiredImport"}' to be imported. Add 'import "${"requiredImport"}".`, }, }, + "permission-not-granted": { + severity: "error", + messages: { + default: paramMessage`Emitter '${"emitterName"}' requested permissions that were not granted: ${"permissions"}. To allow them, add the following to your tspconfig.yaml:\n${"suggestion"}`, + }, + }, /** * Linter diff --git a/packages/compiler/src/core/options.ts b/packages/compiler/src/core/options.ts index 6f5d88140f1..c5207518838 100644 --- a/packages/compiler/src/core/options.ts +++ b/packages/compiler/src/core/options.ts @@ -68,6 +68,14 @@ export interface CompilerOptions { /** Ruleset to enable for linting. */ linterRuleSet?: LinterRuleSet; + /** + * Run emitters inside an OS-isolated sandbox process whose file-system, + * network, environment and child-process access is constrained to the + * permissions the emitter declared and the user approved in `tspconfig.yaml`. + * Defaults to `false` (in-process execution) while the feature is opt-in. + */ + sandbox?: boolean; + /** @internal */ readonly configFile?: TypeSpecConfig; } diff --git a/packages/compiler/src/core/permissions/index.ts b/packages/compiler/src/core/permissions/index.ts new file mode 100644 index 00000000000..b61eeaf69b9 --- /dev/null +++ b/packages/compiler/src/core/permissions/index.ts @@ -0,0 +1,44 @@ +export { + EMPTY_PERMISSION_SET, + PERMISSION_KINDS, + createPermissionSet, + findMissingPermissions, + formatPermission, + intersectPermissionSets, + isEmptyPermissionSet, + isHostWithinScopes, + isPathWithinScopes, + mergePermissionSets, +} from "./permission-set.js"; +export { + PermissionDeniedError, + createPermissionedHost, + createPermissionedSystemHost, +} from "./permissioned-host.js"; +export { + ESSENTIAL_ENV_NAMES, + buildSandboxEnv, + permissionSetToNodeArgs, +} from "./sandbox/node-args.js"; +export type { NodeSandboxArgsOptions } from "./sandbox/node-args.js"; +export { runInSandbox } from "./sandbox/runtime.js"; +export type { RunInSandboxOptions } from "./sandbox/runtime.js"; +export type { SandboxContext } from "./sandbox/bootstrap.js"; +export { + configGrantToPermissionSet, + formatGrantSuggestion, + manifestToPermissionSet, + resolvePermissions, +} from "./resolve.js"; +export type { PermissionGrantInput, PermissionResolution, ResolveGrantOptions } from "./resolve.js"; +export type { + EnvPermission, + ExecPermission, + FsReadPermission, + FsWritePermission, + NetworkPermission, + Permission, + PermissionKind, + PermissionRequest, + PermissionSet, +} from "./types.js"; diff --git a/packages/compiler/src/core/permissions/permission-set.ts b/packages/compiler/src/core/permissions/permission-set.ts new file mode 100644 index 00000000000..70f6b48709e --- /dev/null +++ b/packages/compiler/src/core/permissions/permission-set.ts @@ -0,0 +1,218 @@ +import { getNormalizedPathComponents } from "../path-utils.js"; +import { + EMPTY_PERMISSION_SET, + type Permission, + type PermissionKind, + type PermissionSet, +} from "./types.js"; + +/** + * Build a normalized {@link PermissionSet} from a flat list of granular + * {@link Permission}s. Repeated kinds are merged (their scopes unioned). + */ +export function createPermissionSet(permissions: readonly Permission[]): PermissionSet { + const fsRead = new Set(); + const fsWrite = new Set(); + const network = new Set(); + const env = new Set(); + let exec: boolean | Set = false; + + for (const permission of permissions) { + switch (permission.kind) { + case "fs-read": + for (const p of permission.paths) fsRead.add(p); + break; + case "fs-write": + for (const p of permission.paths) fsWrite.add(p); + break; + case "network": + for (const h of permission.hosts) network.add(h); + break; + case "env": + for (const n of permission.names) env.add(n); + break; + case "exec": + if (permission.commands === undefined) { + exec = true; + } else if (exec !== true) { + const set: Set = exec instanceof Set ? exec : new Set(); + for (const c of permission.commands) set.add(c); + exec = set; + } + break; + } + } + + return { + fsRead: [...fsRead], + fsWrite: [...fsWrite], + network: [...network], + env: [...env], + exec: exec instanceof Set ? [...exec] : exec, + }; +} + +/** Union two permission sets (used to compute the combined needs of libraries). */ +export function mergePermissionSets(a: PermissionSet, b: PermissionSet): PermissionSet { + return { + fsRead: unionScopes(a.fsRead, b.fsRead), + fsWrite: unionScopes(a.fsWrite, b.fsWrite), + network: unionScopes(a.network, b.network), + env: unionScopes(a.env, b.env), + exec: unionExec(a.exec, b.exec), + }; +} + +/** + * Compute the effective grant: everything `requested` that is also covered by + * `granted`. This is what the sandbox will actually be allowed to do — a library + * never receives more than it asked for, nor more than the user approved. + */ +export function intersectPermissionSets( + requested: PermissionSet, + granted: PermissionSet, +): PermissionSet { + return { + fsRead: requested.fsRead.filter((p) => isPathWithinScopes(p, granted.fsRead)), + fsWrite: requested.fsWrite.filter((p) => isPathWithinScopes(p, granted.fsWrite)), + network: requested.network.filter((h) => isHostWithinScopes(h, granted.network)), + env: requested.env.filter((n) => granted.env.includes(n)), + exec: intersectExec(requested.exec, granted.exec), + }; +} + +/** + * Determine which of the `requested` permissions are NOT covered by `granted`. + * Returns granular {@link Permission}s suitable for building an actionable + * diagnostic telling the user exactly what to add to their config. + */ +export function findMissingPermissions( + requested: PermissionSet, + granted: PermissionSet, +): Permission[] { + const missing: Permission[] = []; + + const fsRead = requested.fsRead.filter((p) => !isPathWithinScopes(p, granted.fsRead)); + if (fsRead.length > 0) missing.push({ kind: "fs-read", paths: fsRead }); + + const fsWrite = requested.fsWrite.filter((p) => !isPathWithinScopes(p, granted.fsWrite)); + if (fsWrite.length > 0) missing.push({ kind: "fs-write", paths: fsWrite }); + + const network = requested.network.filter((h) => !isHostWithinScopes(h, granted.network)); + if (network.length > 0) missing.push({ kind: "network", hosts: network }); + + const env = requested.env.filter((n) => !granted.env.includes(n)); + if (env.length > 0) missing.push({ kind: "env", names: env }); + + if (requested.exec === true && granted.exec !== true) { + missing.push({ kind: "exec" }); + } else if (Array.isArray(requested.exec)) { + const commands = requested.exec.filter((c) => !isExecCommandAllowed(c, granted.exec)); + if (commands.length > 0) missing.push({ kind: "exec", commands }); + } + + return missing; +} + +/** Whether a permission set grants no capabilities at all. */ +export function isEmptyPermissionSet(set: PermissionSet): boolean { + return ( + set.fsRead.length === 0 && + set.fsWrite.length === 0 && + set.network.length === 0 && + set.env.length === 0 && + set.exec === false + ); +} + +/** + * Test whether `path` falls within at least one of the granted directory/file + * `scopes`. Paths are normalized; a scope grants access to itself and anything + * beneath it. + */ +export function isPathWithinScopes(path: string, scopes: readonly string[]): boolean { + if (scopes.length === 0) return false; + const target = getNormalizedPathComponents(path, undefined); + return scopes.some((scope) => { + const base = getNormalizedPathComponents(scope, undefined); + if (target.length < base.length) return false; + return base.every((component, i) => component === target[i]); + }); +} + +/** + * Test whether `host` is allowed by `scopes`. A scope of `*` allows any host; + * a scope of `*.example.com` matches any sub-domain (and the apex) of + * `example.com`; otherwise an exact (case-insensitive) match is required. + */ +export function isHostWithinScopes(host: string, scopes: readonly string[]): boolean { + const normalized = host.toLowerCase(); + return scopes.some((scope) => { + const pattern = scope.toLowerCase(); + if (pattern === "*") return true; + if (pattern.startsWith("*.")) { + const suffix = pattern.slice(1); // ".example.com" + return normalized.endsWith(suffix) || normalized === suffix.slice(1); + } + return normalized === pattern; + }); +} + +/** Human-readable rendering of a permission for diagnostics. */ +export function formatPermission(permission: Permission): string { + switch (permission.kind) { + case "fs-read": + return `fs-read(${permission.paths.join(", ")})`; + case "fs-write": + return `fs-write(${permission.paths.join(", ")})`; + case "network": + return `network(${permission.hosts.join(", ")})`; + case "env": + return `env(${permission.names.join(", ")})`; + case "exec": + return permission.commands ? `exec(${permission.commands.join(", ")})` : "exec"; + } +} + +/** Stable display order of permission kinds. */ +export const PERMISSION_KINDS: readonly PermissionKind[] = [ + "fs-read", + "fs-write", + "network", + "env", + "exec", +]; + +function unionScopes(a: readonly string[], b: readonly string[]): string[] { + return [...new Set([...a, ...b])]; +} + +function unionExec( + a: boolean | readonly string[], + b: boolean | readonly string[], +): boolean | readonly string[] { + if (a === true || b === true) return true; + const aList = a === false ? [] : a; + const bList = b === false ? [] : b; + const merged = [...new Set([...aList, ...bList])]; + return merged.length === 0 ? false : merged; +} + +function intersectExec( + requested: boolean | readonly string[], + granted: boolean | readonly string[], +): boolean | readonly string[] { + if (requested === false || granted === false) return false; + if (granted === true) return requested; + if (requested === true) return granted; + const result = requested.filter((c) => granted.includes(c)); + return result.length === 0 ? false : result; +} + +function isExecCommandAllowed(command: string, granted: boolean | readonly string[]): boolean { + if (granted === true) return true; + if (granted === false) return false; + return granted.includes(command); +} + +export { EMPTY_PERMISSION_SET }; diff --git a/packages/compiler/src/core/permissions/permissioned-host.ts b/packages/compiler/src/core/permissions/permissioned-host.ts new file mode 100644 index 00000000000..e85d3d8f474 --- /dev/null +++ b/packages/compiler/src/core/permissions/permissioned-host.ts @@ -0,0 +1,115 @@ +import type { CompilerHost, SystemHost } from "../types.js"; +import { isHostWithinScopes, isPathWithinScopes } from "./permission-set.js"; +import type { PermissionSet } from "./types.js"; + +/** + * Error thrown when sandboxed code attempts a system operation outside its + * granted permissions. The `code` mirrors Node's permission-model error code so + * callers can treat broker-side and OS-side denials uniformly. + */ +export class PermissionDeniedError extends Error { + readonly code = "ERR_ACCESS_DENIED"; + /** The permission category that was violated. */ + readonly permission: "fs-read" | "fs-write" | "network"; + /** The path or host that was denied. */ + readonly resource: string; + + constructor(permission: "fs-read" | "fs-write" | "network", resource: string) { + super( + `Permission denied: '${permission}' access to '${resource}' is not granted to this sandboxed emitter/library.`, + ); + this.name = "PermissionDeniedError"; + this.permission = permission; + this.resource = resource; + } +} + +function assertReadable(path: string, permissions: PermissionSet): void { + if (!isPathWithinScopes(path, permissions.fsRead)) { + throw new PermissionDeniedError("fs-read", path); + } +} + +function assertWritable(path: string, permissions: PermissionSet): void { + if (!isPathWithinScopes(path, permissions.fsWrite)) { + throw new PermissionDeniedError("fs-write", path); + } +} + +function assertNetwork(url: string, permissions: PermissionSet): void { + let host: string; + try { + host = new URL(url).hostname; + } catch { + // A non-URL (e.g. a file path passed to readUrl): treat as a read. + assertReadable(url, permissions); + return; + } + if (!isHostWithinScopes(host, permissions.network)) { + throw new PermissionDeniedError("network", host); + } +} + +/** + * Wrap a {@link SystemHost} so every file-system and network operation is + * validated against the given {@link PermissionSet} before it reaches the real + * host. This is the privileged-side enforcement used by the sandbox broker and + * provides defense in depth alongside the OS-level Node permission model. + * + * Operations outside the granted scopes throw {@link PermissionDeniedError}. + */ +export function createPermissionedSystemHost( + inner: SystemHost, + permissions: PermissionSet, +): SystemHost { + return { + readUrl: async (url) => { + assertNetwork(url, permissions); + return inner.readUrl(url); + }, + readFile: async (path) => { + assertReadable(path, permissions); + return inner.readFile(path); + }, + writeFile: async (path, content) => { + assertWritable(path, permissions); + return inner.writeFile(path, content); + }, + readDir: async (path) => { + assertReadable(path, permissions); + return inner.readDir(path); + }, + rm: async (path, options) => { + assertWritable(path, permissions); + return inner.rm(path, options); + }, + mkdirp: async (path) => { + assertWritable(path, permissions); + return inner.mkdirp(path); + }, + stat: async (path) => { + assertReadable(path, permissions); + return inner.stat(path); + }, + realpath: async (path) => { + assertReadable(path, permissions); + return inner.realpath(path); + }, + }; +} + +/** + * Wrap a {@link CompilerHost} so its file-system/network surface is constrained + * to the given {@link PermissionSet}. Non-IO members (module loading helpers, + * path conversion, log sink) are passed through unchanged. + */ +export function createPermissionedHost( + inner: CompilerHost, + permissions: PermissionSet, +): CompilerHost { + const system = createPermissionedSystemHost(inner, permissions); + return { + ...inner, + ...system, + }; +} diff --git a/packages/compiler/src/core/permissions/resolve.ts b/packages/compiler/src/core/permissions/resolve.ts new file mode 100644 index 00000000000..ede4ce939a3 --- /dev/null +++ b/packages/compiler/src/core/permissions/resolve.ts @@ -0,0 +1,159 @@ +import { resolvePath } from "../path-utils.js"; +import { + createPermissionSet, + findMissingPermissions, + intersectPermissionSets, + mergePermissionSets, +} from "./permission-set.js"; +import { + EMPTY_PERMISSION_SET, + type Permission, + type PermissionRequest, + type PermissionSet, +} from "./types.js"; + +/** + * Plain shape of a per-library permission grant as written in `tspconfig.yaml`. + * Kept structural (rather than importing the config type) so the permissions + * module stays free of a dependency on the config layer. + */ +export interface PermissionGrantInput { + "fs-read"?: readonly string[]; + "fs-write"?: readonly string[]; + network?: readonly string[]; + env?: readonly string[]; + exec?: boolean | readonly string[]; +} + +export interface ResolveGrantOptions { + /** Directory that relative grant paths are resolved against (the config dir). */ + readonly baseDir: string; + /** + * The emitter's output directory. Always granted for `fs-write` so emitters + * can produce their output without an explicit grant. Should be absolute. + */ + readonly outputDir?: string; +} + +/** The outcome of resolving a library/emitter's requested permissions against a grant. */ +export interface PermissionResolution { + /** What the library/emitter asked for in its manifest. */ + readonly requested: PermissionSet; + /** What the user granted in config (plus implicit output-dir write). */ + readonly granted: PermissionSet; + /** The intersection actually handed to the sandbox. Never exceeds either side. */ + readonly effective: PermissionSet; + /** Requested permissions the user did not grant. Empty means fully authorized. */ + readonly missing: Permission[]; +} + +/** Build the requested {@link PermissionSet} from a manifest's permission requests. */ +export function manifestToPermissionSet( + requests: readonly PermissionRequest[] | undefined, +): PermissionSet { + if (!requests || requests.length === 0) { + return EMPTY_PERMISSION_SET; + } + return createPermissionSet(requests.map((r) => r.permission)); +} + +/** + * Build the granted {@link PermissionSet} from a user's config grant. Relative + * path scopes are resolved against {@link ResolveGrantOptions.baseDir}, and the + * emitter output directory is always added to the writable scopes. + */ +export function configGrantToPermissionSet( + grant: PermissionGrantInput | undefined, + options: ResolveGrantOptions, +): PermissionSet { + const permissions: Permission[] = []; + const resolve = (p: string) => resolvePath(options.baseDir, p); + + if (grant?.["fs-read"]?.length) { + permissions.push({ kind: "fs-read", paths: grant["fs-read"].map(resolve) }); + } + + const writeScopes = grant?.["fs-write"]?.map(resolve) ?? []; + const allWrites = options.outputDir + ? [...writeScopes, resolvePath(options.outputDir)] + : [...writeScopes]; + if (allWrites.length) { + permissions.push({ kind: "fs-write", paths: allWrites }); + } + + if (grant?.network?.length) { + permissions.push({ kind: "network", hosts: [...grant.network] }); + } + if (grant?.env?.length) { + permissions.push({ kind: "env", names: [...grant.env] }); + } + if (grant?.exec === true) { + permissions.push({ kind: "exec" }); + } else if (Array.isArray(grant?.exec) && grant.exec.length) { + permissions.push({ kind: "exec", commands: [...grant.exec] }); + } + + return createPermissionSet(permissions); +} + +/** + * Resolve a library/emitter's manifest against the user's config grant, yielding + * the effective permissions for the sandbox and the list of anything missing. + */ +export function resolvePermissions( + requests: readonly PermissionRequest[] | undefined, + grant: PermissionGrantInput | undefined, + options: ResolveGrantOptions, +): PermissionResolution { + const requested = manifestToPermissionSet(requests); + const granted = configGrantToPermissionSet(grant, options); + const missing = findMissingPermissions(requested, granted); + + // The intersection never exceeds what the emitter asked for *and* the user + // granted. The emitter's own output directory is an exception: it is always + // writable without an explicit request or grant, so merge it in directly + // rather than gating it behind the intersection. + const intersection = intersectPermissionSets(requested, granted); + const effective = options.outputDir + ? mergePermissionSets( + intersection, + createPermissionSet([{ kind: "fs-write", paths: [resolvePath(options.outputDir)] }]), + ) + : intersection; + + return { requested, granted, effective, missing }; +} + +/** + * Render a `tspconfig.yaml` snippet the user can paste to grant the `missing` + * permissions to `libraryName`. Used in the `permission-not-granted` diagnostic. + */ +export function formatGrantSuggestion(libraryName: string, missing: readonly Permission[]): string { + const lines: string[] = ["permissions:", ` "${libraryName}":`]; + for (const permission of missing) { + switch (permission.kind) { + case "fs-read": + case "fs-write": + lines.push(` ${permission.kind}:`); + for (const p of permission.paths) lines.push(` - ${p}`); + break; + case "network": + lines.push(` network:`); + for (const h of permission.hosts) lines.push(` - ${h}`); + break; + case "env": + lines.push(` env:`); + for (const n of permission.names) lines.push(` - ${n}`); + break; + case "exec": + if (permission.commands) { + lines.push(` exec:`); + for (const c of permission.commands) lines.push(` - ${c}`); + } else { + lines.push(` exec: true`); + } + break; + } + } + return lines.join("\n"); +} diff --git a/packages/compiler/src/core/permissions/sandbox/bootstrap.ts b/packages/compiler/src/core/permissions/sandbox/bootstrap.ts new file mode 100644 index 00000000000..8016e5ff27a --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/bootstrap.ts @@ -0,0 +1,115 @@ +import type { SystemHost } from "../../types.js"; +import { + deserializeError, + serializeError, + type SandboxChildMessage, + type SandboxHostMethod, + type SandboxHostResponse, + type SandboxJob, + type SandboxParentMessage, +} from "./protocol.js"; + +/** + * Entry point that runs *inside* the sandboxed child process. It is launched by + * {@link runInSandbox} with Node permission flags already restricting its file + * system / child-process access. Here we additionally: + * - expose a brokered {@link SystemHost} that forwards IO to the parent, + * - deny ambient network access by default (best effort; not OS-enforced), + * - import and invoke the requested module export with that context. + */ + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: unknown) => void; +} + +const pending = new Map(); +let nextId = 0; + +function send(message: SandboxChildMessage): void { + process.send!(message); +} + +function hostCall(method: SandboxHostMethod, ...args: unknown[]): Promise { + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + send({ kind: "host-request", id, method, args }); + }); +} + +/** A SystemHost whose operations are serviced (and re-validated) by the broker. */ +const brokeredHost: SystemHost = { + readUrl: (url) => hostCall("readUrl", url) as any, + readFile: (path) => hostCall("readFile", path) as any, + writeFile: (path, content) => hostCall("writeFile", path, content) as any, + readDir: (path) => hostCall("readDir", path) as any, + rm: (path, options) => hostCall("rm", path, options) as any, + mkdirp: (path) => hostCall("mkdirp", path) as any, + stat: async (path) => { + const s = (await hostCall("stat", path)) as { isDirectory: boolean; isFile: boolean }; + return { isDirectory: () => s.isDirectory, isFile: () => s.isFile }; + }, + realpath: (path) => hostCall("realpath", path) as any, +}; + +/** Context handed to the invoked sandboxed function. */ +export interface SandboxContext { + readonly host: SystemHost; + readonly payload: unknown; + /** Report a diagnostic back to the parent program. */ + reportDiagnostic(diagnostic: unknown): void; +} + +function handleHostResponse(message: SandboxHostResponse): void { + const entry = pending.get(message.id); + if (!entry) return; + pending.delete(message.id); + if (message.ok) { + entry.resolve(message.value); + } else { + entry.reject(message.error ? deserializeError(message.error) : new Error("Host request failed")); + } +} + +async function runJob(job: SandboxJob): Promise { + try { + const mod = await import(job.modulePath); + const fn = job.exportName ? mod[job.exportName] : (mod.default ?? mod); + if (typeof fn !== "function") { + throw new Error( + `Sandbox module '${job.modulePath}' does not export a callable '${job.exportName ?? "default"}'.`, + ); + } + const context: SandboxContext = { + host: brokeredHost, + payload: job.payload, + reportDiagnostic: (diagnostic) => send({ kind: "diagnostic", diagnostic }), + }; + const value = await fn(context); + send({ kind: "result", ok: true, value }); + } catch (error) { + send({ kind: "result", ok: false, error: serializeError(error) }); + } finally { + process.disconnect?.(); + } +} + +// Deny ambient network by default. This is a courtesy guard for well-behaved +// code; it is NOT a security boundary (Node's permission model does not cover +// network, so a determined module could still reach `node:net`). Hard network +// isolation requires OS-level sandboxing. +const denyNetwork = () => { + throw Object.assign(new Error("Network access is not granted to this sandboxed emitter/library."), { + code: "ERR_ACCESS_DENIED", + }); +}; +(globalThis as any).fetch = denyNetwork; + +process.on("message", (message: SandboxParentMessage) => { + if (message.kind === "host-response") { + handleHostResponse(message); + } else if (message.kind === "job") { + void runJob(message); + } +}); diff --git a/packages/compiler/src/core/permissions/sandbox/emit-job.ts b/packages/compiler/src/core/permissions/sandbox/emit-job.ts new file mode 100644 index 00000000000..331ace9b652 --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/emit-job.ts @@ -0,0 +1,97 @@ +import { realpath } from "fs"; +import type { CompilerOptions } from "../../options.js"; +import type { CompilerHost } from "../../types.js"; +import type { PermissionSet } from "../types.js"; +import type { SandboxEmitResult } from "./emit-protocol.js"; + +/** + * Payload handed to the sandboxed emit job by the parent ({@link runEmitterSandboxed}). + */ +export interface SandboxEmitPayload { + readonly mainFile: string; + readonly options: CompilerOptions; + readonly emitterNameOrPath: string; + /** + * Effective permissions the emitter's own host is constrained to while + * `$onEmit` runs. Tighter than the child's OS-level grants (which must also + * allow the broader access the local recompile needs): the emitter may only + * touch the file system / network through the host within these scopes. + */ + readonly emitterPermissions: PermissionSet; +} + +interface SandboxContext { + readonly payload: unknown; +} + +/** + * Entry point executed inside the sandboxed child process. It re-runs + * compilation locally (using a real {@link NodeHost}, whose file-system access + * is already constrained by the Node permission flags the parent applied) and + * invokes a single emitter, returning that emitter's diagnostics and emitted + * files back to the parent. + * + * The compiler is imported lazily so the permission flags are fully in effect + * before any of its module-load side effects run. + */ +export default async function runSandboxedEmit( + context: SandboxContext, +): Promise { + const payload = context.payload as SandboxEmitPayload; + const { NodeHost } = await import("../../node-host.js"); + const { runEmitterRecompiled } = await import("../../program.js"); + return runEmitterRecompiled( + sandboxHost(NodeHost), + payload.mainFile, + payload.options, + payload.emitterNameOrPath, + payload.emitterPermissions, + ); +} + +/** + * Adapt a {@link CompilerHost} for use inside the sandbox. + * + * Two adjustments are needed so the child can re-run compilation under Node's + * permission model: + * + * 1. `NodeHost.realpath` uses the async *callback* `fs.realpath` (a JS + * implementation that `lstat`s every ancestor path segment) as a workaround + * for a bug in the promise-based variant. Under the permission model that + * LOOP is denied because intermediate ancestors of a granted scope are not + * themselves granted. The *native* realpath resolves in a single syscall the + * permission model allows, so we route the child's `realpath` through it. + * + * 2. Module/library resolution probes many candidate paths by walking up the + * directory tree. Probes that land outside the granted read scopes are + * rejected by the OS with `ERR_ACCESS_DENIED`. The resolver only treats + * `ENOENT`/`ENOTDIR` as "keep looking", so we translate access-denied on + * probe operations into `ENOENT`: an inaccessible path is, for resolution + * purposes, simply not there. Actual reads of granted files are unaffected. + */ +function sandboxHost(host: CompilerHost): CompilerHost { + return { + ...host, + realpath: (path: string) => + new Promise((resolvePromise, rejectPromise) => + realpath.native(path, (error, resolved) => + error ? rejectPromise(error) : resolvePromise(resolved), + ), + ), + stat: (path: string) => host.stat(path).catch(notFoundIfDenied), + readDir: (path: string) => host.readDir(path).catch(notFoundIfDenied), + }; +} + +/** + * Re-map an `ERR_ACCESS_DENIED` rejection to `ENOENT` so directory-walking + * resolution treats an out-of-scope path as absent rather than failing hard. + */ +function notFoundIfDenied(error: any): never { + if (error?.code === "ERR_ACCESS_DENIED") { + const notFound: NodeJS.ErrnoException = new Error(error.message); + notFound.code = "ENOENT"; + throw notFound; + } + throw error; +} diff --git a/packages/compiler/src/core/permissions/sandbox/emit-protocol.ts b/packages/compiler/src/core/permissions/sandbox/emit-protocol.ts new file mode 100644 index 00000000000..49fa21de2a4 --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/emit-protocol.ts @@ -0,0 +1,74 @@ +import { getSourceLocation } from "../../diagnostics.js"; +import type { Program } from "../../program.js"; +import type { Diagnostic, DiagnosticSeverity, DiagnosticTarget } from "../../types.js"; +import { NoTarget } from "../../types.js"; + +/** + * A {@link Diagnostic} flattened into a structured-clone-safe shape so it can be + * transferred from a sandboxed emitter child back to the parent process. + * + * Live `Type`/`Node` targets cannot cross the process boundary, so the source + * location is reduced to `{ file, pos, end }`. The parent re-targets it against + * its own (identical) source files via {@link deserializeDiagnostic}, preserving + * full-fidelity location reporting without serializing the type graph. + */ +export interface SerializedDiagnostic { + readonly code: string; + readonly severity: DiagnosticSeverity; + readonly message: string; + readonly url?: string; + readonly location?: { + readonly file: string; + readonly pos: number; + readonly end: number; + }; +} + +/** The value a sandboxed emit job resolves with. */ +export interface SandboxEmitResult { + readonly diagnostics: readonly SerializedDiagnostic[]; + readonly emittedFiles: readonly string[]; +} + +/** Flatten a diagnostic into a transferable shape, resolving its source location. */ +export function serializeDiagnostic(diagnostic: Diagnostic): SerializedDiagnostic { + const location = getSourceLocation(diagnostic.target, { locateId: true }); + return { + code: diagnostic.code, + severity: diagnostic.severity, + message: diagnostic.message, + url: diagnostic.url, + location: + location && !location.isSynthetic + ? { file: location.file.path, pos: location.pos, end: location.end } + : undefined, + }; +} + +/** + * Reconstruct a {@link Diagnostic} from its serialized form, re-targeting the + * source location against `program`'s source files so the parent can report it + * with the correct file/line/column. Falls back to {@link NoTarget} when the + * file is not part of the parent program. + */ +export function deserializeDiagnostic( + serialized: SerializedDiagnostic, + program: Program, +): Diagnostic { + let target: DiagnosticTarget | typeof NoTarget = NoTarget; + if (serialized.location) { + const file = + program.sourceFiles.get(serialized.location.file)?.file ?? + program.jsSourceFiles.get(serialized.location.file)?.file; + if (file) { + target = { file, pos: serialized.location.pos, end: serialized.location.end }; + } + } + return { + code: serialized.code, + severity: serialized.severity, + message: serialized.message, + url: serialized.url, + target, + }; +} diff --git a/packages/compiler/src/core/permissions/sandbox/emit-runner.browser.ts b/packages/compiler/src/core/permissions/sandbox/emit-runner.browser.ts new file mode 100644 index 00000000000..bc464a1331f --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/emit-runner.browser.ts @@ -0,0 +1,6 @@ +// Browser stub for the per-emitter sandbox runner. Sandboxed emitter execution +// requires Node process isolation and is never used in the browser. + +export function runEmitterSandboxed(): Promise { + return Promise.reject(new Error("Sandboxed execution is not supported in the browser.")); +} diff --git a/packages/compiler/src/core/permissions/sandbox/emit-runner.ts b/packages/compiler/src/core/permissions/sandbox/emit-runner.ts new file mode 100644 index 00000000000..17c0e3f7dea --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/emit-runner.ts @@ -0,0 +1,126 @@ +import { existsSync } from "fs"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import type { CompilerOptions } from "../../options.js"; +import type { SystemHost } from "../../types.js"; +import type { PermissionSet } from "../types.js"; +import type { SandboxEmitPayload } from "./emit-job.js"; +import type { SandboxEmitResult } from "./emit-protocol.js"; +import { resolveRealpath, runInSandbox } from "./runtime.js"; + +const emitJobPath = resolve(dirname(fileURLToPath(import.meta.url)), "emit-job.js"); + +export interface RunEmitterSandboxedOptions { + /** Spec entry point the child recompiles. */ + readonly mainFile: string; + /** Compiler options to recompile with (the child restricts `emit` to one emitter). */ + readonly options: CompilerOptions; + /** Name or path of the single emitter to run in the child. */ + readonly emitterNameOrPath: string; + /** The effective permission set the user granted this emitter. */ + readonly permissions: PermissionSet; + /** Privileged host used by the broker to service the child's requests. */ + readonly host: SystemHost; + /** Project root; the child needs read access to it (specs + node_modules) to recompile. */ + readonly projectRoot: string; +} + +/** + * Run a single emitter inside an OS-isolated child process. The child re-runs + * compilation locally to rebuild the live `Program`, then invokes only this + * emitter's `$onEmit`, returning its diagnostics and emitted files. + * + * File-system, child-process and (best-effort) network/env access in the child + * are constrained to `permissions`. + */ +export async function runEmitterSandboxed( + opts: RunEmitterSandboxedOptions, +): Promise { + // The emitter may always read its own package directory (its static assets / + // templates), the way the output dir is always writable. This is granted both + // at the OS layer (read scope below) and at the host layer (the emitter + // permission set the child wraps its host with). + // + // Scopes are realpath-resolved so the host wrapper compares the same canonical + // paths the child actually touches: the permission model resolves symlinks + // (e.g. macOS `/var`→`/private/var`) and the emitter's output dir is reached + // by its real path. + const emitterPackageDir = findPackageDir(opts.emitterNameOrPath); + const emitterPermissions: PermissionSet = { + ...opts.permissions, + fsRead: resolveScopes([ + ...opts.permissions.fsRead, + ...(emitterPackageDir ? [emitterPackageDir] : []), + ]), + fsWrite: resolveScopes(opts.permissions.fsWrite), + }; + + const payload: SandboxEmitPayload = { + mainFile: opts.mainFile, + options: stripNonClonable(opts.options), + emitterNameOrPath: opts.emitterNameOrPath, + emitterPermissions, + }; + + const essentialReadScopes = [opts.projectRoot, ...nodeModulesAncestors(opts.projectRoot)]; + if (emitterPackageDir) { + essentialReadScopes.push(emitterPackageDir); + } + + const result = await runInSandbox({ + modulePath: emitJobPath, + payload, + permissions: opts.permissions, + host: opts.host, + essentialReadScopes, + }); + + return result as SandboxEmitResult; +} + +/** Realpath-resolve each scope, tolerating not-yet-existing paths, de-duped. */ +function resolveScopes(scopes: readonly string[]): string[] { + return [...new Set(scopes.map(resolveRealpath))]; +} + +/** + * Nearest ancestor directory of `entryPath` containing a `package.json` — i.e. + * the package the emitter entry point lives in. Returns `undefined` if none is + * found (e.g. a bare script outside any package). + */ +function findPackageDir(entryPath: string): string | undefined { + let current = dirname(resolve(entryPath)); + for (;;) { + if (existsSync(resolve(current, "package.json"))) { + return current; + } + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +/** `node_modules` directories from `start` up to the filesystem root. */ +function nodeModulesAncestors(start: string): string[] { + const roots: string[] = []; + let current = resolve(start); + for (;;) { + const candidate = resolve(current, "node_modules"); + if (existsSync(candidate)) { + roots.push(candidate); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return roots; +} + +/** + * Produce a structured-clone-safe copy of the compiler options for IPC. The + * options object is plain data, but defensively drop anything that cannot cross + * the process boundary. + */ +function stripNonClonable(options: CompilerOptions): CompilerOptions { + return JSON.parse(JSON.stringify(options)); +} diff --git a/packages/compiler/src/core/permissions/sandbox/node-args.ts b/packages/compiler/src/core/permissions/sandbox/node-args.ts new file mode 100644 index 00000000000..5de44f51eec --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/node-args.ts @@ -0,0 +1,94 @@ +import type { PermissionSet } from "../types.js"; + +export interface NodeSandboxArgsOptions { + /** + * Read scopes the sandboxed process always needs regardless of the granted + * permissions, e.g. the bootstrap script, the emitter module + its + * `node_modules`, and the compiler installation. These are unioned with the + * granted `fs-read` scopes. Should already be realpath-resolved. + */ + readonly essentialReadScopes?: readonly string[]; + /** + * Write scopes the sandboxed process always needs, e.g. a temp dir. Unioned + * with the granted `fs-write` scopes. Should already be realpath-resolved. + */ + readonly essentialWriteScopes?: readonly string[]; +} + +/** + * Translate a granted {@link PermissionSet} into Node CLI flags that enforce it + * via the (process-wide) permission model. The result is intended for the + * `execArgv` of a forked child process. + * + * Important caveats, mirroring what the permission model actually covers: + * - **fs-read / fs-write**: enforced. Paths MUST already be realpath-resolved by + * the caller (the model resolves real paths; e.g. `/tmp`→`/private/tmp`). + * - **exec**: `--allow-child-process` is all-or-nothing; a command allow-list is + * NOT OS-enforceable, so any non-empty/`true` exec grant enables child + * processes and command filtering must be done by the broker. + * - **network / env**: NOT covered by the permission model — enforced separately + * (broker-mediated network, curated `env`). They produce no flags here. + */ +export function permissionSetToNodeArgs( + permissions: PermissionSet, + options: NodeSandboxArgsOptions = {}, +): string[] { + const args = ["--permission"]; + + const readScopes = uniq([...(options.essentialReadScopes ?? []), ...permissions.fsRead]); + for (const scope of readScopes) { + args.push(`--allow-fs-read=${scope}`); + } + + const writeScopes = uniq([...(options.essentialWriteScopes ?? []), ...permissions.fsWrite]); + for (const scope of writeScopes) { + args.push(`--allow-fs-write=${scope}`); + } + + if (permissions.exec === true || (Array.isArray(permissions.exec) && permissions.exec.length)) { + args.push("--allow-child-process"); + } + + return args; +} + +/** + * Build the curated environment for the sandboxed process. Only the granted env + * variable names are forwarded from `parentEnv`, plus a minimal set of variables + * required for Node itself to run (e.g. `PATH`). Everything else is dropped so a + * sandboxed emitter cannot read secrets from the ambient environment. + */ +export function buildSandboxEnv( + permissions: PermissionSet, + parentEnv: NodeJS.ProcessEnv, + essentialNames: readonly string[] = ESSENTIAL_ENV_NAMES, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const name of essentialNames) { + if (parentEnv[name] !== undefined) { + env[name] = parentEnv[name]; + } + } + for (const name of permissions.env) { + if (parentEnv[name] !== undefined) { + env[name] = parentEnv[name]; + } + } + return env; +} + +/** Environment variables Node/the OS generally needs to function. */ +export const ESSENTIAL_ENV_NAMES: readonly string[] = [ + "PATH", + "HOME", + "TMPDIR", + "TEMP", + "TMP", + "SystemRoot", + "USERPROFILE", + "NODE_OPTIONS", +]; + +function uniq(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/compiler/src/core/permissions/sandbox/protocol.ts b/packages/compiler/src/core/permissions/sandbox/protocol.ts new file mode 100644 index 00000000000..a171c39f581 --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/protocol.ts @@ -0,0 +1,95 @@ +import type { RmOptions } from "../../types.js"; + +/** + * Messages exchanged between the privileged parent (broker) and the sandboxed + * child over the IPC channel. The child can only affect the outside world by + * asking the broker, which re-validates every request against the granted + * permissions before touching the real file system or network. + */ + +/** A host file-system / network operation the child asks the broker to perform. */ +export type SandboxHostMethod = + | "readUrl" + | "readFile" + | "writeFile" + | "readDir" + | "rm" + | "mkdirp" + | "stat" + | "realpath"; + +export interface SandboxHostRequest { + readonly kind: "host-request"; + readonly id: number; + readonly method: SandboxHostMethod; + readonly args: readonly unknown[]; +} + +export interface SandboxHostResponse { + readonly kind: "host-response"; + readonly id: number; + readonly ok: boolean; + readonly value?: unknown; + readonly error?: SandboxSerializedError; +} + +/** A diagnostic the child wants surfaced by the parent program. */ +export interface SandboxDiagnosticMessage { + readonly kind: "diagnostic"; + readonly diagnostic: unknown; +} + +/** Final outcome reported by the child once the sandboxed job completes. */ +export interface SandboxResultMessage { + readonly kind: "result"; + readonly ok: boolean; + readonly value?: unknown; + readonly error?: SandboxSerializedError; +} + +/** The job description sent from parent to child at startup. */ +export interface SandboxJob { + readonly kind: "job"; + /** Absolute path to the module the child should import and run. */ + readonly modulePath: string; + /** Name of the exported function to invoke (defaults to the module default). */ + readonly exportName?: string; + /** Arbitrary JSON-serializable payload passed to the invoked function. */ + readonly payload?: unknown; +} + +export type SandboxParentMessage = SandboxJob | SandboxHostResponse; +export type SandboxChildMessage = + | SandboxHostRequest + | SandboxDiagnosticMessage + | SandboxResultMessage; + +export interface SandboxSerializedError { + readonly name: string; + readonly message: string; + readonly stack?: string; + readonly code?: string; +} + +export function serializeError(error: unknown): SandboxSerializedError { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + code: (error as { code?: string }).code, + }; + } + return { name: "Error", message: String(error) }; +} + +export function deserializeError(error: SandboxSerializedError): Error { + const err = new Error(error.message); + err.name = error.name; + if (error.stack) err.stack = error.stack; + if (error.code) (err as { code?: string }).code = error.code; + return err; +} + +/** Re-export for the bootstrap's `rm` typing. */ +export type { RmOptions }; diff --git a/packages/compiler/src/core/permissions/sandbox/runtime.browser.ts b/packages/compiler/src/core/permissions/sandbox/runtime.browser.ts new file mode 100644 index 00000000000..4754610de89 --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/runtime.browser.ts @@ -0,0 +1,13 @@ +// Browser stub for the sandbox runtime. OS-level sandboxed execution relies on +// Node built-ins (`child_process`, `fs`) and is never used in the browser, so +// these are inert and throw if somehow invoked. + +const message = "Sandboxed execution is not supported in the browser."; + +export function runInSandbox(): Promise { + return Promise.reject(new Error(message)); +} + +export function resolveRealpath(path: string): string { + return path; +} diff --git a/packages/compiler/src/core/permissions/sandbox/runtime.ts b/packages/compiler/src/core/permissions/sandbox/runtime.ts new file mode 100644 index 00000000000..7ea11c07b41 --- /dev/null +++ b/packages/compiler/src/core/permissions/sandbox/runtime.ts @@ -0,0 +1,271 @@ +import { fork } from "child_process"; +import { existsSync, mkdirSync, realpathSync } from "fs"; +import { dirname, resolve as resolvePathNative } from "path"; +import { fileURLToPath } from "url"; +import type { SystemHost } from "../../types.js"; +import { createPermissionedSystemHost } from "../permissioned-host.js"; +import type { PermissionSet } from "../types.js"; +import { buildSandboxEnv, permissionSetToNodeArgs } from "./node-args.js"; +import { + deserializeError, + serializeError, + type SandboxChildMessage, + type SandboxHostMethod, + type SandboxJob, +} from "./protocol.js"; + +export interface RunInSandboxOptions { + /** Absolute path to the module the sandboxed child should import and run. */ + readonly modulePath: string; + /** Named export to invoke; defaults to the module's default export. */ + readonly exportName?: string; + /** JSON-serializable payload passed to the invoked function. */ + readonly payload?: unknown; + /** The effective permissions to enforce on the child (OS-level + broker). */ + readonly permissions: PermissionSet; + /** The privileged host used by the broker to service the child's requests. */ + readonly host: SystemHost; + /** Working directory for the child process. */ + readonly cwd?: string; + /** Read scopes the child always needs (bootstrap, modules, compiler). */ + readonly essentialReadScopes?: readonly string[]; + /** Write scopes the child always needs (e.g. temp dirs). */ + readonly essentialWriteScopes?: readonly string[]; + /** Called for each diagnostic the child reports. */ + readonly onDiagnostic?: (diagnostic: unknown) => void; + /** Parent environment to derive the curated child env from. Defaults to `process.env`. */ + readonly env?: NodeJS.ProcessEnv; +} + +const bootstrapPath = resolvePathNative(dirname(fileURLToPath(import.meta.url)), "bootstrap.js"); + +// The compiler package root (…/dist/src/core/permissions/sandbox → package root). +// Granting read here lets the child load the bootstrap and the rest of the +// compiler `dist`. Its `node_modules` (and any hoisted ones) are added +// separately via `nodeModulesAncestors`, since the child re-imports the +// compiler and therefore needs its dependencies. +const compilerPackageRoot = resolvePathNative(dirname(bootstrapPath), "..", "..", "..", "..", ".."); + +/** + * Every `node_modules` directory on the path from `start` up to the filesystem + * root. These are the module-resolution roots a child needs read access to in + * order to import bare specifiers (the compiler and its dependencies, including + * hoisted monorepo dependencies). + */ +function nodeModulesAncestors(start: string): string[] { + const roots: string[] = []; + let current = resolvePathNative(start); + for (;;) { + const candidate = resolvePathNative(current, "node_modules"); + if (existsSync(candidate)) { + roots.push(candidate); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return roots; +} + +/** + * Run a module export inside an OS-isolated child process whose file-system and + * child-process access is constrained to `permissions` via Node's permission + * model. The child can only reach the outside world through the broker, which + * re-validates each request against the same permissions. + * + * Resolves with the value the invoked function returned, or rejects if the child + * threw, exited abnormally, or attempted a denied operation. + */ +export function runInSandbox(options: RunInSandboxOptions): Promise { + const { permissions, host } = options; + const parentEnv = options.env ?? process.env; + + const essentialReadScopes = realpathScopes([ + compilerPackageRoot, + ...nodeModulesAncestors(compilerPackageRoot), + dirname(bootstrapPath), + dirname(options.modulePath), + options.modulePath, + ...(options.essentialReadScopes ?? []), + ]); + const essentialWriteScopes = realpathScopes(options.essentialWriteScopes ?? []); + const grantedReadRealpaths = realpathScopes(permissions.fsRead); + const grantedWriteRealpaths = realpathScopes(permissions.fsWrite); + + // Node resolves permission scopes at process startup: a write scope that does + // not yet exist cannot be matched against paths the child creates later (even + // the child's own `mkdir` of the scope leaves subsequent writes to its + // children denied). The privileged parent therefore pre-creates the writable + // directories so each scope resolves to a real, recursive directory before the + // child is forked. + for (const scope of [...essentialWriteScopes, ...grantedWriteRealpaths]) { + if (!existsSync(scope)) { + try { + mkdirSync(scope, { recursive: true }); + } catch { + // Best effort: if creation fails the child will surface the denial. + } + } + } + + // The child must import the canonical (realpath-resolved) module path so the + // permission model — which compares real paths — does not have to traverse + // intermediate symlinks (e.g. macOS `/var`→`/private/var`) that are not in + // the granted scopes. + const moduleRealpath = realpathScope(options.modulePath); + + const execArgv = permissionSetToNodeArgs( + { ...permissions, fsRead: grantedReadRealpaths, fsWrite: grantedWriteRealpaths }, + { essentialReadScopes, essentialWriteScopes }, + ); + + const broker = createPermissionedSystemHost(host, permissions); + + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const child = fork(bootstrapPath, [], { + execArgv, + cwd: options.cwd, + env: buildSandboxEnv(permissions, parentEnv), + stdio: ["inherit", "inherit", "inherit", "ipc"], + }); + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + fn(); + child.removeAllListeners(); + if (child.connected) child.disconnect(); + if (child.exitCode === null) child.kill(); + }; + + child.on("message", (message: SandboxChildMessage) => { + switch (message.kind) { + case "host-request": + void serviceHostRequest(broker, message.method, message.args).then( + (value) => child.send({ kind: "host-response", id: message.id, ok: true, value }), + (error) => + child.send({ + kind: "host-response", + id: message.id, + ok: false, + error: serializeError(error), + }), + ); + break; + case "diagnostic": + options.onDiagnostic?.(message.diagnostic); + break; + case "result": + if (message.ok) { + settle(() => resolvePromise(message.value)); + } else { + settle(() => + rejectPromise( + message.error ? deserializeError(message.error) : new Error("Sandbox job failed"), + ), + ); + } + break; + } + }); + + child.on("error", (error) => settle(() => rejectPromise(error))); + child.on("exit", (code, signal) => { + settle(() => + rejectPromise( + new Error(`Sandboxed process exited unexpectedly (code: ${code}, signal: ${signal})`), + ), + ); + }); + + const job: SandboxJob = { + kind: "job", + modulePath: moduleRealpath, + exportName: options.exportName, + payload: options.payload, + }; + child.send(job); + }); +} + +async function serviceHostRequest( + host: SystemHost, + method: SandboxHostMethod, + args: readonly unknown[], +): Promise { + switch (method) { + case "readUrl": + return host.readUrl(args[0] as string); + case "readFile": + return host.readFile(args[0] as string); + case "writeFile": + return host.writeFile(args[0] as string, args[1] as string); + case "readDir": + return host.readDir(args[0] as string); + case "rm": + return host.rm(args[0] as string, args[1] as any); + case "mkdirp": + return host.mkdirp(args[0] as string); + case "stat": { + const s = await host.stat(args[0] as string); + return { isDirectory: s.isDirectory(), isFile: s.isFile() }; + } + case "realpath": + return host.realpath(args[0] as string); + } +} + +/** + * Resolve each scope to its real path (the permission model compares real + * paths). For paths that do not yet exist (e.g. an output dir), resolve the + * nearest existing ancestor and re-append the remainder so the grant still + * covers the eventual location. + */ +function realpathScopes(scopes: readonly string[]): string[] { + return [...new Set(scopes.map(realpathScope))]; +} + +/** + * Resolve a single path to its real path, tolerating paths that do not yet + * exist (resolves the nearest existing ancestor and re-appends the remainder). + * + * Paths handed to a sandboxed child — the spec entry point, the emitter module, + * output directories — must be realpath-resolved by the (unrestricted) parent, + * because the permission model compares real paths and the child cannot itself + * traverse symlinks (e.g. macOS `/var`→`/private/var`) that fall outside its + * granted scopes. + */ +export function resolveRealpath(path: string): string { + return realpathScope(path); +} + +function realpathScope(scope: string): string { + const abs = resolvePathNative(scope); + if (existsSync(abs)) { + try { + return realpathSync(abs); + } catch { + return abs; + } + } + let current = abs; + let suffix = ""; + while (current !== dirname(current)) { + const parent = dirname(current); + suffix = suffix ? `${basename(current)}/${suffix}` : basename(current); + if (existsSync(parent)) { + try { + return `${realpathSync(parent)}/${suffix}`; + } catch { + return abs; + } + } + current = parent; + } + return abs; +} + +function basename(p: string): string { + return p.slice(dirname(p).length + 1); +} diff --git a/packages/compiler/src/core/permissions/types.ts b/packages/compiler/src/core/permissions/types.ts new file mode 100644 index 00000000000..061613d7935 --- /dev/null +++ b/packages/compiler/src/core/permissions/types.ts @@ -0,0 +1,98 @@ +/** + * Permission model for sandboxed execution of TypeSpec libraries and emitters. + * + * These types describe *what* a library/emitter may request and what a user may + * grant. They are intentionally platform agnostic and contain no Node.js + * specifics so they can be unit tested and reused across hosts. Translation of a + * granted {@link PermissionSet} into concrete enforcement (Node permission + * flags, env scrubbing, network brokering) lives in the sandbox runtime. + */ + +/** The categories of system capability that can be requested/granted. */ +export type PermissionKind = "fs-read" | "fs-write" | "network" | "env" | "exec"; + +/** Read files within the given path scopes. */ +export interface FsReadPermission { + readonly kind: "fs-read"; + /** Absolute (or config relative) directory/file scopes the code may read. */ + readonly paths: readonly string[]; +} + +/** Write files within the given path scopes. Defaults to the emitter output dir. */ +export interface FsWritePermission { + readonly kind: "fs-write"; + /** Absolute (or config relative) directory/file scopes the code may write. */ + readonly paths: readonly string[]; +} + +/** + * Make network requests to the given hosts. A host may be an exact host name, + * a wildcard pattern such as `*.example.com`, or `*` to allow any host. + */ +export interface NetworkPermission { + readonly kind: "network"; + readonly hosts: readonly string[]; +} + +/** Read the given environment variable names. */ +export interface EnvPermission { + readonly kind: "env"; + readonly names: readonly string[]; +} + +/** + * Spawn child processes. When {@link ExecPermission.commands} is omitted any + * command may be spawned; otherwise only the listed commands are permitted. + */ +export interface ExecPermission { + readonly kind: "exec"; + readonly commands?: readonly string[]; +} + +/** A single granular permission a library/emitter can request or be granted. */ +export type Permission = + | FsReadPermission + | FsWritePermission + | NetworkPermission + | EnvPermission + | ExecPermission; + +/** + * A permission a library/emitter declares in its manifest, paired with a + * human-readable reason shown to the user when they review/approve it. + */ +export interface PermissionRequest { + readonly permission: Permission; + /** Why the library/emitter needs this permission. Shown to users. */ + readonly reason?: string; +} + +/** + * The normalized, aggregated view of a set of permissions. This is the canonical + * form used to compute grants (intersection of requested and approved) and to + * detect missing permissions for diagnostics. + */ +export interface PermissionSet { + /** Path scopes readable from the file system. */ + readonly fsRead: readonly string[]; + /** Path scopes writable on the file system. */ + readonly fsWrite: readonly string[]; + /** Network host patterns that may be contacted. */ + readonly network: readonly string[]; + /** Environment variable names that may be read. */ + readonly env: readonly string[]; + /** + * Child-process capability. `false` = no exec; `true` = any command; + * `string[]` = only the listed commands. + */ + readonly exec: boolean | readonly string[]; +} + +/** A {@link PermissionSet} that grants nothing. The secure default. */ +export const EMPTY_PERMISSION_SET: PermissionSet = Object.freeze({ + fsRead: Object.freeze([]), + fsWrite: Object.freeze([]), + network: Object.freeze([]), + env: Object.freeze([]), + exec: false, +}); diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index cf03ca50562..cbb5e3e1738 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -32,6 +32,15 @@ import { CompilerOptions } from "./options.js"; import { parse, parseStandaloneTypeReference } from "./parser.js"; import { getDirectoryPath, joinPaths, resolvePath } from "./path-utils.js"; import { createPerfReporter, perf } from "./perf.js"; +import { formatPermission } from "./permissions/permission-set.js"; +import { createPermissionedHost } from "./permissions/permissioned-host.js"; +import { formatGrantSuggestion, resolvePermissions } from "./permissions/resolve.js"; +import { + deserializeDiagnostic, + serializeDiagnostic, + type SandboxEmitResult, +} from "./permissions/sandbox/emit-protocol.js"; +import type { PermissionSet } from "./permissions/types.js"; import { SourceLoader, SourceResolution, @@ -196,10 +205,20 @@ export async function compile( if (program.compilerOptions.dryRun && !emitter.library.definition?.capabilities?.dryRun) { continue; } - const emitterStats = await emit(emitter, program); - emitStats.emitters[emitter.metadata.name ?? ""] = emitterStats; - if (options.listFiles) { - logEmittedFilesPath(program); + if (options.sandbox) { + const emitterStats = await emitSandboxed(emitter, program, host, mainFile, options); + if (emitterStats) { + emitStats.emitters[emitter.metadata.name ?? ""] = emitterStats; + } + if (options.listFiles) { + logEmittedFilesPath(program); + } + } else { + const emitterStats = await emit(emitter, program); + emitStats.emitters[emitter.metadata.name ?? ""] = emitterStats; + if (options.listFiles) { + logEmittedFilesPath(program); + } } } emitStats.total = timer.end(); @@ -618,13 +637,17 @@ async function createProgram( return { type: "file", name: libDefinition?.name, + permissions: libDefinition?.permissions, }; } - return computeModuleMetadata(module); + return computeModuleMetadata(module, libDefinition); } - function computeModuleMetadata(module: ResolvedModule): ModuleLibraryMetadata { + function computeModuleMetadata( + module: ResolvedModule, + libDefinition: TypeSpecLibrary | undefined, + ): ModuleLibraryMetadata { const metadata: ModuleLibraryMetadata = { type: "module", name: module.manifest.name, @@ -639,6 +662,9 @@ async function createProgram( if (module.manifest.version) { metadata.version = module.manifest.version; } + if (libDefinition?.permissions) { + metadata.permissions = libDefinition.permissions; + } return metadata; } @@ -958,6 +984,127 @@ async function emit( }); } +/** + * Run an emitter inside an OS-isolated sandbox process (see `runEmitterSandboxed`). + * + * Resolves the emitter's declared permissions against the user's `tspconfig.yaml` + * grant; if anything was requested but not granted it reports a + * `permission-not-granted` diagnostic and skips the emitter. Otherwise it spawns + * a child constrained to the effective permissions, which recompiles and runs + * only this emitter, and merges the child's emit-phase diagnostics and emitted + * files back into `program`. + */ +async function emitSandboxed( + emitter: EmitterRef, + program: Program, + host: CompilerHost, + mainFile: string, + options: CompilerOptions, +): Promise<{ total: number; steps: Record } | undefined> { + const emitterName = emitter.metadata.name ?? emitter.main; + // The identifier the child uses to *resolve* the emitter. The resolved entry + // path is always loadable regardless of how it was originally referenced. + const emitId = emitter.main; + const baseDir = options.configFile?.projectRoot ?? getDirectoryPath(mainFile); + + const grant = options.configFile?.permissions?.[emitterName]; + const resolution = resolvePermissions(emitter.metadata.permissions, grant, { + baseDir, + outputDir: emitter.emitterOutputDir, + }); + + if (resolution.missing.length > 0) { + program.reportDiagnostic( + createDiagnostic({ + code: "permission-not-granted", + format: { + emitterName, + permissions: resolution.missing.map(formatPermission).join(", "), + suggestion: formatGrantSuggestion(emitterName, resolution.missing), + }, + target: NoTarget, + }), + ); + return undefined; + } + + // The sandbox runtime depends on Node built-ins (`child_process`, `fs`), so it + // is imported dynamically: this keeps it out of the browser bundle's static + // graph, since sandboxed execution only ever runs on the Node CLI. + const { runEmitterSandboxed } = await import("./permissions/sandbox/emit-runner.js"); + const { resolveRealpath } = await import("./permissions/sandbox/runtime.js"); + + // All paths handed to the child must be realpath-resolved by the parent: the + // permission model compares real paths and the child cannot traverse symlinks + // (e.g. /var→/private/var) outside its granted scopes. + const mainFileReal = resolveRealpath(mainFile); + const emitIdReal = resolveRealpath(emitId); + const outputDirReal = resolveRealpath(emitter.emitterOutputDir); + const baseDirReal = resolveRealpath(baseDir); + + // Restrict the child to just this emitter and pin its output directory so it + // emits to the same place the parent expects. Options are keyed by the + // emitter name (matching how the child's loader looks them up). + const childOptions: CompilerOptions = { + ...options, + emit: [emitIdReal], + outputDir: options.outputDir ? resolveRealpath(options.outputDir) : undefined, + options: { + ...options.options, + [emitterName]: { + ...emitter.options, + "emitter-output-dir": outputDirReal, + }, + }, + configFile: options.configFile + ? { ...options.configFile, projectRoot: baseDirReal } + : undefined, + sandbox: false, + }; + + const relativePathForEmittedFiles = + transformPathForSink(program.host.logSink, emitter.emitterOutputDir) + "/"; + const errorCount = program.diagnostics.filter((x) => x.severity === "error").length; + const warnCount = program.diagnostics.filter((x) => x.severity === "warning").length; + const logger = createLogger({ sink: program.host.logSink }); + + return await logger.trackAction(`Running ${emitterName}...`, "", async (task) => { + const start = perf.startTimer(); + let result: SandboxEmitResult; + try { + result = await runEmitterSandboxed({ + mainFile: mainFileReal, + options: childOptions, + emitterNameOrPath: emitIdReal, + permissions: resolution.effective, + host, + projectRoot: baseDirReal, + }); + } catch (error: unknown) { + throw new ExternalError({ kind: "emitter", metadata: emitter.metadata, error }); + } + const duration = start.end(); + + program.reportDiagnostics(result.diagnostics.map((d) => deserializeDiagnostic(d, program))); + const emitted = getEmittedFilesForProgram(program); + for (const file of result.emittedFiles) { + emitted.push(file); + } + + const message = `${emitterName} ${pc.green(`${Math.round(duration)}ms`)} ${pc.dim(relativePathForEmittedFiles)}`; + const newErrorCount = program.diagnostics.filter((x) => x.severity === "error").length; + const newWarnCount = program.diagnostics.filter((x) => x.severity === "warning").length; + if (newErrorCount > errorCount) { + task.fail(message); + } else if (newWarnCount > warnCount) { + task.warn(message); + } else { + task.succeed(message); + } + return { total: duration, steps: {} }; + }); +} + /** * @param emitter Emitter ref to run */ @@ -977,6 +1124,70 @@ async function runEmitter(emitter: EmitterRef, program: Program): Promise { + // Build the program for just this emitter; never recurse into the sandbox. + const { program } = await createProgram(host, mainFile, { + ...options, + emit: [emitterNameOrPath], + sandbox: false, + }); + + // The parent already validated and loaded this emitter, so a load failure here + // means the sandbox could not see something it needs (e.g. a missing read + // scope). Surface it rather than silently emitting nothing. + if (program.emitters.length === 0) { + const errors = program.diagnostics.filter((d) => d.severity === "error"); + const detail = errors.map((d) => d.message).join("; ") || "no emitter was loaded"; + throw new Error(`Failed to load emitter '${emitterNameOrPath}' inside the sandbox: ${detail}`); + } + + // The recompile above needs broad file-system access (module/spec/compiler + // resolution); the emitter itself does not. From here on the emitter only ever + // touches the system through `program.host`, so we narrow that host to the + // emitter's effective permissions. This makes the host the single enforced + // choke point: even if the OS-level grants are broader (to let the recompile + // succeed), an emitter cannot read/write/fetch outside what it was granted. + program.host = createPermissionedHost(program.host, emitterPermissions); + + const beforeCount = program.diagnostics.length; + for (const emitter of program.emitters) { + if (options.dryRun && !emitter.library.definition?.capabilities?.dryRun) { + continue; + } + await runEmitter(emitter, program); + } + + return { + diagnostics: program.diagnostics.slice(beforeCount).map(serializeDiagnostic), + emittedFiles: [...getEmittedFilesForProgram(program)], + }; +} + function logEmittedFilesPath(program: Program) { getEmittedFilesForProgram(program).forEach((filePath) => { // eslint-disable-next-line no-console diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 50c36efbf4b..dfe37d889b0 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2,6 +2,7 @@ import type { JSONSchemaType as AjvJSONSchemaType } from "ajv"; import type { ModuleResolutionResult } from "../module-resolver/index.js"; import type { YamlPathTarget, YamlScript } from "../yaml/types.js"; import type { Numeric } from "./numeric.js"; +import type { PermissionRequest } from "./permissions/types.js"; import type { Program } from "./program.js"; import type { TokenFlags } from "./scanner.js"; @@ -2116,6 +2117,12 @@ interface LibraryMetadataBase { /** Url where to file bugs for this library. */ url?: string; }; + + /** + * Permissions the library/emitter requested in its manifest (`$lib.permissions`). + * Used to compute the effective sandbox grant against the user's config. + */ + permissions?: readonly PermissionRequest[]; } export interface FileLibraryMetadata extends LibraryMetadataBase { @@ -2484,6 +2491,15 @@ export interface TypeSpecLibraryDef< /** Optional registration of capabilities the library/emitter provides */ readonly capabilities?: TypeSpecLibraryCapabilities; + /** + * Permissions this library/emitter requests in order to run inside the + * sandbox. Each entry pairs a granular permission with a human-readable + * reason that is shown to the user when they review/approve it in their + * `tspconfig.yaml`. When omitted the library/emitter runs with no access to + * any system API (the secure default). + */ + readonly permissions?: readonly PermissionRequest[]; + /** * Map of potential diagnostics that can be emitted in this library where the key is the diagnostic code. */ diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0c6a4e03663..e3a1b366a39 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -110,6 +110,29 @@ export { NodeHost } from "./core/node-host.js"; export { isNumeric, Numeric } from "./core/numeric.js"; export type { CompilerOptions } from "./core/options.js"; export { getPositionBeforeTrivia } from "./core/parser-utils.js"; +export { + createPermissionSet, + EMPTY_PERMISSION_SET, + findMissingPermissions, + formatPermission, + intersectPermissionSets, + isEmptyPermissionSet, + isHostWithinScopes, + isPathWithinScopes, + mergePermissionSets, + PERMISSION_KINDS, +} from "./core/permissions/permission-set.js"; +export type { + EnvPermission, + ExecPermission, + FsReadPermission, + FsWritePermission, + NetworkPermission, + Permission, + PermissionKind, + PermissionRequest, + PermissionSet, +} from "./core/permissions/types.js"; export { $defaultVisibility, $discriminator, diff --git a/packages/compiler/test/config/config.test.ts b/packages/compiler/test/config/config.test.ts index 5f0a13e7f9f..481329b0826 100644 --- a/packages/compiler/test/config/config.test.ts +++ b/packages/compiler/test/config/config.test.ts @@ -253,6 +253,40 @@ describe("compiler: config file loading", () => { strictEqual(diagnostics.length, 1); strictEqual(diagnostics[0].code, "invalid-schema"); }); + + it("succeeds with a valid permissions grant", () => { + deepStrictEqual( + validate({ + permissions: { + "@typespec/openapi3": { + "fs-read": ["./schemas"], + "fs-write": ["./extra"], + network: ["*.example.com"], + env: ["MY_TOKEN"], + exec: ["git"], + }, + "@typespec/other": { exec: true }, + }, + }), + [], + ); + }); + + it("fails with an unknown permission key", () => { + const diagnostics = validate({ + permissions: { "@typespec/openapi3": { "fs-delete": ["./x"] } }, + } as any); + strictEqual(diagnostics.length, 1); + strictEqual(diagnostics[0].code, "invalid-schema"); + }); + + it("fails when a permission scope is not a string array", () => { + const diagnostics = validate({ + permissions: { "@typespec/openapi3": { "fs-read": "everything" } }, + } as any); + strictEqual(diagnostics.length, 1); + strictEqual(diagnostics[0].code, "invalid-schema"); + }); }); describe("project config validation", () => { diff --git a/packages/compiler/test/permissions/permission-set.test.ts b/packages/compiler/test/permissions/permission-set.test.ts new file mode 100644 index 00000000000..88f77aa2bed --- /dev/null +++ b/packages/compiler/test/permissions/permission-set.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import type { Permission } from "../../src/core/permissions/index.js"; +import { + createPermissionSet, + EMPTY_PERMISSION_SET, + findMissingPermissions, + formatPermission, + intersectPermissionSets, + isEmptyPermissionSet, + isHostWithinScopes, + isPathWithinScopes, + mergePermissionSets, +} from "../../src/core/permissions/index.js"; + +describe("createPermissionSet", () => { + it("aggregates granular permissions into a normalized set", () => { + const set = createPermissionSet([ + { kind: "fs-read", paths: ["/a"] }, + { kind: "fs-read", paths: ["/b"] }, + { kind: "fs-write", paths: ["/out"] }, + { kind: "network", hosts: ["example.com"] }, + { kind: "env", names: ["TOKEN"] }, + { kind: "exec", commands: ["git"] }, + ]); + expect(set.fsRead).toEqual(["/a", "/b"]); + expect(set.fsWrite).toEqual(["/out"]); + expect(set.network).toEqual(["example.com"]); + expect(set.env).toEqual(["TOKEN"]); + expect(set.exec).toEqual(["git"]); + }); + + it("treats exec without commands as allowing any command", () => { + const set = createPermissionSet([{ kind: "exec", commands: ["git"] }, { kind: "exec" }]); + expect(set.exec).toBe(true); + }); + + it("an empty list produces an empty set", () => { + expect(isEmptyPermissionSet(createPermissionSet([]))).toBe(true); + expect(isEmptyPermissionSet(EMPTY_PERMISSION_SET)).toBe(true); + }); +}); + +describe("isPathWithinScopes", () => { + it("grants a directory and everything beneath it", () => { + expect(isPathWithinScopes("/project/out/file.ts", ["/project/out"])).toBe(true); + expect(isPathWithinScopes("/project/out", ["/project/out"])).toBe(true); + }); + + it("denies paths outside the scope", () => { + expect(isPathWithinScopes("/etc/hosts", ["/project/out"])).toBe(false); + expect(isPathWithinScopes("/project/other", ["/project/out"])).toBe(false); + }); + + it("normalizes '..' segments to prevent escape", () => { + expect(isPathWithinScopes("/project/out/../../etc/hosts", ["/project/out"])).toBe(false); + }); + + it("denies everything when there are no scopes", () => { + expect(isPathWithinScopes("/anything", [])).toBe(false); + }); +}); + +describe("isHostWithinScopes", () => { + it("matches exact host names case-insensitively", () => { + expect(isHostWithinScopes("Example.com", ["example.com"])).toBe(true); + expect(isHostWithinScopes("other.com", ["example.com"])).toBe(false); + }); + + it("supports the '*' wildcard for any host", () => { + expect(isHostWithinScopes("anything.dev", ["*"])).toBe(true); + }); + + it("supports sub-domain wildcards including the apex", () => { + expect(isHostWithinScopes("api.example.com", ["*.example.com"])).toBe(true); + expect(isHostWithinScopes("example.com", ["*.example.com"])).toBe(true); + expect(isHostWithinScopes("example.org", ["*.example.com"])).toBe(false); + }); +}); + +describe("mergePermissionSets", () => { + it("unions scopes and widens exec", () => { + const a = createPermissionSet([ + { kind: "fs-read", paths: ["/a"] }, + { kind: "exec", commands: ["git"] }, + ]); + const b = createPermissionSet([{ kind: "fs-read", paths: ["/b"] }, { kind: "exec" }]); + const merged = mergePermissionSets(a, b); + expect([...merged.fsRead].sort()).toEqual(["/a", "/b"]); + expect(merged.exec).toBe(true); + }); +}); + +describe("intersectPermissionSets", () => { + it("only keeps requested permissions that are granted", () => { + const requested = createPermissionSet([ + { kind: "fs-read", paths: ["/project/src", "/etc"] }, + { kind: "fs-write", paths: ["/project/out"] }, + { kind: "network", hosts: ["api.example.com"] }, + { kind: "env", names: ["TOKEN", "SECRET"] }, + { kind: "exec", commands: ["git", "rm"] }, + ]); + const granted = createPermissionSet([ + { kind: "fs-read", paths: ["/project"] }, + { kind: "fs-write", paths: ["/project/out"] }, + { kind: "network", hosts: ["*.example.com"] }, + { kind: "env", names: ["TOKEN"] }, + { kind: "exec", commands: ["git"] }, + ]); + const effective = intersectPermissionSets(requested, granted); + expect(effective.fsRead).toEqual(["/project/src"]); + expect(effective.fsWrite).toEqual(["/project/out"]); + expect(effective.network).toEqual(["api.example.com"]); + expect(effective.env).toEqual(["TOKEN"]); + expect(effective.exec).toEqual(["git"]); + }); + + it("grants nothing when nothing is approved", () => { + const requested = createPermissionSet([{ kind: "fs-read", paths: ["/a"] }]); + const effective = intersectPermissionSets(requested, EMPTY_PERMISSION_SET); + expect(isEmptyPermissionSet(effective)).toBe(true); + }); +}); + +describe("findMissingPermissions", () => { + it("reports exactly the requested permissions that were not granted", () => { + const requested = createPermissionSet([ + { kind: "fs-read", paths: ["/project/src", "/etc"] }, + { kind: "network", hosts: ["api.example.com"] }, + { kind: "env", names: ["SECRET"] }, + { kind: "exec" }, + ]); + const granted = createPermissionSet([ + { kind: "fs-read", paths: ["/project"] }, + { kind: "network", hosts: ["api.example.com"] }, + ]); + const missing = findMissingPermissions(requested, granted); + expect(missing).toEqual([ + { kind: "fs-read", paths: ["/etc"] }, + { kind: "env", names: ["SECRET"] }, + { kind: "exec" }, + ]); + }); + + it("returns nothing when everything is granted", () => { + const requested = createPermissionSet([{ kind: "fs-write", paths: ["/out/a"] }]); + const granted = createPermissionSet([{ kind: "fs-write", paths: ["/out"] }]); + expect(findMissingPermissions(requested, granted)).toEqual([]); + }); +}); + +describe("formatPermission", () => { + it("renders each kind for diagnostics", () => { + expect(formatPermission({ kind: "fs-read", paths: ["/a", "/b"] })).toBe("fs-read(/a, /b)"); + expect(formatPermission({ kind: "exec" })).toBe("exec"); + expect(formatPermission({ kind: "exec", commands: ["git"] })).toBe("exec(git)"); + expect(formatPermission({ kind: "network", hosts: ["*"] })).toBe("network(*)"); + }); +}); diff --git a/packages/compiler/test/permissions/permissioned-host.test.ts b/packages/compiler/test/permissions/permissioned-host.test.ts new file mode 100644 index 00000000000..c4ddcc2f7b7 --- /dev/null +++ b/packages/compiler/test/permissions/permissioned-host.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + createPermissionedSystemHost, + createPermissionSet, + PermissionDeniedError, +} from "../../src/core/permissions/index.js"; +import type { SystemHost } from "../../src/core/types.js"; + +/** A SystemHost that records calls and never touches the real file system. */ +function createFakeHost(): SystemHost & { calls: string[] } { + const calls: string[] = []; + return { + calls, + readUrl: async (url) => { + calls.push(`readUrl:${url}`); + return { + path: url, + text: "", + getLineStarts: () => [0], + getLineAndCharacterOfPosition: () => ({ line: 0, character: 0 }), + } as any; + }, + readFile: async (path) => { + calls.push(`readFile:${path}`); + return { + path, + text: "", + getLineStarts: () => [0], + getLineAndCharacterOfPosition: () => ({ line: 0, character: 0 }), + } as any; + }, + writeFile: async (path) => { + calls.push(`writeFile:${path}`); + }, + readDir: async (path) => { + calls.push(`readDir:${path}`); + return []; + }, + rm: async (path) => { + calls.push(`rm:${path}`); + }, + mkdirp: async (path) => { + calls.push(`mkdirp:${path}`); + return path; + }, + stat: async (path) => { + calls.push(`stat:${path}`); + return { isDirectory: () => false, isFile: () => true }; + }, + realpath: async (path) => { + calls.push(`realpath:${path}`); + return path; + }, + }; +} + +describe("createPermissionedSystemHost", () => { + const permissions = createPermissionSet([ + { kind: "fs-read", paths: ["/project/src"] }, + { kind: "fs-write", paths: ["/project/out"] }, + { kind: "network", hosts: ["*.example.com"] }, + ]); + + it("allows reads within granted scope and forwards to the inner host", async () => { + const fake = createFakeHost(); + const host = createPermissionedSystemHost(fake, permissions); + await host.readFile("/project/src/main.tsp"); + expect(fake.calls).toContain("readFile:/project/src/main.tsp"); + }); + + it("denies reads outside granted scope", async () => { + const host = createPermissionedSystemHost(createFakeHost(), permissions); + await expect(host.readFile("/etc/hosts")).rejects.toBeInstanceOf(PermissionDeniedError); + }); + + it("allows writes within the output scope but denies writes elsewhere", async () => { + const fake = createFakeHost(); + const host = createPermissionedSystemHost(fake, permissions); + await host.writeFile("/project/out/openapi.yaml", "x"); + expect(fake.calls).toContain("writeFile:/project/out/openapi.yaml"); + await expect(host.writeFile("/project/src/leak.ts", "x")).rejects.toBeInstanceOf( + PermissionDeniedError, + ); + }); + + it("denies removing or creating directories outside the write scope", async () => { + const host = createPermissionedSystemHost(createFakeHost(), permissions); + await expect(host.rm("/project/src")).rejects.toBeInstanceOf(PermissionDeniedError); + await expect(host.mkdirp("/tmp/x")).rejects.toBeInstanceOf(PermissionDeniedError); + }); + + it("allows network reads to granted hosts and denies others", async () => { + const fake = createFakeHost(); + const host = createPermissionedSystemHost(fake, permissions); + await host.readUrl("https://api.example.com/schema.json"); + expect(fake.calls).toContain("readUrl:https://api.example.com/schema.json"); + await expect(host.readUrl("https://evil.test/x")).rejects.toBeInstanceOf(PermissionDeniedError); + }); + + it("treats a non-URL passed to readUrl as a file read", async () => { + const host = createPermissionedSystemHost(createFakeHost(), permissions); + await expect(host.readUrl("/etc/passwd")).rejects.toMatchObject({ permission: "fs-read" }); + }); + + it("denies everything under the empty (default) permission set", async () => { + const host = createPermissionedSystemHost(createFakeHost(), createPermissionSet([])); + await expect(host.readFile("/project/src/main.tsp")).rejects.toBeInstanceOf( + PermissionDeniedError, + ); + await expect(host.writeFile("/project/out/x", "y")).rejects.toBeInstanceOf( + PermissionDeniedError, + ); + }); + + it("exposes the denial code and resource for diagnostics", async () => { + const host = createPermissionedSystemHost(createFakeHost(), permissions); + await expect(host.writeFile("/nope/x", "y")).rejects.toMatchObject({ + code: "ERR_ACCESS_DENIED", + permission: "fs-write", + resource: "/nope/x", + }); + }); +}); diff --git a/packages/compiler/test/permissions/resolve.test.ts b/packages/compiler/test/permissions/resolve.test.ts new file mode 100644 index 00000000000..bb4845c1375 --- /dev/null +++ b/packages/compiler/test/permissions/resolve.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { PermissionRequest } from "../../src/core/permissions/index.js"; +import { + configGrantToPermissionSet, + formatGrantSuggestion, + manifestToPermissionSet, + resolvePermissions, +} from "../../src/core/permissions/index.js"; + +describe("manifestToPermissionSet", () => { + it("collects requested permissions, ignoring reasons", () => { + const requests: PermissionRequest[] = [ + { permission: { kind: "fs-read", paths: ["/a"] }, reason: "read templates" }, + { permission: { kind: "network", hosts: ["api.example.com"] } }, + ]; + const set = manifestToPermissionSet(requests); + expect(set.fsRead).toEqual(["/a"]); + expect(set.network).toEqual(["api.example.com"]); + }); + + it("returns an empty set when there are no requests", () => { + expect(manifestToPermissionSet(undefined).fsRead).toEqual([]); + }); +}); + +describe("configGrantToPermissionSet", () => { + it("resolves relative paths against the base dir and always grants the output dir for writes", () => { + const set = configGrantToPermissionSet( + { "fs-read": ["./schemas"], "fs-write": ["./extra"] }, + { baseDir: "/project", outputDir: "/project/tsp-output" }, + ); + expect(set.fsRead).toEqual(["/project/schemas"]); + expect(set.fsWrite).toEqual(["/project/extra", "/project/tsp-output"]); + }); + + it("grants the output dir for writes even with no explicit grant", () => { + const set = configGrantToPermissionSet(undefined, { + baseDir: "/project", + outputDir: "/project/out", + }); + expect(set.fsWrite).toEqual(["/project/out"]); + }); + + it("maps exec: true to any-command", () => { + const set = configGrantToPermissionSet({ exec: true }, { baseDir: "/project" }); + expect(set.exec).toBe(true); + }); +}); + +describe("resolvePermissions", () => { + it("authorizes when the grant covers every request", () => { + const requests: PermissionRequest[] = [ + { permission: { kind: "fs-read", paths: ["/project/schemas"] } }, + { permission: { kind: "fs-write", paths: ["/project/tsp-output/openapi"] } }, + ]; + const res = resolvePermissions( + requests, + { "fs-read": ["./schemas"] }, + { baseDir: "/project", outputDir: "/project/tsp-output" }, + ); + expect(res.missing).toEqual([]); + expect(res.effective.fsRead).toEqual(["/project/schemas"]); + // The emitter's output dir is always writable (implicit grant), in addition + // to the intersection of requested and granted write scopes. + expect(res.effective.fsWrite).toEqual(["/project/tsp-output/openapi", "/project/tsp-output"]); + }); + + it("reports missing permissions when the grant is insufficient", () => { + const requests: PermissionRequest[] = [ + { permission: { kind: "fs-read", paths: ["/etc"] } }, + { permission: { kind: "network", hosts: ["api.example.com"] } }, + ]; + const res = resolvePermissions(requests, undefined, { + baseDir: "/project", + outputDir: "/project/out", + }); + expect(res.missing).toEqual([ + { kind: "fs-read", paths: ["/etc"] }, + { kind: "network", hosts: ["api.example.com"] }, + ]); + }); + + it("grants nothing system-related by default (secure default)", () => { + const requests: PermissionRequest[] = [ + { permission: { kind: "exec" } }, + { permission: { kind: "env", names: ["TOKEN"] } }, + ]; + const res = resolvePermissions(requests, undefined, { baseDir: "/project" }); + expect(res.effective.exec).toBe(false); + expect(res.effective.env).toEqual([]); + expect(res.missing).toEqual([{ kind: "env", names: ["TOKEN"] }, { kind: "exec" }]); + }); + + it("always makes the output dir writable, even with no requested permissions", () => { + // A plain emitter that declares no permissions must still be able to write + // its own output directory. + const res = resolvePermissions(undefined, undefined, { + baseDir: "/project", + outputDir: "/project/tsp-output/plain", + }); + expect(res.missing).toEqual([]); + expect(res.effective.fsWrite).toEqual(["/project/tsp-output/plain"]); + }); +}); + +describe("formatGrantSuggestion", () => { + it("renders a pasteable tspconfig snippet", () => { + const snippet = formatGrantSuggestion("@typespec/openapi3", [ + { kind: "fs-read", paths: ["/etc"] }, + { kind: "network", hosts: ["api.example.com"] }, + { kind: "exec" }, + ]); + expect(snippet).toBe( + [ + "permissions:", + ' "@typespec/openapi3":', + " fs-read:", + " - /etc", + " network:", + " - api.example.com", + " exec: true", + ].join("\n"), + ); + }); +}); diff --git a/packages/compiler/test/permissions/sandbox/emit.e2e.test.ts b/packages/compiler/test/permissions/sandbox/emit.e2e.test.ts new file mode 100644 index 00000000000..2e678309a8b --- /dev/null +++ b/packages/compiler/test/permissions/sandbox/emit.e2e.test.ts @@ -0,0 +1,173 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import { pathToFileURL } from "url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { compile as Compile } from "../../../src/core/program.js"; +import type { CompilerHost } from "../../../src/core/types.js"; + +// The sandbox forks children that must run compiled JS, so this e2e runs against +// the built `dist` and is skipped when the package has not been built. +const distProgram = resolve("dist/src/core/program.js"); +const isBuilt = existsSync(distProgram); + +describe.skipIf(!isBuilt)("sandboxed emitter execution (e2e)", () => { + let compile: typeof Compile; + let NodeHost: CompilerHost; + let workDir: string; + let mainFile: string; + + beforeAll(async () => { + compile = (await import(pathToFileURL(distProgram).href)).compile; + NodeHost = (await import(pathToFileURL(resolve("dist/src/core/node-host.js")).href)).NodeHost; + + workDir = mkdtempSync(join(tmpdir(), "tsp-emit-sandbox-")); + mainFile = join(workDir, "main.tsp"); + writeFileSync(mainFile, `op ping(): void;\n`); + + // A plain emitter (no declared permissions) that writes one output file. + writeEmitter("plain-emitter", { name: "plain-emitter" }); + // An emitter that *requests* network access in its manifest. + writeEmitter("net-emitter", { + name: "net-emitter", + permissions: [ + { permission: { kind: "network", hosts: ["example.com"] }, reason: "calls home" }, + ], + }); + }); + + afterAll(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + function writeEmitter(name: string, lib: object): string { + const dir = join(workDir, name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name, version: "0.0.0", main: "index.js", type: "module" }), + ); + writeFileSync( + join(dir, "index.js"), + `export const $lib = ${JSON.stringify(lib)}; + export async function $onEmit(context) { + const dir = context.emitterOutputDir; + await context.program.host.mkdirp(dir); + await context.program.host.writeFile(dir + "/out.txt", "emitted-by-sandbox"); + } + `, + ); + return dir; + } + + function writeEmitterWithBody(name: string, body: string): string { + const dir = join(workDir, name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name, version: "0.0.0", main: "index.js", type: "module" }), + ); + writeFileSync( + join(dir, "index.js"), + `export const $lib = { name: ${JSON.stringify(name)} }; + export async function $onEmit(context) { + ${body} + } + `, + ); + return dir; + } + + function configFile(permissions?: Record) { + return { projectRoot: workDir, permissions, file: undefined } as any; + } + + it("runs an emitter with no declared permissions and writes its output", async () => { + const outputDir = join(workDir, "out-plain"); + const program = await compile(NodeHost, mainFile, { + sandbox: true, + emit: [join(workDir, "plain-emitter")], + outputDir, + options: { "plain-emitter": { "emitter-output-dir": outputDir } }, + configFile: configFile(), + }); + + expect(program.diagnostics).toEqual([]); + expect(readFileSync(join(outputDir, "out.txt"), "utf8")).toBe("emitted-by-sandbox"); + }); + + it("blocks an emitter whose requested permissions were not granted", async () => { + const outputDir = join(workDir, "out-denied"); + const program = await compile(NodeHost, mainFile, { + sandbox: true, + emit: [join(workDir, "net-emitter")], + outputDir, + options: { "net-emitter": { "emitter-output-dir": outputDir } }, + configFile: configFile(), + }); + + const denied = program.diagnostics.find((d) => d.code === "permission-not-granted"); + expect(denied).toBeDefined(); + expect(denied!.message).toContain("net-emitter"); + expect(denied!.message).toContain("network"); + // The emitter was skipped, so nothing was written. + expect(existsSync(join(outputDir, "out.txt"))).toBe(false); + }); + + it("runs an emitter once its requested permissions are granted in config", async () => { + const outputDir = join(workDir, "out-granted"); + const program = await compile(NodeHost, mainFile, { + sandbox: true, + emit: [join(workDir, "net-emitter")], + outputDir, + options: { "net-emitter": { "emitter-output-dir": outputDir } }, + configFile: configFile({ "net-emitter": { network: ["example.com"] } }), + }); + + expect(program.diagnostics.find((d) => d.code === "permission-not-granted")).toBeUndefined(); + expect(readFileSync(join(outputDir, "out.txt"), "utf8")).toBe("emitted-by-sandbox"); + }); + + it("lets an emitter read its own package directory through the host", async () => { + const dir = writeEmitterWithBody( + "self-read-emitter", + `const { fileURLToPath } = await import("url"); + const pkgPath = fileURLToPath(new URL("./package.json", import.meta.url)); + const pkg = await context.program.host.readFile(pkgPath); + const out = context.emitterOutputDir; + await context.program.host.mkdirp(out); + await context.program.host.writeFile(out + "/pkg-name.txt", JSON.parse(pkg.text).name);`, + ); + const outputDir = join(workDir, "out-self-read"); + const program = await compile(NodeHost, mainFile, { + sandbox: true, + emit: [dir], + outputDir, + options: { "self-read-emitter": { "emitter-output-dir": outputDir } }, + configFile: configFile(), + }); + + expect(program.diagnostics).toEqual([]); + expect(readFileSync(join(outputDir, "pkg-name.txt"), "utf8")).toBe("self-read-emitter"); + }); + + it("blocks an emitter from reading outside its grant through the host", async () => { + const dir = writeEmitterWithBody( + "escape-emitter", + `await context.program.host.readFile(${JSON.stringify(mainFile)});`, + ); + const outputDir = join(workDir, "out-escape"); + + // Reading the spec (outside the emitter's grant) makes $onEmit throw, which + // the compiler surfaces as an emitter crash rather than a silent success. + await expect( + compile(NodeHost, mainFile, { + sandbox: true, + emit: [dir], + outputDir, + options: { "escape-emitter": { "emitter-output-dir": outputDir } }, + configFile: configFile(), + }), + ).rejects.toThrow(/ERR_ACCESS_DENIED|Permission denied|escape-emitter/); + }); +}); diff --git a/packages/compiler/test/permissions/sandbox/node-args.test.ts b/packages/compiler/test/permissions/sandbox/node-args.test.ts new file mode 100644 index 00000000000..ee8a7627cb5 --- /dev/null +++ b/packages/compiler/test/permissions/sandbox/node-args.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + buildSandboxEnv, + createPermissionSet, + permissionSetToNodeArgs, +} from "../../../src/core/permissions/index.js"; + +describe("permissionSetToNodeArgs", () => { + it("always enables the permission model and denies everything by default", () => { + const args = permissionSetToNodeArgs(createPermissionSet([])); + expect(args).toEqual(["--permission"]); + }); + + it("emits fs-read/fs-write flags for granted scopes and essential scopes", () => { + const args = permissionSetToNodeArgs( + createPermissionSet([ + { kind: "fs-read", paths: ["/project/src"] }, + { kind: "fs-write", paths: ["/project/out"] }, + ]), + { essentialReadScopes: ["/compiler"], essentialWriteScopes: ["/tmp/work"] }, + ); + expect(args).toContain("--allow-fs-read=/compiler"); + expect(args).toContain("--allow-fs-read=/project/src"); + expect(args).toContain("--allow-fs-write=/tmp/work"); + expect(args).toContain("--allow-fs-write=/project/out"); + }); + + it("enables child processes when exec is granted (true or a command list)", () => { + expect(permissionSetToNodeArgs(createPermissionSet([{ kind: "exec" }]))).toContain( + "--allow-child-process", + ); + expect( + permissionSetToNodeArgs(createPermissionSet([{ kind: "exec", commands: ["git"] }])), + ).toContain("--allow-child-process"); + }); + + it("does not produce flags for network or env (not covered by the permission model)", () => { + const args = permissionSetToNodeArgs( + createPermissionSet([ + { kind: "network", hosts: ["*"] }, + { kind: "env", names: ["TOKEN"] }, + ]), + ); + expect(args).toEqual(["--permission"]); + }); +}); + +describe("buildSandboxEnv", () => { + const parentEnv = { + PATH: "/usr/bin", + HOME: "/home/me", + SECRET_TOKEN: "leak", + MY_VAR: "ok", + }; + + it("forwards only essential vars plus granted env names", () => { + const env = buildSandboxEnv(createPermissionSet([{ kind: "env", names: ["MY_VAR"] }]), parentEnv); + expect(env.PATH).toBe("/usr/bin"); + expect(env.HOME).toBe("/home/me"); + expect(env.MY_VAR).toBe("ok"); + expect(env.SECRET_TOKEN).toBeUndefined(); + }); + + it("drops everything but essentials when no env is granted", () => { + const env = buildSandboxEnv(createPermissionSet([]), parentEnv); + expect(env.SECRET_TOKEN).toBeUndefined(); + expect(env.MY_VAR).toBeUndefined(); + expect(env.PATH).toBe("/usr/bin"); + }); +}); diff --git a/packages/compiler/test/permissions/sandbox/runtime.integration.test.ts b/packages/compiler/test/permissions/sandbox/runtime.integration.test.ts new file mode 100644 index 00000000000..d1383b8ca3f --- /dev/null +++ b/packages/compiler/test/permissions/sandbox/runtime.integration.test.ts @@ -0,0 +1,107 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import { pathToFileURL } from "url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NodeSystemHost } from "../../../src/core/node-system-host.js"; +import type { runInSandbox as RunInSandbox } from "../../../src/core/permissions/index.js"; +import { createPermissionSet } from "../../../src/core/permissions/index.js"; + +// The sandbox forks a child that must run compiled JS (the permission model is +// incompatible with the tsx loader's write cache). These tests therefore run +// against the built `dist` and are skipped when the package has not been built. +const distRuntime = resolve("dist/src/core/permissions/sandbox/runtime.js"); +const isBuilt = existsSync(distRuntime); + +describe.skipIf(!isBuilt)("runInSandbox (process isolation)", () => { + let runInSandbox: typeof RunInSandbox; + let workDir: string; + let fixturePath: string; + + beforeAll(async () => { + ({ runInSandbox } = await import(pathToFileURL(distRuntime).href)); + workDir = mkdtempSync(join(tmpdir(), "tsp-sandbox-")); + fixturePath = join(workDir, "fixture.js"); + writeFileSync( + fixturePath, + ` + export default async function (context) { + const out = {}; + // Legitimate write through the broker (within granted scope). + try { + await context.host.mkdirp(context.payload.allowedDir); + await context.host.writeFile(context.payload.allowedFile, "hello"); + out.brokerWriteOk = true; + } catch (e) { out.brokerWriteOk = false; out.brokerWriteErr = e.code; } + // Write through the broker outside granted scope -> broker denies. + try { + await context.host.writeFile(context.payload.deniedFile, "x"); + out.brokerWriteDenied = "ALLOWED"; + } catch (e) { out.brokerWriteDenied = e.code; } + // Direct fs access -> blocked by the OS permission model. + try { + const fs = await import("node:fs/promises"); + await fs.readFile("/etc/hosts", "utf8"); + out.directRead = "ALLOWED"; + } catch (e) { out.directRead = e.code; } + // Ambient network is denied by default. + out.fetchDenied = typeof fetch === "function"; + try { await fetch("https://example.com"); out.fetchCall = "ALLOWED"; } + catch (e) { out.fetchCall = e.code; } + context.reportDiagnostic({ code: "from-sandbox" }); + return out; + } + `, + ); + }); + + afterAll(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + it("enforces OS-level fs restrictions while allowing brokered writes in scope", async () => { + const allowedDir = join(workDir, "out"); + const allowedFile = join(allowedDir, "result.txt"); + const deniedFile = join(workDir, "secret", "leak.txt"); + const diagnostics: unknown[] = []; + + const result = (await runInSandbox({ + modulePath: fixturePath, + payload: { allowedDir, allowedFile, deniedFile }, + permissions: createPermissionSet([{ kind: "fs-write", paths: [join(workDir, "out")] }]), + host: NodeSystemHost, + onDiagnostic: (d) => diagnostics.push(d), + })) as Record; + + // Brokered write inside the granted scope succeeded and hit disk. + expect(result.brokerWriteOk).toBe(true); + expect(readFileSync(allowedFile, "utf8")).toBe("hello"); + + // Brokered write outside the granted scope was denied by the broker. + expect(result.brokerWriteDenied).toBe("ERR_ACCESS_DENIED"); + + // Direct file-system access was blocked by the OS permission model. + expect(result.directRead).toBe("ERR_ACCESS_DENIED"); + + // Ambient network was denied by default. + expect(result.fetchCall).toBe("ERR_ACCESS_DENIED"); + + // Diagnostics round-tripped back to the parent. + expect(diagnostics).toEqual([{ code: "from-sandbox" }]); + }); + + it("propagates errors thrown by the sandboxed module", async () => { + const throwingFixture = join(workDir, "throwing.js"); + writeFileSync( + throwingFixture, + `export default async function () { throw new Error("boom from sandbox"); }`, + ); + await expect( + runInSandbox({ + modulePath: throwingFixture, + permissions: createPermissionSet([]), + host: NodeSystemHost, + }), + ).rejects.toThrow(/boom from sandbox/); + }); +}); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 9e27ae368f9..f266db266b9 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -20,6 +20,7 @@ const rootOptions = [ "emit", "options", "linter", + "permissions", ]; describe("Test completion items for root options", () => { @@ -484,6 +485,7 @@ describe("Test completion items for extends", () => { "options", "output-dir", "parameters", + "permissions", "trace", "warn-as-error", ],