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 +17 -12
- package/cli.mjs +21 -6
- package/mcp_server.mjs +30 -13
- package/package.json +3 -6
- package/utils/ai_stage.mjs +24 -2
- package/utils/finding_queue.mjs +9 -0
- package/utils/mcp_call_digest.mjs +9 -2
- package/utils/run_record.mjs +5 -1
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.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
|
49
|
-
- **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
1069
|
-
'
|
|
1070
|
-
'
|
|
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.
|
|
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": "^
|
|
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
|
},
|
package/utils/ai_stage.mjs
CHANGED
|
@@ -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
|
-
|
|
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})`;
|
package/utils/finding_queue.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/utils/run_record.mjs
CHANGED
|
@@ -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
|
}
|