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
@@ -11,9 +11,11 @@
11
11
  * AgentChatResponse — response type (re-export from agentStream)
12
12
  * TimeoutError — distinguishes timeout from user abort
13
13
  */
14
- import { existsSync, readFileSync, writeFileSync } from 'fs';
14
+ import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'fs';
15
15
  import { join } from 'path';
16
+ import { StringDecoder } from 'string_decoder';
16
17
  import { createHash } from 'crypto';
18
+ import { UnsafeProjectPathError, leadsOutsideProject, writeProjectFile } from './projectPaths.js';
17
19
  import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
18
20
  import { loadProjectIntelligence, generateContextFromIntelligence } from './projectIntelligence.js';
19
21
  import { formatCommandIndex } from './commandIndex.js';
@@ -41,6 +43,41 @@ export class TimeoutError extends Error {
41
43
  this.name = 'TimeoutError';
42
44
  }
43
45
  }
46
+ /**
47
+ * Read at most `maxBytes` of a project file, or null when it isn't a regular
48
+ * file inside the project. Rules and progress files arrive with a cloned
49
+ * repo, and statSync follows symlinks: a committed `AGENTS.md -> /dev/zero`
50
+ * (or a FIFO) never comes back from readFileSync, and every agent run would
51
+ * hang on it; `CODEEP.md -> ~/.aws/credentials` would put the user's
52
+ * credentials into every system prompt.
53
+ */
54
+ function readProjectFile(filePath, maxBytes, projectRoot) {
55
+ if (leadsOutsideProject(filePath, projectRoot))
56
+ return null;
57
+ const stat = statSync(filePath, { throwIfNoEntry: false });
58
+ if (stat && !stat.isFile())
59
+ return null;
60
+ // A path that vanished since the caller's existsSync is left to
61
+ // readFileSync to report.
62
+ if (!stat || stat.size <= maxBytes)
63
+ return { text: readFileSync(filePath, 'utf-8'), truncated: false };
64
+ const fd = openSync(filePath, 'r');
65
+ try {
66
+ const buf = Buffer.alloc(maxBytes);
67
+ let read = 0;
68
+ while (read < maxBytes) {
69
+ const n = readSync(fd, buf, read, maxBytes - read, read);
70
+ if (n === 0)
71
+ break;
72
+ read += n;
73
+ }
74
+ // StringDecoder holds back a multi-byte character split by the cut.
75
+ return { text: new StringDecoder('utf8').write(buf.subarray(0, read)), truncated: true };
76
+ }
77
+ finally {
78
+ closeSync(fd);
79
+ }
80
+ }
44
81
  /**
45
82
  * Load project rules from .codeep/rules.md or CODEEP.md
46
83
  */
@@ -69,12 +106,14 @@ export function loadProjectRules(projectRoot) {
69
106
  for (const filePath of candidates) {
70
107
  if (existsSync(filePath)) {
71
108
  try {
72
- let content = readFileSync(filePath, 'utf-8').trim();
109
+ const file = readProjectFile(filePath, MAX_RULES_BYTES, projectRoot);
110
+ if (!file)
111
+ continue;
112
+ let content = file.text.trim();
73
113
  if (content) {
74
- if (content.length > MAX_RULES_BYTES) {
75
- debug('Project rules truncated', filePath, `${content.length} > ${MAX_RULES_BYTES}`);
76
- content = content.slice(0, MAX_RULES_BYTES)
77
- + '\n\n[Rules truncated by Codeep — file exceeds the 64KB inline limit.]';
114
+ if (file.truncated) {
115
+ debug('Project rules truncated', filePath, `> ${MAX_RULES_BYTES} bytes`);
116
+ content += '\n\n[Rules truncated by Codeep — file exceeds the 64KB inline limit.]';
78
117
  }
79
118
  debug('Loaded project rules from', filePath);
80
119
  return `\n\n## Project Rules\nThe following rules are defined by the project owner. You MUST follow these rules:\n\n${content}`;
@@ -87,6 +126,8 @@ export function loadProjectRules(projectRoot) {
87
126
  }
88
127
  return '';
89
128
  }
129
+ /** Largest progress log we'll inject (64 KB). */
130
+ const MAX_PROGRESS_BYTES = 64 * 1024;
90
131
  /**
91
132
  * Load agent progress log from .codeep/progress.md
92
133
  * Injected into system prompt so agent knows what was previously done.
@@ -98,7 +139,12 @@ export function loadProgressLog(projectRoot) {
98
139
  if (!existsSync(progressFile))
99
140
  return '';
100
141
  try {
101
- const content = readFileSync(progressFile, 'utf-8').trim();
142
+ // Capped like the rules: this file can arrive with the repo too.
143
+ const file = readProjectFile(progressFile, MAX_PROGRESS_BYTES, projectRoot);
144
+ let content = file ? file.text.trim() : '';
145
+ if (content && file?.truncated) {
146
+ content += '\n\n[Progress log truncated by Codeep — file exceeds the 64KB inline limit.]';
147
+ }
102
148
  if (content) {
103
149
  return `\n\n## Previous Session Progress\nThe agent has previously worked on this project. Read this to understand what was already done and what still needs to be done:\n\n${content}`;
104
150
  }
@@ -108,6 +154,28 @@ export function loadProgressLog(projectRoot) {
108
154
  }
109
155
  return '';
110
156
  }
157
+ /** How much of the task the progress log keeps, split between its start and end. */
158
+ const MAX_PROGRESS_TASK_CHARS = 4000;
159
+ /**
160
+ * The task as the progress log records it. Callers pass the enriched prompt,
161
+ * with attached files in front of the user's words, so a long one keeps its
162
+ * start and its end: written whole, it filled the 64KB read budget and the
163
+ * Status and Summary sections below it never reached the next session.
164
+ */
165
+ function progressTask(prompt) {
166
+ if (prompt.length <= MAX_PROGRESS_TASK_CHARS)
167
+ return prompt;
168
+ const half = MAX_PROGRESS_TASK_CHARS / 2;
169
+ // Don't cut a surrogate pair in two.
170
+ let head = half;
171
+ if (/[\uD800-\uDBFF]/.test(prompt[head - 1]))
172
+ head--;
173
+ let tail = prompt.length - half;
174
+ if (/[\uDC00-\uDFFF]/.test(prompt[tail]))
175
+ tail++;
176
+ const omitted = tail - head;
177
+ return `${prompt.slice(0, head)}\n[… ${omitted} characters of the task omitted …]\n${prompt.slice(tail)}`;
178
+ }
111
179
  /**
112
180
  * Write agent progress log to .codeep/progress.md
113
181
  * Called after each agent run so the next session has context.
@@ -129,7 +197,7 @@ export function writeProgressLog(projectRoot, prompt, result, projectName) {
129
197
  `## Last Session: ${now}`,
130
198
  ``,
131
199
  `### Task`,
132
- `${prompt}`,
200
+ progressTask(prompt),
133
201
  ``,
134
202
  `### Status`,
135
203
  result.success ? `✓ Completed (${result.iterations} iterations)` : `⚠ Incomplete — task may need to be continued`,
@@ -161,13 +229,15 @@ export function writeProgressLog(projectRoot, prompt, result, projectName) {
161
229
  lines.push('');
162
230
  }
163
231
  const content = lines.join('\n');
164
- writeFileSync(join(codeepDir, 'progress.md'), content, 'utf-8');
232
+ // .codeep/ comes with the repo: a symlinked progress.md (or .codeep) would
233
+ // have this overwrite whatever file the repo pointed it at.
234
+ writeProjectFile(projectRoot, join(codeepDir, 'progress.md'), content);
165
235
  if (projectName && projectRoot) {
166
236
  syncProgress({ projectName, projectId: generateProjectId(projectRoot), content });
167
237
  }
168
238
  }
169
239
  catch (err) {
170
- debug('Failed to write progress log:', err);
240
+ debug(err instanceof UnsafeProjectPathError ? 'Not writing progress log:' : 'Failed to write progress log:', err);
171
241
  }
172
242
  }
173
243
  /**
@@ -451,6 +521,8 @@ additionalTools, runtime) {
451
521
  temperature: requiresDefaultTemperature(providerId) ? undefined : Number(config.get('temperature')),
452
522
  timeoutMs,
453
523
  onChunk: useStreaming ? onChunk : undefined,
524
+ // Carries the user's Stop as well as this request's own timeout.
525
+ signal: controller.signal,
454
526
  });
455
527
  if (res.promptTokens != null && res.completionTokens != null) {
456
528
  recordTokenUsage({ promptTokens: res.promptTokens, completionTokens: res.completionTokens, totalTokens: res.promptTokens + res.completionTokens }, model, providerId);
@@ -10,7 +10,8 @@
10
10
  * - **Built-in**: hardcoded below (researcher, reviewer, tester).
11
11
  * - **Project**: `<workspace>/.codeep/agents/<name>.md`
12
12
  * - **Global**: `~/.codeep/agents/<name>.md`
13
- * Project shadows global shadows built-in, by name.
13
+ * Project shadows global shadows built-in, by name. A project file that
14
+ * shadows a built-in keeps at most the built-in's tools.
14
15
  *
15
16
  * File format — YAML-ish frontmatter + Markdown body (the role prompt):
16
17
  * ```
@@ -10,7 +10,8 @@
10
10
  * - **Built-in**: hardcoded below (researcher, reviewer, tester).
11
11
  * - **Project**: `<workspace>/.codeep/agents/<name>.md`
12
12
  * - **Global**: `~/.codeep/agents/<name>.md`
13
- * Project shadows global shadows built-in, by name.
13
+ * Project shadows global shadows built-in, by name. A project file that
14
+ * shadows a built-in keeps at most the built-in's tools.
14
15
  *
15
16
  * File format — YAML-ish frontmatter + Markdown body (the role prompt):
16
17
  * ```
@@ -25,9 +26,10 @@
25
26
  * You are a senior reviewer. Find correctness & security issues…
26
27
  * ```
27
28
  */
28
- import { readFileSync, readdirSync, existsSync } from 'fs';
29
+ import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
29
30
  import { join } from 'path';
30
31
  import { homedir } from 'os';
32
+ import { leadsOutsideProject } from './projectPaths.js';
31
33
  const BUILTIN = [
32
34
  {
33
35
  name: 'planner',
@@ -79,14 +81,73 @@ const BUILTIN = [
79
81
  - Final message: what you added, the command to run them, and the pass/fail result.`,
80
82
  },
81
83
  ];
82
- /** Parse `tools: [a, b]` or `tools: a, b` out of a frontmatter line value. */
84
+ /** Largest agent file we'll read (64 KB). */
85
+ const MAX_AGENT_FILE_BYTES = 64 * 1024;
86
+ const unquote = (s) => s.trim().replace(/^["']|["']$/g, '');
87
+ /** Drop a YAML trailing comment (`read_file # safe`) from an unquoted value. */
88
+ const uncomment = (s) => s.replace(/(^|\s)#.*$/, '');
89
+ /**
90
+ * Parse a `tools:` value: `[a, b]`, `a, b`, or a YAML block list. A key that
91
+ * is present always yields a list, possibly empty. Only an absent key means
92
+ * "all tools", so a value we can't read denies tools rather than granting
93
+ * every one of them.
94
+ */
83
95
  function parseToolsValue(raw) {
84
- const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '');
85
- const list = inner.split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
86
- return list.length > 0 ? list : undefined;
96
+ if (Array.isArray(raw))
97
+ return raw.map(unquote).filter(Boolean);
98
+ const inline = raw.trim().match(/^\[([^\]]*)\]/);
99
+ const inner = inline ? inline[1] : uncomment(raw);
100
+ return inner.split(',').map(unquote).filter(Boolean);
101
+ }
102
+ /**
103
+ * Split an agent file into lowercase frontmatter keys and the body. A key
104
+ * with an empty value collects the `- item` lines under it. Returns null when
105
+ * the file opens a frontmatter fence that never closes: dropping the file is
106
+ * safer than reading its `tools:` line as part of the prompt.
107
+ */
108
+ function parseAgentFile(raw) {
109
+ // Windows editors save CRLF and some prepend a BOM; neither may cost the
110
+ // file its frontmatter. Nor may a blank line above the opening fence: read
111
+ // as body, the `tools:` line would be dropped and every tool allowed.
112
+ const text = raw.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').replace(/^(?:[ \t]*\n)+(?=---)/, '');
113
+ const meta = {};
114
+ if (!/^---[ \t]*\n/.test(text))
115
+ return { meta, body: text };
116
+ // An empty block (`---` twice) is checked first: the general pattern would
117
+ // take a later `---` rule in the body as the closing fence.
118
+ const fm = text.match(/^---[ \t]*\n()---[ \t]*(?:\n([\s\S]*))?$/)
119
+ ?? text.match(/^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n([\s\S]*))?$/);
120
+ if (!fm)
121
+ return null;
122
+ let list = null;
123
+ for (const line of fm[1].split('\n')) {
124
+ // YAML lets a blank line or a comment line sit inside a block list
125
+ if (!line.trim() || /^\s*#/.test(line))
126
+ continue;
127
+ const item = line.match(/^\s*-\s+(.*)$/);
128
+ if (item && list) {
129
+ list.push(uncomment(item[1]));
130
+ continue;
131
+ }
132
+ list = null;
133
+ const m = line.match(/^([a-zA-Z]+)\s*:\s*(.*)$/);
134
+ if (!m)
135
+ continue;
136
+ // `tools: # read-only` is an empty value with a comment; the list follows.
137
+ const value = m[2].trim().startsWith('#') ? '' : m[2].trim();
138
+ if (value) {
139
+ meta[m[1].toLowerCase()] = value;
140
+ }
141
+ else {
142
+ list = [];
143
+ meta[m[1].toLowerCase()] = list;
144
+ }
145
+ }
146
+ return { meta, body: fm[2] ?? '' };
87
147
  }
148
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
88
149
  /** Load custom agents from a `.codeep/agents/` directory. */
89
- function loadFromDir(dir, scope) {
150
+ function loadFromDir(dir, scope, projectRoot) {
90
151
  if (!existsSync(dir))
91
152
  return [];
92
153
  const out = [];
@@ -104,32 +165,33 @@ function loadFromDir(dir, scope) {
104
165
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug))
105
166
  continue;
106
167
  try {
107
- const raw = readFileSync(join(dir, entry), 'utf8');
108
- if (raw.length > 64 * 1024)
168
+ const file = join(dir, entry);
169
+ // A project's files come with the repo: a link out of it would put an
170
+ // arbitrary file of the user's (credentials, history) into the prompt.
171
+ if (projectRoot && leadsOutsideProject(file, projectRoot))
109
172
  continue;
110
- const fm = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
111
- const meta = {};
112
- let body = raw;
113
- if (fm) {
114
- body = fm[2];
115
- for (const line of fm[1].split('\n')) {
116
- const m = line.match(/^([a-zA-Z]+):\s*(.*)$/);
117
- if (m)
118
- meta[m[1].toLowerCase()] = m[2].trim();
119
- }
120
- }
121
- const displayName = meta.name || slug;
122
- const description = meta.description || `Custom agent from ${entry}`;
123
- const tools = meta.tools ? parseToolsValue(meta.tools) : undefined;
124
- const maxIterations = meta.maxiterations ? parseInt(meta.maxiterations, 10) : undefined;
173
+ // statSync follows symlinks: a committed link to /dev/zero or a FIFO
174
+ // never comes back from readFileSync, so check kind and size first.
175
+ const stat = statSync(file);
176
+ if (!stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES)
177
+ continue;
178
+ const parsed = parseAgentFile(readFileSync(file, 'utf8'));
179
+ if (!parsed)
180
+ continue;
181
+ const { meta, body } = parsed;
182
+ const displayName = str(meta.name) || slug;
183
+ const description = str(meta.description) || `Custom agent from ${entry}`;
184
+ const tools = meta.tools !== undefined ? parseToolsValue(meta.tools) : undefined;
185
+ const iterations = str(meta.maxiterations);
186
+ const maxIterations = iterations ? parseInt(iterations, 10) : undefined;
125
187
  out.push({
126
188
  name: slug,
127
189
  displayName,
128
190
  description: description.length > 200 ? description.slice(0, 197) + '…' : description,
129
191
  prompt: body.trim(),
130
192
  tools,
131
- model: meta.model || undefined,
132
- personality: meta.personality || undefined,
193
+ model: str(meta.model),
194
+ personality: str(meta.personality),
133
195
  maxIterations: Number.isFinite(maxIterations) ? maxIterations : undefined,
134
196
  scope,
135
197
  });
@@ -141,15 +203,24 @@ function loadFromDir(dir, scope) {
141
203
  return out;
142
204
  }
143
205
  export function loadAgents(workspaceRoot) {
144
- const project = workspaceRoot ? loadFromDir(join(workspaceRoot, '.codeep', 'agents'), 'project') : [];
206
+ const project = workspaceRoot ? loadFromDir(join(workspaceRoot, '.codeep', 'agents'), 'project', workspaceRoot) : [];
145
207
  const global = loadFromDir(join(homedir(), '.codeep', 'agents'), 'global');
146
208
  const byName = new Map();
147
209
  for (const a of BUILTIN)
148
210
  byName.set(a.name, a);
149
211
  for (const a of global)
150
212
  byName.set(a.name, a);
151
- for (const a of project)
213
+ for (const a of project) {
214
+ // A cloned repo can replace a built-in's prompt, but not hand it more
215
+ // tools than it ships with: auto-review delegates to `reviewer` after
216
+ // every write, so that allowlist must hold whatever `.codeep/agents/` says.
217
+ const builtin = BUILTIN.find((b) => b.name === a.name);
218
+ if (builtin?.tools) {
219
+ const allowed = builtin.tools;
220
+ a.tools = a.tools ? a.tools.filter((t) => allowed.includes(t)) : [...allowed];
221
+ }
152
222
  byName.set(a.name, a);
223
+ }
153
224
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
154
225
  }
155
226
  export function findAgent(name, workspaceRoot) {
@@ -180,7 +251,7 @@ export function formatAgentList(workspaceRoot) {
180
251
  const lines = ['## Sub-agents', '', 'The agent can `delegate` self-contained sub-tasks to these. Each runs in its own context and returns a summary.', '', '| Name | Scope | Tools | Description |', '|---|---|---|---|'];
181
252
  for (const a of list) {
182
253
  const tag = a.scope === 'builtin' ? 'built-in' : a.scope;
183
- const tools = a.tools ? `${a.tools.length} scoped` : 'all';
254
+ const tools = !a.tools ? 'all' : a.tools.length ? `${a.tools.length} scoped` : 'none';
184
255
  lines.push(`| \`${a.name}\` | ${tag} | ${tools} | ${a.description} |`);
185
256
  }
186
257
  lines.push('', 'Add your own: drop a `<name>.md` with frontmatter (name, description, tools, model, personality) in `.codeep/agents/` (project) or `~/.codeep/agents/` (global).');
@@ -14,9 +14,10 @@
14
14
  * Format is JSON Lines. Appending one line per event survives a crash mid-run,
15
15
  * needs no read-modify-write, and stays greppable without a parser.
16
16
  *
17
- * PRIVACY: entries carry command lines and file paths, not file contents. A
18
- * command can still contain a secret someone typed into it, exactly as shell
19
- * history can — treat the directory like shell history, not like source.
17
+ * PRIVACY: entries carry command lines, file paths and MCP tool arguments, not
18
+ * file contents. A command or an argument can still contain a secret someone
19
+ * typed into it, exactly as shell history can — treat the directory like shell
20
+ * history, not like source.
20
21
  *
21
22
  * It sits under `.codeep/`, which most projects already ignore, but Codeep does
22
23
  * not edit anyone's `.gitignore` and this module must not claim otherwise. If a
@@ -14,24 +14,109 @@
14
14
  * Format is JSON Lines. Appending one line per event survives a crash mid-run,
15
15
  * needs no read-modify-write, and stays greppable without a parser.
16
16
  *
17
- * PRIVACY: entries carry command lines and file paths, not file contents. A
18
- * command can still contain a secret someone typed into it, exactly as shell
19
- * history can — treat the directory like shell history, not like source.
17
+ * PRIVACY: entries carry command lines, file paths and MCP tool arguments, not
18
+ * file contents. A command or an argument can still contain a secret someone
19
+ * typed into it, exactly as shell history can — treat the directory like shell
20
+ * history, not like source.
20
21
  *
21
22
  * It sits under `.codeep/`, which most projects already ignore, but Codeep does
22
23
  * not edit anyone's `.gitignore` and this module must not claim otherwise. If a
23
24
  * project tracks `.codeep/`, the audit log will be committed with it.
24
25
  */
25
- import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
26
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
27
+ import { appendProjectFile } from './projectPaths.js';
26
28
  import { join } from 'path';
27
29
  import { randomBytes } from 'crypto';
28
30
  import { config } from '../config/index.js';
31
+ /** Arguments that carry a file body. They are never part of a target. Names
32
+ * are compared without `_`/`-` and case, so `newText` is `new_text`. */
33
+ const CONTENT_ARGUMENTS = new Set([
34
+ 'content', 'contents', 'filecontent', 'filecontents', 'filetext', 'body',
35
+ 'oldstring', 'newstring', 'oldstr', 'newstr', 'oldtext', 'newtext',
36
+ 'oldcontent', 'newcontent', 'replacement', 'patch', 'diff',
37
+ ]);
38
+ /** Argument names whose value is a credential, compared without `_`/`-` and
39
+ * case. `token` is judged separately: most `*_token` arguments are secrets,
40
+ * but a page or continuation token is a cursor the user should see. */
41
+ const SECRET_ARGUMENT = /passw|passphrase|secret|apikey|accesskey|privatekey|authorization|credential|cookie/;
42
+ const CURSOR_TOKEN_QUALIFIERS = new Set(['page', 'next', 'prev', 'previous', 'continuation', 'cursor', 'sync', 'max', 'min', 'num', 'total', 'count']);
43
+ /**
44
+ * Values shaped like a well-known credential, whatever the argument is called.
45
+ * Each alternative consumes the whole credential, because only the matched
46
+ * text is replaced: a pattern that stopped after the prefix would leave the
47
+ * rest of the key on screen.
48
+ */
49
+ const SECRET_VALUE = /\b[sr]k[-_](?:live|test|proj|ant)[-_][\w-]+|\bsk-[\w-]{20,}|\bgh[pousr]_\w{20,}|\bgithub_pat_\w{20,}|\bAKIA[0-9A-Z]{16}\b|\bxox[abprs]-[\w-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)|\bBearer\s+[\w.~+/=-]{16,}/gi;
50
+ /** Longest single argument shown. The permission prompt lays out what is
51
+ * left and says what it drops, so this only bounds a pathological value. */
52
+ const MAX_ARGUMENT = 2000;
53
+ /** The words of an argument name: `page_token`, `pageToken`, `PAGE-TOKEN`. */
54
+ function nameWords(key) {
55
+ return key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[\s_-]+/).filter(Boolean);
56
+ }
57
+ /** True when an argument's name says its value is a credential. */
58
+ function isCredentialName(key) {
59
+ const words = nameWords(key);
60
+ if (SECRET_ARGUMENT.test(words.join('')))
61
+ return true;
62
+ return words.some((w, i) => (w === 'token' || w === 'tokens') && !(i > 0 && CURSOR_TOKEN_QUALIFIERS.has(words[i - 1])));
63
+ }
64
+ /** A plain value as it should appear: flattened, known credential shapes
65
+ * replaced where they occur, the rest left readable. */
66
+ function showValue(value) {
67
+ let text = String(value).replace(SECRET_VALUE, '[redacted]').replace(/\s+/g, ' ').trim();
68
+ if (text.length > MAX_ARGUMENT)
69
+ text = `${text.slice(0, MAX_ARGUMENT)}…[+${text.length - MAX_ARGUMENT} chars]`;
70
+ return text;
71
+ }
72
+ function isPlain(value) {
73
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
74
+ }
75
+ /**
76
+ * An MCP tool's arguments, as `name=value` pairs in the order given. The
77
+ * argument names are the server's, not the built-in `command`/`path`/`url`
78
+ * ones: `postgres__query` carries its SQL in `sql`, and a call described by
79
+ * its name alone would be approved in the permission prompt unseen.
80
+ *
81
+ * Plain values and lists of plain values are shown — an exec tool's `args`
82
+ * is what it will run. A list holding objects, or an object, is where servers
83
+ * put file bodies under names of their own (`files[].content`,
84
+ * `edits[].newText`), so it is counted, never printed.
85
+ */
86
+ function describeMcpArguments(p) {
87
+ const parts = [];
88
+ for (const [key, value] of Object.entries(p)) {
89
+ const name = key.toLowerCase().replace(/[-_]/g, '');
90
+ if (CONTENT_ARGUMENTS.has(name) || value === undefined || value === null)
91
+ continue;
92
+ if (isCredentialName(key) && (isPlain(value) || Array.isArray(value))) {
93
+ parts.push(`${key}=[redacted]`);
94
+ continue;
95
+ }
96
+ if (Array.isArray(value)) {
97
+ parts.push(value.every(isPlain)
98
+ ? `${key}=${value.map(showValue).join(' ')}`
99
+ : `${key}=[${value.length} ${value.length === 1 ? 'item' : 'items'}]`);
100
+ continue;
101
+ }
102
+ if (typeof value === 'object') {
103
+ const size = Object.keys(value).length;
104
+ parts.push(`${key}={${size} ${size === 1 ? 'key' : 'keys'}}`);
105
+ continue;
106
+ }
107
+ parts.push(`${key}=${showValue(value)}`);
108
+ }
109
+ return parts.join(', ');
110
+ }
29
111
  /** A one-line, content-free description of what a tool call was aimed at.
30
112
  * Paths, commands and URLs are the point of the record; file bodies are not,
31
113
  * and `content`/`old_string` style arguments are never read here. */
32
114
  export function describeAuditTarget(call) {
33
115
  const p = call.parameters ?? {};
34
116
  const str = (k) => (typeof p[k] === 'string' ? p[k] : undefined);
117
+ // MCP tools (`<server>__<tool>`; no built-in name contains `__`).
118
+ if (call.tool.includes('__'))
119
+ return describeMcpArguments(p) || call.tool;
35
120
  const command = str('command');
36
121
  if (command) {
37
122
  const args = Array.isArray(p.args) ? p.args.map(String) : [];
@@ -71,9 +156,6 @@ export function recordAuditEvent(projectRoot, event) {
71
156
  if (!isAuditEnabled())
72
157
  return;
73
158
  try {
74
- const dir = auditDir(projectRoot);
75
- if (!existsSync(dir))
76
- mkdirSync(dir, { recursive: true });
77
159
  const line = {
78
160
  ...event,
79
161
  target: clip(event.target, MAX_TARGET),
@@ -82,10 +164,11 @@ export function recordAuditEvent(projectRoot, event) {
82
164
  };
83
165
  // Drop undefined keys so a line stays small and diffs stay readable.
84
166
  const compact = Object.fromEntries(Object.entries(line).filter(([, v]) => v !== undefined));
85
- appendFileSync(auditFile(projectRoot), JSON.stringify(compact) + '\n');
167
+ // .codeep/ can come with a cloned repo: never append through a symlink.
168
+ appendProjectFile(projectRoot, auditFile(projectRoot), JSON.stringify(compact) + '\n');
86
169
  }
87
170
  catch {
88
- /* an unwritable audit log must never fail the run */
171
+ /* an unwritable (or symlinked) audit log must never fail the run */
89
172
  }
90
173
  }
91
174
  /** Open a run and return its id. Records the agent and the capabilities it was
@@ -33,15 +33,14 @@
33
33
  * "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
34
34
  * }
35
35
  */
36
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'fs';
36
+ import { existsSync, readdirSync, readFileSync, unlinkSync, statSync } from 'fs';
37
+ import { isSafeProjectWriteTarget, writeProjectFile } from './projectPaths.js';
37
38
  import { join } from 'path';
38
39
  import { randomUUID } from 'crypto';
39
40
  import { execSync } from 'child_process';
41
+ import { hardenedGitEnv } from './git.js';
40
42
  function getCheckpointsDir(workspaceRoot) {
41
- const dir = join(workspaceRoot, '.codeep', 'checkpoints');
42
- if (!existsSync(dir))
43
- mkdirSync(dir, { recursive: true });
44
- return dir;
43
+ return join(workspaceRoot, '.codeep', 'checkpoints');
45
44
  }
46
45
  function readGitHead(workspaceRoot) {
47
46
  try {
@@ -51,6 +50,8 @@ function readGitHead(workspaceRoot) {
51
50
  encoding: 'utf-8',
52
51
  stdio: ['ignore', 'pipe', 'ignore'],
53
52
  timeout: 2000,
53
+ // Read-only and Codeep's own, so the repository's hooks stay out of it.
54
+ env: hardenedGitEnv({ cwd: workspaceRoot, noHooks: true }),
54
55
  }).trim();
55
56
  return out || undefined;
56
57
  }
@@ -83,7 +84,8 @@ export function createCheckpoint(opts) {
83
84
  gitHead: readGitHead(opts.workspaceRoot),
84
85
  };
85
86
  const dir = getCheckpointsDir(opts.workspaceRoot);
86
- writeFileSync(join(dir, `${checkpoint.id}.json`), JSON.stringify(checkpoint, null, 2));
87
+ // .codeep/ can come with a cloned repo: never write through a symlink.
88
+ writeProjectFile(opts.workspaceRoot, join(dir, `${checkpoint.id}.json`), JSON.stringify(checkpoint, null, 2));
87
89
  return checkpoint;
88
90
  }
89
91
  /**
@@ -150,6 +152,9 @@ export function deleteCheckpoint(workspaceRoot, id) {
150
152
  const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
151
153
  if (!existsSync(file))
152
154
  return false;
155
+ // Through a symlinked .codeep/ the path would name a file elsewhere.
156
+ if (!isSafeProjectWriteTarget(workspaceRoot, file))
157
+ return false;
153
158
  try {
154
159
  unlinkSync(file);
155
160
  return true;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { existsSync, readFileSync, readdirSync } from 'fs';
5
5
  import { join, extname, relative } from 'path';
6
- import { getChangedFiles } from './git.js';
6
+ import { getChangedFilesResult } from './git.js';
7
7
  import { loadReviewConfig, globToRegExp } from './reviewConfig.js';
8
8
  // Built-in code patterns that indicate issues. Each has a stable `id` so it can
9
9
  // be turned off per-project via `.codeep/review.json` { "disable": ["..."] }.
@@ -274,26 +274,24 @@ function analyzeFile(filePath, content, projectRoot, rules, disabled) {
274
274
  }
275
275
  return issues;
276
276
  }
277
- /**
278
- * Get files to review
279
- */
280
277
  function getFilesToReview(projectRoot, specificFiles) {
281
278
  if (specificFiles && specificFiles.length > 0) {
282
- return specificFiles
283
- .map(f => join(projectRoot, f))
284
- .filter(f => existsSync(f));
279
+ return {
280
+ files: specificFiles.map(f => join(projectRoot, f)).filter(f => existsSync(f)),
281
+ source: 'specific',
282
+ };
285
283
  }
286
- // Get changed files from git
287
- const changedFiles = getChangedFiles(projectRoot);
288
- if (changedFiles.length > 0) {
289
- return changedFiles.map(f => join(projectRoot, f));
284
+ // Get changed files from git. Called ONCE — the scope line below used to
285
+ // call getChangedFiles() a second time, which is a second `git config
286
+ // --list` plus a second `git status` on every review.
287
+ const changed = getChangedFilesResult(projectRoot);
288
+ if (changed.files.length > 0) {
289
+ return { files: changed.files.map(f => join(projectRoot, f)), source: 'git' };
290
290
  }
291
291
  // Otherwise, review src directory
292
292
  const srcDir = join(projectRoot, 'src');
293
- if (existsSync(srcDir)) {
294
- return getAllSourceFiles(srcDir);
295
- }
296
- return getAllSourceFiles(projectRoot);
293
+ const files = existsSync(srcDir) ? getAllSourceFiles(srcDir) : getAllSourceFiles(projectRoot);
294
+ return { files, source: 'scan', gitError: changed.error };
297
295
  }
298
296
  /**
299
297
  * Get all source files in directory
@@ -341,7 +339,8 @@ export function performCodeReview(projectContext, specificFiles) {
341
339
  ...CODE_PATTERNS.filter((p) => !disabled.has(p.id)),
342
340
  ...(config?.rules ?? []),
343
341
  ];
344
- let filesToReview = getFilesToReview(projectRoot, specificFiles);
342
+ const selection = getFilesToReview(projectRoot, specificFiles);
343
+ let filesToReview = selection.files;
345
344
  // Apply include/exclude globs (posix-relative paths). Empty include = all.
346
345
  if (config && (config.include.length > 0 || config.exclude.length > 0)) {
347
346
  const inc = config.include.map(globToRegExp);
@@ -356,17 +355,23 @@ export function performCodeReview(projectContext, specificFiles) {
356
355
  });
357
356
  }
358
357
  const allIssues = [];
359
- // Determine scope — mirrors the branching in getFilesToReview so the user
360
- // sees exactly which branch ran.
358
+ // Determine scope — reports the branch getFilesToReview actually took,
359
+ // rather than re-deriving it, so the two can no longer disagree.
360
+ const count = `${filesToReview.length} file${filesToReview.length === 1 ? '' : 's'}`;
361
361
  let scope;
362
- if (specificFiles && specificFiles.length > 0) {
363
- scope = `specific file${specificFiles.length === 1 ? '' : 's'} (${filesToReview.length})`;
362
+ if (selection.source === 'specific') {
363
+ scope = `specific file${specificFiles?.length === 1 ? '' : 's'} (${filesToReview.length})`;
364
+ }
365
+ else if (selection.source === 'git') {
366
+ scope = `unstaged git changes (${count})`;
364
367
  }
365
- else if (getChangedFiles(projectRoot).length > 0) {
366
- scope = `unstaged git changes (${filesToReview.length} file${filesToReview.length === 1 ? '' : 's'})`;
368
+ else if (selection.gitError) {
369
+ // Not "no git changes": git would not run here, so nobody knows whether
370
+ // there are any. Say which, and say why — the message carries the fix.
371
+ scope = `full src/ scan — git could not list the changes: ${selection.gitError} (${count})`;
367
372
  }
368
373
  else {
369
- scope = `full src/ scan — no git changes (${filesToReview.length} file${filesToReview.length === 1 ? '' : 's'})`;
374
+ scope = `full src/ scan — no git changes (${count})`;
370
375
  }
371
376
  for (const filePath of filesToReview) {
372
377
  try {