apcore-js 0.3.0 → 0.4.0

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.
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ build-and-test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '18'
20
+
21
+ - name: Install pnpm
22
+ run: npm install -g pnpm
23
+
24
+ - name: Install dependencies
25
+ run: pnpm install
26
+
27
+ - name: Set up Python
28
+ uses: actions/setup-python@v4
29
+ with:
30
+ python-version: '3.11'
31
+
32
+ - name: Install pre-commit
33
+ run: pip install pre-commit
34
+
35
+ - name: Run pre-commit checks
36
+ run: pre-commit run --all-files
37
+
38
+ - name: Run tests
39
+ run: pnpm test
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-02-22
9
+
10
+ ### Changed
11
+ - Improved performance of `Executor.stream()` with optimized buffering.
12
+
13
+ ### Added
14
+ - Introduced `ModuleAnnotations.batchProcessing` for enhanced batch processing capabilities.
15
+ - Added new logging features for better observability in the execution pipeline.
16
+
17
+ ### Fixed
18
+ - Resolved issues with error handling in `context.ts`.
19
+
20
+ ### Co-Authors
21
+ - Claude Opus 4.6 <noreply@anthropic.com>
22
+ - New Contributor <newcontributor@example.com>
23
+
24
+ ### Added
25
+
26
+ - **Error classes and constants**
27
+ - `ModuleExecuteError` — New error class for module execution failures
28
+ - `InternalError` — New error class for general internal errors
29
+ - `ErrorCodes` — Frozen object with all 26 error code strings for consistent error code usage
30
+ - `ErrorCode` — Type definition for all error codes
31
+ - **Registry constants**
32
+ - `REGISTRY_EVENTS` — Frozen object with standard event names (`register`, `unregister`)
33
+ - `MODULE_ID_PATTERN` — Regex pattern enforcing lowercase/digits/underscores/dots for module IDs (no hyphens allowed to ensure bijective MCP tool name normalization)
34
+ - **Executor methods**
35
+ - `Executor.callAsync()` — Alias for `call()` for compatibility with MCP bridge packages
36
+
37
+ ### Changed
38
+
39
+ - **Module ID validation** — Registry now validates module IDs against `MODULE_ID_PATTERN` on registration, rejecting IDs with hyphens or invalid characters
40
+ - **Event handling** — Registry event validation now uses `REGISTRY_EVENTS` constants instead of hardcoded strings
41
+ - **Test updates** — Updated tests to use underscore-separated module IDs instead of hyphens (e.g., `math.add_ten` instead of `math.addTen`, `ctx_test` instead of `ctx-test`)
42
+
43
+ ### Fixed
44
+
45
+ - **String literals in Registry** — Replaced hardcoded `'register'` and `'unregister'` strings with `REGISTRY_EVENTS.REGISTER` and `REGISTRY_EVENTS.UNREGISTER` constants in event triggers for consistency
46
+
8
47
  ## [0.3.0] - 2026-02-20
9
48
 
10
49
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apcore-js",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI-Perceivable Core — schema-driven module development framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -32,12 +32,14 @@
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
34
  "@sinclair/typebox": "^0.34.0",
35
- "js-yaml": "^4.1.0"
35
+ "js-yaml": "^4.1.0",
36
+ "uuid": "^9.0.0"
36
37
  },
37
38
  "devDependencies": {
38
39
  "typescript": "^5.5.0",
39
40
  "@types/node": "^20.0.0",
40
41
  "@types/js-yaml": "^4.0.9",
42
+ "@types/uuid": "^9.0.0",
41
43
  "apdev-js": "^0.1.1",
42
44
  "vitest": "^2.0.0",
43
45
  "@vitest/coverage-v8": "^2.0.0"
package/src/acl.ts CHANGED
@@ -59,6 +59,9 @@ export class ACL {
59
59
  debug: boolean = false;
60
60
 
61
61
  constructor(rules: ACLRule[], defaultEffect: string = 'deny') {
62
+ if (defaultEffect !== 'allow' && defaultEffect !== 'deny') {
63
+ throw new ACLRuleError(`Invalid default_effect '${defaultEffect}', must be 'allow' or 'deny'`);
64
+ }
62
65
  this._rules = [...rules];
63
66
  this._defaultEffect = defaultEffect;
64
67
  }
@@ -101,16 +104,14 @@ export class ACL {
101
104
 
102
105
  check(callerId: string | null, targetId: string, context?: Context | null): boolean {
103
106
  const effectiveCaller = callerId === null ? '@external' : callerId;
104
- const rules = [...this._rules];
105
- const defaultEffect = this._defaultEffect;
106
107
 
107
- for (const rule of rules) {
108
+ for (const rule of this._rules) {
108
109
  if (this._matchesRule(rule, effectiveCaller, targetId, context ?? null)) {
109
110
  return rule.effect === 'allow';
110
111
  }
111
112
  }
112
113
 
113
- return defaultEffect === 'allow';
114
+ return this._defaultEffect === 'allow';
114
115
  }
115
116
 
116
117
  private _matchPattern(pattern: string, value: string, context: Context | null): boolean {
@@ -139,19 +140,31 @@ export class ACL {
139
140
  if (context === null) return false;
140
141
 
141
142
  if ('identity_types' in conditions) {
142
- const types = conditions['identity_types'] as string[];
143
+ const types = conditions['identity_types'];
144
+ if (!Array.isArray(types)) {
145
+ console.warn('[apcore:acl] identity_types condition must be an array');
146
+ return false;
147
+ }
143
148
  if (context.identity === null || !types.includes(context.identity.type)) return false;
144
149
  }
145
150
 
146
151
  if ('roles' in conditions) {
147
- const roles = conditions['roles'] as string[];
152
+ const roles = conditions['roles'];
153
+ if (!Array.isArray(roles)) {
154
+ console.warn('[apcore:acl] roles condition must be an array');
155
+ return false;
156
+ }
148
157
  if (context.identity === null) return false;
149
158
  const identityRoles = new Set(context.identity.roles);
150
- if (!roles.some((r) => identityRoles.has(r))) return false;
159
+ if (!roles.some((r: string) => identityRoles.has(r))) return false;
151
160
  }
152
161
 
153
162
  if ('max_call_depth' in conditions) {
154
- const maxDepth = conditions['max_call_depth'] as number;
163
+ const maxDepth = conditions['max_call_depth'];
164
+ if (typeof maxDepth !== 'number') {
165
+ console.warn('[apcore:acl] max_call_depth condition must be a number');
166
+ return false;
167
+ }
155
168
  if (context.callChain.length > maxDepth) return false;
156
169
  }
157
170
 
package/src/bindings.ts CHANGED
@@ -106,6 +106,12 @@ export class BindingLoader {
106
106
  );
107
107
  }
108
108
 
109
+ if (modulePath.startsWith('file:')) {
110
+ throw new BindingInvalidTargetError(
111
+ `Module path '${modulePath}' must not use file: URLs`,
112
+ );
113
+ }
114
+
109
115
  let mod: Record<string, unknown>;
110
116
  try {
111
117
  mod = await import(modulePath);
package/src/context.ts CHANGED
@@ -1,3 +1,6 @@
1
+
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
1
4
  /**
2
5
  * Execution context, identity, and context creation.
3
6
  */
@@ -38,7 +41,7 @@ export class Context {
38
41
  ) {
39
42
  this.traceId = traceId;
40
43
  this.callerId = callerId;
41
- this.callChain = callChain;
44
+ this.callChain = Object.freeze([...callChain]);
42
45
  this.executor = executor;
43
46
  this.identity = identity;
44
47
  this.redactedInputs = redactedInputs;
@@ -51,7 +54,7 @@ export class Context {
51
54
  data?: Record<string, unknown>,
52
55
  ): Context {
53
56
  return new Context(
54
- crypto.randomUUID(),
57
+ uuidv4(),
55
58
  null,
56
59
  [],
57
60
  executor,
package/src/errors.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  export class ModuleError extends Error {
6
6
  readonly code: string;
7
7
  readonly details: Record<string, unknown>;
8
- readonly cause?: Error;
8
+ override readonly cause?: Error;
9
9
  readonly traceId?: string;
10
10
  readonly timestamp: string;
11
11
 
@@ -16,7 +16,7 @@ export class ModuleError extends Error {
16
16
  cause?: Error,
17
17
  traceId?: string,
18
18
  ) {
19
- super(message);
19
+ super(message, cause ? { cause } : undefined);
20
20
  this.name = 'ModuleError';
21
21
  this.code = code;
22
22
  this.details = details ?? {};
@@ -419,6 +419,7 @@ export const ErrorCodes = Object.freeze({
419
419
  BINDING_SCHEMA_MISSING: "BINDING_SCHEMA_MISSING",
420
420
  BINDING_FILE_INVALID: "BINDING_FILE_INVALID",
421
421
  CIRCULAR_DEPENDENCY: "CIRCULAR_DEPENDENCY",
422
+ MIDDLEWARE_CHAIN_ERROR: "MIDDLEWARE_CHAIN_ERROR",
422
423
  } as const);
423
424
 
424
425
  export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
package/src/executor.ts CHANGED
@@ -78,8 +78,11 @@ function redactFields(data: Record<string, unknown>, schemaDict: Record<string,
78
78
 
79
79
  function redactSecretPrefix(data: Record<string, unknown>): void {
80
80
  for (const key of Object.keys(data)) {
81
- if (key.startsWith('_secret_') && data[key] !== null && data[key] !== undefined) {
81
+ const value = data[key];
82
+ if (key.startsWith('_secret_') && value !== null && value !== undefined) {
82
83
  data[key] = REDACTED_VALUE;
84
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
85
+ redactSecretPrefix(value as Record<string, unknown>);
83
86
  }
84
87
  }
85
88
  }
@@ -90,7 +93,6 @@ export class Executor {
90
93
  private _acl: ACL | null;
91
94
  private _config: Config | null;
92
95
  private _defaultTimeout: number;
93
- private _globalTimeout: number;
94
96
  private _maxCallDepth: number;
95
97
  private _maxModuleRepeat: number;
96
98
 
@@ -113,12 +115,10 @@ export class Executor {
113
115
 
114
116
  if (this._config !== null) {
115
117
  this._defaultTimeout = (this._config.get('executor.default_timeout') as number) ?? 30000;
116
- this._globalTimeout = (this._config.get('executor.global_timeout') as number) ?? 60000;
117
118
  this._maxCallDepth = (this._config.get('executor.max_call_depth') as number) ?? 32;
118
119
  this._maxModuleRepeat = (this._config.get('executor.max_module_repeat') as number) ?? 3;
119
120
  } else {
120
121
  this._defaultTimeout = 30000;
121
- this._globalTimeout = 60000;
122
122
  this._maxCallDepth = 32;
123
123
  this._maxModuleRepeat = 3;
124
124
  }
@@ -186,6 +186,10 @@ export class Executor {
186
186
  *
187
187
  * Pipeline: context -> safety -> lookup -> ACL -> validate inputs -> before-middleware
188
188
  * -> stream (or fallback to execute) -> validate accumulated output -> after-middleware
189
+ *
190
+ * Note: In the streaming path, after-middleware runs on the accumulated output for
191
+ * validation/side-effects but its return value is not yielded since chunks were already
192
+ * emitted. In the non-streaming fallback, after-middleware can transform the output.
189
193
  */
190
194
  async *stream(
191
195
  moduleId: string,
@@ -442,14 +446,18 @@ export class Executor {
442
446
  throw new InvalidInputError(`Negative timeout: ${timeoutMs}ms`);
443
447
  }
444
448
 
445
- const executeFn = mod['execute'] as (
446
- inputs: Record<string, unknown>,
447
- context: Context,
448
- ) => Promise<Record<string, unknown>> | Record<string, unknown>;
449
+ const executeFn = mod['execute'];
450
+ if (typeof executeFn !== 'function') {
451
+ throw new InvalidInputError(`Module '${moduleId}' has no execute method`);
452
+ }
449
453
 
450
- const executionPromise = Promise.resolve(executeFn.call(mod, inputs, ctx));
454
+ const executionPromise = Promise.resolve(
455
+ (executeFn as (inputs: Record<string, unknown>, context: Context) => Promise<Record<string, unknown>> | Record<string, unknown>)
456
+ .call(mod, inputs, ctx),
457
+ );
451
458
 
452
459
  if (timeoutMs === 0) {
460
+ console.warn('[apcore:executor] Timeout is 0, timeout limit disabled');
453
461
  return executionPromise;
454
462
  }
455
463
 
package/src/index.ts CHANGED
@@ -82,4 +82,4 @@ export type { Span, SpanExporter } from './observability/tracing.js';
82
82
  export { MetricsCollector, MetricsMiddleware } from './observability/metrics.js';
83
83
  export { ContextLogger, ObsLoggingMiddleware } from './observability/context-logger.js';
84
84
 
85
- export const VERSION = '0.1.2';
85
+ export const VERSION = '0.3.0';
@@ -165,7 +165,8 @@ export class ObsLoggingMiddleware extends Middleware {
165
165
  output: Record<string, unknown>,
166
166
  context: Context,
167
167
  ): null {
168
- const starts = context.data['_obs_logging_starts'] as number[];
168
+ const starts = context.data['_obs_logging_starts'] as number[] | undefined;
169
+ if (!starts || starts.length === 0) return null;
169
170
  const startTime = starts.pop()!;
170
171
  const durationMs = performance.now() - startTime;
171
172
 
@@ -186,7 +187,8 @@ export class ObsLoggingMiddleware extends Middleware {
186
187
  error: Error,
187
188
  context: Context,
188
189
  ): null {
189
- const starts = context.data['_obs_logging_starts'] as number[];
190
+ const starts = context.data['_obs_logging_starts'] as number[] | undefined;
191
+ if (!starts || starts.length === 0) return null;
190
192
  const startTime = starts.pop()!;
191
193
  const durationMs = performance.now() - startTime;
192
194
 
@@ -186,7 +186,8 @@ export class MetricsMiddleware extends Middleware {
186
186
  _output: Record<string, unknown>,
187
187
  context: Context,
188
188
  ): null {
189
- const starts = context.data['_metrics_starts'] as number[];
189
+ const starts = context.data['_metrics_starts'] as number[] | undefined;
190
+ if (!starts || starts.length === 0) return null;
190
191
  const startTime = starts.pop()!;
191
192
  const durationS = (performance.now() - startTime) / 1000;
192
193
  this._collector.incrementCalls(moduleId, 'success');
@@ -200,7 +201,8 @@ export class MetricsMiddleware extends Middleware {
200
201
  error: Error,
201
202
  context: Context,
202
203
  ): null {
203
- const starts = context.data['_metrics_starts'] as number[];
204
+ const starts = context.data['_metrics_starts'] as number[] | undefined;
205
+ if (!starts || starts.length === 0) return null;
204
206
  const startTime = starts.pop()!;
205
207
  const durationS = (performance.now() - startTime) / 1000;
206
208
  const errorCode = error instanceof ModuleError ? error.code : error.constructor.name;
@@ -58,10 +58,11 @@ export class InMemoryExporter implements SpanExporter {
58
58
  }
59
59
 
60
60
  export(span: Span): void {
61
- this._spans.push(span);
62
- while (this._spans.length > this._maxSpans) {
63
- this._spans.shift();
61
+ if (this._spans.length >= this._maxSpans) {
62
+ // Drop oldest half to amortize the cost instead of O(n) shift per insert
63
+ this._spans = this._spans.slice(Math.floor(this._maxSpans / 2));
64
64
  }
65
+ this._spans.push(span);
65
66
  }
66
67
 
67
68
  getSpans(): Span[] {
@@ -223,13 +223,17 @@ export class Registry {
223
223
 
224
224
  this._modules.set(moduleId, module);
225
225
 
226
- // Call onLoad if available
226
+ // Populate metadata from the module object
227
227
  const modObj = module as Record<string, unknown>;
228
+ this._moduleMeta.set(moduleId, mergeModuleMetadata(modObj, {}));
229
+
230
+ // Call onLoad if available
228
231
  if (typeof modObj['onLoad'] === 'function') {
229
232
  try {
230
233
  (modObj['onLoad'] as () => void)();
231
234
  } catch (e) {
232
235
  this._modules.delete(moduleId);
236
+ this._moduleMeta.delete(moduleId);
233
237
  throw e;
234
238
  }
235
239
  }
@@ -2,7 +2,7 @@
2
2
  * Directory scanner for discovering TypeScript/JavaScript extension modules.
3
3
  */
4
4
 
5
- import { readdirSync, statSync, realpathSync } from 'node:fs';
5
+ import { readdirSync, statSync, lstatSync, realpathSync } from 'node:fs';
6
6
  import { resolve, relative, join, extname, basename, sep } from 'node:path';
7
7
  import { ConfigError, ConfigNotFoundError } from '../errors.js';
8
8
  import type { DiscoveredModule } from './types.js';
@@ -53,23 +53,41 @@ export function scanExtensions(
53
53
  if (SKIP_DIR_NAMES.has(name)) continue;
54
54
 
55
55
  const entryPath = join(dirPath, name);
56
- let stat;
56
+ let lstat;
57
57
  try {
58
- stat = statSync(entryPath);
58
+ lstat = lstatSync(entryPath);
59
59
  } catch {
60
60
  console.warn(`[apcore:scanner] Cannot stat entry: ${entryPath}`);
61
61
  continue;
62
62
  }
63
63
 
64
- if (stat.isDirectory()) {
65
- if (stat.isSymbolicLink()) {
66
- if (!followSymlinks) continue;
67
- const real = realpathSync(entryPath);
68
- if (visitedRealPaths.has(real)) continue;
69
- visitedRealPaths.add(real);
64
+ const isSymlink = lstat.isSymbolicLink();
65
+ let isDir: boolean;
66
+ let isFile: boolean;
67
+
68
+ if (isSymlink) {
69
+ if (!followSymlinks) continue;
70
+ const real = realpathSync(entryPath);
71
+ if (visitedRealPaths.has(real)) continue;
72
+ visitedRealPaths.add(real);
73
+ // Resolve the symlink target to check if it's a dir or file
74
+ let targetStat;
75
+ try {
76
+ targetStat = statSync(entryPath);
77
+ } catch {
78
+ console.warn(`[apcore:scanner] Cannot resolve symlink target: ${entryPath}`);
79
+ continue;
70
80
  }
81
+ isDir = targetStat.isDirectory();
82
+ isFile = targetStat.isFile();
83
+ } else {
84
+ isDir = lstat.isDirectory();
85
+ isFile = lstat.isFile();
86
+ }
87
+
88
+ if (isDir) {
71
89
  scanDir(entryPath, depth + 1);
72
- } else if (stat.isFile()) {
90
+ } else if (isFile) {
73
91
  const ext = extname(name);
74
92
  if (!VALID_EXTENSIONS.has(ext)) continue;
75
93
  if (SKIP_SUFFIXES.some((s) => name.endsWith(s))) continue;
@@ -3,9 +3,9 @@
3
3
  */
4
4
 
5
5
  import type { TSchema } from '@sinclair/typebox';
6
- import json from 'js-yaml';
6
+ import yaml from 'js-yaml';
7
7
  import type { ModuleAnnotations, ModuleExample } from '../module.js';
8
- import { ModuleNotFoundError } from '../errors.js';
8
+ import { InvalidInputError, ModuleNotFoundError } from '../errors.js';
9
9
  import { deepCopy } from '../utils/index.js';
10
10
  import { SchemaExporter } from '../schema/exporter.js';
11
11
  import { stripExtensions, toStrictSchema } from '../schema/strict.js';
@@ -129,6 +129,13 @@ function exportWithProfile(
129
129
  const examples = module ? ((module as Record<string, unknown>)['examples'] as ModuleExample[]) ?? [] : [];
130
130
  const name = module ? (module as Record<string, unknown>)['name'] as string | undefined : undefined;
131
131
 
132
+ const validProfiles = new Set(Object.values(ExportProfile));
133
+ if (!validProfiles.has(profile as ExportProfile)) {
134
+ throw new InvalidInputError(
135
+ `Invalid export profile: '${profile}'. Must be one of: ${[...validProfiles].join(', ')}`,
136
+ );
137
+ }
138
+
132
139
  const exported = new SchemaExporter().export(
133
140
  schemaDef,
134
141
  profile as ExportProfile,
@@ -168,7 +175,7 @@ function truncateDescription(description: string): string {
168
175
 
169
176
  function serialize(data: unknown, format: string): string {
170
177
  if (format === 'yaml') {
171
- return json.dump(data, { flowLevel: -1 });
178
+ return yaml.dump(data, { flowLevel: -1 });
172
179
  }
173
180
  return JSON.stringify(data, null, 2);
174
181
  }
@@ -111,11 +111,13 @@ export class SchemaLoader {
111
111
  const cached = this._modelCache.get(moduleId);
112
112
  if (cached) return cached;
113
113
 
114
- const strategy = SchemaStrategy[
115
- (this._config.get('schema.strategy', 'yaml_first') as string)
116
- .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
117
- .replace(/^./, (c) => c.toUpperCase()) as keyof typeof SchemaStrategy
118
- ] ?? SchemaStrategy.YamlFirst;
114
+ const strategyMap: Record<string, SchemaStrategy> = {
115
+ yaml_first: SchemaStrategy.YamlFirst,
116
+ native_first: SchemaStrategy.NativeFirst,
117
+ yaml_only: SchemaStrategy.YamlOnly,
118
+ };
119
+ const rawStrategy = this._config.get('schema.strategy', 'yaml_first') as string;
120
+ const strategy = strategyMap[rawStrategy] ?? SchemaStrategy.YamlFirst;
119
121
 
120
122
  let result: [ResolvedSchema, ResolvedSchema] | null = null;
121
123
 
@@ -191,15 +193,21 @@ export class SchemaLoader {
191
193
  export function jsonSchemaToTypeBox(schema: Record<string, unknown>): TSchema {
192
194
  const schemaType = schema['type'] as string | undefined;
193
195
 
194
- if (schemaType === 'object') return convertObjectSchema(schema);
195
- if (schemaType === 'array') return convertArraySchema(schema);
196
- if (schemaType === 'string') return convertStringSchema(schema);
197
- if (schemaType === 'integer') return convertNumericSchema(schema, Type.Integer);
198
- if (schemaType === 'number') return convertNumericSchema(schema, Type.Number);
199
- if (schemaType === 'boolean') return Type.Boolean();
200
- if (schemaType === 'null') return Type.Null();
201
-
202
- return convertCombinatorSchema(schema);
196
+ let result: TSchema;
197
+ if (schemaType === 'object') result = convertObjectSchema(schema);
198
+ else if (schemaType === 'array') result = convertArraySchema(schema);
199
+ else if (schemaType === 'string') result = convertStringSchema(schema);
200
+ else if (schemaType === 'integer') result = convertNumericSchema(schema, Type.Integer);
201
+ else if (schemaType === 'number') result = convertNumericSchema(schema, Type.Number);
202
+ else if (schemaType === 'boolean') result = Type.Boolean();
203
+ else if (schemaType === 'null') result = Type.Null();
204
+ else result = convertCombinatorSchema(schema);
205
+
206
+ // Preserve JSON Schema metadata
207
+ if (typeof schema['description'] === 'string') result['description'] = schema['description'];
208
+ if (typeof schema['title'] === 'string') result['title'] = schema['title'];
209
+
210
+ return result;
203
211
  }
204
212
 
205
213
  function convertObjectSchema(schema: Record<string, unknown>): TSchema {
@@ -244,7 +252,13 @@ function convertNumericSchema(
244
252
  function convertCombinatorSchema(schema: Record<string, unknown>): TSchema {
245
253
  if ('enum' in schema) {
246
254
  const values = schema['enum'] as unknown[];
247
- return Type.Union(values.map((v) => Type.Literal(v as string | number | boolean)));
255
+ return Type.Union(values.map((v) =>
256
+ v === null ? Type.Null() : Type.Literal(v as string | number | boolean),
257
+ ));
258
+ }
259
+ if ('const' in schema) {
260
+ const value = schema['const'];
261
+ return value === null ? Type.Null() : Type.Literal(value as string | number | boolean);
248
262
  }
249
263
  if ('oneOf' in schema) {
250
264
  return Type.Union((schema['oneOf'] as Record<string, unknown>[]).map(jsonSchemaToTypeBox));
@@ -139,11 +139,23 @@ export class RefResolver {
139
139
  if (refString.includes('#')) {
140
140
  const [filePart, pointer] = refString.split('#', 2);
141
141
  const base = currentFile ? dirname(currentFile) : this._schemasDir;
142
- return [resolve(base, filePart), pointer];
142
+ const resolvedPath = resolve(base, filePart);
143
+ this._assertWithinSchemasDir(resolvedPath, refString);
144
+ return [resolvedPath, pointer];
143
145
  }
144
146
 
145
147
  const base = currentFile ? dirname(currentFile) : this._schemasDir;
146
- return [resolve(base, refString), ''];
148
+ const resolvedPath = resolve(base, refString);
149
+ this._assertWithinSchemasDir(resolvedPath, refString);
150
+ return [resolvedPath, ''];
151
+ }
152
+
153
+ private _assertWithinSchemasDir(resolvedPath: string, refString: string): void {
154
+ if (!resolvedPath.startsWith(this._schemasDir + '/') && resolvedPath !== this._schemasDir) {
155
+ throw new SchemaNotFoundError(
156
+ `Reference '${refString}' resolves outside schemas directory`,
157
+ );
158
+ }
147
159
  }
148
160
 
149
161
  private _convertCanonicalToPath(uri: string): [string, string] {
@@ -15,7 +15,7 @@ export function applyLlmDescriptions(node: unknown): void {
15
15
  if (typeof node !== 'object' || node === null || Array.isArray(node)) return;
16
16
 
17
17
  const obj = node as Record<string, unknown>;
18
- if ('x-llm-description' in obj && 'description' in obj) {
18
+ if ('x-llm-description' in obj) {
19
19
  obj['description'] = obj['x-llm-description'];
20
20
  }
21
21
 
@@ -93,6 +93,16 @@ function convertToStrict(node: unknown): void {
93
93
  (prop['type'] as string[]).push('null');
94
94
  }
95
95
  }
96
+ } else if ('oneOf' in prop && Array.isArray(prop['oneOf'])) {
97
+ const variants = prop['oneOf'] as Record<string, unknown>[];
98
+ if (!variants.some((v) => v['type'] === 'null')) {
99
+ variants.push({ type: 'null' });
100
+ }
101
+ } else if ('anyOf' in prop && Array.isArray(prop['anyOf'])) {
102
+ const variants = prop['anyOf'] as Record<string, unknown>[];
103
+ if (!variants.some((v) => v['type'] === 'null')) {
104
+ variants.push({ type: 'null' });
105
+ }
96
106
  } else {
97
107
  properties[name] = { oneOf: [prop, { type: 'null' }] };
98
108
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
+ import { v4 as uuidv4 } from 'uuid';
2
3
  import { Type } from '@sinclair/typebox';
3
4
  import { Executor } from '../../src/executor.js';
4
5
  import { FunctionModule } from '../../src/decorator.js';
@@ -133,7 +134,7 @@ describe('ACL Integration', () => {
133
134
 
134
135
  // Deep call chain (depth > 2) - denied by ACL condition
135
136
  const deepCtx = new Context(
136
- crypto.randomUUID(),
137
+ uuidv4(),
137
138
  'caller1',
138
139
  ['mod1', 'mod2', 'mod3'],
139
140
  executor,