create-walle 0.9.30 → 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 (112) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/api-prompts.js +410 -5
  3. package/template/claude-task-manager/api-reviews.js +251 -3
  4. package/template/claude-task-manager/approval-agent.js +322 -24
  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 +667 -0
  9. package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
  10. package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
  11. package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
  12. package/template/claude-task-manager/git-utils.js +164 -26
  13. package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
  14. package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
  15. package/template/claude-task-manager/lib/agent-presets.js +12 -0
  16. package/template/claude-task-manager/lib/agent-version-service.js +650 -0
  17. package/template/claude-task-manager/lib/approval-drift.js +57 -0
  18. package/template/claude-task-manager/lib/approval-hook.js +13 -0
  19. package/template/claude-task-manager/lib/auth-rules.js +6 -0
  20. package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
  21. package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
  22. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
  23. package/template/claude-task-manager/lib/document-review.js +75 -1
  24. package/template/claude-task-manager/lib/escalation-review.js +33 -3
  25. package/template/claude-task-manager/lib/headless-term-service.js +54 -9
  26. package/template/claude-task-manager/lib/mcp-risk.js +128 -0
  27. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
  28. package/template/claude-task-manager/lib/native-agent-model-args.js +149 -0
  29. package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
  30. package/template/claude-task-manager/lib/permission-match.js +77 -5
  31. package/template/claude-task-manager/lib/read-pool-client.js +15 -6
  32. package/template/claude-task-manager/lib/restore-backoff.js +82 -0
  33. package/template/claude-task-manager/lib/runtime-registry.js +1 -0
  34. package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
  35. package/template/claude-task-manager/lib/session-capture.js +33 -6
  36. package/template/claude-task-manager/lib/session-history.js +60 -3
  37. package/template/claude-task-manager/lib/session-jobs.js +19 -0
  38. package/template/claude-task-manager/lib/session-restore.js +5 -4
  39. package/template/claude-task-manager/lib/session-standup.js +2 -0
  40. package/template/claude-task-manager/lib/session-stream.js +9 -0
  41. package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
  42. package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
  43. package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
  44. package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
  45. package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
  46. package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
  47. package/template/claude-task-manager/package.json +1 -1
  48. package/template/claude-task-manager/providers/claude-code.js +20 -1
  49. package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
  50. package/template/claude-task-manager/providers/codex-mcp.js +23 -20
  51. package/template/claude-task-manager/providers/codex.js +65 -1
  52. package/template/claude-task-manager/public/css/prompts.css +345 -0
  53. package/template/claude-task-manager/public/css/reviews.css +248 -40
  54. package/template/claude-task-manager/public/css/walle-session.css +90 -26
  55. package/template/claude-task-manager/public/index.html +2031 -428
  56. package/template/claude-task-manager/public/js/document-review-links.js +255 -13
  57. package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
  58. package/template/claude-task-manager/public/js/message-renderer.js +46 -1
  59. package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
  60. package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
  61. package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
  62. package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
  63. package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
  64. package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
  65. package/template/claude-task-manager/public/js/prompts.js +683 -215
  66. package/template/claude-task-manager/public/js/resume-state.js +186 -0
  67. package/template/claude-task-manager/public/js/reviews.js +402 -121
  68. package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
  69. package/template/claude-task-manager/public/js/session-search-utils.js +81 -0
  70. package/template/claude-task-manager/public/js/state-sync-client.js +134 -17
  71. package/template/claude-task-manager/public/js/stream-view.js +93 -20
  72. package/template/claude-task-manager/public/js/walle-session.js +183 -32
  73. package/template/claude-task-manager/public/js/walle.js +21 -9
  74. package/template/claude-task-manager/public/m/app.css +117 -0
  75. package/template/claude-task-manager/public/m/app.js +309 -3
  76. package/template/claude-task-manager/public/m/index.html +56 -1
  77. package/template/claude-task-manager/server.js +724 -305
  78. package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
  79. package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
  80. package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
  81. package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
  82. package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
  83. package/template/package.json +1 -1
  84. package/template/wall-e/api-walle.js +21 -3
  85. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
  86. package/template/wall-e/brain.js +50 -5
  87. package/template/wall-e/chat/force-compact.js +73 -0
  88. package/template/wall-e/chat.js +137 -9
  89. package/template/wall-e/coding/action-memory-policy.js +120 -1
  90. package/template/wall-e/coding/compaction-service.js +10 -3
  91. package/template/wall-e/coding/model-router.js +116 -0
  92. package/template/wall-e/coding-orchestrator.js +303 -11
  93. package/template/wall-e/coding-prompts.js +1 -1
  94. package/template/wall-e/docs/skill-self-heal-design.html +429 -0
  95. package/template/wall-e/llm/client.js +115 -6
  96. package/template/wall-e/llm/codex-cli.js +71 -23
  97. package/template/wall-e/llm/codex-cli.plugin.json +1 -0
  98. package/template/wall-e/llm/default-fallback.js +71 -11
  99. package/template/wall-e/llm/mlx-worker.js +0 -0
  100. package/template/wall-e/llm/mlx.js +46 -12
  101. package/template/wall-e/llm/provider-error.js +9 -3
  102. package/template/wall-e/llm/provider-health-state.js +561 -11
  103. package/template/wall-e/llm/registry.js +6 -0
  104. package/template/wall-e/llm/routing-policy.js +255 -25
  105. package/template/wall-e/llm/tool-call-validator.js +156 -0
  106. package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
  107. package/template/wall-e/skills/skill-planner.js +72 -5
  108. package/template/wall-e/tools/command-registry.js +34 -0
  109. package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
  110. package/template/wall-e/training/mlx_lora_launch.py +196 -0
  111. package/template/wall-e/training/training-coexistence.sh +87 -0
  112. package/template/wall-e/training/training-mem-guard.sh +76 -0
@@ -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
@@ -250,6 +251,24 @@ function getApproveKeystroke(context, options = {}) {
250
251
  return context.hasAllowAll ? '2' : '1';
251
252
  }
252
253
 
254
+ // Fallback tool-name derivation when no tool header is recognized above the
255
+ // prompt: read it from the durable grant option ("Yes, and don't ask again for
256
+ // <X> commands in <dir>" / "Yes, allow reading from <dir>") so an unrecognized
257
+ // tool is still targetable by a heuristic/learned rule instead of collapsing to
258
+ // 'Unknown'. `lines` is the trimmed, non-empty line array; `proceedIdx` the
259
+ // "Do you want to…?" line. Pure.
260
+ function _toolNameFromDurableOption(lines, proceedIdx) {
261
+ for (let i = proceedIdx + 1; i < Math.min(proceedIdx + 8, lines.length); i++) {
262
+ const l = String(lines[i] || '');
263
+ const m = l.match(/don'?t ask again for\s+(.+?)\s+commands?\b/i);
264
+ if (m && m[1]) return m[1].trim();
265
+ if (/allow\s+reading\s+from\b/i.test(l)) return 'Read';
266
+ const a = l.match(/allow\s+all\s+(\w+)\b/i);
267
+ if (a && a[1]) return a[1].trim();
268
+ }
269
+ return '';
270
+ }
271
+
253
272
  function _parseGenericApprovalContext(cleanText, providerId) {
254
273
  const lines = String(cleanText || '').split('\n').map(l => l.trim()).filter(Boolean);
255
274
  if (!lines.length) return null;
@@ -284,12 +303,16 @@ function _parseGenericApprovalContext(cleanText, providerId) {
284
303
  const contextLines = [];
285
304
  for (let i = proceedIdx - 1; i >= Math.max(0, proceedIdx - 30); i--) {
286
305
  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)) {
306
+ if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
288
307
  toolName = line.trim();
289
308
  break;
290
309
  }
291
310
  contextLines.unshift(line);
292
311
  }
312
+ if (toolName === 'Generic approval') {
313
+ const durable = _toolNameFromDurableOption(lines, proceedIdx);
314
+ if (durable) toolName = durable;
315
+ }
293
316
 
294
317
  const fullContext = lines.slice(Math.max(0, proceedIdx - 20), Math.min(lines.length, proceedIdx + 12)).join('\n');
295
318
  return {
@@ -373,7 +396,7 @@ function parseApprovalContext(cleanText, providerId) {
373
396
  for (let i = endIdx - 1; i >= Math.max(0, endIdx - 30); i--) {
374
397
  const line = lines[i];
375
398
  // 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)) {
399
+ if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
377
400
  toolName = line.trim();
378
401
  break;
379
402
  }
@@ -403,6 +426,10 @@ function parseApprovalContext(cleanText, providerId) {
403
426
  if (fileOp) { toolName = fileOp.toolName; fileOpCommand = fileOp.command; }
404
427
  }
405
428
 
429
+ // Still no header → derive a name from the durable grant option so the tool is
430
+ // targetable instead of 'Unknown' (mirror of the claude-code provider path).
431
+ if (!toolName) toolName = _toolNameFromDurableOption(lines, proceedIdx);
432
+
406
433
  // Bash approvals render the command followed by one dimmed prose description
407
434
  // line; drop it from the command so titles/signatures stay command-shaped
408
435
  // (mirror of the claude-code provider parse path).
@@ -586,6 +613,17 @@ function cardTextFingerprint(cleanText) {
586
613
  function normalizeCommandSignature(toolName, command) {
587
614
  const tool = (toolName || '').replace(/^[⏺●\s]+/, '').trim();
588
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
+
589
627
  if (!cmd) return tool.toLowerCase();
590
628
 
591
629
  // For non-Bash tools (Edit, Write, Read, etc.), signature is just the tool name
@@ -878,29 +916,121 @@ function _buildSessionContext(session) {
878
916
  return { goal, cwd };
879
917
  }
880
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
+
881
990
  async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, source, riskLevel, callModel) {
882
991
  // Verifier scope: medium+ risk only. Clearly low-risk/read-only approvals skip
883
992
  // the LLM second opinion — keeps the fast path fast and usable offline.
884
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;
885
999
  // Attach the warm session-context packet (goal + cwd) so the built-in verifier
886
1000
  // can judge GOAL-ALIGNMENT, not just the command in isolation. Single point —
887
1001
  // covers both call sites (allow-by-default + approval-rescue). Never overwrite a
888
1002
  // packet the caller already supplied.
889
1003
  if (context && !context.sessionContext) context.sessionContext = _buildSessionContext(session);
890
- const verifier = await verifyIfEnabled({ context, dbModule, callModel });
891
- // Approve-by-default: ONLY a confident "unsafe" verdict blocks an auto-approval.
892
- // A disabled verifier, a 'safe' verdict, or an 'unknown'/errored verdict (AI
893
- // unavailable, timeout, bad response) all fall through to APPROVE. The approver
894
- // escalates only when it is sure the command is high risk — never merely because
895
- // the AI gate could not produce an answer. (The dangerous-command blocklist
896
- // remains the deterministic hard gate above this.)
897
- 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;
898
1022
 
899
1023
  // Prefer the OBJECTIVE block reason (the category — e.g. "Runs arbitrary code
900
1024
  // (node -e)") over the verifier's vague free-text, so the banner + Pending group
901
- // 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.
902
1027
  const blocked = classifyBlockReason(context);
903
- 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.');
904
1034
  const parts = escalationCommandParts(context);
905
1035
  // The group key the Permission "Pending" tab will bucket this under — same fn the
906
1036
  // endpoint uses (lib/escalation-review.commandHead over the signature), so the
@@ -917,8 +1047,8 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
917
1047
  warning: context.warning || '',
918
1048
  decision: 'escalated',
919
1049
  reasoning: reason,
920
- decidedBy: 'verifier',
921
- riskLevel: 'high',
1050
+ decidedBy: cautiousHold ? 'posture' : 'verifier',
1051
+ riskLevel: cautiousHold ? 'medium' : 'high',
922
1052
  };
923
1053
  let decisionId;
924
1054
  try { decisionId = dbModule.addApprovalDecision?.(decision); } catch (e) { console.error('[approval-agent] verifier DB error:', e.message); }
@@ -927,7 +1057,7 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
927
1057
  type: 'approval-decision',
928
1058
  sessionId,
929
1059
  decision: 'escalated',
930
- decidedBy: 'verifier',
1060
+ decidedBy: cautiousHold ? 'posture' : 'verifier',
931
1061
  decisionId,
932
1062
  // Banner shows the actual command (matches the recorded commandSummary),
933
1063
  // not the heuristic rule label.
@@ -939,7 +1069,7 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
939
1069
  groupKey,
940
1070
  riskLevel: decision.riskLevel,
941
1071
  verifierSource: source || '',
942
- verifierVerdict: verifier.verdict,
1072
+ verifierVerdict: verifier.verdict || (highRiskUnverified ? 'unverified' : ''),
943
1073
  command: String(context.command || '').slice(0, 500),
944
1074
  warning: context.warning || '',
945
1075
  });
@@ -1125,6 +1255,55 @@ function _isDevCleanupCommand(cmd) {
1125
1255
  return sawKill || sawTmpRm;
1126
1256
  }
1127
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
+
1128
1307
  // Simple heuristic review when no API key is available
1129
1308
  function reviewWithHeuristics(context) {
1130
1309
  const cmd = (context.command || '').toLowerCase();
@@ -1150,9 +1329,19 @@ function reviewWithHeuristics(context) {
1150
1329
  // "mcp__…" / "(mcp)" command text.
1151
1330
  const isMcp = /^(?:plugin:|mcp__|mcp\b)/i.test(tool) || /\(mcp\)/i.test(cmd);
1152
1331
  if (isMcp) {
1153
- const readOnlyMcp = /\b(navigate(?:_back)?|snapshot|take_screenshot|screenshot|read|list|get|query|search|console_messages|network_requests?|wait_for|hover|tabs|resolve[-_ ]library[-_ ]id|get[-_ ]library[-_ ]docs|browser_snapshot|browser_navigate)\b/i;
1154
- const mutatingMcp = /\b(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)\b/i;
1155
- 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') {
1156
1345
  return { decision: 'approve', reasoning: 'Read-only MCP operation (heuristic)', riskLevel: 'low',
1157
1346
  ruleLabel: context.toolName || 'MCP read-only operation',
1158
1347
  rulePattern: '',
@@ -1165,11 +1354,34 @@ function reviewWithHeuristics(context) {
1165
1354
  ruleDescription: 'Routed to AI reviewer/verifier for a decision' };
1166
1355
  }
1167
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
+
1168
1380
  // Low-risk tools — auto-approve immediately (before high-risk content check,
1169
1381
  // because Edit/Write diffs may contain code with "drop table" or "rm -rf" as
1170
1382
  // string literals — those are code content, not dangerous operations).
1171
1383
  const lowRisk = [
1172
- /^(read|glob|grep|webfetch|notebookedit)/, // Read-only tools
1384
+ /^(read|glob|grep|webfetch|web ?search|notebookedit)/, // Read-only tools (incl. Web Search — read-only, no side effects, same tier as WebFetch)
1173
1385
  /^(edit|write)\b/, // Edit/Write to project files — normal dev workflow
1174
1386
  ];
1175
1387
  for (const re of lowRisk) {
@@ -2099,7 +2311,7 @@ async function decideApproval(context, session, options = {}) {
2099
2311
  // 2) Permission Manager rules (the user's explicit allow/deny).
2100
2312
  let permRules = [];
2101
2313
  try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
2102
- const permMatch = matchPermission({ toolName: context.toolName, command }, permRules);
2314
+ const permMatch = matchPermission({ toolName: context.toolName, command, mcpServer: context.mcpServer, mcpTool: context.mcpTool }, permRules);
2103
2315
  if (permMatch && permMatch.action === 'deny') {
2104
2316
  return {
2105
2317
  decision: 'ask', decidedBy: 'user-deny', riskLevel: 'high',
@@ -2127,13 +2339,39 @@ async function decideApproval(context, session, options = {}) {
2127
2339
  : matchingRule ? `Matched learned rule: ${matchingRule.label}`
2128
2340
  : 'Auto-approved by default (not on the denylist)';
2129
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
+
2130
2354
  // 4) Goal-aligned verifier — medium+ risk only; user-allowed commands skip it
2131
2355
  // (the user has explicitly vouched). Only a confident "unsafe" verdict
2132
2356
  // escalates; a disabled/safe/unknown verdict falls through to allow.
2133
- 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) {
2134
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.
2135
2367
  let verifier = { enabled: false, verdict: 'unknown' };
2136
- 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' }; }
2137
2375
  if (verifier.enabled && verifier.verdict === 'unsafe') {
2138
2376
  const blocked = classifyBlockReason(context);
2139
2377
  return {
@@ -2142,6 +2380,32 @@ async function decideApproval(context, session, options = {}) {
2142
2380
  blockCategory: blocked.category || '', verifierVerdict: verifier.verdict, label,
2143
2381
  };
2144
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
+ };
2145
2409
  }
2146
2410
 
2147
2411
  return {
@@ -2225,7 +2489,7 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
2225
2489
  // verifier (the user has explicitly vouched for it).
2226
2490
  let permRules = [];
2227
2491
  try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
2228
- 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);
2229
2493
  if (permMatch && permMatch.action === 'deny') {
2230
2494
  const reasoning = `Permission Manager deny rule matched: ${permMatch.rule}`;
2231
2495
  try {
@@ -2270,6 +2534,37 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
2270
2534
  : matchingRule ? `Matched learned rule: ${matchingRule.label}`
2271
2535
  : 'Auto-approved by default (not on the denylist)';
2272
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
+
2273
2568
  if (!userAllowed) {
2274
2569
  // Verifier scope: medium+ risk only — read-only/low-risk ops auto-approve fast.
2275
2570
  const verifierBlock = await _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, decidedBy, riskLevel, callModel);
@@ -2309,6 +2604,8 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
2309
2604
 
2310
2605
  module.exports = {
2311
2606
  parseApprovalContext,
2607
+ _refreshMcpAnnotations,
2608
+ _resetMcpAnnotationCache: () => { _mcpAnnoAt = 0; _mcpAnnoRaw = undefined; },
2312
2609
  isLiveApprovalPrompt,
2313
2610
  isPlanApprovalPrompt,
2314
2611
  planCardFingerprint,
@@ -2323,6 +2620,7 @@ module.exports = {
2323
2620
  _isTmpScopedRmClause,
2324
2621
  _isPortScopedKillClause,
2325
2622
  _buildSessionContext,
2623
+ _withTimeout,
2326
2624
  normalizeCommandSignature,
2327
2625
  escalationCommandParts,
2328
2626
  classifyBlockReason,