log-llm-config 1.4.12 → 1.5.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.
@@ -25,13 +25,13 @@ applyDeferredVscdbFromDisk()
25
25
  catch (e) {
26
26
  hookRunLog(`apply_deferred_vscdb: post_apply_verification error: ${e instanceof Error ? e.message : String(e)}`);
27
27
  if (complianceLogPath) {
28
- await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'error');
28
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'failures');
29
29
  }
30
30
  }
31
31
  }
32
32
  else if (complianceLogPath) {
33
33
  hookRunLog('apply_deferred_vscdb: apply failed');
34
- await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'error');
34
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'failures');
35
35
  }
36
36
  process.exit(ok ? 0 : 1);
37
37
  })
@@ -3,3 +3,13 @@
3
3
  * Used by auth, hook logging, and audit output. Do not add agent-specific paths here.
4
4
  */
5
5
  export const OPT_AI_SEC_MANAGEMENT_REL = 'opt-ai-sec/management';
6
+ /**
7
+ * All hook-written log files live under this subdirectory, grouped by type
8
+ * (see LOG_GROUP_* below). Keeping logs in one subtree leaves the management
9
+ * root limited to secrets/identity/state (auth_key, organization-uuid, contracts).
10
+ */
11
+ export const OPT_AI_SEC_LOGS_REL = `${OPT_AI_SEC_MANAGEMENT_REL}/logs`;
12
+ /** Log group subdirectories under {@link OPT_AI_SEC_LOGS_REL}. */
13
+ export const LOG_GROUP_HOOK = 'hook';
14
+ export const LOG_GROUP_COMPLIANCE = 'compliance';
15
+ export const LOG_GROUP_AUDIT = 'audit';
package/dist/cli.js CHANGED
@@ -111,4 +111,34 @@ function parseAndSetLogUuidEnvVars() {
111
111
  if (agentArg)
112
112
  process.env.OPTIMUS_AGENT = agentArg;
113
113
  }
114
- void main();
114
+ /** Map each cli command to its functional unit for failure telemetry. */
115
+ const COMMAND_UNIT = {
116
+ log_uuid: 'auth_version',
117
+ log_config_files: 'inventory',
118
+ log_sensitive_paths_audit: 'inventory',
119
+ compliance_prompt_gate: 'compliance',
120
+ compliance_check_runner: 'compliance',
121
+ 'apply-deferred-vscdb': 'compliance',
122
+ 'execute-trusted-restarts': 'compliance',
123
+ dialog_prefs: 'compliance',
124
+ };
125
+ main().catch(async (err) => {
126
+ // Top-level safety net: any uncaught error in a cli command is reported as a failed
127
+ // FunctionalRun (awaited so the POST lands before exit), then surfaced + non-zero exit.
128
+ try {
129
+ const cmd = args[0] || '';
130
+ const unit = COMMAND_UNIT[cmd] || 'inventory';
131
+ const { reportFunctionalFailureAwait } = await import('./log_config_files/runtime/client_event_reporter.js');
132
+ await reportFunctionalFailureAwait(unit, cmd, err);
133
+ }
134
+ catch {
135
+ /* never let telemetry mask the original error */
136
+ }
137
+ console.error(err instanceof Error ? err.stack || err.message : String(err));
138
+ try {
139
+ process.exit(1);
140
+ }
141
+ catch {
142
+ /* process.exit may be mocked in tests */
143
+ }
144
+ });
@@ -3,6 +3,7 @@ import { complianceRunnerRunnerLine, hookLogAppendSection } from './log_config_f
3
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
+ import { getStoryId, flushBufferedClientEvents, reportFunctionalRun, reportFunctionalFailureAwait, } from './log_config_files/runtime/client_event_reporter.js';
6
7
  function parseAgentArg() {
7
8
  const eq = process.argv.find((a) => a.startsWith('--agent='));
8
9
  if (!eq)
@@ -24,7 +25,6 @@ function parseAgentArg() {
24
25
  complianceLogPath = initComplianceSessionForRunner(resolved);
25
26
  }
26
27
  else {
27
- // Gate may have finalized+moved the session before this background runner started.
28
28
  complianceLogPath = null;
29
29
  }
30
30
  }
@@ -32,9 +32,13 @@ function parseAgentArg() {
32
32
  hookLogAppendSection('compliance_check_runner (background sync + check)');
33
33
  }
34
34
  complianceRunnerRunnerLine('compliance_check_runner: start');
35
+ // Background runner: opportunistically flush any telemetry the (non-blocking) gate buffered.
36
+ getStoryId();
37
+ await flushBufferedClientEvents();
35
38
  try {
36
39
  await runComplianceCheck(parseAgentArg());
37
40
  complianceRunnerRunnerLine('compliance_check_runner: finished ok');
41
+ await reportFunctionalRun({ unit: 'compliance', command: 'compliance_check_runner', outcome: 'ok' });
38
42
  }
39
43
  catch (err) {
40
44
  const detail = err instanceof Error ? err.stack ?? err.message : String(err);
@@ -42,8 +46,9 @@ function parseAgentArg() {
42
46
  complianceRunnerRunnerLine(`compliance_check_runner: stack_or_detail ${detail.slice(0, 4000)}`);
43
47
  process.stderr.write(`compliance_check_runner error: ${err instanceof Error ? err.message : String(err)}\n`);
44
48
  if (complianceLogPath) {
45
- await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'error');
49
+ await finalizeAndUploadComplianceSessionLog(complianceLogPath, 'failures');
46
50
  }
51
+ await reportFunctionalFailureAwait('compliance', 'compliance_check_runner', err);
47
52
  process.exit(1);
48
53
  }
49
54
  if (complianceLogPath) {
@@ -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 { getStoryId, bufferFunctionalRun } from './log_config_files/runtime/client_event_reporter.js';
14
15
  import { finalizeAndUploadComplianceSessionLog, initComplianceSessionForGate, parseComplianceLogArg, } from './log_config_files/runtime/compliance_session_log.js';
15
16
  import { isThisCliModule } from './cli_invocation_match.js';
16
17
  import { ensureAuthentication } from './log_config_files/auth/auth_flow.js';
@@ -188,7 +189,22 @@ async function _uploadSecondaryFile(entry) {
188
189
  * {@link isRunAsCliModule} is false and the gate must be invoked explicitly from cli.ts.
189
190
  */
190
191
  export async function runCompliancePromptGateCli() {
191
- await runCompliancePromptGate().catch(() => printAllow(parseIde()));
192
+ getStoryId();
193
+ try {
194
+ await runCompliancePromptGate();
195
+ // Gate blocks prompt submit — buffer telemetry (no inline POST); the background runner flushes it.
196
+ bufferFunctionalRun({ unit: 'compliance', command: 'compliance_prompt_gate', outcome: 'ok' });
197
+ }
198
+ catch (err) {
199
+ bufferFunctionalRun({
200
+ unit: 'compliance',
201
+ command: 'compliance_prompt_gate',
202
+ outcome: 'failed',
203
+ message: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
204
+ detail: err instanceof Error && err.stack ? err.stack : String(err),
205
+ });
206
+ printAllow(parseIde());
207
+ }
192
208
  }
193
209
  /** Exported for tests; runs when `dist/compliance_prompt_gate.js` is the process entry (argv[1]). */
194
210
  export async function runCompliancePromptGate() {
@@ -4,7 +4,7 @@ import { ensureAuthentication as ensureTofuAuthentication } from '../../tofu.js'
4
4
  import { loadEndpointBase } from '../sender/endpoint_config.js';
5
5
  import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
6
6
  import { hookRunLog } from '../runtime/hook_logger.js';
7
- const AUTH_KEY_RELATIVE_PATH = path.join(OPT_AI_SEC_MANAGEMENT_REL, 'auth_key.txt');
7
+ const AUTH_KEY_RELATIVE_PATH = path.join(OPT_AI_SEC_MANAGEMENT_REL, 'auth', 'auth_key.txt');
8
8
  /** Ensure authentication — verify stored key or request new one via handshake. */
9
9
  async function ensureAuthentication(hardwareUuid, endpointBase) {
10
10
  const key = await ensureTofuAuthentication({
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { getAuthKeyPath as getSharedAuthKeyPath, readStoredAuthKey as readSharedStoredAuthKey, } from '../../tofu.js';
3
3
  import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
4
- const AUTH_KEY_RELATIVE_PATH = path.join(OPT_AI_SEC_MANAGEMENT_REL, 'auth_key.txt');
4
+ const AUTH_KEY_RELATIVE_PATH = path.join(OPT_AI_SEC_MANAGEMENT_REL, 'auth', 'auth_key.txt');
5
5
  const AUTH_KEY_OPTIONS = { authRelativePath: AUTH_KEY_RELATIVE_PATH };
6
6
  /** Return absolute path to auth_key.txt, or null if home dir is unavailable. */
7
7
  function getAuthKeyPath() {
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Fire-and-forget client telemetry → POST /endpoint_security/api/client-event/.
3
+ *
4
+ * Reports HookRun / FunctionalRun / Flow so the backend has visibility into what ran and
5
+ * what failed — even when a unit fails locally before any happy-path object exists.
6
+ *
7
+ * Guarantees the event is never lost AND never breaks the hook:
8
+ * - All calls are best-effort and never throw.
9
+ * - On send failure (backend down / network / no auth) the event is appended to a durable
10
+ * JSONL buffer and flushed on the next successful send (atomic-rename claim, like tool-call).
11
+ * - The backend accepts uuid-keyed reports without a valid signature, so auth failures report too.
12
+ */
13
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, renameSync, unlinkSync, statSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
17
+ import { executeBody } from '../../endpoint_client/http_transport.js';
18
+ import { loadEndpointBase } from '../sender/endpoint_config.js';
19
+ import { createSignature } from '../sender/signing.js';
20
+ import { readStoredAuthKey } from '../auth/auth_key_store.js';
21
+ import { resolveHardwareUuid } from './hardware_uuid.js';
22
+ import { buildLogMetadataObject } from './log_metadata.js';
23
+ const CLIENT_EVENT_PATH = '/endpoint_security/api/client-event/';
24
+ const SEND_TIMEOUT_MS = 4000;
25
+ const BUFFER_BASENAME = 'client_events.jsonl';
26
+ const MAX_BUFFER_BYTES = 1 * 1024 * 1024;
27
+ const MAX_FLUSH_EVENTS = 50;
28
+ function bufferPath() {
29
+ const home = process.env.HOME || process.env.USERPROFILE;
30
+ if (!home)
31
+ return null;
32
+ return path.join(home, OPT_AI_SEC_MANAGEMENT_REL, 'telemetry', BUFFER_BASENAME);
33
+ }
34
+ /** File start-every-prompt overwrites each submit with this prompt's story id (short-lived handoff). */
35
+ function storyFilePath() {
36
+ const home = process.env.HOME || process.env.USERPROFILE;
37
+ if (!home)
38
+ return null;
39
+ return path.join(home, OPT_AI_SEC_MANAGEMENT_REL, 'telemetry', 'current_story.id');
40
+ }
41
+ /** Match shell OPTIMUS_STORY_FILE_MAX_AGE_SEC — ignore stale ids from a prior prompt. */
42
+ const STORY_FILE_MAX_AGE_MS = 180_000;
43
+ function readStoryIdFromFileIfFresh(filePath) {
44
+ try {
45
+ const st = statSync(filePath);
46
+ if (Date.now() - st.mtimeMs > STORY_FILE_MAX_AGE_MS)
47
+ return null;
48
+ const fromFile = readFileSync(filePath, 'utf8').trim();
49
+ return fromFile || null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** Map a functional unit to the shell hook that runs it (every npm command belongs to one hook). */
56
+ const UNIT_HOOK = {
57
+ inventory: 'start_every_prompt',
58
+ auth_version: 'start_every_prompt',
59
+ compliance: 'compliance_check',
60
+ tool_call: 'tool_call',
61
+ install: 'install',
62
+ };
63
+ /**
64
+ * story_id groups all hooks + npm of one prompt submit. Resolution order:
65
+ * 1. OPTIMUS_STORY_ID env (when the hook successfully exported it), then
66
+ * 2. current_story.id if mtime is fresh (same temp handoff the shell hooks use), then
67
+ * 3. a fresh id (standalone / manually-run command).
68
+ * Cached on the env for the rest of this process.
69
+ */
70
+ export function getStoryId() {
71
+ const fromEnv = (process.env.OPTIMUS_STORY_ID || '').trim();
72
+ if (fromEnv)
73
+ return fromEnv;
74
+ const p = storyFilePath();
75
+ if (p) {
76
+ const fromFile = readStoryIdFromFileIfFresh(p);
77
+ if (fromFile) {
78
+ process.env.OPTIMUS_STORY_ID = fromFile;
79
+ return fromFile;
80
+ }
81
+ }
82
+ const id = randomUUID();
83
+ process.env.OPTIMUS_STORY_ID = id;
84
+ return id;
85
+ }
86
+ /** The shell hook this process runs under: env, else derived from the unit. */
87
+ function resolveHookName(unit) {
88
+ const fromEnv = (process.env.OPTIMUS_HOOK_NAME || '').trim();
89
+ if (fromEnv)
90
+ return fromEnv;
91
+ if (unit && UNIT_HOOK[unit])
92
+ return UNIT_HOOK[unit];
93
+ return '';
94
+ }
95
+ /** File written by optimus_hook_begin for this hook invocation (gate + runner share it). */
96
+ function hookRunFilePath() {
97
+ const home = process.env.HOME || process.env.USERPROFILE;
98
+ if (!home)
99
+ return null;
100
+ return path.join(home, OPT_AI_SEC_MANAGEMENT_REL, 'telemetry', 'current_hook_run.id');
101
+ }
102
+ /**
103
+ * hook_run_id: one uuid per shell hook invocation.
104
+ * 1. OPTIMUS_HOOK_RUN_ID env
105
+ * 2. current_hook_run.id (same hook process tree)
106
+ * 3. `${story_id}::${hook_name}` legacy fallback only
107
+ */
108
+ export function getHookRunId(unit) {
109
+ const fromEnv = (process.env.OPTIMUS_HOOK_RUN_ID || '').trim();
110
+ if (fromEnv)
111
+ return fromEnv;
112
+ const hp = hookRunFilePath();
113
+ if (hp) {
114
+ try {
115
+ const fromFile = readFileSync(hp, 'utf8').trim();
116
+ if (fromFile) {
117
+ process.env.OPTIMUS_HOOK_RUN_ID = fromFile;
118
+ return fromFile;
119
+ }
120
+ }
121
+ catch {
122
+ // fall through
123
+ }
124
+ }
125
+ const hook = resolveHookName(unit);
126
+ return hook ? `${getStoryId()}::${hook}` : getStoryId();
127
+ }
128
+ function agentFromEnv() {
129
+ const v = (process.env.OPTIMUS_AGENT || process.env.OPTIMUS_HOOK_TYPE || '').trim().toLowerCase();
130
+ return v || 'unknown';
131
+ }
132
+ function enrich(kind, fields, unit) {
133
+ return {
134
+ kind,
135
+ story_id: fields.story_id ?? getStoryId(),
136
+ hook_run_id: fields.hook_run_id ?? getHookRunId(unit),
137
+ outcome: fields.outcome,
138
+ exit_code: fields.exit_code ?? null,
139
+ message: fields.message ?? '',
140
+ detail: fields.detail ?? '',
141
+ metadata: { ...buildLogMetadataObject(), ...(fields.metadata ?? {}) },
142
+ };
143
+ }
144
+ function appendToBuffer(events) {
145
+ const p = bufferPath();
146
+ if (!p)
147
+ return;
148
+ try {
149
+ const dir = path.dirname(p);
150
+ if (!existsSync(dir))
151
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
152
+ if (existsSync(p) && statSync(p).size > MAX_BUFFER_BYTES)
153
+ return; // cap; drop rather than grow unbounded
154
+ appendFileSync(p, events.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf8');
155
+ }
156
+ catch {
157
+ // best-effort
158
+ }
159
+ }
160
+ /** Atomically claim + read buffered events so a concurrent hook won't double-send. */
161
+ function claimBufferedEvents() {
162
+ const p = bufferPath();
163
+ if (!p || !existsSync(p))
164
+ return [];
165
+ const claimed = `${p}.sending.${process.pid}`;
166
+ try {
167
+ renameSync(p, claimed);
168
+ }
169
+ catch {
170
+ return [];
171
+ }
172
+ try {
173
+ const lines = readFileSync(claimed, 'utf8').split('\n').filter((l) => l.trim());
174
+ unlinkSync(claimed);
175
+ return lines
176
+ .slice(0, MAX_FLUSH_EVENTS)
177
+ .map((l) => {
178
+ try {
179
+ return JSON.parse(l);
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ })
185
+ .filter((e) => e !== null);
186
+ }
187
+ catch {
188
+ return [];
189
+ }
190
+ }
191
+ async function postEvents(events) {
192
+ if (events.length === 0)
193
+ return true;
194
+ let hardwareUuid;
195
+ try {
196
+ hardwareUuid = resolveHardwareUuid();
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ const base = loadEndpointBase().replace(/\/+$/, '');
202
+ const url = `${base}${CLIENT_EVENT_PATH}`;
203
+ const signingPayload = { hardware_uuid: hardwareUuid, events };
204
+ let signature = '';
205
+ try {
206
+ const authKey = readStoredAuthKey();
207
+ if (authKey?.key)
208
+ signature = createSignature(signingPayload, authKey.key);
209
+ }
210
+ catch {
211
+ // unsigned is fine — backend stores signature_valid=false and still records
212
+ }
213
+ const body = JSON.stringify({ ...signingPayload, signature });
214
+ try {
215
+ const res = await executeBody(url, 'POST', body, SEND_TIMEOUT_MS);
216
+ return res.statusCode >= 200 && res.statusCode < 300;
217
+ }
218
+ catch {
219
+ return false;
220
+ }
221
+ }
222
+ /** Core: send these events + opportunistically flush the buffer; buffer on failure. Never throws. */
223
+ async function send(events) {
224
+ try {
225
+ const buffered = claimBufferedEvents();
226
+ const all = [...buffered, ...events];
227
+ const ok = await postEvents(all);
228
+ if (!ok)
229
+ appendToBuffer(all);
230
+ }
231
+ catch {
232
+ appendToBuffer(events);
233
+ }
234
+ }
235
+ /**
236
+ * Each report* returns the send promise but never rejects. Callers on a hot path ignore it
237
+ * (fire-and-forget); callers on a process-exit path `await` it so the POST lands before exit.
238
+ */
239
+ export function reportHookRun(fields) {
240
+ try {
241
+ const hook_name = fields.hook_name;
242
+ return send([
243
+ {
244
+ ...enrich('hook_run', { ...fields, hook_run_id: fields.hook_run_id ?? getHookRunId(undefined) }),
245
+ agent: fields.agent ?? agentFromEnv(),
246
+ hook_name,
247
+ },
248
+ ]);
249
+ }
250
+ catch {
251
+ return Promise.resolve();
252
+ }
253
+ }
254
+ export function reportFunctionalRun(fields) {
255
+ try {
256
+ return send([
257
+ {
258
+ ...enrich('functional_run', fields, fields.unit),
259
+ unit: fields.unit,
260
+ command: fields.command ?? '',
261
+ hook_name: fields.hook_name ?? resolveHookName(fields.unit),
262
+ log_text: fields.log_text ?? '',
263
+ },
264
+ ]);
265
+ }
266
+ catch {
267
+ return Promise.resolve();
268
+ }
269
+ }
270
+ /**
271
+ * Append a functional run to the durable buffer WITHOUT an inline POST. For latency-critical
272
+ * paths (the compliance gate blocks prompt submit) — the next background run flushes it.
273
+ */
274
+ export function bufferFunctionalRun(fields) {
275
+ try {
276
+ appendToBuffer([
277
+ {
278
+ ...enrich('functional_run', fields, fields.unit),
279
+ unit: fields.unit,
280
+ command: fields.command ?? '',
281
+ hook_name: fields.hook_name ?? resolveHookName(fields.unit),
282
+ log_text: fields.log_text ?? '',
283
+ },
284
+ ]);
285
+ }
286
+ catch {
287
+ /* never throw */
288
+ }
289
+ }
290
+ /** Convenience for catch blocks: report a functional-unit failure with stack. */
291
+ export function reportFunctionalFailure(unit, command, err, extra) {
292
+ const message = extra?.message ?? (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
293
+ const detail = err instanceof Error && err.stack ? err.stack : String(err);
294
+ reportFunctionalRun({
295
+ unit,
296
+ command,
297
+ outcome: 'failed',
298
+ message,
299
+ detail,
300
+ exit_code: extra?.exit_code ?? null,
301
+ });
302
+ }
303
+ /**
304
+ * Awaitable failure report for the fatal-exit path (so the POST completes before process.exit).
305
+ * Falls back to the durable buffer if the send fails. Never throws.
306
+ */
307
+ export async function reportFunctionalFailureAwait(unit, command, err, extra) {
308
+ try {
309
+ const message = extra?.message ?? (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
310
+ const detail = err instanceof Error && err.stack ? err.stack : String(err);
311
+ const event = {
312
+ ...enrich('functional_run', { outcome: 'failed', message, detail, exit_code: extra?.exit_code ?? null }, unit),
313
+ unit,
314
+ command,
315
+ hook_name: resolveHookName(unit),
316
+ log_text: '',
317
+ };
318
+ await send([event]);
319
+ }
320
+ catch {
321
+ /* never throw on the exit path */
322
+ }
323
+ }
324
+ /** Flush any buffered events (call opportunistically at the start of a run). Never throws. */
325
+ export async function flushBufferedClientEvents() {
326
+ try {
327
+ const buffered = claimBufferedEvents();
328
+ if (buffered.length === 0)
329
+ return;
330
+ const ok = await postEvents(buffered);
331
+ if (!ok)
332
+ appendToBuffer(buffered);
333
+ }
334
+ catch {
335
+ /* best-effort */
336
+ }
337
+ }
@@ -1,13 +1,16 @@
1
1
  import { existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, appendFileSync, readFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
5
- const COMPLIANCE_SUBDIR = 'compliance';
4
+ import { OPT_AI_SEC_LOGS_REL, LOG_GROUP_COMPLIANCE } from '../../bootstrap_constants.js';
5
+ import { buildLogMetadataBlock } from './log_metadata.js';
6
+ const COMPLIANCE_SUBDIR = LOG_GROUP_COMPLIANCE;
6
7
  const TIER_NOOP = 'noop';
7
8
  const TIER_SUCCESS = 'success';
8
- const TIER_ERROR = 'error';
9
+ const TIER_FAILURES = 'failures';
9
10
  const TIER_IN_PROCESS = 'in_process';
10
- const TIER_DIRS = [TIER_NOOP, TIER_SUCCESS, TIER_ERROR];
11
+ const TIER_DIRS = [TIER_NOOP, TIER_SUCCESS, TIER_FAILURES];
12
+ /** Server upload contract still uses "error" for failed remediation sessions. */
13
+ const UPLOAD_OUTCOME_ERROR = 'error';
11
14
  const NOOP_MAX_AGE_MS = 30 * 60 * 1000;
12
15
  const SUCCESS_ERROR_MAX_AGE_MS = 24 * 60 * 60 * 1000;
13
16
  /** In-flight logs in compliance/in_process/ before finalize. */
@@ -32,7 +35,7 @@ export function isFinalizeComplianceSessionArgv(argv) {
32
35
  return argv.includes('--finalize-compliance-session');
33
36
  }
34
37
  export function getComplianceLogDir() {
35
- return path.join(homedir(), OPT_AI_SEC_MANAGEMENT_REL, COMPLIANCE_SUBDIR);
38
+ return path.join(homedir(), OPT_AI_SEC_LOGS_REL, COMPLIANCE_SUBDIR);
36
39
  }
37
40
  export function getActiveComplianceLogPath() {
38
41
  return activeComplianceLogPath;
@@ -109,7 +112,7 @@ export function pruneComplianceSessionLogs(exceptPath) {
109
112
  let removed = 0;
110
113
  removed += pruneLogFilesInDir(path.join(base, TIER_NOOP), NOOP_MAX_AGE_MS, keep);
111
114
  removed += pruneLogFilesInDir(path.join(base, TIER_SUCCESS), SUCCESS_ERROR_MAX_AGE_MS, keep);
112
- removed += pruneLogFilesInDir(path.join(base, TIER_ERROR), SUCCESS_ERROR_MAX_AGE_MS, keep);
115
+ removed += pruneLogFilesInDir(path.join(base, TIER_FAILURES), SUCCESS_ERROR_MAX_AGE_MS, keep);
113
116
  removed += pruneLogFilesInDir(path.join(base, TIER_IN_PROCESS), INFLIGHT_MAX_AGE_MS, keep);
114
117
  if (!existsSync(base))
115
118
  return removed;
@@ -137,7 +140,7 @@ export function pruneComplianceSessionLogs(exceptPath) {
137
140
  /**
138
141
  * Classify a compliance session for finalize + optional server upload.
139
142
  *
140
- * Only two outcomes are uploaded (success / error): remediation **succeeded** or **failed**.
143
+ * Only two outcomes are uploaded (success / error on the wire): remediation **succeeded** or **failed**.
141
144
  * Policy violations, in-flight restart, and sync-only sessions are noop (local retention only).
142
145
  */
143
146
  export function classifyComplianceSessionLog(content) {
@@ -155,7 +158,7 @@ export function classifyComplianceSessionLog(content) {
155
158
  /enforceRemediation.*failed/i,
156
159
  ];
157
160
  if (remediationFailedPatterns.some((p) => p.test(text))) {
158
- return TIER_ERROR;
161
+ return TIER_FAILURES;
159
162
  }
160
163
  return TIER_NOOP;
161
164
  }
@@ -212,7 +215,7 @@ export function complianceLogSessionBanner(logPath, label, mode) {
212
215
  if (!existsSync(dir))
213
216
  mkdirSync(dir, { recursive: true, mode: 0o700 });
214
217
  if (mode === 'replace') {
215
- writeFileSync(resolved, banner, 'utf8');
218
+ writeFileSync(resolved, banner + buildLogMetadataBlock(), 'utf8');
216
219
  }
217
220
  else {
218
221
  appendFileSync(resolved, banner, 'utf8');
@@ -235,7 +238,7 @@ export function initComplianceSessionForGate(logPath) {
235
238
  writeLatestPointer(resolved);
236
239
  }
237
240
  if (removed > 0) {
238
- appendComplianceLine(resolved, `${new Date().toISOString()} compliance_session: pruned ${removed} log(s) (in_process 2h, noop 30m, success/error 24h)\n`);
241
+ appendComplianceLine(resolved, `${new Date().toISOString()} compliance_session: pruned ${removed} log(s) (in_process 2h, noop 30m, success/failures 24h)\n`);
239
242
  }
240
243
  return resolved;
241
244
  }
@@ -247,12 +250,12 @@ export function initComplianceSessionForRunner(logPath) {
247
250
  setActiveComplianceLogPath(resolved);
248
251
  complianceLogSessionBanner(resolved, 'compliance_check_runner (background sync + check)', 'append');
249
252
  if (removed > 0) {
250
- appendComplianceLine(resolved, `${new Date().toISOString()} compliance_session: pruned ${removed} log(s) (in_process 2h, noop 30m, success/error 24h)\n`);
253
+ appendComplianceLine(resolved, `${new Date().toISOString()} compliance_session: pruned ${removed} log(s) (in_process 2h, noop 30m, success/failures 24h)\n`);
251
254
  }
252
255
  return resolved;
253
256
  }
254
257
  /**
255
- * Move session log from compliance/ root into noop|success|error/ and prune tiers.
258
+ * Move session log from compliance/ root into noop|success|failures/ and prune tiers.
256
259
  * No-op if file missing or already finalized.
257
260
  */
258
261
  export function finalizeComplianceSessionLog(logPath, forcedTier) {
@@ -263,6 +266,9 @@ export function finalizeComplianceSessionLog(logPath, forcedTier) {
263
266
  if (isTierDirName(parentName)) {
264
267
  return { tier: parentName, logPath: resolved, moved: false };
265
268
  }
269
+ if (parentName === 'error') {
270
+ return { tier: TIER_FAILURES, logPath: resolved, moved: false };
271
+ }
266
272
  const content = readFileSync(resolved, 'utf8');
267
273
  if (isRemediationCycleInflight(content)) {
268
274
  return null;
@@ -310,10 +316,10 @@ function appendLineToSessionLogFile(logPath, message) {
310
316
  }
311
317
  }
312
318
  /**
313
- * Upload full session log to the server (success and error tiers only, first finalize only).
319
+ * Upload full session log to the server (success and failures tiers only, first finalize only).
314
320
  */
315
321
  export async function uploadComplianceSessionLogFromFinalize(result) {
316
- if (result.tier !== TIER_SUCCESS && result.tier !== TIER_ERROR)
322
+ if (result.tier !== TIER_SUCCESS && result.tier !== TIER_FAILURES)
317
323
  return;
318
324
  if (!result.moved)
319
325
  return;
@@ -338,7 +344,7 @@ export async function uploadComplianceSessionLogFromFinalize(result) {
338
344
  return;
339
345
  }
340
346
  const sessionFilename = path.basename(result.logPath);
341
- const outcome = result.tier;
347
+ const outcome = result.tier === TIER_FAILURES ? UPLOAD_OUTCOME_ERROR : result.tier;
342
348
  const signingPayload = {
343
349
  hardware_uuid: hardwareUuid,
344
350
  outcome,
@@ -362,7 +368,7 @@ export async function uploadComplianceSessionLogFromFinalize(result) {
362
368
  appendLineToSessionLogFile(result.logPath, `compliance_session_upload: failed ${msg}`);
363
369
  }
364
370
  }
365
- /** Finalize session file into success|error|noop tier, then upload when success or error. */
371
+ /** Finalize session file into success|failures|noop tier, then upload when success or failures. */
366
372
  export async function finalizeAndUploadComplianceSessionLog(logPath, forcedTier) {
367
373
  const result = finalizeComplianceSessionLog(logPath, forcedTier);
368
374
  if (result) {