aienvmp 0.1.6 → 0.1.8

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8
4
+
5
+ - Added optional `sync --security` / `scan --security` vulnerability summary collection.
6
+ - Added read-only npm audit parsing for light SBOM security awareness.
7
+ - Exposed security summaries to AI-facing context, handoff, `AIENV.md`, and the dashboard.
8
+
9
+ ## 0.1.7
10
+
11
+ - Added stale open intent warnings for long-running environment change plans.
12
+ - Added stale handoff warnings when environment changes happen after the last recorded AI handoff.
13
+ - Added optional `aienvmp handoff --record --actor <agent:id>` timeline entries.
14
+
3
15
  ## 0.1.6
4
16
 
5
17
  - Added optional `sync --deep` / `scan --deep` read-only global tool inventory.
package/README.md CHANGED
@@ -18,12 +18,14 @@ Core loop: scan the env, give AI a preflight context, and hand off safe next ste
18
18
  npx aienvmp sync
19
19
  npx aienvmp context
20
20
  npx aienvmp handoff
21
+ npx aienvmp handoff --record --actor agent:codex
21
22
  ```
22
23
 
23
- Optional deeper read-only inventory:
24
+ Optional read-only checks:
24
25
 
25
26
  ```bash
26
27
  npx aienvmp sync --deep
28
+ npx aienvmp sync --security
27
29
  ```
28
30
 
29
31
  ## Output
@@ -65,7 +67,9 @@ aienvmp doctor --ci # strict CI check
65
67
  - simple by default
66
68
  - AI-first
67
69
  - lightweight
68
- - non-blocking unless strict mode is requested
70
+ - one advisory engine, optional enforcement with `doctor --ci`
71
+ - non-blocking unless strict mode is explicitly requested
72
+ - security checks are opt-in and read-only
69
73
 
70
74
  ## Development
71
75
 
package/ROADMAP.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  - Strengthen trust states: observed, planned, changed, review, verified, stale
8
8
  - Detect multi-agent environment intent conflicts
9
+ - Keep one advisory decision engine with optional strict enforcement
10
+ - Keep vulnerability checks opt-in and read-only
9
11
  - Stabilize `.aienvmp/manifest.json` and JSON command schemas
10
12
  - Keep `sync`, `context`, and `handoff` as the simple core flow
11
13
  - Improve the dashboard for 10-second human review
@@ -19,10 +21,12 @@
19
21
  - Global tool inventory:
20
22
  - richer summaries for `npm -g`, `pipx list`, `uv tool list`, and Homebrew
21
23
  - optional `--deep` scanners for more toolchains
24
+ - Security summaries:
25
+ - Python vulnerability summary via optional scanner detection
26
+ - OS/container vulnerability summaries through optional external tools
22
27
  - Conflict detection:
23
- - stale unresolved intents
24
- - recent runtime changes without a fresh handoff
25
28
  - package manager policy vs lockfile mismatch
29
+ - monorepo/project boundary aware intent targets
26
30
  - CI mode:
27
31
  - stable exit codes
28
32
  - GitHub Action example
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aienvmp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "AI-first environment maps and lightweight runtime SBOMs for shared development machines.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -74,9 +74,9 @@ function printUsage() {
74
74
  console.log(`aienvmp - AI-first env map + lightweight runtime SBOM
75
75
 
76
76
  Usage:
77
- aienvmp sync [--dir .] [--json] [--quiet] [--deep]
77
+ aienvmp sync [--dir .] [--json] [--quiet] [--deep] [--security]
78
78
  aienvmp context [--dir .] [--json]
79
- aienvmp handoff [--dir .] [--json]
79
+ aienvmp handoff [--dir .] [--json] [--record --actor agent:id]
80
80
 
81
81
  Common:
82
82
  aienvmp sync update AIENV.md, manifest, ledger, intents, and dashboard
@@ -87,7 +87,7 @@ Common:
87
87
 
88
88
  Advanced:
89
89
  aienvmp init [--dir .]
90
- aienvmp scan [--dir .] [--deep]
90
+ aienvmp scan [--dir .] [--deep] [--security]
91
91
  aienvmp intent [--dir .] --actor agent:codex --action "install pnpm"
92
92
  aienvmp resolve [--dir .] --actor human:you --id <intent-id> [--status resolved|cancelled]
93
93
  aienvmp record [--dir .] --actor agent:codex --summary "updated .nvmrc" [--target node] [--before 20] [--after 24]
@@ -25,6 +25,7 @@ export async function contextWorkspace(args) {
25
25
  packageManagers: manifest.packageManagers,
26
26
  containers: manifest.containers,
27
27
  inventory: inventorySummary(manifest.inventory),
28
+ security: securitySummary(manifest.security),
28
29
  projectHints: manifest.projectHints,
29
30
  warnings,
30
31
  policy,
@@ -37,6 +38,14 @@ export async function contextWorkspace(args) {
37
38
  console.log(renderContext(manifest, timeline, warnings, intents, policy));
38
39
  }
39
40
 
41
+ function securitySummary(security = {}) {
42
+ return {
43
+ mode: security.mode || "basic",
44
+ enabled: security.enabled === true,
45
+ summary: security.summary || { total: 0, critical: 0, high: 0, moderate: 0, low: 0, info: 0 }
46
+ };
47
+ }
48
+
40
49
  function inventorySummary(inventory = {}) {
41
50
  const tools = inventory.tools || {};
42
51
  return {
@@ -1,9 +1,10 @@
1
1
  import { diagnose } from "../doctor.js";
2
- import { readJson } from "../fsutil.js";
2
+ import { appendJsonLine, readJson } from "../fsutil.js";
3
3
  import { loadPolicy, policyWarnings } from "../policy.js";
4
4
  import { intentsPath, manifestPath, timelinePath, workspaceDir } from "../paths.js";
5
5
  import { renderHandoff } from "../render.js";
6
6
  import { openIntents, readJsonl, readTimeline } from "../timeline.js";
7
+ import { changedTrust } from "../trust.js";
7
8
 
8
9
  export async function handoffWorkspace(args) {
9
10
  const dir = workspaceDir(args);
@@ -14,6 +15,9 @@ export async function handoffWorkspace(args) {
14
15
  const policy = await loadPolicy(dir);
15
16
  const warnings = [...diagnose(manifest, { timeline, intents }), ...policyWarnings(manifest, policy)];
16
17
  const handoff = buildHandoff(manifest, timeline, warnings, intents, policy);
18
+ if (args.record) {
19
+ await recordHandoff(timelinePath(dir), handoff, args.actor || "agent:unknown");
20
+ }
17
21
 
18
22
  if (args.json) {
19
23
  console.log(JSON.stringify(handoff, null, 2));
@@ -23,6 +27,19 @@ export async function handoffWorkspace(args) {
23
27
  return handoff;
24
28
  }
25
29
 
30
+ async function recordHandoff(file, handoff, actor) {
31
+ const now = new Date();
32
+ await appendJsonLine(file, {
33
+ at: now.toISOString(),
34
+ actor,
35
+ type: "agent-handoff",
36
+ summary: `handoff ${handoff.status}`,
37
+ status: handoff.status,
38
+ warnings: handoff.warnings.map((warning) => warning.code),
39
+ trust: changedTrust(now, handoff.status !== "clear")
40
+ });
41
+ }
42
+
26
43
  export function buildHandoff(manifest, timeline = [], warnings = [], intents = [], policy = {}) {
27
44
  const reviewRequired = warnings.length > 0 || intents.length > 0;
28
45
  return {
@@ -36,6 +53,7 @@ export function buildHandoff(manifest, timeline = [], warnings = [], intents = [
36
53
  docker: manifest.containers?.docker ? "available" : "not detected"
37
54
  },
38
55
  inventory: inventorySummary(manifest.inventory),
56
+ security: securitySummary(manifest.security),
39
57
  policy: {
40
58
  node: policy.node || "not set",
41
59
  python: policy.python || "not set",
@@ -57,6 +75,14 @@ export function buildHandoff(manifest, timeline = [], warnings = [], intents = [
57
75
  };
58
76
  }
59
77
 
78
+ function securitySummary(security = {}) {
79
+ return {
80
+ mode: security.mode || "basic",
81
+ enabled: security.enabled === true,
82
+ summary: security.summary || { total: 0, critical: 0, high: 0, moderate: 0, low: 0, info: 0 }
83
+ };
84
+ }
85
+
60
86
  function inventorySummary(inventory = {}) {
61
87
  const tools = inventory.tools || {};
62
88
  return {
@@ -11,7 +11,7 @@ export async function scanWorkspace(args) {
11
11
  if (previous && await exists(currentPath)) {
12
12
  await fs.copyFile(currentPath, previousManifestPath(dir));
13
13
  }
14
- const manifest = await buildManifest(dir, { deep: args.deep });
14
+ const manifest = await buildManifest(dir, { deep: args.deep, security: args.security });
15
15
  await writeJson(currentPath, manifest);
16
16
  const changes = diffManifests(previous, manifest);
17
17
  for (const change of changes) {
package/src/doctor.js CHANGED
@@ -42,10 +42,25 @@ export function diagnose(manifest, context = {}) {
42
42
  message: "Environment snapshot is older than 24 hours. Run `aienvmp sync` before changing the environment."
43
43
  });
44
44
  }
45
+ warnings.push(...securityWarnings(manifest.security));
45
46
  warnings.push(...coordinationWarnings(context.intents || []));
47
+ warnings.push(...staleIntentWarnings(context.intents || []));
48
+ warnings.push(...handoffWarnings(context.timeline || []));
46
49
  return warnings;
47
50
  }
48
51
 
52
+ export function securityWarnings(security = {}) {
53
+ if (!security.enabled || !security.summary) return [];
54
+ const summary = security.summary;
55
+ if (Number(summary.critical || 0) > 0 || Number(summary.high || 0) > 0) {
56
+ return [{
57
+ code: "security-vulnerabilities",
58
+ message: `Security scan found ${summary.critical || 0} critical and ${summary.high || 0} high vulnerabilities. Review before changing or deploying the environment.`
59
+ }];
60
+ }
61
+ return [];
62
+ }
63
+
49
64
  export function coordinationWarnings(intents = []) {
50
65
  const warnings = [];
51
66
  const byTarget = new Map();
@@ -69,6 +84,30 @@ export function coordinationWarnings(intents = []) {
69
84
  return warnings;
70
85
  }
71
86
 
87
+ export function staleIntentWarnings(intents = [], now = new Date(), maxAgeHours = 4) {
88
+ const warnings = [];
89
+ for (const intent of intents) {
90
+ if (!isStaleTimestamp(intent.at, now, maxAgeHours)) continue;
91
+ warnings.push({
92
+ code: "stale-open-intent",
93
+ target: intent.target || inferTarget(intent.action) || "",
94
+ message: `Open intent ${intent.id || ""} from ${intent.actor || "unknown"} is older than ${maxAgeHours} hours. Resolve it or confirm it before environment changes.`.replace(/\s+/g, " ").trim()
95
+ });
96
+ }
97
+ return warnings;
98
+ }
99
+
100
+ export function handoffWarnings(timeline = []) {
101
+ const lastEnvChange = [...timeline].reverse().find(isEnvironmentChange);
102
+ if (!lastEnvChange) return [];
103
+ const lastHandoff = [...timeline].reverse().find((item) => item.type === "agent-handoff");
104
+ if (lastHandoff && new Date(lastHandoff.at).getTime() >= new Date(lastEnvChange.at).getTime()) return [];
105
+ return [{
106
+ code: "handoff-stale",
107
+ message: "Environment changes were recorded after the last AI handoff. Run `aienvmp handoff --record --actor agent:id` before the next agent continues."
108
+ }];
109
+ }
110
+
72
111
  function inferTarget(action = "") {
73
112
  const normalized = String(action).toLowerCase();
74
113
  for (const target of ["node", "python", "docker", "npm", "pnpm", "yarn", "uv", "pip", "pipx"]) {
@@ -76,3 +115,24 @@ function inferTarget(action = "") {
76
115
  }
77
116
  return "";
78
117
  }
118
+
119
+ function isEnvironmentChange(item = {}) {
120
+ if (["runtime", "package-manager", "container"].includes(item.change?.scope)) return true;
121
+ if (item.type === "detected-change") return false;
122
+ const text = `${item.type || ""} ${item.target || ""} ${item.summary || ""} ${item.action || ""} ${item.change?.scope || ""} ${item.change?.key || ""}`.toLowerCase();
123
+ return [
124
+ "runtime",
125
+ "node",
126
+ "python",
127
+ "docker",
128
+ "package manager",
129
+ "package-manager",
130
+ "npm",
131
+ "pnpm",
132
+ "yarn",
133
+ "uv",
134
+ "pip",
135
+ "pipx",
136
+ "global"
137
+ ].some((token) => text.includes(token));
138
+ }
package/src/manifest.js CHANGED
@@ -5,6 +5,7 @@ import { commandOutput, commandVersion } from "./shell.js";
5
5
  import { exists } from "./fsutil.js";
6
6
  import { observedTrust } from "./trust.js";
7
7
  import { scanGlobalInventory } from "./inventory.js";
8
+ import { scanSecurity } from "./security.js";
8
9
 
9
10
  export async function buildManifest(dir, options = {}) {
10
11
  const now = new Date().toISOString();
@@ -14,7 +15,7 @@ export async function buildManifest(dir, options = {}) {
14
15
  generatedAt: now,
15
16
  generatedBy: {
16
17
  name: "aienvmp",
17
- command: options.deep ? "aienvmp sync --deep" : "aienvmp sync"
18
+ command: generatedCommand(options)
18
19
  },
19
20
  trust: observedTrust(new Date(now)),
20
21
  workspace: {
@@ -27,13 +28,14 @@ export async function buildManifest(dir, options = {}) {
27
28
  containers: await scanContainers(),
28
29
  projectHints: await scanProjectHints(dir),
29
30
  inventory: await scanGlobalInventory({ deep: options.deep }),
31
+ security: await scanSecurity(dir, { security: options.security }),
30
32
  agentFiles: await scanAgentFiles(dir),
31
33
  agentProtocol: {
32
34
  sourceOfTruth: "AIENV.md",
33
35
  preflightCommand: "aienvmp context",
34
36
  handoffCommand: "aienvmp handoff",
35
- intentCommand: "aienvmp intent --actor <agent:id> --action <planned-change>",
36
- recordCommand: "aienvmp record --actor <agent:id> --summary <what-changed>",
37
+ intentCommand: "aienvmp intent --actor agent:id --action planned-change",
38
+ recordCommand: "aienvmp record --actor agent:id --summary what-changed",
37
39
  afterEnvironmentChange: ["aienvmp sync"],
38
40
  trustModel: {
39
41
  agentWritable: ["observed", "planned", "changed", "review", "stale"],
@@ -78,6 +80,10 @@ export async function buildManifest(dir, options = {}) {
78
80
  uvTools: "uv tool list",
79
81
  brew: "brew list --versions"
80
82
  },
83
+ security: {
84
+ mode: "basic by default; vulnerability summaries only when requested",
85
+ npmAudit: "npm audit --json"
86
+ },
81
87
  projectHints: [
82
88
  ".nvmrc",
83
89
  ".python-version",
@@ -96,6 +102,10 @@ export async function buildManifest(dir, options = {}) {
96
102
  return manifest;
97
103
  }
98
104
 
105
+ function generatedCommand(options = {}) {
106
+ return ["aienvmp", "sync", options.deep ? "--deep" : "", options.security ? "--security" : ""].filter(Boolean).join(" ");
107
+ }
108
+
99
109
  async function scanOS() {
100
110
  return {
101
111
  platform: os.platform(),
package/src/render.js CHANGED
@@ -12,9 +12,10 @@ export function renderAIEnv(manifest, timeline = [], warnings = [], intents = []
12
12
  lines.push("1. Run `aienvmp context`.");
13
13
  lines.push("2. Prefer project-local version files such as `.nvmrc`, `.python-version`, `mise.toml`, and `.tool-versions`.");
14
14
  lines.push("3. Ask the user before changing global environment state.");
15
- lines.push("4. Record planned environment changes with `aienvmp intent --actor <agent:id> --action <planned-change>`.");
15
+ lines.push("4. Record planned environment changes with `aienvmp intent --actor agent:id --action planned-change`.");
16
16
  lines.push("5. After environment changes, run `aienvmp sync`.");
17
- lines.push("6. Record what changed with `aienvmp record --actor <agent:id> --summary <what-changed>`.", "");
17
+ lines.push("6. Record what changed with `aienvmp record --actor agent:id --summary what-changed`.");
18
+ lines.push("7. At handoff, run `aienvmp handoff --record --actor agent:id`.", "");
18
19
  lines.push("## Current Policy", "");
19
20
  lines.push(...policyLines(policy));
20
21
  lines.push("- Enforcement: non-blocking by default; warnings require review but do not lock the machine.");
@@ -30,6 +31,8 @@ export function renderAIEnv(manifest, timeline = [], warnings = [], intents = []
30
31
  pushMap(lines, "Containers", manifest.containers);
31
32
  lines.push("## Global Tool Inventory", "");
32
33
  lines.push(...inventoryLines(manifest.inventory), "");
34
+ lines.push("## Security Summary", "");
35
+ lines.push(...securityLines(manifest.security), "");
33
36
  lines.push("## Project Requirements And Hints", "");
34
37
  pushMap(lines, "Detected", manifest.projectHints);
35
38
  lines.push("## Drift And Warnings", "");
@@ -76,6 +79,7 @@ Before changing runtimes, package managers, Docker settings, global packages, or
76
79
  4. Record planned environment changes with \`aienvmp intent\`.
77
80
  5. After environment changes, run \`aienvmp sync\`.
78
81
  6. Record what changed with \`aienvmp record\`.
82
+ 7. At handoff, run \`aienvmp handoff --record --actor agent:id\`.
79
83
 
80
84
  \`aienvmp\` does not replace this instruction file. It provides the live env map, lightweight runtime SBOM, intent log, timeline, and dashboard.`;
81
85
  }
@@ -94,6 +98,7 @@ export function renderContext(manifest, timeline = [], warnings = [], intents =
94
98
  `Python: ${manifest.runtimes.python || manifest.runtimes.python3 || "not detected"}`,
95
99
  `Docker: ${manifest.containers.docker ? "available" : "not detected"}`,
96
100
  `Inventory: ${manifest.inventory?.mode || "basic"}${manifest.inventory?.enabled ? " enabled" : " disabled"}`,
101
+ `Security: ${manifest.security?.mode || "basic"}${manifest.security?.enabled ? ` enabled (${manifest.security.summary?.total || 0} vulnerabilities)` : " disabled"}`,
97
102
  `Policy Node: ${policy.node || "not set"}`,
98
103
  `Policy Python: ${policy.python || "not set"}`,
99
104
  `Policy Package Manager: ${policy.packageManager || "not set"}`,
@@ -102,9 +107,10 @@ export function renderContext(manifest, timeline = [], warnings = [], intents =
102
107
  "- Ask the user before global runtime, package manager, Docker, or global package changes.",
103
108
  "- Treat policy mismatches as review-required, not as permission to break ongoing operations.",
104
109
  "- Prefer project-local version files and local environments.",
105
- "- Before planned env changes, run `aienvmp intent --actor <agent:id> --action <planned-change>`.",
110
+ "- Before planned env changes, run `aienvmp intent --actor agent:id --action planned-change`.",
106
111
  "- After env changes, run `aienvmp sync`.",
107
- "- Then run `aienvmp record --actor <agent:id> --summary <what-changed>`.",
112
+ "- Then run `aienvmp record --actor agent:id --summary what-changed`.",
113
+ "- Before handing work to another AI, run `aienvmp handoff --record --actor agent:id`.",
108
114
  "",
109
115
  "Warnings:",
110
116
  ...(warnings.length ? warnings.map((w) => `- ${w.message}`) : ["- none"]),
@@ -132,6 +138,7 @@ export function renderHandoff(handoff) {
132
138
  `- Python: ${handoff.safeRuntime.python}`,
133
139
  `- Docker: ${handoff.safeRuntime.docker}`,
134
140
  `- Inventory: ${handoff.inventory?.mode || "basic"}${handoff.inventory?.enabled ? " enabled" : " disabled"}`,
141
+ `- Security: ${handoff.security?.mode || "basic"}${handoff.security?.enabled ? ` enabled (${handoff.security.summary?.total || 0} vulnerabilities)` : " disabled"}`,
135
142
  "",
136
143
  "Open intents:",
137
144
  ...(handoff.openIntents.length ? handoff.openIntents.map((i) => `- ${i.actor}: ${i.action}${i.target ? ` (${i.target})` : ""}`) : ["- none"]),
@@ -210,6 +217,9 @@ const rows=o=>entries(o).map(([k,v])=>\`<tr><th>\${esc(k)}</th><td><code>\${esc(
210
217
  const inventoryGroups=manifest.inventory?.tools||{};
211
218
  const inventoryCount=Object.values(inventoryGroups).reduce((sum,items)=>sum+(Array.isArray(items)?items.length:0),0);
212
219
  const inventoryHtml=manifest.inventory?.enabled?('<table>'+Object.entries(inventoryGroups).map(([k,v])=>\`<tr><th>\${esc(k)}</th><td><code>\${Array.isArray(v)?v.length:0} tools</code></td></tr>\`).join('')+'</table>'):'<div class="okline">Deep global inventory is off. Run <code>aienvmp sync --deep</code> when an AI needs global tool awareness.</div>';
220
+ const sec=manifest.security||{};
221
+ const secSummary=sec.summary||{total:0,critical:0,high:0,moderate:0,low:0,info:0};
222
+ const securityHtml=sec.enabled?\`<table><tr><th>Total</th><td><code>\${esc(secSummary.total||0)}</code></td></tr><tr><th>Critical</th><td><code>\${esc(secSummary.critical||0)}</code></td></tr><tr><th>High</th><td><code>\${esc(secSummary.high||0)}</code></td></tr><tr><th>Moderate</th><td><code>\${esc(secSummary.moderate||0)}</code></td></tr><tr><th>Low</th><td><code>\${esc(secSummary.low||0)}</code></td></tr></table>\`:'<div class="okline">Security scan is off. Run <code>aienvmp sync --security</code> for read-only vulnerability summary.</div>';
213
223
  const change=c=>c.type==='changed'?\`\${c.scope} \${c.key}: \${c.before} -> \${c.after}\`:\`\${c.scope} \${c.key}: \${c.type} \${c.after||c.before}\`;
214
224
  const timelineLabel=t=>t.change?change(t.change):(t.summary||t.action||t.type||'recorded change');
215
225
  const agentNames={agents:'Codex',claude:'Claude',gemini:'Gemini'};
@@ -254,6 +264,7 @@ document.getElementById('app').innerHTML=\`
254
264
  \${card('Containers',manifest.containers?.docker?'<span class="pill">available</span>':'<span class="pill off">not detected</span>',\`<table>\${rows(manifest.containers)}</table>\`)}
255
265
  \${card('Project Hints',\`<span class="pill">\${entries(manifest.projectHints).length} hints</span>\`,\`<table>\${rows(manifest.projectHints)}</table>\`)}
256
266
  \${card('Global Inventory',manifest.inventory?.enabled?'<span class="pill">deep</span>':'<span class="pill off">basic</span>',inventoryHtml)}
267
+ \${card('Security Summary',sec.enabled?'<span class="pill warn">security</span>':'<span class="pill off">basic</span>',securityHtml)}
257
268
  </div>
258
269
  <aside>
259
270
  \${card('Environment Health',warnings.length?'<span class="pill warn">attention</span>':'<span class="pill">clear</span>',warnHtml)}
@@ -323,6 +334,19 @@ function inventoryLines(inventory = {}) {
323
334
  return lines;
324
335
  }
325
336
 
337
+ function securityLines(security = {}) {
338
+ if (!security.enabled) return ["- Mode: basic", "- Security scan is disabled. Run `aienvmp sync --security` when vulnerability context is needed."];
339
+ const summary = security.summary || {};
340
+ return [
341
+ "- Mode: security",
342
+ `- Total vulnerabilities: ${summary.total || 0}`,
343
+ `- Critical: ${summary.critical || 0}`,
344
+ `- High: ${summary.high || 0}`,
345
+ `- Moderate: ${summary.moderate || 0}`,
346
+ `- Low: ${summary.low || 0}`
347
+ ];
348
+ }
349
+
326
350
  function policyLines(policy) {
327
351
  const lines = [];
328
352
  if (policy.node) lines.push(`- Node version policy: ${policy.node}`);
@@ -0,0 +1,90 @@
1
+ import { exists } from "./fsutil.js";
2
+ import { commandResult } from "./shell.js";
3
+ import path from "node:path";
4
+
5
+ const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
6
+
7
+ export async function scanSecurity(dir, options = {}) {
8
+ if (!options.security) {
9
+ return {
10
+ mode: "basic",
11
+ enabled: false,
12
+ note: "Run `aienvmp sync --security` to collect read-only vulnerability summaries."
13
+ };
14
+ }
15
+
16
+ const npmAudit = await scanNpmAudit(dir);
17
+ return {
18
+ mode: "security",
19
+ enabled: true,
20
+ note: "Read-only vulnerability summary. Use for review and CI policy, not automatic fixes.",
21
+ scanners: {
22
+ npmAudit
23
+ },
24
+ summary: summarizeScanners({ npmAudit })
25
+ };
26
+ }
27
+
28
+ export async function scanNpmAudit(dir) {
29
+ const hasPackageJson = await exists(path.join(dir, "package.json"));
30
+ if (!hasPackageJson) return unavailable("npm-audit", "package.json not found");
31
+
32
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
33
+ const result = await commandResult(command, ["audit", "--json"], {
34
+ cwd: dir,
35
+ timeout: 15000,
36
+ maxBuffer: 4 * 1024 * 1024
37
+ });
38
+ const parsed = parseNpmAudit(result.stdout);
39
+ if (!parsed.available) {
40
+ return unavailable("npm-audit", result.stderr || "npm audit did not return parseable JSON");
41
+ }
42
+ return {
43
+ scanner: "npm-audit",
44
+ available: true,
45
+ ok: result.ok,
46
+ exitCode: result.code,
47
+ summary: parsed.summary,
48
+ vulnerablePackages: parsed.vulnerablePackages
49
+ };
50
+ }
51
+
52
+ export function parseNpmAudit(raw) {
53
+ try {
54
+ const parsed = JSON.parse(raw || "{}");
55
+ const metadata = parsed.metadata || {};
56
+ const vulnerabilities = parsed.vulnerabilities || {};
57
+ const severityCounts = Object.fromEntries(SEVERITIES.map((severity) => [severity, Number(metadata.vulnerabilities?.[severity] || 0)]));
58
+ const total = Number(metadata.vulnerabilities?.total || Object.values(severityCounts).reduce((sum, value) => sum + value, 0));
59
+ const vulnerablePackages = Object.entries(vulnerabilities).slice(0, 20).map(([name, value]) => ({
60
+ name,
61
+ severity: value.severity || "unknown",
62
+ viaCount: Array.isArray(value.via) ? value.via.length : 0,
63
+ fixAvailable: Boolean(value.fixAvailable)
64
+ }));
65
+ return {
66
+ available: true,
67
+ summary: { total, ...severityCounts },
68
+ vulnerablePackages
69
+ };
70
+ } catch {
71
+ return { available: false };
72
+ }
73
+ }
74
+
75
+ export function summarizeScanners(scanners = {}) {
76
+ const summary = { total: 0, critical: 0, high: 0, moderate: 0, low: 0, info: 0 };
77
+ for (const scanner of Object.values(scanners)) {
78
+ if (!scanner?.available) continue;
79
+ for (const key of Object.keys(summary)) summary[key] += Number(scanner.summary?.[key] || 0);
80
+ }
81
+ return summary;
82
+ }
83
+
84
+ function unavailable(scanner, reason) {
85
+ return {
86
+ scanner,
87
+ available: false,
88
+ reason
89
+ };
90
+ }
package/src/shell.js CHANGED
@@ -20,6 +20,7 @@ export async function commandOutput(command, args = [], options = {}) {
20
20
  const { stdout } = await execFileAsync(command, args, {
21
21
  timeout: options.timeout || 2500,
22
22
  maxBuffer: options.maxBuffer || 1024 * 1024,
23
+ cwd: options.cwd,
23
24
  windowsHide: true
24
25
  });
25
26
  return stdout.trim();
@@ -28,6 +29,25 @@ export async function commandOutput(command, args = [], options = {}) {
28
29
  }
29
30
  }
30
31
 
32
+ export async function commandResult(command, args = [], options = {}) {
33
+ try {
34
+ const { stdout, stderr } = await execFileAsync(command, args, {
35
+ timeout: options.timeout || 5000,
36
+ maxBuffer: options.maxBuffer || 2 * 1024 * 1024,
37
+ cwd: options.cwd,
38
+ windowsHide: true
39
+ });
40
+ return { ok: true, code: 0, stdout: stdout.trim(), stderr: stderr.trim() };
41
+ } catch (error) {
42
+ return {
43
+ ok: false,
44
+ code: typeof error.code === "number" ? error.code : 1,
45
+ stdout: String(error.stdout || "").trim(),
46
+ stderr: String(error.stderr || error.message || "").trim()
47
+ };
48
+ }
49
+ }
50
+
31
51
  export function firstVersion(text) {
32
52
  const match = String(text).match(/(?:v)?(\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?)/);
33
53
  return match ? match[1] : null;