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