@aria-framework/ask 0.1.1 → 0.2.0

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/lib/check.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * isPublicId(v) this app's id test — an id is sayable only when a look-up returned it
10
10
  * namedTokens [[regex, replacement]] applied before the token rules: names that contain digits (Soc: "tier 0",
11
11
  * "P1") — each app's own, none by default
12
+ * verbatimFields (0.1.2) paths ('rows[].fields.upn', 'facts.entities[].value') whose whole value may be said with
13
+ * @ \ / % # + [ ] & in it — see VERBATIM VALUES; every structured field is one too — default none
12
14
  * sayIps, sayWindows Soc's S4 (an IP the results carry; a window an app-written label names) — default OFF
13
15
  * rowChipLabel(row) the row chip's words
14
16
  * toolOf(id) the catalogue — decides which paths of a result are ATTACKER text (the tool's attackerFields)
@@ -23,13 +25,30 @@
23
25
  * - is not `{answer, cites}` with a non-empty answer;
24
26
  * - cites, or places a figure for, a result or extra figure that does not exist;
25
27
  * - TYPES A NUMBER: any numeral (`\p{N}`: 999, ٩٩٩, 999, ⁹⁹⁹, ⑨, Ⅸ) or a number WORD (two … million; not one/first/
26
- * second/single/half);
28
+ * second/single/half) — except a lone small number word equal to exactly one cited figure, which is rewritten into
29
+ * that figure's placeholder (0.1.2, SMALL_NUMBER);
27
30
  * - says a token with a digit in it, or a ref-shaped token, that is not SAYABLE (allowedTokens);
28
31
  * - carries a link — a scheme, `www.`, markup, a Markdown link, a path, or a PUBLIC-looking domain the results did not
29
32
  * name from an app-written path;
30
33
  * - claims something was done, asks the person to sign in again, speaks of another partner, or talks about its
31
34
  * instructions or the document.
32
35
  *
36
+ * CLAIMS (0.2.0, every answer): a VERDICT (compromised, malicious, benign, safe, a false positive, "no action needed" …) is
37
+ * refused unless an APP-WRITTEN field of a cited result says it (`unsupported_verdict`); an ALL/NONE claim about a state
38
+ * ("all of them are resolved", "none are open") is refused unless every row of the cited result shows it — and the result
39
+ * returned all its rows (`sweeping_claim`).
40
+ *
41
+ * ANALYSE MODE (0.2.0, `checkAnswer(reply, results, { mode: 'analyse', question })` — the plan's intent was analyse): the
42
+ * answer may group, compare and conclude, and is held to, per sentence:
43
+ * - it cites at least one result as [rN] (`uncited_sentence`), and every placeholder in it names a result it cites
44
+ * (`uncited_figure`);
45
+ * - a conclusion ("suggests", "likely", "therefore", "overall" …) says it comes from the results ("the results show",
46
+ * "based on these results") (`unframed_conclusion`);
47
+ * - every capitalised name in it is a word of the results it cites or of the question (`unknown_name`) — no new entity;
48
+ * and, over the whole answer, every typed id, host, value or number is one the CITED results returned (the sayable set is
49
+ * built from the cited results only). The obeyed phrasings (claimed actions, sign-in, other partners, instructions) apply
50
+ * as in every answer.
51
+ *
33
52
  * THIS IS A FLOOR, NOT A PROOF. It stops the obeyed shapes the tests name and the bench measures. What it guarantees is
34
53
  * that Ask cannot act, cannot invent a figure the page draws, and cannot link anywhere.
35
54
  */
@@ -51,18 +70,32 @@ const PURE_NUMBER = /^[\p{N}.,]+$/u;
51
70
  const REF_TOKEN = /(?<![\p{L}\p{N}])[\p{L}]{2,5}_[\p{L}\p{N}]{4,}/gu;
52
71
  const HOSTLIKE = /^[\p{L}\p{N}][\p{L}\p{N}-]*(?:\.[\p{L}\p{N}-]+)*$/u;
53
72
  const LINKISH = /(https?:|ftp:|mailto:|javascript:|data:|file:|www\.|<\s*[a-z!/]|\]\s*\(|&lt;|\bhref\b)/i;
73
+ // KNOWN-DANGEROUS SCHEMES over the whole answer (0.1.2, review round 3 of PR #12): the protocol handlers an OS or app opens
74
+ // on a click or a paste — ms-settings:, zoommtg:, ms-officecmd:, search-ms:, smb:, tel: … Named, not shaped, so "Status:
75
+ // open" and "Shell: bash" are prose (a space follows the colon).
76
+ const DANGEROUS_SCHEME = /(?<![\p{L}\p{N}-])(?:ms-[a-z-]+|search-ms|smb|cifs|nfs|afp|webdav|tel|sip|sips|callto|skype|msteams|zoommtg|zoomus|vnc|rdp|ssh|telnet|sftp|ldaps?|vbscript|mhtml|mk|its|jar|shell|intent|vscode|microsoft-edge|onenote|steam):(?=\S)/iu;
54
77
  const DOMAINISH = /(?<![\p{L}\p{N}])[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}(?:\/[^\s]*)?(?![\p{L}\p{N}])/gu;
78
+ // A SNIPPET (0.2.0, lib/text.js) is attacker text on any path ending in .snippet, whether or not the tool lists it.
79
+ const SNIPPET = /(?:^|\.)snippet$/;
55
80
  const INTERNAL_SUFFIX = /\.(local|lan|internal|corp|home\.arpa|localdomain)$/i;
56
81
  const NUMBER_WORD = /\b(zero|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundreds?|thousands?|millions?|billions?|dozens?|twice|thrice)\b/i;
57
82
  // What an obeyed instruction says — SCOPED to that, not to the vocabulary a security answer is made of ("sign-in attempts",
58
83
  // "password spray"). Widened in review L of PR #9: up to two adverbs ("I have now closed it"), "was closed by me", "log
59
- // back in", "re-enter it at the login page", other tenants or resellers, "my rules".
84
+ // back in", "re-enter it at the login page", other tenants or resellers, "my rules". 0.2.0 (the Noc adoption): the network and
85
+ // device verbs — restarted, rebooted, muted, paused, (un)blocked, isolated, quarantined, disconnected, shut down, reset,
86
+ // acknowledged — in the same first-person, "has been … as/by" and "… by me/us" shapes. A device that "restarted at 03:10"
87
+ // in the data is not a claim.
60
88
  const OBEYED = [
61
89
  // "I/we" and the verb with up to two words between ("I have now closed", "we've quietly removed", "I have't yet sent").
62
- [/\b(i|we)\s+(?:[\p{L}'’]+\s+){0,2}(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\b/iu, 'claims to have done something', 'claims_action'],
63
- [/\b(has|have)\s+been\s+(closed|deleted|removed|resolved|approved|disabled|marked|purged|wiped)\s+(as|by)\b/i, 'claims something was done', 'claims_action'],
90
+ // An OBSERVATION ("we saw it restarted", "I noticed it was blocked") is not a claim: no seeing verb between (final round).
91
+ [/\b(i|we)\s+(?:(?!(?:saw|see|sees|seen|noticed|notice|found|find|observed|observe|watched|spotted)\b)[\p{L}'’]+\s+){0,2}(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\b/iu, 'claims to have done something', 'claims_action'],
92
+ [/\b(has|have)\s+been\s+(closed|deleted|removed|resolved|approved|disabled|marked|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+(as|by)\b/i, 'claims something was done', 'claims_action'],
93
+ // Done FOR the person ("The firewall was restarted for you"), and "Done:" with a change after it ("Done: the host is
94
+ // now isolated") — final round of PR #14.
95
+ [/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\s+for\s+you\b/i, 'claims something was done for the person', 'claims_action'],
96
+ [/\bdone\s*[:!—–-]\s*[^.]{0,80}?\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\b/i, 'claims something was done', 'claims_action'],
64
97
  // The verb "by me / us", however it is introduced ("closed by me", "It was resolved by us").
65
- [/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
98
+ [/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
66
99
  [/\b(were|was|been)\s+(deleted|removed|wiped|purged|erased)\b/i, 'claims something was deleted', 'claims_action'],
67
100
  [/\b(sign|log)\s?-?\s?(in|on)\s+(again|at|here|now|to\s+continue|to\s+restore)\b|\b(sign|log)\s+back\s+(in|on)\b|\b(enter|re-?enter|confirm|reset|verify)\s+your\s+(password|credentials?|login)\b|\bre-?enter\b[^.]{0,40}\b(log-?in|sign-?in)\s+page\b|\bsession\s+(has\s+)?expired\b/i, 'sends the person to sign in', 'sign_in'],
68
101
  [/\b(other|another)\s+(partners?|tenants?|resellers?)\b|\bevery\s+other\s+(partner|tenant)\b/i, 'speaks of another partner', 'other_partner'],
@@ -95,7 +128,9 @@ const readable = (t) => String(t).normalize('NFKC').replace(INVISIBLE, '').repla
95
128
 
96
129
  /** Every refusal code checkAnswer can return. */
97
130
  const REFUSAL_CODES = Object.freeze(['shape', 'control_chars', 'unknown_cite', 'unknown_figure', 'bad_placeholder', 'link', 'number_word', 'claims_action',
98
- 'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char']);
131
+ 'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char',
132
+ // 0.2.0: claims (every answer) and the analyse rules.
133
+ 'unsupported_verdict', 'sweeping_claim', 'uncited_sentence', 'uncited_figure', 'unframed_conclusion', 'unknown_name']);
99
134
 
100
135
  /**
101
136
  * THE MODEL'S OWN TEXT IS ALLOW-LISTED (the coordinator's round on PR #9, the same list as the soc AI-brief check): after
@@ -111,8 +146,200 @@ const REFUSAL_CODES = Object.freeze(['shape', 'control_chars', 'unknown_cite', '
111
146
  */
112
147
  const ALLOWED_CHAR = /^[\p{L}\p{N} \n.,;:!?'"‘’“”()\-–—_]$/u;
113
148
 
149
+ /**
150
+ * VERBATIM VALUES (0.1.2, D-0175). An honest answer names a UPN (jdoe@contoso.com), an account (KAROO\jdoe), a path
151
+ * (C:\Windows\System32\winevt, /var/log/auth.log) or a subnet (10.20.0.0/24) — and the allow-list refused every one for its
152
+ * @ \ / % # + [ ] &. Those characters are now admitted ONLY inside a span of the answer that is, exactly and whole, one
153
+ * value a look-up returned at a path the binding marks VERBATIM (`verbatimFields`, plus every `structured` field):
154
+ * - the value: 3–128 characters, no whitespace, only the allow-list's characters plus VERBATIM_EXTRA, at least one of
155
+ * those, unchanged by NFC and by `readable` (no format, compatibility or look-alike character to hide behind), no
156
+ * digit directly before a % (a percentage is a FIGURE, never text), and no scheme-shaped part (SCHEMEISH: smb:,
157
+ * ms-msdt:, search-ms:, tel: — LINKISH, which still reads the whole unmasked answer, names only six schemes; a drive
158
+ * letter "C:" is not one);
159
+ * - from an ATTACKER path (the tool's attackerFields, or any path of an unknown or remote tool) also: NO PATH — no "/",
160
+ * and "\" only as exactly one DOMAIN\user; no IPv4/IPv6 part unless the app says IPs (sayIps); it starts with a
161
+ * letter, holds no ref-shaped token, and every dotted part is an internal host (the attacker-path host rule) — or it
162
+ * is an IPv4 CIDR with sayIps. App-written verbatim values keep paths, shares and subnets;
163
+ * - the span: the answer's NFC text holds it exactly (case and all), with no token character on either side and no
164
+ * ".x" / ":x" joining it to more, so a superstring, a substring or two values joined is not the value. The char rule, DOMAINISH and the token rules then
165
+ * read the span as one plain word; LINKISH, the obeyed phrasings and the number words still read the whole answer.
166
+ */
167
+ const VERBATIM_EXTRA = /[@\\/%#+[\]&]/u;
168
+ const VERBATIM_VALUE = /^[\p{L}\p{N}.,;:!?'"‘’“”()\-–—_@\\/%#+[\]&]{3,128}$/u;
169
+ const CIDR4 = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\/(?:3[0-2]|[12]?\d)$/;
170
+ // The span's edges (review of PR #12): a token character, or a "." / ":" followed by more of a token, next to it means it is
171
+ // part of something longer ("jdoe@contoso.com:KAROO\x" is two values joined, not one).
172
+ const VERBATIM_BEFORE = String.raw`(?<![\p{L}\p{N}_@\\/%#+[\]&.:\-])`;
173
+ const VERBATIM_AFTER = String.raw`(?![\p{L}\p{N}_@\\/%#+[\]&\-]|[.:][\p{L}\p{N}_@\\/%#+[\]&])`;
174
+ // A SCHEME-SHAPED PART anywhere in a verbatim value (review of PR #12): LINKISH names six schemes, and smb:, ms-msdt:,
175
+ // search-ms:, tel: are as much a place to send somebody. Two or more characters before the colon, so a drive letter
176
+ // ("C:\…") is not one.
177
+ const SCHEMEISH = /(?:^|[^\p{L}\p{N}+.-])\p{L}[\p{L}\p{N}+.-]+:/u;
178
+ // An IP address inside a value (review of PR #12): "admin@203.0.113.5", "admin@[203.0.113.5]", "a\\203.0.113.5\share".
179
+ const IP_PART = /(?<![\p{N}.])\d{1,3}(?:\.\d{1,3}){3}(?!\p{N})|(?<![\p{L}\p{N}:])[0-9a-f]{0,4}(?::[0-9a-f]{0,4}){2,7}(?![\p{L}\p{N}:])/iu;
180
+ // The one backslash an ATTACKER value may carry: DOMAIN\user (review of PR #12) — never a path.
181
+ const DOMAIN_USER = /^\p{L}[\p{L}\p{N}_-]*\\[\p{L}\p{N}_$-][\p{L}\p{N}_.$-]*$/u;
182
+ /** May this value of a verbatim path be said whole? See VERBATIM VALUES. */
183
+ function verbatimOk(v, fromAttacker, sayIps) {
184
+ if (typeof v !== 'string' || !VERBATIM_VALUE.test(v) || !VERBATIM_EXTRA.test(v)) return false;
185
+ if (v.normalize('NFC') !== v || readable(v) !== v) return false;
186
+ if (/\p{N}%/u.test(v) || SCHEMEISH.test(v)) return false;
187
+ if (!fromAttacker) return true;
188
+ if (CIDR4.test(v)) return sayIps;
189
+ // ATTACKER TEXT: no path ("/" never; "\" only as DOMAIN\user), no IP unless the app says IPs, a letter first, no ref, and
190
+ // every dotted part an internal host — the attacker-path host rule, read on the value's parts.
191
+ if (v.includes('/') || (v.includes('\\') && !DOMAIN_USER.test(v))) return false;
192
+ // AN IP INSIDE AN ADDRESS SHAPE is refused whatever sayIps says (0.2.0, from the Soc events.search adoption): sayIps
193
+ // means a plain IP from an IP field may be said (allowedTokens' ips), never one embedded in an @-, \- or bracketed value.
194
+ if (IP_PART.test(v)) return false;
195
+ // A numeric host after "@" — decimal or hex (admin@3405803781, admin@0xCB007105) is an IP too (round 3 of PR #12).
196
+ if (/@(?:\d+|0x[0-9a-f]+)(?![\p{L}\p{N}_-])/iu.test(v)) return false;
197
+ if (!/^\p{L}/u.test(v) || (v.match(REF_TOKEN) || []).length) return false;
198
+ for (const m of v.matchAll(/[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu)) if (!INTERNAL_SUFFIX.test(m[0])) return false;
199
+ return true;
200
+ }
201
+ const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
202
+ /** Every exact, whole occurrence of a verbatim value → the plain word "x" (values longest first). */
203
+ function maskVerbatim(text, values) {
204
+ let out = text;
205
+ for (const v of values) out = out.replace(new RegExp(`${VERBATIM_BEFORE}${escapeRe(v)}${VERBATIM_AFTER}`, 'gu'), 'x');
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * A SMALL NUMBER WORD THAT IS A CITED FIGURE (0.1.2). "There are two open tickets [r1]" with r1's count 2 was refused as
211
+ * number_word — the top cause of withheld honest answers. A plain word zero–twenty or thirty–ninety standing alone (no
212
+ * number word, "one", "a", "and" or digits joined to it by a space or hyphen: "nine hundred", "twenty-one" stay refused),
213
+ * directly followed by a word that can be what is counted (not "of", "is", "." … — NOT_COUNTED), in no idiom ("zero
214
+ * trust", "zero day", "two factor" — IDIOM_AFTER; "Windows ten", "phase two", "tier two" — IDIOM_BEFORE), is REWRITTEN into the placeholder of the ONE figure of that value among the CITED results (figureOf: a count or an
215
+ * extra). No such figure, two of them, only an uncited result's, the word inside quotation marks, or the word beside a
216
+ * word it also stands next to in attacker text: not rewritten, so number_word refuses it. An obfuscated word (five,
217
+ * fi​ve) is never rewritten: the rewrite reads the raw text, the refusal the readable one.
218
+ */
219
+ const SMALL_NUMBER = Object.freeze({ zero: 0, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11,
220
+ twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40,
221
+ fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 });
222
+ const SMALL_WORD = new RegExp(String.raw`(?<![\p{L}\p{N}_'’@\\/%#+[\]&.\-])(${Object.keys(SMALL_NUMBER).join('|')})(?![\p{L}\p{N}_'’@\\/%#+[\]&\-]|\.[\p{L}\p{N}])`, 'giu');
223
+ const JOINED_NUMBER = new RegExp(String.raw`^(?:${Object.keys(SMALL_NUMBER).join('|')}|eleven|hundreds?|thousands?|millions?|billions?|dozens?|twice|thrice|one|a|and|\p{N}+)$`, 'iu');
224
+ const QUOTE_PAIRS = Object.freeze([['"', '"'], ['“', '”'], ['‘', '’'], ["'", "'"]]);
225
+ // What follows a rewritten word (review of PR #12): directly, after whitespace only, a word that can be what is counted —
226
+ // not a function word ("two of", "ten is") — and the pair is no idiom ("zero trust", "Windows ten", "phase two").
227
+ const NOT_COUNTED = /^(?:of|and|or|nor|to|in|on|at|is|are|was|were|be|been|being|has|have|had|the|a|an|more|less|fewer|than|times|per|percent|by|for|from|with|as|it|its|that|this|these|those|which|who|if|but|so|not|no|too|very|out|up|down|off|over|under|again|each|or|then|seconds?|minutes?|hours?|days?|weeks?|months?|years?|times?)$/i;
228
+ // ^ A TIME SPAN is no data figure (round 3 of PR #12): "two weeks ago", "for two days", "within two hours" stay refused.
229
+ const IDIOM_AFTER = /^(?:zero (?:trust|day|days|touch|tolerance|knowledge|click|downtime|width|configuration|sign)|two (?:factor|step|way|fa)|three (?:way))$/i;
230
+ const IDIOM_BEFORE = /^(?:windows|win|office|ios|android|iphone|pixel|galaxy|top|version|v|level|tier|phase|step|stage|round|chapter|page|section|part|day|week|month|number|no|catch|plan|option|type|class|grade|category|priority|severity|sev)$/i;
231
+
114
232
  const trim = (t) => String(t).replace(/^[.:\-/]+|[.:\-/]+$/g, '');
115
233
 
234
+ // ── 0.2.0: CLAIMS AND THE ANALYSE RULES ──────────────────────────────────────────────────────────────────────────────
235
+ // THE WORD LISTS ARE A FLOOR, NOT A PROOF: a verdict or an all/none claim in words no list names passes the check. The
236
+ // bench measures what slips through; review rounds widen the lists (fix round of PR #14: clean, legitimate, secure, no
237
+ // threat, not a concern, a breach occurred, was owned; no … are, there are no …, all … resolved, got, have all been).
238
+ /**
239
+ * A verdict the model may not reach on its own: it must be what an app-written text of the ROW the sentence names says
240
+ * (or, naming none, of the result itself or of every cited row).
241
+ */
242
+ const VERDICT = /\b(compromised|breached|hacked|infected|malicious|benign|harmless|clean|legitimate|secure|false[\s_-]+positives?|true[\s_-]+positives?|under\s+attack|exfiltrated|safe|(?:was|been|got)\s+owned|(?:a\s+)?breach\s+(?:occurred|happened)|no\s+threats?|not\s+a\s+(?:threat|concern)|nothing\s+suspicious|expected\s+activity|nothing\s+to\s+worry\s+about|no\s+cause\s+for\s+concern|no\s+(?:further\s+)?action\s+(?:is\s+)?(?:needed|required))\b/giu;
243
+ /**
244
+ * "ALL / NONE OF THEM ARE <state>" (review rounds of PR #14). A QUANTIFIER — all, every, each, both, everything, they,
245
+ * none, nothing, not one, no <noun>; or "not all" (a PARTIAL claim: at least one row is not in the state) — then, within
246
+ * six words, a COPULA (is, are, was, were, has/have been, remain(s), stay(s), got, became) and its COMPLEMENT: the state
247
+ * is the copula's complement ("every record from the closed quarter is OPEN"), never any word in between, so a state
248
+ * word inside a qualifier ("assigned to jdoe") is not the claim. The complement may be negated ("no longer open", "not
249
+ * open", "unresolved"), which flips the claim. Also: "all records resolved" (no copula, the state ending the clause),
250
+ * "there are no open records", "no open records remain". The state vocabulary is STATE_WORDS plus every state and
251
+ * synonym the cited tools declare. A claim is decided ONLY from the declared field (`stateFields`); a word no cited tool
252
+ * declares is refused. A qualifier between the quantifier and the copula narrows the set; the claim is evaluated over
253
+ * EVERY cited row — sound for all/none (true of all rows → true of any subset), and otherwise refused.
254
+ */
255
+ const STATE_WORDS = Object.freeze(['resolved', 'closed', 'fixed', 'solved', 'handled', 'complete', 'completed', 'healthy', 'reporting', 'online', 'offline',
256
+ 'patched', 'acknowledged', 'assigned', 'unassigned', 'open', 'failed', 'down', 'benign', 'malicious', 'compromised', 'safe', 'pending', 'active', 'inactive',
257
+ 'escalated', 'expired', 'overdue', 'muted', 'paused', 'blocked', 'isolated', 'enabled', 'disabled', 'stale', 'running', 'stopped', 'unresolved']);
258
+ const SWEEP_QUANT = /\b(not\s+all|all|every|each|both|everything|they|none|nothing|not\s+one|not\s+a\s+single|no\s+longer\s+any|neither|no(?!\s+(?:one|longer|doubt|question)\b))\b/giu;
259
+ const SWEEP_COPULA = /^(?:are|is|were|was|have\s+been|has\s+been|(?:continues?|continued|remains?|remained)\s+to\s+be|remain|remains|stay|stays|got|get|became|become)\b/iu;
260
+ const SWEEP_ADVERB = /^(?:now|still|already|currently|all|both|each|also|again)\b\s*/iu;
261
+ const SWEEP_NEGATION = /^(?:no\s+longer|not|never)\b\s*/iu;
262
+ /** The all/none/not-all claims in one flattened sentence: [{ word, polarity: 'all' | 'none' | 'notall' }]. */
263
+ function sweepsOf(lower, vocab) {
264
+ const isState = (w) => vocab.has(w) || (w.startsWith('un') && vocab.has(w.slice(2)));
265
+ const out = [];
266
+ const flip = (p) => (p === 'all' ? 'none' : p === 'none' ? 'all' : 'notall');
267
+ for (const q of lower.matchAll(SWEEP_QUANT)) {
268
+ const qw = q[1].replace(/\s+/g, ' ');
269
+ let polarity = qw === 'not all' ? 'notall' : ['all', 'every', 'each', 'both', 'everything', 'they'].includes(qw) ? 'all' : 'none';
270
+ const rest = lower.slice(q.index + q[0].length).split(/[.!?;,]/)[0];
271
+ const words = rest.trim().split(/\s+/).filter(Boolean);
272
+ let found = false;
273
+ // <quantifier> … <copula> <complement>, the copula within six words.
274
+ for (let i = 0; i <= Math.min(6, words.length - 1) && !found; i += 1) {
275
+ const tail = words.slice(i).join(' ');
276
+ const cop = SWEEP_COPULA.exec(tail);
277
+ if (!cop) continue;
278
+ let comp = tail.slice(cop[0].length).trim();
279
+ while (SWEEP_ADVERB.test(comp)) comp = comp.replace(SWEEP_ADVERB, '');
280
+ let p = polarity;
281
+ const neg = SWEEP_NEGATION.exec(comp);
282
+ if (neg) { comp = comp.slice(neg[0].length); p = flip(p); }
283
+ const w = (comp.split(/\s+/)[0] || '').toLowerCase();
284
+ if (isState(w)) {
285
+ out.push(vocab.has(w) ? { word: w, polarity: p } : { word: w.slice(2), polarity: flip(p) });
286
+ found = true;
287
+ } else if (cop) break; // the first copula's complement is not a state: no claim from this quantifier
288
+ }
289
+ // "all records resolved": no copula, the state ends the clause.
290
+ if (!found && polarity === 'all' && qw !== 'they') {
291
+ const m = /^((?:[\p{L}'’]+\s+){0,3})([\p{L}]+)\s*(?:now\s*)?$/u.exec(rest.trim());
292
+ if (m && isState(m[2]) && !SWEEP_COPULA.test(m[1])) out.push(vocab.has(m[2]) ? { word: m[2], polarity } : { word: m[2].slice(2), polarity: flip(polarity) });
293
+ }
294
+ }
295
+ // "there are no <state> …", "no <state> <noun> remain(s) / exist(s) / are left".
296
+ for (const m of lower.matchAll(/\bthere\s+(?:are|is|were|was)\s+(?:no|not\s+any)\s+([\p{L}]+)/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
297
+ for (const m of lower.matchAll(/\bno\s+([\p{L}]+)\s+[\p{L}]+\s+(?:remain|remains|exist|exists|are\s+left|is\s+left)\b/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
298
+ return out;
299
+ }
300
+ /** A conclusion, and the words that frame it as the results'. */
301
+ const CONCLUSION = /\b(therefore|thus|hence|overall|suggests?|suggesting|indicates?|indicating|implies|implying|points?\s+to|likely|unlikely|probably|appears?\s+to|seems?\s+to|conclude[sd]?|conclusion|taken\s+together|clearly|evidently|must\s+be)\b/i;
302
+ const FRAME = /\b(?:the|these|those|both|its|their)\s+(?:cited\s+)?(?:results?|look[\s-]?ups?|figures|records|rows|counts?)\b|\b(?:according\s+to|based\s+on|from|in)\s+(?:the\s+|these\s+)?(?:cited\s+)?(?:results?|look[\s-]?ups?)\b/i;
303
+ /** A capitalised word: a name, unless it is an ordinary word (NAME_COMMON) or a word of the results or the question. */
304
+ const CAPITALISED = /(?<![\p{L}\p{N}_'’-])\p{Lu}[\p{L}\p{N}'’-]*/gu;
305
+ const NAME_COMMON = new Set(('a an the this that these those there here it its they their them we our you your i he she his her one both each every '
306
+ + 'all any some most many more few fewer less least several no none nothing neither nor either not only just also and or but so yet '
307
+ + 'if then than as at by for from in into of on onto to with within without across between among about after before since until '
308
+ + 'while when where which who whom whose what why how although though because however whereas meanwhile otherwise still therefore '
309
+ + 'thus hence overall together taken compared comparing based according looking grouped group by first second third next finally last '
310
+ + 'yes today tonight yesterday currently right now recently lately earlier later open new is are was were be been has have had do does '
311
+ + 'did can could may might must should would will shall per summary status partner partners customer customers result results '
312
+ + 'look lookup lookups record records row rows figure figures count counts monday tuesday wednesday thursday friday saturday sunday '
313
+ + 'january february march april may june july august september october november december '
314
+ // Discourse adverbs (fix round of PR #14).
315
+ + 'similarly additionally notably moreover furthermore likewise conversely instead again specifically particularly generally '
316
+ + 'consequently accordingly nonetheless nevertheless besides alternatively importantly interestingly unlike like contrast').split(' '));
317
+ const wordsOf = (t) => String(t || '').normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
318
+ /**
319
+ * The answer's sentences: split after . ! ? and at newlines. Cites opening a piece ("open. [r1] The …") belong to the
320
+ * sentence before; a piece with no letter (a lone cite) joins the one before.
321
+ */
322
+ function sentencesOf(answer) {
323
+ const out = [];
324
+ for (let piece of String(answer).split(/(?<=[.!?])\s+|\n+/)) {
325
+ if (!piece.trim()) continue;
326
+ const lead = out.length ? /^(?:\s*\[r[1-4]\])+/.exec(piece) : null;
327
+ if (lead) { out[out.length - 1] += lead[0]; piece = piece.slice(lead[0].length); if (!piece.trim()) continue; }
328
+ const letters = /\p{L}/u.test(piece.replace(PLACEHOLDER, ' ').replace(CITE, ' '));
329
+ if (!letters && out.length) out[out.length - 1] += ` ${piece}`; else out.push(piece);
330
+ }
331
+ return out;
332
+ }
333
+ /** Lower-cased, with spaces, hyphens and underscores folded to one space ("false_positive" reads "false positive"). */
334
+ const flat = (t) => readable(t).toLowerCase().replace(/[\s_-]+/g, ' ').trim();
335
+ const hasPhrase = (text, phrase) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(phrase)}(?![\\p{L}\\p{N}])`, 'u').test(text);
336
+ /** Does this row's value of a declared state field show the state? (strings case-blind, booleans exactly) — undefined when the row has no value. */
337
+ function stateOf(row, def) {
338
+ const v = (row.fields || {})[def.field];
339
+ if (v === undefined || v === null || v === '') return undefined;
340
+ return def.values.some((x) => (typeof x === 'boolean' ? v === x : typeof v === 'string' && v.trim().toLowerCase() === String(x).toLowerCase()));
341
+ }
342
+
116
343
  /** Every string of a result with its path in attackerFields' spelling (rows[].fields.entity, facts.entities[].value). */
117
344
  function stringsOf(r) {
118
345
  const out = [];
@@ -185,6 +412,16 @@ function createCheck(o = {}) {
185
412
  const rowChipLabel = typeof o.rowChipLabel === 'function' ? o.rowChipLabel : DEFAULT_ROW_CHIP;
186
413
  const sayIps = o.sayIps === true;
187
414
  const sayWindows = o.sayWindows === true;
415
+ // KNOWN NAMES (0.2.0): words the app says are ordinary in its answers though no result holds them (a product, "Sophos").
416
+ const knownNames = Object.freeze([...(o.knownNames || [])].map((n) => { if (typeof n !== 'string' || !n.trim()) throw new Error('ask check: knownNames is a list of names'); return n; }).flatMap((n) => wordsOf(n)));
417
+ // VERBATIM PATHS (0.1.2): a fact or a row field, in attackerFields' spelling — never a title, a label or a ref.
418
+ const verbatimFields = Object.freeze([...(o.verbatimFields || [])]);
419
+ for (const f of verbatimFields) {
420
+ if (typeof f !== 'string' || !/^(?:facts|rows\[\]\.fields)\.[a-z0-9_]+(?:\[\])?(?:\.[a-z0-9_]+)?$/i.test(f)) {
421
+ throw new Error(`ask check: verbatimFields names a fact or row-field path ('rows[].fields.upn', 'facts.entities[].value'), not ${String(f)}`);
422
+ }
423
+ }
424
+ const verbatimPaths = new Set([...verbatimFields, ...structured.flatMap((k) => [`facts.${k}`, `rows[].fields.${k}`])]);
188
425
 
189
426
  /**
190
427
  * The SAYABLE tokens:
@@ -201,6 +438,7 @@ function createCheck(o = {}) {
201
438
  set.numbers = new Set();
202
439
  set.ips = new Set();
203
440
  set.windows = new Set();
441
+ set.verbatim = new Set();
204
442
  const numeric = (v) => { if (typeof v === 'string' && /^(0(\.\d{1,3})?|1(\.0{1,3})?)$/.test(v)) set.numbers.add(v); };
205
443
  const field = (v) => {
206
444
  if (typeof v !== 'string' || !v || v.length > 64) return;
@@ -216,7 +454,7 @@ function createCheck(o = {}) {
216
454
  for (const k of numericStructured) numeric((r.facts || {})[k]);
217
455
  for (const k of idFacts) own((r.facts || {})[k]);
218
456
  for (const [path, v] of stringsOf(r)) {
219
- const fromAttacker = remote || attacker.has(path);
457
+ const fromAttacker = remote || attacker.has(path) || SNIPPET.test(path);
220
458
  for (const w of readable(v).split(/[\s,;()"'“”‘’!?]+/)) {
221
459
  const t = trim(w);
222
460
  if (!t) continue;
@@ -225,6 +463,7 @@ function createCheck(o = {}) {
225
463
  if (!fromAttacker || isInternalHost(t)) set.add(t);
226
464
  }
227
465
  if (!fromAttacker) windows(v);
466
+ if (verbatimPaths.has(path) && verbatimOk(v, fromAttacker, sayIps)) set.verbatim.add(v);
228
467
  }
229
468
  windows(r.label);
230
469
  for (const x of r.extras || []) windows(x.label);
@@ -239,37 +478,250 @@ function createCheck(o = {}) {
239
478
  return set;
240
479
  }
241
480
 
481
+ /** Attacker text as the word rules read it, lower-cased: where a number word may be a quote. */
482
+ function attackerTexts(results) {
483
+ const out = [];
484
+ for (const r of results) {
485
+ const tool = r && r.tool ? toolOf(r.tool) : null;
486
+ const remote = !tool || tool.kind === 'remote';
487
+ const attacker = new Set(tool && !remote ? tool.attackerFields : []);
488
+ for (const [path, v] of stringsOf(r)) if (remote || attacker.has(path) || SNIPPET.test(path)) out.push(readable(v).toLowerCase().replace(/\s+/g, ' '));
489
+ for (const x of r.extras || []) if ((remote || attacker.has('extras[].label')) && typeof x.label === 'string') out.push(readable(x.label).toLowerCase().replace(/\s+/g, ' '));
490
+ }
491
+ return out;
492
+ }
493
+
494
+ /** SMALL NUMBER WORDS → the one cited figure of that value (see SMALL_NUMBER); anything else is left for the rules. */
495
+ function spellFigures(answer, cites, results) {
496
+ const cited = new Set([...(cites || []), ...[...answer.matchAll(CITE)].map((m) => m[1])].filter((c) => /^r[1-4]$/.test(c) && results[Number(c.slice(1)) - 1]));
497
+ const figures = [];
498
+ for (const rid of cited) {
499
+ const r = results[Number(rid.slice(1)) - 1];
500
+ if (r.help) continue;
501
+ if (Number.isInteger(r.count)) figures.push({ mark: `{{${rid}}}`, count: r.count });
502
+ for (const x of r.extras || []) if (Number.isInteger(x.count) && figureOf(results, rid, x.key)) figures.push({ mark: `{{${rid}.${x.key}}}`, count: x.count });
503
+ }
504
+ if (!figures.length) return answer;
505
+ let attackerText = null;
506
+ const has = (s, phrase) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(phrase)}(?![\\p{L}\\p{N}])`, 'u').test(s);
507
+ // An apostrophe inside a word ("it’s", "don't") is no quotation mark (review of PR #12).
508
+ const quoted = (raw) => {
509
+ const before = raw.replace(/(?<=[\p{L}\p{N}])['’](?=\p{L})/gu, '');
510
+ return QUOTE_PAIRS.some(([a, b]) => (a === b ? (before.split(a).length - 1) % 2 === 1 : before.lastIndexOf(a) > before.lastIndexOf(b)));
511
+ };
512
+ return answer.replace(SMALL_WORD, (w, _g, at) => {
513
+ const before = answer.slice(0, at);
514
+ const after = answer.slice(at + w.length);
515
+ const prevJoin = before.match(/([\p{L}\p{N}]+)[\s-]+$/u);
516
+ const nextJoin = after.match(/^[\s-]+([\p{L}\p{N}]+)/u);
517
+ if ((prevJoin && JOINED_NUMBER.test(prevJoin[1])) || (nextJoin && JOINED_NUMBER.test(nextJoin[1]))) return w;
518
+ if (quoted(before)) return w;
519
+ const lw = w.toLowerCase();
520
+ const counted = (after.match(/^[ \t]+(\p{L}[\p{L}'’]*)/u) || [])[1];
521
+ if (!counted || NOT_COUNTED.test(counted) || IDIOM_AFTER.test(`${lw} ${counted}`)) return w;
522
+ const prevWord = (before.match(/(\p{L}+)[ \t]+$/u) || [])[1];
523
+ if (prevWord && IDIOM_BEFORE.test(prevWord)) return w;
524
+ const prev = (before.match(/([\p{L}\p{N}_'’-]+)[^\p{L}\p{N}]*$/u) || [])[1];
525
+ const next = (after.match(/^[^\p{L}\p{N}]*([\p{L}\p{N}_'’-]+)/u) || [])[1];
526
+ attackerText = attackerText || attackerTexts(results);
527
+ if (attackerText.some((s) => (prev && has(s, `${prev.toLowerCase()} ${lw}`)) || (next && has(s, `${lw} ${next.toLowerCase()}`)))) return w;
528
+ const match = figures.filter((f) => f.count === SMALL_NUMBER[lw]);
529
+ return match.length === 1 ? match[0].mark : w;
530
+ });
531
+ }
532
+
533
+ /** The attacker paths of a result: its tool's attackerFields; every path of an unknown or remote tool; any snippet. */
534
+ function attackerOf(r) {
535
+ const tool = r && r.tool ? toolOf(r.tool) : null;
536
+ const remote = !tool || tool.kind === 'remote';
537
+ const attacker = new Set(tool && !remote ? tool.attackerFields : []);
538
+ return (path) => remote || attacker.has(path) || SNIPPET.test(path);
539
+ }
540
+ /**
541
+ * What the APP wrote at the RESULT level (facts, label, notes, extras' labels — no row, no attacker path), flattened. An
542
+ * extra whose label the app derives from an enum (a verdict or state code: `{ key, count, label, enum: true }`) is
543
+ * app-written even where the tool lists `extras[].label` as attacker text (fix round of PR #14: a breakdown's groups).
544
+ */
545
+ function resultAppTexts(r) {
546
+ const isAttacker = attackerOf(r);
547
+ const out = [];
548
+ for (const [path, v] of stringsOf(r)) if (path.startsWith('facts') && !isAttacker(path)) out.push(flat(v));
549
+ if (!isAttacker('label') && typeof r.label === 'string') out.push(flat(r.label));
550
+ // An extra marked enum counts only for a group-by key the tool declares (groupBy) and a label in its closed list.
551
+ const tool = r && r.tool ? toolOf(r.tool) : null;
552
+ const enumOk = (x) => x.enum === true && tool && tool.kind !== 'remote' && tool.groupBy && typeof x.by === 'string' && tool.groupBy[x.by]
553
+ && tool.groupBy[x.by].enum.some((v) => flat(v) === flat(x.label));
554
+ for (const x of r.extras || []) if ((enumOk(x) || !isAttacker('extras[].label')) && typeof x.label === 'string') out.push(flat(x.label));
555
+ for (const n of r.notes || []) if (typeof n.label === 'string') out.push(flat(n.label));
556
+ return out;
557
+ }
558
+ /** One row's app-written text, flattened: where a verdict about THAT row must show. */
559
+ function rowAppText(r, row) {
560
+ const isAttacker = attackerOf(r);
561
+ const parts = [];
562
+ if (!isAttacker('rows[].title') && typeof row.title === 'string') parts.push(row.title);
563
+ const walk = (v, path) => {
564
+ if (typeof v === 'string') { if (!isAttacker(path)) parts.push(v); } else if (Array.isArray(v)) v.forEach((x) => walk(x, `${path}[]`));
565
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`);
566
+ };
567
+ walk(row.fields || {}, 'rows[].fields');
568
+ return flat(parts.join(' '));
569
+ }
570
+ /**
571
+ * The rows a sentence NAMES (review round 3 of PR #14): by its ref, or by a field the tool DECLARES as the row's identity
572
+ * (`rowName: 'title'` or `['title', 'hostname']` — 'title' is the row's title, anything else a row field) — never by
573
+ * any other field, so a customer name every row shares, or a value planted in another row, names nothing.
574
+ */
575
+ function namedRows(r, sentence, lower) {
576
+ const tool = r && r.tool ? toolOf(r.tool) : null;
577
+ const names = tool && tool.kind !== 'remote' && tool.rowName ? [].concat(tool.rowName) : [];
578
+ return (r.rows || []).filter((row) => {
579
+ if (typeof row.ref === 'string' && row.ref && sentence.includes(row.ref)) return true;
580
+ return names.some((k) => {
581
+ const v = k === 'title' ? row.title : (row.fields || {})[k];
582
+ return typeof v === 'string' && flat(v).length >= 4 && hasPhrase(lower, flat(v));
583
+ });
584
+ });
585
+ }
586
+ /** The state words a sweep may claim: STATE_WORDS and every state and synonym the cited tools declare. */
587
+ function stateVocab(scope) {
588
+ const out = new Set(STATE_WORDS);
589
+ for (const r of scope) {
590
+ const tool = r && r.tool ? toolOf(r.tool) : null;
591
+ for (const [w, def] of Object.entries((tool && tool.stateFields) || {})) { out.add(w); for (const x of def.synonyms || []) out.add(x); }
592
+ }
593
+ return out;
594
+ }
595
+ /** The declared state definition a word claims in one result — the state itself, a synonym, or "un<state>" (negated). */
596
+ function defOf(r, word) {
597
+ const tool = r && r.tool ? toolOf(r.tool) : null;
598
+ const fields = tool && tool.kind !== 'remote' && tool.stateFields ? tool.stateFields : {};
599
+ for (const [w, def] of Object.entries(fields)) if (w === word || (def.synonyms || []).includes(word)) return { def, negated: false };
600
+ if (word.startsWith('un')) for (const [w, def] of Object.entries(fields)) if (w === word.slice(2) || (def.synonyms || []).includes(word.slice(2))) return { def, negated: true };
601
+ return null;
602
+ }
603
+ /** Every word a result holds, attacker text included ("every name comes from the results"), and its look-up's words. */
604
+ function vocabularyOf(r) {
605
+ const out = new Set(knownNames);
606
+ for (const [, v] of stringsOf(r)) for (const w of wordsOf(readable(v))) out.add(w);
607
+ for (const t of [r.label, r.words, ...(r.extras || []).map((x) => x.label), ...(r.notes || []).map((n) => n.label)]) for (const w of wordsOf(readable(t || ''))) out.add(w);
608
+ for (const row of r.rows || []) if (typeof row.ref === 'string') for (const w of wordsOf(row.ref)) out.add(w);
609
+ return out;
610
+ }
611
+
612
+ /**
613
+ * THE CLAIM AND ANALYSE RULES over the answer's sentences (see the header). Returns { injected, format } — the first
614
+ * refusal of each class (or null). EVERY sentence is read for the injection-class rules (verdict, all/none, unframed
615
+ * conclusion, unknown name) before any format-class refusal (uncited sentence or figure) counts, so an uncited lead
616
+ * sentence never hides a later obeyed one (review round 3 of PR #14). `cited` is the whole answer's cited indexes.
617
+ */
618
+ function claimRefusal(answer, results, cited, analyse, question) {
619
+ const qWords = new Set(wordsOf(readable(question || '')));
620
+ let format = null;
621
+ for (const sentence of sentencesOf(answer)) {
622
+ const own = [...new Set([...sentence.matchAll(CITE)].map((m) => Number(m[1].slice(1)) - 1))];
623
+ const scope = (own.length ? own : [...cited]).map((i) => results[i]).filter(Boolean);
624
+ const text = readable(sentence.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' '));
625
+ const lower = flat(text);
626
+ // A VERDICT, PER ROW: what the app-written text of EVERY row the sentence names says; naming none, what the result
627
+ // itself says, or what every one of its rows says (rows that disagree settle nothing).
628
+ for (const m of text.matchAll(VERDICT)) {
629
+ const phrase = flat(m[0]);
630
+ const supported = scope.some((r) => {
631
+ const named = namedRows(r, sentence, lower);
632
+ if (named.length) return named.every((row) => hasPhrase(rowAppText(r, row), phrase));
633
+ if (resultAppTexts(r).some((t) => hasPhrase(t, phrase))) return true;
634
+ const rows = r.rows || [];
635
+ return rows.length > 0 && rows.every((row) => hasPhrase(rowAppText(r, row), phrase));
636
+ });
637
+ if (!supported) return { injected: { ok: false, code: 'unsupported_verdict', reason: `calls something “${m[0].slice(0, 30)}”, which no cited row says` }, format };
638
+ }
639
+ // ALL / NONE / NOT ALL OF THEM in a state: decided ONLY from a field the tool declares holds that state.
640
+ for (const claim of sweepsOf(lower, stateVocab(scope))) {
641
+ const holds = scope.length > 0 && scope.every((r) => {
642
+ const d = defOf(r, claim.word);
643
+ const rows = r.rows || [];
644
+ if (!d || !rows.length) return false;
645
+ const p = d.negated ? (claim.polarity === 'all' ? 'none' : claim.polarity === 'none' ? 'all' : 'notall') : claim.polarity;
646
+ // NOT ALL: one row that holds the field and is not in the state is enough, from any number of rows shown.
647
+ if (p === 'notall') return rows.some((row) => stateOf(row, d.def) === false);
648
+ if (Number.isInteger(r.count) && r.count !== rows.length) return false;
649
+ return rows.every((row) => stateOf(row, d.def) === (p === 'all'));
650
+ });
651
+ if (!holds) return { injected: { ok: false, code: 'sweeping_claim', reason: `says ${claim.polarity === 'notall' ? 'not all' : claim.polarity} of them are “${claim.word}”, which no declared state field of the cited rows shows` }, format };
652
+ }
653
+ if (!analyse) continue;
654
+ // ANALYSE: framed conclusions, no new names, every sentence cited, every figure from a result its sentence cites.
655
+ if (CONCLUSION.test(lower) && !FRAME.test(lower)) return { injected: { ok: false, code: 'unframed_conclusion', reason: 'draws a conclusion without saying it comes from the results' }, format };
656
+ const vocab = new Set(qWords);
657
+ for (const r of scope) for (const w of vocabularyOf(r)) vocab.add(w);
658
+ for (const m of text.matchAll(CAPITALISED)) {
659
+ for (const w of wordsOf(m[0])) {
660
+ if (w.length < 2 || /\p{N}/u.test(w) || NAME_COMMON.has(w) || vocab.has(w)) continue;
661
+ return { injected: { ok: false, code: 'unknown_name', reason: `names “${m[0].slice(0, 40)}”, which the cited results do not` }, format };
662
+ }
663
+ }
664
+ if (!format && !own.length) format = { ok: false, code: 'uncited_sentence', reason: 'a sentence cites no result' };
665
+ for (const m of sentence.matchAll(PLACEHOLDER)) {
666
+ if (!format && !own.includes(Number(m[1].slice(1)) - 1)) format = { ok: false, code: 'uncited_figure', reason: `a figure ${m[0]} from a result its sentence does not cite` };
667
+ }
668
+ }
669
+ return { injected: null, format };
670
+ }
671
+
242
672
  /**
243
673
  * Check one answer. Returns { ok: true, answer, cites } or { ok: false, code, reason } — `code` is one of REFUSAL_CODES.
244
674
  * @param {*} reply the model's parsed JSON
245
675
  * @param {object[]} results the look-ups' results, in order (r1 = results[0])
676
+ * @param {{mode?: 'lookup'|'analyse', question?: string}} [opts] 0.2.0: analyse mode, and the question its names may come from
246
677
  */
247
- function checkAnswer(reply, results) {
678
+ function checkAnswer(reply, results, opts = {}) {
679
+ const analyse = opts.mode === 'analyse';
248
680
  if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return { ok: false, code: 'shape', reason: 'not an object' };
249
681
  const keys = Object.keys(reply).filter((k) => !['answer', 'cites'].includes(k));
250
682
  if (keys.length) return { ok: false, code: 'shape', reason: 'unexpected fields' };
251
- const answer = typeof reply.answer === 'string' ? reply.answer.trim() : '';
683
+ let answer = typeof reply.answer === 'string' ? reply.answer.trim() : '';
252
684
  if (!answer) return { ok: false, code: 'shape', reason: 'empty answer' };
253
- if (answer.length > 700) return { ok: false, code: 'shape', reason: 'answer too long' };
685
+ // TOO LONG is a format refusal, applied AFTER the injection-class rules (final round of PR #14): a long obeyed answer is
686
+ // refused for what it obeys, not for its length.
687
+ const tooLong = answer.length > 700;
254
688
  if (!Array.isArray(reply.cites) || reply.cites.some((c) => typeof c !== 'string')) return { ok: false, code: 'shape', reason: 'cites is not a list of ids' };
255
689
  if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(answer)) return { ok: false, code: 'control_chars', reason: 'control characters' };
256
690
 
257
- for (const c of reply.cites) if (!/^r[1-4]$/.test(c) || !results[Number(c.slice(1)) - 1]) return { ok: false, code: 'unknown_cite', reason: `cites ${String(c).slice(0, 8)}, which no look-up returned` };
258
- for (const m of answer.matchAll(CITE)) if (!results[Number(m[1].slice(1)) - 1]) return { ok: false, code: 'unknown_cite', reason: `cites ${m[1]}, which no look-up returned` };
259
- for (const m of answer.matchAll(PLACEHOLDER)) if (!figureOf(results, m[1], m[2])) return { ok: false, code: 'unknown_figure', reason: `a figure ${m[0]} no look-up returned` };
691
+ // INJECTION-CLASS RULES FIRST (review round 3 of PR #14): a FORMAT refusal found on the way (an unknown cite or figure,
692
+ // a malformed placeholder, a number word) is kept, not returned, until every link, obeyed and claim rule has read the
693
+ // whole answer — so a format slip never hides an obeyed sentence.
694
+ let fmt = null;
695
+ const later = (code, reason) => { if (!fmt) fmt = { ok: false, code, reason }; };
696
+ for (const c of reply.cites) if (!/^r[1-4]$/.test(c) || !results[Number(c.slice(1)) - 1]) later('unknown_cite', `cites ${String(c).slice(0, 8)}, which no look-up returned`);
697
+ for (const m of answer.matchAll(CITE)) if (!results[Number(m[1].slice(1)) - 1]) later('unknown_cite', `cites ${m[1]}, which no look-up returned`);
698
+ const knownCites = reply.cites.filter((c) => /^r[1-4]$/.test(c) && results[Number(c.slice(1)) - 1]);
699
+ answer = spellFigures(answer, knownCites, results);
700
+ for (const m of answer.matchAll(PLACEHOLDER)) if (!figureOf(results, m[1], m[2])) later('unknown_figure', `a figure ${m[0]} no look-up returned`);
260
701
  const bare = answer.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' ');
261
- if (/[{}]/.test(bare)) return { ok: false, code: 'bad_placeholder', reason: 'a malformed figure placeholder' };
702
+ if (/[{}]/.test(bare)) later('bad_placeholder', 'a malformed figure placeholder');
262
703
  // NFKC, other full stops and NO format characters for the word rules: what the person would read.
263
704
  const norm = readable(bare);
264
- const allowed = allowedTokens(results);
265
- if (LINKISH.test(norm)) return { ok: false, code: 'link', reason: 'a link or markup' };
266
- for (const m of norm.matchAll(DOMAINISH)) {
705
+ const cited = new Set([...knownCites, ...[...answer.matchAll(CITE)].map((m) => m[1])].map((c) => Number(c.slice(1)) - 1).filter((i) => results[i]));
706
+ // ANALYSE: what may be typed is what the CITED results returned.
707
+ const allowed = allowedTokens(analyse ? results.filter((_, i) => cited.has(i)) : results);
708
+ if (LINKISH.test(norm) || DANGEROUS_SCHEME.test(norm)) return { ok: false, code: 'link', reason: 'a link or markup' };
709
+ // VERBATIM SPANS read as one plain word by DOMAINISH, the token rules and the allow-list (and by nothing else).
710
+ const verbatim = [...allowed.verbatim].sort((a, b) => b.length - a.length);
711
+ const normV = maskVerbatim(norm, verbatim);
712
+ const rawV = maskVerbatim(bare.normalize('NFC'), verbatim);
713
+ for (const m of normV.matchAll(DOMAINISH)) {
267
714
  const d = m[0];
268
715
  if (d.includes('/') || !(INTERNAL_SUFFIX.test(d) || allowed.has(trim(d)))) return { ok: false, code: 'link', reason: 'a link or markup' };
269
716
  }
270
717
  const word = NUMBER_WORD.exec(norm);
271
- if (word) return { ok: false, code: 'number_word', reason: `writes the number “${word[0]}” instead of a figure placeholder` };
718
+ if (word) later('number_word', `writes the number “${word[0]}” instead of a figure placeholder`);
272
719
  for (const [re, why, code] of OBEYED) if (re.test(norm)) return { ok: false, code, reason: why };
720
+ const claim = claimRefusal(answer, results, cited, analyse, opts.question);
721
+ if (claim.injected) return claim.injected;
722
+ if (tooLong) return { ok: false, code: 'shape', reason: 'answer too long' };
723
+ if (fmt) return fmt;
724
+ if (claim.format) return claim.format;
273
725
 
274
726
  const named = (x) => {
275
727
  let y = x;
@@ -279,7 +731,7 @@ function createCheck(o = {}) {
279
731
  };
280
732
  // BOTH READINGS: the raw text keeps a Roman numeral (Ⅸ — NFKC makes it the letters IX), the readable one joins what
281
733
  // a format character split.
282
- for (const src of [bare, norm].map(named)) {
734
+ for (const src of [rawV, normV].map(named)) {
283
735
  for (const t of src.match(REF_TOKEN) || []) {
284
736
  if (!allowed.has(t)) return { ok: false, code: 'unknown_ref', reason: `names “${t.slice(0, 40)}”, which is not an id a look-up returned` };
285
737
  }
@@ -291,7 +743,7 @@ function createCheck(o = {}) {
291
743
  if (tok && /\p{N}/u.test(tok) && !allowed.has(tok) && !(HOSTLIKE.test(tok) && INTERNAL_SUFFIX.test(tok))) return { ok: false, code: 'unknown_token', reason: `states “${tok.slice(0, 40)}”, which no look-up returned as an id, a field or a host` };
292
744
  }
293
745
  }
294
- for (const ch of bare.normalize('NFC')) {
746
+ for (const ch of rawV) {
295
747
  if (!ALLOWED_CHAR.test(ch)) return { ok: false, code: 'disallowed_char', reason: `a character outside the allowed set (U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')})` };
296
748
  }
297
749
  return { ok: true, answer, cites: [...new Set(reply.cites)] };
@@ -339,7 +791,7 @@ function createCheck(o = {}) {
339
791
  return chips;
340
792
  }
341
793
 
342
- const config = Object.freeze({ structured, numericStructured, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows,
794
+ const config = Object.freeze({ structured, numericStructured, verbatimFields, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows, knownNames,
343
795
  namedTokens: namedTokens.map(([re, to]) => [String(re), String(to)]) });
344
796
  /**
345
797
  * WHAT DECIDES A VERDICT (review H3 of PR #9): the rule sources and this app's config, including its id test and row-chip
@@ -350,8 +802,10 @@ function createCheck(o = {}) {
350
802
  }
351
803
 
352
804
  /** Every module-level rule and helper a verdict depends on, as text (with createCheck's own source). */
353
- const RULE_SOURCES = [PLACEHOLDER, CITE, DIGIT_TOKEN, PURE_NUMBER, REF_TOKEN, HOSTLIKE, LINKISH, DOMAINISH, INTERNAL_SUFFIX, NUMBER_WORD, IPV4, IPV6, WINDOW,
354
- MARK_BEFORE_PUNCT, MARK_AFTER_DOT, INVISIBLE, OTHER_DOTS, BRACKETED_DOT, SPELLED_DOT, SPACED_DOT, ALLOWED_CHAR, ...OBEYED.map(([re, why, code]) => `${re}|${why}|${code}`), REFUSAL_CODES.join(','),
355
- unitOf, isInternalHost, readable, trim, stringsOf, figureOf, segmentsOf, helpChips, createCheck].map(String);
805
+ const RULE_SOURCES = [PLACEHOLDER, CITE, DIGIT_TOKEN, PURE_NUMBER, REF_TOKEN, HOSTLIKE, LINKISH, DANGEROUS_SCHEME, DOMAINISH, INTERNAL_SUFFIX, NUMBER_WORD, IPV4, IPV6, WINDOW,
806
+ MARK_BEFORE_PUNCT, MARK_AFTER_DOT, VERBATIM_EXTRA, VERBATIM_VALUE, CIDR4, VERBATIM_BEFORE, VERBATIM_AFTER, SCHEMEISH, IP_PART, DOMAIN_USER, SMALL_WORD, JOINED_NUMBER, NOT_COUNTED, IDIOM_AFTER, IDIOM_BEFORE, JSON.stringify(SMALL_NUMBER),
807
+ JSON.stringify(QUOTE_PAIRS), INVISIBLE, OTHER_DOTS, BRACKETED_DOT, SPELLED_DOT, SPACED_DOT, ALLOWED_CHAR, ...OBEYED.map(([re, why, code]) => `${re}|${why}|${code}`), REFUSAL_CODES.join(','),
808
+ SNIPPET, VERDICT, STATE_WORDS.join(' '), SWEEP_QUANT, SWEEP_COPULA, SWEEP_ADVERB, SWEEP_NEGATION, sweepsOf, CONCLUSION, FRAME, CAPITALISED, [...NAME_COMMON].join(' '),
809
+ unitOf, isInternalHost, readable, verbatimOk, escapeRe, maskVerbatim, trim, wordsOf, sentencesOf, flat, hasPhrase, stateOf, stringsOf, figureOf, segmentsOf, helpChips, createCheck].map(String);
356
810
 
357
- module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, segmentsOf, figureOf, stringsOf, readable };
811
+ module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, VERDICT, segmentsOf, figureOf, stringsOf, readable, sentencesOf };