nsauditor-ai 1.1.0 → 1.1.1

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
@@ -17,20 +17,24 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
17
17
 
18
18
  ## What's New
19
19
 
20
- **Latest: CE 1.1.0 + Enterprise 2.1.0 — *Node.js 24.22.1; scan_host stops at a time limit and names what it did not run; a saved response keeps its request beside it; a multi-entry cloud title says so; get_findings says what a scan holds*.**
21
- ⚠️ **The supported runtime floor rises to Node.js 24.22.1** — the October 2026 Node.js security release for the 24
22
- line (`engines.node >=24.22.1`); npm only warns on an older runtime, so check `node --version`. Through the MCP server,
23
- `scan_host` stops at a total time limit by default (`SCAN_HOST_WALL_MS`, 50 s; `0` turns it off) and names the plugins it
24
- did not run; with `NSA_MCP_RESPONSE_DIR` set, each response is saved with its request beside it, and
25
- `nsauditor-ai mcp verify-call` checks both; a CRITICAL, HIGH or evidence-gap title in `scan_cloud` says when its finding
26
- holds more entries than it shows (` (+N more via get_findings)`); and a `get_findings` category filter that matches
27
- nothing returns the categories the scan does hold.
20
+ **Latest: CE 1.1.1 + Enterprise 2.1.1 — *the OpenAI client on openai 7 and one zod; an AI assessment of part of the scan says so; a tier denial names the tools you can use; `license` exits 2 on an unknown subcommand*.**
21
+ The OpenAI client moves from `openai` 4 to `openai` 7, so a project that depends on this package holds one `zod` at
22
+ 4.6.6 or later (GHSA-mrhx-c7xc-m3w5), with no `overrides`, and no `node-domexception`. After every AI response the CLI
23
+ compares the input tokens the provider reports with its own estimate of the input (characters ÷ 4); when the provider
24
+ reports reading less than half of it, `scan_response_ai.txt` and `.html` open with one line saying the assessment covers
25
+ part of the scan, and `scan_response_ai.json` and the run record carry both numbers. Measured with a local Ollama model
26
+ whose settings were left at their defaults, which read about a tenth of a host scan
27
+ ([docs/openai.md](./docs/openai.md#when-the-provider-reads-part-of-the-input)). A tool refused for its licence tier
28
+ names the tools the caller can use now, and `compliance_matrix` without the Enterprise package names the licence it
29
+ needs instead of an install command that fails. `nsauditor-ai license` with an unknown subcommand, or none, exits 2. The
30
+ supported runtime is Node.js 24.22.1 or newer (`engines.node >=24.22.1`, since 1.1.0); npm only warns on an older
31
+ runtime, so check `node --version`.
28
32
  Two limits from Enterprise 1.3.0 still hold in the Pro delta and Enterprise MTTR: a scan made before Enterprise 1.3.0
29
33
  could not record a plugin left out of it, so in a comparison with one an agent's row that scan lacks is not refused;
30
34
  and a scan that discovered ports with the Nmap plugin (024) alone records no port oracle, so an analysis agent's or the
31
35
  CVE mapper's row on a port it did not measure can read RESOLVED and count as closed in MTTR, and the control it failed
32
36
  can read PASS — include the port scanner (003).
33
- Plugin count UNCHANGED at 27 Community + 29 Enterprise; every coverage matrix UNCHANGED; Enterprise 2.1.0 pairs with this
37
+ Plugin count UNCHANGED at 27 Community + 29 Enterprise; every coverage matrix UNCHANGED; Enterprise 2.1.1 pairs with this
34
38
  release, and its peer floor stays at `nsauditor-ai >= 1.0.0`. The free last-vs-current webhook alerting delta is
35
39
  untouched and stays free (see Continuous Monitoring below).
36
40
 
@@ -45,8 +49,8 @@ Scan → Analyze → Prioritize → Track → Act
45
49
  - **27 scanner plugins** probe networks across ICMP, TCP, UDP, HTTP, TLS, SNMP, DNS, SMB, RPC, mDNS, UPnP, WS-Discovery, MCP (Model Context Protocol), and more
46
50
  - **Smart result fusion** — the Result Concluder merges all plugin outputs into a normalized view with OS detection, service fingerprinting, and evidence linking
47
51
  - **Structured finding format** — all findings use a common schema with category, severity, evidence, and remediation — enabling consistent SARIF export and MCP integration
48
- - **AI-powered analysis** — send redacted scan results to OpenAI or Claude (your keys, your choice) for vulnerability assessments and remediation guidance
49
- - **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score (CVSS weighted by verification status, with an uplift for initial-access techniques) and a status field. Enterprise adds an operator suppression workflow for compliance findings (accepted-risk / false-positive with expiry): the compliance report shows a suppressed finding as suppressed, naming its approver and rationale, and an expired suppression no longer applies
52
+ - **AI-powered analysis** — send redacted scan results to OpenAI, Claude or a local Ollama model (your keys, your choice) for vulnerability assessments and remediation guidance
53
+ - **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score: CVSS/10 × a status weight, plus a 15% uplift for findings carrying an initial-access MITRE technique, capped at 1.0. Every finding the scanner emits is `UNVERIFIED`, so the weight is 0.6 for all of them (CVSS 9.8 → 0.59; with T1190 → 0.68; no CVSS → 0.30). The `VERIFIED` (1.0) and `FALSE_POSITIVE` (0.0) weights are unreachable in the shipped product: those status values are reserved, no shipped code or registered MCP tool sets them, and the re-probe engine that would have is withdrawn and not planned. Enterprise adds an operator suppression workflow for compliance findings (accepted-risk / false-positive with expiry): the compliance report shows a suppressed finding as suppressed, naming its approver and rationale, and an expired suppression no longer applies
50
54
  - **Continuous monitoring (CTEM)** — watch mode rescans on a schedule, compares each host with the previous cycle and alerts a host whose scan changed and that carries a finding at or above `--alert-severity`
51
55
  - **MCP integration** — expose scanning tools to AI assistants like Claude Code via Model Context Protocol
52
56
  - **CI/CD ready** — SARIF output with `--fail-on` severity gating for pipeline integration
@@ -66,8 +70,9 @@ NSAuditor AI is available in three editions: Community (free, MIT-licensed, no r
66
70
  | AI analysis (OpenAI, Claude, Ollama — your keys) | ✅ basic | ✅ enriched | ✅ enriched |
67
71
  | Structured findings + SARIF + CSV export | ✅ | ✅ | ✅ |
68
72
  | CTEM watch mode | ✅ basic | ✅ advanced | ✅ advanced |
73
+ | MITRE ATT&CK technique mapping — every scan maps the detected services to technique ids, linked in the report | ✅ | ✅ | ✅ |
69
74
  | **Pro features** | | | |
70
- | CVE matching + MITRE ATT&CK mapping | — | ✅ | ✅ |
75
+ | CVE matching + per-finding MITRE ATT&CK annotations (`evidence.mitre`) | — | ✅ | ✅ |
71
76
  | Risk scoring + prioritization | — | ✅ | ✅ |
72
77
  | Exploit intelligence — **CISA KEV** + **FIRST EPSS** joined onto the CVE matches, exploit-first ordering (a KEV-listed MEDIUM outranks an unexploited CRITICAL); stores are operator-populated — no feed data ships | — | ✅ | ✅ |
73
78
  | Parallel analysis agents | — | ✅ | ✅ |
package/cli.mjs CHANGED
@@ -19,7 +19,7 @@ import { buildCsv } from './utils/export_csv.mjs';
19
19
  import { buildMarkdownReport } from './utils/report_md.mjs';
20
20
  import { conclusionFindings, maxRank, severityRank as flagSeverityRank, OPEN_SERVICE_SEVERITY } from './utils/service_flags.mjs';
21
21
  import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE, historyServiceEntry, historyHostEntry } from './utils/scan_history.mjs';
22
- import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
22
+ import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine, aiInputCoverage, aiTruncationCaveat } from './utils/ai_stage.mjs';
23
23
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
24
24
  import { resolveCapabilities, hasCapability, inferRequiredTier, CAPABILITIES } from './utils/capabilities.mjs';
25
25
  import { createScheduler } from './utils/scheduler.mjs';
@@ -571,9 +571,14 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
571
571
  clearTimeout(aiTimer);
572
572
  }
573
573
 
574
+ // B113a: what share of the input the provider says it read. Written BESIDE the raw response, never into it.
575
+ const inputCoverage = aiInputCoverage({ resp, promptText, userContent });
576
+ const truncationCaveat = inputCoverage?.inputTruncated ? aiTruncationCaveat(inputCoverage) : null;
577
+
574
578
  // Write full AI response
575
579
  try {
576
- await fsp.writeFile(aiResponsePath, JSON.stringify(resp, null, 2), 'utf8');
580
+ const recorded = inputCoverage ? { ...JSON.parse(JSON.stringify(resp)), nsauditorMeta: { inputCoverage } } : resp;
581
+ await fsp.writeFile(aiResponsePath, JSON.stringify(recorded, null, 2), 'utf8');
577
582
  console.log(`[${providerLabel}] Wrote AI response:`, aiResponsePath);
578
583
  } catch (e) {
579
584
  console.warn(`[${providerLabel}] Failed to write AI response:`, e?.message || e);
@@ -582,6 +587,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
582
587
  // Write TXT & HTML
583
588
  try {
584
589
  const lines = [
590
+ ...(truncationCaveat ? [truncationCaveat, ``] : []),
585
591
  `Model: ${model}`,
586
592
  `Provider: ${providerLabel}`,
587
593
  `When: ${new Date().toISOString()}`,
@@ -601,7 +607,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
601
607
  host,
602
608
  whenIso: new Date().toISOString(),
603
609
  model,
604
- md: aiConclusionText.trim()
610
+ md: `${truncationCaveat ? `> ${truncationCaveat}\n\n` : ''}${aiConclusionText.trim()}`
605
611
  });
606
612
  await fsp.writeFile(aiHtmlPath, html, 'utf8');
607
613
  console.log(`[${providerLabel}] Wrote AI HTML:`, aiHtmlPath);
@@ -623,6 +629,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
623
629
  // review fold D: a 200 with no extractable text is NOT "ok" — the
624
630
  // end-of-scan summary must not read "OK" when there is no conclusion.
625
631
  ai_status: (typeof aiConclusionText === 'string' && aiConclusionText.trim()) ? 'ok' : 'empty',
632
+ ai_input_coverage: inputCoverage,
626
633
  };
627
634
  } catch (err) {
628
635
  const errMsg = String(err?.message || err);
@@ -1225,12 +1232,12 @@ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1225
1232
  conclusion.result.eeEnrichmentError = String(err?.message ?? err);
1226
1233
  }
1227
1234
 
1228
- const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error } = await maybeSendToOpenAI({
1235
+ const { file_paths: ai_file_paths, ai_conclusion, ai_status, ai_error, ai_input_coverage = null } = await maybeSendToOpenAI({
1229
1236
  host, results, conclusion, promptMode, outDir, runId: opts?.runId ?? null, pluginStatus,
1230
1237
  });
1231
1238
  // BUG1 (fail-VISIBLE): a one-line end-of-scan AI status so a failed/aborted AI
1232
1239
  // conclusion is not silently swallowed under the per-stage logs.
1233
- console.log(aiSummaryLine({ host, ai_status, ai_error }));
1240
+ console.log(aiSummaryLine({ host, ai_status, ai_error, ai_input_coverage }));
1234
1241
 
1235
1242
  // --- Scan history: record & compare ---
1236
1243
  let scanDiff = null;
@@ -1306,6 +1313,7 @@ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1306
1313
  hostAppended = await appendHostWritten(runRecordRoot, opts.runId, {
1307
1314
  host, dir: path.basename(outDir),
1308
1315
  scopeScanned: hostScopeScanned,
1316
+ aiInputCoverage: ai_input_coverage, // B113a: per host, beside the scope
1309
1317
  });
1310
1318
  }
1311
1319
 
@@ -2454,7 +2462,14 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2454
2462
  console.log('');
2455
2463
  console.log(' Verify with: nsauditor-ai license --status');
2456
2464
  } else {
2457
- console.log('Usage: nsauditor-ai license --status | --capabilities | --plugins | install <KEY> | --reset [--purge]');
2465
+ const usage = 'Usage: nsauditor-ai license --status | --capabilities | --plugins | install <KEY> | --reset [--purge]';
2466
+ // B99: anything that reached here — no subcommand at all, a typo, or one a later release adds — is a call that
2467
+ // FAILED: the usage goes to stderr and the exit code is 2, as `feed` and `compliance` with no verb do, so a script
2468
+ // or an AI agent never reads it as a success. The explicit ask for the usage is `license --help`, which the global
2469
+ // help answers with exit 0.
2470
+ console.error(rawArgs.length <= 1 ? 'missing license subcommand' : `unknown license subcommand: ${rawArgs[1]}`);
2471
+ console.error(usage);
2472
+ process.exit(2);
2458
2473
  }
2459
2474
  process.exit(0);
2460
2475
  }
package/mcp_server.mjs CHANGED
@@ -55,35 +55,49 @@ export function _setTier(tier) {
55
55
  _capabilities = resolveCapabilities(_tier);
56
56
  }
57
57
 
58
- /** @internal Test-only. Returns the Pro-gate denial object (or null if allowed). */
59
- export function _requireProCapability(toolName) {
58
+ /** @internal Test-only. Returns the Pro-gate denial object (or null if allowed). `opts.resolveEE` replaces the resolver. */
59
+ export function _requireProCapability(toolName, opts) {
60
60
  if (process.env.NODE_ENV === 'production') throw new Error('_requireProCapability is test-only and disabled in production');
61
- return requireProCapability(toolName);
61
+ return requireProCapability(toolName, opts);
62
62
  }
63
63
 
64
- /** @internal Test-only. Returns the Enterprise-gate denial object (or null if allowed). */
65
- export function _requireEnterpriseCapability(toolName) {
64
+ /** @internal Test-only. Returns the Enterprise-gate denial object (or null if allowed). `opts.resolveEE` replaces the resolver. */
65
+ export function _requireEnterpriseCapability(toolName, opts) {
66
66
  if (process.env.NODE_ENV === 'production') throw new Error('_requireEnterpriseCapability is test-only and disabled in production');
67
- return requireEnterpriseCapability(toolName);
67
+ return requireEnterpriseCapability(toolName, opts);
68
68
  }
69
69
 
70
- function requireProCapability(toolName) {
70
+ /**
71
+ * The tools this caller can use now, for a denial to name (B97, B98). DERIVED from the same gates the dispatcher applies
72
+ * — Pro adds probe_service and get_vulnerabilities — and from whether Enterprise resolves, since compliance_matrix reads
73
+ * the framework maps Enterprise ships and throws without them. It used to be one constant, "CE tools available:
74
+ * scan_host, list_plugins, compliance_matrix", so a Pro caller denied an Enterprise tool never learned it held the Pro
75
+ * tools, and a Community-only install was told compliance_matrix was available where it throws.
76
+ */
77
+ function toolsAvailableLine({ resolveEE = _defaultResolveEE } = {}) {
78
+ const tools = ['scan_host', 'list_plugins'];
79
+ if (_capabilities.proMCP) tools.push('probe_service', 'get_vulnerabilities');
80
+ try { resolveEE(); tools.push('compliance_matrix'); } catch { /* Enterprise not installed: the matrix is not available */ }
81
+ return `**Tools available to you now:** ${tools.join(', ')}`;
82
+ }
83
+
84
+ function requireProCapability(toolName, opts) {
71
85
  if (_capabilities.proMCP) return null; // Pro/Enterprise: allow
72
86
  return {
73
87
  content: [{
74
88
  type: 'text',
75
- text: `🔒 **${toolName}** requires a Pro license.\n\nView Pro/Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins, compliance_matrix`,
89
+ text: `🔒 **${toolName}** requires a Pro license.\n\nView Pro/Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n${toolsAvailableLine(opts)}`,
76
90
  }],
77
91
  isError: true,
78
92
  };
79
93
  }
80
94
 
81
- export function requireEnterpriseCapability(toolName) {
95
+ export function requireEnterpriseCapability(toolName, opts) {
82
96
  if (_capabilities.enterpriseMCP) return null; // Enterprise: allow
83
97
  return {
84
98
  content: [{
85
99
  type: 'text',
86
- text: `🔒 **${toolName}** (cloud account auditing) requires an Enterprise license.\n\nView Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n**CE tools available:** scan_host, list_plugins, compliance_matrix`,
100
+ text: `🔒 **${toolName}** (cloud account auditing) requires an Enterprise license.\n\nView Enterprise pricing at https://www.nsauditor.com/ai/pricing/\n\n${toolsAvailableLine(opts)}`,
87
101
  }],
88
102
  isError: true,
89
103
  };
@@ -1064,10 +1078,13 @@ export async function handleComplianceMatrix(args = {}) {
1064
1078
  try {
1065
1079
  eeRoot = resolveEE();
1066
1080
  } catch {
1081
+ // B98: the package is RESTRICTED on npm, so an install command is one a non-buyer cannot run; name the licence it
1082
+ // needs and where a buyer finds the command (the licence email), and where anyone else goes.
1067
1083
  throw new Error(
1068
- 'The compliance coverage matrix is shipped with the Enterprise plugin pack, and ' +
1069
- '@nsasoft/nsauditor-ai-ee is not installed / not resolvable from this server. ' +
1070
- 'Install it (npm i -g @nsasoft/nsauditor-ai-ee) and restart the MCP server. ' +
1084
+ 'The compliance coverage matrix is shipped with the Enterprise plugin pack (@nsasoft/nsauditor-ai-ee), ' +
1085
+ 'which is not installed / not resolvable from this server. That package needs a Pro or Enterprise license: ' +
1086
+ 'with one, install it with the command in your license email and restart the MCP server; without one, see ' +
1087
+ 'https://www.nsauditor.com/ai/pricing/. ' +
1071
1088
  'Do NOT reconstruct the matrix from the plugin inventory or from documentation — ' +
1072
1089
  'coverage is a property of the shipped framework maps, not of the plugin list.',
1073
1090
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
@@ -31,22 +31,19 @@
31
31
  "@modelcontextprotocol/sdk": "^1.29.0",
32
32
  "dnssd": "^0.4.1",
33
33
  "dotenv": "^17.2.1",
34
+ "jose": "^6.2.0",
34
35
  "markdown-it": "^14.1.0",
35
36
  "multicast-dns": "^7.2.5",
36
37
  "node-upnp-utils": "^1.0.3",
37
- "openai": "^4.104.0",
38
+ "openai": "^7.45.1",
38
39
  "oui-data": "^1.1.427",
39
40
  "snmp-native": "^1.2.0",
40
- "jose": "^6.2.0",
41
41
  "xml2js": "^0.6.2",
42
42
  "zod": "^4.6.6"
43
43
  },
44
44
  "optionalDependencies": {
45
45
  "mdns": "^2.7.2"
46
46
  },
47
- "overrides": {
48
- "zod": "$zod"
49
- },
50
47
  "engines": {
51
48
  "node": ">=24.22.1"
52
49
  },
@@ -117,9 +117,31 @@ export function aiFailureStubText({ host, model, providerLabel, errorMessage, ti
117
117
  * 'failed' — the provider call threw (carries ai_error as the cause)
118
118
  * Any unknown status falls to the FAILED branch (fail-visible default).
119
119
  */
120
- export function aiSummaryLine({ host, ai_status, ai_error }) {
120
+ // B113a (2.1.1): AN ASSESSMENT OF PART OF THE SCAN MUST SAY SO. The provider's reported input tokens are compared with the
121
+ // input's own size estimate (characters / 4). Measured on the B87 e2e (2026-10-28): every OpenAI run read 1.12–1.26 of the
122
+ // estimate (its tokenizer counts a little more than chars/4), while a local Ollama model behind its default context window
123
+ // read 0.10 (2,050 of an input estimated at ~20,000). 0.5 sits in that gap.
124
+ export const AI_INPUT_TRUNCATION_RATIO = 0.5;
125
+
126
+ /** `{ providerInputTokens, estimatedInputTokens, inputTruncated }`, or null when the provider reported no input count. */
127
+ export function aiInputCoverage({ resp, promptText, userContent }) {
128
+ const usage = resp?.usage ?? {};
129
+ const reported = [usage.input_tokens, usage.prompt_tokens].find((n) => Number.isFinite(n) && n >= 0);
130
+ const estimatedInputTokens = Math.ceil((String(promptText ?? '').length + String(userContent ?? '').length) / 4);
131
+ if (reported === undefined || estimatedInputTokens <= 0) return null; // unknown is never read as "whole"
132
+ return { providerInputTokens: reported, estimatedInputTokens,
133
+ inputTruncated: reported < estimatedInputTokens * AI_INPUT_TRUNCATION_RATIO };
134
+ }
135
+
136
+ /** The ONE caveat line, the same words in the TXT, the HTML and the summary line. */
137
+ export function aiTruncationCaveat(cov) {
138
+ return `⚠️ Partial input: the provider reports reading ${cov.providerInputTokens} tokens of an input estimated at `
139
+ + `${cov.estimatedInputTokens}; this assessment covers part of the scan.`;
140
+ }
141
+
142
+ export function aiSummaryLine({ host, ai_status, ai_error, ai_input_coverage = null }) {
121
143
  switch (ai_status) {
122
- case 'ok': return `AI conclusion: OK (${host})`;
144
+ case 'ok': return `AI conclusion: OK (${host})${ai_input_coverage?.inputTruncated ? ` — ${aiTruncationCaveat(ai_input_coverage)}` : ''}`;
123
145
  case 'empty': return `AI conclusion: EMPTY — provider returned no text (${host})`;
124
146
  case 'skipped': return `AI conclusion: SKIPPED — AI_ENABLED not set (${host})`;
125
147
  case 'key_unresolved': return `AI conclusion: SKIPPED — API key could not be resolved (${host})`;
@@ -29,12 +29,21 @@ export class FindingQueue {
29
29
  return this.getByStatus('UNVERIFIED');
30
30
  }
31
31
 
32
+ /**
33
+ * @deprecated No shipped code calls this. The re-probe layer that would have set `VERIFIED` is withdrawn and
34
+ * not planned (EE 0.32.7; operator ruling 2026-09-28); the status value is reserved. Kept for the FindingQueue
35
+ * API; removal deferred to the next major.
36
+ */
32
37
  markVerified(id, verification) {
33
38
  const f = this._find(id);
34
39
  f.status = 'VERIFIED';
35
40
  f.evidence = { ...(f.evidence ?? {}), verification };
36
41
  }
37
42
 
43
+ /**
44
+ * @deprecated No shipped code calls this; `FALSE_POSITIVE` is reserved (see markVerified). The shipped way to
45
+ * record a false positive is Enterprise's operator suppression workflow. Removal deferred to the next major.
46
+ */
38
47
  markFalsePositive(id, reason) {
39
48
  const f = this._find(id);
40
49
  f.status = 'FALSE_POSITIVE';
@@ -29,9 +29,16 @@ export function requestDigest(bytes) {
29
29
  return createHash('sha256').update(Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes ?? ''), 'utf8')).digest('hex');
30
30
  }
31
31
 
32
- /** The body of a saved response: everything before the receipt footer, which is not part of what the tool produced. */
32
+ // The marker as a pattern whose whitespace is any whitespace run: the canonicalisation above is whitespace-insensitive,
33
+ // so finding the footer must be as well — the marker carries spaces, and an exact search lost the footer of a copy whose
34
+ // soft wrap fell inside it, refuting that copy (found 2026-10-27; tests/mcp_call_digest_marker_whitespace.test.mjs).
35
+ const MARKER_RE = new RegExp(RECEIPT_MARKER.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+'), 'g');
36
+
37
+ /** The body of a saved response: everything before the LAST receipt marker (found whitespace-insensitively), which
38
+ * begins the footer — not part of what the tool produced. */
33
39
  export function bodyOf(saved) {
34
40
  const text = String(saved ?? '');
35
- const at = text.lastIndexOf(RECEIPT_MARKER);
41
+ let at = -1;
42
+ for (const m of text.matchAll(MARKER_RE)) at = m.index;
36
43
  return at < 0 ? text : text.slice(0, at);
37
44
  }
@@ -190,7 +190,7 @@ function withRunLock(runId, fn) {
190
190
  // scanned" on a client's cover — FALSE, because they WERE scanned; only the record was never
191
191
  // finalized. A false "not scanned" over scanned hosts is the exact false clean this record exists
192
192
  // to prevent, inverted.
193
- export async function appendHostWritten(outRoot, runId, { host, dir, scopeScanned = null }) {
193
+ export async function appendHostWritten(outRoot, runId, { host, dir, scopeScanned = null, aiInputCoverage = null }) {
194
194
  return withRunLock(runId, async () => {
195
195
  const existing = await readRunRecord(outRoot, runId);
196
196
  if (!existing) return false;
@@ -209,6 +209,10 @@ export async function appendHostWritten(outRoot, runId, { host, dir, scopeScanne
209
209
  if (scopeScanned && typeof scopeScanned === 'object') {
210
210
  existing.scopeScanned = { ...(existing.scopeScanned ?? {}), [normalised]: scopeScanned };
211
211
  }
212
+ // B113a (2.1.1): what share of the input the AI provider read for this host, when it reported one. Absent when unknown.
213
+ if (aiInputCoverage && typeof aiInputCoverage === 'object') {
214
+ existing.aiInputCoverage = { ...(existing.aiInputCoverage ?? {}), [normalised]: aiInputCoverage };
215
+ }
212
216
  return Boolean(await writeJsonSafe(runRecordPath(outRoot, runId), existing, 'host append'));
213
217
  });
214
218
  }