rulesync 43.7.0 → 43.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1793,28 +1793,6 @@ function splitAbsolutePath(absolutePath) {
1793
1793
  };
1794
1794
  }
1795
1795
  /**
1796
- * Whether writing `targetPath` would land outside `rootPath` once every link on
1797
- * the way there is resolved. Both sides are taken to where a write to them
1798
- * would land (see {@link writeLandingPath}), so a root that does not fully
1799
- * exist yet is judged the same way as the target below it.
1800
- *
1801
- * A link that stays inside the root passes. That is what sets this apart from
1802
- * the stricter {@link assertWritablePathInsideRoot} the sweep paths use: a
1803
- * dotfiles checkout linked from inside the home directory is a common shape for
1804
- * a global write target, and refusing it would refuse `--global` outright.
1805
- *
1806
- * A link chain that cycles is reported as an escape: the write could not get
1807
- * through it, and refusing it is the answer that never writes somewhere
1808
- * unexpected.
1809
- */
1810
- async function writablePathEscapesRoot({ rootPath, targetPath }) {
1811
- const relativeLanding = await relativeWriteLanding({
1812
- rootPath,
1813
- targetPath
1814
- });
1815
- return relativeLanding === null || pathEscapesRoot(relativeLanding);
1816
- }
1817
- /**
1818
1796
  * Where a write to `targetPath` lands relative to where a write to `rootPath`
1819
1797
  * lands, both taken through {@link writeLandingPath}: `""` when the two land on
1820
1798
  * the same directory, and `null` when either side's links form a cycle. Errors
@@ -1827,6 +1805,59 @@ async function relativeWriteLanding({ rootPath, targetPath }) {
1827
1805
  return relative(rootLanding, targetLanding);
1828
1806
  }
1829
1807
  /**
1808
+ * Directory names that hold version-control metadata. Bytes landing in one of
1809
+ * them change how the VCS behaves rather than what it tracks: `.git/config`
1810
+ * can set `core.fsmonitor`, `.git/hooks/*` run on the next commit.
1811
+ */
1812
+ const VCS_METADATA_DIR_NAMES = /* @__PURE__ */ new Set([
1813
+ ".git",
1814
+ ".hg",
1815
+ ".svn",
1816
+ ".jj"
1817
+ ]);
1818
+ function hasVcsMetadataSegment(path) {
1819
+ return splitPathSegments(path).some((segment) => {
1820
+ const folded = segment.replace(/:.*$/, "").replace(/[. ]+$/, "").toLowerCase();
1821
+ return VCS_METADATA_DIR_NAMES.has(folded) || /^(git|hg|svn|jj)~\d+$/.test(folded);
1822
+ });
1823
+ }
1824
+ /**
1825
+ * Whether a link on the way to `targetPath` carries it into version-control
1826
+ * metadata (`.git/` and its siblings) that its spelling does not name.
1827
+ *
1828
+ * {@link writablePathEscapesRoot} lets a link that stays inside the root
1829
+ * through, but a checked-out repository can commit `CLAUDE.md -> .git/config`:
1830
+ * the landing is inside the root, yet a write there hands the repository author
1831
+ * the operator's next `git` command. `git clone` never transfers `.git/`, so a
1832
+ * path spelled into it is never something the repository chose; only a write
1833
+ * that lands exactly where it is spelled may touch it.
1834
+ *
1835
+ * `relativeLanding` is {@link relativeWriteLanding}'s answer for the same pair,
1836
+ * passed in so a caller that already has it does not resolve the links twice.
1837
+ */
1838
+ function landingEntersVcsMetadata({ rootPath, targetPath, relativeLanding }) {
1839
+ return hasVcsMetadataSegment(relativeLanding) && relativeLanding !== relative(resolve(rootPath), resolve(targetPath));
1840
+ }
1841
+ /**
1842
+ * Refuse a write target that a symbolic link carries into version-control
1843
+ * metadata (see {@link landingEntersVcsMetadata}). Meant for writes of remote
1844
+ * bytes, next to {@link assertPathResolvesInsideRoot}: an in-root link (a
1845
+ * skill directory linked to a shared one) stays allowed, but not one that
1846
+ * hands the bytes to `.git/config` or `.git/hooks/`.
1847
+ */
1848
+ async function assertLandingOutsideVcsMetadata({ rootPath, targetPath }) {
1849
+ const relativeLanding = await relativeWriteLanding({
1850
+ rootPath,
1851
+ targetPath
1852
+ });
1853
+ if (relativeLanding === null) throw new PathOutsideRootError(`Refusing to write through a link cycle: ${targetPath}.`);
1854
+ if (landingEntersVcsMetadata({
1855
+ rootPath,
1856
+ targetPath,
1857
+ relativeLanding
1858
+ })) throw new PathOutsideRootError(`Refusing to write ${targetPath}: a symbolic link leads it into version-control metadata.`);
1859
+ }
1860
+ /**
1830
1861
  * How many trailing segments `filePath` and `identity` have in common.
1831
1862
  *
1832
1863
  * A path that walked through no link at all shares all of its own segments with
@@ -5430,7 +5461,8 @@ const QWENCODE_HOOK_EVENTS = [
5430
5461
  "todoCreated",
5431
5462
  "todoCompleted",
5432
5463
  "messageDisplay",
5433
- "sessionDelete"
5464
+ "sessionDelete",
5465
+ "fileChanged"
5434
5466
  ];
5435
5467
  const CANONICAL_TO_KIMI_CODE_EVENT_NAMES = {
5436
5468
  sessionStart: "SessionStart",
@@ -6764,6 +6796,7 @@ const CANONICAL_TO_JUNIE_EVENT_NAMES = {
6764
6796
  sessionStart: "SessionStart",
6765
6797
  beforeSubmitPrompt: "UserPromptSubmit",
6766
6798
  preToolUse: "PreToolUse",
6799
+ postToolUse: "PostToolUse",
6767
6800
  stop: "Stop",
6768
6801
  stopFailure: "StopFailure",
6769
6802
  permissionRequest: "PermissionRequest",
@@ -6772,11 +6805,12 @@ const CANONICAL_TO_JUNIE_EVENT_NAMES = {
6772
6805
  /**
6773
6806
  * Hook events supported by JetBrains Junie CLI.
6774
6807
  *
6775
- * Junie CLI exposes seven lifecycle events under the `"hooks"` key of
6808
+ * Junie CLI exposes eight lifecycle events under the `"hooks"` key of
6776
6809
  * `~/.junie/config.json`: `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
6777
- * `Stop`, `StopFailure`, `PermissionRequest`, and `SessionEnd`. Matchers apply
6778
- * to `SessionStart` (source), `PreToolUse` (tool name), `StopFailure` (error
6779
- * type), `PermissionRequest` (tool name), and `SessionEnd` (reason);
6810
+ * `PostToolUse`, `Stop`, `StopFailure`, `PermissionRequest`, and `SessionEnd`.
6811
+ * Matchers apply to `SessionStart` (source), `PreToolUse` and `PostToolUse`
6812
+ * (tool name), `StopFailure` (error type), `PermissionRequest` (tool name), and
6813
+ * `SessionEnd` (reason);
6780
6814
  * `UserPromptSubmit` and `Stop` are matcher-less and always run. Only
6781
6815
  * `type: "command"` hooks are supported. Project-local hooks are ignored for
6782
6816
  * safety.
@@ -6955,7 +6989,8 @@ const CANONICAL_TO_QWENCODE_EVENT_NAMES = {
6955
6989
  todoCreated: "TodoCreated",
6956
6990
  todoCompleted: "TodoCompleted",
6957
6991
  messageDisplay: "MessageDisplay",
6958
- sessionDelete: "SessionDelete"
6992
+ sessionDelete: "SessionDelete",
6993
+ fileChanged: "FileChanged"
6959
6994
  };
6960
6995
  /**
6961
6996
  * Map Qwen Code PascalCase event names to canonical camelCase.
@@ -9210,7 +9245,8 @@ const AntigravityCliPermissionsOverrideSchema = z.looseObject({
9210
9245
  * The same override also carries the two plugin-consumption keys Auggie reads
9211
9246
  * from `settings.json`: `recommendedMarketplaces` (`"owner/repo"` marketplaces
9212
9247
  * a workspace prompts its contributors to install; honored only in the
9213
- * project's `.augment/settings.json`) and `enabledPlugins` (plugin ids of the
9248
+ * project's `.augment/settings.json`, and only by Auggie 0.x — 1.x no longer
9249
+ * reads it) and `enabledPlugins` (plugin ids of the
9214
9250
  * form `"plugin@marketplace"` mapped to on/off, deep-merged across the user,
9215
9251
  * project and local tiers).
9216
9252
  *
@@ -9394,6 +9430,14 @@ const CodexBasePermissionProfileSchema = z.enum(CODEX_BASE_PERMISSION_PROFILES);
9394
9430
  * `true`; only an explicit `false` suppresses it. Like
9395
9431
  * `base_permission_profile` it is consumed by the profile builder, not
9396
9432
  * written as a top-level config key.
9433
+ * - `bash_canonical_matching` — when `true`, the `forbidden` (deny) and
9434
+ * `prompt` (ask) rules generated from `permission.bash` into
9435
+ * `.codex/rules/rulesync.rules` carry `canonical_matching = True`, so Codex
9436
+ * compares their tokens after Unicode canonical case-insensitive folding (`rm` also
9437
+ * matches `RM`). `allow` rules stay exact so the opt-in never widens what is
9438
+ * auto-approved. Requires Codex 0.167.0 or later — an older Codex rejects the
9439
+ * keyword and fails to load the rules file — so it defaults to `false`. Not
9440
+ * written as a config key.
9397
9441
  *
9398
9442
  * The keys written to `config.toml` are an **allowlist**, not verbatim
9399
9443
  * passthrough: only `CODEXCLI_OVERRIDE_KEYS`
@@ -9401,8 +9445,8 @@ const CodexBasePermissionProfileSchema = z.enum(CODEX_BASE_PERMISSION_PROFILES);
9401
9445
  * `sandbox_workspace_write`, `apps`, `approvals_reviewer`, `auto_review`,
9402
9446
  * `tui`) are emitted, and
9403
9447
  * `computeCodexcliOverridePatch` skips anything else with a warning.
9404
- * `base_permission_profile` and `git_write_rules` are consumed by the profile
9405
- * builder rather than written, as described above, and `permission` is the
9448
+ * `base_permission_profile`, `git_write_rules` and `bash_canonical_matching`
9449
+ * are consumed by the generators rather than written, as described above, and `permission` is the
9406
9450
  * tool-scoped canonical block, which `RulesyncPermissions.forTarget` strips out
9407
9451
  * of the override before it ever reaches the patch. The allowlist is what keeps
9408
9452
  * the override from clobbering a feature-owned key: `mcp_servers.*` per-MCP
@@ -9459,7 +9503,8 @@ const CodexcliPermissionsOverrideSchema = z.looseObject({
9459
9503
  * @see https://developers.openai.com/codex/config-reference
9460
9504
  */
9461
9505
  tui: z.optional(z.looseObject({})),
9462
- git_write_rules: z.optional(z.boolean())
9506
+ git_write_rules: z.optional(z.boolean()),
9507
+ bash_canonical_matching: z.optional(z.boolean())
9463
9508
  });
9464
9509
  /**
9465
9510
  * Tool-scoped override block for Zed. These Zed `agent` keys sit outside the
@@ -9543,6 +9588,14 @@ const ZedPermissionsOverrideSchema = z.looseObject({
9543
9588
  * { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
9544
9589
  * @example
9545
9590
  * { "forbiddenPlugins": ["*"], "optionalPlugins": ["acme/approved"] }
9591
+ *
9592
+ * `permission_mode` (CLI v3000.12.17+) is the mode new local sessions start in
9593
+ * (`normal`, the upstream default, `accept-edits`, `smart` or `dangerous`).
9594
+ * Like `sandbox` it is a **User Config Only** key, so it is emitted at global
9595
+ * scope only. It is never derived from the canonical rules.
9596
+ *
9597
+ * @example
9598
+ * { "permission_mode": "accept-edits" }
9546
9599
  * @see https://docs.devin.ai/cli/sandbox
9547
9600
  * @see https://docs.devin.ai/cli/reference/configuration/config-file
9548
9601
  * @see https://docs.devin.ai/cli/extensibility/plugins/overview
@@ -9558,12 +9611,25 @@ const DEVIN_PLUGIN_GOVERNANCE_SCHEMAS = {
9558
9611
  optionalPlugins: DevinPluginSourceListSchema,
9559
9612
  forbiddenPlugins: z.array(z.string())
9560
9613
  };
9614
+ /**
9615
+ * Values Devin accepts for the user-config `permission_mode` key. Shared by the
9616
+ * override schema and the import, which drops a value that would not pass it.
9617
+ *
9618
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
9619
+ */
9620
+ const DevinPermissionModeSchema = z.enum([
9621
+ "normal",
9622
+ "accept-edits",
9623
+ "smart",
9624
+ "dangerous"
9625
+ ]);
9561
9626
  const DevinPermissionsOverrideSchema = z.looseObject({
9562
9627
  permission: z.optional(ToolScopedPermissionSchema),
9563
9628
  sandbox: z.optional(z.looseObject({})),
9564
9629
  requiredPlugins: z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.requiredPlugins),
9565
9630
  optionalPlugins: z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.optionalPlugins),
9566
- forbiddenPlugins: z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.forbiddenPlugins)
9631
+ forbiddenPlugins: z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.forbiddenPlugins),
9632
+ permission_mode: z.optional(DevinPermissionModeSchema)
9567
9633
  });
9568
9634
  /**
9569
9635
  * Permissions configuration.
@@ -11975,6 +12041,98 @@ function resolveStandardSkillFields({ rootFrontmatter, section }) {
11975
12041
  })
11976
12042
  });
11977
12043
  }
12044
+ // src/utils/remote-name.ts
12045
+ /**
12046
+ * Name checks for a directory or file name chosen by a remote source (a
12047
+ * fetched repository, an installed git, GitHub or npm source). Shared by
12048
+ * `rulesync fetch` and `rulesync install`, so a name one of them turns away is
12049
+ * not written, or printed raw, by the other.
12050
+ */
12051
+ /** A name with no character in it that draws anything of its own. */
12052
+ const NOTHING_DRAWN_PATTERN = /^[\s\p{M}]*$/u;
12053
+ /**
12054
+ * The marks a name begins with. A mark is drawn over the character before it,
12055
+ * and a name has nothing before it of its own: a leading one is drawn over
12056
+ * whatever the terminal printed last — the checkbox, the quote around a name
12057
+ * in a warning — while the name reads as if it began with its first letter.
12058
+ */
12059
+ const LEADING_MARKS_PATTERN = /^\p{M}+/u;
12060
+ /**
12061
+ * A name ending in a dot or an ASCII space, which Win32 strips before it opens
12062
+ * the path. Only the plain space (U+0020): Win32 keeps every other trailing
12063
+ * blank, so a name ending in a no-break or an ideographic space is written
12064
+ * where it says, and is left to the whitespace note of the confusable-name
12065
+ * check rather than dropped here with Windows named as the reason.
12066
+ */
12067
+ const TRAILING_DOT_OR_SPACE_PATTERN = /[. ]$/;
12068
+ /** A name ending in the `NAME~1` shape of a Windows 8.3 short name, extension included. */
12069
+ const SHORT_NAME_ALIAS_PATTERN = /~\d+(?:\.[^.]*)?$/;
12070
+ /**
12071
+ * A name Win32 reserves for a device — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
12072
+ * `CONOUT$`, `COM1`–`COM9`, `LPT1`–`LPT9` — in any case and with any
12073
+ * extension: `nul.md` is the null device too, since the reservation is decided
12074
+ * on the part before the first dot, with any spaces before that dot dropped
12075
+ * first, so `nul .md` is the null device as well. The port digit may also be
12076
+ * one of the superscript digits ¹ ² ³, which Win32 reads as 1, 2 and 3
12077
+ * (`RtlIsDosDeviceName_U`; CPython's `ntpath.isreserved` follows the same
12078
+ * rules).
12079
+ */
12080
+ const RESERVED_DEVICE_NAME_PATTERN = /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9\u00B9\u00B2\u00B3]|LPT[1-9\u00B9\u00B2\u00B3]) *(?:\..*)?$/i;
12081
+ /**
12082
+ * Whether Windows resolves a path segment to something other than the entry it
12083
+ * reads as.
12084
+ *
12085
+ * Win32 drops a trailing dot or ASCII space when it resolves a path, so a remote
12086
+ * `skills/deploy.` is the existing `skills/deploy` there. A name ending in the
12087
+ * `NAME~1` shape is the same class of problem: on a volume that generates
12088
+ * short names, it opens whatever long name it stands for. The shape only
12089
+ * means that at the end of a name, so `data~2parser` and `v1.0` are ordinary
12090
+ * names. A reserved device name is the extreme case: `nul` and `nul.md` are
12091
+ * not entries in any directory but the null device itself, so a write to one
12092
+ * lands nowhere, and a directory of that name cannot be created at all.
12093
+ *
12094
+ * The predicate is asked of every segment of a written path — the skill
12095
+ * directory, but equally a nested `references/notes. ` or a `scripts/con.sh`
12096
+ * — since a folded segment anywhere in a path lands the file beside, or on top
12097
+ * of, a sibling the repository never published. The check is not gated on the
12098
+ * platform: the same repository is checked out on several of them, and a name
12099
+ * that lands somewhere else on any one of them is one this tool would rather
12100
+ * turn away everywhere than write differently depending on where it runs.
12101
+ */
12102
+ function hasWindowsFoldableName(name) {
12103
+ return TRAILING_DOT_OR_SPACE_PATTERN.test(name) || SHORT_NAME_ALIAS_PATTERN.test(name) || RESERVED_DEVICE_NAME_PATTERN.test(name);
12104
+ }
12105
+ /**
12106
+ * Whether a remote name is safe to write and to print as it is.
12107
+ *
12108
+ * A name of nothing but blank space is shown as the empty string, the same as
12109
+ * one that strips down to nothing: both draw as a row with no name on it. So
12110
+ * is a name of nothing but combining marks, which have no character of their
12111
+ * own to sit on and draw as a smear over whatever the terminal puts beside
12112
+ * them. A name that merely begins with a mark is turned away for the same
12113
+ * reason, and shown without it: the mark sits on nothing the name owns. A name
12114
+ * carrying control characters (an ANSI, C1 or OSC sequence) or deceptive
12115
+ * zero-width characters is turned away too, so it can neither rewrite the
12116
+ * terminal nor pass for another name. The Windows-folding check comes after
12117
+ * the hidden characters, so a name that is both is reported by the reason that
12118
+ * keeps its raw form off the terminal.
12119
+ */
12120
+ function classifyRemoteName(name) {
12121
+ const stripped = stripHiddenCharacters(name);
12122
+ const beginsWithMark = LEADING_MARKS_PATTERN.test(stripped);
12123
+ const display = NOTHING_DRAWN_PATTERN.test(stripped) ? "" : stripped.replace(LEADING_MARKS_PATTERN, "");
12124
+ if (display === "" || beginsWithMark || hasDeceptiveHiddenCharacters(name)) return {
12125
+ kind: "unsafe",
12126
+ display,
12127
+ reason: "hidden"
12128
+ };
12129
+ if (hasWindowsFoldableName(name)) return {
12130
+ kind: "unsafe",
12131
+ display,
12132
+ reason: "foldable"
12133
+ };
12134
+ return { kind: "safe" };
12135
+ }
11978
12136
  // src/features/rules/instruction-budget.ts
11979
12137
  /**
11980
12138
  * Normalizes a relative directory path for comparison: POSIX separators, no
@@ -13862,7 +14020,11 @@ const SHARED_CONFIG_OWNERSHIP = {
13862
14020
  },
13863
14021
  permissions: {
13864
14022
  kind: "replace-owned-keys",
13865
- ownedKeys: ["permissions", "sandbox"]
14023
+ ownedKeys: [
14024
+ "permissions",
14025
+ "sandbox",
14026
+ "permission_mode"
14027
+ ]
13866
14028
  }
13867
14029
  }
13868
14030
  },
@@ -14636,6 +14798,16 @@ var RulesyncSourceConsumer = class {
14636
14798
  this.dryRun = dryRun;
14637
14799
  this.logger = logger;
14638
14800
  }
14801
+ /**
14802
+ * Whether the orphan sweep may follow a symbolic link that stays inside the
14803
+ * output root. Only global scope answers true: its root is the operator's
14804
+ * own home directory, where a dotfiles checkout linked in (`~/.claude ->
14805
+ * ~/dotfiles/claude`) is a common shape. A project checkout may carry links
14806
+ * its author committed, so the default refuses them.
14807
+ */
14808
+ sweepsThroughInRootLinks() {
14809
+ return false;
14810
+ }
14639
14811
  recordRulesyncSourceLoadFailure() {
14640
14812
  this.rulesyncSourceLoadFailed = true;
14641
14813
  }
@@ -14768,7 +14940,8 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
14768
14940
  refused = await refusesDeleteOutsideRoot({
14769
14941
  logger: this.logger,
14770
14942
  rootPath,
14771
- dirPath
14943
+ dirPath,
14944
+ allowInRootLinks: this.sweepsThroughInRootLinks()
14772
14945
  });
14773
14946
  refusedDirs.set(cacheKey, refused);
14774
14947
  }
@@ -14868,27 +15041,45 @@ function mergeByIdentity({ perRoot, identity }) {
14868
15041
  return order.map((key) => winnerByKey.get(key));
14869
15042
  }
14870
15043
  /**
14871
- * Whether a write to `targetPath` has to be refused because a link on the way
14872
- * there leads out of `rootPath`, warning about the refusal.
15044
+ * Why a write to `targetPath` has to be refused, or `null` when it may go
15045
+ * ahead: a link on the way there leads out of `rootPath`, or into
15046
+ * version-control metadata that the path as spelled does not name.
14873
15047
  *
14874
15048
  * A checked-out repository can carry a symbolic link at an output path
14875
- * (`AGENTS.md -> ~/.bashrc`, a tool directory linked to `/etc`), and a write
14876
- * through it would land wherever the link points. Every writer that lets the
14877
- * OS follow links — file, directory, and config-rewrite alike — asks this first
14878
- * so that one warning describes the one behavior.
15049
+ * (`AGENTS.md -> ~/.bashrc`, a tool directory linked to `/etc`, `CLAUDE.md ->
15050
+ * .git/config`), and a write through it would land wherever the link points.
15051
+ */
15052
+ async function writeRefusalReason({ rootPath, targetPath }) {
15053
+ const relativeLanding = await relativeWriteLanding({
15054
+ rootPath,
15055
+ targetPath
15056
+ });
15057
+ if (relativeLanding === null || pathEscapesRoot(relativeLanding)) return `it resolves outside ${quoteForLog(rootPath)} through a symbolic link`;
15058
+ if (landingEntersVcsMetadata({
15059
+ rootPath,
15060
+ targetPath,
15061
+ relativeLanding
15062
+ })) return "it resolves into version-control metadata through a symbolic link";
15063
+ return null;
15064
+ }
15065
+ /**
15066
+ * Whether a write to `targetPath` has to be refused (see
15067
+ * {@link writeRefusalReason}), warning about the refusal. Every writer that
15068
+ * lets the OS follow links — file, directory, and config-rewrite alike — asks
15069
+ * this first so that one warning describes the one behavior.
14879
15070
  */
14880
15071
  async function refusesWriteOutsideRoot({ logger, rootPath, targetPath }) {
14881
- if (!await writablePathEscapesRoot({
15072
+ const reason = await writeRefusalReason({
14882
15073
  rootPath,
14883
15074
  targetPath
14884
- })) return false;
14885
- logger.warn(`Refusing to write ${quoteForLog(targetPath)}: it resolves outside ${quoteForLog(rootPath)} through a symbolic link`);
15075
+ });
15076
+ if (reason === null) return false;
15077
+ logger.warn(`Refusing to write ${quoteForLog(targetPath)}: ${reason}`);
14886
15078
  return true;
14887
15079
  }
14888
15080
  /**
14889
- * Whether deleting the files in `dirPath` has to be refused because a link on
14890
- * the way to that directory leads out of `rootPath`, warning about the refusal
14891
- * once for the whole directory.
15081
+ * Why the orphan sweep has to leave the files in `dirPath` alone, or `null`
15082
+ * when it may delete them.
14892
15083
  *
14893
15084
  * Deletion candidates are enumerated without following links *inside* the
14894
15085
  * scanned directory, but the scanned directory itself is reached through
@@ -14897,13 +15088,40 @@ async function refusesWriteOutsideRoot({ logger, rootPath, targetPath }) {
14897
15088
  * an orphan. Only the directory is judged, never the file in it, because
14898
15089
  * removing a file that is itself a link unlinks the link where it is spelled
14899
15090
  * and never touches its target.
15091
+ *
15092
+ * Beyond the write rules, a project checkout is content another party may
15093
+ * control: a committed `.claude/rules -> ..` would make every untracked `*.md`
15094
+ * in the project read as an orphan. Unless `allowInRootLinks` (global scope,
15095
+ * where the root is the operator's own home directory) the swept directory
15096
+ * must therefore be the one its path spells, with no link on the way.
14900
15097
  */
14901
- async function refusesDeleteOutsideRoot({ logger, rootPath, dirPath }) {
14902
- if (!await writablePathEscapesRoot({
15098
+ async function sweepRefusalReason({ rootPath, dirPath, allowInRootLinks }) {
15099
+ const relativeLanding = await relativeWriteLanding({
14903
15100
  rootPath,
14904
15101
  targetPath: dirPath
14905
- })) return false;
14906
- logger.warn(`Refusing to delete files in ${quoteForLog(dirPath)}: it resolves outside ${quoteForLog(rootPath)} through a symbolic link`);
15102
+ });
15103
+ if (relativeLanding === null || pathEscapesRoot(relativeLanding)) return `it resolves outside ${quoteForLog(rootPath)} through a symbolic link`;
15104
+ if (landingEntersVcsMetadata({
15105
+ rootPath,
15106
+ targetPath: dirPath,
15107
+ relativeLanding
15108
+ })) return "it resolves into version-control metadata through a symbolic link";
15109
+ if (!allowInRootLinks && relativeLanding !== relative(resolve(rootPath), resolve(dirPath))) return "it is reached through a symbolic link";
15110
+ return null;
15111
+ }
15112
+ /**
15113
+ * Whether deleting the files in `dirPath` has to be refused (see
15114
+ * {@link sweepRefusalReason}), warning about the refusal once for the whole
15115
+ * directory.
15116
+ */
15117
+ async function refusesDeleteOutsideRoot({ logger, rootPath, dirPath, allowInRootLinks }) {
15118
+ const reason = await sweepRefusalReason({
15119
+ rootPath,
15120
+ dirPath,
15121
+ allowInRootLinks
15122
+ });
15123
+ if (reason === null) return false;
15124
+ logger.warn(`Refusing to delete files in ${quoteForLog(dirPath)}: ${reason}`);
14907
15125
  return true;
14908
15126
  }
14909
15127
  /**
@@ -22614,6 +22832,9 @@ const commandsProcessorToolTargetsGlobal = allToolTargetKeys$3.filter((target) =
22614
22832
  var CommandsProcessor = class extends FeatureProcessor {
22615
22833
  toolTarget;
22616
22834
  global;
22835
+ sweepsThroughInRootLinks() {
22836
+ return this.global;
22837
+ }
22617
22838
  getFactory;
22618
22839
  flattenedCommandNaming;
22619
22840
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, flattenedCommandNaming = "basename", logger }) {
@@ -23185,6 +23406,15 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
23185
23406
  this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${stripControlCharacters(formatError(error))}`);
23186
23407
  continue;
23187
23408
  }
23409
+ const sweepRefusal = await sweepRefusalReason({
23410
+ rootPath: this.outputRoot,
23411
+ dirPath,
23412
+ allowInRootLinks: this.sweepsThroughInRootLinks()
23413
+ });
23414
+ if (sweepRefusal !== null) {
23415
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${sweepRefusal}`);
23416
+ continue;
23417
+ }
23188
23418
  const generatedNames = /* @__PURE__ */ new Set();
23189
23419
  const mainFile = aiDir.getMainFile();
23190
23420
  if (mainFile) generatedNames.add(toPosixPath(mainFile.name));
@@ -25268,6 +25498,8 @@ const CRUSH_CONFIG_FILE_NAME = "crush.json";
25268
25498
  const CRUSH_HIDDEN_CONFIG_FILE_NAME = ".crush.json";
25269
25499
  const CRUSH_PERMISSIONS_KEY = "permissions";
25270
25500
  const CRUSH_ALLOWED_TOOLS_KEY = "allowed_tools";
25501
+ const CRUSH_SAFE_COMMANDS_KEY = "safe_commands";
25502
+ const CRUSH_BLOCKED_COMMANDS_KEY = "blocked_commands";
25271
25503
  const CRUSH_OPTIONS_KEY = "options";
25272
25504
  const CRUSH_DISABLED_TOOLS_KEY = "disabled_tools";
25273
25505
  const CRUSH_HOOKS_KEY = "hooks";
@@ -29948,6 +30180,9 @@ var SkillsProcessor = class extends DirFeatureProcessor {
29948
30180
  global: this.global
29949
30181
  });
29950
30182
  }
30183
+ sweepsThroughInRootLinks() {
30184
+ return this.global;
30185
+ }
29951
30186
  /**
29952
30187
  * The tool's skills roots that exist on disk, each paired with its absolute
29953
30188
  * path and vetted as writable inside this run's output root. Shared by the
@@ -29977,6 +30212,15 @@ var SkillsProcessor = class extends DirFeatureProcessor {
29977
30212
  this.logger.warn(`Skipping the orphan sweep for ${quoteForLog(skillsDirPath)}; nothing under it is deleted: ${stripControlCharacters(formatError(error))}`);
29978
30213
  continue;
29979
30214
  }
30215
+ const sweepRefusal = await sweepRefusalReason({
30216
+ rootPath: this.outputRoot,
30217
+ dirPath: skillsDirPath,
30218
+ allowInRootLinks: this.sweepsThroughInRootLinks()
30219
+ });
30220
+ if (sweepRefusal !== null) {
30221
+ this.logger.warn(`Skipping the orphan sweep for ${quoteForLog(skillsDirPath)}; nothing under it is deleted: ${sweepRefusal}`);
30222
+ continue;
30223
+ }
29980
30224
  existingRoots.push({
29981
30225
  root,
29982
30226
  skillsDirPath
@@ -33203,11 +33447,17 @@ var FirebenderSubagent = class FirebenderSubagent extends ToolSubagent {
33203
33447
  */
33204
33448
  static async fromRulesyncSubagents({ outputRoot = process.cwd(), rulesyncSubagents, global = false, logger, deleteOrphans = false }) {
33205
33449
  const agentsDirPath = this.getSettablePaths({ global }).relativeDirPath;
33206
- const agentsDirEscapesRoot = await writablePathEscapesRoot({
33450
+ const agentsDirFullPath = join(outputRoot, agentsDirPath);
33451
+ const writeRefused = await writeRefusalReason({
33207
33452
  rootPath: outputRoot,
33208
- targetPath: join(outputRoot, agentsDirPath, "agent.md")
33209
- });
33210
- const existingAgentFileNames = new Set(agentsDirEscapesRoot ? [] : await listDirectoryEntryNames(join(outputRoot, agentsDirPath)));
33453
+ targetPath: join(agentsDirFullPath, "agent.md")
33454
+ }) !== null;
33455
+ const sweepRefused = deleteOrphans && await sweepRefusalReason({
33456
+ rootPath: outputRoot,
33457
+ dirPath: agentsDirFullPath,
33458
+ allowInRootLinks: global
33459
+ }) !== null;
33460
+ const existingAgentFileNames = new Set(writeRefused ? [] : await listDirectoryEntryNames(agentsDirFullPath));
33211
33461
  const subagents = rulesyncSubagents.map((rulesyncSubagent) => this.fromRulesyncSubagent({
33212
33462
  outputRoot,
33213
33463
  relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
@@ -33223,8 +33473,8 @@ var FirebenderSubagent = class FirebenderSubagent extends ToolSubagent {
33223
33473
  global
33224
33474
  })),
33225
33475
  existingAgentFileNames,
33226
- deleteOrphans,
33227
- frozen: agentsDirEscapesRoot,
33476
+ deleteOrphans: deleteOrphans && !sweepRefused,
33477
+ frozen: writeRefused,
33228
33478
  global,
33229
33479
  logger
33230
33480
  });
@@ -37342,6 +37592,9 @@ function compareRelativePathsShallowestFirst(left, right) {
37342
37592
  var SubagentsProcessor = class extends FeatureProcessor {
37343
37593
  toolTarget;
37344
37594
  global;
37595
+ sweepsThroughInRootLinks() {
37596
+ return this.global;
37597
+ }
37345
37598
  deleteOrphans;
37346
37599
  getFactory;
37347
37600
  /**
@@ -38063,6 +38316,39 @@ function warnAndSkipMcpServer({ toolName, serverName, reason, logger }) {
38063
38316
  return null;
38064
38317
  }
38065
38318
  /**
38319
+ * Drops every server whose `enabledTools` allowlist is empty, warning about
38320
+ * each one, for a tool that reads an empty (or missing) allowlist as "no
38321
+ * filter". Canonically an empty allowlist allows no tools at all, so emitting
38322
+ * the server unfiltered would expose every tool it reports. With
38323
+ * `ignoreBlankNames`, blank entries count as absent, for a tool that drops
38324
+ * them before applying the list. `productName` names the tool in the reason
38325
+ * when it differs from the `toolName` the warning starts with.
38326
+ */
38327
+ function skipServersWithEmptyEnabledTools({ mcpServers, toolName, logger, ignoreBlankNames = false, productName = toolName }) {
38328
+ const kept = {};
38329
+ for (const [name, server] of Object.entries(mcpServers)) {
38330
+ const { enabledTools } = server;
38331
+ if (Array.isArray(enabledTools)) {
38332
+ if ((ignoreBlankNames ? enabledTools.filter((tool) => typeof tool !== "string" || tool.trim() !== "") : enabledTools).length === 0) {
38333
+ warnAndSkipMcpServer({
38334
+ toolName,
38335
+ serverName: name,
38336
+ reason: `an empty enabledTools list, which allows no tools at all but which ${productName} treats as no filter`,
38337
+ logger
38338
+ });
38339
+ continue;
38340
+ }
38341
+ }
38342
+ Object.defineProperty(kept, name, {
38343
+ value: server,
38344
+ enumerable: true,
38345
+ writable: true,
38346
+ configurable: true
38347
+ });
38348
+ }
38349
+ return kept;
38350
+ }
38351
+ /**
38066
38352
  * The `tools` map Kilo and OpenCode keep beside `mcp` is keyed `<server>_<tool>`
38067
38353
  * and reaches servers `mcp` does not list — ones the global config or a
38068
38354
  * marketplace defines — so entries naming no listed
@@ -38624,7 +38910,11 @@ var KiloMcp = class KiloMcp extends ToolMcp {
38624
38910
  const existingMcp = readExistingKiloMcpEntries(fileContent);
38625
38911
  const parsedExisting = parse$1(fileContent || "{}");
38626
38912
  const existingDocument = isRecord(parsedExisting) ? parsedExisting : {};
38627
- const mcpServers = rulesyncMcp.getMcpServers();
38913
+ const mcpServers = skipServersWithEmptyEnabledTools({
38914
+ mcpServers: rulesyncMcp.getMcpServers(),
38915
+ toolName: "Kilo",
38916
+ logger
38917
+ });
38628
38918
  const { mcp: convertedMcp, toolPermissions } = convertToKiloFormat(resolveKiloEnvVarRefs({
38629
38919
  mcpServers,
38630
38920
  global,
@@ -38753,7 +39043,8 @@ const OPENCODE_PASSTHROUGH_SERVER_FIELDS = [
38753
39043
  "timeout",
38754
39044
  "oauth",
38755
39045
  "protocol",
38756
- "codemode"
39046
+ "codemode",
39047
+ "pool"
38757
39048
  ];
38758
39049
  const OPENCODE_MCP_TRANSPORT_KEYS = [
38759
39050
  ...Object.keys(OpencodeMcpLocalServerSchema.def.shape),
@@ -38916,7 +39207,7 @@ function warnAboutToggleDroppedKeys(serverName, serverConfig, logger) {
38916
39207
  * - env -> environment
38917
39208
  * - disabled -> enabled (inverted)
38918
39209
  * - enabledTools/disabledTools -> top-level tools map (with server name prefix)
38919
- * - OpenCode-supported extras (timeout, oauth, protocol, codemode) -> passed through verbatim
39210
+ * - OpenCode-supported extras (timeout, oauth, protocol, codemode, pool) -> passed through verbatim
38920
39211
  */
38921
39212
  function convertServerToOpencodeFormat(serverName, serverConfig, existingEntry, configShape, logger) {
38922
39213
  const serverRecord = serverConfig;
@@ -39156,7 +39447,11 @@ var OpencodeMcp = class extends ToolMcp {
39156
39447
  if (fileContent) relativeFilePath = this.layout.jsonFileName;
39157
39448
  }
39158
39449
  const transformedServers = convertEnvVarRefsToToolFormat({
39159
- mcpServers: rulesyncMcp.getMcpServers(),
39450
+ mcpServers: skipServersWithEmptyEnabledTools({
39451
+ mcpServers: rulesyncMcp.getMcpServers(),
39452
+ toolName: "OpenCode",
39453
+ logger
39454
+ }),
39160
39455
  replacement: "{env:$1}"
39161
39456
  });
39162
39457
  const existingJson = parse$1(fileContent || "{}");
@@ -48752,6 +49047,9 @@ var RulesProcessor = class extends FeatureProcessor {
48752
49047
  language;
48753
49048
  deriveSubprojectPathFromGlobs;
48754
49049
  global;
49050
+ sweepsThroughInRootLinks() {
49051
+ return this.global;
49052
+ }
48755
49053
  getFactory;
48756
49054
  skills;
48757
49055
  featureOptions;
@@ -51336,12 +51634,29 @@ var HermesagentCheck = class HermesagentCheck extends ToolCheck {
51336
51634
  };
51337
51635
  }
51338
51636
  }
51637
+ /**
51638
+ * The `.rulesync/checks/<stem>.md` name an imported spec gets. The slug is
51639
+ * read from a spec file that may come from a fetched or cloned repository,
51640
+ * so it is kept as written only when it is a single plain file name; a slug
51641
+ * such as `../../docs/OPERATOR` would otherwise move the write out of
51642
+ * `.rulesync/checks/`. Anything else is slugified, and an empty result falls
51643
+ * back to the spec file's own name.
51644
+ */
51645
+ importedCheckStem(slug) {
51646
+ const isPlainFileName = (name) => name !== "" && name !== "." && name !== ".." && !/[/\\<>:"|?*]/.test(name) && !hasWindowsFoldableName(name) && stripControlCharacters(name) === name;
51647
+ if (isPlainFileName(slug)) return slug;
51648
+ for (const candidate of [slugifyCheckName(slug), slugifyCheckName(basename(this.relativeFilePath, ".json"))]) {
51649
+ if (candidate === "") continue;
51650
+ return isPlainFileName(candidate) ? candidate : `${candidate}-check`;
51651
+ }
51652
+ return "check";
51653
+ }
51339
51654
  toRulesyncCheck() {
51340
51655
  const spec = HermesCheckSpecSchema.parse(JSON.parse(this.fileContent));
51341
51656
  return new RulesyncCheck({
51342
51657
  outputRoot: ".",
51343
51658
  relativeDirPath: RULESYNC_CHECKS_RELATIVE_DIR_PATH,
51344
- relativeFilePath: `${spec.slug}.md`,
51659
+ relativeFilePath: `${this.importedCheckStem(spec.slug)}.md`,
51345
51660
  frontmatter: {
51346
51661
  targets: ["*"],
51347
51662
  description: spec.description,
@@ -51849,6 +52164,9 @@ const checksProcessorToolTargetsGlobal = allToolTargetKeys.filter((target) => {
51849
52164
  var ChecksProcessor = class extends FeatureProcessor {
51850
52165
  toolTarget;
51851
52166
  global;
52167
+ sweepsThroughInRootLinks() {
52168
+ return this.global;
52169
+ }
51852
52170
  getFactory;
51853
52171
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
51854
52172
  super({
@@ -54160,6 +54478,10 @@ function definingFields({ h, rawEntry, hookType, converterConfig }) {
54160
54478
  field: "tool",
54161
54479
  value: h.tool
54162
54480
  });
54481
+ for (const { canonical, tool } of converterConfig.stringPassthroughFields ?? []) if (canonical === "if" && h[tool] !== "") fields.push({
54482
+ field: "if",
54483
+ value: h[tool]
54484
+ });
54163
54485
  return fields;
54164
54486
  }
54165
54487
  /**
@@ -62936,10 +63258,12 @@ function emittedEventsWithHook(params) {
62936
63258
  function converterCapabilities(config) {
62937
63259
  if (!config.supportedHookTypes) throw new Error("Hooks converter config must declare its supportedHookTypes");
62938
63260
  const passesThroughEnabled = (config.booleanPassthroughFields ?? []).some((field) => field.canonical === "enabled");
63261
+ const passesThroughIf = (config.stringPassthroughFields ?? []).some((field) => field.canonical === "if") && !(config.skippedWhenFields ?? []).includes("if");
62939
63262
  return {
62940
63263
  supportedEvents: config.supportedEvents,
62941
63264
  supportedHookTypes: [...config.supportedHookTypes],
62942
- ...passesThroughEnabled ? { supportsPerHookEnabled: true } : {}
63265
+ ...passesThroughEnabled ? { supportsPerHookEnabled: true } : {},
63266
+ ...passesThroughIf ? { supportsPerHookIf: true } : {}
62943
63267
  };
62944
63268
  }
62945
63269
  const toolHooksFactories = /* @__PURE__ */ new Map([
@@ -63584,6 +63908,9 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
63584
63908
  var HooksProcessor = class extends FeatureProcessor {
63585
63909
  toolTarget;
63586
63910
  global;
63911
+ sweepsThroughInRootLinks() {
63912
+ return this.global;
63913
+ }
63587
63914
  preserveUnownedHooks;
63588
63915
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, preserveUnownedHooks = false, logger }) {
63589
63916
  super({
@@ -63662,7 +63989,8 @@ var HooksProcessor = class extends FeatureProcessor {
63662
63989
  if (!factory) throw new Error(`Unsupported tool target: ${this.toolTarget}`);
63663
63990
  const config = rulesyncHooks.getJson();
63664
63991
  const sharedHooks = config.hooks;
63665
- const overrideHooks = config[HOOKS_OVERRIDE_KEY_ALIASES[this.toolTarget] ?? this.toolTarget]?.hooks ?? {};
63992
+ const overrideKey = HOOKS_OVERRIDE_KEY_ALIASES[this.toolTarget] ?? this.toolTarget;
63993
+ const overrideHooks = config[overrideKey]?.hooks ?? {};
63666
63994
  const effectiveHooks = {
63667
63995
  ...sharedHooks,
63668
63996
  ...overrideHooks
@@ -63700,7 +64028,10 @@ var HooksProcessor = class extends FeatureProcessor {
63700
64028
  if (eventsWithUnsupportedMatcher.length > 0) this.logger.warn(`Skipped matcher hook(s) for ${this.toolTarget} (not supported): ${eventsWithUnsupportedMatcher.join(", ")}`);
63701
64029
  const toolHooks = await factory.class.fromRulesyncHooks({
63702
64030
  outputRoot: this.outputRoot,
63703
- rulesyncHooks,
64031
+ rulesyncHooks: factory.supportsPerHookIf ? rulesyncHooks : this.withoutConditionalHooks({
64032
+ rulesyncHooks,
64033
+ overrideKey
64034
+ }),
63704
64035
  validate: true,
63705
64036
  global: this.global,
63706
64037
  preserveUnowned: this.preserveUnownedHooks,
@@ -63723,6 +64054,53 @@ var HooksProcessor = class extends FeatureProcessor {
63723
64054
  return result;
63724
64055
  }
63725
64056
  /**
64057
+ * Returns the hooks source without the definitions that carry a per-hook
64058
+ * `if` condition, warning about each one. Used for targets that cannot
64059
+ * express `if`: emitting such a hook without its condition would make it
64060
+ * fire on every call its matcher selects, so a permission-granting hook
64061
+ * scoped by `if` would approve far more than its author meant.
64062
+ */
64063
+ withoutConditionalHooks(params) {
64064
+ const { rulesyncHooks, overrideKey } = params;
64065
+ const config = rulesyncHooks.getJson();
64066
+ const skipped = [];
64067
+ const filterHooks = (hooks) => {
64068
+ if (!isPlainObject(hooks)) return hooks;
64069
+ const filtered = {};
64070
+ for (const [event, defs] of Object.entries(hooks)) {
64071
+ if (!Array.isArray(defs)) {
64072
+ filtered[event] = defs;
64073
+ continue;
64074
+ }
64075
+ filtered[event] = defs.filter((def) => {
64076
+ const condition = isPlainObject(def) ? def.if : void 0;
64077
+ if (typeof condition !== "string" || condition.trim() === "") return true;
64078
+ skipped.push(`${event} (${quoteValueForWarning(condition)})`);
64079
+ return false;
64080
+ });
64081
+ }
64082
+ return filtered;
64083
+ };
64084
+ const filteredConfig = {
64085
+ ...config,
64086
+ hooks: filterHooks(config.hooks)
64087
+ };
64088
+ const override = config[overrideKey];
64089
+ if (Object.hasOwn(config, overrideKey) && isPlainObject(override)) filteredConfig[overrideKey] = {
64090
+ ...override,
64091
+ hooks: filterHooks(override.hooks)
64092
+ };
64093
+ if (skipped.length === 0) return rulesyncHooks;
64094
+ this.logger.warn(`Skipped hook(s) with an "if" condition for ${this.toolTarget} (only the ${targetsSupporting("supportsPerHookIf")} hooks formats support it, and without it the hook would run on every call its matcher selects): ${skipped.join(", ")}`);
64095
+ return new RulesyncHooks({
64096
+ outputRoot: rulesyncHooks.getOutputRoot(),
64097
+ relativeDirPath: rulesyncHooks.getRelativeDirPath(),
64098
+ relativeFilePath: rulesyncHooks.getRelativeFilePath(),
64099
+ fileContent: JSON.stringify(filteredConfig),
64100
+ validate: false
64101
+ });
64102
+ }
64103
+ /**
63726
64104
  * Warns about canonical per-hook fields that this target's hooks format
63727
64105
  * cannot express, so a hook does not silently change behavior on generation.
63728
64106
  * Only canonical definitions are considered: a tool-native field inside an
@@ -63967,6 +64345,35 @@ var BobIgnore = class extends PlainToolIgnore {
63967
64345
  };
63968
64346
  }
63969
64347
  };
64348
+ // src/features/ignore/claude-read-deny-pattern.ts
64349
+ /**
64350
+ * Translate the path of a Claude Code style `Read(<path>)` deny into the
64351
+ * gitignore syntax of `.rulesync/.aiignore`.
64352
+ *
64353
+ * Claude Code reads the path forms differently from gitignore: `./path` is
64354
+ * relative to the current directory, `/path` to the project root, `//path` is
64355
+ * absolute and `~/path` is under the home directory. Copied verbatim, the
64356
+ * common `Read(./.env)` idiom would become `./.env`, which matches nothing in
64357
+ * a gitignore-syntax ignore file, so every other tool would lose the deny.
64358
+ *
64359
+ * - `./path` is anchored to the project root as `/path`.
64360
+ * - `//path` and `~/path` (or a bare `~`) name locations a project-relative
64361
+ * ignore file cannot express; they are skipped with a warning and `null` is
64362
+ * returned.
64363
+ * - Every other path (`.env`, `secrets/**`, `/path`) already means the same
64364
+ * project files in gitignore syntax and is kept as is.
64365
+ */
64366
+ function claudeReadDenyPathToIgnorePattern({ path, toolLabel, logger }) {
64367
+ if (path.startsWith("//") || path === "~" || path.startsWith("~/")) {
64368
+ warnWithFallback(logger, `Skipping ${toolLabel} read deny ${quoteValueForWarning(path)} on ignore import: an absolute or home-directory path cannot be expressed in the project-relative .rulesync/.aiignore.`);
64369
+ return null;
64370
+ }
64371
+ if (path.startsWith("./")) {
64372
+ const rest = path.slice(2).replace(/^(?:\.\/)+/, "");
64373
+ return rest.length > 0 ? `/${rest}` : null;
64374
+ }
64375
+ return path;
64376
+ }
63970
64377
  // src/features/ignore/claudecode-ignore.ts
63971
64378
  const DEFAULT_FILE_MODE = "shared";
63972
64379
  /**
@@ -64025,9 +64432,12 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
64025
64432
  }
64026
64433
  toRulesyncIgnore() {
64027
64434
  const fileContent = this.patterns.map((pattern) => {
64028
- if (isReadDenyEntry(pattern)) return pattern.slice(5, -1);
64435
+ if (isReadDenyEntry(pattern)) return claudeReadDenyPathToIgnorePattern({
64436
+ path: pattern.slice(5, -1),
64437
+ toolLabel: "Claude Code"
64438
+ });
64029
64439
  return pattern;
64030
- }).filter((pattern) => pattern.length > 0).join("\n");
64440
+ }).filter((pattern) => pattern !== null && pattern.length > 0).join("\n");
64031
64441
  return new RulesyncIgnore({
64032
64442
  outputRoot: this.outputRoot,
64033
64443
  relativeDirPath: RulesyncIgnore.getSettablePaths().recommended.relativeDirPath,
@@ -64302,7 +64712,7 @@ var FirebenderIgnore = class FirebenderIgnore extends ToolIgnore {
64302
64712
  const paths = this.getSettablePaths({ global });
64303
64713
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
64304
64714
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
64305
- const managedPatterns = [...new Set(patterns)];
64715
+ const managedPatterns = patterns.filter((pattern, index) => patterns.lastIndexOf(pattern) === index);
64306
64716
  return new FirebenderIgnore({
64307
64717
  outputRoot,
64308
64718
  relativeDirPath: paths.relativeDirPath,
@@ -64957,7 +65367,10 @@ const asClaudeStyleSettings = (document) => {
64957
65367
  };
64958
65368
  /** The pattern of a `read_file(...)` (or legacy `Read(...)`) deny, else null. */
64959
65369
  const readDenyPatternOf = (entry) => {
64960
- for (const toolName of [REASONIX_READ_TOOL_NAME, REASONIX_LEGACY_READ_TOOL_NAME]) if (isReadDenyEntry(entry, toolName)) return entry.slice(toolName.length + 1, -1);
65370
+ for (const toolName of [REASONIX_READ_TOOL_NAME, REASONIX_LEGACY_READ_TOOL_NAME]) if (isReadDenyEntry(entry, toolName)) return claudeReadDenyPathToIgnorePattern({
65371
+ path: entry.slice(toolName.length + 1, -1),
65372
+ toolLabel: "Reasonix"
65373
+ });
64961
65374
  return null;
64962
65375
  };
64963
65376
  /**
@@ -65297,6 +65710,9 @@ var IgnoreProcessor = class extends FeatureProcessor {
65297
65710
  getFactory;
65298
65711
  featureOptions;
65299
65712
  global;
65713
+ sweepsThroughInRootLinks() {
65714
+ return this.global;
65715
+ }
65300
65716
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$1, global = false, dryRun = false, logger, featureOptions }) {
65301
65717
  super({
65302
65718
  outputRoot,
@@ -65630,30 +66046,71 @@ function isEmptyValue(value) {
65630
66046
  if (Array.isArray(value)) return value.length === 0;
65631
66047
  return isPlainObject(value) && Object.keys(value).length === 0;
65632
66048
  }
66049
+ const MILLISECONDS_PER_SECOND$4 = 1e3;
66050
+ /**
66051
+ * Convert the canonical `timeout` (milliseconds) into Antigravity's
66052
+ * `timeoutSeconds` (integer seconds), rounding up so a sub-second timeout does
66053
+ * not become `0`. A negative value disables the timeout upstream, so it is
66054
+ * written as `-1`. An authored native `timeoutSeconds` wins over `timeout`.
66055
+ */
66056
+ function convertTimeoutToAntigravity({ serverName, config, logger }) {
66057
+ if (!("timeout" in config)) return config;
66058
+ const { timeout, ...rest } = config;
66059
+ if (rest.timeoutSeconds !== void 0) {
66060
+ logger?.warn(`Antigravity MCP: ${quoteValueForWarning(serverName)} sets both "timeout" and "timeoutSeconds"; keeping "timeoutSeconds" and dropping "timeout".`);
66061
+ return rest;
66062
+ }
66063
+ if (typeof timeout !== "number" || !Number.isFinite(timeout)) {
66064
+ logger?.warn(`Antigravity MCP: dropping the "timeout" of ${quoteValueForWarning(serverName)}: expected a number of milliseconds.`);
66065
+ return rest;
66066
+ }
66067
+ const timeoutSeconds = timeout < 0 ? -1 : Math.ceil(timeout / MILLISECONDS_PER_SECOND$4);
66068
+ return Object.fromEntries(Object.entries(config).map(([key, value]) => key === "timeout" ? ["timeoutSeconds", timeoutSeconds] : [key, value]));
66069
+ }
65633
66070
  /**
65634
- * Antigravity uses `serverUrl` (not the canonical `url`) for HTTP/SSE MCP
66071
+ * Antigravity uses `serverUrl` (not the canonical `url`) for remote MCP
65635
66072
  * servers. Convert the canonical shape into Antigravity's on the way out, and
65636
66073
  * back to canonical on the way in.
65637
66074
  *
65638
66075
  * Antigravity documents no `type` field: the transport follows from `command`
65639
- * (stdio) or `serverUrl` (Streamable HTTP / SSE), and `agy plugin validate`
66076
+ * (stdio) or `serverUrl` (Streamable HTTP), and `agy plugin validate`
65640
66077
  * reports the canonical `type` as an unknown field, so it is not written. A
65641
66078
  * remote server that carries `command`, `args` or `env` was also observed to be
65642
66079
  * refused at startup (runtime behavior reported by `agy plugin validate` 1.4.4,
65643
66080
  * not documented), so an empty `args` / `env` left on a `serverUrl` server is
65644
66081
  * dropped.
66082
+ *
66083
+ * Remote servers must use Streamable HTTP: the legacy HTTP+SSE transport is
66084
+ * not supported, so a canonical `sse` server is still written but warned
66085
+ * about. The canonical `timeout` (milliseconds) becomes `timeoutSeconds`.
65645
66086
  * https://antigravity.google/docs/mcp
65646
66087
  */
65647
- function toAntigravityMcpServers(servers) {
66088
+ function toAntigravityMcpServers(servers, logger) {
65648
66089
  const renamed = renameServerField(servers, "url", "serverUrl");
65649
66090
  return Object.fromEntries(Object.entries(renamed).map(([name, config]) => {
65650
66091
  if (!isPlainObject(config)) return [name, config];
66092
+ if (config.type === "sse" || config.transport === "sse") logger?.warn(`Antigravity MCP: ${quoteValueForWarning(name)} uses the SSE transport, which Antigravity does not support (remote servers must use Streamable HTTP); it is written as "serverUrl" and may fail to connect.`);
65651
66093
  const isRemote = typeof config.serverUrl === "string";
65652
- return [name, Object.fromEntries(Object.entries(config).filter(([key, value]) => key !== "type" && !(isRemote && (key === "args" || key === "env") && isEmptyValue(value))))];
66094
+ return [name, convertTimeoutToAntigravity({
66095
+ serverName: name,
66096
+ config: Object.fromEntries(Object.entries(config).filter(([key, value]) => key !== "type" && !(isRemote && (key === "args" || key === "env") && isEmptyValue(value)))),
66097
+ logger
66098
+ })];
65653
66099
  }));
65654
66100
  }
66101
+ /**
66102
+ * Convert Antigravity servers back to the canonical shape: `serverUrl` becomes
66103
+ * `url`, and `timeoutSeconds` becomes the canonical `timeout` in milliseconds
66104
+ * (a negative value, which disables the timeout upstream, stays `-1`).
66105
+ */
65655
66106
  function toCanonicalMcpServers(servers) {
65656
- return renameServerField(servers, "serverUrl", "url");
66107
+ const renamed = renameServerField(servers, "serverUrl", "url");
66108
+ return Object.fromEntries(Object.entries(renamed).map(([name, config]) => {
66109
+ if (!isPlainObject(config) || typeof config.timeoutSeconds !== "number") return [name, config];
66110
+ const timeoutSeconds = config.timeoutSeconds;
66111
+ const timeout = timeoutSeconds < 0 ? -1 : timeoutSeconds * MILLISECONDS_PER_SECOND$4;
66112
+ return [name, Object.fromEntries(Object.entries(config).filter(([key]) => key !== "timeout").map(([key, value]) => key === "timeoutSeconds" ? ["timeout", timeout] : [key, value]))];
66113
+ }));
65657
66114
  }
65658
66115
  /**
65659
66116
  * Parse `mcp_config.json` as JSONC and require a top-level object, so a file
@@ -65671,7 +66128,8 @@ function parseAntigravityMcpConfig(fileContent) {
65671
66128
  * the IDE and the CLI.
65672
66129
  *
65673
66130
  * Antigravity writes a dedicated `mcp_config.json` whose `mcpServers` map lives
65674
- * at the top level (`disabledTools` per server is supported). Unlike Gemini
66131
+ * at the top level (`enabledTools` / `disabledTools` per server are supported
66132
+ * under the same names). Unlike Gemini
65675
66133
  * CLI's shared `settings.json`, this file is dedicated to MCP, so it is safely
65676
66134
  * deletable. HTTP servers use `serverUrl` rather than the canonical `url`, so
65677
66135
  * the field is translated in both directions.
@@ -65724,11 +66182,11 @@ var AntigravityMcp = class extends ToolMcp {
65724
66182
  global
65725
66183
  });
65726
66184
  }
65727
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
66185
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
65728
66186
  const paths = this.getSettablePaths({ global });
65729
66187
  const newJson = {
65730
66188
  ...parseAntigravityMcpConfig(await readFileContentOrNull(join(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({ mcpServers: {} }, null, 2)),
65731
- mcpServers: toAntigravityMcpServers(rulesyncMcp.getMcpServers())
66189
+ mcpServers: toAntigravityMcpServers(rulesyncMcp.getMcpServers(), logger)
65732
66190
  };
65733
66191
  return new this({
65734
66192
  outputRoot,
@@ -69397,7 +69855,11 @@ var CrushMcp = class CrushMcp extends ToolMcp {
69397
69855
  ownedPaths: [["mcp"]],
69398
69856
  logger
69399
69857
  });
69400
- const converted = convertToCrushFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => [serverName, {
69858
+ const converted = convertToCrushFormat(Object.fromEntries(Object.entries(skipServersWithEmptyEnabledTools({
69859
+ mcpServers: rulesyncMcp.getMcpServers(),
69860
+ toolName: "Crush",
69861
+ logger
69862
+ })).map(([serverName, serverConfig]) => [serverName, {
69401
69863
  ...serverConfig,
69402
69864
  ...readCrushOnlyKeys({
69403
69865
  serverName,
@@ -70357,31 +70819,6 @@ var DshMcp = class DshMcp extends ToolMcp {
70357
70819
  };
70358
70820
  // src/features/mcp/factorydroid-mcp.ts
70359
70821
  const TOOL_NAME = "factorydroid";
70360
- /**
70361
- * Drop servers whose `enabledTools` allowlist is empty.
70362
- *
70363
- * Canonically an empty allowlist allows no tools, but Droid applies
70364
- * `enabledTools` only "when the list is non-empty"
70365
- * (https://docs.factory.com/harness/mcp), so writing `[]` would load every
70366
- * tool the server reports. Skipping the server is the only form that does not
70367
- * hand the model more tools than the canonical config allows.
70368
- */
70369
- function skipEmptyAllowlistServers({ mcpServers, logger }) {
70370
- const kept = {};
70371
- for (const [name, server] of Object.entries(mcpServers)) {
70372
- if (server.enabledTools?.length === 0) {
70373
- warnAndSkipMcpServer({
70374
- toolName: TOOL_NAME,
70375
- serverName: name,
70376
- reason: "an empty enabledTools list, which allows no tools at all but which Factory Droid treats as no filter",
70377
- logger
70378
- });
70379
- continue;
70380
- }
70381
- kept[name] = server;
70382
- }
70383
- return kept;
70384
- }
70385
70822
  var FactorydroidMcp = class FactorydroidMcp extends ToolMcp {
70386
70823
  json;
70387
70824
  constructor(params) {
@@ -70408,8 +70845,10 @@ var FactorydroidMcp = class FactorydroidMcp extends ToolMcp {
70408
70845
  });
70409
70846
  }
70410
70847
  static fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, logger }) {
70411
- const factorydroidConfig = { mcpServers: skipEmptyAllowlistServers({
70848
+ const factorydroidConfig = { mcpServers: skipServersWithEmptyEnabledTools({
70412
70849
  mcpServers: rulesyncMcp.getMcpServers(),
70850
+ toolName: TOOL_NAME,
70851
+ productName: "Factory Droid",
70413
70852
  logger
70414
70853
  }) };
70415
70854
  const fileContent = JSON.stringify(factorydroidConfig, null, 2);
@@ -71840,19 +72279,42 @@ function normalizeKimiCodeTransport({ transport, hasCommand }) {
71840
72279
  return hasCommand ? "stdio" : "http";
71841
72280
  }
71842
72281
  /**
71843
- * The only `executor` value Kimi Code accepts on a stdio server. 2.2.0 removed
71844
- * the never-implemented `kaos` option and narrowed the schema to
71845
- * `z.enum(['local'])`, so any other value makes Kimi fail validation when it
71846
- * loads `mcp.json`.
72282
+ * The only `executor` value every current Kimi Code release accepts on a stdio
72283
+ * server. 2.2.0 removed the never-implemented `kaos` option and narrowed the
72284
+ * schema to `z.enum(['local'])`, so 2.2.0 through 2.4.x fail validation on any
72285
+ * other value when they load `mcp.json`. 2.5.0 widened the enum back to
72286
+ * `'local' | 'kaos'`, but `kaos` is still undocumented and would break the
72287
+ * older releases, so it keeps being dropped.
71847
72288
  * @see https://github.com/MoonshotAI/kimi-code/pull/4184
72289
+ * @see https://github.com/MoonshotAI/kimi-code/pull/4062
71848
72290
  */
71849
72291
  const KIMI_CODE_MCP_EXECUTOR = "local";
71850
72292
  /** Remove, with a warning, an `executor` value Kimi Code would reject. Mutates `server`. */
71851
72293
  function dropUnsupportedKimiCodeExecutor({ name, server, logger }) {
71852
72294
  if (server.executor === void 0 || server.executor === KIMI_CODE_MCP_EXECUTOR) return;
71853
- warnWithFallback(logger, `Kimi Code MCP: dropping executor ${JSON.stringify(server.executor)} from "${name}" because Kimi Code only accepts "${KIMI_CODE_MCP_EXECUTOR}".`);
72295
+ warnWithFallback(logger, `Kimi Code MCP: dropping executor ${JSON.stringify(server.executor)} from "${name}" because Kimi Code 2.2.0 through 2.4.x accept only "${KIMI_CODE_MCP_EXECUTOR}".`);
71854
72296
  delete server.executor;
71855
72297
  }
72298
+ /**
72299
+ * Remove, with a warning, an `environment_id` Kimi Code cannot use. Kimi Code
72300
+ * 2.5.0 added it to stdio servers only, as a non-empty string naming an
72301
+ * `[environments.<id>]` entry of `config.toml`; an empty or non-string value
72302
+ * fails Kimi's validation. Mutates `server`.
72303
+ * @see https://github.com/MoonshotAI/kimi-code/pull/4062
72304
+ */
72305
+ function dropUnsupportedKimiCodeEnvironmentId({ name, server, isStdio, logger }) {
72306
+ const environmentId = server.environment_id;
72307
+ if (environmentId === void 0) return;
72308
+ if (!isStdio) {
72309
+ warnWithFallback(logger, `Kimi Code MCP: dropping environment_id from "${name}" because Kimi Code only accepts it on stdio servers.`);
72310
+ delete server.environment_id;
72311
+ return;
72312
+ }
72313
+ if (typeof environmentId !== "string" || environmentId.length === 0) {
72314
+ warnWithFallback(logger, `Kimi Code MCP: dropping environment_id ${JSON.stringify(environmentId)} from "${name}" because Kimi Code requires a non-empty string.`);
72315
+ delete server.environment_id;
72316
+ }
72317
+ }
71856
72318
  function toKimiCodeServer({ name, server, logger }) {
71857
72319
  const transport = server.transport ?? server.type;
71858
72320
  if (transport === "ws") {
@@ -71880,6 +72342,7 @@ function toKimiCodeServer({ name, server, logger }) {
71880
72342
  for (const field of [
71881
72343
  "env",
71882
72344
  "cwd",
72345
+ "environment_id",
71883
72346
  "executor",
71884
72347
  "runtime_id",
71885
72348
  "headers",
@@ -71896,6 +72359,12 @@ function toKimiCodeServer({ name, server, logger }) {
71896
72359
  server: converted,
71897
72360
  logger
71898
72361
  });
72362
+ dropUnsupportedKimiCodeEnvironmentId({
72363
+ name,
72364
+ server: converted,
72365
+ isStdio: normalizedTransport === "stdio",
72366
+ logger
72367
+ });
71899
72368
  if (server.disabled === true) converted.enabled = false;
71900
72369
  return converted;
71901
72370
  }
@@ -71920,6 +72389,15 @@ function fromKimiCodeServers({ servers, logger }) {
71920
72389
  logger
71921
72390
  });
71922
72391
  const type = transport === "stdio" || transport === "sse" || transport === "http" ? transport : void 0;
72392
+ dropUnsupportedKimiCodeEnvironmentId({
72393
+ name,
72394
+ server: rest,
72395
+ isStdio: normalizeKimiCodeTransport({
72396
+ transport,
72397
+ hasCommand: rest.command !== void 0
72398
+ }) === "stdio",
72399
+ logger
72400
+ });
71923
72401
  return [name, {
71924
72402
  ...rest,
71925
72403
  ...type && { type },
@@ -73088,7 +73566,12 @@ var OpenclawMcp = class OpenclawMcp extends ToolMcp {
73088
73566
  const existingMcp = isRecord(existing["mcp"]) ? existing["mcp"] : {};
73089
73567
  const existingServers = isRecord(existingMcp["servers"]) ? existingMcp[OPENCLAW_MCP_SERVERS_KEY] : {};
73090
73568
  const converted = convertToOpenclawFormat({
73091
- mcpServers: rulesyncMcp.getMcpServers(),
73569
+ mcpServers: skipServersWithEmptyEnabledTools({
73570
+ mcpServers: rulesyncMcp.getMcpServers(),
73571
+ toolName: "OpenClaw",
73572
+ ignoreBlankNames: true,
73573
+ logger
73574
+ }),
73092
73575
  logger
73093
73576
  });
73094
73577
  const servers = {
@@ -73716,7 +74199,11 @@ var PoolMcp = class PoolMcp extends ToolMcp {
73716
74199
  const paths = this.getSettablePaths({ global });
73717
74200
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
73718
74201
  const existingContent = await readFileContentOrNull(filePath) ?? "";
73719
- const converted = convertToPoolFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => {
74202
+ const converted = convertToPoolFormat(Object.fromEntries(Object.entries(skipServersWithEmptyEnabledTools({
74203
+ mcpServers: rulesyncMcp.getMcpServers(),
74204
+ toolName: "Pool",
74205
+ logger
74206
+ })).map(([serverName, serverConfig]) => {
73720
74207
  const rawServer = rulesyncMcp.getRawMcpServer(serverName);
73721
74208
  const poolAllow = isRecord(rawServer) ? rawServer.poolAllow : void 0;
73722
74209
  return [serverName, {
@@ -77242,7 +77729,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77242
77729
  meta: {
77243
77730
  supportsProject: true,
77244
77731
  supportsGlobal: true,
77245
- supportsEnabledTools: false,
77732
+ supportsEnabledTools: true,
77246
77733
  supportsDisabledTools: true
77247
77734
  }
77248
77735
  }],
@@ -77251,7 +77738,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77251
77738
  meta: {
77252
77739
  supportsProject: true,
77253
77740
  supportsGlobal: true,
77254
- supportsEnabledTools: false,
77741
+ supportsEnabledTools: true,
77255
77742
  supportsDisabledTools: true
77256
77743
  }
77257
77744
  }],
@@ -77260,7 +77747,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77260
77747
  meta: {
77261
77748
  supportsProject: true,
77262
77749
  supportsGlobal: false,
77263
- supportsEnabledTools: false,
77750
+ supportsEnabledTools: true,
77264
77751
  supportsDisabledTools: true
77265
77752
  }
77266
77753
  }],
@@ -77827,6 +78314,9 @@ function warnStrippedMcpServerFields({ mcpServers, fields, toolTarget, logger })
77827
78314
  var McpProcessor = class extends FeatureProcessor {
77828
78315
  toolTarget;
77829
78316
  global;
78317
+ sweepsThroughInRootLinks() {
78318
+ return this.global;
78319
+ }
77830
78320
  getFactory;
77831
78321
  featureOptions;
77832
78322
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory, dryRun = false, logger, featureOptions }) {
@@ -79936,12 +80426,30 @@ function toPathTarget(pattern) {
79936
80426
  "**",
79937
80427
  "**/*"
79938
80428
  ].includes(path)) return { target: "." };
79939
- const directory = path.replace(DIRECTORY_CONTENTS_SUFFIX, "") || (path.startsWith("/") ? "/" : "");
80429
+ const stripped = path.replace(DIRECTORY_CONTENTS_SUFFIX, "");
80430
+ const directory = stripped === "" && path.startsWith("/") ? "/" : /^[A-Za-z]:$/.test(stripped) && stripped !== path ? `${stripped}/` : stripped;
79940
80431
  if (directory !== path && directory.length > 0 && !hasGlob(directory)) return { target: directory };
79941
80432
  if (!hasGlob(path)) return { target: path };
79942
80433
  return { skipReason: "Antigravity file targets are paths, not globs. Only a plain path, `dir/**` or `*` can be expressed" };
79943
80434
  }
79944
80435
  /**
80436
+ * The canonical patterns a `read_file` / `write_file` target imports as. A
80437
+ * target can name a file or a directory, and a directory covers everything
80438
+ * inside it, but the target alone does not say which. A plain path such as
80439
+ * `src` imports as both `src` and `src/**`, so re-exporting it to a tool whose
80440
+ * plain path matches only that one path keeps covering the subtree; regenerating
80441
+ * collapses both back into one target. The bare `*`, and a target that already
80442
+ * holds glob characters (verbatim output of an older Rulesync), stay as written.
80443
+ */
80444
+ function fromPathTarget(target) {
80445
+ if (target === "*" || hasGlob(target) || /^[A-Za-z]:$/.test(target)) return [target];
80446
+ if (target === ".") return [".", "./**"];
80447
+ const root = /^((?:[A-Za-z]:)?\/)\/*$/.exec(target)?.[1];
80448
+ if (root !== void 0) return [root, `${root}**`];
80449
+ const path = target.replace(/\/+$/, "");
80450
+ return [path, `${path}/**`];
80451
+ }
80452
+ /**
79945
80453
  * A URL or domain pattern becomes its hostname. A leading `*.` is dropped,
79946
80454
  * since a domain already covers its subdomains, and the URL path is ignored.
79947
80455
  * Claude Code's `domain:` form is read as well.
@@ -80158,19 +80666,21 @@ function convertAntigravityToRulesyncPermissions({ allow, ask, deny, toCategory,
80158
80666
  for (const entry of entries) {
80159
80667
  const { toolName, pattern: target } = parseToolPatternEntry(entry);
80160
80668
  const canonical = toCategory(toolName);
80161
- const pattern = ANTIGRAVITY_COMMAND_ACTIONS.has(toolName) ? importAntigravityCommandTarget({
80669
+ const patterns = ANTIGRAVITY_COMMAND_ACTIONS.has(toolName) ? [importAntigravityCommandTarget({
80162
80670
  target,
80163
80671
  entry,
80164
80672
  decision: action,
80165
80673
  logger,
80166
80674
  toolLabel
80167
- }) : PATH_ACTIONS.has(toolName) ? collapseLeadingHome(target, homeDirectory) : target;
80168
- if (isPrototypePollutionKey(canonical) || isPrototypePollutionKey(pattern)) continue;
80169
- getOrCreateOwn({
80170
- record: permission,
80171
- key: canonical,
80172
- create: () => ({})
80173
- })[pattern] = action;
80675
+ })] : PATH_ACTIONS.has(toolName) ? fromPathTarget(collapseLeadingHome(target, homeDirectory)) : [target];
80676
+ for (const pattern of patterns) {
80677
+ if (isPrototypePollutionKey(canonical) || isPrototypePollutionKey(pattern)) continue;
80678
+ getOrCreateOwn({
80679
+ record: permission,
80680
+ key: canonical,
80681
+ create: () => ({})
80682
+ })[pattern] = action;
80683
+ }
80174
80684
  }
80175
80685
  };
80176
80686
  processEntries(allow, "allow");
@@ -80941,6 +81451,10 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
80941
81451
  * in global mode it is dropped with a warning instead of written somewhere it
80942
81452
  * does nothing.
80943
81453
  *
81454
+ * Only Auggie 0.x reads `recommendedMarketplaces`: the 1.x plugin settings
81455
+ * reference lists `enabledPlugins` alone. The key is still written because the
81456
+ * stable CLI is 0.x, and the write warning says it is 0.x-only.
81457
+ *
80944
81458
  * Both make Auggie load code the repository did not ship — a recommended
80945
81459
  * marketplace is one prompt away from being cloned, and an enabled plugin can
80946
81460
  * carry its own hooks and MCP servers — so writing either is logged, unless
@@ -80960,7 +81474,7 @@ function buildPluginConsumptionPatch({ override, existing, global, logger }) {
80960
81474
  else {
80961
81475
  patch.recommendedMarketplaces = recommendedMarketplaces;
80962
81476
  const existingMarketplaces = existingKeys.recommendedMarketplaces;
80963
- if (recommendedMarketplaces.length > 0 && !isDeepStrictEqual(recommendedMarketplaces, existingMarketplaces)) logger?.warn(`Writing AugmentCode \`recommendedMarketplaces\` (${recommendedMarketplaces.map(quoteValueForWarning).join(", ")}): Auggie prompts everyone who opens this workspace to install these plugin marketplaces.`);
81477
+ if (recommendedMarketplaces.length > 0 && !isDeepStrictEqual(recommendedMarketplaces, existingMarketplaces)) logger?.warn(`Writing AugmentCode \`recommendedMarketplaces\` (${recommendedMarketplaces.map(quoteValueForWarning).join(", ")}): Auggie 0.x prompts everyone who opens this workspace to install these plugin marketplaces (Auggie 1.x no longer reads this key).`);
80964
81478
  warnAboutRemovedPluginIds({
80965
81479
  key: "recommendedMarketplaces",
80966
81480
  removed: (existingMarketplaces ?? []).filter((marketplace) => !recommendedMarketplaces.includes(marketplace)),
@@ -81189,12 +81703,12 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
81189
81703
  const regex = entry.shellInputRegex;
81190
81704
  if (isShellRegexRoundtrippable(regex)) pattern = shellRegexToGlob(regex);
81191
81705
  else {
81192
- if (action !== "deny" && !canApproximateShellRegexWithoutWidening(regex)) {
81193
- logger?.warn(`AugmentCode permissions: skipping ${action} entry for tool '${entry.toolName}': shellInputRegex '${regex}' uses a quantifier or a literal '*' / '?' that a glob cannot express, and converting it would widen the rule. The entry is not imported.`);
81706
+ if (action === "allow" && !canApproximateShellRegexWithoutWidening(regex)) {
81707
+ logger?.warn(`AugmentCode permissions: skipping allow entry for tool '${entry.toolName}': shellInputRegex '${regex}' uses a quantifier or a literal '*' / '?' that a glob cannot express, and converting it would widen the rule. The entry is not imported.`);
81194
81708
  continue;
81195
81709
  }
81196
- if (action === "deny") {
81197
- logger?.warn(`AugmentCode permissions: shellInputRegex '${regex}' on tool '${entry.toolName}' is not faithfully roundtrippable to a glob. Importing as the catch-all '*' pattern (fail-closed) so the deny rule cannot be silently narrowed on regenerate.`);
81710
+ if (action !== "allow") {
81711
+ logger?.warn(`AugmentCode permissions: shellInputRegex '${regex}' on tool '${entry.toolName}' is not faithfully roundtrippable to a glob. Importing the ${action} rule as the catch-all '*' pattern (fail-closed) so it cannot be silently narrowed on regenerate.`);
81198
81712
  pattern = "*";
81199
81713
  } else {
81200
81714
  pattern = shellRegexToGlob(regex);
@@ -81280,6 +81794,62 @@ function classifyPattern(pattern, bareStarIsWildcard) {
81280
81794
  };
81281
81795
  return { kind: "literal" };
81282
81796
  }
81797
+ /** The trimmed spellings of the literal (prefix) deny entries, minus the bare wildcard. */
81798
+ function collectLiteralDenyStems({ rules, bareStarIsWildcard }) {
81799
+ const stems = /* @__PURE__ */ new Set();
81800
+ for (const [pattern, action] of Object.entries(rules)) {
81801
+ if (action !== "deny" || pattern === "*") continue;
81802
+ const stem = pattern.trimEnd();
81803
+ if (stem.length > 0 && classifyPattern(pattern, bareStarIsWildcard).kind === "literal") stems.add(stem);
81804
+ }
81805
+ return stems;
81806
+ }
81807
+ function isCoveredByLiteralDeny({ pattern, literalDenyStems }) {
81808
+ if (!pattern.endsWith(" *")) return false;
81809
+ const stem = pattern.slice(0, -2).trimEnd();
81810
+ return stem.length > 0 && literalDenyStems.has(stem);
81811
+ }
81812
+ /**
81813
+ * Lower a VS Code style command allow/deny list pair into canonical `bash`
81814
+ * rules on import.
81815
+ *
81816
+ * The native entries are compared as literal command prefixes, while canonical
81817
+ * patterns are globs, so a verbatim copy changes their meaning:
81818
+ *
81819
+ * - A prefix deny such as `curl` blocks `curl https://…` natively but would
81820
+ * only block the exact command `curl` canonically, so a broad allow beside
81821
+ * it would auto-approve what the native config blocked. Each deny that does
81822
+ * not already end in `*` is imported as itself plus `<deny> *`.
81823
+ * - An allow holding glob syntax (`npm *`) is literal prefix text natively and
81824
+ * approves almost nothing, but canonically it is a wildcard. Such an allow is
81825
+ * not imported (with a warning) rather than imported wider than it matched.
81826
+ * The Roo Code lineage's bare `*` is a real wildcard natively and is kept.
81827
+ *
81828
+ * A pattern in both lists imports as `deny`, matching both tools' precedence
81829
+ * for identical entries.
81830
+ */
81831
+ function importVscodeCommandLists({ allowed, denied, toolLabel, logger, prefixSemantics = "roo-lineage" }) {
81832
+ const isRooLineage = prefixSemantics === "roo-lineage";
81833
+ const rules = {};
81834
+ const skippedAllows = [];
81835
+ for (const pattern of allowed) {
81836
+ if (!(isRooLineage && pattern === "*") && /[*?]/.test(pattern)) {
81837
+ skippedAllows.push(pattern);
81838
+ continue;
81839
+ }
81840
+ rules[pattern] = "allow";
81841
+ }
81842
+ for (const pattern of denied) {
81843
+ rules[pattern] = "deny";
81844
+ const stem = pattern.trimEnd();
81845
+ if (!pattern.endsWith("*") && stem.length > 0) rules[`${stem} *`] = "deny";
81846
+ }
81847
+ if (skippedAllows.length > 0) {
81848
+ const count = skippedAllows.length;
81849
+ warnWithFallback(logger, `${toolLabel}: allow ${pluralize(count, "pattern", "patterns")} ${formatPatterns(skippedAllows)} ${pluralize(count, "was", "were")} not imported. ${toolLabel} compares ${pluralize(count, "it", "them")} as literal command prefix text, while a rulesync pattern is a glob, so importing ${pluralize(count, "it", "them")} would approve more commands than the native config does.` + (isRooLineage ? ` Only a bare "*" is a wildcard there.` : ""));
81850
+ }
81851
+ return rules;
81852
+ }
81283
81853
  function pluralize(count, singular, plural) {
81284
81854
  return count === 1 ? singular : plural;
81285
81855
  }
@@ -81374,8 +81944,16 @@ function buildVscodeCommandLists({ rules, toolLabel, logger, prefixSemantics = "
81374
81944
  const matcherAllows = [];
81375
81945
  const widenedDenies = [];
81376
81946
  const inertDenies = [];
81947
+ const literalDenyStems = collectLiteralDenyStems({
81948
+ rules,
81949
+ bareStarIsWildcard: isRooLineage
81950
+ });
81377
81951
  for (const [pattern, action] of Object.entries(rules)) {
81378
81952
  if (action !== "allow" && action !== "deny") continue;
81953
+ if (action === "deny" && isCoveredByLiteralDeny({
81954
+ pattern,
81955
+ literalDenyStems
81956
+ })) continue;
81379
81957
  const shape = classifyPattern(pattern, isRooLineage);
81380
81958
  if (action === "allow") {
81381
81959
  allowed.push(pattern);
@@ -81738,16 +82316,20 @@ var BobPermissions = class BobPermissions extends ToolPermissions {
81738
82316
  validate: true
81739
82317
  });
81740
82318
  }
81741
- toRulesyncPermissions() {
82319
+ toRulesyncPermissions({ logger } = {}) {
81742
82320
  const settings = parseBobSettings({
81743
82321
  fileContent: this.getFileContent(),
81744
82322
  filePath: join(this.getRelativeDirPath(), this.getRelativeFilePath())
81745
82323
  });
81746
82324
  const approval = settings[APPROVAL_KEY$1];
81747
82325
  const entry = (isPlainObject(approval) && Array.isArray(approval.allowedExecutors) ? approval.allowedExecutors : []).find(isExecuteCommandEntry);
81748
- const rules = {};
81749
- for (const pattern of toStringArray$1(entry?.approvedCommands)) rules[pattern] = "allow";
81750
- for (const pattern of toStringArray$1(entry?.deniedCommands)) rules[pattern] = "deny";
82326
+ const rules = importVscodeCommandLists({
82327
+ allowed: toStringArray$1(entry?.approvedCommands),
82328
+ denied: toStringArray$1(entry?.deniedCommands),
82329
+ toolLabel: TOOL_LABEL,
82330
+ logger,
82331
+ prefixSemantics: "documented-prefix"
82332
+ });
81751
82333
  const permission = {};
81752
82334
  if (Object.keys(rules).length > 0) permission[SHELL_PERMISSION_CATEGORY] = rules;
81753
82335
  const bob = readBobOverride(settings);
@@ -85511,7 +86093,7 @@ function computeCodexcliOverridePatch({ existing, override, logger }) {
85511
86093
  const patch = {};
85512
86094
  const allowed = new Set(CODEXCLI_OVERRIDE_KEYS);
85513
86095
  for (const [key, value] of Object.entries(override ?? {})) {
85514
- if (key === "base_permission_profile" || key === "git_write_rules") continue;
86096
+ if (key === "base_permission_profile" || key === "git_write_rules" || key === "bash_canonical_matching") continue;
85515
86097
  if (!allowed.has(key)) {
85516
86098
  logger?.warn(`Codex CLI permission override key "${key}" is not managed and was skipped. "permissions"/"default_permissions" are owned by the canonical permission model and "mcp_servers" gating by the MCP feature.`);
85517
86099
  continue;
@@ -85800,6 +86382,7 @@ function stripTrailingSlash(pattern) {
85800
86382
  function buildCodexBashRulesContent({ config, logger }) {
85801
86383
  const bashRules = bashRulesHonoringAllTools(config.permission);
85802
86384
  const entries = Object.entries(bashRules);
86385
+ const canonicalMatching = config.codexcli?.bash_canonical_matching === true;
85803
86386
  const header = ["# Generated by Rulesync from .rulesync/permissions.jsonc (permission.bash)", "# https://developers.openai.com/codex/rules"];
85804
86387
  if (entries.length === 0) return [...header, "# No bash permission rules were configured."].join("\n");
85805
86388
  const ruleBlocks = entries.map(([pattern, action]) => {
@@ -85818,6 +86401,7 @@ function buildCodexBashRulesContent({ config, logger }) {
85818
86401
  ` pattern = [${serializedTokens}],`,
85819
86402
  ` decision = ${JSON.stringify(decision)},`,
85820
86403
  ` justification = ${JSON.stringify(`Generated from Rulesync permission.bash: ${pattern}`)},`,
86404
+ ...canonicalMatching && decision !== "allow" ? [" canonical_matching = True,"] : [],
85821
86405
  ")"
85822
86406
  ].join("\n");
85823
86407
  }).filter((block) => block !== null);
@@ -87402,9 +87986,126 @@ function toCanonicalToolName$5(crushName) {
87402
87986
  function isMcpToolName(crushName) {
87403
87987
  return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
87404
87988
  }
87989
+ const CRUSH_COMMAND_WRAPPERS = /* @__PURE__ */ new Set([
87990
+ "env",
87991
+ "nohup",
87992
+ "nice",
87993
+ "timeout"
87994
+ ]);
87995
+ const CRUSH_COMMAND_PREFIXES = [...CRUSH_COMMAND_WRAPPERS, "time"];
87996
+ const CRUSH_COMMAND_NAME = /^[a-z0-9_][a-z0-9_.+-]*$/;
87997
+ const CRUSH_COMMAND_ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/;
87998
+ const CRUSH_COMMAND_NAME_SUFFIXES = [
87999
+ ".exe",
88000
+ ".bat",
88001
+ ".cmd"
88002
+ ];
88003
+ function isCrushCommandName(name) {
88004
+ return CRUSH_COMMAND_NAME.test(name) && !CRUSH_COMMAND_NAME_SUFFIXES.some((suffix) => name.endsWith(suffix)) && !isPrototypePollutionKey(name);
88005
+ }
88006
+ /**
88007
+ * Reduce a command name the way Crush matches it (`shell.NormalizeCommandName`):
88008
+ * trimmed, without its directory, lower-cased, and without a Windows
88009
+ * executable suffix.
88010
+ */
88011
+ function normalizeCrushCommandName(raw) {
88012
+ const base = (raw.trim().split(/[\\/]/).at(-1) ?? "").toLowerCase();
88013
+ const suffix = CRUSH_COMMAND_NAME_SUFFIXES.find((candidate) => base.length > candidate.length && base.endsWith(candidate));
88014
+ return suffix === void 0 ? base : base.slice(0, -suffix.length);
88015
+ }
88016
+ /**
88017
+ * Whether a canonical pattern is a literal command line that a Crush
88018
+ * `safe_commands` entry reproduces exactly: Crush matches an entry only when
88019
+ * the command is written with exactly those tokens, no extra arguments and no
88020
+ * flags, which is what a wildcard-free glob means. A leading wrapper is
88021
+ * refused, since Crush would resolve it away and approve the bare command.
88022
+ */
88023
+ function isExactCrushCommandLine(pattern) {
88024
+ const [name, ...args] = pattern.split(" ");
88025
+ return name !== void 0 && isCrushCommandName(name) && !CRUSH_COMMAND_WRAPPERS.has(name) && args.every((arg) => CRUSH_COMMAND_ARGUMENT.test(arg));
88026
+ }
88027
+ /**
88028
+ * The command name a canonical `<name>` / `<name> *` pattern names as a
88029
+ * whole, i.e. the `blocked_commands` entry for it, or undefined.
88030
+ */
88031
+ function wholeCommandName(pattern) {
88032
+ const name = pattern.endsWith(" *") ? pattern.slice(0, -2) : pattern;
88033
+ return isCrushCommandName(name) ? name : void 0;
88034
+ }
88035
+ /**
88036
+ * The line a Crush `safe_commands` entry effectively stands for, in canonical
88037
+ * form (whitespace collapsed, command name normalized), or undefined when the
88038
+ * entry has no exact canonical spelling (wildcards, quoting, a wrapper, ...).
88039
+ */
88040
+ function effectiveSafeCommandLine(entry) {
88041
+ const [name, ...args] = entry.trim().split(/\s+/);
88042
+ if (name === void 0 || name === "") return void 0;
88043
+ const line = [normalizeCrushCommandName(name), ...args].join(" ");
88044
+ return isExactCrushCommandLine(line) ? line : void 0;
88045
+ }
88046
+ /**
88047
+ * A restriction pattern with its command word reduced the way Crush reduces a
88048
+ * wrapper's name before peeling it (`shell.NormalizeCommandName`): any
88049
+ * directory dropped (`./nice`, `bin\env.exe`, `/usr/bin/env`), lower-cased,
88050
+ * and without a Windows executable suffix. A `*` in the command word may
88051
+ * stand for a directory and part of a name, so when what follows it is the
88052
+ * tail of a wrapper name, everything before it collapses into `*`
88053
+ * (`b*nice` -> `*nice`, `/u*v` -> `*v`; `rm*` is left alone). The rest of the pattern is lower-cased as well, for a
88054
+ * nested wrapper spelled in another case. Used only to compare against the
88055
+ * wrapper spellings of a safe line, where it can only withhold more.
88056
+ */
88057
+ function normalizeRestrictionCommandWord(pattern) {
88058
+ const space = pattern.indexOf(" ");
88059
+ let head = space < 0 ? pattern : pattern.slice(0, space);
88060
+ const rest = space < 0 ? "" : pattern.slice(space);
88061
+ head = normalizeCrushCommandName(head);
88062
+ const star = head.lastIndexOf("*");
88063
+ const tail = head.slice(star + 1);
88064
+ if (star > 0 && tail !== "" && CRUSH_COMMAND_PREFIXES.some((prefix) => prefix.endsWith(tail))) head = `*${tail}`;
88065
+ return `${head}${rest.toLowerCase()}`;
88066
+ }
88067
+ /**
88068
+ * Build the test for a `safe_commands` line: which canonical `deny`/`ask`
88069
+ * rules govern a command Crush would approve through it? Besides the line
88070
+ * itself, Crush approves it followed by `--` and behind a wrapper (or the
88071
+ * `time` keyword), whose name it normalizes (`./nice`, `/usr/bin/env`, `NICE`,
88072
+ * `nohup.exe`). The line and its wrapper spellings are compared against the
88073
+ * restrictions as written, and the wrapper spellings also against the
88074
+ * restrictions with their command word normalized the same way — which keeps
88075
+ * an unrelated `rm *` from withholding the line, as a leading wildcard on the
88076
+ * spelling would. Canonically the stricter rule wins whatever its width
88077
+ * (`deny > ask > allow`), so any overlap withholds the line.
88078
+ *
88079
+ * Crush also approves a line composed with other safe lines (`&&`, `||`, `|`,
88080
+ * `;`, a parenthesized group); those compositions are not compared, the way
88081
+ * other tools' per-subcommand matching treats an allowed command.
88082
+ */
88083
+ function createSafeCommandShadowTest(permission) {
88084
+ const restrictions = collectShellCommandRules(permission).rules.filter(({ action }) => action !== "allow");
88085
+ const test = createShadowingRestrictionsTest(restrictions);
88086
+ const normalized = restrictions.map((rule) => ({
88087
+ ...rule,
88088
+ pattern: normalizeRestrictionCommandWord(rule.pattern)
88089
+ }));
88090
+ const testNormalized = createShadowingRestrictionsTest(normalized);
88091
+ const originalOf = /* @__PURE__ */ new Map();
88092
+ normalized.forEach((rule, index) => {
88093
+ const original = restrictions[index]?.pattern;
88094
+ if (original !== void 0 && !originalOf.has(rule.pattern)) originalOf.set(rule.pattern, original);
88095
+ });
88096
+ const withSeparator = (spellings) => spellings.flatMap((spelling) => [spelling, `${spelling} --*`]);
88097
+ const wrapped = (base) => CRUSH_COMMAND_PREFIXES.map((prefix) => `${prefix}*${base}`);
88098
+ return (line) => uniq$1([...withSeparator([line, ...wrapped(line)]).flatMap((spelling) => test(spelling)), ...withSeparator(wrapped(line.toLowerCase())).flatMap((spelling) => testNormalized(spelling).map((pattern) => originalOf.get(pattern) ?? pattern))]);
88099
+ }
87405
88100
  function uniq$1(values) {
87406
88101
  return [...new Set(values)];
87407
88102
  }
88103
+ function stringList(value) {
88104
+ return isStringArray(value) ? value : [];
88105
+ }
88106
+ function listOrUndefined(values) {
88107
+ return values.length > 0 ? values : void 0;
88108
+ }
87408
88109
  /**
87409
88110
  * Permissions adapter for Crush.
87410
88111
  *
@@ -87426,8 +88127,24 @@ function uniq$1(values) {
87426
88127
  * `webfetch` -> `fetch`, `mcp__<server>__<tool>` -> `mcp_<server>_<tool>`;
87427
88128
  * any other category passes through verbatim.
87428
88129
  *
87429
- * A pattern-specific rule cannot be expressed and is reported and skipped.
87430
- * Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
88130
+ * Crush v0.100.0+ also carries two command lists, which the `bash` category's
88131
+ * pattern rules map onto (see `convertBashCommandRules`):
88132
+ * - `permissions.safe_commands`: exact command lines that skip the prompt
88133
+ * (no extra arguments, no flags) <- an `allow` on a literal command line
88134
+ * that no canonical `deny`/`ask` overlaps.
88135
+ * - `permissions.blocked_commands`: command names added to the dangerous
88136
+ * list <- a `deny` on `<name>` or `<name> *`. Not a full deny: a direct
88137
+ * invocation is still prompted for (and auto-approved in yolo mode or
88138
+ * through `allowed_tools: ["bash"]`); only the forms Crush would not
88139
+ * prompt for (its read-only commands, `safe_commands`) and indirect
88140
+ * invocations are refused at exec time. An `ask` is not written there,
88141
+ * since it would turn the read-only forms into a refusal.
88142
+ * `permissions.allowed_commands` only drops names from the dangerous list (the
88143
+ * commands keep prompting), so it has no canonical counterpart and is left
88144
+ * untouched.
88145
+ *
88146
+ * Any other pattern-specific rule cannot be expressed and is reported and
88147
+ * skipped. Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
87431
88148
  * is not written while the same category carries a pattern-specific `deny` or
87432
88149
  * `ask`: the tool keeps prompting (fail closed) rather than being widened to
87433
88150
  * everything. The all-tools `*` category is mirrored onto `bash` by
@@ -87491,7 +88208,12 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87491
88208
  const existingContent = location.fileContent ?? "";
87492
88209
  warnCrushTwinLeftovers({
87493
88210
  location,
87494
- ownedPaths: [[CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY], [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]],
88211
+ ownedPaths: [
88212
+ [CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY],
88213
+ [CRUSH_PERMISSIONS_KEY, CRUSH_SAFE_COMMANDS_KEY],
88214
+ [CRUSH_PERMISSIONS_KEY, CRUSH_BLOCKED_COMMANDS_KEY],
88215
+ [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]
88216
+ ],
87495
88217
  logger
87496
88218
  });
87497
88219
  const existing = parseCrushConfig(existingContent, location.filePath);
@@ -87503,12 +88225,22 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87503
88225
  logger
87504
88226
  });
87505
88227
  const managedToolNames = managedCrushToolNames(config);
88228
+ const commands = rebuildCrushCommandLists({
88229
+ config,
88230
+ existingPermissions,
88231
+ logger
88232
+ });
87506
88233
  const preservedAllowed = (isStringArray(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
87507
88234
  const preservedDisabled = (isStringArray(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
87508
88235
  const allowedList = uniq$1([...preservedAllowed, ...generated.allowed]);
87509
88236
  const disabledList = uniq$1([...preservedDisabled, ...generated.disabled]);
88237
+ const permissionsHasContent = allowedList.length > 0 || commands.safe.length > 0 || commands.blocked.length > 0;
87510
88238
  const patch = {};
87511
- if (allowedList.length > 0 || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = { [CRUSH_ALLOWED_TOOLS_KEY]: allowedList.length > 0 ? allowedList : void 0 };
88239
+ if (permissionsHasContent || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = {
88240
+ [CRUSH_ALLOWED_TOOLS_KEY]: listOrUndefined(allowedList),
88241
+ [CRUSH_SAFE_COMMANDS_KEY]: listOrUndefined(commands.safe),
88242
+ [CRUSH_BLOCKED_COMMANDS_KEY]: listOrUndefined(commands.blocked)
88243
+ };
87512
88244
  if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
87513
88245
  return new CrushPermissions({
87514
88246
  outputRoot,
@@ -87526,17 +88258,79 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87526
88258
  global
87527
88259
  });
87528
88260
  }
87529
- toRulesyncPermissions() {
88261
+ toRulesyncPermissions({ logger } = {}) {
87530
88262
  const permissions = isRecord(this.json["permissions"]) ? this.json[CRUSH_PERMISSIONS_KEY] : {};
87531
88263
  const options = toRecord(this.json[CRUSH_OPTIONS_KEY]);
87532
88264
  const rulesyncConfig = convertCrushListsToRulesync({
87533
- allowed: isStringArray(permissions["allowed_tools"]) ? permissions[CRUSH_ALLOWED_TOOLS_KEY] : [],
87534
- disabled: isStringArray(options["disabled_tools"]) ? options[CRUSH_DISABLED_TOOLS_KEY] : []
88265
+ allowed: stringList(permissions[CRUSH_ALLOWED_TOOLS_KEY]),
88266
+ disabled: stringList(options[CRUSH_DISABLED_TOOLS_KEY]),
88267
+ safe: stringList(permissions[CRUSH_SAFE_COMMANDS_KEY]),
88268
+ blocked: stringList(permissions[CRUSH_BLOCKED_COMMANDS_KEY]),
88269
+ logger
87535
88270
  });
87536
88271
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
87537
88272
  }
87538
88273
  };
87539
88274
  /**
88275
+ * The `safe_commands` / `blocked_commands` lists a generate writes: the
88276
+ * existing entries rulesync keeps, followed by the ones derived from the
88277
+ * canonical `bash` rules. Existing `blocked_commands` entries are all kept:
88278
+ * rulesync cannot tell its own from hand-written ones, and dropping one only
88279
+ * ever loosens. A derived blocked name that is the command of a written safe
88280
+ * line is withheld, since Crush would refuse that line at exec time.
88281
+ */
88282
+ function rebuildCrushCommandLists({ config, existingPermissions, logger }) {
88283
+ const bashRules = honorAllToolsOnBash(config.permission)["bash"] ?? {};
88284
+ const shadowTest = createSafeCommandShadowTest(config.permission);
88285
+ const generated = convertBashCommandRules({
88286
+ bashRules,
88287
+ shadowTest,
88288
+ logger
88289
+ });
88290
+ const safe = uniq$1([...preserveSafeCommands({
88291
+ existing: stringList(existingPermissions?.[CRUSH_SAFE_COMMANDS_KEY]),
88292
+ bashRules,
88293
+ shadowTest,
88294
+ logger
88295
+ }), ...generated.safe]);
88296
+ const safeCommandNames = new Set(safe.flatMap((entry) => {
88297
+ const name = entry.trim().split(/\s+/)[0];
88298
+ return name === void 0 || name === "" ? [] : [normalizeCrushCommandName(name)];
88299
+ }));
88300
+ const blocked = generated.blocked.filter((name) => {
88301
+ if (!safeCommandNames.has(name)) return true;
88302
+ logger?.warn(`The "deny" rule for "bash" on ${quoteValueForWarning(name)} is not added to permissions.blocked_commands because Crush would then refuse the allowed permissions.safe_commands line(s) for that command; Crush keeps prompting for it.`);
88303
+ return false;
88304
+ });
88305
+ const existingBlocked = stringList(existingPermissions?.[CRUSH_BLOCKED_COMMANDS_KEY]);
88306
+ for (const entry of existingBlocked) {
88307
+ if (!safeCommandNames.has(normalizeCrushCommandName(entry))) continue;
88308
+ logger?.warn(`The existing permissions.blocked_commands entry ${quoteValueForWarning(entry)} is kept, but Crush will refuse the permissions.safe_commands line(s) for that command at exec time; remove one of them by hand.`);
88309
+ }
88310
+ return {
88311
+ safe,
88312
+ blocked: uniq$1([...existingBlocked, ...blocked])
88313
+ };
88314
+ }
88315
+ /**
88316
+ * The existing `safe_commands` entries a generate keeps: those the canonical
88317
+ * `bash` rules do not spell out themselves (an entry they do spell is rebuilt
88318
+ * from them), minus any entry a canonical `deny`/`ask` governs, which would
88319
+ * otherwise auto-approve what the config restricts. An entry whose exact
88320
+ * meaning cannot be read is dropped whenever the config restricts anything.
88321
+ */
88322
+ function preserveSafeCommands({ existing, bashRules, shadowTest, logger }) {
88323
+ const managedLines = new Set(Object.keys(bashRules));
88324
+ const restricts = shadowTest("*").length > 0;
88325
+ return existing.filter((entry) => {
88326
+ if (managedLines.has(entry)) return false;
88327
+ const line = effectiveSafeCommandLine(entry);
88328
+ const shadowed = line === void 0 ? restricts : shadowTest(line).length > 0;
88329
+ if (shadowed) logger?.warn(`Crush permissions.safe_commands entry ${quoteValueForWarning(entry)} was removed because a canonical deny/ask rule may cover the commands it approves.`);
88330
+ return !shadowed;
88331
+ });
88332
+ }
88333
+ /**
87540
88334
  * The Crush tool names the canonical config manages, i.e. the names its
87541
88335
  * categories map to. Entries naming any other tool are the user's and survive
87542
88336
  * a generate. The all-tools `*` category names no Crush tool of its own.
@@ -87561,7 +88355,7 @@ function resolveCategoryAction({ category, toolName, rules, logger }) {
87561
88355
  catchAllAction = action;
87562
88356
  continue;
87563
88357
  }
87564
- logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
88358
+ if (category !== "bash") logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
87565
88359
  if (action !== "allow") restricted = true;
87566
88360
  }
87567
88361
  if (catchAllAction === "allow" && restricted) {
@@ -87612,11 +88406,71 @@ function convertRulesyncToCrushLists({ config, logger }) {
87612
88406
  };
87613
88407
  }
87614
88408
  /**
88409
+ * Map the `bash` category's pattern rules onto Crush's command lists:
88410
+ * - `allow` on a literal command line -> `permissions.safe_commands`, which
88411
+ * Crush matches exactly (no extra arguments, no flags). It is withheld
88412
+ * when a canonical `deny`/`ask` governs any command Crush would approve
88413
+ * through it.
88414
+ * - `deny` on `<name>` / `<name> *` -> `permissions.blocked_commands`, the
88415
+ * strictest Crush can say about a single command: a direct invocation is
88416
+ * still prompted for, while the forms Crush would not prompt for and
88417
+ * indirect invocations are refused at exec time. Reported, since it is
88418
+ * not a full deny.
88419
+ * - Anything else is reported and skipped. A pattern `ask` is never written
88420
+ * to `blocked_commands`, which would refuse the command's read-only forms
88421
+ * outright, and an `allow` with a wildcard has no list that auto-approves
88422
+ * it.
88423
+ */
88424
+ function convertBashCommandRules({ bashRules, shadowTest, logger }) {
88425
+ const lists = {
88426
+ safe: [],
88427
+ blocked: []
88428
+ };
88429
+ for (const [rawPattern, action] of Object.entries(bashRules)) {
88430
+ if (isPrototypePollutionKey(rawPattern)) continue;
88431
+ const pattern = normalizeCatchAllPattern(rawPattern);
88432
+ if (pattern === "*") continue;
88433
+ const quoted = quoteValueForWarning(pattern);
88434
+ if (action === "allow") {
88435
+ if (!isExactCrushCommandLine(pattern)) {
88436
+ logger?.warn(`Crush auto-approves a command only as an exact line in permissions.safe_commands (no wildcards or shell syntax), so the "allow" rule for "bash" (pattern ${quoted}) was skipped; Crush keeps prompting for it.`);
88437
+ continue;
88438
+ }
88439
+ const shadows = shadowTest(pattern);
88440
+ if (shadows.length > 0) {
88441
+ logger?.warn(`The "allow" rule for "bash" (pattern ${quoted}) is not added to permissions.safe_commands because the deny/ask rule(s) ${shadows.map(quoteValueForWarning).join(", ")} also cover commands Crush would approve through it; Crush keeps prompting for it.`);
88442
+ continue;
88443
+ }
88444
+ lists.safe.push(pattern);
88445
+ continue;
88446
+ }
88447
+ if (action === "ask") {
88448
+ logger?.warn(`Crush has no per-command prompt list (permissions.blocked_commands would refuse the command's read-only forms outright), so the "ask" rule for "bash" (pattern ${quoted}) was skipped; Crush still prompts for it unless it is one of Crush's built-in read-only commands.`);
88449
+ continue;
88450
+ }
88451
+ const name = wholeCommandName(pattern);
88452
+ if (name !== void 0 && CRUSH_COMMAND_PREFIXES.includes(name)) {
88453
+ logger?.warn(`Crush resolves the command wrapper or keyword ${quoteValueForWarning(name)} before matching permissions.blocked_commands, so an entry for it would have no effect; the "deny" rule for "bash" (pattern ${quoted}) was skipped and Crush still prompts for it.`);
88454
+ continue;
88455
+ }
88456
+ if (name === void 0) {
88457
+ logger?.warn(`Crush can only restrict a whole command (permissions.blocked_commands), so the "deny" rule for "bash" (pattern ${quoted}) was skipped; Crush still prompts for it unless it is one of Crush's built-in read-only commands.`);
88458
+ continue;
88459
+ }
88460
+ logger?.warn(`The "deny" rule for "bash" (pattern ${quoted}) is written as permissions.blocked_commands entry ${quoteValueForWarning(name)}, which is not a full deny: Crush still prompts for a direct invocation (and runs it unprompted in yolo mode or with "bash" in allowed_tools).`);
88461
+ lists.blocked.push(name);
88462
+ }
88463
+ return {
88464
+ safe: uniq$1(lists.safe),
88465
+ blocked: uniq$1(lists.blocked)
88466
+ };
88467
+ }
88468
+ /**
87615
88469
  * Convert Crush's two tool lists back into a rulesync config. A `tool:action`
87616
88470
  * entry scopes an allow to one action of a tool, which the canonical model
87617
88471
  * cannot express, so it is left out rather than widened to the whole tool.
87618
88472
  */
87619
- function convertCrushListsToRulesync({ allowed, disabled }) {
88473
+ function convertCrushListsToRulesync({ allowed, disabled, safe, blocked, logger }) {
87620
88474
  const permission = {};
87621
88475
  const bucketFor = (category) => {
87622
88476
  const own = lookupOwn({
@@ -87632,6 +88486,29 @@ function convertCrushListsToRulesync({ allowed, disabled }) {
87632
88486
  if (entry === "" || isPrototypePollutionKey(entry)) continue;
87633
88487
  bucketFor(toCanonicalToolName$5(entry))["*"] = "deny";
87634
88488
  }
88489
+ const setBashRule = (pattern, action) => {
88490
+ const bucket = bucketFor(SHELL_PERMISSION_CATEGORY);
88491
+ if (!Object.hasOwn(bucket, pattern)) bucket[pattern] = action;
88492
+ };
88493
+ for (const entry of blocked) {
88494
+ const name = normalizeCrushCommandName(entry);
88495
+ if (name === "") continue;
88496
+ if (!isCrushCommandName(name)) {
88497
+ logger?.warn(`Crush permissions.blocked_commands entry ${quoteValueForWarning(entry)} is not a plain command name, so it was not imported.`);
88498
+ continue;
88499
+ }
88500
+ setBashRule(name, "deny");
88501
+ setBashRule(`${name} *`, "deny");
88502
+ }
88503
+ for (const entry of safe) {
88504
+ if (entry.trim() === "") continue;
88505
+ const line = effectiveSafeCommandLine(entry);
88506
+ if (line === void 0) {
88507
+ logger?.warn(`Crush permissions.safe_commands entry ${quoteValueForWarning(entry)} has no exact rulesync pattern (wildcards, shell syntax or a command wrapper), so it was not imported.`);
88508
+ continue;
88509
+ }
88510
+ setBashRule(line, "allow");
88511
+ }
87635
88512
  for (const entry of allowed) {
87636
88513
  if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
87637
88514
  bucketFor(toCanonicalToolName$5(entry))["*"] ??= "allow";
@@ -89011,8 +89888,8 @@ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
89011
89888
  * In global mode the config file is shared with the hooks (`hooks`) feature
89012
89889
  * (MCP moved to the dedicated mcp_config.json in v3000.3), so reads and writes
89013
89890
  * merge into the existing JSON and the file is never deleted; only the keys
89014
- * this feature manages are rewritten — `permissions`, plus `sandbox` in global
89015
- * mode and the plugin governance lists (`requiredPlugins` / `optionalPlugins` /
89891
+ * this feature manages are rewritten — `permissions`, plus `sandbox` and
89892
+ * `permission_mode` in global mode and the plugin governance lists (`requiredPlugins` / `optionalPlugins` /
89016
89893
  * `forbiddenPlugins`) in project mode when the `devin` override authors them.
89017
89894
  *
89018
89895
  * The sibling `sandbox` block — which decides what a permitted command may
@@ -89120,6 +89997,12 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
89120
89997
  });
89121
89998
  } else if (Object.keys(authoredSandboxRecord).length > 0) logger?.warn("Devin reads 'sandbox' from the user config only, so the 'devin.sandbox' override was dropped from the project config. Generate with --global to author it.");
89122
89999
  }
90000
+ applyPermissionModeOverride({
90001
+ permissionMode: config.devin?.permission_mode,
90002
+ patch,
90003
+ global,
90004
+ logger
90005
+ });
89123
90006
  applyPluginGovernanceOverride({
89124
90007
  override: config.devin,
89125
90008
  settings,
@@ -89163,6 +90046,12 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
89163
90046
  if (DEVIN_PLUGIN_GOVERNANCE_SCHEMAS[key].safeParse(value).success) devinOverride[key] = value;
89164
90047
  else logger?.warn(`Skipped Devin \`${key}\` on import: ${key === "forbiddenPlugins" ? "expected an array of strings" : "expected an array of plugin sources (strings or objects)"}.`);
89165
90048
  }
90049
+ const permissionMode = importPermissionMode({
90050
+ value: settings.permission_mode,
90051
+ isGlobalFile: toPosixPath(this.getRelativeDirPath()) === toPosixPath(DevinPermissions.getSettablePaths({ global: true }).relativeDirPath),
90052
+ logger
90053
+ });
90054
+ if (permissionMode !== void 0) devinOverride.permission_mode = permissionMode;
89166
90055
  const result = { ...config };
89167
90056
  if (Object.keys(devinOverride).length > 0) result.devin = devinOverride;
89168
90057
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
@@ -89179,6 +90068,48 @@ const DEVIN_PLUGIN_GOVERNANCE_KEYS = [
89179
90068
  "forbiddenPlugins"
89180
90069
  ];
89181
90070
  /**
90071
+ * Decide what an imported `permission_mode` contributes to the `devin`
90072
+ * override. It is lifted from the user config only: unlike `sandbox`, a
90073
+ * project value Devin ignores is skipped with a warning rather than surfaced,
90074
+ * so it cannot slip into a `--global` generate later. `null` is Devin's
90075
+ * spelling of the default and carries nothing to keep; any other value the
90076
+ * override schema would reject is dropped with a warning so the next generate
90077
+ * cannot fail. `dangerous` is lifted but named, as on generate.
90078
+ */
90079
+ function importPermissionMode({ value, isGlobalFile, logger }) {
90080
+ if (value === void 0 || value === null) return void 0;
90081
+ if (!isGlobalFile) {
90082
+ logger?.warn("Skipped Devin `permission_mode` on import: Devin reads it from the user config only, so the project config's value has no effect. Import with --global to lift it.");
90083
+ return;
90084
+ }
90085
+ const parsed = DevinPermissionModeSchema.safeParse(value);
90086
+ if (!parsed.success) {
90087
+ logger?.warn(`Skipped Devin \`permission_mode\` ${quoteValueForWarning(value)} on import: expected one of ${DevinPermissionModeSchema.options.join(", ")}.`);
90088
+ return;
90089
+ }
90090
+ if (parsed.data === "dangerous") logger?.warn("Imported Devin `permission_mode: \"dangerous\"`: every new local session starts with all permission prompts bypassed, and the next --global generate writes it back.");
90091
+ return parsed.data;
90092
+ }
90093
+ /**
90094
+ * Patch the `permission_mode` the `devin` override authors into the user
90095
+ * `config.json`. It sets the mode new local sessions start in, and Devin reads
90096
+ * it from the user config only, so in project mode it is dropped with a
90097
+ * warning. It is only ever written as authored — never derived from the
90098
+ * canonical rules — and `dangerous` is named in a warning, because every new
90099
+ * session then starts with all permission prompts bypassed.
90100
+ *
90101
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
90102
+ */
90103
+ function applyPermissionModeOverride({ permissionMode, patch, global, logger }) {
90104
+ if (permissionMode === void 0) return;
90105
+ if (!global) {
90106
+ logger?.warn("Devin reads 'permission_mode' from the user config only, so the 'devin.permission_mode' override was dropped from the project config. Generate with --global to author it.");
90107
+ return;
90108
+ }
90109
+ patch.permission_mode = permissionMode;
90110
+ if (permissionMode === "dangerous") logger?.warn("Writing Devin `permission_mode: \"dangerous\"` to the user config: every new local session then starts with all permission prompts bypassed.");
90111
+ }
90112
+ /**
89182
90113
  * Patch the plugin governance lists the `devin` override authors into the
89183
90114
  * project `.devin/config.json`. Each authored list replaces the file's list
89184
90115
  * whole; a list it does not author is left as the file holds it. Devin reads these
@@ -95168,7 +96099,8 @@ function convertQwenToRulesyncPermissions(params) {
95168
96099
  *
95169
96100
  * Reasonix matches a rule's tool exactly against its registered tools, which
95170
96101
  * are snake_case (`read_file`, `write_file`, `grep`, `glob`, `web_fetch`,
95171
- * `web_search`, `notebook_edit`, `task`). Only `Bash`, `Edit` (every file
96102
+ * `web_search`, `notebook_edit`, and `task` up to v2.57.x / `spawn_agents` from
96103
+ * v2.58.0). Only `Bash`, `Edit` (every file
95172
96104
  * writer: `write_file`, `edit_file`, `multi_edit`, `notebook_edit`, ...),
95173
96105
  * `Browser` and `Computer` are Claude-Code-style family aliases
95174
96106
  * (`canonicalRuleTool` / `groupOf`), so a `Read(...)` or `Write(...)` rule
@@ -95191,6 +96123,21 @@ const CANONICAL_TO_REASONIX_TOOL_NAMES = {
95191
96123
  agent: "task"
95192
96124
  };
95193
96125
  /**
96126
+ * Reasonix tool names a canonical category writes beside its primary name, for
96127
+ * a tool Reasonix renamed. studio-v2.58.0 replaced the `task` delegation family
96128
+ * with `spawn_agents` (commit ad2c96347), while the 1.x line and v2 up to
96129
+ * v2.57.x still register `task`. When Reasonix loads the config file it checks
96130
+ * only a rule's syntax: a rule naming a tool the running build lacks matches
96131
+ * nothing (v2.34.0+ reports it as dormant) and is not rejected. (The v2 Web UI
96132
+ * permissions editor refuses to newly add such a rule with `ErrUnknownTool`,
96133
+ * but keeps the ones already in the file.) So writing both names keeps an
96134
+ * `agent` rule in force on old and new builds alike. Both names are read back
96135
+ * to the same category.
96136
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/studio-v2.58.0/internal/runtime/delegation/spawn.go
96137
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/studio-v2.57.0/internal/runtime/delegation/task.go
96138
+ */
96139
+ const CANONICAL_TO_EXTRA_REASONIX_TOOL_NAMES = { agent: ["spawn_agents"] };
96140
+ /**
95194
96141
  * The Claude-Code-style spellings rulesync emitted before it followed the
95195
96142
  * Reasonix tool vocabulary. None of them reaches a Reasonix tool, so they are
95196
96143
  * read back on import (a file generated by an older rulesync still imports)
@@ -95213,7 +96160,8 @@ const CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES = {
95213
96160
  */
95214
96161
  const REASONIX_TO_CANONICAL_TOOL_NAMES = {
95215
96162
  ...invertRecord(CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES),
95216
- ...invertRecord(CANONICAL_TO_REASONIX_TOOL_NAMES)
96163
+ ...invertRecord(CANONICAL_TO_REASONIX_TOOL_NAMES),
96164
+ ...Object.fromEntries(Object.entries(CANONICAL_TO_EXTRA_REASONIX_TOOL_NAMES).flatMap(([canonical, names]) => names.map((name) => [name, canonical])))
95217
96165
  };
95218
96166
  /** Every Reasonix tool name a canonical category owns on generate. */
95219
96167
  function reasonixToolNamesOf(canonical) {
@@ -95221,14 +96169,20 @@ function reasonixToolNamesOf(canonical) {
95221
96169
  record: CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES,
95222
96170
  key: canonical
95223
96171
  });
95224
- const current = toReasonixToolName(canonical);
95225
- return legacy === void 0 ? [current] : [current, legacy];
96172
+ const current = toReasonixToolNames(canonical);
96173
+ return legacy === void 0 ? current : [...current, legacy];
95226
96174
  }
95227
- function toReasonixToolName(canonical) {
95228
- return lookupOwn({
96175
+ /** The Reasonix tool names a canonical category's rules are written under. */
96176
+ function toReasonixToolNames(canonical) {
96177
+ const primary = lookupOwn({
95229
96178
  record: CANONICAL_TO_REASONIX_TOOL_NAMES,
95230
96179
  key: canonical
95231
96180
  }) ?? canonical;
96181
+ const extra = lookupOwn({
96182
+ record: CANONICAL_TO_EXTRA_REASONIX_TOOL_NAMES,
96183
+ key: canonical
96184
+ });
96185
+ return extra === void 0 ? [primary] : [primary, ...extra];
95232
96186
  }
95233
96187
  function toCanonicalToolName$2(reasonixName) {
95234
96188
  return lookupOwn({
@@ -95636,6 +96590,17 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
95636
96590
  });
95637
96591
  if (droppedReadDenyEntries.length > 0) logger.warn(`Permissions feature manages '${REASONIX_READ_TOOL_NAME}' tool and will overwrite ${droppedReadDenyEntries.length} existing read deny entries (possibly from ignore feature). Permissions take precedence.`);
95638
96592
  }
96593
+ warnOnLoosenedAgentEntries({
96594
+ existingPermissions,
96595
+ managedToolNames,
96596
+ generated: {
96597
+ allow: [...allow, ...rawAllow],
96598
+ ask: [...ask, ...rawAsk],
96599
+ deny: [...deny, ...rawDeny]
96600
+ },
96601
+ relativeFilePath: relativeFilePathForLog,
96602
+ logger
96603
+ });
95639
96604
  const mergedPermissions = { ...existingPermissions };
95640
96605
  setOrDeleteEntries(mergedPermissions, "allow", [
95641
96606
  ...preservedAllow,
@@ -95811,6 +96776,55 @@ function preserveExistingEntries({ existingPermissions, managedToolNames, rawOwn
95811
96776
  deny
95812
96777
  };
95813
96778
  }
96779
+ /**
96780
+ * `spawn_agents` became a managed name with Reasonix v2.58.0 support, so a
96781
+ * stricter rule the user wrote for it by hand (e.g. a v2.58-era
96782
+ * `deny = ["spawn_agents"]` next to an older `agent: allow` source) would be
96783
+ * silently replaced by a looser one. Name every such loosening.
96784
+ */
96785
+ function warnOnLoosenedAgentEntries({ existingPermissions, managedToolNames, generated, relativeFilePath, logger }) {
96786
+ if (!logger || !managedToolNames.has("spawn_agents")) return;
96787
+ const loosened = findLoosenedEntries({
96788
+ existingPermissions,
96789
+ toolNames: new Set(reasonixToolNamesOf("agent")),
96790
+ generated
96791
+ });
96792
+ if (loosened.length === 0) return;
96793
+ logger.warn(`Permissions feature manages the Reasonix delegation tools ('task', 'spawn_agents') through the 'agent' category and will replace ${loosened.length} stricter existing entries in ${relativeFilePath}: ${loosened.join(", ")}. To keep them, tighten the 'agent' category in ${RULESYNC_RELATIVE_DIR_PATH}/${RULESYNC_PERMISSIONS_FILE_NAME} or move them to 'reasonix.rawDeny' / 'reasonix.rawAsk'.`);
96794
+ }
96795
+ const PERMISSION_STRICTNESS = {
96796
+ allow: 0,
96797
+ ask: 1,
96798
+ deny: 2
96799
+ };
96800
+ /**
96801
+ * Existing entries for `toolNames` whose action is stricter than the one the
96802
+ * generated lists give the same entry, formatted `<list> "<entry>"`. An entry
96803
+ * the generated lists drop counts as no stricter than `allow`.
96804
+ */
96805
+ function findLoosenedEntries({ existingPermissions, toolNames, generated }) {
96806
+ const generatedStrictness = /* @__PURE__ */ new Map();
96807
+ for (const list of [
96808
+ "allow",
96809
+ "ask",
96810
+ "deny"
96811
+ ]) for (const entry of generated[list]) {
96812
+ const key = normalizeToolPatternEntry(entry);
96813
+ generatedStrictness.set(key, Math.max(generatedStrictness.get(key) ?? 0, PERMISSION_STRICTNESS[list]));
96814
+ }
96815
+ const loosened = [];
96816
+ for (const list of ["ask", "deny"]) for (const entry of toStringArray$1(existingPermissions[list])) {
96817
+ if (isReasonixExactCommandEntry(entry)) continue;
96818
+ if (!toolNames.has(parseToolPatternEntry(entry).toolName)) continue;
96819
+ const key = normalizeToolPatternEntry(entry);
96820
+ if ((generatedStrictness.get(key) ?? 0) < PERMISSION_STRICTNESS[list]) loosened.push(`${list} "${entry}"`);
96821
+ }
96822
+ return loosened;
96823
+ }
96824
+ /** Spell an entry the way rulesync writes it, so `name(*)` equals `name`. */
96825
+ function normalizeToolPatternEntry(entry) {
96826
+ return buildToolPatternEntry(parseToolPatternEntry(entry));
96827
+ }
95814
96828
  /** Sort, dedupe and set a permissions array key, deleting it when empty. */
95815
96829
  function setOrDeleteEntries(table, key, entries) {
95816
96830
  const merged = uniq(entries.toSorted());
@@ -95824,22 +96838,19 @@ function convertRulesyncToReasonixPermissions(config) {
95824
96838
  const allow = [];
95825
96839
  const ask = [];
95826
96840
  const deny = [];
95827
- for (const [category, rules] of Object.entries(config.permission)) {
95828
- const reasonixToolName = toReasonixToolName(category);
95829
- for (const [pattern, action] of Object.entries(rules)) {
95830
- const entry = buildToolPatternEntry({
95831
- toolName: reasonixToolName,
95832
- pattern
95833
- });
95834
- switch (action) {
95835
- case "allow":
95836
- allow.push(entry);
95837
- break;
95838
- case "ask":
95839
- ask.push(entry);
95840
- break;
95841
- case "deny": deny.push(entry);
95842
- }
96841
+ for (const [category, rules] of Object.entries(config.permission)) for (const reasonixToolName of toReasonixToolNames(category)) for (const [pattern, action] of Object.entries(rules)) {
96842
+ const entry = buildToolPatternEntry({
96843
+ toolName: reasonixToolName,
96844
+ pattern
96845
+ });
96846
+ switch (action) {
96847
+ case "allow":
96848
+ allow.push(entry);
96849
+ break;
96850
+ case "ask":
96851
+ ask.push(entry);
96852
+ break;
96853
+ case "deny": deny.push(entry);
95843
96854
  }
95844
96855
  }
95845
96856
  return {
@@ -96066,7 +97077,7 @@ var RooPermissions = class extends ToolPermissions {
96066
97077
  validate: true
96067
97078
  });
96068
97079
  }
96069
- toRulesyncPermissions() {
97080
+ toRulesyncPermissions({ logger } = {}) {
96070
97081
  const constructor = this.constructor;
96071
97082
  let settings;
96072
97083
  try {
@@ -96080,9 +97091,12 @@ var RooPermissions = class extends ToolPermissions {
96080
97091
  } catch (error) {
96081
97092
  throw new Error(`Failed to parse ${constructor.getToolLabel()} VS Code settings in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
96082
97093
  }
96083
- const rules = {};
96084
- for (const pattern of toStringArray$1(settings[constructor.getAllowedCommandsKey()])) rules[pattern] = "allow";
96085
- for (const pattern of toStringArray$1(settings[constructor.getDeniedCommandsKey()])) rules[pattern] = "deny";
97094
+ const rules = importVscodeCommandLists({
97095
+ allowed: toStringArray$1(settings[constructor.getAllowedCommandsKey()]),
97096
+ denied: toStringArray$1(settings[constructor.getDeniedCommandsKey()]),
97097
+ toolLabel: constructor.getToolLabel(),
97098
+ logger
97099
+ });
96086
97100
  const permission = {};
96087
97101
  if (Object.keys(rules).length > 0) permission[COMMAND_CATEGORY] = rules;
96088
97102
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
@@ -101406,6 +102420,9 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
101406
102420
  var PermissionsProcessor = class extends FeatureProcessor {
101407
102421
  toolTarget;
101408
102422
  global;
102423
+ sweepsThroughInRootLinks() {
102424
+ return this.global;
102425
+ }
101409
102426
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
101410
102427
  super({
101411
102428
  outputRoot,
@@ -104091,6 +105108,6 @@ const CHECKS_IMPORT_STRATEGY = {
104091
105108
  missingLabel: "check files",
104092
105109
  createdLabel: "check files"
104093
105110
  };
104094
- export { RulesyncMcp as $, RULESYNC_USER_CONFIG_DIR_NAME as $n, isRootDirPath as $t, CODEBUDDY_LOCAL_RULE_FILE_NAME as A, CURATED_RULES_FEATURE_SUBDIR as An, resetRunWarningState as At, CLAUDECODE_MEMORIES_DIR_NAME as B, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Bn, assertTreeContainsNoSymlinks as Bt, LETTACODE_DIR as C, ALL_TOOL_TARGETS as Cn, findControlCharacter as Ct, COMMANDCODE_DIR as D, ALL_FEATURES as Dn, fallbackLogger as Dt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as E, ToolTargetSchema as En, WarningCollectingLogger as Et, CODEXCLI_BASH_RULES_FILE_NAME as F, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Fn, PathOutsideRootError as Ft, getLocalSkillDirNames as G, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Gn, ensureDir as Gt, CLAUDECODE_SKILLS_DIR_PATH as H, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Hn, checkPathTraversal as Ht, CODEXCLI_DIR as I, RULESYNC_CONFIG_RELATIVE_FILE_PATH as In, applyFileMode as It, RulesyncSkill as J, RULESYNC_RELATIVE_DIR_PATH as Jn, findFilesByGlobs as Jt, RulesyncSubagent as K, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as Kn, fileExists as Kt, CLAUDECODE_AGENT_MEMORY_LOCAL_DIR_NAME as L, RULESYNC_CONFIG_SCHEMA_URL as Ln, assertDirectoryIfExists as Lt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as M, MAX_FILE_SIZE as Mn, CLIError as Mt, caseFoldIdentity as N, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Nn, ErrorCodes as Nt, COMMANDCODE_SETTINGS_LOCAL_FILE_NAME as O, ALL_FEATURES_WITH_WILDCARD as On, warnOnConflictingFlags as Ot, groupSpellingsByCaseFoldedIdentity as P, RULESYNC_CHECKS_RELATIVE_DIR_PATH as Pn, parseJsonc as Pt, RulesyncPermissions as Q, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as Qn, isFileSystemError as Qt, CLAUDECODE_DIR as R, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Rn, assertNoSymlinkBelowRoot as Rt, QWENCODE_LOCAL_RULE_FILE_NAME as S, mapWithConcurrency as Sn, getSourceTransport as St, FACTORYDROID_DIR as T, PACKAGING_TOOL_TARGETS as Tn, JsonLogger as Tt, findProjectInstructionBudgetFindings as U, RULESYNC_MCP_RELATIVE_FILE_PATH as Un, createTempDirectory as Ut, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as V, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Vn, assertWritablePathInsideRoot as Vt, toInstructionBudgetFile as W, RULESYNC_MCP_SCHEMA_URL as Wn, directoryExists as Wt, RulesyncRule as X, RULESYNC_SKILLS_RELATIVE_DIR_PATH as Xn, getHomeDirectory as Xt, RulesyncSkillFrontmatterSchema as Y, RULESYNC_RULES_RELATIVE_DIR_PATH as Yn, getFileSizeAndExecutableBit as Yt, RulesyncRuleFrontmatterSchema as Z, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Zn, isFileNotFoundError as Zt, SkillsProcessor as _, runWithDirectoryRollback as _n, CONFLICTING_TARGET_PAIRS as _t, convertFromTool as a, pathEscapesRoot as an, hasEnclosingMarkOutsideKeycap as ar, RulesyncCommandFrontmatterSchema as at, REASONIX_LOCAL_RULE_FILE_NAME as b, writeFileBuffer as bn, SourceEntrySchema as bt, ELLIPSIS_WIDTH as c, readFileContentOrNull as cn, stripControlCharactersKeepingLineFeeds as cr, stringifyFrontmatter as ct, McpProcessor as d, removeFile as dn, SHARED_USER_MANAGED_CONFIG_PATHS as dt, isSymlink as en, RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME as er, RulesyncIgnore as et, IgnoreProcessor as f, removeFileStrict as fn, SKILL_FILE_NAME as ft, SubagentsProcessor as g, resolvedRelativePath as gn, resolveEffectiveInputRoots as gt, RulesProcessor as h, resolvePath as hn, parseConfigFileContent as ht, inspectInputRoots as i, normalizePosixDirPath as in, hasDeceptiveHiddenCharacters as ir, RulesyncCommand as it, AUGMENTCODE_DIR as j, FEATURE_SOURCE_TREE_ENTRIES as jn, withWarnOnceScope as jt, CODEBUDDY_DIR as k, DEPRECATED_FEATURE_REPLACEMENTS as kn, withFallbackLoggerTarget as kt, displayWidthOf as l, removeDirectory as ln, stripControlCharactersKeepingLineFeedsAndTabs as lr, loadYaml as lt, ChecksProcessor as m, removeTempDirectory as mn, mergeInputRootConfigs as mt, formatSourceLoadFailure as n, listFilePathsRecursively as nn, formatError as nr, getRulesyncSourceCandidates as nt, getProcessorRegistryEntry as o, posixRelativePathEscapesRoot as on, quoteForLog as or, RulesyncCheck as ot, HooksProcessor as p, removeFileWithoutFollowingSymlinks as pn, ConfigResolver as pt, RulesyncSubagentFrontmatterSchema as q, RULESYNC_PERMISSIONS_SCHEMA_URL as qn, fileExistsStrict as qt, generate as r, listSubdirectoryNames as rn, truncateText as rr, resolveRulesyncSourceWritePath as rt, parseCommaSeparatedList as s, readFileContent as sn, stripControlCharacters as sr, RulesyncCheckFrontmatterSchema as st, importFromTool as t, listDirectoryEntryNames as tn, formatMegabytes as tr, RulesyncHooks as tt, shortenToWidth as u, removeDirectoryStrict as un, stripHiddenCharacters as ur, isRecord as ut, CRUSH_LOCAL_RULE_FILE_NAME as v, splitPathSegments as vn, ConfigFileSchema as vt, LETTACODE_SETTINGS_LOCAL_FILE_NAME as w, ALL_TOOL_TARGETS_WITH_WILDCARD as wn, ConsoleLogger as wt, QWENCODE_DIR as x, writeFileContent as xn, assertTargetsFeaturesExclusive as xt, CommandsProcessor as y, toPosixPath as yn, GITIGNORE_DESTINATION_KEY as yt, CLAUDECODE_LOCAL_RULE_FILE_NAME as z, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as zn, assertPathResolvesInsideRoot as zt };
105111
+ export { RulesyncRule as $, RULESYNC_RULES_RELATIVE_DIR_PATH as $n, getFileSizeAndExecutableBit as $t, CODEBUDDY_LOCAL_RULE_FILE_NAME as A, ToolTargetSchema as An, fallbackLogger as At, CLAUDECODE_LOCAL_RULE_FILE_NAME as B, RULESYNC_CONFIG_RELATIVE_FILE_PATH as Bn, assertDirectoryIfExists as Bt, LETTACODE_DIR as C, toPosixPath as Cn, SourceEntrySchema as Ct, COMMANDCODE_DIR as D, ALL_TOOL_TARGETS as Dn, ConsoleLogger as Dt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as E, mapWithConcurrency as En, findControlCharacter as Et, sweepRefusalReason as F, FEATURE_SOURCE_TREE_ENTRIES as Fn, CLIError as Ft, toInstructionBudgetFile as G, RULESYNC_IGNORE_RELATIVE_FILE_PATH as Gn, assertWritablePathInsideRoot as Gt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as H, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as Hn, assertNoSymlinkBelowRoot as Ht, CODEXCLI_BASH_RULES_FILE_NAME as I, MAX_FILE_SIZE as In, ErrorCodes as It, getLocalSkillDirNames as J, RULESYNC_MCP_SCHEMA_URL as Jn, directoryExists as Jt, classifyRemoteName as K, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as Kn, checkPathTraversal as Kt, CODEXCLI_DIR as L, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as Ln, parseJsonc as Lt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as M, ALL_FEATURES_WITH_WILDCARD as Mn, withFallbackLoggerTarget as Mt, caseFoldIdentity as N, DEPRECATED_FEATURE_REPLACEMENTS as Nn, resetRunWarningState as Nt, COMMANDCODE_SETTINGS_LOCAL_FILE_NAME as O, ALL_TOOL_TARGETS_WITH_WILDCARD as On, JsonLogger as Ot, groupSpellingsByCaseFoldedIdentity as P, CURATED_RULES_FEATURE_SUBDIR as Pn, withWarnOnceScope as Pt, RulesyncSkillFrontmatterSchema as Q, RULESYNC_RELATIVE_DIR_PATH as Qn, findFilesByGlobs as Qt, CLAUDECODE_AGENT_MEMORY_LOCAL_DIR_NAME as R, RULESYNC_CHECKS_RELATIVE_DIR_PATH as Rn, PathOutsideRootError as Rt, QWENCODE_LOCAL_RULE_FILE_NAME as S, splitPathSegments as Sn, GITIGNORE_DESTINATION_KEY as St, FACTORYDROID_DIR as T, writeFileContent as Tn, getSourceTransport as Tt, CLAUDECODE_SKILLS_DIR_PATH as U, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Un, assertPathResolvesInsideRoot as Ut, CLAUDECODE_MEMORIES_DIR_NAME as V, RULESYNC_CONFIG_SCHEMA_URL as Vn, assertLandingOutsideVcsMetadata as Vt, findProjectInstructionBudgetFindings as W, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Wn, assertTreeContainsNoSymlinks as Wt, RulesyncSubagentFrontmatterSchema as X, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as Xn, fileExists as Xt, RulesyncSubagent as Y, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as Yn, ensureDir as Yt, RulesyncSkill as Z, RULESYNC_PERMISSIONS_SCHEMA_URL as Zn, fileExistsStrict as Zt, SkillsProcessor as _, removeFileWithoutFollowingSymlinks as _n, mergeInputRootConfigs as _t, convertFromTool as a, listDirectoryEntryNames as an, formatMegabytes as ar, getRulesyncSourceCandidates as at, REASONIX_LOCAL_RULE_FILE_NAME as b, resolvedRelativePath as bn, CONFLICTING_TARGET_PAIRS as bt, ELLIPSIS_WIDTH as c, normalizePosixDirPath as cn, hasEnclosingMarkOutsideKeycap as cr, RulesyncCommandFrontmatterSchema as ct, McpProcessor as d, readFileContent as dn, stripControlCharactersKeepingLineFeeds as dr, stringifyFrontmatter as dt, getHomeDirectory as en, RULESYNC_SKILLS_RELATIVE_DIR_PATH as er, RulesyncRuleFrontmatterSchema as et, IgnoreProcessor as f, readFileContentOrNull as fn, stripControlCharactersKeepingLineFeedsAndTabs as fr, loadYaml as ft, SubagentsProcessor as g, removeFileStrict as gn, ConfigResolver as gt, RulesProcessor as h, removeFile as hn, SKILL_FILE_NAME as ht, inspectInputRoots as i, isSymlink as in, RULESYNC_XDG_CONFIG_HOME_DEFAULT_DIR_NAME as ir, RulesyncHooks as it, AUGMENTCODE_DIR as j, ALL_FEATURES as jn, warnOnConflictingFlags as jt, CODEBUDDY_DIR as k, PACKAGING_TOOL_TARGETS as kn, WarningCollectingLogger as kt, displayWidthOf as l, pathEscapesRoot as ln, quoteForLog as lr, RulesyncCheck as lt, ChecksProcessor as m, removeDirectoryStrict as mn, SHARED_USER_MANAGED_CONFIG_PATHS as mt, formatSourceLoadFailure as n, isFileSystemError as nn, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as nr, RulesyncMcp as nt, getProcessorRegistryEntry as o, listFilePathsRecursively as on, formatError as or, resolveRulesyncSourceWritePath as ot, HooksProcessor as p, removeDirectory as pn, stripHiddenCharacters as pr, isRecord as pt, hasWindowsFoldableName as q, RULESYNC_MCP_RELATIVE_FILE_PATH as qn, createTempDirectory as qt, generate as r, isRootDirPath as rn, RULESYNC_USER_CONFIG_DIR_NAME as rr, RulesyncIgnore as rt, parseCommaSeparatedList as s, listSubdirectoryNames as sn, truncateText as sr, RulesyncCommand as st, importFromTool as t, isFileNotFoundError as tn, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as tr, RulesyncPermissions as tt, shortenToWidth as u, posixRelativePathEscapesRoot as un, stripControlCharacters as ur, RulesyncCheckFrontmatterSchema as ut, CRUSH_LOCAL_RULE_FILE_NAME as v, removeTempDirectory as vn, parseConfigFileContent as vt, LETTACODE_SETTINGS_LOCAL_FILE_NAME as w, writeFileBuffer as wn, assertTargetsFeaturesExclusive as wt, QWENCODE_DIR as x, runWithDirectoryRollback as xn, ConfigFileSchema as xt, CommandsProcessor as y, resolvePath as yn, resolveEffectiveInputRoots as yt, CLAUDECODE_DIR as z, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as zn, applyFileMode as zt };
104095
105112
 
104096
- //# sourceMappingURL=import-tr3jHcG2.js.map
105113
+ //# sourceMappingURL=import-Cu5o6uKO.js.map