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.
@@ -1815,28 +1815,6 @@ function splitAbsolutePath(absolutePath) {
1815
1815
  };
1816
1816
  }
1817
1817
  /**
1818
- * Whether writing `targetPath` would land outside `rootPath` once every link on
1819
- * the way there is resolved. Both sides are taken to where a write to them
1820
- * would land (see {@link writeLandingPath}), so a root that does not fully
1821
- * exist yet is judged the same way as the target below it.
1822
- *
1823
- * A link that stays inside the root passes. That is what sets this apart from
1824
- * the stricter {@link assertWritablePathInsideRoot} the sweep paths use: a
1825
- * dotfiles checkout linked from inside the home directory is a common shape for
1826
- * a global write target, and refusing it would refuse `--global` outright.
1827
- *
1828
- * A link chain that cycles is reported as an escape: the write could not get
1829
- * through it, and refusing it is the answer that never writes somewhere
1830
- * unexpected.
1831
- */
1832
- async function writablePathEscapesRoot({ rootPath, targetPath }) {
1833
- const relativeLanding = await relativeWriteLanding({
1834
- rootPath,
1835
- targetPath
1836
- });
1837
- return relativeLanding === null || pathEscapesRoot(relativeLanding);
1838
- }
1839
- /**
1840
1818
  * Where a write to `targetPath` lands relative to where a write to `rootPath`
1841
1819
  * lands, both taken through {@link writeLandingPath}: `""` when the two land on
1842
1820
  * the same directory, and `null` when either side's links form a cycle. Errors
@@ -1849,6 +1827,59 @@ async function relativeWriteLanding({ rootPath, targetPath }) {
1849
1827
  return (0, node_path.relative)(rootLanding, targetLanding);
1850
1828
  }
1851
1829
  /**
1830
+ * Directory names that hold version-control metadata. Bytes landing in one of
1831
+ * them change how the VCS behaves rather than what it tracks: `.git/config`
1832
+ * can set `core.fsmonitor`, `.git/hooks/*` run on the next commit.
1833
+ */
1834
+ const VCS_METADATA_DIR_NAMES = /* @__PURE__ */ new Set([
1835
+ ".git",
1836
+ ".hg",
1837
+ ".svn",
1838
+ ".jj"
1839
+ ]);
1840
+ function hasVcsMetadataSegment(path) {
1841
+ return splitPathSegments(path).some((segment) => {
1842
+ const folded = segment.replace(/:.*$/, "").replace(/[. ]+$/, "").toLowerCase();
1843
+ return VCS_METADATA_DIR_NAMES.has(folded) || /^(git|hg|svn|jj)~\d+$/.test(folded);
1844
+ });
1845
+ }
1846
+ /**
1847
+ * Whether a link on the way to `targetPath` carries it into version-control
1848
+ * metadata (`.git/` and its siblings) that its spelling does not name.
1849
+ *
1850
+ * {@link writablePathEscapesRoot} lets a link that stays inside the root
1851
+ * through, but a checked-out repository can commit `CLAUDE.md -> .git/config`:
1852
+ * the landing is inside the root, yet a write there hands the repository author
1853
+ * the operator's next `git` command. `git clone` never transfers `.git/`, so a
1854
+ * path spelled into it is never something the repository chose; only a write
1855
+ * that lands exactly where it is spelled may touch it.
1856
+ *
1857
+ * `relativeLanding` is {@link relativeWriteLanding}'s answer for the same pair,
1858
+ * passed in so a caller that already has it does not resolve the links twice.
1859
+ */
1860
+ function landingEntersVcsMetadata({ rootPath, targetPath, relativeLanding }) {
1861
+ return hasVcsMetadataSegment(relativeLanding) && relativeLanding !== (0, node_path.relative)((0, node_path.resolve)(rootPath), (0, node_path.resolve)(targetPath));
1862
+ }
1863
+ /**
1864
+ * Refuse a write target that a symbolic link carries into version-control
1865
+ * metadata (see {@link landingEntersVcsMetadata}). Meant for writes of remote
1866
+ * bytes, next to {@link assertPathResolvesInsideRoot}: an in-root link (a
1867
+ * skill directory linked to a shared one) stays allowed, but not one that
1868
+ * hands the bytes to `.git/config` or `.git/hooks/`.
1869
+ */
1870
+ async function assertLandingOutsideVcsMetadata({ rootPath, targetPath }) {
1871
+ const relativeLanding = await relativeWriteLanding({
1872
+ rootPath,
1873
+ targetPath
1874
+ });
1875
+ if (relativeLanding === null) throw new PathOutsideRootError(`Refusing to write through a link cycle: ${targetPath}.`);
1876
+ if (landingEntersVcsMetadata({
1877
+ rootPath,
1878
+ targetPath,
1879
+ relativeLanding
1880
+ })) throw new PathOutsideRootError(`Refusing to write ${targetPath}: a symbolic link leads it into version-control metadata.`);
1881
+ }
1882
+ /**
1852
1883
  * How many trailing segments `filePath` and `identity` have in common.
1853
1884
  *
1854
1885
  * A path that walked through no link at all shares all of its own segments with
@@ -5452,7 +5483,8 @@ const QWENCODE_HOOK_EVENTS = [
5452
5483
  "todoCreated",
5453
5484
  "todoCompleted",
5454
5485
  "messageDisplay",
5455
- "sessionDelete"
5486
+ "sessionDelete",
5487
+ "fileChanged"
5456
5488
  ];
5457
5489
  const CANONICAL_TO_KIMI_CODE_EVENT_NAMES = {
5458
5490
  sessionStart: "SessionStart",
@@ -6786,6 +6818,7 @@ const CANONICAL_TO_JUNIE_EVENT_NAMES = {
6786
6818
  sessionStart: "SessionStart",
6787
6819
  beforeSubmitPrompt: "UserPromptSubmit",
6788
6820
  preToolUse: "PreToolUse",
6821
+ postToolUse: "PostToolUse",
6789
6822
  stop: "Stop",
6790
6823
  stopFailure: "StopFailure",
6791
6824
  permissionRequest: "PermissionRequest",
@@ -6794,11 +6827,12 @@ const CANONICAL_TO_JUNIE_EVENT_NAMES = {
6794
6827
  /**
6795
6828
  * Hook events supported by JetBrains Junie CLI.
6796
6829
  *
6797
- * Junie CLI exposes seven lifecycle events under the `"hooks"` key of
6830
+ * Junie CLI exposes eight lifecycle events under the `"hooks"` key of
6798
6831
  * `~/.junie/config.json`: `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
6799
- * `Stop`, `StopFailure`, `PermissionRequest`, and `SessionEnd`. Matchers apply
6800
- * to `SessionStart` (source), `PreToolUse` (tool name), `StopFailure` (error
6801
- * type), `PermissionRequest` (tool name), and `SessionEnd` (reason);
6832
+ * `PostToolUse`, `Stop`, `StopFailure`, `PermissionRequest`, and `SessionEnd`.
6833
+ * Matchers apply to `SessionStart` (source), `PreToolUse` and `PostToolUse`
6834
+ * (tool name), `StopFailure` (error type), `PermissionRequest` (tool name), and
6835
+ * `SessionEnd` (reason);
6802
6836
  * `UserPromptSubmit` and `Stop` are matcher-less and always run. Only
6803
6837
  * `type: "command"` hooks are supported. Project-local hooks are ignored for
6804
6838
  * safety.
@@ -6977,7 +7011,8 @@ const CANONICAL_TO_QWENCODE_EVENT_NAMES = {
6977
7011
  todoCreated: "TodoCreated",
6978
7012
  todoCompleted: "TodoCompleted",
6979
7013
  messageDisplay: "MessageDisplay",
6980
- sessionDelete: "SessionDelete"
7014
+ sessionDelete: "SessionDelete",
7015
+ fileChanged: "FileChanged"
6981
7016
  };
6982
7017
  /**
6983
7018
  * Map Qwen Code PascalCase event names to canonical camelCase.
@@ -9232,7 +9267,8 @@ const AntigravityCliPermissionsOverrideSchema = zod_mini.z.looseObject({
9232
9267
  * The same override also carries the two plugin-consumption keys Auggie reads
9233
9268
  * from `settings.json`: `recommendedMarketplaces` (`"owner/repo"` marketplaces
9234
9269
  * a workspace prompts its contributors to install; honored only in the
9235
- * project's `.augment/settings.json`) and `enabledPlugins` (plugin ids of the
9270
+ * project's `.augment/settings.json`, and only by Auggie 0.x — 1.x no longer
9271
+ * reads it) and `enabledPlugins` (plugin ids of the
9236
9272
  * form `"plugin@marketplace"` mapped to on/off, deep-merged across the user,
9237
9273
  * project and local tiers).
9238
9274
  *
@@ -9416,6 +9452,14 @@ const CodexBasePermissionProfileSchema = zod_mini.z.enum(CODEX_BASE_PERMISSION_P
9416
9452
  * `true`; only an explicit `false` suppresses it. Like
9417
9453
  * `base_permission_profile` it is consumed by the profile builder, not
9418
9454
  * written as a top-level config key.
9455
+ * - `bash_canonical_matching` — when `true`, the `forbidden` (deny) and
9456
+ * `prompt` (ask) rules generated from `permission.bash` into
9457
+ * `.codex/rules/rulesync.rules` carry `canonical_matching = True`, so Codex
9458
+ * compares their tokens after Unicode canonical case-insensitive folding (`rm` also
9459
+ * matches `RM`). `allow` rules stay exact so the opt-in never widens what is
9460
+ * auto-approved. Requires Codex 0.167.0 or later — an older Codex rejects the
9461
+ * keyword and fails to load the rules file — so it defaults to `false`. Not
9462
+ * written as a config key.
9419
9463
  *
9420
9464
  * The keys written to `config.toml` are an **allowlist**, not verbatim
9421
9465
  * passthrough: only `CODEXCLI_OVERRIDE_KEYS`
@@ -9423,8 +9467,8 @@ const CodexBasePermissionProfileSchema = zod_mini.z.enum(CODEX_BASE_PERMISSION_P
9423
9467
  * `sandbox_workspace_write`, `apps`, `approvals_reviewer`, `auto_review`,
9424
9468
  * `tui`) are emitted, and
9425
9469
  * `computeCodexcliOverridePatch` skips anything else with a warning.
9426
- * `base_permission_profile` and `git_write_rules` are consumed by the profile
9427
- * builder rather than written, as described above, and `permission` is the
9470
+ * `base_permission_profile`, `git_write_rules` and `bash_canonical_matching`
9471
+ * are consumed by the generators rather than written, as described above, and `permission` is the
9428
9472
  * tool-scoped canonical block, which `RulesyncPermissions.forTarget` strips out
9429
9473
  * of the override before it ever reaches the patch. The allowlist is what keeps
9430
9474
  * the override from clobbering a feature-owned key: `mcp_servers.*` per-MCP
@@ -9481,7 +9525,8 @@ const CodexcliPermissionsOverrideSchema = zod_mini.z.looseObject({
9481
9525
  * @see https://developers.openai.com/codex/config-reference
9482
9526
  */
9483
9527
  tui: zod_mini.z.optional(zod_mini.z.looseObject({})),
9484
- git_write_rules: zod_mini.z.optional(zod_mini.z.boolean())
9528
+ git_write_rules: zod_mini.z.optional(zod_mini.z.boolean()),
9529
+ bash_canonical_matching: zod_mini.z.optional(zod_mini.z.boolean())
9485
9530
  });
9486
9531
  /**
9487
9532
  * Tool-scoped override block for Zed. These Zed `agent` keys sit outside the
@@ -9565,6 +9610,14 @@ const ZedPermissionsOverrideSchema = zod_mini.z.looseObject({
9565
9610
  * { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
9566
9611
  * @example
9567
9612
  * { "forbiddenPlugins": ["*"], "optionalPlugins": ["acme/approved"] }
9613
+ *
9614
+ * `permission_mode` (CLI v3000.12.17+) is the mode new local sessions start in
9615
+ * (`normal`, the upstream default, `accept-edits`, `smart` or `dangerous`).
9616
+ * Like `sandbox` it is a **User Config Only** key, so it is emitted at global
9617
+ * scope only. It is never derived from the canonical rules.
9618
+ *
9619
+ * @example
9620
+ * { "permission_mode": "accept-edits" }
9568
9621
  * @see https://docs.devin.ai/cli/sandbox
9569
9622
  * @see https://docs.devin.ai/cli/reference/configuration/config-file
9570
9623
  * @see https://docs.devin.ai/cli/extensibility/plugins/overview
@@ -9580,12 +9633,25 @@ const DEVIN_PLUGIN_GOVERNANCE_SCHEMAS = {
9580
9633
  optionalPlugins: DevinPluginSourceListSchema,
9581
9634
  forbiddenPlugins: zod_mini.z.array(zod_mini.z.string())
9582
9635
  };
9636
+ /**
9637
+ * Values Devin accepts for the user-config `permission_mode` key. Shared by the
9638
+ * override schema and the import, which drops a value that would not pass it.
9639
+ *
9640
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
9641
+ */
9642
+ const DevinPermissionModeSchema = zod_mini.z.enum([
9643
+ "normal",
9644
+ "accept-edits",
9645
+ "smart",
9646
+ "dangerous"
9647
+ ]);
9583
9648
  const DevinPermissionsOverrideSchema = zod_mini.z.looseObject({
9584
9649
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
9585
9650
  sandbox: zod_mini.z.optional(zod_mini.z.looseObject({})),
9586
9651
  requiredPlugins: zod_mini.z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.requiredPlugins),
9587
9652
  optionalPlugins: zod_mini.z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.optionalPlugins),
9588
- forbiddenPlugins: zod_mini.z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.forbiddenPlugins)
9653
+ forbiddenPlugins: zod_mini.z.optional(DEVIN_PLUGIN_GOVERNANCE_SCHEMAS.forbiddenPlugins),
9654
+ permission_mode: zod_mini.z.optional(DevinPermissionModeSchema)
9589
9655
  });
9590
9656
  /**
9591
9657
  * Permissions configuration.
@@ -11997,6 +12063,98 @@ function resolveStandardSkillFields({ rootFrontmatter, section }) {
11997
12063
  })
11998
12064
  });
11999
12065
  }
12066
+ // src/utils/remote-name.ts
12067
+ /**
12068
+ * Name checks for a directory or file name chosen by a remote source (a
12069
+ * fetched repository, an installed git, GitHub or npm source). Shared by
12070
+ * `rulesync fetch` and `rulesync install`, so a name one of them turns away is
12071
+ * not written, or printed raw, by the other.
12072
+ */
12073
+ /** A name with no character in it that draws anything of its own. */
12074
+ const NOTHING_DRAWN_PATTERN = /^[\s\p{M}]*$/u;
12075
+ /**
12076
+ * The marks a name begins with. A mark is drawn over the character before it,
12077
+ * and a name has nothing before it of its own: a leading one is drawn over
12078
+ * whatever the terminal printed last — the checkbox, the quote around a name
12079
+ * in a warning — while the name reads as if it began with its first letter.
12080
+ */
12081
+ const LEADING_MARKS_PATTERN = /^\p{M}+/u;
12082
+ /**
12083
+ * A name ending in a dot or an ASCII space, which Win32 strips before it opens
12084
+ * the path. Only the plain space (U+0020): Win32 keeps every other trailing
12085
+ * blank, so a name ending in a no-break or an ideographic space is written
12086
+ * where it says, and is left to the whitespace note of the confusable-name
12087
+ * check rather than dropped here with Windows named as the reason.
12088
+ */
12089
+ const TRAILING_DOT_OR_SPACE_PATTERN = /[. ]$/;
12090
+ /** A name ending in the `NAME~1` shape of a Windows 8.3 short name, extension included. */
12091
+ const SHORT_NAME_ALIAS_PATTERN = /~\d+(?:\.[^.]*)?$/;
12092
+ /**
12093
+ * A name Win32 reserves for a device — `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
12094
+ * `CONOUT$`, `COM1`–`COM9`, `LPT1`–`LPT9` — in any case and with any
12095
+ * extension: `nul.md` is the null device too, since the reservation is decided
12096
+ * on the part before the first dot, with any spaces before that dot dropped
12097
+ * first, so `nul .md` is the null device as well. The port digit may also be
12098
+ * one of the superscript digits ¹ ² ³, which Win32 reads as 1, 2 and 3
12099
+ * (`RtlIsDosDeviceName_U`; CPython's `ntpath.isreserved` follows the same
12100
+ * rules).
12101
+ */
12102
+ const RESERVED_DEVICE_NAME_PATTERN = /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9\u00B9\u00B2\u00B3]|LPT[1-9\u00B9\u00B2\u00B3]) *(?:\..*)?$/i;
12103
+ /**
12104
+ * Whether Windows resolves a path segment to something other than the entry it
12105
+ * reads as.
12106
+ *
12107
+ * Win32 drops a trailing dot or ASCII space when it resolves a path, so a remote
12108
+ * `skills/deploy.` is the existing `skills/deploy` there. A name ending in the
12109
+ * `NAME~1` shape is the same class of problem: on a volume that generates
12110
+ * short names, it opens whatever long name it stands for. The shape only
12111
+ * means that at the end of a name, so `data~2parser` and `v1.0` are ordinary
12112
+ * names. A reserved device name is the extreme case: `nul` and `nul.md` are
12113
+ * not entries in any directory but the null device itself, so a write to one
12114
+ * lands nowhere, and a directory of that name cannot be created at all.
12115
+ *
12116
+ * The predicate is asked of every segment of a written path — the skill
12117
+ * directory, but equally a nested `references/notes. ` or a `scripts/con.sh`
12118
+ * — since a folded segment anywhere in a path lands the file beside, or on top
12119
+ * of, a sibling the repository never published. The check is not gated on the
12120
+ * platform: the same repository is checked out on several of them, and a name
12121
+ * that lands somewhere else on any one of them is one this tool would rather
12122
+ * turn away everywhere than write differently depending on where it runs.
12123
+ */
12124
+ function hasWindowsFoldableName(name) {
12125
+ return TRAILING_DOT_OR_SPACE_PATTERN.test(name) || SHORT_NAME_ALIAS_PATTERN.test(name) || RESERVED_DEVICE_NAME_PATTERN.test(name);
12126
+ }
12127
+ /**
12128
+ * Whether a remote name is safe to write and to print as it is.
12129
+ *
12130
+ * A name of nothing but blank space is shown as the empty string, the same as
12131
+ * one that strips down to nothing: both draw as a row with no name on it. So
12132
+ * is a name of nothing but combining marks, which have no character of their
12133
+ * own to sit on and draw as a smear over whatever the terminal puts beside
12134
+ * them. A name that merely begins with a mark is turned away for the same
12135
+ * reason, and shown without it: the mark sits on nothing the name owns. A name
12136
+ * carrying control characters (an ANSI, C1 or OSC sequence) or deceptive
12137
+ * zero-width characters is turned away too, so it can neither rewrite the
12138
+ * terminal nor pass for another name. The Windows-folding check comes after
12139
+ * the hidden characters, so a name that is both is reported by the reason that
12140
+ * keeps its raw form off the terminal.
12141
+ */
12142
+ function classifyRemoteName(name) {
12143
+ const stripped = stripHiddenCharacters(name);
12144
+ const beginsWithMark = LEADING_MARKS_PATTERN.test(stripped);
12145
+ const display = NOTHING_DRAWN_PATTERN.test(stripped) ? "" : stripped.replace(LEADING_MARKS_PATTERN, "");
12146
+ if (display === "" || beginsWithMark || hasDeceptiveHiddenCharacters(name)) return {
12147
+ kind: "unsafe",
12148
+ display,
12149
+ reason: "hidden"
12150
+ };
12151
+ if (hasWindowsFoldableName(name)) return {
12152
+ kind: "unsafe",
12153
+ display,
12154
+ reason: "foldable"
12155
+ };
12156
+ return { kind: "safe" };
12157
+ }
12000
12158
  // src/features/rules/instruction-budget.ts
12001
12159
  /**
12002
12160
  * Normalizes a relative directory path for comparison: POSIX separators, no
@@ -13884,7 +14042,11 @@ const SHARED_CONFIG_OWNERSHIP = {
13884
14042
  },
13885
14043
  permissions: {
13886
14044
  kind: "replace-owned-keys",
13887
- ownedKeys: ["permissions", "sandbox"]
14045
+ ownedKeys: [
14046
+ "permissions",
14047
+ "sandbox",
14048
+ "permission_mode"
14049
+ ]
13888
14050
  }
13889
14051
  }
13890
14052
  },
@@ -14658,6 +14820,16 @@ var RulesyncSourceConsumer = class {
14658
14820
  this.dryRun = dryRun;
14659
14821
  this.logger = logger;
14660
14822
  }
14823
+ /**
14824
+ * Whether the orphan sweep may follow a symbolic link that stays inside the
14825
+ * output root. Only global scope answers true: its root is the operator's
14826
+ * own home directory, where a dotfiles checkout linked in (`~/.claude ->
14827
+ * ~/dotfiles/claude`) is a common shape. A project checkout may carry links
14828
+ * its author committed, so the default refuses them.
14829
+ */
14830
+ sweepsThroughInRootLinks() {
14831
+ return false;
14832
+ }
14661
14833
  recordRulesyncSourceLoadFailure() {
14662
14834
  this.rulesyncSourceLoadFailed = true;
14663
14835
  }
@@ -14790,7 +14962,8 @@ var FeatureProcessor = class extends RulesyncSourceConsumer {
14790
14962
  refused = await refusesDeleteOutsideRoot({
14791
14963
  logger: this.logger,
14792
14964
  rootPath,
14793
- dirPath
14965
+ dirPath,
14966
+ allowInRootLinks: this.sweepsThroughInRootLinks()
14794
14967
  });
14795
14968
  refusedDirs.set(cacheKey, refused);
14796
14969
  }
@@ -14890,27 +15063,45 @@ function mergeByIdentity({ perRoot, identity }) {
14890
15063
  return order.map((key) => winnerByKey.get(key));
14891
15064
  }
14892
15065
  /**
14893
- * Whether a write to `targetPath` has to be refused because a link on the way
14894
- * there leads out of `rootPath`, warning about the refusal.
15066
+ * Why a write to `targetPath` has to be refused, or `null` when it may go
15067
+ * ahead: a link on the way there leads out of `rootPath`, or into
15068
+ * version-control metadata that the path as spelled does not name.
14895
15069
  *
14896
15070
  * A checked-out repository can carry a symbolic link at an output path
14897
- * (`AGENTS.md -> ~/.bashrc`, a tool directory linked to `/etc`), and a write
14898
- * through it would land wherever the link points. Every writer that lets the
14899
- * OS follow links — file, directory, and config-rewrite alike — asks this first
14900
- * so that one warning describes the one behavior.
15071
+ * (`AGENTS.md -> ~/.bashrc`, a tool directory linked to `/etc`, `CLAUDE.md ->
15072
+ * .git/config`), and a write through it would land wherever the link points.
15073
+ */
15074
+ async function writeRefusalReason({ rootPath, targetPath }) {
15075
+ const relativeLanding = await relativeWriteLanding({
15076
+ rootPath,
15077
+ targetPath
15078
+ });
15079
+ if (relativeLanding === null || pathEscapesRoot(relativeLanding)) return `it resolves outside ${quoteForLog(rootPath)} through a symbolic link`;
15080
+ if (landingEntersVcsMetadata({
15081
+ rootPath,
15082
+ targetPath,
15083
+ relativeLanding
15084
+ })) return "it resolves into version-control metadata through a symbolic link";
15085
+ return null;
15086
+ }
15087
+ /**
15088
+ * Whether a write to `targetPath` has to be refused (see
15089
+ * {@link writeRefusalReason}), warning about the refusal. Every writer that
15090
+ * lets the OS follow links — file, directory, and config-rewrite alike — asks
15091
+ * this first so that one warning describes the one behavior.
14901
15092
  */
14902
15093
  async function refusesWriteOutsideRoot({ logger, rootPath, targetPath }) {
14903
- if (!await writablePathEscapesRoot({
15094
+ const reason = await writeRefusalReason({
14904
15095
  rootPath,
14905
15096
  targetPath
14906
- })) return false;
14907
- logger.warn(`Refusing to write ${quoteForLog(targetPath)}: it resolves outside ${quoteForLog(rootPath)} through a symbolic link`);
15097
+ });
15098
+ if (reason === null) return false;
15099
+ logger.warn(`Refusing to write ${quoteForLog(targetPath)}: ${reason}`);
14908
15100
  return true;
14909
15101
  }
14910
15102
  /**
14911
- * Whether deleting the files in `dirPath` has to be refused because a link on
14912
- * the way to that directory leads out of `rootPath`, warning about the refusal
14913
- * once for the whole directory.
15103
+ * Why the orphan sweep has to leave the files in `dirPath` alone, or `null`
15104
+ * when it may delete them.
14914
15105
  *
14915
15106
  * Deletion candidates are enumerated without following links *inside* the
14916
15107
  * scanned directory, but the scanned directory itself is reached through
@@ -14919,13 +15110,40 @@ async function refusesWriteOutsideRoot({ logger, rootPath, targetPath }) {
14919
15110
  * an orphan. Only the directory is judged, never the file in it, because
14920
15111
  * removing a file that is itself a link unlinks the link where it is spelled
14921
15112
  * and never touches its target.
15113
+ *
15114
+ * Beyond the write rules, a project checkout is content another party may
15115
+ * control: a committed `.claude/rules -> ..` would make every untracked `*.md`
15116
+ * in the project read as an orphan. Unless `allowInRootLinks` (global scope,
15117
+ * where the root is the operator's own home directory) the swept directory
15118
+ * must therefore be the one its path spells, with no link on the way.
14922
15119
  */
14923
- async function refusesDeleteOutsideRoot({ logger, rootPath, dirPath }) {
14924
- if (!await writablePathEscapesRoot({
15120
+ async function sweepRefusalReason({ rootPath, dirPath, allowInRootLinks }) {
15121
+ const relativeLanding = await relativeWriteLanding({
14925
15122
  rootPath,
14926
15123
  targetPath: dirPath
14927
- })) return false;
14928
- logger.warn(`Refusing to delete files in ${quoteForLog(dirPath)}: it resolves outside ${quoteForLog(rootPath)} through a symbolic link`);
15124
+ });
15125
+ if (relativeLanding === null || pathEscapesRoot(relativeLanding)) return `it resolves outside ${quoteForLog(rootPath)} through a symbolic link`;
15126
+ if (landingEntersVcsMetadata({
15127
+ rootPath,
15128
+ targetPath: dirPath,
15129
+ relativeLanding
15130
+ })) return "it resolves into version-control metadata through a symbolic link";
15131
+ if (!allowInRootLinks && relativeLanding !== (0, node_path.relative)((0, node_path.resolve)(rootPath), (0, node_path.resolve)(dirPath))) return "it is reached through a symbolic link";
15132
+ return null;
15133
+ }
15134
+ /**
15135
+ * Whether deleting the files in `dirPath` has to be refused (see
15136
+ * {@link sweepRefusalReason}), warning about the refusal once for the whole
15137
+ * directory.
15138
+ */
15139
+ async function refusesDeleteOutsideRoot({ logger, rootPath, dirPath, allowInRootLinks }) {
15140
+ const reason = await sweepRefusalReason({
15141
+ rootPath,
15142
+ dirPath,
15143
+ allowInRootLinks
15144
+ });
15145
+ if (reason === null) return false;
15146
+ logger.warn(`Refusing to delete files in ${quoteForLog(dirPath)}: ${reason}`);
14929
15147
  return true;
14930
15148
  }
14931
15149
  /**
@@ -22636,6 +22854,9 @@ const commandsProcessorToolTargetsGlobal = allToolTargetKeys$3.filter((target) =
22636
22854
  var CommandsProcessor = class extends FeatureProcessor {
22637
22855
  toolTarget;
22638
22856
  global;
22857
+ sweepsThroughInRootLinks() {
22858
+ return this.global;
22859
+ }
22639
22860
  getFactory;
22640
22861
  flattenedCommandNaming;
22641
22862
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$6, dryRun = false, flattenedCommandNaming = "basename", logger }) {
@@ -23207,6 +23428,15 @@ var DirFeatureProcessor = class extends RulesyncSourceConsumer {
23207
23428
  this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${stripControlCharacters(formatError(error))}`);
23208
23429
  continue;
23209
23430
  }
23431
+ const sweepRefusal = await sweepRefusalReason({
23432
+ rootPath: this.outputRoot,
23433
+ dirPath,
23434
+ allowInRootLinks: this.sweepsThroughInRootLinks()
23435
+ });
23436
+ if (sweepRefusal !== null) {
23437
+ this.logger.warn(`Refusing to sweep ${quotedDirPath}: ${sweepRefusal}`);
23438
+ continue;
23439
+ }
23210
23440
  const generatedNames = /* @__PURE__ */ new Set();
23211
23441
  const mainFile = aiDir.getMainFile();
23212
23442
  if (mainFile) generatedNames.add(toPosixPath(mainFile.name));
@@ -25290,6 +25520,8 @@ const CRUSH_CONFIG_FILE_NAME = "crush.json";
25290
25520
  const CRUSH_HIDDEN_CONFIG_FILE_NAME = ".crush.json";
25291
25521
  const CRUSH_PERMISSIONS_KEY = "permissions";
25292
25522
  const CRUSH_ALLOWED_TOOLS_KEY = "allowed_tools";
25523
+ const CRUSH_SAFE_COMMANDS_KEY = "safe_commands";
25524
+ const CRUSH_BLOCKED_COMMANDS_KEY = "blocked_commands";
25293
25525
  const CRUSH_OPTIONS_KEY = "options";
25294
25526
  const CRUSH_DISABLED_TOOLS_KEY = "disabled_tools";
25295
25527
  const CRUSH_HOOKS_KEY = "hooks";
@@ -29970,6 +30202,9 @@ var SkillsProcessor = class extends DirFeatureProcessor {
29970
30202
  global: this.global
29971
30203
  });
29972
30204
  }
30205
+ sweepsThroughInRootLinks() {
30206
+ return this.global;
30207
+ }
29973
30208
  /**
29974
30209
  * The tool's skills roots that exist on disk, each paired with its absolute
29975
30210
  * path and vetted as writable inside this run's output root. Shared by the
@@ -29999,6 +30234,15 @@ var SkillsProcessor = class extends DirFeatureProcessor {
29999
30234
  this.logger.warn(`Skipping the orphan sweep for ${quoteForLog(skillsDirPath)}; nothing under it is deleted: ${stripControlCharacters(formatError(error))}`);
30000
30235
  continue;
30001
30236
  }
30237
+ const sweepRefusal = await sweepRefusalReason({
30238
+ rootPath: this.outputRoot,
30239
+ dirPath: skillsDirPath,
30240
+ allowInRootLinks: this.sweepsThroughInRootLinks()
30241
+ });
30242
+ if (sweepRefusal !== null) {
30243
+ this.logger.warn(`Skipping the orphan sweep for ${quoteForLog(skillsDirPath)}; nothing under it is deleted: ${sweepRefusal}`);
30244
+ continue;
30245
+ }
30002
30246
  existingRoots.push({
30003
30247
  root,
30004
30248
  skillsDirPath
@@ -33225,11 +33469,17 @@ var FirebenderSubagent = class FirebenderSubagent extends ToolSubagent {
33225
33469
  */
33226
33470
  static async fromRulesyncSubagents({ outputRoot = process.cwd(), rulesyncSubagents, global = false, logger, deleteOrphans = false }) {
33227
33471
  const agentsDirPath = this.getSettablePaths({ global }).relativeDirPath;
33228
- const agentsDirEscapesRoot = await writablePathEscapesRoot({
33472
+ const agentsDirFullPath = (0, node_path.join)(outputRoot, agentsDirPath);
33473
+ const writeRefused = await writeRefusalReason({
33229
33474
  rootPath: outputRoot,
33230
- targetPath: (0, node_path.join)(outputRoot, agentsDirPath, "agent.md")
33231
- });
33232
- const existingAgentFileNames = new Set(agentsDirEscapesRoot ? [] : await listDirectoryEntryNames((0, node_path.join)(outputRoot, agentsDirPath)));
33475
+ targetPath: (0, node_path.join)(agentsDirFullPath, "agent.md")
33476
+ }) !== null;
33477
+ const sweepRefused = deleteOrphans && await sweepRefusalReason({
33478
+ rootPath: outputRoot,
33479
+ dirPath: agentsDirFullPath,
33480
+ allowInRootLinks: global
33481
+ }) !== null;
33482
+ const existingAgentFileNames = new Set(writeRefused ? [] : await listDirectoryEntryNames(agentsDirFullPath));
33233
33483
  const subagents = rulesyncSubagents.map((rulesyncSubagent) => this.fromRulesyncSubagent({
33234
33484
  outputRoot,
33235
33485
  relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
@@ -33245,8 +33495,8 @@ var FirebenderSubagent = class FirebenderSubagent extends ToolSubagent {
33245
33495
  global
33246
33496
  })),
33247
33497
  existingAgentFileNames,
33248
- deleteOrphans,
33249
- frozen: agentsDirEscapesRoot,
33498
+ deleteOrphans: deleteOrphans && !sweepRefused,
33499
+ frozen: writeRefused,
33250
33500
  global,
33251
33501
  logger
33252
33502
  });
@@ -37364,6 +37614,9 @@ function compareRelativePathsShallowestFirst(left, right) {
37364
37614
  var SubagentsProcessor = class extends FeatureProcessor {
37365
37615
  toolTarget;
37366
37616
  global;
37617
+ sweepsThroughInRootLinks() {
37618
+ return this.global;
37619
+ }
37367
37620
  deleteOrphans;
37368
37621
  getFactory;
37369
37622
  /**
@@ -38085,6 +38338,39 @@ function warnAndSkipMcpServer({ toolName, serverName, reason, logger }) {
38085
38338
  return null;
38086
38339
  }
38087
38340
  /**
38341
+ * Drops every server whose `enabledTools` allowlist is empty, warning about
38342
+ * each one, for a tool that reads an empty (or missing) allowlist as "no
38343
+ * filter". Canonically an empty allowlist allows no tools at all, so emitting
38344
+ * the server unfiltered would expose every tool it reports. With
38345
+ * `ignoreBlankNames`, blank entries count as absent, for a tool that drops
38346
+ * them before applying the list. `productName` names the tool in the reason
38347
+ * when it differs from the `toolName` the warning starts with.
38348
+ */
38349
+ function skipServersWithEmptyEnabledTools({ mcpServers, toolName, logger, ignoreBlankNames = false, productName = toolName }) {
38350
+ const kept = {};
38351
+ for (const [name, server] of Object.entries(mcpServers)) {
38352
+ const { enabledTools } = server;
38353
+ if (Array.isArray(enabledTools)) {
38354
+ if ((ignoreBlankNames ? enabledTools.filter((tool) => typeof tool !== "string" || tool.trim() !== "") : enabledTools).length === 0) {
38355
+ warnAndSkipMcpServer({
38356
+ toolName,
38357
+ serverName: name,
38358
+ reason: `an empty enabledTools list, which allows no tools at all but which ${productName} treats as no filter`,
38359
+ logger
38360
+ });
38361
+ continue;
38362
+ }
38363
+ }
38364
+ Object.defineProperty(kept, name, {
38365
+ value: server,
38366
+ enumerable: true,
38367
+ writable: true,
38368
+ configurable: true
38369
+ });
38370
+ }
38371
+ return kept;
38372
+ }
38373
+ /**
38088
38374
  * The `tools` map Kilo and OpenCode keep beside `mcp` is keyed `<server>_<tool>`
38089
38375
  * and reaches servers `mcp` does not list — ones the global config or a
38090
38376
  * marketplace defines — so entries naming no listed
@@ -38646,7 +38932,11 @@ var KiloMcp = class KiloMcp extends ToolMcp {
38646
38932
  const existingMcp = readExistingKiloMcpEntries(fileContent);
38647
38933
  const parsedExisting = (0, jsonc_parser.parse)(fileContent || "{}");
38648
38934
  const existingDocument = isRecord(parsedExisting) ? parsedExisting : {};
38649
- const mcpServers = rulesyncMcp.getMcpServers();
38935
+ const mcpServers = skipServersWithEmptyEnabledTools({
38936
+ mcpServers: rulesyncMcp.getMcpServers(),
38937
+ toolName: "Kilo",
38938
+ logger
38939
+ });
38650
38940
  const { mcp: convertedMcp, toolPermissions } = convertToKiloFormat(resolveKiloEnvVarRefs({
38651
38941
  mcpServers,
38652
38942
  global,
@@ -38775,7 +39065,8 @@ const OPENCODE_PASSTHROUGH_SERVER_FIELDS = [
38775
39065
  "timeout",
38776
39066
  "oauth",
38777
39067
  "protocol",
38778
- "codemode"
39068
+ "codemode",
39069
+ "pool"
38779
39070
  ];
38780
39071
  const OPENCODE_MCP_TRANSPORT_KEYS = [
38781
39072
  ...Object.keys(OpencodeMcpLocalServerSchema.def.shape),
@@ -38938,7 +39229,7 @@ function warnAboutToggleDroppedKeys(serverName, serverConfig, logger) {
38938
39229
  * - env -> environment
38939
39230
  * - disabled -> enabled (inverted)
38940
39231
  * - enabledTools/disabledTools -> top-level tools map (with server name prefix)
38941
- * - OpenCode-supported extras (timeout, oauth, protocol, codemode) -> passed through verbatim
39232
+ * - OpenCode-supported extras (timeout, oauth, protocol, codemode, pool) -> passed through verbatim
38942
39233
  */
38943
39234
  function convertServerToOpencodeFormat(serverName, serverConfig, existingEntry, configShape, logger) {
38944
39235
  const serverRecord = serverConfig;
@@ -39178,7 +39469,11 @@ var OpencodeMcp = class extends ToolMcp {
39178
39469
  if (fileContent) relativeFilePath = this.layout.jsonFileName;
39179
39470
  }
39180
39471
  const transformedServers = convertEnvVarRefsToToolFormat({
39181
- mcpServers: rulesyncMcp.getMcpServers(),
39472
+ mcpServers: skipServersWithEmptyEnabledTools({
39473
+ mcpServers: rulesyncMcp.getMcpServers(),
39474
+ toolName: "OpenCode",
39475
+ logger
39476
+ }),
39182
39477
  replacement: "{env:$1}"
39183
39478
  });
39184
39479
  const existingJson = (0, jsonc_parser.parse)(fileContent || "{}");
@@ -48774,6 +49069,9 @@ var RulesProcessor = class extends FeatureProcessor {
48774
49069
  language;
48775
49070
  deriveSubprojectPathFromGlobs;
48776
49071
  global;
49072
+ sweepsThroughInRootLinks() {
49073
+ return this.global;
49074
+ }
48777
49075
  getFactory;
48778
49076
  skills;
48779
49077
  featureOptions;
@@ -51358,12 +51656,29 @@ var HermesagentCheck = class HermesagentCheck extends ToolCheck {
51358
51656
  };
51359
51657
  }
51360
51658
  }
51659
+ /**
51660
+ * The `.rulesync/checks/<stem>.md` name an imported spec gets. The slug is
51661
+ * read from a spec file that may come from a fetched or cloned repository,
51662
+ * so it is kept as written only when it is a single plain file name; a slug
51663
+ * such as `../../docs/OPERATOR` would otherwise move the write out of
51664
+ * `.rulesync/checks/`. Anything else is slugified, and an empty result falls
51665
+ * back to the spec file's own name.
51666
+ */
51667
+ importedCheckStem(slug) {
51668
+ const isPlainFileName = (name) => name !== "" && name !== "." && name !== ".." && !/[/\\<>:"|?*]/.test(name) && !hasWindowsFoldableName(name) && stripControlCharacters(name) === name;
51669
+ if (isPlainFileName(slug)) return slug;
51670
+ for (const candidate of [slugifyCheckName(slug), slugifyCheckName((0, node_path.basename)(this.relativeFilePath, ".json"))]) {
51671
+ if (candidate === "") continue;
51672
+ return isPlainFileName(candidate) ? candidate : `${candidate}-check`;
51673
+ }
51674
+ return "check";
51675
+ }
51361
51676
  toRulesyncCheck() {
51362
51677
  const spec = HermesCheckSpecSchema.parse(JSON.parse(this.fileContent));
51363
51678
  return new RulesyncCheck({
51364
51679
  outputRoot: ".",
51365
51680
  relativeDirPath: RULESYNC_CHECKS_RELATIVE_DIR_PATH,
51366
- relativeFilePath: `${spec.slug}.md`,
51681
+ relativeFilePath: `${this.importedCheckStem(spec.slug)}.md`,
51367
51682
  frontmatter: {
51368
51683
  targets: ["*"],
51369
51684
  description: spec.description,
@@ -51871,6 +52186,9 @@ const checksProcessorToolTargetsGlobal = allToolTargetKeys.filter((target) => {
51871
52186
  var ChecksProcessor = class extends FeatureProcessor {
51872
52187
  toolTarget;
51873
52188
  global;
52189
+ sweepsThroughInRootLinks() {
52190
+ return this.global;
52191
+ }
51874
52192
  getFactory;
51875
52193
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
51876
52194
  super({
@@ -54182,6 +54500,10 @@ function definingFields({ h, rawEntry, hookType, converterConfig }) {
54182
54500
  field: "tool",
54183
54501
  value: h.tool
54184
54502
  });
54503
+ for (const { canonical, tool } of converterConfig.stringPassthroughFields ?? []) if (canonical === "if" && h[tool] !== "") fields.push({
54504
+ field: "if",
54505
+ value: h[tool]
54506
+ });
54185
54507
  return fields;
54186
54508
  }
54187
54509
  /**
@@ -62958,10 +63280,12 @@ function emittedEventsWithHook(params) {
62958
63280
  function converterCapabilities(config) {
62959
63281
  if (!config.supportedHookTypes) throw new Error("Hooks converter config must declare its supportedHookTypes");
62960
63282
  const passesThroughEnabled = (config.booleanPassthroughFields ?? []).some((field) => field.canonical === "enabled");
63283
+ const passesThroughIf = (config.stringPassthroughFields ?? []).some((field) => field.canonical === "if") && !(config.skippedWhenFields ?? []).includes("if");
62961
63284
  return {
62962
63285
  supportedEvents: config.supportedEvents,
62963
63286
  supportedHookTypes: [...config.supportedHookTypes],
62964
- ...passesThroughEnabled ? { supportsPerHookEnabled: true } : {}
63287
+ ...passesThroughEnabled ? { supportsPerHookEnabled: true } : {},
63288
+ ...passesThroughIf ? { supportsPerHookIf: true } : {}
62965
63289
  };
62966
63290
  }
62967
63291
  const toolHooksFactories = /* @__PURE__ */ new Map([
@@ -63606,6 +63930,9 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
63606
63930
  var HooksProcessor = class extends FeatureProcessor {
63607
63931
  toolTarget;
63608
63932
  global;
63933
+ sweepsThroughInRootLinks() {
63934
+ return this.global;
63935
+ }
63609
63936
  preserveUnownedHooks;
63610
63937
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, preserveUnownedHooks = false, logger }) {
63611
63938
  super({
@@ -63684,7 +64011,8 @@ var HooksProcessor = class extends FeatureProcessor {
63684
64011
  if (!factory) throw new Error(`Unsupported tool target: ${this.toolTarget}`);
63685
64012
  const config = rulesyncHooks.getJson();
63686
64013
  const sharedHooks = config.hooks;
63687
- const overrideHooks = config[HOOKS_OVERRIDE_KEY_ALIASES[this.toolTarget] ?? this.toolTarget]?.hooks ?? {};
64014
+ const overrideKey = HOOKS_OVERRIDE_KEY_ALIASES[this.toolTarget] ?? this.toolTarget;
64015
+ const overrideHooks = config[overrideKey]?.hooks ?? {};
63688
64016
  const effectiveHooks = {
63689
64017
  ...sharedHooks,
63690
64018
  ...overrideHooks
@@ -63722,7 +64050,10 @@ var HooksProcessor = class extends FeatureProcessor {
63722
64050
  if (eventsWithUnsupportedMatcher.length > 0) this.logger.warn(`Skipped matcher hook(s) for ${this.toolTarget} (not supported): ${eventsWithUnsupportedMatcher.join(", ")}`);
63723
64051
  const toolHooks = await factory.class.fromRulesyncHooks({
63724
64052
  outputRoot: this.outputRoot,
63725
- rulesyncHooks,
64053
+ rulesyncHooks: factory.supportsPerHookIf ? rulesyncHooks : this.withoutConditionalHooks({
64054
+ rulesyncHooks,
64055
+ overrideKey
64056
+ }),
63726
64057
  validate: true,
63727
64058
  global: this.global,
63728
64059
  preserveUnowned: this.preserveUnownedHooks,
@@ -63745,6 +64076,53 @@ var HooksProcessor = class extends FeatureProcessor {
63745
64076
  return result;
63746
64077
  }
63747
64078
  /**
64079
+ * Returns the hooks source without the definitions that carry a per-hook
64080
+ * `if` condition, warning about each one. Used for targets that cannot
64081
+ * express `if`: emitting such a hook without its condition would make it
64082
+ * fire on every call its matcher selects, so a permission-granting hook
64083
+ * scoped by `if` would approve far more than its author meant.
64084
+ */
64085
+ withoutConditionalHooks(params) {
64086
+ const { rulesyncHooks, overrideKey } = params;
64087
+ const config = rulesyncHooks.getJson();
64088
+ const skipped = [];
64089
+ const filterHooks = (hooks) => {
64090
+ if (!isPlainObject(hooks)) return hooks;
64091
+ const filtered = {};
64092
+ for (const [event, defs] of Object.entries(hooks)) {
64093
+ if (!Array.isArray(defs)) {
64094
+ filtered[event] = defs;
64095
+ continue;
64096
+ }
64097
+ filtered[event] = defs.filter((def) => {
64098
+ const condition = isPlainObject(def) ? def.if : void 0;
64099
+ if (typeof condition !== "string" || condition.trim() === "") return true;
64100
+ skipped.push(`${event} (${quoteValueForWarning(condition)})`);
64101
+ return false;
64102
+ });
64103
+ }
64104
+ return filtered;
64105
+ };
64106
+ const filteredConfig = {
64107
+ ...config,
64108
+ hooks: filterHooks(config.hooks)
64109
+ };
64110
+ const override = config[overrideKey];
64111
+ if (Object.hasOwn(config, overrideKey) && isPlainObject(override)) filteredConfig[overrideKey] = {
64112
+ ...override,
64113
+ hooks: filterHooks(override.hooks)
64114
+ };
64115
+ if (skipped.length === 0) return rulesyncHooks;
64116
+ 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(", ")}`);
64117
+ return new RulesyncHooks({
64118
+ outputRoot: rulesyncHooks.getOutputRoot(),
64119
+ relativeDirPath: rulesyncHooks.getRelativeDirPath(),
64120
+ relativeFilePath: rulesyncHooks.getRelativeFilePath(),
64121
+ fileContent: JSON.stringify(filteredConfig),
64122
+ validate: false
64123
+ });
64124
+ }
64125
+ /**
63748
64126
  * Warns about canonical per-hook fields that this target's hooks format
63749
64127
  * cannot express, so a hook does not silently change behavior on generation.
63750
64128
  * Only canonical definitions are considered: a tool-native field inside an
@@ -63989,6 +64367,35 @@ var BobIgnore = class extends PlainToolIgnore {
63989
64367
  };
63990
64368
  }
63991
64369
  };
64370
+ // src/features/ignore/claude-read-deny-pattern.ts
64371
+ /**
64372
+ * Translate the path of a Claude Code style `Read(<path>)` deny into the
64373
+ * gitignore syntax of `.rulesync/.aiignore`.
64374
+ *
64375
+ * Claude Code reads the path forms differently from gitignore: `./path` is
64376
+ * relative to the current directory, `/path` to the project root, `//path` is
64377
+ * absolute and `~/path` is under the home directory. Copied verbatim, the
64378
+ * common `Read(./.env)` idiom would become `./.env`, which matches nothing in
64379
+ * a gitignore-syntax ignore file, so every other tool would lose the deny.
64380
+ *
64381
+ * - `./path` is anchored to the project root as `/path`.
64382
+ * - `//path` and `~/path` (or a bare `~`) name locations a project-relative
64383
+ * ignore file cannot express; they are skipped with a warning and `null` is
64384
+ * returned.
64385
+ * - Every other path (`.env`, `secrets/**`, `/path`) already means the same
64386
+ * project files in gitignore syntax and is kept as is.
64387
+ */
64388
+ function claudeReadDenyPathToIgnorePattern({ path, toolLabel, logger }) {
64389
+ if (path.startsWith("//") || path === "~" || path.startsWith("~/")) {
64390
+ 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.`);
64391
+ return null;
64392
+ }
64393
+ if (path.startsWith("./")) {
64394
+ const rest = path.slice(2).replace(/^(?:\.\/)+/, "");
64395
+ return rest.length > 0 ? `/${rest}` : null;
64396
+ }
64397
+ return path;
64398
+ }
63992
64399
  // src/features/ignore/claudecode-ignore.ts
63993
64400
  const DEFAULT_FILE_MODE = "shared";
63994
64401
  /**
@@ -64047,9 +64454,12 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
64047
64454
  }
64048
64455
  toRulesyncIgnore() {
64049
64456
  const fileContent = this.patterns.map((pattern) => {
64050
- if (isReadDenyEntry(pattern)) return pattern.slice(5, -1);
64457
+ if (isReadDenyEntry(pattern)) return claudeReadDenyPathToIgnorePattern({
64458
+ path: pattern.slice(5, -1),
64459
+ toolLabel: "Claude Code"
64460
+ });
64051
64461
  return pattern;
64052
- }).filter((pattern) => pattern.length > 0).join("\n");
64462
+ }).filter((pattern) => pattern !== null && pattern.length > 0).join("\n");
64053
64463
  return new RulesyncIgnore({
64054
64464
  outputRoot: this.outputRoot,
64055
64465
  relativeDirPath: RulesyncIgnore.getSettablePaths().recommended.relativeDirPath,
@@ -64324,7 +64734,7 @@ var FirebenderIgnore = class FirebenderIgnore extends ToolIgnore {
64324
64734
  const paths = this.getSettablePaths({ global });
64325
64735
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
64326
64736
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
64327
- const managedPatterns = [...new Set(patterns)];
64737
+ const managedPatterns = patterns.filter((pattern, index) => patterns.lastIndexOf(pattern) === index);
64328
64738
  return new FirebenderIgnore({
64329
64739
  outputRoot,
64330
64740
  relativeDirPath: paths.relativeDirPath,
@@ -64979,7 +65389,10 @@ const asClaudeStyleSettings = (document) => {
64979
65389
  };
64980
65390
  /** The pattern of a `read_file(...)` (or legacy `Read(...)`) deny, else null. */
64981
65391
  const readDenyPatternOf = (entry) => {
64982
- for (const toolName of [REASONIX_READ_TOOL_NAME, REASONIX_LEGACY_READ_TOOL_NAME]) if (isReadDenyEntry(entry, toolName)) return entry.slice(toolName.length + 1, -1);
65392
+ for (const toolName of [REASONIX_READ_TOOL_NAME, REASONIX_LEGACY_READ_TOOL_NAME]) if (isReadDenyEntry(entry, toolName)) return claudeReadDenyPathToIgnorePattern({
65393
+ path: entry.slice(toolName.length + 1, -1),
65394
+ toolLabel: "Reasonix"
65395
+ });
64983
65396
  return null;
64984
65397
  };
64985
65398
  /**
@@ -65319,6 +65732,9 @@ var IgnoreProcessor = class extends FeatureProcessor {
65319
65732
  getFactory;
65320
65733
  featureOptions;
65321
65734
  global;
65735
+ sweepsThroughInRootLinks() {
65736
+ return this.global;
65737
+ }
65322
65738
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$1, global = false, dryRun = false, logger, featureOptions }) {
65323
65739
  super({
65324
65740
  outputRoot,
@@ -65652,30 +66068,71 @@ function isEmptyValue(value) {
65652
66068
  if (Array.isArray(value)) return value.length === 0;
65653
66069
  return isPlainObject(value) && Object.keys(value).length === 0;
65654
66070
  }
66071
+ const MILLISECONDS_PER_SECOND$4 = 1e3;
66072
+ /**
66073
+ * Convert the canonical `timeout` (milliseconds) into Antigravity's
66074
+ * `timeoutSeconds` (integer seconds), rounding up so a sub-second timeout does
66075
+ * not become `0`. A negative value disables the timeout upstream, so it is
66076
+ * written as `-1`. An authored native `timeoutSeconds` wins over `timeout`.
66077
+ */
66078
+ function convertTimeoutToAntigravity({ serverName, config, logger }) {
66079
+ if (!("timeout" in config)) return config;
66080
+ const { timeout, ...rest } = config;
66081
+ if (rest.timeoutSeconds !== void 0) {
66082
+ logger?.warn(`Antigravity MCP: ${quoteValueForWarning(serverName)} sets both "timeout" and "timeoutSeconds"; keeping "timeoutSeconds" and dropping "timeout".`);
66083
+ return rest;
66084
+ }
66085
+ if (typeof timeout !== "number" || !Number.isFinite(timeout)) {
66086
+ logger?.warn(`Antigravity MCP: dropping the "timeout" of ${quoteValueForWarning(serverName)}: expected a number of milliseconds.`);
66087
+ return rest;
66088
+ }
66089
+ const timeoutSeconds = timeout < 0 ? -1 : Math.ceil(timeout / MILLISECONDS_PER_SECOND$4);
66090
+ return Object.fromEntries(Object.entries(config).map(([key, value]) => key === "timeout" ? ["timeoutSeconds", timeoutSeconds] : [key, value]));
66091
+ }
65655
66092
  /**
65656
- * Antigravity uses `serverUrl` (not the canonical `url`) for HTTP/SSE MCP
66093
+ * Antigravity uses `serverUrl` (not the canonical `url`) for remote MCP
65657
66094
  * servers. Convert the canonical shape into Antigravity's on the way out, and
65658
66095
  * back to canonical on the way in.
65659
66096
  *
65660
66097
  * Antigravity documents no `type` field: the transport follows from `command`
65661
- * (stdio) or `serverUrl` (Streamable HTTP / SSE), and `agy plugin validate`
66098
+ * (stdio) or `serverUrl` (Streamable HTTP), and `agy plugin validate`
65662
66099
  * reports the canonical `type` as an unknown field, so it is not written. A
65663
66100
  * remote server that carries `command`, `args` or `env` was also observed to be
65664
66101
  * refused at startup (runtime behavior reported by `agy plugin validate` 1.4.4,
65665
66102
  * not documented), so an empty `args` / `env` left on a `serverUrl` server is
65666
66103
  * dropped.
66104
+ *
66105
+ * Remote servers must use Streamable HTTP: the legacy HTTP+SSE transport is
66106
+ * not supported, so a canonical `sse` server is still written but warned
66107
+ * about. The canonical `timeout` (milliseconds) becomes `timeoutSeconds`.
65667
66108
  * https://antigravity.google/docs/mcp
65668
66109
  */
65669
- function toAntigravityMcpServers(servers) {
66110
+ function toAntigravityMcpServers(servers, logger) {
65670
66111
  const renamed = renameServerField(servers, "url", "serverUrl");
65671
66112
  return Object.fromEntries(Object.entries(renamed).map(([name, config]) => {
65672
66113
  if (!isPlainObject(config)) return [name, config];
66114
+ 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.`);
65673
66115
  const isRemote = typeof config.serverUrl === "string";
65674
- return [name, Object.fromEntries(Object.entries(config).filter(([key, value]) => key !== "type" && !(isRemote && (key === "args" || key === "env") && isEmptyValue(value))))];
66116
+ return [name, convertTimeoutToAntigravity({
66117
+ serverName: name,
66118
+ config: Object.fromEntries(Object.entries(config).filter(([key, value]) => key !== "type" && !(isRemote && (key === "args" || key === "env") && isEmptyValue(value)))),
66119
+ logger
66120
+ })];
65675
66121
  }));
65676
66122
  }
66123
+ /**
66124
+ * Convert Antigravity servers back to the canonical shape: `serverUrl` becomes
66125
+ * `url`, and `timeoutSeconds` becomes the canonical `timeout` in milliseconds
66126
+ * (a negative value, which disables the timeout upstream, stays `-1`).
66127
+ */
65677
66128
  function toCanonicalMcpServers(servers) {
65678
- return renameServerField(servers, "serverUrl", "url");
66129
+ const renamed = renameServerField(servers, "serverUrl", "url");
66130
+ return Object.fromEntries(Object.entries(renamed).map(([name, config]) => {
66131
+ if (!isPlainObject(config) || typeof config.timeoutSeconds !== "number") return [name, config];
66132
+ const timeoutSeconds = config.timeoutSeconds;
66133
+ const timeout = timeoutSeconds < 0 ? -1 : timeoutSeconds * MILLISECONDS_PER_SECOND$4;
66134
+ return [name, Object.fromEntries(Object.entries(config).filter(([key]) => key !== "timeout").map(([key, value]) => key === "timeoutSeconds" ? ["timeout", timeout] : [key, value]))];
66135
+ }));
65679
66136
  }
65680
66137
  /**
65681
66138
  * Parse `mcp_config.json` as JSONC and require a top-level object, so a file
@@ -65693,7 +66150,8 @@ function parseAntigravityMcpConfig(fileContent) {
65693
66150
  * the IDE and the CLI.
65694
66151
  *
65695
66152
  * Antigravity writes a dedicated `mcp_config.json` whose `mcpServers` map lives
65696
- * at the top level (`disabledTools` per server is supported). Unlike Gemini
66153
+ * at the top level (`enabledTools` / `disabledTools` per server are supported
66154
+ * under the same names). Unlike Gemini
65697
66155
  * CLI's shared `settings.json`, this file is dedicated to MCP, so it is safely
65698
66156
  * deletable. HTTP servers use `serverUrl` rather than the canonical `url`, so
65699
66157
  * the field is translated in both directions.
@@ -65746,11 +66204,11 @@ var AntigravityMcp = class extends ToolMcp {
65746
66204
  global
65747
66205
  });
65748
66206
  }
65749
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
66207
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
65750
66208
  const paths = this.getSettablePaths({ global });
65751
66209
  const newJson = {
65752
66210
  ...parseAntigravityMcpConfig(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({ mcpServers: {} }, null, 2)),
65753
- mcpServers: toAntigravityMcpServers(rulesyncMcp.getMcpServers())
66211
+ mcpServers: toAntigravityMcpServers(rulesyncMcp.getMcpServers(), logger)
65754
66212
  };
65755
66213
  return new this({
65756
66214
  outputRoot,
@@ -69419,7 +69877,11 @@ var CrushMcp = class CrushMcp extends ToolMcp {
69419
69877
  ownedPaths: [["mcp"]],
69420
69878
  logger
69421
69879
  });
69422
- const converted = convertToCrushFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => [serverName, {
69880
+ const converted = convertToCrushFormat(Object.fromEntries(Object.entries(skipServersWithEmptyEnabledTools({
69881
+ mcpServers: rulesyncMcp.getMcpServers(),
69882
+ toolName: "Crush",
69883
+ logger
69884
+ })).map(([serverName, serverConfig]) => [serverName, {
69423
69885
  ...serverConfig,
69424
69886
  ...readCrushOnlyKeys({
69425
69887
  serverName,
@@ -70379,31 +70841,6 @@ var DshMcp = class DshMcp extends ToolMcp {
70379
70841
  };
70380
70842
  // src/features/mcp/factorydroid-mcp.ts
70381
70843
  const TOOL_NAME = "factorydroid";
70382
- /**
70383
- * Drop servers whose `enabledTools` allowlist is empty.
70384
- *
70385
- * Canonically an empty allowlist allows no tools, but Droid applies
70386
- * `enabledTools` only "when the list is non-empty"
70387
- * (https://docs.factory.com/harness/mcp), so writing `[]` would load every
70388
- * tool the server reports. Skipping the server is the only form that does not
70389
- * hand the model more tools than the canonical config allows.
70390
- */
70391
- function skipEmptyAllowlistServers({ mcpServers, logger }) {
70392
- const kept = {};
70393
- for (const [name, server] of Object.entries(mcpServers)) {
70394
- if (server.enabledTools?.length === 0) {
70395
- warnAndSkipMcpServer({
70396
- toolName: TOOL_NAME,
70397
- serverName: name,
70398
- reason: "an empty enabledTools list, which allows no tools at all but which Factory Droid treats as no filter",
70399
- logger
70400
- });
70401
- continue;
70402
- }
70403
- kept[name] = server;
70404
- }
70405
- return kept;
70406
- }
70407
70844
  var FactorydroidMcp = class FactorydroidMcp extends ToolMcp {
70408
70845
  json;
70409
70846
  constructor(params) {
@@ -70430,8 +70867,10 @@ var FactorydroidMcp = class FactorydroidMcp extends ToolMcp {
70430
70867
  });
70431
70868
  }
70432
70869
  static fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, logger }) {
70433
- const factorydroidConfig = { mcpServers: skipEmptyAllowlistServers({
70870
+ const factorydroidConfig = { mcpServers: skipServersWithEmptyEnabledTools({
70434
70871
  mcpServers: rulesyncMcp.getMcpServers(),
70872
+ toolName: TOOL_NAME,
70873
+ productName: "Factory Droid",
70435
70874
  logger
70436
70875
  }) };
70437
70876
  const fileContent = JSON.stringify(factorydroidConfig, null, 2);
@@ -71862,19 +72301,42 @@ function normalizeKimiCodeTransport({ transport, hasCommand }) {
71862
72301
  return hasCommand ? "stdio" : "http";
71863
72302
  }
71864
72303
  /**
71865
- * The only `executor` value Kimi Code accepts on a stdio server. 2.2.0 removed
71866
- * the never-implemented `kaos` option and narrowed the schema to
71867
- * `z.enum(['local'])`, so any other value makes Kimi fail validation when it
71868
- * loads `mcp.json`.
72304
+ * The only `executor` value every current Kimi Code release accepts on a stdio
72305
+ * server. 2.2.0 removed the never-implemented `kaos` option and narrowed the
72306
+ * schema to `z.enum(['local'])`, so 2.2.0 through 2.4.x fail validation on any
72307
+ * other value when they load `mcp.json`. 2.5.0 widened the enum back to
72308
+ * `'local' | 'kaos'`, but `kaos` is still undocumented and would break the
72309
+ * older releases, so it keeps being dropped.
71869
72310
  * @see https://github.com/MoonshotAI/kimi-code/pull/4184
72311
+ * @see https://github.com/MoonshotAI/kimi-code/pull/4062
71870
72312
  */
71871
72313
  const KIMI_CODE_MCP_EXECUTOR = "local";
71872
72314
  /** Remove, with a warning, an `executor` value Kimi Code would reject. Mutates `server`. */
71873
72315
  function dropUnsupportedKimiCodeExecutor({ name, server, logger }) {
71874
72316
  if (server.executor === void 0 || server.executor === KIMI_CODE_MCP_EXECUTOR) return;
71875
- warnWithFallback(logger, `Kimi Code MCP: dropping executor ${JSON.stringify(server.executor)} from "${name}" because Kimi Code only accepts "${KIMI_CODE_MCP_EXECUTOR}".`);
72317
+ 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}".`);
71876
72318
  delete server.executor;
71877
72319
  }
72320
+ /**
72321
+ * Remove, with a warning, an `environment_id` Kimi Code cannot use. Kimi Code
72322
+ * 2.5.0 added it to stdio servers only, as a non-empty string naming an
72323
+ * `[environments.<id>]` entry of `config.toml`; an empty or non-string value
72324
+ * fails Kimi's validation. Mutates `server`.
72325
+ * @see https://github.com/MoonshotAI/kimi-code/pull/4062
72326
+ */
72327
+ function dropUnsupportedKimiCodeEnvironmentId({ name, server, isStdio, logger }) {
72328
+ const environmentId = server.environment_id;
72329
+ if (environmentId === void 0) return;
72330
+ if (!isStdio) {
72331
+ warnWithFallback(logger, `Kimi Code MCP: dropping environment_id from "${name}" because Kimi Code only accepts it on stdio servers.`);
72332
+ delete server.environment_id;
72333
+ return;
72334
+ }
72335
+ if (typeof environmentId !== "string" || environmentId.length === 0) {
72336
+ warnWithFallback(logger, `Kimi Code MCP: dropping environment_id ${JSON.stringify(environmentId)} from "${name}" because Kimi Code requires a non-empty string.`);
72337
+ delete server.environment_id;
72338
+ }
72339
+ }
71878
72340
  function toKimiCodeServer({ name, server, logger }) {
71879
72341
  const transport = server.transport ?? server.type;
71880
72342
  if (transport === "ws") {
@@ -71902,6 +72364,7 @@ function toKimiCodeServer({ name, server, logger }) {
71902
72364
  for (const field of [
71903
72365
  "env",
71904
72366
  "cwd",
72367
+ "environment_id",
71905
72368
  "executor",
71906
72369
  "runtime_id",
71907
72370
  "headers",
@@ -71918,6 +72381,12 @@ function toKimiCodeServer({ name, server, logger }) {
71918
72381
  server: converted,
71919
72382
  logger
71920
72383
  });
72384
+ dropUnsupportedKimiCodeEnvironmentId({
72385
+ name,
72386
+ server: converted,
72387
+ isStdio: normalizedTransport === "stdio",
72388
+ logger
72389
+ });
71921
72390
  if (server.disabled === true) converted.enabled = false;
71922
72391
  return converted;
71923
72392
  }
@@ -71942,6 +72411,15 @@ function fromKimiCodeServers({ servers, logger }) {
71942
72411
  logger
71943
72412
  });
71944
72413
  const type = transport === "stdio" || transport === "sse" || transport === "http" ? transport : void 0;
72414
+ dropUnsupportedKimiCodeEnvironmentId({
72415
+ name,
72416
+ server: rest,
72417
+ isStdio: normalizeKimiCodeTransport({
72418
+ transport,
72419
+ hasCommand: rest.command !== void 0
72420
+ }) === "stdio",
72421
+ logger
72422
+ });
71945
72423
  return [name, {
71946
72424
  ...rest,
71947
72425
  ...type && { type },
@@ -73110,7 +73588,12 @@ var OpenclawMcp = class OpenclawMcp extends ToolMcp {
73110
73588
  const existingMcp = isRecord(existing["mcp"]) ? existing["mcp"] : {};
73111
73589
  const existingServers = isRecord(existingMcp["servers"]) ? existingMcp[OPENCLAW_MCP_SERVERS_KEY] : {};
73112
73590
  const converted = convertToOpenclawFormat({
73113
- mcpServers: rulesyncMcp.getMcpServers(),
73591
+ mcpServers: skipServersWithEmptyEnabledTools({
73592
+ mcpServers: rulesyncMcp.getMcpServers(),
73593
+ toolName: "OpenClaw",
73594
+ ignoreBlankNames: true,
73595
+ logger
73596
+ }),
73114
73597
  logger
73115
73598
  });
73116
73599
  const servers = {
@@ -73738,7 +74221,11 @@ var PoolMcp = class PoolMcp extends ToolMcp {
73738
74221
  const paths = this.getSettablePaths({ global });
73739
74222
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
73740
74223
  const existingContent = await readFileContentOrNull(filePath) ?? "";
73741
- const converted = convertToPoolFormat(Object.fromEntries(Object.entries(rulesyncMcp.getMcpServers()).map(([serverName, serverConfig]) => {
74224
+ const converted = convertToPoolFormat(Object.fromEntries(Object.entries(skipServersWithEmptyEnabledTools({
74225
+ mcpServers: rulesyncMcp.getMcpServers(),
74226
+ toolName: "Pool",
74227
+ logger
74228
+ })).map(([serverName, serverConfig]) => {
73742
74229
  const rawServer = rulesyncMcp.getRawMcpServer(serverName);
73743
74230
  const poolAllow = isRecord(rawServer) ? rawServer.poolAllow : void 0;
73744
74231
  return [serverName, {
@@ -77264,7 +77751,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77264
77751
  meta: {
77265
77752
  supportsProject: true,
77266
77753
  supportsGlobal: true,
77267
- supportsEnabledTools: false,
77754
+ supportsEnabledTools: true,
77268
77755
  supportsDisabledTools: true
77269
77756
  }
77270
77757
  }],
@@ -77273,7 +77760,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77273
77760
  meta: {
77274
77761
  supportsProject: true,
77275
77762
  supportsGlobal: true,
77276
- supportsEnabledTools: false,
77763
+ supportsEnabledTools: true,
77277
77764
  supportsDisabledTools: true
77278
77765
  }
77279
77766
  }],
@@ -77282,7 +77769,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77282
77769
  meta: {
77283
77770
  supportsProject: true,
77284
77771
  supportsGlobal: false,
77285
- supportsEnabledTools: false,
77772
+ supportsEnabledTools: true,
77286
77773
  supportsDisabledTools: true
77287
77774
  }
77288
77775
  }],
@@ -77849,6 +78336,9 @@ function warnStrippedMcpServerFields({ mcpServers, fields, toolTarget, logger })
77849
78336
  var McpProcessor = class extends FeatureProcessor {
77850
78337
  toolTarget;
77851
78338
  global;
78339
+ sweepsThroughInRootLinks() {
78340
+ return this.global;
78341
+ }
77852
78342
  getFactory;
77853
78343
  featureOptions;
77854
78344
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory, dryRun = false, logger, featureOptions }) {
@@ -79958,12 +80448,30 @@ function toPathTarget(pattern) {
79958
80448
  "**",
79959
80449
  "**/*"
79960
80450
  ].includes(path)) return { target: "." };
79961
- const directory = path.replace(DIRECTORY_CONTENTS_SUFFIX, "") || (path.startsWith("/") ? "/" : "");
80451
+ const stripped = path.replace(DIRECTORY_CONTENTS_SUFFIX, "");
80452
+ const directory = stripped === "" && path.startsWith("/") ? "/" : /^[A-Za-z]:$/.test(stripped) && stripped !== path ? `${stripped}/` : stripped;
79962
80453
  if (directory !== path && directory.length > 0 && !hasGlob(directory)) return { target: directory };
79963
80454
  if (!hasGlob(path)) return { target: path };
79964
80455
  return { skipReason: "Antigravity file targets are paths, not globs. Only a plain path, `dir/**` or `*` can be expressed" };
79965
80456
  }
79966
80457
  /**
80458
+ * The canonical patterns a `read_file` / `write_file` target imports as. A
80459
+ * target can name a file or a directory, and a directory covers everything
80460
+ * inside it, but the target alone does not say which. A plain path such as
80461
+ * `src` imports as both `src` and `src/**`, so re-exporting it to a tool whose
80462
+ * plain path matches only that one path keeps covering the subtree; regenerating
80463
+ * collapses both back into one target. The bare `*`, and a target that already
80464
+ * holds glob characters (verbatim output of an older Rulesync), stay as written.
80465
+ */
80466
+ function fromPathTarget(target) {
80467
+ if (target === "*" || hasGlob(target) || /^[A-Za-z]:$/.test(target)) return [target];
80468
+ if (target === ".") return [".", "./**"];
80469
+ const root = /^((?:[A-Za-z]:)?\/)\/*$/.exec(target)?.[1];
80470
+ if (root !== void 0) return [root, `${root}**`];
80471
+ const path = target.replace(/\/+$/, "");
80472
+ return [path, `${path}/**`];
80473
+ }
80474
+ /**
79967
80475
  * A URL or domain pattern becomes its hostname. A leading `*.` is dropped,
79968
80476
  * since a domain already covers its subdomains, and the URL path is ignored.
79969
80477
  * Claude Code's `domain:` form is read as well.
@@ -80180,19 +80688,21 @@ function convertAntigravityToRulesyncPermissions({ allow, ask, deny, toCategory,
80180
80688
  for (const entry of entries) {
80181
80689
  const { toolName, pattern: target } = parseToolPatternEntry(entry);
80182
80690
  const canonical = toCategory(toolName);
80183
- const pattern = ANTIGRAVITY_COMMAND_ACTIONS.has(toolName) ? importAntigravityCommandTarget({
80691
+ const patterns = ANTIGRAVITY_COMMAND_ACTIONS.has(toolName) ? [importAntigravityCommandTarget({
80184
80692
  target,
80185
80693
  entry,
80186
80694
  decision: action,
80187
80695
  logger,
80188
80696
  toolLabel
80189
- }) : PATH_ACTIONS.has(toolName) ? collapseLeadingHome(target, homeDirectory) : target;
80190
- if (isPrototypePollutionKey(canonical) || isPrototypePollutionKey(pattern)) continue;
80191
- getOrCreateOwn({
80192
- record: permission,
80193
- key: canonical,
80194
- create: () => ({})
80195
- })[pattern] = action;
80697
+ })] : PATH_ACTIONS.has(toolName) ? fromPathTarget(collapseLeadingHome(target, homeDirectory)) : [target];
80698
+ for (const pattern of patterns) {
80699
+ if (isPrototypePollutionKey(canonical) || isPrototypePollutionKey(pattern)) continue;
80700
+ getOrCreateOwn({
80701
+ record: permission,
80702
+ key: canonical,
80703
+ create: () => ({})
80704
+ })[pattern] = action;
80705
+ }
80196
80706
  }
80197
80707
  };
80198
80708
  processEntries(allow, "allow");
@@ -80963,6 +81473,10 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
80963
81473
  * in global mode it is dropped with a warning instead of written somewhere it
80964
81474
  * does nothing.
80965
81475
  *
81476
+ * Only Auggie 0.x reads `recommendedMarketplaces`: the 1.x plugin settings
81477
+ * reference lists `enabledPlugins` alone. The key is still written because the
81478
+ * stable CLI is 0.x, and the write warning says it is 0.x-only.
81479
+ *
80966
81480
  * Both make Auggie load code the repository did not ship — a recommended
80967
81481
  * marketplace is one prompt away from being cloned, and an enabled plugin can
80968
81482
  * carry its own hooks and MCP servers — so writing either is logged, unless
@@ -80982,7 +81496,7 @@ function buildPluginConsumptionPatch({ override, existing, global, logger }) {
80982
81496
  else {
80983
81497
  patch.recommendedMarketplaces = recommendedMarketplaces;
80984
81498
  const existingMarketplaces = existingKeys.recommendedMarketplaces;
80985
- if (recommendedMarketplaces.length > 0 && !(0, node_util.isDeepStrictEqual)(recommendedMarketplaces, existingMarketplaces)) logger?.warn(`Writing AugmentCode \`recommendedMarketplaces\` (${recommendedMarketplaces.map(quoteValueForWarning).join(", ")}): Auggie prompts everyone who opens this workspace to install these plugin marketplaces.`);
81499
+ if (recommendedMarketplaces.length > 0 && !(0, node_util.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).`);
80986
81500
  warnAboutRemovedPluginIds({
80987
81501
  key: "recommendedMarketplaces",
80988
81502
  removed: (existingMarketplaces ?? []).filter((marketplace) => !recommendedMarketplaces.includes(marketplace)),
@@ -81211,12 +81725,12 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
81211
81725
  const regex = entry.shellInputRegex;
81212
81726
  if (isShellRegexRoundtrippable(regex)) pattern = shellRegexToGlob(regex);
81213
81727
  else {
81214
- if (action !== "deny" && !canApproximateShellRegexWithoutWidening(regex)) {
81215
- 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.`);
81728
+ if (action === "allow" && !canApproximateShellRegexWithoutWidening(regex)) {
81729
+ 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.`);
81216
81730
  continue;
81217
81731
  }
81218
- if (action === "deny") {
81219
- 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.`);
81732
+ if (action !== "allow") {
81733
+ 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.`);
81220
81734
  pattern = "*";
81221
81735
  } else {
81222
81736
  pattern = shellRegexToGlob(regex);
@@ -81302,6 +81816,62 @@ function classifyPattern(pattern, bareStarIsWildcard) {
81302
81816
  };
81303
81817
  return { kind: "literal" };
81304
81818
  }
81819
+ /** The trimmed spellings of the literal (prefix) deny entries, minus the bare wildcard. */
81820
+ function collectLiteralDenyStems({ rules, bareStarIsWildcard }) {
81821
+ const stems = /* @__PURE__ */ new Set();
81822
+ for (const [pattern, action] of Object.entries(rules)) {
81823
+ if (action !== "deny" || pattern === "*") continue;
81824
+ const stem = pattern.trimEnd();
81825
+ if (stem.length > 0 && classifyPattern(pattern, bareStarIsWildcard).kind === "literal") stems.add(stem);
81826
+ }
81827
+ return stems;
81828
+ }
81829
+ function isCoveredByLiteralDeny({ pattern, literalDenyStems }) {
81830
+ if (!pattern.endsWith(" *")) return false;
81831
+ const stem = pattern.slice(0, -2).trimEnd();
81832
+ return stem.length > 0 && literalDenyStems.has(stem);
81833
+ }
81834
+ /**
81835
+ * Lower a VS Code style command allow/deny list pair into canonical `bash`
81836
+ * rules on import.
81837
+ *
81838
+ * The native entries are compared as literal command prefixes, while canonical
81839
+ * patterns are globs, so a verbatim copy changes their meaning:
81840
+ *
81841
+ * - A prefix deny such as `curl` blocks `curl https://…` natively but would
81842
+ * only block the exact command `curl` canonically, so a broad allow beside
81843
+ * it would auto-approve what the native config blocked. Each deny that does
81844
+ * not already end in `*` is imported as itself plus `<deny> *`.
81845
+ * - An allow holding glob syntax (`npm *`) is literal prefix text natively and
81846
+ * approves almost nothing, but canonically it is a wildcard. Such an allow is
81847
+ * not imported (with a warning) rather than imported wider than it matched.
81848
+ * The Roo Code lineage's bare `*` is a real wildcard natively and is kept.
81849
+ *
81850
+ * A pattern in both lists imports as `deny`, matching both tools' precedence
81851
+ * for identical entries.
81852
+ */
81853
+ function importVscodeCommandLists({ allowed, denied, toolLabel, logger, prefixSemantics = "roo-lineage" }) {
81854
+ const isRooLineage = prefixSemantics === "roo-lineage";
81855
+ const rules = {};
81856
+ const skippedAllows = [];
81857
+ for (const pattern of allowed) {
81858
+ if (!(isRooLineage && pattern === "*") && /[*?]/.test(pattern)) {
81859
+ skippedAllows.push(pattern);
81860
+ continue;
81861
+ }
81862
+ rules[pattern] = "allow";
81863
+ }
81864
+ for (const pattern of denied) {
81865
+ rules[pattern] = "deny";
81866
+ const stem = pattern.trimEnd();
81867
+ if (!pattern.endsWith("*") && stem.length > 0) rules[`${stem} *`] = "deny";
81868
+ }
81869
+ if (skippedAllows.length > 0) {
81870
+ const count = skippedAllows.length;
81871
+ 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.` : ""));
81872
+ }
81873
+ return rules;
81874
+ }
81305
81875
  function pluralize(count, singular, plural) {
81306
81876
  return count === 1 ? singular : plural;
81307
81877
  }
@@ -81396,8 +81966,16 @@ function buildVscodeCommandLists({ rules, toolLabel, logger, prefixSemantics = "
81396
81966
  const matcherAllows = [];
81397
81967
  const widenedDenies = [];
81398
81968
  const inertDenies = [];
81969
+ const literalDenyStems = collectLiteralDenyStems({
81970
+ rules,
81971
+ bareStarIsWildcard: isRooLineage
81972
+ });
81399
81973
  for (const [pattern, action] of Object.entries(rules)) {
81400
81974
  if (action !== "allow" && action !== "deny") continue;
81975
+ if (action === "deny" && isCoveredByLiteralDeny({
81976
+ pattern,
81977
+ literalDenyStems
81978
+ })) continue;
81401
81979
  const shape = classifyPattern(pattern, isRooLineage);
81402
81980
  if (action === "allow") {
81403
81981
  allowed.push(pattern);
@@ -81760,16 +82338,20 @@ var BobPermissions = class BobPermissions extends ToolPermissions {
81760
82338
  validate: true
81761
82339
  });
81762
82340
  }
81763
- toRulesyncPermissions() {
82341
+ toRulesyncPermissions({ logger } = {}) {
81764
82342
  const settings = parseBobSettings({
81765
82343
  fileContent: this.getFileContent(),
81766
82344
  filePath: (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())
81767
82345
  });
81768
82346
  const approval = settings[APPROVAL_KEY$1];
81769
82347
  const entry = (isPlainObject(approval) && Array.isArray(approval.allowedExecutors) ? approval.allowedExecutors : []).find(isExecuteCommandEntry);
81770
- const rules = {};
81771
- for (const pattern of toStringArray$1(entry?.approvedCommands)) rules[pattern] = "allow";
81772
- for (const pattern of toStringArray$1(entry?.deniedCommands)) rules[pattern] = "deny";
82348
+ const rules = importVscodeCommandLists({
82349
+ allowed: toStringArray$1(entry?.approvedCommands),
82350
+ denied: toStringArray$1(entry?.deniedCommands),
82351
+ toolLabel: TOOL_LABEL,
82352
+ logger,
82353
+ prefixSemantics: "documented-prefix"
82354
+ });
81773
82355
  const permission = {};
81774
82356
  if (Object.keys(rules).length > 0) permission[SHELL_PERMISSION_CATEGORY] = rules;
81775
82357
  const bob = readBobOverride(settings);
@@ -85533,7 +86115,7 @@ function computeCodexcliOverridePatch({ existing, override, logger }) {
85533
86115
  const patch = {};
85534
86116
  const allowed = new Set(CODEXCLI_OVERRIDE_KEYS);
85535
86117
  for (const [key, value] of Object.entries(override ?? {})) {
85536
- if (key === "base_permission_profile" || key === "git_write_rules") continue;
86118
+ if (key === "base_permission_profile" || key === "git_write_rules" || key === "bash_canonical_matching") continue;
85537
86119
  if (!allowed.has(key)) {
85538
86120
  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.`);
85539
86121
  continue;
@@ -85822,6 +86404,7 @@ function stripTrailingSlash(pattern) {
85822
86404
  function buildCodexBashRulesContent({ config, logger }) {
85823
86405
  const bashRules = bashRulesHonoringAllTools(config.permission);
85824
86406
  const entries = Object.entries(bashRules);
86407
+ const canonicalMatching = config.codexcli?.bash_canonical_matching === true;
85825
86408
  const header = ["# Generated by Rulesync from .rulesync/permissions.jsonc (permission.bash)", "# https://developers.openai.com/codex/rules"];
85826
86409
  if (entries.length === 0) return [...header, "# No bash permission rules were configured."].join("\n");
85827
86410
  const ruleBlocks = entries.map(([pattern, action]) => {
@@ -85840,6 +86423,7 @@ function buildCodexBashRulesContent({ config, logger }) {
85840
86423
  ` pattern = [${serializedTokens}],`,
85841
86424
  ` decision = ${JSON.stringify(decision)},`,
85842
86425
  ` justification = ${JSON.stringify(`Generated from Rulesync permission.bash: ${pattern}`)},`,
86426
+ ...canonicalMatching && decision !== "allow" ? [" canonical_matching = True,"] : [],
85843
86427
  ")"
85844
86428
  ].join("\n");
85845
86429
  }).filter((block) => block !== null);
@@ -87424,9 +88008,126 @@ function toCanonicalToolName$5(crushName) {
87424
88008
  function isMcpToolName(crushName) {
87425
88009
  return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
87426
88010
  }
88011
+ const CRUSH_COMMAND_WRAPPERS = /* @__PURE__ */ new Set([
88012
+ "env",
88013
+ "nohup",
88014
+ "nice",
88015
+ "timeout"
88016
+ ]);
88017
+ const CRUSH_COMMAND_PREFIXES = [...CRUSH_COMMAND_WRAPPERS, "time"];
88018
+ const CRUSH_COMMAND_NAME = /^[a-z0-9_][a-z0-9_.+-]*$/;
88019
+ const CRUSH_COMMAND_ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/;
88020
+ const CRUSH_COMMAND_NAME_SUFFIXES = [
88021
+ ".exe",
88022
+ ".bat",
88023
+ ".cmd"
88024
+ ];
88025
+ function isCrushCommandName(name) {
88026
+ return CRUSH_COMMAND_NAME.test(name) && !CRUSH_COMMAND_NAME_SUFFIXES.some((suffix) => name.endsWith(suffix)) && !isPrototypePollutionKey(name);
88027
+ }
88028
+ /**
88029
+ * Reduce a command name the way Crush matches it (`shell.NormalizeCommandName`):
88030
+ * trimmed, without its directory, lower-cased, and without a Windows
88031
+ * executable suffix.
88032
+ */
88033
+ function normalizeCrushCommandName(raw) {
88034
+ const base = (raw.trim().split(/[\\/]/).at(-1) ?? "").toLowerCase();
88035
+ const suffix = CRUSH_COMMAND_NAME_SUFFIXES.find((candidate) => base.length > candidate.length && base.endsWith(candidate));
88036
+ return suffix === void 0 ? base : base.slice(0, -suffix.length);
88037
+ }
88038
+ /**
88039
+ * Whether a canonical pattern is a literal command line that a Crush
88040
+ * `safe_commands` entry reproduces exactly: Crush matches an entry only when
88041
+ * the command is written with exactly those tokens, no extra arguments and no
88042
+ * flags, which is what a wildcard-free glob means. A leading wrapper is
88043
+ * refused, since Crush would resolve it away and approve the bare command.
88044
+ */
88045
+ function isExactCrushCommandLine(pattern) {
88046
+ const [name, ...args] = pattern.split(" ");
88047
+ return name !== void 0 && isCrushCommandName(name) && !CRUSH_COMMAND_WRAPPERS.has(name) && args.every((arg) => CRUSH_COMMAND_ARGUMENT.test(arg));
88048
+ }
88049
+ /**
88050
+ * The command name a canonical `<name>` / `<name> *` pattern names as a
88051
+ * whole, i.e. the `blocked_commands` entry for it, or undefined.
88052
+ */
88053
+ function wholeCommandName(pattern) {
88054
+ const name = pattern.endsWith(" *") ? pattern.slice(0, -2) : pattern;
88055
+ return isCrushCommandName(name) ? name : void 0;
88056
+ }
88057
+ /**
88058
+ * The line a Crush `safe_commands` entry effectively stands for, in canonical
88059
+ * form (whitespace collapsed, command name normalized), or undefined when the
88060
+ * entry has no exact canonical spelling (wildcards, quoting, a wrapper, ...).
88061
+ */
88062
+ function effectiveSafeCommandLine(entry) {
88063
+ const [name, ...args] = entry.trim().split(/\s+/);
88064
+ if (name === void 0 || name === "") return void 0;
88065
+ const line = [normalizeCrushCommandName(name), ...args].join(" ");
88066
+ return isExactCrushCommandLine(line) ? line : void 0;
88067
+ }
88068
+ /**
88069
+ * A restriction pattern with its command word reduced the way Crush reduces a
88070
+ * wrapper's name before peeling it (`shell.NormalizeCommandName`): any
88071
+ * directory dropped (`./nice`, `bin\env.exe`, `/usr/bin/env`), lower-cased,
88072
+ * and without a Windows executable suffix. A `*` in the command word may
88073
+ * stand for a directory and part of a name, so when what follows it is the
88074
+ * tail of a wrapper name, everything before it collapses into `*`
88075
+ * (`b*nice` -> `*nice`, `/u*v` -> `*v`; `rm*` is left alone). The rest of the pattern is lower-cased as well, for a
88076
+ * nested wrapper spelled in another case. Used only to compare against the
88077
+ * wrapper spellings of a safe line, where it can only withhold more.
88078
+ */
88079
+ function normalizeRestrictionCommandWord(pattern) {
88080
+ const space = pattern.indexOf(" ");
88081
+ let head = space < 0 ? pattern : pattern.slice(0, space);
88082
+ const rest = space < 0 ? "" : pattern.slice(space);
88083
+ head = normalizeCrushCommandName(head);
88084
+ const star = head.lastIndexOf("*");
88085
+ const tail = head.slice(star + 1);
88086
+ if (star > 0 && tail !== "" && CRUSH_COMMAND_PREFIXES.some((prefix) => prefix.endsWith(tail))) head = `*${tail}`;
88087
+ return `${head}${rest.toLowerCase()}`;
88088
+ }
88089
+ /**
88090
+ * Build the test for a `safe_commands` line: which canonical `deny`/`ask`
88091
+ * rules govern a command Crush would approve through it? Besides the line
88092
+ * itself, Crush approves it followed by `--` and behind a wrapper (or the
88093
+ * `time` keyword), whose name it normalizes (`./nice`, `/usr/bin/env`, `NICE`,
88094
+ * `nohup.exe`). The line and its wrapper spellings are compared against the
88095
+ * restrictions as written, and the wrapper spellings also against the
88096
+ * restrictions with their command word normalized the same way — which keeps
88097
+ * an unrelated `rm *` from withholding the line, as a leading wildcard on the
88098
+ * spelling would. Canonically the stricter rule wins whatever its width
88099
+ * (`deny > ask > allow`), so any overlap withholds the line.
88100
+ *
88101
+ * Crush also approves a line composed with other safe lines (`&&`, `||`, `|`,
88102
+ * `;`, a parenthesized group); those compositions are not compared, the way
88103
+ * other tools' per-subcommand matching treats an allowed command.
88104
+ */
88105
+ function createSafeCommandShadowTest(permission) {
88106
+ const restrictions = collectShellCommandRules(permission).rules.filter(({ action }) => action !== "allow");
88107
+ const test = createShadowingRestrictionsTest(restrictions);
88108
+ const normalized = restrictions.map((rule) => ({
88109
+ ...rule,
88110
+ pattern: normalizeRestrictionCommandWord(rule.pattern)
88111
+ }));
88112
+ const testNormalized = createShadowingRestrictionsTest(normalized);
88113
+ const originalOf = /* @__PURE__ */ new Map();
88114
+ normalized.forEach((rule, index) => {
88115
+ const original = restrictions[index]?.pattern;
88116
+ if (original !== void 0 && !originalOf.has(rule.pattern)) originalOf.set(rule.pattern, original);
88117
+ });
88118
+ const withSeparator = (spellings) => spellings.flatMap((spelling) => [spelling, `${spelling} --*`]);
88119
+ const wrapped = (base) => CRUSH_COMMAND_PREFIXES.map((prefix) => `${prefix}*${base}`);
88120
+ return (line) => uniq$11([...withSeparator([line, ...wrapped(line)]).flatMap((spelling) => test(spelling)), ...withSeparator(wrapped(line.toLowerCase())).flatMap((spelling) => testNormalized(spelling).map((pattern) => originalOf.get(pattern) ?? pattern))]);
88121
+ }
87427
88122
  function uniq$11(values) {
87428
88123
  return [...new Set(values)];
87429
88124
  }
88125
+ function stringList(value) {
88126
+ return isStringArray(value) ? value : [];
88127
+ }
88128
+ function listOrUndefined(values) {
88129
+ return values.length > 0 ? values : void 0;
88130
+ }
87430
88131
  /**
87431
88132
  * Permissions adapter for Crush.
87432
88133
  *
@@ -87448,8 +88149,24 @@ function uniq$11(values) {
87448
88149
  * `webfetch` -> `fetch`, `mcp__<server>__<tool>` -> `mcp_<server>_<tool>`;
87449
88150
  * any other category passes through verbatim.
87450
88151
  *
87451
- * A pattern-specific rule cannot be expressed and is reported and skipped.
87452
- * Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
88152
+ * Crush v0.100.0+ also carries two command lists, which the `bash` category's
88153
+ * pattern rules map onto (see `convertBashCommandRules`):
88154
+ * - `permissions.safe_commands`: exact command lines that skip the prompt
88155
+ * (no extra arguments, no flags) <- an `allow` on a literal command line
88156
+ * that no canonical `deny`/`ask` overlaps.
88157
+ * - `permissions.blocked_commands`: command names added to the dangerous
88158
+ * list <- a `deny` on `<name>` or `<name> *`. Not a full deny: a direct
88159
+ * invocation is still prompted for (and auto-approved in yolo mode or
88160
+ * through `allowed_tools: ["bash"]`); only the forms Crush would not
88161
+ * prompt for (its read-only commands, `safe_commands`) and indirect
88162
+ * invocations are refused at exec time. An `ask` is not written there,
88163
+ * since it would turn the read-only forms into a refusal.
88164
+ * `permissions.allowed_commands` only drops names from the dangerous list (the
88165
+ * commands keep prompting), so it has no canonical counterpart and is left
88166
+ * untouched.
88167
+ *
88168
+ * Any other pattern-specific rule cannot be expressed and is reported and
88169
+ * skipped. Because Crush cannot narrow an allowed tool, a category's catch-all `allow`
87453
88170
  * is not written while the same category carries a pattern-specific `deny` or
87454
88171
  * `ask`: the tool keeps prompting (fail closed) rather than being widened to
87455
88172
  * everything. The all-tools `*` category is mirrored onto `bash` by
@@ -87513,7 +88230,12 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87513
88230
  const existingContent = location.fileContent ?? "";
87514
88231
  warnCrushTwinLeftovers({
87515
88232
  location,
87516
- ownedPaths: [[CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY], [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]],
88233
+ ownedPaths: [
88234
+ [CRUSH_PERMISSIONS_KEY, CRUSH_ALLOWED_TOOLS_KEY],
88235
+ [CRUSH_PERMISSIONS_KEY, CRUSH_SAFE_COMMANDS_KEY],
88236
+ [CRUSH_PERMISSIONS_KEY, CRUSH_BLOCKED_COMMANDS_KEY],
88237
+ [CRUSH_OPTIONS_KEY, CRUSH_DISABLED_TOOLS_KEY]
88238
+ ],
87517
88239
  logger
87518
88240
  });
87519
88241
  const existing = parseCrushConfig(existingContent, location.filePath);
@@ -87525,12 +88247,22 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87525
88247
  logger
87526
88248
  });
87527
88249
  const managedToolNames = managedCrushToolNames(config);
88250
+ const commands = rebuildCrushCommandLists({
88251
+ config,
88252
+ existingPermissions,
88253
+ logger
88254
+ });
87528
88255
  const preservedAllowed = (isStringArray(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
87529
88256
  const preservedDisabled = (isStringArray(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
87530
88257
  const allowedList = uniq$11([...preservedAllowed, ...generated.allowed]);
87531
88258
  const disabledList = uniq$11([...preservedDisabled, ...generated.disabled]);
88259
+ const permissionsHasContent = allowedList.length > 0 || commands.safe.length > 0 || commands.blocked.length > 0;
87532
88260
  const patch = {};
87533
- if (allowedList.length > 0 || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = { [CRUSH_ALLOWED_TOOLS_KEY]: allowedList.length > 0 ? allowedList : void 0 };
88261
+ if (permissionsHasContent || existingPermissions !== void 0) patch[CRUSH_PERMISSIONS_KEY] = {
88262
+ [CRUSH_ALLOWED_TOOLS_KEY]: listOrUndefined(allowedList),
88263
+ [CRUSH_SAFE_COMMANDS_KEY]: listOrUndefined(commands.safe),
88264
+ [CRUSH_BLOCKED_COMMANDS_KEY]: listOrUndefined(commands.blocked)
88265
+ };
87534
88266
  if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
87535
88267
  return new CrushPermissions({
87536
88268
  outputRoot,
@@ -87548,17 +88280,79 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87548
88280
  global
87549
88281
  });
87550
88282
  }
87551
- toRulesyncPermissions() {
88283
+ toRulesyncPermissions({ logger } = {}) {
87552
88284
  const permissions = isRecord(this.json["permissions"]) ? this.json[CRUSH_PERMISSIONS_KEY] : {};
87553
88285
  const options = toRecord(this.json[CRUSH_OPTIONS_KEY]);
87554
88286
  const rulesyncConfig = convertCrushListsToRulesync({
87555
- allowed: isStringArray(permissions["allowed_tools"]) ? permissions[CRUSH_ALLOWED_TOOLS_KEY] : [],
87556
- disabled: isStringArray(options["disabled_tools"]) ? options[CRUSH_DISABLED_TOOLS_KEY] : []
88287
+ allowed: stringList(permissions[CRUSH_ALLOWED_TOOLS_KEY]),
88288
+ disabled: stringList(options[CRUSH_DISABLED_TOOLS_KEY]),
88289
+ safe: stringList(permissions[CRUSH_SAFE_COMMANDS_KEY]),
88290
+ blocked: stringList(permissions[CRUSH_BLOCKED_COMMANDS_KEY]),
88291
+ logger
87557
88292
  });
87558
88293
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
87559
88294
  }
87560
88295
  };
87561
88296
  /**
88297
+ * The `safe_commands` / `blocked_commands` lists a generate writes: the
88298
+ * existing entries rulesync keeps, followed by the ones derived from the
88299
+ * canonical `bash` rules. Existing `blocked_commands` entries are all kept:
88300
+ * rulesync cannot tell its own from hand-written ones, and dropping one only
88301
+ * ever loosens. A derived blocked name that is the command of a written safe
88302
+ * line is withheld, since Crush would refuse that line at exec time.
88303
+ */
88304
+ function rebuildCrushCommandLists({ config, existingPermissions, logger }) {
88305
+ const bashRules = honorAllToolsOnBash(config.permission)["bash"] ?? {};
88306
+ const shadowTest = createSafeCommandShadowTest(config.permission);
88307
+ const generated = convertBashCommandRules({
88308
+ bashRules,
88309
+ shadowTest,
88310
+ logger
88311
+ });
88312
+ const safe = uniq$11([...preserveSafeCommands({
88313
+ existing: stringList(existingPermissions?.[CRUSH_SAFE_COMMANDS_KEY]),
88314
+ bashRules,
88315
+ shadowTest,
88316
+ logger
88317
+ }), ...generated.safe]);
88318
+ const safeCommandNames = new Set(safe.flatMap((entry) => {
88319
+ const name = entry.trim().split(/\s+/)[0];
88320
+ return name === void 0 || name === "" ? [] : [normalizeCrushCommandName(name)];
88321
+ }));
88322
+ const blocked = generated.blocked.filter((name) => {
88323
+ if (!safeCommandNames.has(name)) return true;
88324
+ 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.`);
88325
+ return false;
88326
+ });
88327
+ const existingBlocked = stringList(existingPermissions?.[CRUSH_BLOCKED_COMMANDS_KEY]);
88328
+ for (const entry of existingBlocked) {
88329
+ if (!safeCommandNames.has(normalizeCrushCommandName(entry))) continue;
88330
+ 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.`);
88331
+ }
88332
+ return {
88333
+ safe,
88334
+ blocked: uniq$11([...existingBlocked, ...blocked])
88335
+ };
88336
+ }
88337
+ /**
88338
+ * The existing `safe_commands` entries a generate keeps: those the canonical
88339
+ * `bash` rules do not spell out themselves (an entry they do spell is rebuilt
88340
+ * from them), minus any entry a canonical `deny`/`ask` governs, which would
88341
+ * otherwise auto-approve what the config restricts. An entry whose exact
88342
+ * meaning cannot be read is dropped whenever the config restricts anything.
88343
+ */
88344
+ function preserveSafeCommands({ existing, bashRules, shadowTest, logger }) {
88345
+ const managedLines = new Set(Object.keys(bashRules));
88346
+ const restricts = shadowTest("*").length > 0;
88347
+ return existing.filter((entry) => {
88348
+ if (managedLines.has(entry)) return false;
88349
+ const line = effectiveSafeCommandLine(entry);
88350
+ const shadowed = line === void 0 ? restricts : shadowTest(line).length > 0;
88351
+ 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.`);
88352
+ return !shadowed;
88353
+ });
88354
+ }
88355
+ /**
87562
88356
  * The Crush tool names the canonical config manages, i.e. the names its
87563
88357
  * categories map to. Entries naming any other tool are the user's and survive
87564
88358
  * a generate. The all-tools `*` category names no Crush tool of its own.
@@ -87583,7 +88377,7 @@ function resolveCategoryAction({ category, toolName, rules, logger }) {
87583
88377
  catchAllAction = action;
87584
88378
  continue;
87585
88379
  }
87586
- logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
88380
+ if (category !== "bash") logger?.warn(`Crush permissions are tool-wide (no argument patterns), so the "${action}" rule for "${category}" (pattern ${quoteValueForWarning(pattern)}) was skipped.`);
87587
88381
  if (action !== "allow") restricted = true;
87588
88382
  }
87589
88383
  if (catchAllAction === "allow" && restricted) {
@@ -87634,11 +88428,71 @@ function convertRulesyncToCrushLists({ config, logger }) {
87634
88428
  };
87635
88429
  }
87636
88430
  /**
88431
+ * Map the `bash` category's pattern rules onto Crush's command lists:
88432
+ * - `allow` on a literal command line -> `permissions.safe_commands`, which
88433
+ * Crush matches exactly (no extra arguments, no flags). It is withheld
88434
+ * when a canonical `deny`/`ask` governs any command Crush would approve
88435
+ * through it.
88436
+ * - `deny` on `<name>` / `<name> *` -> `permissions.blocked_commands`, the
88437
+ * strictest Crush can say about a single command: a direct invocation is
88438
+ * still prompted for, while the forms Crush would not prompt for and
88439
+ * indirect invocations are refused at exec time. Reported, since it is
88440
+ * not a full deny.
88441
+ * - Anything else is reported and skipped. A pattern `ask` is never written
88442
+ * to `blocked_commands`, which would refuse the command's read-only forms
88443
+ * outright, and an `allow` with a wildcard has no list that auto-approves
88444
+ * it.
88445
+ */
88446
+ function convertBashCommandRules({ bashRules, shadowTest, logger }) {
88447
+ const lists = {
88448
+ safe: [],
88449
+ blocked: []
88450
+ };
88451
+ for (const [rawPattern, action] of Object.entries(bashRules)) {
88452
+ if (isPrototypePollutionKey(rawPattern)) continue;
88453
+ const pattern = normalizeCatchAllPattern(rawPattern);
88454
+ if (pattern === "*") continue;
88455
+ const quoted = quoteValueForWarning(pattern);
88456
+ if (action === "allow") {
88457
+ if (!isExactCrushCommandLine(pattern)) {
88458
+ 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.`);
88459
+ continue;
88460
+ }
88461
+ const shadows = shadowTest(pattern);
88462
+ if (shadows.length > 0) {
88463
+ 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.`);
88464
+ continue;
88465
+ }
88466
+ lists.safe.push(pattern);
88467
+ continue;
88468
+ }
88469
+ if (action === "ask") {
88470
+ 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.`);
88471
+ continue;
88472
+ }
88473
+ const name = wholeCommandName(pattern);
88474
+ if (name !== void 0 && CRUSH_COMMAND_PREFIXES.includes(name)) {
88475
+ 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.`);
88476
+ continue;
88477
+ }
88478
+ if (name === void 0) {
88479
+ 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.`);
88480
+ continue;
88481
+ }
88482
+ 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).`);
88483
+ lists.blocked.push(name);
88484
+ }
88485
+ return {
88486
+ safe: uniq$11(lists.safe),
88487
+ blocked: uniq$11(lists.blocked)
88488
+ };
88489
+ }
88490
+ /**
87637
88491
  * Convert Crush's two tool lists back into a rulesync config. A `tool:action`
87638
88492
  * entry scopes an allow to one action of a tool, which the canonical model
87639
88493
  * cannot express, so it is left out rather than widened to the whole tool.
87640
88494
  */
87641
- function convertCrushListsToRulesync({ allowed, disabled }) {
88495
+ function convertCrushListsToRulesync({ allowed, disabled, safe, blocked, logger }) {
87642
88496
  const permission = {};
87643
88497
  const bucketFor = (category) => {
87644
88498
  const own = lookupOwn({
@@ -87654,6 +88508,29 @@ function convertCrushListsToRulesync({ allowed, disabled }) {
87654
88508
  if (entry === "" || isPrototypePollutionKey(entry)) continue;
87655
88509
  bucketFor(toCanonicalToolName$5(entry))["*"] = "deny";
87656
88510
  }
88511
+ const setBashRule = (pattern, action) => {
88512
+ const bucket = bucketFor(SHELL_PERMISSION_CATEGORY);
88513
+ if (!Object.hasOwn(bucket, pattern)) bucket[pattern] = action;
88514
+ };
88515
+ for (const entry of blocked) {
88516
+ const name = normalizeCrushCommandName(entry);
88517
+ if (name === "") continue;
88518
+ if (!isCrushCommandName(name)) {
88519
+ logger?.warn(`Crush permissions.blocked_commands entry ${quoteValueForWarning(entry)} is not a plain command name, so it was not imported.`);
88520
+ continue;
88521
+ }
88522
+ setBashRule(name, "deny");
88523
+ setBashRule(`${name} *`, "deny");
88524
+ }
88525
+ for (const entry of safe) {
88526
+ if (entry.trim() === "") continue;
88527
+ const line = effectiveSafeCommandLine(entry);
88528
+ if (line === void 0) {
88529
+ 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.`);
88530
+ continue;
88531
+ }
88532
+ setBashRule(line, "allow");
88533
+ }
87657
88534
  for (const entry of allowed) {
87658
88535
  if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
87659
88536
  bucketFor(toCanonicalToolName$5(entry))["*"] ??= "allow";
@@ -89033,8 +89910,8 @@ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
89033
89910
  * In global mode the config file is shared with the hooks (`hooks`) feature
89034
89911
  * (MCP moved to the dedicated mcp_config.json in v3000.3), so reads and writes
89035
89912
  * merge into the existing JSON and the file is never deleted; only the keys
89036
- * this feature manages are rewritten — `permissions`, plus `sandbox` in global
89037
- * mode and the plugin governance lists (`requiredPlugins` / `optionalPlugins` /
89913
+ * this feature manages are rewritten — `permissions`, plus `sandbox` and
89914
+ * `permission_mode` in global mode and the plugin governance lists (`requiredPlugins` / `optionalPlugins` /
89038
89915
  * `forbiddenPlugins`) in project mode when the `devin` override authors them.
89039
89916
  *
89040
89917
  * The sibling `sandbox` block — which decides what a permitted command may
@@ -89142,6 +90019,12 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
89142
90019
  });
89143
90020
  } 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.");
89144
90021
  }
90022
+ applyPermissionModeOverride({
90023
+ permissionMode: config.devin?.permission_mode,
90024
+ patch,
90025
+ global,
90026
+ logger
90027
+ });
89145
90028
  applyPluginGovernanceOverride({
89146
90029
  override: config.devin,
89147
90030
  settings,
@@ -89185,6 +90068,12 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
89185
90068
  if (DEVIN_PLUGIN_GOVERNANCE_SCHEMAS[key].safeParse(value).success) devinOverride[key] = value;
89186
90069
  else logger?.warn(`Skipped Devin \`${key}\` on import: ${key === "forbiddenPlugins" ? "expected an array of strings" : "expected an array of plugin sources (strings or objects)"}.`);
89187
90070
  }
90071
+ const permissionMode = importPermissionMode({
90072
+ value: settings.permission_mode,
90073
+ isGlobalFile: toPosixPath(this.getRelativeDirPath()) === toPosixPath(DevinPermissions.getSettablePaths({ global: true }).relativeDirPath),
90074
+ logger
90075
+ });
90076
+ if (permissionMode !== void 0) devinOverride.permission_mode = permissionMode;
89188
90077
  const result = { ...config };
89189
90078
  if (Object.keys(devinOverride).length > 0) result.devin = devinOverride;
89190
90079
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
@@ -89201,6 +90090,48 @@ const DEVIN_PLUGIN_GOVERNANCE_KEYS = [
89201
90090
  "forbiddenPlugins"
89202
90091
  ];
89203
90092
  /**
90093
+ * Decide what an imported `permission_mode` contributes to the `devin`
90094
+ * override. It is lifted from the user config only: unlike `sandbox`, a
90095
+ * project value Devin ignores is skipped with a warning rather than surfaced,
90096
+ * so it cannot slip into a `--global` generate later. `null` is Devin's
90097
+ * spelling of the default and carries nothing to keep; any other value the
90098
+ * override schema would reject is dropped with a warning so the next generate
90099
+ * cannot fail. `dangerous` is lifted but named, as on generate.
90100
+ */
90101
+ function importPermissionMode({ value, isGlobalFile, logger }) {
90102
+ if (value === void 0 || value === null) return void 0;
90103
+ if (!isGlobalFile) {
90104
+ 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.");
90105
+ return;
90106
+ }
90107
+ const parsed = DevinPermissionModeSchema.safeParse(value);
90108
+ if (!parsed.success) {
90109
+ logger?.warn(`Skipped Devin \`permission_mode\` ${quoteValueForWarning(value)} on import: expected one of ${DevinPermissionModeSchema.options.join(", ")}.`);
90110
+ return;
90111
+ }
90112
+ 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.");
90113
+ return parsed.data;
90114
+ }
90115
+ /**
90116
+ * Patch the `permission_mode` the `devin` override authors into the user
90117
+ * `config.json`. It sets the mode new local sessions start in, and Devin reads
90118
+ * it from the user config only, so in project mode it is dropped with a
90119
+ * warning. It is only ever written as authored — never derived from the
90120
+ * canonical rules — and `dangerous` is named in a warning, because every new
90121
+ * session then starts with all permission prompts bypassed.
90122
+ *
90123
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
90124
+ */
90125
+ function applyPermissionModeOverride({ permissionMode, patch, global, logger }) {
90126
+ if (permissionMode === void 0) return;
90127
+ if (!global) {
90128
+ 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.");
90129
+ return;
90130
+ }
90131
+ patch.permission_mode = permissionMode;
90132
+ 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.");
90133
+ }
90134
+ /**
89204
90135
  * Patch the plugin governance lists the `devin` override authors into the
89205
90136
  * project `.devin/config.json`. Each authored list replaces the file's list
89206
90137
  * whole; a list it does not author is left as the file holds it. Devin reads these
@@ -95190,7 +96121,8 @@ function convertQwenToRulesyncPermissions(params) {
95190
96121
  *
95191
96122
  * Reasonix matches a rule's tool exactly against its registered tools, which
95192
96123
  * are snake_case (`read_file`, `write_file`, `grep`, `glob`, `web_fetch`,
95193
- * `web_search`, `notebook_edit`, `task`). Only `Bash`, `Edit` (every file
96124
+ * `web_search`, `notebook_edit`, and `task` up to v2.57.x / `spawn_agents` from
96125
+ * v2.58.0). Only `Bash`, `Edit` (every file
95194
96126
  * writer: `write_file`, `edit_file`, `multi_edit`, `notebook_edit`, ...),
95195
96127
  * `Browser` and `Computer` are Claude-Code-style family aliases
95196
96128
  * (`canonicalRuleTool` / `groupOf`), so a `Read(...)` or `Write(...)` rule
@@ -95213,6 +96145,21 @@ const CANONICAL_TO_REASONIX_TOOL_NAMES = {
95213
96145
  agent: "task"
95214
96146
  };
95215
96147
  /**
96148
+ * Reasonix tool names a canonical category writes beside its primary name, for
96149
+ * a tool Reasonix renamed. studio-v2.58.0 replaced the `task` delegation family
96150
+ * with `spawn_agents` (commit ad2c96347), while the 1.x line and v2 up to
96151
+ * v2.57.x still register `task`. When Reasonix loads the config file it checks
96152
+ * only a rule's syntax: a rule naming a tool the running build lacks matches
96153
+ * nothing (v2.34.0+ reports it as dormant) and is not rejected. (The v2 Web UI
96154
+ * permissions editor refuses to newly add such a rule with `ErrUnknownTool`,
96155
+ * but keeps the ones already in the file.) So writing both names keeps an
96156
+ * `agent` rule in force on old and new builds alike. Both names are read back
96157
+ * to the same category.
96158
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/studio-v2.58.0/internal/runtime/delegation/spawn.go
96159
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/studio-v2.57.0/internal/runtime/delegation/task.go
96160
+ */
96161
+ const CANONICAL_TO_EXTRA_REASONIX_TOOL_NAMES = { agent: ["spawn_agents"] };
96162
+ /**
95216
96163
  * The Claude-Code-style spellings rulesync emitted before it followed the
95217
96164
  * Reasonix tool vocabulary. None of them reaches a Reasonix tool, so they are
95218
96165
  * read back on import (a file generated by an older rulesync still imports)
@@ -95235,7 +96182,8 @@ const CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES = {
95235
96182
  */
95236
96183
  const REASONIX_TO_CANONICAL_TOOL_NAMES = {
95237
96184
  ...invertRecord(CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES),
95238
- ...invertRecord(CANONICAL_TO_REASONIX_TOOL_NAMES)
96185
+ ...invertRecord(CANONICAL_TO_REASONIX_TOOL_NAMES),
96186
+ ...Object.fromEntries(Object.entries(CANONICAL_TO_EXTRA_REASONIX_TOOL_NAMES).flatMap(([canonical, names]) => names.map((name) => [name, canonical])))
95239
96187
  };
95240
96188
  /** Every Reasonix tool name a canonical category owns on generate. */
95241
96189
  function reasonixToolNamesOf(canonical) {
@@ -95243,14 +96191,20 @@ function reasonixToolNamesOf(canonical) {
95243
96191
  record: CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES,
95244
96192
  key: canonical
95245
96193
  });
95246
- const current = toReasonixToolName(canonical);
95247
- return legacy === void 0 ? [current] : [current, legacy];
96194
+ const current = toReasonixToolNames(canonical);
96195
+ return legacy === void 0 ? current : [...current, legacy];
95248
96196
  }
95249
- function toReasonixToolName(canonical) {
95250
- return lookupOwn({
96197
+ /** The Reasonix tool names a canonical category's rules are written under. */
96198
+ function toReasonixToolNames(canonical) {
96199
+ const primary = lookupOwn({
95251
96200
  record: CANONICAL_TO_REASONIX_TOOL_NAMES,
95252
96201
  key: canonical
95253
96202
  }) ?? canonical;
96203
+ const extra = lookupOwn({
96204
+ record: CANONICAL_TO_EXTRA_REASONIX_TOOL_NAMES,
96205
+ key: canonical
96206
+ });
96207
+ return extra === void 0 ? [primary] : [primary, ...extra];
95254
96208
  }
95255
96209
  function toCanonicalToolName$2(reasonixName) {
95256
96210
  return lookupOwn({
@@ -95658,6 +96612,17 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
95658
96612
  });
95659
96613
  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.`);
95660
96614
  }
96615
+ warnOnLoosenedAgentEntries({
96616
+ existingPermissions,
96617
+ managedToolNames,
96618
+ generated: {
96619
+ allow: [...allow, ...rawAllow],
96620
+ ask: [...ask, ...rawAsk],
96621
+ deny: [...deny, ...rawDeny]
96622
+ },
96623
+ relativeFilePath: relativeFilePathForLog,
96624
+ logger
96625
+ });
95661
96626
  const mergedPermissions = { ...existingPermissions };
95662
96627
  setOrDeleteEntries(mergedPermissions, "allow", [
95663
96628
  ...preservedAllow,
@@ -95833,6 +96798,55 @@ function preserveExistingEntries({ existingPermissions, managedToolNames, rawOwn
95833
96798
  deny
95834
96799
  };
95835
96800
  }
96801
+ /**
96802
+ * `spawn_agents` became a managed name with Reasonix v2.58.0 support, so a
96803
+ * stricter rule the user wrote for it by hand (e.g. a v2.58-era
96804
+ * `deny = ["spawn_agents"]` next to an older `agent: allow` source) would be
96805
+ * silently replaced by a looser one. Name every such loosening.
96806
+ */
96807
+ function warnOnLoosenedAgentEntries({ existingPermissions, managedToolNames, generated, relativeFilePath, logger }) {
96808
+ if (!logger || !managedToolNames.has("spawn_agents")) return;
96809
+ const loosened = findLoosenedEntries({
96810
+ existingPermissions,
96811
+ toolNames: new Set(reasonixToolNamesOf("agent")),
96812
+ generated
96813
+ });
96814
+ if (loosened.length === 0) return;
96815
+ 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'.`);
96816
+ }
96817
+ const PERMISSION_STRICTNESS = {
96818
+ allow: 0,
96819
+ ask: 1,
96820
+ deny: 2
96821
+ };
96822
+ /**
96823
+ * Existing entries for `toolNames` whose action is stricter than the one the
96824
+ * generated lists give the same entry, formatted `<list> "<entry>"`. An entry
96825
+ * the generated lists drop counts as no stricter than `allow`.
96826
+ */
96827
+ function findLoosenedEntries({ existingPermissions, toolNames, generated }) {
96828
+ const generatedStrictness = /* @__PURE__ */ new Map();
96829
+ for (const list of [
96830
+ "allow",
96831
+ "ask",
96832
+ "deny"
96833
+ ]) for (const entry of generated[list]) {
96834
+ const key = normalizeToolPatternEntry(entry);
96835
+ generatedStrictness.set(key, Math.max(generatedStrictness.get(key) ?? 0, PERMISSION_STRICTNESS[list]));
96836
+ }
96837
+ const loosened = [];
96838
+ for (const list of ["ask", "deny"]) for (const entry of toStringArray$1(existingPermissions[list])) {
96839
+ if (isReasonixExactCommandEntry(entry)) continue;
96840
+ if (!toolNames.has(parseToolPatternEntry(entry).toolName)) continue;
96841
+ const key = normalizeToolPatternEntry(entry);
96842
+ if ((generatedStrictness.get(key) ?? 0) < PERMISSION_STRICTNESS[list]) loosened.push(`${list} "${entry}"`);
96843
+ }
96844
+ return loosened;
96845
+ }
96846
+ /** Spell an entry the way rulesync writes it, so `name(*)` equals `name`. */
96847
+ function normalizeToolPatternEntry(entry) {
96848
+ return buildToolPatternEntry(parseToolPatternEntry(entry));
96849
+ }
95836
96850
  /** Sort, dedupe and set a permissions array key, deleting it when empty. */
95837
96851
  function setOrDeleteEntries(table, key, entries) {
95838
96852
  const merged = (0, es_toolkit.uniq)(entries.toSorted());
@@ -95846,22 +96860,19 @@ function convertRulesyncToReasonixPermissions(config) {
95846
96860
  const allow = [];
95847
96861
  const ask = [];
95848
96862
  const deny = [];
95849
- for (const [category, rules] of Object.entries(config.permission)) {
95850
- const reasonixToolName = toReasonixToolName(category);
95851
- for (const [pattern, action] of Object.entries(rules)) {
95852
- const entry = buildToolPatternEntry({
95853
- toolName: reasonixToolName,
95854
- pattern
95855
- });
95856
- switch (action) {
95857
- case "allow":
95858
- allow.push(entry);
95859
- break;
95860
- case "ask":
95861
- ask.push(entry);
95862
- break;
95863
- case "deny": deny.push(entry);
95864
- }
96863
+ for (const [category, rules] of Object.entries(config.permission)) for (const reasonixToolName of toReasonixToolNames(category)) for (const [pattern, action] of Object.entries(rules)) {
96864
+ const entry = buildToolPatternEntry({
96865
+ toolName: reasonixToolName,
96866
+ pattern
96867
+ });
96868
+ switch (action) {
96869
+ case "allow":
96870
+ allow.push(entry);
96871
+ break;
96872
+ case "ask":
96873
+ ask.push(entry);
96874
+ break;
96875
+ case "deny": deny.push(entry);
95865
96876
  }
95866
96877
  }
95867
96878
  return {
@@ -96088,7 +97099,7 @@ var RooPermissions = class extends ToolPermissions {
96088
97099
  validate: true
96089
97100
  });
96090
97101
  }
96091
- toRulesyncPermissions() {
97102
+ toRulesyncPermissions({ logger } = {}) {
96092
97103
  const constructor = this.constructor;
96093
97104
  let settings;
96094
97105
  try {
@@ -96102,9 +97113,12 @@ var RooPermissions = class extends ToolPermissions {
96102
97113
  } catch (error) {
96103
97114
  throw new Error(`Failed to parse ${constructor.getToolLabel()} VS Code settings in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
96104
97115
  }
96105
- const rules = {};
96106
- for (const pattern of toStringArray$1(settings[constructor.getAllowedCommandsKey()])) rules[pattern] = "allow";
96107
- for (const pattern of toStringArray$1(settings[constructor.getDeniedCommandsKey()])) rules[pattern] = "deny";
97116
+ const rules = importVscodeCommandLists({
97117
+ allowed: toStringArray$1(settings[constructor.getAllowedCommandsKey()]),
97118
+ denied: toStringArray$1(settings[constructor.getDeniedCommandsKey()]),
97119
+ toolLabel: constructor.getToolLabel(),
97120
+ logger
97121
+ });
96108
97122
  const permission = {};
96109
97123
  if (Object.keys(rules).length > 0) permission[COMMAND_CATEGORY] = rules;
96110
97124
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
@@ -101428,6 +102442,9 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
101428
102442
  var PermissionsProcessor = class extends FeatureProcessor {
101429
102443
  toolTarget;
101430
102444
  global;
102445
+ sweepsThroughInRootLinks() {
102446
+ return this.global;
102447
+ }
101431
102448
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
101432
102449
  super({
101433
102450
  outputRoot,
@@ -104671,6 +105688,12 @@ Object.defineProperty(exports, "assertDirectoryIfExists", {
104671
105688
  return assertDirectoryIfExists;
104672
105689
  }
104673
105690
  });
105691
+ Object.defineProperty(exports, "assertLandingOutsideVcsMetadata", {
105692
+ enumerable: true,
105693
+ get: function() {
105694
+ return assertLandingOutsideVcsMetadata;
105695
+ }
105696
+ });
104674
105697
  Object.defineProperty(exports, "assertNoSymlinkBelowRoot", {
104675
105698
  enumerable: true,
104676
105699
  get: function() {
@@ -104713,6 +105736,12 @@ Object.defineProperty(exports, "checkPathTraversal", {
104713
105736
  return checkPathTraversal;
104714
105737
  }
104715
105738
  });
105739
+ Object.defineProperty(exports, "classifyRemoteName", {
105740
+ enumerable: true,
105741
+ get: function() {
105742
+ return classifyRemoteName;
105743
+ }
105744
+ });
104716
105745
  Object.defineProperty(exports, "convertFromTool", {
104717
105746
  enumerable: true,
104718
105747
  get: function() {
@@ -104845,16 +105874,16 @@ Object.defineProperty(exports, "groupSpellingsByCaseFoldedIdentity", {
104845
105874
  return groupSpellingsByCaseFoldedIdentity;
104846
105875
  }
104847
105876
  });
104848
- Object.defineProperty(exports, "hasDeceptiveHiddenCharacters", {
105877
+ Object.defineProperty(exports, "hasEnclosingMarkOutsideKeycap", {
104849
105878
  enumerable: true,
104850
105879
  get: function() {
104851
- return hasDeceptiveHiddenCharacters;
105880
+ return hasEnclosingMarkOutsideKeycap;
104852
105881
  }
104853
105882
  });
104854
- Object.defineProperty(exports, "hasEnclosingMarkOutsideKeycap", {
105883
+ Object.defineProperty(exports, "hasWindowsFoldableName", {
104855
105884
  enumerable: true,
104856
105885
  get: function() {
104857
- return hasEnclosingMarkOutsideKeycap;
105886
+ return hasWindowsFoldableName;
104858
105887
  }
104859
105888
  });
104860
105889
  Object.defineProperty(exports, "importFromTool", {
@@ -105103,6 +106132,12 @@ Object.defineProperty(exports, "stripHiddenCharacters", {
105103
106132
  return stripHiddenCharacters;
105104
106133
  }
105105
106134
  });
106135
+ Object.defineProperty(exports, "sweepRefusalReason", {
106136
+ enumerable: true,
106137
+ get: function() {
106138
+ return sweepRefusalReason;
106139
+ }
106140
+ });
105106
106141
  Object.defineProperty(exports, "toInstructionBudgetFile", {
105107
106142
  enumerable: true,
105108
106143
  get: function() {