@octocodeai/octocode-engine 16.6.0 → 16.6.2
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/README.md +95 -195
- package/dist/lsp/client.d.ts +1 -0
- package/dist/lsp/client.js +10 -0
- package/dist/lsp/config.d.ts +29 -1
- package/dist/lsp/config.js +156 -1
- package/dist/lsp/ideContext.d.ts +18 -0
- package/dist/lsp/ideContext.js +29 -0
- package/dist/lsp/index.d.ts +8 -3
- package/dist/lsp/index.js +7 -2
- package/dist/lsp/manager.d.ts +11 -0
- package/dist/lsp/manager.js +47 -22
- package/dist/lsp/platform.d.ts +20 -0
- package/dist/lsp/platform.js +64 -0
- package/dist/lsp/serverDiscovery.d.ts +63 -0
- package/dist/lsp/serverDiscovery.js +226 -0
- package/dist/lsp/serverManifest.d.ts +70 -0
- package/dist/lsp/serverManifest.js +78 -0
- package/dist/lsp/serverManifestData.d.ts +2 -0
- package/dist/lsp/serverManifestData.js +33 -0
- package/dist/lsp/serverProvisioner.d.ts +19 -0
- package/dist/lsp/serverProvisioner.js +253 -0
- package/dist/lsp/types.d.ts +7 -0
- package/dist/security/commandValidator.js +10 -10
- package/dist/security/discoveryFilter.js +7 -0
- package/dist/security/filePatterns.js +6 -0
- package/dist/security/ignoredPathFilter.js +5 -0
- package/dist/security/mask.js +5 -22
- package/dist/security/maskUtils.d.ts +6 -0
- package/dist/security/maskUtils.js +22 -0
- package/dist/security/native.js +5 -22
- package/dist/security/pathPatterns.js +5 -0
- package/dist/security/pathValidator.js +21 -18
- package/dist/security/withSecurityValidation.js +3 -0
- package/package.json +49 -8
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { gunzipSync, inflateRawSync } from 'node:zlib';
|
|
5
|
+
import { detectPlatformId } from './platform.js';
|
|
6
|
+
import { cachedServerBinPath, managedCacheRoot, manifestServer, provisionMode, resolveCachedServer, } from './serverManifest.js';
|
|
7
|
+
/**
|
|
8
|
+
* The write half of LSP provisioning — downloads a pinned, verified portable
|
|
9
|
+
* server binary into the managed cache. This is invoked ONLY by the explicit
|
|
10
|
+
* `octocode lsp-server install` path, never lazily during a semantic query
|
|
11
|
+
* (the resolution ladder's L4 stays read-only). Provisioning a server is
|
|
12
|
+
* orthogonal to the runtime contract: when no server is available at query
|
|
13
|
+
* time the tool layer throws and the agent pivots to text search.
|
|
14
|
+
*
|
|
15
|
+
* Security invariants (all enforced here):
|
|
16
|
+
* - opt-in: refuses unless OCTOCODE_LSP_AUTO_INSTALL is prompt|auto
|
|
17
|
+
* - pinned SHA-256 gate: refuses if the manifest sha256 is null
|
|
18
|
+
* - host allowlist on the request URL AND every redirect hop; https only
|
|
19
|
+
* - atomic: download to a temp file in the dest dir, verify, chmod, rename
|
|
20
|
+
* - `.ok` completion marker written last, so a partial install never resolves
|
|
21
|
+
* - per-target lock so editor + CLI don't race the same download
|
|
22
|
+
* v1 handles `gz` and `none` (raw) assets with zero new deps; archive formats
|
|
23
|
+
* needing zip/tar/xz extraction return a clear "install manually" error.
|
|
24
|
+
*/
|
|
25
|
+
const ALLOWED_HOSTS = new Set([
|
|
26
|
+
'github.com',
|
|
27
|
+
// GitHub release assets redirect to a CDN host; both the legacy
|
|
28
|
+
// (objects.) and current (release-assets.) hosts are GitHub-owned.
|
|
29
|
+
'objects.githubusercontent.com',
|
|
30
|
+
'release-assets.githubusercontent.com',
|
|
31
|
+
'releases.hashicorp.com',
|
|
32
|
+
]);
|
|
33
|
+
const MAX_REDIRECTS = 5;
|
|
34
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
35
|
+
function fail(error) {
|
|
36
|
+
return { ok: false, error };
|
|
37
|
+
}
|
|
38
|
+
function hostAllowed(url) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
return parsed.protocol === 'https:' && ALLOWED_HOSTS.has(parsed.hostname);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Fetch following redirects manually, re-checking the host allowlist each hop. */
|
|
48
|
+
async function fetchAllowlisted(url, signal) {
|
|
49
|
+
let current = url;
|
|
50
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
51
|
+
if (!hostAllowed(current)) {
|
|
52
|
+
// Report only the host — release-asset URLs carry signed query tokens we
|
|
53
|
+
// must not echo into logs/CLI output.
|
|
54
|
+
let host;
|
|
55
|
+
try {
|
|
56
|
+
host = new URL(current).host;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
host = '(unparseable url)';
|
|
60
|
+
}
|
|
61
|
+
return { ok: false, error: `Blocked non-allowlisted/insecure host: ${host}` };
|
|
62
|
+
}
|
|
63
|
+
let response;
|
|
64
|
+
try {
|
|
65
|
+
response = await fetch(current, { redirect: 'manual', signal });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return { ok: false, error: `Download failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
69
|
+
}
|
|
70
|
+
if (response.status >= 300 && response.status < 400) {
|
|
71
|
+
const location = response.headers.get('location');
|
|
72
|
+
if (!location)
|
|
73
|
+
return { ok: false, error: 'Redirect without Location header' };
|
|
74
|
+
current = new URL(location, current).toString();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
return { ok: false, error: `Download failed: HTTP ${response.status}` };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, body: await response.arrayBuffer() };
|
|
81
|
+
}
|
|
82
|
+
return { ok: false, error: 'Too many redirects' };
|
|
83
|
+
}
|
|
84
|
+
function sha256(buffer) {
|
|
85
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
86
|
+
}
|
|
87
|
+
/** Decode the downloaded asset into the final executable bytes. */
|
|
88
|
+
function extractBinary(asset, raw) {
|
|
89
|
+
switch (asset.archive) {
|
|
90
|
+
case 'none':
|
|
91
|
+
return { ok: true, bytes: raw };
|
|
92
|
+
case 'gz':
|
|
93
|
+
return { ok: true, bytes: gunzipSync(raw) };
|
|
94
|
+
case 'zip': {
|
|
95
|
+
if (!asset.binPath) {
|
|
96
|
+
return { ok: false, error: 'zip asset is missing binPath in manifest' };
|
|
97
|
+
}
|
|
98
|
+
const extracted = extractFromZip(raw, asset.binPath);
|
|
99
|
+
if (!extracted) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `Could not find '${asset.binPath}' in zip archive — try installing via your package manager.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, bytes: extracted };
|
|
106
|
+
}
|
|
107
|
+
case 'tar.gz':
|
|
108
|
+
case 'tar.xz':
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: `Archive format '${asset.archive}' is not supported; install the server via your package manager.`,
|
|
112
|
+
};
|
|
113
|
+
default:
|
|
114
|
+
return { ok: false, error: `Unknown archive format '${asset.archive}'` };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Minimal ZIP local-file-header parser. Handles stored (method 0) and deflate
|
|
119
|
+
* (method 8) entries — the two methods used by all known LSP server releases.
|
|
120
|
+
* Searches for `targetPath` by exact match or trailing-segment match so both
|
|
121
|
+
* `clangd_22.1.0/bin/clangd` and `./clangd_22.1.0/bin/clangd` resolve.
|
|
122
|
+
*/
|
|
123
|
+
function extractFromZip(zipBuffer, targetPath) {
|
|
124
|
+
const LOCAL_HEADER_SIG = 0x04034b50;
|
|
125
|
+
const normalizedTarget = targetPath.replace(/^\.?\//u, '');
|
|
126
|
+
let offset = 0;
|
|
127
|
+
while (offset + 30 < zipBuffer.length) {
|
|
128
|
+
if (zipBuffer.readUInt32LE(offset) !== LOCAL_HEADER_SIG)
|
|
129
|
+
break;
|
|
130
|
+
const compressionMethod = zipBuffer.readUInt16LE(offset + 8);
|
|
131
|
+
const compressedSize = zipBuffer.readUInt32LE(offset + 18);
|
|
132
|
+
const filenameLen = zipBuffer.readUInt16LE(offset + 26);
|
|
133
|
+
const extraLen = zipBuffer.readUInt16LE(offset + 28);
|
|
134
|
+
const filename = zipBuffer
|
|
135
|
+
.subarray(offset + 30, offset + 30 + filenameLen)
|
|
136
|
+
.toString('utf8')
|
|
137
|
+
.replace(/^\.?\//u, '');
|
|
138
|
+
const dataOffset = offset + 30 + filenameLen + extraLen;
|
|
139
|
+
if (filename === normalizedTarget) {
|
|
140
|
+
const compressed = zipBuffer.subarray(dataOffset, dataOffset + compressedSize);
|
|
141
|
+
if (compressionMethod === 0)
|
|
142
|
+
return compressed; // stored
|
|
143
|
+
if (compressionMethod === 8)
|
|
144
|
+
return inflateRawSync(compressed); // deflate
|
|
145
|
+
return null; // unsupported method within zip
|
|
146
|
+
}
|
|
147
|
+
offset = dataOffset + compressedSize;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function acquireLock(lockPath) {
|
|
152
|
+
try {
|
|
153
|
+
closeSync(openSync(lockPath, 'wx'));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Reclaim a stale lock from a crashed/killed installer.
|
|
158
|
+
try {
|
|
159
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
160
|
+
rmSync(lockPath, { force: true });
|
|
161
|
+
closeSync(openSync(lockPath, 'wx'));
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* fall through */
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Provision `serverName` into the managed cache. Idempotent: returns the
|
|
173
|
+
* already-present path when a verified copy exists. Pure-data inputs come from
|
|
174
|
+
* `serverManifest.json`; nothing is executed during install.
|
|
175
|
+
*/
|
|
176
|
+
export async function provisionServer(serverName, options = {}) {
|
|
177
|
+
const platformId = detectPlatformId();
|
|
178
|
+
const mode = options.mode ?? provisionMode();
|
|
179
|
+
const server = manifestServer(serverName);
|
|
180
|
+
if (!server)
|
|
181
|
+
return fail(`${serverName} is not an auto-downloadable server.`);
|
|
182
|
+
const unsupported = server.unsupportedPlatforms?.[platformId];
|
|
183
|
+
if (unsupported)
|
|
184
|
+
return fail(unsupported);
|
|
185
|
+
const asset = server.platforms[platformId];
|
|
186
|
+
if (!asset) {
|
|
187
|
+
return fail(`No ${serverName} asset for platform ${platformId}.`);
|
|
188
|
+
}
|
|
189
|
+
// Idempotent: a verified copy is already present.
|
|
190
|
+
const existing = resolveCachedServer(serverName, platformId);
|
|
191
|
+
if (existing)
|
|
192
|
+
return { ok: true, path: existing, source: 'already-present' };
|
|
193
|
+
if (mode === 'off') {
|
|
194
|
+
return fail('Auto-install is off. Set OCTOCODE_LSP_AUTO_INSTALL=prompt|auto (or pass --yes) to allow downloading.');
|
|
195
|
+
}
|
|
196
|
+
if (!asset.sha256) {
|
|
197
|
+
return fail(`${serverName} has no pinned sha256 in the manifest yet; refusing to download unverified bytes.`);
|
|
198
|
+
}
|
|
199
|
+
const binPath = cachedServerBinPath(serverName, platformId);
|
|
200
|
+
if (!binPath)
|
|
201
|
+
return fail(`Cannot compute cache path for ${serverName}.`);
|
|
202
|
+
const dir = path.dirname(binPath);
|
|
203
|
+
mkdirSync(dir, { recursive: true });
|
|
204
|
+
const lockPath = path.join(dir, '.lock');
|
|
205
|
+
if (!acquireLock(lockPath)) {
|
|
206
|
+
return fail(`Another install of ${serverName} is in progress (${lockPath}).`);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
// Re-check after acquiring the lock (another process may have finished).
|
|
210
|
+
const racedWinner = resolveCachedServer(serverName, platformId);
|
|
211
|
+
if (racedWinner)
|
|
212
|
+
return { ok: true, path: racedWinner, source: 'already-present' };
|
|
213
|
+
const fetched = await fetchAllowlisted(asset.url, options.signal);
|
|
214
|
+
if (!fetched.ok)
|
|
215
|
+
return fail(fetched.error);
|
|
216
|
+
const downloaded = Buffer.from(fetched.body);
|
|
217
|
+
const actualSha = sha256(downloaded);
|
|
218
|
+
if (actualSha !== asset.sha256) {
|
|
219
|
+
return fail(`Checksum mismatch for ${serverName}: expected ${asset.sha256}, got ${actualSha}.`);
|
|
220
|
+
}
|
|
221
|
+
const extracted = extractBinary(asset, downloaded);
|
|
222
|
+
if (!extracted.ok)
|
|
223
|
+
return fail(extracted.error);
|
|
224
|
+
// Atomic: write a temp file in the same dir, chmod, then rename into place.
|
|
225
|
+
const tmpPath = `${binPath}.tmp-${process.pid}`;
|
|
226
|
+
writeFileSync(tmpPath, extracted.bytes);
|
|
227
|
+
if (process.platform !== 'win32')
|
|
228
|
+
chmodSync(tmpPath, 0o755);
|
|
229
|
+
renameSync(tmpPath, binPath);
|
|
230
|
+
// Completion marker LAST — resolveCachedServer trusts the binary only now.
|
|
231
|
+
writeFileSync(`${binPath}.ok`, JSON.stringify({ sha256: asset.sha256, size: extracted.bytes.length }));
|
|
232
|
+
return { ok: true, path: binPath, source: 'downloaded' };
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
rmSync(lockPath, { force: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Remove a server from the managed cache only (never touches external installs). */
|
|
239
|
+
export function uninstallServer(serverName, platformId = detectPlatformId()) {
|
|
240
|
+
const binPath = cachedServerBinPath(serverName, platformId);
|
|
241
|
+
if (!binPath)
|
|
242
|
+
return false;
|
|
243
|
+
const serverDir = path.dirname(path.dirname(binPath)); // <root>/<server>
|
|
244
|
+
if (!existsSync(serverDir))
|
|
245
|
+
return false;
|
|
246
|
+
// Guard: only ever delete inside the managed cache root.
|
|
247
|
+
const root = managedCacheRoot();
|
|
248
|
+
if (!path.resolve(serverDir).startsWith(path.resolve(root) + path.sep)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
rmSync(serverDir, { recursive: true, force: true });
|
|
252
|
+
return true;
|
|
253
|
+
}
|
package/dist/lsp/types.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
export type InitializationOptions = Record<string, unknown>;
|
|
2
|
+
/**
|
|
3
|
+
* Where a language-server executable was resolved from, for honest status
|
|
4
|
+
* reporting. Ordered by the resolution ladder (see docs/context/LSP_GUIDE.md):
|
|
5
|
+
* explicit override / PATH → bundled JS dep → project-local → ecosystem dir →
|
|
6
|
+
* managed download cache → IDE bridge; `unavailable` when nothing provides it.
|
|
7
|
+
*/
|
|
8
|
+
export type LspServerSource = 'path' | 'bundled' | 'project-local' | 'ecosystem' | 'managed-cache' | 'ide-bridge' | 'unavailable';
|
|
2
9
|
export interface LanguageServerConfig {
|
|
3
10
|
command: string;
|
|
4
11
|
args?: string[];
|
|
@@ -201,6 +201,9 @@ function validateCommandArgs(command, args) {
|
|
|
201
201
|
};
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
|
+
// grep: no per-flag allowlist — only the shared dangerous-pattern scan below
|
|
205
|
+
// applies. grep flags are wide (e.g. -r, -Z, --include) and intentionally
|
|
206
|
+
// left to the pattern scan rather than a strict allowlist.
|
|
204
207
|
const patternPositions = getPatternArgPositions(command, args);
|
|
205
208
|
for (let i = 0; i < args.length; i++) {
|
|
206
209
|
const arg = args[i];
|
|
@@ -295,16 +298,13 @@ function getFindPatternPositions(args) {
|
|
|
295
298
|
return positions;
|
|
296
299
|
}
|
|
297
300
|
function getPatternArgPositions(command, args) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
default:
|
|
306
|
-
return new Set();
|
|
307
|
-
}
|
|
301
|
+
if (isRgCommand(command))
|
|
302
|
+
return getRgPatternPositions(args);
|
|
303
|
+
if (command === 'grep')
|
|
304
|
+
return getGrepPatternPositions(args);
|
|
305
|
+
if (command === 'find')
|
|
306
|
+
return getFindPatternPositions(args);
|
|
307
|
+
return new Set();
|
|
308
308
|
}
|
|
309
309
|
function findDisallowedRgFlag(args) {
|
|
310
310
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// Discovery pruning lists: skip these dirs/files/extensions during tree walks.
|
|
2
|
+
// SYNC NOTE: pathPatterns.ts:IGNORED_PATH_PATTERNS and
|
|
3
|
+
// filePatterns.ts:IGNORED_FILE_PATTERNS overlap these lists (e.g. .git, .aws,
|
|
4
|
+
// .ssh, .docker, .kube, id_rsa, .pem, .key). The two systems have different
|
|
5
|
+
// roles — pathPatterns/filePatterns block *access* (read-time check);
|
|
6
|
+
// discoveryFilter blocks *visibility* (tree-walk pruning). Both must be kept
|
|
7
|
+
// in sync so that adding a sensitive path/file to one is reflected in the other.
|
|
1
8
|
export const DISCOVERY_IGNORED_FOLDER_NAMES = [
|
|
2
9
|
'.github',
|
|
3
10
|
'.git',
|
|
@@ -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\..+$/,
|
|
@@ -51,6 +51,11 @@ export function shouldIgnorePath(pathToCheck) {
|
|
|
51
51
|
return regex.test(normalizedPath);
|
|
52
52
|
}
|
|
53
53
|
function normalizePathForIgnoreMatching(normalizedPath) {
|
|
54
|
+
if (normalizedPath === '/private/tmp')
|
|
55
|
+
return '/tmp';
|
|
56
|
+
if (normalizedPath.startsWith('/private/tmp/')) {
|
|
57
|
+
return normalizedPath.slice('/private'.length);
|
|
58
|
+
}
|
|
54
59
|
if (normalizedPath === '/private/var')
|
|
55
60
|
return '/var';
|
|
56
61
|
if (normalizedPath.startsWith('/private/var/')) {
|
package/dist/security/mask.js
CHANGED
|
@@ -1,41 +1,24 @@
|
|
|
1
1
|
import { nativeMaskSensitiveData } from './native.js';
|
|
2
2
|
import { securityRegistry } from './registry.js';
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
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 (
|
|
19
|
+
if (spans.length === 0)
|
|
20
20
|
return text;
|
|
21
|
-
|
|
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
|
+
}
|
package/dist/security/native.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 (
|
|
137
|
+
if (spans.length === 0)
|
|
138
138
|
return text;
|
|
139
|
-
|
|
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(?:\/|$)/,
|
|
@@ -49,6 +49,15 @@ export class PathValidator {
|
|
|
49
49
|
if (!this.allowedRoots.includes(resolvedRoot)) {
|
|
50
50
|
this.allowedRoots.push(resolvedRoot);
|
|
51
51
|
}
|
|
52
|
+
try {
|
|
53
|
+
const realRoot = fs.realpathSync(resolvedRoot);
|
|
54
|
+
if (!this.allowedRoots.includes(realRoot)) {
|
|
55
|
+
this.allowedRoots.push(realRoot);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Non-existent roots are still useful for validating future output paths.
|
|
60
|
+
}
|
|
52
61
|
}
|
|
53
62
|
isResolvedPathAllowed(absolutePath, resolvedPath) {
|
|
54
63
|
return this.allowedRoots.some(root => {
|
|
@@ -76,24 +85,6 @@ export class PathValidator {
|
|
|
76
85
|
}
|
|
77
86
|
const expandedPath = this.expandTilde(inputPath);
|
|
78
87
|
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
88
|
try {
|
|
98
89
|
const realPath = fs.realpathSync(absolutePath);
|
|
99
90
|
const isRealPathAllowed = this.isResolvedPathAllowed(absolutePath, realPath);
|
|
@@ -103,6 +94,12 @@ export class PathValidator {
|
|
|
103
94
|
error: `Symlink target '${redactPath(realPath)}' is outside allowed directories`,
|
|
104
95
|
};
|
|
105
96
|
}
|
|
97
|
+
if (shouldIgnore(absolutePath)) {
|
|
98
|
+
return {
|
|
99
|
+
isValid: false,
|
|
100
|
+
error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
106
103
|
if (shouldIgnore(realPath)) {
|
|
107
104
|
return {
|
|
108
105
|
isValid: false,
|
|
@@ -177,6 +174,12 @@ export class PathValidator {
|
|
|
177
174
|
error: `Path '${redactPath(inputPath)}' is in an ignored directory or matches an ignored pattern`,
|
|
178
175
|
};
|
|
179
176
|
}
|
|
177
|
+
if (shouldIgnore(resolvedPath)) {
|
|
178
|
+
return {
|
|
179
|
+
isValid: false,
|
|
180
|
+
error: `Path '${redactPath(inputPath)}' resolves into an ignored directory or matches an ignored pattern`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
180
183
|
return {
|
|
181
184
|
isValid: true,
|
|
182
185
|
sanitizedPath: resolvedPath,
|
|
@@ -27,6 +27,9 @@ function withToolTimeout(toolName, promise, signal, timeoutMs) {
|
|
|
27
27
|
resolve(createErrorResult(`Tool '${toolName}' was cancelled by the client.`));
|
|
28
28
|
};
|
|
29
29
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
30
|
+
// Re-check after registering the listener: the signal may have been aborted
|
|
31
|
+
// in the window between addEventListener and this line, which the listener
|
|
32
|
+
// alone cannot catch.
|
|
30
33
|
if (signal?.aborted) {
|
|
31
34
|
clearTimeout(timer);
|
|
32
35
|
signal.removeEventListener('abort', onAbort);
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octocodeai/octocode-engine",
|
|
3
|
-
"version": "16.6.
|
|
3
|
+
"version": "16.6.2",
|
|
4
4
|
"description": "Rust native Octocode engine for context compression, structural search, and LSP semantic navigation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.cjs",
|
|
7
7
|
"types": "index.d.ts",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"author": "Guy Bary <bgauryy@octocodeai.com>",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
10
13
|
"repository": {
|
|
11
14
|
"type": "git",
|
|
12
15
|
"url": "https://github.com/bgauryy/octocode-mcp.git",
|
|
@@ -109,6 +112,26 @@
|
|
|
109
112
|
"import": "./dist/lsp/manager.js",
|
|
110
113
|
"default": "./dist/lsp/manager.js"
|
|
111
114
|
},
|
|
115
|
+
"./lsp/platform": {
|
|
116
|
+
"types": "./dist/lsp/platform.d.ts",
|
|
117
|
+
"import": "./dist/lsp/platform.js",
|
|
118
|
+
"default": "./dist/lsp/platform.js"
|
|
119
|
+
},
|
|
120
|
+
"./lsp/serverDiscovery": {
|
|
121
|
+
"types": "./dist/lsp/serverDiscovery.d.ts",
|
|
122
|
+
"import": "./dist/lsp/serverDiscovery.js",
|
|
123
|
+
"default": "./dist/lsp/serverDiscovery.js"
|
|
124
|
+
},
|
|
125
|
+
"./lsp/serverManifest": {
|
|
126
|
+
"types": "./dist/lsp/serverManifest.d.ts",
|
|
127
|
+
"import": "./dist/lsp/serverManifest.js",
|
|
128
|
+
"default": "./dist/lsp/serverManifest.js"
|
|
129
|
+
},
|
|
130
|
+
"./lsp/serverProvisioner": {
|
|
131
|
+
"types": "./dist/lsp/serverProvisioner.d.ts",
|
|
132
|
+
"import": "./dist/lsp/serverProvisioner.js",
|
|
133
|
+
"default": "./dist/lsp/serverProvisioner.js"
|
|
134
|
+
},
|
|
112
135
|
"./lsp/resolver": {
|
|
113
136
|
"types": "./dist/lsp/resolver.d.ts",
|
|
114
137
|
"import": "./dist/lsp/resolver.js",
|
|
@@ -196,6 +219,18 @@
|
|
|
196
219
|
"lsp/manager": [
|
|
197
220
|
"./dist/lsp/manager.d.ts"
|
|
198
221
|
],
|
|
222
|
+
"lsp/platform": [
|
|
223
|
+
"./dist/lsp/platform.d.ts"
|
|
224
|
+
],
|
|
225
|
+
"lsp/serverDiscovery": [
|
|
226
|
+
"./dist/lsp/serverDiscovery.d.ts"
|
|
227
|
+
],
|
|
228
|
+
"lsp/serverManifest": [
|
|
229
|
+
"./dist/lsp/serverManifest.d.ts"
|
|
230
|
+
],
|
|
231
|
+
"lsp/serverProvisioner": [
|
|
232
|
+
"./dist/lsp/serverProvisioner.d.ts"
|
|
233
|
+
],
|
|
199
234
|
"lsp/resolver": [
|
|
200
235
|
"./dist/lsp/resolver.d.ts"
|
|
201
236
|
],
|
|
@@ -227,12 +262,12 @@
|
|
|
227
262
|
"README.md"
|
|
228
263
|
],
|
|
229
264
|
"optionalDependencies": {
|
|
230
|
-
"@octocodeai/octocode-engine-darwin-arm64": "16.6.
|
|
231
|
-
"@octocodeai/octocode-engine-darwin-x64": "16.6.
|
|
232
|
-
"@octocodeai/octocode-engine-linux-arm64-gnu": "16.6.
|
|
233
|
-
"@octocodeai/octocode-engine-linux-x64-gnu": "16.6.
|
|
234
|
-
"@octocodeai/octocode-engine-linux-x64-musl": "16.6.
|
|
235
|
-
"@octocodeai/octocode-engine-win32-x64-msvc": "16.6.
|
|
265
|
+
"@octocodeai/octocode-engine-darwin-arm64": "16.6.2",
|
|
266
|
+
"@octocodeai/octocode-engine-darwin-x64": "16.6.2",
|
|
267
|
+
"@octocodeai/octocode-engine-linux-arm64-gnu": "16.6.2",
|
|
268
|
+
"@octocodeai/octocode-engine-linux-x64-gnu": "16.6.2",
|
|
269
|
+
"@octocodeai/octocode-engine-linux-x64-musl": "16.6.2",
|
|
270
|
+
"@octocodeai/octocode-engine-win32-x64-msvc": "16.6.2"
|
|
236
271
|
},
|
|
237
272
|
"engines": {
|
|
238
273
|
"node": ">=20.0.0"
|
|
@@ -256,10 +291,11 @@
|
|
|
256
291
|
"build:linux-x64-musl": "node scripts/prebuild.cjs && napi build --platform --release --cross-compile --target x86_64-unknown-linux-musl && node scripts/postbuild.cjs",
|
|
257
292
|
"build:linux-arm64-gnu": "node scripts/prebuild.cjs && napi build --platform --release --cross-compile --target aarch64-unknown-linux-gnu && node scripts/postbuild.cjs",
|
|
258
293
|
"build:windows-x64": "node scripts/prebuild.cjs && napi build --platform --release --cross-compile --target x86_64-pc-windows-msvc && node scripts/postbuild.cjs",
|
|
259
|
-
"build:all": "yarn build:darwin-arm64 && yarn build:darwin-x64 && yarn build:linux-x64-gnu && yarn build:linux-x64-musl && yarn build:linux-arm64-gnu && yarn build:windows-x64 && yarn build:ts",
|
|
294
|
+
"build:all": "yarn clean:binaries && yarn build:darwin-arm64 && yarn build:darwin-x64 && yarn build:linux-x64-gnu && yarn build:linux-x64-musl && yarn build:linux-arm64-gnu && yarn build:windows-x64 && yarn build:ts",
|
|
260
295
|
"build:dev": "yarn readme:sync && node scripts/prebuild.cjs && napi build --platform && node scripts/postbuild.cjs && tsc -p tsconfig.build.json",
|
|
261
296
|
"build:ts": "yarn readme:sync && rm -rf dist && tsc -p tsconfig.build.json",
|
|
262
297
|
"clean": "rm -rf dist/ && rm -f *.node",
|
|
298
|
+
"clean:binaries": "rm -f *.node && find npm -name '*.node' -delete",
|
|
263
299
|
"gen": "node scripts/gen-patterns.mjs",
|
|
264
300
|
"verify:patterns": "node scripts/gen-patterns.mjs && git diff --exit-code src/security/patterns.rs || (echo 'ERROR: src/security/patterns.rs is out of sync with the TS source — run yarn gen and commit the result' && exit 1)",
|
|
265
301
|
"pack:check": "node scripts/check-pack-size.cjs",
|
|
@@ -302,8 +338,13 @@
|
|
|
302
338
|
"prepublishOnly": "yarn version:sync"
|
|
303
339
|
},
|
|
304
340
|
"dependencies": {
|
|
341
|
+
"bash-language-server": "^5.6.0",
|
|
342
|
+
"intelephense": "^1.14.1",
|
|
343
|
+
"pyright": "^1.1.411",
|
|
305
344
|
"typescript": "^5.9.3",
|
|
306
345
|
"typescript-language-server": "^4.4.0",
|
|
346
|
+
"vscode-langservers-extracted": "^4.10.0",
|
|
347
|
+
"yaml-language-server": "^1.23.0",
|
|
307
348
|
"zod": "^4.4.3"
|
|
308
349
|
},
|
|
309
350
|
"devDependencies": {
|