@octocodeai/octocode-engine 16.5.1 → 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 +153 -235
- package/dist/lsp/client.d.ts +6 -0
- package/dist/lsp/client.js +31 -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 +75 -13
- package/dist/lsp/native.d.ts +5 -0
- 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/lsp/workspaceRoot.d.ts +1 -1
- package/dist/lsp/workspaceRoot.js +3 -1
- package/dist/security/commandValidator.js +10 -10
- package/dist/security/discoveryFilter.js +7 -3
- 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.d.ts +0 -3
- package/dist/security/withSecurityValidation.js +3 -21
- package/index.cjs +32 -2
- package/index.d.ts +113 -0
- package/index.js +5 -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[];
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare function resolveWorkspaceRootForFile(filePath: string,
|
|
1
|
+
export declare function resolveWorkspaceRootForFile(filePath: string, workspaceRoot?: string): Promise<string>;
|
|
2
2
|
export declare function findWorkspaceRoot(filePath: string): Promise<string>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { nativeBinding } from './native.js';
|
|
2
|
-
export async function resolveWorkspaceRootForFile(filePath,
|
|
2
|
+
export async function resolveWorkspaceRootForFile(filePath, workspaceRoot) {
|
|
3
|
+
if (workspaceRoot)
|
|
4
|
+
return workspaceRoot;
|
|
3
5
|
return nativeBinding.resolveWorkspaceRootForFile(filePath);
|
|
4
6
|
}
|
|
5
7
|
export async function findWorkspaceRoot(filePath) {
|
|
@@ -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',
|
|
@@ -76,8 +83,6 @@ export const DISCOVERY_IGNORED_FOLDER_NAMES = [
|
|
|
76
83
|
'.history',
|
|
77
84
|
'coverage',
|
|
78
85
|
'.nyc_output',
|
|
79
|
-
'logs',
|
|
80
|
-
'log',
|
|
81
86
|
'.DS_Store',
|
|
82
87
|
];
|
|
83
88
|
export const DISCOVERY_IGNORED_FILE_NAMES = [
|
|
@@ -126,7 +131,6 @@ export const DISCOVERY_IGNORED_FILE_NAMES = [
|
|
|
126
131
|
];
|
|
127
132
|
export const DISCOVERY_IGNORED_FILE_EXTENSIONS = [
|
|
128
133
|
'.lock',
|
|
129
|
-
'.log',
|
|
130
134
|
'.tmp',
|
|
131
135
|
'.temp',
|
|
132
136
|
'.cache',
|
|
@@ -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,
|
|
@@ -2,9 +2,6 @@ import type { ISanitizer, ToolResult } from './types.js';
|
|
|
2
2
|
export interface SecurityDepsConfig {
|
|
3
3
|
sanitizer?: ISanitizer;
|
|
4
4
|
defaultTimeoutMs?: number;
|
|
5
|
-
logToolCall?: (toolName: string, repos: string[], goal?: string, rGoal?: string, reasoning?: string) => Promise<void>;
|
|
6
|
-
logSessionError?: (toolName: string, errorCode: string) => Promise<void>;
|
|
7
|
-
isLoggingEnabled?: () => boolean;
|
|
8
5
|
}
|
|
9
6
|
export declare function configureSecurity(deps: SecurityDepsConfig): void;
|
|
10
7
|
export declare function withSecurityValidation<T extends Record<string, unknown>, TAuth = unknown>(toolName: string, toolHandler: (sanitizedArgs: T, authInfo?: TAuth, sessionId?: string) => Promise<ToolResult>, options?: {
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { ContentSanitizer } from './contentSanitizer.js';
|
|
2
|
-
import { extractResearchFields, extractRepoOwnerFromParams, } from './paramExtractors.js';
|
|
3
|
-
const SECURITY_VALIDATION_FAILED_CODE = 'TOOL_SECURITY_VALIDATION_FAILED';
|
|
4
2
|
const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
|
|
5
3
|
let _deps = {};
|
|
6
4
|
function getSanitizer() {
|
|
@@ -29,6 +27,9 @@ function withToolTimeout(toolName, promise, signal, timeoutMs) {
|
|
|
29
27
|
resolve(createErrorResult(`Tool '${toolName}' was cancelled by the client.`));
|
|
30
28
|
};
|
|
31
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.
|
|
32
33
|
if (signal?.aborted) {
|
|
33
34
|
clearTimeout(timer);
|
|
34
35
|
signal.removeEventListener('abort', onAbort);
|
|
@@ -58,15 +59,9 @@ async function runSecure(opts) {
|
|
|
58
59
|
}
|
|
59
60
|
const sanitizedParams = validation.sanitizedParams;
|
|
60
61
|
const rawResult = await withToolTimeout(toolName, handler(sanitizedParams, authInfo, sessionId), signal, timeoutMs);
|
|
61
|
-
if (!rawResult.isError && _deps.isLoggingEnabled?.()) {
|
|
62
|
-
handleBulk(toolName, sanitizedParams);
|
|
63
|
-
}
|
|
64
62
|
return rawResult;
|
|
65
63
|
}
|
|
66
64
|
catch (error) {
|
|
67
|
-
_deps
|
|
68
|
-
.logSessionError?.(toolName, SECURITY_VALIDATION_FAILED_CODE)
|
|
69
|
-
.catch(() => { });
|
|
70
65
|
return createErrorResult(`Security validation error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
71
66
|
}
|
|
72
67
|
}
|
|
@@ -92,16 +87,3 @@ export function withBasicSecurityValidation(toolHandler, toolName, options) {
|
|
|
92
87
|
timeoutMs: options?.timeoutMs,
|
|
93
88
|
});
|
|
94
89
|
}
|
|
95
|
-
function handleBulk(toolName, params) {
|
|
96
|
-
const queries = params.queries;
|
|
97
|
-
const items = queries && Array.isArray(queries) && queries.length > 0
|
|
98
|
-
? queries
|
|
99
|
-
: [params];
|
|
100
|
-
for (const item of items) {
|
|
101
|
-
const repos = extractRepoOwnerFromParams(item);
|
|
102
|
-
const fields = extractResearchFields(item);
|
|
103
|
-
_deps
|
|
104
|
-
.logToolCall?.(toolName, repos, fields.mainResearchGoal, fields.researchGoal, fields.reasoning)
|
|
105
|
-
.catch(() => { });
|
|
106
|
-
}
|
|
107
|
-
}
|