@aimarket/warden 0.20.0 → 0.21.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 +91 -0
- package/README-es.md +26 -11
- package/README-fr.md +26 -11
- package/README-ru.md +26 -11
- package/README-zh.md +26 -11
- package/README.md +26 -11
- package/dist/atomic-screen.d.ts +61 -0
- package/dist/atomic-screen.d.ts.map +1 -0
- package/dist/atomic-screen.js +165 -0
- package/dist/atomic-screen.js.map +1 -0
- package/dist/flow-guard.d.ts +181 -2
- package/dist/flow-guard.d.ts.map +1 -1
- package/dist/flow-guard.js +1194 -72
- package/dist/flow-guard.js.map +1 -1
- package/dist/mcp-server.js +5 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools.d.ts.map +1 -1
- package/dist/mcp-tools.js +46 -9
- package/dist/mcp-tools.js.map +1 -1
- package/dist/review-diff.d.ts +36 -0
- package/dist/review-diff.d.ts.map +1 -1
- package/dist/review-diff.js +179 -7
- package/dist/review-diff.js.map +1 -1
- package/dist/task-adapter.d.ts +73 -0
- package/dist/task-adapter.d.ts.map +1 -0
- package/dist/task-adapter.js +379 -0
- package/dist/task-adapter.js.map +1 -0
- package/dist/task-cli.d.ts +3 -0
- package/dist/task-cli.d.ts.map +1 -0
- package/dist/task-cli.js +162 -0
- package/dist/task-cli.js.map +1 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/wrap-cli.d.ts +5 -0
- package/dist/wrap-cli.d.ts.map +1 -1
- package/dist/wrap-cli.js +27 -1
- package/dist/wrap-cli.js.map +1 -1
- package/dist/wrap-screen.d.ts +3 -0
- package/dist/wrap-screen.d.ts.map +1 -0
- package/dist/wrap-screen.js +20 -0
- package/dist/wrap-screen.js.map +1 -0
- package/dist/wrap-state.d.ts +5 -0
- package/dist/wrap-state.d.ts.map +1 -1
- package/dist/wrap-state.js +3 -1
- package/dist/wrap-state.js.map +1 -1
- package/dist/wrap.d.ts.map +1 -1
- package/dist/wrap.js +161 -5
- package/dist/wrap.js.map +1 -1
- package/docs/flow-guard.es.md +274 -9
- package/docs/flow-guard.fr.md +274 -9
- package/docs/flow-guard.md +275 -10
- package/docs/flow-guard.ru.md +275 -10
- package/docs/flow-guard.zh.md +273 -5
- package/docs/security-hardening.es.md +1 -1
- package/docs/security-hardening.fr.md +1 -1
- package/docs/security-hardening.md +1 -1
- package/docs/security-hardening.ru.md +1 -1
- package/docs/security-hardening.zh.md +1 -1
- package/examples/atomic-screen-server.mjs +42 -0
- package/native/macos/Package.swift +9 -0
- package/native/macos/Sources/WardenDesktop/NativeSurface.swift +246 -0
- package/native/macos/Sources/WardenDesktop/StandardIO.swift +139 -0
- package/native/macos/Sources/WardenDesktopHost/Main.swift +211 -0
- package/package.json +17 -5
- package/scripts/build-macos.mjs +22 -0
package/dist/flow-guard.js
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
* the compiled file on every tool call (claude-plugin/scripts/flow-guard.mjs; a test keeps
|
|
24
24
|
* the copy identical to dist/flow-guard.js).
|
|
25
25
|
*/
|
|
26
|
-
import { createHash } from 'node:crypto';
|
|
27
|
-
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
27
|
+
import { appendFileSync, closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from 'node:path';
|
|
30
30
|
const MAX_SOURCES = 20;
|
|
@@ -37,6 +37,9 @@ const STRIDE = 4;
|
|
|
37
37
|
// made-up keys in eight formats matched anywhere in 100 million letters and digits of ordinary text
|
|
38
38
|
// (scripts/redteam/window-collisions.mjs): 8 for keys of letters and digits, 10 for hex keys, whose characters carry
|
|
39
39
|
// half the information and which share their alphabet with every hash and address in the text around them.
|
|
40
|
+
const FRAGMENT_SIZE = 6;
|
|
41
|
+
const MAX_FRAGMENT_INDEX = 16_384;
|
|
42
|
+
const MAX_FRAGMENT_SEEN = 256;
|
|
40
43
|
const TOKEN_WINDOW = 8;
|
|
41
44
|
const HEX_TOKEN_WINDOW = 10;
|
|
42
45
|
const TOKEN_WINDOWS = [TOKEN_WINDOW, HEX_TOKEN_WINDOW];
|
|
@@ -160,6 +163,12 @@ export function loadPolicy(env = process.env) {
|
|
|
160
163
|
policy.taintLocalFiles = r.taintLocalFiles;
|
|
161
164
|
if (typeof r.answerNote === 'string' && ANSWER_NOTES.includes(r.answerNote))
|
|
162
165
|
policy.answerNote = r.answerNote;
|
|
166
|
+
if (r.gui !== undefined)
|
|
167
|
+
policy.gui = parseGuiPolicy(r.gui);
|
|
168
|
+
if (r.credentialEgress !== undefined)
|
|
169
|
+
policy.credentialEgress = parseCredentialEgress(r.credentialEgress);
|
|
170
|
+
if (r.taskAdapter !== undefined)
|
|
171
|
+
policy.taskAdapter = parseTaskAdapter(r.taskAdapter);
|
|
163
172
|
if (r.destinationArgs && typeof r.destinationArgs === 'object' && !Array.isArray(r.destinationArgs)) {
|
|
164
173
|
for (const [k, v] of Object.entries(r.destinationArgs))
|
|
165
174
|
if (Array.isArray(v))
|
|
@@ -171,6 +180,113 @@ export function loadPolicy(env = process.env) {
|
|
|
171
180
|
policy.mode = mode;
|
|
172
181
|
return policy;
|
|
173
182
|
}
|
|
183
|
+
function parseTaskAdapter(value) {
|
|
184
|
+
const invalid = { mode: 'strict', server: '', manifestSha256: '', invalid: true };
|
|
185
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
186
|
+
return invalid;
|
|
187
|
+
const v = value;
|
|
188
|
+
if (Object.keys(v).some(k => !['mode', 'server', 'manifestSha256'].includes(k)) ||
|
|
189
|
+
v.mode !== undefined && !['strict', 'capabilities', 'review'].includes(v.mode) ||
|
|
190
|
+
typeof v.server !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(v.server) || v.server.includes('__') ||
|
|
191
|
+
typeof v.manifestSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(v.manifestSha256))
|
|
192
|
+
return invalid;
|
|
193
|
+
return { mode: v.mode === 'capabilities' || v.mode === 'review' ? v.mode : 'strict', server: v.server, manifestSha256: v.manifestSha256 };
|
|
194
|
+
}
|
|
195
|
+
/** Read bounded, pinned operator configuration. The task runtime additionally validates the complete schema. */
|
|
196
|
+
export function readPinnedTaskManifest(policy, env = process.env) {
|
|
197
|
+
if (!policy.taskAdapter || policy.taskAdapter.invalid)
|
|
198
|
+
throw new Error('task adapter is not configured');
|
|
199
|
+
const path = join(stateDir(env), 'task-manifest.json');
|
|
200
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
201
|
+
try {
|
|
202
|
+
if (!fstatSync(fd).isFile())
|
|
203
|
+
throw new Error('invalid task manifest file');
|
|
204
|
+
const bytes = Buffer.alloc(262_145);
|
|
205
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
206
|
+
if (count > 262_144)
|
|
207
|
+
throw new Error('task manifest is too large');
|
|
208
|
+
const data = bytes.subarray(0, count);
|
|
209
|
+
if (createHash('sha256').update(data).digest('hex') !== policy.taskAdapter.manifestSha256)
|
|
210
|
+
throw new Error('task manifest pin mismatch');
|
|
211
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(data);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
closeSync(fd);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** This delegates only fixed control IDs to our own enforcing adapter, never clicks or arbitrary MCP servers.
|
|
218
|
+
* The operator must bind this exact MCP server name to `warden-mcp task`; tool output cannot establish trust. */
|
|
219
|
+
function taskDelegation(tool, args, policy, env) {
|
|
220
|
+
const task = policy.taskAdapter;
|
|
221
|
+
if (policy.mode !== 'ask' || !task || !['capabilities', 'review'].includes(task.mode) || task.invalid)
|
|
222
|
+
return false;
|
|
223
|
+
const prefix = `mcp__${task.server}__`;
|
|
224
|
+
if (!tool.startsWith(prefix))
|
|
225
|
+
return false;
|
|
226
|
+
const action = tool.slice(prefix.length);
|
|
227
|
+
if (action === 'task_list')
|
|
228
|
+
return Object.keys(args).length === 0;
|
|
229
|
+
if (!['task_read', 'task_execute', 'task_run'].includes(action) || task.mode === 'review' && action === 'task_execute' || Object.keys(args).length !== 1 || typeof args.id !== 'string')
|
|
230
|
+
return false;
|
|
231
|
+
try {
|
|
232
|
+
const manifest = JSON.parse(readPinnedTaskManifest(policy, env));
|
|
233
|
+
if (!manifest || manifest.version !== 1 || !Number.isFinite(Date.parse(manifest.expiresAt)) || Date.parse(manifest.expiresAt) <= Date.now())
|
|
234
|
+
return false;
|
|
235
|
+
const entries = action === 'task_read' ? manifest.resources : action === 'task_run' ? manifest.runs : manifest.operations;
|
|
236
|
+
return Array.isArray(entries) && entries.some((entry) => !!entry && typeof entry === 'object' && entry.id === args.id);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function parseCredentialEgress(value) {
|
|
243
|
+
const deny = () => ({ allow: [], invalid: true });
|
|
244
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
245
|
+
return deny();
|
|
246
|
+
const v = value;
|
|
247
|
+
if (Object.keys(v).some(k => k !== 'allow') || !Array.isArray(v.allow) || v.allow.length > 32)
|
|
248
|
+
return deny();
|
|
249
|
+
const allow = [];
|
|
250
|
+
for (const raw of v.allow) {
|
|
251
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
252
|
+
return deny();
|
|
253
|
+
const r = raw;
|
|
254
|
+
if (Object.keys(r).some(k => !['tool', 'requestSha256'].includes(k)) || typeof r.tool !== 'string' ||
|
|
255
|
+
!/^(?:mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_.-]+|WebFetch|WebSearch|Write|Edit|MultiEdit|NotebookEdit)$/.test(r.tool) || r.tool.length > 200 ||
|
|
256
|
+
typeof r.requestSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(r.requestSha256))
|
|
257
|
+
return deny();
|
|
258
|
+
allow.push({ tool: r.tool, requestSha256: r.requestSha256 });
|
|
259
|
+
}
|
|
260
|
+
return { allow };
|
|
261
|
+
}
|
|
262
|
+
/** Sorted JSON keys; array order and every argument/destination are significant. Never hashes a truncated input. */
|
|
263
|
+
export function publicRequestDigest(tool, input) {
|
|
264
|
+
let nodes = 0, chars = 0;
|
|
265
|
+
const encode = (v, depth = 0) => {
|
|
266
|
+
if (++nodes > 1024 || depth > 32)
|
|
267
|
+
throw new Error('request exceeds digest limits');
|
|
268
|
+
let result;
|
|
269
|
+
if (v === null || typeof v === 'boolean' || typeof v === 'string' || typeof v === 'number' && Number.isFinite(v))
|
|
270
|
+
result = JSON.stringify(v);
|
|
271
|
+
else if (Array.isArray(v))
|
|
272
|
+
result = '[' + v.map(x => encode(x, depth + 1)).join(',') + ']';
|
|
273
|
+
else if (v && typeof v === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(v))) {
|
|
274
|
+
result = '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + encode(v[k], depth + 1)).join(',') + '}';
|
|
275
|
+
}
|
|
276
|
+
else
|
|
277
|
+
throw new Error('request is not JSON');
|
|
278
|
+
chars += result.length;
|
|
279
|
+
if (chars > 65_536 || result.length > 16_384)
|
|
280
|
+
throw new Error('request exceeds digest limits');
|
|
281
|
+
return result;
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
return createHash('sha256').update(encode([tool, input])).digest('hex');
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
174
290
|
function globMatch(glob, name) {
|
|
175
291
|
const re = new RegExp('^' + glob.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$', 'i');
|
|
176
292
|
return re.test(name);
|
|
@@ -190,14 +306,30 @@ function strings(value, out = [], budget = { left: MAX_TEXT }) {
|
|
|
190
306
|
out.push(value.slice(0, budget.left));
|
|
191
307
|
budget.left -= value.length;
|
|
192
308
|
}
|
|
193
|
-
else if (Array.isArray(value))
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
309
|
+
else if (Array.isArray(value)) {
|
|
310
|
+
// Explicit numeric arrays are another spelling of character codes. Never stringify arbitrary objects.
|
|
311
|
+
if (value.length >= 5 && value.length <= 4096 && value.every(v => typeof v === 'number' && Number.isSafeInteger(v)))
|
|
312
|
+
strings(value.join(' '), out, budget);
|
|
313
|
+
else
|
|
314
|
+
for (const v of value)
|
|
315
|
+
strings(v, out, budget);
|
|
316
|
+
}
|
|
317
|
+
else if (value && typeof value === 'object') {
|
|
318
|
+
const o = value;
|
|
319
|
+
for (const [key, v] of Object.entries(o)) {
|
|
320
|
+
if (budget.omitBinary && key === 'data' && (o.type === 'image' || o.type === 'audio'))
|
|
321
|
+
continue;
|
|
198
322
|
strings(v, out, budget);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
199
325
|
return out;
|
|
200
326
|
}
|
|
327
|
+
// MCP binary blocks are not text. Their base64 must not exhaust the text budget before a screen's labels,
|
|
328
|
+
// titles or JSON text blocks are read. Arguments still use strings() without this exemption: an outgoing
|
|
329
|
+
// credential disguised as an image's data is still a credential leaving the session.
|
|
330
|
+
function responseStrings(value) {
|
|
331
|
+
return strings(value, [], { left: MAX_TEXT, omitBinary: true });
|
|
332
|
+
}
|
|
201
333
|
const URL_RE = /\b(?:https?|ftp|wss?):\/\/[^\s<>"']+/gi;
|
|
202
334
|
export function hostsIn(texts) {
|
|
203
335
|
const hosts = new Set();
|
|
@@ -311,7 +443,8 @@ function trustedShellExecutables(command, env) {
|
|
|
311
443
|
});
|
|
312
444
|
}
|
|
313
445
|
// Built-in tool words for MCP tools without operator labels (hints that only ADD caution).
|
|
314
|
-
const SEND_WORDS = /(^|_|-)(send|post|publish|upload|share|tweet|email|mail|notify|message|comment|reply|create_?issue|create_?pr|pull_?request|webhook|http|request|fetch|browse|navigate|goto|open_?url|visit|submit|forward|transfer|pay|invoke)(_|-|$)/i;
|
|
446
|
+
const SEND_WORDS = /(^|_|-)(send|post|publish|push|upload|share|tweet|email|mail|notify|message|comment|reply|create_?issue|create_?pr|pull_?request|webhook|http|request|fetch|browse|navigate|goto|open_?url|visit|submit|forward|transfer|pay|invoke)(_|-|$)/i;
|
|
447
|
+
const COMMIT_ACTION = /^(?:(?:git|github|repo)_)?commit(?:_|$)|^create_commit(?:_|$)/i;
|
|
315
448
|
const INBOUND_WORDS = /(^|_|-)(fetch|browse|navigate|scrape|crawl|search|web|url|read_?(email|mail|issue|issues|message|messages|comments?|page)|get_?(issue|issues|email|emails|messages?|comments?|page|url)|list_?(issues|emails|messages|comments)|inbox|feed|rss|snapshot|screenshot)(_|-|$)/i;
|
|
316
449
|
const DESTROY_WORDS = /(^|_|-)(delete|remove|drop|destroy|truncate|purge|wipe|reset|kill|terminate|force_?push|overwrite)(_|-|$)/i;
|
|
317
450
|
export function classifyCall(toolName, input, cwd, policy, env = process.env) {
|
|
@@ -377,12 +510,12 @@ export function classifyCall(toolName, input, cwd, policy, env = process.env) {
|
|
|
377
510
|
}
|
|
378
511
|
default:
|
|
379
512
|
if (toolName.startsWith('mcp__')) {
|
|
380
|
-
const tool = toolName.split('__').slice(2).join('__') || toolName;
|
|
513
|
+
const tool = screenOperation(toolName.split('__').slice(2).join('__') || toolName);
|
|
381
514
|
result.hosts = hostsIn(texts);
|
|
382
515
|
result.guessed = true;
|
|
383
516
|
// A tool whose name says nothing can still reach a person: it names a recipient, attendee or account,
|
|
384
517
|
// or carries an e-mail address or IBAN. Reaching a party is what outbound means here.
|
|
385
|
-
if (SEND_WORDS.test(tool) || result.hosts.length || namesParty(input, texts))
|
|
518
|
+
if (SEND_WORDS.test(tool) || COMMIT_ACTION.test(tool) || result.hosts.length || namesParty(input, texts))
|
|
386
519
|
result.labels.add('outbound');
|
|
387
520
|
if (INBOUND_WORDS.test(tool) || result.hosts.length)
|
|
388
521
|
result.labels.add('untrusted');
|
|
@@ -395,6 +528,558 @@ export function classifyCall(toolName, input, cwd, policy, env = process.env) {
|
|
|
395
528
|
}
|
|
396
529
|
return result;
|
|
397
530
|
}
|
|
531
|
+
const SCREEN_WORDS = /(^|_|-)(screenshot|snapshot|screen|observe|accessibility|a11y|read_?page|page_?content|get_?(page|elements|ui|tree|screen|state)|elements|ui_?tree|dom|inspect)(_|-|$)/i;
|
|
532
|
+
const POINTER_WORDS = /(^|_|-)(click|tap|double_?click|right_?click|left_?click|middle_?click|triple_?click|press_?(button|element)|mouse|pointer|touch|drag|drop|swipe|long_?press)(_|-|$)/i;
|
|
533
|
+
// `type` in known control names, not arbitrary nouns such as `get_cuisine_type`.
|
|
534
|
+
const TYPE_WORDS = /(^|_|-)(type_?text|input_?text|insert_?text|enter_?text|fill)(_|-|$)|^type(_|-|$)|^(browser|screen|computer|desktop|keyboard)[_-]type$/i;
|
|
535
|
+
const KEY_WORDS = /(^|_|-)(key|keys|keypress|key_?press|hotkey|shortcut|keyboard|paste)(_|-|$)/i;
|
|
536
|
+
const keyControl = (name) => KEY_WORDS.test(name) || TYPE_WORDS.test(name);
|
|
537
|
+
const GENERIC_CONTROL = /^(computer|screen_control|desktop_control|browser_control)$/i;
|
|
538
|
+
// Exempt only complete observation names, never a substring such as `screenshot_then_submit`.
|
|
539
|
+
const READ_CONTROL_ACTIONS = new Set(['screenshot', 'snapshot', 'observe', 'accessibility', 'a11y', 'read_page',
|
|
540
|
+
'page_content', 'get_page', 'get_elements', 'get_ui', 'get_tree', 'get_screen', 'get_state', 'elements', 'ui_tree', 'dom', 'inspect']);
|
|
541
|
+
const PASSIVE_KEY_ACTIONS = new Set(['key', 'keys', 'keypress', 'key_press', 'press_key', 'press_keys', 'hotkey', 'shortcut', 'keyboard']);
|
|
542
|
+
const screenOperation = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[.-]/g, '_').toLowerCase();
|
|
543
|
+
const SEND_TARGET = /\b(send|post|publish|submit|share|reply|comment|tweet|forward|upload|invite|pay|payment|buy|purchase|check\s?out|place\s+order|order\s+now|transfer|donate|sign\s?in|log\s?in)\b/i;
|
|
544
|
+
const DESTROY_TARGET = /\b(delete|remove|discard|erase|wipe|empty\s+trash|uninstall|revoke)\b/i;
|
|
545
|
+
const PASTE_KEYS = /\b(cmd|command|meta|super|win|ctrl|control)\s*[+-]\s*v\b|\bshift\s*[+-]\s*insert\b/i;
|
|
546
|
+
const REF_ARGS = new Set(['ref', 'element_ref', 'elementref', 'index', 'idx', 'element_index', 'elementindex', 'element_id', 'elementid', 'id', 'node_id', 'nodeid', 'mark']);
|
|
547
|
+
const TARGET_TEXT_ARGS = new Set(['element', 'label', 'name', 'text', 'title', 'target', 'description', 'selector', 'button', 'aria_label', 'arialabel']);
|
|
548
|
+
const KEY_TEXT_ARGS = new Set(['key', 'keys', 'text', 'combo', 'combination', 'shortcut', 'sequence', 'value', 'chars', 'input']);
|
|
549
|
+
const ID_KEYS = ['index', 'idx', 'id', 'ref', 'element_id', 'elementId', 'node_id', 'nodeId', 'mark'];
|
|
550
|
+
const LABEL_KEYS = ['name', 'label', 'text', 'title', 'aria_label', 'ariaLabel', 'description', 'value', 'innerText'];
|
|
551
|
+
const TITLE_KEYS = ['windowTitle', 'window_title', 'pageTitle', 'page_title', 'title'];
|
|
552
|
+
const ACCESSIBILITY_ID_KEYS = ['accessibilityIdentifier', 'accessibility_id', 'accessibilityId', 'automationId', 'automation_id', 'AXIdentifier', 'identifier'];
|
|
553
|
+
const TEXT_INPUT_ROLES = new Set(['textbox', 'input', 'textfield', 'text_field', 'entry', 'searchbox', 'textarea', 'combobox']);
|
|
554
|
+
const GUI_NAV_KEYS = new Set(['tab', 'shift+tab', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'home', 'end', 'pageup', 'pagedown', 'escape']);
|
|
555
|
+
// Best-effort explanation vocabulary, never an allow-list. Unknown words still require approval.
|
|
556
|
+
const SCREEN_ACTION_ALIASES = Object.fromEntries([
|
|
557
|
+
['send', 'senden envoyer enviar wyślij отправить 发送 送信 إرسال'],
|
|
558
|
+
['submit', 'absenden soumettre zatwierdź отправка 提交'],
|
|
559
|
+
['pay', 'bezahlen payer pagar zapłać оплатить 支付'],
|
|
560
|
+
['share', 'teilen partager compartir udostępnij поделиться 分享'],
|
|
561
|
+
['delete', 'löschen supprimer eliminar usuń удалить 删除'],
|
|
562
|
+
['discard', 'verwerfen abandonner descartar odrzuć отменить 丢弃'],
|
|
563
|
+
].flatMap(([verb, words]) => words.split(' ').map(word => [word, verb])));
|
|
564
|
+
// Playwright's accessibility snapshot (`- button "Send" [ref=e14]`), the `[ref_14]` form, numbered element lists
|
|
565
|
+
// (`14: button "Send"`, `1 'Issues' tab`) and `Page Title:` / `window:` lines.
|
|
566
|
+
const REF_LINE = /(?:^|\n)[ \t]*-?[ \t]*([a-z][\w-]{0,30})[ \t]+"([^"\n]{1,160})"[^\n]*?\[ref=([\w-]{1,24})\]/gi;
|
|
567
|
+
const REF_TOKEN_LINE = /(?:^|\n)[ \t]*-?[ \t]*([a-z][\w-]{0,30})[ \t]+"([^"\n]{1,160})"[^\n]*?\[(ref_\d{1,6})\]/gi;
|
|
568
|
+
const INDEX_LINE = /(?:^|[\n,;])[ \t]*(?:elements?:)?[ \t]*\[?(\d{1,4})\]?[ \t]*[:.)\]=-]?[ \t]*(?:([a-z][\w-]{0,30})[ \t]+)?["'“‘]([^"'”’\n]{1,160})["'”’](?:[ \t]+([a-z][\w-]{0,30}))?/gi;
|
|
569
|
+
const TITLE_LINE = /(?:^|\n)[ \t]*-?[ \t]*(?:page[ \t]+title|window[ \t]+title|active[ \t]+window|window|title)[ \t]*[:=][ \t]*["']?([^\n"']{1,160})/gi;
|
|
570
|
+
const MAX_SCREENS = 8;
|
|
571
|
+
const MAX_REFS = 500;
|
|
572
|
+
const MAX_TITLES = 6;
|
|
573
|
+
function screenServer(toolName) {
|
|
574
|
+
return (toolName.split('__')[1] || toolName).slice(0, 100);
|
|
575
|
+
}
|
|
576
|
+
function refKey(v) {
|
|
577
|
+
return String(v).trim().toLowerCase().slice(0, 40);
|
|
578
|
+
}
|
|
579
|
+
const ROLE = (v) => (typeof v === 'string' && /^[a-z][\w-]{0,30}$/i.test(v) ? v.toLowerCase() : '');
|
|
580
|
+
const screenObject = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
581
|
+
const screenNumber = (v) => typeof v === 'number' && Number.isFinite(v) && Math.abs(v) <= 10_000_000;
|
|
582
|
+
const screenHash = (v) => createHash('sha256').update(v).digest('hex');
|
|
583
|
+
const guiIdentity = (v) => typeof v === 'string' && v.length > 0 && v.length <= 200 && !/[\x00-\x1f\x7f]/.test(v);
|
|
584
|
+
const guiFields = (o, fields) => Object.keys(o).every(k => fields.includes(k));
|
|
585
|
+
function guiOrigin(v, policy = false) {
|
|
586
|
+
if (typeof v !== 'string' || v.length > 2048)
|
|
587
|
+
return undefined;
|
|
588
|
+
try {
|
|
589
|
+
const u = new URL(v);
|
|
590
|
+
if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password)
|
|
591
|
+
return undefined;
|
|
592
|
+
if (policy && (u.pathname !== '/' || u.search || u.hash))
|
|
593
|
+
return undefined;
|
|
594
|
+
return u.origin;
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function guiKey(v) {
|
|
601
|
+
if (typeof v !== 'string' && !(Array.isArray(v) && v.length > 0 && v.every(k => typeof k === 'string')))
|
|
602
|
+
return undefined;
|
|
603
|
+
const raw = Array.isArray(v) ? v.join('+') : v;
|
|
604
|
+
if (raw.length > 40 || /[^a-z+ ]/i.test(raw))
|
|
605
|
+
return undefined;
|
|
606
|
+
const key = raw.trim().toLowerCase().replace(/ *\+ */g, '+');
|
|
607
|
+
return GUI_NAV_KEYS.has(key) ? key : undefined;
|
|
608
|
+
}
|
|
609
|
+
/** Invalid GUI policy is strict as a whole: a typo must never create a broader permission. */
|
|
610
|
+
function parseGuiPolicy(value) {
|
|
611
|
+
const strict = { mode: 'strict', maxSnapshotAgeMs: 5000, allow: [] };
|
|
612
|
+
const invalid = { ...strict, invalid: true };
|
|
613
|
+
const o = screenObject(value);
|
|
614
|
+
if (o?.mode === 'strict' && guiFields(o, ['mode', 'maxSnapshotAgeMs', 'allow']))
|
|
615
|
+
return strict;
|
|
616
|
+
if (!o || !guiFields(o, ['mode', 'maxSnapshotAgeMs', 'allow']) || o.mode !== 'policy')
|
|
617
|
+
return invalid;
|
|
618
|
+
const age = o.maxSnapshotAgeMs ?? 5000;
|
|
619
|
+
if (!Number.isInteger(age) || age < 100 || age > 30_000 || !Array.isArray(o.allow) || o.allow.length > 64)
|
|
620
|
+
return invalid;
|
|
621
|
+
const allow = [];
|
|
622
|
+
for (const raw of o.allow) {
|
|
623
|
+
const r = screenObject(raw), target = screenObject(r?.target), context = screenObject(r?.context);
|
|
624
|
+
if (!r || !target || !context || !guiFields(r, ['tool', 'action', 'target', 'context', 'keys', 'maxTextLength'])
|
|
625
|
+
|| !guiFields(target, ['role', 'accessibilityId']) || !guiFields(context, ['origin', 'application'])
|
|
626
|
+
|| typeof r.tool !== 'string' || !/^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_.-]+$/.test(r.tool)
|
|
627
|
+
|| typeof r.action !== 'string' || !['click', 'type', 'key'].includes(r.action) || !guiIdentity(target.accessibilityId)
|
|
628
|
+
|| typeof target.role !== 'string' || !/^[a-z][\w-]{0,30}$/.test(target.role))
|
|
629
|
+
return invalid;
|
|
630
|
+
const origin = guiOrigin(context.origin, true), application = context.application;
|
|
631
|
+
if ((context.origin !== undefined && !origin) || (application !== undefined && (typeof application !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(application)))
|
|
632
|
+
|| (!origin && application === undefined))
|
|
633
|
+
return invalid;
|
|
634
|
+
const keys = guiKey(r.keys);
|
|
635
|
+
if (r.action === 'key' ? typeof r.keys !== 'string' || !keys || r.maxTextLength !== undefined : r.keys !== undefined)
|
|
636
|
+
return invalid;
|
|
637
|
+
if (r.action === 'type' ? !Number.isInteger(r.maxTextLength) || r.maxTextLength < 1 || r.maxTextLength > 4096 : r.maxTextLength !== undefined)
|
|
638
|
+
return invalid;
|
|
639
|
+
allow.push({ tool: r.tool, action: r.action, target: { role: target.role, accessibilityId: target.accessibilityId },
|
|
640
|
+
context: { ...(origin ? { origin } : {}), ...(typeof application === 'string' ? { application } : {}) },
|
|
641
|
+
...(keys ? { keys } : {}), ...(r.action === 'type' ? { maxTextLength: r.maxTextLength } : {}) });
|
|
642
|
+
}
|
|
643
|
+
return { mode: 'policy', maxSnapshotAgeMs: age, allow };
|
|
644
|
+
}
|
|
645
|
+
function screenPoint(v) {
|
|
646
|
+
const o = screenObject(v);
|
|
647
|
+
if (o && screenNumber(o.x) && screenNumber(o.y))
|
|
648
|
+
return { x: o.x, y: o.y };
|
|
649
|
+
if (Array.isArray(v) && v.length === 2 && screenNumber(v[0]) && screenNumber(v[1]))
|
|
650
|
+
return { x: v[0], y: v[1] };
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
function screenBox(v) {
|
|
654
|
+
const o = screenObject(v);
|
|
655
|
+
if (!o)
|
|
656
|
+
return undefined; // Bare arrays have incompatible xywh/xyxy conventions; do not guess.
|
|
657
|
+
const x = o.x ?? o.left, y = o.y ?? o.top;
|
|
658
|
+
if (!screenNumber(x) || !screenNumber(y))
|
|
659
|
+
return undefined;
|
|
660
|
+
if ((o.left !== undefined && o.left !== x) || (o.top !== undefined && o.top !== y))
|
|
661
|
+
return undefined;
|
|
662
|
+
const width = o.width ?? (screenNumber(o.right) ? o.right - x : undefined);
|
|
663
|
+
const height = o.height ?? (screenNumber(o.bottom) ? o.bottom - y : undefined);
|
|
664
|
+
if (!screenNumber(width) || !screenNumber(height) || width <= 0 || height <= 0 || !screenNumber(x + width) || !screenNumber(y + height))
|
|
665
|
+
return undefined;
|
|
666
|
+
if ((o.right !== undefined && o.right !== x + width) || (o.bottom !== undefined && o.bottom !== y + height))
|
|
667
|
+
return undefined;
|
|
668
|
+
return { x, y, width, height };
|
|
669
|
+
}
|
|
670
|
+
function screenAction(text) {
|
|
671
|
+
const words = screenOperation(text.normalize('NFKC')).split(/[^\p{L}\p{N}]+/u);
|
|
672
|
+
const verb = words.join(' ').match(SEND_TARGET)?.[1]?.toLowerCase().replace(/\s+/g, ' ');
|
|
673
|
+
const destroy = words.join(' ').match(DESTROY_TARGET)?.[1]?.toLowerCase().replace(/\s+/g, ' ');
|
|
674
|
+
const aliases = words
|
|
675
|
+
.map(word => Object.hasOwn(SCREEN_ACTION_ALIASES, word) ? SCREEN_ACTION_ALIASES[word] : undefined);
|
|
676
|
+
return { verb: verb ?? aliases.find(v => v && v !== 'delete' && v !== 'discard'), destroy: destroy ?? aliases.find(v => v === 'delete' || v === 'discard') };
|
|
677
|
+
}
|
|
678
|
+
function screenElement(o, role, form) {
|
|
679
|
+
const boxes = ['bounds', 'boundingBox', 'bounding_box', 'rect'].filter(k => o[k] !== undefined).map(k => screenBox(o[k]));
|
|
680
|
+
const bounds = boxes.length && boxes.every(b => b && JSON.stringify(b) === JSON.stringify(boxes[0])) ? boxes[0] : undefined;
|
|
681
|
+
const center = boxes.length ? undefined : screenPoint(o.center);
|
|
682
|
+
const identifier = ACCESSIBILITY_ID_KEYS.map(k => typeof o[k] === 'string' ? o[k].slice(0, 200) : '').join(' ');
|
|
683
|
+
const action = screenAction(screenOperation(identifier));
|
|
684
|
+
const identities = [...new Set(ACCESSIBILITY_ID_KEYS.filter(k => o[k] !== undefined).map(k => guiIdentity(o[k]) ? o[k] : ''))];
|
|
685
|
+
const identityHash = identities.length === 1 && identities[0] ? screenHash(identities[0]) : undefined;
|
|
686
|
+
const idKey = ID_KEYS.find(k => typeof o[k] === 'string' || typeof o[k] === 'number');
|
|
687
|
+
const reference = idKey ? String(o[idKey]) : '';
|
|
688
|
+
const referenceHash = reference.length > 0 && reference.length <= 40 ? screenHash(reference) : undefined;
|
|
689
|
+
const labelHash = screenHash(JSON.stringify(LABEL_KEYS.map(k => typeof o[k] === 'string' ? o[k] : null)));
|
|
690
|
+
const formRef = typeof o.formId === 'string' || typeof o.formId === 'number' ? refKey(o.formId) : typeof o.form === 'string' || typeof o.form === 'number' ? refKey(o.form) : form;
|
|
691
|
+
return { ...(role ? { role } : {}), ...(bounds ? { bounds } : {}), ...(center ? { center } : {}),
|
|
692
|
+
...(o.focused === true ? { focused: true } : {}), ...(typeof o.multiline === 'boolean' ? { multiline: o.multiline } : {}),
|
|
693
|
+
...(formRef ? { form: formRef } : {}), ...(action.verb ? { identifierVerb: action.verb } : {}),
|
|
694
|
+
...(action.destroy ? { identifierDestroy: action.destroy } : {}),
|
|
695
|
+
...(o.type === 'submit' && ['button', 'input', 'submit'].includes(role) ? { submit: true } : {}),
|
|
696
|
+
...(identityHash ? { identityHash } : {}), ...(referenceHash ? { referenceHash } : {}), labelHash };
|
|
697
|
+
}
|
|
698
|
+
function coordinateRef(input, view) {
|
|
699
|
+
const points = ['coordinate', 'coordinates', 'position'].filter(k => input[k] !== undefined).map(k => screenPoint(input[k]));
|
|
700
|
+
if (input.x !== undefined || input.y !== undefined)
|
|
701
|
+
points.push(screenPoint(input));
|
|
702
|
+
if (!points.length || !points.every(p => p && p.x === points[0]?.x && p.y === points[0]?.y))
|
|
703
|
+
return undefined;
|
|
704
|
+
const p = points[0];
|
|
705
|
+
const hits = Object.entries(view?.elements ?? {}).filter(([, e]) => {
|
|
706
|
+
if (e.ambiguous)
|
|
707
|
+
return false;
|
|
708
|
+
const b = e.bounds;
|
|
709
|
+
return b ? p.x >= b.x && p.y >= b.y && p.x < b.x + b.width && p.y < b.y + b.height
|
|
710
|
+
: e.center?.x === p.x && e.center?.y === p.y;
|
|
711
|
+
});
|
|
712
|
+
return hits.length === 1 ? hits[0][0] : undefined; // Overlaps have no reliable stacking order.
|
|
713
|
+
}
|
|
714
|
+
function explainScreenElement(out, ref, view) {
|
|
715
|
+
const e = Object.hasOwn(view.elements ?? {}, ref) ? view.elements?.[ref] : undefined;
|
|
716
|
+
if (e?.ambiguous)
|
|
717
|
+
return;
|
|
718
|
+
out.ref = ref;
|
|
719
|
+
const label = Object.hasOwn(view.refs, ref) ? view.refs[ref] : '';
|
|
720
|
+
const action = screenAction(label ?? '');
|
|
721
|
+
out.verb ??= action.verb;
|
|
722
|
+
out.destroy ??= action.destroy;
|
|
723
|
+
if (!out.verb && !out.destroy && (e?.identifierVerb || e?.identifierDestroy || e?.submit)) {
|
|
724
|
+
out.verb = e.identifierVerb ?? (e.submit ? 'submit' : undefined);
|
|
725
|
+
out.destroy = e.identifierDestroy;
|
|
726
|
+
out.evidence = e.submit && !e.identifierVerb && !e.identifierDestroy ? 'semantic' : 'identifier';
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/** The elements and titles a screen tool's result shows, or undefined when the result is not a screen. A screen
|
|
730
|
+
* tool's result always replaces the server's last view, even when it is only an image: a stale list must not
|
|
731
|
+
* label the next click. Numbered lists also count from pointer/keyboard action results; acknowledgments alone
|
|
732
|
+
* keep the previous view, while an image-only action result clears it. `[ref=…]` lines and JSON nodes with a role
|
|
733
|
+
* count from any tool, since they are what accessibility trees look like. */
|
|
734
|
+
export function screenFromResult(toolName, response, text, input = {}) {
|
|
735
|
+
const tool = screenOperation(toolName.split('__').slice(2).join('__') || toolName);
|
|
736
|
+
const action = typeof input.action === 'string' ? screenOperation(input.action) : '';
|
|
737
|
+
const screenTool = SCREEN_WORDS.test(tool) || (GENERIC_CONTROL.test(tool) && SCREEN_WORDS.test(action));
|
|
738
|
+
const controlTool = POINTER_WORDS.test(tool) || keyControl(tool) || POINTER_WORDS.test(action) || keyControl(action);
|
|
739
|
+
const refs = Object.create(null);
|
|
740
|
+
const elements = Object.create(null);
|
|
741
|
+
const focusedRefs = new Set();
|
|
742
|
+
const conflictingRefs = new Set();
|
|
743
|
+
const origins = new Set(), applications = new Set();
|
|
744
|
+
const titles = [];
|
|
745
|
+
let structural = false;
|
|
746
|
+
let image = false;
|
|
747
|
+
let truncated = text.length >= MAX_TEXT;
|
|
748
|
+
const add = (key, role, label) => {
|
|
749
|
+
if (Object.keys(refs).length >= MAX_REFS || !label.trim())
|
|
750
|
+
return;
|
|
751
|
+
const ref = refKey(key), value = `${role ? role + ' ' : ''}"${label.trim().slice(0, 160)}"`;
|
|
752
|
+
if (Object.hasOwn(refs, ref) && refs[ref] !== value)
|
|
753
|
+
conflictingRefs.add(ref);
|
|
754
|
+
refs[ref] = value;
|
|
755
|
+
};
|
|
756
|
+
const body = text.slice(0, MAX_TEXT);
|
|
757
|
+
for (const m of body.matchAll(REF_LINE)) {
|
|
758
|
+
structural = true;
|
|
759
|
+
add(m[3], (m[1] ?? '').toLowerCase(), m[2] ?? '');
|
|
760
|
+
}
|
|
761
|
+
for (const m of body.matchAll(REF_TOKEN_LINE)) {
|
|
762
|
+
structural = true;
|
|
763
|
+
add(m[3], (m[1] ?? '').toLowerCase(), m[2] ?? '');
|
|
764
|
+
}
|
|
765
|
+
if (screenTool || controlTool)
|
|
766
|
+
for (const m of body.matchAll(INDEX_LINE)) {
|
|
767
|
+
structural = true;
|
|
768
|
+
add(m[1], ROLE(m[2]) || ROLE(m[4]), m[3] ?? '');
|
|
769
|
+
}
|
|
770
|
+
// JSON element lists, given as objects or as JSON in a text block.
|
|
771
|
+
const nodes = [response];
|
|
772
|
+
for (const t of responseStrings(response)) {
|
|
773
|
+
const s = t.trim();
|
|
774
|
+
if ((s.startsWith('{') || s.startsWith('[')) && s.length < MAX_TEXT) {
|
|
775
|
+
try {
|
|
776
|
+
nodes.push(JSON.parse(s));
|
|
777
|
+
}
|
|
778
|
+
catch { /* not JSON */ }
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// Only document-level context: an element/link URL cannot authorize the page containing it.
|
|
782
|
+
for (const n of nodes) {
|
|
783
|
+
const root = screenObject(n);
|
|
784
|
+
for (const scope of [root, screenObject(root?.structuredContent)]) {
|
|
785
|
+
if (!scope)
|
|
786
|
+
continue;
|
|
787
|
+
for (const k of ['url', 'pageUrl', 'page_url', 'documentUrl'])
|
|
788
|
+
if (scope[k] !== undefined)
|
|
789
|
+
origins.add(guiOrigin(scope[k]) ?? '');
|
|
790
|
+
for (const k of ['applicationId', 'bundleId'])
|
|
791
|
+
if (scope[k] !== undefined)
|
|
792
|
+
applications.add(guiIdentity(scope[k]) ? scope[k] : '');
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
let budget = 20_000;
|
|
796
|
+
const walk = (v, depth, form) => {
|
|
797
|
+
if (budget-- <= 0 || depth > 8) {
|
|
798
|
+
truncated = true;
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (!v || typeof v !== 'object')
|
|
802
|
+
return;
|
|
803
|
+
if (Array.isArray(v)) {
|
|
804
|
+
for (const x of v)
|
|
805
|
+
walk(x, depth + 1, form);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const o = v;
|
|
809
|
+
if (o.type === 'image')
|
|
810
|
+
image = true;
|
|
811
|
+
const idKey = ID_KEYS.find(k => typeof o[k] === 'string' || typeof o[k] === 'number');
|
|
812
|
+
const labelKey = LABEL_KEYS.find(k => typeof o[k] === 'string' && o[k].trim());
|
|
813
|
+
const role = ROLE(o.role) || ROLE(o.type) || ROLE(o.tag);
|
|
814
|
+
const elementKey = idKey ? refKey(o[idKey]) : undefined;
|
|
815
|
+
const childForm = role === 'form' && elementKey ? elementKey : form;
|
|
816
|
+
if (elementKey && (role || screenTool) && (Object.hasOwn(elements, elementKey) || Object.keys(elements).length < MAX_REFS)) {
|
|
817
|
+
const element = screenElement(o, role, role === 'form' ? undefined : form);
|
|
818
|
+
if (Object.hasOwn(elements, elementKey) && JSON.stringify(elements[elementKey]) !== JSON.stringify(element))
|
|
819
|
+
elements[elementKey] = { ambiguous: true };
|
|
820
|
+
else
|
|
821
|
+
elements[elementKey] = element;
|
|
822
|
+
if (o.role !== undefined)
|
|
823
|
+
structural = true;
|
|
824
|
+
}
|
|
825
|
+
else if (elementKey && (role || screenTool))
|
|
826
|
+
truncated = true;
|
|
827
|
+
for (const k of ['focusedRef', 'focusedElementId', 'activeElementId']) {
|
|
828
|
+
if (typeof o[k] === 'string' || typeof o[k] === 'number')
|
|
829
|
+
focusedRefs.add(refKey(o[k]));
|
|
830
|
+
}
|
|
831
|
+
if (idKey && labelKey && (role || screenTool)) {
|
|
832
|
+
if (o.role !== undefined)
|
|
833
|
+
structural = true;
|
|
834
|
+
add(o[idKey], role, o[labelKey]);
|
|
835
|
+
}
|
|
836
|
+
else if (!idKey) {
|
|
837
|
+
for (const k of TITLE_KEYS)
|
|
838
|
+
if (typeof o[k] === 'string' && o[k].trim() && titles.length < MAX_TITLES)
|
|
839
|
+
titles.push(o[k].trim().slice(0, 160));
|
|
840
|
+
}
|
|
841
|
+
for (const x of Object.values(o))
|
|
842
|
+
if (x && typeof x === 'object')
|
|
843
|
+
walk(x, depth + 1, childForm);
|
|
844
|
+
};
|
|
845
|
+
for (const n of nodes)
|
|
846
|
+
walk(n, 0);
|
|
847
|
+
for (const ref of conflictingRefs)
|
|
848
|
+
if (Object.hasOwn(elements, ref))
|
|
849
|
+
elements[ref] = { ambiguous: true };
|
|
850
|
+
for (const m of body.matchAll(TITLE_LINE)) {
|
|
851
|
+
if (titles.length >= MAX_TITLES)
|
|
852
|
+
break;
|
|
853
|
+
titles.push((m[1] ?? '').trim().slice(0, 160));
|
|
854
|
+
}
|
|
855
|
+
if (!screenTool && !structural && !(controlTool && (image || titles.length)))
|
|
856
|
+
return undefined;
|
|
857
|
+
for (const [ref, e] of Object.entries(elements))
|
|
858
|
+
if (e.focused)
|
|
859
|
+
focusedRefs.add(ref);
|
|
860
|
+
const focused = focusedRefs.size === 1 ? [...focusedRefs][0] : undefined;
|
|
861
|
+
return { refs, titles, ...(Object.keys(elements).length ? { elements } : {}),
|
|
862
|
+
...(origins.size > 1 || origins.has('') || applications.size > 1 || applications.has('') ? { contextAmbiguous: true } : {}),
|
|
863
|
+
...(!truncated ? { policyComplete: true } : {}),
|
|
864
|
+
...(origins.size === 1 && !origins.has('') ? { origin: [...origins][0] } : {}),
|
|
865
|
+
...(applications.size === 1 && !applications.has('') ? { application: [...applications][0] } : {}),
|
|
866
|
+
...(focused && Object.hasOwn(elements, focused) && !elements[focused]?.ambiguous ? { focused } : {}) };
|
|
867
|
+
}
|
|
868
|
+
/** A pointer or keyboard call to an MCP screen server, read against that server's last screen. */
|
|
869
|
+
export function screenCall(toolName, input, state) {
|
|
870
|
+
if (!toolName.startsWith('mcp__'))
|
|
871
|
+
return undefined;
|
|
872
|
+
const tool = screenOperation(toolName.split('__').slice(2).join('__') || toolName);
|
|
873
|
+
const action = typeof input.action === 'string' ? screenOperation(input.action) : '';
|
|
874
|
+
const pointer = POINTER_WORDS.test(tool) || POINTER_WORDS.test(action);
|
|
875
|
+
const key = keyControl(tool) || keyControl(action);
|
|
876
|
+
if (!pointer && !key) {
|
|
877
|
+
if (GENERIC_CONTROL.test(tool) && !(READ_CONTROL_ACTIONS.has(action) && Object.keys(input).length === 1))
|
|
878
|
+
return { guarded: 'control' };
|
|
879
|
+
return undefined;
|
|
880
|
+
}
|
|
881
|
+
const out = {};
|
|
882
|
+
const view = state.screens?.[screenServer(toolName)];
|
|
883
|
+
if (pointer) {
|
|
884
|
+
// No label, role, coordinate or caller-supplied description establishes the actual effect of a click.
|
|
885
|
+
// Gate even familiar labels: "Inbox" can be a submit button, or a page can change after the snapshot.
|
|
886
|
+
out.guarded = 'pointer';
|
|
887
|
+
const targets = [];
|
|
888
|
+
const requestedRefs = new Set();
|
|
889
|
+
for (const [k, v] of Object.entries(input)) {
|
|
890
|
+
const name = k.toLowerCase();
|
|
891
|
+
if (TARGET_TEXT_ARGS.has(name) && typeof v === 'string')
|
|
892
|
+
targets.push(v.slice(0, 300));
|
|
893
|
+
if (REF_ARGS.has(name) && (typeof v === 'string' || typeof v === 'number'))
|
|
894
|
+
requestedRefs.add(refKey(v));
|
|
895
|
+
}
|
|
896
|
+
const hit = coordinateRef(input, view);
|
|
897
|
+
const ref = requestedRefs.size === 1 ? [...requestedRefs][0] : requestedRefs.size === 0 ? hit : undefined;
|
|
898
|
+
if (ref && view && (hit === undefined || hit === ref)) {
|
|
899
|
+
explainScreenElement(out, ref, view);
|
|
900
|
+
if (hit === ref)
|
|
901
|
+
out.via = 'coordinates';
|
|
902
|
+
}
|
|
903
|
+
for (const t of targets) {
|
|
904
|
+
const action = screenAction(t);
|
|
905
|
+
out.verb ??= action.verb;
|
|
906
|
+
out.destroy ??= action.destroy;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (key) {
|
|
910
|
+
const typed = keyTexts(input);
|
|
911
|
+
const paste = /(^|_|-)paste(_|-|$)/i.test(tool) || /^paste$/i.test(action) || typed.some(t => PASTE_KEYS.test(t));
|
|
912
|
+
// What the clipboard holds is not in the call. After a credential read it may be the credential.
|
|
913
|
+
if (paste)
|
|
914
|
+
out.paste = [...state.private].reverse().find(p => p.secret);
|
|
915
|
+
if (!out.guarded) {
|
|
916
|
+
if (paste)
|
|
917
|
+
out.guarded = 'paste';
|
|
918
|
+
else if (TYPE_WORDS.test(tool) || TYPE_WORDS.test(action))
|
|
919
|
+
out.guarded = 'text';
|
|
920
|
+
else if (typed.some(t => /\b(enter|return|numpadenter)\b|[\r\n]/i.test(t)))
|
|
921
|
+
out.guarded = 'enter';
|
|
922
|
+
else if (!(PASSIVE_KEY_ACTIONS.has(tool) || GENERIC_CONTROL.test(tool)) || !passiveShortcut(input))
|
|
923
|
+
out.guarded = 'keyboard';
|
|
924
|
+
}
|
|
925
|
+
if (out.guarded === 'enter' && view?.focused) {
|
|
926
|
+
explainScreenElement(out, view.focused, view);
|
|
927
|
+
out.via = 'focus';
|
|
928
|
+
const focused = view.elements?.[view.focused];
|
|
929
|
+
const chord = typed.some(t => /\b(cmd|command|meta|ctrl|control)[+-]enter\b/i.test(t));
|
|
930
|
+
if (!out.verb && !out.destroy && focused?.form && TEXT_INPUT_ROLES.has(focused.role ?? '') && (focused.multiline === false || (focused.multiline === true && chord))) {
|
|
931
|
+
const buttons = Object.entries(view.elements ?? {}).filter(([, e]) => !e.ambiguous && e.form === focused.form && (e.role === 'button' || e.submit));
|
|
932
|
+
const submits = buttons.map(([ref]) => { const result = {}; explainScreenElement(result, ref, view); return result; }).filter(e => e.verb);
|
|
933
|
+
if (submits.length === 1) {
|
|
934
|
+
out.verb = submits[0].verb;
|
|
935
|
+
out.evidence = 'form';
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
/** Operator-selected observation schema only. Never infer read authority from tool annotations. */
|
|
943
|
+
export function screenObservation(toolName, input) {
|
|
944
|
+
const name = screenOperation(toolName.split('__').slice(2).join('__') || toolName);
|
|
945
|
+
return READ_CONTROL_ACTIONS.has(name) || ['browser_snapshot', 'browser_take_screenshot', 'browser_screenshot', 'get_accessibility_tree', 'accessibility_snapshot', 'computer_screenshot'].includes(name) ? Object.keys(input).length === 0
|
|
946
|
+
: GENERIC_CONTROL.test(name) && Object.keys(input).length === 1 && typeof input.action === 'string' && READ_CONTROL_ACTIONS.has(screenOperation(input.action));
|
|
947
|
+
}
|
|
948
|
+
/** Equality evidence for a new observation, not permission and not an attestation of GUI effects. */
|
|
949
|
+
export function screenTargetEvidence(toolName, input, view) {
|
|
950
|
+
if (!view?.policyComplete || view.contextAmbiguous || !view.elements || !(view.origin || view.application || view.titles.length))
|
|
951
|
+
return;
|
|
952
|
+
const operation = guiOperation(toolName, input);
|
|
953
|
+
if (!operation)
|
|
954
|
+
return;
|
|
955
|
+
const keys = ['key', 'keys', 'combo', 'combination', 'shortcut'], texts = ['text', 'value'];
|
|
956
|
+
const allowed = operation === 'click' ? ['x', 'y', 'coordinate', 'coordinates', 'position'] : operation === 'key' ? keys : texts;
|
|
957
|
+
if (Object.keys(input).some(key => key !== 'action' && !REF_ARGS.has(key.toLowerCase()) && !allowed.includes(key)))
|
|
958
|
+
return;
|
|
959
|
+
if (operation !== 'click') {
|
|
960
|
+
const values = (operation === 'key' ? keys : texts).filter(key => input[key] !== undefined);
|
|
961
|
+
if (values.length !== 1)
|
|
962
|
+
return;
|
|
963
|
+
const value = input[values[0]];
|
|
964
|
+
if (operation === 'type' ? typeof value !== 'string' : !(typeof value === 'string' || Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string')))
|
|
965
|
+
return;
|
|
966
|
+
if (operation === 'key') {
|
|
967
|
+
const chord = (Array.isArray(value) ? value.join('+') : value).trim().toLowerCase().replace(/ *\+ */g, '+');
|
|
968
|
+
if (chord.length > 64 || !/^(?:(?:cmd|command|meta|ctrl|control|alt|option|shift|super|win)\+)*(?:[a-z0-9]|enter|return|numpadenter|insert|backspace|delete|space|tab|escape|home|end|pageup|pagedown|arrowup|arrowdown|arrowleft|arrowright|f(?:[1-9]|1\d|2[0-4]))$/.test(chord))
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const call = screenCall(toolName, input, { v: 1, untrusted: [], private: [], notified: false, full: false });
|
|
973
|
+
if (!call)
|
|
974
|
+
return;
|
|
975
|
+
const refs = Object.entries(input).filter(([key]) => REF_ARGS.has(key.toLowerCase()));
|
|
976
|
+
if (refs.length > 1)
|
|
977
|
+
return;
|
|
978
|
+
const raw = refs[0]?.[1];
|
|
979
|
+
if (refs.length && !(typeof raw === 'string' && /^[A-Za-z0-9_.:-]{1,40}$/.test(raw) || typeof raw === 'number' && Number.isSafeInteger(raw)))
|
|
980
|
+
return;
|
|
981
|
+
const ref = refs.length ? refKey(raw) : undefined;
|
|
982
|
+
const hasCoordinates = ['x', 'y', 'coordinate', 'coordinates', 'position'].some(k => input[k] !== undefined);
|
|
983
|
+
for (const key of ['coordinate', 'coordinates', 'position']) {
|
|
984
|
+
const value = input[key];
|
|
985
|
+
if (value !== undefined && !(Array.isArray(value) ? value.length === 2 && value.every(screenNumber) : screenObject(value) && Object.keys(value).every(k => ['x', 'y'].includes(k))))
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const hit = hasCoordinates ? coordinateRef(input, view) : undefined;
|
|
989
|
+
if (hasCoordinates && (!hit || ref && hit !== ref))
|
|
990
|
+
return;
|
|
991
|
+
const pointer = call.guarded === 'pointer';
|
|
992
|
+
const target = ref ?? hit ?? (!pointer ? view.focused : undefined);
|
|
993
|
+
if (!target || !pointer && target !== view.focused)
|
|
994
|
+
return;
|
|
995
|
+
const e = Object.hasOwn(view.elements, target) ? view.elements[target] : undefined;
|
|
996
|
+
if (!e || e.ambiguous || !e.role || !e.bounds || !e.referenceHash || ref && e.referenceHash !== screenHash(String(raw)))
|
|
997
|
+
return;
|
|
998
|
+
if (e.identityHash && Object.values(view.elements).filter(other => other.identityHash === e.identityHash).length !== 1)
|
|
999
|
+
return;
|
|
1000
|
+
return { target, stamp: screenHash(JSON.stringify({ target, role: e.role, reference: e.referenceHash, identity: e.identityHash,
|
|
1001
|
+
bounds: e.bounds, label: e.labelHash, form: e.form, submit: e.submit,
|
|
1002
|
+
origin: view.origin, application: view.application, titles: view.titles.map(screenHash), ...(!pointer ? { focus: view.focused } : {}) })) };
|
|
1003
|
+
}
|
|
1004
|
+
export function screenTargetStamp(toolName, input, view) {
|
|
1005
|
+
return screenTargetEvidence(toolName, input, view)?.stamp;
|
|
1006
|
+
}
|
|
1007
|
+
export function screenResponseEvidence(actionTool, action, observationTool, observation, response) {
|
|
1008
|
+
const o = screenObject(response);
|
|
1009
|
+
if (o?.isError || o?.error !== undefined)
|
|
1010
|
+
return;
|
|
1011
|
+
const view = screenFromResult(observationTool, response, responseStrings(response).join('\n'), observation);
|
|
1012
|
+
return view ? screenTargetEvidence(actionTool, action, { at: new Date().toISOString(), tool: observationTool, ...view }) : undefined;
|
|
1013
|
+
}
|
|
1014
|
+
/** Derive equality evidence from this response itself, never a concurrently replaced cached view. */
|
|
1015
|
+
export function screenResponseStamp(actionTool, action, observationTool, observation, response) {
|
|
1016
|
+
return screenResponseEvidence(actionTool, action, observationTool, observation, response)?.stamp;
|
|
1017
|
+
}
|
|
1018
|
+
/** The only keyboard exemptions are an exact copy/select-all chord in a simple known schema. Extra fields,
|
|
1019
|
+
* malformed arrays and sequences never turn into a harmless chord by dropping values during extraction. */
|
|
1020
|
+
function passiveShortcut(input) {
|
|
1021
|
+
const fields = Object.entries(input).filter(([k]) => k !== 'action');
|
|
1022
|
+
if (fields.length !== 1)
|
|
1023
|
+
return false;
|
|
1024
|
+
const [name, value] = fields[0];
|
|
1025
|
+
if (!['key', 'keys', 'combo', 'combination', 'shortcut'].includes(name))
|
|
1026
|
+
return false;
|
|
1027
|
+
if (input.action !== undefined && (typeof input.action !== 'string' || !PASSIVE_KEY_ACTIONS.has(screenOperation(input.action))))
|
|
1028
|
+
return false;
|
|
1029
|
+
if (typeof value !== 'string' && !(Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string')))
|
|
1030
|
+
return false;
|
|
1031
|
+
const raw = Array.isArray(value) ? value.join('+') : value;
|
|
1032
|
+
if (raw.length > 64 || /[^a-z+ ]/i.test(raw))
|
|
1033
|
+
return false;
|
|
1034
|
+
const chord = raw.trim().toLowerCase()
|
|
1035
|
+
.replace(/\s*\+\s*/g, '+').replace(/^(command|meta|super|win)\+/, 'cmd+').replace(/^control\+/, 'ctrl+');
|
|
1036
|
+
return /^(cmd|ctrl)\+[ac]$/.test(chord);
|
|
1037
|
+
}
|
|
1038
|
+
function keyTexts(input) {
|
|
1039
|
+
return Object.entries(input).filter(([k]) => KEY_TEXT_ARGS.has(k.toLowerCase()))
|
|
1040
|
+
.map(([, v]) => (typeof v === 'string' ? v : Array.isArray(v) ? v.filter(x => typeof x === 'string').join('+') : ''))
|
|
1041
|
+
.filter(Boolean).map(t => t.slice(0, 2000));
|
|
1042
|
+
}
|
|
1043
|
+
function guiOperation(toolName, input) {
|
|
1044
|
+
const operation = (name) => {
|
|
1045
|
+
const simple = name.replace(/^(browser|screen|computer|desktop|keyboard)_/, '');
|
|
1046
|
+
if (/^(click|left_click|tap)$/.test(simple))
|
|
1047
|
+
return 'click';
|
|
1048
|
+
if (/^(type|type_text|input_text|insert_text|enter_text|fill)$/.test(simple))
|
|
1049
|
+
return 'type';
|
|
1050
|
+
if (/^(key|keys|key_press|keypress|press_key|press_keys|hotkey|shortcut|keyboard)$/.test(simple))
|
|
1051
|
+
return 'key';
|
|
1052
|
+
return undefined;
|
|
1053
|
+
};
|
|
1054
|
+
const name = screenOperation(toolName.split('__').slice(2).join('__'));
|
|
1055
|
+
const action = typeof input.action === 'string' ? operation(screenOperation(input.action)) : undefined;
|
|
1056
|
+
const tool = GENERIC_CONTROL.test(name) ? action : operation(name);
|
|
1057
|
+
return tool && (input.action === undefined || action === tool) ? tool : undefined;
|
|
1058
|
+
}
|
|
1059
|
+
/** A credential read that a screen server's calls reveal: a credential path typed (into an open dialog, a
|
|
1060
|
+
* terminal, an address bar) or a window or page title naming a credential file. Only the file name is kept. */
|
|
1061
|
+
export function screenSecretRead(toolName, input, view) {
|
|
1062
|
+
if (!toolName.startsWith('mcp__'))
|
|
1063
|
+
return undefined;
|
|
1064
|
+
const tool = screenOperation(toolName.split('__').slice(2).join('__') || toolName);
|
|
1065
|
+
const action = typeof input.action === 'string' ? screenOperation(input.action) : '';
|
|
1066
|
+
const name = (path) => { const b = basename(path.replace(/\\/g, '/')); return /^[A-Za-z0-9_.+-]{1,60}$/.test(b) ? b : 'a credential file'; };
|
|
1067
|
+
if (keyControl(tool) || keyControl(action)) {
|
|
1068
|
+
for (const t of keyTexts(input)) {
|
|
1069
|
+
for (const raw of t.split(/[\s"'`]+/)) {
|
|
1070
|
+
const token = raw.replace(/[,;:)]+$/, '');
|
|
1071
|
+
if (/[/~.]/.test(token) && isSecretPath(token))
|
|
1072
|
+
return `typed path ${name(token)}`;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
for (const title of view?.titles ?? []) {
|
|
1077
|
+
for (const token of title.split(/[\s"'()|·–—]+/))
|
|
1078
|
+
if (token && isSecretPath(token))
|
|
1079
|
+
return `screen title naming ${name(token)}`;
|
|
1080
|
+
}
|
|
1081
|
+
return undefined;
|
|
1082
|
+
}
|
|
398
1083
|
// Who or what a call is aimed at. A destination the user named in their own words is the user's choice.
|
|
399
1084
|
const DEST_ARG_NAMES = new Set(['to', 'recipient', 'recipients', 'cc', 'bcc', 'email', 'emails', 'participants', 'user', 'users',
|
|
400
1085
|
'channel', 'url', 'urls', 'iban', 'account', 'address', 'phone', 'host', 'hostname', 'domain', 'webhook', 'endpoint',
|
|
@@ -405,7 +1090,8 @@ const DEST_ARG_NAMES = new Set(['to', 'recipient', 'recipients', 'cc', 'bcc', 'e
|
|
|
405
1090
|
const PARTY_ARG_NAMES = new Set(['to', 'recipient', 'recipients', 'cc', 'bcc', 'email', 'emails', 'participants', 'iban', 'phone',
|
|
406
1091
|
'webhook', 'endpoint', 'attendee', 'attendees', 'invitee', 'invitees', 'assignee', 'assignees', 'reviewer', 'reviewers',
|
|
407
1092
|
'collaborator', 'collaborators', 'member', 'members', 'username']);
|
|
408
|
-
|
|
1093
|
+
// Bounded address parts and a fixed start prevent quadratic scans of long non-address payloads.
|
|
1094
|
+
const EMAIL_RE = /(?<![a-z0-9._%+-])[a-z0-9._%+-]{1,64}@[a-z0-9.-]{1,253}\.[a-z]{2,63}\b/gi;
|
|
409
1095
|
// A bare domain in text ("www.example.com/x"), limited to common TLDs so "file.txt" or "v1.2" do not count.
|
|
410
1096
|
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;
|
|
411
1097
|
const IBAN_RE = /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]){10,30}\b/g;
|
|
@@ -593,34 +1279,152 @@ export function fingerprints(text) {
|
|
|
593
1279
|
}
|
|
594
1280
|
return [...out];
|
|
595
1281
|
}
|
|
596
|
-
/**
|
|
597
|
-
|
|
1282
|
+
/** Only explicit character lists are decoded, never arbitrary prose. Unknown items end a run.
|
|
1283
|
+
* English digit names and case markers cover the recorded attack, not arbitrary verbal encodings. */
|
|
1284
|
+
export function spelledCandidates(text) {
|
|
1285
|
+
const digits = { zero: '0', one: '1', two: '2', three: '3', four: '4', five: '5', six: '6', seven: '7', eight: '8', nine: '9' };
|
|
1286
|
+
const out = [];
|
|
1287
|
+
let run = '';
|
|
1288
|
+
const flush = () => { if (run.length >= 5)
|
|
1289
|
+
out.push(run); run = ''; };
|
|
1290
|
+
for (const item of text.slice(0, MAX_CANDIDATE).normalize('NFKC').split(/[,;|\r\n]+/)) {
|
|
1291
|
+
const unit = item.trim().toLowerCase();
|
|
1292
|
+
const ch = /^[a-z0-9]$/.test(unit) ? unit : (Object.hasOwn(digits, unit) ? digits[unit] : undefined) ?? /^(?:capital|uppercase|lowercase|letter) ([a-z])$/.exec(unit)?.[1];
|
|
1293
|
+
if (ch)
|
|
1294
|
+
run += ch;
|
|
1295
|
+
else
|
|
1296
|
+
flush();
|
|
1297
|
+
}
|
|
1298
|
+
flush();
|
|
1299
|
+
return out;
|
|
1300
|
+
}
|
|
1301
|
+
/** Decimal scalar codes, in a contiguous explicit list. Unknown/invalid items terminate a run.
|
|
1302
|
+
* Merely decoding public numbers does not block anything: existing secret/private fingerprints still decide. */
|
|
1303
|
+
export function characterCodeCandidates(text) {
|
|
598
1304
|
const out = [];
|
|
599
|
-
let
|
|
600
|
-
const
|
|
601
|
-
out.push(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
if (
|
|
1305
|
+
let run = '', count = 0;
|
|
1306
|
+
const flush = () => { if (count >= 5)
|
|
1307
|
+
out.push(run); run = ''; count = 0; };
|
|
1308
|
+
for (const item of text.slice(0, MAX_CANDIDATE).normalize('NFKC').split(/[\s,;|[\]]+/)) {
|
|
1309
|
+
if (!item)
|
|
1310
|
+
continue;
|
|
1311
|
+
const code = /^\d{1,7}$/.test(item) ? Number(item) : -1;
|
|
1312
|
+
if (code >= 32 && code <= 0x10ffff && !(code >= 0x7f && code <= 0x9f) && !(code >= 0xd800 && code <= 0xdfff) || [9, 10, 13].includes(code)) {
|
|
1313
|
+
run += String.fromCodePoint(code);
|
|
1314
|
+
count++;
|
|
1315
|
+
}
|
|
1316
|
+
else
|
|
1317
|
+
flush();
|
|
1318
|
+
}
|
|
1319
|
+
flush();
|
|
1320
|
+
return out;
|
|
1321
|
+
}
|
|
1322
|
+
const NATO = Object.freeze(Object.fromEntries([
|
|
1323
|
+
...'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)]),
|
|
1324
|
+
['alpha', 'a'], ['juliet', 'j'], ['x-ray', 'x'],
|
|
1325
|
+
['niner', '9'],
|
|
1326
|
+
...'zero one two three four five six seven eight nine'.split(' ').map((word, i) => [word, String(i)]),
|
|
1327
|
+
['underscore', '_'], ['hyphen', '-'], ['dash', '-'], ['slash', '/'], ['plus', '+'], ['equals', '='], ['dot', '.'],
|
|
1328
|
+
]));
|
|
1329
|
+
/** A small explicit spelling grammar, not a language model. Preserve explicit case for nested base64. */
|
|
1330
|
+
export function natoCandidates(text) {
|
|
1331
|
+
const out = [];
|
|
1332
|
+
let run = '', count = 0, marker = '';
|
|
1333
|
+
const flush = () => { if (count >= 5)
|
|
1334
|
+
out.push(run); run = ''; count = 0; marker = ''; };
|
|
1335
|
+
for (const item of text.slice(0, MAX_CANDIDATE).normalize('NFKC').split(/[\s,;|]+/)) {
|
|
1336
|
+
if (!item)
|
|
1337
|
+
continue;
|
|
1338
|
+
const unit = item.toLowerCase();
|
|
1339
|
+
if (['capital', 'uppercase', 'lowercase', 'letter'].includes(unit)) {
|
|
1340
|
+
if (marker)
|
|
1341
|
+
flush();
|
|
1342
|
+
marker = unit;
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
const ch = Object.hasOwn(NATO, unit) ? NATO[unit] : /^\d$/.test(unit) ? unit : undefined;
|
|
1346
|
+
if (!ch || marker && !/^[a-z]$/.test(ch)) {
|
|
1347
|
+
flush();
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
run += ['capital', 'uppercase'].includes(marker) ? ch.toUpperCase() : ch;
|
|
1351
|
+
count++;
|
|
1352
|
+
marker = '';
|
|
1353
|
+
}
|
|
1354
|
+
flush();
|
|
1355
|
+
return out;
|
|
1356
|
+
}
|
|
1357
|
+
/** Reversible text encodings only: bounded breadth/depth, no eval or decompression. Exhaustion is explicit. */
|
|
1358
|
+
export function scanCandidates(texts) {
|
|
1359
|
+
const values = [], queue = [];
|
|
1360
|
+
const seen = new Set();
|
|
1361
|
+
let left = MAX_CANDIDATE, incomplete = false;
|
|
1362
|
+
const push = (text, depth) => {
|
|
1363
|
+
if (!text || seen.has(text))
|
|
1364
|
+
return;
|
|
1365
|
+
if (depth > 4 || values.length >= 64 || text.length > left) {
|
|
1366
|
+
incomplete = true;
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
seen.add(text);
|
|
1370
|
+
left -= text.length;
|
|
1371
|
+
values.push(text);
|
|
1372
|
+
queue.push({ text, depth });
|
|
1373
|
+
};
|
|
1374
|
+
for (const text of texts) {
|
|
1375
|
+
if (text.length > left) {
|
|
1376
|
+
incomplete = true;
|
|
1377
|
+
if (left)
|
|
1378
|
+
push(text.slice(0, left), 0);
|
|
1379
|
+
}
|
|
1380
|
+
else
|
|
1381
|
+
push(text, 0);
|
|
1382
|
+
}
|
|
1383
|
+
const utf8 = (bytes) => {
|
|
1384
|
+
try {
|
|
1385
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
1386
|
+
return text && !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text) ? text : undefined;
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
return undefined;
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
for (let index = 0; index < queue.length; index++) {
|
|
1393
|
+
const { text, depth } = queue[index];
|
|
1394
|
+
const add = (value) => { if (value && value !== text)
|
|
1395
|
+
push(value, depth + 1); };
|
|
1396
|
+
if (/%[0-9a-f]{2}/i.test(text)) {
|
|
607
1397
|
try {
|
|
608
|
-
|
|
1398
|
+
add(decodeURIComponent(text.replace(/\+/g, ' ')));
|
|
609
1399
|
}
|
|
610
|
-
catch { /*
|
|
1400
|
+
catch { /* malformed literal, never executable */ }
|
|
611
1401
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1402
|
+
for (const value of spelledCandidates(text))
|
|
1403
|
+
add(value);
|
|
1404
|
+
for (const value of characterCodeCandidates(text))
|
|
1405
|
+
add(value);
|
|
1406
|
+
for (const value of natoCandidates(text))
|
|
1407
|
+
add(value);
|
|
1408
|
+
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) => {
|
|
1409
|
+
const code = Number.parseInt(a ?? b ?? c ?? d ?? e, e === undefined ? 16 : 10);
|
|
1410
|
+
return code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : whole;
|
|
1411
|
+
});
|
|
1412
|
+
add(escaped);
|
|
1413
|
+
for (const m of text.matchAll(/[A-Za-z0-9+/_-]{8,}={0,2}/g)) {
|
|
1414
|
+
const normalized = m[0].replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
|
|
1415
|
+
if (normalized.length % 4 === 1)
|
|
1416
|
+
continue;
|
|
1417
|
+
const bytes = Buffer.from(normalized, 'base64');
|
|
1418
|
+
if (bytes.toString('base64').replace(/=+$/, '') === normalized)
|
|
1419
|
+
add(utf8(bytes));
|
|
618
1420
|
}
|
|
619
|
-
for (const m of
|
|
620
|
-
|
|
1421
|
+
for (const m of text.matchAll(/\b(?:[0-9a-f]{2}){3,}\b/gi))
|
|
1422
|
+
add(utf8(Buffer.from(m[0], 'hex')));
|
|
621
1423
|
}
|
|
622
|
-
return
|
|
1424
|
+
return { values, incomplete };
|
|
623
1425
|
}
|
|
1426
|
+
/** Compatibility helper: enforcement uses scanCandidates() so truncation is never mistaken for a clean scan. */
|
|
1427
|
+
export function candidates(texts) { return scanCandidates(texts).values; }
|
|
624
1428
|
// Short structured values a 20-character window misses: card, passport, account and phone numbers.
|
|
625
1429
|
// Words are split on whitespace; consecutive words that carry digits ("DE89 3704 0044", "+1 (555) 123-4567")
|
|
626
1430
|
// join into one value, and a word without digits ends it.
|
|
@@ -697,7 +1501,7 @@ function entropy(s) {
|
|
|
697
1501
|
const TOKEN_RUN = /[A-Za-z0-9+/_-]{16,}={0,2}/g;
|
|
698
1502
|
/** Key-like values in private text: every random-looking run of a credential file, and every well-known
|
|
699
1503
|
* secret format wherever it was read. Words, paths and host names are not random enough to count. */
|
|
700
|
-
export function
|
|
1504
|
+
export function secretValues(text, credentialFile) {
|
|
701
1505
|
const out = new Set();
|
|
702
1506
|
const t = text.slice(0, MAX_TEXT);
|
|
703
1507
|
const add = (raw) => {
|
|
@@ -707,7 +1511,7 @@ export function secretTokens(text, credentialFile) {
|
|
|
707
1511
|
return;
|
|
708
1512
|
const a = alnum(raw);
|
|
709
1513
|
if (a.length >= 16 && /\d/.test(a) && /[a-z]/.test(a) && entropy(a) >= 3.5)
|
|
710
|
-
out.add(
|
|
1514
|
+
out.add(raw);
|
|
711
1515
|
};
|
|
712
1516
|
if (credentialFile)
|
|
713
1517
|
for (const m of t.matchAll(TOKEN_RUN))
|
|
@@ -717,6 +1521,10 @@ export function secretTokens(text, credentialFile) {
|
|
|
717
1521
|
add(m[0]);
|
|
718
1522
|
return [...out];
|
|
719
1523
|
}
|
|
1524
|
+
/** Normalized credentials for the original strong-window check. Raw values exist only transiently. */
|
|
1525
|
+
export function secretTokens(text, credentialFile) {
|
|
1526
|
+
return [...new Set(secretValues(text, credentialFile).map(alnum))];
|
|
1527
|
+
}
|
|
720
1528
|
/** Windows that hold both a letter and a digit, so an ordinary word is never a piece of a key. A window never
|
|
721
1529
|
* starts in the key's leading letters ("sk-ant-api03" or "rk_live" is a format every such key shares), never in the
|
|
722
1530
|
* first 48 characters of a DER key or certificate (its header and algorithm), and never lies inside KEY_STRUCTURE. */
|
|
@@ -733,6 +1541,119 @@ export function tokenFingerprints(tokens) {
|
|
|
733
1541
|
}
|
|
734
1542
|
return [...out];
|
|
735
1543
|
}
|
|
1544
|
+
const fragmentHash = (s) => createHash('sha256').update(`warden-fragment-v1:${s}`).digest('hex').slice(0, 32);
|
|
1545
|
+
const fragmentKey = (r) => `${r.group}:${r.offset}:${r.size ?? FRAGMENT_SIZE}:${r.edge ? 'edge' : 'atom'}:${r.hash}`;
|
|
1546
|
+
const CREDENTIAL_PREFIX = /^(?:[rs]k_(?:live|test)_|sk-ant-api[0-9]+-|sk-ant-|sk-proj-|sk-|gh[pousr]_|github_pat_|glpat-|npm_|xox[abprs]-|AKIA|ASIA|AIza)/i;
|
|
1547
|
+
const fragmentVisible = (s) => s.normalize('NFKC').toLowerCase().replace(/[^a-z0-9]/g, ch => CONFUSABLE[ch] ?? ch);
|
|
1548
|
+
const SHORT_SIZES = [5, 6, 7];
|
|
1549
|
+
/** Weak evidence asks before its first transmission, never silently learns by sending it.
|
|
1550
|
+
* Five-char windows require upper/lower/digit classes in the source, while 6/7-char atoms may be words.
|
|
1551
|
+
* Exact prefix-boundary slices cover chunks that already contain payload but no full six-char window.
|
|
1552
|
+
* Only hash/offset/size metadata is persisted; public format and DER structure alone do not count. */
|
|
1553
|
+
export function fragmentFingerprints(tokens, limit = MAX_FRAGMENT_INDEX) {
|
|
1554
|
+
const out = [];
|
|
1555
|
+
for (const raw of new Set(tokens)) {
|
|
1556
|
+
const a = alnum(raw), prefix = CREDENTIAL_PREFIX.exec(raw)?.[0];
|
|
1557
|
+
const normPrefix = /^(?:[rs]k(?:live|test)|skantapi[0-9]+|skant|skproj|sk|gh[pousr]|githubpat|glpat|npm|xox[abprs]|akia|asia|aiza)/.exec(a)?.[0];
|
|
1558
|
+
const start = /^(mii|mig|mhc|mc4|mco)/.test(a) ? Math.max(48, /^[a-z]*/.exec(a)[0].length) : normPrefix?.length ?? /^[a-z]*/.exec(a)[0].length;
|
|
1559
|
+
const group = fragmentHash(`key:${a}`);
|
|
1560
|
+
// Preserve case only transiently to avoid indexing every short hex/hash or ordinary word.
|
|
1561
|
+
const caseful = raw.normalize('NFKC').replace(/[^a-z0-9]/gi, '');
|
|
1562
|
+
for (const size of SHORT_SIZES)
|
|
1563
|
+
for (let offset = start; offset + size <= a.length; offset++) {
|
|
1564
|
+
const piece = a.slice(offset, offset + size), original = caseful.slice(offset, offset + size);
|
|
1565
|
+
if (size < FRAGMENT_SIZE && !(/[A-Z]/.test(original) && /[a-z]/.test(original) && /[0-9]/.test(original)))
|
|
1566
|
+
continue;
|
|
1567
|
+
if (/^(.)\1+$/.test(piece) || KEY_STRUCTURE.some(k => k.includes(piece)))
|
|
1568
|
+
continue;
|
|
1569
|
+
out.push({ group, hash: fragmentHash(piece), offset, size });
|
|
1570
|
+
if (out.length > limit)
|
|
1571
|
+
return out;
|
|
1572
|
+
}
|
|
1573
|
+
if (prefix)
|
|
1574
|
+
for (let offset = 0; offset < prefix.length; offset++) {
|
|
1575
|
+
// Require a real format delimiter, or the entire delimiter-free prefix (AWS/Google).
|
|
1576
|
+
const suffix = prefix.slice(offset);
|
|
1577
|
+
if (offset !== 0 && (!/[_-]/.test(suffix) || !/[a-z]/i.test(suffix)))
|
|
1578
|
+
continue;
|
|
1579
|
+
for (let length = 1; length <= 7 && prefix.length + length <= raw.length; length++) {
|
|
1580
|
+
const piece = fragmentVisible(raw.slice(offset, prefix.length + length));
|
|
1581
|
+
if (piece.length < 5)
|
|
1582
|
+
continue;
|
|
1583
|
+
out.push({ group, hash: fragmentHash(`edge:${piece}`), offset, size: piece.length, edge: true });
|
|
1584
|
+
if (out.length > limit)
|
|
1585
|
+
return out;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
return out;
|
|
1590
|
+
}
|
|
1591
|
+
/** Full isolated atoms, not every substring of long ordinary hashes. Structured encoded candidates are included. */
|
|
1592
|
+
function fragmentAtoms(input) {
|
|
1593
|
+
const hashes = new Set(), scan = scanCandidates(strings(input));
|
|
1594
|
+
if (scan.incomplete)
|
|
1595
|
+
return { hashes, incomplete: true };
|
|
1596
|
+
for (const text of scan.values) {
|
|
1597
|
+
const visible = fragmentVisible(text);
|
|
1598
|
+
for (const run of visible.matchAll(/\b(?:[a-z0-9][\t ]+){4,6}[a-z0-9]\b/g)) {
|
|
1599
|
+
const atom = alnum(run[0]);
|
|
1600
|
+
hashes.add(fragmentHash(atom));
|
|
1601
|
+
hashes.add(fragmentHash([...atom].reverse().join('')));
|
|
1602
|
+
}
|
|
1603
|
+
for (const m of visible.matchAll(/[a-z0-9+/_-]+/g)) {
|
|
1604
|
+
const raw = m[0];
|
|
1605
|
+
if (raw.length <= 64)
|
|
1606
|
+
hashes.add(fragmentHash(`edge:${raw}`));
|
|
1607
|
+
const joined = alnum(raw);
|
|
1608
|
+
if (/^[a-z0-9]{5,7}$/.test(joined)) {
|
|
1609
|
+
hashes.add(fragmentHash(joined));
|
|
1610
|
+
hashes.add(fragmentHash([...joined].reverse().join('')));
|
|
1611
|
+
}
|
|
1612
|
+
// Separators delimit atoms; a long uninterrupted hash is never scanned with weak windows.
|
|
1613
|
+
for (const atom of raw.matchAll(/[a-z0-9]+/g))
|
|
1614
|
+
if (/^[a-z0-9]{5,7}$/.test(atom[0])) {
|
|
1615
|
+
hashes.add(fragmentHash(atom[0]));
|
|
1616
|
+
hashes.add(fragmentHash([...atom[0]].reverse().join('')));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return { hashes, incomplete: scan.incomplete };
|
|
1621
|
+
}
|
|
1622
|
+
/** Must run under the session lock when remembering evidence. Audit never consumes or whitelists a match. */
|
|
1623
|
+
export function checkFragments(input, state, hasLegacyTokens, remember) {
|
|
1624
|
+
const f = state.fragments;
|
|
1625
|
+
if (!f)
|
|
1626
|
+
return hasLegacyTokens ? { unavailable: true } : {};
|
|
1627
|
+
if (f.v !== 2 || f.full)
|
|
1628
|
+
return { unavailable: true };
|
|
1629
|
+
if (!f.index.length)
|
|
1630
|
+
return {};
|
|
1631
|
+
const { hashes, incomplete } = fragmentAtoms(input);
|
|
1632
|
+
const matches = f.index.filter(r => hashes.has(r.hash));
|
|
1633
|
+
if (!matches.length)
|
|
1634
|
+
return incomplete ? { unavailable: true } : {};
|
|
1635
|
+
const seen = new Map(f.seen.map(r => [fragmentKey(r), r]));
|
|
1636
|
+
for (const r of matches)
|
|
1637
|
+
seen.set(fragmentKey(r), r);
|
|
1638
|
+
const all = [...seen.values()];
|
|
1639
|
+
const offsets = new Map();
|
|
1640
|
+
for (const r of all) {
|
|
1641
|
+
if (r.edge || r.size !== FRAGMENT_SIZE)
|
|
1642
|
+
continue;
|
|
1643
|
+
const key = `${r.group}:${r.offset}`, at = offsets.get(key) ?? new Set();
|
|
1644
|
+
at.add(r.hash);
|
|
1645
|
+
offsets.set(key, at);
|
|
1646
|
+
}
|
|
1647
|
+
const paired = all.some(r => !r.edge && r.size === FRAGMENT_SIZE && [...(offsets.get(`${r.group}:${r.offset + FRAGMENT_SIZE}`) ?? [])].some(h => h !== r.hash));
|
|
1648
|
+
if (remember) {
|
|
1649
|
+
f.seen = all.slice(0, MAX_FRAGMENT_SEEN);
|
|
1650
|
+
if (all.length > MAX_FRAGMENT_SEEN)
|
|
1651
|
+
f.full = true;
|
|
1652
|
+
}
|
|
1653
|
+
if (paired)
|
|
1654
|
+
return { secret: 'adjacent short fragments of a credential this session read' };
|
|
1655
|
+
return { review: true, ...(incomplete || all.length > MAX_FRAGMENT_SEEN ? { unavailable: true } : {}) };
|
|
1656
|
+
}
|
|
736
1657
|
/** Every window of letters and digits of both token sizes, forwards and backwards: what a call is checked by. */
|
|
737
1658
|
export function tokenWindows(text) {
|
|
738
1659
|
const out = [];
|
|
@@ -744,14 +1665,34 @@ export function tokenWindows(text) {
|
|
|
744
1665
|
return out;
|
|
745
1666
|
}
|
|
746
1667
|
function tokenHit(text, tokFp) {
|
|
747
|
-
|
|
1668
|
+
for (const a of [alnum(text), alnum([...text].reverse().join(''))]) {
|
|
1669
|
+
for (const size of TOKEN_WINDOWS)
|
|
1670
|
+
for (let i = 0; i + size <= a.length; i++)
|
|
1671
|
+
if (tokFp.has(fpHash(a.slice(i, i + size))))
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
function windowHit(text, store) {
|
|
1677
|
+
if (!store.size)
|
|
1678
|
+
return false;
|
|
1679
|
+
const value = normalizeText(text);
|
|
1680
|
+
for (let i = 0; i + WINDOW <= value.length; i++)
|
|
1681
|
+
if (store.has(fpHash(value.slice(i, i + WINDOW))))
|
|
1682
|
+
return true;
|
|
1683
|
+
return false;
|
|
748
1684
|
}
|
|
749
1685
|
function readFp(path) {
|
|
750
1686
|
try {
|
|
751
|
-
|
|
1687
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
1688
|
+
if (lines.some(line => !/^[0-9a-f]{12}$/.test(line)))
|
|
1689
|
+
throw new Error('invalid fingerprints');
|
|
1690
|
+
return new Set(lines);
|
|
752
1691
|
}
|
|
753
|
-
catch {
|
|
754
|
-
|
|
1692
|
+
catch (err) {
|
|
1693
|
+
if (err.code === 'ENOENT')
|
|
1694
|
+
return new Set();
|
|
1695
|
+
throw new Error('flow fingerprints cannot be read safely');
|
|
755
1696
|
}
|
|
756
1697
|
}
|
|
757
1698
|
function readStores(paths) {
|
|
@@ -761,24 +1702,26 @@ function readStores(paths) {
|
|
|
761
1702
|
* for ordinary private text, never for secrets. */
|
|
762
1703
|
export function checkLeak(paths, input, typed = [], stores = readStores(paths)) {
|
|
763
1704
|
const all = strings(input);
|
|
764
|
-
const
|
|
1705
|
+
const scan = scanCandidates(all);
|
|
1706
|
+
const cands = scan.values;
|
|
765
1707
|
const typedText = typed.join('\n');
|
|
766
1708
|
const own = (s) => { const n = normalizeText(s); return n.length > 0 && typedText.includes(n); };
|
|
767
|
-
const privateCands = candidates(all.filter(s => !own(s)));
|
|
768
1709
|
const pattern = secretPattern(cands);
|
|
769
1710
|
if (pattern)
|
|
770
1711
|
return { secret: pattern };
|
|
1712
|
+
if (scan.incomplete)
|
|
1713
|
+
return { incomplete: true };
|
|
771
1714
|
const { secret: secretFp, fp: privateFp, pii: piiFp, tok: tokFp } = stores;
|
|
772
|
-
if (!secretFp.size && !privateFp.size && !tokFp.size)
|
|
1715
|
+
if (!secretFp.size && !privateFp.size && !tokFp.size && !piiFp.size)
|
|
773
1716
|
return {};
|
|
774
1717
|
for (const c of cands)
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
return { secret: 'text from a credential file this session read' };
|
|
1718
|
+
if (windowHit(c, secretFp))
|
|
1719
|
+
return { secret: 'text from a credential file this session read' };
|
|
778
1720
|
if (tokFp.size)
|
|
779
1721
|
for (const c of cands)
|
|
780
1722
|
if (tokenHit(c, tokFp))
|
|
781
1723
|
return { secret: 'part of a credential this session read' };
|
|
1724
|
+
const privateCands = candidates(all.filter(s => !own(s)));
|
|
782
1725
|
const result = {};
|
|
783
1726
|
if (piiFp.size)
|
|
784
1727
|
for (const c of privateCands)
|
|
@@ -788,12 +1731,11 @@ export function checkLeak(paths, input, typed = [], stores = readStores(paths))
|
|
|
788
1731
|
break;
|
|
789
1732
|
}
|
|
790
1733
|
if (privateFp.size)
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
1734
|
+
for (const c of privateCands)
|
|
1735
|
+
if (windowHit(c, privateFp)) {
|
|
1736
|
+
result.private = true;
|
|
1737
|
+
break;
|
|
1738
|
+
}
|
|
797
1739
|
return result;
|
|
798
1740
|
}
|
|
799
1741
|
/**
|
|
@@ -849,6 +1791,24 @@ export function readFlowState(path) {
|
|
|
849
1791
|
const s = JSON.parse(readFileSync(path, 'utf8'));
|
|
850
1792
|
if (!s || s.v !== 1 || !Array.isArray(s.untrusted) || !Array.isArray(s.private))
|
|
851
1793
|
throw new Error('invalid flow state');
|
|
1794
|
+
if (s.credentialRead !== undefined && s.credentialRead !== true || s.credentialGrants !== undefined && (!Array.isArray(s.credentialGrants) || s.credentialGrants.length > 32 || !s.credentialGrants.every(v => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v))))
|
|
1795
|
+
throw new Error('invalid credential egress state');
|
|
1796
|
+
if (s.fragments !== undefined) {
|
|
1797
|
+
const f = s.fragments;
|
|
1798
|
+
if (!f || ![1, 2].includes(f.v))
|
|
1799
|
+
throw new Error('invalid fragment state');
|
|
1800
|
+
const valid = (r) => {
|
|
1801
|
+
if (!r || typeof r.group !== 'string' || !/^[0-9a-f]{32}$/.test(r.group) || typeof r.hash !== 'string' || !/^[0-9a-f]{32}$/.test(r.hash) ||
|
|
1802
|
+
!Number.isSafeInteger(r.offset) || r.offset < 0 || r.offset > MAX_TEXT || r.edge !== undefined && r.edge !== true)
|
|
1803
|
+
return false;
|
|
1804
|
+
if (f.v === 1)
|
|
1805
|
+
return r.size === undefined || Number.isSafeInteger(r.size) && r.size >= 1 && r.size <= 64;
|
|
1806
|
+
return typeof r.size === 'number' && Number.isSafeInteger(r.size) && (r.edge ? r.size >= 5 && r.size <= 64 : SHORT_SIZES.includes(r.size));
|
|
1807
|
+
};
|
|
1808
|
+
if (!Array.isArray(f.index) || f.index.length > MAX_FRAGMENT_INDEX || !f.index.every(valid) ||
|
|
1809
|
+
!Array.isArray(f.seen) || f.seen.length > MAX_FRAGMENT_SEEN || !f.seen.every(valid) || f.full !== undefined && f.full !== true)
|
|
1810
|
+
throw new Error('invalid fragment state');
|
|
1811
|
+
}
|
|
852
1812
|
return s;
|
|
853
1813
|
}
|
|
854
1814
|
catch (err) {
|
|
@@ -1010,6 +1970,32 @@ function targetSummary(toolName, input, policy, sensitivePayload) {
|
|
|
1010
1970
|
}
|
|
1011
1971
|
return targets.length ? targets.join(', ') + (omitted ? '; additional targets omitted — review full call' : '') : 'not identified in destination arguments; review full call';
|
|
1012
1972
|
}
|
|
1973
|
+
function credentialSession(state, stores) {
|
|
1974
|
+
return !!(state.credentialRead || state.private.some(s => s.secret) || state.fragments?.index.length || stores.secret.size || stores.tok.size);
|
|
1975
|
+
}
|
|
1976
|
+
function reservePublicRequest(paths, tool, input, checked, policy, env) {
|
|
1977
|
+
const digest = publicRequestDigest(tool, input);
|
|
1978
|
+
if (!digest || policy.mode !== 'ask' || !policy.credentialEgress?.allow.some(r => r.tool === tool && r.requestSha256 === digest))
|
|
1979
|
+
return;
|
|
1980
|
+
let grant;
|
|
1981
|
+
mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
1982
|
+
updateFlowState(paths.state, current => {
|
|
1983
|
+
const fresh = loadPolicy(env);
|
|
1984
|
+
if (fresh.mode !== policy.mode || !fresh.credentialEgress?.allow.some(r => r.tool === tool && r.requestSha256 === digest))
|
|
1985
|
+
return;
|
|
1986
|
+
if (current.full || current.credentialGrants?.includes(digest) || (current.credentialGrants?.length ?? 0) >= 32)
|
|
1987
|
+
return;
|
|
1988
|
+
const stores = readStores(paths), leak = checkLeak(paths, checked, current.intent, stores);
|
|
1989
|
+
const fragments = checkFragments(checked, current, stores.tok.size > 0, false);
|
|
1990
|
+
const stream = checkStream(checked, current.tails ?? {}, stores).leak;
|
|
1991
|
+
if (stream.secret || stream.private || stream.pii || leak.secret || leak.private || leak.pii || leak.incomplete || fragments.secret || fragments.review || fragments.unavailable)
|
|
1992
|
+
return;
|
|
1993
|
+
current.credentialGrants = [...(current.credentialGrants ?? []), digest];
|
|
1994
|
+
current.credentialRead = true;
|
|
1995
|
+
grant = digest;
|
|
1996
|
+
});
|
|
1997
|
+
return grant;
|
|
1998
|
+
}
|
|
1013
1999
|
export function flowPre(input, env = process.env, presentation = 'hook') {
|
|
1014
2000
|
const policy = loadPolicy(env);
|
|
1015
2001
|
if (policy.mode === 'off')
|
|
@@ -1020,36 +2006,69 @@ export function flowPre(input, env = process.env, presentation = 'hook') {
|
|
|
1020
2006
|
const toolInput = input.tool_input && typeof input.tool_input === 'object' && !Array.isArray(input.tool_input) ? input.tool_input : {};
|
|
1021
2007
|
if (!toolName || !sessionId)
|
|
1022
2008
|
return undefined;
|
|
2009
|
+
if (input.tool_input !== undefined && (!input.tool_input || typeof input.tool_input !== 'object' || Array.isArray(input.tool_input)))
|
|
2010
|
+
return { permissionDecision: 'deny', permissionDecisionReason: 'WARDEN flow guard: tool arguments must be a JSON object; malformed input cannot receive a public-request grant.' };
|
|
1023
2011
|
const dir = stateDir(env);
|
|
1024
2012
|
const paths = flowPaths(dir, sessionId);
|
|
2013
|
+
const publicEligible = input.public_request_eligible !== false && (input.tool_input === undefined || !!input.tool_input && typeof input.tool_input === 'object' && !Array.isArray(input.tool_input));
|
|
2014
|
+
if (publicEligible && presentation !== 'audit' && taskDelegation(toolName, toolInput, policy, env)) {
|
|
2015
|
+
log(paths, { event: 'pre', tool: safeToolName(toolName), decision: 'allow', reason: 'task_adapter_delegated',
|
|
2016
|
+
disposition: 'policy_allowed', approvalOutcome: 'unknown', mode: policy.mode });
|
|
2017
|
+
return undefined;
|
|
2018
|
+
}
|
|
1025
2019
|
const c = classifyCall(toolName, toolInput, cwd, policy, env);
|
|
1026
2020
|
const state = readFlowState(paths.state);
|
|
2021
|
+
// A click is what it lands on. This only adds caution, so it applies to operator-labelled tools too: a glob that
|
|
2022
|
+
// labels a screen server's screenshots must not switch off the check on its clicks.
|
|
2023
|
+
const screen = screenCall(toolName, toolInput, state);
|
|
2024
|
+
if (screen?.verb)
|
|
2025
|
+
c.labels.add('outbound');
|
|
2026
|
+
if (screen?.destroy)
|
|
2027
|
+
c.labels.add('destructive');
|
|
1027
2028
|
const defTaint = definitionTaint(dir, sessionId);
|
|
1028
2029
|
const tainted = state.untrusted.length > 0 || !!defTaint;
|
|
1029
2030
|
let decision;
|
|
1030
2031
|
let reason;
|
|
1031
2032
|
let sensitivePayload = false;
|
|
2033
|
+
let publicGrant;
|
|
1032
2034
|
if (c.protectedTouch) {
|
|
1033
2035
|
reason = 'protected_settings';
|
|
1034
2036
|
sensitivePayload = true;
|
|
1035
2037
|
decision = { permissionDecision: tainted ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: this call would change WARDEN's policy or Claude Code's hook/MCP settings${tainted ? ` after the session read outside text (${sourcesText(state, defTaint)})` : ''}.` };
|
|
1036
2038
|
}
|
|
1037
|
-
else if (tainted && (c.labels.has('outbound') || toolName.startsWith('mcp__'))) {
|
|
2039
|
+
else if (tainted && (c.labels.has('outbound') || toolName.startsWith('mcp__') || ['WebFetch', 'WebSearch'].includes(toolName))) {
|
|
1038
2040
|
const stores = readStores(paths);
|
|
1039
2041
|
const leak = checkLeak(paths, toolInput, state.intent, stores);
|
|
1040
2042
|
const stream = checkStream(toolInput, state.tails ?? {}, stores);
|
|
2043
|
+
let credential = credentialSession(state, stores);
|
|
1041
2044
|
leak.secret ||= stream.leak.secret;
|
|
1042
2045
|
leak.private ||= stream.leak.private;
|
|
1043
2046
|
leak.pii ||= stream.leak.pii;
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
2047
|
+
// Conservative observations include refused calls, and survive prompts/server changes. Re-evaluate under
|
|
2048
|
+
// the shared lock so simultaneous proxies cannot both send the two halves of the same credential.
|
|
2049
|
+
let fragments = {};
|
|
2050
|
+
if (policy.mode === 'audit' || presentation === 'audit') {
|
|
2051
|
+
fragments = checkFragments(toolInput, state, stores.tok.size > 0, false);
|
|
2052
|
+
}
|
|
2053
|
+
else {
|
|
2054
|
+
mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
2055
|
+
updateFlowState(paths.state, current => {
|
|
2056
|
+
const currentStores = readStores(paths);
|
|
2057
|
+
credential ||= credentialSession(current, currentStores);
|
|
2058
|
+
if (credential)
|
|
2059
|
+
current.credentialRead = true;
|
|
2060
|
+
const currentLeak = checkLeak(paths, toolInput, current.intent, currentStores);
|
|
2061
|
+
const currentStream = checkStream(toolInput, current.tails ?? {}, currentStores);
|
|
2062
|
+
leak.secret ||= currentLeak.secret || currentStream.leak.secret;
|
|
2063
|
+
leak.private ||= currentLeak.private || currentStream.leak.private;
|
|
2064
|
+
leak.pii ||= currentLeak.pii || currentStream.leak.pii;
|
|
2065
|
+
leak.incomplete ||= currentLeak.incomplete;
|
|
2066
|
+
fragments = checkFragments(toolInput, current, currentStores.tok.size > 0, true);
|
|
2067
|
+
current.tails = Object.fromEntries(Object.entries({ ...(current.tails ?? {}), ...currentStream.next }).slice(-MAX_TAIL_KEYS));
|
|
1050
2068
|
});
|
|
1051
2069
|
}
|
|
1052
|
-
|
|
2070
|
+
leak.secret ||= fragments.secret;
|
|
2071
|
+
sensitivePayload = !!(leak.secret || leak.private || leak.pii || leak.incomplete || credential || fragments.review || fragments.unavailable);
|
|
1053
2072
|
const dests = destinations(toolName, toolInput, policy, c.hosts);
|
|
1054
2073
|
const targetKnown = hasDestinationArgument(toolName, toolInput, policy);
|
|
1055
2074
|
const allowedHosts = targetKnown && dests.length > 0 && dests.every(d => allowedDestination(d, policy.allowedDestinations));
|
|
@@ -1057,10 +2076,27 @@ export function flowPre(input, env = process.env, presentation = 'hook') {
|
|
|
1057
2076
|
const userNamed = (targetKnown && namedByUser(dests, state.intent, state.intentRaw, contacts)) || (dests.length === 0 && keepsDestination(toolName, toolInput, policy));
|
|
1058
2077
|
if (c.opaqueShell && c.secret)
|
|
1059
2078
|
leak.secret = 'credential file referenced by shell code';
|
|
1060
|
-
if (leak.secret) {
|
|
2079
|
+
if (!leak.secret && fragments.review) {
|
|
2080
|
+
reason = 'secret_fragment';
|
|
2081
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: an argument matches a short fragment of a credential read this session. A short match can be coincidental, so review it before transmission. Named or allowed destinations and earlier approvals do not exempt this call.' };
|
|
2082
|
+
}
|
|
2083
|
+
else if (!leak.secret && screen?.paste) {
|
|
2084
|
+
reason = 'paste_after_secret';
|
|
2085
|
+
sensitivePayload = true;
|
|
2086
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: this call pastes the clipboard after the session read a credential (${screen.paste.src.slice(0, 80)}) and outside text (${sourcesText(state, defTaint)}). What a paste carries is not in its arguments, so the guard cannot check it. Allow it only if you know what the clipboard holds.` };
|
|
2087
|
+
}
|
|
2088
|
+
else if (leak.secret) {
|
|
1061
2089
|
reason = 'secret';
|
|
1062
2090
|
decision = { permissionDecision: 'deny', permissionDecisionReason: `WARDEN flow guard: this call carries a secret (${leak.secret}) after the session read outside text (${sourcesText(state, defTaint)}). Denied.` };
|
|
1063
2091
|
}
|
|
2092
|
+
else if (leak.incomplete) {
|
|
2093
|
+
reason = 'payload_incomplete';
|
|
2094
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: the bounded payload decoder could not finish checking this request. Review it before execution; no automatic public-request grant applies.' };
|
|
2095
|
+
}
|
|
2096
|
+
else if (fragments.unavailable) {
|
|
2097
|
+
reason = 'secret_tracking';
|
|
2098
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: short-secret tracking is incomplete or at capacity. This call after outside text requires review even for a named or allowed destination. Start a new session and reread private sources to restore tracking.' };
|
|
2099
|
+
}
|
|
1064
2100
|
else if (c.opaqueShell) {
|
|
1065
2101
|
reason = 'opaque_shell';
|
|
1066
2102
|
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: after outside text, shell code outside the literal read-only subset requires approval. Named or allowed hosts do not authorize shell programs, hooks, redirections or file uploads.' };
|
|
@@ -1075,6 +2111,20 @@ export function flowPre(input, env = process.env, presentation = 'hook') {
|
|
|
1075
2111
|
reason = 'private_text';
|
|
1076
2112
|
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: an argument matches a private-text fingerprint recorded during this session, after outside text (${sourcesText(state, defTaint)}), and the destination is not confirmed as user-named. The matching source was not retained. Check what is being sent.` };
|
|
1077
2113
|
}
|
|
2114
|
+
else if (credential && !screen?.guarded) {
|
|
2115
|
+
if (publicEligible && presentation !== 'audit' && !c.labels.has('destructive') && !guiOperation(toolName, toolInput))
|
|
2116
|
+
publicGrant = reservePublicRequest(paths, toolName, toolInput, toolInput, policy, env);
|
|
2117
|
+
if (!publicGrant) {
|
|
2118
|
+
reason = 'credential_egress';
|
|
2119
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: this session holds credentials and outside text. Review this outbound request before execution, regardless of fragment length, encoding or destination. This is a credential boundary, not a claim that its contents are a detected secret.' };
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
else if (screen?.guarded) {
|
|
2123
|
+
reason = 'screen_action';
|
|
2124
|
+
const actions = { pointer: 'a pointer action', paste: 'a clipboard paste', enter: 'Enter/Return', text: 'text input', keyboard: 'a keyboard action', control: 'an unresolved GUI control action' };
|
|
2125
|
+
const policyNote = policy.gui?.invalid ? ' Invalid GUI policy: strict behavior is active.' : policy.gui?.mode === 'policy' ? ' Legacy GUI allow rules cannot authorize actions from untrusted screen metadata. Use the built-in task adapter for constrained operations.' : '';
|
|
2126
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: ${actions[screen.guarded]} follows outside text (${sourcesText(state, defTaint)}). Screen labels, coordinates, images and key arguments do not establish what this action will send or change. Check the actual window, target and effect before allowing it. A named or allowed destination does not authorize GUI actions.${policyNote}` };
|
|
2127
|
+
}
|
|
1078
2128
|
else if (c.labels.has('outbound') && !allowedHosts && !userNamed) {
|
|
1079
2129
|
reason = 'unnamed_destination';
|
|
1080
2130
|
// Not only leaks: an outbound call acts with the user's authority (a message, a payment, an invite),
|
|
@@ -1085,17 +2135,35 @@ export function flowPre(input, env = process.env, presentation = 'hook') {
|
|
|
1085
2135
|
}
|
|
1086
2136
|
// A file the agent writes inside the project can leave later without another call: CI publishes docs/, a commit
|
|
1087
2137
|
// is pushed by a person. After outside text, a key read this session written into an ordinary project file asks;
|
|
1088
|
-
//
|
|
2138
|
+
// credential files (.env, .npmrc …) remain exempt. Ordinary writes need review after a credential read,
|
|
1089
2139
|
if (!decision && tainted && ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
|
|
1090
2140
|
const target = typeof toolInput.file_path === 'string' ? toolInput.file_path : typeof toolInput.notebook_path === 'string' ? toolInput.notebook_path : '';
|
|
1091
2141
|
if (!target || !isSecretPath(absPath(target, cwd))) {
|
|
1092
2142
|
const written = { content: toolInput.content, new_string: toolInput.new_string, new_source: toolInput.new_source,
|
|
1093
2143
|
edits: Array.isArray(toolInput.edits) ? toolInput.edits.map(e => (e && typeof e === 'object' ? e.new_string : undefined)) : undefined };
|
|
1094
2144
|
const leak = checkLeak(paths, written);
|
|
1095
|
-
|
|
2145
|
+
let fragments = {};
|
|
2146
|
+
if (policy.mode === 'audit' || presentation === 'audit')
|
|
2147
|
+
fragments = checkFragments(written, state, readFp(paths.tok).size > 0, false);
|
|
2148
|
+
else {
|
|
2149
|
+
mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
2150
|
+
updateFlowState(paths.state, current => { fragments = checkFragments(written, current, readFp(paths.tok).size > 0, true); });
|
|
2151
|
+
}
|
|
2152
|
+
leak.secret ||= fragments.secret;
|
|
2153
|
+
const credential = credentialSession(readFlowState(paths.state), readStores(paths));
|
|
2154
|
+
if (leak.secret || fragments.review || fragments.unavailable || leak.incomplete) {
|
|
1096
2155
|
reason = 'secret_to_file';
|
|
1097
2156
|
sensitivePayload = true;
|
|
1098
|
-
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: this write puts a secret (${leak.secret}) into a project file after the session read outside text (${sourcesText(state, defTaint)}). Files in the project can be committed or published later. Allow it only if you asked for this key to go into this file.` };
|
|
2157
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: leak.secret ? `WARDEN flow guard: this write puts a secret (${leak.secret}) into a project file after the session read outside text (${sourcesText(state, defTaint)}). Files in the project can be committed or published later. Allow it only if you asked for this key to go into this file.` : fragments.review ? 'WARDEN flow guard: this project-file write matches a short credential fragment. Review it before writing; project files can be published later.' : 'WARDEN flow guard: short-secret tracking is incomplete or at capacity. Review this project-file write after outside text; project files can be published later. Start a new session and reread private sources to restore tracking.' };
|
|
2158
|
+
}
|
|
2159
|
+
else if (credential) {
|
|
2160
|
+
if (publicEligible && presentation !== 'audit' && !c.labels.has('destructive'))
|
|
2161
|
+
publicGrant = reservePublicRequest(paths, toolName, toolInput, written, policy, env);
|
|
2162
|
+
if (!publicGrant) {
|
|
2163
|
+
reason = 'credential_egress';
|
|
2164
|
+
sensitivePayload = true;
|
|
2165
|
+
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: 'WARDEN flow guard: review this project-file write after credentials and outside text. Short or encoded data can leave later through published files; an exact reviewed public request is required for automatic execution.' };
|
|
2166
|
+
}
|
|
1099
2167
|
}
|
|
1100
2168
|
}
|
|
1101
2169
|
}
|
|
@@ -1104,7 +2172,21 @@ export function flowPre(input, env = process.env, presentation = 'hook') {
|
|
|
1104
2172
|
decision = { permissionDecision: policy.mode === 'deny' ? 'deny' : 'ask', permissionDecisionReason: `WARDEN flow guard: the session read outside text (${sourcesText(state, defTaint)}); this call changes or deletes things outside the project. Allow it only if you expected it.` };
|
|
1105
2173
|
}
|
|
1106
2174
|
if (decision) {
|
|
1107
|
-
|
|
2175
|
+
let targets = targetSummary(toolName, toolInput, policy, sensitivePayload);
|
|
2176
|
+
// The screen label is outside text: the prompt names only the guard's own word for it, never the label.
|
|
2177
|
+
const word = screen?.verb ?? screen?.destroy;
|
|
2178
|
+
if (word || screen?.via) {
|
|
2179
|
+
const ref = screen?.ref && /^[A-Za-z0-9_.:-]{1,24}$/.test(screen.ref) ? ` ${JSON.stringify(screen.ref)}` : '';
|
|
2180
|
+
const evidence = screen?.evidence === 'form' ? ` in a form with a "${word}" control; Enter may submit it`
|
|
2181
|
+
: screen?.evidence === 'identifier' ? ` whose accessibility identifier suggests "${word}"`
|
|
2182
|
+
: screen?.evidence === 'semantic' ? ` reported as a "${word}" control`
|
|
2183
|
+
: word ? ` whose label says "${word}"` : '';
|
|
2184
|
+
const via = screen?.via === 'coordinates' ? ' (coordinates intersect its reported geometry)'
|
|
2185
|
+
: screen?.via === 'focus' ? ' (reported focused element)' : '';
|
|
2186
|
+
const note = `screen element${ref}${evidence}${via}; last-snapshot context is untrusted, not a verified live target`;
|
|
2187
|
+
targets = targets.startsWith('not identified') ? note : `${note}; ${targets}`;
|
|
2188
|
+
}
|
|
2189
|
+
decision.permissionDecisionReason = `Action: ${safeToolName(toolName)}. Targets: ${targets}.\n${decision.permissionDecisionReason}`;
|
|
1108
2190
|
// These are guard decisions, not proof a dialog was shown or a human clicked it. Never infer
|
|
1109
2191
|
// approval from PostToolUse: execution can also follow automatic client permissions.
|
|
1110
2192
|
const disposition = policy.mode === 'audit' || presentation === 'audit' ? 'audit_only'
|
|
@@ -1112,6 +2194,8 @@ export function flowPre(input, env = process.env, presentation = 'hook') {
|
|
|
1112
2194
|
log(paths, { event: 'pre', tool: safeToolName(toolName), decision: decision.permissionDecision, reason, disposition,
|
|
1113
2195
|
approvalOutcome: 'unknown', mode: policy.mode, labels: [...c.labels] });
|
|
1114
2196
|
}
|
|
2197
|
+
if (publicGrant)
|
|
2198
|
+
log(paths, { event: 'pre', tool: safeToolName(toolName), decision: 'allow', reason: 'credential_public_request', disposition: 'policy_allowed', approvalOutcome: 'unknown', mode: policy.mode });
|
|
1115
2199
|
if (policy.mode === 'audit')
|
|
1116
2200
|
return undefined;
|
|
1117
2201
|
return decision;
|
|
@@ -1123,6 +2207,9 @@ export function flowApproval(input, outcome, env = process.env) {
|
|
|
1123
2207
|
return;
|
|
1124
2208
|
log(flowPaths(stateDir(env), input.session_id), { event: 'approval', tool: safeToolName(input.tool_name), approvalOutcome: outcome });
|
|
1125
2209
|
}
|
|
2210
|
+
export function flowScreenValidation(input, outcome, env = process.env) {
|
|
2211
|
+
log(flowPaths(stateDir(env), input.session_id), { event: 'screen_validation', tool: safeToolName(input.tool_name), outcome });
|
|
2212
|
+
}
|
|
1126
2213
|
export function flowPost(input, env = process.env) {
|
|
1127
2214
|
const policy = loadPolicy(env);
|
|
1128
2215
|
if (policy.mode === 'off')
|
|
@@ -1133,23 +2220,25 @@ export function flowPost(input, env = process.env) {
|
|
|
1133
2220
|
const toolInput = input.tool_input && typeof input.tool_input === 'object' && !Array.isArray(input.tool_input) ? input.tool_input : {};
|
|
1134
2221
|
if (!toolName || !sessionId)
|
|
1135
2222
|
return undefined;
|
|
1136
|
-
const text =
|
|
2223
|
+
const text = responseStrings(input.tool_response).join('\n');
|
|
1137
2224
|
const dir = stateDir(env);
|
|
1138
2225
|
const paths = flowPaths(dir, sessionId);
|
|
1139
2226
|
mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
1140
2227
|
const c = classifyCall(toolName, toolInput, cwd, policy, env);
|
|
1141
2228
|
const t = resultTaints(toolName, toolInput, text, cwd, policy, c);
|
|
2229
|
+
const resultError = input.tool_error !== undefined || !!(input.tool_response && typeof input.tool_response === 'object' && input.tool_response.isError === true);
|
|
1142
2230
|
// Errors are server-authored text even when a successful result is operator-labelled private.
|
|
1143
|
-
if (
|
|
2231
|
+
if (resultError)
|
|
1144
2232
|
t.untrusted = true;
|
|
1145
|
-
|
|
2233
|
+
// A screen server's result says what the next click lands on; its calls can reveal a credential read.
|
|
2234
|
+
const view = toolName.startsWith('mcp__') && !resultError ? screenFromResult(toolName, input.tool_response, text, toolInput) : undefined;
|
|
2235
|
+
const screenSecret = screenSecretRead(toolName, toolInput, view);
|
|
2236
|
+
const screenControl = screenCall(toolName, toolInput, EMPTY);
|
|
2237
|
+
if (!t.untrusted && !t.private && !view && !screenSecret && !screenControl)
|
|
1146
2238
|
return undefined;
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
appendFp(paths.pii, piiTokens(text).map(fpHash));
|
|
1151
|
-
appendFp(paths.tok, tokenFingerprints(secretTokens(text, c.secret)));
|
|
1152
|
-
}
|
|
2239
|
+
const values = t.private && text ? secretValues(text, c.secret) : [];
|
|
2240
|
+
const tokens = [...new Set(values.map(alnum))];
|
|
2241
|
+
const shortIndex = fragmentFingerprints(values);
|
|
1153
2242
|
const looksLikeInstruction = t.untrusted && INJECTION_HINTS.test(text.slice(0, MAX_TEXT));
|
|
1154
2243
|
if (looksLikeInstruction)
|
|
1155
2244
|
appendFp(paths.hint, hintWindows(instructionText(text)));
|
|
@@ -1159,8 +2248,41 @@ export function flowPost(input, env = process.env) {
|
|
|
1159
2248
|
const newResolved = contactTool && t.private && !t.untrusted ? resolvedContacts(input.tool_response) : [];
|
|
1160
2249
|
let firstTaint = false;
|
|
1161
2250
|
updateFlowState(paths.state, s => {
|
|
2251
|
+
let stored = true;
|
|
2252
|
+
if (screenSecret || t.private && (c.secret || values.length > 0))
|
|
2253
|
+
s.credentialRead = true;
|
|
2254
|
+
if (t.private && text) {
|
|
2255
|
+
// Fingerprints and fragment metadata are committed under the same lock as outbound observations.
|
|
2256
|
+
const legacy = s.fragments?.v === 1 || !s.fragments && readFp(paths.tok).size > 0;
|
|
2257
|
+
stored = appendFp(c.secret ? paths.secret : paths.fp, fingerprints(text));
|
|
2258
|
+
appendFp(paths.pii, piiTokens(text).map(fpHash));
|
|
2259
|
+
appendFp(paths.tok, tokenFingerprints(tokens));
|
|
2260
|
+
const f = s.fragments ?? { v: 2, index: [], seen: [] };
|
|
2261
|
+
const records = new Map(f.index.map(r => [fragmentKey(r), r]));
|
|
2262
|
+
for (const r of shortIndex)
|
|
2263
|
+
records.set(fragmentKey(r), r);
|
|
2264
|
+
f.index = [...records.values()].slice(0, MAX_FRAGMENT_INDEX);
|
|
2265
|
+
if (legacy || records.size > MAX_FRAGMENT_INDEX)
|
|
2266
|
+
f.full = true;
|
|
2267
|
+
s.fragments = f;
|
|
2268
|
+
}
|
|
1162
2269
|
if (t.private)
|
|
1163
2270
|
s.private.push({ tool: toolName, src: t.src.slice(0, 200), at, secret: c.secret });
|
|
2271
|
+
if (screenSecret)
|
|
2272
|
+
s.private.push({ tool: toolName, src: screenSecret, at, secret: true });
|
|
2273
|
+
if (screenControl || resultError && toolName.startsWith('mcp__')) {
|
|
2274
|
+
for (const snapshot of Object.values(s.screens ?? {}))
|
|
2275
|
+
snapshot.policyConsumed = true;
|
|
2276
|
+
if (screenControl?.guarded && screenControl.guarded !== 'pointer')
|
|
2277
|
+
s.guiInputSeen = true;
|
|
2278
|
+
}
|
|
2279
|
+
if (view) {
|
|
2280
|
+
const server = screenServer(toolName);
|
|
2281
|
+
const screens = { ...(s.screens ?? {}) };
|
|
2282
|
+
delete screens[server];
|
|
2283
|
+
screens[server] = { at, tool: toolName.slice(0, 200), ...view, revision: randomUUID() };
|
|
2284
|
+
s.screens = Object.fromEntries(Object.entries(screens).slice(-MAX_SCREENS));
|
|
2285
|
+
}
|
|
1164
2286
|
if (newResolved.length) {
|
|
1165
2287
|
s.resolved = [...(s.resolvedVersion === 2 ? s.resolved ?? [] : []), ...newResolved].slice(-MAX_RESOLVED);
|
|
1166
2288
|
s.resolvedVersion = 2;
|