opencode-swarm 7.74.1 → 7.74.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.
package/dist/cli/index.js CHANGED
@@ -52,7 +52,7 @@ var package_default;
52
52
  var init_package = __esm(() => {
53
53
  package_default = {
54
54
  name: "opencode-swarm",
55
- version: "7.74.1",
55
+ version: "7.74.2",
56
56
  description: "Architect-centric agentic swarm plugin for OpenCode - hub-and-spoke orchestration with SME consultation, code generation, and QA review",
57
57
  main: "dist/index.js",
58
58
  types: "dist/index.d.ts",
package/dist/index.js CHANGED
@@ -69,7 +69,7 @@ var package_default;
69
69
  var init_package = __esm(() => {
70
70
  package_default = {
71
71
  name: "opencode-swarm",
72
- version: "7.74.1",
72
+ version: "7.74.2",
73
73
  description: "Architect-centric agentic swarm plugin for OpenCode - hub-and-spoke orchestration with SME consultation, code generation, and QA review",
74
74
  main: "dist/index.js",
75
75
  types: "dist/index.d.ts",
@@ -124899,6 +124899,102 @@ var git_blame = createSwarmTool({
124899
124899
 
124900
124900
  // src/tools/gitingest.ts
124901
124901
  init_zod();
124902
+
124903
+ // src/services/external-content-scanner.ts
124904
+ init_knowledge_validator();
124905
+ function scanInvisibleFormatChars2(text) {
124906
+ const findings = [];
124907
+ const matches = text.match(INVISIBLE_FORMAT_CHARS);
124908
+ if (matches !== null && matches.length > 0) {
124909
+ for (const match of matches) {
124910
+ findings.push({
124911
+ pattern: "invisible_format_chars",
124912
+ field: "external_content",
124913
+ description: `Invisible format characters detected (${matches.length} occurrence(s))`,
124914
+ severity: "error",
124915
+ match: match.slice(0, 100)
124916
+ });
124917
+ }
124918
+ }
124919
+ return findings;
124920
+ }
124921
+ function neutralizeThreatPatterns(text, findings) {
124922
+ if (findings.length === 0) {
124923
+ return text;
124924
+ }
124925
+ let result = text;
124926
+ for (const finding of findings.filter((f) => f.severity === "error")) {
124927
+ const escapedMatch = finding.match.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
124928
+ const pattern = new RegExp(escapedMatch, "g");
124929
+ result = result.replace(pattern, () => `[EXTERNAL_CONTENT_THREAT: ${finding.pattern}] ${finding.match} [/EXTERNAL_CONTENT_THREAT]`);
124930
+ }
124931
+ return result;
124932
+ }
124933
+ function scanExternalContent(text, options) {
124934
+ const trustLevel = options?.trustLevel ?? "low";
124935
+ const maxLength = options?.maxLength ?? 50000;
124936
+ if (text == null) {
124937
+ text = "";
124938
+ }
124939
+ const originalLength = text.length;
124940
+ const findings = [];
124941
+ if (text.length > maxLength) {
124942
+ findings.push({
124943
+ pattern: "oversized_content",
124944
+ field: "external_content",
124945
+ description: `External content exceeds safe size threshold (${text.length} > ${maxLength} bytes)`,
124946
+ severity: "error",
124947
+ match: `${text.length} bytes`
124948
+ });
124949
+ }
124950
+ findings.push(...scanInvisibleFormatChars2(text));
124951
+ for (const entry of PROMPT_INJECTION_PATTERNS) {
124952
+ const match = entry.pattern.exec(text);
124953
+ if (match !== null) {
124954
+ findings.push({
124955
+ pattern: entry.name,
124956
+ field: "external_content",
124957
+ description: entry.description,
124958
+ severity: entry.severity,
124959
+ match: match[0]
124960
+ });
124961
+ }
124962
+ }
124963
+ for (const entry of UNSAFE_INSTRUCTION_PATTERNS) {
124964
+ const match = entry.pattern.exec(text);
124965
+ if (match !== null) {
124966
+ findings.push({
124967
+ pattern: entry.name,
124968
+ field: "external_content",
124969
+ description: entry.description,
124970
+ severity: entry.severity,
124971
+ match: match[0]
124972
+ });
124973
+ }
124974
+ }
124975
+ const promoteWarnings = trustLevel === "low";
124976
+ const modulatedFindings = findings.map((f) => promoteWarnings && f.severity === "warning" ? { ...f, severity: "error" } : f);
124977
+ const hasErrors = modulatedFindings.some((f) => f.severity === "error");
124978
+ const hasWarnings = modulatedFindings.some((f) => f.severity === "warning");
124979
+ let threatLevel;
124980
+ if (hasErrors) {
124981
+ threatLevel = "error";
124982
+ } else if (hasWarnings) {
124983
+ threatLevel = "warning";
124984
+ } else {
124985
+ threatLevel = "none";
124986
+ }
124987
+ const neutralized = neutralizeThreatPatterns(text, modulatedFindings.filter((f) => f.severity === "error"));
124988
+ return {
124989
+ clean: threatLevel === "none",
124990
+ findings: modulatedFindings,
124991
+ threatLevel,
124992
+ originalLength,
124993
+ neutralized
124994
+ };
124995
+ }
124996
+
124997
+ // src/tools/gitingest.ts
124902
124998
  init_create_tool();
124903
124999
  var GITINGEST_TIMEOUT_MS = 1e4;
124904
125000
  var GITINGEST_MAX_RESPONSE_BYTES = 5242880;
@@ -124936,7 +125032,31 @@ async function fetchGitingest(args2) {
124936
125032
  if (Number.isFinite(contentLength) && contentLength > GITINGEST_MAX_RESPONSE_BYTES) {
124937
125033
  throw new Error("gitingest response too large");
124938
125034
  }
124939
- const text = await response.text();
125035
+ let text;
125036
+ const reader = response.body?.getReader();
125037
+ if (reader) {
125038
+ let buffer = "";
125039
+ const decoder = new TextDecoder;
125040
+ let totalBytes = 0;
125041
+ try {
125042
+ while (true) {
125043
+ const { done, value } = await reader.read();
125044
+ if (done)
125045
+ break;
125046
+ totalBytes += value.byteLength;
125047
+ if (totalBytes > GITINGEST_MAX_RESPONSE_BYTES) {
125048
+ throw new Error("gitingest response too large");
125049
+ }
125050
+ buffer += decoder.decode(value, { stream: true });
125051
+ }
125052
+ buffer += decoder.decode();
125053
+ text = buffer;
125054
+ } finally {
125055
+ reader.cancel().catch(() => {});
125056
+ }
125057
+ } else {
125058
+ text = await response.text();
125059
+ }
124940
125060
  if (Buffer.byteLength(text) > GITINGEST_MAX_RESPONSE_BYTES) {
124941
125061
  throw new Error("gitingest response too large");
124942
125062
  }
@@ -124946,11 +125066,24 @@ async function fetchGitingest(args2) {
124946
125066
  } catch {
124947
125067
  throw new Error(`gitingest API returned non-JSON response (${text.length} chars, starts: ${text.slice(0, 80)})`);
124948
125068
  }
124949
- return `${data.summary}
125069
+ const combined = `${data.summary}
124950
125070
 
124951
125071
  ${data.tree}
124952
125072
 
124953
125073
  ${data.content}`;
125074
+ const scanResult = scanExternalContent(combined, { trustLevel: "low" });
125075
+ let result = combined;
125076
+ if (!scanResult.clean) {
125077
+ const threatSummary = scanResult.findings.filter((f) => f.severity === "error").map((f) => `- ${f.pattern}: ${f.description}`).join(`
125078
+ `);
125079
+ result = `[GITINGEST SECURITY NOTE: External repository content scanned and contains potential threat patterns]
125080
+ ${threatSummary}
125081
+
125082
+ [Content follows with threats marked for LLM awareness]
125083
+
125084
+ ${scanResult.neutralized}`;
125085
+ }
125086
+ return result;
124954
125087
  } catch (error93) {
124955
125088
  if (error93 instanceof DOMException && (error93.name === "TimeoutError" || error93.name === "AbortError")) {
124956
125089
  if (attempt >= GITINGEST_MAX_RETRIES) {
@@ -142322,6 +142455,20 @@ var web_search = createSwarmTool({
142322
142455
  freshness
142323
142456
  });
142324
142457
  const evidence = await captureSearchEvidence(dirResult.directory, policy.query, results);
142458
+ const scannedResults = results.map(({ title, url: url3, snippet }) => {
142459
+ const titleScan = scanExternalContent(title, { trustLevel: "low" });
142460
+ const snippetScan = scanExternalContent(snippet, {
142461
+ trustLevel: "low"
142462
+ });
142463
+ const threatLevel = titleScan.threatLevel === "error" || snippetScan.threatLevel === "error" ? "error" : titleScan.threatLevel === "warning" || snippetScan.threatLevel === "warning" ? "warning" : "none";
142464
+ return {
142465
+ title: titleScan.clean ? title : titleScan.neutralized,
142466
+ url: url3,
142467
+ snippet: snippetScan.clean ? snippet : snippetScan.neutralized,
142468
+ evidenceRef: evidence.refByUrl.get(url3),
142469
+ threatLevel
142470
+ };
142471
+ });
142325
142472
  const ok2 = {
142326
142473
  success: true,
142327
142474
  query: policy.query,
@@ -142330,12 +142477,7 @@ var web_search = createSwarmTool({
142330
142477
  freshness,
142331
142478
  removedStaleYears: policy.removedStaleYears,
142332
142479
  totalResults: results.length,
142333
- results: results.map(({ title, url: url3, snippet }) => ({
142334
- title,
142335
- url: url3,
142336
- snippet,
142337
- evidenceRef: evidence.refByUrl.get(url3)
142338
- })),
142480
+ results: scannedResults,
142339
142481
  evidence: {
142340
142482
  stored: evidence.stored,
142341
142483
  path: evidence.path,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * External content scanner — shared ingress point for arbitrary external text.
3
+ *
4
+ * Reuses the prompt-injection and unsafe-instruction patterns from
5
+ * external-skill-validator.ts to scan network-fetched content (gitingest,
6
+ * web_search, future network tools) before it enters the LLM context.
7
+ *
8
+ * Provides a single shared interface: `scanExternalContent(text, options?)`.
9
+ * This ensures consistent threat detection across all external sources
10
+ * and closes the asymmetry documented in issue #1278.
11
+ *
12
+ * Uses an `_internals` DI seam for testability — no `mock.module` leakage.
13
+ */
14
+ import { type ValidationFinding } from './external-skill-validator';
15
+ /** Result from scanning external content for injection and unsafe instructions. */
16
+ export interface ExternalContentScanResult {
17
+ /** Whether threats were detected. */
18
+ clean: boolean;
19
+ /** Individual findings from the scan. */
20
+ findings: ValidationFinding[];
21
+ /** Threats found: 'none', 'warning', or 'error'. */
22
+ threatLevel: 'none' | 'warning' | 'error';
23
+ /** The original text (for comparison). */
24
+ originalLength: number;
25
+ /** The neutralized text with threat markers wrapped. */
26
+ neutralized: string;
27
+ }
28
+ /**
29
+ * Apply invisible-format-character detection to raw text.
30
+ *
31
+ * Unlike the other patterns, invisible format chars are detected by counting
32
+ * occurrences in the raw string (not via regex .test), because we need the
33
+ * match string and they are multi-codepoint.
34
+ *
35
+ * Returns an array of findings (empty if none found).
36
+ * Each finding includes the individual match string (not concatenated),
37
+ * so callers can neutralize each occurrence at its original position.
38
+ */
39
+ declare function scanInvisibleFormatChars(text: string): ValidationFinding[];
40
+ /**
41
+ * Neutralize threat patterns in text by wrapping them with delimiters.
42
+ * This makes them visible to the LLM as data, not instructions.
43
+ */
44
+ declare function neutralizeThreatPatterns(text: string, findings: ValidationFinding[]): string;
45
+ /**
46
+ * Scan arbitrary external content for prompt-injection and unsafe-instruction threats.
47
+ *
48
+ * Returns a structured result with:
49
+ * - `clean`: boolean indicating no error-severity findings
50
+ * - `findings`: all detected findings
51
+ * - `threatLevel`: aggregated threat assessment
52
+ * - `neutralized`: the text with threat patterns wrapped for safety
53
+ *
54
+ * @param text - The external content to scan (arbitrary length, typically from API)
55
+ * @param options - Optional: { trustLevel = 'low' }
56
+ * - 'low': warnings are treated as errors
57
+ * - 'medium'/'high': warnings stay warnings
58
+ */
59
+ export declare function scanExternalContent(text: string, options?: {
60
+ trustLevel?: 'low' | 'medium' | 'high';
61
+ maxLength?: number;
62
+ }): ExternalContentScanResult;
63
+ export declare const _internals: {
64
+ scanInvisibleFormatChars: typeof scanInvisibleFormatChars;
65
+ neutralizeThreatPatterns: typeof neutralizeThreatPatterns;
66
+ };
67
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm",
3
- "version": "7.74.1",
3
+ "version": "7.74.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",