rulesync 16.16.0 → 16.17.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/cli/index.cjs +9 -10
- package/dist/cli/index.js +9 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-NiPCjH6E.cjs → import-DDPTPxOX.cjs} +1013 -60
- package/dist/{import-DimKwtQ6.js → import-u8yswsGB.js} +1004 -63
- package/dist/import-u8yswsGB.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +77 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/import-DimKwtQ6.js.map +0 -1
|
@@ -34,6 +34,7 @@ let gray_matter = require("gray-matter");
|
|
|
34
34
|
gray_matter = __toESM(gray_matter, 1);
|
|
35
35
|
let js_yaml = require("js-yaml");
|
|
36
36
|
let es_toolkit_object = require("es-toolkit/object");
|
|
37
|
+
let node_fs = require("node:fs");
|
|
37
38
|
let node_crypto = require("node:crypto");
|
|
38
39
|
let node_util = require("node:util");
|
|
39
40
|
let smol_toml = require("smol-toml");
|
|
@@ -495,9 +496,22 @@ function isEnvTest() {
|
|
|
495
496
|
}
|
|
496
497
|
//#endregion
|
|
497
498
|
//#region src/utils/file.ts
|
|
499
|
+
/**
|
|
500
|
+
* Whether a relative path leads out of the root it is relative to. Matching
|
|
501
|
+
* whole segments matters: a directory really named `..cache` relatively
|
|
502
|
+
* resolves to `..cache/file`, which a prefix test would report as an escape.
|
|
503
|
+
*/
|
|
498
504
|
function pathEscapesRoot(relativePath) {
|
|
499
505
|
return relativePath === ".." || relativePath.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(relativePath);
|
|
500
506
|
}
|
|
507
|
+
/** Whether a single path segment is a hidden (dot-prefixed) name. */
|
|
508
|
+
function isHiddenPathSegment(segment) {
|
|
509
|
+
return segment.startsWith(".") && segment !== "." && segment !== "..";
|
|
510
|
+
}
|
|
511
|
+
/** Split a path on both separators, so one predicate serves either platform. */
|
|
512
|
+
function splitPathSegments(filePath) {
|
|
513
|
+
return filePath.split(/[/\\]/);
|
|
514
|
+
}
|
|
501
515
|
async function assertWritablePathInsideRoot(params) {
|
|
502
516
|
const { rootPath, targetPath } = params;
|
|
503
517
|
let existingPath = targetPath;
|
|
@@ -728,8 +742,45 @@ async function listDirectoryFiles(dir) {
|
|
|
728
742
|
return [];
|
|
729
743
|
}
|
|
730
744
|
}
|
|
745
|
+
/** How many dot-prefixed segments a path has, used to prefer a named alias over a hidden one. */
|
|
746
|
+
function countHiddenSegments(filePath) {
|
|
747
|
+
return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* The real file a path denotes, posix-separated so it compares against the globby results
|
|
751
|
+
* that produce it. Two paths share an identity when they resolve to the very same file --
|
|
752
|
+
* a link beside its target, a link into a shared tree, or a cycle that walks back into an
|
|
753
|
+
* ancestor and yields the same file forty levels down.
|
|
754
|
+
*/
|
|
755
|
+
async function realFileIdentity(filePath) {
|
|
756
|
+
try {
|
|
757
|
+
return toPosixPath(await (0, node_fs_promises.realpath)(filePath));
|
|
758
|
+
} catch {
|
|
759
|
+
return toPosixPath(filePath);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Pick the one path that represents a file among the paths that resolve to it.
|
|
764
|
+
*
|
|
765
|
+
* The path that walked through no link at all wins outright: it is already the real one,
|
|
766
|
+
* so it equals the file's identity. That keeps the real location of a file as the path
|
|
767
|
+
* callers see, rather than an alias that happens to sort first -- a directory link named
|
|
768
|
+
* `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a cycle must not replace
|
|
769
|
+
* `sub/note.md` with the same file reached back through the cycle.
|
|
770
|
+
* Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
|
|
771
|
+
* named one represents the entry rather than a hidden alias that a hidden-entry rule may
|
|
772
|
+
* then drop, taking the named path's content with it. `candidates` arrives in sorted
|
|
773
|
+
* order, so ties keep the first one deterministically.
|
|
774
|
+
*/
|
|
775
|
+
function chooseRepresentative(candidates, identity) {
|
|
776
|
+
return candidates.reduce((best, candidate) => {
|
|
777
|
+
if (toPosixPath(best) === identity) return best;
|
|
778
|
+
if (toPosixPath(candidate) === identity) return candidate;
|
|
779
|
+
return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
|
|
780
|
+
});
|
|
781
|
+
}
|
|
731
782
|
async function findFilesByGlobs(globs, options = {}) {
|
|
732
|
-
const { type = "all", followSymbolicLinks = true, ignore } = options;
|
|
783
|
+
const { type = "all", followSymbolicLinks = true, ignore, dot = false } = options;
|
|
733
784
|
const globbyOptions = type === "file" ? {
|
|
734
785
|
onlyFiles: true,
|
|
735
786
|
onlyDirectories: false
|
|
@@ -744,23 +795,18 @@ async function findFilesByGlobs(globs, options = {}) {
|
|
|
744
795
|
const results = (0, globby.globbySync)(normalizedGlobs, {
|
|
745
796
|
absolute: true,
|
|
746
797
|
followSymbolicLinks,
|
|
798
|
+
dot,
|
|
747
799
|
...ignore ? { ignore: ignore.map((pattern) => pattern.replaceAll("\\", "/")) } : {},
|
|
748
800
|
...globbyOptions
|
|
749
801
|
});
|
|
750
|
-
const
|
|
751
|
-
const deduped = [];
|
|
802
|
+
const candidatesByFile = /* @__PURE__ */ new Map();
|
|
752
803
|
for (const result of results.toSorted()) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
realResult = result;
|
|
758
|
-
}
|
|
759
|
-
if (seenRealPaths.has(realResult)) continue;
|
|
760
|
-
seenRealPaths.add(realResult);
|
|
761
|
-
deduped.push(result);
|
|
804
|
+
const identity = await realFileIdentity(result);
|
|
805
|
+
const candidates = candidatesByFile.get(identity);
|
|
806
|
+
if (candidates === void 0) candidatesByFile.set(identity, [result]);
|
|
807
|
+
else candidates.push(result);
|
|
762
808
|
}
|
|
763
|
-
return
|
|
809
|
+
return [...candidatesByFile.entries()].map(([identity, candidates]) => chooseRepresentative(candidates, identity)).toSorted();
|
|
764
810
|
}
|
|
765
811
|
async function removeDirectory(dirPath) {
|
|
766
812
|
if ([
|
|
@@ -919,6 +965,25 @@ var CLIError = class extends Error {
|
|
|
919
965
|
}
|
|
920
966
|
};
|
|
921
967
|
//#endregion
|
|
968
|
+
//#region src/utils/warned-once.ts
|
|
969
|
+
/**
|
|
970
|
+
* The messages a once-per-run warning has already emitted in this process.
|
|
971
|
+
* This lives in its own module, free of imports, so the vitest setup file can
|
|
972
|
+
* clear it between tests without pulling `logger.js` into every test's module
|
|
973
|
+
* graph (which would defeat the module mocks some of those tests install).
|
|
974
|
+
*/
|
|
975
|
+
const warnedOnceMessages = /* @__PURE__ */ new Set();
|
|
976
|
+
/** Whether `message` has not been emitted yet; records it when it has not. */
|
|
977
|
+
function claimWarnOnce(message) {
|
|
978
|
+
if (warnedOnceMessages.has(message)) return false;
|
|
979
|
+
warnedOnceMessages.add(message);
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
/** Forget which warnings were already emitted, so each test starts silent. */
|
|
983
|
+
function resetWarnedOnceMessages() {
|
|
984
|
+
warnedOnceMessages.clear();
|
|
985
|
+
}
|
|
986
|
+
//#endregion
|
|
922
987
|
//#region src/utils/logger.ts
|
|
923
988
|
/**
|
|
924
989
|
* Base class for shared verbose/silent state and configuration logic
|
|
@@ -1071,6 +1136,17 @@ const fallbackLogger = new ConsoleLogger();
|
|
|
1071
1136
|
function warnWithFallback(logger, message) {
|
|
1072
1137
|
(logger ?? fallbackLogger).warn(message);
|
|
1073
1138
|
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Emit a warning at most once per run. A single `generate` reads the same source
|
|
1141
|
+
* file once per enabled tool target, so a warning that describes the source
|
|
1142
|
+
* rather than the target would otherwise be printed a dozen identical times.
|
|
1143
|
+
* Diagnostics that name the file they are about qualify; anything whose text
|
|
1144
|
+
* varies with what the user should do next does not.
|
|
1145
|
+
*/
|
|
1146
|
+
function warnOnceWithFallback(logger, message) {
|
|
1147
|
+
if (!claimWarnOnce(message)) return;
|
|
1148
|
+
warnWithFallback(logger, message);
|
|
1149
|
+
}
|
|
1074
1150
|
//#endregion
|
|
1075
1151
|
//#region src/utils/validation.ts
|
|
1076
1152
|
/**
|
|
@@ -2015,8 +2091,7 @@ var AiFile = class {
|
|
|
2015
2091
|
const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.relativeFilePath);
|
|
2016
2092
|
const resolvedFull = (0, node_path.resolve)(fullPath);
|
|
2017
2093
|
const resolvedBase = (0, node_path.resolve)(this.outputRoot);
|
|
2018
|
-
|
|
2019
|
-
if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
|
|
2094
|
+
if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", relativeFilePath="${this.relativeFilePath}"`);
|
|
2020
2095
|
return fullPath;
|
|
2021
2096
|
}
|
|
2022
2097
|
getFileContent() {
|
|
@@ -2078,6 +2153,27 @@ var RulesyncFile = class extends AiFile {
|
|
|
2078
2153
|
}
|
|
2079
2154
|
};
|
|
2080
2155
|
//#endregion
|
|
2156
|
+
//#region src/utils/control-characters.ts
|
|
2157
|
+
/**
|
|
2158
|
+
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
2159
|
+
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
2160
|
+
* line and paragraph separators, and the plain LRM/RLM marks. A name or value
|
|
2161
|
+
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
2162
|
+
* settings file must never reach the terminal with these intact: they let the
|
|
2163
|
+
* text forge log lines, reorder what is printed around them, or inject escape
|
|
2164
|
+
* sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
|
|
2165
|
+
* neutral characters beside them, so they go too — a diagnostic line is not the
|
|
2166
|
+
* place to preserve the typography of a right-to-left name.
|
|
2167
|
+
*/
|
|
2168
|
+
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
2169
|
+
/**
|
|
2170
|
+
* Removes every control character from `text` so it is safe to splice into a
|
|
2171
|
+
* log line or other terminal output.
|
|
2172
|
+
*/
|
|
2173
|
+
function stripControlCharacters(text) {
|
|
2174
|
+
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
2175
|
+
}
|
|
2176
|
+
//#endregion
|
|
2081
2177
|
//#region src/utils/type-guards.ts
|
|
2082
2178
|
/**
|
|
2083
2179
|
* Type guard to check if a value is a plain object (Record<string, unknown>).
|
|
@@ -2201,7 +2297,7 @@ function parseFrontmatter(content, filePath) {
|
|
|
2201
2297
|
let body;
|
|
2202
2298
|
let hasFrontmatter;
|
|
2203
2299
|
try {
|
|
2204
|
-
const result = (0, gray_matter.default)(content);
|
|
2300
|
+
const result = (0, gray_matter.default)(content, {});
|
|
2205
2301
|
frontmatter = result.data;
|
|
2206
2302
|
body = result.content;
|
|
2207
2303
|
hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
|
|
@@ -2215,6 +2311,104 @@ function parseFrontmatter(content, filePath) {
|
|
|
2215
2311
|
hasFrontmatter
|
|
2216
2312
|
};
|
|
2217
2313
|
}
|
|
2314
|
+
/**
|
|
2315
|
+
* A top-level `key: value` entry. Nested entries are left alone deliberately:
|
|
2316
|
+
* the repair below rewrites a value's meaning, and the failure it exists for —
|
|
2317
|
+
* an unquoted sentence with a colon in it — is a `description`, which is always
|
|
2318
|
+
* top-level.
|
|
2319
|
+
*/
|
|
2320
|
+
const TOP_LEVEL_ENTRY_PATTERN = /^([A-Za-z_][\w.-]*):[^\S\r\n]+(\S.*)$/;
|
|
2321
|
+
/** A plain scalar that already starts as some other YAML construct. */
|
|
2322
|
+
const YAML_CONSTRUCT_PREFIX_PATTERN = /^["'|>&*![{#]/;
|
|
2323
|
+
/**
|
|
2324
|
+
* Cut a plain scalar at its inline comment — whitespace followed by `#`.
|
|
2325
|
+
*
|
|
2326
|
+
* This has to happen before anything else looks at the value, or a line such as
|
|
2327
|
+
* `allowed-tools: Read # TODO: add Bash later` reads as needing repair and comes
|
|
2328
|
+
* back quoted with the comment inside it, which for a list of tool permissions
|
|
2329
|
+
* would grant what the comment had disabled. Scanning by hand rather than with
|
|
2330
|
+
* `/\s+#.*$/`: that pattern is unanchored, so a long run of spaces with no `#`
|
|
2331
|
+
* after it backtracks from every starting position, and a value padded with a
|
|
2332
|
+
* megabyte of spaces takes minutes to reject. This is linear in the value.
|
|
2333
|
+
*/
|
|
2334
|
+
function stripInlineComment(rawValue) {
|
|
2335
|
+
for (let index = 1; index < rawValue.length; index++) if (rawValue[index] === "#" && /\s/.test(rawValue[index - 1] ?? "")) return rawValue.slice(0, index).trimEnd();
|
|
2336
|
+
return rawValue.trimEnd();
|
|
2337
|
+
}
|
|
2338
|
+
function repairFrontmatterLine(line) {
|
|
2339
|
+
const unchanged = {
|
|
2340
|
+
line,
|
|
2341
|
+
droppedComment: false
|
|
2342
|
+
};
|
|
2343
|
+
const carriageReturn = line.endsWith("\r") ? "\r" : "";
|
|
2344
|
+
const bareLine = carriageReturn === "" ? line : line.slice(0, -1);
|
|
2345
|
+
const match = TOP_LEVEL_ENTRY_PATTERN.exec(bareLine);
|
|
2346
|
+
if (!match) return unchanged;
|
|
2347
|
+
const [, key = "", rawValue = ""] = match;
|
|
2348
|
+
const value = stripInlineComment(rawValue);
|
|
2349
|
+
if (value === "") return unchanged;
|
|
2350
|
+
if (!/:(?:\s|$)/.test(value)) return unchanged;
|
|
2351
|
+
if (YAML_CONSTRUCT_PREFIX_PATTERN.test(value)) return unchanged;
|
|
2352
|
+
return {
|
|
2353
|
+
line: `${key}: ${JSON.stringify(value)}${carriageReturn}`,
|
|
2354
|
+
droppedComment: value !== rawValue.trimEnd()
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Quote the unquoted scalars that make a frontmatter block unparseable, or
|
|
2359
|
+
* return `undefined` when there is nothing to repair. Only the frontmatter
|
|
2360
|
+
* block is rewritten; the body is passed through untouched.
|
|
2361
|
+
*/
|
|
2362
|
+
function repairMalformedFrontmatterYaml(content) {
|
|
2363
|
+
const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
|
|
2364
|
+
if (!opening) return;
|
|
2365
|
+
const blockStart = opening[0].length;
|
|
2366
|
+
const closing = /\r?\n---/.exec(content.slice(blockStart));
|
|
2367
|
+
if (!closing) return;
|
|
2368
|
+
const blockEnd = blockStart + closing.index;
|
|
2369
|
+
const block = content.slice(blockStart, blockEnd);
|
|
2370
|
+
const repairedLines = block.split("\n").map(repairFrontmatterLine);
|
|
2371
|
+
const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
|
|
2372
|
+
if (repairedBlock === block) return;
|
|
2373
|
+
return {
|
|
2374
|
+
content: content.slice(0, blockStart) + repairedBlock + content.slice(blockEnd),
|
|
2375
|
+
droppedComment: repairedLines.some(({ droppedComment }) => droppedComment)
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Parse frontmatter, retrying once with unquoted colon-bearing values quoted.
|
|
2380
|
+
*
|
|
2381
|
+
* Files authored for another client routinely carry YAML that only that
|
|
2382
|
+
* client's parser accepts — `description: Use this skill when: the user asks
|
|
2383
|
+
* about PDFs` is the case the Agent Skills client guide names. Without a retry
|
|
2384
|
+
* such a file is not merely reported, it is dropped: the lenient skill import
|
|
2385
|
+
* catches the parse error and skips the whole skill. The retry is deliberately
|
|
2386
|
+
* narrow — one pass, top-level entries only, and the original error is what
|
|
2387
|
+
* surfaces if it does not help, so a genuinely broken file still fails with the
|
|
2388
|
+
* message that describes what is actually wrong with it. A file with no closing
|
|
2389
|
+
* `---`, or one whose opening fence carries a language tag, is not repaired at
|
|
2390
|
+
* all: neither is a frontmatter block gray-matter would have read.
|
|
2391
|
+
*
|
|
2392
|
+
* @see https://agentskills.io/client-implementation/adding-skills-support
|
|
2393
|
+
*/
|
|
2394
|
+
function parseFrontmatterWithYamlRepair(content, filePath, options = {}) {
|
|
2395
|
+
try {
|
|
2396
|
+
return parseFrontmatter(content, filePath);
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
const repaired = repairMalformedFrontmatterYaml(content);
|
|
2399
|
+
if (repaired === void 0) throw error;
|
|
2400
|
+
let result;
|
|
2401
|
+
try {
|
|
2402
|
+
result = parseFrontmatter(repaired.content, filePath);
|
|
2403
|
+
} catch {
|
|
2404
|
+
throw error;
|
|
2405
|
+
}
|
|
2406
|
+
if (options.quiet === true) return result;
|
|
2407
|
+
const commentNote = repaired.droppedComment ? " Text following a space and `#` was read as a YAML comment and left out of the value." : "";
|
|
2408
|
+
warnOnceWithFallback(void 0, `Recovered malformed YAML frontmatter in ${filePath === void 0 ? "the input" : stripControlCharacters(toPosixPath(filePath))} by quoting values that contain a colon.${commentNote} Quote them in the file itself so other tools can read it too.`);
|
|
2409
|
+
return result;
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2218
2412
|
//#endregion
|
|
2219
2413
|
//#region src/features/checks/rulesync-check.ts
|
|
2220
2414
|
const RulesyncCheckFrontmatterSchema = zod_mini.z.looseObject({
|
|
@@ -5884,8 +6078,474 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
5884
6078
|
}
|
|
5885
6079
|
};
|
|
5886
6080
|
//#endregion
|
|
6081
|
+
//#region src/utils/concurrency.ts
|
|
6082
|
+
/**
|
|
6083
|
+
* Map over items with a bounded number of operations in flight.
|
|
6084
|
+
*
|
|
6085
|
+
* `Promise.all(items.map(...))` starts every operation at once, which is fine
|
|
6086
|
+
* for a handful of paths and not fine for a directory tree of unknown size: a
|
|
6087
|
+
* few thousand concurrent `realpath` calls queue on the libuv thread pool and
|
|
6088
|
+
* hold their closures alive while they wait. Results keep the input order.
|
|
6089
|
+
*
|
|
6090
|
+
* `withSemaphore` in `src/lib/github-utils.ts` bounds concurrency too, but it
|
|
6091
|
+
* wraps one call at a time: the caller still writes `Promise.all(items.map(…))`
|
|
6092
|
+
* around it, so every item's promise chain is allocated up front. This walks a
|
|
6093
|
+
* shared cursor with `limit` workers instead, so a list of unknown size costs
|
|
6094
|
+
* `limit` pending operations rather than one per item.
|
|
6095
|
+
*/
|
|
6096
|
+
async function mapWithConcurrency({ items, limit, mapper }) {
|
|
6097
|
+
const results = Array.from({ length: items.length });
|
|
6098
|
+
let nextIndex = 0;
|
|
6099
|
+
const runWorker = async () => {
|
|
6100
|
+
while (nextIndex < items.length) {
|
|
6101
|
+
const index = nextIndex;
|
|
6102
|
+
nextIndex += 1;
|
|
6103
|
+
const item = items[index];
|
|
6104
|
+
if (item === void 0) continue;
|
|
6105
|
+
results[index] = await mapper(item);
|
|
6106
|
+
}
|
|
6107
|
+
};
|
|
6108
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
6109
|
+
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
|
6110
|
+
return results;
|
|
6111
|
+
}
|
|
6112
|
+
//#endregion
|
|
5887
6113
|
//#region src/types/ai-dir.ts
|
|
5888
|
-
|
|
6114
|
+
/**
|
|
6115
|
+
* Directories that hold credentials. Excluding these protects something, so
|
|
6116
|
+
* their exclusion is reported rather than silent.
|
|
6117
|
+
*/
|
|
6118
|
+
const NEVER_CARRIED_CREDENTIAL_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6119
|
+
".ssh",
|
|
6120
|
+
".aws",
|
|
6121
|
+
".gnupg"
|
|
6122
|
+
]);
|
|
6123
|
+
/**
|
|
6124
|
+
* Directories that are refused only when the path leaves the skill directory to
|
|
6125
|
+
* reach them. These are the per-application trees of a home directory, where
|
|
6126
|
+
* naming every credential file is a list always one release behind -- `gcloud`
|
|
6127
|
+
* alone writes `credentials.db` and `application_default_credentials.json`, and
|
|
6128
|
+
* `.config/anthropic/` holds an API key. A skill that ships a `.config/` of its
|
|
6129
|
+
* own still carries it: what is refused is a link that reaches the *user's*.
|
|
6130
|
+
*
|
|
6131
|
+
* A tool home is deliberately absent. `~/.claude/skills/` and `~/.codex/` hold
|
|
6132
|
+
* the global skills this feature exists to share, so refusing a link that
|
|
6133
|
+
* reaches one would refuse the ordinary case along with the bad one.
|
|
6134
|
+
*/
|
|
6135
|
+
const NEVER_CARRIED_ESCAPING_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6136
|
+
".config",
|
|
6137
|
+
".local",
|
|
6138
|
+
".azure",
|
|
6139
|
+
".m2",
|
|
6140
|
+
".terraform.d",
|
|
6141
|
+
".docker",
|
|
6142
|
+
".kube",
|
|
6143
|
+
"keychains"
|
|
6144
|
+
]);
|
|
6145
|
+
/**
|
|
6146
|
+
* Whether an escaping real path passes through a directory only reachable by
|
|
6147
|
+
* escaping.
|
|
6148
|
+
*
|
|
6149
|
+
* Only the segments *past* the skill directory count. A global skill lives at
|
|
6150
|
+
* `~/.config/agents/skills/<name>` for Amp, Devin and Muse alike, so judging
|
|
6151
|
+
* the whole real path would refuse a link that never left the skills tree it
|
|
6152
|
+
* was already in -- the shared-directory case, reported as a credential.
|
|
6153
|
+
*/
|
|
6154
|
+
function escapesIntoCredentialDir({ realDirPath, realFilePath }) {
|
|
6155
|
+
const normalize = (segment) => normalizePathSegment(segment.toLowerCase());
|
|
6156
|
+
const dirSegments = splitPathSegments(realDirPath).map(normalize);
|
|
6157
|
+
const fileSegments = splitPathSegments(realFilePath).map(normalize);
|
|
6158
|
+
let shared = 0;
|
|
6159
|
+
while (shared < dirSegments.length && shared < fileSegments.length && dirSegments[shared] === fileSegments[shared]) shared += 1;
|
|
6160
|
+
return (dirSegments.length - shared > 1 ? fileSegments : fileSegments.slice(shared)).some((segment) => NEVER_CARRIED_ESCAPING_DIR_NAMES.has(segment));
|
|
6161
|
+
}
|
|
6162
|
+
/**
|
|
6163
|
+
* Directories that are never part of a skill for the ordinary reasons: a
|
|
6164
|
+
* nested repository, or a build/cache tree. Leaving these out is what a user
|
|
6165
|
+
* expects, so it happens quietly.
|
|
6166
|
+
*/
|
|
6167
|
+
const NEVER_CARRIED_NOISE_DIR_NAMES = /* @__PURE__ */ new Set([
|
|
6168
|
+
".git",
|
|
6169
|
+
".hg",
|
|
6170
|
+
".svn",
|
|
6171
|
+
".cache",
|
|
6172
|
+
".venv",
|
|
6173
|
+
".tox",
|
|
6174
|
+
".mypy_cache",
|
|
6175
|
+
".pytest_cache",
|
|
6176
|
+
".ruff_cache",
|
|
6177
|
+
".gradle",
|
|
6178
|
+
".next",
|
|
6179
|
+
".nuxt",
|
|
6180
|
+
".turbo",
|
|
6181
|
+
".parcel-cache",
|
|
6182
|
+
".nyc_output",
|
|
6183
|
+
".terraform"
|
|
6184
|
+
]);
|
|
6185
|
+
/** Files that hold credentials. Compared lower-cased. */
|
|
6186
|
+
const NEVER_CARRIED_CREDENTIAL_FILE_NAMES = /* @__PURE__ */ new Set([
|
|
6187
|
+
".npmrc",
|
|
6188
|
+
".netrc",
|
|
6189
|
+
".git-credentials",
|
|
6190
|
+
".pgpass",
|
|
6191
|
+
".pypirc",
|
|
6192
|
+
".htpasswd",
|
|
6193
|
+
".dockercfg",
|
|
6194
|
+
".envrc"
|
|
6195
|
+
]);
|
|
6196
|
+
/** Files that are local noise. Compared lower-cased. */
|
|
6197
|
+
const NEVER_CARRIED_NOISE_FILE_NAMES = /* @__PURE__ */ new Set([".ds_store"]);
|
|
6198
|
+
/** Credential files whose parent directory is otherwise ordinary content. */
|
|
6199
|
+
const NEVER_CARRIED_PATH_SUFFIXES = [
|
|
6200
|
+
".docker/config.json",
|
|
6201
|
+
".kube/config",
|
|
6202
|
+
".config/gh/hosts.yml",
|
|
6203
|
+
".config/gcloud/credentials.db",
|
|
6204
|
+
".gem/credentials",
|
|
6205
|
+
"gcloud/application_default_credentials.json",
|
|
6206
|
+
".codex/auth.json",
|
|
6207
|
+
".gemini/oauth_creds.json"
|
|
6208
|
+
];
|
|
6209
|
+
/** Whether a path ends in one of the credential files named above. */
|
|
6210
|
+
function endsWithNeverCarriedSuffix(filePath) {
|
|
6211
|
+
const posixPath = toPosixPath(filePath).toLowerCase();
|
|
6212
|
+
return NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`));
|
|
6213
|
+
}
|
|
6214
|
+
/**
|
|
6215
|
+
* `.env.<suffix>` spellings that are templates rather than real values.
|
|
6216
|
+
* Everything else matching `.env*` is treated as holding secrets, because
|
|
6217
|
+
* `.env.production` is no less sensitive than `.env` itself.
|
|
6218
|
+
*/
|
|
6219
|
+
const ENV_TEMPLATE_SUFFIXES = /* @__PURE__ */ new Set([
|
|
6220
|
+
"example",
|
|
6221
|
+
"sample",
|
|
6222
|
+
"template",
|
|
6223
|
+
"dist",
|
|
6224
|
+
"defaults"
|
|
6225
|
+
]);
|
|
6226
|
+
/**
|
|
6227
|
+
* Kernel pseudo-filesystems, matched against the resolved real path. A link
|
|
6228
|
+
* into one of these does not reach a file at all: `/proc/self/environ` reads
|
|
6229
|
+
* back the entire environment of the running process, API keys included, and
|
|
6230
|
+
* `stat` reports it as an ordinary file. Nothing a skill carries lives here.
|
|
6231
|
+
*/
|
|
6232
|
+
const NEVER_CARRIED_REAL_PATH_ROOTS = [
|
|
6233
|
+
"/proc",
|
|
6234
|
+
"/sys",
|
|
6235
|
+
"/dev"
|
|
6236
|
+
];
|
|
6237
|
+
/**
|
|
6238
|
+
* Whether a directory of this name is pruned during the walk, so it is never
|
|
6239
|
+
* descended into at all. Derived from the directory names above so the pruning
|
|
6240
|
+
* and the path check below cannot drift apart.
|
|
6241
|
+
*/
|
|
6242
|
+
function isNeverCarriedDirName(dirName) {
|
|
6243
|
+
const normalized = normalizePathSegment(dirName.toLowerCase());
|
|
6244
|
+
return NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(normalized) || NEVER_CARRIED_NOISE_DIR_NAMES.has(normalized) || isCredentialFileName(normalized);
|
|
6245
|
+
}
|
|
6246
|
+
/**
|
|
6247
|
+
* Windows drops trailing dots and spaces from a name, so a file called `.env `
|
|
6248
|
+
* is written as `.env` once it lands in a tool directory there. Normalizing
|
|
6249
|
+
* before every comparison means the name is judged as what it becomes.
|
|
6250
|
+
*/
|
|
6251
|
+
function normalizePathSegment(segment) {
|
|
6252
|
+
const normalized = segment.replace(/[\s.]+$/, "");
|
|
6253
|
+
return normalized === "" ? segment : normalized;
|
|
6254
|
+
}
|
|
6255
|
+
/**
|
|
6256
|
+
* Why a path reaches something that is never skill content, or `undefined`
|
|
6257
|
+
* when it does not.
|
|
6258
|
+
*
|
|
6259
|
+
* Carrying hidden entries means a secret sitting in a skill directory would be
|
|
6260
|
+
* copied into every enabled tool root, multiplying the places it can be
|
|
6261
|
+
* committed from, and a `.venv` would be copied file by file into each of them.
|
|
6262
|
+
* None of these names is ever skill content, so excluding them costs nothing.
|
|
6263
|
+
*
|
|
6264
|
+
* The check is applied to the resolved real path as well as the literal one:
|
|
6265
|
+
* the names are what makes an entry dangerous, and a symbolic link named
|
|
6266
|
+
* `vendor` pointing at `~/.aws` is exactly as dangerous as a directory called
|
|
6267
|
+
* `.aws`. Comparison is lower-cased because macOS and Windows resolve `.SSH`
|
|
6268
|
+
* and `.ssh` to the same file.
|
|
6269
|
+
*/
|
|
6270
|
+
function classifyNeverCarried(relativePath) {
|
|
6271
|
+
const segments = toPosixPath(relativePath).toLowerCase().split("/").filter((segment) => segment !== "" && segment !== ".").map(normalizePathSegment);
|
|
6272
|
+
const fileName = segments.at(-1) ?? "";
|
|
6273
|
+
const posixPath = segments.join("/");
|
|
6274
|
+
if (segments.slice(0, -1).some((segment) => isCredentialFileName(segment))) return "credential";
|
|
6275
|
+
if (segments.some((segment) => NEVER_CARRIED_CREDENTIAL_DIR_NAMES.has(segment))) return "credential";
|
|
6276
|
+
if (segments.some((segment) => NEVER_CARRIED_NOISE_DIR_NAMES.has(segment))) return "noise";
|
|
6277
|
+
if (isCredentialFileName(fileName)) return "credential";
|
|
6278
|
+
if (NEVER_CARRIED_NOISE_FILE_NAMES.has(fileName)) return "noise";
|
|
6279
|
+
if (NEVER_CARRIED_PATH_SUFFIXES.some((suffix) => posixPath === suffix || posixPath.endsWith(`/${suffix}`))) return "credential";
|
|
6280
|
+
}
|
|
6281
|
+
/**
|
|
6282
|
+
* Whether a single path segment names a credential file, whether it is the file
|
|
6283
|
+
* itself or a directory somebody gave that name to.
|
|
6284
|
+
*/
|
|
6285
|
+
function isCredentialFileName(segment) {
|
|
6286
|
+
const name = normalizePathSegment(segment.toLowerCase());
|
|
6287
|
+
if (NEVER_CARRIED_CREDENTIAL_FILE_NAMES.has(name)) return true;
|
|
6288
|
+
for (const base of [".env", ".envrc"]) {
|
|
6289
|
+
if (name === base) return true;
|
|
6290
|
+
if (name.startsWith(`${base}.`)) {
|
|
6291
|
+
const lastPiece = name.split(".").at(-1) ?? "";
|
|
6292
|
+
return !ENV_TEMPLATE_SUFFIXES.has(lastPiece);
|
|
6293
|
+
}
|
|
6294
|
+
}
|
|
6295
|
+
return false;
|
|
6296
|
+
}
|
|
6297
|
+
/** Whether a resolved real path points into a kernel pseudo-filesystem. */
|
|
6298
|
+
function isSystemPseudoPath(absolutePath) {
|
|
6299
|
+
const posixPath = toPosixPath(absolutePath);
|
|
6300
|
+
return NEVER_CARRIED_REAL_PATH_ROOTS.some((root) => posixPath === root || posixPath.startsWith(`${root}/`));
|
|
6301
|
+
}
|
|
6302
|
+
/** How many links a chain may be followed before it is treated as a loop. */
|
|
6303
|
+
const MAX_LINK_CHAIN_HOPS = 40;
|
|
6304
|
+
/**
|
|
6305
|
+
* Whether reaching a path goes through a kernel pseudo-filesystem, even when it
|
|
6306
|
+
* does not end in one.
|
|
6307
|
+
*
|
|
6308
|
+
* Asking `realpath` alone is not enough, and the entries it is not enough for
|
|
6309
|
+
* are the dangerous ones: `/proc/<pid>/fd/N`, `exe`, `cwd` and `root` are magic
|
|
6310
|
+
* links, so resolving them lands *outside* `/proc`, on whatever file the
|
|
6311
|
+
* process happens to hold open — a private key another program is reading right
|
|
6312
|
+
* now would come back as an ordinary path and be carried. Following the chain a
|
|
6313
|
+
* hop at a time, and checking each hop, is what sees the `/proc` in the middle.
|
|
6314
|
+
*/
|
|
6315
|
+
async function resolvesThroughSystemPseudoPath(filePath) {
|
|
6316
|
+
let currentPath = (0, node_path.resolve)(filePath);
|
|
6317
|
+
let hops = 0;
|
|
6318
|
+
for (; hops < MAX_LINK_CHAIN_HOPS; hops++) {
|
|
6319
|
+
if (isSystemPseudoPath(currentPath)) return {
|
|
6320
|
+
throughPseudoPath: true,
|
|
6321
|
+
hops
|
|
6322
|
+
};
|
|
6323
|
+
try {
|
|
6324
|
+
if (isSystemPseudoPath(await (0, node_fs_promises.realpath)((0, node_path.dirname)(currentPath)))) return {
|
|
6325
|
+
throughPseudoPath: true,
|
|
6326
|
+
hops
|
|
6327
|
+
};
|
|
6328
|
+
} catch {}
|
|
6329
|
+
let linkStats;
|
|
6330
|
+
try {
|
|
6331
|
+
linkStats = await (0, node_fs_promises.lstat)(currentPath);
|
|
6332
|
+
} catch {
|
|
6333
|
+
return {
|
|
6334
|
+
throughPseudoPath: false,
|
|
6335
|
+
hops
|
|
6336
|
+
};
|
|
6337
|
+
}
|
|
6338
|
+
if (!linkStats.isSymbolicLink()) break;
|
|
6339
|
+
let target;
|
|
6340
|
+
try {
|
|
6341
|
+
target = await (0, node_fs_promises.readlink)(currentPath);
|
|
6342
|
+
} catch {
|
|
6343
|
+
return {
|
|
6344
|
+
throughPseudoPath: false,
|
|
6345
|
+
hops
|
|
6346
|
+
};
|
|
6347
|
+
}
|
|
6348
|
+
currentPath = (0, node_path.isAbsolute)(target) ? target : (0, node_path.resolve)((0, node_path.dirname)(currentPath), target);
|
|
6349
|
+
}
|
|
6350
|
+
return {
|
|
6351
|
+
throughPseudoPath: false,
|
|
6352
|
+
hops
|
|
6353
|
+
};
|
|
6354
|
+
}
|
|
6355
|
+
const MAX_CARRIED_FILES = 1e4;
|
|
6356
|
+
const MAX_CARRIED_DIRECTORIES = 1e4;
|
|
6357
|
+
/**
|
|
6358
|
+
* The bounds above limit what is *carried*; this one limits what is *looked at*.
|
|
6359
|
+
* A directory holding nothing but a few hundred thousand links to itself carries
|
|
6360
|
+
* no files and occupies no depth, and would still cost a `stat` apiece.
|
|
6361
|
+
*/
|
|
6362
|
+
const MAX_CARRIED_ENTRIES_EXAMINED = 2e5;
|
|
6363
|
+
const MAX_CARRIED_BYTES = 104857600;
|
|
6364
|
+
/** How many `realpath` calls the carried-file filter keeps in flight. */
|
|
6365
|
+
const CARRIED_REALPATH_CONCURRENCY = 32;
|
|
6366
|
+
/** Sort directory entries by name so a walk of the same tree is reproducible. */
|
|
6367
|
+
function compareByName(left, right) {
|
|
6368
|
+
if (left.name === right.name) return 0;
|
|
6369
|
+
return left.name < right.name ? -1 : 1;
|
|
6370
|
+
}
|
|
6371
|
+
/**
|
|
6372
|
+
* Order the routes that cross the same number of symbolic links: the one with
|
|
6373
|
+
* the fewest hidden segments first, so a named alias represents a shared tree
|
|
6374
|
+
* rather than a hidden one that a hidden-entry rule then refuses, taking the
|
|
6375
|
+
* named route's content with it.
|
|
6376
|
+
*/
|
|
6377
|
+
function comparePendingCarriedDirs(left, right) {
|
|
6378
|
+
if (left.hiddenSegments !== right.hiddenSegments) return left.hiddenSegments - right.hiddenSegments;
|
|
6379
|
+
if (left.depth !== right.depth) return left.depth - right.depth;
|
|
6380
|
+
if (left.dirPath === right.dirPath) return 0;
|
|
6381
|
+
return left.dirPath < right.dirPath ? -1 : 1;
|
|
6382
|
+
}
|
|
6383
|
+
/**
|
|
6384
|
+
* Collect the files under a carried directory, following symbolic links but
|
|
6385
|
+
* visiting each real directory exactly once, by its cheapest route.
|
|
6386
|
+
*
|
|
6387
|
+
* A glob walk cannot do this safely. Two links in one directory that both point
|
|
6388
|
+
* back at an ancestor double the paths walked per level, and the walker follows
|
|
6389
|
+
* them until the kernel's ELOOP limit (~40), so the path array alone exhausts
|
|
6390
|
+
* the heap long before anything reads a file — a depth bound only lowers the
|
|
6391
|
+
* exponent, while the base is whatever number of links the tree's author chose.
|
|
6392
|
+
* Remembering the real directories already visited removes the multiplication
|
|
6393
|
+
* itself: a cycle, and an alias for a directory already walked, both stop at the
|
|
6394
|
+
* entry that closes them.
|
|
6395
|
+
*
|
|
6396
|
+
* Which route represents a directory then matters, because the others are
|
|
6397
|
+
* dropped. The walk proceeds in rounds by the number of symbolic links crossed:
|
|
6398
|
+
* everything reachable without crossing one, then everything one link away, and
|
|
6399
|
+
* so on. A real location therefore always wins over an alias for it — at any
|
|
6400
|
+
* nesting depth, not just among siblings, which a depth-first walk could not
|
|
6401
|
+
* promise — and among aliases the named one wins over a hidden one.
|
|
6402
|
+
*
|
|
6403
|
+
* A broken link is skipped: it resolves to nothing to read.
|
|
6404
|
+
*/
|
|
6405
|
+
async function walkCarriedFiles(dirPath, { skipHiddenRoutes = false } = {}) {
|
|
6406
|
+
const filePaths = [];
|
|
6407
|
+
const visitedRealDirPaths = /* @__PURE__ */ new Set();
|
|
6408
|
+
const truncations = /* @__PURE__ */ new Set();
|
|
6409
|
+
const unreadablePaths = /* @__PURE__ */ new Set();
|
|
6410
|
+
const pseudoPaths = /* @__PURE__ */ new Set();
|
|
6411
|
+
const depthStoppedRealDirPaths = /* @__PURE__ */ new Set();
|
|
6412
|
+
let deferredLinkedDirs = [];
|
|
6413
|
+
/** Whether the walk has hit a bound that ends it rather than one branch of it. */
|
|
6414
|
+
let examinedEntries = 0;
|
|
6415
|
+
const isFull = () => truncations.has("count") || truncations.has("directories") || truncations.has("entries");
|
|
6416
|
+
const addFile = (filePath) => {
|
|
6417
|
+
if (filePaths.length >= 1e4) {
|
|
6418
|
+
truncations.add("count");
|
|
6419
|
+
return;
|
|
6420
|
+
}
|
|
6421
|
+
filePaths.push(filePath);
|
|
6422
|
+
};
|
|
6423
|
+
/** Carry what a symbolic link names, or hold its directory for the next round. */
|
|
6424
|
+
const routeLinkedEntry = async (child, entryName) => {
|
|
6425
|
+
let targetStats;
|
|
6426
|
+
try {
|
|
6427
|
+
targetStats = await (0, node_fs_promises.stat)(child.dirPath);
|
|
6428
|
+
} catch {
|
|
6429
|
+
return;
|
|
6430
|
+
}
|
|
6431
|
+
if (targetStats.isFile()) {
|
|
6432
|
+
addFile(child.dirPath);
|
|
6433
|
+
return;
|
|
6434
|
+
}
|
|
6435
|
+
if (!targetStats.isDirectory() || isNeverCarriedDirName(entryName)) return;
|
|
6436
|
+
const { throughPseudoPath, hops } = await resolvesThroughSystemPseudoPath(child.dirPath);
|
|
6437
|
+
examinedEntries += hops;
|
|
6438
|
+
if (throughPseudoPath) {
|
|
6439
|
+
pseudoPaths.add(child.dirPath);
|
|
6440
|
+
return;
|
|
6441
|
+
}
|
|
6442
|
+
deferredLinkedDirs.push(child);
|
|
6443
|
+
};
|
|
6444
|
+
/** Walk one directory and everything below it that no symbolic link leads to. */
|
|
6445
|
+
const walkWithoutCrossingLinks = async (pending) => {
|
|
6446
|
+
if (isFull()) return;
|
|
6447
|
+
let realCurrentPath;
|
|
6448
|
+
try {
|
|
6449
|
+
realCurrentPath = await (0, node_fs_promises.realpath)(pending.dirPath);
|
|
6450
|
+
} catch {
|
|
6451
|
+
unreadablePaths.add(pending.dirPath);
|
|
6452
|
+
return;
|
|
6453
|
+
}
|
|
6454
|
+
if (visitedRealDirPaths.has(realCurrentPath)) return;
|
|
6455
|
+
if (isSystemPseudoPath(realCurrentPath)) {
|
|
6456
|
+
pseudoPaths.add(pending.dirPath);
|
|
6457
|
+
return;
|
|
6458
|
+
}
|
|
6459
|
+
if (pending.depth > 12) {
|
|
6460
|
+
depthStoppedRealDirPaths.add(realCurrentPath);
|
|
6461
|
+
return;
|
|
6462
|
+
}
|
|
6463
|
+
if (visitedRealDirPaths.size >= 1e4) {
|
|
6464
|
+
truncations.add("directories");
|
|
6465
|
+
return;
|
|
6466
|
+
}
|
|
6467
|
+
visitedRealDirPaths.add(realCurrentPath);
|
|
6468
|
+
let entries;
|
|
6469
|
+
try {
|
|
6470
|
+
entries = await (0, node_fs_promises.readdir)(pending.dirPath, { withFileTypes: true });
|
|
6471
|
+
} catch {
|
|
6472
|
+
unreadablePaths.add(pending.dirPath);
|
|
6473
|
+
return;
|
|
6474
|
+
}
|
|
6475
|
+
const realSubDirs = [];
|
|
6476
|
+
for (const entry of entries.toSorted(compareByName)) {
|
|
6477
|
+
if (isFull()) return;
|
|
6478
|
+
examinedEntries += 1;
|
|
6479
|
+
if (examinedEntries > 2e5) {
|
|
6480
|
+
truncations.add("entries");
|
|
6481
|
+
return;
|
|
6482
|
+
}
|
|
6483
|
+
if (skipHiddenRoutes && isHiddenPathSegment(entry.name)) continue;
|
|
6484
|
+
const entryPath = (0, node_path.join)(pending.dirPath, entry.name);
|
|
6485
|
+
const child = {
|
|
6486
|
+
dirPath: entryPath,
|
|
6487
|
+
depth: pending.depth + 1,
|
|
6488
|
+
hiddenSegments: pending.hiddenSegments + (isHiddenPathSegment(entry.name) ? 1 : 0)
|
|
6489
|
+
};
|
|
6490
|
+
if (entry.isFile()) {
|
|
6491
|
+
addFile(entryPath);
|
|
6492
|
+
continue;
|
|
6493
|
+
}
|
|
6494
|
+
if (entry.isDirectory()) {
|
|
6495
|
+
if (!isNeverCarriedDirName(entry.name)) realSubDirs.push(child);
|
|
6496
|
+
continue;
|
|
6497
|
+
}
|
|
6498
|
+
if (!entry.isSymbolicLink()) continue;
|
|
6499
|
+
await routeLinkedEntry(child, entry.name);
|
|
6500
|
+
}
|
|
6501
|
+
for (const realSubDir of realSubDirs) {
|
|
6502
|
+
if (isFull()) return;
|
|
6503
|
+
await walkWithoutCrossingLinks(realSubDir);
|
|
6504
|
+
}
|
|
6505
|
+
};
|
|
6506
|
+
let round = [{
|
|
6507
|
+
dirPath,
|
|
6508
|
+
depth: 0,
|
|
6509
|
+
hiddenSegments: 0
|
|
6510
|
+
}];
|
|
6511
|
+
while (round.length > 0 && !isFull()) {
|
|
6512
|
+
deferredLinkedDirs = [];
|
|
6513
|
+
for (const pending of round.toSorted(comparePendingCarriedDirs)) {
|
|
6514
|
+
if (isFull()) break;
|
|
6515
|
+
await walkWithoutCrossingLinks(pending);
|
|
6516
|
+
}
|
|
6517
|
+
round = deferredLinkedDirs;
|
|
6518
|
+
}
|
|
6519
|
+
for (const realDirPath of depthStoppedRealDirPaths) if (!visitedRealDirPaths.has(realDirPath)) {
|
|
6520
|
+
truncations.add("depth");
|
|
6521
|
+
break;
|
|
6522
|
+
}
|
|
6523
|
+
return {
|
|
6524
|
+
filePaths: filePaths.toSorted(),
|
|
6525
|
+
truncations,
|
|
6526
|
+
unreadablePaths: [...unreadablePaths],
|
|
6527
|
+
pseudoPaths: [...pseudoPaths]
|
|
6528
|
+
};
|
|
6529
|
+
}
|
|
6530
|
+
/** Render a set of refused or noteworthy paths for one warning line. */
|
|
6531
|
+
function formatReportedPaths(paths) {
|
|
6532
|
+
const sorted = [...paths].toSorted();
|
|
6533
|
+
const named = sorted.slice(0, 10).map((filePath) => stripControlCharacters(toPosixPath(filePath))).join(", ");
|
|
6534
|
+
const remaining = sorted.length - 10;
|
|
6535
|
+
return {
|
|
6536
|
+
count: sorted.length,
|
|
6537
|
+
list: `${named}${remaining > 0 ? `, and ${remaining} more` : ""}`
|
|
6538
|
+
};
|
|
6539
|
+
}
|
|
6540
|
+
/** "entry" or "entries", so the warnings below read as sentences. */
|
|
6541
|
+
function entryWord(count) {
|
|
6542
|
+
return count === 1 ? "entry" : "entries";
|
|
6543
|
+
}
|
|
6544
|
+
/** "resolves" or "resolve", to agree with the entry count it follows. */
|
|
6545
|
+
function resolveWord(count) {
|
|
6546
|
+
return count === 1 ? "resolves" : "resolve";
|
|
6547
|
+
}
|
|
6548
|
+
var AiDir = class AiDir {
|
|
5889
6549
|
/**
|
|
5890
6550
|
* @example "."
|
|
5891
6551
|
*/
|
|
@@ -5935,8 +6595,7 @@ var AiDir = class {
|
|
|
5935
6595
|
const fullPath = node_path.default.join(this.outputRoot, this.relativeDirPath, this.dirName);
|
|
5936
6596
|
const resolvedFull = (0, node_path.resolve)(fullPath);
|
|
5937
6597
|
const resolvedBase = (0, node_path.resolve)(this.outputRoot);
|
|
5938
|
-
|
|
5939
|
-
if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
|
|
6598
|
+
if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}", dirName="${this.dirName}"`);
|
|
5940
6599
|
return fullPath;
|
|
5941
6600
|
}
|
|
5942
6601
|
getMainFile() {
|
|
@@ -5962,9 +6621,227 @@ var AiDir = class {
|
|
|
5962
6621
|
};
|
|
5963
6622
|
}
|
|
5964
6623
|
/**
|
|
6624
|
+
* A nested repository inside a carried directory is the one exclusion worth
|
|
6625
|
+
* reporting: unlike `.DS_Store`, it is there on purpose, and the tree it
|
|
6626
|
+
* points at is simply not reproduced on generate. Only the top level is
|
|
6627
|
+
* checked, which is where a submodule or a stray `git init` puts it, so the
|
|
6628
|
+
* check costs one stat per directory rather than a second walk. `fileExists`
|
|
6629
|
+
* is a bare `stat`, so it answers for a submodule pointer file and for a real
|
|
6630
|
+
* `.git` directory alike.
|
|
6631
|
+
*/
|
|
6632
|
+
static async warnOnNestedGitDirectory(dirPath) {
|
|
6633
|
+
const gitEntryPath = (0, node_path.join)(dirPath, ".git");
|
|
6634
|
+
if (await fileExists(gitEntryPath)) warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(gitEntryPath))} with its directory: a nested repository is excluded, so the files it tracks are copied but its history is not.`);
|
|
6635
|
+
}
|
|
6636
|
+
/** Whether any segment of a relative path is dot-prefixed. */
|
|
6637
|
+
static hasHiddenSegment(relativePath) {
|
|
6638
|
+
return splitPathSegments(relativePath).some(isHiddenPathSegment);
|
|
6639
|
+
}
|
|
6640
|
+
/**
|
|
6641
|
+
* Whether the entry a path ends at is itself hidden, its ancestors aside.
|
|
6642
|
+
* A shared skill tree usually lives under a dot-directory, so an ancestor
|
|
6643
|
+
* says nothing about the file; its own name is what was chosen for it.
|
|
6644
|
+
*/
|
|
6645
|
+
static hasHiddenName(relativePath) {
|
|
6646
|
+
return isHiddenPathSegment(splitPathSegments(relativePath).at(-1) ?? "");
|
|
6647
|
+
}
|
|
6648
|
+
/**
|
|
6649
|
+
* Drop the entries a skill directory must not carry.
|
|
6650
|
+
*
|
|
6651
|
+
* Three rules. `classifyNeverCarried` comes first, evaluated against the
|
|
6652
|
+
* resolved real path as well as the literal one: names that are never skill
|
|
6653
|
+
* content stay out however they are reached, so renaming a symbolic link
|
|
6654
|
+
* does not turn `~/.aws` into content a skill carries. Next, a real path
|
|
6655
|
+
* inside a kernel pseudo-filesystem is refused outright — that is process
|
|
6656
|
+
* state, not a file.
|
|
6657
|
+
*
|
|
6658
|
+
* The third concerns hidden entries a symbolic link reaches outside the
|
|
6659
|
+
* directory. Following symlinks out of a source tree is deliberate and
|
|
6660
|
+
* documented — it is how a shared skill is referenced from several projects
|
|
6661
|
+
* without being duplicated (issue #1707), and the trust boundary is the tree
|
|
6662
|
+
* you point Rulesync at. Carrying hidden entries changes what that costs,
|
|
6663
|
+
* though: one ordinary-looking link to a home directory would pull in every
|
|
6664
|
+
* dotfile under it, and those are the entries with credential value. What
|
|
6665
|
+
* decides is the name in the skill directory, not what the link resolves
|
|
6666
|
+
* through: a named file keeps its documented behavior even when the target
|
|
6667
|
+
* sits under a dot-directory such as `~/.dotfiles`, because somebody chose
|
|
6668
|
+
* that name. What the link resolves *to* still counts at the end of the path,
|
|
6669
|
+
* though — `notes.md` pointing at `~/.claude/.credentials.json` reaches a
|
|
6670
|
+
* file nobody named for a skill — so a hidden final segment is refused on
|
|
6671
|
+
* either side. Reaching outside is reported either way, since content from
|
|
6672
|
+
* outside the tree is about to be copied into every enabled tool root.
|
|
6673
|
+
*
|
|
6674
|
+
* A path that cannot be resolved is kept: `realpath` fails on a broken link
|
|
6675
|
+
* or a race, and neither is a reason to silently drop a file.
|
|
6676
|
+
*/
|
|
6677
|
+
/** Report what a carried directory left out, once both walks have had their say. */
|
|
6678
|
+
static warnOnRefusedCarriedFiles(dirPath, carried) {
|
|
6679
|
+
const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
|
|
6680
|
+
if (carried.refusedCredentials.size > 0) {
|
|
6681
|
+
const { count, list } = formatReportedPaths(carried.refusedCredentials);
|
|
6682
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} named as a credential store: ${list}. A skill must not ship secrets; read them from the environment instead.`);
|
|
6683
|
+
}
|
|
6684
|
+
if (carried.refusedPseudoPaths.size > 0) {
|
|
6685
|
+
const { count, list } = formatReportedPaths(carried.refusedPseudoPaths);
|
|
6686
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a system pseudo-filesystem: ${list}. Those read back process state, not skill content.`);
|
|
6687
|
+
}
|
|
6688
|
+
if (carried.refusedNoiseAliases.size > 0) {
|
|
6689
|
+
const { count, list } = formatReportedPaths(carried.refusedNoiseAliases);
|
|
6690
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that ${resolveWord(count)} into a directory a skill never carries: ${list}. A nested repository, or a build or cache tree, is the usual target.`);
|
|
6691
|
+
}
|
|
6692
|
+
if (carried.refusedEscapedHidden.size > 0) {
|
|
6693
|
+
const { count, list } = formatReportedPaths(carried.refusedEscapedHidden);
|
|
6694
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} hidden ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Copy them into the directory if the skill really needs them.`);
|
|
6695
|
+
}
|
|
6696
|
+
if (carried.carriedFromOutside.size > 0) {
|
|
6697
|
+
const { count, list } = formatReportedPaths([...carried.carriedFromOutside].map((filePath) => {
|
|
6698
|
+
const realFilePath = carried.realFilePathByPath.get(filePath);
|
|
6699
|
+
return realFilePath === void 0 ? filePath : `${filePath} -> ${realFilePath}`;
|
|
6700
|
+
}));
|
|
6701
|
+
warnOnceWithFallback(void 0, `Carrying ${count} ${entryWord(count)} that ${resolveWord(count)} outside ${reportedDirPath}: ${list}. Their content is copied into every generated tool directory.`);
|
|
6702
|
+
}
|
|
6703
|
+
}
|
|
6704
|
+
static async filterCarriedFiles(dirPath, filePaths) {
|
|
6705
|
+
let realDirPath;
|
|
6706
|
+
try {
|
|
6707
|
+
realDirPath = await (0, node_fs_promises.realpath)(dirPath);
|
|
6708
|
+
} catch {
|
|
6709
|
+
realDirPath = (0, node_path.resolve)(dirPath);
|
|
6710
|
+
}
|
|
6711
|
+
const refusedCredentials = /* @__PURE__ */ new Set();
|
|
6712
|
+
const refusedPseudoPaths = /* @__PURE__ */ new Set();
|
|
6713
|
+
const refusedEscapedHidden = /* @__PURE__ */ new Set();
|
|
6714
|
+
const refusedNoiseAliases = /* @__PURE__ */ new Set();
|
|
6715
|
+
const carriedFromOutside = /* @__PURE__ */ new Set();
|
|
6716
|
+
const realFilePathByPath = /* @__PURE__ */ new Map();
|
|
6717
|
+
const verdicts = await mapWithConcurrency({
|
|
6718
|
+
items: filePaths,
|
|
6719
|
+
limit: CARRIED_REALPATH_CONCURRENCY,
|
|
6720
|
+
mapper: async (filePath) => {
|
|
6721
|
+
const literalPath = (0, node_path.relative)(dirPath, filePath);
|
|
6722
|
+
const literalReason = classifyNeverCarried(literalPath);
|
|
6723
|
+
if (literalReason !== void 0) {
|
|
6724
|
+
if (literalReason === "credential") refusedCredentials.add(filePath);
|
|
6725
|
+
return false;
|
|
6726
|
+
}
|
|
6727
|
+
let realFilePath;
|
|
6728
|
+
try {
|
|
6729
|
+
realFilePath = await (0, node_fs_promises.realpath)(filePath);
|
|
6730
|
+
} catch {
|
|
6731
|
+
return true;
|
|
6732
|
+
}
|
|
6733
|
+
realFilePathByPath.set(filePath, realFilePath);
|
|
6734
|
+
if (isSystemPseudoPath(realFilePath)) {
|
|
6735
|
+
refusedPseudoPaths.add(filePath);
|
|
6736
|
+
return false;
|
|
6737
|
+
}
|
|
6738
|
+
const realPath = (0, node_path.relative)(realDirPath, realFilePath);
|
|
6739
|
+
const realReason = classifyNeverCarried(realPath);
|
|
6740
|
+
if (realReason !== void 0) {
|
|
6741
|
+
if (realReason === "credential") refusedCredentials.add(filePath);
|
|
6742
|
+
else refusedNoiseAliases.add(filePath);
|
|
6743
|
+
return false;
|
|
6744
|
+
}
|
|
6745
|
+
if (!pathEscapesRoot(realPath)) return true;
|
|
6746
|
+
if ((await resolvesThroughSystemPseudoPath(filePath)).throughPseudoPath) {
|
|
6747
|
+
refusedPseudoPaths.add(filePath);
|
|
6748
|
+
return false;
|
|
6749
|
+
}
|
|
6750
|
+
if (endsWithNeverCarriedSuffix(realFilePath)) {
|
|
6751
|
+
refusedCredentials.add(filePath);
|
|
6752
|
+
return false;
|
|
6753
|
+
}
|
|
6754
|
+
if (escapesIntoCredentialDir({
|
|
6755
|
+
realDirPath,
|
|
6756
|
+
realFilePath
|
|
6757
|
+
})) {
|
|
6758
|
+
refusedCredentials.add(filePath);
|
|
6759
|
+
return false;
|
|
6760
|
+
}
|
|
6761
|
+
if (AiDir.hasHiddenSegment(literalPath) || AiDir.hasHiddenName(realPath)) {
|
|
6762
|
+
refusedEscapedHidden.add(filePath);
|
|
6763
|
+
return false;
|
|
6764
|
+
}
|
|
6765
|
+
carriedFromOutside.add(filePath);
|
|
6766
|
+
return true;
|
|
6767
|
+
}
|
|
6768
|
+
});
|
|
6769
|
+
return {
|
|
6770
|
+
filePaths: filePaths.filter((_filePath, index) => verdicts[index]),
|
|
6771
|
+
realFilePathByPath,
|
|
6772
|
+
refusedCredentials,
|
|
6773
|
+
refusedPseudoPaths,
|
|
6774
|
+
refusedEscapedHidden,
|
|
6775
|
+
refusedNoiseAliases,
|
|
6776
|
+
carriedFromOutside
|
|
6777
|
+
};
|
|
6778
|
+
}
|
|
6779
|
+
/**
|
|
6780
|
+
* Report what the walk had to leave behind, so a skill that silently lost
|
|
6781
|
+
* files says so rather than generating a directory that is quietly short.
|
|
6782
|
+
*/
|
|
6783
|
+
static warnOnCarriedWalkLimits({ reportedDirPath, truncations, unreadablePaths }) {
|
|
6784
|
+
if (truncations.has("depth")) warnOnceWithFallback(void 0, `Not carrying the entries more than 12 directories below ${reportedDirPath}: a skill directory is walked to that depth only. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6785
|
+
if (truncations.has("count")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} beyond the first ${MAX_CARRIED_FILES}: a directory may carry at most that many files. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6786
|
+
if (truncations.has("directories")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} below the first ${MAX_CARRIED_DIRECTORIES} directories: a directory may carry files from at most that many directories. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6787
|
+
if (truncations.has("entries")) warnOnceWithFallback(void 0, `Not carrying the entries under ${reportedDirPath} that come after the first ${MAX_CARRIED_ENTRIES_EXAMINED} looked at: a directory is walked over at most that many entries. A tree of symbolic links that lead back into it is the usual cause.`);
|
|
6788
|
+
if (unreadablePaths.length > 0) {
|
|
6789
|
+
const { count, list } = formatReportedPaths(unreadablePaths);
|
|
6790
|
+
warnOnceWithFallback(void 0, `Not carrying ${count} ${entryWord(count)} that could not be read: ${list}. A permission the current user does not hold is the usual cause.`);
|
|
6791
|
+
}
|
|
6792
|
+
}
|
|
6793
|
+
/**
|
|
6794
|
+
* Walk the directory once more with the hidden routes pruned, and take the
|
|
6795
|
+
* files the first walk had to leave behind because the route that reached
|
|
6796
|
+
* them ran through a hidden directory. This can only add files, and only ones
|
|
6797
|
+
* a fully named route reaches.
|
|
6798
|
+
*/
|
|
6799
|
+
static async recoverCarriedFilesFromNamedRoutes({ dirPath, carried, carriedPaths, carriedRealPaths, walk }) {
|
|
6800
|
+
const named = await walkCarriedFiles(dirPath, { skipHiddenRoutes: true });
|
|
6801
|
+
const recovered = await AiDir.filterCarriedFiles(dirPath, named.filePaths);
|
|
6802
|
+
for (const truncation of named.truncations) walk.truncations.add(truncation);
|
|
6803
|
+
for (const unreadablePath of named.unreadablePaths) if (!walk.unreadablePaths.includes(unreadablePath)) walk.unreadablePaths.push(unreadablePath);
|
|
6804
|
+
for (const pseudoPath of named.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
|
|
6805
|
+
for (const refused of recovered.refusedCredentials) carried.refusedCredentials.add(refused);
|
|
6806
|
+
for (const refused of recovered.refusedPseudoPaths) carried.refusedPseudoPaths.add(refused);
|
|
6807
|
+
for (const refused of recovered.refusedNoiseAliases) carried.refusedNoiseAliases.add(refused);
|
|
6808
|
+
for (const refused of recovered.refusedEscapedHidden) carried.refusedEscapedHidden.add(refused);
|
|
6809
|
+
for (const filePath of recovered.filePaths) {
|
|
6810
|
+
const realFilePath = recovered.realFilePathByPath.get(filePath) ?? filePath;
|
|
6811
|
+
if (carriedRealPaths.has(realFilePath)) continue;
|
|
6812
|
+
if (carriedPaths.length >= 1e4) {
|
|
6813
|
+
walk.truncations.add("count");
|
|
6814
|
+
break;
|
|
6815
|
+
}
|
|
6816
|
+
carriedRealPaths.add(realFilePath);
|
|
6817
|
+
carriedPaths.push(filePath);
|
|
6818
|
+
carried.realFilePathByPath.set(filePath, realFilePath);
|
|
6819
|
+
if (recovered.carriedFromOutside.has(filePath)) carried.carriedFromOutside.add(filePath);
|
|
6820
|
+
}
|
|
6821
|
+
for (const filePath of carried.refusedEscapedHidden) {
|
|
6822
|
+
const realFilePath = carried.realFilePathByPath.get(filePath) ?? filePath;
|
|
6823
|
+
if (carriedRealPaths.has(realFilePath)) carried.refusedEscapedHidden.delete(filePath);
|
|
6824
|
+
}
|
|
6825
|
+
}
|
|
6826
|
+
/**
|
|
5965
6827
|
* Recursively collects all files from a directory, excluding the specified main file.
|
|
5966
6828
|
* This is a common utility for loading additional files alongside the main file.
|
|
5967
6829
|
*
|
|
6830
|
+
* Hidden entries are included. The directories this walks are skill trees,
|
|
6831
|
+
* whose specification says a skill directory "may contain any files and
|
|
6832
|
+
* directories beyond the required `SKILL.md`" — a `.env.example` beside the
|
|
6833
|
+
* scripts that read it is content, not noise, and dropping it silently on
|
|
6834
|
+
* both import and generate loses part of the skill. What is left out is the
|
|
6835
|
+
* set of entries that are never skill content — a nested repository's `.git`,
|
|
6836
|
+
* the macOS Finder's `.DS_Store`, credential stores, build and cache trees —
|
|
6837
|
+
* as decided by `classifyNeverCarried`. Whole directories from that set are
|
|
6838
|
+
* pruned during the walk too, so it never descends into them at all.
|
|
6839
|
+
*
|
|
6840
|
+
* The walk is bounded and cycle-aware — see `walkCarriedFiles` — because the
|
|
6841
|
+
* tree may contain symbolic links that somebody else chose.
|
|
6842
|
+
*
|
|
6843
|
+
* @see https://agentskills.io/specification
|
|
6844
|
+
*
|
|
5968
6845
|
* @param outputRoot - The base directory path
|
|
5969
6846
|
* @param relativeDirPath - The relative path to the directory containing the skill
|
|
5970
6847
|
* @param dirName - The name of the directory
|
|
@@ -5973,14 +6850,57 @@ var AiDir = class {
|
|
|
5973
6850
|
*/
|
|
5974
6851
|
static async collectOtherFiles(outputRoot, relativeDirPath, dirName, excludeFileName) {
|
|
5975
6852
|
const dirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
|
|
5976
|
-
const
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
6853
|
+
const walk = await walkCarriedFiles(dirPath);
|
|
6854
|
+
const reportedDirPath = stripControlCharacters(toPosixPath(dirPath));
|
|
6855
|
+
await AiDir.warnOnNestedGitDirectory(dirPath);
|
|
6856
|
+
const carried = await AiDir.filterCarriedFiles(dirPath, walk.filePaths);
|
|
6857
|
+
const carriedPaths = [...carried.filePaths];
|
|
6858
|
+
const carriedRealPaths = new Set(carriedPaths.map((filePath) => carried.realFilePathByPath.get(filePath) ?? filePath));
|
|
6859
|
+
if (carried.refusedEscapedHidden.size > 0) await AiDir.recoverCarriedFilesFromNamedRoutes({
|
|
6860
|
+
dirPath,
|
|
6861
|
+
carried,
|
|
6862
|
+
carriedPaths,
|
|
6863
|
+
carriedRealPaths,
|
|
6864
|
+
walk
|
|
6865
|
+
});
|
|
6866
|
+
for (const pseudoPath of walk.pseudoPaths) carried.refusedPseudoPaths.add(pseudoPath);
|
|
6867
|
+
AiDir.warnOnCarriedWalkLimits({
|
|
6868
|
+
reportedDirPath,
|
|
6869
|
+
truncations: walk.truncations,
|
|
6870
|
+
unreadablePaths: walk.unreadablePaths
|
|
6871
|
+
});
|
|
6872
|
+
AiDir.warnOnRefusedCarriedFiles(dirPath, carried);
|
|
6873
|
+
const filteredPaths = carriedPaths.toSorted().filter((filePath) => (0, node_path.basename)(filePath) !== excludeFileName);
|
|
6874
|
+
const files = [];
|
|
6875
|
+
let carriedBytes = 0;
|
|
6876
|
+
for (const [index, filePath] of filteredPaths.entries()) {
|
|
6877
|
+
const classifiedPath = carried.realFilePathByPath.get(filePath) ?? filePath;
|
|
6878
|
+
let fileHandle;
|
|
6879
|
+
try {
|
|
6880
|
+
fileHandle = await (0, node_fs_promises.open)(classifiedPath, node_fs.constants.O_RDONLY | (node_fs.constants.O_NOFOLLOW ?? 0));
|
|
6881
|
+
} catch (error) {
|
|
6882
|
+
warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
|
|
6883
|
+
continue;
|
|
6884
|
+
}
|
|
6885
|
+
try {
|
|
6886
|
+
const fileSize = (await fileHandle.stat()).size;
|
|
6887
|
+
if (carriedBytes + fileSize > 104857600) {
|
|
6888
|
+
warnOnceWithFallback(void 0, `Not carrying ${filteredPaths.length - index} of the ${filteredPaths.length} entries under ${reportedDirPath}: a directory may carry at most ${MAX_CARRIED_BYTES / 1024 / 1024}MB. A symbolic link that reaches a large tree is the usual cause.`);
|
|
6889
|
+
break;
|
|
6890
|
+
}
|
|
6891
|
+
const fileBuffer = await fileHandle.readFile();
|
|
6892
|
+
carriedBytes += fileBuffer.byteLength;
|
|
6893
|
+
files.push({
|
|
6894
|
+
relativeFilePathToDirPath: (0, node_path.relative)(dirPath, filePath),
|
|
6895
|
+
fileBuffer
|
|
6896
|
+
});
|
|
6897
|
+
} catch (error) {
|
|
6898
|
+
warnOnceWithFallback(void 0, `Not carrying ${stripControlCharacters(toPosixPath(filePath))}: ${stripControlCharacters(formatError(error))}.`);
|
|
6899
|
+
} finally {
|
|
6900
|
+
await fileHandle.close();
|
|
6901
|
+
}
|
|
6902
|
+
}
|
|
6903
|
+
return files;
|
|
5984
6904
|
}
|
|
5985
6905
|
};
|
|
5986
6906
|
const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
|
|
@@ -6207,7 +7127,7 @@ var RulesyncSkill = class RulesyncSkill extends AiDir {
|
|
|
6207
7127
|
const skillDirPath = (0, node_path.join)(outputRoot, relativeDirPath, dirName);
|
|
6208
7128
|
const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
|
|
6209
7129
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
6210
|
-
const { frontmatter, body: content, hasFrontmatter } =
|
|
7130
|
+
const { frontmatter, body: content, hasFrontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
6211
7131
|
if (!hasFrontmatter) throw new Error(`Missing frontmatter in ${skillFilePath}. Rulesync files must begin with a YAML frontmatter block delimited by '---'.`);
|
|
6212
7132
|
const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter);
|
|
6213
7133
|
if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
|
|
@@ -6644,27 +7564,6 @@ function companionFileContentsEquivalent({ filePath, expected, existing, compose
|
|
|
6644
7564
|
return tryFileContentsEquivalent(filePath, expectedText, existingText) ?? false;
|
|
6645
7565
|
}
|
|
6646
7566
|
//#endregion
|
|
6647
|
-
//#region src/utils/control-characters.ts
|
|
6648
|
-
/**
|
|
6649
|
-
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
6650
|
-
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
6651
|
-
* line and paragraph separators, and the plain LRM/RLM marks. A name or value
|
|
6652
|
-
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
6653
|
-
* settings file must never reach the terminal with these intact: they let the
|
|
6654
|
-
* text forge log lines, reorder what is printed around them, or inject escape
|
|
6655
|
-
* sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
|
|
6656
|
-
* neutral characters beside them, so they go too — a diagnostic line is not the
|
|
6657
|
-
* place to preserve the typography of a right-to-left name.
|
|
6658
|
-
*/
|
|
6659
|
-
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
6660
|
-
/**
|
|
6661
|
-
* Removes every control character from `text` so it is safe to splice into a
|
|
6662
|
-
* log line or other terminal output.
|
|
6663
|
-
*/
|
|
6664
|
-
function stripControlCharacters(text) {
|
|
6665
|
-
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
6666
|
-
}
|
|
6667
|
-
//#endregion
|
|
6668
7567
|
//#region src/types/feature-processor.ts
|
|
6669
7568
|
var FeatureProcessor = class {
|
|
6670
7569
|
outputRoot;
|
|
@@ -11591,6 +12490,26 @@ function toolSkillSearchRoots(paths) {
|
|
|
11591
12490
|
* https://agentskills.io/client-implementation/adding-skills-support
|
|
11592
12491
|
*/
|
|
11593
12492
|
const AGENT_SKILLS_INTEROP_ROOTS = /* @__PURE__ */ new Set([toPosixPath(AGENTSMD_SKILLS_DIR_PATH), toPosixPath(AMP_SKILLS_GLOBAL_DIR)]);
|
|
12493
|
+
/**
|
|
12494
|
+
* The one spec violation both sides of the conversion report. Generation warns
|
|
12495
|
+
* about it before writing the file; import warns about it because a conformant
|
|
12496
|
+
* client would skip the skill entirely, and a user who never sees that has no
|
|
12497
|
+
* reason to fix it.
|
|
12498
|
+
*/
|
|
12499
|
+
const EMPTY_SKILL_DESCRIPTION_VIOLATION = "`description` is required and must not be empty; conformant clients skip a skill without one";
|
|
12500
|
+
/**
|
|
12501
|
+
* Report a skill read from disk whose `description` is empty. This lives on the
|
|
12502
|
+
* read rather than on any one tool class because several targets share one
|
|
12503
|
+
* `.agents/skills` tree: which of them reports the skill must not depend on
|
|
12504
|
+
* which target the user happened to enable. Every loader calls it — the two
|
|
12505
|
+
* directory loaders (`loadSkillDirContent`, `SimulatedSkill.fromDirDefault`)
|
|
12506
|
+
* and the flat-file one — so the form a tool stores its skills in does not
|
|
12507
|
+
* decide whether the problem is reported either.
|
|
12508
|
+
*/
|
|
12509
|
+
function warnOnEmptyLoadedDescription({ skillFilePath, description }) {
|
|
12510
|
+
if (typeof description !== "string" || description.length > 0) return;
|
|
12511
|
+
warnOnceWithFallback(void 0, `${stripControlCharacters(toPosixPath(skillFilePath))}: ${EMPTY_SKILL_DESCRIPTION_VIOLATION}. Rulesync imports it anyway so the content is not lost; fill it in before relying on this skill.`);
|
|
12512
|
+
}
|
|
11594
12513
|
function isAgentSkillsInteropRoot(relativeDirPath) {
|
|
11595
12514
|
return AGENT_SKILLS_INTEROP_ROOTS.has(toPosixPath(relativeDirPath));
|
|
11596
12515
|
}
|
|
@@ -11703,7 +12622,11 @@ var ToolSkill = class extends AiDir {
|
|
|
11703
12622
|
const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
|
|
11704
12623
|
const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
|
|
11705
12624
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
11706
|
-
const { frontmatter, body: content } =
|
|
12625
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
12626
|
+
warnOnEmptyLoadedDescription({
|
|
12627
|
+
skillFilePath,
|
|
12628
|
+
description: frontmatter.description
|
|
12629
|
+
});
|
|
11707
12630
|
const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
|
|
11708
12631
|
return {
|
|
11709
12632
|
outputRoot,
|
|
@@ -11828,6 +12751,10 @@ function toSpecConformantAgentSkillFields(section, { coerceMetadata = true } = {
|
|
|
11828
12751
|
...allowedTools !== void 0 && allowedTools.length > 0 && { "allowed-tools": allowedTools }
|
|
11829
12752
|
};
|
|
11830
12753
|
}
|
|
12754
|
+
/** The `SKILL.md` a diagnostic should point at, in the scope it is written to. */
|
|
12755
|
+
function agentSkillFilePath({ outputRoot, relativeDirPath, dirName }) {
|
|
12756
|
+
return (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
|
|
12757
|
+
}
|
|
11831
12758
|
/**
|
|
11832
12759
|
* Collect the normative violations the Agent Skills spec defines for a skill
|
|
11833
12760
|
* about to be written. These are reported as warnings rather than errors:
|
|
@@ -11854,7 +12781,7 @@ function collectAgentSkillViolations({ frontmatter, dirName, sourceAllowedTools
|
|
|
11854
12781
|
if (!NAME_PATTERN.test(name)) violations.push(`\`name\` "${name}" must contain only lowercase letters, digits and single hyphens, with no leading, trailing or consecutive hyphens`);
|
|
11855
12782
|
if (name !== dirName) violations.push(`\`name\` "${name}" must match its parent directory name "${dirName}"; conformant clients require them to be equal`);
|
|
11856
12783
|
}
|
|
11857
|
-
if (description.length === 0) violations.push(
|
|
12784
|
+
if (description.length === 0) violations.push(EMPTY_SKILL_DESCRIPTION_VIOLATION);
|
|
11858
12785
|
else if (description.length > DESCRIPTION_MAX_LENGTH) violations.push(`\`description\` is ${description.length} characters; the Agent Skills spec allows at most ${DESCRIPTION_MAX_LENGTH}`);
|
|
11859
12786
|
const { compatibility } = frontmatter;
|
|
11860
12787
|
if (typeof compatibility !== "string" && compatibility !== void 0) violations.push("`compatibility` must be a string; the Agent Skills spec does not allow a mapping here");
|
|
@@ -11973,12 +12900,16 @@ var AgentsSkillsSkill = class AgentsSkillsSkill extends ToolSkill {
|
|
|
11973
12900
|
* directory rather than a same-named project path.
|
|
11974
12901
|
*/
|
|
11975
12902
|
static reportSpecViolations({ outputRoot, relativeDirPath, dirName, frontmatter, sourceAllowedTools, logger }) {
|
|
11976
|
-
const skillPath = (
|
|
12903
|
+
const skillPath = agentSkillFilePath({
|
|
12904
|
+
outputRoot,
|
|
12905
|
+
relativeDirPath,
|
|
12906
|
+
dirName
|
|
12907
|
+
});
|
|
11977
12908
|
for (const violation of collectAgentSkillViolations({
|
|
11978
12909
|
frontmatter,
|
|
11979
12910
|
dirName,
|
|
11980
12911
|
sourceAllowedTools
|
|
11981
|
-
})) warnWithFallback(logger, `${skillPath}: ${violation}`);
|
|
12912
|
+
})) warnWithFallback(logger, `${stripControlCharacters(toPosixPath(skillPath))}: ${violation}`);
|
|
11982
12913
|
}
|
|
11983
12914
|
static isTargetedByRulesyncSkill(rulesyncSkill) {
|
|
11984
12915
|
const targets = rulesyncSkill.getFrontmatter().targets;
|
|
@@ -38575,9 +39506,13 @@ var SimulatedSkill = class extends ToolSkill {
|
|
|
38575
39506
|
const skillDirPath = (0, node_path.join)(outputRoot, actualRelativeDirPath, dirName);
|
|
38576
39507
|
const skillFilePath = (0, node_path.join)(skillDirPath, SKILL_FILE_NAME);
|
|
38577
39508
|
if (!await fileExists(skillFilePath)) throw new Error(`${SKILL_FILE_NAME} not found in ${skillDirPath}`);
|
|
38578
|
-
const { frontmatter, body: content } =
|
|
39509
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath);
|
|
38579
39510
|
const result = SimulatedSkillFrontmatterSchema.safeParse(frontmatter);
|
|
38580
39511
|
if (!result.success) throw new Error(`Invalid frontmatter in ${skillFilePath}: ${formatError(result.error)}`);
|
|
39512
|
+
warnOnEmptyLoadedDescription({
|
|
39513
|
+
skillFilePath,
|
|
39514
|
+
description: result.data.description
|
|
39515
|
+
});
|
|
38581
39516
|
const otherFiles = await this.collectOtherFiles(outputRoot, actualRelativeDirPath, dirName, SKILL_FILE_NAME);
|
|
38582
39517
|
return {
|
|
38583
39518
|
outputRoot,
|
|
@@ -42105,7 +43040,7 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
|
|
|
42105
43040
|
}
|
|
42106
43041
|
static async fromFlatFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
|
|
42107
43042
|
const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
|
|
42108
|
-
const { frontmatter, body } =
|
|
43043
|
+
const { frontmatter, body } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath);
|
|
42109
43044
|
const result = KimiCodeFlatSkillFrontmatterSchema.safeParse(frontmatter);
|
|
42110
43045
|
if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
|
|
42111
43046
|
const fileName = (0, node_path.basename)(relativeFilePath, (0, node_path.extname)(relativeFilePath));
|
|
@@ -42115,6 +43050,10 @@ var KimiCodeSkill = class KimiCodeSkill extends ToolSkill {
|
|
|
42115
43050
|
name: result.data.name ?? fileName,
|
|
42116
43051
|
description: result.data.description ?? firstBodyLine?.slice(0, 240) ?? "No description provided."
|
|
42117
43052
|
};
|
|
43053
|
+
warnOnEmptyLoadedDescription({
|
|
43054
|
+
skillFilePath: filePath,
|
|
43055
|
+
description: normalizedFrontmatter.description
|
|
43056
|
+
});
|
|
42118
43057
|
return new KimiCodeSkill({
|
|
42119
43058
|
outputRoot,
|
|
42120
43059
|
relativeDirPath,
|
|
@@ -43118,7 +44057,7 @@ var ReasonixSkill = class ReasonixSkill extends ToolSkill {
|
|
|
43118
44057
|
static async isDirOwned({ outputRoot, relativeDirPath, dirName }) {
|
|
43119
44058
|
const skillFilePath = (0, node_path.join)(outputRoot, relativeDirPath, dirName, SKILL_FILE_NAME);
|
|
43120
44059
|
try {
|
|
43121
|
-
const { frontmatter } =
|
|
44060
|
+
const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(skillFilePath), skillFilePath, { quiet: true });
|
|
43122
44061
|
return frontmatter["runAs"] !== REASONIX_SUBAGENT_RUN_AS;
|
|
43123
44062
|
} catch {
|
|
43124
44063
|
return true;
|
|
@@ -43487,8 +44426,7 @@ var TaktSkill = class TaktSkill extends ToolSkill {
|
|
|
43487
44426
|
const fullPath = (0, node_path.join)(this.outputRoot, this.relativeDirPath);
|
|
43488
44427
|
const resolvedFull = (0, node_path.resolve)(fullPath);
|
|
43489
44428
|
const resolvedBase = (0, node_path.resolve)(this.outputRoot);
|
|
43490
|
-
|
|
43491
|
-
if (rel.startsWith("..") || node_path.default.isAbsolute(rel)) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
|
|
44429
|
+
if (pathEscapesRoot((0, node_path.relative)(resolvedBase, resolvedFull))) throw new Error(`Path traversal detected: Final path escapes outputRoot. outputRoot="${this.outputRoot}", relativeDirPath="${this.relativeDirPath}"`);
|
|
43492
44430
|
return fullPath;
|
|
43493
44431
|
}
|
|
43494
44432
|
getRelativePathFromCwd() {
|
|
@@ -48455,7 +49393,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
|
|
|
48455
49393
|
const paths = this.getSettablePaths({ global });
|
|
48456
49394
|
const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
48457
49395
|
const fileContent = await readFileContent(filePath);
|
|
48458
|
-
const { frontmatter, body: content } =
|
|
49396
|
+
const { frontmatter, body: content } = parseFrontmatterWithYamlRepair(fileContent, filePath);
|
|
48459
49397
|
const result = ReasonixSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
48460
49398
|
if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
|
|
48461
49399
|
return new ReasonixSubagent({
|
|
@@ -48484,7 +49422,7 @@ var ReasonixSubagent = class ReasonixSubagent extends ToolSubagent {
|
|
|
48484
49422
|
static async isFileOwned({ outputRoot, relativeDirPath, relativeFilePath }) {
|
|
48485
49423
|
const filePath = (0, node_path.join)(outputRoot, relativeDirPath, relativeFilePath);
|
|
48486
49424
|
try {
|
|
48487
|
-
const { frontmatter } =
|
|
49425
|
+
const { frontmatter } = parseFrontmatterWithYamlRepair(await readFileContent(filePath), filePath, { quiet: true });
|
|
48488
49426
|
return frontmatter["runAs"] === REASONIX_SUBAGENT_RUN_AS;
|
|
48489
49427
|
} catch {
|
|
48490
49428
|
return false;
|
|
@@ -55878,6 +56816,7 @@ function resolveToolOutputRoot({ outputRoot, toolTarget, global }) {
|
|
|
55878
56816
|
* `.rulesync/` files to disk. Rulesync file instances live in memory only.
|
|
55879
56817
|
*/
|
|
55880
56818
|
async function convertFromTool(params) {
|
|
56819
|
+
resetWarnedOnceMessages();
|
|
55881
56820
|
const packagingTarget = [params.fromTool, ...params.toTools].find(isPackagingToolTarget);
|
|
55882
56821
|
if (packagingTarget) throw new Error(`Plugin packaging target '${packagingTarget}' is not supported by convert. Use import and generate with explicit plugin directories.`);
|
|
55883
56822
|
const ctx = params;
|
|
@@ -56807,6 +57746,7 @@ async function collectHermesProjectPluginNames({ config, resultsById }) {
|
|
|
56807
57746
|
async function generate(params) {
|
|
56808
57747
|
const { config, logger } = params;
|
|
56809
57748
|
resetRootShadowingWarnings({ logger });
|
|
57749
|
+
resetWarnedOnceMessages();
|
|
56810
57750
|
for (const toolTarget of config.getTargets()) for (const outputRoot of config.getOutputRoots(toolTarget)) await assertPluginRootSafe({
|
|
56811
57751
|
toolTarget,
|
|
56812
57752
|
outputRoot
|
|
@@ -57366,6 +58306,7 @@ function getToolOutputRoot({ config, tool }) {
|
|
|
57366
58306
|
*/
|
|
57367
58307
|
async function importFromTool(params) {
|
|
57368
58308
|
const { config, tool, logger } = params;
|
|
58309
|
+
resetWarnedOnceMessages();
|
|
57369
58310
|
await assertPluginRootSafe({
|
|
57370
58311
|
toolTarget: tool,
|
|
57371
58312
|
outputRoot: getToolOutputRoot({
|
|
@@ -58317,6 +59258,12 @@ Object.defineProperty(exports, "parseJsonc", {
|
|
|
58317
59258
|
return parseJsonc$8;
|
|
58318
59259
|
}
|
|
58319
59260
|
});
|
|
59261
|
+
Object.defineProperty(exports, "pathEscapesRoot", {
|
|
59262
|
+
enumerable: true,
|
|
59263
|
+
get: function() {
|
|
59264
|
+
return pathEscapesRoot;
|
|
59265
|
+
}
|
|
59266
|
+
});
|
|
58320
59267
|
Object.defineProperty(exports, "readFileContent", {
|
|
58321
59268
|
enumerable: true,
|
|
58322
59269
|
get: function() {
|
|
@@ -58359,6 +59306,12 @@ Object.defineProperty(exports, "removeTempDirectory", {
|
|
|
58359
59306
|
return removeTempDirectory;
|
|
58360
59307
|
}
|
|
58361
59308
|
});
|
|
59309
|
+
Object.defineProperty(exports, "resetWarnedOnceMessages", {
|
|
59310
|
+
enumerable: true,
|
|
59311
|
+
get: function() {
|
|
59312
|
+
return resetWarnedOnceMessages;
|
|
59313
|
+
}
|
|
59314
|
+
});
|
|
58362
59315
|
Object.defineProperty(exports, "resolveEffectiveInputRoots", {
|
|
58363
59316
|
enumerable: true,
|
|
58364
59317
|
get: function() {
|