codeep 3.3.3 → 3.4.1
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/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/utils/logger.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
2
|
+
import { appendProjectFile } from './projectPaths.js';
|
|
2
3
|
import { join } from 'path';
|
|
3
4
|
import { homedir } from 'os';
|
|
4
5
|
const GLOBAL_LOG_DIR = join(homedir(), '.codeep', 'logs');
|
|
@@ -18,11 +19,7 @@ export function setLogProjectPath(projectPath) {
|
|
|
18
19
|
* Get local log directory for project
|
|
19
20
|
*/
|
|
20
21
|
function getLocalLogDir(projectPath) {
|
|
21
|
-
|
|
22
|
-
if (!existsSync(logDir)) {
|
|
23
|
-
mkdirSync(logDir, { recursive: true });
|
|
24
|
-
}
|
|
25
|
-
return logDir;
|
|
22
|
+
return join(projectPath, '.codeep', 'logs');
|
|
26
23
|
}
|
|
27
24
|
/**
|
|
28
25
|
* Check if path is a project directory
|
|
@@ -43,6 +40,7 @@ function getLogFilePaths() {
|
|
|
43
40
|
if (currentProjectPath && isProjectDirectory(currentProjectPath)) {
|
|
44
41
|
const localLogDir = getLocalLogDir(currentProjectPath);
|
|
45
42
|
paths.local = join(localLogDir, filename);
|
|
43
|
+
paths.project = currentProjectPath;
|
|
46
44
|
}
|
|
47
45
|
return paths;
|
|
48
46
|
}
|
|
@@ -71,8 +69,9 @@ function writeLog(level, message, data, localOnly = false) {
|
|
|
71
69
|
appendFileSync(paths.global, logLine, 'utf-8');
|
|
72
70
|
}
|
|
73
71
|
// Write to local log if available
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
// .codeep/ can come with a cloned repo: never append through a symlink.
|
|
73
|
+
if (paths.local && paths.project) {
|
|
74
|
+
appendProjectFile(paths.project, paths.local, logLine);
|
|
76
75
|
}
|
|
77
76
|
}
|
|
78
77
|
catch {
|
|
@@ -69,6 +69,30 @@ export declare function untrustWorkspaceMcp(workspaceRoot: string): void;
|
|
|
69
69
|
* "skip" file-based ones).
|
|
70
70
|
*/
|
|
71
71
|
export declare function mergeMcpServers(fromConfig: McpServer[], fromAcp: McpServer[] | undefined): McpServer[];
|
|
72
|
+
/**
|
|
73
|
+
* The MCP servers a session runs. Every place that (re)starts a session's
|
|
74
|
+
* servers goes through here, so they all apply the same rule:
|
|
75
|
+
*
|
|
76
|
+
* - global (~/.codeep) entries always run — they are the user's own —
|
|
77
|
+
* unless a workspace entry of the same name runs in their place;
|
|
78
|
+
* - workspace entries run only once the workspace is trusted, because
|
|
79
|
+
* they arrive with the repo. `userAdded` names entries the user has
|
|
80
|
+
* just added by hand (`/mcp add`, `/mcp install`); those need no
|
|
81
|
+
* further consent;
|
|
82
|
+
* - `fromClient` — the servers the editor passed for the session — run
|
|
83
|
+
* too and win on name collisions, as in `mergeMcpServers`.
|
|
84
|
+
*
|
|
85
|
+
* `skipped` lists the workspace entries left out, so callers can say why.
|
|
86
|
+
* Registering replaces a session's whole set of servers, which is why the
|
|
87
|
+
* editor's servers have to be part of every selection.
|
|
88
|
+
*/
|
|
89
|
+
export declare function selectSessionMcpServers(workspaceRoot: string | undefined, opts?: {
|
|
90
|
+
fromClient?: McpServer[];
|
|
91
|
+
userAdded?: string[];
|
|
92
|
+
}): {
|
|
93
|
+
servers: McpServer[];
|
|
94
|
+
skipped: McpServer[];
|
|
95
|
+
};
|
|
72
96
|
/**
|
|
73
97
|
* Add or replace a server entry in the project config file. Used by the
|
|
74
98
|
* interactive `/mcp add` command. Project file is created if missing.
|
package/dist/utils/mcpConfig.js
CHANGED
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
* A flat array form (`{"mcpServers": [{...}, ...]}`) is also accepted because
|
|
26
26
|
* that's the shape ACP passes over JSON-RPC.
|
|
27
27
|
*/
|
|
28
|
-
import { existsSync, readFileSync
|
|
29
|
-
import {
|
|
28
|
+
import { existsSync, readFileSync } from 'fs';
|
|
29
|
+
import { writeProjectFile } from './projectPaths.js';
|
|
30
|
+
import { join } from 'path';
|
|
30
31
|
import { homedir } from 'os';
|
|
31
32
|
import { config } from '../config/index.js';
|
|
32
33
|
const PROJECT_CONFIG_PATH = '.codeep/mcp_servers.json';
|
|
@@ -216,6 +217,36 @@ export function mergeMcpServers(fromConfig, fromAcp) {
|
|
|
216
217
|
byName.set(s.name, s);
|
|
217
218
|
return [...byName.values()];
|
|
218
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* The MCP servers a session runs. Every place that (re)starts a session's
|
|
222
|
+
* servers goes through here, so they all apply the same rule:
|
|
223
|
+
*
|
|
224
|
+
* - global (~/.codeep) entries always run — they are the user's own —
|
|
225
|
+
* unless a workspace entry of the same name runs in their place;
|
|
226
|
+
* - workspace entries run only once the workspace is trusted, because
|
|
227
|
+
* they arrive with the repo. `userAdded` names entries the user has
|
|
228
|
+
* just added by hand (`/mcp add`, `/mcp install`); those need no
|
|
229
|
+
* further consent;
|
|
230
|
+
* - `fromClient` — the servers the editor passed for the session — run
|
|
231
|
+
* too and win on name collisions, as in `mergeMcpServers`.
|
|
232
|
+
*
|
|
233
|
+
* `skipped` lists the workspace entries left out, so callers can say why.
|
|
234
|
+
* Registering replaces a session's whole set of servers, which is why the
|
|
235
|
+
* editor's servers have to be part of every selection.
|
|
236
|
+
*/
|
|
237
|
+
export function selectSessionMcpServers(workspaceRoot, opts = {}) {
|
|
238
|
+
const { workspace } = loadMcpServerConfigSplit(workspaceRoot);
|
|
239
|
+
const trusted = workspaceRoot !== undefined && isWorkspaceMcpTrusted(workspaceRoot);
|
|
240
|
+
const allowed = trusted ? workspace : workspace.filter(s => opts.userAdded?.includes(s.name));
|
|
241
|
+
const skipped = workspace.filter(s => !allowed.includes(s));
|
|
242
|
+
// The split list has already dropped every global entry a workspace entry
|
|
243
|
+
// names. Only an entry that is going to run may take the place of one of
|
|
244
|
+
// the user's own servers; one left out must not stop it.
|
|
245
|
+
const allowedNames = new Set(allowed.map(s => s.name));
|
|
246
|
+
const globalServers = loadFromFile(join(homedir(), GLOBAL_CONFIG_PATH))
|
|
247
|
+
.filter(s => !allowedNames.has(s.name));
|
|
248
|
+
return { servers: mergeMcpServers([...globalServers, ...allowed], opts.fromClient), skipped };
|
|
249
|
+
}
|
|
219
250
|
/**
|
|
220
251
|
* Add or replace a server entry in the project config file. Used by the
|
|
221
252
|
* interactive `/mcp add` command. Project file is created if missing.
|
|
@@ -247,8 +278,8 @@ export function addProjectMcpServer(workspaceRoot, server) {
|
|
|
247
278
|
const { name, ...rest } = server;
|
|
248
279
|
map[name] = rest;
|
|
249
280
|
parsed.mcpServers = map;
|
|
250
|
-
|
|
251
|
-
|
|
281
|
+
// .codeep/ can come with a cloned repo: never write through a symlink.
|
|
282
|
+
writeProjectFile(workspaceRoot, path, JSON.stringify(parsed, null, 2) + '\n');
|
|
252
283
|
}
|
|
253
284
|
/**
|
|
254
285
|
* Remove a server entry from the project config file. Returns true if a
|
|
@@ -277,6 +308,6 @@ export function removeProjectMcpServer(workspaceRoot, name) {
|
|
|
277
308
|
else {
|
|
278
309
|
return false;
|
|
279
310
|
}
|
|
280
|
-
|
|
311
|
+
writeProjectFile(workspaceRoot, path, JSON.stringify(parsed, null, 2) + '\n');
|
|
281
312
|
return true;
|
|
282
313
|
}
|
package/dist/utils/mentions.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Supported mention forms (case-sensitive `@`):
|
|
10
10
|
* @src/index.ts → relative-to-project-root file
|
|
11
|
-
* @./local.ts → relative-to-
|
|
11
|
+
* @./local.ts → relative-to-project-root file too
|
|
12
12
|
* @/abs/path.ts → absolute path
|
|
13
13
|
* @"path with space.ts" → quoted (spaces/special chars allowed)
|
|
14
14
|
* @'path with space.ts' → single-quoted variant
|
|
@@ -77,9 +77,8 @@ export declare const MENTION_BOUNDARY: RegExp;
|
|
|
77
77
|
export declare function extractMentions(text: string): MentionToken[];
|
|
78
78
|
export interface MentionExpansionOptions {
|
|
79
79
|
/**
|
|
80
|
-
* The root directory mentions
|
|
81
|
-
*
|
|
82
|
-
* or `process.cwd()`.
|
|
80
|
+
* The root directory relative mentions (`src/a.ts`, `./a.ts`, `.`) are
|
|
81
|
+
* resolved against. Usually the project root or `process.cwd()`.
|
|
83
82
|
*/
|
|
84
83
|
root: string;
|
|
85
84
|
}
|
|
@@ -168,8 +167,32 @@ export interface SuggestOptions {
|
|
|
168
167
|
export declare function suggestMentions(opts: SuggestOptions): MentionSuggestion[];
|
|
169
168
|
/** Clear the suggestion cache. Call between tests so fixtures don't leak. */
|
|
170
169
|
export declare function clearSuggestionCache(): void;
|
|
171
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* True if `fullPath` looks like it holds secrets, judged by its basename and,
|
|
172
|
+
* for a symlink, by the basename of the file it points at: a repo can commit
|
|
173
|
+
* `.env.example -> .env` or `tsconfig.json -> ../.env`, and every read
|
|
174
|
+
* follows the link.
|
|
175
|
+
*/
|
|
172
176
|
export declare function isSensitiveFile(fullPath: string): boolean;
|
|
177
|
+
/**
|
|
178
|
+
* True if a file named on its own (an @-mention, or a path smart context
|
|
179
|
+
* picks out of the prompt) must not be inlined: secrets, and committed `.env`
|
|
180
|
+
* templates, which can still hold a real value. Both paths use this one rule,
|
|
181
|
+
* so a mention the user was told is refused never reaches the provider
|
|
182
|
+
* another way.
|
|
183
|
+
*/
|
|
184
|
+
export declare function isRefusedMention(fullPath: string): boolean;
|
|
185
|
+
/**
|
|
186
|
+
* True if `text` holds private-key material anywhere. Keys are saved under
|
|
187
|
+
* any name (`~/.ssh/github`, `deploy/prod`), so the name check alone lets
|
|
188
|
+
* them through, and a key can sit past the top of a JSON or YAML file.
|
|
189
|
+
*/
|
|
190
|
+
export declare function looksLikeKeyMaterial(text: string): boolean;
|
|
191
|
+
/**
|
|
192
|
+
* True if `fullPath`, with symlinks resolved, lies inside `dir` (also
|
|
193
|
+
* resolved). False when either doesn't exist.
|
|
194
|
+
*/
|
|
195
|
+
export declare function resolvesWithin(fullPath: string, dir: string): boolean;
|
|
173
196
|
/** One `@git <ref>` mention match. */
|
|
174
197
|
interface GitToken {
|
|
175
198
|
raw: string;
|
package/dist/utils/mentions.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Supported mention forms (case-sensitive `@`):
|
|
10
10
|
* @src/index.ts → relative-to-project-root file
|
|
11
|
-
* @./local.ts → relative-to-
|
|
11
|
+
* @./local.ts → relative-to-project-root file too
|
|
12
12
|
* @/abs/path.ts → absolute path
|
|
13
13
|
* @"path with space.ts" → quoted (spaces/special chars allowed)
|
|
14
14
|
* @'path with space.ts' → single-quoted variant
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
* with a warning rather than silently truncated — the user should
|
|
23
23
|
* explicitly `/add` very large files if they really want them.
|
|
24
24
|
*/
|
|
25
|
-
import { statSync, readFileSync, readdirSync } from 'fs';
|
|
25
|
+
import { statSync, readFileSync, readdirSync, realpathSync } from 'fs';
|
|
26
26
|
import { join, isAbsolute, relative, resolve, sep } from 'path';
|
|
27
|
+
import { loadIgnoreRules, isIgnored, rulesBelow } from './gitignore.js';
|
|
27
28
|
/** Max file size we'll auto-inline from a mention (100 KB). */
|
|
28
29
|
export const MAX_MENTION_BYTES = 100 * 1024;
|
|
29
30
|
/**
|
|
@@ -101,8 +102,8 @@ export function expandMentions(prompt, opts) {
|
|
|
101
102
|
// pasted (an issue body, a log, model output), so `@.env` or
|
|
102
103
|
// `@~/.aws/credentials` would silently ship credentials to the provider.
|
|
103
104
|
// `/add` remains the explicit, deliberate path for these.
|
|
104
|
-
if (
|
|
105
|
-
failures.push({ mention: tok.raw, reason:
|
|
105
|
+
if (isRefusedMention(resolved.fullPath)) {
|
|
106
|
+
failures.push({ mention: tok.raw, reason: SECRETS_REASON });
|
|
106
107
|
continue;
|
|
107
108
|
}
|
|
108
109
|
const stat = safeStat(resolved.fullPath);
|
|
@@ -114,6 +115,12 @@ export function expandMentions(prompt, opts) {
|
|
|
114
115
|
failures.push({ mention: tok.raw, reason: 'not a file' });
|
|
115
116
|
continue;
|
|
116
117
|
}
|
|
118
|
+
// A cloned repo can commit `notes.md -> ~/.zsh_history`: a path inside
|
|
119
|
+
// the project must not read a file from outside it.
|
|
120
|
+
if (linksOutsideRoot(resolved.fullPath, opts.root)) {
|
|
121
|
+
failures.push({ mention: tok.raw, reason: LINKS_OUTSIDE_REASON });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
117
124
|
if (stat.size > MAX_MENTION_BYTES) {
|
|
118
125
|
failures.push({
|
|
119
126
|
mention: tok.raw,
|
|
@@ -126,6 +133,11 @@ export function expandMentions(prompt, opts) {
|
|
|
126
133
|
failures.push({ mention: tok.raw, reason: 'could not read (binary?)' });
|
|
127
134
|
continue;
|
|
128
135
|
}
|
|
136
|
+
// Keys are often saved under names no pattern knows (`~/.ssh/github`).
|
|
137
|
+
if (looksLikeKeyMaterial(content)) {
|
|
138
|
+
failures.push({ mention: tok.raw, reason: SECRETS_REASON });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
129
141
|
loaded.push({ fullPath: resolved.fullPath, relativePath: resolved.relativePath, content });
|
|
130
142
|
}
|
|
131
143
|
// Build the enriched prompt: strip the `@` prefix from each mention so
|
|
@@ -219,15 +231,15 @@ export function expandFolderMentions(prompt, opts) {
|
|
|
219
231
|
failures.push({ mention: tok.raw, reason: 'not a directory (use @file)' });
|
|
220
232
|
continue;
|
|
221
233
|
}
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
failures.push({ mention: tok.raw, reason:
|
|
234
|
+
// Same rule as a single file: a committed `docs -> ~` must not be walked.
|
|
235
|
+
if (linksOutsideRoot(resolved.fullPath, opts.root)) {
|
|
236
|
+
failures.push({ mention: tok.raw, reason: LINKS_OUTSIDE_REASON });
|
|
225
237
|
continue;
|
|
226
238
|
}
|
|
239
|
+
const walked = walkDirectory(resolved.fullPath, opts.root, seen);
|
|
227
240
|
loaded.push(...walked.files);
|
|
228
|
-
|
|
229
|
-
failures.push({ mention: tok.raw, reason
|
|
230
|
-
}
|
|
241
|
+
for (const reason of walkNotes(walked))
|
|
242
|
+
failures.push({ mention: tok.raw, reason });
|
|
231
243
|
}
|
|
232
244
|
// Strip the `@folder <path>` tokens from the visible prompt, leaving
|
|
233
245
|
// the bare path so the agent still sees what was referenced.
|
|
@@ -275,15 +287,14 @@ export function expandFileAndFolderMentions(prompt, opts) {
|
|
|
275
287
|
folderFailures.push({ mention: tok.raw, reason: 'not a directory (use @file)' });
|
|
276
288
|
continue;
|
|
277
289
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
folderFailures.push({ mention: tok.raw, reason: walked.capped ? `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap` : 'no source files found' });
|
|
290
|
+
if (linksOutsideRoot(resolved.fullPath, opts.root)) {
|
|
291
|
+
folderFailures.push({ mention: tok.raw, reason: LINKS_OUTSIDE_REASON });
|
|
281
292
|
continue;
|
|
282
293
|
}
|
|
294
|
+
const walked = walkDirectory(resolved.fullPath, opts.root, seen);
|
|
283
295
|
folderLoaded.push(...walked.files);
|
|
284
|
-
|
|
285
|
-
folderFailures.push({ mention: tok.raw, reason
|
|
286
|
-
}
|
|
296
|
+
for (const reason of walkNotes(walked))
|
|
297
|
+
folderFailures.push({ mention: tok.raw, reason });
|
|
287
298
|
}
|
|
288
299
|
// Strip the `@folder` tokens from the prompt before running `@file`
|
|
289
300
|
// expansion, so `@folder src/x` isn't re-matched as a file mention.
|
|
@@ -318,8 +329,20 @@ export function expandFileAndFolderMentions(prompt, opts) {
|
|
|
318
329
|
*/
|
|
319
330
|
function walkDirectory(dir, root, seen) {
|
|
320
331
|
const files = [];
|
|
332
|
+
const skipped = { secrets: 0, ignored: 0, ignoredDirs: 0, outside: 0, outsideDirs: 0 };
|
|
321
333
|
let totalBytes = 0;
|
|
322
334
|
let capped = false;
|
|
335
|
+
// Honour the project's .gitignore beneath the named directory: that is
|
|
336
|
+
// where local secrets and build output live. The directory itself was named
|
|
337
|
+
// deliberately, so the rules that ignore it or everything in it (`dist/`,
|
|
338
|
+
// `dist/*`) are dropped, the same way DEFAULT_IGNORE_DIRS only applies
|
|
339
|
+
// below it. A directory outside the project has no rules of its own here.
|
|
340
|
+
const insideRoot = computeRelativePath(dir, root) !== dir;
|
|
341
|
+
const ignore = insideRoot ? rulesBelow(dir, loadIgnoreRules(root)) : null;
|
|
342
|
+
// A symlink may only lead somewhere the user already chose to share: the
|
|
343
|
+
// named directory or the project. A cloned repo can commit
|
|
344
|
+
// `docs/notes.md -> ~/.zsh_history`, and the loaded file list is never shown.
|
|
345
|
+
const allowed = [realPathOf(dir), realPathOf(root)].filter((p) => p !== null);
|
|
323
346
|
const walk = (d, depth) => {
|
|
324
347
|
if (capped || depth > 6)
|
|
325
348
|
return;
|
|
@@ -342,36 +365,90 @@ function walkDirectory(dir, root, seen) {
|
|
|
342
365
|
catch {
|
|
343
366
|
continue;
|
|
344
367
|
}
|
|
368
|
+
if (isDir && DEFAULT_IGNORE_DIRS.has(name))
|
|
369
|
+
continue;
|
|
370
|
+
if (!isDir && (!shouldSuggest(name) || seen.has(full)))
|
|
371
|
+
continue;
|
|
372
|
+
if (ignore && isIgnored(full, ignore)) {
|
|
373
|
+
if (isDir)
|
|
374
|
+
skipped.ignoredDirs++;
|
|
375
|
+
else
|
|
376
|
+
skipped.ignored++;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const real = realPathOf(full);
|
|
380
|
+
if (real === null)
|
|
381
|
+
continue;
|
|
382
|
+
if (!allowed.some((base) => isWithin(real, base))) {
|
|
383
|
+
if (isDir)
|
|
384
|
+
skipped.outsideDirs++;
|
|
385
|
+
else
|
|
386
|
+
skipped.outside++;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
345
389
|
if (isDir) {
|
|
346
|
-
if (DEFAULT_IGNORE_DIRS.has(name))
|
|
347
|
-
continue;
|
|
348
390
|
walk(full, depth + 1);
|
|
391
|
+
continue;
|
|
349
392
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const fstat = safeStat(full);
|
|
356
|
-
if (!fstat.exists || !fstat.isFile)
|
|
357
|
-
continue;
|
|
358
|
-
if (fstat.size > MAX_MENTION_BYTES)
|
|
359
|
-
continue;
|
|
360
|
-
const content = safeRead(full);
|
|
361
|
-
if (content === null)
|
|
362
|
-
continue;
|
|
363
|
-
if (totalBytes + content.length > MAX_FOLDER_BYTES) {
|
|
364
|
-
capped = true;
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
totalBytes += content.length;
|
|
368
|
-
seen.add(full);
|
|
369
|
-
files.push({ fullPath: full, relativePath: relative(root, full), content });
|
|
393
|
+
// Same rule as a single-file mention: `@dir ~/.ssh` or `@dir config`
|
|
394
|
+
// must not ship the private key that `@config/server.key` refuses.
|
|
395
|
+
if (isSensitiveFile(full)) {
|
|
396
|
+
skipped.secrets++;
|
|
397
|
+
continue;
|
|
370
398
|
}
|
|
399
|
+
const fstat = safeStat(full);
|
|
400
|
+
if (!fstat.exists || !fstat.isFile)
|
|
401
|
+
continue;
|
|
402
|
+
if (fstat.size > MAX_MENTION_BYTES)
|
|
403
|
+
continue;
|
|
404
|
+
const content = safeRead(full);
|
|
405
|
+
if (content === null)
|
|
406
|
+
continue;
|
|
407
|
+
if (looksLikeKeyMaterial(content)) {
|
|
408
|
+
skipped.secrets++;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (totalBytes + content.length > MAX_FOLDER_BYTES) {
|
|
412
|
+
capped = true;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
totalBytes += content.length;
|
|
416
|
+
seen.add(full);
|
|
417
|
+
files.push({ fullPath: full, relativePath: relative(root, full), content });
|
|
371
418
|
}
|
|
372
419
|
};
|
|
373
420
|
walk(dir, 0);
|
|
374
|
-
return { files, capped };
|
|
421
|
+
return { files, capped, skipped };
|
|
422
|
+
}
|
|
423
|
+
/** The notes a folder mention reports back: the cap, and what was left out. */
|
|
424
|
+
function walkNotes(walked) {
|
|
425
|
+
const { secrets, ignored, ignoredDirs, outside, outsideDirs } = walked.skipped;
|
|
426
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
427
|
+
const parts = [];
|
|
428
|
+
if (secrets)
|
|
429
|
+
parts.push(count(secrets, 'secret-looking file'));
|
|
430
|
+
if (ignored)
|
|
431
|
+
parts.push(count(ignored, 'ignored file'));
|
|
432
|
+
if (ignoredDirs)
|
|
433
|
+
parts.push(count(ignoredDirs, 'ignored folder'));
|
|
434
|
+
if (outside)
|
|
435
|
+
parts.push(`${count(outside, 'file')} linked from outside the project`);
|
|
436
|
+
if (outsideDirs)
|
|
437
|
+
parts.push(`${count(outsideDirs, 'folder')} linked from outside the project`);
|
|
438
|
+
const skippedNote = parts.length
|
|
439
|
+
? `skipped ${parts.join(', ')}; use /add to attach one deliberately`
|
|
440
|
+
: '';
|
|
441
|
+
const capNote = `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap`;
|
|
442
|
+
if (walked.files.length === 0) {
|
|
443
|
+
const reason = walked.capped ? capNote : 'no source files found';
|
|
444
|
+
return [skippedNote ? `${reason}; ${skippedNote}` : reason];
|
|
445
|
+
}
|
|
446
|
+
const notes = [];
|
|
447
|
+
if (walked.capped)
|
|
448
|
+
notes.push(`${capNote}, loaded ${walked.files.length} file(s)`);
|
|
449
|
+
if (skippedNote)
|
|
450
|
+
notes.push(skippedNote);
|
|
451
|
+
return notes;
|
|
375
452
|
}
|
|
376
453
|
/**
|
|
377
454
|
* Resolve a mention's path to an absolute filesystem path and a
|
|
@@ -379,7 +456,8 @@ function walkDirectory(dir, root, seen) {
|
|
|
379
456
|
*
|
|
380
457
|
* Rules:
|
|
381
458
|
* `/abs/...` → used as-is, relativePath computed from root.
|
|
382
|
-
* `./rel/...` → resolved against
|
|
459
|
+
* `./rel/...` → resolved against root too. In ACP the process cwd is not
|
|
460
|
+
* the workspace, so `@dir .` walked the wrong directory.
|
|
383
461
|
* `rel/...` → resolved against root (project root).
|
|
384
462
|
* `~/...` → expanded to the home directory.
|
|
385
463
|
*/
|
|
@@ -394,7 +472,7 @@ function resolveMentionPath(mentionPath, root) {
|
|
|
394
472
|
fullPath = resolve(mentionPath);
|
|
395
473
|
}
|
|
396
474
|
else if (mentionPath.startsWith('./') || mentionPath.startsWith('.\\') || mentionPath === '.') {
|
|
397
|
-
fullPath = resolve(
|
|
475
|
+
fullPath = resolve(root, mentionPath);
|
|
398
476
|
}
|
|
399
477
|
else {
|
|
400
478
|
fullPath = resolve(join(root, mentionPath));
|
|
@@ -526,12 +604,142 @@ const IGNORED_DOTFILES = new Set(['.DS_Store', '.env']);
|
|
|
526
604
|
* Filenames that typically hold credentials. Mentions never auto-inline
|
|
527
605
|
* these — see the guard in `expandMentions`. Matched on the basename so it
|
|
528
606
|
* catches the file wherever it lives (project root, `~/.aws/`, …).
|
|
607
|
+
* Committed templates (`.env.example`) are left to `ENV_TEMPLATE_RE`;
|
|
608
|
+
* SSH keys match with any suffix (`id_ed25519_work`) except `.pub`.
|
|
609
|
+
*/
|
|
610
|
+
const SENSITIVE_FILE_RE = /^(\.env(?!\.(?:example|sample|template)$)(\..*)?|\.netrc|\.npmrc|\.pgpass|\.git-credentials|\.pypirc|\.dockercfg|credentials|id_(?!.*\.pub$)(rsa|dsa|ecdsa|ed25519)(_sk)?([_.-].*)?|.*\.(pem|key|p12|pfx|keystore|ppk))$/i;
|
|
611
|
+
/**
|
|
612
|
+
* Credential files whose own name is generic, recognised by the directory
|
|
613
|
+
* they sit in: `~/.docker/config.json`, `~/.kube/config`,
|
|
614
|
+
* `~/.config/gh/hosts.yml`.
|
|
615
|
+
*/
|
|
616
|
+
const SENSITIVE_PATH_RE = /(^|[\\/])(\.docker[\\/]config\.json|\.kube[\\/]config|gh[\\/]hosts\.ya?ml)$/i;
|
|
617
|
+
const SECRETS_REASON = 'looks like a secrets file — use /add to attach it deliberately';
|
|
618
|
+
/**
|
|
619
|
+
* Committed `.env` templates. A walk and smart context include them, but a
|
|
620
|
+
* single `@.env.example` is refused as it always was: a template can still
|
|
621
|
+
* hold a real value.
|
|
622
|
+
*/
|
|
623
|
+
const ENV_TEMPLATE_RE = /^\.env\.(?:example|sample|template)$/i;
|
|
624
|
+
/**
|
|
625
|
+
* True if `fullPath` looks like it holds secrets, judged by its basename and,
|
|
626
|
+
* for a symlink, by the basename of the file it points at: a repo can commit
|
|
627
|
+
* `.env.example -> .env` or `tsconfig.json -> ../.env`, and every read
|
|
628
|
+
* follows the link.
|
|
529
629
|
*/
|
|
530
|
-
const SENSITIVE_FILE_RE = /^(\.env(\..*)?|\.netrc|\.npmrc|\.pgpass|credentials|id_(rsa|dsa|ecdsa|ed25519)|.*\.(pem|key|p12|pfx|keystore))$/i;
|
|
531
|
-
/** True if `fullPath`'s basename looks like it holds secrets. */
|
|
532
630
|
export function isSensitiveFile(fullPath) {
|
|
533
|
-
|
|
534
|
-
|
|
631
|
+
if (nameMatches(fullPath, SENSITIVE_FILE_RE))
|
|
632
|
+
return true;
|
|
633
|
+
if (SENSITIVE_PATH_RE.test(fullPath))
|
|
634
|
+
return true;
|
|
635
|
+
const real = realPathOf(fullPath);
|
|
636
|
+
return real !== null && SENSITIVE_PATH_RE.test(real);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* True if a file named on its own (an @-mention, or a path smart context
|
|
640
|
+
* picks out of the prompt) must not be inlined: secrets, and committed `.env`
|
|
641
|
+
* templates, which can still hold a real value. Both paths use this one rule,
|
|
642
|
+
* so a mention the user was told is refused never reaches the provider
|
|
643
|
+
* another way.
|
|
644
|
+
*/
|
|
645
|
+
export function isRefusedMention(fullPath) {
|
|
646
|
+
return isSensitiveFile(fullPath) || nameMatches(fullPath, ENV_TEMPLATE_RE);
|
|
647
|
+
}
|
|
648
|
+
/** True if the basename of `fullPath`, or of the file it links to, matches `re`. */
|
|
649
|
+
function nameMatches(fullPath, re) {
|
|
650
|
+
const nameOf = (p) => p.split(sep).pop() ?? p;
|
|
651
|
+
if (re.test(nameOf(fullPath)))
|
|
652
|
+
return true;
|
|
653
|
+
const real = realPathOf(fullPath);
|
|
654
|
+
return real !== null && re.test(nameOf(real));
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* A PEM / OpenSSH / PGP private-key block, or a PuTTY key header. The armour
|
|
658
|
+
* line must be followed by a line break, real or escaped (a service account
|
|
659
|
+
* JSON holds the key inside a string), then optional `Proc-Type:` /
|
|
660
|
+
* `Version:` headers and a blank line, then a line of base64. Code and docs
|
|
661
|
+
* that only name the armour (`"-----BEGIN RSA PRIVATE KEY-----"`, a regex,
|
|
662
|
+
* `'…-----\n' + body`, a `...` placeholder) don't match.
|
|
663
|
+
*/
|
|
664
|
+
const PRIVATE_KEY_RE = new RegExp(String.raw `-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[ \t]*(?:\r?\n|\\(?:r\\)?n)` +
|
|
665
|
+
String.raw `(?:[ \t]*[A-Za-z][\w-]*:[^\r\n\\]*(?:\r?\n|\\(?:r\\)?n))*` +
|
|
666
|
+
String.raw `(?:[ \t]*(?:\r?\n|\\(?:r\\)?n))?` +
|
|
667
|
+
String.raw `[ \t]*[A-Za-z0-9+/]+=*[ \t]*(?:\\(?:r\\)?n|$)` +
|
|
668
|
+
String.raw `|^PuTTY-User-Key-File-\d+:`, 'm');
|
|
669
|
+
/**
|
|
670
|
+
* True if `text` holds private-key material anywhere. Keys are saved under
|
|
671
|
+
* any name (`~/.ssh/github`, `deploy/prod`), so the name check alone lets
|
|
672
|
+
* them through, and a key can sit past the top of a JSON or YAML file.
|
|
673
|
+
*/
|
|
674
|
+
export function looksLikeKeyMaterial(text) {
|
|
675
|
+
return PRIVATE_KEY_RE.test(text) || hasPrivateKeyBlock(text);
|
|
676
|
+
}
|
|
677
|
+
const KEY_ARMOUR_RE = /-----BEGIN ((?:[A-Z0-9]+ )*)PRIVATE KEY( BLOCK)?-----/g;
|
|
678
|
+
/** How far past an armour line a key body is looked for. */
|
|
679
|
+
const KEY_BLOCK_WINDOW = 20 * 1024;
|
|
680
|
+
/**
|
|
681
|
+
* A complete private key block written the way code and config write it: on
|
|
682
|
+
* one line with spaces for breaks (YAML, CI variables), as concatenated or
|
|
683
|
+
* joined string literals, with `\n` or `\\n` escapes, or with the body glued
|
|
684
|
+
* to the armour line. The line-shaped pattern above misses those.
|
|
685
|
+
*
|
|
686
|
+
* Plain string work rather than one regex: a pattern that spans BEGIN, a body
|
|
687
|
+
* and END backtracks badly on a file full of BEGIN lines with no END.
|
|
688
|
+
*/
|
|
689
|
+
function hasPrivateKeyBlock(text) {
|
|
690
|
+
for (const m of text.matchAll(KEY_ARMOUR_RE)) {
|
|
691
|
+
const start = (m.index ?? 0) + m[0].length;
|
|
692
|
+
const nextBegin = text.indexOf('-----BEGIN ', start);
|
|
693
|
+
const limit = Math.min(nextBegin < 0 ? text.length : nextBegin, start + KEY_BLOCK_WINDOW);
|
|
694
|
+
const window = text.slice(start, limit);
|
|
695
|
+
const endAt = window.indexOf(`-----END ${m[1]}PRIVATE KEY${m[2] ?? ''}-----`);
|
|
696
|
+
if (endAt < 0)
|
|
697
|
+
continue;
|
|
698
|
+
const body = window.slice(0, endAt)
|
|
699
|
+
.replace(/\\\\?[rn]/g, '\n') // \n and \\n escapes are line breaks
|
|
700
|
+
.replace(/\\\//g, '/') // JSON writes / as \/
|
|
701
|
+
.split(/\r?\n/)
|
|
702
|
+
// Armour headers (Proc-Type:, Comment:) are not base64, which has no colon.
|
|
703
|
+
.filter((line) => !line.includes(':'))
|
|
704
|
+
.join('')
|
|
705
|
+
.replace(/[\s"'`+,[\]]/g, '');
|
|
706
|
+
const run = /^[A-Za-z0-9/=]{40,}/.exec(body)?.[0];
|
|
707
|
+
// A real body mixes many characters; a placeholder like xxxx… does not.
|
|
708
|
+
if (run && new Set(run).size >= 10)
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
/** `fullPath` with every symlink resolved, or null when it doesn't resolve. */
|
|
714
|
+
function realPathOf(fullPath) {
|
|
715
|
+
try {
|
|
716
|
+
return realpathSync(fullPath);
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const LINKS_OUTSIDE_REASON = 'links outside the project — use /add to attach it deliberately';
|
|
723
|
+
/**
|
|
724
|
+
* True if `fullPath` names a place inside `root` but, with symlinks resolved,
|
|
725
|
+
* leads outside it. A path outside the project by name (`~/.ssh`) was chosen
|
|
726
|
+
* on purpose and is not judged here.
|
|
727
|
+
*/
|
|
728
|
+
function linksOutsideRoot(fullPath, root) {
|
|
729
|
+
return isWithin(fullPath, resolve(root)) && !resolvesWithin(fullPath, root);
|
|
730
|
+
}
|
|
731
|
+
/** True if `child` is `parent` or lies beneath it. */
|
|
732
|
+
function isWithin(child, parent) {
|
|
733
|
+
return child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* True if `fullPath`, with symlinks resolved, lies inside `dir` (also
|
|
737
|
+
* resolved). False when either doesn't exist.
|
|
738
|
+
*/
|
|
739
|
+
export function resolvesWithin(fullPath, dir) {
|
|
740
|
+
const real = realPathOf(fullPath);
|
|
741
|
+
const realDir = realPathOf(dir);
|
|
742
|
+
return real !== null && realDir !== null && isWithin(real, realDir);
|
|
535
743
|
}
|
|
536
744
|
/** True if a filename looks like a suggestible source file. */
|
|
537
745
|
function shouldSuggest(name) {
|
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
* personality is active.
|
|
35
35
|
* - Persists across sessions until cleared with `/personality off`.
|
|
36
36
|
*/
|
|
37
|
-
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
37
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
|
|
38
38
|
import { basename, join } from 'path';
|
|
39
39
|
import { homedir } from 'os';
|
|
40
|
+
import { leadsOutsideProject } from './projectPaths.js';
|
|
40
41
|
import { config } from '../config/index.js';
|
|
41
42
|
import { getProvider } from '../config/providers.js';
|
|
42
43
|
const CAPABILITIES = new Set([
|
|
@@ -323,7 +324,7 @@ The user wants this merged today:
|
|
|
323
324
|
},
|
|
324
325
|
];
|
|
325
326
|
/** Load custom personalities from a `.codeep/personalities/` directory. */
|
|
326
|
-
function loadFromDir(dir, scope) {
|
|
327
|
+
function loadFromDir(dir, scope, projectRoot) {
|
|
327
328
|
if (!existsSync(dir))
|
|
328
329
|
return [];
|
|
329
330
|
const out = [];
|
|
@@ -341,9 +342,18 @@ function loadFromDir(dir, scope) {
|
|
|
341
342
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(name))
|
|
342
343
|
continue; // skip weirdly-named files
|
|
343
344
|
try {
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
345
|
+
const file = join(dir, entry);
|
|
346
|
+
// A project's files come with the repo: a link out of it would put an
|
|
347
|
+
// arbitrary file of the user's (credentials, history) into the prompt.
|
|
348
|
+
if (projectRoot && leadsOutsideProject(file, projectRoot))
|
|
349
|
+
continue;
|
|
350
|
+
// Cap at 64 KB, and check before reading: statSync follows symlinks,
|
|
351
|
+
// and a committed link to /dev/zero (or a FIFO) never comes back from
|
|
352
|
+
// readFileSync.
|
|
353
|
+
const stat = statSync(file);
|
|
354
|
+
if (!stat.isFile() || stat.size > 64 * 1024)
|
|
355
|
+
continue;
|
|
356
|
+
const raw = readFileSync(file, 'utf8');
|
|
347
357
|
out.push(parsePersonalityMarkdown(raw, name, scope));
|
|
348
358
|
}
|
|
349
359
|
catch {
|
|
@@ -684,7 +694,7 @@ export function resolvePersonalityRuntimeModel(personality, current) {
|
|
|
684
694
|
}
|
|
685
695
|
export function loadAllPersonalities(workspaceRoot) {
|
|
686
696
|
const project = workspaceRoot
|
|
687
|
-
? loadFromDir(join(workspaceRoot, '.codeep', 'personalities'), 'project')
|
|
697
|
+
? loadFromDir(join(workspaceRoot, '.codeep', 'personalities'), 'project', workspaceRoot)
|
|
688
698
|
: [];
|
|
689
699
|
const global = loadFromDir(join(homedir(), '.codeep', 'personalities'), 'global');
|
|
690
700
|
// Merge with scope priority: project > global > builtin.
|