opencode-swarm 4.3.1 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
1
  import { type PluginConfig } from './schema';
2
+ export declare const MAX_CONFIG_FILE_BYTES = 102400;
3
+ export declare const MAX_MERGE_DEPTH = 10;
2
4
  /**
3
5
  * Deep merge two objects, with override values taking precedence.
4
6
  */
@@ -5,4 +5,4 @@ export { createDelegationTrackerHook } from './delegation-tracker';
5
5
  export { extractCurrentPhase, extractCurrentTask, extractDecisions, extractIncompleteTasks, extractPatterns, } from './extractors';
6
6
  export { createPipelineTrackerHook } from './pipeline-tracker';
7
7
  export { createSystemEnhancerHook } from './system-enhancer';
8
- export { composeHandlers, estimateTokens, readSwarmFileAsync, safeHook, } from './utils';
8
+ export { composeHandlers, estimateTokens, readSwarmFileAsync, safeHook, validateSwarmPath, } from './utils';
@@ -7,5 +7,14 @@
7
7
  */
8
8
  export declare function safeHook<I, O>(fn: (input: I, output: O) => Promise<void>): (input: I, output: O) => Promise<void>;
9
9
  export declare function composeHandlers<I, O>(...fns: Array<(input: I, output: O) => Promise<void>>): (input: I, output: O) => Promise<void>;
10
+ /**
11
+ * Validates that a filename is safe to use within the .swarm directory
12
+ *
13
+ * @param directory - The base directory containing the .swarm folder
14
+ * @param filename - The filename to validate
15
+ * @returns The resolved absolute path if validation passes
16
+ * @throws Error if the filename is invalid or attempts path traversal
17
+ */
18
+ export declare function validateSwarmPath(directory: string, filename: string): string;
10
19
  export declare function readSwarmFileAsync(directory: string, filename: string): Promise<string | null>;
11
20
  export declare function estimateTokens(text: string): number;
package/dist/index.js CHANGED
@@ -13603,11 +13603,17 @@ import * as os from "os";
13603
13603
  import * as path from "path";
13604
13604
  var CONFIG_FILENAME = "opencode-swarm.json";
13605
13605
  var PROMPTS_DIR_NAME = "opencode-swarm";
13606
+ var MAX_CONFIG_FILE_BYTES = 102400;
13606
13607
  function getUserConfigDir() {
13607
13608
  return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
13608
13609
  }
13609
13610
  function loadConfigFromPath(configPath) {
13610
13611
  try {
13612
+ const stats = fs.statSync(configPath);
13613
+ if (stats.size > MAX_CONFIG_FILE_BYTES) {
13614
+ console.warn(`[opencode-swarm] Config file too large (max 100 KB): ${configPath}`);
13615
+ return null;
13616
+ }
13611
13617
  const content = fs.readFileSync(configPath, "utf-8");
13612
13618
  const rawConfig = JSON.parse(content);
13613
13619
  const result = PluginConfigSchema.safeParse(rawConfig);
@@ -13624,23 +13630,30 @@ function loadConfigFromPath(configPath) {
13624
13630
  return null;
13625
13631
  }
13626
13632
  }
13627
- function deepMerge(base, override) {
13628
- if (!base)
13629
- return override;
13630
- if (!override)
13631
- return base;
13633
+ var MAX_MERGE_DEPTH = 10;
13634
+ function deepMergeInternal(base, override, depth) {
13635
+ if (depth >= MAX_MERGE_DEPTH) {
13636
+ throw new Error(`deepMerge exceeded maximum depth of ${MAX_MERGE_DEPTH}`);
13637
+ }
13632
13638
  const result = { ...base };
13633
13639
  for (const key of Object.keys(override)) {
13634
13640
  const baseVal = base[key];
13635
13641
  const overrideVal = override[key];
13636
13642
  if (typeof baseVal === "object" && baseVal !== null && typeof overrideVal === "object" && overrideVal !== null && !Array.isArray(baseVal) && !Array.isArray(overrideVal)) {
13637
- result[key] = deepMerge(baseVal, overrideVal);
13643
+ result[key] = deepMergeInternal(baseVal, overrideVal, depth + 1);
13638
13644
  } else {
13639
13645
  result[key] = overrideVal;
13640
13646
  }
13641
13647
  }
13642
13648
  return result;
13643
13649
  }
13650
+ function deepMerge(base, override) {
13651
+ if (!base)
13652
+ return override;
13653
+ if (!override)
13654
+ return base;
13655
+ return deepMergeInternal(base, override, 0);
13656
+ }
13644
13657
  function loadPluginConfig(directory) {
13645
13658
  const userConfigPath = path.join(getUserConfigDir(), "opencode", CONFIG_FILENAME);
13646
13659
  const projectConfigPath = path.join(directory, ".opencode", CONFIG_FILENAME);
@@ -14397,6 +14410,9 @@ function handleAgentsCommand(agents) {
14397
14410
  `);
14398
14411
  }
14399
14412
 
14413
+ // src/hooks/utils.ts
14414
+ import * as path2 from "path";
14415
+
14400
14416
  // src/utils/logger.ts
14401
14417
  var DEBUG = process.env.OPENCODE_SWARM_DEBUG === "1";
14402
14418
  function log(message, data) {
@@ -14439,10 +14455,30 @@ function composeHandlers(...fns) {
14439
14455
  }
14440
14456
  };
14441
14457
  }
14458
+ function validateSwarmPath(directory, filename) {
14459
+ if (/[\0]/.test(filename)) {
14460
+ throw new Error("Invalid filename: contains null bytes");
14461
+ }
14462
+ if (/\.\.[/\\]/.test(filename)) {
14463
+ throw new Error("Invalid filename: path traversal detected");
14464
+ }
14465
+ const baseDir = path2.normalize(path2.resolve(directory, ".swarm"));
14466
+ const resolved = path2.normalize(path2.resolve(baseDir, filename));
14467
+ if (process.platform === "win32") {
14468
+ if (!resolved.toLowerCase().startsWith((baseDir + path2.sep).toLowerCase())) {
14469
+ throw new Error("Invalid filename: path escapes .swarm directory");
14470
+ }
14471
+ } else {
14472
+ if (!resolved.startsWith(baseDir + path2.sep)) {
14473
+ throw new Error("Invalid filename: path escapes .swarm directory");
14474
+ }
14475
+ }
14476
+ return resolved;
14477
+ }
14442
14478
  async function readSwarmFileAsync(directory, filename) {
14443
- const path2 = `${directory}/.swarm/${filename}`;
14444
14479
  try {
14445
- const file2 = Bun.file(path2);
14480
+ const resolvedPath = validateSwarmPath(directory, filename);
14481
+ const file2 = Bun.file(resolvedPath);
14446
14482
  const content = await file2.text();
14447
14483
  return content;
14448
14484
  } catch {
@@ -14781,8 +14817,8 @@ async function doFlush(directory) {
14781
14817
  const activitySection = renderActivitySection();
14782
14818
  const updated = replaceOrAppendSection(existing, "## Agent Activity", activitySection);
14783
14819
  const flushedCount = swarmState.pendingEvents;
14784
- const path2 = `${directory}/.swarm/context.md`;
14785
- await Bun.write(path2, updated);
14820
+ const path3 = `${directory}/.swarm/context.md`;
14821
+ await Bun.write(path3, updated);
14786
14822
  swarmState.pendingEvents = Math.max(0, swarmState.pendingEvents - flushedCount);
14787
14823
  } catch (error49) {
14788
14824
  warn("Agent activity flush failed:", error49);
@@ -15801,10 +15837,10 @@ function mergeDefs2(...defs) {
15801
15837
  function cloneDef2(schema) {
15802
15838
  return mergeDefs2(schema._zod.def);
15803
15839
  }
15804
- function getElementAtPath2(obj, path2) {
15805
- if (!path2)
15840
+ function getElementAtPath2(obj, path3) {
15841
+ if (!path3)
15806
15842
  return obj;
15807
- return path2.reduce((acc, key) => acc?.[key], obj);
15843
+ return path3.reduce((acc, key) => acc?.[key], obj);
15808
15844
  }
15809
15845
  function promiseAllObject2(promisesObj) {
15810
15846
  const keys = Object.keys(promisesObj);
@@ -16163,11 +16199,11 @@ function aborted2(x, startIndex = 0) {
16163
16199
  }
16164
16200
  return false;
16165
16201
  }
16166
- function prefixIssues2(path2, issues) {
16202
+ function prefixIssues2(path3, issues) {
16167
16203
  return issues.map((iss) => {
16168
16204
  var _a2;
16169
16205
  (_a2 = iss).path ?? (_a2.path = []);
16170
- iss.path.unshift(path2);
16206
+ iss.path.unshift(path3);
16171
16207
  return iss;
16172
16208
  });
16173
16209
  }
@@ -16335,7 +16371,7 @@ function treeifyError2(error49, _mapper) {
16335
16371
  return issue3.message;
16336
16372
  };
16337
16373
  const result = { errors: [] };
16338
- const processError = (error50, path2 = []) => {
16374
+ const processError = (error50, path3 = []) => {
16339
16375
  var _a2, _b;
16340
16376
  for (const issue3 of error50.issues) {
16341
16377
  if (issue3.code === "invalid_union" && issue3.errors.length) {
@@ -16345,7 +16381,7 @@ function treeifyError2(error49, _mapper) {
16345
16381
  } else if (issue3.code === "invalid_element") {
16346
16382
  processError({ issues: issue3.issues }, issue3.path);
16347
16383
  } else {
16348
- const fullpath = [...path2, ...issue3.path];
16384
+ const fullpath = [...path3, ...issue3.path];
16349
16385
  if (fullpath.length === 0) {
16350
16386
  result.errors.push(mapper(issue3));
16351
16387
  continue;
@@ -16377,8 +16413,8 @@ function treeifyError2(error49, _mapper) {
16377
16413
  }
16378
16414
  function toDotPath2(_path) {
16379
16415
  const segs = [];
16380
- const path2 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
16381
- for (const seg of path2) {
16416
+ const path3 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
16417
+ for (const seg of path3) {
16382
16418
  if (typeof seg === "number")
16383
16419
  segs.push(`[${seg}]`);
16384
16420
  else if (typeof seg === "symbol")
@@ -27574,7 +27610,7 @@ Use these as DOMAIN values when delegating to @sme.`;
27574
27610
  });
27575
27611
  // src/tools/file-extractor.ts
27576
27612
  import * as fs2 from "fs";
27577
- import * as path2 from "path";
27613
+ import * as path3 from "path";
27578
27614
  var EXT_MAP = {
27579
27615
  python: ".py",
27580
27616
  py: ".py",
@@ -27652,12 +27688,12 @@ var extract_code_blocks = tool({
27652
27688
  if (prefix) {
27653
27689
  filename = `${prefix}_${filename}`;
27654
27690
  }
27655
- let filepath = path2.join(targetDir, filename);
27656
- const base = path2.basename(filepath, path2.extname(filepath));
27657
- const ext = path2.extname(filepath);
27691
+ let filepath = path3.join(targetDir, filename);
27692
+ const base = path3.basename(filepath, path3.extname(filepath));
27693
+ const ext = path3.extname(filepath);
27658
27694
  let counter = 1;
27659
27695
  while (fs2.existsSync(filepath)) {
27660
- filepath = path2.join(targetDir, `${base}_${counter}${ext}`);
27696
+ filepath = path3.join(targetDir, `${base}_${counter}${ext}`);
27661
27697
  counter++;
27662
27698
  }
27663
27699
  try {
@@ -27686,26 +27722,73 @@ Errors:
27686
27722
  }
27687
27723
  });
27688
27724
  // src/tools/gitingest.ts
27725
+ var GITINGEST_TIMEOUT_MS = 1e4;
27726
+ var GITINGEST_MAX_RESPONSE_BYTES = 5242880;
27727
+ var GITINGEST_MAX_RETRIES = 2;
27728
+ var delay = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
27689
27729
  async function fetchGitingest(args) {
27690
- const response = await fetch("https://gitingest.com/api/ingest", {
27691
- method: "POST",
27692
- headers: { "Content-Type": "application/json" },
27693
- body: JSON.stringify({
27694
- input_text: args.url,
27695
- max_file_size: args.maxFileSize ?? 50000,
27696
- pattern: args.pattern ?? "",
27697
- pattern_type: args.patternType ?? "exclude"
27698
- })
27699
- });
27700
- if (!response.ok) {
27701
- throw new Error(`gitingest API error: ${response.status} ${response.statusText}`);
27702
- }
27703
- const data = await response.json();
27704
- return `${data.summary}
27730
+ for (let attempt = 0;attempt <= GITINGEST_MAX_RETRIES; attempt++) {
27731
+ try {
27732
+ const controller = new AbortController;
27733
+ const timeoutId = setTimeout(() => controller.abort(), GITINGEST_TIMEOUT_MS);
27734
+ const response = await fetch("https://gitingest.com/api/ingest", {
27735
+ method: "POST",
27736
+ headers: { "Content-Type": "application/json" },
27737
+ body: JSON.stringify({
27738
+ input_text: args.url,
27739
+ max_file_size: args.maxFileSize ?? 50000,
27740
+ pattern: args.pattern ?? "",
27741
+ pattern_type: args.patternType ?? "exclude"
27742
+ }),
27743
+ signal: controller.signal
27744
+ });
27745
+ clearTimeout(timeoutId);
27746
+ if (response.status >= 500 && attempt < GITINGEST_MAX_RETRIES) {
27747
+ const backoff = 200 * 2 ** attempt;
27748
+ await delay(backoff);
27749
+ continue;
27750
+ }
27751
+ if (response.status >= 400 && response.status < 500) {
27752
+ throw new Error(`gitingest API error: ${response.status} ${response.statusText}`);
27753
+ }
27754
+ if (!response.ok) {
27755
+ throw new Error(`gitingest API error: ${response.status} ${response.statusText}`);
27756
+ }
27757
+ const contentLength = Number(response.headers.get("content-length"));
27758
+ if (Number.isFinite(contentLength) && contentLength > GITINGEST_MAX_RESPONSE_BYTES) {
27759
+ throw new Error("gitingest response too large");
27760
+ }
27761
+ const text = await response.text();
27762
+ if (Buffer.byteLength(text) > GITINGEST_MAX_RESPONSE_BYTES) {
27763
+ throw new Error("gitingest response too large");
27764
+ }
27765
+ const data = JSON.parse(text);
27766
+ return `${data.summary}
27705
27767
 
27706
27768
  ${data.tree}
27707
27769
 
27708
27770
  ${data.content}`;
27771
+ } catch (error93) {
27772
+ if (error93 instanceof DOMException && (error93.name === "TimeoutError" || error93.name === "AbortError")) {
27773
+ if (attempt >= GITINGEST_MAX_RETRIES) {
27774
+ throw new Error("gitingest request timed out");
27775
+ }
27776
+ const backoff = 200 * 2 ** attempt;
27777
+ await delay(backoff);
27778
+ continue;
27779
+ }
27780
+ if (error93 instanceof Error && error93.message.startsWith("gitingest ")) {
27781
+ throw error93;
27782
+ }
27783
+ if (attempt < GITINGEST_MAX_RETRIES) {
27784
+ const backoff = 200 * 2 ** attempt;
27785
+ await delay(backoff);
27786
+ continue;
27787
+ }
27788
+ throw error93;
27789
+ }
27790
+ }
27791
+ throw new Error("gitingest request failed after retries");
27709
27792
  }
27710
27793
  var gitingest = tool({
27711
27794
  description: "Fetch a GitHub repository's full content via gitingest.com. Returns summary, directory tree, and file contents optimized for LLM analysis. Use when you need to understand an external repository's structure or code.",
@@ -5,8 +5,11 @@ export interface GitingestArgs {
5
5
  pattern?: string;
6
6
  patternType?: 'include' | 'exclude';
7
7
  }
8
+ export declare const GITINGEST_TIMEOUT_MS = 10000;
9
+ export declare const GITINGEST_MAX_RESPONSE_BYTES = 5242880;
10
+ export declare const GITINGEST_MAX_RETRIES = 2;
8
11
  /**
9
- * Fetch repository content via gitingest.com API
12
+ * Fetch repository content via gitingest.com API with timeout, size guard, and retry logic
10
13
  */
11
14
  export declare function fetchGitingest(args: GitingestArgs): Promise<string>;
12
15
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm",
3
- "version": "4.3.1",
3
+ "version": "4.3.2",
4
4
  "description": "Architect-centric agentic swarm plugin for OpenCode - hub-and-spoke orchestration with SME consultation, code generation, and QA review",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",