Skip to content
5 changes: 5 additions & 0 deletions .changeset/auto-140fe7085c0b5f28.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"claude-auto": minor
---

- Added protection to block shell commands that attempt to modify or delete validator files
7 changes: 7 additions & 0 deletions .changeset/auto-272d242d3df04cd5.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/auto-55812bafc77921a8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"claude-auto": minor
---

- Added protection for validator files by blocking direct edits and writes through the pre-tool-use hook
5 changes: 5 additions & 0 deletions .changeset/auto-a3c621b950c81fa3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"claude-auto": minor
---

- Added path protection utility for detecting validator files, enabling hooks to identify and safeguard validator-related paths
9 changes: 9 additions & 0 deletions .changeset/auto-ddd203e5ae4bfdd2.md
Original file line number Diff line number Diff line change
@@ -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
41 changes: 40 additions & 1 deletion dist/bundle/scripts/pre-tool-use.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`);
Expand Down
33 changes: 0 additions & 33 deletions ketchup-plan.md
Original file line number Diff line number Diff line change
@@ -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)
76 changes: 75 additions & 1 deletion src/hooks/pre-tool-use.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 });
Expand Down
46 changes: 45 additions & 1 deletion src/hooks/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

type HookResult = {
Expand Down Expand Up @@ -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`);
Expand Down
Loading