diff --git a/.changeset/auto-140fe7085c0b5f28.md b/.changeset/auto-140fe7085c0b5f28.md new file mode 100644 index 0000000..c449f48 --- /dev/null +++ b/.changeset/auto-140fe7085c0b5f28.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Added protection to block shell commands that attempt to modify or delete validator files diff --git a/.changeset/auto-272d242d3df04cd5.md b/.changeset/auto-272d242d3df04cd5.md new file mode 100644 index 0000000..d9c9b5d --- /dev/null +++ b/.changeset/auto-272d242d3df04cd5.md @@ -0,0 +1,7 @@ +--- +"claude-auto": minor +--- + +- Protected validator files from unauthorized modifications by blocking Edit and Write operations +- Blocked Bash commands that target validator files to prevent bypassing protections +- Added path detection to identify protected validator files across the hook system diff --git a/.changeset/auto-55812bafc77921a8.md b/.changeset/auto-55812bafc77921a8.md new file mode 100644 index 0000000..573b866 --- /dev/null +++ b/.changeset/auto-55812bafc77921a8.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Added protection for validator files by blocking direct edits and writes through the pre-tool-use hook diff --git a/.changeset/auto-a3c621b950c81fa3.md b/.changeset/auto-a3c621b950c81fa3.md new file mode 100644 index 0000000..8aae6f8 --- /dev/null +++ b/.changeset/auto-a3c621b950c81fa3.md @@ -0,0 +1,5 @@ +--- +"claude-auto": minor +--- + +- Added path protection utility for detecting validator files, enabling hooks to identify and safeguard validator-related paths diff --git a/.changeset/auto-ddd203e5ae4bfdd2.md b/.changeset/auto-ddd203e5ae4bfdd2.md new file mode 100644 index 0000000..ba32497 --- /dev/null +++ b/.changeset/auto-ddd203e5ae4bfdd2.md @@ -0,0 +1,9 @@ +--- +"claude-auto": minor +--- + +- Switched to plugin-only mode, removing the legacy npx/CLI installation system entirely +- Added plugin marketplace support for easier installation via BeOnAuto/auto-plugins +- Added runtime configuration skill for managing validators and reminders with overrides +- Fixed commit validation ignoring the "off" mode setting +- Updated all documentation for plugin-only workflow diff --git a/dist/bundle/scripts/pre-tool-use.js b/dist/bundle/scripts/pre-tool-use.js index 04f42cd..9022054 100755 --- a/dist/bundle/scripts/pre-tool-use.js +++ b/dist/bundle/scripts/pre-tool-use.js @@ -6799,6 +6799,20 @@ function loadValidators(dirs, overrides) { } // src/hooks/pre-tool-use.ts +function isProtectedPath(filePath, validatorsDirs) { + return validatorsDirs.some((dir) => filePath.startsWith(`${dir}/`)); +} +function commandTargetsProtectedPath(command, validatorsDirs) { + for (const dir of validatorsDirs) { + if (command.includes(`${dir}/`)) { + const idx = command.indexOf(`${dir}/`); + const rest = command.slice(idx); + const match = rest.match(/^(\S+)/); + if (match) return match[1]; + } + } + return void 0; +} async function handlePreToolUse(paths, sessionId, toolInput, options2 = {}) { if (!fs8.existsSync(paths.autoDir)) { return { @@ -6813,8 +6827,33 @@ async function handlePreToolUse(paths, sessionId, toolInput, options2 = {}) { const gitCwd = options2.cwd ?? process.cwd(); return handleCommitValidation(paths, sessionId, command, options2, gitCwd); } - const patterns = loadDenyPatterns(paths.claudeDir); + if (command) { + const targetedPath = commandTargetsProtectedPath(command, paths.validatorsDirs); + if (targetedPath) { + activityLog(paths.autoDir, sessionId, "pre-tool-use", `blocked protected: ${targetedPath}`); + debugLog(paths.autoDir, "pre-tool-use", `${targetedPath} blocked (immutable validator)`); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `Validator files are immutable: ${targetedPath}` + } + }; + } + } const filePath = toolInput.file_path; + if (filePath && isProtectedPath(filePath, paths.validatorsDirs)) { + activityLog(paths.autoDir, sessionId, "pre-tool-use", `blocked protected: ${filePath}`); + debugLog(paths.autoDir, "pre-tool-use", `${filePath} blocked (immutable validator)`); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `Validator files are immutable: ${filePath}` + } + }; + } + const patterns = loadDenyPatterns(paths.claudeDir); if (filePath && isDenied(filePath, patterns)) { activityLog(paths.autoDir, sessionId, "pre-tool-use", `blocked: ${filePath}`); debugLog(paths.autoDir, "pre-tool-use", `${filePath} blocked by deny-list`); diff --git a/ketchup-plan.md b/ketchup-plan.md index 05884e5..e69de29 100644 --- a/ketchup-plan.md +++ b/ketchup-plan.md @@ -1,33 +0,0 @@ -# Ketchup Plan: Make claude-auto opt-in per repository - -## TODO - -## DONE - -- [x] Burst 7.4: Wrap init config tip in directive so Claude surfaces it (45f3a8a) -- [x] Burst 7.3: Wrap `INIT_HINT_MESSAGE` in a directive so Claude surfaces it on first reply (f388145) -- [x] Burst 7.2: Fix skill name in `INIT_HINT_MESSAGE` to `/claude-auto-init` (0832f02) -- [x] Burst 7.1: Simplify `INIT_HINT_MESSAGE` to plain one-line reminder (8783851) -- [x] Burst 6.1: `formatInitResult` uses emojis and does not instruct Claude to ask the user -- [x] Burst 6.2: `INIT_HINT_MESSAGE` uses emojis for visibility - -- [x] Burst 1.1: `createHookState` does not create autoDir (6fe15c2) -- [x] Burst 1.2: `read()` returns defaults when autoDir missing (7ee52cd) -- [x] Burst 1.3: `write()` is no-op when autoDir missing (c70de21) -- [x] Burst 1.4: `update()` returns defaults when autoDir missing (c70de21) -- [x] Burst 1.5: Remove `firstSetupRequired` from initial state creation (7ee52cd) -- [x] Burst 2.1: `activityLog` no-op when autoDir missing (e33d77f) -- [x] Burst 2.2: `debugLog` no-op when autoDir missing (e33d77f) -- [x] Burst 2.3: `writeHookLog` no-op when autoDir missing (e33d77f) -- [x] Burst 2.4: `logPluginDiagnostics` no file write when autoDir missing (e33d77f) -- [x] Burst 3.1: `INIT_HINT_MESSAGE` constant (86fac7f) -- [x] Burst 3.2: `handleSessionStart` returns only hint when autoDir missing (86fac7f) -- [x] Burst 3.3: `handlePreToolUse` allows everything when autoDir missing (62efee8) -- [x] Burst 3.4: `handleUserPromptSubmit` returns empty when autoDir missing (86fac7f) -- [x] Burst 3.5: `handleStop` returns stop when autoDir missing (62efee8) -- [x] Burst 4.1: Remove `firstSetupRequired` block from user-prompt-submit (86fac7f) -- [x] Burst 4.2: Remove `FIRST_SETUP_MESSAGE` (86fac7f) -- [x] Burst 5.1: `initClaudeAuto` creates `.claude-auto/` with default state (43244eb) -- [x] Burst 5.2: Returns `created: false` when already initialized (43244eb) -- [x] Burst 5.3: Detects `.gitignore` status for `.claude-auto` (43244eb) -- [x] Burst 5.4: Script entry point + SKILL.md (7526a57) diff --git a/src/hooks/pre-tool-use.test.ts b/src/hooks/pre-tool-use.test.ts index fdcd99f..e7a5a4e 100644 --- a/src/hooks/pre-tool-use.test.ts +++ b/src/hooks/pre-tool-use.test.ts @@ -5,7 +5,7 @@ import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ResolvedPaths } from '../path-resolver.js'; -import { handlePreToolUse } from './pre-tool-use.js'; +import { commandTargetsProtectedPath, handlePreToolUse, isProtectedPath } from './pre-tool-use.js'; const DEFAULT_AUTO_DIR = '.claude-auto'; @@ -336,6 +336,80 @@ Validate this commit`, } }); + it('denies Bash command targeting validator files', async () => { + const validatorPath = path.join(autoDir, 'validators', 'burst-atomicity.md'); + const toolInput = { command: `rm ${validatorPath}` }; + + const result = await handlePreToolUse(resolvedPaths, 'session-bash-protect', toolInput); + + expect(result).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `Validator files are immutable: ${validatorPath}`, + }, + }); + }); + + it('denies Edit/Write to validator files', async () => { + const toolInput = { file_path: path.join(autoDir, 'validators', 'burst-atomicity.md') }; + + const result = await handlePreToolUse(resolvedPaths, 'session-protect', toolInput); + + expect(result).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `Validator files are immutable: ${toolInput.file_path}`, + }, + }); + }); + + describe('isProtectedPath', () => { + it('returns true for file inside a validatorsDirs path', () => { + const validatorsDirs = ['/plugin/validators', '/project/.claude-auto/validators']; + + expect(isProtectedPath('/project/.claude-auto/validators/burst-atomicity.md', validatorsDirs)).toBe(true); + expect(isProtectedPath('/plugin/validators/coverage-rules.md', validatorsDirs)).toBe(true); + }); + + it('returns false for file outside validatorsDirs', () => { + const validatorsDirs = ['/plugin/validators', '/project/.claude-auto/validators']; + + expect(isProtectedPath('/project/src/hooks/pre-tool-use.ts', validatorsDirs)).toBe(false); + expect(isProtectedPath('/project/.claude-auto/reminders/tcr.md', validatorsDirs)).toBe(false); + }); + }); + + describe('commandTargetsProtectedPath', () => { + it('returns matched path when command contains a validator path', () => { + const dirs = ['/project/.claude-auto/validators']; + + expect(commandTargetsProtectedPath('rm /project/.claude-auto/validators/test.md', dirs)).toBe( + '/project/.claude-auto/validators/test.md', + ); + }); + + it('returns undefined when command does not contain a validator path', () => { + const dirs = ['/project/.claude-auto/validators']; + + expect(commandTargetsProtectedPath('rm /project/src/file.ts', dirs)).toBe(undefined); + }); + }); + + it('allows Bash commands not targeting validator files', async () => { + const toolInput = { command: 'rm /project/src/file.ts' }; + + const result = await handlePreToolUse(resolvedPaths, 'session-bash-ok', toolInput); + + expect(result).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + }, + }); + }); + it('injects reminders matching PreToolUse hook and toolName', async () => { const remindersDir = path.join(autoDir, 'reminders'); fs.mkdirSync(remindersDir, { recursive: true }); diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 71a453e..7434b68 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -15,6 +15,22 @@ import type { ResolvedPaths } from '../path-resolver.js'; import { loadReminders } from '../reminder-loader.js'; import { loadValidators } from '../validator-loader.js'; +export function isProtectedPath(filePath: string, validatorsDirs: string[]): boolean { + return validatorsDirs.some((dir) => filePath.startsWith(`${dir}/`)); +} + +export function commandTargetsProtectedPath(command: string, validatorsDirs: string[]): string | undefined { + for (const dir of validatorsDirs) { + if (command.includes(`${dir}/`)) { + const idx = command.indexOf(`${dir}/`); + const rest = command.slice(idx); + const match = rest.match(/^(\S+)/); + if (match) return match[1]; + } + } + return undefined; +} + type ToolInput = Record; type HookResult = { @@ -54,9 +70,37 @@ export async function handlePreToolUse( return handleCommitValidation(paths, sessionId, command, options, gitCwd); } - const patterns = loadDenyPatterns(paths.claudeDir); + if (command) { + const targetedPath = commandTargetsProtectedPath(command, paths.validatorsDirs); + if (targetedPath) { + activityLog(paths.autoDir, sessionId, 'pre-tool-use', `blocked protected: ${targetedPath}`); + debugLog(paths.autoDir, 'pre-tool-use', `${targetedPath} blocked (immutable validator)`); + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `Validator files are immutable: ${targetedPath}`, + }, + }; + } + } + const filePath = toolInput.file_path as string; + if (filePath && isProtectedPath(filePath, paths.validatorsDirs)) { + activityLog(paths.autoDir, sessionId, 'pre-tool-use', `blocked protected: ${filePath}`); + debugLog(paths.autoDir, 'pre-tool-use', `${filePath} blocked (immutable validator)`); + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `Validator files are immutable: ${filePath}`, + }, + }; + } + + const patterns = loadDenyPatterns(paths.claudeDir); + if (filePath && isDenied(filePath, patterns)) { activityLog(paths.autoDir, sessionId, 'pre-tool-use', `blocked: ${filePath}`); debugLog(paths.autoDir, 'pre-tool-use', `${filePath} blocked by deny-list`);