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.
- package/README.md +53 -16
- package/dist/acp/commands.js +11 -55
- package/dist/acp/protocol.d.ts +34 -0
- package/dist/acp/server.d.ts +6 -1
- package/dist/acp/server.js +97 -2
- package/dist/api/index.js +9 -0
- package/dist/commands/core/index.d.ts +19 -0
- package/dist/commands/core/index.js +28 -0
- package/dist/commands/core/keysync.d.ts +2 -0
- package/dist/commands/core/keysync.js +34 -0
- package/dist/commands/core/telemetry.d.ts +2 -0
- package/dist/commands/core/telemetry.js +34 -0
- package/dist/config/index.js +2 -2
- package/dist/renderer/App.d.ts +9 -48
- package/dist/renderer/App.js +113 -338
- package/dist/renderer/Screen.d.ts +13 -0
- package/dist/renderer/Screen.js +22 -0
- package/dist/renderer/commands/registry.js +3 -3
- package/dist/renderer/commands.js +19 -51
- package/dist/renderer/components/CommandAutocomplete.d.ts +46 -0
- package/dist/renderer/components/CommandAutocomplete.js +103 -0
- package/dist/renderer/components/HunkPicker.d.ts +48 -0
- package/dist/renderer/components/HunkPicker.js +140 -0
- package/dist/renderer/components/MentionPicker.d.ts +60 -0
- package/dist/renderer/components/MentionPicker.js +111 -0
- package/dist/renderer/components/PasteDialog.d.ts +43 -0
- package/dist/renderer/components/PasteDialog.js +70 -0
- package/dist/renderer/layout.js +1 -0
- package/dist/renderer/main.js +15 -39
- package/dist/utils/agent.js +121 -26
- package/dist/utils/agentChat.d.ts +11 -4
- package/dist/utils/agentChat.js +53 -25
- package/dist/utils/codeepCloud.d.ts +3 -0
- package/dist/utils/codeepCloud.js +62 -7
- package/dist/utils/personalities.d.ts +63 -5
- package/dist/utils/personalities.js +583 -31
- package/dist/utils/shell.d.ts +11 -1
- package/dist/utils/shell.js +169 -82
- package/dist/utils/ssrfGuard.d.ts +18 -0
- package/dist/utils/ssrfGuard.js +83 -0
- package/dist/utils/taskPlanner.d.ts +7 -1
- package/dist/utils/taskPlanner.js +16 -7
- package/dist/utils/toolExecution.d.ts +1 -0
- package/dist/utils/toolExecution.js +48 -88
- package/dist/utils/tools.d.ts +3 -3
- package/dist/utils/tools.js +18 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.');
|