From 9f6baaafdb21d18bb49c7a3f079c9c221d2c4219 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:24:10 +0000 Subject: [PATCH 1/3] feat(core)!: retire PluginSecurityScanner across all four surfaces (#14919) ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 (director summon #14, decision batch #42). The class was a shell that reported success: four of its five private scanners returned an empty issue list unconditionally, and the fifth matched against an in-memory vulnerability database whose only writer had zero callers -- so every scan() ever performed answered status: 'passed' with a perfect score, for a malicious plugin as readily as a benign one. A security control that cannot fail is worse than none, because callers rely on it. - delete packages/core/examples/phase2-integration.ts (the only constructor) - delete src/security/security-scanner.ts; drop its export block from src/security/index.ts, leaving a tombstone naming the retirement - rewrite PHASE2_IMPLEMENTATION.md section 6 to state plainly that plugin security scanning is NOT a platform capability, and drop the two capability claims elsewhere in the same document that outlived their subject - delete the FOLLOW-UPS.md row, repair the paragraph that existed only to compound it, and correct the neighbouring row whose evidence the deletion falsified - pin the retirement as an export-list assertion on both barrels Repair was refused by name: a real vulnerability scanner is a feature with a design surface, not a defect fix. There is no replacement export. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/plugin-security-scanner-retired.md | 70 +++ docs/qa/platform-checklist/FOLLOW-UPS.md | 22 +- packages/core/PHASE2_IMPLEMENTATION.md | 89 ++-- packages/core/examples/phase2-integration.ts | 407 ------------------ packages/core/src/security/index.ts | 18 +- .../security-scanner-retirement.pin.test.ts | 76 ++++ .../core/src/security/security-scanner.ts | 367 ---------------- 7 files changed, 210 insertions(+), 839 deletions(-) create mode 100644 .changeset/plugin-security-scanner-retired.md delete mode 100644 packages/core/examples/phase2-integration.ts create mode 100644 packages/core/src/security/security-scanner-retirement.pin.test.ts delete mode 100644 packages/core/src/security/security-scanner.ts diff --git a/.changeset/plugin-security-scanner-retired.md b/.changeset/plugin-security-scanner-retired.md new file mode 100644 index 0000000000..19aed6d511 --- /dev/null +++ b/.changeset/plugin-security-scanner-retired.md @@ -0,0 +1,70 @@ +--- +"@objectstack/core": minor +--- + +feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919) + + + +**BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`, +together with its two companion types `ScanTarget` and `SecurityIssue`. Landing +as `minor` under the repo's launch-window convention for breaking changes. +**There is no replacement**, and none is planned. + +⚠️ **The out-of-repo consumer population for these three exports is NOT +MEASURED.** This changeset can state only what was measured *inside* the +sources this repo can read: zero constructors in objectstack, zero in objectui +at the pinned sha, and zero in the deleted example itself. How many published +consumers of `@objectstack/core` import the class is unknown — no download, +dependent or source telemetry was consulted. Read the removal as breaking for +an unmeasured population, not as a removal proven to break nobody. + +## Why it was removed rather than repaired + +The class was a shell that reported success. `scan()` composed five private +scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, +`scanConfiguration`) allocated an empty issue array, logged, and returned it +with no code in between — none could report a finding for any input. The fifth, +`scanDependencies`, ran a real loop but matched only against an in-memory +vulnerability database whose sole writer, the public `addVulnerability`, had +zero callers; `updateVulnerabilityDatabase()` logged twice and fetched nothing. +The database was therefore empty on every code path that has ever executed, so +no issue was ever produced, the score stayed 100, and the result was +`status: 'passed'` for every plugin the scanner was ever handed — a malicious +one included. + +A security control that cannot fail is worse than no security control, because +callers rely on it. Repair — writing a real vulnerability scanner — was refused +by name: it is a feature with a design surface and no demand, not a defect fix. + +## FROM → TO + +```ts +// FROM — compiles today, and passes every plugin it is given +import { PluginSecurityScanner } from '@objectstack/core'; + +const scanner = new PluginSecurityScanner(kernel.logger); +const result = await scanner.scan({ pluginId, version, dependencies }); +if (result.status === 'passed') { await kernel.use(plugin); } + +// TO — delete it. The condition above was always true. +await kernel.use(plugin); +``` + +**The one-line fix:** delete the import and every call; no symbol replaces it. +If your code branched on `result.status`, take the `'passed'` branch — that is +the only branch it ever took. + +**If you were relying on it for actual security**, you were not getting any. +Audit dependencies with the tools built for it (`npm audit` / `pnpm audit`, +Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited +third-party plugin as untrusted code. What ObjectStack does still enforce is +artifact **integrity and signatures** (`verifyPluginArtifactIntegrity`, the +plugin signature verifier — "is this what the publisher signed?", never "is +this safe?"), explicit plugin **permissions**, and the sandbox **resource +limits**; all three are unchanged. + +Removed under ADR-0049 enforce-or-remove, per the maintainer ruling of +2026-09-05 (director summon #14, decision batch #42). The retirement is pinned +as an export-list assertion on both barrels in +`packages/core/src/security/security-scanner-retirement.pin.test.ts`. diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index d855c63cd3..85c3a20bdc 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -183,19 +183,23 @@ governance hole. | surface | evidence | the deadness, precisely | |---|---|---| -| `PluginSecurityScanner` (`packages/core/src/security/security-scanner.ts`) | zero constructors outside `packages/core/examples/`; not in plugin-loader, service-package, rest, or any CLI path | Exported dead code on the PUBLIC barrel (`packages/core/src/index.ts` re-exports `./security/index.js`). 3 of 5 scan methods are empty stubs; `scanDependencies` has a real loop whose only data source (`addVulnerability`, ``) has zero callers; `updateVulnerabilityDatabase` (``) is a log-only no-op. | -| `KernelSecurityScanResult` / `KernelSecurityVulnerability` / `PluginSecurityManifest.scanResults` (`packages/spec/src/kernel/plugin-security-advanced.zod.ts,476,625`) | no `.parse`/`.safeParse` site anywhere; only consumer is the dead scanner (type-only import) | 22 rows published to `packages/spec/authorable-surface/kernel.json` with zero authors and zero parsers. The whole `plugin-security-advanced` module has no runtime consumer. | +| `KernelSecurityScanResult` / `KernelSecurityVulnerability` / `PluginSecurityManifest.scanResults` (`packages/spec/src/kernel/plugin-security-advanced.zod.ts,476,625`) | no `.parse`/`.safeParse` site anywhere; **zero** consumers of any kind since #14919 retired the dead scanner that was the last type-only importer | 22 rows published to `packages/spec/authorable-surface/kernel.json` with zero authors and zero parsers. The whole `plugin-security-advanced` module has no runtime consumer. | | `PluginQualityMetrics.securityScan` (`packages/spec/src/kernel/plugin-registry.zod.ts`) | spec self-test only | Nothing reads or writes it at runtime. | | Marketplace/incident scan vocab (`marketplace.zod.ts` 'scanning' status, `marketplace-admin.zod.ts,193`, `incident-response.zod.ts` 'malware') | declared-only enum members, no producer in this repo | Cloud/EE surface. Same shape as the `'failed'`/`'expired'` upload statuses #7667 had to close: declared, published, no writer. | | MetadataPlugin FS scan + `metadata-fs` boot scan (`packages/metadata/src/plugin.ts,270` — `watch ?? false`; `packages/runtime/src/standalone-stack.ts` hard-off; `metadata-fs` unwired from any `os dev`/`os serve` lane) | unit-pinned in-package only | No reachable fixture from any shipped boot; if a future lane wires `metadata-fs`, the boot-scan/watcher dot-entry divergence is the risk to test first. | -Compounding the first row: `packages/core/PHASE2_IMPLEMENTATION.md` advertises -the scanner as a working feature, tells readers to import from `@objectstack/core/security` -(a subpath `packages/core/package.json` does not export), and its sample fields -(`scanResult.passed`/`.score`/`.summary.critical`) do not exist on the actual schema — -the example (`examples/phase2-integration.ts`) sits outside every tsconfig and is never -typechecked. Enforce or remove; if removed, the spec-property-retirement playbook applies -to the authorable-surface rows. +The scanner row above was **CLOSED by removal** in #14919 (maintainer ruling, +director summon #14, decision batch #42): the class, its barrel export, its +`packages/core/examples/` demonstration and the `PHASE2_IMPLEMENTATION.md` section that +advertised it are gone, and that section now states plainly that plugin security scanning +is not a platform capability. Repair was refused by name. Do not re-derive it. + +**What SURVIVES that removal, in the same document.** `PHASE2_IMPLEMENTATION.md` sections +4 and 5 still tell readers to `import … from '@objectstack/core/security'` — a subpath +`packages/core/package.json` declares in no `exports` entry, so it resolves for no +consumer of the published package. Deliberately left: the two repairs (declare the +subpath, or repoint both sections at the root barrel) differ in whether they widen the +published contract, which is not a lane's call. Filed separately. ### 7b. Docs drift (PD#10 class — file as docs fixes, not checklist items) diff --git a/packages/core/PHASE2_IMPLEMENTATION.md b/packages/core/PHASE2_IMPLEMENTATION.md index e68fbe78f9..d1456cfd76 100644 --- a/packages/core/PHASE2_IMPLEMENTATION.md +++ b/packages/core/PHASE2_IMPLEMENTATION.md @@ -263,52 +263,41 @@ const { withinLimits, violations } = sandbox.checkResourceLimits('my-plugin'); const usage = sandbox.getResourceUsage('my-plugin'); ``` -### 6. Security Scanner (`security/security-scanner.ts`) - -The Security Scanner performs comprehensive security analysis of plugins. - -**Features:** -- Code vulnerability scanning -- Dependency vulnerability detection (CVE database integration) -- Malware pattern detection -- License compliance checking -- Configuration security analysis -- Security scoring (0-100) -- Issue categorization (critical, high, medium, low, info) - -**Usage:** - -```typescript -import { PluginSecurityScanner } from '@objectstack/core/security'; - -const scanner = new PluginSecurityScanner(logger); - -// Perform security scan -const result = await scanner.scan({ - pluginId: 'my-plugin', - version: '1.0.0', - files: ['src/**/*.ts'], - dependencies: { - 'express': '4.18.0', - 'lodash': '4.17.21', - }, -}); - -console.log(`Security Score: ${result.score}/100`); -console.log(`Passed: ${result.passed}`); -console.log(`Issues:`, result.summary); - -// Add vulnerability to database -scanner.addVulnerability('lodash', '4.17.20', { - cve: 'CVE-2021-23337', - severity: 'high', - affectedVersions: ['<=4.17.20'], - fixedIn: ['4.17.21'], -}); - -// Update vulnerability database -await scanner.updateVulnerabilityDatabase(); -``` +### 6. Plugin security scanning — NOT a platform capability + +**ObjectStack does not scan plugins for vulnerabilities, malware or license +compliance, and it never has.** There is no scanner to import, no security +score, and no CVE database. Nothing in the runtime, the CLI, the plugin loader +or the REST layer inspects a plugin's code, its dependencies or its +configuration for security issues. + +This section used to document a `PluginSecurityScanner` class exported from +`@objectstack/core`. That class was retired in #14919 under ADR-0049 +enforce-or-remove, because it was a shell that reported success rather than a +scanner that found anything: four of its five scan methods returned an empty +issue list unconditionally, and the fifth matched dependencies against an +in-memory vulnerability database whose only writer had zero callers anywhere. +Every `scan()` it was ever asked to perform therefore answered +`status: 'passed'` with a perfect score — for a benign plugin and a malicious +one alike. Advertising it here was the failure Prime Directive #10 names: +advertising a capability the runtime does not deliver. It has no replacement, +and none is planned; building a real scanner is a feature with a design surface +of its own, not a repair. + +**What the platform does enforce**, and what to use instead of a scan: + +- **Artifact integrity and signatures** — `verifyPluginArtifactIntegrity` and + the signature verifier (`security/plugin-artifact-integrity.ts`, + `security/plugin-artifact-signature.ts`) answer *"is this the artifact the + publisher signed?"*. They do not answer *"is this artifact safe?"*. +- **Permissions** — section 4 above. A plugin gets what it is explicitly + granted. +- **Sandboxing** — section 5 above. Resource and access limits at run time. + +For dependency vulnerabilities, use the tools built for it against your own +project — `npm audit`, `pnpm audit`, GitHub's Dependabot / Advisory Database, +or OSV. Treat an unaudited third-party plugin as untrusted code, because +nothing here has audited it for you. ## Integration with Kernel @@ -321,8 +310,7 @@ import { HotReloadManager, DependencyResolver, PluginPermissionManager, - PluginSandboxRuntime, - PluginSecurityScanner + PluginSandboxRuntime } from '@objectstack/core'; const kernel = new ObjectKernel({ logger: { level: 'info' } }); @@ -333,7 +321,6 @@ const hotReload = new HotReloadManager(kernel.logger); const depResolver = new DependencyResolver(kernel.logger); const permManager = new PluginPermissionManager(kernel.logger); const sandbox = new PluginSandboxRuntime(kernel.logger); -const scanner = new PluginSecurityScanner(kernel.logger); // Register plugins with enhanced features // ... plugin registration code ... @@ -362,13 +349,13 @@ npm test - State preservation uses checksums for integrity verification - Dependency resolution uses efficient topological sorting - Resource monitoring is throttled (default 5 seconds) -- Security scanning can be run asynchronously ## Security - All permissions must be explicitly granted - Sandbox provides multiple isolation levels -- Security scanner integrates with CVE databases +- **No plugin security scanning.** The platform performs no vulnerability, + malware or license analysis of a plugin — see section 6 - Resource limits prevent DoS attacks - State preservation uses checksums to detect tampering diff --git a/packages/core/examples/phase2-integration.ts b/packages/core/examples/phase2-integration.ts deleted file mode 100644 index 853566e993..0000000000 --- a/packages/core/examples/phase2-integration.ts +++ /dev/null @@ -1,407 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Phase 2 Integration Example - * - * This example demonstrates how to use all Phase 2 components together - * in a real-world scenario. - */ - -import { realpathSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - ObjectKernel, - PluginHealthMonitor, - HotReloadManager, - PluginPermissionManager, - PluginSandboxRuntime, - PluginSecurityScanner, - createLogger -} from '../src/index.js'; - -import type { Plugin, ObjectLogger } from '../src/index.js'; -// [#14613] The PARSED variants, because that is what the methods below take: -// `PluginHealthMonitor.registerPlugin` is declared over `PluginHealthCheckParsed` -// and `HotReloadManager.registerPlugin` over `HotReloadConfigParsed` -// (`src/health-monitor.ts`, `src/hot-reload.ts`). The unparsed shapes have every -// key optional, so passing them was a real type error this file carried while no -// tsc program read it. -import type { - PluginHealthCheckParsed, - HotReloadConfigParsed, - PluginPermissionSet, - SandboxConfig -} from '@objectstack/spec/kernel'; - -/** - * Example: Enterprise Plugin Platform with Phase 2 Features - */ -export class EnterprisePluginPlatform { - private kernel: ObjectKernel; - private logger: ObjectLogger; - private healthMonitor: PluginHealthMonitor; - private hotReload: HotReloadManager; - private permManager: PluginPermissionManager; - private sandbox: PluginSandboxRuntime; - private scanner: PluginSecurityScanner; - - constructor() { - // Initialize kernel - this.kernel = new ObjectKernel({ - logger: { - level: 'info', - name: 'EnterprisePluginPlatform', - }, - }); - - // [#14613] The example's OWN logger. `ObjectKernel.logger` is private, so - // every one of the 20 reads this file made of it was a type error -- - // invisible until this package declared a `typecheck` script, because no - // tsc program had ever compiled this directory. - this.logger = createLogger({ level: 'info', name: 'EnterprisePluginPlatform' }); - - // Initialize Phase 2 components - this.healthMonitor = new PluginHealthMonitor(this.logger); - this.hotReload = new HotReloadManager(this.logger); - this.permManager = new PluginPermissionManager(this.logger); - this.sandbox = new PluginSandboxRuntime(this.logger); - this.scanner = new PluginSecurityScanner(this.logger); - } - - /** - * Install and configure a plugin with full Phase 2 features - */ - async installPlugin( - plugin: Plugin, - config: { - health?: PluginHealthCheckParsed; - hotReload?: HotReloadConfigParsed; - permissions?: PluginPermissionSet; - sandbox?: SandboxConfig; - securityScan?: boolean; - } - ): Promise { - const pluginName = plugin.name; - const pluginVersion = plugin.version || '1.0.0'; - - this.logger.info(`Installing plugin: ${pluginName} v${pluginVersion}`); - - // Step 1: Security Scan - if (config.securityScan !== false) { - this.logger.info('Running security scan...'); - - const scanResult = await this.scanner.scan({ - pluginId: pluginName, - version: pluginVersion, - // In real implementation, would provide actual files and dependencies - }); - - // [#14613] `KernelSecurityScanResult` carries `status` and per-severity - // COUNTS; it has never had `passed`, `score`, `summary.critical` or - // `summary.high`. This block read four members that do not exist. - if (scanResult.status !== 'passed') { - throw new Error( - `Security scan ${scanResult.status}: ` + - `${scanResult.summary.totalVulnerabilities} vulnerability(ies), ` + - `Critical: ${scanResult.summary.criticalCount}, ` + - `High: ${scanResult.summary.highCount}` - ); - } - - this.logger.info( - `Security scan passed: ${scanResult.summary.totalVulnerabilities} vulnerability(ies)` - ); - } - - // Step 2: Register Permissions - if (config.permissions) { - this.permManager.registerPermissions(pluginName, config.permissions); - - // Auto-grant all permissions (in production, would prompt user) - this.permManager.grantAllPermissions(pluginName, 'system'); - - this.logger.info( - `Permissions registered: ${config.permissions.permissions.length} permissions` - ); - } - - // Step 3: Create Sandbox - if (config.sandbox) { - this.sandbox.createSandbox(pluginName, config.sandbox); - this.logger.info(`Sandbox created: ${config.sandbox.level} level`); - } - - // Step 4: Register for Health Monitoring - if (config.health) { - this.healthMonitor.registerPlugin(pluginName, config.health); - this.logger.info( - `Health monitoring configured: ${config.health.interval}ms interval` - ); - } - - // Step 5: Register for Hot Reload - if (config.hotReload) { - this.hotReload.registerPlugin(pluginName, config.hotReload); - this.logger.info( - `Hot reload enabled: ${config.hotReload.stateStrategy} state strategy` - ); - } - - // Step 6: Register with Kernel - this.kernel.use(plugin); - - this.logger.info(`Plugin ${pluginName} installed successfully`); - } - - /** - * Bootstrap the platform - */ - async start(): Promise { - // Bootstrap kernel (will init and start all plugins) - await this.kernel.bootstrap(); - - // Start health monitoring for all registered plugins - for (const [pluginName, plugin] of this.kernel['plugins']) { - if (this.healthMonitor['healthChecks'].has(pluginName)) { - this.healthMonitor.startMonitoring(pluginName, plugin); - } - } - - this.logger.info('Platform started successfully'); - } - - /** - * Shutdown the platform - */ - async shutdown(): Promise { - this.logger.info('Shutting down platform...'); - - // Stop health monitoring - this.healthMonitor.shutdown(); - - // Shutdown sandbox - this.sandbox.shutdown(); - - // Shutdown kernel - await this.kernel.shutdown(); - - this.logger.info('Platform shutdown complete'); - } - - /** - * Get platform health status - */ - getHealthStatus(): Record { - const statuses = this.healthMonitor.getAllHealthStatuses(); - const summary: Record = { - totalPlugins: statuses.size, - healthy: 0, - degraded: 0, - unhealthy: 0, - failed: 0, - plugins: {}, - }; - - for (const [pluginName, status] of statuses) { - summary[status]++; - summary.plugins[pluginName] = { - status, - report: this.healthMonitor.getHealthReport(pluginName), - }; - } - - return summary; - } - - /** - * Perform hot reload of a plugin - */ - async reloadPlugin(pluginName: string): Promise { - this.logger.info(`Hot reloading plugin: ${pluginName}`); - - const plugin = this.kernel['plugins'].get(pluginName); - if (!plugin) { - throw new Error(`Plugin not found: ${pluginName}`); - } - - // Get current state (simplified - would need plugin cooperation) - const getState = () => ({ - timestamp: Date.now(), - // ... plugin state - }); - - // Restore state (simplified - would need plugin cooperation) - const restoreState = (state: Record) => { - this.logger.info(`Restoring state from ${new Date(state.timestamp)}`); - // ... restore plugin state - }; - - await this.hotReload.reloadPlugin( - pluginName, - plugin, - plugin.version || '1.0.0', - getState, - restoreState - ); - - this.logger.info(`Plugin ${pluginName} reloaded successfully`); - } -} - -/** - * Example Usage - */ -async function example() { - const platform = new EnterprisePluginPlatform(); - - // Define a sample plugin - const myPlugin: Plugin = { - name: 'com.example.my-plugin', - version: '1.0.0', - dependencies: ['com.objectstack.engine.objectql'], - - async init(ctx) { - ctx.logger.info('MyPlugin initializing...'); - // Initialize plugin - }, - - async start(ctx) { - ctx.logger.info('MyPlugin starting...'); - // Start plugin services - }, - - async destroy() { - console.log('MyPlugin destroying...'); - // Cleanup - }, - }; - - // Install plugin with full Phase 2 features - await platform.installPlugin(myPlugin, { - // Health monitoring - health: { - interval: 30000, // Check every 30 seconds - timeout: 5000, - failureThreshold: 3, - successThreshold: 1, - // [#12032] `autoRestart` / `maxRestartAttempts` / `restartBackoff` - // removed: the monitor never restarted anything (it called - // `plugin.destroy()` and reported the corpse `healthy`), so the keys - // were retired under ADR-0049. Act on `getHealthStatus()` in the host. - }, - - // Hot reload - hotReload: { - enabled: true, - debounceDelay: 1000, - preserveState: true, - stateStrategy: 'memory', - shutdownTimeout: 30000, - }, - - // Permissions - permissions: { - permissions: [ - { - id: 'read-data', - resource: 'data.object', - actions: ['read'], - scope: 'plugin', - description: 'Read object data', - required: true, - }, - { - id: 'write-data', - resource: 'data.object', - actions: ['create', 'update'], - scope: 'plugin', - description: 'Write object data', - required: false, - }, - ], - defaultGrant: 'prompt', - }, - - // Sandbox - sandbox: { - enabled: true, - level: 'standard', - filesystem: { - mode: 'restricted', - allowedPaths: ['/app/plugins/my-plugin'], - deniedPaths: ['/etc', '/root'], - }, - network: { - mode: 'restricted', - allowedHosts: ['api.example.com'], - maxConnections: 10, - }, - process: { - allowSpawn: false, - }, - memory: { - maxHeap: 100 * 1024 * 1024, // 100 MB - }, - }, - - // Security scanning - securityScan: true, - }); - - // Start platform - await platform.start(); - - // Get health status - const health = platform.getHealthStatus(); - console.log('Platform Health:', health); - - // Simulate hot reload after some time - setTimeout(async () => { - await platform.reloadPlugin('com.example.my-plugin'); - }, 60000); - - // Shutdown on SIGINT - process.on('SIGINT', async () => { - await platform.shutdown(); - process.exit(0); - }); -} - -// ─── entry guard ─────────────────────────────────────────────────────── -// ⛔ NOT ``import.meta.url === `file://${process.argv[1]}` ``. Node symlink-resolves -// `import.meta.url` but leaves `process.argv[1]` exactly as the caller typed it, and -// the template also skips the percent-encoding `pathToFileURL` applies — so that -// spelling goes INERT (exit 0, no output) through a symlink AND on any checkout path -// containing a character that needs encoding (a `#` in a parent directory name is -// enough, with no symlink involved). Compare RESOLVED PATHS, never URL strings. -// -// Same predicate as `packages/cli/src/utils/invocation.ts` (`isProcessEntry`) and -// `scripts/invoked-as.mjs` (`invokedAs`). Spelled out rather than imported because -// neither home is legally reachable from this file — the PR for #10269 carries the -// boundary measurement. ⚠️ Two predicates answering this question differently IS the -// defect this closes; change one, change all of them. -function isProcessEntry(): boolean { - const entryArg = process.argv[1]; - if (!entryArg) return false; // `node --eval` / the REPL - const self = resolve(fileURLToPath(import.meta.url)); - const entry = resolve(entryArg); - // `node ` gives the ENTRY ARGUMENT, and only it, directory resolution. - const candidates = [entry, join(entry, 'index.js'), join(entry, 'index.mjs'), join(entry, 'index.ts')]; - if (candidates.includes(self)) return true; - const realSelf = realOrSelf(self); - return candidates.some((candidate) => realOrSelf(candidate) === realSelf); -} - -/** `realpathSync`, degrading to the input for a path that cannot be read. */ -function realOrSelf(p: string): string { - try { - return realpathSync(p); - } catch { - return p; - } -} - -if (isProcessEntry()) { - example().catch(console.error); -} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index b1db5feb45..d8691e426f 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -75,11 +75,19 @@ export { type ResourceUsage, } from './sandbox-runtime.js'; -export { - PluginSecurityScanner, - type ScanTarget, - type SecurityIssue, -} from './security-scanner.js'; +// `./security-scanner.js` was RETIRED in #14919 (ADR-0049 enforce-or-remove). +// `PluginSecurityScanner` and its two companion types (`ScanTarget`, +// `SecurityIssue`) shipped on this barrel and on `@objectstack/core`'s root +// barrel with zero constructors anywhere in this repo, in objectui at the +// pinned sha, or in any runtime, CLI or plugin-loader path -- the only one +// ever written stood in `packages/core/examples/phase2-integration.ts`, a +// demonstration. Four of its five scan methods could not return an issue at +// all, and the fifth read a vulnerability database whose +// only writer (`addVulnerability`) had zero callers -- so a `scan()` answered +// `status: 'passed'` for every plugin ever handed to it, including a malicious +// one. Plugin security scanning is NOT a platform capability; there is no +// replacement export. Repairing it -- a real vulnerability scanner -- is a +// feature with a design surface, and was refused rather than deferred. export { API_KEY_PREFIX, diff --git a/packages/core/src/security/security-scanner-retirement.pin.test.ts b/packages/core/src/security/security-scanner-retirement.pin.test.ts new file mode 100644 index 0000000000..2e2192dc6f --- /dev/null +++ b/packages/core/src/security/security-scanner-retirement.pin.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import * as coreBarrel from '../index.js'; +import * as securityBarrel from './index.js'; + +// ─── [#14919] `PluginSecurityScanner` is RETIRED ──────────────────────────── +// +// ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 (director summon +// #14, decision batch #42). The class, its two companion types (`ScanTarget`, +// `SecurityIssue`), its `packages/core/examples/phase2-integration.ts` +// demonstration and the `PHASE2_IMPLEMENTATION.md` section that advertised it +// are all gone. There is NO replacement export, and repair — writing a real +// vulnerability scanner — was refused by name: it is a feature with a design +// surface, not a repair. +// +// WHAT IT ACTUALLY DID, which is why removal beat repair. `scan()` composed +// five private scanners and scored the result. Four of them — `scanCode`, +// `scanMalware`, `scanLicenses`, `scanConfiguration` — allocated an empty +// issue array, logged, and returned it, with no code between; they could not +// report a finding for any input. The fifth, `scanDependencies`, ran a real +// loop, but only ever matched against `vulnerabilityDb`, an in-memory Map +// whose sole writer was the public `addVulnerability` — which had zero callers +// in this repo, in objectui at the pinned sha, and in the example itself. +// `updateVulnerabilityDatabase()` logged twice and fetched nothing. So the +// database was empty on every code path that has ever executed, no issue was +// ever produced, the score stayed 100, and `status` was `'passed'` for every +// plugin the scanner was ever handed — a malicious one included. That is the +// Prime Directive #10 shape exactly: a security capability advertised on the +// public barrel and delivered by nothing. +// +// ⛔ WHY THIS IS AN EXPORT-LIST ASSERTION AND NOT A GREP. The ruling asks for +// the symbol's absence from a published SURFACE, and a grep cannot answer +// that: the name legitimately survives in this file, in the tombstone comment +// on `./index.ts`, and in the retired section of `PHASE2_IMPLEMENTATION.md` — +// a grep pin would go red on the tombstones that exist to explain the +// retirement, and would stay green if someone re-exported the class under a +// different local name. Reading the barrels' own export lists asks the +// question a consumer's `import` asks. +// +// ⚠️ ON THE SECOND SURFACE. The ruling names `@objectstack/core/security`. +// Measured at head: `packages/core/package.json` declares exactly two +// `exports` entries, `.` and `./logger` — there is no `./security` subpath, +// so that specifier resolves for no consumer of the published package and +// never has (`PHASE2_IMPLEMENTATION.md` sections 4 and 5 still teach it; filed +// separately, since the two repairs differ in whether they widen the published +// contract). This pin therefore reads the in-repo module that a `./security` +// subpath would name — `src/security/index.ts` — which is the surface that +// would carry the symbol outward the moment anyone declares the subpath. +// Pinning it here means the retirement survives that declaration. + +const RETIRED = 'PluginSecurityScanner'; + +describe('[#14919] PluginSecurityScanner retirement', () => { + it(`is absent from @objectstack/core's export list`, () => { + expect(Object.keys(coreBarrel)).not.toContain(RETIRED); + }); + + it(`is absent from the security barrel's export list (@objectstack/core/security)`, () => { + expect(Object.keys(securityBarrel)).not.toContain(RETIRED); + }); + + // The assertions above are only worth anything if these export lists are + // real — a barrel that failed to load, or a namespace object read the wrong + // way, would answer "absent" for every name ever asked about and pass + // forever. Two survivors from the SAME retired file's neighbourhood prove + // the lists are populated and that this is the surface the class stood on: + // `PluginSandboxRuntime` is the export block immediately above the retired + // one in `./index.ts`, and it reaches the root barrel by the same + // `export * from './security/index.js'` line the scanner used. + it('reads populated export lists (control)', () => { + expect(Object.keys(securityBarrel)).toContain('PluginSandboxRuntime'); + expect(Object.keys(coreBarrel)).toContain('PluginSandboxRuntime'); + }); +}); diff --git a/packages/core/src/security/security-scanner.ts b/packages/core/src/security/security-scanner.ts deleted file mode 100644 index 6efaa8a77e..0000000000 --- a/packages/core/src/security/security-scanner.ts +++ /dev/null @@ -1,367 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { - KernelSecurityVulnerability, - KernelSecurityScanResult -} from '@objectstack/spec/kernel'; -import type { ObjectLogger } from '../logger.js'; - -/** - * Scan Target - */ -export interface ScanTarget { - pluginId: string; - version: string; - files?: string[]; - dependencies?: Record; -} - -/** - * Security Issue - */ -export interface SecurityIssue { - id: string; - severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; - category: 'vulnerability' | 'malware' | 'license' | 'code-quality' | 'configuration'; - title: string; - description: string; - location?: { - file?: string; - line?: number; - column?: number; - }; - remediation?: string; - cve?: string; - cvss?: number; -} - -/** - * Plugin Security Scanner - * - * Scans plugins for security vulnerabilities, malware, and license issues - */ -export class PluginSecurityScanner { - private logger: ObjectLogger; - - // Known vulnerabilities database (CVE cache) - private vulnerabilityDb = new Map(); - - // Scan results cache - private scanResults = new Map(); - - private passThreshold: number = 70; - - constructor(logger: ObjectLogger, config?: { passThreshold?: number }) { - this.logger = logger.child({ component: 'SecurityScanner' }); - if (config?.passThreshold !== undefined) { - this.passThreshold = config.passThreshold; - } - } - - /** - * Perform a comprehensive security scan on a plugin - */ - async scan(target: ScanTarget): Promise { - this.logger.info('Starting security scan', { - pluginId: target.pluginId, - version: target.version - }); - - const issues: SecurityIssue[] = []; - - try { - // 1. Scan for code vulnerabilities - const codeIssues = await this.scanCode(target); - issues.push(...codeIssues); - - // 2. Scan dependencies for known vulnerabilities - const depIssues = await this.scanDependencies(target); - issues.push(...depIssues); - - // 3. Scan for malware patterns - const malwareIssues = await this.scanMalware(target); - issues.push(...malwareIssues); - - // 4. Check license compliance - const licenseIssues = await this.scanLicenses(target); - issues.push(...licenseIssues); - - // 5. Check configuration security - const configIssues = await this.scanConfiguration(target); - issues.push(...configIssues); - - // Calculate security score (0-100, higher is better) - const score = this.calculateSecurityScore(issues); - - const result: KernelSecurityScanResult = { - timestamp: new Date().toISOString(), - scanner: { name: 'ObjectStack Security Scanner', version: '1.0.0' }, - status: score >= this.passThreshold ? 'passed' : 'failed', - vulnerabilities: issues.map(issue => ({ - id: issue.id, - severity: issue.severity, - category: issue.category, - title: issue.title, - description: issue.description, - location: issue.location ? `${issue.location.file}:${issue.location.line}` : undefined, - remediation: issue.remediation, - affectedVersions: [], - exploitAvailable: false, - patchAvailable: false, - })), - summary: { - totalVulnerabilities: issues.length, - criticalCount: issues.filter(i => i.severity === 'critical').length, - highCount: issues.filter(i => i.severity === 'high').length, - mediumCount: issues.filter(i => i.severity === 'medium').length, - lowCount: issues.filter(i => i.severity === 'low').length, - infoCount: issues.filter(i => i.severity === 'info').length, - }, - }; - - this.scanResults.set(`${target.pluginId}:${target.version}`, result); - - this.logger.info('Security scan complete', { - pluginId: target.pluginId, - score, - status: result.status, - summary: result.summary - }); - - return result; - } catch (error) { - this.logger.error('Security scan failed', { - pluginId: target.pluginId, - error - }); - - throw error; - } - } - - /** - * Scan code for vulnerabilities - */ - private async scanCode(target: ScanTarget): Promise { - const issues: SecurityIssue[] = []; - - // In a real implementation, this would: - // - Parse code with AST (e.g., using @typescript-eslint/parser) - // - Check for dangerous patterns (eval, Function constructor, etc.) - // - Check for XSS vulnerabilities - // - Check for SQL injection patterns - // - Check for insecure crypto usage - // - Check for path traversal vulnerabilities - - this.logger.debug('Code scan complete', { - pluginId: target.pluginId, - issuesFound: issues.length - }); - - return issues; - } - - /** - * Scan dependencies for known vulnerabilities - */ - private async scanDependencies(target: ScanTarget): Promise { - const issues: SecurityIssue[] = []; - - if (!target.dependencies) { - return issues; - } - - // In a real implementation, this would: - // - Query npm audit API - // - Check GitHub Advisory Database - // - Check Snyk vulnerability database - // - Check OSV (Open Source Vulnerabilities) - - for (const [depName, version] of Object.entries(target.dependencies)) { - const vulnKey = `${depName}@${version}`; - const vulnerability = this.vulnerabilityDb.get(vulnKey); - - if (vulnerability) { - issues.push({ - id: `vuln-${vulnerability.cve || depName}`, - severity: vulnerability.severity, - category: 'vulnerability', - title: `Vulnerable dependency: ${depName}`, - description: `${depName}@${version} has known security vulnerabilities`, - remediation: vulnerability.fixedIn - ? `Upgrade to ${vulnerability.fixedIn.join(' or ')}` - : 'No fix available', - cve: vulnerability.cve, - }); - } - } - - this.logger.debug('Dependency scan complete', { - pluginId: target.pluginId, - dependencies: Object.keys(target.dependencies).length, - vulnerabilities: issues.length - }); - - return issues; - } - - /** - * Scan for malware patterns - */ - private async scanMalware(target: ScanTarget): Promise { - const issues: SecurityIssue[] = []; - - // In a real implementation, this would: - // - Check for obfuscated code - // - Check for suspicious network activity patterns - // - Check for crypto mining patterns - // - Check for data exfiltration patterns - // - Use ML-based malware detection - // - Check file hashes against known malware databases - - this.logger.debug('Malware scan complete', { - pluginId: target.pluginId, - issuesFound: issues.length - }); - - return issues; - } - - /** - * Check license compliance - */ - private async scanLicenses(target: ScanTarget): Promise { - const issues: SecurityIssue[] = []; - - if (!target.dependencies) { - return issues; - } - - // In a real implementation, this would: - // - Check license compatibility - // - Detect GPL contamination - // - Flag proprietary dependencies - // - Check for missing licenses - // - Verify SPDX identifiers - - this.logger.debug('License scan complete', { - pluginId: target.pluginId, - issuesFound: issues.length - }); - - return issues; - } - - /** - * Check configuration security - */ - private async scanConfiguration(target: ScanTarget): Promise { - const issues: SecurityIssue[] = []; - - // In a real implementation, this would: - // - Check for hardcoded secrets - // - Check for weak permissions - // - Check for insecure defaults - // - Check for missing security headers - // - Check CSP policies - - this.logger.debug('Configuration scan complete', { - pluginId: target.pluginId, - issuesFound: issues.length - }); - - return issues; - } - - /** - * Calculate security score based on issues - */ - private calculateSecurityScore(issues: SecurityIssue[]): number { - // Start with perfect score - let score = 100; - - // Deduct points based on severity - for (const issue of issues) { - switch (issue.severity) { - case 'critical': - score -= 20; - break; - case 'high': - score -= 10; - break; - case 'medium': - score -= 5; - break; - case 'low': - score -= 2; - break; - case 'info': - score -= 0; - break; - } - } - - // Ensure score doesn't go below 0 - return Math.max(0, score); - } - - /** - * Add a vulnerability to the database - */ - addVulnerability( - packageName: string, - version: string, - vulnerability: KernelSecurityVulnerability - ): void { - const key = `${packageName}@${version}`; - this.vulnerabilityDb.set(key, vulnerability); - - this.logger.debug('Vulnerability added to database', { - package: packageName, - version, - cve: vulnerability.cve - }); - } - - /** - * Get scan result from cache - */ - getScanResult(pluginId: string, version: string): KernelSecurityScanResult | undefined { - return this.scanResults.get(`${pluginId}:${version}`); - } - - /** - * Clear scan results cache - */ - clearCache(): void { - this.scanResults.clear(); - this.logger.debug('Scan results cache cleared'); - } - - /** - * Update vulnerability database from external source - */ - async updateVulnerabilityDatabase(): Promise { - this.logger.info('Updating vulnerability database'); - - // In a real implementation, this would: - // - Fetch from GitHub Advisory Database - // - Fetch from npm audit - // - Fetch from NVD (National Vulnerability Database) - // - Parse and cache vulnerability data - - this.logger.info('Vulnerability database updated', { - entries: this.vulnerabilityDb.size - }); - } - - /** - * Shutdown security scanner - */ - shutdown(): void { - this.vulnerabilityDb.clear(); - this.scanResults.clear(); - - this.logger.info('Security scanner shutdown complete'); - } -} From 5b3d1a9ed7406ddaec216d818d1330a3b88ae4e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:08:40 +0000 Subject: [PATCH 2/3] feat(spec): register the PluginSecurityScanner retirement in the ADR-0087 ledger (#14919) check-adr-0087-registration refused the previous disposition, correctly: the changeset carries a real consumer prescription (delete the import and every call), so `not-required (no-migration-prescription)` was a self-contradiction. Every other not-required category is false too -- @objectstack/core publishes, so `unpublished` is out; `already-registered` has no entry to name; `type-surface-only` needs an any/unknown-to-concrete narrowing this is not; and `runtime-interface-only` explicitly inherits the same prescription refusal (#8299). The only truthful disposition left is `registered`. That is also the repo's settled convention for this exact shape -- a published TS symbol with no spec schema, no stored source and no tombstone, where the ledger is the only channel that reaches an upgrader. contracts.IDataDriver.findStream and actor-user-roles-to-positions are both registered on those grounds. D3 semantic, not a D2 conversion: the class has no spec schema, so there is no authorable key to tombstone and no stored sys_metadata row to rewrite -- a scanner was constructed per call and every result lived in a per-instance Map discarded with the object, so applyConversionsToStoredItem has no seam that would ever see one. This is what the ruling's "no metadata migration" excludes, and it is excluded. - add entries/semantic/18.plugin-security-scanner-retired.ts (one file, per the entries README kit -- no hand edit inside registry.ts's generated markers) - regenerate registry.ts via gen:migration-registry (157 semantic entries) - flip the core changeset's marker to `registered plugin-security-scanner-retired`, keeping the BREAKING banner, the no-replacement statement and the NOT MEASURED paragraph untouched - add the @objectstack/spec patch changeset, mirroring the #6138 backfill Measured and recorded in that changeset: the regeneration lap the entries README warns about did not materialise. check:generated reports all 15 artifacts up to date, and running gen:spec-changes and gen:upgrade-guide explicitly moved neither file -- a major-18 semantic entry is not yet projected into either. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../plugin-security-scanner-ledger-entry.md | 42 ++++++++++++ .changeset/plugin-security-scanner-retired.md | 2 +- .../18.plugin-security-scanner-retired.ts | 67 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 63 +++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 .changeset/plugin-security-scanner-ledger-entry.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts diff --git a/.changeset/plugin-security-scanner-ledger-entry.md b/.changeset/plugin-security-scanner-ledger-entry.md new file mode 100644 index 0000000000..c904cd9283 --- /dev/null +++ b/.changeset/plugin-security-scanner-ledger-entry.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": patch +--- + +ADR-0087 semantic-migration ledger: register the retirement of `@objectstack/core`'s `PluginSecurityScanner` (#14919) + +`PluginSecurityScanner`, `ScanTarget` and `SecurityIssue` are removed from +`@objectstack/core` in the same PR, under ADR-0049 enforce-or-remove (maintainer +ruling 2026-09-05, director summon #14, decision batch #42). This is the ledger +half: a D3 semantic entry +(`src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts`, +concatenated into `MIGRATIONS_BY_MAJOR[18].semantic` by `gen:migration-registry`) +so the retirement reaches `spec-changes.json` and the generated upgrade guide +rather than being invisible to every upgrade channel. + +FROM `new PluginSecurityScanner(kernel.logger)` → TO nothing: delete the import +and every call. There is no replacement export, and a caller that branched on +`result.status === 'passed'` takes that branch unconditionally — it is the only +branch the scanner ever produced, because four of its five scan methods returned +an empty issue list on every input and the fifth read a vulnerability database +whose only writer had zero callers. + +Why an entry is owed at all, and why D3 rather than a D2 conversion: the class +has no spec schema and never had one. It is a runtime TS class, so there is no +authorable key to tombstone with `retiredKey()` and no stored `sys_metadata` row +a conversion could rewrite — a scanner was constructed per call and every result +lived in a per-instance Map discarded with the object, so +`applyConversionsToStoredItem` has no seam that would ever see one. The enforced +channel is tsc at the consumer's own import site; for anyone it does not reach, +this entry and the upgrade guide are the only channel. That is the +`contracts.IDataDriver.findStream` and `actor-user-roles-to-positions` +disposition, applied to a surface one layer further out than either — those are +declared in `packages/spec`, this one only in `packages/core`. + +Measured, and worth recording because the entries README warns of a regeneration +lap that did not materialise here: `check:generated` reports all 15 artifacts up +to date after the entry landed, and running `gen:spec-changes` and +`gen:upgrade-guide` explicitly moved neither file — a major-18 semantic entry is +not yet projected into either. `registry.ts` is the whole generated diff. + +No behaviour in `@objectstack/spec` changes; this adds a ledger row and the +regenerated region that carries it. diff --git a/.changeset/plugin-security-scanner-retired.md b/.changeset/plugin-security-scanner-retired.md index 19aed6d511..f933fd283a 100644 --- a/.changeset/plugin-security-scanner-retired.md +++ b/.changeset/plugin-security-scanner-retired.md @@ -4,7 +4,7 @@ feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919) - + **BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`, together with its two companion types `ScanTarget` and `SecurityIssue`. Landing diff --git a/packages/spec/src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts b/packages/spec/src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts new file mode 100644 index 0000000000..05630a29d5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.plugin-security-scanner-retired.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'plugin-security-scanner-retired', + surface: + '`@objectstack/core` runtime exports: `PluginSecurityScanner`, and the two types ' + + 'declared only to feed it, `ScanTarget` and `SecurityIssue`', + replacement: + 'nothing to re-declare — delete the import and every call. Plugin security scanning is ' + + 'not a platform capability and there is no replacement export. A caller that branched ' + + 'on `result.status === "passed"` takes that branch unconditionally, because it is the ' + + 'only branch the scanner ever produced. What the platform does still enforce, and what ' + + 'to reach for instead: artifact integrity and signatures ' + + '(`verifyPluginArtifactIntegrity`, the plugin signature verifier) answer "is this the ' + + 'artifact the publisher signed?" and never "is this artifact safe?"; plugin permissions ' + + 'and the sandbox resource limits are unchanged. For dependency vulnerabilities use the ' + + 'tools built for it against your own project — `npm audit` / `pnpm audit`, Dependabot, ' + + 'the GitHub Advisory Database, OSV — and treat an unaudited third-party plugin as ' + + 'untrusted code.', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #14919 (director summon #14, ' + + 'decision batch #42, ruled A: retire in three surfaces). The class shipped on ' + + '`@objectstack/core`\'s public barrel as a SECURITY control and could not fail. `scan()` ' + + 'composed five private scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, ' + + '`scanConfiguration`) allocated an empty issue array, logged and returned it with no code ' + + 'in between, so none could report a finding for any input; the fifth, `scanDependencies`, ' + + 'ran a real loop but matched only against an in-memory vulnerability database whose sole ' + + 'writer, the public `addVulnerability`, had zero callers in objectstack, in objectui at ' + + 'the pinned sha, or in the one demonstration that constructed the scanner, and ' + + '`updateVulnerabilityDatabase()` logged twice and fetched nothing. The database was ' + + 'therefore empty on every code path that has ever executed: no issue was ever produced, ' + + 'the score stayed 100, and the verdict was `status: "passed"` for every plugin the ' + + 'scanner was ever handed — a malicious one as readily as a benign one. Repair was refused ' + + 'by name: a real vulnerability scanner is a feature with a design surface, not a defect ' + + 'fix. Why this entry exists at all, and why D3 semantic rather than a D2 conversion: ' + + '`PluginSecurityScanner` has no spec schema and never had one — it is a runtime TS class, ' + + 'so there is no authorable key to tombstone with `retiredKey()`, no stored `sys_metadata` ' + + 'row that could carry it (a scanner was constructed per call and every result lived in a ' + + 'per-instance Map discarded with the object), and hence no seam `applyConversionsToStored' + + 'Item` would ever reach. The enforced channel is tsc, at the consumer\'s own import site; ' + + 'for anyone it does not reach, this ledger entry and the generated upgrade guide are the ' + + 'only channel there is. That is the `contracts.IDataDriver.findStream` (#4484) and ' + + '`actor-user-roles-to-positions` (#6011) disposition — a TS/API contract, no stored ' + + 'source, no tombstone, tsc at the call site — applied to a surface one layer further out ' + + 'than either: those are declared in `packages/spec`, this one only in `packages/core`. ' + + '⚠️ The out-of-repo consumer population is NOT MEASURED. Zero constructors were found in ' + + 'objectstack, in objectui at the pinned sha, and in the deleted example, but no download, ' + + 'dependent or source telemetry was consulted for consumers of the published package, so ' + + 'this is breaking for an unmeasured population rather than a removal proven to break ' + + 'nobody.', + acceptanceCriteria: + 'No source imports `PluginSecurityScanner`, `ScanTarget` or `SecurityIssue` from ' + + '`@objectstack/core` (or from `@objectstack/core/security`, a subpath the package has ' + + 'never declared in its `exports` and which therefore resolved for nobody). A TypeScript ' + + 'consumer gets the refusal at compile time at the import site — the export is absent from ' + + 'the built `dist/index.d.ts`, not merely undocumented. ⚠️ Runtime behaviour is ' + + 'deliberately UNCHANGED and must be verified as such: every scan this class ever ' + + 'performed returned zero issues and `status: "passed"`, so deleting a call removes no ' + + 'check that was running. A caller that treated a passing scan as evidence of safety was ' + + 'never getting any, and its remediation is to audit dependencies with a real tool, not to ' + + 'find a replacement symbol — there is none. Verified in-repo by export-list assertions on ' + + 'both barrels (`packages/core/src/security/security-scanner-retirement.pin.test.ts`), not ' + + 'by a grep: the name legitimately survives in the tombstone comments that explain the ' + + 'retirement.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d70701822d..e0fc20c0b5 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7828,6 +7828,69 @@ const step18: MigrationStep = { + 'still carries `globs` keeps serving as stored data; clear it by deleting the key from ' + 'the source manifest and republishing.', }, + { + id: 'plugin-security-scanner-retired', + surface: + '`@objectstack/core` runtime exports: `PluginSecurityScanner`, and the two types ' + + 'declared only to feed it, `ScanTarget` and `SecurityIssue`', + replacement: + 'nothing to re-declare — delete the import and every call. Plugin security scanning is ' + + 'not a platform capability and there is no replacement export. A caller that branched ' + + 'on `result.status === "passed"` takes that branch unconditionally, because it is the ' + + 'only branch the scanner ever produced. What the platform does still enforce, and what ' + + 'to reach for instead: artifact integrity and signatures ' + + '(`verifyPluginArtifactIntegrity`, the plugin signature verifier) answer "is this the ' + + 'artifact the publisher signed?" and never "is this artifact safe?"; plugin permissions ' + + 'and the sandbox resource limits are unchanged. For dependency vulnerabilities use the ' + + 'tools built for it against your own project — `npm audit` / `pnpm audit`, Dependabot, ' + + 'the GitHub Advisory Database, OSV — and treat an unaudited third-party plugin as ' + + 'untrusted code.', + reason: + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on #14919 (director summon #14, ' + + 'decision batch #42, ruled A: retire in three surfaces). The class shipped on ' + + '`@objectstack/core`\'s public barrel as a SECURITY control and could not fail. `scan()` ' + + 'composed five private scanners: four of them (`scanCode`, `scanMalware`, `scanLicenses`, ' + + '`scanConfiguration`) allocated an empty issue array, logged and returned it with no code ' + + 'in between, so none could report a finding for any input; the fifth, `scanDependencies`, ' + + 'ran a real loop but matched only against an in-memory vulnerability database whose sole ' + + 'writer, the public `addVulnerability`, had zero callers in objectstack, in objectui at ' + + 'the pinned sha, or in the one demonstration that constructed the scanner, and ' + + '`updateVulnerabilityDatabase()` logged twice and fetched nothing. The database was ' + + 'therefore empty on every code path that has ever executed: no issue was ever produced, ' + + 'the score stayed 100, and the verdict was `status: "passed"` for every plugin the ' + + 'scanner was ever handed — a malicious one as readily as a benign one. Repair was refused ' + + 'by name: a real vulnerability scanner is a feature with a design surface, not a defect ' + + 'fix. Why this entry exists at all, and why D3 semantic rather than a D2 conversion: ' + + '`PluginSecurityScanner` has no spec schema and never had one — it is a runtime TS class, ' + + 'so there is no authorable key to tombstone with `retiredKey()`, no stored `sys_metadata` ' + + 'row that could carry it (a scanner was constructed per call and every result lived in a ' + + 'per-instance Map discarded with the object), and hence no seam `applyConversionsToStored' + + 'Item` would ever reach. The enforced channel is tsc, at the consumer\'s own import site; ' + + 'for anyone it does not reach, this ledger entry and the generated upgrade guide are the ' + + 'only channel there is. That is the `contracts.IDataDriver.findStream` (#4484) and ' + + '`actor-user-roles-to-positions` (#6011) disposition — a TS/API contract, no stored ' + + 'source, no tombstone, tsc at the call site — applied to a surface one layer further out ' + + 'than either: those are declared in `packages/spec`, this one only in `packages/core`. ' + + '⚠️ The out-of-repo consumer population is NOT MEASURED. Zero constructors were found in ' + + 'objectstack, in objectui at the pinned sha, and in the deleted example, but no download, ' + + 'dependent or source telemetry was consulted for consumers of the published package, so ' + + 'this is breaking for an unmeasured population rather than a removal proven to break ' + + 'nobody.', + acceptanceCriteria: + 'No source imports `PluginSecurityScanner`, `ScanTarget` or `SecurityIssue` from ' + + '`@objectstack/core` (or from `@objectstack/core/security`, a subpath the package has ' + + 'never declared in its `exports` and which therefore resolved for nobody). A TypeScript ' + + 'consumer gets the refusal at compile time at the import site — the export is absent from ' + + 'the built `dist/index.d.ts`, not merely undocumented. ⚠️ Runtime behaviour is ' + + 'deliberately UNCHANGED and must be verified as such: every scan this class ever ' + + 'performed returned zero issues and `status: "passed"`, so deleting a call removes no ' + + 'check that was running. A caller that treated a passing scan as evidence of safety was ' + + 'never getting any, and its remediation is to audit dependencies with a real tool, not to ' + + 'find a replacement symbol — there is none. Verified in-repo by export-list assertions on ' + + 'both barrels (`packages/core/src/security/security-scanner-retirement.pin.test.ts`), not ' + + 'by a grep: the name legitimately survives in the tombstone comments that explain the ' + + 'retirement.', + }, { id: 'record-chatter-position-vocabulary-converged', surface: From 67e559697ada8e10715b6dfa73cadf71446164de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:09:16 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(changeset):=20the=20`registered`=20mark?= =?UTF-8?q?er=20takes=20ids=20only=20=E2=80=94=20move=20its=20rationale=20?= =?UTF-8?q?into=20the=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-adr-0087-registration parses everything after `registered` as a comma/space-separated id list, so the trailing `why` prose that the `not-required (...)` forms accept was read as 131 nonexistent migration ids. The asymmetry is real and AGENTS.md spells it: `registered SOME-MIGRATION-ID` carries no `why`, the three `not-required` forms do. The rationale is unchanged, only relocated into the changeset body where a reader gets it anyway. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/plugin-security-scanner-retired.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.changeset/plugin-security-scanner-retired.md b/.changeset/plugin-security-scanner-retired.md index f933fd283a..b05dacd33c 100644 --- a/.changeset/plugin-security-scanner-retired.md +++ b/.changeset/plugin-security-scanner-retired.md @@ -4,7 +4,20 @@ feat(core)!: retire `PluginSecurityScanner` — plugin security scanning is not a platform capability (#14919) - + + +**ADR-0087 disposition: registered**, as `plugin-security-scanner-retired` in +`MIGRATIONS_BY_MAJOR[18].semantic` — a **D3 semantic** entry, not a D2 conversion, +and so not the metadata migration the ruling excludes. The class has no spec schema +and never had one, so there is no authorable key to tombstone with `retiredKey()` +and no stored `sys_metadata` row a conversion could rewrite: a scanner was +constructed per call and every result lived in a per-instance Map discarded with the +object, so `applyConversionsToStoredItem` has no seam that would ever see one. An +entry is nevertheless owed rather than optional, because this changeset carries a +real consumer prescription — the enforced channel is tsc at the import site, and for +any consumer it does not reach, the ledger and the generated upgrade guide are the +only channel there is. Same disposition as `contracts.IDataDriver.findStream` and +`actor-user-roles-to-positions`. **BREAKING** — `PluginSecurityScanner` is removed from `@objectstack/core`, together with its two companion types `ScanTarget` and `SecurityIssue`. Landing