termux-dev 1.5.0 → 1.8.0
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 +215 -163
- package/assets/banner.svg +23 -28
- package/assets/preview.png +0 -0
- package/assets/preview.svg +94 -0
- package/dist/cli/banner.js +7 -3
- package/dist/cli/clipboard-write.js +55 -0
- package/dist/cli/clipboard.js +182 -106
- package/dist/cli/colors.js +67 -0
- package/dist/cli/confirm.js +50 -0
- package/dist/cli/doctor.js +4 -4
- package/dist/cli/effort.js +80 -0
- package/dist/cli/export.js +3 -3
- package/dist/cli/files.js +19 -5
- package/dist/cli/headless.js +29 -14
- package/dist/cli/index.js +1395 -474
- package/dist/cli/keys.js +69 -0
- package/dist/cli/liveblock.js +45 -0
- package/dist/cli/log.js +19 -0
- package/dist/cli/markdown.js +158 -36
- package/dist/cli/mcp-panel.js +432 -0
- package/dist/cli/permission.js +84 -0
- package/dist/cli/picker.js +135 -0
- package/dist/cli/prompt.js +338 -121
- package/dist/cli/prompt_palette.js +6 -0
- package/dist/cli/resume.js +15 -0
- package/dist/cli/rewind.js +51 -0
- package/dist/cli/runtime_errors.js +6 -0
- package/dist/cli/server.js +85 -24
- package/dist/cli/settings.js +307 -0
- package/dist/cli/statusline.js +27 -1
- package/dist/cli/theme.js +126 -108
- package/dist/cli/transcript.js +114 -0
- package/dist/cli/typeahead.js +371 -0
- package/dist/cli/ui.js +1021 -0
- package/dist/cli/updater.js +23 -21
- package/dist/cli/usage-panel.js +242 -0
- package/dist/cli/width.js +178 -0
- package/dist/core/capabilities.js +433 -0
- package/dist/core/catalog-util.js +27 -0
- package/dist/core/checkpoint.js +90 -0
- package/dist/core/context-stats.js +29 -0
- package/dist/core/cost.js +191 -0
- package/dist/core/formats.js +199 -0
- package/dist/core/history.js +51 -1
- package/dist/core/hooks.js +79 -0
- package/dist/core/i18n.js +42 -0
- package/dist/core/local-config.js +69 -0
- package/dist/core/locales/de.js +210 -0
- package/dist/core/locales/en.js +221 -0
- package/dist/core/locales/es.js +210 -0
- package/dist/core/locales/fr.js +210 -0
- package/dist/core/locales/ja.js +210 -0
- package/dist/core/locales/pt.js +210 -0
- package/dist/core/locales/ru.js +210 -0
- package/dist/core/locales/uk.js +210 -0
- package/dist/core/locales/zh.js +210 -0
- package/dist/core/loop.js +125 -49
- package/dist/core/memory.js +39 -12
- package/dist/core/models.js +33 -20
- package/dist/core/network.js +12 -11
- package/dist/core/notify.js +54 -0
- package/dist/core/planfile.js +43 -0
- package/dist/core/price-catalog.json +1 -0
- package/dist/core/prices.js +249 -0
- package/dist/core/reasoning-catalog.json +1 -0
- package/dist/core/reasoning.js +393 -0
- package/dist/core/redact.js +41 -0
- package/dist/core/secure-file.js +22 -0
- package/dist/core/session.js +16 -5
- package/dist/core/snapshot.js +14 -2
- package/dist/core/title.js +61 -0
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +43 -7
- package/dist/core/vision-catalog.json +1 -0
- package/dist/core/vision.js +123 -0
- package/dist/mcp/client.js +36 -5
- package/dist/mcp/http-client.js +174 -0
- package/dist/mcp/manager.js +330 -93
- package/dist/permissions/guard.js +385 -117
- package/dist/prompts/builder.js +46 -38
- package/dist/providers/anthropic.js +517 -0
- package/dist/providers/index.js +18 -1
- package/dist/providers/openai.js +138 -125
- package/dist/providers/shared.js +116 -0
- package/dist/providers/toolguard.js +101 -0
- package/dist/tools/bash.js +141 -51
- package/dist/tools/diagnostics.js +76 -48
- package/dist/tools/fs.js +77 -5
- package/dist/tools/index.js +2 -1
- package/dist/tools/mcp.js +72 -0
- package/dist/tools/packages.js +16 -1
- package/dist/tools/plan.js +8 -8
- package/dist/tools/questions.js +50 -54
- package/dist/tools/server.js +6 -0
- package/dist/tools/todo.js +20 -18
- package/dist/tools/web.js +143 -8
- package/package.json +4 -2
- package/assets/preview_work1.png +0 -0
|
@@ -1,52 +1,262 @@
|
|
|
1
|
-
import * as p from '@clack/prompts';
|
|
2
1
|
import pc from 'picocolors';
|
|
3
2
|
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import fsSync from 'fs';
|
|
5
|
+
import { askPermissionDecision } from '../cli/permission.js';
|
|
6
|
+
// ───────────────────────── path helpers ─────────────────────────
|
|
7
|
+
function isInside(base, target) {
|
|
8
|
+
const rel = path.relative(base, target);
|
|
9
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
|
|
10
|
+
}
|
|
11
|
+
/** Resolves a path against cwd, following symlinks of the nearest existing ancestor. */
|
|
12
|
+
function realResolve(target) {
|
|
13
|
+
const abs = path.resolve(process.cwd(), target);
|
|
14
|
+
let existing = abs;
|
|
15
|
+
const rest = [];
|
|
16
|
+
while (!fsSync.existsSync(existing)) {
|
|
17
|
+
const parent = path.dirname(existing);
|
|
18
|
+
if (parent === existing)
|
|
19
|
+
break;
|
|
20
|
+
rest.unshift(path.basename(existing));
|
|
21
|
+
existing = parent;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return path.join(fsSync.realpathSync(existing), ...rest);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return abs;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function realCwd() {
|
|
31
|
+
try {
|
|
32
|
+
return fsSync.realpathSync(process.cwd());
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return process.cwd();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
4
38
|
/**
|
|
5
|
-
*
|
|
39
|
+
* Returns true if the path resolves outside the current working directory
|
|
40
|
+
* (symlinks are followed, so a link inside the project cannot be used to escape it).
|
|
6
41
|
*/
|
|
7
|
-
function isPathOutsideCwd(targetPath) {
|
|
8
|
-
if (!targetPath)
|
|
42
|
+
export function isPathOutsideCwd(targetPath) {
|
|
43
|
+
if (!targetPath || typeof targetPath !== 'string')
|
|
9
44
|
return false;
|
|
10
45
|
try {
|
|
11
|
-
|
|
12
|
-
const resolved = path.resolve(cwd, targetPath);
|
|
13
|
-
const relative = path.relative(cwd, resolved);
|
|
14
|
-
return relative.startsWith('..') || path.isAbsolute(relative);
|
|
46
|
+
return !isInside(realCwd(), realResolve(targetPath));
|
|
15
47
|
}
|
|
16
48
|
catch {
|
|
17
49
|
return true;
|
|
18
50
|
}
|
|
19
51
|
}
|
|
52
|
+
// Credential stores that live in the home directory. Reading or writing these is
|
|
53
|
+
// always confirmed, even in YOLO mode.
|
|
54
|
+
const HOME_SENSITIVE_DIRS = ['.ssh', '.aws', '.gnupg', '.devx', '.kube', '.docker', '.azure', path.join('.config', 'gcloud'), path.join('.config', 'gh')];
|
|
55
|
+
const HOME_SENSITIVE_FILES = ['.devxrc.json', '.netrc', '.npmrc', '.pypirc', '.git-credentials', '.pgpass', '.bash_history', '.zsh_history'];
|
|
56
|
+
export function isCredentialStore(targetPath) {
|
|
57
|
+
if (!targetPath || typeof targetPath !== 'string')
|
|
58
|
+
return false;
|
|
59
|
+
try {
|
|
60
|
+
const abs = realResolve(targetPath);
|
|
61
|
+
let home = os.homedir();
|
|
62
|
+
try {
|
|
63
|
+
home = fsSync.realpathSync(home);
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
return (HOME_SENSITIVE_DIRS.some(d => isInside(path.join(home, d), abs)) ||
|
|
67
|
+
HOME_SENSITIVE_FILES.some(f => abs === path.join(home, f)));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const SECRET_BASENAME = /^(id_(rsa|dsa|ecdsa|ed25519)|\.env(\..+)?|\.netrc|\.npmrc|credentials)$/i;
|
|
74
|
+
const ENV_TEMPLATE = /^\.env(\..+)?\.(example|sample|template|dist|defaults?)$/i;
|
|
75
|
+
const SECRET_EXT = new Set(['.pem', '.key', '.p12', '.pfx', '.keystore', '.jks']);
|
|
76
|
+
/** Private keys and dotenv files, wherever they are. */
|
|
77
|
+
export function isSecretFile(targetPath) {
|
|
78
|
+
if (!targetPath || typeof targetPath !== 'string')
|
|
79
|
+
return false;
|
|
80
|
+
const base = path.basename(targetPath);
|
|
81
|
+
if (ENV_TEMPLATE.test(base))
|
|
82
|
+
return false;
|
|
83
|
+
return SECRET_BASENAME.test(base) || SECRET_EXT.has(path.extname(base).toLowerCase());
|
|
84
|
+
}
|
|
85
|
+
/** Classifies a path argument of a shell command relative to the project directory. */
|
|
86
|
+
function classifyTarget(raw) {
|
|
87
|
+
let t = raw.trim().replace(/^["']|["']$/g, '');
|
|
88
|
+
if (!t)
|
|
89
|
+
return 'unknown';
|
|
90
|
+
const home = os.homedir();
|
|
91
|
+
t = t.replace(/^~(?=\/|\\|$)/, home).replace(/^\$\{?HOME\}?(?=\/|$)/, home);
|
|
92
|
+
if (process.env.PREFIX) {
|
|
93
|
+
t = t.replace(/^\$\{?PREFIX\}?(?=\/|$)/, process.env.PREFIX);
|
|
94
|
+
}
|
|
95
|
+
if (/[$`]/.test(t))
|
|
96
|
+
return 'unknown'; // unexpanded variable: may be empty or anything
|
|
97
|
+
// For globs, the directory the glob expands in is what matters.
|
|
98
|
+
const globIdx = t.search(/[*?[]/);
|
|
99
|
+
let dirPart = t;
|
|
100
|
+
if (globIdx !== -1) {
|
|
101
|
+
const slash = t.lastIndexOf('/', globIdx);
|
|
102
|
+
dirPart = slash >= 0 ? t.slice(0, slash + 1) || '/' : '.';
|
|
103
|
+
}
|
|
104
|
+
const abs = path.resolve(process.cwd(), dirPart);
|
|
105
|
+
if (abs === path.parse(abs).root || abs === home)
|
|
106
|
+
return 'root';
|
|
107
|
+
const cwd = process.cwd();
|
|
108
|
+
if (abs === cwd)
|
|
109
|
+
return 'cwd';
|
|
110
|
+
if (isInside(cwd, abs))
|
|
111
|
+
return 'inside';
|
|
112
|
+
const tmp = os.tmpdir();
|
|
113
|
+
if (abs !== tmp && isInside(tmp, abs))
|
|
114
|
+
return 'inside';
|
|
115
|
+
return 'outside';
|
|
116
|
+
}
|
|
117
|
+
const RISKY_FOR_RECURSIVE_DELETE = new Set(['root', 'outside', 'cwd', 'unknown']);
|
|
118
|
+
const RISKY_FOR_RECURSIVE_CHANGE = new Set(['root', 'outside', 'unknown']);
|
|
119
|
+
function tokenize(segment) {
|
|
120
|
+
const tokens = [];
|
|
121
|
+
const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
|
|
122
|
+
let m;
|
|
123
|
+
while ((m = re.exec(segment)) !== null) {
|
|
124
|
+
tokens.push(m[1] ?? m[2] ?? m[3]);
|
|
125
|
+
}
|
|
126
|
+
return tokens;
|
|
127
|
+
}
|
|
128
|
+
const hasRecursiveFlag = (flags) => flags.some(f => f === '--recursive' || /^-[a-zA-Z]*[rR][a-zA-Z]*$/.test(f));
|
|
129
|
+
const ALWAYS_DANGEROUS_COMMANDS = new Set([
|
|
130
|
+
'sudo', 'su', 'doas', 'pkexec',
|
|
131
|
+
'mke2fs', 'fdisk', 'sfdisk', 'cfdisk', 'parted', 'wipefs', 'diskpart',
|
|
132
|
+
'shutdown', 'reboot', 'halt', 'poweroff',
|
|
133
|
+
]);
|
|
134
|
+
function analyzeSegment(tokens) {
|
|
135
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
136
|
+
const name = path.basename(tokens[i]).toLowerCase().replace(/\.exe$/, '');
|
|
137
|
+
const args = tokens.slice(i + 1);
|
|
138
|
+
const flags = args.filter(a => a.startsWith('-') && a !== '-' && a !== '--');
|
|
139
|
+
const operands = args.filter(a => !a.startsWith('-'));
|
|
140
|
+
if (ALWAYS_DANGEROUS_COMMANDS.has(name) || name === 'mkfs' || name.startsWith('mkfs.'))
|
|
141
|
+
return true;
|
|
142
|
+
switch (name) {
|
|
143
|
+
case 'rm':
|
|
144
|
+
case 'unlink':
|
|
145
|
+
case 'shred': {
|
|
146
|
+
if (flags.includes('--no-preserve-root'))
|
|
147
|
+
return true;
|
|
148
|
+
const recursive = hasRecursiveFlag(flags);
|
|
149
|
+
for (const target of operands) {
|
|
150
|
+
const kind = classifyTarget(target);
|
|
151
|
+
if (kind === 'root')
|
|
152
|
+
return true;
|
|
153
|
+
if (recursive && RISKY_FOR_RECURSIVE_DELETE.has(kind))
|
|
154
|
+
return true;
|
|
155
|
+
if (isCredentialStore(target.replace(/^~(?=\/|$)/, os.homedir())))
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'find': {
|
|
161
|
+
const hasDelete = args.includes('-delete');
|
|
162
|
+
const execsRm = args.some((a, idx) => ['-exec', '-execdir', '-ok'].includes(a) && /^(rm|shred|unlink)$/.test(path.basename(args[idx + 1] || '')));
|
|
163
|
+
if (!hasDelete && !execsRm)
|
|
164
|
+
break;
|
|
165
|
+
const firstPredicate = args.findIndex(a => a.startsWith('-') || a === '(' || a === '!');
|
|
166
|
+
const roots = firstPredicate === -1 ? args : args.slice(0, firstPredicate);
|
|
167
|
+
const predicates = firstPredicate === -1 ? [] : args.slice(firstPredicate).filter(a => a !== '-delete');
|
|
168
|
+
const effectiveRoots = roots.length ? roots : ['.'];
|
|
169
|
+
for (const root of effectiveRoots) {
|
|
170
|
+
const kind = classifyTarget(root);
|
|
171
|
+
if (RISKY_FOR_RECURSIVE_CHANGE.has(kind))
|
|
172
|
+
return true;
|
|
173
|
+
// `find . -delete` with no filter wipes the whole project.
|
|
174
|
+
if (kind === 'cwd' && predicates.length === 0)
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case 'chmod':
|
|
180
|
+
case 'chown':
|
|
181
|
+
case 'chgrp': {
|
|
182
|
+
if (!hasRecursiveFlag(flags))
|
|
183
|
+
break;
|
|
184
|
+
// The first operand is the mode / owner, the rest are paths.
|
|
185
|
+
for (const target of operands.slice(1)) {
|
|
186
|
+
if (RISKY_FOR_RECURSIVE_CHANGE.has(classifyTarget(target)))
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'dd': {
|
|
192
|
+
if (args.some(a => a.startsWith('if=') || a.startsWith('of=/dev/')))
|
|
193
|
+
return true;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case 'format': {
|
|
197
|
+
if (args.some(a => /^[a-z]:$/i.test(a)))
|
|
198
|
+
return true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'git': {
|
|
202
|
+
if (args.includes('push') && args.some(a => /^(-f|--force|--force-with-lease(=.*)?|--mirror|--delete)$/.test(a) || /^\+\S+/.test(a)))
|
|
203
|
+
return true;
|
|
204
|
+
if (args.includes('reset') && args.includes('--hard'))
|
|
205
|
+
return true;
|
|
206
|
+
if (args.includes('clean') && args.some(a => a === '--force' || /^-[a-zA-Z]*f/.test(a)))
|
|
207
|
+
return true;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
20
214
|
/**
|
|
21
|
-
*
|
|
215
|
+
* A shell command that names a credentials store or a secrets file (`cat ~/.devxrc.json`,
|
|
216
|
+
* `grep key ~/.ssh/config`, `head .env`). Such a command is asked about even in YOLO mode:
|
|
217
|
+
* its output would reach the model and the chat. Templates such as `.env.example` are fine.
|
|
22
218
|
*/
|
|
23
|
-
function
|
|
24
|
-
if (!commandStr)
|
|
219
|
+
export function shellTouchesSecrets(commandStr) {
|
|
220
|
+
if (!commandStr || typeof commandStr !== 'string')
|
|
25
221
|
return false;
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
222
|
+
const home = os.homedir();
|
|
223
|
+
const tokens = commandStr.split(/[\s;|&<>()`]+/).filter(Boolean);
|
|
224
|
+
return tokens.some((raw) => {
|
|
225
|
+
const t = raw.replace(/^["']+|["']+$/g, '').replace(/^~(?=\/|$)/, home).replace(/^\$\{?HOME\}?(?=\/|$)/, home);
|
|
226
|
+
if (!t || t.startsWith('-') || /[$*?]/.test(t))
|
|
227
|
+
return false;
|
|
228
|
+
// `cd ~ && cat .devxrc.json`: the folder is not in the token, so the file name alone counts.
|
|
229
|
+
return isCredentialStore(t) || isSecretFile(t) || HOME_SENSITIVE_FILES.includes(path.basename(t));
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const SHELL_INTERPRETER = '(?:\\S*\\/)?(?:ba|z|da|k|c|fi|a)?sh';
|
|
233
|
+
/**
|
|
234
|
+
* Detects destructive or remote-code-execution shell commands. This is a safety
|
|
235
|
+
* net for YOLO mode, not a sandbox: it works on tokens (so `rm --recursive --force ~`
|
|
236
|
+
* and `find ~ -delete` are caught), but a determined obfuscation can still slip through.
|
|
237
|
+
*/
|
|
238
|
+
export function isDangerousBashCommand(commandStr) {
|
|
239
|
+
if (!commandStr || typeof commandStr !== 'string')
|
|
240
|
+
return false;
|
|
241
|
+
const norm = commandStr.replace(/\s+/g, ' ').trim();
|
|
242
|
+
const lower = norm.toLowerCase();
|
|
243
|
+
// Whole-command patterns (these need the pipes / redirections intact).
|
|
244
|
+
const patterns = [
|
|
245
|
+
/:\s*\(\s*\)\s*\{/, // fork bomb
|
|
246
|
+
new RegExp(`\\|\\s*(?:sudo\\s+(?:-\\S+\\s+)*)?${SHELL_INTERPRETER}\\b`), // anything piped into a shell
|
|
247
|
+
/\b(?:curl|wget|fetch)\b[^|]*\|\s*(?:sudo\s+)?(?:python[0-9.]*|node|perl|ruby|php)\b/, // download | interpreter
|
|
248
|
+
/<\(\s*(?:curl|wget)\b/, // sh <(curl ...)
|
|
249
|
+
/\$\(\s*(?:curl|wget)\b/, // sh -c "$(curl ...)"
|
|
250
|
+
/>\s*\/dev\/(?:sd|hd|vd|nvme|mmcblk|disk|rdisk)/, // overwrite a block device
|
|
251
|
+
/>>?\s*["']?(?:~|\$\{?home\}?|\/home\/[^/\s]+|\/root|\/users\/[^/\s]+)\/\.(?:ssh|bashrc|zshrc|profile|bash_profile|zprofile|gnupg|aws|netrc)/, // plant keys / shell hooks
|
|
252
|
+
/\b(?:rd|rmdir)\s+(?:\/[a-z]\s+)*\/s\b/, // Windows recursive delete
|
|
253
|
+
/\bdel\b[^&|]*\s\/s\b/,
|
|
254
|
+
];
|
|
255
|
+
if (patterns.some(re => re.test(lower)))
|
|
256
|
+
return true;
|
|
257
|
+
// Per-command analysis on tokens.
|
|
258
|
+
const segments = commandStr.split(/\$\(|`|&&|\|\||[;|&()\n\r]/);
|
|
259
|
+
return segments.some(segment => analyzeSegment(tokenize(segment)));
|
|
50
260
|
}
|
|
51
261
|
/**
|
|
52
262
|
* Checks if a command contains chaining, redirection, or subshell operators.
|
|
@@ -66,113 +276,171 @@ function hasComplexChainingOrRedirection(commandStr) {
|
|
|
66
276
|
commandStr.includes('\n') ||
|
|
67
277
|
commandStr.includes('\r'));
|
|
68
278
|
}
|
|
279
|
+
// ───────────────────────── guard ─────────────────────────
|
|
280
|
+
const SHELL_TOOLS = new Set(['bash', 'exec', 'run_command', 'execute_command']);
|
|
281
|
+
const READ_TOOLS = new Set(['read_file', 'view_file', 'read', 'list_dir', 'list_files', 'ls', 'search', 'grep_search', 'grep', 'diagnose_code']);
|
|
282
|
+
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'make_dir', 'mkdir', 'create_file', 'write_to_file', 'patch']);
|
|
283
|
+
function pathArg(args) {
|
|
284
|
+
const v = args?.path ?? args?.dir ?? args?.targetFile ?? args?.filePath ?? args?.file;
|
|
285
|
+
return typeof v === 'string' ? v : undefined;
|
|
286
|
+
}
|
|
287
|
+
export function newPermissionMemory() {
|
|
288
|
+
return { bash: new Set(), tools: new Set() };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* What an "Allow for the session / Always" answer would remember for a request: a plain single
|
|
292
|
+
* command by its program and subcommand (`npm test`, `git status`, `ls`), or an MCP tool by its
|
|
293
|
+
* name. Dangerous commands, commands that read secrets, chains and redirections are never
|
|
294
|
+
* remembered: those are asked every time.
|
|
295
|
+
*/
|
|
296
|
+
export function rememberKeyFor(toolName, args) {
|
|
297
|
+
const t = (toolName || '').toLowerCase();
|
|
298
|
+
if (t.startsWith('mcp__'))
|
|
299
|
+
return { kind: 'tool', key: toolName };
|
|
300
|
+
if (!SHELL_TOOLS.has(t))
|
|
301
|
+
return null;
|
|
302
|
+
const cmd = String(args?.command || args?.cmd || '').trim();
|
|
303
|
+
if (!cmd || isDangerousBashCommand(cmd) || shellTouchesSecrets(cmd) || hasComplexChainingOrRedirection(cmd))
|
|
304
|
+
return null;
|
|
305
|
+
const tokens = tokenize(cmd);
|
|
306
|
+
if (tokens.length === 0)
|
|
307
|
+
return null;
|
|
308
|
+
const program = path.basename(tokens[0]).toLowerCase();
|
|
309
|
+
if (!/^[a-z0-9._+-]+$/.test(program))
|
|
310
|
+
return null;
|
|
311
|
+
const sub = tokens[1];
|
|
312
|
+
// The subcommand is a plain word (`test`, `status`); a flag, a path or a value makes the program alone the key.
|
|
313
|
+
const withSub = sub && /^[a-z][a-z0-9:_-]{0,19}$/.test(sub) && tokens.length <= 3 ? `${program} ${sub.toLowerCase()}` : program;
|
|
314
|
+
return { kind: 'bash', key: withSub };
|
|
315
|
+
}
|
|
69
316
|
export class CLIConsoleGuard {
|
|
70
317
|
autoApprove;
|
|
71
318
|
bashAllowlist;
|
|
72
|
-
|
|
319
|
+
options;
|
|
320
|
+
memory;
|
|
321
|
+
constructor(autoApprove = false, bashAllowlist = [], options = {}) {
|
|
73
322
|
this.autoApprove = autoApprove;
|
|
74
323
|
this.bashAllowlist = bashAllowlist;
|
|
324
|
+
this.options = options;
|
|
325
|
+
this.memory = options.memory ?? newPermissionMemory();
|
|
75
326
|
}
|
|
76
327
|
check(toolName, args) {
|
|
77
328
|
const t = (toolName || '').toLowerCase();
|
|
78
329
|
const cmd = (args?.command || args?.cmd || '').trim();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
330
|
+
const target = pathArg(args);
|
|
331
|
+
// Critical safety net. These ask for confirmation even in YOLO mode.
|
|
332
|
+
if (SHELL_TOOLS.has(t) && (isDangerousBashCommand(cmd) || shellTouchesSecrets(cmd)))
|
|
333
|
+
return true;
|
|
334
|
+
if (target && (READ_TOOLS.has(t) || WRITE_TOOLS.has(t)) && isCredentialStore(target))
|
|
335
|
+
return true;
|
|
336
|
+
if (target && READ_TOOLS.has(t) && isSecretFile(target))
|
|
337
|
+
return true;
|
|
338
|
+
// Adding a server starts a program on this machine every time devx runs: always asked.
|
|
339
|
+
if (t === 'mcp_manage' && (args?.action === 'add' || args?.action === 'import_claude'))
|
|
340
|
+
return true;
|
|
341
|
+
if (this.autoApprove)
|
|
84
342
|
return false;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const isAllowed = this.bashAllowlist.some(pattern => {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
return lowerCmd === p || lowerCmd.startsWith(p + ' ');
|
|
343
|
+
// Bash allowlist: only for single, clean commands without chaining / redirection.
|
|
344
|
+
if (SHELL_TOOLS.has(t) && cmd) {
|
|
345
|
+
if (!hasComplexChainingOrRedirection(cmd)) {
|
|
346
|
+
const lowerCmd = cmd.toLowerCase();
|
|
347
|
+
const isAllowed = [...this.bashAllowlist, ...this.memory.bash].some(pattern => {
|
|
348
|
+
const pat = pattern.trim().toLowerCase();
|
|
349
|
+
return lowerCmd === pat || lowerCmd.startsWith(pat + ' ');
|
|
93
350
|
});
|
|
94
|
-
if (isAllowed)
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
351
|
+
if (isAllowed)
|
|
352
|
+
return false;
|
|
97
353
|
}
|
|
98
354
|
return true; // Requires user confirmation
|
|
99
355
|
}
|
|
100
|
-
|
|
101
|
-
if (t === 'install_package' || t === 'packages') {
|
|
356
|
+
if (t === 'install_package' || t === 'packages')
|
|
102
357
|
return true;
|
|
103
|
-
|
|
104
|
-
// 3. File deletions require confirmation
|
|
105
|
-
if (t === 'delete_file' || t === 'remove_file') {
|
|
358
|
+
if (t === 'delete_file' || t === 'remove_file')
|
|
106
359
|
return true;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (t === 'write_file' || t === 'edit_file' || t === 'make_dir' || t === 'mkdir') {
|
|
110
|
-
const target = args?.path || args?.dir || args?.targetFile || args?.filePath;
|
|
111
|
-
if (isPathOutsideCwd(target)) {
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
// 5. MCP Tools: ask if autoApprove is false
|
|
116
|
-
if (t.startsWith('mcp__')) {
|
|
360
|
+
// The preview server listens on all interfaces and serves the project folder.
|
|
361
|
+
if (t === 'serve_preview' && args?.action !== 'stop' && args?.action !== 'status')
|
|
117
362
|
return true;
|
|
118
|
-
|
|
363
|
+
// Anything that touches files outside the project needs approval (reads included).
|
|
364
|
+
if ((WRITE_TOOLS.has(t) || READ_TOOLS.has(t)) && isPathOutsideCwd(target))
|
|
365
|
+
return true;
|
|
366
|
+
if (t.startsWith('mcp__'))
|
|
367
|
+
return !this.memory.tools.has(toolName);
|
|
119
368
|
return false;
|
|
120
369
|
}
|
|
121
370
|
async askUser(toolName, args) {
|
|
122
|
-
const t = (toolName || '').toLowerCase();
|
|
123
|
-
const cmd = args?.command || args?.cmd || '';
|
|
124
|
-
const isDangerous = t === 'bash' && isDangerousBashCommand(cmd);
|
|
125
371
|
// In headless / non-interactive environment without TTY, deny confirmation-requiring actions immediately
|
|
126
372
|
if (!process.stdin.isTTY) {
|
|
127
|
-
console.error(pc.red(`\n
|
|
373
|
+
console.error(pc.red(`\n[PERMISSION DENIED] Action '${toolName}' requires user confirmation, but no interactive terminal is available. Pass --yolo (-y) to auto-approve regular actions in headless mode (dangerous commands and credential files still need a terminal).`));
|
|
128
374
|
return false;
|
|
129
375
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
else if (t === 'install_package') {
|
|
147
|
-
console.log(` ${pc.bold('Packages:')} ${pc.green((args.packages || []).join(', '))}`);
|
|
148
|
-
}
|
|
149
|
-
else if (args?.path || args?.targetFile) {
|
|
150
|
-
const pth = args.path || args.targetFile;
|
|
151
|
-
const isOutside = isPathOutsideCwd(pth);
|
|
152
|
-
console.log(` ${pc.bold('Target File:')} ${isOutside ? pc.red(pth + ' (OUTSIDE PROJECT)') : pc.green(pth)}`);
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
console.log(` ${pc.dim('Arguments:')} ${JSON.stringify(args, null, 2)}`);
|
|
156
|
-
}
|
|
157
|
-
// Pause raw mode for interactive prompt
|
|
158
|
-
const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
|
|
159
|
-
if (wasRaw) {
|
|
160
|
-
process.stdin.setRawMode(false);
|
|
161
|
-
}
|
|
162
|
-
let allowed = false;
|
|
163
|
-
try {
|
|
164
|
-
allowed = await p.confirm({
|
|
165
|
-
message: isDangerous ? pc.red('Confirm executing this dangerous command?') : 'Allow execution?',
|
|
166
|
-
initialValue: !isDangerous
|
|
167
|
-
});
|
|
376
|
+
const request = buildPermissionRequest(toolName, args);
|
|
377
|
+
// Only requests that are safe to remember offer "Session" and "Always".
|
|
378
|
+
const remember = request.danger ? null : rememberKeyFor(toolName, args);
|
|
379
|
+
const offer = remember ? { session: true, always: true } : {};
|
|
380
|
+
// Risky requests start on "Deny": a stray Enter must not approve them.
|
|
381
|
+
const defaultAnswer = request.danger ? 'deny' : 'allow';
|
|
382
|
+
const answer = this.options.decide
|
|
383
|
+
? await this.options.decide(request, offer, !!request.danger)
|
|
384
|
+
: await askPermissionDecision(request, defaultAnswer, undefined, offer);
|
|
385
|
+
if (answer === 'deny')
|
|
386
|
+
return false;
|
|
387
|
+
if (remember && (answer === 'session' || answer === 'always')) {
|
|
388
|
+
(remember.kind === 'bash' ? this.memory.bash : this.memory.tools).add(remember.key);
|
|
389
|
+
if (answer === 'always')
|
|
390
|
+
this.options.onAlways?.(remember);
|
|
168
391
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Describes what the agent wants to do, for the approval card. */
|
|
396
|
+
export function buildPermissionRequest(toolName, args) {
|
|
397
|
+
const t = (toolName || '').toLowerCase();
|
|
398
|
+
const cmd = String(args?.command || args?.cmd || '');
|
|
399
|
+
const target = pathArg(args);
|
|
400
|
+
const notes = [];
|
|
401
|
+
const sensitive = !!target && (isCredentialStore(target) || (READ_TOOLS.has(t) && isSecretFile(target)));
|
|
402
|
+
if (sensitive)
|
|
403
|
+
notes.push('this is a credentials / secrets file');
|
|
404
|
+
else if (target && isPathOutsideCwd(target))
|
|
405
|
+
notes.push('outside the project folder');
|
|
406
|
+
if (SHELL_TOOLS.has(t)) {
|
|
407
|
+
const danger = isDangerousBashCommand(cmd) || shellTouchesSecrets(cmd);
|
|
408
|
+
if (isDangerousBashCommand(cmd))
|
|
409
|
+
notes.push('looks destructive or runs downloaded code');
|
|
410
|
+
else if (shellTouchesSecrets(cmd))
|
|
411
|
+
notes.push('reads a credentials / secrets file: its content would reach the model');
|
|
412
|
+
return { title: t === 'bash' ? 'bash' : toolName, danger, lines: cmd.split('\n'), notes };
|
|
413
|
+
}
|
|
414
|
+
if (t === 'mcp_manage') {
|
|
415
|
+
if (args?.action === 'import_claude') {
|
|
416
|
+
return { title: 'mcp · import from Claude Code', lines: ['servers of ~/.claude.json -> ~/.devx/mcp.json'], notes: ['they start as programs on your machine'] };
|
|
175
417
|
}
|
|
176
|
-
|
|
418
|
+
const full = args?.url ? String(args.url) : [String(args?.command ?? ''), ...(Array.isArray(args?.args) ? args.args.map(String) : [])].join(' ');
|
|
419
|
+
const envNames = args?.env && typeof args.env === 'object' ? Object.keys(args.env) : [];
|
|
420
|
+
return {
|
|
421
|
+
title: `mcp · add server ${String(args?.name ?? '')}`,
|
|
422
|
+
lines: [full, ...(envNames.length ? [`env: ${envNames.join(', ')}`] : [])],
|
|
423
|
+
notes: ['saved to ~/.devx/mcp.json and started now and at every start of devx'],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (t.startsWith('mcp__')) {
|
|
427
|
+
const parts = toolName.split('__');
|
|
428
|
+
const server = parts[1] || 'mcp';
|
|
429
|
+
const tool = parts.slice(2).join('__');
|
|
430
|
+
const lines = args && Object.keys(args).length > 0 ? JSON.stringify(args, null, 2).split('\n') : ['(no arguments)'];
|
|
431
|
+
return { title: `mcp · ${server} / ${tool}`, lines, notes };
|
|
432
|
+
}
|
|
433
|
+
if (t === 'install_package' || t === 'packages') {
|
|
434
|
+
const manager = args?.manager ? `${args.manager}: ` : '';
|
|
435
|
+
return { title: 'install_package', lines: [`${manager}${String(args?.package ?? '')}${args?.dev ? ' (dev dependency)' : ''}`], notes };
|
|
436
|
+
}
|
|
437
|
+
if (t === 'serve_preview') {
|
|
438
|
+
notes.push('serves the project folder to your local network');
|
|
439
|
+
return { title: 'serve_preview', lines: [`${args?.action ?? 'start'}${args?.port ? ` on port ${args.port}` : ''}`], notes };
|
|
440
|
+
}
|
|
441
|
+
if (target) {
|
|
442
|
+
return { title: toolName, danger: sensitive, lines: [target], notes };
|
|
177
443
|
}
|
|
444
|
+
const lines = args && Object.keys(args).length > 0 ? JSON.stringify(args, null, 2).split('\n') : ['(no arguments)'];
|
|
445
|
+
return { title: toolName, lines, notes };
|
|
178
446
|
}
|