@webskill/sdk 0.2.4 → 0.2.5

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.
@@ -16,6 +16,10 @@ var WebSkillError = class extends Error {
16
16
  this.details = details;
17
17
  }
18
18
  };
19
+ /** unknown 异常取消息文本(全仓单一来源,勿再本地重复定义) */
20
+ function messageOf(e) {
21
+ return e instanceof Error ? e.message : String(e);
22
+ }
19
23
  /** 技能目录内的安装清单文件名(不参与 files 列表与 digest) */
20
24
  const SKILL_MANIFEST_FILE = "webskill.skill-manifest.json";
21
25
  /** managed root 下的锁定文件名(不参与 files 列表与 digest) */
@@ -200,7 +204,12 @@ var MemoryFS = class {
200
204
  this.#entries.delete(src);
201
205
  }
202
206
  };
203
- /** 原子写文本:先写临时文件再 rename(进程崩溃不留下半写文件;lockfile 等账本场景) */
207
+ /**
208
+ * 原子写文本:先写临时文件再 rename(进程崩溃不留下半写文件;lockfile 等账本场景)。
209
+ * 原子性强弱取决于 provider 的 rename 实现:NodeFS/MemoryFS 为真 rename;
210
+ * OPFS 无原生 move,rename 是 copy+delete(非原子,崩溃可能残留双份),
211
+ * 对崩溃一致性要求极高的场景在浏览器侧需知悉此前提。
212
+ */
204
213
  async function atomicWriteText(fs, path, content) {
205
214
  const tmp = `${path}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
206
215
  await fs.writeText(tmp, content);
@@ -243,6 +252,65 @@ function resolveInsideRoot(root, relativePath) {
243
252
  const joined = normalizedRoot === "" ? clean.join("/") : `${normalizedRoot}/${clean.join("/")}`;
244
253
  return root.startsWith("/") || root.startsWith("\\") ? `/${joined}` : joined;
245
254
  }
255
+ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost."]);
256
+ function ipv4Octets(host) {
257
+ const parts = host.split(".");
258
+ if (parts.length !== 4) return void 0;
259
+ const octets = parts.map((p) => /^\d{1,3}$/.test(p) ? Number(p) : NaN);
260
+ return octets.every((n) => n >= 0 && n <= 255) ? octets : void 0;
261
+ }
262
+ function isPrivateIpv4(octets) {
263
+ const [a, b] = octets;
264
+ return a === 0 || a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
265
+ }
266
+ function isPrivateIpv6(host) {
267
+ const h = host.toLowerCase();
268
+ if (h === "::1" || h === "::") return true;
269
+ const mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
270
+ if (mapped) {
271
+ const octets = ipv4Octets(mapped[1]);
272
+ return octets !== void 0 && isPrivateIpv4(octets);
273
+ }
274
+ const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
275
+ if (mappedHex) {
276
+ const hi = parseInt(mappedHex[1], 16);
277
+ const lo = parseInt(mappedHex[2], 16);
278
+ return isPrivateIpv4([
279
+ hi >> 8,
280
+ hi & 255,
281
+ lo >> 8,
282
+ lo & 255
283
+ ]);
284
+ }
285
+ const first = h.split(":")[0];
286
+ if (/^[0-9a-f]{1,4}$/.test(first)) {
287
+ const n = parseInt(first, 16);
288
+ if ((n & 65472) === 65152) return true;
289
+ if ((n & 65024) === 64512) return true;
290
+ }
291
+ return false;
292
+ }
293
+ function isPrivateHost(hostname) {
294
+ const host = hostname.toLowerCase();
295
+ if (LOOPBACK_HOSTNAMES.has(host) || host.endsWith(".localhost")) return true;
296
+ const v4 = ipv4Octets(host);
297
+ if (v4) return isPrivateIpv4(v4);
298
+ if (host.startsWith("[") && host.endsWith("]")) return isPrivateIpv6(host.slice(1, -1));
299
+ if (host.includes(":")) return isPrivateIpv6(host);
300
+ return false;
301
+ }
302
+ /**
303
+ * 校验远程 URL 是否允许连接;违规抛 NETWORK_BLOCKED。
304
+ * rawUrl 非合法 URL 时抛 TypeError(由调用方按自身错误码包装)。
305
+ */
306
+ function assertRemoteUrlAllowed(rawUrl, policy = {}) {
307
+ const url = new URL(rawUrl);
308
+ if (url.protocol === "http:") {
309
+ if (!policy.allowHttp) throw new WebSkillError("NETWORK_BLOCKED", `Insecure URL scheme "http:" is not allowed (opt in via allowHttp): ${url.href}`);
310
+ } else if (url.protocol !== "https:") throw new WebSkillError("NETWORK_BLOCKED", `URL scheme "${url.protocol}" is not allowed (https only): ${url.href}`);
311
+ if (!policy.allowPrivateHosts && isPrivateHost(url.hostname)) throw new WebSkillError("NETWORK_BLOCKED", `Refusing to connect to a private/loopback/link-local address: ${url.hostname}`);
312
+ return url;
313
+ }
246
314
  const FRONTMATTER_RE = /^---[^\S\r\n]*\r?\n([\s\S]*?)\r?\n---[^\S\r\n]*(?:\r?\n|$)([\s\S]*)$/;
247
315
  const invalid = (message, details) => {
248
316
  throw new WebSkillError("SKILL_INVALID_METADATA", message, details);
@@ -383,7 +451,6 @@ function checkDependencyCycles(adjacency) {
383
451
  */
384
452
  /** 包级清单文件名(位于 zip 根,不参与各技能 digest) */
385
453
  const SKILL_PACK_FILE = "webskill.skill-pack.json";
386
- const messageOf = (e) => e instanceof Error ? e.message : String(e);
387
454
  const baseName$1 = (p) => p.split("/").pop() ?? p;
388
455
  async function listFilesRecursive(fs, root, prefix = "") {
389
456
  const out = [];
@@ -769,4 +836,4 @@ const xmlRenderer = {
769
836
  };
770
837
 
771
838
  //#endregion
772
- export { unzipWithLimits as A, parseSkillMarkdown as C, renderCatalogJson as D, renderAvailableSkillsXml as E, verifyManifest as M, xmlRenderer as N, resolveArchiveLimits as O, normalizePath as S, readResponseWithLimit as T, computeDigest as _, SKILL_NAME_MAX_LENGTH as a, isValidSkillName as b, SkillDiscovery as c, assertSafePathSegment as d, atomicWriteText as f, checkSkillRules as g, checkDependencyCycles as h, SKILL_MANIFEST_FILE as i, validateSkills as j, resolveInsideRoot as k, SkillReader as l, buildManifest as m, MemoryFS as n, SKILL_NAME_PATTERN as o, buildCatalog as p, SKILLS_LOCKFILE as r, SKILL_PACK_FILE as s, DEFAULT_ARCHIVE_LIMITS as t, WebSkillError as u, escapeXml as v, parseSkillPackManifest as w, jsonRenderer as x, exportSkills as y };
839
+ export { resolveArchiveLimits as A, messageOf as C, readResponseWithLimit as D, parseSkillPackManifest as E, xmlRenderer as F, unzipWithLimits as M, validateSkills as N, renderAvailableSkillsXml as O, verifyManifest as P, jsonRenderer as S, parseSkillMarkdown as T, checkSkillRules as _, SKILL_NAME_MAX_LENGTH as a, exportSkills as b, SkillDiscovery as c, assertRemoteUrlAllowed as d, assertSafePathSegment as f, checkDependencyCycles as g, buildManifest as h, SKILL_MANIFEST_FILE as i, resolveInsideRoot as j, renderCatalogJson as k, SkillReader as l, buildCatalog as m, MemoryFS as n, SKILL_NAME_PATTERN as o, atomicWriteText as p, SKILLS_LOCKFILE as r, SKILL_PACK_FILE as s, DEFAULT_ARCHIVE_LIMITS as t, WebSkillError as u, computeDigest as v, normalizePath as w, isValidSkillName as x, escapeXml as y };
@@ -1,5 +1,5 @@
1
- import { A as unzipWithLimits, C as parseSkillMarkdown, M as verifyManifest, O as resolveArchiveLimits, T as readResponseWithLimit, b as isValidSkillName, f as atomicWriteText, i as SKILL_MANIFEST_FILE, j as validateSkills, k as resolveInsideRoot, m as buildManifest, r as SKILLS_LOCKFILE, s as SKILL_PACK_FILE, u as WebSkillError, w as parseSkillPackManifest, y as exportSkills } from "./dist-D7MsoMPx.js";
2
- import { A as isNetworkAllowed, C as bridgeError, F as normalizeToolError, I as parseBridgeRequest, M as networkUrlHost, P as normalizeToolContent, c as FsArtifactStore, l as FsMemoryStore, o as CapabilityApproval } from "./dist-CV64gN62.js";
1
+ import { A as resolveArchiveLimits, C as messageOf, D as readResponseWithLimit, E as parseSkillPackManifest, M as unzipWithLimits, N as validateSkills, P as verifyManifest, T as parseSkillMarkdown, b as exportSkills, d as assertRemoteUrlAllowed, h as buildManifest, i as SKILL_MANIFEST_FILE, j as resolveInsideRoot, p as atomicWriteText, r as SKILLS_LOCKFILE, s as SKILL_PACK_FILE, u as WebSkillError, x as isValidSkillName } from "./dist-BQzncxXg.js";
2
+ import { A as isNetworkAllowed, C as bridgeError, F as normalizeToolError, I as parseBridgeRequest, M as networkUrlHost, P as normalizeToolContent, c as FsArtifactStore, l as FsMemoryStore, o as CapabilityApproval } from "./dist-B77plHjw.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { unzipSync, zipSync } from "fflate";
5
5
  import { existsSync, promises, realpathSync } from "node:fs";
@@ -295,7 +295,6 @@ var NodeScriptExecutor = class {
295
295
  };
296
296
  const baseName$1 = (p) => p.split("/").pop() ?? p;
297
297
  const toPlatform$3 = (p) => p.split("/").join(path.sep);
298
- const messageOf$6 = (e) => e instanceof Error ? e.message : String(e);
299
298
  /**
300
299
  * 网络策略判定函数源码(注入 Worker;匹配逻辑单一来源在
301
300
  * runtime/sandbox/networkPolicy.ts,Worker 线程不走 vitest 别名故注入而非 import)
@@ -381,7 +380,7 @@ var SandboxedScriptExecutor = class {
381
380
  content: [],
382
381
  error: {
383
382
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
384
- message: messageOf$6(e)
383
+ message: messageOf(e)
385
384
  }
386
385
  };
387
386
  }
@@ -422,7 +421,7 @@ var SandboxedScriptExecutor = class {
422
421
  content: [],
423
422
  error: {
424
423
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
425
- message: messageOf$6(e)
424
+ message: messageOf(e)
426
425
  }
427
426
  };
428
427
  }
@@ -473,7 +472,7 @@ var SandboxedScriptExecutor = class {
473
472
  respond(bridgeError("unknown", "TOOL_EXECUTION_FAILED", "invalid bridge request"));
474
473
  return;
475
474
  }
476
- onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf$6(e))));
475
+ onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf(e))));
477
476
  return;
478
477
  }
479
478
  if (msg?.type === "load-result" || msg?.type === "execute-result") done(() => resolve(msg));
@@ -534,13 +533,12 @@ var SandboxedScriptExecutor = class {
534
533
  }
535
534
  }
536
535
  } catch (e) {
537
- return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf$6(e));
536
+ return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf(e));
538
537
  }
539
538
  }
540
539
  };
541
540
  const baseName = (p) => p.split("/").pop() ?? p;
542
541
  const toPlatform$2 = (p) => p.split("/").join(path.sep);
543
- const messageOf$5 = (e) => e instanceof Error ? e.message : String(e);
544
542
  const NETWORK_POLICY_LIB = `${isNetworkAllowed.toString()}\n${networkUrlHost.toString()}`;
545
543
  const readAllow = (p) => [`--allow-fs-read=${p}`, `--allow-fs-read=${realpathSync(p)}`];
546
544
  const writeAllow = (p) => [`--allow-fs-write=${p}`, `--allow-fs-write=${realpathSync(p)}`];
@@ -572,6 +570,7 @@ var ProcessSandboxExecutor = class {
572
570
  #approval;
573
571
  #slots = [];
574
572
  #waiters = [];
573
+ #disposed = false;
575
574
  constructor(fs, options = {}) {
576
575
  this.#fs = fs;
577
576
  this.#options = options;
@@ -589,8 +588,9 @@ var ProcessSandboxExecutor = class {
589
588
  get poolSize() {
590
589
  return this.#options.poolSize ?? 2;
591
590
  }
592
- /** 池全部子进程销毁(测试收尾/进程退出前调用) */
591
+ /** 池全部子进程销毁(测试收尾/进程退出前调用);排队中的 acquire 一律 reject(不悬挂) */
593
592
  async dispose() {
593
+ this.#disposed = true;
594
594
  for (const slot of this.#slots) {
595
595
  slot.child.kill();
596
596
  await rm(slot.artifactDir, {
@@ -599,7 +599,8 @@ var ProcessSandboxExecutor = class {
599
599
  }).catch(() => void 0);
600
600
  }
601
601
  this.#slots = [];
602
- this.#waiters = [];
602
+ const waiters = this.#waiters.splice(0);
603
+ for (const waiter of waiters) waiter.reject(new WebSkillError("TOOL_EXECUTION_FAILED", "Process sandbox executor disposed while a caller was waiting for a pool slot"));
603
604
  }
604
605
  async #locateScript(skillRoot, scriptName) {
605
606
  const tsPath = `${skillRoot}/scripts/${scriptName}.ts`;
@@ -612,6 +613,7 @@ var ProcessSandboxExecutor = class {
612
613
  /** 取一个可用子进程(同 key 复用 / 淘汰 idle 重生 / 扩容 / 排队) */
613
614
  async #acquire(key) {
614
615
  for (;;) {
616
+ if (this.#disposed) throw new WebSkillError("TOOL_EXECUTION_FAILED", "Process sandbox executor is disposed");
615
617
  const idle = this.#slots.filter((s) => !s.busy);
616
618
  const match = idle.find((s) => s.key === key);
617
619
  if (match) {
@@ -633,7 +635,10 @@ var ProcessSandboxExecutor = class {
633
635
  this.#slots.push(slot);
634
636
  return slot;
635
637
  }
636
- await new Promise((resolve) => this.#waiters.push(resolve));
638
+ await new Promise((resolve, reject) => this.#waiters.push({
639
+ resolve,
640
+ reject
641
+ }));
637
642
  }
638
643
  }
639
644
  /** 执行后回收:kill 旧子进程,补位重生同 key 新子进程(温池保持),唤醒排队 */
@@ -645,13 +650,27 @@ var ProcessSandboxExecutor = class {
645
650
  recursive: true,
646
651
  force: true
647
652
  }).catch(() => void 0);
648
- this.#spawnSlot(slot.key).then((fresh) => {
653
+ this.#spawnSlot(slot.key).then(async (fresh) => {
654
+ if (this.#disposed) {
655
+ fresh.child.kill();
656
+ await rm(fresh.artifactDir, {
657
+ recursive: true,
658
+ force: true
659
+ }).catch(() => void 0);
660
+ return;
661
+ }
649
662
  this.#slots.push(fresh);
650
- }).catch(() => void 0).finally(() => {
663
+ }).catch((e) => {
664
+ this.#warn(`Failed to respawn sandbox child for pool maintenance: ${messageOf(e)}`);
665
+ }).finally(() => {
651
666
  const waiter = this.#waiters.shift();
652
- if (waiter) waiter();
667
+ if (waiter) waiter.resolve();
653
668
  });
654
669
  }
670
+ #warn(message) {
671
+ if (this.#options.onWarning) this.#options.onWarning(message);
672
+ else console.warn(message);
673
+ }
655
674
  async #spawnSlot(key) {
656
675
  const artifactDir = await mkdtemp(path.join(tmpdir(), "webskill-psbx-out-")).then((d) => d.split(path.sep).join("/"));
657
676
  const entry = processEntryPath();
@@ -712,7 +731,7 @@ var ProcessSandboxExecutor = class {
712
731
  content: [],
713
732
  error: {
714
733
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
715
- message: messageOf$5(e)
734
+ message: messageOf(e)
716
735
  }
717
736
  };
718
737
  }
@@ -755,7 +774,7 @@ var ProcessSandboxExecutor = class {
755
774
  content: [],
756
775
  error: {
757
776
  code: e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED",
758
- message: messageOf$5(e)
777
+ message: messageOf(e)
759
778
  }
760
779
  };
761
780
  } finally {
@@ -794,7 +813,7 @@ var ProcessSandboxExecutor = class {
794
813
  respond(bridgeError("unknown", "TOOL_EXECUTION_FAILED", "invalid bridge request"));
795
814
  return;
796
815
  }
797
- onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf$5(e))));
816
+ onBridge(request).then(respond, (e) => respond(bridgeError(request.id, "TOOL_EXECUTION_FAILED", messageOf(e))));
798
817
  return;
799
818
  }
800
819
  if (msg?.type === "load-result" || msg?.type === "execute-result") done(() => resolve(msg));
@@ -863,7 +882,7 @@ var ProcessSandboxExecutor = class {
863
882
  }
864
883
  }
865
884
  } catch (e) {
866
- return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf$5(e));
885
+ return bridgeError(request.id, e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", messageOf(e));
867
886
  }
868
887
  }
869
888
  };
@@ -1195,7 +1214,6 @@ var CliUiBridge = class {
1195
1214
  return new Promise((resolve) => this.#waiters.push(resolve));
1196
1215
  }
1197
1216
  };
1198
- const messageOf$4 = (e) => e instanceof Error ? e.message : String(e);
1199
1217
  const toPlatform$1 = (p) => p.split("/").join(path.sep);
1200
1218
  const isZipArchive = (data) => data.length > 1 && data[0] === 80 && data[1] === 75;
1201
1219
  let tarModule$1;
@@ -1244,7 +1262,7 @@ async function extractTarFile(archivePath, destRoot, limits) {
1244
1262
  });
1245
1263
  } catch (e) {
1246
1264
  if (e instanceof WebSkillError) throw e;
1247
- throw new WebSkillError("INSTALL_FAILED", `Failed to extract tar archive: ${messageOf$4(e)}`, e);
1265
+ throw new WebSkillError("INSTALL_FAILED", `Failed to extract tar archive: ${messageOf(e)}`, e);
1248
1266
  }
1249
1267
  }
1250
1268
  /** 递归列出目录内全部文件(相对路径,posix 分隔,不含目录条目) */
@@ -1288,7 +1306,6 @@ async function removeDirQuiet(fs, dir) {
1288
1306
  if (await fs.exists(dir)) await fs.remove(dir, { recursive: true });
1289
1307
  } catch {}
1290
1308
  }
1291
- const messageOf$3 = (e) => e instanceof Error ? e.message : String(e);
1292
1309
  let tarModule;
1293
1310
  async function loadTar() {
1294
1311
  tarModule ??= await import("tar").catch((e) => {
@@ -1310,7 +1327,7 @@ async function exportArchive(fs, skillRoot, options) {
1310
1327
  return options.outPath;
1311
1328
  } catch (e) {
1312
1329
  if (e instanceof WebSkillError) throw e;
1313
- throw new WebSkillError("EXPORT_FAILED", `Failed to export ${options.format} archive: ${messageOf$3(e)}`, e);
1330
+ throw new WebSkillError("EXPORT_FAILED", `Failed to export ${options.format} archive: ${messageOf(e)}`, e);
1314
1331
  }
1315
1332
  }
1316
1333
  /** 只解出归档中的 webskill.skill-manifest.json 条目(安装前预览) */
@@ -1338,13 +1355,12 @@ async function readArchiveManifest(fs, archivePath) {
1338
1355
  }
1339
1356
  });
1340
1357
  } catch (e) {
1341
- throw new WebSkillError("EXPORT_FAILED", `Failed to read tar archive: ${messageOf$3(e)}`, e);
1358
+ throw new WebSkillError("EXPORT_FAILED", `Failed to read tar archive: ${messageOf(e)}`, e);
1342
1359
  }
1343
1360
  if (manifestText === void 0) throw notFound();
1344
1361
  return JSON.parse(manifestText);
1345
1362
  }
1346
1363
  const _execFileP = promisify(execFile);
1347
- const messageOf$2 = (e) => e instanceof Error ? e.message : String(e);
1348
1364
  /**
1349
1365
  * 跨平台执行命令:Windows 下对 npm 等 cmd 包装的命令通过 cmd.exe 代理执行,
1350
1366
  * git 等原生 exe 不受影响。默认 120s 超时(防挂死安装管线)。
@@ -1360,7 +1376,7 @@ async function execFileP(command, args, options = {}) {
1360
1376
  }
1361
1377
  /** 命令缺失/失败 → INSTALL_FAILED 结构化诊断 */
1362
1378
  function commandFailed(command, e) {
1363
- return new WebSkillError("INSTALL_FAILED", `Failed to run ${command}: ${e?.code === "ENOENT" ? `command "${command}" not found on this system` : messageOf$2(e)}`, e);
1379
+ return new WebSkillError("INSTALL_FAILED", `Failed to run ${command}: ${e?.code === "ENOENT" ? `command "${command}" not found on this system` : messageOf(e)}`, e);
1364
1380
  }
1365
1381
  /** git url 协议白名单:https:// 与 git@ SCP 形式;无 scheme 的本地路径放行(dev 工作流);
1366
1382
  * 其余显式协议(ext::/file:// 等)一律拒绝 */
@@ -1409,15 +1425,38 @@ async function stageGit(source, ctx) {
1409
1425
  function sha256Hex(data) {
1410
1426
  return createHash("sha256").update(data).digest("hex");
1411
1427
  }
1412
- const messageOf$1 = (e) => e instanceof Error ? e.message : String(e);
1413
- /** http 源:下载归档(Content-Length + 流式累计上限;可选 expectedSha256 校验包体)→ 解包 → 定位技能根/包集 */
1428
+ /** 重定向跳数上限(SSRF 防护:逐跳重新校验目标 URL) */
1429
+ const MAX_REDIRECT_HOPS = 3;
1430
+ /**
1431
+ * 带 SSRF 防护的下载:手动跟随重定向(默认 ≤3 跳),初始 URL 与每一跳目标
1432
+ * 都过 assertRemoteUrlAllowed(https 默认;私有/环回/链路本地默认拒绝)。
1433
+ */
1434
+ async function fetchWithSsrfGuard(source, fetchImpl) {
1435
+ const policy = {
1436
+ allowHttp: source.allowHttp ?? false,
1437
+ allowPrivateHosts: source.allowPrivateHosts ?? false
1438
+ };
1439
+ let url = assertRemoteUrlAllowed(source.url, policy);
1440
+ for (let hop = 0;; hop++) {
1441
+ const res = await fetchImpl(url.href, { redirect: "manual" });
1442
+ const location = res.headers.get("location");
1443
+ if (res.status >= 300 && res.status < 400 && location) {
1444
+ if (hop >= MAX_REDIRECT_HOPS) throw new WebSkillError("INSTALL_FAILED", `Download exceeded the redirect limit of ${MAX_REDIRECT_HOPS} hops`);
1445
+ url = assertRemoteUrlAllowed(new URL(location, url).href, policy);
1446
+ continue;
1447
+ }
1448
+ return res;
1449
+ }
1450
+ }
1451
+ /** http 源:下载归档(SSRF 防护 + Content-Length/流式上限;可选 expectedSha256 校验包体)→ 解包 → 定位技能根/包集 */
1414
1452
  async function stageHttp(source, ctx, expectedSha256) {
1415
1453
  const fetchImpl = ctx.fetchImpl ?? fetch;
1416
1454
  let res;
1417
1455
  try {
1418
- res = await fetchImpl(source.url);
1456
+ res = await fetchWithSsrfGuard(source, fetchImpl);
1419
1457
  } catch (e) {
1420
- throw new WebSkillError("INSTALL_FAILED", `Download failed: ${messageOf$1(e)}`, e);
1458
+ if (e instanceof WebSkillError) throw e;
1459
+ throw new WebSkillError("INSTALL_FAILED", `Download failed: ${messageOf(e)}`, e);
1421
1460
  }
1422
1461
  if (!res.ok) throw new WebSkillError("INSTALL_FAILED", `Download failed with HTTP ${res.status}`);
1423
1462
  const data = await readResponseWithLimit(res, ctx.archiveLimits);
@@ -1466,13 +1505,19 @@ async function stageLocal(source, ctx) {
1466
1505
  /** npm 包名(@scope/name 或 name,小写字母数字 . _ - /)与版本(semver 或 dist-tag)正则 */
1467
1506
  const PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/;
1468
1507
  const VERSION_RE = /^[a-z0-9._~^>=<x*+-]+$/i;
1508
+ /**
1509
+ * 本地路径字符白名单(Windows 下 npm 经 cmd /c 代理二次解析,
1510
+ * `& | < > ^ " ' % ! ;` 与空格一律拒绝;允许 Windows 盘符 `C:\` 与正/反斜杠)
1511
+ */
1512
+ const LOCAL_PATH_RE = /^[A-Za-z0-9._~+@/\\:-]+$/;
1469
1513
  function assertNpmSpecSafe(source) {
1470
1514
  const name = source.packageName;
1471
1515
  const isLocalPath = name.startsWith("/") || name.startsWith("./") || name.startsWith("../");
1472
1516
  if (name.startsWith("--") || !PACKAGE_NAME_RE.test(name) && !isLocalPath) throw new WebSkillError("INSTALL_FAILED", `Invalid npm package name: ${JSON.stringify(name)}`);
1517
+ if (isLocalPath && !LOCAL_PATH_RE.test(name)) throw new WebSkillError("INSTALL_FAILED", `Local npm path contains characters outside the safe whitelist: ${JSON.stringify(name)}`);
1473
1518
  if (source.version !== void 0 && (!VERSION_RE.test(source.version) || source.version.startsWith("--"))) throw new WebSkillError("INSTALL_FAILED", `Invalid npm package version: ${JSON.stringify(source.version)}`);
1474
1519
  }
1475
- /** npm 源:npm pack → 解 tar.gz;技能根取包的 skill/ 子目录(存在时)否则包根;spec 前插 `--` 防选项注入 */
1520
+ /** npm 源:npm pack --ignore-scripts → 解 tar.gz;技能根取包的 skill/ 子目录(存在时)否则包根;spec 前插 `--` 防选项注入 */
1476
1521
  async function stageNpm(source, ctx) {
1477
1522
  assertNpmSpecSafe(source);
1478
1523
  const spec = source.version ? `${source.packageName}@${source.version}` : source.packageName;
@@ -1480,6 +1525,7 @@ async function stageNpm(source, ctx) {
1480
1525
  try {
1481
1526
  ({stdout} = await execFileP("npm", [
1482
1527
  "pack",
1528
+ "--ignore-scripts",
1483
1529
  "--pack-destination",
1484
1530
  ctx.stagingRoot,
1485
1531
  "--",
@@ -1596,7 +1642,6 @@ async function verifyIntegrity(fs, skillRoot) {
1596
1642
  for (const rel of actualFiles) actualHashes.set(rel, sha256Hex(await fs.readBinary(`${skillRoot}/${rel}`)));
1597
1643
  return verifyManifest(manifest, actualHashes, actualFiles);
1598
1644
  }
1599
- const messageOf = (e) => e instanceof Error ? e.message : String(e);
1600
1645
  const asInstallFailed = (e) => e instanceof WebSkillError && e.code === "INSTALL_FAILED" ? e : new WebSkillError("INSTALL_FAILED", `Install failed: ${messageOf(e)}`, e);
1601
1646
  /**
1602
1647
  * 技能管理门面:统一安装管线(staging → 解析 name → 校验 → 拷贝 → manifest → lockfile),