create-walle 0.9.30 → 0.9.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/claude-task-manager/api-prompts.js +410 -5
- package/template/claude-task-manager/api-reviews.js +251 -3
- package/template/claude-task-manager/approval-agent.js +322 -24
- package/template/claude-task-manager/db.js +77 -13
- package/template/claude-task-manager/docs/codex-app-server-approvals.md +35 -1
- package/template/claude-task-manager/docs/postgres-concurrency-dgx-spark.html +407 -0
- package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +667 -0
- package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
- package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
- package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
- package/template/claude-task-manager/git-utils.js +164 -26
- package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
- package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
- package/template/claude-task-manager/lib/agent-presets.js +12 -0
- package/template/claude-task-manager/lib/agent-version-service.js +650 -0
- package/template/claude-task-manager/lib/approval-drift.js +57 -0
- package/template/claude-task-manager/lib/approval-hook.js +13 -0
- package/template/claude-task-manager/lib/auth-rules.js +6 -0
- package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
- package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
- package/template/claude-task-manager/lib/document-review.js +75 -1
- package/template/claude-task-manager/lib/escalation-review.js +33 -3
- package/template/claude-task-manager/lib/headless-term-service.js +54 -9
- package/template/claude-task-manager/lib/mcp-risk.js +128 -0
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
- package/template/claude-task-manager/lib/native-agent-model-args.js +149 -0
- package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
- package/template/claude-task-manager/lib/permission-match.js +77 -5
- package/template/claude-task-manager/lib/read-pool-client.js +15 -6
- package/template/claude-task-manager/lib/restore-backoff.js +82 -0
- package/template/claude-task-manager/lib/runtime-registry.js +1 -0
- package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
- package/template/claude-task-manager/lib/session-capture.js +33 -6
- package/template/claude-task-manager/lib/session-history.js +60 -3
- package/template/claude-task-manager/lib/session-jobs.js +19 -0
- package/template/claude-task-manager/lib/session-restore.js +5 -4
- package/template/claude-task-manager/lib/session-standup.js +2 -0
- package/template/claude-task-manager/lib/session-stream.js +9 -0
- package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
- package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
- package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
- package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
- package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
- package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
- package/template/claude-task-manager/package.json +1 -1
- package/template/claude-task-manager/providers/claude-code.js +20 -1
- package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
- package/template/claude-task-manager/providers/codex-mcp.js +23 -20
- package/template/claude-task-manager/providers/codex.js +65 -1
- package/template/claude-task-manager/public/css/prompts.css +345 -0
- package/template/claude-task-manager/public/css/reviews.css +248 -40
- package/template/claude-task-manager/public/css/walle-session.css +90 -26
- package/template/claude-task-manager/public/index.html +2031 -428
- package/template/claude-task-manager/public/js/document-review-links.js +255 -13
- package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
- package/template/claude-task-manager/public/js/message-renderer.js +46 -1
- package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
- package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
- package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
- package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
- package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
- package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
- package/template/claude-task-manager/public/js/prompts.js +683 -215
- package/template/claude-task-manager/public/js/resume-state.js +186 -0
- package/template/claude-task-manager/public/js/reviews.js +402 -121
- package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
- package/template/claude-task-manager/public/js/session-search-utils.js +81 -0
- package/template/claude-task-manager/public/js/state-sync-client.js +134 -17
- package/template/claude-task-manager/public/js/stream-view.js +93 -20
- package/template/claude-task-manager/public/js/walle-session.js +183 -32
- package/template/claude-task-manager/public/js/walle.js +21 -9
- package/template/claude-task-manager/public/m/app.css +117 -0
- package/template/claude-task-manager/public/m/app.js +309 -3
- package/template/claude-task-manager/public/m/index.html +56 -1
- package/template/claude-task-manager/server.js +724 -305
- package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
- package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
- package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
- package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
- package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
- package/template/package.json +1 -1
- package/template/wall-e/api-walle.js +21 -3
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
- package/template/wall-e/brain.js +50 -5
- package/template/wall-e/chat/force-compact.js +73 -0
- package/template/wall-e/chat.js +137 -9
- package/template/wall-e/coding/action-memory-policy.js +120 -1
- package/template/wall-e/coding/compaction-service.js +10 -3
- package/template/wall-e/coding/model-router.js +116 -0
- package/template/wall-e/coding-orchestrator.js +303 -11
- package/template/wall-e/coding-prompts.js +1 -1
- package/template/wall-e/docs/skill-self-heal-design.html +429 -0
- package/template/wall-e/llm/client.js +115 -6
- package/template/wall-e/llm/codex-cli.js +71 -23
- package/template/wall-e/llm/codex-cli.plugin.json +1 -0
- package/template/wall-e/llm/default-fallback.js +71 -11
- package/template/wall-e/llm/mlx-worker.js +0 -0
- package/template/wall-e/llm/mlx.js +46 -12
- package/template/wall-e/llm/provider-error.js +9 -3
- package/template/wall-e/llm/provider-health-state.js +561 -11
- package/template/wall-e/llm/registry.js +6 -0
- package/template/wall-e/llm/routing-policy.js +255 -25
- package/template/wall-e/llm/tool-call-validator.js +156 -0
- package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
- package/template/wall-e/skills/skill-planner.js +72 -5
- package/template/wall-e/tools/command-registry.js +34 -0
- package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
- package/template/wall-e/training/mlx_lora_launch.py +196 -0
- package/template/wall-e/training/training-coexistence.sh +87 -0
- package/template/wall-e/training/training-mem-guard.sh +76 -0
|
@@ -13,6 +13,7 @@ const { commandHead } = require('./lib/escalation-review');
|
|
|
13
13
|
const { callBackgroundLlm } = require('./lib/background-llm');
|
|
14
14
|
const { verifyIfEnabled } = require('./lib/auto-approval-verifier');
|
|
15
15
|
const { matchPermission } = require('./lib/permission-match');
|
|
16
|
+
const mcpRisk = require('./lib/mcp-risk');
|
|
16
17
|
const approvalAiRefinement = require('./lib/approval-ai-refinement');
|
|
17
18
|
|
|
18
19
|
// Dangerous-command blocklist: when enabled, any command matching
|
|
@@ -250,6 +251,24 @@ function getApproveKeystroke(context, options = {}) {
|
|
|
250
251
|
return context.hasAllowAll ? '2' : '1';
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
// Fallback tool-name derivation when no tool header is recognized above the
|
|
255
|
+
// prompt: read it from the durable grant option ("Yes, and don't ask again for
|
|
256
|
+
// <X> commands in <dir>" / "Yes, allow reading from <dir>") so an unrecognized
|
|
257
|
+
// tool is still targetable by a heuristic/learned rule instead of collapsing to
|
|
258
|
+
// 'Unknown'. `lines` is the trimmed, non-empty line array; `proceedIdx` the
|
|
259
|
+
// "Do you want to…?" line. Pure.
|
|
260
|
+
function _toolNameFromDurableOption(lines, proceedIdx) {
|
|
261
|
+
for (let i = proceedIdx + 1; i < Math.min(proceedIdx + 8, lines.length); i++) {
|
|
262
|
+
const l = String(lines[i] || '');
|
|
263
|
+
const m = l.match(/don'?t ask again for\s+(.+?)\s+commands?\b/i);
|
|
264
|
+
if (m && m[1]) return m[1].trim();
|
|
265
|
+
if (/allow\s+reading\s+from\b/i.test(l)) return 'Read';
|
|
266
|
+
const a = l.match(/allow\s+all\s+(\w+)\b/i);
|
|
267
|
+
if (a && a[1]) return a[1].trim();
|
|
268
|
+
}
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
271
|
+
|
|
253
272
|
function _parseGenericApprovalContext(cleanText, providerId) {
|
|
254
273
|
const lines = String(cleanText || '').split('\n').map(l => l.trim()).filter(Boolean);
|
|
255
274
|
if (!lines.length) return null;
|
|
@@ -284,12 +303,16 @@ function _parseGenericApprovalContext(cleanText, providerId) {
|
|
|
284
303
|
const contextLines = [];
|
|
285
304
|
for (let i = proceedIdx - 1; i >= Math.max(0, proceedIdx - 30); i--) {
|
|
286
305
|
const line = lines[i];
|
|
287
|
-
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
|
|
306
|
+
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent|MCP)\b/i.test(line)) {
|
|
288
307
|
toolName = line.trim();
|
|
289
308
|
break;
|
|
290
309
|
}
|
|
291
310
|
contextLines.unshift(line);
|
|
292
311
|
}
|
|
312
|
+
if (toolName === 'Generic approval') {
|
|
313
|
+
const durable = _toolNameFromDurableOption(lines, proceedIdx);
|
|
314
|
+
if (durable) toolName = durable;
|
|
315
|
+
}
|
|
293
316
|
|
|
294
317
|
const fullContext = lines.slice(Math.max(0, proceedIdx - 20), Math.min(lines.length, proceedIdx + 12)).join('\n');
|
|
295
318
|
return {
|
|
@@ -373,7 +396,7 @@ function parseApprovalContext(cleanText, providerId) {
|
|
|
373
396
|
for (let i = endIdx - 1; i >= Math.max(0, endIdx - 30); i--) {
|
|
374
397
|
const line = lines[i];
|
|
375
398
|
// Detect tool headers (Claude Code shows "Bash command", "⏺ Bash(...)", "Edit", etc.)
|
|
376
|
-
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
|
|
399
|
+
if (/^[⏺●]?\s*(Bash command|Bash|Edit|Write|Read|Glob|Grep|Fetch|WebFetch|Web ?Search|NotebookEdit|TodoWrite|Agent)\b/.test(line)) {
|
|
377
400
|
toolName = line.trim();
|
|
378
401
|
break;
|
|
379
402
|
}
|
|
@@ -403,6 +426,10 @@ function parseApprovalContext(cleanText, providerId) {
|
|
|
403
426
|
if (fileOp) { toolName = fileOp.toolName; fileOpCommand = fileOp.command; }
|
|
404
427
|
}
|
|
405
428
|
|
|
429
|
+
// Still no header → derive a name from the durable grant option so the tool is
|
|
430
|
+
// targetable instead of 'Unknown' (mirror of the claude-code provider path).
|
|
431
|
+
if (!toolName) toolName = _toolNameFromDurableOption(lines, proceedIdx);
|
|
432
|
+
|
|
406
433
|
// Bash approvals render the command followed by one dimmed prose description
|
|
407
434
|
// line; drop it from the command so titles/signatures stay command-shaped
|
|
408
435
|
// (mirror of the claude-code provider parse path).
|
|
@@ -586,6 +613,17 @@ function cardTextFingerprint(cleanText) {
|
|
|
586
613
|
function normalizeCommandSignature(toolName, command) {
|
|
587
614
|
const tool = (toolName || '').replace(/^[⏺●\s]+/, '').trim();
|
|
588
615
|
const cmd = (command || '').trim();
|
|
616
|
+
|
|
617
|
+
// Network-access approvals: the HOST is the meaningful, granular unit, so keep it
|
|
618
|
+
// in the signature ("network <host>"). Otherwise the non-Bash branch below would
|
|
619
|
+
// collapse every host to the bare tool name 'network' → one all-hosts group and a
|
|
620
|
+
// dangerously broad Network(*) rule. Runs before the empty-cmd guard so a host that
|
|
621
|
+
// only survives in the synthesized command is still captured.
|
|
622
|
+
if (/^network\b/i.test(tool)) {
|
|
623
|
+
const host = (cmd.match(/[a-z0-9-]+(?:\.[a-z0-9-]+)+/i) || [])[0];
|
|
624
|
+
return host ? `network ${host.toLowerCase()}` : 'network';
|
|
625
|
+
}
|
|
626
|
+
|
|
589
627
|
if (!cmd) return tool.toLowerCase();
|
|
590
628
|
|
|
591
629
|
// For non-Bash tools (Edit, Write, Read, etc.), signature is just the tool name
|
|
@@ -878,29 +916,121 @@ function _buildSessionContext(session) {
|
|
|
878
916
|
return { goal, cwd };
|
|
879
917
|
}
|
|
880
918
|
|
|
919
|
+
// Hard ceiling on the auto-approval verifier's LLM round-trip. The inner builtin
|
|
920
|
+
// verifier already has its own ~20s budget (lib/auto-approval-verifier BUILTIN_TIMEOUT_MS)
|
|
921
|
+
// and the Wall-E status fetch a 2.5s AbortSignal — but the await at the call sites had no
|
|
922
|
+
// overall guard, so a provider that ignores its abort signal could strand a live approval
|
|
923
|
+
// prompt indefinitely (the symptom behind the stuck Codex `search_code` banner). This sits
|
|
924
|
+
// just above the inner budget: it normally never fires (the inner timeout wins, preserving
|
|
925
|
+
// the verifier verdict) and only trips on a true hang.
|
|
926
|
+
const VERIFIER_OVERALL_TIMEOUT_MS = 25_000;
|
|
927
|
+
|
|
928
|
+
// Resolve `promise`, but never settle later than `ms`. On timeout OR rejection, resolve to
|
|
929
|
+
// `fallback`. The verifier is fail-OPEN (a disabled/unknown verdict → approve), so the safe
|
|
930
|
+
// fallback for a wedged verifier is the same "no block" sentinel — a stuck banner is worse
|
|
931
|
+
// than approving a medium-risk op the blocklist already cleared.
|
|
932
|
+
function _withTimeout(promise, ms, fallback) {
|
|
933
|
+
let timer;
|
|
934
|
+
const guard = new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); });
|
|
935
|
+
return Promise.race([
|
|
936
|
+
Promise.resolve(promise).then(
|
|
937
|
+
(v) => { clearTimeout(timer); return v; },
|
|
938
|
+
() => { clearTimeout(timer); return fallback; },
|
|
939
|
+
),
|
|
940
|
+
guard,
|
|
941
|
+
]);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Verifier posture (proposal P-E). The LLM verifier is fail-OPEN by design — when
|
|
945
|
+
// it can't produce a confident "unsafe" verdict (disabled, timeout, errored,
|
|
946
|
+
// 'unknown') the approval proceeds. That is correct for the bounded-blast-radius
|
|
947
|
+
// MEDIUM tier, but for HIGH-risk commands it means "approved blind" whenever the
|
|
948
|
+
// verifier can't vouch — which is exactly what happens to Codex sessions where the
|
|
949
|
+
// background verifier ~100% times out. So HIGH risk fails CLOSED: unless the
|
|
950
|
+
// verifier POSITIVELY cleared it (enabled + verdict==='safe'), escalate to the
|
|
951
|
+
// human instead of auto-approving. The dangerous-command blocklist remains the
|
|
952
|
+
// deterministic hard gate above all of this; this is the second net for the
|
|
953
|
+
// high-risk heuristic patterns (rm -rf /, --force, drop table, sudo, dd, …).
|
|
954
|
+
function _highRiskNeedsHumanWhenUnverified(riskLevel, verifier) {
|
|
955
|
+
if (riskLevel !== 'high') return false;
|
|
956
|
+
const positivelyCleared = !!(verifier && verifier.enabled && verifier.verdict === 'safe');
|
|
957
|
+
return !positivelyCleared;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Approval posture (proposal P-G) — one global knob over the UNCERTAIN MEDIUM tier
|
|
961
|
+
// only. It NEVER weakens the hard floors: the dangerous-command blocklist, deny
|
|
962
|
+
// rules, the HIGH-risk fail-closed gate (_highRiskNeedsHumanWhenUnverified), and
|
|
963
|
+
// forced egress escalation all run regardless of posture. User-allowed commands and
|
|
964
|
+
// learned-rule matches are explicit trust and are never held. Low risk always
|
|
965
|
+
// auto-approves. So posture moves exactly one boundary: what to do with a
|
|
966
|
+
// pure-heuristic ('auto') MEDIUM-risk action.
|
|
967
|
+
// - cautious → hold it for the human (escalate) instead of fail-open approving
|
|
968
|
+
// - balanced → today's behavior (verifier second-opinion, then approve)
|
|
969
|
+
// - autonomous → trust the heuristic and skip the LLM verifier round-trip for medium
|
|
970
|
+
const APPROVAL_POSTURES = new Set(['cautious', 'balanced', 'autonomous']);
|
|
971
|
+
function _approvalPosture() {
|
|
972
|
+
try {
|
|
973
|
+
if (!dbModule || typeof dbModule.getSetting !== 'function') return 'balanced';
|
|
974
|
+
const v = String(dbModule.getSetting('approval_posture', 'balanced') || 'balanced').toLowerCase();
|
|
975
|
+
return APPROVAL_POSTURES.has(v) ? v : 'balanced';
|
|
976
|
+
} catch { return 'balanced'; }
|
|
977
|
+
}
|
|
978
|
+
// Autonomous trusts the heuristic for MEDIUM and skips the (slower, sometimes
|
|
979
|
+
// timing-out) LLM verifier. HIGH still runs it. decidedBy guards keep this to the
|
|
980
|
+
// pure-heuristic tier.
|
|
981
|
+
function _postureSkipsMediumVerifier(posture, riskLevel) {
|
|
982
|
+
return posture === 'autonomous' && riskLevel === 'medium';
|
|
983
|
+
}
|
|
984
|
+
// Cautious holds an uncertain MEDIUM for the human. Only the 'auto' (pure-heuristic)
|
|
985
|
+
// tier — never a user-allow, a learned rule, low, or high.
|
|
986
|
+
function _postureHoldsMedium(posture, riskLevel, decidedBy) {
|
|
987
|
+
return posture === 'cautious' && riskLevel === 'medium' && decidedBy === 'auto';
|
|
988
|
+
}
|
|
989
|
+
|
|
881
990
|
async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, source, riskLevel, callModel) {
|
|
882
991
|
// Verifier scope: medium+ risk only. Clearly low-risk/read-only approvals skip
|
|
883
992
|
// the LLM second opinion — keeps the fast path fast and usable offline.
|
|
884
993
|
if (riskLevel && riskLevel !== 'medium' && riskLevel !== 'high') return null;
|
|
994
|
+
// Posture (P-G): autonomous trusts the heuristic for the medium tier and skips
|
|
995
|
+
// the verifier entirely (HIGH still runs it below). Cautious is handled after the
|
|
996
|
+
// verdict so it can hold an otherwise-approved medium.
|
|
997
|
+
const posture = _approvalPosture();
|
|
998
|
+
if (_postureSkipsMediumVerifier(posture, riskLevel) && source === 'auto') return null;
|
|
885
999
|
// Attach the warm session-context packet (goal + cwd) so the built-in verifier
|
|
886
1000
|
// can judge GOAL-ALIGNMENT, not just the command in isolation. Single point —
|
|
887
1001
|
// covers both call sites (allow-by-default + approval-rescue). Never overwrite a
|
|
888
1002
|
// packet the caller already supplied.
|
|
889
1003
|
if (context && !context.sessionContext) context.sessionContext = _buildSessionContext(session);
|
|
890
|
-
|
|
891
|
-
//
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1004
|
+
// Fail-open if the verifier wedges: a disabled-shaped verdict ⇒ the guard below
|
|
1005
|
+
// returns null ⇒ the approval proceeds, same as an 'unknown'/timeout verdict.
|
|
1006
|
+
const verifier = await _withTimeout(
|
|
1007
|
+
verifyIfEnabled({ context, dbModule, callModel }),
|
|
1008
|
+
VERIFIER_OVERALL_TIMEOUT_MS,
|
|
1009
|
+
{ enabled: false },
|
|
1010
|
+
);
|
|
1011
|
+
// Escalate when EITHER (a) the verifier is confidently "unsafe", OR (b) the
|
|
1012
|
+
// command is HIGH risk and the verifier could not positively clear it (P-E posture
|
|
1013
|
+
// — see _highRiskNeedsHumanWhenUnverified). MEDIUM risk stays fail-open: a disabled
|
|
1014
|
+
// verifier or an 'unknown'/timeout verdict falls through to APPROVE, so the fast
|
|
1015
|
+
// path stays fast and usable offline. The dangerous-command blocklist remains the
|
|
1016
|
+
// deterministic hard gate above this.
|
|
1017
|
+
const isUnsafe = !!(verifier.enabled && verifier.verdict === 'unsafe');
|
|
1018
|
+
const highRiskUnverified = !isUnsafe && _highRiskNeedsHumanWhenUnverified(riskLevel, verifier);
|
|
1019
|
+
// Cautious posture: hold an otherwise-approved pure-heuristic MEDIUM for the human.
|
|
1020
|
+
const cautiousHold = !isUnsafe && !highRiskUnverified && _postureHoldsMedium(posture, riskLevel, source);
|
|
1021
|
+
if (!isUnsafe && !highRiskUnverified && !cautiousHold) return null;
|
|
898
1022
|
|
|
899
1023
|
// Prefer the OBJECTIVE block reason (the category — e.g. "Runs arbitrary code
|
|
900
1024
|
// (node -e)") over the verifier's vague free-text, so the banner + Pending group
|
|
901
|
-
// tell the user WHY it was held. Fall back to the verifier's reason
|
|
1025
|
+
// tell the user WHY it was held. Fall back to the verifier's reason (unsafe case),
|
|
1026
|
+
// a cautious-posture message, or the high-risk-unverified message, then a default.
|
|
902
1027
|
const blocked = classifyBlockReason(context);
|
|
903
|
-
const reason = blocked.reason
|
|
1028
|
+
const reason = blocked.reason
|
|
1029
|
+
|| (isUnsafe
|
|
1030
|
+
? (verifier.reason || 'Auto-approval verifier flagged this command as high risk.')
|
|
1031
|
+
: cautiousHold
|
|
1032
|
+
? 'Cautious posture — holding this medium-risk action for your approval.'
|
|
1033
|
+
: 'High-risk command could not be automatically verified — approve once to allow it.');
|
|
904
1034
|
const parts = escalationCommandParts(context);
|
|
905
1035
|
// The group key the Permission "Pending" tab will bucket this under — same fn the
|
|
906
1036
|
// endpoint uses (lib/escalation-review.commandHead over the signature), so the
|
|
@@ -917,8 +1047,8 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
|
|
|
917
1047
|
warning: context.warning || '',
|
|
918
1048
|
decision: 'escalated',
|
|
919
1049
|
reasoning: reason,
|
|
920
|
-
decidedBy: 'verifier',
|
|
921
|
-
riskLevel: 'high',
|
|
1050
|
+
decidedBy: cautiousHold ? 'posture' : 'verifier',
|
|
1051
|
+
riskLevel: cautiousHold ? 'medium' : 'high',
|
|
922
1052
|
};
|
|
923
1053
|
let decisionId;
|
|
924
1054
|
try { decisionId = dbModule.addApprovalDecision?.(decision); } catch (e) { console.error('[approval-agent] verifier DB error:', e.message); }
|
|
@@ -927,7 +1057,7 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
|
|
|
927
1057
|
type: 'approval-decision',
|
|
928
1058
|
sessionId,
|
|
929
1059
|
decision: 'escalated',
|
|
930
|
-
decidedBy: 'verifier',
|
|
1060
|
+
decidedBy: cautiousHold ? 'posture' : 'verifier',
|
|
931
1061
|
decisionId,
|
|
932
1062
|
// Banner shows the actual command (matches the recorded commandSummary),
|
|
933
1063
|
// not the heuristic rule label.
|
|
@@ -939,7 +1069,7 @@ async function _verifyAutoApprovalOrBlock(sessionId, session, context, broadcast
|
|
|
939
1069
|
groupKey,
|
|
940
1070
|
riskLevel: decision.riskLevel,
|
|
941
1071
|
verifierSource: source || '',
|
|
942
|
-
verifierVerdict: verifier.verdict,
|
|
1072
|
+
verifierVerdict: verifier.verdict || (highRiskUnverified ? 'unverified' : ''),
|
|
943
1073
|
command: String(context.command || '').slice(0, 500),
|
|
944
1074
|
warning: context.warning || '',
|
|
945
1075
|
});
|
|
@@ -1125,6 +1255,55 @@ function _isDevCleanupCommand(cmd) {
|
|
|
1125
1255
|
return sawKill || sawTmpRm;
|
|
1126
1256
|
}
|
|
1127
1257
|
|
|
1258
|
+
// Network egress is a genuine security boundary (a confused/compromised agent can
|
|
1259
|
+
// exfiltrate to any host), so it is NOT blanket-approved. Only a small allowlist of
|
|
1260
|
+
// the agent's OWN provider backends auto-approves — stalling Codex on OpenAI's own
|
|
1261
|
+
// infra (e.g. ab.chatgpt.com) is pure friction with no egress benefit. Every other
|
|
1262
|
+
// host force-escalates to a granular, one-click Network(<host>) Pending rule the user
|
|
1263
|
+
// approves once. Suffix match so subdomains of an allowlisted apex are covered.
|
|
1264
|
+
// EDIT THIS LIST to broaden/narrow the auto-approved egress set.
|
|
1265
|
+
const NETWORK_ALLOWLIST = ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com'];
|
|
1266
|
+
function isAllowedNetworkHost(host) {
|
|
1267
|
+
const h = String(host || '').toLowerCase().trim().replace(/:\d+$/, '').replace(/\.$/, '');
|
|
1268
|
+
if (!h) return false;
|
|
1269
|
+
return NETWORK_ALLOWLIST.some((d) => h === d || h.endsWith('.' + d));
|
|
1270
|
+
}
|
|
1271
|
+
// Pull a hostname out of a Codex network-access prompt's question/reason text — used
|
|
1272
|
+
// as a fallback when the provider did not already extract context.networkHost.
|
|
1273
|
+
function extractNetworkHost(text) {
|
|
1274
|
+
const s = String(text || '');
|
|
1275
|
+
let m = s.match(/network access to ['"]?([a-z0-9.-]+\.[a-z]{2,})['"]?/i)
|
|
1276
|
+
|| s.match(/([a-z0-9.-]+\.[a-z]{2,})\s+is\s+not\s+in\s+the\s+allowed[_ ]?domains/i)
|
|
1277
|
+
|| s.match(/\b([a-z0-9-]+(?:\.[a-z0-9-]+)+\.[a-z]{2,})\b/i)
|
|
1278
|
+
|| s.match(/\b([a-z0-9-]+\.[a-z]{2,})\b/i);
|
|
1279
|
+
return m ? m[1].toLowerCase().replace(/[.'"]+$/, '') : '';
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Refresh the shared MCP annotation registry (lib/mcp-risk) from the persisted
|
|
1283
|
+
// `mcp_tool_annotations` setting so authoritative read/write classifications
|
|
1284
|
+
// override the verb heuristic. TTL-cached (and change-gated) so the hot approval
|
|
1285
|
+
// path doesn't re-read/re-parse every call; tolerant of a missing db or bad JSON
|
|
1286
|
+
// (leaves the registry as-is). An ABSENT setting is a no-op — only an explicit
|
|
1287
|
+
// value (e.g. `{}` to clear) replaces the registry. Shared by every decision path
|
|
1288
|
+
// because they all funnel through reviewWithHeuristics.
|
|
1289
|
+
const MCP_ANNO_TTL_MS = 5000;
|
|
1290
|
+
let _mcpAnnoRaw;
|
|
1291
|
+
let _mcpAnnoAt = 0;
|
|
1292
|
+
function _refreshMcpAnnotations() {
|
|
1293
|
+
try {
|
|
1294
|
+
if (!dbModule || typeof dbModule.getSetting !== 'function') return;
|
|
1295
|
+
const now = Date.now();
|
|
1296
|
+
if (now - _mcpAnnoAt < MCP_ANNO_TTL_MS) return;
|
|
1297
|
+
_mcpAnnoAt = now;
|
|
1298
|
+
const raw = dbModule.getSetting('mcp_tool_annotations', null);
|
|
1299
|
+
if (raw === _mcpAnnoRaw) return; // unchanged since last load
|
|
1300
|
+
_mcpAnnoRaw = raw;
|
|
1301
|
+
if (raw == null) return; // absent → leave the registry untouched
|
|
1302
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
1303
|
+
mcpRisk.loadAnnotations(parsed);
|
|
1304
|
+
} catch { /* keep whatever is registered; never block a decision on this */ }
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1128
1307
|
// Simple heuristic review when no API key is available
|
|
1129
1308
|
function reviewWithHeuristics(context) {
|
|
1130
1309
|
const cmd = (context.command || '').toLowerCase();
|
|
@@ -1150,9 +1329,19 @@ function reviewWithHeuristics(context) {
|
|
|
1150
1329
|
// "mcp__…" / "(mcp)" command text.
|
|
1151
1330
|
const isMcp = /^(?:plugin:|mcp__|mcp\b)/i.test(tool) || /\(mcp\)/i.test(cmd);
|
|
1152
1331
|
if (isMcp) {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1332
|
+
_refreshMcpAnnotations(); // pick up any persisted authoritative annotations
|
|
1333
|
+
// Read/write classification lives in lib/mcp-risk (shared with the MCP(...)
|
|
1334
|
+
// permission-rule matcher so the two never drift) and is authoritative-first:
|
|
1335
|
+
// a server's registered tool annotation wins, else the verb heuristic.
|
|
1336
|
+
// Some providers (codex-mcp) emit a literal toolName='MCP' and carry the real tool
|
|
1337
|
+
// verb in the structured `mcpTool` field, hiding it from a tool-name-only match — so
|
|
1338
|
+
// a read-only `search_code` looked verb-less and fell through to the medium verifier
|
|
1339
|
+
// path. Classify against the tool name PLUS `mcpTool` so the verb is visible wherever
|
|
1340
|
+
// the provider put it. When mcpTool is absent (Claude's `mcp__…` ids already carry the
|
|
1341
|
+
// verb in toolName), mcpTarget === tool, so nothing else changes.
|
|
1342
|
+
const mcpTarget = context.mcpTool ? `${tool} ${String(context.mcpTool).toLowerCase()}` : tool;
|
|
1343
|
+
const mcpClass = mcpRisk.classifyMcp({ server: context.mcpServer, tool: context.mcpTool, target: mcpTarget });
|
|
1344
|
+
if (mcpClass === 'read') {
|
|
1156
1345
|
return { decision: 'approve', reasoning: 'Read-only MCP operation (heuristic)', riskLevel: 'low',
|
|
1157
1346
|
ruleLabel: context.toolName || 'MCP read-only operation',
|
|
1158
1347
|
rulePattern: '',
|
|
@@ -1165,11 +1354,34 @@ function reviewWithHeuristics(context) {
|
|
|
1165
1354
|
ruleDescription: 'Routed to AI reviewer/verifier for a decision' };
|
|
1166
1355
|
}
|
|
1167
1356
|
|
|
1357
|
+
// Network-access approvals (Codex "Do you want to approve network access to <host>?").
|
|
1358
|
+
// Allowlisted provider-own hosts auto-approve (low). Every other host force-escalates
|
|
1359
|
+
// to a granular, generalized Network(<host>) Pending rule — NOT to the LLM verifier,
|
|
1360
|
+
// which can't reason about egress and would let it through when uncertain/down. The
|
|
1361
|
+
// verifier is a fail-OPEN gate; egress must fail-CLOSED to the human.
|
|
1362
|
+
if (/^network\b/.test(tool) || context.networkHost) {
|
|
1363
|
+
const host = String(
|
|
1364
|
+
context.networkHost || extractNetworkHost(context.command) || extractNetworkHost(context.warning),
|
|
1365
|
+
).toLowerCase();
|
|
1366
|
+
if (host && isAllowedNetworkHost(host)) {
|
|
1367
|
+
return { decision: 'approve', reasoning: `Network access to allowlisted host ${host} (heuristic)`, riskLevel: 'low',
|
|
1368
|
+
ruleLabel: `Network access to ${host}`, rulePattern: `Network(${host})`,
|
|
1369
|
+
ruleDescription: `Auto-approve network access to ${host}` };
|
|
1370
|
+
}
|
|
1371
|
+
return { decision: 'escalate', forceEscalate: true, riskLevel: 'medium',
|
|
1372
|
+
reasoning: host
|
|
1373
|
+
? `Network access to ${host} is not on the egress allowlist — approve once to allow it for the future.`
|
|
1374
|
+
: 'Network access requested — approve once to allow this host for the future.',
|
|
1375
|
+
ruleLabel: host ? `Network access to ${host}` : 'Network access',
|
|
1376
|
+
rulePattern: host ? `Network(${host})` : '',
|
|
1377
|
+
ruleDescription: host ? `Allow network access to ${host}` : 'Allow network access' };
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1168
1380
|
// Low-risk tools — auto-approve immediately (before high-risk content check,
|
|
1169
1381
|
// because Edit/Write diffs may contain code with "drop table" or "rm -rf" as
|
|
1170
1382
|
// string literals — those are code content, not dangerous operations).
|
|
1171
1383
|
const lowRisk = [
|
|
1172
|
-
/^(read|glob|grep|webfetch|notebookedit)/, // Read-only tools
|
|
1384
|
+
/^(read|glob|grep|webfetch|web ?search|notebookedit)/, // Read-only tools (incl. Web Search — read-only, no side effects, same tier as WebFetch)
|
|
1173
1385
|
/^(edit|write)\b/, // Edit/Write to project files — normal dev workflow
|
|
1174
1386
|
];
|
|
1175
1387
|
for (const re of lowRisk) {
|
|
@@ -2099,7 +2311,7 @@ async function decideApproval(context, session, options = {}) {
|
|
|
2099
2311
|
// 2) Permission Manager rules (the user's explicit allow/deny).
|
|
2100
2312
|
let permRules = [];
|
|
2101
2313
|
try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
|
|
2102
|
-
const permMatch = matchPermission({ toolName: context.toolName, command }, permRules);
|
|
2314
|
+
const permMatch = matchPermission({ toolName: context.toolName, command, mcpServer: context.mcpServer, mcpTool: context.mcpTool }, permRules);
|
|
2103
2315
|
if (permMatch && permMatch.action === 'deny') {
|
|
2104
2316
|
return {
|
|
2105
2317
|
decision: 'ask', decidedBy: 'user-deny', riskLevel: 'high',
|
|
@@ -2127,13 +2339,39 @@ async function decideApproval(context, session, options = {}) {
|
|
|
2127
2339
|
: matchingRule ? `Matched learned rule: ${matchingRule.label}`
|
|
2128
2340
|
: 'Auto-approved by default (not on the denylist)';
|
|
2129
2341
|
|
|
2342
|
+
// 3b) Heuristic-forced escalation (e.g. un-allowlisted network egress) — ask the
|
|
2343
|
+
// human deterministically, before the fail-open verifier, with the granular
|
|
2344
|
+
// Network(<host>) rule pre-suggested.
|
|
2345
|
+
if (!userAllowed && !matchingRule && heuristic && heuristic.forceEscalate) {
|
|
2346
|
+
return {
|
|
2347
|
+
decision: 'ask', decidedBy: 'heuristic', riskLevel: heuristic.riskLevel || 'medium',
|
|
2348
|
+
reason: heuristic.reasoning || 'Needs review before allowing.',
|
|
2349
|
+
label: heuristic.ruleLabel || context.toolName,
|
|
2350
|
+
suggestedRule: heuristic.rulePattern || '',
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2130
2354
|
// 4) Goal-aligned verifier — medium+ risk only; user-allowed commands skip it
|
|
2131
2355
|
// (the user has explicitly vouched). Only a confident "unsafe" verdict
|
|
2132
2356
|
// escalates; a disabled/safe/unknown verdict falls through to allow.
|
|
2133
|
-
|
|
2357
|
+
// Posture (P-G): autonomous trusts the heuristic for medium and skips the
|
|
2358
|
+
// verifier (HIGH still runs it); cautious is applied after, on the medium
|
|
2359
|
+
// fall-through, so it can hold an otherwise-approved action.
|
|
2360
|
+
const posture = _approvalPosture();
|
|
2361
|
+
const runVerifier = !userAllowed
|
|
2362
|
+
&& (riskLevel === 'high' || (riskLevel === 'medium' && !_postureSkipsMediumVerifier(posture, riskLevel)));
|
|
2363
|
+
if (runVerifier) {
|
|
2134
2364
|
if (context && !context.sessionContext) context.sessionContext = _buildSessionContext(session);
|
|
2365
|
+
// Bounded like the live-path verifier (see _withTimeout / VERIFIER_OVERALL_TIMEOUT_MS):
|
|
2366
|
+
// a wedged background model must not strand this approval. Fail-open on timeout/error.
|
|
2135
2367
|
let verifier = { enabled: false, verdict: 'unknown' };
|
|
2136
|
-
try {
|
|
2368
|
+
try {
|
|
2369
|
+
verifier = await _withTimeout(
|
|
2370
|
+
verifyIfEnabled({ context, dbModule, callModel }),
|
|
2371
|
+
VERIFIER_OVERALL_TIMEOUT_MS,
|
|
2372
|
+
{ enabled: false, verdict: 'unknown' },
|
|
2373
|
+
);
|
|
2374
|
+
} catch { verifier = { enabled: false, verdict: 'unknown' }; }
|
|
2137
2375
|
if (verifier.enabled && verifier.verdict === 'unsafe') {
|
|
2138
2376
|
const blocked = classifyBlockReason(context);
|
|
2139
2377
|
return {
|
|
@@ -2142,6 +2380,32 @@ async function decideApproval(context, session, options = {}) {
|
|
|
2142
2380
|
blockCategory: blocked.category || '', verifierVerdict: verifier.verdict, label,
|
|
2143
2381
|
};
|
|
2144
2382
|
}
|
|
2383
|
+
// P-E posture: HIGH risk the verifier could not positively clear (disabled /
|
|
2384
|
+
// timeout / unknown) fails CLOSED — ask the human rather than approve blind.
|
|
2385
|
+
// MEDIUM falls through to allow (bounded blast radius). Mirrors the live-path
|
|
2386
|
+
// _verifyAutoApprovalOrBlock so both entrypoints share one posture.
|
|
2387
|
+
if (_highRiskNeedsHumanWhenUnverified(riskLevel, verifier)) {
|
|
2388
|
+
const blocked = classifyBlockReason(context);
|
|
2389
|
+
return {
|
|
2390
|
+
decision: 'ask', decidedBy: 'heuristic', riskLevel: 'high',
|
|
2391
|
+
reason: blocked.reason
|
|
2392
|
+
|| (heuristic && heuristic.reasoning)
|
|
2393
|
+
|| 'High-risk command could not be automatically verified — approve once to allow it.',
|
|
2394
|
+
blockCategory: blocked.category || '', verifierVerdict: verifier.verdict || 'unverified',
|
|
2395
|
+
label, suggestedRule: (heuristic && heuristic.rulePattern) || '',
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// Posture (P-G): cautious holds an otherwise-approved pure-heuristic MEDIUM for
|
|
2401
|
+
// the human. Runs after every hard gate (blocklist/deny/high-fail-closed) and
|
|
2402
|
+
// never touches user-allow / learned-rule / low decisions.
|
|
2403
|
+
if (_postureHoldsMedium(posture, riskLevel, decidedBy)) {
|
|
2404
|
+
return {
|
|
2405
|
+
decision: 'ask', decidedBy: 'posture', riskLevel: 'medium',
|
|
2406
|
+
reason: 'Cautious posture — holding this medium-risk action for your approval.',
|
|
2407
|
+
label, suggestedRule: (heuristic && heuristic.rulePattern) || '',
|
|
2408
|
+
};
|
|
2145
2409
|
}
|
|
2146
2410
|
|
|
2147
2411
|
return {
|
|
@@ -2225,7 +2489,7 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
|
|
|
2225
2489
|
// verifier (the user has explicitly vouched for it).
|
|
2226
2490
|
let permRules = [];
|
|
2227
2491
|
try { permRules = typeof dbModule.listPermRules === 'function' ? dbModule.listPermRules({}) : []; } catch { permRules = []; }
|
|
2228
|
-
const permMatch = matchPermission({ toolName: context.toolName, command: context.command }, permRules);
|
|
2492
|
+
const permMatch = matchPermission({ toolName: context.toolName, command: context.command, mcpServer: context.mcpServer, mcpTool: context.mcpTool }, permRules);
|
|
2229
2493
|
if (permMatch && permMatch.action === 'deny') {
|
|
2230
2494
|
const reasoning = `Permission Manager deny rule matched: ${permMatch.rule}`;
|
|
2231
2495
|
try {
|
|
@@ -2270,6 +2534,37 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
|
|
|
2270
2534
|
: matchingRule ? `Matched learned rule: ${matchingRule.label}`
|
|
2271
2535
|
: 'Auto-approved by default (not on the denylist)';
|
|
2272
2536
|
|
|
2537
|
+
// Heuristic-forced escalation (e.g. un-allowlisted network egress): route straight
|
|
2538
|
+
// to the Pending "Needs Review" surface as a granular, generalized rule the user can
|
|
2539
|
+
// approve once. Deterministic — does NOT depend on the fail-open LLM verifier, which
|
|
2540
|
+
// could let an un-vouched host through when uncertain or down.
|
|
2541
|
+
if (!userAllowed && !matchingRule && heuristic && heuristic.forceEscalate) {
|
|
2542
|
+
const parts = escalationCommandParts(context);
|
|
2543
|
+
const groupKey = (() => { try { return commandHead(parts.signature) || ''; } catch { return ''; } })();
|
|
2544
|
+
const escReason = heuristic.reasoning || 'Needs review before allowing.';
|
|
2545
|
+
const escRisk = heuristic.riskLevel || 'medium';
|
|
2546
|
+
// Prefer the heuristic's human label (e.g. "Network access to <host>") over the
|
|
2547
|
+
// bare operative command (the host) so the Pending row reads as a category.
|
|
2548
|
+
const escLabel = heuristic.ruleLabel || parts.title || context.toolName || 'Approval needs review';
|
|
2549
|
+
let decisionId;
|
|
2550
|
+
try {
|
|
2551
|
+
decisionId = dbModule.addApprovalDecision({
|
|
2552
|
+
sessionId, toolName: context.toolName,
|
|
2553
|
+
commandSummary: escLabel,
|
|
2554
|
+
fullContext: context.fullContext.slice(0, 2000), warning: context.warning,
|
|
2555
|
+
decision: 'escalated', reasoning: escReason, decidedBy: 'heuristic', riskLevel: escRisk,
|
|
2556
|
+
commandSignature: parts.signature || commandSignature,
|
|
2557
|
+
});
|
|
2558
|
+
} catch (e) { console.error('[approval-agent] DB error:', e.message); }
|
|
2559
|
+
broadcastFn(sessionId, session, {
|
|
2560
|
+
type: 'approval-decision', sessionId, decision: 'escalated', decidedBy: 'heuristic', decisionId,
|
|
2561
|
+
label: escLabel, reasoning: escReason, groupKey, riskLevel: escRisk,
|
|
2562
|
+
suggestedRule: heuristic.rulePattern || '',
|
|
2563
|
+
command: (context.command || '').slice(0, 500), warning: context.warning,
|
|
2564
|
+
});
|
|
2565
|
+
return true;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2273
2568
|
if (!userAllowed) {
|
|
2274
2569
|
// Verifier scope: medium+ risk only — read-only/low-risk ops auto-approve fast.
|
|
2275
2570
|
const verifierBlock = await _verifyAutoApprovalOrBlock(sessionId, session, context, broadcastFn, label, decidedBy, riskLevel, callModel);
|
|
@@ -2309,6 +2604,8 @@ async function handleApprovalCheck(sessionId, session, cleanText, broadcastFn, p
|
|
|
2309
2604
|
|
|
2310
2605
|
module.exports = {
|
|
2311
2606
|
parseApprovalContext,
|
|
2607
|
+
_refreshMcpAnnotations,
|
|
2608
|
+
_resetMcpAnnotationCache: () => { _mcpAnnoAt = 0; _mcpAnnoRaw = undefined; },
|
|
2312
2609
|
isLiveApprovalPrompt,
|
|
2313
2610
|
isPlanApprovalPrompt,
|
|
2314
2611
|
planCardFingerprint,
|
|
@@ -2323,6 +2620,7 @@ module.exports = {
|
|
|
2323
2620
|
_isTmpScopedRmClause,
|
|
2324
2621
|
_isPortScopedKillClause,
|
|
2325
2622
|
_buildSessionContext,
|
|
2623
|
+
_withTimeout,
|
|
2326
2624
|
normalizeCommandSignature,
|
|
2327
2625
|
escalationCommandParts,
|
|
2328
2626
|
classifyBlockReason,
|