reqon-dsl 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.
Files changed (122) hide show
  1. package/README.md +23 -3
  2. package/dist/ast/nodes.d.ts +8 -0
  3. package/dist/auth/circuit-breaker.d.ts +11 -0
  4. package/dist/auth/circuit-breaker.js +83 -12
  5. package/dist/auth/credentials.d.ts +6 -1
  6. package/dist/auth/credentials.js +12 -4
  7. package/dist/auth/oauth2-provider.js +13 -3
  8. package/dist/auth/rate-limiter.d.ts +8 -1
  9. package/dist/auth/rate-limiter.js +30 -10
  10. package/dist/auth/token-store.js +8 -1
  11. package/dist/cli.d.ts +11 -1
  12. package/dist/cli.js +65 -6
  13. package/dist/config/constants.d.ts +15 -4
  14. package/dist/config/constants.js +15 -4
  15. package/dist/control/server.d.ts +17 -0
  16. package/dist/control/server.js +82 -5
  17. package/dist/control/types.d.ts +6 -0
  18. package/dist/debug/cli-debugger.js +8 -3
  19. package/dist/execution/store.js +2 -2
  20. package/dist/execution-log/events.d.ts +125 -0
  21. package/dist/execution-log/events.js +17 -0
  22. package/dist/execution-log/fold.d.ts +38 -0
  23. package/dist/execution-log/fold.js +54 -0
  24. package/dist/execution-log/index.d.ts +18 -0
  25. package/dist/execution-log/index.js +6 -0
  26. package/dist/execution-log/postgres-store.d.ts +36 -0
  27. package/dist/execution-log/postgres-store.js +108 -0
  28. package/dist/execution-log/resume.d.ts +11 -0
  29. package/dist/execution-log/resume.js +5 -0
  30. package/dist/execution-log/sqlite-store.d.ts +16 -0
  31. package/dist/execution-log/sqlite-store.js +101 -0
  32. package/dist/execution-log/store.d.ts +72 -0
  33. package/dist/execution-log/store.js +182 -0
  34. package/dist/index.d.ts +4 -3
  35. package/dist/index.js +4 -3
  36. package/dist/interpreter/context.d.ts +15 -0
  37. package/dist/interpreter/context.js +3 -0
  38. package/dist/interpreter/evaluator.js +38 -8
  39. package/dist/interpreter/executor.d.ts +63 -1
  40. package/dist/interpreter/executor.js +406 -30
  41. package/dist/interpreter/fetch-handler.d.ts +39 -1
  42. package/dist/interpreter/fetch-handler.js +84 -15
  43. package/dist/interpreter/http.d.ts +31 -2
  44. package/dist/interpreter/http.js +187 -26
  45. package/dist/interpreter/index.d.ts +3 -3
  46. package/dist/interpreter/index.js +3 -3
  47. package/dist/interpreter/pagination.d.ts +1 -1
  48. package/dist/interpreter/pagination.js +7 -1
  49. package/dist/interpreter/step-handlers/for-handler.d.ts +3 -0
  50. package/dist/interpreter/step-handlers/for-handler.js +18 -3
  51. package/dist/interpreter/step-handlers/match-handler.js +5 -2
  52. package/dist/interpreter/step-handlers/store-handler.d.ts +7 -1
  53. package/dist/interpreter/step-handlers/store-handler.js +25 -16
  54. package/dist/interpreter/step-handlers/validate-handler.js +4 -1
  55. package/dist/interpreter/step-handlers/webhook-handler.d.ts +1 -0
  56. package/dist/interpreter/step-handlers/webhook-handler.js +13 -3
  57. package/dist/interpreter/store-manager.d.ts +1 -1
  58. package/dist/interpreter/store-manager.js +5 -1
  59. package/dist/loader/index.js +5 -8
  60. package/dist/mcp/sandbox.d.ts +41 -0
  61. package/dist/mcp/sandbox.js +76 -0
  62. package/dist/mcp/server.js +62 -9
  63. package/dist/oas/loader.d.ts +13 -1
  64. package/dist/oas/loader.js +25 -3
  65. package/dist/oas/mock-generator.js +13 -4
  66. package/dist/oas/validator.js +45 -5
  67. package/dist/observability/events.d.ts +6 -2
  68. package/dist/observability/events.js +0 -5
  69. package/dist/observability/logger.js +17 -10
  70. package/dist/observability/otel.d.ts +8 -0
  71. package/dist/observability/otel.js +45 -10
  72. package/dist/parser/action-parser.js +2 -2
  73. package/dist/parser/base.d.ts +7 -0
  74. package/dist/parser/base.js +11 -0
  75. package/dist/parser/expressions.d.ts +1 -0
  76. package/dist/parser/expressions.js +17 -4
  77. package/dist/parser/fetch-parser.js +13 -2
  78. package/dist/pause/index.d.ts +1 -0
  79. package/dist/pause/index.js +1 -0
  80. package/dist/pause/log-store.d.ts +33 -0
  81. package/dist/pause/log-store.js +98 -0
  82. package/dist/pause/manager.d.ts +12 -0
  83. package/dist/pause/manager.js +77 -28
  84. package/dist/pause/store.js +5 -3
  85. package/dist/scheduler/cron-parser.d.ts +10 -3
  86. package/dist/scheduler/cron-parser.js +227 -48
  87. package/dist/scheduler/scheduler.js +56 -22
  88. package/dist/stores/factory.d.ts +6 -0
  89. package/dist/stores/factory.js +11 -1
  90. package/dist/stores/file.js +9 -17
  91. package/dist/stores/memory.js +3 -12
  92. package/dist/stores/postgrest.d.ts +28 -0
  93. package/dist/stores/postgrest.js +84 -37
  94. package/dist/sync/index.d.ts +3 -2
  95. package/dist/sync/index.js +2 -1
  96. package/dist/sync/log-store.d.ts +30 -0
  97. package/dist/sync/log-store.js +45 -0
  98. package/dist/sync/store.js +1 -1
  99. package/dist/trace/index.d.ts +2 -0
  100. package/dist/trace/index.js +1 -0
  101. package/dist/trace/log-view.d.ts +57 -0
  102. package/dist/trace/log-view.js +76 -0
  103. package/dist/trace/recorder.d.ts +5 -1
  104. package/dist/trace/recorder.js +19 -6
  105. package/dist/trace/store.d.ts +6 -0
  106. package/dist/trace/store.js +47 -22
  107. package/dist/utils/deep-merge.d.ts +10 -0
  108. package/dist/utils/deep-merge.js +23 -0
  109. package/dist/utils/file.d.ts +13 -4
  110. package/dist/utils/file.js +70 -12
  111. package/dist/utils/index.d.ts +1 -1
  112. package/dist/utils/index.js +1 -1
  113. package/dist/utils/long-timeout.d.ts +19 -0
  114. package/dist/utils/long-timeout.js +33 -0
  115. package/dist/utils/path.d.ts +22 -1
  116. package/dist/utils/path.js +46 -1
  117. package/dist/utils/redact.d.ts +22 -0
  118. package/dist/utils/redact.js +42 -0
  119. package/dist/webhook/server.d.ts +9 -0
  120. package/dist/webhook/server.js +115 -30
  121. package/dist/webhook/types.d.ts +9 -1
  122. package/package.json +22 -4
@@ -167,7 +167,7 @@ export class CursorPaginationStrategy {
167
167
  }
168
168
  return {};
169
169
  }
170
- extractResults(response, ctx) {
170
+ extractResults(response, _ctx) {
171
171
  if (!response || typeof response !== 'object') {
172
172
  return { items: [], hasMore: false };
173
173
  }
@@ -206,6 +206,12 @@ export class CursorPaginationStrategy {
206
206
  * Create the appropriate pagination strategy based on config
207
207
  */
208
208
  export function createPaginationStrategy(config) {
209
+ // A non-positive page size makes every page request the same offset and makes
210
+ // the `items.length >= pageSize` termination test always true, so pagination
211
+ // re-fetches page one until it hits the page cap. Reject it up front.
212
+ if (!Number.isFinite(config.pageSize) || config.pageSize < 1) {
213
+ throw new Error(`Pagination page size must be a positive integer, got ${config.pageSize}`);
214
+ }
209
215
  switch (config.type) {
210
216
  case 'offset':
211
217
  return new OffsetPaginationStrategy(config);
@@ -1,6 +1,7 @@
1
1
  import type { ForStep, ActionStep } from '../../ast/nodes.js';
2
2
  import type { StepHandler, StepHandlerDeps } from './types.js';
3
3
  import type { ExecutionContext } from '../context.js';
4
+ import { QueueSignal } from '../signals.js';
4
5
  import type { DebugController, DebugSnapshot } from '../../debug/index.js';
5
6
  export interface ForHandlerDeps extends StepHandlerDeps {
6
7
  executeStep: (step: ActionStep, actionName: string, ctx: ExecutionContext) => Promise<void>;
@@ -17,6 +18,8 @@ export interface ForHandlerDeps extends StepHandlerDeps {
17
18
  }) => void;
18
19
  /** Optional callback to check for pause requests (called every N iterations) */
19
20
  checkPause?: () => Promise<void>;
21
+ /** Handle a `queue` directive raised within a loop item. */
22
+ handleQueue?: (signal: QueueSignal) => Promise<void>;
20
23
  }
21
24
  /**
22
25
  * Handles for...in...where iteration steps
@@ -1,6 +1,7 @@
1
1
  import { evaluate } from '../evaluator.js';
2
2
  import { childContext, setVariable, getVariable } from '../context.js';
3
3
  import { StepError } from '../../errors/index.js';
4
+ import { SkipSignal, QueueSignal } from '../signals.js';
4
5
  /** Heartbeat interval for loop iterations */
5
6
  const LOOP_HEARTBEAT_INTERVAL = 10;
6
7
  /**
@@ -110,9 +111,23 @@ export class ForHandler {
110
111
  async executeForItem(step, item) {
111
112
  const childCtx = childContext(this.deps.ctx);
112
113
  setVariable(childCtx, step.variable, item);
113
- // Execute each inner step with child context
114
- for (const innerStep of step.steps) {
115
- await this.deps.executeStep(innerStep, this.deps.actionName, childCtx);
114
+ try {
115
+ // Execute each inner step with child context
116
+ for (const innerStep of step.steps) {
117
+ await this.deps.executeStep(innerStep, this.deps.actionName, childCtx);
118
+ }
119
+ }
120
+ catch (error) {
121
+ // `skip` skips the rest of this item's steps; the loop continues.
122
+ if (error instanceof SkipSignal) {
123
+ return;
124
+ }
125
+ // `queue` stashes the item and moves on to the next iteration.
126
+ if (error instanceof QueueSignal) {
127
+ await this.deps.handleQueue?.(error);
128
+ return;
129
+ }
130
+ throw error;
116
131
  }
117
132
  }
118
133
  }
@@ -48,7 +48,9 @@ export class MatchHandler {
48
48
  // Execute steps
49
49
  if (matchedArm.steps) {
50
50
  // Debug pause point - before match arm body (step-into mode)
51
- if (this.deps.debugController && this.deps.captureDebugSnapshot && this.deps.handleDebugCommand) {
51
+ if (this.deps.debugController &&
52
+ this.deps.captureDebugSnapshot &&
53
+ this.deps.handleDebugCommand) {
52
54
  const location = {
53
55
  action: this.deps.actionName,
54
56
  stepIndex: -1, // Use -1 for match arms
@@ -79,10 +81,11 @@ export class MatchHandler {
79
81
  throw new JumpSignal(flow.action, flow.then);
80
82
  case 'queue':
81
83
  throw new QueueSignal(value, flow.target);
82
- default:
84
+ default: {
83
85
  // This should never happen if TypeScript is working correctly
84
86
  const _exhaustive = flow;
85
87
  throw new Error(`Unknown flow directive: ${_exhaustive.type}`);
88
+ }
86
89
  }
87
90
  }
88
91
  }
@@ -9,7 +9,11 @@ export declare class StoreHandler implements StepHandler<StoreStep> {
9
9
  constructor(deps: StepHandlerDeps);
10
10
  /**
11
11
  * Compute the storage key for a record based on step options.
12
- * Uses the key expression if provided, otherwise falls back to record.id or a random key.
12
+ *
13
+ * Uses the key expression if provided, otherwise falls back to `record.id`.
14
+ * A missing or null/undefined key is an error: inventing a random key breaks
15
+ * dedup (re-runs duplicate everything), and stringifying undefined collapses
16
+ * every such record onto the literal key "undefined".
13
17
  */
14
18
  private getRecordKey;
15
19
  /**
@@ -19,5 +23,7 @@ export declare class StoreHandler implements StepHandler<StoreStep> {
19
23
  execute(step: StoreStep): Promise<void>;
20
24
  private storeMany;
21
25
  private storeOne;
26
+ /** True when the record should be merged into an existing one (deep upsert). */
27
+ private shouldMerge;
22
28
  private storeRecord;
23
29
  }
@@ -11,13 +11,20 @@ export class StoreHandler {
11
11
  }
12
12
  /**
13
13
  * Compute the storage key for a record based on step options.
14
- * Uses the key expression if provided, otherwise falls back to record.id or a random key.
14
+ *
15
+ * Uses the key expression if provided, otherwise falls back to `record.id`.
16
+ * A missing or null/undefined key is an error: inventing a random key breaks
17
+ * dedup (re-runs duplicate everything), and stringifying undefined collapses
18
+ * every such record onto the literal key "undefined".
15
19
  */
16
20
  getRecordKey(step, record) {
17
- if (step.options.key) {
18
- return String(evaluate(step.options.key, this.deps.ctx, record));
21
+ const raw = step.options.key ? evaluate(step.options.key, this.deps.ctx, record) : record.id;
22
+ if (raw === undefined || raw === null || raw === '') {
23
+ const which = step.options.key ? 'key expression' : "record 'id'";
24
+ throw new RuntimeError(`Cannot store to '${step.target}': ${which} is missing or empty. ` +
25
+ `Provide a 'key:' option that resolves to a stable, non-empty value.`, { line: 1, column: 1 }, undefined, { stepType: 'store' });
19
26
  }
20
- return String(record.id ?? Math.random());
27
+ return String(raw);
21
28
  }
22
29
  /**
23
30
  * Emit a data.store event with operation metadata.
@@ -34,7 +41,9 @@ export class StoreHandler {
34
41
  async execute(step) {
35
42
  const store = this.deps.ctx.stores.get(step.target);
36
43
  if (!store) {
37
- throw new RuntimeError(`Store not found: ${step.target}`, { line: 1, column: 1 }, undefined, { stepType: 'store' });
44
+ throw new RuntimeError(`Store not found: ${step.target}`, { line: 1, column: 1 }, undefined, {
45
+ stepType: 'store',
46
+ });
38
47
  }
39
48
  const source = evaluate(step.source, this.deps.ctx);
40
49
  if (Array.isArray(source)) {
@@ -45,18 +54,16 @@ export class StoreHandler {
45
54
  }
46
55
  }
47
56
  async storeMany(step, store, items) {
48
- const operation = step.options.upsert ? 'upsert' : 'set';
57
+ const merge = this.shouldMerge(step);
58
+ const operation = merge ? 'upsert' : 'set';
49
59
  // Check if we can use bulk operations
50
- const canBulkSet = store.bulkSet && !step.options.upsert;
51
- const canBulkUpsert = store.bulkUpsert && step.options.upsert;
60
+ const canBulkSet = store.bulkSet && !merge;
61
+ const canBulkUpsert = store.bulkUpsert && merge;
52
62
  if (canBulkSet || canBulkUpsert) {
53
63
  const records = [];
54
64
  for (const item of items) {
55
65
  const record = item;
56
66
  const key = this.getRecordKey(step, record);
57
- if (step.options.partial !== undefined) {
58
- record._partial = step.options.partial;
59
- }
60
67
  records.push({ key, value: record });
61
68
  }
62
69
  if (canBulkUpsert) {
@@ -78,17 +85,19 @@ export class StoreHandler {
78
85
  }
79
86
  async storeOne(step, store, record) {
80
87
  const key = this.getRecordKey(step, record);
81
- const operation = step.options.upsert ? 'upsert' : 'set';
88
+ const operation = this.shouldMerge(step) ? 'upsert' : 'set';
82
89
  await this.storeRecord(step, store, record);
83
90
  this.deps.log(`Stored item to ${step.target}`);
84
91
  this.emitStoreEvent(step, operation, 1, key);
85
92
  }
93
+ /** True when the record should be merged into an existing one (deep upsert). */
94
+ shouldMerge(step) {
95
+ return step.options.upsert === true || step.options.partial === true;
96
+ }
86
97
  async storeRecord(step, store, record) {
87
98
  const key = this.getRecordKey(step, record);
88
- if (step.options.partial !== undefined) {
89
- record._partial = step.options.partial;
90
- }
91
- if (step.options.upsert) {
99
+ // Never mutate the caller's record or persist a storage-internal flag.
100
+ if (this.shouldMerge(step)) {
92
101
  await store.update(key, record);
93
102
  }
94
103
  else {
@@ -16,7 +16,10 @@ export class ValidateHandler {
16
16
  if (!result) {
17
17
  const message = constraint.message ?? `Validation failed: ${JSON.stringify(constraint.condition)}`;
18
18
  if (constraint.severity === 'error') {
19
- throw new ValidationError(message, { line: 1, column: 1 }, undefined, { constraint: JSON.stringify(constraint.condition), severity: 'error' });
19
+ throw new ValidationError(message, { line: 1, column: 1 }, undefined, {
20
+ constraint: JSON.stringify(constraint.condition),
21
+ severity: 'error',
22
+ });
20
23
  }
21
24
  else {
22
25
  this.deps.log(`Warning: ${message}`);
@@ -36,4 +36,5 @@ export declare class WebhookHandler {
36
36
  * Execute the wait step
37
37
  */
38
38
  execute(step: WebhookStep): Promise<WebhookHandlerResult>;
39
+ private waitAndProcess;
39
40
  }
@@ -44,7 +44,18 @@ export class WebhookHandler {
44
44
  webhookUrl,
45
45
  webhookPath: registration.path,
46
46
  };
47
- // Wait for webhook events
47
+ // Wait for webhook events. The registration must be torn down on every
48
+ // exit path (timeout, filter throw, store failure, retry signal) or it
49
+ // leaks the server-side handle — hence the try/finally below.
50
+ try {
51
+ return await this.waitAndProcess(step, registration, webhookUrl, timeout, ctx, log, emit);
52
+ }
53
+ finally {
54
+ await webhookServer.unregister(registration.id);
55
+ }
56
+ }
57
+ async waitAndProcess(step, registration, webhookUrl, timeout, ctx, log, emit) {
58
+ const webhookServer = this.deps.webhookServer;
48
59
  const result = await webhookServer.waitForEvents(registration.id, timeout);
49
60
  if (result.timedOut) {
50
61
  log(`Webhook timeout: ${webhookUrl}`);
@@ -109,8 +120,7 @@ export class WebhookHandler {
109
120
  timedOut: result.timedOut ?? false,
110
121
  storedTo: step.storage?.target,
111
122
  });
112
- // Clean up registration
113
- await webhookServer.unregister(registration.id);
123
+ // Registration teardown happens in the caller's finally block.
114
124
  return {
115
125
  registration,
116
126
  events,
@@ -13,7 +13,7 @@ import type { StoreAdapter } from '../stores/types.js';
13
13
  export interface StoreManagerConfig {
14
14
  /** Custom store adapters by name */
15
15
  customStores?: Record<string, StoreAdapter>;
16
- /** Development mode - use file stores instead of sql/nosql (default: true) */
16
+ /** Development mode - use file stores instead of sql/nosql (default: false) */
17
17
  developmentMode?: boolean;
18
18
  /** Base directory for file stores (default: '.reqon-data') */
19
19
  dataDir?: string;
@@ -8,6 +8,7 @@
8
8
  * - Resolving store types for development/production modes
9
9
  */
10
10
  import { createStore, resolveStoreType } from '../stores/index.js';
11
+ import { EXECUTION_DEFAULTS } from '../config/index.js';
11
12
  /**
12
13
  * Manages store initialization and provides access to store adapters.
13
14
  */
@@ -28,12 +29,15 @@ export class StoreManager {
28
29
  return;
29
30
  }
30
31
  // Use store factory to create appropriate adapter
31
- const developmentMode = this.config.developmentMode ?? true;
32
+ const developmentMode = this.config.developmentMode ?? EXECUTION_DEFAULTS.DEVELOPMENT_MODE;
32
33
  const storeType = resolveStoreType(store.storeType, developmentMode);
33
34
  const adapter = createStore({
34
35
  type: storeType,
35
36
  name: store.target,
36
37
  baseDir: this.config.dataDir,
38
+ // In dev mode the type is already resolved to 'file'; this only matters
39
+ // if a raw sql/nosql type reaches the factory.
40
+ allowFileFallback: developmentMode,
37
41
  });
38
42
  ctx.stores.set(store.name, adapter);
39
43
  ctx.storeTypes.set(store.name, storeType);
@@ -59,9 +59,7 @@ async function loadMissionFile(filePath) {
59
59
  */
60
60
  async function loadMissionFolder(folderPath, options) {
61
61
  // Find root file - try extensions in order of preference
62
- const extensionsToTry = options.extension
63
- ? [options.extension]
64
- : SUPPORTED_EXTENSIONS;
62
+ const extensionsToTry = options.extension ? [options.extension] : SUPPORTED_EXTENSIONS;
65
63
  let rootFilePath = null;
66
64
  let ext = null;
67
65
  for (const extension of extensionsToTry) {
@@ -77,9 +75,8 @@ async function loadMissionFolder(folderPath, options) {
77
75
  }
78
76
  }
79
77
  if (!rootFilePath || !ext) {
80
- const tried = extensionsToTry.map(e => `mission${e}`).join(' or ');
81
- throw new Error(`Mission folder must contain a root file (${tried}). ` +
82
- `Not found in: ${folderPath}`);
78
+ const tried = extensionsToTry.map((e) => `mission${e}`).join(' or ');
79
+ throw new Error(`Mission folder must contain a root file (${tried}). ` + `Not found in: ${folderPath}`);
83
80
  }
84
81
  // Load root file
85
82
  const rootSource = await readFile(rootFilePath, 'utf-8');
@@ -87,7 +84,7 @@ async function loadMissionFolder(folderPath, options) {
87
84
  // Find all other action files in the folder (same extension as root)
88
85
  const rootFileName = basename(rootFilePath);
89
86
  const files = await readdir(folderPath);
90
- const actionFiles = files.filter(f => f.endsWith(ext) && f !== rootFileName);
87
+ const actionFiles = files.filter((f) => f.endsWith(ext) && f !== rootFileName);
91
88
  const sourceFiles = [rootFilePath];
92
89
  const externalActions = [];
93
90
  // Parse each action file
@@ -169,7 +166,7 @@ function validatePipelineActions(program) {
169
166
  const mission = program.statements.find((s) => s.type === 'MissionDefinition');
170
167
  if (!mission)
171
168
  return;
172
- const actionNames = new Set(mission.actions.map(a => a.name));
169
+ const actionNames = new Set(mission.actions.map((a) => a.name));
173
170
  for (const stage of mission.pipeline.stages) {
174
171
  const stageActions = stage.actions ?? (stage.action ? [stage.action] : []);
175
172
  for (const actionName of stageActions) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sandboxing helpers for the MCP server.
3
+ *
4
+ * The MCP server is driven by an LLM acting on untrusted input, so missions
5
+ * must not be able to reach the network/filesystem or escape the working
6
+ * directory unless the operator explicitly opts in.
7
+ */
8
+ /**
9
+ * Resolve an untrusted path against a base working directory and assert the
10
+ * result stays inside it. Blocks `../` and absolute-path escapes.
11
+ *
12
+ * @throws if the resolved path escapes the working directory
13
+ */
14
+ export declare function resolveWithinWorkingDir(workingDir: string, p: string): string;
15
+ /**
16
+ * Decide the effective dryRun for an execution. Effects (real network/fs) are
17
+ * opt-in: when not allowed, force dryRun regardless of what the caller asked.
18
+ */
19
+ export declare function resolveDryRun(allowEffects: boolean, requestedDryRun?: boolean): boolean;
20
+ /** Default wall-clock budget for an untrusted mission execution. */
21
+ export declare const DEFAULT_EXECUTION_TIMEOUT_MS = 30000;
22
+ /** Thrown when an execution exceeds its allotted time budget. */
23
+ export declare class ExecutionTimeoutError extends Error {
24
+ readonly timeoutMs: number;
25
+ constructor(timeoutMs: number);
26
+ }
27
+ /**
28
+ * Race a promise against a timeout. An untrusted (LLM-supplied) mission could
29
+ * otherwise loop or hang forever (e.g. a long pause/wait) and wedge the server.
30
+ * On timeout the returned promise rejects with an {@link ExecutionTimeoutError};
31
+ * the underlying work may keep running, but the caller is no longer blocked.
32
+ *
33
+ * A non-positive or non-finite timeout disables the guard.
34
+ */
35
+ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T>;
36
+ /**
37
+ * Whether a store of the given type may be registered under the current effects
38
+ * policy. File-backed stores write to disk, which is an effect, so they require
39
+ * effects to be enabled. In-memory stores are always allowed.
40
+ */
41
+ export declare function isStoreTypeAllowed(storeType: string | undefined, allowEffects: boolean): boolean;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Sandboxing helpers for the MCP server.
3
+ *
4
+ * The MCP server is driven by an LLM acting on untrusted input, so missions
5
+ * must not be able to reach the network/filesystem or escape the working
6
+ * directory unless the operator explicitly opts in.
7
+ */
8
+ import { resolve, sep } from 'node:path';
9
+ /**
10
+ * Resolve an untrusted path against a base working directory and assert the
11
+ * result stays inside it. Blocks `../` and absolute-path escapes.
12
+ *
13
+ * @throws if the resolved path escapes the working directory
14
+ */
15
+ export function resolveWithinWorkingDir(workingDir, p) {
16
+ const base = resolve(workingDir);
17
+ const resolved = resolve(base, p);
18
+ if (resolved !== base && !resolved.startsWith(base + sep)) {
19
+ throw new Error(`Path "${p}" escapes the working directory`);
20
+ }
21
+ return resolved;
22
+ }
23
+ /**
24
+ * Decide the effective dryRun for an execution. Effects (real network/fs) are
25
+ * opt-in: when not allowed, force dryRun regardless of what the caller asked.
26
+ */
27
+ export function resolveDryRun(allowEffects, requestedDryRun) {
28
+ if (!allowEffects)
29
+ return true;
30
+ return requestedDryRun ?? false;
31
+ }
32
+ /** Default wall-clock budget for an untrusted mission execution. */
33
+ export const DEFAULT_EXECUTION_TIMEOUT_MS = 30_000;
34
+ /** Thrown when an execution exceeds its allotted time budget. */
35
+ export class ExecutionTimeoutError extends Error {
36
+ timeoutMs;
37
+ constructor(timeoutMs) {
38
+ super(`Execution exceeded the ${timeoutMs}ms timeout`);
39
+ this.timeoutMs = timeoutMs;
40
+ this.name = 'ExecutionTimeoutError';
41
+ }
42
+ }
43
+ /**
44
+ * Race a promise against a timeout. An untrusted (LLM-supplied) mission could
45
+ * otherwise loop or hang forever (e.g. a long pause/wait) and wedge the server.
46
+ * On timeout the returned promise rejects with an {@link ExecutionTimeoutError};
47
+ * the underlying work may keep running, but the caller is no longer blocked.
48
+ *
49
+ * A non-positive or non-finite timeout disables the guard.
50
+ */
51
+ export function withTimeout(promise, timeoutMs) {
52
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
53
+ return promise;
54
+ return new Promise((resolve, reject) => {
55
+ const timer = setTimeout(() => reject(new ExecutionTimeoutError(timeoutMs)), timeoutMs);
56
+ // Don't let the timer keep the event loop alive on its own.
57
+ timer.unref?.();
58
+ promise.then((value) => {
59
+ clearTimeout(timer);
60
+ resolve(value);
61
+ }, (error) => {
62
+ clearTimeout(timer);
63
+ reject(error);
64
+ });
65
+ });
66
+ }
67
+ /**
68
+ * Whether a store of the given type may be registered under the current effects
69
+ * policy. File-backed stores write to disk, which is an effect, so they require
70
+ * effects to be enabled. In-memory stores are always allowed.
71
+ */
72
+ export function isStoreTypeAllowed(storeType, allowEffects) {
73
+ if (storeType === 'file')
74
+ return allowEffects;
75
+ return true;
76
+ }
@@ -17,14 +17,41 @@
17
17
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
18
18
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
19
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
20
+ import { z } from 'zod';
21
+ import { resolveWithinWorkingDir as confinePath, resolveDryRun, withTimeout, isStoreTypeAllowed, DEFAULT_EXECUTION_TIMEOUT_MS, } from './sandbox.js';
20
22
  import { parse, execute, fromPath } from '../index.js';
21
- import { createStore, } from '../stores/index.js';
23
+ import { createStore } from '../stores/index.js';
22
24
  // Global store registry for cross-execution access
23
25
  const storeRegistry = new Map();
24
- let serverConfig = {
26
+ const serverConfig = {
25
27
  workingDirectory: process.cwd(),
26
28
  verbose: false,
29
+ allowEffects: false,
30
+ executionTimeoutMs: DEFAULT_EXECUTION_TIMEOUT_MS,
27
31
  };
32
+ /**
33
+ * Resolve an untrusted path against the configured working directory and
34
+ * assert it stays inside it. Blocks `../` / absolute-path escapes.
35
+ */
36
+ function resolveWithinWorkingDir(p) {
37
+ return confinePath(serverConfig.workingDirectory ?? process.cwd(), p);
38
+ }
39
+ // Argument schemas (validated against each tool's inputSchema via zod).
40
+ const executeArgsSchema = z.object({
41
+ source: z.string(),
42
+ verbose: z.boolean().optional(),
43
+ dryRun: z.boolean().optional(),
44
+ });
45
+ const executeFileArgsSchema = z.object({
46
+ path: z.string(),
47
+ verbose: z.boolean().optional(),
48
+ dryRun: z.boolean().optional(),
49
+ });
50
+ const registerStoreArgsSchema = z.object({
51
+ name: z.string(),
52
+ type: z.enum(['memory', 'file']).optional(),
53
+ path: z.string().optional(),
54
+ });
28
55
  /**
29
56
  * Format execution result for MCP response
30
57
  */
@@ -54,9 +81,12 @@ function formatExecutionResult(result) {
54
81
  * Create executor config
55
82
  */
56
83
  function createExecutorConfig(options) {
84
+ // Effects are opt-in at the server level. When disabled, force dryRun so an
85
+ // untrusted mission cannot reach the network or write to disk, regardless of
86
+ // what the caller requests.
57
87
  return {
58
88
  verbose: options?.verbose ?? serverConfig.verbose,
59
- dryRun: options?.dryRun ?? false,
89
+ dryRun: resolveDryRun(serverConfig.allowEffects ?? false, options?.dryRun),
60
90
  };
61
91
  }
62
92
  // Define available tools
@@ -208,9 +238,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
208
238
  try {
209
239
  switch (name) {
210
240
  case 'reqon.execute': {
211
- const { source, verbose, dryRun } = args;
241
+ const { source, verbose, dryRun } = executeArgsSchema.parse(args);
212
242
  const config = createExecutorConfig({ verbose, dryRun });
213
- const result = await execute(source, config);
243
+ const result = await withTimeout(execute(source, config), serverConfig.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS);
214
244
  return {
215
245
  content: [
216
246
  {
@@ -221,9 +251,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
221
251
  };
222
252
  }
223
253
  case 'reqon.execute_file': {
224
- const { path, verbose, dryRun } = args;
254
+ const { path, verbose, dryRun } = executeFileArgsSchema.parse(args);
255
+ const safePath = resolveWithinWorkingDir(path);
225
256
  const config = createExecutorConfig({ verbose, dryRun });
226
- const result = await fromPath(path, config);
257
+ const result = await withTimeout(fromPath(safePath, config), serverConfig.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS);
227
258
  return {
228
259
  content: [
229
260
  {
@@ -308,7 +339,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
308
339
  };
309
340
  }
310
341
  case 'reqon.register_store': {
311
- const { name, type = 'memory', path } = args;
342
+ const { name, type = 'memory', path } = registerStoreArgsSchema.parse(args);
312
343
  if (storeRegistry.has(name)) {
313
344
  return {
314
345
  content: [
@@ -319,10 +350,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
319
350
  ],
320
351
  };
321
352
  }
353
+ // A file-backed store writes to disk, which is an effect. Refuse to
354
+ // register one unless effects are explicitly enabled, so a sandboxed
355
+ // (dryRun) server can't be coaxed into persisting data.
356
+ if (!isStoreTypeAllowed(type, serverConfig.allowEffects ?? false)) {
357
+ return {
358
+ isError: true,
359
+ content: [
360
+ {
361
+ type: 'text',
362
+ text: `File-backed stores are disabled in sandboxed mode. Start the server with --allow-effects to register a "file" store, or use type "memory".`,
363
+ },
364
+ ],
365
+ };
366
+ }
367
+ // Confine a file store's base directory to the working directory.
368
+ const baseDir = resolveWithinWorkingDir(path ?? '.reqon-data');
322
369
  const store = createStore({
323
370
  type,
324
371
  name,
325
- baseDir: path ?? '.reqon-data',
372
+ baseDir,
326
373
  });
327
374
  storeRegistry.set(name, store);
328
375
  return {
@@ -436,11 +483,17 @@ async function main() {
436
483
  serverConfig.workingDirectory = args[++i];
437
484
  process.chdir(serverConfig.workingDirectory);
438
485
  }
486
+ if (args[i] === '--allow-effects') {
487
+ serverConfig.allowEffects = true;
488
+ }
439
489
  }
440
490
  const transport = new StdioServerTransport();
441
491
  await server.connect(transport);
442
492
  // Log to stderr so it doesn't interfere with MCP protocol on stdout
443
493
  console.error('Reqon MCP Server running on stdio');
494
+ console.error(serverConfig.allowEffects
495
+ ? ' Effects ENABLED (--allow-effects): missions may make real network/filesystem calls.'
496
+ : ' Sandboxed: missions run in dryRun (no network/filesystem effects). Pass --allow-effects to enable.');
444
497
  if (serverConfig.verbose) {
445
498
  console.error(` Working directory: ${serverConfig.workingDirectory}`);
446
499
  }
@@ -15,7 +15,19 @@ export interface OASSource {
15
15
  operations: Map<string, OASOperation>;
16
16
  schemas: Map<string, OpenAPIV3.SchemaObject>;
17
17
  }
18
- export declare function loadOAS(specPath: string, forceReload?: boolean): Promise<OASSource>;
18
+ export interface LoadOASOptions {
19
+ /** Bypass the spec cache and re-parse. */
20
+ forceReload?: boolean;
21
+ /**
22
+ * Allow `$ref` pointers to external documents, including remote http(s)
23
+ * URLs. Off by default: an OAS spec is often untrusted input, and resolving
24
+ * external $refs lets the parser fetch arbitrary hosts/files — an SSRF and
25
+ * resource-exhaustion sink. When false, only internal `#/...` references are
26
+ * resolved; external ones are not fetched.
27
+ */
28
+ allowExternalRefs?: boolean;
29
+ }
30
+ export declare function loadOAS(specPath: string, optionsOrForceReload?: LoadOASOptions | boolean): Promise<OASSource>;
19
31
  export declare function resolveOperation(source: OASSource, operationId: string): OASOperation;
20
32
  export declare function getResponseSchema(source: OASSource, operationId: string, statusCode?: string): OpenAPIV3.SchemaObject | undefined;
21
33
  export declare function clearCache(): void;
@@ -1,11 +1,18 @@
1
1
  import SwaggerParser from '@apidevtools/swagger-parser';
2
2
  // Cache loaded specs to avoid re-parsing
3
3
  const specCache = new Map();
4
- export async function loadOAS(specPath, forceReload = false) {
4
+ export async function loadOAS(specPath, optionsOrForceReload = {}) {
5
+ // Back-compat: a bare boolean used to mean `forceReload`.
6
+ const options = typeof optionsOrForceReload === 'boolean'
7
+ ? { forceReload: optionsOrForceReload }
8
+ : optionsOrForceReload;
9
+ const { forceReload = false, allowExternalRefs = false } = options;
5
10
  if (!forceReload && specCache.has(specPath)) {
6
11
  return specCache.get(specPath);
7
12
  }
8
- const api = await SwaggerParser.dereference(specPath);
13
+ // Default-deny external reference resolution to prevent SSRF / remote fetches.
14
+ const parserOptions = allowExternalRefs ? {} : { resolve: { external: false } };
15
+ const api = (await SwaggerParser.dereference(specPath, parserOptions));
9
16
  const baseUrl = extractBaseUrl(api);
10
17
  const operations = extractOperations(api);
11
18
  const schemas = extractSchemas(api);
@@ -20,10 +27,25 @@ export async function loadOAS(specPath, forceReload = false) {
20
27
  }
21
28
  function extractBaseUrl(spec) {
22
29
  if (spec.servers && spec.servers.length > 0) {
23
- return spec.servers[0].url;
30
+ return resolveServerUrl(spec.servers[0]);
24
31
  }
25
32
  return '';
26
33
  }
34
+ /**
35
+ * Resolve `{variable}` templating in a server URL using each variable's
36
+ * declared default. A template variable with no default is rejected rather
37
+ * than left raw (a raw `{var}` would otherwise be sent as part of request
38
+ * URLs).
39
+ */
40
+ function resolveServerUrl(server) {
41
+ return server.url.replace(/\{([^{}]+)\}/g, (_match, name) => {
42
+ const variable = server.variables?.[name];
43
+ if (variable && variable.default !== undefined && variable.default !== '') {
44
+ return String(variable.default);
45
+ }
46
+ throw new Error(`OAS server URL variable '{${name}}' has no default value (in "${server.url}")`);
47
+ });
48
+ }
27
49
  function extractOperations(spec) {
28
50
  const operations = new Map();
29
51
  if (!spec.paths)