@withone/cli 2.13.0 → 2.14.1

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/README.md CHANGED
@@ -610,14 +610,17 @@ Press Ctrl+C during execution to pause - the run can be resumed later with `one
610
610
  | Option | What it does |
611
611
  |--------|-------------|
612
612
  | `-i, --input <name=value>` | Input parameter (repeatable) |
613
- | `--dry-run` | Resolve each step's interpolations and show what they evaluate to, without executing any step. `$.input.*`/`$.env.*` resolve up front; `$.steps.*` refs are reported as deferred (they're produced at runtime). |
613
+ | `--dry-run` | Resolve each step's interpolations and show what they evaluate to, without running actions, code, bash or file steps. Transform and condition expressions are evaluated, in the sandbox. `$.input.*`/`$.env.*` resolve up front; `$.steps.*` refs are reported as deferred (they're produced at runtime). |
614
614
  | `--mock` | With `--dry-run`: execute transforms/code with realistic mock API responses |
615
615
  | `--stop-after <stepId>` | Execute steps up to and including `<stepId>`, then stop (later steps are not run). Combined with `--dry-run`, runs earlier steps for real and dry-resolves `<stepId>` against their output without executing it. Inspect what ran with `one flow inspect <runId>`. |
616
616
  | `--skip-validation` | Skip input validation against action schemas |
617
617
  | `--allow-bash` | Allow bash step execution (disabled by default for security) |
618
+ | `--allow-code` | Allow `code.module` steps, which run a `.mjs` file with full Node access (disabled by default for security) |
618
619
  | `-v, --verbose` | Show full request/response for each step |
619
620
  | `--output-file <path>` | Stream the full result to a file instead of stdout — for large results that would otherwise be truncated or exceed the JSON string-size limit. stdout (and `--agent` output) then carries an `outputFile` pointer instead of inline `steps`. |
620
621
 
622
+ Inline JavaScript in a flow (transforms, conditions, `if`/`unless`, `code.source`) runs in a sandbox: a separate Node process with no file, child-process or network access and no environment of its own. It needs no flag. `code.module` steps are full Node programs and need `--allow-code`.
623
+
621
624
  Step-level `if`/`unless` conditions (and `while`/`condition` steps) are null-safe: a condition that references a skipped or not-yet-run step's output (e.g. `$.steps.maybeSkipped.output.x`) evaluates to `false` rather than crashing the run.
622
625
 
623
626
  ### `one flow list`
@@ -643,9 +646,10 @@ Resume a paused or failed workflow run from where it left off.
643
646
  ```bash
644
647
  one flow resume abc123
645
648
  one flow resume abc123 --allow-bash # required if the flow has bash steps
649
+ one flow resume abc123 --allow-code # required if the flow has code.module steps
646
650
  ```
647
651
 
648
- `--allow-bash` is gated the same way as on `flow execute` — resuming doesn't
652
+ `--allow-bash` and `--allow-code` are gated the same way as on `flow execute` — resuming doesn't
649
653
  inherit the permission from the original run, so pass it again. `one flow list`
650
654
  shows which flows need it.
651
655
 
@@ -40,7 +40,7 @@ import crypto from "crypto";
40
40
  import fs4 from "fs";
41
41
  import os2 from "os";
42
42
  import path5 from "path";
43
- import { exec, spawn } from "child_process";
43
+ import { exec, spawn as spawn2 } from "child_process";
44
44
  import { promisify } from "util";
45
45
 
46
46
  // src/lib/install-context.ts
@@ -1506,8 +1506,8 @@ var FLOW_SCHEMA = {
1506
1506
  configKey: "code",
1507
1507
  description: "JS code \u2014 inline source or an external .mjs module under the flow's lib/ folder",
1508
1508
  fields: {
1509
- source: { type: "string", required: false, description: 'Inline JS function body (flow context as $, supports await). Mutually exclusive with "module".' },
1510
- module: { type: "string", required: false, description: 'Relative path to a .mjs file under the flow folder (e.g. "lib/normalize.mjs"). Reads $ from stdin as JSON, writes result to stdout as JSON. Mutually exclusive with "source".' }
1509
+ source: { type: "string", required: false, description: 'Inline JS function body (flow context as $, supports await). Runs in the sandbox: no files, programs, network or timers. Mutually exclusive with "module".' },
1510
+ module: { type: "string", required: false, description: 'Relative path to a .mjs file under the flow folder (e.g. "lib/normalize.mjs"). Reads $ from stdin as JSON, writes result to stdout as JSON. Mutually exclusive with "source". Runs with full Node access and requires --allow-code.' }
1511
1511
  },
1512
1512
  example: {
1513
1513
  id: "processData",
@@ -1735,7 +1735,7 @@ function generateFlowGuide() {
1735
1735
  Workflows live in \`.one/flows/\` (relative to your current working directory \u2014 the CLI does NOT walk up parent directories or fall back to a global location) and chain actions across platforms. Two layouts are supported:
1736
1736
 
1737
1737
  - **Folder layout (REQUIRED for new flows)** \u2014 \`.one/flows/<key>/flow.json\`, with an optional \`lib/\` subfolder for JavaScript modules. This is like a skill: the folder groups the JSON spec with any JavaScript modules it needs, so the whole flow is shareable. **Always create new flows in this layout.**
1738
- - **Single-file layout (DEPRECATED)** \u2014 \`.one/flows/<key>.flow.json\`. Still loads and runs for backward compatibility, but is deprecated. Do not create new flows in this layout. When editing an existing single-file flow, migrate it to the folder layout: move \`<key>.flow.json\` to \`<key>/flow.json\` and extract any non-trivial \`code.source\` blocks into \`<key>/lib/*.mjs\` modules.
1738
+ - **Single-file layout (DEPRECATED)** \u2014 \`.one/flows/<key>.flow.json\`. Still loads and runs for backward compatibility, but is deprecated. Do not create new flows in this layout. When editing an existing single-file flow, migrate it to the folder layout: move \`<key>.flow.json\` to \`<key>/flow.json\`. Move a \`code.source\` block into a \`<key>/lib/*.mjs\` module only if it needs files, network or timers: modules run with full Node access and make the flow require \`--allow-code\`.
1739
1739
 
1740
1740
  **Subdirectory groups** \u2014 Flows can be organized into subdirectories: \`.one/flows/<group>/<key>/flow.json\`. For example:
1741
1741
  \`\`\`
@@ -1755,16 +1755,17 @@ When resolving a flow by key, the CLI checks the folder layout first, then the d
1755
1755
  **Agents: always inspect a flow before running it.** Nothing about a flow's runtime requirements is guessable from its name. Before \`flow execute\`, do one of these:
1756
1756
 
1757
1757
  1. Run \`one --agent flow list\` \u2014 the JSON output includes \`requiresBash\`, \`usesCodeModules\`, \`inputs\` (with \`autoResolvable\` flags), \`stepTypes\`, and the flow's \`description\`. This is the fastest path.
1758
- 2. Read the flow's \`description\` field directly from the JSON. Flow authors are required (see "Author conventions" below) to state any \`--allow-bash\` requirement and any non-auto-resolving inputs in the description.
1758
+ 2. Read the flow's \`description\` field directly from the JSON. Flow authors are required (see "Author conventions" below) to state any \`--allow-bash\` or \`--allow-code\` requirement and any non-auto-resolving inputs in the description.
1759
1759
  3. Run \`one --agent flow execute <key> --dry-run\` to see the resolved inputs and step plan without side effects.
1760
1760
 
1761
- If you skip this step you will hit errors like *"Workflow X contains bash steps. Re-run with --allow-bash."* \u2014 the CLI now pre-flights and fails fast, so you won't waste a long run, but the error is still avoidable by reading first.
1761
+ If you skip this step you will hit errors like *"Workflow X contains bash steps. Re-run with --allow-bash."* (or the same for \`code.module\` steps and \`--allow-code\`) \u2014 the CLI now pre-flights and fails fast, so you won't waste a long run, but the error is still avoidable by reading first.
1762
1762
 
1763
1763
  ## Author conventions \u2014 WRITE flows that are safe to execute blind
1764
1764
 
1765
1765
  When you create a flow, its \`description\` field is the contract with future executors (human or agent). It MUST state:
1766
1766
 
1767
1767
  - **\`--allow-bash\` if any step is type \`bash\`.** Example: *"Fetches recent Gmail threads and summarizes them with Claude Haiku. Requires \`--allow-bash\`."*
1768
+ - **\`--allow-code\` if any code step uses \`code.module\`.** Modules run with full Node access, so they are gated like bash.
1768
1769
  - **Every input that does NOT have a \`connection\` hint.** Connection inputs auto-resolve when exactly one matching connection exists; everything else must be passed via \`-i name=value\` and the description must name it.
1769
1770
  - **Any files/directories the flow writes to** so operators know what will be modified on disk.
1770
1771
 
@@ -1782,6 +1783,7 @@ one --agent flow execute <key> -i name=value # Execute (bare
1782
1783
  one --agent flow execute <group/key> -i name=value # Execute (namespaced key)
1783
1784
  one --agent flow execute <key> --dry-run --mock # Test with mock data
1784
1785
  one --agent flow execute <key> --allow-bash # Enable bash steps
1786
+ one --agent flow execute <key> --allow-code # Enable code.module steps (full Node)
1785
1787
  one --agent flow runs [flowKey] # List past runs
1786
1788
  one --agent flow resume <runId> # Resume failed run
1787
1789
  one --agent flow scaffold [template] # Generate a starter template
@@ -1828,7 +1830,7 @@ If you're editing an existing \`.one/flows/<key>.flow.json\`, migrate it \u2014
1828
1830
 
1829
1831
  1. \`mkdir -p .one/flows/<key>/lib\`
1830
1832
  2. Move the file: \`mv .one/flows/<key>.flow.json .one/flows/<key>/flow.json\`
1831
- 3. For each non-trivial \`code\` step with inline \`source\`, extract it into \`lib/<step-id>.mjs\` (see translation pattern below) and swap the step config from \`{ "source": "..." }\` to \`{ "module": "lib/<step-id>.mjs" }\`. One-liners can stay inline.
1833
+ 3. Leave inline \`code.source\` in place unless it needs files, network or timers; it runs in the sandbox and needs no flag. If it does need them, extract it into \`lib/<step-id>.mjs\` (see translation pattern below) and swap the step config to \`{ "module": "lib/<step-id>.mjs" }\`. That gives it full Node access and makes the flow require \`--allow-code\`.
1832
1834
  4. Validate: \`one --agent flow validate <key>\`.
1833
1835
  5. Run it and confirm behavior is unchanged.
1834
1836
 
@@ -2196,13 +2198,272 @@ Set timeout to at least 180000ms (3 min). Run Claude-heavy flows sequentially, n
2196
2198
 
2197
2199
  - Connection keys are **inputs**, not hardcoded
2198
2200
  - Action IDs in examples are placeholders \u2014 always use \`actions find\`
2199
- - Inline \`code.source\` steps allow \`require('crypto' | 'buffer' | 'url' | 'path')\` \u2014 \`fs\`, \`http\`, \`child_process\` are blocked
2200
- - For anything beyond one-liners, use \`code.module\` to point at a \`.mjs\` file in the flow's \`lib/\` folder \u2014 runs as a child \`node\` process with full Node APIs, reads \`$\` from stdin, writes JSON to stdout
2201
+ - Transforms, conditions, \`if\`/\`unless\` and inline \`code.source\` run in a sandbox (separate process, no files, programs, network or environment of its own). \`code.source\` allows \`require('crypto' | 'buffer' | 'url' | 'path')\` \u2014 \`fs\`, \`http\`, \`child_process\` are blocked. \`$\` is a copy: return a value, don't mutate \`$\`
2202
+ - When a step needs files, network or timers, use \`code.module\` to point at a \`.mjs\` file in the flow's \`lib/\` folder \u2014 runs as a child \`node\` process with full Node APIs, reads \`$\` from stdin, writes JSON to stdout. Requires \`--allow-code\`
2201
2203
  - Bash steps require \`--allow-bash\` flag
2202
2204
  - State is persisted after every step \u2014 resume picks up where it left off`);
2203
2205
  return sections.join("\n");
2204
2206
  }
2205
2207
 
2208
+ // src/lib/flow-sandbox.ts
2209
+ import { spawn } from "child_process";
2210
+ var syncTimeoutMs = 3e4;
2211
+ var callTimeoutMs = 6e4;
2212
+ var SANDBOX_NODE_FLAGS = ["--permission", "--disallow-code-generation-from-strings"];
2213
+ function sandboxEnv() {
2214
+ if (process.platform === "win32" && process.env.SystemRoot) return { SystemRoot: process.env.SystemRoot };
2215
+ return {};
2216
+ }
2217
+ var BOOTSTRAP = String.raw`
2218
+ 'use strict';
2219
+ const vm = require('node:vm');
2220
+ const proc = process;
2221
+ const MODULES = {
2222
+ buffer: require('node:buffer'),
2223
+ crypto: require('node:crypto'),
2224
+ url: require('node:url'),
2225
+ path: require('node:path'),
2226
+ };
2227
+ const BLOCKED = new Set(['fs', 'http', 'https', 'net', 'child_process', 'process', 'os',
2228
+ 'cluster', 'dgram', 'tls', 'vm', 'worker_threads']);
2229
+ const sandboxRequire = async (name) => {
2230
+ const clean = String(name).replace(/^node:/, '');
2231
+ if (BLOCKED.has(clean)) throw new Error('Module "' + name + '" is blocked in code steps');
2232
+ if (!Object.prototype.hasOwnProperty.call(MODULES, clean)) {
2233
+ throw new Error('Module "' + name + '" not available. Allowed: ' + Object.keys(MODULES).join(', '));
2234
+ }
2235
+ return MODULES[clean];
2236
+ };
2237
+ const SYNC_TIMEOUT = Number(proc.argv[proc.argv.length - 1]) || 30000;
2238
+ const send = proc.send.bind(proc);
2239
+
2240
+ for (const k of ['process', 'require', 'module', 'exports', '__filename', '__dirname',
2241
+ 'fetch', 'WebSocket', 'EventSource', 'Request', 'Response', 'Headers', 'FormData',
2242
+ 'setTimeout', 'setInterval', 'setImmediate', 'clearTimeout', 'clearInterval',
2243
+ 'clearImmediate', 'navigator', 'global']) {
2244
+ try { delete globalThis[k]; } catch (_) { /* non-configurable: leave it */ }
2245
+ }
2246
+
2247
+ const describe = (err) => {
2248
+ if (err && typeof err === 'object') {
2249
+ return { name: String(err.name || 'Error'), message: String(err.message), stack: String(err.stack || '') };
2250
+ }
2251
+ return { name: 'Error', message: String(err), stack: '' };
2252
+ };
2253
+
2254
+ const reply = (id, value) => {
2255
+ try {
2256
+ send({ id, ok: true, value });
2257
+ } catch (err) {
2258
+ send({ id, ok: false, error: { name: 'Error', stack: '', message:
2259
+ 'returned a value that cannot be copied out of the sandbox (functions, symbols and class instances with private state cannot be passed between steps): ' + (err && err.message) } });
2260
+ }
2261
+ };
2262
+
2263
+ // The sandbox's copy of $. The host sends only the parts that changed since
2264
+ // the last call (see syncFor in flow-sandbox.ts), so a loop over a large
2265
+ // step output doesn't copy that output again on every iteration.
2266
+ let store = { input: {}, env: {}, steps: {}, loop: {} };
2267
+ const applySync = (sync) => {
2268
+ if (sync.reset) store = { input: {}, env: {}, steps: {}, loop: {} };
2269
+ if ('input' in sync) store.input = sync.input;
2270
+ if ('env' in sync) store.env = sync.env;
2271
+ if ('loop' in sync) store.loop = sync.loop;
2272
+ if (sync.steps) for (const k of Object.keys(sync.steps)) store.steps[k] = sync.steps[k];
2273
+ if (sync.drop) for (const k of sync.drop) delete store.steps[k];
2274
+ };
2275
+
2276
+ proc.on('message', async (msg) => {
2277
+ const { id, kind, code, sync, filename } = msg;
2278
+ try {
2279
+ applySync(sync);
2280
+ globalThis.__one_ctx = store;
2281
+ globalThis.__one_require = sandboxRequire;
2282
+ let value;
2283
+ if (kind === 'expr') {
2284
+ const script = new vm.Script('(function ($) { return (' + code + '\n) })(__one_ctx)', { filename: filename || 'expression' });
2285
+ value = script.runInThisContext({ timeout: SYNC_TIMEOUT });
2286
+ } else {
2287
+ // Same wrapper shape as new AsyncFunction('$', 'require', body), so
2288
+ // stack line numbers are offset by exactly 2, as the engine expects.
2289
+ const script = new vm.Script('(async function anonymous($,require\n) {\n' + code + '\n})(__one_ctx, __one_require)', { filename });
2290
+ value = await script.runInThisContext({ timeout: SYNC_TIMEOUT });
2291
+ }
2292
+ reply(id, value);
2293
+ } catch (err) {
2294
+ send({ id, ok: false, error: describe(err) });
2295
+ } finally {
2296
+ delete globalThis.__one_ctx;
2297
+ delete globalThis.__one_require;
2298
+ }
2299
+ });
2300
+ send({ ready: true });
2301
+ `;
2302
+ var child = null;
2303
+ var ready = null;
2304
+ var nextId = 1;
2305
+ var pending2 = /* @__PURE__ */ new Map();
2306
+ var ERROR_CTORS = {
2307
+ TypeError,
2308
+ SyntaxError,
2309
+ ReferenceError,
2310
+ RangeError,
2311
+ EvalError,
2312
+ URIError
2313
+ };
2314
+ function rebuildError(e) {
2315
+ const Ctor = ERROR_CTORS[e.name] ?? Error;
2316
+ const err = new Ctor(e.message);
2317
+ if (!ERROR_CTORS[e.name] && e.name && e.name !== "Error") err.name = e.name;
2318
+ if (e.stack) err.stack = e.stack;
2319
+ return err;
2320
+ }
2321
+ function setRef(on) {
2322
+ if (!child) return;
2323
+ const channel = child.channel;
2324
+ if (on) {
2325
+ child.ref();
2326
+ channel?.ref();
2327
+ } else {
2328
+ child.unref();
2329
+ channel?.unref();
2330
+ }
2331
+ }
2332
+ function failAll(proc, err) {
2333
+ for (const [id, p] of pending2) {
2334
+ if (p.proc !== proc) continue;
2335
+ clearTimeout(p.timer);
2336
+ pending2.delete(id);
2337
+ p.reject(err);
2338
+ }
2339
+ }
2340
+ function start() {
2341
+ if (ready) return ready;
2342
+ const proc = spawn(
2343
+ process.execPath,
2344
+ [...SANDBOX_NODE_FLAGS, "-e", BOOTSTRAP, "--", String(syncTimeoutMs)],
2345
+ {
2346
+ stdio: ["ignore", "inherit", "inherit", "ipc"],
2347
+ serialization: "advanced",
2348
+ env: sandboxEnv(),
2349
+ windowsHide: true
2350
+ }
2351
+ );
2352
+ child = proc;
2353
+ ready = new Promise((resolve2, reject) => {
2354
+ const onReady = (msg) => {
2355
+ if (msg && typeof msg === "object" && msg.ready) {
2356
+ proc.off("message", onReady);
2357
+ resolve2();
2358
+ }
2359
+ };
2360
+ proc.on("message", onReady);
2361
+ proc.once("error", (err) => reject(new Error(`Could not start the flow code sandbox: ${err.message}`)));
2362
+ proc.once("exit", (code, signal) => {
2363
+ reject(new Error(`Flow code sandbox exited before it was ready (code ${code}, signal ${signal})`));
2364
+ });
2365
+ });
2366
+ proc.on("message", (msg) => {
2367
+ if (typeof msg?.id !== "number") return;
2368
+ const p = pending2.get(msg.id);
2369
+ if (!p) return;
2370
+ clearTimeout(p.timer);
2371
+ pending2.delete(msg.id);
2372
+ if (pending2.size === 0) setRef(false);
2373
+ if (msg.ok) p.resolve(msg.value);
2374
+ else p.reject(rebuildError(msg.error ?? { name: "Error", message: "unknown sandbox error", stack: "" }));
2375
+ });
2376
+ proc.on("exit", (code, signal) => {
2377
+ if (child === proc) {
2378
+ child = null;
2379
+ ready = null;
2380
+ }
2381
+ failAll(proc, new Error(`Flow code sandbox stopped (code ${code}, signal ${signal})`));
2382
+ });
2383
+ proc.on("error", (err) => {
2384
+ if (child === proc) {
2385
+ child = null;
2386
+ ready = null;
2387
+ }
2388
+ failAll(proc, err);
2389
+ });
2390
+ return ready;
2391
+ }
2392
+ var synced = null;
2393
+ function syncFor(proc, context) {
2394
+ const prev = synced && synced.proc === proc && synced.context === context && synced.stepsObj === context.steps ? synced : null;
2395
+ const sync = {};
2396
+ if (!prev) sync.reset = true;
2397
+ if (!prev || prev.input !== context.input) sync.input = context.input;
2398
+ if (!prev || prev.env !== context.env) sync.env = context.env;
2399
+ if (!prev || prev.loop !== context.loop) sync.loop = context.loop;
2400
+ const stepUpdates = {};
2401
+ let anyStep = false;
2402
+ const nextSteps = /* @__PURE__ */ new Map();
2403
+ for (const [k, v] of Object.entries(context.steps)) {
2404
+ nextSteps.set(k, v);
2405
+ if (!prev || prev.steps.get(k) !== v) {
2406
+ stepUpdates[k] = v;
2407
+ anyStep = true;
2408
+ }
2409
+ }
2410
+ if (anyStep) sync.steps = stepUpdates;
2411
+ if (prev) {
2412
+ const drop = [...prev.steps.keys()].filter((k) => !nextSteps.has(k));
2413
+ if (drop.length) sync.drop = drop;
2414
+ }
2415
+ const commit = () => {
2416
+ synced = {
2417
+ proc,
2418
+ context,
2419
+ stepsObj: context.steps,
2420
+ input: context.input,
2421
+ env: context.env,
2422
+ loop: context.loop,
2423
+ steps: nextSteps
2424
+ };
2425
+ };
2426
+ return { sync, commit };
2427
+ }
2428
+ async function call(kind, code, context, filename) {
2429
+ await start();
2430
+ const proc = child;
2431
+ const id = nextId++;
2432
+ return new Promise((resolve2, reject) => {
2433
+ const timer = setTimeout(() => {
2434
+ pending2.delete(id);
2435
+ reject(new Error(`timed out after ${callTimeoutMs / 1e3}s`));
2436
+ if (child === proc) {
2437
+ child = null;
2438
+ ready = null;
2439
+ }
2440
+ proc.kill();
2441
+ }, callTimeoutMs);
2442
+ timer.unref();
2443
+ pending2.set(id, { resolve: resolve2, reject, timer, proc });
2444
+ setRef(true);
2445
+ const { sync, commit } = syncFor(proc, context);
2446
+ try {
2447
+ proc.send({ id, kind, code, sync, filename });
2448
+ commit();
2449
+ } catch (err) {
2450
+ synced = null;
2451
+ clearTimeout(timer);
2452
+ pending2.delete(id);
2453
+ if (pending2.size === 0) setRef(false);
2454
+ reject(new Error(
2455
+ `the flow context contains a value that cannot be passed to the sandbox: ${err.message}`
2456
+ ));
2457
+ }
2458
+ });
2459
+ }
2460
+ function sandboxEvaluate(expr, context) {
2461
+ return call("expr", expr, context, "expression");
2462
+ }
2463
+ function sandboxRunCode(source, context, filename) {
2464
+ return call("code", source, context, filename);
2465
+ }
2466
+
2206
2467
  // src/lib/flow-engine.ts
2207
2468
  var execAsync = promisify(exec);
2208
2469
  function sleep2(ms) {
@@ -2456,13 +2717,12 @@ function resolveValue(value, context) {
2456
2717
  }
2457
2718
  return value;
2458
2719
  }
2459
- function evaluateExpression(expr, context) {
2460
- const fn = new Function("$", `return (${expr})`);
2461
- return fn(context);
2720
+ async function evaluateExpression(expr, context) {
2721
+ return sandboxEvaluate(expr, context);
2462
2722
  }
2463
- function evaluateCondition(expr, context) {
2723
+ async function evaluateCondition(expr, context) {
2464
2724
  try {
2465
- return Boolean(evaluateExpression(expr, context));
2725
+ return Boolean(await evaluateExpression(expr, context));
2466
2726
  } catch (err) {
2467
2727
  if (err instanceof TypeError) return false;
2468
2728
  throw err;
@@ -2538,13 +2798,13 @@ function stepInterpolationSource(step) {
2538
2798
  return {};
2539
2799
  }
2540
2800
  }
2541
- function dryResolveStep(step, context) {
2801
+ async function dryResolveStep(step, context) {
2542
2802
  const base = { stepId: step.id, name: step.name, type: step.type };
2543
2803
  const expr = step.type === "transform" ? step.transform?.expression : step.type === "condition" ? step.condition?.expression : step.type === "while" ? step.while?.condition : void 0;
2544
2804
  if (step.type === "transform" || step.type === "condition" || step.type === "while") {
2545
2805
  if (!expr) return { ...base, references: [] };
2546
2806
  try {
2547
- return { ...base, references: [], resolved: evaluateExpression(expr, context) };
2807
+ return { ...base, references: [], resolved: await evaluateExpression(expr, context) };
2548
2808
  } catch (err) {
2549
2809
  if (err instanceof TypeError) {
2550
2810
  const deps = expressionDeferredOn(expr, context);
@@ -2577,57 +2837,23 @@ function dryResolveStep(step, context) {
2577
2837
  }
2578
2838
  return { ...base, references, resolved };
2579
2839
  }
2580
- function dryResolveAllSteps(steps, context) {
2840
+ async function dryResolveAllSteps(steps, context) {
2581
2841
  const out = [];
2582
- const visit = (list) => {
2842
+ const visit = async (list) => {
2583
2843
  for (const step of list) {
2584
- out.push(dryResolveStep(step, context));
2844
+ out.push(await dryResolveStep(step, context));
2585
2845
  const config = step;
2586
2846
  for (const { configKey, fieldName } of getNestedStepsKeys()) {
2587
2847
  const block = config[configKey];
2588
2848
  if (block && Array.isArray(block[fieldName])) {
2589
- visit(block[fieldName]);
2849
+ await visit(block[fieldName]);
2590
2850
  }
2591
2851
  }
2592
2852
  }
2593
2853
  };
2594
- visit(steps);
2854
+ await visit(steps);
2595
2855
  return out;
2596
2856
  }
2597
- var ALLOWED_MODULES = {
2598
- buffer: () => import("buffer"),
2599
- crypto: () => import("crypto"),
2600
- url: () => import("url"),
2601
- path: () => import("path")
2602
- };
2603
- var BLOCKED_MODULES = /* @__PURE__ */ new Set([
2604
- "fs",
2605
- "http",
2606
- "https",
2607
- "net",
2608
- "child_process",
2609
- "process",
2610
- "os",
2611
- "cluster",
2612
- "dgram",
2613
- "tls",
2614
- "vm",
2615
- "worker_threads"
2616
- ]);
2617
- function createSandboxedRequire() {
2618
- const cache = {};
2619
- return async (moduleName) => {
2620
- const clean = moduleName.replace(/^node:/, "");
2621
- if (BLOCKED_MODULES.has(clean)) {
2622
- throw new Error(`Module "${moduleName}" is blocked in code steps`);
2623
- }
2624
- if (!ALLOWED_MODULES[clean]) {
2625
- throw new Error(`Module "${moduleName}" not available. Allowed: ${Object.keys(ALLOWED_MODULES).join(", ")}`);
2626
- }
2627
- if (!cache[clean]) cache[clean] = await ALLOWED_MODULES[clean]();
2628
- return cache[clean];
2629
- };
2630
- }
2631
2857
  function stripCodeFences(text) {
2632
2858
  const trimmed = text.trim();
2633
2859
  const match = trimmed.match(/^```(?:\w*)\s*\n([\s\S]*?)\n\s*```\s*$/);
@@ -2637,9 +2863,9 @@ function stripToJson(text) {
2637
2863
  const s = stripCodeFences(text);
2638
2864
  const candidates = [s.indexOf("{"), s.indexOf("[")].filter((i) => i >= 0);
2639
2865
  if (candidates.length === 0) return s;
2640
- const start = Math.min(...candidates);
2866
+ const start2 = Math.min(...candidates);
2641
2867
  const end = Math.max(s.lastIndexOf("}"), s.lastIndexOf("]"));
2642
- return end > start ? s.slice(start, end + 1) : s;
2868
+ return end > start2 ? s.slice(start2, end + 1) : s;
2643
2869
  }
2644
2870
  function unwrapClaudeEnvelope(stdout, stepId) {
2645
2871
  const trimmed = stdout.trim();
@@ -2727,8 +2953,8 @@ async function executeActionStep(step, context, api, permissions, allowedActionI
2727
2953
  output: result.responseData
2728
2954
  };
2729
2955
  }
2730
- function executeTransformStep(step, context) {
2731
- const output = evaluateExpression(step.transform.expression, context);
2956
+ async function executeTransformStep(step, context) {
2957
+ const output = await evaluateExpression(step.transform.expression, context);
2732
2958
  return { status: "success", output, response: output };
2733
2959
  }
2734
2960
  async function executeCodeStep(step, context, options) {
@@ -2740,15 +2966,9 @@ async function executeCodeStep(step, context, options) {
2740
2966
  if (typeof config.source !== "string") {
2741
2967
  throw new Error(`Code step "${step.id}" must define either "source" or "module"`);
2742
2968
  }
2743
- const AsyncFunction = Object.getPrototypeOf(async function() {
2744
- }).constructor;
2745
- const sandboxedRequire = createSandboxedRequire();
2746
2969
  const sourceURL = `code:${step.id}`;
2747
- const taggedSource = `${config.source}
2748
- //# sourceURL=${sourceURL}`;
2749
- const fn = new AsyncFunction("$", "require", taggedSource);
2750
2970
  try {
2751
- const output = await fn(context, sandboxedRequire);
2971
+ const output = await sandboxRunCode(config.source, context, sourceURL);
2752
2972
  return { status: "success", output, response: output };
2753
2973
  } catch (err) {
2754
2974
  throw rewriteCodeStepError(err, step.id, config.source, sourceURL);
@@ -2780,6 +3000,9 @@ function rewriteCodeStepError(err, stepId, source, sourceURL) {
2780
3000
  return err;
2781
3001
  }
2782
3002
  async function executeCodeModule(stepId, modulePath, context, options) {
3003
+ if (!options.allowCode) {
3004
+ throw new Error(`Code step "${stepId}" runs module "${modulePath}" with full Node access and requires the --allow-code flag`);
3005
+ }
2783
3006
  const rootDir = options.rootDir;
2784
3007
  if (!rootDir) {
2785
3008
  throw new Error(`Code step "${stepId}" uses module "${modulePath}" but no flow rootDir is available. Flows that use code modules must be loaded via loadFlowWithMeta.`);
@@ -2799,16 +3022,16 @@ async function executeCodeModule(stepId, modulePath, context, options) {
2799
3022
  void _omitEnv;
2800
3023
  const stdinPayload = JSON.stringify(safeContext);
2801
3024
  return await new Promise((resolve2, reject) => {
2802
- const child = spawn(process.execPath, [absPath], {
3025
+ const child2 = spawn2(process.execPath, [absPath], {
2803
3026
  cwd: rootDir,
2804
3027
  stdio: ["pipe", "pipe", "pipe"]
2805
3028
  });
2806
3029
  const stdoutChunks = [];
2807
3030
  const stderrChunks = [];
2808
- child.stdout.on("data", (c) => stdoutChunks.push(c));
2809
- child.stderr.on("data", (c) => stderrChunks.push(c));
2810
- child.on("error", (err) => reject(err));
2811
- child.on("close", (code) => {
3031
+ child2.stdout.on("data", (c) => stdoutChunks.push(c));
3032
+ child2.stderr.on("data", (c) => stderrChunks.push(c));
3033
+ child2.on("error", (err) => reject(err));
3034
+ child2.on("close", (code) => {
2812
3035
  const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
2813
3036
  const stderr = Buffer.concat(stderrChunks).toString("utf-8");
2814
3037
  if (code !== 0) {
@@ -2826,13 +3049,13 @@ async function executeCodeModule(stepId, modulePath, context, options) {
2826
3049
  reject(new Error(`Code module "${modulePath}" did not print valid JSON to stdout: ${err.message}`));
2827
3050
  }
2828
3051
  });
2829
- child.stdin.write(stdinPayload);
2830
- child.stdin.end();
3052
+ child2.stdin.write(stdinPayload);
3053
+ child2.stdin.end();
2831
3054
  });
2832
3055
  }
2833
3056
  async function executeConditionStep(step, context, api, permissions, allowedActionIds, options, flowStack) {
2834
3057
  const condition = step.condition;
2835
- const result = evaluateCondition(condition.expression, context);
3058
+ const result = await evaluateCondition(condition.expression, context);
2836
3059
  const branch = result ? condition.then : condition.else || [];
2837
3060
  const branchResults = await executeSteps(branch, context, api, permissions, allowedActionIds, options, void 0, flowStack);
2838
3061
  return {
@@ -2975,7 +3198,7 @@ async function executeWhileStep(step, context, api, permissions, allowedActionId
2975
3198
  };
2976
3199
  for (let iteration = 0; iteration < maxIterations; iteration++) {
2977
3200
  if (iteration > 0) {
2978
- const conditionResult = evaluateCondition(config.condition, context);
3201
+ const conditionResult = await evaluateCondition(config.condition, context);
2979
3202
  if (!conditionResult) break;
2980
3203
  }
2981
3204
  await executeSteps(config.steps, context, api, permissions, allowedActionIds, options, void 0, flowStack);
@@ -3000,7 +3223,7 @@ async function executeSubflowStep(step, context, api, permissions, allowedAction
3000
3223
  if (flowStack.includes(resolvedKey)) {
3001
3224
  throw new Error(`Circular flow detected: ${[...flowStack, resolvedKey].join(" \u2192 ")}`);
3002
3225
  }
3003
- const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-Y4DW3UD3.js");
3226
+ const { loadFlowWithMeta: loadFlowWithMeta2 } = await import("./flow-runner-SRO6YL62.js");
3004
3227
  const { flow: subFlow, rootDir: subRootDir } = loadFlowWithMeta2(resolvedKey);
3005
3228
  const subContext = await executeFlow(
3006
3229
  subFlow,
@@ -3197,7 +3420,7 @@ function checkRequires(step, context) {
3197
3420
  }
3198
3421
  async function executeSingleStep(step, context, api, permissions, allowedActionIds, options, flowStack = []) {
3199
3422
  if (step.if) {
3200
- const condResult = evaluateCondition(step.if, context);
3423
+ const condResult = await evaluateCondition(step.if, context);
3201
3424
  if (!condResult) {
3202
3425
  const result = { status: "skipped" };
3203
3426
  context.steps[step.id] = result;
@@ -3205,7 +3428,7 @@ async function executeSingleStep(step, context, api, permissions, allowedActionI
3205
3428
  }
3206
3429
  }
3207
3430
  if (step.unless) {
3208
- const condResult = evaluateCondition(step.unless, context);
3431
+ const condResult = await evaluateCondition(step.unless, context);
3209
3432
  if (condResult) {
3210
3433
  const result = { status: "skipped" };
3211
3434
  context.steps[step.id] = result;
@@ -3269,7 +3492,7 @@ async function executeSingleStep(step, context, api, permissions, allowedActionI
3269
3492
  case "action":
3270
3493
  return await executeActionStep(step, context, api, permissions, allowedActionIds, options);
3271
3494
  case "transform":
3272
- return executeTransformStep(step, context);
3495
+ return await executeTransformStep(step, context);
3273
3496
  case "code":
3274
3497
  return await executeCodeStep(step, context, options);
3275
3498
  case "condition":
@@ -3366,7 +3589,7 @@ async function executeSteps(steps, context, api, permissions, allowedActionIds,
3366
3589
  if (isDryResolve && options.stopAfter === step.id) {
3367
3590
  options.onEvent?.({
3368
3591
  event: "step:dry-resolve",
3369
- ...dryResolveStep(step, context),
3592
+ ...await dryResolveStep(step, context),
3370
3593
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3371
3594
  });
3372
3595
  throw new FlowStopSignal(step.id);
@@ -3483,7 +3706,7 @@ async function executeFlow(flow, inputs, api, permissions, allowedActionIds, opt
3483
3706
  event: "flow:dry-run",
3484
3707
  flowKey: flow.key,
3485
3708
  resolvedInputs,
3486
- steps: dryResolveAllSteps(flow.steps, context),
3709
+ steps: await dryResolveAllSteps(flow.steps, context),
3487
3710
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3488
3711
  });
3489
3712
  return context;
@@ -12,7 +12,7 @@ import {
12
12
  stripStepsAlias,
13
13
  summarizeFlowInputs,
14
14
  walkSteps
15
- } from "./chunk-UZC3IHLW.js";
15
+ } from "./chunk-IM3OA7CU.js";
16
16
  import "./chunk-44CV5IMX.js";
17
17
  import "./chunk-Q6PWTATS.js";
18
18
  import "./chunk-XUAUDMQE.js";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  describeInstallContext,
15
15
  drainQueue,
16
16
  flowRequiresBash,
17
+ flowUsesCodeModules,
17
18
  flush,
18
19
  flushUsageRollups,
19
20
  formatAge,
@@ -39,7 +40,7 @@ import {
39
40
  validateActionInput,
40
41
  walkSteps,
41
42
  writeCache
42
- } from "./chunk-UZC3IHLW.js";
43
+ } from "./chunk-IM3OA7CU.js";
43
44
  import {
44
45
  memSqlCommand
45
46
  } from "./chunk-TSMVFVQ3.js";
@@ -4957,6 +4958,14 @@ ${preflightErrors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`);
4957
4958
  }
4958
4959
  error(msg);
4959
4960
  }
4961
+ if (!options.allowCode && flowUsesCodeModules(flow2)) {
4962
+ const msg = `Workflow "${flow2.key}" contains code.module steps, which run with full Node access. Re-run with --allow-code to permit them.`;
4963
+ if (isAgentMode()) {
4964
+ json({ error: msg, requiresCode: true, flowKey: flow2.key });
4965
+ process.exit(1);
4966
+ }
4967
+ error(msg);
4968
+ }
4960
4969
  if (options.stopAfter) {
4961
4970
  const ids = /* @__PURE__ */ new Set();
4962
4971
  walkSteps(flow2.steps, (s) => {
@@ -5019,6 +5028,7 @@ ${pc8.yellow("Pausing after current step completes...")} (run ID: ${runId})`);
5019
5028
  mock: options.mock,
5020
5029
  verbose: options.verbose,
5021
5030
  allowBash: options.allowBash,
5031
+ allowCode: options.allowCode,
5022
5032
  skipValidation: options.skipValidation,
5023
5033
  stopAfter: options.stopAfter,
5024
5034
  rootDir,
@@ -5129,7 +5139,7 @@ async function flowListCommand() {
5129
5139
  layout: f.layout,
5130
5140
  inputCount: String(f.inputCount),
5131
5141
  stepCount: String(f.stepCount),
5132
- flags: f.requiresBash ? "--allow-bash" : ""
5142
+ flags: [f.requiresBash ? "--allow-bash" : "", f.usesCodeModules ? "--allow-code" : ""].filter(Boolean).join(" ")
5133
5143
  }))
5134
5144
  );
5135
5145
  console.log();
@@ -5205,6 +5215,14 @@ async function flowResumeCommand(runId, options = {}) {
5205
5215
  }
5206
5216
  error(msg);
5207
5217
  }
5218
+ if (!options.allowCode && flowUsesCodeModules(flow2)) {
5219
+ const msg = `Workflow "${flow2.key}" contains code.module steps, which run with full Node access. Re-run with --allow-code to permit them.`;
5220
+ if (isAgentMode()) {
5221
+ json({ error: msg, requiresCode: true, flowKey: flow2.key, runId });
5222
+ process.exit(1);
5223
+ }
5224
+ error(msg);
5225
+ }
5208
5226
  const runner = FlowRunner.fromRunState(state);
5209
5227
  const onEvent = (event) => {
5210
5228
  if (isAgentMode()) {
@@ -5217,7 +5235,8 @@ async function flowResumeCommand(runId, options = {}) {
5217
5235
  const context = await runner.resume(flow2, api, permissions, actionIds, {
5218
5236
  onEvent,
5219
5237
  rootDir,
5220
- allowBash: options.allowBash
5238
+ allowBash: options.allowBash,
5239
+ allowCode: options.allowCode
5221
5240
  });
5222
5241
  spinner.stop("Workflow completed");
5223
5242
  if (isAgentMode()) {
@@ -11436,15 +11455,16 @@ one --agent flow list # List all workflows
11436
11455
 
11437
11456
  **Key concepts:**
11438
11457
  - Workflows live at \`.one/flows/<key>/flow.json\` (folder layout \u2014 REQUIRED for new flows). Flows can be organized into subdirectory groups: \`.one/flows/<group>/<key>/flow.json\`. Reference them as \`group/key\` or just the bare key. The legacy \`.one/flows/<key>.flow.json\` single-file layout is DEPRECATED but still loads for backward compatibility
11439
- - Code steps can reference an external \`.mjs\` module under the flow's \`lib/\` folder (stdin JSON in, stdout JSON out) \u2014 keeps JS out of JSON strings and makes flows shareable
11458
+ - Code steps can reference an external \`.mjs\` module under the flow's \`lib/\` folder (stdin JSON in, stdout JSON out). Modules have full Node access, so flows that use them need \`--allow-code\`
11459
+ - Inline JavaScript (transforms, conditions, \`if\`/\`unless\`, \`code.source\`) runs in a sandbox: no files, programs, network, timers or environment of its own, and no flag needed
11440
11460
  - 12 step types: action, transform, code, condition, loop, parallel, file-read, file-write, while, flow, paginate, bash
11441
11461
  - Data wiring via selectors: \`$.input.param\`, \`$.steps.stepId.response\`, \`$.loop.item\`
11442
11462
  - AI analysis via bash steps: \`claude --print --output-format json\` with \`parseEnvelope: true\` (unwraps the CLI envelope + fences + preamble into clean parsed JSON; use instead of \`parseJson\` for claude steps)
11443
- - Use \`--allow-bash\` to enable bash steps, \`--mock\` for dry-run with realistic mock responses (uses example data from action schemas)
11463
+ - Use \`--allow-bash\` to enable bash steps, \`--allow-code\` to enable \`code.module\` steps, \`--mock\` for dry-run with realistic mock responses (uses example data from action schemas)
11444
11464
  - Use \`--skip-validation\` to bypass input validation on action steps
11445
11465
  - Use \`--output-file <path>\` to stream the full result to a file instead of stdout \u2014 for large results that would otherwise be truncated or hit the JSON string-size limit; stdout (and \`--agent\` output) then carries an \`outputFile\` pointer instead of inline \`steps\`
11446
11466
  - Step-level \`if\`/\`unless\` (and \`while\`/\`condition\` steps) are null-safe: a condition referencing a skipped or not-yet-run step's output (e.g. \`$.steps.maybeSkipped.output.x\`) evaluates to \`false\` instead of crashing the flow
11447
- - Debugging: \`--dry-run\` resolves each step's interpolations and shows what they evaluate to without executing (\`$.steps.*\` refs are reported as deferred); \`--stop-after <stepId>\` runs up to a step then stops; \`--dry-run --stop-after <stepId>\` runs earlier steps for real then dry-resolves the target against their output; \`flow inspect <runId>\` shows a past run's per-step outputs (post-mortem, no re-run; \`--full\` for untruncated)
11467
+ - Debugging: \`--dry-run\` resolves each step's interpolations and shows what they evaluate to without executing actions, code, bash or file steps (transform and condition expressions are evaluated, in the sandbox; \`$.steps.*\` refs are reported as deferred); \`--stop-after <stepId>\` runs up to a step then stops; \`--dry-run --stop-after <stepId>\` runs earlier steps for real then dry-resolves the target against their output; \`flow inspect <runId>\` shows a past run's per-step outputs (post-mortem, no re-run; \`--full\` for untruncated)
11448
11468
 
11449
11469
  ### 3. Relay \u2014 Webhook event forwarding between platforms
11450
11470
  Receive webhooks from platforms (Stripe, GitHub, Airtable, Attio, Google Calendar) and forward event data to any connected platform using passthrough actions with Handlebars templates. No middleware, no code.
@@ -13225,8 +13245,9 @@ function copyDir(src, dest) {
13225
13245
  if (entry.name === SKILL_STAMP) continue;
13226
13246
  const from = path12.join(src, entry.name);
13227
13247
  const to = path12.join(dest, entry.name);
13248
+ if (entry.isSymbolicLink()) continue;
13228
13249
  if (entry.isDirectory()) copyDir(from, to);
13229
- else fs14.copyFileSync(from, to);
13250
+ else if (entry.isFile()) fs14.copyFileSync(from, to);
13230
13251
  }
13231
13252
  }
13232
13253
  function removePath(target) {
@@ -13253,20 +13274,50 @@ function linkDir(target, linkPath) {
13253
13274
  copyDir(target, linkPath);
13254
13275
  return "copy";
13255
13276
  }
13277
+ function knownLinkPaths(skill, scope) {
13278
+ const canonical = `${UNIVERSAL_PROJECT_DIR}/${skill.dirName}`;
13279
+ return new Set(
13280
+ SKILL_AGENT_TARGETS.map((a) => `${scope === "project" ? a.projectDir : a.homeDir}/${skill.dirName}`).filter((p9) => p9 !== canonical)
13281
+ );
13282
+ }
13283
+ function stampLinkPath(skill, plan, rel) {
13284
+ return knownLinkPaths(skill, plan.scope).has(toPosix(rel)) ? fromStamp(plan.root, rel) : null;
13285
+ }
13286
+ function assertInsideProject(plan, target) {
13287
+ if (plan.scope !== "project") return;
13288
+ let dir = path12.dirname(target);
13289
+ while (!fs14.existsSync(dir)) {
13290
+ const parent = path12.dirname(dir);
13291
+ if (parent === dir) break;
13292
+ dir = parent;
13293
+ }
13294
+ const realRoot = fs14.realpathSync(plan.root);
13295
+ const rel = path12.relative(realRoot, fs14.realpathSync(dir));
13296
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
13297
+ throw new Error(`Refusing to change ${target}: a folder on that path leads outside the project (${plan.root}).`);
13298
+ }
13299
+ }
13256
13300
  function installSkill(skill, source, plan, cliVersion2) {
13257
13301
  const canonical = canonicalSkillDir(skill, plan);
13258
13302
  const previous = readStamp(canonical);
13259
13303
  const { readers, folders } = resolveAgentFolders(plan);
13260
13304
  const nextPaths = new Set(folders.map((f) => `${f.dir}/${skill.dirName}`));
13305
+ const targets = folders.map((folder) => {
13306
+ const rel = `${folder.dir}/${skill.dirName}`;
13307
+ return { rel, full: fromStamp(plan.root, rel), agents: folder.agents };
13308
+ });
13309
+ assertInsideProject(plan, canonical);
13310
+ for (const t of targets) assertInsideProject(plan, t.full);
13261
13311
  for (const link of previous?.links ?? []) {
13262
- if (!nextPaths.has(toPosix(link.path))) removePath(fromStamp(plan.root, link.path));
13312
+ if (nextPaths.has(toPosix(link.path))) continue;
13313
+ const full = stampLinkPath(skill, plan, link.path);
13314
+ if (!full) continue;
13315
+ assertInsideProject(plan, full);
13316
+ removePath(full);
13263
13317
  }
13264
13318
  removePath(canonical);
13265
13319
  copyDir(source.dir, canonical);
13266
- const links = folders.map((folder) => {
13267
- const rel = `${folder.dir}/${skill.dirName}`;
13268
- return { path: rel, kind: linkDir(canonical, fromStamp(plan.root, rel)), agents: folder.agents };
13269
- });
13320
+ const links = targets.map((t) => ({ path: t.rel, kind: linkDir(canonical, t.full), agents: t.agents }));
13270
13321
  const stamp = {
13271
13322
  skill: skill.name,
13272
13323
  package: skill.packageName,
@@ -13285,8 +13336,11 @@ function removeSkill(skill, plan) {
13285
13336
  const stamp = readStamp(canonical);
13286
13337
  if (!stamp && !fs14.existsSync(path12.join(canonical, "SKILL.md"))) return { removed: false, paths: [] };
13287
13338
  const paths = [];
13339
+ assertInsideProject(plan, canonical);
13288
13340
  for (const link of stamp?.links ?? []) {
13289
- const full = fromStamp(plan.root, link.path);
13341
+ const full = stampLinkPath(skill, plan, link.path);
13342
+ if (!full) continue;
13343
+ assertInsideProject(plan, full);
13290
13344
  if (fs14.existsSync(full) || isDanglingLink(full)) {
13291
13345
  removePath(full);
13292
13346
  paths.push(full);
@@ -13884,7 +13938,7 @@ var flow = program.command("flow").alias("f").description("Create, execute, and
13884
13938
  flow.command("create [key]").description("Create a new workflow from JSON definition").option("--definition <json>", "Workflow definition as JSON string").option("-o, --output <path>", "Custom output path (default .one/flows/<key>/flow.json)").action(async (key, options) => {
13885
13939
  await flowCreateCommand(key, options);
13886
13940
  });
13887
- flow.command("execute <keyOrPath>").alias("x").description("Execute a workflow by key or file path").option("-i, --input <name=value>", "Input parameter (repeatable)", collect, []).option("--dry-run", "Resolve each step's interpolations and show what they evaluate to, without executing any step ($.steps.* refs resolve at runtime)").option("--mock", "With --dry-run: execute transforms/code with realistic mock API responses").option("--stop-after <stepId>", "Execute steps up to and including <stepId>, then stop (later steps are not run). With --dry-run, runs earlier steps for real and dry-resolves <stepId> against their output without executing it. See: one flow inspect").option("--skip-validation", "Skip input validation against action schemas").option("--allow-bash", "Allow bash step execution (disabled by default for security)").option("-v, --verbose", "Show full request/response for each step").option("--output-file <path>", "Write the full result to a file (streamed) instead of stdout \u2014 avoids truncation/string-limit errors for large results; stdout/agent output then carries an outputFile pointer").action(async (keyOrPath, options) => {
13941
+ flow.command("execute <keyOrPath>").alias("x").description("Execute a workflow by key or file path").option("-i, --input <name=value>", "Input parameter (repeatable)", collect, []).option("--dry-run", "Resolve each step's interpolations and show what they evaluate to, without running actions, code, bash or file steps. Transform and condition expressions are evaluated in the sandbox ($.steps.* refs resolve at runtime)").option("--mock", "With --dry-run: execute transforms/code with realistic mock API responses").option("--stop-after <stepId>", "Execute steps up to and including <stepId>, then stop (later steps are not run). With --dry-run, runs earlier steps for real and dry-resolves <stepId> against their output without executing it. See: one flow inspect").option("--skip-validation", "Skip input validation against action schemas").option("--allow-bash", "Allow bash step execution (disabled by default for security)").option("--allow-code", "Allow code.module steps, which run a .mjs file with full Node access (disabled by default for security). Inline transforms, conditions and code.source run sandboxed and need no flag").option("-v, --verbose", "Show full request/response for each step").option("--output-file <path>", "Write the full result to a file (streamed) instead of stdout \u2014 avoids truncation/string-limit errors for large results; stdout/agent output then carries an outputFile pointer").action(async (keyOrPath, options) => {
13888
13942
  await flowExecuteCommand(keyOrPath, options);
13889
13943
  });
13890
13944
  flow.command("list").alias("ls").description("List all workflows in .one/flows/").action(async () => {
@@ -13893,7 +13947,7 @@ flow.command("list").alias("ls").description("List all workflows in .one/flows/"
13893
13947
  flow.command("validate <keyOrPath>").description("Validate a workflow JSON file").action(async (keyOrPath) => {
13894
13948
  await flowValidateCommand(keyOrPath);
13895
13949
  });
13896
- flow.command("resume <runId>").description("Resume a paused or failed workflow run").option("--allow-bash", "Allow bash step execution (disabled by default for security) \u2014 required to resume any flow containing bash steps").action(async (runId, options) => {
13950
+ flow.command("resume <runId>").description("Resume a paused or failed workflow run").option("--allow-bash", "Allow bash step execution (disabled by default for security) \u2014 required to resume any flow containing bash steps").option("--allow-code", "Allow code.module steps (full Node access) \u2014 required to resume any flow containing them").action(async (runId, options) => {
13897
13951
  await flowResumeCommand(runId, options);
13898
13952
  });
13899
13953
  flow.command("runs [flowKey]").description("List workflow runs (optionally filtered by flow key)").action(async (flowKey) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "2.13.0",
3
+ "version": "2.14.1",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -25,7 +25,7 @@ If a flow's description doesn't tell you how to run it, treat that as a bug in t
25
25
  **Storage layout:**
26
26
 
27
27
  - **Folder layout (REQUIRED for new flows):** `.one/flows/<key>/flow.json`, with an optional `lib/` subfolder for `.mjs` code modules. Like a skill — the folder groups the spec with its helper code, so the whole flow is shareable. **Always create new flows here.**
28
- - **Legacy single-file layout (DEPRECATED):** `.one/flows/<key>.flow.json`. Still loads and runs for backward compatibility, but do not create new flows in this layout. When touching an existing single-file flow, migrate it: move `<key>.flow.json` to `<key>/flow.json` and extract any non-trivial `code.source` blocks into `<key>/lib/*.mjs` modules.
28
+ - **Legacy single-file layout (DEPRECATED):** `.one/flows/<key>.flow.json`. Still loads and runs for backward compatibility, but do not create new flows in this layout. When touching an existing single-file flow, migrate it: move `<key>.flow.json` to `<key>/flow.json`. Move a `code.source` block into a `<key>/lib/*.mjs` module only if it needs files, network or timers: modules run with full Node access and make the flow require `--allow-code`.
29
29
 
30
30
  `one flow create` always writes the folder layout.
31
31
 
@@ -93,20 +93,20 @@ process.stdout.write(JSON.stringify(items.filter(i => i.active)));
93
93
  }
94
94
  ```
95
95
 
96
- The module runs as a child `node` process: the flow context `$` is piped to stdin as JSON, and stdout is parsed as JSON and used as the step's output. Modules have full Node APIs available (unlike inline `code.source`, which is sandboxed). Use `code.module` for anything non-trivial; keep `code.source` for one-liners.
96
+ The module runs as a child `node` process: the flow context `$` is piped to stdin as JSON, and stdout is parsed as JSON and used as the step's output. Modules have full Node APIs available (unlike inline `code.source`, which is sandboxed), so **a flow with any `code.module` step only runs with `--allow-code`**, the same way bash steps need `--allow-bash`. Say so in the flow's `description`. Use `code.module` when you need files, network or timers; keep pure data shaping in `code.source`.
97
97
 
98
- #### Inline `code.source` sandbox
98
+ #### Sandbox for inline JavaScript
99
99
 
100
- Inline `code.source` runs inside an async function with a restricted `require`. Only the following Node built-ins are importable:
100
+ Every piece of JavaScript written inside `flow.json` runs in a sandbox: `transform.expression`, `condition.expression`, `while.condition`, step-level `if`/`unless`, and `code.source`. A flow file can come from anywhere (a cloned repo, a shared folder, another agent), so this code cannot start programs, read or write files, or see the CLI's own environment.
101
101
 
102
- - `node:buffer`
103
- - `node:crypto`
104
- - `node:url`
105
- - `node:path`
102
+ - It runs in a separate `node` process started with Node's permission model (`--permission`), an empty environment, and `eval`/`new Function` turned off.
103
+ - `process`, `require` (outside `code.source`), `fetch`, `WebSocket`, `setTimeout`/`setInterval`, `__dirname` and `__filename` are not defined.
104
+ - Inside `code.source`, `await require(name)` loads only these built-ins: `node:buffer`, `node:crypto`, `node:url`, `node:path`. Everything else (`fs`, `http`, `https`, `net`, `child_process`, `process`, `os`, `cluster`, `dgram`, `tls`, `vm`, `worker_threads`) throws `Module "<name>" is blocked in code steps`.
105
+ - `$` is a copy of the flow context. Read from it and `return` a result; changing `$` inside a step does not change what later steps see.
106
+ - Values cross into and out of the sandbox by structured clone: `Date`, `Map`, `Set`, `RegExp`, typed arrays and `undefined` survive; functions and symbols cannot be returned and fail the step.
107
+ - Each evaluation has a 30-second limit for synchronous work and 60 seconds overall. A step that runs longer fails with `timed out`.
106
108
 
107
- Everything else — `fs`, `http`, `https`, `net`, `child_process`, `process`, `os`, `cluster`, `dgram`, `tls`, `vm`, `worker_threads` — is **blocked** and will throw `Module "<name>" is blocked in code steps`. The runtime also does not expose `process`, `__dirname`, `__filename`, `setTimeout`, or `fetch`.
108
-
109
- If you need any of those (filesystem reads, network calls, timers, etc.), use a `code.module` step instead — modules run as a real child `node` process and have the full Node API surface.
109
+ These steps need no flag. If you need files, network calls or timers, use a `code.module` step, which requires `--allow-code`.
110
110
 
111
111
  When an inline `code.source` step throws at runtime, the error message reports the user-relative line and column plus the offending line of source — e.g. `Code step "blowup" failed at line 3:34\n const c = $.steps.mk.output.data.score;\n Cannot read properties of null (reading 'score')`. No need to bisect the step manually.
112
112
 
@@ -118,7 +118,7 @@ If you touch an existing `.one/flows/<key>.flow.json`, migrate it:
118
118
 
119
119
  1. `mkdir -p .one/flows/<key>/lib`
120
120
  2. `mv .one/flows/<key>.flow.json .one/flows/<key>/flow.json`
121
- 3. Extract non-trivial `code.source` blocks into `lib/<step-id>.mjs` and swap the step config from `{ "source": "..." }` to `{ "module": "lib/<step-id>.mjs" }`. One-liners can stay inline.
121
+ 3. Leave `code.source` inline unless it needs files, network or timers. Inline code runs in the sandbox and needs no flag. Moving it to `lib/<step-id>.mjs` (`{ "module": "lib/<step-id>.mjs" }`) gives it full Node access and makes the flow require `--allow-code`.
122
122
  4. `one --agent flow validate <key>`
123
123
  5. Execute and confirm behavior is unchanged.
124
124
 
@@ -708,8 +708,9 @@ Three tools that turn "re-run the whole 40-step flow to debug step 30" into near
708
708
  - **Prefer passthrough actions over custom actions.** Custom actions add server-side fan-out that causes timeouts at scale. The flow runner handles pagination, retries, and rate limiting locally. Find with `-t knowledge` to reach passthrough endpoints (e.g. GET `/gmail/v1/users/{userId}/threads` instead of POST `/gmail/get-threads`)
709
709
  - Connection keys are inputs, not hardcoded — makes workflows portable
710
710
  - Action IDs in examples are placeholders — always use `actions find` to find real IDs
711
- - Code steps support `require('crypto')`, `require('buffer')`, `require('url')`, `require('path')` — `fs`, `http`, `child_process` are blocked
711
+ - Transforms, conditions, `if`/`unless` and `code.source` run in a sandbox with no file, process or network access. `code.source` supports `require('crypto')`, `require('buffer')`, `require('url')`, `require('path')`; `fs`, `http`, `child_process` are blocked
712
712
  - Bash steps require `--allow-bash` flag
713
+ - `code.module` steps require `--allow-code` flag (they run with full Node access)
713
714
  - Action steps validate required params before executing — pass `--skip-validation` to bypass
714
715
  - `--mock` now returns realistic example data from action schemas (instead of echoed config)
715
716
  - State is persisted after every step — resume picks up where it left off