nsauditor-ai 0.1.31 → 0.1.32

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/README.md CHANGED
@@ -15,6 +15,30 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
15
15
 
16
16
  **Zero Data Exfiltration by design.** NSAuditor AI works fully offline. AI analysis, CVE correlation, and continuous monitoring all happen locally. External calls (to AI APIs, NVD, etc.) are opt-in and use your own API keys. We never see your scan data.
17
17
 
18
+ ## What's New (0.1.32) — Claude Desktop integration overhaul + ground-truth diagnostics
19
+
20
+ The 0.1.32 line bundles three operational improvements driven by real customer-onboarding friction surfaced during the developer's own Claude Desktop integration test (2026-05-10):
21
+
22
+ - **`nsauditor-ai mcp install-key` now auto-generates a machine-specific Claude Desktop config snippet.** Reads `process.execPath` (the Node binary actually running) and derives the script path from `import.meta.url` — the printed JSON has absolute paths that work whether you're on system Node, homebrew, nvm, fnm, local-project install, Linux, or Windows. No install-type detective work, no PATH-misalignment failures. On macOS, the snippet uses `keychain:` indirection for **both** auth and license — secrets never land in the world-readable Claude Desktop config file.
23
+ - **License `keychain:` indirection.** `loadLicense()` and `resolveLicenseKey()` honor the `keychain:LABEL` prefix on the env-supplied value (mirrors the EE-SEC.1 MCP-auth pattern). Operators can put `"NSAUDITOR_LICENSE_KEY": "keychain:NSAUDITOR_LICENSE_KEY"` in their Claude Desktop env block; the JWT stays in macOS Keychain. Backward-compat: literal JWTs continue to work unchanged.
24
+ - **`nsauditor-ai mcp tier` ground-truth subcommand.** Customer-side check that prints the EXACT tier the spawned MCP server resolves to. We discovered Claude Desktop reports of "Current tier: CE" despite verified Pro/Enterprise license were caused by Claude (the AI) **synthesizing the tier text from training data + context** instead of actually calling `list_plugins` via MCP. The MCP server's resolution was always correct. `mcp tier` bypasses Claude's interpretive layer — paste the output into a support ticket to distinguish "MCP genuinely broken" from "Claude misreading."
25
+ - **MCP key rotation cadence (Thread I).** Optional 90-day rotation soft warning at server startup + `mcp status` (override via `NSA_MCP_AUTH_KEY_ROTATION_DAYS`, clamped to [7, 365]). SOC 2 CC6.1/CC6.7 reviewers expect a credential-rotation cadence; an unrotated MCP auth key is treated the same way as an unrotated IAM access key.
26
+ - **Keychain-locked vs Keychain-empty distinction.** New `keychain-locked` source variant in `mcp status` for headless macOS / SSH-only CI runners — instead of falling through silently to "unconfigured", operators get an actionable error with three GUI-free workarounds.
27
+ - **Atomic file writes** for `~/.nsauditor/.env` (`.tmp` + POSIX-rename) so concurrent readers + crash recovery never observe a truncated file.
28
+ - **Auto-mirror license file→Keychain on `mcp install-key`** (macOS) when the license is configured in `~/.nsauditor/.env` but absent from Keychain. Lets the printed snippet's `keychain:` indirection actually resolve, with original storage location preserved unchanged.
29
+
30
+ **Breaking change for existing 0.1.31 operators**: nothing breaks, but the recommended Claude Desktop config snippet has changed. Re-run `nsauditor-ai mcp install-key` after upgrading to get the new auto-generated snippet with absolute paths + license indirection. Old configs continue to work.
31
+
32
+ ```bash
33
+ npm install -g nsauditor-ai@0.1.32
34
+ nsauditor-ai mcp install-key # prints the new snippet — paste into Claude Desktop config
35
+ nsauditor-ai mcp tier # confirm the actual MCP server tier (ground truth)
36
+ ```
37
+
38
+ See [Authentication](#authentication-required-new-in-0131) and [Troubleshooting MCP authentication](#troubleshooting-mcp-authentication) for full setup + diagnostics.
39
+
40
+ ---
41
+
18
42
  ## What's New (0.1.31) — security release
19
43
 
20
44
  **MCP server authentication is now required.** Pre-0.1.31, the local MCP server (stdio transport) accepted any incoming JSON-RPC frames — any process running as your user could spawn it and use the Pro/Enterprise tools (which include the AWS-talking shadow-admin path detectors that ship in `@nsasoft/nsauditor-ai-ee@0.3.4`). 0.1.31 closes this gap with a per-operator shared-secret check at startup.
@@ -362,7 +386,17 @@ nsauditor-ai mcp rotate-key --confirm # generates a new key (invalidates old
362
386
  | Future HTTP/SSE transport network exposure | ✅ — key gates server startup, not network |
363
387
  | Attacker with full operator code-exec AND can suppress macOS Keychain prompts | ⚠ partial — recent macOS versions log Keychain-access denial events |
364
388
  | Debugger-attach memory snooping | ⚠ out of scope (any shared-secret auth has this limit) |
365
- | Linux env-var visibility in `/proc/<pid>/environ` | ⚠ partial — only literal-key configs leak; the macOS keychain: indirection avoids this entirely |
389
+ | Linux env-var visibility in `/proc/<pid>/environ` | ⚠ partial — see Linux note below |
390
+
391
+ **Linux note (`/proc/<pid>/environ`)**: on modern Linux, `/proc/<pid>/environ` is readable only by the process owner (the same user that spawned the MCP server). Other users on a multi-user system **cannot** read your MCP auth key from `/proc` under default kernel settings. The realistic remaining risks are:
392
+
393
+ - Container scenarios where multiple "users" share the same kernel UID (e.g., a Docker container running as root, with multiple processes inside) — the secret is visible to any process in the same UID namespace. Mitigation: run the MCP server in its own container / user.
394
+ - Audit/SIEM agents with broad read access (e.g., `auditd` configured to log child-process env). Mitigation: review your `auditd` rules; modern setups exclude env from logs by default.
395
+ - The legacy `ps eww` command on older POSIX systems (modern `ps` respects `/proc` permissions).
396
+
397
+ A shell-wrapper indirection script (read key from `~/.nsauditor/.env` at exec time, pass to child) was considered for v1 but does NOT solve the underlying issue: the spawned MCP server still needs the key in its env to perform the auth check, so it appears in `/proc/<server-pid>/environ` regardless of how the parent process obtained it. v2 may add libsecret integration on Linux to mirror the macOS Keychain indirection model.
398
+
399
+ **Rotation cadence (NEW in 0.1.32)**: keys older than 90 days emit a soft warning at every server startup AND in `nsauditor-ai mcp status` output. SOC 2 CC6.1 / CC6.7 reviewers expect a credential-rotation cadence; rotate with `nsauditor-ai mcp rotate-key --confirm` and update Claude Desktop config with the new key.
366
400
 
367
401
  **Escape hatch for CI / dev** (operator-acknowledged risk; emits a stderr warning every startup):
368
402
 
@@ -426,6 +460,29 @@ claude mcp add nsauditor-ai \
426
460
 
427
461
  **"MCP_AUTH uses keychain: indirection but the referenced Keychain entry could not be read"** → typically a headless macOS / SSH-only CI runner where there's no GUI session to approve Keychain access. Replace the `keychain:` placeholder with the literal key value (or move auth to `~/.nsauditor/.env` with mode 0600).
428
462
 
463
+ **`mcp status` reports `keychain-locked`** (NEW in 0.1.32) → distinct from `unconfigured`: the Keychain entry exists but the security daemon refused to unlock without a GUI prompt. Same workarounds as the previous error: approve a Keychain GUI prompt, replace `keychain:` indirection with the literal key, or move auth to `~/.nsauditor/.env`. Pre-0.1.32 the resolver silently fell through to the file branch and reported `unconfigured` — the new state distinguishes "operator never ran install-key" from "operator did run it but Keychain is locked right now".
464
+
465
+ **`mcp status` shows `⚠ Created: ... — > 90d threshold`** (NEW in 0.1.32) → key is older than the 90-day rotation cadence. Run `nsauditor-ai mcp rotate-key --confirm` and update Claude Desktop config with the new key. Server emits the same warning to stderr at every startup.
466
+
467
+ **Claude Desktop reports "Current tier: CE" despite `nsauditor-ai license --status` showing Enterprise** (NEW in 0.1.32) → first run `nsauditor-ai mcp tier` to get the **ground-truth tier** the MCP server actually resolves at startup. If `mcp tier` reports `enterprise` but Claude Desktop's `list_plugins` says CE, **Claude (the AI) is synthesizing the tier text from context rather than calling the tool**. Force a real call by asking in a NEW Claude Desktop conversation:
468
+
469
+ > Use the `list_plugins` MCP tool right now and show me the raw tool response verbatim, including the exact text after the JSON.
470
+
471
+ Then check the MCP log to verify a real call happened:
472
+
473
+ ```bash
474
+ grep '"method":"tools/call"' ~/Library/Logs/Claude/mcp-server-nsauditor-ai.log | tail -5
475
+ ```
476
+
477
+ If `mcp tier` itself reports CE → genuine resolution failure. Inspect the license storage:
478
+
479
+ ```bash
480
+ nsauditor-ai license --status
481
+ security find-generic-password -s nsauditor-ai -a NSAUDITOR_LICENSE_KEY -w 2>&1 | head -c 30
482
+ ```
483
+
484
+ If license is in `~/.nsauditor/.env` but not in Keychain on macOS, re-run `nsauditor-ai mcp install-key` — the auto-mirror writes the license to Keychain so Claude Desktop's child process can read it via the `keychain:` indirection.
485
+
429
486
  ---
430
487
 
431
488
  ## Secure Credential Storage
package/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
10
  import path from 'node:path';
11
+ import { platform } from 'node:os';
11
12
  import { openaiSimplePrompt, openaiPrompt as openaiProPrompt, openaiPromptOptimized } from './utils/prompts.mjs';
12
13
  import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
13
14
  import { buildSarifLog } from './utils/sarif.mjs';
@@ -864,6 +865,9 @@ MCP server-auth subcommands (EE-SEC.1):
864
865
  nsauditor-ai mcp print-key --confirm Reveal the stored key (use with care)
865
866
  nsauditor-ai mcp rotate-key Replace the stored key with a fresh one
866
867
  nsauditor-ai mcp status Show storage source without revealing the key
868
+ nsauditor-ai mcp tier Print actual MCP server tier (ground truth — bypasses
869
+ Claude AI synthesis when "list_plugins" reports
870
+ unexpected CE despite verified Pro/Enterprise license)
867
871
 
868
872
  Security subcommands (macOS Keychain):
869
873
  nsauditor-ai security set <KEY> Store a secret (read from stdin)
@@ -1086,57 +1090,159 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1086
1090
  const rawArgs = process.argv.slice(2);
1087
1091
  const subCmd = rawArgs[1]; // install-key | print-key | rotate-key | status
1088
1092
 
1089
- function printConfigSnippet(key, persistedLocation) {
1090
- // Claude Desktop config snippet the canonical client integration
1091
- // for stdio MCP servers. Operators paste this into
1092
- // ~/Library/Application Support/Claude/claude_desktop_config.json
1093
- // (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows).
1093
+ async function printConfigSnippet(key, persistedLocation) {
1094
+ // Thread K (CE 0.1.32): generate a MACHINE-SPECIFIC config snippet
1095
+ // so customers don't have to figure out:
1096
+ // - which Node binary Claude Desktop should call (system /
1097
+ // homebrew / nvm / fnm — Claude Desktop's launchd PATH does
1098
+ // NOT include nvm/fnm bin dirs reliably)
1099
+ // - which absolute path to the .mjs script
1100
+ // - whether to use `keychain:` indirection (macOS only) or
1101
+ // literal value (Linux/Windows)
1102
+ // - whether to also include the license env line (only if
1103
+ // license is configured AND we can avoid baking the JWT
1104
+ // into the world-readable config file)
1094
1105
  //
1095
- // Reviewer 2 CRITICAL #2 fold: when the key was persisted to
1096
- // macOS Keychain, emit a `keychain:` indirection in the snippet
1097
- // instead of the literal key. Claude Desktop's config file is
1098
- // typically world-readable on macOS (default umask 0644); baking
1099
- // the secret into it defeats the per-operator threat model.
1100
- // The MCP server resolves `keychain:LABEL` at startup via
1101
- // resolveSecret() (utils/keychain.mjs:105), pulling the actual
1102
- // value from Keychain secret never leaves the secure store.
1103
- //
1104
- // On Linux/Windows there's no equivalent secret store today; the
1105
- // snippet uses the literal key value with an explicit chmod warning.
1106
+ // This eliminates the install-type matrix that was the #1
1107
+ // source of customer-onboarding friction.
1108
+ const isDarwin = platform() === 'darwin';
1109
+
1110
+ // process.execPath is the actual Node binary executing this CLI.
1111
+ // For nvm: /Users/<u>/.nvm/versions/node/vX.Y.Z/bin/node
1112
+ // For homebrew: /opt/homebrew/bin/node
1113
+ // For system: /usr/local/bin/node or /usr/bin/node
1114
+ // Always absolute; always the right binary that loaded our code.
1115
+ const nodeBin = process.execPath;
1116
+
1117
+ // The script path: derive from where THIS cli.mjs file lives,
1118
+ // then walk to the bin/ directory. cli.mjs is at the package
1119
+ // root; bin/nsauditor-ai-mcp.mjs is its sibling.
1120
+ // import.meta.url gives the file:// URL of THIS cli.mjs.
1121
+ const cliUrl = new URL(import.meta.url);
1122
+ const cliPath = fileURLToPath(cliUrl);
1123
+ const pkgRoot = path.dirname(cliPath);
1124
+ const mcpScriptPath = path.join(pkgRoot, 'bin', 'nsauditor-ai-mcp.mjs');
1125
+
1126
+ // MCP auth: keychain: indirection on macOS (no plaintext in
1127
+ // config file). Literal value on Linux/Windows where there's
1128
+ // no system secret store equivalent.
1106
1129
  const onKeychain = typeof persistedLocation === 'string' && persistedLocation.includes('Keychain');
1107
- const envValue = onKeychain ? `keychain:${MCP_AUTH_ENV_VAR}` : key;
1130
+ const authEnvValue = onKeychain ? `keychain:${MCP_AUTH_ENV_VAR}` : key;
1131
+
1132
+ // License: detect whether configured and where it lives. On
1133
+ // macOS, prefer the keychain: indirection — same no-plaintext
1134
+ // pattern as auth. If license is in file (~/.nsauditor/.env)
1135
+ // but NOT in Keychain, we'll prompt the operator to migrate
1136
+ // (handled by the caller; this fn just emits the snippet).
1137
+ const { loadLicense } = await import('./utils/license.mjs');
1138
+ const licenseStatus = await loadLicense();
1139
+ const licenseConfigured = licenseStatus.valid;
1140
+
1141
+ // Build the env block as a JSON-serializable object so the
1142
+ // snippet output is valid JSON the operator can paste verbatim.
1143
+ const envBlock = {};
1144
+ envBlock[MCP_AUTH_ENV_VAR] = authEnvValue;
1145
+ if (licenseConfigured) {
1146
+ if (isDarwin) {
1147
+ // Indirection — secret stays in Keychain. Requires that
1148
+ // the license actually IS in Keychain (or in a place
1149
+ // resolveSecret can reach). Caller is responsible for
1150
+ // ensuring this is true before printing the snippet.
1151
+ envBlock['NSAUDITOR_LICENSE_KEY'] = 'keychain:NSAUDITOR_LICENSE_KEY';
1152
+ }
1153
+ // On Linux/Windows we deliberately OMIT NSAUDITOR_LICENSE_KEY
1154
+ // from the env block — the MCP server will fall through to
1155
+ // the file fallback (~/.nsauditor/.env). Including a literal
1156
+ // JWT would expose it in the world-readable config file.
1157
+ }
1158
+
1159
+ const snippet = {
1160
+ mcpServers: {
1161
+ 'nsauditor-ai': {
1162
+ command: nodeBin,
1163
+ args: [mcpScriptPath],
1164
+ env: envBlock,
1165
+ },
1166
+ },
1167
+ };
1108
1168
 
1109
1169
  console.log('');
1110
- console.log('Add this to your Claude Desktop config (claude_desktop_config.json):');
1111
- console.log('');
1112
- console.log(' {');
1113
- console.log(' "mcpServers": {');
1114
- console.log(' "nsauditor-ai": {');
1115
- console.log(' "command": "nsauditor-ai-mcp",');
1116
- console.log(` "env": { "${MCP_AUTH_ENV_VAR}": "${envValue}" }`);
1117
- console.log(' }');
1118
- console.log(' }');
1119
- console.log(' }');
1170
+ console.log('═'.repeat(70));
1171
+ console.log('Claude Desktop config — paste this into:');
1172
+ if (isDarwin) {
1173
+ console.log(' ~/Library/Application Support/Claude/claude_desktop_config.json');
1174
+ } else if (platform() === 'win32') {
1175
+ console.log(' %APPDATA%\\Claude\\claude_desktop_config.json');
1176
+ } else {
1177
+ console.log(' ~/.config/Claude/claude_desktop_config.json');
1178
+ }
1179
+ console.log(''.repeat(70));
1180
+ // Pretty-print with 2-space indent. If the operator already has
1181
+ // mcpServers, they merge the inner "nsauditor-ai" block.
1182
+ console.log(JSON.stringify(snippet, null, 2));
1183
+ console.log('═'.repeat(70));
1120
1184
  console.log('');
1185
+
1121
1186
  if (onKeychain) {
1122
- console.log(`The "${envValue}" placeholder tells the MCP server to resolve the actual`);
1123
- console.log('key from your macOS Keychain at startup. The secret value never leaves');
1124
- console.log("the Keychain and is never written to your Claude Desktop config file.");
1187
+ // macOS + Keychain reachable: the snippet uses indirection
1188
+ // for both auth and license. Secret never lands in the
1189
+ // world-readable Claude Desktop config file.
1190
+ console.log('Security notes:');
1191
+ console.log(` • Auth key uses "keychain:${MCP_AUTH_ENV_VAR}" indirection — the actual`);
1192
+ console.log(' secret stays in macOS Keychain. The config file contains only the');
1193
+ console.log(' placeholder string, NOT the secret.');
1194
+ if (licenseConfigured) {
1195
+ console.log(' • License key uses the same indirection — JWT never lands in the config.');
1196
+ } else {
1197
+ console.log(' • No license configured. To activate Pro/Enterprise features:');
1198
+ console.log(' nsauditor-ai license install <YOUR-KEY>');
1199
+ console.log(' Then re-run `nsauditor-ai mcp install-key` to get a snippet that');
1200
+ console.log(' includes the license line.');
1201
+ }
1125
1202
  console.log('');
1126
- console.log('On a headless macOS / CI runner where Keychain access is unavailable,');
1127
- console.log(`replace the placeholder with the literal key (run \`nsauditor-ai mcp`);
1128
- console.log(`print-key --confirm\` to retrieve it).`);
1203
+ console.log('On a HEADLESS macOS / SSH-only CI runner where Keychain GUI prompts');
1204
+ console.log(" won't reach you, replace the placeholder values with the literal");
1205
+ console.log(' secrets (run `nsauditor-ai mcp print-key --confirm` for the auth');
1206
+ console.log(' key). Move the config file to mode 0600 in that case.');
1129
1207
  } else {
1130
- console.log(`⚠ The literal key value is now in your Claude Desktop config file.`);
1131
- console.log(` On a multi-user system, ensure that file is mode 0600 (operator-only).`);
1132
- console.log(` On Linux, also note that the spawned MCP server's env block is`);
1133
- console.log(` visible to other users via /proc same caveat as any env-based secret.`);
1208
+ // Linux/Windows OR macOS-with-Keychain-unavailable: snippet
1209
+ // contains literal secret. chmod warning required.
1210
+ console.log('Security notes:');
1211
+ console.log(` Auth key value is the LITERAL secret in the config file.`);
1212
+ console.log(' chmod 600 your Claude Desktop config file to keep other local users');
1213
+ console.log(' from reading it:');
1214
+ if (platform() === 'win32') {
1215
+ console.log(' icacls "%APPDATA%\\Claude\\claude_desktop_config.json" /inheritance:r /grant:r "%USERNAME%:F"');
1216
+ } else if (isDarwin) {
1217
+ console.log(' chmod 600 ~/Library/Application\\ Support/Claude/claude_desktop_config.json');
1218
+ } else {
1219
+ console.log(' chmod 600 ~/.config/Claude/claude_desktop_config.json');
1220
+ }
1221
+ if (licenseConfigured) {
1222
+ console.log(' • License key is NOT in the env block — the MCP server reads it from');
1223
+ console.log(' ~/.nsauditor/.env (mode 0600) at startup. No JWT in the config.');
1224
+ } else {
1225
+ console.log(' • No license configured. To activate Pro/Enterprise features:');
1226
+ console.log(' nsauditor-ai license install <YOUR-KEY>');
1227
+ }
1228
+ }
1229
+
1230
+ console.log('');
1231
+ console.log('After pasting:');
1232
+ console.log(' 1. Save the config file');
1233
+ console.log(' 2. Cmd+Q Claude Desktop (full quit) and re-launch');
1234
+ if (isDarwin) {
1235
+ console.log(' 3. macOS will prompt for Keychain access on first launch — click "Always Allow"');
1236
+ console.log(' for both NSA_MCP_AUTH_KEY and NSAUDITOR_LICENSE_KEY entries.');
1134
1237
  }
1238
+ console.log(' 4. Verify in Claude: ask "list nsauditor plugins"');
1239
+ console.log(` Tier should report as "${licenseConfigured ? licenseStatus.tier : 'ce'}"`);
1135
1240
  console.log('');
1136
- console.log(`The configured key is also stored at: ${persistedLocation || '<see install-key output>'}`);
1137
- console.log('The MCP server compares the env-resolved key against the stored key at');
1138
- console.log('startup and refuses to start on mismatch — so anyone trying to spawn the');
1139
- console.log('server without the right key fails immediately.');
1241
+ console.log('Diagnostic if it doesn\'t work:');
1242
+ console.log(' nsauditor-ai mcp status # confirm storage source');
1243
+ if (licenseConfigured) {
1244
+ console.log(' nsauditor-ai license --status # confirm license still verified');
1245
+ }
1140
1246
  }
1141
1247
 
1142
1248
  if (subCmd === 'install-key') {
@@ -1172,7 +1278,43 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1172
1278
  }
1173
1279
  console.log(`✓ MCP auth key ${generated ? 'generated and ' : ''}installed`);
1174
1280
  console.log(` Stored at: ${persisted.location}`);
1175
- printConfigSnippet(key, persisted.location);
1281
+
1282
+ // Thread K: if license is configured but NOT in Keychain (e.g.,
1283
+ // operator has it in ~/.nsauditor/.env from a pre-0.1.30 install
1284
+ // or from manual editing), back-fill to Keychain on macOS so the
1285
+ // `keychain:NSAUDITOR_LICENSE_KEY` indirection in the printed
1286
+ // snippet actually resolves. Without this, the snippet would
1287
+ // include the indirection but the MCP server would fail to find
1288
+ // the license and report CE — exactly the customer-onboarding
1289
+ // friction Thread K eliminates.
1290
+ if (platform() === 'darwin') {
1291
+ try {
1292
+ const { resolveLicenseKey } = await import('./utils/license.mjs');
1293
+ const { keychainGetDetailed, keychainSet } = await import('./utils/keychain.mjs');
1294
+ const licenseKey = await resolveLicenseKey();
1295
+ if (licenseKey) {
1296
+ const keychainState = await keychainGetDetailed('NSAUDITOR_LICENSE_KEY');
1297
+ if (keychainState.state !== 'ok') {
1298
+ // License is reachable via env or file but not Keychain.
1299
+ // Mirror it into Keychain so the indirection works.
1300
+ await keychainSet('NSAUDITOR_LICENSE_KEY', licenseKey);
1301
+ console.log('');
1302
+ console.log(' ✓ License key mirrored from file/env to macOS Keychain');
1303
+ console.log(' so the snippet below can use keychain: indirection.');
1304
+ console.log(' Original storage location is preserved unchanged.');
1305
+ }
1306
+ }
1307
+ } catch (err) {
1308
+ // Best-effort: if the mirror fails, the snippet's keychain:
1309
+ // indirection won't resolve, but the operator can fall back
1310
+ // to literal-key configuration. Don't block install-key.
1311
+ console.warn(` ⚠ Could not mirror license to Keychain (${err.message}).`);
1312
+ console.warn(` The snippet below uses keychain: indirection — if Claude Desktop`);
1313
+ console.warn(` reports CE tier, replace the indirection with the literal license JWT.`);
1314
+ }
1315
+ }
1316
+
1317
+ await printConfigSnippet(key, persisted.location);
1176
1318
  } else if (subCmd === 'rotate-key') {
1177
1319
  // Generate a fresh key and persist over the old one. Old key is
1178
1320
  // immediately invalid — operator must update Claude Desktop
@@ -1202,7 +1344,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1202
1344
  console.log(` Stored at: ${persisted.location}`);
1203
1345
  console.log('');
1204
1346
  console.log(' ⚠ The OLD key is now invalid. Update your Claude Desktop config NOW.');
1205
- printConfigSnippet(key, persisted.location);
1347
+ await printConfigSnippet(key, persisted.location);
1206
1348
  } else if (subCmd === 'print-key') {
1207
1349
  // Reveal the stored key — gated behind --confirm to defend
1208
1350
  // against accidental shell-history capture. Reviewer 2 CRITICAL #1
@@ -1242,10 +1384,62 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1242
1384
  // visible terminal (otherwise we'd refuse). Operators copy from
1243
1385
  // the visible terminal output, not from a redirected stdout.
1244
1386
  process.stderr.write(`${key}\n`);
1387
+ } else if (subCmd === 'tier') {
1388
+ // Thread K (CE 0.1.32): customer-side ground-truth check for the
1389
+ // tier the MCP server WOULD resolve to at startup. Customers
1390
+ // (including the maintainer 2026-05-10) reported "Claude Desktop
1391
+ // shows tier=CE" — but on investigation, Claude (the AI) was
1392
+ // synthesizing the tier text from training data + context
1393
+ // without actually calling list_plugins via MCP. The real MCP
1394
+ // server's _tier was correct (enterprise), only Claude's
1395
+ // narration was wrong. `mcp tier` runs the same loadLicense()
1396
+ // path the MCP server uses and prints the unambiguous result —
1397
+ // customers can paste this output into a support ticket and
1398
+ // distinguish "MCP genuinely broken" from "Claude misreading".
1399
+ const { loadLicense, getTierFromEnv } = await import('./utils/license.mjs');
1400
+ const result = await loadLicense();
1401
+ const tier = getTierFromEnv();
1402
+ const tierLabels = {
1403
+ ce: 'Community Edition (CE)',
1404
+ pro: 'Pro',
1405
+ enterprise: 'Enterprise',
1406
+ };
1407
+ const symbol = tier === 'ce' ? '✗' : '✓';
1408
+ console.log(`${symbol} MCP server tier: ${tier} — ${tierLabels[tier] ?? tier}`);
1409
+ if (result.valid) {
1410
+ console.log(` Org: ${result.org}`);
1411
+ console.log(` Seats: ${result.seats}`);
1412
+ console.log(` License ID: ${result.licenseId}`);
1413
+ console.log(` Expires: ${result.expiresAt}`);
1414
+ if (result.daysUntilExpiry !== undefined) {
1415
+ console.log(` Renews in: ${result.daysUntilExpiry} days`);
1416
+ }
1417
+ if (result.expiryWarning) {
1418
+ console.log(` ⚠ ${result.expiryWarning}`);
1419
+ }
1420
+ } else {
1421
+ console.log(` Reason: ${result.reason}`);
1422
+ console.log('');
1423
+ console.log(' Diagnose with: nsauditor-ai license --status');
1424
+ console.log(' Install with: nsauditor-ai license install <KEY>');
1425
+ }
1426
+ console.log('');
1427
+ console.log('This is the EXACT tier the spawned MCP server resolves to. If Claude');
1428
+ console.log("Desktop reports a different tier, Claude isn't calling list_plugins —");
1429
+ console.log('it\'s synthesizing from context. Force a real call by asking:');
1430
+ console.log(' "Use the list_plugins MCP tool right now and show the raw response."');
1431
+ // Exit 0 if any tier resolved (success), 1 if CE/no key (operator action needed).
1432
+ process.exit(tier === 'ce' ? 1 : 0);
1245
1433
  } else if (subCmd === 'status') {
1246
1434
  // Report which storage source the resolver currently honors,
1247
1435
  // WITHOUT printing the key value. Safe to run in screen-share,
1248
- // logs, etc.
1436
+ // logs, etc. EE-SEC.1.1 (Thread I): also surfaces key age + the
1437
+ // keychain-locked state distinction (so headless macOS / SSH
1438
+ // sessions get an actionable error rather than a generic
1439
+ // "unconfigured" fallthrough). MEDIUM #4 fold: rotation
1440
+ // threshold respects NSA_MCP_AUTH_KEY_ROTATION_DAYS env override.
1441
+ const { getRotationWarningDays: _grwd } = await import('./utils/mcp_auth.mjs');
1442
+ const _rwd = _grwd();
1249
1443
  const result = await reportMcpAuthSource();
1250
1444
  if (result.source === 'unconfigured') {
1251
1445
  console.log(`✗ MCP authentication is not configured.`);
@@ -1255,9 +1449,57 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1255
1449
  console.log(` ⚠ ${MCP_AUTH_DISABLE_ENV_VAR}=1 is set — server will start without auth.`);
1256
1450
  }
1257
1451
  process.exit(1);
1452
+ } else if (result.source === 'keychain-locked') {
1453
+ // EE-SEC.1.1 (Reviewer 2 MEDIUM #3 from EE-SEC.1): Keychain
1454
+ // entry exists but the security daemon refused to unlock —
1455
+ // GUI prompt unavailable. Common on SSH sessions, headless
1456
+ // CI runners, and login-keychain-not-unlocked-yet scenarios.
1457
+ // MEDIUM #3 fold (post-EE-SEC.1.1): operators triggering this
1458
+ // branch are by construction in a no-GUI context. Reorder
1459
+ // workarounds to put the GUI-FREE options first; "approve
1460
+ // GUI prompt" demoted to the fallback for operators who DO
1461
+ // have GUI access (rare given they hit this branch).
1462
+ console.log(`⚠ MCP auth key is configured in macOS Keychain, but Keychain access`);
1463
+ console.log(` is currently locked (security daemon refused without GUI prompt).`);
1464
+ console.log(` This is normal on SSH sessions and headless CI runners.`);
1465
+ console.log('');
1466
+ console.log(` Detail: ${result.detail}`);
1467
+ console.log('');
1468
+ console.log(` Workarounds (GUI-free paths first):`);
1469
+ console.log(` 1. Replace the keychain: indirection in Claude Desktop config`);
1470
+ console.log(` with the literal key value (run \`mcp print-key --confirm\`).`);
1471
+ console.log(` 2. Move auth to the file fallback by setting NSA_MCP_AUTH_KEY`);
1472
+ console.log(` in ~/.nsauditor/.env directly (mode 0600).`);
1473
+ console.log(` 3. If you have GUI access: approve a Keychain prompt in the`);
1474
+ console.log(` macOS GUI session.`);
1475
+ process.exit(1);
1258
1476
  } else {
1259
1477
  console.log(`✓ MCP auth key configured`);
1260
1478
  console.log(` Source: ${result.source}${result.detail ? ` (${result.detail})` : ''}`);
1479
+ // EE-SEC.1.1: surface key age when known. Older installs
1480
+ // (predating the timestamp companion) get null and the
1481
+ // CRITICAL #1 hint below instead.
1482
+ if (typeof result.ageDays === 'number') {
1483
+ const ageStr = result.ageDays === 0 ? 'today' : `${result.ageDays} day${result.ageDays === 1 ? '' : 's'} ago`;
1484
+ if (result.ageDays > _rwd) {
1485
+ console.log(` ⚠ Created: ${result.createdAt} (${ageStr}) — > ${_rwd}d threshold`);
1486
+ console.log(` Consider: nsauditor-ai mcp rotate-key --confirm`);
1487
+ console.log(` SOC 2 CC6.1 / CC6.7 reviewers flag unrotated shared secrets.`);
1488
+ } else {
1489
+ console.log(` Created: ${result.createdAt} (${ageStr})`);
1490
+ }
1491
+ } else if (result.legacyTimestampMissing) {
1492
+ // CRITICAL #1 fold (post-review): existing CE 0.1.31
1493
+ // operators upgrading have a key but no timestamp →
1494
+ // ageDays is null → silent. Distinct hint pointing at
1495
+ // backfill so SOC 2 evidence isn't dark for the installed base.
1496
+ console.log(` ⚠ Created: unknown (pre-0.1.32 install — no rotation timestamp)`);
1497
+ console.log(` Backfill the timestamp without invalidating the key:`);
1498
+ console.log(` nsauditor-ai mcp print-key --confirm # retrieve current key`);
1499
+ console.log(` nsauditor-ai mcp install-key <KEY> # re-install with timestamp`);
1500
+ console.log(` Or rotate to a fresh key:`);
1501
+ console.log(` nsauditor-ai mcp rotate-key --confirm`);
1502
+ }
1261
1503
  if (process.env[MCP_AUTH_DISABLE_ENV_VAR] === '1') {
1262
1504
  console.log('');
1263
1505
  console.log(` ⚠ ${MCP_AUTH_DISABLE_ENV_VAR}=1 is set — server will start without auth.`);
@@ -1270,6 +1512,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
1270
1512
  console.log(' nsauditor-ai mcp print-key --confirm Reveal the stored key (use with care)');
1271
1513
  console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
1272
1514
  console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
1515
+ console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
1273
1516
  console.log('');
1274
1517
  console.log('Environment variables:');
1275
1518
  console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
package/mcp_server.mjs CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
23
23
  import { resolveCapabilities } from './utils/capabilities.mjs';
24
24
  import { buildMarkdownReport } from './utils/report_md.mjs';
25
- import { authorizeMcpServerStartup } from './utils/mcp_auth.mjs';
25
+ import { authorizeMcpServerStartup, getMcpAuthKeyAge, getRotationWarningDays, reportMcpAuthSource } from './utils/mcp_auth.mjs';
26
26
 
27
27
  const _require = createRequire(import.meta.url);
28
28
  const { version: TOOL_VERSION } = _require('./package.json');
@@ -436,6 +436,47 @@ if (isMainModule) {
436
436
  }
437
437
  }
438
438
 
439
+ // EE-SEC.1.1 (Thread I): rotation-cadence soft warning. SOC 2 CC6.1 /
440
+ // CC6.7 reviewers expect a credential-rotation cadence; an unrotated
441
+ // shared secret is treated the same way as an unrotated IAM access
442
+ // key.
443
+ //
444
+ // Design note (LOW #1 from Reviewer 2): soft warning, not hard
445
+ // refusal. Hard-refuse would break Claude Desktop integration mid-
446
+ // session for operators who haven't seen 30 days of warnings (Claude
447
+ // Desktop buries MCP server stderr in a log file most operators
448
+ // never check). Soft warning + `mcp status` operator-runnable
449
+ // evidence path is the right SOC 2 posture — auditor sees the warning
450
+ // fires AND that the operator chose to defer rotation.
451
+ //
452
+ // Reviewer 2 CRITICAL #1 fold: when a key is configured but the
453
+ // NSA_MCP_AUTH_KEY_CREATED companion is missing (operator upgraded
454
+ // from CE 0.1.31 without re-installing), we surface a DIFFERENT
455
+ // stderr message pointing at `mcp install-key <existing-key>` to
456
+ // backfill the timestamp. Without this hint, EE-SEC.1.1 ships dark
457
+ // for the entire installed base of CE 0.1.31 deployments.
458
+ try {
459
+ const status = await reportMcpAuthSource();
460
+ const threshold = getRotationWarningDays();
461
+ if (status.legacyTimestampMissing) {
462
+ process.stderr.write(
463
+ `⚠ MCP auth key is configured but the rotation-cadence timestamp ` +
464
+ `is missing (likely a pre-0.1.32 install). Rotation warnings will ` +
465
+ `not fire until you backfill the timestamp. Either:\n` +
466
+ ` (1) re-run \`nsauditor-ai mcp install-key <KEY>\` with your existing key ` +
467
+ `(use \`mcp print-key --confirm\` to retrieve it), OR\n` +
468
+ ` (2) rotate to a fresh key with \`nsauditor-ai mcp rotate-key --confirm\` ` +
469
+ `and update Claude Desktop config.\n`,
470
+ );
471
+ } else if (typeof status.ageDays === 'number' && status.ageDays > threshold) {
472
+ process.stderr.write(
473
+ `⚠ MCP auth key is ${status.ageDays} days old (> ${threshold}d threshold). ` +
474
+ `Consider \`nsauditor-ai mcp rotate-key --confirm\` and update Claude Desktop config. ` +
475
+ `SOC 2 CC6.1 / CC6.7 reviewers flag unrotated shared secrets.\n`,
476
+ );
477
+ }
478
+ } catch { /* age check is non-fatal — never block startup on it */ }
479
+
439
480
  // Verify license JWT before accepting MCP requests — upgrades _tier from
440
481
  // prefix-based to cryptographically verified.
441
482
  await loadLicense();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -33,6 +33,61 @@ export async function keychainGet(account) {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Diagnostic variant of keychainGet that distinguishes failure modes.
38
+ * Used by EE-SEC.1.1 (Thread I) to surface a different error message
39
+ * for "Keychain locked / GUI prompt needed" (headless macOS / SSH-only
40
+ * CI) vs "no entry exists" (operator never ran install-key).
41
+ *
42
+ * Returns { value, state, raw }:
43
+ * - state='ok' — value is the secret
44
+ * - state='not-found' — Keychain has no entry for this account
45
+ * - state='locked' — Keychain entry exists but GUI approval needed
46
+ * - state='unavailable' — security daemon error (non-mac platform,
47
+ * timeout, etc.)
48
+ *
49
+ * On non-macOS platforms, returns { value: null, state: 'unavailable' }.
50
+ *
51
+ * @param {string} account
52
+ * @returns {Promise<{ value: string|null, state: 'ok'|'not-found'|'locked'|'unavailable', raw?: string }>}
53
+ */
54
+ export async function keychainGetDetailed(account) {
55
+ if (!isMac) {
56
+ return { value: null, state: 'unavailable', raw: 'not-macos' };
57
+ }
58
+ try {
59
+ const value = await exec('security', [
60
+ 'find-generic-password', '-s', SERVICE, '-a', account, '-w'
61
+ ]);
62
+ return { value, state: 'ok' };
63
+ } catch (err) {
64
+ const raw = (err && err.message) ? err.message : String(err);
65
+ // `security` returns specific stderr per failure mode. We pattern-
66
+ // match on the stable English text — the underlying SecKeychain
67
+ // API error codes (errSecInteractionNotAllowed=-25308,
68
+ // errSecItemNotFound=-25300) are also embedded but the strings
69
+ // are more reliable across macOS versions.
70
+ // EE-SEC.1.1 LOW #2 fold (post-review): also pattern-match numeric
71
+ // SecKeychain error codes (-25300 errSecItemNotFound, -25308
72
+ // errSecInteractionNotAllowed). The English-text patterns are
73
+ // stable on US-locale macOS but break on localized installs
74
+ // (French/Japanese/etc.); the numeric codes appear in the security
75
+ // command's stderr regardless of locale.
76
+ if (/could not be found in the keychain/i.test(raw)
77
+ || /SecKeychainSearchCopyNext: The specified item could not be found/i.test(raw)
78
+ || /errSecItemNotFound/i.test(raw)
79
+ || /-25300\b/.test(raw)) {
80
+ return { value: null, state: 'not-found', raw };
81
+ }
82
+ if (/User interaction is not allowed/i.test(raw)
83
+ || /errSecInteractionNotAllowed/i.test(raw)
84
+ || /-25308\b/.test(raw)) {
85
+ return { value: null, state: 'locked', raw };
86
+ }
87
+ return { value: null, state: 'unavailable', raw };
88
+ }
89
+ }
90
+
36
91
  /**
37
92
  * Store a secret in the macOS Keychain.
38
93
  * Updates existing entry if present.
package/utils/license.mjs CHANGED
@@ -11,7 +11,7 @@ import { promises as fsp } from 'node:fs';
11
11
  import { homedir, platform } from 'node:os';
12
12
  import { dirname, join } from 'node:path';
13
13
  import dotenv from 'dotenv';
14
- import { keychainGet, keychainSet } from './keychain.mjs';
14
+ import { keychainGet, keychainSet, resolveSecret } from './keychain.mjs';
15
15
 
16
16
  // ES256 public key — embedded directly so it works in npm package (no file read).
17
17
  // Corresponding private key is in the license-manager service (NEVER shipped here).
@@ -286,9 +286,24 @@ export async function loadLicense(keyStr) {
286
286
  // Explicit keyStr argument wins (preserves the existing behavior for
287
287
  // callers like the `license --status` subcommand which passes the env
288
288
  // var directly). When omitted, run the multi-source resolution chain.
289
- const raw = keyStr ?? (await resolveLicenseKey());
289
+ let raw = keyStr ?? (await resolveLicenseKey());
290
290
  if (!raw) return { valid: false, tier: 'ce', reason: 'no key provided' };
291
291
 
292
+ // Thread K (CE 0.1.32): support `keychain:LABEL` indirection on the
293
+ // resolved value. Mirrors the EE-SEC.1 MCP-auth pattern — operators
294
+ // can put `"NSAUDITOR_LICENSE_KEY": "keychain:NSAUDITOR_LICENSE_KEY"`
295
+ // in their Claude Desktop config env block; the literal JWT never
296
+ // lands in the world-readable config file. resolveSecret is a no-op
297
+ // (returns input unchanged) for non-`keychain:` strings, so literal
298
+ // JWT keys continue to work for backward compat.
299
+ if (typeof raw === 'string' && raw.startsWith('keychain:')) {
300
+ const resolved = await resolveSecret(raw);
301
+ if (!resolved) {
302
+ return { valid: false, tier: 'ce', reason: 'license keychain: indirection could not be resolved (entry missing or Keychain locked)' };
303
+ }
304
+ raw = resolved;
305
+ }
306
+
292
307
  // Strip tier prefix
293
308
  let token = raw;
294
309
  let prefixTier = null;
@@ -51,7 +51,7 @@ import { dirname, join } from 'node:path';
51
51
  import { randomBytes, timingSafeEqual } from 'node:crypto';
52
52
  import dotenv from 'dotenv';
53
53
 
54
- import { keychainGet, keychainSet, resolveSecret } from './keychain.mjs';
54
+ import { keychainGet, keychainSet, keychainGetDetailed, resolveSecret } from './keychain.mjs';
55
55
 
56
56
  // ── Constants ────────────────────────────────────────────────────────────────
57
57
 
@@ -71,6 +71,43 @@ export const MCP_AUTH_DISABLE_ENV_VAR = 'NSA_MCP_AUTH_DISABLE';
71
71
  // named so operators don't have to remember two strings.
72
72
  const MCP_AUTH_KEYCHAIN_ACCOUNT = MCP_AUTH_ENV_VAR;
73
73
 
74
+ // EE-SEC.1.1: created-at timestamp companion. Stored alongside the key
75
+ // so `mcp status` and the server-startup stderr can emit a soft warning
76
+ // when the key has been in use for more than ROTATION_WARNING_DAYS.
77
+ // SOC 2 CC6.1 / CC6.7 reviewers expect a credential rotation cadence;
78
+ // an unrotated MCP auth key is a finding the same way an unrotated
79
+ // IAM access key is a finding.
80
+ export const MCP_AUTH_CREATED_ENV_VAR = 'NSA_MCP_AUTH_KEY_CREATED';
81
+ const MCP_AUTH_CREATED_KEYCHAIN_ACCOUNT = MCP_AUTH_CREATED_ENV_VAR;
82
+ export const ROTATION_WARNING_DAYS = 90;
83
+ // EE-SEC.1.1 MEDIUM #4 fold (post-review): operator override env var.
84
+ // SOC 2 doesn't fix a number — common interpretations span 30/60/90/180.
85
+ // PCI-DSS pushes for 90d hard; NIST SP 800-63B prefers event-based rotation.
86
+ // An operator whose customer auditor demands 60 days needs a knob; clamping
87
+ // to [7, 365] prevents pathological values (sub-week = constant-warn-noise;
88
+ // > 1 year = effectively no rotation).
89
+ export const ROTATION_WARNING_DAYS_ENV_VAR = 'NSA_MCP_AUTH_KEY_ROTATION_DAYS';
90
+
91
+ /**
92
+ * Resolve the effective rotation-warning threshold. Reads
93
+ * NSA_MCP_AUTH_KEY_ROTATION_DAYS from the env (or `opts._env`),
94
+ * clamps to [7, 365], falls back to ROTATION_WARNING_DAYS (90).
95
+ *
96
+ * @param {object} [opts]
97
+ * @param {Record<string, string|undefined>} [opts._env]
98
+ * @returns {number}
99
+ */
100
+ export function getRotationWarningDays(opts = {}) {
101
+ const env = opts._env ?? process.env;
102
+ const raw = env[ROTATION_WARNING_DAYS_ENV_VAR];
103
+ if (!raw) return ROTATION_WARNING_DAYS;
104
+ const parsed = Number.parseInt(raw, 10);
105
+ if (!Number.isFinite(parsed)) return ROTATION_WARNING_DAYS;
106
+ return Math.max(7, Math.min(365, parsed));
107
+ }
108
+
109
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
110
+
74
111
  // Key prefix. Lets operators distinguish from license keys (`pro_eyJ...`,
75
112
  // `enterprise_eyJ...`) at a glance. 32 bytes of entropy → 43 chars
76
113
  // base64url after the prefix.
@@ -196,29 +233,153 @@ export async function resolveMcpAuthKey(opts = {}) {
196
233
  * verify their setup without exposing the secret to shell history /
197
234
  * tmux scrollback / screen-share.
198
235
  *
199
- * @param {object} [opts] same test seams as resolveMcpAuthKey.
200
- * @returns {Promise<{ source: 'env'|'keychain'|'file'|'unconfigured', detail?: string }>}
236
+ * EE-SEC.1.1: extended with `ageDays` (when a created-at timestamp is
237
+ * available) so `mcp status` and the server-startup stderr can emit a
238
+ * soft warning when the key has been in use for more than
239
+ * ROTATION_WARNING_DAYS. Also distinguishes Keychain-locked from
240
+ * Keychain-empty so headless macOS / SSH-only CI runners get an
241
+ * actionable error rather than a generic "unconfigured" fallthrough.
242
+ *
243
+ * @param {object} [opts] — same test seams as resolveMcpAuthKey, plus
244
+ * `_keychainGetDetailed` for the lock-state distinction and `_now`
245
+ * for hermetic age computation.
246
+ * @returns {Promise<{
247
+ * source: 'env'|'keychain'|'file'|'unconfigured'|'keychain-locked',
248
+ * detail?: string,
249
+ * ageDays?: number|null,
250
+ * createdAt?: string|null,
251
+ * }>}
201
252
  */
202
253
  export async function reportMcpAuthSource(opts = {}) {
254
+ const now = opts._now ? new Date(opts._now).getTime() : Date.now();
255
+
203
256
  if (process.env[MCP_AUTH_ENV_VAR]) {
204
- return { source: 'env', detail: MCP_AUTH_ENV_VAR };
257
+ // No createdAt available for env-supplied keys operator-managed,
258
+ // we don't know the rotation history. EE-SEC.1.1 CRITICAL #1 fold:
259
+ // surface a `legacyTimestampMissing: false` here so callers don't
260
+ // mis-interpret env-supplied as "old install missing timestamp".
261
+ return {
262
+ source: 'env',
263
+ detail: MCP_AUTH_ENV_VAR,
264
+ ageDays: null,
265
+ createdAt: null,
266
+ legacyTimestampMissing: false,
267
+ };
205
268
  }
206
- const kget = opts._keychainGet ?? keychainGet;
269
+
270
+ // EE-SEC.1.1: use detailed Keychain lookup so lock-state is preserved.
271
+ // Backward-compat: legacy callers (and EE-SEC.1 tests) supply
272
+ // _keychainGet which returns just `string|null`. Wrap it into the
273
+ // detailed shape so the resolver works for both APIs.
274
+ let kgetDetailed;
275
+ if (opts._keychainGetDetailed) {
276
+ kgetDetailed = opts._keychainGetDetailed;
277
+ } else if (opts._keychainGet) {
278
+ const legacyKget = opts._keychainGet;
279
+ kgetDetailed = async (account) => {
280
+ try {
281
+ const value = await legacyKget(account);
282
+ return value
283
+ ? { value, state: 'ok' }
284
+ : { value: null, state: 'not-found' };
285
+ } catch (err) {
286
+ return { value: null, state: 'unavailable', raw: err && err.message ? err.message : String(err) };
287
+ }
288
+ };
289
+ } else {
290
+ kgetDetailed = keychainGetDetailed;
291
+ }
292
+ let detailedResult = null;
207
293
  try {
208
- const fromKeychain = await kget(MCP_AUTH_KEYCHAIN_ACCOUNT);
209
- if (fromKeychain) {
210
- return { source: 'keychain', detail: 'macOS Keychain (service=nsauditor-ai)' };
294
+ detailedResult = await kgetDetailed(MCP_AUTH_KEYCHAIN_ACCOUNT);
295
+ } catch { /* defensive — should never throw, but tolerate test seams that do */ }
296
+ if (detailedResult && detailedResult.state === 'ok' && detailedResult.value) {
297
+ // Read the companion timestamp (best-effort; old installs may not
298
+ // have it). EE-SEC.1.1: skip the timestamp lookup when the caller
299
+ // only supplied the legacy `_keychainGet` seam — that seam returns
300
+ // the same value for any account name (it's a single-account stub),
301
+ // so calling it for the timestamp contaminates the result with the
302
+ // key value. Real `keychainGetDetailed` properly distinguishes by
303
+ // account.
304
+ let createdAt = null;
305
+ const usedLegacySeam = !opts._keychainGetDetailed && opts._keychainGet;
306
+ if (!usedLegacySeam) {
307
+ try {
308
+ const ts = await kgetDetailed(MCP_AUTH_CREATED_KEYCHAIN_ACCOUNT);
309
+ if (ts && ts.state === 'ok' && ts.value) createdAt = ts.value;
310
+ } catch { /* fall through */ }
211
311
  }
212
- } catch { /* fall through */ }
312
+ return {
313
+ source: 'keychain',
314
+ detail: 'macOS Keychain (service=nsauditor-ai)',
315
+ createdAt,
316
+ ageDays: _ageDaysFromIso(createdAt, now),
317
+ // EE-SEC.1.1 CRITICAL #1 fold: existing CE 0.1.31 operators
318
+ // upgrading to 0.1.32 have a key but no NSA_MCP_AUTH_KEY_CREATED
319
+ // companion — `getMcpAuthKeyAge` returns null and the rotation
320
+ // warning silently never fires. The auditor evidence story is
321
+ // broken until the operator happens to rotate. Distinguish
322
+ // "legacy install missing timestamp" from "env-supplied (no
323
+ // history known)" so cli.mjs `mcp status` and the server-startup
324
+ // stderr can prompt operators to backfill via `mcp install-key
325
+ // <existing-key>` (which preserves the key but writes the
326
+ // timestamp companion).
327
+ legacyTimestampMissing: createdAt === null,
328
+ };
329
+ }
330
+ if (detailedResult && detailedResult.state === 'locked') {
331
+ // Keychain has the entry but the security daemon refused to
332
+ // unlock — operator's GUI is unavailable (SSH / headless CI /
333
+ // login-keychain not unlocked yet).
334
+ return {
335
+ source: 'keychain-locked',
336
+ detail: 'macOS Keychain (service=nsauditor-ai) — entry exists but interaction not allowed',
337
+ ageDays: null,
338
+ createdAt: null,
339
+ };
340
+ }
341
+
213
342
  const filePath = opts._homeFileOverride ?? defaultMcpAuthFilePath();
214
343
  try {
215
344
  const buf = await fsp.readFile(filePath, 'utf8');
216
345
  const parsed = dotenv.parse(buf);
217
346
  if (parsed[MCP_AUTH_ENV_VAR]) {
218
- return { source: 'file', detail: filePath };
347
+ const createdAt = parsed[MCP_AUTH_CREATED_ENV_VAR] || null;
348
+ return {
349
+ source: 'file',
350
+ detail: filePath,
351
+ createdAt,
352
+ ageDays: _ageDaysFromIso(createdAt, now),
353
+ // CRITICAL #1 fold: same legacy-install signal as the keychain
354
+ // branch above.
355
+ legacyTimestampMissing: createdAt === null,
356
+ };
219
357
  }
220
358
  } catch { /* fall through */ }
221
- return { source: 'unconfigured' };
359
+ return { source: 'unconfigured', ageDays: null, createdAt: null };
360
+ }
361
+
362
+ /**
363
+ * Get the age of the configured MCP auth key, in days. Returns null
364
+ * when no key is configured or the timestamp is missing/unparseable
365
+ * (older installs predating EE-SEC.1.1 won't have the timestamp).
366
+ *
367
+ * @param {object} [opts] — same test seams as reportMcpAuthSource.
368
+ * @returns {Promise<number|null>}
369
+ */
370
+ export async function getMcpAuthKeyAge(opts = {}) {
371
+ const status = await reportMcpAuthSource(opts);
372
+ return status.ageDays ?? null;
373
+ }
374
+
375
+ // Internal: parse an ISO-8601 timestamp and return age in days from
376
+ // the reference time. Returns null on parse failure or future dates.
377
+ function _ageDaysFromIso(isoString, nowMs) {
378
+ if (typeof isoString !== 'string' || isoString.length === 0) return null;
379
+ const ms = Date.parse(isoString);
380
+ if (Number.isNaN(ms)) return null;
381
+ if (ms > nowMs) return null; // clock skew — don't emit a misleading negative
382
+ return Math.floor((nowMs - ms) / MS_PER_DAY);
222
383
  }
223
384
 
224
385
  function defaultMcpAuthFilePath() {
@@ -251,13 +412,34 @@ export async function persistMcpAuthKey(key, opts = {}) {
251
412
 
252
413
  const plat = opts._platform ?? platform();
253
414
  const kset = opts._keychainSet ?? keychainSet;
415
+ // EE-SEC.1.1: timestamp the key for rotation cadence tracking.
416
+ // ISO-8601 UTC for unambiguous parsing across timezones / locales.
417
+ // Test seam allows hermetic injection of "now".
418
+ // Reviewer 1 MEDIUM #1 fold (post-EE-SEC.1.1): normalize _now via
419
+ // `new Date(...).toISOString()` so callers passing an epoch-ms
420
+ // number (valid in reportMcpAuthSource via `new Date(opts._now).getTime()`)
421
+ // don't write an unparseable literal. Production callers always
422
+ // omit _now; this guards the hermetic-test path symmetrically.
423
+ const createdAt = opts._now
424
+ ? new Date(opts._now).toISOString()
425
+ : new Date().toISOString();
254
426
 
255
427
  // 1. macOS: try Keychain first.
256
428
  let keychainFallbackReason = null;
257
429
  if (plat === 'darwin') {
258
430
  try {
431
+ // Reviewer 2 MEDIUM #2 fold (post-EE-SEC.1.1): write timestamp
432
+ // FIRST, then key. A half-write where the timestamp succeeds
433
+ // but the key fails leaves no usable key — auth check refuses
434
+ // the next startup → fail-CLOSED. Pre-fold the order was
435
+ // reversed: a half-write left a usable key with no timestamp,
436
+ // silently disabling rotation-cadence warnings forever.
437
+ // Companion writes use the same kset; if the FIRST one fails
438
+ // we fall through to file storage (full reset) rather than
439
+ // leaving partial state.
440
+ await kset(MCP_AUTH_CREATED_KEYCHAIN_ACCOUNT, createdAt);
259
441
  await kset(MCP_AUTH_KEYCHAIN_ACCOUNT, key);
260
- return { ok: true, location: 'macOS Keychain (service=nsauditor-ai)' };
442
+ return { ok: true, location: 'macOS Keychain (service=nsauditor-ai)', createdAt };
261
443
  } catch (err) {
262
444
  keychainFallbackReason = err && err.message ? err.message : String(err);
263
445
  }
@@ -284,13 +466,35 @@ export async function persistMcpAuthKey(key, opts = {}) {
284
466
  existingContent = await fsp.readFile(filePath, 'utf8');
285
467
  } catch { /* missing file — create one */ }
286
468
 
287
- const newContent = mergeMcpAuthIntoEnvFile(existingContent, key);
288
- await fsp.writeFile(filePath, newContent, { mode: 0o600 });
469
+ // EE-SEC.1.1: write BOTH the key and the created-at timestamp.
470
+ // Same merge semantics preserves other env-vars, replaces
471
+ // existing values, drops duplicates. The two writes are atomic
472
+ // at the file level (single writeFile call after merging both
473
+ // into the new content).
474
+ //
475
+ // Reviewer 2 MEDIUM #1 fold (post-EE-SEC.1.1): write to a
476
+ // sibling .tmp file first, then rename() — POSIX rename is
477
+ // atomic, so concurrent readers see either the OLD file or the
478
+ // NEW file, never a truncated mid-write state. Fixes the race
479
+ // where `mcp status` running simultaneously with `mcp install-key`
480
+ // could observe an empty file → "unconfigured" → operator
481
+ // confusion. Also defends against host-crash mid-write losing
482
+ // both the key AND the timestamp.
483
+ let newContent = mergeMcpAuthIntoEnvFile(existingContent, key);
484
+ newContent = mergeMcpAuthCreatedIntoEnvFile(newContent, createdAt);
485
+ const tmpPath = `${filePath}.tmp`;
486
+ await fsp.writeFile(tmpPath, newContent, { mode: 0o600 });
487
+ if (plat !== 'win32') {
488
+ await fsp.chmod(tmpPath, 0o600);
489
+ }
490
+ await fsp.rename(tmpPath, filePath);
289
491
  if (plat !== 'win32') {
492
+ // rename preserves source mode on POSIX, but a chmod after is
493
+ // belt-and-suspenders if the source mode was somehow changed.
290
494
  await fsp.chmod(filePath, 0o600);
291
495
  }
292
496
 
293
- const result = { ok: true, location: filePath };
497
+ const result = { ok: true, location: filePath, createdAt };
294
498
  if (keychainFallbackReason !== null) {
295
499
  result.warning =
296
500
  `macOS Keychain unavailable (${keychainFallbackReason}); fell back to file storage. ` +
@@ -316,34 +520,86 @@ export async function persistMcpAuthKey(key, opts = {}) {
316
520
  * @internal
317
521
  */
318
522
  export function mergeMcpAuthIntoEnvFile(existingContent, key) {
319
- // Reviewer 1 MEDIUM #5 fold: escape regex metacharacters in
320
- // MCP_AUTH_ENV_VAR before interpolating. Today the constant is
321
- // `NSA_MCP_AUTH_KEY` (no specials), so this is defense-in-depth
322
- // — but a future rename to e.g. `NSA-MCP.AUTH+KEY` would silently
323
- // break the merge without this guard. License version uses a
324
- // literal regex, so it doesn't have this risk; this version
325
- // interpolates because the env-var name is exported as a constant.
326
- const escaped = MCP_AUTH_ENV_VAR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
523
+ return mergeKeyValueIntoEnvFile(
524
+ existingContent,
525
+ MCP_AUTH_ENV_VAR,
526
+ key,
527
+ `# NSAuditor AI MCP auth key set via \`nsauditor-ai mcp install-key\``,
528
+ );
529
+ }
530
+
531
+ /**
532
+ * Merge a created-at timestamp companion line. EE-SEC.1.1 — used by
533
+ * persistMcpAuthKey to write `NSA_MCP_AUTH_KEY_CREATED=<ISO-8601>`
534
+ * alongside the auth key for rotation-cadence tracking.
535
+ *
536
+ * Exported for test coverage.
537
+ * @internal
538
+ */
539
+ export function mergeMcpAuthCreatedIntoEnvFile(existingContent, createdAt) {
540
+ return mergeKeyValueIntoEnvFile(
541
+ existingContent,
542
+ MCP_AUTH_CREATED_ENV_VAR,
543
+ createdAt,
544
+ null, // no header — already present from the key line
545
+ );
546
+ }
547
+
548
+ /**
549
+ * Generic dotenv-format merge: replace `${name}=...` line if present
550
+ * (collapsing duplicates per the corrupted-file defense), append at
551
+ * end if absent, write a header for empty input.
552
+ *
553
+ * EE-SEC.1.1 (Thread I refactor): the original merge logic was
554
+ * copy-pasted between the auth-key and (now) created-at lines; this
555
+ * factor-out also addresses Reviewer 2 LOW #2 from EE-SEC.1 (the
556
+ * universal-exclusion + sentinel-string duplication concern — both
557
+ * are now contained in a single helper).
558
+ *
559
+ * Reviewer 1 MEDIUM #5 fold (preserved): regex metacharacters in
560
+ * `name` are escaped before interpolation. Today the names are
561
+ * `NSA_MCP_AUTH_KEY` and `NSA_MCP_AUTH_KEY_CREATED` (no specials),
562
+ * so this is defense-in-depth — but a future rename containing
563
+ * `.+?^${}()|[]\\` would silently break the merge without this guard.
564
+ *
565
+ * @param {string} existingContent — current dotenv file content.
566
+ * @param {string} name — env-var name to write/replace.
567
+ * @param {string} value — value to assign.
568
+ * @param {string|null} headerComment — optional header to write when
569
+ * the file is empty (e.g., `# NSAuditor AI MCP auth key — ...`).
570
+ * @internal
571
+ */
572
+ export function mergeKeyValueIntoEnvFile(existingContent, name, value, headerComment = null) {
573
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
327
574
  const KEY_LINE_RE = new RegExp(
328
575
  `^[ \\t]*${escaped}[ \\t]*=[^\\r\\n]*$`,
329
576
  'gm',
330
577
  );
331
- const newLine = `${MCP_AUTH_ENV_VAR}=${key}`;
578
+ const newLine = `${name}=${value}`;
579
+ // Sentinel includes the env-var name to avoid the (astronomical
580
+ // but possible) collision concern Reviewer 2 LOW #2 raised — the
581
+ // sentinel is now per-key rather than global.
582
+ const PURGE_SENTINEL = `__NSAUDITOR_PURGE_${name}__`;
332
583
 
333
584
  const matches = existingContent.match(KEY_LINE_RE);
334
585
  if (matches && matches.length > 0) {
335
586
  let firstReplaced = false;
336
587
  let merged = existingContent.replace(KEY_LINE_RE, () => {
337
- if (firstReplaced) return '__NSAUDITOR_PURGE__';
588
+ if (firstReplaced) return PURGE_SENTINEL;
338
589
  firstReplaced = true;
339
590
  return newLine;
340
591
  });
341
- merged = merged.replace(/__NSAUDITOR_PURGE__\r?\n?/g, '');
592
+ const sentinelRe = new RegExp(
593
+ PURGE_SENTINEL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\r?\\n?',
594
+ 'g',
595
+ );
596
+ merged = merged.replace(sentinelRe, '');
342
597
  return merged;
343
598
  }
344
599
 
345
600
  if (existingContent.trim().length === 0) {
346
- return `# NSAuditor AI MCP auth key — set via \`nsauditor-ai mcp install-key\`\n${newLine}\n`;
601
+ const header = headerComment ? `${headerComment}\n` : '';
602
+ return `${header}${newLine}\n`;
347
603
  }
348
604
 
349
605
  const sep = existingContent.endsWith('\n') ? '' : '\n';