aienvmp 0.1.36 → 0.1.37

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.37
4
+
5
+ - Added `lightSbom` to the manifest as an AI-ready package and vulnerability summary.
6
+ - Linked dependency manifests, ecosystem/group counts, vulnerable direct dependencies, and top risk packages into one compact SBOM view.
7
+ - Surfaced dependency change hints in AI-facing outputs and the dashboard so agents and humans can identify relevant manifests before edits.
8
+ - Added read-only lockfile awareness to dependency snapshots and light SBOM hints.
9
+ - Added package manager policy hints from lockfiles to reduce accidental npm/pnpm/yarn drift.
10
+
3
11
  ## 0.1.36
4
12
 
5
13
  - Added an explicit enforcement profile to the shared AI preflight contract.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  It helps Codex, Claude, Gemini, and humans avoid silent Node, Python, package manager, dependency, Docker, and security drift.
12
12
 
13
- Core loop: scan once, link runtime/dependency/security context, give AI a shared decision contract with advisory priorities, and hand off safe next steps.
13
+ Core loop: scan once, link runtime/dependency/security context, give AI a shared decision contract with a light SBOM summary, and hand off safe next steps.
14
14
 
15
15
  ## Quick Start
16
16
 
@@ -108,6 +108,11 @@ aienvmp doctor --strict security # fail only scoped warnings
108
108
  - dashboard and preflight explain advisory default vs optional strict mode
109
109
  - non-blocking unless strict mode is explicitly requested
110
110
  - security checks are opt-in and read-only
111
+ - light SBOM is generated from project files and optional scanner summaries
112
+ - dependency change hints point AI agents to the relevant manifest before edits
113
+ - lockfiles are detected read-only and shown before dependency edits
114
+ - package manager policy is inferred from lockfiles to avoid accidental npm/pnpm/yarn drift
115
+ - dashboard mirrors AI-facing SBOM hints so humans can review the same dependency context
111
116
 
112
117
  ## Development
113
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aienvmp",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "AI-first environment maps and lightweight runtime SBOMs for shared development machines.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,7 @@ export async function contextWorkspace(args) {
38
38
  containers: manifest.containers,
39
39
  inventory: inventorySummary(manifest.inventory),
40
40
  dependencySnapshot: dependencySummary(manifest.dependencySnapshot),
41
+ lightSbom: lightSbomSummary(manifest.lightSbom),
41
42
  security: securitySummary(manifest.security),
42
43
  projectHints: manifest.projectHints,
43
44
  warnings,
@@ -51,6 +52,31 @@ export async function contextWorkspace(args) {
51
52
  console.log(renderContext(manifest, timeline, warnings, intents, policy, actions));
52
53
  }
53
54
 
55
+ function lightSbomSummary(lightSbom = {}) {
56
+ return {
57
+ mode: lightSbom.mode || "light-sbom",
58
+ summary: lightSbom.summary || {
59
+ ecosystems: {},
60
+ managers: {},
61
+ groups: {},
62
+ manifests: [],
63
+ lockfiles: [],
64
+ packages: 0,
65
+ vulnerabilities: 0,
66
+ directVulnerablePackages: 0,
67
+ transitiveOrUnmatchedVulnerablePackages: 0
68
+ },
69
+ topRisk: (lightSbom.topRisk || []).slice(0, 8),
70
+ packageManagerPolicy: lightSbom.packageManagerPolicy || {
71
+ status: "no-lockfile",
72
+ ecosystems: {},
73
+ guidance: "No lockfile policy detected."
74
+ },
75
+ dependencyChangeHints: (lightSbom.dependencyChangeHints || []).slice(0, 8),
76
+ aiUse: lightSbom.aiUse || {}
77
+ };
78
+ }
79
+
54
80
  function dependencySummary(snapshot = {}) {
55
81
  return {
56
82
  mode: snapshot.mode || "snapshot",
@@ -3,20 +3,33 @@ import path from "node:path";
3
3
  import { exists, readJson } from "./fsutil.js";
4
4
 
5
5
  const NODE_GROUPS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
6
+ const LOCKFILE_CANDIDATES = [
7
+ { file: "package-lock.json", ecosystem: "npm", manager: "npm" },
8
+ { file: "npm-shrinkwrap.json", ecosystem: "npm", manager: "npm" },
9
+ { file: "pnpm-lock.yaml", ecosystem: "npm", manager: "pnpm" },
10
+ { file: "yarn.lock", ecosystem: "npm", manager: "yarn" },
11
+ { file: "bun.lockb", ecosystem: "npm", manager: "bun" },
12
+ { file: "uv.lock", ecosystem: "python", manager: "uv" },
13
+ { file: "poetry.lock", ecosystem: "python", manager: "poetry" },
14
+ { file: "Pipfile.lock", ecosystem: "python", manager: "pipenv" }
15
+ ];
6
16
 
7
17
  export async function scanDependencySnapshot(dir) {
8
18
  const node = await scanNodeDependencies(dir);
9
19
  const python = await scanPythonDependencies(dir);
10
20
  const packages = [...node.packages, ...python.packages];
11
21
  const manifests = [...node.manifests, ...python.manifests];
22
+ const lockfiles = await scanDependencyLockfiles(dir);
12
23
  return {
13
24
  mode: "snapshot",
14
25
  enabled: true,
15
26
  note: "Read-only dependency snapshot from project files. It does not install, update, or resolve packages.",
16
27
  manifests,
28
+ lockfiles,
17
29
  summary: {
18
30
  ecosystems: [...new Set(packages.map((pkg) => pkg.ecosystem))],
19
31
  manifests: manifests.length,
32
+ lockfiles: lockfiles.length,
20
33
  packages: packages.length
21
34
  },
22
35
  packages: packages.slice(0, 80)
@@ -45,6 +58,144 @@ export function linkVulnerableDependencies(security = {}, snapshot = {}) {
45
58
  };
46
59
  }
47
60
 
61
+ export function buildLightSbom(snapshot = {}, security = {}) {
62
+ const packages = snapshot.packages || [];
63
+ const vulnerable = security.topPackages || [];
64
+ const directVulnerable = vulnerable.filter((pkg) => pkg.directDependency === true);
65
+ const transitiveOrUnmatched = vulnerable.filter((pkg) => pkg.directDependency !== true);
66
+ const topRisk = vulnerable.slice(0, 8).map((pkg) => ({
67
+ name: pkg.name,
68
+ ecosystem: scannerEcosystem(pkg.scanner),
69
+ severity: pkg.severity || "unknown",
70
+ directDependency: pkg.directDependency === true,
71
+ manifest: pkg.dependency?.manifest || "",
72
+ version: pkg.dependency?.version || pkg.version || "",
73
+ priority: pkg.remediationPriority?.level || "low",
74
+ score: pkg.remediationPriority?.score || 0,
75
+ fixAvailable: pkg.fixAvailable === true || Boolean(pkg.fixVersions?.length),
76
+ fixVersions: (pkg.fixVersions || []).slice(0, 3)
77
+ }));
78
+ return {
79
+ schemaVersion: 1,
80
+ mode: "light-sbom",
81
+ note: "AI-ready package and vulnerability summary from read-only project files and optional scanners.",
82
+ summary: {
83
+ ecosystems: countBy(packages, "ecosystem"),
84
+ managers: countBy(packages, "manager"),
85
+ groups: countBy(packages, "group"),
86
+ manifests: snapshot.manifests || [],
87
+ lockfiles: snapshot.lockfiles || [],
88
+ packages: packages.length,
89
+ vulnerabilities: Number(security.summary?.total || 0),
90
+ directVulnerablePackages: directVulnerable.length,
91
+ transitiveOrUnmatchedVulnerablePackages: transitiveOrUnmatched.length
92
+ },
93
+ topRisk,
94
+ packageManagerPolicy: packageManagerPolicy(snapshot.lockfiles || []),
95
+ dependencyChangeHints: dependencyChangeHints(packages, topRisk, snapshot.lockfiles || []),
96
+ aiUse: {
97
+ beforeDependencyChanges: "Read lightSbom.summary and lightSbom.topRisk before changing dependencies.",
98
+ securityMode: security.enabled ? "scanner-summary" : "scanner-off",
99
+ dependencySource: "project manifests only; no install or resolver is run"
100
+ }
101
+ };
102
+ }
103
+
104
+ function packageManagerPolicy(lockfiles = []) {
105
+ const byEcosystem = {};
106
+ for (const lockfile of lockfiles) {
107
+ const ecosystem = lockfile.ecosystem || "unknown";
108
+ const managers = byEcosystem[ecosystem]?.managers || new Set();
109
+ managers.add(lockfile.manager || "unknown");
110
+ byEcosystem[ecosystem] = {
111
+ ecosystem,
112
+ managers,
113
+ lockfiles: [...(byEcosystem[ecosystem]?.lockfiles || []), lockfile.file]
114
+ };
115
+ }
116
+ const ecosystems = Object.fromEntries(Object.entries(byEcosystem).map(([name, value]) => {
117
+ const managers = [...value.managers].sort();
118
+ return [name, {
119
+ managers,
120
+ lockfiles: value.lockfiles.sort(),
121
+ status: managers.length > 1 ? "mixed-lockfiles" : "single-manager",
122
+ recommendedManager: managers[0] || "not-detected",
123
+ guidance: managers.length > 1
124
+ ? "Review with the user before dependency changes; multiple package manager lockfiles are present."
125
+ : "Use the detected package manager for dependency changes unless the user says otherwise."
126
+ }];
127
+ }));
128
+ return {
129
+ status: Object.keys(ecosystems).length === 0
130
+ ? "no-lockfile"
131
+ : Object.values(ecosystems).some((item) => item.status === "mixed-lockfiles") ? "review-required" : "clear",
132
+ ecosystems,
133
+ guidance: Object.keys(ecosystems).length === 0
134
+ ? "No lockfile detected; avoid creating one with an unexpected package manager without user approval."
135
+ : "Preserve existing lockfile and package manager choices during dependency changes."
136
+ };
137
+ }
138
+
139
+ function dependencyChangeHints(packages = [], topRisk = [], lockfiles = []) {
140
+ const byManifest = new Map();
141
+ for (const pkg of packages) {
142
+ const key = pkg.manifest || "unknown";
143
+ const entry = byManifest.get(key) || {
144
+ manifest: key,
145
+ ecosystem: pkg.ecosystem || "unknown",
146
+ manager: pkg.manager || "unknown",
147
+ groups: new Set(),
148
+ packages: 0,
149
+ riskPackages: []
150
+ };
151
+ entry.groups.add(pkg.group || "unknown");
152
+ entry.packages += 1;
153
+ byManifest.set(key, entry);
154
+ }
155
+ for (const risk of topRisk) {
156
+ if (!risk.manifest || !byManifest.has(risk.manifest)) continue;
157
+ byManifest.get(risk.manifest).riskPackages.push({
158
+ name: risk.name,
159
+ severity: risk.severity,
160
+ priority: risk.priority,
161
+ fixAvailable: risk.fixAvailable
162
+ });
163
+ }
164
+ return [...byManifest.values()].map((entry) => ({
165
+ manifest: entry.manifest,
166
+ ecosystem: entry.ecosystem,
167
+ manager: entry.manager,
168
+ groups: [...entry.groups].sort(),
169
+ packages: entry.packages,
170
+ riskPackages: entry.riskPackages.slice(0, 5),
171
+ lockfiles: lockfilesForEntry(entry, lockfiles),
172
+ beforeChange: [
173
+ `Read ${entry.manifest} and the active package manager policy before editing dependencies.`,
174
+ lockfilesForEntry(entry, lockfiles).length
175
+ ? `Preserve related lockfiles: ${lockfilesForEntry(entry, lockfiles).map((item) => item.file).join(", ")}.`
176
+ : "No related lockfile was detected; do not create one with a different package manager without approval.",
177
+ "Record an intent before dependency or lockfile changes when another AI may be working."
178
+ ],
179
+ afterChange: [
180
+ "Run project tests or the narrowest relevant validation.",
181
+ "Run aienvmp sync.",
182
+ "Record the dependency change with aienvmp record."
183
+ ]
184
+ }));
185
+ }
186
+
187
+ async function scanDependencyLockfiles(dir) {
188
+ const found = [];
189
+ for (const item of LOCKFILE_CANDIDATES) {
190
+ if (await exists(path.join(dir, item.file))) found.push(item);
191
+ }
192
+ return found;
193
+ }
194
+
195
+ function lockfilesForEntry(entry, lockfiles = []) {
196
+ return lockfiles.filter((lockfile) => lockfile.ecosystem === entry.ecosystem);
197
+ }
198
+
48
199
  export function remediationPriority(pkg = {}, context = {}) {
49
200
  const severityScore = { critical: 90, high: 70, moderate: 45, low: 20, info: 5, unknown: 30 };
50
201
  const severity = String(pkg.severity || "unknown").toLowerCase();
@@ -66,6 +217,14 @@ export function remediationPriority(pkg = {}, context = {}) {
66
217
  return { level, score, reasons };
67
218
  }
68
219
 
220
+ function countBy(items = [], key) {
221
+ return Object.fromEntries(Object.entries(items.reduce((acc, item) => {
222
+ const value = item[key] || "unknown";
223
+ acc[value] = (acc[value] || 0) + 1;
224
+ return acc;
225
+ }, {})).sort(([a], [b]) => a.localeCompare(b)));
226
+ }
227
+
69
228
  async function scanNodeDependencies(dir) {
70
229
  const file = path.join(dir, "package.json");
71
230
  if (!(await exists(file))) return { manifests: [], packages: [] };
package/src/manifest.js CHANGED
@@ -6,12 +6,13 @@ import { exists } from "./fsutil.js";
6
6
  import { observedTrust } from "./trust.js";
7
7
  import { scanGlobalInventory } from "./inventory.js";
8
8
  import { scanSecurity } from "./security.js";
9
- import { linkVulnerableDependencies, scanDependencySnapshot } from "./dependencies.js";
9
+ import { buildLightSbom, linkVulnerableDependencies, scanDependencySnapshot } from "./dependencies.js";
10
10
 
11
11
  export async function buildManifest(dir, options = {}) {
12
12
  const now = new Date().toISOString();
13
13
  const dependencySnapshot = await scanDependencySnapshot(dir);
14
14
  const security = linkVulnerableDependencies(await scanSecurity(dir, { security: options.security }), dependencySnapshot);
15
+ const lightSbom = buildLightSbom(dependencySnapshot, security);
15
16
  const manifest = {
16
17
  schemaName: "aienvmp.runtime-sbom",
17
18
  schemaVersion: 1,
@@ -31,6 +32,7 @@ export async function buildManifest(dir, options = {}) {
31
32
  containers: await scanContainers(),
32
33
  projectHints: await scanProjectHints(dir),
33
34
  dependencySnapshot,
35
+ lightSbom,
34
36
  inventory: await scanGlobalInventory({ deep: options.deep }),
35
37
  security,
36
38
  agentFiles: await scanAgentFiles(dir),
package/src/render.js CHANGED
@@ -33,6 +33,8 @@ export function renderAIEnv(manifest, timeline = [], warnings = [], intents = []
33
33
  lines.push(...inventoryLines(manifest.inventory), "");
34
34
  lines.push("## Dependency Snapshot", "");
35
35
  lines.push(...dependencyLines(manifest.dependencySnapshot), "");
36
+ lines.push("## Light SBOM", "");
37
+ lines.push(...lightSbomLines(manifest.lightSbom), "");
36
38
  lines.push("## Security Summary", "");
37
39
  lines.push(...securityLines(manifest.security), "");
38
40
  lines.push("## Project Requirements And Hints", "");
@@ -284,6 +286,14 @@ const deps=manifest.dependencySnapshot||{};
284
286
  const depSummary=deps.summary||{ecosystems:[],manifests:0,packages:0};
285
287
  const depPackages=deps.packages||[];
286
288
  const depHtml=depPackages.length?'<table><tr><th>Packages</th><td><code>'+esc(depSummary.packages||0)+'</code></td></tr><tr><th>Ecosystems</th><td><code>'+esc((depSummary.ecosystems||[]).join(', ')||'none')+'</code></td></tr><tr><th>Manifests</th><td><code>'+esc((deps.manifests||[]).join(', ')||'none')+'</code></td></tr></table><div class="timeline">'+depPackages.slice(0,8).map(p=>\`<div class="event"><time>\${esc(p.ecosystem)}</time><div><b>\${esc(p.name)}</b> <code>\${esc(p.version)}</code><div class="path">\${esc(p.manifest)} / \${esc(p.group)}</div></div></div>\`).join('')+'</div>':'<div class="okline">No project dependency manifests detected.</div>';
289
+ const lightSbom=manifest.lightSbom||{};
290
+ const lightSbomSummary=lightSbom.summary||{};
291
+ const pmPolicy=lightSbom.packageManagerPolicy||{};
292
+ const topRisk=lightSbom.topRisk||[];
293
+ const dependencyHints=lightSbom.dependencyChangeHints||[];
294
+ const dependencyHintsHtml=dependencyHints.length?'<div class="timeline">'+dependencyHints.slice(0,5).map(h=>\`<div class="event"><time>\${esc(h.ecosystem||'deps')}</time><div><b>\${esc(h.manifest)}</b> <code>\${esc(h.manager||'unknown')}</code> \${esc(h.packages||0)} packages\${h.riskPackages?.length?\`<div class="path">risk: \${esc(h.riskPackages.map(p=>p.name).join(', '))}</div>\`:''}<div class="path">\${esc((h.groups||[]).join(', ')||'no groups')}\${h.lockfiles?.length?\` / lockfiles: \${esc(h.lockfiles.map(l=>l.file).join(', '))}\`:''}</div></div></div>\`).join('')+'</div>':'<div class="okline">No dependency change hints available.</div>';
295
+ const pmPolicyHtml='<table><tr><th>Status</th><td><code>'+esc(pmPolicy.status||'no-lockfile')+'</code></td></tr><tr><th>Guidance</th><td>'+esc(pmPolicy.guidance||'No lockfile policy detected.')+'</td></tr></table>';
296
+ const lightSbomHtml=\`<table><tr><th>Packages</th><td><code>\${esc(lightSbomSummary.packages||0)}</code></td></tr><tr><th>Vulnerabilities</th><td><code>\${esc(lightSbomSummary.vulnerabilities||0)}</code></td></tr><tr><th>Direct vulnerable</th><td><code>\${esc(lightSbomSummary.directVulnerablePackages||0)}</code></td></tr><tr><th>Manifests</th><td><code>\${esc((lightSbomSummary.manifests||[]).join(', ')||'none')}</code></td></tr><tr><th>Lockfiles</th><td><code>\${esc((lightSbomSummary.lockfiles||[]).map(l=>l.file).join(', ')||'none')}</code></td></tr></table><h3 style="margin-top:12px">Package manager policy</h3>\${pmPolicyHtml}\${topRisk.length?'<div class="timeline">'+topRisk.slice(0,5).map(p=>\`<div class="event"><time>\${esc(p.priority)}</time><div><b>\${esc(p.name)}</b> \${esc(p.severity)} \${p.directDependency?'<code>direct</code>':'<code>transitive</code>'}<div class="path">\${esc(p.manifest||p.ecosystem)} \${esc(p.version||'')}</div></div></div>\`).join('')+'</div>':'<div class="okline">No high-risk package summary in the current light SBOM.</div>'}<h3 style="margin-top:12px">Dependency change hints</h3>\${dependencyHintsHtml}\`;
287
297
  const sec=manifest.security||{};
288
298
  const secSummary=sec.summary||{total:0,critical:0,high:0,moderate:0,low:0,info:0};
289
299
  const secPackages=sec.topPackages||[];
@@ -353,6 +363,7 @@ document.getElementById('app').innerHTML=\`
353
363
  \${card('Project Hints',\`<span class="pill">\${entries(manifest.projectHints).length} hints</span>\`,\`<table>\${rows(manifest.projectHints)}</table>\`)}
354
364
  \${card('Global Inventory',manifest.inventory?.enabled?'<span class="pill">deep</span>':'<span class="pill off">basic</span>',inventoryHtml)}
355
365
  \${card('Dependency Snapshot','<span class="pill">'+(depSummary.packages||0)+' packages</span>',depHtml)}
366
+ \${card('Light SBOM','<span class="pill">'+(lightSbomSummary.packages||0)+' packages</span>',lightSbomHtml)}
356
367
  \${card('Security Summary',sec.enabled?'<span class="pill warn">security</span>':'<span class="pill off">basic</span>',securityHtml)}
357
368
  </div>
358
369
  <aside>
@@ -451,6 +462,39 @@ function dependencyLines(snapshot = {}) {
451
462
  return lines;
452
463
  }
453
464
 
465
+ function lightSbomLines(lightSbom = {}) {
466
+ const summary = lightSbom.summary || {};
467
+ const lines = [
468
+ `- Mode: ${lightSbom.mode || "light-sbom"}`,
469
+ `- Packages: ${summary.packages || 0}`,
470
+ `- Vulnerabilities: ${summary.vulnerabilities || 0}`,
471
+ `- Direct vulnerable packages: ${summary.directVulnerablePackages || 0}`,
472
+ `- Transitive or unmatched vulnerable packages: ${summary.transitiveOrUnmatchedVulnerablePackages || 0}`,
473
+ `- Lockfiles: ${(summary.lockfiles || []).map((item) => item.file).join(", ") || "none"}`
474
+ ];
475
+ if (lightSbom.packageManagerPolicy) {
476
+ lines.push(`- Package manager policy: ${lightSbom.packageManagerPolicy.status}`);
477
+ lines.push(`- Package manager guidance: ${lightSbom.packageManagerPolicy.guidance}`);
478
+ }
479
+ const risks = lightSbom.topRisk || [];
480
+ if (risks.length) {
481
+ lines.push("- Top risk:");
482
+ for (const item of risks.slice(0, 8)) {
483
+ lines.push(` - ${item.name}: ${item.severity}; ${item.priority}/${item.score}; ${item.directDependency ? "direct" : "transitive-or-unmatched"}${item.version ? `; ${item.version}` : ""}`);
484
+ }
485
+ }
486
+ const hints = lightSbom.dependencyChangeHints || [];
487
+ if (hints.length) {
488
+ lines.push("- Dependency change hints:");
489
+ for (const hint of hints.slice(0, 6)) {
490
+ const risk = hint.riskPackages?.length ? `; risk: ${hint.riskPackages.map((pkg) => pkg.name).join(", ")}` : "";
491
+ const lockfiles = hint.lockfiles?.length ? `; lockfiles: ${hint.lockfiles.map((item) => item.file).join(", ")}` : "";
492
+ lines.push(` - ${hint.manifest}: ${hint.ecosystem}/${hint.manager}; ${hint.packages} packages${risk}${lockfiles}`);
493
+ }
494
+ }
495
+ return lines;
496
+ }
497
+
454
498
  function securityLines(security = {}) {
455
499
  if (!security.enabled) return ["- Mode: basic", "- Security scan is disabled. Run `aienvmp sync --security` when vulnerability context is needed."];
456
500
  const summary = security.summary || {};