rulesync 43.6.3 → 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";
@@ -29122,11 +29354,7 @@ var ZoocodeSkill = class extends RooSkill {
29122
29354
  static toolTarget = "zoocode";
29123
29355
  };
29124
29356
  // src/features/skills/skills-processor.ts
29125
- var skills_processor_exports = /* @__PURE__ */ __exportAll({
29126
- SkillsProcessor: () => SkillsProcessor,
29127
- SkillsProcessorToolTargetSchema: () => SkillsProcessorToolTargetSchema,
29128
- toolSkillFactories: () => toolSkillFactories
29129
- });
29357
+ var skills_processor_exports = /* @__PURE__ */ __exportAll({ toolSkillFactories: () => toolSkillFactories });
29130
29358
  const SkillsProcessorToolTargetSchema = zod_mini.z.enum(skillsProcessorToolTargetTuple);
29131
29359
  /**
29132
29360
  * Factory Map mapping tool targets to their skill factories.
@@ -29974,6 +30202,9 @@ var SkillsProcessor = class extends DirFeatureProcessor {
29974
30202
  global: this.global
29975
30203
  });
29976
30204
  }
30205
+ sweepsThroughInRootLinks() {
30206
+ return this.global;
30207
+ }
29977
30208
  /**
29978
30209
  * The tool's skills roots that exist on disk, each paired with its absolute
29979
30210
  * path and vetted as writable inside this run's output root. Shared by the
@@ -30003,6 +30234,15 @@ var SkillsProcessor = class extends DirFeatureProcessor {
30003
30234
  this.logger.warn(`Skipping the orphan sweep for ${quoteForLog(skillsDirPath)}; nothing under it is deleted: ${stripControlCharacters(formatError(error))}`);
30004
30235
  continue;
30005
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
+ }
30006
30246
  existingRoots.push({
30007
30247
  root,
30008
30248
  skillsDirPath
@@ -33229,11 +33469,17 @@ var FirebenderSubagent = class FirebenderSubagent extends ToolSubagent {
33229
33469
  */
33230
33470
  static async fromRulesyncSubagents({ outputRoot = process.cwd(), rulesyncSubagents, global = false, logger, deleteOrphans = false }) {
33231
33471
  const agentsDirPath = this.getSettablePaths({ global }).relativeDirPath;
33232
- const agentsDirEscapesRoot = await writablePathEscapesRoot({
33472
+ const agentsDirFullPath = (0, node_path.join)(outputRoot, agentsDirPath);
33473
+ const writeRefused = await writeRefusalReason({
33233
33474
  rootPath: outputRoot,
33234
- targetPath: (0, node_path.join)(outputRoot, agentsDirPath, "agent.md")
33235
- });
33236
- 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));
33237
33483
  const subagents = rulesyncSubagents.map((rulesyncSubagent) => this.fromRulesyncSubagent({
33238
33484
  outputRoot,
33239
33485
  relativeDirPath: this.getSettablePaths({ global }).relativeDirPath,
@@ -33249,8 +33495,8 @@ var FirebenderSubagent = class FirebenderSubagent extends ToolSubagent {
33249
33495
  global
33250
33496
  })),
33251
33497
  existingAgentFileNames,
33252
- deleteOrphans,
33253
- frozen: agentsDirEscapesRoot,
33498
+ deleteOrphans: deleteOrphans && !sweepRefused,
33499
+ frozen: writeRefused,
33254
33500
  global,
33255
33501
  logger
33256
33502
  });
@@ -37368,6 +37614,9 @@ function compareRelativePathsShallowestFirst(left, right) {
37368
37614
  var SubagentsProcessor = class extends FeatureProcessor {
37369
37615
  toolTarget;
37370
37616
  global;
37617
+ sweepsThroughInRootLinks() {
37618
+ return this.global;
37619
+ }
37371
37620
  deleteOrphans;
37372
37621
  getFactory;
37373
37622
  /**
@@ -38089,6 +38338,39 @@ function warnAndSkipMcpServer({ toolName, serverName, reason, logger }) {
38089
38338
  return null;
38090
38339
  }
38091
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
+ /**
38092
38374
  * The `tools` map Kilo and OpenCode keep beside `mcp` is keyed `<server>_<tool>`
38093
38375
  * and reaches servers `mcp` does not list — ones the global config or a
38094
38376
  * marketplace defines — so entries naming no listed
@@ -38650,7 +38932,11 @@ var KiloMcp = class KiloMcp extends ToolMcp {
38650
38932
  const existingMcp = readExistingKiloMcpEntries(fileContent);
38651
38933
  const parsedExisting = (0, jsonc_parser.parse)(fileContent || "{}");
38652
38934
  const existingDocument = isRecord(parsedExisting) ? parsedExisting : {};
38653
- const mcpServers = rulesyncMcp.getMcpServers();
38935
+ const mcpServers = skipServersWithEmptyEnabledTools({
38936
+ mcpServers: rulesyncMcp.getMcpServers(),
38937
+ toolName: "Kilo",
38938
+ logger
38939
+ });
38654
38940
  const { mcp: convertedMcp, toolPermissions } = convertToKiloFormat(resolveKiloEnvVarRefs({
38655
38941
  mcpServers,
38656
38942
  global,
@@ -38779,7 +39065,8 @@ const OPENCODE_PASSTHROUGH_SERVER_FIELDS = [
38779
39065
  "timeout",
38780
39066
  "oauth",
38781
39067
  "protocol",
38782
- "codemode"
39068
+ "codemode",
39069
+ "pool"
38783
39070
  ];
38784
39071
  const OPENCODE_MCP_TRANSPORT_KEYS = [
38785
39072
  ...Object.keys(OpencodeMcpLocalServerSchema.def.shape),
@@ -38942,7 +39229,7 @@ function warnAboutToggleDroppedKeys(serverName, serverConfig, logger) {
38942
39229
  * - env -> environment
38943
39230
  * - disabled -> enabled (inverted)
38944
39231
  * - enabledTools/disabledTools -> top-level tools map (with server name prefix)
38945
- * - OpenCode-supported extras (timeout, oauth, protocol, codemode) -> passed through verbatim
39232
+ * - OpenCode-supported extras (timeout, oauth, protocol, codemode, pool) -> passed through verbatim
38946
39233
  */
38947
39234
  function convertServerToOpencodeFormat(serverName, serverConfig, existingEntry, configShape, logger) {
38948
39235
  const serverRecord = serverConfig;
@@ -39182,7 +39469,11 @@ var OpencodeMcp = class extends ToolMcp {
39182
39469
  if (fileContent) relativeFilePath = this.layout.jsonFileName;
39183
39470
  }
39184
39471
  const transformedServers = convertEnvVarRefsToToolFormat({
39185
- mcpServers: rulesyncMcp.getMcpServers(),
39472
+ mcpServers: skipServersWithEmptyEnabledTools({
39473
+ mcpServers: rulesyncMcp.getMcpServers(),
39474
+ toolName: "OpenCode",
39475
+ logger
39476
+ }),
39186
39477
  replacement: "{env:$1}"
39187
39478
  });
39188
39479
  const existingJson = (0, jsonc_parser.parse)(fileContent || "{}");
@@ -48778,6 +49069,9 @@ var RulesProcessor = class extends FeatureProcessor {
48778
49069
  language;
48779
49070
  deriveSubprojectPathFromGlobs;
48780
49071
  global;
49072
+ sweepsThroughInRootLinks() {
49073
+ return this.global;
49074
+ }
48781
49075
  getFactory;
48782
49076
  skills;
48783
49077
  featureOptions;
@@ -51362,12 +51656,29 @@ var HermesagentCheck = class HermesagentCheck extends ToolCheck {
51362
51656
  };
51363
51657
  }
51364
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
+ }
51365
51676
  toRulesyncCheck() {
51366
51677
  const spec = HermesCheckSpecSchema.parse(JSON.parse(this.fileContent));
51367
51678
  return new RulesyncCheck({
51368
51679
  outputRoot: ".",
51369
51680
  relativeDirPath: RULESYNC_CHECKS_RELATIVE_DIR_PATH,
51370
- relativeFilePath: `${spec.slug}.md`,
51681
+ relativeFilePath: `${this.importedCheckStem(spec.slug)}.md`,
51371
51682
  frontmatter: {
51372
51683
  targets: ["*"],
51373
51684
  description: spec.description,
@@ -51875,6 +52186,9 @@ const checksProcessorToolTargetsGlobal = allToolTargetKeys.filter((target) => {
51875
52186
  var ChecksProcessor = class extends FeatureProcessor {
51876
52187
  toolTarget;
51877
52188
  global;
52189
+ sweepsThroughInRootLinks() {
52190
+ return this.global;
52191
+ }
51878
52192
  getFactory;
51879
52193
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory$2, dryRun = false, logger }) {
51880
52194
  super({
@@ -54186,6 +54500,10 @@ function definingFields({ h, rawEntry, hookType, converterConfig }) {
54186
54500
  field: "tool",
54187
54501
  value: h.tool
54188
54502
  });
54503
+ for (const { canonical, tool } of converterConfig.stringPassthroughFields ?? []) if (canonical === "if" && h[tool] !== "") fields.push({
54504
+ field: "if",
54505
+ value: h[tool]
54506
+ });
54189
54507
  return fields;
54190
54508
  }
54191
54509
  /**
@@ -62962,10 +63280,12 @@ function emittedEventsWithHook(params) {
62962
63280
  function converterCapabilities(config) {
62963
63281
  if (!config.supportedHookTypes) throw new Error("Hooks converter config must declare its supportedHookTypes");
62964
63282
  const passesThroughEnabled = (config.booleanPassthroughFields ?? []).some((field) => field.canonical === "enabled");
63283
+ const passesThroughIf = (config.stringPassthroughFields ?? []).some((field) => field.canonical === "if") && !(config.skippedWhenFields ?? []).includes("if");
62965
63284
  return {
62966
63285
  supportedEvents: config.supportedEvents,
62967
63286
  supportedHookTypes: [...config.supportedHookTypes],
62968
- ...passesThroughEnabled ? { supportsPerHookEnabled: true } : {}
63287
+ ...passesThroughEnabled ? { supportsPerHookEnabled: true } : {},
63288
+ ...passesThroughIf ? { supportsPerHookIf: true } : {}
62969
63289
  };
62970
63290
  }
62971
63291
  const toolHooksFactories = /* @__PURE__ */ new Map([
@@ -63610,6 +63930,9 @@ const hooksProcessorToolTargetsGlobalImportable = [...toolHooksFactories.entries
63610
63930
  var HooksProcessor = class extends FeatureProcessor {
63611
63931
  toolTarget;
63612
63932
  global;
63933
+ sweepsThroughInRootLinks() {
63934
+ return this.global;
63935
+ }
63613
63936
  preserveUnownedHooks;
63614
63937
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, preserveUnownedHooks = false, logger }) {
63615
63938
  super({
@@ -63688,7 +64011,8 @@ var HooksProcessor = class extends FeatureProcessor {
63688
64011
  if (!factory) throw new Error(`Unsupported tool target: ${this.toolTarget}`);
63689
64012
  const config = rulesyncHooks.getJson();
63690
64013
  const sharedHooks = config.hooks;
63691
- 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 ?? {};
63692
64016
  const effectiveHooks = {
63693
64017
  ...sharedHooks,
63694
64018
  ...overrideHooks
@@ -63726,7 +64050,10 @@ var HooksProcessor = class extends FeatureProcessor {
63726
64050
  if (eventsWithUnsupportedMatcher.length > 0) this.logger.warn(`Skipped matcher hook(s) for ${this.toolTarget} (not supported): ${eventsWithUnsupportedMatcher.join(", ")}`);
63727
64051
  const toolHooks = await factory.class.fromRulesyncHooks({
63728
64052
  outputRoot: this.outputRoot,
63729
- rulesyncHooks,
64053
+ rulesyncHooks: factory.supportsPerHookIf ? rulesyncHooks : this.withoutConditionalHooks({
64054
+ rulesyncHooks,
64055
+ overrideKey
64056
+ }),
63730
64057
  validate: true,
63731
64058
  global: this.global,
63732
64059
  preserveUnowned: this.preserveUnownedHooks,
@@ -63749,6 +64076,53 @@ var HooksProcessor = class extends FeatureProcessor {
63749
64076
  return result;
63750
64077
  }
63751
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
+ /**
63752
64126
  * Warns about canonical per-hook fields that this target's hooks format
63753
64127
  * cannot express, so a hook does not silently change behavior on generation.
63754
64128
  * Only canonical definitions are considered: a tool-native field inside an
@@ -63993,6 +64367,35 @@ var BobIgnore = class extends PlainToolIgnore {
63993
64367
  };
63994
64368
  }
63995
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
+ }
63996
64399
  // src/features/ignore/claudecode-ignore.ts
63997
64400
  const DEFAULT_FILE_MODE = "shared";
63998
64401
  /**
@@ -64051,9 +64454,12 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
64051
64454
  }
64052
64455
  toRulesyncIgnore() {
64053
64456
  const fileContent = this.patterns.map((pattern) => {
64054
- 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
+ });
64055
64461
  return pattern;
64056
- }).filter((pattern) => pattern.length > 0).join("\n");
64462
+ }).filter((pattern) => pattern !== null && pattern.length > 0).join("\n");
64057
64463
  return new RulesyncIgnore({
64058
64464
  outputRoot: this.outputRoot,
64059
64465
  relativeDirPath: RulesyncIgnore.getSettablePaths().recommended.relativeDirPath,
@@ -64328,7 +64734,7 @@ var FirebenderIgnore = class FirebenderIgnore extends ToolIgnore {
64328
64734
  const paths = this.getSettablePaths({ global });
64329
64735
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
64330
64736
  const existingContent = await readFileContentOrNull(filePath) ?? "{}";
64331
- const managedPatterns = [...new Set(patterns)];
64737
+ const managedPatterns = patterns.filter((pattern, index) => patterns.lastIndexOf(pattern) === index);
64332
64738
  return new FirebenderIgnore({
64333
64739
  outputRoot,
64334
64740
  relativeDirPath: paths.relativeDirPath,
@@ -64983,7 +65389,10 @@ const asClaudeStyleSettings = (document) => {
64983
65389
  };
64984
65390
  /** The pattern of a `read_file(...)` (or legacy `Read(...)`) deny, else null. */
64985
65391
  const readDenyPatternOf = (entry) => {
64986
- 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
+ });
64987
65396
  return null;
64988
65397
  };
64989
65398
  /**
@@ -65323,6 +65732,9 @@ var IgnoreProcessor = class extends FeatureProcessor {
65323
65732
  getFactory;
65324
65733
  featureOptions;
65325
65734
  global;
65735
+ sweepsThroughInRootLinks() {
65736
+ return this.global;
65737
+ }
65326
65738
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, getFactory = defaultGetFactory$1, global = false, dryRun = false, logger, featureOptions }) {
65327
65739
  super({
65328
65740
  outputRoot,
@@ -65656,30 +66068,71 @@ function isEmptyValue(value) {
65656
66068
  if (Array.isArray(value)) return value.length === 0;
65657
66069
  return isPlainObject(value) && Object.keys(value).length === 0;
65658
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
+ }
65659
66092
  /**
65660
- * Antigravity uses `serverUrl` (not the canonical `url`) for HTTP/SSE MCP
66093
+ * Antigravity uses `serverUrl` (not the canonical `url`) for remote MCP
65661
66094
  * servers. Convert the canonical shape into Antigravity's on the way out, and
65662
66095
  * back to canonical on the way in.
65663
66096
  *
65664
66097
  * Antigravity documents no `type` field: the transport follows from `command`
65665
- * (stdio) or `serverUrl` (Streamable HTTP / SSE), and `agy plugin validate`
66098
+ * (stdio) or `serverUrl` (Streamable HTTP), and `agy plugin validate`
65666
66099
  * reports the canonical `type` as an unknown field, so it is not written. A
65667
66100
  * remote server that carries `command`, `args` or `env` was also observed to be
65668
66101
  * refused at startup (runtime behavior reported by `agy plugin validate` 1.4.4,
65669
66102
  * not documented), so an empty `args` / `env` left on a `serverUrl` server is
65670
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`.
65671
66108
  * https://antigravity.google/docs/mcp
65672
66109
  */
65673
- function toAntigravityMcpServers(servers) {
66110
+ function toAntigravityMcpServers(servers, logger) {
65674
66111
  const renamed = renameServerField(servers, "url", "serverUrl");
65675
66112
  return Object.fromEntries(Object.entries(renamed).map(([name, config]) => {
65676
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.`);
65677
66115
  const isRemote = typeof config.serverUrl === "string";
65678
- 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
+ })];
65679
66121
  }));
65680
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
+ */
65681
66128
  function toCanonicalMcpServers(servers) {
65682
- 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
+ }));
65683
66136
  }
65684
66137
  /**
65685
66138
  * Parse `mcp_config.json` as JSONC and require a top-level object, so a file
@@ -65697,7 +66150,8 @@ function parseAntigravityMcpConfig(fileContent) {
65697
66150
  * the IDE and the CLI.
65698
66151
  *
65699
66152
  * Antigravity writes a dedicated `mcp_config.json` whose `mcpServers` map lives
65700
- * 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
65701
66155
  * CLI's shared `settings.json`, this file is dedicated to MCP, so it is safely
65702
66156
  * deletable. HTTP servers use `serverUrl` rather than the canonical `url`, so
65703
66157
  * the field is translated in both directions.
@@ -65750,11 +66204,11 @@ var AntigravityMcp = class extends ToolMcp {
65750
66204
  global
65751
66205
  });
65752
66206
  }
65753
- static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false }) {
66207
+ static async fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, global = false, logger }) {
65754
66208
  const paths = this.getSettablePaths({ global });
65755
66209
  const newJson = {
65756
66210
  ...parseAntigravityMcpConfig(await readFileContentOrNull((0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath)) ?? JSON.stringify({ mcpServers: {} }, null, 2)),
65757
- mcpServers: toAntigravityMcpServers(rulesyncMcp.getMcpServers())
66211
+ mcpServers: toAntigravityMcpServers(rulesyncMcp.getMcpServers(), logger)
65758
66212
  };
65759
66213
  return new this({
65760
66214
  outputRoot,
@@ -69423,7 +69877,11 @@ var CrushMcp = class CrushMcp extends ToolMcp {
69423
69877
  ownedPaths: [["mcp"]],
69424
69878
  logger
69425
69879
  });
69426
- 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, {
69427
69885
  ...serverConfig,
69428
69886
  ...readCrushOnlyKeys({
69429
69887
  serverName,
@@ -70383,31 +70841,6 @@ var DshMcp = class DshMcp extends ToolMcp {
70383
70841
  };
70384
70842
  // src/features/mcp/factorydroid-mcp.ts
70385
70843
  const TOOL_NAME = "factorydroid";
70386
- /**
70387
- * Drop servers whose `enabledTools` allowlist is empty.
70388
- *
70389
- * Canonically an empty allowlist allows no tools, but Droid applies
70390
- * `enabledTools` only "when the list is non-empty"
70391
- * (https://docs.factory.com/harness/mcp), so writing `[]` would load every
70392
- * tool the server reports. Skipping the server is the only form that does not
70393
- * hand the model more tools than the canonical config allows.
70394
- */
70395
- function skipEmptyAllowlistServers({ mcpServers, logger }) {
70396
- const kept = {};
70397
- for (const [name, server] of Object.entries(mcpServers)) {
70398
- if (server.enabledTools?.length === 0) {
70399
- warnAndSkipMcpServer({
70400
- toolName: TOOL_NAME,
70401
- serverName: name,
70402
- reason: "an empty enabledTools list, which allows no tools at all but which Factory Droid treats as no filter",
70403
- logger
70404
- });
70405
- continue;
70406
- }
70407
- kept[name] = server;
70408
- }
70409
- return kept;
70410
- }
70411
70844
  var FactorydroidMcp = class FactorydroidMcp extends ToolMcp {
70412
70845
  json;
70413
70846
  constructor(params) {
@@ -70434,8 +70867,10 @@ var FactorydroidMcp = class FactorydroidMcp extends ToolMcp {
70434
70867
  });
70435
70868
  }
70436
70869
  static fromRulesyncMcp({ outputRoot = process.cwd(), rulesyncMcp, validate = true, logger }) {
70437
- const factorydroidConfig = { mcpServers: skipEmptyAllowlistServers({
70870
+ const factorydroidConfig = { mcpServers: skipServersWithEmptyEnabledTools({
70438
70871
  mcpServers: rulesyncMcp.getMcpServers(),
70872
+ toolName: TOOL_NAME,
70873
+ productName: "Factory Droid",
70439
70874
  logger
70440
70875
  }) };
70441
70876
  const fileContent = JSON.stringify(factorydroidConfig, null, 2);
@@ -71866,19 +72301,42 @@ function normalizeKimiCodeTransport({ transport, hasCommand }) {
71866
72301
  return hasCommand ? "stdio" : "http";
71867
72302
  }
71868
72303
  /**
71869
- * The only `executor` value Kimi Code accepts on a stdio server. 2.2.0 removed
71870
- * the never-implemented `kaos` option and narrowed the schema to
71871
- * `z.enum(['local'])`, so any other value makes Kimi fail validation when it
71872
- * 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.
71873
72310
  * @see https://github.com/MoonshotAI/kimi-code/pull/4184
72311
+ * @see https://github.com/MoonshotAI/kimi-code/pull/4062
71874
72312
  */
71875
72313
  const KIMI_CODE_MCP_EXECUTOR = "local";
71876
72314
  /** Remove, with a warning, an `executor` value Kimi Code would reject. Mutates `server`. */
71877
72315
  function dropUnsupportedKimiCodeExecutor({ name, server, logger }) {
71878
72316
  if (server.executor === void 0 || server.executor === KIMI_CODE_MCP_EXECUTOR) return;
71879
- 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}".`);
71880
72318
  delete server.executor;
71881
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
+ }
71882
72340
  function toKimiCodeServer({ name, server, logger }) {
71883
72341
  const transport = server.transport ?? server.type;
71884
72342
  if (transport === "ws") {
@@ -71906,6 +72364,7 @@ function toKimiCodeServer({ name, server, logger }) {
71906
72364
  for (const field of [
71907
72365
  "env",
71908
72366
  "cwd",
72367
+ "environment_id",
71909
72368
  "executor",
71910
72369
  "runtime_id",
71911
72370
  "headers",
@@ -71922,6 +72381,12 @@ function toKimiCodeServer({ name, server, logger }) {
71922
72381
  server: converted,
71923
72382
  logger
71924
72383
  });
72384
+ dropUnsupportedKimiCodeEnvironmentId({
72385
+ name,
72386
+ server: converted,
72387
+ isStdio: normalizedTransport === "stdio",
72388
+ logger
72389
+ });
71925
72390
  if (server.disabled === true) converted.enabled = false;
71926
72391
  return converted;
71927
72392
  }
@@ -71946,6 +72411,15 @@ function fromKimiCodeServers({ servers, logger }) {
71946
72411
  logger
71947
72412
  });
71948
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
+ });
71949
72423
  return [name, {
71950
72424
  ...rest,
71951
72425
  ...type && { type },
@@ -73114,7 +73588,12 @@ var OpenclawMcp = class OpenclawMcp extends ToolMcp {
73114
73588
  const existingMcp = isRecord(existing["mcp"]) ? existing["mcp"] : {};
73115
73589
  const existingServers = isRecord(existingMcp["servers"]) ? existingMcp[OPENCLAW_MCP_SERVERS_KEY] : {};
73116
73590
  const converted = convertToOpenclawFormat({
73117
- mcpServers: rulesyncMcp.getMcpServers(),
73591
+ mcpServers: skipServersWithEmptyEnabledTools({
73592
+ mcpServers: rulesyncMcp.getMcpServers(),
73593
+ toolName: "OpenClaw",
73594
+ ignoreBlankNames: true,
73595
+ logger
73596
+ }),
73118
73597
  logger
73119
73598
  });
73120
73599
  const servers = {
@@ -73742,7 +74221,11 @@ var PoolMcp = class PoolMcp extends ToolMcp {
73742
74221
  const paths = this.getSettablePaths({ global });
73743
74222
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
73744
74223
  const existingContent = await readFileContentOrNull(filePath) ?? "";
73745
- 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]) => {
73746
74229
  const rawServer = rulesyncMcp.getRawMcpServer(serverName);
73747
74230
  const poolAllow = isRecord(rawServer) ? rawServer.poolAllow : void 0;
73748
74231
  return [serverName, {
@@ -77268,7 +77751,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77268
77751
  meta: {
77269
77752
  supportsProject: true,
77270
77753
  supportsGlobal: true,
77271
- supportsEnabledTools: false,
77754
+ supportsEnabledTools: true,
77272
77755
  supportsDisabledTools: true
77273
77756
  }
77274
77757
  }],
@@ -77277,7 +77760,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77277
77760
  meta: {
77278
77761
  supportsProject: true,
77279
77762
  supportsGlobal: true,
77280
- supportsEnabledTools: false,
77763
+ supportsEnabledTools: true,
77281
77764
  supportsDisabledTools: true
77282
77765
  }
77283
77766
  }],
@@ -77286,7 +77769,7 @@ const toolMcpFactories = /* @__PURE__ */ new Map([
77286
77769
  meta: {
77287
77770
  supportsProject: true,
77288
77771
  supportsGlobal: false,
77289
- supportsEnabledTools: false,
77772
+ supportsEnabledTools: true,
77290
77773
  supportsDisabledTools: true
77291
77774
  }
77292
77775
  }],
@@ -77853,6 +78336,9 @@ function warnStrippedMcpServerFields({ mcpServers, fields, toolTarget, logger })
77853
78336
  var McpProcessor = class extends FeatureProcessor {
77854
78337
  toolTarget;
77855
78338
  global;
78339
+ sweepsThroughInRootLinks() {
78340
+ return this.global;
78341
+ }
77856
78342
  getFactory;
77857
78343
  featureOptions;
77858
78344
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, getFactory = defaultGetFactory, dryRun = false, logger, featureOptions }) {
@@ -79905,6 +80391,39 @@ const MATCH_ALL_PATH_GLOBS = /* @__PURE__ */ new Set([
79905
80391
  ]);
79906
80392
  const DIRECTORY_CONTENTS_SUFFIX = /\/\*\*(?:\/\*)?$/;
79907
80393
  const HOME_OR_VARIABLE_PATH = /^~(?:\/|$)|^\$[A-Za-z_]|\$\{[A-Za-z_]/;
80394
+ const LEADING_HOME = /^~(?:\/|$)/;
80395
+ /**
80396
+ * The home directory as an Antigravity path: forward slashes, no trailing
80397
+ * slash. agy accepts `C:/Users/...` on Windows, so one spelling serves both.
80398
+ */
80399
+ function toAntigravityHomePath(homeDirectory) {
80400
+ return homeDirectory.replaceAll("\\", "/").replace(/\/+$/, "");
80401
+ }
80402
+ /**
80403
+ * Rewrite a leading `~` or `~/` of a path target to the absolute home
80404
+ * directory. agy 1.5.1 matches `~` literally, so a `~/...` rule would never
80405
+ * apply (rulesync#3324). Only a file the user's own home holds, such as the
80406
+ * global Antigravity CLI settings, may carry a machine-specific path, so the
80407
+ * caller passes `homeDirectory` only then. `$HOME`, `${HOME}`, `%VAR%` and
80408
+ * `~user/` are left as written (the variable forms are still warned about).
80409
+ */
80410
+ function expandLeadingHome(target, homeDirectory) {
80411
+ if (homeDirectory === void 0 || !LEADING_HOME.test(target)) return target;
80412
+ const home = toAntigravityHomePath(homeDirectory);
80413
+ return target === "~" ? home || "/" : `${home}${target.slice(1)}`;
80414
+ }
80415
+ /**
80416
+ * The reverse of {@link expandLeadingHome} on import: an absolute path in the
80417
+ * home directory goes back to `~/...`, so `.rulesync/permissions.jsonc` stays
80418
+ * the same on every machine.
80419
+ */
80420
+ function collapseLeadingHome(target, homeDirectory) {
80421
+ if (homeDirectory === void 0) return target;
80422
+ const home = toAntigravityHomePath(homeDirectory);
80423
+ if (home.length === 0) return target;
80424
+ if (target === home) return "~";
80425
+ return target.startsWith(`${home}/`) ? `~${target.slice(home.length)}` : target;
80426
+ }
79908
80427
  const HOSTNAME = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/;
79909
80428
  const DENY_HINTS = {
79910
80429
  read_file: "deny a directory or file path instead",
@@ -79929,12 +80448,30 @@ function toPathTarget(pattern) {
79929
80448
  "**",
79930
80449
  "**/*"
79931
80450
  ].includes(path)) return { target: "." };
79932
- 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;
79933
80453
  if (directory !== path && directory.length > 0 && !hasGlob(directory)) return { target: directory };
79934
80454
  if (!hasGlob(path)) return { target: path };
79935
80455
  return { skipReason: "Antigravity file targets are paths, not globs. Only a plain path, `dir/**` or `*` can be expressed" };
79936
80456
  }
79937
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
+ /**
79938
80475
  * A URL or domain pattern becomes its hostname. A leading `*.` is dropped,
79939
80476
  * since a domain already covers its subdomains, and the URL path is ignored.
79940
80477
  * Claude Code's `domain:` form is read as well.
@@ -80057,13 +80594,14 @@ function warnHomeOrVariablePath({ action, target, rule, decision, logger, toolLa
80057
80594
  * nothing enforces it, and a widened one is warned about too, because it
80058
80595
  * blocks more than it says.
80059
80596
  */
80060
- function buildAntigravityPermissionEntry({ action: toolAction, category, pattern, decision, logger, toolLabel }) {
80597
+ function buildAntigravityPermissionEntry({ action: toolAction, category, pattern, decision, logger, toolLabel, homeDirectory }) {
80061
80598
  const isMcp = category.startsWith(MCP_CATEGORY_PREFIX);
80062
80599
  const action = isMcp ? "mcp" : toolAction;
80063
80600
  const result = isMcp ? toMcpRuleTarget(category, pattern) : pattern === "*" ? { target: "*" } : toTarget(action, pattern);
80064
80601
  const rule = `${category}: { "${pattern}": "${decision}" }`;
80065
80602
  if ("skipReason" in result) {
80066
- const prefix = decision !== "deny" ? void 0 : isMcp ? result.lead : toWidenedDenyTarget(action, pattern);
80603
+ const lead = decision !== "deny" ? void 0 : isMcp ? result.lead : toWidenedDenyTarget(action, pattern);
80604
+ const prefix = lead !== void 0 && PATH_ACTIONS.has(action) ? expandLeadingHome(lead, homeDirectory) : lead;
80067
80605
  if (prefix !== void 0) {
80068
80606
  const { entry, scope } = describeWidenedDeny({
80069
80607
  action,
@@ -80089,22 +80627,23 @@ function buildAntigravityPermissionEntry({ action: toolAction, category, pattern
80089
80627
  const consequence = decision === "deny" ? ", so the deny also overrides any allow or ask there" : "";
80090
80628
  logger.warn(`${toolLabel} permissions: ${rule}: ${result.note}${consequence}.`);
80091
80629
  }
80630
+ const target = PATH_ACTIONS.has(action) ? expandLeadingHome(result.target, homeDirectory) : result.target;
80092
80631
  warnHomeOrVariablePath({
80093
80632
  action,
80094
- target: result.target,
80633
+ target,
80095
80634
  rule,
80096
80635
  decision,
80097
80636
  logger,
80098
80637
  toolLabel
80099
80638
  });
80100
- return result.target === "*" ? action : `${action}(${result.target})`;
80639
+ return target === "*" ? action : `${action}(${target})`;
80101
80640
  }
80102
80641
  /**
80103
80642
  * Convert rulesync permissions config to Antigravity allow/ask/deny arrays,
80104
80643
  * shared by the CLI and IDE generators; `toAction` maps a canonical category to
80105
80644
  * the target's tool or action name.
80106
80645
  */
80107
- function convertRulesyncToAntigravityPermissions({ config, toAction, toolLabel, logger }) {
80646
+ function convertRulesyncToAntigravityPermissions({ config, toAction, toolLabel, logger, homeDirectory }) {
80108
80647
  const allow = [];
80109
80648
  const ask = [];
80110
80649
  const deny = [];
@@ -80117,7 +80656,8 @@ function convertRulesyncToAntigravityPermissions({ config, toAction, toolLabel,
80117
80656
  pattern,
80118
80657
  decision: permissionAction,
80119
80658
  logger,
80120
- toolLabel
80659
+ toolLabel,
80660
+ homeDirectory
80121
80661
  });
80122
80662
  if (entry === void 0) continue;
80123
80663
  switch (permissionAction) {
@@ -80142,25 +80682,27 @@ function convertRulesyncToAntigravityPermissions({ config, toAction, toolLabel,
80142
80682
  * shared by the CLI and IDE adapters; `toCategory` maps the target's tool or
80143
80683
  * action name back to a canonical category.
80144
80684
  */
80145
- function convertAntigravityToRulesyncPermissions({ allow, ask, deny, toCategory, toolLabel, logger }) {
80685
+ function convertAntigravityToRulesyncPermissions({ allow, ask, deny, toCategory, toolLabel, logger, homeDirectory }) {
80146
80686
  const permission = {};
80147
80687
  const processEntries = (entries, action) => {
80148
80688
  for (const entry of entries) {
80149
80689
  const { toolName, pattern: target } = parseToolPatternEntry(entry);
80150
80690
  const canonical = toCategory(toolName);
80151
- const pattern = ANTIGRAVITY_COMMAND_ACTIONS.has(toolName) ? importAntigravityCommandTarget({
80691
+ const patterns = ANTIGRAVITY_COMMAND_ACTIONS.has(toolName) ? [importAntigravityCommandTarget({
80152
80692
  target,
80153
80693
  entry,
80154
80694
  decision: action,
80155
80695
  logger,
80156
80696
  toolLabel
80157
- }) : target;
80158
- if (isPrototypePollutionKey(canonical) || isPrototypePollutionKey(pattern)) continue;
80159
- getOrCreateOwn({
80160
- record: permission,
80161
- key: canonical,
80162
- create: () => ({})
80163
- })[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
+ }
80164
80706
  }
80165
80707
  };
80166
80708
  processEntries(allow, "allow");
@@ -80293,7 +80835,8 @@ var AntigravityCliPermissions = class AntigravityCliPermissions extends ToolPerm
80293
80835
  config,
80294
80836
  toAction: toAntigravityCliToolName,
80295
80837
  toolLabel: "Antigravity CLI",
80296
- logger
80838
+ logger,
80839
+ homeDirectory: outputRoot
80297
80840
  });
80298
80841
  const managedToolNames = new Set(Object.keys(config.permission).map((category) => toAntigravityCliToolName(category)));
80299
80842
  const isManagedToolName = (toolName) => managedToolNames.has(toolName) || managedToolNames.has("mcp") && toolName.startsWith("mcp__");
@@ -80344,7 +80887,8 @@ var AntigravityCliPermissions = class AntigravityCliPermissions extends ToolPerm
80344
80887
  deny: permissions.deny ?? [],
80345
80888
  toCategory: toCanonicalToolName$9,
80346
80889
  toolLabel: "Antigravity CLI",
80347
- logger: moduleLogger$4
80890
+ logger: moduleLogger$4,
80891
+ homeDirectory: this.getOutputRoot()
80348
80892
  });
80349
80893
  const override = {};
80350
80894
  const toolPermission = pickDocumentedValue({
@@ -80929,6 +81473,10 @@ var AugmentcodePermissions = class AugmentcodePermissions extends ToolPermission
80929
81473
  * in global mode it is dropped with a warning instead of written somewhere it
80930
81474
  * does nothing.
80931
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
+ *
80932
81480
  * Both make Auggie load code the repository did not ship — a recommended
80933
81481
  * marketplace is one prompt away from being cloned, and an enabled plugin can
80934
81482
  * carry its own hooks and MCP servers — so writing either is logged, unless
@@ -80948,7 +81496,7 @@ function buildPluginConsumptionPatch({ override, existing, global, logger }) {
80948
81496
  else {
80949
81497
  patch.recommendedMarketplaces = recommendedMarketplaces;
80950
81498
  const existingMarketplaces = existingKeys.recommendedMarketplaces;
80951
- 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).`);
80952
81500
  warnAboutRemovedPluginIds({
80953
81501
  key: "recommendedMarketplaces",
80954
81502
  removed: (existingMarketplaces ?? []).filter((marketplace) => !recommendedMarketplaces.includes(marketplace)),
@@ -81177,12 +81725,12 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
81177
81725
  const regex = entry.shellInputRegex;
81178
81726
  if (isShellRegexRoundtrippable(regex)) pattern = shellRegexToGlob(regex);
81179
81727
  else {
81180
- if (action !== "deny" && !canApproximateShellRegexWithoutWidening(regex)) {
81181
- 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.`);
81182
81730
  continue;
81183
81731
  }
81184
- if (action === "deny") {
81185
- 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.`);
81186
81734
  pattern = "*";
81187
81735
  } else {
81188
81736
  pattern = shellRegexToGlob(regex);
@@ -81268,6 +81816,62 @@ function classifyPattern(pattern, bareStarIsWildcard) {
81268
81816
  };
81269
81817
  return { kind: "literal" };
81270
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
+ }
81271
81875
  function pluralize(count, singular, plural) {
81272
81876
  return count === 1 ? singular : plural;
81273
81877
  }
@@ -81362,8 +81966,16 @@ function buildVscodeCommandLists({ rules, toolLabel, logger, prefixSemantics = "
81362
81966
  const matcherAllows = [];
81363
81967
  const widenedDenies = [];
81364
81968
  const inertDenies = [];
81969
+ const literalDenyStems = collectLiteralDenyStems({
81970
+ rules,
81971
+ bareStarIsWildcard: isRooLineage
81972
+ });
81365
81973
  for (const [pattern, action] of Object.entries(rules)) {
81366
81974
  if (action !== "allow" && action !== "deny") continue;
81975
+ if (action === "deny" && isCoveredByLiteralDeny({
81976
+ pattern,
81977
+ literalDenyStems
81978
+ })) continue;
81367
81979
  const shape = classifyPattern(pattern, isRooLineage);
81368
81980
  if (action === "allow") {
81369
81981
  allowed.push(pattern);
@@ -81726,16 +82338,20 @@ var BobPermissions = class BobPermissions extends ToolPermissions {
81726
82338
  validate: true
81727
82339
  });
81728
82340
  }
81729
- toRulesyncPermissions() {
82341
+ toRulesyncPermissions({ logger } = {}) {
81730
82342
  const settings = parseBobSettings({
81731
82343
  fileContent: this.getFileContent(),
81732
82344
  filePath: (0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())
81733
82345
  });
81734
82346
  const approval = settings[APPROVAL_KEY$1];
81735
82347
  const entry = (isPlainObject(approval) && Array.isArray(approval.allowedExecutors) ? approval.allowedExecutors : []).find(isExecuteCommandEntry);
81736
- const rules = {};
81737
- for (const pattern of toStringArray$1(entry?.approvedCommands)) rules[pattern] = "allow";
81738
- 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
+ });
81739
82355
  const permission = {};
81740
82356
  if (Object.keys(rules).length > 0) permission[SHELL_PERMISSION_CATEGORY] = rules;
81741
82357
  const bob = readBobOverride(settings);
@@ -85499,7 +86115,7 @@ function computeCodexcliOverridePatch({ existing, override, logger }) {
85499
86115
  const patch = {};
85500
86116
  const allowed = new Set(CODEXCLI_OVERRIDE_KEYS);
85501
86117
  for (const [key, value] of Object.entries(override ?? {})) {
85502
- 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;
85503
86119
  if (!allowed.has(key)) {
85504
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.`);
85505
86121
  continue;
@@ -85788,6 +86404,7 @@ function stripTrailingSlash(pattern) {
85788
86404
  function buildCodexBashRulesContent({ config, logger }) {
85789
86405
  const bashRules = bashRulesHonoringAllTools(config.permission);
85790
86406
  const entries = Object.entries(bashRules);
86407
+ const canonicalMatching = config.codexcli?.bash_canonical_matching === true;
85791
86408
  const header = ["# Generated by Rulesync from .rulesync/permissions.jsonc (permission.bash)", "# https://developers.openai.com/codex/rules"];
85792
86409
  if (entries.length === 0) return [...header, "# No bash permission rules were configured."].join("\n");
85793
86410
  const ruleBlocks = entries.map(([pattern, action]) => {
@@ -85806,6 +86423,7 @@ function buildCodexBashRulesContent({ config, logger }) {
85806
86423
  ` pattern = [${serializedTokens}],`,
85807
86424
  ` decision = ${JSON.stringify(decision)},`,
85808
86425
  ` justification = ${JSON.stringify(`Generated from Rulesync permission.bash: ${pattern}`)},`,
86426
+ ...canonicalMatching && decision !== "allow" ? [" canonical_matching = True,"] : [],
85809
86427
  ")"
85810
86428
  ].join("\n");
85811
86429
  }).filter((block) => block !== null);
@@ -87390,9 +88008,126 @@ function toCanonicalToolName$5(crushName) {
87390
88008
  function isMcpToolName(crushName) {
87391
88009
  return crushName.startsWith(MCP_CRUSH_PREFIX) && crushName.length > 4;
87392
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
+ }
87393
88122
  function uniq$11(values) {
87394
88123
  return [...new Set(values)];
87395
88124
  }
88125
+ function stringList(value) {
88126
+ return isStringArray(value) ? value : [];
88127
+ }
88128
+ function listOrUndefined(values) {
88129
+ return values.length > 0 ? values : void 0;
88130
+ }
87396
88131
  /**
87397
88132
  * Permissions adapter for Crush.
87398
88133
  *
@@ -87414,8 +88149,24 @@ function uniq$11(values) {
87414
88149
  * `webfetch` -> `fetch`, `mcp__<server>__<tool>` -> `mcp_<server>_<tool>`;
87415
88150
  * any other category passes through verbatim.
87416
88151
  *
87417
- * A pattern-specific rule cannot be expressed and is reported and skipped.
87418
- * 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`
87419
88170
  * is not written while the same category carries a pattern-specific `deny` or
87420
88171
  * `ask`: the tool keeps prompting (fail closed) rather than being widened to
87421
88172
  * everything. The all-tools `*` category is mirrored onto `bash` by
@@ -87479,7 +88230,12 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87479
88230
  const existingContent = location.fileContent ?? "";
87480
88231
  warnCrushTwinLeftovers({
87481
88232
  location,
87482
- 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
+ ],
87483
88239
  logger
87484
88240
  });
87485
88241
  const existing = parseCrushConfig(existingContent, location.filePath);
@@ -87491,12 +88247,22 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87491
88247
  logger
87492
88248
  });
87493
88249
  const managedToolNames = managedCrushToolNames(config);
88250
+ const commands = rebuildCrushCommandLists({
88251
+ config,
88252
+ existingPermissions,
88253
+ logger
88254
+ });
87494
88255
  const preservedAllowed = (isStringArray(existingPermissions?.["allowed_tools"]) ? existingPermissions[CRUSH_ALLOWED_TOOLS_KEY] : []).filter((entry) => entry.includes(":") || !managedToolNames.has(entry));
87495
88256
  const preservedDisabled = (isStringArray(existingOptions?.["disabled_tools"]) ? existingOptions[CRUSH_DISABLED_TOOLS_KEY] : []).filter((entry) => !managedToolNames.has(entry));
87496
88257
  const allowedList = uniq$11([...preservedAllowed, ...generated.allowed]);
87497
88258
  const disabledList = uniq$11([...preservedDisabled, ...generated.disabled]);
88259
+ const permissionsHasContent = allowedList.length > 0 || commands.safe.length > 0 || commands.blocked.length > 0;
87498
88260
  const patch = {};
87499
- 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
+ };
87500
88266
  if (disabledList.length > 0 || existingOptions !== void 0) patch[CRUSH_OPTIONS_KEY] = { [CRUSH_DISABLED_TOOLS_KEY]: disabledList.length > 0 ? disabledList : void 0 };
87501
88267
  return new CrushPermissions({
87502
88268
  outputRoot,
@@ -87514,17 +88280,79 @@ var CrushPermissions = class CrushPermissions extends ToolPermissions {
87514
88280
  global
87515
88281
  });
87516
88282
  }
87517
- toRulesyncPermissions() {
88283
+ toRulesyncPermissions({ logger } = {}) {
87518
88284
  const permissions = isRecord(this.json["permissions"]) ? this.json[CRUSH_PERMISSIONS_KEY] : {};
87519
88285
  const options = toRecord(this.json[CRUSH_OPTIONS_KEY]);
87520
88286
  const rulesyncConfig = convertCrushListsToRulesync({
87521
- allowed: isStringArray(permissions["allowed_tools"]) ? permissions[CRUSH_ALLOWED_TOOLS_KEY] : [],
87522
- 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
87523
88292
  });
87524
88293
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
87525
88294
  }
87526
88295
  };
87527
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
+ /**
87528
88356
  * The Crush tool names the canonical config manages, i.e. the names its
87529
88357
  * categories map to. Entries naming any other tool are the user's and survive
87530
88358
  * a generate. The all-tools `*` category names no Crush tool of its own.
@@ -87549,7 +88377,7 @@ function resolveCategoryAction({ category, toolName, rules, logger }) {
87549
88377
  catchAllAction = action;
87550
88378
  continue;
87551
88379
  }
87552
- 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.`);
87553
88381
  if (action !== "allow") restricted = true;
87554
88382
  }
87555
88383
  if (catchAllAction === "allow" && restricted) {
@@ -87600,11 +88428,71 @@ function convertRulesyncToCrushLists({ config, logger }) {
87600
88428
  };
87601
88429
  }
87602
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
+ /**
87603
88491
  * Convert Crush's two tool lists back into a rulesync config. A `tool:action`
87604
88492
  * entry scopes an allow to one action of a tool, which the canonical model
87605
88493
  * cannot express, so it is left out rather than widened to the whole tool.
87606
88494
  */
87607
- function convertCrushListsToRulesync({ allowed, disabled }) {
88495
+ function convertCrushListsToRulesync({ allowed, disabled, safe, blocked, logger }) {
87608
88496
  const permission = {};
87609
88497
  const bucketFor = (category) => {
87610
88498
  const own = lookupOwn({
@@ -87620,6 +88508,29 @@ function convertCrushListsToRulesync({ allowed, disabled }) {
87620
88508
  if (entry === "" || isPrototypePollutionKey(entry)) continue;
87621
88509
  bucketFor(toCanonicalToolName$5(entry))["*"] = "deny";
87622
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
+ }
87623
88534
  for (const entry of allowed) {
87624
88535
  if (entry === "" || entry.includes(":") || isPrototypePollutionKey(entry)) continue;
87625
88536
  bucketFor(toCanonicalToolName$5(entry))["*"] ??= "allow";
@@ -88999,8 +89910,8 @@ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
88999
89910
  * In global mode the config file is shared with the hooks (`hooks`) feature
89000
89911
  * (MCP moved to the dedicated mcp_config.json in v3000.3), so reads and writes
89001
89912
  * merge into the existing JSON and the file is never deleted; only the keys
89002
- * this feature manages are rewritten — `permissions`, plus `sandbox` in global
89003
- * 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` /
89004
89915
  * `forbiddenPlugins`) in project mode when the `devin` override authors them.
89005
89916
  *
89006
89917
  * The sibling `sandbox` block — which decides what a permitted command may
@@ -89108,6 +90019,12 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
89108
90019
  });
89109
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.");
89110
90021
  }
90022
+ applyPermissionModeOverride({
90023
+ permissionMode: config.devin?.permission_mode,
90024
+ patch,
90025
+ global,
90026
+ logger
90027
+ });
89111
90028
  applyPluginGovernanceOverride({
89112
90029
  override: config.devin,
89113
90030
  settings,
@@ -89151,6 +90068,12 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
89151
90068
  if (DEVIN_PLUGIN_GOVERNANCE_SCHEMAS[key].safeParse(value).success) devinOverride[key] = value;
89152
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)"}.`);
89153
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;
89154
90077
  const result = { ...config };
89155
90078
  if (Object.keys(devinOverride).length > 0) result.devin = devinOverride;
89156
90079
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
@@ -89167,6 +90090,48 @@ const DEVIN_PLUGIN_GOVERNANCE_KEYS = [
89167
90090
  "forbiddenPlugins"
89168
90091
  ];
89169
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
+ /**
89170
90135
  * Patch the plugin governance lists the `devin` override authors into the
89171
90136
  * project `.devin/config.json`. Each authored list replaces the file's list
89172
90137
  * whole; a list it does not author is left as the file holds it. Devin reads these
@@ -95156,7 +96121,8 @@ function convertQwenToRulesyncPermissions(params) {
95156
96121
  *
95157
96122
  * Reasonix matches a rule's tool exactly against its registered tools, which
95158
96123
  * are snake_case (`read_file`, `write_file`, `grep`, `glob`, `web_fetch`,
95159
- * `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
95160
96126
  * writer: `write_file`, `edit_file`, `multi_edit`, `notebook_edit`, ...),
95161
96127
  * `Browser` and `Computer` are Claude-Code-style family aliases
95162
96128
  * (`canonicalRuleTool` / `groupOf`), so a `Read(...)` or `Write(...)` rule
@@ -95179,6 +96145,21 @@ const CANONICAL_TO_REASONIX_TOOL_NAMES = {
95179
96145
  agent: "task"
95180
96146
  };
95181
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
+ /**
95182
96163
  * The Claude-Code-style spellings rulesync emitted before it followed the
95183
96164
  * Reasonix tool vocabulary. None of them reaches a Reasonix tool, so they are
95184
96165
  * read back on import (a file generated by an older rulesync still imports)
@@ -95201,7 +96182,8 @@ const CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES = {
95201
96182
  */
95202
96183
  const REASONIX_TO_CANONICAL_TOOL_NAMES = {
95203
96184
  ...invertRecord(CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES),
95204
- ...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])))
95205
96187
  };
95206
96188
  /** Every Reasonix tool name a canonical category owns on generate. */
95207
96189
  function reasonixToolNamesOf(canonical) {
@@ -95209,14 +96191,20 @@ function reasonixToolNamesOf(canonical) {
95209
96191
  record: CANONICAL_TO_LEGACY_REASONIX_TOOL_NAMES,
95210
96192
  key: canonical
95211
96193
  });
95212
- const current = toReasonixToolName(canonical);
95213
- return legacy === void 0 ? [current] : [current, legacy];
96194
+ const current = toReasonixToolNames(canonical);
96195
+ return legacy === void 0 ? current : [...current, legacy];
95214
96196
  }
95215
- function toReasonixToolName(canonical) {
95216
- return lookupOwn({
96197
+ /** The Reasonix tool names a canonical category's rules are written under. */
96198
+ function toReasonixToolNames(canonical) {
96199
+ const primary = lookupOwn({
95217
96200
  record: CANONICAL_TO_REASONIX_TOOL_NAMES,
95218
96201
  key: canonical
95219
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];
95220
96208
  }
95221
96209
  function toCanonicalToolName$2(reasonixName) {
95222
96210
  return lookupOwn({
@@ -95624,6 +96612,17 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
95624
96612
  });
95625
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.`);
95626
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
+ });
95627
96626
  const mergedPermissions = { ...existingPermissions };
95628
96627
  setOrDeleteEntries(mergedPermissions, "allow", [
95629
96628
  ...preservedAllow,
@@ -95799,6 +96798,55 @@ function preserveExistingEntries({ existingPermissions, managedToolNames, rawOwn
95799
96798
  deny
95800
96799
  };
95801
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
+ }
95802
96850
  /** Sort, dedupe and set a permissions array key, deleting it when empty. */
95803
96851
  function setOrDeleteEntries(table, key, entries) {
95804
96852
  const merged = (0, es_toolkit.uniq)(entries.toSorted());
@@ -95812,22 +96860,19 @@ function convertRulesyncToReasonixPermissions(config) {
95812
96860
  const allow = [];
95813
96861
  const ask = [];
95814
96862
  const deny = [];
95815
- for (const [category, rules] of Object.entries(config.permission)) {
95816
- const reasonixToolName = toReasonixToolName(category);
95817
- for (const [pattern, action] of Object.entries(rules)) {
95818
- const entry = buildToolPatternEntry({
95819
- toolName: reasonixToolName,
95820
- pattern
95821
- });
95822
- switch (action) {
95823
- case "allow":
95824
- allow.push(entry);
95825
- break;
95826
- case "ask":
95827
- ask.push(entry);
95828
- break;
95829
- case "deny": deny.push(entry);
95830
- }
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);
95831
96876
  }
95832
96877
  }
95833
96878
  return {
@@ -96054,7 +97099,7 @@ var RooPermissions = class extends ToolPermissions {
96054
97099
  validate: true
96055
97100
  });
96056
97101
  }
96057
- toRulesyncPermissions() {
97102
+ toRulesyncPermissions({ logger } = {}) {
96058
97103
  const constructor = this.constructor;
96059
97104
  let settings;
96060
97105
  try {
@@ -96068,9 +97113,12 @@ var RooPermissions = class extends ToolPermissions {
96068
97113
  } catch (error) {
96069
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 });
96070
97115
  }
96071
- const rules = {};
96072
- for (const pattern of toStringArray$1(settings[constructor.getAllowedCommandsKey()])) rules[pattern] = "allow";
96073
- 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
+ });
96074
97122
  const permission = {};
96075
97123
  if (Object.keys(rules).length > 0) permission[COMMAND_CATEGORY] = rules;
96076
97124
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
@@ -101394,6 +102442,9 @@ const toolPermissionsFactories = /* @__PURE__ */ new Map([
101394
102442
  var PermissionsProcessor = class extends FeatureProcessor {
101395
102443
  toolTarget;
101396
102444
  global;
102445
+ sweepsThroughInRootLinks() {
102446
+ return this.global;
102447
+ }
101397
102448
  constructor({ outputRoot = process.cwd(), inputRoots, toolTarget, global = false, dryRun = false, logger }) {
101398
102449
  super({
101399
102450
  outputRoot,
@@ -104637,6 +105688,12 @@ Object.defineProperty(exports, "assertDirectoryIfExists", {
104637
105688
  return assertDirectoryIfExists;
104638
105689
  }
104639
105690
  });
105691
+ Object.defineProperty(exports, "assertLandingOutsideVcsMetadata", {
105692
+ enumerable: true,
105693
+ get: function() {
105694
+ return assertLandingOutsideVcsMetadata;
105695
+ }
105696
+ });
104640
105697
  Object.defineProperty(exports, "assertNoSymlinkBelowRoot", {
104641
105698
  enumerable: true,
104642
105699
  get: function() {
@@ -104679,6 +105736,12 @@ Object.defineProperty(exports, "checkPathTraversal", {
104679
105736
  return checkPathTraversal;
104680
105737
  }
104681
105738
  });
105739
+ Object.defineProperty(exports, "classifyRemoteName", {
105740
+ enumerable: true,
105741
+ get: function() {
105742
+ return classifyRemoteName;
105743
+ }
105744
+ });
104682
105745
  Object.defineProperty(exports, "convertFromTool", {
104683
105746
  enumerable: true,
104684
105747
  get: function() {
@@ -104811,16 +105874,16 @@ Object.defineProperty(exports, "groupSpellingsByCaseFoldedIdentity", {
104811
105874
  return groupSpellingsByCaseFoldedIdentity;
104812
105875
  }
104813
105876
  });
104814
- Object.defineProperty(exports, "hasDeceptiveHiddenCharacters", {
105877
+ Object.defineProperty(exports, "hasEnclosingMarkOutsideKeycap", {
104815
105878
  enumerable: true,
104816
105879
  get: function() {
104817
- return hasDeceptiveHiddenCharacters;
105880
+ return hasEnclosingMarkOutsideKeycap;
104818
105881
  }
104819
105882
  });
104820
- Object.defineProperty(exports, "hasEnclosingMarkOutsideKeycap", {
105883
+ Object.defineProperty(exports, "hasWindowsFoldableName", {
104821
105884
  enumerable: true,
104822
105885
  get: function() {
104823
- return hasEnclosingMarkOutsideKeycap;
105886
+ return hasWindowsFoldableName;
104824
105887
  }
104825
105888
  });
104826
105889
  Object.defineProperty(exports, "importFromTool", {
@@ -105069,6 +106132,12 @@ Object.defineProperty(exports, "stripHiddenCharacters", {
105069
106132
  return stripHiddenCharacters;
105070
106133
  }
105071
106134
  });
106135
+ Object.defineProperty(exports, "sweepRefusalReason", {
106136
+ enumerable: true,
106137
+ get: function() {
106138
+ return sweepRefusalReason;
106139
+ }
106140
+ });
105072
106141
  Object.defineProperty(exports, "toInstructionBudgetFile", {
105073
106142
  enumerable: true,
105074
106143
  get: function() {