rulesync 16.19.0 → 16.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { ZodError } from "zod";
2
2
  import { meta, minLength, nonnegative, optional, refine, z } from "zod/mini";
3
3
  import { chmod, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
4
4
  import path, { basename, dirname, extname, isAbsolute, join, normalize, posix, relative, resolve, sep, win32 } from "node:path";
5
- import { parse, parseTree, printParseErrorCode } from "jsonc-parser";
5
+ import { applyEdits, findNodeAtLocation, getNodeValue, modify, parse, parseTree, printParseErrorCode } from "jsonc-parser";
6
6
  import os from "node:os";
7
7
  import { intersection, kebabCase, uniq } from "es-toolkit";
8
8
  import { globbySync, isGitIgnoredSync } from "globby";
@@ -10631,11 +10631,30 @@ const CODEXCLI_OVERRIDE_KEYS = [
10631
10631
  ];
10632
10632
  //#endregion
10633
10633
  //#region src/features/shared/shared-config-gateway.ts
10634
+ /**
10635
+ * Rebuild a parsed document without its prototype-pollution keys.
10636
+ *
10637
+ * Every object is rebuilt, not just the ones that are already plain: a literal
10638
+ * `"__proto__"` is assigned with `obj[key] = value` by `jsonc-parser`, which
10639
+ * *replaces the containing object's prototype* instead of adding a key. Such
10640
+ * an object is no longer a plain object, and the injected value is reachable
10641
+ * through it by plain property access while `Object.keys` and `JSON.stringify`
10642
+ * show nothing — so leaving it as it came out of the parser would both hide a
10643
+ * server or permission the file does not state and, at the root, cost the
10644
+ * whole document (see {@link parseSharedConfig}). Rebuilding gives every
10645
+ * object `Object.prototype` back and drops the injected value with the key.
10646
+ *
10647
+ * Dates are the one object the YAML and TOML parsers produce that is not a
10648
+ * mapping, so they are passed through rather than flattened into `{}`.
10649
+ */
10634
10650
  function sanitizeSharedConfigValue(value) {
10635
10651
  if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
10636
- if (!isPlainObject$1(value)) return value;
10652
+ if (value === null || typeof value !== "object" || value instanceof Date) return value;
10637
10653
  const result = {};
10638
- for (const [key, nested] of Object.entries(omitPrototypePollutionKeys(value))) result[key] = sanitizeSharedConfigValue(nested);
10654
+ for (const [key, nested] of Object.entries(value)) {
10655
+ if (isPrototypePollutionKey(key)) continue;
10656
+ result[key] = sanitizeSharedConfigValue(nested);
10657
+ }
10639
10658
  return result;
10640
10659
  }
10641
10660
  /**
@@ -10664,11 +10683,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
10664
10683
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
10665
10684
  }
10666
10685
  if (parsed === void 0 || parsed === null) return {};
10667
- if (!isPlainObject$1(parsed)) {
10686
+ const sanitized = sanitizeSharedConfigValue(parsed);
10687
+ if (!isPlainObject$1(sanitized)) {
10668
10688
  if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
10669
10689
  return {};
10670
10690
  }
10671
- return sanitizeSharedConfigValue(parsed);
10691
+ return sanitized;
10672
10692
  }
10673
10693
  /**
10674
10694
  * Serialize a shared config document. YAML output always ends with exactly one
@@ -10685,6 +10705,571 @@ function stringifySharedConfig({ format, document }) {
10685
10705
  return JSON.stringify(document, null, 2);
10686
10706
  }
10687
10707
  /**
10708
+ * Where the line holding `from` starts and where the line starting at `from`
10709
+ * ends: the nearest `\r` or `\n` in each direction, or the edge of the text.
10710
+ *
10711
+ * Both characters count in both directions, because the JSONC scanner treats
10712
+ * either as a line break. A file written with lone CRs parses without an error
10713
+ * and so reaches these paths, and a comment read only up to the next `\n`
10714
+ * would run past its own line and take the closing brace — or a whole sibling
10715
+ * key — with it.
10716
+ */
10717
+ function endOfLineFrom({ text, from }) {
10718
+ for (let index = from; index < text.length; index += 1) {
10719
+ const character = text[index];
10720
+ if (character === "\n" || character === "\r") return index;
10721
+ }
10722
+ return text.length;
10723
+ }
10724
+ function startOfLineAt({ text, from }) {
10725
+ for (let index = from - 1; index >= 0; index -= 1) {
10726
+ const character = text[index];
10727
+ if (character === "\n" || character === "\r") return index + 1;
10728
+ }
10729
+ return 0;
10730
+ }
10731
+ /**
10732
+ * Read the indentation and line ending a JSONC document already uses, so
10733
+ * inserted properties match the surrounding file instead of imposing the
10734
+ * 2-space `JSON.stringify` shape on a file written with 4 spaces or tabs.
10735
+ *
10736
+ * The root object's first property decides, located through the syntax tree
10737
+ * rather than by scanning for the first indented line: a file opening with a
10738
+ * banner comment indents that comment's continuation lines too (` * ...`
10739
+ * aligns at three columns), and reading the width off one of those would leave
10740
+ * `modify` re-indenting the lines it touches to a width nothing else in the
10741
+ * file uses. A property that does not start its own line — a one-line object,
10742
+ * or an empty one — carries no indent to read, so those fall back to the
10743
+ * 2-space default the whole-document writer emits.
10744
+ */
10745
+ function detectJsoncFormattingOptions({ text, root }) {
10746
+ const eol = "\n";
10747
+ const first = root.children?.[0];
10748
+ const indent = first === void 0 ? "" : text.slice(startOfLineAt({
10749
+ text,
10750
+ from: first.offset
10751
+ }), first.offset);
10752
+ if (indent === "" || indent.length > 8 || !/^[ \t]+$/.test(indent)) return {
10753
+ tabSize: 2,
10754
+ insertSpaces: true,
10755
+ eol
10756
+ };
10757
+ if (indent.includes(" ")) return {
10758
+ tabSize: 2,
10759
+ insertSpaces: false,
10760
+ eol
10761
+ };
10762
+ return {
10763
+ tabSize: indent.length,
10764
+ insertSpaces: true,
10765
+ eol
10766
+ };
10767
+ }
10768
+ /**
10769
+ * Whether the document states a key no edit-based write can be trusted with.
10770
+ *
10771
+ * Two kinds, both answered from the syntax tree because the parsed value no
10772
+ * longer knows about either:
10773
+ *
10774
+ * - A key stated twice. That is legal JSON text which every reader resolves
10775
+ * last-wins, while `modify` edits the *first* occurrence — so an edit-based
10776
+ * write would land on the dead copy and leave the live one saying whatever
10777
+ * it said before. For an owned key that is a silent ownership failure: a
10778
+ * `deny` rulesync just wrote would sit above the `allow` the tool reads.
10779
+ * - `__proto__`, `constructor` or `prototype`. None survives into the parsed
10780
+ * document — a nested one is dropped, a root-level `__proto__` replaces the
10781
+ * root's prototype — so an edit-based write would find no difference to
10782
+ * apply and leave the key in the file.
10783
+ *
10784
+ * The whole-document writer resolves duplicates last-wins and drops pollution
10785
+ * keys, which is what it has always done, so those files go to it.
10786
+ */
10787
+ function statesUneditableKeys(node) {
10788
+ if (node.type === "array") return (node.children ?? []).some((child) => statesUneditableKeys(child));
10789
+ if (node.type !== "object") return false;
10790
+ const seen = /* @__PURE__ */ new Set();
10791
+ for (const property of node.children ?? []) {
10792
+ const key = property.children?.[0]?.value;
10793
+ if (typeof key === "string") {
10794
+ if (seen.has(key) || isPrototypePollutionKey(key)) return true;
10795
+ seen.add(key);
10796
+ }
10797
+ const value = property.children?.[1];
10798
+ if (value !== void 0 && statesUneditableKeys(value)) return true;
10799
+ }
10800
+ return false;
10801
+ }
10802
+ /**
10803
+ * The offset just past the whitespace and comments starting at `from`.
10804
+ */
10805
+ function skipJsoncTrivia({ text, from }) {
10806
+ let index = from;
10807
+ while (index < text.length) {
10808
+ const char = text[index];
10809
+ if (char === " " || char === " " || char === "\n" || char === "\r") {
10810
+ index += 1;
10811
+ continue;
10812
+ }
10813
+ if (char === "/" && text[index + 1] === "/") {
10814
+ index = endOfLineFrom({
10815
+ text,
10816
+ from: index
10817
+ });
10818
+ continue;
10819
+ }
10820
+ if (char === "/" && text[index + 1] === "*") {
10821
+ const commentEnd = text.indexOf("*/", index + 2);
10822
+ index = commentEnd === -1 ? text.length : commentEnd + 2;
10823
+ continue;
10824
+ }
10825
+ break;
10826
+ }
10827
+ return index;
10828
+ }
10829
+ /**
10830
+ * Where the deletion of a property whose text ends at `end` should stop.
10831
+ *
10832
+ * A comment written after the property on its own line is that property's
10833
+ * note — `"stale": {...}, // retired` says something about `stale` and nothing
10834
+ * about the key above it. Leaving it behind would re-attach it to whichever
10835
+ * property now ends that line, so a note about a server rulesync removed would
10836
+ * read as a note about the one before it. A comment with a sibling after it on
10837
+ * the same line is not claimed: it may belong to either.
10838
+ */
10839
+ function endOfRemoval({ text, end }) {
10840
+ const stop = endOfLineFrom({
10841
+ text,
10842
+ from: end
10843
+ });
10844
+ const tail = text.slice(end, stop);
10845
+ return /^[ \t]*\/\/[^\r\n]*$/.test(tail) || /^[ \t]*\/\*(?:[^*]|\*(?!\/))*\*\/[ \t]*$/.test(tail) || /^[ \t]*$/.test(tail) ? stop : end;
10846
+ }
10847
+ /**
10848
+ * Where the deletion of a property ending at `end` should begin.
10849
+ *
10850
+ * A property that has its line to itself is removed with the line: its
10851
+ * indentation and the newline above it would otherwise be left as a blank gap.
10852
+ * A property sharing its line with something else — a sibling, or the object's
10853
+ * own `}` — is removed on its own, because swallowing the newline would splice
10854
+ * whatever follows onto the line above, and a line comment up there would
10855
+ * comment it out: a key rulesync means to write would vanish from the file, or
10856
+ * the closing brace would, leaving the document unparsable.
10857
+ */
10858
+ function startOfRemoval({ text, propertyOffset, end }) {
10859
+ let after = end;
10860
+ while (text[after] === " " || text[after] === " ") after += 1;
10861
+ if (!(after >= text.length || text[after] === "\n" || text[after] === "\r")) return propertyOffset;
10862
+ let start = propertyOffset;
10863
+ while (start > 0 && (text[start - 1] === " " || text[start - 1] === " ")) start -= 1;
10864
+ if (start > 0 && text[start - 1] === "\n") start -= 1;
10865
+ if (start > 0 && text[start - 1] === "\r") start -= 1;
10866
+ return start;
10867
+ }
10868
+ /**
10869
+ * Delete the property at `path` from `text`, taking its own line and its
10870
+ * separating comma but nothing else.
10871
+ *
10872
+ * `modify(..., undefined)` would do this, but the range it deletes runs from
10873
+ * the end of the *previous* property to the end of this one — or, for the
10874
+ * first property of an object, all the way to where the *next* one starts. So
10875
+ * removing one key takes the comments sitting between it and the keys around
10876
+ * it, including the comment describing the key that survives.
10877
+ * Deleting the property's own text instead leaves the comments around it in
10878
+ * place. Only the note that follows the property on its own line goes with it
10879
+ * (see {@link endOfRemoval}); a comment written on the line *above* is left
10880
+ * behind rather than guessed at, which is the direction this whole path errs
10881
+ * in.
10882
+ */
10883
+ function removeJsoncProperty({ text, path }) {
10884
+ const root = parseTree(text, [], { allowTrailingComma: true });
10885
+ const property = root === void 0 ? void 0 : findNodeAtLocation(root, [...path])?.parent;
10886
+ const object = property?.parent;
10887
+ if (property?.type !== "property" || object?.type !== "object") return text;
10888
+ const siblings = object.children ?? [];
10889
+ const edits = [];
10890
+ let end = property.offset + property.length;
10891
+ const afterProperty = skipJsoncTrivia({
10892
+ text,
10893
+ from: end
10894
+ });
10895
+ if (text[afterProperty] === ",") end = afterProperty + 1;
10896
+ else {
10897
+ const previous = siblings[siblings.indexOf(property) - 1];
10898
+ if (previous !== void 0) {
10899
+ const comma = skipJsoncTrivia({
10900
+ text,
10901
+ from: previous.offset + previous.length
10902
+ });
10903
+ if (text[comma] === ",") edits.push({
10904
+ offset: comma,
10905
+ length: 1,
10906
+ content: ""
10907
+ });
10908
+ }
10909
+ }
10910
+ end = endOfRemoval({
10911
+ text,
10912
+ end
10913
+ });
10914
+ const start = startOfRemoval({
10915
+ text,
10916
+ propertyOffset: property.offset,
10917
+ end
10918
+ });
10919
+ edits.push({
10920
+ offset: start,
10921
+ length: end - start,
10922
+ content: ""
10923
+ });
10924
+ return applyEdits(text, edits);
10925
+ }
10926
+ /**
10927
+ * Where the comment written at `from` (past any spaces or tabs) ends, or
10928
+ * `undefined` if what stands there is not a comment.
10929
+ */
10930
+ function endOfNoteAt({ text, from }) {
10931
+ let cursor = from;
10932
+ while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
10933
+ if (text.startsWith("//", cursor)) return endOfLineFrom({
10934
+ text,
10935
+ from: cursor
10936
+ });
10937
+ if (text.startsWith("/*", cursor)) {
10938
+ const closing = text.indexOf("*/", cursor + 2);
10939
+ return closing === -1 ? void 0 : closing + 2;
10940
+ }
10941
+ }
10942
+ /**
10943
+ * Every comment written at `from`, as spans of `text`, each span running from
10944
+ * where the previous one stopped so the whitespace between them is carried
10945
+ * along. A comma is stepped over once (a file may spell one before its note,
10946
+ * or after it) but never collected, because the separator belongs to the
10947
+ * property rather than to its note. So a file that writes a note on each side
10948
+ * of its comma gets both of them back, in order, after the comma: the notes
10949
+ * stay with the key they describe, and the comma keeps the place the file gave
10950
+ * it. The run stops at the first thing that is neither: a newline ends it, so
10951
+ * a comment on the next line is left alone.
10952
+ */
10953
+ function notesAt({ text, from }) {
10954
+ const spans = [];
10955
+ let cursor = from;
10956
+ let steppedOverComma = false;
10957
+ for (;;) {
10958
+ const end = endOfNoteAt({
10959
+ text,
10960
+ from: cursor
10961
+ });
10962
+ if (end !== void 0) {
10963
+ spans.push({
10964
+ start: cursor,
10965
+ end
10966
+ });
10967
+ cursor = end;
10968
+ continue;
10969
+ }
10970
+ if (steppedOverComma) return spans;
10971
+ let comma = cursor;
10972
+ while (text[comma] === " " || text[comma] === " ") comma += 1;
10973
+ if (text[comma] !== ",") return spans;
10974
+ steppedOverComma = true;
10975
+ cursor = comma + 1;
10976
+ }
10977
+ }
10978
+ /**
10979
+ * Detach the notes written at the point where the object at `path` will take a
10980
+ * new key: after its last property (and around the comma a trailing-comma file
10981
+ * spells there), or just inside the `{` when it has no properties yet.
10982
+ *
10983
+ * `modify` computes its insert from exactly that point — in front of a note
10984
+ * written there — so applying the edit unchanged re-emits the note *after* the
10985
+ * key that was just inserted: `"stale": {...} // retired` turns into a note
10986
+ * about a server rulesync has only now written, and `{ /* none yet *\/ }`
10987
+ * turns into a note about the first entry rulesync puts in it. Lifting the
10988
+ * notes out before the insert and putting them back afterwards keeps them
10989
+ * where their author wrote them, matching what {@link endOfRemoval} does on
10990
+ * the way out.
10991
+ *
10992
+ * Returns `undefined` when there is no such note, which is the common case.
10993
+ */
10994
+ function detachTrailingNote({ text, path }) {
10995
+ const root = parseTree(text, [], { allowTrailingComma: true });
10996
+ const object = root === void 0 ? void 0 : findNodeAtLocation(root, [...path]);
10997
+ if (object?.type !== "object") return void 0;
10998
+ const property = object.children?.at(-1);
10999
+ const anchorKey = property?.children?.[0]?.value;
11000
+ if (property !== void 0 && typeof anchorKey !== "string") return void 0;
11001
+ const spans = notesAt({
11002
+ text,
11003
+ from: property === void 0 ? object.offset + 1 : property.offset + property.length
11004
+ });
11005
+ if (spans.length === 0) return void 0;
11006
+ let stripped = text;
11007
+ for (const span of spans.toReversed()) stripped = stripped.slice(0, span.start) + stripped.slice(span.end);
11008
+ return {
11009
+ text: stripped,
11010
+ note: spans.map((span) => text.slice(span.start, span.end)).join(""),
11011
+ anchorKey: typeof anchorKey === "string" ? anchorKey : void 0
11012
+ };
11013
+ }
11014
+ /**
11015
+ * Put a note detached by {@link detachTrailingNote} back where it was: after
11016
+ * the property it describes (behind the comma the insert gave that property),
11017
+ * or just inside the `{` of the object it was written in when there was no
11018
+ * property to describe. Returns `undefined` if that place can no longer be
11019
+ * located, so the caller can fall back to the plain insert rather than drop
11020
+ * the note.
11021
+ */
11022
+ function reattachTrailingNote({ text, path, anchorKey, note }) {
11023
+ const root = parseTree(text, [], { allowTrailingComma: true });
11024
+ const location = anchorKey === void 0 ? [...path] : [...path, anchorKey];
11025
+ const anchor = root === void 0 ? void 0 : findNodeAtLocation(root, location);
11026
+ if (anchor === void 0) return void 0;
11027
+ if (anchorKey === void 0) {
11028
+ if (anchor.type !== "object") return void 0;
11029
+ const brace = anchor.offset + 1;
11030
+ return text.slice(0, brace) + note + text.slice(brace);
11031
+ }
11032
+ let cursor = anchor.offset + anchor.length;
11033
+ while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
11034
+ if (text[cursor] === ",") cursor += 1;
11035
+ return text.slice(0, cursor) + note + text.slice(cursor);
11036
+ }
11037
+ /**
11038
+ * Write `value` at `[...path, key]`, keeping the trailing note of the property
11039
+ * the new key is inserted after (see {@link detachTrailingNote}). Replacing an
11040
+ * existing key needs none of this: `modify` rewrites the value's own span and
11041
+ * leaves every comment where it is.
11042
+ */
11043
+ function insertJsoncProperty({ text, path, key, value, options }) {
11044
+ const write = (source) => applyEdits(source, modify(source, [...path, key], value, options));
11045
+ const detached = detachTrailingNote({
11046
+ text,
11047
+ path
11048
+ });
11049
+ if (detached === void 0) return write(text);
11050
+ return reattachTrailingNote({
11051
+ text: write(detached.text),
11052
+ path,
11053
+ anchorKey: detached.anchorKey,
11054
+ note: detached.note
11055
+ }) ?? write(text);
11056
+ }
11057
+ /**
11058
+ * How much work an edit-based write may cost, as the file's length times the
11059
+ * number of keys that differ.
11060
+ *
11061
+ * Each changed key re-parses the whole file, so a file whose keys nearly all
11062
+ * change costs quadratic work: an 823 KB `opencode.json` whose 6,400 servers
11063
+ * are all replaced took 40 seconds to write as edits, and cloning a
11064
+ * repository that ships such a file is enough to reach that. Past this budget
11065
+ * the file is written whole instead — it loses its comments, the same as a
11066
+ * file rulesync cannot parse does, which is the better of the two outcomes
11067
+ * against a `generate` that looks like it has hung. The limit is under a
11068
+ * second of editing for a replacement and a second or two for an insert,
11069
+ * which parses more; a hand-written config file is orders of magnitude below
11070
+ * either.
11071
+ */
11072
+ const JSONC_EDIT_BUDGET_BYTES = 5e7;
11073
+ /**
11074
+ * How much text the edits of one write may put into the file, all of them
11075
+ * together.
11076
+ *
11077
+ * The budget above charges by the number of keys that differ, which prices an
11078
+ * insert of a whole subtree as one edit — but `modify` re-indents the text it
11079
+ * writes, and it does that in time quadratic in the length of that text: a
11080
+ * 127 KB value takes half a second to write, a 516 KB one 9 seconds, a 1 MB
11081
+ * one 35. The cost is quadratic in the total as well as in each part, because
11082
+ * every edit re-indents against the text the ones before it left, so a limit
11083
+ * on the widest single value would let a handful of values just under it cost
11084
+ * minutes between them. This is a limit on their sum, which holds the
11085
+ * re-indenting to about a second whether it arrives as one value or twenty.
11086
+ * A hand-written config file changes a few hundred bytes at a time.
11087
+ */
11088
+ const JSONC_EDIT_WRITTEN_BYTES = 2e5;
11089
+ /**
11090
+ * How much text one edit writes: the value as `modify` formats it, plus the
11091
+ * indentation that formatting puts in front of every line of it.
11092
+ *
11093
+ * A value written deep in a document is written far wider than it reads on
11094
+ * its own — a 114 KB server list nested 1,200 deep is 36 MB of text once
11095
+ * every line of it carries 2,400 spaces — so measuring the value alone would
11096
+ * miss the whole of what makes that write expensive.
11097
+ */
11098
+ function measureJsoncWrite({ value, depth }) {
11099
+ const formatted = JSON.stringify(value, null, 2) ?? "";
11100
+ let lines = 1;
11101
+ for (let at = formatted.indexOf("\n"); at !== -1; at = formatted.indexOf("\n", at + 1)) lines += 1;
11102
+ return formatted.length + lines * 2 * depth;
11103
+ }
11104
+ /**
11105
+ * How much {@link applyJsoncObjectEdits} would write, deciding each key
11106
+ * exactly the way it does — the two walk together, so a new case in one needs
11107
+ * the same case here. It reports both how many edits there would be and how
11108
+ * much text they would write between them, because the two are budgeted
11109
+ * separately: one stands for the parse each edit costs, the other for the
11110
+ * re-indenting each edit costs. Counting is a walk of the documents alone: no
11111
+ * text is touched, so the budgets above are spent on the write rather than on
11112
+ * finding out how large the write is.
11113
+ */
11114
+ function countJsoncEdits({ base, next, depth }) {
11115
+ let edits = 0;
11116
+ let written = 0;
11117
+ for (const [key, value] of Object.entries(next)) {
11118
+ if (isPrototypePollutionKey(key)) continue;
11119
+ const present = Object.hasOwn(base, key);
11120
+ const previous = present ? base[key] : void 0;
11121
+ if (value === void 0) {
11122
+ if (present) edits += 1;
11123
+ continue;
11124
+ }
11125
+ if (isPlainObject$1(previous) && isPlainObject$1(value)) {
11126
+ const nested = countJsoncEdits({
11127
+ base: previous,
11128
+ next: value,
11129
+ depth: depth + 1
11130
+ });
11131
+ edits += nested.edits;
11132
+ written += nested.written;
11133
+ continue;
11134
+ }
11135
+ if (present && isDeepStrictEqual(previous, value)) continue;
11136
+ edits += 1;
11137
+ written += measureJsoncWrite({
11138
+ value,
11139
+ depth: depth + 1
11140
+ });
11141
+ }
11142
+ for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) edits += 1;
11143
+ return {
11144
+ edits,
11145
+ written
11146
+ };
11147
+ }
11148
+ /**
11149
+ * Rewrite `text` so the object at `path` matches `next`, touching only the
11150
+ * spans that actually differ from `base`.
11151
+ *
11152
+ * Each difference is applied on its own — `modify` computes an edit against
11153
+ * the current text and `applyEdits` returns the text with that edit applied,
11154
+ * which is then the input for the next difference, because every edit shifts
11155
+ * the offsets the following ones would have been computed from. Nested objects
11156
+ * present on both sides are recursed into rather than replaced wholesale, so a
11157
+ * one-key change deep in the document leaves its siblings — and the comments
11158
+ * attached to them — byte-identical.
11159
+ *
11160
+ * Every difference re-parses the document, so the work is one parse of the
11161
+ * file per *changed* key rather than one parse overall. A regeneration that
11162
+ * changes nothing costs a single parse, and the files this runs on are config
11163
+ * files, so the shape is left simple rather than batched — with the file
11164
+ * large enough and enough of it changing, the product of the two is what
11165
+ * {@link JSONC_EDIT_BUDGET_BYTES} keeps off this path.
11166
+ */
11167
+ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11168
+ let result = text;
11169
+ for (const [key, value] of Object.entries(next)) {
11170
+ if (isPrototypePollutionKey(key)) continue;
11171
+ const present = Object.hasOwn(base, key);
11172
+ const previous = present ? base[key] : void 0;
11173
+ if (value === void 0) {
11174
+ if (present) result = removeJsoncProperty({
11175
+ text: result,
11176
+ path: [...path, key]
11177
+ });
11178
+ continue;
11179
+ }
11180
+ if (isPlainObject$1(previous) && isPlainObject$1(value)) {
11181
+ result = applyJsoncObjectEdits({
11182
+ text: result,
11183
+ base: previous,
11184
+ next: value,
11185
+ path: [...path, key],
11186
+ options
11187
+ });
11188
+ continue;
11189
+ }
11190
+ if (present) {
11191
+ if (isDeepStrictEqual(previous, value)) continue;
11192
+ result = applyEdits(result, modify(result, [...path, key], value, options));
11193
+ continue;
11194
+ }
11195
+ result = insertJsoncProperty({
11196
+ text: result,
11197
+ path,
11198
+ key,
11199
+ value,
11200
+ options
11201
+ });
11202
+ }
11203
+ for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) result = removeJsoncProperty({
11204
+ text: result,
11205
+ path: [...path, key]
11206
+ });
11207
+ return result;
11208
+ }
11209
+ /**
11210
+ * Serialize a document back over the file it was parsed from.
11211
+ *
11212
+ * For every format but JSONC this is {@link stringifySharedConfig}: those
11213
+ * files carry no comments, so re-serializing loses nothing. A JSONC file does
11214
+ * carry comments — `.vscode/settings.json` and `opencode.json` are hand-edited
11215
+ * far more often than they are generated — and re-serializing would delete
11216
+ * every one of them, along with the author's blank lines and key order. So a
11217
+ * JSONC document is written back as a set of edits against the existing text:
11218
+ * regions the merge did not change stay byte-identical, and a regeneration
11219
+ * that changes nothing leaves the file untouched.
11220
+ *
11221
+ * The whole-document writer is still used when there is nothing to preserve or
11222
+ * nothing to edit against:
11223
+ *
11224
+ * - an empty (or whitespace-only) file, which has no comments to keep;
11225
+ * - a file that does not parse, or whose root is not an object — editing it
11226
+ * would mean guessing at the author's intent, and the callers that reach
11227
+ * here have already decided (via `invalidRootPolicy`) that such a file is
11228
+ * replaced;
11229
+ * - a file stating the same key twice, or using `__proto__`, `constructor` or
11230
+ * `prototype` as a key (see {@link statesUneditableKeys});
11231
+ * - a file so large, with so much of it changing, that editing it key by key
11232
+ * would take longer than a user would wait (see
11233
+ * {@link JSONC_EDIT_BUDGET_BYTES}), or changed keys that write more new text
11234
+ * between them than re-indenting can afford (see
11235
+ * {@link JSONC_EDIT_WRITTEN_BYTES});
11236
+ * - a file the editor itself refuses, which it answers with an exception
11237
+ * rather than a result.
11238
+ */
11239
+ function serializeSharedConfig({ format, document, existingContent }) {
11240
+ const whole = stringifySharedConfig({
11241
+ format,
11242
+ document
11243
+ });
11244
+ if (format !== "jsonc" || existingContent.trim() === "") return whole;
11245
+ try {
11246
+ const errors = [];
11247
+ const root = parseTree(existingContent, errors, { allowTrailingComma: true });
11248
+ if (root === void 0 || errors.length > 0 || root.type !== "object" || statesUneditableKeys(root)) return whole;
11249
+ const base = sanitizeSharedConfigValue(getNodeValue(root));
11250
+ if (!isPlainObject$1(base)) return whole;
11251
+ const span = Math.max(existingContent.length, whole.length);
11252
+ const cost = countJsoncEdits({
11253
+ base,
11254
+ next: document,
11255
+ depth: 0
11256
+ });
11257
+ if (cost.written > JSONC_EDIT_WRITTEN_BYTES || cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return whole;
11258
+ return applyJsoncObjectEdits({
11259
+ text: existingContent,
11260
+ base,
11261
+ next: document,
11262
+ path: [],
11263
+ options: { formattingOptions: detectJsoncFormattingOptions({
11264
+ text: existingContent,
11265
+ root
11266
+ }) }
11267
+ });
11268
+ } catch {
11269
+ return whole;
11270
+ }
11271
+ }
11272
+ /**
10688
11273
  * Shallow merge: every top-level key in `patch` replaces the base key
10689
11274
  * wholesale; all other base keys are preserved. The policy for a feature that
10690
11275
  * owns a fixed set of top-level keys.
@@ -10707,7 +11292,7 @@ function mergeSharedConfigShallow({ base, patch }) {
10707
11292
  function mergeSharedConfigDeep({ base, patch }) {
10708
11293
  const result = { ...base };
10709
11294
  for (const [key, patchValue] of Object.entries(patch)) {
10710
- if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
11295
+ if (isPrototypePollutionKey(key)) continue;
10711
11296
  if (patchValue === void 0) {
10712
11297
  delete result[key];
10713
11298
  continue;
@@ -11289,7 +11874,10 @@ const SHARED_CONFIG_OWNERSHIP = {
11289
11874
  /**
11290
11875
  * Execute a feature's declared write to a gateway-managed shared file: parse
11291
11876
  * the existing content, merge the patch under the feature's declared policy,
11292
- * and serialize. Throws when the file or feature is undeclared, when a
11877
+ * and serialize it back over the existing content (see
11878
+ * {@link serializeSharedConfig}, which keeps a JSONC file's comments and
11879
+ * formatting outside the spans the merge actually changed). Throws when the
11880
+ * file or feature is undeclared, when a
11293
11881
  * `replace-owned-keys` patch strays outside its owned keys, or when the
11294
11882
  * feature's policy is `custom` (those calls go to the named policy function
11295
11883
  * instead).
@@ -11315,9 +11903,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11315
11903
  patch
11316
11904
  });
11317
11905
  for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
11318
- return stringifySharedConfig({
11906
+ return serializeSharedConfig({
11319
11907
  format: declaration.format,
11320
- document
11908
+ document,
11909
+ existingContent
11321
11910
  });
11322
11911
  }
11323
11912
  const merged = mergeSharedConfigDeep({
@@ -11325,9 +11914,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11325
11914
  patch
11326
11915
  });
11327
11916
  for (const key of policy.replaceKeys ?? []) if (patch[key] !== void 0) merged[key] = sanitizeSharedConfigValue(patch[key]);
11328
- return stringifySharedConfig({
11917
+ return serializeSharedConfig({
11329
11918
  format: declaration.format,
11330
- document: merged
11919
+ document: merged,
11920
+ existingContent
11331
11921
  });
11332
11922
  }
11333
11923
  const READ_TOOL_NAME = "Read";
@@ -26729,7 +27319,7 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
26729
27319
  json;
26730
27320
  constructor(params) {
26731
27321
  super(params);
26732
- this.json = this.fileContent !== void 0 ? JSON.parse(this.fileContent) : {};
27322
+ this.json = this.fileContent !== void 0 ? parseJsonc(this.fileContent) : {};
26733
27323
  }
26734
27324
  getJson() {
26735
27325
  return this.json;
@@ -28952,7 +29542,7 @@ var KiloMcp = class KiloMcp extends ToolMcp {
28952
29542
  }, null, 2) });
28953
29543
  }
28954
29544
  validate() {
28955
- const json = JSON.parse(this.fileContent || "{}");
29545
+ const json = parseJsonc(this.fileContent || "{}");
28956
29546
  const result = KiloConfigSchema.safeParse(json);
28957
29547
  if (!result.success) return {
28958
29548
  success: false,
@@ -30028,7 +30618,7 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30028
30618
  }, null, 2) });
30029
30619
  }
30030
30620
  validate() {
30031
- const json = JSON.parse(this.fileContent || "{}");
30621
+ const json = parseJsonc(this.fileContent || "{}");
30032
30622
  const result = OpencodeConfigSchema.safeParse(json);
30033
30623
  if (!result.success) return {
30034
30624
  success: false,
@@ -33482,41 +34072,123 @@ function matchesGlobStep(step, character) {
33482
34072
  const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
33483
34073
  return step.negated ? !admitted : admitted;
33484
34074
  }
34075
+ /** Whether two single-character steps can both match one same character. */
34076
+ function stepsShareACharacter(left, right) {
34077
+ if (left.kind === "any" || right.kind === "any") return true;
34078
+ if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34079
+ if (left.kind === "literal") return matchesGlobStep(right, left.character);
34080
+ if (right.kind === "literal") return matchesGlobStep(left, right.character);
34081
+ return true;
34082
+ }
34083
+ /** Whether every step from `index` on can match the empty string. */
34084
+ function isAllStars(steps, index) {
34085
+ for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34086
+ return true;
34087
+ }
34088
+ /**
34089
+ * The most work one intersection walk will do, counted in cells times the cost
34090
+ * of one. Past it the two patterns are reported as intersecting without being
34091
+ * walked: the product of two lengths grows quadratically, and a pattern long
34092
+ * enough to reach this is pathological rather than a command anybody typed.
34093
+ * Answering `true` withholds an `allow`, which is the direction that fails
34094
+ * closed.
34095
+ */
34096
+ const MAX_INTERSECTION_CELLS = 1e6;
34097
+ /**
34098
+ * The most work a whole run of comparisons will do. A caller holding R
34099
+ * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34100
+ * bounds none of that: a hundred restrictions against a hundred allow rules,
34101
+ * each pattern just under the per-pair cap, is ten thousand affordable walks
34102
+ * that together take minutes. The shared budget is spent down across the run
34103
+ * and, once it is gone, every remaining pair is reported as intersecting —
34104
+ * again the direction that withholds an `allow` rather than writing one.
34105
+ */
34106
+ const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34107
+ /**
34108
+ * What a pair costs on top of the cells it walks: the call itself, sizing and
34109
+ * filling the two rows the table is held in, and collecting the answer.
34110
+ * Charging only cells would leave the *number* of pairs unbounded — a pair of
34111
+ * one-step patterns walks a single cell, so n short restrictions against n
34112
+ * short allow rules is n squared comparisons that never spend the budget down
34113
+ * however many of them there are. Charging a floor per pair puts pair count and
34114
+ * walk length on the same exhaustible resource.
34115
+ *
34116
+ * For the short patterns of an ordinary config the floor is the whole charge,
34117
+ * which lowers how many pairs a run compares from around a million to about
34118
+ * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34119
+ * that line withholds every allow it has not yet compared, the same fail-closed
34120
+ * answer exhaustion gives everywhere else.
34121
+ */
34122
+ const INTERSECTION_PAIR_COST = 64;
34123
+ /**
34124
+ * A budget for one caller's run of comparisons. Hand the same one to every
34125
+ * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34126
+ * config — so the run as a whole stays bounded rather than only each pair in
34127
+ * it.
34128
+ */
34129
+ function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34130
+ return { remaining };
34131
+ }
33485
34132
  /**
33486
- * Parse `glob` once and return a predicate that walks it, for a caller that
33487
- * tests the same glob against a whole list of names.
34133
+ * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34134
+ * the same pattern against a whole list parses it once and reuses the result.
33488
34135
  */
33489
- function compileGlob(glob) {
34136
+ function parseGlobPattern(glob) {
33490
34137
  const steps = parseGlob(glob);
33491
- return (value) => matchesParsedGlob(steps, value);
34138
+ return {
34139
+ steps,
34140
+ maxRanges: maxRangeCount(steps)
34141
+ };
33492
34142
  }
33493
- function matchesParsedGlob(steps, value) {
33494
- const characters = [...value];
33495
- let stepIndex = 0;
33496
- let characterIndex = 0;
33497
- let starStepIndex = -1;
33498
- let starCharacterIndex = 0;
33499
- while (characterIndex < characters.length) {
33500
- const step = steps[stepIndex];
33501
- if (step?.kind === "star") {
33502
- starStepIndex = stepIndex;
33503
- starCharacterIndex = characterIndex;
33504
- stepIndex += 1;
33505
- continue;
34143
+ /**
34144
+ * What one cell can cost, as a multiplier on the cell count. A literal met by a
34145
+ * `[a-z...]` class walks that class's ranges, so a single class carrying
34146
+ * thousands of them turns a walk that looks affordable by cell count alone into
34147
+ * a quadratic one — which is why the budget is spent on cells times this rather
34148
+ * than on cells.
34149
+ */
34150
+ function maxRangeCount(steps) {
34151
+ let most = 0;
34152
+ for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34153
+ return most;
34154
+ }
34155
+ /**
34156
+ * `globsIntersect` for two globs already parsed, optionally spending a budget
34157
+ * shared with the rest of the caller's run — see `createIntersectionBudget`.
34158
+ * Once that budget is exhausted every further pair answers `true` without being
34159
+ * walked, so a caller reading the answer as a reason to restrict stays on the
34160
+ * safe side.
34161
+ */
34162
+ function parsedGlobsIntersect(left, right, budget) {
34163
+ const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
34164
+ const cellCost = 1 + left.maxRanges + right.maxRanges;
34165
+ const cost = rows.length * columns.length * cellCost;
34166
+ if (cost > MAX_INTERSECTION_CELLS) return true;
34167
+ if (budget !== void 0) {
34168
+ const charge = cost + INTERSECTION_PAIR_COST;
34169
+ if (charge > budget.remaining) {
34170
+ budget.remaining = 0;
34171
+ return true;
33506
34172
  }
33507
- if (step !== void 0 && matchesGlobStep(step, characters[characterIndex] ?? "")) {
33508
- stepIndex += 1;
33509
- characterIndex += 1;
33510
- continue;
34173
+ budget.remaining -= charge;
34174
+ }
34175
+ let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
34176
+ for (let i = rows.length - 1; i >= 0; i--) {
34177
+ const row = Array.from({ length: columns.length + 1 }, () => false);
34178
+ row[columns.length] = isAllStars(rows, i);
34179
+ for (let j = columns.length - 1; j >= 0; j--) {
34180
+ const rowStep = rows[i];
34181
+ const columnStep = columns[j];
34182
+ if (rowStep === void 0 || columnStep === void 0) continue;
34183
+ if (rowStep.kind === "star" || columnStep.kind === "star") {
34184
+ row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
34185
+ continue;
34186
+ }
34187
+ row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
33511
34188
  }
33512
- if (starStepIndex < 0) return false;
33513
- starCharacterIndex += 1;
33514
- stepIndex = starStepIndex + 1;
33515
- characterIndex = starCharacterIndex;
34189
+ next = row;
33516
34190
  }
33517
- let remaining = stepIndex;
33518
- while (steps[remaining]?.kind === "star") remaining += 1;
33519
- return remaining === steps.length;
34191
+ return next[0] ?? false;
33520
34192
  }
33521
34193
  //#endregion
33522
34194
  //#region src/features/permissions/augmentcode-permissions.ts
@@ -33979,6 +34651,216 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
33979
34651
  }
33980
34652
  return { permission };
33981
34653
  }
34654
+ /**
34655
+ * Collect the canonical rules that govern shell commands, for the adapters
34656
+ * whose tool models commands and nothing else.
34657
+ *
34658
+ * The `bash` category contributes every rule. The all-tools `*` category
34659
+ * contributes its **restricting** rules — `deny` and `ask` — because a rule
34660
+ * written there covers shell commands too, and dropping it inverts the
34661
+ * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
34662
+ * an adapter that reads only `bash` auto-approves the very command the file
34663
+ * denies.
34664
+ *
34665
+ * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
34666
+ * need not be a command at all — `secrets/**` under `*` denies a path — and
34667
+ * carrying it in the restricting direction only over-restricts, while carrying
34668
+ * it in the permissive direction would grant something the author never said
34669
+ * about commands. Both directions therefore fail closed.
34670
+ */
34671
+ function collectShellCommandRules(permission) {
34672
+ const rules = [];
34673
+ const foreignRestrictingCategories = [];
34674
+ const ignoredAllToolsAllowPatterns = [];
34675
+ for (const [category, categoryRules] of Object.entries(permission)) {
34676
+ if (category === "bash") {
34677
+ for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
34678
+ pattern,
34679
+ action,
34680
+ fromAllToolsCategory: false
34681
+ });
34682
+ continue;
34683
+ }
34684
+ if (category === "*") {
34685
+ for (const [pattern, action] of Object.entries(categoryRules)) {
34686
+ if (action === "allow") {
34687
+ ignoredAllToolsAllowPatterns.push(pattern);
34688
+ continue;
34689
+ }
34690
+ rules.push({
34691
+ pattern,
34692
+ action,
34693
+ fromAllToolsCategory: true
34694
+ });
34695
+ }
34696
+ continue;
34697
+ }
34698
+ if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
34699
+ }
34700
+ return {
34701
+ rules,
34702
+ foreignRestrictingCategories,
34703
+ ignoredAllToolsAllowPatterns
34704
+ };
34705
+ }
34706
+ /**
34707
+ * Build the test an adapter applies to an `allow` pattern before writing it:
34708
+ * which restrictions it cannot write name some of the same commands? The
34709
+ * answer is the list of those restrictions — empty when the `allow` may be
34710
+ * written — so a caller can report both the allow rules it withheld and the
34711
+ * restrictions that withheld nothing.
34712
+ *
34713
+ * Canonically the stricter rule wins **whatever its width** — rulesync collapses
34714
+ * colliding rules as `deny > ask > allow` — so the two patterns are compared by
34715
+ * asking whether any one command matches both. Width does not enter into it: an
34716
+ * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
34717
+ * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
34718
+ * on every `git ... --force` command even though neither pattern covers the
34719
+ * other's spelling. Comparing only identical spellings would let the most
34720
+ * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
34721
+ *
34722
+ * Identical spellings are still compared as strings first, as a shortcut past
34723
+ * the walk for the commonest case.
34724
+ *
34725
+ * `normalizePattern` rewrites a pattern written in the tool's own language into
34726
+ * the widest glob it could stand for, for a tool whose patterns are not globs.
34727
+ * It reaches the `bash` rules and the `allow` rules, which is where such a
34728
+ * pattern is written; an all-tools `*` pattern is canonical — it is read by
34729
+ * every tool, so it is a glob already — and is compared as it stands. The
34730
+ * rewrite must only ever widen what a pattern covers, so an inexact reading
34731
+ * withholds an allow rather than writing one the config restricts — see
34732
+ * `warpCommandPatternToGlob`.
34733
+ */
34734
+ function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
34735
+ const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
34736
+ pattern,
34737
+ glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
34738
+ }));
34739
+ return (allowPattern) => {
34740
+ if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
34741
+ const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
34742
+ return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
34743
+ };
34744
+ }
34745
+ /**
34746
+ * Which of the given all-tools `*` restrictions look like they may not name a
34747
+ * command at all — the question a `deny` and an `ask` written there both raise.
34748
+ *
34749
+ * "Withheld no allow rule" alone does not answer it: a config with no `allow`
34750
+ * rules has nothing to withhold, and a pattern the author also wrote under
34751
+ * `bash` is a command on their own word. Both are excluded, so what remains is
34752
+ * a `*` pattern that had allow rules to overlap, overlapped none of them, and
34753
+ * is claimed as a command nowhere else — the shape `secrets/**` has.
34754
+ *
34755
+ * A `bash` restriction never belongs here: it names a command by construction,
34756
+ * so overlapping no allow rule says nothing is wrong with it.
34757
+ */
34758
+ function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
34759
+ if (!rules.some(({ action }) => action === "allow")) return [];
34760
+ const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
34761
+ return uniq(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
34762
+ }
34763
+ /**
34764
+ * Split shell-command rules into the allow and deny lists of a tool that models
34765
+ * commands with those two tiers and nothing else.
34766
+ *
34767
+ * `ask` has no list of its own — such a tool already prompts for whatever it
34768
+ * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
34769
+ * still has to *withhold* the `allow` rules it covers, though: the canonical
34770
+ * order is `deny > ask > allow`, so auto-approving a command the file also asks
34771
+ * about would answer the prompt the author wanted.
34772
+ *
34773
+ * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
34774
+ * from the all-tools `*` category. Warp's cannot: it matches commands with
34775
+ * regular expressions rather than globs, and writing any denylist **replaces**
34776
+ * Warp's built-in default one, so an inert `secrets/**` entry there would trade
34777
+ * the tool's own protection for a rule that matches no command. Where the deny
34778
+ * cannot be written it withholds the allow rules it covers instead, which
34779
+ * restricts in the same direction without touching the denylist.
34780
+ *
34781
+ * A `bash` deny withholds nothing: it names a command by construction, so the
34782
+ * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
34783
+ * and a narrow deny keeps carving an exception out of a wider allow (`git *`
34784
+ * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
34785
+ * even where it is written: a pattern under `*` need not name a command —
34786
+ * `secrets/**` there denies a path — so as a denylist entry it may match nothing
34787
+ * at all, and leaving an overlapping allow beside it would auto-approve the very
34788
+ * commands the author meant to stop. Over-restricting a `*` deny that *was* a
34789
+ * command pattern is reported; failing open would not be.
34790
+ *
34791
+ * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
34792
+ * patterns are not globs.
34793
+ */
34794
+ function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
34795
+ const deny = [];
34796
+ const unwrittenDenyPatterns = [];
34797
+ const restrictions = [];
34798
+ const writtenAllToolsDenyPatterns = [];
34799
+ const allToolsAskPatterns = [];
34800
+ for (const rule of rules) {
34801
+ const { pattern, action, fromAllToolsCategory } = rule;
34802
+ if (action === "allow") continue;
34803
+ if (action !== "deny") {
34804
+ restrictions.push(rule);
34805
+ if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
34806
+ continue;
34807
+ }
34808
+ if (writesAllToolsDeny || !fromAllToolsCategory) {
34809
+ deny.push(pattern);
34810
+ if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
34811
+ } else unwrittenDenyPatterns.push(pattern);
34812
+ if (fromAllToolsCategory) restrictions.push(rule);
34813
+ }
34814
+ const budget = createIntersectionBudget();
34815
+ const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
34816
+ normalizePattern,
34817
+ budget
34818
+ });
34819
+ const allow = [];
34820
+ const shadowedAllowPatterns = [];
34821
+ const withholdingPatterns = /* @__PURE__ */ new Set();
34822
+ for (const { pattern, action } of rules) {
34823
+ if (action !== "allow") continue;
34824
+ const shadowing = shadowingRestrictions(pattern);
34825
+ if (shadowing.length > 0) {
34826
+ shadowedAllowPatterns.push(pattern);
34827
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
34828
+ continue;
34829
+ }
34830
+ allow.push(pattern);
34831
+ }
34832
+ return {
34833
+ allow,
34834
+ deny,
34835
+ shadowedAllowPatterns,
34836
+ unwrittenDenyPatterns,
34837
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
34838
+ rules,
34839
+ allToolsPatterns: writtenAllToolsDenyPatterns,
34840
+ withholdingPatterns
34841
+ }),
34842
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
34843
+ rules,
34844
+ allToolsPatterns: allToolsAskPatterns,
34845
+ withholdingPatterns
34846
+ }),
34847
+ intersectionBudgetExhausted: budget.remaining === 0
34848
+ };
34849
+ }
34850
+ /**
34851
+ * Report, for one command-only tool, every canonical rule its two lists could
34852
+ * not carry. Every command-only adapter shares this reporting, so a rule
34853
+ * dropped in one is worded the same way in all.
34854
+ */
34855
+ function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
34856
+ if (intersectionBudgetExhausted) warnWithFallback(logger, `${toolLabel} reached the limit on how much work one generation may spend comparing .rulesync/permissions.jsonc's allow rules against its deny and ask rules, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than the file asks for. Write fewer or shorter command patterns to have them all compared.`);
34857
+ for (const category of foreignRestrictingCategories) warnWithFallback(logger, `${toolLabel} only models shell-command permissions (${surfaceLabel}); '${category}' deny and ask rules cannot be represented and were skipped.`);
34858
+ if (unwrittenDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} did not write the all-tools '*' deny rule(s) for ${unwrittenDenyPatterns.join(", ")} into its denylist.${unwrittenDenyReason === void 0 ? "" : ` ${unwrittenDenyReason}`} They restrict only by withholding the allow rules they cover; write them under 'bash' to have them enforced as commands.`);
34859
+ if (unenforcedAllToolsDenyPatterns.length > 0) warnWithFallback(logger, `${toolLabel} wrote the all-tools '*' deny rule(s) for ${unenforcedAllToolsDenyPatterns.join(", ")} into its denylist as they stand, but they withheld none of the allow rules beside them. A pattern written under '*' need not name a command — 'secrets/**' there denies a path — and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern.`);
34860
+ if (unenforcedAllToolsAskPatterns.length > 0) warnWithFallback(logger, `${toolLabel} has no ask tier (${surfaceLabel}), so the all-tools '*' ask rule(s) for ${unenforcedAllToolsAskPatterns.join(", ")} restrict only by withholding the allow rules they cover — and they covered none. A pattern written under '*' need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns.`);
34861
+ if (ignoredAllToolsAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} reads the all-tools '*' category for its deny and ask rules only, so the allow rule(s) for ${ignoredAllToolsAllowPatterns.join(", ")} were skipped — a pattern written under '*' need not be a command. Write them under 'bash' to auto-approve them as commands.`);
34862
+ if (shadowedAllowPatterns.length > 0) warnWithFallback(logger, `${toolLabel} was not given the allow rule(s) for ${shadowedAllowPatterns.join(", ")} because .rulesync/permissions.jsonc restricts the same commands elsewhere, and the stricter rule wins whatever its width.`);
34863
+ }
33982
34864
  //#endregion
33983
34865
  //#region src/features/permissions/claudecode-permissions.ts
33984
34866
  /**
@@ -34909,8 +35791,10 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
34909
35791
  const ask = [];
34910
35792
  const deny = [];
34911
35793
  const actionByEntry = /* @__PURE__ */ new Map();
35794
+ const allToolsPatterns = [];
34912
35795
  for (const [category, rules] of Object.entries(config.permission)) {
34913
35796
  const claudeToolName = toClaudeToolName(category);
35797
+ if (category === "*") allToolsPatterns.push(...Object.keys(rules));
34914
35798
  for (const [pattern, action] of Object.entries(rules)) {
34915
35799
  const entry = buildClaudePermissionEntry(claudeToolName, pattern);
34916
35800
  const previous = actionByEntry.get(entry);
@@ -34927,6 +35811,7 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
34927
35811
  }
34928
35812
  }
34929
35813
  }
35814
+ if (allToolsPatterns.length > 0) logger?.warn(`Claude Code permissions: a rule names one tool, and there is no name standing for every tool, so the all-tools '*' rule(s) for ${allToolsPatterns.join(", ")} were written as '*(pattern)' entries that Claude Code matches against no tool. Write them under the categories they are meant for (for example 'bash' or 'read') to have them enforced.`);
34930
35815
  return {
34931
35816
  allow,
34932
35817
  ask,
@@ -34971,34 +35856,64 @@ const ClineCommandPermissionsSchema = z.looseObject({
34971
35856
  });
34972
35857
  /**
34973
35858
  * Translate rulesync permission categories into Cline allow/deny command lists.
34974
- * Non-bash categories and `ask` rules are tracked separately so a single
34975
- * translation notice can be surfaced by the caller.
35859
+ * The `bash` category maps, and so do the restricting rules of the all-tools
35860
+ * `*` category a rule written there covers shell commands too. Other
35861
+ * categories and `ask` rules are tracked separately so a single translation
35862
+ * notice can be surfaced by the caller.
34976
35863
  */
34977
35864
  function translateClinePermissions(permission) {
34978
35865
  const allow = [];
34979
35866
  const deny = [];
34980
- const droppedCategories = [];
34981
35867
  const translatedAskPatterns = [];
34982
- for (const [category, rules] of Object.entries(permission)) {
34983
- if (category !== "bash") {
34984
- droppedCategories.push(category);
34985
- continue;
34986
- }
34987
- for (const [pattern, action] of Object.entries(rules)) {
34988
- if (action === "ask") {
34989
- translatedAskPatterns.push(pattern);
34990
- deny.push(pattern);
35868
+ const shadowedAllowPatterns = [];
35869
+ const droppedCategories = Object.keys(permission).filter((category) => category !== "bash" && category !== "*");
35870
+ const { rules, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permission);
35871
+ const budget = createIntersectionBudget();
35872
+ const shadowingRestrictions = createShadowingRestrictionsTest(rules.filter(({ fromAllToolsCategory }) => fromAllToolsCategory), { budget });
35873
+ const allToolsDenyPatterns = [];
35874
+ const allToolsAskPatterns = [];
35875
+ const withholdingPatterns = /* @__PURE__ */ new Set();
35876
+ for (const { pattern, action, fromAllToolsCategory } of rules) {
35877
+ if (action === "ask") {
35878
+ if (fromAllToolsCategory) {
35879
+ allToolsAskPatterns.push(pattern);
34991
35880
  continue;
34992
35881
  }
34993
- if (action === "allow") allow.push(pattern);
34994
- else if (action === "deny") deny.push(pattern);
35882
+ translatedAskPatterns.push(pattern);
35883
+ deny.push(pattern);
35884
+ continue;
35885
+ }
35886
+ if (action === "deny") {
35887
+ deny.push(pattern);
35888
+ if (fromAllToolsCategory) allToolsDenyPatterns.push(pattern);
35889
+ continue;
35890
+ }
35891
+ const shadowing = shadowingRestrictions(pattern);
35892
+ if (shadowing.length > 0) {
35893
+ shadowedAllowPatterns.push(pattern);
35894
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
35895
+ continue;
34995
35896
  }
35897
+ allow.push(pattern);
34996
35898
  }
34997
35899
  return {
34998
35900
  allow,
34999
35901
  deny,
35000
35902
  droppedCategories,
35001
- translatedAskPatterns
35903
+ translatedAskPatterns,
35904
+ shadowedAllowPatterns,
35905
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35906
+ rules,
35907
+ allToolsPatterns: allToolsDenyPatterns,
35908
+ withholdingPatterns
35909
+ }),
35910
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35911
+ rules,
35912
+ allToolsPatterns: allToolsAskPatterns,
35913
+ withholdingPatterns
35914
+ }),
35915
+ ignoredAllToolsAllowPatterns,
35916
+ intersectionBudgetExhausted: budget.remaining === 0
35002
35917
  };
35003
35918
  }
35004
35919
  /**
@@ -35007,11 +35922,16 @@ function translateClinePermissions(permission) {
35007
35922
  * project convention used by every other permissions translator, and
35008
35923
  * (b) the user still sees one prominent "WARNING" message describing the translation.
35009
35924
  */
35010
- function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, logger }) {
35011
- if (droppedCategories.length === 0 && translatedAskPatterns.length === 0) return;
35925
+ function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted, logger }) {
35926
+ if (droppedCategories.length === 0 && translatedAskPatterns.length === 0 && shadowedAllowPatterns.length === 0 && unenforcedAllToolsDenyPatterns.length === 0 && unenforcedAllToolsAskPatterns.length === 0 && ignoredAllToolsAllowPatterns.length === 0 && !intersectionBudgetExhausted) return;
35012
35927
  const parts = [];
35013
35928
  if (droppedCategories.length > 0) parts.push(`non-bash categories [${droppedCategories.join(", ")}] (Cline only enforces shell commands; use the rulesync ignore feature for read/write restrictions)`);
35014
35929
  if (translatedAskPatterns.length > 0) parts.push(`'ask' rules for bash patterns [${translatedAskPatterns.join(", ")}] translated to 'deny' for fail-closed safety, since Cline lacks 'ask'`);
35930
+ if (shadowedAllowPatterns.length > 0) parts.push(`'allow' rules for [${shadowedAllowPatterns.join(", ")}] withheld because the all-tools '*' category restricts the same commands, and a pattern written there need not name a command Cline's own lists can act on. Cline's allowlist is a gate — once set, only the commands matching it run without approval — so withholding every entry leaves every command asking`);
35931
+ if (unenforcedAllToolsDenyPatterns.length > 0) parts.push(`'deny' rules for [${unenforcedAllToolsDenyPatterns.join(", ")}] under the all-tools '*' category written into the denylist as they stand, where they withheld none of the allow rules beside them — a pattern written there need not name a command, and a denylist entry that names none blocks nothing; write it under 'bash' too if it is a command pattern`);
35932
+ if (unenforcedAllToolsAskPatterns.length > 0) parts.push(`'ask' rules for [${unenforcedAllToolsAskPatterns.join(", ")}] under the all-tools '*' category left with nothing to do, since Cline has no ask tier and they withheld none of the allow rules beside them — a pattern written there need not name a command, so nothing observed says these ones do; write them under 'bash' if they are command patterns`);
35933
+ if (ignoredAllToolsAllowPatterns.length > 0) parts.push(`'allow' rules for [${ignoredAllToolsAllowPatterns.join(", ")}] under the all-tools '*' category skipped (only its deny and ask rules are read, since a pattern written there need not be a command); write them under 'bash' to auto-approve them`);
35934
+ if (intersectionBudgetExhausted) parts.push("the limit on how much work one generation may spend comparing allow rules against the all-tools '*' category's deny and ask rules was reached, so the allow rules left over were withheld rather than compared — the safe answer, but a wider one than .rulesync/permissions.jsonc asks for; write fewer or shorter command patterns to have them all compared");
35015
35935
  logger?.warn(`WARNING: Cline command permissions translation notice: ${parts.join("; ")}.`);
35016
35936
  }
35017
35937
  var ClinePermissions = class ClinePermissions extends ToolPermissions {
@@ -35059,10 +35979,15 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
35059
35979
  throw new Error(`Failed to parse existing Cline command-permissions at ${filePath}: ${formatError(error)}`, { cause: error });
35060
35980
  }
35061
35981
  const config = rulesyncPermissions.getJson();
35062
- const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(config.permission);
35982
+ const { allow, deny, droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted } = translateClinePermissions(config.permission);
35063
35983
  warnClineTranslationNotices({
35064
35984
  droppedCategories,
35065
35985
  translatedAskPatterns,
35986
+ shadowedAllowPatterns,
35987
+ unenforcedAllToolsDenyPatterns,
35988
+ unenforcedAllToolsAskPatterns,
35989
+ ignoredAllToolsAllowPatterns,
35990
+ intersectionBudgetExhausted,
35066
35991
  logger
35067
35992
  });
35068
35993
  const dedupedAllow = uniq(allow.toSorted());
@@ -35070,7 +35995,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
35070
35995
  const mergedDeny = uniq([...existing.deny ?? [], ...dedupedDeny]).toSorted();
35071
35996
  const denySet = new Set(mergedDeny);
35072
35997
  const collisions = dedupedAllow.filter((p) => denySet.has(p));
35073
- if (collisions.length > 0) logger?.warn(`Cline command permissions: pattern(s) ${collisions.map((p) => `'${p}'`).join(", ")} appear in both 'allow' and 'deny'. Cline's evaluation order is not documented to guarantee deny-priority; the resulting behavior is undefined. Consider removing the duplicate rule from rulesync.`);
35998
+ if (collisions.length > 0) logger?.warn(`Cline command permissions: pattern(s) ${collisions.map((p) => `'${p}'`).join(", ")} appear in both 'allow' and 'deny'. Cline documents that deny rules always take precedence, so the 'allow' entry has no effect. Consider removing the duplicate rule.`);
35074
35999
  const next = {
35075
36000
  ...existing,
35076
36001
  allow: dedupedAllow,
@@ -36489,7 +37414,9 @@ const TRAILING_ARGUMENT_WILDCARD_PATTERN = /:\*$/;
36489
37414
  * This surface is **global only** — dcode reads no project-level config file,
36490
37415
  * so there is nothing to write into a repository.
36491
37416
  *
36492
- * Only the canonical `bash` category maps, and only its `allow` rules:
37417
+ * Only `allow` rules map, and only from the canonical `bash` category — the
37418
+ * all-tools `*` category contributes its restricting rules instead, since a rule
37419
+ * written there covers shell commands too (see `collectShellCommandRules`):
36493
37420
  *
36494
37421
  * - A pattern is reduced to its executable token, because that is all dcode
36495
37422
  * matches on — `git *`, `git:*`, `git commit:*` and a bare `git` all become
@@ -36603,7 +37530,7 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
36603
37530
  const shell = isPlainObject$1(existingShell) ? { ...existingShell } : {};
36604
37531
  if (allowList.length > 0) shell[ALLOW_LIST_KEY] = allowList;
36605
37532
  else if (shell[ALLOW_LIST_KEY] !== void 0) {
36606
- warnWithFallback(logger, `deepagents-cli: no bash allow rule maps to an executable name, so the existing [${SHELL_TABLE_KEY}].${ALLOW_LIST_KEY} in ${filePath} was removed and those commands will be asked about again.`);
37533
+ warnWithFallback(logger, `deepagents-cli: no bash allow rule is left to auto-approve — none maps to an executable name, or every one of them is covered by a stricter rule — so the existing [${SHELL_TABLE_KEY}].${ALLOW_LIST_KEY} in ${filePath} was removed and those commands will be asked about again.`);
36607
37534
  delete shell[ALLOW_LIST_KEY];
36608
37535
  }
36609
37536
  if (Object.keys(shell).length > 0) settings[SHELL_TABLE_KEY] = shell;
@@ -36662,35 +37589,69 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
36662
37589
  }
36663
37590
  };
36664
37591
  /**
37592
+ * Split the restricting rules by what the generated allowlist does to them, and
37593
+ * name the entries that have to go.
37594
+ *
37595
+ * An allowlist entry auto-approves its executable however it is invoked, so an
37596
+ * `ask` or `deny` on any command that entry would run — a narrower pattern
37597
+ * (`npm publish`) beside an allowed `npm *`, or one naming no executable at all
37598
+ * (`*delete*` beside an allowed `kubectl`) — collides with it. dcode has no
37599
+ * denylist, so the collision cannot be settled there: keeping the allow would
37600
+ * auto-approve the very command the author wanted stopped. The colliding
37601
+ * entries are therefore withheld — canonically the stricter rule wins whatever
37602
+ * its width — which leaves those executables prompting, which is what an `ask`
37603
+ * asks for and the closest dcode can come to a `deny`.
37604
+ */
37605
+ function partitionRestrictingRules({ allowList, askPatterns, denyPatterns, willWrite }) {
37606
+ const approved = allowList.map((token) => ({
37607
+ token,
37608
+ globs: [parseGlobPattern(token), parseGlobPattern(`${token} *`)]
37609
+ }));
37610
+ const budget = createIntersectionBudget();
37611
+ const collidingTokens = (pattern) => {
37612
+ if (!willWrite) return [];
37613
+ if (budget.remaining === 0) return [...allowList];
37614
+ const restriction = parseGlobPattern(pattern.trim().replace(TRAILING_ARGUMENT_WILDCARD_PATTERN, "*").replaceAll(SHLEX_STRIPPED_PATTERN, ""));
37615
+ return approved.filter(({ globs }) => globs.some((glob) => parsedGlobsIntersect(restriction, glob, budget))).map(({ token }) => token);
37616
+ };
37617
+ const withheldTokens = /* @__PURE__ */ new Set();
37618
+ const collect = (pattern) => {
37619
+ const tokens = collidingTokens(pattern);
37620
+ for (const token of tokens) withheldTokens.add(token);
37621
+ return tokens.length > 0;
37622
+ };
37623
+ const shadowedAsk = uniq(askPatterns).filter(collect);
37624
+ const shadowedDeny = [];
37625
+ const unenforcedDeny = [];
37626
+ for (const pattern of uniq(denyPatterns)) (collect(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
37627
+ return {
37628
+ shadowedAsk,
37629
+ shadowedDeny,
37630
+ unenforcedDeny,
37631
+ withheldTokens,
37632
+ intersectionBudgetExhausted: budget.remaining === 0
37633
+ };
37634
+ }
37635
+ /**
36665
37636
  * Say what the reduction to executable names could not write. Split out from
36666
37637
  * `convertRulesyncToDeepagentsAllowList` because the rules it reports on
36667
37638
  * outnumber the ones it writes: every category dcode cannot express is a
36668
37639
  * sentence here.
36669
37640
  */
36670
- function warnAboutUnwrittenBashRules({ allowList, allowAll, requestedAllowAll, askPatterns, denyPatterns, widenedPatterns, unmatchablePatterns, sentinelPatterns, willWrite, logger }) {
36671
- const allowedTokens = new Set(allowList);
36672
- const collidesWithAllow = (pattern) => {
36673
- if (!willWrite) return false;
36674
- if (allowAll) return true;
36675
- const leading = leadingToken(pattern).replaceAll(SHLEX_STRIPPED_PATTERN, "");
36676
- if (GLOB_CHARACTERS_PATTERN.test(leading)) {
36677
- const matches = compileGlob(leading);
36678
- return allowList.some((token) => matches(token));
36679
- }
36680
- return allowedTokens.has(leading);
36681
- };
36682
- const shadowedAsk = askPatterns.filter(collidesWithAllow);
36683
- const shadowedDeny = [];
36684
- const unenforcedDeny = [];
36685
- for (const pattern of denyPatterns) (collidesWithAllow(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
36686
- if (unenforcedDeny.length > 0) warnWithFallback(logger, `deepagents-cli has no command denylist — a command it does not auto-approve is asked about, not blocked — so ${unenforcedDeny.length} bash deny rule(s) from .rulesync/permissions.jsonc were skipped and those commands remain runnable on approval.`);
36687
- const shadowReason = allowAll ? `allow_list = ["all"] auto-approves every command` : `the generated allow_list auto-approves commands they cover`;
36688
- if (shadowedDeny.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name, so the deny rule(s) ${shadowedDeny.join(", ")} are not merely unenforced: ${shadowReason}, so those commands run without a prompt. Narrow or drop the allow rule that covers them.`);
36689
- if (shadowedAsk.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name, so the ask rule(s) ${shadowedAsk.join(", ")} run without a prompt: ${shadowReason}. Narrow or drop the allow rule that covers them.`);
37641
+ function warnAboutUnwrittenBashRules({ allowAll, requestedAllowAll, askPatterns, denyPatterns, foreignRestrictingCategories, shadowedAsk, shadowedDeny, unenforcedDeny, intersectionBudgetExhausted, widenedPatterns, unmatchablePatterns, sentinelPatterns, willWrite, logger }) {
37642
+ if (unenforcedDeny.length > 0) warnWithFallback(logger, `deepagents-cli has no command denylist — a command it does not auto-approve is asked about, not blocked — so ${unenforcedDeny.length} command deny rule(s) from .rulesync/permissions.jsonc were skipped and those commands remain runnable on approval.`);
37643
+ if (shadowedDeny.length > 0) warnWithFallback(logger, intersectionBudgetExhausted ? `deepagents-cli withheld the allow_list entries beside the deny rule(s) ${shadowedDeny.join(", ")} without comparing them, the comparison limit above having been reached. Those executables are asked about instead.` : `deepagents-cli matches only the executable name, so the allow rule(s) covered by the deny rule(s) ${shadowedDeny.join(", ")} were withheld from allow_list — keeping them would auto-approve the very commands those rules deny. Those executables are asked about instead; narrow the deny rule if you meant them auto-approved.`);
37644
+ if (shadowedAsk.length > 0) warnWithFallback(logger, intersectionBudgetExhausted ? `deepagents-cli withheld the allow_list entries beside the ask rule(s) ${shadowedAsk.join(", ")} without comparing them, the comparison limit above having been reached. Those executables are asked about instead.` : `deepagents-cli matches only the executable name, so the allow rule(s) covered by the ask rule(s) ${shadowedAsk.join(", ")} were withheld from allow_list — keeping them would run those commands without the prompt the ask rule asks for. Narrow the ask rule if you meant them auto-approved.`);
36690
37645
  if (willWrite && widenedPatterns.length > 0) warnWithFallback(logger, `deepagents-cli matches only the executable name of a command, so ${widenedPatterns.join(", ")} were widened to their first token — every invocation of those executables is now auto-approved, not just the listed arguments.`);
36691
37646
  if (willWrite && unmatchablePatterns.length > 0) warnWithFallback(logger, `deepagents-cli compares an allow_list entry to the executable name exactly, so a glob in that name matches nothing, a name longer than ${MAX_EXECUTABLE_NAME_LENGTH} characters is longer than a command's own name can be, and a name holding a shell metacharacter, a quote or an escape is one dcode splits on, refuses outright, or reads differently than it is written — the pattern(s) ${unmatchablePatterns.join(", ")} were therefore skipped rather than written as a rule that cannot be relied on to fire.`);
36692
37647
  if (willWrite && sentinelPatterns.length > 0) warnWithFallback(logger, `deepagents-cli reads 'all' and 'recommended' in allow_list as sentinels rather than command names, so ${sentinelPatterns.join(", ")} were skipped. Use the '*' pattern to allow every command.`);
36693
- if (willWrite && requestedAllowAll && !allowAll) warnWithFallback(logger, `The bash '*' allow rule was not written as allow_list = ["all"] for deepagents-cli, because 'all' also turns off its dangerous-pattern check (command substitution, redirects, process substitution) and ${denyPatterns.length > 0 ? `your config denies commands` : `your config has deny rules for other tools`} — the two together would be weaker than dcode's own default. List the executables you want auto-approved instead.`);
37648
+ if (willWrite && requestedAllowAll && !allowAll) {
37649
+ let restrictionReason = `your config restricts other tools`;
37650
+ if (denyPatterns.length > 0) restrictionReason = `your config denies commands`;
37651
+ else if (askPatterns.length > 0) restrictionReason = `your config asks before running commands`;
37652
+ else if (foreignRestrictingCategories.length > 0) restrictionReason = `your config restricts '${foreignRestrictingCategories.join("', '")}'`;
37653
+ warnWithFallback(logger, `The bash '*' allow rule was not written as allow_list = ["all"] for deepagents-cli, because 'all' also turns off its dangerous-pattern check (command substitution, redirects, process substitution) and ${restrictionReason} — the two together would be weaker than dcode's own default. List the executables you want auto-approved instead.`);
37654
+ }
36694
37655
  if (willWrite && allowAll) warnWithFallback(logger, "The bash '*' allow rule became allow_list = [\"all\"] for deepagents-cli, which auto-approves every command and skips its dangerous-pattern check (command substitution, redirects, process substitution). List the executables you want instead if that is more than you meant.");
36695
37656
  }
36696
37657
  /**
@@ -36837,62 +37798,78 @@ function toExecutableToken(pattern) {
36837
37798
  }
36838
37799
  /**
36839
37800
  * Convert rulesync permissions config to dcode's `[shell].allow_list`. Only
36840
- * `bash` `allow` rules map; everything else is skipped, with a warning
37801
+ * `allow` rules map from the `bash` category, and never from the all-tools
37802
+ * `*` one, whose restricting rules still count against them (see
37803
+ * `collectShellCommandRules`). Everything else is skipped, with a warning
36841
37804
  * wherever the skip loses a restriction rather than a redundancy.
36842
37805
  */
36843
37806
  function convertRulesyncToDeepagentsAllowList({ config, willWrite, logger }) {
36844
37807
  const allowed = [];
36845
- const widenedPatterns = [];
37808
+ const widened = [];
36846
37809
  const unmatchablePatterns = [];
36847
37810
  const sentinelPatterns = [];
36848
37811
  const askPatterns = [];
36849
37812
  const denyPatterns = [];
36850
- let hasForeignDeny = false;
36851
37813
  let requestedAllowAll = false;
36852
- for (const [category, rules] of Object.entries(config.permission)) {
36853
- if (category !== "bash") {
36854
- if (Object.values(rules).some((action) => action === "deny")) {
36855
- hasForeignDeny = true;
36856
- warnWithFallback(logger, `deepagents-cli only models shell-command permissions ([shell].allow_list), so '${category}' deny rules cannot be represented and were skipped.`);
36857
- }
37814
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
37815
+ for (const { pattern, action } of rules) {
37816
+ if (action === "deny") {
37817
+ denyPatterns.push(pattern);
36858
37818
  continue;
36859
37819
  }
36860
- for (const [pattern, action] of Object.entries(rules)) {
36861
- if (action === "deny") {
36862
- denyPatterns.push(pattern);
36863
- continue;
36864
- }
36865
- if (action === "ask") {
36866
- askPatterns.push(pattern);
36867
- continue;
36868
- }
36869
- if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
36870
- requestedAllowAll = true;
36871
- continue;
36872
- }
36873
- const reduced = toExecutableToken(pattern);
36874
- if (!reduced) {
36875
- unmatchablePatterns.push(pattern);
36876
- continue;
36877
- }
36878
- if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
36879
- sentinelPatterns.push(pattern);
36880
- continue;
36881
- }
36882
- if (reduced.widened) widenedPatterns.push(pattern);
36883
- allowed.push(reduced.token);
37820
+ if (action === "ask") {
37821
+ askPatterns.push(pattern);
37822
+ continue;
37823
+ }
37824
+ if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
37825
+ requestedAllowAll = true;
37826
+ continue;
37827
+ }
37828
+ const reduced = toExecutableToken(pattern);
37829
+ if (!reduced) {
37830
+ unmatchablePatterns.push(pattern);
37831
+ continue;
37832
+ }
37833
+ if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
37834
+ sentinelPatterns.push(pattern);
37835
+ continue;
36884
37836
  }
37837
+ if (reduced.widened) widened.push({
37838
+ pattern,
37839
+ token: reduced.token
37840
+ });
37841
+ allowed.push(reduced.token);
36885
37842
  }
36886
- const hasDenyRule = hasForeignDeny || denyPatterns.length > 0;
36887
- const allowAll = requestedAllowAll && !hasDenyRule;
36888
- const allowList = allowAll ? [ALLOW_ALL_SENTINEL] : uniq(allowed.toSorted());
37843
+ const hasRestriction = foreignRestrictingCategories.length > 0 || denyPatterns.length > 0 || askPatterns.length > 0;
37844
+ const allowAll = requestedAllowAll && !hasRestriction;
37845
+ const candidates = uniq(allowed.toSorted());
37846
+ const { shadowedAsk, shadowedDeny, unenforcedDeny, withheldTokens, intersectionBudgetExhausted } = partitionRestrictingRules({
37847
+ allowList: candidates,
37848
+ askPatterns,
37849
+ denyPatterns,
37850
+ willWrite
37851
+ });
37852
+ const allowList = allowAll ? [ALLOW_ALL_SENTINEL] : candidates.filter((token) => !withheldTokens.has(token));
37853
+ warnAboutUnwrittenCommandRules({
37854
+ toolLabel: "deepagents-cli",
37855
+ surfaceLabel: "[shell].allow_list",
37856
+ foreignRestrictingCategories,
37857
+ shadowedAllowPatterns: [],
37858
+ ignoredAllToolsAllowPatterns,
37859
+ intersectionBudgetExhausted,
37860
+ logger
37861
+ });
36889
37862
  warnAboutUnwrittenBashRules({
36890
- allowList,
36891
37863
  allowAll,
36892
37864
  requestedAllowAll,
36893
37865
  askPatterns,
36894
37866
  denyPatterns,
36895
- widenedPatterns,
37867
+ foreignRestrictingCategories,
37868
+ shadowedAsk,
37869
+ shadowedDeny,
37870
+ unenforcedDeny,
37871
+ intersectionBudgetExhausted,
37872
+ widenedPatterns: uniq(widened.filter(({ token }) => !withheldTokens.has(token)).map(({ pattern }) => pattern)),
36896
37873
  unmatchablePatterns,
36897
37874
  sentinelPatterns,
36898
37875
  willWrite,
@@ -37192,10 +38169,17 @@ function convertDevinToRulesyncPermissions(params) {
37192
38169
  *
37193
38170
  * rulesync's canonical `permission.bash` patterns map directly: `allow` →
37194
38171
  * `commandAllowlist`, `deny` → `commandDenylist`. Factory Droid has no separate
37195
- * "ask" list (any command not in the allowlist already prompts), so `ask`
37196
- * rules are intentionally dropped. The allow/deny lists only model shell
37197
- * commands, so categories other than `bash` cannot be represented and are
37198
- * skipped (with a warning when they carry `deny` rules, to surface the gap).
38172
+ * "ask" list (any command not in the allowlist already prompts), so `ask` rules
38173
+ * write nothing they only withhold the allow rules they cover, since the
38174
+ * stricter rule wins whatever its width. The all-tools `*` category contributes
38175
+ * its restricting rules too, because a rule written there covers shell commands
38176
+ * as well. They withhold the allow rules they cover the way a `bash` `ask`
38177
+ * does, because a pattern written under `*` need not name a command at all: a
38178
+ * `deny` there is written to `commandDenylist` too, for the case where it *is*
38179
+ * one, but an entry naming no command enforces nothing by itself.
38180
+ * The allow/deny lists only model shell commands, so categories other
38181
+ * than `bash` and `*` cannot be represented and are skipped (with a warning
38182
+ * when they carry `deny` rules, to surface the gap).
37199
38183
  *
37200
38184
  * Factory Droid also has a stronger `commandBlocklist` tier — commands that can
37201
38185
  * never run, not even under full autonomy — plus other security controls
@@ -37312,24 +38296,28 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
37312
38296
  };
37313
38297
  /**
37314
38298
  * Convert rulesync permissions config to Factory Droid allow/deny command lists.
37315
- * Only the `bash` category maps; `ask` rules and non-`bash` categories are
37316
- * dropped (the latter with a warning when they carry `deny` rules).
38299
+ * The `bash` category maps, and so do the restricting rules of the all-tools
38300
+ * `*` category a `deny` written there covers shell commands too, and skipping
38301
+ * it would auto-approve a command the file blocks. Other categories are dropped
38302
+ * (with a warning when they carry `deny` rules).
37317
38303
  */
37318
38304
  function convertRulesyncToFactorydroidPermissions({ config, logger }) {
37319
- const allow = [];
37320
- const deny = [];
37321
- for (const [category, rules] of Object.entries(config.permission)) {
37322
- if (category !== "bash") {
37323
- if (Object.values(rules).some((action) => action === "deny") && logger) logger.warn(`Factory Droid only models shell-command permissions (commandAllowlist/commandDenylist); '${category}' deny rules cannot be represented and were skipped.`);
37324
- continue;
37325
- }
37326
- for (const [pattern, action] of Object.entries(rules)) switch (action) {
37327
- case "allow":
37328
- allow.push(pattern);
37329
- break;
37330
- case "deny": deny.push(pattern);
37331
- }
37332
- }
38305
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
38306
+ const { allow, deny, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
38307
+ rules,
38308
+ writesAllToolsDeny: true
38309
+ });
38310
+ warnAboutUnwrittenCommandRules({
38311
+ toolLabel: "Factory Droid",
38312
+ surfaceLabel: "commandAllowlist/commandDenylist",
38313
+ foreignRestrictingCategories,
38314
+ shadowedAllowPatterns,
38315
+ unenforcedAllToolsDenyPatterns,
38316
+ unenforcedAllToolsAskPatterns,
38317
+ ignoredAllToolsAllowPatterns,
38318
+ intersectionBudgetExhausted,
38319
+ logger
38320
+ });
37333
38321
  return {
37334
38322
  allow,
37335
38323
  deny
@@ -39380,9 +40368,10 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
39380
40368
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
39381
40369
  }
39382
40370
  const parsed = parse(fileContent ?? "{}");
40371
+ const record = isRecord$1(parsed) ? parsed : {};
39383
40372
  const nextJson = {
39384
- ...parsed,
39385
- permission: parsed.permission ?? {}
40373
+ ...record,
40374
+ permission: Object.hasOwn(record, "permission") ? record.permission ?? {} : {}
39386
40375
  };
39387
40376
  return new OpencodePermissions({
39388
40377
  outputRoot,
@@ -39451,7 +40440,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
39451
40440
  }
39452
40441
  validate() {
39453
40442
  try {
39454
- const json = JSON.parse(this.fileContent || "{}");
40443
+ const json = parseJsonc(this.fileContent || "{}");
39455
40444
  const result = OpencodePermissionsConfigSchema.safeParse(json);
39456
40445
  if (!result.success) return {
39457
40446
  success: false,
@@ -43152,9 +44141,13 @@ function warpSettingsDir() {
43152
44141
  * allowlist, `deny` → denylist). Warp matches commands with regular
43153
44142
  * expressions, so patterns are emitted verbatim — author canonical `bash`
43154
44143
  * patterns as regexes when targeting Warp (mirrors the Zed permissions
43155
- * adapter). Warp has no per-command "ask" list, so `ask` rules are dropped; and
43156
- * the command lists only model shell commands, so non-`bash` categories are
43157
- * skipped (with a warning when they carry `deny` rules).
44144
+ * adapter). Warp has no per-command "ask" list, so `ask` rules write nothing
44145
+ * they only withhold the allow rules they cover, since the stricter rule wins
44146
+ * whatever its width. The all-tools `*` category is read for its restricting
44147
+ * rules as well, but those only withhold allows too: writing any denylist
44148
+ * **replaces** Warp's built-in default one, and a `*` pattern need not name a
44149
+ * command at all. Categories other than `bash` and `*` are skipped (with a
44150
+ * warning when they carry `deny` rules).
43158
44151
  *
43159
44152
  * Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy
43160
44153
  * knobs that do not fit the canonical `allow | ask | deny` per-command model:
@@ -43327,25 +44320,267 @@ function mergeIntoDefaultExecutionProfile({ agents, mergedAllow, mergedDeny, exe
43327
44320
  if ((mergedDeny.length > 0 || hasOverrideKeys) && otherProfileIds.length > 0 && logger) logger.warn(`Warp command deny rules and execution_profile override keys were written to the 'default' execution profile only; they are not enforced while another profile (${otherProfileIds.join(", ")}) is active.`);
43328
44321
  }
43329
44322
  /**
43330
- * Convert rulesync permissions config to Warp command allow/deny regex lists.
43331
- * Only the `bash` category maps; `ask` rules and non-`bash` categories are
43332
- * dropped (the latter with a warning when they carry `deny` rules).
44323
+ * Read a `[...]` class starting at its `[` and return the index just past its
44324
+ * `]`, or `undefined` when it cannot be read that simply. Regex class rules
44325
+ * apply: a `]` in the first position is a member rather than the terminator and
44326
+ * a backslash escapes the character after it. A nested `[` gives up: Rust's
44327
+ * `regex` crate — the engine Warp matches with — reads `[a[b]c]` as one class
44328
+ * built by set operations, so stopping at the first `]` would leave `c]` behind
44329
+ * as text the glob then requires. Giving up widens the whole pattern instead,
44330
+ * which is the only safe direction here.
44331
+ */
44332
+ function skipRegexClass(body, start) {
44333
+ let index = start + 1;
44334
+ if (body.charAt(index) === "^") index += 1;
44335
+ if (body.charAt(index) === "]") index += 1;
44336
+ while (index < body.length) {
44337
+ const character = body.charAt(index);
44338
+ if (character === "\\") {
44339
+ index += 2;
44340
+ continue;
44341
+ }
44342
+ if (character === "[") return;
44343
+ if (character === "]") return index + 1;
44344
+ index += 1;
44345
+ }
44346
+ }
44347
+ /**
44348
+ * Read a `(...)` group starting at its `(` and return the index just past its
44349
+ * `)`, or `undefined` when it never closes. Groups nest, so the depth is
44350
+ * counted — but a class inside one is skipped whole, since a `)` written there
44351
+ * is a member rather than a closer.
43333
44352
  */
43334
- function convertRulesyncToWarpPermissions({ config, logger }) {
43335
- const allow = [];
43336
- const deny = [];
43337
- for (const [category, rules] of Object.entries(config.permission)) {
43338
- if (category !== "bash") {
43339
- if (Object.values(rules).some((action) => action === "deny") && logger) logger.warn(`Warp only models shell-command permissions (agent_mode_command_execution_allowlist/denylist); '${category}' deny rules cannot be represented and were skipped.`);
44353
+ function skipRegexGroup(body, start) {
44354
+ let depth = 0;
44355
+ let index = start;
44356
+ while (index < body.length) {
44357
+ const character = body.charAt(index);
44358
+ if (character === "\\") {
44359
+ index += 2;
43340
44360
  continue;
43341
44361
  }
43342
- for (const [pattern, action] of Object.entries(rules)) switch (action) {
43343
- case "allow":
43344
- allow.push(pattern);
43345
- break;
43346
- case "deny": deny.push(pattern);
44362
+ if (character === "[") {
44363
+ const next = skipRegexClass(body, index);
44364
+ if (next === void 0) return;
44365
+ index = next;
44366
+ continue;
44367
+ }
44368
+ if (character === "(") {
44369
+ depth += 1;
44370
+ index += 1;
44371
+ continue;
44372
+ }
44373
+ if (character === ")") {
44374
+ depth -= 1;
44375
+ index += 1;
44376
+ if (depth === 0) return index;
44377
+ continue;
44378
+ }
44379
+ index += 1;
44380
+ }
44381
+ }
44382
+ /**
44383
+ * Read a `{2,3}` quantifier starting at its `{`, or `undefined` when what
44384
+ * follows is not one. The shape is checked rather than scanned to the next `}`,
44385
+ * because a `{` that spells no repetition is a literal to the regex engine —
44386
+ * `{a|b}` is an alternation in braces, and reading it as a quantifier would skip
44387
+ * past the `|` that has to widen the whole pattern.
44388
+ */
44389
+ const QUANTIFIER = /\{\d+(,\d*)?\}/y;
44390
+ function skipQuantifier(body, start) {
44391
+ QUANTIFIER.lastIndex = start;
44392
+ return QUANTIFIER.exec(body) === null ? void 0 : QUANTIFIER.lastIndex;
44393
+ }
44394
+ /**
44395
+ * Read the bracketed construct that opens at `start`, whichever kind it is, and
44396
+ * return the index just past it — or `undefined` when it never closes.
44397
+ */
44398
+ function skipBracketedConstruct(body, start, open) {
44399
+ if (open === "[") return skipRegexClass(body, start);
44400
+ if (open === "(") return skipRegexGroup(body, start);
44401
+ return skipQuantifier(body, start);
44402
+ }
44403
+ /**
44404
+ * Read the tail of a `\\x` / `\\u` escape — the hex run of `\\x20`, or the
44405
+ * braced `\\x{263A}` form — and return the index just past it. Such an escape
44406
+ * spells one character across several, so consuming only its introducer would
44407
+ * leave the hex digits behind as literal atoms and *narrow* the glob, the one
44408
+ * direction the rewrite must never take. Over-consuming only widens, so an
44409
+ * unterminated brace swallows the rest of the pattern.
44410
+ */
44411
+ function skipHexEscapeTail(body, start) {
44412
+ if (body.charAt(start) === "{") {
44413
+ const closing = body.indexOf("}", start);
44414
+ return closing === -1 ? body.length : closing + 1;
44415
+ }
44416
+ let index = start;
44417
+ while (index < body.length && /^[0-9A-Fa-f]$/.test(body.charAt(index))) index += 1;
44418
+ return index;
44419
+ }
44420
+ /**
44421
+ * Read the tail of a `\\p` / `\\P` Unicode-class escape — the braced `\\p{Greek}`
44422
+ * form, or the one-letter `\\pL` shorthand — and return the index just past it.
44423
+ * Like a hex escape, it spells its class across more than one character, so
44424
+ * leaving the shorthand letter behind would narrow the glob.
44425
+ */
44426
+ function skipUnicodeClassTail(body, start) {
44427
+ if (body.charAt(start) === "{") {
44428
+ const closing = body.indexOf("}", start);
44429
+ return closing === -1 ? body.length : closing + 1;
44430
+ }
44431
+ return Math.min(start + 1, body.length);
44432
+ }
44433
+ /**
44434
+ * Read the escape that opens at `start` — the `\\` and whatever it spells — and
44435
+ * say where it ends and which atom it stands for.
44436
+ */
44437
+ function readEscape(body, start) {
44438
+ const escaped = body.charAt(start + 1);
44439
+ if (escaped === "x" || escaped === "u" || escaped === "U") return {
44440
+ next: skipHexEscapeTail(body, start + 2),
44441
+ atom: "*"
44442
+ };
44443
+ if (escaped === "p" || escaped === "P") return {
44444
+ next: skipUnicodeClassTail(body, start + 2),
44445
+ atom: "*"
44446
+ };
44447
+ return {
44448
+ next: start + 2,
44449
+ atom: escapedCharacterWidens(escaped) ? "*" : escaped
44450
+ };
44451
+ }
44452
+ /**
44453
+ * Whether an escaped character has to widen to `*` rather than stand for
44454
+ * itself: a letter or a digit spells a class (`\s`, `\d`, `\w`), a glob
44455
+ * metacharacter would be read as a wildcard or a class by the comparison
44456
+ * instead of as the literal the escape asked for, and an empty string is a
44457
+ * trailing backslash spelling nothing at all.
44458
+ */
44459
+ function escapedCharacterWidens(escaped) {
44460
+ return escaped === "" || /^[A-Za-z0-9*?[\]]$/.test(escaped);
44461
+ }
44462
+ /**
44463
+ * Read one `[...]`, `(...)` or `{...}` construct starting at `start`, saying
44464
+ * where it ends and whether it repeats the atom in front of it (a quantifier)
44465
+ * or is an atom of its own (a class or a group) — or that the whole pattern has
44466
+ * to widen, which is the only safe reading of a construct that never closes and
44467
+ * of one that sets flags for everything after it.
44468
+ */
44469
+ function readBracketedConstruct(body, start, open) {
44470
+ const next = skipBracketedConstruct(body, start, open);
44471
+ if (next === void 0) return "widens-pattern";
44472
+ if (open === "(" && INLINE_FLAG_GROUP_PATTERN.test(body.slice(start, next))) return "widens-pattern";
44473
+ return {
44474
+ next,
44475
+ widensLastAtom: open === "{"
44476
+ };
44477
+ }
44478
+ /**
44479
+ * A group that only sets flags — `(?i)`, `(?im)`, `(?-i)` — as opposed to one
44480
+ * that scopes them to its own body (`(?i:...)`, which widens to `*` like any
44481
+ * other group).
44482
+ */
44483
+ const INLINE_FLAG_GROUP_PATTERN = /^\(\?[A-Za-z]*-?[A-Za-z]*\)$/;
44484
+ /**
44485
+ * Approximate a Warp command pattern as a glob, so restrictions and allow rules
44486
+ * can be compared by `createShadowingRestrictionsTest`.
44487
+ *
44488
+ * Warp matches commands with regular expressions, and a glob reader would
44489
+ * misread the ordinary spellings: `.*` — Warp's catch-all — is a literal dot
44490
+ * followed by a wildcard, `[rf]` is a character class in one language and plain
44491
+ * text in the other, and an unanchored regex covers every command that merely
44492
+ * contains it. The rewrite therefore only ever widens what a pattern covers, so
44493
+ * an inexact reading withholds an allow rather than writing one the config
44494
+ * restricts.
44495
+ *
44496
+ * Widening means a construct is replaced whole rather than character by
44497
+ * character, because the characters inside it are not literals of the command:
44498
+ * a class (`[rf]`), a group (`(sudo )?`), a character escape (`\s`) and a
44499
+ * missing `^`/`$` anchor all become `*`, and a quantifier (`?`, `*`, `+`,
44500
+ * `{2}`) widens the atom in front of it — `git commits?` covers `git commit`,
44501
+ * so its glob has to as well. Both sides of a comparison are widened, so a
44502
+ * pattern that is really a glob still compares sensibly.
44503
+ *
44504
+ * A pattern the walk cannot read as one sequence widens to `*` whole: a
44505
+ * top-level `|` is two patterns rather than one, and a class, group or
44506
+ * quantifier that never closes leaves the rest of the pattern unreadable —
44507
+ * guessing at either could only narrow the result, which is the one direction
44508
+ * this rewrite must never take. An alternation *inside* a group needs no such
44509
+ * treatment: the group it sits in already widens to `*`.
44510
+ */
44511
+ function warpCommandPatternToGlob(pattern) {
44512
+ const anchoredStart = pattern.startsWith("^");
44513
+ const anchoredEnd = pattern.endsWith("$") && !pattern.endsWith("\\$");
44514
+ const body = pattern.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : void 0);
44515
+ const atoms = [];
44516
+ const widenLastAtom = () => {
44517
+ if (atoms.length === 0) {
44518
+ atoms.push("*");
44519
+ return;
43347
44520
  }
44521
+ atoms[atoms.length - 1] = "*";
44522
+ };
44523
+ let index = 0;
44524
+ while (index < body.length) {
44525
+ const character = body.charAt(index);
44526
+ if (character === "|") return "*";
44527
+ if (character === "\\") {
44528
+ const escape = readEscape(body, index);
44529
+ index = escape.next;
44530
+ atoms.push(escape.atom);
44531
+ continue;
44532
+ }
44533
+ if (character === "[" || character === "(" || character === "{") {
44534
+ const read = readBracketedConstruct(body, index, character);
44535
+ if (read === "widens-pattern") return "*";
44536
+ index = read.next;
44537
+ if (read.widensLastAtom) widenLastAtom();
44538
+ else atoms.push("*");
44539
+ continue;
44540
+ }
44541
+ if (character === "*" || character === "+" || character === "?") {
44542
+ index += 1;
44543
+ widenLastAtom();
44544
+ continue;
44545
+ }
44546
+ const codePoint = body.codePointAt(index);
44547
+ const atom = codePoint === void 0 ? character : String.fromCodePoint(codePoint);
44548
+ index += atom.length;
44549
+ atoms.push(atom === "." || ")]}^$".includes(atom) ? "*" : atom);
43348
44550
  }
44551
+ const glob = atoms.join("");
44552
+ return `${anchoredStart ? "" : "*"}${glob}${anchoredEnd ? "" : "*"}`;
44553
+ }
44554
+ /**
44555
+ * Convert rulesync permissions config to Warp command allow/deny regex lists.
44556
+ * The `bash` category maps to both lists. The all-tools `*` category's
44557
+ * restricting rules are read too — a rule written there covers shell commands
44558
+ * as well, and ignoring it would auto-approve a command the file blocks — but
44559
+ * they only *withhold* the allow rules they cover: Warp's denylist is a regex
44560
+ * list that replaces the tool's built-in default one, so writing a pattern
44561
+ * there that may not even name a command would cost more protection than it
44562
+ * adds. Other categories are dropped (with a warning when they carry `deny`
44563
+ * rules).
44564
+ */
44565
+ function convertRulesyncToWarpPermissions({ config, logger }) {
44566
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
44567
+ const { allow, deny, shadowedAllowPatterns, unwrittenDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
44568
+ rules,
44569
+ writesAllToolsDeny: false,
44570
+ normalizePattern: warpCommandPatternToGlob
44571
+ });
44572
+ warnAboutUnwrittenCommandRules({
44573
+ toolLabel: "Warp",
44574
+ surfaceLabel: "agent_mode_command_execution_allowlist/denylist",
44575
+ foreignRestrictingCategories,
44576
+ shadowedAllowPatterns,
44577
+ unwrittenDenyPatterns,
44578
+ unwrittenDenyReason: "Writing any denylist replaces Warp's built-in default one, and a pattern written under '*' need not be a command at all.",
44579
+ unenforcedAllToolsAskPatterns,
44580
+ ignoredAllToolsAllowPatterns,
44581
+ intersectionBudgetExhausted,
44582
+ logger
44583
+ });
43349
44584
  return {
43350
44585
  allow,
43351
44586
  deny
@@ -64630,4 +65865,4 @@ async function importChecksCore(params) {
64630
65865
  //#endregion
64631
65866
  export { SHARED_USER_MANAGED_CONFIG_PATHS as $, MAX_FILE_SIZE as $t, groupSpellingsByCaseFoldedIdentity as A, DEPRECATED_FEATURE_REPLACEMENTS as An, isFileSystemError as At, RulesyncPermissions as B, removeFile as Bt, CLAUDECODE_SKILLS_DIR_PATH as C, RULESYNC_RULES_RELATIVE_DIR_PATH as Cn, createTempDirectory as Ct, FACTORYDROID_DIR as D, parseCommaSeparatedList as Dn, getFileSize as Dt, CODEXCLI_DIR as E, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as En, fileExists as Et, RulesyncSubagentFrontmatterSchema as F, stripControlCharactersKeepingLineFeeds as Fn, pathEscapesRoot as Ft, resolveRulesyncSourceWritePath as G, toPosixPath as Gt, RulesyncIgnore as H, removeTempDirectory as Ht, RulesyncSkill as I, stripHiddenCharacters as In, readFileContent as It, RulesyncCommandFrontmatterSchema as J, ALL_TOOL_TARGETS as Jt, parseJsonc as K, writeFileBuffer as Kt, RulesyncSkillFrontmatterSchema as L, readFileContentOrNull as Lt, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as M, truncateText as Mn, listDirectoryEntryNames as Mt, getLocalSkillDirNames as N, hasDeceptiveHiddenCharacters as Nn, listFilePathsRecursively as Nt, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as O, ALL_FEATURES as On, getHomeDirectory as Ot, RulesyncSubagent as P, stripControlCharacters as Pn, listSubdirectoryNames as Pt, loadYaml as Q, CURATED_RULES_FEATURE_SUBDIR as Qt, RulesyncRule as R, removeDirectory as Rt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as S, RULESYNC_RELATIVE_DIR_PATH as Sn, checkPathTraversal as St, CODEXCLI_BASH_RULES_FILE_NAME as T, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Tn, ensureDir as Tt, RulesyncHooks as U, resolvePath as Ut, RulesyncMcp as V, removeFileStrict as Vt, getRulesyncSourceCandidates as W, runWithDirectoryRollback as Wt, RulesyncCheckFrontmatterSchema as X, PACKAGING_TOOL_TARGETS as Xt, RulesyncCheck as Y, ALL_TOOL_TARGETS_WITH_WILDCARD as Yt, stringifyFrontmatter as Z, ToolTargetSchema as Zt, QWENCODE_DIR as _, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as _n, CLIError as _t, getProcessorRegistryEntry as a, RULESYNC_CONFIG_SCHEMA_URL as an, ConfigFileSchema as at, CLAUDECODE_LOCAL_RULE_FILE_NAME as b, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as bn, assertTreeContainsNoSymlinks as bt, RulesProcessor as c, RULESYNC_HOOKS_FILE_NAME as cn, findControlCharacter as ct, displayWidthOf as d, RULESYNC_IGNORE_RELATIVE_FILE_PATH as dn, WarningCollectingLogger as dt, RULESYNC_AIIGNORE_FILE_NAME as en, SKILL_FILE_NAME as et, shortenToWidth as f, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as fn, fallbackLogger as ft, CommandsProcessor as g, RULESYNC_MCP_SCHEMA_URL as gn, withWarnOnceScope as gt, HooksProcessor as h, RULESYNC_MCP_RELATIVE_FILE_PATH as hn, resetWarnedOnceMessages as ht, inspectInputRoots as i, RULESYNC_CONFIG_RELATIVE_FILE_PATH as in, CONFLICTING_TARGET_PAIRS as it, AUGMENTCODE_DIR as j, formatError as jn, isSymlink as jt, caseFoldIdentity as k, ALL_FEATURES_WITH_WILDCARD as kn, isFileNotFoundError as kt, SubagentsProcessor as l, RULESYNC_HOOKS_LEGACY_FILE_NAME as ln, ConsoleLogger as lt, IgnoreProcessor as m, RULESYNC_MCP_LEGACY_FILE_NAME as mn, withFallbackLoggerTarget as mt, formatSourceLoadFailure as n, RULESYNC_CHECKS_RELATIVE_DIR_PATH as nn, mergeInputRootConfigs as nt, convertFromTool as o, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as on, GITIGNORE_DESTINATION_KEY as ot, McpProcessor as p, RULESYNC_MCP_FILE_NAME as pn, warnOnConflictingFlags as pt, RulesyncCommand as q, writeFileContent as qt, generate as r, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as rn, resolveEffectiveInputRoots as rt, isPackagingToolTarget as s, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as sn, SourceEntrySchema as st, importFromTool as t, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as tn, ConfigResolver as tt, SkillsProcessor as u, RULESYNC_HOOKS_RELATIVE_FILE_PATH as un, JsonLogger as ut, QWENCODE_LOCAL_RULE_FILE_NAME as v, RULESYNC_PERMISSIONS_FILE_NAME as vn, ErrorCodes as vt, ChecksProcessor as w, RULESYNC_SKILLS_RELATIVE_DIR_PATH as wn, directoryExists as wt, CLAUDECODE_MEMORIES_DIR_NAME as x, RULESYNC_PERMISSIONS_SCHEMA_URL as xn, assertWritablePathInsideRoot as xt, CLAUDECODE_DIR as y, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as yn, assertDirectoryIfExists as yt, RulesyncRuleFrontmatterSchema as z, removeDirectoryStrict as zt };
64632
65867
 
64633
- //# sourceMappingURL=import-DijDR24m.js.map
65868
+ //# sourceMappingURL=import-1-jjDDAm.js.map