codeep 2.18.1 → 2.19.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 (49) hide show
  1. package/README.md +53 -16
  2. package/dist/acp/commands.js +11 -55
  3. package/dist/acp/protocol.d.ts +34 -0
  4. package/dist/acp/server.d.ts +6 -1
  5. package/dist/acp/server.js +97 -2
  6. package/dist/api/index.js +9 -0
  7. package/dist/commands/core/index.d.ts +19 -0
  8. package/dist/commands/core/index.js +28 -0
  9. package/dist/commands/core/keysync.d.ts +2 -0
  10. package/dist/commands/core/keysync.js +34 -0
  11. package/dist/commands/core/telemetry.d.ts +2 -0
  12. package/dist/commands/core/telemetry.js +34 -0
  13. package/dist/config/index.js +2 -2
  14. package/dist/renderer/App.d.ts +9 -48
  15. package/dist/renderer/App.js +113 -338
  16. package/dist/renderer/Screen.d.ts +13 -0
  17. package/dist/renderer/Screen.js +22 -0
  18. package/dist/renderer/commands/registry.js +3 -3
  19. package/dist/renderer/commands.js +19 -51
  20. package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
  21. package/dist/renderer/components/CommandAutocomplete.js +103 -0
  22. package/dist/renderer/components/HunkPicker.d.ts +48 -0
  23. package/dist/renderer/components/HunkPicker.js +140 -0
  24. package/dist/renderer/components/MentionPicker.d.ts +60 -0
  25. package/dist/renderer/components/MentionPicker.js +111 -0
  26. package/dist/renderer/components/PasteDialog.d.ts +43 -0
  27. package/dist/renderer/components/PasteDialog.js +70 -0
  28. package/dist/renderer/layout.js +1 -0
  29. package/dist/renderer/main.js +15 -39
  30. package/dist/utils/agent.js +121 -26
  31. package/dist/utils/agentChat.d.ts +11 -4
  32. package/dist/utils/agentChat.js +53 -25
  33. package/dist/utils/codeepCloud.d.ts +3 -0
  34. package/dist/utils/codeepCloud.js +62 -7
  35. package/dist/utils/personalities.d.ts +63 -5
  36. package/dist/utils/personalities.js +583 -31
  37. package/dist/utils/shell.d.ts +11 -1
  38. package/dist/utils/shell.js +169 -82
  39. package/dist/utils/ssrfGuard.d.ts +18 -0
  40. package/dist/utils/ssrfGuard.js +83 -0
  41. package/dist/utils/taskPlanner.d.ts +7 -1
  42. package/dist/utils/taskPlanner.js +16 -7
  43. package/dist/utils/toolExecution.d.ts +1 -0
  44. package/dist/utils/toolExecution.js +48 -88
  45. package/dist/utils/tools.d.ts +3 -3
  46. package/dist/utils/tools.js +18 -13
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
@@ -10,13 +10,21 @@
10
10
  *
11
11
  * Project shadows global shadows built-in, by name.
12
12
  *
13
- * File format (project / global):
13
+ * File format (project / global): legacy prompt-only Markdown remains valid;
14
+ * structured custom bots add a small versioned frontmatter block:
14
15
  * ```
15
- * # Personality: Concise Reviewer
16
- * <free-form Markdown body — gets appended to system prompt verbatim>
16
+ * ---
17
+ * codeep: custom-bot/v1
18
+ * model: automatic
19
+ * tools: [files, tests, git]
20
+ * scope: all
21
+ * projects: []
22
+ * ---
23
+ * # Concise Reviewer
24
+ * <behavior sections — appended to the system prompt>
17
25
  * ```
18
- * (The first H1 line is parsed as the display name; everything else is
19
- * the prompt body.)
26
+ * The first H1 is the display name. Tools/model/scope are enforced only for
27
+ * structured files; old files stay unrestricted.
20
28
  *
21
29
  * Activation:
22
30
  * - `config.activePersonality` holds the active name (or null/undefined
@@ -27,9 +35,196 @@
27
35
  * - Persists across sessions until cleared with `/personality off`.
28
36
  */
29
37
  import { readFileSync, readdirSync, existsSync } from 'fs';
30
- import { join } from 'path';
38
+ import { basename, join } from 'path';
31
39
  import { homedir } from 'os';
32
40
  import { config } from '../config/index.js';
41
+ import { getProvider } from '../config/providers.js';
42
+ const CAPABILITIES = new Set([
43
+ 'files', 'terminal', 'tests', 'git', 'web', 'mcp',
44
+ ]);
45
+ const FILE_TOOLS = [
46
+ 'read_file', 'write_file', 'edit_file', 'delete_file', 'list_files',
47
+ 'create_directory', 'search_code', 'find_files',
48
+ ];
49
+ const WEB_TOOLS = [
50
+ 'fetch_url', 'web_search', 'web_read', 'github_read', 'minimax_web_search',
51
+ ];
52
+ function unquoteScalar(value) {
53
+ return (value ?? '').trim().replace(/^(["'])(.*)\1$/, '$2').trim();
54
+ }
55
+ function splitFrontmatter(raw) {
56
+ const normalised = raw.replace(/^\uFEFF/, '');
57
+ if (!normalised.startsWith('---\n') && !normalised.startsWith('---\r\n')) {
58
+ return { meta: {}, body: normalised, codeepDeclared: false, versioned: false };
59
+ }
60
+ const match = normalised.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
61
+ if (!match)
62
+ return { meta: {}, body: normalised, codeepDeclared: false, versioned: false };
63
+ const meta = {};
64
+ for (const line of match[1].split(/\r?\n/)) {
65
+ const parsed = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*:\s*(.*?)\s*$/);
66
+ if (parsed)
67
+ meta[parsed[1].toLowerCase()] = parsed[2];
68
+ }
69
+ const codeepDeclared = Object.prototype.hasOwnProperty.call(meta, 'codeep');
70
+ return {
71
+ meta,
72
+ body: normalised.slice(match[0].length),
73
+ codeepDeclared,
74
+ versioned: codeepDeclared && unquoteScalar(meta.codeep) === 'custom-bot/v1',
75
+ };
76
+ }
77
+ function parseInlineList(value) {
78
+ if (!value)
79
+ return [];
80
+ const trimmed = value.trim();
81
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']'))
82
+ return trimmed ? [trimmed] : [];
83
+ return trimmed.slice(1, -1).split(',').map(item => item.trim().replace(/^(["'])(.*)\1$/, '$2').trim()).filter(Boolean);
84
+ }
85
+ function section(body, title) {
86
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
+ const start = body.search(new RegExp(`^##\\s+${escaped}\\s*$`, 'im'));
88
+ if (start < 0)
89
+ return undefined;
90
+ const afterHeading = body.slice(start).replace(/^##[^\n]*(?:\r?\n|$)/, '');
91
+ const next = afterHeading.search(/^##\s+/m);
92
+ return (next < 0 ? afterHeading : afterHeading.slice(0, next)).trim();
93
+ }
94
+ function listSection(body, title) {
95
+ const value = section(body, title);
96
+ if (value === undefined)
97
+ return [];
98
+ return value.split(/\r?\n/)
99
+ .map(line => line.replace(/^\s*[-*]\s+/, '').trim())
100
+ .filter(Boolean);
101
+ }
102
+ function normaliseProjectScope(raw) {
103
+ const value = unquoteScalar(raw).toLowerCase();
104
+ if (['all', 'all projects'].includes(value))
105
+ return 'all';
106
+ if (['selected', 'selected projects', 'current project only'].includes(value))
107
+ return 'selected';
108
+ if (['personal', 'personal mode', 'personal mode only'].includes(value))
109
+ return 'personal';
110
+ return 'unspecified';
111
+ }
112
+ function normaliseCapabilities(values) {
113
+ const out = new Set();
114
+ for (const raw of values) {
115
+ const value = raw.trim().toLowerCase();
116
+ if (CAPABILITIES.has(value))
117
+ out.add(value);
118
+ }
119
+ return [...out];
120
+ }
121
+ function exactModelPreference(preference) {
122
+ const value = preference?.trim() ?? '';
123
+ const slash = value.indexOf('/');
124
+ if (slash <= 0 || slash === value.length - 1)
125
+ return null;
126
+ const providerId = value.slice(0, slash).trim();
127
+ const model = value.slice(slash + 1).trim();
128
+ return providerId && model ? { providerId, model } : null;
129
+ }
130
+ /** Whether a structured bot's model field satisfies the portable v1 contract. */
131
+ export function isPersonalityModelPreferenceValid(personality) {
132
+ if (!personality.structured)
133
+ return true;
134
+ const preference = personality.modelPreference?.trim() || 'automatic';
135
+ if (preference.toLowerCase() === 'automatic')
136
+ return true;
137
+ const exact = exactModelPreference(preference);
138
+ if (!exact)
139
+ return false;
140
+ const provider = getProvider(exact.providerId);
141
+ if (!provider)
142
+ return false;
143
+ return provider.dynamicModels === true || provider.models.some(item => item.id === exact.model);
144
+ }
145
+ /** Parse both custom-bot/v1 and the original web builder's heading format. */
146
+ export function parsePersonalityMarkdown(raw, name, scope) {
147
+ const { meta, body, codeepDeclared, versioned } = splitFrontmatter(raw);
148
+ const h1 = body.match(/^#\s+(?:Personality:\s+)?(.+)$/m);
149
+ const displayName = h1?.[1].trim() ?? name;
150
+ const h1Start = h1 ? body.indexOf(h1[0]) : -1;
151
+ const h1End = h1Start >= 0 ? body.indexOf('\n', h1Start) : -1;
152
+ const promptBody = h1
153
+ ? (h1End >= 0 ? body.slice(h1End + 1) : '').trimStart()
154
+ : body.trimStart();
155
+ const responsibility = section(body, 'Responsibility');
156
+ const responseStyle = section(body, 'Response style');
157
+ const advancedInstructions = section(body, 'Advanced instructions');
158
+ const sectionModel = section(body, 'Model');
159
+ const sectionToolsPresent = /^##\s+Tools\s*$/im.test(body);
160
+ const sectionTools = listSection(body, 'Tools');
161
+ const sectionScope = section(body, 'Scope');
162
+ const sectionProjects = listSection(body, 'Projects');
163
+ // The original section-only builder format has no version marker. A single
164
+ // common heading is not a safe signature (`## Tools` is perfectly ordinary
165
+ // legacy prompt prose), so require Responsibility + another builder
166
+ // section. Heading presence, not content, is what matters once that strong
167
+ // signature is met.
168
+ const standardSections = [
169
+ 'Responsibility', 'Response style', 'Always', 'Never', 'Model', 'Tools',
170
+ 'Scope', 'Projects', 'Advanced instructions',
171
+ ].filter(title => section(body, title) !== undefined);
172
+ const sectionStructured = standardSections.includes('Responsibility') && standardSections.length >= 2;
173
+ // A declared schema marker owns interpretation even when unsupported. Never
174
+ // reinterpret a typo/future version as permissive legacy prose.
175
+ const structured = codeepDeclared || sectionStructured;
176
+ const schemaValid = !codeepDeclared || versioned;
177
+ const frontmatterToolsPresent = Object.prototype.hasOwnProperty.call(meta, 'tools');
178
+ // Portable v1 requires list syntax. Accepting a malformed scalar such as
179
+ // `tools: files` would silently grant a capability when the fail-closed
180
+ // contract says it must become conversation-only.
181
+ const versionedToolsValue = meta.tools?.trim() ?? '';
182
+ const versionedToolsWellFormed = versionedToolsValue.startsWith('[') && versionedToolsValue.endsWith(']');
183
+ const declaredTools = codeepDeclared
184
+ ? (frontmatterToolsPresent && versionedToolsWellFormed ? parseInlineList(meta.tools) : [])
185
+ : (frontmatterToolsPresent ? parseInlineList(meta.tools) : sectionTools);
186
+ const tools = normaliseCapabilities(declaredTools);
187
+ const frontmatterProjectsPresent = Object.prototype.hasOwnProperty.call(meta, 'projects');
188
+ const versionedProjectsValue = meta.projects?.trim() ?? '';
189
+ const versionedProjectsWellFormed = versionedProjectsValue.startsWith('[') && versionedProjectsValue.endsWith(']');
190
+ const projects = codeepDeclared
191
+ ? (frontmatterProjectsPresent && versionedProjectsWellFormed ? parseInlineList(meta.projects) : [])
192
+ : (frontmatterProjectsPresent ? parseInlineList(meta.projects) : sectionProjects);
193
+ const descriptionFromMeta = unquoteScalar(meta.description);
194
+ const descriptionFromQuote = body.match(/^>\s+(.+)$/m)?.[1]?.trim();
195
+ const firstPara = promptBody.split(/\n\s*\n/)[0]?.replace(/^>\s*/, '').replace(/\s+/g, ' ').trim() ?? '';
196
+ const descriptionRaw = descriptionFromMeta || descriptionFromQuote || firstPara;
197
+ const description = descriptionRaw.length > 200 ? descriptionRaw.slice(0, 197) + '…' : descriptionRaw;
198
+ const modelPreference = unquoteScalar((codeepDeclared ? meta.model : (meta.model || sectionModel)) || 'automatic');
199
+ const frontmatterScopePresent = Object.prototype.hasOwnProperty.call(meta, 'scope');
200
+ const projectScope = normaliseProjectScope(codeepDeclared ? meta.scope : (meta.scope || sectionScope));
201
+ const explicitScopePresent = codeepDeclared ? frontmatterScopePresent : (frontmatterScopePresent || sectionScope !== undefined);
202
+ const scopeValid = !(structured && explicitScopePresent && projectScope === 'unspecified');
203
+ return {
204
+ name,
205
+ displayName,
206
+ description: description || `Custom personality from ${name}.md`,
207
+ prompt: '\n\n## Personality: ' + displayName + '\n\n' + promptBody,
208
+ scope,
209
+ structured,
210
+ schemaValid,
211
+ modelPreference,
212
+ tools,
213
+ declaredTools,
214
+ // Versioned v1 is fail-closed: missing, empty, malformed, or unknown Tools
215
+ // all mean no tools. Only true legacy and compatible section-only files
216
+ // without a Tools heading preserve unrestricted prompt-only behavior.
217
+ restrictTools: codeepDeclared || (sectionStructured && sectionToolsPresent),
218
+ projectScope,
219
+ scopeValid,
220
+ projects,
221
+ responsibility,
222
+ responseStyle,
223
+ always: listSection(body, 'Always'),
224
+ never: listSection(body, 'Never'),
225
+ advancedInstructions,
226
+ };
227
+ }
33
228
  const BUILTIN = [
34
229
  {
35
230
  name: 'concise',
@@ -149,20 +344,7 @@ function loadFromDir(dir, scope) {
149
344
  const raw = readFileSync(join(dir, entry), 'utf8');
150
345
  if (raw.length > 64 * 1024)
151
346
  continue; // cap at 64 KB
152
- // First H1 → displayName; rest → prompt.
153
- const h1 = raw.match(/^#\s+(?:Personality:\s+)?(.+)$/m);
154
- const displayName = h1?.[1].trim() ?? name;
155
- const body = h1 ? raw.slice(raw.indexOf('\n', raw.indexOf(h1[0])) + 1).trimStart() : raw;
156
- // First paragraph (or line) → description (cap 200 chars).
157
- const firstPara = body.split(/\n\s*\n/)[0]?.replace(/\s+/g, ' ').trim() ?? '';
158
- const description = firstPara.length > 200 ? firstPara.slice(0, 197) + '…' : firstPara;
159
- out.push({
160
- name,
161
- displayName,
162
- description: description || `Custom personality from ${entry}`,
163
- prompt: '\n\n## Personality: ' + displayName + '\n\n' + body,
164
- scope,
165
- });
347
+ out.push(parsePersonalityMarkdown(raw, name, scope));
166
348
  }
167
349
  catch {
168
350
  // Skip broken files — never crash personality loading.
@@ -170,6 +352,337 @@ function loadFromDir(dir, scope) {
170
352
  }
171
353
  return out;
172
354
  }
355
+ function globMatchesProject(pattern, projectName) {
356
+ const escaped = pattern.trim().replace(/[.+^${}()|[\]\\]/g, '\\$&')
357
+ .replace(/\*/g, '.*')
358
+ .replace(/\?/g, '.');
359
+ if (!escaped)
360
+ return false;
361
+ return new RegExp(`^${escaped}$`, 'i').test(projectName);
362
+ }
363
+ /**
364
+ * Scope is enforced against the workspace basename only. This intentionally
365
+ * avoids accepting arbitrary paths from cloud-authored metadata.
366
+ */
367
+ export function isPersonalityAvailable(personality, workspaceRoot) {
368
+ if (personality.schemaValid === false)
369
+ return false;
370
+ if (!isPersonalityModelPreferenceValid(personality))
371
+ return false;
372
+ if (personality.scopeValid === false)
373
+ return false;
374
+ if (!personality.structured || personality.projectScope === 'unspecified' || personality.projectScope === 'all') {
375
+ return true;
376
+ }
377
+ if (personality.projectScope === 'personal') {
378
+ // With no workspace the caller is explicitly in personal mode. When a
379
+ // root is supplied, a directory without common project markers is also
380
+ // considered personal (the TUI always has a cwd).
381
+ if (!workspaceRoot)
382
+ return true;
383
+ const markers = [
384
+ '.git', '.hg', '.svn', '.idea',
385
+ 'package.json', 'pnpm-workspace.yaml', 'deno.json', 'deno.jsonc',
386
+ 'pyproject.toml', 'requirements.txt', 'setup.py',
387
+ 'Cargo.toml', 'go.mod', 'Package.swift',
388
+ 'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts',
389
+ 'Makefile', 'CMakeLists.txt', 'meson.build', 'WORKSPACE', 'WORKSPACE.bazel',
390
+ 'composer.json', 'Gemfile', 'mix.exs', 'pubspec.yaml', 'flake.nix',
391
+ join('.codeep', 'project.json'),
392
+ ];
393
+ if (markers.some(marker => existsSync(join(workspaceRoot, marker))))
394
+ return false;
395
+ try {
396
+ const projectContainer = /\.(?:xcodeproj|xcworkspace|sln|code-workspace)$/i;
397
+ if (readdirSync(workspaceRoot).some(entry => projectContainer.test(entry)))
398
+ return false;
399
+ }
400
+ catch {
401
+ // An unreadable directory is not safe to classify as explicit personal
402
+ // mode, so fail closed.
403
+ return false;
404
+ }
405
+ return true;
406
+ }
407
+ if (personality.projectScope === 'selected') {
408
+ // A project-local file is inherently selected for that project even when
409
+ // an older file has no explicit Projects list.
410
+ if (personality.scope === 'project')
411
+ return true;
412
+ if (!workspaceRoot || !personality.projects?.length)
413
+ return false;
414
+ const projectName = basename(workspaceRoot);
415
+ return personality.projects.some(pattern => globMatchesProject(pattern, projectName));
416
+ }
417
+ return true;
418
+ }
419
+ /** Concrete tool names that may be advertised for a structured custom bot. */
420
+ export function getPersonalityToolAllowlist(personality, registeredMcpToolNames = new Set()) {
421
+ if (!personality.restrictTools)
422
+ return undefined;
423
+ const allowed = new Set();
424
+ if (personality.tools?.includes('files'))
425
+ FILE_TOOLS.forEach(tool => allowed.add(tool));
426
+ if (personality.tools?.includes('terminal') || personality.tools?.includes('tests') || personality.tools?.includes('git')) {
427
+ allowed.add('execute_command');
428
+ }
429
+ if (personality.tools?.includes('web'))
430
+ WEB_TOOLS.forEach(tool => allowed.add(tool));
431
+ if (personality.tools?.includes('mcp'))
432
+ registeredMcpToolNames.forEach(tool => allowed.add(tool));
433
+ return [...allowed];
434
+ }
435
+ function commandName(toolCall) {
436
+ const value = String(toolCall.parameters.command ?? '').trim();
437
+ return basename(value.replace(/\\/g, '/')).toLowerCase().replace(/\.(exe|cmd|bat)$/i, '');
438
+ }
439
+ function commandArgs(toolCall) {
440
+ const raw = toolCall.parameters.args;
441
+ return Array.isArray(raw) ? raw.map(String) : [];
442
+ }
443
+ function isTestCommand(toolCall) {
444
+ const command = commandName(toolCall);
445
+ const args = commandArgs(toolCall).map(value => value.toLowerCase());
446
+ if (['pytest', 'vitest', 'jest', 'playwright', 'cypress', 'phpunit'].includes(command))
447
+ return true;
448
+ if (command === 'go')
449
+ return args[0] === 'test';
450
+ if (command === 'cargo' || command === 'swift' || command === 'dotnet')
451
+ return args[0] === 'test';
452
+ if (command === 'python' || command === 'python3')
453
+ return args[0] === '-m' && ['pytest', 'unittest'].includes(args[1] ?? '');
454
+ if (['npm', 'pnpm', 'yarn', 'bun'].includes(command)) {
455
+ return args[0] === 'test' || (args[0] === 'run' && /^(test|check)(:|$)/.test(args[1] ?? ''));
456
+ }
457
+ if (command === 'npx')
458
+ return ['vitest', 'jest', 'playwright', 'cypress'].includes(args[0] ?? '');
459
+ if (command === 'mvn' || command === 'mvnw')
460
+ return args.some(arg => ['test', 'verify'].includes(arg));
461
+ if (command === 'gradle' || command === 'gradlew')
462
+ return args.some(arg => /(^|:)test$/.test(arg));
463
+ if (command === 'xcodebuild')
464
+ return args.includes('test');
465
+ return false;
466
+ }
467
+ const SAFE_GIT_SUBCOMMANDS = new Set([
468
+ 'add', 'am', 'apply', 'blame', 'branch', 'cat-file', 'check-attr',
469
+ 'check-ignore', 'checkout', 'cherry-pick', 'clean', 'commit',
470
+ 'count-objects', 'describe', 'diff', 'fetch', 'for-each-ref',
471
+ 'format-patch', 'fsck', 'grep', 'init', 'log', 'ls-files', 'ls-remote',
472
+ 'ls-tree', 'merge', 'merge-base', 'mv', 'name-rev', 'pull', 'push',
473
+ 'range-diff', 'rebase', 'reflog', 'remote', 'reset', 'restore', 'revert',
474
+ 'rev-list', 'rev-parse', 'rm', 'shortlog', 'show', 'show-branch',
475
+ 'sparse-checkout', 'stash', 'status', 'switch', 'symbolic-ref', 'tag',
476
+ 'update-index', 'update-ref', 'whatchanged',
477
+ ]);
478
+ function hasOption(args, ...options) {
479
+ return args.some(arg => options.some(option => arg === option || arg.startsWith(option + '=')));
480
+ }
481
+ function hasInlineOption(args, ...options) {
482
+ return args.some(arg => options.some(option => arg.startsWith(option + '=')));
483
+ }
484
+ function hasAttachedShortOption(args, option) {
485
+ return args.some(arg => arg.startsWith(option) && arg.length > option.length);
486
+ }
487
+ /**
488
+ * Git can dispatch arbitrary `git-<name>` executables and has several options
489
+ * that deliberately execute shell commands. A Git-only bot therefore uses a
490
+ * conservative built-in allowlist instead of trusting the executable name.
491
+ */
492
+ function isRestrictedGitCommandAllowed(toolCall) {
493
+ const executable = commandName(toolCall);
494
+ const args = commandArgs(toolCall);
495
+ if (executable !== 'git')
496
+ return false;
497
+ if (args.length === 0)
498
+ return false;
499
+ // Global config/dispatch controls. `-C` is also denied: it can move Git
500
+ // outside the workspace before any subcommand runs.
501
+ if (args.some(arg => arg === '-c' || (arg.startsWith('-c') && arg.length > 2)
502
+ || arg === '-C' || (arg.startsWith('-C') && arg.length > 2)
503
+ || hasOption([arg], '--config-env', '--exec-path', '--git-dir', '--work-tree', '--namespace')))
504
+ return false;
505
+ // Only harmless global flags may precede the subcommand. Everything else
506
+ // fails closed so an unknown global option cannot change executable lookup.
507
+ let index = 0;
508
+ const safeGlobal = new Set([
509
+ '--no-pager', '--no-replace-objects', '--literal-pathspecs',
510
+ '--glob-pathspecs', '--noglob-pathspecs', '--icase-pathspecs',
511
+ ]);
512
+ while (index < args.length && args[index].startsWith('-')) {
513
+ if (args[index] === '--version' && args.length === 1)
514
+ return true;
515
+ if (!safeGlobal.has(args[index]))
516
+ return false;
517
+ index++;
518
+ }
519
+ const subcommand = args[index]?.toLowerCase();
520
+ if (!subcommand)
521
+ return false;
522
+ const rest = args.slice(index + 1);
523
+ const restLower = rest.map(arg => arg.toLowerCase());
524
+ // Custom aliases and helpers are executable programs. Unknown subcommands
525
+ // are denied by the allowlist below; these explicit names document the
526
+ // high-risk surfaces and guard future allowlist expansion.
527
+ if (['config', 'alias', 'difftool', 'mergetool', 'filter-branch', 'credential', 'help', 'web--browse', 'instaweb'].includes(subcommand)) {
528
+ return false;
529
+ }
530
+ // Reject flags that launch a shell/helper/editor/signing program or replace
531
+ // the remote-side Git binary. `ext::` and unknown `scheme::` URLs dispatch
532
+ // remote-helper executables, so they are denied too.
533
+ if (hasOption(rest, '--exec', '--config-env', '--ext-diff', '--textconv', '--upload-pack', '--receive-pack', '--open-files-in-pager', '--show-signature', '--gpg-sign', '--local-user', '--edit-description',
534
+ // These embed filesystem paths in an option token. The generic command
535
+ // validator deliberately skips option-shaped args, so a restricted Git
536
+ // bot must reject them here rather than accidentally writing outside the
537
+ // workspace (for example `diff --output=/tmp/result`).
538
+ '--unsafe-paths', '--separate-git-dir', '--output', '--output-directory', '--directory', '--pathspec-from-file', '--template', '--index-output', '--object-directory', '--alternate-refs-command', '--contents', '--build-fake-ancestor', '--mailmap-file', '--signature-file'))
539
+ return false;
540
+ // Long `--file=/outside` and attached short path/editor variants bypass the
541
+ // generic shell path check because the entire argument begins with `-`.
542
+ if (hasInlineOption(rest, '--file'))
543
+ return false;
544
+ if (subcommand === 'format-patch' && (rest.includes('-o') || hasAttachedShortOption(rest, '-o')))
545
+ return false;
546
+ if (subcommand === 'init' && (rest.includes('-t') || hasAttachedShortOption(rest, '-t')))
547
+ return false;
548
+ if (['fetch', 'pull', 'ls-remote'].includes(subcommand) && (rest.includes('-u') || hasAttachedShortOption(rest, '-u')))
549
+ return false;
550
+ if (subcommand === 'grep' && (rest.includes('-O') || hasAttachedShortOption(rest, '-O')
551
+ || hasAttachedShortOption(rest, '-f')))
552
+ return false;
553
+ if (rest.some(arg => /^-[S]($|.)/.test(arg)))
554
+ return false;
555
+ if (rest.some(arg => {
556
+ const match = arg.match(/^([a-z][a-z0-9+.-]*)::/i);
557
+ return match ? !['http', 'https', 'ssh', 'git', 'file'].includes(match[1].toLowerCase()) : false;
558
+ }))
559
+ return false;
560
+ if (subcommand === 'submodule') {
561
+ const operation = restLower.find(arg => !arg.startsWith('-'));
562
+ return operation === undefined || operation === 'status' || operation === 'summary';
563
+ }
564
+ if (subcommand === 'bisect') {
565
+ const operation = restLower.find(arg => !arg.startsWith('-'));
566
+ return operation !== 'run' && ['start', 'bad', 'good', 'new', 'old', 'reset', 'log', 'terms'].includes(operation ?? '');
567
+ }
568
+ if (subcommand === 'rebase') {
569
+ if (rest.some(arg => arg === '-x' || arg.startsWith('-x') || arg === '-i'))
570
+ return false;
571
+ if (hasOption(rest, '--exec', '--interactive', '--edit-todo', '--strategy'))
572
+ return false;
573
+ if (rest.includes('-s'))
574
+ return false;
575
+ if (rest.includes('--continue'))
576
+ return false; // may launch the configured editor
577
+ }
578
+ if (['merge', 'cherry-pick', 'revert'].includes(subcommand)) {
579
+ if (hasOption(rest, '--strategy'))
580
+ return false;
581
+ if (rest.includes('-s'))
582
+ return false;
583
+ if (hasOption(rest, '--edit') || rest.includes('-e'))
584
+ return false;
585
+ }
586
+ if (subcommand === 'merge') {
587
+ const avoidsEditor = hasOption(rest, '--no-edit', '--message')
588
+ || rest.some(arg => arg === '-m' || arg.startsWith('-m'))
589
+ || rest.some(arg => ['--ff-only', '--squash', '--no-commit', '--abort', '--quit'].includes(arg));
590
+ if (!avoidsEditor)
591
+ return false;
592
+ }
593
+ if (subcommand === 'pull') {
594
+ if (hasOption(rest, '--edit') || rest.includes('-e') || rest.includes('--rebase=interactive'))
595
+ return false;
596
+ const avoidsEditor = hasOption(rest, '--no-edit')
597
+ || rest.includes('--ff-only')
598
+ || rest.includes('--rebase')
599
+ || rest.includes('-r')
600
+ || rest.some(arg => ['--rebase=true', '--rebase=merges', '--rebase=preserve'].includes(arg));
601
+ if (!avoidsEditor)
602
+ return false;
603
+ }
604
+ if (subcommand === 'revert') {
605
+ const avoidsEditor = hasOption(rest, '--no-edit', '--no-commit')
606
+ || rest.includes('-n')
607
+ || rest.some(arg => ['--abort', '--quit'].includes(arg));
608
+ if (!avoidsEditor)
609
+ return false;
610
+ }
611
+ if (subcommand === 'commit') {
612
+ if (rest.includes('-e') || rest.includes('--edit'))
613
+ return false;
614
+ if (hasOption(rest, '--reedit-message'))
615
+ return false;
616
+ // A bare commit launches core.editor, which may itself be an arbitrary
617
+ // shell command. Git-only bots must supply a message (or reuse one).
618
+ if (hasAttachedShortOption(rest, '-F') || rest.includes('-t') || hasAttachedShortOption(rest, '-t'))
619
+ return false;
620
+ const hasMessage = rest.some(arg => arg === '-m' || arg.startsWith('-m') || arg === '-F')
621
+ || hasOption(rest, '--message', '--file', '--reuse-message')
622
+ || rest.includes('--no-edit');
623
+ if (!hasMessage)
624
+ return false;
625
+ }
626
+ if (subcommand === 'tag') {
627
+ if (hasOption(rest, '--sign') || rest.includes('-s') || rest.includes('-u') || hasAttachedShortOption(rest, '-u'))
628
+ return false;
629
+ const annotated = rest.includes('-a') || rest.includes('--annotate');
630
+ if (hasAttachedShortOption(rest, '-F'))
631
+ return false;
632
+ const hasMessage = rest.some(arg => arg === '-m' || arg.startsWith('-m') || arg === '-F')
633
+ || hasOption(rest, '--message', '--file');
634
+ if (annotated && !hasMessage)
635
+ return false; // otherwise Git launches an editor
636
+ }
637
+ return SAFE_GIT_SUBCOMMANDS.has(subcommand);
638
+ }
639
+ /** Runtime gate. It is deliberately stricter than the prompt/tool catalog. */
640
+ export function isPersonalityToolCallAllowed(personality, toolCall, registeredMcpToolNames = new Set()) {
641
+ if (!personality.restrictTools)
642
+ return true;
643
+ const tool = toolCall.tool.toLowerCase().replace(/-/g, '_');
644
+ if (FILE_TOOLS.includes(tool))
645
+ return personality.tools?.includes('files') === true;
646
+ if (WEB_TOOLS.includes(tool))
647
+ return personality.tools?.includes('web') === true;
648
+ if (tool === 'execute_command') {
649
+ if (personality.tools?.includes('terminal'))
650
+ return true;
651
+ const command = commandName(toolCall);
652
+ if (personality.tools?.includes('git') && isRestrictedGitCommandAllowed(toolCall))
653
+ return true;
654
+ if (personality.tools?.includes('tests') && isTestCommand(toolCall))
655
+ return true;
656
+ return false;
657
+ }
658
+ if (registeredMcpToolNames.has(toolCall.tool))
659
+ return personality.tools?.includes('mcp') === true;
660
+ // Skills, delegation, vision, and future tools are denied until a portable
661
+ // schema adds a capability that can classify them without guessing.
662
+ return false;
663
+ }
664
+ /** Resolve an exact provider/model preference without mutating global config. */
665
+ export function resolvePersonalityRuntimeModel(personality, current) {
666
+ const preference = personality.modelPreference?.trim();
667
+ if (!personality.structured || !preference || preference.toLowerCase() === 'automatic')
668
+ return null;
669
+ // v1 deliberately requires an exact provider/model pair. Invalid bots are
670
+ // unavailable at activation time; keep this guard for callers holding a
671
+ // stale parsed object from before a file changed.
672
+ if (!isPersonalityModelPreferenceValid(personality))
673
+ return null;
674
+ const exact = exactModelPreference(preference);
675
+ if (!exact)
676
+ return null;
677
+ const { providerId, model } = exact;
678
+ const provider = getProvider(providerId);
679
+ if (!provider)
680
+ return null;
681
+ const protocol = providerId === current.providerId && provider.protocols[current.protocol]
682
+ ? current.protocol
683
+ : provider.defaultProtocol;
684
+ return { providerId, model, protocol };
685
+ }
173
686
  export function loadAllPersonalities(workspaceRoot) {
174
687
  const project = workspaceRoot
175
688
  ? loadFromDir(join(workspaceRoot, '.codeep', 'personalities'), 'project')
@@ -187,7 +700,12 @@ export function loadAllPersonalities(workspaceRoot) {
187
700
  }
188
701
  export function findPersonality(name, workspaceRoot) {
189
702
  const lower = name.toLowerCase();
190
- return loadAllPersonalities(workspaceRoot).find((p) => p.name === lower) ?? null;
703
+ const personality = loadAllPersonalities(workspaceRoot).find((p) => p.name === lower) ?? null;
704
+ return personality && isPersonalityAvailable(personality, workspaceRoot) ? personality : null;
705
+ }
706
+ export function getActivePersonality(workspaceRoot) {
707
+ const name = config.get('activePersonality');
708
+ return name ? findPersonality(name, workspaceRoot) : null;
191
709
  }
192
710
  /**
193
711
  * Returns the prompt addendum for the currently active personality, or
@@ -196,29 +714,63 @@ export function findPersonality(name, workspaceRoot) {
196
714
  * project rules conflict.
197
715
  */
198
716
  export function getActivePersonalityPrompt(workspaceRoot) {
199
- const name = config.get('activePersonality');
200
- if (!name)
201
- return '';
202
- const p = findPersonality(name, workspaceRoot);
203
- return p?.prompt ?? '';
717
+ return getActivePersonality(workspaceRoot)?.prompt ?? '';
718
+ }
719
+ export function formatPersonalityActivation(personality) {
720
+ const lines = [
721
+ `Active personality: **${personality.displayName}** (\`${personality.name}\`, ${personality.scope})`,
722
+ '',
723
+ `_${personality.description}_`,
724
+ ];
725
+ if (personality.structured) {
726
+ const model = personality.modelPreference?.toLowerCase() === 'automatic'
727
+ ? 'Automatic (inherits the current model)'
728
+ : personality.modelPreference || 'Automatic';
729
+ const tools = personality.restrictTools
730
+ ? (personality.declaredTools?.join(', ') || 'none')
731
+ : 'Unrestricted';
732
+ lines.push('', `**Model:** ${model} · **Tools:** ${tools} · **Availability:** ${personality.projectScope ?? 'unspecified'}`);
733
+ // Granting Git without Files still exposes committed file contents:
734
+ // `git show HEAD:secrets.env` is functionally `cat secrets.env`, and
735
+ // history inspection cannot be separated from the content it inspects.
736
+ // Say so where the capability is shown, rather than implying otherwise.
737
+ if (personality.restrictTools
738
+ && personality.tools?.includes('git')
739
+ && !personality.tools.includes('files')) {
740
+ lines.push('', '_Note: Git is not read-only. It reads committed file contents (`git show HEAD:file`) and can stage, commit and push changes (`git rm`, `git commit`, `git push`). Grant it only if this bot may do both._');
741
+ }
742
+ }
743
+ lines.push('', 'Clear with `/personality off`.');
744
+ return lines.join('\n');
204
745
  }
205
746
  export function formatPersonalityList(workspaceRoot) {
206
747
  const list = loadAllPersonalities(workspaceRoot);
207
748
  const active = config.get('activePersonality');
208
749
  const lines = ['## Personalities', ''];
209
- if (active) {
750
+ const activeEntry = active ? list.find(personality => personality.name === active) : undefined;
751
+ const activeAvailable = activeEntry ? isPersonalityAvailable(activeEntry, workspaceRoot) : false;
752
+ if (active && activeAvailable) {
210
753
  lines.push(`**Active:** \`${active}\` — switch with \`/personality <name>\` or clear with \`/personality off\`.`);
211
754
  }
755
+ else if (active) {
756
+ lines.push(`**Configured:** \`${active}\` is not available in this workspace, so the agent uses default behavior here.`);
757
+ }
212
758
  else {
213
759
  lines.push('**Active:** _(none — agent uses default tone)_');
214
760
  }
215
761
  lines.push('');
216
- lines.push('| Name | Scope | Description |');
217
- lines.push('|---|---|---|');
762
+ lines.push('| Name | Scope | Model | Tools | Description |');
763
+ lines.push('|---|---|---|---|---|');
764
+ const cell = (value) => value.replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
218
765
  for (const p of list) {
219
766
  const tag = p.scope === 'builtin' ? 'built-in' : p.scope;
220
- const marker = active === p.name ? ' ✓' : '';
221
- lines.push(`| \`${p.name}\`${marker} | ${tag} | ${p.description} |`);
767
+ const marker = active === p.name && activeAvailable ? ' ✓' : '';
768
+ const available = isPersonalityAvailable(p, workspaceRoot);
769
+ const scopeLabel = p.projectScope && p.projectScope !== 'unspecified' ? ` · ${p.projectScope}` : '';
770
+ const availability = available ? '' : ' _(not available here)_';
771
+ const model = p.structured ? (p.modelPreference || 'automatic') : 'inherit';
772
+ const tools = p.restrictTools ? (p.declaredTools?.join(', ') || 'none') : 'unrestricted';
773
+ lines.push(`| \`${p.name}\`${marker} | ${cell(tag + scopeLabel)} | ${cell(model)} | ${cell(tools)} | ${cell(p.description)}${availability} |`);
222
774
  }
223
775
  lines.push('');
224
776
  lines.push('Drop a `<name>.md` file into `.codeep/personalities/` (project) or `~/.codeep/personalities/` (global) to add your own — first `#` line becomes the display name, body becomes the prompt addendum.');