log-llm-config 1.3.97 → 1.4.8

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.
@@ -1,9 +1,38 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * Runs after Cursor SIGKILL: applies queued state.vscdb patches from
4
- * ~/opt-ai-sec/management/optimus_deferred_vscdb_apply.json (see remediation_sync).
5
- */
2
+ // Runs after Cursor SIGKILL: applies queued state.vscdb patches, then immediately runs
3
+ // post-restart verification so the server gets `verified` without waiting for the next prompt.
6
4
  import { applyDeferredVscdbFromDisk } from './log_config_files/runtime/remediation_sync.js';
5
+ import { runPostApplyVerification } from './log_config_files/runtime/compliance_check.js';
6
+ import { hookRunLog } from './log_config_files/runtime/hook_logger.js';
7
+ import { finalizeAndUploadComplianceSessionLog, setActiveComplianceLogPath, } from './log_config_files/runtime/compliance_session_log.js';
8
+ const complianceLogPath = process.env.OPTIMUS_COMPLIANCE_LOG?.trim() || null;
9
+ if (complianceLogPath) {
10
+ setActiveComplianceLogPath(complianceLogPath);
11
+ }
7
12
  applyDeferredVscdbFromDisk()
8
- .then((ok) => process.exit(ok ? 0 : 1))
13
+ .then(async (ok) => {
14
+ if (ok) {
15
+ try {
16
+ const outcomes = await runPostApplyVerification();
17
+ if (outcomes.length > 0) {
18
+ hookRunLog(`apply_deferred_vscdb: post_apply_verification count=${outcomes.length} ` +
19
+ `verified=${outcomes.filter((o) => o.status === 'verified').length}`);
20
+ }
21
+ if (complianceLogPath) {
22
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath);
23
+ }
24
+ }
25
+ catch (e) {
26
+ hookRunLog(`apply_deferred_vscdb: post_apply_verification error: ${e instanceof Error ? e.message : String(e)}`);
27
+ if (complianceLogPath) {
28
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'error');
29
+ }
30
+ }
31
+ }
32
+ else if (complianceLogPath) {
33
+ hookRunLog('apply_deferred_vscdb: apply failed');
34
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'error');
35
+ }
36
+ process.exit(ok ? 0 : 1);
37
+ })
9
38
  .catch(() => process.exit(1));
@@ -1,8 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { complianceRunnerRunnerLine, hookLogAppendSection } from './log_config_files/runtime/hook_logger.js';
3
3
  import { runComplianceCheck } from './log_config_files/runtime/compliance_check.js';
4
+ import { finalizeAndUploadComplianceSessionLog, initComplianceSessionForRunner, isFinalizeComplianceSessionArgv, isInflightComplianceLogPath, parseComplianceLogArg, resolveComplianceSessionLogPath, } from './log_config_files/runtime/compliance_session_log.js';
5
+ import { existsSync } from 'node:fs';
4
6
  (async () => {
5
- hookLogAppendSection('compliance_check_runner (background sync + check)');
7
+ let complianceLogPath = parseComplianceLogArg(process.argv);
8
+ if (isFinalizeComplianceSessionArgv(process.argv)) {
9
+ if (complianceLogPath) {
10
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath);
11
+ }
12
+ process.exit(0);
13
+ }
14
+ if (complianceLogPath) {
15
+ const resolved = resolveComplianceSessionLogPath(complianceLogPath);
16
+ if (existsSync(resolved) && isInflightComplianceLogPath(resolved)) {
17
+ complianceLogPath = initComplianceSessionForRunner(resolved);
18
+ }
19
+ else {
20
+ // Gate may have finalized+moved the session before this background runner started.
21
+ complianceLogPath = null;
22
+ }
23
+ }
24
+ else {
25
+ hookLogAppendSection('compliance_check_runner (background sync + check)');
26
+ }
6
27
  complianceRunnerRunnerLine('compliance_check_runner: start');
7
28
  try {
8
29
  await runComplianceCheck();
@@ -13,6 +34,12 @@ import { runComplianceCheck } from './log_config_files/runtime/compliance_check.
13
34
  complianceRunnerRunnerLine(`compliance_check_runner: uncaught error — ${err instanceof Error ? err.message : String(err)}`);
14
35
  complianceRunnerRunnerLine(`compliance_check_runner: stack_or_detail ${detail.slice(0, 4000)}`);
15
36
  process.stderr.write(`compliance_check_runner error: ${err instanceof Error ? err.message : String(err)}\n`);
37
+ if (complianceLogPath) {
38
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'error');
39
+ }
16
40
  process.exit(1);
17
41
  }
42
+ if (complianceLogPath) {
43
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath);
44
+ }
18
45
  })();
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Synchronous pre-prompt gate: local compliance only (stdout = single JSON line for IDE hooks).
4
- * stderr must stay clean; logs go via hookRunLog (file).
4
+ * stderr must stay clean; logs go via hookRunLog (compliance session file when --compliance-log is set).
5
5
  *
6
6
  * When autofix returns restart_commands, this process does not spawn them — the shell hook pipes
7
7
  * the same JSON line to execute_trusted_restarts (TS allowlist + spawn).
@@ -11,6 +11,7 @@ import { isRemediationQuarantined } from './log_config_files/runtime/remediation
11
11
  import { existsSync, readFileSync, statSync } from 'node:fs';
12
12
  import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
13
13
  import { hookLogSessionBanner, hookRunLog, logRemediationApplyFailure } from './log_config_files/runtime/hook_logger.js';
14
+ import { finalizeAndUploadComplianceSessionLog, initComplianceSessionForGate, parseComplianceLogArg, } from './log_config_files/runtime/compliance_session_log.js';
14
15
  import { isThisCliModule } from './cli_invocation_match.js';
15
16
  import { ensureAuthentication } from './log_config_files/auth/auth_flow.js';
16
17
  import { sendConfigFile } from './log_config_files/sender/batch_sender.js';
@@ -19,6 +20,17 @@ import { syncRemediations } from './log_config_files/runtime/remediation_sync.js
19
20
  import { loadEndpointBase } from './log_config_files/sender/endpoint_config.js';
20
21
  import { formatRemediationChangePreviewForApplied } from './remediation_change_preview.js';
21
22
  const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
23
+ /** After verified (or failed verify), push enforcement + session log immediately — do not wait for background runner. */
24
+ async function flushRemediationOutcomeToServer(complianceLogPath) {
25
+ if (!complianceLogPath)
26
+ return;
27
+ try {
28
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath);
29
+ }
30
+ catch (err) {
31
+ hookRunLog(`compliance_prompt_gate: session finalize/upload failed: ${err instanceof Error ? err.message : String(err)}`);
32
+ }
33
+ }
22
34
  function parseIde() {
23
35
  const eq = process.argv.find((a) => a.startsWith('--ide='));
24
36
  if (eq) {
@@ -164,10 +176,23 @@ export async function runCompliancePromptGateCli() {
164
176
  export async function runCompliancePromptGate() {
165
177
  const ide = parseIde();
166
178
  const agent = parseAgent(ide);
167
- hookLogSessionBanner('compliance_prompt_gate (before submit)');
168
- // Sync before checking so server-side changes (enforce/unenforce) propagate on every prompt
169
- // without waiting for the background runner. Race against a 3 s timeout so a slow or
170
- // unavailable server never delays the gate significantly.
179
+ let complianceLogPath = parseComplianceLogArg(process.argv);
180
+ if (complianceLogPath) {
181
+ complianceLogPath = initComplianceSessionForGate(complianceLogPath);
182
+ }
183
+ else {
184
+ hookLogSessionBanner('compliance_prompt_gate (before submit)');
185
+ }
186
+ let status = runLocalRemediationComplianceCheck(agent);
187
+ // Post-restart verify + server reports BEFORE sync so a manifest UUID swap cannot delay
188
+ // verified / activity-log until the following prompt.
189
+ const postRestartVerify = reportPostRestartVerificationOutcomes(status.violations);
190
+ if (postRestartVerify.outcomes.length > 0) {
191
+ await Promise.allSettled(postRestartVerify.reportPromises);
192
+ await flushRemediationOutcomeToServer(complianceLogPath);
193
+ hookRunLog(`compliance_prompt_gate: post_restart_verification count=${postRestartVerify.outcomes.length} quarantined=${postRestartVerify.outcomes.filter((o) => o.status === 'quarantined').length}`);
194
+ status = runLocalRemediationComplianceCheck(agent);
195
+ }
171
196
  const hw = tryResolveHardwareUuid();
172
197
  if (hw) {
173
198
  try {
@@ -180,13 +205,7 @@ export async function runCompliancePromptGate() {
180
205
  // Network or auth failure — fall back to local file state.
181
206
  }
182
207
  }
183
- const status = runLocalRemediationComplianceCheck(agent);
184
- // Always process pending post-restart rows (even when compliance is ok — apply may have stuck).
185
- const postRestartVerify = reportPostRestartVerificationOutcomes(status.violations);
186
- if (postRestartVerify.outcomes.length > 0) {
187
- await Promise.allSettled(postRestartVerify.reportPromises);
188
- hookRunLog(`compliance_prompt_gate: post_restart_verification count=${postRestartVerify.outcomes.length} quarantined=${postRestartVerify.outcomes.filter((o) => o.status === 'quarantined').length}`);
189
- }
208
+ status = runLocalRemediationComplianceCheck(agent);
190
209
  // Secondary-satisfied: primary checks failed but settings.json (or equiv) has the fix.
191
210
  // Upload those files fire-and-forget so the backend can resolve the finding immediately.
192
211
  for (const e of status.secondarySatisfied ?? []) {
@@ -255,6 +274,7 @@ export async function runCompliancePromptGate() {
255
274
  if (immediateVerified) {
256
275
  confirmAppliedAutofixVerified(appliedViolations, reportPromises);
257
276
  await Promise.allSettled(reportPromises);
277
+ await flushRemediationOutcomeToServer(complianceLogPath);
258
278
  }
259
279
  const changePreview = formatRemediationChangePreviewForApplied(appliedViolations);
260
280
  const changePreviewSuffix = changePreview ? `\n\n${changePreview}` : '';
@@ -7,9 +7,14 @@
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { isThisCliModule } from './cli_invocation_match.js';
9
9
  import { appendComplianceRunnerLine, hookRunLog } from './log_config_files/runtime/hook_logger.js';
10
+ import { setActiveComplianceLogPath } from './log_config_files/runtime/compliance_session_log.js';
10
11
  import { executeTrustedRestartCommands } from './log_config_files/runtime/trusted_restarts.js';
11
12
  /** Invoked by dist entrypoint or by `npx log-llm-config@latest execute-trusted-restarts` (cli.js dispatches here). */
12
13
  export function runExecuteTrustedRestartsFromStdin() {
14
+ const complianceLogPath = process.env.OPTIMUS_COMPLIANCE_LOG?.trim();
15
+ if (complianceLogPath) {
16
+ setActiveComplianceLogPath(complianceLogPath);
17
+ }
13
18
  let raw;
14
19
  try {
15
20
  raw = readFileSync(0, 'utf8');
@@ -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,13 +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';
10
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
+ }
11
52
  function buildCollectionContext(patterns, projectRoot, home, homeRecurseSkipDirs = [], absolutePathPrefixes = [], mcpToolGlobSpec = null, clientPathConstants = null, pathResolutionSpecs = null) {
12
53
  const seenPaths = new Set();
13
54
  const targets = [];
@@ -69,11 +110,28 @@ function collectRegularFileEntry(t, enrichByFileType) {
69
110
  const recipe = enrichByFileType[t.file_type];
70
111
  if (recipe)
71
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
+ }
72
123
  return { file_type: t.file_type, file_path: t.logicalFilePath ?? t.path, raw_content: raw };
73
124
  }
74
125
  function collectMetadataEntry(t) {
75
126
  try {
76
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
+ }
77
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' };
78
136
  }
79
137
  catch (err) {
@@ -99,7 +157,8 @@ function collectConfigFilesFromPatterns(patterns, projectRoot, onProgress, optio
99
157
  const vscdbPath = getVscdbPath(home, pathConstants);
100
158
  const extensionsCachePath = getExtensionsCachePath(home, pathConstants);
101
159
  const extensionsInstalledSuffix = getExtensionsCacheInstalledSuffix(pathConstants);
102
- const { targets, enrichByFileType, metadataOnlyFileTypes } = buildCollectionContext(patterns, projectRoot, home, options?.home_recurse_skip_dirs ?? [], options?.absolute_path_prefixes ?? [], options?.mcp_tool_glob_spec ?? null, pathConstants, options?.path_resolution_specs ?? null);
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);
103
162
  const configFiles = [];
104
163
  const handledSpecialPaths = new Set();
105
164
  const loggedFileTypes = new Set();
@@ -156,7 +215,7 @@ export { collectConfigFilesFromPatterns };
156
215
  export { collectMcpToolFiles } from './mcp_tool_collector.js';
157
216
  export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles } from './plugin_collector.js';
158
217
  export { collectMcpFromClaudeJsonProjects } from './claude_known_projects_collector.js';
159
- export { collectClaudeDesktopExtensionManifests, enrichClaudeDesktopExtensionsInstallationsUpload, } from './claude_desktop_extensions_collector.js';
218
+ export { collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, } from './claude_desktop_extensions_collector.js';
160
219
  export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
161
220
  export { determineFileTypeFromPath } from './file_type_rules.js';
162
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
- * normalized mcpServers for inventory plus cursor_workspace metadata for scope display.
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
- if (existsSync(cachePath)) {
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 (!(label in mcpServers)) {
162
- mcpServers[label] = {
163
- cursor_source: 'mcps',
164
- server_identifier: serverDir.name,
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 {