log-llm-config 1.3.96 → 1.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/log_config_files/collection/claude_desktop_extensions_collector.js +104 -1
- package/dist/log_config_files/collection/config_collector.js +64 -4
- package/dist/log_config_files/collection/cowork_session_whitelist.js +89 -0
- package/dist/log_config_files/collection/cursor_project_mcp_collector.js +47 -18
- package/dist/log_config_files/collection/directory_collector.js +2 -0
- package/dist/log_config_files/collection/metadata_merge.js +43 -0
- package/dist/log_config_files/collection/skills_cli_collector.js +98 -0
- package/dist/log_config_files/paths/pattern_resolver.js +122 -31
- package/dist/log_config_files/readers/file_readers.js +112 -2
- package/dist/log_config_files/runtime/compliance_check.js +9 -1
- package/dist/log_config_files/runtime/hook_type_for_request.js +30 -0
- package/dist/log_config_files/runtime/main_runner.js +19 -3
- package/dist/log_config_files/runtime/trusted_restarts.js +8 -0
- package/dist/log_sensitive_paths_audit.js +22 -3
- package/package.json +4 -4
|
@@ -19,6 +19,109 @@ function extensionsInstallationsPath(home) {
|
|
|
19
19
|
const base = claudeAppSupportDir(home);
|
|
20
20
|
return base ? join(base, 'extensions-installations.json') : '';
|
|
21
21
|
}
|
|
22
|
+
function claudeExtensionSettingsDir(home) {
|
|
23
|
+
const base = claudeAppSupportDir(home);
|
|
24
|
+
return base ? join(base, 'Claude Extensions Settings') : '';
|
|
25
|
+
}
|
|
26
|
+
/** Per-connector toggle: Claude Extensions Settings/<extension-id>.json → { isEnabled: boolean }. */
|
|
27
|
+
function readClaudeDesktopExtensionEnableSettings(home) {
|
|
28
|
+
const settingsDir = claudeExtensionSettingsDir(home);
|
|
29
|
+
if (!settingsDir || !existsSync(settingsDir))
|
|
30
|
+
return new Map();
|
|
31
|
+
const out = new Map();
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = readdirSync(settingsDir, { withFileTypes: true }).filter((d) => d.isFile());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
for (const dirent of entries) {
|
|
40
|
+
if (!dirent.name.endsWith('.json'))
|
|
41
|
+
continue;
|
|
42
|
+
const extId = dirent.name.slice(0, -'.json'.length);
|
|
43
|
+
if (!extId)
|
|
44
|
+
continue;
|
|
45
|
+
const raw = readJSONFile(join(settingsDir, dirent.name));
|
|
46
|
+
if (!raw || typeof raw !== 'object')
|
|
47
|
+
continue;
|
|
48
|
+
const isEnabled = raw.isEnabled;
|
|
49
|
+
if (typeof isEnabled === 'boolean') {
|
|
50
|
+
out.set(extId, isEnabled);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function applyExtensionEnableSettingsToRawContent(rawContent, settings) {
|
|
56
|
+
if (settings.size === 0)
|
|
57
|
+
return 0;
|
|
58
|
+
const extensions = rawContent.extensions;
|
|
59
|
+
if (!extensions || typeof extensions !== 'object')
|
|
60
|
+
return 0;
|
|
61
|
+
let applied = 0;
|
|
62
|
+
for (const [extId, enabled] of settings) {
|
|
63
|
+
const ext = extensions[extId];
|
|
64
|
+
if (!ext || typeof ext !== 'object')
|
|
65
|
+
continue;
|
|
66
|
+
ext.isEnabled = enabled;
|
|
67
|
+
applied += 1;
|
|
68
|
+
}
|
|
69
|
+
return applied;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Upload each Claude Extensions Settings/*.json as its own config file (raw on-disk JSON).
|
|
73
|
+
* Do not merge isEnabled into extensions-installations.json — that mutates the payload
|
|
74
|
+
* vs the source file and creates spurious inventory audit diffs.
|
|
75
|
+
*/
|
|
76
|
+
function collectClaudeDesktopExtensionSettingsFiles(home = homedir()) {
|
|
77
|
+
const settingsDir = claudeExtensionSettingsDir(home);
|
|
78
|
+
if (!settingsDir || !existsSync(settingsDir))
|
|
79
|
+
return [];
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = readdirSync(settingsDir, { withFileTypes: true }).filter((d) => d.isFile());
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const results = [];
|
|
88
|
+
for (const dirent of entries) {
|
|
89
|
+
if (!dirent.name.endsWith('.json'))
|
|
90
|
+
continue;
|
|
91
|
+
const filePath = join(settingsDir, dirent.name);
|
|
92
|
+
const raw = readJSONFile(filePath);
|
|
93
|
+
if (!raw || typeof raw !== 'object')
|
|
94
|
+
continue;
|
|
95
|
+
results.push({
|
|
96
|
+
file_type: 'claude_extension_settings',
|
|
97
|
+
file_path: filePath,
|
|
98
|
+
raw_content: raw,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* @deprecated Do not call during collection — mutates extensions-installations upload payloads.
|
|
105
|
+
* isEnabled is resolved server-side from claude_extension_settings uploads.
|
|
106
|
+
*/
|
|
107
|
+
function mergeClaudeDesktopExtensionEnableSettings(configFiles, home = homedir()) {
|
|
108
|
+
const settings = readClaudeDesktopExtensionEnableSettings(home);
|
|
109
|
+
if (settings.size === 0) {
|
|
110
|
+
return { applied: 0, settingsCount: 0 };
|
|
111
|
+
}
|
|
112
|
+
let applied = 0;
|
|
113
|
+
for (const entry of configFiles) {
|
|
114
|
+
if (entry.file_type !== 'claude_extensions_installations')
|
|
115
|
+
continue;
|
|
116
|
+
if (!entry.raw_content || typeof entry.raw_content !== 'object')
|
|
117
|
+
continue;
|
|
118
|
+
const extensions = entry.raw_content.extensions;
|
|
119
|
+
if (!extensions || typeof extensions !== 'object')
|
|
120
|
+
continue;
|
|
121
|
+
applied += applyExtensionEnableSettingsToRawContent(entry.raw_content, settings);
|
|
122
|
+
}
|
|
123
|
+
return { applied, settingsCount: settings.size };
|
|
124
|
+
}
|
|
22
125
|
function normalizePathForMatch(path) {
|
|
23
126
|
return path.replace(/\\/g, '/');
|
|
24
127
|
}
|
|
@@ -142,4 +245,4 @@ function collectClaudeDesktopExtensionManifests(home = homedir()) {
|
|
|
142
245
|
}
|
|
143
246
|
return results;
|
|
144
247
|
}
|
|
145
|
-
export { claudeAppSupportDir, claudeDesktopExtensionsDir, collectClaudeDesktopExtensionManifests, collectDiskOnlyClaudeDesktopExtensions, enrichClaudeDesktopExtensionsInstallationsUpload, };
|
|
248
|
+
export { claudeAppSupportDir, claudeDesktopExtensionsDir, claudeExtensionSettingsDir, collectClaudeDesktopExtensionSettingsFiles, collectClaudeDesktopExtensionManifests, collectDiskOnlyClaudeDesktopExtensions, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, readClaudeDesktopExtensionEnableSettings, };
|
|
@@ -1,12 +1,54 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { readJSONFile, readMCPConfig, readMarkdownFile, readInstalledExtensions } from '../readers/file_readers.js';
|
|
4
4
|
import { getExtensionsCachePath, getExtensionsCacheInstalledSuffix, getVscdbPath } from '../paths/path_constants_helpers.js';
|
|
5
5
|
import { resolvePatternToTargets, normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
|
|
6
6
|
import { enrichRawFromRecipe } from './enrichment_helpers.js';
|
|
7
|
+
import { applyCoworkSessionWhitelist } from './cowork_session_whitelist.js';
|
|
7
8
|
import { collectDirectoryEntries, collectDirectoryMetadata } from './directory_collector.js';
|
|
8
9
|
import { collectVscdbEntries } from '../readers/vscdb_config_builder.js';
|
|
9
10
|
import { pushDerivedFilesFromRecipe } from './openclaw_helpers.js';
|
|
11
|
+
import { collapseMetadataByFileType } from './metadata_merge.js';
|
|
12
|
+
function withOpencodeEnvPatterns(patterns) {
|
|
13
|
+
const out = [...patterns];
|
|
14
|
+
const seen = new Set(out.map((p) => `${p.type}:${p.file_type}:${p.path}`));
|
|
15
|
+
const add = (pattern) => {
|
|
16
|
+
const key = `${pattern.type}:${pattern.file_type}:${pattern.path}`;
|
|
17
|
+
if (seen.has(key))
|
|
18
|
+
return;
|
|
19
|
+
seen.add(key);
|
|
20
|
+
out.push(pattern);
|
|
21
|
+
};
|
|
22
|
+
const envConfig = (process.env.OPENCODE_CONFIG || '').trim();
|
|
23
|
+
if (envConfig) {
|
|
24
|
+
add({
|
|
25
|
+
path: envConfig,
|
|
26
|
+
type: 'file',
|
|
27
|
+
file_type: 'opencode_mcp',
|
|
28
|
+
content_format: 'json',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const envDirRaw = (process.env.OPENCODE_CONFIG_DIR || '').trim();
|
|
32
|
+
if (!envDirRaw)
|
|
33
|
+
return out;
|
|
34
|
+
const envDir = envDirRaw.replace(/[\\/]+$/, '');
|
|
35
|
+
add({ path: `${envDir}/opencode.json`, type: 'file', file_type: 'opencode_mcp', content_format: 'json' });
|
|
36
|
+
add({ path: `${envDir}/opencode.jsonc`, type: 'file', file_type: 'opencode_mcp', content_format: 'json' });
|
|
37
|
+
add({ path: `${envDir}/command-hooks.json`, type: 'file', file_type: 'opencode_command_hooks', content_format: 'json' });
|
|
38
|
+
add({ path: `${envDir}/command-hooks.jsonc`, type: 'file', file_type: 'opencode_command_hooks', content_format: 'json' });
|
|
39
|
+
add({ path: `${envDir}/AGENTS.md`, type: 'file', file_type: 'opencode_rule', content_format: 'markdown' });
|
|
40
|
+
add({ path: `${envDir}/agents/`, type: 'dir', file_type: 'opencode_subagent', content_format: 'markdown', dir_glob: '*.md' });
|
|
41
|
+
add({ path: `${envDir}/commands/`, type: 'dir', file_type: 'opencode_command', content_format: 'markdown', dir_glob: '*.md' });
|
|
42
|
+
add({ path: `${envDir}/skills/`, type: 'dir', file_type: 'opencode_skill', content_format: 'markdown', dir_glob: '*.md' });
|
|
43
|
+
add({ path: `${envDir}/mcp-auth.json`, type: 'file', file_type: 'opencode_mcp_auth', content_format: 'json' });
|
|
44
|
+
add({ path: `${envDir}/managed.json`, type: 'file', file_type: 'opencode_managed_preference', content_format: 'json' });
|
|
45
|
+
add({ path: `${envDir}/package.json`, type: 'file', file_type: 'opencode_plugin_manifest', content_format: 'json' });
|
|
46
|
+
add({ path: `${envDir}/plugins/`, type: 'dir', file_type: 'opencode_plugin', content_format: 'text', dir_glob: '*' });
|
|
47
|
+
add({ path: `${envDir}/plugin/`, type: 'dir', file_type: 'opencode_plugin', content_format: 'text', dir_glob: '*' });
|
|
48
|
+
add({ path: `${envDir}/plugins/*/package.json`, type: 'file', file_type: 'opencode_plugin_manifest', content_format: 'json' });
|
|
49
|
+
add({ path: `${envDir}/plugin/*/package.json`, type: 'file', file_type: 'opencode_plugin_manifest', content_format: 'json' });
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
10
52
|
function buildCollectionContext(patterns, projectRoot, home, homeRecurseSkipDirs = [], absolutePathPrefixes = [], mcpToolGlobSpec = null, clientPathConstants = null, pathResolutionSpecs = null) {
|
|
11
53
|
const seenPaths = new Set();
|
|
12
54
|
const targets = [];
|
|
@@ -68,11 +110,28 @@ function collectRegularFileEntry(t, enrichByFileType) {
|
|
|
68
110
|
const recipe = enrichByFileType[t.file_type];
|
|
69
111
|
if (recipe)
|
|
70
112
|
enrichRawFromRecipe(raw, recipe);
|
|
113
|
+
// Cowork session metadata: reduce to the PII whitelist (hash identity, drop
|
|
114
|
+
// server URLs, keep only the activity-signal keys) BEFORE it leaves the
|
|
115
|
+
// device. Drop the entry entirely if nothing whitelisted survives so raw
|
|
116
|
+
// session content can never be emitted (e.g. on a JSON parse failure).
|
|
117
|
+
if (t.file_type === 'cowork_session_metadata') {
|
|
118
|
+
const filtered = applyCoworkSessionWhitelist(raw);
|
|
119
|
+
if (filtered === null)
|
|
120
|
+
return null;
|
|
121
|
+
raw = filtered;
|
|
122
|
+
}
|
|
71
123
|
return { file_type: t.file_type, file_path: t.logicalFilePath ?? t.path, raw_content: raw };
|
|
72
124
|
}
|
|
73
125
|
function collectMetadataEntry(t) {
|
|
74
126
|
try {
|
|
75
127
|
const stats = statSync(t.path);
|
|
128
|
+
if (stats.isDirectory?.()) {
|
|
129
|
+
try {
|
|
130
|
+
if (readdirSync(t.path).length === 0)
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
catch { /* unreadable — assume non-empty, allow */ }
|
|
134
|
+
}
|
|
76
135
|
return { file_type: t.file_type, file_path: t.logicalFilePath ?? t.path, raw_content: { filename: t.path, last_modified: stats.mtime.toISOString(), source: 'file_metadata' }, collect_style: 'metadata' };
|
|
77
136
|
}
|
|
78
137
|
catch (err) {
|
|
@@ -98,7 +157,8 @@ function collectConfigFilesFromPatterns(patterns, projectRoot, onProgress, optio
|
|
|
98
157
|
const vscdbPath = getVscdbPath(home, pathConstants);
|
|
99
158
|
const extensionsCachePath = getExtensionsCachePath(home, pathConstants);
|
|
100
159
|
const extensionsInstalledSuffix = getExtensionsCacheInstalledSuffix(pathConstants);
|
|
101
|
-
const
|
|
160
|
+
const effectivePatterns = withOpencodeEnvPatterns(patterns);
|
|
161
|
+
const { targets, enrichByFileType, metadataOnlyFileTypes } = buildCollectionContext(effectivePatterns, projectRoot, home, options?.home_recurse_skip_dirs ?? [], options?.absolute_path_prefixes ?? [], options?.mcp_tool_glob_spec ?? null, pathConstants, options?.path_resolution_specs ?? null);
|
|
102
162
|
const configFiles = [];
|
|
103
163
|
const handledSpecialPaths = new Set();
|
|
104
164
|
const loggedFileTypes = new Set();
|
|
@@ -148,14 +208,14 @@ function collectConfigFilesFromPatterns(patterns, projectRoot, onProgress, optio
|
|
|
148
208
|
pushDerivedFilesFromRecipe(entry.raw_content, configFiles, recipe);
|
|
149
209
|
}
|
|
150
210
|
}
|
|
151
|
-
return configFiles;
|
|
211
|
+
return collapseMetadataByFileType(configFiles);
|
|
152
212
|
}
|
|
153
213
|
// ─── Re-exports (barrel for public API) ──────────────────────────────────────
|
|
154
214
|
export { collectConfigFilesFromPatterns };
|
|
155
215
|
export { collectMcpToolFiles } from './mcp_tool_collector.js';
|
|
156
216
|
export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles } from './plugin_collector.js';
|
|
157
217
|
export { collectMcpFromClaudeJsonProjects } from './claude_known_projects_collector.js';
|
|
158
|
-
export { collectClaudeDesktopExtensionManifests, enrichClaudeDesktopExtensionsInstallationsUpload, } from './claude_desktop_extensions_collector.js';
|
|
218
|
+
export { collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, } from './claude_desktop_extensions_collector.js';
|
|
159
219
|
export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
|
|
160
220
|
export { determineFileTypeFromPath } from './file_type_rules.js';
|
|
161
221
|
export { enrichRawFromRecipe } from './enrichment_helpers.js';
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PII boundary for Cowork session metadata (`local_<sessionId>.json`).
|
|
3
|
+
*
|
|
4
|
+
* Each Cowork session file carries ~47 KB of system prompt plus the user's
|
|
5
|
+
* conversation seed, selected folders, etc. We only need a small activity
|
|
6
|
+
* signal (which MCP servers were connected, recency, archived state). This
|
|
7
|
+
* reduces a parsed session object to a whitelist of top-level keys BEFORE it
|
|
8
|
+
* leaves the device — minimum-egress, matching DeepSecurityAndPrivacyEnhancer.
|
|
9
|
+
*
|
|
10
|
+
* - Only the keys in ALLOWED_KEYS survive; everything else is dropped.
|
|
11
|
+
* - `accountName` / `emailAddress` are sha256-hashed (cross-session joins
|
|
12
|
+
* without storing raw identity).
|
|
13
|
+
* - `remoteMcpServersConfig[]` keeps `{name, uuid, tools}` only; `url` is
|
|
14
|
+
* dropped (remote-MCP endpoint URLs can embed workspace ids / tokens).
|
|
15
|
+
*
|
|
16
|
+
* The sibling `local_<sessionId>/` directory (audit.jsonl, outputs/, uploads/)
|
|
17
|
+
* is never collected — see the collection glob in agents/cowork.py. Server-side
|
|
18
|
+
* ingest re-validates the shape and rejects any denied key as defense in depth.
|
|
19
|
+
*
|
|
20
|
+
* See design_notes/Phase0_5CoworkActivityEvidence.md.
|
|
21
|
+
*/
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
export const COWORK_SESSION_ALLOWED_KEYS = [
|
|
24
|
+
'sessionId',
|
|
25
|
+
'accountName', // hashed
|
|
26
|
+
'emailAddress', // hashed
|
|
27
|
+
'model',
|
|
28
|
+
'createdAt',
|
|
29
|
+
'lastActivityAt',
|
|
30
|
+
'isArchived',
|
|
31
|
+
'pluginsEnabled',
|
|
32
|
+
'skillsEnabled',
|
|
33
|
+
'memoryEnabled',
|
|
34
|
+
'hostLoopMode',
|
|
35
|
+
'cwd',
|
|
36
|
+
'enabledMcpTools', // dict "<serverUUID>:<tool>" -> bool
|
|
37
|
+
'remoteMcpServersConfig', // list of {name, uuid, tools} (url dropped)
|
|
38
|
+
];
|
|
39
|
+
const HASHED_KEYS = new Set(['accountName', 'emailAddress']);
|
|
40
|
+
const REMOTE_SERVER_KEEP = ['name', 'uuid', 'tools'];
|
|
41
|
+
function sha256Hex(value) {
|
|
42
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
function sanitizeRemoteServers(value) {
|
|
45
|
+
if (!Array.isArray(value))
|
|
46
|
+
return [];
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const entry of value) {
|
|
49
|
+
// Drop any non-object entry: a malformed/tampered remoteMcpServersConfig
|
|
50
|
+
// could carry a raw string (e.g. "https://…?token=…") that would otherwise
|
|
51
|
+
// survive the whitelist. Only keep whitelisted keys of well-formed objects.
|
|
52
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
|
53
|
+
continue;
|
|
54
|
+
const e = entry;
|
|
55
|
+
const kept = {};
|
|
56
|
+
for (const k of REMOTE_SERVER_KEEP) {
|
|
57
|
+
if (k in e)
|
|
58
|
+
kept[k] = e[k];
|
|
59
|
+
}
|
|
60
|
+
out.push(kept);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reduce a parsed Cowork session object to the whitelisted, hashed shape.
|
|
66
|
+
* Returns `null` when nothing survives (e.g. a non-object or a file that
|
|
67
|
+
* failed to parse to the expected shape) so the caller can drop the entry
|
|
68
|
+
* rather than emit an empty row — and, critically, never emit raw content.
|
|
69
|
+
*/
|
|
70
|
+
export function applyCoworkSessionWhitelist(raw) {
|
|
71
|
+
if (raw === null || raw === undefined || typeof raw !== 'object')
|
|
72
|
+
return null;
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const key of COWORK_SESSION_ALLOWED_KEYS) {
|
|
75
|
+
if (!(key in raw))
|
|
76
|
+
continue;
|
|
77
|
+
if (HASHED_KEYS.has(key)) {
|
|
78
|
+
const v = raw[key];
|
|
79
|
+
out[key] = v === null || v === undefined || v === '' ? '' : sha256Hex(v);
|
|
80
|
+
}
|
|
81
|
+
else if (key === 'remoteMcpServersConfig') {
|
|
82
|
+
out[key] = sanitizeRemoteServers(raw[key]);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
out[key] = raw[key];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
89
|
+
}
|
|
@@ -105,10 +105,45 @@ function isEphemeralCursorProjectDir(name) {
|
|
|
105
105
|
function isInternalCursorWorkspaceSlug(slug) {
|
|
106
106
|
return normalizeSlugPart(slug) === 'empty-window';
|
|
107
107
|
}
|
|
108
|
+
function readMcpToolCache(projectPath) {
|
|
109
|
+
const cachePath = join(projectPath, 'mcp-cache.json');
|
|
110
|
+
if (!existsSync(cachePath))
|
|
111
|
+
return null;
|
|
112
|
+
const cache = readJSONFile(cachePath);
|
|
113
|
+
if (cache && typeof cache === 'object' && !Array.isArray(cache)) {
|
|
114
|
+
return cache;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/** Tool schemas from mcp-cache.json for a connected mcps/ server (never creates inventory rows). */
|
|
119
|
+
function cursorMcpCacheToolsForServer(cache, label, serverDirName) {
|
|
120
|
+
if (!cache)
|
|
121
|
+
return undefined;
|
|
122
|
+
const aliases = new Set();
|
|
123
|
+
const addAlias = (s) => {
|
|
124
|
+
const t = s.trim();
|
|
125
|
+
if (t)
|
|
126
|
+
aliases.add(t.toLowerCase());
|
|
127
|
+
};
|
|
128
|
+
addAlias(label);
|
|
129
|
+
addAlias(serverDirName);
|
|
130
|
+
if (serverDirName.startsWith('user-'))
|
|
131
|
+
addAlias(serverDirName.slice('user-'.length));
|
|
132
|
+
else
|
|
133
|
+
addAlias(`user-${label}`);
|
|
134
|
+
for (const [key, val] of Object.entries(cache)) {
|
|
135
|
+
if (!key.trim() || !aliases.has(key.trim().toLowerCase()))
|
|
136
|
+
continue;
|
|
137
|
+
if (val && typeof val === 'object' && Array.isArray(val.tools)) {
|
|
138
|
+
return val.tools;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
108
143
|
/**
|
|
109
144
|
* Cursor stores per-workspace MCP state under ~/.cursor/projects/<slug>/ (mcp-cache.json, mcps/*).
|
|
110
145
|
* This is not the repo file at <repo>/.cursor/mcp.json. Upload one mcp_config per slug with
|
|
111
|
-
*
|
|
146
|
+
* mcpServers from mcps/ only; mcp-cache.json supplies supplementary tool schemas when names match.
|
|
112
147
|
*/
|
|
113
148
|
function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constants, workspaceVscdbSpec, home = homedir()) {
|
|
114
149
|
const skipPrefixes = normalizePathSkipPrefixes(pathSkipPrefixes);
|
|
@@ -136,18 +171,7 @@ function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constant
|
|
|
136
171
|
continue;
|
|
137
172
|
const mcpServers = {};
|
|
138
173
|
const cachePath = join(projectPath, 'mcp-cache.json');
|
|
139
|
-
|
|
140
|
-
const cache = readJSONFile(cachePath);
|
|
141
|
-
if (cache && typeof cache === 'object' && !Array.isArray(cache)) {
|
|
142
|
-
for (const [name, val] of Object.entries(cache)) {
|
|
143
|
-
if (!name.trim())
|
|
144
|
-
continue;
|
|
145
|
-
if (val && typeof val === 'object' && Array.isArray(val.tools)) {
|
|
146
|
-
mcpServers[name] = { cursor_source: 'mcp-cache.json' };
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
174
|
+
const mcpToolCache = readMcpToolCache(projectPath);
|
|
151
175
|
const mcpsPath = join(projectPath, 'mcps');
|
|
152
176
|
if (existsSync(mcpsPath)) {
|
|
153
177
|
try {
|
|
@@ -158,12 +182,17 @@ function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constant
|
|
|
158
182
|
const label = mcpServerLabelFromMcpsDir(serverDir.name, metaObj);
|
|
159
183
|
if (!label)
|
|
160
184
|
continue;
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
185
|
+
if (label in mcpServers)
|
|
186
|
+
continue;
|
|
187
|
+
const entry = {
|
|
188
|
+
cursor_source: 'mcps',
|
|
189
|
+
server_identifier: serverDir.name,
|
|
190
|
+
};
|
|
191
|
+
const cacheTools = cursorMcpCacheToolsForServer(mcpToolCache, label, serverDir.name);
|
|
192
|
+
if (cacheTools !== undefined) {
|
|
193
|
+
entry.cursor_mcp_cache_tools = cacheTools;
|
|
166
194
|
}
|
|
195
|
+
mcpServers[label] = entry;
|
|
167
196
|
}
|
|
168
197
|
}
|
|
169
198
|
catch {
|
|
@@ -57,6 +57,8 @@ function matchesDirGlob(name, glob) {
|
|
|
57
57
|
// **/*.jsonl → match any extension suffix after **/
|
|
58
58
|
if (glob.startsWith('**/')) {
|
|
59
59
|
const suffix = glob.slice(3);
|
|
60
|
+
if (suffix === '*' || suffix === '**')
|
|
61
|
+
return true;
|
|
60
62
|
if (suffix.startsWith('*.'))
|
|
61
63
|
return name.endsWith(suffix.slice(1));
|
|
62
64
|
return name === suffix;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** When several install/log paths exist for one agent, keep a single metadata row (newest mtime). */
|
|
2
|
+
const METADATA_COLLAPSE_BY_FILE_TYPE = new Set(['opencode_presence', 'opencode_log']);
|
|
3
|
+
function metadataLastModifiedMs(entry) {
|
|
4
|
+
const raw = entry.raw_content;
|
|
5
|
+
if (!raw || typeof raw !== 'object')
|
|
6
|
+
return 0;
|
|
7
|
+
const lm = raw.last_modified;
|
|
8
|
+
if (typeof lm !== 'string')
|
|
9
|
+
return 0;
|
|
10
|
+
const ms = Date.parse(lm);
|
|
11
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Collapse multiple metadata uploads of the same file_type to one row: highest last_modified wins.
|
|
15
|
+
*/
|
|
16
|
+
function collapseMetadataByFileType(files) {
|
|
17
|
+
const passthrough = [];
|
|
18
|
+
const groups = new Map();
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
if (f.collect_style === 'metadata' && METADATA_COLLAPSE_BY_FILE_TYPE.has(f.file_type)) {
|
|
21
|
+
const list = groups.get(f.file_type) ?? [];
|
|
22
|
+
list.push(f);
|
|
23
|
+
groups.set(f.file_type, list);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
passthrough.push(f);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const merged = [...passthrough];
|
|
30
|
+
for (const [, list] of groups) {
|
|
31
|
+
if (list.length === 0)
|
|
32
|
+
continue;
|
|
33
|
+
let best = list[0];
|
|
34
|
+
for (let i = 1; i < list.length; i++) {
|
|
35
|
+
if (metadataLastModifiedMs(list[i]) > metadataLastModifiedMs(best)) {
|
|
36
|
+
best = list[i];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
merged.push(best);
|
|
40
|
+
}
|
|
41
|
+
return merged;
|
|
42
|
+
}
|
|
43
|
+
export { collapseMetadataByFileType, METADATA_COLLAPSE_BY_FILE_TYPE };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export const SKILLS_CLI_FILE_TYPE = 'skills_cli_installed';
|
|
5
|
+
export const SKILLS_CLI_INSTALLED_PATH = join(homedir(), '.agents', '.skills-cli-installed.json');
|
|
6
|
+
/** Override the skills package spec, e.g. `skills@1.5.10`. Default uses whatever is on the machine. */
|
|
7
|
+
export const SKILLS_CLI_NPX_PACKAGE_ENV = 'SKILLS_CLI_NPX_PACKAGE';
|
|
8
|
+
const LIST_TIMEOUT_MS = 120_000;
|
|
9
|
+
function listExecEnv() {
|
|
10
|
+
return {
|
|
11
|
+
...process.env,
|
|
12
|
+
DISABLE_TELEMETRY: process.env.DISABLE_TELEMETRY ?? '1',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function npxPackageSpec() {
|
|
16
|
+
const fromEnv = (process.env[SKILLS_CLI_NPX_PACKAGE_ENV] || '').trim();
|
|
17
|
+
return fromEnv || 'skills';
|
|
18
|
+
}
|
|
19
|
+
export function runSkillsListJson(args, cwd) {
|
|
20
|
+
const out = execFileSync('npx', [npxPackageSpec(), 'list', ...args, '--json'], {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
cwd,
|
|
23
|
+
timeout: LIST_TIMEOUT_MS,
|
|
24
|
+
env: listExecEnv(),
|
|
25
|
+
});
|
|
26
|
+
const trimmed = out.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
return [];
|
|
29
|
+
const parsed = JSON.parse(trimmed);
|
|
30
|
+
if (!Array.isArray(parsed))
|
|
31
|
+
return [];
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
function normalizeListRows(rows, scope) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
const name = (row.name || '').trim();
|
|
38
|
+
const path = (row.path || '').trim();
|
|
39
|
+
if (!name || !path)
|
|
40
|
+
continue;
|
|
41
|
+
const agents = Array.isArray(row.agents)
|
|
42
|
+
? row.agents.map((a) => String(a).trim()).filter(Boolean)
|
|
43
|
+
: [];
|
|
44
|
+
out.push({
|
|
45
|
+
name,
|
|
46
|
+
path,
|
|
47
|
+
scope: (row.scope || scope).trim() || scope,
|
|
48
|
+
agents,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** One-line-per-scope summary for hook_request.log (matches `skills list --json`). */
|
|
54
|
+
export function formatSkillsListScopeForHookLog(scopeLabel, entries) {
|
|
55
|
+
if (entries.length === 0) {
|
|
56
|
+
return `skills_cli list ${scopeLabel}: 0 skill(s)`;
|
|
57
|
+
}
|
|
58
|
+
const detail = entries
|
|
59
|
+
.map((e) => `${e.name}@${e.path} agents=${e.agents.length ? e.agents.join(',') : 'none'}`)
|
|
60
|
+
.join(' | ');
|
|
61
|
+
return `skills_cli list ${scopeLabel}: ${entries.length} skill(s) — ${detail}`;
|
|
62
|
+
}
|
|
63
|
+
export function collectSkillsCliInstalled(projectRoot, log) {
|
|
64
|
+
const logLine = (message) => {
|
|
65
|
+
log?.(message);
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
const packageSpec = npxPackageSpec();
|
|
69
|
+
logLine(`skills_cli: npx ${packageSpec} — list -g --json && list --json (projectRoot=${projectRoot})`);
|
|
70
|
+
const globalRows = runSkillsListJson(['-g'], projectRoot);
|
|
71
|
+
const projectRows = runSkillsListJson([], projectRoot);
|
|
72
|
+
const global = normalizeListRows(globalRows, 'global');
|
|
73
|
+
const project = normalizeListRows(projectRows, 'project');
|
|
74
|
+
logLine(formatSkillsListScopeForHookLog('-g', global));
|
|
75
|
+
logLine(formatSkillsListScopeForHookLog('project', project));
|
|
76
|
+
const payload = {
|
|
77
|
+
version: 1,
|
|
78
|
+
skills_cli_version: packageSpec,
|
|
79
|
+
generated_at: new Date().toISOString(),
|
|
80
|
+
global,
|
|
81
|
+
project,
|
|
82
|
+
};
|
|
83
|
+
if (payload.global.length === 0 && payload.project.length === 0) {
|
|
84
|
+
logLine('skills_cli_installed: not uploaded (no global or project skills)');
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
logLine(`skills_cli_installed: upload ${SKILLS_CLI_INSTALLED_PATH} global=${global.length} project=${project.length}`);
|
|
88
|
+
return {
|
|
89
|
+
file_type: SKILLS_CLI_FILE_TYPE,
|
|
90
|
+
file_path: SKILLS_CLI_INSTALLED_PATH,
|
|
91
|
+
raw_content: payload,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
logLine(`skills_cli: list --json failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -8,6 +8,20 @@ function normalizePathSkipPrefixes(prefixes) {
|
|
|
8
8
|
return [];
|
|
9
9
|
return prefixes.filter((p) => typeof p === 'string' && p.length > 0);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Expands glob patterns containing double-asterisk (recursive directory traversal).
|
|
13
|
+
*
|
|
14
|
+
* Example: ~/.cursor/plugins/cache/ ** /skills/ recursively finds all skills
|
|
15
|
+
* directories under the cache folder, up to RECURSIVE_GLOB_MAX_DEPTH.
|
|
16
|
+
*
|
|
17
|
+
* @param pathPattern - Glob pattern with double-asterisk for recursive descent
|
|
18
|
+
* @param fileType - File type classification for collected targets
|
|
19
|
+
* @param home - User home directory path
|
|
20
|
+
* @param contentFormat - Optional content format hint
|
|
21
|
+
* @param dirGlob - Optional directory glob pattern
|
|
22
|
+
* @param homeRecurseSkipDirs - Directory names to skip when recursing from home
|
|
23
|
+
* @returns Array of collection targets matching the pattern
|
|
24
|
+
*/
|
|
11
25
|
function expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentFormat, dirGlob, homeRecurseSkipDirs = []) {
|
|
12
26
|
const norm = pathPattern.replace(/\\/g, '/');
|
|
13
27
|
const doubleStarIndex = norm.indexOf('**');
|
|
@@ -25,7 +39,9 @@ function expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentForm
|
|
|
25
39
|
return [];
|
|
26
40
|
}
|
|
27
41
|
const targets = [];
|
|
28
|
-
const parts = after.split('/');
|
|
42
|
+
const parts = after.split('/').filter((s) => s.length > 0);
|
|
43
|
+
if (parts.length === 0)
|
|
44
|
+
return [];
|
|
29
45
|
const dirName = parts[0];
|
|
30
46
|
const fileName = parts.length > 1 ? parts[parts.length - 1] : null;
|
|
31
47
|
const skipSet = homeRecurseSkipDirs.length ? new Set(homeRecurseSkipDirs) : new Set();
|
|
@@ -41,8 +57,21 @@ function expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentForm
|
|
|
41
57
|
continue;
|
|
42
58
|
if (entry.name === dirName) {
|
|
43
59
|
const filePath = parts.length === 1 ? full : join(full, ...parts.slice(1));
|
|
44
|
-
if (
|
|
60
|
+
if (parts.length === 1) {
|
|
61
|
+
// e.g. ~/.cursor/plugins/cache/**/skills/ — collect each matched skills/ tree as a directory target
|
|
62
|
+
if (existsSync(filePath)) {
|
|
63
|
+
targets.push({
|
|
64
|
+
path: filePath,
|
|
65
|
+
file_type: fileType,
|
|
66
|
+
isDirectory: true,
|
|
67
|
+
content_format: contentFormat,
|
|
68
|
+
dir_glob: dirGlob,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (fileName && existsSync(filePath)) {
|
|
45
73
|
targets.push({ path: filePath, file_type: fileType, content_format: contentFormat });
|
|
74
|
+
}
|
|
46
75
|
}
|
|
47
76
|
walk(full, depth + 1, false);
|
|
48
77
|
}
|
|
@@ -52,26 +81,43 @@ function expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentForm
|
|
|
52
81
|
walk(basePath, 0, true);
|
|
53
82
|
return targets;
|
|
54
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Expands glob patterns with asterisk wildcards (non-recursive) into concrete file or directory paths.
|
|
86
|
+
*
|
|
87
|
+
* Supports multi-segment wildcards (e.g., star-slash-star-slash patterns).
|
|
88
|
+
*
|
|
89
|
+
* **Key behavior:** On the final segment of a file pattern (not ending in slash), wildcards match
|
|
90
|
+
* files; earlier segments—and directory patterns—match directories only. This allows filename
|
|
91
|
+
* patterns like local_STAR.json to correctly resolve files rather than directories.
|
|
92
|
+
*
|
|
93
|
+
* Examples:
|
|
94
|
+
* - Pattern ending with a literal filename matches that file in wildcard directories
|
|
95
|
+
* - Pattern ending with prefix_STAR.ext matches files with that prefix and extension
|
|
96
|
+
* - Pattern ending with slash matches only directories
|
|
97
|
+
*
|
|
98
|
+
* @param pathPattern - Glob pattern (may not contain double-asterisk; use expandRecursiveGlobPathPattern for that)
|
|
99
|
+
* @param fileType - File type classification for collected targets
|
|
100
|
+
* @param home - User home directory path
|
|
101
|
+
* @param projectRoot - Project root directory path
|
|
102
|
+
* @param contentFormat - Optional content format hint
|
|
103
|
+
* @param dirGlob - Optional directory glob pattern
|
|
104
|
+
* @param absolutePathPrefixes - Allowed absolute path prefixes
|
|
105
|
+
* @returns Array of collection targets matching the pattern
|
|
106
|
+
*/
|
|
55
107
|
function expandGlobPathPattern(pathPattern, fileType, home, projectRoot, contentFormat, dirGlob, absolutePathPrefixes = []) {
|
|
56
108
|
const norm = pathPattern.replace(/\\/g, '/');
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
109
|
+
if (!norm.includes('*'))
|
|
110
|
+
return [];
|
|
111
|
+
// `**` is the recursive-glob sigil handled by expandRecursiveGlobPathPattern.
|
|
112
|
+
if (norm.includes('**'))
|
|
113
|
+
return [];
|
|
61
114
|
const isDir = norm.endsWith('/');
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!before.endsWith('/')) {
|
|
69
|
-
const lastSlash = parentPart.lastIndexOf('/');
|
|
70
|
-
if (lastSlash !== -1) {
|
|
71
|
-
namePrefix = parentPart.slice(lastSlash + 1);
|
|
72
|
-
parentPart = parentPart.slice(0, lastSlash);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
115
|
+
// Resolve the literal prefix that ends before the first wildcard segment.
|
|
116
|
+
const firstStarIndex = norm.indexOf('*');
|
|
117
|
+
const lastSlashBeforeStar = norm.lastIndexOf('/', firstStarIndex);
|
|
118
|
+
if (lastSlashBeforeStar === -1)
|
|
119
|
+
return [];
|
|
120
|
+
const parentPart = norm.slice(0, lastSlashBeforeStar);
|
|
75
121
|
let basePath;
|
|
76
122
|
if (parentPart.startsWith('~/')) {
|
|
77
123
|
basePath = join(home, parentPart.slice(2));
|
|
@@ -83,20 +129,65 @@ function expandGlobPathPattern(pathPattern, fileType, home, projectRoot, content
|
|
|
83
129
|
basePath = join(projectRoot, parentPart.startsWith('/') ? parentPart.slice(1) : parentPart);
|
|
84
130
|
}
|
|
85
131
|
if (!existsSync(basePath))
|
|
86
|
-
return
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
return [];
|
|
133
|
+
// Walk remaining segments. Each segment may be a bare wildcard '*' (matches
|
|
134
|
+
// any single directory), a prefix/suffix wildcard like 'foo*' or 'foo*bar',
|
|
135
|
+
// or a literal name. Recursion correctly handles multi-wildcard patterns
|
|
136
|
+
// such as ``*/*/cowork_plugins/marketplaces/*/.claude-plugin/marketplace.json``
|
|
137
|
+
// — the prior single-split implementation only expanded the first wildcard
|
|
138
|
+
// and silently pushed a partial directory path as the target, which then
|
|
139
|
+
// surfaced as EISDIR when downstream collectors tried to read it as a file.
|
|
140
|
+
const remaining = norm.slice(lastSlashBeforeStar + 1);
|
|
141
|
+
const segments = remaining.split('/').filter((s) => s !== '');
|
|
142
|
+
const targets = [];
|
|
143
|
+
function recurse(currentPath, segIdx) {
|
|
144
|
+
if (segIdx === segments.length) {
|
|
145
|
+
if (!existsSync(currentPath))
|
|
146
|
+
return;
|
|
147
|
+
targets.push({ path: currentPath, file_type: fileType, isDirectory: isDir, dir_glob: dirGlob, content_format: contentFormat });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const seg = segments[segIdx];
|
|
151
|
+
// On the final segment of a file pattern (not ending in `/`) wildcards
|
|
152
|
+
// match files; earlier segments — and dir patterns — match directories.
|
|
153
|
+
const wantFile = segIdx === segments.length - 1 && !isDir;
|
|
154
|
+
const entryMatches = (entry) => wantFile ? entry.isFile() : entry.isDirectory();
|
|
155
|
+
if (seg === '*') {
|
|
156
|
+
if (!existsSync(currentPath))
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
|
|
160
|
+
if (!entryMatches(entry))
|
|
161
|
+
continue;
|
|
162
|
+
recurse(join(currentPath, entry.name), segIdx + 1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch { /* ignore read errors */ }
|
|
166
|
+
}
|
|
167
|
+
else if (seg.includes('*')) {
|
|
168
|
+
const wildcardIdx = seg.indexOf('*');
|
|
169
|
+
const prefix = seg.slice(0, wildcardIdx);
|
|
170
|
+
const suffix = seg.slice(wildcardIdx + 1);
|
|
171
|
+
if (!existsSync(currentPath))
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
|
|
175
|
+
if (!entryMatches(entry))
|
|
176
|
+
continue;
|
|
177
|
+
if (prefix && !entry.name.startsWith(prefix))
|
|
178
|
+
continue;
|
|
179
|
+
if (suffix && !entry.name.endsWith(suffix))
|
|
180
|
+
continue;
|
|
181
|
+
recurse(join(currentPath, entry.name), segIdx + 1);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* ignore read errors */ }
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
recurse(join(currentPath, seg), segIdx + 1);
|
|
97
188
|
}
|
|
98
189
|
}
|
|
99
|
-
|
|
190
|
+
recurse(basePath, 0);
|
|
100
191
|
return targets;
|
|
101
192
|
}
|
|
102
193
|
/**
|
|
@@ -1,10 +1,120 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
function stripJsoncComments(input) {
|
|
3
|
+
let out = '';
|
|
4
|
+
let inString = false;
|
|
5
|
+
let quote = '';
|
|
6
|
+
let escaped = false;
|
|
7
|
+
let inLineComment = false;
|
|
8
|
+
let inBlockComment = false;
|
|
9
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
10
|
+
const ch = input[i];
|
|
11
|
+
const next = i + 1 < input.length ? input[i + 1] : '';
|
|
12
|
+
if (inLineComment) {
|
|
13
|
+
if (ch === '\n') {
|
|
14
|
+
inLineComment = false;
|
|
15
|
+
out += ch;
|
|
16
|
+
}
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (inBlockComment) {
|
|
20
|
+
if (ch === '*' && next === '/') {
|
|
21
|
+
inBlockComment = false;
|
|
22
|
+
i += 1;
|
|
23
|
+
}
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (inString) {
|
|
27
|
+
out += ch;
|
|
28
|
+
if (escaped) {
|
|
29
|
+
escaped = false;
|
|
30
|
+
}
|
|
31
|
+
else if (ch === '\\') {
|
|
32
|
+
escaped = true;
|
|
33
|
+
}
|
|
34
|
+
else if (ch === quote) {
|
|
35
|
+
inString = false;
|
|
36
|
+
quote = '';
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (ch === '"' || ch === "'") {
|
|
41
|
+
inString = true;
|
|
42
|
+
quote = ch;
|
|
43
|
+
out += ch;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '/' && next === '/') {
|
|
47
|
+
inLineComment = true;
|
|
48
|
+
i += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === '/' && next === '*') {
|
|
52
|
+
inBlockComment = true;
|
|
53
|
+
i += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out += ch;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
function stripTrailingCommas(input) {
|
|
61
|
+
let out = '';
|
|
62
|
+
let inString = false;
|
|
63
|
+
let quote = '';
|
|
64
|
+
let escaped = false;
|
|
65
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
66
|
+
const ch = input[i];
|
|
67
|
+
if (inString) {
|
|
68
|
+
out += ch;
|
|
69
|
+
if (escaped) {
|
|
70
|
+
escaped = false;
|
|
71
|
+
}
|
|
72
|
+
else if (ch === '\\') {
|
|
73
|
+
escaped = true;
|
|
74
|
+
}
|
|
75
|
+
else if (ch === quote) {
|
|
76
|
+
inString = false;
|
|
77
|
+
quote = '';
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (ch === '"' || ch === "'") {
|
|
82
|
+
inString = true;
|
|
83
|
+
quote = ch;
|
|
84
|
+
out += ch;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === ',') {
|
|
88
|
+
let j = i + 1;
|
|
89
|
+
while (j < input.length && /\s/.test(input[j]))
|
|
90
|
+
j += 1;
|
|
91
|
+
if (j < input.length && (input[j] === '}' || input[j] === ']'))
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
out += ch;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function parseJsonWithJsoncFallback(raw) {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
try {
|
|
104
|
+
const sanitized = stripTrailingCommas(stripJsoncComments(raw));
|
|
105
|
+
return JSON.parse(sanitized);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
2
112
|
/** Read and parse an MCP config file. Returns null on any error. */
|
|
3
113
|
function readMCPConfig(filePath) {
|
|
4
114
|
try {
|
|
5
115
|
if (!existsSync(filePath))
|
|
6
116
|
return null;
|
|
7
|
-
return
|
|
117
|
+
return parseJsonWithJsoncFallback(readFileSync(filePath, 'utf-8'));
|
|
8
118
|
}
|
|
9
119
|
catch (error) {
|
|
10
120
|
console.error(`Error reading ${filePath}:`, error instanceof Error ? error.message : String(error));
|
|
@@ -16,7 +126,7 @@ function readJSONFile(filePath) {
|
|
|
16
126
|
try {
|
|
17
127
|
if (!existsSync(filePath))
|
|
18
128
|
return null;
|
|
19
|
-
return
|
|
129
|
+
return parseJsonWithJsoncFallback(readFileSync(filePath, 'utf-8'));
|
|
20
130
|
}
|
|
21
131
|
catch (error) {
|
|
22
132
|
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
@@ -41,6 +41,10 @@ export function normalizeAgentToken(raw) {
|
|
|
41
41
|
return 'cursor';
|
|
42
42
|
if (s === 'copilot')
|
|
43
43
|
return 'copilot';
|
|
44
|
+
if (s === 'opencode')
|
|
45
|
+
return 'opencode';
|
|
46
|
+
if (s === 'codex')
|
|
47
|
+
return 'codex';
|
|
44
48
|
return '';
|
|
45
49
|
}
|
|
46
50
|
function currentAgentFromEnv() {
|
|
@@ -48,12 +52,16 @@ function currentAgentFromEnv() {
|
|
|
48
52
|
const override = normalizeAgentToken(process.env.OPTIMUS_AGENT);
|
|
49
53
|
if (override)
|
|
50
54
|
return override;
|
|
51
|
-
// Backwards-compatible: hook wrappers set OPTIMUS_HOOK_TYPE to cursor|claude|copilot.
|
|
55
|
+
// Backwards-compatible: hook wrappers set OPTIMUS_HOOK_TYPE to cursor|claude|copilot|opencode.
|
|
52
56
|
const hookType = normalizeAgentToken(process.env.OPTIMUS_HOOK_TYPE);
|
|
53
57
|
if (hookType === 'cursor')
|
|
54
58
|
return 'cursor';
|
|
55
59
|
if (hookType === 'copilot')
|
|
56
60
|
return 'copilot';
|
|
61
|
+
if (hookType === 'opencode')
|
|
62
|
+
return 'opencode';
|
|
63
|
+
if (hookType === 'codex')
|
|
64
|
+
return 'codex';
|
|
57
65
|
return 'claude';
|
|
58
66
|
}
|
|
59
67
|
function targetsCurrentAgent(entry, agent) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function normalizeToken(raw) {
|
|
2
|
+
if (!raw?.trim())
|
|
3
|
+
return '';
|
|
4
|
+
const s = raw.trim().toLowerCase().replace(/-/g, '_');
|
|
5
|
+
if (s === 'claude_desktop')
|
|
6
|
+
return 'claude';
|
|
7
|
+
if (s === 'github_copilot')
|
|
8
|
+
return 'copilot';
|
|
9
|
+
if (s === 'cursor' || s === 'claude' || s === 'copilot' || s === 'opencode' || s === 'codex')
|
|
10
|
+
return s;
|
|
11
|
+
// Legacy hooks set OPTIMUS_AGENT=Cursor (display casing)
|
|
12
|
+
if (raw.trim() === 'Cursor')
|
|
13
|
+
return 'cursor';
|
|
14
|
+
return s;
|
|
15
|
+
}
|
|
16
|
+
/** Resolve hook_request.hook_type from hook wrapper env (OPTIMUS_HOOK_TYPE, then OPTIMUS_AGENT). */
|
|
17
|
+
export function resolveHookTypeFromEnv(env = process.env) {
|
|
18
|
+
const fromHook = normalizeToken(env.OPTIMUS_HOOK_TYPE);
|
|
19
|
+
const fromAgent = normalizeToken(env.OPTIMUS_AGENT);
|
|
20
|
+
const token = fromHook || fromAgent || 'claude';
|
|
21
|
+
if (token === 'cursor')
|
|
22
|
+
return 'cursor';
|
|
23
|
+
if (token === 'copilot')
|
|
24
|
+
return 'copilot';
|
|
25
|
+
if (token === 'opencode')
|
|
26
|
+
return 'opencode';
|
|
27
|
+
if (token === 'codex')
|
|
28
|
+
return 'codex';
|
|
29
|
+
return 'claude';
|
|
30
|
+
}
|
|
@@ -7,12 +7,14 @@ import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
|
|
|
7
7
|
import { runSensitivePathsAudit } from '../../log_sensitive_paths_audit.js';
|
|
8
8
|
import { loadEndpointBase, getEndpointSource } from '../sender/endpoint_config.js';
|
|
9
9
|
import { hookLogReplace, hookRunLog } from './hook_logger.js';
|
|
10
|
+
import { resolveHookTypeFromEnv } from './hook_type_for_request.js';
|
|
10
11
|
import { resolveHardwareUuid } from './hardware_uuid.js';
|
|
11
12
|
import { ensureAuthentication } from '../auth/auth_flow.js';
|
|
12
13
|
import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
|
|
13
14
|
import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
|
|
14
15
|
import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
|
|
15
|
-
import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeDesktopExtensionManifests, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
|
|
16
|
+
import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
|
|
17
|
+
import { collectSkillsCliInstalled } from '../collection/skills_cli_collector.js';
|
|
16
18
|
import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
|
|
17
19
|
import { collectCursorProjectWorkspaceMcpConfigs } from '../collection/cursor_project_mcp_collector.js';
|
|
18
20
|
import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
|
|
@@ -97,6 +99,13 @@ async function collectAllConfigFiles(endpointBase) {
|
|
|
97
99
|
hookRunLog(`merging disk-only Claude Desktop extensions into extensions-installations.json`);
|
|
98
100
|
const diskExtMerge = enrichClaudeDesktopExtensionsInstallationsUpload(configFiles, HOME_DIR);
|
|
99
101
|
hookRunLog(`claude desktop extensions: merged=${diskExtMerge.mergedCount} had_registry_upload=${diskExtMerge.hadRegistryUpload}`);
|
|
102
|
+
for (const entry of collectClaudeDesktopExtensionSettingsFiles(HOME_DIR)) {
|
|
103
|
+
const key = `${entry.file_type}\t${entry.file_path}`;
|
|
104
|
+
if (!existingPaths.has(key)) {
|
|
105
|
+
existingPaths.add(key);
|
|
106
|
+
configFiles.push(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
100
109
|
if (!diskExtMerge.hadRegistryUpload && diskExtMerge.mergedCount > 0) {
|
|
101
110
|
hookRunLog(`scanning Claude Desktop extension manifests on disk (no registry upload in batch)`);
|
|
102
111
|
for (const entry of collectClaudeDesktopExtensionManifests(HOME_DIR)) {
|
|
@@ -107,6 +116,14 @@ async function collectAllConfigFiles(endpointBase) {
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
}
|
|
119
|
+
const skillsCliEntry = collectSkillsCliInstalled(PROJECT_ROOT, hookRunLog);
|
|
120
|
+
if (skillsCliEntry) {
|
|
121
|
+
const key = `${skillsCliEntry.file_type}\t${skillsCliEntry.file_path}`;
|
|
122
|
+
if (!existingPaths.has(key)) {
|
|
123
|
+
existingPaths.add(key);
|
|
124
|
+
configFiles.push(skillsCliEntry);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
110
127
|
const worktreeReport = scanActiveWorktrees(PROJECT_ROOT, HOME_DIR);
|
|
111
128
|
const activeRoots = activeWorktreeRootSet(worktreeReport);
|
|
112
129
|
const beforeFilter = configFiles.length;
|
|
@@ -128,8 +145,7 @@ async function addSensitivePathsAudit(endpointBase, configFiles) {
|
|
|
128
145
|
}
|
|
129
146
|
}
|
|
130
147
|
async function sendAllConfigFiles(configFiles, worktreeReport, hardwareUuid, authKey) {
|
|
131
|
-
const
|
|
132
|
-
const hookType = hookTypeRaw === 'cursor' ? 'cursor' : hookTypeRaw === 'copilot' ? 'copilot' : 'claude';
|
|
148
|
+
const hookType = resolveHookTypeFromEnv();
|
|
133
149
|
const manifest = configFiles.map((c) => canonicalCursorUserStateVscdbPath(c.file_path));
|
|
134
150
|
const workspaceRepo = ensureWorkspaceRepoEnv(manifest);
|
|
135
151
|
const hookRequestId = await sendHookRequestCreate(hardwareUuid, authKey, hookType, workspaceRepo);
|
|
@@ -34,6 +34,10 @@ function currentAgentFromEnv() {
|
|
|
34
34
|
return 'cursor';
|
|
35
35
|
if (override === 'copilot')
|
|
36
36
|
return 'copilot';
|
|
37
|
+
if (override === 'opencode')
|
|
38
|
+
return 'opencode';
|
|
39
|
+
if (override === 'codex')
|
|
40
|
+
return 'codex';
|
|
37
41
|
if (override === 'claude' || override === 'claude_desktop')
|
|
38
42
|
return 'claude';
|
|
39
43
|
const hookType = normalizeAgentToken(process.env.OPTIMUS_HOOK_TYPE);
|
|
@@ -41,6 +45,10 @@ function currentAgentFromEnv() {
|
|
|
41
45
|
return 'cursor';
|
|
42
46
|
if (hookType === 'copilot')
|
|
43
47
|
return 'copilot';
|
|
48
|
+
if (hookType === 'opencode')
|
|
49
|
+
return 'opencode';
|
|
50
|
+
if (hookType === 'codex')
|
|
51
|
+
return 'codex';
|
|
44
52
|
return 'claude';
|
|
45
53
|
}
|
|
46
54
|
/** Spawn each trusted command detached (same pattern as former compliance_prompt_gate fireRestartCommands). */
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Path templates are fetched from the backend (GET api/file-path-registry/sensitive-paths-audit-candidates/).
|
|
7
7
|
* The audit is sent with the rest of the config files in log_config_files (same auth, same batch).
|
|
8
8
|
*/
|
|
9
|
-
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { existsSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { getSensitivePathsAuditCandidates } from './endpoint_client/index.js';
|
|
@@ -22,16 +22,35 @@ function expandPath(template, cwd) {
|
|
|
22
22
|
}
|
|
23
23
|
return join(cwd, template);
|
|
24
24
|
}
|
|
25
|
+
/** True when path exists: directories always qualify; files must be non-empty (size > 0). */
|
|
26
|
+
function shouldIncludeSensitivePath(resolved) {
|
|
27
|
+
if (!existsSync(resolved)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const st = statSync(resolved);
|
|
32
|
+
if (st.isDirectory()) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (st.isFile()) {
|
|
36
|
+
return st.size > 0;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
25
44
|
/**
|
|
26
45
|
* Write sensitive_paths_audit.txt under outputDir. pathTemplates from backend (~ = home).
|
|
27
|
-
* Lists one path per line for paths that exist. No file contents are read.
|
|
46
|
+
* Lists one path per line for paths that exist (files must be non-empty). No file contents are read.
|
|
28
47
|
* Overwrites the file on each run (same as hook_log.txt); does not append.
|
|
29
48
|
*/
|
|
30
49
|
export function writeSensitivePathsAudit(outputDir, pathTemplates, cwd = process.cwd()) {
|
|
31
50
|
const existing = [];
|
|
32
51
|
for (const template of pathTemplates) {
|
|
33
52
|
const resolved = expandPath(template, cwd);
|
|
34
|
-
if (
|
|
53
|
+
if (shouldIncludeSensitivePath(resolved)) {
|
|
35
54
|
existing.push(resolved);
|
|
36
55
|
}
|
|
37
56
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "log-llm-config",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -49,11 +49,11 @@
|
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^24.10.1",
|
|
52
|
-
"@vitest/coverage-v8": "^
|
|
53
|
-
"@vitest/ui": "^
|
|
52
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
53
|
+
"@vitest/ui": "^4.1.8",
|
|
54
54
|
"ts-node": "^10.9.2",
|
|
55
55
|
"typescript": "^5.4.5",
|
|
56
|
-
"vitest": "^
|
|
56
|
+
"vitest": "^4.1.8"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"axios": "^1.15.2",
|