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.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -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
- const logDir = join(projectPath, '.codeep', 'logs');
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
- if (paths.local) {
75
- appendFileSync(paths.local, logLine, 'utf-8');
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.
@@ -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, writeFileSync, mkdirSync } from 'fs';
29
- import { join, dirname } from 'path';
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
- mkdirSync(dirname(path), { recursive: true });
251
- writeFileSync(path, JSON.stringify(parsed, null, 2) + '\n');
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
- writeFileSync(path, JSON.stringify(parsed, null, 2) + '\n');
311
+ writeProjectFile(workspaceRoot, path, JSON.stringify(parsed, null, 2) + '\n');
281
312
  return true;
282
313
  }
@@ -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-cwd file
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 are resolved against when they're
81
- * relative (not starting with `/` or `.`). Usually the project root
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
- /** True if `fullPath`'s basename looks like it holds secrets. */
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;
@@ -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-cwd file
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 (isSensitiveFile(resolved.fullPath)) {
105
- failures.push({ mention: tok.raw, reason: 'looks like a secrets file — use /add to attach it deliberately' });
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
- const walked = walkDirectory(resolved.fullPath, opts.root, seen);
223
- if (walked.files.length === 0) {
224
- failures.push({ mention: tok.raw, reason: walked.capped ? `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap` : 'no source files found' });
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
- if (walked.capped) {
229
- failures.push({ mention: tok.raw, reason: `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap, loaded ${walked.files.length} file(s)` });
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
- const walked = walkDirectory(resolved.fullPath, opts.root, seen);
279
- if (walked.files.length === 0) {
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
- if (walked.capped) {
285
- folderFailures.push({ mention: tok.raw, reason: `stopped at ${MAX_FOLDER_BYTES / 1024}KB cap, loaded ${walked.files.length} file(s)` });
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
- else {
351
- if (!shouldSuggest(name))
352
- continue;
353
- if (seen.has(full))
354
- continue;
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 cwd (not root), like a normal import.
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(process.cwd(), mentionPath);
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
- const name = fullPath.split(sep).pop() ?? fullPath;
534
- return SENSITIVE_FILE_RE.test(name);
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 raw = readFileSync(join(dir, entry), 'utf8');
345
- if (raw.length > 64 * 1024)
346
- continue; // cap at 64 KB
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.