log-llm-config 1.4.8 → 1.4.10
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/compliance_check_runner.js +9 -2
- package/dist/compliance_prompt_gate.js +33 -11
- package/dist/log_config_files/collection/config_collector.js +1 -1
- package/dist/log_config_files/paths/pattern_resolver.js +8 -0
- package/dist/log_config_files/readers/file_readers.js +1 -1
- package/dist/log_config_files/runtime/compliance_check.js +63 -19
- package/dist/log_config_files/runtime/hook_logger.js +18 -9
- package/dist/log_config_files/runtime/remediation_sync.js +10 -3
- package/dist/log_config_files/runtime/secret_regex_scan.js +126 -0
- package/dist/log_config_files/runtime/secretlint_scan.js +69 -0
- package/dist/log_config_files/runtime/trusted_restarts.js +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { complianceRunnerRunnerLine, hookLogAppendSection } from './log_config_files/runtime/hook_logger.js';
|
|
3
|
-
import { runComplianceCheck } from './log_config_files/runtime/compliance_check.js';
|
|
3
|
+
import { normalizeAgentToken, runComplianceCheck, } from './log_config_files/runtime/compliance_check.js';
|
|
4
4
|
import { finalizeAndUploadComplianceSessionLog, initComplianceSessionForRunner, isFinalizeComplianceSessionArgv, isInflightComplianceLogPath, parseComplianceLogArg, resolveComplianceSessionLogPath, } from './log_config_files/runtime/compliance_session_log.js';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
|
+
function parseAgentArg() {
|
|
7
|
+
const eq = process.argv.find((a) => a.startsWith('--agent='));
|
|
8
|
+
if (!eq)
|
|
9
|
+
return undefined;
|
|
10
|
+
const normalized = normalizeAgentToken(eq.slice('--agent='.length));
|
|
11
|
+
return normalized ? normalized : undefined;
|
|
12
|
+
}
|
|
6
13
|
(async () => {
|
|
7
14
|
let complianceLogPath = parseComplianceLogArg(process.argv);
|
|
8
15
|
if (isFinalizeComplianceSessionArgv(process.argv)) {
|
|
@@ -26,7 +33,7 @@ import { existsSync } from 'node:fs';
|
|
|
26
33
|
}
|
|
27
34
|
complianceRunnerRunnerLine('compliance_check_runner: start');
|
|
28
35
|
try {
|
|
29
|
-
await runComplianceCheck();
|
|
36
|
+
await runComplianceCheck(parseAgentArg());
|
|
30
37
|
complianceRunnerRunnerLine('compliance_check_runner: finished ok');
|
|
31
38
|
}
|
|
32
39
|
catch (err) {
|
|
@@ -35,13 +35,23 @@ function parseIde() {
|
|
|
35
35
|
const eq = process.argv.find((a) => a.startsWith('--ide='));
|
|
36
36
|
if (eq) {
|
|
37
37
|
const v = eq.slice('--ide='.length).toLowerCase();
|
|
38
|
-
|
|
38
|
+
if (v === 'claude')
|
|
39
|
+
return 'claude';
|
|
40
|
+
if (v === 'copilot')
|
|
41
|
+
return 'copilot';
|
|
42
|
+
if (v === 'opencode')
|
|
43
|
+
return 'opencode';
|
|
44
|
+
return 'cursor';
|
|
39
45
|
}
|
|
40
46
|
if (process.argv.includes('--claude'))
|
|
41
47
|
return 'claude';
|
|
42
48
|
return 'cursor';
|
|
43
49
|
}
|
|
44
50
|
function defaultAgentFromIde(ide) {
|
|
51
|
+
if (ide === 'copilot')
|
|
52
|
+
return 'copilot';
|
|
53
|
+
if (ide === 'opencode')
|
|
54
|
+
return 'opencode';
|
|
45
55
|
return ide === 'claude' ? 'claude' : 'cursor';
|
|
46
56
|
}
|
|
47
57
|
function parseAgent(ide) {
|
|
@@ -54,13 +64,13 @@ function parseAgent(ide) {
|
|
|
54
64
|
return defaultAgentFromIde(ide);
|
|
55
65
|
}
|
|
56
66
|
function printAllow(ide) {
|
|
57
|
-
if (ide === 'claude')
|
|
67
|
+
if (ide === 'claude' || ide === 'copilot' || ide === 'opencode')
|
|
58
68
|
console.log('{}');
|
|
59
69
|
else
|
|
60
70
|
console.log(JSON.stringify({ continue: true }));
|
|
61
71
|
}
|
|
62
72
|
function printAllowWithAdvisory(ide, advisoryMessage) {
|
|
63
|
-
if (ide === 'claude') {
|
|
73
|
+
if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
|
|
64
74
|
console.log(JSON.stringify({ __optimus_advisory: true, advisory_message: advisoryMessage }));
|
|
65
75
|
}
|
|
66
76
|
else {
|
|
@@ -74,7 +84,7 @@ function printAllowWithAdvisory(ide, advisoryMessage) {
|
|
|
74
84
|
function blockPayload(ide, violationMessage) {
|
|
75
85
|
const prefix = 'Prompt blocked by Optimus: ';
|
|
76
86
|
const text = prefix + violationMessage;
|
|
77
|
-
if (ide === 'claude') {
|
|
87
|
+
if (ide === 'claude' || ide === 'copilot' || ide === 'opencode') {
|
|
78
88
|
return JSON.stringify({ decision: 'block', reason: text, systemMessage: text });
|
|
79
89
|
}
|
|
80
90
|
return JSON.stringify({ continue: false, user_message: text });
|
|
@@ -128,6 +138,14 @@ function formatPreventiveAutofixDialog(appliedViolations, applyLine) {
|
|
|
128
138
|
export function formatClaudeAutofixDialog(appliedViolations) {
|
|
129
139
|
return formatPreventiveAutofixDialog(appliedViolations, 'Claude will now apply this policy to your environment.');
|
|
130
140
|
}
|
|
141
|
+
/** Copilot dialog after enforced/preventive remediation is applied locally (no restart). */
|
|
142
|
+
export function formatCopilotAutofixDialog(appliedViolations) {
|
|
143
|
+
return formatPreventiveAutofixDialog(appliedViolations, 'Copilot will now apply this policy to your environment.');
|
|
144
|
+
}
|
|
145
|
+
/** OpenCode dialog after enforced/preventive remediation is applied locally (terminal, no restart). */
|
|
146
|
+
export function formatOpenCodeAutofixDialog(appliedViolations) {
|
|
147
|
+
return formatPreventiveAutofixDialog(appliedViolations, 'OpenCode will now apply this policy to your environment.');
|
|
148
|
+
}
|
|
131
149
|
/** Cursor restart dialog after enforced/preventive remediation is applied locally. */
|
|
132
150
|
export function formatCursorRestartAutofixDialog(appliedViolations) {
|
|
133
151
|
return formatPreventiveAutofixDialog(appliedViolations, 'Cursor will now restart to apply this policy, and your context will be retained.');
|
|
@@ -257,15 +275,15 @@ export async function runCompliancePromptGate() {
|
|
|
257
275
|
const recheck = runLocalRemediationComplianceCheck(agent);
|
|
258
276
|
const recheckOk = recheck.status === 'ok' || recheck.violations.length === 0;
|
|
259
277
|
// Cursor: tolerate a failing recheck only when SQLite updates are deferred (apply after restart).
|
|
260
|
-
// Claude
|
|
261
|
-
// in-process recheck red for the same UUID — allow in that case
|
|
278
|
+
// Claude / Copilot: JSON remediations are written immediately; merge/verify timing can still leave
|
|
279
|
+
// the in-process recheck red for the same UUID — allow in that case for immediate JSON agents.
|
|
262
280
|
const appliedUuids = new Set(appliedViolations.map((v) => v.uuid));
|
|
263
|
-
const claudeRecheckStaleAfterImmediateApply = ide === 'claude' &&
|
|
281
|
+
const claudeRecheckStaleAfterImmediateApply = (ide === 'claude' || ide === 'copilot' || ide === 'opencode') &&
|
|
264
282
|
!recheckOk &&
|
|
265
283
|
recheck.violations.length > 0 &&
|
|
266
284
|
recheck.violations.every((v) => appliedUuids.has(v.uuid));
|
|
267
285
|
if (claudeRecheckStaleAfterImmediateApply) {
|
|
268
|
-
hookRunLog(
|
|
286
|
+
hookRunLog(`compliance_prompt_gate: ${ide} — autofix wrote JSON; recheck still flags same UUID(s) — proceeding (immediate apply)`);
|
|
269
287
|
}
|
|
270
288
|
if (deferredSqlitePending || recheckOk || claudeRecheckStaleAfterImmediateApply) {
|
|
271
289
|
const immediateVerified = restartCommands.length === 0 &&
|
|
@@ -282,9 +300,13 @@ export async function runCompliancePromptGate() {
|
|
|
282
300
|
? formatCursorRestartAutofixDialog(appliedViolations)
|
|
283
301
|
: ide === 'claude'
|
|
284
302
|
? formatClaudeAutofixDialog(appliedViolations)
|
|
285
|
-
:
|
|
286
|
-
|
|
287
|
-
|
|
303
|
+
: ide === 'copilot'
|
|
304
|
+
? formatCopilotAutofixDialog(appliedViolations)
|
|
305
|
+
: ide === 'opencode'
|
|
306
|
+
? formatOpenCodeAutofixDialog(appliedViolations)
|
|
307
|
+
: `Optimus Labs auto-fixed ${fixed} ${fixed === 1 ? 'policy violation' : 'policy violations'}:\n\n${appliedViolations
|
|
308
|
+
.map((v) => autofixDialogLine(v))
|
|
309
|
+
.join('\n')}${changePreviewSuffix}`;
|
|
288
310
|
const payload = { __optimus_autofix: true, autofix_message: autofixMessage };
|
|
289
311
|
if (restartCommands.length > 0)
|
|
290
312
|
payload.restart_commands = restartCommands;
|
|
@@ -69,7 +69,7 @@ function buildCollectionContext(patterns, projectRoot, home, homeRecurseSkipDirs
|
|
|
69
69
|
t.dir_subdir_filename = p.dir_subdir_filename;
|
|
70
70
|
if (p.dir_subdir_source)
|
|
71
71
|
t.dir_subdir_source = p.dir_subdir_source;
|
|
72
|
-
const key = `${t.path}\t${t.file_type}`;
|
|
72
|
+
const key = `${t.path}\t${t.file_type}\t${t.isDirectory ? 'dir' : 'file'}\t${t.dir_glob ?? ''}`;
|
|
73
73
|
if (!seenPaths.has(key)) {
|
|
74
74
|
seenPaths.add(key);
|
|
75
75
|
targets.push(t);
|
|
@@ -293,6 +293,14 @@ function resolvePatternToTargets(pathPattern, pathType, fileType, projectRoot, h
|
|
|
293
293
|
targets.push({ path: basePath + suffix, file_type: fileType, content_format: contentFormat ?? 'extensions_cache' });
|
|
294
294
|
return targets;
|
|
295
295
|
}
|
|
296
|
+
if (spec?.type === 'env_dir_file') {
|
|
297
|
+
const configuredDir = process.env[spec.env_var]?.trim();
|
|
298
|
+
const baseDir = configuredDir
|
|
299
|
+
? configuredDir.replace(/^~\//, `${home}/`)
|
|
300
|
+
: join(home, spec.fallback_under_home.replace(/^~\//, ''));
|
|
301
|
+
pushTargetPaths(targets, join(baseDir, spec.filename), fileType, false, collectStyle, contentFormat, dirGlob);
|
|
302
|
+
return targets;
|
|
303
|
+
}
|
|
296
304
|
if (norm.includes('**'))
|
|
297
305
|
return expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentFormat, dirGlob, homeRecurseSkipDirs);
|
|
298
306
|
if (norm.includes('*'))
|
|
@@ -176,4 +176,4 @@ function readInstalledExtensions(extensionsCachePath) {
|
|
|
176
176
|
}
|
|
177
177
|
return extensions;
|
|
178
178
|
}
|
|
179
|
-
export { readMCPConfig, readJSONFile, readMarkdownFile, readInstalledExtensions };
|
|
179
|
+
export { readMCPConfig, readJSONFile, readMarkdownFile, readInstalledExtensions, parseJsonWithJsoncFallback, };
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
+
import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
|
|
16
17
|
import { mergeComposerShadowKeysFromReactiveBlob, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
|
|
17
18
|
import { readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
|
|
18
19
|
import { resolveRemediationConfigPath } from './remediation_config_path.js';
|
|
19
20
|
import { resolveOpsTargetPath } from './ops_target_path.js';
|
|
20
21
|
import { isRemediationQuarantined, markRemediationApplyPendingVerification, markRemediationApplyVerified, processPendingPostRestartVerifications, readRemediationApplyTrackingFile, writeRemediationApplyTrackingFile, } from './remediation_apply_tracking.js';
|
|
21
22
|
import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
|
|
23
|
+
import { isEnvBackedSecretValue, scanJsonForHardcodedSecrets, } from './secret_regex_scan.js';
|
|
22
24
|
import { loadEndpointBase } from '../sender/endpoint_config.js';
|
|
23
25
|
import { resolveHardwareUuid, tryResolveHardwareUuid } from './hardware_uuid.js';
|
|
24
26
|
import { buildDeferredCursorRestartCommand, discoverAllWorkspaceVscdbs, enforceRemediation, fetchSync, isTrustedRestartCommandForAutofix, remediationFixSpec, reportAutofixApplied, syncRemediations, } from './remediation_sync.js';
|
|
@@ -188,6 +190,36 @@ function allowlistEntryMatchesRemoveToken(entry, token) {
|
|
|
188
190
|
const pattern = new RegExp(`(^|[^a-z0-9_|])${escapeRegExp(t)}([^a-z0-9_|]|$)`);
|
|
189
191
|
return pattern.test(valStr);
|
|
190
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* MCP secret remediations deploy ops.set to ${env:KEY}. Local compliance passes when the
|
|
195
|
+
* user picks any env reference, not only the exact string from the manifest.
|
|
196
|
+
*/
|
|
197
|
+
function remediationSetOpSatisfied(current, expected) {
|
|
198
|
+
if (deepEqual(current, expected))
|
|
199
|
+
return true;
|
|
200
|
+
if (typeof expected === 'string' &&
|
|
201
|
+
isEnvBackedSecretValue(expected) &&
|
|
202
|
+
isEnvBackedSecretValue(current)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
function violationFromSecretScanFinding(entry, compliance, finding) {
|
|
208
|
+
return {
|
|
209
|
+
uuid: entry.uuid,
|
|
210
|
+
finding_formatted_id: compliance.finding_formatted_id,
|
|
211
|
+
setting_path: finding.path,
|
|
212
|
+
description: compliance.description,
|
|
213
|
+
finding_title: entry.finding_title,
|
|
214
|
+
finding_description: entry.finding_description,
|
|
215
|
+
policy_name: entry.policy_name,
|
|
216
|
+
severity: compliance.severity,
|
|
217
|
+
autofix_allowed: compliance.autofix_allowed,
|
|
218
|
+
config_file_path: entry.config_file_path,
|
|
219
|
+
expected_value: { op: 'secret_scan', path: finding.path, secret_type: finding.secretType },
|
|
220
|
+
message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Remove the hardcoded ${finding.secretType} from ${finding.path} in ${entry.config_file_path}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
191
223
|
function verifyOpsApplied(configJson, settingPath, ops) {
|
|
192
224
|
const parts = settingPath.split('.');
|
|
193
225
|
const leafKey = parts[parts.length - 1] ?? '';
|
|
@@ -201,8 +233,9 @@ function verifyOpsApplied(configJson, settingPath, ops) {
|
|
|
201
233
|
if (Object.prototype.hasOwnProperty.call(set, k)) {
|
|
202
234
|
const cur = getByPath(configJson, targetPath);
|
|
203
235
|
const expected = set[k];
|
|
204
|
-
if (!
|
|
236
|
+
if (!remediationSetOpSatisfied(cur, expected)) {
|
|
205
237
|
return { ok: false, expected: { op: 'set', path: targetPath, value: expected } };
|
|
238
|
+
}
|
|
206
239
|
continue;
|
|
207
240
|
}
|
|
208
241
|
const cur = getByPath(configJson, targetPath);
|
|
@@ -262,12 +295,12 @@ function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
|
|
|
262
295
|
}
|
|
263
296
|
if (!existsSync(resolvedPath))
|
|
264
297
|
return { ok: false, reason: 'file_not_found' };
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
298
|
+
// OpenCode configs are JSONC (comments / trailing commas); parse strict JSON first, then
|
|
299
|
+
// fall back to JSONC sanitization so comment-bearing files are not skipped as parse_error.
|
|
300
|
+
const parsed = parseJsonWithJsoncFallback(readFileSync(resolvedPath, 'utf8'));
|
|
301
|
+
if (parsed === null)
|
|
269
302
|
return { ok: false, reason: 'parse_error' };
|
|
270
|
-
}
|
|
303
|
+
return { ok: true, json: parsed };
|
|
271
304
|
}
|
|
272
305
|
/**
|
|
273
306
|
* Evaluate all checks in a secondary group against the group's config file.
|
|
@@ -319,6 +352,14 @@ export function evaluateManifestEntryCompliance(entry) {
|
|
|
319
352
|
if (!loaded.ok)
|
|
320
353
|
return { violations: [] };
|
|
321
354
|
const configJson = loaded.json;
|
|
355
|
+
if (compliance.requires_secret_scan === true) {
|
|
356
|
+
const secretFindings = scanJsonForHardcodedSecrets(configJson);
|
|
357
|
+
return secretFindings.length === 0
|
|
358
|
+
? { violations: [] }
|
|
359
|
+
: {
|
|
360
|
+
violations: secretFindings.map((f) => violationFromSecretScanFinding(entry, compliance, f)),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
322
363
|
const entryViolations = [];
|
|
323
364
|
for (const check of checks) {
|
|
324
365
|
const effectivePath = canonicalComplianceSettingPath(entry.config_file_path, check);
|
|
@@ -638,12 +679,8 @@ export function applyAutofixViolations(violations, agent = 'cursor') {
|
|
|
638
679
|
updatedContent = itemKey ? (readVscdbItemTableJson(dbPath, itemKey) ?? undefined) : undefined;
|
|
639
680
|
}
|
|
640
681
|
else {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
catch {
|
|
645
|
-
updatedContent = undefined;
|
|
646
|
-
}
|
|
682
|
+
updatedContent =
|
|
683
|
+
parseJsonWithJsoncFallback(readFileSync(configPathForDisk, 'utf8')) ?? undefined;
|
|
647
684
|
}
|
|
648
685
|
if (updatedContent !== undefined) {
|
|
649
686
|
const fileType = (inst.file_type ?? '').trim();
|
|
@@ -759,6 +796,16 @@ export function pruneSatisfiedOneTimeRemediations(agent = 'cursor') {
|
|
|
759
796
|
continue;
|
|
760
797
|
}
|
|
761
798
|
const configJson = prLoaded.json;
|
|
799
|
+
if (spec?.requires_secret_scan === true) {
|
|
800
|
+
if (scanJsonForHardcodedSecrets(configJson).length === 0) {
|
|
801
|
+
removed++;
|
|
802
|
+
hookRunLog(`remediation_prune: satisfied secret-scan uuid=${inst.uuid} path=${inst.config_file_path}`);
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
remaining.push(raw);
|
|
806
|
+
}
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
762
809
|
// Only prune when every check is ops-based and currently satisfied.
|
|
763
810
|
let okAll = true;
|
|
764
811
|
for (const check of checks) {
|
|
@@ -856,11 +903,8 @@ export function uploadSatisfiedManifestConfigs(agent = 'cursor') {
|
|
|
856
903
|
continue;
|
|
857
904
|
}
|
|
858
905
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
rawContent = JSON.parse(readFileSync(diskPath, 'utf8'));
|
|
862
|
-
}
|
|
863
|
-
catch {
|
|
906
|
+
const rawContent = parseJsonWithJsoncFallback(readFileSync(diskPath, 'utf8'));
|
|
907
|
+
if (rawContent === null) {
|
|
864
908
|
hookRunLog(`satisfied_upload: could not read path=${diskPath} uuid=${entry.uuid}`);
|
|
865
909
|
continue;
|
|
866
910
|
}
|
|
@@ -893,8 +937,8 @@ export function uploadSatisfiedManifestConfigs(agent = 'cursor') {
|
|
|
893
937
|
* Apply (autofix) is intentionally deferred to the gate on the next prompt — this pass only downloads
|
|
894
938
|
* a fresh manifest so the gate has up-to-date data when it runs.
|
|
895
939
|
*/
|
|
896
|
-
export async function runComplianceCheck() {
|
|
897
|
-
const agent = currentAgentFromEnv();
|
|
940
|
+
export async function runComplianceCheck(agentOverride) {
|
|
941
|
+
const agent = agentOverride ?? currentAgentFromEnv();
|
|
898
942
|
try {
|
|
899
943
|
await syncRemediations(loadEndpointBase(), resolveHardwareUuid());
|
|
900
944
|
}
|
|
@@ -4,6 +4,8 @@ import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
|
|
|
4
4
|
import { getActiveComplianceLogPath } from './compliance_session_log.js';
|
|
5
5
|
const HOOK_LOG_FILENAME = 'hook_log.txt';
|
|
6
6
|
const COMPLIANCE_RUNNER_LOG_FILENAME = 'compliance_runner.log';
|
|
7
|
+
const COMPLIANCE_SUBDIR = 'compliance';
|
|
8
|
+
const COMPLIANCE_FALLBACK_TIER = 'noop';
|
|
7
9
|
/** Append-only: remediation verify/apply failures (not cleared per hook session). */
|
|
8
10
|
const REMEDIATION_APPLY_FAILURES_FILENAME = 'remediation_apply_failures.log';
|
|
9
11
|
/** Hard cap so a single upload/sync session cannot grow hook_log.txt without bound. */
|
|
@@ -18,7 +20,7 @@ function getComplianceRunnerLogPath() {
|
|
|
18
20
|
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
19
21
|
if (!homeDir)
|
|
20
22
|
return null;
|
|
21
|
-
return path.join(homeDir, OPT_AI_SEC_MANAGEMENT_REL, COMPLIANCE_RUNNER_LOG_FILENAME);
|
|
23
|
+
return path.join(homeDir, OPT_AI_SEC_MANAGEMENT_REL, COMPLIANCE_SUBDIR, COMPLIANCE_FALLBACK_TIER, COMPLIANCE_RUNNER_LOG_FILENAME);
|
|
22
24
|
}
|
|
23
25
|
function getRemediationApplyFailuresLogPath() {
|
|
24
26
|
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
@@ -27,7 +29,8 @@ function getRemediationApplyFailuresLogPath() {
|
|
|
27
29
|
return path.join(homeDir, OPT_AI_SEC_MANAGEMENT_REL, REMEDIATION_APPLY_FAILURES_FILENAME);
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
|
-
* Append one ISO-timestamped line to
|
|
32
|
+
* Append one ISO-timestamped line to the active compliance session log.
|
|
33
|
+
* If no session is active, use compliance/noop/compliance_runner.log as a legacy fallback.
|
|
31
34
|
* `level` examples: INFO, RUNNER, RESTART, FAIL
|
|
32
35
|
*/
|
|
33
36
|
function appendComplianceRunnerLine(level, message) {
|
|
@@ -69,7 +72,7 @@ function complianceRunnerRunnerLine(message) {
|
|
|
69
72
|
}
|
|
70
73
|
/**
|
|
71
74
|
* Loud, append-only record when a remediation cannot be verified or applied. Writes
|
|
72
|
-
* {@link REMEDIATION_APPLY_FAILURES_FILENAME} and mirrors the same block to
|
|
75
|
+
* {@link REMEDIATION_APPLY_FAILURES_FILENAME} and mirrors the same block to the compliance log;
|
|
73
76
|
* also appends a single summary line to hook_log.txt for the current session.
|
|
74
77
|
*/
|
|
75
78
|
function logRemediationApplyFailure(context, fields) {
|
|
@@ -100,12 +103,18 @@ function logRemediationApplyFailure(context, fields) {
|
|
|
100
103
|
const failPath = getRemediationApplyFailuresLogPath();
|
|
101
104
|
if (failPath)
|
|
102
105
|
writeFailAppend(failPath);
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
const activeCompliancePath = getActiveComplianceLogPath();
|
|
107
|
+
if (activeCompliancePath) {
|
|
108
|
+
writeFailAppend(activeCompliancePath);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const crPath = getComplianceRunnerLogPath();
|
|
112
|
+
if (crPath) {
|
|
113
|
+
const dir = path.dirname(crPath);
|
|
114
|
+
if (!existsSync(dir))
|
|
115
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
116
|
+
appendFileSync(crPath, block, 'utf8');
|
|
117
|
+
}
|
|
109
118
|
}
|
|
110
119
|
}
|
|
111
120
|
catch {
|
|
@@ -3,6 +3,7 @@ import { delimiter, dirname, join } from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { executeBody } from '../../endpoint_client/http_transport.js';
|
|
6
|
+
import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
|
|
6
7
|
import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
|
|
7
8
|
import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
|
|
8
9
|
import { readStoredAuthKey } from '../auth/auth_key_store.js';
|
|
@@ -1396,10 +1397,16 @@ export function enforceRemediation(instruction) {
|
|
|
1396
1397
|
}
|
|
1397
1398
|
let configJson = {};
|
|
1398
1399
|
if (existsSync(inst.config_file_path)) {
|
|
1399
|
-
|
|
1400
|
-
|
|
1400
|
+
// OpenCode configs are JSONC; parse strict JSON first, then JSONC fallback so a
|
|
1401
|
+
// comment-bearing opencode.json(c) keeps its existing settings instead of being reset to
|
|
1402
|
+
// {} on a parse failure. NOTE: the write-back below is JSON.stringify, so comments and
|
|
1403
|
+
// trailing commas in the original file are not preserved (same as every other agent's
|
|
1404
|
+
// JSON config) — only the key/value settings are retained and patched.
|
|
1405
|
+
const parsed = parseJsonWithJsoncFallback(readFileSync(inst.config_file_path, 'utf8'));
|
|
1406
|
+
if (parsed !== null) {
|
|
1407
|
+
configJson = parsed;
|
|
1401
1408
|
}
|
|
1402
|
-
|
|
1409
|
+
else {
|
|
1403
1410
|
hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${inst.uuid}`);
|
|
1404
1411
|
}
|
|
1405
1412
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight hardcoded-secret detection for local compliance (requires_secret_scan rows).
|
|
3
|
+
* Regex set aligned with policy_engine HardcodedSecretRule — not a full Secretlint/Gitleaks port.
|
|
4
|
+
*/
|
|
5
|
+
/** Values that reference env vars are never treated as hardcoded secrets. */
|
|
6
|
+
export function isEnvBackedSecretValue(value) {
|
|
7
|
+
if (typeof value !== 'string')
|
|
8
|
+
return false;
|
|
9
|
+
const s = value.trim();
|
|
10
|
+
if (!s)
|
|
11
|
+
return false;
|
|
12
|
+
return s.includes('${env:') || s.includes('$env:') || s.includes('process.env.');
|
|
13
|
+
}
|
|
14
|
+
/** Provider / format patterns (order matters — first match wins). */
|
|
15
|
+
const KNOWN_SECRET_PATTERNS = [
|
|
16
|
+
{ re: /sk-proj-[a-zA-Z0-9_-]{20,}/i, label: 'OpenAI API key' },
|
|
17
|
+
{ re: /sk-ant-[a-zA-Z0-9_-]{20,}/i, label: 'Anthropic API key' },
|
|
18
|
+
{ re: /sk-[a-zA-Z0-9]{20,}/i, label: 'API key (sk-...)' },
|
|
19
|
+
{ re: /sk_(?:live|test|prod)_[a-zA-Z0-9]{20,}/i, label: 'Stripe secret key' },
|
|
20
|
+
{ re: /pk_(?:live|test)_[a-zA-Z0-9]{20,}/i, label: 'Stripe publishable key' },
|
|
21
|
+
{ re: /pk_[a-zA-Z0-9_]{20,}/i, label: 'API key (pk_...)' },
|
|
22
|
+
{ re: /whsec_[a-zA-Z0-9]{20,}/i, label: 'Stripe webhook secret' },
|
|
23
|
+
{ re: /rk_(?:live|test)_[a-zA-Z0-9]{20,}/i, label: 'Stripe restricted key' },
|
|
24
|
+
{ re: /pplx-[a-zA-Z0-9-]{20,}/i, label: 'Perplexity API key' },
|
|
25
|
+
{ re: /ghp_[a-zA-Z0-9]{36}/i, label: 'GitHub personal access token' },
|
|
26
|
+
{ re: /gho_[a-zA-Z0-9]{36}/i, label: 'GitHub OAuth token' },
|
|
27
|
+
{ re: /ghu_[a-zA-Z0-9]{36}/i, label: 'GitHub user-to-server token' },
|
|
28
|
+
{ re: /ghs_[a-zA-Z0-9]{36}/i, label: 'GitHub server-to-server token' },
|
|
29
|
+
{ re: /ghr_[a-zA-Z0-9]{76}/i, label: 'GitHub refresh token' },
|
|
30
|
+
{ re: /github_pat_[a-zA-Z0-9_]{20,}/i, label: 'GitHub fine-grained PAT' },
|
|
31
|
+
{ re: /glpat-[a-zA-Z0-9_-]{20,}/i, label: 'GitLab personal access token' },
|
|
32
|
+
{ re: /glcbt-[a-zA-Z0-9_-]{20,}/i, label: 'GitLab CI job token' },
|
|
33
|
+
{ re: /xoxb-[a-zA-Z0-9-]+/i, label: 'Slack bot token' },
|
|
34
|
+
{ re: /xoxp-[a-zA-Z0-9-]+/i, label: 'Slack user token' },
|
|
35
|
+
{ re: /xoxa-[a-zA-Z0-9-]+/i, label: 'Slack app-level token' },
|
|
36
|
+
{ re: /xapp-[a-zA-Z0-9-]+/i, label: 'Slack app token' },
|
|
37
|
+
{ re: /AKIA[0-9A-Z]{16}/i, label: 'AWS access key ID' },
|
|
38
|
+
{ re: /ASIA[0-9A-Z]{16}/i, label: 'AWS temporary access key ID' },
|
|
39
|
+
{ re: /AIza[0-9A-Za-z_-]{35}/i, label: 'Google API key' },
|
|
40
|
+
{ re: /ya29\.[0-9A-Za-z_-]+/i, label: 'Google OAuth access token' },
|
|
41
|
+
{ re: /AC[a-z0-9]{32}/i, label: 'Twilio account SID' },
|
|
42
|
+
{ re: /SK[a-z0-9]{32}/i, label: 'Twilio API key' },
|
|
43
|
+
{ re: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/i, label: 'SendGrid API key' },
|
|
44
|
+
{ re: /key-[a-f0-9]{32}/i, label: 'Mailgun API key' },
|
|
45
|
+
{ re: /npm_[a-zA-Z0-9]{36}/i, label: 'npm access token' },
|
|
46
|
+
{ re: /pypi-[a-zA-Z0-9_-]{50,}/i, label: 'PyPI API token' },
|
|
47
|
+
{ re: /discord(?:app)?\.com\/api\/webhooks\/\d+\/[a-zA-Z0-9_-]+/i, label: 'Discord webhook URL' },
|
|
48
|
+
{ re: /hooks\.slack\.com\/services\/T[a-zA-Z0-9_]+\/B[a-zA-Z0-9_]+\/[a-zA-Z0-9_]+/i, label: 'Slack webhook URL' },
|
|
49
|
+
{ re: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/i, label: 'JWT (JSON Web Token)' },
|
|
50
|
+
{ re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i, label: 'Private key block' },
|
|
51
|
+
{ re: /sq0[a-zA-Z0-9_-]{20,}/i, label: 'Square access token' },
|
|
52
|
+
{ re: /shpat_[a-fA-F0-9]{32}/i, label: 'Shopify access token' },
|
|
53
|
+
{ re: /shpca_[a-fA-F0-9]{32}/i, label: 'Shopify custom app token' },
|
|
54
|
+
{ re: /shpss_[a-fA-F0-9]{32}/i, label: 'Shopify shared secret' },
|
|
55
|
+
{ re: /dop_v1_[a-f0-9]{64}/i, label: 'DigitalOcean personal access token' },
|
|
56
|
+
{ re: /pat_[a-zA-Z0-9]{22}_[a-fA-F0-9]{59}/i, label: 'Atlassian API token' },
|
|
57
|
+
{ re: /Bearer\s+[a-zA-Z0-9\-_.]{20,}/i, label: 'Bearer token' },
|
|
58
|
+
];
|
|
59
|
+
const BASIC_AUTH_IN_URL = /[a-zA-Z0-9._%+-]+:[a-zA-Z0-9._%+-]+@/gi;
|
|
60
|
+
const GENERIC_TOKEN = /\b[a-zA-Z0-9]{32,}\b/g;
|
|
61
|
+
const GENERIC_KEY_HINT = /(?:api[_-]?key|app[_-]?key|secret|token|password|passwd|pass\b|(?<!o)auth|credential|private[_-]?key|access[_-]?key|master[_-]?key)/i;
|
|
62
|
+
function basicAuthMatchIsCredential(value, matchIndex) {
|
|
63
|
+
const before = value.slice(0, matchIndex);
|
|
64
|
+
const schemeIdx = before.lastIndexOf('://');
|
|
65
|
+
if (schemeIdx === -1)
|
|
66
|
+
return true;
|
|
67
|
+
const between = value.slice(schemeIdx + 3, matchIndex);
|
|
68
|
+
return !between.includes('/');
|
|
69
|
+
}
|
|
70
|
+
function looksLikeUrlContext(value) {
|
|
71
|
+
return value.includes(':') && (value.toLowerCase().includes('http') || value.includes('://'));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Scan one scalar config value. Returns secret type label or null if clean / env-backed.
|
|
75
|
+
*/
|
|
76
|
+
export function scanScalarForHardcodedSecret(value, keyName) {
|
|
77
|
+
if (value == null)
|
|
78
|
+
return null;
|
|
79
|
+
const valueStr = String(value);
|
|
80
|
+
if (isEnvBackedSecretValue(valueStr))
|
|
81
|
+
return null;
|
|
82
|
+
for (const { re, label } of KNOWN_SECRET_PATTERNS) {
|
|
83
|
+
if (re.test(valueStr))
|
|
84
|
+
return label;
|
|
85
|
+
}
|
|
86
|
+
BASIC_AUTH_IN_URL.lastIndex = 0;
|
|
87
|
+
for (const match of valueStr.matchAll(BASIC_AUTH_IN_URL)) {
|
|
88
|
+
if (basicAuthMatchIsCredential(valueStr, match.index ?? 0)) {
|
|
89
|
+
return 'Basic authentication credentials';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!GENERIC_KEY_HINT.test(keyName) || looksLikeUrlContext(valueStr)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const genericMatches = valueStr.match(GENERIC_TOKEN) ?? [];
|
|
96
|
+
if (genericMatches.some((token) => token.length >= 40)) {
|
|
97
|
+
return 'Potential token/secret';
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function collectScalarLeaves(value, path = '') {
|
|
102
|
+
if (value == null)
|
|
103
|
+
return [];
|
|
104
|
+
if (Array.isArray(value)) {
|
|
105
|
+
return value.flatMap((item, idx) => collectScalarLeaves(item, path ? `${path}.${idx}` : String(idx)));
|
|
106
|
+
}
|
|
107
|
+
if (typeof value === 'object') {
|
|
108
|
+
return Object.entries(value).flatMap(([key, nested]) => collectScalarLeaves(nested, path ? `${path}.${key}` : key));
|
|
109
|
+
}
|
|
110
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const key = path.split('.').pop() ?? path;
|
|
114
|
+
return [{ path: path || '.', key, value }];
|
|
115
|
+
}
|
|
116
|
+
/** Walk parsed JSON and return all hardcoded-secret hits (JSON dot paths). */
|
|
117
|
+
export function scanJsonForHardcodedSecrets(configJson) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
for (const leaf of collectScalarLeaves(configJson)) {
|
|
120
|
+
const secretType = scanScalarForHardcodedSecret(leaf.value, leaf.key);
|
|
121
|
+
if (secretType) {
|
|
122
|
+
findings.push({ path: leaf.path, key: leaf.key, secretType });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process Secretlint scan for compliance rows with requires_secret_scan.
|
|
3
|
+
* Bundled as log-llm-config dependencies — no separate user install.
|
|
4
|
+
*/
|
|
5
|
+
import { lintSource } from '@secretlint/core';
|
|
6
|
+
import { loadPackagesFromConfigDescriptor } from '@secretlint/config-loader';
|
|
7
|
+
let secretlintConfigPromise = null;
|
|
8
|
+
async function getSecretlintConfig() {
|
|
9
|
+
if (!secretlintConfigPromise) {
|
|
10
|
+
secretlintConfigPromise = loadPackagesFromConfigDescriptor({
|
|
11
|
+
configDescriptor: {
|
|
12
|
+
rules: [{ id: '@secretlint/secretlint-rule-preset-recommend' }],
|
|
13
|
+
},
|
|
14
|
+
}).then((loaded) => {
|
|
15
|
+
if (!loaded.ok) {
|
|
16
|
+
throw new Error('Failed to load Secretlint config');
|
|
17
|
+
}
|
|
18
|
+
return loaded.config;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return secretlintConfigPromise;
|
|
22
|
+
}
|
|
23
|
+
function collectStringLeaves(value, path = '') {
|
|
24
|
+
if (value == null)
|
|
25
|
+
return [];
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.flatMap((item, idx) => collectStringLeaves(item, path ? `${path}.${idx}` : String(idx)));
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === 'object') {
|
|
30
|
+
return Object.entries(value).flatMap(([key, nested]) => collectStringLeaves(nested, path ? `${path}.${key}` : key));
|
|
31
|
+
}
|
|
32
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const key = path.split('.').pop() ?? path;
|
|
36
|
+
return [{ path: path || '.', key, value: String(value) }];
|
|
37
|
+
}
|
|
38
|
+
function secretTypeFromMessage(ruleId, message) {
|
|
39
|
+
const trimmed = message.trim();
|
|
40
|
+
return trimmed || ruleId || 'Secretlint detection';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Scan parsed JSON config for secrets using Secretlint (preset-recommend).
|
|
44
|
+
*/
|
|
45
|
+
export async function scanJsonForSecretsWithSecretlint(configJson) {
|
|
46
|
+
const config = await getSecretlintConfig();
|
|
47
|
+
const leaves = collectStringLeaves(configJson);
|
|
48
|
+
const findings = [];
|
|
49
|
+
for (const leaf of leaves) {
|
|
50
|
+
const result = await lintSource({
|
|
51
|
+
source: {
|
|
52
|
+
filePath: `${leaf.path.replace(/\./g, '/')}.json`,
|
|
53
|
+
content: leaf.value,
|
|
54
|
+
ext: '.json',
|
|
55
|
+
contentType: 'text',
|
|
56
|
+
},
|
|
57
|
+
options: { config },
|
|
58
|
+
});
|
|
59
|
+
if (result.messages.length === 0)
|
|
60
|
+
continue;
|
|
61
|
+
const first = result.messages[0];
|
|
62
|
+
findings.push({
|
|
63
|
+
path: leaf.path,
|
|
64
|
+
key: leaf.key,
|
|
65
|
+
secretType: secretTypeFromMessage(first.ruleId ?? 'secret', String(first.message ?? '')),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return findings;
|
|
69
|
+
}
|
|
@@ -85,7 +85,7 @@ export function executeTrustedRestartCommands(commands) {
|
|
|
85
85
|
appendComplianceRunnerLine('RESTART', `spawn deferred_vscdb_restart sh -c (detail_log=${detailPath}) cwd_inherited_from_node`);
|
|
86
86
|
}
|
|
87
87
|
else {
|
|
88
|
-
hookRunLog('execute_trusted_restarts: spawning trusted restart (see
|
|
88
|
+
hookRunLog('execute_trusted_restarts: spawning trusted restart (see compliance session log [RESTART])');
|
|
89
89
|
appendComplianceRunnerLine('RESTART', 'spawn trusted_restart sh -c (non-deferred)');
|
|
90
90
|
}
|
|
91
91
|
const projectRoot = resolveProjectRoot();
|