Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/protect-every-hook-file.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 8 additions & 5 deletions packages/cli/src/protect/agent-hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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<boolean> {
Expand All @@ -52,7 +55,7 @@ export async function installCodexHook(home: string): Promise<boolean> {
}

/** 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 = '.*';
Expand Down Expand Up @@ -80,7 +83,7 @@ export async function removeGeminiHook(home: string): Promise<boolean> {
}

/** 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';

/**
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/gate/protected-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@
/** 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-]+)?$/;

Expand All @@ -27,6 +43,8 @@
/\.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);
}

Expand Down Expand Up @@ -66,8 +84,18 @@
* 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;
Expand Down
41 changes: 41 additions & 0 deletions packages/core/test/self-protection.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down
Loading