create-walle 0.9.30 → 0.9.31
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/package.json +1 -1
- package/template/claude-task-manager/api-prompts.js +4 -4
- package/template/claude-task-manager/api-reviews.js +153 -3
- package/template/claude-task-manager/approval-agent.js +37 -5
- package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +666 -0
- package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
- package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
- package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
- package/template/claude-task-manager/lib/headless-term-service.js +12 -1
- package/template/claude-task-manager/lib/native-agent-model-args.js +118 -0
- package/template/claude-task-manager/lib/session-history.js +4 -1
- package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
- package/template/claude-task-manager/providers/claude-code.js +20 -1
- package/template/claude-task-manager/public/css/reviews.css +232 -0
- package/template/claude-task-manager/public/index.html +785 -134
- package/template/claude-task-manager/public/js/document-review-links.js +40 -4
- package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
- package/template/claude-task-manager/public/js/message-renderer.js +27 -1
- package/template/claude-task-manager/public/js/resume-state.js +186 -0
- package/template/claude-task-manager/public/js/reviews.js +377 -70
- package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
- package/template/claude-task-manager/public/js/session-search-utils.js +26 -0
- package/template/claude-task-manager/public/js/state-sync-client.js +95 -17
- package/template/claude-task-manager/public/js/stream-view.js +87 -20
- package/template/claude-task-manager/server.js +151 -6
- package/template/package.json +1 -1
- package/template/wall-e/api-walle.js +21 -3
- package/template/wall-e/chat.js +101 -8
- package/template/wall-e/llm/client.js +64 -5
- package/template/wall-e/llm/codex-cli.js +71 -23
- package/template/wall-e/llm/codex-cli.plugin.json +1 -0
- package/template/wall-e/llm/default-fallback.js +12 -6
- package/template/wall-e/llm/provider-error.js +7 -1
- package/template/wall-e/llm/provider-health-state.js +557 -11
- package/template/wall-e/llm/registry.js +6 -0
- package/template/wall-e/llm/routing-policy.js +255 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-walle",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.31",
|
|
4
4
|
"description": "CTM + Wall-E — AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, remote phone and tablet access, code/doc review, and an agent that learns from Slack, email & calendar.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-walle": "bin/create-walle.js"
|
|
@@ -28,6 +28,7 @@ const { queryPromptExecutions } = require('./lib/prompt-executions-query');
|
|
|
28
28
|
const { createIngestCooldown } = require('./lib/ingest-cooldown');
|
|
29
29
|
const { claudeFileSessionId, _usageMetadata } = require('./lib/jsonl-conversation-parser');
|
|
30
30
|
const structuredCapture = require('./lib/structured-capture');
|
|
31
|
+
const { coopSchedulerEnabled } = require('./lib/db-owner-cooperative-scheduler');
|
|
31
32
|
// AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
|
|
32
33
|
|
|
33
34
|
let dbMaintenanceRunner = null;
|
|
@@ -1852,10 +1853,9 @@ function _readResetChunkedRouteStats() {
|
|
|
1852
1853
|
|
|
1853
1854
|
function _chunkedImportFlags(sessionId, messages, fileSize, reason) {
|
|
1854
1855
|
// Gated on the coop scheduler: deferring rows only helps when the cooperative scheduler is driving
|
|
1855
|
-
// bounded slices (and the content-rows-backfill coop/legacy job fills the deferred rows).
|
|
1856
|
-
//
|
|
1857
|
-
|
|
1858
|
-
if (process.env.CTM_COOP_SCHEDULER !== '1') return { skipMessageRows: false, forceBlobWrite: false };
|
|
1856
|
+
// bounded slices (and the content-rows-backfill coop/legacy job fills the deferred rows). The feature
|
|
1857
|
+
// is ON by default; set CTM_COOP_SCHEDULER=0 to fall back to byte-for-byte legacy (rows written inline).
|
|
1858
|
+
if (!coopSchedulerEnabled()) return { skipMessageRows: false, forceBlobWrite: false };
|
|
1859
1859
|
const minMsgs = Math.max(1, Number(process.env.CTM_IMPORT_CHUNK_MIN_MSGS || 1000));
|
|
1860
1860
|
// ALSO trigger on file size: a giant rollout is tail-bounded at parse time (transcriptMaxBytes),
|
|
1861
1861
|
// so `messages.length` here can be small even though the session is huge and its inline row write
|
|
@@ -23,6 +23,56 @@ function isValidProjectPath(p) {
|
|
|
23
23
|
return true;
|
|
24
24
|
} catch { return false; }
|
|
25
25
|
}
|
|
26
|
+
// Resolve `filePath` within `project` and enforce containment AFTER following
|
|
27
|
+
// symlinks. A lexical `resolved.startsWith(root + sep)` check is not enough: a
|
|
28
|
+
// symlink that lives inside the repo can point at /etc/passwd (or ~/.ssh) and the
|
|
29
|
+
// lexical path still looks in-bounds. realpath canonicalizes the link target, so
|
|
30
|
+
// containment is checked against where the file ACTUALLY is.
|
|
31
|
+
// Returns the safe absolute path to read, or null if it escapes the project.
|
|
32
|
+
// A path that doesn't exist yet (ENOENT) can't be a symlink, so the lexical check
|
|
33
|
+
// already guarantees safety — return it so the caller can produce its own 404.
|
|
34
|
+
// Best-effort fallback when an html path doesn't resolve at the given root — e.g. a path
|
|
35
|
+
// relative to the repo root while the session cwd is a subdir. Searches tracked + untracked
|
|
36
|
+
// .html within the repo and returns the best suffix/basename match (stays inside the root).
|
|
37
|
+
function findHtmlInRepo(root, relPath) {
|
|
38
|
+
try {
|
|
39
|
+
const { execFileSync } = require('child_process');
|
|
40
|
+
const want = String(relPath || '').replace(/^\.?\//, '');
|
|
41
|
+
const base = path.basename(want);
|
|
42
|
+
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '*.html', '*.htm'],
|
|
43
|
+
{ cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }).split('\0').filter(Boolean);
|
|
44
|
+
let hit = out.find(f => f === want || f.endsWith('/' + want));
|
|
45
|
+
if (!hit) hit = out.find(f => path.basename(f) === base);
|
|
46
|
+
return hit ? path.join(root, hit) : null;
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function htmlNotFoundPage(filePath) {
|
|
51
|
+
const safe = String(filePath || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
52
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>File not found</title>
|
|
53
|
+
<style>body{font-family:-apple-system,system-ui,sans-serif;background:#1a1b26;color:#c0caf5;display:grid;place-items:center;height:100vh;margin:0}
|
|
54
|
+
.box{text-align:center;max-width:34rem;padding:2rem}h1{font-size:1.1rem;color:#f7768e}code{background:rgba(122,162,247,.12);padding:.15rem .4rem;border-radius:4px;font-size:.85em;word-break:break-all}p{color:#8b94bd;line-height:1.6;font-size:.9rem}</style>
|
|
55
|
+
</head><body><div class="box"><h1>File not found</h1>
|
|
56
|
+
<p><code>${safe}</code> isn't in this checkout. It may live in a different worktree or branch than the one this session is running in.</p></div></body></html>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveWithinProject(project, filePath) {
|
|
60
|
+
if (!project || typeof filePath !== 'string') return null;
|
|
61
|
+
const root = path.resolve(project);
|
|
62
|
+
const resolved = path.resolve(root, filePath);
|
|
63
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null; // lexical ../ escape
|
|
64
|
+
let realRoot;
|
|
65
|
+
try { realRoot = fs.realpathSync(root); }
|
|
66
|
+
catch { return null; }
|
|
67
|
+
let realFile;
|
|
68
|
+
try { realFile = fs.realpathSync(resolved); }
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e && e.code === 'ENOENT') return resolved; // lexically in-bounds, not yet on disk
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) return null; // symlink escape
|
|
74
|
+
return realFile;
|
|
75
|
+
}
|
|
26
76
|
function jsonResponse(res, code, data) {
|
|
27
77
|
const body = JSON.stringify(data);
|
|
28
78
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
|
@@ -364,9 +414,9 @@ function handleReviewApi(req, res, url) {
|
|
|
364
414
|
const ext = path.extname(filePath).toLowerCase();
|
|
365
415
|
const imageExts = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon', '.bmp': 'image/bmp' };
|
|
366
416
|
if (!imageExts[ext]) return jsonResponse(res, 400, { error: 'Only image files are supported' });
|
|
367
|
-
// Prevent path traversal
|
|
368
|
-
const resolved =
|
|
369
|
-
if (!resolved
|
|
417
|
+
// Prevent path traversal (incl. symlinks escaping the project — see resolveWithinProject)
|
|
418
|
+
const resolved = resolveWithinProject(project, filePath);
|
|
419
|
+
if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
|
|
370
420
|
try {
|
|
371
421
|
const data = fs.readFileSync(resolved);
|
|
372
422
|
res.writeHead(200, { 'Content-Type': imageExts[ext], 'Content-Length': data.length, 'Cache-Control': 'no-cache' });
|
|
@@ -462,6 +512,105 @@ function handleReviewApi(req, res, url) {
|
|
|
462
512
|
return jsonResponse(res, 200, { prompt });
|
|
463
513
|
}
|
|
464
514
|
|
|
515
|
+
// GET /api/reviews/documents?project=... — list reviewable markdown/text files in a
|
|
516
|
+
// project (powers the Review Hub "Documents" discovery column). Tracked + untracked-
|
|
517
|
+
// not-ignored, bounded, sorted most-recently-modified first.
|
|
518
|
+
if (p === '/api/reviews/documents' && m === 'GET') {
|
|
519
|
+
const project = url.searchParams.get('project');
|
|
520
|
+
if (!project) return jsonResponse(res, 400, { error: 'project required' });
|
|
521
|
+
if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
|
|
522
|
+
try {
|
|
523
|
+
const { execFileSync } = require('child_process');
|
|
524
|
+
const root = path.resolve(project);
|
|
525
|
+
const globs = ['*.md', '*.markdown', '*.mdown', '*.txt'];
|
|
526
|
+
// -z → NUL-separated so paths with spaces/specials survive (no core.quotePath issues).
|
|
527
|
+
const run = (args) => {
|
|
528
|
+
try {
|
|
529
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 })
|
|
530
|
+
.split('\0').filter(Boolean);
|
|
531
|
+
} catch { return []; }
|
|
532
|
+
};
|
|
533
|
+
const set = new Set([
|
|
534
|
+
...run(['ls-files', '-z', ...globs]),
|
|
535
|
+
...run(['ls-files', '-z', '--others', '--exclude-standard', ...globs]),
|
|
536
|
+
]);
|
|
537
|
+
let items = [...set].slice(0, 2000).map(rel => {
|
|
538
|
+
let mtime = 0;
|
|
539
|
+
try { mtime = fs.statSync(path.join(root, rel)).mtimeMs; } catch {}
|
|
540
|
+
return { path: rel, name: path.basename(rel), dir: path.dirname(rel), mtime };
|
|
541
|
+
});
|
|
542
|
+
items.sort((a, b) => b.mtime - a.mtime);
|
|
543
|
+
return jsonResponse(res, 200, { documents: items.slice(0, 300), total: set.size });
|
|
544
|
+
} catch (e) {
|
|
545
|
+
return jsonResponse(res, 500, { error: e.message });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// GET /api/reviews/serve-html?project=...&path=... — serve a project .html file so the
|
|
550
|
+
// file-router can open it in a new browser tab. Path-validated; no traversal outside project.
|
|
551
|
+
if (p === '/api/reviews/serve-html' && m === 'GET') {
|
|
552
|
+
const project = url.searchParams.get('project');
|
|
553
|
+
const filePath = url.searchParams.get('path');
|
|
554
|
+
if (!project || !filePath) return jsonResponse(res, 400, { error: 'project and path required' });
|
|
555
|
+
if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
|
|
556
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
557
|
+
if (ext !== '.html' && ext !== '.htm') return jsonResponse(res, 400, { error: 'Only .html files are supported' });
|
|
558
|
+
let resolved = resolveWithinProject(project, filePath);
|
|
559
|
+
if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
|
|
560
|
+
// Fallback: the path may be relative to the repo root while the session cwd is a
|
|
561
|
+
// subdir/worktree — search the repo for the file before giving up.
|
|
562
|
+
if (!fs.existsSync(resolved)) {
|
|
563
|
+
const found = findHtmlInRepo(path.resolve(project), filePath);
|
|
564
|
+
if (found) resolved = found;
|
|
565
|
+
}
|
|
566
|
+
// Sandbox the served file: arbitrary repo HTML rendered on the CTM origin (which holds
|
|
567
|
+
// the auth cookie). A bare `sandbox` blocks scripting AND assigns an opaque origin, so it
|
|
568
|
+
// can't run JS, read CTM storage, or make cookie-authed calls — inline CSS/images still render.
|
|
569
|
+
const htmlHeaders = (len) => ({
|
|
570
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
571
|
+
'Content-Length': len,
|
|
572
|
+
'Cache-Control': 'no-cache',
|
|
573
|
+
'X-Content-Type-Options': 'nosniff',
|
|
574
|
+
'Content-Security-Policy': 'sandbox',
|
|
575
|
+
});
|
|
576
|
+
let data;
|
|
577
|
+
try { data = fs.readFileSync(resolved); }
|
|
578
|
+
catch {
|
|
579
|
+
// Friendly in-tab page instead of a scary "unreachable file" / bare text 404.
|
|
580
|
+
const body = Buffer.from(htmlNotFoundPage(filePath));
|
|
581
|
+
res.writeHead(404, htmlHeaders(body.length));
|
|
582
|
+
res.end(body);
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
res.writeHead(200, htmlHeaders(data.length));
|
|
586
|
+
res.end(data);
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// POST /api/reviews/reveal {project, path} — reveal a project file in Finder (macOS
|
|
591
|
+
// `open -R`). Path-validated; best-effort (the file-router menu's "Reveal" action).
|
|
592
|
+
if (p === '/api/reviews/reveal' && m === 'POST') {
|
|
593
|
+
readBody(req).then(body => {
|
|
594
|
+
const project = String(body.project || '');
|
|
595
|
+
const filePath = String(body.path || '');
|
|
596
|
+
if (!project || !filePath) return jsonResponse(res, 400, { error: 'project and path required' });
|
|
597
|
+
if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
|
|
598
|
+
const resolved = resolveWithinProject(project, filePath);
|
|
599
|
+
if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
|
|
600
|
+
if (!fs.existsSync(resolved)) return jsonResponse(res, 404, { error: 'File not found' });
|
|
601
|
+
try {
|
|
602
|
+
const { execFile } = require('child_process');
|
|
603
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
604
|
+
const args = process.platform === 'darwin' ? ['-R', resolved] : [path.dirname(resolved)];
|
|
605
|
+
execFile(cmd, args, () => {});
|
|
606
|
+
jsonResponse(res, 200, { ok: true });
|
|
607
|
+
} catch (e) {
|
|
608
|
+
jsonResponse(res, 500, { error: e.message });
|
|
609
|
+
}
|
|
610
|
+
}).catch(e => jsonResponse(res, 400, { error: e.message }));
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
465
614
|
return false; // Not handled
|
|
466
615
|
}
|
|
467
616
|
|
|
@@ -508,4 +657,5 @@ module.exports = {
|
|
|
508
657
|
_sanitizeReviewCommentInput: sanitizeReviewCommentInput,
|
|
509
658
|
_sanitizeReviewCommentUpdate: sanitizeReviewCommentUpdate,
|
|
510
659
|
_documentReview: documentReview,
|
|
660
|
+
_resolveWithinProject: resolveWithinProject,
|
|
511
661
|
};
|
|
@@ -250,6 +250,24 @@ function getApproveKeystroke(context, options = {}) {
|
|
|
250
250
|
return context.hasAllowAll ? '2' : '1';
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// Fallback tool-name derivation when no tool header is recognized above the
|
|
254
|
+
// prompt: read it from the durable grant option ("Yes, and don't ask again for
|
|
255
|
+
// <X> commands in <dir>" / "Yes, allow reading from <dir>") so an unrecognized
|
|
256
|
+
// tool is still targetable by a heuristic/learned rule instead of collapsing to
|
|
257
|
+
// 'Unknown'. `lines` is the trimmed, non-empty line array; `proceedIdx` the
|
|
258
|
+
// "Do you want to…?" line. Pure.
|
|
259
|
+
function _toolNameFromDurableOption(lines, proceedIdx) {
|
|
260
|
+
for (let i = proceedIdx + 1; i < Math.min(proceedIdx + 8, lines.length); i++) {
|
|
261
|
+
const l = String(lines[i] || '');
|
|
262
|
+
const m = l.match(/don'?t ask again for\s+(.+?)\s+commands?\b/i);
|
|
263
|
+
if (m && m[1]) return m[1].trim();
|
|
264
|
+
if (/allow\s+reading\s+from\b/i.test(l)) return 'Read';
|
|
265
|
+
const a = l.match(/allow\s+all\s+(\w+)\b/i);
|
|
266
|
+
if (a && a[1]) return a[1].trim();
|
|
267
|
+
}
|
|
268
|
+
return '';
|
|
269
|
+
}
|
|
270
|
+
|
|
253
271
|
function _parseGenericApprovalContext(cleanText, providerId) {
|
|
254
272
|
const lines = String(cleanText || '').split('\n').map(l => l.trim()).filter(Boolean);
|
|
255
273
|
if (!lines.length) return null;
|
|
@@ -284,12 +302,16 @@ function _parseGenericApprovalContext(cleanText, providerId) {
|
|
|
284
302
|
const contextLines = [];
|
|
285
303
|
for (let i = proceedIdx - 1; i >= Math.max(0, proceedIdx - 30); i--) {
|
|
286
304
|
const line = lines[i];
|
|
287
|
-
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
|
|
305
|
+
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
|
|
288
306
|
toolName = line.trim();
|
|
289
307
|
break;
|
|
290
308
|
}
|
|
291
309
|
contextLines.unshift(line);
|
|
292
310
|
}
|
|
311
|
+
if (toolName === 'Generic approval') {
|
|
312
|
+
const durable = _toolNameFromDurableOption(lines, proceedIdx);
|
|
313
|
+
if (durable) toolName = durable;
|
|
314
|
+
}
|
|
293
315
|
|
|
294
316
|
const fullContext = lines.slice(Math.max(0, proceedIdx - 20), Math.min(lines.length, proceedIdx + 12)).join('\n');
|
|
295
317
|
return {
|
|
@@ -373,7 +395,7 @@ function parseApprovalContext(cleanText, providerId) {
|
|
|
373
395
|
for (let i = endIdx - 1; i >= Math.max(0, endIdx - 30); i--) {
|
|
374
396
|
const line = lines[i];
|
|
375
397
|
// Detect tool headers (Claude Code shows "Bash command", "⏺ Bash(...)", "Edit", etc.)
|
|
376
|
-
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
|
|
398
|
+
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
|
|
377
399
|
toolName = line.trim();
|
|
378
400
|
break;
|
|
379
401
|
}
|
|
@@ -403,6 +425,10 @@ function parseApprovalContext(cleanText, providerId) {
|
|
|
403
425
|
if (fileOp) { toolName = fileOp.toolName; fileOpCommand = fileOp.command; }
|
|
404
426
|
}
|
|
405
427
|
|
|
428
|
+
// Still no header → derive a name from the durable grant option so the tool is
|
|
429
|
+
// targetable instead of 'Unknown' (mirror of the claude-code provider path).
|
|
430
|
+
if (!toolName) toolName = _toolNameFromDurableOption(lines, proceedIdx);
|
|
431
|
+
|
|
406
432
|
// Bash approvals render the command followed by one dimmed prose description
|
|
407
433
|
// line; drop it from the command so titles/signatures stay command-shaped
|
|
408
434
|
// (mirror of the claude-code provider parse path).
|
|
@@ -1150,8 +1176,14 @@ function reviewWithHeuristics(context) {
|
|
|
1150
1176
|
// "mcp__…" / "(mcp)" command text.
|
|
1151
1177
|
const isMcp = /^(?:plugin:|mcp__|mcp\b)/i.test(tool) || /\(mcp\)/i.test(cmd);
|
|
1152
1178
|
if (isMcp) {
|
|
1153
|
-
|
|
1154
|
-
|
|
1179
|
+
// Boundaries are `(?<![a-z0-9]) … (?![a-z0-9])` rather than `\b`: snake_case
|
|
1180
|
+
// tool ids (mcp__server__get_thread) glue the verb to the noun with `_`, which
|
|
1181
|
+
// IS a regex word char, so `\bget\b` never fired and every read-only snake_case
|
|
1182
|
+
// MCP op (Gmail/Calendar/Drive/wall-e) fell through to the medium verifier path.
|
|
1183
|
+
// The class treats `_`, `-`, space, `:`, and start/end as separators while still
|
|
1184
|
+
// rejecting alnum-glued false positives (`get` in "target", `draft` in "drafts").
|
|
1185
|
+
const readOnlyMcp = /(?<![a-z0-9])(navigate(?:_back)?|snapshot|take_screenshot|screenshot|read|list|get|query|search|fetch|download|find|view|suggest|stats|status|brief|ask|console_messages|network_requests?|wait_for|hover|tabs|resolve[-_ ]?library[-_ ]?id|get[-_ ]?library[-_ ]?docs|browser_snapshot|browser_navigate)(?![a-z0-9])/i;
|
|
1186
|
+
const mutatingMcp = /(?<![a-z0-9])(click|type|fill|drag|drop|file_upload|upload|run_code|evaluate|press_key|select_option|handle_dialog|write|create|delete|remove|update|send|post|install|deploy|exec|label|unlabel|respond|copy|move|add|set|insert|draft|authenticate|complete|reconnect|ingest|rebuild|export|remember|put|patch)(?![a-z0-9])/i;
|
|
1155
1187
|
if (readOnlyMcp.test(tool) && !mutatingMcp.test(tool)) {
|
|
1156
1188
|
return { decision: 'approve', reasoning: 'Read-only MCP operation (heuristic)', riskLevel: 'low',
|
|
1157
1189
|
ruleLabel: context.toolName || 'MCP read-only operation',
|
|
@@ -1169,7 +1201,7 @@ function reviewWithHeuristics(context) {
|
|
|
1169
1201
|
// because Edit/Write diffs may contain code with "drop table" or "rm -rf" as
|
|
1170
1202
|
// string literals — those are code content, not dangerous operations).
|
|
1171
1203
|
const lowRisk = [
|
|
1172
|
-
/^(read|glob|grep|webfetch|notebookedit)/, // Read-only tools
|
|
1204
|
+
/^(read|glob|grep|webfetch|web ?search|notebookedit)/, // Read-only tools (incl. Web Search — read-only, no side effects, same tier as WebFetch)
|
|
1173
1205
|
/^(edit|write)\b/, // Edit/Write to project files — normal dev workflow
|
|
1174
1206
|
];
|
|
1175
1207
|
for (const re of lowRisk) {
|