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