codeep 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/commands.d.ts +15 -0
- package/dist/acp/commands.js +39 -5
- package/dist/acp/server.d.ts +13 -0
- package/dist/acp/server.js +283 -27
- package/dist/acp/serverHandlers.js +10 -10
- package/dist/acp/session.d.ts +13 -2
- package/dist/acp/transport.d.ts +6 -0
- package/dist/acp/transport.js +98 -3
- package/dist/api/index.js +6 -3
- package/dist/config/index.js +12 -4
- package/dist/config/providers.d.ts +48 -4
- package/dist/config/providers.js +325 -88
- package/dist/renderer/agentExecution.js +116 -69
- package/dist/renderer/commands.js +36 -11
- package/dist/renderer/main.d.ts +24 -0
- package/dist/renderer/main.js +57 -2
- package/dist/utils/agent.d.ts +33 -2
- package/dist/utils/agent.js +86 -8
- package/dist/utils/agentChat.js +22 -10
- package/dist/utils/checkpoints.js +3 -0
- package/dist/utils/codeReview.js +28 -23
- 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/headlessReview.js +26 -5
- package/dist/utils/personalities.js +8 -2
- package/dist/utils/shell.d.ts +108 -0
- package/dist/utils/shell.js +364 -5
- package/dist/utils/taskPlanner.js +12 -4
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/tokenTracker.d.ts +13 -5
- package/dist/utils/tokenTracker.js +163 -34
- package/dist/utils/toolExecution.d.ts +41 -0
- package/dist/utils/toolExecution.js +357 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* executeTool() dispatches to individual tool handlers.
|
|
6
6
|
* listDirectory() and htmlToText() are private helpers.
|
|
7
7
|
* createActionLog() converts a ToolCall+ToolResult into a history ActionLog.
|
|
8
|
+
* trustBearingWrite() names the writes that decide what runs later.
|
|
8
9
|
*/
|
|
9
10
|
import { existsSync, readdirSync, statSync, lstatSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync, realpathSync } from 'fs';
|
|
10
|
-
import { join, dirname, relative, resolve, isAbsolute, sep } from 'path';
|
|
11
|
+
import { join, dirname, basename, relative, resolve, isAbsolute, sep } from 'path';
|
|
11
12
|
import { executeCommandAsync } from './shell.js';
|
|
12
13
|
import { recordWrite, recordEdit, recordDelete, recordMkdir, recordCommand, discardAction, recordResult } from './history.js';
|
|
13
14
|
import { loadIgnoreRules, isIgnored } from './gitignore.js';
|
|
@@ -17,6 +18,7 @@ import { logger } from './logger.js';
|
|
|
17
18
|
import { runHook } from './hooks.js';
|
|
18
19
|
import { checkCommandRateLimit } from './ratelimit.js';
|
|
19
20
|
import { isMcpToolName, callSessionTool, isVirtualMcpToolName, callSessionVirtualTool } from './mcpRegistry.js';
|
|
21
|
+
import { resolveHooksDirResult } from './gitHookInstaller.js';
|
|
20
22
|
// SSRF guard (isBlockedIp / assertFetchUrlAllowed) moved to ./ssrfGuard —
|
|
21
23
|
// shared with shell.ts for curl/wget URL checks. Re-exported here so the
|
|
22
24
|
// existing tests that import it from toolExecution keep working.
|
|
@@ -79,6 +81,354 @@ export function validatePath(path, projectRoot) {
|
|
|
79
81
|
}
|
|
80
82
|
return { valid: true, absolutePath };
|
|
81
83
|
}
|
|
84
|
+
// ── Files that decide what runs later ────────────────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// Writing one of these is not an edit, it is code execution on a delay:
|
|
87
|
+
//
|
|
88
|
+
// - `.git/` — git honours `core.fsmonitor`, `core.pager`, `diff.external`,
|
|
89
|
+
// `core.hooksPath`, `core.sshCommand` and `credential.helper` by RUNNING
|
|
90
|
+
// the command they name, so the next `git status` Codeep makes for the
|
|
91
|
+
// status line executes whatever `.git/config` says. Scripts in
|
|
92
|
+
// `.git/hooks/` run on the next commit. A worktree's `.git` is a file
|
|
93
|
+
// pointing at the real directory, so the whole name is off limits and not
|
|
94
|
+
// only what sits beneath it.
|
|
95
|
+
// - the repository's hook directory — `.git/hooks/` by default, but
|
|
96
|
+
// `core.hooksPath` moves it, and `.githooks/` and husky's `.husky/` are
|
|
97
|
+
// exactly that convention. A hook there runs on the user's own next
|
|
98
|
+
// `git commit` in their own terminal, long after the agent stopped.
|
|
99
|
+
// - `.codeep/hooks/` — scripts Codeep itself runs around every tool call.
|
|
100
|
+
// - `.codeep/skills/` — a skill's steps are commands Codeep runs when the
|
|
101
|
+
// skill is used (see skillBundles.ts, which loads project skills).
|
|
102
|
+
// - `.codeep/agents/` — a sub-agent definition. Its `tools:` is the
|
|
103
|
+
// allowlist the nested run is checked against and REPLACES the parent's,
|
|
104
|
+
// so it can hand a delegated run a tool this run was restricted from; its
|
|
105
|
+
// `model:` decides which provider the code in that run's context is sent
|
|
106
|
+
// to. Both take effect the next time anything delegates to that name.
|
|
107
|
+
// - `.codeep/mcp_servers.json`, `.mcp.json` — every entry is a command
|
|
108
|
+
// Codeep spawns.
|
|
109
|
+
// - `.codeep/config.json` — Codeep's own settings for this project.
|
|
110
|
+
//
|
|
111
|
+
// `.codeep/commands/` is deliberately NOT here, and must stay out: a custom
|
|
112
|
+
// command is prompt text, expanded into a message to the model, and the model's
|
|
113
|
+
// tool calls then go through every gate in this file. Gating it would put a
|
|
114
|
+
// confirmation in front of writing a prompt. That is also the line the agents
|
|
115
|
+
// directory falls on the other side of: an agent file is prompt text PLUS a
|
|
116
|
+
// tool allowlist and a model, which is the part a prompt cannot change.
|
|
117
|
+
//
|
|
118
|
+
// WHAT THIS DOES NOT COVER, so nobody reads the list above as a boundary:
|
|
119
|
+
// only the tools that take a `path` are gated. `execute_command` reaches the
|
|
120
|
+
// same files through any program that writes one — reviewers proved it with
|
|
121
|
+
// git 2.54 by having the agent write an ordinary `setup.cjs` (not a name on
|
|
122
|
+
// this list, so not gated) and then run `node setup.cjs`, which wrote
|
|
123
|
+
// `.git/config`; `cp`, `tee` and `mv` do it in one step and need no file
|
|
124
|
+
// written first. There is no fix for that here, because a gate that asked
|
|
125
|
+
// about every command able to write a file would be asking about every
|
|
126
|
+
// command.
|
|
127
|
+
//
|
|
128
|
+
// Nor is the classification a property of the file: it is decided at the
|
|
129
|
+
// moment of the write, from where the repository keeps its hooks AT THAT
|
|
130
|
+
// MOMENT. `write_file ci/deploy.sh` in a repository with the default hook
|
|
131
|
+
// directory is an ordinary file and goes through unasked; a later `git config
|
|
132
|
+
// core.hooksPath ci` makes that same file a live `pre-commit` without any
|
|
133
|
+
// further write for this gate to see. The cache below is dropped before every
|
|
134
|
+
// command line so the NEXT write is judged against the new hook directory,
|
|
135
|
+
// but nothing re-judges the writes that already happened — reclassifying them
|
|
136
|
+
// would mean keeping every path a run has written and re-running the gate
|
|
137
|
+
// after each command, and there is still nothing to do about a file already
|
|
138
|
+
// on disk. What remains covered is the write that installs the hook itself:
|
|
139
|
+
// `core.hooksPath` lives in `.git/config`, which this gate confirms.
|
|
140
|
+
//
|
|
141
|
+
// What it IS worth is the case it was built for: a model that writes a hook
|
|
142
|
+
// or a `.git/config` as part of an ordinary-looking edit, with no shell
|
|
143
|
+
// involved at all, which is what a prompt injection reaches for because
|
|
144
|
+
// execute_command is the tool users already watch. That write now stops for a
|
|
145
|
+
// confirmation in every mode. Someone who has approved a shell command has
|
|
146
|
+
// approved a shell command.
|
|
147
|
+
//
|
|
148
|
+
// The reasons are written for the person answering the confirmation prompt:
|
|
149
|
+
// "a config file" tells them nothing, "this decides what git runs" does.
|
|
150
|
+
const GIT_REASON = 'This file controls what commands git runs — core.fsmonitor, core.pager and diff.external are commands git executes for you.';
|
|
151
|
+
const GIT_HOOK_REASON = 'This is a git hook — git runs it on your next commit or push, in your own terminal.';
|
|
152
|
+
/** The same gate, for the repository that pointed `core.hooksPath` at its own
|
|
153
|
+
* root. Every top-level file matches there, so the reason may not say "this
|
|
154
|
+
* is a git hook": it would tell someone editing `package.json` that their
|
|
155
|
+
* package manifest is a hook. What is true of all of them is the directory
|
|
156
|
+
* they sit in, so that is what this says. */
|
|
157
|
+
const HOOKS_AT_ROOT_REASON = "This repository has named its own top level as its git hook directory (core.hooksPath), so git runs files " +
|
|
158
|
+
'from here by name — writing one can install a hook that runs on your next commit.';
|
|
159
|
+
const CODEEP_HOOK_REASON = 'This file runs on every tool call.';
|
|
160
|
+
const SKILL_REASON = 'This is a skill — its steps are commands Codeep runs whenever the skill is used.';
|
|
161
|
+
const MCP_SERVERS_REASON = 'This file starts MCP servers — every entry is a command Codeep spawns.';
|
|
162
|
+
const AGENT_REASON = 'This file defines a sub-agent — the tools it may use and the model it runs on, every time something delegates to it.';
|
|
163
|
+
const CODEEP_CONFIG_REASON = "This file is Codeep's own configuration for this project.";
|
|
164
|
+
/** The one reason that is not a constant: it carries git's own refusal with
|
|
165
|
+
* it, because over ACP this prompt is the only place the user is ever told
|
|
166
|
+
* which key made Codeep refuse and how to clear it. */
|
|
167
|
+
const unknownHooksReason = (why) => 'Codeep could not ask git where this repository keeps its hooks, so it cannot tell whether this write installs ' +
|
|
168
|
+
`one that runs on your next commit. ${why}`;
|
|
169
|
+
/** Directory names that are a git hook directory by convention, so a repo
|
|
170
|
+
* using one is covered before git is asked anything. `.githooks` is the bare
|
|
171
|
+
* `core.hooksPath` convention and `.husky` is husky's. */
|
|
172
|
+
const HOOK_DIRECTORY_NAMES = new Set(['.githooks', '.husky']);
|
|
173
|
+
/** Tools whose `path` parameter names a file they create, change or remove. */
|
|
174
|
+
const PATH_WRITING_TOOLS = new Set(['write_file', 'edit_file', 'delete_file', 'create_directory']);
|
|
175
|
+
/**
|
|
176
|
+
* A path's segments as the filesystem will match them: lowercased, because
|
|
177
|
+
* macOS and Windows both hand `.GIT/config` to the same file git reads, and
|
|
178
|
+
* without the trailing dots and spaces Windows silently drops — `.git./config`
|
|
179
|
+
* and `.git /config` are two literal directories on POSIX but land in the real
|
|
180
|
+
* `.git` on Windows, which is the whole point of writing them that way. A
|
|
181
|
+
* segment that is nothing but dots or spaces keeps its own spelling, so `..`
|
|
182
|
+
* stays `..` instead of collapsing to nothing.
|
|
183
|
+
*/
|
|
184
|
+
function pathSegments(path) {
|
|
185
|
+
return path
|
|
186
|
+
.split(/[\\/]+/)
|
|
187
|
+
.filter(s => s && s !== '.')
|
|
188
|
+
.map(s => (s.replace(/[. ]+$/, '') || s).toLowerCase());
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The answer per project, for one run.
|
|
192
|
+
*
|
|
193
|
+
* The comment that used to sit here refused a cache, and it was right about
|
|
194
|
+
* the risk and wrong about the cost: `core.hooksPath` really can change
|
|
195
|
+
* mid-run without any write this gate sees — `execute_command` running
|
|
196
|
+
* `git config core.hooksPath .evil` needs no path-writing tool — but paying
|
|
197
|
+
* for that with a fresh resolution on EVERY path-writing call meant two git
|
|
198
|
+
* subprocesses per tool call. Measured over 100 ordinary writes in a real
|
|
199
|
+
* repository: 2484ms without the cache, 4.7ms with it (24.8ms → 0.05ms per
|
|
200
|
+
* call), all of it on the event loop and almost all of it on the common path
|
|
201
|
+
* where no name matches anything.
|
|
202
|
+
*
|
|
203
|
+
* So the answer is cached and thrown away the moment a command runs, which is
|
|
204
|
+
* the only in-run way the answer can change. Every caller that spawns a
|
|
205
|
+
* command line calls forgetHooksDirectory() first: the execute_command tool
|
|
206
|
+
* below, the ACP path that delegates that tool to the client's terminal (see
|
|
207
|
+
* agent.ts), and the two skill runners. `git config core.hooksPath .evil`
|
|
208
|
+
* followed by `write_file .evil/pre-commit` therefore still finds a cold
|
|
209
|
+
* cache, which is the case the old comment was protecting.
|
|
210
|
+
*
|
|
211
|
+
* Keyed by project root because one process serves several workspaces over
|
|
212
|
+
* ACP, and cleared whole rather than per root because a command line can `cd`
|
|
213
|
+
* into any of them.
|
|
214
|
+
*/
|
|
215
|
+
const hooksDirectoryCache = new Map();
|
|
216
|
+
/** Drop the cached hook directories. Call before spawning a command line. */
|
|
217
|
+
export function forgetHooksDirectory() {
|
|
218
|
+
hooksDirectoryCache.clear();
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Where this repository's hooks live, as segments relative to the project
|
|
222
|
+
* root — see HooksDirectory.
|
|
223
|
+
*
|
|
224
|
+
* Asking git is the only way to learn where a repo actually keeps them, and
|
|
225
|
+
* gitHookInstaller already does it with a hardened environment: every
|
|
226
|
+
* command-executing key is neutralised, `core.hooksPath` alone is left under
|
|
227
|
+
* the repo's control, and `git rev-parse` only prints a path. So the
|
|
228
|
+
* resolution cannot run anything the repository chose.
|
|
229
|
+
*
|
|
230
|
+
* resolveHooksDirResult() is the variant that does not throw, and the reason
|
|
231
|
+
* it exists: `none` and `unknown` used to be the same null, and reading a
|
|
232
|
+
* refusal as "no hook directory" switched this gate off in exactly the
|
|
233
|
+
* repositories that earned the refusal. It is still wrapped in a try/catch,
|
|
234
|
+
* and ANY throw counts as unknown — the fail-closed answer must not depend on
|
|
235
|
+
* a promise made by another module.
|
|
236
|
+
*/
|
|
237
|
+
function hooksDirectorySegments(projectRoot, realRoot) {
|
|
238
|
+
const cached = hooksDirectoryCache.get(projectRoot);
|
|
239
|
+
if (cached !== undefined)
|
|
240
|
+
return cached;
|
|
241
|
+
const answer = resolveHooksDirectory(projectRoot, realRoot);
|
|
242
|
+
hooksDirectoryCache.set(projectRoot, answer);
|
|
243
|
+
return answer;
|
|
244
|
+
}
|
|
245
|
+
function resolveHooksDirectory(projectRoot, realRoot) {
|
|
246
|
+
let result;
|
|
247
|
+
try {
|
|
248
|
+
result = resolveHooksDirResult(projectRoot);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
return { kind: 'unknown', reason: error instanceof Error ? error.message : String(error) };
|
|
252
|
+
}
|
|
253
|
+
if (result.kind !== 'hooks')
|
|
254
|
+
return result;
|
|
255
|
+
// Both spellings of the root, as below: git may print the hooks path
|
|
256
|
+
// canonicalised (an absolute `core.hooksPath`, a symlinked checkout), and on
|
|
257
|
+
// macOS that is `/private/var/…` where the root arrived as `/var/…`.
|
|
258
|
+
const rel = insideRoot(projectRoot, result.dir) ?? insideRoot(realRoot, result.dir);
|
|
259
|
+
// `??` and not `||`: '' is the hook directory BEING the project root, which
|
|
260
|
+
// is a repository this has to cover, and pathSegments('') is the empty
|
|
261
|
+
// prefix that says so.
|
|
262
|
+
return rel === null ? { kind: 'none' } : { kind: 'segments', segments: pathSegments(rel) };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* What a project-relative path controls, or null when it controls nothing.
|
|
266
|
+
*
|
|
267
|
+
* Matched on path segments rather than on a prefix, so a nested checkout's
|
|
268
|
+
* `vendor/lib/.git/config` is covered the same as the top-level one.
|
|
269
|
+
*
|
|
270
|
+
* `hooksDir` is the repository's own hook directory. It costs a `git
|
|
271
|
+
* rev-parse` the first time it is asked in a run, so it is asked for only once
|
|
272
|
+
* a name has failed to answer.
|
|
273
|
+
*/
|
|
274
|
+
function reasonForSegments(relativePath, hooksDir) {
|
|
275
|
+
const segments = pathSegments(relativePath);
|
|
276
|
+
if (segments.includes('.git'))
|
|
277
|
+
return GIT_REASON;
|
|
278
|
+
if (segments.some(s => HOOK_DIRECTORY_NAMES.has(s)))
|
|
279
|
+
return GIT_HOOK_REASON;
|
|
280
|
+
const last = segments[segments.length - 1];
|
|
281
|
+
if (last === '.mcp.json')
|
|
282
|
+
return MCP_SERVERS_REASON;
|
|
283
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
284
|
+
if (segments[i] !== '.codeep')
|
|
285
|
+
continue;
|
|
286
|
+
if (segments[i + 1] === 'hooks')
|
|
287
|
+
return CODEEP_HOOK_REASON;
|
|
288
|
+
if (segments[i + 1] === 'skills')
|
|
289
|
+
return SKILL_REASON;
|
|
290
|
+
if (segments[i + 1] === 'agents')
|
|
291
|
+
return AGENT_REASON;
|
|
292
|
+
if (i + 2 !== segments.length)
|
|
293
|
+
continue; // `.codeep/<file>` only, not deeper
|
|
294
|
+
if (segments[i + 1] === 'mcp_servers.json')
|
|
295
|
+
return MCP_SERVERS_REASON;
|
|
296
|
+
if (segments[i + 1] === 'config.json')
|
|
297
|
+
return CODEEP_CONFIG_REASON;
|
|
298
|
+
}
|
|
299
|
+
const hooks = hooksDir();
|
|
300
|
+
// Git could not be asked, so "this is not a hook" is not something anyone
|
|
301
|
+
// knows. A repository gets that answer when its own config names a program
|
|
302
|
+
// git would run, which is the last repository to hand a write through
|
|
303
|
+
// unasked — so every write in it is confirmed until the config is fixed.
|
|
304
|
+
// Noisy, and that is the trade: the alternative is a `core.hooksPath` this
|
|
305
|
+
// cannot see, pointed anywhere, written into silently.
|
|
306
|
+
if (hooks.kind === 'unknown')
|
|
307
|
+
return unknownHooksReason(hooks.reason);
|
|
308
|
+
if (hooks.kind === 'none')
|
|
309
|
+
return null;
|
|
310
|
+
// `every` over the empty prefix is trivially true, which is right: a repo
|
|
311
|
+
// whose `core.hooksPath` IS its root runs `<root>/pre-commit` on the next
|
|
312
|
+
// commit (verified with git 2.54), and that file was going unasked. What
|
|
313
|
+
// would NOT be right is reading that empty prefix as "every path in the
|
|
314
|
+
// project is a hook" — it would put a confirmation in front of every write
|
|
315
|
+
// in the repository. git looks for hooks DIRECTLY in its hook directory, so
|
|
316
|
+
// at the root that is the top level and nothing under it. A hook directory
|
|
317
|
+
// of its own keeps the whole subtree: a hook sources its helpers from
|
|
318
|
+
// beside it, the way husky's `pre-commit` sources `_/husky.sh`.
|
|
319
|
+
const prefix = hooks.segments;
|
|
320
|
+
if (prefix.every((s, i) => segments[i] === s) && (prefix.length > 0 || segments.length === 1)) {
|
|
321
|
+
// Which reason depends on which of those two shapes matched: a hook
|
|
322
|
+
// directory of its own means this file IS one, the root means only that
|
|
323
|
+
// this is the directory git looks in — and that one reports `package.json`
|
|
324
|
+
// along with everything else at the top level.
|
|
325
|
+
return prefix.length > 0 ? GIT_HOOK_REASON : HOOKS_AT_ROOT_REASON;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
/** The path with a symlinked ancestor resolved, or null if it cannot be. */
|
|
330
|
+
function realPathThroughLinks(absolutePath) {
|
|
331
|
+
let existing = absolutePath;
|
|
332
|
+
const rest = [];
|
|
333
|
+
for (;;) {
|
|
334
|
+
try {
|
|
335
|
+
lstatSync(existing); // lstat, so a symlink counts as existing
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
const parent = dirname(existing);
|
|
340
|
+
if (parent === existing)
|
|
341
|
+
return null;
|
|
342
|
+
rest.unshift(basename(existing));
|
|
343
|
+
existing = parent;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
return join(realpathSync(existing), ...rest);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The path relative to `root`, or null when it is not inside it.
|
|
355
|
+
*
|
|
356
|
+
* The empty string is a RESULT, not a miss: it means the path is the root
|
|
357
|
+
* itself. Folding it into null cost the hook gate a real repository — one
|
|
358
|
+
* with `core.hooksPath` set to its own root, where git runs `<root>/pre-commit`
|
|
359
|
+
* on the next commit and the relative path of the hook directory is ''.
|
|
360
|
+
*/
|
|
361
|
+
function insideRoot(root, absolutePath) {
|
|
362
|
+
const rel = relative(root, absolutePath);
|
|
363
|
+
return !rel.startsWith('..') && !isAbsolute(rel) ? rel : null;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The tail of the refusal one of these writes gets when the run has nobody to
|
|
367
|
+
* ask — no permission callback at all, which is how `codeep review --fix`
|
|
368
|
+
* runs in CI. agent.ts builds the refusal; headlessReview.ts recognises it by
|
|
369
|
+
* this text so it can say so in the run output rather than leave it buried in
|
|
370
|
+
* the agent's tool log.
|
|
371
|
+
*
|
|
372
|
+
* One constant, in the module that owns the classification rather than in
|
|
373
|
+
* agent.ts, for two reasons: a reworded refusal that stopped matching would
|
|
374
|
+
* put CI back to failing silently, and agent.js is a module the fix-run tests
|
|
375
|
+
* replace wholesale — a constant read from there would have been undefined in
|
|
376
|
+
* exactly the test that guards this.
|
|
377
|
+
*/
|
|
378
|
+
export const NO_CONFIRMER_REFUSAL = 'Nobody could be asked to confirm it, so nothing was written.';
|
|
379
|
+
/**
|
|
380
|
+
* What a tool call would write that decides what runs later, or null.
|
|
381
|
+
*
|
|
382
|
+
* Callers use this for two things: to force a confirmation the mode would
|
|
383
|
+
* otherwise skip (see agent.ts), and to tell the person answering it what
|
|
384
|
+
* they are approving.
|
|
385
|
+
*
|
|
386
|
+
* A symlink inside the project can point at one of these names — `ln -s .git
|
|
387
|
+
* tools/cfg` makes a write to `tools/cfg/config` land in the real `.git`, and
|
|
388
|
+
* validatePath allows it because it never leaves the project — so the
|
|
389
|
+
* resolved path is classified alongside the one the model asked for.
|
|
390
|
+
*/
|
|
391
|
+
export function trustBearingWrite(toolCall, projectRoot) {
|
|
392
|
+
if (!PATH_WRITING_TOOLS.has(normalizeToolName(toolCall.tool)))
|
|
393
|
+
return null;
|
|
394
|
+
const path = toolCall.parameters?.path;
|
|
395
|
+
if (typeof path !== 'string' || !path)
|
|
396
|
+
return null;
|
|
397
|
+
const absolute = isAbsolute(path) ? resolve(path) : resolve(projectRoot, path);
|
|
398
|
+
let realRoot = projectRoot;
|
|
399
|
+
try {
|
|
400
|
+
realRoot = realpathSync(projectRoot);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// Unreadable root: the given path is all there is to go on.
|
|
404
|
+
}
|
|
405
|
+
const candidates = [absolute];
|
|
406
|
+
const resolved = realPathThroughLinks(absolute);
|
|
407
|
+
if (resolved && resolved !== absolute)
|
|
408
|
+
candidates.push(resolved);
|
|
409
|
+
// Looked up at most once per call, and only if a candidate gets far enough
|
|
410
|
+
// to need it. The cache behind it spans the run; this spans the call, so
|
|
411
|
+
// the two candidates below share one lookup.
|
|
412
|
+
let hooksDir;
|
|
413
|
+
const hooksDirOnce = () => {
|
|
414
|
+
if (hooksDir === undefined)
|
|
415
|
+
hooksDir = hooksDirectorySegments(projectRoot, realRoot);
|
|
416
|
+
return hooksDir;
|
|
417
|
+
};
|
|
418
|
+
for (const candidate of candidates) {
|
|
419
|
+
// Only the part below the project root is classified. The root's own
|
|
420
|
+
// path is not the agent's doing, and on macOS it usually arrives
|
|
421
|
+
// unresolved (/var/folders/… for /private/var/folders/…), so both
|
|
422
|
+
// spellings get a chance to match.
|
|
423
|
+
const rel = insideRoot(projectRoot, candidate) ?? insideRoot(realRoot, candidate);
|
|
424
|
+
const reason = rel && reasonForSegments(rel, hooksDirOnce);
|
|
425
|
+
// The resolved candidate is the stable name for this file, so it keys the
|
|
426
|
+
// answer when there is one.
|
|
427
|
+
if (reason)
|
|
428
|
+
return { path, file: resolved ?? candidate, reason };
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
82
432
|
/**
|
|
83
433
|
* List directory contents, respecting .gitignore rules.
|
|
84
434
|
* Tracks visited inodes to prevent infinite loops caused by circular symlinks.
|
|
@@ -530,6 +880,12 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall, signal)
|
|
|
530
880
|
const args = parameters.args || [];
|
|
531
881
|
if (!command)
|
|
532
882
|
return { success: false, output: '', error: 'Missing required parameter: command', tool, parameters };
|
|
883
|
+
// A command is the one thing in a run that can move this repository's
|
|
884
|
+
// hooks — `git config core.hooksPath .evil` — without any write the
|
|
885
|
+
// gate above sees. Drop the cached answer before it runs, so the next
|
|
886
|
+
// `write_file .evil/pre-commit` asks git again rather than trusting
|
|
887
|
+
// where the hooks were a moment ago.
|
|
888
|
+
forgetHooksDirectory();
|
|
533
889
|
// Command throttle — guards against agent loops that spawn commands
|
|
534
890
|
// every iteration (each can be up to 2 minutes of subprocess time).
|
|
535
891
|
// Rate-limited *after* permission resolution: an allowed command
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "3.
|
|
1
|
+
export declare const VERSION = "3.5.0";
|
package/dist/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
|
|
2
2
|
// Baked from package.json at build time so the bun-compiled binary reports
|
|
3
3
|
// the right version (it has no package.json on disk to read at runtime).
|
|
4
|
-
export const VERSION = '3.
|
|
4
|
+
export const VERSION = '3.5.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|