Skip to content
Merged
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
19 changes: 1 addition & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ A Claude Code / OpenCode plugin that blocks destructive git and filesystem comma
| AST rules | `bun run sg:scan` |
| Doctor | `bun src/bin/cc-safety-net.ts doctor` |

**`bun run check`** runs: biome check → typecheck → knip → ast-grep scan → bun test
**Always use `bun run check` to verify changes.** This runs typecheck, knip, biome lint, and tests together. Do not run these separately.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Include AST scan in the bun run check description.

Line 21 explicitly lists what runs, but it omits ast-grep scan, which can mislead contributors about the full validation pipeline.

📝 Suggested doc tweak
-**Always use `bun run check` to verify changes.** This runs typecheck, knip, biome lint, and tests together. Do not run these separately.
+**Always use `bun run check` to verify changes.** This runs biome check, typecheck, knip, ast-grep scan, and tests together. Do not run these separately.

Based on learnings: Run bun run check before committing which executes: biome check → typecheck → knip → ast-grep scan → bun test.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**Always use `bun run check` to verify changes.** This runs typecheck, knip, biome lint, and tests together. Do not run these separately.
**Always use `bun run check` to verify changes.** This runs biome check, typecheck, knip, ast-grep scan, and tests together. Do not run these separately.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` at line 21, Update the AGENTS.md description for the "bun run
check" command to include the missing "ast-grep scan" step; locate the sentence
that currently lists the pipeline ("biome check → typecheck → knip → bun test")
and change it to explicitly enumerate "biome check → typecheck → knip → ast-grep
scan → bun test" so contributors know the full validation pipeline when running
`bun run check`.


## Pre-commit Hooks

Expand Down Expand Up @@ -69,23 +69,6 @@ export const myInternalFn = () => { ... };
- Exit codes: `0` = success, `1` = error
- Block commands: exit 0 with JSON `permissionDecision: "deny"`

## Architecture

```
src/
├── index.ts # OpenCode plugin export (main entry)
├── types.ts # Shared types and constants
├── bin/
│ └── cc-safety-net.ts # Claude Code CLI wrapper
└── core/
├── analyze.ts # Main analysis logic
├── config.ts # Config loading (.safety-net.json)
├── shell.ts # Shell parsing (uses shell-quote)
├── rules-git.ts # Git subcommand analysis
├── rules-rm.ts # rm command analysis
└── rules-custom.ts # Custom rule evaluation
```

## Testing

Use Bun's built-in test runner with test helpers:
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ A Claude Code and OpenCode plugin that blocks destructive git and filesystem com
- **Build**: `bun run build`
- **Doctor**: `bun src/bin/cc-safety-net.ts doctor` (diagnostics)

**Always use `bun run check` to verify changes.** This runs typecheck, knip, biome lint, and tests together. Do not run these separately.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep bun run check contents accurate here as well.

Line 22 omits ast-grep scan from the listed checks, so the instruction is incomplete.

📝 Suggested doc tweak
-**Always use `bun run check` to verify changes.** This runs typecheck, knip, biome lint, and tests together. Do not run these separately.
+**Always use `bun run check` to verify changes.** This runs biome check, typecheck, knip, ast-grep scan, and tests together. Do not run these separately.

Based on learnings: Run bun run check before committing which executes: biome check → typecheck → knip → ast-grep scan → bun test.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**Always use `bun run check` to verify changes.** This runs typecheck, knip, biome lint, and tests together. Do not run these separately.
**Always use `bun run check` to verify changes.** This runs biome check, typecheck, knip, ast-grep scan, and tests together. Do not run these separately.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` at line 22, Update the CLAUDE.md sentence that describes what `bun
run check` runs so it includes the missing `ast-grep scan`; specifically edit
the line containing "Always use `bun run check` to verify changes." (the
paragraph listing checks for `bun run check`) to reflect the full sequence:
biome check → typecheck → knip → ast-grep scan → bun test, ensuring the
documented order matches the actual script.


## Pre-commit Hooks

Runs on commit: `knip` → `lint-staged` (biome check --write, ast-grep scan)
Expand Down
29 changes: 22 additions & 7 deletions dist/bin/cc-safety-net.js
Original file line number Diff line number Diff line change
Expand Up @@ -3731,6 +3731,7 @@ function analyzeCommand(command, options = {}) {
}

// src/bin/doctor/hooks.ts
var COPILOT_PLUGIN_CONFIG_PATH = "copilot-plugin";
var SELF_TEST_CASES = [
{ command: "git reset --hard", description: "git reset --hard", expectBlocked: true },
{ command: "rm -rf /", description: "rm -rf /", expectBlocked: true },
Expand Down Expand Up @@ -4178,13 +4179,15 @@ function detectAllHooks(cwd, options) {
errors: errors.length > 0 ? errors : undefined
};
}
if (hooksCheck.activeConfigPaths.length > 0) {
if (options?.copilotPluginInstalled === true || hooksCheck.activeConfigPaths.length > 0) {
const viaPlugin = options?.copilotPluginInstalled === true;
const primaryConfigPath = hooksCheck.activeConfigPaths[0];
return {
platform: "copilot-cli",
status: "configured",
method: "hook config",
configPath: hooksCheck.activeConfigPaths[0],
configPaths: hooksCheck.activeConfigPaths,
method: viaPlugin ? "plugin list" : "hook config",
configPath: primaryConfigPath ?? (viaPlugin ? COPILOT_PLUGIN_CONFIG_PATH : undefined),
configPaths: hooksCheck.activeConfigPaths.length > 0 ? hooksCheck.activeConfigPaths : undefined,
selfTest: runSelfTest(),
errors: errors.length > 0 ? errors : undefined
};
Expand All @@ -4210,6 +4213,7 @@ var VERSION_FETCH_TIMEOUT_MS = 2000;
function getPackageVersion() {
return CURRENT_VERSION;
}
var COPILOT_PLUGIN_ID = "copilot-safety-net";
var defaultVersionFetcher = async (args) => {
const [cmd, ...rest] = args;
if (!cmd)
Expand Down Expand Up @@ -4260,6 +4264,12 @@ function parseVersion(output) {
`)[0]?.trim();
return firstLine || null;
}
function hasCopilotSafetyNetPlugin(output) {
if (!output)
return false;
const pluginPattern = new RegExp(`(^|[^a-z0-9-])${COPILOT_PLUGIN_ID}([^a-z0-9-]|$)`, "m");
return pluginPattern.test(output);
}
async function getSystemInfo(fetcher = defaultVersionFetcher) {
const fetchCopilotVersion = async () => {
const binaryVersionPromise = fetcher(["copilot", "--binary-version"]);
Expand All @@ -4270,14 +4280,15 @@ async function getSystemInfo(fetcher = defaultVersionFetcher) {
}
return fallbackVersionPromise;
};
const [claudeRaw, openCodeRaw, geminiRaw, copilotRaw, nodeRaw, npmRaw, bunRaw] = await Promise.all([
const [claudeRaw, openCodeRaw, geminiRaw, copilotRaw, nodeRaw, npmRaw, bunRaw, pluginListRaw] = await Promise.all([
fetcher(["claude", "--version"]),
fetcher(["opencode", "--version"]),
fetcher(["gemini", "--version"]),
fetchCopilotVersion(),
fetcher(["node", "--version"]),
fetcher(["npm", "--version"]),
fetcher(["bun", "--version"])
fetcher(["bun", "--version"]),
fetcher(["copilot", "plugin", "list"])
]);
return {
version: CURRENT_VERSION,
Expand All @@ -4288,6 +4299,7 @@ async function getSystemInfo(fetcher = defaultVersionFetcher) {
nodeVersion: parseVersion(nodeRaw),
npmVersion: parseVersion(npmRaw),
bunVersion: parseVersion(bunRaw),
copilotPluginInstalled: hasCopilotSafetyNetPlugin(pluginListRaw),
platform: `${process.platform} ${process.arch}`
};
}
Expand Down Expand Up @@ -4353,7 +4365,10 @@ function parseDoctorFlags(args) {
async function runDoctor(options = {}) {
const cwd = options.cwd ?? process.cwd();
const system = await getSystemInfo();
const hooks = detectAllHooks(cwd, { copilotCliVersion: system.copilotCliVersion });
const hooks = detectAllHooks(cwd, {
copilotCliVersion: system.copilotCliVersion,
copilotPluginInstalled: system.copilotPluginInstalled
});
const configInfo = getConfigInfo(cwd);
const environment = getEnvironmentInfo();
const activity = getActivitySummary(7);
Expand Down
1 change: 1 addition & 0 deletions dist/bin/doctor/hooks.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { LoadConfigOptions } from '@/core/config';
interface HookDetectOptions extends LoadConfigOptions {
homeDir?: string;
copilotCliVersion?: string | null;
copilotPluginInstalled?: boolean;
}
/**
* Strip JSONC-style comments and trailing commas from a string.
Expand Down
2 changes: 2 additions & 0 deletions dist/bin/doctor/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export interface SystemInfo {
npmVersion: string | null;
/** Bun version (from `bun --version`) */
bunVersion: string | null;
/** Whether the copilot-safety-net plugin is installed (from `copilot plugin list`) */
copilotPluginInstalled: boolean;
/** Platform (e.g., "darwin arm64") */
platform: string;
}
Expand Down
14 changes: 10 additions & 4 deletions src/bin/doctor/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { Config } from '@/types';
interface HookDetectOptions extends LoadConfigOptions {
homeDir?: string;
copilotCliVersion?: string | null;
copilotPluginInstalled?: boolean;
}

interface CopilotHookEntry {
Expand All @@ -39,6 +40,8 @@ interface CopilotDetectionState {
disabledBy?: string;
}

const COPILOT_PLUGIN_CONFIG_PATH = 'copilot-plugin';

/** Self-test cases for validating the analyzer */
const SELF_TEST_CASES: SelfTestCase[] = [
// Git destructive commands
Expand Down Expand Up @@ -684,13 +687,16 @@ export function detectAllHooks(cwd: string, options?: HookDetectOptions): HookSt
};
}

if (hooksCheck.activeConfigPaths.length > 0) {
if (options?.copilotPluginInstalled === true || hooksCheck.activeConfigPaths.length > 0) {
const viaPlugin = options?.copilotPluginInstalled === true;
const primaryConfigPath = hooksCheck.activeConfigPaths[0];
return {
platform: 'copilot-cli',
status: 'configured',
method: 'hook config',
configPath: hooksCheck.activeConfigPaths[0],
configPaths: hooksCheck.activeConfigPaths,
method: viaPlugin ? 'plugin list' : 'hook config',
configPath: primaryConfigPath ?? (viaPlugin ? COPILOT_PLUGIN_CONFIG_PATH : undefined),
configPaths:
hooksCheck.activeConfigPaths.length > 0 ? hooksCheck.activeConfigPaths : undefined,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
selfTest: runSelfTest(),
errors: errors.length > 0 ? errors : undefined,
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down
5 changes: 4 additions & 1 deletion src/bin/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<number> {

// Collect all data
const system = await getSystemInfo();
const hooks = detectAllHooks(cwd, { copilotCliVersion: system.copilotCliVersion });
const hooks = detectAllHooks(cwd, {
copilotCliVersion: system.copilotCliVersion,
copilotPluginInstalled: system.copilotPluginInstalled,
});
const configInfo = getConfigInfo(cwd);
const environment = getEnvironmentInfo();
const activity = getActivitySummary(7);
Expand Down
14 changes: 13 additions & 1 deletion src/bin/doctor/system-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export function getPackageVersion(): string {
*/
export type VersionFetcher = (args: string[]) => Promise<string | null>;

const COPILOT_PLUGIN_ID = 'copilot-safety-net';

/**
* Default version fetcher that runs shell commands.
* Uses Node.js child_process.spawn for compatibility with both Node and Bun runtimes.
Expand Down Expand Up @@ -92,6 +94,14 @@ function parseVersion(output: string | null): string | null {
return firstLine || null;
}

function hasCopilotSafetyNetPlugin(output: string | null): boolean {
if (!output) return false;

const pluginPattern = new RegExp(`(^|[^a-z0-9-])${COPILOT_PLUGIN_ID}([^a-z0-9-]|$)`, 'm');

return pluginPattern.test(output);
}

/**
* Fetch system info with tool versions.
* Runs all version checks in parallel for performance.
Expand All @@ -110,7 +120,7 @@ export async function getSystemInfo(
};

// Run all version fetches in parallel
const [claudeRaw, openCodeRaw, geminiRaw, copilotRaw, nodeRaw, npmRaw, bunRaw] =
const [claudeRaw, openCodeRaw, geminiRaw, copilotRaw, nodeRaw, npmRaw, bunRaw, pluginListRaw] =
await Promise.all([
fetcher(['claude', '--version']),
fetcher(['opencode', '--version']),
Expand All @@ -119,6 +129,7 @@ export async function getSystemInfo(
fetcher(['node', '--version']),
fetcher(['npm', '--version']),
fetcher(['bun', '--version']),
fetcher(['copilot', 'plugin', 'list']),
]);

return {
Expand All @@ -130,6 +141,7 @@ export async function getSystemInfo(
nodeVersion: parseVersion(nodeRaw),
npmVersion: parseVersion(npmRaw),
bunVersion: parseVersion(bunRaw),
copilotPluginInstalled: hasCopilotSafetyNetPlugin(pluginListRaw),
platform: `${process.platform} ${process.arch}`,
};
}
2 changes: 2 additions & 0 deletions src/bin/doctor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ export interface SystemInfo {
npmVersion: string | null;
/** Bun version (from `bun --version`) */
bunVersion: string | null;
/** Whether the copilot-safety-net plugin is installed (from `copilot plugin list`) */
copilotPluginInstalled: boolean;
/** Platform (e.g., "darwin arm64") */
platform: string;
}
Expand Down
7 changes: 7 additions & 0 deletions tests/bin/doctor/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ describe('formatSystemInfoSection', () => {
nodeVersion: '22.0.0',
npmVersion: null,
bunVersion: '1.0.0',
copilotPluginInstalled: false,
platform: 'darwin arm64',
};
const output = formatSystemInfoSection(sysInfo);
Expand Down Expand Up @@ -412,6 +413,7 @@ describe('formatConfigSection', () => {
nodeVersion: '22.0.0',
npmVersion: '10.0.0',
bunVersion: '1.0.0',
copilotPluginInstalled: false,
platform: 'darwin arm64',
},
};
Expand Down Expand Up @@ -463,6 +465,7 @@ describe('formatConfigSection', () => {
nodeVersion: '22.0.0',
npmVersion: '10.0.0',
bunVersion: '1.0.0',
copilotPluginInstalled: false,
platform: 'darwin arm64',
},
};
Expand Down Expand Up @@ -505,6 +508,7 @@ describe('formatConfigSection', () => {
nodeVersion: '22.0.0',
npmVersion: '10.0.0',
bunVersion: '1.0.0',
copilotPluginInstalled: false,
platform: 'darwin arm64',
},
};
Expand Down Expand Up @@ -535,6 +539,7 @@ describe('formatSummary', () => {
nodeVersion: '22.0.0',
npmVersion: '10.0.0',
bunVersion: '1.0.0',
copilotPluginInstalled: false,
platform: 'darwin',
},
};
Expand All @@ -561,6 +566,7 @@ describe('formatSummary', () => {
nodeVersion: '22.0.0',
npmVersion: '10.0.0',
bunVersion: '1.0.0',
copilotPluginInstalled: false,
platform: 'darwin',
},
};
Expand All @@ -587,6 +593,7 @@ describe('formatSummary', () => {
nodeVersion: '22.0.0',
npmVersion: '10.0.0',
bunVersion: '1.0.0',
copilotPluginInstalled: false,
platform: 'darwin',
},
};
Expand Down
70 changes: 70 additions & 0 deletions tests/bin/doctor/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,76 @@ describe('detectAllHooks', () => {
}
});

test('Copilot CLI: configured from installed plugin list without hook config', () => {
const tmpBase = join(tmpdir(), `doctor-copilot-${Date.now()}`);
const homeDir = join(tmpBase, 'home');
const projectDir = join(tmpBase, 'project');
mkdirSync(homeDir, { recursive: true });
mkdirSync(projectDir, { recursive: true });

try {
const hooks = detectAllHooks(projectDir, { homeDir, copilotPluginInstalled: true });
const copilot = hooks.find((hook) => hook.platform === 'copilot-cli');

expect(copilot?.status).toBe('configured');
expect(copilot?.method).toBe('plugin list');
expect(copilot?.configPath).toBe('copilot-plugin');
expect(copilot?.configPaths).toBeUndefined();
expect(copilot?.selfTest?.failed).toBe(0);
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
});

test('Copilot CLI: installed plugin list overrides legacy hook config as configured signal', () => {
const tmpBase = join(tmpdir(), `doctor-copilot-${Date.now()}`);
const homeDir = join(tmpBase, 'home');
const projectDir = join(tmpBase, 'project');
const copilotDir = join(projectDir, '.github', 'hooks');
mkdirSync(homeDir, { recursive: true });
mkdirSync(copilotDir, { recursive: true });
_writeCopilotHook(join(copilotDir, 'safety-net.json'));

try {
const hooks = detectAllHooks(projectDir, { homeDir, copilotPluginInstalled: true });
const copilot = hooks.find((hook) => hook.platform === 'copilot-cli');

expect(copilot?.status).toBe('configured');
expect(copilot?.method).toBe('plugin list');
expect(copilot?.configPath).toBe(join(copilotDir, 'safety-net.json'));
expect(copilot?.configPaths).toEqual([join(copilotDir, 'safety-net.json')]);
expect(copilot?.selfTest?.failed).toBe(0);
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
});

test('Copilot CLI: disableAllHooks still overrides installed plugin list', () => {
const tmpBase = join(tmpdir(), `doctor-copilot-${Date.now()}`);
const homeDir = join(tmpBase, 'home');
const projectDir = join(tmpBase, 'project');
const configDir = join(projectDir, '.github', 'copilot');
mkdirSync(homeDir, { recursive: true });
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'settings.json'), JSON.stringify({ disableAllHooks: true }));

try {
const hooks = detectAllHooks(projectDir, {
homeDir,
copilotCliVersion: '1.0.9',
copilotPluginInstalled: true,
});
const copilot = hooks.find((hook) => hook.platform === 'copilot-cli');

expect(copilot?.status).toBe('disabled');
expect(copilot?.configPath).toBe(join(configDir, 'settings.json'));
expect(copilot?.configPaths).toEqual([join(configDir, 'settings.json')]);
expect(copilot?.selfTest).toBeUndefined();
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
});

test('Copilot CLI: configured from global hook config', () => {
const tmpBase = join(tmpdir(), `doctor-copilot-${Date.now()}`);
const homeDir = join(tmpBase, 'home');
Expand Down
Loading
Loading