@objectstack/core 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/core",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Microkernel Core for ObjectStack",
6
6
  "type": "module",
@@ -16,13 +16,13 @@
16
16
  "devDependencies": {
17
17
  "typescript": "^5.0.0",
18
18
  "vitest": "^4.0.18",
19
- "@types/node": "^25.1.0"
19
+ "@types/node": "^25.2.2"
20
20
  },
21
21
  "dependencies": {
22
22
  "pino": "^10.3.0",
23
23
  "pino-pretty": "^13.1.3",
24
- "zod": "^3.24.1",
25
- "@objectstack/spec": "2.0.1"
24
+ "zod": "^4.3.6",
25
+ "@objectstack/spec": "2.0.2"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "pino": "^8.0.0"
package/src/hot-reload.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
+ import { createHash } from 'node:crypto';
4
+
3
5
  import type {
4
6
  HotReloadConfig,
5
7
  PluginStateSnapshot
@@ -136,21 +138,11 @@ class PluginStateManager {
136
138
  }
137
139
 
138
140
  /**
139
- * Calculate simple checksum for state verification
140
- * WARNING: This is a simple hash for demo purposes.
141
- * In production, use a cryptographic hash like SHA-256.
141
+ * Calculate checksum for state verification using SHA-256.
142
142
  */
143
143
  private calculateChecksum(state: Record<string, any>): string {
144
- // Simple checksum using JSON serialization
145
- // TODO: Replace with crypto.createHash('sha256') for production
146
144
  const stateStr = JSON.stringify(state);
147
- let hash = 0;
148
- for (let i = 0; i < stateStr.length; i++) {
149
- const char = stateStr.charCodeAt(i);
150
- hash = ((hash << 5) - hash) + char;
151
- hash = hash & hash; // Convert to 32-bit integer
152
- }
153
- return hash.toString(16);
145
+ return createHash('sha256').update(stateStr).digest('hex');
154
146
  }
155
147
 
156
148
  /**
package/src/qa/runner.ts CHANGED
@@ -131,10 +131,18 @@ export class TestRunner {
131
131
  return result;
132
132
  }
133
133
 
134
- private resolveVariables(action: QA.TestAction, _context: Record<string, unknown>): QA.TestAction {
135
- // TODO: Implement JSON path variable substitution stringify/parse
136
- // For now returning as is
137
- return action;
134
+ private resolveVariables(action: QA.TestAction, context: Record<string, unknown>): QA.TestAction {
135
+ const actionStr = JSON.stringify(action);
136
+ const resolved = actionStr.replace(/\{\{([^}]+)\}\}/g, (_match, varPath: string) => {
137
+ const value = this.getValueByPath(context, varPath.trim());
138
+ if (value === undefined) return _match; // Keep unresolved
139
+ return typeof value === 'string' ? value : JSON.stringify(value);
140
+ });
141
+ try {
142
+ return JSON.parse(resolved) as QA.TestAction;
143
+ } catch {
144
+ return action; // Fallback to original if parse fails
145
+ }
138
146
  }
139
147
 
140
148
  private getValueByPath(obj: unknown, path: string): unknown {
@@ -306,45 +306,65 @@ export class PluginPermissionEnforcer {
306
306
  });
307
307
  }
308
308
 
309
- private checkFileRead(capabilities: PluginCapability[], _path: string): boolean {
309
+ private matchGlob(pattern: string, str: string): boolean {
310
+ const regexStr = pattern
311
+ .split('**')
312
+ .map(segment => {
313
+ const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
314
+ return escaped.replace(/\*/g, '[^/]*');
315
+ })
316
+ .join('.*');
317
+ return new RegExp(`^${regexStr}$`).test(str);
318
+ }
319
+
320
+ private checkFileRead(capabilities: PluginCapability[], path: string): boolean {
310
321
  // Check if plugin has capability to read this file
311
322
  return capabilities.some(cap => {
312
323
  const protocolId = cap.protocol.id;
313
324
 
314
325
  // Check for file read capability
315
326
  if (protocolId.includes('protocol.filesystem.read')) {
316
- // TODO: Add path pattern matching
317
- return true;
327
+ const paths = cap.metadata?.paths;
328
+ if (!Array.isArray(paths) || paths.length === 0) {
329
+ return true;
330
+ }
331
+ return paths.some(p => typeof p === 'string' && this.matchGlob(p, path));
318
332
  }
319
333
 
320
334
  return false;
321
335
  });
322
336
  }
323
337
 
324
- private checkFileWrite(capabilities: PluginCapability[], _path: string): boolean {
338
+ private checkFileWrite(capabilities: PluginCapability[], path: string): boolean {
325
339
  // Check if plugin has capability to write this file
326
340
  return capabilities.some(cap => {
327
341
  const protocolId = cap.protocol.id;
328
342
 
329
343
  // Check for file write capability
330
344
  if (protocolId.includes('protocol.filesystem.write')) {
331
- // TODO: Add path pattern matching
332
- return true;
345
+ const paths = cap.metadata?.paths;
346
+ if (!Array.isArray(paths) || paths.length === 0) {
347
+ return true;
348
+ }
349
+ return paths.some(p => typeof p === 'string' && this.matchGlob(p, path));
333
350
  }
334
351
 
335
352
  return false;
336
353
  });
337
354
  }
338
355
 
339
- private checkNetworkAccess(capabilities: PluginCapability[], _url: string): boolean {
356
+ private checkNetworkAccess(capabilities: PluginCapability[], url: string): boolean {
340
357
  // Check if plugin has capability to access this URL
341
358
  return capabilities.some(cap => {
342
359
  const protocolId = cap.protocol.id;
343
360
 
344
361
  // Check for network capability
345
362
  if (protocolId.includes('protocol.network')) {
346
- // TODO: Add URL pattern matching
347
- return true;
363
+ const hosts = cap.metadata?.hosts;
364
+ if (!Array.isArray(hosts) || hosts.length === 0) {
365
+ return true;
366
+ }
367
+ return hosts.some(h => typeof h === 'string' && this.matchGlob(h, url));
348
368
  }
349
369
 
350
370
  return false;
@@ -336,14 +336,55 @@ export class PluginSignatureVerifier {
336
336
  }
337
337
 
338
338
  private async verifyCryptoSignatureBrowser(
339
- _data: string,
340
- _signature: string,
341
- _publicKey: string
339
+ data: string,
340
+ signature: string,
341
+ publicKey: string
342
342
  ): Promise<boolean> {
343
- // Browser implementation using SubtleCrypto
344
- // TODO: Implement SubtleCrypto-based verification
345
- this.logger.warn('Browser signature verification not yet implemented');
346
- return false;
343
+ try {
344
+ const subtle = globalThis.crypto?.subtle;
345
+ if (!subtle) {
346
+ this.logger.error('SubtleCrypto not available in this environment');
347
+ return false;
348
+ }
349
+
350
+ // Decode PEM public key to raw DER bytes
351
+ const pemBody = publicKey
352
+ .replace(/-----BEGIN PUBLIC KEY-----/, '')
353
+ .replace(/-----END PUBLIC KEY-----/, '')
354
+ .replace(/\s/g, '');
355
+ const keyBytes = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
356
+
357
+ // Configure algorithms based on RS256 or ES256
358
+ let importAlgorithm: { name: string; hash?: string; namedCurve?: string };
359
+ let verifyAlgorithm: { name: string; hash?: string };
360
+
361
+ if (this.config.algorithm === 'ES256') {
362
+ importAlgorithm = { name: 'ECDSA', namedCurve: 'P-256' };
363
+ verifyAlgorithm = { name: 'ECDSA', hash: 'SHA-256' };
364
+ } else {
365
+ importAlgorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
366
+ verifyAlgorithm = { name: 'RSASSA-PKCS1-v1_5' };
367
+ }
368
+
369
+ const cryptoKey = await subtle.importKey(
370
+ 'spki',
371
+ keyBytes,
372
+ importAlgorithm,
373
+ false,
374
+ ['verify']
375
+ );
376
+
377
+ // Decode base64 signature to ArrayBuffer
378
+ const signatureBytes = Uint8Array.from(atob(signature), c => c.charCodeAt(0));
379
+
380
+ // Encode data to ArrayBuffer
381
+ const dataBytes = new TextEncoder().encode(data);
382
+
383
+ return await subtle.verify(verifyAlgorithm, cryptoKey, signatureBytes, dataBytes);
384
+ } catch (error) {
385
+ this.logger.error('Browser signature verification failed', error as Error);
386
+ return false;
387
+ }
347
388
  }
348
389
 
349
390
  private validateConfig(): void {
@@ -1,5 +1,7 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
+ import nodePath from 'node:path';
4
+
3
5
  import type {
4
6
  SandboxConfig
5
7
  } from '@objectstack/spec/kernel';
@@ -44,6 +46,8 @@ export interface SandboxContext {
44
46
  * and access controls
45
47
  */
46
48
  export class PluginSandboxRuntime {
49
+ private static readonly MONITORING_INTERVAL_MS = 5000;
50
+
47
51
  private logger: ObjectLogger;
48
52
 
49
53
  // Active sandboxes (pluginId -> context)
@@ -52,6 +56,10 @@ export class PluginSandboxRuntime {
52
56
  // Resource monitoring intervals
53
57
  private monitoringIntervals = new Map<string, NodeJS.Timeout>();
54
58
 
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
+
55
63
  constructor(logger: ObjectLogger) {
56
64
  this.logger = logger.child({ component: 'SandboxRuntime' });
57
65
  }
@@ -77,6 +85,11 @@ export class PluginSandboxRuntime {
77
85
 
78
86
  this.sandboxes.set(pluginId, context);
79
87
 
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
+
80
93
  // Start resource monitoring
81
94
  this.startResourceMonitoring(pluginId);
82
95
 
@@ -102,6 +115,8 @@ export class PluginSandboxRuntime {
102
115
  // Stop monitoring
103
116
  this.stopResourceMonitoring(pluginId);
104
117
 
118
+ this.memoryBaselines.delete(pluginId);
119
+ this.cpuBaselines.delete(pluginId);
105
120
  this.sandboxes.delete(pluginId);
106
121
 
107
122
  this.logger.info('Sandbox destroyed', { pluginId });
@@ -142,12 +157,11 @@ export class PluginSandboxRuntime {
142
157
 
143
158
  /**
144
159
  * 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.
147
161
  */
148
162
  private checkFileAccess(
149
163
  config: SandboxConfig,
150
- path?: string
164
+ filePath?: string
151
165
  ): { allowed: boolean; reason?: string } {
152
166
  if (config.level === 'none') {
153
167
  return { allowed: true };
@@ -158,36 +172,36 @@ export class PluginSandboxRuntime {
158
172
  }
159
173
 
160
174
  // If no path specified, check general access
161
- if (!path) {
175
+ if (!filePath) {
162
176
  return { allowed: config.filesystem.mode !== 'none' };
163
177
  }
164
178
 
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
167
180
  const allowedPaths = config.filesystem.allowedPaths || [];
181
+ const resolvedPath = nodePath.normalize(nodePath.resolve(filePath));
168
182
  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);
172
185
  });
173
186
 
174
187
  if (allowedPaths.length > 0 && !isAllowed) {
175
188
  return {
176
189
  allowed: false,
177
- reason: `Path not in allowed list: ${path}`
190
+ reason: `Path not in allowed list: ${filePath}`
178
191
  };
179
192
  }
180
193
 
181
- // Check denied paths
194
+ // Check denied paths using proper path resolution
182
195
  const deniedPaths = config.filesystem.deniedPaths || [];
183
196
  const isDenied = deniedPaths.some(denied => {
184
- return path.startsWith(denied);
197
+ const resolvedDenied = nodePath.normalize(nodePath.resolve(denied));
198
+ return resolvedPath.startsWith(resolvedDenied);
185
199
  });
186
200
 
187
201
  if (isDenied) {
188
202
  return {
189
203
  allowed: false,
190
- reason: `Path is explicitly denied: ${path}`
204
+ reason: `Path is explicitly denied: ${filePath}`
191
205
  };
192
206
  }
193
207
 
@@ -196,8 +210,7 @@ export class PluginSandboxRuntime {
196
210
 
197
211
  /**
198
212
  * 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.
201
214
  */
202
215
  private checkNetworkAccess(
203
216
  config: SandboxConfig,
@@ -221,14 +234,19 @@ export class PluginSandboxRuntime {
221
234
  return { allowed: (config.network.mode as string) !== 'none' };
222
235
  }
223
236
 
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
+
225
245
  // Check allowed hosts
226
246
  const allowedHosts = config.network.allowedHosts || [];
227
247
  if (allowedHosts.length > 0) {
228
248
  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;
232
250
  });
233
251
 
234
252
  if (!isAllowed) {
@@ -242,7 +260,7 @@ export class PluginSandboxRuntime {
242
260
  // Check denied hosts
243
261
  const deniedHosts = config.network.deniedHosts || [];
244
262
  const isDenied = deniedHosts.some(host => {
245
- return url.includes(host);
263
+ return parsedHostname === host;
246
264
  });
247
265
 
248
266
  if (isDenied) {
@@ -352,10 +370,10 @@ export class PluginSandboxRuntime {
352
370
  * Start monitoring resource usage
353
371
  */
354
372
  private startResourceMonitoring(pluginId: string): void {
355
- // Monitor every 5 seconds
373
+ // Monitor at the configured interval
356
374
  const interval = setInterval(() => {
357
375
  this.updateResourceUsage(pluginId);
358
- }, 5000);
376
+ }, PluginSandboxRuntime.MONITORING_INTERVAL_MS);
359
377
 
360
378
  this.monitoringIntervals.set(pluginId, interval);
361
379
  }
@@ -374,10 +392,9 @@ export class PluginSandboxRuntime {
374
392
  /**
375
393
  * Update resource usage statistics
376
394
  *
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.
381
398
  */
382
399
  private updateResourceUsage(pluginId: string): void {
383
400
  const context = this.sandboxes.get(pluginId);
@@ -388,19 +405,27 @@ export class PluginSandboxRuntime {
388
405
  // In a real implementation, this would collect actual metrics
389
406
  // For now, this is a placeholder structure
390
407
 
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
393
409
  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;
395
413
  context.resourceUsage.memory.peak = Math.max(
396
414
  context.resourceUsage.memory.peak,
397
- memoryUsage.heapUsed
415
+ memoryDelta
398
416
  );
399
417
 
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);
404
429
 
405
430
  // Check for violations
406
431
  const { withinLimits, violations } = this.checkResourceLimits(pluginId);
@@ -429,6 +454,8 @@ export class PluginSandboxRuntime {
429
454
  }
430
455
 
431
456
  this.sandboxes.clear();
457
+ this.memoryBaselines.clear();
458
+ this.cpuBaselines.clear();
432
459
 
433
460
  this.logger.info('Sandbox runtime shutdown complete');
434
461
  }