@sweny-ai/core 0.1.111 → 0.1.112

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.
@@ -74,6 +74,15 @@ export interface CliConfig {
74
74
  context: string[];
75
75
  offline: boolean;
76
76
  fetchAuth: Record<string, string>;
77
+ /**
78
+ * When set, sandbox `file:` Sources to this directory tree: a `file:` source
79
+ * that escapes it (via `..`, an absolute path, or a symlink pointing out) is
80
+ * rejected. The sandbox is opt-in: empty string means no root configured, so
81
+ * `file:` Sources read anywhere on disk (default). Set via `--file-root [dir]`
82
+ * (the working directory when the flag is given without a value) or a
83
+ * `.sweny.yml` `file-root: <path>`.
84
+ */
85
+ fileRoot: string;
77
86
  }
78
87
  export declare function registerTriageCommand(program: Command): Command;
79
88
  export declare function parseCliInputs(options: Record<string, unknown>, fileConfig?: FileConfig): CliConfig;
@@ -63,7 +63,8 @@ export function registerTriageCommand(program) {
63
63
  .option("--no-cache", "Disable step cache")
64
64
  .option("--output-dir <path>", "Output directory for file providers (default: .sweny/output)")
65
65
  .option("--workspace-tools <tools>", "Comma-separated workspace tool integrations to enable (slack, notion, pagerduty, monday)")
66
- .option("--offline", "Skip URL Sources and fail fast if any are referenced", false);
66
+ .option("--offline", "Skip URL Sources and fail fast if any are referenced", false)
67
+ .option("--file-root [dir]", "Sandbox file: Sources to this directory tree (defaults to the working directory when the flag is given without a value)");
67
68
  }
68
69
  export function parseCliInputs(options, fileConfig = {}) {
69
70
  const env = process.env;
@@ -183,8 +184,21 @@ export function parseCliInputs(options, fileConfig = {}) {
183
184
  context: fa("context"),
184
185
  offline: Boolean(options.offline),
185
186
  fetchAuth: parseFetchAuth(fileConfig),
187
+ // Opt-in file: sandbox. `--file-root <dir>` sets the root; `--file-root`
188
+ // with no value defaults to cwd; otherwise honor a `.sweny.yml`
189
+ // `file-root: <path>`. Empty string = no root = read-anywhere (default).
190
+ fileRoot: typeof options.fileRoot === "string"
191
+ ? options.fileRoot
192
+ : options.fileRoot === true
193
+ ? process.cwd()
194
+ : fileStr(fileConfig, "file-root"),
186
195
  };
187
196
  }
197
+ /** Read a string key from file config, returning "" when absent or non-string. */
198
+ function fileStr(fileConfig, key) {
199
+ const v = fileConfig[key];
200
+ return typeof v === "string" ? v : "";
201
+ }
188
202
  function parseFetchAuth(fileConfig) {
189
203
  const raw = fileConfig["fetch.auth"];
190
204
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -659,7 +673,8 @@ export function registerImplementCommand(program) {
659
673
  .option("--additional-instructions <text>", "Extra instructions for the coding agent")
660
674
  .option("--stream", "Stream NDJSON events to stdout (for Studio / automation)", false)
661
675
  .option("--verbose", "Print each tool call's input and output inline (human-readable, truncated). Use --stream for full untruncated NDJSON.", false)
662
- .option("--offline", "Skip URL Sources and fail fast if any are referenced", false);
676
+ .option("--offline", "Skip URL Sources and fail fast if any are referenced", false)
677
+ .option("--file-root [dir]", "Sandbox file: Sources to this directory tree (defaults to the working directory when the flag is given without a value)");
663
678
  }
664
679
  /**
665
680
  * Parse a JSON string into MCP server configs.
package/dist/cli/main.js CHANGED
@@ -145,6 +145,7 @@ async function resolveRulesAndContext(config) {
145
145
  offline: config.offline,
146
146
  fetchAuth: config.fetchAuth,
147
147
  env: process.env,
148
+ fileRoot: config.fileRoot || undefined,
148
149
  };
149
150
  const [rulesResult, contextResult] = await Promise.all([
150
151
  loadAdditionalContext(config.rules, loadOptions),
@@ -366,6 +367,7 @@ triageCmd.action(async (options) => {
366
367
  env: process.env,
367
368
  fetchAuth: config.fetchAuth,
368
369
  offline: config.offline,
370
+ fileRoot: config.fileRoot || undefined,
369
371
  });
370
372
  const durationMs = Date.now() - runStart;
371
373
  // Output
@@ -552,6 +554,7 @@ implementCmd.action(async (issueId, options) => {
552
554
  env: process.env,
553
555
  fetchAuth: config.fetchAuth,
554
556
  offline: config.offline,
557
+ fileRoot: config.fileRoot || undefined,
555
558
  });
556
559
  const hasFailed = [...results.values()].some((r) => r.status === "failed");
557
560
  const implDurationMs = Date.now() - implRunStart;
@@ -833,6 +836,7 @@ export async function workflowRunAction(file, options) {
833
836
  env: process.env,
834
837
  fetchAuth: config.fetchAuth,
835
838
  offline: config.offline,
839
+ fileRoot: config.fileRoot || undefined,
836
840
  });
837
841
  const wfDurationMs = Date.now() - runStart;
838
842
  const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
@@ -32,6 +32,15 @@ export interface ExecuteOptions {
32
32
  fetchAuth?: Record<string, string>;
33
33
  /** When true, URL Sources throw instead of fetching */
34
34
  offline?: boolean;
35
+ /**
36
+ * When set, `file:` Sources are sandboxed to this directory tree: a `file:`
37
+ * source that resolves outside it (via `..` traversal, an absolute path, or a
38
+ * symlink pointing out) is rejected with `SOURCE_FILE_OUTSIDE_ROOT`. The
39
+ * sandbox is opt-in: when unset, `file:` sources may read anywhere on disk
40
+ * (default), preserving the read-anywhere behavior workflow authors rely on
41
+ * for files elsewhere in their own repo / machine.
42
+ */
43
+ fileRoot?: string;
35
44
  /**
36
45
  * Workflow-level hard cap on total node executions (the number of times the
37
46
  * main loop visits a node). Guards against unbounded cycles, e.g. a back-edge
package/dist/executor.js CHANGED
@@ -93,11 +93,15 @@ export async function execute(workflow, input, options) {
93
93
  nodeRules.forEach((s, i) => (sourceMap[`nodes.${nodeId}.rules.${i}`] = s));
94
94
  nodeContext.forEach((s, i) => (sourceMap[`nodes.${nodeId}.context.${i}`] = s));
95
95
  }
96
+ const resolveCwd = options.cwd ?? process.cwd();
96
97
  const resolvedSources = await resolveSources(sourceMap, {
97
- cwd: options.cwd ?? process.cwd(),
98
+ cwd: resolveCwd,
98
99
  env: options.env ?? process.env,
99
100
  authConfig: options.fetchAuth ?? {},
100
101
  offline: options.offline ?? false,
102
+ // Opt-in `file:` sandbox: only engaged when the caller sets `fileRoot`.
103
+ // When unset, `file:` sources read anywhere on disk (default).
104
+ fileRoot: options.fileRoot,
101
105
  logger,
102
106
  });
103
107
  trace.sources = resolvedSources;
@@ -7,6 +7,7 @@
7
7
  * or `node:crypto` into bundlers that target the browser.
8
8
  */
9
9
  import * as dns from "node:dns/promises";
10
+ import { realpathSync } from "node:fs";
10
11
  import * as fs from "node:fs/promises";
11
12
  import * as path from "node:path";
12
13
  import { normalizeSource } from "./sources.js";
@@ -385,22 +386,50 @@ function embeddedIpv4(ip) {
385
386
  }
386
387
  /**
387
388
  * Enforce the optional repo-root file sandbox. The sandbox is opt-in: it only
388
- * applies when `fileRoot` is set (preserving legacy "read anywhere" behavior
389
- * for callers that don't configure a root). When active, a `file:` source must
390
- * resolve to a path inside `fileRoot`; `..` traversal and absolute paths that
391
- * escape the root are rejected. Set `allowFileOutsideRoot` to opt back out even
389
+ * applies when `fileRoot` is set. When unset, legacy "read anywhere" behavior is
390
+ * preserved (a `file:` source may resolve to any path on disk). When a root is
391
+ * configured, a `file:` source must resolve to a path inside it; `..` traversal
392
+ * and absolute paths that escape the root are rejected with
393
+ * `SOURCE_FILE_OUTSIDE_ROOT`. Set `allowFileOutsideRoot` to opt back out even
392
394
  * when a root is configured.
395
+ *
396
+ * Symlinks are resolved with `fs.realpathSync` before the containment check so
397
+ * a symlink inside the root pointing at an external target cannot smuggle
398
+ * out-of-root content. `realpath` throws `ENOENT` on a not-yet-existing file; in
399
+ * that case we resolve the nearest existing ancestor (so a symlinked parent dir
400
+ * is still caught) and re-append the missing tail, preserving the downstream
401
+ * `SOURCE_FILE_NOT_FOUND` error for genuinely missing files.
393
402
  */
394
403
  function assertFileWithinRoot(absolute, ctx, fieldPath) {
395
404
  if (ctx.allowFileOutsideRoot)
396
405
  return;
397
406
  if (!ctx.fileRoot)
398
- return; // sandbox not configured — legacy behavior
399
- const root = path.resolve(ctx.fileRoot);
400
- const normalized = path.resolve(absolute);
407
+ return; // no root configured — sandbox not engaged
408
+ const root = realpathOrLexical(path.resolve(ctx.fileRoot));
409
+ const normalized = realpathOrLexical(path.resolve(absolute));
401
410
  const rel = path.relative(root, normalized);
402
411
  const escapes = rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
403
412
  if (escapes) {
404
413
  throw new Error(`SOURCE_FILE_OUTSIDE_ROOT: ${absolute} is outside the allowed root ${root} (referenced by ${fieldPath}). Set allowFileOutsideRoot to permit reads outside the repo.`);
405
414
  }
406
415
  }
416
+ /**
417
+ * Canonicalize `target` via `fs.realpathSync` (resolving symlinks). If the path
418
+ * does not yet exist, walk up to the nearest existing ancestor, canonicalize
419
+ * that, and re-append the non-existent tail. This keeps symlink resolution
420
+ * meaningful for the directory portion (catching a symlinked parent that
421
+ * escapes the root) while not throwing on a file that simply isn't there — that
422
+ * case is surfaced later as `SOURCE_FILE_NOT_FOUND` by the read.
423
+ */
424
+ function realpathOrLexical(target) {
425
+ try {
426
+ return realpathSync(target);
427
+ }
428
+ catch {
429
+ const parent = path.dirname(target);
430
+ if (parent === target)
431
+ return target; // reached filesystem root
432
+ const base = path.basename(target);
433
+ return path.join(realpathOrLexical(parent), base);
434
+ }
435
+ }
package/dist/sources.d.ts CHANGED
@@ -86,15 +86,18 @@ export type SourceResolutionContext = {
86
86
  */
87
87
  fetchTokenHosts?: string[];
88
88
  /**
89
- * Restrict `file:` source resolution to this directory tree (the repo root,
90
- * typically `cwd`). Relative and absolute paths that escape it via `..` or
91
- * symlinks are rejected. Defaults to `cwd`. Set
92
- * {@link allowFileOutsideRoot} to opt out.
89
+ * Restrict `file:` source resolution to this directory tree. Relative and
90
+ * absolute paths that escape it via `..` or via a symlink pointing outside the
91
+ * root are rejected (the resolver canonicalizes with `realpath` before the
92
+ * containment check). The sandbox is opt-in: when unset, it is not engaged
93
+ * (default) and `file:` sources may read anywhere on disk. Set
94
+ * {@link allowFileOutsideRoot} to opt back out even when a root is configured.
93
95
  */
94
96
  fileRoot?: string;
95
97
  /**
96
- * Opt out of the {@link fileRoot} sandbox, permitting `file:` sources to read
97
- * anywhere on disk (legacy behavior). Defaults to `false`.
98
+ * When a {@link fileRoot} IS configured, opt out of the sandbox to permit
99
+ * `file:` sources to read anywhere on disk (legacy read-anywhere behavior).
100
+ * No effect when `fileRoot` is unset. Defaults to `false`.
98
101
  */
99
102
  allowFileOutsideRoot?: boolean;
100
103
  /**
@@ -31,6 +31,13 @@ export interface SourceLoadOptions {
31
31
  fetchAuth?: Record<string, string>;
32
32
  /** Environment used to resolve `fetchAuth` env-var names. Defaults to process.env. */
33
33
  env?: NodeJS.ProcessEnv;
34
+ /**
35
+ * When set, `file:` template/context Sources are sandboxed to this directory
36
+ * tree: a path that escapes it via `..`, an absolute path, or a symlink
37
+ * pointing out is rejected. The sandbox is opt-in: when unset, `file:` sources
38
+ * may read anywhere on disk (default).
39
+ */
40
+ fileRoot?: string;
34
41
  }
35
42
  /**
36
43
  * Load a template from a file path or URL.
package/dist/templates.js CHANGED
@@ -53,11 +53,14 @@ export const DEFAULT_PR_TEMPLATE = `## Summary
53
53
  Fixes #
54
54
  `;
55
55
  function buildCtx(options) {
56
+ const cwd = options.cwd ?? process.cwd();
56
57
  return {
57
- cwd: options.cwd ?? process.cwd(),
58
+ cwd,
58
59
  env: options.env ?? process.env,
59
60
  authConfig: options.fetchAuth ?? {},
60
61
  offline: options.offline ?? false,
62
+ // Opt-in `file:` sandbox: only engaged when the caller sets `fileRoot`.
63
+ fileRoot: options.fileRoot,
61
64
  logger: consoleLogger,
62
65
  };
63
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"