akm-cli 0.3.1 → 0.4.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/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { filterNonEmptyStrings } from "./common";
3
4
  import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath } from "./paths";
4
5
  // ── Defaults ────────────────────────────────────────────────────────────────
5
6
  export const DEFAULT_CONFIG = {
@@ -21,42 +22,47 @@ export function getConfigPath() {
21
22
  return _getConfigPath();
22
23
  }
23
24
  // ── Load / Save / Update ────────────────────────────────────────────────────
25
+ const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
24
26
  let cachedConfig;
27
+ let cachedUserConfig;
25
28
  export function resetConfigCache() {
26
29
  cachedConfig = undefined;
30
+ cachedUserConfig = undefined;
27
31
  }
28
- export function loadConfig() {
32
+ export function loadUserConfig() {
29
33
  const configPath = getConfigPath();
30
34
  let stat;
31
35
  try {
32
36
  stat = fs.statSync(configPath);
33
- if (cachedConfig && cachedConfig.path === configPath && cachedConfig.mtime === stat.mtimeMs) {
34
- return cachedConfig.config;
37
+ if (cachedUserConfig && cachedUserConfig.path === configPath && cachedUserConfig.mtime === stat.mtimeMs) {
38
+ return cachedUserConfig.config;
35
39
  }
36
40
  }
37
41
  catch {
38
- // File doesn't exist — return defaults below
39
- cachedConfig = undefined;
40
- return { ...DEFAULT_CONFIG };
41
- }
42
- const raw = readConfigObject(configPath);
43
- const expanded = raw ? expandEnvVars(raw) : undefined;
44
- const config = expanded ? pickKnownKeys(expanded) : { ...DEFAULT_CONFIG };
45
- // Legacy: inject API keys from well-known env vars when not set via ${} substitution
46
- if (config.embedding && !config.embedding.apiKey) {
47
- const envKey = process.env.AKM_EMBED_API_KEY?.trim();
48
- if (envKey)
49
- config.embedding.apiKey = envKey;
42
+ cachedUserConfig = undefined;
43
+ return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
50
44
  }
51
- if (config.llm && !config.llm.apiKey) {
52
- const envKey = process.env.AKM_LLM_API_KEY?.trim();
53
- if (envKey)
54
- config.llm.apiKey = envKey;
45
+ const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfig(configPath));
46
+ const finalConfig = applyRuntimeEnvApiKeys(config);
47
+ cachedUserConfig = { config: finalConfig, path: configPath, mtime: stat.mtimeMs };
48
+ return finalConfig;
49
+ }
50
+ export function loadConfig() {
51
+ const configPaths = getEffectiveConfigPaths();
52
+ const signature = getConfigSignature(configPaths);
53
+ if (cachedConfig && cachedConfig.signature === signature) {
54
+ return cachedConfig.config;
55
+ }
56
+ let config = loadUserConfig();
57
+ const userConfigPath = getConfigPath();
58
+ for (const configPath of configPaths) {
59
+ if (configPath === userConfigPath)
60
+ continue;
61
+ config = mergeLoadedConfig(config, readNormalizedConfig(configPath));
55
62
  }
56
- // Cache the parsed config with its path and mtime for subsequent calls.
57
- // Reuse the stat already obtained above (avoids a second syscall + TOCTOU gap).
58
- cachedConfig = { config, path: configPath, mtime: stat.mtimeMs };
59
- return config;
63
+ const finalConfig = applyRuntimeEnvApiKeys(config);
64
+ cachedConfig = { config: finalConfig, signature };
65
+ return finalConfig;
60
66
  }
61
67
  export function saveConfig(config) {
62
68
  cachedConfig = undefined;
@@ -98,7 +104,7 @@ function sanitizeConfigForWrite(config) {
98
104
  return sanitized;
99
105
  }
100
106
  export function updateConfig(partial) {
101
- const current = loadConfig();
107
+ const current = loadUserConfig();
102
108
  // Shallow-merge for top-level scalar fields; deep-merge known object-type config keys.
103
109
  const merged = { ...current, ...partial };
104
110
  // Deep-merge output — partial update should not wipe sibling keys
@@ -113,12 +119,22 @@ export function updateConfig(partial) {
113
119
  if (current.llm && partial.llm && partial.llm !== current.llm) {
114
120
  merged.llm = { ...current.llm, ...partial.llm };
115
121
  }
122
+ if (current.security && partial.security && partial.security !== current.security) {
123
+ merged.security = mergeSecurityConfig(current.security, partial.security);
124
+ }
116
125
  saveConfig(merged);
117
126
  return merged;
118
127
  }
119
128
  // ── Helpers ─────────────────────────────────────────────────────────────────
129
+ /**
130
+ * Normalize a raw config object into a sparse config layer containing only
131
+ * recognized keys that were valid in the source object. This function does not
132
+ * merge with DEFAULT_CONFIG; callers are responsible for layering defaults and
133
+ * combining multiple config sources so project config files only override what
134
+ * they set.
135
+ */
120
136
  function pickKnownKeys(raw) {
121
- const config = { ...DEFAULT_CONFIG };
137
+ const config = {};
122
138
  if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
123
139
  config.stashDir = raw.stashDir.trim();
124
140
  }
@@ -159,14 +175,25 @@ function pickKnownKeys(raw) {
159
175
  const registries = parseRegistriesConfig(raw.registries);
160
176
  if (registries)
161
177
  config.registries = registries;
178
+ if (typeof raw.disableGlobalStashes === "boolean") {
179
+ config.disableGlobalStashes = raw.disableGlobalStashes;
180
+ }
162
181
  const stashes = parseStashesConfig(raw.stashes);
163
182
  if (stashes)
164
183
  config.stashes = stashes;
184
+ const security = parseSecurityConfig(raw.security);
185
+ if (security)
186
+ config.security = security;
165
187
  const output = parseOutputConfig(raw.output);
166
188
  if (output)
167
189
  config.output = output;
168
190
  return config;
169
191
  }
192
+ function readNormalizedConfig(configPath) {
193
+ const raw = readConfigObject(configPath);
194
+ const expanded = raw ? expandEnvVars(raw) : undefined;
195
+ return expanded ? pickKnownKeys(expanded) : undefined;
196
+ }
170
197
  function parseOutputConfig(value) {
171
198
  if (typeof value !== "object" || value === null || Array.isArray(value))
172
199
  return undefined;
@@ -440,6 +467,32 @@ function parseStashesConfig(value) {
440
467
  .filter((entry) => entry !== undefined);
441
468
  return entries;
442
469
  }
470
+ function parseSecurityConfig(value) {
471
+ if (typeof value !== "object" || value === null || Array.isArray(value))
472
+ return undefined;
473
+ const obj = value;
474
+ const installAudit = parseInstallAuditConfig(obj.installAudit);
475
+ if (!installAudit)
476
+ return undefined;
477
+ return { installAudit };
478
+ }
479
+ function parseInstallAuditConfig(value) {
480
+ if (typeof value !== "object" || value === null || Array.isArray(value))
481
+ return undefined;
482
+ const obj = value;
483
+ const config = {};
484
+ if (typeof obj.enabled === "boolean")
485
+ config.enabled = obj.enabled;
486
+ if (typeof obj.blockOnCritical === "boolean")
487
+ config.blockOnCritical = obj.blockOnCritical;
488
+ if (typeof obj.blockUnlistedRegistries === "boolean")
489
+ config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
490
+ const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
491
+ if (rawAllowlist) {
492
+ config.registryAllowlist = rawAllowlist;
493
+ }
494
+ return Object.keys(config).length > 0 ? config : undefined;
495
+ }
443
496
  function parseStashConfigEntry(value) {
444
497
  if (typeof value !== "object" || value === null || Array.isArray(value))
445
498
  return undefined;
@@ -485,3 +538,122 @@ function parseRegistryConfigEntry(value) {
485
538
  }
486
539
  return entry;
487
540
  }
541
+ function mergeSecurityConfig(base, override) {
542
+ if (!base && !override)
543
+ return undefined;
544
+ const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
545
+ return installAudit ? { installAudit } : undefined;
546
+ }
547
+ function mergeInstallAuditConfig(base, override) {
548
+ if (!base && !override)
549
+ return undefined;
550
+ const merged = {
551
+ ...(base ?? {}),
552
+ ...(override ?? {}),
553
+ };
554
+ return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
555
+ }
556
+ /**
557
+ * Merge a normalized config layer into an accumulated config.
558
+ *
559
+ * Scalar fields follow normal override semantics. Known nested objects are
560
+ * deep-merged so project config files can override individual fields without
561
+ * clobbering sibling settings. `stashes` are additive by default, but a later
562
+ * layer can set `disableGlobalStashes: true` to drop inherited stashes first.
563
+ */
564
+ function mergeLoadedConfig(base, override) {
565
+ if (!override)
566
+ return { ...base };
567
+ const merged = {
568
+ ...base,
569
+ ...override,
570
+ };
571
+ if (base.output && override.output) {
572
+ merged.output = { ...base.output, ...override.output };
573
+ }
574
+ if (base.embedding && override.embedding) {
575
+ merged.embedding = { ...base.embedding, ...override.embedding };
576
+ }
577
+ if (base.llm && override.llm) {
578
+ merged.llm = { ...base.llm, ...override.llm };
579
+ }
580
+ if (base.security && override.security) {
581
+ merged.security = mergeSecurityConfig(base.security, override.security);
582
+ }
583
+ if (override.disableGlobalStashes) {
584
+ merged.stashes = [...(override.stashes ?? [])];
585
+ }
586
+ else if (override.stashes) {
587
+ merged.stashes = [...(base.stashes ?? []), ...override.stashes];
588
+ }
589
+ return merged;
590
+ }
591
+ function applyRuntimeEnvApiKeys(config) {
592
+ const next = { ...config };
593
+ if (next.embedding && !next.embedding.apiKey) {
594
+ const envKey = process.env.AKM_EMBED_API_KEY?.trim();
595
+ if (envKey)
596
+ next.embedding = { ...next.embedding, apiKey: envKey };
597
+ }
598
+ if (next.llm && !next.llm.apiKey) {
599
+ const envKey = process.env.AKM_LLM_API_KEY?.trim();
600
+ if (envKey)
601
+ next.llm = { ...next.llm, apiKey: envKey };
602
+ }
603
+ return next;
604
+ }
605
+ /**
606
+ * Return config file paths in merge order: user config first, then project
607
+ * config files from the outermost parent directory down to the current working
608
+ * directory. Later entries have higher precedence when merged.
609
+ */
610
+ function getEffectiveConfigPaths() {
611
+ const configPath = getConfigPath();
612
+ const paths = [];
613
+ if (isFile(configPath)) {
614
+ paths.push(configPath);
615
+ }
616
+ return [...paths, ...discoverProjectConfigPaths(process.cwd())];
617
+ }
618
+ /**
619
+ * Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
620
+ * files. Paths are returned from outermost parent to innermost directory so
621
+ * nearer project directories override broader project settings.
622
+ */
623
+ function discoverProjectConfigPaths(startDir) {
624
+ const paths = [];
625
+ let currentDir = path.resolve(startDir);
626
+ while (true) {
627
+ const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
628
+ if (isFile(configPath)) {
629
+ paths.unshift(configPath);
630
+ }
631
+ const parentDir = path.dirname(currentDir);
632
+ if (parentDir === currentDir) {
633
+ break;
634
+ }
635
+ currentDir = parentDir;
636
+ }
637
+ return paths;
638
+ }
639
+ function getConfigSignature(configPaths) {
640
+ if (configPaths.length === 0)
641
+ return "defaults";
642
+ return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
643
+ }
644
+ function isFile(filePath) {
645
+ try {
646
+ return fs.statSync(filePath).isFile();
647
+ }
648
+ catch {
649
+ return false;
650
+ }
651
+ }
652
+ function getFileSignatureToken(filePath) {
653
+ try {
654
+ return String(fs.statSync(filePath).mtimeMs);
655
+ }
656
+ catch {
657
+ return "missing";
658
+ }
659
+ }
package/dist/init.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { TYPE_DIRS } from "./asset-spec";
10
- import { getConfigPath, loadConfig, saveConfig } from "./config";
10
+ import { getConfigPath, loadUserConfig, saveConfig } from "./config";
11
11
  import { getBinDir, getDefaultStashDir } from "./paths";
12
12
  import { ensureRg } from "./ripgrep-install";
13
13
  export async function akmInit(options) {
@@ -25,7 +25,7 @@ export async function akmInit(options) {
25
25
  }
26
26
  // Persist stashDir in config.json
27
27
  const configPath = getConfigPath();
28
- const existing = loadConfig();
28
+ const existing = loadUserConfig();
29
29
  if (!existing.stashDir || existing.stashDir !== stashDir) {
30
30
  saveConfig({ ...existing, stashDir });
31
31
  }
@@ -0,0 +1,324 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { filterNonEmptyStrings } from "./common";
4
+ const DEFAULT_INSTALL_AUDIT_CONFIG = {
5
+ enabled: true,
6
+ blockOnCritical: true,
7
+ blockUnlistedRegistries: false,
8
+ registryAllowlist: [],
9
+ };
10
+ const MAX_SCANNED_FILE_BYTES = 256 * 1024;
11
+ const LIFECYCLE_SCRIPT_NAMES = new Set([
12
+ "preinstall",
13
+ "install",
14
+ "postinstall",
15
+ "prepublish",
16
+ "prepublishOnly",
17
+ "prepare",
18
+ ]);
19
+ const TEXT_FILE_EXTENSIONS = new Set([
20
+ ".cjs",
21
+ ".cts",
22
+ ".js",
23
+ ".json",
24
+ ".jsonc",
25
+ ".jsx",
26
+ ".mjs",
27
+ ".md",
28
+ ".ps1",
29
+ ".py",
30
+ ".rb",
31
+ ".sh",
32
+ ".toml",
33
+ ".ts",
34
+ ".tsx",
35
+ ".txt",
36
+ ".yaml",
37
+ ".yml",
38
+ ]);
39
+ const CONTENT_RULES = [
40
+ {
41
+ id: "prompt-ignore-previous-instructions",
42
+ severity: "high",
43
+ category: "prompt-injection",
44
+ message: "Contains instructions to ignore prior prompts or instructions.",
45
+ pattern: /\b(ignore|disregard|forget)\b[^.\n]{0,100}\b(previous|prior|earlier)\b[^.\n]{0,100}\b(instructions?|prompts?|messages?)\b/i,
46
+ },
47
+ {
48
+ id: "prompt-reveal-hidden-secrets",
49
+ severity: "critical",
50
+ category: "prompt-injection",
51
+ message: "Contains instructions to reveal hidden prompts or secrets.",
52
+ pattern: /\b(reveal|print|dump|show|exfiltrat(?:e|ion))\b[^.\n]{0,120}\b(system prompt|hidden instructions?|developer message|api key|token|secret|password)\b/i,
53
+ },
54
+ {
55
+ id: "prompt-bypass-guardrails",
56
+ severity: "high",
57
+ category: "prompt-injection",
58
+ message: "Contains instructions to bypass safety or security controls.",
59
+ pattern: /\b(bypass|disable|ignore)\b[^.\n]{0,100}\b(safety|security|guardrails|restrictions|policies)\b/i,
60
+ },
61
+ {
62
+ id: "remote-shell-pipe",
63
+ severity: "critical",
64
+ category: "malicious-code",
65
+ message: "Downloads remote content and pipes it directly into a shell.",
66
+ pattern: /\b(curl|wget)\b[^\n|]{0,200}\|\s*(sh|bash|zsh)\b/i,
67
+ },
68
+ {
69
+ id: "powershell-download-exec",
70
+ severity: "critical",
71
+ category: "malicious-code",
72
+ message: "Downloads remote content and executes it in PowerShell.",
73
+ pattern: /\b(Invoke-WebRequest|iwr|curl)\b[^\n|]{0,200}\|\s*(iex|Invoke-Expression)\b/i,
74
+ },
75
+ {
76
+ id: "powershell-encoded-command",
77
+ severity: "critical",
78
+ category: "malicious-code",
79
+ message: "Uses an encoded PowerShell command.",
80
+ pattern: /\bpowershell(?:\.exe)?\b[^\n]{0,120}\s-(?:enc|encodedcommand)\b/i,
81
+ },
82
+ {
83
+ id: "credential-exfiltration-language",
84
+ severity: "high",
85
+ category: "malicious-code",
86
+ message: "Contains language associated with credential or secret exfiltration.",
87
+ pattern: /\b(exfiltrat(?:e|ion)|harvest|steal)\b[^.\n]{0,120}\b(credentials?|tokens?|secrets?|ssh keys?|passwords?|cookies?)\b/i,
88
+ },
89
+ ];
90
+ export function resolveInstallAuditConfig(config) {
91
+ const installAudit = config?.security?.installAudit;
92
+ const allowlist = filterNonEmptyStrings(installAudit?.registryAllowlist) ??
93
+ filterNonEmptyStrings(installAudit?.registryWhitelist) ??
94
+ [];
95
+ return {
96
+ enabled: installAudit?.enabled ?? DEFAULT_INSTALL_AUDIT_CONFIG.enabled,
97
+ blockOnCritical: installAudit?.blockOnCritical ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockOnCritical,
98
+ blockUnlistedRegistries: installAudit?.blockUnlistedRegistries ?? DEFAULT_INSTALL_AUDIT_CONFIG.blockUnlistedRegistries,
99
+ registryAllowlist: allowlist.map((entry) => entry.trim().toLowerCase()),
100
+ };
101
+ }
102
+ export function enforceRegistryInstallPolicy(registryLabels, config, ref) {
103
+ const resolved = resolveInstallAuditConfig(config);
104
+ if (!resolved.blockUnlistedRegistries)
105
+ return;
106
+ if (resolved.registryAllowlist.length === 0) {
107
+ throw new Error(`Install blocked for ${ref}: no registries are allowlisted. Configure security.installAudit.registryAllowlist or disable security.installAudit.blockUnlistedRegistries.`);
108
+ }
109
+ const matched = registryLabels.some((label) => resolved.registryAllowlist.includes(label.toLowerCase()));
110
+ if (matched)
111
+ return;
112
+ throw new Error(`Install blocked for ${ref}: registry is not allowlisted. Allowed: ${resolved.registryAllowlist.join(", ")}. Seen: ${registryLabels.join(", ")}.`);
113
+ }
114
+ export function auditInstallCandidate(input) {
115
+ const resolved = resolveInstallAuditConfig(input.config);
116
+ if (!resolved.enabled) {
117
+ return {
118
+ enabled: false,
119
+ passed: true,
120
+ blocked: false,
121
+ registryLabels: [...input.registryLabels],
122
+ findings: [],
123
+ scannedFiles: 0,
124
+ scannedBytes: 0,
125
+ summary: buildSummary([]),
126
+ };
127
+ }
128
+ const findings = [];
129
+ const counters = { scannedFiles: 0, scannedBytes: 0 };
130
+ scanDirectory(input.rootDir, input.rootDir, findings, counters);
131
+ const summary = buildSummary(findings);
132
+ const blocked = resolved.blockOnCritical && summary.critical > 0;
133
+ return {
134
+ enabled: true,
135
+ passed: findings.length === 0,
136
+ blocked,
137
+ registryLabels: [...input.registryLabels],
138
+ findings,
139
+ scannedFiles: counters.scannedFiles,
140
+ scannedBytes: counters.scannedBytes,
141
+ summary,
142
+ };
143
+ }
144
+ export function formatInstallAuditFailure(ref, report) {
145
+ const lines = [`Security audit failed for ${ref}.`, formatInstallAuditSummary(report)];
146
+ for (const finding of report.findings.slice(0, 5)) {
147
+ lines.push(`- [${finding.severity}] ${finding.message}${finding.file ? ` (${finding.file})` : ""}`);
148
+ }
149
+ if (report.findings.length > 5) {
150
+ lines.push(`- ${report.findings.length - 5} more finding(s) omitted`);
151
+ }
152
+ lines.push("Disable blocking with `security.installAudit.blockOnCritical = false`, or disable audits with `security.installAudit.enabled = false`.");
153
+ return lines.join("\n");
154
+ }
155
+ export function formatInstallAuditSummary(report) {
156
+ if (!report.enabled)
157
+ return "Audit: disabled";
158
+ const severitySummary = [];
159
+ if (report.summary.critical > 0)
160
+ severitySummary.push(`${report.summary.critical} critical`);
161
+ if (report.summary.high > 0)
162
+ severitySummary.push(`${report.summary.high} high`);
163
+ if (report.summary.moderate > 0)
164
+ severitySummary.push(`${report.summary.moderate} moderate`);
165
+ if (report.summary.low > 0)
166
+ severitySummary.push(`${report.summary.low} low`);
167
+ const detail = severitySummary.length > 0 ? severitySummary.join(", ") : "no findings";
168
+ return `Audit: ${report.blocked ? "blocked" : report.passed ? "passed" : "warnings"} (${detail}; scanned ${report.scannedFiles} file${report.scannedFiles === 1 ? "" : "s"})`;
169
+ }
170
+ export function deriveRegistryLabels(input) {
171
+ const labels = new Set();
172
+ labels.add(input.source);
173
+ if (input.source === "github")
174
+ labels.add("github.com");
175
+ if (input.source === "npm")
176
+ labels.add("npm");
177
+ addUrlLabels(labels, input.artifactUrl);
178
+ addUrlLabels(labels, input.gitUrl);
179
+ if (input.source === "github" && input.ref.startsWith("github:")) {
180
+ labels.add("github");
181
+ }
182
+ return [...labels];
183
+ }
184
+ function scanDirectory(dir, rootDir, findings, counters) {
185
+ let entries;
186
+ try {
187
+ entries = fs.readdirSync(dir, { withFileTypes: true });
188
+ }
189
+ catch {
190
+ return;
191
+ }
192
+ for (const entry of entries) {
193
+ if (entry.name === ".git" || entry.name === "node_modules")
194
+ continue;
195
+ const fullPath = path.join(dir, entry.name);
196
+ if (entry.isDirectory()) {
197
+ scanDirectory(fullPath, rootDir, findings, counters);
198
+ continue;
199
+ }
200
+ if (!entry.isFile())
201
+ continue;
202
+ scanFile(fullPath, rootDir, findings, counters);
203
+ }
204
+ }
205
+ function scanFile(filePath, rootDir, findings, counters) {
206
+ const ext = path.extname(filePath).toLowerCase();
207
+ const basename = path.basename(filePath).toLowerCase();
208
+ if (basename !== "package.json" && !TEXT_FILE_EXTENSIONS.has(ext))
209
+ return;
210
+ let fileSize;
211
+ try {
212
+ fileSize = fs.statSync(filePath).size;
213
+ }
214
+ catch {
215
+ return;
216
+ }
217
+ const readSize = Math.min(fileSize, MAX_SCANNED_FILE_BYTES);
218
+ const buf = Buffer.alloc(readSize);
219
+ let bytesRead;
220
+ try {
221
+ const fd = fs.openSync(filePath, "r");
222
+ try {
223
+ bytesRead = fs.readSync(fd, buf, 0, readSize, 0);
224
+ }
225
+ finally {
226
+ fs.closeSync(fd);
227
+ }
228
+ }
229
+ catch {
230
+ return;
231
+ }
232
+ if (bytesRead === 0)
233
+ return;
234
+ const bytes = buf.subarray(0, bytesRead);
235
+ if (bytes.includes(0))
236
+ return;
237
+ counters.scannedFiles += 1;
238
+ counters.scannedBytes += bytesRead;
239
+ const content = bytes.toString("utf8");
240
+ const relativePath = path.relative(rootDir, filePath) || path.basename(filePath);
241
+ const genericContent = basename === "package.json" ? stripPackageJsonScripts(content) : content;
242
+ for (const rule of CONTENT_RULES) {
243
+ const match = genericContent.match(rule.pattern);
244
+ if (!match)
245
+ continue;
246
+ findings.push({
247
+ id: rule.id,
248
+ severity: rule.severity,
249
+ category: rule.category,
250
+ message: rule.message,
251
+ file: relativePath,
252
+ snippet: clipSnippet(match[0]),
253
+ });
254
+ }
255
+ if (basename === "package.json") {
256
+ scanPackageJson(content, relativePath, findings);
257
+ }
258
+ }
259
+ function stripPackageJsonScripts(content) {
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(content);
263
+ }
264
+ catch {
265
+ return content;
266
+ }
267
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
268
+ return content;
269
+ const packageJson = { ...parsed };
270
+ delete packageJson.scripts;
271
+ return JSON.stringify(packageJson, null, 2);
272
+ }
273
+ function scanPackageJson(content, relativePath, findings) {
274
+ let parsed;
275
+ try {
276
+ parsed = JSON.parse(content);
277
+ }
278
+ catch {
279
+ return;
280
+ }
281
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
282
+ return;
283
+ const scripts = parsed.scripts;
284
+ if (typeof scripts !== "object" || scripts === null || Array.isArray(scripts))
285
+ return;
286
+ for (const [name, command] of Object.entries(scripts)) {
287
+ if (!LIFECYCLE_SCRIPT_NAMES.has(name) || typeof command !== "string")
288
+ continue;
289
+ for (const rule of CONTENT_RULES) {
290
+ if (!rule.pattern.test(command))
291
+ continue;
292
+ findings.push({
293
+ id: `lifecycle-${name}-${rule.id}`,
294
+ severity: rule.severity,
295
+ category: "install-script",
296
+ message: `Lifecycle script "${name}" is suspicious: ${rule.message.toLowerCase()}`,
297
+ file: relativePath,
298
+ snippet: clipSnippet(command),
299
+ });
300
+ }
301
+ }
302
+ }
303
+ function clipSnippet(value) {
304
+ const normalized = value.replace(/\s+/g, " ").trim();
305
+ return normalized.length <= 140 ? normalized : `${normalized.slice(0, 137)}...`;
306
+ }
307
+ function buildSummary(findings) {
308
+ const summary = { low: 0, moderate: 0, high: 0, critical: 0, total: findings.length };
309
+ for (const finding of findings) {
310
+ summary[finding.severity] += 1;
311
+ }
312
+ return summary;
313
+ }
314
+ function addUrlLabels(labels, rawUrl) {
315
+ if (!rawUrl)
316
+ return;
317
+ try {
318
+ const parsed = new URL(rawUrl);
319
+ labels.add(parsed.hostname.toLowerCase());
320
+ }
321
+ catch {
322
+ // Ignore non-URL refs (for example git@host:path)
323
+ }
324
+ }
@@ -247,8 +247,8 @@ function tryResolveInstalledTarget(installed, target) {
247
247
  return undefined;
248
248
  }
249
249
  function toInstalledEntry(status) {
250
- // KitInstallStatus extends InstalledKitEntry; omit the extra extractedDir field.
251
- const { extractedDir: _extractedDir, ...base } = status;
250
+ // KitInstallStatus extends InstalledKitEntry; omit transient install-only fields.
251
+ const { extractedDir: _extractedDir, audit: _audit, ...base } = status;
252
252
  return base;
253
253
  }
254
254
  function toInstallStatus(status) {