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.
- package/dist/cli/index.cjs +4 -4
- package/dist/cli/index.js +4 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/{import-DijDR24m.js → import-1-jjDDAm.js} +1398 -163
- package/dist/import-1-jjDDAm.js.map +1 -0
- package/dist/{import-q1SKbuFR.cjs → import-BlhbArzI.cjs} +1396 -161
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/import-DijDR24m.js.map +0 -1
|
@@ -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 (
|
|
10677
|
+
if (value === null || typeof value !== "object" || value instanceof Date) return value;
|
|
10662
10678
|
const result = {};
|
|
10663
|
-
for (const [key, nested] of Object.entries(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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`
|
|
33512
|
-
*
|
|
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
|
|
34161
|
+
function parseGlobPattern(glob) {
|
|
33515
34162
|
const steps = parseGlob(glob);
|
|
33516
|
-
return
|
|
34163
|
+
return {
|
|
34164
|
+
steps,
|
|
34165
|
+
maxRanges: maxRangeCount(steps)
|
|
34166
|
+
};
|
|
33517
34167
|
}
|
|
33518
|
-
|
|
33519
|
-
|
|
33520
|
-
|
|
33521
|
-
|
|
33522
|
-
|
|
33523
|
-
|
|
33524
|
-
|
|
33525
|
-
|
|
33526
|
-
|
|
33527
|
-
|
|
33528
|
-
|
|
33529
|
-
|
|
33530
|
-
|
|
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
|
-
|
|
33533
|
-
|
|
33534
|
-
|
|
33535
|
-
|
|
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
|
-
|
|
33538
|
-
starCharacterIndex += 1;
|
|
33539
|
-
stepIndex = starStepIndex + 1;
|
|
33540
|
-
characterIndex = starCharacterIndex;
|
|
34214
|
+
next = row;
|
|
33541
34215
|
}
|
|
33542
|
-
|
|
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
|
-
*
|
|
35000
|
-
*
|
|
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
|
-
|
|
35008
|
-
|
|
35009
|
-
|
|
35010
|
-
|
|
35011
|
-
|
|
35012
|
-
|
|
35013
|
-
|
|
35014
|
-
|
|
35015
|
-
|
|
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
|
-
|
|
35019
|
-
|
|
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
|
|
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
|
|
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({
|
|
36696
|
-
|
|
36697
|
-
|
|
36698
|
-
|
|
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)
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
|
|
36878
|
-
|
|
36879
|
-
|
|
36880
|
-
|
|
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
|
-
|
|
36886
|
-
|
|
36887
|
-
|
|
36888
|
-
|
|
36889
|
-
|
|
36890
|
-
|
|
36891
|
-
|
|
36892
|
-
|
|
36893
|
-
|
|
36894
|
-
|
|
36895
|
-
|
|
36896
|
-
|
|
36897
|
-
|
|
36898
|
-
|
|
36899
|
-
|
|
36900
|
-
|
|
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
|
|
36912
|
-
const allowAll = requestedAllowAll && !
|
|
36913
|
-
const
|
|
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
|
-
|
|
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
|
-
*
|
|
37222
|
-
*
|
|
37223
|
-
*
|
|
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
|
-
*
|
|
37341
|
-
*
|
|
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
|
|
37345
|
-
const deny =
|
|
37346
|
-
|
|
37347
|
-
|
|
37348
|
-
|
|
37349
|
-
|
|
37350
|
-
|
|
37351
|
-
|
|
37352
|
-
|
|
37353
|
-
|
|
37354
|
-
|
|
37355
|
-
|
|
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
|
-
...
|
|
39410
|
-
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 =
|
|
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
|
|
43181
|
-
*
|
|
43182
|
-
*
|
|
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
|
-
*
|
|
43356
|
-
*
|
|
43357
|
-
*
|
|
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
|
|
43360
|
-
|
|
43361
|
-
|
|
43362
|
-
|
|
43363
|
-
|
|
43364
|
-
|
|
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
|
-
|
|
43368
|
-
|
|
43369
|
-
|
|
43370
|
-
|
|
43371
|
-
|
|
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
|