codeep 2.1.2 → 2.1.4
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/README.md +9 -4
- package/dist/acp/commands.js +12 -2
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +2 -0
- package/dist/renderer/commands.js +15 -2
- package/dist/utils/agent.js +8 -2
- package/dist/utils/agentChat.d.ts +15 -0
- package/dist/utils/agentChat.js +79 -0
- package/dist/utils/codeepCloud.js +7 -2
- package/dist/utils/hooks.d.ts +11 -0
- package/dist/utils/hooks.js +52 -1
- package/dist/utils/shell.js +36 -0
- package/dist/utils/toolExecution.js +83 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -181,7 +181,7 @@ Codeep works as a **full AI coding agent** that autonomously:
|
|
|
181
181
|
### Context Persistence
|
|
182
182
|
- **Save conversations** - Continue where you left off
|
|
183
183
|
- **Per-project context** - Each project maintains its own history
|
|
184
|
-
- **Automatic summarization** -
|
|
184
|
+
- **Automatic summarization** - When prior history overflows the agent's context budget, the dropped (oldest) messages are condensed into a short recap (decisions, constraints, unfinished threads) instead of being silently truncated — so long sessions don't forget how they started. One cheap LLM call, made only on overflow and cached per session; opt out with `autoSummarizeHistory: false` (`/settings`)
|
|
185
185
|
|
|
186
186
|
### Web & MCP Tools
|
|
187
187
|
- Agent can fetch documentation and web content
|
|
@@ -449,9 +449,14 @@ Example — auto-format on edit (`.codeep/hooks/post_edit.sh`):
|
|
|
449
449
|
prettier --write "$CODEEP_HOOK_FILE" 2>/dev/null
|
|
450
450
|
```
|
|
451
451
|
|
|
452
|
-
Run `/hooks` to see which hooks are installed in the current workspace.
|
|
453
|
-
|
|
454
|
-
|
|
452
|
+
Run `/hooks` to see which hooks are installed in the current workspace.
|
|
453
|
+
|
|
454
|
+
**Trust required (security).** Because hooks run arbitrary shell, a freshly
|
|
455
|
+
cloned repo's hooks are **not** run until you approve the workspace. Run
|
|
456
|
+
`/hooks trust` to enable them for the current project (revoke with
|
|
457
|
+
`/hooks untrust`); `/hooks` and the welcome banner show the trust state. Your
|
|
458
|
+
own projects just need a one-time `/hooks trust`. Global `~/.codeep/hooks/` are
|
|
459
|
+
never run for the same reason.
|
|
455
460
|
|
|
456
461
|
### Skill Bundles (new in 2.0)
|
|
457
462
|
Beyond the built-in skills and custom slash commands, Codeep now supports
|
package/dist/acp/commands.js
CHANGED
|
@@ -881,8 +881,18 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
|
|
|
881
881
|
return { handled: true, response: `Unknown subcommand: \`${sub}\`. Use \`show\`, \`prefer\`, \`ignore\`, \`fallbacks\`, \`privacy\`, or \`clear\`.` };
|
|
882
882
|
}
|
|
883
883
|
case 'hooks': {
|
|
884
|
-
const { listInstalledHooks, formatHookList } = await import('../utils/hooks.js');
|
|
885
|
-
|
|
884
|
+
const { listInstalledHooks, formatHookList, formatHookTrust, trustWorkspaceHooks, untrustWorkspaceHooks } = await import('../utils/hooks.js');
|
|
885
|
+
const sub = (args[0] || '').toLowerCase();
|
|
886
|
+
if (sub === 'trust') {
|
|
887
|
+
trustWorkspaceHooks(session.workspaceRoot);
|
|
888
|
+
return { handled: true, response: 'Hooks trusted for this workspace — they will now run.' };
|
|
889
|
+
}
|
|
890
|
+
if (sub === 'untrust') {
|
|
891
|
+
untrustWorkspaceHooks(session.workspaceRoot);
|
|
892
|
+
return { handled: true, response: 'Hooks untrusted — they will be skipped until you trust again.' };
|
|
893
|
+
}
|
|
894
|
+
const trust = formatHookTrust(session.workspaceRoot);
|
|
895
|
+
return { handled: true, response: formatHookList(listInstalledHooks(session.workspaceRoot)) + (trust ? `\n\n${trust}` : '') };
|
|
886
896
|
}
|
|
887
897
|
case 'mcp': {
|
|
888
898
|
const sub = args[0]?.toLowerCase();
|
package/dist/config/index.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ interface ConfigSchema {
|
|
|
27
27
|
* small background API call (uses the active model) once per session.
|
|
28
28
|
* Default true; set false to avoid any unsolicited API calls. */
|
|
29
29
|
autoSessionTitle: boolean;
|
|
30
|
+
/** When prior chat history overflows the agent's context budget, summarize
|
|
31
|
+
* the dropped (oldest) messages via one LLM call instead of silently
|
|
32
|
+
* discarding them — so long sessions keep early decisions/constraints.
|
|
33
|
+
* Default true; set false to fall back to plain truncation (no extra call). */
|
|
34
|
+
autoSummarizeHistory: boolean;
|
|
35
|
+
/** Absolute workspace roots whose project-local `.codeep/hooks/*` the user
|
|
36
|
+
* has approved to run. Untrusted projects' hooks are skipped (a cloned repo
|
|
37
|
+
* can't execute shell on first tool call). Granted via `/hooks trust`. */
|
|
38
|
+
trustedHookProjects: string[];
|
|
30
39
|
currentSessionId: string;
|
|
31
40
|
temperature: number;
|
|
32
41
|
maxTokens: number;
|
package/dist/config/index.js
CHANGED
|
@@ -1151,8 +1151,21 @@ Format: use headers per category, only include categories where you found issues
|
|
|
1151
1151
|
break;
|
|
1152
1152
|
}
|
|
1153
1153
|
case 'hooks': {
|
|
1154
|
-
const { listInstalledHooks, formatHookList } = await import('../utils/hooks.js');
|
|
1155
|
-
|
|
1154
|
+
const { listInstalledHooks, formatHookList, formatHookTrust, trustWorkspaceHooks, untrustWorkspaceHooks } = await import('../utils/hooks.js');
|
|
1155
|
+
const sub = (args[0] || '').toLowerCase();
|
|
1156
|
+
if (sub === 'trust') {
|
|
1157
|
+
trustWorkspaceHooks(ctx.projectPath);
|
|
1158
|
+
ctx.app.notify('Hooks trusted for this workspace — they will now run.');
|
|
1159
|
+
break;
|
|
1160
|
+
}
|
|
1161
|
+
if (sub === 'untrust') {
|
|
1162
|
+
untrustWorkspaceHooks(ctx.projectPath);
|
|
1163
|
+
ctx.app.notify('Hooks untrusted — they will be skipped until you trust again.');
|
|
1164
|
+
break;
|
|
1165
|
+
}
|
|
1166
|
+
const trust = formatHookTrust(ctx.projectPath);
|
|
1167
|
+
const body = formatHookList(listInstalledHooks(ctx.projectPath)) + (trust ? `\n\n${trust}` : '');
|
|
1168
|
+
ctx.app.addMessage({ role: 'system', content: body });
|
|
1156
1169
|
break;
|
|
1157
1170
|
}
|
|
1158
1171
|
case 'rewind': {
|
package/dist/utils/agent.js
CHANGED
|
@@ -11,7 +11,7 @@ const debug = (...args) => {
|
|
|
11
11
|
}
|
|
12
12
|
};
|
|
13
13
|
// Import chat layer (prompt building + API calls)
|
|
14
|
-
import { agentChat, getAgentSystemPrompt, getFallbackSystemPrompt, loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent, } from './agentChat.js';
|
|
14
|
+
import { agentChat, getAgentSystemPrompt, getFallbackSystemPrompt, loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent, summarizeEarlierHistory, } from './agentChat.js';
|
|
15
15
|
import { ApiError } from '../api/index.js';
|
|
16
16
|
export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
|
|
17
17
|
/**
|
|
@@ -242,7 +242,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
242
242
|
if (taskCtx) {
|
|
243
243
|
systemPrompt += taskCtx;
|
|
244
244
|
}
|
|
245
|
-
// Inject prior chat session context
|
|
245
|
+
// Inject prior chat session context. When the history overflows the budget,
|
|
246
|
+
// prepend an LLM recap of the dropped (oldest) messages so long sessions
|
|
247
|
+
// keep early decisions/constraints, then the recent messages verbatim.
|
|
248
|
+
const earlierSummary = await summarizeEarlierHistory(opts.chatHistory);
|
|
249
|
+
if (earlierSummary) {
|
|
250
|
+
systemPrompt += earlierSummary;
|
|
251
|
+
}
|
|
246
252
|
const chatHistoryStr = formatChatHistoryForAgent(opts.chatHistory);
|
|
247
253
|
if (chatHistoryStr) {
|
|
248
254
|
systemPrompt += chatHistoryStr;
|
|
@@ -52,6 +52,21 @@ export declare function formatChatHistoryForAgent(history?: Array<{
|
|
|
52
52
|
role: 'user' | 'assistant';
|
|
53
53
|
content: string;
|
|
54
54
|
}>, maxChars?: number): string;
|
|
55
|
+
/**
|
|
56
|
+
* Summarize the OVERFLOW that `formatChatHistoryForAgent` drops. When prior
|
|
57
|
+
* history exceeds `maxChars`, that function keeps only the most recent messages
|
|
58
|
+
* and silently discards the older ones — losing early decisions/constraints on
|
|
59
|
+
* long sessions. This condenses those dropped messages into a short recap that
|
|
60
|
+
* the caller prepends *before* the recent verbatim history.
|
|
61
|
+
*
|
|
62
|
+
* Returns '' when: opted out (`autoSummarizeHistory === false`), nothing
|
|
63
|
+
* overflows, or the summarization call fails (graceful fallback — the recent
|
|
64
|
+
* history still goes in, we just don't add a recap).
|
|
65
|
+
*/
|
|
66
|
+
export declare function summarizeEarlierHistory(history?: Array<{
|
|
67
|
+
role: 'user' | 'assistant';
|
|
68
|
+
content: string;
|
|
69
|
+
}>, maxChars?: number): Promise<string>;
|
|
55
70
|
export declare function getAgentSystemPrompt(projectContext: ProjectContext): string;
|
|
56
71
|
export declare function getFallbackSystemPrompt(projectContext: ProjectContext, additionalTools?: AdditionalToolDef[]): string;
|
|
57
72
|
/**
|
package/dist/utils/agentChat.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
|
+
import { createHash } from 'crypto';
|
|
16
17
|
import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
17
18
|
import { loadProjectIntelligence, generateContextFromIntelligence } from './projectIntelligence.js';
|
|
18
19
|
import { syncProgress, generateProjectId } from './codeepCloud.js';
|
|
@@ -180,6 +181,84 @@ export function formatChatHistoryForAgent(history, maxChars = 16000) {
|
|
|
180
181
|
const lines = selected.map(m => `**${m.role === 'user' ? 'User' : 'Assistant'}:** ${m.content}`).join('\n\n');
|
|
181
182
|
return `\n\n## Prior Conversation Context\nThe following is the recent chat history from this session. Use it as background context to understand the user's intent, but focus on completing the current task.\n\n${lines}`;
|
|
182
183
|
}
|
|
184
|
+
// Same noise filter formatChatHistoryForAgent uses — kept in sync so the two
|
|
185
|
+
// functions agree on which messages are "real" conversation.
|
|
186
|
+
function filterAgentHistory(history) {
|
|
187
|
+
return history.filter(m => {
|
|
188
|
+
const content = m.content.trimStart();
|
|
189
|
+
if (content.startsWith('[AGENT]') || content.startsWith('[DRY RUN]'))
|
|
190
|
+
return false;
|
|
191
|
+
if (content.startsWith('Agent completed') || content.startsWith('Agent failed') || content.startsWith('Agent stopped'))
|
|
192
|
+
return false;
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// Cache summaries by a hash of the dropped messages, so re-running the agent in
|
|
197
|
+
// the same session (same overflow) doesn't re-summarize on every task.
|
|
198
|
+
const earlierSummaryCache = new Map();
|
|
199
|
+
/**
|
|
200
|
+
* Summarize the OVERFLOW that `formatChatHistoryForAgent` drops. When prior
|
|
201
|
+
* history exceeds `maxChars`, that function keeps only the most recent messages
|
|
202
|
+
* and silently discards the older ones — losing early decisions/constraints on
|
|
203
|
+
* long sessions. This condenses those dropped messages into a short recap that
|
|
204
|
+
* the caller prepends *before* the recent verbatim history.
|
|
205
|
+
*
|
|
206
|
+
* Returns '' when: opted out (`autoSummarizeHistory === false`), nothing
|
|
207
|
+
* overflows, or the summarization call fails (graceful fallback — the recent
|
|
208
|
+
* history still goes in, we just don't add a recap).
|
|
209
|
+
*/
|
|
210
|
+
export async function summarizeEarlierHistory(history, maxChars = 16000) {
|
|
211
|
+
if (config.get('autoSummarizeHistory') === false)
|
|
212
|
+
return '';
|
|
213
|
+
if (!history || history.length === 0)
|
|
214
|
+
return '';
|
|
215
|
+
const filtered = filterAgentHistory(history);
|
|
216
|
+
if (filtered.length === 0)
|
|
217
|
+
return '';
|
|
218
|
+
// Mirror formatChatHistoryForAgent's newest→oldest budget walk to find which
|
|
219
|
+
// messages it KEEPS; everything older than the oldest kept message is dropped.
|
|
220
|
+
let totalChars = 0;
|
|
221
|
+
let firstKept = filtered.length;
|
|
222
|
+
for (let i = filtered.length - 1; i >= 0; i--) {
|
|
223
|
+
const entry = `${filtered[i].role === 'user' ? 'User' : 'Assistant'}: ${filtered[i].content}`;
|
|
224
|
+
if (totalChars + entry.length > maxChars && firstKept < filtered.length)
|
|
225
|
+
break;
|
|
226
|
+
if (entry.length > maxChars) {
|
|
227
|
+
firstKept = i;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
firstKept = i;
|
|
231
|
+
totalChars += entry.length;
|
|
232
|
+
}
|
|
233
|
+
const dropped = filtered.slice(0, firstKept);
|
|
234
|
+
if (dropped.length === 0)
|
|
235
|
+
return '';
|
|
236
|
+
const key = createHash('sha256')
|
|
237
|
+
.update(dropped.map(m => `${m.role}:${m.content}`).join(''))
|
|
238
|
+
.digest('hex');
|
|
239
|
+
const cached = earlierSummaryCache.get(key);
|
|
240
|
+
if (cached)
|
|
241
|
+
return cached;
|
|
242
|
+
// Compact transcript of the dropped messages, capped so the summarization
|
|
243
|
+
// prompt stays cheap even when a lot has overflowed.
|
|
244
|
+
const transcript = dropped
|
|
245
|
+
.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.replace(/\s+/g, ' ').slice(0, 600)}`)
|
|
246
|
+
.join('\n')
|
|
247
|
+
.slice(0, 24000);
|
|
248
|
+
const system = 'You are condensing the EARLIER part of an ongoing coding session that no longer fits the context window. Summarize what happened in 3-6 sentences: concrete decisions made, constraints/requirements stated, files or APIs involved, and anything still unfinished. Past tense, no preamble, no bullet headers — just the recap.';
|
|
249
|
+
try {
|
|
250
|
+
const { chat } = await import('../api/index.js');
|
|
251
|
+
const summary = (await chat(transcript, [{ role: 'system', content: system }])).trim();
|
|
252
|
+
if (!summary)
|
|
253
|
+
return '';
|
|
254
|
+
const block = `\n\n## Earlier Conversation (summarized)\nThe earlier part of this session was condensed to fit context. Treat it as established background:\n\n${summary}`;
|
|
255
|
+
earlierSummaryCache.set(key, block);
|
|
256
|
+
return block;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return ''; // graceful — recent verbatim history still gets injected
|
|
260
|
+
}
|
|
261
|
+
}
|
|
183
262
|
export function getAgentSystemPrompt(projectContext) {
|
|
184
263
|
const root = projectContext.root || process.cwd();
|
|
185
264
|
// State the real underlying model/provider so "which model are you"
|
|
@@ -110,9 +110,13 @@ export function reportStats(payload) {
|
|
|
110
110
|
const githubId = getGithubId();
|
|
111
111
|
if (!githubId)
|
|
112
112
|
return; // not linked, skip silently
|
|
113
|
+
// Send the sync token so the server can attribute the event to us. The
|
|
114
|
+
// server derives github_id from the token and ignores the body value (the
|
|
115
|
+
// body githubId is kept only for backward-compat with older servers).
|
|
116
|
+
const syncToken = getSyncToken();
|
|
113
117
|
fetchWithRetry(`${API_BASE}/api/stats`, {
|
|
114
118
|
method: 'POST',
|
|
115
|
-
headers: { 'Content-Type': 'application/json' },
|
|
119
|
+
headers: { 'Content-Type': 'application/json', ...(syncToken ? { 'x-sync-token': syncToken } : {}) },
|
|
116
120
|
body: JSON.stringify({ ...payload, githubId, isGit: payload.isGit ?? false }),
|
|
117
121
|
}).catch(() => { });
|
|
118
122
|
}
|
|
@@ -120,9 +124,10 @@ export async function reportStatsAsync(payload) {
|
|
|
120
124
|
const githubId = getGithubId();
|
|
121
125
|
if (!githubId)
|
|
122
126
|
return;
|
|
127
|
+
const syncToken = getSyncToken();
|
|
123
128
|
await fetchWithRetry(`${API_BASE}/api/stats`, {
|
|
124
129
|
method: 'POST',
|
|
125
|
-
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
headers: { 'Content-Type': 'application/json', ...(syncToken ? { 'x-sync-token': syncToken } : {}) },
|
|
126
131
|
body: JSON.stringify({ ...payload, githubId, isGit: payload.isGit ?? false }),
|
|
127
132
|
});
|
|
128
133
|
}
|
package/dist/utils/hooks.d.ts
CHANGED
|
@@ -45,6 +45,9 @@
|
|
|
45
45
|
* banner warns when hooks exist (see `summarizeHooks`); we do not run
|
|
46
46
|
* hooks from `~/.codeep/hooks/` (global) for that reason.
|
|
47
47
|
*/
|
|
48
|
+
export declare function isHooksTrusted(workspaceRoot: string): boolean;
|
|
49
|
+
export declare function trustWorkspaceHooks(workspaceRoot: string): void;
|
|
50
|
+
export declare function untrustWorkspaceHooks(workspaceRoot: string): void;
|
|
48
51
|
export type HookEvent = 'pre_tool_call' | 'post_edit' | 'on_error' | 'pre_commit';
|
|
49
52
|
export declare const HOOK_EVENTS: readonly HookEvent[];
|
|
50
53
|
export interface HookContext {
|
|
@@ -69,6 +72,9 @@ export interface HookResult {
|
|
|
69
72
|
blocked: boolean;
|
|
70
73
|
/** Path that was executed (useful for error messages). */
|
|
71
74
|
scriptPath?: string;
|
|
75
|
+
/** True when a hook script exists but the workspace isn't trusted, so it was
|
|
76
|
+
* skipped (not run). Lets callers surface "run /hooks trust to enable". */
|
|
77
|
+
untrusted?: boolean;
|
|
72
78
|
}
|
|
73
79
|
/**
|
|
74
80
|
* Execute the configured hook for an event, if any. Returns `executed: false`
|
|
@@ -90,6 +96,11 @@ export declare function listInstalledHooks(workspaceRoot: string): {
|
|
|
90
96
|
* Render an installed-hook list as Markdown for `/hooks` output.
|
|
91
97
|
*/
|
|
92
98
|
export declare function formatHookList(hooks: ReturnType<typeof listInstalledHooks>): string;
|
|
99
|
+
/**
|
|
100
|
+
* Build the trust banner for `/hooks` and the welcome screen. `workspaceRoot`
|
|
101
|
+
* is needed to read trust state; returns '' if no hooks are installed.
|
|
102
|
+
*/
|
|
103
|
+
export declare function formatHookTrust(workspaceRoot: string): string;
|
|
93
104
|
/**
|
|
94
105
|
* Short one-line summary used in the welcome banner when hooks are present.
|
|
95
106
|
* Returns empty string if no hooks installed.
|
package/dist/utils/hooks.js
CHANGED
|
@@ -48,6 +48,31 @@
|
|
|
48
48
|
import { existsSync, readdirSync, statSync, accessSync, constants } from 'fs';
|
|
49
49
|
import { join } from 'path';
|
|
50
50
|
import { spawnSync } from 'child_process';
|
|
51
|
+
import { config } from '../config/index.js';
|
|
52
|
+
// ─── Trust-on-first-use ──────────────────────────────────────────────────────
|
|
53
|
+
// Project-local hooks run arbitrary shell, so a freshly-cloned hostile repo
|
|
54
|
+
// must NOT execute its scripts on the first tool call. A workspace's hooks run
|
|
55
|
+
// only after the user explicitly trusts it (`/hooks trust`); the approval is
|
|
56
|
+
// stored per-workspace-root in config. Mirrors VS Code Workspace Trust /
|
|
57
|
+
// `direnv allow`.
|
|
58
|
+
export function isHooksTrusted(workspaceRoot) {
|
|
59
|
+
try {
|
|
60
|
+
const trusted = config.get('trustedHookProjects');
|
|
61
|
+
return Array.isArray(trusted) && trusted.includes(workspaceRoot);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function trustWorkspaceHooks(workspaceRoot) {
|
|
68
|
+
const cur = config.get('trustedHookProjects') ?? [];
|
|
69
|
+
if (!cur.includes(workspaceRoot))
|
|
70
|
+
config.set('trustedHookProjects', [...cur, workspaceRoot]);
|
|
71
|
+
}
|
|
72
|
+
export function untrustWorkspaceHooks(workspaceRoot) {
|
|
73
|
+
const cur = config.get('trustedHookProjects') ?? [];
|
|
74
|
+
config.set('trustedHookProjects', cur.filter((p) => p !== workspaceRoot));
|
|
75
|
+
}
|
|
51
76
|
export const HOOK_EVENTS = ['pre_tool_call', 'post_edit', 'on_error', 'pre_commit'];
|
|
52
77
|
/** Events whose non-zero exit aborts the action that triggered them. */
|
|
53
78
|
const BLOCKING_EVENTS = new Set(['pre_tool_call', 'pre_commit']);
|
|
@@ -89,6 +114,12 @@ export function runHook(ctx, opts = {}) {
|
|
|
89
114
|
const script = findHookScript(ctx.workspaceRoot, ctx.event);
|
|
90
115
|
if (!script)
|
|
91
116
|
return NOT_EXECUTED;
|
|
117
|
+
// Trust gate: never run a project's hooks until the user has approved this
|
|
118
|
+
// workspace. A non-blocking skip — the agent proceeds without the hook
|
|
119
|
+
// rather than being held hostage by an untrusted (or hostile) script.
|
|
120
|
+
if (!isHooksTrusted(ctx.workspaceRoot)) {
|
|
121
|
+
return { executed: false, exitCode: 0, stdout: '', stderr: '', blocked: false, untrusted: true, scriptPath: script };
|
|
122
|
+
}
|
|
92
123
|
const env = {
|
|
93
124
|
...process.env,
|
|
94
125
|
CODEEP_HOOK_EVENT: ctx.event,
|
|
@@ -211,6 +242,22 @@ export function formatHookList(hooks) {
|
|
|
211
242
|
}
|
|
212
243
|
return lines.join('\n');
|
|
213
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Build the trust banner for `/hooks` and the welcome screen. `workspaceRoot`
|
|
247
|
+
* is needed to read trust state; returns '' if no hooks are installed.
|
|
248
|
+
*/
|
|
249
|
+
export function formatHookTrust(workspaceRoot) {
|
|
250
|
+
const hooks = listInstalledHooks(workspaceRoot);
|
|
251
|
+
if (hooks.length === 0)
|
|
252
|
+
return '';
|
|
253
|
+
if (isHooksTrusted(workspaceRoot)) {
|
|
254
|
+
return '✓ This workspace is **trusted** — its hooks will run. Use `/hooks untrust` to revoke.';
|
|
255
|
+
}
|
|
256
|
+
return [
|
|
257
|
+
'⚠️ This workspace is **not trusted**, so its hooks are **skipped** (they run arbitrary shell).',
|
|
258
|
+
'If you wrote these hooks (or trust this repo), run `/hooks trust` to enable them.',
|
|
259
|
+
].join('\n');
|
|
260
|
+
}
|
|
214
261
|
/**
|
|
215
262
|
* Short one-line summary used in the welcome banner when hooks are present.
|
|
216
263
|
* Returns empty string if no hooks installed.
|
|
@@ -219,5 +266,9 @@ export function summarizeHooks(workspaceRoot) {
|
|
|
219
266
|
const hooks = listInstalledHooks(workspaceRoot);
|
|
220
267
|
if (hooks.length === 0)
|
|
221
268
|
return '';
|
|
222
|
-
|
|
269
|
+
const list = hooks.map(h => h.event).join(', ');
|
|
270
|
+
if (!isHooksTrusted(workspaceRoot)) {
|
|
271
|
+
return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} present but NOT trusted — run /hooks trust to enable (${list})`;
|
|
272
|
+
}
|
|
273
|
+
return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} active (${list})`;
|
|
223
274
|
}
|
package/dist/utils/shell.js
CHANGED
|
@@ -74,6 +74,37 @@ const ALLOWED_COMMANDS = new Set([
|
|
|
74
74
|
// HTTP tools
|
|
75
75
|
'http', 'https',
|
|
76
76
|
]);
|
|
77
|
+
// Interpreter flags that execute inline code straight from the command line.
|
|
78
|
+
// Without this check, a whitelisted runtime (`node`, `python`, …) becomes
|
|
79
|
+
// arbitrary code execution — `node -e "<anything>"`, `python -c "<anything>"` —
|
|
80
|
+
// bypassing the command whitelist entirely. File execution (`node app.js`)
|
|
81
|
+
// stays allowed; only the eval flags are blocked.
|
|
82
|
+
const INLINE_EVAL_SHORT = {
|
|
83
|
+
node: ['e', 'p'], bun: ['e'], python: ['c'], python3: ['c'], php: ['r'], ruby: ['e'], perl: ['e', 'E'],
|
|
84
|
+
};
|
|
85
|
+
const INLINE_EVAL_LONG = {
|
|
86
|
+
node: ['--eval', '--print'], deno: ['eval'], bun: ['--eval'],
|
|
87
|
+
};
|
|
88
|
+
function hasInlineEval(command, args) {
|
|
89
|
+
const short = INLINE_EVAL_SHORT[command] ?? [];
|
|
90
|
+
const long = INLINE_EVAL_LONG[command] ?? [];
|
|
91
|
+
if (short.length === 0 && long.length === 0)
|
|
92
|
+
return false;
|
|
93
|
+
for (const arg of args) {
|
|
94
|
+
if (arg.startsWith('--')) {
|
|
95
|
+
if (long.includes(arg.split('=')[0]))
|
|
96
|
+
return true; // --eval / --print(=...)
|
|
97
|
+
}
|
|
98
|
+
else if (arg.length > 1 && arg.startsWith('-')) {
|
|
99
|
+
if (arg.slice(1).split('').some((l) => short.includes(l)))
|
|
100
|
+
return true; // -e, -c, -pe …
|
|
101
|
+
}
|
|
102
|
+
else if (long.includes(arg)) {
|
|
103
|
+
return true; // bare subcommand, e.g. `deno eval`
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
77
108
|
/**
|
|
78
109
|
* Validate if a command is safe to execute
|
|
79
110
|
*/
|
|
@@ -86,6 +117,11 @@ export function validateCommand(command, args, options) {
|
|
|
86
117
|
if (!ALLOWED_COMMANDS.has(command)) {
|
|
87
118
|
return { valid: false, reason: `Command '${command}' is not in the allowed list` };
|
|
88
119
|
}
|
|
120
|
+
// Block inline-code execution that would turn a whitelisted interpreter into
|
|
121
|
+
// arbitrary code execution (the whitelist alone doesn't stop `node -e "…"`).
|
|
122
|
+
if (hasInlineEval(command, args)) {
|
|
123
|
+
return { valid: false, reason: `Inline code execution via '${command}' (e.g. -e/-c/--eval) is not allowed in agent mode — put the code in a file and run that, or run it yourself.` };
|
|
124
|
+
}
|
|
89
125
|
// Check full command string against dangerous patterns
|
|
90
126
|
const fullCommand = `${command} ${args.join(' ')}`;
|
|
91
127
|
for (const pattern of BLOCKED_PATTERNS) {
|
|
@@ -16,6 +16,82 @@ import { getZaiMcpConfig, getZaiVisionConfig, getMinimaxMcpConfig, callZaiMcp, c
|
|
|
16
16
|
import { logger } from './logger.js';
|
|
17
17
|
import { runHook } from './hooks.js';
|
|
18
18
|
import { isMcpToolName, callSessionTool, isVirtualMcpToolName, callSessionVirtualTool } from './mcpRegistry.js';
|
|
19
|
+
import { lookup as dnsLookup } from 'dns/promises';
|
|
20
|
+
/**
|
|
21
|
+
* SSRF guard for the agent's `fetch_url` tool. The URL there comes from model
|
|
22
|
+
* output / page content (untrusted, prompt-injectable), so the agent must not
|
|
23
|
+
* be able to reach internal services or the cloud metadata endpoint
|
|
24
|
+
* (169.254.169.254). NOTE: this does NOT apply to user-configured provider
|
|
25
|
+
* base URLs (Ollama localhost, custom vLLM/Tailscale endpoints) — those are
|
|
26
|
+
* trusted config and never routed through fetch_url.
|
|
27
|
+
*/
|
|
28
|
+
function isBlockedIp(ip) {
|
|
29
|
+
const s = ip.trim().toLowerCase();
|
|
30
|
+
if (s.includes(':')) {
|
|
31
|
+
// IPv6
|
|
32
|
+
if (s === '::1' || s === '::')
|
|
33
|
+
return true; // loopback / unspecified
|
|
34
|
+
if (s.startsWith('fe80') || s.startsWith('fc') || s.startsWith('fd'))
|
|
35
|
+
return true; // link-local / ULA
|
|
36
|
+
const mapped = s.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped
|
|
37
|
+
if (mapped)
|
|
38
|
+
return isBlockedIp(mapped[1]);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const parts = s.split('.').map(Number);
|
|
42
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255))
|
|
43
|
+
return false;
|
|
44
|
+
const [a, b] = parts;
|
|
45
|
+
if (a === 127)
|
|
46
|
+
return true; // loopback
|
|
47
|
+
if (a === 10)
|
|
48
|
+
return true; // RFC1918
|
|
49
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
50
|
+
return true; // RFC1918
|
|
51
|
+
if (a === 192 && b === 168)
|
|
52
|
+
return true; // RFC1918
|
|
53
|
+
if (a === 169 && b === 254)
|
|
54
|
+
return true; // link-local incl. metadata 169.254.169.254
|
|
55
|
+
if (a === 0)
|
|
56
|
+
return true; // 0.0.0.0/8
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/** Returns an error string if the URL must not be fetched, else null. */
|
|
60
|
+
async function assertFetchUrlAllowed(rawUrl) {
|
|
61
|
+
let u;
|
|
62
|
+
try {
|
|
63
|
+
u = new URL(rawUrl);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'Invalid URL format';
|
|
67
|
+
}
|
|
68
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
69
|
+
return `Blocked: only http/https URLs can be fetched (got "${u.protocol}")`;
|
|
70
|
+
}
|
|
71
|
+
const host = u.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
|
|
72
|
+
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
73
|
+
return 'Blocked: localhost is not fetchable by the agent';
|
|
74
|
+
}
|
|
75
|
+
if (/^[0-9.]+$/.test(host) || host.includes(':')) {
|
|
76
|
+
// Literal IP — check directly.
|
|
77
|
+
if (isBlockedIp(host))
|
|
78
|
+
return `Blocked: ${host} is a private/loopback/link-local address`;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
// Resolve and check every address (catches internal hostnames + single-record rebinding).
|
|
82
|
+
try {
|
|
83
|
+
const addrs = await dnsLookup(host, { all: true });
|
|
84
|
+
for (const a of addrs) {
|
|
85
|
+
if (isBlockedIp(a.address)) {
|
|
86
|
+
return `Blocked: ${host} resolves to a private/internal address (${a.address})`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// DNS failure — let curl attempt and fail naturally; not an SSRF risk.
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
19
95
|
const debug = (...args) => {
|
|
20
96
|
if (process.env.CODEEP_DEBUG === '1') {
|
|
21
97
|
logger.debug(args.map(String).join(' '));
|
|
@@ -515,13 +591,13 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
|
|
|
515
591
|
const url = parameters.url;
|
|
516
592
|
if (!url)
|
|
517
593
|
return { success: false, output: '', error: 'Missing required parameter: url', tool, parameters };
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const result = await executeCommandAsync('curl', ['-s', '-L', '-m', '30', '-A', 'Codeep/1.0', '--max-filesize', '1000000', url], {
|
|
594
|
+
const blockedReason = await assertFetchUrlAllowed(url);
|
|
595
|
+
if (blockedReason)
|
|
596
|
+
return { success: false, output: '', error: blockedReason, tool, parameters };
|
|
597
|
+
// Restrict to http/https on the initial request AND redirects, and cap
|
|
598
|
+
// redirect hops — defends against protocol-smuggling and limits
|
|
599
|
+
// redirect-based SSRF reach (initial host is already IP-checked above).
|
|
600
|
+
const result = await executeCommandAsync('curl', ['-s', '-L', '--proto', '=http,https', '--proto-redir', '=http,https', '--max-redirs', '5', '-m', '30', '-A', 'Codeep/1.0', '--max-filesize', '1000000', url], {
|
|
525
601
|
cwd: projectRoot,
|
|
526
602
|
projectRoot,
|
|
527
603
|
timeout: 35000,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
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",
|