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.
Files changed (145) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/CONTRIBUTING.md +19 -0
  3. package/LICENSE +661 -0
  4. package/README.md +177 -0
  5. package/README.zh-CN.md +177 -0
  6. package/SECURITY.md +25 -0
  7. package/package.json +142 -0
  8. package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
  9. package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
  10. package/plugins/sift-light/.mcp.json +24 -0
  11. package/plugins/sift-light/LICENSE +661 -0
  12. package/plugins/sift-light/concept-worker.mjs +567 -0
  13. package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
  14. package/plugins/sift-light/hooks/hooks.json +16 -0
  15. package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
  16. package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
  17. package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
  18. package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
  19. package/plugins/sift-light/kimi.plugin.json +36 -0
  20. package/plugins/sift-light/mcp-server.mjs +1 -0
  21. package/plugins/sift-light/omp-extension.mjs +20914 -0
  22. package/plugins/sift-light/package.json +11 -0
  23. package/plugins/sift-light/syntax-worker.mjs +280 -0
  24. package/plugins/sift-light/syntax-worker.toml +2 -0
  25. package/src/analysis-evidence.ts +43 -0
  26. package/src/analysis-limits.ts +27 -0
  27. package/src/analysis-store.ts +559 -0
  28. package/src/analysis-term-pages.ts +73 -0
  29. package/src/analysis-types.ts +162 -0
  30. package/src/audit-search.ts +391 -0
  31. package/src/capped-lines.ts +112 -0
  32. package/src/concept-embedding-cache.ts +186 -0
  33. package/src/concept-inference.ts +218 -0
  34. package/src/concept-model.ts +96 -0
  35. package/src/concept-search.ts +577 -0
  36. package/src/concept-setup.ts +75 -0
  37. package/src/concept-source-generation.ts +268 -0
  38. package/src/concept-worker.mjs +567 -0
  39. package/src/concept-worker.ts +210 -0
  40. package/src/config-reader.ts +283 -0
  41. package/src/config.ts +37 -0
  42. package/src/context-budget.ts +26 -0
  43. package/src/discovery-errors.ts +2 -0
  44. package/src/errors.ts +95 -0
  45. package/src/evidence-candidates.ts +344 -0
  46. package/src/evidence-ranking.ts +48 -0
  47. package/src/evidence-service.ts +1047 -0
  48. package/src/evidence-validation.ts +466 -0
  49. package/src/evidence-validity.ts +33 -0
  50. package/src/excerpt.ts +53 -0
  51. package/src/file-discovery.ts +150 -0
  52. package/src/file-metadata-filter.ts +46 -0
  53. package/src/format.ts +407 -0
  54. package/src/git-diff.ts +195 -0
  55. package/src/git-process.ts +189 -0
  56. package/src/git-repository.ts +367 -0
  57. package/src/git-source.ts +385 -0
  58. package/src/historical-paths.ts +205 -0
  59. package/src/hybrid-search.ts +340 -0
  60. package/src/import-model.ts +611 -0
  61. package/src/import-navigation.ts +100 -0
  62. package/src/import-resolution.ts +240 -0
  63. package/src/index.ts +97 -0
  64. package/src/inspect-batch.ts +265 -0
  65. package/src/inspect.ts +271 -0
  66. package/src/language-capabilities.ts +157 -0
  67. package/src/language-capability-definitions.ts +204 -0
  68. package/src/literal-search.ts +25 -0
  69. package/src/mcp-cli.ts +21 -0
  70. package/src/mcp-model-output.ts +120 -0
  71. package/src/mcp-output.ts +9 -0
  72. package/src/mcp-semantic-judge.ts +39 -0
  73. package/src/mcp-server.mjs +14064 -0
  74. package/src/mcp-server.ts +157 -0
  75. package/src/mcp-stdio.ts +114 -0
  76. package/src/mcp.ts +631 -0
  77. package/src/model-error.ts +120 -0
  78. package/src/multi-term-search.ts +122 -0
  79. package/src/omp-index.ts +277 -0
  80. package/src/omp-marketplace-entry.ts +7 -0
  81. package/src/operation-lifecycle.ts +413 -0
  82. package/src/operation-output.ts +100 -0
  83. package/src/owned-parallel.ts +22 -0
  84. package/src/owned-process.ts +102 -0
  85. package/src/owned-task-queue.ts +38 -0
  86. package/src/package-version.ts +3 -0
  87. package/src/path-policy.ts +203 -0
  88. package/src/pi-search-policy.ts +33 -0
  89. package/src/prompt-guidelines.ts +54 -0
  90. package/src/python-outline.ts +231 -0
  91. package/src/record-value.ts +3 -0
  92. package/src/redaction.ts +95 -0
  93. package/src/request-contract-catalog.ts +215 -0
  94. package/src/request-contract-error.ts +96 -0
  95. package/src/request-contract.ts +481 -0
  96. package/src/request.ts +132 -0
  97. package/src/result-statistics.ts +178 -0
  98. package/src/rg.ts +574 -0
  99. package/src/ripgrep-diagnostics.ts +122 -0
  100. package/src/ripgrep-executable.ts +43 -0
  101. package/src/runtime.ts +53 -0
  102. package/src/scan-revisions.ts +148 -0
  103. package/src/script-runtime.ts +11 -0
  104. package/src/search-policy-commands.ts +170 -0
  105. package/src/search-policy-hook.ts +61 -0
  106. package/src/search-policy-recovery.ts +388 -0
  107. package/src/search-policy-shell.ts +280 -0
  108. package/src/search-policy.ts +102 -0
  109. package/src/search-retention.ts +77 -0
  110. package/src/semantic-judge-batches.ts +135 -0
  111. package/src/semantic-judge.ts +519 -0
  112. package/src/service.ts +945 -0
  113. package/src/session-summary.ts +100 -0
  114. package/src/snapshot-store.ts +203 -0
  115. package/src/source-access.ts +288 -0
  116. package/src/source-continuations.ts +168 -0
  117. package/src/source-document.ts +277 -0
  118. package/src/source-inspection.ts +731 -0
  119. package/src/source-pages.ts +112 -0
  120. package/src/source.ts +286 -0
  121. package/src/structural-search.ts +125 -0
  122. package/src/structure.ts +259 -0
  123. package/src/summary-previews.ts +96 -0
  124. package/src/summary.ts +79 -0
  125. package/src/syntax-facts.ts +464 -0
  126. package/src/syntax-fields.ts +161 -0
  127. package/src/syntax-search.ts +361 -0
  128. package/src/syntax-tree.ts +30 -0
  129. package/src/syntax-types.ts +72 -0
  130. package/src/syntax-worker.mjs +280 -0
  131. package/src/syntax-worker.toml +2 -0
  132. package/src/syntax-worker.ts +143 -0
  133. package/src/syntax.ts +259 -0
  134. package/src/test-navigation-facts.ts +261 -0
  135. package/src/test-navigation.ts +543 -0
  136. package/src/tool-schema.ts +321 -0
  137. package/src/tui/dashboard.ts +107 -0
  138. package/src/tui/layout.ts +291 -0
  139. package/src/tui/palette.ts +73 -0
  140. package/src/tui/presentation.ts +240 -0
  141. package/src/tui/renderers.ts +106 -0
  142. package/src/types.ts +368 -0
  143. package/src/validation-output.ts +148 -0
  144. package/src/validation-types.ts +48 -0
  145. package/src/workspace-files.ts +142 -0
@@ -0,0 +1,43 @@
1
+ import { constants } from "node:fs";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { isAbsolute } from "node:path";
4
+ import { SiftLightError } from "./errors.js";
5
+
6
+ const OVERRIDE_ENV = "SIFT_LIGHT_RG_PATH";
7
+ const BUNDLED_REPAIR = `Reinstall sift-light with optional dependencies enabled for this platform, or set ${OVERRIDE_ENV} to an absolute ripgrep executable path.`;
8
+
9
+ /** One executable choice for content, filename and historical-source searches. */
10
+ export async function resolveRipgrepExecutable(): Promise<string> {
11
+ const configured = process.env[OVERRIDE_ENV];
12
+ if (configured !== undefined && !isAbsolute(configured))
13
+ throw new SiftLightError(
14
+ `${OVERRIDE_ENV} must be an absolute executable file path; shell functions, aliases and relative paths are not supported.`,
15
+ );
16
+
17
+ let executable: string;
18
+ if (configured !== undefined) {
19
+ executable = configured;
20
+ } else {
21
+ try {
22
+ // Resolve lazily so a broken installation remains a visible tool error and an
23
+ // explicit executable can be used without loading the platform package.
24
+ executable = (await import("@vscode/ripgrep")).rgPath;
25
+ } catch (cause) {
26
+ throw new SiftLightError(`Bundled ripgrep is unavailable. ${BUNDLED_REPAIR}`, { cause });
27
+ }
28
+ }
29
+
30
+ try {
31
+ if (!(await stat(executable)).isFile()) throw new Error("Expected an executable file");
32
+ await access(executable, constants.X_OK);
33
+ } catch (cause) {
34
+ const repair =
35
+ configured === undefined
36
+ ? BUNDLED_REPAIR
37
+ : `Fix ${OVERRIDE_ENV} or unset it to use bundled ripgrep. No fallback was attempted.`;
38
+ throw new SiftLightError(`ripgrep executable is unavailable: ${executable}. ${repair}`, {
39
+ cause,
40
+ });
41
+ }
42
+ return executable;
43
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { SiftLightLocale } from "./config.js";
2
+ import type { SiftLightInput, SiftLightSearchOptions, SiftLightService } from "./service.js";
3
+ import { type SessionSummarySnapshot, SessionSummary } from "./session-summary.js";
4
+ import type { ContextBudget, SiftLightResult } from "./types.js";
5
+
6
+ export class SiftLightRuntime {
7
+ readonly #service: SiftLightService;
8
+ readonly #summary = new SessionSummary();
9
+
10
+ constructor(service: SiftLightService) {
11
+ this.#service = service;
12
+ }
13
+
14
+ async search(
15
+ input: SiftLightInput,
16
+ cwd: string,
17
+ signal?: AbortSignal,
18
+ contextBudget?: ContextBudget,
19
+ ): Promise<SiftLightResult> {
20
+ const searchOptions: SiftLightSearchOptions = {};
21
+ if (contextBudget) searchOptions.contextBudget = contextBudget;
22
+ const result = await this.#service.search(input, cwd, signal, searchOptions);
23
+ this.#summary.record(input, result);
24
+ return result;
25
+ }
26
+ recordFailure(): void {
27
+ this.#summary.recordFailure();
28
+ }
29
+
30
+ get sessionSummary(): SessionSummarySnapshot {
31
+ return this.#summary.snapshot;
32
+ }
33
+
34
+ formatSessionStatus(locale: SiftLightLocale): string | undefined {
35
+ return this.#summary.format(locale);
36
+ }
37
+
38
+ clear(): void {
39
+ this.#service.clear();
40
+ }
41
+
42
+ async shutdown(): Promise<void> {
43
+ await this.#service.shutdown();
44
+ }
45
+
46
+ get snapshotCount(): number {
47
+ return this.#service.snapshotCount;
48
+ }
49
+
50
+ get storedMatches(): number {
51
+ return this.#service.storedMatches;
52
+ }
53
+ }
@@ -0,0 +1,148 @@
1
+ import { resolve } from "node:path";
2
+ import { abortError, SiftLightError } from "./errors.js";
3
+ import { runOwnedProcess } from "./owned-process.js";
4
+ import {
5
+ boundedRipgrepDiagnostic,
6
+ classifyRipgrepDiagnostics,
7
+ createRipgrepInputError,
8
+ type RipgrepUnreadableDiagnostic,
9
+ } from "./ripgrep-diagnostics.js";
10
+ import { getSourceRevision, sameSourceRevision } from "./source.js";
11
+ import {
12
+ MAX_PROTOCOL_LINE_BYTES,
13
+ MAX_SOURCE_REVISION_CONCURRENCY,
14
+ type SourceRevision,
15
+ } from "./types.js";
16
+
17
+ async function captureRevision(
18
+ path: string,
19
+ revisions: Map<string, SourceRevision>,
20
+ onRevisionError?: (path: string, error: unknown) => void,
21
+ ): Promise<void> {
22
+ let failed = false;
23
+ let failure: unknown;
24
+ const revision = await getSourceRevision(path, (error) => {
25
+ failed = true;
26
+ failure = error;
27
+ });
28
+ if (revision) revisions.set(path, revision);
29
+ if (failed) onRevisionError?.(path, failure);
30
+ }
31
+
32
+ async function captureBatch(
33
+ paths: string[],
34
+ revisions: Map<string, SourceRevision>,
35
+ signal?: AbortSignal,
36
+ onRevisionError?: (path: string, error: unknown) => void,
37
+ ): Promise<void> {
38
+ if (signal?.aborted) throw abortError();
39
+ await Promise.all(paths.map((path) => captureRevision(path, revisions, onRevisionError)));
40
+ if (signal?.aborted) throw abortError();
41
+ }
42
+
43
+ /** Enumerate names only; finish all retained metadata reads before starting content search. */
44
+ export async function captureCandidateRevisions(
45
+ executable: string,
46
+ args: string[],
47
+ cwd: string,
48
+ maxFiles: number,
49
+ signal?: AbortSignal,
50
+ redact = false,
51
+ ): Promise<{
52
+ revisions: Map<string, SourceRevision>;
53
+ unreadable: RipgrepUnreadableDiagnostic[];
54
+ enumerationTruncated: boolean;
55
+ invalidPathCount: number;
56
+ }> {
57
+ const revisions = new Map<string, SourceRevision>();
58
+ let candidateCount = 0;
59
+ let enumerationTruncated = false;
60
+ let invalidPathCount = 0;
61
+ const metadataFailures: RipgrepUnreadableDiagnostic[] = [];
62
+ const recordMetadataFailure = (path: string, error: unknown): void => {
63
+ const reason = error instanceof Error ? error.message : String(error);
64
+ metadataFailures.push({
65
+ path,
66
+ message: `Source metadata unavailable: ${boundedRipgrepDiagnostic(reason, redact)}`,
67
+ });
68
+ };
69
+ const result = await runOwnedProcess(
70
+ { executable, args, cwd, ...(signal ? { signal } : {}) },
71
+ async (stdout) => {
72
+ let pending = Buffer.alloc(0);
73
+ let batch: string[] = [];
74
+ for await (const chunk of stdout) {
75
+ if (signal?.aborted) throw abortError();
76
+ pending = Buffer.concat([pending, chunk]);
77
+ let delimiter = pending.indexOf(0);
78
+ while (delimiter >= 0) {
79
+ const rawPath = pending.subarray(0, delimiter);
80
+ if (rawPath.length > MAX_PROTOCOL_LINE_BYTES) {
81
+ throw new SiftLightError("ripgrep file path exceeds the protocol byte limit");
82
+ }
83
+ const path = rawPath.toString("utf8");
84
+ // Lossy file names cannot provide trustworthy path-based revision evidence.
85
+ if (Buffer.from(path, "utf8").equals(rawPath)) {
86
+ if (candidateCount < maxFiles) {
87
+ candidateCount += 1;
88
+ batch.push(resolve(cwd, path));
89
+ } else {
90
+ enumerationTruncated = true;
91
+ }
92
+ if (batch.length === MAX_SOURCE_REVISION_CONCURRENCY) {
93
+ // oxlint-disable-next-line no-await-in-loop -- backpressure bounds concurrent metadata reads.
94
+ await captureBatch(batch, revisions, signal, recordMetadataFailure);
95
+ batch = [];
96
+ }
97
+ } else {
98
+ invalidPathCount += 1;
99
+ }
100
+ pending = pending.subarray(delimiter + 1);
101
+ delimiter = pending.indexOf(0);
102
+ }
103
+ if (pending.length > MAX_PROTOCOL_LINE_BYTES) {
104
+ throw new SiftLightError("ripgrep file path exceeds the protocol byte limit");
105
+ }
106
+ }
107
+ if (pending.length > 0) {
108
+ throw new SiftLightError("ripgrep file enumeration ended without a NUL delimiter");
109
+ }
110
+ await captureBatch(batch, revisions, signal, recordMetadataFailure);
111
+ },
112
+ );
113
+ const diagnostics = classifyRipgrepDiagnostics(result.stderr);
114
+ const inputError = createRipgrepInputError(result.stderr, redact);
115
+ const unreadable = [...diagnostics.unreadable, ...metadataFailures];
116
+ if (inputError) throw inputError;
117
+ if (result.code === 2 && diagnostics.other.length === 0 && unreadable.length > 0)
118
+ return { revisions, unreadable, enumerationTruncated, invalidPathCount };
119
+ if (result.code !== 0 && result.code !== 1) {
120
+ throw new SiftLightError(
121
+ result.stderr.trim() || `ripgrep file enumeration exited with status ${String(result.code)}`,
122
+ );
123
+ }
124
+ return { revisions, unreadable, enumerationTruncated, invalidPathCount };
125
+ }
126
+
127
+ export async function retainStableSourceRevisions(
128
+ paths: ReadonlySet<string>,
129
+ before: ReadonlyMap<string, SourceRevision>,
130
+ signal?: AbortSignal,
131
+ ): Promise<Map<string, SourceRevision>> {
132
+ const after = new Map<string, SourceRevision>();
133
+ const candidates = [...paths].filter((path) => before.has(path));
134
+ for (let offset = 0; offset < candidates.length; offset += MAX_SOURCE_REVISION_CONCURRENCY) {
135
+ // oxlint-disable-next-line no-await-in-loop -- batches keep source metadata concurrency bounded.
136
+ await captureBatch(
137
+ candidates.slice(offset, offset + MAX_SOURCE_REVISION_CONCURRENCY),
138
+ after,
139
+ signal,
140
+ );
141
+ }
142
+ return new Map(
143
+ [...after].filter(([path, revision]) => {
144
+ const initial = before.get(path);
145
+ return initial !== undefined && sameSourceRevision(initial, revision);
146
+ }),
147
+ );
148
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Children executing JavaScript must run the runtime, not a compiled host's
3
+ * bundled entrypoint. Bun's documented CLI mode preserves the host runtime
4
+ * without requiring a second executable on PATH. Never mutate the host env.
5
+ */
6
+ export function scriptRuntimeEnvironment(): NodeJS.ProcessEnv {
7
+ const env = { ...process.env };
8
+ delete env.NODE_OPTIONS;
9
+ if (process.versions.bun) env.BUN_BE_BUN = "1";
10
+ return env;
11
+ }
@@ -0,0 +1,170 @@
1
+ export type SearchKind = "content" | "files";
2
+ export type ShellLanguage = "bash" | "powershell";
3
+
4
+ export interface CommandDecision {
5
+ kind?: SearchKind;
6
+ command?: string;
7
+ nested?: { command: string; language: ShellLanguage };
8
+ }
9
+
10
+ const contentCommands = new Set([
11
+ "rg",
12
+ "ripgrep",
13
+ "grep",
14
+ "egrep",
15
+ "fgrep",
16
+ "ag",
17
+ "ack",
18
+ "ack-grep",
19
+ "ugrep",
20
+ "pt",
21
+ "sift",
22
+ "findstr",
23
+ ]);
24
+ const fileCommands = new Set(["find", "fd", "fdfind", "locate", "mlocate", "plocate"]);
25
+
26
+ export function executableName(value: string): string {
27
+ return (
28
+ value
29
+ .split(/[\\/]/u)
30
+ .at(-1)
31
+ ?.replace(/\.(?:exe|cmd|bat)$/iu, "") ?? value
32
+ );
33
+ }
34
+
35
+ function afterOptions(args: readonly (string | null)[], valueOptions: ReadonlySet<string>): number {
36
+ let index = 0;
37
+ while (index < args.length) {
38
+ const value = args[index];
39
+ if (value === null || value === undefined) return args.length;
40
+ if (value === "--") return index + 1;
41
+ if (!value.startsWith("-")) return index;
42
+ index += valueOptions.has(value) ? 2 : 1;
43
+ }
44
+ return index;
45
+ }
46
+
47
+ const gitValueOptions = new Set([
48
+ "-C",
49
+ "-c",
50
+ "--git-dir",
51
+ "--work-tree",
52
+ "--namespace",
53
+ "--config-env",
54
+ ]);
55
+ const envValueOptions = new Set(["-u", "--unset", "-C", "--chdir"]);
56
+ const wrapperValueOptions: Readonly<Record<string, ReadonlySet<string>>> = {
57
+ env: envValueOptions,
58
+ sudo: new Set([
59
+ "-u",
60
+ "--user",
61
+ "-g",
62
+ "--group",
63
+ "-h",
64
+ "--host",
65
+ "-p",
66
+ "--prompt",
67
+ "-C",
68
+ "--close-from",
69
+ "-T",
70
+ "--command-timeout",
71
+ "-r",
72
+ "--role",
73
+ "-t",
74
+ "--type",
75
+ ]),
76
+ nice: new Set(["-n", "--adjustment"]),
77
+ timeout: new Set(["-s", "--signal", "-k", "--kill-after"]),
78
+ time: new Set(["-f", "--format", "-o", "--output"]),
79
+ xargs: new Set([
80
+ "-a",
81
+ "--arg-file",
82
+ "-d",
83
+ "--delimiter",
84
+ "-E",
85
+ "-I",
86
+ "-L",
87
+ "-n",
88
+ "--max-args",
89
+ "-P",
90
+ "--max-procs",
91
+ "-s",
92
+ "--max-chars",
93
+ ]),
94
+ exec: new Set(["-a"]),
95
+ command: new Set(),
96
+ nohup: new Set(),
97
+ busybox: new Set(),
98
+ };
99
+
100
+ /** Classify executable positions only; never inspect ordinary argument text as a command. */
101
+ export function classifyCommand(
102
+ argv: readonly (string | null)[],
103
+ language: ShellLanguage,
104
+ depth = 0,
105
+ ): CommandDecision {
106
+ if (depth > 8)
107
+ throw new Error("Search policy wrapper nesting exceeds 8 levels; simplify the command");
108
+ const first = argv[0];
109
+ if (first === null || first === undefined) return {};
110
+ const rawName = executableName(first);
111
+ const name = language === "powershell" ? rawName.toLowerCase() : rawName;
112
+ const args = argv.slice(1);
113
+ if (args.length === 1 && (args[0] === "--help" || args[0] === "--version")) return {};
114
+ if (contentCommands.has(name)) return { kind: "content", command: name };
115
+ if (fileCommands.has(name)) return { kind: "files", command: name };
116
+ if (language === "powershell") {
117
+ if (name === "select-string" || name === "sls") return { kind: "content", command: name };
118
+ if (
119
+ ["get-childitem", "gci", "dir", "ls"].includes(name) &&
120
+ args.some((arg, index) => {
121
+ if (arg === null) return false;
122
+ if (/^-(?:recurse|r|filter|include)(?::|$)/iu.test(arg)) return true;
123
+ // LiteralPath treats brackets and wildcard characters as ordinary path text.
124
+ if (/^-literalpath(?::|$)/iu.test(arg) || /^-literalpath$/iu.test(args[index - 1] ?? ""))
125
+ return false;
126
+ return /[*?[]/u.test(arg);
127
+ })
128
+ )
129
+ return { kind: "files", command: name };
130
+ }
131
+ if (name === "git") {
132
+ return args[afterOptions(args, gitValueOptions)] === "grep"
133
+ ? { kind: "content", command: "git grep" }
134
+ : {};
135
+ }
136
+ if (["bash", "sh", "zsh", "dash", "ksh"].includes(name)) {
137
+ const index = args.findIndex((arg) => arg !== null && /^-[a-z]*c[a-z]*$/u.test(arg));
138
+ const command = index >= 0 ? args[index + 1] : undefined;
139
+ return typeof command === "string" ? { nested: { command, language: "bash" } } : {};
140
+ }
141
+ if (name === "pwsh" || name === "powershell") {
142
+ const index = args.findIndex((arg) => arg !== null && /^-(?:command|c)$/iu.test(arg));
143
+ const command = index >= 0 ? args[index + 1] : undefined;
144
+ return typeof command === "string" ? { nested: { command, language: "powershell" } } : {};
145
+ }
146
+ if (name === "env") {
147
+ const split = args
148
+ .slice(0, afterOptions(args, envValueOptions))
149
+ .findIndex(
150
+ (arg) => arg === "-S" || arg === "--split-string" || arg?.startsWith("--split-string="),
151
+ );
152
+ const option = split >= 0 ? args[split] : undefined;
153
+ const command = option?.startsWith("--split-string=")
154
+ ? option.slice("--split-string=".length)
155
+ : split >= 0
156
+ ? args[split + 1]
157
+ : undefined;
158
+ if (typeof command === "string") return { nested: { command, language: "bash" } };
159
+ }
160
+ const options = wrapperValueOptions[name];
161
+ if (!options) return {};
162
+ if (name === "command" && args.some((arg) => arg === "-v" || arg === "-V")) return {};
163
+ let start = afterOptions(args, options);
164
+ if (name === "timeout") start += 1;
165
+ if (name === "env") {
166
+ while (typeof args[start] === "string" && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(args[start] ?? ""))
167
+ start += 1;
168
+ }
169
+ return classifyCommand(args.slice(start), language, depth + 1);
170
+ }
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { SearchPolicy } from "./search-policy.js";
3
+ import { readNativeSearchEnforcement } from "./config-reader.js";
4
+
5
+ const MAX_HOOK_INPUT_BYTES = 256 * 1024;
6
+
7
+ async function hookInput(): Promise<unknown> {
8
+ const chunks: Buffer[] = [];
9
+ let size = 0;
10
+ for await (const chunk of process.stdin) {
11
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
12
+ size += bytes.byteLength;
13
+ if (size > MAX_HOOK_INPUT_BYTES) throw new Error("Search policy hook input exceeds 256 KiB");
14
+ chunks.push(bytes);
15
+ }
16
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
17
+ }
18
+
19
+ function deny(reason: string): void {
20
+ process.stdout.write(
21
+ `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } })}\n`,
22
+ );
23
+ }
24
+
25
+ const inputTimer = setTimeout(() => {
26
+ process.stderr.write("sift-light search policy: hook input timed out\n");
27
+ process.exit(2);
28
+ }, 3000);
29
+ try {
30
+ const input = await hookInput();
31
+ clearTimeout(inputTimer);
32
+ const enforcement = readNativeSearchEnforcement();
33
+ if (
34
+ typeof input !== "object" ||
35
+ input === null ||
36
+ !("hook_event_name" in input) ||
37
+ input.hook_event_name !== "PreToolUse" ||
38
+ !("tool_name" in input) ||
39
+ typeof input.tool_name !== "string" ||
40
+ !("tool_input" in input)
41
+ )
42
+ throw new Error("Search policy received an invalid PreToolUse payload");
43
+ const decision =
44
+ enforcement === "hard"
45
+ ? await new SearchPolicy(new URL("./", import.meta.url)).check(
46
+ input.tool_name,
47
+ input.tool_input,
48
+ )
49
+ : undefined;
50
+ if (decision) deny(decision.reason);
51
+ } catch (error) {
52
+ clearTimeout(inputTimer);
53
+ // Do not echo command text, paths, or JSON parse errors that could contain credentials.
54
+ deny(
55
+ error instanceof Error &&
56
+ (error.message.startsWith("Search policy") ||
57
+ error.message.startsWith("Invalid sift-light environment variable"))
58
+ ? error.message
59
+ : "sift-light search policy failed; repair or disable this plugin before retrying",
60
+ );
61
+ }