create-walle 0.9.31 → 0.9.32

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.
Files changed (99) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/api-prompts.js +406 -1
  3. package/template/claude-task-manager/api-reviews.js +104 -6
  4. package/template/claude-task-manager/approval-agent.js +293 -27
  5. package/template/claude-task-manager/db.js +77 -13
  6. package/template/claude-task-manager/docs/codex-app-server-approvals.md +35 -1
  7. package/template/claude-task-manager/docs/postgres-concurrency-dgx-spark.html +407 -0
  8. package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +24 -23
  9. package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
  10. package/template/claude-task-manager/git-utils.js +164 -26
  11. package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
  12. package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
  13. package/template/claude-task-manager/lib/agent-presets.js +12 -0
  14. package/template/claude-task-manager/lib/agent-version-service.js +650 -0
  15. package/template/claude-task-manager/lib/approval-drift.js +57 -0
  16. package/template/claude-task-manager/lib/approval-hook.js +13 -0
  17. package/template/claude-task-manager/lib/auth-rules.js +6 -0
  18. package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
  19. package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
  20. package/template/claude-task-manager/lib/document-review.js +75 -1
  21. package/template/claude-task-manager/lib/escalation-review.js +33 -3
  22. package/template/claude-task-manager/lib/headless-term-service.js +42 -8
  23. package/template/claude-task-manager/lib/mcp-risk.js +128 -0
  24. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
  25. package/template/claude-task-manager/lib/native-agent-model-args.js +31 -0
  26. package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
  27. package/template/claude-task-manager/lib/permission-match.js +77 -5
  28. package/template/claude-task-manager/lib/read-pool-client.js +15 -6
  29. package/template/claude-task-manager/lib/restore-backoff.js +82 -0
  30. package/template/claude-task-manager/lib/runtime-registry.js +1 -0
  31. package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
  32. package/template/claude-task-manager/lib/session-capture.js +33 -6
  33. package/template/claude-task-manager/lib/session-history.js +56 -2
  34. package/template/claude-task-manager/lib/session-jobs.js +19 -0
  35. package/template/claude-task-manager/lib/session-restore.js +5 -4
  36. package/template/claude-task-manager/lib/session-standup.js +2 -0
  37. package/template/claude-task-manager/lib/session-stream.js +9 -0
  38. package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
  39. package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
  40. package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
  41. package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
  42. package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
  43. package/template/claude-task-manager/package.json +1 -1
  44. package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
  45. package/template/claude-task-manager/providers/codex-mcp.js +23 -20
  46. package/template/claude-task-manager/providers/codex.js +65 -1
  47. package/template/claude-task-manager/public/css/prompts.css +345 -0
  48. package/template/claude-task-manager/public/css/reviews.css +16 -40
  49. package/template/claude-task-manager/public/css/walle-session.css +90 -26
  50. package/template/claude-task-manager/public/index.html +1251 -299
  51. package/template/claude-task-manager/public/js/document-review-links.js +215 -9
  52. package/template/claude-task-manager/public/js/message-renderer.js +19 -0
  53. package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
  54. package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
  55. package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
  56. package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
  57. package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
  58. package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
  59. package/template/claude-task-manager/public/js/prompts.js +683 -215
  60. package/template/claude-task-manager/public/js/reviews.js +28 -54
  61. package/template/claude-task-manager/public/js/session-search-utils.js +55 -0
  62. package/template/claude-task-manager/public/js/state-sync-client.js +40 -1
  63. package/template/claude-task-manager/public/js/stream-view.js +6 -0
  64. package/template/claude-task-manager/public/js/walle-session.js +183 -32
  65. package/template/claude-task-manager/public/js/walle.js +21 -9
  66. package/template/claude-task-manager/public/m/app.css +117 -0
  67. package/template/claude-task-manager/public/m/app.js +309 -3
  68. package/template/claude-task-manager/public/m/index.html +56 -1
  69. package/template/claude-task-manager/server.js +573 -299
  70. package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
  71. package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
  72. package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
  73. package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
  74. package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
  75. package/template/package.json +1 -1
  76. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
  77. package/template/wall-e/brain.js +50 -5
  78. package/template/wall-e/chat/force-compact.js +73 -0
  79. package/template/wall-e/chat.js +54 -19
  80. package/template/wall-e/coding/action-memory-policy.js +120 -1
  81. package/template/wall-e/coding/compaction-service.js +10 -3
  82. package/template/wall-e/coding/model-router.js +116 -0
  83. package/template/wall-e/coding-orchestrator.js +303 -11
  84. package/template/wall-e/coding-prompts.js +1 -1
  85. package/template/wall-e/docs/skill-self-heal-design.html +429 -0
  86. package/template/wall-e/llm/client.js +53 -3
  87. package/template/wall-e/llm/default-fallback.js +59 -5
  88. package/template/wall-e/llm/mlx-worker.js +0 -0
  89. package/template/wall-e/llm/mlx.js +46 -12
  90. package/template/wall-e/llm/provider-error.js +2 -2
  91. package/template/wall-e/llm/provider-health-state.js +6 -2
  92. package/template/wall-e/llm/tool-call-validator.js +156 -0
  93. package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
  94. package/template/wall-e/skills/skill-planner.js +72 -5
  95. package/template/wall-e/tools/command-registry.js +34 -0
  96. package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
  97. package/template/wall-e/training/mlx_lora_launch.py +196 -0
  98. package/template/wall-e/training/training-coexistence.sh +87 -0
  99. package/template/wall-e/training/training-mem-guard.sh +76 -0
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const db = require('./db');
5
6
  const gitUtils = require('./git-utils');
6
7
  const documentReview = require('./lib/document-review');
@@ -31,14 +32,27 @@ function isValidProjectPath(p) {
31
32
  // Returns the safe absolute path to read, or null if it escapes the project.
32
33
  // A path that doesn't exist yet (ENOENT) can't be a symlink, so the lexical check
33
34
  // 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) {
35
+ // The repo's worktree roots, main-style checkouts first (work usually lands there), capped.
36
+ function htmlWorktreeRoots(projectRoot, cap = 20) {
37
+ try {
38
+ const { execFileSync } = require('child_process');
39
+ const out = execFileSync('git', ['worktree', 'list', '--porcelain'],
40
+ { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
41
+ const roots = [];
42
+ for (const line of out.split('\n')) {
43
+ if (line.startsWith('worktree ')) roots.push(line.slice('worktree '.length).trim());
44
+ }
45
+ const isWorktreeSubdir = (pth) => /\/\.(?:claude|walle)\/worktrees\/|\/\.worktrees\//.test(pth);
46
+ roots.sort((a, b) => (isWorktreeSubdir(a) ? 1 : 0) - (isWorktreeSubdir(b) ? 1 : 0));
47
+ return roots.slice(0, Math.max(1, cap));
48
+ } catch { return []; }
49
+ }
50
+
51
+ // Find an .html file (tracked + untracked) within a single worktree `root` by exact relative
52
+ // path, suffix, or basename.
53
+ function findHtmlInRoot(root, want, base) {
38
54
  try {
39
55
  const { execFileSync } = require('child_process');
40
- const want = String(relPath || '').replace(/^\.?\//, '');
41
- const base = path.basename(want);
42
56
  const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '*.html', '*.htm'],
43
57
  { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }).split('\0').filter(Boolean);
44
58
  let hit = out.find(f => f === want || f.endsWith('/' + want));
@@ -47,6 +61,35 @@ function findHtmlInRepo(root, relPath) {
47
61
  } catch { return null; }
48
62
  }
49
63
 
64
+ // Best-effort fallback when an html path doesn't resolve at the given root — e.g. a path
65
+ // relative to the repo root while the session cwd is a subdir, OR a file that lives in a
66
+ // SIBLING worktree of the same repo (work landed on main while the session runs in a feature
67
+ // worktree). Searches the own worktree first, then the repo's others; returns a path under
68
+ // the user's HOME or null.
69
+ function findHtmlInRepo(root, relPath) {
70
+ const want = String(relPath || '').replace(/^\.?\//, '');
71
+ if (!want) return null;
72
+ const base = path.basename(want);
73
+ const home = process.env.HOME || '';
74
+ const underHome = (pth) => {
75
+ if (!home) return false;
76
+ const r = path.resolve(pth);
77
+ return r === home || r.startsWith(home + path.sep);
78
+ };
79
+ const own = findHtmlInRoot(root, want, base);
80
+ if (own && underHome(own)) return own;
81
+ let realRoot = '';
82
+ try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
83
+ for (const wt of htmlWorktreeRoots(root)) {
84
+ let realWt = '';
85
+ try { realWt = fs.realpathSync(wt); } catch { realWt = path.resolve(wt); }
86
+ if (realWt === realRoot) continue; // own worktree already searched
87
+ const hit = findHtmlInRoot(wt, want, base);
88
+ if (hit && underHome(hit)) return hit;
89
+ }
90
+ return null;
91
+ }
92
+
50
93
  function htmlNotFoundPage(filePath) {
51
94
  const safe = String(filePath || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
52
95
  return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>File not found</title>
@@ -73,6 +116,40 @@ function resolveWithinProject(project, filePath) {
73
116
  if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) return null; // symlink escape
74
117
  return realFile;
75
118
  }
119
+
120
+ // The per-session scratchpad lives OUTSIDE the project root AND outside HOME:
121
+ // <tmp>/claude-<uid>/<project-slug>/<session>/scratchpad/<file>.html
122
+ // (e.g. <tmpdir>/claude-<uid>/.../scratchpad/plan.html). Agents author HTML there and
123
+ // print the absolute path into session output; clicking it built a serve-html URL that
124
+ // 403'd because resolveWithinProject (project- and HOME-scoped) rejected it. Serve these
125
+ // too — same trust level as project HTML (same uid, mode-0700 temp dir), behind the same
126
+ // .html + sandbox-CSP guards, with realpath containment to block a symlink escaping the
127
+ // scratchpad. Returns the safe absolute path to read, or null.
128
+ function scratchpadRoots() {
129
+ let uid = '';
130
+ try { if (typeof process.getuid === 'function') uid = String(process.getuid()); } catch {}
131
+ if (!uid) return [];
132
+ const roots = new Set();
133
+ // os.tmpdir() (launchd gives a per-user /var/folders/... TMPDIR) plus the /tmp aliases
134
+ // Claude Code actually uses; realpath collapses /tmp → /private/tmp on macOS and dedupes.
135
+ for (const base of [os.tmpdir(), '/tmp', '/private/tmp']) {
136
+ try { roots.add(fs.realpathSync(path.join(base, 'claude-' + uid))); } catch {}
137
+ }
138
+ return [...roots];
139
+ }
140
+
141
+ function resolveWithinScratchpad(filePath) {
142
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) return null;
143
+ const roots = scratchpadRoots();
144
+ if (!roots.length) return null;
145
+ let realFile;
146
+ try { realFile = fs.realpathSync(filePath); } // must exist; no pre-create 404 for scratchpad
147
+ catch { return null; }
148
+ for (const root of roots) {
149
+ if (realFile === root || realFile.startsWith(root + path.sep)) return realFile;
150
+ }
151
+ return null;
152
+ }
76
153
  function jsonResponse(res, code, data) {
77
154
  const body = JSON.stringify(data);
78
155
  res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
@@ -284,6 +361,22 @@ function handleReviewApi(req, res, url) {
284
361
  return true;
285
362
  }
286
363
 
364
+ // POST /api/reviews/verify-commits — given a repo and a list of candidate SHAs
365
+ // (mined from session output), return only the ones that are real commits, with
366
+ // their short hash + subject. Lets the client linkify commit references safely.
367
+ if (p === '/api/reviews/verify-commits' && m === 'POST') {
368
+ readBody(req).then(body => {
369
+ const project = body && body.project;
370
+ const shas = (body && Array.isArray(body.shas)) ? body.shas.slice(0, 50) : [];
371
+ if (!project) return jsonResponse(res, 400, { error: 'project required' });
372
+ if (!isValidProjectPath(project)) return jsonResponse(res, 403, { error: 'Invalid project path' });
373
+ gitUtils.verifyCommits(project, shas)
374
+ .then(commits => jsonResponse(res, 200, { commits }))
375
+ .catch(e => jsonResponse(res, 500, { error: e.message }));
376
+ }).catch(e => jsonResponse(res, 400, { error: e.message }));
377
+ return true;
378
+ }
379
+
287
380
  // GET /api/reviews/diff-stat?project=...&base=...
288
381
  if (p === '/api/reviews/diff-stat' && m === 'GET') {
289
382
  const project = url.searchParams.get('project');
@@ -556,6 +649,9 @@ function handleReviewApi(req, res, url) {
556
649
  const ext = path.extname(filePath).toLowerCase();
557
650
  if (ext !== '.html' && ext !== '.htm') return jsonResponse(res, 400, { error: 'Only .html files are supported' });
558
651
  let resolved = resolveWithinProject(project, filePath);
652
+ // The file may be agent-authored HTML in the session scratchpad (absolute /tmp path,
653
+ // outside the project and HOME). realpath-validated to the user's claude-<uid> temp dir.
654
+ if (!resolved) resolved = resolveWithinScratchpad(filePath);
559
655
  if (!resolved) return jsonResponse(res, 403, { error: 'Invalid file path' });
560
656
  // Fallback: the path may be relative to the repo root while the session cwd is a
561
657
  // subdir/worktree — search the repo for the file before giving up.
@@ -658,4 +754,6 @@ module.exports = {
658
754
  _sanitizeReviewCommentUpdate: sanitizeReviewCommentUpdate,
659
755
  _documentReview: documentReview,
660
756
  _resolveWithinProject: resolveWithinProject,
757
+ _resolveWithinScratchpad: resolveWithinScratchpad,
758
+ _findHtmlInRepo: findHtmlInRepo,
661
759
  };
@@ -13,6 +13,7 @@ const { commandHead } = require('./lib/escalation-review');
13
13
  const { callBackgroundLlm } = require('./lib/background-llm');
14
14
  const { verifyIfEnabled } = require('./lib/auto-approval-verifier');
15
15
  const { matchPermission } = require('./lib/permission-match');
16
+ const mcpRisk = require('./lib/mcp-risk');
16
17
  const approvalAiRefinement = require('./lib/approval-ai-refinement');
17
18
 
18
19
  // Dangerous-command blocklist: when enabled, any command matching
@@ -612,6 +613,17 @@ function cardTextFingerprint(cleanText) {
612
613
  function normalizeCommandSignature(toolName, command) {
613
614
  const tool = (toolName || '').replace(/^[⏺●\s]+/, '').trim();
614
615
  const cmd = (command || '').trim();
616
+
617
+ // Network-access approvals: the HOST is the meaningful, granular unit, so keep it
618
+ // in the signature ("network <host>"). Otherwise the non-Bash branch below would
619
+ // collapse every host to the bare tool name 'network' → one all-hosts group and a
620
+ // dangerously broad Network(*) rule. Runs before the empty-cmd guard so a host that
621
+ // only survives in the synthesized command is still captured.
622
+ if (/^network\b/i.test(tool)) {
623
+ const host = (cmd.match(/[a-z0-9-]+(?:\.[a-z0-9-]+)+/i) || [])[0];
624
+ return host ? `network ${host.toLowerCase()}` : 'network';
625
+ }
626
+
615
627
  if (!cmd) return tool.toLowerCase();
616
628
 
617
629
  // For non-Bash tools (Edit, Write, Read, etc.), signature is just the tool name
@@ -904,29 +916,121 @@ function _buildSessionContext(session) {
904
916
  return { goal, cwd };
905
917
  }
906
918
 
919
+ // Hard ceiling on the auto-approval verifier's LLM round-trip. The inner builtin
920
+ // verifier already has its own ~20s budget (lib/auto-approval-verifier BUILTIN_TIMEOUT_MS)
921
+ // and the Wall-E status fetch a 2.5s AbortSignal — but the await at the call sites had no
922
+ // overall guard, so a provider that ignores its abort signal could strand a live approval
923
+ // prompt indefinitely (the symptom behind the stuck Codex `search_code` banner). This sits
924
+ // just above the inner budget: it normally never fires (the inner timeout wins, preserving
925
+ // the verifier verdict) and only trips on a true hang.
926
+ const VERIFIER_OVERALL_TIMEOUT_MS = 25_000;
927
+
928
+ // Resolve `promise`, but never settle later than `ms`. On timeout OR rejection, resolve to
929
+ // `fallback`. The verifier is fail-OPEN (a disabled/unknown verdict → approve), so the safe
930
+ // fallback for a wedged verifier is the same "no block" sentinel — a stuck banner is worse
931
+ // than approving a medium-risk op the blocklist already cleared.
932
+ function _withTimeout(promise, ms, fallback) {
933
+ let timer;
934
+ const guard = new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); });
935
+ return Promise.race([
936
+ Promise.resolve(promise).then(
937
+ (v) => { clearTimeout(timer); return v; },
938
+ () => { clearTimeout(timer); return fallback; },
939
+ ),
940
+ guard,
941
+ ]);
942
+ }
943
+
944
+ // Verifier posture (proposal P-E). The LLM verifier is fail-OPEN by design — when
945
+ // it can't produce a confident "unsafe" verdict (disabled, timeout, errored,
946
+ // 'unknown') the approval proceeds. That is correct for the bounded-blast-radius
947
+ // MEDIUM tier, but for HIGH-risk commands it means "approved blind" whenever the
948
+ // verifier can't vouch — which is exactly what happens to Codex sessions where the
949
+ // background verifier ~100% times out. So HIGH risk fails CLOSED: unless the
950
+ // verifier POSITIVELY cleared it (enabled + verdict==='safe'), escalate to the
951
+ // human instead of auto-approving. The dangerous-command blocklist remains the
952
+ // deterministic hard gate above all of this; this is the second net for the
953
+ // high-risk heuristic patterns (rm -rf /, --force, drop table, sudo, dd, …).
954
+ function _highRiskNeedsHumanWhenUnverified(riskLevel, verifier) {
955
+ if (riskLevel !== 'high') return false;
956
+ const positivelyCleared = !!(verifier && verifier.enabled && verifier.verdict === 'safe');
957
+ return !positivelyCleared;
958
+ }
959
+
960
+ // Approval posture (proposal P-G) — one global knob over the UNCERTAIN MEDIUM tier
961
+ // only. It NEVER weakens the hard floors: the dangerous-command blocklist, deny
962
+ // rules, the HIGH-risk fail-closed gate (_highRiskNeedsHumanWhenUnverified), and
963
+ // forced egress escalation all run regardless of posture. User-allowed commands and
964
+ // learned-rule matches are explicit trust and are never held. Low risk always
965
+ // auto-approves. So posture moves exactly one boundary: what to do with a
966
+ // pure-heuristic ('auto') MEDIUM-risk action.
967
+ // - cautious → hold it for the human (escalate) instead of fail-open approving
968
+ // - balanced → today's behavior (verifier second-opinion, then approve)
969
+ // - autonomous → trust the heuristic and skip the LLM verifier round-trip for medium
970
+ const APPROVAL_POSTURES = new Set(['cautious', 'balanced', 'autonomous']);
971
+ function _approvalPosture() {
972
+ try {
973
+ if (!dbModule || typeof dbModule.getSetting !== 'function') return 'balanced';
974
+ const v = String(dbModule.getSetting('approval_posture', 'balanced') || 'balanced').toLowerCase();
975
+ return APPROVAL_POSTURES.has(v) ? v : 'balanced';
976
+ } catch { return 'balanced'; }
977
+ }
978
+ // Autonomous trusts the heuristic for MEDIUM and skips the (slower, sometimes
979
+ // timing-out) LLM verifier. HIGH still runs it. decidedBy guards keep this to the
980
+ // pure-heuristic tier.
981
+ function _postureSkipsMediumVerifier(posture, riskLevel) {
982
+ return posture === 'autonomous' && riskLevel === 'medium';
983
+ }
984
+ // Cautious holds an uncertain MEDIUM for the human. Only the 'auto' (pure-heuristic)
985
+ // tier — never a user-allow, a learned rule, low, or high.
986
+ function _postureHoldsMedium(posture, riskLevel, decidedBy) {
987
+ return posture === 'cautious' && riskLevel === 'medium' && decidedBy === 'auto';
988
+ }
989
+
907
990
  async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, source, riskLevel, callModel) {
908
991
  // Verifier scope: medium+ risk only. Clearly low-risk/read-only approvals skip
909
992
  // the LLM second opinion — keeps the fast path fast and usable offline.
910
993
  if (riskLevel && riskLevel !== 'medium' && riskLevel !== 'high') return null;
994
+ // Posture (P-G): autonomous trusts the heuristic for the medium tier and skips
995
+ // the verifier entirely (HIGH still runs it below). Cautious is handled after the
996
+ // verdict so it can hold an otherwise-approved medium.
997
+ const posture = _approvalPosture();
998
+ if (_postureSkipsMediumVerifier(posture, riskLevel) && source === 'auto') return null;
911
999
  // Attach the warm session-context packet (goal + cwd) so the built-in verifier
912
1000
  // can judge GOAL-ALIGNMENT, not just the command in isolation. Single point —
913
1001
  // covers both call sites (allow-by-default + approval-rescue). Never overwrite a
914
1002
  // packet the caller already supplied.
915
1003
  if (context && !context.sessionContext) context.sessionContext = _buildSessionContext(session);
916
- const verifier = await verifyIfEnabled({ context, dbModule, callModel });
917
- // Approve-by-default: ONLY a confident "unsafe" verdict blocks an auto-approval.
918
- // A disabled verifier, a 'safe' verdict, or an 'unknown'/errored verdict (AI
919
- // unavailable, timeout, bad response) all fall through to APPROVE. The approver
920
- // escalates only when it is sure the command is high risk — never merely because
921
- // the AI gate could not produce an answer. (The dangerous-command blocklist
922
- // remains the deterministic hard gate above this.)
923
- if (!verifier.enabled || verifier.verdict !== 'unsafe') return null;
1004
+ // Fail-open if the verifier wedges: a disabled-shaped verdict the guard below
1005
+ // returns null the approval proceeds, same as an 'unknown'/timeout verdict.
1006
+ const verifier = await _withTimeout(
1007
+ verifyIfEnabled({ context, dbModule, callModel }),
1008
+ VERIFIER_OVERALL_TIMEOUT_MS,
1009
+ { enabled: false },
1010
+ );
1011
+ // Escalate when EITHER (a) the verifier is confidently "unsafe", OR (b) the
1012
+ // command is HIGH risk and the verifier could not positively clear it (P-E posture
1013
+ // — see _highRiskNeedsHumanWhenUnverified). MEDIUM risk stays fail-open: a disabled
1014
+ // verifier or an 'unknown'/timeout verdict falls through to APPROVE, so the fast
1015
+ // path stays fast and usable offline. The dangerous-command blocklist remains the
1016
+ // deterministic hard gate above this.
1017
+ const isUnsafe = !!(verifier.enabled && verifier.verdict === 'unsafe');
1018
+ const highRiskUnverified = !isUnsafe && _highRiskNeedsHumanWhenUnverified(riskLevel, verifier);
1019
+ // Cautious posture: hold an otherwise-approved pure-heuristic MEDIUM for the human.
1020
+ const cautiousHold = !isUnsafe && !highRiskUnverified && _postureHoldsMedium(posture, riskLevel, source);
1021
+ if (!isUnsafe && !highRiskUnverified && !cautiousHold) return null;
924
1022
 
925
1023
  // Prefer the OBJECTIVE block reason (the category — e.g. "Runs arbitrary code
926
1024
  // (node -e)") over the verifier's vague free-text, so the banner + Pending group
927
- // tell the user WHY it was held. Fall back to the verifier's reason, then a default.
1025
+ // tell the user WHY it was held. Fall back to the verifier's reason (unsafe case),
1026
+ // a cautious-posture message, or the high-risk-unverified message, then a default.
928
1027
  const blocked = classifyBlockReason(context);
929
- const reason = blocked.reason || verifier.reason || 'Auto-approval verifier flagged this command as high risk.';
1028
+ const reason = blocked.reason
1029
+ || (isUnsafe
1030
+ ? (verifier.reason || 'Auto-approval verifier flagged this command as high risk.')
1031
+ : cautiousHold
1032
+ ? 'Cautious posture — holding this medium-risk action for your approval.'
1033
+ : 'High-risk command could not be automatically verified — approve once to allow it.');
930
1034
  const parts = escalationCommandParts(context);
931
1035
  // The group key the Permission "Pending" tab will bucket this under — same fn the
932
1036
  // endpoint uses (lib/escalation-review.commandHead over the signature), so the
@@ -943,8 +1047,8 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
943
1047
  warning: context.warning || '',
944
1048
  decision: 'escalated',
945
1049
  reasoning: reason,
946
- decidedBy: 'verifier',
947
- riskLevel: 'high',
1050
+ decidedBy: cautiousHold ? 'posture' : 'verifier',
1051
+ riskLevel: cautiousHold ? 'medium' : 'high',
948
1052
  };
949
1053
  let decisionId;
950
1054
  try { decisionId = dbModule.addApprovalDecision?.(decision); } catch (e) { console.error('[approval-agent] verifier DB error:', e.message); }
@@ -953,7 +1057,7 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
953
1057
  type: 'approval-decision',
954
1058
  sessionId,
955
1059
  decision: 'escalated',
956
- decidedBy: 'verifier',
1060
+ decidedBy: cautiousHold ? 'posture' : 'verifier',
957
1061
  decisionId,
958
1062
  // Banner shows the actual command (matches the recorded commandSummary),
959
1063
  // not the heuristic rule label.
@@ -965,7 +1069,7 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
965
1069
  groupKey,
966
1070
  riskLevel: decision.riskLevel,
967
1071
  verifierSource: source || '',
968
- verifierVerdict: verifier.verdict,
1072
+ verifierVerdict: verifier.verdict || (highRiskUnverified ? 'unverified' : ''),
969
1073
  command: String(context.command || '').slice(0, 500),
970
1074
  warning: context.warning || '',
971
1075
  });
@@ -1151,6 +1255,55 @@ function _isDevCleanupCommand(cmd) {
1151
1255
  return sawKill || sawTmpRm;
1152
1256
  }
1153
1257
 
1258
+ // Network egress is a genuine security boundary (a confused/compromised agent can
1259
+ // exfiltrate to any host), so it is NOT blanket-approved. Only a small allowlist of
1260
+ // the agent's OWN provider backends auto-approves — stalling Codex on OpenAI's own
1261
+ // infra (e.g. ab.chatgpt.com) is pure friction with no egress benefit. Every other
1262
+ // host force-escalates to a granular, one-click Network(<host>) Pending rule the user
1263
+ // approves once. Suffix match so subdomains of an allowlisted apex are covered.
1264
+ // EDIT THIS LIST to broaden/narrow the auto-approved egress set.
1265
+ const NETWORK_ALLOWLIST = ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com'];
1266
+ function isAllowedNetworkHost(host) {
1267
+ const h = String(host || '').toLowerCase().trim().replace(/:\d+$/, '').replace(/\.$/, '');
1268
+ if (!h) return false;
1269
+ return NETWORK_ALLOWLIST.some((d) => h === d || h.endsWith('.' + d));
1270
+ }
1271
+ // Pull a hostname out of a Codex network-access prompt's question/reason text — used
1272
+ // as a fallback when the provider did not already extract context.networkHost.
1273
+ function extractNetworkHost(text) {
1274
+ const s = String(text || '');
1275
+ let m = s.match(/network access to ['"]?([a-z0-9.-]+\.[a-z]{2,})['"]?/i)
1276
+ || s.match(/([a-z0-9.-]+\.[a-z]{2,})\s+is\s+not\s+in\s+the\s+allowed[_ ]?domains/i)
1277
+ || s.match(/\b([a-z0-9-]+(?:\.[a-z0-9-]+)+\.[a-z]{2,})\b/i)
1278
+ || s.match(/\b([a-z0-9-]+\.[a-z]{2,})\b/i);
1279
+ return m ? m[1].toLowerCase().replace(/[.'"]+$/, '') : '';
1280
+ }
1281
+
1282
+ // Refresh the shared MCP annotation registry (lib/mcp-risk) from the persisted
1283
+ // `mcp_tool_annotations` setting so authoritative read/write classifications
1284
+ // override the verb heuristic. TTL-cached (and change-gated) so the hot approval
1285
+ // path doesn't re-read/re-parse every call; tolerant of a missing db or bad JSON
1286
+ // (leaves the registry as-is). An ABSENT setting is a no-op — only an explicit
1287
+ // value (e.g. `{}` to clear) replaces the registry. Shared by every decision path
1288
+ // because they all funnel through reviewWithHeuristics.
1289
+ const MCP_ANNO_TTL_MS = 5000;
1290
+ let _mcpAnnoRaw;
1291
+ let _mcpAnnoAt = 0;
1292
+ function _refreshMcpAnnotations() {
1293
+ try {
1294
+ if (!dbModule || typeof dbModule.getSetting !== 'function') return;
1295
+ const now = Date.now();
1296
+ if (now - _mcpAnnoAt < MCP_ANNO_TTL_MS) return;
1297
+ _mcpAnnoAt = now;
1298
+ const raw = dbModule.getSetting('mcp_tool_annotations', null);
1299
+ if (raw === _mcpAnnoRaw) return; // unchanged since last load
1300
+ _mcpAnnoRaw = raw;
1301
+ if (raw == null) return; // absent → leave the registry untouched
1302
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
1303
+ mcpRisk.loadAnnotations(parsed);
1304
+ } catch { /* keep whatever is registered; never block a decision on this */ }
1305
+ }
1306
+
1154
1307
  // Simple heuristic review when no API key is available
1155
1308
  function reviewWithHeuristics(context) {
1156
1309
  const cmd = (context.command || '').toLowerCase();
@@ -1176,15 +1329,19 @@ function reviewWithHeuristics(context) {
1176
1329
  // "mcp__…" / "(mcp)" command text.
1177
1330
  const isMcp = /^(?:plugin:|mcp__|mcp\b)/i.test(tool) || /\(mcp\)/i.test(cmd);
1178
1331
  if (isMcp) {
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;
1187
- if (readOnlyMcp.test(tool) && !mutatingMcp.test(tool)) {
1332
+ _refreshMcpAnnotations(); // pick up any persisted authoritative annotations
1333
+ // Read/write classification lives in lib/mcp-risk (shared with the MCP(...)
1334
+ // permission-rule matcher so the two never drift) and is authoritative-first:
1335
+ // a server's registered tool annotation wins, else the verb heuristic.
1336
+ // Some providers (codex-mcp) emit a literal toolName='MCP' and carry the real tool
1337
+ // verb in the structured `mcpTool` field, hiding it from a tool-name-only match — so
1338
+ // a read-only `search_code` looked verb-less and fell through to the medium verifier
1339
+ // path. Classify against the tool name PLUS `mcpTool` so the verb is visible wherever
1340
+ // the provider put it. When mcpTool is absent (Claude's `mcp__…` ids already carry the
1341
+ // verb in toolName), mcpTarget === tool, so nothing else changes.
1342
+ const mcpTarget = context.mcpTool ? `${tool} ${String(context.mcpTool).toLowerCase()}` : tool;
1343
+ const mcpClass = mcpRisk.classifyMcp({ server: context.mcpServer, tool: context.mcpTool, target: mcpTarget });
1344
+ if (mcpClass === 'read') {
1188
1345
  return { decision: 'approve', reasoning: 'Read-only MCP operation (heuristic)', riskLevel: 'low',
1189
1346
  ruleLabel: context.toolName || 'MCP read-only operation',
1190
1347
  rulePattern: '',
@@ -1197,6 +1354,29 @@ function reviewWithHeuristics(context) {
1197
1354
  ruleDescription: 'Routed to AI reviewer/verifier for a decision' };
1198
1355
  }
1199
1356
 
1357
+ // Network-access approvals (Codex "Do you want to approve network access to <host>?").
1358
+ // Allowlisted provider-own hosts auto-approve (low). Every other host force-escalates
1359
+ // to a granular, generalized Network(<host>) Pending rule — NOT to the LLM verifier,
1360
+ // which can't reason about egress and would let it through when uncertain/down. The
1361
+ // verifier is a fail-OPEN gate; egress must fail-CLOSED to the human.
1362
+ if (/^network\b/.test(tool) || context.networkHost) {
1363
+ const host = String(
1364
+ context.networkHost || extractNetworkHost(context.command) || extractNetworkHost(context.warning),
1365
+ ).toLowerCase();
1366
+ if (host && isAllowedNetworkHost(host)) {
1367
+ return { decision: 'approve', reasoning: `Network access to allowlisted host ${host} (heuristic)`, riskLevel: 'low',
1368
+ ruleLabel: `Network access to ${host}`, rulePattern: `Network(${host})`,
1369
+ ruleDescription: `Auto-approve network access to ${host}` };
1370
+ }
1371
+ return { decision: 'escalate', forceEscalate: true, riskLevel: 'medium',
1372
+ reasoning: host
1373
+ ? `Network access to ${host} is not on the egress allowlist — approve once to allow it for the future.`
1374
+ : 'Network access requested — approve once to allow this host for the future.',
1375
+ ruleLabel: host ? `Network access to ${host}` : 'Network access',
1376
+ rulePattern: host ? `Network(${host})` : '',
1377
+ ruleDescription: host ? `Allow network access to ${host}` : 'Allow network access' };
1378
+ }
1379
+
1200
1380
  // Low-risk tools — auto-approve immediately (before high-risk content check,
1201
1381
  // because Edit/Write diffs may contain code with "drop table" or "rm -rf" as
1202
1382
  // string literals — those are code content, not dangerous operations).
@@ -2131,7 +2311,7 @@ async function decideApproval(context, session, options = {}) {
2131
2311
  // 2) Permission Manager rules (the user's explicit allow/deny).
2132
2312
  let permRules = [];
2133
2313
  try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
2134
- const permMatch = matchPermission({ toolName: context.toolName, command }, permRules);
2314
+ const permMatch = matchPermission({ toolName: context.toolName, command, mcpServer: context.mcpServer, mcpTool: context.mcpTool }, permRules);
2135
2315
  if (permMatch && permMatch.action === 'deny') {
2136
2316
  return {
2137
2317
  decision: 'ask', decidedBy: 'user-deny', riskLevel: 'high',
@@ -2159,13 +2339,39 @@ async function decideApproval(context, session, options = {}) {
2159
2339
  : matchingRule ? `Matched learned rule: ${matchingRule.label}`
2160
2340
  : 'Auto-approved by default (not on the denylist)';
2161
2341
 
2342
+ // 3b) Heuristic-forced escalation (e.g. un-allowlisted network egress) — ask the
2343
+ // human deterministically, before the fail-open verifier, with the granular
2344
+ // Network(<host>) rule pre-suggested.
2345
+ if (!userAllowed && !matchingRule && heuristic && heuristic.forceEscalate) {
2346
+ return {
2347
+ decision: 'ask', decidedBy: 'heuristic', riskLevel: heuristic.riskLevel || 'medium',
2348
+ reason: heuristic.reasoning || 'Needs review before allowing.',
2349
+ label: heuristic.ruleLabel || context.toolName,
2350
+ suggestedRule: heuristic.rulePattern || '',
2351
+ };
2352
+ }
2353
+
2162
2354
  // 4) Goal-aligned verifier — medium+ risk only; user-allowed commands skip it
2163
2355
  // (the user has explicitly vouched). Only a confident "unsafe" verdict
2164
2356
  // escalates; a disabled/safe/unknown verdict falls through to allow.
2165
- if (!userAllowed && (riskLevel === 'medium' || riskLevel === 'high')) {
2357
+ // Posture (P-G): autonomous trusts the heuristic for medium and skips the
2358
+ // verifier (HIGH still runs it); cautious is applied after, on the medium
2359
+ // fall-through, so it can hold an otherwise-approved action.
2360
+ const posture = _approvalPosture();
2361
+ const runVerifier = !userAllowed
2362
+ && (riskLevel === 'high' || (riskLevel === 'medium' && !_postureSkipsMediumVerifier(posture, riskLevel)));
2363
+ if (runVerifier) {
2166
2364
  if (context && !context.sessionContext) context.sessionContext = _buildSessionContext(session);
2365
+ // Bounded like the live-path verifier (see _withTimeout / VERIFIER_OVERALL_TIMEOUT_MS):
2366
+ // a wedged background model must not strand this approval. Fail-open on timeout/error.
2167
2367
  let verifier = { enabled: false, verdict: 'unknown' };
2168
- try { verifier = await verifyIfEnabled({ context, dbModule, callModel }); } catch { verifier = { enabled: false, verdict: 'unknown' }; }
2368
+ try {
2369
+ verifier = await _withTimeout(
2370
+ verifyIfEnabled({ context, dbModule, callModel }),
2371
+ VERIFIER_OVERALL_TIMEOUT_MS,
2372
+ { enabled: false, verdict: 'unknown' },
2373
+ );
2374
+ } catch { verifier = { enabled: false, verdict: 'unknown' }; }
2169
2375
  if (verifier.enabled && verifier.verdict === 'unsafe') {
2170
2376
  const blocked = classifyBlockReason(context);
2171
2377
  return {
@@ -2174,6 +2380,32 @@ async function decideApproval(context, session, options = {}) {
2174
2380
  blockCategory: blocked.category || '', verifierVerdict: verifier.verdict, label,
2175
2381
  };
2176
2382
  }
2383
+ // P-E posture: HIGH risk the verifier could not positively clear (disabled /
2384
+ // timeout / unknown) fails CLOSED — ask the human rather than approve blind.
2385
+ // MEDIUM falls through to allow (bounded blast radius). Mirrors the live-path
2386
+ // _verifyAutoApprovalOrBlock so both entrypoints share one posture.
2387
+ if (_highRiskNeedsHumanWhenUnverified(riskLevel, verifier)) {
2388
+ const blocked = classifyBlockReason(context);
2389
+ return {
2390
+ decision: 'ask', decidedBy: 'heuristic', riskLevel: 'high',
2391
+ reason: blocked.reason
2392
+ || (heuristic && heuristic.reasoning)
2393
+ || 'High-risk command could not be automatically verified — approve once to allow it.',
2394
+ blockCategory: blocked.category || '', verifierVerdict: verifier.verdict || 'unverified',
2395
+ label, suggestedRule: (heuristic && heuristic.rulePattern) || '',
2396
+ };
2397
+ }
2398
+ }
2399
+
2400
+ // Posture (P-G): cautious holds an otherwise-approved pure-heuristic MEDIUM for
2401
+ // the human. Runs after every hard gate (blocklist/deny/high-fail-closed) and
2402
+ // never touches user-allow / learned-rule / low decisions.
2403
+ if (_postureHoldsMedium(posture, riskLevel, decidedBy)) {
2404
+ return {
2405
+ decision: 'ask', decidedBy: 'posture', riskLevel: 'medium',
2406
+ reason: 'Cautious posture — holding this medium-risk action for your approval.',
2407
+ label, suggestedRule: (heuristic && heuristic.rulePattern) || '',
2408
+ };
2177
2409
  }
2178
2410
 
2179
2411
  return {
@@ -2257,7 +2489,7 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
2257
2489
  // verifier (the user has explicitly vouched for it).
2258
2490
  let permRules = [];
2259
2491
  try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
2260
- const permMatch = matchPermission({ toolName: context.toolName, command: context.command }, permRules);
2492
+ const permMatch = matchPermission({ toolName: context.toolName, command: context.command, mcpServer: context.mcpServer, mcpTool: context.mcpTool }, permRules);
2261
2493
  if (permMatch && permMatch.action === 'deny') {
2262
2494
  const reasoning = `Permission Manager deny rule matched: ${permMatch.rule}`;
2263
2495
  try {
@@ -2302,6 +2534,37 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
2302
2534
  : matchingRule ? `Matched learned rule: ${matchingRule.label}`
2303
2535
  : 'Auto-approved by default (not on the denylist)';
2304
2536
 
2537
+ // Heuristic-forced escalation (e.g. un-allowlisted network egress): route straight
2538
+ // to the Pending "Needs Review" surface as a granular, generalized rule the user can
2539
+ // approve once. Deterministic — does NOT depend on the fail-open LLM verifier, which
2540
+ // could let an un-vouched host through when uncertain or down.
2541
+ if (!userAllowed && !matchingRule && heuristic && heuristic.forceEscalate) {
2542
+ const parts = escalationCommandParts(context);
2543
+ const groupKey = (() => { try { return commandHead(parts.signature) || ''; } catch { return ''; } })();
2544
+ const escReason = heuristic.reasoning || 'Needs review before allowing.';
2545
+ const escRisk = heuristic.riskLevel || 'medium';
2546
+ // Prefer the heuristic's human label (e.g. "Network access to <host>") over the
2547
+ // bare operative command (the host) so the Pending row reads as a category.
2548
+ const escLabel = heuristic.ruleLabel || parts.title || context.toolName || 'Approval needs review';
2549
+ let decisionId;
2550
+ try {
2551
+ decisionId = dbModule.addApprovalDecision({
2552
+ sessionId, toolName: context.toolName,
2553
+ commandSummary: escLabel,
2554
+ fullContext: context.fullContext.slice(0, 2000), warning: context.warning,
2555
+ decision: 'escalated', reasoning: escReason, decidedBy: 'heuristic', riskLevel: escRisk,
2556
+ commandSignature: parts.signature || commandSignature,
2557
+ });
2558
+ } catch (e) { console.error('[approval-agent] DB error:', e.message); }
2559
+ broadcastFn(sessionId, session, {
2560
+ type: 'approval-decision', sessionId, decision: 'escalated', decidedBy: 'heuristic', decisionId,
2561
+ label: escLabel, reasoning: escReason, groupKey, riskLevel: escRisk,
2562
+ suggestedRule: heuristic.rulePattern || '',
2563
+ command: (context.command || '').slice(0, 500), warning: context.warning,
2564
+ });
2565
+ return true;
2566
+ }
2567
+
2305
2568
  if (!userAllowed) {
2306
2569
  // Verifier scope: medium+ risk only — read-only/low-risk ops auto-approve fast.
2307
2570
  const verifierBlock = await _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, decidedBy, riskLevel, callModel);
@@ -2341,6 +2604,8 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
2341
2604
 
2342
2605
  module.exports = {
2343
2606
  parseApprovalContext,
2607
+ _refreshMcpAnnotations,
2608
+ _resetMcpAnnotationCache: () => { _mcpAnnoAt = 0; _mcpAnnoRaw = undefined; },
2344
2609
  isLiveApprovalPrompt,
2345
2610
  isPlanApprovalPrompt,
2346
2611
  planCardFingerprint,
@@ -2355,6 +2620,7 @@ module.exports = {
2355
2620
  _isTmpScopedRmClause,
2356
2621
  _isPortScopedKillClause,
2357
2622
  _buildSessionContext,
2623
+ _withTimeout,
2358
2624
  normalizeCommandSignature,
2359
2625
  escalationCommandParts,
2360
2626
  classifyBlockReason,