log-llm-config 1.5.14 → 1.5.18

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.
@@ -119,20 +119,20 @@ function autofixDialogLine(v) {
119
119
  const short = d.length > 160 ? `${d.slice(0, 157)}…` : d;
120
120
  return `• [${v.finding_formatted_id}] ${short}`;
121
121
  }
122
- /** Policy name(s) for preventive enforcement dialog copy. */
123
- function uniquePolicyLabelsFromViolations(appliedViolations) {
124
- const policyNames = [
122
+ /** Ruleset name(s) for preventive enforcement dialog copy. */
123
+ function uniqueRulesetLabelsFromViolations(appliedViolations) {
124
+ const rulesetNames = [
125
125
  ...new Set(appliedViolations
126
- .map((v) => v.policy_name?.trim() || v.finding_title?.trim() || '')
126
+ .map((v) => v.ruleset_name?.trim() || v.finding_title?.trim() || '')
127
127
  .filter(Boolean)),
128
128
  ];
129
- return policyNames.length > 0 ? policyNames.join('\n\n') : 'Agent security policy';
129
+ return rulesetNames.length > 0 ? rulesetNames.join('\n\n') : 'Agent security policy';
130
130
  }
131
131
  function formatPreventiveAutofixDialog(appliedViolations, applyLine) {
132
132
  const changePreview = formatRemediationChangePreviewForApplied(appliedViolations);
133
133
  const changePreviewBlock = changePreview ? `${changePreview}\n\n` : '';
134
134
  return ('Optimus Labs enforced a preventive agent security policy as determined by your security team:\n\n' +
135
- `${uniquePolicyLabelsFromViolations(appliedViolations)}\n\n` +
135
+ `${uniqueRulesetLabelsFromViolations(appliedViolations)}\n\n` +
136
136
  changePreviewBlock +
137
137
  `${applyLine}\n\n` +
138
138
  'Click OK to continue');
@@ -339,7 +339,7 @@ export async function runCompliancePromptGate() {
339
339
  ? formatCopilotAutofixDialog(appliedViolations)
340
340
  : ide === 'opencode'
341
341
  ? formatOpenCodeAutofixDialog(appliedViolations)
342
- : `Optimus Labs auto-fixed ${fixed} ${fixed === 1 ? 'policy violation' : 'policy violations'}:\n\n${appliedViolations
342
+ : `Optimus Labs auto-fixed ${fixed} ${fixed === 1 ? 'ruleset violation' : 'ruleset violations'}:\n\n${appliedViolations
343
343
  .map((v) => autofixDialogLine(v))
344
344
  .join('\n')}${changePreviewSuffix}`;
345
345
  const payload = { __optimus_autofix: true, autofix_message: autofixMessage };
@@ -349,7 +349,7 @@ export async function runCompliancePromptGate() {
349
349
  // Restarts: always executed by optimus-compliance-check.sh via execute_trusted_restarts (TS allowlist + spawn).
350
350
  return;
351
351
  }
352
- const msg = recheck.violations[0]?.message ?? 'A security policy violation has been detected.';
352
+ const msg = recheck.violations[0]?.message ?? 'A security ruleset violation has been detected.';
353
353
  logRemediationApplyFailure('prompt_gate_block_recheck_failed', {
354
354
  reason: 'after autofix, local compliance recheck still reports violations',
355
355
  ide,
@@ -372,7 +372,7 @@ export async function runCompliancePromptGate() {
372
372
  console.log(blockPayload(ide, msg));
373
373
  return;
374
374
  }
375
- const msg = status.violations[0]?.message ?? 'A security policy violation has been detected.';
375
+ const msg = status.violations[0]?.message ?? 'A security ruleset violation has been detected.';
376
376
  logRemediationApplyFailure('prompt_gate_block_violations_no_autofix_applied', {
377
377
  reason: 'violations on submit but autofix did not succeed — see autofix_skipped_not_allowed, enforceRemediation, or compliance_check entries',
378
378
  ide,
@@ -0,0 +1,25 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ /** macOS ships gzip at a fixed path; call it directly so a modified PATH cannot redirect us. */
3
+ const GZIP_BIN = '/usr/bin/gzip';
4
+ const GZIP_TIMEOUT_MS = 10_000;
5
+ const GZIP_MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
6
+ /**
7
+ * Compress a request body with the system gzip. Output is gzip-framed, so it pairs with
8
+ * `Content-Encoding: gzip`. Throws if gzip is missing, times out, or exits non-zero.
9
+ */
10
+ export function gzipPayload(payload) {
11
+ const child = spawnSync(GZIP_BIN, ['-c'], {
12
+ input: Buffer.from(payload, 'utf8'),
13
+ timeout: GZIP_TIMEOUT_MS,
14
+ maxBuffer: GZIP_MAX_OUTPUT_BYTES,
15
+ });
16
+ if (child.error)
17
+ throw child.error;
18
+ if (child.status !== 0) {
19
+ const stderr = (child.stderr ?? '').toString().trim();
20
+ throw new Error(stderr || `${GZIP_BIN} exited ${child.status ?? 'unknown'}`);
21
+ }
22
+ if (!child.stdout || child.stdout.length === 0)
23
+ throw new Error(`${GZIP_BIN} produced no output`);
24
+ return child.stdout;
25
+ }
@@ -25,6 +25,8 @@ function buildBodyOptions(url, payload, method, timeoutMs) {
25
25
  headers: {
26
26
  'Content-Type': 'application/json',
27
27
  'Content-Length': Buffer.byteLength(payload).toString(),
28
+ // A Buffer body is always gzip output from compress_payload.gzipPayload.
29
+ ...(Buffer.isBuffer(payload) ? { 'Content-Encoding': 'gzip' } : {}),
28
30
  },
29
31
  timeout: timeoutMs,
30
32
  };
@@ -46,7 +48,7 @@ export function executeGet(urlStr, timeoutMs) {
46
48
  req.end();
47
49
  });
48
50
  }
49
- /** Execute a POST/PATCH request. Rejects on network error or timeout. */
51
+ /** Execute a POST/PATCH request. A Buffer payload is sent as `Content-Encoding: gzip`. Rejects on network error or timeout. */
50
52
  export function executeBody(urlStr, method, payload, timeoutMs) {
51
53
  const url = new URL(urlStr);
52
54
  const options = buildBodyOptions(url, payload, method, timeoutMs);
@@ -1,6 +1,7 @@
1
1
  import { executeBody } from './http_transport.js';
2
+ import { gzipPayload } from './compress_payload.js';
2
3
  export const postStartupPayload = async (endpointUrl, body, timeoutMs = 5000) => {
3
- const payload = JSON.stringify(body);
4
+ const payload = gzipPayload(JSON.stringify(body));
4
5
  const { statusCode, statusMessage, body: responseBody } = await executeBody(endpointUrl, 'POST', payload, timeoutMs);
5
6
  if (statusCode >= 400) {
6
7
  const msg = statusCode === 413
@@ -18,7 +19,7 @@ export const postStartupPayload = async (endpointUrl, body, timeoutMs = 5000) =>
18
19
  }
19
20
  };
20
21
  export const patchPayload = async (endpointUrl, body, timeoutMs = 10000) => {
21
- const payload = JSON.stringify(body);
22
+ const payload = gzipPayload(JSON.stringify(body));
22
23
  const { body: responseBody } = await executeBody(endpointUrl, 'PATCH', payload, timeoutMs);
23
24
  if (!responseBody)
24
25
  return { status: 'error', error: 'empty_response' };
@@ -1,10 +1,20 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
4
+ import { collectSkillMdRecord, isSkillDirEntry } from './skill_symlink.js';
4
5
  function collectSubdirMdFiles(dirPath, fileType, mdFilename, source) {
5
6
  const results = [];
7
+ const isSkillMd = mdFilename.toLowerCase() === 'skill.md';
6
8
  try {
7
9
  for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
10
+ if (isSkillMd) {
11
+ if (!isSkillDirEntry(entry))
12
+ continue;
13
+ const record = collectSkillMdRecord(join(dirPath, entry.name), fileType, source, mdFilename);
14
+ if (record)
15
+ results.push(record);
16
+ continue;
17
+ }
8
18
  if (!entry.isDirectory())
9
19
  continue;
10
20
  const mdPath = join(dirPath, entry.name, mdFilename);
@@ -1,20 +1,121 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { createHash } from 'node:crypto';
4
5
  import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
5
6
  import { getInstalledPluginsPath, getPluginHooksPath, getPluginMcpPaths, getPluginSkillFilename, getPluginSkillsDir } from '../paths/path_constants_helpers.js';
6
7
  import { versionFromPluginCachePath, parseInstalledPluginKey } from './plugin_version_helpers.js';
8
+ import { collectSkillMdRecord, isSkillDirEntry, symlinkInfoAllowsExtraFiles } from './skill_symlink.js';
9
+ /** Directories never worth shipping: git history is huge and already-compressed, deps are not the skill. */
10
+ const SKILL_SKIP_DIRS = new Set(['.git', 'node_modules']);
11
+ /** Fonts are the single largest thing in skill folders (canvas-design ships 54) and carry no signal. */
12
+ const SKILL_SKIP_EXTS = new Set(['.ttf', '.otf', '.woff', '.woff2']);
13
+ /** Inlined as text; everything else is recorded as path + size + hash. */
14
+ const SKILL_TEXT_EXTS = new Set([
15
+ '.md', '.txt', '.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonc',
16
+ '.sh', '.bash', '.zsh', '.yaml', '.yml', '.xml', '.xsd', '.html', '.css', '.toml',
17
+ '.lock', '.csv', '.sql', '.rb', '.go', '.java', '.php', '.rs',
18
+ ]);
19
+ const SKILL_MAX_FILE_BYTES = 1024 * 1024;
20
+ const SKILL_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
21
+ const SKILL_MAX_FILES = 500;
22
+ const SKILL_MAX_DEPTH = 8;
23
+ /** Hidden files are collected, but credential-shaped ones are recorded without their contents. */
24
+ function isSecretName(name) {
25
+ const lower = name.toLowerCase();
26
+ return lower === '.env' || lower.startsWith('.env.') || lower === '.npmrc' || lower === '.netrc'
27
+ || lower === '.pgpass' || lower.endsWith('.pem') || lower.endsWith('.key');
28
+ }
29
+ function skillMetaRecord(filePath, size, sha256, ver) {
30
+ return {
31
+ file_type: 'claude_skill',
32
+ file_path: filePath,
33
+ collect_style: 'metadata',
34
+ raw_content: { source: 'file', size, ...(sha256 ? { sha256 } : {}), ...(ver ? { version: ver } : {}) },
35
+ };
36
+ }
37
+ function collectSkillDirFiles(dir, results, constants, skillFilename, budget, depth) {
38
+ if (depth > SKILL_MAX_DEPTH)
39
+ return;
40
+ let entries;
41
+ try {
42
+ entries = readdirSync(dir, { withFileTypes: true });
43
+ }
44
+ catch (err) {
45
+ console.warn(`Error reading skill dir ${dir}:`, err instanceof Error ? err.message : String(err));
46
+ return;
47
+ }
48
+ for (const ent of entries) {
49
+ if (budget.files >= SKILL_MAX_FILES || budget.bytes >= SKILL_MAX_TOTAL_BYTES)
50
+ return;
51
+ const full = join(dir, ent.name);
52
+ if (ent.isDirectory()) {
53
+ if (!SKILL_SKIP_DIRS.has(ent.name))
54
+ collectSkillDirFiles(full, results, constants, skillFilename, budget, depth + 1);
55
+ continue;
56
+ }
57
+ if (ent.name === skillFilename)
58
+ continue;
59
+ // Extra skill files: skip nested symlinks so the walk stays inside this skill dir.
60
+ if (!ent.isFile())
61
+ continue;
62
+ const ext = extname(ent.name).toLowerCase();
63
+ if (SKILL_SKIP_EXTS.has(ext))
64
+ continue;
65
+ let size;
66
+ try {
67
+ size = statSync(full).size;
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ const ver = versionFromPluginCachePath(full, constants);
73
+ if (size > SKILL_MAX_FILE_BYTES) {
74
+ results.push(skillMetaRecord(full, size, '', ver));
75
+ budget.files += 1;
76
+ continue;
77
+ }
78
+ if (!SKILL_TEXT_EXTS.has(ext) && ent.name !== skillFilename) {
79
+ let sha256 = '';
80
+ try {
81
+ sha256 = createHash('sha256').update(readFileSync(full)).digest('hex');
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ results.push(skillMetaRecord(full, size, sha256, ver));
87
+ budget.files += 1;
88
+ continue;
89
+ }
90
+ if (isSecretName(ent.name)) {
91
+ results.push(skillMetaRecord(full, size, '', ver));
92
+ budget.files += 1;
93
+ continue;
94
+ }
95
+ const content = readMarkdownFile(full);
96
+ if (content === null)
97
+ continue;
98
+ results.push({ file_type: 'claude_skill', file_path: full, raw_content: { content, source: 'file', ...(ver ? { version: ver } : {}) } });
99
+ budget.files += 1;
100
+ budget.bytes += Buffer.byteLength(content);
101
+ }
102
+ }
7
103
  function collectSkillFiles(skillsDir, results, constants) {
8
104
  const skillFilename = getPluginSkillFilename(constants);
9
105
  try {
10
- for (const d of readdirSync(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory())) {
11
- const skillPath = join(skillsDir, d.name, skillFilename);
12
- if (!existsSync(skillPath))
106
+ for (const d of readdirSync(skillsDir, { withFileTypes: true }).filter((d) => isSkillDirEntry(d))) {
107
+ const skillRoot = join(skillsDir, d.name);
108
+ const ver = versionFromPluginCachePath(join(skillRoot, skillFilename), constants);
109
+ const record = collectSkillMdRecord(skillRoot, 'claude_skill', 'file', skillFilename, ver ? { version: ver } : {});
110
+ if (!record)
13
111
  continue;
14
- const content = readMarkdownFile(skillPath);
15
- if (content !== null) {
16
- const ver = versionFromPluginCachePath(skillPath, constants);
17
- results.push({ file_type: 'claude_skill', file_path: skillPath, raw_content: { content, source: 'file', ...(ver ? { version: ver } : {}) } });
112
+ results.push(record);
113
+ if (!symlinkInfoAllowsExtraFiles(record.symlink_info))
114
+ continue;
115
+ const budget = { files: 1, bytes: 0 };
116
+ collectSkillDirFiles(skillRoot, results, constants, skillFilename, budget, 0);
117
+ if (budget.files >= SKILL_MAX_FILES || budget.bytes >= SKILL_MAX_TOTAL_BYTES) {
118
+ console.warn(`Skill ${skillRoot} hit collection cap (${budget.files} files, ${budget.bytes} bytes)`);
18
119
  }
19
120
  }
20
121
  }
@@ -0,0 +1,191 @@
1
+ import { accessSync, constants, lstatSync, readlinkSync, realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { readMarkdownFile } from '../readers/file_readers.js';
4
+ function isMissingPathError(err) {
5
+ const code = err?.code;
6
+ return code === 'ENOENT' || code === 'ENOTDIR';
7
+ }
8
+ /**
9
+ * Resolve readlink's raw target the way the OS does when parent dirs include
10
+ * symlinks. A lexical join against the logical parent is wrong when that parent
11
+ * is itself a link (e.g. ~/.config → a dotfiles repo).
12
+ */
13
+ export function resolveRawSymlinkTarget(linkPath, target) {
14
+ if (isAbsolute(target))
15
+ return resolve(target);
16
+ const physicalParent = realpathSync(dirname(linkPath));
17
+ return resolve(physicalParent, target);
18
+ }
19
+ function probeAccess(path) {
20
+ try {
21
+ accessSync(path, constants.F_OK);
22
+ return 'valid';
23
+ }
24
+ catch (err) {
25
+ return isMissingPathError(err) ? 'broken' : 'inaccessible';
26
+ }
27
+ }
28
+ function readResolvedSymlinkTarget(linkPath) {
29
+ try {
30
+ const raw = readlinkSync(linkPath);
31
+ const target = resolveRawSymlinkTarget(linkPath, raw);
32
+ return { target, status: probeAccess(target) };
33
+ }
34
+ catch (err) {
35
+ if (err?.code === 'EINVAL') {
36
+ return { target: null, status: 'valid' };
37
+ }
38
+ return { target: null, status: isMissingPathError(err) ? 'broken' : 'inaccessible' };
39
+ }
40
+ }
41
+ function asOverall(status) {
42
+ return status === 'missing' ? 'broken' : status;
43
+ }
44
+ function worseStatus(current, next) {
45
+ const rank = { valid: 0, broken: 1, inaccessible: 2 };
46
+ return rank[next] > rank[current] ? next : current;
47
+ }
48
+ function emptyPath(path, status = 'valid') {
49
+ return { path, is_symlink: false, points_to: null, status };
50
+ }
51
+ /** True for a skills-dir child that is a real folder or a symlink slot. */
52
+ export function isSkillDirEntry(entry) {
53
+ return entry.isDirectory() || Boolean(entry.isSymbolicLink?.());
54
+ }
55
+ /**
56
+ * Inspect one agent skill slot: the folder (local vs symlink) and SKILL.md inside it.
57
+ */
58
+ export function inspectSkillLink(skillDir, skillFilename = 'SKILL.md') {
59
+ const skillMdPath = join(skillDir, skillFilename);
60
+ const skill_folder = emptyPath(skillDir);
61
+ const skill_md = emptyPath(skillMdPath);
62
+ try {
63
+ const dirStat = lstatSync(skillDir);
64
+ if (dirStat.isSymbolicLink()) {
65
+ skill_folder.is_symlink = true;
66
+ const resolved = readResolvedSymlinkTarget(skillDir);
67
+ skill_folder.points_to = resolved.target;
68
+ skill_folder.status = resolved.status;
69
+ }
70
+ else if (!dirStat.isDirectory()) {
71
+ skill_folder.status = 'broken';
72
+ }
73
+ }
74
+ catch (err) {
75
+ skill_folder.status = isMissingPathError(err) ? 'broken' : 'inaccessible';
76
+ skill_md.status = 'missing';
77
+ return {
78
+ status: asOverall(skill_folder.status),
79
+ skill_folder,
80
+ skill_md,
81
+ };
82
+ }
83
+ try {
84
+ const mdStat = lstatSync(skillMdPath);
85
+ if (mdStat.isSymbolicLink()) {
86
+ skill_md.is_symlink = true;
87
+ const resolved = readResolvedSymlinkTarget(skillMdPath);
88
+ skill_md.points_to = resolved.target;
89
+ skill_md.status = resolved.status;
90
+ }
91
+ }
92
+ catch (err) {
93
+ skill_md.status = isMissingPathError(err) ? 'missing' : 'inaccessible';
94
+ }
95
+ return {
96
+ status: worseStatus(asOverall(skill_folder.status), asOverall(skill_md.status)),
97
+ skill_folder,
98
+ skill_md,
99
+ };
100
+ }
101
+ function symlinkEntry(info) {
102
+ return {
103
+ path: info.path,
104
+ points_to: info.points_to,
105
+ status: info.status === 'missing' ? 'broken' : info.status,
106
+ };
107
+ }
108
+ /** Only include the path that is actually a symlink. No symlink → `{ symlink_enabled: false }`. */
109
+ export function skillLinkPayload(info) {
110
+ const symlink_info = { symlink_enabled: false };
111
+ if (info.skill_folder.is_symlink) {
112
+ symlink_info.symlink_enabled = true;
113
+ symlink_info.skill_folder = symlinkEntry(info.skill_folder);
114
+ }
115
+ if (info.skill_md.is_symlink) {
116
+ symlink_info.symlink_enabled = true;
117
+ symlink_info.skill_md = symlinkEntry(info.skill_md);
118
+ }
119
+ return symlink_info;
120
+ }
121
+ export function symlinkInfoAllowsExtraFiles(info) {
122
+ if (!info || typeof info !== 'object')
123
+ return true;
124
+ const payload = info;
125
+ if (!payload.symlink_enabled)
126
+ return true;
127
+ for (const entry of [payload.skill_folder, payload.skill_md]) {
128
+ if (entry?.status && entry.status !== 'valid')
129
+ return false;
130
+ }
131
+ return true;
132
+ }
133
+ function shouldEmitSkillSlot(info, skillFilename) {
134
+ if (info.skill_folder.is_symlink)
135
+ return true;
136
+ if (info.status === 'inaccessible')
137
+ return true;
138
+ if (info.skill_md.is_symlink)
139
+ return true;
140
+ try {
141
+ accessSync(join(info.skill_folder.path, skillFilename), constants.F_OK);
142
+ return true;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ function unknownInspectFailure(skillDir, skillFilename, err) {
149
+ const status = isMissingPathError(err) ? 'broken' : 'inaccessible';
150
+ return {
151
+ status,
152
+ skill_folder: emptyPath(skillDir, status),
153
+ skill_md: emptyPath(join(skillDir, skillFilename), 'missing'),
154
+ };
155
+ }
156
+ /**
157
+ * Collect one SKILL.md inventory row, including symlink metadata even when the
158
+ * file is missing, dangling, or unreadable.
159
+ */
160
+ export function collectSkillMdRecord(skillDir, fileType, source, skillFilename = 'SKILL.md', extraRaw = {}) {
161
+ let info;
162
+ try {
163
+ info = inspectSkillLink(skillDir, skillFilename);
164
+ }
165
+ catch (err) {
166
+ info = unknownInspectFailure(skillDir, skillFilename, err);
167
+ }
168
+ if (!shouldEmitSkillSlot(info, skillFilename))
169
+ return null;
170
+ const mdPath = join(skillDir, skillFilename);
171
+ const raw = {
172
+ source,
173
+ ...extraRaw,
174
+ };
175
+ const symlink_info = skillLinkPayload(info);
176
+ if (info.status === 'valid') {
177
+ const content = readMarkdownFile(mdPath);
178
+ if (content !== null) {
179
+ raw.content = content;
180
+ }
181
+ else if (symlink_info.symlink_enabled) {
182
+ const skill_md = symlink_info.skill_md;
183
+ const skill_folder = symlink_info.skill_folder;
184
+ if (skill_md)
185
+ skill_md.status = 'inaccessible';
186
+ else if (skill_folder)
187
+ skill_folder.status = 'inaccessible';
188
+ }
189
+ }
190
+ return { file_type: fileType, file_path: mdPath, raw_content: raw, symlink_info };
191
+ }
@@ -75,6 +75,20 @@ export function runSkillsListJson(args, cwd) {
75
75
  rmSync(tmpDir, { recursive: true, force: true });
76
76
  }
77
77
  }
78
+ function assignListLockFields(row, out) {
79
+ for (const key of ['source', 'sourceUrl', 'sourceType']) {
80
+ if (!Object.prototype.hasOwnProperty.call(row, key))
81
+ continue;
82
+ const raw = row[key];
83
+ if (raw == null) {
84
+ out[key] = null;
85
+ }
86
+ else {
87
+ const trimmed = String(raw).trim();
88
+ out[key] = trimmed || null;
89
+ }
90
+ }
91
+ }
78
92
  function normalizeListRows(rows, scope) {
79
93
  const out = [];
80
94
  for (const row of rows) {
@@ -85,12 +99,14 @@ function normalizeListRows(rows, scope) {
85
99
  const agents = Array.isArray(row.agents)
86
100
  ? row.agents.map((a) => String(a).trim()).filter(Boolean)
87
101
  : [];
88
- out.push({
102
+ const entry = {
89
103
  name,
90
104
  path,
91
105
  scope: (row.scope || scope).trim() || scope,
92
106
  agents,
93
- });
107
+ };
108
+ assignListLockFields(row, entry);
109
+ out.push(entry);
94
110
  }
95
111
  return out;
96
112
  }
@@ -212,7 +212,7 @@ function violationFromSecretScanFinding(entry, compliance, finding) {
212
212
  description: compliance.description,
213
213
  finding_title: entry.finding_title,
214
214
  finding_description: entry.finding_description,
215
- policy_name: entry.policy_name,
215
+ ruleset_name: entry.ruleset_name,
216
216
  severity: compliance.severity,
217
217
  autofix_allowed: compliance.autofix_allowed,
218
218
  config_file_path: entry.config_file_path,
@@ -353,7 +353,7 @@ function violationFromCheck(entry, compliance, check, expected) {
353
353
  description: check.description,
354
354
  finding_title: entry.finding_title,
355
355
  finding_description: entry.finding_description,
356
- policy_name: entry.policy_name,
356
+ ruleset_name: entry.ruleset_name,
357
357
  severity: compliance.severity,
358
358
  autofix_allowed: compliance.autofix_allowed,
359
359
  config_file_path: entry.config_file_path,
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /** Org-pushed endpoint config. Prompt collection and later org options share this file. */
5
+ const ORG_CONFIG_RELATIVE_PATH = path.join(".optimuslabs", "management", "org", "config.json");
6
+ export function orgConfigPath() {
7
+ return path.join(os.homedir(), ORG_CONFIG_RELATIVE_PATH);
8
+ }
9
+ function readOrgConfig() {
10
+ try {
11
+ const parsed = JSON.parse(fs.readFileSync(orgConfigPath(), "utf8"));
12
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
13
+ return parsed;
14
+ }
15
+ }
16
+ catch {
17
+ // Missing or invalid — treat as empty.
18
+ }
19
+ return {};
20
+ }
21
+ function collectPromptsFromConfig(cfg) {
22
+ if (typeof cfg.collect_prompts === "boolean")
23
+ return cfg.collect_prompts;
24
+ // Previous on-disk key was inverted (true = hash / don't collect).
25
+ if (typeof cfg.hash_tool_call_prompts === "boolean")
26
+ return cfg.hash_tool_call_prompts !== true;
27
+ // No org config yet: match server default (collection off).
28
+ return false;
29
+ }
30
+ export function readCollectPrompts() {
31
+ return collectPromptsFromConfig(readOrgConfig());
32
+ }
33
+ export function persistCollectPrompts(value) {
34
+ if (typeof value !== "boolean")
35
+ return;
36
+ const filePath = orgConfigPath();
37
+ try {
38
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
39
+ const next = { ...readOrgConfig(), collect_prompts: value };
40
+ delete next.hash_tool_call_prompts;
41
+ fs.writeFileSync(filePath, `${JSON.stringify(next)}\n`, { encoding: "utf8", mode: 0o600 });
42
+ }
43
+ catch {
44
+ // Best-effort: next heartbeat can retry.
45
+ }
46
+ }
47
+ export function persistCollectPromptsFromResponse(body) {
48
+ if (!body || typeof body !== "object")
49
+ return;
50
+ if (typeof body.collect_prompts === "boolean") {
51
+ persistCollectPrompts(body.collect_prompts);
52
+ return;
53
+ }
54
+ if (typeof body.hash_tool_call_prompts === "boolean") {
55
+ persistCollectPrompts(body.hash_tool_call_prompts !== true);
56
+ }
57
+ }
@@ -4,6 +4,7 @@ import { loadEndpointBase } from './endpoint_config.js';
4
4
  import { hookRunLog } from '../runtime/hook_logger.js';
5
5
  import { canonicalCursorUserStateVscdbPath } from '../runtime/remediation_config_path.js';
6
6
  import { resolveWorkspaceRepoFromEnv } from '../runtime/workspace_repo.js';
7
+ import { persistCollectPromptsFromResponse } from '../runtime/prompt-collection-policy.js';
7
8
  import fs from 'node:fs';
8
9
  import os from 'node:os';
9
10
  import path from 'node:path';
@@ -79,11 +80,16 @@ function buildBatchChunks(configFiles, basePayloadSize) {
79
80
  return chunks;
80
81
  }
81
82
  function buildChunkBody(chunk, hardwareUuid, authKey, hookRequestId, metadata) {
82
- const config_files = chunk.map((c) => ({
83
- file_type: c.file_type,
84
- file_path: canonicalCursorUserStateVscdbPath(c.file_path),
85
- raw_content: c.raw_content,
86
- }));
83
+ const config_files = chunk.map((c) => {
84
+ const item = {
85
+ file_type: c.file_type,
86
+ file_path: canonicalCursorUserStateVscdbPath(c.file_path),
87
+ raw_content: c.raw_content,
88
+ };
89
+ if (c.symlink_info !== undefined)
90
+ item.symlink_info = c.symlink_info;
91
+ return item;
92
+ });
87
93
  const payload = { hardware_uuid: hardwareUuid, metadata, config_files };
88
94
  if (hookRequestId != null)
89
95
  payload.hook_request_id = hookRequestId;
@@ -127,6 +133,7 @@ async function sendConfigFilesBatch(configFiles, hardwareUuid, authKey, hookRequ
127
133
  const timeoutMs = Math.min(20000 + Math.ceil(bodySize / (1024 * 1024)) * 15000, 90000);
128
134
  try {
129
135
  const response = (await postStartupPayload(apiUrl, body, timeoutMs));
136
+ persistCollectPromptsFromResponse(response);
130
137
  if (response.status === 'accepted') {
131
138
  totals.accepted += typeof response.accepted === 'number' ? response.accepted : chunk.length;
132
139
  const failedList = Array.isArray(response.failed) ? response.failed : [];
@@ -221,6 +228,8 @@ async function sendConfigFile(configFile, hardwareUuid, authKey, repoIdentifier)
221
228
  const apiUrl = `${resolveApiBase(endpoint)}/endpoint_security/log-config-file/`;
222
229
  const uploadPath = canonicalCursorUserStateVscdbPath(configFile.file_path);
223
230
  const payload = { hardware_uuid: hardwareUuid, file_type: configFile.file_type, file_path: uploadPath, raw_content: configFile.raw_content };
231
+ if (configFile.symlink_info !== undefined)
232
+ payload.symlink_info = configFile.symlink_info;
224
233
  const signature = createSignature(payload, authKey.key);
225
234
  const body = { ...payload, signature, key_id: authKey.key_id || '', metadata: { org_identifier: process.env.GITHUB_ORG || process.env.GH_ORG || '', organization_uuid: readOrganizationUuid(), repo_identifier: repoIdentifier ?? resolveWorkspaceRepoFromEnv() } };
226
235
  try {
@@ -5,6 +5,7 @@ import { resolveHardwareUuid } from './hardware_uuid.js';
5
5
  import { writeAuthKey, readStoredAuthKey, loadEndpointBase, buildStartupEndpointUrl } from './auth_key_store.js';
6
6
  import { resolveUserProfile } from './user_profile.js';
7
7
  import { resolveWorkspaceRepo } from '../log_config_files/runtime/workspace_repo.js';
8
+ import { persistCollectPromptsFromResponse } from '../log_config_files/runtime/prompt-collection-policy.js';
8
9
  import fs from 'node:fs';
9
10
  import os from 'node:os';
10
11
  import path from 'node:path';
@@ -83,6 +84,7 @@ const maybeSendToEndpoint = async (hardwareUuid, timestamp, options = {}) => {
83
84
  const requestBody = buildRequestBody(hardwareUuid, timestamp);
84
85
  try {
85
86
  const response = await postStartupPayload(startupEndpointUrl, requestBody);
87
+ persistCollectPromptsFromResponse(response);
86
88
  const result = classifyEndpointResponse(response);
87
89
  console.log(result.message);
88
90
  if (result.branch === 'key_issued' && result.key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config",
3
- "version": "1.5.14",
3
+ "version": "1.5.18",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {