codeep 3.4.0 → 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 +15 -0
- package/dist/acp/commands.js +30 -1
- package/dist/acp/server.js +237 -23
- 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/renderer/agentExecution.js +116 -69
- package/dist/renderer/commands.js +19 -3
- package/dist/renderer/main.d.ts +24 -0
- package/dist/renderer/main.js +57 -2
- package/dist/utils/agent.d.ts +17 -2
- package/dist/utils/agent.js +62 -5
- 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/shell.d.ts +108 -0
- package/dist/utils/shell.js +364 -5
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- 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
|
@@ -14,6 +14,8 @@ import { takeRunFromPhone } from '../utils/telegramInbox.js';
|
|
|
14
14
|
import { isFlatFeeProvider } from '../config/providers.js';
|
|
15
15
|
import { raceApproval } from '../utils/approvalRace.js';
|
|
16
16
|
import { describeAuditTarget } from '../utils/auditLog.js';
|
|
17
|
+
import { trustBearingWrite, forgetHooksDirectory } from '../utils/toolExecution.js';
|
|
18
|
+
import { shellCommandEnv } from '../utils/shell.js';
|
|
17
19
|
import { charWidth } from './ansi.js';
|
|
18
20
|
import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
|
|
19
21
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
@@ -235,75 +237,95 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
235
237
|
const asksPerTool = confirmationMode === 'dangerous' || confirmationMode === 'always';
|
|
236
238
|
// Read the Telegram credentials once for the whole run rather than per tool
|
|
237
239
|
// call: they come from the OS keychain, and paying that on every dangerous
|
|
238
|
-
// tool would put a keychain round-trip in front of each confirmation.
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// confirmations off is exactly the one you are most likely to walk away
|
|
246
|
-
// from. Reuse the credentials already read above when there are any, so
|
|
247
|
-
// this costs a second keychain round-trip only when there are not.
|
|
248
|
-
const noticeCredentials = telegramCredentials ?? await loadTelegramCredentials();
|
|
240
|
+
// tool would put a keychain round-trip in front of each confirmation. Read
|
|
241
|
+
// in every mode, for the finish notice — and because 'never' now asks
|
|
242
|
+
// about a file that decides what runs later, which is exactly the kind of
|
|
243
|
+
// run someone has walked away from. Null means the feature is off or
|
|
244
|
+
// half-configured, and the terminal is then the only place the question
|
|
245
|
+
// appears — exactly as before.
|
|
246
|
+
const telegramCredentials = await loadTelegramCredentials();
|
|
249
247
|
const runStartedAt = Date.now();
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
248
|
+
// 'never' still gets a callback. A write to a file that decides what runs
|
|
249
|
+
// later — `.git/config`, a hook, an MCP server list — is asked about in
|
|
250
|
+
// every mode (the agent gate decides which calls those are), and "never
|
|
251
|
+
// ask" is then answered here for everything else, exactly as before:
|
|
252
|
+
// without a callback the agent would have to refuse those writes instead.
|
|
253
|
+
const onRequestPermission = async (toolCall,
|
|
254
|
+
// What the agent gate already worked out about this call, passed rather
|
|
255
|
+
// than worked out twice: trustBearingWrite() stats the path, resolves a
|
|
256
|
+
// symlinked ancestor and may ask git where this repo keeps its hooks.
|
|
257
|
+
// Undefined means the question came from somewhere that has not looked
|
|
258
|
+
// — a skill's shell line asks through this same callback — so it is
|
|
259
|
+
// only then that this side looks for itself. `null` is an answer.
|
|
260
|
+
known) => {
|
|
261
|
+
const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, context.root || process.cwd());
|
|
262
|
+
if (!asksPerTool && !trustBearing)
|
|
263
|
+
return 'allow_once';
|
|
264
|
+
// `parameters.command` is the binary alone — `git`, not `git status`.
|
|
265
|
+
// Showing that asks someone to approve a command they have not been
|
|
266
|
+
// shown, which is the one thing this gate must not do. The audit
|
|
267
|
+
// record already joins the binary with its arguments; reuse it rather
|
|
268
|
+
// than writing a second, subtly different answer.
|
|
269
|
+
const target = describeAuditTarget(toolCall);
|
|
270
|
+
// Indented by two in the dialog.
|
|
271
|
+
const targetLines = wrapConfirmTarget(target, (process.stdout.columns || 80) - 4)
|
|
272
|
+
.map(line => ` ${line}`);
|
|
273
|
+
const inTerminal = {
|
|
274
|
+
answer: new Promise((resolve) => {
|
|
275
|
+
app.showConfirm({
|
|
276
|
+
title: '⚠️ Confirm Action',
|
|
277
|
+
message: [
|
|
278
|
+
'The agent wants to execute:',
|
|
279
|
+
'',
|
|
280
|
+
` ${showControls(toolCall.tool)}`,
|
|
281
|
+
...targetLines,
|
|
282
|
+
// What the file does, not that it is "sensitive": someone
|
|
283
|
+
// deciding in one second needs the consequence, not a label.
|
|
284
|
+
...(trustBearing ? ['', ...wrapConfirmTarget(`⚠️ ${trustBearing.reason}`, (process.stdout.columns || 80) - 4)] : []),
|
|
285
|
+
'',
|
|
286
|
+
telegramCredentials ? 'Allow this action? (or answer on Telegram)' : 'Allow this action?',
|
|
287
|
+
],
|
|
288
|
+
confirmLabel: 'Allow',
|
|
289
|
+
cancelLabel: 'Deny',
|
|
290
|
+
// No "Always Allow" for one of those files: the agent answers
|
|
291
|
+
// about this file only and would not remember it anyway.
|
|
292
|
+
extraOption: trustBearing ? undefined : { label: 'Always Allow', onSelect: () => resolve('allow_always') },
|
|
293
|
+
onConfirm: () => resolve('allow_once'),
|
|
294
|
+
// The one "no" there is, and it answers reject_always. For one of
|
|
295
|
+
// those files the agent remembers that against the FILE rather
|
|
296
|
+
// than the tool, so refusing a `.git/config` prompt does not also
|
|
297
|
+
// switch delete_file off for the rest of the run.
|
|
298
|
+
onCancel: () => resolve('reject_always'),
|
|
299
|
+
});
|
|
300
|
+
}),
|
|
301
|
+
// Answered on the phone: take the dialog down without running either
|
|
302
|
+
// callback, since the decision is already made and taken.
|
|
303
|
+
withdraw: (winner) => app.dismissConfirm(`Answered on Telegram — ${winner}.`),
|
|
304
|
+
};
|
|
305
|
+
let onPhone = null;
|
|
306
|
+
if (telegramCredentials) {
|
|
307
|
+
// Report a failure to *ask* once, in the terminal. Without this a
|
|
308
|
+
// wrong chat id looks exactly like a phone nobody picked up.
|
|
309
|
+
const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
|
|
310
|
+
onPhone = {
|
|
311
|
+
answer: telegram
|
|
312
|
+
// The reason goes with it: a phone showing less than the terminal
|
|
313
|
+
// asks for a decision on less than the terminal had.
|
|
314
|
+
.ask(target, toolCall.tool, true, undefined, trustBearing?.reason)
|
|
315
|
+
.then(answer => (answer ? outcomeForAnswer(answer) : null))
|
|
316
|
+
// A phone that cannot be reached is not a denial. Step aside and
|
|
317
|
+
// let the terminal decide, however long that takes.
|
|
318
|
+
.catch(() => null),
|
|
319
|
+
withdraw: (winner) => telegram.withdraw(winner),
|
|
283
320
|
};
|
|
284
|
-
let onPhone = null;
|
|
285
|
-
if (telegramCredentials) {
|
|
286
|
-
// Report a failure to *ask* once, in the terminal. Without this a
|
|
287
|
-
// wrong chat id looks exactly like a phone nobody picked up.
|
|
288
|
-
const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
|
|
289
|
-
onPhone = {
|
|
290
|
-
answer: telegram
|
|
291
|
-
.ask(target, toolCall.tool, true)
|
|
292
|
-
.then(answer => (answer ? outcomeForAnswer(answer) : null))
|
|
293
|
-
// A phone that cannot be reached is not a denial. Step aside and
|
|
294
|
-
// let the terminal decide, however long that takes.
|
|
295
|
-
.catch(() => null),
|
|
296
|
-
withdraw: (winner) => telegram.withdraw(winner),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
|
-
const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
|
|
300
|
-
// Nobody answered — neither side could even ask. `classifyPermissionOutcome`
|
|
301
|
-
// fails closed on anything it does not recognise, and this is spelled
|
|
302
|
-
// out rather than left to that: a question that was never put must
|
|
303
|
-
// never read as a yes.
|
|
304
|
-
return answer ?? 'reject_once';
|
|
305
321
|
}
|
|
306
|
-
|
|
322
|
+
const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
|
|
323
|
+
// Nobody answered — neither side could even ask. `classifyPermissionOutcome`
|
|
324
|
+
// fails closed on anything it does not recognise, and this is spelled
|
|
325
|
+
// out rather than left to that: a question that was never put must
|
|
326
|
+
// never read as a yes.
|
|
327
|
+
return answer ?? 'reject_once';
|
|
328
|
+
};
|
|
307
329
|
const result = await runAgent(enrichedTask, context, {
|
|
308
330
|
dryRun,
|
|
309
331
|
onRequestPermission,
|
|
@@ -558,7 +580,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
558
580
|
// Told once the run is over, and only when it ran long enough that you
|
|
559
581
|
// could plausibly have stopped watching. Awaited so the process does not
|
|
560
582
|
// exit from under the request, but never allowed to fail the run.
|
|
561
|
-
if (
|
|
583
|
+
if (telegramCredentials) {
|
|
562
584
|
const elapsedMs = Date.now() - runStartedAt;
|
|
563
585
|
const fromPhone = startedFromPhone;
|
|
564
586
|
// The one-minute threshold exists so a phone is not buzzed about work you
|
|
@@ -578,7 +600,7 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
578
600
|
costUsd: payPerUse.reduce((sum, e) => sum + e.estimatedCost, 0),
|
|
579
601
|
});
|
|
580
602
|
for (const message of messages) {
|
|
581
|
-
await sendTelegramNotice(
|
|
603
|
+
await sendTelegramNotice(telegramCredentials, message).catch(() => false);
|
|
582
604
|
}
|
|
583
605
|
}
|
|
584
606
|
}
|
|
@@ -657,12 +679,37 @@ export async function runSkill(nameOrShortcut, args, ctx) {
|
|
|
657
679
|
try {
|
|
658
680
|
const result = await executeSkill(skill, params, {
|
|
659
681
|
onCommand: async (cmd) => {
|
|
682
|
+
const cwd = ctx.projectPath || process.cwd();
|
|
683
|
+
// A raw process.env here handed the repository's own `.git/config`
|
|
684
|
+
// back to git: `/commit` runs `git commit`, and a repo-scope
|
|
685
|
+
// `gpg.program` that Codeep's own commit path neutralises executed
|
|
686
|
+
// through this spawn instead.
|
|
687
|
+
//
|
|
688
|
+
// Built before the spawn, and caught: shellCommandEnv() refuses a git
|
|
689
|
+
// line in a repository whose config names a program no override
|
|
690
|
+
// switches off, and a refusal escaping here would abort the whole
|
|
691
|
+
// skill rather than fail the step that asked for git. A step that
|
|
692
|
+
// cannot run is reported the same way a step that failed is.
|
|
693
|
+
let env;
|
|
694
|
+
try {
|
|
695
|
+
env = shellCommandEnv(cmd, cwd);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
699
|
+
ctx.app.addMessage({ role: 'system', content: `\`${cmd}\` was not run:\n\`\`\`\n${why}\n\`\`\`` });
|
|
700
|
+
throw new Error(why);
|
|
701
|
+
}
|
|
702
|
+
// A command line is the one thing in a skill that can move this
|
|
703
|
+
// repository's hooks (`git config core.hooksPath .evil`), and the
|
|
704
|
+
// write gate caches where they are for the run.
|
|
705
|
+
forgetHooksDirectory();
|
|
660
706
|
const proc = spawnSync(cmd, {
|
|
661
|
-
cwd
|
|
707
|
+
cwd,
|
|
662
708
|
encoding: 'utf-8',
|
|
663
709
|
timeout: 60000,
|
|
664
710
|
shell: true,
|
|
665
711
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
712
|
+
env,
|
|
666
713
|
});
|
|
667
714
|
const stdout = (proc.stdout || '').trim();
|
|
668
715
|
const stderr = (proc.stderr || '').trim();
|
|
@@ -1215,9 +1215,25 @@ Format: use headers per category, only include categories where you found issues
|
|
|
1215
1215
|
ctx.app.notify('Usage: /git-commit <message>');
|
|
1216
1216
|
return;
|
|
1217
1217
|
}
|
|
1218
|
-
// Use execFile to avoid shell injection — pass commit message as a direct argument
|
|
1219
|
-
|
|
1220
|
-
|
|
1218
|
+
// Use execFile to avoid shell injection — pass commit message as a direct argument.
|
|
1219
|
+
// hardenedGitEnv() on top of that: the repo's own .git/config can name programs
|
|
1220
|
+
// git would run for this commit (core.fsmonitor, core.hooksPath, …).
|
|
1221
|
+
Promise.all([import('child_process'), import('../utils/git.js')]).then(([{ execFile }, { hardenedGitEnv }]) => {
|
|
1222
|
+
// The SAME cwd the commit runs in. Called with no argument, the scan
|
|
1223
|
+
// read process.cwd() instead — so a project opened anywhere other
|
|
1224
|
+
// than the directory Codeep was launched from was "hardened" against
|
|
1225
|
+
// a different repository's config entirely.
|
|
1226
|
+
let env;
|
|
1227
|
+
try {
|
|
1228
|
+
env = hardenedGitEnv({ cwd: ctx.projectPath });
|
|
1229
|
+
}
|
|
1230
|
+
catch (error) {
|
|
1231
|
+
// It refuses rather than hand git a half-scanned environment, and
|
|
1232
|
+
// its message names the key and what to do about it.
|
|
1233
|
+
ctx.app.notify(error instanceof Error ? error.message : 'Commit failed');
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
execFile('git', ['commit', '-m', message], { cwd: ctx.projectPath, encoding: 'utf-8', env }, (err) => {
|
|
1221
1237
|
if (err) {
|
|
1222
1238
|
ctx.app.notify(`Commit failed: ${err.message}`);
|
|
1223
1239
|
}
|
package/dist/renderer/main.d.ts
CHANGED
|
@@ -6,7 +6,31 @@
|
|
|
6
6
|
* commands.ts and agent execution in agentExecution.ts.
|
|
7
7
|
*/
|
|
8
8
|
import { App } from './App';
|
|
9
|
+
import { type GitStatus } from '../utils/git';
|
|
9
10
|
import type { McpServer } from '../acp/protocol';
|
|
11
|
+
/**
|
|
12
|
+
* What to tell the user when git would not run in this project, or null when
|
|
13
|
+
* there is nothing to tell them.
|
|
14
|
+
*
|
|
15
|
+
* Without it the only symptom is the branch quietly missing from the header,
|
|
16
|
+
* which reads as "not a repository" — so a repository whose own `.git/config`
|
|
17
|
+
* names a program git would run looks like an ordinary folder, and the one
|
|
18
|
+
* thing the user has to do (remove that key) is never said anywhere. The
|
|
19
|
+
* refusal names the key and the `git config --unset` that clears it, so it is
|
|
20
|
+
* passed through verbatim rather than summarised into "git failed".
|
|
21
|
+
*
|
|
22
|
+
* Read off `refusal` and NOT off `error`, which is the field an earlier cut
|
|
23
|
+
* of this used. `error` is every way git can fail in a repository, and the
|
|
24
|
+
* commonest of them is a brand-new `git init` with no commit yet: `git
|
|
25
|
+
* rev-parse --abbrev-ref HEAD` answers `fatal: ambiguous argument 'HEAD'`
|
|
26
|
+
* there (git 2.54), so the first thing a user does in a new project met a
|
|
27
|
+
* warning made of git internals telling them to remove a config key that does
|
|
28
|
+
* not exist. `refusal` is filled on the hardening path and nowhere else, so
|
|
29
|
+
* an ordinary git failure stays as silent as it was before this notice
|
|
30
|
+
* existed — the branch is simply missing from the header, which is what it
|
|
31
|
+
* has always done.
|
|
32
|
+
*/
|
|
33
|
+
export declare function gitRefusalNotice(status: GitStatus): string | null;
|
|
10
34
|
/** Derive a short display name from a user message (first ~5 words, max 48 chars). */
|
|
11
35
|
export declare function deriveSessionName(message: string): string;
|
|
12
36
|
/**
|
package/dist/renderer/main.js
CHANGED
|
@@ -31,8 +31,13 @@ import { symlinkedCodeepNotice } from '../utils/projectPaths.js';
|
|
|
31
31
|
// ─── Global state ─────────────────────────────────────────────────────────────
|
|
32
32
|
let projectPath = process.cwd();
|
|
33
33
|
/** Cached header branch. Resolved on first use so `--version`/`--help` never
|
|
34
|
-
* shell out to git, and cached because getStatus runs on every render frame.
|
|
34
|
+
* shell out to git, and cached because getStatus runs on every render frame.
|
|
35
|
+
* `refusal` is the message getGitStatus carries back when git would not run
|
|
36
|
+
* here at all — see reportGitRefusal(). */
|
|
35
37
|
let gitBranchCache = null;
|
|
38
|
+
/** Projects whose refusal has already been put in the transcript, so a cache
|
|
39
|
+
* dropped after every agent run does not repeat it once a minute. */
|
|
40
|
+
const gitRefusalReported = new Set();
|
|
36
41
|
let projectContext = null;
|
|
37
42
|
let hasWriteAccess = false;
|
|
38
43
|
let sessionId = getCurrentSessionId();
|
|
@@ -51,10 +56,60 @@ const addedFiles = new Map();
|
|
|
51
56
|
* or an agent run finished (an agent can check out a different branch). */
|
|
52
57
|
function getHeaderBranch() {
|
|
53
58
|
if (!gitBranchCache || gitBranchCache.path !== projectPath) {
|
|
54
|
-
|
|
59
|
+
const status = getGitStatus(projectPath);
|
|
60
|
+
const refusal = gitRefusalNotice(status);
|
|
61
|
+
gitBranchCache = { path: projectPath, branch: status.branch, refusal: refusal ?? undefined };
|
|
62
|
+
reportGitRefusal(projectPath, refusal);
|
|
55
63
|
}
|
|
56
64
|
return gitBranchCache.branch;
|
|
57
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* What to tell the user when git would not run in this project, or null when
|
|
68
|
+
* there is nothing to tell them.
|
|
69
|
+
*
|
|
70
|
+
* Without it the only symptom is the branch quietly missing from the header,
|
|
71
|
+
* which reads as "not a repository" — so a repository whose own `.git/config`
|
|
72
|
+
* names a program git would run looks like an ordinary folder, and the one
|
|
73
|
+
* thing the user has to do (remove that key) is never said anywhere. The
|
|
74
|
+
* refusal names the key and the `git config --unset` that clears it, so it is
|
|
75
|
+
* passed through verbatim rather than summarised into "git failed".
|
|
76
|
+
*
|
|
77
|
+
* Read off `refusal` and NOT off `error`, which is the field an earlier cut
|
|
78
|
+
* of this used. `error` is every way git can fail in a repository, and the
|
|
79
|
+
* commonest of them is a brand-new `git init` with no commit yet: `git
|
|
80
|
+
* rev-parse --abbrev-ref HEAD` answers `fatal: ambiguous argument 'HEAD'`
|
|
81
|
+
* there (git 2.54), so the first thing a user does in a new project met a
|
|
82
|
+
* warning made of git internals telling them to remove a config key that does
|
|
83
|
+
* not exist. `refusal` is filled on the hardening path and nowhere else, so
|
|
84
|
+
* an ordinary git failure stays as silent as it was before this notice
|
|
85
|
+
* existed — the branch is simply missing from the header, which is what it
|
|
86
|
+
* has always done.
|
|
87
|
+
*/
|
|
88
|
+
export function gitRefusalNotice(status) {
|
|
89
|
+
const refusal = status.isRepo ? status.refusal : undefined;
|
|
90
|
+
if (!refusal)
|
|
91
|
+
return null;
|
|
92
|
+
return `⚠️ ${refusal}\n\nUntil then the header shows no branch, and everything Codeep does with git here — the status line, \`@git\`, \`/commit\`, the review hook — is off.`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Put that message in the transcript once per project.
|
|
96
|
+
*
|
|
97
|
+
* A transcript message and not notify(): a toast is gone in three seconds,
|
|
98
|
+
* and this is a thing to act on, not a thing to glance at. Once per project
|
|
99
|
+
* because the cache it rides on is dropped after every agent run, and a
|
|
100
|
+
* warning repeated after each run is one the user learns to scroll past.
|
|
101
|
+
*
|
|
102
|
+
* Queued rather than added inline because the only caller runs inside
|
|
103
|
+
* getStatus(), which the render loop calls — pushing a message onto the list
|
|
104
|
+
* the frame is reading would tear that frame. The microtask lands before the
|
|
105
|
+
* next one.
|
|
106
|
+
*/
|
|
107
|
+
function reportGitRefusal(path, notice) {
|
|
108
|
+
if (!notice || gitRefusalReported.has(path))
|
|
109
|
+
return;
|
|
110
|
+
gitRefusalReported.add(path);
|
|
111
|
+
queueMicrotask(() => { app?.addMessage({ role: 'system', content: notice }); });
|
|
112
|
+
}
|
|
58
113
|
/** Derive a short display name from a user message (first ~5 words, max 48 chars). */
|
|
59
114
|
export function deriveSessionName(message) {
|
|
60
115
|
const clean = message.replace(/\s+/g, ' ').trim();
|
package/dist/utils/agent.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { type Personality } from './personalities';
|
|
|
10
10
|
export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
|
|
11
11
|
export type { AgentChatResponse };
|
|
12
12
|
import { ToolCall, ToolResult, ActionLog } from './tools';
|
|
13
|
+
import { type TrustBearingWrite } from './toolExecution';
|
|
13
14
|
import { undoLastAction, undoAllActions, getCurrentSession, getRecentSessions, formatSession, ActionSession } from './history';
|
|
14
15
|
import { VerifyResult } from './verify';
|
|
15
16
|
import { TaskPlan, SubTask } from './taskPlanner';
|
|
@@ -65,7 +66,18 @@ export interface AgentOptions {
|
|
|
65
66
|
onVerification?: (results: VerifyResult[]) => void;
|
|
66
67
|
onTaskPlan?: (plan: TaskPlan) => void;
|
|
67
68
|
onTaskUpdate?: (task: SubTask) => void;
|
|
68
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Ask the user about one tool call.
|
|
71
|
+
*
|
|
72
|
+
* `trustBearing` is what this run already worked out about the call — the
|
|
73
|
+
* file it would write that decides what runs later, or null when it writes
|
|
74
|
+
* no such file. It is passed so the side putting up the dialog can word it
|
|
75
|
+
* without calling trustBearingWrite() a second time: that call stats the
|
|
76
|
+
* path, follows a symlink and may ask git where the repo's hooks live.
|
|
77
|
+
* Optional, so a caller that would rather work it out itself (or was not
|
|
78
|
+
* called from the gate below) still type-checks.
|
|
79
|
+
*/
|
|
80
|
+
onRequestPermission?: (toolCall: ToolCall, trustBearing?: TrustBearingWrite | null) => Promise<PermissionOutcome>;
|
|
69
81
|
/** Tool names to force into the per-run dangerous set, on top of the global
|
|
70
82
|
* agentConfirm* settings. ACP manual mode passes ['write_file','edit_file']
|
|
71
83
|
* here to gate them for THIS run only, instead of mutating global config. */
|
|
@@ -123,10 +135,13 @@ export interface AgentOptions {
|
|
|
123
135
|
roleAddendum?: string;
|
|
124
136
|
/** "Always allow" / "always deny" answers shared with a delegating parent,
|
|
125
137
|
* so a sub-agent neither asks again about a tool the user already decided
|
|
126
|
-
* on nor runs one the user refused.
|
|
138
|
+
* on nor runs one the user refused. `alwaysRejectedPaths` holds the same
|
|
139
|
+
* for a single file that decides what runs later, which is refused by name
|
|
140
|
+
* rather than by tool. */
|
|
127
141
|
permissionMemory?: {
|
|
128
142
|
alwaysAllowed: Set<string>;
|
|
129
143
|
alwaysRejected: Set<string>;
|
|
144
|
+
alwaysRejectedPaths?: Set<string>;
|
|
130
145
|
};
|
|
131
146
|
/** Provider/model for this run only, used in place of the global selection.
|
|
132
147
|
* A sub-agent with its own `model:` runs on it this way; the global config
|
package/dist/utils/agent.js
CHANGED
|
@@ -37,6 +37,7 @@ function calculateDynamicTimeout(iteration, baseTimeout) {
|
|
|
37
37
|
return Math.max(calculatedTimeout, 120000);
|
|
38
38
|
}
|
|
39
39
|
import { parseToolCalls, executeTool, createActionLog } from './tools.js';
|
|
40
|
+
import { trustBearingWrite, forgetHooksDirectory, NO_CONFIRMER_REFUSAL } from './toolExecution.js';
|
|
40
41
|
import { config } from '../config/index.js';
|
|
41
42
|
import { supportsNativeTools } from '../config/providers.js';
|
|
42
43
|
import { isMcpToolName, isVirtualMcpToolName } from './mcpRegistry.js';
|
|
@@ -568,6 +569,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
568
569
|
const alwaysAllowedTools = opts.permissionMemory?.alwaysAllowed ?? new Set();
|
|
569
570
|
// Track tools permanently rejected this session via reject_always
|
|
570
571
|
const alwaysRejectedTools = opts.permissionMemory?.alwaysRejected ?? new Set();
|
|
572
|
+
// Files that decide what runs later and were refused for good this session.
|
|
573
|
+
// Kept apart from the tool set on purpose: the TUI's only "no" button answers
|
|
574
|
+
// reject_always, so saying no to one `.git/config` prompt would otherwise
|
|
575
|
+
// turn off delete_file — and every other use of that tool — for the rest of
|
|
576
|
+
// the run. Keyed by the resolved path, so one answer covers every spelling
|
|
577
|
+
// of the same file.
|
|
578
|
+
const alwaysRejectedPaths = opts.permissionMemory?.alwaysRejectedPaths ?? new Set();
|
|
571
579
|
// Tools that require permission when onRequestPermission is set (configurable)
|
|
572
580
|
const dangerousTools = buildDangerousTools(opts.extraDangerousTools);
|
|
573
581
|
// Delegation handler: run a named (or generic) sub-agent in its own fresh
|
|
@@ -634,7 +642,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
634
642
|
dryRun: opts.dryRun,
|
|
635
643
|
onRequestPermission: opts.onRequestPermission,
|
|
636
644
|
extraDangerousTools: opts.extraDangerousTools,
|
|
637
|
-
permissionMemory: { alwaysAllowed: alwaysAllowedTools, alwaysRejected: alwaysRejectedTools },
|
|
645
|
+
permissionMemory: { alwaysAllowed: alwaysAllowedTools, alwaysRejected: alwaysRejectedTools, alwaysRejectedPaths },
|
|
638
646
|
modelOverride,
|
|
639
647
|
onExecuteCommand: opts.onExecuteCommand,
|
|
640
648
|
fs: opts.fs,
|
|
@@ -690,13 +698,56 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
690
698
|
if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
|
|
691
699
|
return refuse(`Tool "${toolCall.tool}" is not available to this sub-agent.`, `Tool ${toolCall.tool} is not allowed for this sub-agent. Use only: ${opts.allowedTools.join(', ')}.`);
|
|
692
700
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
701
|
+
const denied = () => refuse(`User rejected permission for ${toolCall.tool}`, `Tool ${toolCall.tool} was denied by user. Do not attempt this action again.`);
|
|
702
|
+
// Writing a file that decides what runs later is code execution on a
|
|
703
|
+
// delay, not an edit: git runs `core.fsmonitor` itself on the next
|
|
704
|
+
// `git status` the status line makes, a `.codeep/hooks/` script runs on
|
|
705
|
+
// the next tool call, an MCP entry spawns a process. A prompt injection
|
|
706
|
+
// that gets one of these written has walked around every other gate, so
|
|
707
|
+
// the write is confirmed in EVERY confirmation mode — not only the tiers
|
|
708
|
+
// that happen to list write_file — and an "always allow" answer given for
|
|
709
|
+
// the tool never covers it. With nobody to ask, it fails the way a write
|
|
710
|
+
// the editor refused fails: proceeding quietly is the one outcome that
|
|
711
|
+
// cannot be taken back.
|
|
712
|
+
const trustBearing = trustBearingWrite(toolCall, projectContext.root || process.cwd());
|
|
713
|
+
if (trustBearing) {
|
|
714
|
+
if (!opts.onRequestPermission) {
|
|
715
|
+
const refusal = refuse(`Refused ${toolCall.tool} on ${trustBearing.path}: ${trustBearing.reason} ${NO_CONFIRMER_REFUSAL}`, `Tool ${toolCall.tool} was refused on ${trustBearing.path}. ${trustBearing.reason} Nobody could be asked to confirm it. Do not try again — tell the user to edit that file themselves.`);
|
|
716
|
+
recordAuditEvent(auditRoot, {
|
|
717
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
718
|
+
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
719
|
+
detail: `${trustBearing.path} decides what runs later and no confirmation was possible`,
|
|
720
|
+
});
|
|
721
|
+
return refusal;
|
|
722
|
+
}
|
|
723
|
+
// An "always deny" already given: for the tool, when the user really
|
|
724
|
+
// chose that in an ordinary prompt, or for this file.
|
|
725
|
+
if (alwaysRejectedTools.has(toolCall.tool) || alwaysRejectedPaths.has(trustBearing.file))
|
|
726
|
+
return denied();
|
|
727
|
+
const decision = classifyPermissionOutcome(await opts.onRequestPermission(toolCall, trustBearing));
|
|
728
|
+
// Neither answer is remembered for the TOOL. "Always allow" is not
|
|
729
|
+
// remembered at all: it was an answer about THIS file, and the next
|
|
730
|
+
// `.git/config` write must be asked about again. "Always deny" is
|
|
731
|
+
// remembered against the file — the fail-closed half of the same rule.
|
|
732
|
+
// Against the tool it would be a trap: the TUI offers Allow, Always
|
|
733
|
+
// Allow and Deny, and that Deny answers reject_always, so refusing one
|
|
734
|
+
// `.git/config` prompt would silently disable delete_file for the rest
|
|
735
|
+
// of the run.
|
|
736
|
+
if (decision !== 'allow-once' && decision !== 'allow-always') {
|
|
737
|
+
if (decision === 'deny-always')
|
|
738
|
+
alwaysRejectedPaths.add(trustBearing.file);
|
|
739
|
+
return denied();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
else if (opts.onRequestPermission && requiresPermission(toolCall.tool, dangerousTools) && !alwaysAllowedTools.has(toolCall.tool)) {
|
|
743
|
+
// Every other tool: the run's dangerous set decides, and only when
|
|
744
|
+
// there is a callback to ask through (e.g. ACP/Zed).
|
|
696
745
|
// Skip without asking if permanently rejected this session
|
|
697
746
|
if (alwaysRejectedTools.has(toolCall.tool))
|
|
698
747
|
return denied();
|
|
699
|
-
|
|
748
|
+
// `null` and not nothing: this branch runs only when the call writes no
|
|
749
|
+
// such file, and saying so spares the dialog the second lookup.
|
|
750
|
+
const outcome = await opts.onRequestPermission(toolCall, null);
|
|
700
751
|
// Fail CLOSED: allow ONLY on an explicit allow outcome; reject_* and
|
|
701
752
|
// any malformed/unknown outcome deny (see classifyPermissionOutcome).
|
|
702
753
|
const decision = classifyPermissionOutcome(outcome);
|
|
@@ -740,6 +791,12 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
740
791
|
}
|
|
741
792
|
else {
|
|
742
793
|
try {
|
|
794
|
+
// Runs in the editor's terminal instead of ours, so executeTool's
|
|
795
|
+
// own invalidation never fires — but `git config core.hooksPath
|
|
796
|
+
// .evil` moves this repository's hooks just the same. Drop the
|
|
797
|
+
// cached answer here too, or the next write to the new hook
|
|
798
|
+
// directory goes through unasked.
|
|
799
|
+
forgetHooksDirectory();
|
|
743
800
|
const commandResult = await opts.onExecuteCommand(command, args, cwd);
|
|
744
801
|
toolResult = {
|
|
745
802
|
success: commandResult.exitCode === 0,
|
|
@@ -38,6 +38,7 @@ import { isSafeProjectWriteTarget, writeProjectFile } from './projectPaths.js';
|
|
|
38
38
|
import { join } from 'path';
|
|
39
39
|
import { randomUUID } from 'crypto';
|
|
40
40
|
import { execSync } from 'child_process';
|
|
41
|
+
import { hardenedGitEnv } from './git.js';
|
|
41
42
|
function getCheckpointsDir(workspaceRoot) {
|
|
42
43
|
return join(workspaceRoot, '.codeep', 'checkpoints');
|
|
43
44
|
}
|
|
@@ -49,6 +50,8 @@ function readGitHead(workspaceRoot) {
|
|
|
49
50
|
encoding: 'utf-8',
|
|
50
51
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
51
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 }),
|
|
52
55
|
}).trim();
|
|
53
56
|
return out || undefined;
|
|
54
57
|
}
|
package/dist/utils/codeReview.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
5
5
|
import { join, extname, relative } from 'path';
|
|
6
|
-
import {
|
|
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
|
|
283
|
-
.map(f => join(projectRoot, f))
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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 —
|
|
360
|
-
//
|
|
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 (
|
|
363
|
-
scope = `specific file${specificFiles
|
|
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 (
|
|
366
|
-
|
|
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 (${
|
|
374
|
+
scope = `full src/ scan — no git changes (${count})`;
|
|
370
375
|
}
|
|
371
376
|
for (const filePath of filesToReview) {
|
|
372
377
|
try {
|