log-llm-config 1.1.0 → 1.2.0

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/cli.js CHANGED
File without changes
@@ -0,0 +1,10 @@
1
+ import { runComplianceCheck } from './log_config_files/runtime/compliance_check.js';
2
+ (async () => {
3
+ try {
4
+ await runComplianceCheck();
5
+ }
6
+ catch (err) {
7
+ process.stderr.write(`compliance_check_runner error: ${err instanceof Error ? err.message : String(err)}\n`);
8
+ process.exit(1);
9
+ }
10
+ })();
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Synchronous pre-prompt gate: local compliance only (stdout = single JSON line for IDE hooks).
3
+ * stderr must stay clean; logs go via hookRunLog (file).
4
+ */
5
+ import { applyAutofixViolations, runLocalRemediationComplianceCheck } from './log_config_files/runtime/compliance_check.js';
6
+ import { existsSync, statSync } from 'node:fs';
7
+ import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
8
+ const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
9
+ function parseIde() {
10
+ const eq = process.argv.find((a) => a.startsWith('--ide='));
11
+ if (eq) {
12
+ const v = eq.slice('--ide='.length).toLowerCase();
13
+ return v === 'claude' ? 'claude' : 'cursor';
14
+ }
15
+ if (process.argv.includes('--claude'))
16
+ return 'claude';
17
+ return 'cursor';
18
+ }
19
+ function printAllow(ide) {
20
+ if (ide === 'claude')
21
+ console.log('{}');
22
+ else
23
+ console.log(JSON.stringify({ continue: true }));
24
+ }
25
+ function printAllowWithAdvisory(ide, advisoryMessage) {
26
+ if (ide === 'claude') {
27
+ console.log(JSON.stringify({ __optimus_advisory: true, advisory_message: advisoryMessage }));
28
+ }
29
+ else {
30
+ console.log(JSON.stringify({
31
+ continue: true,
32
+ __optimus_advisory: true,
33
+ advisory_message: advisoryMessage,
34
+ }));
35
+ }
36
+ }
37
+ function blockPayload(ide, violationMessage) {
38
+ const prefix = 'Prompt blocked by Optimus: ';
39
+ const text = prefix + violationMessage;
40
+ if (ide === 'claude') {
41
+ return JSON.stringify({ decision: 'block', reason: text, systemMessage: text });
42
+ }
43
+ return JSON.stringify({ continue: false, user_message: text });
44
+ }
45
+ function getManifestStalenessMs() {
46
+ try {
47
+ const path = getRemediationInstructionsPath();
48
+ if (!existsSync(path))
49
+ return null;
50
+ const st = statSync(path);
51
+ return Date.now() - st.mtimeMs;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ const ide = parseIde();
58
+ async function run() {
59
+ const status = runLocalRemediationComplianceCheck();
60
+ if (status.status === 'fail' && status.violations.length > 0) {
61
+ const staleMs = getManifestStalenessMs();
62
+ if (staleMs !== null && staleMs > MANIFEST_STALE_MS) {
63
+ const staleDays = Math.floor(staleMs / (24 * 60 * 60 * 1000));
64
+ const advisory = `Remediation enforcement suspended: local remediation manifest is stale (${staleDays} days old). ` +
65
+ 'Please reconnect to sync latest policy state.';
66
+ printAllowWithAdvisory(ide, advisory);
67
+ return;
68
+ }
69
+ const { fixed, restartCommands, failedViolations, reportPromises } = applyAutofixViolations(status.violations);
70
+ if (fixed > 0) {
71
+ // Wait for all server reports before exiting so the POST lands.
72
+ await Promise.allSettled(reportPromises);
73
+ const recheck = runLocalRemediationComplianceCheck();
74
+ if (recheck.status === 'ok' || recheck.violations.length === 0) {
75
+ const fixedViolations = status.violations.filter((v) => v.autofix_allowed);
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')}`;
78
+ const payload = { __optimus_autofix: true, autofix_message: autofixMessage };
79
+ if (restartCommands.length > 0)
80
+ payload.restart_commands = restartCommands;
81
+ console.log(JSON.stringify(payload));
82
+ return;
83
+ }
84
+ const msg = recheck.violations[0]?.message ?? 'A security policy violation has been detected.';
85
+ console.log(blockPayload(ide, msg));
86
+ return;
87
+ }
88
+ if (failedViolations.length > 0) {
89
+ const ids = failedViolations.map((v) => `[${v.finding_formatted_id}]`).join(', ');
90
+ const msg = `Auto-fix failed for ${ids} — please fix manually or contact your security team.\n\n${failedViolations[0]?.message ?? ''}`;
91
+ console.log(blockPayload(ide, msg));
92
+ return;
93
+ }
94
+ const msg = status.violations[0]?.message ?? 'A security policy violation has been detected.';
95
+ console.log(blockPayload(ide, msg));
96
+ return;
97
+ }
98
+ printAllow(ide);
99
+ }
100
+ run().catch(() => printAllow(ide)).finally(() => process.exit(0));
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Compliance check: types and check runner entry point.
3
+ *
4
+ * Prompt gate: compliance_prompt_gate runs runLocalRemediationComplianceCheck (local files only).
5
+ * Background: compliance_check_runner runs syncRemediations (network) then the same local check.
6
+ */
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { readRemediationInstructionsFile, writeRemediationInstructionsFile } from './management_storage.js';
9
+ import { hookRunLog } from './hook_logger.js';
10
+ import { loadEndpointBase } from '../sender/endpoint_config.js';
11
+ import { resolveHardwareUuid } from './hardware_uuid.js';
12
+ import { enforceRemediation, fetchSync, reportAutofixApplied, syncRemediations } from './remediation_sync.js';
13
+ import { sendConfigFile } from '../sender/batch_sender.js';
14
+ import { readStoredAuthKey } from '../auth/auth_key_store.js';
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+ /** Traverse a JSON object using dot-notation path. Returns undefined if any segment is missing. */
19
+ function getByPath(obj, path) {
20
+ const parts = path.split('.');
21
+ let current = obj;
22
+ for (const part of parts) {
23
+ if (current == null || typeof current !== 'object')
24
+ return undefined;
25
+ current = current[part];
26
+ }
27
+ return current;
28
+ }
29
+ /** Deep-equal comparison via JSON serialisation (handles booleans, strings, numbers, null). */
30
+ function deepEqual(a, b) {
31
+ return JSON.stringify(a) === JSON.stringify(b);
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Check runner — Section 6: real per-check evaluation
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * 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).
40
+ */
41
+ export function runLocalRemediationComplianceCheck() {
42
+ try {
43
+ const { remediations: rawEntries } = readRemediationInstructionsFile();
44
+ const entries = rawEntries;
45
+ if (entries.length === 0) {
46
+ hookRunLog('compliance_check: no remediation instructions present');
47
+ return { status: 'ok', checked_at: new Date().toISOString(), manifest_uuids: [], violations: [] };
48
+ }
49
+ const uuids = entries.map((e) => e.uuid);
50
+ const violations = [];
51
+ for (const entry of entries) {
52
+ const compliance = entry.compliance;
53
+ if (!compliance)
54
+ continue;
55
+ if (compliance.file_format !== 'json') {
56
+ hookRunLog(`compliance_check: skipping non-json entry uuid=${entry.uuid}`);
57
+ continue;
58
+ }
59
+ const checks = compliance.checks ?? [];
60
+ if (checks.length === 0)
61
+ continue;
62
+ if (!existsSync(entry.config_file_path)) {
63
+ hookRunLog(`compliance_check: config file not found, skipping uuid=${entry.uuid}`);
64
+ continue;
65
+ }
66
+ let configJson;
67
+ try {
68
+ configJson = JSON.parse(readFileSync(entry.config_file_path, 'utf8'));
69
+ }
70
+ catch {
71
+ hookRunLog(`compliance_check: could not parse config file, skipping uuid=${entry.uuid}`);
72
+ continue;
73
+ }
74
+ for (const check of checks) {
75
+ const currentValue = getByPath(configJson, check.setting_path);
76
+ const leafKey = check.setting_path.split('.').pop();
77
+ const expectedValue = check.after[leafKey];
78
+ if (!deepEqual(currentValue, expectedValue)) {
79
+ hookRunLog(`compliance_check: VIOLATION uuid=${entry.uuid} path=${check.setting_path} current=${JSON.stringify(currentValue)} expected=${JSON.stringify(expectedValue)}`);
80
+ violations.push({
81
+ uuid: entry.uuid,
82
+ finding_formatted_id: compliance.finding_formatted_id,
83
+ setting_path: check.setting_path,
84
+ description: check.description,
85
+ severity: compliance.severity,
86
+ autofix_allowed: compliance.autofix_allowed,
87
+ config_file_path: entry.config_file_path,
88
+ expected_value: expectedValue,
89
+ message: `[${compliance.finding_formatted_id}] ${entry.finding_title ?? compliance.description}\nDescription: ${entry.finding_description ?? compliance.description}\nHow to fix: Set ${check.setting_path} to ${JSON.stringify(expectedValue)} in ${entry.config_file_path}`,
90
+ });
91
+ }
92
+ }
93
+ }
94
+ const status = {
95
+ status: violations.length > 0 ? 'fail' : 'ok',
96
+ checked_at: new Date().toISOString(),
97
+ manifest_uuids: uuids,
98
+ violations,
99
+ };
100
+ hookRunLog(`compliance_check: done — status=${status.status} violations=${violations.length} uuids=${uuids.length}`);
101
+ return status;
102
+ }
103
+ catch (err) {
104
+ hookRunLog(`compliance_check: unexpected error: ${err instanceof Error ? err.message : String(err)}`);
105
+ const fallback = {
106
+ status: 'ok',
107
+ checked_at: new Date().toISOString(),
108
+ manifest_uuids: [],
109
+ violations: [],
110
+ };
111
+ return fallback;
112
+ }
113
+ }
114
+ export function applyAutofixViolations(violations) {
115
+ const autofixable = violations.filter((v) => v.autofix_allowed);
116
+ if (autofixable.length === 0)
117
+ return { fixed: 0, restartCommands: [], failedViolations: [], reportPromises: [] };
118
+ const { remediations } = readRemediationInstructionsFile();
119
+ const byUuid = new Map(remediations.map((r) => [r.uuid, r]));
120
+ let fixed = 0;
121
+ const seen = new Set();
122
+ const restartCommands = [];
123
+ const failedViolations = [];
124
+ const reportPromises = [];
125
+ const oneTimeAppliedUuids = new Set();
126
+ for (const violation of autofixable) {
127
+ if (seen.has(violation.uuid))
128
+ continue;
129
+ const instruction = byUuid.get(violation.uuid);
130
+ if (!instruction) {
131
+ hookRunLog(`autofix: no instruction found for uuid=${violation.uuid}, skipping`);
132
+ failedViolations.push(violation);
133
+ continue;
134
+ }
135
+ const inst = instruction;
136
+ const ok = enforceRemediation(inst);
137
+ if (ok) {
138
+ seen.add(violation.uuid);
139
+ fixed++;
140
+ hookRunLog(`autofix: applied uuid=${inst.uuid} path=${inst.config_file_path}`);
141
+ reportPromises.push(reportAutofixApplied(inst.uuid, 'success'));
142
+ if (inst.file_type) {
143
+ const authKey = readStoredAuthKey();
144
+ 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
+ }));
148
+ }
149
+ }
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);
154
+ }
155
+ if (!inst.is_enforced) {
156
+ oneTimeAppliedUuids.add(inst.uuid);
157
+ }
158
+ }
159
+ else {
160
+ failedViolations.push(violation);
161
+ }
162
+ }
163
+ if (oneTimeAppliedUuids.size > 0) {
164
+ const remaining = remediations.filter((r) => !oneTimeAppliedUuids.has(r.uuid));
165
+ writeRemediationInstructionsFile({ remediations: remaining });
166
+ hookRunLog(`autofix: removed ${oneTimeAppliedUuids.size} one-time remediation(s) from local store`);
167
+ // Send a post-autofix heartbeat so the server sees the updated (reduced) UUID set immediately,
168
+ // without waiting for the background runner (which may be locked out).
169
+ const remainingUuids = remaining.map((r) => r.uuid);
170
+ reportPromises.push(fetchSync(loadEndpointBase(), resolveHardwareUuid(), remainingUuids)
171
+ .then(() => hookRunLog(`autofix: post-autofix heartbeat sent uuids=${remainingUuids.length}`))
172
+ .catch(() => undefined));
173
+ }
174
+ if (fixed > 0) {
175
+ hookRunLog(`autofix: total_applied=${fixed}`);
176
+ }
177
+ return { fixed, restartCommands, failedViolations, reportPromises };
178
+ }
179
+ /** Background refresh: server sync for latest instructions, then local evaluation and latch write. */
180
+ export async function runComplianceCheck() {
181
+ try {
182
+ await syncRemediations(loadEndpointBase(), resolveHardwareUuid());
183
+ }
184
+ catch (err) {
185
+ hookRunLog(`compliance_check: remediation_sync unexpected error: ${err instanceof Error ? err.message : String(err)}`);
186
+ }
187
+ runLocalRemediationComplianceCheck();
188
+ }
@@ -84,13 +84,15 @@ async function sendAllConfigFiles(configFiles, hardwareUuid, authKey) {
84
84
  const ok = await sendHookRequestUpdateManifest(hardwareUuid, authKey, hookRequestId, manifest);
85
85
  hookRunLog(`hook-request update manifest result=${ok ? 'ok' : 'fail'}`);
86
86
  }
87
- const exitCode = batchResult.accepted === configFiles.length ? 0 : 1;
87
+ // Exit 0 if anything was persisted so the shell hook keeps last_log and throttles. Exit 1 only when
88
+ // every item failed (e.g. SQLite locked on server) — otherwise partial success used to return 1,
89
+ // the hook deleted last_log, and the next prompt re-uploaded all files every time.
90
+ const exitCode = batchResult.accepted > 0 ? 0 : 1;
88
91
  hookRunLog(`summary: exit=${exitCode} collected=${configFiles.length} sent=${batchResult.accepted} failed=${configFiles.length - batchResult.accepted} hook_id=${hookRequestId ?? 'n/a'}`);
89
92
  hookRunLog(`done`);
90
93
  process.exit(exitCode);
91
94
  }
92
95
  async function main() {
93
- const startMs = Date.now();
94
96
  hookLogReplace();
95
97
  const hardwareUuid = resolveHardwareUuid();
96
98
  const endpointBase = loadEndpointBase();
@@ -105,7 +107,6 @@ async function main() {
105
107
  hookRunLog(`auth: failed ${err instanceof Error ? err.message : String(err)}`);
106
108
  throw err;
107
109
  }
108
- const collectStartMs = Date.now();
109
110
  let configFiles;
110
111
  try {
111
112
  configFiles = await collectAllConfigFiles(endpointBase);
@@ -115,9 +116,8 @@ async function main() {
115
116
  throw err;
116
117
  }
117
118
  await addSensitivePathsAudit(endpointBase, configFiles);
118
- const collectMs = Date.now() - collectStartMs;
119
119
  const fileTypes = [...new Set(configFiles.map((c) => c.file_type))].join(', ');
120
- hookRunLog(`collected ${configFiles.length} config file(s) collect_ms=${collectMs} file_types=${fileTypes}`);
120
+ hookRunLog(`collected ${configFiles.length} config file(s) file_types=${fileTypes}`);
121
121
  const claudeSettings = configFiles.filter((c) => c.file_type === 'claude_settings');
122
122
  if (claudeSettings.length > 0)
123
123
  hookRunLog(`claude_settings in batch: ${claudeSettings.length} path(s): ${claudeSettings.map((c) => c.file_path).join(', ')}`);
@@ -127,8 +127,6 @@ async function main() {
127
127
  hookRunLog('no config files found, exiting');
128
128
  process.exit(0);
129
129
  }
130
- const totalMs = Date.now() - startMs;
131
- hookRunLog(`total setup ms=${totalMs}`);
132
130
  await sendAllConfigFiles(configFiles, hardwareUuid, authKey);
133
131
  }
134
132
  async function logSingleFile(filePath) {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Paths and JSON persistence for ~/opt-ai-sec/management:
3
+ * - remediation_instructions.json (remediations[])
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
9
+ export const REMEDIATION_INSTRUCTIONS_BASENAME = 'remediation_instructions.json';
10
+ export function getManagementDir() {
11
+ return join(homedir(), OPT_AI_SEC_MANAGEMENT_REL);
12
+ }
13
+ export function getRemediationInstructionsPath() {
14
+ return join(getManagementDir(), REMEDIATION_INSTRUCTIONS_BASENAME);
15
+ }
16
+ export function atomicWriteJson(filePath, data) {
17
+ const dir = dirname(filePath);
18
+ if (!existsSync(dir))
19
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
20
+ const tmp = `${filePath}.tmp`;
21
+ writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
22
+ renameSync(tmp, filePath);
23
+ }
24
+ function parseInstructionsRaw(raw) {
25
+ try {
26
+ const parsed = JSON.parse(raw);
27
+ return Array.isArray(parsed.remediations) ? parsed : { remediations: [] };
28
+ }
29
+ catch {
30
+ return { remediations: [] };
31
+ }
32
+ }
33
+ export function writeRemediationInstructionsFile(file) {
34
+ atomicWriteJson(getRemediationInstructionsPath(), file);
35
+ }
36
+ export function readRemediationInstructionsFile() {
37
+ const path = getRemediationInstructionsPath();
38
+ if (!existsSync(path))
39
+ return { remediations: [] };
40
+ try {
41
+ return parseInstructionsRaw(readFileSync(path, 'utf8'));
42
+ }
43
+ catch {
44
+ return { remediations: [] };
45
+ }
46
+ }
@@ -0,0 +1,276 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { executeGet, executeBody } from '../../endpoint_client/http_transport.js';
4
+ import { hookRunLog } from './hook_logger.js';
5
+ import { getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
6
+ import { readStoredAuthKey } from '../auth/auth_key_store.js';
7
+ import { createSignature } from '../sender/signing.js';
8
+ import { loadEndpointBase } from '../sender/endpoint_config.js';
9
+ import { resolveHardwareUuid } from './hardware_uuid.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Persistence (single file: remediation_instructions.json)
12
+ // ---------------------------------------------------------------------------
13
+ function readInstructions() {
14
+ const { remediations } = readRemediationInstructionsFile();
15
+ return { remediations: remediations };
16
+ }
17
+ function writeInstructions(file) {
18
+ writeRemediationInstructionsFile(file);
19
+ }
20
+ /**
21
+ * Merge persisted rows with API overlay; GET manifest for enforced UUIDs still missing rows.
22
+ */
23
+ async function ensureInstructionsForUuids(endpointBase, machineUuid, want, overlayFromApi) {
24
+ const byUuid = new Map();
25
+ for (const inst of readInstructions().remediations) {
26
+ if (want.has(inst.uuid))
27
+ byUuid.set(inst.uuid, inst);
28
+ }
29
+ for (const inst of overlayFromApi ?? []) {
30
+ if (want.has(inst.uuid))
31
+ byUuid.set(inst.uuid, inst);
32
+ }
33
+ const missing = [...want].filter((u) => !byUuid.has(u));
34
+ if (missing.length > 0) {
35
+ try {
36
+ const fetched = await fetchManifest(endpointBase, machineUuid, missing);
37
+ if (fetched) {
38
+ for (const inst of fetched) {
39
+ if (want.has(inst.uuid))
40
+ byUuid.set(inst.uuid, inst);
41
+ }
42
+ }
43
+ }
44
+ catch (err) {
45
+ hookRunLog(`remediation_manifest_backfill_error: ${err instanceof Error ? err.message : String(err)}`);
46
+ }
47
+ const stillMissing = [...want].filter((u) => !byUuid.has(u));
48
+ if (stillMissing.length > 0) {
49
+ hookRunLog(`remediation_manifest_backfill_incomplete: uuids=${stillMissing.join(',')}`);
50
+ }
51
+ }
52
+ const remediations = [...byUuid.values()].sort((a, b) => a.uuid.localeCompare(b.uuid));
53
+ const payload = { remediations };
54
+ const nextJson = JSON.stringify(payload, null, 2);
55
+ const path = getRemediationInstructionsPath();
56
+ let prev = '';
57
+ if (existsSync(path)) {
58
+ try {
59
+ prev = readFileSync(path, 'utf8');
60
+ }
61
+ catch {
62
+ /* ignore */
63
+ }
64
+ }
65
+ if (prev !== nextJson) {
66
+ writeInstructions(payload);
67
+ }
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // API helpers
71
+ // ---------------------------------------------------------------------------
72
+ function resolveOrigin(endpointBase) {
73
+ try {
74
+ return new URL(endpointBase).origin;
75
+ }
76
+ catch {
77
+ return endpointBase.replace(/\/+$/, '');
78
+ }
79
+ }
80
+ export async function fetchSync(endpointBase, machineUuid, activeUuids, timeoutMs = 8000) {
81
+ const uuidsParam = activeUuids.join(',');
82
+ const url = `${resolveOrigin(endpointBase)}/api/findings/remediations/sync/?machine_uuid=${encodeURIComponent(machineUuid)}&active_uuids=${encodeURIComponent(uuidsParam)}`;
83
+ const { statusCode, body } = await executeGet(url, timeoutMs);
84
+ if (statusCode !== 200 || !body)
85
+ return null;
86
+ try {
87
+ return JSON.parse(body);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ async function fetchManifest(endpointBase, machineUuid, uuids, timeoutMs = 8000) {
94
+ const url = `${resolveOrigin(endpointBase)}/api/findings/remediations/manifest/?machine_uuid=${encodeURIComponent(machineUuid)}&uuids=${encodeURIComponent(uuids.join(','))}`;
95
+ const { statusCode, body } = await executeGet(url, timeoutMs);
96
+ if (statusCode !== 200 || !body)
97
+ return null;
98
+ try {
99
+ const parsed = JSON.parse(body);
100
+ return Array.isArray(parsed.remediations) ? parsed.remediations : null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Enforce / remove
108
+ // ---------------------------------------------------------------------------
109
+ export function enforceRemediation(instruction) {
110
+ try {
111
+ const dir = dirname(instruction.config_file_path);
112
+ if (!existsSync(dir))
113
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
114
+ const content = JSON.stringify(instruction.recommended_settings, null, 2);
115
+ const tmp = `${instruction.config_file_path}.tmp`;
116
+ writeFileSync(tmp, content, 'utf8');
117
+ renameSync(tmp, instruction.config_file_path);
118
+ return true;
119
+ }
120
+ catch (err) {
121
+ hookRunLog(`remediation_enforce_error: uuid=${instruction.uuid} err=${err instanceof Error ? err.message : String(err)}`);
122
+ return false;
123
+ }
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Autofix reporting
127
+ // ---------------------------------------------------------------------------
128
+ /**
129
+ * Fire-and-forget: notify the server that this machine applied an autofix for the given
130
+ * remediation UUID. Creates one EnforcementLog row on the server for the audit trail.
131
+ * Never throws — any failure is logged and silently swallowed so it cannot block the
132
+ * autofix flow.
133
+ */
134
+ export function reportAutofixApplied(remediationUuid, result) {
135
+ const authKey = readStoredAuthKey();
136
+ if (!authKey) {
137
+ hookRunLog(`autofix_report: no auth key available, skipping report for uuid=${remediationUuid}`);
138
+ return Promise.resolve();
139
+ }
140
+ const hardwareUuid = resolveHardwareUuid();
141
+ const endpointBase = loadEndpointBase();
142
+ const url = `${resolveOrigin(endpointBase)}/endpoint_security/api/autofix-applied/`;
143
+ const payload = { hardware_uuid: hardwareUuid, remediation_uuid: remediationUuid, result };
144
+ const signature = createSignature(payload, authKey.key);
145
+ const body = JSON.stringify({ ...payload, signature });
146
+ return executeBody(url, 'POST', body, 8000)
147
+ .then(({ statusCode }) => {
148
+ if (statusCode !== 200) {
149
+ hookRunLog(`autofix_report: server returned ${statusCode} for uuid=${remediationUuid}`);
150
+ }
151
+ })
152
+ .catch((err) => {
153
+ hookRunLog(`autofix_report: request failed for uuid=${remediationUuid}: ${err instanceof Error ? err.message : String(err)}`);
154
+ });
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Main entry point
158
+ // ---------------------------------------------------------------------------
159
+ export async function syncRemediations(endpointBase, machineUuid) {
160
+ let remediations = readInstructions().remediations;
161
+ const activeUuids = remediations.map((r) => r.uuid);
162
+ let syncResult;
163
+ try {
164
+ syncResult = await fetchSync(endpointBase, machineUuid, activeUuids);
165
+ }
166
+ catch (err) {
167
+ hookRunLog(`remediation_sync_error: ${err instanceof Error ? err.message : String(err)}`);
168
+ return;
169
+ }
170
+ if (!syncResult) {
171
+ hookRunLog(`remediation_sync: no response from server, skipping`);
172
+ return;
173
+ }
174
+ if (syncResult.status === 'unchanged') {
175
+ hookRunLog(`remediation_sync: unchanged enforced=${activeUuids.length}`);
176
+ const want = new Set(activeUuids);
177
+ const have = new Map(remediations.map((r) => [r.uuid, r]));
178
+ const needBackfill = activeUuids.some((u) => !have.has(u));
179
+ const haveOrphan = remediations.some((r) => !want.has(r.uuid));
180
+ const path = getRemediationInstructionsPath();
181
+ if (activeUuids.length > 0) {
182
+ if (needBackfill || haveOrphan || !existsSync(path)) {
183
+ try {
184
+ await ensureInstructionsForUuids(endpointBase, machineUuid, want, null);
185
+ }
186
+ catch (err) {
187
+ hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
188
+ }
189
+ }
190
+ }
191
+ else if (remediations.length > 0) {
192
+ try {
193
+ await ensureInstructionsForUuids(endpointBase, machineUuid, new Set(), null);
194
+ }
195
+ catch (err) {
196
+ hookRunLog(`remediation_instructions_reconcile_error: ${err instanceof Error ? err.message : String(err)}`);
197
+ }
198
+ }
199
+ return;
200
+ }
201
+ const { added, removed } = syncResult;
202
+ hookRunLog(`remediation_sync: status=updated added=${added.length} removed=${removed.length}`);
203
+ const removedSet = new Set(removed);
204
+ let removedCount = 0;
205
+ remediations = remediations.filter((r) => {
206
+ if (removedSet.has(r.uuid)) {
207
+ removedCount++;
208
+ hookRunLog(`remediation_remove: uuid=${r.uuid} path=${r.config_file_path}`);
209
+ return false;
210
+ }
211
+ return true;
212
+ });
213
+ let manifest = null;
214
+ if (added.length > 0) {
215
+ try {
216
+ manifest = await fetchManifest(endpointBase, machineUuid, added);
217
+ }
218
+ catch (err) {
219
+ hookRunLog(`remediation_manifest_error: ${err instanceof Error ? err.message : String(err)}`);
220
+ if (removedCount > 0) {
221
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
222
+ writeInstructions({ remediations });
223
+ }
224
+ return;
225
+ }
226
+ if (!manifest) {
227
+ hookRunLog(`remediation_sync: manifest fetch failed, skipping`);
228
+ if (removedCount > 0) {
229
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
230
+ writeInstructions({ remediations });
231
+ }
232
+ return;
233
+ }
234
+ }
235
+ const existingUuids = new Set(remediations.map((r) => r.uuid));
236
+ const addedSet = new Set(added);
237
+ const overlay = [];
238
+ for (const instruction of manifest ?? []) {
239
+ if (!addedSet.has(instruction.uuid))
240
+ continue;
241
+ if (existingUuids.has(instruction.uuid)) {
242
+ // Enforced remediations are always in `added`; overwrite local copy with fresh server data.
243
+ const idx = remediations.findIndex((r) => r.uuid === instruction.uuid);
244
+ if (idx >= 0)
245
+ remediations[idx] = instruction;
246
+ }
247
+ else {
248
+ remediations.push(instruction);
249
+ existingUuids.add(instruction.uuid);
250
+ }
251
+ overlay.push(instruction);
252
+ hookRunLog(`remediation_instructions_saved: uuid=${instruction.uuid} path=${instruction.config_file_path}`);
253
+ }
254
+ const addedCount = overlay.length;
255
+ if (removedCount > 0 || addedCount > 0) {
256
+ remediations.sort((a, b) => a.uuid.localeCompare(b.uuid));
257
+ writeInstructions({ remediations });
258
+ const finalWant = new Set(remediations.map((r) => r.uuid));
259
+ try {
260
+ await ensureInstructionsForUuids(endpointBase, machineUuid, finalWant, overlay.length > 0 ? overlay : null);
261
+ }
262
+ catch (err) {
263
+ hookRunLog(`remediation_instructions_persist_error: ${err instanceof Error ? err.message : String(err)}`);
264
+ }
265
+ // Post-sync heartbeat: report the updated UUID set so the server has a before/after pair.
266
+ const finalUuids = remediations.map((r) => r.uuid);
267
+ try {
268
+ await fetchSync(endpointBase, machineUuid, finalUuids);
269
+ hookRunLog(`remediation_sync: post-sync heartbeat reported uuids=${finalUuids.length}`);
270
+ }
271
+ catch {
272
+ // Non-critical — skip silently.
273
+ }
274
+ }
275
+ hookRunLog(`remediation_sync: saved=${addedCount} removed=${removedCount}`);
276
+ }