sift-light 1.0.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/CHANGELOG.md +0 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +661 -0
- package/README.md +177 -0
- package/README.zh-CN.md +177 -0
- package/SECURITY.md +25 -0
- package/package.json +142 -0
- package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
- package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
- package/plugins/sift-light/.mcp.json +24 -0
- package/plugins/sift-light/LICENSE +661 -0
- package/plugins/sift-light/concept-worker.mjs +567 -0
- package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
- package/plugins/sift-light/hooks/hooks.json +16 -0
- package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
- package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
- package/plugins/sift-light/kimi.plugin.json +36 -0
- package/plugins/sift-light/mcp-server.mjs +1 -0
- package/plugins/sift-light/omp-extension.mjs +20914 -0
- package/plugins/sift-light/package.json +11 -0
- package/plugins/sift-light/syntax-worker.mjs +280 -0
- package/plugins/sift-light/syntax-worker.toml +2 -0
- package/src/analysis-evidence.ts +43 -0
- package/src/analysis-limits.ts +27 -0
- package/src/analysis-store.ts +559 -0
- package/src/analysis-term-pages.ts +73 -0
- package/src/analysis-types.ts +162 -0
- package/src/audit-search.ts +391 -0
- package/src/capped-lines.ts +112 -0
- package/src/concept-embedding-cache.ts +186 -0
- package/src/concept-inference.ts +218 -0
- package/src/concept-model.ts +96 -0
- package/src/concept-search.ts +577 -0
- package/src/concept-setup.ts +75 -0
- package/src/concept-source-generation.ts +268 -0
- package/src/concept-worker.mjs +567 -0
- package/src/concept-worker.ts +210 -0
- package/src/config-reader.ts +283 -0
- package/src/config.ts +37 -0
- package/src/context-budget.ts +26 -0
- package/src/discovery-errors.ts +2 -0
- package/src/errors.ts +95 -0
- package/src/evidence-candidates.ts +344 -0
- package/src/evidence-ranking.ts +48 -0
- package/src/evidence-service.ts +1047 -0
- package/src/evidence-validation.ts +466 -0
- package/src/evidence-validity.ts +33 -0
- package/src/excerpt.ts +53 -0
- package/src/file-discovery.ts +150 -0
- package/src/file-metadata-filter.ts +46 -0
- package/src/format.ts +407 -0
- package/src/git-diff.ts +195 -0
- package/src/git-process.ts +189 -0
- package/src/git-repository.ts +367 -0
- package/src/git-source.ts +385 -0
- package/src/historical-paths.ts +205 -0
- package/src/hybrid-search.ts +340 -0
- package/src/import-model.ts +611 -0
- package/src/import-navigation.ts +100 -0
- package/src/import-resolution.ts +240 -0
- package/src/index.ts +97 -0
- package/src/inspect-batch.ts +265 -0
- package/src/inspect.ts +271 -0
- package/src/language-capabilities.ts +157 -0
- package/src/language-capability-definitions.ts +204 -0
- package/src/literal-search.ts +25 -0
- package/src/mcp-cli.ts +21 -0
- package/src/mcp-model-output.ts +120 -0
- package/src/mcp-output.ts +9 -0
- package/src/mcp-semantic-judge.ts +39 -0
- package/src/mcp-server.mjs +14064 -0
- package/src/mcp-server.ts +157 -0
- package/src/mcp-stdio.ts +114 -0
- package/src/mcp.ts +631 -0
- package/src/model-error.ts +120 -0
- package/src/multi-term-search.ts +122 -0
- package/src/omp-index.ts +277 -0
- package/src/omp-marketplace-entry.ts +7 -0
- package/src/operation-lifecycle.ts +413 -0
- package/src/operation-output.ts +100 -0
- package/src/owned-parallel.ts +22 -0
- package/src/owned-process.ts +102 -0
- package/src/owned-task-queue.ts +38 -0
- package/src/package-version.ts +3 -0
- package/src/path-policy.ts +203 -0
- package/src/pi-search-policy.ts +33 -0
- package/src/prompt-guidelines.ts +54 -0
- package/src/python-outline.ts +231 -0
- package/src/record-value.ts +3 -0
- package/src/redaction.ts +95 -0
- package/src/request-contract-catalog.ts +215 -0
- package/src/request-contract-error.ts +96 -0
- package/src/request-contract.ts +481 -0
- package/src/request.ts +132 -0
- package/src/result-statistics.ts +178 -0
- package/src/rg.ts +574 -0
- package/src/ripgrep-diagnostics.ts +122 -0
- package/src/ripgrep-executable.ts +43 -0
- package/src/runtime.ts +53 -0
- package/src/scan-revisions.ts +148 -0
- package/src/script-runtime.ts +11 -0
- package/src/search-policy-commands.ts +170 -0
- package/src/search-policy-hook.ts +61 -0
- package/src/search-policy-recovery.ts +388 -0
- package/src/search-policy-shell.ts +280 -0
- package/src/search-policy.ts +102 -0
- package/src/search-retention.ts +77 -0
- package/src/semantic-judge-batches.ts +135 -0
- package/src/semantic-judge.ts +519 -0
- package/src/service.ts +945 -0
- package/src/session-summary.ts +100 -0
- package/src/snapshot-store.ts +203 -0
- package/src/source-access.ts +288 -0
- package/src/source-continuations.ts +168 -0
- package/src/source-document.ts +277 -0
- package/src/source-inspection.ts +731 -0
- package/src/source-pages.ts +112 -0
- package/src/source.ts +286 -0
- package/src/structural-search.ts +125 -0
- package/src/structure.ts +259 -0
- package/src/summary-previews.ts +96 -0
- package/src/summary.ts +79 -0
- package/src/syntax-facts.ts +464 -0
- package/src/syntax-fields.ts +161 -0
- package/src/syntax-search.ts +361 -0
- package/src/syntax-tree.ts +30 -0
- package/src/syntax-types.ts +72 -0
- package/src/syntax-worker.mjs +280 -0
- package/src/syntax-worker.toml +2 -0
- package/src/syntax-worker.ts +143 -0
- package/src/syntax.ts +259 -0
- package/src/test-navigation-facts.ts +261 -0
- package/src/test-navigation.ts +543 -0
- package/src/tool-schema.ts +321 -0
- package/src/tui/dashboard.ts +107 -0
- package/src/tui/layout.ts +291 -0
- package/src/tui/palette.ts +73 -0
- package/src/tui/presentation.ts +240 -0
- package/src/tui/renderers.ts +106 -0
- package/src/types.ts +368 -0
- package/src/validation-output.ts +148 -0
- package/src/validation-types.ts +48 -0
- package/src/workspace-files.ts +142 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import type { SearchKind } from "./search-policy-commands.js";
|
|
3
|
+
import { SiftLightError } from "./errors.js";
|
|
4
|
+
import { validateRawSearchInput, type RawSearchInput } from "./request.js";
|
|
5
|
+
import type { ShellSearchMatch } from "./search-policy-shell.js";
|
|
6
|
+
|
|
7
|
+
export interface ConcreteSearchRecovery {
|
|
8
|
+
kind: "concrete";
|
|
9
|
+
request: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ManualSearchRecovery {
|
|
13
|
+
kind: "manual";
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SearchRecovery = ConcreteSearchRecovery | ManualSearchRecovery;
|
|
18
|
+
|
|
19
|
+
interface ParsedRipgrepRequest {
|
|
20
|
+
pattern: string;
|
|
21
|
+
path: string;
|
|
22
|
+
literal: boolean;
|
|
23
|
+
ignoreCase: boolean | undefined;
|
|
24
|
+
hidden: boolean;
|
|
25
|
+
wholeWord: boolean;
|
|
26
|
+
glob: string[];
|
|
27
|
+
exclude: string[];
|
|
28
|
+
noConfig: boolean;
|
|
29
|
+
pathWasProvided: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CONTENT_MANUAL_REASON =
|
|
33
|
+
"the command is not one standalone static rg search using the supported option and single-target subset";
|
|
34
|
+
const FILE_MANUAL_REASON =
|
|
35
|
+
"the command is not a plain `find <root> -type f` enumeration, so depth limits, multiple or case-insensitive name predicates, extra tests and other actions cannot be expressed as one files request with the same scope";
|
|
36
|
+
|
|
37
|
+
function manual(kind: SearchKind): ManualSearchRecovery {
|
|
38
|
+
return { kind: "manual", reason: kind === "files" ? FILE_MANUAL_REASON : CONTENT_MANUAL_REASON };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ParsedFindRequest {
|
|
42
|
+
root: string;
|
|
43
|
+
glob?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pushGlob(
|
|
47
|
+
value: string,
|
|
48
|
+
include: string[],
|
|
49
|
+
exclude: string[],
|
|
50
|
+
exclusionSeen: boolean,
|
|
51
|
+
): boolean | undefined {
|
|
52
|
+
if (value.length === 0 || value.startsWith("\\!") || value.startsWith("!!")) return undefined;
|
|
53
|
+
if (value.startsWith("!")) {
|
|
54
|
+
if (value.length === 1) return undefined;
|
|
55
|
+
exclude.push(value.slice(1));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// The MCP runner applies every exclusion after every inclusion. A later positive rg glob
|
|
59
|
+
// could re-include an earlier exclusion, so that ordering cannot be represented faithfully.
|
|
60
|
+
if (exclusionSeen) return undefined;
|
|
61
|
+
include.push(value);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseRipgrepArguments(args: readonly string[]): ParsedRipgrepRequest | undefined {
|
|
66
|
+
let pattern: string | undefined;
|
|
67
|
+
const paths: string[] = [];
|
|
68
|
+
const glob: string[] = [];
|
|
69
|
+
const exclude: string[] = [];
|
|
70
|
+
let literal = false;
|
|
71
|
+
let ignoreCase: boolean | undefined = false;
|
|
72
|
+
let hidden = false;
|
|
73
|
+
let wholeWord = false;
|
|
74
|
+
let noConfig = false;
|
|
75
|
+
let optionsEnded = false;
|
|
76
|
+
let exclusionSeen = false;
|
|
77
|
+
|
|
78
|
+
const takePattern = (value: string): boolean => {
|
|
79
|
+
if (pattern !== undefined) return false;
|
|
80
|
+
pattern = value;
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
85
|
+
const value = args[index];
|
|
86
|
+
if (value === undefined) return undefined;
|
|
87
|
+
if (optionsEnded || !value.startsWith("-") || value === "-") {
|
|
88
|
+
if (!takePattern(value)) paths.push(value);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (value === "--") {
|
|
92
|
+
optionsEnded = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (value.startsWith("--")) {
|
|
96
|
+
const separator = value.indexOf("=");
|
|
97
|
+
const option = separator < 0 ? value : value.slice(0, separator);
|
|
98
|
+
const inlineValue = separator < 0 ? undefined : value.slice(separator + 1);
|
|
99
|
+
if (
|
|
100
|
+
[
|
|
101
|
+
"--line-number",
|
|
102
|
+
"--no-line-number",
|
|
103
|
+
"--heading",
|
|
104
|
+
"--no-heading",
|
|
105
|
+
"--with-filename",
|
|
106
|
+
"--no-filename",
|
|
107
|
+
].includes(option)
|
|
108
|
+
) {
|
|
109
|
+
if (inlineValue !== undefined) return undefined;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (option === "--no-config") {
|
|
113
|
+
if (inlineValue !== undefined) return undefined;
|
|
114
|
+
noConfig = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (option === "--fixed-strings" || option === "--no-fixed-strings") {
|
|
118
|
+
if (inlineValue !== undefined) return undefined;
|
|
119
|
+
literal = option === "--fixed-strings";
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (["--ignore-case", "--case-sensitive", "--smart-case"].includes(option)) {
|
|
123
|
+
if (inlineValue !== undefined) return undefined;
|
|
124
|
+
ignoreCase =
|
|
125
|
+
option === "--ignore-case" ? true : option === "--case-sensitive" ? false : undefined;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (option === "--hidden" || option === "--no-hidden") {
|
|
129
|
+
if (inlineValue !== undefined) return undefined;
|
|
130
|
+
hidden = option === "--hidden";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (option === "--word-regexp" || option === "--no-word-regexp") {
|
|
134
|
+
if (inlineValue !== undefined) return undefined;
|
|
135
|
+
wholeWord = option === "--word-regexp";
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (option === "--regexp") {
|
|
139
|
+
const next = inlineValue ?? args[index + 1];
|
|
140
|
+
if (next === undefined || !takePattern(next)) return undefined;
|
|
141
|
+
if (inlineValue === undefined) index += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (option === "--glob") {
|
|
145
|
+
const next = inlineValue ?? args[index + 1];
|
|
146
|
+
if (next === undefined) return undefined;
|
|
147
|
+
const excluded = pushGlob(next, glob, exclude, exclusionSeen);
|
|
148
|
+
if (excluded === undefined) return undefined;
|
|
149
|
+
exclusionSeen ||= excluded;
|
|
150
|
+
if (inlineValue === undefined) index += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (option === "--color") {
|
|
154
|
+
const next = inlineValue ?? args[index + 1];
|
|
155
|
+
if (next === undefined || !["never", "auto", "always", "ansi"].includes(next))
|
|
156
|
+
return undefined;
|
|
157
|
+
if (inlineValue === undefined) index += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (let offset = 1; offset < value.length; offset += 1) {
|
|
164
|
+
const option = value[offset];
|
|
165
|
+
if (option === undefined) return undefined;
|
|
166
|
+
if (["n", "N", "H"].includes(option)) continue;
|
|
167
|
+
if (option === "F") {
|
|
168
|
+
literal = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (option === "i" || option === "s" || option === "S") {
|
|
172
|
+
ignoreCase = option === "i" ? true : option === "s" ? false : undefined;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (option === "w") {
|
|
176
|
+
wholeWord = true;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (option === "e") {
|
|
180
|
+
const next = value.slice(offset + 1) || args[index + 1];
|
|
181
|
+
if (next === undefined || !takePattern(next)) return undefined;
|
|
182
|
+
if (value.slice(offset + 1).length === 0) index += 1;
|
|
183
|
+
offset = value.length;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (option === "g") {
|
|
187
|
+
const next = value.slice(offset + 1) || args[index + 1];
|
|
188
|
+
if (next === undefined) return undefined;
|
|
189
|
+
const excluded = pushGlob(next, glob, exclude, exclusionSeen);
|
|
190
|
+
if (excluded === undefined) return undefined;
|
|
191
|
+
exclusionSeen ||= excluded;
|
|
192
|
+
if (value.slice(offset + 1).length === 0) index += 1;
|
|
193
|
+
offset = value.length;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (pattern === undefined || paths.length > 1 || paths[0] === "-") return undefined;
|
|
201
|
+
return {
|
|
202
|
+
pattern,
|
|
203
|
+
path: paths[0] ?? ".",
|
|
204
|
+
literal,
|
|
205
|
+
ignoreCase,
|
|
206
|
+
hidden,
|
|
207
|
+
wholeWord,
|
|
208
|
+
glob,
|
|
209
|
+
exclude,
|
|
210
|
+
noConfig,
|
|
211
|
+
pathWasProvided: paths.length === 1,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function listValue(values: readonly string[]): string | string[] | undefined {
|
|
216
|
+
if (values.length === 0) return undefined;
|
|
217
|
+
return values.length === 1 ? values[0] : [...values];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isStandalone(command: string, match: ShellSearchMatch): boolean {
|
|
221
|
+
if (match.nestedDepth > 0) return false;
|
|
222
|
+
const bytes = Buffer.from(command, "utf8");
|
|
223
|
+
if (match.startByte < 0 || match.endByte > bytes.length || match.startByte > match.endByte)
|
|
224
|
+
return false;
|
|
225
|
+
return (
|
|
226
|
+
bytes.subarray(0, match.startByte).toString("utf8").trim().length === 0 &&
|
|
227
|
+
bytes.subarray(match.endByte).toString("utf8").trim().length === 0
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isPlatformPathSeparator(character: string): boolean {
|
|
232
|
+
return character === sep || (process.platform === "win32" && character === "/");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function hasNormalizationSensitivePath(path: string): boolean {
|
|
236
|
+
if (isPlatformPathSeparator(path.at(-1) ?? "")) return true;
|
|
237
|
+
if (process.platform === "win32" && /^[A-Za-z]:(?![\\/])/u.test(path)) return true;
|
|
238
|
+
const segments = path.split(process.platform === "win32" ? /[\\/]/u : "/");
|
|
239
|
+
let namedSegmentSeen = isAbsolute(path);
|
|
240
|
+
for (const segment of segments) {
|
|
241
|
+
if (segment === "..") return true;
|
|
242
|
+
if (segment === ".") {
|
|
243
|
+
if (namedSegmentSeen) return true;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (segment.length > 0) namedSegmentSeen = true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isRipgrepExecutable(executable: string, language: ShellSearchMatch["language"]): boolean {
|
|
252
|
+
const name = executable.split(/[\\/]/u).at(-1) ?? executable;
|
|
253
|
+
const normalized =
|
|
254
|
+
language === "powershell" || process.platform === "win32" ? name.toLowerCase() : name;
|
|
255
|
+
return (
|
|
256
|
+
normalized === "rg" ||
|
|
257
|
+
normalized === "ripgrep" ||
|
|
258
|
+
((language === "powershell" || process.platform === "win32") &&
|
|
259
|
+
(normalized === "rg.exe" || normalized === "ripgrep.exe"))
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isFindExecutable(executable: string, language: ShellSearchMatch["language"]): boolean {
|
|
264
|
+
const name = executable.split(/[\\/]/u).at(-1) ?? executable;
|
|
265
|
+
const normalized =
|
|
266
|
+
language === "powershell" || process.platform === "win32" ? name.toLowerCase() : name;
|
|
267
|
+
return (
|
|
268
|
+
normalized === "find" ||
|
|
269
|
+
((language === "powershell" || process.platform === "win32") && normalized === "find.exe")
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Accepts only `find <root> [-name <pattern>] -type f [-print|-print0]`, in any option order. */
|
|
274
|
+
function parseFindArguments(args: readonly string[]): ParsedFindRequest | undefined {
|
|
275
|
+
let root: string | undefined;
|
|
276
|
+
let glob: string | undefined;
|
|
277
|
+
let typeFile = false;
|
|
278
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
279
|
+
const value = args[index];
|
|
280
|
+
if (value === undefined) return undefined;
|
|
281
|
+
if (!value.startsWith("-") || value === "-") {
|
|
282
|
+
if (root !== undefined) return undefined;
|
|
283
|
+
root = value;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (value === "-print" || value === "-print0") continue;
|
|
287
|
+
if (value === "-type") {
|
|
288
|
+
const next = args[index + 1];
|
|
289
|
+
if (next === undefined || next !== "f") return undefined;
|
|
290
|
+
index += 1;
|
|
291
|
+
typeFile = true;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (value === "-name") {
|
|
295
|
+
const next = args[index + 1];
|
|
296
|
+
if (next === undefined || glob !== undefined) return undefined;
|
|
297
|
+
index += 1;
|
|
298
|
+
glob = next;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
if (!typeFile || root === undefined || root.length === 0) return undefined;
|
|
304
|
+
return glob === undefined ? { root } : { root, glob };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function recoverFileEnumeration(
|
|
308
|
+
argv: readonly string[],
|
|
309
|
+
language: ShellSearchMatch["language"],
|
|
310
|
+
workingDirectory: string,
|
|
311
|
+
): SearchRecovery {
|
|
312
|
+
const executable = argv[0];
|
|
313
|
+
if (executable === undefined || !isFindExecutable(executable, language)) return manual("files");
|
|
314
|
+
const parsed = parseFindArguments(argv.slice(1));
|
|
315
|
+
if (!parsed) return manual("files");
|
|
316
|
+
if (hasNormalizationSensitivePath(parsed.root)) return manual("files");
|
|
317
|
+
const base = resolve(workingDirectory);
|
|
318
|
+
const path = resolve(base, parsed.root);
|
|
319
|
+
const localPath = relative(base, path);
|
|
320
|
+
if (isAbsolute(localPath) || localPath === ".." || localPath.startsWith(`..${sep}`))
|
|
321
|
+
return manual("files");
|
|
322
|
+
if (path.split(/[\\/]/u).some((part) => part.toLowerCase() === ".git")) return manual("files");
|
|
323
|
+
const filters: RawSearchInput = {
|
|
324
|
+
path,
|
|
325
|
+
...(parsed.glob === undefined ? {} : { glob: parsed.glob }),
|
|
326
|
+
};
|
|
327
|
+
try {
|
|
328
|
+
validateRawSearchInput(filters);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error instanceof SiftLightError) return manual("files");
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
return { kind: "concrete", request: JSON.stringify({ mode: "files", ...filters }) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function recoverShellSearch(
|
|
337
|
+
command: string,
|
|
338
|
+
match: ShellSearchMatch,
|
|
339
|
+
workingDirectory: string | undefined,
|
|
340
|
+
): SearchRecovery {
|
|
341
|
+
if (workingDirectory === undefined) return manual(match.kind);
|
|
342
|
+
if (match.hasVariableAssignments || match.hasUntranslatedShellSyntax || match.hasSyntaxError)
|
|
343
|
+
return manual(match.kind);
|
|
344
|
+
const argv: readonly string[] = match.argv.filter((value): value is string => value !== null);
|
|
345
|
+
if (argv.length !== match.argv.length) return manual(match.kind);
|
|
346
|
+
// File enumeration rewrites only the retained `find` expression, so a compound
|
|
347
|
+
// atomic call still reports that nothing ran and the two parts are replayed separately.
|
|
348
|
+
if (match.kind === "files") return recoverFileEnumeration(argv, match.language, workingDirectory);
|
|
349
|
+
if (!isStandalone(command, match)) return manual(match.kind);
|
|
350
|
+
const executable = argv[0];
|
|
351
|
+
if (executable === undefined || !isRipgrepExecutable(executable, match.language))
|
|
352
|
+
return manual(match.kind);
|
|
353
|
+
const parsed = parseRipgrepArguments(argv.slice(1));
|
|
354
|
+
if (!parsed) return manual(match.kind);
|
|
355
|
+
if (!parsed.noConfig && (process.env.RIPGREP_CONFIG_PATH?.length ?? 0) > 0)
|
|
356
|
+
return manual(match.kind);
|
|
357
|
+
if (!parsed.pathWasProvided) return manual(match.kind);
|
|
358
|
+
if (parsed.path.length === 0) return manual(match.kind);
|
|
359
|
+
if (hasNormalizationSensitivePath(parsed.path)) return manual(match.kind);
|
|
360
|
+
|
|
361
|
+
const base = resolve(workingDirectory);
|
|
362
|
+
const path = resolve(base, parsed.path);
|
|
363
|
+
const localPath = relative(base, path);
|
|
364
|
+
if (isAbsolute(localPath) || localPath === ".." || localPath.startsWith(`..${sep}`))
|
|
365
|
+
return manual(match.kind);
|
|
366
|
+
if (path.split(/[\\/]/u).some((part) => part.toLowerCase() === ".git")) return manual(match.kind);
|
|
367
|
+
|
|
368
|
+
const glob = listValue(parsed.glob);
|
|
369
|
+
const exclude = listValue(parsed.exclude);
|
|
370
|
+
const request: RawSearchInput = {
|
|
371
|
+
pattern: parsed.pattern,
|
|
372
|
+
...(parsed.literal ? { literal: true } : {}),
|
|
373
|
+
...(parsed.ignoreCase === undefined ? {} : { ignoreCase: parsed.ignoreCase }),
|
|
374
|
+
...(parsed.wholeWord ? { wholeWord: true } : {}),
|
|
375
|
+
...(glob === undefined ? {} : { glob }),
|
|
376
|
+
...(exclude === undefined ? {} : { exclude }),
|
|
377
|
+
hidden: parsed.hidden,
|
|
378
|
+
path,
|
|
379
|
+
scope: "strict",
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
validateRawSearchInput(request);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof SiftLightError) return manual(match.kind);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
return { kind: "concrete", request: JSON.stringify(request) };
|
|
388
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { Language, Parser, type Node } from "web-tree-sitter";
|
|
3
|
+
import {
|
|
4
|
+
classifyCommand,
|
|
5
|
+
executableName,
|
|
6
|
+
type SearchKind,
|
|
7
|
+
type ShellLanguage,
|
|
8
|
+
} from "./search-policy-commands.js";
|
|
9
|
+
|
|
10
|
+
export const MAX_POLICY_COMMAND_BYTES = 64 * 1024;
|
|
11
|
+
const MAX_SHELL_NESTING = 4;
|
|
12
|
+
|
|
13
|
+
export interface ShellSearchMatch {
|
|
14
|
+
kind: SearchKind;
|
|
15
|
+
command: string;
|
|
16
|
+
argv: readonly (string | null)[];
|
|
17
|
+
hasUntranslatedShellSyntax: boolean;
|
|
18
|
+
hasVariableAssignments: boolean;
|
|
19
|
+
hasSyntaxError: boolean;
|
|
20
|
+
commandIndex: number;
|
|
21
|
+
startByte: number;
|
|
22
|
+
endByte: number;
|
|
23
|
+
nestedDepth: number;
|
|
24
|
+
language: ShellLanguage;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface InspectionState {
|
|
28
|
+
nextCommandIndex: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function decodeBashDoubleQuoted(value: string): string {
|
|
32
|
+
let result = "";
|
|
33
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
34
|
+
const character = value[index];
|
|
35
|
+
if (character !== "\\") {
|
|
36
|
+
result += character;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const next = value[index + 1];
|
|
40
|
+
if (next === undefined || !["$", "`", '"', "\\", "\n"].includes(next)) {
|
|
41
|
+
result += character;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (next !== "\n") result += next;
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function decodePowerShellSingleQuoted(value: string): string | null {
|
|
51
|
+
if (!(value.length >= 2 && value.startsWith("'") && value.endsWith("'"))) return null;
|
|
52
|
+
const body = value.slice(1, -1);
|
|
53
|
+
let result = "";
|
|
54
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
55
|
+
const character = body[index];
|
|
56
|
+
if (character !== "'") {
|
|
57
|
+
result += character;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (body[index + 1] !== "'") return null;
|
|
61
|
+
result += "'";
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function literalWord(node: Node, language: ShellLanguage): string | null {
|
|
68
|
+
const text = node.text;
|
|
69
|
+
if (language === "powershell") {
|
|
70
|
+
const singleQuoted = decodePowerShellSingleQuoted(text);
|
|
71
|
+
if (singleQuoted !== null) return singleQuoted;
|
|
72
|
+
if (node.descendantsOfType(["variable", "sub_expression"]).length > 0) return null;
|
|
73
|
+
const quoted = text.length >= 2 && text.startsWith('"') && text.endsWith('"');
|
|
74
|
+
if (!quoted && !["command_name", "generic_token", "command_parameter"].includes(node.type))
|
|
75
|
+
return null;
|
|
76
|
+
const unquoted = quoted ? text.slice(1, -1) : text;
|
|
77
|
+
return unquoted.replace(/`([`"'$])/gu, "$1");
|
|
78
|
+
}
|
|
79
|
+
if (node.type === "raw_string")
|
|
80
|
+
return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
|
|
81
|
+
? text.slice(1, -1)
|
|
82
|
+
: null;
|
|
83
|
+
if (node.type === "string_content") return decodeBashDoubleQuoted(text);
|
|
84
|
+
if (node.type === "word" || node.type === "number") return text.replace(/\\(.)/gsu, "$1");
|
|
85
|
+
if (["command_name", "concatenation", "string"].includes(node.type)) {
|
|
86
|
+
if (node.type === "string" && !(text.length >= 2 && text.startsWith('"') && text.endsWith('"')))
|
|
87
|
+
return null;
|
|
88
|
+
let result = "";
|
|
89
|
+
for (const child of node.namedChildren) {
|
|
90
|
+
if (!child) continue;
|
|
91
|
+
const part = literalWord(child, language);
|
|
92
|
+
if (part === null) return null;
|
|
93
|
+
result += part;
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function commandWordNodes(node: Node, language: ShellLanguage): Node[] {
|
|
101
|
+
const name = node.childForFieldName(language === "bash" ? "name" : "command_name");
|
|
102
|
+
if (!name) return [];
|
|
103
|
+
const args =
|
|
104
|
+
language === "bash"
|
|
105
|
+
? node.childrenForFieldName("argument").filter((child): child is Node => child !== null)
|
|
106
|
+
: (node
|
|
107
|
+
.childForFieldName("command_elements")
|
|
108
|
+
?.namedChildren.filter(
|
|
109
|
+
(child): child is Node =>
|
|
110
|
+
child !== null && !["command_argument_sep", "redirection"].includes(child.type),
|
|
111
|
+
) ?? []);
|
|
112
|
+
return [name, ...args];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function commandWords(node: Node, language: ShellLanguage): Array<string | null> {
|
|
116
|
+
return commandWordNodes(node, language).map((word) => literalWord(word, language));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasUntranslatedShellSyntax(node: Node, language: ShellLanguage): boolean {
|
|
120
|
+
const text = node.text;
|
|
121
|
+
if (language === "powershell") {
|
|
122
|
+
if (text.length >= 2 && text.startsWith("'") && text.endsWith("'"))
|
|
123
|
+
return decodePowerShellSingleQuoted(text) === null;
|
|
124
|
+
const quoted = text.length >= 2 && text.startsWith('"') && text.endsWith('"');
|
|
125
|
+
const quotedBody = quoted ? text.slice(1, -1) : "";
|
|
126
|
+
return (
|
|
127
|
+
text.includes("`") ||
|
|
128
|
+
quotedBody.includes('"') ||
|
|
129
|
+
(!quoted && /['"]/u.test(text)) ||
|
|
130
|
+
(!quoted && text.startsWith("~"))
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (["raw_string", "string", "string_content"].includes(node.type)) return false;
|
|
134
|
+
if (["word", "number", "command_name"].includes(node.type))
|
|
135
|
+
return /[*?[\]{}]/u.test(text) || text.startsWith("~") || text.startsWith("=");
|
|
136
|
+
if (node.type === "concatenation")
|
|
137
|
+
return node.namedChildren.some(
|
|
138
|
+
(child) => child !== null && hasUntranslatedShellSyntax(child, language),
|
|
139
|
+
);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isSafePipelineFilter(
|
|
144
|
+
node: Node,
|
|
145
|
+
language: ShellLanguage,
|
|
146
|
+
words: Array<string | null>,
|
|
147
|
+
): boolean {
|
|
148
|
+
const executable = words[0];
|
|
149
|
+
if (executable === null || executable === undefined) return false;
|
|
150
|
+
const name = executableName(executable);
|
|
151
|
+
const filterNames =
|
|
152
|
+
language === "powershell"
|
|
153
|
+
? new Set(["select-string", "sls"])
|
|
154
|
+
: new Set(["grep", "egrep", "fgrep"]);
|
|
155
|
+
if (!filterNames.has(language === "powershell" ? name.toLowerCase() : name)) return false;
|
|
156
|
+
const pipeline = node.parent;
|
|
157
|
+
if (!pipeline || pipeline.type !== "pipeline") return false;
|
|
158
|
+
const commands = pipeline.namedChildren.filter(
|
|
159
|
+
(child): child is Node => child !== null && child.type === "command",
|
|
160
|
+
);
|
|
161
|
+
const last = commands.at(-1);
|
|
162
|
+
if (
|
|
163
|
+
!last ||
|
|
164
|
+
last.startIndex !== node.startIndex ||
|
|
165
|
+
last.endIndex !== node.endIndex ||
|
|
166
|
+
commands.length < 2
|
|
167
|
+
)
|
|
168
|
+
return false;
|
|
169
|
+
return commands.slice(0, -1).every((candidate) => {
|
|
170
|
+
const decision = classifyCommand(commandWords(candidate, language), language);
|
|
171
|
+
return !decision.kind && !decision.nested;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** WASM grammars are shipped with the hook; parsing never reads shell scripts or executes code. */
|
|
176
|
+
export class ShellSearchPolicy {
|
|
177
|
+
readonly #assets: URL;
|
|
178
|
+
#initialization: Promise<void> | undefined;
|
|
179
|
+
readonly #languages = new Map<ShellLanguage, Promise<Language>>();
|
|
180
|
+
|
|
181
|
+
constructor(assets: URL) {
|
|
182
|
+
this.#assets = assets;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async inspect(command: string, language: ShellLanguage): Promise<ShellSearchMatch | undefined> {
|
|
186
|
+
if (Buffer.byteLength(command, "utf8") > MAX_POLICY_COMMAND_BYTES)
|
|
187
|
+
throw new Error("Search policy command exceeds 64 KiB; split the shell request");
|
|
188
|
+
this.#initialization ??= Parser.init({
|
|
189
|
+
locateFile: () => fileURLToPath(new URL("tree-sitter.wasm", this.#assets)),
|
|
190
|
+
});
|
|
191
|
+
await this.#initialization;
|
|
192
|
+
// Both grammars are needed for literal bash -c / pwsh -Command wrappers.
|
|
193
|
+
for (const shell of ["bash", "powershell"] as const) {
|
|
194
|
+
if (!this.#languages.has(shell))
|
|
195
|
+
this.#languages.set(
|
|
196
|
+
shell,
|
|
197
|
+
Language.load(fileURLToPath(new URL(`tree-sitter-${shell}.wasm`, this.#assets))),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const [bash, powershell] = await Promise.all([
|
|
201
|
+
this.#languages.get("bash"),
|
|
202
|
+
this.#languages.get("powershell"),
|
|
203
|
+
]);
|
|
204
|
+
if (!bash || !powershell) throw new Error("Search policy grammar initialization failed");
|
|
205
|
+
return this.#inspect(command, language, { bash, powershell }, 0, { nextCommandIndex: 1 });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#inspect(
|
|
209
|
+
command: string,
|
|
210
|
+
language: ShellLanguage,
|
|
211
|
+
grammars: Record<ShellLanguage, Language>,
|
|
212
|
+
depth: number,
|
|
213
|
+
state: InspectionState,
|
|
214
|
+
): ShellSearchMatch | undefined {
|
|
215
|
+
if (depth > MAX_SHELL_NESTING)
|
|
216
|
+
throw new Error("Search policy shell nesting exceeds 4 levels; simplify the command");
|
|
217
|
+
const parser = new Parser();
|
|
218
|
+
try {
|
|
219
|
+
parser.setLanguage(grammars[language]);
|
|
220
|
+
const deadline = performance.now() + 100;
|
|
221
|
+
const tree = parser.parse(command, null, {
|
|
222
|
+
progressCallback: () => performance.now() > deadline,
|
|
223
|
+
});
|
|
224
|
+
if (!tree)
|
|
225
|
+
throw new Error("Search policy parsing exceeded its time budget; simplify the command");
|
|
226
|
+
try {
|
|
227
|
+
const hasSyntaxError = tree.rootNode.hasError;
|
|
228
|
+
const commands = tree.rootNode.descendantsOfType("command");
|
|
229
|
+
for (const node of commands) {
|
|
230
|
+
if (!node) continue;
|
|
231
|
+
const commandIndex = state.nextCommandIndex;
|
|
232
|
+
state.nextCommandIndex += 1;
|
|
233
|
+
const words = commandWords(node, language);
|
|
234
|
+
const decision = classifyCommand(words, language);
|
|
235
|
+
if (
|
|
236
|
+
decision.kind &&
|
|
237
|
+
!(decision.kind === "content" && isSafePipelineFilter(node, language, words))
|
|
238
|
+
)
|
|
239
|
+
return {
|
|
240
|
+
kind: decision.kind,
|
|
241
|
+
command: decision.command ?? "search command",
|
|
242
|
+
argv: words,
|
|
243
|
+
hasUntranslatedShellSyntax:
|
|
244
|
+
node.descendantsOfType("redirection").length > 0 ||
|
|
245
|
+
commandWordNodes(node, language).some((word) =>
|
|
246
|
+
hasUntranslatedShellSyntax(word, language),
|
|
247
|
+
),
|
|
248
|
+
hasVariableAssignments:
|
|
249
|
+
language === "bash" && node.descendantsOfType("variable_assignment").length > 0,
|
|
250
|
+
hasSyntaxError,
|
|
251
|
+
commandIndex,
|
|
252
|
+
startByte: node.startIndex,
|
|
253
|
+
endByte: node.endIndex,
|
|
254
|
+
nestedDepth: depth,
|
|
255
|
+
language,
|
|
256
|
+
};
|
|
257
|
+
if (decision.nested) {
|
|
258
|
+
const nested = this.#inspect(
|
|
259
|
+
decision.nested.command,
|
|
260
|
+
decision.nested.language,
|
|
261
|
+
grammars,
|
|
262
|
+
depth + 1,
|
|
263
|
+
state,
|
|
264
|
+
);
|
|
265
|
+
if (nested) return nested;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Tree-sitter's Bash grammar deliberately does not implement every zsh extension.
|
|
269
|
+
// Error recovery still exposes executable-position `command` nodes, which are the
|
|
270
|
+
// policy's authority. An error in an argument (for example `${(P)name}`) is not
|
|
271
|
+
// evidence of a search and must not turn an otherwise ordinary command into a denial.
|
|
272
|
+
return undefined;
|
|
273
|
+
} finally {
|
|
274
|
+
tree.delete();
|
|
275
|
+
}
|
|
276
|
+
} finally {
|
|
277
|
+
parser.delete();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|