log-llm-config 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  * Synchronous pre-prompt gate: local compliance only (stdout = single JSON line for IDE hooks).
3
3
  * stderr must stay clean; logs go via hookRunLog (file).
4
4
  */
5
- import { applyAutofixViolations, runLocalRemediationComplianceCheck } from './log_config_files/runtime/compliance_check.js';
5
+ import { applyAutofixViolations, pruneSatisfiedOneTimeRemediations, runLocalRemediationComplianceCheck, } from './log_config_files/runtime/compliance_check.js';
6
6
  import { existsSync, statSync } from 'node:fs';
7
7
  import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
8
8
  const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -74,7 +74,7 @@ async function run() {
74
74
  if (recheck.status === 'ok' || recheck.violations.length === 0) {
75
75
  const fixedViolations = status.violations.filter((v) => v.autofix_allowed);
76
76
  const lines = fixedViolations.map((v) => `• [${v.finding_formatted_id}] ${v.description}`);
77
- const autofixMessage = `Optimus Security auto-fixed ${fixed} compliance violation(s):\n${lines.join('\n')}`;
77
+ const autofixMessage = `Optimus Security auto-fixed ${fixed} policy violation(s):\n${lines.join('\n')}`;
78
78
  const payload = { __optimus_autofix: true, autofix_message: autofixMessage };
79
79
  if (restartCommands.length > 0)
80
80
  payload.restart_commands = restartCommands;
@@ -95,6 +95,11 @@ async function run() {
95
95
  console.log(blockPayload(ide, msg));
96
96
  return;
97
97
  }
98
+ // No violations: clean up satisfied one-time remediations so they don't linger locally forever.
99
+ const pruned = pruneSatisfiedOneTimeRemediations();
100
+ if (pruned.removed > 0) {
101
+ await Promise.allSettled(pruned.reportPromises);
102
+ }
98
103
  printAllow(ide);
99
104
  }
100
105
  run().catch(() => printAllow(ide)).finally(() => process.exit(0));
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Compliance check: types and check runner entry point.
3
3
  *
4
+ * Compliance is in-memory only (no compliance.json): runLocalRemediationComplianceCheck returns
5
+ * ComplianceStatus; the prompt gate and tests use that return value. Autofix may write config files
6
+ * and remediation_instructions.json via applyAutofixViolations / enforceRemediation.
7
+ *
4
8
  * Prompt gate: compliance_prompt_gate runs runLocalRemediationComplianceCheck (local files only).
5
9
  * Background: compliance_check_runner runs syncRemediations (network) then the same local check.
6
10
  */
@@ -9,7 +13,7 @@ import { readRemediationInstructionsFile, writeRemediationInstructionsFile } fro
9
13
  import { hookRunLog } from './hook_logger.js';
10
14
  import { loadEndpointBase } from '../sender/endpoint_config.js';
11
15
  import { resolveHardwareUuid } from './hardware_uuid.js';
12
- import { enforceRemediation, fetchSync, reportAutofixApplied, syncRemediations } from './remediation_sync.js';
16
+ import { enforceRemediation, fetchSync, remediationFixSpec, reportAutofixApplied, syncRemediations, } from './remediation_sync.js';
13
17
  import { sendConfigFile } from '../sender/batch_sender.js';
14
18
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
15
19
  // ---------------------------------------------------------------------------
@@ -30,13 +34,54 @@ function getByPath(obj, path) {
30
34
  function deepEqual(a, b) {
31
35
  return JSON.stringify(a) === JSON.stringify(b);
32
36
  }
37
+ function isStringArray(v) {
38
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
39
+ }
40
+ function verifyOpsApplied(configJson, settingPath, ops) {
41
+ const parts = settingPath.split('.');
42
+ const leafKey = parts[parts.length - 1] ?? '';
43
+ const parentPath = parts.slice(0, -1).join('.');
44
+ const set = ops.set ?? {};
45
+ const add = ops.add ?? {};
46
+ const remove = ops.remove ?? {};
47
+ const keys = new Set([...Object.keys(set), ...Object.keys(add), ...Object.keys(remove)]);
48
+ // If ops targets a single leaf key, check at settingPath; otherwise treat keys as subkeys under parent.
49
+ for (const k of keys) {
50
+ const targetPath = k === leafKey || (keys.size === 1 && (k === leafKey || k === ''))
51
+ ? settingPath
52
+ : parentPath
53
+ ? `${parentPath}.${k}`
54
+ : k;
55
+ if (Object.prototype.hasOwnProperty.call(set, k)) {
56
+ const cur = getByPath(configJson, targetPath);
57
+ const expected = set[k];
58
+ if (!deepEqual(cur, expected))
59
+ return { ok: false, expected: { op: 'set', path: targetPath, value: expected } };
60
+ continue;
61
+ }
62
+ const cur = getByPath(configJson, targetPath);
63
+ const curArr = isStringArray(cur) ? cur : [];
64
+ const toAdd = add[k] ?? [];
65
+ const toRemove = remove[k] ?? [];
66
+ for (const item of toRemove) {
67
+ if (curArr.includes(item)) {
68
+ return { ok: false, expected: { op: 'remove', path: targetPath, value: item } };
69
+ }
70
+ }
71
+ for (const item of toAdd) {
72
+ if (!curArr.includes(item)) {
73
+ return { ok: false, expected: { op: 'add', path: targetPath, value: item } };
74
+ }
75
+ }
76
+ }
77
+ return { ok: true, expected: null };
78
+ }
33
79
  // ---------------------------------------------------------------------------
34
80
  // Check runner — Section 6: real per-check evaluation
35
81
  // ---------------------------------------------------------------------------
36
82
  /**
37
83
  * Evaluate current on-disk configs against remediation_instructions.json only (no server).
38
- * Writes compliance.json and returns the status for prompt gating.
39
- * Logs `compliance_check: local_file_check_wall_ms=<n>` — instruction + config reads and comparisons only (not sync/remediate).
84
+ * Returns status for prompt gating / callers; does not persist compliance.json.
40
85
  */
41
86
  export function runLocalRemediationComplianceCheck() {
42
87
  try {
@@ -49,7 +94,7 @@ export function runLocalRemediationComplianceCheck() {
49
94
  const uuids = entries.map((e) => e.uuid);
50
95
  const violations = [];
51
96
  for (const entry of entries) {
52
- const compliance = entry.compliance;
97
+ const compliance = entry.fix ?? entry.compliance;
53
98
  if (!compliance)
54
99
  continue;
55
100
  if (compliance.file_format !== 'json') {
@@ -72,9 +117,31 @@ export function runLocalRemediationComplianceCheck() {
72
117
  continue;
73
118
  }
74
119
  for (const check of checks) {
120
+ // Prefer ops-based verification (matches delta apply semantics; doesn't require full after snapshot).
121
+ if (check.ops) {
122
+ const { ok, expected } = verifyOpsApplied(configJson, check.setting_path, check.ops);
123
+ if (!ok) {
124
+ hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${check.setting_path} expected=${JSON.stringify(expected)}`);
125
+ violations.push({
126
+ uuid: entry.uuid,
127
+ finding_formatted_id: compliance.finding_formatted_id,
128
+ setting_path: check.setting_path,
129
+ description: check.description,
130
+ severity: compliance.severity,
131
+ autofix_allowed: compliance.autofix_allowed,
132
+ config_file_path: entry.config_file_path,
133
+ expected_value: expected,
134
+ message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Apply remediation ops for ${check.setting_path} in ${entry.config_file_path}`,
135
+ });
136
+ }
137
+ continue;
138
+ }
139
+ // Backwards compat: old local files may still carry after snapshots.
75
140
  const currentValue = getByPath(configJson, check.setting_path);
76
141
  const leafKey = check.setting_path.split('.').pop();
77
- const expectedValue = check.after[leafKey];
142
+ const expectedValue = check.after?.[leafKey];
143
+ if (expectedValue === undefined)
144
+ continue;
78
145
  if (!deepEqual(currentValue, expectedValue)) {
79
146
  hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${check.setting_path} current=${JSON.stringify(currentValue)} expected=${JSON.stringify(expectedValue)}`);
80
147
  violations.push({
@@ -142,15 +209,24 @@ export function applyAutofixViolations(violations) {
142
209
  if (inst.file_type) {
143
210
  const authKey = readStoredAuthKey();
144
211
  if (authKey) {
145
- reportPromises.push(sendConfigFile({ file_type: inst.file_type, file_path: inst.config_file_path, raw_content: inst.recommended_settings }, resolveHardwareUuid(), authKey).then((ok) => {
146
- hookRunLog(`autofix: re-sent updated file uuid=${inst.uuid} ok=${ok}`);
147
- }));
212
+ let updatedContent;
213
+ try {
214
+ updatedContent = JSON.parse(readFileSync(inst.config_file_path, 'utf8'));
215
+ }
216
+ catch {
217
+ updatedContent = undefined;
218
+ }
219
+ if (updatedContent !== undefined) {
220
+ reportPromises.push(sendConfigFile({ file_type: inst.file_type, file_path: inst.config_file_path, raw_content: updatedContent }, resolveHardwareUuid(), authKey).then((ok) => {
221
+ hookRunLog(`autofix: re-sent updated file uuid=${inst.uuid} ok=${ok}`);
222
+ }));
223
+ }
148
224
  }
149
225
  }
150
- const compliance = inst.compliance;
151
- if (compliance?.restart_required && compliance.restart_command) {
152
- hookRunLog(`autofix: restart required uuid=${inst.uuid} command=${compliance.restart_command}`);
153
- restartCommands.push(compliance.restart_command);
226
+ const spec = remediationFixSpec(inst);
227
+ if (spec?.restart_required && spec.restart_command) {
228
+ hookRunLog(`autofix: restart required uuid=${inst.uuid} command=${spec.restart_command}`);
229
+ restartCommands.push(spec.restart_command);
154
230
  }
155
231
  if (!inst.is_enforced) {
156
232
  oneTimeAppliedUuids.add(inst.uuid);
@@ -176,7 +252,81 @@ export function applyAutofixViolations(violations) {
176
252
  }
177
253
  return { fixed, restartCommands, failedViolations, reportPromises };
178
254
  }
179
- /** Background refresh: server sync for latest instructions, then local evaluation and latch write. */
255
+ /**
256
+ * Remove satisfied one-time remediations from local remediation_instructions.json.
257
+ *
258
+ * This handles the case where a user (or another tool) manually applied the change, so the
259
+ * instruction no longer needs to persist locally. Enforced remediations are never pruned.
260
+ *
261
+ * Returns number removed and any async report promises (heartbeat).
262
+ */
263
+ export function pruneSatisfiedOneTimeRemediations() {
264
+ const { remediations } = readRemediationInstructionsFile();
265
+ if (!Array.isArray(remediations) || remediations.length === 0)
266
+ return { removed: 0, reportPromises: [] };
267
+ const remaining = [];
268
+ let removed = 0;
269
+ for (const raw of remediations) {
270
+ const inst = raw;
271
+ if (inst.is_enforced) {
272
+ remaining.push(raw);
273
+ continue;
274
+ }
275
+ const spec = remediationFixSpec(inst);
276
+ const checks = spec?.file_format === 'json' ? (spec.checks ?? []) : [];
277
+ if (checks.length === 0) {
278
+ remaining.push(raw);
279
+ continue;
280
+ }
281
+ if (!existsSync(inst.config_file_path)) {
282
+ remaining.push(raw);
283
+ continue;
284
+ }
285
+ let configJson;
286
+ try {
287
+ configJson = JSON.parse(readFileSync(inst.config_file_path, 'utf8'));
288
+ }
289
+ catch {
290
+ remaining.push(raw);
291
+ continue;
292
+ }
293
+ // Only prune when every check is ops-based and currently satisfied.
294
+ let okAll = true;
295
+ for (const check of checks) {
296
+ if (!check.ops) {
297
+ okAll = false;
298
+ break;
299
+ }
300
+ const res = verifyOpsApplied(configJson, check.setting_path, check.ops);
301
+ if (!res.ok) {
302
+ okAll = false;
303
+ break;
304
+ }
305
+ }
306
+ if (okAll) {
307
+ removed++;
308
+ hookRunLog(`remediation_prune: satisfied one-time uuid=${inst.uuid} path=${inst.config_file_path}`);
309
+ }
310
+ else {
311
+ remaining.push(raw);
312
+ }
313
+ }
314
+ if (removed === 0)
315
+ return { removed: 0, reportPromises: [] };
316
+ writeRemediationInstructionsFile({ remediations: remaining });
317
+ hookRunLog(`remediation_prune: removed=${removed} remaining=${remaining.length}`);
318
+ const remainingUuids = remaining.map((r) => r.uuid);
319
+ const reportPromises = [
320
+ fetchSync(loadEndpointBase(), resolveHardwareUuid(), remainingUuids)
321
+ .then(() => hookRunLog(`remediation_prune: post-prune heartbeat sent uuids=${remainingUuids.length}`))
322
+ .catch(() => undefined),
323
+ ];
324
+ return { removed, reportPromises };
325
+ }
326
+ /**
327
+ * Background refresh: server sync for latest instructions, then the same local evaluation as the hook.
328
+ * Does not persist compliance state to disk.
329
+ */
180
330
  export async function runComplianceCheck() {
181
331
  try {
182
332
  await syncRemediations(loadEndpointBase(), resolveHardwareUuid());
@@ -7,6 +7,10 @@ import { readStoredAuthKey } from '../auth/auth_key_store.js';
7
7
  import { createSignature } from '../sender/signing.js';
8
8
  import { loadEndpointBase } from '../sender/endpoint_config.js';
9
9
  import { resolveHardwareUuid } from './hardware_uuid.js';
10
+ /** Resolve fix payload from API or legacy `compliance` key in local JSON. */
11
+ export function remediationFixSpec(inst) {
12
+ return inst.fix ?? inst.compliance ?? null;
13
+ }
10
14
  // ---------------------------------------------------------------------------
11
15
  // Persistence (single file: remediation_instructions.json)
12
16
  // ---------------------------------------------------------------------------
@@ -97,7 +101,13 @@ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000)
97
101
  return null;
98
102
  try {
99
103
  const parsed = JSON.parse(body);
100
- return Array.isArray(parsed.remediations) ? parsed.remediations : null;
104
+ if (!Array.isArray(parsed.remediations))
105
+ return null;
106
+ return parsed.remediations.map((raw) => {
107
+ const { compliance, ...rest } = raw;
108
+ const fix = rest.fix ?? compliance ?? null;
109
+ return { ...rest, fix };
110
+ });
101
111
  }
102
112
  catch {
103
113
  return null;
@@ -106,12 +116,161 @@ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000)
106
116
  // ---------------------------------------------------------------------------
107
117
  // Enforce / remove
108
118
  // ---------------------------------------------------------------------------
119
+ function setByPath(obj, path, value) {
120
+ const parts = path.split('.');
121
+ let current = obj;
122
+ for (let i = 0; i < parts.length - 1; i++) {
123
+ const part = parts[i];
124
+ if (current[part] == null || typeof current[part] !== 'object' || Array.isArray(current[part])) {
125
+ current[part] = {};
126
+ }
127
+ current = current[part];
128
+ }
129
+ current[parts[parts.length - 1]] = value;
130
+ }
131
+ /** Traverse a JSON object using dot-notation path. Returns undefined if any segment is missing. */
132
+ function getByPath(obj, path) {
133
+ const parts = path.split('.');
134
+ let current = obj;
135
+ for (const part of parts) {
136
+ if (current == null || typeof current !== 'object')
137
+ return undefined;
138
+ current = current[part];
139
+ }
140
+ return current;
141
+ }
142
+ function isPlainObject(v) {
143
+ return v != null && typeof v === 'object' && !Array.isArray(v);
144
+ }
145
+ function isStringArray(v) {
146
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
147
+ }
148
+ function applyStringArrayDelta(current, before, after) {
149
+ const cur = isStringArray(current) ? [...current] : [];
150
+ const b = isStringArray(before) ? before : [];
151
+ const a = isStringArray(after) ? after : [];
152
+ const bSet = new Set(b);
153
+ const aSet = new Set(a);
154
+ const removed = b.filter((x) => !aSet.has(x));
155
+ const added = a.filter((x) => !bSet.has(x));
156
+ // Remove any items that were removed by the remediation (preserve other local additions).
157
+ const removedSet = new Set(removed);
158
+ const next = cur.filter((x) => !removedSet.has(x));
159
+ // Append items that were added by the remediation (avoid duplicates).
160
+ const nextSet = new Set(next);
161
+ for (const x of added) {
162
+ if (!nextSet.has(x)) {
163
+ next.push(x);
164
+ nextSet.add(x);
165
+ }
166
+ }
167
+ return next;
168
+ }
169
+ function applyCheck(configJson, check) {
170
+ const parts = check.setting_path.split('.');
171
+ const leafKey = parts[parts.length - 1] ?? '';
172
+ const parentPath = parts.slice(0, -1).join('.');
173
+ // Prefer explicit ops when provided by server (safest: avoids clobbering local edits).
174
+ if (check.ops && typeof check.ops === 'object') {
175
+ const { set, add, remove } = check.ops;
176
+ const keys = new Set([
177
+ ...Object.keys(set ?? {}),
178
+ ...Object.keys(add ?? {}),
179
+ ...Object.keys(remove ?? {}),
180
+ ]);
181
+ for (const k of keys) {
182
+ const targetPath = k === leafKey || keys.size === 1 && (k === leafKey || k === '')
183
+ ? check.setting_path
184
+ : parentPath
185
+ ? `${parentPath}.${k}`
186
+ : k;
187
+ if (set && Object.prototype.hasOwnProperty.call(set, k)) {
188
+ setByPath(configJson, targetPath, set[k]);
189
+ continue;
190
+ }
191
+ const curVal = getByPath(configJson, targetPath);
192
+ const cur = isStringArray(curVal) ? [...curVal] : [];
193
+ const toRemove = (remove && remove[k]) ?? [];
194
+ const toAdd = (add && add[k]) ?? [];
195
+ const rmSet = new Set(toRemove);
196
+ const next = cur.filter((x) => !rmSet.has(x));
197
+ const nextSet = new Set(next);
198
+ for (const x of toAdd) {
199
+ if (!nextSet.has(x)) {
200
+ next.push(x);
201
+ nextSet.add(x);
202
+ }
203
+ }
204
+ setByPath(configJson, targetPath, next);
205
+ }
206
+ return;
207
+ }
208
+ // Normal v2 shape: after[leafKey] is the intended leaf value.
209
+ // Some remediations may include multi-key objects (e.g. {allow, deny}) while the path points at one leaf.
210
+ // In that case, treat the payload as a patch for the parent object and apply per-key deltas.
211
+ if (isPlainObject(check.before) && isPlainObject(check.after)) {
212
+ const afterKeys = Object.keys(check.after);
213
+ const beforeKeys = Object.keys(check.before);
214
+ const looksMultiKey = afterKeys.length > 1 || (afterKeys.length > 0 && !Object.prototype.hasOwnProperty.call(check.after, leafKey));
215
+ if (looksMultiKey) {
216
+ for (const k of afterKeys) {
217
+ const targetPath = parentPath ? `${parentPath}.${k}` : k;
218
+ const curVal = getByPath(configJson, targetPath);
219
+ const bVal = check.before[k];
220
+ const aVal = check.after[k];
221
+ if (isStringArray(bVal) && isStringArray(aVal)) {
222
+ setByPath(configJson, targetPath, applyStringArrayDelta(curVal, bVal, aVal));
223
+ }
224
+ else {
225
+ setByPath(configJson, targetPath, aVal);
226
+ }
227
+ }
228
+ return;
229
+ }
230
+ }
231
+ let value = undefined;
232
+ if (isPlainObject(check.after) && Object.prototype.hasOwnProperty.call(check.after, leafKey)) {
233
+ value = check.after[leafKey];
234
+ }
235
+ else {
236
+ value = check.after;
237
+ }
238
+ // If the leaf is a string[] and we have before/after snapshots, apply delta not replacement.
239
+ if (isPlainObject(check.before) && isPlainObject(check.after)) {
240
+ const b = check.before[leafKey];
241
+ const a = check.after[leafKey];
242
+ if (isStringArray(b) && isStringArray(a)) {
243
+ const cur = getByPath(configJson, check.setting_path);
244
+ setByPath(configJson, check.setting_path, applyStringArrayDelta(cur, b, a));
245
+ return;
246
+ }
247
+ }
248
+ setByPath(configJson, check.setting_path, value);
249
+ }
109
250
  export function enforceRemediation(instruction) {
110
251
  try {
111
252
  const dir = dirname(instruction.config_file_path);
112
253
  if (!existsSync(dir))
113
254
  mkdirSync(dir, { recursive: true, mode: 0o700 });
114
- const content = JSON.stringify(instruction.recommended_settings, null, 2);
255
+ const fixSpec = remediationFixSpec(instruction);
256
+ const checks = fixSpec?.file_format === 'json' ? (fixSpec.checks ?? []) : [];
257
+ if (checks.length === 0) {
258
+ hookRunLog(`remediation_enforce: no checks to apply uuid=${instruction.uuid}`);
259
+ return false;
260
+ }
261
+ let configJson = {};
262
+ if (existsSync(instruction.config_file_path)) {
263
+ try {
264
+ configJson = JSON.parse(readFileSync(instruction.config_file_path, 'utf8'));
265
+ }
266
+ catch {
267
+ hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${instruction.uuid}`);
268
+ }
269
+ }
270
+ for (const check of checks) {
271
+ applyCheck(configJson, check);
272
+ }
273
+ const content = JSON.stringify(configJson, null, 2);
115
274
  const tmp = `${instruction.config_file_path}.tmp`;
116
275
  writeFileSync(tmp, content, 'utf8');
117
276
  renameSync(tmp, instruction.config_file_path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {