rulesync 16.21.0 → 16.22.1

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.
@@ -246,6 +246,35 @@ function hasDeceptiveHiddenCharacters(text) {
246
246
  return !joinsCharacter(characters[index - 1]) || !joinsCharacter(characters[index + 1]);
247
247
  });
248
248
  }
249
+ /** A mark that draws around the character before it — a circle, a square, a keycap box. */
250
+ const ENCLOSING_MARK_PATTERN = /\p{Me}/u;
251
+ /**
252
+ * Whether `text` carries an enclosing mark that is not the keycap of an emoji
253
+ * keycap sequence.
254
+ *
255
+ * An enclosing mark (`\p{Me}`: U+20DD COMBINING ENCLOSING CIRCLE, U+20E3
256
+ * COMBINING ENCLOSING KEYCAP, the Cyrillic and Vedic ones) is drawn over the
257
+ * character before it and takes no column of its own, so `pdf` with one after
258
+ * it occupies the three columns of `pdf` and is a fourth directory underneath.
259
+ * Unlike a joiner or a variation selector it is not invisible — the box is
260
+ * drawn — which is why `hasDeceptiveHiddenCharacters` does not refuse it and
261
+ * why it is a question for the confusable-name note instead: the row is drawn,
262
+ * only not the way its name reads. The one place an enclosing mark belongs in
263
+ * a name is the keycap sequence of UTS #51, which `isKeycapSequence` matches
264
+ * whole; every other one is left over.
265
+ *
266
+ * Restricted to `\p{Me}` on purpose: a non-spacing mark (`\p{Mn}`) is how
267
+ * Devanagari, Arabic and Vietnamese write, and folding those would mark
268
+ * ordinary names in every one of them.
269
+ */
270
+ function hasEnclosingMarkOutsideKeycap(text) {
271
+ const characters = [...text];
272
+ return characters.some((character, index) => ENCLOSING_MARK_PATTERN.test(character) && !isKeycapSequence({
273
+ base: characters[index - 2],
274
+ selector: characters[index - 1] ?? "",
275
+ following: character
276
+ }));
277
+ }
249
278
  //#endregion
250
279
  //#region src/utils/truncate.ts
251
280
  /**
@@ -415,34 +444,34 @@ const isFeatureValueEnabled = (value) => {
415
444
  const parseCommaSeparatedList = (value) => value.split(",").map((s) => s.trim()).filter(Boolean);
416
445
  //#endregion
417
446
  //#region src/constants/rulesync-paths.ts
418
- const { join: join$299 } = node_path.posix;
447
+ const { join: join$304 } = node_path.posix;
419
448
  const RULESYNC_CONFIG_RELATIVE_FILE_PATH = "rulesync.jsonc";
420
449
  const RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH = "rulesync.local.jsonc";
421
450
  const RULESYNC_RELATIVE_DIR_PATH = ".rulesync";
422
451
  const RULES_FEATURE_SUBDIR = "rules";
423
- const CURATED_RULES_FEATURE_SUBDIR = join$299(RULES_FEATURE_SUBDIR, ".curated");
452
+ const CURATED_RULES_FEATURE_SUBDIR = join$304(RULES_FEATURE_SUBDIR, ".curated");
424
453
  const COMMANDS_FEATURE_SUBDIR = "commands";
425
454
  const SUBAGENTS_FEATURE_SUBDIR = "subagents";
426
455
  const CHECKS_FEATURE_SUBDIR = "checks";
427
456
  const SKILLS_FEATURE_SUBDIR = "skills";
428
- const CURATED_SKILLS_FEATURE_SUBDIR = join$299(SKILLS_FEATURE_SUBDIR, ".curated");
429
- const RULESYNC_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
430
- const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
431
- const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
432
- const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
433
- const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
434
- const RULESYNC_MCP_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
435
- const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
436
- const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
437
- join$299(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
438
- const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
439
- const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
457
+ const CURATED_SKILLS_FEATURE_SUBDIR = join$304(SKILLS_FEATURE_SUBDIR, ".curated");
458
+ const RULESYNC_RULES_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, RULES_FEATURE_SUBDIR);
459
+ const RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CURATED_RULES_FEATURE_SUBDIR);
460
+ const RULESYNC_COMMANDS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, COMMANDS_FEATURE_SUBDIR);
461
+ const RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, SUBAGENTS_FEATURE_SUBDIR);
462
+ const RULESYNC_CHECKS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CHECKS_FEATURE_SUBDIR);
463
+ const RULESYNC_MCP_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "mcp.jsonc");
464
+ const RULESYNC_HOOKS_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "hooks.jsonc");
465
+ const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "permissions.jsonc");
466
+ join$304(RULESYNC_RELATIVE_DIR_PATH, "mcp.json");
467
+ const RULESYNC_HOOKS_LEGACY_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "hooks.json");
468
+ const RULESYNC_PERMISSIONS_LEGACY_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, "permissions.json");
440
469
  const RULESYNC_AIIGNORE_FILE_NAME = ".aiignore";
441
- const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
470
+ const RULESYNC_AIIGNORE_RELATIVE_FILE_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, ".aiignore");
442
471
  const RULESYNC_IGNORE_RELATIVE_FILE_PATH = ".rulesyncignore";
443
472
  const RULESYNC_OVERVIEW_FILE_NAME = "overview.md";
444
- const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
445
- const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$299(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
473
+ const RULESYNC_SKILLS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, SKILLS_FEATURE_SUBDIR);
474
+ const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join$304(RULESYNC_RELATIVE_DIR_PATH, CURATED_SKILLS_FEATURE_SUBDIR);
446
475
  const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock";
447
476
  const RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync-npm.lock.json";
448
477
  const RULESYNC_MCP_FILE_NAME = "mcp.jsonc";
@@ -469,9 +498,11 @@ const rulesProcessorToolTargetTuple = [
469
498
  "claudecode",
470
499
  "claudecode-legacy",
471
500
  "cline",
501
+ "codebuddy",
472
502
  "codexcli",
473
503
  "copilot",
474
504
  "copilotcli",
505
+ "crush",
475
506
  "cursor",
476
507
  "deepagents",
477
508
  "factorydroid",
@@ -507,6 +538,7 @@ const ignoreProcessorToolTargetTuple = [
507
538
  "claudecode",
508
539
  "claudecode-legacy",
509
540
  "cline",
541
+ "crush",
510
542
  "cursor",
511
543
  "hermesagent",
512
544
  "junie",
@@ -648,6 +680,7 @@ const skillsProcessorToolTargetTuple = [
648
680
  "codexcli",
649
681
  "copilot",
650
682
  "copilotcli",
683
+ "crush",
651
684
  "cursor",
652
685
  "deepagents",
653
686
  "factorydroid",
@@ -3352,6 +3385,83 @@ var RulesyncFile = class extends AiFile {
3352
3385
  }
3353
3386
  };
3354
3387
  //#endregion
3388
+ //#region src/utils/bounded-walk.ts
3389
+ const ALIAS_HINT = "(a chain of YAML aliases may be amplifying the document)";
3390
+ /**
3391
+ * Create the bookkeeping for one walk. `subject` names the document kind in
3392
+ * every error ("Frontmatter", "Shared config"); `root`, when given, is entered
3393
+ * up front so the root object counts as the first nesting level, matching the
3394
+ * +1 that `enter` applies to every container nested inside it.
3395
+ */
3396
+ function createBoundedWalk({ subject, limits, root }) {
3397
+ const ancestors = /* @__PURE__ */ new WeakSet();
3398
+ let valuesRemaining = limits.maxValues;
3399
+ let stringCharsRemaining = limits.maxStringChars;
3400
+ let depth = 0;
3401
+ const chargeChars = (chars) => {
3402
+ stringCharsRemaining -= chars;
3403
+ if (stringCharsRemaining < 0) throw new Error(`${subject}'s string values expand to more than ${limits.maxStringChars} characters; refusing to process it ${ALIAS_HINT}`);
3404
+ };
3405
+ const chargeValue = (stringChars = 0) => {
3406
+ valuesRemaining -= 1;
3407
+ if (valuesRemaining < 0) throw new Error(`${subject} expands to more than ${limits.maxValues} values; refusing to process it ${ALIAS_HINT}`);
3408
+ chargeChars(stringChars);
3409
+ };
3410
+ const enter = (container) => {
3411
+ depth += 1;
3412
+ if (depth > limits.maxDepth) throw new Error(`${subject} nests more than ${limits.maxDepth} levels deep; refusing to process it ${ALIAS_HINT}`);
3413
+ ancestors.add(container);
3414
+ };
3415
+ const leave = (container) => {
3416
+ ancestors.delete(container);
3417
+ depth -= 1;
3418
+ };
3419
+ if (root !== void 0) enter(root);
3420
+ return {
3421
+ chargeValue,
3422
+ chargeChars,
3423
+ isAncestor: (container) => ancestors.has(container),
3424
+ enter,
3425
+ leave
3426
+ };
3427
+ }
3428
+ //#endregion
3429
+ //#region src/utils/prototype-pollution.ts
3430
+ /**
3431
+ * Keys that, if walked into when constructing or merging objects from
3432
+ * untrusted input, can mutate `Object.prototype` (or otherwise the prototype
3433
+ * chain) and propagate state to every other object in the runtime. Any code
3434
+ * that copies arbitrary user-supplied keys into a fresh object — frontmatter
3435
+ * parsing, MCP config conversion, settings round-trip — should skip these.
3436
+ */
3437
+ const PROTOTYPE_POLLUTION_KEYS = /* @__PURE__ */ new Set([
3438
+ "__proto__",
3439
+ "constructor",
3440
+ "prototype"
3441
+ ]);
3442
+ function isPrototypePollutionKey(key) {
3443
+ return PROTOTYPE_POLLUTION_KEYS.has(key);
3444
+ }
3445
+ /**
3446
+ * Returns a shallow copy of a record's own entries with every
3447
+ * prototype-pollution key (`__proto__`, `constructor`, `prototype`) dropped.
3448
+ *
3449
+ * Use when copying a nested, user-supplied string map — an MCP server's `env`
3450
+ * or `headers` table — into freshly generated config. Carrying such a map by
3451
+ * reference, or re-assigning its keys via bracket notation, would let a literal
3452
+ * `__proto__` key ride along (and re-assigning it would mutate the target's
3453
+ * prototype). Walking the entries through this helper severs that path while
3454
+ * preserving every legitimate key.
3455
+ */
3456
+ function omitPrototypePollutionKeys(record) {
3457
+ const sanitized = {};
3458
+ for (const [key, value] of Object.entries(record)) {
3459
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
3460
+ sanitized[key] = value;
3461
+ }
3462
+ return sanitized;
3463
+ }
3464
+ //#endregion
3355
3465
  //#region src/utils/type-guards.ts
3356
3466
  /**
3357
3467
  * Type guard to check if a value is a plain object (Record<string, unknown>).
@@ -3416,67 +3526,154 @@ function loadYaml(content) {
3416
3526
  }
3417
3527
  //#endregion
3418
3528
  //#region src/utils/frontmatter.ts
3419
- function deepRemoveNullishValue(value) {
3529
+ /**
3530
+ * Upper bound on the number of values a frontmatter document may expand to
3531
+ * once every YAML alias is written out.
3532
+ *
3533
+ * A YAML alias makes one parsed container reachable from many keys, and the
3534
+ * cleaners below copy each reachable value, so a small file with a few levels
3535
+ * of nested aliases (an "alias bomb") can expand into megabytes of output or
3536
+ * exhaust the heap. Counting every visited value against this budget turns
3537
+ * that into an error instead. Real frontmatter is a handful of keys; even a
3538
+ * generous skill manifest stays orders of magnitude below the limit.
3539
+ */
3540
+ const MAX_FRONTMATTER_VALUES = 1e5;
3541
+ /**
3542
+ * Upper bound on the total character count of string leaves a frontmatter
3543
+ * document may expand to.
3544
+ *
3545
+ * {@link MAX_FRONTMATTER_VALUES} bounds how many values are visited, but a
3546
+ * single long string aliased thousands of times still fits that budget while
3547
+ * the duplicated output balloons: one scalar of a few KB, chained through a
3548
+ * handful of aliases within the value budget, can multiply into a document
3549
+ * many megabytes larger than it started. Charging every visited string's
3550
+ * length against this separate budget bounds that output regardless of how
3551
+ * many aliases point at it.
3552
+ */
3553
+ const MAX_FRONTMATTER_STRING_CHARS = 4e6;
3554
+ /**
3555
+ * Upper bound on the raw character length of the `---`-delimited frontmatter
3556
+ * block itself, checked before it is ever handed to the YAML parser.
3557
+ *
3558
+ * The budgets above only bound the *parsed* document — the walk over
3559
+ * `matter()`'s output — but a complex YAML key (an array or mapping used as a
3560
+ * mapping key) is joined into a string by js-yaml while it parses, and a
3561
+ * mapping with many such keys can cost real memory before that walk ever
3562
+ * starts, or even before `matter()` returns. Capping the raw block size keeps
3563
+ * that parse-time cost bounded regardless of what the block contains. Real
3564
+ * frontmatter blocks are a few hundred bytes at most; even a large project
3565
+ * manifest stays well under this.
3566
+ */
3567
+ const MAX_FRONTMATTER_RAW_CHARS = 65536;
3568
+ /**
3569
+ * Estimate the serialized character cost of a leaf that is not a string (a
3570
+ * string leaf is charged by its own length instead).
3571
+ *
3572
+ * js-yaml's default schema resolves `!!binary` scalars to a `Uint8Array`, and
3573
+ * its dumper writes one back out as base64 — roughly 4 output characters per
3574
+ * 3 input bytes. Without this, an aliased binary blob would walk the budget
3575
+ * for free even though it can dominate the emitted document's size.
3576
+ */
3577
+ function estimateLeafChars(value) {
3578
+ if (value instanceof Uint8Array) return Math.ceil(value.byteLength / 3) * 4;
3579
+ return 0;
3580
+ }
3581
+ /**
3582
+ * Copy one parsed value, dropping nullish leaves and cyclic references.
3583
+ *
3584
+ * Every alias is still written out as an independent copy, as gray-matter's
3585
+ * default YAML engine would otherwise serialize shared references as `&ref_0`
3586
+ * anchors that simplified frontmatter parsers cannot read; the expansion is
3587
+ * bounded by {@link MAX_FRONTMATTER_VALUES} instead.
3588
+ */
3589
+ function deepCleanValue(value, options) {
3590
+ const leafChars = typeof value === "string" ? value.length : estimateLeafChars(value);
3591
+ options.walk.chargeValue(leafChars);
3420
3592
  if (value === null || value === void 0) return;
3421
- if (Array.isArray(value)) return value.map((item) => deepRemoveNullishValue(item)).filter((item) => item !== void 0);
3593
+ if (typeof value === "string") return options.transformString ? options.transformString(value) : value;
3594
+ if (Array.isArray(value)) {
3595
+ if (options.walk.isAncestor(value)) return;
3596
+ options.walk.enter(value);
3597
+ const cleanedArray = [];
3598
+ for (const item of value) {
3599
+ const cleaned = deepCleanValue(item, options);
3600
+ if (cleaned !== void 0) cleanedArray.push(cleaned);
3601
+ }
3602
+ options.walk.leave(value);
3603
+ return cleanedArray;
3604
+ }
3422
3605
  if (isPlainObject$1(value)) {
3423
- const result = {};
3424
- for (const [key, val] of Object.entries(value)) {
3425
- const cleaned = deepRemoveNullishValue(val);
3426
- if (cleaned !== void 0) result[key] = cleaned;
3427
- }
3606
+ if (options.walk.isAncestor(value)) return;
3607
+ options.walk.enter(value);
3608
+ const result = cleanOwnEntries(value, options);
3609
+ options.walk.leave(value);
3428
3610
  return result;
3429
3611
  }
3430
3612
  return value;
3431
3613
  }
3432
- function deepRemoveNullishObject(obj) {
3433
- if (!obj || typeof obj !== "object") return {};
3614
+ /**
3615
+ * Copy the cleaned own entries of a parsed object into a fresh record.
3616
+ *
3617
+ * A YAML parser defines a `__proto__:` key as an own property, and assigning
3618
+ * it back with bracket notation would instead replace the new record's
3619
+ * prototype, whose members zod's loose object schemas then promote to real
3620
+ * keys. So a fetched skill could hide `allowed-tools` under an innocuous
3621
+ * looking `__proto__:` block. That key, `constructor` and `prototype` are
3622
+ * therefore dropped rather than copied, and cannot be used as frontmatter
3623
+ * keys.
3624
+ */
3625
+ function cleanOwnEntries(obj, options) {
3434
3626
  const result = {};
3435
3627
  for (const [key, val] of Object.entries(obj)) {
3436
- const cleaned = deepRemoveNullishValue(val);
3628
+ options.walk.chargeChars(key.length);
3629
+ const cleaned = deepCleanValue(val, options);
3630
+ if (isPrototypePollutionKey(key)) continue;
3437
3631
  if (cleaned !== void 0) result[key] = cleaned;
3438
3632
  }
3439
3633
  return result;
3440
3634
  }
3441
- function deepFlattenStringsValue(value) {
3442
- if (value === null || value === void 0) return;
3443
- if (typeof value === "string") return value.replace(/\n+/g, " ").trim();
3444
- if (Array.isArray(value)) return value.map((item) => deepFlattenStringsValue(item)).filter((item) => item !== void 0);
3445
- if (isPlainObject$1(value)) {
3446
- const result = {};
3447
- for (const [key, val] of Object.entries(value)) {
3448
- const cleaned = deepFlattenStringsValue(val);
3449
- if (cleaned !== void 0) result[key] = cleaned;
3450
- }
3451
- return result;
3452
- }
3453
- return value;
3635
+ function deepCleanObject(obj, options) {
3636
+ if (!obj || typeof obj !== "object") return {};
3637
+ return cleanOwnEntries(obj, {
3638
+ ...options,
3639
+ walk: createBoundedWalk({
3640
+ subject: "Frontmatter",
3641
+ limits: {
3642
+ maxValues: MAX_FRONTMATTER_VALUES,
3643
+ maxStringChars: MAX_FRONTMATTER_STRING_CHARS,
3644
+ maxDepth: 64
3645
+ },
3646
+ root: obj
3647
+ })
3648
+ });
3649
+ }
3650
+ /** Drop null and undefined values, recursively. */
3651
+ function deepRemoveNullishObject(obj) {
3652
+ return deepCleanObject(obj, {});
3454
3653
  }
3654
+ /** Drop nullish values and collapse every string onto a single line. */
3455
3655
  function deepFlattenStringsObject(obj) {
3456
- if (!obj || typeof obj !== "object") return {};
3457
- const result = {};
3458
- for (const [key, val] of Object.entries(obj)) {
3459
- const cleaned = deepFlattenStringsValue(val);
3460
- if (cleaned !== void 0) result[key] = cleaned;
3461
- }
3462
- return result;
3656
+ return deepCleanObject(obj, { transformString: (value) => value.replace(/\n+/g, " ").trim() });
3463
3657
  }
3464
3658
  function stringifyFrontmatter(body, frontmatter, options) {
3465
3659
  const { avoidBlockScalars = false } = options ?? {};
3466
3660
  const cleanFrontmatter = avoidBlockScalars ? deepFlattenStringsObject(frontmatter) : deepRemoveNullishObject(frontmatter);
3467
- if (avoidBlockScalars) return gray_matter.default.stringify(body, cleanFrontmatter, { engines: { yaml: {
3661
+ const file = { content: body };
3662
+ if (avoidBlockScalars) return gray_matter.default.stringify(file, cleanFrontmatter, { engines: { yaml: {
3468
3663
  parse: (input) => loadYaml(input) ?? {},
3469
3664
  stringify: (data) => (0, js_yaml.dump)(data, { lineWidth: -1 })
3470
3665
  } } });
3471
- return gray_matter.default.stringify(body, cleanFrontmatter);
3666
+ return gray_matter.default.stringify(file, cleanFrontmatter);
3472
3667
  }
3473
3668
  function parseFrontmatter(content, filePath) {
3474
3669
  let frontmatter;
3475
3670
  let body;
3476
3671
  let hasFrontmatter;
3477
3672
  try {
3673
+ const bounds = findFrontmatterBlockBounds(content);
3674
+ if (bounds && bounds.blockEnd - bounds.blockStart > 65536) throw new Error(`Frontmatter block is larger than ${MAX_FRONTMATTER_RAW_CHARS} characters; refusing to parse it (a complex YAML key can cost memory while parsing, before any post-parse budget applies)`);
3478
3675
  const result = (0, gray_matter.default)(content, {});
3479
- frontmatter = result.data;
3676
+ frontmatter = deepRemoveNullishObject(result.data);
3480
3677
  body = result.content;
3481
3678
  hasFrontmatter = result.matter !== "" || content.trimStart().startsWith("---");
3482
3679
  } catch (error) {
@@ -3484,7 +3681,7 @@ function parseFrontmatter(content, filePath) {
3484
3681
  throw error;
3485
3682
  }
3486
3683
  return {
3487
- frontmatter: deepRemoveNullishObject(frontmatter),
3684
+ frontmatter,
3488
3685
  body,
3489
3686
  hasFrontmatter
3490
3687
  };
@@ -3533,17 +3730,34 @@ function repairFrontmatterLine(line) {
3533
3730
  };
3534
3731
  }
3535
3732
  /**
3536
- * Quote the unquoted scalars that make a frontmatter block unparseable, or
3537
- * return `undefined` when there is nothing to repair. Only the frontmatter
3538
- * block is rewritten; the body is passed through untouched.
3733
+ * Locate a raw `---`-delimited frontmatter block's bounds within `content`,
3734
+ * without parsing it. Shared by the size guard in {@link parseFrontmatter} and
3735
+ * the YAML repair pass below, so both agree on exactly what gray-matter would
3736
+ * treat as the block: gray-matter ends it at the first `\n---`, with no
3737
+ * requirement that the delimiter be alone on its line, so a stricter pattern
3738
+ * here would run past gray-matter's delimiter and act on text that is really
3739
+ * the body.
3539
3740
  */
3540
- function repairMalformedFrontmatterYaml(content) {
3741
+ function findFrontmatterBlockBounds(content) {
3541
3742
  const opening = /^\uFEFF?---[^\S\r\n]*\r?\n/.exec(content);
3542
3743
  if (!opening) return;
3543
3744
  const blockStart = opening[0].length;
3544
3745
  const closing = /\r?\n---/.exec(content.slice(blockStart));
3545
3746
  if (!closing) return;
3546
- const blockEnd = blockStart + closing.index;
3747
+ return {
3748
+ blockStart,
3749
+ blockEnd: blockStart + closing.index
3750
+ };
3751
+ }
3752
+ /**
3753
+ * Quote the unquoted scalars that make a frontmatter block unparseable, or
3754
+ * return `undefined` when there is nothing to repair. Only the frontmatter
3755
+ * block is rewritten; the body is passed through untouched.
3756
+ */
3757
+ function repairMalformedFrontmatterYaml(content) {
3758
+ const bounds = findFrontmatterBlockBounds(content);
3759
+ if (!bounds) return;
3760
+ const { blockStart, blockEnd } = bounds;
3547
3761
  const block = content.slice(blockStart, blockEnd);
3548
3762
  const repairedLines = block.split("\n").map(repairFrontmatterLine);
3549
3763
  const repairedBlock = repairedLines.map(({ line }) => line).join("\n");
@@ -5222,42 +5436,6 @@ const CANONICAL_TO_GROKCLI_EVENT_NAMES = {
5222
5436
  */
5223
5437
  const GROKCLI_TO_CANONICAL_EVENT_NAMES = Object.fromEntries(Object.entries(CANONICAL_TO_GROKCLI_EVENT_NAMES).map(([k, v]) => [v, k]));
5224
5438
  //#endregion
5225
- //#region src/utils/prototype-pollution.ts
5226
- /**
5227
- * Keys that, if walked into when constructing or merging objects from
5228
- * untrusted input, can mutate `Object.prototype` (or otherwise the prototype
5229
- * chain) and propagate state to every other object in the runtime. Any code
5230
- * that copies arbitrary user-supplied keys into a fresh object — frontmatter
5231
- * parsing, MCP config conversion, settings round-trip — should skip these.
5232
- */
5233
- const PROTOTYPE_POLLUTION_KEYS = /* @__PURE__ */ new Set([
5234
- "__proto__",
5235
- "constructor",
5236
- "prototype"
5237
- ]);
5238
- function isPrototypePollutionKey(key) {
5239
- return PROTOTYPE_POLLUTION_KEYS.has(key);
5240
- }
5241
- /**
5242
- * Returns a shallow copy of a record's own entries with every
5243
- * prototype-pollution key (`__proto__`, `constructor`, `prototype`) dropped.
5244
- *
5245
- * Use when copying a nested, user-supplied string map — an MCP server's `env`
5246
- * or `headers` table — into freshly generated config. Carrying such a map by
5247
- * reference, or re-assigning its keys via bracket notation, would let a literal
5248
- * `__proto__` key ride along (and re-assigning it would mutate the target's
5249
- * prototype). Walking the entries through this helper severs that path while
5250
- * preserving every legitimate key.
5251
- */
5252
- function omitPrototypePollutionKeys(record) {
5253
- const sanitized = {};
5254
- for (const [key, value] of Object.entries(record)) {
5255
- if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
5256
- sanitized[key] = value;
5257
- }
5258
- return sanitized;
5259
- }
5260
- //#endregion
5261
5439
  //#region src/utils/jsonc.ts
5262
5440
  /**
5263
5441
  * Rebuild the parsed value from its own enumerable entries, dropping
@@ -6008,6 +6186,17 @@ var RulesyncMcp = class RulesyncMcp extends RulesyncFile {
6008
6186
  const fallbackDirPath = overrideDirPath ?? paths.recommended.relativeDirPath;
6009
6187
  throw new RulesyncSourceNotFoundError(`No ${(0, node_path.join)(outputRoot, fallbackDirPath, paths.recommended.relativeFilePath)} found.`);
6010
6188
  }
6189
+ /**
6190
+ * Return one server exactly as authored, before `getMcpServers()` strips
6191
+ * rulesync- and tool-specific fields. Keep this lookup here so every target
6192
+ * that re-merges one of those fields shares the same own-property and
6193
+ * prototype-pollution guards.
6194
+ */
6195
+ getRawMcpServer(name) {
6196
+ if (isPrototypePollutionKey(name)) return void 0;
6197
+ const mcpServers = isRecord$1(this.json) ? this.json.mcpServers : void 0;
6198
+ return isRecord$1(mcpServers) && Object.hasOwn(mcpServers, name) ? mcpServers[name] : void 0;
6199
+ }
6011
6200
  getMcpServers() {
6012
6201
  const mcpServers = this.json.mcpServers ?? {};
6013
6202
  const entries = Object.entries(mcpServers);
@@ -7634,6 +7823,11 @@ const RulesyncRuleFrontmatterSchema = zod_mini.z.object({
7634
7823
  globs: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
7635
7824
  agentsmd: zod_mini.z.optional(zod_mini.z.looseObject({ subprojectPath: zod_mini.z.optional(zod_mini.z.string()) })),
7636
7825
  claudecode: zod_mini.z.optional(zod_mini.z.looseObject({ paths: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) })),
7826
+ codebuddy: zod_mini.z.optional(zod_mini.z.looseObject({
7827
+ paths: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
7828
+ alwaysApply: zod_mini.z.optional(zod_mini.z.boolean()),
7829
+ description: zod_mini.z.optional(zod_mini.z.string())
7830
+ })),
7637
7831
  cursor: zod_mini.z.optional(zod_mini.z.looseObject({
7638
7832
  alwaysApply: zod_mini.z.optional(zod_mini.z.boolean()),
7639
7833
  description: zod_mini.z.optional(zod_mini.z.string()),
@@ -7675,7 +7869,8 @@ const RulesyncRuleFrontmatterSchema = zod_mini.z.object({
7675
7869
  name: zod_mini.z.optional(zod_mini.z.string()),
7676
7870
  extends: zod_mini.z.optional(zod_mini.z.string()),
7677
7871
  facet: zod_mini.z.optional(zod_mini.z.enum(["policies", "output-contracts"]))
7678
- }))
7872
+ })),
7873
+ factorydroid: zod_mini.z.optional(zod_mini.z.looseObject({ channel: zod_mini.z.optional(zod_mini.z.enum(["design"])) }))
7679
7874
  });
7680
7875
  /**
7681
7876
  * The `agentsmd.subprojectPath` every consumer should act on, resolved once so
@@ -8790,15 +8985,15 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8790
8985
  })),
8791
8986
  opencode: zod_mini.z.optional(zod_mini.z.looseObject({
8792
8987
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8793
- license: zod_mini.z.optional(zod_mini.z.string()),
8794
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8795
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
8988
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
8989
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
8990
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
8796
8991
  })),
8797
8992
  kilo: zod_mini.z.optional(zod_mini.z.looseObject({
8798
8993
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8799
- license: zod_mini.z.optional(zod_mini.z.string()),
8800
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8801
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
8994
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
8995
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
8996
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
8802
8997
  })),
8803
8998
  kiro: zod_mini.z.optional(zod_mini.z.looseObject({
8804
8999
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -8807,9 +9002,9 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8807
9002
  })),
8808
9003
  deepagents: zod_mini.z.optional(zod_mini.z.looseObject({
8809
9004
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
8810
- license: zod_mini.z.optional(zod_mini.z.string()),
8811
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
8812
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
9005
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
9006
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
9007
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
8813
9008
  })),
8814
9009
  copilot: zod_mini.z.optional(zod_mini.z.looseObject({
8815
9010
  license: zod_mini.z.optional(zod_mini.z.string()),
@@ -8916,6 +9111,13 @@ const RulesyncSkillFrontmatterSchema = zod_mini.z.looseObject({
8916
9111
  takt: zod_mini.z.optional(zod_mini.z.looseObject({
8917
9112
  name: zod_mini.z.optional(zod_mini.z.string()),
8918
9113
  extends: zod_mini.z.optional(zod_mini.z.string())
9114
+ })),
9115
+ crush: zod_mini.z.optional(zod_mini.z.looseObject({
9116
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
9117
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
9118
+ license: zod_mini.z.optional(zod_mini.z.string()),
9119
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
9120
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
8919
9121
  }))
8920
9122
  });
8921
9123
  /**
@@ -9141,7 +9343,7 @@ async function getLocalSkillDirNames(sourceTree) {
9141
9343
  *
9142
9344
  * The rulesync skill frontmatter exposes a root-level `disable-model-invocation`
9143
9345
  * default that applies to every tool supporting the flag (claudecode, copilot,
9144
- * copilotcli, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
9346
+ * copilotcli, crush, cursor, zed, pi, qwencode, grokcli, factorydroid). Each tool's own section may override that
9145
9347
  * default with a per-target value. A defined section value (including `false`)
9146
9348
  * always wins over the root default.
9147
9349
  *
@@ -9160,7 +9362,7 @@ function resolveDisableModelInvocation({ rootFrontmatter, section }) {
9160
9362
  *
9161
9363
  * The rulesync skill frontmatter exposes a root-level `user-invocable` default
9162
9364
  * that applies to every tool supporting the flag (claudecode, copilot,
9163
- * copilotcli, cursor, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
9365
+ * copilotcli, crush, cursor, qwencode, vibe, grokcli, factorydroid). Each tool's own section may override that default with a
9164
9366
  * per-target value. A defined section value (including `false`) always wins
9165
9367
  * over the root default.
9166
9368
  *
@@ -9625,8 +9827,8 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
9625
9827
  * This only deletes files that are no longer in the rulesync source, not files that will be overwritten.
9626
9828
  */
9627
9829
  async removeOrphanAiFiles(existingFiles, generatedFiles) {
9628
- const generatedPaths = new Set(generatedFiles.map((f) => f.getFilePath()));
9629
- const orphanFiles = existingFiles.filter((f) => !generatedPaths.has(f.getFilePath()));
9830
+ const generatedPaths = new Set(generatedFiles.map((f) => caseFoldIdentity(f.getFilePath())));
9831
+ const orphanFiles = existingFiles.filter((f) => !generatedPaths.has(caseFoldIdentity(f.getFilePath())));
9630
9832
  for (const aiFile of orphanFiles) {
9631
9833
  const filePath = aiFile.getFilePath();
9632
9834
  const loggedPath = stripControlCharacters(filePath);
@@ -10753,6 +10955,15 @@ const FACTORYDROID_COMMANDS_DIR_PATH = (0, node_path.join)(FACTORYDROID_DIR, "co
10753
10955
  const FACTORYDROID_SKILLS_DIR_PATH = (0, node_path.join)(FACTORYDROID_DIR, "skills");
10754
10956
  const FACTORYDROID_DROIDS_DIR_PATH = (0, node_path.join)(FACTORYDROID_DIR, "droids");
10755
10957
  const FACTORYDROID_RULE_FILE_NAME = "AGENTS.md";
10958
+ /**
10959
+ * Factory Droid's design-guidelines instruction file: "Always-on design-system,
10960
+ * UX, visual, and interaction guidance", loaded separately from `AGENTS.md`'s
10961
+ * coding guidelines. Project scope only — Factory's docs describe root and
10962
+ * nested `DESIGN.md` files like `AGENTS.md`, but document no personal/global
10963
+ * home-directory equivalent.
10964
+ * @see https://docs.factory.ai/cli/configuration/agents-md
10965
+ */
10966
+ const FACTORYDROID_DESIGN_FILE_NAME = "DESIGN.md";
10756
10967
  const FACTORYDROID_MCP_FILE_NAME = "mcp.json";
10757
10968
  const FACTORYDROID_SETTINGS_FILE_NAME = "settings.json";
10758
10969
  const FACTORYDROID_HOOKS_FILE_NAME = "hooks.json";
@@ -11275,6 +11486,21 @@ function stripStrings(_key, value) {
11275
11486
  //#endregion
11276
11487
  //#region src/features/shared/shared-config-gateway.ts
11277
11488
  /**
11489
+ * Upper bound on the number of values a shared config document may expand
11490
+ * to once every YAML alias is written out. Real config files hold a few
11491
+ * hundred values at most; even a large MCP server catalog stays orders of
11492
+ * magnitude below the limit.
11493
+ */
11494
+ const MAX_SHARED_CONFIG_VALUES = 1e5;
11495
+ /**
11496
+ * Upper bound on the total character count of the string leaves and keys a
11497
+ * shared config document may expand to. The value budget bounds how many
11498
+ * values are visited, but one long string aliased thousands of times fits
11499
+ * that budget while the duplicated output balloons; charging every visited
11500
+ * string's length separately bounds the output regardless of alias count.
11501
+ */
11502
+ const MAX_SHARED_CONFIG_STRING_CHARS = 4e6;
11503
+ /**
11278
11504
  * Rebuild a parsed document without its prototype-pollution keys.
11279
11505
  *
11280
11506
  * Every object is rebuilt, not just the ones that are already plain: a literal
@@ -11289,15 +11515,61 @@ function stripStrings(_key, value) {
11289
11515
  *
11290
11516
  * Dates are the one object the YAML and TOML parsers produce that is not a
11291
11517
  * mapping, so they are passed through rather than flattened into `{}`.
11518
+ *
11519
+ * The rebuild is bounded, because a YAML alias makes one parsed container
11520
+ * reachable from many keys and every alias is copied out independently (the
11521
+ * writers dump with `noRefs: true`, so memoizing here would only move the
11522
+ * blowup into serialization). A small "alias bomb" of nested anchors would
11523
+ * otherwise cost exponential time and memory, and a self-referencing anchor
11524
+ * would recurse until the stack overflowed — both reachable from a config
11525
+ * file committed to a cloned repository. The walk therefore charges every
11526
+ * value against {@link MAX_SHARED_CONFIG_VALUES}, every string and key
11527
+ * against {@link MAX_SHARED_CONFIG_STRING_CHARS}, caps nesting at
11528
+ * {@link MAX_SHARED_CONFIG_DEPTH}, and refuses a reference back to an
11529
+ * ancestor outright, each with a clear error instead of a hang or a crash.
11292
11530
  */
11293
11531
  function sanitizeSharedConfigValue(value) {
11294
- if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
11532
+ return sanitizeSharedConfigValueBounded(value, createBoundedWalk({
11533
+ subject: "Shared config",
11534
+ limits: {
11535
+ maxValues: MAX_SHARED_CONFIG_VALUES,
11536
+ maxStringChars: MAX_SHARED_CONFIG_STRING_CHARS,
11537
+ maxDepth: 64
11538
+ }
11539
+ }));
11540
+ }
11541
+ /**
11542
+ * Refuse a container that is already on the descent path. Unlike the
11543
+ * frontmatter cleaner, which drops such a cycle and keeps the rest of the
11544
+ * document, a shared config file is refused outright: silently dropping part
11545
+ * of a user's settings file would let a later write-back persist the loss.
11546
+ */
11547
+ function enterSharedConfigContainer(walk, container) {
11548
+ if (walk.isAncestor(container)) throw new Error("Shared config contains a value that refers back to itself (a circular YAML alias); refusing to process it");
11549
+ walk.enter(container);
11550
+ }
11551
+ function sanitizeSharedConfigValueBounded(value, walk) {
11552
+ if (typeof value === "string") {
11553
+ walk.chargeValue(value.length);
11554
+ return value;
11555
+ }
11556
+ walk.chargeValue();
11557
+ if (Array.isArray(value)) {
11558
+ enterSharedConfigContainer(walk, value);
11559
+ const items = value.map((item) => sanitizeSharedConfigValueBounded(item, walk));
11560
+ walk.leave(value);
11561
+ return items;
11562
+ }
11295
11563
  if (value === null || typeof value !== "object" || value instanceof Date) return value;
11564
+ enterSharedConfigContainer(walk, value);
11296
11565
  const result = {};
11297
11566
  for (const [key, nested] of Object.entries(value)) {
11567
+ walk.chargeChars(key.length);
11568
+ const sanitized = sanitizeSharedConfigValueBounded(nested, walk);
11298
11569
  if (isPrototypePollutionKey(key)) continue;
11299
- result[key] = sanitizeSharedConfigValue(nested);
11570
+ result[key] = sanitized;
11300
11571
  }
11572
+ walk.leave(value);
11301
11573
  return result;
11302
11574
  }
11303
11575
  /**
@@ -11326,7 +11598,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
11326
11598
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11327
11599
  }
11328
11600
  if (parsed === void 0 || parsed === null) return {};
11329
- const sanitized = sanitizeSharedConfigValue(parsed);
11601
+ let sanitized;
11602
+ try {
11603
+ sanitized = sanitizeSharedConfigValue(parsed);
11604
+ } catch (error) {
11605
+ throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11606
+ }
11330
11607
  if (!isPlainObject$1(sanitized)) {
11331
11608
  if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
11332
11609
  return {};
@@ -15652,6 +15929,10 @@ function toAllowedToolsArray(value) {
15652
15929
  * The spec types `compatibility` as a free-form string. An object from a legacy
15653
15930
  * rulesync input is flattened to `key: value` pairs instead of being emitted as
15654
15931
  * a YAML mapping, which conformant clients reject.
15932
+ *
15933
+ * Exported for `CrushSkill`, which requires the same bare-string shape (Crush's
15934
+ * Go struct types `Compatibility` as a plain `string`) and reuses this
15935
+ * implementation rather than maintaining a second, divergent copy.
15655
15936
  */
15656
15937
  function toCompatibilityString(value) {
15657
15938
  if (typeof value === "string") return value;
@@ -15660,6 +15941,10 @@ function toCompatibilityString(value) {
15660
15941
  /**
15661
15942
  * The spec types `metadata` as "a map from string keys to string values", so
15662
15943
  * non-string values (e.g. a YAML number `version: 1`) are stringified.
15944
+ *
15945
+ * Exported for `CrushSkill`, which requires the same `map[string]string`
15946
+ * shape (Crush's Go struct types `Metadata` that way) and reuses this
15947
+ * implementation rather than maintaining a second, divergent copy.
15663
15948
  */
15664
15949
  function toStringMetadata(metadata) {
15665
15950
  return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [key, stringifyValue(value)]));
@@ -18670,7 +18955,13 @@ var CommandsProcessor = class extends FeatureProcessor {
18670
18955
  if (!matchByBasename || flatOnly && (0, node_path.dirname)(key) !== ".") return [key];
18671
18956
  return [key, (0, node_path.basename)(key)];
18672
18957
  };
18673
- const seen = new Set(toolCommands.flatMap((command) => keysOf(command)));
18958
+ const claimedKeys = new ClaimedIdentities();
18959
+ const primarySource = paths.relativeDirPath;
18960
+ const secondarySource = "a secondary source";
18961
+ for (const command of toolCommands) for (const candidate of keysOf(command)) claimedKeys.claim({
18962
+ identity: candidate,
18963
+ source: primarySource
18964
+ });
18674
18965
  const additionalCommands = await factory.class.loadAdditionalImportFiles({
18675
18966
  outputRoot: this.outputRoot,
18676
18967
  global: this.global,
@@ -18678,11 +18969,26 @@ var CommandsProcessor = class extends FeatureProcessor {
18678
18969
  });
18679
18970
  for (const command of additionalCommands) {
18680
18971
  const key = command.getRelativeFilePath();
18681
- if (keysOf(command, true).some((candidate) => seen.has(candidate))) {
18682
- this.logger.warn(`Duplicate ${this.toolTarget} command "${key}" from a secondary source; keeping the one already loaded.`);
18972
+ const collision = [...new Set(keysOf(command, true))].map((candidate) => {
18973
+ const claimed = claimedKeys.claim({
18974
+ identity: candidate,
18975
+ source: secondarySource
18976
+ });
18977
+ return claimed === null ? void 0 : {
18978
+ candidate,
18979
+ claimed
18980
+ };
18981
+ }).find((hit) => hit !== void 0);
18982
+ if (collision) {
18983
+ const { candidate, claimed } = collision;
18984
+ if (claimed.spelling === candidate) this.logger.warn(`Duplicate ${this.toolTarget} command "${stripControlCharacters(key)}" from ${secondarySource}; keeping the one already loaded.`);
18985
+ else this.logger.warn(`Case-insensitive ${this.toolTarget} command collision: "${stripControlCharacters(claimed.spelling)}" and "${stripControlCharacters(candidate)}" resolve to the same command file. Keeping "${stripControlCharacters(claimed.spelling)}" from ${claimed.source === secondarySource ? "earlier in the same source" : `the higher-precedence ${claimed.source}`} and ignoring "${stripControlCharacters(key)}" from ${secondarySource}, which is not imported.`);
18683
18986
  continue;
18684
18987
  }
18685
- for (const candidate of keysOf(command)) seen.add(candidate);
18988
+ for (const candidate of keysOf(command)) claimedKeys.claim({
18989
+ identity: candidate,
18990
+ source: secondarySource
18991
+ });
18686
18992
  toolCommands.push(command);
18687
18993
  }
18688
18994
  }
@@ -18972,6 +19278,21 @@ var AmpHooks = class AmpHooks extends ToolHooks {
18972
19278
  }
18973
19279
  };
18974
19280
  //#endregion
19281
+ //#region src/utils/own-lookup.ts
19282
+ /**
19283
+ * Read a key from a plain string map without walking its prototype chain.
19284
+ *
19285
+ * A bracket read on an object literal resolves inherited members too, so a
19286
+ * user-supplied key such as `toString` or `constructor` "succeeds" with an
19287
+ * `Object.prototype` function instead of falling through to the caller's
19288
+ * `?? fallback`. Hook adapters translate native event names this way from
19289
+ * `Object.entries()` over a config file, so route the read through here to keep
19290
+ * the fallback honest: only a key the map itself defines yields a value.
19291
+ */
19292
+ function lookupOwn({ record, key }) {
19293
+ return Object.hasOwn(record, key) ? record[key] : void 0;
19294
+ }
19295
+ //#endregion
18975
19296
  //#region src/utils/object.ts
18976
19297
  /**
18977
19298
  * Return a shallow copy of `obj` keeping only the entries whose value is
@@ -19356,7 +19677,10 @@ function canonicalToToolHooks({ config, toolOverrideHooks, converterConfig, logg
19356
19677
  const warn = warnOnce(logger);
19357
19678
  const result = {};
19358
19679
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
19359
- const toolEventName = converterConfig.canonicalToToolEventNames[eventName] ?? eventName;
19680
+ const toolEventName = lookupOwn({
19681
+ record: converterConfig.canonicalToToolEventNames,
19682
+ key: eventName
19683
+ }) ?? eventName;
19360
19684
  const byMatcher = groupDefinitionsByMatcher({
19361
19685
  definitions,
19362
19686
  converterConfig
@@ -19784,7 +20108,10 @@ function toolHooksToCanonical({ hooks, converterConfig, logger }) {
19784
20108
  const warn = warnOnce(logger);
19785
20109
  const canonical = {};
19786
20110
  for (const [toolEventName, matcherEntries] of Object.entries(hooks)) {
19787
- const eventName = converterConfig.toolToCanonicalEventNames[toolEventName] ?? toolEventName;
20111
+ const eventName = lookupOwn({
20112
+ record: converterConfig.toolToCanonicalEventNames,
20113
+ key: toolEventName
20114
+ }) ?? toolEventName;
19788
20115
  if (!Array.isArray(matcherEntries)) continue;
19789
20116
  const defs = [];
19790
20117
  for (const rawEntry of matcherEntries) {
@@ -19840,7 +20167,10 @@ function flattenAntigravityHooks(parsed) {
19840
20167
  const flat = {};
19841
20168
  const addEvent = (event, entries) => {
19842
20169
  if (isPrototypePollutionKey(event) || !Array.isArray(entries)) return;
19843
- const existing = Object.hasOwn(flat, event) ? flat[event] : void 0;
20170
+ const existing = lookupOwn({
20171
+ record: flat,
20172
+ key: event
20173
+ });
19844
20174
  flat[event] = existing ? [...existing, ...entries] : [...entries];
19845
20175
  };
19846
20176
  for (const [key, value] of Object.entries(parsed)) if (Array.isArray(value)) addEvent(key, value);
@@ -20227,6 +20557,16 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
20227
20557
  const paths = AugmentcodeHooks.getSettablePaths({ global });
20228
20558
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
20229
20559
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
20560
+ let existingHooks = {};
20561
+ try {
20562
+ const parsed = JSON.parse(existingContent);
20563
+ const candidate = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.hooks : void 0;
20564
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) existingHooks = candidate;
20565
+ } catch {
20566
+ existingHooks = {};
20567
+ }
20568
+ const nativeEventKeys = new Set(Object.values(CANONICAL_TO_AUGMENTCODE_EVENT_NAMES));
20569
+ const preservedHooks = Object.fromEntries(Object.entries(existingHooks).filter(([key]) => !nativeEventKeys.has(key)));
20230
20570
  const config = rulesyncHooks.getJson();
20231
20571
  const augmentHooks = canonicalToToolHooks({
20232
20572
  config,
@@ -20238,7 +20578,10 @@ var AugmentcodeHooks = class AugmentcodeHooks extends ToolHooks {
20238
20578
  fileKey: sharedConfigFileKey(paths),
20239
20579
  feature: "hooks",
20240
20580
  existingContent,
20241
- patch: { hooks: augmentHooks },
20581
+ patch: { hooks: {
20582
+ ...preservedHooks,
20583
+ ...augmentHooks
20584
+ } },
20242
20585
  filePath
20243
20586
  });
20244
20587
  return new AugmentcodeHooks({
@@ -20996,7 +21339,10 @@ function canonicalToCopilotHooks(config) {
20996
21339
  };
20997
21340
  const copilot = {};
20998
21341
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
20999
- const copilotEventName = CANONICAL_TO_COPILOT_EVENT_NAMES[eventName] ?? eventName;
21342
+ const copilotEventName = lookupOwn({
21343
+ record: CANONICAL_TO_COPILOT_EVENT_NAMES,
21344
+ key: eventName
21345
+ }) ?? eventName;
21000
21346
  const entries = [];
21001
21347
  for (const def of definitions) {
21002
21348
  const hookType = def.type ?? "command";
@@ -21073,7 +21419,10 @@ function copilotHooksToCanonical(copilotHooks, logger) {
21073
21419
  if (copilotHooks === null || copilotHooks === void 0 || typeof copilotHooks !== "object") return {};
21074
21420
  const canonical = {};
21075
21421
  for (const [copilotEventName, hookEntries] of Object.entries(copilotHooks)) {
21076
- const eventName = COPILOT_TO_CANONICAL_EVENT_NAMES[copilotEventName] ?? copilotEventName;
21422
+ const eventName = lookupOwn({
21423
+ record: COPILOT_TO_CANONICAL_EVENT_NAMES,
21424
+ key: copilotEventName
21425
+ }) ?? copilotEventName;
21077
21426
  if (!Array.isArray(hookEntries)) continue;
21078
21427
  const defs = [];
21079
21428
  for (const rawEntry of hookEntries) {
@@ -21343,7 +21692,10 @@ function canonicalToCopilotCliHooks(config, logger) {
21343
21692
  };
21344
21693
  const out = {};
21345
21694
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
21346
- const copilotEventName = CANONICAL_TO_COPILOTCLI_EVENT_NAMES[eventName] ?? eventName;
21695
+ const copilotEventName = lookupOwn({
21696
+ record: CANONICAL_TO_COPILOTCLI_EVENT_NAMES,
21697
+ key: eventName
21698
+ }) ?? eventName;
21347
21699
  const entries = buildCopilotCliEntriesForEvent({
21348
21700
  eventName,
21349
21701
  definitions,
@@ -21400,7 +21752,10 @@ function copilotCliHooksToCanonical(rawHooks, logger) {
21400
21752
  if (rawHooks === null || rawHooks === void 0 || typeof rawHooks !== "object") return {};
21401
21753
  const canonical = {};
21402
21754
  for (const [copilotEventName, hookEntries] of Object.entries(rawHooks)) {
21403
- const eventName = COPILOTCLI_TO_CANONICAL_EVENT_NAMES[copilotEventName] ?? copilotEventName;
21755
+ const eventName = lookupOwn({
21756
+ record: COPILOTCLI_TO_CANONICAL_EVENT_NAMES,
21757
+ key: copilotEventName
21758
+ }) ?? copilotEventName;
21404
21759
  if (!Array.isArray(hookEntries)) continue;
21405
21760
  const defs = [];
21406
21761
  for (const rawEntry of hookEntries) {
@@ -21552,7 +21907,10 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21552
21907
  const mappedHooks = {};
21553
21908
  const cursorSupportedTypes = /* @__PURE__ */ new Set(["command", "prompt"]);
21554
21909
  for (const [eventName, defs] of Object.entries(mergedHooks)) {
21555
- const cursorEventName = CANONICAL_TO_CURSOR_EVENT_NAMES[eventName] ?? eventName;
21910
+ const cursorEventName = lookupOwn({
21911
+ record: CANONICAL_TO_CURSOR_EVENT_NAMES,
21912
+ key: eventName
21913
+ }) ?? eventName;
21556
21914
  const mappedDefs = defs.filter((def) => cursorSupportedTypes.has(def.type ?? "command")).map((def) => ({
21557
21915
  ...def.type !== void 0 && def.type !== null && { type: def.type },
21558
21916
  ...def.command !== void 0 && def.command !== null && { command: def.command },
@@ -21585,7 +21943,10 @@ var CursorHooks = class CursorHooks extends ToolHooks {
21585
21943
  const cursorHooks = parsed.hooks ?? {};
21586
21944
  const canonicalHooks = {};
21587
21945
  for (const [cursorEventName, defs] of Object.entries(cursorHooks)) {
21588
- const eventName = CURSOR_TO_CANONICAL_EVENT_NAMES[cursorEventName] ?? cursorEventName;
21946
+ const eventName = lookupOwn({
21947
+ record: CURSOR_TO_CANONICAL_EVENT_NAMES,
21948
+ key: cursorEventName
21949
+ }) ?? cursorEventName;
21589
21950
  canonicalHooks[eventName] = defs;
21590
21951
  }
21591
21952
  const version = parsed.version ?? 1;
@@ -21655,7 +22016,10 @@ function canonicalToDeepagentsHooks(config) {
21655
22016
  const hooks = {};
21656
22017
  for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) {
21657
22018
  if (!supported.has(canonicalEvent)) continue;
21658
- const deepagentsEvent = CANONICAL_TO_DEEPAGENTS_EVENT_NAMES[canonicalEvent];
22019
+ const deepagentsEvent = lookupOwn({
22020
+ record: CANONICAL_TO_DEEPAGENTS_EVENT_NAMES,
22021
+ key: canonicalEvent
22022
+ });
21659
22023
  if (!deepagentsEvent) continue;
21660
22024
  for (const def of definitions) {
21661
22025
  if ((def.type ?? "command") !== "command") continue;
@@ -21684,7 +22048,10 @@ function canonicalToDeepagentsHooks(config) {
21684
22048
  function deepagentsToCanonicalHooks(hooks) {
21685
22049
  const canonical = {};
21686
22050
  for (const [deepagentsEvent, groups] of Object.entries(hooks)) {
21687
- const canonicalEvent = DEEPAGENTS_TO_CANONICAL_EVENT_NAMES[deepagentsEvent];
22051
+ const canonicalEvent = lookupOwn({
22052
+ record: DEEPAGENTS_TO_CANONICAL_EVENT_NAMES,
22053
+ key: deepagentsEvent
22054
+ });
21688
22055
  if (!canonicalEvent || !Array.isArray(groups)) continue;
21689
22056
  for (const group of groups) {
21690
22057
  if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
@@ -21717,7 +22084,10 @@ function deepagentsLegacyToCanonicalHooks(entries) {
21717
22084
  const command = argv.length === 3 && argv[0] === "bash" && argv[1] === "-c" ? String(argv[2] ?? "") : argv.join(" ");
21718
22085
  const events = Array.isArray(entry.events) ? entry.events : [];
21719
22086
  for (const legacyEvent of events) {
21720
- const canonicalEvent = typeof legacyEvent === "string" ? DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES[legacyEvent] : void 0;
22087
+ const canonicalEvent = typeof legacyEvent === "string" ? lookupOwn({
22088
+ record: DEEPAGENTS_LEGACY_TO_CANONICAL_EVENT_NAMES,
22089
+ key: legacyEvent
22090
+ }) : void 0;
21721
22091
  if (!canonicalEvent) continue;
21722
22092
  (canonical[canonicalEvent] ??= []).push({
21723
22093
  type: "command",
@@ -22438,7 +22808,10 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
22438
22808
  const result = {};
22439
22809
  for (const [canonicalEvent, definitions] of Object.entries(config.hooks)) {
22440
22810
  if (!HERMESAGENT_CANONICAL_EVENTS.has(canonicalEvent)) continue;
22441
- const nativeEvent = CANONICAL_TO_HERMESAGENT_EVENT_NAMES[canonicalEvent];
22811
+ const nativeEvent = lookupOwn({
22812
+ record: CANONICAL_TO_HERMESAGENT_EVENT_NAMES,
22813
+ key: canonicalEvent
22814
+ });
22442
22815
  if (nativeEvent) setHermesHookEntries({
22443
22816
  result,
22444
22817
  event: nativeEvent,
@@ -22449,7 +22822,10 @@ function canonicalToHermesHooks({ config, toolOverrideHooks, logger }) {
22449
22822
  }
22450
22823
  for (const [canonicalEvent, definitions] of Object.entries(toolOverrideHooks ?? {})) {
22451
22824
  if (!HERMESAGENT_CANONICAL_EVENTS.has(canonicalEvent)) continue;
22452
- const nativeEvent = CANONICAL_TO_HERMESAGENT_EVENT_NAMES[canonicalEvent];
22825
+ const nativeEvent = lookupOwn({
22826
+ record: CANONICAL_TO_HERMESAGENT_EVENT_NAMES,
22827
+ key: canonicalEvent
22828
+ });
22453
22829
  if (nativeEvent) setHermesHookEntries({
22454
22830
  result,
22455
22831
  event: nativeEvent,
@@ -22515,7 +22891,10 @@ function hermesHooksToCanonical(hooks) {
22515
22891
  for (const [nativeEvent, entries] of Object.entries(hooks)) {
22516
22892
  if (PROTOTYPE_POLLUTION_KEYS.has(nativeEvent) || !Array.isArray(entries)) continue;
22517
22893
  if (!isHermesHookEventEntry(nativeEvent, entries)) continue;
22518
- const rulesyncEvent = HERMESAGENT_TO_CANONICAL_EVENT_NAMES[nativeEvent] ?? nativeEvent;
22894
+ const rulesyncEvent = lookupOwn({
22895
+ record: HERMESAGENT_TO_CANONICAL_EVENT_NAMES,
22896
+ key: nativeEvent
22897
+ }) ?? nativeEvent;
22519
22898
  const defs = entries.map((raw) => hermesEntryToDefinition({
22520
22899
  nativeEvent,
22521
22900
  raw
@@ -23152,7 +23531,10 @@ function canonicalToKimiCodeHooks({ config, toolOverrideHooks, trustedDirectory,
23152
23531
  const result = [];
23153
23532
  const nativeEvents = new Set(KIMI_CODE_NATIVE_HOOK_EVENTS);
23154
23533
  for (const [event, definitions] of Object.entries(buildEffectiveHooks(config, toolOverrideHooks))) {
23155
- const nativeEvent = CANONICAL_TO_KIMI_CODE_EVENT_NAMES[event] ?? event;
23534
+ const nativeEvent = lookupOwn({
23535
+ record: CANONICAL_TO_KIMI_CODE_EVENT_NAMES,
23536
+ key: event
23537
+ }) ?? event;
23156
23538
  if (!nativeEvents.has(nativeEvent)) {
23157
23539
  logger?.warn(`Kimi Code hooks: skipping unsupported event "${event}".`);
23158
23540
  continue;
@@ -23186,14 +23568,22 @@ function kimiCodeHooksToCanonical(hooks) {
23186
23568
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
23187
23569
  const entry = raw;
23188
23570
  if (typeof entry.event !== "string" || typeof entry.command !== "string") continue;
23189
- const event = KIMI_CODE_TO_CANONICAL_EVENT_NAMES[entry.event] ?? entry.event;
23571
+ const event = lookupOwn({
23572
+ record: KIMI_CODE_TO_CANONICAL_EVENT_NAMES,
23573
+ key: entry.event
23574
+ }) ?? entry.event;
23190
23575
  const definition = {
23191
23576
  type: "command",
23192
23577
  command: stripTrustedDirectoryWrapper(entry.command),
23193
23578
  ...typeof entry.matcher === "string" && { matcher: entry.matcher },
23194
23579
  ...typeof entry.timeout === "number" && { timeout: entry.timeout }
23195
23580
  };
23196
- (result[event] ??= []).push(definition);
23581
+ const list = lookupOwn({
23582
+ record: result,
23583
+ key: event
23584
+ }) ?? [];
23585
+ list.push(definition);
23586
+ result[event] = list;
23197
23587
  }
23198
23588
  return result;
23199
23589
  }
@@ -23396,7 +23786,13 @@ function canonicalToKiroIdeHooks(config) {
23396
23786
  };
23397
23787
  const entries = [];
23398
23788
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
23399
- const trigger = CANONICAL_TO_KIRO_IDE_EVENT_NAMES[eventName] ?? KIRO_LEGACY_TO_KIRO_IDE_TRIGGER_NAMES[eventName] ?? eventName;
23789
+ const trigger = lookupOwn({
23790
+ record: CANONICAL_TO_KIRO_IDE_EVENT_NAMES,
23791
+ key: eventName
23792
+ }) ?? lookupOwn({
23793
+ record: KIRO_LEGACY_TO_KIRO_IDE_TRIGGER_NAMES,
23794
+ key: eventName
23795
+ }) ?? eventName;
23400
23796
  entries.push(...buildKiroIdeEntriesForEvent(trigger, definitions));
23401
23797
  }
23402
23798
  return entries;
@@ -23405,7 +23801,10 @@ function kiroIdeHooksToCanonical(entries) {
23405
23801
  const canonical = {};
23406
23802
  for (const entry of entries) {
23407
23803
  if (entry.trigger === void 0 || entry.action === void 0) continue;
23408
- const eventName = KIRO_IDE_TO_CANONICAL_EVENT_NAMES[entry.trigger] ?? entry.trigger;
23804
+ const eventName = lookupOwn({
23805
+ record: KIRO_IDE_TO_CANONICAL_EVENT_NAMES,
23806
+ key: entry.trigger
23807
+ }) ?? entry.trigger;
23409
23808
  if (isPrototypePollutionKey(eventName)) continue;
23410
23809
  const def = {};
23411
23810
  if (entry.action.type === "command") {
@@ -23422,7 +23821,12 @@ function kiroIdeHooksToCanonical(entries) {
23422
23821
  if (entry.matcher !== void 0 && entry.matcher !== null && entry.matcher !== "") def.matcher = entry.matcher;
23423
23822
  if (entry.timeout !== void 0 && entry.timeout !== null) def.timeout = entry.timeout;
23424
23823
  if (entry.enabled === false) def.enabled = false;
23425
- (canonical[eventName] ??= []).push(def);
23824
+ const list = lookupOwn({
23825
+ record: canonical,
23826
+ key: eventName
23827
+ }) ?? [];
23828
+ list.push(def);
23829
+ canonical[eventName] = list;
23426
23830
  }
23427
23831
  return canonical;
23428
23832
  }
@@ -23603,7 +24007,10 @@ function canonicalToKiroHooks({ config, logger }) {
23603
24007
  };
23604
24008
  const kiro = {};
23605
24009
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
23606
- const kiroEventName = CANONICAL_TO_KIRO_EVENT_NAMES[eventName] ?? eventName;
24010
+ const kiroEventName = lookupOwn({
24011
+ record: CANONICAL_TO_KIRO_EVENT_NAMES,
24012
+ key: eventName
24013
+ }) ?? eventName;
23607
24014
  const entries = buildKiroEntriesForEvent(definitions);
23608
24015
  if (entries.length > 0) if (kiro[kiroEventName]) kiro[kiroEventName].push(...entries);
23609
24016
  else kiro[kiroEventName] = entries;
@@ -23634,7 +24041,10 @@ function kiroHooksToCanonical(kiroHooks) {
23634
24041
  if (kiroHooks === null || kiroHooks === void 0 || typeof kiroHooks !== "object") return {};
23635
24042
  const canonical = {};
23636
24043
  for (const [kiroEventName, entries] of Object.entries(kiroHooks)) {
23637
- const eventName = KIRO_TO_CANONICAL_EVENT_NAMES[kiroEventName] ?? kiroEventName;
24044
+ const eventName = lookupOwn({
24045
+ record: KIRO_TO_CANONICAL_EVENT_NAMES,
24046
+ key: kiroEventName
24047
+ }) ?? kiroEventName;
23638
24048
  if (!Array.isArray(entries)) continue;
23639
24049
  const defs = [];
23640
24050
  for (const rawEntry of entries) {
@@ -24193,7 +24603,10 @@ function canonicalToQwencodeHooks(config, logger) {
24193
24603
  ]);
24194
24604
  const qwencode = {};
24195
24605
  for (const [eventName, definitions] of Object.entries(effectiveHooks)) {
24196
- const qwencodeEventName = CANONICAL_TO_QWENCODE_EVENT_NAMES[eventName] ?? eventName;
24606
+ const qwencodeEventName = lookupOwn({
24607
+ record: CANONICAL_TO_QWENCODE_EVENT_NAMES,
24608
+ key: eventName
24609
+ }) ?? eventName;
24197
24610
  const byMatcher = /* @__PURE__ */ new Map();
24198
24611
  for (const def of definitions) {
24199
24612
  if (!qwencodeSupportedTypes.has(def.type ?? "command")) continue;
@@ -24298,7 +24711,10 @@ function qwencodeHooksToCanonical(qwencodeHooks) {
24298
24711
  if (qwencodeHooks === null || qwencodeHooks === void 0 || typeof qwencodeHooks !== "object") return {};
24299
24712
  const canonical = {};
24300
24713
  for (const [qwencodeEventName, matcherEntries] of Object.entries(qwencodeHooks)) {
24301
- const eventName = QWENCODE_TO_CANONICAL_EVENT_NAMES[qwencodeEventName] ?? qwencodeEventName;
24714
+ const eventName = lookupOwn({
24715
+ record: QWENCODE_TO_CANONICAL_EVENT_NAMES,
24716
+ key: qwencodeEventName
24717
+ }) ?? qwencodeEventName;
24302
24718
  if (!Array.isArray(matcherEntries)) continue;
24303
24719
  const defs = [];
24304
24720
  for (const rawEntry of matcherEntries) {
@@ -24413,7 +24829,10 @@ function canonicalToReasonixHooks({ config, toolOverrideHooks, logger }) {
24413
24829
  const result = {};
24414
24830
  for (const [event, defs] of Object.entries(effectiveHooks)) {
24415
24831
  if (!SUPPORTED_REASONIX_EVENTS.has(event)) continue;
24416
- const reasonixEvent = CANONICAL_TO_REASONIX_EVENT_NAMES[event] ?? event;
24832
+ const reasonixEvent = lookupOwn({
24833
+ record: CANONICAL_TO_REASONIX_EVENT_NAMES,
24834
+ key: event
24835
+ }) ?? event;
24417
24836
  const isMatcherEvent = REASONIX_MATCHER_EVENTS.has(reasonixEvent);
24418
24837
  const entries = [];
24419
24838
  for (const def of defs) {
@@ -24426,7 +24845,10 @@ function canonicalToReasonixHooks({ config, toolOverrideHooks, logger }) {
24426
24845
  if (typeof def.timeout === "number") entry.timeout = Math.round(def.timeout * 1e3);
24427
24846
  entries.push(entry);
24428
24847
  }
24429
- if (entries.length > 0) result[reasonixEvent] = [...result[reasonixEvent] ?? [], ...entries];
24848
+ if (entries.length > 0) result[reasonixEvent] = [...lookupOwn({
24849
+ record: result,
24850
+ key: reasonixEvent
24851
+ }) ?? [], ...entries];
24430
24852
  }
24431
24853
  return result;
24432
24854
  }
@@ -24439,7 +24861,10 @@ function reasonixHooksToCanonical(hooks) {
24439
24861
  if (hooks === null || hooks === void 0 || typeof hooks !== "object" || Array.isArray(hooks)) return canonical;
24440
24862
  for (const [reasonixEvent, rawEntries] of Object.entries(hooks)) {
24441
24863
  if (!Array.isArray(rawEntries)) continue;
24442
- const canonicalEvent = REASONIX_TO_CANONICAL_EVENT_NAMES[reasonixEvent] ?? reasonixEvent;
24864
+ const canonicalEvent = lookupOwn({
24865
+ record: REASONIX_TO_CANONICAL_EVENT_NAMES,
24866
+ key: reasonixEvent
24867
+ }) ?? reasonixEvent;
24443
24868
  const defs = [];
24444
24869
  for (const rawEntry of rawEntries) {
24445
24870
  if (rawEntry === null || typeof rawEntry !== "object" || Array.isArray(rawEntry)) continue;
@@ -24454,7 +24879,10 @@ function reasonixHooksToCanonical(hooks) {
24454
24879
  if (typeof entry.timeout === "number") def.timeout = entry.timeout / 1e3;
24455
24880
  defs.push(def);
24456
24881
  }
24457
- if (defs.length > 0) canonical[canonicalEvent] = [...canonical[canonicalEvent] ?? [], ...defs];
24882
+ if (defs.length > 0) canonical[canonicalEvent] = [...lookupOwn({
24883
+ record: canonical,
24884
+ key: canonicalEvent
24885
+ }) ?? [], ...defs];
24458
24886
  }
24459
24887
  return canonical;
24460
24888
  }
@@ -24582,7 +25010,10 @@ function canonicalToVibeHooks(config, toolOverride) {
24582
25010
  const hooks = [];
24583
25011
  for (const [event, defs] of Object.entries(effective)) {
24584
25012
  if (!SUPPORTED_VIBE_EVENTS.has(event)) continue;
24585
- const vibeEvent = CANONICAL_TO_VIBE_EVENT_NAMES[event] ?? event;
25013
+ const vibeEvent = lookupOwn({
25014
+ record: CANONICAL_TO_VIBE_EVENT_NAMES,
25015
+ key: event
25016
+ }) ?? event;
24586
25017
  let index = 0;
24587
25018
  for (const def of defs) {
24588
25019
  if ((def.type ?? "command") !== "command") continue;
@@ -24612,7 +25043,10 @@ function vibeEntryToCanonicalDef(raw) {
24612
25043
  const vibeEvent = typeof entry.type === "string" ? entry.type : void 0;
24613
25044
  if (vibeEvent === void 0) return null;
24614
25045
  if (isPrototypePollutionKey(vibeEvent)) return null;
24615
- const canonicalEvent = VIBE_TO_CANONICAL_EVENT_NAMES[vibeEvent] ?? vibeEvent;
25046
+ const canonicalEvent = lookupOwn({
25047
+ record: VIBE_TO_CANONICAL_EVENT_NAMES,
25048
+ key: vibeEvent
25049
+ }) ?? vibeEvent;
24616
25050
  const def = { type: "command" };
24617
25051
  if (typeof entry.command === "string") def.command = entry.command;
24618
25052
  if (typeof entry.match === "string" && entry.match !== "" && entry.match !== "*") def.matcher = entry.match;
@@ -24637,7 +25071,10 @@ function vibeHooksToCanonical(parsed) {
24637
25071
  for (const raw of rawHooks) {
24638
25072
  const result = vibeEntryToCanonicalDef(raw);
24639
25073
  if (result === null) continue;
24640
- const list = canonical[result.canonicalEvent] ?? [];
25074
+ const list = lookupOwn({
25075
+ record: canonical,
25076
+ key: result.canonicalEvent
25077
+ }) ?? [];
24641
25078
  list.push(result.def);
24642
25079
  canonical[result.canonicalEvent] = list;
24643
25080
  }
@@ -25716,6 +26153,68 @@ var ClineIgnore = class ClineIgnore extends ToolIgnore {
25716
26153
  }
25717
26154
  };
25718
26155
  //#endregion
26156
+ //#region src/constants/crush-paths.ts
26157
+ const CRUSH_RULE_FILE_NAME = "CRUSH.md";
26158
+ const CRUSH_GLOBAL_DIR = (0, node_path.join)(".config", "crush");
26159
+ const CRUSH_IGNORE_FILE_NAME = ".crushignore";
26160
+ const CRUSH_SKILLS_PROJECT_DIR = (0, node_path.join)(".crush", "skills");
26161
+ const CRUSH_SKILLS_GLOBAL_DIR = (0, node_path.join)(CRUSH_GLOBAL_DIR, "skills");
26162
+ //#endregion
26163
+ //#region src/features/ignore/crush-ignore.ts
26164
+ /**
26165
+ * Ignore generator for Crush.
26166
+ *
26167
+ * Crush excludes files from tool access via a `.crushignore` file, read
26168
+ * hierarchically (root and any subdirectory, the same way it walks
26169
+ * `.gitignore`) using gitignore syntax. Crush documents no global/user-scope
26170
+ * ignore file, so this is project-only.
26171
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/fsext/fileutil.go
26172
+ */
26173
+ var CrushIgnore = class CrushIgnore extends ToolIgnore {
26174
+ static getSettablePaths() {
26175
+ return {
26176
+ relativeDirPath: ".",
26177
+ relativeFilePath: CRUSH_IGNORE_FILE_NAME
26178
+ };
26179
+ }
26180
+ toRulesyncIgnore() {
26181
+ return new RulesyncIgnore({
26182
+ outputRoot: ".",
26183
+ relativeDirPath: ".",
26184
+ relativeFilePath: RULESYNC_AIIGNORE_RELATIVE_FILE_PATH,
26185
+ fileContent: this.fileContent
26186
+ });
26187
+ }
26188
+ static fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore }) {
26189
+ const body = rulesyncIgnore.getFileContent();
26190
+ return new CrushIgnore({
26191
+ outputRoot,
26192
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
26193
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
26194
+ fileContent: body
26195
+ });
26196
+ }
26197
+ static async fromFile({ outputRoot = process.cwd(), validate = true }) {
26198
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, this.getSettablePaths().relativeDirPath, this.getSettablePaths().relativeFilePath));
26199
+ return new CrushIgnore({
26200
+ outputRoot,
26201
+ relativeDirPath: this.getSettablePaths().relativeDirPath,
26202
+ relativeFilePath: this.getSettablePaths().relativeFilePath,
26203
+ fileContent,
26204
+ validate
26205
+ });
26206
+ }
26207
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath }) {
26208
+ return new CrushIgnore({
26209
+ outputRoot,
26210
+ relativeDirPath,
26211
+ relativeFilePath,
26212
+ fileContent: "",
26213
+ validate: false
26214
+ });
26215
+ }
26216
+ };
26217
+ //#endregion
25719
26218
  //#region src/features/ignore/cursor-ignore.ts
25720
26219
  /**
25721
26220
  * Cursor ignore adapter.
@@ -26712,6 +27211,7 @@ const toolIgnoreFactories = /* @__PURE__ */ new Map([
26712
27211
  ["claudecode", { class: ClaudecodeIgnore }],
26713
27212
  ["claudecode-legacy", { class: ClaudecodeIgnore }],
26714
27213
  ["cline", { class: ClineIgnore }],
27214
+ ["crush", { class: CrushIgnore }],
26715
27215
  ["cursor", { class: CursorIgnore }],
26716
27216
  ["hermesagent", { class: HermesagentIgnore }],
26717
27217
  ["junie", { class: JunieIgnore }],
@@ -28016,9 +28516,8 @@ var CodexcliMcp = class CodexcliMcp extends ToolMcp {
28016
28516
  throw new Error(`Failed to parse existing Codex CLI config at ${configTomlFilePath}: ${formatError(error)}`, { cause: error });
28017
28517
  }
28018
28518
  const strippedMcpServers = rulesyncMcp.getMcpServers();
28019
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
28020
28519
  const converted = convertToCodexFormat(Object.fromEntries(Object.entries(strippedMcpServers).map(([serverName, serverConfig]) => {
28021
- const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
28520
+ const rawServer = rulesyncMcp.getRawMcpServer(serverName);
28022
28521
  return [serverName, {
28023
28522
  ...serverConfig,
28024
28523
  ...isRecord$1(rawServer) && isEnvVarEntryArray(rawServer.envVars) ? { envVars: rawServer.envVars } : {},
@@ -30981,9 +31480,8 @@ var MusecodeMcp = class MusecodeMcp extends ToolMcp {
30981
31480
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
30982
31481
  const existingContent = await readFileContentOrNull(filePath) ?? "";
30983
31482
  const existing = parseMusecodeSettings(existingContent, filePath);
30984
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
30985
31483
  const converted = convertToMusecodeFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => {
30986
- const rawServer = isRecord$1(rawMcpServers) ? rawMcpServers[serverName] : void 0;
31484
+ const rawServer = rulesyncMcp.getRawMcpServer(serverName);
30987
31485
  const mode = asMusecodeMode(isRecord$1(rawServer) ? rawServer.musecodeMode : void 0);
30988
31486
  return [serverName, {
30989
31487
  ...serverConfig,
@@ -31932,6 +32430,66 @@ async function readRovodevConfigYaml({ outputRoot }) {
31932
32430
  filePath: (0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)
31933
32431
  });
31934
32432
  }
32433
+ /**
32434
+ * Decide the absolute path a configured `mcpConfigPath` would name in the
32435
+ * given scope, without yet checking whether that path stays inside it. Split
32436
+ * out of resolveRovodevMcpImportPath so each function's branching stays
32437
+ * within the project's complexity budget.
32438
+ */
32439
+ function resolveMcpConfigCandidatePath({ outputRoot, global, normalizedPath, configuredPath }) {
32440
+ if (global && normalizedPath.startsWith("~/")) return { path: (0, node_path.resolve)(outputRoot, normalizedPath.slice(2)) };
32441
+ if (!global && normalizedPath.startsWith("~/")) return { rejectionMessage: `Rovo Dev MCP: mcp.mcpConfigPath is ${quoteValueForWarning(configuredPath)} in project scope. A home-anchored path cannot be imported as part of a project, so importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.` };
32442
+ if ((0, node_path.isAbsolute)(normalizedPath)) return { path: (0, node_path.resolve)(normalizedPath) };
32443
+ if (!global) return { path: (0, node_path.resolve)(outputRoot, normalizedPath) };
32444
+ return { rejectionMessage: `Rovo Dev MCP: mcp.mcpConfigPath is ${quoteValueForWarning(configuredPath)} in global scope. Only home-anchored or absolute paths can be imported safely, so importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.` };
32445
+ }
32446
+ /**
32447
+ * Resolve the active Rovo Dev MCP config without following a pointer outside
32448
+ * the import scope. The implementation is deliberately separate from
32449
+ * fromFile: it keeps path-policy decisions testable without changing the
32450
+ * public ToolMcp contract.
32451
+ */
32452
+ async function resolveRovodevMcpImportPath({ outputRoot, global, config, logger }) {
32453
+ const fallback = {
32454
+ filePath: (0, node_path.join)(outputRoot, ROVODEV_DIR, ROVODEV_MCP_FILE_NAME),
32455
+ relativeDirPath: ROVODEV_DIR,
32456
+ relativeFilePath: ROVODEV_MCP_FILE_NAME
32457
+ };
32458
+ const configuredPath = (config && isRecord$1(config.mcp) ? config.mcp : {}).mcpConfigPath;
32459
+ if (configuredPath === void 0) {
32460
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath is unset in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)}. Importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)}, which may not be the file Rovo Dev reads.`);
32461
+ return fallback;
32462
+ }
32463
+ if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
32464
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_CONFIG_FILE_NAME)} must be a non-empty string. Importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.`);
32465
+ return fallback;
32466
+ }
32467
+ const normalizedPath = normalizeMcpConfigPathValue(configuredPath.trim());
32468
+ const candidateResult = resolveMcpConfigCandidatePath({
32469
+ outputRoot,
32470
+ global,
32471
+ normalizedPath,
32472
+ configuredPath
32473
+ });
32474
+ if ("rejectionMessage" in candidateResult) {
32475
+ logger?.warn(candidateResult.rejectionMessage);
32476
+ return fallback;
32477
+ }
32478
+ const candidatePath = candidateResult.path;
32479
+ const relativePath = (0, node_path.relative)((0, node_path.resolve)(outputRoot), candidatePath);
32480
+ if (relativePath === "" || splitPathSegments(normalizedPath).includes("..") || pathEscapesRoot(relativePath) || await resolvedPathEscapesRoot({
32481
+ rootPath: outputRoot,
32482
+ targetPath: candidatePath
32483
+ })) {
32484
+ logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath is ${quoteValueForWarning(configuredPath)}, which is outside the import scope or traverses a symbolic link. Importing ${(0, node_path.join)(ROVODEV_DIR, ROVODEV_MCP_FILE_NAME)} instead.`);
32485
+ return fallback;
32486
+ }
32487
+ return {
32488
+ filePath: candidatePath,
32489
+ relativeDirPath: (0, node_path.dirname)(relativePath),
32490
+ relativeFilePath: (0, node_path.basename)(relativePath)
32491
+ };
32492
+ }
31935
32493
  function disabledNamesOf(config) {
31936
32494
  const mcpBlock = config && isRecord$1(config.mcp) ? config.mcp : {};
31937
32495
  return isStringArray$2(mcpBlock.disabledMcpServers) ? mcpBlock.disabledMcpServers : [];
@@ -32037,6 +32595,25 @@ function envVarMcpFileSpellings({ fileName }) {
32037
32595
  return [`$HOME/${tail}`, `\${HOME}/${tail}`];
32038
32596
  }
32039
32597
  /**
32598
+ * Classify the existing `mcpConfigPath` without deciding how to report it.
32599
+ * Keep the known-file checks in this order: the generated file is valid in
32600
+ * either scope, while the documented default and environment-variable
32601
+ * spellings are global-only alternatives that need their own warnings.
32602
+ */
32603
+ function classifyExistingPointer({ existing, global, outputRoot }) {
32604
+ if (existing === void 0) return { kind: "unset" };
32605
+ const normalized = typeof existing === "string" ? normalizeMcpConfigPathValue(existing) : void 0;
32606
+ const namesFile = (fileName) => normalized !== void 0 && mcpFileSpellings({
32607
+ fileName,
32608
+ global,
32609
+ outputRoot
32610
+ }).includes(normalized);
32611
+ if (namesFile("mcp.json")) return { kind: "already-generated" };
32612
+ if (global && namesFile(ROVODEV_ALTERNATE_MCP_FILE_NAME)) return { kind: "documented-default" };
32613
+ if (global && normalized !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalized)) return { kind: "env-var-spelling" };
32614
+ return { kind: "unrelated" };
32615
+ }
32616
+ /**
32040
32617
  * Point `mcp.mcpConfigPath` at the `mcp.json` rulesync writes for this scope,
32041
32618
  * and report whether the block gained a value it did not already carry.
32042
32619
  *
@@ -32147,45 +32724,44 @@ function announcePointer({ global, logger }) {
32147
32724
  async function applyMcpConfigPointer({ existingMcp, global, hasLiveServers, outputRoot, logger }) {
32148
32725
  const { pointer, configLabel, mcpLabel } = pointerLabels(global);
32149
32726
  const existing = existingMcp.mcpConfigPath;
32150
- const normalizedExisting = typeof existing === "string" ? normalizeMcpConfigPathValue(existing) : void 0;
32151
- const namesFile = (fileName) => normalizedExisting !== void 0 && mcpFileSpellings({
32152
- fileName,
32727
+ const classification = classifyExistingPointer({
32728
+ existing,
32153
32729
  global,
32154
32730
  outputRoot
32155
- }).includes(normalizedExisting);
32156
- const pointsAtGeneratedFile = namesFile(ROVODEV_MCP_FILE_NAME);
32731
+ });
32157
32732
  if (!hasLiveServers) {
32158
- if (pointsAtGeneratedFile) logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} points at ${mcpLabel}, which now has no enabled server. Rovo Dev reads MCP servers from that file and nowhere else, so ${global ? "Rovo Dev has" : "this project has"} no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to ${global ? "Rovo Dev's own default" : "the global config"}.`);
32733
+ if (classification.kind === "already-generated") logger?.warn(`Rovo Dev MCP: mcp.mcpConfigPath in ${configLabel} points at ${mcpLabel}, which now has no enabled server. Rovo Dev reads MCP servers from that file and nowhere else, so ${global ? "Rovo Dev has" : "this project has"} no MCP servers at all until one targeting rovodev is added back — remove the mcp.mcpConfigPath line to fall back to ${global ? "Rovo Dev's own default" : "the global config"}.`);
32159
32734
  return false;
32160
32735
  }
32161
- if (existing === void 0) {
32162
- const displaced = global ? await describeDisplacedGlobalServers({ outputRoot }) : null;
32163
- if (displaced !== null) {
32164
- logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath unset in ${configLabel}, because ${displaced}. Atlassian documents that path and ${mcpLabel} as two different defaults for the setting, and mcpConfigPath names one config rather than merging, so pointing it at ${mcpLabel} would stop those servers being read on every project. Move the ones you want to keep into .rulesync/mcp.jsonc — or none at all, if it turns out to hold nothing you need — then set mcp.mcpConfigPath to "${pointer}" yourself, which rulesync will not overwrite.`);
32165
- return false;
32736
+ switch (classification.kind) {
32737
+ case "unset": {
32738
+ const displaced = global ? await describeDisplacedGlobalServers({ outputRoot }) : null;
32739
+ if (displaced !== null) {
32740
+ logger?.warn(`Rovo Dev MCP: leaving mcp.mcpConfigPath unset in ${configLabel}, because ${displaced}. Atlassian documents that path and ${mcpLabel} as two different defaults for the setting, and mcpConfigPath names one config rather than merging, so pointing it at ${mcpLabel} would stop those servers being read on every project. Move the ones you want to keep into .rulesync/mcp.jsonc — or none at all, if it turns out to hold nothing you need — then set mcp.mcpConfigPath to "${pointer}" yourself, which rulesync will not overwrite.`);
32741
+ return false;
32742
+ }
32743
+ existingMcp.mcpConfigPath = pointer;
32744
+ announcePointer({
32745
+ global,
32746
+ logger
32747
+ });
32748
+ return true;
32166
32749
  }
32167
- existingMcp.mcpConfigPath = pointer;
32168
- announcePointer({
32169
- global,
32170
- logger
32171
- });
32172
- return true;
32173
- }
32174
- if (pointsAtGeneratedFile) return false;
32175
- if (global && namesFile(ROVODEV_ALTERNATE_MCP_FILE_NAME)) {
32176
- await warnAtDocumentedDefault({
32177
- existing,
32178
- outputRoot,
32179
- logger
32180
- });
32181
- return false;
32182
- }
32183
- if (global && normalizedExisting !== void 0 && envVarMcpFileSpellings({ fileName: "mcp.json" }).includes(normalizedExisting)) {
32184
- 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.`);
32185
- return false;
32750
+ case "already-generated": return false;
32751
+ case "documented-default":
32752
+ await warnAtDocumentedDefault({
32753
+ existing,
32754
+ outputRoot,
32755
+ logger
32756
+ });
32757
+ return false;
32758
+ case "env-var-spelling":
32759
+ 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.`);
32760
+ return false;
32761
+ case "unrelated":
32762
+ 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}".`);
32763
+ return false;
32186
32764
  }
32187
- 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}".`);
32188
- return false;
32189
32765
  }
32190
32766
  /**
32191
32767
  * Auxiliary writer for the `mcp:` block of `.rovodev/config.yml` (project) /
@@ -32227,14 +32803,20 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
32227
32803
  relativeFilePath: ROVODEV_MCP_FILE_NAME
32228
32804
  };
32229
32805
  }
32230
- static async fromFile({ outputRoot = process.cwd(), validate = true, global = false }) {
32231
- const paths = this.getSettablePaths({ global });
32232
- const json = parseRovodevMcpJson(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? "{\"mcpServers\":{}}", paths.relativeDirPath, paths.relativeFilePath);
32806
+ static async fromFile({ outputRoot = process.cwd(), validate = true, global = false, logger }) {
32807
+ const rovodevConfig = await readRovodevConfigYaml({ outputRoot });
32808
+ const paths = await resolveRovodevMcpImportPath({
32809
+ outputRoot,
32810
+ global,
32811
+ config: rovodevConfig,
32812
+ logger
32813
+ });
32814
+ const json = parseRovodevMcpJson(await readFileContentOrNull(paths.filePath) ?? "{\"mcpServers\":{}}", paths.relativeDirPath, paths.relativeFilePath);
32233
32815
  const newJson = {
32234
32816
  ...json,
32235
32817
  mcpServers: json.mcpServers ?? {}
32236
32818
  };
32237
- const disabledNames = disabledNamesOf(await readRovodevConfigYaml({ outputRoot }));
32819
+ const disabledNames = disabledNamesOf(rovodevConfig);
32238
32820
  if (disabledNames.length > 0 && isMcpServers(newJson.mcpServers)) {
32239
32821
  const servers = newJson.mcpServers;
32240
32822
  for (const name of disabledNames) {
@@ -32264,9 +32846,8 @@ var RovodevMcp = class RovodevMcp extends ToolMcp {
32264
32846
  } catch {
32265
32847
  canWriteDisableToggle = false;
32266
32848
  }
32267
- const rawMcpServers = rulesyncMcp.getJson().mcpServers;
32268
32849
  const mcpServers = Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([name, server]) => {
32269
- const rawServer = isRecord$1(rawMcpServers) && Object.hasOwn(rawMcpServers, name) ? rawMcpServers[name] : void 0;
32850
+ const rawServer = rulesyncMcp.getRawMcpServer(name);
32270
32851
  const record = {
32271
32852
  ...server,
32272
32853
  ...readEnableInstructions(rawServer) && { rovodevEnableInstructions: true }
@@ -34254,6 +34835,518 @@ function convertAmpToRulesync({ disable, permissions }) {
34254
34835
  return { permission };
34255
34836
  }
34256
34837
  //#endregion
34838
+ //#region src/utils/glob.ts
34839
+ /**
34840
+ * Convert a glob-like pattern into an anchored regex source string.
34841
+ *
34842
+ * Only `*` (any run of characters) and `?` (one character) carry meaning;
34843
+ * every other regex metacharacter is escaped so it matches literally. The
34844
+ * result is anchored at both ends, because the callers ask "is this the whole
34845
+ * name?" rather than "does this appear somewhere in it?".
34846
+ *
34847
+ * Note that `[` and `]` are escaped along with everything else, so a bracket
34848
+ * class is a literal here while `matchesGlob` below reads it as a class. The
34849
+ * one caller wants exactly that: AugmentCode writes this source into its own
34850
+ * config as the tool's own shell-command regex, and never executes it, so it
34851
+ * has to say what the tool would read rather than what a glob means. Use
34852
+ * `matchesGlob` for an actual comparison.
34853
+ */
34854
+ function globToAnchoredRegexSource(glob) {
34855
+ let source = "";
34856
+ for (const char of glob) if (char === "*") source += ".*";
34857
+ else if (char === "?") source += ".";
34858
+ else if (/[\\^$.|+(){}[\]]/.test(char)) source += `\\${char}`;
34859
+ else source += char;
34860
+ return `^${source}$`;
34861
+ }
34862
+ /**
34863
+ * Read a `[...]` class body starting just past the `[`, or `undefined` when the
34864
+ * bracket is never closed — in which case it is an ordinary character.
34865
+ */
34866
+ function parseGlobClass(characters, start) {
34867
+ let index = start;
34868
+ const negated = characters[index] === "!" || characters[index] === "^";
34869
+ if (negated) index += 1;
34870
+ const members = /* @__PURE__ */ new Set();
34871
+ const ranges = [];
34872
+ let first = true;
34873
+ while (index < characters.length) {
34874
+ const character = characters[index] ?? "";
34875
+ if (character === "]" && !first) return {
34876
+ step: {
34877
+ kind: "class",
34878
+ negated,
34879
+ members,
34880
+ ranges
34881
+ },
34882
+ next: index + 1
34883
+ };
34884
+ first = false;
34885
+ const high = characters[index + 2];
34886
+ if (characters[index + 1] === "-" && high !== void 0 && high !== "]") {
34887
+ ranges.push([character.codePointAt(0) ?? 0, high.codePointAt(0) ?? 0]);
34888
+ index += 3;
34889
+ continue;
34890
+ }
34891
+ members.add(character);
34892
+ index += 1;
34893
+ }
34894
+ }
34895
+ /** Split a glob into the steps `matchesGlob` walks. */
34896
+ function parseGlob(glob) {
34897
+ const characters = [...glob];
34898
+ const steps = [];
34899
+ let index = 0;
34900
+ let bracketsAreClosed = true;
34901
+ while (index < characters.length) {
34902
+ const character = characters[index] ?? "";
34903
+ index += 1;
34904
+ if (character === "*") {
34905
+ if (steps.at(-1)?.kind !== "star") steps.push({ kind: "star" });
34906
+ continue;
34907
+ }
34908
+ if (character === "?") {
34909
+ steps.push({ kind: "any" });
34910
+ continue;
34911
+ }
34912
+ if (character === "[" && bracketsAreClosed) {
34913
+ const parsed = parseGlobClass(characters, index);
34914
+ if (parsed === void 0) bracketsAreClosed = false;
34915
+ else {
34916
+ steps.push(parsed.step);
34917
+ index = parsed.next;
34918
+ continue;
34919
+ }
34920
+ }
34921
+ steps.push({
34922
+ kind: "literal",
34923
+ character
34924
+ });
34925
+ }
34926
+ return steps;
34927
+ }
34928
+ function matchesGlobStep(step, character) {
34929
+ if (step.kind === "star") return false;
34930
+ if (step.kind === "any") return true;
34931
+ if (step.kind === "literal") return step.character === character;
34932
+ const code = character.codePointAt(0) ?? 0;
34933
+ const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
34934
+ return step.negated ? !admitted : admitted;
34935
+ }
34936
+ /** Whether two single-character steps can both match one same character. */
34937
+ function stepsShareACharacter(left, right) {
34938
+ if (left.kind === "any" || right.kind === "any") return true;
34939
+ if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34940
+ if (left.kind === "literal") return matchesGlobStep(right, left.character);
34941
+ if (right.kind === "literal") return matchesGlobStep(left, right.character);
34942
+ return true;
34943
+ }
34944
+ /** Whether every step from `index` on can match the empty string. */
34945
+ function isAllStars(steps, index) {
34946
+ for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34947
+ return true;
34948
+ }
34949
+ /**
34950
+ * The most work one intersection walk will do, counted in cells times the cost
34951
+ * of one. Past it the two patterns are reported as intersecting without being
34952
+ * walked: the product of two lengths grows quadratically, and a pattern long
34953
+ * enough to reach this is pathological rather than a command anybody typed.
34954
+ * Answering `true` withholds an `allow`, which is the direction that fails
34955
+ * closed.
34956
+ */
34957
+ const MAX_INTERSECTION_CELLS = 1e6;
34958
+ /**
34959
+ * The most work a whole run of comparisons will do. A caller holding R
34960
+ * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34961
+ * bounds none of that: a hundred restrictions against a hundred allow rules,
34962
+ * each pattern just under the per-pair cap, is ten thousand affordable walks
34963
+ * that together take minutes. The shared budget is spent down across the run
34964
+ * and, once it is gone, every remaining pair is reported as intersecting —
34965
+ * again the direction that withholds an `allow` rather than writing one.
34966
+ */
34967
+ const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34968
+ /**
34969
+ * What a pair costs on top of the cells it walks: the call itself, sizing and
34970
+ * filling the two rows the table is held in, and collecting the answer.
34971
+ * Charging only cells would leave the *number* of pairs unbounded — a pair of
34972
+ * one-step patterns walks a single cell, so n short restrictions against n
34973
+ * short allow rules is n squared comparisons that never spend the budget down
34974
+ * however many of them there are. Charging a floor per pair puts pair count and
34975
+ * walk length on the same exhaustible resource.
34976
+ *
34977
+ * For the short patterns of an ordinary config the floor is the whole charge,
34978
+ * which lowers how many pairs a run compares from around a million to about
34979
+ * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34980
+ * that line withholds every allow it has not yet compared, the same fail-closed
34981
+ * answer exhaustion gives everywhere else.
34982
+ */
34983
+ const INTERSECTION_PAIR_COST = 64;
34984
+ /**
34985
+ * A budget for one caller's run of comparisons. Hand the same one to every
34986
+ * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34987
+ * config — so the run as a whole stays bounded rather than only each pair in
34988
+ * it.
34989
+ */
34990
+ function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34991
+ return { remaining };
34992
+ }
34993
+ /**
34994
+ * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34995
+ * the same pattern against a whole list parses it once and reuses the result.
34996
+ */
34997
+ function parseGlobPattern(glob) {
34998
+ const steps = parseGlob(glob);
34999
+ return {
35000
+ steps,
35001
+ maxRanges: maxRangeCount(steps)
35002
+ };
35003
+ }
35004
+ /**
35005
+ * What one cell can cost, as a multiplier on the cell count. A literal met by a
35006
+ * `[a-z...]` class walks that class's ranges, so a single class carrying
35007
+ * thousands of them turns a walk that looks affordable by cell count alone into
35008
+ * a quadratic one — which is why the budget is spent on cells times this rather
35009
+ * than on cells.
35010
+ */
35011
+ function maxRangeCount(steps) {
35012
+ let most = 0;
35013
+ for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
35014
+ return most;
35015
+ }
35016
+ /**
35017
+ * `globsIntersect` for two globs already parsed, optionally spending a budget
35018
+ * shared with the rest of the caller's run — see `createIntersectionBudget`.
35019
+ * Once that budget is exhausted every further pair answers `true` without being
35020
+ * walked, so a caller reading the answer as a reason to restrict stays on the
35021
+ * safe side.
35022
+ */
35023
+ function parsedGlobsIntersect(left, right, budget) {
35024
+ const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
35025
+ const cellCost = 1 + left.maxRanges + right.maxRanges;
35026
+ const cost = rows.length * columns.length * cellCost;
35027
+ if (cost > MAX_INTERSECTION_CELLS) return true;
35028
+ if (budget !== void 0) {
35029
+ const charge = cost + INTERSECTION_PAIR_COST;
35030
+ if (charge > budget.remaining) {
35031
+ budget.remaining = 0;
35032
+ return true;
35033
+ }
35034
+ budget.remaining -= charge;
35035
+ }
35036
+ let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
35037
+ for (let i = rows.length - 1; i >= 0; i--) {
35038
+ const row = Array.from({ length: columns.length + 1 }, () => false);
35039
+ row[columns.length] = isAllStars(rows, i);
35040
+ for (let j = columns.length - 1; j >= 0; j--) {
35041
+ const rowStep = rows[i];
35042
+ const columnStep = columns[j];
35043
+ if (rowStep === void 0 || columnStep === void 0) continue;
35044
+ if (rowStep.kind === "star" || columnStep.kind === "star") {
35045
+ row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
35046
+ continue;
35047
+ }
35048
+ row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
35049
+ }
35050
+ next = row;
35051
+ }
35052
+ return next[0] ?? false;
35053
+ }
35054
+ //#endregion
35055
+ //#region src/features/permissions/shell-command-categories.ts
35056
+ /** The canonical category that names a shell command's permissions. */
35057
+ const SHELL_PERMISSION_CATEGORY = "bash";
35058
+ /**
35059
+ * Collect the canonical rules that govern shell commands, for the adapters
35060
+ * whose tool models commands and nothing else.
35061
+ *
35062
+ * The `bash` category contributes every rule. The all-tools `*` category
35063
+ * contributes its **restricting** rules — `deny` and `ask` — because a rule
35064
+ * written there covers shell commands too, and dropping it inverts the
35065
+ * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
35066
+ * an adapter that reads only `bash` auto-approves the very command the file
35067
+ * denies.
35068
+ *
35069
+ * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
35070
+ * need not be a command at all — `secrets/**` under `*` denies a path — and
35071
+ * carrying it in the restricting direction only over-restricts, while carrying
35072
+ * it in the permissive direction would grant something the author never said
35073
+ * about commands. Both directions therefore fail closed.
35074
+ */
35075
+ function collectShellCommandRules(permission) {
35076
+ const rules = [];
35077
+ const foreignRestrictingCategories = [];
35078
+ const ignoredAllToolsAllowPatterns = [];
35079
+ for (const [category, categoryRules] of Object.entries(permission)) {
35080
+ if (category === "bash") {
35081
+ for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
35082
+ pattern,
35083
+ action,
35084
+ fromAllToolsCategory: false
35085
+ });
35086
+ continue;
35087
+ }
35088
+ if (category === "*") {
35089
+ for (const [pattern, action] of Object.entries(categoryRules)) {
35090
+ if (action === "allow") {
35091
+ ignoredAllToolsAllowPatterns.push(pattern);
35092
+ continue;
35093
+ }
35094
+ rules.push({
35095
+ pattern,
35096
+ action,
35097
+ fromAllToolsCategory: true
35098
+ });
35099
+ }
35100
+ continue;
35101
+ }
35102
+ if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
35103
+ }
35104
+ return {
35105
+ rules,
35106
+ foreignRestrictingCategories,
35107
+ ignoredAllToolsAllowPatterns
35108
+ };
35109
+ }
35110
+ /**
35111
+ * Build the test an adapter applies to an `allow` pattern before writing it:
35112
+ * which restrictions it cannot write name some of the same commands? The
35113
+ * answer is the list of those restrictions — empty when the `allow` may be
35114
+ * written — so a caller can report both the allow rules it withheld and the
35115
+ * restrictions that withheld nothing.
35116
+ *
35117
+ * Canonically the stricter rule wins **whatever its width** — rulesync collapses
35118
+ * colliding rules as `deny > ask > allow` — so the two patterns are compared by
35119
+ * asking whether any one command matches both. Width does not enter into it: an
35120
+ * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
35121
+ * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
35122
+ * on every `git ... --force` command even though neither pattern covers the
35123
+ * other's spelling. Comparing only identical spellings would let the most
35124
+ * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
35125
+ *
35126
+ * Identical spellings are still compared as strings first, as a shortcut past
35127
+ * the walk for the commonest case.
35128
+ *
35129
+ * `normalizePattern` rewrites a pattern written in the tool's own language into
35130
+ * the widest glob it could stand for, for a tool whose patterns are not globs.
35131
+ * It reaches the `bash` rules and the `allow` rules, which is where such a
35132
+ * pattern is written; an all-tools `*` pattern is canonical — it is read by
35133
+ * every tool, so it is a glob already — and is compared as it stands. The
35134
+ * rewrite must only ever widen what a pattern covers, so an inexact reading
35135
+ * withholds an allow rather than writing one the config restricts — see
35136
+ * `warpCommandPatternToGlob`.
35137
+ */
35138
+ function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
35139
+ const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
35140
+ pattern,
35141
+ glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
35142
+ }));
35143
+ return (allowPattern) => {
35144
+ if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
35145
+ const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
35146
+ return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
35147
+ };
35148
+ }
35149
+ /**
35150
+ * Which of the given all-tools `*` restrictions look like they may not name a
35151
+ * command at all — the question a `deny` and an `ask` written there both raise.
35152
+ *
35153
+ * "Withheld no allow rule" alone does not answer it: a config with no `allow`
35154
+ * rules has nothing to withhold, and a pattern the author also wrote under
35155
+ * `bash` is a command on their own word. Both are excluded, so what remains is
35156
+ * a `*` pattern that had allow rules to overlap, overlapped none of them, and
35157
+ * is claimed as a command nowhere else — the shape `secrets/**` has.
35158
+ *
35159
+ * A `bash` restriction never belongs here: it names a command by construction,
35160
+ * so overlapping no allow rule says nothing is wrong with it.
35161
+ */
35162
+ function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
35163
+ if (!rules.some(({ action }) => action === "allow")) return [];
35164
+ const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
35165
+ return (0, es_toolkit.uniq)(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
35166
+ }
35167
+ /**
35168
+ * Split shell-command rules into the allow and deny lists of a tool that models
35169
+ * commands with those two tiers and nothing else.
35170
+ *
35171
+ * `ask` has no list of its own — such a tool already prompts for whatever it
35172
+ * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
35173
+ * still has to *withhold* the `allow` rules it covers, though: the canonical
35174
+ * order is `deny > ask > allow`, so auto-approving a command the file also asks
35175
+ * about would answer the prompt the author wanted.
35176
+ *
35177
+ * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
35178
+ * from the all-tools `*` category. Warp's cannot: it matches commands with
35179
+ * regular expressions rather than globs, and writing any denylist **replaces**
35180
+ * Warp's built-in default one, so an inert `secrets/**` entry there would trade
35181
+ * the tool's own protection for a rule that matches no command. Where the deny
35182
+ * cannot be written it withholds the allow rules it covers instead, which
35183
+ * restricts in the same direction without touching the denylist.
35184
+ *
35185
+ * A `bash` deny withholds nothing: it names a command by construction, so the
35186
+ * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
35187
+ * and a narrow deny keeps carving an exception out of a wider allow (`git *`
35188
+ * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
35189
+ * even where it is written: a pattern under `*` need not name a command —
35190
+ * `secrets/**` there denies a path — so as a denylist entry it may match nothing
35191
+ * at all, and leaving an overlapping allow beside it would auto-approve the very
35192
+ * commands the author meant to stop. Over-restricting a `*` deny that *was* a
35193
+ * command pattern is reported; failing open would not be.
35194
+ *
35195
+ * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
35196
+ * patterns are not globs.
35197
+ */
35198
+ function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
35199
+ const deny = [];
35200
+ const unwrittenDenyPatterns = [];
35201
+ const restrictions = [];
35202
+ const writtenAllToolsDenyPatterns = [];
35203
+ const allToolsAskPatterns = [];
35204
+ for (const rule of rules) {
35205
+ const { pattern, action, fromAllToolsCategory } = rule;
35206
+ if (action === "allow") continue;
35207
+ if (action !== "deny") {
35208
+ restrictions.push(rule);
35209
+ if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
35210
+ continue;
35211
+ }
35212
+ if (writesAllToolsDeny || !fromAllToolsCategory) {
35213
+ deny.push(pattern);
35214
+ if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
35215
+ } else unwrittenDenyPatterns.push(pattern);
35216
+ if (fromAllToolsCategory) restrictions.push(rule);
35217
+ }
35218
+ const budget = createIntersectionBudget();
35219
+ const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
35220
+ normalizePattern,
35221
+ budget
35222
+ });
35223
+ const allow = [];
35224
+ const shadowedAllowPatterns = [];
35225
+ const withholdingPatterns = /* @__PURE__ */ new Set();
35226
+ for (const { pattern, action } of rules) {
35227
+ if (action !== "allow") continue;
35228
+ const shadowing = shadowingRestrictions(pattern);
35229
+ if (shadowing.length > 0) {
35230
+ shadowedAllowPatterns.push(pattern);
35231
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
35232
+ continue;
35233
+ }
35234
+ allow.push(pattern);
35235
+ }
35236
+ return {
35237
+ allow,
35238
+ deny,
35239
+ shadowedAllowPatterns,
35240
+ unwrittenDenyPatterns,
35241
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35242
+ rules,
35243
+ allToolsPatterns: writtenAllToolsDenyPatterns,
35244
+ withholdingPatterns
35245
+ }),
35246
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35247
+ rules,
35248
+ allToolsPatterns: allToolsAskPatterns,
35249
+ withholdingPatterns
35250
+ }),
35251
+ intersectionBudgetExhausted: budget.remaining === 0
35252
+ };
35253
+ }
35254
+ /**
35255
+ * Report, for one command-only tool, every canonical rule its two lists could
35256
+ * not carry. Every command-only adapter shares this reporting, so a rule
35257
+ * dropped in one is worded the same way in all.
35258
+ */
35259
+ function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
35260
+ 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.`);
35261
+ 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.`);
35262
+ 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.`);
35263
+ 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.`);
35264
+ 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.`);
35265
+ 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.`);
35266
+ 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.`);
35267
+ }
35268
+ function resolveShellCommandState(permission, writesAllToolsDeny) {
35269
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permission);
35270
+ const partitioned = partitionCommandRules({
35271
+ rules,
35272
+ writesAllToolsDeny
35273
+ });
35274
+ return {
35275
+ allow: partitioned.allow,
35276
+ deny: partitioned.deny,
35277
+ bash: bashRulesHonoringAllTools(permission),
35278
+ foreignRestrictingCategories,
35279
+ ignoredAllToolsAllowPatterns,
35280
+ shadowedAllowPatterns: partitioned.shadowedAllowPatterns,
35281
+ unwrittenDenyPatterns: partitioned.unwrittenDenyPatterns,
35282
+ unenforcedAllToolsDenyPatterns: partitioned.unenforcedAllToolsDenyPatterns,
35283
+ unenforcedAllToolsAskPatterns: partitioned.unenforcedAllToolsAskPatterns,
35284
+ intersectionBudgetExhausted: partitioned.intersectionBudgetExhausted
35285
+ };
35286
+ }
35287
+ /**
35288
+ * Collect shell-command allow/deny lists the way the command-only adapters do,
35289
+ * and report every restriction the surface cannot carry.
35290
+ */
35291
+ function resolveShellCommandLists({ permission, writesAllToolsDeny, toolLabel, surfaceLabel, logger }) {
35292
+ const resolved = resolveShellCommandState(permission, writesAllToolsDeny);
35293
+ warnAboutUnwrittenCommandRules({
35294
+ toolLabel,
35295
+ surfaceLabel,
35296
+ foreignRestrictingCategories: resolved.foreignRestrictingCategories,
35297
+ shadowedAllowPatterns: resolved.shadowedAllowPatterns,
35298
+ unwrittenDenyPatterns: resolved.unwrittenDenyPatterns,
35299
+ unenforcedAllToolsDenyPatterns: resolved.unenforcedAllToolsDenyPatterns,
35300
+ unenforcedAllToolsAskPatterns: resolved.unenforcedAllToolsAskPatterns,
35301
+ ignoredAllToolsAllowPatterns: resolved.ignoredAllToolsAllowPatterns,
35302
+ intersectionBudgetExhausted: resolved.intersectionBudgetExhausted,
35303
+ logger
35304
+ });
35305
+ return {
35306
+ allow: resolved.allow,
35307
+ deny: resolved.deny,
35308
+ bash: resolved.bash
35309
+ };
35310
+ }
35311
+ /**
35312
+ * The `bash` category after all-tools `*` restrictions have been applied. A
35313
+ * `deny`/`ask` written under `*` covers shell commands too, so a bash `allow`
35314
+ * it overlaps is withheld, a `*` deny is copied in, and a `*` ask is copied in
35315
+ * wherever `bash` says nothing about that exact pattern yet — otherwise it
35316
+ * would vanish from the resolved category entirely rather than falling back to
35317
+ * a tier that still prompts. An existing `bash` entry for the same pattern is
35318
+ * never downgraded by a `*` ask (a bash `allow` was already dropped above, and
35319
+ * a bash `deny`/`ask` there is at least as strict already).
35320
+ */
35321
+ function bashRulesHonoringAllTools(permission) {
35322
+ const { rules } = collectShellCommandRules(permission);
35323
+ const allToolsRestrictions = rules.filter(({ fromAllToolsCategory }) => fromAllToolsCategory);
35324
+ const shadowingRestrictions = createShadowingRestrictionsTest(allToolsRestrictions);
35325
+ const bash = { ...permission.bash };
35326
+ for (const [pattern, action] of Object.entries(bash)) if (action === "allow" && shadowingRestrictions(pattern).length > 0) delete bash[pattern];
35327
+ for (const { pattern, action } of allToolsRestrictions) {
35328
+ if (isPrototypePollutionKey(pattern)) continue;
35329
+ if (action === "deny") {
35330
+ if (bash[pattern] !== "ask") bash[pattern] = "deny";
35331
+ continue;
35332
+ }
35333
+ if (bash[pattern] === void 0) bash[pattern] = "ask";
35334
+ }
35335
+ return bash;
35336
+ }
35337
+ /**
35338
+ * Return a permission block whose `bash` category honors all-tools `*`
35339
+ * restrictions. Other categories are unchanged, so adapters that already model
35340
+ * `*` keep doing so.
35341
+ */
35342
+ function honorAllToolsOnBash(permission) {
35343
+ if (permission.bash === void 0) return permission;
35344
+ return {
35345
+ ...permission,
35346
+ bash: bashRulesHonoringAllTools(permission)
35347
+ };
35348
+ }
35349
+ //#endregion
34257
35350
  //#region src/features/permissions/antigravity-cli-permissions.ts
34258
35351
  /**
34259
35352
  * Top-level `~/.gemini/antigravity-cli/settings.json` keys the `antigravity-cli`
@@ -34501,7 +35594,7 @@ function convertRulesyncToAntigravityCliPermissions(config) {
34501
35594
  const allow = [];
34502
35595
  const ask = [];
34503
35596
  const deny = [];
34504
- for (const [category, rules] of Object.entries(config.permission)) {
35597
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
34505
35598
  const cliToolName = toAntigravityCliToolName(category);
34506
35599
  for (const [pattern, action] of Object.entries(rules)) {
34507
35600
  const entry = buildPermissionEntry$1(cliToolName, pattern);
@@ -34712,7 +35805,7 @@ function convertRulesyncToAntigravityIdePermissions(config) {
34712
35805
  const allow = [];
34713
35806
  const ask = [];
34714
35807
  const deny = [];
34715
- for (const [category, rules] of Object.entries(config.permission)) {
35808
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
34716
35809
  const action = toIdeAction(category);
34717
35810
  for (const [pattern, permissionAction] of Object.entries(rules)) {
34718
35811
  const entry = buildPermissionEntry(action, pattern);
@@ -34752,223 +35845,6 @@ function convertAntigravityIdeToRulesyncPermissions(params) {
34752
35845
  return { permission };
34753
35846
  }
34754
35847
  //#endregion
34755
- //#region src/utils/glob.ts
34756
- /**
34757
- * Convert a glob-like pattern into an anchored regex source string.
34758
- *
34759
- * Only `*` (any run of characters) and `?` (one character) carry meaning;
34760
- * every other regex metacharacter is escaped so it matches literally. The
34761
- * result is anchored at both ends, because the callers ask "is this the whole
34762
- * name?" rather than "does this appear somewhere in it?".
34763
- *
34764
- * Note that `[` and `]` are escaped along with everything else, so a bracket
34765
- * class is a literal here while `matchesGlob` below reads it as a class. The
34766
- * one caller wants exactly that: AugmentCode writes this source into its own
34767
- * config as the tool's own shell-command regex, and never executes it, so it
34768
- * has to say what the tool would read rather than what a glob means. Use
34769
- * `matchesGlob` for an actual comparison.
34770
- */
34771
- function globToAnchoredRegexSource(glob) {
34772
- let source = "";
34773
- for (const char of glob) if (char === "*") source += ".*";
34774
- else if (char === "?") source += ".";
34775
- else if (/[\\^$.|+(){}[\]]/.test(char)) source += `\\${char}`;
34776
- else source += char;
34777
- return `^${source}$`;
34778
- }
34779
- /**
34780
- * Read a `[...]` class body starting just past the `[`, or `undefined` when the
34781
- * bracket is never closed — in which case it is an ordinary character.
34782
- */
34783
- function parseGlobClass(characters, start) {
34784
- let index = start;
34785
- const negated = characters[index] === "!" || characters[index] === "^";
34786
- if (negated) index += 1;
34787
- const members = /* @__PURE__ */ new Set();
34788
- const ranges = [];
34789
- let first = true;
34790
- while (index < characters.length) {
34791
- const character = characters[index] ?? "";
34792
- if (character === "]" && !first) return {
34793
- step: {
34794
- kind: "class",
34795
- negated,
34796
- members,
34797
- ranges
34798
- },
34799
- next: index + 1
34800
- };
34801
- first = false;
34802
- const high = characters[index + 2];
34803
- if (characters[index + 1] === "-" && high !== void 0 && high !== "]") {
34804
- ranges.push([character.codePointAt(0) ?? 0, high.codePointAt(0) ?? 0]);
34805
- index += 3;
34806
- continue;
34807
- }
34808
- members.add(character);
34809
- index += 1;
34810
- }
34811
- }
34812
- /** Split a glob into the steps `matchesGlob` walks. */
34813
- function parseGlob(glob) {
34814
- const characters = [...glob];
34815
- const steps = [];
34816
- let index = 0;
34817
- let bracketsAreClosed = true;
34818
- while (index < characters.length) {
34819
- const character = characters[index] ?? "";
34820
- index += 1;
34821
- if (character === "*") {
34822
- if (steps.at(-1)?.kind !== "star") steps.push({ kind: "star" });
34823
- continue;
34824
- }
34825
- if (character === "?") {
34826
- steps.push({ kind: "any" });
34827
- continue;
34828
- }
34829
- if (character === "[" && bracketsAreClosed) {
34830
- const parsed = parseGlobClass(characters, index);
34831
- if (parsed === void 0) bracketsAreClosed = false;
34832
- else {
34833
- steps.push(parsed.step);
34834
- index = parsed.next;
34835
- continue;
34836
- }
34837
- }
34838
- steps.push({
34839
- kind: "literal",
34840
- character
34841
- });
34842
- }
34843
- return steps;
34844
- }
34845
- function matchesGlobStep(step, character) {
34846
- if (step.kind === "star") return false;
34847
- if (step.kind === "any") return true;
34848
- if (step.kind === "literal") return step.character === character;
34849
- const code = character.codePointAt(0) ?? 0;
34850
- const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
34851
- return step.negated ? !admitted : admitted;
34852
- }
34853
- /** Whether two single-character steps can both match one same character. */
34854
- function stepsShareACharacter(left, right) {
34855
- if (left.kind === "any" || right.kind === "any") return true;
34856
- if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34857
- if (left.kind === "literal") return matchesGlobStep(right, left.character);
34858
- if (right.kind === "literal") return matchesGlobStep(left, right.character);
34859
- return true;
34860
- }
34861
- /** Whether every step from `index` on can match the empty string. */
34862
- function isAllStars(steps, index) {
34863
- for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34864
- return true;
34865
- }
34866
- /**
34867
- * The most work one intersection walk will do, counted in cells times the cost
34868
- * of one. Past it the two patterns are reported as intersecting without being
34869
- * walked: the product of two lengths grows quadratically, and a pattern long
34870
- * enough to reach this is pathological rather than a command anybody typed.
34871
- * Answering `true` withholds an `allow`, which is the direction that fails
34872
- * closed.
34873
- */
34874
- const MAX_INTERSECTION_CELLS = 1e6;
34875
- /**
34876
- * The most work a whole run of comparisons will do. A caller holding R
34877
- * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34878
- * bounds none of that: a hundred restrictions against a hundred allow rules,
34879
- * each pattern just under the per-pair cap, is ten thousand affordable walks
34880
- * that together take minutes. The shared budget is spent down across the run
34881
- * and, once it is gone, every remaining pair is reported as intersecting —
34882
- * again the direction that withholds an `allow` rather than writing one.
34883
- */
34884
- const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34885
- /**
34886
- * What a pair costs on top of the cells it walks: the call itself, sizing and
34887
- * filling the two rows the table is held in, and collecting the answer.
34888
- * Charging only cells would leave the *number* of pairs unbounded — a pair of
34889
- * one-step patterns walks a single cell, so n short restrictions against n
34890
- * short allow rules is n squared comparisons that never spend the budget down
34891
- * however many of them there are. Charging a floor per pair puts pair count and
34892
- * walk length on the same exhaustible resource.
34893
- *
34894
- * For the short patterns of an ordinary config the floor is the whole charge,
34895
- * which lowers how many pairs a run compares from around a million to about
34896
- * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34897
- * that line withholds every allow it has not yet compared, the same fail-closed
34898
- * answer exhaustion gives everywhere else.
34899
- */
34900
- const INTERSECTION_PAIR_COST = 64;
34901
- /**
34902
- * A budget for one caller's run of comparisons. Hand the same one to every
34903
- * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34904
- * config — so the run as a whole stays bounded rather than only each pair in
34905
- * it.
34906
- */
34907
- function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34908
- return { remaining };
34909
- }
34910
- /**
34911
- * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34912
- * the same pattern against a whole list parses it once and reuses the result.
34913
- */
34914
- function parseGlobPattern(glob) {
34915
- const steps = parseGlob(glob);
34916
- return {
34917
- steps,
34918
- maxRanges: maxRangeCount(steps)
34919
- };
34920
- }
34921
- /**
34922
- * What one cell can cost, as a multiplier on the cell count. A literal met by a
34923
- * `[a-z...]` class walks that class's ranges, so a single class carrying
34924
- * thousands of them turns a walk that looks affordable by cell count alone into
34925
- * a quadratic one — which is why the budget is spent on cells times this rather
34926
- * than on cells.
34927
- */
34928
- function maxRangeCount(steps) {
34929
- let most = 0;
34930
- for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34931
- return most;
34932
- }
34933
- /**
34934
- * `globsIntersect` for two globs already parsed, optionally spending a budget
34935
- * shared with the rest of the caller's run — see `createIntersectionBudget`.
34936
- * Once that budget is exhausted every further pair answers `true` without being
34937
- * walked, so a caller reading the answer as a reason to restrict stays on the
34938
- * safe side.
34939
- */
34940
- function parsedGlobsIntersect(left, right, budget) {
34941
- const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
34942
- const cellCost = 1 + left.maxRanges + right.maxRanges;
34943
- const cost = rows.length * columns.length * cellCost;
34944
- if (cost > MAX_INTERSECTION_CELLS) return true;
34945
- if (budget !== void 0) {
34946
- const charge = cost + INTERSECTION_PAIR_COST;
34947
- if (charge > budget.remaining) {
34948
- budget.remaining = 0;
34949
- return true;
34950
- }
34951
- budget.remaining -= charge;
34952
- }
34953
- let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
34954
- for (let i = rows.length - 1; i >= 0; i--) {
34955
- const row = Array.from({ length: columns.length + 1 }, () => false);
34956
- row[columns.length] = isAllStars(rows, i);
34957
- for (let j = columns.length - 1; j >= 0; j--) {
34958
- const rowStep = rows[i];
34959
- const columnStep = columns[j];
34960
- if (rowStep === void 0 || columnStep === void 0) continue;
34961
- if (rowStep.kind === "star" || columnStep.kind === "star") {
34962
- row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
34963
- continue;
34964
- }
34965
- row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
34966
- }
34967
- next = row;
34968
- }
34969
- return next[0] ?? false;
34970
- }
34971
- //#endregion
34972
35848
  //#region src/features/permissions/augmentcode-permissions.ts
34973
35849
  const moduleLogger$2 = fallbackLogger;
34974
35850
  zod_mini.z.enum([
@@ -35220,6 +36096,7 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
35220
36096
  const basicExistingEntries = existingEntries.filter((entry) => !isSpecialEntry(entry));
35221
36097
  const generatedKeys = new Set(generated.map((e) => `${e.toolName}|${e.shellInputRegex ?? ""}|${e.permission.type}`));
35222
36098
  const preservedBasicEntries = basicExistingEntries.filter((entry) => {
36099
+ if (entry.toolName === "*") return false;
35223
36100
  if (!MANAGED_AUGMENT_TOOL_NAMES.has(entry.toolName)) return true;
35224
36101
  if (entry.permission.type === "deny") {
35225
36102
  const key = `${entry.toolName}|${entry.shellInputRegex ?? ""}|${entry.permission.type}`;
@@ -35297,7 +36174,18 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
35297
36174
  };
35298
36175
  function convertRulesyncToAugmentEntries({ config, logger }) {
35299
36176
  const entries = [];
35300
- for (const [category, rules] of Object.entries(config.permission)) {
36177
+ const resolvedBashRules = bashRulesHonoringAllTools(config.permission);
36178
+ const permission = config.permission.bash !== void 0 || Object.keys(resolvedBashRules).length > 0 ? {
36179
+ ...config.permission,
36180
+ bash: resolvedBashRules
36181
+ } : config.permission;
36182
+ const allToolsFailClosedType = computeAllToolsFailClosedType(config.permission["*"]);
36183
+ const categoriesWithOwnEntries = /* @__PURE__ */ new Set();
36184
+ for (const [category, rules] of Object.entries(permission)) {
36185
+ if (category === "*") {
36186
+ logger?.warn("AugmentCode permissions: the all-tools '*' category cannot be emitted as a single entry because AugmentCode has no wildcard toolName. Deny/ask rules are folded into 'launch-process' and, for any other managed tool that has no explicit rules of its own, applied as a blanket deny/ask; all-tools allow rules and tools that already define their own rules are left untouched.");
36187
+ continue;
36188
+ }
35301
36189
  const augmentToolName = toAugmentToolName(category);
35302
36190
  if (!MANAGED_AUGMENT_TOOL_NAMES.has(augmentToolName) && augmentToolName === category) logger?.warn(`AugmentCode permissions: passing through unknown tool category '${category}' as toolName.`);
35303
36191
  if (augmentToolName === "launch-process") {
@@ -35322,19 +36210,50 @@ function convertRulesyncToAugmentEntries({ config, logger }) {
35322
36210
  toolName: augmentToolName,
35323
36211
  permission: { type: "deny" }
35324
36212
  });
36213
+ categoriesWithOwnEntries.add(category);
35325
36214
  continue;
35326
36215
  }
35327
36216
  const droppedPatterns = [];
35328
- for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") entries.push({
35329
- toolName: augmentToolName,
35330
- permission: { type: actionToAugmentType(action) }
35331
- });
35332
- else droppedPatterns.push(pattern);
36217
+ for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") {
36218
+ entries.push({
36219
+ toolName: augmentToolName,
36220
+ permission: { type: actionToAugmentType(action) }
36221
+ });
36222
+ categoriesWithOwnEntries.add(category);
36223
+ } else droppedPatterns.push(pattern);
35333
36224
  if (droppedPatterns.length > 0) logger?.warn(`AugmentCode permissions: dropping non-wildcard patterns for category '${category}' (${droppedPatterns.join(", ")}); AugmentCode does not document a per-input matcher for this tool. Use a 'deny' rule with pattern '*' if you need to block this tool entirely.`);
35334
36225
  }
36226
+ entries.push(...synthesizeManagedToolFallbackEntries(categoriesWithOwnEntries, allToolsFailClosedType));
35335
36227
  return entries;
35336
36228
  }
35337
36229
  /**
36230
+ * The strictest action the all-tools `*` category imposes, for tools with no per-input matcher to
36231
+ * narrow it onto (see {@link synthesizeManagedToolFallbackEntries}). `deny` wins over `ask`,
36232
+ * and an all-tools `allow` never forces an entry — there is nothing to fail closed on.
36233
+ */
36234
+ function computeAllToolsFailClosedType(allToolsRules) {
36235
+ if (!allToolsRules) return void 0;
36236
+ const actions = Object.values(allToolsRules);
36237
+ if (actions.some((action) => action === "deny")) return "deny";
36238
+ if (actions.some((action) => action === "ask")) return "ask-user";
36239
+ }
36240
+ /**
36241
+ * Extend the same fail-closed treatment `bash` gets (via {@link bashRulesHonoringAllTools}) to the
36242
+ * other managed tools: one that produced no entries of its own above must not fall back to
36243
+ * AugmentCode's own default just because it has no per-input matcher to narrow the all-tools
36244
+ * restriction onto. A category can be stated yet still emit nothing (e.g. only non-`*` allow/ask
36245
+ * patterns, dropped with a warning), so this checks emitted entries rather than whether the
36246
+ * category was merely present in the source config. A tool whose own rules did emit entries is
36247
+ * left untouched here.
36248
+ */
36249
+ function synthesizeManagedToolFallbackEntries(categoriesWithOwnEntries, allToolsFailClosedType) {
36250
+ if (allToolsFailClosedType === void 0) return [];
36251
+ return Object.entries(CANONICAL_TO_AUGMENT_TOOL_NAMES).filter(([canonicalName]) => canonicalName !== "bash" && !categoriesWithOwnEntries.has(canonicalName)).map(([, augmentToolName]) => ({
36252
+ toolName: augmentToolName,
36253
+ permission: { type: allToolsFailClosedType }
36254
+ }));
36255
+ }
36256
+ /**
35338
36257
  * Sort AugmentCode tool-permission entries to make the `first-match-wins` semantics safe and predictable.
35339
36258
  *
35340
36259
  * Augment evaluates `toolPermissions` top-to-bottom and stops at the first match. To prevent a
@@ -35429,216 +36348,6 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
35429
36348
  }
35430
36349
  return { permission };
35431
36350
  }
35432
- /**
35433
- * Collect the canonical rules that govern shell commands, for the adapters
35434
- * whose tool models commands and nothing else.
35435
- *
35436
- * The `bash` category contributes every rule. The all-tools `*` category
35437
- * contributes its **restricting** rules — `deny` and `ask` — because a rule
35438
- * written there covers shell commands too, and dropping it inverts the
35439
- * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
35440
- * an adapter that reads only `bash` auto-approves the very command the file
35441
- * denies.
35442
- *
35443
- * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
35444
- * need not be a command at all — `secrets/**` under `*` denies a path — and
35445
- * carrying it in the restricting direction only over-restricts, while carrying
35446
- * it in the permissive direction would grant something the author never said
35447
- * about commands. Both directions therefore fail closed.
35448
- */
35449
- function collectShellCommandRules(permission) {
35450
- const rules = [];
35451
- const foreignRestrictingCategories = [];
35452
- const ignoredAllToolsAllowPatterns = [];
35453
- for (const [category, categoryRules] of Object.entries(permission)) {
35454
- if (category === "bash") {
35455
- for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
35456
- pattern,
35457
- action,
35458
- fromAllToolsCategory: false
35459
- });
35460
- continue;
35461
- }
35462
- if (category === "*") {
35463
- for (const [pattern, action] of Object.entries(categoryRules)) {
35464
- if (action === "allow") {
35465
- ignoredAllToolsAllowPatterns.push(pattern);
35466
- continue;
35467
- }
35468
- rules.push({
35469
- pattern,
35470
- action,
35471
- fromAllToolsCategory: true
35472
- });
35473
- }
35474
- continue;
35475
- }
35476
- if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
35477
- }
35478
- return {
35479
- rules,
35480
- foreignRestrictingCategories,
35481
- ignoredAllToolsAllowPatterns
35482
- };
35483
- }
35484
- /**
35485
- * Build the test an adapter applies to an `allow` pattern before writing it:
35486
- * which restrictions it cannot write name some of the same commands? The
35487
- * answer is the list of those restrictions — empty when the `allow` may be
35488
- * written — so a caller can report both the allow rules it withheld and the
35489
- * restrictions that withheld nothing.
35490
- *
35491
- * Canonically the stricter rule wins **whatever its width** — rulesync collapses
35492
- * colliding rules as `deny > ask > allow` — so the two patterns are compared by
35493
- * asking whether any one command matches both. Width does not enter into it: an
35494
- * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
35495
- * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
35496
- * on every `git ... --force` command even though neither pattern covers the
35497
- * other's spelling. Comparing only identical spellings would let the most
35498
- * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
35499
- *
35500
- * Identical spellings are still compared as strings first, as a shortcut past
35501
- * the walk for the commonest case.
35502
- *
35503
- * `normalizePattern` rewrites a pattern written in the tool's own language into
35504
- * the widest glob it could stand for, for a tool whose patterns are not globs.
35505
- * It reaches the `bash` rules and the `allow` rules, which is where such a
35506
- * pattern is written; an all-tools `*` pattern is canonical — it is read by
35507
- * every tool, so it is a glob already — and is compared as it stands. The
35508
- * rewrite must only ever widen what a pattern covers, so an inexact reading
35509
- * withholds an allow rather than writing one the config restricts — see
35510
- * `warpCommandPatternToGlob`.
35511
- */
35512
- function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
35513
- const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
35514
- pattern,
35515
- glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
35516
- }));
35517
- return (allowPattern) => {
35518
- if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
35519
- const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
35520
- return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
35521
- };
35522
- }
35523
- /**
35524
- * Which of the given all-tools `*` restrictions look like they may not name a
35525
- * command at all — the question a `deny` and an `ask` written there both raise.
35526
- *
35527
- * "Withheld no allow rule" alone does not answer it: a config with no `allow`
35528
- * rules has nothing to withhold, and a pattern the author also wrote under
35529
- * `bash` is a command on their own word. Both are excluded, so what remains is
35530
- * a `*` pattern that had allow rules to overlap, overlapped none of them, and
35531
- * is claimed as a command nowhere else — the shape `secrets/**` has.
35532
- *
35533
- * A `bash` restriction never belongs here: it names a command by construction,
35534
- * so overlapping no allow rule says nothing is wrong with it.
35535
- */
35536
- function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
35537
- if (!rules.some(({ action }) => action === "allow")) return [];
35538
- const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
35539
- return (0, es_toolkit.uniq)(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
35540
- }
35541
- /**
35542
- * Split shell-command rules into the allow and deny lists of a tool that models
35543
- * commands with those two tiers and nothing else.
35544
- *
35545
- * `ask` has no list of its own — such a tool already prompts for whatever it
35546
- * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
35547
- * still has to *withhold* the `allow` rules it covers, though: the canonical
35548
- * order is `deny > ask > allow`, so auto-approving a command the file also asks
35549
- * about would answer the prompt the author wanted.
35550
- *
35551
- * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
35552
- * from the all-tools `*` category. Warp's cannot: it matches commands with
35553
- * regular expressions rather than globs, and writing any denylist **replaces**
35554
- * Warp's built-in default one, so an inert `secrets/**` entry there would trade
35555
- * the tool's own protection for a rule that matches no command. Where the deny
35556
- * cannot be written it withholds the allow rules it covers instead, which
35557
- * restricts in the same direction without touching the denylist.
35558
- *
35559
- * A `bash` deny withholds nothing: it names a command by construction, so the
35560
- * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
35561
- * and a narrow deny keeps carving an exception out of a wider allow (`git *`
35562
- * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
35563
- * even where it is written: a pattern under `*` need not name a command —
35564
- * `secrets/**` there denies a path — so as a denylist entry it may match nothing
35565
- * at all, and leaving an overlapping allow beside it would auto-approve the very
35566
- * commands the author meant to stop. Over-restricting a `*` deny that *was* a
35567
- * command pattern is reported; failing open would not be.
35568
- *
35569
- * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
35570
- * patterns are not globs.
35571
- */
35572
- function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
35573
- const deny = [];
35574
- const unwrittenDenyPatterns = [];
35575
- const restrictions = [];
35576
- const writtenAllToolsDenyPatterns = [];
35577
- const allToolsAskPatterns = [];
35578
- for (const rule of rules) {
35579
- const { pattern, action, fromAllToolsCategory } = rule;
35580
- if (action === "allow") continue;
35581
- if (action !== "deny") {
35582
- restrictions.push(rule);
35583
- if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
35584
- continue;
35585
- }
35586
- if (writesAllToolsDeny || !fromAllToolsCategory) {
35587
- deny.push(pattern);
35588
- if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
35589
- } else unwrittenDenyPatterns.push(pattern);
35590
- if (fromAllToolsCategory) restrictions.push(rule);
35591
- }
35592
- const budget = createIntersectionBudget();
35593
- const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
35594
- normalizePattern,
35595
- budget
35596
- });
35597
- const allow = [];
35598
- const shadowedAllowPatterns = [];
35599
- const withholdingPatterns = /* @__PURE__ */ new Set();
35600
- for (const { pattern, action } of rules) {
35601
- if (action !== "allow") continue;
35602
- const shadowing = shadowingRestrictions(pattern);
35603
- if (shadowing.length > 0) {
35604
- shadowedAllowPatterns.push(pattern);
35605
- for (const restriction of shadowing) withholdingPatterns.add(restriction);
35606
- continue;
35607
- }
35608
- allow.push(pattern);
35609
- }
35610
- return {
35611
- allow,
35612
- deny,
35613
- shadowedAllowPatterns,
35614
- unwrittenDenyPatterns,
35615
- unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35616
- rules,
35617
- allToolsPatterns: writtenAllToolsDenyPatterns,
35618
- withholdingPatterns
35619
- }),
35620
- unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35621
- rules,
35622
- allToolsPatterns: allToolsAskPatterns,
35623
- withholdingPatterns
35624
- }),
35625
- intersectionBudgetExhausted: budget.remaining === 0
35626
- };
35627
- }
35628
- /**
35629
- * Report, for one command-only tool, every canonical rule its two lists could
35630
- * not carry. Every command-only adapter shares this reporting, so a rule
35631
- * dropped in one is worded the same way in all.
35632
- */
35633
- function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
35634
- 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.`);
35635
- 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.`);
35636
- 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.`);
35637
- 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.`);
35638
- 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.`);
35639
- 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.`);
35640
- 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.`);
35641
- }
35642
36351
  //#endregion
35643
36352
  //#region src/features/permissions/claudecode-permissions.ts
35644
36353
  /**
@@ -37400,7 +38109,7 @@ function mergeFilesystemCategoryRules({ categoryRules, logger }) {
37400
38109
  return merged;
37401
38110
  }
37402
38111
  function buildCodexBashRulesContent(config) {
37403
- const bashRules = config.permission.bash ?? {};
38112
+ const bashRules = bashRulesHonoringAllTools(config.permission);
37404
38113
  const entries = Object.entries(bashRules);
37405
38114
  const header = ["# Generated by Rulesync from .rulesync/permissions.jsonc (permission.bash)", "# https://developers.openai.com/codex/rules"];
37406
38115
  if (entries.length === 0) return [...header, "# No bash permission rules were configured."].join("\n");
@@ -38091,7 +38800,7 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
38091
38800
  function convertRulesyncToCursorPermissions(config, logger) {
38092
38801
  const allow = [];
38093
38802
  const deny = [];
38094
- for (const [category, rules] of Object.entries(config.permission)) {
38803
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
38095
38804
  const cursorType = toCursorType(category);
38096
38805
  for (const [pattern, action] of Object.entries(rules)) {
38097
38806
  const entry = buildCursorPermissionEntry(cursorType, toCursorPattern(category, pattern));
@@ -38910,7 +39619,7 @@ function convertRulesyncToDevinPermissions(config) {
38910
39619
  const allow = [];
38911
39620
  const ask = [];
38912
39621
  const deny = [];
38913
- for (const [category, rules] of Object.entries(config.permission)) {
39622
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
38914
39623
  const scope = toDevinScope(category);
38915
39624
  for (const [pattern, action] of Object.entries(rules)) {
38916
39625
  const entry = buildDevinPermissionEntry(scope, pattern);
@@ -39198,6 +39907,19 @@ var GoosePermissions = class GoosePermissions extends ToolPermissions {
39198
39907
  isDeletable() {
39199
39908
  return false;
39200
39909
  }
39910
+ /**
39911
+ * `permission.yaml` is Goose's file, not one rulesync owns: rulesync merges
39912
+ * into it when it exists but has no business bringing it into existence to
39913
+ * hold nothing. When no rule maps, the `user` block holds three empty lists,
39914
+ * which would otherwise be written as a fresh permission.yaml that says
39915
+ * nothing — an absent file and empty lists both mean "no user override, so
39916
+ * Goose decides on its own". An existing file is still rewritten as before,
39917
+ * so user content is never dropped — the skip only applies when there is no
39918
+ * file yet.
39919
+ */
39920
+ shouldSkipCreationWhenPayloadEmpty() {
39921
+ return true;
39922
+ }
39201
39923
  static getSettablePaths(_options) {
39202
39924
  return {
39203
39925
  relativeDirPath: GOOSE_GLOBAL_DIR,
@@ -39282,7 +40004,7 @@ function convertRulesyncToGoosePermissionConfig({ config, logger }) {
39282
40004
  never_allow: []
39283
40005
  };
39284
40006
  const assigned = /* @__PURE__ */ new Map();
39285
- const orderedEntries = Object.entries(config.permission).toSorted(([a], [b]) => (a === "edit" ? 1 : 0) - (b === "edit" ? 1 : 0));
40007
+ const orderedEntries = Object.entries(honorAllToolsOnBash(config.permission)).toSorted(([a], [b]) => (a === "edit" ? 1 : 0) - (b === "edit" ? 1 : 0));
39286
40008
  for (const [category, rules] of orderedEntries) {
39287
40009
  const toolName = RULESYNC_TO_GOOSE_TOOL_NAME[category] ?? category;
39288
40010
  for (const [pattern, action] of Object.entries(rules)) {
@@ -39614,7 +40336,7 @@ function unmanagedEntries(existingPermission, key) {
39614
40336
  */
39615
40337
  function buildGrokPermissionArrays(config, existingPermission, logger) {
39616
40338
  const ranked = /* @__PURE__ */ new Map();
39617
- for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
40339
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) for (const [pattern, action] of Object.entries(rules)) {
39618
40340
  const entry = buildGrokEntry(category, pattern);
39619
40341
  if (entry === null) {
39620
40342
  if (action === "deny" && logger) logger.warn(`Grok CLI has no permission tool for the '${category}' category; its 'deny' rule could not be represented and was skipped.`);
@@ -39703,6 +40425,8 @@ function deriveGrokPermissionMode(config) {
39703
40425
  function patternsByAction(category, action) {
39704
40426
  return Object.entries(category ?? {}).filter(([, value]) => value === action).map(([pattern]) => pattern);
39705
40427
  }
40428
+ /** The canonical category whose `deny` rules feed `security.website_blocklist`. */
40429
+ const WEBFETCH_PERMISSION_CATEGORY = "webfetch";
39706
40430
  function clonePermissionBlock(permission) {
39707
40431
  return Object.fromEntries(Object.entries(permission).map(([category, rules]) => [category, { ...rules }]));
39708
40432
  }
@@ -39715,18 +40439,39 @@ function ensureCategory(permission, category) {
39715
40439
  function removeEmptyCategories(permission) {
39716
40440
  for (const [category, rules] of Object.entries(permission)) if (Object.keys(rules).length === 0) delete permission[category];
39717
40441
  }
40442
+ /**
40443
+ * Make the native `command_allowlist` authoritative for the `bash` allow rules
40444
+ * it can speak for, and for those only. The allowlist is a list of shell-command
40445
+ * patterns, so it is generated from `bash` alone (see `fromRulesyncPermissions`);
40446
+ * an allow in any other category names something Hermes's allowlist cannot
40447
+ * carry, so its absence from the list says nothing about it and it is kept as
40448
+ * provenance wrote it. The same holds for a `bash` allow the generator withheld
40449
+ * because a stricter `*` or `bash` rule covers it: the allowlist never carried
40450
+ * it, so its absence is not a retraction and the rule is kept too.
40451
+ */
39718
40452
  function reconcileCommandAllowlist({ permission, commandAllowlist }) {
39719
- const nativeAllows = new Set(commandAllowlist);
39720
- const existingAllowCategories = /* @__PURE__ */ new Map();
39721
- for (const [category, rules] of Object.entries(permission)) for (const [pattern, action] of Object.entries(rules)) {
39722
- if (action !== "allow") continue;
39723
- existingAllowCategories.set(pattern, category);
39724
- if (!nativeAllows.has(pattern)) delete rules[pattern];
39725
- }
39726
- for (const pattern of nativeAllows) {
39727
- const existingCategory = existingAllowCategories.get(pattern);
39728
- if (existingCategory) ensureCategory(permission, existingCategory)[pattern] = "allow";
39729
- else ensureCategory(permission, "bash")[pattern] = "allow";
40453
+ const { rules: commandRules } = collectShellCommandRules(permission);
40454
+ const { shadowedAllowPatterns } = partitionCommandRules({
40455
+ rules: commandRules,
40456
+ writesAllToolsDeny: false
40457
+ });
40458
+ const withheld = new Set(shadowedAllowPatterns);
40459
+ const rules = ensureCategory(permission, SHELL_PERMISSION_CATEGORY);
40460
+ for (const [pattern, action] of Object.entries(rules)) if (action === "allow" && !withheld.has(pattern)) delete rules[pattern];
40461
+ for (const pattern of commandAllowlist) rules[pattern] = "allow";
40462
+ }
40463
+ /**
40464
+ * Report the restricting rules Hermes has no per-pattern primitive for: a
40465
+ * `deny` or `ask` in any category other than `bash`, `*`, and `webfetch`, and
40466
+ * an `ask` under `webfetch` — the blocklist carries a `webfetch` deny but has
40467
+ * no ask tier. (`bash` and `*` are reported by `warnAboutUnwrittenCommandRules`.)
40468
+ * Such rules survive only in the round-trip blob.
40469
+ */
40470
+ function warnAboutUnexpressedHermesRestrictions({ permissionBlock, foreignRestrictingCategories, logger }) {
40471
+ for (const category of foreignRestrictingCategories) {
40472
+ const isWebfetch = category === WEBFETCH_PERMISSION_CATEGORY;
40473
+ if (isWebfetch && patternsByAction(permissionBlock[category], "ask").length === 0) continue;
40474
+ warnWithFallback(logger, isWebfetch ? "Hermes Agent's security.website_blocklist has no ask tier, so the 'webfetch' ask rule(s) cannot be represented and were skipped; they survive only in the permissions.rulesync round-trip block." : `Hermes Agent has no per-pattern primitive for '${category}' deny and ask rules (it enforces command_allowlist, approvals.deny, and security.website_blocklist), so they were skipped; they survive only in the permissions.rulesync round-trip block.`);
39730
40475
  }
39731
40476
  }
39732
40477
  function reconcileNativeDenies({ permission, category, patterns }) {
@@ -39838,14 +40583,14 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
39838
40583
  const approvals = isRecord$1(config.approvals) ? config.approvals : {};
39839
40584
  reconcileNativeDenies({
39840
40585
  permission,
39841
- category: "bash",
40586
+ category: SHELL_PERMISSION_CATEGORY,
39842
40587
  patterns: isStringArray$2(approvals.deny) ? approvals.deny : []
39843
40588
  });
39844
40589
  const security = isRecord$1(config.security) ? config.security : {};
39845
40590
  const websiteBlocklist = isRecord$1(security.website_blocklist) ? security.website_blocklist : {};
39846
40591
  reconcileNativeDenies({
39847
40592
  permission,
39848
- category: "webfetch",
40593
+ category: WEBFETCH_PERMISSION_CATEGORY,
39849
40594
  patterns: websiteBlocklist.enabled === true && isStringArray$2(websiteBlocklist.domains) ? websiteBlocklist.domains : []
39850
40595
  });
39851
40596
  removeEmptyCategories(permission);
@@ -39865,12 +40610,32 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
39865
40610
  fileContent: JSON.stringify(imported, null, 2)
39866
40611
  });
39867
40612
  }
39868
- static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false }) {
40613
+ static fromRulesyncPermissions({ outputRoot, rulesyncPermissions, global = false, logger }) {
39869
40614
  const permissions = rulesyncPermissions.getJson();
39870
40615
  const permissionBlock = permissions.permission ?? {};
39871
- const commandAllowlist = Object.entries(permissionBlock).flatMap(([, patterns]) => patternsByAction(patterns, "allow"));
39872
- const bashDeny = patternsByAction(permissionBlock.bash, "deny");
39873
- const webfetchDeny = patternsByAction(permissionBlock.webfetch, "deny");
40616
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permissionBlock);
40617
+ const { allow: commandAllowlist, deny: bashDeny, shadowedAllowPatterns, unwrittenDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
40618
+ rules,
40619
+ writesAllToolsDeny: false
40620
+ });
40621
+ warnAboutUnexpressedHermesRestrictions({
40622
+ permissionBlock,
40623
+ foreignRestrictingCategories,
40624
+ logger
40625
+ });
40626
+ warnAboutUnwrittenCommandRules({
40627
+ toolLabel: "Hermes Agent",
40628
+ surfaceLabel: "command_allowlist/approvals.deny",
40629
+ foreignRestrictingCategories: [],
40630
+ shadowedAllowPatterns,
40631
+ unwrittenDenyPatterns,
40632
+ unwrittenDenyReason: "approvals.deny is a hard denylist of shell commands, and a pattern written under '*' need not be a command at all.",
40633
+ unenforcedAllToolsAskPatterns,
40634
+ ignoredAllToolsAllowPatterns,
40635
+ intersectionBudgetExhausted,
40636
+ logger
40637
+ });
40638
+ const webfetchDeny = patternsByAction(permissionBlock[WEBFETCH_PERMISSION_CATEGORY], "deny");
39874
40639
  let config = {};
39875
40640
  if (commandAllowlist.length > 0) config.command_allowlist = commandAllowlist;
39876
40641
  if (bashDeny.length > 0) config.approvals = { deny: bashDeny };
@@ -40137,7 +40902,7 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
40137
40902
  */
40138
40903
  function convertRulesyncToJunieRules({ config, logger, existingRules, overrideSecretFile, overrideRuleDefaults }) {
40139
40904
  const ruleLists = {};
40140
- for (const [category, patterns] of Object.entries(config.permission)) {
40905
+ for (const [category, patterns] of Object.entries(honorAllToolsOnBash(config.permission))) {
40141
40906
  const group = CANONICAL_TO_JUNIE_GROUP[category];
40142
40907
  if (!group) {
40143
40908
  if (Object.keys(patterns).length > 0) logger?.warn(`Junie allowlist only models executables/fileEditing/mcpTools/readOutsideProject (canonical bash/edit/write/read/mcp); '${category}' rules cannot be represented and were skipped.`);
@@ -40387,7 +41152,7 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
40387
41152
  const rulesyncJson = rulesyncPermissions.getJson();
40388
41153
  const kiloOverride = rulesyncJson.kilo;
40389
41154
  const incomingPermission = {
40390
- ...rulesyncJson.permission,
41155
+ ...honorAllToolsOnBash(rulesyncJson.permission),
40391
41156
  ...kiloOverride?.permission
40392
41157
  };
40393
41158
  const droppedDenyByKey = {};
@@ -40906,7 +41671,7 @@ function buildKiroPermissionsFromRulesync({ config, logger, existing }) {
40906
41671
  allowedCommands: [],
40907
41672
  deniedCommands: []
40908
41673
  };
40909
- for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
41674
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) for (const [pattern, action] of Object.entries(rules)) {
40910
41675
  if (action === "ask") {
40911
41676
  logger?.warn(`Kiro permissions do not support "ask". Skipping ${category}:${pattern}`);
40912
41677
  continue;
@@ -41190,7 +41955,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
41190
41955
  const rulesyncJson = rulesyncPermissions.getJson();
41191
41956
  const overridePermission = rulesyncJson.opencode?.permission ?? {};
41192
41957
  const sharedPermission = {};
41193
- for (const [category, value] of Object.entries(rulesyncJson.permission ?? {})) sharedPermission[toOpencodePermissionKey(category)] = value;
41958
+ for (const [category, value] of Object.entries(honorAllToolsOnBash(rulesyncJson.permission ?? {}))) sharedPermission[toOpencodePermissionKey(category)] = value;
41194
41959
  const permission = {};
41195
41960
  for (const [category, value] of Object.entries({
41196
41961
  ...sharedPermission,
@@ -42319,11 +43084,19 @@ var RooPermissions = class extends ToolPermissions {
42319
43084
  const paths = this.getSettablePaths();
42320
43085
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
42321
43086
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
42322
- const rules = rulesyncPermissions.getJson().permission[COMMAND_CATEGORY];
43087
+ const permission = rulesyncPermissions.getJson().permission;
43088
+ const bashStated = permission[COMMAND_CATEGORY] !== void 0;
42323
43089
  const patch = {};
42324
- if (rules !== void 0) {
43090
+ if (bashStated) {
43091
+ const { bash } = resolveShellCommandLists({
43092
+ permission,
43093
+ writesAllToolsDeny: true,
43094
+ toolLabel: this.getToolLabel(),
43095
+ surfaceLabel: `${this.getAllowedCommandsKey()}/${this.getDeniedCommandsKey()}`,
43096
+ logger
43097
+ });
42325
43098
  const { allowed, denied } = buildVscodeCommandLists({
42326
- rules,
43099
+ rules: bash,
42327
43100
  toolLabel: this.getToolLabel(),
42328
43101
  logger
42329
43102
  });
@@ -42334,7 +43107,7 @@ var RooPermissions = class extends ToolPermissions {
42334
43107
  outputRoot,
42335
43108
  relativeDirPath: paths.relativeDirPath,
42336
43109
  relativeFilePath: paths.relativeFilePath,
42337
- ownsCommandKeys: rules !== void 0,
43110
+ ownsCommandKeys: bashStated,
42338
43111
  fileContent: applySharedConfigPatch({
42339
43112
  fileKey: sharedConfigFileKey(paths),
42340
43113
  feature: "permissions",
@@ -42714,7 +43487,7 @@ function convertRulesyncToRovodevToolPermissions({ config, logger }) {
42714
43487
  config,
42715
43488
  logger
42716
43489
  });
42717
- for (const [category, rules] of Object.entries(config.permission)) {
43490
+ for (const [category, rules] of Object.entries(honorAllToolsOnBash(config.permission))) {
42718
43491
  if (category === CATCH_ALL_PATTERN$1) {
42719
43492
  const toolWideDefault = convertAllToolsRules({
42720
43493
  rules,
@@ -43724,7 +44497,7 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
43724
44497
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43725
44498
  const existingContent = await readFileContentOrNull(filePath) ?? "";
43726
44499
  const config = parseVibeConfig(existingContent);
43727
- const permission = rulesyncPermissions.getJson().permission;
44500
+ const permission = honorAllToolsOnBash(rulesyncPermissions.getJson().permission);
43728
44501
  const vibeOverride = rulesyncPermissions.getJson().vibe;
43729
44502
  const tools = toVibeToolsRecord(config.tools);
43730
44503
  const diskShellPatterns = new Map([VIBE_SHELL_CATEGORY, ...VIBE_SHELL_ALIAS_TOOL_NAMES].map((vibeToolName) => [vibeToolName, toStringArray(readVibeToolConfig({
@@ -45065,6 +45838,19 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
45065
45838
  isDeletable() {
45066
45839
  return false;
45067
45840
  }
45841
+ /**
45842
+ * `settings.toml` is Warp's file, not one rulesync owns: rulesync merges into
45843
+ * it when it exists but has no business bringing it into existence to hold
45844
+ * nothing. When no rule maps, both command lists are dropped and the payload
45845
+ * is a bare `[agents.profiles]` table, which would otherwise be written as a
45846
+ * fresh settings file that says nothing — an absent file and an empty table
45847
+ * both mean "Warp's own defaults". An existing file is still rewritten as
45848
+ * before, so user content is never dropped — the skip only applies when
45849
+ * there is no file yet.
45850
+ */
45851
+ shouldSkipCreationWhenPayloadEmpty() {
45852
+ return true;
45853
+ }
45068
45854
  static getSettablePaths(_options) {
45069
45855
  return {
45070
45856
  relativeDirPath: warpSettingsDir(),
@@ -45676,6 +46462,7 @@ function buildZedToolPermissions({ permission, logger }) {
45676
46462
  for (const [category, rules] of Object.entries(permission)) {
45677
46463
  if (category === "*") {
45678
46464
  for (const [pattern, action] of Object.entries(rules)) if (pattern === "*") managedDefault = CANONICAL_TO_ZED_ACTION[action];
46465
+ else if (permission.bash?.[pattern] === "deny" || permission.bash?.[pattern] === "ask") continue;
45679
46466
  else logger?.warn(`Zed permissions: dropping the "*" category rule for pattern "${pattern}" — Zed's global tool-permission default takes no patterns; scope the rule to a tool category instead.`);
45680
46467
  continue;
45681
46468
  }
@@ -45830,7 +46617,7 @@ var ZedPermissions = class ZedPermissions extends ToolPermissions {
45830
46617
  const toolPermissions = asRecord(agent.tool_permissions);
45831
46618
  const existingTools = asRecord(toolPermissions.tools);
45832
46619
  const { managedDefault, managedTools, excludedCategories, inertMcpCategories } = buildZedToolPermissions({
45833
- permission: config.permission,
46620
+ permission: honorAllToolsOnBash(config.permission),
45834
46621
  logger
45835
46622
  });
45836
46623
  if (excludedCategories.length > 0) logger?.warn(`Zed permissions: dropping the ${excludedCategories.map((category) => `"${category}"`).join(", ")} ${excludedCategories.length === 1 ? "category" : "categories"} — Zed does not gate its read-only tools, so the entries would never be consulted. Zed's read-denial surface is \`private_files\`, which the ignore feature writes from \`.rulesync/.aiignore\`.`);
@@ -46339,6 +47126,24 @@ var PermissionsProcessor = class extends FeatureProcessor {
46339
47126
  }
46340
47127
  };
46341
47128
  //#endregion
47129
+ //#region src/constants/codebuddy-paths.ts
47130
+ /**
47131
+ * CodeBuddy Code configuration-layout conventions.
47132
+ *
47133
+ * CodeBuddy Code (`@tencent-ai/codebuddy-code`) is Tencent Cloud's terminal
47134
+ * coding agent. Its configuration surface mirrors Claude Code closely: a
47135
+ * root memory file plus a `.codebuddy/` tree.
47136
+ *
47137
+ * @see https://www.codebuddy.ai/docs/cli/memory
47138
+ * @see https://www.codebuddy.ai/docs/cli/codebuddy-dir
47139
+ */
47140
+ /** Root directory for CodeBuddy Code configuration, relative to the scope root. */
47141
+ const CODEBUDDY_DIR = ".codebuddy";
47142
+ const CODEBUDDY_RULE_FILE_NAME = "CODEBUDDY.md";
47143
+ const CODEBUDDY_LOCAL_RULE_FILE_NAME = "CODEBUDDY.local.md";
47144
+ /** Modular rules directory name under `.codebuddy/`. */
47145
+ const CODEBUDDY_RULES_DIR_NAME = "rules";
47146
+ //#endregion
46342
47147
  //#region src/features/skills/simulated-skill.ts
46343
47148
  const SimulatedSkillFrontmatterSchema = zod_mini.z.looseObject({
46344
47149
  name: zod_mini.z.string(),
@@ -46877,7 +47682,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46877
47682
  * This only deletes directories that are no longer in the rulesync source, not directories that will be overwritten.
46878
47683
  */
46879
47684
  async removeOrphanAiDirs(existingDirs, generatedDirs) {
46880
- const generatedPaths = new Set(generatedDirs.map((d) => d.getDirPath()));
47685
+ const generatedPaths = new Set(generatedDirs.map((d) => caseFoldIdentity(d.getDirPath())));
46881
47686
  const orphanPaths = /* @__PURE__ */ new Set();
46882
47687
  const quotedOutputRoot = quoteForLog(this.outputRoot);
46883
47688
  for (const aiDir of existingDirs) {
@@ -46902,7 +47707,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
46902
47707
  this.logger.warn(verdict === "equal" ? `Refusing to delete ${quotedDirPath}: it is the root it was found in, not a directory inside that root` : `Refusing to delete ${quotedDirPath}: it is not inside ${quotedRoot}, the root it was found in`);
46903
47708
  continue;
46904
47709
  }
46905
- if (!generatedPaths.has(dirPath)) orphanPaths.add(dirPath);
47710
+ if (!generatedPaths.has(caseFoldIdentity(dirPath))) orphanPaths.add(dirPath);
46906
47711
  }
46907
47712
  return await this.deleteOrphanPaths({
46908
47713
  paths: orphanPaths,
@@ -47001,7 +47806,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47001
47806
  const mainFile = aiDir.getMainFile();
47002
47807
  if (mainFile) generatedNames.add(toPosixPath(mainFile.name));
47003
47808
  for (const file of aiDir.getOtherFiles()) generatedNames.add(toPosixPath(file.relativeFilePathToDirPath));
47004
- const generatedNamesFolded = new Set([...generatedNames].map((name) => name.toLowerCase()));
47809
+ const generatedNamesFolded = new Set([...generatedNames].map((name) => caseFoldIdentity(name)));
47005
47810
  let existingNames;
47006
47811
  try {
47007
47812
  existingNames = await listFilePathsRecursively(dirPath, {
@@ -47016,7 +47821,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47016
47821
  const posixName = toPosixPath(existingName);
47017
47822
  if (generatedNames.has(posixName)) continue;
47018
47823
  const filePath = (0, node_path.join)(dirPath, existingName);
47019
- if (generatedNamesFolded.has(posixName.toLowerCase())) {
47824
+ if (generatedNamesFolded.has(caseFoldIdentity(posixName))) {
47020
47825
  this.logger.warn(`Refusing to delete ${quoteForLog(filePath)}: this run wrote a file whose path differs from it only in case, which on a case-insensitive filesystem is the very file it wrote`);
47021
47826
  continue;
47022
47827
  }
@@ -47072,7 +47877,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47072
47877
  generatedPaths.add(flatFilePath);
47073
47878
  for (const file of generatedDir.getOtherFiles()) generatedPaths.add((0, node_path.join)(generatedDirPath, file.relativeFilePathToDirPath));
47074
47879
  }
47075
- const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => generatedPath.toLowerCase()));
47880
+ const generatedPathsFolded = new Set([...generatedPaths].map((generatedPath) => caseFoldIdentity(generatedPath)));
47076
47881
  const orphanPaths = /* @__PURE__ */ new Set();
47077
47882
  const quotedOutputRoot = quoteForLog(this.outputRoot);
47078
47883
  for (const aiDir of existingFlatFiles) {
@@ -47102,7 +47907,7 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
47102
47907
  continue;
47103
47908
  }
47104
47909
  if (generatedPaths.has(filePath)) continue;
47105
- if (generatedPathsFolded.has(filePath.toLowerCase())) {
47910
+ if (generatedPathsFolded.has(caseFoldIdentity(filePath))) {
47106
47911
  this.logger.warn(`Refusing to delete ${quotedFilePath}: this run wrote a file whose path differs from it only in case, which on a case-insensitive filesystem is the very file it wrote`);
47107
47912
  continue;
47108
47913
  }
@@ -48934,6 +49739,191 @@ var CopilotcliSkill = class CopilotcliSkill extends ToolSkill {
48934
49739
  }
48935
49740
  };
48936
49741
  //#endregion
49742
+ //#region src/features/skills/crush-skill.ts
49743
+ const CrushSkillFrontmatterSchema = zod_mini.z.looseObject({
49744
+ name: zod_mini.z.string(),
49745
+ description: zod_mini.z.string(),
49746
+ "user-invocable": zod_mini.z.optional(zod_mini.z.boolean()),
49747
+ "disable-model-invocation": zod_mini.z.optional(zod_mini.z.boolean()),
49748
+ license: zod_mini.z.optional(zod_mini.z.string()),
49749
+ compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
49750
+ metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
49751
+ });
49752
+ /**
49753
+ * Represents a Crush Agent Skill directory.
49754
+ *
49755
+ * Crush auto-discovers Agent Skills (`SKILL.md` per directory) from
49756
+ * `.crush/skills/` at project scope and `~/.config/crush/skills/` (or
49757
+ * `$CRUSH_SKILLS_DIR`) at global scope. Unless `$CRUSH_SKILLS_DIR` is set,
49758
+ * Crush also scans several shared directories it does not own (globally
49759
+ * `~/.config/agents/skills/`, `~/.agents/skills/`, `~/.claude/skills/`;
49760
+ * per-project `.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, also
49761
+ * checked at a git worktree's common root); this class writes only to the
49762
+ * Crush-specific path above, leaving those shared roots to their own targets.
49763
+ *
49764
+ * Crush's `UserInvocable` field is a non-pointer Go `bool`, so an omitted
49765
+ * `user-invocable` (at both the root and the `crush:` section) resolves to
49766
+ * `false`: the skill stays reachable by the model but is hidden from Crush's
49767
+ * command palette. See `FromSkillCatalog` in `internal/commands/commands.go`.
49768
+ * @see https://github.com/charmbracelet/crush/blob/main/internal/config/load.go
49769
+ */
49770
+ var CrushSkill = class CrushSkill extends ToolSkill {
49771
+ constructor({ outputRoot = process.cwd(), relativeDirPath = CRUSH_SKILLS_PROJECT_DIR, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
49772
+ super({
49773
+ outputRoot,
49774
+ relativeDirPath,
49775
+ dirName,
49776
+ mainFile: {
49777
+ name: SKILL_FILE_NAME,
49778
+ body,
49779
+ frontmatter: { ...frontmatter }
49780
+ },
49781
+ otherFiles,
49782
+ global
49783
+ });
49784
+ if (validate) {
49785
+ const result = this.validate();
49786
+ if (!result.success) throw result.error;
49787
+ }
49788
+ }
49789
+ static getSettablePaths({ global = false } = {}) {
49790
+ return { relativeDirPath: global ? CRUSH_SKILLS_GLOBAL_DIR : CRUSH_SKILLS_PROJECT_DIR };
49791
+ }
49792
+ getFrontmatter() {
49793
+ return CrushSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
49794
+ }
49795
+ getBody() {
49796
+ return this.mainFile?.body ?? "";
49797
+ }
49798
+ validate() {
49799
+ if (!this.mainFile) return {
49800
+ success: false,
49801
+ error: /* @__PURE__ */ new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`)
49802
+ };
49803
+ const result = CrushSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter);
49804
+ if (!result.success) return {
49805
+ success: false,
49806
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`)
49807
+ };
49808
+ return {
49809
+ success: true,
49810
+ error: null
49811
+ };
49812
+ }
49813
+ toRulesyncSkill() {
49814
+ const frontmatter = this.getFrontmatter();
49815
+ const crushSection = {
49816
+ ...frontmatter["user-invocable"] !== void 0 && { "user-invocable": frontmatter["user-invocable"] },
49817
+ ...frontmatter["disable-model-invocation"] !== void 0 && { "disable-model-invocation": frontmatter["disable-model-invocation"] },
49818
+ ...frontmatter.license !== void 0 && { license: frontmatter.license },
49819
+ ...frontmatter.compatibility !== void 0 && { compatibility: frontmatter.compatibility },
49820
+ ...frontmatter.metadata !== void 0 && { metadata: frontmatter.metadata }
49821
+ };
49822
+ const rulesyncFrontmatter = {
49823
+ name: frontmatter.name,
49824
+ description: frontmatter.description,
49825
+ targets: ["*"],
49826
+ ...Object.keys(crushSection).length > 0 && { crush: crushSection }
49827
+ };
49828
+ return new RulesyncSkill({
49829
+ outputRoot: this.outputRoot,
49830
+ relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
49831
+ dirName: this.getDirName(),
49832
+ frontmatter: rulesyncFrontmatter,
49833
+ body: this.getBody(),
49834
+ otherFiles: this.getOtherFiles(),
49835
+ validate: true,
49836
+ global: this.global
49837
+ });
49838
+ }
49839
+ static fromRulesyncSkill({ outputRoot = process.cwd(), rulesyncSkill, validate = true, global = false }) {
49840
+ const settablePaths = CrushSkill.getSettablePaths({ global });
49841
+ const rulesyncFrontmatter = rulesyncSkill.getFrontmatter();
49842
+ const crushSection = rulesyncFrontmatter.crush;
49843
+ const resolvedUserInvocable = resolveUserInvocable({
49844
+ rootFrontmatter: rulesyncFrontmatter,
49845
+ section: crushSection
49846
+ });
49847
+ const resolvedDisableModelInvocation = resolveDisableModelInvocation({
49848
+ rootFrontmatter: rulesyncFrontmatter,
49849
+ section: crushSection
49850
+ });
49851
+ const license = resolveLicense({
49852
+ rootFrontmatter: rulesyncFrontmatter,
49853
+ section: crushSection
49854
+ });
49855
+ const compatibility = resolveCompatibility({
49856
+ rootFrontmatter: rulesyncFrontmatter,
49857
+ section: crushSection
49858
+ });
49859
+ const metadata = resolveMetadata({
49860
+ rootFrontmatter: rulesyncFrontmatter,
49861
+ section: crushSection
49862
+ });
49863
+ const compatibilityString = compatibility === void 0 ? void 0 : toCompatibilityString(compatibility);
49864
+ const crushFrontmatter = {
49865
+ name: rulesyncFrontmatter.name,
49866
+ description: rulesyncFrontmatter.description,
49867
+ ...resolvedUserInvocable !== void 0 && { "user-invocable": resolvedUserInvocable },
49868
+ ...resolvedDisableModelInvocation !== void 0 && { "disable-model-invocation": resolvedDisableModelInvocation },
49869
+ ...license !== void 0 && { license },
49870
+ ...compatibilityString !== void 0 && compatibilityString.length > 0 && { compatibility: compatibilityString },
49871
+ ...metadata !== void 0 && { metadata: toStringMetadata(metadata) }
49872
+ };
49873
+ return new CrushSkill({
49874
+ outputRoot,
49875
+ relativeDirPath: settablePaths.relativeDirPath,
49876
+ dirName: rulesyncSkill.getDirName(),
49877
+ frontmatter: crushFrontmatter,
49878
+ body: rulesyncSkill.getBody(),
49879
+ otherFiles: rulesyncSkill.getOtherFiles(),
49880
+ validate,
49881
+ global
49882
+ });
49883
+ }
49884
+ static isTargetedByRulesyncSkill(rulesyncSkill) {
49885
+ const targets = rulesyncSkill.getFrontmatter().targets;
49886
+ return targets.includes("*") || targets.includes("crush");
49887
+ }
49888
+ static async fromDir(params) {
49889
+ const loaded = await this.loadSkillDirContent({
49890
+ ...params,
49891
+ getSettablePaths: CrushSkill.getSettablePaths
49892
+ });
49893
+ const result = CrushSkillFrontmatterSchema.safeParse(loaded.frontmatter);
49894
+ if (!result.success) {
49895
+ const skillDirPath = (0, node_path.join)(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName);
49896
+ throw new Error(`Invalid frontmatter in ${(0, node_path.join)(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`);
49897
+ }
49898
+ return new CrushSkill({
49899
+ outputRoot: loaded.outputRoot,
49900
+ relativeDirPath: loaded.relativeDirPath,
49901
+ dirName: loaded.dirName,
49902
+ frontmatter: result.data,
49903
+ body: loaded.body,
49904
+ otherFiles: loaded.otherFiles,
49905
+ validate: true,
49906
+ global: loaded.global
49907
+ });
49908
+ }
49909
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, dirName, global = false }) {
49910
+ const settablePaths = CrushSkill.getSettablePaths({ global });
49911
+ return new CrushSkill({
49912
+ outputRoot,
49913
+ relativeDirPath: relativeDirPath ?? settablePaths.relativeDirPath,
49914
+ dirName,
49915
+ frontmatter: {
49916
+ name: "",
49917
+ description: ""
49918
+ },
49919
+ body: "",
49920
+ otherFiles: [],
49921
+ validate: false,
49922
+ global
49923
+ });
49924
+ }
49925
+ };
49926
+ //#endregion
48937
49927
  //#region src/features/skills/cursor-skill.ts
48938
49928
  const CursorSkillFrontmatterSchema = zod_mini.z.looseObject({
48939
49929
  name: zod_mini.z.string(),
@@ -49098,9 +50088,9 @@ const DeepagentsSkillFrontmatterSchema = zod_mini.z.looseObject({
49098
50088
  name: zod_mini.z.string(),
49099
50089
  description: zod_mini.z.string(),
49100
50090
  "allowed-tools": zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.array(zod_mini.z.string())])),
49101
- license: zod_mini.z.optional(zod_mini.z.string()),
49102
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
49103
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({}))
50091
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
50092
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
50093
+ metadata: zod_mini.z.optional(zod_mini.z.unknown())
49104
50094
  });
49105
50095
  var DeepagentsSkill = class DeepagentsSkill extends ToolSkill {
49106
50096
  constructor({ outputRoot = process.cwd(), relativeDirPath = DEEPAGENTS_SKILLS_DIR_PATH, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
@@ -50158,9 +51148,9 @@ var JunieSkill = class JunieSkill extends ToolSkill {
50158
51148
  const KiloSkillFrontmatterSchema = zod_mini.z.looseObject({
50159
51149
  name: zod_mini.z.string(),
50160
51150
  description: zod_mini.z.string(),
50161
- license: zod_mini.z.optional(zod_mini.z.string()),
50162
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
50163
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
51151
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
51152
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
51153
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
50164
51154
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
50165
51155
  });
50166
51156
  var KiloSkill = class KiloSkill extends ToolSkill {
@@ -50597,10 +51587,11 @@ var KiroSkill = class KiroSkill extends ToolSkill {
50597
51587
  rootFrontmatter: rulesyncFrontmatter,
50598
51588
  section: kiroSection
50599
51589
  });
51590
+ const { name: _sectionName, description: _sectionDescription, ...section } = kiroSection ?? {};
50600
51591
  const kiroFrontmatter = {
50601
- ...kiroSection,
50602
51592
  name: rulesyncFrontmatter.name,
50603
51593
  description: rulesyncFrontmatter.description,
51594
+ ...section,
50604
51595
  ...license !== void 0 && { license },
50605
51596
  ...compatibility !== void 0 && { compatibility },
50606
51597
  ...metadata !== void 0 && { metadata }
@@ -50832,9 +51823,9 @@ var MusecodeSkill = class MusecodeSkill extends ToolSkill {
50832
51823
  const OpenCodeSkillFrontmatterSchema = zod_mini.z.looseObject({
50833
51824
  name: zod_mini.z.string(),
50834
51825
  description: zod_mini.z.string(),
50835
- license: zod_mini.z.optional(zod_mini.z.string()),
50836
- compatibility: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.string(), zod_mini.z.looseObject({})])),
50837
- metadata: zod_mini.z.optional(zod_mini.z.looseObject({})),
51826
+ license: zod_mini.z.optional(zod_mini.z.unknown()),
51827
+ compatibility: zod_mini.z.optional(zod_mini.z.unknown()),
51828
+ metadata: zod_mini.z.optional(zod_mini.z.unknown()),
50838
51829
  "allowed-tools": zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
50839
51830
  });
50840
51831
  var OpenCodeSkill = class OpenCodeSkill extends ToolSkill {
@@ -52748,6 +53739,14 @@ const toolSkillFactories = /* @__PURE__ */ new Map([
52748
53739
  supportsGlobal: true
52749
53740
  }
52750
53741
  }],
53742
+ ["crush", {
53743
+ class: CrushSkill,
53744
+ meta: {
53745
+ supportsProject: true,
53746
+ supportsSimulated: false,
53747
+ supportsGlobal: true
53748
+ }
53749
+ }],
52751
53750
  ["cursor", {
52752
53751
  class: CursorSkill,
52753
53752
  meta: {
@@ -60491,6 +61490,238 @@ var ClineRule = class ClineRule extends ToolRule {
60491
61490
  }
60492
61491
  };
60493
61492
  //#endregion
61493
+ //#region src/features/rules/codebuddy-rule.ts
61494
+ /**
61495
+ * Frontmatter schema for CodeBuddy Code modular rules.
61496
+ * @see https://www.codebuddy.ai/docs/cli/memory
61497
+ */
61498
+ const CodebuddyRuleFrontmatterSchema = zod_mini.z.object({
61499
+ description: zod_mini.z.optional(zod_mini.z.string()),
61500
+ paths: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
61501
+ alwaysApply: zod_mini.z.optional(zod_mini.z.boolean())
61502
+ });
61503
+ /**
61504
+ * A universal glob (matching everything) is redundant on an Always Apply
61505
+ * rule and, paired with `alwaysApply: true`, is the same semantic conflict
61506
+ * `CursorRule.resolveCursorGlobs` avoids for Cursor: `alwaysApply` already
61507
+ * applies the rule everywhere, so also emitting an explicit
61508
+ * `paths: ["**\/*"]` is at best redundant and, on a subsequent
61509
+ * import/generate round-trip, misleadingly implies the rule is scoped by
61510
+ * path rather than always-on.
61511
+ */
61512
+ const UNIVERSAL_PATHS = /* @__PURE__ */ new Set(["**/*", "*"]);
61513
+ /**
61514
+ * Rule generator for CodeBuddy Code, Tencent Cloud's terminal coding agent
61515
+ * (`@tencent-ai/codebuddy-code`). Its configuration surface mirrors Claude
61516
+ * Code closely.
61517
+ *
61518
+ * Rules format:
61519
+ * - {project}/CODEBUDDY.md (root: true), also read from {project}/.codebuddy/CODEBUDDY.md
61520
+ * - {project}/.codebuddy/rules/*.md (root: false, with optional
61521
+ * `description` / `paths` / `alwaysApply` frontmatter)
61522
+ * - Global: ~/.codebuddy/CODEBUDDY.md and ~/.codebuddy/rules/*.md
61523
+ *
61524
+ * @see https://www.codebuddy.ai/docs/cli/memory
61525
+ * @see https://www.codebuddy.ai/docs/cli/codebuddy-dir
61526
+ */
61527
+ var CodebuddyRule = class CodebuddyRule extends ToolRule {
61528
+ frontmatter;
61529
+ body;
61530
+ static getSettablePaths({ global, excludeToolDir } = {}) {
61531
+ if (global) return {
61532
+ root: {
61533
+ relativeDirPath: buildToolPath(CODEBUDDY_DIR, ".", excludeToolDir),
61534
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61535
+ },
61536
+ nonRoot: { relativeDirPath: buildToolPath(CODEBUDDY_DIR, CODEBUDDY_RULES_DIR_NAME, excludeToolDir) }
61537
+ };
61538
+ return {
61539
+ root: {
61540
+ relativeDirPath: ".",
61541
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61542
+ },
61543
+ alternativeRoots: [{
61544
+ relativeDirPath: CODEBUDDY_DIR,
61545
+ relativeFilePath: CODEBUDDY_RULE_FILE_NAME
61546
+ }],
61547
+ nonRoot: { relativeDirPath: buildToolPath(CODEBUDDY_DIR, CODEBUDDY_RULES_DIR_NAME, excludeToolDir) }
61548
+ };
61549
+ }
61550
+ constructor({ frontmatter, body, ...rest }) {
61551
+ if (rest.validate) {
61552
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(frontmatter);
61553
+ if (!result.success) throw new Error(`Invalid frontmatter in ${(0, node_path.join)(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`);
61554
+ }
61555
+ super({
61556
+ ...rest,
61557
+ fileContent: rest.root ? body : CodebuddyRule.generateFileContent(body, frontmatter)
61558
+ });
61559
+ this.frontmatter = frontmatter;
61560
+ this.body = body;
61561
+ }
61562
+ static generateFileContent(body, frontmatter) {
61563
+ if (frontmatter.description === void 0 && frontmatter.paths === void 0 && frontmatter.alwaysApply === void 0) return body;
61564
+ return stringifyFrontmatter(body, {
61565
+ description: frontmatter.description,
61566
+ alwaysApply: frontmatter.alwaysApply,
61567
+ paths: frontmatter.paths
61568
+ });
61569
+ }
61570
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false, relativeDirPath: overrideDirPath }) {
61571
+ const paths = this.getSettablePaths({ global });
61572
+ if (relativeFilePath === paths.root.relativeFilePath) {
61573
+ const rootDirPath = overrideDirPath ?? paths.root.relativeDirPath;
61574
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, rootDirPath, paths.root.relativeFilePath));
61575
+ return new CodebuddyRule({
61576
+ outputRoot,
61577
+ relativeDirPath: rootDirPath,
61578
+ relativeFilePath: paths.root.relativeFilePath,
61579
+ frontmatter: {},
61580
+ body: fileContent.trim(),
61581
+ validate,
61582
+ root: true
61583
+ });
61584
+ }
61585
+ if (!paths.nonRoot) throw new Error(`nonRoot path is not set for ${relativeFilePath}`);
61586
+ const relativePath = (0, node_path.join)(paths.nonRoot.relativeDirPath, relativeFilePath);
61587
+ const filePath = (0, node_path.join)(outputRoot, relativePath);
61588
+ const { frontmatter, body: content } = parseFrontmatter(await readFileContent(filePath), filePath);
61589
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(frontmatter);
61590
+ if (!result.success) throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`);
61591
+ return new CodebuddyRule({
61592
+ outputRoot,
61593
+ relativeDirPath: paths.nonRoot.relativeDirPath,
61594
+ relativeFilePath,
61595
+ frontmatter: result.data,
61596
+ body: content.trim(),
61597
+ validate,
61598
+ root: false
61599
+ });
61600
+ }
61601
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
61602
+ const isRoot = relativeFilePath === this.getSettablePaths({ global }).root.relativeFilePath;
61603
+ return new CodebuddyRule({
61604
+ outputRoot,
61605
+ relativeDirPath,
61606
+ relativeFilePath,
61607
+ frontmatter: {},
61608
+ body: "",
61609
+ validate: false,
61610
+ root: isRoot
61611
+ });
61612
+ }
61613
+ static resolveCodebuddyPaths({ paths, alwaysApply }) {
61614
+ if (!paths || paths.length === 0) return;
61615
+ if (alwaysApply && paths.every((path) => UNIVERSAL_PATHS.has(path.trim()))) return;
61616
+ return paths;
61617
+ }
61618
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
61619
+ const rulesyncFrontmatter = rulesyncRule.getFrontmatter();
61620
+ const root = rulesyncFrontmatter.root ?? false;
61621
+ const paths = this.getSettablePaths({ global });
61622
+ const body = rulesyncRule.getBody();
61623
+ if (root) return new CodebuddyRule({
61624
+ outputRoot,
61625
+ frontmatter: {},
61626
+ body,
61627
+ relativeDirPath: paths.root.relativeDirPath,
61628
+ relativeFilePath: paths.root.relativeFilePath,
61629
+ validate,
61630
+ root
61631
+ });
61632
+ if (!paths.nonRoot) throw new Error(`nonRoot path is not set for ${rulesyncRule.getRelativeFilePath()}`);
61633
+ const codebuddyPaths = rulesyncFrontmatter.codebuddy?.paths;
61634
+ const globs = rulesyncFrontmatter.globs;
61635
+ const alwaysApply = rulesyncFrontmatter.codebuddy?.alwaysApply;
61636
+ const pathsValue = CodebuddyRule.resolveCodebuddyPaths({
61637
+ paths: codebuddyPaths ?? (globs?.length ? globs : void 0),
61638
+ alwaysApply: alwaysApply === true
61639
+ });
61640
+ const codebuddyFrontmatter = {
61641
+ description: rulesyncFrontmatter.codebuddy?.description ?? rulesyncFrontmatter.description,
61642
+ paths: pathsValue,
61643
+ alwaysApply
61644
+ };
61645
+ return new CodebuddyRule({
61646
+ outputRoot,
61647
+ frontmatter: codebuddyFrontmatter,
61648
+ body,
61649
+ relativeDirPath: paths.nonRoot.relativeDirPath,
61650
+ relativeFilePath: rulesyncRule.getRelativeFilePath(),
61651
+ validate,
61652
+ root
61653
+ });
61654
+ }
61655
+ toRulesyncRule() {
61656
+ const targets = ["*"];
61657
+ if (this.isRoot()) {
61658
+ const rulesyncFrontmatter = {
61659
+ targets,
61660
+ root: true,
61661
+ description: this.description,
61662
+ globs: ["**/*"]
61663
+ };
61664
+ return new RulesyncRule({
61665
+ outputRoot: this.getOutputRoot(),
61666
+ frontmatter: rulesyncFrontmatter,
61667
+ body: this.body,
61668
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
61669
+ relativeFilePath: this.getRelativeFilePath(),
61670
+ validate: true
61671
+ });
61672
+ }
61673
+ const isAlways = this.frontmatter.alwaysApply === true;
61674
+ const sourcePaths = this.frontmatter.paths ?? [];
61675
+ const globs = sourcePaths.length === 0 && isAlways ? ["**/*"] : sourcePaths;
61676
+ const rulesyncFrontmatter = {
61677
+ targets,
61678
+ root: false,
61679
+ description: this.frontmatter.description,
61680
+ globs,
61681
+ ...(this.frontmatter.paths !== void 0 || this.frontmatter.alwaysApply !== void 0 || this.frontmatter.description !== void 0) && { codebuddy: {
61682
+ paths: this.frontmatter.paths,
61683
+ alwaysApply: this.frontmatter.alwaysApply,
61684
+ description: this.frontmatter.description
61685
+ } }
61686
+ };
61687
+ return new RulesyncRule({
61688
+ outputRoot: this.getOutputRoot(),
61689
+ frontmatter: rulesyncFrontmatter,
61690
+ body: this.body,
61691
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
61692
+ relativeFilePath: this.getRelativeFilePath(),
61693
+ validate: true
61694
+ });
61695
+ }
61696
+ validate() {
61697
+ if (!this.frontmatter) return {
61698
+ success: true,
61699
+ error: null
61700
+ };
61701
+ const result = CodebuddyRuleFrontmatterSchema.safeParse(this.frontmatter);
61702
+ if (result.success) return {
61703
+ success: true,
61704
+ error: null
61705
+ };
61706
+ else return {
61707
+ success: false,
61708
+ error: /* @__PURE__ */ new Error(`Invalid frontmatter in ${(0, node_path.join)(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`)
61709
+ };
61710
+ }
61711
+ getFrontmatter() {
61712
+ return this.frontmatter;
61713
+ }
61714
+ getBody() {
61715
+ return this.body;
61716
+ }
61717
+ static isTargetedByRulesyncRule(rulesyncRule) {
61718
+ return this.isTargetedByRulesyncRuleDefault({
61719
+ rulesyncRule,
61720
+ toolTarget: "codebuddy"
61721
+ });
61722
+ }
61723
+ };
61724
+ //#endregion
60494
61725
  //#region src/features/rules/codexcli-rule.ts
60495
61726
  var CodexcliRule = class CodexcliRule extends ToolRule {
60496
61727
  constructor({ fileContent, root, ...rest }) {
@@ -60787,6 +62018,79 @@ var CopilotcliRule = class CopilotcliRule extends CopilotRule {
60787
62018
  }
60788
62019
  };
60789
62020
  //#endregion
62021
+ //#region src/features/rules/crush-rule.ts
62022
+ var CrushRule = class CrushRule extends ToolRule {
62023
+ constructor({ fileContent, root, ...rest }) {
62024
+ super({
62025
+ ...rest,
62026
+ fileContent,
62027
+ root: root ?? false
62028
+ });
62029
+ }
62030
+ static getSettablePaths({ global = false } = {}) {
62031
+ if (global) return { root: {
62032
+ relativeDirPath: CRUSH_GLOBAL_DIR,
62033
+ relativeFilePath: CRUSH_RULE_FILE_NAME
62034
+ } };
62035
+ return { root: {
62036
+ relativeDirPath: ".",
62037
+ relativeFilePath: CRUSH_RULE_FILE_NAME
62038
+ } };
62039
+ }
62040
+ static async fromFile({ outputRoot = process.cwd(), relativeFilePath: _relativeFilePath, validate = true, global = false }) {
62041
+ const { root } = this.getSettablePaths({ global });
62042
+ const relativePath = (0, node_path.join)(root.relativeDirPath, root.relativeFilePath);
62043
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
62044
+ return new CrushRule({
62045
+ outputRoot,
62046
+ relativeDirPath: root.relativeDirPath,
62047
+ relativeFilePath: root.relativeFilePath,
62048
+ fileContent,
62049
+ validate,
62050
+ root: true
62051
+ });
62052
+ }
62053
+ static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
62054
+ const { root } = this.getSettablePaths({ global });
62055
+ const isRoot = rulesyncRule.getFrontmatter().root ?? false;
62056
+ return new CrushRule({
62057
+ outputRoot,
62058
+ relativeDirPath: root.relativeDirPath,
62059
+ relativeFilePath: root.relativeFilePath,
62060
+ fileContent: rulesyncRule.getBody(),
62061
+ validate,
62062
+ root: isRoot
62063
+ });
62064
+ }
62065
+ toRulesyncRule() {
62066
+ return this.toRulesyncRuleDefault();
62067
+ }
62068
+ validate() {
62069
+ return {
62070
+ success: true,
62071
+ error: null
62072
+ };
62073
+ }
62074
+ static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
62075
+ const { root } = this.getSettablePaths({ global });
62076
+ const isRoot = relativeFilePath === root.relativeFilePath && relativeDirPath === root.relativeDirPath;
62077
+ return new CrushRule({
62078
+ outputRoot,
62079
+ relativeDirPath,
62080
+ relativeFilePath,
62081
+ fileContent: "",
62082
+ validate: false,
62083
+ root: isRoot
62084
+ });
62085
+ }
62086
+ static isTargetedByRulesyncRule(rulesyncRule) {
62087
+ return this.isTargetedByRulesyncRuleDefault({
62088
+ rulesyncRule,
62089
+ toolTarget: "crush"
62090
+ });
62091
+ }
62092
+ };
62093
+ //#endregion
60790
62094
  //#region src/features/rules/cursor-rule.ts
60791
62095
  const CursorRuleFrontmatterSchema = zod_mini.z.object({
60792
62096
  description: zod_mini.z.optional(zod_mini.z.string()),
@@ -61394,13 +62698,34 @@ var DevinRule = class DevinRule extends ToolRule {
61394
62698
  };
61395
62699
  //#endregion
61396
62700
  //#region src/features/rules/factorydroid-rule.ts
62701
+ /**
62702
+ * Rule generator for Factory Droid.
62703
+ *
62704
+ * Factory Droid loads the root `AGENTS.md` (project) / `~/.factory/AGENTS.md`
62705
+ * (global) as coding guidelines, plus non-root rules referenced from it via
62706
+ * `.factory/rules/*.md`.
62707
+ *
62708
+ * Factory Droid also loads `DESIGN.md` (project only) as a second,
62709
+ * independent instruction surface: "Always-on design-system, UX, visual, and
62710
+ * interaction guidance", loaded separately from `AGENTS.md`'s coding
62711
+ * guidelines. Rulesync emits it from any non-root rule that opts in via a
62712
+ * `factorydroid.channel: design` frontmatter block — those rule bodies are
62713
+ * routed to `DESIGN.md` instead of `AGENTS.md`/`.factory/rules/*.md`, and
62714
+ * multiple opted-in rules concatenate in source order. Factory's docs describe
62715
+ * `DESIGN.md` at the repository root and in nested subdirectories, like
62716
+ * `AGENTS.md`, but document no personal/global home-directory equivalent, so
62717
+ * this channel is project scope only.
62718
+ * @see https://docs.factory.ai/cli/configuration/agents-md
62719
+ */
61397
62720
  var FactorydroidRule = class FactorydroidRule extends ToolRule {
61398
- constructor({ fileContent, root, ...rest }) {
62721
+ design;
62722
+ constructor({ fileContent, root, design = false, ...rest }) {
61399
62723
  super({
61400
62724
  ...rest,
61401
62725
  fileContent,
61402
62726
  root: root ?? false
61403
62727
  });
62728
+ this.design = design;
61404
62729
  }
61405
62730
  static getSettablePaths({ global, excludeToolDir } = {}) {
61406
62731
  if (global) return { root: {
@@ -61412,11 +62737,47 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61412
62737
  relativeDirPath: ".",
61413
62738
  relativeFilePath: FACTORYDROID_RULE_FILE_NAME
61414
62739
  },
61415
- nonRoot: { relativeDirPath: buildToolPath(FACTORYDROID_DIR, "rules", excludeToolDir) }
62740
+ nonRoot: { relativeDirPath: buildToolPath(FACTORYDROID_DIR, "rules", excludeToolDir) },
62741
+ design: {
62742
+ relativeDirPath: ".",
62743
+ relativeFilePath: FACTORYDROID_DESIGN_FILE_NAME
62744
+ }
61416
62745
  };
61417
62746
  }
61418
- static async fromFile({ outputRoot = process.cwd(), relativeFilePath, validate = true, global = false }) {
62747
+ /**
62748
+ * Extra fixed files this tool manages beyond the root/non-root rules. The
62749
+ * RulesProcessor enumerates these for import and deletion so a stale
62750
+ * `DESIGN.md` is cleaned up once no rule opts in anymore. Empty in global
62751
+ * mode: `DESIGN.md` has no documented home-directory equivalent.
62752
+ */
62753
+ static getExtraFixedFiles({ global = false } = {}) {
62754
+ if (global) return [];
62755
+ return [this.getSettablePaths({ global }).design];
62756
+ }
62757
+ /**
62758
+ * Factory Droid loads `DESIGN.md` itself, so listing it in the root rule's
62759
+ * TOON reference section would double-load the content (and misrepresent it
62760
+ * as a rule the model must remember to open).
62761
+ */
62762
+ isExcludedFromRootReferences() {
62763
+ return this.design;
62764
+ }
62765
+ static async fromFile({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, validate = true, global = false }) {
61419
62766
  const paths = this.getSettablePaths({ global });
62767
+ const design = !global ? paths.design : void 0;
62768
+ if (design !== void 0 && relativeDirPath === design.relativeDirPath && relativeFilePath === design.relativeFilePath) {
62769
+ const relativePath = (0, node_path.join)(design.relativeDirPath, design.relativeFilePath);
62770
+ const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
62771
+ return new FactorydroidRule({
62772
+ outputRoot,
62773
+ relativeDirPath: design.relativeDirPath,
62774
+ relativeFilePath: design.relativeFilePath,
62775
+ fileContent,
62776
+ validate,
62777
+ root: false,
62778
+ design: true
62779
+ });
62780
+ }
61420
62781
  if (relativeFilePath === paths.root.relativeFilePath) {
61421
62782
  const relativePath = (0, node_path.join)(paths.root.relativeDirPath, paths.root.relativeFilePath);
61422
62783
  const fileContent = await readFileContent((0, node_path.join)(outputRoot, relativePath));
@@ -61443,18 +62804,34 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61443
62804
  }
61444
62805
  static forDeletion({ outputRoot = process.cwd(), relativeDirPath, relativeFilePath, global = false }) {
61445
62806
  const paths = this.getSettablePaths({ global });
61446
- const isRoot = relativeFilePath === paths.root.relativeFilePath && relativeDirPath === paths.root.relativeDirPath;
62807
+ const design = !global ? paths.design : void 0;
62808
+ const isDesign = design !== void 0 && relativeDirPath === design.relativeDirPath && relativeFilePath === design.relativeFilePath;
62809
+ const isRoot = !isDesign && relativeFilePath === paths.root.relativeFilePath && relativeDirPath === paths.root.relativeDirPath;
61447
62810
  return new FactorydroidRule({
61448
62811
  outputRoot,
61449
62812
  relativeDirPath,
61450
62813
  relativeFilePath,
61451
62814
  fileContent: "",
61452
62815
  validate: false,
61453
- root: isRoot
62816
+ root: isRoot,
62817
+ design: isDesign
61454
62818
  });
61455
62819
  }
61456
62820
  static fromRulesyncRule({ outputRoot = process.cwd(), rulesyncRule, validate = true, global = false }) {
62821
+ const frontmatter = rulesyncRule.getFrontmatter();
61457
62822
  const paths = this.getSettablePaths({ global });
62823
+ if (!global && !frontmatter.root && frontmatter.factorydroid?.channel === "design") {
62824
+ const { design } = paths;
62825
+ return new FactorydroidRule({
62826
+ outputRoot,
62827
+ relativeDirPath: design.relativeDirPath,
62828
+ relativeFilePath: design.relativeFilePath,
62829
+ fileContent: rulesyncRule.getBody(),
62830
+ validate,
62831
+ root: false,
62832
+ design: true
62833
+ });
62834
+ }
61458
62835
  return new FactorydroidRule(this.buildToolRuleParamsAgentsmd({
61459
62836
  outputRoot,
61460
62837
  rulesyncRule,
@@ -61464,6 +62841,17 @@ var FactorydroidRule = class FactorydroidRule extends ToolRule {
61464
62841
  }));
61465
62842
  }
61466
62843
  toRulesyncRule() {
62844
+ if (this.design) return new RulesyncRule({
62845
+ outputRoot: process.cwd(),
62846
+ relativeDirPath: RULESYNC_RULES_RELATIVE_DIR_PATH,
62847
+ relativeFilePath: FACTORYDROID_DESIGN_FILE_NAME,
62848
+ frontmatter: {
62849
+ root: false,
62850
+ targets: ["factorydroid"],
62851
+ factorydroid: { channel: "design" }
62852
+ },
62853
+ body: this.getFileContent()
62854
+ });
61467
62855
  return this.toRulesyncRuleDefault();
61468
62856
  }
61469
62857
  validate() {
@@ -63981,6 +65369,16 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
63981
65369
  ruleDiscoveryMode: "auto"
63982
65370
  }
63983
65371
  }],
65372
+ ["codebuddy", {
65373
+ class: CodebuddyRule,
65374
+ meta: {
65375
+ extension: "md",
65376
+ supportsGlobal: true,
65377
+ ruleDiscoveryMode: "auto",
65378
+ localRootMode: "separate-local-file",
65379
+ localRootFileName: CODEBUDDY_LOCAL_RULE_FILE_NAME
65380
+ }
65381
+ }],
63984
65382
  ["codexcli", {
63985
65383
  class: CodexcliRule,
63986
65384
  meta: {
@@ -64006,6 +65404,15 @@ const toolRuleFactories = /* @__PURE__ */ new Map([
64006
65404
  ruleDiscoveryMode: "auto"
64007
65405
  }
64008
65406
  }],
65407
+ ["crush", {
65408
+ class: CrushRule,
65409
+ meta: {
65410
+ extension: "md",
65411
+ supportsGlobal: true,
65412
+ ruleDiscoveryMode: "auto",
65413
+ collisionPolicy: "fold"
65414
+ }
65415
+ }],
64009
65416
  ["cursor", {
64010
65417
  class: CursorRule,
64011
65418
  meta: {
@@ -64374,6 +65781,10 @@ var RulesProcessor = class extends FeatureProcessor {
64374
65781
  outputFiles,
64375
65782
  convertedRules
64376
65783
  });
65784
+ await this.warnForDeactivatedImportOnlyRoots({
65785
+ toolRules,
65786
+ factory
65787
+ });
64377
65788
  return outputFiles;
64378
65789
  }
64379
65790
  /**
@@ -64595,6 +66006,41 @@ var RulesProcessor = class extends FeatureProcessor {
64595
66006
  }
64596
66007
  }
64597
66008
  /**
66009
+ * Warn when this generate run is about to write a root rule file that will
66010
+ * make the tool stop reading paths it currently reads instead — Junie's
66011
+ * `.junie/rules/*.md` and `.junie/playbook.md` become unreachable the
66012
+ * moment `.junie/AGENTS.md` exists, since Junie reads the root file
66013
+ * exclusively once it is present. `importOnlyRoots` with
66014
+ * `onlyWhenRootAbsent` already models exactly this shape for import; this
66015
+ * reuses the same declaration so the
66016
+ * `generate` path — which never calls `loadToolFiles` and so never reached
66017
+ * the existing import-side warning — surfaces it too. Without this, a repo
66018
+ * that only ever runs `generate` never sees any warning: the deactivated
66019
+ * files stay on disk, untouched and not gitignored, silently unread.
66020
+ */
66021
+ async warnForDeactivatedImportOnlyRoots({ toolRules, factory }) {
66022
+ const rootRule = toolRules.find((rule) => rule.isRoot());
66023
+ if (!rootRule) return;
66024
+ const settablePaths = factory.class.getSettablePaths({ global: this.global });
66025
+ const importOnlyRoots = "importOnlyRoots" in settablePaths ? settablePaths.importOnlyRoots : void 0;
66026
+ if (!importOnlyRoots || importOnlyRoots.length === 0) return;
66027
+ const existingPaths = [];
66028
+ for (const importOnlyRoot of importOnlyRoots) {
66029
+ if (importOnlyRoot.onlyWhenRootAbsent !== true) continue;
66030
+ const matchedPaths = await findFilesByGlobs(rootRelativeGlob(importOnlyRoot.relativeDirPath, importOnlyRoot.relativeFilePath ?? `*.${factory.meta.extension}`), {
66031
+ cwd: this.outputRoot,
66032
+ type: "file"
66033
+ });
66034
+ existingPaths.push(...matchedPaths);
66035
+ }
66036
+ if (existingPaths.length === 0) return;
66037
+ const rootFileRelativePath = (0, node_path.join)(rootRule.getRelativeDirPath(), rootRule.getRelativeFilePath());
66038
+ const names = existingPaths.map((filePath) => stripControlCharacters((0, node_path.relative)(this.outputRoot, filePath)));
66039
+ const listedNames = names.slice(0, MAX_LISTED_SKIPPED_IMPORT_ONLY_PATHS);
66040
+ const remainingCount = names.length - listedNames.length;
66041
+ this.logger.warn(`Writing ${stripControlCharacters(rootFileRelativePath)} for ${this.toolTarget} means ${listedNames.join(", ")}${remainingCount > 0 ? ` and ${remainingCount} more` : ""} will no longer be read. Run \`rulesync import --targets ${this.toolTarget}\` first to carry that content into ${RULESYNC_RULES_RELATIVE_DIR_PATH}, or delete ${listedNames.length === 1 ? "it" : "them"} once you have checked the content is already in the root file.`);
66042
+ }
66043
+ /**
64598
66044
  * Handle localRoot rule generation based on tool target.
64599
66045
  * - `separate-local-file`: writes a dedicated `*.local.md` root file
64600
66046
  * (claudecode/legacy: `./CLAUDE.local.md`, rovodev: `./AGENTS.local.md`)
@@ -64846,6 +66292,26 @@ As this project's AI coding tool, you must follow the additional conventions bel
64846
66292
  }));
64847
66293
  }
64848
66294
  /**
66295
+ * Load and merge rulesync rule files from every configured input root's
66296
+ * `.rulesync/rules/` directory, by relative path, so that a rule with the
66297
+ * same target path from a later root replaces the earlier root's copy
66298
+ * (case-insensitive, matching the intra-root collision handling).
66299
+ *
66300
+ * This is the side-effect-free half of `loadRulesyncFiles`: it does not
66301
+ * warn about a missing root rule or validate `localRoot` placement, so it
66302
+ * is also safe to call from code paths — like
66303
+ * `warnForFoldImportDuplicationRisk` — that only need the merged rule set
66304
+ * and must not trigger `loadRulesyncFiles`'s generate-time checks.
66305
+ */
66306
+ async loadMergedRulesyncRules() {
66307
+ return mergeByCaseInsensitiveIdentity({
66308
+ perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
66309
+ identity: (rule) => rule.getRelativeFilePath(),
66310
+ artifactName: "rule",
66311
+ logger: this.logger
66312
+ });
66313
+ }
66314
+ /**
64849
66315
  * Implementation of abstract method from FeatureProcessor
64850
66316
  * Load and parse rulesync rule files from every configured input root's
64851
66317
  * `.rulesync/rules/` directory, merging by relative path so that a rule
@@ -64853,12 +66319,7 @@ As this project's AI coding tool, you must follow the additional conventions bel
64853
66319
  * copy (case-insensitive, matching the intra-root collision handling).
64854
66320
  */
64855
66321
  async loadRulesyncFiles() {
64856
- const rulesyncRules = mergeByCaseInsensitiveIdentity({
64857
- perRoot: await Promise.all(this.inputRoots.map((root) => this.loadRulesyncFilesForRoot(root))),
64858
- identity: (rule) => rule.getRelativeFilePath(),
64859
- artifactName: "rule",
64860
- logger: this.logger
64861
- });
66322
+ const rulesyncRules = await this.loadMergedRulesyncRules();
64862
66323
  const factory = this.getFactory(this.toolTarget);
64863
66324
  const targetedRootRules = rulesyncRules.filter((rule) => rule.getFrontmatter().root).filter((rule) => factory.class.isTargetedByRulesyncRule(rule));
64864
66325
  if (targetedRootRules.length === 0 && rulesyncRules.length > 0) this.logger.warn(`No root rulesync rule file found for target '${this.toolTarget}'. Consider adding 'root: true' to one of your rule files in ${RULESYNC_RULES_RELATIVE_DIR_PATH}.`);
@@ -64915,6 +66376,46 @@ As this project's AI coding tool, you must follow the additional conventions bel
64915
66376
  });
64916
66377
  }
64917
66378
  /**
66379
+ * Warn when importing a `collisionPolicy: "fold"` target's root file while
66380
+ * `.rulesync/rules/` still holds non-root rules targeting it. A fold target
66381
+ * (codexcli, junie, and others) concatenates every targeted non-root rule
66382
+ * into its one root output file on `generate`. Importing that root file
66383
+ * back therefore re-reads the already-folded content as a single new
66384
+ * rulesync rule, while the original non-root rules stay in place
66385
+ * untouched — the next `generate` folds both together, duplicating the
66386
+ * content once per generate/import cycle with nothing to indicate why.
66387
+ *
66388
+ * This does not attempt to detect or drop the specific duplicated content
66389
+ * (the root file has no marker recording which rule contributed what); it
66390
+ * only surfaces that the cycle produces one, per the "at minimum, warn"
66391
+ * option recorded on issue #2743.
66392
+ *
66393
+ * Only the actual `rulesync import` call site invokes this (and only once
66394
+ * it has confirmed there is something to import) — `loadToolFiles` is also
66395
+ * the entry point for `rulesync convert` and `rulesync fetch`, neither of
66396
+ * which writes to `.rulesync/rules/` or carries this duplication risk.
66397
+ *
66398
+ * Reads via `loadMergedRulesyncRules` rather than `loadRulesyncFiles`
66399
+ * deliberately: this runs before the imported root file is written, so
66400
+ * `.rulesync/rules/` never yet has a root rule targeting this tool, and
66401
+ * `loadRulesyncFiles`'s "no root rule found" warning and `localRoot`
66402
+ * validation (which can throw) would fire spuriously on every fold-tool
66403
+ * import — including ones where nothing is actually misconfigured.
66404
+ *
66405
+ * In global mode, a `localRoot: true` rule is excluded from the
66406
+ * duplication check the same way `loadRulesyncFiles`'s global-mode branch
66407
+ * excludes it from `nonRootRules`: `generate` ignores `localRoot` entirely
66408
+ * in global mode, so such a rule is never actually folded into the global
66409
+ * root output and warning about it here would be inaccurate.
66410
+ */
66411
+ async warnForFoldImportDuplicationRisk() {
66412
+ const factory = this.getFactory(this.toolTarget);
66413
+ if (factory.meta.collisionPolicy !== "fold") return;
66414
+ const nonRootRules = (await this.loadMergedRulesyncRules()).filter((rule) => !rule.getFrontmatter().root && (!this.global || !rule.getFrontmatter().localRoot) && factory.class.isTargetedByRulesyncRule(rule));
66415
+ if (nonRootRules.length === 0) return;
66416
+ this.logger.warn(`Importing ${this.toolTarget}'s root file will re-add content already folded from ${formatRulePaths(nonRootRules)}: ${this.toolTarget} concatenates every non-root rule into its single root output file, so the imported copy duplicates them the next time you run \`rulesync generate --targets ${this.toolTarget}\`. Review the imported rule and remove the duplicated content, or remove the original non-root rule files, before generating again.`);
66417
+ }
66418
+ /**
64918
66419
  * Implementation of abstract method from FeatureProcessor
64919
66420
  * Load tool-specific rule configurations and parse them into ToolRule instances
64920
66421
  */
@@ -66978,6 +68479,7 @@ async function importRulesCore(params) {
66978
68479
  logger.warn(`No rule files found for ${tool}. Skipping import.`);
66979
68480
  return 0;
66980
68481
  }
68482
+ await rulesProcessor.warnForFoldImportDuplicationRisk();
66981
68483
  const rulesyncFiles = await rulesProcessor.convertToolFilesToRulesyncFiles(toolFiles);
66982
68484
  const { count: writtenCount } = await rulesProcessor.writeAiFiles(rulesyncFiles);
66983
68485
  if (config.getVerbose() && writtenCount > 0) logger.success(`Created ${writtenCount} rule files`);
@@ -67297,6 +68799,18 @@ Object.defineProperty(exports, "CLIError", {
67297
68799
  return CLIError;
67298
68800
  }
67299
68801
  });
68802
+ Object.defineProperty(exports, "CODEBUDDY_DIR", {
68803
+ enumerable: true,
68804
+ get: function() {
68805
+ return CODEBUDDY_DIR;
68806
+ }
68807
+ });
68808
+ Object.defineProperty(exports, "CODEBUDDY_LOCAL_RULE_FILE_NAME", {
68809
+ enumerable: true,
68810
+ get: function() {
68811
+ return CODEBUDDY_LOCAL_RULE_FILE_NAME;
68812
+ }
68813
+ });
67300
68814
  Object.defineProperty(exports, "CODEXCLI_BASH_RULES_FILE_NAME", {
67301
68815
  enumerable: true,
67302
68816
  get: function() {
@@ -67879,6 +69393,12 @@ Object.defineProperty(exports, "hasDeceptiveHiddenCharacters", {
67879
69393
  return hasDeceptiveHiddenCharacters;
67880
69394
  }
67881
69395
  });
69396
+ Object.defineProperty(exports, "hasEnclosingMarkOutsideKeycap", {
69397
+ enumerable: true,
69398
+ get: function() {
69399
+ return hasEnclosingMarkOutsideKeycap;
69400
+ }
69401
+ });
67882
69402
  Object.defineProperty(exports, "importFromTool", {
67883
69403
  enumerable: true,
67884
69404
  get: function() {