diff --git a/.changeset/protect-every-hook-file.md b/.changeset/protect-every-hook-file.md new file mode 100644 index 0000000..6e15cd8 --- /dev/null +++ b/.changeset/protect-every-hook-file.md @@ -0,0 +1,10 @@ +--- +'@memnox/core': patch +'@memnox/proxy': patch +'@memnox/interceptors': patch +'memnox': patch +--- + +Every file Memnox installs a hook into is a file only a person may write. Claude Code's settings were protected and the other four were not, so Cursor could edit `~/.cursor/hooks.json` and take the policy hook straight back out — and the same for Codex, Gemini CLI and Windsurf. A write to any of them is refused now, and so is a shell line that names one. + +`AGENT_HOOK_FILES` is the one list, exported from `@memnox/core` and imported by the installer that writes the hooks as well as by the guard that protects them, so a file gaining a hook cannot be left unguarded by the two drifting apart. diff --git a/packages/cli/src/protect/agent-hooks.ts b/packages/cli/src/protect/agent-hooks.ts index 13e142e..f9c5938 100644 --- a/packages/cli/src/protect/agent-hooks.ts +++ b/packages/cli/src/protect/agent-hooks.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { DISCOVERED_AGENT_KIND } from '@memnox/core'; +import { AGENT_HOOK_FILES, DISCOVERED_AGENT_KIND } from '@memnox/core'; import { CURSOR_EDIT_TOOLS, CURSOR_EVENT, @@ -28,7 +28,10 @@ import { isInPlace, REWRITE, rewriteJsonFile } from './json-config'; */ /** Codex reads hooks from a file shaped like Claude Code's settings. */ -const CODEX_HOOKS = join('.codex', 'hooks.json'); +const [CODEX_PARTS, CURSOR_PARTS, GEMINI_PARTS, WINDSURF_PARTS] = AGENT_HOOK_FILES; + +// The one list `gate/protected-paths.ts` guards, so installing into a file protects it too. +const CODEX_HOOKS = join(...(CODEX_PARTS ?? [])); /** * Codex's `PreToolUse` with no matcher, which every tool meets: its shell, its patch tool * and its MCP calls are all ruled on, so no pattern syntax has to be guessed. @@ -40,7 +43,7 @@ function codexEvents(): HookEvent[] { } /** Cursor's user-level hooks, which apply in every repository it opens. */ -const CURSOR_HOOKS = join('.cursor', 'hooks.json'); +const CURSOR_HOOKS = join(...(CURSOR_PARTS ?? [])); const CURSOR_HOOKS_VERSION = 1; export async function installCodexHook(home: string): Promise { @@ -52,7 +55,7 @@ export async function installCodexHook(home: string): Promise { } /** Gemini CLI reads hooks from its own settings, in the same shape under its own event names. */ -const GEMINI_SETTINGS = join('.gemini', 'settings.json'); +const GEMINI_SETTINGS = join(...(GEMINI_PARTS ?? [])); const GEMINI_AGENT = 'gemini-cli'; /** Every tool, for the moment after one returns; Gemini's matchers are regular expressions. */ const GEMINI_EVERY_TOOL = '.*'; @@ -80,7 +83,7 @@ export async function removeGeminiHook(home: string): Promise { } /** Windsurf's user-level hooks, which Cascade reads in every workspace. */ -const WINDSURF_HOOKS = join('.codeium', 'windsurf', 'hooks.json'); +const WINDSURF_HOOKS = join(...(WINDSURF_PARTS ?? [])); const WINDSURF_AGENT = 'windsurf'; /** diff --git a/packages/core/src/gate/protected-paths.ts b/packages/core/src/gate/protected-paths.ts index a4d31cb..43e715e 100644 --- a/packages/core/src/gate/protected-paths.ts +++ b/packages/core/src/gate/protected-paths.ts @@ -9,6 +9,22 @@ import { basename, dirname, join, resolve } from 'node:path'; /** Claude Code's settings, which hold the hooks and could switch every one of them off. */ const CLAUDE_SETTINGS = /(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.claude\.json$/; +/** + * Every other file Memnox installs a hook into, as segments under a home directory. Shared + * with the installer in the CLI on purpose: a file a hook is written into and not protected + * here is a hook the agent can take back out. + */ +export const AGENT_HOOK_FILES: readonly (readonly string[])[] = [ + ['.codex', 'hooks.json'], + ['.cursor', 'hooks.json'], + ['.gemini', 'settings.json'], + ['.codeium', 'windsurf', 'hooks.json'], +]; + +const HOOK_FILE_PATHS: readonly string[] = AGENT_HOOK_FILES.map((parts) => + parts.join('/'), +); + /** The binaries a hook runs by name, so replacing one would answer for Memnox. */ const MEMNOX_BINARY = /\/(bin|\.bin)\/memnox(-[\w-]+)?$/; @@ -27,6 +43,8 @@ export function isProtectedPath(target: string): boolean { /\.policies\.toml$/.test(name) ) return true; + if (HOOK_FILE_PATHS.some((file) => path === file || path.endsWith(`/${file}`))) + return true; return CLAUDE_SETTINGS.test(path) || MEMNOX_BINARY.test(path); } @@ -66,8 +84,18 @@ export function touchesProtected(target: string, cwd?: string): boolean { * a determined agent can spell it so no pattern finds it, which is what the kernel wall * around its shell is for; this catches the plain spelling before anything runs. */ -const NAMES_PROTECTED = - /\.memnox\b|\.policies\.toml\b|\.claude\/settings(\.local)?\.json|\.claude\.json|disableAllHooks|allowUnsandboxedCommands/; +const NAMES_PROTECTED = new RegExp( + [ + '\\.memnox\\b', + '\\.policies\\.toml\\b', + '\\.claude\\/settings(\\.local)?\\.json', + '\\.claude\\.json', + // Built from the one list, so a hook file cannot be installed into and left unnamed here. + ...HOOK_FILE_PATHS.map((file) => file.replace(/\./g, '\\.')), + 'disableAllHooks', + 'allowUnsandboxedCommands', + ].join('|'), +); export function namesProtected(line: string): string | null { return NAMES_PROTECTED.exec(line)?.[0] ?? null; diff --git a/packages/core/test/self-protection.test.ts b/packages/core/test/self-protection.test.ts index 554c54f..dd5f086 100644 --- a/packages/core/test/self-protection.test.ts +++ b/packages/core/test/self-protection.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; import { LocalGate } from '../src/gate/local-gate'; +import { + AGENT_HOOK_FILES, + isProtectedPath, + namesProtected, +} from '../src/gate/protected-paths'; import { loosens } from '../src/gate/self-protection'; import { resolveAction, resolveShellLine } from '../src/intercept/resolve'; @@ -96,6 +101,42 @@ describe('a shell line into a rule file', () => { ); expect(refused.map((each) => each.effect)).toContain('deny'); }); + + /* Memnox installs its policy hook into each of these, and protected only Claude Code's. + So Cursor could edit its own hooks.json and take the hook back out. */ + it.each([ + 'sed -i "" /memnox/d ~/.cursor/hooks.json', + 'rm ~/.codex/hooks.json', + 'echo {} > ~/.gemini/settings.json', + 'cp /tmp/empty.json ~/.codeium/windsurf/hooks.json', + ])('denies `%s`', (line) => { + const { actions } = resolveShellLine(line, { HOME: '/Users/me' }); + const refused = actions.map((each) => + gate().evaluate({ + action: each.action, + toolClass: each.class, + ...(each.target === undefined ? {} : { target: each.target }), + }), + ); + expect(refused.map((each) => each.effect)).toContain('deny'); + }); +}); + +/* One list, imported by the installer and by the guard, so a file a hook is written into + cannot be left unprotected by the two drifting apart. */ +describe('every file a hook is installed into', () => { + it.each(AGENT_HOOK_FILES.map((parts) => [parts.join('/')]))( + '~/%s is a path only a person may write', + (file) => { + expect(isProtectedPath(`/Users/me/${file}`)).toBe(true); + expect(namesProtected(`vi /Users/me/${file}`)).not.toBeNull(); + }, + ); + + it('leaves a file beside one of them alone', () => { + expect(isProtectedPath('/Users/me/.cursor/rules.json')).toBe(false); + expect(isProtectedPath('/Users/me/.codex/notes.json')).toBe(false); + }); }); /* Each of these reached ~/.memnox or the hook settings with no argument that looked like diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f23872..6e8bba7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + source-map-js: '>=1.2.2' + importers: .: @@ -1380,8 +1383,8 @@ packages: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} source-map@0.7.6: @@ -2532,7 +2535,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -2652,7 +2655,7 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prettier@2.8.8: {} @@ -2750,7 +2753,7 @@ snapshots: smol-toml@1.8.0: {} - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} source-map@0.7.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 01e6153..c97fd79 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,15 @@ packages: allowBuilds: better-sqlite3: true esbuild: true + +# source-map-js is a transitive dev dependency of vitest and tsup pulled below +# GHSA-68fv-2mgg-jv7q; the audit fails the build at high, so hold it at the patched line. +overrides: + source-map-js: '>=1.2.2' + +# braces is a transitive dev dependency of changesets, knip and vitest with no patched +# release yet (GHSA-vfj7-8cjw-p6xm lists none and 3.0.3 is still the latest), so the only +# way past the gate is to ignore it. Drop this line the day a fixed braces ships. +auditConfig: + ignoreGhsas: + - GHSA-vfj7-8cjw-p6xm