codeep 2.1.1 → 2.1.3

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 CHANGED
@@ -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. Hooks
453
- trigger a security banner on session start since they're arbitrary shell that
454
- runs whenever an agent tool fires.
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
@@ -1296,3 +1301,25 @@ The chat sidebar now surfaces two extra ACP signals that previously only the TUI
1296
1301
  - **Diff preview on permission prompts** — manual-mode permission cards now show a `-` / `+` diff for `edit_file`, a content preview for `write_file`, and the full `$ command` + `cwd` for `execute_command`, so users can verify before clicking *Allow*. Payload is truncated (~4 KB per field, 200 lines per file) with a visible marker. Other ACP clients (Zed, etc.) ignore the extra fields silently.
1297
1302
  - **`@file` mentions** — type `@` in the chat input to open a workspace-wide file picker. Pick with arrow keys + Enter; the file's content is inlined into the prompt as an `[Attached files]` preamble. Files over 200 KB are skipped with a marker. Multiple mentions in one message are deduplicated.
1298
1303
  - **Auto-reconnect** — if the CLI process exits unexpectedly (crash, OOM kill, parent restart), the extension reconnects on its own with exponential backoff (1s → 2s → 4s → 8s → 16s → 30s, capped at 6 attempts). The status bar shows the countdown. Configurable idle-watchdog timeout (`codeep.requestTimeoutMinutes`, default 5 min) replaces the old fixed cap so slow reasoning models don't get killed mid-thought.
1304
+
1305
+ ### Editor-native actions (new in 2.2)
1306
+
1307
+ - **Code Actions (lightbulb)** — select code and press `Ctrl+.` for **Explain**, **Improve / refactor**, **Add tests**, and **Add doc comment**. On a line with an error/warning, a **Fix this problem** quick-fix sends the diagnostic plus the code to Codeep. Everything routes through the chat, so the full agent (file edits via the diff preview, MCP tools) is available.
1308
+ - **Model picker in the status bar** — click `Codeep · <model>` (or run **Codeep: Select Provider & Model**) to switch provider + model from a quick-pick. Providers with open-ended catalogs (OpenRouter, Ollama, custom endpoints) let you type a model id.
1309
+ - **Self-hosted endpoints from settings** — point the extension at vLLM / LiteLLM / LM Studio / text-generation-webui with `codeep.baseUrl` (e.g. `http://localhost:8000/v1`), plus `codeep.provider` (`custom` or `openai`) and `codeep.model`. The `codeep.provider` / `codeep.model` settings are applied on every connect, so they stay authoritative.
1310
+ - **Get Started walkthrough** — a native VS Code walkthrough (Help → Get Started) covering CLI install, opening the chat, editor actions, and choosing a model.
1311
+
1312
+ > The 2.2 model picker and `custom`-provider settings need CLI **2.1.2+**; they degrade gracefully on older CLIs (free-text model input, and `codeep.baseUrl` still works for the `openai` provider via `OPENAI_BASE_URL`).
1313
+
1314
+ ### Native chat & agent integration (new in 2.3)
1315
+
1316
+ - **`@codeep` chat participant** — invoke Codeep from VS Code's built-in Chat view: type `@codeep` and ask, or `@codeep /explain` / `@codeep /review` with a selection. Answers come from your configured Codeep provider/model via the CLI (not VS Code's model picker), on an independent session.
1317
+ - **`#codeepSkills` language-model tool** — exposes your workspace's Codeep skill bundles (`.codeep/skills/*/SKILL.md`) to VS Code agent mode and `#`-references, so the native agent can follow your project's own workflows.
1318
+ - Requires VS Code **1.95+** (for the stable Chat Participant + Language Model Tools APIs).
1319
+
1320
+ ### Source control & sidebar (new in 2.3)
1321
+
1322
+ - **Generate Commit Message** — a sparkle button in the Source Control panel (and `Codeep: Generate Commit Message` in the palette) reads your staged diff and writes a Conventional Commits message into the commit box. Falls back to the working-tree diff if nothing is staged; asks before replacing a message you've already typed.
1323
+ - **Sessions tree view** — a native **Sessions** view in the Codeep sidebar lists saved conversations (title + age). Click to load into the chat, inline-delete, or start a New Session from the title bar.
1324
+ - **MCP config validation** — `.codeep/mcp_servers.json` (project and global) gets JSON schema validation + autocomplete, so a mistyped `command` / `args` / `env` is caught before a session starts.
1325
+ - **Workspace Trust** — declares limited support for untrusted workspaces, with a reminder to review permission prompts carefully (Codeep runs a local agent that can edit files and run commands).
@@ -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
- return { handled: true, response: formatHookList(listInstalledHooks(session.workspaceRoot)) };
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();
@@ -752,6 +752,18 @@ export function startAcpServer() {
752
752
  config.set('model', value);
753
753
  }
754
754
  }
755
+ else if (configId === 'provider' && typeof value === 'string') {
756
+ // Switch provider without specifying a model — picks the provider's
757
+ // default model + protocol. Used by editor clients that pin a provider
758
+ // in their settings (e.g. the VS Code `codeep.provider` setting).
759
+ setProvider(value);
760
+ }
761
+ else if (configId === 'customBaseUrl' && typeof value === 'string') {
762
+ // Base URL for the `custom` (OpenAI-compatible) provider — lets editor
763
+ // clients point Codeep at a self-hosted endpoint (vLLM/LiteLLM/LM Studio)
764
+ // without hand-editing ~/.codeep/config.json.
765
+ config.set('customBaseUrl', value);
766
+ }
755
767
  else if (configId === 'language' && typeof value === 'string') {
756
768
  config.set('language', value);
757
769
  }
@@ -837,6 +849,14 @@ export function startAcpServer() {
837
849
  hint: p.hint ?? p.description,
838
850
  requiresKey: !p.noApiKey,
839
851
  subscribeUrl: p.subscribeUrl,
852
+ // Model metadata so ACP clients (e.g. the VS Code model picker) can
853
+ // offer a provider → model selector without hardcoding a catalog.
854
+ // `dynamicModels` flags providers whose model list is open-ended
855
+ // (OpenRouter, Ollama, custom endpoints) — clients should let the
856
+ // user type a model id rather than only pick from `models`.
857
+ models: p.models.map((m) => ({ id: m.id, name: m.name })),
858
+ defaultModel: p.defaultModel,
859
+ dynamicModels: p.dynamicModels ?? false,
840
860
  }));
841
861
  transport.respond(msg.id, { providers });
842
862
  }
@@ -27,6 +27,10 @@ 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
+ /** Absolute workspace roots whose project-local `.codeep/hooks/*` the user
31
+ * has approved to run. Untrusted projects' hooks are skipped (a cloned repo
32
+ * can't execute shell on first tool call). Granted via `/hooks trust`. */
33
+ trustedHookProjects: string[];
30
34
  currentSessionId: string;
31
35
  temperature: number;
32
36
  maxTokens: number;
@@ -167,6 +167,7 @@ function createConfig() {
167
167
  language: 'en',
168
168
  autoSave: true,
169
169
  autoSessionTitle: true,
170
+ trustedHookProjects: [],
170
171
  currentSessionId: '',
171
172
  temperature: 0.7,
172
173
  maxTokens: 32768,
@@ -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
- ctx.app.addMessage({ role: 'system', content: formatHookList(listInstalledHooks(ctx.projectPath)) });
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': {
@@ -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
  }
@@ -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.
@@ -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
- return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} active (${hooks.map(h => h.event).join(', ')})`;
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
  }
@@ -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
- try {
519
- new URL(url);
520
- }
521
- catch {
522
- return { success: false, output: '', error: 'Invalid URL format', tool, parameters };
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.1",
3
+ "version": "2.1.3",
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",