diff --git a/e2e/harmony/scope-trust.e2e.ts b/e2e/harmony/scope-trust.e2e.ts new file mode 100644 index 000000000000..98bb7494044f --- /dev/null +++ b/e2e/harmony/scope-trust.e2e.ts @@ -0,0 +1,203 @@ +/** + * Verifies the workspace's scope-trust gate around aspect loading. + * + * The gate is opt-in: it only runs when `trustedScopes` is present in + * workspace.jsonc. The deny test below opts in explicitly; the allow test + * lists the env's scope. + * + * Setup: an env in scope A has a top-level statement that writes a marker + * file when an env-var is set. A consumer component uses that env. A second + * workspace whose `defaultScope` belongs to a different owner imports the + * consumer. + */ +import { expect } from 'chai'; +import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; +import { Helper, NpmCiRegistry, supportNpmCiRegistryTesting } from '@teambit/legacy.e2e-helper'; + +const MARKER_ENV_VAR = 'BIT_SCOPE_TRUST_TEST_MARKER'; + +const ENV_WITH_MARKER_SOURCE = ` +import * as fs from 'fs'; +const marker = process.env.${MARKER_ENV_VAR}; +if (marker) { + try { fs.writeFileSync(marker, 'env-module-loaded'); } catch (e) { /* best effort */ } +} +export class EmptyEnv {} +export default new EmptyEnv(); +`; + +(supportNpmCiRegistryTesting ? describe : describe.skip)( + 'workspace scope-trust gate around aspect loading', + function () { + this.timeout(0); + let helper: Helper; + let npmCiRegistry: NpmCiRegistry; + let markerPath: string; + let originalMarkerEnv: string | undefined; + let importErrorOutput: string; + + before(async () => { + // Stable absolute marker path; passed to spawned bit processes via the + // current process's env so the env module's top-level can write it. + markerPath = path.join( + os.tmpdir(), + `bit-scope-trust-marker-${Date.now()}-${Math.random().toString(36).slice(2)}.txt` + ); + fs.removeSync(markerPath); + originalMarkerEnv = process.env[MARKER_ENV_VAR]; + process.env[MARKER_ENV_VAR] = markerPath; + + helper = new Helper({ scopesOptions: { remoteScopeWithDot: true } }); + helper.scopeHelper.setWorkspaceWithRemoteScope(); + + // Env whose module body writes the marker file when MARKER_ENV_VAR is set. + helper.env.setEmptyEnv(); + helper.fs.outputFile('empty-env/empty-env.bit-env.ts', ENV_WITH_MARKER_SOURCE); + + helper.fixtures.populateComponents(1, false); + helper.command.setEnv('comp1', 'empty-env'); + + npmCiRegistry = new NpmCiRegistry(helper); + await npmCiRegistry.init(); + npmCiRegistry.configureCiInPackageJsonHarmony(); + helper.command.install(); + helper.command.compile(); + helper.command.tagAllComponents(); + helper.command.export(); + + // Second workspace, opted in to scope-trust with an empty list. The + // defaultScope is under a different owner so the owner-wildcard doesn't + // auto-trust the publisher's scope; the empty list means only builtins + // are trusted, so the publisher's scope is rejected. + helper.scopeHelper.reInitWorkspace({ addRemoteScopeAsDefaultScope: false }); + helper.workspaceJsonc.addKeyValToWorkspace('defaultScope', 'other-owner.app'); + helper.workspaceJsonc.addKeyValToWorkspace('trustedScopes', []); + npmCiRegistry.setResolver(); + + // Clear the marker before the import so any later write is attributable + // to the consumer-side aspect load (publisher-side install/compile runs + // its own env code naturally — its scope is trusted in its own workspace). + fs.removeSync(markerPath); + expect(fs.existsSync(markerPath), 'marker should be absent before import').to.be.false; + + // The aspect-load gate refuses; the import surfaces the refusal + // message. Capture both stdout and any thrown error so we can assert + // on the message (avoids a false positive if the import had failed for + // an unrelated reason like a network/registry hiccup). + try { + importErrorOutput = helper.command.importComponent('comp1'); + } catch (err: any) { + importErrorOutput = `${err?.stdout || ''}\n${err?.stderr || ''}\n${err?.message || ''}`; + } + }); + + after(() => { + try { + fs.removeSync(markerPath); + } catch {} + if (originalMarkerEnv === undefined) delete process.env[MARKER_ENV_VAR]; + else process.env[MARKER_ENV_VAR] = originalMarkerEnv; + npmCiRegistry?.destroy(); + helper?.scopeHelper.destroy(); + }); + + it('does not load the env from a scope outside the trust list', () => { + expect(fs.existsSync(markerPath), `env module loaded; marker at ${markerPath}`).to.be.false; + }); + + it("surfaces a refusal message naming the scope that isn't on the trust list", () => { + expect(importErrorOutput).to.match(/isn't on the workspace's trusted list/i); + }); + } +); + +(supportNpmCiRegistryTesting ? describe : describe.skip)( + 'workspace scope-trust gate: trusted env in a different scope than the consumer', + function () { + this.timeout(0); + let helper: Helper; + let npmCiRegistry: NpmCiRegistry; + let markerPath: string; + let originalMarkerEnv: string | undefined; + let envScopeName: string; + let importOutput: string; + + before(async () => { + markerPath = path.join( + os.tmpdir(), + `bit-scope-trust-marker-${Date.now()}-${Math.random().toString(36).slice(2)}.txt` + ); + fs.removeSync(markerPath); + originalMarkerEnv = process.env[MARKER_ENV_VAR]; + process.env[MARKER_ENV_VAR] = markerPath; + + helper = new Helper({ scopesOptions: { remoteScopeWithDot: true } }); + helper.scopeHelper.setWorkspaceWithRemoteScope(); + // The publisher's default remote will hold the env. envScopeName is the + // exact scope name the victim will trust explicitly via `bit scope trust`. + envScopeName = helper.scopes.remote; + + // A second remote holds the consumer component. The victim does NOT + // trust this scope. The import should still succeed because only the + // env's scope is checked at aspect-load. + const compRemote = helper.scopeHelper.getNewBareScope('-comp-remote'); + // Register compRemote in the publisher workspace and cross-link the two + // remotes so each can resolve dependencies from the other. + helper.scopeHelper.addRemoteScope(compRemote.scopePath); + helper.scopeHelper.addRemoteScope(compRemote.scopePath, helper.scopes.remotePath); + helper.scopeHelper.addRemoteScope(helper.scopes.remotePath, compRemote.scopePath); + + helper.env.setEmptyEnv(); + helper.fs.outputFile('empty-env/empty-env.bit-env.ts', ENV_WITH_MARKER_SOURCE); + + helper.fixtures.populateComponents(1, false); + helper.command.setEnv('comp1', 'empty-env'); + // Retarget the consumer (comp1) to the second remote scope so its scope + // differs from the env's scope. + helper.command.setScope(compRemote.scopeName, 'comp1'); + + npmCiRegistry = new NpmCiRegistry(helper); + await npmCiRegistry.init(); + npmCiRegistry.configureCiInPackageJsonHarmony(); + helper.command.install(); + helper.command.compile(); + helper.command.tagAllComponents(); + helper.command.export(); + + // Victim workspace under a different owner (so neither scope is auto- + // trusted via the owner-of-defaultScope wildcard). Trust ONLY the env's + // scope explicitly. The consumer's scope remains untrusted. + helper.scopeHelper.reInitWorkspace({ addRemoteScopeAsDefaultScope: false }); + helper.workspaceJsonc.addKeyValToWorkspace('defaultScope', 'other-owner.app'); + helper.workspaceJsonc.addKeyValToWorkspace('trustedScopes', [envScopeName]); + helper.scopeHelper.addRemoteScope(compRemote.scopePath); + npmCiRegistry.setResolver({ [compRemote.scopeName]: compRemote.scopePath }); + + fs.removeSync(markerPath); + expect(fs.existsSync(markerPath), 'marker should be absent before import').to.be.false; + + importOutput = helper.command.import(`${compRemote.scopeName}/comp1`); + }); + + after(() => { + try { + fs.removeSync(markerPath); + } catch {} + if (originalMarkerEnv === undefined) delete process.env[MARKER_ENV_VAR]; + else process.env[MARKER_ENV_VAR] = originalMarkerEnv; + npmCiRegistry?.destroy(); + helper?.scopeHelper.destroy(); + }); + + it('imports successfully (no error about untrusted scopes)', () => { + expect(importOutput).to.match(/successfully imported/i); + expect(importOutput).to.not.match(/isn't on the workspace's trusted list/i); + }); + + it('loads the env from the trusted scope', () => { + expect(fs.existsSync(markerPath), `env module did not load; expected marker at ${markerPath}`).to.be.true; + }); + } +); diff --git a/e2e/performance/filesystem-read.e2e.ts b/e2e/performance/filesystem-read.e2e.ts index ce75bf09dfdd..0e8fad8d2788 100644 --- a/e2e/performance/filesystem-read.e2e.ts +++ b/e2e/performance/filesystem-read.e2e.ts @@ -11,7 +11,7 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const MAX_FILES_READ = 1057; +const MAX_FILES_READ = 1062; const MAX_FILES_READ_STATUS = 1500; /** diff --git a/scopes/scope/scope/scope-aspects-loader.ts b/scopes/scope/scope/scope-aspects-loader.ts index 3dd22e0ab034..194fa0edb8c9 100644 --- a/scopes/scope/scope/scope-aspects-loader.ts +++ b/scopes/scope/scope/scope-aspects-loader.ts @@ -214,7 +214,11 @@ needed-for: ${neededFor || ''}`); return new RequireableComponent( capsule.component, async () => { - // eslint-disable-next-line global-require, import/no-dynamic-require + // Honor the workspace's scope-trust hook if registered. Absent in + // pure scope (remote-server) contexts. + const guard = this.scope.getAspectLoadGuard(); + if (guard) await guard(capsule.component.id); + const plugins = this.aspectLoader.getPlugins(capsule.component, capsule.path); if (plugins.has()) { await this.compileIfNoDist(capsule, capsule.component); @@ -227,7 +231,6 @@ needed-for: ${neededFor || ''}`); const runtimePath = scopeRuntime || mainRuntime; // eslint-disable-next-line global-require, import/no-dynamic-require if (runtimePath) require(runtimePath); - // eslint-disable-next-line global-require, import/no-dynamic-require return aspect; }, capsule diff --git a/scopes/scope/scope/scope.main.runtime.ts b/scopes/scope/scope/scope.main.runtime.ts index e10a9df59636..8b7734016103 100644 --- a/scopes/scope/scope/scope.main.runtime.ts +++ b/scopes/scope/scope/scope.main.runtime.ts @@ -201,6 +201,21 @@ export class ScopeMain implements ComponentFactory { localAspects: string[] = []; + /** + * Optional hook called immediately before an aspect is `require()`d from a + * scope-aspects capsule. Throws to refuse the load. The workspace aspect + * registers this to enforce the per-workspace scope-trust list. + */ + private aspectLoadGuard?: (componentId: ComponentID) => Promise; + + setAspectLoadGuard(guard: (componentId: ComponentID) => Promise): void { + this.aspectLoadGuard = guard; + } + + getAspectLoadGuard(): ((componentId: ComponentID) => Promise) | undefined { + return this.aspectLoadGuard; + } + /** * name of the scope */ diff --git a/scopes/workspace/workspace/scope-trust/index.ts b/scopes/workspace/workspace/scope-trust/index.ts new file mode 100644 index 000000000000..01783c65d400 --- /dev/null +++ b/scopes/workspace/workspace/scope-trust/index.ts @@ -0,0 +1,2 @@ +export { ScopeTrust } from './scope-trust'; +export { ScopeTrustCmd } from './scope-trust.cmd'; diff --git a/scopes/workspace/workspace/scope-trust/scope-trust.cmd.ts b/scopes/workspace/workspace/scope-trust/scope-trust.cmd.ts new file mode 100644 index 000000000000..ca9c0440cefb --- /dev/null +++ b/scopes/workspace/workspace/scope-trust/scope-trust.cmd.ts @@ -0,0 +1,111 @@ +import type { Command } from '@teambit/cli'; +import { formatHint, formatItem, formatSection, formatSuccessSummary, formatTitle, joinSections } from '@teambit/cli'; +import { BitError } from '@teambit/bit-error'; +import chalk from 'chalk'; +import type { ScopeTrust } from './scope-trust'; + +const ACTIONS = ['list', 'enable', 'disable', 'add', 'remove'] as const; +type Action = (typeof ACTIONS)[number]; + +export class ScopeTrustCmd implements Command { + name = 'trust [action] [pattern]'; + description = "manage which scopes are trusted to load aspects (envs, etc.) into the workspace's process"; + arguments = [ + { + name: 'action', + description: `one of: ${ACTIONS.join(', ')}. defaults to "list".`, + }, + { + name: 'pattern', + description: 'scope pattern (required for "add" and "remove")', + }, + ]; + options = []; + group = 'component-config'; + // Don't load aspects for this command. If the workspace already references + // an aspect from a scope that the trust list doesn't allow, the pre-command + // aspect-load step would itself trip the gate, leaving the user with no way + // to run `bit scope trust` to fix it. Skipping aspect-load keeps the command + // usable as a recovery path. + loadAspects = false; + extendedDescription = `scope-trust is opt-in. when off (the default), aspects from any scope load without a check. when on, aspects from a scope outside the trust list trigger a prompt (interactive shells) or an error (non-interactive). + + bit scope trust # same as "list" + bit scope trust list # show status; if on, print the effective trust list + bit scope trust enable # turn on (writes "trustedScopes": [] to workspace.jsonc) + bit scope trust disable # turn off (removes "trustedScopes" from workspace.jsonc) + bit scope trust add PATTERN # add a pattern (auto-enables if needed) + bit scope trust remove PATTERN # remove a pattern (does NOT disable when list is empty) + +once on, the effective trust set is: builtin scopes (teambit.*, bitdev.*, and a few others — run "bit scope trust list" to see) + the owner of defaultScope + entries listed under "trustedScopes". patterns are exact ("acme.frontend") or owner wildcard ("acme.*").`; + + constructor(private scopeTrust: ScopeTrust) {} + + async report(args: string[]): Promise { + const [rawAction, pattern] = args; + const action = (rawAction || 'list') as Action; + if (!ACTIONS.includes(action)) { + throw new BitError(`unknown action "${rawAction}". valid actions: ${ACTIONS.join(', ')}.`); + } + switch (action) { + case 'list': + return this.formatList(); + case 'enable': + await this.scopeTrust.enable(); + return formatSuccessSummary('scope-trust enabled (added trustedScopes: [] to workspace.jsonc)'); + case 'disable': + await this.scopeTrust.disable(); + return formatSuccessSummary('scope-trust disabled (removed trustedScopes from workspace.jsonc)'); + case 'add': { + const p = requirePattern(action, pattern); + await this.scopeTrust.addTrustedScope(p); + return formatSuccessSummary(`added ${chalk.bold(p)} to trustedScopes in workspace.jsonc`); + } + case 'remove': { + const p = requirePattern(action, pattern); + await this.scopeTrust.removeTrustedScope(p); + return formatSuccessSummary(`removed ${chalk.bold(p)} from trustedScopes in workspace.jsonc`); + } + } + } + + private formatList(): string { + if (!this.scopeTrust.isOptedIn()) { + return joinSections([ + formatTitle('scope-trust is off for this workspace.'), + 'aspects from any scope load without a check.', + formatHint( + 'to turn on:\n bit scope trust enable (no scopes added; only builtins + owner-of-defaultScope auto-trusted)\n bit scope trust add (turns on and adds the first scope)' + ), + ]); + } + const groups = this.scopeTrust.getEffectiveTrustedPatterns(); + return joinSections([ + formatTitle('scope-trust is on. aspects from these scopes load without a prompt:'), + formatSection( + 'builtin', + '', + groups.builtin.map((p) => formatItem(p)) + ), + formatSection( + 'inferred from workspace defaultScope', + '', + groups.owner.map((p) => formatItem(p)) + ), + groups.configured.length + ? formatSection( + 'configured in workspace.jsonc', + '', + groups.configured.map((p) => formatItem(p)) + ) + : formatHint('no scopes configured in workspace.jsonc. add one with `bit scope trust add `.'), + ]); + } +} + +function requirePattern(action: Action, pattern: string | undefined): string { + if (!pattern) { + throw new BitError(`"${action}" requires a pattern. example: bit scope trust ${action} acme.frontend`); + } + return pattern; +} diff --git a/scopes/workspace/workspace/scope-trust/scope-trust.ts b/scopes/workspace/workspace/scope-trust/scope-trust.ts new file mode 100644 index 000000000000..da6a6d115a74 --- /dev/null +++ b/scopes/workspace/workspace/scope-trust/scope-trust.ts @@ -0,0 +1,260 @@ +import type { ComponentID } from '@teambit/component-id'; +import type { Logger } from '@teambit/logger'; +import { BitError } from '@teambit/bit-error'; +import { isValidScopeName } from '@teambit/legacy-bit-id'; +import { prompt } from 'enquirer'; +import type { Workspace } from '../workspace'; + +const BUILTIN_TRUSTED_PATTERNS = ['teambit.*', 'bitdev.*', 'learn-bit-react.*', 'bitdesign.*', 'frontend.*']; + +const WORKSPACE_ASPECT_ID = 'teambit.workspace/workspace'; + +const TRUSTED_SCOPES_KEY = 'trustedScopes'; + +export type TrustedScopesGroups = { + /** patterns built into Bit (e.g. `teambit.*`, `bitdev.*`) */ + builtin: string[]; + /** owner wildcard inferred from `defaultScope` (e.g. `acme.frontend` → `acme.*`) */ + owner: string[]; + /** patterns explicitly configured in `workspace.jsonc` under `trustedScopes` */ + configured: string[]; +}; + +/** + * Workspace-level scope-trust policy. Opt-in: when the `trustedScopes` key is + * present in workspace.jsonc (even as an empty array), the aspect-load gate + * is active. When the key is absent, no gate runs and any aspect loads. + * + * Once opted in, a scope is trusted if it matches any pattern in: + * - the builtin set (e.g. `teambit.*`, `bitdev.*`; see `BUILTIN_TRUSTED_PATTERNS`), + * - the pattern derived from the workspace's `defaultScope` + * (e.g. `acme.frontend` → `acme.*`; legacy dotless `my-scope` → `my-scope`), + * - the `trustedScopes` array configured in workspace.jsonc. + * + * Patterns are exact (`acme.frontend`) or owner wildcard (`acme.*`). + * + * Wired into `ScopeMain` via `setAspectLoadGuard`; the guard runs in the + * aspect-loader path so untrusted aspects never reach `require()`. + */ +export class ScopeTrust { + private deniedThisRun = new Set(); + + constructor( + private workspace: Workspace, + private logger: Logger + ) {} + + /** + * `true` when the workspace has opted in (the `trustedScopes` key is present + * in workspace.jsonc, even as an empty array). When `false`, the aspect-load + * gate is a no-op. + */ + isOptedIn(): boolean { + return Object.prototype.hasOwnProperty.call(this.readExt(), TRUSTED_SCOPES_KEY); + } + + /** + * Effective trust list, broken down by source. Useful for both internal + * checks and the `bit scope trust list` UX. + */ + getEffectiveTrustedPatterns(): TrustedScopesGroups { + const ext = this.readExt(); + const configured = Array.isArray(ext[TRUSTED_SCOPES_KEY]) ? (ext[TRUSTED_SCOPES_KEY] as string[]).slice() : []; + const owner = this.getInferredOwnerPattern(); + return { + builtin: BUILTIN_TRUSTED_PATTERNS.slice(), + owner: owner ? [owner] : [], + configured, + }; + } + + /** + * True iff `scopeName` matches any pattern in the effective trust list. + * `scopeName` is expected to be the bare scope (e.g. `acme.frontend`). + */ + isScopeTrusted(scopeName: string): boolean { + if (!scopeName) return false; + const groups = this.getEffectiveTrustedPatterns(); + const all = [...groups.builtin, ...groups.owner, ...groups.configured]; + return all.some((pattern) => ScopeTrust.matchesPattern(scopeName, pattern)); + } + + /** + * Pattern matcher. Two forms: + * - exact: `acme.frontend` matches only `acme.frontend`. + * - owner wildcard: `acme.*` matches `acme.`. + */ + static matchesPattern(scopeName: string, pattern: string): boolean { + if (pattern === scopeName) return true; + if (pattern.endsWith('.*')) { + const owner = pattern.slice(0, -2); + return scopeName.startsWith(`${owner}.`); + } + return false; + } + + /** Opt the workspace in by writing `trustedScopes: []` (idempotent). */ + async enable(): Promise { + if (this.isOptedIn()) return; + await this.writeExtPatch({ [TRUSTED_SCOPES_KEY]: [] }, 'enable scope-trust'); + } + + /** + * Opt the workspace out by removing the `trustedScopes` key (idempotent). + * Uses `overrideExisting` because key deletion isn't expressible via + * `mergeIntoExisting`; comments on other keys may be reformatted as a result. + */ + async disable(): Promise { + if (!this.isOptedIn()) return; + const updated = { ...this.readExt() }; + delete updated[TRUSTED_SCOPES_KEY]; + const wsConfig = this.workspace.getWorkspaceConfig(); + wsConfig.setExtension(WORKSPACE_ASPECT_ID, updated, { overrideExisting: true, ignoreVersion: true }); + await wsConfig.write({ reasonForChange: 'disable scope-trust' }); + } + + /** Add `pattern` to `trustedScopes` (auto-enables if not yet). */ + async addTrustedScope(pattern: string): Promise { + if (!ScopeTrust.isValidPattern(pattern)) { + throw new BitError( + `invalid scope pattern: "${pattern}". use an exact scope name (e.g. "acme.frontend" or "my-scope") or an owner wildcard (e.g. "acme.*").` + ); + } + await this.mutateConfiguredList( + (list) => (list.includes(pattern) ? null : [...list, pattern]), + `add trusted scope ${pattern}` + ); + } + + /** + * Remove `pattern` from `trustedScopes`. Leaves the key in place even if + * the list becomes empty — use `disable()` to fully turn the gate off. + */ + async removeTrustedScope(pattern: string): Promise { + await this.mutateConfiguredList( + (list) => (list.includes(pattern) ? list.filter((p) => p !== pattern) : null), + `remove trusted scope ${pattern}` + ); + } + + /** + * Build the aspect-load guard. No-op when not opted in. When opted in: + * untrusted scopes get a TTY prompt to extend the trust list, or in + * non-TTY contexts an instructional error. + */ + createGuard(): (componentId: ComponentID) => Promise { + return async (componentId: ComponentID) => { + if (!this.isOptedIn()) return; + const scopeName = componentId.scope; + if (this.isScopeTrusted(scopeName)) return; + + const deny = (): never => { + throw makeUntrustedError(scopeName, componentId); + }; + + // The user's answer is persisted to workspace.jsonc on accept; remember + // a denial so we don't re-prompt for the same scope in this run. + if (this.deniedThisRun.has(scopeName)) deny(); + + const isInteractive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); + if (!isInteractive) deny(); + + const accepted = await this.promptForTrust(scopeName, componentId); + if (!accepted) { + this.deniedThisRun.add(scopeName); + deny(); + } + await this.addTrustedScope(scopeName); + this.logger.consoleSuccess(`added "${scopeName}" to trustedScopes in workspace.jsonc`); + }; + } + + private readExt(): Record { + try { + return (this.workspace.getWorkspaceConfig().extension(WORKSPACE_ASPECT_ID, true) || {}) as Record< + string, + unknown + >; + } catch { + return {}; + } + } + + /** + * Apply `mutator` to the current `trustedScopes` list. If the mutator + * returns `null`, treat the call as a no-op (idempotent fast path). + * Uses `mergeIntoExisting` so other keys' comments are preserved. + */ + private async mutateConfiguredList(mutator: (list: string[]) => string[] | null, reason: string): Promise { + const ext = this.readExt(); + const current: string[] = Array.isArray(ext[TRUSTED_SCOPES_KEY]) ? (ext[TRUSTED_SCOPES_KEY] as string[]) : []; + const next = mutator(current); + if (next === null) return; + await this.writeExtPatch({ [TRUSTED_SCOPES_KEY]: next }, reason); + } + + private async writeExtPatch(patch: Record, reason: string): Promise { + const wsConfig = this.workspace.getWorkspaceConfig(); + wsConfig.setExtension(WORKSPACE_ASPECT_ID, patch, { mergeIntoExisting: true, ignoreVersion: true }); + await wsConfig.write({ reasonForChange: reason }); + } + + /** + * Returns the trust pattern derived from the workspace's `defaultScope`: + * - `acme.frontend` → `acme.*` (owner wildcard) + * - `my-scope` (legacy dotless) → `my-scope` (exact match) + * - empty / unset → undefined + */ + private getInferredOwnerPattern(): string | undefined { + const defaultScope = this.workspace.defaultScope; + if (!defaultScope) return undefined; + if (!defaultScope.includes('.')) return defaultScope; + const owner = defaultScope.split('.')[0]; + if (!owner) return undefined; + return `${owner}.*`; + } + + private async promptForTrust(scopeName: string, componentId: ComponentID): Promise { + try { + const response = (await prompt({ + type: 'toggle', + name: 'trust', + message: + `Aspect ${componentId.toString()} comes from scope "${scopeName}", which isn't on your workspace's trusted list.\n` + + `Trust "${scopeName}" and add it to workspace.jsonc?`, + enabled: 'Yes', + disabled: 'No', + initial: false, + // The `toggle` prompt's option type isn't exported by enquirer's main + // typings; cast just the literal so the rest of the call stays typed. + } as Parameters[0])) as { trust: boolean }; + return Boolean(response.trust); + } catch { + // user cancelled the prompt (Ctrl+C etc.) + return false; + } + } + + static isValidPattern(pattern: string): boolean { + if (!pattern || typeof pattern !== 'string') return false; + if (pattern.endsWith('.*')) { + const owner = pattern.slice(0, -2); + // wildcard must be a single owner segment ("acme.*"), not nested + // ("acme.frontend.*") — the matcher only consults scope owners. + if (owner.includes('.')) return false; + return isValidScopeName(owner); + } + // exact match: "acme.frontend" or dotless legacy "my-scope". + return isValidScopeName(pattern); + } +} + +function makeUntrustedError(scopeName: string, componentId: ComponentID): BitError { + return new BitError( + `cannot load aspect ${componentId.toString()}: scope "${scopeName}" isn't on the workspace's trusted list.\n` + + `\n` + + `to trust this scope, run:\n` + + ` bit scope trust add ${scopeName}\n` + + `or add it to "trustedScopes" under "${WORKSPACE_ASPECT_ID}" in workspace.jsonc.` + ); +} diff --git a/scopes/workspace/workspace/types.ts b/scopes/workspace/workspace/types.ts index 23b9a651ca53..0a40dc4334c5 100644 --- a/scopes/workspace/workspace/types.ts +++ b/scopes/workspace/workspace/types.ts @@ -92,6 +92,17 @@ export interface WorkspaceExtConfig { */ ignoredFiles?: string[]; + /** + * Scope-name patterns that the workspace trusts when loading aspects (envs, + * generators, etc.) imported from those scopes. The effective trust set is: + * a builtin set (e.g. `teambit.*`, `bitdev.*`) + the owner of `defaultScope` + * (e.g. `acme.frontend` → `acme.*`) + entries listed here. + * + * Patterns: exact (`acme.frontend`) or owner wildcard (`acme.*`). + * Manage via `bit scope trust [enable|disable|add|remove] [pattern]`. + */ + trustedScopes?: string[]; + /** * If set to `true`, Bit auto-syncs the local `.bitmap` to the latest scope HEAD versions * whenever the git HEAD has moved since the last sync (sentinel-driven, runs once per diff --git a/scopes/workspace/workspace/workspace-aspects-loader.ts b/scopes/workspace/workspace/workspace-aspects-loader.ts index 9c60439d18a2..5e9bfb2dde03 100644 --- a/scopes/workspace/workspace/workspace-aspects-loader.ts +++ b/scopes/workspace/workspace/workspace-aspects-loader.ts @@ -490,6 +490,11 @@ your workspace.jsonc has this component-id set. you might want to remove/change const component = aspectDef.component; if (!component) return undefined; const requireFunc = async () => { + // Honor the workspace's scope-trust hook (registered on ScopeMain). + // Workspace and scope-aspects-loader take parallel require paths. + const guard = this.scope.getAspectLoadGuard(); + if (guard) await guard(component.id); + const plugins = this.aspectLoader.getPlugins(component, localPath); if (plugins.has()) { return plugins.load(MainRuntime.name); diff --git a/scopes/workspace/workspace/workspace.main.runtime.ts b/scopes/workspace/workspace/workspace.main.runtime.ts index b97280dc3e7b..8eaef356e6c4 100644 --- a/scopes/workspace/workspace/workspace.main.runtime.ts +++ b/scopes/workspace/workspace/workspace.main.runtime.ts @@ -47,6 +47,7 @@ import { EnvsUnsetCmd } from './envs-subcommands/envs-unset.cmd'; import { PatternCommand } from './pattern.cmd'; import { EnvsReplaceCmd } from './envs-subcommands/envs-replace.cmd'; import { ScopeSetCmd } from './scope-subcommands/scope-set.cmd'; +import { ScopeTrust, ScopeTrustCmd } from './scope-trust'; import { UseCmd } from './use.cmd'; import { EnvsUpdateCmd } from './envs-subcommands/envs-update.cmd'; import { UnuseCmd } from './unuse.cmd'; @@ -334,6 +335,12 @@ export class WorkspaceMain { const scopeCommand = cli.getCommand('scope'); scopeCommand?.commands?.push(new ScopeSetCmd(workspace)); + // Workspace scope-trust: aspect-load hook wired into ScopeMain, plus the + // bit scope trust subcommand. Opt-in via workspace.jsonc. + const scopeTrust = new ScopeTrust(workspace, logger); + scope.setAspectLoadGuard(scopeTrust.createGuard()); + scopeCommand?.commands?.push(new ScopeTrustCmd(scopeTrust)); + return workspace; } static defineRuntime = 'browser';