termux-dev 1.6.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.
Files changed (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
@@ -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
- * Robust path escape check: returns true if target path resolves outside current working directory
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
- const cwd = process.cwd();
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
- * Checks for destructive or dangerous shell commands
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 isDangerousBashCommand(commandStr) {
24
- if (!commandStr)
219
+ export function shellTouchesSecrets(commandStr) {
220
+ if (!commandStr || typeof commandStr !== 'string')
25
221
  return false;
26
- // Normalize whitespace: collapse multiple spaces/tabs into single space
27
- const norm = commandStr.toLowerCase().replace(/\s+/g, ' ').trim();
28
- // Pattern matching rm with combined flags (-rf, -fr, -r -f, etc.) targeting root, home, termux prefix, glob, or current/parent dir
29
- const isDangerousRm = /rm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\s+(?:\/|\/\*|~|~\/|\*|\.|\.\/|\.\.\/?.*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm) ||
30
- /rm\s+-[a-z]*r[a-z]*\s+-[a-z]*f[a-z]*\s+(?:\/|\/\*|~|~\/|\*|\.|\.\/|\.\.\/?.*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm) ||
31
- /rm\s+-[a-z]*f[a-z]*\s+-[a-z]*r[a-z]*\s+(?:\/|\/\*|~|~\/|\*|\.|\.\/|\.\.\/?.*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm);
32
- return (isDangerousRm ||
33
- norm.includes('rm -rf /') ||
34
- norm.includes('rm -rf ~') ||
35
- norm.includes('rm -rf *') ||
36
- norm.includes('rm -rf .') ||
37
- norm.includes('rm -rf ./') ||
38
- norm.includes('rm -rf ..') ||
39
- norm.includes('rm -fr .') ||
40
- norm.includes('rm -fr ./') ||
41
- norm.includes('rm -rf $prefix') ||
42
- norm.includes('rm -rf $home') ||
43
- norm.includes('rm -rf /data/data/com.termux') ||
44
- norm.includes('mkfs') ||
45
- norm.includes('dd if=') ||
46
- norm.includes(':(){ :|:& };:') ||
47
- norm.includes('chmod -r 777 /') ||
48
- norm.includes('> /dev/sda') ||
49
- norm.includes('format c:'));
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
- constructor(autoApprove = false, bashAllowlist = []) {
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
- // Critical safety net: even in YOLO mode, warn and confirm destructive system commands
80
- if (this.autoApprove) {
81
- if (t === 'bash' && isDangerousBashCommand(cmd)) {
82
- return true;
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
- // Check Bash Allowlist: only allow if command is NOT dangerous AND does not use chaining/redirection
87
- if ((t === 'bash' || t === 'exec' || t === 'run_command') && cmd) {
88
- if (!isDangerousBashCommand(cmd) && !hasComplexChainingOrRedirection(cmd)) {
89
- const isAllowed = this.bashAllowlist.some(pattern => {
90
- const p = pattern.trim().toLowerCase();
91
- const lowerCmd = cmd.toLowerCase();
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; // Automatically allowed!
96
- }
351
+ if (isAllowed)
352
+ return false;
97
353
  }
98
354
  return true; // Requires user confirmation
99
355
  }
100
- // 2. Package installations require confirmation
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
- // 4. File writes, edits, or directory creations outside project CWD require confirmation
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🛡️ [PERMISSION DENIED] Action '${toolName}' requires user confirmation in safe mode, but no interactive terminal is available. Pass --yolo (-y) to auto-approve actions in headless mode.`));
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
- if (isDangerous) {
131
- p.log.error(pc.bold(pc.red('⚠️ [SECURITY WARNING] Agent requested a potentially dangerous system command!')));
132
- }
133
- else if (t.startsWith('mcp__')) {
134
- const parts = toolName.split('__');
135
- const serverName = parts[1] || 'mcp';
136
- const mcpToolName = parts.slice(2).join('__');
137
- p.log.warn(pc.bold(pc.yellow(`🛡️ [PERMISSION GUARD] Agent wants to call MCP Tool: ${pc.cyan(serverName)} / ${pc.green(mcpToolName)}`)));
138
- }
139
- else {
140
- p.log.warn(pc.bold(pc.yellow(`🛡️ [PERMISSION GUARD] Agent wants to execute: ${pc.cyan(toolName)}`)));
141
- }
142
- // Pretty preview of key arguments
143
- if (t === 'bash') {
144
- console.log(` ${pc.bold('Command:')} ${pc.green(cmd)}`);
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
- finally {
170
- // Restore raw mode
171
- if (wasRaw && process.stdin.isTTY) {
172
- process.stdin.setRawMode(true);
173
- process.stdin.resume();
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
- return allowed === true;
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
  }