log-llm-config 1.4.11 → 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.
- package/dist/apply_deferred_vscdb.js +2 -2
- package/dist/bootstrap_constants.js +10 -0
- package/dist/cli.js +31 -1
- package/dist/compliance_check_runner.js +7 -2
- package/dist/compliance_prompt_gate.js +17 -1
- package/dist/log_config_files/auth/auth_flow.js +1 -1
- package/dist/log_config_files/auth/auth_key_store.js +1 -1
- package/dist/log_config_files/collection/directory_collector.js +9 -1
- package/dist/log_config_files/runtime/client_event_reporter.js +337 -0
- package/dist/log_config_files/runtime/compliance_session_log.js +22 -16
- package/dist/log_config_files/runtime/hook_logger.js +33 -20
- package/dist/log_config_files/runtime/log_metadata.js +122 -0
- package/dist/log_config_files/runtime/main_runner.js +23 -5
- package/dist/log_config_files/runtime/management_storage.js +36 -7
- package/dist/log_config_files/runtime/remediation_apply_tracking.js +2 -2
- package/dist/log_config_files/runtime/remediation_sync.js +1 -1
- package/dist/log_config_files/sender/batch_sender.js +1 -1
- package/dist/log_sensitive_paths_audit.js +3 -3
- package/dist/log_uuid/auth_key_store.js +1 -1
- package/dist/log_uuid/startup_sender.js +21 -3
- package/package.json +1 -1
- package/dist/log_config_files/readers/vscdb_item_table_registry.js +0 -82
- package/dist/log_config_files/runtime/secretlint_scan.js +0 -69
- package/dist/post_restart_verify.js +0 -48
|
@@ -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, '
|
|
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, '
|
|
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
|
-
|
|
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, '
|
|
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
|
-
|
|
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() {
|
|
@@ -101,7 +101,15 @@ function collectDirectoryMetadata(t) {
|
|
|
101
101
|
try {
|
|
102
102
|
const dirStat = statSync(t.path);
|
|
103
103
|
const glob = t.dir_glob ?? '*';
|
|
104
|
-
|
|
104
|
+
// A "|"-delimited dir_glob is an ordered priority list: use the newest match of the FIRST
|
|
105
|
+
// glob that matches anything, so e.g. "**/*.jsonl|**/*" prefers a session transcript
|
|
106
|
+
// (events.jsonl) over any other session-state file — even when a non-jsonl file is newer.
|
|
107
|
+
let newest = null;
|
|
108
|
+
for (const g of glob.split('|')) {
|
|
109
|
+
newest = findNewestMatchingFile(t.path, g, 0, METADATA_DIR_SCAN_MAX_DEPTH);
|
|
110
|
+
if (newest)
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
105
113
|
const bestPath = newest?.path ?? t.path;
|
|
106
114
|
const bestMtime = newest?.mtime ?? dirStat.mtime;
|
|
107
115
|
return {
|
|
@@ -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 {
|
|
5
|
-
|
|
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
|
|
9
|
+
const TIER_FAILURES = 'failures';
|
|
9
10
|
const TIER_IN_PROCESS = 'in_process';
|
|
10
|
-
const TIER_DIRS = [TIER_NOOP, TIER_SUCCESS,
|
|
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(),
|
|
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,
|
|
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
|
|
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/
|
|
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/
|
|
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|
|
|
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
|
|
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 !==
|
|
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|
|
|
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) {
|