agentsmesh 0.15.0 → 0.16.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0b33c0d: Security and robustness hardening across MCP write tools, hook script generation, remote fetch, and canonical name validation. Some changes are behaviorally breaking; pre-1.0 minor bump per project policy.
8
+
9
+ **Hardening (no contract change)**
10
+ - MCP `add_mcp_server` / `update_mcp_server` / `update_hooks` / `update_permissions` now reject obviously malicious payloads at the schema layer (shell metacharacters in `command`, embedded newlines in matchers, env keys outside `[A-Za-z_][A-Za-z0-9_]*`, non-`http(s)` URLs, args arrays over 100, unknown server fields, permission patterns outside `Tool` / `Tool(matcher)`).
11
+ - Generated Copilot and Cline hook wrappers strip CR/LF from event/matcher/command before embedding them in the `# agentsmesh-*:` comment header so a multi-line YAML scalar cannot break out of the comment into executable shell.
12
+ - Generated `.sh` / `.bash` / `.zsh` files are now written with mode `0o755` so hooks emitted to disk are exec'able by the runner without a manual `chmod +x`.
13
+ - GitHub tarball downloads are capped at 500 MiB and aborted mid-stream when the running byte total exceeds the cap (Content-Length is also pre-checked).
14
+ - Git refs and clone URLs that begin with `-` are rejected to block `--upload-pack=…` style option injection.
15
+ - `MCP` non-`McpError` fallbacks redact absolute filesystem paths from raw `Error.message` strings; `IO_ERROR` envelopes carry the underlying `errno` in `details`.
16
+ - `parseAgents` / `parseCommands` / `parseRules` / `parseSkills` reject canonical filenames that are Windows reserved devices (CON, AUX, NUL, COM1–9, LPT1–9), contain `<>:|?*`, or end in `.`/space; nested basename collisions (e.g. `agents/foo.md` and `agents/sub/foo.md`) now error instead of silently last-write-wins.
17
+ - `loadAllPlugins` now collects all per-plugin failures and rethrows as a single combined error when any entry has `strict: true` or `AGENTSMESH_STRICT_PLUGINS=1` is set in the environment. Previous warn-and-skip behavior remains the default.
18
+
19
+ **Breaking**
20
+ - Generated hook wrappers run under `set -eu` instead of `set -e`. A canonical `command` that references an unset shell variable (`echo $VAR` where `$VAR` is never exported) will now abort the hook. Use `${VAR:-default}` syntax when an unset value is intentional.
21
+ - `AGENTSMESH_CACHE` must now be an absolute path that is not the filesystem root (`/` or a Windows drive root). Relative paths and roots throw at startup. Previously the value was used verbatim.
22
+ - MCP `create_rule` / `create_command` / `create_agent` and the canonical handlers reject names containing `/`. The `NAME_RE` validator was tightened from `[a-zA-Z0-9_/-]*` to `[a-zA-Z0-9_-]*` — names must be flat identifiers.
23
+ - Canonical files named after Windows reserved devices or with reserved characters now throw `CanonicalNameError` at parse time on every host (previously silent failure on Windows, success on POSIX).
24
+
25
+ **Internal**
26
+ - `src/mcp/register.ts`, `src/utils/filesystem/fs.ts`, `src/mcp/handlers/orchestrate.ts`, and `src/cli/commands/target-scaffold/templates.ts` were each split under the project's 200-line file budget. Public API surface (`./engine`, `./canonical`, `./targets`) is unchanged.
27
+ - New `executableModeFor(path)` helper in `src/utils/filesystem/fs.ts` infers the executable bit from the path extension; `writeFileAtomic` accepts an optional `{ mode }` override.
28
+
3
29
  ## 0.15.0
4
30
 
5
31
  ### Minor Changes
package/README.md CHANGED
@@ -259,7 +259,7 @@ agentsmesh generate # plugin targets run alongside built-ins
259
259
  agentsmesh generate --global # global mode works for plugins too
260
260
  ```
261
261
 
262
- Plugins have full parity with built-in targets: project + global layouts, feature conversions, scoped settings, per-feature lint hooks, and hook post-processing. [Build a plugin →](https://samplexbro.github.io/agentsmesh/guides/building-plugins/)
262
+ Plugins have full parity with built-in targets: project + global layouts, feature conversions, scoped settings, per-feature lint hooks, and hook post-processing. By default a failed plugin import logs a warning and is skipped; set `strict: true` on the plugin entry or run `AGENTSMESH_STRICT_PLUGINS=1 agentsmesh generate` to fail the build instead — useful in CI where a missing target is a real regression. [Build a plugin →](https://samplexbro.github.io/agentsmesh/guides/building-plugins/)
263
263
 
264
264
  ### Team-safe collaboration & CI drift detection
265
265
 
@@ -1,5 +1,5 @@
1
- import { b as CanonicalFiles, V as ValidatedConfig } from './schema-o4oXUVBP.js';
2
- export { C as CanonicalAgent, a as CanonicalCommand, c as CanonicalRule, d as CanonicalSkill, H as HookEntry, e as Hooks, I as IgnorePatterns, M as McpConfig, f as McpServer, P as Permissions, S as SkillSupportingFile, g as StdioMcpServer, U as UrlMcpServer } from './schema-o4oXUVBP.js';
1
+ import { b as CanonicalFiles, V as ValidatedConfig } from './schema-CD2qcmDL.js';
2
+ export { C as CanonicalAgent, a as CanonicalCommand, c as CanonicalRule, d as CanonicalSkill, H as HookEntry, e as Hooks, I as IgnorePatterns, M as McpConfig, f as McpServer, P as Permissions, S as SkillSupportingFile, g as StdioMcpServer, U as UrlMcpServer } from './schema-CD2qcmDL.js';
3
3
  import 'zod';
4
4
 
5
5
  /**
package/dist/canonical.js CHANGED
@@ -1,5 +1,5 @@
1
- import { access, readdir, realpath, stat, readFile, rm, mkdir, writeFile, rename, lstat, unlink } from 'fs/promises';
2
1
  import { join, basename, dirname, resolve, relative, posix, win32, extname } from 'path';
2
+ import { access, readdir, realpath, stat, readFile, rm, mkdir, writeFile, rename, lstat, unlink, chmod } from 'fs/promises';
3
3
  import { constants, existsSync, realpathSync, statSync } from 'fs';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { parse as parse$1 } from 'smol-toml';
@@ -79,6 +79,104 @@ function shouldNormalizeLineEndings(path) {
79
79
  function normalizeLineEndings(content) {
80
80
  return content.replace(/\r\n?/g, "\n");
81
81
  }
82
+ function executableModeFor(path) {
83
+ return EXECUTABLE_SCRIPT_EXTENSIONS.has(extname(path).toLowerCase()) ? 493 : void 0;
84
+ }
85
+ var UTF8_BOM, TEXT_EXTENSIONS, TEXT_DOTFILES, EXECUTABLE_SCRIPT_EXTENSIONS;
86
+ var init_fs_text_encoding = __esm({
87
+ "src/utils/filesystem/fs-text-encoding.ts"() {
88
+ UTF8_BOM = "\uFEFF";
89
+ TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
90
+ ".md",
91
+ ".mdc",
92
+ ".mdx",
93
+ ".markdown",
94
+ ".txt",
95
+ ".json",
96
+ ".jsonc",
97
+ ".yaml",
98
+ ".yml",
99
+ ".toml",
100
+ ".ini",
101
+ ".sh",
102
+ ".bash",
103
+ ".zsh",
104
+ ".ps1",
105
+ ".js",
106
+ ".mjs",
107
+ ".cjs",
108
+ ".ts",
109
+ ".tsx",
110
+ ".html",
111
+ ".css"
112
+ ]);
113
+ TEXT_DOTFILES = /* @__PURE__ */ new Set([
114
+ ".gitignore",
115
+ ".cursorignore",
116
+ ".cursorindexingignore",
117
+ ".aiignore",
118
+ ".agentignore",
119
+ ".clineignore",
120
+ ".geminiignore",
121
+ ".codeiumignore",
122
+ ".continueignore",
123
+ ".copilotignore",
124
+ ".windsurfignore",
125
+ ".junieignore",
126
+ ".kiroignore",
127
+ ".rooignore",
128
+ ".antigravityignore"
129
+ ]);
130
+ EXECUTABLE_SCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".sh", ".bash", ".zsh"]);
131
+ }
132
+ });
133
+ async function readDirRecursive(dir, visited) {
134
+ let canonicalDir;
135
+ try {
136
+ canonicalDir = await realpath(dir);
137
+ } catch (err) {
138
+ const e = err;
139
+ if (e.code === "ENOENT" || e.code === "ENOTDIR" || e.code === "ELOOP") return [];
140
+ throw new FileSystemError(
141
+ dir,
142
+ `Failed to read directory ${dir}: ${e.message}. Check permissions.`,
143
+ { cause: err, errnoCode: e.code }
144
+ );
145
+ }
146
+ const seen = visited ?? /* @__PURE__ */ new Set();
147
+ if (seen.has(canonicalDir)) return [];
148
+ seen.add(canonicalDir);
149
+ try {
150
+ const entries = await readdir(dir, { withFileTypes: true });
151
+ const files = [];
152
+ for (const ent of entries) {
153
+ const full = join(dir, ent.name);
154
+ const walkChild = ent.isDirectory() || ent.isSymbolicLink() && await stat(full).then(
155
+ (s) => s.isDirectory(),
156
+ () => false
157
+ );
158
+ if (walkChild) {
159
+ files.push(...await readDirRecursive(full, seen));
160
+ } else {
161
+ files.push(full);
162
+ }
163
+ }
164
+ return files;
165
+ } catch (err) {
166
+ const e = err;
167
+ if (e.code === "ENOENT" || e.code === "ENOTDIR" || e.code === "EACCES") return [];
168
+ throw new FileSystemError(
169
+ dir,
170
+ `Failed to read directory ${dir}: ${e.message}. Check permissions.`,
171
+ { cause: err, errnoCode: e.code }
172
+ );
173
+ }
174
+ }
175
+ var init_fs_traverse = __esm({
176
+ "src/utils/filesystem/fs-traverse.ts"() {
177
+ init_errors();
178
+ }
179
+ });
82
180
  async function readFileSafe(path) {
83
181
  try {
84
182
  const data = await readFile(path, "utf-8");
@@ -93,7 +191,7 @@ async function readFileSafe(path) {
93
191
  );
94
192
  }
95
193
  }
96
- async function writeFileAtomic(path, content) {
194
+ async function writeFileAtomic(path, content, options) {
97
195
  const dir = dirname(path);
98
196
  await mkdir(dir, { recursive: true });
99
197
  try {
@@ -117,6 +215,7 @@ async function writeFileAtomic(path, content) {
117
215
  }
118
216
  const tmpPath = `${path}.tmp`;
119
217
  const payload = shouldNormalizeLineEndings(path) ? normalizeLineEndings(content) : content;
218
+ const mode = executableModeFor(path);
120
219
  try {
121
220
  try {
122
221
  const tmpInfo = await lstat(tmpPath);
@@ -126,8 +225,16 @@ async function writeFileAtomic(path, content) {
126
225
  } catch (tmpErr) {
127
226
  if (tmpErr.code !== "ENOENT") throw tmpErr;
128
227
  }
129
- await writeFile(tmpPath, payload, { encoding: "utf-8", flag: "w" });
228
+ const writeOpts = {
229
+ encoding: "utf-8",
230
+ flag: "w"
231
+ };
232
+ if (mode !== void 0) writeOpts.mode = mode;
233
+ await writeFile(tmpPath, payload, writeOpts);
130
234
  await rename(tmpPath, path);
235
+ if (mode !== void 0) {
236
+ await chmod(path, mode);
237
+ }
131
238
  } catch (err) {
132
239
  await rm(tmpPath, { force: true }).catch(() => {
133
240
  });
@@ -150,94 +257,12 @@ async function exists(path) {
150
257
  async function mkdirp(path) {
151
258
  await mkdir(path, { recursive: true });
152
259
  }
153
- async function readDirRecursive(dir, visited) {
154
- let canonicalDir;
155
- try {
156
- canonicalDir = await realpath(dir);
157
- } catch (err) {
158
- const e = err;
159
- if (e.code === "ENOENT" || e.code === "ENOTDIR" || e.code === "ELOOP") return [];
160
- throw new FileSystemError(
161
- dir,
162
- `Failed to read directory ${dir}: ${e.message}. Check permissions.`,
163
- { cause: err, errnoCode: e.code }
164
- );
165
- }
166
- const seen = visited ?? /* @__PURE__ */ new Set();
167
- if (seen.has(canonicalDir)) return [];
168
- seen.add(canonicalDir);
169
- try {
170
- const entries = await readdir(dir, { withFileTypes: true });
171
- const files = [];
172
- for (const ent of entries) {
173
- const full = join(dir, ent.name);
174
- const walkChild = ent.isDirectory() || ent.isSymbolicLink() && await stat(full).then(
175
- (s) => s.isDirectory(),
176
- () => false
177
- );
178
- if (walkChild) {
179
- files.push(...await readDirRecursive(full, seen));
180
- } else {
181
- files.push(full);
182
- }
183
- }
184
- return files;
185
- } catch (err) {
186
- const e = err;
187
- if (e.code === "ENOENT" || e.code === "ENOTDIR" || e.code === "EACCES") return [];
188
- throw new FileSystemError(
189
- dir,
190
- `Failed to read directory ${dir}: ${e.message}. Check permissions.`,
191
- { cause: err, errnoCode: e.code }
192
- );
193
- }
194
- }
195
- var UTF8_BOM, TEXT_EXTENSIONS, TEXT_DOTFILES;
196
260
  var init_fs = __esm({
197
261
  "src/utils/filesystem/fs.ts"() {
198
262
  init_errors();
199
- UTF8_BOM = "\uFEFF";
200
- TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
201
- ".md",
202
- ".mdc",
203
- ".mdx",
204
- ".markdown",
205
- ".txt",
206
- ".json",
207
- ".jsonc",
208
- ".yaml",
209
- ".yml",
210
- ".toml",
211
- ".ini",
212
- ".sh",
213
- ".bash",
214
- ".zsh",
215
- ".ps1",
216
- ".js",
217
- ".mjs",
218
- ".cjs",
219
- ".ts",
220
- ".tsx",
221
- ".html",
222
- ".css"
223
- ]);
224
- TEXT_DOTFILES = /* @__PURE__ */ new Set([
225
- ".gitignore",
226
- ".cursorignore",
227
- ".cursorindexingignore",
228
- ".aiignore",
229
- ".agentignore",
230
- ".clineignore",
231
- ".geminiignore",
232
- ".codeiumignore",
233
- ".continueignore",
234
- ".copilotignore",
235
- ".windsurfignore",
236
- ".junieignore",
237
- ".kiroignore",
238
- ".rooignore",
239
- ".antigravityignore"
240
- ]);
263
+ init_fs_text_encoding();
264
+ init_fs_traverse();
265
+ init_fs_text_encoding();
241
266
  }
242
267
  });
243
268
  function parseFrontmatter(content) {
@@ -3998,13 +4023,16 @@ function generateAgents4(canonical) {
3998
4023
  function safeEventName(event) {
3999
4024
  return event.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();
4000
4025
  }
4026
+ function safeShellLine(value) {
4027
+ return value.replace(/[\r\n]+/g, " ");
4028
+ }
4001
4029
  function buildHookScript(event, command, matcher) {
4002
4030
  return [
4003
4031
  "#!/usr/bin/env bash",
4004
- `# agentsmesh-event: ${event}`,
4005
- `# agentsmesh-matcher: ${matcher}`,
4006
- `# agentsmesh-command: ${command}`,
4007
- "set -e",
4032
+ `# agentsmesh-event: ${safeShellLine(event)}`,
4033
+ `# agentsmesh-matcher: ${safeShellLine(matcher)}`,
4034
+ `# agentsmesh-command: ${safeShellLine(command)}`,
4035
+ "set -eu",
4008
4036
  command,
4009
4037
  ""
4010
4038
  ].join("\n");
@@ -6461,7 +6489,7 @@ function extractMatcher(comment) {
6461
6489
  function extractWrapperCommand(content) {
6462
6490
  const metadataMatch = content.match(/^# agentsmesh-command:\s*(.+)$/m);
6463
6491
  if (metadataMatch?.[1]) return metadataMatch[1].trim();
6464
- return content.replace(/^#!.*\n/, "").replace(/^#.*\n/gm, "").replace(/^HOOK_DIR=.*\n/gm, "").replace(/^set -e\n?/m, "").trim();
6492
+ return content.replace(/^#!.*\n/, "").replace(/^#.*\n/gm, "").replace(/^HOOK_DIR=.*\n/gm, "").replace(/^set -e[u]?\n?/m, "").trim();
6465
6493
  }
6466
6494
  async function importHooks(projectRoot, results) {
6467
6495
  const hooksDir = join(projectRoot, COPILOT_HOOKS_DIR);
@@ -6783,12 +6811,15 @@ async function buildAssetOutput(projectRoot, command) {
6783
6811
  function wrapperPath(event, index) {
6784
6812
  return `${COPILOT_HOOKS_DIR}/scripts/${safePhaseName(event)}-${index}.sh`;
6785
6813
  }
6814
+ function safeShellLine2(value) {
6815
+ return value.replace(/[\r\n]+/g, " ");
6816
+ }
6786
6817
  function buildWrapper(command, matcher) {
6787
6818
  return [
6788
6819
  "#!/usr/bin/env bash",
6789
- `# agentsmesh-matcher: ${matcher}`,
6790
- `# agentsmesh-command: ${command}`,
6791
- "set -e",
6820
+ `# agentsmesh-matcher: ${safeShellLine2(matcher)}`,
6821
+ `# agentsmesh-command: ${safeShellLine2(command)}`,
6822
+ "set -eu",
6792
6823
  command,
6793
6824
  ""
6794
6825
  ].join("\n");
@@ -6812,8 +6843,8 @@ async function addHookScriptAssets(projectRoot, canonical, outputs) {
6812
6843
  }
6813
6844
  }
6814
6845
  const wrapper = buildWrapper(command, entry.matcher).replace(
6815
- "set -e\n",
6816
- 'set -e\nHOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
6846
+ "set -eu\n",
6847
+ 'set -eu\nHOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n'
6817
6848
  );
6818
6849
  wrapperOutputs.push({ path: scriptPath, content: wrapper });
6819
6850
  index++;
@@ -14417,6 +14448,13 @@ init_fs();
14417
14448
  init_fs();
14418
14449
  var execFileAsync = promisify(execFile);
14419
14450
  var REPO_DIRNAME = "repo";
14451
+ function ensureNotFlag(value, kind) {
14452
+ if (value.startsWith("-")) {
14453
+ throw new Error(
14454
+ `agentsmesh refuses ${kind} starting with "-" (option-injection guard): ${value}`
14455
+ );
14456
+ }
14457
+ }
14420
14458
  async function fetchGitRemoteExtend(parsed, extendName, options, cacheDir, buildCacheKey2) {
14421
14459
  const provider = "cloneUrl" in parsed ? "gitlab" : "git";
14422
14460
  const identifier = "cloneUrl" in parsed ? `${parsed.namespace}/${parsed.project}` : parsed.url;
@@ -14474,9 +14512,11 @@ function resolveCloneUrl(parsed) {
14474
14512
  return parsed.url;
14475
14513
  }
14476
14514
  async function cloneRepo(cloneUrl, repoDir) {
14515
+ ensureNotFlag(cloneUrl, "clone-url");
14477
14516
  await runGit(["clone", cloneUrl, repoDir]);
14478
14517
  }
14479
14518
  async function checkoutRef(repoDir, ref) {
14519
+ ensureNotFlag(ref, "ref");
14480
14520
  await runGit(["checkout", ref], repoDir);
14481
14521
  }
14482
14522
  async function getHeadSha(repoDir) {
@@ -14495,6 +14535,46 @@ async function runGit(args, cwd) {
14495
14535
 
14496
14536
  // src/config/remote/github-remote.ts
14497
14537
  init_fs();
14538
+ var MAX_TARBALL_BYTES = 500 * 1024 * 1024;
14539
+ async function readBoundedResponse(res, maxBytes) {
14540
+ const lenHeader = typeof res.headers?.get === "function" ? res.headers.get("content-length") : null;
14541
+ if (lenHeader !== null) {
14542
+ const declared = Number(lenHeader);
14543
+ if (Number.isFinite(declared) && declared > maxBytes) {
14544
+ throw new Error(`remote response declared ${declared} bytes; exceeds cap of ${maxBytes}`);
14545
+ }
14546
+ }
14547
+ const stream = res.body;
14548
+ if (!stream) {
14549
+ const buf = await res.arrayBuffer();
14550
+ if (buf.byteLength > maxBytes) {
14551
+ throw new Error(`remote response is ${buf.byteLength} bytes; exceeds cap of ${maxBytes}`);
14552
+ }
14553
+ return new Uint8Array(buf);
14554
+ }
14555
+ const reader = stream.getReader();
14556
+ const chunks = [];
14557
+ let total = 0;
14558
+ for (; ; ) {
14559
+ const { done, value } = await reader.read();
14560
+ if (done) break;
14561
+ if (!value) continue;
14562
+ total += value.byteLength;
14563
+ if (total > maxBytes) {
14564
+ await reader.cancel().catch(() => {
14565
+ });
14566
+ throw new Error(`remote response exceeded cap of ${maxBytes} bytes during streaming`);
14567
+ }
14568
+ chunks.push(value);
14569
+ }
14570
+ const out2 = new Uint8Array(total);
14571
+ let offset = 0;
14572
+ for (const c2 of chunks) {
14573
+ out2.set(c2, offset);
14574
+ offset += c2.byteLength;
14575
+ }
14576
+ return out2;
14577
+ }
14498
14578
  async function resolveLatestTag(org, repo, token) {
14499
14579
  const url = `https://api.github.com/repos/${org}/${repo}/releases/latest`;
14500
14580
  const headers = {
@@ -14534,11 +14614,11 @@ async function fetchGithubRemoteExtend(parsed, extendName, options, cacheDir, bu
14534
14614
  const tarballUrl = `https://github.com/${parsed.org}/${parsed.repo}/tarball/${tag}`;
14535
14615
  const headers = {};
14536
14616
  if (token) headers.Authorization = `Bearer ${token}`;
14537
- let tarballBuffer;
14617
+ let tarballBytes;
14538
14618
  try {
14539
14619
  const res = await globalThis.fetch(tarballUrl, { headers, redirect: "follow" });
14540
14620
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
14541
- tarballBuffer = await res.arrayBuffer();
14621
+ tarballBytes = await readBoundedResponse(res, MAX_TARBALL_BYTES);
14542
14622
  } catch (err) {
14543
14623
  const allowFallback = options.allowOfflineFallback !== false;
14544
14624
  if (allowFallback && await exists(extractDir)) {
@@ -14555,7 +14635,7 @@ async function fetchGithubRemoteExtend(parsed, extendName, options, cacheDir, bu
14555
14635
  await rm(extractDir, { recursive: true, force: true });
14556
14636
  await mkdir(extractDir, { recursive: true });
14557
14637
  const tarPath = join(extractDir, "archive.tar.gz");
14558
- await writeFile(tarPath, new Uint8Array(tarballBuffer));
14638
+ await writeFile(tarPath, tarballBytes);
14559
14639
  try {
14560
14640
  await tar.extract({
14561
14641
  file: tarPath,
@@ -14727,7 +14807,20 @@ function buildCacheKey(provider, identifier, ref) {
14727
14807
  }
14728
14808
  function getCacheDir() {
14729
14809
  const env = process.env.AGENTSMESH_CACHE;
14730
- if (env) return env;
14810
+ if (env) {
14811
+ const trimmed = env.trim();
14812
+ if (!trimmed) {
14813
+ return join(homedir(), ".agentsmesh", "cache");
14814
+ }
14815
+ const isAbs = /^([A-Za-z]:[\\/]|\/)/.test(trimmed);
14816
+ if (!isAbs) {
14817
+ throw new Error(`AGENTSMESH_CACHE must be an absolute path (got: "${trimmed}").`);
14818
+ }
14819
+ if (trimmed === "/" || /^[A-Za-z]:[\\/]?$/.test(trimmed)) {
14820
+ throw new Error(`AGENTSMESH_CACHE must not be the filesystem root (got: "${trimmed}").`);
14821
+ }
14822
+ return trimmed;
14823
+ }
14731
14824
  return join(homedir(), ".agentsmesh", "cache");
14732
14825
  }
14733
14826
  async function fetchRemoteExtend(source, extendName, options = {}) {
@@ -14819,6 +14912,98 @@ async function resolveExtendPaths(config, configDir, options = {}) {
14819
14912
  // src/canonical/features/rules.ts
14820
14913
  init_fs();
14821
14914
  init_markdown();
14915
+
14916
+ // src/utils/filesystem/windows-path-safety.ts
14917
+ var WINDOWS_RESERVED_NAMES = /* @__PURE__ */ new Set([
14918
+ "CON",
14919
+ "PRN",
14920
+ "AUX",
14921
+ "NUL",
14922
+ "COM1",
14923
+ "COM2",
14924
+ "COM3",
14925
+ "COM4",
14926
+ "COM5",
14927
+ "COM6",
14928
+ "COM7",
14929
+ "COM8",
14930
+ "COM9",
14931
+ "LPT1",
14932
+ "LPT2",
14933
+ "LPT3",
14934
+ "LPT4",
14935
+ "LPT5",
14936
+ "LPT6",
14937
+ "LPT7",
14938
+ "LPT8",
14939
+ "LPT9"
14940
+ ]);
14941
+ var WINDOWS_ILLEGAL_CHARS = new RegExp('[<>:"|?*\\u0000-\\u001F]');
14942
+ function segmentReservedName(segment) {
14943
+ const stem = segment.replace(/\.[^.]*$/, "").toUpperCase();
14944
+ return WINDOWS_RESERVED_NAMES.has(stem);
14945
+ }
14946
+ function findWindowsPathIssues(path) {
14947
+ const issues = [];
14948
+ const segments = path.split(/[\\/]/);
14949
+ for (const segment of segments) {
14950
+ if (segment === "" || segment === "." || segment === "..") continue;
14951
+ if (WINDOWS_ILLEGAL_CHARS.test(segment)) {
14952
+ issues.push({ segment, reason: "illegal-character" });
14953
+ continue;
14954
+ }
14955
+ if (/[. ]$/.test(segment)) {
14956
+ issues.push({ segment, reason: "trailing-dot-or-space" });
14957
+ continue;
14958
+ }
14959
+ if (segmentReservedName(segment)) {
14960
+ issues.push({ segment, reason: "reserved-name" });
14961
+ }
14962
+ }
14963
+ return issues;
14964
+ }
14965
+
14966
+ // src/canonical/features/validate-name.ts
14967
+ var CanonicalNameError = class extends Error {
14968
+ feature;
14969
+ name;
14970
+ constructor(feature, name, message) {
14971
+ super(message);
14972
+ this.feature = feature;
14973
+ this.name = name;
14974
+ }
14975
+ };
14976
+ function assertCanonicalName(feature, name) {
14977
+ const issues = findWindowsPathIssues(name);
14978
+ if (issues.length === 0) return;
14979
+ const reasons = issues.map((i) => `${i.segment} (${i.reason})`).join(", ");
14980
+ throw new CanonicalNameError(
14981
+ feature,
14982
+ name,
14983
+ `canonical ${feature} name "${name}" is not portable to Windows: ${reasons}. Rename the file.`
14984
+ );
14985
+ }
14986
+ function assertNoBasenameCollisions(feature, paths, stripExt) {
14987
+ const seen = /* @__PURE__ */ new Map();
14988
+ for (const p of paths) {
14989
+ const fwdIdx = p.lastIndexOf("/");
14990
+ const bckIdx = p.lastIndexOf("\\");
14991
+ const idx = Math.max(fwdIdx, bckIdx);
14992
+ const base = idx === -1 ? p : p.slice(idx + 1);
14993
+ const slug = base.endsWith(stripExt) ? base.slice(0, -stripExt.length) : base;
14994
+ const prior = seen.get(slug);
14995
+ if (prior !== void 0 && prior !== p) {
14996
+ throw new CanonicalNameError(
14997
+ feature,
14998
+ slug,
14999
+ `canonical ${feature} files collide on slug "${slug}": ${prior} vs ${p}. Rename one.`
15000
+ );
15001
+ }
15002
+ seen.set(slug, p);
15003
+ }
15004
+ }
15005
+
15006
+ // src/canonical/features/rules.ts
14822
15007
  var VALID_TRIGGERS = ["always_on", "model_decision", "glob", "manual"];
14823
15008
  function toStrArray(v) {
14824
15009
  if (Array.isArray(v)) return v.filter((x) => typeof x === "string");
@@ -14838,6 +15023,7 @@ async function parseRules(rulesDir) {
14838
15023
  if (!content) continue;
14839
15024
  const { frontmatter, body } = parseFrontmatter(content);
14840
15025
  const name = basename(path, ".md");
15026
+ assertCanonicalName("rule", name);
14841
15027
  const rootFromFilename = name === "_root";
14842
15028
  const rootFromFm = frontmatter.root === true;
14843
15029
  const triggerRaw = frontmatter.trigger;
@@ -14879,12 +15065,14 @@ function toToolsArray(v) {
14879
15065
  async function parseCommands(commandsDir) {
14880
15066
  const files = await readDirRecursive(commandsDir);
14881
15067
  const mdFiles = files.filter((f) => f.endsWith(".md") && !basename(f).startsWith("_"));
15068
+ assertNoBasenameCollisions("command", mdFiles, ".md");
14882
15069
  const commands = [];
14883
15070
  for (const path of mdFiles) {
14884
15071
  const content = await readFileSafe(path);
14885
15072
  if (!content) continue;
14886
15073
  const { frontmatter, body } = parseFrontmatter(content);
14887
15074
  const name = basename(path, ".md");
15075
+ assertCanonicalName("command", name);
14888
15076
  const fromCamel = toToolsArray(frontmatter.allowedTools);
14889
15077
  const fromKebab = toToolsArray(frontmatter["allowed-tools"]);
14890
15078
  const allowedTools = fromCamel.length > 0 ? fromCamel : fromKebab;
@@ -14932,12 +15120,14 @@ function toHooks(v) {
14932
15120
  async function parseAgents(agentsDir) {
14933
15121
  const files = await readDirRecursive(agentsDir);
14934
15122
  const mdFiles = files.filter((f) => f.endsWith(".md") && !basename(f).startsWith("_"));
15123
+ assertNoBasenameCollisions("agent", mdFiles, ".md");
14935
15124
  const agents = [];
14936
15125
  for (const path of mdFiles) {
14937
15126
  const content = await readFileSafe(path);
14938
15127
  if (!content) continue;
14939
15128
  const { frontmatter, body } = parseFrontmatter(content);
14940
15129
  const name = basename(path, ".md");
15130
+ assertCanonicalName("agent", name);
14941
15131
  const toolsCamel = toStrArray2(frontmatter.tools);
14942
15132
  const toolsKebab = toStrArray2(frontmatter["tools"]);
14943
15133
  const tools = toolsCamel.length > 0 ? toolsCamel : toolsKebab;
@@ -15005,9 +15195,11 @@ async function parseSkillDirectory(skillDir) {
15005
15195
  const { frontmatter, body } = parseFrontmatter(content);
15006
15196
  const supportingFiles = await listSupportingFiles(skillDir);
15007
15197
  const fmName = typeof frontmatter.name === "string" ? sanitizeSkillName(frontmatter.name) : "";
15198
+ const name = fmName || basename(skillDir);
15199
+ assertCanonicalName("skill", name);
15008
15200
  return {
15009
15201
  source: skillPath,
15010
- name: fmName || basename(skillDir),
15202
+ name,
15011
15203
  description: typeof frontmatter.description === "string" ? frontmatter.description : "",
15012
15204
  body,
15013
15205
  supportingFiles
@@ -15024,6 +15216,7 @@ async function parseSkills(skillsDir) {
15024
15216
  for (const ent of entries) {
15025
15217
  if (!ent.isDirectory()) continue;
15026
15218
  if (ent.name.startsWith("_")) continue;
15219
+ assertCanonicalName("skill", ent.name);
15027
15220
  const skillDir = join(skillsDir, ent.name);
15028
15221
  const skillPath = join(skillDir, SKILL_FILE);
15029
15222
  const content = await readFileSafe(skillPath);
@@ -15842,7 +16035,15 @@ var conversionsSchema = z.object({
15842
16035
  var pluginEntrySchema = z.object({
15843
16036
  id: z.string().regex(/^[a-z][a-z0-9-]*$/),
15844
16037
  source: z.string(),
15845
- version: z.string().optional()
16038
+ version: z.string().optional(),
16039
+ /**
16040
+ * When true, a failure to import or validate this plugin throws and
16041
+ * aborts the run. Default `false` keeps the lenient behavior of logging
16042
+ * a warning and continuing. Use strict mode in CI when missing
16043
+ * descriptors should fail the build instead of silently shrinking the
16044
+ * generation matrix.
16045
+ */
16046
+ strict: z.boolean().optional()
15846
16047
  }).strict();
15847
16048
  var configSchema = z.object({
15848
16049
  version: z.literal(1),