codeep 3.3.3 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -5,11 +5,12 @@
|
|
|
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
|
-
import { recordWrite, recordEdit, recordDelete, recordMkdir, recordCommand } from './history.js';
|
|
13
|
+
import { recordWrite, recordEdit, recordDelete, recordMkdir, recordCommand, discardAction, recordResult } from './history.js';
|
|
13
14
|
import { loadIgnoreRules, isIgnored } from './gitignore.js';
|
|
14
15
|
import { normalizeToolName } from './toolParsing.js';
|
|
15
16
|
import { getZaiMcpConfig, getZaiVisionConfig, getMinimaxMcpConfig, callZaiMcp, callZaiVisionApi, callMinimaxApi } from './mcpIntegration.js';
|
|
@@ -17,11 +18,13 @@ 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.
|
|
23
25
|
export { isBlockedIp, assertFetchUrlAllowed } from './ssrfGuard.js';
|
|
24
26
|
import { fetchUrlGuarded } from './guardedFetch.js';
|
|
27
|
+
import { AcpRequestError } from '../acp/transport.js';
|
|
25
28
|
const debug = (...args) => {
|
|
26
29
|
if (process.env.CODEEP_DEBUG === '1') {
|
|
27
30
|
logger.debug(args.map(String).join(' '));
|
|
@@ -78,6 +81,354 @@ export function validatePath(path, projectRoot) {
|
|
|
78
81
|
}
|
|
79
82
|
return { valid: true, absolutePath };
|
|
80
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
|
+
}
|
|
81
432
|
/**
|
|
82
433
|
* List directory contents, respecting .gitignore rules.
|
|
83
434
|
* Tracks visited inodes to prevent infinite loops caused by circular symlinks.
|
|
@@ -180,9 +531,12 @@ function htmlToText(html) {
|
|
|
180
531
|
* `fs` is optional — if provided and the relevant method is defined, file
|
|
181
532
|
* read/write is delegated to the client. Otherwise we fall back to direct
|
|
182
533
|
* disk I/O. A delegated call that throws also falls back to disk so a
|
|
183
|
-
* single client hiccup doesn't kill the agent loop
|
|
534
|
+
* single client hiccup doesn't kill the agent loop — except a write the
|
|
535
|
+
* client explicitly refused, which fails the tool (see isRefusedWrite).
|
|
536
|
+
*
|
|
537
|
+
* `signal` stops a running execute_command when it fires.
|
|
184
538
|
*/
|
|
185
|
-
export async function executeTool(toolCall, projectRoot, fs, mcpSessionId) {
|
|
539
|
+
export async function executeTool(toolCall, projectRoot, fs, mcpSessionId, signal) {
|
|
186
540
|
const tool = normalizeToolName(toolCall.tool);
|
|
187
541
|
const parameters = toolCall.parameters;
|
|
188
542
|
debug(`Executing tool: ${tool}`, parameters.path || parameters.command || '');
|
|
@@ -258,7 +612,7 @@ export async function executeTool(toolCall, projectRoot, fs, mcpSessionId) {
|
|
|
258
612
|
}
|
|
259
613
|
// Wrap the original dispatch so we can run on_error / post_edit hooks
|
|
260
614
|
// around it without indenting every case.
|
|
261
|
-
const result = await dispatchTool(tool, parameters, projectRoot, fs, toolCall);
|
|
615
|
+
const result = await dispatchTool(tool, parameters, projectRoot, fs, toolCall, signal);
|
|
262
616
|
if (!result.success) {
|
|
263
617
|
runHook({
|
|
264
618
|
event: 'on_error',
|
|
@@ -283,7 +637,20 @@ export async function executeTool(toolCall, projectRoot, fs, mcpSessionId) {
|
|
|
283
637
|
}
|
|
284
638
|
return result;
|
|
285
639
|
}
|
|
286
|
-
|
|
640
|
+
/**
|
|
641
|
+
* True when the client answered fs/write_text_file with an error, i.e. it
|
|
642
|
+
* received the write and said no (read-only buffer, rejected by the user…).
|
|
643
|
+
* Writing the file to disk anyway would leave the editor and the disk
|
|
644
|
+
* disagreeing, and the editor's next save would silently undo the change.
|
|
645
|
+
* "Method not found" is the exception: the client does not implement the
|
|
646
|
+
* method after all, which is the same as having no delegation. A timeout
|
|
647
|
+
* or a broken transport is not an answer either, so both keep the disk
|
|
648
|
+
* fallback.
|
|
649
|
+
*/
|
|
650
|
+
function isRefusedWrite(err) {
|
|
651
|
+
return err instanceof AcpRequestError && err.code !== -32601;
|
|
652
|
+
}
|
|
653
|
+
async function dispatchTool(tool, parameters, projectRoot, fs, toolCall, signal) {
|
|
287
654
|
try {
|
|
288
655
|
switch (tool) {
|
|
289
656
|
case 'read_file': {
|
|
@@ -342,26 +709,41 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
342
709
|
// and lint-on-save reactions consistent. The client is responsible
|
|
343
710
|
// for creating parent directories — VS Code's WorkspaceEdit does;
|
|
344
711
|
// for the disk fallback below we do it ourselves.
|
|
712
|
+
// The undo record is taken before the write, so it holds what the
|
|
713
|
+
// file was, and once only: a delegation that falls through to disk
|
|
714
|
+
// keeps it. A write that never happens must not leave it behind.
|
|
715
|
+
let rec = null;
|
|
345
716
|
if (fs?.writeTextFile) {
|
|
346
717
|
try {
|
|
347
718
|
const existed = existsSync(validation.absolutePath);
|
|
719
|
+
rec = recordWrite(validation.absolutePath);
|
|
348
720
|
await fs.writeTextFile(validation.absolutePath, content);
|
|
349
|
-
|
|
350
|
-
// failed delegation that falls through to disk would double-log.
|
|
351
|
-
recordWrite(validation.absolutePath);
|
|
721
|
+
recordResult(rec, content);
|
|
352
722
|
return { success: true, output: `${existed ? 'Updated' : 'Created'} file: ${path}`, tool, parameters };
|
|
353
723
|
}
|
|
354
724
|
catch (err) {
|
|
725
|
+
if (isRefusedWrite(err)) {
|
|
726
|
+
discardAction(rec);
|
|
727
|
+
return { success: false, output: '', error: `The editor refused to write ${path}: ${err.message}`, tool, parameters };
|
|
728
|
+
}
|
|
355
729
|
debug('fs/write_text_file delegation failed, falling back to disk:', err);
|
|
356
730
|
// fall through to disk write
|
|
357
731
|
}
|
|
358
732
|
}
|
|
359
|
-
const dir = dirname(validation.absolutePath);
|
|
360
|
-
if (!existsSync(dir))
|
|
361
|
-
mkdirSync(dir, { recursive: true });
|
|
362
733
|
const existed = existsSync(validation.absolutePath);
|
|
363
|
-
|
|
364
|
-
|
|
734
|
+
if (!rec)
|
|
735
|
+
rec = recordWrite(validation.absolutePath);
|
|
736
|
+
try {
|
|
737
|
+
const dir = dirname(validation.absolutePath);
|
|
738
|
+
if (!existsSync(dir))
|
|
739
|
+
mkdirSync(dir, { recursive: true });
|
|
740
|
+
writeFileSync(validation.absolutePath, content, 'utf-8');
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
discardAction(rec);
|
|
744
|
+
throw err;
|
|
745
|
+
}
|
|
746
|
+
recordResult(rec, content);
|
|
365
747
|
return { success: true, output: `${existed ? 'Updated' : 'Created'} file: ${path}`, tool, parameters };
|
|
366
748
|
}
|
|
367
749
|
case 'edit_file': {
|
|
@@ -407,7 +789,8 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
407
789
|
if (matchCount > 1) {
|
|
408
790
|
return { success: false, output: '', error: `old_text matches ${matchCount} locations in the file. Provide more surrounding context to make it unique (only 1 match allowed).`, tool, parameters };
|
|
409
791
|
}
|
|
410
|
-
|
|
792
|
+
// Recorded before the write; dropped again if the write never happens.
|
|
793
|
+
const rec = recordEdit(validation.absolutePath);
|
|
411
794
|
// Function replacer so newText is written literally — a plain-string
|
|
412
795
|
// replacement interprets $&, $1, $$ etc., which silently corrupts any
|
|
413
796
|
// edit whose new_text contains `$` (shell vars, template literals, regex).
|
|
@@ -415,9 +798,14 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
415
798
|
if (fs?.writeTextFile) {
|
|
416
799
|
try {
|
|
417
800
|
await fs.writeTextFile(validation.absolutePath, updated);
|
|
801
|
+
recordResult(rec, updated);
|
|
418
802
|
return { success: true, output: `Edited file: ${path}`, tool, parameters };
|
|
419
803
|
}
|
|
420
804
|
catch (err) {
|
|
805
|
+
if (isRefusedWrite(err)) {
|
|
806
|
+
discardAction(rec);
|
|
807
|
+
return { success: false, output: '', error: `The editor refused to write ${path}: ${err.message}`, tool, parameters };
|
|
808
|
+
}
|
|
421
809
|
debug('fs/write_text_file (in edit_file) failed, falling back to disk:', err);
|
|
422
810
|
// If we read through the client but write back to disk, the
|
|
423
811
|
// editor's dirty buffer could be discarded next save. Log and
|
|
@@ -426,7 +814,14 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
426
814
|
debug('warning: edit_file read via client but writing to disk');
|
|
427
815
|
}
|
|
428
816
|
}
|
|
429
|
-
|
|
817
|
+
try {
|
|
818
|
+
writeFileSync(validation.absolutePath, updated, 'utf-8');
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
discardAction(rec);
|
|
822
|
+
throw err;
|
|
823
|
+
}
|
|
824
|
+
recordResult(rec, updated);
|
|
430
825
|
return { success: true, output: `Edited file: ${path}`, tool, parameters };
|
|
431
826
|
}
|
|
432
827
|
case 'delete_file': {
|
|
@@ -485,6 +880,12 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
485
880
|
const args = parameters.args || [];
|
|
486
881
|
if (!command)
|
|
487
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();
|
|
488
889
|
// Command throttle — guards against agent loops that spawn commands
|
|
489
890
|
// every iteration (each can be up to 2 minutes of subprocess time).
|
|
490
891
|
// Rate-limited *after* permission resolution: an allowed command
|
|
@@ -498,6 +899,7 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
498
899
|
cwd: projectRoot,
|
|
499
900
|
projectRoot,
|
|
500
901
|
timeout: 120000,
|
|
902
|
+
signal,
|
|
501
903
|
});
|
|
502
904
|
if (result.success)
|
|
503
905
|
return { success: true, output: result.stdout || '(no output)', tool, parameters };
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { ToolCall } from './tools';
|
|
8
8
|
/**
|
|
9
|
-
* Normalize tool name to lowercase with underscores
|
|
9
|
+
* Normalize tool name to lowercase with underscores.
|
|
10
|
+
*
|
|
11
|
+
* MCP tools (`<server>__<tool>`) are returned untouched. Their names are the
|
|
12
|
+
* server's, not ours: `brave-search__brave_web_search` or
|
|
13
|
+
* `context7__resolve-library-id` must reach the registry exactly as advertised,
|
|
14
|
+
* or the server lookup and the server's own tool lookup both miss. No built-in
|
|
15
|
+
* tool name contains `__`, so the check cannot catch one of ours.
|
|
10
16
|
*/
|
|
11
17
|
export declare function normalizeToolName(name: string): string;
|
|
12
18
|
/**
|
|
@@ -11,9 +11,17 @@ const debug = (...args) => {
|
|
|
11
11
|
}
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
14
|
-
* Normalize tool name to lowercase with underscores
|
|
14
|
+
* Normalize tool name to lowercase with underscores.
|
|
15
|
+
*
|
|
16
|
+
* MCP tools (`<server>__<tool>`) are returned untouched. Their names are the
|
|
17
|
+
* server's, not ours: `brave-search__brave_web_search` or
|
|
18
|
+
* `context7__resolve-library-id` must reach the registry exactly as advertised,
|
|
19
|
+
* or the server lookup and the server's own tool lookup both miss. No built-in
|
|
20
|
+
* tool name contains `__`, so the check cannot catch one of ours.
|
|
15
21
|
*/
|
|
16
22
|
export function normalizeToolName(name) {
|
|
23
|
+
if (name.includes('__'))
|
|
24
|
+
return name;
|
|
17
25
|
const toolNameMap = {
|
|
18
26
|
'executecommand': 'execute_command',
|
|
19
27
|
'execute_command': 'execute_command',
|
|
@@ -256,7 +264,8 @@ export function parseToolCalls(response) {
|
|
|
256
264
|
// Format 2: <toolcall>toolname{...}
|
|
257
265
|
const malformedRegex = /<toolcall>(\w+)[\s,]*(?:"parameters"\s*:\s*)?(\{[\s\S]*?\})/gi;
|
|
258
266
|
while ((match = malformedRegex.exec(response)) !== null) {
|
|
259
|
-
|
|
267
|
+
// MCP names keep their case — see normalizeToolName.
|
|
268
|
+
const toolName = match[1].includes('__') ? match[1] : match[1].toLowerCase();
|
|
260
269
|
const actualToolName = TEXT_TOOL_NAME_MAP[toolName] || toolName;
|
|
261
270
|
try {
|
|
262
271
|
const parsed = JSON.parse(match[2]);
|
|
@@ -271,7 +280,7 @@ export function parseToolCalls(response) {
|
|
|
271
280
|
// Format 2b: loose toolname + parameters key
|
|
272
281
|
const looseRegex = /<toolcall>(\w+)[,\s]+["']?parameters["']?\s*:\s*(\{[\s\S]*?\})(?:<\/toolcall>|<|$)/gi;
|
|
273
282
|
while ((match = looseRegex.exec(response)) !== null) {
|
|
274
|
-
const toolName = match[1].toLowerCase();
|
|
283
|
+
const toolName = match[1].includes('__') ? match[1] : match[1].toLowerCase();
|
|
275
284
|
const actualToolName = TEXT_TOOL_NAME_MAP[toolName] || toolName;
|
|
276
285
|
if (toolCalls.some(t => t.tool === actualToolName))
|
|
277
286
|
continue;
|