Skip to content

Commit f804867

Browse files
feat: implement trust-on-first-use for project hooks and enhance SSRF protection in fetch_url tool
1 parent 3bd527b commit f804867

10 files changed

Lines changed: 239 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@ For releases before v1.3.35, see [GitHub Releases](https://github.com/VladoIvank
1111
> as the social-share summary (IFTTT → X/Bluesky), capped at 220 chars.
1212
> If omitted, the feed falls back to the first paragraph.
1313
14+
## [2.1.3] — 2026-05-22
15+
16+
> Security hardening: project hooks now require trust before they run, the web-fetch tool blocks internal/metadata addresses, and usage stats are sent with your sync token.
17+
18+
### Security
19+
20+
- **Hooks now require trust-on-first-use.** Project-local `.codeep/hooks/*` run
21+
arbitrary shell, so a freshly-cloned repo could previously execute its scripts
22+
on your first tool call. Hooks in an unapproved workspace are now **skipped**
23+
until you run `/hooks trust` (revoke with `/hooks untrust`). `/hooks` and the
24+
welcome banner show the trust state. Your own already-set-up projects just need
25+
a one-time `/hooks trust`.
26+
- **SSRF guard on the `fetch_url` web tool.** The agent can no longer be steered
27+
(e.g. via prompt injection) into fetching `localhost`, private/RFC1918, or
28+
link-local addresses — including the cloud metadata endpoint
29+
`169.254.169.254`. Only `http`/`https` are allowed, on the initial request and
30+
redirects. Your configured provider endpoints (Ollama, custom vLLM/Tailscale)
31+
are unaffected — they don't go through this tool.
32+
33+
### Changed
34+
35+
- **Stats reporting now sends the `x-sync-token` header.** The dashboard derives
36+
your GitHub id from the token instead of trusting the `githubId` in the request
37+
body, closing a spoofing gap where anyone could forge usage events (or unarchive
38+
projects) for another user. Stats keep working on older CLIs — they're just
39+
recorded anonymously until you upgrade. No behavior change for you locally.
40+
1441
## [2.1.2] — 2026-05-21
1542

1643
> ACP server enhancements that power the new Codeep VS Code 2.2 features — editor clients can now list models per provider and pin a provider, model, or custom endpoint over the protocol.

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -449,9 +449,14 @@ Example — auto-format on edit (`.codeep/hooks/post_edit.sh`):
449449
prettier --write "$CODEEP_HOOK_FILE" 2>/dev/null
450450
```
451451

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.
455460

456461
### Skill Bundles (new in 2.0)
457462
Beyond the built-in skills and custom slash commands, Codeep now supports

src/acp/commands.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -983,8 +983,18 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
983983
}
984984

985985
case 'hooks': {
986-
const { listInstalledHooks, formatHookList } = await import('../utils/hooks.js');
987-
return { handled: true, response: formatHookList(listInstalledHooks(session.workspaceRoot)) };
986+
const { listInstalledHooks, formatHookList, formatHookTrust, trustWorkspaceHooks, untrustWorkspaceHooks } = await import('../utils/hooks.js');
987+
const sub = (args[0] || '').toLowerCase();
988+
if (sub === 'trust') {
989+
trustWorkspaceHooks(session.workspaceRoot);
990+
return { handled: true, response: 'Hooks trusted for this workspace — they will now run.' };
991+
}
992+
if (sub === 'untrust') {
993+
untrustWorkspaceHooks(session.workspaceRoot);
994+
return { handled: true, response: 'Hooks untrusted — they will be skipped until you trust again.' };
995+
}
996+
const trust = formatHookTrust(session.workspaceRoot);
997+
return { handled: true, response: formatHookList(listInstalledHooks(session.workspaceRoot)) + (trust ? `\n\n${trust}` : '') };
988998
}
989999

9901000
case 'mcp': {

src/config/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ interface ConfigSchema {
5656
* small background API call (uses the active model) once per session.
5757
* Default true; set false to avoid any unsolicited API calls. */
5858
autoSessionTitle: boolean;
59+
/** Absolute workspace roots whose project-local `.codeep/hooks/*` the user
60+
* has approved to run. Untrusted projects' hooks are skipped (a cloned repo
61+
* can't execute shell on first tool call). Granted via `/hooks trust`. */
62+
trustedHookProjects: string[];
5963
currentSessionId: string;
6064
temperature: number;
6165
maxTokens: number;
@@ -277,6 +281,7 @@ function createConfig(): Conf<ConfigSchema> {
277281
language: 'en',
278282
autoSave: true,
279283
autoSessionTitle: true,
284+
trustedHookProjects: [],
280285
currentSessionId: '',
281286
temperature: 0.7,
282287
maxTokens: 32768,

src/renderer/commands.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,8 +1138,21 @@ Format: use headers per category, only include categories where you found issues
11381138
}
11391139

11401140
case 'hooks': {
1141-
const { listInstalledHooks, formatHookList } = await import('../utils/hooks');
1142-
ctx.app.addMessage({ role: 'system', content: formatHookList(listInstalledHooks(ctx.projectPath)) });
1141+
const { listInstalledHooks, formatHookList, formatHookTrust, trustWorkspaceHooks, untrustWorkspaceHooks } = await import('../utils/hooks');
1142+
const sub = (args[0] || '').toLowerCase();
1143+
if (sub === 'trust') {
1144+
trustWorkspaceHooks(ctx.projectPath);
1145+
ctx.app.notify('Hooks trusted for this workspace — they will now run.');
1146+
break;
1147+
}
1148+
if (sub === 'untrust') {
1149+
untrustWorkspaceHooks(ctx.projectPath);
1150+
ctx.app.notify('Hooks untrusted — they will be skipped until you trust again.');
1151+
break;
1152+
}
1153+
const trust = formatHookTrust(ctx.projectPath);
1154+
const body = formatHookList(listInstalledHooks(ctx.projectPath)) + (trust ? `\n\n${trust}` : '');
1155+
ctx.app.addMessage({ role: 'system', content: body });
11431156
break;
11441157
}
11451158

src/utils/codeepCloud.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,13 @@ export function reportStats(payload: StatsPayload): void {
140140
const githubId = getGithubId();
141141
if (!githubId) return; // not linked, skip silently
142142

143+
// Send the sync token so the server can attribute the event to us. The
144+
// server derives github_id from the token and ignores the body value (the
145+
// body githubId is kept only for backward-compat with older servers).
146+
const syncToken = getSyncToken();
143147
fetchWithRetry(`${API_BASE}/api/stats`, {
144148
method: 'POST',
145-
headers: { 'Content-Type': 'application/json' },
149+
headers: { 'Content-Type': 'application/json', ...(syncToken ? { 'x-sync-token': syncToken } : {}) },
146150
body: JSON.stringify({ ...payload, githubId, isGit: payload.isGit ?? false }),
147151
}).catch(() => {});
148152
}
@@ -151,9 +155,10 @@ export async function reportStatsAsync(payload: StatsPayload): Promise<void> {
151155
const githubId = getGithubId();
152156
if (!githubId) return;
153157

158+
const syncToken = getSyncToken();
154159
await fetchWithRetry(`${API_BASE}/api/stats`, {
155160
method: 'POST',
156-
headers: { 'Content-Type': 'application/json' },
161+
headers: { 'Content-Type': 'application/json', ...(syncToken ? { 'x-sync-token': syncToken } : {}) },
157162
body: JSON.stringify({ ...payload, githubId, isGit: payload.isGit ?? false }),
158163
});
159164
}

src/utils/hooks.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
listInstalledHooks,
88
formatHookList,
99
summarizeHooks,
10+
trustWorkspaceHooks,
11+
untrustWorkspaceHooks,
1012
HookEvent,
1113
} from './hooks';
1214

@@ -17,9 +19,13 @@ beforeEach(() => {
1719
workspaceRoot = mkdtempSync(join(tmpdir(), 'codeep-hooks-'));
1820
hooksDir = join(workspaceRoot, '.codeep', 'hooks');
1921
mkdirSync(hooksDir, { recursive: true });
22+
// Hooks are skipped in untrusted workspaces (trust-on-first-use). These tests
23+
// exercise the run path, so trust the temp workspace up front.
24+
trustWorkspaceHooks(workspaceRoot);
2025
});
2126

2227
afterEach(() => {
28+
untrustWorkspaceHooks(workspaceRoot);
2329
rmSync(workspaceRoot, { recursive: true, force: true });
2430
});
2531

@@ -50,6 +56,35 @@ describe('runHook — no script present', () => {
5056
});
5157
});
5258

59+
describe('runHook — trust gate', () => {
60+
it('skips an existing hook when the workspace is not trusted', () => {
61+
writeHook('post_edit', 'echo "should not run"');
62+
untrustWorkspaceHooks(workspaceRoot);
63+
const result = runHook({ event: 'post_edit', workspaceRoot });
64+
expect(result.executed).toBe(false);
65+
expect(result.untrusted).toBe(true);
66+
expect(result.stdout).toBe('');
67+
});
68+
69+
it('does not block a tool call when an untrusted blocking hook is skipped', () => {
70+
writeHook('pre_tool_call', 'exit 1'); // would block if it ran
71+
untrustWorkspaceHooks(workspaceRoot);
72+
const result = runHook({ event: 'pre_tool_call', workspaceRoot });
73+
expect(result.executed).toBe(false);
74+
expect(result.blocked).toBe(false);
75+
});
76+
77+
it('runs the hook again once the workspace is re-trusted', () => {
78+
writeHook('post_edit', 'echo "trusted run"');
79+
untrustWorkspaceHooks(workspaceRoot);
80+
expect(runHook({ event: 'post_edit', workspaceRoot }).executed).toBe(false);
81+
trustWorkspaceHooks(workspaceRoot);
82+
const result = runHook({ event: 'post_edit', workspaceRoot });
83+
expect(result.executed).toBe(true);
84+
expect(result.stdout).toMatch(/trusted run/);
85+
});
86+
});
87+
5388
describe('runHook — script present', () => {
5489
it('runs the script and captures stdout/stderr', () => {
5590
writeHook('post_edit', 'echo "hello from hook"; echo "err msg" >&2');

src/utils/hooks.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@
4949
import { existsSync, readdirSync, statSync, accessSync, constants } from 'fs';
5050
import { join } from 'path';
5151
import { spawnSync } from 'child_process';
52+
import { config } from '../config/index.js';
53+
54+
// ─── Trust-on-first-use ──────────────────────────────────────────────────────
55+
// Project-local hooks run arbitrary shell, so a freshly-cloned hostile repo
56+
// must NOT execute its scripts on the first tool call. A workspace's hooks run
57+
// only after the user explicitly trusts it (`/hooks trust`); the approval is
58+
// stored per-workspace-root in config. Mirrors VS Code Workspace Trust /
59+
// `direnv allow`.
60+
61+
export function isHooksTrusted(workspaceRoot: string): boolean {
62+
try {
63+
const trusted = config.get('trustedHookProjects') as string[] | undefined;
64+
return Array.isArray(trusted) && trusted.includes(workspaceRoot);
65+
} catch {
66+
return false;
67+
}
68+
}
69+
70+
export function trustWorkspaceHooks(workspaceRoot: string): void {
71+
const cur = (config.get('trustedHookProjects') as string[] | undefined) ?? [];
72+
if (!cur.includes(workspaceRoot)) config.set('trustedHookProjects', [...cur, workspaceRoot]);
73+
}
74+
75+
export function untrustWorkspaceHooks(workspaceRoot: string): void {
76+
const cur = (config.get('trustedHookProjects') as string[] | undefined) ?? [];
77+
config.set('trustedHookProjects', cur.filter((p) => p !== workspaceRoot));
78+
}
5279

5380
export type HookEvent = 'pre_tool_call' | 'post_edit' | 'on_error' | 'pre_commit';
5481
export const HOOK_EVENTS: readonly HookEvent[] = ['pre_tool_call', 'post_edit', 'on_error', 'pre_commit'];
@@ -79,6 +106,9 @@ export interface HookResult {
79106
blocked: boolean;
80107
/** Path that was executed (useful for error messages). */
81108
scriptPath?: string;
109+
/** True when a hook script exists but the workspace isn't trusted, so it was
110+
* skipped (not run). Lets callers surface "run /hooks trust to enable". */
111+
untrusted?: boolean;
82112
}
83113

84114
const NOT_EXECUTED: HookResult = { executed: false, exitCode: 0, stdout: '', stderr: '', blocked: false };
@@ -118,6 +148,13 @@ export function runHook(ctx: HookContext, opts: { timeoutMs?: number } = {}): Ho
118148
const script = findHookScript(ctx.workspaceRoot, ctx.event);
119149
if (!script) return NOT_EXECUTED;
120150

151+
// Trust gate: never run a project's hooks until the user has approved this
152+
// workspace. A non-blocking skip — the agent proceeds without the hook
153+
// rather than being held hostage by an untrusted (or hostile) script.
154+
if (!isHooksTrusted(ctx.workspaceRoot)) {
155+
return { executed: false, exitCode: 0, stdout: '', stderr: '', blocked: false, untrusted: true, scriptPath: script };
156+
}
157+
121158
const env: Record<string, string> = {
122159
...process.env as Record<string, string>,
123160
CODEEP_HOOK_EVENT: ctx.event,
@@ -235,12 +272,32 @@ export function formatHookList(hooks: ReturnType<typeof listInstalledHooks>): st
235272
return lines.join('\n');
236273
}
237274

275+
/**
276+
* Build the trust banner for `/hooks` and the welcome screen. `workspaceRoot`
277+
* is needed to read trust state; returns '' if no hooks are installed.
278+
*/
279+
export function formatHookTrust(workspaceRoot: string): string {
280+
const hooks = listInstalledHooks(workspaceRoot);
281+
if (hooks.length === 0) return '';
282+
if (isHooksTrusted(workspaceRoot)) {
283+
return '✓ This workspace is **trusted** — its hooks will run. Use `/hooks untrust` to revoke.';
284+
}
285+
return [
286+
'⚠️ This workspace is **not trusted**, so its hooks are **skipped** (they run arbitrary shell).',
287+
'If you wrote these hooks (or trust this repo), run `/hooks trust` to enable them.',
288+
].join('\n');
289+
}
290+
238291
/**
239292
* Short one-line summary used in the welcome banner when hooks are present.
240293
* Returns empty string if no hooks installed.
241294
*/
242295
export function summarizeHooks(workspaceRoot: string): string {
243296
const hooks = listInstalledHooks(workspaceRoot);
244297
if (hooks.length === 0) return '';
245-
return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} active (${hooks.map(h => h.event).join(', ')})`;
298+
const list = hooks.map(h => h.event).join(', ');
299+
if (!isHooksTrusted(workspaceRoot)) {
300+
return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} present but NOT trusted — run /hooks trust to enable (${list})`;
301+
}
302+
return `${hooks.length} hook${hooks.length === 1 ? '' : 's'} active (${list})`;
246303
}

src/utils/toolExecution.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@ import { join } from 'path';
44
import { tmpdir } from 'os';
55
import { executeTool, FsCallbacks } from './toolExecution';
66
import { ToolCall } from './tools';
7+
import { trustWorkspaceHooks, untrustWorkspaceHooks } from './hooks';
78

89
let tmpRoot: string;
910

1011
beforeEach(() => {
1112
tmpRoot = mkdtempSync(join(tmpdir(), 'codeep-toolexec-'));
13+
// Project hooks only run in trusted workspaces; the integration tests below
14+
// exercise the hook path, so trust the temp workspace.
15+
trustWorkspaceHooks(tmpRoot);
1216
});
1317

1418
afterEach(() => {
19+
untrustWorkspaceHooks(tmpRoot);
1520
rmSync(tmpRoot, { recursive: true, force: true });
1621
});
1722

src/utils/toolExecution.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,67 @@ import { ToolCall, ToolResult, ActionLog } from './tools';
1818
import { logger } from './logger';
1919
import { runHook } from './hooks';
2020
import { isMcpToolName, callSessionTool, isVirtualMcpToolName, callSessionVirtualTool } from './mcpRegistry';
21+
import { lookup as dnsLookup } from 'dns/promises';
22+
23+
/**
24+
* SSRF guard for the agent's `fetch_url` tool. The URL there comes from model
25+
* output / page content (untrusted, prompt-injectable), so the agent must not
26+
* be able to reach internal services or the cloud metadata endpoint
27+
* (169.254.169.254). NOTE: this does NOT apply to user-configured provider
28+
* base URLs (Ollama localhost, custom vLLM/Tailscale endpoints) — those are
29+
* trusted config and never routed through fetch_url.
30+
*/
31+
function isBlockedIp(ip: string): boolean {
32+
const s = ip.trim().toLowerCase();
33+
if (s.includes(':')) {
34+
// IPv6
35+
if (s === '::1' || s === '::') return true; // loopback / unspecified
36+
if (s.startsWith('fe80') || s.startsWith('fc') || s.startsWith('fd')) return true; // link-local / ULA
37+
const mapped = s.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped
38+
if (mapped) 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)) return false;
43+
const [a, b] = parts;
44+
if (a === 127) return true; // loopback
45+
if (a === 10) return true; // RFC1918
46+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
47+
if (a === 192 && b === 168) return true; // RFC1918
48+
if (a === 169 && b === 254) return true; // link-local incl. metadata 169.254.169.254
49+
if (a === 0) return true; // 0.0.0.0/8
50+
return false;
51+
}
52+
53+
/** Returns an error string if the URL must not be fetched, else null. */
54+
async function assertFetchUrlAllowed(rawUrl: string): Promise<string | null> {
55+
let u: URL;
56+
try { u = new URL(rawUrl); } catch { return 'Invalid URL format'; }
57+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
58+
return `Blocked: only http/https URLs can be fetched (got "${u.protocol}")`;
59+
}
60+
const host = u.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
61+
if (host === 'localhost' || host.endsWith('.localhost')) {
62+
return 'Blocked: localhost is not fetchable by the agent';
63+
}
64+
if (/^[0-9.]+$/.test(host) || host.includes(':')) {
65+
// Literal IP — check directly.
66+
if (isBlockedIp(host)) return `Blocked: ${host} is a private/loopback/link-local address`;
67+
return null;
68+
}
69+
// Resolve and check every address (catches internal hostnames + single-record rebinding).
70+
try {
71+
const addrs = await dnsLookup(host, { all: true });
72+
for (const a of addrs) {
73+
if (isBlockedIp(a.address)) {
74+
return `Blocked: ${host} resolves to a private/internal address (${a.address})`;
75+
}
76+
}
77+
} catch {
78+
// DNS failure — let curl attempt and fail naturally; not an SSRF risk.
79+
}
80+
return null;
81+
}
2182

2283
const debug = (...args: unknown[]) => {
2384
if (process.env.CODEEP_DEBUG === '1') {
@@ -578,9 +639,13 @@ async function dispatchTool(
578639
const url = parameters.url as string;
579640
if (!url) return { success: false, output: '', error: 'Missing required parameter: url', tool, parameters };
580641

581-
try { new URL(url); } catch { return { success: false, output: '', error: 'Invalid URL format', tool, parameters }; }
642+
const blockedReason = await assertFetchUrlAllowed(url);
643+
if (blockedReason) return { success: false, output: '', error: blockedReason, tool, parameters };
582644

583-
const result = await executeCommandAsync('curl', ['-s', '-L', '-m', '30', '-A', 'Codeep/1.0', '--max-filesize', '1000000', url], {
645+
// Restrict to http/https on the initial request AND redirects, and cap
646+
// redirect hops — defends against protocol-smuggling and limits
647+
// redirect-based SSRF reach (initial host is already IP-checked above).
648+
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], {
584649
cwd: projectRoot,
585650
projectRoot,
586651
timeout: 35000,

0 commit comments

Comments
 (0)