@wrongstack/tools 0.300.0 → 0.301.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/dist/patch.js CHANGED
@@ -3,10 +3,11 @@ import { spawn } from "node:child_process";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import * as path2 from "node:path";
6
- import { buildChildEnv } from "@wrongstack/core/utils";
6
+ import { buildChildEnv, toErrorMessage } from "@wrongstack/core/utils";
7
7
 
8
8
  // src/_util.ts
9
9
  import { createHash } from "node:crypto";
10
+ import * as fsp from "node:fs/promises";
10
11
  import * as path from "node:path";
11
12
  import * as Core from "@wrongstack/core/utils";
12
13
  function sha256hex(content) {
@@ -33,13 +34,46 @@ function ensureInsideRoot(absPath, ctx) {
33
34
  function safeResolve(input, ctx) {
34
35
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
35
36
  }
37
+ async function resolveRealInsideRoot(absPath, ctx) {
38
+ if (ctx.allowOutsideProjectRoot) return absPath;
39
+ const realRoots = await Promise.all(
40
+ allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r)))
41
+ );
42
+ let probe = absPath;
43
+ const pendingTail = [];
44
+ for (; ; ) {
45
+ let real;
46
+ try {
47
+ real = await fsp.realpath(probe);
48
+ } catch (err) {
49
+ if (err.code === "ENOENT") {
50
+ const parent = path.dirname(probe);
51
+ if (parent === probe) return absPath;
52
+ pendingTail.unshift(path.basename(probe));
53
+ probe = parent;
54
+ continue;
55
+ }
56
+ throw err;
57
+ }
58
+ if (isInsideAny(real, realRoots)) {
59
+ return pendingTail.length > 0 ? path.join(real, ...pendingTail) : real;
60
+ }
61
+ throw new Error(
62
+ `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
63
+ );
64
+ }
65
+ }
66
+ async function safeResolveReal(input, ctx) {
67
+ const abs = safeResolve(input, ctx);
68
+ return await resolveRealInsideRoot(abs, ctx);
69
+ }
36
70
 
37
71
  // src/patch.ts
38
72
  var patchTool = {
39
73
  name: "patch",
40
74
  category: "Filesystem",
41
75
  description: "Apply a unified diff (patch) to the project. This is the correct tool when you have a diff that needs to be applied precisely, including handling of rejects.",
42
- usageHint: "Best used when you already have a diff (from generation, external source, or previous step).\n- Use `dry_run: true` to see what would happen without modifying files.\n- On failure it creates .rej and .orig files for manual review.\nOften cleaner than many small `edit` operations for larger changes.",
76
+ usageHint: "Best used when you already have a diff (from generation, external source, or previous step).\n- Use `dry_run: true` to see what would happen without modifying files.\n- Applied with `--merge`: a conflicting hunk writes git-style conflict\n markers (<<<<<<< / ======= / >>>>>>>) INTO the file and reports failure.\n It does NOT create .rej/.orig files. `files` lists what changed on disk\n even when the patch failed, so read those back before retrying.\nOften cleaner than many small `edit` operations for larger changes.",
43
77
  selection: {
44
78
  doNotUseWhen: "you do not already have a unified diff or only need one precise replacement.",
45
79
  useInstead: ["edit"]
@@ -65,31 +99,50 @@ var patchTool = {
65
99
  },
66
100
  async execute(input, ctx, opts) {
67
101
  if (!input?.patch) throw new Error("patch: patch content is required");
68
- const dir = input.directory ? safeResolve(input.directory, ctx) : ctx.cwd;
69
102
  const strip = Math.max(1, input.strip ?? 1);
70
103
  const dryRun = input.dry_run ?? false;
104
+ const refuse = (message) => ({
105
+ applied: 0,
106
+ rejected: 1,
107
+ files: [],
108
+ dry_run: dryRun,
109
+ message
110
+ });
111
+ let dir;
112
+ try {
113
+ dir = input.directory ? await safeResolveReal(input.directory, ctx) : ctx.cwd;
114
+ } catch (err) {
115
+ return refuse(`patch refused: ${toErrorMessage(err)}`);
116
+ }
117
+ const realRoot = await fs.realpath(ctx.projectRoot).catch(() => path2.resolve(ctx.projectRoot));
71
118
  const targets = extractDiffTargets(input.patch);
72
119
  const resolvedTargets = [];
73
120
  for (const t of targets) {
74
- const stripped = stripPathComponents(t, strip);
121
+ const stripped = stripPathComponents(t.raw, strip);
75
122
  if (!stripped) continue;
123
+ if (path2.isAbsolute(stripped)) {
124
+ return refuse(`patch refused: target "${t.raw}" strips to absolute path`);
125
+ }
76
126
  const candidate = path2.resolve(dir, stripped);
77
- const rel = path2.relative(ctx.projectRoot, candidate);
127
+ let real;
128
+ try {
129
+ real = await resolveRealInsideRoot(candidate, ctx);
130
+ } catch (err) {
131
+ return refuse(`patch refused: target "${t.raw}" ${toErrorMessage(err)}`);
132
+ }
133
+ const rel = path2.relative(realRoot, real);
78
134
  if (rel.startsWith("..") || path2.isAbsolute(rel)) {
79
- return {
80
- applied: 0,
81
- rejected: 1,
82
- files: [],
83
- dry_run: dryRun,
84
- message: `patch refused: target "${t}" resolves outside project root`
85
- };
135
+ return refuse(`patch refused: target "${t.raw}" resolves outside project root`);
86
136
  }
87
- resolvedTargets.push(candidate);
137
+ resolvedTargets.push({ raw: t.raw, deleted: t.deleted, abs: real });
88
138
  }
89
139
  const beforeContents = /* @__PURE__ */ new Map();
140
+ const beforeExisted = /* @__PURE__ */ new Set();
90
141
  if (!dryRun) {
91
142
  for (const target of resolvedTargets) {
92
- beforeContents.set(target, await readTextForTracking(target));
143
+ const existed = (await fs.stat(target.abs).catch(() => null))?.isFile() ?? false;
144
+ if (existed) beforeExisted.add(target.abs);
145
+ beforeContents.set(target.abs, await readTextForTracking(target.abs));
93
146
  }
94
147
  }
95
148
  const tmpDir = await fs.mkdtemp(path2.join(os.tmpdir(), ".wstack_patch_"));
@@ -99,32 +152,70 @@ var patchTool = {
99
152
  const patchFile = path2.join(tmpDir, "in.diff");
100
153
  await fs.writeFile(patchFile, input.patch, { mode: 384 });
101
154
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
102
- const result = await runPatch(args, dir, opts.signal);
103
- if (result.exitCode !== 0 && !dryRun) {
104
- return {
105
- applied: 0,
106
- rejected: 1,
107
- files: [],
108
- dry_run: dryRun,
109
- message: `patch failed: ${result.stderr || result.stdout}`
110
- };
111
- }
112
- const patched = extractPatchedFiles(result.stdout);
155
+ const result = await runPatch(args, dir, opts.signal, {
156
+ patchFile,
157
+ strip,
158
+ dryRun
159
+ });
160
+ const touched = [];
113
161
  if (!dryRun) {
114
162
  for (const target of resolvedTargets) {
115
- const before = beforeContents.get(target) ?? null;
116
- const after = await readTextForTracking(target);
163
+ const abs = target.abs;
164
+ const before = beforeContents.get(abs) ?? null;
165
+ const stat2 = await fs.stat(abs).catch(() => null);
166
+ if (!stat2?.isFile()) {
167
+ if (beforeExisted.has(abs)) {
168
+ touched.push(abs);
169
+ ctx.session?.recordFileChange?.({
170
+ path: abs,
171
+ action: "deleted",
172
+ before,
173
+ after: null
174
+ });
175
+ }
176
+ continue;
177
+ }
178
+ const after = await readTextForTracking(abs);
117
179
  if (after === null || after === before) continue;
118
- const stat2 = await fs.stat(target).catch(() => null);
119
- if (stat2) ctx.recordRead?.(target, stat2.mtimeMs, "write", sha256hex(after));
180
+ touched.push(abs);
181
+ ctx.recordRead?.(abs, stat2.mtimeMs, "write", sha256hex(after));
120
182
  ctx.session?.recordFileChange?.({
121
- path: target,
183
+ path: abs,
122
184
  action: before === null ? "created" : "modified",
123
185
  before,
124
186
  after
125
187
  });
126
188
  }
127
189
  }
190
+ if (result.exitCode !== 0) {
191
+ if (!dryRun) {
192
+ const partial = touched.length > 0 ? ` ${touched.length} file(s) were still modified on disk and have been recorded for rewind: ${touched.map((p) => path2.relative(realRoot, p) || p).join(", ")}.` : "";
193
+ return {
194
+ applied: touched.length,
195
+ rejected: 1,
196
+ // Normalize to relative-to-realRoot for API consistency with the
197
+ // success path (which returns GNU patch's dir-relative names).
198
+ // `touched` entries are realpaths from resolveRealInsideRoot, and
199
+ // realRoot is also a realpath, so path.relative is like-for-like.
200
+ files: touched.map((p) => path2.relative(realRoot, p) || p),
201
+ dry_run: dryRun,
202
+ message: `patch failed: ${result.stderr || result.stdout}${partial}`
203
+ };
204
+ }
205
+ const wouldPatch = extractPatchedFiles(result.stdout);
206
+ return {
207
+ applied: wouldPatch.length,
208
+ rejected: 1,
209
+ files: wouldPatch,
210
+ dry_run: dryRun,
211
+ message: `patch preview: would conflict \u2014 ${result.stderr || result.stdout}`
212
+ };
213
+ }
214
+ const patched = result.engine === "git" ? [
215
+ ...new Set(
216
+ resolvedTargets.map((target) => path2.relative(dir, target.abs) || target.abs)
217
+ )
218
+ ] : extractPatchedFiles(result.stdout);
128
219
  return {
129
220
  applied: patched.length,
130
221
  rejected: 0,
@@ -152,27 +243,86 @@ async function readTextForTracking(absPath) {
152
243
  }
153
244
  function extractDiffTargets(patch) {
154
245
  const out = [];
155
- const re = /^\+\+\+\s+([^\t\r\n]+)/gm;
156
- for (const m of patch.matchAll(re)) {
157
- const raw = m[1];
158
- if (!raw) continue;
159
- const target = raw.length > 4096 ? raw.slice(0, 4096).trim() : raw.trim();
160
- if (!target || target === "/dev/null") continue;
161
- out.push(target);
246
+ const clean = (raw) => {
247
+ if (!raw) return "";
248
+ return (raw.length > 4096 ? raw.slice(0, 4096) : raw).trim();
249
+ };
250
+ let lastOld;
251
+ let inHunk = false;
252
+ let oldLinesLeft = 0;
253
+ let newLinesLeft = 0;
254
+ for (const line of patch.split(/\r?\n/)) {
255
+ const hunkMatch = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
256
+ if (hunkMatch) {
257
+ inHunk = true;
258
+ oldLinesLeft = hunkMatch[1] ? Number(hunkMatch[1]) : 1;
259
+ newLinesLeft = hunkMatch[2] ? Number(hunkMatch[2]) : 1;
260
+ lastOld = void 0;
261
+ continue;
262
+ }
263
+ if (inHunk) {
264
+ const ch = line[0];
265
+ if (ch === "-") oldLinesLeft--;
266
+ else if (ch === "+") newLinesLeft--;
267
+ else if (ch === " " || ch === void 0) {
268
+ oldLinesLeft--;
269
+ newLinesLeft--;
270
+ }
271
+ if (oldLinesLeft <= 0 && newLinesLeft <= 0) inHunk = false;
272
+ continue;
273
+ }
274
+ const oldMatch = /^---\s+([^\t\r\n]+)/.exec(line);
275
+ if (oldMatch) {
276
+ lastOld = clean(oldMatch[1]);
277
+ continue;
278
+ }
279
+ const newMatch = /^\+\+\+\s+([^\t\r\n]+)/.exec(line);
280
+ if (!newMatch) continue;
281
+ const newTarget = clean(newMatch[1]);
282
+ if (newTarget && newTarget !== "/dev/null") {
283
+ out.push({ raw: newTarget, deleted: false });
284
+ } else if (lastOld && lastOld !== "/dev/null") {
285
+ out.push({ raw: lastOld, deleted: true });
286
+ }
287
+ lastOld = void 0;
162
288
  }
163
289
  return out;
164
290
  }
165
291
  function stripPathComponents(p, strip) {
166
- const parts = p.replace(/\\/g, "/").split("/").filter((s) => s !== "" && s !== ".");
167
- if (parts.length <= strip) return void 0;
168
- return parts.slice(strip).join("/");
292
+ const s = p.replace(/\\/g, "/");
293
+ let idx = 0;
294
+ for (let i = 0; i < strip; i++) {
295
+ while (idx < s.length && s[idx] !== "/") idx++;
296
+ let hadSlash = false;
297
+ while (idx < s.length && s[idx] === "/") {
298
+ idx++;
299
+ hadSlash = true;
300
+ }
301
+ if (!hadSlash) return void 0;
302
+ }
303
+ return s.slice(idx) || void 0;
304
+ }
305
+ function runPatch(args, cwd, signal, fallback) {
306
+ return runPatchProcess("patch", args, cwd, signal).then(async (result) => {
307
+ if (!result.unavailable) return { ...result, engine: "patch" };
308
+ const gitArgs = [
309
+ "apply",
310
+ "--unsafe-paths",
311
+ `-p${fallback.strip}`,
312
+ "--verbose",
313
+ ...fallback.dryRun ? ["--check"] : [],
314
+ fallback.patchFile
315
+ ];
316
+ const gitResult = await runPatchProcess("git", gitArgs, cwd, signal);
317
+ return { ...gitResult, engine: "git" };
318
+ });
169
319
  }
170
- function runPatch(args, cwd, signal) {
320
+ function runPatchProcess(command, args, cwd, signal) {
171
321
  return new Promise((resolve3) => {
172
322
  let stdout = "";
173
323
  let stderr = "";
174
324
  const env = { ...buildChildEnv(), LANG: "C", LC_ALL: "C" };
175
- const child = spawn("patch", args, {
325
+ const child = spawn(command, args, {
176
326
  cwd,
177
327
  signal,
178
328
  env,
@@ -185,13 +335,24 @@ function runPatch(args, cwd, signal) {
185
335
  child.stderr?.on("data", (c) => {
186
336
  stderr += c.toString();
187
337
  });
188
- child.on("close", (code) => resolve3({ exitCode: code ?? 1, stdout, stderr }));
189
- child.on("error", (e) => resolve3({ exitCode: 1, stdout: "", stderr: e.message }));
338
+ child.on(
339
+ "close",
340
+ (code) => resolve3({ exitCode: code ?? 1, stdout, stderr, unavailable: false })
341
+ );
342
+ child.on(
343
+ "error",
344
+ (e) => resolve3({
345
+ exitCode: 1,
346
+ stdout: "",
347
+ stderr: e.message,
348
+ unavailable: e.code === "ENOENT"
349
+ })
350
+ );
190
351
  });
191
352
  }
192
353
  function extractPatchedFiles(output) {
193
354
  const files = [];
194
- const re = /patching file (.+)/gi;
355
+ const re = /(?:patching|checking) file (.+)/gi;
195
356
  for (const m of output.matchAll(re)) {
196
357
  if (m[1]) files.push(m[1]);
197
358
  }
package/dist/read.js CHANGED
@@ -992,16 +992,23 @@ function looksBinary(content) {
992
992
  }
993
993
  return bad / sample.length > 0.1;
994
994
  }
995
- function lineColAt(content, index) {
996
- let line = 1;
997
- let lastNl = -1;
998
- for (let i = 0; i < index && i < content.length; i++) {
999
- if (content.charCodeAt(i) === 10) {
1000
- line++;
1001
- lastNl = i;
1002
- }
995
+ function newlineOffsets2(content) {
996
+ const offsets = [];
997
+ for (let i = 0; i < content.length; i++) {
998
+ if (content.charCodeAt(i) === 10) offsets.push(i);
999
+ }
1000
+ return offsets;
1001
+ }
1002
+ function lineColAt(offsets, index) {
1003
+ let low = 0;
1004
+ let high = offsets.length;
1005
+ while (low < high) {
1006
+ const mid = low + high >>> 1;
1007
+ if ((offsets[mid] ?? 0) < index) low = mid + 1;
1008
+ else high = mid;
1003
1009
  }
1004
- return { line, col: index - lastNl };
1010
+ const lastNl = low > 0 ? offsets[low - 1] : -1;
1011
+ return { line: low + 1, col: index - lastNl };
1005
1012
  }
1006
1013
  function parseGeneric(opts) {
1007
1014
  const { file, lang } = opts;
@@ -1014,6 +1021,7 @@ function parseGeneric(opts) {
1014
1021
  const patterns = patternsFor(lang);
1015
1022
  const symbols = [];
1016
1023
  const seen = /* @__PURE__ */ new Set();
1024
+ const nlOffsets = newlineOffsets2(content);
1017
1025
  for (const pattern of patterns) {
1018
1026
  const re = new RegExp(pattern.re.source, pattern.re.flags.includes("g") ? pattern.re.flags : `${pattern.re.flags}g`);
1019
1027
  re.lastIndex = 0;
@@ -1027,7 +1035,7 @@ function parseGeneric(opts) {
1027
1035
  if (!/^[A-Za-z_#.@/\w][\w.\-:/#!?]*$/.test(name) && lang !== "md" && lang !== "toml") {
1028
1036
  continue;
1029
1037
  }
1030
- const { line, col } = lineColAt(content, match.index);
1038
+ const { line, col } = lineColAt(nlOffsets, match.index ?? 0);
1031
1039
  const key = `${name}\0${line}\0${pattern.kind}`;
1032
1040
  if (seen.has(key)) continue;
1033
1041
  seen.add(key);
@@ -3128,9 +3136,9 @@ import * as path11 from "node:path";
3128
3136
  // src/codebase-index/bm25.ts
3129
3137
  var K1 = 1.5;
3130
3138
  var B = 0.75;
3139
+ var TOKENISE_RE = new RegExp("[^\\p{L}\\p{N}$']", "gu");
3131
3140
  function tokenise(text) {
3132
- const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
3133
- return sanitised.toLowerCase().split(" ").filter(Boolean);
3141
+ return text.replace(TOKENISE_RE, " ").toLowerCase().trim().split(/\s+/).filter(Boolean);
3134
3142
  }
3135
3143
  function splitName(name) {
3136
3144
  return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/([\p{L}])(\d)/gu, "$1 $2").replace(/(\d)([\p{L}])/gu, "$1 $2").replace(/[_-]+/g, " ").trim();
@@ -5878,7 +5886,9 @@ var PROJECT_INDEX_SERVER_METADATA_FILE = "server.json";
5878
5886
  var PROJECT_INDEX_SERVER_SOCKET_DIR = `wsci-v${PROJECT_INDEX_SERVER_PROTOCOL_VERSION}`;
5879
5887
  var buildIdCache;
5880
5888
  function projectIndexServerBuildId(entrypoint) {
5881
- const file = entrypoint instanceof URL || entrypoint.startsWith("file:") ? fileURLToPath(entrypoint) : path13.resolve(entrypoint);
5889
+ const href = entrypoint instanceof URL ? entrypoint.href : entrypoint;
5890
+ const cleanHref = href.split(/[?#]/, 1)[0] ?? href;
5891
+ const file = cleanHref.startsWith("file:") ? fileURLToPath(cleanHref) : path13.resolve(cleanHref);
5882
5892
  try {
5883
5893
  const stat3 = fs9.statSync(file);
5884
5894
  if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat3.mtimeMs && buildIdCache.size === stat3.size) {
package/dist/replace.js CHANGED
@@ -27,6 +27,81 @@ var DANGEROUS_PATTERNS = [
27
27
  // Greedy quantifier inside lookahead/lookbehind — (?!.*a+)
28
28
  /[([][^)\]]*[+*][^)\]]*[)\]][^)]*\?\??/
29
29
  ];
30
+ function hasAmbiguousQuantifiedAlternation(pattern) {
31
+ for (let i = 0; i < pattern.length; i++) {
32
+ if (pattern[i] !== "(") continue;
33
+ if (i > 0 && pattern[i - 1] === "\\") continue;
34
+ let depth = 0;
35
+ let inClass = false;
36
+ let j = i;
37
+ for (; j < pattern.length; j++) {
38
+ const ch = pattern[j];
39
+ if (ch === "\\") {
40
+ j++;
41
+ continue;
42
+ }
43
+ if (inClass) {
44
+ if (ch === "]") inClass = false;
45
+ continue;
46
+ }
47
+ if (ch === "[") {
48
+ inClass = true;
49
+ continue;
50
+ }
51
+ if (ch === "(") depth++;
52
+ else if (ch === ")") {
53
+ depth--;
54
+ if (depth === 0) break;
55
+ }
56
+ }
57
+ if (j >= pattern.length) return false;
58
+ const next = pattern[j + 1];
59
+ if (next !== "+" && next !== "*" && next !== "{") continue;
60
+ let inner = pattern.slice(i + 1, j);
61
+ inner = inner.replace(/^\?(?::|<?[=!])/u, "");
62
+ const branches = [];
63
+ let current = "";
64
+ let d = 0;
65
+ let cls = false;
66
+ for (let k = 0; k < inner.length; k++) {
67
+ const ch = inner[k];
68
+ if (ch === "\\") {
69
+ current += ch + (inner[k + 1] ?? "");
70
+ k++;
71
+ continue;
72
+ }
73
+ if (cls) {
74
+ if (ch === "]") cls = false;
75
+ current += ch;
76
+ continue;
77
+ }
78
+ if (ch === "[") {
79
+ cls = true;
80
+ current += ch;
81
+ continue;
82
+ }
83
+ if (ch === "(") d++;
84
+ if (ch === ")") d--;
85
+ if (ch === "|" && d === 0) {
86
+ branches.push(current);
87
+ current = "";
88
+ continue;
89
+ }
90
+ current += ch;
91
+ }
92
+ branches.push(current);
93
+ if (branches.length < 2) continue;
94
+ for (let a = 0; a < branches.length; a++) {
95
+ for (let b = a + 1; b < branches.length; b++) {
96
+ const x = branches[a];
97
+ const y = branches[b];
98
+ if (x === "" || y === "") return true;
99
+ if (x === y || x.startsWith(y) || y.startsWith(x)) return true;
100
+ }
101
+ }
102
+ }
103
+ return false;
104
+ }
30
105
  function compileUserRegex(pattern, flags) {
31
106
  if (typeof pattern !== "string") {
32
107
  return { ok: false, reason: "pattern must be a string" };
@@ -45,6 +120,12 @@ function compileUserRegex(pattern, flags) {
45
120
  };
46
121
  }
47
122
  }
123
+ if (hasAmbiguousQuantifiedAlternation(pattern)) {
124
+ return {
125
+ ok: false,
126
+ reason: "pattern quantifies an alternation with overlapping branches \u2014 rewrite so no two branches can match the same text"
127
+ };
128
+ }
48
129
  try {
49
130
  return { ok: true, regex: new RegExp(pattern, flags) };
50
131
  } catch (err) {
package/dist/skill.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // src/skill.ts
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import {
5
+ missingRequiredRuntimeTools,
6
+ missingRuntimeCapabilities,
7
+ runtimeToolReferencesFromText
8
+ } from "@wrongstack/core/agent-catalog";
4
9
  import { SKILL_LIMITS, stripFrontmatter } from "@wrongstack/core/skills";
5
10
  import { ToolValidationError } from "@wrongstack/core/types";
6
11
  var MAX_BODY_CHARS = SKILL_LIMITS.MAX_SKILL_BODY_CHARS;
@@ -44,12 +49,37 @@ function makeSkillTool(skillLoader) {
44
49
  field: "name"
45
50
  });
46
51
  }
52
+ const availableToolNames = (ctx?.catalogTools ?? ctx?.tools ?? []).map((tool) => tool.name);
53
+ const missingCapabilities = missingRuntimeCapabilities(
54
+ manifest.requiredCapabilities,
55
+ availableToolNames
56
+ );
57
+ const missingTools = missingRequiredRuntimeTools(manifest.requiredTools, availableToolNames);
58
+ if (missingCapabilities.length > 0 || missingTools.length > 0) {
59
+ throw new ToolValidationError({
60
+ message: `skill "${name}" is unavailable in this runtime; ` + [
61
+ missingCapabilities.length > 0 ? `missing capabilities: ${missingCapabilities.join(", ")}` : "",
62
+ missingTools.length > 0 ? `missing tools: ${missingTools.join(", ")}` : ""
63
+ ].filter(Boolean).join("; "),
64
+ field: "name"
65
+ });
66
+ }
47
67
  const dir = path.dirname(manifest.path);
48
68
  let loadedResource;
49
69
  if (input.resource?.trim()) {
50
70
  loadedResource = await loadResource(dir, input.resource.trim());
51
71
  }
52
72
  const raw = await skillLoader.readBody(name);
73
+ const missingBodyTools = missingRequiredRuntimeTools(
74
+ runtimeToolReferencesFromText(raw),
75
+ availableToolNames
76
+ );
77
+ if (missingBodyTools.length > 0) {
78
+ throw new ToolValidationError({
79
+ message: `skill "${name}" references unregistered tools: ${missingBodyTools.join(", ")}`,
80
+ field: "name"
81
+ });
82
+ }
53
83
  const body = stripFrontmatter(raw).trim().slice(0, MAX_BODY_CHARS);
54
84
  const resources = loadedResource ? [] : await listResources(dir);
55
85
  try {
@@ -108,9 +138,26 @@ async function loadResource(skillDir, rel) {
108
138
  field: "resource"
109
139
  });
110
140
  }
141
+ let realPath;
142
+ let realRoot;
143
+ try {
144
+ realRoot = await fs.realpath(root);
145
+ realPath = await fs.realpath(absPath);
146
+ } catch {
147
+ throw new ToolValidationError({
148
+ message: `skill: resource "${rel}" not readable`,
149
+ field: "resource"
150
+ });
151
+ }
152
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
153
+ throw new ToolValidationError({
154
+ message: `skill: resource "${rel}" resolves outside the skill directory`,
155
+ field: "resource"
156
+ });
157
+ }
111
158
  let buf;
112
159
  try {
113
- buf = await fs.readFile(absPath);
160
+ buf = await fs.readFile(realPath);
114
161
  } catch {
115
162
  throw new ToolValidationError({
116
163
  message: `skill: resource "${rel}" not readable`,
@@ -121,7 +168,9 @@ async function loadResource(skillDir, rel) {
121
168
  const truncated = raw.length > MAX_RESOURCE_CHARS;
122
169
  return {
123
170
  rel: norm,
124
- absPath,
171
+ // The canonical path — the one actually opened, and the one a follow-up
172
+ // `bash` invocation should use.
173
+ absPath: realPath,
125
174
  content: truncated ? raw.slice(0, MAX_RESOURCE_CHARS) : raw,
126
175
  bytes: buf.length,
127
176
  truncated
package/dist/tool-help.js CHANGED
@@ -31,7 +31,7 @@ var toolHelpTool = {
31
31
  const format = input.format ?? "short";
32
32
  const includeExamples = input.include_examples ?? false;
33
33
  if (input.tool) {
34
- const tool = ctx.tools.find((t) => t.name === input.tool);
34
+ const tool = (ctx.catalogTools ?? ctx.tools).find((t) => t.name === input.tool);
35
35
  if (!tool) {
36
36
  return {
37
37
  tool: input.tool,
@@ -56,7 +56,7 @@ var toolHelpTool = {
56
56
  total: 1
57
57
  };
58
58
  }
59
- const allTools = ctx.tools.map((t) => ({
59
+ const allTools = (ctx.catalogTools ?? ctx.tools).map((t) => ({
60
60
  name: t.name,
61
61
  description: t.description,
62
62
  usageHint: t.usageHint ?? "",
@@ -40,7 +40,7 @@ var toolSearchTool = {
40
40
  },
41
41
  async execute(input, ctx) {
42
42
  const limit = Math.min(input.limit ?? 20, 100);
43
- const tools = ctx.tools;
43
+ const tools = ctx.catalogTools ?? ctx.tools;
44
44
  const query = input.query?.toLowerCase() ?? "";
45
45
  const filtered = tools.filter((t) => {
46
46
  if (query && !t.name.toLowerCase().includes(query) && !t.description.toLowerCase().includes(query)) {
@@ -1,5 +1,5 @@
1
- import type { ConcreteTokenSavingTier, Tool } from '@wrongstack/core/types';
2
1
  import type { ToolRegistry } from '@wrongstack/core/registry';
2
+ import type { ConcreteTokenSavingTier, Tool } from '@wrongstack/core/types';
3
3
  /**
4
4
  * Select built-in tools for a concrete token-saving tier while preserving the
5
5
  * order and instances supplied by the caller.