@octocodeai/octocode-engine 16.6.0 → 16.7.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 (44) hide show
  1. package/README.md +190 -249
  2. package/dist/lsp/client.d.ts +18 -9
  3. package/dist/lsp/client.js +35 -25
  4. package/dist/lsp/config.d.ts +29 -1
  5. package/dist/lsp/config.js +156 -1
  6. package/dist/lsp/ideContext.d.ts +18 -0
  7. package/dist/lsp/ideContext.js +29 -0
  8. package/dist/lsp/index.d.ts +8 -3
  9. package/dist/lsp/index.js +7 -2
  10. package/dist/lsp/lspClientPool.d.ts +2 -0
  11. package/dist/lsp/lspClientPool.js +9 -4
  12. package/dist/lsp/manager.d.ts +11 -0
  13. package/dist/lsp/manager.js +72 -25
  14. package/dist/lsp/native.d.ts +2 -1
  15. package/dist/lsp/platform.d.ts +20 -0
  16. package/dist/lsp/platform.js +64 -0
  17. package/dist/lsp/resolver.js +10 -4
  18. package/dist/lsp/serverDiscovery.d.ts +63 -0
  19. package/dist/lsp/serverDiscovery.js +226 -0
  20. package/dist/lsp/serverManifest.d.ts +71 -0
  21. package/dist/lsp/serverManifest.js +116 -0
  22. package/dist/lsp/serverManifestData.d.ts +2 -0
  23. package/dist/lsp/serverManifestData.js +96 -0
  24. package/dist/lsp/serverProvisioner.d.ts +19 -0
  25. package/dist/lsp/serverProvisioner.js +262 -0
  26. package/dist/lsp/types.d.ts +18 -0
  27. package/dist/security/commandValidator.js +74 -10
  28. package/dist/security/contentSanitizer.js +4 -3
  29. package/dist/security/discoveryFilter.js +10 -0
  30. package/dist/security/filePatterns.js +6 -0
  31. package/dist/security/ignoredPathFilter.js +10 -2
  32. package/dist/security/mask.js +5 -22
  33. package/dist/security/maskUtils.d.ts +6 -0
  34. package/dist/security/maskUtils.js +22 -0
  35. package/dist/security/native.js +5 -22
  36. package/dist/security/pathPatterns.js +5 -0
  37. package/dist/security/pathValidator.js +38 -37
  38. package/dist/security/registry.js +27 -9
  39. package/dist/security/securityConstants.d.ts +1 -1
  40. package/dist/security/securityConstants.js +22 -1
  41. package/dist/security/withSecurityValidation.js +18 -3
  42. package/docs/LSP_SERVER_LIFECYCLE.md +258 -0
  43. package/index.d.ts +8 -2
  44. package/package.json +54 -10
@@ -1,3 +1,9 @@
1
+ // File-access block list: prevents reading sensitive files by name/extension.
2
+ // SYNC NOTE: discoveryFilter.ts:DISCOVERY_IGNORED_FILE_NAMES and
3
+ // DISCOVERY_IGNORED_FILE_EXTENSIONS partially overlap this list (e.g. id_rsa,
4
+ // id_dsa, .pem, .key, .crt). Both sets must be kept in sync — a new sensitive
5
+ // file added here should also be added to discoveryFilter.ts to prevent it
6
+ // from appearing in discovery results, and vice versa.
1
7
  export const IGNORED_FILE_PATTERNS = [
2
8
  /\.env$/,
3
9
  /\.env\..+$/,
@@ -22,7 +22,10 @@ function getCompiledPathRegex() {
22
22
  const all = extra.length > 0
23
23
  ? [...IGNORED_PATH_PATTERNS, ...extra]
24
24
  : IGNORED_PATH_PATTERNS;
25
- _compiledPathRegex = new RegExp(all.map(r => stripNamedGroups(r.source)).join('|'));
25
+ // Wrap each source in a non-capturing group so alternation precedence is
26
+ // correct: `a|b` joined with `c|d` must be `(?:a|b)|(?:c|d)`, not
27
+ // `a|b|c|d` which re-associates differently once combined.
28
+ _compiledPathRegex = new RegExp(all.map(r => `(?:${stripNamedGroups(r.source)})`).join('|'));
26
29
  }
27
30
  return _compiledPathRegex;
28
31
  }
@@ -33,7 +36,7 @@ function getCompiledFileRegex() {
33
36
  const all = extra.length > 0
34
37
  ? [...IGNORED_FILE_PATTERNS, ...extra]
35
38
  : IGNORED_FILE_PATTERNS;
36
- _compiledFileRegex = new RegExp(all.map(r => stripNamedGroups(r.source)).join('|'));
39
+ _compiledFileRegex = new RegExp(all.map(r => `(?:${stripNamedGroups(r.source)})`).join('|'));
37
40
  }
38
41
  return _compiledFileRegex;
39
42
  }
@@ -51,6 +54,11 @@ export function shouldIgnorePath(pathToCheck) {
51
54
  return regex.test(normalizedPath);
52
55
  }
53
56
  function normalizePathForIgnoreMatching(normalizedPath) {
57
+ if (normalizedPath === '/private/tmp')
58
+ return '/tmp';
59
+ if (normalizedPath.startsWith('/private/tmp/')) {
60
+ return normalizedPath.slice('/private'.length);
61
+ }
54
62
  if (normalizedPath === '/private/var')
55
63
  return '/var';
56
64
  if (normalizedPath.startsWith('/private/var/')) {
@@ -1,41 +1,24 @@
1
1
  import { nativeMaskSensitiveData } from './native.js';
2
2
  import { securityRegistry } from './registry.js';
3
- import { maskEveryOtherChar } from './maskUtils.js';
3
+ import { deduplicateSpans, applyMaskToSpans } from './maskUtils.js';
4
4
  function applyJsMask(text, patterns) {
5
5
  const applicable = patterns.filter(p => !p.fileContext);
6
6
  if (applicable.length === 0)
7
7
  return text;
8
- const matches = [];
8
+ const spans = [];
9
9
  for (const p of applicable) {
10
10
  p.regex.lastIndex = 0;
11
11
  let m;
12
12
  const re = new RegExp(p.regex.source, p.regex.flags.replace('g', '') + 'g');
13
13
  while ((m = re.exec(text)) !== null) {
14
- matches.push({ start: m.index, end: m.index + m[0].length });
14
+ spans.push({ start: m.index, end: m.index + m[0].length });
15
15
  if (m[0].length === 0)
16
16
  re.lastIndex++;
17
17
  }
18
18
  }
19
- if (matches.length === 0)
19
+ if (spans.length === 0)
20
20
  return text;
21
- matches.sort((a, b) => a.start - b.start);
22
- const deduped = [];
23
- let lastEnd = 0;
24
- for (const m of matches) {
25
- if (m.start >= lastEnd) {
26
- deduped.push(m);
27
- lastEnd = m.end;
28
- }
29
- }
30
- let result = text;
31
- for (let i = deduped.length - 1; i >= 0; i--) {
32
- const { start, end } = deduped[i];
33
- result =
34
- result.slice(0, start) +
35
- maskEveryOtherChar(text.slice(start, end)) +
36
- result.slice(end);
37
- }
38
- return result;
21
+ return applyMaskToSpans(text, deduplicateSpans(spans));
39
22
  }
40
23
  export function maskSensitiveData(text) {
41
24
  if (!text)
@@ -1 +1,7 @@
1
+ export type Span = {
2
+ start: number;
3
+ end: number;
4
+ };
1
5
  export declare function maskEveryOtherChar(text: string): string;
6
+ export declare function deduplicateSpans(spans: Span[]): Span[];
7
+ export declare function applyMaskToSpans(text: string, spans: Span[]): string;
@@ -5,3 +5,25 @@ export function maskEveryOtherChar(text) {
5
5
  }
6
6
  return result;
7
7
  }
8
+ export function deduplicateSpans(spans) {
9
+ spans.sort((a, b) => a.start - b.start);
10
+ const result = [];
11
+ let lastEnd = 0;
12
+ for (const span of spans) {
13
+ if (span.start >= lastEnd) {
14
+ result.push(span);
15
+ lastEnd = span.end;
16
+ }
17
+ }
18
+ return result;
19
+ }
20
+ export function applyMaskToSpans(text, spans) {
21
+ let result = '';
22
+ let position = 0;
23
+ for (const span of spans) {
24
+ result += text.slice(position, span.start);
25
+ result += maskEveryOtherChar(text.slice(span.start, span.end));
26
+ position = span.end;
27
+ }
28
+ return result + text.slice(position);
29
+ }
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'module';
2
2
  import { dirname, join } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { maskEveryOtherChar } from './maskUtils.js';
4
+ import { deduplicateSpans, applyMaskToSpans } from './maskUtils.js';
5
5
  import { allRegexPatterns } from './regexes/index.js';
6
6
  const _require = createRequire(import.meta.url);
7
7
  const _dir = dirname(fileURLToPath(import.meta.url));
@@ -122,38 +122,21 @@ function sanitizeWithJsFallback(content, filePath) {
122
122
  function maskWithJsFallback(text) {
123
123
  if (!text)
124
124
  return text;
125
- const matches = [];
125
+ const spans = [];
126
126
  for (const pattern of allRegexPatterns) {
127
127
  if (pattern.fileContext)
128
128
  continue;
129
129
  const regex = cloneGlobalRegex(pattern.regex);
130
130
  let match;
131
131
  while ((match = regex.exec(text)) !== null) {
132
- matches.push({ start: match.index, end: match.index + match[0].length });
132
+ spans.push({ start: match.index, end: match.index + match[0].length });
133
133
  if (match[0].length === 0)
134
134
  regex.lastIndex++;
135
135
  }
136
136
  }
137
- if (matches.length === 0)
137
+ if (spans.length === 0)
138
138
  return text;
139
- matches.sort((left, right) => left.start - right.start);
140
- const nonOverlapping = [];
141
- let lastEnd = 0;
142
- for (const match of matches) {
143
- if (match.start >= lastEnd) {
144
- nonOverlapping.push(match);
145
- lastEnd = match.end;
146
- }
147
- }
148
- let result = '';
149
- let position = 0;
150
- for (const match of nonOverlapping) {
151
- result += text.slice(position, match.start);
152
- result += maskEveryOtherChar(text.slice(match.start, match.end));
153
- position = match.end;
154
- }
155
- result += text.slice(position);
156
- return result;
139
+ return applyMaskToSpans(text, deduplicateSpans(spans));
157
140
  }
158
141
  export const nativeSanitizeContent = (content, filePath) => getNativeModule()?.sanitizeContent(content, filePath) ??
159
142
  sanitizeWithJsFallback(content, filePath);
@@ -1,3 +1,8 @@
1
+ // Path-access block list: prevents reading from sensitive directories.
2
+ // SYNC NOTE: discoveryFilter.ts:DISCOVERY_IGNORED_FOLDER_NAMES overlaps this
3
+ // list (e.g. .git, .aws, .ssh, .docker, .kube). Both lists must be kept in
4
+ // sync — changes here that protect against directory traversal attacks should
5
+ // be reflected there, and vice versa.
1
6
  export const IGNORED_PATH_PATTERNS = [
2
7
  /(?:^|\/)\.git(?:\/|$)/,
3
8
  /(?:^|\/)\.ssh(?:\/|$)/,
@@ -12,7 +12,10 @@ export class PathValidator {
12
12
  if (opts.workspaceRoot) {
13
13
  this.addAllowedRoot(opts.workspaceRoot);
14
14
  }
15
- if (opts.includeHomeDir !== false) {
15
+ // Home dir is opt-in. Local tools intentionally include it (users search
16
+ // ~/projects, ~/Documents, etc.) and rely on ignoredPathFilter as the
17
+ // second layer to block .ssh, .aws, .kube, etc. within it.
18
+ if (opts.includeHomeDir === true) {
16
19
  const homeDir = os.homedir();
17
20
  if (homeDir && !this.allowedRoots.includes(homeDir)) {
18
21
  this.allowedRoots.push(homeDir);
@@ -49,23 +52,21 @@ export class PathValidator {
49
52
  if (!this.allowedRoots.includes(resolvedRoot)) {
50
53
  this.allowedRoots.push(resolvedRoot);
51
54
  }
52
- }
53
- isResolvedPathAllowed(absolutePath, resolvedPath) {
54
- return this.allowedRoots.some(root => {
55
- if (resolvedPath === root || resolvedPath.startsWith(root + path.sep)) {
56
- return true;
57
- }
58
- try {
59
- const realRoot = fs.realpathSync(root);
60
- if (absolutePath === root) {
61
- return resolvedPath === realRoot;
62
- }
63
- return resolvedPath.startsWith(realRoot + path.sep);
64
- }
65
- catch {
66
- return false;
55
+ try {
56
+ const realRoot = fs.realpathSync(resolvedRoot);
57
+ if (!this.allowedRoots.includes(realRoot)) {
58
+ this.allowedRoots.push(realRoot);
67
59
  }
68
- });
60
+ }
61
+ catch {
62
+ // Non-existent roots are still useful for validating future output paths.
63
+ }
64
+ }
65
+ isResolvedPathAllowed(_absolutePath, resolvedPath) {
66
+ // addAllowedRoot already resolves both path.resolve() and realpathSync()
67
+ // and pushes both into allowedRoots, so a plain string comparison is
68
+ // sufficient here — no need for another realpathSync per validate() call.
69
+ return this.allowedRoots.some(root => resolvedPath === root || resolvedPath.startsWith(root + path.sep));
69
70
  }
70
71
  validate(inputPath) {
71
72
  if (!inputPath || inputPath.trim() === '') {
@@ -76,24 +77,6 @@ export class PathValidator {
76
77
  }
77
78
  const expandedPath = this.expandTilde(inputPath);
78
79
  const absolutePath = path.resolve(expandedPath);
79
- const isAllowed = this.allowedRoots.some(root => {
80
- if (absolutePath === root) {
81
- return true;
82
- }
83
- return absolutePath.startsWith(root + path.sep);
84
- });
85
- if (!isAllowed) {
86
- return {
87
- isValid: false,
88
- error: `Path '${redactPath(inputPath)}' is outside allowed directories`,
89
- };
90
- }
91
- if (shouldIgnore(absolutePath)) {
92
- return {
93
- isValid: false,
94
- error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
95
- };
96
- }
97
80
  try {
98
81
  const realPath = fs.realpathSync(absolutePath);
99
82
  const isRealPathAllowed = this.isResolvedPathAllowed(absolutePath, realPath);
@@ -103,6 +86,12 @@ export class PathValidator {
103
86
  error: `Symlink target '${redactPath(realPath)}' is outside allowed directories`,
104
87
  };
105
88
  }
89
+ if (shouldIgnore(absolutePath)) {
90
+ return {
91
+ isValid: false,
92
+ error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
93
+ };
94
+ }
106
95
  if (shouldIgnore(realPath)) {
107
96
  return {
108
97
  isValid: false,
@@ -177,6 +166,12 @@ export class PathValidator {
177
166
  error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
178
167
  };
179
168
  }
169
+ if (shouldIgnore(resolvedPath)) {
170
+ return {
171
+ isValid: false,
172
+ error: `Path '${redactPath(inputPath)}' resolves into an ignored directory or matches an ignored pattern`,
173
+ };
174
+ }
180
175
  return {
181
176
  isValid: true,
182
177
  sanitizedPath: resolvedPath,
@@ -221,9 +216,15 @@ export class PathValidator {
221
216
  this.allowedRoots = [...roots];
222
217
  }
223
218
  }
224
- export const pathValidator = new PathValidator();
219
+ // The production singleton explicitly opts in to the home dir because local
220
+ // tools are expected to search ~/projects, ~/Documents, etc. Security within
221
+ // the home directory is provided by ignoredPathFilter (.ssh, .aws, .kube, etc.).
222
+ export const pathValidator = new PathValidator({ includeHomeDir: true });
225
223
  export function resetPathValidator(options) {
226
- const newValidator = new PathValidator(options);
224
+ // When called with no arguments (e.g. in afterEach tear-down), restore the
225
+ // same includeHomeDir state the singleton starts with.
226
+ const effective = options ?? { includeHomeDir: true };
227
+ const newValidator = new PathValidator(effective);
227
228
  pathValidator.replaceAllowedRoots(newValidator.getAllowedRoots());
228
229
  return pathValidator;
229
230
  }
@@ -1,15 +1,33 @@
1
1
  import { normalizeCommandName } from './commandUtils.js';
2
- const REDOS_TIMEOUT_MS = 50;
3
- const REDOS_TEST_INPUT = 'a'.repeat(100);
2
+ const REDOS_TIMEOUT_MS = 200;
3
+ // Use multiple input lengths to catch ReDoS patterns that only blow up at larger sizes.
4
+ const REDOS_TEST_INPUTS = [
5
+ 'a'.repeat(50),
6
+ 'a'.repeat(500),
7
+ 'a'.repeat(2000),
8
+ ];
4
9
  function isReDoSSafe(regex) {
5
- const start = performance.now();
6
- try {
7
- regex.test(REDOS_TEST_INPUT);
8
- }
9
- catch {
10
- return false;
10
+ // Timing heuristic — catches obvious exponential backtracking but cannot provide
11
+ // structural guarantees. Tests multiple input sizes to catch patterns that only
12
+ // blow up at larger scales. A proper linear-time checker (e.g., re2) would be
13
+ // the gold standard but adds a native dependency.
14
+ for (const input of REDOS_TEST_INPUTS) {
15
+ regex.lastIndex = 0;
16
+ const start = performance.now();
17
+ try {
18
+ regex.test(input);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ finally {
24
+ regex.lastIndex = 0;
25
+ }
26
+ if (performance.now() - start >= REDOS_TIMEOUT_MS) {
27
+ return false;
28
+ }
11
29
  }
12
- return performance.now() - start < REDOS_TIMEOUT_MS;
30
+ return true;
13
31
  }
14
32
  export class SecurityRegistry {
15
33
  _extraSecretPatterns = [];
@@ -1,3 +1,3 @@
1
- export declare const ALLOWED_COMMANDS: readonly ["rg", "ls", "find", "grep", "git"];
1
+ export declare const ALLOWED_COMMANDS: readonly ["rg", "ls", "find", "grep", "git", "file", "zcat", "gunzip", "bzcat", "xzcat", "zstdcat", "zstd", "lz4cat", "brotli", "lzfse", "tar", "unzip", "bsdtar", "7z", "7zz"];
2
2
  export declare const DANGEROUS_PATTERNS: readonly [RegExp, RegExp, RegExp];
3
3
  export declare const PATTERN_DANGEROUS_PATTERNS: readonly [RegExp, RegExp, RegExp, RegExp];
@@ -1,4 +1,25 @@
1
- export const ALLOWED_COMMANDS = ['rg', 'ls', 'find', 'grep', 'git'];
1
+ export const ALLOWED_COMMANDS = [
2
+ 'rg',
3
+ 'ls',
4
+ 'find',
5
+ 'grep',
6
+ 'git',
7
+ 'file',
8
+ 'zcat',
9
+ 'gunzip',
10
+ 'bzcat',
11
+ 'xzcat',
12
+ 'zstdcat',
13
+ 'zstd',
14
+ 'lz4cat',
15
+ 'brotli',
16
+ 'lzfse',
17
+ 'tar',
18
+ 'unzip',
19
+ 'bsdtar',
20
+ '7z',
21
+ '7zz',
22
+ ];
2
23
  export const DANGEROUS_PATTERNS = [
3
24
  /[;&|`$(){}[\]<>]/, // Shell metacharacters
4
25
  /\${/,
@@ -13,13 +13,24 @@ export function configureSecurity(deps) {
13
13
  function createErrorResult(text) {
14
14
  return { content: [{ type: 'text', text }], isError: true };
15
15
  }
16
- function withToolTimeout(toolName, promise, signal, timeoutMs) {
16
+ // Combine an external caller-provided AbortSignal with an internal one so that
17
+ // aborting either cancels the tool. Returns the single source unchanged when
18
+ // only one (or none) is present, preserving existing behavior.
19
+ function mergeAbortSignals(external, internal) {
20
+ if (!external)
21
+ return internal;
22
+ if (!internal)
23
+ return external;
24
+ return AbortSignal.any([external, internal]);
25
+ }
26
+ function withToolTimeout(toolName, promise, signal, timeoutMs, onTimeout) {
17
27
  const timeout = getTimeoutMs(timeoutMs);
18
28
  if (signal?.aborted) {
19
29
  return Promise.resolve(createErrorResult(`Tool '${toolName}' was cancelled before execution.`));
20
30
  }
21
31
  return new Promise(resolve => {
22
32
  const timer = setTimeout(() => {
33
+ onTimeout?.();
23
34
  resolve(createErrorResult(`Tool '${toolName}' timed out after ${timeout / 1000}s. Try reducing query complexity or scope.`));
24
35
  }, timeout);
25
36
  const onAbort = () => {
@@ -27,6 +38,9 @@ function withToolTimeout(toolName, promise, signal, timeoutMs) {
27
38
  resolve(createErrorResult(`Tool '${toolName}' was cancelled by the client.`));
28
39
  };
29
40
  signal?.addEventListener('abort', onAbort, { once: true });
41
+ // Re-check after registering the listener: the signal may have been aborted
42
+ // in the window between addEventListener and this line, which the listener
43
+ // alone cannot catch.
30
44
  if (signal?.aborted) {
31
45
  clearTimeout(timer);
32
46
  signal.removeEventListener('abort', onAbort);
@@ -47,7 +61,7 @@ function withToolTimeout(toolName, promise, signal, timeoutMs) {
47
61
  });
48
62
  }
49
63
  async function runSecure(opts) {
50
- const { toolName, handler, args, authInfo, sessionId, signal, timeoutMs } = opts;
64
+ const { toolName, handler, args, authInfo, sessionId, signal, timeoutMs, onTimeout, abortSignal, } = opts;
51
65
  try {
52
66
  const sanitizer = getSanitizer();
53
67
  const validation = sanitizer.validateInputParameters(args);
@@ -55,7 +69,8 @@ async function runSecure(opts) {
55
69
  return createErrorResult(`Security validation failed: ${validation.warnings.join('; ')}`);
56
70
  }
57
71
  const sanitizedParams = validation.sanitizedParams;
58
- const rawResult = await withToolTimeout(toolName, handler(sanitizedParams, authInfo, sessionId), signal, timeoutMs);
72
+ const mergedSignal = mergeAbortSignals(signal, abortSignal);
73
+ const rawResult = await withToolTimeout(toolName, handler(sanitizedParams, authInfo, sessionId), mergedSignal, timeoutMs, onTimeout);
59
74
  return rawResult;
60
75
  }
61
76
  catch (error) {