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.
@@ -10656,11 +10656,30 @@ const CODEXCLI_OVERRIDE_KEYS = [
10656
10656
  ];
10657
10657
  //#endregion
10658
10658
  //#region src/features/shared/shared-config-gateway.ts
10659
+ /**
10660
+ * Rebuild a parsed document without its prototype-pollution keys.
10661
+ *
10662
+ * Every object is rebuilt, not just the ones that are already plain: a literal
10663
+ * `"__proto__"` is assigned with `obj[key] = value` by `jsonc-parser`, which
10664
+ * *replaces the containing object's prototype* instead of adding a key. Such
10665
+ * an object is no longer a plain object, and the injected value is reachable
10666
+ * through it by plain property access while `Object.keys` and `JSON.stringify`
10667
+ * show nothing — so leaving it as it came out of the parser would both hide a
10668
+ * server or permission the file does not state and, at the root, cost the
10669
+ * whole document (see {@link parseSharedConfig}). Rebuilding gives every
10670
+ * object `Object.prototype` back and drops the injected value with the key.
10671
+ *
10672
+ * Dates are the one object the YAML and TOML parsers produce that is not a
10673
+ * mapping, so they are passed through rather than flattened into `{}`.
10674
+ */
10659
10675
  function sanitizeSharedConfigValue(value) {
10660
10676
  if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
10661
- if (!isPlainObject$1(value)) return value;
10677
+ if (value === null || typeof value !== "object" || value instanceof Date) return value;
10662
10678
  const result = {};
10663
- for (const [key, nested] of Object.entries(omitPrototypePollutionKeys(value))) result[key] = sanitizeSharedConfigValue(nested);
10679
+ for (const [key, nested] of Object.entries(value)) {
10680
+ if (isPrototypePollutionKey(key)) continue;
10681
+ result[key] = sanitizeSharedConfigValue(nested);
10682
+ }
10664
10683
  return result;
10665
10684
  }
10666
10685
  /**
@@ -10689,11 +10708,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
10689
10708
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
10690
10709
  }
10691
10710
  if (parsed === void 0 || parsed === null) return {};
10692
- if (!isPlainObject$1(parsed)) {
10711
+ const sanitized = sanitizeSharedConfigValue(parsed);
10712
+ if (!isPlainObject$1(sanitized)) {
10693
10713
  if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
10694
10714
  return {};
10695
10715
  }
10696
- return sanitizeSharedConfigValue(parsed);
10716
+ return sanitized;
10697
10717
  }
10698
10718
  /**
10699
10719
  * Serialize a shared config document. YAML output always ends with exactly one
@@ -10710,6 +10730,571 @@ function stringifySharedConfig({ format, document }) {
10710
10730
  return JSON.stringify(document, null, 2);
10711
10731
  }
10712
10732
  /**
10733
+ * Where the line holding `from` starts and where the line starting at `from`
10734
+ * ends: the nearest `\r` or `\n` in each direction, or the edge of the text.
10735
+ *
10736
+ * Both characters count in both directions, because the JSONC scanner treats
10737
+ * either as a line break. A file written with lone CRs parses without an error
10738
+ * and so reaches these paths, and a comment read only up to the next `\n`
10739
+ * would run past its own line and take the closing brace — or a whole sibling
10740
+ * key — with it.
10741
+ */
10742
+ function endOfLineFrom({ text, from }) {
10743
+ for (let index = from; index < text.length; index += 1) {
10744
+ const character = text[index];
10745
+ if (character === "\n" || character === "\r") return index;
10746
+ }
10747
+ return text.length;
10748
+ }
10749
+ function startOfLineAt({ text, from }) {
10750
+ for (let index = from - 1; index >= 0; index -= 1) {
10751
+ const character = text[index];
10752
+ if (character === "\n" || character === "\r") return index + 1;
10753
+ }
10754
+ return 0;
10755
+ }
10756
+ /**
10757
+ * Read the indentation and line ending a JSONC document already uses, so
10758
+ * inserted properties match the surrounding file instead of imposing the
10759
+ * 2-space `JSON.stringify` shape on a file written with 4 spaces or tabs.
10760
+ *
10761
+ * The root object's first property decides, located through the syntax tree
10762
+ * rather than by scanning for the first indented line: a file opening with a
10763
+ * banner comment indents that comment's continuation lines too (` * ...`
10764
+ * aligns at three columns), and reading the width off one of those would leave
10765
+ * `modify` re-indenting the lines it touches to a width nothing else in the
10766
+ * file uses. A property that does not start its own line — a one-line object,
10767
+ * or an empty one — carries no indent to read, so those fall back to the
10768
+ * 2-space default the whole-document writer emits.
10769
+ */
10770
+ function detectJsoncFormattingOptions({ text, root }) {
10771
+ const eol = "\n";
10772
+ const first = root.children?.[0];
10773
+ const indent = first === void 0 ? "" : text.slice(startOfLineAt({
10774
+ text,
10775
+ from: first.offset
10776
+ }), first.offset);
10777
+ if (indent === "" || indent.length > 8 || !/^[ \t]+$/.test(indent)) return {
10778
+ tabSize: 2,
10779
+ insertSpaces: true,
10780
+ eol
10781
+ };
10782
+ if (indent.includes(" ")) return {
10783
+ tabSize: 2,
10784
+ insertSpaces: false,
10785
+ eol
10786
+ };
10787
+ return {
10788
+ tabSize: indent.length,
10789
+ insertSpaces: true,
10790
+ eol
10791
+ };
10792
+ }
10793
+ /**
10794
+ * Whether the document states a key no edit-based write can be trusted with.
10795
+ *
10796
+ * Two kinds, both answered from the syntax tree because the parsed value no
10797
+ * longer knows about either:
10798
+ *
10799
+ * - A key stated twice. That is legal JSON text which every reader resolves
10800
+ * last-wins, while `modify` edits the *first* occurrence — so an edit-based
10801
+ * write would land on the dead copy and leave the live one saying whatever
10802
+ * it said before. For an owned key that is a silent ownership failure: a
10803
+ * `deny` rulesync just wrote would sit above the `allow` the tool reads.
10804
+ * - `__proto__`, `constructor` or `prototype`. None survives into the parsed
10805
+ * document — a nested one is dropped, a root-level `__proto__` replaces the
10806
+ * root's prototype — so an edit-based write would find no difference to
10807
+ * apply and leave the key in the file.
10808
+ *
10809
+ * The whole-document writer resolves duplicates last-wins and drops pollution
10810
+ * keys, which is what it has always done, so those files go to it.
10811
+ */
10812
+ function statesUneditableKeys(node) {
10813
+ if (node.type === "array") return (node.children ?? []).some((child) => statesUneditableKeys(child));
10814
+ if (node.type !== "object") return false;
10815
+ const seen = /* @__PURE__ */ new Set();
10816
+ for (const property of node.children ?? []) {
10817
+ const key = property.children?.[0]?.value;
10818
+ if (typeof key === "string") {
10819
+ if (seen.has(key) || isPrototypePollutionKey(key)) return true;
10820
+ seen.add(key);
10821
+ }
10822
+ const value = property.children?.[1];
10823
+ if (value !== void 0 && statesUneditableKeys(value)) return true;
10824
+ }
10825
+ return false;
10826
+ }
10827
+ /**
10828
+ * The offset just past the whitespace and comments starting at `from`.
10829
+ */
10830
+ function skipJsoncTrivia({ text, from }) {
10831
+ let index = from;
10832
+ while (index < text.length) {
10833
+ const char = text[index];
10834
+ if (char === " " || char === " " || char === "\n" || char === "\r") {
10835
+ index += 1;
10836
+ continue;
10837
+ }
10838
+ if (char === "/" && text[index + 1] === "/") {
10839
+ index = endOfLineFrom({
10840
+ text,
10841
+ from: index
10842
+ });
10843
+ continue;
10844
+ }
10845
+ if (char === "/" && text[index + 1] === "*") {
10846
+ const commentEnd = text.indexOf("*/", index + 2);
10847
+ index = commentEnd === -1 ? text.length : commentEnd + 2;
10848
+ continue;
10849
+ }
10850
+ break;
10851
+ }
10852
+ return index;
10853
+ }
10854
+ /**
10855
+ * Where the deletion of a property whose text ends at `end` should stop.
10856
+ *
10857
+ * A comment written after the property on its own line is that property's
10858
+ * note — `"stale": {...}, // retired` says something about `stale` and nothing
10859
+ * about the key above it. Leaving it behind would re-attach it to whichever
10860
+ * property now ends that line, so a note about a server rulesync removed would
10861
+ * read as a note about the one before it. A comment with a sibling after it on
10862
+ * the same line is not claimed: it may belong to either.
10863
+ */
10864
+ function endOfRemoval({ text, end }) {
10865
+ const stop = endOfLineFrom({
10866
+ text,
10867
+ from: end
10868
+ });
10869
+ const tail = text.slice(end, stop);
10870
+ return /^[ \t]*\/\/[^\r\n]*$/.test(tail) || /^[ \t]*\/\*(?:[^*]|\*(?!\/))*\*\/[ \t]*$/.test(tail) || /^[ \t]*$/.test(tail) ? stop : end;
10871
+ }
10872
+ /**
10873
+ * Where the deletion of a property ending at `end` should begin.
10874
+ *
10875
+ * A property that has its line to itself is removed with the line: its
10876
+ * indentation and the newline above it would otherwise be left as a blank gap.
10877
+ * A property sharing its line with something else — a sibling, or the object's
10878
+ * own `}` — is removed on its own, because swallowing the newline would splice
10879
+ * whatever follows onto the line above, and a line comment up there would
10880
+ * comment it out: a key rulesync means to write would vanish from the file, or
10881
+ * the closing brace would, leaving the document unparsable.
10882
+ */
10883
+ function startOfRemoval({ text, propertyOffset, end }) {
10884
+ let after = end;
10885
+ while (text[after] === " " || text[after] === " ") after += 1;
10886
+ if (!(after >= text.length || text[after] === "\n" || text[after] === "\r")) return propertyOffset;
10887
+ let start = propertyOffset;
10888
+ while (start > 0 && (text[start - 1] === " " || text[start - 1] === " ")) start -= 1;
10889
+ if (start > 0 && text[start - 1] === "\n") start -= 1;
10890
+ if (start > 0 && text[start - 1] === "\r") start -= 1;
10891
+ return start;
10892
+ }
10893
+ /**
10894
+ * Delete the property at `path` from `text`, taking its own line and its
10895
+ * separating comma but nothing else.
10896
+ *
10897
+ * `modify(..., undefined)` would do this, but the range it deletes runs from
10898
+ * the end of the *previous* property to the end of this one — or, for the
10899
+ * first property of an object, all the way to where the *next* one starts. So
10900
+ * removing one key takes the comments sitting between it and the keys around
10901
+ * it, including the comment describing the key that survives.
10902
+ * Deleting the property's own text instead leaves the comments around it in
10903
+ * place. Only the note that follows the property on its own line goes with it
10904
+ * (see {@link endOfRemoval}); a comment written on the line *above* is left
10905
+ * behind rather than guessed at, which is the direction this whole path errs
10906
+ * in.
10907
+ */
10908
+ function removeJsoncProperty({ text, path }) {
10909
+ const root = (0, jsonc_parser.parseTree)(text, [], { allowTrailingComma: true });
10910
+ const property = root === void 0 ? void 0 : (0, jsonc_parser.findNodeAtLocation)(root, [...path])?.parent;
10911
+ const object = property?.parent;
10912
+ if (property?.type !== "property" || object?.type !== "object") return text;
10913
+ const siblings = object.children ?? [];
10914
+ const edits = [];
10915
+ let end = property.offset + property.length;
10916
+ const afterProperty = skipJsoncTrivia({
10917
+ text,
10918
+ from: end
10919
+ });
10920
+ if (text[afterProperty] === ",") end = afterProperty + 1;
10921
+ else {
10922
+ const previous = siblings[siblings.indexOf(property) - 1];
10923
+ if (previous !== void 0) {
10924
+ const comma = skipJsoncTrivia({
10925
+ text,
10926
+ from: previous.offset + previous.length
10927
+ });
10928
+ if (text[comma] === ",") edits.push({
10929
+ offset: comma,
10930
+ length: 1,
10931
+ content: ""
10932
+ });
10933
+ }
10934
+ }
10935
+ end = endOfRemoval({
10936
+ text,
10937
+ end
10938
+ });
10939
+ const start = startOfRemoval({
10940
+ text,
10941
+ propertyOffset: property.offset,
10942
+ end
10943
+ });
10944
+ edits.push({
10945
+ offset: start,
10946
+ length: end - start,
10947
+ content: ""
10948
+ });
10949
+ return (0, jsonc_parser.applyEdits)(text, edits);
10950
+ }
10951
+ /**
10952
+ * Where the comment written at `from` (past any spaces or tabs) ends, or
10953
+ * `undefined` if what stands there is not a comment.
10954
+ */
10955
+ function endOfNoteAt({ text, from }) {
10956
+ let cursor = from;
10957
+ while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
10958
+ if (text.startsWith("//", cursor)) return endOfLineFrom({
10959
+ text,
10960
+ from: cursor
10961
+ });
10962
+ if (text.startsWith("/*", cursor)) {
10963
+ const closing = text.indexOf("*/", cursor + 2);
10964
+ return closing === -1 ? void 0 : closing + 2;
10965
+ }
10966
+ }
10967
+ /**
10968
+ * Every comment written at `from`, as spans of `text`, each span running from
10969
+ * where the previous one stopped so the whitespace between them is carried
10970
+ * along. A comma is stepped over once (a file may spell one before its note,
10971
+ * or after it) but never collected, because the separator belongs to the
10972
+ * property rather than to its note. So a file that writes a note on each side
10973
+ * of its comma gets both of them back, in order, after the comma: the notes
10974
+ * stay with the key they describe, and the comma keeps the place the file gave
10975
+ * it. The run stops at the first thing that is neither: a newline ends it, so
10976
+ * a comment on the next line is left alone.
10977
+ */
10978
+ function notesAt({ text, from }) {
10979
+ const spans = [];
10980
+ let cursor = from;
10981
+ let steppedOverComma = false;
10982
+ for (;;) {
10983
+ const end = endOfNoteAt({
10984
+ text,
10985
+ from: cursor
10986
+ });
10987
+ if (end !== void 0) {
10988
+ spans.push({
10989
+ start: cursor,
10990
+ end
10991
+ });
10992
+ cursor = end;
10993
+ continue;
10994
+ }
10995
+ if (steppedOverComma) return spans;
10996
+ let comma = cursor;
10997
+ while (text[comma] === " " || text[comma] === " ") comma += 1;
10998
+ if (text[comma] !== ",") return spans;
10999
+ steppedOverComma = true;
11000
+ cursor = comma + 1;
11001
+ }
11002
+ }
11003
+ /**
11004
+ * Detach the notes written at the point where the object at `path` will take a
11005
+ * new key: after its last property (and around the comma a trailing-comma file
11006
+ * spells there), or just inside the `{` when it has no properties yet.
11007
+ *
11008
+ * `modify` computes its insert from exactly that point — in front of a note
11009
+ * written there — so applying the edit unchanged re-emits the note *after* the
11010
+ * key that was just inserted: `"stale": {...} // retired` turns into a note
11011
+ * about a server rulesync has only now written, and `{ /* none yet *\/ }`
11012
+ * turns into a note about the first entry rulesync puts in it. Lifting the
11013
+ * notes out before the insert and putting them back afterwards keeps them
11014
+ * where their author wrote them, matching what {@link endOfRemoval} does on
11015
+ * the way out.
11016
+ *
11017
+ * Returns `undefined` when there is no such note, which is the common case.
11018
+ */
11019
+ function detachTrailingNote({ text, path }) {
11020
+ const root = (0, jsonc_parser.parseTree)(text, [], { allowTrailingComma: true });
11021
+ const object = root === void 0 ? void 0 : (0, jsonc_parser.findNodeAtLocation)(root, [...path]);
11022
+ if (object?.type !== "object") return void 0;
11023
+ const property = object.children?.at(-1);
11024
+ const anchorKey = property?.children?.[0]?.value;
11025
+ if (property !== void 0 && typeof anchorKey !== "string") return void 0;
11026
+ const spans = notesAt({
11027
+ text,
11028
+ from: property === void 0 ? object.offset + 1 : property.offset + property.length
11029
+ });
11030
+ if (spans.length === 0) return void 0;
11031
+ let stripped = text;
11032
+ for (const span of spans.toReversed()) stripped = stripped.slice(0, span.start) + stripped.slice(span.end);
11033
+ return {
11034
+ text: stripped,
11035
+ note: spans.map((span) => text.slice(span.start, span.end)).join(""),
11036
+ anchorKey: typeof anchorKey === "string" ? anchorKey : void 0
11037
+ };
11038
+ }
11039
+ /**
11040
+ * Put a note detached by {@link detachTrailingNote} back where it was: after
11041
+ * the property it describes (behind the comma the insert gave that property),
11042
+ * or just inside the `{` of the object it was written in when there was no
11043
+ * property to describe. Returns `undefined` if that place can no longer be
11044
+ * located, so the caller can fall back to the plain insert rather than drop
11045
+ * the note.
11046
+ */
11047
+ function reattachTrailingNote({ text, path, anchorKey, note }) {
11048
+ const root = (0, jsonc_parser.parseTree)(text, [], { allowTrailingComma: true });
11049
+ const location = anchorKey === void 0 ? [...path] : [...path, anchorKey];
11050
+ const anchor = root === void 0 ? void 0 : (0, jsonc_parser.findNodeAtLocation)(root, location);
11051
+ if (anchor === void 0) return void 0;
11052
+ if (anchorKey === void 0) {
11053
+ if (anchor.type !== "object") return void 0;
11054
+ const brace = anchor.offset + 1;
11055
+ return text.slice(0, brace) + note + text.slice(brace);
11056
+ }
11057
+ let cursor = anchor.offset + anchor.length;
11058
+ while (text[cursor] === " " || text[cursor] === " ") cursor += 1;
11059
+ if (text[cursor] === ",") cursor += 1;
11060
+ return text.slice(0, cursor) + note + text.slice(cursor);
11061
+ }
11062
+ /**
11063
+ * Write `value` at `[...path, key]`, keeping the trailing note of the property
11064
+ * the new key is inserted after (see {@link detachTrailingNote}). Replacing an
11065
+ * existing key needs none of this: `modify` rewrites the value's own span and
11066
+ * leaves every comment where it is.
11067
+ */
11068
+ function insertJsoncProperty({ text, path, key, value, options }) {
11069
+ const write = (source) => (0, jsonc_parser.applyEdits)(source, (0, jsonc_parser.modify)(source, [...path, key], value, options));
11070
+ const detached = detachTrailingNote({
11071
+ text,
11072
+ path
11073
+ });
11074
+ if (detached === void 0) return write(text);
11075
+ return reattachTrailingNote({
11076
+ text: write(detached.text),
11077
+ path,
11078
+ anchorKey: detached.anchorKey,
11079
+ note: detached.note
11080
+ }) ?? write(text);
11081
+ }
11082
+ /**
11083
+ * How much work an edit-based write may cost, as the file's length times the
11084
+ * number of keys that differ.
11085
+ *
11086
+ * Each changed key re-parses the whole file, so a file whose keys nearly all
11087
+ * change costs quadratic work: an 823 KB `opencode.json` whose 6,400 servers
11088
+ * are all replaced took 40 seconds to write as edits, and cloning a
11089
+ * repository that ships such a file is enough to reach that. Past this budget
11090
+ * the file is written whole instead — it loses its comments, the same as a
11091
+ * file rulesync cannot parse does, which is the better of the two outcomes
11092
+ * against a `generate` that looks like it has hung. The limit is under a
11093
+ * second of editing for a replacement and a second or two for an insert,
11094
+ * which parses more; a hand-written config file is orders of magnitude below
11095
+ * either.
11096
+ */
11097
+ const JSONC_EDIT_BUDGET_BYTES = 5e7;
11098
+ /**
11099
+ * How much text the edits of one write may put into the file, all of them
11100
+ * together.
11101
+ *
11102
+ * The budget above charges by the number of keys that differ, which prices an
11103
+ * insert of a whole subtree as one edit — but `modify` re-indents the text it
11104
+ * writes, and it does that in time quadratic in the length of that text: a
11105
+ * 127 KB value takes half a second to write, a 516 KB one 9 seconds, a 1 MB
11106
+ * one 35. The cost is quadratic in the total as well as in each part, because
11107
+ * every edit re-indents against the text the ones before it left, so a limit
11108
+ * on the widest single value would let a handful of values just under it cost
11109
+ * minutes between them. This is a limit on their sum, which holds the
11110
+ * re-indenting to about a second whether it arrives as one value or twenty.
11111
+ * A hand-written config file changes a few hundred bytes at a time.
11112
+ */
11113
+ const JSONC_EDIT_WRITTEN_BYTES = 2e5;
11114
+ /**
11115
+ * How much text one edit writes: the value as `modify` formats it, plus the
11116
+ * indentation that formatting puts in front of every line of it.
11117
+ *
11118
+ * A value written deep in a document is written far wider than it reads on
11119
+ * its own — a 114 KB server list nested 1,200 deep is 36 MB of text once
11120
+ * every line of it carries 2,400 spaces — so measuring the value alone would
11121
+ * miss the whole of what makes that write expensive.
11122
+ */
11123
+ function measureJsoncWrite({ value, depth }) {
11124
+ const formatted = JSON.stringify(value, null, 2) ?? "";
11125
+ let lines = 1;
11126
+ for (let at = formatted.indexOf("\n"); at !== -1; at = formatted.indexOf("\n", at + 1)) lines += 1;
11127
+ return formatted.length + lines * 2 * depth;
11128
+ }
11129
+ /**
11130
+ * How much {@link applyJsoncObjectEdits} would write, deciding each key
11131
+ * exactly the way it does — the two walk together, so a new case in one needs
11132
+ * the same case here. It reports both how many edits there would be and how
11133
+ * much text they would write between them, because the two are budgeted
11134
+ * separately: one stands for the parse each edit costs, the other for the
11135
+ * re-indenting each edit costs. Counting is a walk of the documents alone: no
11136
+ * text is touched, so the budgets above are spent on the write rather than on
11137
+ * finding out how large the write is.
11138
+ */
11139
+ function countJsoncEdits({ base, next, depth }) {
11140
+ let edits = 0;
11141
+ let written = 0;
11142
+ for (const [key, value] of Object.entries(next)) {
11143
+ if (isPrototypePollutionKey(key)) continue;
11144
+ const present = Object.hasOwn(base, key);
11145
+ const previous = present ? base[key] : void 0;
11146
+ if (value === void 0) {
11147
+ if (present) edits += 1;
11148
+ continue;
11149
+ }
11150
+ if (isPlainObject$1(previous) && isPlainObject$1(value)) {
11151
+ const nested = countJsoncEdits({
11152
+ base: previous,
11153
+ next: value,
11154
+ depth: depth + 1
11155
+ });
11156
+ edits += nested.edits;
11157
+ written += nested.written;
11158
+ continue;
11159
+ }
11160
+ if (present && (0, node_util.isDeepStrictEqual)(previous, value)) continue;
11161
+ edits += 1;
11162
+ written += measureJsoncWrite({
11163
+ value,
11164
+ depth: depth + 1
11165
+ });
11166
+ }
11167
+ for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) edits += 1;
11168
+ return {
11169
+ edits,
11170
+ written
11171
+ };
11172
+ }
11173
+ /**
11174
+ * Rewrite `text` so the object at `path` matches `next`, touching only the
11175
+ * spans that actually differ from `base`.
11176
+ *
11177
+ * Each difference is applied on its own — `modify` computes an edit against
11178
+ * the current text and `applyEdits` returns the text with that edit applied,
11179
+ * which is then the input for the next difference, because every edit shifts
11180
+ * the offsets the following ones would have been computed from. Nested objects
11181
+ * present on both sides are recursed into rather than replaced wholesale, so a
11182
+ * one-key change deep in the document leaves its siblings — and the comments
11183
+ * attached to them — byte-identical.
11184
+ *
11185
+ * Every difference re-parses the document, so the work is one parse of the
11186
+ * file per *changed* key rather than one parse overall. A regeneration that
11187
+ * changes nothing costs a single parse, and the files this runs on are config
11188
+ * files, so the shape is left simple rather than batched — with the file
11189
+ * large enough and enough of it changing, the product of the two is what
11190
+ * {@link JSONC_EDIT_BUDGET_BYTES} keeps off this path.
11191
+ */
11192
+ function applyJsoncObjectEdits({ text, base, next, path, options }) {
11193
+ let result = text;
11194
+ for (const [key, value] of Object.entries(next)) {
11195
+ if (isPrototypePollutionKey(key)) continue;
11196
+ const present = Object.hasOwn(base, key);
11197
+ const previous = present ? base[key] : void 0;
11198
+ if (value === void 0) {
11199
+ if (present) result = removeJsoncProperty({
11200
+ text: result,
11201
+ path: [...path, key]
11202
+ });
11203
+ continue;
11204
+ }
11205
+ if (isPlainObject$1(previous) && isPlainObject$1(value)) {
11206
+ result = applyJsoncObjectEdits({
11207
+ text: result,
11208
+ base: previous,
11209
+ next: value,
11210
+ path: [...path, key],
11211
+ options
11212
+ });
11213
+ continue;
11214
+ }
11215
+ if (present) {
11216
+ if ((0, node_util.isDeepStrictEqual)(previous, value)) continue;
11217
+ result = (0, jsonc_parser.applyEdits)(result, (0, jsonc_parser.modify)(result, [...path, key], value, options));
11218
+ continue;
11219
+ }
11220
+ result = insertJsoncProperty({
11221
+ text: result,
11222
+ path,
11223
+ key,
11224
+ value,
11225
+ options
11226
+ });
11227
+ }
11228
+ for (const key of Object.keys(base)) if (!Object.hasOwn(next, key)) result = removeJsoncProperty({
11229
+ text: result,
11230
+ path: [...path, key]
11231
+ });
11232
+ return result;
11233
+ }
11234
+ /**
11235
+ * Serialize a document back over the file it was parsed from.
11236
+ *
11237
+ * For every format but JSONC this is {@link stringifySharedConfig}: those
11238
+ * files carry no comments, so re-serializing loses nothing. A JSONC file does
11239
+ * carry comments — `.vscode/settings.json` and `opencode.json` are hand-edited
11240
+ * far more often than they are generated — and re-serializing would delete
11241
+ * every one of them, along with the author's blank lines and key order. So a
11242
+ * JSONC document is written back as a set of edits against the existing text:
11243
+ * regions the merge did not change stay byte-identical, and a regeneration
11244
+ * that changes nothing leaves the file untouched.
11245
+ *
11246
+ * The whole-document writer is still used when there is nothing to preserve or
11247
+ * nothing to edit against:
11248
+ *
11249
+ * - an empty (or whitespace-only) file, which has no comments to keep;
11250
+ * - a file that does not parse, or whose root is not an object — editing it
11251
+ * would mean guessing at the author's intent, and the callers that reach
11252
+ * here have already decided (via `invalidRootPolicy`) that such a file is
11253
+ * replaced;
11254
+ * - a file stating the same key twice, or using `__proto__`, `constructor` or
11255
+ * `prototype` as a key (see {@link statesUneditableKeys});
11256
+ * - a file so large, with so much of it changing, that editing it key by key
11257
+ * would take longer than a user would wait (see
11258
+ * {@link JSONC_EDIT_BUDGET_BYTES}), or changed keys that write more new text
11259
+ * between them than re-indenting can afford (see
11260
+ * {@link JSONC_EDIT_WRITTEN_BYTES});
11261
+ * - a file the editor itself refuses, which it answers with an exception
11262
+ * rather than a result.
11263
+ */
11264
+ function serializeSharedConfig({ format, document, existingContent }) {
11265
+ const whole = stringifySharedConfig({
11266
+ format,
11267
+ document
11268
+ });
11269
+ if (format !== "jsonc" || existingContent.trim() === "") return whole;
11270
+ try {
11271
+ const errors = [];
11272
+ const root = (0, jsonc_parser.parseTree)(existingContent, errors, { allowTrailingComma: true });
11273
+ if (root === void 0 || errors.length > 0 || root.type !== "object" || statesUneditableKeys(root)) return whole;
11274
+ const base = sanitizeSharedConfigValue((0, jsonc_parser.getNodeValue)(root));
11275
+ if (!isPlainObject$1(base)) return whole;
11276
+ const span = Math.max(existingContent.length, whole.length);
11277
+ const cost = countJsoncEdits({
11278
+ base,
11279
+ next: document,
11280
+ depth: 0
11281
+ });
11282
+ if (cost.written > JSONC_EDIT_WRITTEN_BYTES || cost.edits * span > JSONC_EDIT_BUDGET_BYTES) return whole;
11283
+ return applyJsoncObjectEdits({
11284
+ text: existingContent,
11285
+ base,
11286
+ next: document,
11287
+ path: [],
11288
+ options: { formattingOptions: detectJsoncFormattingOptions({
11289
+ text: existingContent,
11290
+ root
11291
+ }) }
11292
+ });
11293
+ } catch {
11294
+ return whole;
11295
+ }
11296
+ }
11297
+ /**
10713
11298
  * Shallow merge: every top-level key in `patch` replaces the base key
10714
11299
  * wholesale; all other base keys are preserved. The policy for a feature that
10715
11300
  * owns a fixed set of top-level keys.
@@ -10732,7 +11317,7 @@ function mergeSharedConfigShallow({ base, patch }) {
10732
11317
  function mergeSharedConfigDeep({ base, patch }) {
10733
11318
  const result = { ...base };
10734
11319
  for (const [key, patchValue] of Object.entries(patch)) {
10735
- if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
11320
+ if (isPrototypePollutionKey(key)) continue;
10736
11321
  if (patchValue === void 0) {
10737
11322
  delete result[key];
10738
11323
  continue;
@@ -11314,7 +11899,10 @@ const SHARED_CONFIG_OWNERSHIP = {
11314
11899
  /**
11315
11900
  * Execute a feature's declared write to a gateway-managed shared file: parse
11316
11901
  * the existing content, merge the patch under the feature's declared policy,
11317
- * and serialize. Throws when the file or feature is undeclared, when a
11902
+ * and serialize it back over the existing content (see
11903
+ * {@link serializeSharedConfig}, which keeps a JSONC file's comments and
11904
+ * formatting outside the spans the merge actually changed). Throws when the
11905
+ * file or feature is undeclared, when a
11318
11906
  * `replace-owned-keys` patch strays outside its owned keys, or when the
11319
11907
  * feature's policy is `custom` (those calls go to the named policy function
11320
11908
  * instead).
@@ -11340,9 +11928,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11340
11928
  patch
11341
11929
  });
11342
11930
  for (const [key, value] of Object.entries(patch)) if (value === void 0) delete document[key];
11343
- return stringifySharedConfig({
11931
+ return serializeSharedConfig({
11344
11932
  format: declaration.format,
11345
- document
11933
+ document,
11934
+ existingContent
11346
11935
  });
11347
11936
  }
11348
11937
  const merged = mergeSharedConfigDeep({
@@ -11350,9 +11939,10 @@ function applySharedConfigPatch({ fileKey, feature, existingContent, patch, file
11350
11939
  patch
11351
11940
  });
11352
11941
  for (const key of policy.replaceKeys ?? []) if (patch[key] !== void 0) merged[key] = sanitizeSharedConfigValue(patch[key]);
11353
- return stringifySharedConfig({
11942
+ return serializeSharedConfig({
11354
11943
  format: declaration.format,
11355
- document: merged
11944
+ document: merged,
11945
+ existingContent
11356
11946
  });
11357
11947
  }
11358
11948
  const READ_TOOL_NAME = "Read";
@@ -26754,7 +27344,7 @@ var CopilotMcp = class CopilotMcp extends ToolMcp {
26754
27344
  json;
26755
27345
  constructor(params) {
26756
27346
  super(params);
26757
- this.json = this.fileContent !== void 0 ? JSON.parse(this.fileContent) : {};
27347
+ this.json = this.fileContent !== void 0 ? parseJsonc$8(this.fileContent) : {};
26758
27348
  }
26759
27349
  getJson() {
26760
27350
  return this.json;
@@ -28977,7 +29567,7 @@ var KiloMcp = class KiloMcp extends ToolMcp {
28977
29567
  }, null, 2) });
28978
29568
  }
28979
29569
  validate() {
28980
- const json = JSON.parse(this.fileContent || "{}");
29570
+ const json = parseJsonc$8(this.fileContent || "{}");
28981
29571
  const result = KiloConfigSchema.safeParse(json);
28982
29572
  if (!result.success) return {
28983
29573
  success: false,
@@ -30053,7 +30643,7 @@ var OpencodeMcp = class OpencodeMcp extends ToolMcp {
30053
30643
  }, null, 2) });
30054
30644
  }
30055
30645
  validate() {
30056
- const json = JSON.parse(this.fileContent || "{}");
30646
+ const json = parseJsonc$8(this.fileContent || "{}");
30057
30647
  const result = OpencodeConfigSchema.safeParse(json);
30058
30648
  if (!result.success) return {
30059
30649
  success: false,
@@ -33507,41 +34097,123 @@ function matchesGlobStep(step, character) {
33507
34097
  const admitted = step.members.has(character) || step.ranges.some(([low, high]) => code >= low && code <= high);
33508
34098
  return step.negated ? !admitted : admitted;
33509
34099
  }
34100
+ /** Whether two single-character steps can both match one same character. */
34101
+ function stepsShareACharacter(left, right) {
34102
+ if (left.kind === "any" || right.kind === "any") return true;
34103
+ if (left.kind === "literal" && right.kind === "literal") return left.character === right.character;
34104
+ if (left.kind === "literal") return matchesGlobStep(right, left.character);
34105
+ if (right.kind === "literal") return matchesGlobStep(left, right.character);
34106
+ return true;
34107
+ }
34108
+ /** Whether every step from `index` on can match the empty string. */
34109
+ function isAllStars(steps, index) {
34110
+ for (let step = index; step < steps.length; step++) if (steps[step]?.kind !== "star") return false;
34111
+ return true;
34112
+ }
34113
+ /**
34114
+ * The most work one intersection walk will do, counted in cells times the cost
34115
+ * of one. Past it the two patterns are reported as intersecting without being
34116
+ * walked: the product of two lengths grows quadratically, and a pattern long
34117
+ * enough to reach this is pathological rather than a command anybody typed.
34118
+ * Answering `true` withholds an `allow`, which is the direction that fails
34119
+ * closed.
34120
+ */
34121
+ const MAX_INTERSECTION_CELLS = 1e6;
34122
+ /**
34123
+ * The most work a whole run of comparisons will do. A caller holding R
34124
+ * restrictions and A allow rules asks R x A times, and a per-pair cap alone
34125
+ * bounds none of that: a hundred restrictions against a hundred allow rules,
34126
+ * each pattern just under the per-pair cap, is ten thousand affordable walks
34127
+ * that together take minutes. The shared budget is spent down across the run
34128
+ * and, once it is gone, every remaining pair is reported as intersecting —
34129
+ * again the direction that withholds an `allow` rather than writing one.
34130
+ */
34131
+ const MAX_TOTAL_INTERSECTION_CELLS = 1e7;
34132
+ /**
34133
+ * What a pair costs on top of the cells it walks: the call itself, sizing and
34134
+ * filling the two rows the table is held in, and collecting the answer.
34135
+ * Charging only cells would leave the *number* of pairs unbounded — a pair of
34136
+ * one-step patterns walks a single cell, so n short restrictions against n
34137
+ * short allow rules is n squared comparisons that never spend the budget down
34138
+ * however many of them there are. Charging a floor per pair puts pair count and
34139
+ * walk length on the same exhaustible resource.
34140
+ *
34141
+ * For the short patterns of an ordinary config the floor is the whole charge,
34142
+ * which lowers how many pairs a run compares from around a million to about
34143
+ * 150,000 — roughly 400 restrictions against 400 allow rules. A config past
34144
+ * that line withholds every allow it has not yet compared, the same fail-closed
34145
+ * answer exhaustion gives everywhere else.
34146
+ */
34147
+ const INTERSECTION_PAIR_COST = 64;
34148
+ /**
34149
+ * A budget for one caller's run of comparisons. Hand the same one to every
34150
+ * `parsedGlobsIntersect` call that belongs together — one adapter reading one
34151
+ * config — so the run as a whole stays bounded rather than only each pair in
34152
+ * it.
34153
+ */
34154
+ function createIntersectionBudget(remaining = MAX_TOTAL_INTERSECTION_CELLS) {
34155
+ return { remaining };
34156
+ }
33510
34157
  /**
33511
- * Parse `glob` once and return a predicate that walks it, for a caller that
33512
- * tests the same glob against a whole list of names.
34158
+ * Parse `glob` into the form `parsedGlobsIntersect` walks. A caller comparing
34159
+ * the same pattern against a whole list parses it once and reuses the result.
33513
34160
  */
33514
- function compileGlob(glob) {
34161
+ function parseGlobPattern(glob) {
33515
34162
  const steps = parseGlob(glob);
33516
- return (value) => matchesParsedGlob(steps, value);
34163
+ return {
34164
+ steps,
34165
+ maxRanges: maxRangeCount(steps)
34166
+ };
33517
34167
  }
33518
- function matchesParsedGlob(steps, value) {
33519
- const characters = [...value];
33520
- let stepIndex = 0;
33521
- let characterIndex = 0;
33522
- let starStepIndex = -1;
33523
- let starCharacterIndex = 0;
33524
- while (characterIndex < characters.length) {
33525
- const step = steps[stepIndex];
33526
- if (step?.kind === "star") {
33527
- starStepIndex = stepIndex;
33528
- starCharacterIndex = characterIndex;
33529
- stepIndex += 1;
33530
- continue;
34168
+ /**
34169
+ * What one cell can cost, as a multiplier on the cell count. A literal met by a
34170
+ * `[a-z...]` class walks that class's ranges, so a single class carrying
34171
+ * thousands of them turns a walk that looks affordable by cell count alone into
34172
+ * a quadratic one — which is why the budget is spent on cells times this rather
34173
+ * than on cells.
34174
+ */
34175
+ function maxRangeCount(steps) {
34176
+ let most = 0;
34177
+ for (const step of steps) if (step.kind === "class" && step.ranges.length > most) most = step.ranges.length;
34178
+ return most;
34179
+ }
34180
+ /**
34181
+ * `globsIntersect` for two globs already parsed, optionally spending a budget
34182
+ * shared with the rest of the caller's run — see `createIntersectionBudget`.
34183
+ * Once that budget is exhausted every further pair answers `true` without being
34184
+ * walked, so a caller reading the answer as a reason to restrict stays on the
34185
+ * safe side.
34186
+ */
34187
+ function parsedGlobsIntersect(left, right, budget) {
34188
+ const [rows, columns] = left.steps.length >= right.steps.length ? [left.steps, right.steps] : [right.steps, left.steps];
34189
+ const cellCost = 1 + left.maxRanges + right.maxRanges;
34190
+ const cost = rows.length * columns.length * cellCost;
34191
+ if (cost > MAX_INTERSECTION_CELLS) return true;
34192
+ if (budget !== void 0) {
34193
+ const charge = cost + INTERSECTION_PAIR_COST;
34194
+ if (charge > budget.remaining) {
34195
+ budget.remaining = 0;
34196
+ return true;
33531
34197
  }
33532
- if (step !== void 0 && matchesGlobStep(step, characters[characterIndex] ?? "")) {
33533
- stepIndex += 1;
33534
- characterIndex += 1;
33535
- continue;
34198
+ budget.remaining -= charge;
34199
+ }
34200
+ let next = Array.from({ length: columns.length + 1 }, (_, j) => isAllStars(columns, j));
34201
+ for (let i = rows.length - 1; i >= 0; i--) {
34202
+ const row = Array.from({ length: columns.length + 1 }, () => false);
34203
+ row[columns.length] = isAllStars(rows, i);
34204
+ for (let j = columns.length - 1; j >= 0; j--) {
34205
+ const rowStep = rows[i];
34206
+ const columnStep = columns[j];
34207
+ if (rowStep === void 0 || columnStep === void 0) continue;
34208
+ if (rowStep.kind === "star" || columnStep.kind === "star") {
34209
+ row[j] = (next[j] ?? false) || (row[j + 1] ?? false);
34210
+ continue;
34211
+ }
34212
+ row[j] = stepsShareACharacter(rowStep, columnStep) && (next[j + 1] ?? false);
33536
34213
  }
33537
- if (starStepIndex < 0) return false;
33538
- starCharacterIndex += 1;
33539
- stepIndex = starStepIndex + 1;
33540
- characterIndex = starCharacterIndex;
34214
+ next = row;
33541
34215
  }
33542
- let remaining = stepIndex;
33543
- while (steps[remaining]?.kind === "star") remaining += 1;
33544
- return remaining === steps.length;
34216
+ return next[0] ?? false;
33545
34217
  }
33546
34218
  //#endregion
33547
34219
  //#region src/features/permissions/augmentcode-permissions.ts
@@ -34004,6 +34676,216 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
34004
34676
  }
34005
34677
  return { permission };
34006
34678
  }
34679
+ /**
34680
+ * Collect the canonical rules that govern shell commands, for the adapters
34681
+ * whose tool models commands and nothing else.
34682
+ *
34683
+ * The `bash` category contributes every rule. The all-tools `*` category
34684
+ * contributes its **restricting** rules — `deny` and `ask` — because a rule
34685
+ * written there covers shell commands too, and dropping it inverts the
34686
+ * author's intent: with `{"*": {"rm *": "deny"}, "bash": {"rm *": "allow"}}`,
34687
+ * an adapter that reads only `bash` auto-approves the very command the file
34688
+ * denies.
34689
+ *
34690
+ * Its `allow` rules are deliberately **not** contributed. A pattern under `*`
34691
+ * need not be a command at all — `secrets/**` under `*` denies a path — and
34692
+ * carrying it in the restricting direction only over-restricts, while carrying
34693
+ * it in the permissive direction would grant something the author never said
34694
+ * about commands. Both directions therefore fail closed.
34695
+ */
34696
+ function collectShellCommandRules(permission) {
34697
+ const rules = [];
34698
+ const foreignRestrictingCategories = [];
34699
+ const ignoredAllToolsAllowPatterns = [];
34700
+ for (const [category, categoryRules] of Object.entries(permission)) {
34701
+ if (category === "bash") {
34702
+ for (const [pattern, action] of Object.entries(categoryRules)) rules.push({
34703
+ pattern,
34704
+ action,
34705
+ fromAllToolsCategory: false
34706
+ });
34707
+ continue;
34708
+ }
34709
+ if (category === "*") {
34710
+ for (const [pattern, action] of Object.entries(categoryRules)) {
34711
+ if (action === "allow") {
34712
+ ignoredAllToolsAllowPatterns.push(pattern);
34713
+ continue;
34714
+ }
34715
+ rules.push({
34716
+ pattern,
34717
+ action,
34718
+ fromAllToolsCategory: true
34719
+ });
34720
+ }
34721
+ continue;
34722
+ }
34723
+ if (Object.values(categoryRules).some((action) => action === "deny" || action === "ask")) foreignRestrictingCategories.push(category);
34724
+ }
34725
+ return {
34726
+ rules,
34727
+ foreignRestrictingCategories,
34728
+ ignoredAllToolsAllowPatterns
34729
+ };
34730
+ }
34731
+ /**
34732
+ * Build the test an adapter applies to an `allow` pattern before writing it:
34733
+ * which restrictions it cannot write name some of the same commands? The
34734
+ * answer is the list of those restrictions — empty when the `allow` may be
34735
+ * written — so a caller can report both the allow rules it withheld and the
34736
+ * restrictions that withheld nothing.
34737
+ *
34738
+ * Canonically the stricter rule wins **whatever its width** — rulesync collapses
34739
+ * colliding rules as `deny > ask > allow` — so the two patterns are compared by
34740
+ * asking whether any one command matches both. Width does not enter into it: an
34741
+ * `ask` on `*` overlaps an allowed `git *`, an `ask` on `npm publish` overlaps
34742
+ * an allowed `npm *`, and an `ask` on `* --force` overlaps an allowed `git *`
34743
+ * on every `git ... --force` command even though neither pattern covers the
34744
+ * other's spelling. Comparing only identical spellings would let the most
34745
+ * ordinary catch-all (`{"*": {"*": "ask"}}`) disappear without a word.
34746
+ *
34747
+ * Identical spellings are still compared as strings first, as a shortcut past
34748
+ * the walk for the commonest case.
34749
+ *
34750
+ * `normalizePattern` rewrites a pattern written in the tool's own language into
34751
+ * the widest glob it could stand for, for a tool whose patterns are not globs.
34752
+ * It reaches the `bash` rules and the `allow` rules, which is where such a
34753
+ * pattern is written; an all-tools `*` pattern is canonical — it is read by
34754
+ * every tool, so it is a glob already — and is compared as it stands. The
34755
+ * rewrite must only ever widen what a pattern covers, so an inexact reading
34756
+ * withholds an allow rather than writing one the config restricts — see
34757
+ * `warpCommandPatternToGlob`.
34758
+ */
34759
+ function createShadowingRestrictionsTest(restrictions, { normalizePattern = (pattern) => pattern, budget = createIntersectionBudget() } = {}) {
34760
+ const normalized = restrictions.map(({ pattern, fromAllToolsCategory }) => ({
34761
+ pattern,
34762
+ glob: parseGlobPattern(fromAllToolsCategory ? pattern : normalizePattern(pattern))
34763
+ }));
34764
+ return (allowPattern) => {
34765
+ if (budget.remaining === 0) return normalized.map(({ pattern }) => pattern);
34766
+ const allowGlob = parseGlobPattern(normalizePattern(allowPattern));
34767
+ return normalized.filter(({ pattern, glob }) => pattern === allowPattern || parsedGlobsIntersect(glob, allowGlob, budget)).map(({ pattern }) => pattern);
34768
+ };
34769
+ }
34770
+ /**
34771
+ * Which of the given all-tools `*` restrictions look like they may not name a
34772
+ * command at all — the question a `deny` and an `ask` written there both raise.
34773
+ *
34774
+ * "Withheld no allow rule" alone does not answer it: a config with no `allow`
34775
+ * rules has nothing to withhold, and a pattern the author also wrote under
34776
+ * `bash` is a command on their own word. Both are excluded, so what remains is
34777
+ * a `*` pattern that had allow rules to overlap, overlapped none of them, and
34778
+ * is claimed as a command nowhere else — the shape `secrets/**` has.
34779
+ *
34780
+ * A `bash` restriction never belongs here: it names a command by construction,
34781
+ * so overlapping no allow rule says nothing is wrong with it.
34782
+ */
34783
+ function collectUnenforcedAllToolsPatterns({ rules, allToolsPatterns, withholdingPatterns }) {
34784
+ if (!rules.some(({ action }) => action === "allow")) return [];
34785
+ const shellPatterns = new Set(rules.filter(({ fromAllToolsCategory }) => !fromAllToolsCategory).map(({ pattern }) => pattern));
34786
+ return (0, es_toolkit.uniq)(allToolsPatterns).filter((pattern) => !withholdingPatterns.has(pattern) && !shellPatterns.has(pattern));
34787
+ }
34788
+ /**
34789
+ * Split shell-command rules into the allow and deny lists of a tool that models
34790
+ * commands with those two tiers and nothing else.
34791
+ *
34792
+ * `ask` has no list of its own — such a tool already prompts for whatever it
34793
+ * does not auto-approve, so an `ask` rule is satisfied by writing nothing. It
34794
+ * still has to *withhold* the `allow` rules it covers, though: the canonical
34795
+ * order is `deny > ask > allow`, so auto-approving a command the file also asks
34796
+ * about would answer the prompt the author wanted.
34797
+ *
34798
+ * `writesAllToolsDeny` says whether the tool's denylist can carry a pattern
34799
+ * from the all-tools `*` category. Warp's cannot: it matches commands with
34800
+ * regular expressions rather than globs, and writing any denylist **replaces**
34801
+ * Warp's built-in default one, so an inert `secrets/**` entry there would trade
34802
+ * the tool's own protection for a rule that matches no command. Where the deny
34803
+ * cannot be written it withholds the allow rules it covers instead, which
34804
+ * restricts in the same direction without touching the denylist.
34805
+ *
34806
+ * A `bash` deny withholds nothing: it names a command by construction, so the
34807
+ * denylist entry enforces it wherever the tool's deny-beats-allow order applies,
34808
+ * and a narrow deny keeps carving an exception out of a wider allow (`git *`
34809
+ * allowed, `git push *` denied). An all-tools `*` deny withholds all the same,
34810
+ * even where it is written: a pattern under `*` need not name a command —
34811
+ * `secrets/**` there denies a path — so as a denylist entry it may match nothing
34812
+ * at all, and leaving an overlapping allow beside it would auto-approve the very
34813
+ * commands the author meant to stop. Over-restricting a `*` deny that *was* a
34814
+ * command pattern is reported; failing open would not be.
34815
+ *
34816
+ * `normalizePattern` is handed to `createShadowingRestrictionsTest` for a tool whose
34817
+ * patterns are not globs.
34818
+ */
34819
+ function partitionCommandRules({ rules, writesAllToolsDeny, normalizePattern }) {
34820
+ const deny = [];
34821
+ const unwrittenDenyPatterns = [];
34822
+ const restrictions = [];
34823
+ const writtenAllToolsDenyPatterns = [];
34824
+ const allToolsAskPatterns = [];
34825
+ for (const rule of rules) {
34826
+ const { pattern, action, fromAllToolsCategory } = rule;
34827
+ if (action === "allow") continue;
34828
+ if (action !== "deny") {
34829
+ restrictions.push(rule);
34830
+ if (fromAllToolsCategory) allToolsAskPatterns.push(pattern);
34831
+ continue;
34832
+ }
34833
+ if (writesAllToolsDeny || !fromAllToolsCategory) {
34834
+ deny.push(pattern);
34835
+ if (fromAllToolsCategory) writtenAllToolsDenyPatterns.push(pattern);
34836
+ } else unwrittenDenyPatterns.push(pattern);
34837
+ if (fromAllToolsCategory) restrictions.push(rule);
34838
+ }
34839
+ const budget = createIntersectionBudget();
34840
+ const shadowingRestrictions = createShadowingRestrictionsTest(restrictions, {
34841
+ normalizePattern,
34842
+ budget
34843
+ });
34844
+ const allow = [];
34845
+ const shadowedAllowPatterns = [];
34846
+ const withholdingPatterns = /* @__PURE__ */ new Set();
34847
+ for (const { pattern, action } of rules) {
34848
+ if (action !== "allow") continue;
34849
+ const shadowing = shadowingRestrictions(pattern);
34850
+ if (shadowing.length > 0) {
34851
+ shadowedAllowPatterns.push(pattern);
34852
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
34853
+ continue;
34854
+ }
34855
+ allow.push(pattern);
34856
+ }
34857
+ return {
34858
+ allow,
34859
+ deny,
34860
+ shadowedAllowPatterns,
34861
+ unwrittenDenyPatterns,
34862
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
34863
+ rules,
34864
+ allToolsPatterns: writtenAllToolsDenyPatterns,
34865
+ withholdingPatterns
34866
+ }),
34867
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
34868
+ rules,
34869
+ allToolsPatterns: allToolsAskPatterns,
34870
+ withholdingPatterns
34871
+ }),
34872
+ intersectionBudgetExhausted: budget.remaining === 0
34873
+ };
34874
+ }
34875
+ /**
34876
+ * Report, for one command-only tool, every canonical rule its two lists could
34877
+ * not carry. Every command-only adapter shares this reporting, so a rule
34878
+ * dropped in one is worded the same way in all.
34879
+ */
34880
+ function warnAboutUnwrittenCommandRules({ toolLabel, surfaceLabel, foreignRestrictingCategories, shadowedAllowPatterns, unwrittenDenyPatterns = [], unwrittenDenyReason, unenforcedAllToolsDenyPatterns = [], unenforcedAllToolsAskPatterns = [], ignoredAllToolsAllowPatterns = [], intersectionBudgetExhausted = false, logger }) {
34881
+ 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.`);
34882
+ 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.`);
34883
+ 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.`);
34884
+ 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.`);
34885
+ 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.`);
34886
+ 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.`);
34887
+ 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.`);
34888
+ }
34007
34889
  //#endregion
34008
34890
  //#region src/features/permissions/claudecode-permissions.ts
34009
34891
  /**
@@ -34934,8 +35816,10 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
34934
35816
  const ask = [];
34935
35817
  const deny = [];
34936
35818
  const actionByEntry = /* @__PURE__ */ new Map();
35819
+ const allToolsPatterns = [];
34937
35820
  for (const [category, rules] of Object.entries(config.permission)) {
34938
35821
  const claudeToolName = toClaudeToolName(category);
35822
+ if (category === "*") allToolsPatterns.push(...Object.keys(rules));
34939
35823
  for (const [pattern, action] of Object.entries(rules)) {
34940
35824
  const entry = buildClaudePermissionEntry(claudeToolName, pattern);
34941
35825
  const previous = actionByEntry.get(entry);
@@ -34952,6 +35836,7 @@ function convertRulesyncToClaudePermissions({ config, logger }) {
34952
35836
  }
34953
35837
  }
34954
35838
  }
35839
+ 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.`);
34955
35840
  return {
34956
35841
  allow,
34957
35842
  ask,
@@ -34996,34 +35881,64 @@ const ClineCommandPermissionsSchema = zod_mini.z.looseObject({
34996
35881
  });
34997
35882
  /**
34998
35883
  * Translate rulesync permission categories into Cline allow/deny command lists.
34999
- * Non-bash categories and `ask` rules are tracked separately so a single
35000
- * translation notice can be surfaced by the caller.
35884
+ * The `bash` category maps, and so do the restricting rules of the all-tools
35885
+ * `*` category a rule written there covers shell commands too. Other
35886
+ * categories and `ask` rules are tracked separately so a single translation
35887
+ * notice can be surfaced by the caller.
35001
35888
  */
35002
35889
  function translateClinePermissions(permission) {
35003
35890
  const allow = [];
35004
35891
  const deny = [];
35005
- const droppedCategories = [];
35006
35892
  const translatedAskPatterns = [];
35007
- for (const [category, rules] of Object.entries(permission)) {
35008
- if (category !== "bash") {
35009
- droppedCategories.push(category);
35010
- continue;
35011
- }
35012
- for (const [pattern, action] of Object.entries(rules)) {
35013
- if (action === "ask") {
35014
- translatedAskPatterns.push(pattern);
35015
- deny.push(pattern);
35893
+ const shadowedAllowPatterns = [];
35894
+ const droppedCategories = Object.keys(permission).filter((category) => category !== "bash" && category !== "*");
35895
+ const { rules, ignoredAllToolsAllowPatterns } = collectShellCommandRules(permission);
35896
+ const budget = createIntersectionBudget();
35897
+ const shadowingRestrictions = createShadowingRestrictionsTest(rules.filter(({ fromAllToolsCategory }) => fromAllToolsCategory), { budget });
35898
+ const allToolsDenyPatterns = [];
35899
+ const allToolsAskPatterns = [];
35900
+ const withholdingPatterns = /* @__PURE__ */ new Set();
35901
+ for (const { pattern, action, fromAllToolsCategory } of rules) {
35902
+ if (action === "ask") {
35903
+ if (fromAllToolsCategory) {
35904
+ allToolsAskPatterns.push(pattern);
35016
35905
  continue;
35017
35906
  }
35018
- if (action === "allow") allow.push(pattern);
35019
- else if (action === "deny") deny.push(pattern);
35907
+ translatedAskPatterns.push(pattern);
35908
+ deny.push(pattern);
35909
+ continue;
35910
+ }
35911
+ if (action === "deny") {
35912
+ deny.push(pattern);
35913
+ if (fromAllToolsCategory) allToolsDenyPatterns.push(pattern);
35914
+ continue;
35915
+ }
35916
+ const shadowing = shadowingRestrictions(pattern);
35917
+ if (shadowing.length > 0) {
35918
+ shadowedAllowPatterns.push(pattern);
35919
+ for (const restriction of shadowing) withholdingPatterns.add(restriction);
35920
+ continue;
35020
35921
  }
35922
+ allow.push(pattern);
35021
35923
  }
35022
35924
  return {
35023
35925
  allow,
35024
35926
  deny,
35025
35927
  droppedCategories,
35026
- translatedAskPatterns
35928
+ translatedAskPatterns,
35929
+ shadowedAllowPatterns,
35930
+ unenforcedAllToolsDenyPatterns: collectUnenforcedAllToolsPatterns({
35931
+ rules,
35932
+ allToolsPatterns: allToolsDenyPatterns,
35933
+ withholdingPatterns
35934
+ }),
35935
+ unenforcedAllToolsAskPatterns: collectUnenforcedAllToolsPatterns({
35936
+ rules,
35937
+ allToolsPatterns: allToolsAskPatterns,
35938
+ withholdingPatterns
35939
+ }),
35940
+ ignoredAllToolsAllowPatterns,
35941
+ intersectionBudgetExhausted: budget.remaining === 0
35027
35942
  };
35028
35943
  }
35029
35944
  /**
@@ -35032,11 +35947,16 @@ function translateClinePermissions(permission) {
35032
35947
  * project convention used by every other permissions translator, and
35033
35948
  * (b) the user still sees one prominent "WARNING" message describing the translation.
35034
35949
  */
35035
- function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, logger }) {
35036
- if (droppedCategories.length === 0 && translatedAskPatterns.length === 0) return;
35950
+ function warnClineTranslationNotices({ droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted, logger }) {
35951
+ if (droppedCategories.length === 0 && translatedAskPatterns.length === 0 && shadowedAllowPatterns.length === 0 && unenforcedAllToolsDenyPatterns.length === 0 && unenforcedAllToolsAskPatterns.length === 0 && ignoredAllToolsAllowPatterns.length === 0 && !intersectionBudgetExhausted) return;
35037
35952
  const parts = [];
35038
35953
  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)`);
35039
35954
  if (translatedAskPatterns.length > 0) parts.push(`'ask' rules for bash patterns [${translatedAskPatterns.join(", ")}] translated to 'deny' for fail-closed safety, since Cline lacks 'ask'`);
35955
+ 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`);
35956
+ 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`);
35957
+ 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`);
35958
+ 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`);
35959
+ 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");
35040
35960
  logger?.warn(`WARNING: Cline command permissions translation notice: ${parts.join("; ")}.`);
35041
35961
  }
35042
35962
  var ClinePermissions = class ClinePermissions extends ToolPermissions {
@@ -35084,10 +36004,15 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
35084
36004
  throw new Error(`Failed to parse existing Cline command-permissions at ${filePath}: ${formatError(error)}`, { cause: error });
35085
36005
  }
35086
36006
  const config = rulesyncPermissions.getJson();
35087
- const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(config.permission);
36007
+ const { allow, deny, droppedCategories, translatedAskPatterns, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, ignoredAllToolsAllowPatterns, intersectionBudgetExhausted } = translateClinePermissions(config.permission);
35088
36008
  warnClineTranslationNotices({
35089
36009
  droppedCategories,
35090
36010
  translatedAskPatterns,
36011
+ shadowedAllowPatterns,
36012
+ unenforcedAllToolsDenyPatterns,
36013
+ unenforcedAllToolsAskPatterns,
36014
+ ignoredAllToolsAllowPatterns,
36015
+ intersectionBudgetExhausted,
35091
36016
  logger
35092
36017
  });
35093
36018
  const dedupedAllow = (0, es_toolkit.uniq)(allow.toSorted());
@@ -35095,7 +36020,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
35095
36020
  const mergedDeny = (0, es_toolkit.uniq)([...existing.deny ?? [], ...dedupedDeny]).toSorted();
35096
36021
  const denySet = new Set(mergedDeny);
35097
36022
  const collisions = dedupedAllow.filter((p) => denySet.has(p));
35098
- 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.`);
36023
+ 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.`);
35099
36024
  const next = {
35100
36025
  ...existing,
35101
36026
  allow: dedupedAllow,
@@ -36514,7 +37439,9 @@ const TRAILING_ARGUMENT_WILDCARD_PATTERN = /:\*$/;
36514
37439
  * This surface is **global only** — dcode reads no project-level config file,
36515
37440
  * so there is nothing to write into a repository.
36516
37441
  *
36517
- * Only the canonical `bash` category maps, and only its `allow` rules:
37442
+ * Only `allow` rules map, and only from the canonical `bash` category — the
37443
+ * all-tools `*` category contributes its restricting rules instead, since a rule
37444
+ * written there covers shell commands too (see `collectShellCommandRules`):
36518
37445
  *
36519
37446
  * - A pattern is reduced to its executable token, because that is all dcode
36520
37447
  * matches on — `git *`, `git:*`, `git commit:*` and a bare `git` all become
@@ -36628,7 +37555,7 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
36628
37555
  const shell = isPlainObject$1(existingShell) ? { ...existingShell } : {};
36629
37556
  if (allowList.length > 0) shell[ALLOW_LIST_KEY] = allowList;
36630
37557
  else if (shell[ALLOW_LIST_KEY] !== void 0) {
36631
- 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.`);
37558
+ 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.`);
36632
37559
  delete shell[ALLOW_LIST_KEY];
36633
37560
  }
36634
37561
  if (Object.keys(shell).length > 0) settings[SHELL_TABLE_KEY] = shell;
@@ -36687,35 +37614,69 @@ var DeepagentsPermissions = class DeepagentsPermissions extends ToolPermissions
36687
37614
  }
36688
37615
  };
36689
37616
  /**
37617
+ * Split the restricting rules by what the generated allowlist does to them, and
37618
+ * name the entries that have to go.
37619
+ *
37620
+ * An allowlist entry auto-approves its executable however it is invoked, so an
37621
+ * `ask` or `deny` on any command that entry would run — a narrower pattern
37622
+ * (`npm publish`) beside an allowed `npm *`, or one naming no executable at all
37623
+ * (`*delete*` beside an allowed `kubectl`) — collides with it. dcode has no
37624
+ * denylist, so the collision cannot be settled there: keeping the allow would
37625
+ * auto-approve the very command the author wanted stopped. The colliding
37626
+ * entries are therefore withheld — canonically the stricter rule wins whatever
37627
+ * its width — which leaves those executables prompting, which is what an `ask`
37628
+ * asks for and the closest dcode can come to a `deny`.
37629
+ */
37630
+ function partitionRestrictingRules({ allowList, askPatterns, denyPatterns, willWrite }) {
37631
+ const approved = allowList.map((token) => ({
37632
+ token,
37633
+ globs: [parseGlobPattern(token), parseGlobPattern(`${token} *`)]
37634
+ }));
37635
+ const budget = createIntersectionBudget();
37636
+ const collidingTokens = (pattern) => {
37637
+ if (!willWrite) return [];
37638
+ if (budget.remaining === 0) return [...allowList];
37639
+ const restriction = parseGlobPattern(pattern.trim().replace(TRAILING_ARGUMENT_WILDCARD_PATTERN, "*").replaceAll(SHLEX_STRIPPED_PATTERN, ""));
37640
+ return approved.filter(({ globs }) => globs.some((glob) => parsedGlobsIntersect(restriction, glob, budget))).map(({ token }) => token);
37641
+ };
37642
+ const withheldTokens = /* @__PURE__ */ new Set();
37643
+ const collect = (pattern) => {
37644
+ const tokens = collidingTokens(pattern);
37645
+ for (const token of tokens) withheldTokens.add(token);
37646
+ return tokens.length > 0;
37647
+ };
37648
+ const shadowedAsk = (0, es_toolkit.uniq)(askPatterns).filter(collect);
37649
+ const shadowedDeny = [];
37650
+ const unenforcedDeny = [];
37651
+ for (const pattern of (0, es_toolkit.uniq)(denyPatterns)) (collect(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
37652
+ return {
37653
+ shadowedAsk,
37654
+ shadowedDeny,
37655
+ unenforcedDeny,
37656
+ withheldTokens,
37657
+ intersectionBudgetExhausted: budget.remaining === 0
37658
+ };
37659
+ }
37660
+ /**
36690
37661
  * Say what the reduction to executable names could not write. Split out from
36691
37662
  * `convertRulesyncToDeepagentsAllowList` because the rules it reports on
36692
37663
  * outnumber the ones it writes: every category dcode cannot express is a
36693
37664
  * sentence here.
36694
37665
  */
36695
- function warnAboutUnwrittenBashRules({ allowList, allowAll, requestedAllowAll, askPatterns, denyPatterns, widenedPatterns, unmatchablePatterns, sentinelPatterns, willWrite, logger }) {
36696
- const allowedTokens = new Set(allowList);
36697
- const collidesWithAllow = (pattern) => {
36698
- if (!willWrite) return false;
36699
- if (allowAll) return true;
36700
- const leading = leadingToken(pattern).replaceAll(SHLEX_STRIPPED_PATTERN, "");
36701
- if (GLOB_CHARACTERS_PATTERN.test(leading)) {
36702
- const matches = compileGlob(leading);
36703
- return allowList.some((token) => matches(token));
36704
- }
36705
- return allowedTokens.has(leading);
36706
- };
36707
- const shadowedAsk = askPatterns.filter(collidesWithAllow);
36708
- const shadowedDeny = [];
36709
- const unenforcedDeny = [];
36710
- for (const pattern of denyPatterns) (collidesWithAllow(pattern) ? shadowedDeny : unenforcedDeny).push(pattern);
36711
- 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.`);
36712
- const shadowReason = allowAll ? `allow_list = ["all"] auto-approves every command` : `the generated allow_list auto-approves commands they cover`;
36713
- 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.`);
36714
- 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.`);
37666
+ function warnAboutUnwrittenBashRules({ allowAll, requestedAllowAll, askPatterns, denyPatterns, foreignRestrictingCategories, shadowedAsk, shadowedDeny, unenforcedDeny, intersectionBudgetExhausted, widenedPatterns, unmatchablePatterns, sentinelPatterns, willWrite, logger }) {
37667
+ 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.`);
37668
+ 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.`);
37669
+ 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.`);
36715
37670
  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.`);
36716
37671
  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.`);
36717
37672
  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.`);
36718
- 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.`);
37673
+ if (willWrite && requestedAllowAll && !allowAll) {
37674
+ let restrictionReason = `your config restricts other tools`;
37675
+ if (denyPatterns.length > 0) restrictionReason = `your config denies commands`;
37676
+ else if (askPatterns.length > 0) restrictionReason = `your config asks before running commands`;
37677
+ else if (foreignRestrictingCategories.length > 0) restrictionReason = `your config restricts '${foreignRestrictingCategories.join("', '")}'`;
37678
+ 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.`);
37679
+ }
36719
37680
  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.");
36720
37681
  }
36721
37682
  /**
@@ -36862,62 +37823,78 @@ function toExecutableToken(pattern) {
36862
37823
  }
36863
37824
  /**
36864
37825
  * Convert rulesync permissions config to dcode's `[shell].allow_list`. Only
36865
- * `bash` `allow` rules map; everything else is skipped, with a warning
37826
+ * `allow` rules map from the `bash` category, and never from the all-tools
37827
+ * `*` one, whose restricting rules still count against them (see
37828
+ * `collectShellCommandRules`). Everything else is skipped, with a warning
36866
37829
  * wherever the skip loses a restriction rather than a redundancy.
36867
37830
  */
36868
37831
  function convertRulesyncToDeepagentsAllowList({ config, willWrite, logger }) {
36869
37832
  const allowed = [];
36870
- const widenedPatterns = [];
37833
+ const widened = [];
36871
37834
  const unmatchablePatterns = [];
36872
37835
  const sentinelPatterns = [];
36873
37836
  const askPatterns = [];
36874
37837
  const denyPatterns = [];
36875
- let hasForeignDeny = false;
36876
37838
  let requestedAllowAll = false;
36877
- for (const [category, rules] of Object.entries(config.permission)) {
36878
- if (category !== "bash") {
36879
- if (Object.values(rules).some((action) => action === "deny")) {
36880
- hasForeignDeny = true;
36881
- warnWithFallback(logger, `deepagents-cli only models shell-command permissions ([shell].allow_list), so '${category}' deny rules cannot be represented and were skipped.`);
36882
- }
37839
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
37840
+ for (const { pattern, action } of rules) {
37841
+ if (action === "deny") {
37842
+ denyPatterns.push(pattern);
36883
37843
  continue;
36884
37844
  }
36885
- for (const [pattern, action] of Object.entries(rules)) {
36886
- if (action === "deny") {
36887
- denyPatterns.push(pattern);
36888
- continue;
36889
- }
36890
- if (action === "ask") {
36891
- askPatterns.push(pattern);
36892
- continue;
36893
- }
36894
- if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
36895
- requestedAllowAll = true;
36896
- continue;
36897
- }
36898
- const reduced = toExecutableToken(pattern);
36899
- if (!reduced) {
36900
- unmatchablePatterns.push(pattern);
36901
- continue;
36902
- }
36903
- if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
36904
- sentinelPatterns.push(pattern);
36905
- continue;
36906
- }
36907
- if (reduced.widened) widenedPatterns.push(pattern);
36908
- allowed.push(reduced.token);
37845
+ if (action === "ask") {
37846
+ askPatterns.push(pattern);
37847
+ continue;
37848
+ }
37849
+ if (leadingToken(pattern) === "*" && meansAnyArguments(pattern)) {
37850
+ requestedAllowAll = true;
37851
+ continue;
37852
+ }
37853
+ const reduced = toExecutableToken(pattern);
37854
+ if (!reduced) {
37855
+ unmatchablePatterns.push(pattern);
37856
+ continue;
37857
+ }
37858
+ if (reduced.token.toLowerCase() === ALLOW_ALL_SENTINEL || reduced.token.toLowerCase() === RECOMMENDED_SENTINEL) {
37859
+ sentinelPatterns.push(pattern);
37860
+ continue;
36909
37861
  }
37862
+ if (reduced.widened) widened.push({
37863
+ pattern,
37864
+ token: reduced.token
37865
+ });
37866
+ allowed.push(reduced.token);
36910
37867
  }
36911
- const hasDenyRule = hasForeignDeny || denyPatterns.length > 0;
36912
- const allowAll = requestedAllowAll && !hasDenyRule;
36913
- const allowList = allowAll ? [ALLOW_ALL_SENTINEL] : (0, es_toolkit.uniq)(allowed.toSorted());
37868
+ const hasRestriction = foreignRestrictingCategories.length > 0 || denyPatterns.length > 0 || askPatterns.length > 0;
37869
+ const allowAll = requestedAllowAll && !hasRestriction;
37870
+ const candidates = (0, es_toolkit.uniq)(allowed.toSorted());
37871
+ const { shadowedAsk, shadowedDeny, unenforcedDeny, withheldTokens, intersectionBudgetExhausted } = partitionRestrictingRules({
37872
+ allowList: candidates,
37873
+ askPatterns,
37874
+ denyPatterns,
37875
+ willWrite
37876
+ });
37877
+ const allowList = allowAll ? [ALLOW_ALL_SENTINEL] : candidates.filter((token) => !withheldTokens.has(token));
37878
+ warnAboutUnwrittenCommandRules({
37879
+ toolLabel: "deepagents-cli",
37880
+ surfaceLabel: "[shell].allow_list",
37881
+ foreignRestrictingCategories,
37882
+ shadowedAllowPatterns: [],
37883
+ ignoredAllToolsAllowPatterns,
37884
+ intersectionBudgetExhausted,
37885
+ logger
37886
+ });
36914
37887
  warnAboutUnwrittenBashRules({
36915
- allowList,
36916
37888
  allowAll,
36917
37889
  requestedAllowAll,
36918
37890
  askPatterns,
36919
37891
  denyPatterns,
36920
- widenedPatterns,
37892
+ foreignRestrictingCategories,
37893
+ shadowedAsk,
37894
+ shadowedDeny,
37895
+ unenforcedDeny,
37896
+ intersectionBudgetExhausted,
37897
+ widenedPatterns: (0, es_toolkit.uniq)(widened.filter(({ token }) => !withheldTokens.has(token)).map(({ pattern }) => pattern)),
36921
37898
  unmatchablePatterns,
36922
37899
  sentinelPatterns,
36923
37900
  willWrite,
@@ -37217,10 +38194,17 @@ function convertDevinToRulesyncPermissions(params) {
37217
38194
  *
37218
38195
  * rulesync's canonical `permission.bash` patterns map directly: `allow` →
37219
38196
  * `commandAllowlist`, `deny` → `commandDenylist`. Factory Droid has no separate
37220
- * "ask" list (any command not in the allowlist already prompts), so `ask`
37221
- * rules are intentionally dropped. The allow/deny lists only model shell
37222
- * commands, so categories other than `bash` cannot be represented and are
37223
- * skipped (with a warning when they carry `deny` rules, to surface the gap).
38197
+ * "ask" list (any command not in the allowlist already prompts), so `ask` rules
38198
+ * write nothing they only withhold the allow rules they cover, since the
38199
+ * stricter rule wins whatever its width. The all-tools `*` category contributes
38200
+ * its restricting rules too, because a rule written there covers shell commands
38201
+ * as well. They withhold the allow rules they cover the way a `bash` `ask`
38202
+ * does, because a pattern written under `*` need not name a command at all: a
38203
+ * `deny` there is written to `commandDenylist` too, for the case where it *is*
38204
+ * one, but an entry naming no command enforces nothing by itself.
38205
+ * The allow/deny lists only model shell commands, so categories other
38206
+ * than `bash` and `*` cannot be represented and are skipped (with a warning
38207
+ * when they carry `deny` rules, to surface the gap).
37224
38208
  *
37225
38209
  * Factory Droid also has a stronger `commandBlocklist` tier — commands that can
37226
38210
  * never run, not even under full autonomy — plus other security controls
@@ -37337,24 +38321,28 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
37337
38321
  };
37338
38322
  /**
37339
38323
  * Convert rulesync permissions config to Factory Droid allow/deny command lists.
37340
- * Only the `bash` category maps; `ask` rules and non-`bash` categories are
37341
- * dropped (the latter with a warning when they carry `deny` rules).
38324
+ * The `bash` category maps, and so do the restricting rules of the all-tools
38325
+ * `*` category a `deny` written there covers shell commands too, and skipping
38326
+ * it would auto-approve a command the file blocks. Other categories are dropped
38327
+ * (with a warning when they carry `deny` rules).
37342
38328
  */
37343
38329
  function convertRulesyncToFactorydroidPermissions({ config, logger }) {
37344
- const allow = [];
37345
- const deny = [];
37346
- for (const [category, rules] of Object.entries(config.permission)) {
37347
- if (category !== "bash") {
37348
- 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.`);
37349
- continue;
37350
- }
37351
- for (const [pattern, action] of Object.entries(rules)) switch (action) {
37352
- case "allow":
37353
- allow.push(pattern);
37354
- break;
37355
- case "deny": deny.push(pattern);
37356
- }
37357
- }
38330
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
38331
+ const { allow, deny, shadowedAllowPatterns, unenforcedAllToolsDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
38332
+ rules,
38333
+ writesAllToolsDeny: true
38334
+ });
38335
+ warnAboutUnwrittenCommandRules({
38336
+ toolLabel: "Factory Droid",
38337
+ surfaceLabel: "commandAllowlist/commandDenylist",
38338
+ foreignRestrictingCategories,
38339
+ shadowedAllowPatterns,
38340
+ unenforcedAllToolsDenyPatterns,
38341
+ unenforcedAllToolsAskPatterns,
38342
+ ignoredAllToolsAllowPatterns,
38343
+ intersectionBudgetExhausted,
38344
+ logger
38345
+ });
37358
38346
  return {
37359
38347
  allow,
37360
38348
  deny
@@ -39405,9 +40393,10 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
39405
40393
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
39406
40394
  }
39407
40395
  const parsed = (0, jsonc_parser.parse)(fileContent ?? "{}");
40396
+ const record = isRecord$1(parsed) ? parsed : {};
39408
40397
  const nextJson = {
39409
- ...parsed,
39410
- permission: parsed.permission ?? {}
40398
+ ...record,
40399
+ permission: Object.hasOwn(record, "permission") ? record.permission ?? {} : {}
39411
40400
  };
39412
40401
  return new OpencodePermissions({
39413
40402
  outputRoot,
@@ -39476,7 +40465,7 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
39476
40465
  }
39477
40466
  validate() {
39478
40467
  try {
39479
- const json = JSON.parse(this.fileContent || "{}");
40468
+ const json = parseJsonc$8(this.fileContent || "{}");
39480
40469
  const result = OpencodePermissionsConfigSchema.safeParse(json);
39481
40470
  if (!result.success) return {
39482
40471
  success: false,
@@ -43177,9 +44166,13 @@ function warpSettingsDir() {
43177
44166
  * allowlist, `deny` → denylist). Warp matches commands with regular
43178
44167
  * expressions, so patterns are emitted verbatim — author canonical `bash`
43179
44168
  * patterns as regexes when targeting Warp (mirrors the Zed permissions
43180
- * adapter). Warp has no per-command "ask" list, so `ask` rules are dropped; and
43181
- * the command lists only model shell commands, so non-`bash` categories are
43182
- * skipped (with a warning when they carry `deny` rules).
44169
+ * adapter). Warp has no per-command "ask" list, so `ask` rules write nothing
44170
+ * they only withhold the allow rules they cover, since the stricter rule wins
44171
+ * whatever its width. The all-tools `*` category is read for its restricting
44172
+ * rules as well, but those only withhold allows too: writing any denylist
44173
+ * **replaces** Warp's built-in default one, and a `*` pattern need not name a
44174
+ * command at all. Categories other than `bash` and `*` are skipped (with a
44175
+ * warning when they carry `deny` rules).
43183
44176
  *
43184
44177
  * Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy
43185
44178
  * knobs that do not fit the canonical `allow | ask | deny` per-command model:
@@ -43352,25 +44345,267 @@ function mergeIntoDefaultExecutionProfile({ agents, mergedAllow, mergedDeny, exe
43352
44345
  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.`);
43353
44346
  }
43354
44347
  /**
43355
- * Convert rulesync permissions config to Warp command allow/deny regex lists.
43356
- * Only the `bash` category maps; `ask` rules and non-`bash` categories are
43357
- * dropped (the latter with a warning when they carry `deny` rules).
44348
+ * Read a `[...]` class starting at its `[` and return the index just past its
44349
+ * `]`, or `undefined` when it cannot be read that simply. Regex class rules
44350
+ * apply: a `]` in the first position is a member rather than the terminator and
44351
+ * a backslash escapes the character after it. A nested `[` gives up: Rust's
44352
+ * `regex` crate — the engine Warp matches with — reads `[a[b]c]` as one class
44353
+ * built by set operations, so stopping at the first `]` would leave `c]` behind
44354
+ * as text the glob then requires. Giving up widens the whole pattern instead,
44355
+ * which is the only safe direction here.
44356
+ */
44357
+ function skipRegexClass(body, start) {
44358
+ let index = start + 1;
44359
+ if (body.charAt(index) === "^") index += 1;
44360
+ if (body.charAt(index) === "]") index += 1;
44361
+ while (index < body.length) {
44362
+ const character = body.charAt(index);
44363
+ if (character === "\\") {
44364
+ index += 2;
44365
+ continue;
44366
+ }
44367
+ if (character === "[") return;
44368
+ if (character === "]") return index + 1;
44369
+ index += 1;
44370
+ }
44371
+ }
44372
+ /**
44373
+ * Read a `(...)` group starting at its `(` and return the index just past its
44374
+ * `)`, or `undefined` when it never closes. Groups nest, so the depth is
44375
+ * counted — but a class inside one is skipped whole, since a `)` written there
44376
+ * is a member rather than a closer.
43358
44377
  */
43359
- function convertRulesyncToWarpPermissions({ config, logger }) {
43360
- const allow = [];
43361
- const deny = [];
43362
- for (const [category, rules] of Object.entries(config.permission)) {
43363
- if (category !== "bash") {
43364
- 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.`);
44378
+ function skipRegexGroup(body, start) {
44379
+ let depth = 0;
44380
+ let index = start;
44381
+ while (index < body.length) {
44382
+ const character = body.charAt(index);
44383
+ if (character === "\\") {
44384
+ index += 2;
43365
44385
  continue;
43366
44386
  }
43367
- for (const [pattern, action] of Object.entries(rules)) switch (action) {
43368
- case "allow":
43369
- allow.push(pattern);
43370
- break;
43371
- case "deny": deny.push(pattern);
44387
+ if (character === "[") {
44388
+ const next = skipRegexClass(body, index);
44389
+ if (next === void 0) return;
44390
+ index = next;
44391
+ continue;
44392
+ }
44393
+ if (character === "(") {
44394
+ depth += 1;
44395
+ index += 1;
44396
+ continue;
44397
+ }
44398
+ if (character === ")") {
44399
+ depth -= 1;
44400
+ index += 1;
44401
+ if (depth === 0) return index;
44402
+ continue;
44403
+ }
44404
+ index += 1;
44405
+ }
44406
+ }
44407
+ /**
44408
+ * Read a `{2,3}` quantifier starting at its `{`, or `undefined` when what
44409
+ * follows is not one. The shape is checked rather than scanned to the next `}`,
44410
+ * because a `{` that spells no repetition is a literal to the regex engine —
44411
+ * `{a|b}` is an alternation in braces, and reading it as a quantifier would skip
44412
+ * past the `|` that has to widen the whole pattern.
44413
+ */
44414
+ const QUANTIFIER = /\{\d+(,\d*)?\}/y;
44415
+ function skipQuantifier(body, start) {
44416
+ QUANTIFIER.lastIndex = start;
44417
+ return QUANTIFIER.exec(body) === null ? void 0 : QUANTIFIER.lastIndex;
44418
+ }
44419
+ /**
44420
+ * Read the bracketed construct that opens at `start`, whichever kind it is, and
44421
+ * return the index just past it — or `undefined` when it never closes.
44422
+ */
44423
+ function skipBracketedConstruct(body, start, open) {
44424
+ if (open === "[") return skipRegexClass(body, start);
44425
+ if (open === "(") return skipRegexGroup(body, start);
44426
+ return skipQuantifier(body, start);
44427
+ }
44428
+ /**
44429
+ * Read the tail of a `\\x` / `\\u` escape — the hex run of `\\x20`, or the
44430
+ * braced `\\x{263A}` form — and return the index just past it. Such an escape
44431
+ * spells one character across several, so consuming only its introducer would
44432
+ * leave the hex digits behind as literal atoms and *narrow* the glob, the one
44433
+ * direction the rewrite must never take. Over-consuming only widens, so an
44434
+ * unterminated brace swallows the rest of the pattern.
44435
+ */
44436
+ function skipHexEscapeTail(body, start) {
44437
+ if (body.charAt(start) === "{") {
44438
+ const closing = body.indexOf("}", start);
44439
+ return closing === -1 ? body.length : closing + 1;
44440
+ }
44441
+ let index = start;
44442
+ while (index < body.length && /^[0-9A-Fa-f]$/.test(body.charAt(index))) index += 1;
44443
+ return index;
44444
+ }
44445
+ /**
44446
+ * Read the tail of a `\\p` / `\\P` Unicode-class escape — the braced `\\p{Greek}`
44447
+ * form, or the one-letter `\\pL` shorthand — and return the index just past it.
44448
+ * Like a hex escape, it spells its class across more than one character, so
44449
+ * leaving the shorthand letter behind would narrow the glob.
44450
+ */
44451
+ function skipUnicodeClassTail(body, start) {
44452
+ if (body.charAt(start) === "{") {
44453
+ const closing = body.indexOf("}", start);
44454
+ return closing === -1 ? body.length : closing + 1;
44455
+ }
44456
+ return Math.min(start + 1, body.length);
44457
+ }
44458
+ /**
44459
+ * Read the escape that opens at `start` — the `\\` and whatever it spells — and
44460
+ * say where it ends and which atom it stands for.
44461
+ */
44462
+ function readEscape(body, start) {
44463
+ const escaped = body.charAt(start + 1);
44464
+ if (escaped === "x" || escaped === "u" || escaped === "U") return {
44465
+ next: skipHexEscapeTail(body, start + 2),
44466
+ atom: "*"
44467
+ };
44468
+ if (escaped === "p" || escaped === "P") return {
44469
+ next: skipUnicodeClassTail(body, start + 2),
44470
+ atom: "*"
44471
+ };
44472
+ return {
44473
+ next: start + 2,
44474
+ atom: escapedCharacterWidens(escaped) ? "*" : escaped
44475
+ };
44476
+ }
44477
+ /**
44478
+ * Whether an escaped character has to widen to `*` rather than stand for
44479
+ * itself: a letter or a digit spells a class (`\s`, `\d`, `\w`), a glob
44480
+ * metacharacter would be read as a wildcard or a class by the comparison
44481
+ * instead of as the literal the escape asked for, and an empty string is a
44482
+ * trailing backslash spelling nothing at all.
44483
+ */
44484
+ function escapedCharacterWidens(escaped) {
44485
+ return escaped === "" || /^[A-Za-z0-9*?[\]]$/.test(escaped);
44486
+ }
44487
+ /**
44488
+ * Read one `[...]`, `(...)` or `{...}` construct starting at `start`, saying
44489
+ * where it ends and whether it repeats the atom in front of it (a quantifier)
44490
+ * or is an atom of its own (a class or a group) — or that the whole pattern has
44491
+ * to widen, which is the only safe reading of a construct that never closes and
44492
+ * of one that sets flags for everything after it.
44493
+ */
44494
+ function readBracketedConstruct(body, start, open) {
44495
+ const next = skipBracketedConstruct(body, start, open);
44496
+ if (next === void 0) return "widens-pattern";
44497
+ if (open === "(" && INLINE_FLAG_GROUP_PATTERN.test(body.slice(start, next))) return "widens-pattern";
44498
+ return {
44499
+ next,
44500
+ widensLastAtom: open === "{"
44501
+ };
44502
+ }
44503
+ /**
44504
+ * A group that only sets flags — `(?i)`, `(?im)`, `(?-i)` — as opposed to one
44505
+ * that scopes them to its own body (`(?i:...)`, which widens to `*` like any
44506
+ * other group).
44507
+ */
44508
+ const INLINE_FLAG_GROUP_PATTERN = /^\(\?[A-Za-z]*-?[A-Za-z]*\)$/;
44509
+ /**
44510
+ * Approximate a Warp command pattern as a glob, so restrictions and allow rules
44511
+ * can be compared by `createShadowingRestrictionsTest`.
44512
+ *
44513
+ * Warp matches commands with regular expressions, and a glob reader would
44514
+ * misread the ordinary spellings: `.*` — Warp's catch-all — is a literal dot
44515
+ * followed by a wildcard, `[rf]` is a character class in one language and plain
44516
+ * text in the other, and an unanchored regex covers every command that merely
44517
+ * contains it. The rewrite therefore only ever widens what a pattern covers, so
44518
+ * an inexact reading withholds an allow rather than writing one the config
44519
+ * restricts.
44520
+ *
44521
+ * Widening means a construct is replaced whole rather than character by
44522
+ * character, because the characters inside it are not literals of the command:
44523
+ * a class (`[rf]`), a group (`(sudo )?`), a character escape (`\s`) and a
44524
+ * missing `^`/`$` anchor all become `*`, and a quantifier (`?`, `*`, `+`,
44525
+ * `{2}`) widens the atom in front of it — `git commits?` covers `git commit`,
44526
+ * so its glob has to as well. Both sides of a comparison are widened, so a
44527
+ * pattern that is really a glob still compares sensibly.
44528
+ *
44529
+ * A pattern the walk cannot read as one sequence widens to `*` whole: a
44530
+ * top-level `|` is two patterns rather than one, and a class, group or
44531
+ * quantifier that never closes leaves the rest of the pattern unreadable —
44532
+ * guessing at either could only narrow the result, which is the one direction
44533
+ * this rewrite must never take. An alternation *inside* a group needs no such
44534
+ * treatment: the group it sits in already widens to `*`.
44535
+ */
44536
+ function warpCommandPatternToGlob(pattern) {
44537
+ const anchoredStart = pattern.startsWith("^");
44538
+ const anchoredEnd = pattern.endsWith("$") && !pattern.endsWith("\\$");
44539
+ const body = pattern.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : void 0);
44540
+ const atoms = [];
44541
+ const widenLastAtom = () => {
44542
+ if (atoms.length === 0) {
44543
+ atoms.push("*");
44544
+ return;
43372
44545
  }
44546
+ atoms[atoms.length - 1] = "*";
44547
+ };
44548
+ let index = 0;
44549
+ while (index < body.length) {
44550
+ const character = body.charAt(index);
44551
+ if (character === "|") return "*";
44552
+ if (character === "\\") {
44553
+ const escape = readEscape(body, index);
44554
+ index = escape.next;
44555
+ atoms.push(escape.atom);
44556
+ continue;
44557
+ }
44558
+ if (character === "[" || character === "(" || character === "{") {
44559
+ const read = readBracketedConstruct(body, index, character);
44560
+ if (read === "widens-pattern") return "*";
44561
+ index = read.next;
44562
+ if (read.widensLastAtom) widenLastAtom();
44563
+ else atoms.push("*");
44564
+ continue;
44565
+ }
44566
+ if (character === "*" || character === "+" || character === "?") {
44567
+ index += 1;
44568
+ widenLastAtom();
44569
+ continue;
44570
+ }
44571
+ const codePoint = body.codePointAt(index);
44572
+ const atom = codePoint === void 0 ? character : String.fromCodePoint(codePoint);
44573
+ index += atom.length;
44574
+ atoms.push(atom === "." || ")]}^$".includes(atom) ? "*" : atom);
43373
44575
  }
44576
+ const glob = atoms.join("");
44577
+ return `${anchoredStart ? "" : "*"}${glob}${anchoredEnd ? "" : "*"}`;
44578
+ }
44579
+ /**
44580
+ * Convert rulesync permissions config to Warp command allow/deny regex lists.
44581
+ * The `bash` category maps to both lists. The all-tools `*` category's
44582
+ * restricting rules are read too — a rule written there covers shell commands
44583
+ * as well, and ignoring it would auto-approve a command the file blocks — but
44584
+ * they only *withhold* the allow rules they cover: Warp's denylist is a regex
44585
+ * list that replaces the tool's built-in default one, so writing a pattern
44586
+ * there that may not even name a command would cost more protection than it
44587
+ * adds. Other categories are dropped (with a warning when they carry `deny`
44588
+ * rules).
44589
+ */
44590
+ function convertRulesyncToWarpPermissions({ config, logger }) {
44591
+ const { rules, foreignRestrictingCategories, ignoredAllToolsAllowPatterns } = collectShellCommandRules(config.permission);
44592
+ const { allow, deny, shadowedAllowPatterns, unwrittenDenyPatterns, unenforcedAllToolsAskPatterns, intersectionBudgetExhausted } = partitionCommandRules({
44593
+ rules,
44594
+ writesAllToolsDeny: false,
44595
+ normalizePattern: warpCommandPatternToGlob
44596
+ });
44597
+ warnAboutUnwrittenCommandRules({
44598
+ toolLabel: "Warp",
44599
+ surfaceLabel: "agent_mode_command_execution_allowlist/denylist",
44600
+ foreignRestrictingCategories,
44601
+ shadowedAllowPatterns,
44602
+ unwrittenDenyPatterns,
44603
+ unwrittenDenyReason: "Writing any denylist replaces Warp's built-in default one, and a pattern written under '*' need not be a command at all.",
44604
+ unenforcedAllToolsAskPatterns,
44605
+ ignoredAllToolsAllowPatterns,
44606
+ intersectionBudgetExhausted,
44607
+ logger
44608
+ });
43374
44609
  return {
43375
44610
  allow,
43376
44611
  deny