sift-light 1.0.3-2 → 1.0.3-4

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.
@@ -7,7 +7,7 @@ import { URL as URL2 } from "node:url";
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "sift-light",
10
- version: "1.0.3-2",
10
+ version: "1.0.3-4",
11
11
  description: "Context-efficient local search for files, documents, notes and logs across Pi, OMP and MCP clients",
12
12
  keywords: [
13
13
  "ai-agent",
@@ -1580,9 +1580,9 @@ async function consumeCappedLines(stream, onLine, options = {}) {
1580
1580
  }
1581
1581
 
1582
1582
  // src/path-policy.ts
1583
- import { realpath } from "node:fs/promises";
1583
+ import { realpath, stat } from "node:fs/promises";
1584
1584
  import { homedir } from "node:os";
1585
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
1585
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
1586
1586
  var POSIX_SPECIAL_ROOTS = ["/dev", "/proc", "/sys"];
1587
1587
  var PORTABLE_CREDENTIAL_DIRECTORY_NAMES = [
1588
1588
  ".ssh",
@@ -1717,6 +1717,28 @@ class SearchPathPolicy {
1717
1717
  ]);
1718
1718
  return canonical && (!isPathInsideRoot(absolute, this.cwd) || !isPathInsideRoot(canonical, canonicalCwd)) ? canonical : absolute;
1719
1719
  }
1720
+ async allowsScopeExpansion(path, scope) {
1721
+ if (scope === "strict")
1722
+ return false;
1723
+ if (scope === "expand")
1724
+ return true;
1725
+ const searchTarget = await this.resolveSearchTarget(path);
1726
+ return isPathInsideRoot(searchTarget, this.cwd);
1727
+ }
1728
+ async ripgrepWorkingDirectory(searchPath) {
1729
+ const absolute = resolve(this.cwd, searchPath);
1730
+ if (isPathInsideRoot(absolute, this.cwd))
1731
+ return this.cwd;
1732
+ let isDirectory;
1733
+ try {
1734
+ isDirectory = (await stat(absolute)).isDirectory();
1735
+ } catch (error) {
1736
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
1737
+ return this.cwd;
1738
+ throw error;
1739
+ }
1740
+ return isDirectory ? absolute : dirname(absolute);
1741
+ }
1720
1742
  ripgrepGlobArguments(searchPath) {
1721
1743
  const absolute = resolve(this.cwd, searchPath);
1722
1744
  if (isPathInsideRoot(absolute, this.cwd))
@@ -1837,7 +1859,7 @@ async function runOwnedProcess(options, consumeOutput) {
1837
1859
 
1838
1860
  // src/ripgrep-executable.ts
1839
1861
  import { constants } from "node:fs";
1840
- import { access, stat } from "node:fs/promises";
1862
+ import { access, stat as stat2 } from "node:fs/promises";
1841
1863
  import { isAbsolute as isAbsolute2 } from "node:path";
1842
1864
  var OVERRIDE_ENV = "SIFT_LIGHT_RG_PATH";
1843
1865
  var BUNDLED_REPAIR = `Reinstall sift-light with optional dependencies enabled for this platform, or set ${OVERRIDE_ENV} to an absolute ripgrep executable path.`;
@@ -1856,7 +1878,7 @@ async function resolveRipgrepExecutable() {
1856
1878
  }
1857
1879
  }
1858
1880
  try {
1859
- if (!(await stat(executable)).isFile())
1881
+ if (!(await stat2(executable)).isFile())
1860
1882
  throw new Error("Expected an executable file");
1861
1883
  await access(executable, constants.X_OK);
1862
1884
  } catch (cause) {
@@ -1945,12 +1967,12 @@ function describeUnreadableDiagnostics(diagnostics) {
1945
1967
  }
1946
1968
 
1947
1969
  // src/source.ts
1948
- import { readFile, realpath as realpath2, stat as stat2 } from "node:fs/promises";
1970
+ import { readFile, realpath as realpath2, stat as stat3 } from "node:fs/promises";
1949
1971
  var SOURCE_RANGE_METADATA_RESERVE_BYTES = 1024;
1950
1972
  var MAX_SOURCE_RANGE_BYTES = MAX_RESULT_BYTES - SOURCE_RANGE_METADATA_RESERVE_BYTES;
1951
1973
  async function getSourceRevision(path, onError) {
1952
1974
  try {
1953
- const metadata = await stat2(path);
1975
+ const metadata = await stat3(path);
1954
1976
  return sourceRevisionFromStats(metadata);
1955
1977
  } catch (error) {
1956
1978
  onError?.(error);
@@ -2411,6 +2433,7 @@ function createRipgrepRunner(options = {}) {
2411
2433
  const searchPath = resolve4(cwd, request.path ?? ".");
2412
2434
  const policy = new SearchPathPolicy(cwd);
2413
2435
  const validatedSearchPath = await policy.resolveSearchTarget(searchPath);
2436
+ const ripgrepCwd = await policy.ripgrepWorkingDirectory(validatedSearchPath);
2414
2437
  const expectedSearchTarget = await policy.resolveExistingPath(validatedSearchPath);
2415
2438
  const searchTarget = isPathInsideCwd(validatedSearchPath, cwd) ? relative2(resolve4(cwd), validatedSearchPath) || "." : validatedSearchPath;
2416
2439
  const args = buildRipgrepArguments(request, cwd, validatedSearchPath);
@@ -2500,7 +2523,7 @@ function createRipgrepRunner(options = {}) {
2500
2523
  ...policy.ripgrepGlobArguments(validatedSearchPath),
2501
2524
  "--",
2502
2525
  searchTarget
2503
- ], cwd, maxSourceRevisionFiles, signal, request.redact);
2526
+ ], ripgrepCwd, maxSourceRevisionFiles, signal, request.redact);
2504
2527
  let ignoredFileCount = 0;
2505
2528
  let ignoredFileSamples = [];
2506
2529
  let filesystemCoverage = "complete";
@@ -2522,7 +2545,7 @@ function createRipgrepRunner(options = {}) {
2522
2545
  ...policy.ripgrepGlobArguments(validatedSearchPath),
2523
2546
  "--",
2524
2547
  searchTarget
2525
- ], cwd, maxSourceRevisionFiles, signal, request.redact);
2548
+ ], ripgrepCwd, maxSourceRevisionFiles, signal, request.redact);
2526
2549
  const ignored = [...allCandidates.revisions.keys()].filter((path) => !before.revisions.has(path));
2527
2550
  ignoredFileCount = ignored.length;
2528
2551
  ignoredFileSamples = ignored.slice(0, 20).map((path) => displayPath(path, cwd).displayPath);
@@ -2551,7 +2574,7 @@ function createRipgrepRunner(options = {}) {
2551
2574
  if (before.unreadable.length > 0)
2552
2575
  filesystemCoverageReasons.add(describeUnreadableDiagnostics(before.unreadable));
2553
2576
  await assertSearchTargetIdentity(policy, validatedSearchPath, expectedSearchTarget);
2554
- const { code, stderr } = await runOwnedProcess({ executable, args, cwd, ...signal ? { signal } : {} }, (stdout) => consumeCappedLines(stdout, onLine, {
2577
+ const { code, stderr } = await runOwnedProcess({ executable, args, cwd: ripgrepCwd, ...signal ? { signal } : {} }, (stdout) => consumeCappedLines(stdout, onLine, {
2555
2578
  maxLineBytes: maxEventBytes,
2556
2579
  onLineTooLong: ({ prefix, observedBytes }) => {
2557
2580
  const source = oversizedMatchPath(prefix, cwd);
@@ -2876,7 +2899,7 @@ class OwnedTaskQueue {
2876
2899
  }
2877
2900
 
2878
2901
  // src/concept-search.ts
2879
- import { dirname as dirname3, join as join4, resolve as resolve12 } from "node:path";
2902
+ import { dirname as dirname4, join as join4, resolve as resolve12 } from "node:path";
2880
2903
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2881
2904
  import { StringDecoder } from "node:string_decoder";
2882
2905
  import { mkdir as mkdir2 } from "node:fs/promises";
@@ -3013,7 +3036,7 @@ import { extname as extname2, resolve as resolve11 } from "node:path";
3013
3036
  import { lstat, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
3014
3037
  import { constants as constants2 } from "node:fs";
3015
3038
  import { tmpdir } from "node:os";
3016
- import { dirname, join as join3, parse, relative as relative4, resolve as resolve8 } from "node:path";
3039
+ import { dirname as dirname2, join as join3, parse, relative as relative4, resolve as resolve8 } from "node:path";
3017
3040
 
3018
3041
  // src/workspace-files.ts
3019
3042
  import { relative as relative3, resolve as resolve7, sep as sep2 } from "node:path";
@@ -3031,6 +3054,7 @@ async function listWorkspaceFiles(cwd, signal, options = {}) {
3031
3054
  const absolutePath = resolve7(cwd, options.path ?? ".");
3032
3055
  const policy = new SearchPathPolicy(cwd);
3033
3056
  const searchPath = await policy.resolveSearchTarget(absolutePath);
3057
+ const ripgrepCwd = await policy.ripgrepWorkingDirectory(searchPath);
3034
3058
  const maxFiles = options.maxFiles ?? MAX_SOURCE_REVISION_FILES;
3035
3059
  if (!Number.isSafeInteger(maxFiles) || maxFiles < 1)
3036
3060
  throw new SiftLightError("Candidate file limit must be a positive integer");
@@ -3056,7 +3080,7 @@ async function listWorkspaceFiles(cwd, signal, options = {}) {
3056
3080
  "--",
3057
3081
  searchPath
3058
3082
  ],
3059
- cwd,
3083
+ cwd: ripgrepCwd,
3060
3084
  ...signal ? { signal } : {}
3061
3085
  }, async (stdout) => {
3062
3086
  let pending = Buffer.alloc(0);
@@ -3124,11 +3148,11 @@ function partitionPaths(paths) {
3124
3148
  }
3125
3149
  function relevantDirectories(cwd, paths) {
3126
3150
  const directories = new Set;
3127
- for (const path of [cwd, ...paths.map((sourcePath) => dirname(resolve8(cwd, sourcePath)))]) {
3151
+ for (const path of [cwd, ...paths.map((sourcePath) => dirname2(resolve8(cwd, sourcePath)))]) {
3128
3152
  let current = path;
3129
3153
  for (;; ) {
3130
3154
  directories.add(current);
3131
- const parent = dirname(current);
3155
+ const parent = dirname2(current);
3132
3156
  if (current === parent)
3133
3157
  break;
3134
3158
  current = parent;
@@ -3212,12 +3236,12 @@ async function filterHistoricalPaths(cwd, paths, request, signal) {
3212
3236
  for (const path of group) {
3213
3237
  const safe = workspaceRelativePath(absoluteCwd, path);
3214
3238
  const placeholder = resolve8(target, safe);
3215
- await mkdir(dirname(placeholder), { recursive: true });
3239
+ await mkdir(dirname2(placeholder), { recursive: true });
3216
3240
  await writeFile(placeholder, "");
3217
3241
  }
3218
3242
  for (const ignore of ignoreFiles) {
3219
3243
  const destination = join3(tree, ignore.local);
3220
- await mkdir(dirname(destination), { recursive: true });
3244
+ await mkdir(dirname2(destination), { recursive: true });
3221
3245
  await writeFile(destination, ignore.bytes);
3222
3246
  }
3223
3247
  const privacy = await listWorkspaceFiles(tree, signal, { ignoreParents: false });
@@ -4210,7 +4234,7 @@ async function readWorkspaceDocument(path, cwd, signal, expected, readBudget = M
4210
4234
  }
4211
4235
 
4212
4236
  // src/syntax.ts
4213
- import { dirname as dirname2 } from "node:path";
4237
+ import { dirname as dirname3 } from "node:path";
4214
4238
  import { fileURLToPath } from "node:url";
4215
4239
 
4216
4240
  // src/syntax-tree.ts
@@ -4751,7 +4775,7 @@ async function parseSyntax(path, text, signal, pattern) {
4751
4775
  const result = await runOwnedProcess({
4752
4776
  executable: process.execPath,
4753
4777
  args,
4754
- cwd: dirname2(worker),
4778
+ cwd: dirname3(worker),
4755
4779
  env,
4756
4780
  signal: controller.signal,
4757
4781
  input: Buffer.from(JSON.stringify({ language, text, pattern }))
@@ -5270,7 +5294,7 @@ async function similarities(query, passages, parent, onProgress) {
5270
5294
  worker,
5271
5295
  "--infer"
5272
5296
  ] : [worker, "--infer"],
5273
- cwd: dirname3(worker),
5297
+ cwd: dirname4(worker),
5274
5298
  env: { ...env, SIFT_LIGHT_CONCEPT_CACHE_STAGING_DIR: stagingRoot },
5275
5299
  ...parent ? { signal: parent } : {},
5276
5300
  input: Buffer.from(JSON.stringify({
@@ -5692,7 +5716,7 @@ async function structuralSearch(input, access) {
5692
5716
  }
5693
5717
 
5694
5718
  // src/evidence-service.ts
5695
- import { dirname as dirname4, extname as extname3, resolve as resolve21 } from "node:path";
5719
+ import { dirname as dirname5, extname as extname3, resolve as resolve21 } from "node:path";
5696
5720
 
5697
5721
  // src/analysis-store.ts
5698
5722
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -8846,7 +8870,7 @@ async function continueSource(cursor, access, continuations, expectedPath) {
8846
8870
  }
8847
8871
 
8848
8872
  // src/evidence-validation.ts
8849
- import { realpath as realpath5, stat as stat3 } from "node:fs/promises";
8873
+ import { realpath as realpath5, stat as stat4 } from "node:fs/promises";
8850
8874
  import { resolve as resolve19 } from "node:path";
8851
8875
 
8852
8876
  // src/evidence-validity.ts
@@ -8884,9 +8908,9 @@ async function confirmWorktreeState(path, cwd, signal) {
8884
8908
  const policy = new SearchPathPolicy(cwd);
8885
8909
  try {
8886
8910
  policy.assertPath(absolute);
8887
- const before = await stat3(absolute);
8911
+ const before = await stat4(absolute);
8888
8912
  const beforeCanonical = await realpath5(absolute);
8889
- const after = await stat3(absolute);
8913
+ const after = await stat4(absolute);
8890
8914
  const afterCanonical = await realpath5(absolute);
8891
8915
  if (!before.isFile() || !after.isFile()) {
8892
8916
  return { status: "stale", reason: "Source is no longer a regular file" };
@@ -9028,9 +9052,9 @@ async function validateSnapshotTarget(target, cwd, policy, signal) {
9028
9052
  const result = await confirmWorktreeState(target.path, cwd, signal);
9029
9053
  return { path: target.path, role: "source", ...result };
9030
9054
  }
9031
- const before = await stat3(absolute);
9055
+ const before = await stat4(absolute);
9032
9056
  const beforeCanonical = canonical;
9033
- const after = await stat3(absolute);
9057
+ const after = await stat4(absolute);
9034
9058
  const afterCanonical = await realpath5(absolute);
9035
9059
  if (!before.isFile() || !after.isFile()) {
9036
9060
  return {
@@ -10983,10 +11007,10 @@ function searchScope(request) {
10983
11007
  }
10984
11008
  async function navigationRoot(cwd, path, signal) {
10985
11009
  const absolute = resolve21(cwd, path);
10986
- const repository = await findGitRepository(dirname4(absolute), signal);
11010
+ const repository = await findGitRepository(dirname5(absolute), signal);
10987
11011
  if (repository)
10988
11012
  return repository;
10989
- return isPathInsideCwd(absolute, cwd) ? resolve21(cwd) : dirname4(absolute);
11013
+ return isPathInsideCwd(absolute, cwd) ? resolve21(cwd) : dirname5(absolute);
10990
11014
  }
10991
11015
  function navigationFilters(input) {
10992
11016
  const request = normalizeRequest({
@@ -11101,7 +11125,7 @@ class EvidenceService {
11101
11125
  maxFiles: access.maxFiles
11102
11126
  });
11103
11127
  const candidates = await collect(request);
11104
- if (input.changes || request.scope === "strict" || request.path === undefined || candidates.files.length > 0 || candidates.partial) {
11128
+ if (input.changes || request.path === undefined || !await new SearchPathPolicy(access.cwd).allowsScopeExpansion(request.path, request.scope) || candidates.files.length > 0 || candidates.partial) {
11105
11129
  return { candidates, request };
11106
11130
  }
11107
11131
  const { path: requestedPath, ...projectRequest } = request;
@@ -11246,7 +11270,7 @@ class EvidenceService {
11246
11270
  });
11247
11271
  }, signal);
11248
11272
  let chunkResults = await runChunks();
11249
- if (!input.changes && input.path !== undefined && input.scope !== "strict" && chunkResults.every(({ candidates }) => !candidates.partial && candidates.files.length === 0)) {
11273
+ if (!input.changes && input.path !== undefined && await new SearchPathPolicy(cwd).allowsScopeExpansion(input.path, input.scope) && chunkResults.every(({ candidates }) => !candidates.partial && candidates.files.length === 0)) {
11250
11274
  chunkResults = await runChunks(input.path.replace(/^@/, ""));
11251
11275
  }
11252
11276
  const reasons = new Set;
@@ -13415,7 +13439,7 @@ class SiftLightService {
13415
13439
  throw new SiftLightError("line requires mode=inspect");
13416
13440
  const request = normalizeRequest(input);
13417
13441
  let scan = await this.#runRipgrep(request, cwd, signal);
13418
- if (scan.totalMatches === 0 && request.path !== undefined && request.scope !== "strict") {
13442
+ if (scan.totalMatches === 0 && request.path !== undefined && await new SearchPathPolicy(cwd).allowsScopeExpansion(request.path, request.scope)) {
13419
13443
  const { path: requestedPath, ...projectRequest } = request;
13420
13444
  scan = await this.#runRipgrep({ ...projectRequest, expandedFromPath: requestedPath }, cwd, signal);
13421
13445
  }
@@ -13685,7 +13709,7 @@ function siftLightPromptGuidelines(structuredOutput = true) {
13685
13709
  return [
13686
13710
  SIFT_LIGHT_DISCOVERY_GUIDANCE,
13687
13711
  `Use sift-light for read-only content search. ${SOURCE_OUTPUT_GUIDANCE} For routine development searches, start with fast exact content, filename or applicable structural modes when the request has a usable name, symbol, error text or other literal clue. Omitted mode is ordinary exact search and never loads the local embedding model. Vector search is disabled by default; concept/hybrid require vectorSearchEnabled:true in sift-light.json and an installed model. They can take tens of seconds on an uncached scope, so select them only when semantic recall is needed. Omit mode and limit for automatic detail/summary selection; use mode="matches" for ordinary match metadata.`,
13688
- `An omitted path searches the project cwd. Use scope:"strict" for a question restricted to one path; otherwise, if an explicit subpath has zero matches, ordinary and content-analysis searches retry from cwd and return project-wide counts with an expansion notice. Explicit absolute paths and .. traversal can search outside cwd, except protected external system areas and .git internals. Git changes mode remains cwd-scoped.`,
13712
+ `An omitted path searches the project cwd. Use scope:"strict" to disable zero-match expansion; by default, explicit paths inside cwd that return no matches retry from cwd and report project-wide counts with an expansion notice. Explicit absolute paths and .. traversal can search outside cwd; those external targets stay isolated unless scope:"expand" opts into the same cwd retry. Protected external system areas and .git internals remain excluded. Git changes mode remains cwd-scoped.`,
13689
13713
  `Search output includes counts, categories, ranked paths, coverage and continuation metadata. Source excerpts may contain the searched text. Use mode="inspect" or the host read capability when exact source is required for an edit or verification.`,
13690
13714
  `Use file and directory distributions to choose evidence. Reuse the visible cursor with path or paths for match metadata; mode="summary" pages the remaining file statistics. Match counts are not relevance scores.`,
13691
13715
  `Mode="inspect" with a direct path/line or ordinary retained match returns bounded source windows and source-revision metadata. Some retained analysis selectors return only revision metadata; use direct path/line or the host read capability when source is needed. Do not use inspection merely to obtain a citation.`,
@@ -1,6 +1,6 @@
1
- import { realpath } from "node:fs/promises";
1
+ import { realpath, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { SiftLightError } from "./errors.js";
5
5
 
6
6
  const POSIX_SPECIAL_ROOTS = ["/dev", "/proc", "/sys"] as const;
@@ -173,6 +173,26 @@ export class SearchPathPolicy {
173
173
  : absolute;
174
174
  }
175
175
 
176
+ async allowsScopeExpansion(path: string, scope?: "strict" | "expand"): Promise<boolean> {
177
+ if (scope === "strict") return false;
178
+ if (scope === "expand") return true;
179
+ const searchTarget = await this.resolveSearchTarget(path);
180
+ return isPathInsideRoot(searchTarget, this.cwd);
181
+ }
182
+
183
+ async ripgrepWorkingDirectory(searchPath: string): Promise<string> {
184
+ const absolute = resolve(this.cwd, searchPath);
185
+ if (isPathInsideRoot(absolute, this.cwd)) return this.cwd;
186
+ let isDirectory: boolean;
187
+ try {
188
+ isDirectory = (await stat(absolute)).isDirectory();
189
+ } catch (error) {
190
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return this.cwd;
191
+ throw error;
192
+ }
193
+ return isDirectory ? absolute : dirname(absolute);
194
+ }
195
+
176
196
  ripgrepGlobArguments(searchPath: string): string[] {
177
197
  const absolute = resolve(this.cwd, searchPath);
178
198
  if (isPathInsideRoot(absolute, this.cwd)) return [];
@@ -10,7 +10,7 @@ export function siftLightPromptGuidelines(structuredOutput = true): string[] {
10
10
  return [
11
11
  SIFT_LIGHT_DISCOVERY_GUIDANCE,
12
12
  `Use sift-light for read-only content search. ${SOURCE_OUTPUT_GUIDANCE} For routine development searches, start with fast exact content, filename or applicable structural modes when the request has a usable name, symbol, error text or other literal clue. Omitted mode is ordinary exact search and never loads the local embedding model. Vector search is disabled by default; concept/hybrid require vectorSearchEnabled:true in sift-light.json and an installed model. They can take tens of seconds on an uncached scope, so select them only when semantic recall is needed. Omit mode and limit for automatic detail/summary selection; use mode="matches" for ordinary match metadata.`,
13
- `An omitted path searches the project cwd. Use scope:"strict" for a question restricted to one path; otherwise, if an explicit subpath has zero matches, ordinary and content-analysis searches retry from cwd and return project-wide counts with an expansion notice. Explicit absolute paths and .. traversal can search outside cwd, except protected external system areas and .git internals. Git changes mode remains cwd-scoped.`,
13
+ `An omitted path searches the project cwd. Use scope:"strict" to disable zero-match expansion; by default, explicit paths inside cwd that return no matches retry from cwd and report project-wide counts with an expansion notice. Explicit absolute paths and .. traversal can search outside cwd; those external targets stay isolated unless scope:"expand" opts into the same cwd retry. Protected external system areas and .git internals remain excluded. Git changes mode remains cwd-scoped.`,
14
14
  `Search output includes counts, categories, ranked paths, coverage and continuation metadata. Source excerpts may contain the searched text. Use mode="inspect" or the host read capability when exact source is required for an edit or verification.`,
15
15
  `Use file and directory distributions to choose evidence. Reuse the visible cursor with path or paths for match metadata; mode="summary" pages the remaining file statistics. Match counts are not relevance scores.`,
16
16
  `Mode="inspect" with a direct path/line or ordinary retained match returns bounded source windows and source-revision metadata. Some retained analysis selectors return only revision metadata; use direct path/line or the host read capability when source is needed. Do not use inspection merely to obtain a citation.`,
package/src/rg.ts CHANGED
@@ -332,6 +332,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
332
332
  const searchPath = resolve(cwd, request.path ?? ".");
333
333
  const policy = new SearchPathPolicy(cwd);
334
334
  const validatedSearchPath = await policy.resolveSearchTarget(searchPath);
335
+ const ripgrepCwd = await policy.ripgrepWorkingDirectory(validatedSearchPath);
335
336
  const expectedSearchTarget = await policy.resolveExistingPath(validatedSearchPath);
336
337
  const searchTarget = isPathInsideCwd(validatedSearchPath, cwd)
337
338
  ? relative(resolve(cwd), validatedSearchPath) || "."
@@ -430,7 +431,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
430
431
  "--",
431
432
  searchTarget,
432
433
  ],
433
- cwd,
434
+ ripgrepCwd,
434
435
  maxSourceRevisionFiles,
435
436
  signal,
436
437
  request.redact,
@@ -463,7 +464,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
463
464
  "--",
464
465
  searchTarget,
465
466
  ],
466
- cwd,
467
+ ripgrepCwd,
467
468
  maxSourceRevisionFiles,
468
469
  signal,
469
470
  request.redact,
@@ -501,7 +502,7 @@ export function createRipgrepRunner(options: RipgrepRunnerOptions = {}) {
501
502
  filesystemCoverageReasons.add(describeUnreadableDiagnostics(before.unreadable));
502
503
  await assertSearchTargetIdentity(policy, validatedSearchPath, expectedSearchTarget);
503
504
  const { code, stderr } = await runOwnedProcess(
504
- { executable, args, cwd, ...(signal ? { signal } : {}) },
505
+ { executable, args, cwd: ripgrepCwd, ...(signal ? { signal } : {}) },
505
506
  (stdout) =>
506
507
  consumeCappedLines(stdout, onLine, {
507
508
  maxLineBytes: maxEventBytes,
@@ -6,9 +6,19 @@ import {
6
6
  type SearchKind,
7
7
  type ShellLanguage,
8
8
  } from "./search-policy-commands.js";
9
+ import { isStdinOnlySearchFilter } from "./search-policy-stdin-filter.js";
9
10
 
10
11
  export const MAX_POLICY_COMMAND_BYTES = 64 * 1024;
11
12
  const MAX_SHELL_NESTING = 4;
13
+ const POWERSHELL_FILE_OBJECT_PRODUCERS = new Set([
14
+ "get-item",
15
+ "gi",
16
+ "get-childitem",
17
+ "gci",
18
+ "dir",
19
+ "ls",
20
+ ]);
21
+ const POWERSHELL_STRING_CONVERTERS = new Set(["out-string", "oss", "convertto-json"]);
12
22
 
13
23
  export interface ShellSearchMatch {
14
24
  kind: SearchKind;
@@ -116,6 +126,18 @@ function commandWords(node: Node, language: ShellLanguage): Array<string | null>
116
126
  return commandWordNodes(node, language).map((word) => literalWord(word, language));
117
127
  }
118
128
 
129
+ function hasBashWordExpansion(text: string): boolean {
130
+ if (text.startsWith("~") || text.startsWith("=")) return true;
131
+ for (let index = 0; index < text.length; index += 1) {
132
+ if (text[index] === "\\") {
133
+ index += 1;
134
+ } else if ("*?[]{}".includes(text[index] ?? "")) {
135
+ return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+
119
141
  function hasUntranslatedShellSyntax(node: Node, language: ShellLanguage): boolean {
120
142
  const text = node.text;
121
143
  if (language === "powershell") {
@@ -131,8 +153,7 @@ function hasUntranslatedShellSyntax(node: Node, language: ShellLanguage): boolea
131
153
  );
132
154
  }
133
155
  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("=");
156
+ if (["word", "number", "command_name"].includes(node.type)) return hasBashWordExpansion(text);
136
157
  if (node.type === "concatenation")
137
158
  return node.namedChildren.some(
138
159
  (child) => child !== null && hasUntranslatedShellSyntax(child, language),
@@ -140,36 +161,80 @@ function hasUntranslatedShellSyntax(node: Node, language: ShellLanguage): boolea
140
161
  return false;
141
162
  }
142
163
 
164
+ function hasInputRedirectNodes(owner: Node): boolean {
165
+ return owner
166
+ .descendantsOfType(["file_redirect", "herestring_redirect", "heredoc_redirect"])
167
+ .some((redirect) => redirect !== null && /^(?:\d+)?</u.test(redirect.text));
168
+ }
169
+
170
+ function hasInputRedirect(node: Node): boolean {
171
+ if (hasInputRedirectNodes(node)) return true;
172
+ for (let parent = node.parent; parent; parent = parent.parent) {
173
+ if (parent.type !== "redirected_statement") continue;
174
+ const body = parent.childForFieldName("body");
175
+ const last =
176
+ body?.descendantsOfType("command").at(-1) ?? (body?.type === "command" ? body : undefined);
177
+ if (
178
+ last?.startIndex === node.startIndex &&
179
+ last.endIndex === node.endIndex &&
180
+ parent
181
+ .childrenForFieldName("redirect")
182
+ .some((redirect) => redirect !== null && hasInputRedirectNodes(redirect))
183
+ )
184
+ return true;
185
+ }
186
+ return false;
187
+ }
188
+
143
189
  function isSafePipelineFilter(
144
190
  node: Node,
145
191
  language: ShellLanguage,
146
192
  words: Array<string | null>,
147
193
  ): 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);
194
+ const wordNodes = commandWordNodes(node, language);
162
195
  if (
163
- !last ||
164
- last.startIndex !== node.startIndex ||
165
- last.endIndex !== node.endIndex ||
166
- commands.length < 2
196
+ !isStdinOnlySearchFilter(
197
+ words,
198
+ language,
199
+ wordNodes.map((word) => word.type === "command_parameter"),
200
+ )
167
201
  )
168
202
  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
- });
203
+ if (wordNodes.some((word) => hasUntranslatedShellSyntax(word, language))) return false;
204
+ if (hasInputRedirect(node)) return false;
205
+ const pipeline = node.parent;
206
+ if (!pipeline || !["pipeline", "pipeline_chain"].includes(pipeline.type)) return false;
207
+ const stages = pipeline.namedChildren.filter((child): child is Node => child !== null);
208
+ const position = stages.findIndex(
209
+ (stage) => stage.startIndex <= node.startIndex && stage.endIndex >= node.endIndex,
210
+ );
211
+ if (position < 1) return false;
212
+ let fileObjectFlow = false;
213
+ for (const [index, stage] of stages.entries()) {
214
+ if (index === position) continue;
215
+ const commands = stage.descendantsOfType("command");
216
+ for (const candidate of commands) {
217
+ if (candidate === null) continue;
218
+ const candidateWords = commandWords(candidate, language);
219
+ const decision = classifyCommand(candidateWords, language);
220
+ if (decision.kind || decision.nested) return false;
221
+ if (language !== "powershell" || index > position) continue;
222
+ const executable = candidateWords[0];
223
+ if (executable === null || executable === undefined) continue;
224
+ const name = executableName(executable).toLowerCase();
225
+ if (POWERSHELL_FILE_OBJECT_PRODUCERS.has(name)) fileObjectFlow = true;
226
+ }
227
+ if (language === "powershell" && index < position && stage.type === "command") {
228
+ const executable = commandWords(stage, language)[0];
229
+ if (
230
+ executable !== null &&
231
+ executable !== undefined &&
232
+ POWERSHELL_STRING_CONVERTERS.has(executableName(executable).toLowerCase())
233
+ )
234
+ fileObjectFlow = false;
235
+ }
236
+ }
237
+ return !fileObjectFlow;
173
238
  }
174
239
 
175
240
  /** WASM grammars are shipped with the hook; parsing never reads shell scripts or executes code. */