rulesync 16.18.0 → 16.20.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/README.md +1 -1
- package/dist/cli/index.cjs +605 -211
- package/dist/cli/index.js +604 -210
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-BKnAc4rT.js → import-1-jjDDAm.js} +2940 -687
- package/dist/import-1-jjDDAm.js.map +1 -0
- package/dist/{import-BQpUs1JO.cjs → import-BlhbArzI.cjs} +3010 -709
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/import-BKnAc4rT.js.map +0 -1
|
@@ -2,19 +2,225 @@ import { ZodError } from "zod";
|
|
|
2
2
|
import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
|
|
3
3
|
import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
|
|
5
|
-
import { parse, parseTree, printParseErrorCode } from "jsonc-parser";
|
|
5
|
+
import { applyEdits, findNodeAtLocation, getNodeValue, modify, parse, parseTree, printParseErrorCode } from "jsonc-parser";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import { intersection, kebabCase, uniq } from "es-toolkit";
|
|
8
8
|
import { globbySync, isGitIgnoredSync } from "globby";
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
import { format, isDeepStrictEqual } from "node:util";
|
|
9
11
|
import matter from "gray-matter";
|
|
10
12
|
import { YAMLException, dump, load } from "js-yaml";
|
|
11
13
|
import { omit } from "es-toolkit/object";
|
|
12
14
|
import { constants } from "node:fs";
|
|
13
15
|
import { createHash } from "node:crypto";
|
|
14
|
-
import { isDeepStrictEqual } from "node:util";
|
|
15
16
|
import * as smolToml from "smol-toml";
|
|
16
17
|
import { parse as parse$1, stringify } from "smol-toml";
|
|
17
18
|
import { encode } from "@toon-format/toon";
|
|
19
|
+
//#region src/utils/control-characters.ts
|
|
20
|
+
/**
|
|
21
|
+
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
22
|
+
* introducer U+009B), the bidirectional overrides and isolates, the Unicode
|
|
23
|
+
* line and paragraph separators, and the plain LRM/RLM/ALM marks. A name or
|
|
24
|
+
* value copied out of an untrusted config file, a fetched repository, or a
|
|
25
|
+
* tool's own settings file must never reach the terminal with these intact:
|
|
26
|
+
* they let the text forge log lines, reorder what is printed around them, or
|
|
27
|
+
* inject escape sequences. LRM, RLM and the Arabic letter mark open no bidi
|
|
28
|
+
* scope of their own, but they still reorder the neutral characters beside
|
|
29
|
+
* them, so they go too — a diagnostic line is not the place to preserve the
|
|
30
|
+
* typography of a right-to-left name.
|
|
31
|
+
*/
|
|
32
|
+
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
33
|
+
/**
|
|
34
|
+
* Removes every control character from `text` so it is safe to splice into a
|
|
35
|
+
* log line or other terminal output.
|
|
36
|
+
*/
|
|
37
|
+
function stripControlCharacters(text) {
|
|
38
|
+
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Removes every control character from `text` except the line feed, so a
|
|
42
|
+
* message written to be read over several lines still is.
|
|
43
|
+
*
|
|
44
|
+
* `stripControlCharacters` takes newlines out because a diagnostic is one line
|
|
45
|
+
* and a name that carries one can forge a second. An error message is not: a
|
|
46
|
+
* lock file names the process holding it over several lines, and the MCP
|
|
47
|
+
* `generate` failure lists one unreadable source per line. The carriage return
|
|
48
|
+
* still goes, since on its own it paints over the line already written — which
|
|
49
|
+
* is why this splits on the line feed and strips each line rather than carrying
|
|
50
|
+
* a second character class that has to be kept in step with the first.
|
|
51
|
+
*/
|
|
52
|
+
function stripControlCharactersKeepingLineFeeds(text) {
|
|
53
|
+
return text.split("\n").map(stripControlCharacters).join("\n");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Matches the characters that take no width of their own.
|
|
57
|
+
*
|
|
58
|
+
* `Default_Ignorable_Code_Point` is the Unicode property for exactly this — the
|
|
59
|
+
* zero-width joiners, the soft hyphen, the variation selectors, the Hangul
|
|
60
|
+
* fillers, the tag characters — and the format category `Cf` covers the few
|
|
61
|
+
* that sit outside it, such as the interlinear annotation marks. Both are used
|
|
62
|
+
* rather than a list of ranges because a list has to be revisited every time
|
|
63
|
+
* Unicode adds one, and the one that is missed is the one an attacker reaches
|
|
64
|
+
* for: U+3164 HANGUL FILLER, the classic of the homograph domain names, is a
|
|
65
|
+
* letter of the Hangul script and would pass every check aimed at Latin.
|
|
66
|
+
*
|
|
67
|
+
* The braille blank is named on its own. It carries no dots, so it draws as
|
|
68
|
+
* nothing while belonging to neither set.
|
|
69
|
+
*
|
|
70
|
+
* None of these is a control character, so none is caught by
|
|
71
|
+
* `stripControlCharacters` — and none of them shows. A name that differs from
|
|
72
|
+
* another only by one of these is drawn exactly like it, which is why a name
|
|
73
|
+
* that carries one is not a name a user can be asked to judge.
|
|
74
|
+
*/
|
|
75
|
+
const INVISIBLE_CHARACTERS_PATTERN = /[\p{Default_Ignorable_Code_Point}\p{Cf}\u2800]/gu;
|
|
76
|
+
/**
|
|
77
|
+
* Removes every zero-width and otherwise invisible character from `text`.
|
|
78
|
+
*
|
|
79
|
+
* Kept apart from `stripControlCharacters` because the two answer different
|
|
80
|
+
* questions. That one asks what is safe to print; this one asks whether a name
|
|
81
|
+
* shows everything it contains, which is what a prompt needs before it offers
|
|
82
|
+
* the name as something to pick.
|
|
83
|
+
*/
|
|
84
|
+
function stripInvisibleCharacters(text) {
|
|
85
|
+
return text.replace(INVISIBLE_CHARACTERS_PATTERN, "");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Removes every character that does not show: the control characters and the
|
|
89
|
+
* invisible ones alike.
|
|
90
|
+
*
|
|
91
|
+
* This is the form a name has to survive unchanged before it can be offered as
|
|
92
|
+
* something to choose. Callers that need the whole answer should reach for this
|
|
93
|
+
* rather than composing the two strippers themselves, so that the order — and
|
|
94
|
+
* the definition of "hidden" — lives in one place.
|
|
95
|
+
*/
|
|
96
|
+
function stripHiddenCharacters(text) {
|
|
97
|
+
return stripInvisibleCharacters(stripControlCharacters(text));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The invisible characters that do a job in some scripts rather than only
|
|
101
|
+
* hiding: the two zero-width joiners and the variation selectors.
|
|
102
|
+
*
|
|
103
|
+
* A Persian or Indic name spells a word with ZWNJ (U+200C) in it, and an emoji
|
|
104
|
+
* name is a chain of ZWJ (U+200D) and variation selectors. Refusing those
|
|
105
|
+
* outright would refuse names that are written the only way their script writes
|
|
106
|
+
* them, so they are judged by where they sit rather than by what they are.
|
|
107
|
+
*/
|
|
108
|
+
const ZERO_WIDTH_JOINER_PATTERN = /\u200c|\u200d/u;
|
|
109
|
+
const VARIATION_SELECTOR_PATTERN = /[\u{fe00}-\u{fe0f}]|[\u{e0100}-\u{e01ef}]/u;
|
|
110
|
+
/** The twelve characters a keycap can be built on, per ED-14 of UTS #51. */
|
|
111
|
+
const KEYCAP_BASE_PATTERN = /[0-9#*]/u;
|
|
112
|
+
/** The selector that asks for the emoji form of the character before it. */
|
|
113
|
+
const EMOJI_PRESENTATION_SELECTOR$1 = "️";
|
|
114
|
+
/** U+20E3, which draws the box the base and the selector go inside. */
|
|
115
|
+
const COMBINING_ENCLOSING_KEYCAP = "⃣";
|
|
116
|
+
/**
|
|
117
|
+
* Whether the three characters are an emoji keycap, spelled as UTS #51 spells
|
|
118
|
+
* it: `Emoji_Keycap_Sequence := [0-9#*] FE0F 20E3`.
|
|
119
|
+
*
|
|
120
|
+
* The twelve bases are the only ASCII characters Unicode gives the `Emoji`
|
|
121
|
+
* property to, and not one of them is a pictograph — `1` is a digit and `#` is
|
|
122
|
+
* punctuation — so the joining list below cannot see the sequence, and a
|
|
123
|
+
* directory named `1\u{fe0f}\u{20e3}` would be turned away as a digit padded
|
|
124
|
+
* with a variation selector. It is not padding: the selector is what asks for
|
|
125
|
+
* the emoji form of the digit, and the enclosing keycap behind it is what draws
|
|
126
|
+
* the box around it. Both neighbors are required, which is what keeps the
|
|
127
|
+
* exception to the sequence rather than handing it to every digit in every
|
|
128
|
+
* name: `pdf1` with a variation selector and no keycap after it is padding
|
|
129
|
+
* still, and is refused still.
|
|
130
|
+
*
|
|
131
|
+
* @see https://www.unicode.org/reports/tr51/#def_emoji_keycap_sequence
|
|
132
|
+
*/
|
|
133
|
+
function isKeycapSequence(params) {
|
|
134
|
+
const { base, selector, following } = params;
|
|
135
|
+
return base !== void 0 && KEYCAP_BASE_PATTERN.test(base) && selector === EMOJI_PRESENTATION_SELECTOR$1 && following === COMBINING_ENCLOSING_KEYCAP;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The characters a joiner has work to do beside: the scripts whose words are
|
|
139
|
+
* written with one, and the pictographs an emoji sequence is built from.
|
|
140
|
+
*
|
|
141
|
+
* A list of what may join rather than of what may not, because the two are not
|
|
142
|
+
* the same size. `pdf` with a ZWNJ between the d and the f is `pdf` on screen
|
|
143
|
+
* and a different directory underneath, and the same is true of `設定` with a
|
|
144
|
+
* ZWJ after the first character: neither Latin nor Han joins anything that way,
|
|
145
|
+
* and nor does Cyrillic, Greek, Hangul or kana. Naming the scripts that do —
|
|
146
|
+
* the Arabic family, the Indic ones, Mongolian, and the pictographs — is what
|
|
147
|
+
* keeps the exception to the names that need it, instead of handing it to every
|
|
148
|
+
* writing system that is merely not Latin.
|
|
149
|
+
*/
|
|
150
|
+
const JOINING_CONTEXT_PATTERN = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Mongolian}\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}\p{Script=Sinhala}\p{Script=Myanmar}\p{Script=Khmer}\p{Script=Tibetan}\p{Script=Adlam}\p{Extended_Pictographic}]/u;
|
|
151
|
+
/** Non-global copies, because `test` on a global regex carries state between calls. */
|
|
152
|
+
const CONTROL_CHARACTER_PATTERN = new RegExp(CONTROL_CHARACTERS_PATTERN.source, "u");
|
|
153
|
+
const INVISIBLE_CHARACTER_PATTERN = new RegExp(INVISIBLE_CHARACTERS_PATTERN.source, "u");
|
|
154
|
+
/**
|
|
155
|
+
* Whether `text` carries a hidden character that is there to hide something.
|
|
156
|
+
*
|
|
157
|
+
* This is the question a name has to answer before it can be offered as
|
|
158
|
+
* something to pick, and it is a narrower one than `stripHiddenCharacters`
|
|
159
|
+
* answers. Every control character counts, and so does every invisible
|
|
160
|
+
* character — except a joiner or variation selector standing where its own
|
|
161
|
+
* script would put one, which is to say beside a character from a script that
|
|
162
|
+
* is written with joiners, or beside a pictograph.
|
|
163
|
+
*
|
|
164
|
+
* A joiner is held to both of its neighbors, since it exists to bind two
|
|
165
|
+
* characters and a name that ends in one is binding nothing: `設定` with a ZWJ
|
|
166
|
+
* after it is `設定` on screen and a second directory underneath. A variation
|
|
167
|
+
* selector is held only to the character before it, which is the one it selects
|
|
168
|
+
* a form for, and which is why an emoji name may end in one.
|
|
169
|
+
*
|
|
170
|
+
* The keycap sequence is the one emoji the joining list cannot recognize on its
|
|
171
|
+
* own, since what it is built on is a digit or an ASCII sign rather than a
|
|
172
|
+
* pictograph, so it is matched whole instead.
|
|
173
|
+
*
|
|
174
|
+
* Han is not on the joining list, so an ideographic variation sequence — a Han
|
|
175
|
+
* character followed by one of U+E0100 onward — is refused along with the rest.
|
|
176
|
+
* That is the intended trade: no skill directory here is named with one, and
|
|
177
|
+
* the pair is drawn as the bare character on every terminal that has no font
|
|
178
|
+
* for the variant, which is the shape the check exists to refuse.
|
|
179
|
+
*
|
|
180
|
+
* The test is a heuristic in place of the CONTEXTJ joining rules of IDNA,
|
|
181
|
+
* which decide the same question by the joining type of the characters around
|
|
182
|
+
* the joiner. It errs toward accepting a name written in a script that needs
|
|
183
|
+
* these characters, and toward rejecting one that mixes them into Latin, where
|
|
184
|
+
* they can only be padding.
|
|
185
|
+
*/
|
|
186
|
+
function hasDeceptiveHiddenCharacters(text) {
|
|
187
|
+
const characters = [...text];
|
|
188
|
+
const joinsCharacter = (neighbor) => neighbor !== void 0 && JOINING_CONTEXT_PATTERN.test(neighbor);
|
|
189
|
+
return characters.some((character, index) => {
|
|
190
|
+
if (CONTROL_CHARACTER_PATTERN.test(character)) return true;
|
|
191
|
+
if (!INVISIBLE_CHARACTER_PATTERN.test(character)) return false;
|
|
192
|
+
if (VARIATION_SELECTOR_PATTERN.test(character)) {
|
|
193
|
+
if (isKeycapSequence({
|
|
194
|
+
base: characters[index - 1],
|
|
195
|
+
selector: character,
|
|
196
|
+
following: characters[index + 1]
|
|
197
|
+
})) return false;
|
|
198
|
+
return !joinsCharacter(characters[index - 1]);
|
|
199
|
+
}
|
|
200
|
+
if (!ZERO_WIDTH_JOINER_PATTERN.test(character)) return true;
|
|
201
|
+
return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/utils/truncate.ts
|
|
206
|
+
/**
|
|
207
|
+
* Cut `text` to `maxLength` without splitting a character in half.
|
|
208
|
+
*
|
|
209
|
+
* `String.prototype.slice` counts UTF-16 units, so cutting inside a surrogate
|
|
210
|
+
* pair leaves a lone surrogate that the next encoder turns into a replacement
|
|
211
|
+
* character, and cutting inside a `\uXXXX` escape that `JSON.stringify` wrote
|
|
212
|
+
* leaves a dangling backslash. Diagnostics quote files rulesync did not write,
|
|
213
|
+
* so both are reachable from a repository's own content rather than only from
|
|
214
|
+
* a hand-crafted string.
|
|
215
|
+
*/
|
|
216
|
+
function truncateText({ text, maxLength, suffix }) {
|
|
217
|
+
if (text.length <= maxLength) return text;
|
|
218
|
+
const characters = Array.from(text.slice(0, maxLength * 2 + 2));
|
|
219
|
+
if (characters.length <= maxLength) return text;
|
|
220
|
+
const cut = characters.slice(0, maxLength).join("");
|
|
221
|
+
return `${(/\\*$/.exec(cut)?.[0].length ?? 0) % 2 === 0 ? cut : cut.slice(0, -1)}${suffix}`;
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
18
224
|
//#region src/utils/error.ts
|
|
19
225
|
/**
|
|
20
226
|
* Convert various error types to a readable error message
|
|
@@ -39,10 +245,52 @@ import { encode } from "@toon-format/toon";
|
|
|
39
245
|
function isZodErrorLike(error) {
|
|
40
246
|
return error !== null && typeof error === "object" && "issues" in error && Array.isArray(error.issues) && error.issues.every((issue) => issue !== null && typeof issue === "object" && "path" in issue && Array.isArray(issue.path) && "message" in issue && typeof issue.message === "string");
|
|
41
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* How much of a Zod error the formatted message spells out.
|
|
250
|
+
*
|
|
251
|
+
* One `safeParse` of a large invalid document produces an issue per offending
|
|
252
|
+
* node, each carrying the path and message, so the raw expansion is bounded by
|
|
253
|
+
* the size of the input rather than by anything rulesync decides — and the
|
|
254
|
+
* formatted message no longer stops at a terminal: it becomes the `message` of
|
|
255
|
+
* a `--json` failure document and of an MCP result. The first few issues are
|
|
256
|
+
* what tells the reader which file to open; the rest is the same information
|
|
257
|
+
* again, at whatever length the input chose.
|
|
258
|
+
*/
|
|
259
|
+
const MAX_ZOD_ISSUES_LENGTH = 2e3;
|
|
260
|
+
/**
|
|
261
|
+
* How much of any other error the formatted message spells out.
|
|
262
|
+
*
|
|
263
|
+
* Larger than the Zod bound because the text is the error's own sentence rather
|
|
264
|
+
* than a re-listing of one issue per offending node, and because the MCP
|
|
265
|
+
* `generate` failure hands this one a line per unreadable source. Bounded all
|
|
266
|
+
* the same: a parser quotes the offending line verbatim, and a minified file is
|
|
267
|
+
* one line the length of the file.
|
|
268
|
+
*/
|
|
269
|
+
const MAX_ERROR_MESSAGE_LENGTH = 8e3;
|
|
270
|
+
/**
|
|
271
|
+
* Strip and bound an error message that is about to be read by something other
|
|
272
|
+
* than a terminal — a `--json` failure document, an MCP result.
|
|
273
|
+
*
|
|
274
|
+
* The line feed stays: several messages are deliberately written over more than
|
|
275
|
+
* one line, and running them together would cost more than the newline can do
|
|
276
|
+
* here. Everything that reorders the text around it, or that an escape sequence
|
|
277
|
+
* is written with, goes.
|
|
278
|
+
*/
|
|
279
|
+
function boundErrorMessage(text) {
|
|
280
|
+
return truncateText({
|
|
281
|
+
text: stripControlCharactersKeepingLineFeeds(text),
|
|
282
|
+
maxLength: MAX_ERROR_MESSAGE_LENGTH,
|
|
283
|
+
suffix: "…(truncated)"
|
|
284
|
+
});
|
|
285
|
+
}
|
|
42
286
|
function formatError(error) {
|
|
43
|
-
if (error instanceof ZodError || isZodErrorLike(error)) return `Zod raw error: ${
|
|
44
|
-
|
|
45
|
-
|
|
287
|
+
if (error instanceof ZodError || isZodErrorLike(error)) return `Zod raw error: ${truncateText({
|
|
288
|
+
text: stripControlCharacters(JSON.stringify(error.issues)),
|
|
289
|
+
maxLength: MAX_ZOD_ISSUES_LENGTH,
|
|
290
|
+
suffix: "…(truncated)"
|
|
291
|
+
})}`;
|
|
292
|
+
if (error instanceof Error) return boundErrorMessage(`${error.name}: ${error.message}`);
|
|
293
|
+
return boundErrorMessage(String(error));
|
|
46
294
|
}
|
|
47
295
|
//#endregion
|
|
48
296
|
//#region src/types/features.ts
|
|
@@ -336,7 +584,8 @@ const subagentsProcessorToolTargetTuple = [
|
|
|
336
584
|
"zoocode",
|
|
337
585
|
"rovodev",
|
|
338
586
|
"takt",
|
|
339
|
-
"vibe"
|
|
587
|
+
"vibe",
|
|
588
|
+
"zcode"
|
|
340
589
|
];
|
|
341
590
|
const skillsProcessorToolTargetTuple = [
|
|
342
591
|
"agentsmd",
|
|
@@ -516,138 +765,6 @@ async function mapWithConcurrency({ items, limit, mapper }) {
|
|
|
516
765
|
return results;
|
|
517
766
|
}
|
|
518
767
|
//#endregion
|
|
519
|
-
//#region src/utils/control-characters.ts
|
|
520
|
-
/**
|
|
521
|
-
* Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
|
|
522
|
-
* introducer U+009B), the bidirectional overrides and isolates, and the Unicode
|
|
523
|
-
* line and paragraph separators, and the plain LRM/RLM/ALM marks. A name or value
|
|
524
|
-
* copied out of an untrusted config file, a fetched repository, or a tool's own
|
|
525
|
-
* settings file must never reach the terminal with these intact: they let the
|
|
526
|
-
* text forge log lines, reorder what is printed around them, or inject escape
|
|
527
|
-
* sequences. LRM, RLM and the Arabic letter mark open no bidi scope of their
|
|
528
|
-
* own, but they still reorder the neutral characters beside them, so they go too — a diagnostic line is not the
|
|
529
|
-
* place to preserve the typography of a right-to-left name.
|
|
530
|
-
*/
|
|
531
|
-
const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
|
|
532
|
-
/**
|
|
533
|
-
* Removes every control character from `text` so it is safe to splice into a
|
|
534
|
-
* log line or other terminal output.
|
|
535
|
-
*/
|
|
536
|
-
function stripControlCharacters(text) {
|
|
537
|
-
return text.replace(CONTROL_CHARACTERS_PATTERN, "");
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Matches the characters that take no width of their own.
|
|
541
|
-
*
|
|
542
|
-
* `Default_Ignorable_Code_Point` is the Unicode property for exactly this — the
|
|
543
|
-
* zero-width joiners, the soft hyphen, the variation selectors, the Hangul
|
|
544
|
-
* fillers, the tag characters — and the format category `Cf` covers the few
|
|
545
|
-
* that sit outside it, such as the interlinear annotation marks. Both are used
|
|
546
|
-
* rather than a list of ranges because a list has to be revisited every time
|
|
547
|
-
* Unicode adds one, and the one that is missed is the one an attacker reaches
|
|
548
|
-
* for: U+3164 HANGUL FILLER, the classic of the homograph domain names, is a
|
|
549
|
-
* letter of the Hangul script and would pass every check aimed at Latin.
|
|
550
|
-
*
|
|
551
|
-
* The braille blank is named on its own. It carries no dots, so it draws as
|
|
552
|
-
* nothing while belonging to neither set.
|
|
553
|
-
*
|
|
554
|
-
* None of these is a control character, so none is caught by
|
|
555
|
-
* `stripControlCharacters` — and none of them shows. A name that differs from
|
|
556
|
-
* another only by one of these is drawn exactly like it, which is why a name
|
|
557
|
-
* that carries one is not a name a user can be asked to judge.
|
|
558
|
-
*/
|
|
559
|
-
const INVISIBLE_CHARACTERS_PATTERN = /[\p{Default_Ignorable_Code_Point}\p{Cf}\u2800]/gu;
|
|
560
|
-
/**
|
|
561
|
-
* Removes every zero-width and otherwise invisible character from `text`.
|
|
562
|
-
*
|
|
563
|
-
* Kept apart from `stripControlCharacters` because the two answer different
|
|
564
|
-
* questions. That one asks what is safe to print; this one asks whether a name
|
|
565
|
-
* shows everything it contains, which is what a prompt needs before it offers
|
|
566
|
-
* the name as something to pick.
|
|
567
|
-
*/
|
|
568
|
-
function stripInvisibleCharacters(text) {
|
|
569
|
-
return text.replace(INVISIBLE_CHARACTERS_PATTERN, "");
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Removes every character that does not show: the control characters and the
|
|
573
|
-
* invisible ones alike.
|
|
574
|
-
*
|
|
575
|
-
* This is the form a name has to survive unchanged before it can be offered as
|
|
576
|
-
* something to choose. Callers that need the whole answer should reach for this
|
|
577
|
-
* rather than composing the two strippers themselves, so that the order — and
|
|
578
|
-
* the definition of "hidden" — lives in one place.
|
|
579
|
-
*/
|
|
580
|
-
function stripHiddenCharacters(text) {
|
|
581
|
-
return stripInvisibleCharacters(stripControlCharacters(text));
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* The invisible characters that do a job in some scripts rather than only
|
|
585
|
-
* hiding: the two zero-width joiners and the variation selectors.
|
|
586
|
-
*
|
|
587
|
-
* A Persian or Indic name spells a word with ZWNJ (U+200C) in it, and an emoji
|
|
588
|
-
* name is a chain of ZWJ (U+200D) and variation selectors. Refusing those
|
|
589
|
-
* outright would refuse names that are written the only way their script writes
|
|
590
|
-
* them, so they are judged by where they sit rather than by what they are.
|
|
591
|
-
*/
|
|
592
|
-
const ZERO_WIDTH_JOINER_PATTERN = /\u200c|\u200d/u;
|
|
593
|
-
const VARIATION_SELECTOR_PATTERN = /[\u{fe00}-\u{fe0f}]|[\u{e0100}-\u{e01ef}]/u;
|
|
594
|
-
/**
|
|
595
|
-
* The characters a joiner has work to do beside: the scripts whose words are
|
|
596
|
-
* written with one, and the pictographs an emoji sequence is built from.
|
|
597
|
-
*
|
|
598
|
-
* A list of what may join rather than of what may not, because the two are not
|
|
599
|
-
* the same size. `pdf` with a ZWNJ between the d and the f is `pdf` on screen
|
|
600
|
-
* and a different directory underneath, and the same is true of `設定` with a
|
|
601
|
-
* ZWJ after the first character: neither Latin nor Han joins anything that way,
|
|
602
|
-
* and nor does Cyrillic, Greek, Hangul or kana. Naming the scripts that do —
|
|
603
|
-
* the Arabic family, the Indic ones, Mongolian, and the pictographs — is what
|
|
604
|
-
* keeps the exception to the names that need it, instead of handing it to every
|
|
605
|
-
* writing system that is merely not Latin.
|
|
606
|
-
*/
|
|
607
|
-
const JOINING_CONTEXT_PATTERN = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Mongolian}\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}\p{Script=Sinhala}\p{Script=Myanmar}\p{Script=Khmer}\p{Script=Tibetan}\p{Script=Adlam}\p{Extended_Pictographic}]/u;
|
|
608
|
-
/** Non-global copies, because `test` on a global regex carries state between calls. */
|
|
609
|
-
const CONTROL_CHARACTER_PATTERN = new RegExp(CONTROL_CHARACTERS_PATTERN.source, "u");
|
|
610
|
-
const INVISIBLE_CHARACTER_PATTERN = new RegExp(INVISIBLE_CHARACTERS_PATTERN.source, "u");
|
|
611
|
-
/**
|
|
612
|
-
* Whether `text` carries a hidden character that is there to hide something.
|
|
613
|
-
*
|
|
614
|
-
* This is the question a name has to answer before it can be offered as
|
|
615
|
-
* something to pick, and it is a narrower one than `stripHiddenCharacters`
|
|
616
|
-
* answers. Every control character counts, and so does every invisible
|
|
617
|
-
* character — except a joiner or variation selector standing where its own
|
|
618
|
-
* script would put one, which is to say beside a character from a script that
|
|
619
|
-
* is written with joiners, or beside a pictograph.
|
|
620
|
-
*
|
|
621
|
-
* A joiner is held to both of its neighbors, since it exists to bind two
|
|
622
|
-
* characters and a name that ends in one is binding nothing: `設定` with a ZWJ
|
|
623
|
-
* after it is `設定` on screen and a second directory underneath. A variation
|
|
624
|
-
* selector is held only to the character before it, which is the one it selects
|
|
625
|
-
* a form for, and which is why an emoji name may end in one.
|
|
626
|
-
*
|
|
627
|
-
* Han is not on the joining list, so an ideographic variation sequence — a Han
|
|
628
|
-
* character followed by one of U+E0100 onward — is refused along with the rest.
|
|
629
|
-
* That is the intended trade: no skill directory here is named with one, and
|
|
630
|
-
* the pair is drawn as the bare character on every terminal that has no font
|
|
631
|
-
* for the variant, which is the shape the check exists to refuse.
|
|
632
|
-
*
|
|
633
|
-
* The test is a heuristic in place of the CONTEXTJ joining rules of IDNA,
|
|
634
|
-
* which decide the same question by the joining type of the characters around
|
|
635
|
-
* the joiner. It errs toward accepting a name written in a script that needs
|
|
636
|
-
* these characters, and toward rejecting one that mixes them into Latin, where
|
|
637
|
-
* they can only be padding.
|
|
638
|
-
*/
|
|
639
|
-
function hasDeceptiveHiddenCharacters(text) {
|
|
640
|
-
const characters = [...text];
|
|
641
|
-
const joinsCharacter = (neighbor) => neighbor !== void 0 && JOINING_CONTEXT_PATTERN.test(neighbor);
|
|
642
|
-
return characters.some((character, index) => {
|
|
643
|
-
if (CONTROL_CHARACTER_PATTERN.test(character)) return true;
|
|
644
|
-
if (!INVISIBLE_CHARACTER_PATTERN.test(character)) return false;
|
|
645
|
-
if (VARIATION_SELECTOR_PATTERN.test(character)) return !joinsCharacter(characters[index - 1]);
|
|
646
|
-
if (!ZERO_WIDTH_JOINER_PATTERN.test(character)) return true;
|
|
647
|
-
return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
//#endregion
|
|
651
768
|
//#region src/utils/vitest.ts
|
|
652
769
|
function isEnvTest() {
|
|
653
770
|
return process.env.NODE_ENV === "test";
|
|
@@ -1004,7 +1121,16 @@ async function isSymlink(filepath) {
|
|
|
1004
1121
|
return false;
|
|
1005
1122
|
}
|
|
1006
1123
|
}
|
|
1007
|
-
|
|
1124
|
+
/**
|
|
1125
|
+
* Every entry name directly under `dir`, of whatever kind, in the order the
|
|
1126
|
+
* filesystem reports them.
|
|
1127
|
+
*
|
|
1128
|
+
* Named for what it returns, because {@link listFileNames} sits beside it and
|
|
1129
|
+
* answers a narrower question: this one reports directories and links as well
|
|
1130
|
+
* as files, and reads an unreadable directory as an empty one instead of
|
|
1131
|
+
* failing. Picking this where the other was meant brings both of those back.
|
|
1132
|
+
*/
|
|
1133
|
+
async function listDirectoryEntryNames(dir) {
|
|
1008
1134
|
try {
|
|
1009
1135
|
return await readdir(dir);
|
|
1010
1136
|
} catch {
|
|
@@ -1016,6 +1142,20 @@ function countHiddenSegments(filePath) {
|
|
|
1016
1142
|
return splitPathSegments(filePath).filter(isHiddenPathSegment).length;
|
|
1017
1143
|
}
|
|
1018
1144
|
/**
|
|
1145
|
+
* A path the running platform produced, written posix-separated.
|
|
1146
|
+
*
|
|
1147
|
+
* Not {@link toPosixPath}, which rewrites every backslash whatever it means. That
|
|
1148
|
+
* is right for a path a caller spelled, which may be spelled either way; it is
|
|
1149
|
+
* wrong for one the platform handed back. On a posix platform a backslash is an
|
|
1150
|
+
* ordinary character in a name, so rewriting it folds `a\b` onto `a/b` -- two
|
|
1151
|
+
* different directories, and wherever the result is used as a file's identity the
|
|
1152
|
+
* second one to be seen is taken for a repeat of the first and dropped. On
|
|
1153
|
+
* Windows no name can hold a backslash, so the rewrite there is lossless.
|
|
1154
|
+
*/
|
|
1155
|
+
function nativePathToPosix(filePath) {
|
|
1156
|
+
return sep === "\\" ? filePath.replaceAll("\\", "/") : filePath;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1019
1159
|
* The real file a path denotes, posix-separated so it compares against the globby results
|
|
1020
1160
|
* that produce it. Two paths share an identity when they resolve to the very same file --
|
|
1021
1161
|
* a link beside its target, a link into a shared tree, or a cycle that walks back into an
|
|
@@ -1023,19 +1163,61 @@ function countHiddenSegments(filePath) {
|
|
|
1023
1163
|
*/
|
|
1024
1164
|
async function realFileIdentity(filePath) {
|
|
1025
1165
|
try {
|
|
1026
|
-
return
|
|
1166
|
+
return nativePathToPosix(await realpath(filePath));
|
|
1027
1167
|
} catch {
|
|
1028
|
-
return
|
|
1168
|
+
return nativePathToPosix(filePath);
|
|
1029
1169
|
}
|
|
1030
1170
|
}
|
|
1031
1171
|
/**
|
|
1172
|
+
* Where the file `targetPath` really denotes sits relative to the one `rootPath`
|
|
1173
|
+
* does, with every link on both sides resolved. Posix-separated, because the
|
|
1174
|
+
* resolved paths it is built from are; a caller that splits it must split on `/`
|
|
1175
|
+
* alone, which is also the only separator a real name can never contain.
|
|
1176
|
+
*
|
|
1177
|
+
* Both sides fall back to their literal path when they cannot be resolved, so an
|
|
1178
|
+
* unresolvable path reads as an escape rather than as a contained one.
|
|
1179
|
+
*/
|
|
1180
|
+
async function resolvedRelativePath({ rootPath, targetPath }) {
|
|
1181
|
+
const [realRootPath, realTargetPath] = await Promise.all([realFileIdentity(rootPath), realFileIdentity(targetPath)]);
|
|
1182
|
+
return posix.relative(realRootPath, realTargetPath);
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Whether a posix relative path -- one {@link resolvedRelativePath} returned, or any
|
|
1186
|
+
* built the same way -- leads out of the root it was taken against.
|
|
1187
|
+
*
|
|
1188
|
+
* Exported so a caller that already holds the relative path can ask this of the very
|
|
1189
|
+
* path it goes on to read, instead of resolving both sides a second time and judging
|
|
1190
|
+
* a result it then has to trust is the same one.
|
|
1191
|
+
*/
|
|
1192
|
+
function posixRelativePathEscapesRoot(relativePath) {
|
|
1193
|
+
return relativePath === ".." || relativePath.startsWith("../") || posix.isAbsolute(relativePath);
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* How many trailing segments `filePath` and `identity` have in common.
|
|
1197
|
+
*
|
|
1198
|
+
* A path that walked through no link at all shares all of its own segments with
|
|
1199
|
+
* the file's identity; one that walked through a link named differently from
|
|
1200
|
+
* its target parts from the identity at that segment and shares only what
|
|
1201
|
+
* follows it. Counting from the end rather than testing the two for equality is
|
|
1202
|
+
* what lets the comparison hold for a path that is not itself resolved: a glob
|
|
1203
|
+
* rooted at a directory that is a link of its own gives every candidate the
|
|
1204
|
+
* same unresolved prefix, and only the segments below it decide.
|
|
1205
|
+
*/
|
|
1206
|
+
function sharedTrailingSegments(filePath, identity) {
|
|
1207
|
+
const left = splitPathSegments(nativePathToPosix(filePath));
|
|
1208
|
+
const right = splitPathSegments(identity);
|
|
1209
|
+
let shared = 0;
|
|
1210
|
+
while (shared < left.length && shared < right.length && left[left.length - 1 - shared] === right[right.length - 1 - shared]) shared++;
|
|
1211
|
+
return shared;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1032
1214
|
* Pick the one path that represents a file among the paths that resolve to it.
|
|
1033
1215
|
*
|
|
1034
|
-
* The path that walked through no link at all wins outright: it
|
|
1035
|
-
*
|
|
1036
|
-
* callers see, rather than an alias that happens to sort first -- a
|
|
1037
|
-
* `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a
|
|
1038
|
-
* `sub/note.md` with the same file reached back through the cycle.
|
|
1216
|
+
* The path that walked through no link at all wins outright: it shares every one of its
|
|
1217
|
+
* segments with the file's identity, which no alias does. That keeps the real location of
|
|
1218
|
+
* a file as the path callers see, rather than an alias that happens to sort first -- a
|
|
1219
|
+
* directory link named `aaa` pointing at `zzz` must not make `zzz/x.md` disappear, and a
|
|
1220
|
+
* cycle must not replace `sub/note.md` with the same file reached back through the cycle.
|
|
1039
1221
|
* Failing that, the fewest dot-prefixed segments wins: when only links are on offer, the
|
|
1040
1222
|
* named one represents the entry rather than a hidden alias that a hidden-entry rule may
|
|
1041
1223
|
* then drop, taking the named path's content with it. `candidates` arrives in sorted
|
|
@@ -1043,8 +1225,9 @@ async function realFileIdentity(filePath) {
|
|
|
1043
1225
|
*/
|
|
1044
1226
|
function chooseRepresentative(candidates, identity) {
|
|
1045
1227
|
return candidates.reduce((best, candidate) => {
|
|
1046
|
-
|
|
1047
|
-
|
|
1228
|
+
const bestShared = sharedTrailingSegments(best, identity);
|
|
1229
|
+
const candidateShared = sharedTrailingSegments(candidate, identity);
|
|
1230
|
+
if (candidateShared !== bestShared) return candidateShared > bestShared ? candidate : best;
|
|
1048
1231
|
return countHiddenSegments(candidate) < countHiddenSegments(best) ? candidate : best;
|
|
1049
1232
|
});
|
|
1050
1233
|
}
|
|
@@ -1142,7 +1325,7 @@ async function dedupeNamesByFileIdentity(params) {
|
|
|
1142
1325
|
});
|
|
1143
1326
|
const entriesByIdentity = /* @__PURE__ */ new Map();
|
|
1144
1327
|
for (const [index, entry] of entries.entries()) {
|
|
1145
|
-
const identity = identities[index] ??
|
|
1328
|
+
const identity = identities[index] ?? nativePathToPosix(join(dirPath, entry.name));
|
|
1146
1329
|
const group = entriesByIdentity.get(identity);
|
|
1147
1330
|
if (group === void 0) entriesByIdentity.set(identity, [entry]);
|
|
1148
1331
|
else group.push(entry);
|
|
@@ -1194,6 +1377,94 @@ async function listFileNames(dirPath, options = {}) {
|
|
|
1194
1377
|
nameFilter: options.nameFilter
|
|
1195
1378
|
});
|
|
1196
1379
|
}
|
|
1380
|
+
/**
|
|
1381
|
+
* The paths of every file below `dirPath`, relative to it, walked rather than
|
|
1382
|
+
* globbed.
|
|
1383
|
+
*
|
|
1384
|
+
* The recursive counterpart of {@link listFileNames}, and there for the same
|
|
1385
|
+
* reason: globby reads a backslash as a path separator and rewrites it in the
|
|
1386
|
+
* paths it returns, so a file named `back\\slash.md` comes back as
|
|
1387
|
+
* `back/slash.md` — a path that belongs to no file, under a name that belongs
|
|
1388
|
+
* to no file either.
|
|
1389
|
+
*
|
|
1390
|
+
* A root that is not there is an empty root. A root that is there but cannot be
|
|
1391
|
+
* read is reported, so it is never mistaken for an empty one — as is a
|
|
1392
|
+
* directory below it, since a subtree silently missing from the result is the
|
|
1393
|
+
* same mistake one level down.
|
|
1394
|
+
*
|
|
1395
|
+
* A directory link is followed like any other directory, but each real
|
|
1396
|
+
* directory is walked only once, so neither a cycle nor a mesh of links can
|
|
1397
|
+
* make the walk repeat itself -- taking every distinct route through a graph of
|
|
1398
|
+
* aliases would cost one traversal per route, which a handful of links is
|
|
1399
|
+
* enough to make hopeless. The name a twice-reachable directory is reported
|
|
1400
|
+
* under is therefore whichever the walk reaches first, and the walk goes level
|
|
1401
|
+
* by level with each level sorted, so that is the shortest path to it and the
|
|
1402
|
+
* first in sorted order among equals. Note the difference from
|
|
1403
|
+
* {@link findFilesByGlobs}, which resolves each of its results and keeps the
|
|
1404
|
+
* real one: here a link nearer the root than the directory it points at stands
|
|
1405
|
+
* in for it. `nameFilter` narrows the files, not the directories the walk
|
|
1406
|
+
* descends into.
|
|
1407
|
+
*
|
|
1408
|
+
* The cost is one round of `readdir` per directory, taken in sequence, and the
|
|
1409
|
+
* whole of a level is held at once, so residency follows the widest level
|
|
1410
|
+
* rather than the deepest path.
|
|
1411
|
+
*/
|
|
1412
|
+
async function listFilePathsRecursively(dirPath, options = {}) {
|
|
1413
|
+
const { followSymbolicLinks = true, includeHidden = false, nameFilter, deduplicateByFileIdentity = false } = options;
|
|
1414
|
+
if (!await directoryExists(dirPath)) return [];
|
|
1415
|
+
const filePaths = [];
|
|
1416
|
+
const walkedIdentities = /* @__PURE__ */ new Set();
|
|
1417
|
+
let level = [{
|
|
1418
|
+
currentPath: dirPath,
|
|
1419
|
+
prefix: ""
|
|
1420
|
+
}];
|
|
1421
|
+
while (level.length > 0) {
|
|
1422
|
+
const nextLevel = [];
|
|
1423
|
+
for (const { currentPath, prefix } of level.toSorted((a, b) => a.prefix < b.prefix ? -1 : a.prefix > b.prefix ? 1 : 0)) {
|
|
1424
|
+
const identity = await realFileIdentity(currentPath);
|
|
1425
|
+
if (walkedIdentities.has(identity)) continue;
|
|
1426
|
+
walkedIdentities.add(identity);
|
|
1427
|
+
const [fileNames, dirNames] = await Promise.all([listFileNames(currentPath, {
|
|
1428
|
+
followSymbolicLinks,
|
|
1429
|
+
includeHidden,
|
|
1430
|
+
nameFilter
|
|
1431
|
+
}), listSubdirectoryNames(currentPath, {
|
|
1432
|
+
followSymbolicLinks,
|
|
1433
|
+
includeHidden
|
|
1434
|
+
})]);
|
|
1435
|
+
for (const fileName of fileNames) filePaths.push(prefix === "" ? fileName : join(prefix, fileName));
|
|
1436
|
+
for (const dirName of dirNames) nextLevel.push({
|
|
1437
|
+
currentPath: join(currentPath, dirName),
|
|
1438
|
+
prefix: prefix === "" ? dirName : join(prefix, dirName)
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
level = nextLevel;
|
|
1442
|
+
}
|
|
1443
|
+
return deduplicateByFileIdentity ? await deduplicateRelativePathsByFileIdentity({
|
|
1444
|
+
dirPath,
|
|
1445
|
+
relativePaths: filePaths
|
|
1446
|
+
}) : filePaths.toSorted();
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* One path per real file, chosen the way {@link findFilesByGlobs} chooses it.
|
|
1450
|
+
*
|
|
1451
|
+
* The walk de-duplicates the directories it descends into, so it cannot loop,
|
|
1452
|
+
* but it still reports every name it walks past: a file reached under two names
|
|
1453
|
+
* is listed twice. A caller reading the result as a set of files has to fold
|
|
1454
|
+
* those aliases together, and has to fold them the same way the glob does, or
|
|
1455
|
+
* the two disagree about the name a file has.
|
|
1456
|
+
*/
|
|
1457
|
+
async function deduplicateRelativePathsByFileIdentity({ dirPath, relativePaths }) {
|
|
1458
|
+
const candidatesByFile = /* @__PURE__ */ new Map();
|
|
1459
|
+
for (const relativePath of relativePaths.toSorted()) {
|
|
1460
|
+
const absolutePath = join(dirPath, relativePath);
|
|
1461
|
+
const identity = await realFileIdentity(absolutePath);
|
|
1462
|
+
const candidates = candidatesByFile.get(identity);
|
|
1463
|
+
if (candidates === void 0) candidatesByFile.set(identity, [absolutePath]);
|
|
1464
|
+
else candidates.push(absolutePath);
|
|
1465
|
+
}
|
|
1466
|
+
return [...candidatesByFile.entries()].map(([identity, candidates]) => relative(dirPath, chooseRepresentative(candidates, identity))).toSorted();
|
|
1467
|
+
}
|
|
1197
1468
|
async function removeDirectory(dirPath) {
|
|
1198
1469
|
if ([
|
|
1199
1470
|
".",
|
|
@@ -1355,24 +1626,111 @@ var CLIError = class extends Error {
|
|
|
1355
1626
|
//#region src/utils/warned-once.ts
|
|
1356
1627
|
/**
|
|
1357
1628
|
* The messages a once-per-run warning has already emitted in this process.
|
|
1358
|
-
* This lives in its own module,
|
|
1359
|
-
* clear it between tests without pulling `logger.js` into every
|
|
1360
|
-
* graph (which would defeat the module mocks some of those tests
|
|
1629
|
+
* This lives in its own module, importing nothing of rulesync's, so the vitest
|
|
1630
|
+
* setup file can clear it between tests without pulling `logger.js` into every
|
|
1631
|
+
* test's module graph (which would defeat the module mocks some of those tests
|
|
1632
|
+
* install).
|
|
1633
|
+
*/
|
|
1634
|
+
const processWideMessages = /* @__PURE__ */ new Set();
|
|
1635
|
+
/**
|
|
1636
|
+
* The set an operation that opened its own scope uses instead.
|
|
1637
|
+
*
|
|
1638
|
+
* The MCP server does not serialize requests, so two runs can be in flight at
|
|
1639
|
+
* once. Sharing one set between them would let the first run spend the token
|
|
1640
|
+
* for a message and leave the second one's result silent about a diagnostic
|
|
1641
|
+
* that applies to it just as much. A scope gives each run its own bookkeeping.
|
|
1361
1642
|
*/
|
|
1362
|
-
const
|
|
1643
|
+
const scopedMessages = new AsyncLocalStorage();
|
|
1644
|
+
function currentMessages() {
|
|
1645
|
+
return scopedMessages.getStore() ?? processWideMessages;
|
|
1646
|
+
}
|
|
1363
1647
|
/** Whether `message` has not been emitted yet; records it when it has not. */
|
|
1364
1648
|
function claimWarnOnce(message) {
|
|
1365
|
-
|
|
1366
|
-
|
|
1649
|
+
const messages = currentMessages();
|
|
1650
|
+
if (messages.has(message)) return false;
|
|
1651
|
+
messages.add(message);
|
|
1367
1652
|
return true;
|
|
1368
1653
|
}
|
|
1369
|
-
/** Forget which warnings were already emitted, so
|
|
1654
|
+
/** Forget which warnings were already emitted, so the next run starts silent. */
|
|
1370
1655
|
function resetWarnedOnceMessages() {
|
|
1371
|
-
|
|
1656
|
+
currentMessages().clear();
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Run `operation` with its own once-per-run bookkeeping, so a concurrent run
|
|
1660
|
+
* neither spends its tokens nor clears its record.
|
|
1661
|
+
*/
|
|
1662
|
+
async function withWarnOnceScope(operation) {
|
|
1663
|
+
return await scopedMessages.run(/* @__PURE__ */ new Set(), operation);
|
|
1372
1664
|
}
|
|
1373
1665
|
//#endregion
|
|
1374
1666
|
//#region src/utils/logger.ts
|
|
1375
1667
|
/**
|
|
1668
|
+
* Formats a log line the way `console.warn` would, so a warning that is handed
|
|
1669
|
+
* back to a caller reads the same as the one that reaches a terminal.
|
|
1670
|
+
*/
|
|
1671
|
+
function formatLogLine({ message, args }) {
|
|
1672
|
+
return args.length === 0 ? message : format(message, ...args);
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* How much a collecting logger keeps: at most this many warnings, each at most
|
|
1676
|
+
* this long, and no more than this in total.
|
|
1677
|
+
*
|
|
1678
|
+
* Collected warnings travel to places a console line does not — a `--json`
|
|
1679
|
+
* document that another program parses, an MCP result that an agent reads as
|
|
1680
|
+
* context — and their text quotes files rulesync did not write. So the amount a
|
|
1681
|
+
* repository can push through has to be bounded twice over: a config with
|
|
1682
|
+
* thousands of odd keys is a plausible accident, and a report sized in hundreds
|
|
1683
|
+
* of kilobytes is a generous budget for text aimed at whoever reads it next.
|
|
1684
|
+
* The total is the binding limit; the per-line and per-count limits keep one
|
|
1685
|
+
* enormous warning, or one enormous number of them, from being the whole of it.
|
|
1686
|
+
*/
|
|
1687
|
+
const MAX_COLLECTED_WARNINGS = 100;
|
|
1688
|
+
const MAX_COLLECTED_WARNING_LENGTH = 1e3;
|
|
1689
|
+
const MAX_COLLECTED_TOTAL_LENGTH = 8e3;
|
|
1690
|
+
/**
|
|
1691
|
+
* How many distinct warnings the de-duplication remembers.
|
|
1692
|
+
*
|
|
1693
|
+
* The record has to outlive the reported lines — a line dropped for want of
|
|
1694
|
+
* budget must not be counted again the next time the same diagnostic repeats —
|
|
1695
|
+
* so it grows with the number of distinct warnings a run raises rather than
|
|
1696
|
+
* with the number reported. Bounded for the same reason everything else here
|
|
1697
|
+
* is: past this many, later repeats are counted rather than recognized, which
|
|
1698
|
+
* inflates the trailing count but cannot grow the record without end.
|
|
1699
|
+
*/
|
|
1700
|
+
const MAX_DEDUPLICATED_WARNINGS = 1e3;
|
|
1701
|
+
/**
|
|
1702
|
+
* A bounded list of warning lines.
|
|
1703
|
+
*/
|
|
1704
|
+
var WarningCollection = class {
|
|
1705
|
+
lines = [];
|
|
1706
|
+
seen = /* @__PURE__ */ new Set();
|
|
1707
|
+
totalLength = 0;
|
|
1708
|
+
omitted = 0;
|
|
1709
|
+
add({ message, args }) {
|
|
1710
|
+
const kept = truncateText({
|
|
1711
|
+
text: stripControlCharacters(formatLogLine({
|
|
1712
|
+
message,
|
|
1713
|
+
args
|
|
1714
|
+
})),
|
|
1715
|
+
maxLength: MAX_COLLECTED_WARNING_LENGTH,
|
|
1716
|
+
suffix: "…(truncated)"
|
|
1717
|
+
});
|
|
1718
|
+
if (this.seen.has(kept)) return;
|
|
1719
|
+
if (this.lines.length >= MAX_COLLECTED_WARNINGS || this.totalLength + kept.length > MAX_COLLECTED_TOTAL_LENGTH) {
|
|
1720
|
+
if (this.seen.size < MAX_DEDUPLICATED_WARNINGS) this.seen.add(kept);
|
|
1721
|
+
this.omitted++;
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
this.seen.add(kept);
|
|
1725
|
+
this.lines.push(kept);
|
|
1726
|
+
this.totalLength += kept.length;
|
|
1727
|
+
}
|
|
1728
|
+
toArray() {
|
|
1729
|
+
if (this.omitted === 0) return [...this.lines];
|
|
1730
|
+
return [...this.lines, `… and ${this.omitted} more warning(s) not reported`];
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
/**
|
|
1376
1734
|
* Base class for shared verbose/silent state and configuration logic
|
|
1377
1735
|
*/
|
|
1378
1736
|
var BaseLogger = class {
|
|
@@ -1388,6 +1746,9 @@ var BaseLogger = class {
|
|
|
1388
1746
|
get silent() {
|
|
1389
1747
|
return this._silent;
|
|
1390
1748
|
}
|
|
1749
|
+
get reportsWhileSilent() {
|
|
1750
|
+
return false;
|
|
1751
|
+
}
|
|
1391
1752
|
configure({ verbose, silent }) {
|
|
1392
1753
|
this._silent = silent;
|
|
1393
1754
|
this._verbose = verbose && !silent;
|
|
@@ -1433,11 +1794,18 @@ var ConsoleLogger = class extends BaseLogger {
|
|
|
1433
1794
|
/**
|
|
1434
1795
|
* JsonLogger - structured JSON output to stdout/stderr
|
|
1435
1796
|
*
|
|
1436
|
-
*
|
|
1797
|
+
* The console output methods (info, success, debug) are no-ops. `warn` is not:
|
|
1798
|
+
* a diagnostic that only reached the console would be invisible to a `--json`
|
|
1799
|
+
* consumer, which reads the document and nothing else, so warnings are
|
|
1800
|
+
* collected and emitted as the document's top-level `warnings` array instead.
|
|
1801
|
+
* Top-level rather than inside `data` so it can never collide with a key a
|
|
1802
|
+
* command captured, and so it survives on the failure document too — the case
|
|
1803
|
+
* where a diagnostic about the input is most likely to explain the failure.
|
|
1437
1804
|
*/
|
|
1438
1805
|
var JsonLogger = class extends BaseLogger {
|
|
1439
1806
|
_jsonOutputDone = false;
|
|
1440
1807
|
_jsonData = {};
|
|
1808
|
+
_warnings = new WarningCollection();
|
|
1441
1809
|
_commandName;
|
|
1442
1810
|
_version;
|
|
1443
1811
|
constructor({ command, version, verbose = false, silent = false }) {
|
|
@@ -1466,6 +1834,8 @@ var JsonLogger = class extends BaseLogger {
|
|
|
1466
1834
|
command: this._commandName,
|
|
1467
1835
|
version: this._version
|
|
1468
1836
|
};
|
|
1837
|
+
const warnings = this._warnings.toArray();
|
|
1838
|
+
if (warnings.length > 0) output.warnings = warnings;
|
|
1469
1839
|
if (success) output.data = this._jsonData;
|
|
1470
1840
|
else if (error) {
|
|
1471
1841
|
output.error = {
|
|
@@ -1481,7 +1851,13 @@ var JsonLogger = class extends BaseLogger {
|
|
|
1481
1851
|
}
|
|
1482
1852
|
info(_message, ..._args) {}
|
|
1483
1853
|
success(_message, ..._args) {}
|
|
1484
|
-
warn(
|
|
1854
|
+
warn(message, ...args) {
|
|
1855
|
+
if (this._silent) return;
|
|
1856
|
+
this._warnings.add({
|
|
1857
|
+
message,
|
|
1858
|
+
args
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1485
1861
|
error(message, code, ..._args) {
|
|
1486
1862
|
if (isEnvTest()) return;
|
|
1487
1863
|
const errorMessage = message instanceof Error ? message.message : message;
|
|
@@ -1507,13 +1883,110 @@ function warnOnConflictingFlags({ verbose, silent, jsonMode }) {
|
|
|
1507
1883
|
console.warn("Both --verbose and --silent specified; --silent takes precedence");
|
|
1508
1884
|
}
|
|
1509
1885
|
/**
|
|
1886
|
+
* Where `fallbackLogger` sends what it is given, scoped to the operation that
|
|
1887
|
+
* adopted it.
|
|
1888
|
+
*
|
|
1889
|
+
* An `AsyncLocalStorage` rather than a plain variable because the target is
|
|
1890
|
+
* per-operation, not per-process: a long-lived MCP server can have two requests
|
|
1891
|
+
* in flight at once, and a save/restore pair would let the first one to finish
|
|
1892
|
+
* hand the still-running request's warnings back to a console nobody reads.
|
|
1893
|
+
* Each operation sees only its own store.
|
|
1894
|
+
*/
|
|
1895
|
+
const fallbackTargetStorage = new AsyncLocalStorage();
|
|
1896
|
+
/**
|
|
1897
|
+
* Where warnings go outside any adopted scope: a plain console logger, which is
|
|
1898
|
+
* what a code path with no logger threaded through would otherwise have used.
|
|
1899
|
+
*/
|
|
1900
|
+
const defaultFallbackTarget = new ConsoleLogger();
|
|
1901
|
+
function currentFallbackTarget() {
|
|
1902
|
+
return fallbackTargetStorage.getStore() ?? defaultFallbackTarget;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* True while the forwarder is inside a call it is forwarding.
|
|
1906
|
+
*
|
|
1907
|
+
* The adopted target is supposed to be something other than the forwarder, but
|
|
1908
|
+
* a wrapper *around* it — `hooks-processor.ts` returns one that prefixes the
|
|
1909
|
+
* tool target onto every warning — passes the identity check in
|
|
1910
|
+
* {@link withFallbackLoggerTarget} and would forward straight back here. A
|
|
1911
|
+
* plain module-level flag is enough because the forwarding is synchronous: the
|
|
1912
|
+
* call returns before anything else can run.
|
|
1913
|
+
*/
|
|
1914
|
+
let forwarding = false;
|
|
1915
|
+
/**
|
|
1916
|
+
* Reads from, or writes to, whichever logger the running operation adopted,
|
|
1917
|
+
* with the wrapper case above cut off at one hop.
|
|
1918
|
+
*
|
|
1919
|
+
* Every member of the forwarder goes through here, not just the two that write:
|
|
1920
|
+
* `warnOnceWithFallback` reads `silent` and `reportsWhileSilent` before it ever
|
|
1921
|
+
* calls `warn`, so a guard on the writing side alone would still be reached
|
|
1922
|
+
* through a getter that never returns.
|
|
1923
|
+
*/
|
|
1924
|
+
function throughFallbackTarget(use) {
|
|
1925
|
+
if (forwarding) return use(defaultFallbackTarget);
|
|
1926
|
+
forwarding = true;
|
|
1927
|
+
try {
|
|
1928
|
+
return use(currentFallbackTarget());
|
|
1929
|
+
} finally {
|
|
1930
|
+
forwarding = false;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1510
1934
|
* Shared fallback logger for code paths that have no command logger threaded
|
|
1511
1935
|
* through (module-level translators, `warnWithFallback(undefined, ...)`).
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1936
|
+
*
|
|
1937
|
+
* It is a thin forwarder rather than a logger of its own so that the operation
|
|
1938
|
+
* currently running can adopt it: `wrapCommand` points it at the command
|
|
1939
|
+
* logger, which is how a warning raised deep in a translator still reaches a
|
|
1940
|
+
* `--json` document or an MCP result instead of being written to a console that
|
|
1941
|
+
* nobody in those modes is reading. Modules that captured a reference to
|
|
1942
|
+
* `fallbackLogger` at import time follow the redirection too, which a
|
|
1943
|
+
* swapped-out binding would not give us.
|
|
1944
|
+
*/
|
|
1945
|
+
const fallbackLogger = {
|
|
1946
|
+
configure(options) {
|
|
1947
|
+
defaultFallbackTarget.configure(options);
|
|
1948
|
+
},
|
|
1949
|
+
get verbose() {
|
|
1950
|
+
return throughFallbackTarget((target) => target.verbose);
|
|
1951
|
+
},
|
|
1952
|
+
get silent() {
|
|
1953
|
+
return throughFallbackTarget((target) => target.silent);
|
|
1954
|
+
},
|
|
1955
|
+
get reportsWhileSilent() {
|
|
1956
|
+
return throughFallbackTarget((target) => target.reportsWhileSilent);
|
|
1957
|
+
},
|
|
1958
|
+
get jsonMode() {
|
|
1959
|
+
return throughFallbackTarget((target) => target.jsonMode);
|
|
1960
|
+
},
|
|
1961
|
+
captureData(_key, _value) {},
|
|
1962
|
+
getJsonData() {
|
|
1963
|
+
return {};
|
|
1964
|
+
},
|
|
1965
|
+
outputJson(_success, _error) {},
|
|
1966
|
+
info(_message, ..._args) {},
|
|
1967
|
+
success(_message, ..._args) {},
|
|
1968
|
+
warn(message, ...args) {
|
|
1969
|
+
throughFallbackTarget((target) => {
|
|
1970
|
+
target.warn(message, ...args);
|
|
1971
|
+
});
|
|
1972
|
+
},
|
|
1973
|
+
error(message, code, ...args) {
|
|
1974
|
+
defaultFallbackTarget.error(message, code, ...args);
|
|
1975
|
+
},
|
|
1976
|
+
debug(_message, ..._args) {}
|
|
1977
|
+
};
|
|
1978
|
+
/**
|
|
1979
|
+
* Run `operation` with `fallbackLogger` pointed at `logger`, so warnings raised
|
|
1980
|
+
* where no logger was threaded through end up in the same place as the rest of
|
|
1981
|
+
* that operation's diagnostics.
|
|
1982
|
+
*
|
|
1983
|
+
* The redirection lasts exactly as long as the operation and is invisible to
|
|
1984
|
+
* anything running beside it.
|
|
1515
1985
|
*/
|
|
1516
|
-
|
|
1986
|
+
async function withFallbackLoggerTarget({ logger, operation }) {
|
|
1987
|
+
if (logger === fallbackLogger) return await operation();
|
|
1988
|
+
return await fallbackTargetStorage.run(logger, () => withWarnOnceScope(operation));
|
|
1989
|
+
}
|
|
1517
1990
|
/**
|
|
1518
1991
|
* Emit a warning through `logger.warn` if a logger is supplied, otherwise
|
|
1519
1992
|
* fall through to the shared `fallbackLogger`. Centralizes the "logger may
|
|
@@ -1531,9 +2004,35 @@ function warnWithFallback(logger, message) {
|
|
|
1531
2004
|
* varies with what the user should do next does not.
|
|
1532
2005
|
*/
|
|
1533
2006
|
function warnOnceWithFallback(logger, message) {
|
|
2007
|
+
const destination = logger ?? fallbackLogger;
|
|
2008
|
+
if (destination.silent && !destination.reportsWhileSilent) return;
|
|
1534
2009
|
if (!claimWarnOnce(message)) return;
|
|
1535
|
-
|
|
2010
|
+
destination.warn(message);
|
|
1536
2011
|
}
|
|
2012
|
+
/**
|
|
2013
|
+
* A `ConsoleLogger` that keeps the warnings it is given.
|
|
2014
|
+
*
|
|
2015
|
+
* A caller with no console to write to — an MCP tool answering over stdio, where
|
|
2016
|
+
* the server's stderr never reaches the agent — can hand this in and put what
|
|
2017
|
+
* was reported into its own result, so a diagnostic about the files it just read
|
|
2018
|
+
* is something the agent can act on rather than something it never hears.
|
|
2019
|
+
*/
|
|
2020
|
+
var WarningCollectingLogger = class extends ConsoleLogger {
|
|
2021
|
+
warnings = new WarningCollection();
|
|
2022
|
+
get reportsWhileSilent() {
|
|
2023
|
+
return true;
|
|
2024
|
+
}
|
|
2025
|
+
warn(message, ...args) {
|
|
2026
|
+
this.warnings.add({
|
|
2027
|
+
message,
|
|
2028
|
+
args
|
|
2029
|
+
});
|
|
2030
|
+
super.warn(message, ...args);
|
|
2031
|
+
}
|
|
2032
|
+
getWarnings() {
|
|
2033
|
+
return this.warnings.toArray();
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
1537
2036
|
//#endregion
|
|
1538
2037
|
//#region src/utils/validation.ts
|
|
1539
2038
|
/**
|
|
@@ -4540,6 +5039,17 @@ function parseStrict(content) {
|
|
|
4540
5039
|
return result;
|
|
4541
5040
|
}
|
|
4542
5041
|
/**
|
|
5042
|
+
* The error a source document should fail with when
|
|
5043
|
+
* {@link parseJsoncReportingDroppedKeys} reports keys the parser removed.
|
|
5044
|
+
*
|
|
5045
|
+
* Shared by every source that reports them, so the three files a user can
|
|
5046
|
+
* author explain the same removal the same way rather than each inventing its
|
|
5047
|
+
* own wording.
|
|
5048
|
+
*/
|
|
5049
|
+
function droppedPollutionKeysError({ sourcePath, droppedKeys }) {
|
|
5050
|
+
return /* @__PURE__ */ new Error(`${JSON.stringify(sourcePath)} uses ${droppedKeys.map((key) => JSON.stringify(key)).join(", ")} as ${droppedKeys.length === 1 ? "a key" : "keys"}. Rulesync removes __proto__, constructor and prototype from every source document it parses, because assigning them would reach the prototype chain instead of the object. They are therefore never written to any tool's config — rename them rather than leaving entries that silently do nothing.`);
|
|
5051
|
+
}
|
|
5052
|
+
/**
|
|
4543
5053
|
* The same parse as {@link parseJsonc}, additionally reporting which
|
|
4544
5054
|
* prototype-pollution keys were removed, as dotted paths
|
|
4545
5055
|
* (`permission.bash.__proto__`).
|
|
@@ -4619,9 +5129,17 @@ async function resolveRulesyncSourceWritePath({ outputRoot, paths }) {
|
|
|
4619
5129
|
//#region src/features/hooks/rulesync-hooks.ts
|
|
4620
5130
|
var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
|
|
4621
5131
|
json;
|
|
5132
|
+
/**
|
|
5133
|
+
* Prototype-pollution keys the parser removed. They are dropped before the
|
|
5134
|
+
* schema ever sees them, so without this record a hook keyed `constructor`
|
|
5135
|
+
* would produce neither an error nor an entry in any generated file.
|
|
5136
|
+
*/
|
|
5137
|
+
droppedKeys;
|
|
4622
5138
|
constructor(params) {
|
|
4623
5139
|
super({ ...params });
|
|
4624
|
-
|
|
5140
|
+
const { value, droppedKeys } = parseJsoncReportingDroppedKeys({ content: this.fileContent });
|
|
5141
|
+
this.json = value;
|
|
5142
|
+
this.droppedKeys = droppedKeys;
|
|
4625
5143
|
if (params.validate) {
|
|
4626
5144
|
const result = this.validate();
|
|
4627
5145
|
if (!result.success) throw result.error;
|
|
@@ -4640,6 +5158,13 @@ var RulesyncHooks = class RulesyncHooks extends RulesyncFile {
|
|
|
4640
5158
|
};
|
|
4641
5159
|
}
|
|
4642
5160
|
validate() {
|
|
5161
|
+
if (this.droppedKeys.length > 0) return {
|
|
5162
|
+
success: false,
|
|
5163
|
+
error: droppedPollutionKeysError({
|
|
5164
|
+
sourcePath: this.getRelativePathFromCwd(),
|
|
5165
|
+
droppedKeys: this.droppedKeys
|
|
5166
|
+
})
|
|
5167
|
+
};
|
|
4643
5168
|
const result = HooksConfigSchema.safeParse(this.json);
|
|
4644
5169
|
if (!result.success) return {
|
|
4645
5170
|
success: false,
|
|
@@ -4986,9 +5511,17 @@ function mergeMcpJsonOverlays({ base, overlay }) {
|
|
|
4986
5511
|
}
|
|
4987
5512
|
var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
4988
5513
|
json;
|
|
5514
|
+
/**
|
|
5515
|
+
* Prototype-pollution keys the parser removed. They are dropped before the
|
|
5516
|
+
* schema ever sees them, so without this record a server named `__proto__`
|
|
5517
|
+
* would produce neither an error nor an entry in any generated file.
|
|
5518
|
+
*/
|
|
5519
|
+
droppedKeys;
|
|
4989
5520
|
constructor(params) {
|
|
4990
5521
|
super(params);
|
|
4991
|
-
|
|
5522
|
+
const { value, droppedKeys } = parseJsoncReportingDroppedKeys({ content: this.fileContent });
|
|
5523
|
+
this.json = value;
|
|
5524
|
+
this.droppedKeys = droppedKeys;
|
|
4992
5525
|
if (params.validate) {
|
|
4993
5526
|
const result = this.validate();
|
|
4994
5527
|
if (!result.success) throw result.error;
|
|
@@ -5010,6 +5543,13 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
|
5010
5543
|
};
|
|
5011
5544
|
}
|
|
5012
5545
|
validate() {
|
|
5546
|
+
if (this.droppedKeys.length > 0) return {
|
|
5547
|
+
success: false,
|
|
5548
|
+
error: droppedPollutionKeysError({
|
|
5549
|
+
sourcePath: this.getRelativePathFromCwd(),
|
|
5550
|
+
droppedKeys: this.droppedKeys
|
|
5551
|
+
})
|
|
5552
|
+
};
|
|
5013
5553
|
const result = RulesyncMcpFileSchema.safeParse(this.json);
|
|
5014
5554
|
if (!result.success) return {
|
|
5015
5555
|
success: false,
|
|
@@ -5079,16 +5619,23 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
|
|
|
5079
5619
|
}
|
|
5080
5620
|
const fileContent = await readFileContent(filePath);
|
|
5081
5621
|
let parsed;
|
|
5622
|
+
let droppedKeys;
|
|
5082
5623
|
try {
|
|
5083
|
-
|
|
5084
|
-
if (!isRecord$1(
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
if (!result.success) throw result.error;
|
|
5088
|
-
}
|
|
5624
|
+
const result = parseJsoncReportingDroppedKeys({ content: fileContent });
|
|
5625
|
+
if (!isRecord$1(result.value)) throw new Error("Expected a JSON object.");
|
|
5626
|
+
parsed = result.value;
|
|
5627
|
+
droppedKeys = result.droppedKeys;
|
|
5089
5628
|
} catch (error) {
|
|
5090
5629
|
throw new Error(`Invalid MCP source file '${filePath}': ${formatError(error)}`, { cause: error });
|
|
5091
5630
|
}
|
|
5631
|
+
if (validate) {
|
|
5632
|
+
if (droppedKeys.length > 0) throw droppedPollutionKeysError({
|
|
5633
|
+
sourcePath: toPosixPath(relative(process.cwd(), filePath)),
|
|
5634
|
+
droppedKeys
|
|
5635
|
+
});
|
|
5636
|
+
const result = RulesyncMcpFileSchema.safeParse(parsed);
|
|
5637
|
+
if (!result.success) throw new Error(`Invalid MCP source file '${filePath}': ${formatError(result.error)}`, { cause: result.error });
|
|
5638
|
+
}
|
|
5092
5639
|
rootSources.push({
|
|
5093
5640
|
record: parsed,
|
|
5094
5641
|
outputRoot: parent,
|
|
@@ -5334,12 +5881,13 @@ const PermissionActionSchema = z.enum([
|
|
|
5334
5881
|
"deny"
|
|
5335
5882
|
]);
|
|
5336
5883
|
/**
|
|
5337
|
-
* Whether a
|
|
5338
|
-
*
|
|
5339
|
-
* filter removes can never
|
|
5884
|
+
* Whether a key in a permission block — a category name or a pattern — is
|
|
5885
|
+
* blank, that is empty or only whitespace. Shared with the import-side filter
|
|
5886
|
+
* so the key the schema rejects and the key that filter removes can never
|
|
5887
|
+
* drift apart.
|
|
5340
5888
|
*/
|
|
5341
|
-
function
|
|
5342
|
-
return
|
|
5889
|
+
function isBlankPermissionKey(key) {
|
|
5890
|
+
return key.trim().length === 0;
|
|
5343
5891
|
}
|
|
5344
5892
|
/**
|
|
5345
5893
|
* A single permission pattern key.
|
|
@@ -5351,7 +5899,18 @@ function isBlankPermissionPattern(pattern) {
|
|
|
5351
5899
|
* silently ignores it. Rather than let each target decide, reject it here so
|
|
5352
5900
|
* the mistake surfaces once, on the source file.
|
|
5353
5901
|
*/
|
|
5354
|
-
const PermissionPatternSchema = z.string().check(z.refine((pattern) => !
|
|
5902
|
+
const PermissionPatternSchema = z.string().check(z.refine((pattern) => !isBlankPermissionKey(pattern), { message: "Permission pattern must not be blank" }));
|
|
5903
|
+
/**
|
|
5904
|
+
* A permission category key: the name of the tool surface a rules map applies
|
|
5905
|
+
* to (`bash`, `edit`, `webfetch`, ...).
|
|
5906
|
+
*
|
|
5907
|
+
* Blank is rejected for the same reason a blank pattern is, one step up. Every
|
|
5908
|
+
* translator reads categories by name, so `{"": {"git *": "allow"}}` reaches no
|
|
5909
|
+
* tool at all and the rules under it are silently dead — the mistake is only
|
|
5910
|
+
* visible as an entry missing from a generated config. Rejecting it here
|
|
5911
|
+
* surfaces it on the source file instead.
|
|
5912
|
+
*/
|
|
5913
|
+
const PermissionCategorySchema = z.string().check(z.refine((category) => !isBlankPermissionKey(category), { message: "Permission category must not be blank" }));
|
|
5355
5914
|
/**
|
|
5356
5915
|
* Permission rules for a single tool category.
|
|
5357
5916
|
* Keys are glob patterns matching tool input (commands, file paths, etc.).
|
|
@@ -5372,7 +5931,7 @@ const PermissionRulesSchema = z.record(PermissionPatternSchema, PermissionAction
|
|
|
5372
5931
|
* @example
|
|
5373
5932
|
* { "claudecode": { "permission": { "bash": { "git push *": "deny" } } } }
|
|
5374
5933
|
*/
|
|
5375
|
-
const ToolScopedPermissionSchema = z.record(
|
|
5934
|
+
const ToolScopedPermissionSchema = z.record(PermissionCategorySchema, PermissionRulesSchema);
|
|
5376
5935
|
/**
|
|
5377
5936
|
* Generic tool-scoped override block for tools that have no tool-specific
|
|
5378
5937
|
* override keys of their own; it carries only the canonical tool-scoped
|
|
@@ -5430,7 +5989,7 @@ const OpencodeOverridePermissionValueSchema = z.union([PermissionActionSchema, P
|
|
|
5430
5989
|
* @example
|
|
5431
5990
|
* { "permission": { "external_directory": "deny", "webfetch": "allow" } }
|
|
5432
5991
|
*/
|
|
5433
|
-
const OpencodePermissionsOverrideSchema = z.looseObject({ permission: z.optional(z.record(
|
|
5992
|
+
const OpencodePermissionsOverrideSchema = z.looseObject({ permission: z.optional(z.record(PermissionCategorySchema, OpencodeOverridePermissionValueSchema)) });
|
|
5434
5993
|
/**
|
|
5435
5994
|
* Tool-scoped override block for Hermes Agent. Keys placed here are deep-merged
|
|
5436
5995
|
* into Hermes's `~/.hermes/config.yaml` and never leak into other tools' configs.
|
|
@@ -5490,7 +6049,7 @@ const ClinePermissionsOverrideSchema = z.looseObject({
|
|
|
5490
6049
|
* @see https://kilo.ai/docs/getting-started/settings/sandboxing
|
|
5491
6050
|
*/
|
|
5492
6051
|
const KiloPermissionsOverrideSchema = z.looseObject({
|
|
5493
|
-
permission: z.optional(z.record(
|
|
6052
|
+
permission: z.optional(z.record(PermissionCategorySchema, OpencodeOverridePermissionValueSchema)),
|
|
5494
6053
|
sandbox: z.optional(z.looseObject({}))
|
|
5495
6054
|
});
|
|
5496
6055
|
/**
|
|
@@ -5561,7 +6120,7 @@ const ClaudecodePermissionsOverrideSchema = z.looseObject({
|
|
|
5561
6120
|
* { "enabled_tools": ["bash", "read_file", "grep"] }
|
|
5562
6121
|
*/
|
|
5563
6122
|
const VibePermissionsOverrideSchema = z.looseObject({
|
|
5564
|
-
permission: z.optional(z.record(
|
|
6123
|
+
permission: z.optional(z.record(PermissionCategorySchema, z.looseObject({ sensitive_patterns: z.optional(z.array(z.string())) }))),
|
|
5565
6124
|
enabled_tools: z.optional(z.array(z.string()))
|
|
5566
6125
|
});
|
|
5567
6126
|
/**
|
|
@@ -6295,12 +6854,12 @@ const ZedPermissionsOverrideSchema = z.looseObject({
|
|
|
6295
6854
|
* Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
|
|
6296
6855
|
* Values are pattern-to-action mappings for that tool category.
|
|
6297
6856
|
*
|
|
6298
|
-
* The optional
|
|
6299
|
-
* `
|
|
6300
|
-
* `
|
|
6301
|
-
*
|
|
6302
|
-
*
|
|
6303
|
-
*
|
|
6857
|
+
* The optional tool keys below are tool-scoped overrides consumed only by their
|
|
6858
|
+
* respective translator (see the matching `*PermissionsOverrideSchema`); every
|
|
6859
|
+
* other tool reads the shared `permission` block and ignores them. The set of
|
|
6860
|
+
* keys is exactly `permissionsProcessorToolTargetTuple` mapped through
|
|
6861
|
+
* `PERMISSION_OVERRIDE_KEY_ALIASES` — a test asserts that, so this comment does
|
|
6862
|
+
* not enumerate them and go stale.
|
|
6304
6863
|
*
|
|
6305
6864
|
* Additionally, every permissions-capable tool accepts a canonical tool-scoped
|
|
6306
6865
|
* `permission` block under its override key (`{toolname}.permission`, same
|
|
@@ -6318,7 +6877,7 @@ const ZedPermissionsOverrideSchema = z.looseObject({
|
|
|
6318
6877
|
* }
|
|
6319
6878
|
*/
|
|
6320
6879
|
const PermissionsConfigSchema = z.looseObject({
|
|
6321
|
-
permission: z.record(
|
|
6880
|
+
permission: z.record(PermissionCategorySchema, PermissionRulesSchema),
|
|
6322
6881
|
opencode: z.optional(OpencodePermissionsOverrideSchema),
|
|
6323
6882
|
hermes: z.optional(HermesPermissionsOverrideSchema),
|
|
6324
6883
|
cline: z.optional(ClinePermissionsOverrideSchema),
|
|
@@ -6347,7 +6906,9 @@ const PermissionsConfigSchema = z.looseObject({
|
|
|
6347
6906
|
goose: z.optional(CanonicalPermissionsOverrideSchema),
|
|
6348
6907
|
grokcli: z.optional(CanonicalPermissionsOverrideSchema),
|
|
6349
6908
|
"kimi-code": z.optional(KimiCodePermissionsOverrideSchema),
|
|
6350
|
-
|
|
6909
|
+
roo: z.optional(CanonicalPermissionsOverrideSchema),
|
|
6910
|
+
rovodev: z.optional(CanonicalPermissionsOverrideSchema),
|
|
6911
|
+
zoocode: z.optional(CanonicalPermissionsOverrideSchema)
|
|
6351
6912
|
});
|
|
6352
6913
|
/**
|
|
6353
6914
|
* Full permissions file schema including optional $schema field.
|
|
@@ -6389,10 +6950,40 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
|
|
|
6389
6950
|
}]
|
|
6390
6951
|
};
|
|
6391
6952
|
}
|
|
6953
|
+
/**
|
|
6954
|
+
* The canonical document an importer produces from a tool's own config.
|
|
6955
|
+
*
|
|
6956
|
+
* Every importer has to run the blank-key filter over what it is about to
|
|
6957
|
+
* write: the canonical schema rejects a blank pattern and a blank category
|
|
6958
|
+
* outright, so a source file carrying either would be refused by the very
|
|
6959
|
+
* next `generate` — and that refusal takes the whole file with it, so one
|
|
6960
|
+
* blank key imported from one tool stops every tool's permissions from being
|
|
6961
|
+
* generated. Building the imported document here rather than calling the
|
|
6962
|
+
* filter beside each `new RulesyncPermissions(...)` is what keeps the next
|
|
6963
|
+
* importer from forgetting it.
|
|
6964
|
+
*
|
|
6965
|
+
* `sourcePath` is the tool config being read, used only to name it if
|
|
6966
|
+
* something is dropped.
|
|
6967
|
+
*/
|
|
6968
|
+
static fromImportedFileContent({ outputRoot, fileContent, sourcePath, logger }) {
|
|
6969
|
+
return new RulesyncPermissions({
|
|
6970
|
+
outputRoot,
|
|
6971
|
+
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
|
|
6972
|
+
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
|
|
6973
|
+
fileContent: withoutBlankPermissionKeys({
|
|
6974
|
+
fileContent,
|
|
6975
|
+
sourcePath,
|
|
6976
|
+
logger
|
|
6977
|
+
})
|
|
6978
|
+
});
|
|
6979
|
+
}
|
|
6392
6980
|
validate() {
|
|
6393
6981
|
if (this.droppedKeys.length > 0) return {
|
|
6394
6982
|
success: false,
|
|
6395
|
-
error:
|
|
6983
|
+
error: droppedPollutionKeysError({
|
|
6984
|
+
sourcePath: this.getRelativePathFromCwd(),
|
|
6985
|
+
droppedKeys: this.droppedKeys
|
|
6986
|
+
})
|
|
6396
6987
|
};
|
|
6397
6988
|
const result = RulesyncPermissionsFileSchema.safeParse(this.json);
|
|
6398
6989
|
if (!result.success) return {
|
|
@@ -6464,33 +7055,62 @@ var RulesyncPermissions = class RulesyncPermissions extends RulesyncFile {
|
|
|
6464
7055
|
}
|
|
6465
7056
|
};
|
|
6466
7057
|
/**
|
|
6467
|
-
*
|
|
6468
|
-
*
|
|
7058
|
+
* Tool-scoped override keys whose `permission` block maps a category to
|
|
7059
|
+
* something other than a pattern map. Vibe alone keeps
|
|
7060
|
+
* `{ sensitive_patterns: [...] }` objects there, so the keys one level down are
|
|
7061
|
+
* field names and the blank-pattern filter must not walk them. The category
|
|
7062
|
+
* names above them are still category names, and are filtered like any other.
|
|
7063
|
+
*
|
|
7064
|
+
* Typed as `ToolTarget` so a renamed target fails to compile here rather than
|
|
7065
|
+
* silently stopping to match, which would let the filter start deleting Vibe's
|
|
7066
|
+
* fields and reporting them as removed permission patterns. That only holds for
|
|
7067
|
+
* targets whose override key is the target name itself: a target that aliases
|
|
7068
|
+
* to another key (see `PERMISSION_OVERRIDE_KEY_ALIASES`) would have to be listed
|
|
7069
|
+
* under the alias, which this type would reject. None of them is non-pattern-map
|
|
7070
|
+
* today, so add that spelling only when one becomes so.
|
|
7071
|
+
*/
|
|
7072
|
+
const NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["vibe"]);
|
|
7073
|
+
/**
|
|
7074
|
+
* Strip every blank key — category or pattern — from an already-parsed
|
|
7075
|
+
* canonical document, reporting how many were dropped from each block.
|
|
6469
7076
|
*
|
|
6470
7077
|
* Both the shared `permission` block and every tool-scoped
|
|
6471
7078
|
* `{toolname}.permission` block are walked, because import produces both:
|
|
6472
7079
|
* OpenCode and Kilo route their tool-only categories into the tool-scoped block
|
|
6473
|
-
* verbatim, so a blank
|
|
6474
|
-
* whose value is not a rules map
|
|
6475
|
-
* the tool-native shapes intact — OpenCode's and Kilo's bare action
|
|
6476
|
-
* (`"external_directory": "deny"`) have no pattern key to inspect,
|
|
6477
|
-
*
|
|
6478
|
-
*
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
7080
|
+
* verbatim, so a blank key in the user's own config lands there. A category
|
|
7081
|
+
* whose value is not a rules map keeps its value exactly as it is, which is what
|
|
7082
|
+
* keeps the tool-native shapes intact — OpenCode's and Kilo's bare action
|
|
7083
|
+
* strings (`"external_directory": "deny"`) have no pattern key to inspect, and
|
|
7084
|
+
* Kilo's `sandbox` is not a `permission` block at all.
|
|
7085
|
+
*
|
|
7086
|
+
* Categories are filtered for the same reason patterns are, one level up: the
|
|
7087
|
+
* canonical schema rejects a blank category, so reproducing one would write a
|
|
7088
|
+
* source file the very next `generate` refuses — and it would refuse the whole
|
|
7089
|
+
* file, taking every tool's permissions generation down with it.
|
|
7090
|
+
*
|
|
7091
|
+
* The patterns inside {@link NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS} blocks
|
|
7092
|
+
* are left alone: they are field names rather than patterns there. Their
|
|
7093
|
+
* category names are still filtered.
|
|
7094
|
+
*/
|
|
7095
|
+
function stripBlankPermissionKeys(config) {
|
|
7096
|
+
const patterns = /* @__PURE__ */ new Map();
|
|
7097
|
+
const categories = /* @__PURE__ */ new Map();
|
|
7098
|
+
const filterBlock = ({ block, blockPath, filterPatterns }) => {
|
|
6483
7099
|
const filtered = {};
|
|
6484
7100
|
for (const [category, rules] of Object.entries(block)) {
|
|
6485
|
-
if (
|
|
7101
|
+
if (isBlankPermissionKey(category)) {
|
|
7102
|
+
categories.set(blockPath, (categories.get(blockPath) ?? 0) + 1);
|
|
7103
|
+
continue;
|
|
7104
|
+
}
|
|
7105
|
+
if (!filterPatterns || !isRecord$1(rules)) {
|
|
6486
7106
|
filtered[category] = rules;
|
|
6487
7107
|
continue;
|
|
6488
7108
|
}
|
|
6489
7109
|
const kept = {};
|
|
6490
7110
|
for (const [pattern, action] of Object.entries(rules)) {
|
|
6491
|
-
if (
|
|
7111
|
+
if (isBlankPermissionKey(pattern)) {
|
|
6492
7112
|
const path = `${blockPath}.${category}`;
|
|
6493
|
-
|
|
7113
|
+
patterns.set(path, (patterns.get(path) ?? 0) + 1);
|
|
6494
7114
|
continue;
|
|
6495
7115
|
}
|
|
6496
7116
|
kept[pattern] = action;
|
|
@@ -6503,25 +7123,41 @@ function stripBlankPermissionPatterns(config) {
|
|
|
6503
7123
|
const next = { ...config };
|
|
6504
7124
|
if (isRecord$1(config.permission)) next.permission = filterBlock({
|
|
6505
7125
|
block: config.permission,
|
|
6506
|
-
blockPath: "permission"
|
|
7126
|
+
blockPath: "permission",
|
|
7127
|
+
filterPatterns: true
|
|
6507
7128
|
});
|
|
6508
7129
|
for (const [key, value] of Object.entries(config)) {
|
|
6509
7130
|
if (key === "permission" || !isRecord$1(value) || !isRecord$1(value.permission)) continue;
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
7131
|
+
const permission = filterBlock({
|
|
7132
|
+
block: value.permission,
|
|
7133
|
+
blockPath: `${key}.permission`,
|
|
7134
|
+
filterPatterns: !NON_PATTERN_MAP_PERMISSION_OVERRIDE_KEYS.has(key)
|
|
7135
|
+
});
|
|
7136
|
+
if (!(Object.keys(permission).length === 0 && Object.keys(value.permission).length > 0)) {
|
|
7137
|
+
next[key] = {
|
|
7138
|
+
...value,
|
|
7139
|
+
permission
|
|
7140
|
+
};
|
|
7141
|
+
continue;
|
|
7142
|
+
}
|
|
7143
|
+
const { permission: _emptied, ...rest } = value;
|
|
7144
|
+
if (Object.keys(rest).length === 0) {
|
|
7145
|
+
delete next[key];
|
|
7146
|
+
continue;
|
|
7147
|
+
}
|
|
7148
|
+
next[key] = rest;
|
|
6517
7149
|
}
|
|
6518
7150
|
return {
|
|
6519
7151
|
config: next,
|
|
6520
|
-
removed
|
|
7152
|
+
removed: {
|
|
7153
|
+
patterns,
|
|
7154
|
+
categories
|
|
7155
|
+
}
|
|
6521
7156
|
};
|
|
6522
7157
|
}
|
|
7158
|
+
const summarizeDroppedCounts = (counts) => [...counts.entries()].map(([path, count]) => `${count} in ${JSON.stringify(path)}`).join(", ");
|
|
6523
7159
|
/**
|
|
6524
|
-
* Report the dropped
|
|
7160
|
+
* Report the dropped keys.
|
|
6525
7161
|
*
|
|
6526
7162
|
* Dropping an entry silently is the failure mode a permissions source must not
|
|
6527
7163
|
* have. A blanket blank pattern can read as "deny everything by default";
|
|
@@ -6532,27 +7168,36 @@ function stripBlankPermissionPatterns(config) {
|
|
|
6532
7168
|
* `logger` is optional because the import direction (`toRulesyncPermissions`)
|
|
6533
7169
|
* takes no logger parameter; the shared `fallbackLogger` is configured from the
|
|
6534
7170
|
* CLI flags and the resolved config, so `silent` is still honored.
|
|
7171
|
+
*
|
|
7172
|
+
* `sourcePath` names the tool config the keys came out of. A single import run
|
|
7173
|
+
* reads many tools, and the block paths alone (`permission.bash`) are the same
|
|
7174
|
+
* for all of them, so without it the user is told something was dropped but not
|
|
7175
|
+
* from where.
|
|
6535
7176
|
*/
|
|
6536
|
-
function
|
|
6537
|
-
|
|
7177
|
+
function warnAboutDroppedKeys({ removed, sourcePath, logger }) {
|
|
7178
|
+
const source = sourcePath === void 0 ? "a tool's permission configuration" : JSON.stringify(sourcePath);
|
|
7179
|
+
if (removed.patterns.size > 0) warnWithFallback(logger, `Dropped blank permission patterns while reading ${source} (${summarizeDroppedCounts(removed.patterns)}). An empty or whitespace-only pattern matches everything, and tools disagree on what it means — some apply it to every command, others ignore it entirely — so it is not carried into the rulesync permissions config. If one of them was a blanket deny, the imported configuration now allows more than the file it came from; re-add it with a real pattern.`);
|
|
7180
|
+
if (removed.categories.size > 0) warnWithFallback(logger, `Dropped blank permission categories while reading ${source} (${summarizeDroppedCounts(removed.categories)}). A category name is how every tool finds the rules underneath it, so an empty or whitespace-only one reaches no tool at all and the rules below it were never going to be generated. Carrying one into the rulesync permissions config would make the next generate refuse the whole file, so it is removed here; re-add those rules under a real category name.`);
|
|
6538
7181
|
}
|
|
6539
7182
|
/**
|
|
6540
|
-
* Drop blank permission
|
|
7183
|
+
* Drop blank permission keys from a canonical document produced by import.
|
|
6541
7184
|
*
|
|
6542
|
-
* The canonical schema rejects a blank pattern
|
|
6543
|
-
* has
|
|
6544
|
-
* pattern (Roo Code, for instance, keeps only
|
|
6545
|
-
* `cmd.trim().length > 0`). Reproducing
|
|
6546
|
-
* would therefore write a source file that the
|
|
6547
|
-
*
|
|
7185
|
+
* The canonical schema rejects a blank pattern and a blank category outright,
|
|
7186
|
+
* and every tool that has a blank pattern in its own config already treats it as
|
|
7187
|
+
* something other than a real pattern (Roo Code, for instance, keeps only
|
|
7188
|
+
* entries passing `cmd.trim().length > 0`). Reproducing either in
|
|
7189
|
+
* `.rulesync/permissions.jsonc` would therefore write a source file that the
|
|
7190
|
+
* very next `generate` refuses — the whole file, not just that entry — so they
|
|
7191
|
+
* are removed here instead, and reported.
|
|
6548
7192
|
*/
|
|
6549
|
-
function
|
|
7193
|
+
function withoutBlankPermissionKeys({ fileContent, sourcePath, logger }) {
|
|
6550
7194
|
const parsed = parseJsonc(fileContent);
|
|
6551
7195
|
if (!isRecord$1(parsed)) return fileContent;
|
|
6552
|
-
const { config, removed } =
|
|
6553
|
-
if (removed.size === 0) return fileContent;
|
|
6554
|
-
|
|
7196
|
+
const { config, removed } = stripBlankPermissionKeys(parsed);
|
|
7197
|
+
if (removed.patterns.size === 0 && removed.categories.size === 0) return fileContent;
|
|
7198
|
+
warnAboutDroppedKeys({
|
|
6555
7199
|
removed,
|
|
7200
|
+
sourcePath,
|
|
6556
7201
|
logger
|
|
6557
7202
|
});
|
|
6558
7203
|
return JSON.stringify(config, null, 2);
|
|
@@ -6561,14 +7206,15 @@ function withoutBlankPermissionPatterns({ fileContent, logger }) {
|
|
|
6561
7206
|
* The same filter over an already-parsed document, for callers that validate a
|
|
6562
7207
|
* canonical block before it is ever serialized. Hermes Agent stores its
|
|
6563
7208
|
* rulesync provenance inside its own config and parses it back on import; left
|
|
6564
|
-
* unfiltered, one blank pattern would fail `safeParse` and discard
|
|
6565
|
-
* provenance block without a word.
|
|
7209
|
+
* unfiltered, one blank pattern or category would fail `safeParse` and discard
|
|
7210
|
+
* the entire provenance block without a word.
|
|
6566
7211
|
*/
|
|
6567
|
-
function
|
|
6568
|
-
const { config: filtered, removed } =
|
|
6569
|
-
if (removed.size === 0) return config;
|
|
6570
|
-
|
|
7212
|
+
function withoutBlankPermissionKeysIn({ config, sourcePath, logger }) {
|
|
7213
|
+
const { config: filtered, removed } = stripBlankPermissionKeys(config);
|
|
7214
|
+
if (removed.patterns.size === 0 && removed.categories.size === 0) return config;
|
|
7215
|
+
warnAboutDroppedKeys({
|
|
6571
7216
|
removed,
|
|
7217
|
+
sourcePath,
|
|
6572
7218
|
logger
|
|
6573
7219
|
});
|
|
6574
7220
|
return filtered;
|
|
@@ -6717,6 +7363,24 @@ var RulesyncRule = class RulesyncRule extends RulesyncFile {
|
|
|
6717
7363
|
//#endregion
|
|
6718
7364
|
//#region src/types/ai-dir.ts
|
|
6719
7365
|
/**
|
|
7366
|
+
* Whether `name` is a path rather than a single name.
|
|
7367
|
+
*
|
|
7368
|
+
* Both separators are rejected on every platform, which is why neither
|
|
7369
|
+
* `path.sep` nor the platform is consulted: a backslash is a legal character in
|
|
7370
|
+
* a POSIX name, but a name carrying one is a path the moment it reaches
|
|
7371
|
+
* Windows, and `AiDir` names travel between the two — most tools take one
|
|
7372
|
+
* straight from a skill's frontmatter.
|
|
7373
|
+
*
|
|
7374
|
+
* Exported because the checks that run *before* a name reaches `AiDir` — so
|
|
7375
|
+
* that an unusable name is reported and skipped rather than thrown over — have
|
|
7376
|
+
* to reject exactly the set this guard does. Two spellings of one rule drift
|
|
7377
|
+
* apart the moment either is tightened, and the pre-filter drifting narrower
|
|
7378
|
+
* turns a reported name back into a failed run.
|
|
7379
|
+
*/
|
|
7380
|
+
function containsPathSeparator(name) {
|
|
7381
|
+
return name.includes("/") || name.includes("\\");
|
|
7382
|
+
}
|
|
7383
|
+
/**
|
|
6720
7384
|
* Directories that hold credentials. Excluding these protects something, so
|
|
6721
7385
|
* their exclusion is reported rather than silent.
|
|
6722
7386
|
*/
|
|
@@ -7176,7 +7840,7 @@ var AiDir = class AiDir {
|
|
|
7176
7840
|
*/
|
|
7177
7841
|
global;
|
|
7178
7842
|
constructor({ outputRoot = process.cwd(), relativeDirPath, dirName, mainFile, otherFiles = [], global = false }) {
|
|
7179
|
-
if (
|
|
7843
|
+
if (containsPathSeparator(dirName)) throw new Error(`Directory name cannot contain path separators: dirName="${dirName}"`);
|
|
7180
7844
|
if (dirName === "" || dirName === "." || dirName === "..") throw new Error(`Directory name cannot be empty, ".", or "..": dirName="${dirName}"`);
|
|
7181
7845
|
this.outputRoot = outputRoot;
|
|
7182
7846
|
this.relativeDirPath = relativeDirPath;
|
|
@@ -7237,7 +7901,7 @@ var AiDir = class AiDir {
|
|
|
7237
7901
|
const mainFile = this.getMainFile();
|
|
7238
7902
|
if (mainFile === void 0) return;
|
|
7239
7903
|
const name = mainFile.name;
|
|
7240
|
-
if (name === "" || name === "." || name === ".." ||
|
|
7904
|
+
if (name === "" || name === "." || name === ".." || containsPathSeparator(name)) return;
|
|
7241
7905
|
return path.join(this.getDirPath(), name);
|
|
7242
7906
|
}
|
|
7243
7907
|
getDirPath() {
|
|
@@ -7568,7 +8232,7 @@ var AiDir = class AiDir {
|
|
|
7568
8232
|
* differ from the path the run claims, which turns it into an orphan.
|
|
7569
8233
|
*/
|
|
7570
8234
|
function isUnsafeSkillDirName(name) {
|
|
7571
|
-
return name === "" || name === "." || name === ".." ||
|
|
8235
|
+
return name === "" || name === "." || name === ".." || containsPathSeparator(name) || name.endsWith(".") || name.endsWith(" ");
|
|
7572
8236
|
}
|
|
7573
8237
|
const RulesyncSkillFrontmatterSchema = z.looseObject({
|
|
7574
8238
|
name: z.string().check(z.refine((name) => !isUnsafeSkillDirName(name), { message: "name is used as a directory name: it may not be empty, \".\" or \"..\", contain a path separator, or end with a dot or a space" })),
|
|
@@ -7828,6 +8492,16 @@ const RulesyncSubagentFrontmatterSchema = z.looseObject({
|
|
|
7828
8492
|
zoocode: z.optional(z.looseObject({
|
|
7829
8493
|
/** Per-mode MCP server allowlist (Zoo Code v3.60.0+); omitted = all. */
|
|
7830
8494
|
allowedMcpServers: z.optional(z.array(z.string())) })),
|
|
8495
|
+
zcode: z.optional(z.looseObject({
|
|
8496
|
+
model: z.optional(z.string()),
|
|
8497
|
+
thoughtLevel: z.optional(z.string()),
|
|
8498
|
+
color: z.optional(z.string()),
|
|
8499
|
+
tools: z.optional(z.array(z.string())),
|
|
8500
|
+
disallowedTools: z.optional(z.array(z.string())),
|
|
8501
|
+
maxTurns: z.optional(z.number().check(z.int(), z.positive())),
|
|
8502
|
+
injectAgentsMd: z.optional(z.boolean()),
|
|
8503
|
+
mcpServers: z.optional(z.array(z.string()))
|
|
8504
|
+
})),
|
|
7831
8505
|
vibe: z.optional(z.looseObject({
|
|
7832
8506
|
agent_type: z.optional(z.enum(["agent", "subagent"])),
|
|
7833
8507
|
display_name: z.optional(z.string()),
|
|
@@ -7915,10 +8589,12 @@ var RulesyncSubagent = class RulesyncSubagent extends RulesyncFile {
|
|
|
7915
8589
|
* lets a directory be *created* with a backslash in its name, so such a
|
|
7916
8590
|
* directory can sit in a skills root that nothing here can name — it is
|
|
7917
8591
|
* reported rather than passed on, since the alternative is a candidate built
|
|
7918
|
-
* from a name that belongs to no directory at all.
|
|
8592
|
+
* from a name that belongs to no directory at all. The test is `AiDir`'s own,
|
|
8593
|
+
* so this pre-filter cannot come to accept a name the guard behind it throws
|
|
8594
|
+
* over.
|
|
7919
8595
|
*/
|
|
7920
8596
|
function isAddressableSkillName(name) {
|
|
7921
|
-
return !
|
|
8597
|
+
return !containsPathSeparator(name);
|
|
7922
8598
|
}
|
|
7923
8599
|
/**
|
|
7924
8600
|
* The names of the skill directories directly under `skillsRoot`.
|
|
@@ -9309,18 +9985,168 @@ function splitCheckFile({ fileContent, fallbackName }) {
|
|
|
9309
9985
|
});
|
|
9310
9986
|
}
|
|
9311
9987
|
//#endregion
|
|
9988
|
+
//#region src/features/checks/aggregated-tool-check.ts
|
|
9989
|
+
/**
|
|
9990
|
+
* Shared skeleton for the checks adapters whose output is a single aggregated
|
|
9991
|
+
* instruction file — one file whose sections are the marked blocks
|
|
9992
|
+
* `aggregated-check-file.ts` renders and splits, rather than a directory with a
|
|
9993
|
+
* file per check.
|
|
9994
|
+
*
|
|
9995
|
+
* That module already held the rendering and the splitting; what is here is the
|
|
9996
|
+
* adapter around it, which was near-verbatim in Cursor Bugbot, Rovo Dev and
|
|
9997
|
+
* Factory Droid. A subclass supplies {@link ToolCheck.getSettablePaths} and
|
|
9998
|
+
* {@link getAggregatedCheckConfig}, and inherits the rest — so a fix to how
|
|
9999
|
+
* these files are read or written lands once instead of three times.
|
|
10000
|
+
*
|
|
10001
|
+
* `fromRulesyncCheck` is refused here rather than implemented: sections share
|
|
10002
|
+
* one file, so an output cannot be produced from one check in isolation and the
|
|
10003
|
+
* processor calls {@link fromRulesyncChecks} instead.
|
|
10004
|
+
*
|
|
10005
|
+
* Not declared `abstract`, even though nothing instantiates it directly: the
|
|
10006
|
+
* statics below build the subclass with `new this(...)`, which an abstract
|
|
10007
|
+
* constructor type forbids. What a subclass owes is enforced the way the rest
|
|
10008
|
+
* of this codebase enforces it on a static — {@link getAggregatedCheckConfig}
|
|
10009
|
+
* throws until it is overridden.
|
|
10010
|
+
*/
|
|
10011
|
+
var AggregatedToolCheck = class extends ToolCheck {
|
|
10012
|
+
/**
|
|
10013
|
+
* The per-tool values the shared skeleton reads. Thrown rather than abstract
|
|
10014
|
+
* because TypeScript has no abstract statics; a subclass that forgets it
|
|
10015
|
+
* fails on its first use rather than silently taking a default.
|
|
10016
|
+
*/
|
|
10017
|
+
static getAggregatedCheckConfig() {
|
|
10018
|
+
throw new Error("Please implement this method in the subclass.");
|
|
10019
|
+
}
|
|
10020
|
+
/**
|
|
10021
|
+
* The settable paths with the file name required. An aggregated adapter names
|
|
10022
|
+
* the one file it writes — that is what keeps consumers which would otherwise
|
|
10023
|
+
* claim the whole tool directory, the gitignore derivation among them,
|
|
10024
|
+
* narrowed to it — so a missing name is a mistake in the subclass rather than
|
|
10025
|
+
* a case to fall back for.
|
|
10026
|
+
*/
|
|
10027
|
+
static getAggregatedPaths({ global = false } = {}) {
|
|
10028
|
+
const paths = this.getSettablePaths({ global });
|
|
10029
|
+
if (!paths.relativeFilePath) throw new Error(`${this.name} writes one aggregated file, so getSettablePaths must name it.`);
|
|
10030
|
+
return {
|
|
10031
|
+
relativeDirPath: paths.relativeDirPath,
|
|
10032
|
+
relativeFilePath: paths.relativeFilePath
|
|
10033
|
+
};
|
|
10034
|
+
}
|
|
10035
|
+
static getAggregatedFilePath({ outputRoot, global = false }) {
|
|
10036
|
+
const paths = this.getAggregatedPaths({ global });
|
|
10037
|
+
return {
|
|
10038
|
+
...paths,
|
|
10039
|
+
filePath: join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)
|
|
10040
|
+
};
|
|
10041
|
+
}
|
|
10042
|
+
static isTargetedByRulesyncCheck(rulesyncCheck) {
|
|
10043
|
+
return this.isTargetedByRulesyncCheckDefault({
|
|
10044
|
+
rulesyncCheck,
|
|
10045
|
+
toolTarget: this.getAggregatedCheckConfig().toolTarget
|
|
10046
|
+
});
|
|
10047
|
+
}
|
|
10048
|
+
/**
|
|
10049
|
+
* Ownership guard the processor consults before it deletes anything for this
|
|
10050
|
+
* tool. Every one of these paths is one a user may well have written by hand,
|
|
10051
|
+
* whether because the tool documents it as the place to write review
|
|
10052
|
+
* instructions or because it is shared with something else the tool loads, so
|
|
10053
|
+
* anything in it that rulesync did not write is not rulesync's to remove — dropping the last check targeting the tool must not
|
|
10054
|
+
* take somebody's review instructions with it. Deletion is therefore allowed
|
|
10055
|
+
* only for a file that is nothing but generated sections: one that carries no
|
|
10056
|
+
* marker at all, or that carries hand-written text ahead of the first marker,
|
|
10057
|
+
* stays.
|
|
10058
|
+
*/
|
|
10059
|
+
static async canDeleteAuxiliaryFiles({ outputRoot }) {
|
|
10060
|
+
const { filePath } = this.getAggregatedFilePath({ outputRoot });
|
|
10061
|
+
const fileContent = await readFileContentOrNull(filePath);
|
|
10062
|
+
if (fileContent === null) return true;
|
|
10063
|
+
return isOnlyGeneratedSections(fileContent);
|
|
10064
|
+
}
|
|
10065
|
+
static fromRulesyncCheck(_params) {
|
|
10066
|
+
const { displayName } = this.getAggregatedCheckConfig();
|
|
10067
|
+
throw new Error(`${displayName} checks are built from all checks at once; use fromRulesyncChecks.`);
|
|
10068
|
+
}
|
|
10069
|
+
static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
|
|
10070
|
+
if (rulesyncChecks.length === 0) return [];
|
|
10071
|
+
const config = this.getAggregatedCheckConfig();
|
|
10072
|
+
const { relativeDirPath, relativeFilePath, filePath } = this.getAggregatedFilePath({
|
|
10073
|
+
outputRoot,
|
|
10074
|
+
global
|
|
10075
|
+
});
|
|
10076
|
+
if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) {
|
|
10077
|
+
if (config.handWrittenPreamble === "skip") {
|
|
10078
|
+
logger?.warn(config.handWrittenWarning({
|
|
10079
|
+
filePath,
|
|
10080
|
+
displayName: config.displayName,
|
|
10081
|
+
toolTarget: config.toolTarget
|
|
10082
|
+
}));
|
|
10083
|
+
return [];
|
|
10084
|
+
}
|
|
10085
|
+
logger?.warn(`${config.displayName} checks: ${filePath} holds instructions rulesync did not write, and generating replaces the whole file. Run \`rulesync import --targets ${config.toolTarget} --features checks\` first to keep them.`);
|
|
10086
|
+
}
|
|
10087
|
+
return [new this({
|
|
10088
|
+
outputRoot,
|
|
10089
|
+
relativeDirPath,
|
|
10090
|
+
relativeFilePath,
|
|
10091
|
+
fileContent: renderCheckFile(rulesyncChecks),
|
|
10092
|
+
global
|
|
10093
|
+
})];
|
|
10094
|
+
}
|
|
10095
|
+
static async fromFile({ outputRoot = process.cwd(), global = false }) {
|
|
10096
|
+
const { relativeDirPath, relativeFilePath, filePath } = this.getAggregatedFilePath({
|
|
10097
|
+
outputRoot,
|
|
10098
|
+
global
|
|
10099
|
+
});
|
|
10100
|
+
return new this({
|
|
10101
|
+
outputRoot,
|
|
10102
|
+
relativeDirPath,
|
|
10103
|
+
relativeFilePath,
|
|
10104
|
+
fileContent: await readFileContentOrNull(filePath) ?? "",
|
|
10105
|
+
global
|
|
10106
|
+
});
|
|
10107
|
+
}
|
|
10108
|
+
static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
|
|
10109
|
+
return new this({
|
|
10110
|
+
outputRoot,
|
|
10111
|
+
relativeDirPath,
|
|
10112
|
+
relativeFilePath,
|
|
10113
|
+
fileContent: "",
|
|
10114
|
+
validate: false,
|
|
10115
|
+
global
|
|
10116
|
+
});
|
|
10117
|
+
}
|
|
10118
|
+
validate() {
|
|
10119
|
+
return {
|
|
10120
|
+
success: true,
|
|
10121
|
+
error: null
|
|
10122
|
+
};
|
|
10123
|
+
}
|
|
10124
|
+
toRulesyncCheck() {
|
|
10125
|
+
const first = this.toRulesyncChecks()[0];
|
|
10126
|
+
if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
|
|
10127
|
+
return first;
|
|
10128
|
+
}
|
|
10129
|
+
toRulesyncChecks() {
|
|
10130
|
+
const config = this.constructor.getAggregatedCheckConfig();
|
|
10131
|
+
const fileContent = this.getFileContent();
|
|
10132
|
+
return splitCheckFile({
|
|
10133
|
+
fileContent: config.transformImportedContent?.(fileContent) ?? fileContent,
|
|
10134
|
+
fallbackName: config.fallbackCheckName
|
|
10135
|
+
});
|
|
10136
|
+
}
|
|
10137
|
+
};
|
|
10138
|
+
//#endregion
|
|
9312
10139
|
//#region src/features/checks/cursor-check.ts
|
|
9313
|
-
const FALLBACK_CHECK_NAME$2 = "bugbot";
|
|
9314
10140
|
/**
|
|
9315
10141
|
* Checks adapter for Cursor Bugbot (`.cursor/BUGBOT.md`).
|
|
9316
10142
|
*
|
|
9317
10143
|
* Bugbot takes one aggregated instruction file per directory rather than a file
|
|
9318
10144
|
* per check, so every `.rulesync/checks/*.md` targeting Cursor collapses into
|
|
9319
|
-
* the repository-root `.cursor/BUGBOT.md` — hence
|
|
9320
|
-
*
|
|
9321
|
-
* an HTML-comment marker carrying the
|
|
9322
|
-
*
|
|
9323
|
-
* body is empty).
|
|
10145
|
+
* the repository-root `.cursor/BUGBOT.md` — hence `fromRulesyncChecks` rather
|
|
10146
|
+
* than the usual per-check conversion, which {@link AggregatedToolCheck}
|
|
10147
|
+
* provides. Each check becomes one section: an HTML-comment marker carrying the
|
|
10148
|
+
* check name, an `## <name>` heading, and the check body as the instruction
|
|
10149
|
+
* text (the `description` is used when the body is empty).
|
|
9324
10150
|
*
|
|
9325
10151
|
* Bugbot reads the file as free prose, so a check's `severity` and `tools` have
|
|
9326
10152
|
* no equivalent there: they are not written and do not come back on import. So
|
|
@@ -9336,97 +10162,26 @@ const FALLBACK_CHECK_NAME$2 = "bugbot";
|
|
|
9336
10162
|
* before the first marker — and a hand-written file with no markers at all —
|
|
9337
10163
|
* becomes a single `bugbot` check, so nothing in the file is dropped. A file
|
|
9338
10164
|
* holding anything rulesync did not write is never deleted either (see
|
|
9339
|
-
*
|
|
9340
|
-
* replace it — import first to keep what is there, which is warned about.
|
|
10165
|
+
* `canDeleteAuxiliaryFiles` on the base), though generating checks for Cursor
|
|
10166
|
+
* does replace it — import first to keep what is there, which is warned about.
|
|
9341
10167
|
*
|
|
9342
10168
|
* @see https://cursor.com/docs/bugbot
|
|
9343
10169
|
*/
|
|
9344
|
-
var CursorCheck = class
|
|
10170
|
+
var CursorCheck = class extends AggregatedToolCheck {
|
|
9345
10171
|
static getSettablePaths(_options = {}) {
|
|
9346
10172
|
return {
|
|
9347
10173
|
relativeDirPath: CURSOR_DIR,
|
|
9348
10174
|
relativeFilePath: CURSOR_BUGBOT_FILE_NAME
|
|
9349
10175
|
};
|
|
9350
10176
|
}
|
|
9351
|
-
static
|
|
9352
|
-
return this.isTargetedByRulesyncCheckDefault({
|
|
9353
|
-
rulesyncCheck,
|
|
9354
|
-
toolTarget: "cursor"
|
|
9355
|
-
});
|
|
9356
|
-
}
|
|
9357
|
-
/**
|
|
9358
|
-
* Ownership guard the processor consults before it deletes anything for this
|
|
9359
|
-
* tool. `.cursor/BUGBOT.md` is a file Cursor's own documentation tells users
|
|
9360
|
-
* to hand-write, so anything in it that rulesync did not write is not
|
|
9361
|
-
* rulesync's to remove — dropping the last check targeting Cursor must not
|
|
9362
|
-
* take somebody's hand-written review instructions with it. Deletion is
|
|
9363
|
-
* therefore allowed only for a file that is nothing but generated sections:
|
|
9364
|
-
* one that carries no marker at all, or that carries hand-written text ahead
|
|
9365
|
-
* of the first marker, stays.
|
|
9366
|
-
*/
|
|
9367
|
-
static async canDeleteAuxiliaryFiles({ outputRoot }) {
|
|
9368
|
-
const paths = CursorCheck.getSettablePaths();
|
|
9369
|
-
const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? "BUGBOT.md"));
|
|
9370
|
-
if (fileContent === null) return true;
|
|
9371
|
-
return isOnlyGeneratedSections(fileContent);
|
|
9372
|
-
}
|
|
9373
|
-
static fromRulesyncCheck(_params) {
|
|
9374
|
-
throw new Error("Cursor checks are built from all checks at once; use fromRulesyncChecks.");
|
|
9375
|
-
}
|
|
9376
|
-
static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
|
|
9377
|
-
if (rulesyncChecks.length === 0) return [];
|
|
9378
|
-
const paths = CursorCheck.getSettablePaths({ global });
|
|
9379
|
-
const relativeFilePath = paths.relativeFilePath ?? "BUGBOT.md";
|
|
9380
|
-
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
9381
|
-
if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) logger?.warn(`Cursor checks: ${filePath} holds instructions rulesync did not write, and generating replaces the whole file. Run \`rulesync import --targets cursor --features checks\` first to keep them.`);
|
|
9382
|
-
const fileContent = renderCheckFile(rulesyncChecks);
|
|
9383
|
-
return [new CursorCheck({
|
|
9384
|
-
outputRoot,
|
|
9385
|
-
relativeDirPath: paths.relativeDirPath,
|
|
9386
|
-
relativeFilePath,
|
|
9387
|
-
fileContent,
|
|
9388
|
-
global
|
|
9389
|
-
})];
|
|
9390
|
-
}
|
|
9391
|
-
static async fromFile({ outputRoot = process.cwd(), global = false }) {
|
|
9392
|
-
const paths = CursorCheck.getSettablePaths({ global });
|
|
9393
|
-
const relativeFilePath = paths.relativeFilePath ?? "BUGBOT.md";
|
|
9394
|
-
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
9395
|
-
return new CursorCheck({
|
|
9396
|
-
outputRoot,
|
|
9397
|
-
relativeDirPath: paths.relativeDirPath,
|
|
9398
|
-
relativeFilePath,
|
|
9399
|
-
fileContent: await readFileContentOrNull(filePath) ?? "",
|
|
9400
|
-
global
|
|
9401
|
-
});
|
|
9402
|
-
}
|
|
9403
|
-
static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
|
|
9404
|
-
return new CursorCheck({
|
|
9405
|
-
outputRoot,
|
|
9406
|
-
relativeDirPath,
|
|
9407
|
-
relativeFilePath,
|
|
9408
|
-
fileContent: "",
|
|
9409
|
-
validate: false,
|
|
9410
|
-
global
|
|
9411
|
-
});
|
|
9412
|
-
}
|
|
9413
|
-
validate() {
|
|
10177
|
+
static getAggregatedCheckConfig() {
|
|
9414
10178
|
return {
|
|
9415
|
-
|
|
9416
|
-
|
|
10179
|
+
displayName: "Cursor",
|
|
10180
|
+
toolTarget: "cursor",
|
|
10181
|
+
fallbackCheckName: "bugbot",
|
|
10182
|
+
handWrittenPreamble: "replace"
|
|
9417
10183
|
};
|
|
9418
10184
|
}
|
|
9419
|
-
toRulesyncCheck() {
|
|
9420
|
-
const first = this.toRulesyncChecks()[0];
|
|
9421
|
-
if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
|
|
9422
|
-
return first;
|
|
9423
|
-
}
|
|
9424
|
-
toRulesyncChecks() {
|
|
9425
|
-
return splitCheckFile({
|
|
9426
|
-
fileContent: this.getFileContent(),
|
|
9427
|
-
fallbackName: FALLBACK_CHECK_NAME$2
|
|
9428
|
-
});
|
|
9429
|
-
}
|
|
9430
10185
|
};
|
|
9431
10186
|
//#endregion
|
|
9432
10187
|
//#region src/constants/factorydroid-paths.ts
|
|
@@ -9463,7 +10218,6 @@ const FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME = "review-guidelines";
|
|
|
9463
10218
|
const FACTORYDROID_REVIEW_GUIDELINES_DIR_PATH = join(FACTORYDROID_SKILLS_DIR_PATH, FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME);
|
|
9464
10219
|
//#endregion
|
|
9465
10220
|
//#region src/features/checks/factorydroid-check.ts
|
|
9466
|
-
const FALLBACK_CHECK_NAME$1 = FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME;
|
|
9467
10221
|
/**
|
|
9468
10222
|
* Drop the YAML frontmatter of a hand-authored `review-guidelines` skill before
|
|
9469
10223
|
* the file is split into checks.
|
|
@@ -9498,6 +10252,9 @@ function stripSkillFrontmatter(fileContent) {
|
|
|
9498
10252
|
content = rest.slice(closing.index + closing[0].length).replace(LEADING_BLANK_LINE_PATTERN, "");
|
|
9499
10253
|
}
|
|
9500
10254
|
}
|
|
10255
|
+
function handWrittenWarning({ filePath, displayName, toolTarget }) {
|
|
10256
|
+
return `${displayName} checks: ${filePath} holds instructions rulesync did not write, so it is left as it is and no checks were generated for ${displayName}. Run \`rulesync import --targets ${toolTarget} --features checks\` to bring them into \`.rulesync/checks/\` and then delete the file, so the next generate writes it back from there; delete it outright if you no longer want it, or rename the directory if it is an ordinary skill rather than review guidelines. Importing alone leaves this file as it is, so it keeps blocking generation until it is gone. A rulesync skill named \`${FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME}\` is no longer generated here — Factory's reviewer reads this path, so the checks feature owns it — but a directory an older rulesync wrote can still be sitting there.`;
|
|
10257
|
+
}
|
|
9501
10258
|
/**
|
|
9502
10259
|
* Checks adapter for Factory Droid's code-review guidelines
|
|
9503
10260
|
* (`.factory/skills/review-guidelines/SKILL.md`).
|
|
@@ -9506,9 +10263,10 @@ function stripSkillFrontmatter(fileContent) {
|
|
|
9506
10263
|
* "repository-specific review guidelines" from a skill named
|
|
9507
10264
|
* `review-guidelines` and injects them into every review run. That makes the
|
|
9508
10265
|
* output a single aggregated file like Cursor Bugbot's and Rovo Dev's, so every
|
|
9509
|
-
* `.rulesync/checks/*.md` targeting Factory Droid collapses into it via
|
|
9510
|
-
* {@link
|
|
9511
|
-
* `aggregated-check-file.ts` for the marker convention the
|
|
10266
|
+
* `.rulesync/checks/*.md` targeting Factory Droid collapses into it via the
|
|
10267
|
+
* `fromRulesyncChecks` on {@link AggregatedToolCheck}, each check written as a
|
|
10268
|
+
* marked section (see `aggregated-check-file.ts` for the marker convention the
|
|
10269
|
+
* three aggregated adapters share).
|
|
9512
10270
|
*
|
|
9513
10271
|
* The file is plain Markdown with no frontmatter, matching Factory's documented
|
|
9514
10272
|
* example. Frontmatter would also be self-defeating here: `renderCheckFile`
|
|
@@ -9526,8 +10284,9 @@ function stripSkillFrontmatter(fileContent) {
|
|
|
9526
10284
|
* The output lives inside the same `.factory/skills/` tree the `skills` feature
|
|
9527
10285
|
* writes, so a user-authored `review-guidelines` skill collides with it. The
|
|
9528
10286
|
* path has one owner rather than a merge rule, and the owner is this feature:
|
|
9529
|
-
*
|
|
9530
|
-
*
|
|
10287
|
+
* generating leaves a file holding anything rulesync did not write untouched —
|
|
10288
|
+
* the `skip` policy below — and the base's `canDeleteAuxiliaryFiles` refuses to
|
|
10289
|
+
* remove one.
|
|
9531
10290
|
* Their content is somebody's own writing and rulesync cannot reconstruct it,
|
|
9532
10291
|
* so neither direction guesses. That is stricter than Cursor Bugbot's
|
|
9533
10292
|
* replace-and-warn, and deliberately: `.cursor/BUGBOT.md` is a path only the
|
|
@@ -9537,92 +10296,23 @@ function stripSkillFrontmatter(fileContent) {
|
|
|
9537
10296
|
*
|
|
9538
10297
|
* @see https://docs.factory.ai/software-factory/code-review-ci
|
|
9539
10298
|
*/
|
|
9540
|
-
var FactorydroidCheck = class
|
|
10299
|
+
var FactorydroidCheck = class extends AggregatedToolCheck {
|
|
9541
10300
|
static getSettablePaths(_options = {}) {
|
|
9542
10301
|
return {
|
|
9543
10302
|
relativeDirPath: FACTORYDROID_REVIEW_GUIDELINES_DIR_PATH,
|
|
9544
10303
|
relativeFilePath: SKILL_FILE_NAME
|
|
9545
10304
|
};
|
|
9546
10305
|
}
|
|
9547
|
-
static
|
|
9548
|
-
return this.isTargetedByRulesyncCheckDefault({
|
|
9549
|
-
rulesyncCheck,
|
|
9550
|
-
toolTarget: "factorydroid"
|
|
9551
|
-
});
|
|
9552
|
-
}
|
|
9553
|
-
/**
|
|
9554
|
-
* Ownership guard the processor consults before it deletes anything for this
|
|
9555
|
-
* tool. `review-guidelines` is an ordinary skill directory a user may have
|
|
9556
|
-
* authored by hand, so dropping the last check targeting Factory Droid must
|
|
9557
|
-
* not take their review instructions with it. Deletion is allowed only for a
|
|
9558
|
-
* file that is nothing but generated sections.
|
|
9559
|
-
*/
|
|
9560
|
-
static async canDeleteAuxiliaryFiles({ outputRoot }) {
|
|
9561
|
-
const paths = FactorydroidCheck.getSettablePaths();
|
|
9562
|
-
const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? "SKILL.md"));
|
|
9563
|
-
if (fileContent === null) return true;
|
|
9564
|
-
return isOnlyGeneratedSections(fileContent);
|
|
9565
|
-
}
|
|
9566
|
-
static fromRulesyncCheck(_params) {
|
|
9567
|
-
throw new Error("Factory Droid checks are built from all checks at once; use fromRulesyncChecks.");
|
|
9568
|
-
}
|
|
9569
|
-
static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
|
|
9570
|
-
if (rulesyncChecks.length === 0) return [];
|
|
9571
|
-
const paths = FactorydroidCheck.getSettablePaths({ global });
|
|
9572
|
-
const relativeFilePath = paths.relativeFilePath ?? "SKILL.md";
|
|
9573
|
-
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
9574
|
-
if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) {
|
|
9575
|
-
logger?.warn(`Factory Droid checks: ${filePath} holds instructions rulesync did not write, so it is left as it is and no checks were generated for Factory Droid. Run \`rulesync import --targets factorydroid --features checks\` to bring them into \`.rulesync/checks/\` and then delete the file, so the next generate writes it back from there; delete it outright if you no longer want it, or rename the directory if it is an ordinary skill rather than review guidelines. Importing alone leaves this file as it is, so it keeps blocking generation until it is gone. A rulesync skill named \`${FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME}\` is no longer generated here — Factory's reviewer reads this path, so the checks feature owns it — but a directory an older rulesync wrote can still be sitting there.`);
|
|
9576
|
-
return [];
|
|
9577
|
-
}
|
|
9578
|
-
const fileContent = renderCheckFile(rulesyncChecks);
|
|
9579
|
-
return [new FactorydroidCheck({
|
|
9580
|
-
outputRoot,
|
|
9581
|
-
relativeDirPath: paths.relativeDirPath,
|
|
9582
|
-
relativeFilePath,
|
|
9583
|
-
fileContent,
|
|
9584
|
-
global
|
|
9585
|
-
})];
|
|
9586
|
-
}
|
|
9587
|
-
static async fromFile({ outputRoot = process.cwd(), global = false }) {
|
|
9588
|
-
const paths = FactorydroidCheck.getSettablePaths({ global });
|
|
9589
|
-
const relativeFilePath = paths.relativeFilePath ?? "SKILL.md";
|
|
9590
|
-
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
9591
|
-
return new FactorydroidCheck({
|
|
9592
|
-
outputRoot,
|
|
9593
|
-
relativeDirPath: paths.relativeDirPath,
|
|
9594
|
-
relativeFilePath,
|
|
9595
|
-
fileContent: await readFileContentOrNull(filePath) ?? "",
|
|
9596
|
-
global
|
|
9597
|
-
});
|
|
9598
|
-
}
|
|
9599
|
-
static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
|
|
9600
|
-
return new FactorydroidCheck({
|
|
9601
|
-
outputRoot,
|
|
9602
|
-
relativeDirPath,
|
|
9603
|
-
relativeFilePath,
|
|
9604
|
-
fileContent: "",
|
|
9605
|
-
validate: false,
|
|
9606
|
-
global
|
|
9607
|
-
});
|
|
9608
|
-
}
|
|
9609
|
-
validate() {
|
|
10306
|
+
static getAggregatedCheckConfig() {
|
|
9610
10307
|
return {
|
|
9611
|
-
|
|
9612
|
-
|
|
10308
|
+
displayName: "Factory Droid",
|
|
10309
|
+
toolTarget: "factorydroid",
|
|
10310
|
+
fallbackCheckName: FACTORYDROID_REVIEW_GUIDELINES_DIR_NAME,
|
|
10311
|
+
handWrittenPreamble: "skip",
|
|
10312
|
+
handWrittenWarning,
|
|
10313
|
+
transformImportedContent: stripSkillFrontmatter
|
|
9613
10314
|
};
|
|
9614
10315
|
}
|
|
9615
|
-
toRulesyncCheck() {
|
|
9616
|
-
const first = this.toRulesyncChecks()[0];
|
|
9617
|
-
if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
|
|
9618
|
-
return first;
|
|
9619
|
-
}
|
|
9620
|
-
toRulesyncChecks() {
|
|
9621
|
-
return splitCheckFile({
|
|
9622
|
-
fileContent: stripSkillFrontmatter(this.getFileContent()),
|
|
9623
|
-
fallbackName: FALLBACK_CHECK_NAME$1
|
|
9624
|
-
});
|
|
9625
|
-
}
|
|
9626
10316
|
};
|
|
9627
10317
|
//#endregion
|
|
9628
10318
|
//#region src/constants/hermesagent-paths.ts
|
|
@@ -9881,7 +10571,6 @@ var HermesagentCheck = class HermesagentCheck extends ToolCheck {
|
|
|
9881
10571
|
};
|
|
9882
10572
|
//#endregion
|
|
9883
10573
|
//#region src/features/checks/rovodev-check.ts
|
|
9884
|
-
const FALLBACK_CHECK_NAME = "review-agent";
|
|
9885
10574
|
/**
|
|
9886
10575
|
* Checks adapter for Rovo Dev CLI's code-review custom instructions
|
|
9887
10576
|
* (`.rovodev/.review-agent.md`).
|
|
@@ -9890,9 +10579,9 @@ const FALLBACK_CHECK_NAME = "review-agent";
|
|
|
9890
10579
|
* `.rovodev/` folder — no frontmatter, and note the leading dot in the file
|
|
9891
10580
|
* name. Like Cursor Bugbot it is a single aggregated file rather than a file
|
|
9892
10581
|
* per check, so every `.rulesync/checks/*.md` targeting Rovo Dev collapses into
|
|
9893
|
-
* it via {@link
|
|
9894
|
-
* section (see `aggregated-check-file.ts` for the
|
|
9895
|
-
* adapters share).
|
|
10582
|
+
* it via the `fromRulesyncChecks` on {@link AggregatedToolCheck}, with each
|
|
10583
|
+
* check written as a marked section (see `aggregated-check-file.ts` for the
|
|
10584
|
+
* marker convention the three aggregated adapters share).
|
|
9896
10585
|
*
|
|
9897
10586
|
* Rovo Dev reads the file as free prose, so a check's `severity` and `tools`
|
|
9898
10587
|
* have no equivalent there: they are not written and do not come back on
|
|
@@ -9904,88 +10593,21 @@ const FALLBACK_CHECK_NAME = "review-agent";
|
|
|
9904
10593
|
*
|
|
9905
10594
|
* @see https://support.atlassian.com/rovo/docs/set-custom-instructions-for-code-reviews/
|
|
9906
10595
|
*/
|
|
9907
|
-
var RovodevCheck = class
|
|
10596
|
+
var RovodevCheck = class extends AggregatedToolCheck {
|
|
9908
10597
|
static getSettablePaths(_options = {}) {
|
|
9909
10598
|
return {
|
|
9910
10599
|
relativeDirPath: ROVODEV_DIR,
|
|
9911
10600
|
relativeFilePath: ROVODEV_REVIEW_AGENT_FILE_NAME
|
|
9912
10601
|
};
|
|
9913
10602
|
}
|
|
9914
|
-
static
|
|
9915
|
-
return this.isTargetedByRulesyncCheckDefault({
|
|
9916
|
-
rulesyncCheck,
|
|
9917
|
-
toolTarget: "rovodev"
|
|
9918
|
-
});
|
|
9919
|
-
}
|
|
9920
|
-
/**
|
|
9921
|
-
* Ownership guard the processor consults before it deletes anything for this
|
|
9922
|
-
* tool. `.review-agent.md` is a file Rovo Dev's own documentation tells users
|
|
9923
|
-
* to hand-write, so anything in it that rulesync did not write is not
|
|
9924
|
-
* rulesync's to remove — dropping the last check targeting Rovo Dev must not
|
|
9925
|
-
* take somebody's hand-written review instructions with it.
|
|
9926
|
-
*/
|
|
9927
|
-
static async canDeleteAuxiliaryFiles({ outputRoot }) {
|
|
9928
|
-
const paths = RovodevCheck.getSettablePaths();
|
|
9929
|
-
const fileContent = await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath ?? ".review-agent.md"));
|
|
9930
|
-
if (fileContent === null) return true;
|
|
9931
|
-
return isOnlyGeneratedSections(fileContent);
|
|
9932
|
-
}
|
|
9933
|
-
static fromRulesyncCheck(_params) {
|
|
9934
|
-
throw new Error("Rovo Dev checks are built from all checks at once; use fromRulesyncChecks.");
|
|
9935
|
-
}
|
|
9936
|
-
static async fromRulesyncChecks({ outputRoot = process.cwd(), rulesyncChecks, global = false, logger }) {
|
|
9937
|
-
if (rulesyncChecks.length === 0) return [];
|
|
9938
|
-
const paths = RovodevCheck.getSettablePaths({ global });
|
|
9939
|
-
const relativeFilePath = paths.relativeFilePath ?? ".review-agent.md";
|
|
9940
|
-
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
9941
|
-
if (hasHandWrittenPreamble(await readFileContentOrNull(filePath) ?? "")) logger?.warn(`Rovo Dev checks: ${filePath} holds instructions rulesync did not write, and generating replaces the whole file. Run \`rulesync import --targets rovodev --features checks\` first to keep them.`);
|
|
9942
|
-
return [new RovodevCheck({
|
|
9943
|
-
outputRoot,
|
|
9944
|
-
relativeDirPath: paths.relativeDirPath,
|
|
9945
|
-
relativeFilePath,
|
|
9946
|
-
fileContent: renderCheckFile(rulesyncChecks),
|
|
9947
|
-
global
|
|
9948
|
-
})];
|
|
9949
|
-
}
|
|
9950
|
-
static async fromFile({ outputRoot = process.cwd(), global = false }) {
|
|
9951
|
-
const paths = RovodevCheck.getSettablePaths({ global });
|
|
9952
|
-
const relativeFilePath = paths.relativeFilePath ?? ".review-agent.md";
|
|
9953
|
-
const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath);
|
|
9954
|
-
return new RovodevCheck({
|
|
9955
|
-
outputRoot,
|
|
9956
|
-
relativeDirPath: paths.relativeDirPath,
|
|
9957
|
-
relativeFilePath,
|
|
9958
|
-
fileContent: await readFileContentOrNull(filePath) ?? "",
|
|
9959
|
-
global
|
|
9960
|
-
});
|
|
9961
|
-
}
|
|
9962
|
-
static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
|
|
9963
|
-
return new RovodevCheck({
|
|
9964
|
-
outputRoot,
|
|
9965
|
-
relativeDirPath,
|
|
9966
|
-
relativeFilePath,
|
|
9967
|
-
fileContent: "",
|
|
9968
|
-
validate: false,
|
|
9969
|
-
global
|
|
9970
|
-
});
|
|
9971
|
-
}
|
|
9972
|
-
validate() {
|
|
10603
|
+
static getAggregatedCheckConfig() {
|
|
9973
10604
|
return {
|
|
9974
|
-
|
|
9975
|
-
|
|
10605
|
+
displayName: "Rovo Dev",
|
|
10606
|
+
toolTarget: "rovodev",
|
|
10607
|
+
fallbackCheckName: "review-agent",
|
|
10608
|
+
handWrittenPreamble: "replace"
|
|
9976
10609
|
};
|
|
9977
10610
|
}
|
|
9978
|
-
toRulesyncCheck() {
|
|
9979
|
-
const first = this.toRulesyncChecks()[0];
|
|
9980
|
-
if (!first) throw new Error(`No check instructions found in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}.`);
|
|
9981
|
-
return first;
|
|
9982
|
-
}
|
|
9983
|
-
toRulesyncChecks() {
|
|
9984
|
-
return splitCheckFile({
|
|
9985
|
-
fileContent: this.getFileContent(),
|
|
9986
|
-
fallbackName: FALLBACK_CHECK_NAME
|
|
9987
|
-
});
|
|
9988
|
-
}
|
|
9989
10611
|
};
|
|
9990
10612
|
//#endregion
|
|
9991
10613
|
//#region src/constants/codexcli-paths.ts
|
|
@@ -10009,11 +10631,30 @@ const CODEXCLI_OVERRIDE_KEYS = [
|
|
|
10009
10631
|
];
|
|
10010
10632
|
//#endregion
|
|
10011
10633
|
//#region src/features/shared/shared-config-gateway.ts
|
|
10634
|
+
/**
|
|
10635
|
+
* Rebuild a parsed document without its prototype-pollution keys.
|
|
10636
|
+
*
|
|
10637
|
+
* Every object is rebuilt, not just the ones that are already plain: a literal
|
|
10638
|
+
* `"__proto__"` is assigned with `obj[key] = value` by `jsonc-parser`, which
|
|
10639
|
+
* *replaces the containing object's prototype* instead of adding a key. Such
|
|
10640
|
+
* an object is no longer a plain object, and the injected value is reachable
|
|
10641
|
+
* through it by plain property access while `Object.keys` and `JSON.stringify`
|
|
10642
|
+
* show nothing — so leaving it as it came out of the parser would both hide a
|
|
10643
|
+
* server or permission the file does not state and, at the root, cost the
|
|
10644
|
+
* whole document (see {@link parseSharedConfig}). Rebuilding gives every
|
|
10645
|
+
* object `Object.prototype` back and drops the injected value with the key.
|
|
10646
|
+
*
|
|
10647
|
+
* Dates are the one object the YAML and TOML parsers produce that is not a
|
|
10648
|
+
* mapping, so they are passed through rather than flattened into `{}`.
|
|
10649
|
+
*/
|
|
10012
10650
|
function sanitizeSharedConfigValue(value) {
|
|
10013
10651
|
if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
|
|
10014
|
-
if (
|
|
10652
|
+
if (value === null || typeof value !== "object" || value instanceof Date) return value;
|
|
10015
10653
|
const result = {};
|
|
10016
|
-
for (const [key, nested] of Object.entries(
|
|
10654
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
10655
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
10656
|
+
result[key] = sanitizeSharedConfigValue(nested);
|
|
10657
|
+
}
|
|
10017
10658
|
return result;
|
|
10018
10659
|
}
|
|
10019
10660
|
/**
|
|
@@ -10042,11 +10683,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
|
|
|
10042
10683
|
throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
|
|
10043
10684
|
}
|
|
10044
10685
|
if (parsed === void 0 || parsed === null) return {};
|
|
10045
|
-
|
|
10686
|
+
const sanitized = sanitizeSharedConfigValue(parsed);
|
|
10687
|
+
if (!isPlainObject$1(sanitized)) {
|
|
10046
10688
|
if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
|
|
10047
10689
|
return {};
|
|
10048
10690
|
}
|
|
10049
|
-
return
|
|
10691
|
+
return sanitized;
|
|
10050
10692
|
}
|
|
10051
10693
|
/**
|
|
10052
10694
|
* Serialize a shared config document. YAML output always ends with exactly one
|
|
@@ -10063,6 +10705,571 @@ function stringifySharedConfig({ format, document }) {
|
|
|
10063
10705
|
return JSON.stringify(document, null, 2);
|
|
10064
10706
|
}
|
|
10065
10707
|
/**
|
|
10708
|
+
* Where the line holding `from` starts and where the line starting at `from`
|
|
10709
|
+
* ends: the nearest `\r` or `\n` in each direction, or the edge of the text.
|
|
10710
|
+
*
|
|
10711
|
+
* Both characters count in both directions, because the JSONC scanner treats
|
|
10712
|
+
* either as a line break. A file written with lone CRs parses without an error
|
|
10713
|
+
* and so reaches these paths, and a comment read only up to the next `\n`
|
|
10714
|
+
* would run past its own line and take the closing brace — or a whole sibling
|
|
10715
|
+
* key — with it.
|
|
10716
|
+
*/
|
|
10717
|
+
function endOfLineFrom({ text, from }) {
|
|
10718
|
+
for (let index = from; index < text.length; index += 1) {
|
|
10719
|
+
const character = text[index];
|
|
10720
|
+
if (character === "\n" || character === "\r") return index;
|
|
10721
|
+
}
|
|
10722
|
+
return text.length;
|
|
10723
|
+
}
|
|
10724
|
+
function startOfLineAt({ text, from }) {
|
|
10725
|
+
for (let index = from - 1; index >= 0; index -= 1) {
|
|
10726
|
+
const character = text[index];
|
|
10727
|
+
if (character === "\n" || character === "\r") return index + 1;
|
|
10728
|
+
}
|
|
10729
|
+
return 0;
|
|
10730
|
+
}
|
|
10731
|
+
/**
|
|
10732
|
+
* Read the indentation and line ending a JSONC document already uses, so
|
|
10733
|
+
* inserted properties match the surrounding file instead of imposing the
|
|
10734
|
+
* 2-space `JSON.stringify` shape on a file written with 4 spaces or tabs.
|
|
10735
|
+
*
|
|
10736
|
+
* The root object's first property decides, located through the syntax tree
|
|
10737
|
+
* rather than by scanning for the first indented line: a file opening with a
|
|
10738
|
+
* banner comment indents that comment's continuation lines too (` * ...`
|
|
10739
|
+
* aligns at three columns), and reading the width off one of those would leave
|
|
10740
|
+
* `modify` re-indenting the lines it touches to a width nothing else in the
|
|
10741
|
+
* file uses. A property that does not start its own line — a one-line object,
|
|
10742
|
+
* or an empty one — carries no indent to read, so those fall back to the
|
|
10743
|
+
* 2-space default the whole-document writer emits.
|
|
10744
|
+
*/
|
|
10745
|
+
function detectJsoncFormattingOptions({ text, root }) {
|
|
10746
|
+
const eol = "\n";
|
|
10747
|
+
const first = root.children?.[0];
|
|
10748
|
+
const indent = first === void 0 ? "" : text.slice(startOfLineAt({
|
|
10749
|
+
text,
|
|
10750
|
+
from: first.offset
|
|
10751
|
+
}), first.offset);
|
|
10752
|
+
if (indent === "" || indent.length > 8 || !/^[ \t]+$/.test(indent)) return {
|
|
10753
|
+
tabSize: 2,
|
|
10754
|
+
insertSpaces: true,
|
|
10755
|
+
eol
|
|
10756
|
+
};
|
|
10757
|
+
if (indent.includes(" ")) return {
|
|
10758
|
+
tabSize: 2,
|
|
10759
|
+
insertSpaces: false,
|
|
10760
|
+
eol
|
|
10761
|
+
};
|
|
10762
|
+
return {
|
|
10763
|
+
tabSize: indent.length,
|
|
10764
|
+
insertSpaces: true,
|
|
10765
|
+
eol
|
|
10766
|
+
};
|
|
10767
|
+
}
|
|
10768
|
+
/**
|
|
10769
|
+
* Whether the document states a key no edit-based write can be trusted with.
|
|
10770
|
+
*
|
|
10771
|
+
* Two kinds, both answered from the syntax tree because the parsed value no
|
|
10772
|
+
* longer knows about either:
|
|
10773
|
+
*
|
|
10774
|
+
* - A key stated twice. That is legal JSON text which every reader resolves
|
|
10775
|
+
* last-wins, while `modify` edits the *first* occurrence — so an edit-based
|
|
10776
|
+
* write would land on the dead copy and leave the live one saying whatever
|
|
10777
|
+
* it said before. For an owned key that is a silent ownership failure: a
|
|
10778
|
+
* `deny` rulesync just wrote would sit above the `allow` the tool reads.
|
|
10779
|
+
* - `__proto__`, `constructor` or `prototype`. None survives into the parsed
|
|
10780
|
+
* document — a nested one is dropped, a root-level `__proto__` replaces the
|
|
10781
|
+
* root's prototype — so an edit-based write would find no difference to
|
|
10782
|
+
* apply and leave the key in the file.
|
|
10783
|
+
*
|
|
10784
|
+
* The whole-document writer resolves duplicates last-wins and drops pollution
|
|
10785
|
+
* keys, which is what it has always done, so those files go to it.
|
|
10786
|
+
*/
|
|
10787
|
+
function statesUneditableKeys(node) {
|
|
10788
|
+
if (node.type === "array") return (node.children ?? []).some((child) => statesUneditableKeys(child));
|
|
10789
|
+
if (node.type !== "object") return false;
|
|
10790
|
+
const seen = /* @__PURE__ */ new Set();
|
|
10791
|
+
for (const property of node.children ?? []) {
|
|
10792
|
+
const key = property.children?.[0]?.value;
|
|
10793
|
+
if (typeof key === "string") {
|
|
10794
|
+
if (seen.has(key) || isPrototypePollutionKey(key)) return true;
|
|
10795
|
+
seen.add(key);
|
|
10796
|
+
}
|
|
10797
|
+
const value = property.children?.[1];
|
|
10798
|
+
if (value !== void 0 && statesUneditableKeys(value)) return true;
|
|
10799
|
+
}
|
|
10800
|
+
return false;
|
|
10801
|
+
}
|
|
10802
|
+
/**
|
|
10803
|
+
* The offset just past the whitespace and comments starting at `from`.
|
|
10804
|
+
*/
|
|
10805
|
+
function skipJsoncTrivia({ text, from }) {
|
|
10806
|
+
let index = from;
|
|
10807
|
+
while (index < text.length) {
|
|
10808
|
+
const char = text[index];
|
|
10809
|
+
if (char === " " || char === " " || char === "\n" || char === "\r") {
|
|
10810
|
+
index += 1;
|
|
10811
|
+
continue;
|
|
10812
|
+
}
|
|
10813
|
+
if (char === "/" && text[index + 1] === "/") {
|
|
10814
|
+
index = endOfLineFrom({
|
|
10815
|
+
text,
|
|
10816
|
+
from: index
|
|
10817
|
+
});
|
|
10818
|
+
continue;
|
|
10819
|
+
}
|
|
10820
|
+
if (char === "/" && text[index + 1] === "*") {
|
|
10821
|
+
const commentEnd = text.indexOf("*/", index + 2);
|
|
10822
|
+
index = commentEnd === -1 ? text.length : commentEnd + 2;
|
|
10823
|
+
continue;
|
|
10824
|
+
}
|
|
10825
|
+
break;
|
|
10826
|
+
}
|
|
10827
|
+
return index;
|
|
10828
|
+
}
|
|
10829
|
+
/**
|
|
10830
|
+
* Where the deletion of a property whose text ends at `end` should stop.
|
|
10831
|
+
*
|
|
10832
|
+
* A comment written after the property on its own line is that property's
|
|
10833
|
+
* note — `"stale": {...}, // retired` says something about `stale` and nothing
|
|
10834
|
+
* about the key above it. Leaving it behind would re-attach it to whichever
|
|
10835
|
+
* property now ends that line, so a note about a server rulesync removed would
|
|
10836
|
+
* read as a note about the one before it. A comment with a sibling after it on
|
|
10837
|
+
* the same line is not claimed: it may belong to either.
|
|
10838
|
+
*/
|
|
10839
|
+
function endOfRemoval({ text, end }) {
|
|
10840
|
+
const stop = endOfLineFrom({
|
|
10841
|
+
text,
|
|
10842
|
+
from: end
|
|
10843
|
+
});
|
|
10844
|
+
const tail = text.slice(end, stop);
|
|
10845
|
+
return /^[ \t]*\/\/[^\r\n]*$/.test(tail) || /^[ \t]*\/\*(?:[^*]|\*(?!\/))*\*\/[ \t]*$/.test(tail) || /^[ \t]*$/.test(tail) ? stop : end;
|
|
10846
|
+
}
|
|
10847
|
+
/**
|
|
10848
|
+
* Where the deletion of a property ending at `end` should begin.
|
|
10849
|
+
*
|
|
10850
|
+
* A property that has its line to itself is removed with the line: its
|
|
10851
|
+
* indentation and the newline above it would otherwise be left as a blank gap.
|
|
10852
|
+
* A property sharing its line with something else — a sibling, or the object's
|
|
10853
|
+
* own `}` — is removed on its own, because swallowing the newline would splice
|
|
10854
|
+
* whatever follows onto the line above, and a line comment up there would
|
|
10855
|
+
* comment it out: a key rulesync means to write would vanish from the file, or
|
|
10856
|
+
* the closing brace would, leaving the document unparsable.
|
|
10857
|
+
*/
|
|
10858
|
+
function startOfRemoval({ text, propertyOffset, end }) {
|
|
10859
|
+
let after = end;
|
|
10860
|
+
while (text[after] === " " || text[after] === " ") after += 1;
|
|
10861
|
+
if (!(after >= text.length || text[after] === "\n" || text[after] === "\r")) return propertyOffset;
|
|
10862
|
+
let start = propertyOffset;
|
|
10863
|
+
while (start > 0 && (text[start - 1] === " " || text[start - 1] === " ")) start -= 1;
|
|
10864
|
+
if (start > 0 && text[start - 1] === "\n") start -= 1;
|
|
10865
|
+
if (start > 0 && text[start - 1] === "\r") start -= 1;
|
|
10866
|
+
return start;
|
|
10867
|
+
}
|
|
10868
|
+
/**
|
|
10869
|
+
* Delete the property at `path` from `text`, taking its own line and its
|
|
10870
|
+
* separating comma but nothing else.
|
|
10871
|
+
*
|
|
10872
|
+
* `modify(..., undefined)` would do this, but the range it deletes runs from
|
|
10873
|
+
* the end of the *previous* property to the end of this one — or, for the
|
|
10874
|
+
* first property of an object, all the way to where the *next* one starts. So
|
|
10875
|
+
* removing one key takes the comments sitting between it and the keys around
|
|
10876
|
+
* it, including the comment describing the key that survives.
|
|
10877
|
+
* Deleting the property's own text instead leaves the comments around it in
|
|
10878
|
+
* place. Only the note that follows the property on its own line goes with it
|
|
10879
|
+
* (see {@link endOfRemoval}); a comment written on the line *above* is left
|
|
10880
|
+
* behind rather than guessed at, which is the direction this whole path errs
|
|
10881
|
+
* in.
|
|
10882
|
+
*/
|
|
10883
|
+
function removeJsoncProperty({ text, path }) {
|
|
10884
|
+
const root = parseTree(text, [], { allowTrailingComma: true });
|
|
10885
|
+
const property = root === void 0 ? void 0 : findNodeAtLocation(root, [...path])?.parent;
|
|
10886
|
+
const object = property?.parent;
|
|
10887
|
+
if (property?.type !== "property" || object?.type !== "object") return text;
|
|
10888
|
+
const siblings = object.children ?? [];
|
|
10889
|
+
const edits = [];
|
|
10890
|
+
let end = property.offset + property.length;
|
|
10891
|
+
const afterProperty = skipJsoncTrivia({
|
|
10892
|
+
text,
|
|
10893
|
+
from: end
|
|
10894
|
+
});
|
|
10895
|
+
if (text[afterProperty] === ",") end = afterProperty + 1;
|
|
10896
|
+
else {
|
|
10897
|
+
const previous = siblings[siblings.indexOf(property) - 1];
|
|
10898
|
+
if (previous !== void 0) {
|
|
10899
|
+
const comma = skipJsoncTrivia({
|
|
10900
|
+
text,
|
|
10901
|
+
from: previous.offset + previous.length
|
|
10902
|
+
});
|
|
10903
|
+
if (text[comma] === ",") edits.push({
|
|
10904
|
+
offset: comma,
|
|
10905
|
+
length: 1,
|
|
10906
|
+
content: ""
|
|
10907
|
+
});
|
|
10908
|
+
}
|
|
10909
|
+
}
|
|
10910
|
+
end = endOfRemoval({
|
|
10911
|
+
text,
|
|
10912
|
+
end
|
|
10913
|
+
});
|
|
10914
|
+
const start = startOfRemoval({
|
|
10915
|
+
text,
|
|
10916
|
+
propertyOffset: property.offset,
|
|
10917
|
+
end
|
|
10918
|
+
});
|
|
10919
|
+
edits.push({
|
|
10920
|
+
offset: start,
|
|
10921
|
+
length: end - start,
|
|
10922
|
+
content: ""
|
|
10923
|
+
});
|
|
10924
|
+
return applyEdits(text, edits);
|
|
10925
|
+
}
|
|
10926
|
+
/**
|
|
10927
|
+
* Where the comment written at `from` (past any spaces or tabs) ends, or
|
|
10928
|
+
* `undefined` if what stands there is not a comment.
|
|
10929
|
+
*/
|
|
10930
|
+
function endOfNoteAt({ text, from }) {
|
|
10931
|
+
let cursor = from;
|
|
10932
|
+
while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
|
|
10933
|
+
if (text.startsWith("//", cursor)) return endOfLineFrom({
|
|
10934
|
+
text,
|
|
10935
|
+
from: cursor
|
|
10936
|
+
});
|
|
10937
|
+
if (text.startsWith("/*", cursor)) {
|
|
10938
|
+
const closing = text.indexOf("*/", cursor + 2);
|
|
10939
|
+
return closing === -1 ? void 0 : closing + 2;
|
|
10940
|
+
}
|
|
10941
|
+
}
|
|
10942
|
+
/**
|
|
10943
|
+
* Every comment written at `from`, as spans of `text`, each span running from
|
|
10944
|
+
* where the previous one stopped so the whitespace between them is carried
|
|
10945
|
+
* along. A comma is stepped over once (a file may spell one before its note,
|
|
10946
|
+
* or after it) but never collected, because the separator belongs to the
|
|
10947
|
+
* property rather than to its note. So a file that writes a note on each side
|
|
10948
|
+
* of its comma gets both of them back, in order, after the comma: the notes
|
|
10949
|
+
* stay with the key they describe, and the comma keeps the place the file gave
|
|
10950
|
+
* it. The run stops at the first thing that is neither: a newline ends it, so
|
|
10951
|
+
* a comment on the next line is left alone.
|
|
10952
|
+
*/
|
|
10953
|
+
function notesAt({ text, from }) {
|
|
10954
|
+
const spans = [];
|
|
10955
|
+
let cursor = from;
|
|
10956
|
+
let steppedOverComma = false;
|
|
10957
|
+
for (;;) {
|
|
10958
|
+
const end = endOfNoteAt({
|
|
10959
|
+
text,
|
|
10960
|
+
from: cursor
|
|
10961
|
+
});
|
|
10962
|
+
if (end !== void 0) {
|
|
10963
|
+
spans.push({
|
|
10964
|
+
start: cursor,
|
|
10965
|
+
end
|
|
10966
|
+
});
|
|
10967
|
+
cursor = end;
|
|
10968
|
+
continue;
|
|
10969
|
+
}
|
|
10970
|
+
if (steppedOverComma) return spans;
|
|
10971
|
+
let comma = cursor;
|
|
10972
|
+
while (text[comma] === " " || text[comma] === " ") comma += 1;
|
|
10973
|
+
if (text[comma] !== ",") return spans;
|
|
10974
|
+
steppedOverComma = true;
|
|
10975
|
+
cursor = comma + 1;
|
|
10976
|
+
}
|
|
10977
|
+
}
|
|
10978
|
+
/**
|
|
10979
|
+
* Detach the notes written at the point where the object at `path` will take a
|
|
10980
|
+
* new key: after its last property (and around the comma a trailing-comma file
|
|
10981
|
+
* spells there), or just inside the `{` when it has no properties yet.
|
|
10982
|
+
*
|
|
10983
|
+
* `modify` computes its insert from exactly that point — in front of a note
|
|
10984
|
+
* written there — so applying the edit unchanged re-emits the note *after* the
|
|
10985
|
+
* key that was just inserted: `"stale": {...} // retired` turns into a note
|
|
10986
|
+
* about a server rulesync has only now written, and `{ /* none yet *\/ }`
|
|
10987
|
+
* turns into a note about the first entry rulesync puts in it. Lifting the
|
|
10988
|
+
* notes out before the insert and putting them back afterwards keeps them
|
|
10989
|
+
* where their author wrote them, matching what {@link endOfRemoval} does on
|
|
10990
|
+
* the way out.
|
|
10991
|
+
*
|
|
10992
|
+
* Returns `undefined` when there is no such note, which is the common case.
|
|
10993
|
+
*/
|
|
10994
|
+
function detachTrailingNote({ text, path }) {
|
|
10995
|
+
const root = parseTree(text, [], { allowTrailingComma: true });
|
|
10996
|
+
const object = root === void 0 ? void 0 : findNodeAtLocation(root, [...path]);
|
|
10997
|
+
if (object?.type !== "object") return void 0;
|
|
10998
|
+
const property = object.children?.at(-1);
|
|
10999
|
+
const anchorKey = property?.children?.[0]?.value;
|
|
11000
|
+
if (property !== void 0 && typeof anchorKey !== "string") return void 0;
|
|
11001
|
+
const spans = notesAt({
|
|
11002
|
+
text,
|
|
11003
|
+
from: property === void 0 ? object.offset + 1 : property.offset + property.length
|
|
11004
|
+
});
|
|
11005
|
+
if (spans.length === 0) return void 0;
|
|
11006
|
+
let stripped = text;
|
|
11007
|
+
for (const span of spans.toReversed()) stripped = stripped.slice(0, span.start) + stripped.slice(span.end);
|
|
11008
|
+
return {
|
|
11009
|
+
text: stripped,
|
|
11010
|
+
note: spans.map((span) => text.slice(span.start, span.end)).join(""),
|
|
11011
|
+
anchorKey: typeof anchorKey === "string" ? anchorKey : void 0
|
|
11012
|
+
};
|
|
11013
|
+
}
|
|
11014
|
+
/**
|
|
11015
|
+
* Put a note detached by {@link detachTrailingNote} back where it was: after
|
|
11016
|
+
* the property it describes (behind the comma the insert gave that property),
|
|
11017
|
+
* or just inside the `{` of the object it was written in when there was no
|
|
11018
|
+
* property to describe. Returns `undefined` if that place can no longer be
|
|
11019
|
+
* located, so the caller can fall back to the plain insert rather than drop
|
|
11020
|
+
* the note.
|
|
11021
|
+
*/
|
|
11022
|
+
function reattachTrailingNote({ text, path, anchorKey, note }) {
|
|
11023
|
+
const root = parseTree(text, [], { allowTrailingComma: true });
|
|
11024
|
+
const location = anchorKey === void 0 ? [...path] : [...path, anchorKey];
|
|
11025
|
+
const anchor = root === void 0 ? void 0 : findNodeAtLocation(root, location);
|
|
11026
|
+
if (anchor === void 0) return void 0;
|
|
11027
|
+
if (anchorKey === void 0) {
|
|
11028
|
+
if (anchor.type !== "object") return void 0;
|
|
11029
|
+
const brace = anchor.offset + 1;
|
|
11030
|
+
return text.slice(0, brace) + note + text.slice(brace);
|
|
11031
|
+
}
|
|
11032
|
+
let cursor = anchor.offset + anchor.length;
|
|
11033
|
+
while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
|
|
11034
|
+
if (text[cursor] === ",") cursor += 1;
|
|
11035
|
+
return text.slice(0, cursor) + note + text.slice(cursor);
|
|
11036
|
+
}
|
|
11037
|
+
/**
|
|
11038
|
+
* Write `value` at `[...path, key]`, keeping the trailing note of the property
|
|
11039
|
+
* the new key is inserted after (see {@link detachTrailingNote}). Replacing an
|
|
11040
|
+
* existing key needs none of this: `modify` rewrites the value's own span and
|
|
11041
|
+
* leaves every comment where it is.
|
|
11042
|
+
*/
|
|
11043
|
+
function insertJsoncProperty({ text, path, key, value, options }) {
|
|
11044
|
+
const write = (source) => applyEdits(source, modify(source, [...path, key], value, options));
|
|
11045
|
+
const detached = detachTrailingNote({
|
|
11046
|
+
text,
|
|
11047
|
+
path
|
|
11048
|
+
});
|
|
11049
|
+
if (detached === void 0) return write(text);
|
|
11050
|
+
return reattachTrailingNote({
|
|
11051
|
+
text: write(detached.text),
|
|
11052
|
+
path,
|
|
11053
|
+
anchorKey: detached.anchorKey,
|
|
11054
|
+
note: detached.note
|
|
11055
|
+
}) ?? write(text);
|
|
11056
|
+
}
|
|
11057
|
+
/**
|
|
11058
|
+
* How much work an edit-based write may cost, as the file's length times the
|
|
11059
|
+
* number of keys that differ.
|
|
11060
|
+
*
|
|
11061
|
+
* Each changed key re-parses the whole file, so a file whose keys nearly all
|
|
11062
|
+
* change costs quadratic work: an 823 KB `opencode.json` whose 6,400 servers
|
|
11063
|
+
* are all replaced took 40 seconds to write as edits, and cloning a
|
|
11064
|
+
* repository that ships such a file is enough to reach that. Past this budget
|
|
11065
|
+
* the file is written whole instead — it loses its comments, the same as a
|
|
11066
|
+
* file rulesync cannot parse does, which is the better of the two outcomes
|
|
11067
|
+
* against a `generate` that looks like it has hung. The limit is under a
|
|
11068
|
+
* second of editing for a replacement and a second or two for an insert,
|
|
11069
|
+
* which parses more; a hand-written config file is orders of magnitude below
|
|
11070
|
+
* either.
|
|
11071
|
+
*/
|
|
11072
|
+
const JSONC_EDIT_BUDGET_BYTES = 5e7;
|
|
11073
|
+
/**
|
|
11074
|
+
* How much text the edits of one write may put into the file, all of them
|
|
11075
|
+
* together.
|
|
11076
|
+
*
|
|
11077
|
+
* The budget above charges by the number of keys that differ, which prices an
|
|
11078
|
+
* insert of a whole subtree as one edit — but `modify` re-indents the text it
|
|
11079
|
+
* writes, and it does that in time quadratic in the length of that text: a
|
|
11080
|
+
* 127 KB value takes half a second to write, a 516 KB one 9 seconds, a 1 MB
|
|
11081
|
+
* one 35. The cost is quadratic in the total as well as in each part, because
|
|
11082
|
+
* every edit re-indents against the text the ones before it left, so a limit
|
|
11083
|
+
* on the widest single value would let a handful of values just under it cost
|
|
11084
|
+
* minutes between them. This is a limit on their sum, which holds the
|
|
11085
|
+
* re-indenting to about a second whether it arrives as one value or twenty.
|
|
11086
|
+
* A hand-written config file changes a few hundred bytes at a time.
|
|
11087
|
+
*/
|
|
11088
|
+
const JSONC_EDIT_WRITTEN_BYTES = 2e5;
|
|
11089
|
+
/**
|
|
11090
|
+
* How much text one edit writes: the value as `modify` formats it, plus the
|
|
11091
|
+
* indentation that formatting puts in front of every line of it.
|
|
11092
|
+
*
|
|
11093
|
+
* A value written deep in a document is written far wider than it reads on
|
|
11094
|
+
* its own — a 114 KB server list nested 1,200 deep is 36 MB of text once
|
|
11095
|
+
* every line of it carries 2,400 spaces — so measuring the value alone would
|
|
11096
|
+
* miss the whole of what makes that write expensive.
|
|
11097
|
+
*/
|
|
11098
|
+
function measureJsoncWrite({ value, depth }) {
|
|
11099
|
+
const formatted = JSON.stringify(value, null, 2) ?? "";
|
|
11100
|
+
let lines = 1;
|
|
11101
|
+
for (let at = formatted.indexOf("\n"); at !== -1; at = formatted.indexOf("\n", at + 1)) lines += 1;
|
|
11102
|
+
return formatted.length + lines * 2 * depth;
|
|
11103
|
+
}
|
|
11104
|
+
/**
|
|
11105
|
+
* How much {@link applyJsoncObjectEdits} would write, deciding each key
|
|
11106
|
+
* exactly the way it does — the two walk together, so a new case in one needs
|
|
11107
|
+
* the same case here. It reports both how many edits there would be and how
|
|
11108
|
+
* much text they would write between them, because the two are budgeted
|
|
11109
|
+
* separately: one stands for the parse each edit costs, the other for the
|
|
11110
|
+
* re-indenting each edit costs. Counting is a walk of the documents alone: no
|
|
11111
|
+
* text is touched, so the budgets above are spent on the write rather than on
|
|
11112
|
+
* finding out how large the write is.
|
|
11113
|
+
*/
|
|
11114
|
+
function countJsoncEdits({ base, next, depth }) {
|
|
11115
|
+
let edits = 0;
|
|
11116
|
+
let written = 0;
|
|
11117
|
+
for (const [key, value] of Object.entries(next)) {
|
|
11118
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
11119
|
+
const present = Object.hasOwn(base, key);
|
|
11120
|
+
const previous = present ? base[key] : void 0;
|
|
11121
|
+
if (value === void 0) {
|
|
11122
|
+
if (present) edits += 1;
|
|
11123
|
+
continue;
|
|
11124
|
+
}
|
|
11125
|
+
if (isPlainObject$1(previous) && isPlainObject$1(value)) {
|
|
11126
|
+
const nested = countJsoncEdits({
|
|
11127
|
+
base: previous,
|
|
11128
|
+
next: value,
|
|
11129
|
+
depth: depth + 1
|
|
11130
|
+
});
|
|
11131
|
+
edits += nested.edits;
|
|
11132
|
+
written += nested.written;
|
|
11133
|
+
continue;
|
|
11134
|
+
}
|
|
11135
|
+
if (present && isDeepStrictEqual(previous, value)) continue;
|
|
11136
|
+
edits += 1;
|
|
11137
|
+
written += measureJsoncWrite({
|
|
11138
|
+
value,
|
|
11139
|
+
depth: depth + 1
|
|
11140
|
+
});
|
|
11141
|
+
}
|
|
11142
|
+
for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) edits += 1;
|
|
11143
|
+
return {
|
|
11144
|
+
edits,
|
|
11145
|
+
written
|
|
11146
|
+
};
|
|
11147
|
+
}
|
|
11148
|
+
/**
|
|
11149
|
+
* Rewrite `text` so the object at `path` matches `next`, touching only the
|
|
11150
|
+
* spans that actually differ from `base`.
|
|
11151
|
+
*
|
|
11152
|
+
* Each difference is applied on its own — `modify` computes an edit against
|
|
11153
|
+
* the current text and `applyEdits` returns the text with that edit applied,
|
|
11154
|
+
* which is then the input for the next difference, because every edit shifts
|
|
11155
|
+
* the offsets the following ones would have been computed from. Nested objects
|
|
11156
|
+
* present on both sides are recursed into rather than replaced wholesale, so a
|
|
11157
|
+
* one-key change deep in the document leaves its siblings — and the comments
|
|
11158
|
+
* attached to them — byte-identical.
|
|
11159
|
+
*
|
|
11160
|
+
* Every difference re-parses the document, so the work is one parse of the
|
|
11161
|
+
* file per *changed* key rather than one parse overall. A regeneration that
|
|
11162
|
+
* changes nothing costs a single parse, and the files this runs on are config
|
|
11163
|
+
* files, so the shape is left simple rather than batched — with the file
|
|
11164
|
+
* large enough and enough of it changing, the product of the two is what
|
|
11165
|
+
* {@link JSONC_EDIT_BUDGET_BYTES} keeps off this path.
|
|
11166
|
+
*/
|
|
11167
|
+
function applyJsoncObjectEdits({ text, base, next, path, options }) {
|
|
11168
|
+
let result = text;
|
|
11169
|
+
for (const [key, value] of Object.entries(next)) {
|
|
11170
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
11171
|
+
const present = Object.hasOwn(base, key);
|
|
11172
|
+
const previous = present ? base[key] : void 0;
|
|
11173
|
+
if (value === void 0) {
|
|
11174
|
+
if (present) result = removeJsoncProperty({
|
|
11175
|
+
text: result,
|
|
11176
|
+
path: [...path, key]
|
|
11177
|
+
});
|
|
11178
|
+
continue;
|
|
11179
|
+
}
|
|
11180
|
+
if (isPlainObject$1(previous) && isPlainObject$1(value)) {
|
|
11181
|
+
result = applyJsoncObjectEdits({
|
|
11182
|
+
text: result,
|
|
11183
|
+
base: previous,
|
|
11184
|
+
next: value,
|
|
11185
|
+
path: [...path, key],
|
|
11186
|
+
options
|
|
11187
|
+
});
|
|
11188
|
+
continue;
|
|
11189
|
+
}
|
|
11190
|
+
if (present) {
|
|
11191
|
+
if (isDeepStrictEqual(previous, value)) continue;
|
|
11192
|
+
result = applyEdits(result, modify(result, [...path, key], value, options));
|
|
11193
|
+
continue;
|
|
11194
|
+
}
|
|
11195
|
+
result = insertJsoncProperty({
|
|
11196
|
+
text: result,
|
|
11197
|
+
path,
|
|
11198
|
+
key,
|
|
11199
|
+
value,
|
|
11200
|
+
options
|
|
11201
|
+
});
|
|
11202
|
+
}
|
|
11203
|
+
for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) result = removeJsoncProperty({
|
|
11204
|
+
text: result,
|
|
11205
|
+
path: [...path, key]
|
|
11206
|
+
});
|
|
11207
|
+
return result;
|
|
11208
|
+
}
|
|
11209
|
+
/**
|
|
11210
|
+
* Serialize a document back over the file it was parsed from.
|
|
11211
|
+
*
|
|
11212
|
+
* For every format but JSONC this is {@link stringifySharedConfig}: those
|
|
11213
|
+
* files carry no comments, so re-serializing loses nothing. A JSONC file does
|
|
11214
|
+
* carry comments — `.vscode/settings.json` and `opencode.json` are hand-edited
|
|
11215
|
+
* far more often than they are generated — and re-serializing would delete
|
|
11216
|
+
* every one of them, along with the author's blank lines and key order. So a
|
|
11217
|
+
* JSONC document is written back as a set of edits against the existing text:
|
|
11218
|
+
* regions the merge did not change stay byte-identical, and a regeneration
|
|
11219
|
+
* that changes nothing leaves the file untouched.
|
|
11220
|
+
*
|
|
11221
|
+
* The whole-document writer is still used when there is nothing to preserve or
|
|
11222
|
+
* nothing to edit against:
|
|
11223
|
+
*
|
|
11224
|
+
* - an empty (or whitespace-only) file, which has no comments to keep;
|
|
11225
|
+
* - a file that does not parse, or whose root is not an object — editing it
|
|
11226
|
+
* would mean guessing at the author's intent, and the callers that reach
|
|
11227
|
+
* here have already decided (via `invalidRootPolicy`) that such a file is
|
|
11228
|
+
* replaced;
|
|
11229
|
+
* - a file stating the same key twice, or using `__proto__`, `constructor` or
|
|
11230
|
+
* `prototype` as a key (see {@link statesUneditableKeys});
|
|
11231
|
+
* - a file so large, with so much of it changing, that editing it key by key
|
|
11232
|
+
* would take longer than a user would wait (see
|
|
11233
|
+
* {@link JSONC_EDIT_BUDGET_BYTES}), or changed keys that write more new text
|
|
11234
|
+
* between them than re-indenting can afford (see
|
|
11235
|
+
* {@link JSONC_EDIT_WRITTEN_BYTES});
|
|
11236
|
+
* - a file the editor itself refuses, which it answers with an exception
|
|
11237
|
+
* rather than a result.
|
|
11238
|
+
*/
|
|
11239
|
+
function serializeSharedConfig({ format, document, existingContent }) {
|
|
11240
|
+
const whole = stringifySharedConfig({
|
|
11241
|
+
format,
|
|
11242
|
+
document
|
|
11243
|
+
});
|
|
11244
|
+
if (format !== "jsonc" || existingContent.trim() === "") return whole;
|
|
11245
|
+
try {
|
|
11246
|
+
const errors = [];
|
|
11247
|
+
const root = parseTree(existingContent, errors, { allowTrailingComma: true });
|
|
11248
|
+
if (root === void 0 || errors.length > 0 || root.type !== "object" || statesUneditableKeys(root)) return whole;
|
|
11249
|
+
const base = sanitizeSharedConfigValue(getNodeValue(root));
|
|
11250
|
+
if (!isPlainObject$1(base)) return whole;
|
|
11251
|
+
const span = Math.max(existingContent.length, whole.length);
|
|
11252
|
+
const cost = countJsoncEdits({
|
|
11253
|
+
base,
|
|
11254
|
+
next: document,
|
|
11255
|
+
depth: 0
|
|
11256
|
+
});
|
|
11257
|
+
if (cost.written > JSONC_EDIT_WRITTEN_BYTES || cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return whole;
|
|
11258
|
+
return applyJsoncObjectEdits({
|
|
11259
|
+
text: existingContent,
|
|
11260
|
+
base,
|
|
11261
|
+
next: document,
|
|
11262
|
+
path: [],
|
|
11263
|
+
options: { formattingOptions: detectJsoncFormattingOptions({
|
|
11264
|
+
text: existingContent,
|
|
11265
|
+
root
|
|
11266
|
+
}) }
|
|
11267
|
+
});
|
|
11268
|
+
} catch {
|
|
11269
|
+
return whole;
|
|
11270
|
+
}
|
|
11271
|
+
}
|
|
11272
|
+
/**
|
|
10066
11273
|
* Shallow merge: every top-level key in `patch` replaces the base key
|
|
10067
11274
|
* wholesale; all other base keys are preserved. The policy for a feature that
|
|
10068
11275
|
* owns a fixed set of top-level keys.
|
|
@@ -10085,7 +11292,7 @@ function mergeSharedConfigShallow({ base, patch }) {
|
|
|
10085
11292
|
function mergeSharedConfigDeep({ base, patch }) {
|
|
10086
11293
|
const result = { ...base };
|
|
10087
11294
|
for (const [key, patchValue] of Object.entries(patch)) {
|
|
10088
|
-
if (
|
|
11295
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
10089
11296
|
if (patchValue === void 0) {
|
|
10090
11297
|
delete result[key];
|
|
10091
11298
|
continue;
|
|
@@ -10667,7 +11874,10 @@ const SHARED_CONFIG_OWNERSHIP = {
|
|
|
10667
11874
|
/**
|
|
10668
11875
|
* Execute a feature's declared write to a gateway-managed shared file: parse
|
|
10669
11876
|
* the existing content, merge the patch under the feature's declared policy,
|
|
10670
|
-
* and serialize
|
|
11877
|
+
* and serialize it back over the existing content (see
|
|
11878
|
+
* {@link serializeSharedConfig}, which keeps a JSONC file's comments and
|
|
11879
|
+
* formatting outside the spans the merge actually changed). Throws when the
|
|
11880
|
+
* file or feature is undeclared, when a
|
|
10671
11881
|
* `replace-owned-keys` patch strays outside its owned keys, or when the
|
|
10672
11882
|
* feature's policy is `custom` (those calls go to the named policy function
|
|
10673
11883
|
* instead).
|
|
@@ -10693,9 +11903,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
|
|
|
10693
11903
|
patch
|
|
10694
11904
|
});
|
|
10695
11905
|
for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
|
|
10696
|
-
return
|
|
11906
|
+
return serializeSharedConfig({
|
|
10697
11907
|
format: declaration.format,
|
|
10698
|
-
document
|
|
11908
|
+
document,
|
|
11909
|
+
existingContent
|
|
10699
11910
|
});
|
|
10700
11911
|
}
|
|
10701
11912
|
const merged = mergeSharedConfigDeep({
|
|
@@ -10703,9 +11914,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
|
|
|
10703
11914
|
patch
|
|
10704
11915
|
});
|
|
10705
11916
|
for (const key of policy.replaceKeys ?? []) if (patch[key] !== void 0) merged[key] = sanitizeSharedConfigValue(patch[key]);
|
|
10706
|
-
return
|
|
11917
|
+
return serializeSharedConfig({
|
|
10707
11918
|
format: declaration.format,
|
|
10708
|
-
document: merged
|
|
11919
|
+
document: merged,
|
|
11920
|
+
existingContent
|
|
10709
11921
|
});
|
|
10710
11922
|
}
|
|
10711
11923
|
const READ_TOOL_NAME = "Read";
|
|
@@ -11193,7 +12405,7 @@ var ChecksProcessor = class extends FeatureProcessor {
|
|
|
11193
12405
|
this.logger.debug(`Rulesync checks directory not found: ${checksDir}`);
|
|
11194
12406
|
return [];
|
|
11195
12407
|
}
|
|
11196
|
-
const mdFiles = (await
|
|
12408
|
+
const mdFiles = (await listDirectoryEntryNames(checksDir)).filter((file) => file.endsWith(".md"));
|
|
11197
12409
|
if (mdFiles.length === 0) {
|
|
11198
12410
|
this.logger.debug(`No markdown files found in rulesync checks directory: ${checksDir}`);
|
|
11199
12411
|
return [];
|
|
@@ -16015,6 +17227,7 @@ const ZCODE_SKILLS_DIR_PATH = join(ZCODE_DIR, "skills");
|
|
|
16015
17227
|
const ZCODE_CONFIG_FILE_NAME = "config.json";
|
|
16016
17228
|
const ZCODE_GLOBAL_CONFIG_DIR_PATH = join(ZCODE_DIR, "cli");
|
|
16017
17229
|
const ZCODE_MCP_SERVERS_KEY = "servers";
|
|
17230
|
+
const ZCODE_AGENTS_DIR_PATH = join(ZCODE_DIR, "agents");
|
|
16018
17231
|
//#endregion
|
|
16019
17232
|
//#region src/features/commands/zcode-command.ts
|
|
16020
17233
|
/**
|
|
@@ -16977,6 +18190,52 @@ function compact(obj) {
|
|
|
16977
18190
|
return result;
|
|
16978
18191
|
}
|
|
16979
18192
|
//#endregion
|
|
18193
|
+
//#region src/utils/quote-value.ts
|
|
18194
|
+
/**
|
|
18195
|
+
* How much of a value read off disk a diagnostic quotes.
|
|
18196
|
+
*
|
|
18197
|
+
* Enough to recognize which entry is meant, and no more. A warning names the
|
|
18198
|
+
* offending value so the reader can find it, but the values these warnings
|
|
18199
|
+
* quote come from files rulesync did not write — a tool's own settings, a
|
|
18200
|
+
* machine-local overrides file, a repository fetched from elsewhere — and they
|
|
18201
|
+
* no longer stop at a terminal: they travel into a `--json` document another
|
|
18202
|
+
* program parses and into an MCP result an agent reads as context. A command
|
|
18203
|
+
* line or a header is the shape most likely to carry a credential, and a long
|
|
18204
|
+
* value is the shape most likely to carry instructions aimed at the agent.
|
|
18205
|
+
*/
|
|
18206
|
+
const MAX_QUOTED_VALUE_LENGTH = 60;
|
|
18207
|
+
/**
|
|
18208
|
+
* A short, quotable rendering of a value for a diagnostic.
|
|
18209
|
+
*
|
|
18210
|
+
* Serialized rather than interpolated, because an unquoted value is what lets a
|
|
18211
|
+
* crafted one read as a second line; stripped of the control characters
|
|
18212
|
+
* `JSON.stringify` leaves intact (it escapes C0 only, not the C1 range or the
|
|
18213
|
+
* bidirectional overrides); and truncated.
|
|
18214
|
+
*/
|
|
18215
|
+
function quoteValueForWarning(value) {
|
|
18216
|
+
return truncateText({
|
|
18217
|
+
text: stripControlCharacters(serialize(value)),
|
|
18218
|
+
maxLength: MAX_QUOTED_VALUE_LENGTH,
|
|
18219
|
+
suffix: "…(truncated)"
|
|
18220
|
+
});
|
|
18221
|
+
}
|
|
18222
|
+
function serialize(value) {
|
|
18223
|
+
try {
|
|
18224
|
+
return JSON.stringify(value, stripStrings) ?? String(value);
|
|
18225
|
+
} catch {
|
|
18226
|
+
return `[unserializable ${typeof value}]`;
|
|
18227
|
+
}
|
|
18228
|
+
}
|
|
18229
|
+
/**
|
|
18230
|
+
* Strip the control characters out of every string before `JSON.stringify`
|
|
18231
|
+
* sees it, not only out of the document it produces: `JSON.stringify` escapes
|
|
18232
|
+
* a C0 character into the six literal characters `\u001b`, which no later pass
|
|
18233
|
+
* over the output can recognize as a control character again.
|
|
18234
|
+
*/
|
|
18235
|
+
function stripStrings(_key, value) {
|
|
18236
|
+
return typeof value === "string" ? stripControlCharacters(value) : value;
|
|
18237
|
+
}
|
|
18238
|
+
//#endregion
|
|
16980
18239
|
//#region src/features/hooks/tool-hooks-converter.ts
|
|
16981
18240
|
function isToolMatcherEntry(x) {
|
|
16982
18241
|
if (x === null || typeof x !== "object") return false;
|
|
@@ -17078,7 +18337,7 @@ function emitPassthroughFields({ def, hookType, eventName, fields, isValid, warn
|
|
|
17078
18337
|
if (!isValid({
|
|
17079
18338
|
value,
|
|
17080
18339
|
canonical
|
|
17081
|
-
})) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${
|
|
18340
|
+
})) warn?.(`Dropping "${canonical}" from a "${hookType}" hook on "${eventName}": ${quoteValueForWarning(value)} is not a value this tool can express as "${tool}".`);
|
|
17082
18341
|
}
|
|
17083
18342
|
return Object.fromEntries(fields.filter(({ canonical, commandOnly }) => isFieldApplicable({
|
|
17084
18343
|
commandOnly,
|
|
@@ -17173,7 +18432,7 @@ function describeScalarConstraint({ canonical, value }) {
|
|
|
17173
18432
|
if (issue === void 0) return `it is not a value the canonical "${canonical}" field accepts.`;
|
|
17174
18433
|
return `it does not satisfy the canonical "${canonical}" field: ${issue.message}.`;
|
|
17175
18434
|
}
|
|
17176
|
-
const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${
|
|
18435
|
+
const describeInvalidScalar = ({ tool, canonical, value }) => `Dropping "${tool}" (${quoteValueForWarning(value)}) while importing a hook: ${describeScalarConstraint({
|
|
17177
18436
|
canonical,
|
|
17178
18437
|
value
|
|
17179
18438
|
})} Importing it would fail validation on the next run.`;
|
|
@@ -17200,7 +18459,7 @@ function emitGroupPassthroughFields({ defs, eventName, converterConfig, logger }
|
|
|
17200
18459
|
if (first === void 0) continue;
|
|
17201
18460
|
const firstStable = stableJson(first);
|
|
17202
18461
|
const agrees = (value) => isGroupPassthroughValue(value, valueType) && stableJson(value) === firstStable;
|
|
17203
|
-
if (!carried.every(agrees)) logger?.warn(`"${tool}" belongs to the whole matcher group on "${eventName}" hooks, so every hook in this group gets ${
|
|
18462
|
+
if (!carried.every(agrees)) logger?.warn(`"${tool}" belongs to the whole matcher group on "${eventName}" hooks, so every hook in this group gets ${quoteValueForWarning(first)} — including any that asked for something else, or for nothing.`);
|
|
17204
18463
|
emitted[tool] = first;
|
|
17205
18464
|
}
|
|
17206
18465
|
return emitted;
|
|
@@ -17687,7 +18946,7 @@ function describeGroupSkipReason({ rawEntry, converterConfig }) {
|
|
|
17687
18946
|
for (const { tool, valueType, subdividesGroup } of converterConfig.groupPassthroughFields ?? []) {
|
|
17688
18947
|
const value = entry[tool];
|
|
17689
18948
|
if (subdividesGroup !== true || value === void 0) continue;
|
|
17690
|
-
if (!isGroupPassthroughValue(value, valueType)) return `Skipping the hooks of a matcher group while importing: its "${tool}" (${
|
|
18949
|
+
if (!isGroupPassthroughValue(value, valueType)) return `Skipping the hooks of a matcher group while importing: its "${tool}" (${quoteValueForWarning(value)}) is unusable, and these hooks run only where it matches. Importing them without it would widen when they fire, so they are skipped.`;
|
|
17691
18950
|
}
|
|
17692
18951
|
}
|
|
17693
18952
|
/**
|
|
@@ -17704,7 +18963,7 @@ function describeHookSkipReason({ h, rawEntry, hookType, converterConfig }) {
|
|
|
17704
18963
|
value,
|
|
17705
18964
|
canonical: field
|
|
17706
18965
|
})) continue;
|
|
17707
|
-
return `Skipping a hook while importing: its "${field}" (${
|
|
18966
|
+
return `Skipping a hook while importing: its "${field}" (${quoteValueForWarning(value)}) is unusable — ${describeScalarConstraint({
|
|
17708
18967
|
canonical: field,
|
|
17709
18968
|
value
|
|
17710
18969
|
})} Keeping the hook without it would change what it does, so the whole hook is skipped.`;
|
|
@@ -18026,7 +19285,22 @@ async function readSettingsWithLocalOverlay({ outputRoot, relativeDirPath, baseF
|
|
|
18026
19285
|
}
|
|
18027
19286
|
/** Quotes a name read off disk, the way every other such name is logged. */
|
|
18028
19287
|
function quoteKey(key) {
|
|
18029
|
-
return
|
|
19288
|
+
return quoteValueForWarning(key);
|
|
19289
|
+
}
|
|
19290
|
+
/**
|
|
19291
|
+
* How many keys the warning names before it stops counting.
|
|
19292
|
+
*
|
|
19293
|
+
* The keys come from a file rulesync did not write, and the warning now travels
|
|
19294
|
+
* into `--json` documents and MCP results as well as onto a console. A settings
|
|
19295
|
+
* file with hundreds of top-level keys is unusual but not impossible, and the
|
|
19296
|
+
* point of the sentence is to make the reader open the file — naming the first
|
|
19297
|
+
* few does that as well as naming all of them.
|
|
19298
|
+
*/
|
|
19299
|
+
const MAX_LISTED_KEYS = 20;
|
|
19300
|
+
function listKeys(keys) {
|
|
19301
|
+
const named = keys.slice(0, MAX_LISTED_KEYS).map(quoteKey).join(", ");
|
|
19302
|
+
const rest = keys.length - MAX_LISTED_KEYS;
|
|
19303
|
+
return rest > 0 ? `${named} and ${rest} more` : named;
|
|
18030
19304
|
}
|
|
18031
19305
|
/**
|
|
18032
19306
|
* Name the settings the machine-local file contributed, so nobody publishes one
|
|
@@ -18046,8 +19320,8 @@ function warnAboutLocalKeys({ localParsed, configPath, toolLabel, sensitiveKeys,
|
|
|
18046
19320
|
const keys = Object.keys(localParsed);
|
|
18047
19321
|
if (keys.length === 0) return;
|
|
18048
19322
|
const flagged = keys.filter((key) => sensitiveKeys.includes(key));
|
|
18049
|
-
const guardrailSentence = flagged.length === 0 ? "" : ` ${flagged
|
|
18050
|
-
warnOnceWithFallback(logger, `${toolLabel}: ${configPath} is a machine-local overrides file, and importing read ${keys
|
|
19323
|
+
const guardrailSentence = flagged.length === 0 ? "" : ` ${listKeys(flagged)} ${flagged.length === 1 ? "decides" : "decide"} what ${toolLabel} is allowed to do, so a value meant for one machine would become the team's guardrail.`;
|
|
19324
|
+
warnOnceWithFallback(logger, `${toolLabel}: ${configPath} is a machine-local overrides file, and importing read ${listKeys(keys)} from it. Whatever an import takes from there lands in files rulesync commits, so check the imported files and remove anything personal to this machine before sharing them.${guardrailSentence}`);
|
|
18051
19325
|
}
|
|
18052
19326
|
//#endregion
|
|
18053
19327
|
//#region src/utils/augmentcode-settings.ts
|
|
@@ -22742,6 +24016,9 @@ function withToolTargetPrefix({ logger, toolTarget }) {
|
|
|
22742
24016
|
get silent() {
|
|
22743
24017
|
return logger.silent;
|
|
22744
24018
|
},
|
|
24019
|
+
get reportsWhileSilent() {
|
|
24020
|
+
return logger.reportsWhileSilent;
|
|
24021
|
+
},
|
|
22745
24022
|
get jsonMode() {
|
|
22746
24023
|
return logger.jsonMode;
|
|
22747
24024
|
},
|
|
@@ -26042,7 +27319,7 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
|
|
|
26042
27319
|
json;
|
|
26043
27320
|
constructor(params) {
|
|
26044
27321
|
super(params);
|
|
26045
|
-
this.json = this.fileContent !== void 0 ?
|
|
27322
|
+
this.json = this.fileContent !== void 0 ? parseJsonc(this.fileContent) : {};
|
|
26046
27323
|
}
|
|
26047
27324
|
getJson() {
|
|
26048
27325
|
return this.json;
|
|
@@ -28265,7 +29542,7 @@ var KiloMcp = class KiloMcp extends ToolMcp {
|
|
|
28265
29542
|
}, null, 2) });
|
|
28266
29543
|
}
|
|
28267
29544
|
validate() {
|
|
28268
|
-
const json =
|
|
29545
|
+
const json = parseJsonc(this.fileContent || "{}");
|
|
28269
29546
|
const result = KiloConfigSchema.safeParse(json);
|
|
28270
29547
|
if (!result.success) return {
|
|
28271
29548
|
success: false,
|
|
@@ -28856,7 +30133,7 @@ function convertFromMusecodeFormat(musecodeMcp) {
|
|
|
28856
30133
|
if (key === "mode") {
|
|
28857
30134
|
const mode = asMusecodeMode(value);
|
|
28858
30135
|
if (mode === void 0) {
|
|
28859
|
-
warnWithFallback(void 0, `Muse Code MCP: dropping mode ${
|
|
30136
|
+
warnWithFallback(void 0, `Muse Code MCP: dropping mode ${quoteValueForWarning(value)} on server ${quoteValueForWarning(name)} because it is neither "required" nor "optional", the only two modes Muse Code documents.`);
|
|
28860
30137
|
continue;
|
|
28861
30138
|
}
|
|
28862
30139
|
converted.musecodeMode = mode;
|
|
@@ -29341,7 +30618,7 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
|
|
|
29341
30618
|
}, null, 2) });
|
|
29342
30619
|
}
|
|
29343
30620
|
validate() {
|
|
29344
|
-
const json =
|
|
30621
|
+
const json = parseJsonc(this.fileContent || "{}");
|
|
29345
30622
|
const result = OpencodeConfigSchema.safeParse(json);
|
|
29346
30623
|
if (!result.success) return {
|
|
29347
30624
|
success: false,
|
|
@@ -30060,7 +31337,7 @@ function pointerLabels(global) {
|
|
|
30060
31337
|
async function warnAtDocumentedDefault({ existing, outputRoot, logger }) {
|
|
30061
31338
|
const { pointer, configLabel, mcpLabel } = pointerLabels(true);
|
|
30062
31339
|
const displaced = await describeDisplacedGlobalServers({ outputRoot });
|
|
30063
|
-
logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${
|
|
31340
|
+
logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${quoteValueForWarning(existing)} in ${configLabel}. That is the default Atlassian's settings reference documents, so it may be Rovo Dev's own value rather than one you chose — and while it stands, the generated ${mcpLabel} is never read. ` + (displaced === null ? `It defines no servers of its own, so setting mcp.mcpConfigPath to "${pointer}" costs nothing.` : `${displaced} — move the servers you want to keep into .rulesync/mcp.jsonc first, or none at all if it turns out to hold nothing you need, then change mcp.mcpConfigPath to "${pointer}".`));
|
|
30064
31341
|
}
|
|
30065
31342
|
/**
|
|
30066
31343
|
* Announce a pointer that was just written. Warned rather than noted in global
|
|
@@ -30117,10 +31394,10 @@ async function applyMcpConfigPointer({ existingMcp, global, hasLiveServers, outp
|
|
|
30117
31394
|
return false;
|
|
30118
31395
|
}
|
|
30119
31396
|
if (global && normalizedExisting !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalizedExisting)) {
|
|
30120
|
-
logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} is ${
|
|
31397
|
+
logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} is ${quoteValueForWarning(existing)}. That names ${mcpLabel} only if Rovo Dev expands environment variables in this setting, which Atlassian does not document — if it does not, the path resolves literally and Rovo Dev reads no MCP servers at all. Write "${pointer}" instead, the form its own documented default uses.`);
|
|
30121
31398
|
return false;
|
|
30122
31399
|
}
|
|
30123
|
-
logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${
|
|
31400
|
+
logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath as ${quoteValueForWarning(existing)} in ${configLabel}. Rovo Dev reads MCP servers from that path, so the generated ${mcpLabel} is unused until it is set to "${pointer}".`);
|
|
30124
31401
|
return false;
|
|
30125
31402
|
}
|
|
30126
31403
|
/**
|
|
@@ -31740,11 +33017,10 @@ var ToolPermissions = class extends ToolFile {
|
|
|
31740
33017
|
throw new Error("Please implement this method in the subclass.");
|
|
31741
33018
|
}
|
|
31742
33019
|
toRulesyncPermissionsDefault({ fileContent }) {
|
|
31743
|
-
return
|
|
33020
|
+
return RulesyncPermissions.fromImportedFileContent({
|
|
31744
33021
|
outputRoot: this.outputRoot,
|
|
31745
|
-
|
|
31746
|
-
|
|
31747
|
-
fileContent: withoutBlankPermissionPatterns({ fileContent })
|
|
33022
|
+
fileContent,
|
|
33023
|
+
sourcePath: this.getRelativePathFromCwd()
|
|
31748
33024
|
});
|
|
31749
33025
|
}
|
|
31750
33026
|
static async fromFile(_params) {
|
|
@@ -32796,41 +34072,123 @@ function matchesGlobStep(step, character) {
|
|
|
32796
34072
|
const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
|
|
32797
34073
|
return step.negated ? !admitted : admitted;
|
|
32798
34074
|
}
|
|
34075
|
+
/** Whether two single-character steps can both match one same character. */
|
|
34076
|
+
function stepsShareACharacter(left, right) {
|
|
34077
|
+
if (left.kind === "any" || right.kind === "any") return true;
|
|
34078
|
+
if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
|
|
34079
|
+
if (left.kind === "literal") return matchesGlobStep(right, left.character);
|
|
34080
|
+
if (right.kind === "literal") return matchesGlobStep(left, right.character);
|
|
34081
|
+
return true;
|
|
34082
|
+
}
|
|
34083
|
+
/** Whether every step from `index` on can match the empty string. */
|
|
34084
|
+
function isAllStars(steps, index) {
|
|
34085
|
+
for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
|
|
34086
|
+
return true;
|
|
34087
|
+
}
|
|
34088
|
+
/**
|
|
34089
|
+
* The most work one intersection walk will do, counted in cells times the cost
|
|
34090
|
+
* of one. Past it the two patterns are reported as intersecting without being
|
|
34091
|
+
* walked: the product of two lengths grows quadratically, and a pattern long
|
|
34092
|
+
* enough to reach this is pathological rather than a command anybody typed.
|
|
34093
|
+
* Answering `true` withholds an `allow`, which is the direction that fails
|
|
34094
|
+
* closed.
|
|
34095
|
+
*/
|
|
34096
|
+
const MAX_INTERSECTION_CELLS = 1e6;
|
|
34097
|
+
/**
|
|
34098
|
+
* The most work a whole run of comparisons will do. A caller holding R
|
|
34099
|
+
* restrictions and A allow rules asks R x A times, and a per-pair cap alone
|
|
34100
|
+
* bounds none of that: a hundred restrictions against a hundred allow rules,
|
|
34101
|
+
* each pattern just under the per-pair cap, is ten thousand affordable walks
|
|
34102
|
+
* that together take minutes. The shared budget is spent down across the run
|
|
34103
|
+
* and, once it is gone, every remaining pair is reported as intersecting —
|
|
34104
|
+
* again the direction that withholds an `allow` rather than writing one.
|
|
34105
|
+
*/
|
|
34106
|
+
const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
|
|
34107
|
+
/**
|
|
34108
|
+
* What a pair costs on top of the cells it walks: the call itself, sizing and
|
|
34109
|
+
* filling the two rows the table is held in, and collecting the answer.
|
|
34110
|
+
* Charging only cells would leave the *number* of pairs unbounded — a pair of
|
|
34111
|
+
* one-step patterns walks a single cell, so n short restrictions against n
|
|
34112
|
+
* short allow rules is n squared comparisons that never spend the budget down
|
|
34113
|
+
* however many of them there are. Charging a floor per pair puts pair count and
|
|
34114
|
+
* walk length on the same exhaustible resource.
|
|
34115
|
+
*
|
|
34116
|
+
* For the short patterns of an ordinary config the floor is the whole charge,
|
|
34117
|
+
* which lowers how many pairs a run compares from around a million to about
|
|
34118
|
+
* 150,000 — roughly 400 restrictions against 400 allow rules. A config past
|
|
34119
|
+
* that line withholds every allow it has not yet compared, the same fail-closed
|
|
34120
|
+
* answer exhaustion gives everywhere else.
|
|
34121
|
+
*/
|
|
34122
|
+
const INTERSECTION_PAIR_COST = 64;
|
|
34123
|
+
/**
|
|
34124
|
+
* A budget for one caller's run of comparisons. Hand the same one to every
|
|
34125
|
+
* `parsedGlobsIntersect` call that belongs together — one adapter reading one
|
|
34126
|
+
* config — so the run as a whole stays bounded rather than only each pair in
|
|
34127
|
+
* it.
|
|
34128
|
+
*/
|
|
34129
|
+
function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
|
|
34130
|
+
return { remaining };
|
|
34131
|
+
}
|
|
32799
34132
|
/**
|
|
32800
|
-
* Parse `glob`
|
|
32801
|
-
*
|
|
34133
|
+
* Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
|
|
34134
|
+
* the same pattern against a whole list parses it once and reuses the result.
|
|
32802
34135
|
*/
|
|
32803
|
-
function
|
|
34136
|
+
function parseGlobPattern(glob) {
|
|
32804
34137
|
const steps = parseGlob(glob);
|
|
32805
|
-
return
|
|
34138
|
+
return {
|
|
34139
|
+
steps,
|
|
34140
|
+
maxRanges: maxRangeCount(steps)
|
|
34141
|
+
};
|
|
32806
34142
|
}
|
|
32807
|
-
|
|
32808
|
-
|
|
32809
|
-
|
|
32810
|
-
|
|
32811
|
-
|
|
32812
|
-
|
|
32813
|
-
|
|
32814
|
-
|
|
32815
|
-
|
|
32816
|
-
|
|
32817
|
-
|
|
32818
|
-
|
|
32819
|
-
|
|
34143
|
+
/**
|
|
34144
|
+
* What one cell can cost, as a multiplier on the cell count. A literal met by a
|
|
34145
|
+
* `[a-z...]` class walks that class's ranges, so a single class carrying
|
|
34146
|
+
* thousands of them turns a walk that looks affordable by cell count alone into
|
|
34147
|
+
* a quadratic one — which is why the budget is spent on cells times this rather
|
|
34148
|
+
* than on cells.
|
|
34149
|
+
*/
|
|
34150
|
+
function maxRangeCount(steps) {
|
|
34151
|
+
let most = 0;
|
|
34152
|
+
for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
|
|
34153
|
+
return most;
|
|
34154
|
+
}
|
|
34155
|
+
/**
|
|
34156
|
+
* `globsIntersect` for two globs already parsed, optionally spending a budget
|
|
34157
|
+
* shared with the rest of the caller's run — see `createIntersectionBudget`.
|
|
34158
|
+
* Once that budget is exhausted every further pair answers `true` without being
|
|
34159
|
+
* walked, so a caller reading the answer as a reason to restrict stays on the
|
|
34160
|
+
* safe side.
|
|
34161
|
+
*/
|
|
34162
|
+
function parsedGlobsIntersect(left, right, budget) {
|
|
34163
|
+
const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
|
|
34164
|
+
const cellCost = 1 + left.maxRanges + right.maxRanges;
|
|
34165
|
+
const cost = rows.length * columns.length * cellCost;
|
|
34166
|
+
if (cost > MAX_INTERSECTION_CELLS) return true;
|
|
34167
|
+
if (budget !== void 0) {
|
|
34168
|
+
const charge = cost + INTERSECTION_PAIR_COST;
|
|
34169
|
+
if (charge > budget.remaining) {
|
|
34170
|
+
budget.remaining = 0;
|
|
34171
|
+
return true;
|
|
32820
34172
|
}
|
|
32821
|
-
|
|
32822
|
-
|
|
32823
|
-
|
|
32824
|
-
|
|
34173
|
+
budget.remaining -= charge;
|
|
34174
|
+
}
|
|
34175
|
+
let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
|
|
34176
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
34177
|
+
const row = Array.from({ length: columns.length + 1 }, () => false);
|
|
34178
|
+
row[columns.length] = isAllStars(rows, i);
|
|
34179
|
+
for (let j = columns.length - 1; j >= 0; j--) {
|
|
34180
|
+
const rowStep = rows[i];
|
|
34181
|
+
const columnStep = columns[j];
|
|
34182
|
+
if (rowStep === void 0 || columnStep === void 0) continue;
|
|
34183
|
+
if (rowStep.kind === "star" || columnStep.kind === "star") {
|
|
34184
|
+
row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
|
|
34185
|
+
continue;
|
|
34186
|
+
}
|
|
34187
|
+
row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
|
|
32825
34188
|
}
|
|
32826
|
-
|
|
32827
|
-
starCharacterIndex += 1;
|
|
32828
|
-
stepIndex = starStepIndex + 1;
|
|
32829
|
-
characterIndex = starCharacterIndex;
|
|
34189
|
+
next = row;
|
|
32830
34190
|
}
|
|
32831
|
-
|
|
32832
|
-
while (steps[remaining]?.kind === "star") remaining += 1;
|
|
32833
|
-
return remaining === steps.length;
|
|
34191
|
+
return next[0] ?? false;
|
|
32834
34192
|
}
|
|
32835
34193
|
//#endregion
|
|
32836
34194
|
//#region src/features/permissions/augmentcode-permissions.ts
|
|
@@ -33293,6 +34651,216 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
|
|
|
33293
34651
|
}
|
|
33294
34652
|
return { permission };
|
|
33295
34653
|
}
|
|
34654
|
+
/**
|
|
34655
|
+
* Collect the canonical rules that govern shell commands, for the adapters
|
|
34656
|
+
* whose tool models commands and nothing else.
|
|
34657
|
+
*
|
|
34658
|
+
* The `bash` category contributes every rule. The all-tools `*` category
|
|
34659
|
+
* contributes its **restricting** rules — `deny` and `ask` — because a rule
|
|
34660
|
+
* written there covers shell commands too, and dropping it inverts the
|
|
34661
|
+
* author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
|
|
34662
|
+
* an adapter that reads only `bash` auto-approves the very command the file
|
|
34663
|
+
* denies.
|
|
34664
|
+
*
|
|
34665
|
+
* Its `allow` rules are deliberately **not** contributed. A pattern under `*`
|
|
34666
|
+
* need not be a command at all — `secrets/**` under `*` denies a path — and
|
|
34667
|
+
* carrying it in the restricting direction only over-restricts, while carrying
|
|
34668
|
+
* it in the permissive direction would grant something the author never said
|
|
34669
|
+
* about commands. Both directions therefore fail closed.
|
|
34670
|
+
*/
|
|
34671
|
+
function collectShellCommandRules(permission) {
|
|
34672
|
+
const rules = [];
|
|
34673
|
+
const foreignRestrictingCategories = [];
|
|
34674
|
+
const ignoredAllToolsAllowPatterns = [];
|
|
34675
|
+
for (const [category, categoryRules] of Object.entries(permission)) {
|
|
34676
|
+
if (category === "bash") {
|
|
34677
|
+
for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
|
|
34678
|
+
pattern,
|
|
34679
|
+
action,
|
|
34680
|
+
fromAllToolsCategory: false
|
|
34681
|
+
});
|
|
34682
|
+
continue;
|
|
34683
|
+
}
|
|
34684
|
+
if (category === "*") {
|
|
34685
|
+
for (const [pattern, action] of Object.entries(categoryRules)) {
|
|
34686
|
+
if (action === "allow") {
|
|
34687
|
+
ignoredAllToolsAllowPatterns.push(pattern);
|
|
34688
|
+
continue;
|
|
34689
|
+
}
|
|
34690
|
+
rules.push({
|
|
34691
|
+
pattern,
|
|
34692
|
+
action,
|
|
34693
|
+
fromAllToolsCategory: true
|
|
34694
|
+
});
|
|
34695
|
+
}
|
|
34696
|
+
continue;
|
|
34697
|
+
}
|
|
34698
|
+
if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
|
|
34699
|
+
}
|
|
34700
|
+
return {
|
|
34701
|
+
rules,
|
|
34702
|
+
foreignRestrictingCategories,
|
|
34703
|
+
ignoredAllToolsAllowPatterns
|
|
34704
|
+
};
|
|
34705
|
+
}
|
|
34706
|
+
/**
|
|
34707
|
+
* Build the test an adapter applies to an `allow` pattern before writing it:
|
|
34708
|
+
* which restrictions it cannot write name some of the same commands? The
|
|
34709
|
+
* answer is the list of those restrictions — empty when the `allow` may be
|
|
34710
|
+
* written — so a caller can report both the allow rules it withheld and the
|
|
34711
|
+
* restrictions that withheld nothing.
|
|
34712
|
+
*
|
|
34713
|
+
* Canonically the stricter rule wins **whatever its width** — rulesync collapses
|
|
34714
|
+
* colliding rules as `deny > ask > allow` — so the two patterns are compared by
|
|
34715
|
+
* asking whether any one command matches both. Width does not enter into it: an
|
|
34716
|
+
* `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
|
|
34717
|
+
* an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
|
|
34718
|
+
* on every `git ... --force` command even though neither pattern covers the
|
|
34719
|
+
* other's spelling. Comparing only identical spellings would let the most
|
|
34720
|
+
* ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
|
|
34721
|
+
*
|
|
34722
|
+
* Identical spellings are still compared as strings first, as a shortcut past
|
|
34723
|
+
* the walk for the commonest case.
|
|
34724
|
+
*
|
|
34725
|
+
* `normalizePattern` rewrites a pattern written in the tool's own language into
|
|
34726
|
+
* the widest glob it could stand for, for a tool whose patterns are not globs.
|
|
34727
|
+
* It reaches the `bash` rules and the `allow` rules, which is where such a
|
|
34728
|
+
* pattern is written; an all-tools `*` pattern is canonical — it is read by
|
|
34729
|
+
* every tool, so it is a glob already — and is compared as it stands. The
|
|
34730
|
+
* rewrite must only ever widen what a pattern covers, so an inexact reading
|
|
34731
|
+
* withholds an allow rather than writing one the config restricts — see
|
|
34732
|
+
* `warpCommandPatternToGlob`.
|
|
34733
|
+
*/
|
|
34734
|
+
function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
|
|
34735
|
+
const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
|
|
34736
|
+
pattern,
|
|
34737
|
+
glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
|
|
34738
|
+
}));
|
|
34739
|
+
return (allowPattern) => {
|
|
34740
|
+
if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
|
|
34741
|
+
const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
|
|
34742
|
+
return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
|
|
34743
|
+
};
|
|
34744
|
+
}
|
|
34745
|
+
/**
|
|
34746
|
+
* Which of the given all-tools `*` restrictions look like they may not name a
|
|
34747
|
+
* command at all — the question a `deny` and an `ask` written there both raise.
|
|
34748
|
+
*
|
|
34749
|
+
* "Withheld no allow rule" alone does not answer it: a config with no `allow`
|
|
34750
|
+
* rules has nothing to withhold, and a pattern the author also wrote under
|
|
34751
|
+
* `bash` is a command on their own word. Both are excluded, so what remains is
|
|
34752
|
+
* a `*` pattern that had allow rules to overlap, overlapped none of them, and
|
|
34753
|
+
* is claimed as a command nowhere else — the shape `secrets/**` has.
|
|
34754
|
+
*
|
|
34755
|
+
* A `bash` restriction never belongs here: it names a command by construction,
|
|
34756
|
+
* so overlapping no allow rule says nothing is wrong with it.
|
|
34757
|
+
*/
|
|
34758
|
+
function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
|
|
34759
|
+
if (!rules.some(({ action }) => action === "allow")) return [];
|
|
34760
|
+
const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
|
|
34761
|
+
return uniq(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
|
|
34762
|
+
}
|
|
34763
|
+
/**
|
|
34764
|
+
* Split shell-command rules into the allow and deny lists of a tool that models
|
|
34765
|
+
* commands with those two tiers and nothing else.
|
|
34766
|
+
*
|
|
34767
|
+
* `ask` has no list of its own — such a tool already prompts for whatever it
|
|
34768
|
+
* does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
|
|
34769
|
+
* still has to *withhold* the `allow` rules it covers, though: the canonical
|
|
34770
|
+
* order is `deny > ask > allow`, so auto-approving a command the file also asks
|
|
34771
|
+
* about would answer the prompt the author wanted.
|
|
34772
|
+
*
|
|
34773
|
+
* `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
|
|
34774
|
+
* from the all-tools `*` category. Warp's cannot: it matches commands with
|
|
34775
|
+
* regular expressions rather than globs, and writing any denylist **replaces**
|
|
34776
|
+
* Warp's built-in default one, so an inert `secrets/**` entry there would trade
|
|
34777
|
+
* the tool's own protection for a rule that matches no command. Where the deny
|
|
34778
|
+
* cannot be written it withholds the allow rules it covers instead, which
|
|
34779
|
+
* restricts in the same direction without touching the denylist.
|
|
34780
|
+
*
|
|
34781
|
+
* A `bash` deny withholds nothing: it names a command by construction, so the
|
|
34782
|
+
* denylist entry enforces it wherever the tool's deny-beats-allow order applies,
|
|
34783
|
+
* and a narrow deny keeps carving an exception out of a wider allow (`git *`
|
|
34784
|
+
* allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
|
|
34785
|
+
* even where it is written: a pattern under `*` need not name a command —
|
|
34786
|
+
* `secrets/**` there denies a path — so as a denylist entry it may match nothing
|
|
34787
|
+
* at all, and leaving an overlapping allow beside it would auto-approve the very
|
|
34788
|
+
* commands the author meant to stop. Over-restricting a `*` deny that *was* a
|
|
34789
|
+
* command pattern is reported; failing open would not be.
|
|
34790
|
+
*
|
|
34791
|
+
* `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
|
|
34792
|
+
* patterns are not globs.
|
|
34793
|
+
*/
|
|
34794
|
+
function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
|
|
34795
|
+
const deny = [];
|
|
34796
|
+
const unwrittenDenyPatterns = [];
|
|
34797
|
+
const restrictions = [];
|
|
34798
|
+
const writtenAllToolsDenyPatterns = [];
|
|
34799
|
+
const allToolsAskPatterns = [];
|
|
34800
|
+
for (const rule of rules) {
|
|
34801
|
+
const { pattern, action, fromAllToolsCategory } = rule;
|
|
34802
|
+
if (action === "allow") continue;
|
|
34803
|
+
if (action !== "deny") {
|
|
34804
|
+
restrictions.push(rule);
|
|
34805
|
+
if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
|
|
34806
|
+
continue;
|
|
34807
|
+
}
|
|
34808
|
+
if (writesAllToolsDeny || !fromAllToolsCategory) {
|
|
34809
|
+
deny.push(pattern);
|
|
34810
|
+
if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
|
|
34811
|
+
} else unwrittenDenyPatterns.push(pattern);
|
|
34812
|
+
if (fromAllToolsCategory) restrictions.push(rule);
|
|
34813
|
+
}
|
|
34814
|
+
const budget = createIntersectionBudget();
|
|
34815
|
+
const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
|
|
34816
|
+
normalizePattern,
|
|
34817
|
+
budget
|
|
34818
|
+
});
|
|
34819
|
+
const allow = [];
|
|
34820
|
+
const shadowedAllowPatterns = [];
|
|
34821
|
+
const withholdingPatterns = /* @__PURE__ */ new Set();
|
|
34822
|
+
for (const { pattern, action } of rules) {
|
|
34823
|
+
if (action !== "allow") continue;
|
|
34824
|
+
const shadowing = shadowingRestrictions(pattern);
|
|
34825
|
+
if (shadowing.length > 0) {
|
|
34826
|
+
shadowedAllowPatterns.push(pattern);
|
|
34827
|
+
for (const restriction of shadowing) withholdingPatterns.add(restriction);
|
|
34828
|
+
continue;
|
|
34829
|
+
}
|
|
34830
|
+
allow.push(pattern);
|
|
34831
|
+
}
|
|
34832
|
+
return {
|
|
34833
|
+
allow,
|
|
34834
|
+
deny,
|
|
34835
|
+
shadowedAllowPatterns,
|
|
34836
|
+
unwrittenDenyPatterns,
|
|
34837
|
+
unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
|
|
34838
|
+
rules,
|
|
34839
|
+
allToolsPatterns: writtenAllToolsDenyPatterns,
|
|
34840
|
+
withholdingPatterns
|
|
34841
|
+
}),
|
|
34842
|
+
unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
|
|
34843
|
+
rules,
|
|
34844
|
+
allToolsPatterns: allToolsAskPatterns,
|
|
34845
|
+
withholdingPatterns
|
|
34846
|
+
}),
|
|
34847
|
+
intersectionBudgetExhausted: budget.remaining === 0
|
|
34848
|
+
};
|
|
34849
|
+
}
|
|
34850
|
+
/**
|
|
34851
|
+
* Report, for one command-only tool, every canonical rule its two lists could
|
|
34852
|
+
* not carry. Every command-only adapter shares this reporting, so a rule
|
|
34853
|
+
* dropped in one is worded the same way in all.
|
|
34854
|
+
*/
|
|
34855
|
+
function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
|
|
34856
|
+
if (intersectionBudgetExhausted) warnWithFallback(logger, `${toolLabel} reached the limit on how much work one generation may spend comparing .rulesync/permissions.jsonc's allow rules against its deny and ask rules, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than the file asks for. Write fewer or shorter command patterns to have them all compared.`);
|
|
34857
|
+
for (const category of foreignRestrictingCategories) warnWithFallback(logger, `${toolLabel} only models shell-command permissions (${surfaceLabel}); '${category}' deny and ask rules cannot be represented and were skipped.`);
|
|
34858
|
+
if (unwrittenDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} did not write the all-tools '*' deny rule(s) for ${unwrittenDenyPatterns.join(", ")} into its denylist.${unwrittenDenyReason === void 0 ? "" : ` ${unwrittenDenyReason}`} They restrict only by withholding the allow rules they cover; write them under 'bash' to have them enforced as commands.`);
|
|
34859
|
+
if (unenforcedAllToolsDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} wrote the all-tools '*' deny rule(s) for ${unenforcedAllToolsDenyPatterns.join(", ")} into its denylist as they stand, but they withheld none of the allow rules beside them. A pattern written under '*' need not name a command — 'secrets/**' there denies a path — and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern.`);
|
|
34860
|
+
if (unenforcedAllToolsAskPatterns.length > 0) warnWithFallback(logger, `${toolLabel} has no ask tier (${surfaceLabel}), so the all-tools '*' ask rule(s) for ${unenforcedAllToolsAskPatterns.join(", ")} restrict only by withholding the allow rules they cover — and they covered none. A pattern written under '*' need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns.`);
|
|
34861
|
+
if (ignoredAllToolsAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} reads the all-tools '*' category for its deny and ask rules only, so the allow rule(s) for ${ignoredAllToolsAllowPatterns.join(", ")} were skipped — a pattern written under '*' need not be a command. Write them under 'bash' to auto-approve them as commands.`);
|
|
34862
|
+
if (shadowedAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} was not given the allow rule(s) for ${shadowedAllowPatterns.join(", ")} because .rulesync/permissions.jsonc restricts the same commands elsewhere, and the stricter rule wins whatever its width.`);
|
|
34863
|
+
}
|
|
33296
34864
|
//#endregion
|
|
33297
34865
|
//#region src/features/permissions/claudecode-permissions.ts
|
|
33298
34866
|
/**
|
|
@@ -34223,8 +35791,10 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
|
|
|
34223
35791
|
const ask = [];
|
|
34224
35792
|
const deny = [];
|
|
34225
35793
|
const actionByEntry = /* @__PURE__ */ new Map();
|
|
35794
|
+
const allToolsPatterns = [];
|
|
34226
35795
|
for (const [category, rules] of Object.entries(config.permission)) {
|
|
34227
35796
|
const claudeToolName = toClaudeToolName(category);
|
|
35797
|
+
if (category === "*") allToolsPatterns.push(...Object.keys(rules));
|
|
34228
35798
|
for (const [pattern, action] of Object.entries(rules)) {
|
|
34229
35799
|
const entry = buildClaudePermissionEntry(claudeToolName, pattern);
|
|
34230
35800
|
const previous = actionByEntry.get(entry);
|
|
@@ -34241,6 +35811,7 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
|
|
|
34241
35811
|
}
|
|
34242
35812
|
}
|
|
34243
35813
|
}
|
|
35814
|
+
if (allToolsPatterns.length > 0) logger?.warn(`Claude Code permissions: a rule names one tool, and there is no name standing for every tool, so the all-tools '*' rule(s) for ${allToolsPatterns.join(", ")} were written as '*(pattern)' entries that Claude Code matches against no tool. Write them under the categories they are meant for (for example 'bash' or 'read') to have them enforced.`);
|
|
34244
35815
|
return {
|
|
34245
35816
|
allow,
|
|
34246
35817
|
ask,
|
|
@@ -34285,34 +35856,64 @@ const ClineCommandPermissionsSchema = z.looseObject({
|
|
|
34285
35856
|
});
|
|
34286
35857
|
/**
|
|
34287
35858
|
* Translate rulesync permission categories into Cline allow/deny command lists.
|
|
34288
|
-
*
|
|
34289
|
-
*
|
|
35859
|
+
* The `bash` category maps, and so do the restricting rules of the all-tools
|
|
35860
|
+
* `*` category — a rule written there covers shell commands too. Other
|
|
35861
|
+
* categories and `ask` rules are tracked separately so a single translation
|
|
35862
|
+
* notice can be surfaced by the caller.
|
|
34290
35863
|
*/
|
|
34291
35864
|
function translateClinePermissions(permission) {
|
|
34292
35865
|
const allow = [];
|
|
34293
35866
|
const deny = [];
|
|
34294
|
-
const droppedCategories = [];
|
|
34295
35867
|
const translatedAskPatterns = [];
|
|
34296
|
-
|
|
34297
|
-
|
|
34298
|
-
|
|
34299
|
-
|
|
34300
|
-
|
|
34301
|
-
|
|
34302
|
-
|
|
34303
|
-
|
|
34304
|
-
|
|
35868
|
+
const shadowedAllowPatterns = [];
|
|
35869
|
+
const droppedCategories = Object.keys(permission).filter((category) => category !== "bash" && category !== "*");
|
|
35870
|
+
const { rules, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permission);
|
|
35871
|
+
const budget = createIntersectionBudget();
|
|
35872
|
+
const shadowingRestrictions = createShadowingRestrictionsTest(rules.filter(({ fromAllToolsCategory }) => fromAllToolsCategory), { budget });
|
|
35873
|
+
const allToolsDenyPatterns = [];
|
|
35874
|
+
const allToolsAskPatterns = [];
|
|
35875
|
+
const withholdingPatterns = /* @__PURE__ */ new Set();
|
|
35876
|
+
for (const { pattern, action, fromAllToolsCategory } of rules) {
|
|
35877
|
+
if (action === "ask") {
|
|
35878
|
+
if (fromAllToolsCategory) {
|
|
35879
|
+
allToolsAskPatterns.push(pattern);
|
|
34305
35880
|
continue;
|
|
34306
35881
|
}
|
|
34307
|
-
|
|
34308
|
-
|
|
35882
|
+
translatedAskPatterns.push(pattern);
|
|
35883
|
+
deny.push(pattern);
|
|
35884
|
+
continue;
|
|
35885
|
+
}
|
|
35886
|
+
if (action === "deny") {
|
|
35887
|
+
deny.push(pattern);
|
|
35888
|
+
if (fromAllToolsCategory) allToolsDenyPatterns.push(pattern);
|
|
35889
|
+
continue;
|
|
35890
|
+
}
|
|
35891
|
+
const shadowing = shadowingRestrictions(pattern);
|
|
35892
|
+
if (shadowing.length > 0) {
|
|
35893
|
+
shadowedAllowPatterns.push(pattern);
|
|
35894
|
+
for (const restriction of shadowing) withholdingPatterns.add(restriction);
|
|
35895
|
+
continue;
|
|
34309
35896
|
}
|
|
35897
|
+
allow.push(pattern);
|
|
34310
35898
|
}
|
|
34311
35899
|
return {
|
|
34312
35900
|
allow,
|
|
34313
35901
|
deny,
|
|
34314
35902
|
droppedCategories,
|
|
34315
|
-
translatedAskPatterns
|
|
35903
|
+
translatedAskPatterns,
|
|
35904
|
+
shadowedAllowPatterns,
|
|
35905
|
+
unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
|
|
35906
|
+
rules,
|
|
35907
|
+
allToolsPatterns: allToolsDenyPatterns,
|
|
35908
|
+
withholdingPatterns
|
|
35909
|
+
}),
|
|
35910
|
+
unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
|
|
35911
|
+
rules,
|
|
35912
|
+
allToolsPatterns: allToolsAskPatterns,
|
|
35913
|
+
withholdingPatterns
|
|
35914
|
+
}),
|
|
35915
|
+
ignoredAllToolsAllowPatterns,
|
|
35916
|
+
intersectionBudgetExhausted: budget.remaining === 0
|
|
34316
35917
|
};
|
|
34317
35918
|
}
|
|
34318
35919
|
/**
|
|
@@ -34321,11 +35922,16 @@ function translateClinePermissions(permission) {
|
|
|
34321
35922
|
* project convention used by every other permissions translator, and
|
|
34322
35923
|
* (b) the user still sees one prominent "WARNING" message describing the translation.
|
|
34323
35924
|
*/
|
|
34324
|
-
function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, logger }) {
|
|
34325
|
-
if (droppedCategories.length === 0 && translatedAskPatterns.length === 0) return;
|
|
35925
|
+
function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted, logger }) {
|
|
35926
|
+
if (droppedCategories.length === 0 && translatedAskPatterns.length === 0 && shadowedAllowPatterns.length === 0 && unenforcedAllToolsDenyPatterns.length === 0 && unenforcedAllToolsAskPatterns.length === 0 && ignoredAllToolsAllowPatterns.length === 0 && !intersectionBudgetExhausted) return;
|
|
34326
35927
|
const parts = [];
|
|
34327
35928
|
if (droppedCategories.length > 0) parts.push(`non-bash categories [${droppedCategories.join(", ")}] (Cline only enforces shell commands; use the rulesync ignore feature for read/write restrictions)`);
|
|
34328
35929
|
if (translatedAskPatterns.length > 0) parts.push(`'ask' rules for bash patterns [${translatedAskPatterns.join(", ")}] translated to 'deny' for fail-closed safety, since Cline lacks 'ask'`);
|
|
35930
|
+
if (shadowedAllowPatterns.length > 0) parts.push(`'allow' rules for [${shadowedAllowPatterns.join(", ")}] withheld because the all-tools '*' category restricts the same commands, and a pattern written there need not name a command Cline's own lists can act on. Cline's allowlist is a gate — once set, only the commands matching it run without approval — so withholding every entry leaves every command asking`);
|
|
35931
|
+
if (unenforcedAllToolsDenyPatterns.length > 0) parts.push(`'deny' rules for [${unenforcedAllToolsDenyPatterns.join(", ")}] under the all-tools '*' category written into the denylist as they stand, where they withheld none of the allow rules beside them — a pattern written there need not name a command, and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern`);
|
|
35932
|
+
if (unenforcedAllToolsAskPatterns.length > 0) parts.push(`'ask' rules for [${unenforcedAllToolsAskPatterns.join(", ")}] under the all-tools '*' category left with nothing to do, since Cline has no ask tier and they withheld none of the allow rules beside them — a pattern written there need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns`);
|
|
35933
|
+
if (ignoredAllToolsAllowPatterns.length > 0) parts.push(`'allow' rules for [${ignoredAllToolsAllowPatterns.join(", ")}] under the all-tools '*' category skipped (only its deny and ask rules are read, since a pattern written there need not be a command); write them under 'bash' to auto-approve them`);
|
|
35934
|
+
if (intersectionBudgetExhausted) parts.push("the limit on how much work one generation may spend comparing allow rules against the all-tools '*' category's deny and ask rules was reached, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than .rulesync/permissions.jsonc asks for; write fewer or shorter command patterns to have them all compared");
|
|
34329
35935
|
logger?.warn(`WARNING: Cline command permissions translation notice: ${parts.join("; ")}.`);
|
|
34330
35936
|
}
|
|
34331
35937
|
var ClinePermissions = class ClinePermissions extends ToolPermissions {
|
|
@@ -34373,10 +35979,15 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
|
|
|
34373
35979
|
throw new Error(`Failed to parse existing Cline command-permissions at ${filePath}: ${formatError(error)}`, { cause: error });
|
|
34374
35980
|
}
|
|
34375
35981
|
const config = rulesyncPermissions.getJson();
|
|
34376
|
-
const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(config.permission);
|
|
35982
|
+
const { allow, deny, droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted } = translateClinePermissions(config.permission);
|
|
34377
35983
|
warnClineTranslationNotices({
|
|
34378
35984
|
droppedCategories,
|
|
34379
35985
|
translatedAskPatterns,
|
|
35986
|
+
shadowedAllowPatterns,
|
|
35987
|
+
unenforcedAllToolsDenyPatterns,
|
|
35988
|
+
unenforcedAllToolsAskPatterns,
|
|
35989
|
+
ignoredAllToolsAllowPatterns,
|
|
35990
|
+
intersectionBudgetExhausted,
|
|
34380
35991
|
logger
|
|
34381
35992
|
});
|
|
34382
35993
|
const dedupedAllow = uniq(allow.toSorted());
|
|
@@ -34384,7 +35995,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
|
|
|
34384
35995
|
const mergedDeny = uniq([...existing.deny ?? [], ...dedupedDeny]).toSorted();
|
|
34385
35996
|
const denySet = new Set(mergedDeny);
|
|
34386
35997
|
const collisions = dedupedAllow.filter((p) => denySet.has(p));
|
|
34387
|
-
if (collisions.length > 0) logger?.warn(`Cline command permissions: pattern(s) ${collisions.map((p) => `'${p}'`).join(", ")} appear in both 'allow' and 'deny'. Cline
|
|
35998
|
+
if (collisions.length > 0) logger?.warn(`Cline command permissions: pattern(s) ${collisions.map((p) => `'${p}'`).join(", ")} appear in both 'allow' and 'deny'. Cline documents that deny rules always take precedence, so the 'allow' entry has no effect. Consider removing the duplicate rule.`);
|
|
34388
35999
|
const next = {
|
|
34389
36000
|
...existing,
|
|
34390
36001
|
allow: dedupedAllow,
|
|
@@ -35502,7 +37113,7 @@ function asCursorPermissionEntryArray(value, logger, fieldLabel) {
|
|
|
35502
37113
|
if (!Array.isArray(value)) return [];
|
|
35503
37114
|
const result = [];
|
|
35504
37115
|
for (const item of value) if (typeof item === "string") result.push(item);
|
|
35505
|
-
else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${
|
|
37116
|
+
else logger?.warn(`Cursor CLI permissions${fieldLabel ? `.${fieldLabel}` : ""} contains a non-string entry; dropping ${quoteValueForWarning(item)}.`);
|
|
35506
37117
|
return result;
|
|
35507
37118
|
}
|
|
35508
37119
|
/**
|
|
@@ -35803,7 +37414,9 @@ const TRAILING_ARGUMENT_WILDCARD_PATTERN = /:\*$/;
|
|
|
35803
37414
|
* This surface is **global only** — dcode reads no project-level config file,
|
|
35804
37415
|
* so there is nothing to write into a repository.
|
|
35805
37416
|
*
|
|
35806
|
-
* Only
|
|
37417
|
+
* Only `allow` rules map, and only from the canonical `bash` category — the
|
|
37418
|
+
* all-tools `*` category contributes its restricting rules instead, since a rule
|
|
37419
|
+
* written there covers shell commands too (see `collectShellCommandRules`):
|
|
35807
37420
|
*
|
|
35808
37421
|
* - A pattern is reduced to its executable token, because that is all dcode
|
|
35809
37422
|
* matches on — `git *`, `git:*`, `git commit:*` and a bare `git` all become
|
|
@@ -35917,7 +37530,7 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
|
|
|
35917
37530
|
const shell = isPlainObject$1(existingShell) ? { ...existingShell } : {};
|
|
35918
37531
|
if (allowList.length > 0) shell[ALLOW_LIST_KEY] = allowList;
|
|
35919
37532
|
else if (shell[ALLOW_LIST_KEY] !== void 0) {
|
|
35920
|
-
warnWithFallback(logger, `deepagents-cli: no bash allow rule maps to an executable name, so the existing [${SHELL_TABLE_KEY}].${ALLOW_LIST_KEY} in ${filePath} was removed and those commands will be asked about again.`);
|
|
37533
|
+
warnWithFallback(logger, `deepagents-cli: no bash allow rule is left to auto-approve — none maps to an executable name, or every one of them is covered by a stricter rule — so the existing [${SHELL_TABLE_KEY}].${ALLOW_LIST_KEY} in ${filePath} was removed and those commands will be asked about again.`);
|
|
35921
37534
|
delete shell[ALLOW_LIST_KEY];
|
|
35922
37535
|
}
|
|
35923
37536
|
if (Object.keys(shell).length > 0) settings[SHELL_TABLE_KEY] = shell;
|
|
@@ -35976,35 +37589,69 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
|
|
|
35976
37589
|
}
|
|
35977
37590
|
};
|
|
35978
37591
|
/**
|
|
37592
|
+
* Split the restricting rules by what the generated allowlist does to them, and
|
|
37593
|
+
* name the entries that have to go.
|
|
37594
|
+
*
|
|
37595
|
+
* An allowlist entry auto-approves its executable however it is invoked, so an
|
|
37596
|
+
* `ask` or `deny` on any command that entry would run — a narrower pattern
|
|
37597
|
+
* (`npm publish`) beside an allowed `npm *`, or one naming no executable at all
|
|
37598
|
+
* (`*delete*` beside an allowed `kubectl`) — collides with it. dcode has no
|
|
37599
|
+
* denylist, so the collision cannot be settled there: keeping the allow would
|
|
37600
|
+
* auto-approve the very command the author wanted stopped. The colliding
|
|
37601
|
+
* entries are therefore withheld — canonically the stricter rule wins whatever
|
|
37602
|
+
* its width — which leaves those executables prompting, which is what an `ask`
|
|
37603
|
+
* asks for and the closest dcode can come to a `deny`.
|
|
37604
|
+
*/
|
|
37605
|
+
function partitionRestrictingRules({ allowList, askPatterns, denyPatterns, willWrite }) {
|
|
37606
|
+
const approved = allowList.map((token) => ({
|
|
37607
|
+
token,
|
|
37608
|
+
globs: [parseGlobPattern(token), parseGlobPattern(`${token} *`)]
|
|
37609
|
+
}));
|
|
37610
|
+
const budget = createIntersectionBudget();
|
|
37611
|
+
const collidingTokens = (pattern) => {
|
|
37612
|
+
if (!willWrite) return [];
|
|
37613
|
+
if (budget.remaining === 0) return [...allowList];
|
|
37614
|
+
const restriction = parseGlobPattern(pattern.trim().replace(TRAILING_ARGUMENT_WILDCARD_PATTERN, "*").replaceAll(SHLEX_STRIPPED_PATTERN, ""));
|
|
37615
|
+
return approved.filter(({ globs }) => globs.some((glob) => parsedGlobsIntersect(restriction, glob, budget))).map(({ token }) => token);
|
|
37616
|
+
};
|
|
37617
|
+
const withheldTokens = /* @__PURE__ */ new Set();
|
|
37618
|
+
const collect = (pattern) => {
|
|
37619
|
+
const tokens = collidingTokens(pattern);
|
|
37620
|
+
for (const token of tokens) withheldTokens.add(token);
|
|
37621
|
+
return tokens.length > 0;
|
|
37622
|
+
};
|
|
37623
|
+
const shadowedAsk = uniq(askPatterns).filter(collect);
|
|
37624
|
+
const shadowedDeny = [];
|
|
37625
|
+
const unenforcedDeny = [];
|
|
37626
|
+
for (const pattern of uniq(denyPatterns)) (collect(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
|
|
37627
|
+
return {
|
|
37628
|
+
shadowedAsk,
|
|
37629
|
+
shadowedDeny,
|
|
37630
|
+
unenforcedDeny,
|
|
37631
|
+
withheldTokens,
|
|
37632
|
+
intersectionBudgetExhausted: budget.remaining === 0
|
|
37633
|
+
};
|
|
37634
|
+
}
|
|
37635
|
+
/**
|
|
35979
37636
|
* Say what the reduction to executable names could not write. Split out from
|
|
35980
37637
|
* `convertRulesyncToDeepagentsAllowList` because the rules it reports on
|
|
35981
37638
|
* outnumber the ones it writes: every category dcode cannot express is a
|
|
35982
37639
|
* sentence here.
|
|
35983
37640
|
*/
|
|
35984
|
-
function warnAboutUnwrittenBashRules({
|
|
35985
|
-
|
|
35986
|
-
|
|
35987
|
-
|
|
35988
|
-
if (allowAll) return true;
|
|
35989
|
-
const leading = leadingToken(pattern).replaceAll(SHLEX_STRIPPED_PATTERN, "");
|
|
35990
|
-
if (GLOB_CHARACTERS_PATTERN.test(leading)) {
|
|
35991
|
-
const matches = compileGlob(leading);
|
|
35992
|
-
return allowList.some((token) => matches(token));
|
|
35993
|
-
}
|
|
35994
|
-
return allowedTokens.has(leading);
|
|
35995
|
-
};
|
|
35996
|
-
const shadowedAsk = askPatterns.filter(collidesWithAllow);
|
|
35997
|
-
const shadowedDeny = [];
|
|
35998
|
-
const unenforcedDeny = [];
|
|
35999
|
-
for (const pattern of denyPatterns) (collidesWithAllow(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
|
|
36000
|
-
if (unenforcedDeny.length > 0) warnWithFallback(logger, `deepagents-cli has no command denylist — a command it does not auto-approve is asked about, not blocked — so ${unenforcedDeny.length} bash deny rule(s) from .rulesync/permissions.jsonc were skipped and those commands remain runnable on approval.`);
|
|
36001
|
-
const shadowReason = allowAll ? `allow_list = ["all"] auto-approves every command` : `the generated allow_list auto-approves commands they cover`;
|
|
36002
|
-
if (shadowedDeny.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name, so the deny rule(s) ${shadowedDeny.join(", ")} are not merely unenforced: ${shadowReason}, so those commands run without a prompt. Narrow or drop the allow rule that covers them.`);
|
|
36003
|
-
if (shadowedAsk.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name, so the ask rule(s) ${shadowedAsk.join(", ")} run without a prompt: ${shadowReason}. Narrow or drop the allow rule that covers them.`);
|
|
37641
|
+
function warnAboutUnwrittenBashRules({ allowAll, requestedAllowAll, askPatterns, denyPatterns, foreignRestrictingCategories, shadowedAsk, shadowedDeny, unenforcedDeny, intersectionBudgetExhausted, widenedPatterns, unmatchablePatterns, sentinelPatterns, willWrite, logger }) {
|
|
37642
|
+
if (unenforcedDeny.length > 0) warnWithFallback(logger, `deepagents-cli has no command denylist — a command it does not auto-approve is asked about, not blocked — so ${unenforcedDeny.length} command deny rule(s) from .rulesync/permissions.jsonc were skipped and those commands remain runnable on approval.`);
|
|
37643
|
+
if (shadowedDeny.length > 0) warnWithFallback(logger, intersectionBudgetExhausted ? `deepagents-cli withheld the allow_list entries beside the deny rule(s) ${shadowedDeny.join(", ")} without comparing them, the comparison limit above having been reached. Those executables are asked about instead.` : `deepagents-cli matches only the executable name, so the allow rule(s) covered by the deny rule(s) ${shadowedDeny.join(", ")} were withheld from allow_list — keeping them would auto-approve the very commands those rules deny. Those executables are asked about instead; narrow the deny rule if you meant them auto-approved.`);
|
|
37644
|
+
if (shadowedAsk.length > 0) warnWithFallback(logger, intersectionBudgetExhausted ? `deepagents-cli withheld the allow_list entries beside the ask rule(s) ${shadowedAsk.join(", ")} without comparing them, the comparison limit above having been reached. Those executables are asked about instead.` : `deepagents-cli matches only the executable name, so the allow rule(s) covered by the ask rule(s) ${shadowedAsk.join(", ")} were withheld from allow_list — keeping them would run those commands without the prompt the ask rule asks for. Narrow the ask rule if you meant them auto-approved.`);
|
|
36004
37645
|
if (willWrite && widenedPatterns.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name of a command, so ${widenedPatterns.join(", ")} were widened to their first token — every invocation of those executables is now auto-approved, not just the listed arguments.`);
|
|
36005
37646
|
if (willWrite && unmatchablePatterns.length > 0) warnWithFallback(logger, `deepagents-cli compares an allow_list entry to the executable name exactly, so a glob in that name matches nothing, a name longer than ${MAX_EXECUTABLE_NAME_LENGTH} characters is longer than a command's own name can be, and a name holding a shell metacharacter, a quote or an escape is one dcode splits on, refuses outright, or reads differently than it is written — the pattern(s) ${unmatchablePatterns.join(", ")} were therefore skipped rather than written as a rule that cannot be relied on to fire.`);
|
|
36006
37647
|
if (willWrite && sentinelPatterns.length > 0) warnWithFallback(logger, `deepagents-cli reads 'all' and 'recommended' in allow_list as sentinels rather than command names, so ${sentinelPatterns.join(", ")} were skipped. Use the '*' pattern to allow every command.`);
|
|
36007
|
-
if (willWrite && requestedAllowAll && !allowAll)
|
|
37648
|
+
if (willWrite && requestedAllowAll && !allowAll) {
|
|
37649
|
+
let restrictionReason = `your config restricts other tools`;
|
|
37650
|
+
if (denyPatterns.length > 0) restrictionReason = `your config denies commands`;
|
|
37651
|
+
else if (askPatterns.length > 0) restrictionReason = `your config asks before running commands`;
|
|
37652
|
+
else if (foreignRestrictingCategories.length > 0) restrictionReason = `your config restricts '${foreignRestrictingCategories.join("', '")}'`;
|
|
37653
|
+
warnWithFallback(logger, `The bash '*' allow rule was not written as allow_list = ["all"] for deepagents-cli, because 'all' also turns off its dangerous-pattern check (command substitution, redirects, process substitution) and ${restrictionReason} — the two together would be weaker than dcode's own default. List the executables you want auto-approved instead.`);
|
|
37654
|
+
}
|
|
36008
37655
|
if (willWrite && allowAll) warnWithFallback(logger, "The bash '*' allow rule became allow_list = [\"all\"] for deepagents-cli, which auto-approves every command and skips its dangerous-pattern check (command substitution, redirects, process substitution). List the executables you want instead if that is more than you meant.");
|
|
36009
37656
|
}
|
|
36010
37657
|
/**
|
|
@@ -36151,62 +37798,78 @@ function toExecutableToken(pattern) {
|
|
|
36151
37798
|
}
|
|
36152
37799
|
/**
|
|
36153
37800
|
* Convert rulesync permissions config to dcode's `[shell].allow_list`. Only
|
|
36154
|
-
* `
|
|
37801
|
+
* `allow` rules map — from the `bash` category, and never from the all-tools
|
|
37802
|
+
* `*` one, whose restricting rules still count against them (see
|
|
37803
|
+
* `collectShellCommandRules`). Everything else is skipped, with a warning
|
|
36155
37804
|
* wherever the skip loses a restriction rather than a redundancy.
|
|
36156
37805
|
*/
|
|
36157
37806
|
function convertRulesyncToDeepagentsAllowList({ config, willWrite, logger }) {
|
|
36158
37807
|
const allowed = [];
|
|
36159
|
-
const
|
|
37808
|
+
const widened = [];
|
|
36160
37809
|
const unmatchablePatterns = [];
|
|
36161
37810
|
const sentinelPatterns = [];
|
|
36162
37811
|
const askPatterns = [];
|
|
36163
37812
|
const denyPatterns = [];
|
|
36164
|
-
let hasForeignDeny = false;
|
|
36165
37813
|
let requestedAllowAll = false;
|
|
36166
|
-
|
|
36167
|
-
|
|
36168
|
-
|
|
36169
|
-
|
|
36170
|
-
warnWithFallback(logger, `deepagents-cli only models shell-command permissions ([shell].allow_list), so '${category}' deny rules cannot be represented and were skipped.`);
|
|
36171
|
-
}
|
|
37814
|
+
const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
|
|
37815
|
+
for (const { pattern, action } of rules) {
|
|
37816
|
+
if (action === "deny") {
|
|
37817
|
+
denyPatterns.push(pattern);
|
|
36172
37818
|
continue;
|
|
36173
37819
|
}
|
|
36174
|
-
|
|
36175
|
-
|
|
36176
|
-
|
|
36177
|
-
|
|
36178
|
-
|
|
36179
|
-
|
|
36180
|
-
|
|
36181
|
-
continue;
|
|
36182
|
-
}
|
|
36183
|
-
if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
|
|
36184
|
-
requestedAllowAll = true;
|
|
36185
|
-
continue;
|
|
36186
|
-
}
|
|
36187
|
-
const reduced = toExecutableToken(pattern);
|
|
36188
|
-
if (!reduced) {
|
|
36189
|
-
unmatchablePatterns.push(pattern);
|
|
36190
|
-
continue;
|
|
36191
|
-
}
|
|
36192
|
-
if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
|
|
36193
|
-
sentinelPatterns.push(pattern);
|
|
36194
|
-
continue;
|
|
36195
|
-
}
|
|
36196
|
-
if (reduced.widened) widenedPatterns.push(pattern);
|
|
36197
|
-
allowed.push(reduced.token);
|
|
37820
|
+
if (action === "ask") {
|
|
37821
|
+
askPatterns.push(pattern);
|
|
37822
|
+
continue;
|
|
37823
|
+
}
|
|
37824
|
+
if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
|
|
37825
|
+
requestedAllowAll = true;
|
|
37826
|
+
continue;
|
|
36198
37827
|
}
|
|
37828
|
+
const reduced = toExecutableToken(pattern);
|
|
37829
|
+
if (!reduced) {
|
|
37830
|
+
unmatchablePatterns.push(pattern);
|
|
37831
|
+
continue;
|
|
37832
|
+
}
|
|
37833
|
+
if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
|
|
37834
|
+
sentinelPatterns.push(pattern);
|
|
37835
|
+
continue;
|
|
37836
|
+
}
|
|
37837
|
+
if (reduced.widened) widened.push({
|
|
37838
|
+
pattern,
|
|
37839
|
+
token: reduced.token
|
|
37840
|
+
});
|
|
37841
|
+
allowed.push(reduced.token);
|
|
36199
37842
|
}
|
|
36200
|
-
const
|
|
36201
|
-
const allowAll = requestedAllowAll && !
|
|
36202
|
-
const
|
|
37843
|
+
const hasRestriction = foreignRestrictingCategories.length > 0 || denyPatterns.length > 0 || askPatterns.length > 0;
|
|
37844
|
+
const allowAll = requestedAllowAll && !hasRestriction;
|
|
37845
|
+
const candidates = uniq(allowed.toSorted());
|
|
37846
|
+
const { shadowedAsk, shadowedDeny, unenforcedDeny, withheldTokens, intersectionBudgetExhausted } = partitionRestrictingRules({
|
|
37847
|
+
allowList: candidates,
|
|
37848
|
+
askPatterns,
|
|
37849
|
+
denyPatterns,
|
|
37850
|
+
willWrite
|
|
37851
|
+
});
|
|
37852
|
+
const allowList = allowAll ? [ALLOW_ALL_SENTINEL] : candidates.filter((token) => !withheldTokens.has(token));
|
|
37853
|
+
warnAboutUnwrittenCommandRules({
|
|
37854
|
+
toolLabel: "deepagents-cli",
|
|
37855
|
+
surfaceLabel: "[shell].allow_list",
|
|
37856
|
+
foreignRestrictingCategories,
|
|
37857
|
+
shadowedAllowPatterns: [],
|
|
37858
|
+
ignoredAllToolsAllowPatterns,
|
|
37859
|
+
intersectionBudgetExhausted,
|
|
37860
|
+
logger
|
|
37861
|
+
});
|
|
36203
37862
|
warnAboutUnwrittenBashRules({
|
|
36204
|
-
allowList,
|
|
36205
37863
|
allowAll,
|
|
36206
37864
|
requestedAllowAll,
|
|
36207
37865
|
askPatterns,
|
|
36208
37866
|
denyPatterns,
|
|
36209
|
-
|
|
37867
|
+
foreignRestrictingCategories,
|
|
37868
|
+
shadowedAsk,
|
|
37869
|
+
shadowedDeny,
|
|
37870
|
+
unenforcedDeny,
|
|
37871
|
+
intersectionBudgetExhausted,
|
|
37872
|
+
widenedPatterns: uniq(widened.filter(({ token }) => !withheldTokens.has(token)).map(({ pattern }) => pattern)),
|
|
36210
37873
|
unmatchablePatterns,
|
|
36211
37874
|
sentinelPatterns,
|
|
36212
37875
|
willWrite,
|
|
@@ -36506,10 +38169,17 @@ function convertDevinToRulesyncPermissions(params) {
|
|
|
36506
38169
|
*
|
|
36507
38170
|
* rulesync's canonical `permission.bash` patterns map directly: `allow` →
|
|
36508
38171
|
* `commandAllowlist`, `deny` → `commandDenylist`. Factory Droid has no separate
|
|
36509
|
-
* "ask" list (any command not in the allowlist already prompts), so `ask`
|
|
36510
|
-
*
|
|
36511
|
-
*
|
|
36512
|
-
*
|
|
38172
|
+
* "ask" list (any command not in the allowlist already prompts), so `ask` rules
|
|
38173
|
+
* write nothing — they only withhold the allow rules they cover, since the
|
|
38174
|
+
* stricter rule wins whatever its width. The all-tools `*` category contributes
|
|
38175
|
+
* its restricting rules too, because a rule written there covers shell commands
|
|
38176
|
+
* as well. They withhold the allow rules they cover the way a `bash` `ask`
|
|
38177
|
+
* does, because a pattern written under `*` need not name a command at all: a
|
|
38178
|
+
* `deny` there is written to `commandDenylist` too, for the case where it *is*
|
|
38179
|
+
* one, but an entry naming no command enforces nothing by itself.
|
|
38180
|
+
* The allow/deny lists only model shell commands, so categories other
|
|
38181
|
+
* than `bash` and `*` cannot be represented and are skipped (with a warning
|
|
38182
|
+
* when they carry `deny` rules, to surface the gap).
|
|
36513
38183
|
*
|
|
36514
38184
|
* Factory Droid also has a stronger `commandBlocklist` tier — commands that can
|
|
36515
38185
|
* never run, not even under full autonomy — plus other security controls
|
|
@@ -36626,24 +38296,28 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
|
|
|
36626
38296
|
};
|
|
36627
38297
|
/**
|
|
36628
38298
|
* Convert rulesync permissions config to Factory Droid allow/deny command lists.
|
|
36629
|
-
*
|
|
36630
|
-
*
|
|
38299
|
+
* The `bash` category maps, and so do the restricting rules of the all-tools
|
|
38300
|
+
* `*` category — a `deny` written there covers shell commands too, and skipping
|
|
38301
|
+
* it would auto-approve a command the file blocks. Other categories are dropped
|
|
38302
|
+
* (with a warning when they carry `deny` rules).
|
|
36631
38303
|
*/
|
|
36632
38304
|
function convertRulesyncToFactorydroidPermissions({ config, logger }) {
|
|
36633
|
-
const
|
|
36634
|
-
const deny =
|
|
36635
|
-
|
|
36636
|
-
|
|
36637
|
-
|
|
36638
|
-
|
|
36639
|
-
|
|
36640
|
-
|
|
36641
|
-
|
|
36642
|
-
|
|
36643
|
-
|
|
36644
|
-
|
|
36645
|
-
|
|
36646
|
-
|
|
38305
|
+
const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
|
|
38306
|
+
const { allow, deny, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
|
|
38307
|
+
rules,
|
|
38308
|
+
writesAllToolsDeny: true
|
|
38309
|
+
});
|
|
38310
|
+
warnAboutUnwrittenCommandRules({
|
|
38311
|
+
toolLabel: "Factory Droid",
|
|
38312
|
+
surfaceLabel: "commandAllowlist/commandDenylist",
|
|
38313
|
+
foreignRestrictingCategories,
|
|
38314
|
+
shadowedAllowPatterns,
|
|
38315
|
+
unenforcedAllToolsDenyPatterns,
|
|
38316
|
+
unenforcedAllToolsAskPatterns,
|
|
38317
|
+
ignoredAllToolsAllowPatterns,
|
|
38318
|
+
intersectionBudgetExhausted,
|
|
38319
|
+
logger
|
|
38320
|
+
});
|
|
36647
38321
|
return {
|
|
36648
38322
|
allow,
|
|
36649
38323
|
deny
|
|
@@ -37356,7 +39030,10 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
|
|
|
37356
39030
|
fileContent: this.getFileContent()
|
|
37357
39031
|
});
|
|
37358
39032
|
const rawProvenance = (isRecord$1(config.permissions) ? config.permissions : {}).rulesync;
|
|
37359
|
-
const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ?
|
|
39033
|
+
const parsedProvenance = RulesyncPermissionsFileSchema.safeParse(isRecord$1(rawProvenance) ? withoutBlankPermissionKeysIn({
|
|
39034
|
+
config: rawProvenance,
|
|
39035
|
+
sourcePath: this.getRelativePathFromCwd()
|
|
39036
|
+
}) : rawProvenance);
|
|
37360
39037
|
const provenance = parsedProvenance.success ? parsedProvenance.data : { permission: {} };
|
|
37361
39038
|
const permission = clonePermissionBlock(provenance.permission);
|
|
37362
39039
|
reconcileCommandAllowlist({
|
|
@@ -37384,14 +39061,13 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
|
|
|
37384
39061
|
permission,
|
|
37385
39062
|
...Object.keys(hermes).length > 0 && { hermes }
|
|
37386
39063
|
};
|
|
37387
|
-
return
|
|
39064
|
+
return RulesyncPermissions.fromImportedFileContent({
|
|
37388
39065
|
outputRoot: getHermesagentRulesyncOutputRoot({
|
|
37389
39066
|
nativeOutputRoot: this.outputRoot,
|
|
37390
39067
|
global: this.global
|
|
37391
39068
|
}),
|
|
37392
|
-
|
|
37393
|
-
|
|
37394
|
-
fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify(imported, null, 2) })
|
|
39069
|
+
sourcePath: this.getRelativePathFromCwd(),
|
|
39070
|
+
fileContent: JSON.stringify(imported, null, 2)
|
|
37395
39071
|
});
|
|
37396
39072
|
}
|
|
37397
39073
|
static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false }) {
|
|
@@ -38292,17 +39968,16 @@ var KimiCodePermissions = class KimiCodePermissions extends ToolPermissions {
|
|
|
38292
39968
|
...nativeRules.length > 0 && { rules: nativeRules },
|
|
38293
39969
|
...tools && { tools }
|
|
38294
39970
|
};
|
|
38295
|
-
return
|
|
39971
|
+
return RulesyncPermissions.fromImportedFileContent({
|
|
38296
39972
|
outputRoot: getKimiCodeRulesyncOutputRoot({
|
|
38297
39973
|
nativeOutputRoot: this.outputRoot,
|
|
38298
39974
|
global: this.global
|
|
38299
39975
|
}),
|
|
38300
|
-
|
|
38301
|
-
|
|
38302
|
-
fileContent: withoutBlankPermissionPatterns({ fileContent: JSON.stringify({
|
|
39976
|
+
sourcePath: this.getRelativePathFromCwd(),
|
|
39977
|
+
fileContent: JSON.stringify({
|
|
38303
39978
|
permission,
|
|
38304
39979
|
...Object.keys(toolOverride).length > 0 && { "kimi-code": toolOverride }
|
|
38305
|
-
}, null, 2)
|
|
39980
|
+
}, null, 2)
|
|
38306
39981
|
});
|
|
38307
39982
|
}
|
|
38308
39983
|
static forDeletion({ outputRoot = process.cwd() }) {
|
|
@@ -38693,9 +40368,10 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
|
|
|
38693
40368
|
if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
|
|
38694
40369
|
}
|
|
38695
40370
|
const parsed = parse(fileContent ?? "{}");
|
|
40371
|
+
const record = isRecord$1(parsed) ? parsed : {};
|
|
38696
40372
|
const nextJson = {
|
|
38697
|
-
...
|
|
38698
|
-
permission:
|
|
40373
|
+
...record,
|
|
40374
|
+
permission: Object.hasOwn(record, "permission") ? record.permission ?? {} : {}
|
|
38699
40375
|
};
|
|
38700
40376
|
return new OpencodePermissions({
|
|
38701
40377
|
outputRoot,
|
|
@@ -38764,7 +40440,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
|
|
|
38764
40440
|
}
|
|
38765
40441
|
validate() {
|
|
38766
40442
|
try {
|
|
38767
|
-
const json =
|
|
40443
|
+
const json = parseJsonc(this.fileContent || "{}");
|
|
38768
40444
|
const result = OpencodePermissionsConfigSchema.safeParse(json);
|
|
38769
40445
|
if (!result.success) return {
|
|
38770
40446
|
success: false,
|
|
@@ -40876,6 +42552,28 @@ function pickSecurityPolicies(source, report) {
|
|
|
40876
42552
|
* Ambiguous-width characters are counted as one column, which is what a
|
|
40877
42553
|
* terminal running a Latin font does.
|
|
40878
42554
|
*
|
|
42555
|
+
* Two of the ranges are here for a narrower reason: a name the skill prompt can
|
|
42556
|
+
* offer may not be counted narrower here than the prompt's own renderer counts
|
|
42557
|
+
* it, or a label that fits the budget wraps anyway and paints the second row
|
|
42558
|
+
* the budget exists to prevent. `@inquirer/core` measures with
|
|
42559
|
+
* `fast-string-width`, which takes the whole of `Script=Hangul` as wide and
|
|
42560
|
+
* every `Emoji_Modifier_Base` as an emoji. So the Hangul jamo are taken to
|
|
42561
|
+
* U+11FF rather than stopping at the leading consonants, and the modifier bases
|
|
42562
|
+
* are named beside the emoji: U+261D, U+26F9 and the two hands of U+270C–U+270D
|
|
42563
|
+
* are `Emoji` without being `Emoji_Presentation`, and were the only characters
|
|
42564
|
+
* outside Hangul this counted at one column while the renderer counted two.
|
|
42565
|
+
*
|
|
42566
|
+
* The rule is over the names that can reach the prompt, which is a smaller set
|
|
42567
|
+
* than the characters that exist. The renderer counts a tab at eight columns and
|
|
42568
|
+
* the Hangul fillers at two, where this counts one and none: a name carrying
|
|
42569
|
+
* either is refused outright by `hasDeceptiveHiddenCharacters` — the tab as a
|
|
42570
|
+
* control character, the fillers as characters that draw as nothing — and never
|
|
42571
|
+
* becomes a row to be measured. The two joiners are the invisible characters
|
|
42572
|
+
* that check lets through, so they are counted below rather than left to the
|
|
42573
|
+
* zero-width rule. Where this is used to lay out text of the tool's own rather
|
|
42574
|
+
* than to bound an untrusted name, the difference is a column of alignment and
|
|
42575
|
+
* not a forged row.
|
|
42576
|
+
*
|
|
40879
42577
|
* The wide planes are taken whole rather than range by range — Tangut, Khitan
|
|
40880
42578
|
* and Nushu together are U+17000–U+18DFF, and the kana supplements are
|
|
40881
42579
|
* U+1AFF0–U+1B2FF — because a gap between two of them is exactly the character
|
|
@@ -40888,7 +42586,7 @@ function pickSecurityPolicies(source, report) {
|
|
|
40888
42586
|
* is canonically the ordinary ideograph U+8C48, and a range that starts there
|
|
40889
42587
|
* instead silently swallows thirty thousand code points that are not wide.
|
|
40890
42588
|
*/
|
|
40891
|
-
const WIDE_CHARACTERS_PATTERN = /\p{Emoji_Presentation}|[\u2329\u232a\u2630-\u2637\u268a-\u268f\u4dc0-\u4dff]|[\u1100-\
|
|
42589
|
+
const WIDE_CHARACTERS_PATTERN = /\p{Emoji_Presentation}|\p{Emoji_Modifier_Base}|[\u2329\u232a\u2630-\u2637\u268a-\u268f\u4dc0-\u4dff]|[\u1100-\u11ff\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\ua960-\ua97f\uac00-\ud7a3\ud7b0-\ud7fb\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]|[\u{16fe0}-\u{16ff6}]|[\u{17000}-\u{18dff}]|[\u{1aff0}-\u{1b2ff}]|[\u{1f000}-\u{1faff}]|[\u{20000}-\u{3fffd}]/u;
|
|
40892
42590
|
/**
|
|
40893
42591
|
* U+FE0F VARIATION SELECTOR-16, which takes no width of its own but asks the
|
|
40894
42592
|
* character before it to be drawn as an emoji — that is, in two columns rather
|
|
@@ -40902,6 +42600,27 @@ const COMBINING_MARK_PATTERN = /[\p{Mn}\p{Me}]/u;
|
|
|
40902
42600
|
/** The characters that take no width at all, marks aside. */
|
|
40903
42601
|
const ZERO_WIDTH_CHARACTERS_PATTERN = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
|
|
40904
42602
|
/**
|
|
42603
|
+
* The zero-width joiner and its non-joining twin, which the renderer spends a
|
|
42604
|
+
* column on apiece.
|
|
42605
|
+
*
|
|
42606
|
+
* A terminal draws neither, and every other character that draws as nothing is
|
|
42607
|
+
* counted at nothing here. These two are the exception because they are the
|
|
42608
|
+
* only invisible characters `hasDeceptiveHiddenCharacters` lets through — a
|
|
42609
|
+
* Persian or Indic name spells a word with one, and an emoji is a chain of them
|
|
42610
|
+
* — so they are the only ones an attacker can put in a name that reaches the
|
|
42611
|
+
* prompt. `fast-string-width`, which is where the prompt's own wrapping is
|
|
42612
|
+
* decided, counts each of them as a column, and 40 of them in a name is 40
|
|
42613
|
+
* columns of budget this would otherwise hand over for free: enough for a name
|
|
42614
|
+
* measured at 39 columns to be drawn at 77 and wrap a forged row underneath
|
|
42615
|
+
* itself.
|
|
42616
|
+
*
|
|
42617
|
+
* The cost is that an emoji built from a chain is overstated by a column per
|
|
42618
|
+
* joiner, on top of the two columns per component it is already overstated by.
|
|
42619
|
+
* Overstating shortens a label that did not need it; understating lets one
|
|
42620
|
+
* wrap.
|
|
42621
|
+
*/
|
|
42622
|
+
const RENDERER_COUNTED_JOINERS = /\u200c|\u200d/u;
|
|
42623
|
+
/**
|
|
40905
42624
|
* How many marks a single character is allowed to carry for free.
|
|
40906
42625
|
*
|
|
40907
42626
|
* A written language stacks two or three at most — a Devanagari vowel sign and
|
|
@@ -40929,6 +42648,7 @@ function widthInContext(params) {
|
|
|
40929
42648
|
const { character, precedingMarks } = params;
|
|
40930
42649
|
if (character === EMOJI_PRESENTATION_SELECTOR) return 1;
|
|
40931
42650
|
if (isCombiningMark(character)) return precedingMarks < FREE_MARKS_PER_CHARACTER ? 0 : 1;
|
|
42651
|
+
if (RENDERER_COUNTED_JOINERS.test(character)) return 1;
|
|
40932
42652
|
if (ZERO_WIDTH_CHARACTERS_PATTERN.test(character)) return 0;
|
|
40933
42653
|
return WIDE_CHARACTERS_PATTERN.test(character) ? 2 : 1;
|
|
40934
42654
|
}
|
|
@@ -40951,6 +42671,8 @@ function displayWidthOf(text) {
|
|
|
40951
42671
|
}
|
|
40952
42672
|
return width;
|
|
40953
42673
|
}
|
|
42674
|
+
/** The mark a cut string ends in. */
|
|
42675
|
+
const SHORTENING_ELLIPSIS = "…";
|
|
40954
42676
|
/**
|
|
40955
42677
|
* Cut `text` down to at most `budget` columns, marking the cut with an ellipsis.
|
|
40956
42678
|
*
|
|
@@ -40976,7 +42698,7 @@ function shortenToWidth(params) {
|
|
|
40976
42698
|
width += characterWidth;
|
|
40977
42699
|
marks = isCombiningMark(character) ? marks + 1 : 0;
|
|
40978
42700
|
}
|
|
40979
|
-
return `${kept.join("")}
|
|
42701
|
+
return `${kept.join("")}${SHORTENING_ELLIPSIS}`;
|
|
40980
42702
|
}
|
|
40981
42703
|
//#endregion
|
|
40982
42704
|
//#region src/features/permissions/vibe-permissions.ts
|
|
@@ -42419,9 +44141,13 @@ function warpSettingsDir() {
|
|
|
42419
44141
|
* allowlist, `deny` → denylist). Warp matches commands with regular
|
|
42420
44142
|
* expressions, so patterns are emitted verbatim — author canonical `bash`
|
|
42421
44143
|
* patterns as regexes when targeting Warp (mirrors the Zed permissions
|
|
42422
|
-
* adapter). Warp has no per-command "ask" list, so `ask` rules
|
|
42423
|
-
*
|
|
42424
|
-
*
|
|
44144
|
+
* adapter). Warp has no per-command "ask" list, so `ask` rules write nothing —
|
|
44145
|
+
* they only withhold the allow rules they cover, since the stricter rule wins
|
|
44146
|
+
* whatever its width. The all-tools `*` category is read for its restricting
|
|
44147
|
+
* rules as well, but those only withhold allows too: writing any denylist
|
|
44148
|
+
* **replaces** Warp's built-in default one, and a `*` pattern need not name a
|
|
44149
|
+
* command at all. Categories other than `bash` and `*` are skipped (with a
|
|
44150
|
+
* warning when they carry `deny` rules).
|
|
42425
44151
|
*
|
|
42426
44152
|
* Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy
|
|
42427
44153
|
* knobs that do not fit the canonical `allow | ask | deny` per-command model:
|
|
@@ -42594,25 +44320,267 @@ function mergeIntoDefaultExecutionProfile({ agents, mergedAllow, mergedDeny, exe
|
|
|
42594
44320
|
if ((mergedDeny.length > 0 || hasOverrideKeys) && otherProfileIds.length > 0 && logger) logger.warn(`Warp command deny rules and execution_profile override keys were written to the 'default' execution profile only; they are not enforced while another profile (${otherProfileIds.join(", ")}) is active.`);
|
|
42595
44321
|
}
|
|
42596
44322
|
/**
|
|
42597
|
-
*
|
|
42598
|
-
*
|
|
42599
|
-
*
|
|
44323
|
+
* Read a `[...]` class starting at its `[` and return the index just past its
|
|
44324
|
+
* `]`, or `undefined` when it cannot be read that simply. Regex class rules
|
|
44325
|
+
* apply: a `]` in the first position is a member rather than the terminator and
|
|
44326
|
+
* a backslash escapes the character after it. A nested `[` gives up: Rust's
|
|
44327
|
+
* `regex` crate — the engine Warp matches with — reads `[a[b]c]` as one class
|
|
44328
|
+
* built by set operations, so stopping at the first `]` would leave `c]` behind
|
|
44329
|
+
* as text the glob then requires. Giving up widens the whole pattern instead,
|
|
44330
|
+
* which is the only safe direction here.
|
|
44331
|
+
*/
|
|
44332
|
+
function skipRegexClass(body, start) {
|
|
44333
|
+
let index = start + 1;
|
|
44334
|
+
if (body.charAt(index) === "^") index += 1;
|
|
44335
|
+
if (body.charAt(index) === "]") index += 1;
|
|
44336
|
+
while (index < body.length) {
|
|
44337
|
+
const character = body.charAt(index);
|
|
44338
|
+
if (character === "\\") {
|
|
44339
|
+
index += 2;
|
|
44340
|
+
continue;
|
|
44341
|
+
}
|
|
44342
|
+
if (character === "[") return;
|
|
44343
|
+
if (character === "]") return index + 1;
|
|
44344
|
+
index += 1;
|
|
44345
|
+
}
|
|
44346
|
+
}
|
|
44347
|
+
/**
|
|
44348
|
+
* Read a `(...)` group starting at its `(` and return the index just past its
|
|
44349
|
+
* `)`, or `undefined` when it never closes. Groups nest, so the depth is
|
|
44350
|
+
* counted — but a class inside one is skipped whole, since a `)` written there
|
|
44351
|
+
* is a member rather than a closer.
|
|
42600
44352
|
*/
|
|
42601
|
-
function
|
|
42602
|
-
|
|
42603
|
-
|
|
42604
|
-
|
|
42605
|
-
|
|
42606
|
-
|
|
44353
|
+
function skipRegexGroup(body, start) {
|
|
44354
|
+
let depth = 0;
|
|
44355
|
+
let index = start;
|
|
44356
|
+
while (index < body.length) {
|
|
44357
|
+
const character = body.charAt(index);
|
|
44358
|
+
if (character === "\\") {
|
|
44359
|
+
index += 2;
|
|
42607
44360
|
continue;
|
|
42608
44361
|
}
|
|
42609
|
-
|
|
42610
|
-
|
|
42611
|
-
|
|
42612
|
-
|
|
42613
|
-
|
|
44362
|
+
if (character === "[") {
|
|
44363
|
+
const next = skipRegexClass(body, index);
|
|
44364
|
+
if (next === void 0) return;
|
|
44365
|
+
index = next;
|
|
44366
|
+
continue;
|
|
44367
|
+
}
|
|
44368
|
+
if (character === "(") {
|
|
44369
|
+
depth += 1;
|
|
44370
|
+
index += 1;
|
|
44371
|
+
continue;
|
|
44372
|
+
}
|
|
44373
|
+
if (character === ")") {
|
|
44374
|
+
depth -= 1;
|
|
44375
|
+
index += 1;
|
|
44376
|
+
if (depth === 0) return index;
|
|
44377
|
+
continue;
|
|
42614
44378
|
}
|
|
44379
|
+
index += 1;
|
|
42615
44380
|
}
|
|
44381
|
+
}
|
|
44382
|
+
/**
|
|
44383
|
+
* Read a `{2,3}` quantifier starting at its `{`, or `undefined` when what
|
|
44384
|
+
* follows is not one. The shape is checked rather than scanned to the next `}`,
|
|
44385
|
+
* because a `{` that spells no repetition is a literal to the regex engine —
|
|
44386
|
+
* `{a|b}` is an alternation in braces, and reading it as a quantifier would skip
|
|
44387
|
+
* past the `|` that has to widen the whole pattern.
|
|
44388
|
+
*/
|
|
44389
|
+
const QUANTIFIER = /\{\d+(,\d*)?\}/y;
|
|
44390
|
+
function skipQuantifier(body, start) {
|
|
44391
|
+
QUANTIFIER.lastIndex = start;
|
|
44392
|
+
return QUANTIFIER.exec(body) === null ? void 0 : QUANTIFIER.lastIndex;
|
|
44393
|
+
}
|
|
44394
|
+
/**
|
|
44395
|
+
* Read the bracketed construct that opens at `start`, whichever kind it is, and
|
|
44396
|
+
* return the index just past it — or `undefined` when it never closes.
|
|
44397
|
+
*/
|
|
44398
|
+
function skipBracketedConstruct(body, start, open) {
|
|
44399
|
+
if (open === "[") return skipRegexClass(body, start);
|
|
44400
|
+
if (open === "(") return skipRegexGroup(body, start);
|
|
44401
|
+
return skipQuantifier(body, start);
|
|
44402
|
+
}
|
|
44403
|
+
/**
|
|
44404
|
+
* Read the tail of a `\\x` / `\\u` escape — the hex run of `\\x20`, or the
|
|
44405
|
+
* braced `\\x{263A}` form — and return the index just past it. Such an escape
|
|
44406
|
+
* spells one character across several, so consuming only its introducer would
|
|
44407
|
+
* leave the hex digits behind as literal atoms and *narrow* the glob, the one
|
|
44408
|
+
* direction the rewrite must never take. Over-consuming only widens, so an
|
|
44409
|
+
* unterminated brace swallows the rest of the pattern.
|
|
44410
|
+
*/
|
|
44411
|
+
function skipHexEscapeTail(body, start) {
|
|
44412
|
+
if (body.charAt(start) === "{") {
|
|
44413
|
+
const closing = body.indexOf("}", start);
|
|
44414
|
+
return closing === -1 ? body.length : closing + 1;
|
|
44415
|
+
}
|
|
44416
|
+
let index = start;
|
|
44417
|
+
while (index < body.length && /^[0-9A-Fa-f]$/.test(body.charAt(index))) index += 1;
|
|
44418
|
+
return index;
|
|
44419
|
+
}
|
|
44420
|
+
/**
|
|
44421
|
+
* Read the tail of a `\\p` / `\\P` Unicode-class escape — the braced `\\p{Greek}`
|
|
44422
|
+
* form, or the one-letter `\\pL` shorthand — and return the index just past it.
|
|
44423
|
+
* Like a hex escape, it spells its class across more than one character, so
|
|
44424
|
+
* leaving the shorthand letter behind would narrow the glob.
|
|
44425
|
+
*/
|
|
44426
|
+
function skipUnicodeClassTail(body, start) {
|
|
44427
|
+
if (body.charAt(start) === "{") {
|
|
44428
|
+
const closing = body.indexOf("}", start);
|
|
44429
|
+
return closing === -1 ? body.length : closing + 1;
|
|
44430
|
+
}
|
|
44431
|
+
return Math.min(start + 1, body.length);
|
|
44432
|
+
}
|
|
44433
|
+
/**
|
|
44434
|
+
* Read the escape that opens at `start` — the `\\` and whatever it spells — and
|
|
44435
|
+
* say where it ends and which atom it stands for.
|
|
44436
|
+
*/
|
|
44437
|
+
function readEscape(body, start) {
|
|
44438
|
+
const escaped = body.charAt(start + 1);
|
|
44439
|
+
if (escaped === "x" || escaped === "u" || escaped === "U") return {
|
|
44440
|
+
next: skipHexEscapeTail(body, start + 2),
|
|
44441
|
+
atom: "*"
|
|
44442
|
+
};
|
|
44443
|
+
if (escaped === "p" || escaped === "P") return {
|
|
44444
|
+
next: skipUnicodeClassTail(body, start + 2),
|
|
44445
|
+
atom: "*"
|
|
44446
|
+
};
|
|
44447
|
+
return {
|
|
44448
|
+
next: start + 2,
|
|
44449
|
+
atom: escapedCharacterWidens(escaped) ? "*" : escaped
|
|
44450
|
+
};
|
|
44451
|
+
}
|
|
44452
|
+
/**
|
|
44453
|
+
* Whether an escaped character has to widen to `*` rather than stand for
|
|
44454
|
+
* itself: a letter or a digit spells a class (`\s`, `\d`, `\w`), a glob
|
|
44455
|
+
* metacharacter would be read as a wildcard or a class by the comparison
|
|
44456
|
+
* instead of as the literal the escape asked for, and an empty string is a
|
|
44457
|
+
* trailing backslash spelling nothing at all.
|
|
44458
|
+
*/
|
|
44459
|
+
function escapedCharacterWidens(escaped) {
|
|
44460
|
+
return escaped === "" || /^[A-Za-z0-9*?[\]]$/.test(escaped);
|
|
44461
|
+
}
|
|
44462
|
+
/**
|
|
44463
|
+
* Read one `[...]`, `(...)` or `{...}` construct starting at `start`, saying
|
|
44464
|
+
* where it ends and whether it repeats the atom in front of it (a quantifier)
|
|
44465
|
+
* or is an atom of its own (a class or a group) — or that the whole pattern has
|
|
44466
|
+
* to widen, which is the only safe reading of a construct that never closes and
|
|
44467
|
+
* of one that sets flags for everything after it.
|
|
44468
|
+
*/
|
|
44469
|
+
function readBracketedConstruct(body, start, open) {
|
|
44470
|
+
const next = skipBracketedConstruct(body, start, open);
|
|
44471
|
+
if (next === void 0) return "widens-pattern";
|
|
44472
|
+
if (open === "(" && INLINE_FLAG_GROUP_PATTERN.test(body.slice(start, next))) return "widens-pattern";
|
|
44473
|
+
return {
|
|
44474
|
+
next,
|
|
44475
|
+
widensLastAtom: open === "{"
|
|
44476
|
+
};
|
|
44477
|
+
}
|
|
44478
|
+
/**
|
|
44479
|
+
* A group that only sets flags — `(?i)`, `(?im)`, `(?-i)` — as opposed to one
|
|
44480
|
+
* that scopes them to its own body (`(?i:...)`, which widens to `*` like any
|
|
44481
|
+
* other group).
|
|
44482
|
+
*/
|
|
44483
|
+
const INLINE_FLAG_GROUP_PATTERN = /^\(\?[A-Za-z]*-?[A-Za-z]*\)$/;
|
|
44484
|
+
/**
|
|
44485
|
+
* Approximate a Warp command pattern as a glob, so restrictions and allow rules
|
|
44486
|
+
* can be compared by `createShadowingRestrictionsTest`.
|
|
44487
|
+
*
|
|
44488
|
+
* Warp matches commands with regular expressions, and a glob reader would
|
|
44489
|
+
* misread the ordinary spellings: `.*` — Warp's catch-all — is a literal dot
|
|
44490
|
+
* followed by a wildcard, `[rf]` is a character class in one language and plain
|
|
44491
|
+
* text in the other, and an unanchored regex covers every command that merely
|
|
44492
|
+
* contains it. The rewrite therefore only ever widens what a pattern covers, so
|
|
44493
|
+
* an inexact reading withholds an allow rather than writing one the config
|
|
44494
|
+
* restricts.
|
|
44495
|
+
*
|
|
44496
|
+
* Widening means a construct is replaced whole rather than character by
|
|
44497
|
+
* character, because the characters inside it are not literals of the command:
|
|
44498
|
+
* a class (`[rf]`), a group (`(sudo )?`), a character escape (`\s`) and a
|
|
44499
|
+
* missing `^`/`$` anchor all become `*`, and a quantifier (`?`, `*`, `+`,
|
|
44500
|
+
* `{2}`) widens the atom in front of it — `git commits?` covers `git commit`,
|
|
44501
|
+
* so its glob has to as well. Both sides of a comparison are widened, so a
|
|
44502
|
+
* pattern that is really a glob still compares sensibly.
|
|
44503
|
+
*
|
|
44504
|
+
* A pattern the walk cannot read as one sequence widens to `*` whole: a
|
|
44505
|
+
* top-level `|` is two patterns rather than one, and a class, group or
|
|
44506
|
+
* quantifier that never closes leaves the rest of the pattern unreadable —
|
|
44507
|
+
* guessing at either could only narrow the result, which is the one direction
|
|
44508
|
+
* this rewrite must never take. An alternation *inside* a group needs no such
|
|
44509
|
+
* treatment: the group it sits in already widens to `*`.
|
|
44510
|
+
*/
|
|
44511
|
+
function warpCommandPatternToGlob(pattern) {
|
|
44512
|
+
const anchoredStart = pattern.startsWith("^");
|
|
44513
|
+
const anchoredEnd = pattern.endsWith("$") && !pattern.endsWith("\\$");
|
|
44514
|
+
const body = pattern.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : void 0);
|
|
44515
|
+
const atoms = [];
|
|
44516
|
+
const widenLastAtom = () => {
|
|
44517
|
+
if (atoms.length === 0) {
|
|
44518
|
+
atoms.push("*");
|
|
44519
|
+
return;
|
|
44520
|
+
}
|
|
44521
|
+
atoms[atoms.length - 1] = "*";
|
|
44522
|
+
};
|
|
44523
|
+
let index = 0;
|
|
44524
|
+
while (index < body.length) {
|
|
44525
|
+
const character = body.charAt(index);
|
|
44526
|
+
if (character === "|") return "*";
|
|
44527
|
+
if (character === "\\") {
|
|
44528
|
+
const escape = readEscape(body, index);
|
|
44529
|
+
index = escape.next;
|
|
44530
|
+
atoms.push(escape.atom);
|
|
44531
|
+
continue;
|
|
44532
|
+
}
|
|
44533
|
+
if (character === "[" || character === "(" || character === "{") {
|
|
44534
|
+
const read = readBracketedConstruct(body, index, character);
|
|
44535
|
+
if (read === "widens-pattern") return "*";
|
|
44536
|
+
index = read.next;
|
|
44537
|
+
if (read.widensLastAtom) widenLastAtom();
|
|
44538
|
+
else atoms.push("*");
|
|
44539
|
+
continue;
|
|
44540
|
+
}
|
|
44541
|
+
if (character === "*" || character === "+" || character === "?") {
|
|
44542
|
+
index += 1;
|
|
44543
|
+
widenLastAtom();
|
|
44544
|
+
continue;
|
|
44545
|
+
}
|
|
44546
|
+
const codePoint = body.codePointAt(index);
|
|
44547
|
+
const atom = codePoint === void 0 ? character : String.fromCodePoint(codePoint);
|
|
44548
|
+
index += atom.length;
|
|
44549
|
+
atoms.push(atom === "." || ")]}^$".includes(atom) ? "*" : atom);
|
|
44550
|
+
}
|
|
44551
|
+
const glob = atoms.join("");
|
|
44552
|
+
return `${anchoredStart ? "" : "*"}${glob}${anchoredEnd ? "" : "*"}`;
|
|
44553
|
+
}
|
|
44554
|
+
/**
|
|
44555
|
+
* Convert rulesync permissions config to Warp command allow/deny regex lists.
|
|
44556
|
+
* The `bash` category maps to both lists. The all-tools `*` category's
|
|
44557
|
+
* restricting rules are read too — a rule written there covers shell commands
|
|
44558
|
+
* as well, and ignoring it would auto-approve a command the file blocks — but
|
|
44559
|
+
* they only *withhold* the allow rules they cover: Warp's denylist is a regex
|
|
44560
|
+
* list that replaces the tool's built-in default one, so writing a pattern
|
|
44561
|
+
* there that may not even name a command would cost more protection than it
|
|
44562
|
+
* adds. Other categories are dropped (with a warning when they carry `deny`
|
|
44563
|
+
* rules).
|
|
44564
|
+
*/
|
|
44565
|
+
function convertRulesyncToWarpPermissions({ config, logger }) {
|
|
44566
|
+
const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
|
|
44567
|
+
const { allow, deny, shadowedAllowPatterns, unwrittenDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
|
|
44568
|
+
rules,
|
|
44569
|
+
writesAllToolsDeny: false,
|
|
44570
|
+
normalizePattern: warpCommandPatternToGlob
|
|
44571
|
+
});
|
|
44572
|
+
warnAboutUnwrittenCommandRules({
|
|
44573
|
+
toolLabel: "Warp",
|
|
44574
|
+
surfaceLabel: "agent_mode_command_execution_allowlist/denylist",
|
|
44575
|
+
foreignRestrictingCategories,
|
|
44576
|
+
shadowedAllowPatterns,
|
|
44577
|
+
unwrittenDenyPatterns,
|
|
44578
|
+
unwrittenDenyReason: "Writing any denylist replaces Warp's built-in default one, and a pattern written under '*' need not be a command at all.",
|
|
44579
|
+
unenforcedAllToolsAskPatterns,
|
|
44580
|
+
ignoredAllToolsAllowPatterns,
|
|
44581
|
+
intersectionBudgetExhausted,
|
|
44582
|
+
logger
|
|
44583
|
+
});
|
|
42616
44584
|
return {
|
|
42617
44585
|
allow,
|
|
42618
44586
|
deny
|
|
@@ -44764,6 +46732,83 @@ const NESTED_SCAN_EXCLUDED_ROOT_DIRS = [
|
|
|
44764
46732
|
];
|
|
44765
46733
|
//#endregion
|
|
44766
46734
|
//#region src/features/skills/claudecode-skill.ts
|
|
46735
|
+
/**
|
|
46736
|
+
* The `.claude/skills` tail every scanned root is expected to end with, written
|
|
46737
|
+
* posix-separated so it can be compared against a resolved relative path, and
|
|
46738
|
+
* split into its segments so what sits above it can be taken apart.
|
|
46739
|
+
*
|
|
46740
|
+
* Split here rather than through the shared helper: this is evaluated as the
|
|
46741
|
+
* module loads, before a test that mocks the file utilities can supply one.
|
|
46742
|
+
*/
|
|
46743
|
+
const CLAUDECODE_SKILLS_DIR_SEGMENTS = CLAUDECODE_SKILLS_DIR_PATH.split(sep);
|
|
46744
|
+
const CLAUDECODE_SKILLS_DIR_POSIX_PATH = CLAUDECODE_SKILLS_DIR_SEGMENTS.join("/");
|
|
46745
|
+
/**
|
|
46746
|
+
* The segment that puts `relativeDirPath` inside a tree the nested scan
|
|
46747
|
+
* excludes, or `undefined` when none does. These are the same three rules the
|
|
46748
|
+
* scan states as glob `ignore` patterns below -- dependency trees at any depth,
|
|
46749
|
+
* build and vendoring directories at the project root, hidden directories other
|
|
46750
|
+
* than the `.claude` being matched -- and the two have to be kept in step.
|
|
46751
|
+
*
|
|
46752
|
+
* Saying them twice is what a second pass costs. globby matches its patterns
|
|
46753
|
+
* against the path it reports, before the `..` a rewritten directory name
|
|
46754
|
+
* carries is folded away and before a link in the path is resolved. A root
|
|
46755
|
+
* reported at `x/../node_modules/.claude/skills` matches none of the patterns
|
|
46756
|
+
* and then leads to the dependency tree they name, so the decision has to be
|
|
46757
|
+
* taken again on the path that is really read.
|
|
46758
|
+
*
|
|
46759
|
+
* The segments passed are the ones above the `.claude/skills` tail, taken from
|
|
46760
|
+
* the resolved path: a directory name may hold a backslash -- the whole reason a
|
|
46761
|
+
* path can arrive here misspelled -- so the split that produced them has to be on
|
|
46762
|
+
* `/`, the one separator no name can contain. The tail itself is the part the
|
|
46763
|
+
* glob matched and is not judged; `.claude` is hidden by definition and every
|
|
46764
|
+
* root the scan reports ends with it.
|
|
46765
|
+
*/
|
|
46766
|
+
function excludedNestedScanSegment(segments) {
|
|
46767
|
+
return segments.find((segment, index) => NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.includes(segment) || index === 0 && NESTED_SCAN_EXCLUDED_ROOT_DIRS.includes(segment) || isHiddenPathSegment(segment));
|
|
46768
|
+
}
|
|
46769
|
+
/**
|
|
46770
|
+
* Whether a nested skills directory the scan reported can be used as an import
|
|
46771
|
+
* root: either the reason it cannot, or the path it resolves to relative to the
|
|
46772
|
+
* project, which the caller uses to tell two spellings of one root apart.
|
|
46773
|
+
*
|
|
46774
|
+
* A recursive glob cannot be swapped for a walk the way a flat one can, so the
|
|
46775
|
+
* path it hands back is checked instead. globby reads a backslash as a path
|
|
46776
|
+
* separator and rewrites it, so a root below a directory really named
|
|
46777
|
+
* `back\\slash` is reported at `back/slash`. Where that leads decides what to
|
|
46778
|
+
* do with it, and the spelling alone does not say: `back/slash` usually answers
|
|
46779
|
+
* to nothing, but `x\\..\\..\\outside` is reported at `x/../../outside`, which
|
|
46780
|
+
* climbs out of the project through the real sibling `x/`, and `a\\b` at `a/b`,
|
|
46781
|
+
* which may be a symbolic link out of the project that the scan — it passes
|
|
46782
|
+
* `followSymbolicLinks: false` — never meant to reach. Both are refused by
|
|
46783
|
+
* resolving the path rather than reading it.
|
|
46784
|
+
*
|
|
46785
|
+
* What is deliberately not refused is a rewritten path that stays inside the
|
|
46786
|
+
* project, such as `x\\..\\y` reported at `x/../y`. It names a real directory
|
|
46787
|
+
* `y`, and the scan reports that directory under this spelling *instead of* its
|
|
46788
|
+
* own, so refusing it would lose `y`'s skills rather than protect anything. The
|
|
46789
|
+
* skills under the directory that was really named are unreachable either way:
|
|
46790
|
+
* no path the scan can report leads back to a name holding a backslash.
|
|
46791
|
+
*
|
|
46792
|
+
* That last shape is the one case the scan cannot warn about. `a\\b` reported
|
|
46793
|
+
* at `a/b`, where `a/b` is itself a real directory, is indistinguishable from
|
|
46794
|
+
* the ordinary root `a/b` -- both are spelled the same and both are there -- so
|
|
46795
|
+
* the skills under `a\\b` are dropped without a word. Nothing in the path says
|
|
46796
|
+
* a second directory was ever involved.
|
|
46797
|
+
*/
|
|
46798
|
+
async function checkNestedSkillsRoot({ outputRoot, dirPath }) {
|
|
46799
|
+
if (!await directoryExists(dirPath)) return { reason: "it could not be read under the path the scan reports, most often because a directory name above it contains a backslash." };
|
|
46800
|
+
const realRelativeDirPath = await resolvedRelativePath({
|
|
46801
|
+
rootPath: outputRoot,
|
|
46802
|
+
targetPath: dirPath
|
|
46803
|
+
});
|
|
46804
|
+
if (posixRelativePathEscapesRoot(realRelativeDirPath)) return { reason: "it resolves outside the project." };
|
|
46805
|
+
const segments = realRelativeDirPath.split("/");
|
|
46806
|
+
const aboveTailSegments = segments.slice(0, -CLAUDECODE_SKILLS_DIR_SEGMENTS.length);
|
|
46807
|
+
if (segments.slice(-CLAUDECODE_SKILLS_DIR_SEGMENTS.length).join("/") !== CLAUDECODE_SKILLS_DIR_POSIX_PATH) return { reason: `it resolves to ${realRelativeDirPath === "" ? "the project root" : JSON.stringify(stripControlCharacters(realRelativeDirPath))}, which is not a ${CLAUDECODE_SKILLS_DIR_POSIX_PATH} directory.` };
|
|
46808
|
+
const excludedSegment = excludedNestedScanSegment(aboveTailSegments);
|
|
46809
|
+
if (excludedSegment !== void 0) return { reason: `it resolves inside ${JSON.stringify(stripControlCharacters(excludedSegment))}, which the nested scan excludes.` };
|
|
46810
|
+
return { realRelativeDirPath };
|
|
46811
|
+
}
|
|
44767
46812
|
const ClaudecodeSkillFrontmatterSchema = z.looseObject({
|
|
44768
46813
|
name: z.string(),
|
|
44769
46814
|
description: z.string(),
|
|
@@ -45012,10 +47057,10 @@ var ClaudecodeSkill = class extends ToolSkill {
|
|
|
45012
47057
|
*
|
|
45013
47058
|
* @see https://code.claude.com/docs/en/skills
|
|
45014
47059
|
*/
|
|
45015
|
-
static async getConfiguredImportRoots({ outputRoot, global = false }) {
|
|
47060
|
+
static async getConfiguredImportRoots({ outputRoot, global = false, logger }) {
|
|
45016
47061
|
if (global) return [];
|
|
45017
47062
|
const root = toPosixPath(outputRoot);
|
|
45018
|
-
|
|
47063
|
+
const filteredDirPaths = filterOutPathsInGitIgnoredDirectories({
|
|
45019
47064
|
rootDir: outputRoot,
|
|
45020
47065
|
filePaths: await findFilesByGlobs([`${root}/*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`], {
|
|
45021
47066
|
type: "dir",
|
|
@@ -45026,10 +47071,27 @@ var ClaudecodeSkill = class extends ToolSkill {
|
|
|
45026
47071
|
...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`)
|
|
45027
47072
|
]
|
|
45028
47073
|
})
|
|
45029
|
-
}).toSorted()
|
|
45030
|
-
|
|
45031
|
-
|
|
45032
|
-
|
|
47074
|
+
}).toSorted();
|
|
47075
|
+
const roots = [];
|
|
47076
|
+
const seenRealRelativeDirPaths = /* @__PURE__ */ new Set([CLAUDECODE_SKILLS_DIR_POSIX_PATH]);
|
|
47077
|
+
for (const dirPath of filteredDirPaths) {
|
|
47078
|
+
const scannedDirPath = resolve(dirPath);
|
|
47079
|
+
const check = await checkNestedSkillsRoot({
|
|
47080
|
+
outputRoot,
|
|
47081
|
+
dirPath: scannedDirPath
|
|
47082
|
+
});
|
|
47083
|
+
if ("reason" in check) {
|
|
47084
|
+
logger?.warn(`Skipping the nested Claude Code skills directory ${JSON.stringify(stripControlCharacters(scannedDirPath))}: ${check.reason} Its skills are not imported.`);
|
|
47085
|
+
continue;
|
|
47086
|
+
}
|
|
47087
|
+
if (seenRealRelativeDirPaths.has(check.realRelativeDirPath)) continue;
|
|
47088
|
+
seenRealRelativeDirPaths.add(check.realRelativeDirPath);
|
|
47089
|
+
roots.push({
|
|
47090
|
+
outputRoot,
|
|
47091
|
+
relativeDirPath: relative(outputRoot, scannedDirPath)
|
|
47092
|
+
});
|
|
47093
|
+
}
|
|
47094
|
+
return roots;
|
|
45033
47095
|
}
|
|
45034
47096
|
getFrontmatter() {
|
|
45035
47097
|
return ClaudecodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
|
|
@@ -49916,7 +51978,8 @@ var SkillsProcessor = class extends DirFeatureProcessor {
|
|
|
49916
51978
|
const paths = factory.class.getSettablePaths({ global: this.global });
|
|
49917
51979
|
const configuredRoots = factory.class.getConfiguredImportRoots ? await factory.class.getConfiguredImportRoots({
|
|
49918
51980
|
outputRoot: this.outputRoot,
|
|
49919
|
-
global: this.global
|
|
51981
|
+
global: this.global,
|
|
51982
|
+
logger: this.logger
|
|
49920
51983
|
}) : [];
|
|
49921
51984
|
const configuredRootPaths = new Set(configuredRoots.map((root) => root.relativeDirPath));
|
|
49922
51985
|
const roots = [...toolSkillImportRoots(paths), ...configuredRoots];
|
|
@@ -54632,6 +56695,152 @@ var VibeSubagent = class VibeSubagent extends ToolSubagent {
|
|
|
54632
56695
|
}
|
|
54633
56696
|
};
|
|
54634
56697
|
//#endregion
|
|
56698
|
+
//#region src/features/subagents/zcode-subagent.ts
|
|
56699
|
+
const ZcodeSubagentFrontmatterSchema = z.looseObject({
|
|
56700
|
+
name: z.string(),
|
|
56701
|
+
description: z.optional(z.string()),
|
|
56702
|
+
model: z.optional(z.string()),
|
|
56703
|
+
thoughtLevel: z.optional(z.string()),
|
|
56704
|
+
color: z.optional(z.string()),
|
|
56705
|
+
tools: z.optional(z.array(z.string())),
|
|
56706
|
+
disallowedTools: z.optional(z.array(z.string())),
|
|
56707
|
+
maxTurns: z.optional(z.number().check(z.int(), z.positive())),
|
|
56708
|
+
injectAgentsMd: z.optional(z.boolean()),
|
|
56709
|
+
mcpServers: z.optional(z.array(z.string()))
|
|
56710
|
+
});
|
|
56711
|
+
/**
|
|
56712
|
+
* ZCode subagents.
|
|
56713
|
+
*
|
|
56714
|
+
* Each subagent is one Markdown file with YAML frontmatter, named after the
|
|
56715
|
+
* agent, under `~/.zcode/agents/`.
|
|
56716
|
+
*
|
|
56717
|
+
* Global scope only. The current Beta "manages global / user-level subagents
|
|
56718
|
+
* stored under `~/.zcode/agents/`", and creating or editing workspace /
|
|
56719
|
+
* project-level subagents "is not available yet" — so this adapter is
|
|
56720
|
+
* registered with `supportsProject: false` and never writes into a project's
|
|
56721
|
+
* own `.zcode/`. The relative path is nonetheless spelled against
|
|
56722
|
+
* {@link ZCODE_AGENTS_DIR_PATH} so the workspace scope needs nothing more than
|
|
56723
|
+
* flipping that flag if ZCode ships it.
|
|
56724
|
+
*
|
|
56725
|
+
* @see https://zcode.z.ai/en/docs/subagents
|
|
56726
|
+
*/
|
|
56727
|
+
var ZcodeSubagent = class ZcodeSubagent extends ToolSubagent {
|
|
56728
|
+
frontmatter;
|
|
56729
|
+
body;
|
|
56730
|
+
constructor({ frontmatter, body, fileContent, ...rest }) {
|
|
56731
|
+
if (rest.validate !== false) {
|
|
56732
|
+
const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
56733
|
+
if (!result.success) throw new Error(`Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
|
|
56734
|
+
}
|
|
56735
|
+
super({
|
|
56736
|
+
...rest,
|
|
56737
|
+
fileContent: fileContent ?? stringifyFrontmatter(body, frontmatter)
|
|
56738
|
+
});
|
|
56739
|
+
this.frontmatter = frontmatter;
|
|
56740
|
+
this.body = body;
|
|
56741
|
+
}
|
|
56742
|
+
static getSettablePaths(_options = {}) {
|
|
56743
|
+
return { relativeDirPath: ZCODE_AGENTS_DIR_PATH };
|
|
56744
|
+
}
|
|
56745
|
+
getFrontmatter() {
|
|
56746
|
+
return this.frontmatter;
|
|
56747
|
+
}
|
|
56748
|
+
getBody() {
|
|
56749
|
+
return this.body;
|
|
56750
|
+
}
|
|
56751
|
+
toRulesyncSubagent() {
|
|
56752
|
+
const { name, description, ...rest } = this.frontmatter;
|
|
56753
|
+
return new RulesyncSubagent({
|
|
56754
|
+
outputRoot: ".",
|
|
56755
|
+
frontmatter: {
|
|
56756
|
+
targets: ["*"],
|
|
56757
|
+
name,
|
|
56758
|
+
description,
|
|
56759
|
+
...Object.keys(rest).length > 0 && { zcode: rest }
|
|
56760
|
+
},
|
|
56761
|
+
body: this.body,
|
|
56762
|
+
relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
|
|
56763
|
+
relativeFilePath: this.getRelativeFilePath(),
|
|
56764
|
+
validate: true
|
|
56765
|
+
});
|
|
56766
|
+
}
|
|
56767
|
+
static fromRulesyncSubagent({ outputRoot = process.cwd(), rulesyncSubagent, validate = true, global = false }) {
|
|
56768
|
+
const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter();
|
|
56769
|
+
const zcodeSection = rulesyncFrontmatter.zcode ?? {};
|
|
56770
|
+
const zcodeFrontmatter = {
|
|
56771
|
+
name: rulesyncFrontmatter.name,
|
|
56772
|
+
description: rulesyncFrontmatter.description,
|
|
56773
|
+
...zcodeSection
|
|
56774
|
+
};
|
|
56775
|
+
const body = rulesyncSubagent.getBody();
|
|
56776
|
+
const fileContent = stringifyFrontmatter(body, zcodeFrontmatter, { avoidBlockScalars: true });
|
|
56777
|
+
const paths = this.getSettablePaths({ global });
|
|
56778
|
+
return new ZcodeSubagent({
|
|
56779
|
+
outputRoot,
|
|
56780
|
+
frontmatter: zcodeFrontmatter,
|
|
56781
|
+
body,
|
|
56782
|
+
relativeDirPath: paths.relativeDirPath,
|
|
56783
|
+
relativeFilePath: rulesyncSubagent.getRelativeFilePath(),
|
|
56784
|
+
fileContent,
|
|
56785
|
+
validate,
|
|
56786
|
+
global
|
|
56787
|
+
});
|
|
56788
|
+
}
|
|
56789
|
+
validate() {
|
|
56790
|
+
if (!this.frontmatter) return {
|
|
56791
|
+
success: true,
|
|
56792
|
+
error: null
|
|
56793
|
+
};
|
|
56794
|
+
const result = ZcodeSubagentFrontmatterSchema.safeParse(this.frontmatter);
|
|
56795
|
+
if (result.success) return {
|
|
56796
|
+
success: true,
|
|
56797
|
+
error: null
|
|
56798
|
+
};
|
|
56799
|
+
else return {
|
|
56800
|
+
success: false,
|
|
56801
|
+
error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
|
|
56802
|
+
};
|
|
56803
|
+
}
|
|
56804
|
+
static isTargetedByRulesyncSubagent(rulesyncSubagent) {
|
|
56805
|
+
return this.isTargetedByRulesyncSubagentDefault({
|
|
56806
|
+
rulesyncSubagent,
|
|
56807
|
+
toolTarget: "zcode"
|
|
56808
|
+
});
|
|
56809
|
+
}
|
|
56810
|
+
static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, global = false }) {
|
|
56811
|
+
const dirPath = relativeDirPath ?? this.getSettablePaths({ global }).relativeDirPath;
|
|
56812
|
+
const filePath = join(outputRoot, dirPath, relativeFilePath);
|
|
56813
|
+
const fileContent = await readFileContent(filePath);
|
|
56814
|
+
const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath);
|
|
56815
|
+
const result = ZcodeSubagentFrontmatterSchema.safeParse(frontmatter);
|
|
56816
|
+
if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
|
|
56817
|
+
return new ZcodeSubagent({
|
|
56818
|
+
outputRoot,
|
|
56819
|
+
relativeDirPath: dirPath,
|
|
56820
|
+
relativeFilePath,
|
|
56821
|
+
frontmatter: result.data,
|
|
56822
|
+
body: content.trim(),
|
|
56823
|
+
fileContent,
|
|
56824
|
+
validate,
|
|
56825
|
+
global
|
|
56826
|
+
});
|
|
56827
|
+
}
|
|
56828
|
+
static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
|
|
56829
|
+
return new ZcodeSubagent({
|
|
56830
|
+
outputRoot,
|
|
56831
|
+
relativeDirPath,
|
|
56832
|
+
relativeFilePath,
|
|
56833
|
+
frontmatter: {
|
|
56834
|
+
name: "",
|
|
56835
|
+
description: ""
|
|
56836
|
+
},
|
|
56837
|
+
body: "",
|
|
56838
|
+
fileContent: "",
|
|
56839
|
+
validate: false
|
|
56840
|
+
});
|
|
56841
|
+
}
|
|
56842
|
+
};
|
|
56843
|
+
//#endregion
|
|
54635
56844
|
//#region src/features/subagents/zoocode-subagent.ts
|
|
54636
56845
|
/**
|
|
54637
56846
|
* Subagent (custom-mode) generator for **Zoo Code**, the community
|
|
@@ -54696,6 +56905,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54696
56905
|
["agentsmd", {
|
|
54697
56906
|
class: AgentsmdSubagent,
|
|
54698
56907
|
meta: {
|
|
56908
|
+
supportsProject: true,
|
|
54699
56909
|
supportsSimulated: true,
|
|
54700
56910
|
supportsGlobal: false,
|
|
54701
56911
|
filePattern: "*.md"
|
|
@@ -54704,6 +56914,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54704
56914
|
["antigravity-cli", {
|
|
54705
56915
|
class: AntigravityCliSubagent,
|
|
54706
56916
|
meta: {
|
|
56917
|
+
supportsProject: true,
|
|
54707
56918
|
supportsSimulated: false,
|
|
54708
56919
|
supportsGlobal: true,
|
|
54709
56920
|
filePattern: "*.md"
|
|
@@ -54712,6 +56923,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54712
56923
|
["antigravity-ide", {
|
|
54713
56924
|
class: AntigravityIdeSubagent,
|
|
54714
56925
|
meta: {
|
|
56926
|
+
supportsProject: true,
|
|
54715
56927
|
supportsSimulated: false,
|
|
54716
56928
|
supportsGlobal: true,
|
|
54717
56929
|
filePattern: "*.md"
|
|
@@ -54720,6 +56932,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54720
56932
|
["antigravity-plugin", {
|
|
54721
56933
|
class: AntigravityPluginSubagent,
|
|
54722
56934
|
meta: {
|
|
56935
|
+
supportsProject: true,
|
|
54723
56936
|
supportsSimulated: false,
|
|
54724
56937
|
supportsGlobal: false,
|
|
54725
56938
|
filePattern: "*.md"
|
|
@@ -54728,6 +56941,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54728
56941
|
["augmentcode", {
|
|
54729
56942
|
class: AugmentcodeSubagent,
|
|
54730
56943
|
meta: {
|
|
56944
|
+
supportsProject: true,
|
|
54731
56945
|
supportsSimulated: false,
|
|
54732
56946
|
supportsGlobal: true,
|
|
54733
56947
|
filePattern: "*.md"
|
|
@@ -54736,6 +56950,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54736
56950
|
["claudecode", {
|
|
54737
56951
|
class: ClaudecodeSubagent,
|
|
54738
56952
|
meta: {
|
|
56953
|
+
supportsProject: true,
|
|
54739
56954
|
supportsSimulated: false,
|
|
54740
56955
|
supportsGlobal: true,
|
|
54741
56956
|
filePattern: "*.md"
|
|
@@ -54744,6 +56959,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54744
56959
|
["claudecode-plugin", {
|
|
54745
56960
|
class: ClaudecodePluginSubagent,
|
|
54746
56961
|
meta: {
|
|
56962
|
+
supportsProject: true,
|
|
54747
56963
|
supportsSimulated: false,
|
|
54748
56964
|
supportsGlobal: false,
|
|
54749
56965
|
filePattern: "*.md"
|
|
@@ -54752,6 +56968,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54752
56968
|
["claudecode-legacy", {
|
|
54753
56969
|
class: ClaudecodeSubagent,
|
|
54754
56970
|
meta: {
|
|
56971
|
+
supportsProject: true,
|
|
54755
56972
|
supportsSimulated: false,
|
|
54756
56973
|
supportsGlobal: true,
|
|
54757
56974
|
filePattern: "*.md"
|
|
@@ -54760,6 +56977,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54760
56977
|
["cline", {
|
|
54761
56978
|
class: ClineSubagent,
|
|
54762
56979
|
meta: {
|
|
56980
|
+
supportsProject: true,
|
|
54763
56981
|
supportsSimulated: false,
|
|
54764
56982
|
supportsGlobal: true,
|
|
54765
56983
|
filePattern: "*.{yaml,yml}"
|
|
@@ -54768,6 +56986,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54768
56986
|
["codexcli", {
|
|
54769
56987
|
class: CodexCliSubagent,
|
|
54770
56988
|
meta: {
|
|
56989
|
+
supportsProject: true,
|
|
54771
56990
|
supportsSimulated: false,
|
|
54772
56991
|
supportsGlobal: true,
|
|
54773
56992
|
filePattern: "*.toml"
|
|
@@ -54776,6 +56995,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54776
56995
|
["copilot", {
|
|
54777
56996
|
class: CopilotSubagent,
|
|
54778
56997
|
meta: {
|
|
56998
|
+
supportsProject: true,
|
|
54779
56999
|
supportsSimulated: false,
|
|
54780
57000
|
supportsGlobal: true,
|
|
54781
57001
|
filePattern: "*.md"
|
|
@@ -54784,6 +57004,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54784
57004
|
["copilotcli", {
|
|
54785
57005
|
class: CopilotcliSubagent,
|
|
54786
57006
|
meta: {
|
|
57007
|
+
supportsProject: true,
|
|
54787
57008
|
supportsSimulated: false,
|
|
54788
57009
|
supportsGlobal: true,
|
|
54789
57010
|
filePattern: "*.agent.md"
|
|
@@ -54792,6 +57013,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54792
57013
|
["cursor", {
|
|
54793
57014
|
class: CursorSubagent,
|
|
54794
57015
|
meta: {
|
|
57016
|
+
supportsProject: true,
|
|
54795
57017
|
supportsSimulated: false,
|
|
54796
57018
|
supportsGlobal: true,
|
|
54797
57019
|
filePattern: "*.md"
|
|
@@ -54800,6 +57022,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54800
57022
|
["deepagents", {
|
|
54801
57023
|
class: DeepagentsSubagent,
|
|
54802
57024
|
meta: {
|
|
57025
|
+
supportsProject: true,
|
|
54803
57026
|
supportsSimulated: false,
|
|
54804
57027
|
supportsGlobal: true,
|
|
54805
57028
|
filePattern: join("*", "AGENTS.md")
|
|
@@ -54808,6 +57031,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54808
57031
|
["devin", {
|
|
54809
57032
|
class: DevinSubagent,
|
|
54810
57033
|
meta: {
|
|
57034
|
+
supportsProject: true,
|
|
54811
57035
|
supportsSimulated: false,
|
|
54812
57036
|
supportsGlobal: true,
|
|
54813
57037
|
filePattern: join("*", "AGENT.md")
|
|
@@ -54816,6 +57040,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54816
57040
|
["factorydroid", {
|
|
54817
57041
|
class: FactorydroidSubagent,
|
|
54818
57042
|
meta: {
|
|
57043
|
+
supportsProject: true,
|
|
54819
57044
|
supportsSimulated: false,
|
|
54820
57045
|
supportsGlobal: true,
|
|
54821
57046
|
filePattern: "*.md"
|
|
@@ -54824,6 +57049,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54824
57049
|
["goose", {
|
|
54825
57050
|
class: GooseSubagent,
|
|
54826
57051
|
meta: {
|
|
57052
|
+
supportsProject: true,
|
|
54827
57053
|
supportsSimulated: false,
|
|
54828
57054
|
supportsGlobal: true,
|
|
54829
57055
|
filePattern: "*.md"
|
|
@@ -54832,6 +57058,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54832
57058
|
["hermesagent", {
|
|
54833
57059
|
class: HermesagentSubagent,
|
|
54834
57060
|
meta: {
|
|
57061
|
+
supportsProject: true,
|
|
54835
57062
|
supportsGlobal: true,
|
|
54836
57063
|
supportsSimulated: false,
|
|
54837
57064
|
filePattern: "*.json"
|
|
@@ -54840,6 +57067,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54840
57067
|
["grokcli", {
|
|
54841
57068
|
class: GrokcliSubagent,
|
|
54842
57069
|
meta: {
|
|
57070
|
+
supportsProject: true,
|
|
54843
57071
|
supportsSimulated: false,
|
|
54844
57072
|
supportsGlobal: true,
|
|
54845
57073
|
filePattern: "*.md"
|
|
@@ -54848,6 +57076,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54848
57076
|
["junie", {
|
|
54849
57077
|
class: JunieSubagent,
|
|
54850
57078
|
meta: {
|
|
57079
|
+
supportsProject: true,
|
|
54851
57080
|
supportsSimulated: false,
|
|
54852
57081
|
supportsGlobal: true,
|
|
54853
57082
|
filePattern: "*.md"
|
|
@@ -54856,6 +57085,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54856
57085
|
["kiro", {
|
|
54857
57086
|
class: KiroSubagent,
|
|
54858
57087
|
meta: {
|
|
57088
|
+
supportsProject: true,
|
|
54859
57089
|
supportsSimulated: false,
|
|
54860
57090
|
supportsGlobal: false,
|
|
54861
57091
|
filePattern: "*.json"
|
|
@@ -54864,6 +57094,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54864
57094
|
["kiro-cli", {
|
|
54865
57095
|
class: KiroCliSubagent,
|
|
54866
57096
|
meta: {
|
|
57097
|
+
supportsProject: true,
|
|
54867
57098
|
supportsSimulated: false,
|
|
54868
57099
|
supportsGlobal: true,
|
|
54869
57100
|
filePattern: "*.json"
|
|
@@ -54872,6 +57103,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54872
57103
|
["kiro-ide", {
|
|
54873
57104
|
class: KiroIdeSubagent,
|
|
54874
57105
|
meta: {
|
|
57106
|
+
supportsProject: true,
|
|
54875
57107
|
supportsSimulated: false,
|
|
54876
57108
|
supportsGlobal: true,
|
|
54877
57109
|
filePattern: "*.md"
|
|
@@ -54880,6 +57112,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54880
57112
|
["kilo", {
|
|
54881
57113
|
class: KiloSubagent,
|
|
54882
57114
|
meta: {
|
|
57115
|
+
supportsProject: true,
|
|
54883
57116
|
supportsSimulated: false,
|
|
54884
57117
|
supportsGlobal: true,
|
|
54885
57118
|
filePattern: "*.md"
|
|
@@ -54888,6 +57121,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54888
57121
|
["kimi-code", {
|
|
54889
57122
|
class: KimiCodeSubagent,
|
|
54890
57123
|
meta: {
|
|
57124
|
+
supportsProject: true,
|
|
54891
57125
|
supportsSimulated: false,
|
|
54892
57126
|
supportsGlobal: true,
|
|
54893
57127
|
filePattern: join("**", "*.md")
|
|
@@ -54896,6 +57130,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54896
57130
|
["opencode", {
|
|
54897
57131
|
class: OpenCodeSubagent,
|
|
54898
57132
|
meta: {
|
|
57133
|
+
supportsProject: true,
|
|
54899
57134
|
supportsSimulated: false,
|
|
54900
57135
|
supportsGlobal: true,
|
|
54901
57136
|
filePattern: "*.md"
|
|
@@ -54904,6 +57139,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54904
57139
|
["qwencode", {
|
|
54905
57140
|
class: QwencodeSubagent,
|
|
54906
57141
|
meta: {
|
|
57142
|
+
supportsProject: true,
|
|
54907
57143
|
supportsSimulated: false,
|
|
54908
57144
|
supportsGlobal: true,
|
|
54909
57145
|
filePattern: "*.md"
|
|
@@ -54912,6 +57148,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54912
57148
|
["reasonix", {
|
|
54913
57149
|
class: ReasonixSubagent,
|
|
54914
57150
|
meta: {
|
|
57151
|
+
supportsProject: true,
|
|
54915
57152
|
supportsSimulated: false,
|
|
54916
57153
|
supportsGlobal: true,
|
|
54917
57154
|
filePattern: join("*", "SKILL.md")
|
|
@@ -54920,6 +57157,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54920
57157
|
["roo", {
|
|
54921
57158
|
class: RooSubagent,
|
|
54922
57159
|
meta: {
|
|
57160
|
+
supportsProject: true,
|
|
54923
57161
|
supportsSimulated: false,
|
|
54924
57162
|
supportsGlobal: false,
|
|
54925
57163
|
filePattern: ".roomodes"
|
|
@@ -54928,6 +57166,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54928
57166
|
["zoocode", {
|
|
54929
57167
|
class: ZoocodeSubagent,
|
|
54930
57168
|
meta: {
|
|
57169
|
+
supportsProject: true,
|
|
54931
57170
|
supportsSimulated: false,
|
|
54932
57171
|
supportsGlobal: false,
|
|
54933
57172
|
filePattern: ".roomodes"
|
|
@@ -54936,6 +57175,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54936
57175
|
["rovodev", {
|
|
54937
57176
|
class: RovodevSubagent,
|
|
54938
57177
|
meta: {
|
|
57178
|
+
supportsProject: true,
|
|
54939
57179
|
supportsSimulated: false,
|
|
54940
57180
|
supportsGlobal: true,
|
|
54941
57181
|
filePattern: "*.md"
|
|
@@ -54944,6 +57184,7 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54944
57184
|
["takt", {
|
|
54945
57185
|
class: TaktSubagent,
|
|
54946
57186
|
meta: {
|
|
57187
|
+
supportsProject: true,
|
|
54947
57188
|
supportsSimulated: false,
|
|
54948
57189
|
supportsGlobal: true,
|
|
54949
57190
|
filePattern: "*.md"
|
|
@@ -54952,10 +57193,20 @@ const toolSubagentFactories = /* @__PURE__ */ new Map([
|
|
|
54952
57193
|
["vibe", {
|
|
54953
57194
|
class: VibeSubagent,
|
|
54954
57195
|
meta: {
|
|
57196
|
+
supportsProject: true,
|
|
54955
57197
|
supportsSimulated: false,
|
|
54956
57198
|
supportsGlobal: true,
|
|
54957
57199
|
filePattern: "*.toml"
|
|
54958
57200
|
}
|
|
57201
|
+
}],
|
|
57202
|
+
["zcode", {
|
|
57203
|
+
class: ZcodeSubagent,
|
|
57204
|
+
meta: {
|
|
57205
|
+
supportsProject: false,
|
|
57206
|
+
supportsSimulated: false,
|
|
57207
|
+
supportsGlobal: true,
|
|
57208
|
+
filePattern: "*.md"
|
|
57209
|
+
}
|
|
54959
57210
|
}]
|
|
54960
57211
|
]);
|
|
54961
57212
|
const defaultGetFactory$1 = (target) => {
|
|
@@ -54964,7 +57215,9 @@ const defaultGetFactory$1 = (target) => {
|
|
|
54964
57215
|
return factory;
|
|
54965
57216
|
};
|
|
54966
57217
|
const allToolTargetKeys$1 = [...toolSubagentFactories.keys()];
|
|
54967
|
-
const subagentsProcessorToolTargets = allToolTargetKeys$1
|
|
57218
|
+
const subagentsProcessorToolTargets = allToolTargetKeys$1.filter((target) => {
|
|
57219
|
+
return toolSubagentFactories.get(target)?.meta.supportsProject ?? false;
|
|
57220
|
+
});
|
|
54968
57221
|
const subagentsProcessorToolTargetsSimulated = allToolTargetKeys$1.filter((target) => {
|
|
54969
57222
|
return toolSubagentFactories.get(target)?.meta.supportsSimulated ?? false;
|
|
54970
57223
|
});
|
|
@@ -55064,7 +57317,7 @@ var SubagentsProcessor = class extends FeatureProcessor {
|
|
|
55064
57317
|
this.logger.debug(`Rulesync subagents directory not found: ${subagentsDir}`);
|
|
55065
57318
|
return [];
|
|
55066
57319
|
}
|
|
55067
|
-
const mdFiles = (await
|
|
57320
|
+
const mdFiles = (await listDirectoryEntryNames(subagentsDir)).filter((file) => file.endsWith(".md"));
|
|
55068
57321
|
if (mdFiles.length === 0) {
|
|
55069
57322
|
this.logger.debug(`No markdown files found in rulesync subagents directory: ${subagentsDir}`);
|
|
55070
57323
|
return [];
|
|
@@ -63610,6 +65863,6 @@ async function importChecksCore(params) {
|
|
|
63610
65863
|
return writtenCount;
|
|
63611
65864
|
}
|
|
63612
65865
|
//#endregion
|
|
63613
|
-
export {
|
|
65866
|
+
export { SHARED_USER_MANAGED_CONFIG_PATHS as $, MAX_FILE_SIZE as $t, groupSpellingsByCaseFoldedIdentity as A, DEPRECATED_FEATURE_REPLACEMENTS as An, isFileSystemError as At, RulesyncPermissions as B, removeFile as Bt, CLAUDECODE_SKILLS_DIR_PATH as C, RULESYNC_RULES_RELATIVE_DIR_PATH as Cn, createTempDirectory as Ct, FACTORYDROID_DIR as D, parseCommaSeparatedList as Dn, getFileSize as Dt, CODEXCLI_DIR as E, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as En, fileExists as Et, RulesyncSubagentFrontmatterSchema as F, stripControlCharactersKeepingLineFeeds as Fn, pathEscapesRoot as Ft, resolveRulesyncSourceWritePath as G, toPosixPath as Gt, RulesyncIgnore as H, removeTempDirectory as Ht, RulesyncSkill as I, stripHiddenCharacters as In, readFileContent as It, RulesyncCommandFrontmatterSchema as J, ALL_TOOL_TARGETS as Jt, parseJsonc as K, writeFileBuffer as Kt, RulesyncSkillFrontmatterSchema as L, readFileContentOrNull as Lt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as M, truncateText as Mn, listDirectoryEntryNames as Mt, getLocalSkillDirNames as N, hasDeceptiveHiddenCharacters as Nn, listFilePathsRecursively as Nt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as O, ALL_FEATURES as On, getHomeDirectory as Ot, RulesyncSubagent as P, stripControlCharacters as Pn, listSubdirectoryNames as Pt, loadYaml as Q, CURATED_RULES_FEATURE_SUBDIR as Qt, RulesyncRule as R, removeDirectory as Rt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as S, RULESYNC_RELATIVE_DIR_PATH as Sn, checkPathTraversal as St, CODEXCLI_BASH_RULES_FILE_NAME as T, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Tn, ensureDir as Tt, RulesyncHooks as U, resolvePath as Ut, RulesyncMcp as V, removeFileStrict as Vt, getRulesyncSourceCandidates as W, runWithDirectoryRollback as Wt, RulesyncCheckFrontmatterSchema as X, PACKAGING_TOOL_TARGETS as Xt, RulesyncCheck as Y, ALL_TOOL_TARGETS_WITH_WILDCARD as Yt, stringifyFrontmatter as Z, ToolTargetSchema as Zt, QWENCODE_DIR as _, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as _n, CLIError as _t, getProcessorRegistryEntry as a, RULESYNC_CONFIG_SCHEMA_URL as an, ConfigFileSchema as at, CLAUDECODE_LOCAL_RULE_FILE_NAME as b, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as bn, assertTreeContainsNoSymlinks as bt, RulesProcessor as c, RULESYNC_HOOKS_FILE_NAME as cn, findControlCharacter as ct, displayWidthOf as d, RULESYNC_IGNORE_RELATIVE_FILE_PATH as dn, WarningCollectingLogger as dt, RULESYNC_AIIGNORE_FILE_NAME as en, SKILL_FILE_NAME as et, shortenToWidth as f, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as fn, fallbackLogger as ft, CommandsProcessor as g, RULESYNC_MCP_SCHEMA_URL as gn, withWarnOnceScope as gt, HooksProcessor as h, RULESYNC_MCP_RELATIVE_FILE_PATH as hn, resetWarnedOnceMessages as ht, inspectInputRoots as i, RULESYNC_CONFIG_RELATIVE_FILE_PATH as in, CONFLICTING_TARGET_PAIRS as it, AUGMENTCODE_DIR as j, formatError as jn, isSymlink as jt, caseFoldIdentity as k, ALL_FEATURES_WITH_WILDCARD as kn, isFileNotFoundError as kt, SubagentsProcessor as l, RULESYNC_HOOKS_LEGACY_FILE_NAME as ln, ConsoleLogger as lt, IgnoreProcessor as m, RULESYNC_MCP_LEGACY_FILE_NAME as mn, withFallbackLoggerTarget as mt, formatSourceLoadFailure as n, RULESYNC_CHECKS_RELATIVE_DIR_PATH as nn, mergeInputRootConfigs as nt, convertFromTool as o, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as on, GITIGNORE_DESTINATION_KEY as ot, McpProcessor as p, RULESYNC_MCP_FILE_NAME as pn, warnOnConflictingFlags as pt, RulesyncCommand as q, writeFileContent as qt, generate as r, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as rn, resolveEffectiveInputRoots as rt, isPackagingToolTarget as s, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as sn, SourceEntrySchema as st, importFromTool as t, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as tn, ConfigResolver as tt, SkillsProcessor as u, RULESYNC_HOOKS_RELATIVE_FILE_PATH as un, JsonLogger as ut, QWENCODE_LOCAL_RULE_FILE_NAME as v, RULESYNC_PERMISSIONS_FILE_NAME as vn, ErrorCodes as vt, ChecksProcessor as w, RULESYNC_SKILLS_RELATIVE_DIR_PATH as wn, directoryExists as wt, CLAUDECODE_MEMORIES_DIR_NAME as x, RULESYNC_PERMISSIONS_SCHEMA_URL as xn, assertWritablePathInsideRoot as xt, CLAUDECODE_DIR as y, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as yn, assertDirectoryIfExists as yt, RulesyncRuleFrontmatterSchema as z, removeDirectoryStrict as zt };
|
|
63614
65867
|
|
|
63615
|
-
//# sourceMappingURL=import-
|
|
65868
|
+
//# sourceMappingURL=import-1-jjDDAm.js.map
|