11// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22
3+ import nodePath from 'node:path' ;
4+
35import type {
46 SandboxConfig
57} from '@objectstack/spec/kernel' ;
@@ -44,6 +46,8 @@ export interface SandboxContext {
4446 * and access controls
4547 */
4648export class PluginSandboxRuntime {
49+ private static readonly MONITORING_INTERVAL_MS = 5000 ;
50+
4751 private logger : ObjectLogger ;
4852
4953 // Active sandboxes (pluginId -> context)
@@ -52,6 +56,10 @@ export class PluginSandboxRuntime {
5256 // Resource monitoring intervals
5357 private monitoringIntervals = new Map < string , NodeJS . Timeout > ( ) ;
5458
59+ // Per-plugin resource baselines for delta tracking
60+ private memoryBaselines = new Map < string , number > ( ) ;
61+ private cpuBaselines = new Map < string , { user : number ; system : number } > ( ) ;
62+
5563 constructor ( logger : ObjectLogger ) {
5664 this . logger = logger . child ( { component : 'SandboxRuntime' } ) ;
5765 }
@@ -77,6 +85,11 @@ export class PluginSandboxRuntime {
7785
7886 this . sandboxes . set ( pluginId , context ) ;
7987
88+ // Capture resource baselines for per-plugin delta tracking
89+ const baselineMemory = getMemoryUsage ( ) ;
90+ this . memoryBaselines . set ( pluginId , baselineMemory . heapUsed ) ;
91+ this . cpuBaselines . set ( pluginId , process . cpuUsage ( ) ) ;
92+
8093 // Start resource monitoring
8194 this . startResourceMonitoring ( pluginId ) ;
8295
@@ -102,6 +115,8 @@ export class PluginSandboxRuntime {
102115 // Stop monitoring
103116 this . stopResourceMonitoring ( pluginId ) ;
104117
118+ this . memoryBaselines . delete ( pluginId ) ;
119+ this . cpuBaselines . delete ( pluginId ) ;
105120 this . sandboxes . delete ( pluginId ) ;
106121
107122 this . logger . info ( 'Sandbox destroyed' , { pluginId } ) ;
@@ -142,12 +157,11 @@ export class PluginSandboxRuntime {
142157
143158 /**
144159 * Check file system access
145- * WARNING: Uses simple prefix matching. For production, use proper path
146- * resolution with path.resolve() and path.normalize() to prevent traversal.
160+ * Uses path.resolve() and path.normalize() to prevent directory traversal.
147161 */
148162 private checkFileAccess (
149163 config : SandboxConfig ,
150- path ?: string
164+ filePath ?: string
151165 ) : { allowed : boolean ; reason ?: string } {
152166 if ( config . level === 'none' ) {
153167 return { allowed : true } ;
@@ -158,36 +172,36 @@ export class PluginSandboxRuntime {
158172 }
159173
160174 // If no path specified, check general access
161- if ( ! path ) {
175+ if ( ! filePath ) {
162176 return { allowed : config . filesystem . mode !== 'none' } ;
163177 }
164178
165- // TODO: Use path.resolve() and path.normalize() for production
166- // Check allowed paths
179+ // Check allowed paths using proper path resolution to prevent directory traversal
167180 const allowedPaths = config . filesystem . allowedPaths || [ ] ;
181+ const resolvedPath = nodePath . normalize ( nodePath . resolve ( filePath ) ) ;
168182 const isAllowed = allowedPaths . some ( allowed => {
169- // Simple prefix matching - vulnerable to traversal attacks
170- // TODO: Use proper path resolution
171- return path . startsWith ( allowed ) ;
183+ const resolvedAllowed = nodePath . normalize ( nodePath . resolve ( allowed ) ) ;
184+ return resolvedPath . startsWith ( resolvedAllowed ) ;
172185 } ) ;
173186
174187 if ( allowedPaths . length > 0 && ! isAllowed ) {
175188 return {
176189 allowed : false ,
177- reason : `Path not in allowed list: ${ path } `
190+ reason : `Path not in allowed list: ${ filePath } `
178191 } ;
179192 }
180193
181- // Check denied paths
194+ // Check denied paths using proper path resolution
182195 const deniedPaths = config . filesystem . deniedPaths || [ ] ;
183196 const isDenied = deniedPaths . some ( denied => {
184- return path . startsWith ( denied ) ;
197+ const resolvedDenied = nodePath . normalize ( nodePath . resolve ( denied ) ) ;
198+ return resolvedPath . startsWith ( resolvedDenied ) ;
185199 } ) ;
186200
187201 if ( isDenied ) {
188202 return {
189203 allowed : false ,
190- reason : `Path is explicitly denied: ${ path } `
204+ reason : `Path is explicitly denied: ${ filePath } `
191205 } ;
192206 }
193207
@@ -196,8 +210,7 @@ export class PluginSandboxRuntime {
196210
197211 /**
198212 * Check network access
199- * WARNING: Uses simple string matching. For production, use proper URL
200- * parsing with new URL() and check hostname property.
213+ * Uses URL parsing to properly validate hostnames.
201214 */
202215 private checkNetworkAccess (
203216 config : SandboxConfig ,
@@ -221,14 +234,19 @@ export class PluginSandboxRuntime {
221234 return { allowed : ( config . network . mode as string ) !== 'none' } ;
222235 }
223236
224- // TODO: Use new URL() and check hostname property for production
237+ // Parse URL and check hostname against allowed/denied hosts
238+ let parsedHostname : string ;
239+ try {
240+ parsedHostname = new URL ( url ) . hostname ;
241+ } catch {
242+ return { allowed : false , reason : `Invalid URL: ${ url } ` } ;
243+ }
244+
225245 // Check allowed hosts
226246 const allowedHosts = config . network . allowedHosts || [ ] ;
227247 if ( allowedHosts . length > 0 ) {
228248 const isAllowed = allowedHosts . some ( host => {
229- // Simple string matching - vulnerable to bypass
230- // TODO: Use proper URL parsing
231- return url . includes ( host ) ;
249+ return parsedHostname === host ;
232250 } ) ;
233251
234252 if ( ! isAllowed ) {
@@ -242,7 +260,7 @@ export class PluginSandboxRuntime {
242260 // Check denied hosts
243261 const deniedHosts = config . network . deniedHosts || [ ] ;
244262 const isDenied = deniedHosts . some ( host => {
245- return url . includes ( host ) ;
263+ return parsedHostname === host ;
246264 } ) ;
247265
248266 if ( isDenied ) {
@@ -352,10 +370,10 @@ export class PluginSandboxRuntime {
352370 * Start monitoring resource usage
353371 */
354372 private startResourceMonitoring ( pluginId : string ) : void {
355- // Monitor every 5 seconds
373+ // Monitor at the configured interval
356374 const interval = setInterval ( ( ) => {
357375 this . updateResourceUsage ( pluginId ) ;
358- } , 5000 ) ;
376+ } , PluginSandboxRuntime . MONITORING_INTERVAL_MS ) ;
359377
360378 this . monitoringIntervals . set ( pluginId , interval ) ;
361379 }
@@ -374,10 +392,9 @@ export class PluginSandboxRuntime {
374392 /**
375393 * Update resource usage statistics
376394 *
377- * NOTE: Currently uses global process.memoryUsage() which tracks the entire
378- * Node.js process, not individual plugins. For production, implement proper
379- * per-plugin tracking using V8 heap snapshots or allocation tracking at
380- * plugin boundaries.
395+ * Tracks per-plugin memory and CPU usage using delta from baseline
396+ * captured at sandbox creation time. This is an approximation since
397+ * true per-plugin isolation isn't possible in a single Node.js process.
381398 */
382399 private updateResourceUsage ( pluginId : string ) : void {
383400 const context = this . sandboxes . get ( pluginId ) ;
@@ -388,19 +405,27 @@ export class PluginSandboxRuntime {
388405 // In a real implementation, this would collect actual metrics
389406 // For now, this is a placeholder structure
390407
391- // Update memory usage (global process memory - not per-plugin)
392- // TODO: Implement per-plugin memory tracking
408+ // Update memory usage using delta from baseline for per-plugin approximation
393409 const memoryUsage = getMemoryUsage ( ) ;
394- context . resourceUsage . memory . current = memoryUsage . heapUsed ;
410+ const memoryBaseline = this . memoryBaselines . get ( pluginId ) ?? 0 ;
411+ const memoryDelta = Math . max ( 0 , memoryUsage . heapUsed - memoryBaseline ) ;
412+ context . resourceUsage . memory . current = memoryDelta ;
395413 context . resourceUsage . memory . peak = Math . max (
396414 context . resourceUsage . memory . peak ,
397- memoryUsage . heapUsed
415+ memoryDelta
398416 ) ;
399417
400- // Update CPU usage (would use process.cpuUsage() or similar)
401- // This is a placeholder - real implementation would track per-plugin CPU
402- // TODO: Implement per-plugin CPU tracking
403- context . resourceUsage . cpu . current = 0 ;
418+ // Update CPU usage using delta from baseline for per-plugin approximation
419+ const cpuBaseline = this . cpuBaselines . get ( pluginId ) ?? { user : 0 , system : 0 } ;
420+ const cpuCurrent = process . cpuUsage ( ) ;
421+ const cpuDeltaUser = cpuCurrent . user - cpuBaseline . user ;
422+ const cpuDeltaSystem = cpuCurrent . system - cpuBaseline . system ;
423+ // Convert microseconds to a percentage approximation over the monitoring interval
424+ const totalCpuMicros = cpuDeltaUser + cpuDeltaSystem ;
425+ const intervalMicros = PluginSandboxRuntime . MONITORING_INTERVAL_MS * 1000 ;
426+ context . resourceUsage . cpu . current = ( totalCpuMicros / intervalMicros ) * 100 ;
427+ // Update baseline for next interval
428+ this . cpuBaselines . set ( pluginId , cpuCurrent ) ;
404429
405430 // Check for violations
406431 const { withinLimits, violations } = this . checkResourceLimits ( pluginId ) ;
@@ -429,6 +454,8 @@ export class PluginSandboxRuntime {
429454 }
430455
431456 this . sandboxes . clear ( ) ;
457+ this . memoryBaselines . clear ( ) ;
458+ this . cpuBaselines . clear ( ) ;
432459
433460 this . logger . info ( 'Sandbox runtime shutdown complete' ) ;
434461 }
0 commit comments