@aimarket/warden 0.23.0 → 0.24.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/CHANGELOG.md +31 -0
- package/README-es.md +19 -9
- package/README-fr.md +19 -9
- package/README-ru.md +19 -9
- package/README-zh.md +18 -9
- package/README.md +20 -10
- package/codex-hooks/hooks.json +17 -0
- package/codex-hooks/requirements.toml +38 -0
- package/codex-hooks/scripts/codex-hook.mjs +15 -0
- package/codex-hooks/scripts/flow-guard-answer.mjs +228 -0
- package/codex-hooks/scripts/flow-guard-classify.mjs +224 -0
- package/codex-hooks/scripts/flow-guard-codex.mjs +222 -0
- package/codex-hooks/scripts/flow-guard-constraints.mjs +69 -0
- package/codex-hooks/scripts/flow-guard-decode.mjs +149 -0
- package/codex-hooks/scripts/flow-guard-destinations.mjs +158 -0
- package/codex-hooks/scripts/flow-guard-fingerprints.mjs +180 -0
- package/codex-hooks/scripts/flow-guard-fragments.mjs +122 -0
- package/codex-hooks/scripts/flow-guard-instructions.mjs +36 -0
- package/codex-hooks/scripts/flow-guard-labels.mjs +55 -0
- package/codex-hooks/scripts/flow-guard-leak.mjs +135 -0
- package/codex-hooks/scripts/flow-guard-paths.mjs +80 -0
- package/codex-hooks/scripts/flow-guard-policy.mjs +179 -0
- package/codex-hooks/scripts/flow-guard-post.mjs +131 -0
- package/codex-hooks/scripts/flow-guard-pre.mjs +339 -0
- package/codex-hooks/scripts/flow-guard-screen.mjs +558 -0
- package/codex-hooks/scripts/flow-guard-state.mjs +129 -0
- package/codex-hooks/scripts/flow-guard-text.mjs +46 -0
- package/codex-hooks/scripts/flow-guard-types.mjs +2 -0
- package/codex-hooks/scripts/flow-guard-unattended.mjs +22 -0
- package/codex-hooks/scripts/flow-guard.mjs +103 -0
- package/dist/flow-guard-codex.d.ts +32 -0
- package/dist/flow-guard-codex.d.ts.map +1 -0
- package/dist/flow-guard-codex.js +222 -0
- package/dist/flow-guard-codex.js.map +1 -0
- package/dist/flow-guard-paths.d.ts.map +1 -1
- package/dist/flow-guard-paths.js +6 -1
- package/dist/flow-guard-paths.js.map +1 -1
- package/dist/flow-guard-unattended.d.ts +27 -0
- package/dist/flow-guard-unattended.d.ts.map +1 -0
- package/dist/flow-guard-unattended.js +22 -0
- package/dist/flow-guard-unattended.js.map +1 -0
- package/dist/opencode-plugin.d.ts +3 -9
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +3 -18
- package/dist/opencode-plugin.js.map +1 -1
- package/docs/codex.es.md +260 -0
- package/docs/codex.fr.md +262 -0
- package/docs/codex.md +251 -0
- package/docs/codex.ru.md +254 -0
- package/docs/codex.zh.md +232 -0
- package/docs/flow-guard.es.md +1 -1
- package/docs/flow-guard.fr.md +1 -1
- package/docs/flow-guard.md +1 -1
- package/docs/flow-guard.ru.md +1 -1
- package/docs/flow-guard.zh.md +1 -1
- package/docs/opencode.md +33 -1
- package/docs/scan.es.md +2 -2
- package/docs/scan.fr.md +2 -2
- package/docs/scan.md +2 -2
- package/docs/scan.ru.md +2 -2
- package/docs/scan.zh.md +2 -2
- package/package.json +10 -2
- package/scripts/codex-live-check.sh +109 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// GENERATED from src/flow-guard-codex.ts by scripts/sync-flow-guard.mjs. Do not edit; run npm run build.
|
|
2
|
+
/**
|
|
3
|
+
* WARDEN flow guard for OpenAI Codex (CLI, desktop app, `codex exec`, a Multica runtime): the checks of the Claude
|
|
4
|
+
* Code plugin, through Codex's command hooks (`hooks.json`, `[hooks]` in `config.toml` or `requirements.toml`).
|
|
5
|
+
*
|
|
6
|
+
* Codex hooks speak nearly the Claude Code hook protocol: the same events, `tool_name`/`tool_input`/`tool_response`,
|
|
7
|
+
* `hookSpecificOutput.permissionDecision`. Three differences decide this adapter:
|
|
8
|
+
*
|
|
9
|
+
* - **No `ask`.** Codex does not support `permissionDecision: "ask"` in PreToolUse: the hook run counts as failed and
|
|
10
|
+
* THE TOOL CALL CONTINUES. Passing the Claude Code decision through would let every questioned call run. Every
|
|
11
|
+
* `ask` therefore becomes `deny` or a logged pass, by `block` (see flow-guard-unattended.ts); this adapter never
|
|
12
|
+
* prints `ask`.
|
|
13
|
+
* - **A failed hook does not stop the call.** An exception in a hook, or output Codex cannot parse, lets the tool
|
|
14
|
+
* run. Every path of PreToolUse that is not a clean "no decision" prints a deny, and a PostToolUse that could not
|
|
15
|
+
* record its result marks the session, so every later call of that session is denied.
|
|
16
|
+
* - **Tool names.** Shell and unified exec arrive as `Bash` with `tool_input.command`; edits as `apply_patch` with the
|
|
17
|
+
* patch in `tool_input.command`; MCP tools as `mcp__<server>__<tool>`; other local tools by their own name.
|
|
18
|
+
* Subagent hooks carry the parent's `session_id`, so a session tree shares one guard state without extra work.
|
|
19
|
+
*
|
|
20
|
+
* Codex's own session ids are namespaced (`codex:<id>`) so they never share state with a Claude Code session.
|
|
21
|
+
* Hosted tools (Codex's own web search) run no local hook: what they bring in is not recorded. Turn web search off
|
|
22
|
+
* for a guarded agent (`web_search = "disabled"`) or accept that gap.
|
|
23
|
+
*
|
|
24
|
+
* Node built-ins and flow-guard parts only: `codex-plugin/scripts` holds copies that run without the package.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { flowPaths, stateDir } from './flow-guard-paths.mjs';
|
|
30
|
+
import { flowPost } from './flow-guard-post.mjs';
|
|
31
|
+
import { flowPrompt, flowStop } from './flow-guard-answer.mjs';
|
|
32
|
+
import { flowPre } from './flow-guard-pre.mjs';
|
|
33
|
+
import { log } from './flow-guard-state.mjs';
|
|
34
|
+
import { blockMode, patchFiles, refuses } from './flow-guard-unattended.mjs';
|
|
35
|
+
/** Local tools that only change Codex's own session state: no data in, no data out. */
|
|
36
|
+
const IGNORED = new Set(['update_plan', 'request_user_input', 'list_mcp_resources', 'list_mcp_resource_templates']);
|
|
37
|
+
const SHELLS = /^(?:\/(?:usr\/)?bin\/)?(?:ba|z|da|k)?sh$/;
|
|
38
|
+
// A PostToolUse input carries the whole tool result (a large file read); too small a cap would skip fingerprinting it.
|
|
39
|
+
const STDIN_BYTES = 16 * 1_048_576;
|
|
40
|
+
function obj(v) {
|
|
41
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
42
|
+
}
|
|
43
|
+
function str(v) {
|
|
44
|
+
return typeof v === 'string' ? v : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** The shell text of a Codex command: a string, or an argv where `bash -lc "<script>"` means its script. */
|
|
47
|
+
export function commandText(value) {
|
|
48
|
+
if (typeof value === 'string')
|
|
49
|
+
return value;
|
|
50
|
+
if (!Array.isArray(value) || !value.every(v => typeof v === 'string'))
|
|
51
|
+
return value === undefined ? '' : JSON.stringify(value);
|
|
52
|
+
const argv = value;
|
|
53
|
+
const flag = argv.findIndex((a, i) => i > 0 && /^-[a-z]*c[a-z]*$/.test(a));
|
|
54
|
+
if (argv.length >= 3 && SHELLS.test(argv[0]) && flag > 0 && flag === argv.length - 2)
|
|
55
|
+
return argv[argv.length - 1];
|
|
56
|
+
// Quote each word, so a word with a space stays one word for the guard's shell parser.
|
|
57
|
+
return argv.map(a => /^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`).join(' ');
|
|
58
|
+
}
|
|
59
|
+
/** One Codex tool call in the guard's (Claude Code) vocabulary: zero, one or, for a multi-file patch, several calls. */
|
|
60
|
+
export function codexGuardCalls(toolName, rawInput) {
|
|
61
|
+
const a = obj(rawInput);
|
|
62
|
+
if (IGNORED.has(toolName))
|
|
63
|
+
return [];
|
|
64
|
+
if (toolName.startsWith('mcp__'))
|
|
65
|
+
return [{ name: toolName, input: a }];
|
|
66
|
+
switch (toolName) {
|
|
67
|
+
case 'Bash':
|
|
68
|
+
case 'shell':
|
|
69
|
+
case 'exec_command':
|
|
70
|
+
case 'local_shell':
|
|
71
|
+
case 'unified_exec':
|
|
72
|
+
return [{ name: 'Bash', input: { command: commandText(a.command ?? a.cmd) } }];
|
|
73
|
+
case 'apply_patch':
|
|
74
|
+
case 'Edit':
|
|
75
|
+
case 'Write': {
|
|
76
|
+
const text = str(a.command) ?? str(a.patch) ?? str(a.input) ?? (typeof rawInput === 'string' ? rawInput : '');
|
|
77
|
+
const files = patchFiles(text);
|
|
78
|
+
// An unparsable patch is still a write of its whole text somewhere.
|
|
79
|
+
return (files.length ? files : ['']).map(f => ({ name: 'Write', input: { file_path: f, content: text } }));
|
|
80
|
+
}
|
|
81
|
+
case 'view_image': {
|
|
82
|
+
const path = str(a.path) ?? str(a.file_path);
|
|
83
|
+
return [{ name: 'Read', input: path ? { file_path: path } : {} }];
|
|
84
|
+
}
|
|
85
|
+
case 'write_stdin':
|
|
86
|
+
// Input to a running command: its text is what that command receives.
|
|
87
|
+
return [{ name: 'Bash', input: { command: str(a.chars) ?? str(a.input) ?? '' } }];
|
|
88
|
+
}
|
|
89
|
+
// Anything else (spawn_agent, a plugin's tool) is an unlabelled tool: its result is outside text, and it sends
|
|
90
|
+
// when its name or arguments say so.
|
|
91
|
+
return [{ name: `mcp__codex__${toolName.replace(/[^A-Za-z0-9_-]/g, '_')}`, input: a }];
|
|
92
|
+
}
|
|
93
|
+
/** The guard's session id for a Codex session: namespaced, so it never shares state with a Claude Code session. */
|
|
94
|
+
export function codexSession(input) {
|
|
95
|
+
const id = str(input.session_id);
|
|
96
|
+
return id ? `codex:${id}` : '';
|
|
97
|
+
}
|
|
98
|
+
function brokenPath(env, sessionId) {
|
|
99
|
+
return join(stateDir(env), 'codex', 'broken', createHash('sha256').update(sessionId).digest('hex'));
|
|
100
|
+
}
|
|
101
|
+
function deny(reason) {
|
|
102
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
|
|
103
|
+
}
|
|
104
|
+
function settings(options) {
|
|
105
|
+
const env = options.env ?? process.env;
|
|
106
|
+
return { env, block: options.block ?? blockMode(env.WARDEN_CODEX_BLOCK) ?? 'data' };
|
|
107
|
+
}
|
|
108
|
+
function hookInput(input, call, sessionId) {
|
|
109
|
+
return { session_id: sessionId, tool_name: call.name, tool_input: call.input, cwd: str(input.cwd) || process.cwd(),
|
|
110
|
+
...(input.tool_response !== undefined ? { tool_response: input.tool_response } : {}) };
|
|
111
|
+
}
|
|
112
|
+
/** PreToolUse: a deny, or undefined to let the call run. Never `ask` (Codex runs the call on `ask`). */
|
|
113
|
+
export function codexPre(input, options = {}) {
|
|
114
|
+
const { env, block } = settings(options);
|
|
115
|
+
const sessionId = codexSession(input);
|
|
116
|
+
const tool = str(input.tool_name) ?? '';
|
|
117
|
+
if (!sessionId || !tool)
|
|
118
|
+
return deny('WARDEN flow guard got a Codex hook input without a session or tool name. Refused.');
|
|
119
|
+
if (existsSync(brokenPath(env, sessionId))) {
|
|
120
|
+
return deny('WARDEN flow guard could not record an earlier result of this session, so it cannot judge this call. Refused; start a new session.');
|
|
121
|
+
}
|
|
122
|
+
for (const call of codexGuardCalls(tool, input.tool_input)) {
|
|
123
|
+
const detail = {};
|
|
124
|
+
const decision = flowPre(hookInput(input, call, sessionId), env, 'hook', detail);
|
|
125
|
+
if (!decision)
|
|
126
|
+
continue;
|
|
127
|
+
const refused = refuses(decision, detail, block);
|
|
128
|
+
log(flowPaths(stateDir(env), sessionId), { event: 'codex', tool: call.name, decision: decision.permissionDecision,
|
|
129
|
+
reason: detail.reason, sensitive: !!detail.sensitive, resolution: refused ? 'refused' : 'allowed_unattended', block });
|
|
130
|
+
if (refused) {
|
|
131
|
+
return deny(`${decision.permissionDecisionReason}\nRefused by the WARDEN flow guard (Codex, block=${block}). Do not retry this call in another form; report what was refused.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
/** PostToolUse: records what the result brought in. A failure marks the session so that later calls are denied. */
|
|
137
|
+
export function codexPost(input, options = {}) {
|
|
138
|
+
const { env } = settings(options);
|
|
139
|
+
const sessionId = codexSession(input);
|
|
140
|
+
const tool = str(input.tool_name) ?? '';
|
|
141
|
+
if (!sessionId || !tool)
|
|
142
|
+
return undefined;
|
|
143
|
+
const warnings = [];
|
|
144
|
+
for (const call of codexGuardCalls(tool, input.tool_input)) {
|
|
145
|
+
let post;
|
|
146
|
+
try {
|
|
147
|
+
post = flowPost(hookInput(input, call, sessionId), env);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
markBroken(env, sessionId);
|
|
151
|
+
log(flowPaths(stateDir(env), sessionId), { event: 'codex', tool: call.name, resolution: 'record_failed', error: err instanceof Error ? err.name : 'error' });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const warning = post?.additionalContext || post?.systemMessage;
|
|
155
|
+
if (warning && !warnings.includes(warning))
|
|
156
|
+
warnings.push(warning);
|
|
157
|
+
}
|
|
158
|
+
return warnings.length ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: `[WARDEN] ${warnings.join('\n')}` } } : undefined;
|
|
159
|
+
}
|
|
160
|
+
function markBroken(env, sessionId) {
|
|
161
|
+
const path = brokenPath(env, sessionId);
|
|
162
|
+
mkdirSync(join(stateDir(env), 'codex', 'broken'), { recursive: true, mode: 0o700 });
|
|
163
|
+
writeFileSync(path, new Date().toISOString() + '\n', { mode: 0o600 });
|
|
164
|
+
}
|
|
165
|
+
/** UserPromptSubmit: the destinations the user's own message names. */
|
|
166
|
+
export function codexPrompt(input, options = {}) {
|
|
167
|
+
const { env } = settings(options);
|
|
168
|
+
const sessionId = codexSession(input);
|
|
169
|
+
if (sessionId)
|
|
170
|
+
flowPrompt({ ...input, session_id: sessionId }, env);
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
/** Stop: the guard's note on the answer. `decision: block` makes Codex continue once with the reason as the prompt. */
|
|
174
|
+
export function codexStop(input, options = {}) {
|
|
175
|
+
const { env } = settings(options);
|
|
176
|
+
const sessionId = codexSession(input);
|
|
177
|
+
return sessionId ? flowStop({ ...input, session_id: sessionId }, env) : undefined;
|
|
178
|
+
}
|
|
179
|
+
const EVENTS = ['pre-tool-use', 'post-tool-use', 'user-prompt-submit', 'stop'];
|
|
180
|
+
/** One hook run: the stdout line, or undefined. Throws only on bad input; the caller turns that into a deny. */
|
|
181
|
+
export function runCodexHook(event, stdin, options = {}) {
|
|
182
|
+
if (Buffer.byteLength(stdin) > STDIN_BYTES)
|
|
183
|
+
throw new Error('hook input too large');
|
|
184
|
+
const parsed = JSON.parse(stdin || '{}');
|
|
185
|
+
const input = obj(parsed);
|
|
186
|
+
if (input !== parsed)
|
|
187
|
+
throw new Error('hook input is not an object');
|
|
188
|
+
const out = event === 'pre-tool-use' ? codexPre(input, options) : event === 'post-tool-use' ? codexPost(input, options)
|
|
189
|
+
: event === 'user-prompt-submit' ? codexPrompt(input, options) : codexStop(input, options);
|
|
190
|
+
return out ? JSON.stringify(out) : undefined;
|
|
191
|
+
}
|
|
192
|
+
/** `codex-hook.mjs <event>` reads the Codex hook JSON on stdin. Fails closed for PreToolUse. */
|
|
193
|
+
export function codexHookMain(argv = process.argv.slice(2), options = {}) {
|
|
194
|
+
const event = argv[0];
|
|
195
|
+
if (!EVENTS.includes(event)) {
|
|
196
|
+
process.stderr.write(`Usage: codex-hook.mjs ${EVENTS.join('|')} (Codex command hook; reads the hook JSON on stdin)\n`);
|
|
197
|
+
// Codex reads a deny from stdout; a misconfigured PreToolUse hook must not let calls through.
|
|
198
|
+
process.stdout.write(JSON.stringify(deny('WARDEN Codex hook is misconfigured (unknown event). Refused.')) + '\n');
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
let stdin = '';
|
|
202
|
+
try {
|
|
203
|
+
stdin = readFileSync(0, 'utf8');
|
|
204
|
+
const out = runCodexHook(event, stdin, options);
|
|
205
|
+
if (out)
|
|
206
|
+
process.stdout.write(out + '\n');
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
process.stderr.write(`warden codex hook ${event}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
210
|
+
if (event === 'pre-tool-use')
|
|
211
|
+
process.stdout.write(JSON.stringify(deny('WARDEN flow guard could not safely check this call. Repair the guard state before retrying.')) + '\n');
|
|
212
|
+
if (event === 'post-tool-use') {
|
|
213
|
+
try {
|
|
214
|
+
const sessionId = codexSession(obj(JSON.parse(stdin || '{}')));
|
|
215
|
+
if (sessionId)
|
|
216
|
+
markBroken(options.env ?? process.env, sessionId);
|
|
217
|
+
}
|
|
218
|
+
catch { /* the input itself is unreadable: nothing to mark */ }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// GENERATED from src/flow-guard-constraints.ts by scripts/sync-flow-guard.mjs. Do not edit; run npm run build.
|
|
2
|
+
/** Optional host-supplied constraint evidence. No natural-language inference or permission grants. */
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
6
|
+
const scalar = (value) => typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 1e12;
|
|
7
|
+
const id = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$/.test(value);
|
|
8
|
+
const field = (value) => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(value) && !['constructor', 'prototype'].includes(value);
|
|
9
|
+
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
10
|
+
const same = (a, b) => typeof a === typeof b && a === b;
|
|
11
|
+
/** Fixed templates + finite scalars/positions only. No names, quotes, URLs or source-authored field labels. */
|
|
12
|
+
export function checkAnswerConstraints(value, answer, sessionId, turn) {
|
|
13
|
+
const empty = (status) => ({ status, violations: [], unknownReferences: 0, text: status === 'invalid' ? 'WARDEN: constraint evidence is unavailable because the host check did not validate. This is not a passed check.' : '' });
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return empty('absent');
|
|
16
|
+
if (!object(value) || !exact(value, ['schema', 'sessionId', 'turn', 'answerSha256', 'rules', 'records', 'claims']) || value.schema !== 'warden-answer-check/1' ||
|
|
17
|
+
value.sessionId !== sessionId || !Number.isSafeInteger(value.turn) || value.turn < 1 || value.turn !== turn || value.answerSha256 !== hash(answer) ||
|
|
18
|
+
!Array.isArray(value.rules) || !value.rules.length || value.rules.length > 16 || !Array.isArray(value.records) || !value.records.length || value.records.length > 256 || !object(value.claims))
|
|
19
|
+
return empty('invalid');
|
|
20
|
+
const rules = [];
|
|
21
|
+
for (const candidate of value.rules) {
|
|
22
|
+
if (!object(candidate) || !exact(candidate, ['field', 'op', 'value']) || !field(candidate.field) || typeof candidate.op !== 'string' || !['lte', 'gte', 'eq'].includes(candidate.op) || !scalar(candidate.value) || candidate.op !== 'eq' && typeof candidate.value !== 'number')
|
|
23
|
+
return empty('invalid');
|
|
24
|
+
rules.push(candidate);
|
|
25
|
+
}
|
|
26
|
+
const records = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const candidate of value.records) {
|
|
29
|
+
if (!object(candidate) || !exact(candidate, ['id', 'attributes']) || !id(candidate.id) || seen.has(candidate.id) || !object(candidate.attributes) || Object.keys(candidate.attributes).length > 32 ||
|
|
30
|
+
!Object.entries(candidate.attributes).every(([key, entry]) => field(key) && scalar(entry)))
|
|
31
|
+
return empty('invalid');
|
|
32
|
+
records.push(candidate);
|
|
33
|
+
seen.add(candidate.id);
|
|
34
|
+
}
|
|
35
|
+
const claims = value.claims;
|
|
36
|
+
if (!exact(claims, ['selectedId', 'eligibleIds']) || !id(claims.selectedId) || !Array.isArray(claims.eligibleIds) || claims.eligibleIds.length > 128 || !claims.eligibleIds.every(id) || new Set(claims.eligibleIds).size !== claims.eligibleIds.length)
|
|
37
|
+
return empty('invalid');
|
|
38
|
+
// Stable facts identity is independent of selected claims and the generated answer.
|
|
39
|
+
const factsSha256 = hash(JSON.stringify({ rules, records: records.map(record => ({ id: record.id, attributes: Object.fromEntries(Object.entries(record.attributes).sort(([a], [b]) => a.localeCompare(b))) })) }));
|
|
40
|
+
const map = new Map(records.map(record => [record.id, record]));
|
|
41
|
+
const referenced = [...new Set([claims.selectedId, ...claims.eligibleIds])];
|
|
42
|
+
const violations = [];
|
|
43
|
+
let unknownReferences = 0;
|
|
44
|
+
for (const itemId of referenced) {
|
|
45
|
+
const record = map.get(itemId);
|
|
46
|
+
if (!record) {
|
|
47
|
+
unknownReferences++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const [index, rule] of rules.entries()) {
|
|
51
|
+
if (!Object.hasOwn(record.attributes, rule.field)) {
|
|
52
|
+
unknownReferences++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const actual = record.attributes[rule.field];
|
|
56
|
+
if (rule.op !== 'eq' && typeof actual !== 'number') {
|
|
57
|
+
unknownReferences++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const passes = rule.op === 'eq' ? same(actual, rule.value) : rule.op === 'lte' ? actual <= rule.value : actual >= rule.value;
|
|
61
|
+
if (!passes)
|
|
62
|
+
violations.push({ item: records.indexOf(record) + 1, rule: index + 1, op: rule.op, limit: rule.value, actual });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const snippets = violations.slice(0, 8).map(v => `Source snapshot record #${v.item} (1-based source-record order, not claim-list order) violates rule #${v.rule}: actual ${v.actual}; required ${v.op === 'lte' ? '<=' : v.op === 'gte' ? '>=' : '==='} ${v.limit}.`);
|
|
66
|
+
const status = unknownReferences ? 'partial' : 'checked';
|
|
67
|
+
const text = snippets.length ? 'WARDEN constraint evidence (conditional on the host record snapshot): ' + snippets.join(' ') + (violations.length > 8 ? ` ${violations.length - 8} more contradictions recorded.` : '') + (unknownReferences ? ' Some references or fields are unknown; this is not a complete check.' : '') : unknownReferences ? 'WARDEN: some claimed items or rule fields are unknown. Constraint checking is incomplete, not passed.' : '';
|
|
68
|
+
return { status, factsSha256, violations, unknownReferences, text };
|
|
69
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// GENERATED from src/flow-guard-decode.ts by scripts/sync-flow-guard.mjs. Do not edit; run npm run build.
|
|
2
|
+
/** Flow guard: reversible spellings a value can hide in (URL, base64, hex, escapes, character lists, NATO), decoded into candidates. */
|
|
3
|
+
const MAX_CANDIDATE = 524_288;
|
|
4
|
+
/** Only explicit character lists are decoded, never arbitrary prose. Unknown items end a run.
|
|
5
|
+
* English digit names and case markers cover the recorded attack, not arbitrary verbal encodings. */
|
|
6
|
+
export function spelledCandidates(text) {
|
|
7
|
+
const digits = { zero: '0', one: '1', two: '2', three: '3', four: '4', five: '5', six: '6', seven: '7', eight: '8', nine: '9' };
|
|
8
|
+
const out = [];
|
|
9
|
+
let run = '';
|
|
10
|
+
const flush = () => { if (run.length >= 5)
|
|
11
|
+
out.push(run); run = ''; };
|
|
12
|
+
for (const item of text.slice(0, MAX_CANDIDATE).normalize('NFKC').split(/[,;|\r\n]+/)) {
|
|
13
|
+
const unit = item.trim().toLowerCase();
|
|
14
|
+
const ch = /^[a-z0-9]$/.test(unit) ? unit : (Object.hasOwn(digits, unit) ? digits[unit] : undefined) ?? /^(?:capital|uppercase|lowercase|letter) ([a-z])$/.exec(unit)?.[1];
|
|
15
|
+
if (ch)
|
|
16
|
+
run += ch;
|
|
17
|
+
else
|
|
18
|
+
flush();
|
|
19
|
+
}
|
|
20
|
+
flush();
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** Decimal scalar codes, in a contiguous explicit list. Unknown/invalid items terminate a run.
|
|
24
|
+
* Merely decoding public numbers does not block anything: existing secret/private fingerprints still decide. */
|
|
25
|
+
export function characterCodeCandidates(text) {
|
|
26
|
+
const out = [];
|
|
27
|
+
let run = '', count = 0;
|
|
28
|
+
const flush = () => { if (count >= 5)
|
|
29
|
+
out.push(run); run = ''; count = 0; };
|
|
30
|
+
for (const item of text.slice(0, MAX_CANDIDATE).normalize('NFKC').split(/[\s,;|[\]]+/)) {
|
|
31
|
+
if (!item)
|
|
32
|
+
continue;
|
|
33
|
+
const code = /^\d{1,7}$/.test(item) ? Number(item) : -1;
|
|
34
|
+
if (code >= 32 && code <= 0x10ffff && !(code >= 0x7f && code <= 0x9f) && !(code >= 0xd800 && code <= 0xdfff) || [9, 10, 13].includes(code)) {
|
|
35
|
+
run += String.fromCodePoint(code);
|
|
36
|
+
count++;
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
flush();
|
|
40
|
+
}
|
|
41
|
+
flush();
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
const NATO = Object.freeze(Object.fromEntries([
|
|
45
|
+
...'alfa bravo charlie delta echo foxtrot golf hotel india juliett kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu'.split(' ').map((word, i) => [word, String.fromCharCode(97 + i)]),
|
|
46
|
+
['alpha', 'a'], ['juliet', 'j'], ['x-ray', 'x'],
|
|
47
|
+
['niner', '9'],
|
|
48
|
+
...'zero one two three four five six seven eight nine'.split(' ').map((word, i) => [word, String(i)]),
|
|
49
|
+
['underscore', '_'], ['hyphen', '-'], ['dash', '-'], ['slash', '/'], ['plus', '+'], ['equals', '='], ['dot', '.'],
|
|
50
|
+
]));
|
|
51
|
+
/** A small explicit spelling grammar, not a language model. Preserve explicit case for nested base64. */
|
|
52
|
+
export function natoCandidates(text) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let run = '', count = 0, marker = '';
|
|
55
|
+
const flush = () => { if (count >= 5)
|
|
56
|
+
out.push(run); run = ''; count = 0; marker = ''; };
|
|
57
|
+
for (const item of text.slice(0, MAX_CANDIDATE).normalize('NFKC').split(/[\s,;|]+/)) {
|
|
58
|
+
if (!item)
|
|
59
|
+
continue;
|
|
60
|
+
const unit = item.toLowerCase();
|
|
61
|
+
if (['capital', 'uppercase', 'lowercase', 'letter'].includes(unit)) {
|
|
62
|
+
if (marker)
|
|
63
|
+
flush();
|
|
64
|
+
marker = unit;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const ch = Object.hasOwn(NATO, unit) ? NATO[unit] : /^\d$/.test(unit) ? unit : undefined;
|
|
68
|
+
if (!ch || marker && !/^[a-z]$/.test(ch)) {
|
|
69
|
+
flush();
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
run += ['capital', 'uppercase'].includes(marker) ? ch.toUpperCase() : ch;
|
|
73
|
+
count++;
|
|
74
|
+
marker = '';
|
|
75
|
+
}
|
|
76
|
+
flush();
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Reversible text encodings only: bounded breadth/depth, no eval or decompression. Exhaustion is explicit. */
|
|
80
|
+
export function scanCandidates(texts) {
|
|
81
|
+
const values = [], queue = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
let left = MAX_CANDIDATE, incomplete = false;
|
|
84
|
+
const push = (text, depth) => {
|
|
85
|
+
if (!text || seen.has(text))
|
|
86
|
+
return;
|
|
87
|
+
if (depth > 4 || values.length >= 64 || text.length > left) {
|
|
88
|
+
incomplete = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
seen.add(text);
|
|
92
|
+
left -= text.length;
|
|
93
|
+
values.push(text);
|
|
94
|
+
queue.push({ text, depth });
|
|
95
|
+
};
|
|
96
|
+
for (const text of texts) {
|
|
97
|
+
if (text.length > left) {
|
|
98
|
+
incomplete = true;
|
|
99
|
+
if (left)
|
|
100
|
+
push(text.slice(0, left), 0);
|
|
101
|
+
}
|
|
102
|
+
else
|
|
103
|
+
push(text, 0);
|
|
104
|
+
}
|
|
105
|
+
const utf8 = (bytes) => {
|
|
106
|
+
try {
|
|
107
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
108
|
+
return text && !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text) ? text : undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
for (let index = 0; index < queue.length; index++) {
|
|
115
|
+
const { text, depth } = queue[index];
|
|
116
|
+
const add = (value) => { if (value && value !== text)
|
|
117
|
+
push(value, depth + 1); };
|
|
118
|
+
if (/%[0-9a-f]{2}/i.test(text)) {
|
|
119
|
+
try {
|
|
120
|
+
add(decodeURIComponent(text.replace(/\+/g, ' ')));
|
|
121
|
+
}
|
|
122
|
+
catch { /* malformed literal, never executable */ }
|
|
123
|
+
}
|
|
124
|
+
for (const value of spelledCandidates(text))
|
|
125
|
+
add(value);
|
|
126
|
+
for (const value of characterCodeCandidates(text))
|
|
127
|
+
add(value);
|
|
128
|
+
for (const value of natoCandidates(text))
|
|
129
|
+
add(value);
|
|
130
|
+
const escaped = text.replace(/\\u\{([0-9a-f]{1,6})\}|\\u([0-9a-f]{4})|\\x([0-9a-f]{2})|&#(?:x([0-9a-f]+)|([0-9]+));/gi, (whole, a, b, c, d, e) => {
|
|
131
|
+
const code = Number.parseInt(a ?? b ?? c ?? d ?? e, e === undefined ? 16 : 10);
|
|
132
|
+
return code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : whole;
|
|
133
|
+
});
|
|
134
|
+
add(escaped);
|
|
135
|
+
for (const m of text.matchAll(/[A-Za-z0-9+/_-]{8,}={0,2}/g)) {
|
|
136
|
+
const normalized = m[0].replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
|
|
137
|
+
if (normalized.length % 4 === 1)
|
|
138
|
+
continue;
|
|
139
|
+
const bytes = Buffer.from(normalized, 'base64');
|
|
140
|
+
if (bytes.toString('base64').replace(/=+$/, '') === normalized)
|
|
141
|
+
add(utf8(bytes));
|
|
142
|
+
}
|
|
143
|
+
for (const m of text.matchAll(/\b(?:[0-9a-f]{2}){3,}\b/gi))
|
|
144
|
+
add(utf8(Buffer.from(m[0], 'hex')));
|
|
145
|
+
}
|
|
146
|
+
return { values, incomplete };
|
|
147
|
+
}
|
|
148
|
+
/** Compatibility helper: enforcement uses scanCandidates() so truncation is never mistaken for a clean scan. */
|
|
149
|
+
export function candidates(texts) { return scanCandidates(texts).values; }
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// GENERATED from src/flow-guard-destinations.ts by scripts/sync-flow-guard.mjs. Do not edit; run npm run build.
|
|
2
|
+
import { globMatch, hostMatches } from './flow-guard-policy.mjs';
|
|
3
|
+
import { MAX_TEXT, hostsIn, strings } from './flow-guard-text.mjs';
|
|
4
|
+
import { normalizeText } from './flow-guard-fingerprints.mjs';
|
|
5
|
+
export const MAX_RESOLVED = 200;
|
|
6
|
+
// Who or what a call is aimed at. A destination the user named in their own words is the user's choice.
|
|
7
|
+
export const DEST_ARG_NAMES = new Set(['to', 'recipient', 'recipients', 'cc', 'bcc', 'email', 'emails', 'participants', 'user', 'users',
|
|
8
|
+
'channel', 'url', 'urls', 'iban', 'account', 'address', 'phone', 'host', 'hostname', 'domain', 'webhook', 'endpoint',
|
|
9
|
+
'attendee', 'attendees', 'invitee', 'invitees', 'assignee', 'assignees', 'reviewer', 'reviewers', 'collaborator', 'collaborators',
|
|
10
|
+
'member', 'members', 'username']);
|
|
11
|
+
// The subset that names a party without doubt. On an unlabeled MCP tool one of these makes the call outbound;
|
|
12
|
+
// "user", "channel", "account" or "host" also name what a read looks at, so they do not.
|
|
13
|
+
const PARTY_ARG_NAMES = new Set(['to', 'recipient', 'recipients', 'cc', 'bcc', 'email', 'emails', 'participants', 'iban', 'phone',
|
|
14
|
+
'webhook', 'endpoint', 'attendee', 'attendees', 'invitee', 'invitees', 'assignee', 'assignees', 'reviewer', 'reviewers',
|
|
15
|
+
'collaborator', 'collaborators', 'member', 'members', 'username']);
|
|
16
|
+
// Bounded address parts and a fixed start prevent quadratic scans of long non-address payloads.
|
|
17
|
+
const EMAIL_RE = /(?<![a-z0-9._%+-])[a-z0-9._%+-]{1,64}@[a-z0-9.-]{1,253}\.[a-z]{2,63}\b/gi;
|
|
18
|
+
// A bare domain in text ("www.example.com/x"), limited to common TLDs so "file.txt" or "v1.2" do not count.
|
|
19
|
+
const BARE_DOMAIN_RE = /\b(?:www\.)?(?:[a-z0-9-]+\.)+(?:com|net|org|io|dev|ai|co|app|info|biz|xyz|me|us|uk|de|fr|es|it|nl|ru|cn|jp|in|br|eu|ch|se|no|fi|pl|cz|at|be|ca|au|nz|online|site|top|link|click|live|shop|store|tech|cloud|pro|tk|gq|ml|ga|cf|ly|gg|to|so|sh|cc|tv|ws)\b(?:\/[^\s"'<>)]*)?/gi;
|
|
20
|
+
const IBAN_RE = /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]){10,30}\b/g;
|
|
21
|
+
export function namesParty(input, texts) {
|
|
22
|
+
const named = Object.entries(input).some(([k, v]) => PARTY_ARG_NAMES.has(k.toLowerCase()) &&
|
|
23
|
+
((typeof v === 'string' && !!v.trim()) || typeof v === 'number' || (Array.isArray(v) && v.length > 0)));
|
|
24
|
+
return named || texts.some(t => new RegExp(EMAIL_RE.source, 'i').test(t) || new RegExp(IBAN_RE.source).test(t));
|
|
25
|
+
}
|
|
26
|
+
export function destinations(toolName, input, policy, hosts) {
|
|
27
|
+
const out = new Set(hosts);
|
|
28
|
+
const configured = Object.entries(policy.destinationArgs).find(([glob]) => globMatch(glob, toolName))?.[1];
|
|
29
|
+
const names = configured ? new Set(configured.map(n => n.toLowerCase())) : DEST_ARG_NAMES;
|
|
30
|
+
const add = (v) => {
|
|
31
|
+
if (typeof v === 'string' || typeof v === 'number') {
|
|
32
|
+
const t = String(v).trim();
|
|
33
|
+
if (t)
|
|
34
|
+
out.add(t);
|
|
35
|
+
}
|
|
36
|
+
else if (Array.isArray(v))
|
|
37
|
+
v.forEach(add);
|
|
38
|
+
else if (v && typeof v === 'object')
|
|
39
|
+
Object.values(v).forEach(add);
|
|
40
|
+
};
|
|
41
|
+
for (const [k, v] of Object.entries(input))
|
|
42
|
+
if (names.has(k.toLowerCase()))
|
|
43
|
+
add(v);
|
|
44
|
+
// Anything that names a place to reach, wherever it sits (a link in a message body is where the reader goes).
|
|
45
|
+
for (const t of strings(input)) {
|
|
46
|
+
for (const m of t.matchAll(EMAIL_RE))
|
|
47
|
+
out.add(m[0]);
|
|
48
|
+
for (const m of t.matchAll(IBAN_RE))
|
|
49
|
+
out.add(m[0]);
|
|
50
|
+
// A mailbox is one destination, not a separate grant to its provider's web host.
|
|
51
|
+
for (const m of t.matchAll(BARE_DOMAIN_RE))
|
|
52
|
+
if (t[(m.index ?? 0) - 1] !== '@')
|
|
53
|
+
out.add(m[0]);
|
|
54
|
+
}
|
|
55
|
+
for (const h of hostsIn(strings(input)))
|
|
56
|
+
out.add(h);
|
|
57
|
+
return [...new Set([...out].map(d => d.toLowerCase().replace(/^[a-z]+:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '')))].filter(d => d.length >= 1);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The operator named this tool's destination arguments and the call sets none of them: it acts on an
|
|
61
|
+
* existing relationship (change the amount of a standing order, not its payee), so no new party is reached.
|
|
62
|
+
* Unconfigured tools never qualify: without the operator's word, an absent argument proves nothing.
|
|
63
|
+
*/
|
|
64
|
+
export function keepsDestination(toolName, input, policy) {
|
|
65
|
+
const configured = Object.entries(policy.destinationArgs).find(([glob]) => globMatch(glob, toolName))?.[1];
|
|
66
|
+
if (!configured?.length)
|
|
67
|
+
return false;
|
|
68
|
+
// It must act on something that already exists, named by id: creating a new thing is not "keeping" a party.
|
|
69
|
+
const existing = Object.entries(input).some(([k, v]) => /(^|_)id$/i.test(k) && v !== undefined && v !== null && v !== '' && !configured.includes(k));
|
|
70
|
+
if (!existing)
|
|
71
|
+
return false;
|
|
72
|
+
return configured.every(name => { const v = input[name]; return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0); });
|
|
73
|
+
}
|
|
74
|
+
/** Every destination appears in what the user typed this session. Hosts match on the bare name too. */
|
|
75
|
+
/** A whole-word, case-insensitive match of `token` (length >= 3) in the user's raw prompt. */
|
|
76
|
+
function typedWord(token, raw) {
|
|
77
|
+
if (token.length < 3)
|
|
78
|
+
return false;
|
|
79
|
+
return new RegExp(`(^|[^\\p{L}\\p{N}])${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\p{L}\\p{N}])`, 'iu').test(raw);
|
|
80
|
+
}
|
|
81
|
+
/** Only structured records from explicitly configured contact tools may resolve a full display name.
|
|
82
|
+
* Arbitrary prose, YAML, capitalized words and local file reads cannot create authority. */
|
|
83
|
+
export function resolvedContacts(value, depth = 0) {
|
|
84
|
+
if (depth > 8)
|
|
85
|
+
return [];
|
|
86
|
+
if (typeof value === 'string') {
|
|
87
|
+
if (value.length > MAX_TEXT)
|
|
88
|
+
return [];
|
|
89
|
+
try {
|
|
90
|
+
return resolvedContacts(JSON.parse(value), depth + 1);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(value))
|
|
97
|
+
return value.slice(0, MAX_RESOLVED).flatMap(v => resolvedContacts(v, depth + 1));
|
|
98
|
+
if (!value || typeof value !== 'object')
|
|
99
|
+
return [];
|
|
100
|
+
const record = value;
|
|
101
|
+
if (typeof record.name === 'string' && typeof record.email === 'string') {
|
|
102
|
+
const name = record.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
103
|
+
const email = record.email.trim().toLowerCase();
|
|
104
|
+
if (name.length >= 3 && name.length <= 200 && /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(email))
|
|
105
|
+
return [{ v: email, names: [name] }];
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
// Common MCP envelopes only, not arbitrary nested notes or metadata.
|
|
109
|
+
return ['content', 'structuredContent', 'contacts', 'text'].flatMap(k => resolvedContacts(record[k], depth + 1));
|
|
110
|
+
}
|
|
111
|
+
function exactNamed(value, text) {
|
|
112
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
113
|
+
// A full stop or comma that ends the sentence ends the value too ("send it to bob@corp.com."); one that goes on
|
|
114
|
+
// into more letters ("bob@corp.com.evil") does not.
|
|
115
|
+
return new RegExp(`(^|[^\\p{L}\\p{N}._%+@-])${escaped}($|[^\\p{L}\\p{N}._%+@-]|[.,](?=$|\\s))`, 'iu').test(text);
|
|
116
|
+
}
|
|
117
|
+
/** A URL mentioned in prose cannot stand in for an actual target argument. */
|
|
118
|
+
export function hasDestinationArgument(tool, input, policy) {
|
|
119
|
+
const configured = Object.entries(policy.destinationArgs).find(([glob]) => globMatch(glob, tool))?.[1];
|
|
120
|
+
const names = configured ? new Set(configured.map(n => n.toLowerCase())) : DEST_ARG_NAMES;
|
|
121
|
+
return Object.entries(input).some(([k, v]) => names.has(k.toLowerCase()) &&
|
|
122
|
+
((typeof v === 'string' && !!v.trim()) || typeof v === 'number' || (Array.isArray(v) && v.length > 0)));
|
|
123
|
+
}
|
|
124
|
+
export function allowedDestination(destination, patterns) {
|
|
125
|
+
// Exact mailbox entries are possible; a host entry never authorizes every mailbox at that host.
|
|
126
|
+
if (destination.includes('@'))
|
|
127
|
+
return patterns.some(p => p.toLowerCase() === destination);
|
|
128
|
+
try {
|
|
129
|
+
const url = new URL(`https://${destination}`);
|
|
130
|
+
return !url.username && !url.password && hostMatches(url.hostname, patterns);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export function namedByUser(dests, intent, intentRaw, resolved) {
|
|
137
|
+
if (!dests.length || !intent?.length)
|
|
138
|
+
return false;
|
|
139
|
+
const raw = (intentRaw ?? []).join('\n');
|
|
140
|
+
// Contact authority applies to the exact mailbox, never to its provider's web host.
|
|
141
|
+
const viaContacts = dests.filter(d => d.includes('@') && (exactNamed(d, raw) || !!resolved?.some(r => r.v === d && r.names.some(n => typedWord(n, raw) && new Set(resolved.filter(other => other.names.includes(n)).map(other => other.v)).size === 1))));
|
|
142
|
+
return dests.every(d => {
|
|
143
|
+
if (viaContacts.includes(d))
|
|
144
|
+
return true;
|
|
145
|
+
// A bare number names an id only next to a word that says so ("file 13", "ID '13'", "#13"): in
|
|
146
|
+
// "June 13" it is a date. Short names ("Bob") must match a whole word.
|
|
147
|
+
if (/^\d+$/.test(d)) {
|
|
148
|
+
const re = new RegExp(`(\\bid\\b|\\bids\\b|\\bfile\\b|\\bnumber\\b|\\bno\\.|#|№)\\s*[:'"\\s]*${d}(?!\\d)`, 'iu');
|
|
149
|
+
return re.test(raw);
|
|
150
|
+
}
|
|
151
|
+
if (d.length < 6) {
|
|
152
|
+
const re = new RegExp(`(^|[^\\p{L}\\p{N}])${d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\p{L}\\p{N}])`, 'iu');
|
|
153
|
+
return re.test(raw);
|
|
154
|
+
}
|
|
155
|
+
return exactNamed(d, raw) || (/^[a-z]{2}\d{2}[a-z0-9]{10,30}$/i.test(d) &&
|
|
156
|
+
[...raw.toUpperCase().matchAll(IBAN_RE)].some(m => normalizeText(m[0]) === d));
|
|
157
|
+
});
|
|
158
|
+
}
|